Files
tripz-llc/dashboards/superadmin-web/index.html
T
Hamza-AyedandClaude Opus 4.8 c93be38622 feat: المجموعة N (طبقة الـSaaS) — تزويد المستأجر + مانيفست التطبيق + لوحة سوبر-أدمن
الهدف الأساسي: السوبر-أدمن ينشئ مستأجراً كاملاً (اسم/لوغو/دولة/دفع/باقة/
ميزات/bundle IDs) ويولّد تطبيقه.

الباك إند (خلف PlatformGuard):
- POST /admin/tenants/provision — تزويد بضربة واحدة
- GET /admin/tenants/:id/summary + /app-manifest (يستهلكه سكربت البناء)
- PATCH .../branding + .../payment-methods
- POST /admin/tenants/:id/logo (رفع) + GET /tenant/logo/:slug عام
  **يخدم أصل الهوية فقط لا مجلد التخزين كاملاً** — وإلا سُرّبت صور وثائق
  الهوية الحساسة بمفاتيحها
- كتالوج payment-methods.ts (cash/wallet/cliq/zaincash/paymob/mtn/
  syriatel/shamcash حسب الدولة) + تصفية المخترع
- GET /admin/features يرجع الميزات ووسائل الدفع للكتالوج

الواجهة: dashboards/superadmin-web/index.html — SPA مكتفية ذاتياً (vanilla
JS، بلا خطوة بناء)، سرّ المنصة في sessionStorage، RTL. تُخدَم كملف ثابت.

سكربت التوليد: scripts/generate-tenant-app.sh — يجلب المانيفست ويضبط
الهيكل؛ خطوات فلاتر موسومة [Q] تُوصَل عند بناء مشروع فلاتر.

اختبار: provision-test.mjs (يثبت الحارس + التزويد + تصفية الدفع +
الاستحقاقات + المانيفست). لا هجرة — settings/branding jsonb موجودان.

القرار: اللوحات ويب (لا فلاتر) — تحديث فوري، سطح مكتب، معيار الصناعة.
البدء بـN (طبقة الـSaaS) قرار المالك.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 03:39:02 +03:00

205 lines
10 KiB
HTML

