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.
|
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.
|
- [`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-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.
|
- [`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