Files
tripz-llc/dashboards/superadmin-web/index.html
T
Hamza-AyedandClaude Opus 5 75b80bd299 refactor(marketing): تفكيك الموقع إلى وحدات + توحيد الباقات الثلاث
- marketing: index.html من 2274 إلى 775 سطراً، والباقي إلى
  styles/ (tokens·base·components·sections·responsive) و
  js/ (app·i18n·calculator·analytics) و i18n/ بخمس لغات
  (ar·en·fr·es·tr) عبر سمة data-i18n.
- docs/05 + docs/33 + لوحة السوبر-أدمن: الباقات صارت ثلاثاً
  (إطلاق · نمو · سيادة) بدل الأربع، وتحديث docs/31 بما يطابقها.
- docs/36: توثيق سيرو إكسبرس للتوصيل.
- تنظيف استيرادات غير مستعملة في apps/rider و apps/driver.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 04:53:19 +03:00

279 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>النظرة الشاملة
<select id="ovDays" style="width:auto; display:inline-block; margin-right:10px">
<option value="7">٧ أيام</option><option value="30" selected>٣٠ يوماً</option><option value="90">٩٠ يوماً</option>
</select>
</h2>
<div id="ovTotals" class="muted">…</div>
</div>
<!-- المستأجرون -->
<div class="card">
<h2>المستأجرون</h2>
<table>
<thead><tr><th>الاسم</th><th>slug</th><th>الدولة</th><th>الباقة</th><th>الحالة</th><th>رحلات</th><th>GMV</th><th>عمولة الرحلات</th><th>الإيراد الكلي</th><th></th></tr></thead>
<tbody id="tenantRows"><tr><td colspan="10" 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="growth">نمو</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);
}
}
// اسم المستأجر يدخله بشر ويُحقن في innerHTML — بلا تهريب يصير اسمٌ فيه
// <script> ثغرة XSS في أخطر لوحة عندنا.
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const money = (n) => Number(n || 0).toLocaleString('en-US', { maximumFractionDigits: 2 });
async function loadTenants() {
const rows = document.getElementById('tenantRows');
const days = document.getElementById('ovDays').value;
try {
const ov = await api('GET', `/admin/overview?days=${days}`);
const t0 = ov.totals || {};
document.getElementById('ovTotals').innerHTML =
`مستأجرون: <b>${t0.tenants ?? 0}</b> (فعّال: ${t0.active_tenants ?? 0}) ·
رحلات: <b>${t0.trips ?? 0}</b> (مكتملة: ${t0.completed_trips ?? 0}) ·
GMV: <b>${money(t0.gmv)}</b> · عمولة رحلات: <b>${money(t0.trip_commission)}</b> · الإيراد الكلي: <b>${money(t0.revenue)}</b>`;
const tenants = ov.tenants || [];
rows.innerHTML = tenants.length ? '' : '<tr><td colspan="10" class="muted">لا مستأجرين بعد</td></tr>';
for (const t of tenants) {
const suspended = t.status !== 'active';
const tr = document.createElement('tr');
tr.innerHTML = `<td>${esc(t.name)}</td><td class="muted">${esc(t.slug)}</td><td>${esc(t.countryPack || '-')}</td>
<td>${esc(t.plan)}</td>
<td><span class="badge ${suspended ? '' : 'active'}">${esc(t.status)}</span></td>
<td>${t.completed_trips}/${t.trips}</td><td>${money(t.gmv)}</td>
<td class="muted">${money(t.trip_commission)}</td><td>${money(t.revenue)}</td>
<td><button class="ghost" data-act="manifest">مانيفست</button>
<button class="ghost" data-act="admin">تعيين أدمن</button>
<button class="ghost" data-act="logo">اللوغو</button>
<button class="ghost" data-act="status">${suspended ? 'تفعيل' : 'تعليق'}</button></td>`;
tr.querySelector('[data-act=manifest]').onclick = async () => {
const m = await api('GET', `/admin/tenants/${t.id}/app-manifest`);
alert(JSON.stringify(m, null, 2));
};
// أول أدمن للمستأجر: تُنشئ الحساب إن لم يوجد (docs/22 — N1d).
// بلا هذا لا يمكن الدخول إلى لوحة مستأجر مزوَّد حديثاً إطلاقاً.
tr.querySelector('[data-act=admin]').onclick = async () => {
const phone = prompt(`رقم هاتف أدمن «${t.name}» (بصيغة دولته: ${t.countryPack})`);
if (!phone) return;
try {
const r = await api('POST', '/platform/users/role', {
tenantSlug: t.slug, phone: phone.trim(), role: 'admin',
});
toast(r.created ? 'أُنشئ الحساب وعُيّن أدمن ✅' : 'صار أدمن ✅');
} catch (e) { toast(e.message, false); }
};
// رفع اللوغو (docs/22 — N2). لا نمرّ بـapi() لأن الرفع multipart
// لا JSON: ضبط Content-Type يدوياً يكسر حدّ الأجزاء (boundary).
tr.querySelector('[data-act=logo]').onclick = () => {
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = 'image/png,image/jpeg';
inp.onchange = async () => {
const f = inp.files?.[0]; if (!f) return;
const fd = new FormData(); fd.append('file', f);
try {
const res = await fetch(`${API}/admin/tenants/${t.id}/logo`, {
method: 'POST', headers: { 'x-platform-secret': SECRET }, body: fd,
});
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j.message || `HTTP ${res.status}`);
toast(`رُفع اللوغو ${j.logo?.width}×${j.logo?.height} ✅`);
} catch (e) { toast(e.message, false); }
};
inp.click();
};
tr.querySelector('[data-act=status]').onclick = async () => {
// التعليق يقطع الخدمة عن مستأجر حيّ فوراً — لذلك تأكيد صريح.
const next = suspended ? 'active' : 'suspended';
if (!suspended && !confirm(`تعليق «${t.name}»؟ سيتوقّف تطبيقه وسائقوه وركّابه فوراً.`)) return;
try {
await api('PATCH', `/admin/tenants/${t.id}/status`, { status: next });
toast(next === 'active' ? 'أُعيد التفعيل ✅' : 'عُلّق المستأجر ⛔');
loadTenants();
} catch (e) { toast(e.message, false); }
};
rows.appendChild(tr);
}
} catch (e) { if (e.message !== 'unauthorized') rows.innerHTML = `<tr><td colspan="10" class="muted">${esc(e.message)}</td></tr>`; }
}
document.getElementById('ovDays').onchange = loadTenants;
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>