<!doctype html>
<html lang="ar" dir="rtl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Tripz — لوحة السوبر-أدمن</title>
<!--
لوحة مالك المنصة (docs/22 — N1). SPA مكتفية ذاتياً بلا خطوة بناء:
Vanilla JS + fetch. تُخدَم كملف ثابت خلف نفس نطاق الـTLS (Nginx location).
المصادقة: سرّ المنصة (x-platform-secret) يُدخَل مرة ويُحفظ في sessionStorage.
كل الحماية على السيرفر (PlatformGuard) — هذه واجهة فقط.
-->
<style>
:root { --bg:#0f1420; --card:#1a2130; --line:#2a3346; --fg:#e6ebf5; --mut:#8b97ad; --acc:#4f8cff; --ok:#2ecc71; --bad:#e74c3c; }
* { box-sizing:border-box; }
body { margin:0; font-family:system-ui,'Segoe UI',Tahoma,sans-serif; background:var(--bg); color:var(--fg); }
header { padding:16px 24px; border-bottom:1px solid var(--line); display:flex; align-items:center; gap:12px; }
header h1 { font-size:18px; margin:0; }
.wrap { max-width:1100px; margin:0 auto; padding:24px; }
.card { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:20px; margin-bottom:20px; }
.card h2 { margin:0 0 16px; font-size:15px; color:var(--mut); font-weight:600; }
label { display:block; font-size:13px; color:var(--mut); margin:10px 0 4px; }
input, select { width:100%; padding:9px 11px; background:var(--bg); border:1px solid var(--line); border-radius:8px; color:var(--fg); font-size:14px; }
button { background:var(--acc); color:#fff; border:0; border-radius:8px; padding:10px 18px; font-size:14px; cursor:pointer; }
button.ghost { background:transparent; border:1px solid var(--line); color:var(--fg); }
button:disabled { opacity:.5; cursor:default; }
.row { display:grid; grid-template-columns:1fr 1fr; gap:14px; }
.chips { display:flex; flex-wrap:wrap; gap:8px; margin-top:6px; }
.chip { padding:6px 12px; border:1px solid var(--line); border-radius:20px; font-size:13px; cursor:pointer; user-select:none; }
.chip.on { background:var(--acc); border-color:var(--acc); }
table { width:100%; border-collapse:collapse; }
th, td { text-align:right; padding:10px; border-bottom:1px solid var(--line); font-size:14px; }
th { color:var(--mut); font-weight:600; font-size:12px; }
.badge { padding:2px 9px; border-radius:6px; font-size:12px; }
.badge.active { background:rgba(46,204,113,.15); color:var(--ok); }
.toast { position:fixed; bottom:20px; left:50%; transform:translateX(-50%); padding:12px 20px; border-radius:8px; font-size:14px; opacity:0; transition:opacity .2s; }
.toast.ok { background:var(--ok); } .toast.bad { background:var(--bad); } .toast.show { opacity:1; }
.muted { color:var(--mut); font-size:13px; }
dialog { background:var(--card); color:var(--fg); border:1px solid var(--line); border-radius:12px; padding:24px; max-width:460px; width:90%; }
dialog::backdrop { background:rgba(0,0,0,.6); }
</style>
</head>
<body>
<header>
<h1>🚕 Tripz — لوحة السوبر-أدمن</h1>
<span class="muted" id="apiLabel"></span>
<span style="flex:1"></span>
<button class="ghost" id="logoutBtn" style="display:none">تسجيل خروج</button>
</header>
<div class="wrap">
<!-- المستأجرون -->
<div class="card">
<h2>المستأجرون</h2>
<table>
<thead><tr><th>الاسم</th><th>slug</th><th>الدولة</th><th>الباقة</th><th>الحالة</th><th></th></tr></thead>
<tbody id="tenantRows"><tr><td colspan="6" class="muted">…</td></tr></tbody>
</table>
</div>
<!-- تزويد مستأجر جديد -->
<div class="card">
<h2>تزويد مستأجر جديد</h2>
<div class="row">
<div><label>الاسم</label><input id="pName" placeholder="Siro" /></div>
<div><label>slug (معرّف)</label><input id="pSlug" placeholder="siro" /></div>
</div>
<div class="row">
<div><label>الدولة</label>
<select id="pCountry"><option value="jo">الأردن</option><option value="sy">سوريا</option><option value="eg">مصر</option></select>
</div>
<div><label>الباقة</label>
<select id="pPlan"><option value="launch">انطلاقة</option><option value="brand">علامة</option><option value="fleet">أسطول+</option><option value="sovereign">سيادة</option></select>
</div>
</div>
<label>اسم التطبيق</label><input id="pAppName" placeholder="سيرو" />
<div class="row">
<div><label>bundle ID (أندرويد)</label><input id="pBidA" placeholder="com.siro.rider" /></div>
<div><label>bundle ID (iOS)</label><input id="pBidI" placeholder="com.siro.rider" /></div>
</div>
<label>وسائل الدفع</label><div class="chips" id="payChips"></div>
<label>الميزات المدفوعة المفعّلة</label><div class="chips" id="featChips"></div>
<div style="margin-top:18px"><button id="provisionBtn">تزويد المستأجر</button></div>
</div>
</div>
<div class="toast" id="toast"></div>
<!-- نافذة السرّ -->
<dialog id="authDlg">
<h2 style="margin-top:0">سرّ المنصة</h2>
<p class="muted">أدخل <code>PLATFORM_SECRET</code> للوصول. يُحفظ في هذه الجلسة فقط.</p>
<input id="secretInput" type="password" placeholder="platform secret" />
<div style="margin-top:16px; text-align:left"><button id="secretOk">دخول</button></div>
</dialog>
<script>
const API = (location.origin.includes('localhost') ? 'http://localhost:4010/api' : '/api');
document.getElementById('apiLabel').textContent = API;
let SECRET = sessionStorage.getItem('tripz_platform_secret') || '';
let CATALOG = { features: [], payment_methods: [] };
const selectedPay = new Set(), selectedFeat = new Set();
const toast = (msg, ok = true) => {
const t = document.getElementById('toast');
t.textContent = msg; t.className = `toast show ${ok ? 'ok' : 'bad'}`;
setTimeout(() => (t.className = 'toast'), 2600);
};
async function api(method, path, body) {
const res = await fetch(API + path, {
method,
headers: { 'Content-Type': 'application/json', 'x-platform-secret': SECRET },
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 401) { openAuth(); throw new Error('unauthorized'); }
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json.message || `HTTP ${res.status}`);
return json;
}
function openAuth() { document.getElementById('authDlg').showModal(); }
document.getElementById('secretOk').onclick = () => {
SECRET = document.getElementById('secretInput').value.trim();
sessionStorage.setItem('tripz_platform_secret', SECRET);
document.getElementById('authDlg').close();
boot();
};
document.getElementById('logoutBtn').onclick = () => {
sessionStorage.removeItem('tripz_platform_secret'); location.reload();
};
function renderChips(el, items, selected, labelKey = 'name_ar', codeKey = 'code') {
el.innerHTML = '';
for (const it of items) {
const code = typeof it === 'string' ? it : it[codeKey];
const label = typeof it === 'string' ? it : (it[labelKey] || code);
const chip = document.createElement('span');
chip.className = 'chip' + (selected.has(code) ? ' on' : '');
chip.textContent = label;
chip.onclick = () => {
selected.has(code) ? selected.delete(code) : selected.add(code);
chip.classList.toggle('on');
};
el.appendChild(chip);
}
}
async function loadTenants() {
const rows = document.getElementById('tenantRows');
try {
const tenants = await api('GET', '/admin/tenants');
rows.innerHTML = tenants.length ? '' : '<tr><td colspan="6" class="muted">لا مستأجرين بعد</td></tr>';
for (const t of tenants) {
const tr = document.createElement('tr');
tr.innerHTML = `<td>${t.name}</td><td class="muted">${t.slug}</td><td>${t.countryPack||t.country_pack||'-'}</td>
<td>${t.plan}</td><td><span class="badge active">${t.status}</span></td>
<td><button class="ghost" data-id="${t.id}">مانيفست</button></td>`;
tr.querySelector('button').onclick = async () => {
const m = await api('GET', `/admin/tenants/${t.id}/app-manifest`);
alert(JSON.stringify(m, null, 2));
};
rows.appendChild(tr);
}
} catch (e) { if (e.message !== 'unauthorized') rows.innerHTML = `<tr><td colspan="6" class="muted">${e.message}</td></tr>`; }
}
document.getElementById('provisionBtn').onclick = async () => {
const btn = document.getElementById('provisionBtn'); btn.disabled = true;
try {
const feats = {}; for (const f of selectedFeat) feats[f] = true;
await api('POST', '/admin/tenants/provision', {
name: document.getElementById('pName').value.trim(),
slug: document.getElementById('pSlug').value.trim(),
countryPack: document.getElementById('pCountry').value,
plan: document.getElementById('pPlan').value,
features: feats,
paymentMethods: [...selectedPay],
branding: {
app_name: document.getElementById('pAppName').value.trim(),
bundle_id_android: document.getElementById('pBidA').value.trim() || null,
bundle_id_ios: document.getElementById('pBidI').value.trim() || null,
},
});
toast('تمّ تزويد المستأجر ✅');
loadTenants();
} catch (e) { toast(e.message, false); }
finally { btn.disabled = false; }
};
async function boot() {
if (!SECRET) { openAuth(); return; }
document.getElementById('logoutBtn').style.display = '';
try {
CATALOG = await api('GET', '/admin/features');
renderChips(document.getElementById('payChips'), CATALOG.payment_methods || [], selectedPay);
renderChips(document.getElementById('featChips'),
(CATALOG.features || []).map((f) => ({ code: f, name_ar: f })), selectedFeat);
loadTenants();
} catch (e) { /* openAuth already fired on 401 */ }
}
boot();
</script>
</body>
</html>