feat: deliver Persian merchant analytics dashboard

This commit is contained in:
2026-08-20 19:47:33 +03:30
parent c719a11ad7
commit d8c7e7263c
8 changed files with 825 additions and 0 deletions

View File

@@ -4,6 +4,29 @@ Persian RTL merchant analytics panel built with SvelteKit 2, Svelte 5, strict Ty
Current state: design, specifications, implementation plan, and task list are approved. Phase 4 frontend implementation is authorized.
## Local development
The dashboard is a client-rendered static SvelteKit application. During development, Vite serves it at `http://127.0.0.1:5173` and proxies same-origin `/api` requests to the Go backend at `http://127.0.0.1:8080`. This preserves the backend-owned secure session model without storing credentials in browser storage.
```powershell
Copy-Item .env.example .env
npm ci
npm run dev -- --host 127.0.0.1
```
Open `http://127.0.0.1:5173`. The demo merchant credentials are created by the backend importer/bootstrap process; they are intentionally not stored in this repository.
Quality commands:
```powershell
npm run generate:api
npm run check
npm test
npm run build
```
`PUBLIC_API_BASE_URL` is public routing configuration, not a secret. Keep its default `/api/v1` for the local Vite proxy and the production same-origin Nginx proxy.
- [`DESIGN.md`](DESIGN.md): visual language, interaction rules, responsive behavior, and current design-system state.
- [`docs/DASHBOARD-SPEC.md`](docs/DASHBOARD-SPEC.md): approved product requirements and acceptance criteria for implementation planning.
- [`docs/DASHBOARD-UX-SPEC.md`](docs/DASHBOARD-UX-SPEC.md): detailed screen map, RTL layouts, async states, data presentation, accessibility, and authorized export workflows.

View File

