feat: redesign routed ZarinPal merchant dashboard

This commit is contained in:
2026-08-20 21:41:18 +03:30
parent d8eeff57cd
commit 9ce2e6ec83
183 changed files with 7991 additions and 1569 deletions

View File

@@ -0,0 +1,220 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
import FilterIcon from '@lucide/svelte/icons/list-filter';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import { api, type SessionDetailPage, type SessionStatus } from '$lib/api/client';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import * as Table from '$lib/components/ui/table';
import { useDashboardContext } from '$lib/dashboard-context.svelte';
import { parseDashboardFilters, setDashboardQuery } from '$lib/dashboard-filters';
import { formatDate, formatMoney, formatValue } from '$lib/format';
import PageHeader from './PageHeader.svelte';
const dashboard = useDashboardContext();
const terminalIds = $derived(dashboard.terminals.map((terminal) => terminal.terminalId));
const filters = $derived(parseDashboardFilters(page.url.searchParams, terminalIds));
let result = $state<SessionDetailPage | null>(null);
let loading = $state(true);
let error = $state('');
let cursor = $state<string | undefined>();
let minAmount = $state('');
let maxAmount = $state('');
let sequence = 0;
const statusLabels: Record<SessionStatus, string> = {
Paid: 'پرداخت‌شده',
Verified: 'تأییدشده',
Failed: 'ناموفق',
Reversed: 'برگشت‌خورده',
};
function badgeVariant(status: SessionStatus) {
return status === 'Verified' || status === 'Paid'
? 'secondary'
: status === 'Failed'
? 'destructive'
: 'outline';
}
async function fetchRows(reset = false) {
const current = ++sequence;
if (reset) cursor = undefined;
loading = true;
error = '';
const period = dashboard.periodFor(filters.range);
if (!period) return;
try {
const next = await api.details(period.periodId, 'ALL', {
cursor,
terminalId: filters.terminal === 'all' ? undefined : filters.terminal,
status: filters.status === 'all' ? undefined : filters.status,
minAmountRial: filters.minAmount ?? undefined,
maxAmountRial: filters.maxAmount ?? undefined,
limit: 25,
});
if (current === sequence) result = next;
} catch {
if (current === sequence) error = 'فهرست تراکنش‌ها دریافت نشد.';
} finally {
if (current === sequence) loading = false;
}
}
function updateQuery(updates: Record<string, string | null>) {
const next = setDashboardQuery(page.url, updates);
void goto(`${next.pathname}${next.search}`, { keepFocus: true, noScroll: true });
}
function applyAmount(event: SubmitEvent) {
event.preventDefault();
updateQuery({ minAmount: minAmount || null, maxAmount: maxAmount || null });
}
function openRow(sessionId: string) {
void goto(`/transactions/${encodeURIComponent(sessionId)}${page.url.search}`);
}
async function nextPage() {
if (!result?.pageInfo.nextCursor) return;
cursor = result.pageInfo.nextCursor;
await fetchRows();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
$effect(() => {
filters.range;
filters.terminal;
filters.status;
filters.minAmount;
filters.maxAmount;
minAmount = filters.minAmount?.toString() ?? '';
maxAmount = filters.maxAmount?.toString() ?? '';
void fetchRows(true);
});
</script>
<svelte:head><title>تراکنش‌ها | تحلیل زرین‌پال</title></svelte:head>
<PageHeader
title="تراکنش‌ها"
description="جزئیات و تاریخچه پرداخت‌ها با فیلترهای قابل اشتراک"
icon={ArrowUpDownIcon}
/>
<Card class="rounded-lg shadow-xs">
<CardContent class="p-0">
<div class="flex flex-wrap items-end gap-2 border-b p-4">
<div class="me-1 flex items-center gap-2 self-center text-sm font-semibold">
<FilterIcon class="text-primary size-4" /> فیلترها
</div>
<label class="text-muted-foreground text-xs"
>وضعیت
<select
value={filters.status}
onchange={(event) => updateQuery({ status: event.currentTarget.value })}
class="border-input bg-background text-foreground mt-1 block h-9 rounded-md border px-3 text-sm"
>
<option value="all">همه وضعیت‌ها</option><option value="Verified">تأییدشده</option><option
value="Paid">پرداخت‌شده</option
><option value="Failed">ناموفق</option><option value="Reversed">برگشت‌خورده</option>
</select>
</label>
<form class="flex flex-wrap items-end gap-2" onsubmit={applyAmount}>
<label class="text-muted-foreground text-xs"
>حداقل مبلغ (ریال)<Input
class="mt-1 h-9 w-36"
type="number"
min="0"
bind:value={minAmount}
/></label
>
<label class="text-muted-foreground text-xs"
>حداکثر مبلغ (ریال)<Input
class="mt-1 h-9 w-36"
type="number"
min="0"
bind:value={maxAmount}
/></label
>
<Button type="submit" size="sm" class="h-9">اعمال مبلغ</Button>
{#if filters.status !== 'all' || filters.minAmount !== null || filters.maxAmount !== null}
<Button
type="button"
variant="ghost"
size="sm"
class="h-9"
onclick={() => updateQuery({ status: null, minAmount: null, maxAmount: null })}
><RotateCcwIcon /> پاک‌کردن</Button
>
{/if}
</form>
</div>
{#if loading}
<div class="text-muted-foreground grid min-h-72 place-items-center text-sm" role="status">
در حال دریافت تراکنش‌ها…
</div>
{:else if error}
<div class="grid min-h-72 place-items-center text-sm" role="alert">{error}</div>
{:else if result && result.items.length}
<div class="overflow-x-auto">
<Table.Root>
<Table.Header
><Table.Row
><Table.Head>شناسه پرداخت</Table.Head><Table.Head>درگاه</Table.Head><Table.Head>زمان</Table.Head
><Table.Head>مبلغ</Table.Head><Table.Head>تلاش‌ها</Table.Head><Table.Head>وضعیت</Table.Head
><Table.Head><span class="sr-only">جزئیات</span></Table.Head></Table.Row
></Table.Header
>
<Table.Body>
{#each result.items as item}
<Table.Row
class="hover:bg-muted/60 cursor-pointer transition-colors"
tabindex={0}
onclick={() => openRow(item.sessionId)}
onkeydown={(event) => {
if (event.key === 'Enter') openRow(item.sessionId);
}}
>
<Table.Cell class="font-mono text-xs" dir="ltr">{item.sessionId}</Table.Cell>
<Table.Cell>{item.terminalId}</Table.Cell>
<Table.Cell class="whitespace-nowrap">{formatDate(item.createdAt)}</Table.Cell>
<Table.Cell class="font-semibold whitespace-nowrap">{formatMoney(item.amountRial)}</Table.Cell
>
<Table.Cell>{formatValue(item.attemptCount)}</Table.Cell>
<Table.Cell
><Badge variant={badgeVariant(item.sessionStatus)} class="rounded-md"
>{statusLabels[item.sessionStatus]}</Badge
></Table.Cell
>
<Table.Cell><ArrowLeftIcon class="text-muted-foreground size-4" /></Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
<div class="text-muted-foreground flex items-center justify-between border-t p-4 text-xs">
<span>{formatValue(result.items.length)} مورد در این صفحه</span><Button
variant="outline"
size="sm"
disabled={!result.pageInfo.hasNextPage}
onclick={nextPage}>صفحه بعد <ArrowLeftIcon /></Button
>
</div>
{:else}
<div class="grid min-h-72 place-items-center px-4 text-center">
<div>
<ArrowUpDownIcon class="text-muted-foreground mx-auto mb-4 size-9" />
<h2 class="font-bold">تراکنشی با این فیلتر پیدا نشد</h2>
<p class="text-muted-foreground mt-2 text-sm">بازه یا فیلتر مبلغ و وضعیت را تغییر دهید.</p>
</div>
</div>
{/if}
</CardContent>
</Card>