feat: deliver Persian merchant analytics dashboard
This commit is contained in:
23
README.md
23
README.md
@@ -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.
|
||||
|
||||
460
src/lib/components/Dashboard.svelte
Normal file
460
src/lib/components/Dashboard.svelte
Normal 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}
|
||||
120
src/lib/components/DetailsPanel.svelte
Normal file
120
src/lib/components/DetailsPanel.svelte
Normal 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>
|
||||
98
src/lib/components/Login.svelte
Normal file
98
src/lib/components/Login.svelte
Normal 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>
|
||||
20
src/lib/components/MetricCard.svelte
Normal file
20
src/lib/components/MetricCard.svelte
Normal 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>
|
||||
51
src/lib/components/SectionFrame.svelte
Normal file
51
src/lib/components/SectionFrame.svelte
Normal 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>
|
||||
18
src/lib/components/ThemeToggle.svelte
Normal file
18
src/lib/components/ThemeToggle.svelte
Normal 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
35
src/routes/+page.svelte
Normal 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}
|
||||
Reference in New Issue
Block a user