@@ -0,0 +1,460 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
ApiError,
api,
type AnalyticsRun,
type AuthSession,
type DetailView,
type ReportingPeriod,
} from '$lib/api/client';
import { formatDate, formatMilliseconds, formatMoney, formatPercent, formatValue } from '$lib/format';
import DetailsPanel from './DetailsPanel.svelte';
import MetricCard from './MetricCard.svelte';
import SectionFrame from './SectionFrame.svelte';
import ThemeToggle from './ThemeToggle.svelte';
let { session, onLogout }: { session: AuthSession; onLogout: () => void } = $props();
let periods = $state<ReportingPeriod[]>([]);
let periodId = $state('');
let run = $state<AnalyticsRun | null>(null);
let busy = $state(true);
let error = $state('');
let menuOpen = $state(false);
let details = $state<{ view: DetailView; title: string } | null>(null);
let polling: ReturnType<typeof setTimeout> | undefined;
let eventStream: EventSource | undefined;
const navigation = [
{ id: 'overview', label: 'پیشخوان', mark: '⌂' },
{ id: 'payments', label: 'عملکرد پرداخت', mark: '↗' },
{ id: 'fees', label: 'کارمزد و درآمد', mark: '٪' },
{ id: 'peers', label: 'مقایسه با هم‌گروه‌ها', mark: '◉' },
];
async function loadPeriods() {
busy = true;
error = '';
try {
const result = await api.periods();
periods = result.periods;
periodId = result.defaultPeriodId;
await startRun();
} catch (cause) {
error =
cause instanceof ApiError && cause.status === 429
? 'درخواست‌ها موقتاً محدود شده‌اند. کمی بعد دوباره تلاش کنید.'
: 'داشبورد در حال حاضر در دسترس نیست.';
} finally {
busy = false;
}
}
async function startRun() {
if (!periodId) return;
if (polling) clearTimeout(polling);
busy = true;
error = '';
try {
run = await api.startRun(periodId, session.csrfToken);
connectStream();
} catch (cause) {
if (cause instanceof ApiError && cause.status === 401) return onLogout();
error = 'شروع محاسبه گزارش ممکن نشد. دوباره تلاش کنید.';
} finally {
busy = false;
}
}
function schedulePoll() {
if (!run || run.state !== 'IN_PROGRESS') return;
polling = setTimeout(async () => {
if (!run) return;
try {
run = await api.run(run.runId);
schedulePoll();
} catch (cause) {
if (cause instanceof ApiError && cause.status === 401) onLogout();
else schedulePoll();
}
}, 1500);
}
function connectStream() {
eventStream?.close();
if (!run || run.state !== 'IN_PROGRESS') return;
const source = new EventSource(run.eventsUrl, { withCredentials: true });
eventStream = source;
const eventNames = [
'run.snapshot',
'section.ready',
'section.failed',
'section.suppressed',
'run.completed',
];
const update = (event: MessageEvent<string>) => {
try {
const payload = JSON.parse(event.data) as {
run?: AnalyticsRun;
sectionId?: string;
section?: unknown;
};
if (payload.run) {
run = payload.run;
if (run.state !== 'IN_PROGRESS') source.close();
return;
}
if (!run || !payload.sectionId || !payload.section) return;
const sectionKeys: Record<string, keyof AnalyticsRun['sections']> = {
overview: 'overview',
payment_performance: 'paymentPerformance',
fees: 'fees',
peer_comparison: 'peerComparison',
};
const key = sectionKeys[payload.sectionId];
if (key)
run = { ...run, sections: { ...run.sections, [key]: payload.section } as AnalyticsRun['sections'] };
} catch {
/* A malformed event is ignored; the snapshot fallback remains authoritative. */
}
};
for (const name of eventNames) source.addEventListener(name, update as EventListener);
source.onerror = () => {
source.close();
if (eventStream === source) schedulePoll();
};
}
async function retry(sectionId: string) {
if (!run) return;
try {
run = await api.retry(run.runId, sectionId, session.csrfToken);
connectStream();
} catch {
error = 'تلاش دوباره ممکن نشد. کمی بعد امتحان کنید.';
}
}
function jump(id: string) {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' });
menuOpen = false;
}
async function logout() {
try {
await api.logout(session.csrfToken);
} finally {
onLogout();
}
}
onMount(() => {
loadPeriods();
return () => {
if (polling) clearTimeout(polling);
eventStream?.close();
};
});
</script>
<svelte:head><title>پیشخوان | تحلیل عملکرد زرین‌پال</title></svelte:head>
<div class="min-h-screen lg:pr-64">
{#if menuOpen}<button
class="fixed inset-0 z-30 bg-black/40 lg:hidden"
aria-label="بستن فهرست"
onclick={() => (menuOpen = false)}
></button>{/if}
<aside
class="surface fixed inset-y-0 right-0 z-40 flex w-64 flex-col border-y-0 border-r-0 p-4 transition-transform duration-200"
class:translate-x-full={!menuOpen}
class:lg:translate-x-0={!menuOpen}
>
<div class="mb-8 flex items-center gap-3 px-2 pt-2">
<span class="grid size-10 place-items-center rounded-xl bg-[#ffd400] font-extrabold text-[#19191a]"
>ز</span
>
<div>
<p class="font-bold">تحلیل زرین‌پال</p>
<p class="muted text-xs">پنل پذیرنده</p>
</div>
</div>
<nav aria-label="ناوبری اصلی" class="space-y-1">
{#each navigation as item}
<button
class="flex min-h-11 w-full items-center gap-3 rounded-xl px-3 text-right font-medium hover:bg-[var(--surface-muted)]"
onclick={() => jump(item.id)}
>
<span
class="grid size-8 place-items-center rounded-lg bg-[var(--surface-muted)] text-[var(--brand)]"
aria-hidden="true">{item.mark}</span
>{item.label}
</button>
{/each}
<button
class="flex min-h-11 w-full items-center gap-3 rounded-xl px-3 text-right font-medium hover:bg-[var(--surface-muted)]"
onclick={() => jump('methodology')}
><span class="grid size-8 place-items-center rounded-lg bg-[var(--surface-muted)] text-[var(--brand)]"
>؟</span
>درباره شاخص‌ها</button
>
</nav>
<div class="mt-auto rounded-xl bg-[var(--surface-muted)] p-3">
<p class="truncate font-semibold">{session.merchant.displayName}</p>
<p class="ltr muted truncate text-left text-xs">{session.user.username}</p>
<button
class="mt-3 min-h-11 w-full rounded-lg text-sm font-semibold text-[var(--danger)] hover:bg-[var(--danger)]/10"
onclick={logout}>خروج از حساب</button
>
</div>
</aside>
<header
class="sticky top-0 z-20 flex min-h-16 items-center justify-between border-b border-[var(--border)] bg-[var(--canvas)]/90 px-4 backdrop-blur sm:px-7"
>
<div class="flex items-center gap-3">
<button class="button-secondary lg:hidden" aria-label="بازکردن فهرست" onclick={() => (menuOpen = true)}
>☰</button
>
<div>
<p class="font-bold">سلام، {session.merchant.displayName}</p>
<p class="muted hidden text-xs sm:block">آخرین تصویر کامل داده‌های واردشده</p>
</div>
</div>
<ThemeToggle />
</header>
<main class="mx-auto max-w-[1500px] space-y-10 p-4 pb-16 sm:p-7 lg:p-8">
<section id="overview" class="scroll-mt-24">
<div class="mb-6 flex flex-wrap items-end justify-between gap-4">
<div>
<p class="mb-2 text-sm font-semibold text-[var(--brand)]">نمای کلی کسب‌وکار</p>
<h1 class="text-2xl font-extrabold sm:text-3xl">عملکرد پرداخت‌های شما</h1>
<p class="muted mt-2">شاخص‌های کلیدی، دقیق و قابل پیگیری در یک نگاه</p>
</div>
<div>
<label class="mb-1 block text-xs font-semibold" for="period">بازه گزارش</label><select
id="period"
class="surface min-h-11 min-w-48 rounded-[10px] px-3"
bind:value={periodId}
onchange={startRun}
disabled={busy}
>{#each periods as period}<option value={period.periodId}>{period.jalaliLabelFa}</option
>{/each}</select
>
</div>
</div>
{#if error}<div
class="mb-5 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[var(--danger)]/40 bg-[var(--danger)]/10 p-4"
role="alert"
>
<span>{error}</span><button class="button-secondary" onclick={loadPeriods}>تلاش دوباره</button>
</div>{/if}
{#if !run}
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4" role="status">
{#each Array(4) as _}<div class="skeleton h-40 rounded-[14px]"></div>{/each}
</div>
{:else}
{@const section = run.sections.overview}
<SectionFrame
title="خلاصه عملکرد"
description={`${run.period.jalaliLabelFa} · منطقه زمانی تهران`}
state={section.state}
onRetry={() => retry('overview')}
>
{#if section.state === 'READY'}
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<MetricCard
label="مبلغ پرداخت‌شده بانکی"
value={formatMoney(section.data.bankPaidAmountRial.value)}
note="Paid و Verified، یک‌بار در سطح نشست"
accent
/>
<MetricCard
label="نشست‌های پرداخت‌شده"
value={formatValue(section.data.bankPaidSessions.value)}
note="تعداد نشست، نه تعداد تلاش"
/>
<MetricCard
label="نرخ موفقیت پرداخت"
value={formatPercent(section.data.paymentSuccessRate.value)}
note={`${formatValue(section.data.paymentSuccessRate.numerator)} از ${formatValue(section.data.paymentSuccessRate.denominator)} نشست`}
/>
<MetricCard
label="تکمیل تأیید"
value={formatPercent(section.data.verificationCompletionRate.value)}
note="سهم پرداخت‌های نهایی‌شده"
/>
</div>
{/if}
</SectionFrame>
{/if}
</section>
{#if run}
{@const payment = run.sections.paymentPerformance}
<div id="payments">
<SectionFrame
title="عملکرد پرداخت"
description="نتیجه نهایی، تلاش مجدد و زمان پردازش"
state={payment.state}
onRetry={() => retry('payment_performance')}
>
{#if payment.state === 'READY'}
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<MetricCard
label="تأییدشده"
value={formatValue(payment.data.verifiedSessions.value)}
note="پرداخت نهایی‌شده"
/>
<button
class="text-right"
onclick={() => (details = { view: 'UNVERIFIED_PAID', title: 'پرداخت‌شده‌های تأییدنشده' })}
><MetricCard
label="پرداخت‌شده، تأییدنشده"
value={formatValue(payment.data.unverifiedPaidSessions.value)}
note="برای دیدن جزئیات انتخاب کنید"
/></button
>
<button
class="text-right"
onclick={() => (details = { view: 'FAILED', title: 'نشست‌های ناموفق' })}
><MetricCard
label="ناموفق"
value={formatValue(payment.data.failedSessions.value)}
note="مرحله مشاهده‌شده، نه علت قطعی"
/></button
>
<button
class="text-right"
onclick={() => (details = { view: 'RETRIED', title: 'نشست‌های دارای تلاش مجدد' })}
><MetricCard
label="دارای تلاش مجدد"
value={formatValue(payment.data.retriedSessions.value)}
note={`نرخ بازیابی ${formatPercent(payment.data.retryRecoveryRate.value)}`}
/></button
>
</div>
<div class="card mt-4 grid gap-5 p-5 md:grid-cols-2">
<div>
<h3 class="font-bold">زمان آغاز پرداخت</h3>
<div class="mt-3 flex justify-between rounded-xl bg-[var(--surface-muted)] p-4">
<span>میانه</span><b>{formatMilliseconds(payment.data.initTiming.p50Ms)}</b>
</div>
<div class="mt-2 flex justify-between px-4">
<span class="muted">صدک ۹۵</span><span
>{formatMilliseconds(payment.data.initTiming.p95Ms)}</span
>
</div>
</div>
<div>
<h3 class="font-bold">زمان تأیید</h3>
<div class="mt-3 flex justify-between rounded-xl bg-[var(--surface-muted)] p-4">
<span>میانه</span><b>{formatMilliseconds(payment.data.verifyTiming.p50Ms)}</b>
</div>
<div class="mt-2 flex justify-between px-4">
<span class="muted">صدک ۹۵</span><span
>{formatMilliseconds(payment.data.verifyTiming.p95Ms)}</span
>
</div>
</div>
</div>
{/if}
</SectionFrame>
</div>
{@const fees = run.sections.fees}
<div id="fees">
<SectionFrame
title="کارمزد و درآمد"
description="کارمزد واقعی پذیرنده، محاسبه‌شده یک‌بار برای هر نشست"
state={fees.state}
onRetry={() => retry('fees')}
>
{#if fees.state === 'READY'}<div class="grid gap-4 sm:grid-cols-3">
<MetricCard
label="مجموع کارمزد پرداخت‌شده"
value={formatMoney(fees.data.paidFeeAmountRial.value)}
accent
/><MetricCard
label="مبلغ پرداخت‌شده بانکی"
value={formatMoney(fees.data.bankPaidAmountRial.value)}
/><MetricCard
label="نرخ کارمزد"
value={formatPercent(fees.data.paidFeeRate.value)}
note="کارمزد ÷ مبلغ پرداخت‌شده"
/>
</div>{/if}
</SectionFrame>
</div>
{@const peers = run.sections.peerComparison}
<div id="peers">
<SectionFrame
title="مقایسه با هم‌گروه‌ها"
description="میانه هم‌گروه‌های واجد شرایط؛ هر پذیرنده یک وزن برابر دارد"
state={peers.state}
onRetry={() => retry('peer_comparison')}
>
{#if peers.state === 'SUPPRESSED'}<div class="card p-7 text-center">
<p class="text-lg font-bold">برای مقایسه امن، داده کافی نیست</p>
<p class="muted mt-2">
تا رسیدن گروه به حداقل حریم خصوصی، هیچ مقدار یا هویت هم‌گروهی نمایش داده نمی‌شود.
</p>
</div>
{:else if peers.state === 'READY'}<div class="card overflow-hidden">
<div
class="grid grid-cols-[1fr_auto_auto] gap-3 bg-[var(--surface-muted)] p-4 text-sm font-semibold"
>
<span>شاخص</span><span>میانه گروه</span><span>صدک عملکرد شما</span>
</div>
{#each peers.data.metrics as metric}<div
class="grid grid-cols-[1fr_auto_auto] items-center gap-3 border-t border-[var(--border)] p-4 text-sm"
>
<span class="ltr text-left">{metric.metricId}</span><span
>{formatPercent(metric.cohortMedian)}</span
><strong class="text-[var(--brand)]"
>{formatPercent(metric.merchantPerformancePercentile)}</strong
>
</div>{/each}
<p class="muted border-t border-[var(--border)] p-4 text-xs">
صدک بالاتر همیشه به‌معنای عملکرد بهتر است؛ شاخص‌های زمان و کارمزد جهت معکوس دارند.
</p>
</div>{/if}
</SectionFrame>
</div>
<section id="methodology" class="card p-6 sm:p-8">
<h2 class="text-xl font-bold">کیفیت داده و تعریف شاخص‌ها</h2>
<div class="mt-5 grid gap-5 md:grid-cols-3">
<div>
<h3 class="font-semibold">مبنای گزارش</h3>
<p class="muted mt-1 text-sm">
تصویر ثابت داده واردشده با شناسه <span class="ltr">{run.datasetSnapshotId}</span>
</p>
</div>
<div>
<h3 class="font-semibold">نسخه تعریف</h3>
<p class="muted mt-1 text-sm">
<span class="ltr">{run.metricDefinitionVersion}</span> · محاسبه در سطح نشست پرداخت
</p>
</div>
<div>
<h3 class="font-semibold">آخرین محاسبه</h3>
<p class="muted mt-1 text-sm">{formatDate(run.updatedAt)}</p>
</div>
</div>
</section>
{/if}
</main>
</div>
{#if details && run}<DetailsPanel
period={run.period}
view={details.view}
title={details.title}
onClose={() => (details = null)}
/>{/if}

View File

@@ -0,0 +1,120 @@
<script lang="ts">
import {
api,
type DetailView,
type FailureStage,
type ReportingPeriod,
type SessionDetailPage,
} from '$lib/api/client';
import { formatDate, formatMoney, formatValue } from '$lib/format';
let {
period,
view,
title,
onClose,
}: { period: ReportingPeriod; view: DetailView; title: string; onClose: () => void } = $props();
let page = $state<SessionDetailPage | null>(null);
let loading = $state(true);
let error = $state('');
let failureStage = $state<FailureStage | undefined>(undefined);
async function load(cursor?: string) {
loading = true;
error = '';
try {
page = await api.details(period.periodId, view, cursor, failureStage);
} catch {
error = 'جزئیات در حال حاضر در دسترس نیست.';
} finally {
loading = false;
}
}
function changeStage(event: Event) {
const value = (event.currentTarget as HTMLSelectElement).value;
failureStage = value ? (value as FailureStage) : undefined;
load();
}
$effect(() => {
load();
});
</script>
<div
class="fixed inset-0 z-50 bg-black/45 p-0 sm:p-6"
role="presentation"
onclick={(event) => event.currentTarget === event.target && onClose()}
>
<div
class="surface mx-auto flex h-full max-w-6xl flex-col overflow-hidden sm:h-[calc(100vh-3rem)] sm:rounded-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="details-title"
>
<header
class="flex flex-wrap items-center justify-between gap-3 border-b border-[var(--border)] p-4 sm:p-6"
>
<div>
<h2 id="details-title" class="text-xl font-bold">{title}</h2>
<p class="muted text-sm">{period.jalaliLabelFa} · مبالغ به ریال</p>
</div>
<button class="button-secondary" onclick={onClose} aria-label="بستن جزئیات">بستن</button>
</header>
{#if view === 'FAILED'}
<div class="border-b border-[var(--border)] p-4 sm:px-6">
<label for="stage" class="ml-3 font-semibold">مرحله مشاهده‌شده</label>
<select id="stage" class="surface min-h-11 rounded-lg px-3" onchange={changeStage}>
<option value="">همه مراحل</option><option value="NO_ATTEMPT">بدون تلاش بانکی</option><option
value="REACHED_BANK">رسیده به بانک</option
><option value="OTHER_OBSERVED">سایر مراحل مشاهده‌شده</option>
</select>
</div>
{/if}
<div class="flex-1 overflow-auto p-4 sm:p-6">
{#if loading}<p role="status">در حال دریافت جزئیات…</p>
{:else if error}<p role="alert" class="text-[var(--danger)]">{error}</p>
{:else if page && page.items.length === 0}<p
class="muted rounded-xl bg-[var(--surface-muted)] p-8 text-center"
>
در این بازه موردی پیدا نشد.
</p>
{:else if page}
<div class="overflow-x-auto">
<table class="w-full min-w-[820px] border-collapse text-right text-sm">
<caption class="sr-only">نشست‌های پرداخت پذیرنده</caption>
<thead class="bg-[var(--surface-muted)]"
><tr
><th class="p-3">شناسه نشست</th><th class="p-3">وضعیت</th><th class="p-3">مبلغ</th><th
class="p-3">کارمزد</th
><th class="p-3">تلاش</th><th class="p-3">زمان ایجاد</th></tr
></thead
>
<tbody
>{#each page.items as item}<tr class="border-b border-[var(--border)]"
><td class="ltr p-3 text-left font-mono text-xs">{item.sessionId}</td><td class="p-3"
>{item.sessionStatus}</td
><td class="p-3 whitespace-nowrap">{formatMoney(item.amountRial)}</td><td
class="p-3 whitespace-nowrap">{formatMoney(item.feeAmountRial)}</td
><td class="p-3">{formatValue(item.attemptCount)}</td><td class="p-3 whitespace-nowrap"
>{formatDate(item.createdAt)}</td
></tr
>{/each}</tbody
>
</table>
</div>
<div class="mt-5 flex flex-wrap gap-3">
<a
class="button-primary inline-flex items-center"
href={api.exportUrl(period.periodId, view, failureStage)}>دریافت CSV</a
>
{#if page.pageInfo.hasNextPage && page.pageInfo.nextCursor}<button
class="button-secondary"
onclick={() => load(page?.pageInfo.nextCursor ?? undefined)}>صفحه بعد</button
>{/if}
</div>
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,98 @@
<script lang="ts">
import { ApiError, api, type AuthSession } from '$lib/api/client';
let { onAuthenticated }: { onAuthenticated: (session: AuthSession) => void } = $props();
let username = $state('merchant-0001');
let password = $state('');
let loading = $state(false);
let errorMessage = $state('');
async function submit(event: SubmitEvent) {
event.preventDefault();
errorMessage = '';
loading = true;
try {
onAuthenticated(await api.login(username.trim(), password));
} catch (error) {
errorMessage =
error instanceof ApiError && error.status === 429
? 'تعداد تلاش‌ها بیش از حد مجاز بود. کمی بعد دوباره امتحان کنید.'
: 'نام کاربری یا رمز عبور درست نیست.';
} finally {
loading = false;
}
}
</script>
<svelte:head><title>ورود | تحلیل عملکرد زرین‌پال</title></svelte:head>
<main class="grid min-h-screen lg:grid-cols-[1.08fr_.92fr]">
<section
class="relative hidden overflow-hidden bg-[#19191a] p-14 text-white lg:flex lg:flex-col lg:justify-between"
>
<div class="absolute -top-20 -left-20 size-80 rounded-full bg-[#0a33ff] opacity-30 blur-3xl"></div>
<div class="relative flex items-center gap-3 text-xl font-bold">
<span class="grid size-11 place-items-center rounded-xl bg-[#ffd400] text-[#19191a]">ز</span>
تحلیل عملکرد زرین‌پال
</div>
<div class="relative max-w-xl">
<p class="mb-5 text-sm font-semibold text-[#ffd400]">داشبورد اختصاصی پذیرندگان</p>
<h1 class="text-4xl leading-[1.55] font-extrabold">تصمیم‌های بهتر، با تصویری روشن از پرداخت‌ها</h1>
<p class="mt-6 max-w-lg text-base leading-8 text-white/70">
موفقیت پرداخت، تلاش‌های مجدد، کارمزد واقعی و جایگاه شما در میان کسب‌وکارهای هم‌گروه را یک‌جا ببینید.
</p>
</div>
<p class="relative text-sm text-white/50">داده‌ها در محدوده حساب پذیرنده شما نمایش داده می‌شوند.</p>
</section>
<section class="flex min-h-screen items-center justify-center p-5 sm:p-10">
<div class="w-full max-w-md">
<div class="mb-10 flex items-center gap-3 lg:hidden">
<span class="grid size-11 place-items-center rounded-xl bg-[#ffd400] font-bold text-[#19191a]">ز</span
>
<span class="text-lg font-bold">تحلیل عملکرد زرین‌پال</span>
</div>
<p class="mb-2 text-sm font-semibold text-[var(--brand)]">خوش آمدید</p>
<h1 class="text-3xl font-extrabold">ورود به پنل پذیرنده</h1>
<p class="muted mt-3">برای مشاهده گزارش‌های کسب‌وکار، وارد حساب آزمایشی خود شوید.</p>
<form class="mt-9 space-y-5" onsubmit={submit}>
<div>
<label class="mb-2 block font-semibold" for="username">نام کاربری</label>
<input
id="username"
class="surface ltr min-h-12 w-full rounded-[10px] px-4 text-left"
autocomplete="username"
maxlength="64"
required
bind:value={username}
/>
</div>
<div>
<label class="mb-2 block font-semibold" for="password">رمز عبور</label>
<input
id="password"
class="surface ltr min-h-12 w-full rounded-[10px] px-4 text-left"
type="password"
autocomplete="current-password"
minlength="12"
required
bind:value={password}
/>
</div>
{#if errorMessage}
<p
class="rounded-xl border border-[var(--danger)]/30 bg-[var(--danger)]/10 p-3 text-sm text-[var(--danger)]"
role="alert"
>
{errorMessage}
</p>
{/if}
<button class="button-primary w-full" disabled={loading} type="submit">
{loading ? 'در حال ورود…' : 'ورود به داشبورد'}
</button>
</form>
<p class="muted mt-7 text-center text-xs">این نسخه برای ارزیابی داده‌های آزمایشی ساخته شده است.</p>
</div>
</section>
</main>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
let {
label,
value,
note = '',
accent = false,
}: { label: string; value: string; note?: string; accent?: boolean } = $props();
</script>
<article class="card min-h-40 p-5" class:border-[var(--brand)]={accent}>
<div class="mb-5 flex items-start justify-between gap-3">
<h3 class="muted text-sm font-medium">{label}</h3>
<span
class="grid size-8 shrink-0 place-items-center rounded-lg bg-[var(--surface-muted)] text-[var(--brand)]"
aria-hidden="true">●</span
>
</div>
<p class="text-2xl leading-tight font-bold tabular-nums sm:text-[28px]">{value}</p>
{#if note}<p class="muted mt-3 text-xs">{note}</p>{/if}
</article>

View File

@@ -0,0 +1,51 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
title,
description,
state,
onRetry,
children,
}: {
title: string;
description: string;
state: string;
onRetry?: () => void;
children: Snippet;
} = $props();
</script>
<section class="scroll-mt-6" aria-labelledby={`section-${title}`}>
<header class="mb-4 flex flex-wrap items-end justify-between gap-3">
<div>
<h2 id={`section-${title}`} class="text-xl font-bold sm:text-2xl">{title}</h2>
<p class="muted mt-1 text-sm">{description}</p>
</div>
{#if state === 'READY'}
<span class="rounded-full bg-[var(--success)]/10 px-3 py-1 text-xs font-semibold text-[var(--success)]"
>✓ آماده</span
>
{:else if state === 'SUPPRESSED'}
<span class="rounded-full bg-[var(--warning)]/10 px-3 py-1 text-xs font-semibold text-[var(--warning)]"
>اطلاعات ناکافی</span
>
{/if}
</header>
{#if state === 'FAILED'}
<div class="card border-[var(--danger)]/40 p-6" role="alert">
<h3 class="font-bold text-[var(--danger)]">محاسبه این بخش کامل نشد</h3>
<p class="muted mt-1 text-sm">
بخش‌های آماده همچنان در دسترس‌اند. می‌توانید فقط همین بخش را دوباره محاسبه کنید.
</p>
{#if onRetry}<button class="button-secondary mt-4" onclick={onRetry}>تلاش دوباره</button>{/if}
</div>
{:else if state === 'QUEUED' || state === 'CALCULATING'}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4" role="status" aria-label="در حال محاسبه">
{#each Array(4) as _}<div class="skeleton h-40 rounded-[14px]"></div>{/each}
</div>
{:else}
{@render children()}
{/if}
</section>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
let dark = $state(document.documentElement.dataset.theme === 'dark');
function toggle() {
dark = !dark;
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
localStorage.setItem('zpa-theme', dark ? 'dark' : 'light');
}
</script>
<button
class="button-secondary grid min-w-11 place-items-center px-3"
onclick={toggle}
aria-label={dark ? 'فعال‌کردن نمای روشن' : 'فعال‌کردن نمای تیره'}
title={dark ? 'نمای روشن' : 'نمای تیره'}
>
<span aria-hidden="true">{dark ? '☀' : '◐'}</span>
</button>

35
src/routes/+page.svelte Normal file
View File

@@ -0,0 +1,35 @@
<script lang="ts">
import { onMount } from 'svelte';
import { ApiError, api, type AuthSession } from '$lib/api/client';
import Dashboard from '$lib/components/Dashboard.svelte';
import Login from '$lib/components/Login.svelte';
let session = $state<AuthSession | null>(null);
let checking = $state(true);
onMount(async () => {
try {
session = await api.session();
} catch (error) {
if (!(error instanceof ApiError && error.status === 401)) console.warn('Session check unavailable');
} finally {
checking = false;
}
});
</script>
{#if checking}
<main class="grid min-h-screen place-items-center" role="status">
<div class="text-center">
<span
class="mx-auto mb-4 block size-11 animate-spin rounded-full border-4 border-[var(--border)] border-t-[var(--brand)]"
></span>
<p>در حال آماده‌سازی پنل…</p>
</div>
</main>
{:else if session}
<Dashboard {session} onLogout={() => (session = null)} />
{:else}
<Login onAuthenticated={(value) => (session = value)} />
{/if}