feat: enrich dashboard filters and feedback

This commit is contained in:
2026-08-20 22:31:41 +03:30
parent 9ce2e6ec83
commit 458ab69a3c
24 changed files with 1895 additions and 340 deletions

438
.impeccable/design.json Normal file
View File

@@ -0,0 +1,438 @@
{
"schemaVersion": 2,
"generatedAt": "2026-08-20T22:28:26.8204862+03:30",
"title": "Design System: ZarinPal Merchant Analytics",
"extensions": {
"colorMeta": {
"action-blue": {
"role": "primary",
"displayName": "ZarinPal Action Blue",
"canonical": "#0A33FF",
"tonalRamp": [
"oklch(18% 0.12 265)",
"oklch(28% 0.18 265)",
"oklch(40% 0.23 265)",
"oklch(52% 0.27 265)",
"oklch(64% 0.22 265)",
"oklch(75% 0.16 265)",
"oklch(86% 0.09 265)",
"oklch(95% 0.035 265)"
]
},
"action-blue-night": {
"role": "primary",
"displayName": "Night Action Blue",
"canonical": "#6F8DFF",
"tonalRamp": [
"oklch(18% 0.08 265)",
"oklch(28% 0.12 265)",
"oklch(40% 0.16 265)",
"oklch(52% 0.18 265)",
"oklch(64% 0.17 265)",
"oklch(75% 0.14 265)",
"oklch(86% 0.08 265)",
"oklch(95% 0.03 265)"
]
},
"action-tint-light": {
"role": "neutral",
"displayName": "Light Action Tint",
"canonical": "#EEF1FF",
"tonalRamp": [
"oklch(18% 0.025 265)",
"oklch(30% 0.03 265)",
"oklch(42% 0.035 265)",
"oklch(55% 0.04 265)",
"oklch(68% 0.04 265)",
"oklch(79% 0.035 265)",
"oklch(89% 0.025 265)",
"oklch(96% 0.015 265)"
]
},
"action-tint-night": {
"role": "neutral",
"displayName": "Night Action Tint",
"canonical": "#202950",
"tonalRamp": [
"oklch(17% 0.04 265)",
"oklch(27% 0.055 265)",
"oklch(39% 0.065 265)",
"oklch(51% 0.07 265)",
"oklch(63% 0.065 265)",
"oklch(75% 0.05 265)",
"oklch(86% 0.03 265)",
"oklch(95% 0.015 265)"
]
},
"brand-yellow": {
"role": "secondary",
"displayName": "ZarinPal Yellow",
"canonical": "#FFD400",
"tonalRamp": [
"oklch(18% 0.05 95)",
"oklch(30% 0.08 95)",
"oklch(42% 0.11 95)",
"oklch(55% 0.14 95)",
"oklch(68% 0.16 95)",
"oklch(80% 0.17 95)",
"oklch(89% 0.12 95)",
"oklch(96% 0.05 95)"
]
},
"workspace-light": {
"role": "neutral",
"displayName": "Cool Workspace",
"canonical": "#F5F5F6",
"tonalRamp": ["#202023", "#3A3A3E", "#5A5A60", "#7C7C83", "#A0A0A6", "#C3C3C8", "#E2E2E5", "#F5F5F6"]
},
"surface-light": {
"role": "neutral",
"displayName": "White Surface",
"canonical": "#FFFFFF",
"tonalRamp": ["#262626", "#424242", "#626262", "#858585", "#AAAAAA", "#CECECE", "#EEEEEE", "#FFFFFF"]
},
"nested-light": {
"role": "neutral",
"displayName": "Nested Neutral",
"canonical": "#F3F4F6",
"tonalRamp": ["#202125", "#3A3B40", "#595B61", "#7B7D84", "#9FA1A8", "#C2C4CA", "#E0E2E6", "#F3F4F6"]
},
"ink-light": {
"role": "neutral",
"displayName": "Primary Ink",
"canonical": "#18181B",
"tonalRamp": ["#18181B", "#303034", "#4D4D52", "#6E6E74", "#929298", "#B8B8BD", "#DCDCE0", "#F5F5F6"]
},
"muted-ink-light": {
"role": "neutral",
"displayName": "Muted Ink",
"canonical": "#71717A",
"tonalRamp": ["#1D1D20", "#353539", "#505057", "#71717A", "#96969D", "#BABAC0", "#DEDEE2", "#F5F5F6"]
},
"border-light": {
"role": "neutral",
"displayName": "Cool Border",
"canonical": "#E4E4E7",
"tonalRamp": ["#222225", "#3D3D41", "#5D5D62", "#7F7F85", "#A2A2A8", "#C4C4C9", "#E4E4E7", "#F7F7F8"]
},
"workspace-night": {
"role": "neutral",
"displayName": "Night Workspace",
"canonical": "#101012",
"tonalRamp": ["#101012", "#28282B", "#444448", "#626267", "#838388", "#A7A7AC", "#CECED1", "#F2F2F3"]
},
"surface-night": {
"role": "neutral",
"displayName": "Night Surface",
"canonical": "#19191C",
"tonalRamp": ["#19191C", "#313135", "#4D4D52", "#6D6D72", "#909096", "#B5B5BA", "#DBDBDE", "#F5F5F6"]
},
"ink-night": {
"role": "neutral",
"displayName": "Night Ink",
"canonical": "#FAFAFA",
"tonalRamp": ["#252525", "#414141", "#606060", "#828282", "#A6A6A6", "#CACACA", "#E8E8E8", "#FAFAFA"]
},
"muted-ink-night": {
"role": "neutral",
"displayName": "Night Muted Ink",
"canonical": "#A1A1AA",
"tonalRamp": ["#202023", "#39393E", "#55555B", "#74747B", "#A1A1AA", "#BDBDC4", "#DCDCE0", "#F5F5F6"]
},
"border-night": {
"role": "neutral",
"displayName": "Night Border",
"canonical": "#3A3A3D",
"tonalRamp": ["#171719", "#28282B", "#3A3A3D", "#5C5C61", "#808086", "#A6A6AB", "#CDCDD1", "#F1F1F2"]
},
"success-light": {
"role": "tertiary",
"displayName": "Merchant Success",
"canonical": "#248061",
"tonalRamp": [
"oklch(18% 0.045 165)",
"oklch(28% 0.07 165)",
"oklch(40% 0.09 165)",
"oklch(52% 0.105 165)",
"oklch(64% 0.095 165)",
"oklch(76% 0.075 165)",
"oklch(87% 0.045 165)",
"oklch(95% 0.02 165)"
]
},
"warning-light": {
"role": "tertiary",
"displayName": "Merchant Warning",
"canonical": "#8A5300",
"tonalRamp": [
"oklch(18% 0.05 75)",
"oklch(28% 0.08 75)",
"oklch(40% 0.11 75)",
"oklch(52% 0.13 75)",
"oklch(64% 0.12 75)",
"oklch(76% 0.09 75)",
"oklch(87% 0.055 75)",
"oklch(95% 0.025 75)"
]
},
"danger-light": {
"role": "tertiary",
"displayName": "Merchant Danger",
"canonical": "#C93637",
"tonalRamp": [
"oklch(18% 0.065 25)",
"oklch(28% 0.105 25)",
"oklch(40% 0.15 25)",
"oklch(52% 0.18 25)",
"oklch(64% 0.16 25)",
"oklch(76% 0.11 25)",
"oklch(87% 0.06 25)",
"oklch(95% 0.025 25)"
]
},
"success-night": {
"role": "tertiary",
"displayName": "Night Success",
"canonical": "#6FD6AE",
"tonalRamp": [
"oklch(18% 0.035 165)",
"oklch(28% 0.055 165)",
"oklch(40% 0.075 165)",
"oklch(52% 0.09 165)",
"oklch(64% 0.09 165)",
"oklch(76% 0.075 165)",
"oklch(87% 0.045 165)",
"oklch(95% 0.018 165)"
]
},
"warning-night": {
"role": "tertiary",
"displayName": "Night Warning",
"canonical": "#F3BE5B",
"tonalRamp": [
"oklch(18% 0.045 80)",
"oklch(28% 0.07 80)",
"oklch(40% 0.10 80)",
"oklch(52% 0.13 80)",
"oklch(64% 0.14 80)",
"oklch(76% 0.12 80)",
"oklch(87% 0.07 80)",
"oklch(95% 0.025 80)"
]
},
"danger-night": {
"role": "tertiary",
"displayName": "Night Danger",
"canonical": "#FF8A8B",
"tonalRamp": [
"oklch(18% 0.055 25)",
"oklch(28% 0.09 25)",
"oklch(40% 0.13 25)",
"oklch(52% 0.16 25)",
"oklch(64% 0.15 25)",
"oklch(76% 0.11 25)",
"oklch(87% 0.06 25)",
"oklch(95% 0.022 25)"
]
}
},
"typographyMeta": {
"page-heading": {
"displayName": "Page Heading",
"purpose": "Routed page identity; rises from 20px to 24px at 640px."
},
"section-heading": {
"displayName": "Section Heading",
"purpose": "Major analytics modules and methodology groups."
},
"metric": { "displayName": "Metric", "purpose": "Tabular KPI values, amounts, counts, and rates." },
"body": { "displayName": "Body", "purpose": "Default Persian interface copy." },
"supporting": {
"displayName": "Supporting",
"purpose": "Descriptions, filters, tables, and insight summaries."
},
"label": {
"displayName": "Label",
"purpose": "Compact controls, metadata, badges, and chart annotations."
}
},
"shadows": [
{
"name": "surface-low",
"value": "0 1px 2px 0 rgb(0 0 0 / 0.05)",
"purpose": "Subtle reinforcement on repeated cards, chart surfaces, and filter bars."
},
{
"name": "navbar-hairline",
"value": "0 1px 2px rgba(0,0,0,0.03)",
"purpose": "Fixed top-navbar separation only."
},
{
"name": "overlay-high",
"value": "0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10)",
"purpose": "Tooltips, sheets, and modal-level floating content."
}
],
"motion": [
{ "name": "state-fast", "value": "160ms ease", "purpose": "Button tone and press feedback." },
{
"name": "shell-standard",
"value": "200ms ease-in-out",
"purpose": "Sidebar, sheet, and compact surface transitions."
},
{
"name": "chart-trace",
"value": "900ms cubic-bezier(0.22, 1, 0.36, 1)",
"purpose": "One-time payment-history line reveal; removed under reduced motion."
}
],
"breakpoints": [
{ "name": "kpi", "value": "600px" },
{ "name": "sm", "value": "640px" },
{ "name": "md", "value": "768px" },
{ "name": "lg", "value": "1024px" }
]
},
"components": [
{
"name": "Selected Range Button",
"kind": "button",
"refersTo": "button-primary",
"description": "Marks the active reporting period inside the compact segmented filter row.",
"html": "<button class=\"ds-range-selected\" aria-pressed=\"true\">ماهانه</button>",
"css": ".ds-range-selected { min-height:44px; padding:0 10px; border:1px solid transparent; border-radius:6px; background:var(--primary,#0a33ff); color:var(--primary-foreground,#fff); font:500 12px/1.5 IRANYekanX,Tahoma,sans-serif; cursor:pointer; transition:background-color 160ms ease,transform 160ms ease; } .ds-range-selected:hover { filter:brightness(.92); } .ds-range-selected:active { transform:translateY(1px); } .ds-range-selected:focus-visible { outline:3px solid var(--ring,#0a33ff); outline-offset:3px; } @media (min-width:640px) { .ds-range-selected { min-height:36px; } }"
},
{
"name": "Ghost Range Button",
"kind": "button",
"refersTo": "button-ghost",
"description": "Keeps inactive period choices legible but visually quiet.",
"html": "<button class=\"ds-range-ghost\" aria-pressed=\"false\">هفتگی</button>",
"css": ".ds-range-ghost { min-height:44px; padding:0 10px; border:1px solid transparent; border-radius:6px; background:transparent; color:var(--foreground,#18181b); font:500 12px/1.5 IRANYekanX,Tahoma,sans-serif; cursor:pointer; transition:background-color 160ms ease; } .ds-range-ghost:hover { background:var(--muted,#f3f4f6); } .ds-range-ghost:focus-visible { outline:3px solid var(--ring,#0a33ff); outline-offset:3px; } @media (min-width:640px) { .ds-range-ghost { min-height:36px; } }"
},
{
"name": "RTL Terminal Dropdown",
"kind": "input",
"refersTo": "input",
"description": "The custom RTL select primitive used for terminal scope and transaction status.",
"html": "<div class=\"ds-select\" dir=\"rtl\"><button class=\"ds-select-trigger\" aria-haspopup=\"listbox\" aria-expanded=\"true\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\"><rect x=\"3\" y=\"5\" width=\"18\" height=\"14\" rx=\"2\"/><path d=\"M7 9h10\"/></svg><span>همه درگاه‌ها</span><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"><path d=\"m6 9 6 6 6-6\"/></svg></button><div class=\"ds-select-menu\" role=\"listbox\"><div class=\"ds-select-option\" role=\"option\" aria-selected=\"true\">همه درگاه‌ها</div><div class=\"ds-select-option\" role=\"option\"><span>فروشگاه اصلی</span><small>۶۳۹ تراکنش</small></div></div></div>",
"css": ".ds-select { position:relative; width:220px; color:var(--foreground,#18181b); font-family:IRANYekanX,Tahoma,sans-serif; } .ds-select-trigger { display:flex; width:100%; min-height:44px; align-items:center; gap:8px; padding:0 10px; border:1px solid var(--input,#e4e4e7); border-radius:6px; background:var(--background,#f5f5f6); color:inherit; font-size:12px; cursor:pointer; } .ds-select-trigger span { flex:1; overflow:hidden; text-align:right; text-overflow:ellipsis; white-space:nowrap; } .ds-select-trigger:hover { background:var(--muted,#f3f4f6); } .ds-select-trigger:focus-visible { outline:3px solid var(--ring,#0a33ff); outline-offset:2px; } .ds-select-menu { position:absolute; inset-inline-end:0; z-index:2; width:100%; margin-top:6px; padding:4px; border:1px solid var(--border,#e4e4e7); border-radius:8px; background:var(--popover,#fff); box-shadow:0 10px 15px -3px rgb(0 0 0 / .1); } .ds-select-option { display:flex; min-height:44px; align-items:center; justify-content:space-between; gap:16px; padding:8px; border-radius:6px; font-size:12px; cursor:pointer; } .ds-select-option:hover,.ds-select-option[aria-selected=true] { background:var(--accent,#eef1ff); color:var(--accent-foreground,#0a33ff); } .ds-select-option small { color:var(--muted-foreground,#71717a); font-size:11px; } @media (min-width:640px) { .ds-select-trigger { min-height:40px; } }"
},
{
"name": "Jalali Period Picker",
"kind": "custom",
"description": "Chooses an exact complete Jalali day, week, month, or season inside the full report-scope bar.",
"html": "<section class=\"ds-period\" dir=\"rtl\"><button class=\"ds-period-trigger\" aria-expanded=\"true\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\"><rect x=\"3\" y=\"5\" width=\"18\" height=\"16\" rx=\"2\"/><path d=\"M16 3v4M8 3v4M3 11h18\"/></svg>خرداد ۱۴۰۵</button><div class=\"ds-period-panel\"><strong>انتخاب بازه دقیق</strong><small>فقط بازه‌هایی که داده کامل دارند قابل انتخاب‌اند.</small><div class=\"ds-period-head\"><input value=\"۱۴۰۵\" aria-label=\"سال گزارش\"><button>خرداد</button></div><div class=\"ds-period-weekdays\"><span>ش</span><span>ی</span><span>د</span><span>س</span><span>چ</span><span>پ</span><span>ج</span></div><div class=\"ds-period-grid\"><button>۱</button><button>۲</button><button>۳</button><button class=\"ds-period-selected\">۴</button><button>۵</button><button>۶</button><button>۷</button></div></div></section>",
"css": ".ds-period { position:relative; color:var(--foreground,#18181b); font-family:IRANYekanX,Tahoma,sans-serif; } .ds-period-trigger { display:inline-flex; min-height:44px; align-items:center; gap:8px; padding:0 12px; border:1px solid var(--input,#e4e4e7); border-radius:6px; background:var(--background,#f5f5f6); color:inherit; font-size:12px; font-weight:500; cursor:pointer; } .ds-period-trigger svg { color:var(--primary,#0a33ff); } .ds-period-trigger:hover { background:var(--muted,#f3f4f6); } .ds-period-trigger:focus-visible,.ds-period-panel button:focus-visible,.ds-period-panel input:focus-visible { outline:3px solid var(--ring,#0a33ff); outline-offset:2px; } .ds-period-panel { width:min(384px,calc(100vw - 32px)); margin-top:6px; padding:16px; border:1px solid var(--border,#e4e4e7); border-radius:8px; background:var(--popover,#fff); box-shadow:0 10px 15px -3px rgb(0 0 0 / .1); } .ds-period-panel>small { display:block; margin-top:4px; color:var(--muted-foreground,#71717a); font-size:12px; } .ds-period-head { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:16px; } .ds-period-head input,.ds-period-head button { min-height:44px; padding:0 12px; border:1px solid var(--input,#e4e4e7); border-radius:6px; background:var(--background,#f5f5f6); color:inherit; font:400 14px IRANYekanX,Tahoma,sans-serif; } .ds-period-weekdays,.ds-period-grid { display:grid; grid-template-columns:repeat(7,1fr); gap:4px; margin-top:12px; text-align:center; } .ds-period-weekdays { color:var(--muted-foreground,#71717a); font-size:11px; } .ds-period-grid button { aspect-ratio:1; min-height:40px; border:0; border-radius:6px; background:transparent; color:inherit; cursor:pointer; } .ds-period-grid button:hover { background:color-mix(in srgb,var(--primary,#0a33ff) 10%,transparent); } .ds-period-grid .ds-period-selected { background:var(--primary,#0a33ff); color:var(--primary-foreground,#fff); } @media (min-width:640px) { .ds-period-trigger,.ds-period-head input,.ds-period-head button { min-height:36px; } }"
},
{
"name": "RTL Amount Range",
"kind": "input",
"description": "A two-thumb Rial range filter with explicit values and apply/reset actions.",
"html": "<section class=\"ds-amount\" dir=\"rtl\"><strong>بازه مبلغ تراکنش</strong><p>دو نشانگر را برای تعیین کمینه و بیشینه جابه‌جا کنید.</p><div class=\"ds-amount-values\"><output>از ۱۰۰٬۰۰۰ ریال</output><output>تا ۱۰٬۰۰۰٬۰۰۰٬۰۰۰ ریال</output></div><div class=\"ds-amount-track\"><span></span><i class=\"ds-amount-thumb ds-amount-min\" tabindex=\"0\"></i><i class=\"ds-amount-thumb ds-amount-max\" tabindex=\"0\"></i></div><div class=\"ds-amount-actions\"><button>اعمال بازه</button><button>پاک‌کردن</button></div></section>",
"css": ".ds-amount { width:min(352px,calc(100vw - 32px)); padding:16px; border:1px solid var(--border,#e4e4e7); border-radius:8px; background:var(--popover,#fff); color:var(--foreground,#18181b); font-family:IRANYekanX,Tahoma,sans-serif; } .ds-amount p { margin:4px 0 16px; color:var(--muted-foreground,#71717a); font-size:12px; } .ds-amount-values { display:flex; justify-content:space-between; gap:12px; font-size:12px; font-variant-numeric:tabular-nums; } .ds-amount-values output { padding:4px 8px; border-radius:6px; background:var(--muted,#f3f4f6); } .ds-amount-track { position:relative; height:44px; } .ds-amount-track span { position:absolute; inset:20px 0 auto; height:4px; border-radius:999px; background:linear-gradient(to left,var(--muted,#f3f4f6) 0 10%,var(--primary,#0a33ff) 10% 82%,var(--muted,#f3f4f6) 82% 100%); } .ds-amount-thumb { position:absolute; top:12px; width:20px; height:20px; border:3px solid var(--background,#f5f5f6); border-radius:50%; background:var(--primary,#0a33ff); box-shadow:0 0 0 1px color-mix(in srgb,var(--primary,#0a33ff) 45%,transparent); cursor:grab; } .ds-amount-min { right:8%; } .ds-amount-max { left:16%; } .ds-amount-thumb:focus-visible { outline:3px solid color-mix(in srgb,var(--primary,#0a33ff) 28%,transparent); outline-offset:2px; } .ds-amount-actions { display:flex; gap:8px; } .ds-amount-actions button { min-height:44px; padding:0 12px; border:1px solid var(--input,#e4e4e7); border-radius:6px; background:transparent; color:inherit; font:600 14px IRANYekanX,Tahoma,sans-serif; cursor:pointer; } .ds-amount-actions button:first-child { flex:1; border-color:transparent; background:var(--primary,#0a33ff); color:var(--primary-foreground,#fff); } .ds-amount-actions button:hover { filter:brightness(.95); } .ds-amount-actions button:focus-visible { outline:3px solid var(--ring,#0a33ff); outline-offset:2px; }"
},
{
"name": "KPI Card",
"kind": "card",
"refersTo": "card",
"description": "The main scan unit for amount, count, rate, or completion metrics.",
"html": "<article class=\"ds-kpi\" dir=\"rtl\"><div class=\"ds-kpi-top\"><span>مبلغ پرداخت موفق</span><span class=\"ds-kpi-icon\" aria-hidden=\"true\"><svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\"><rect x=\"3\" y=\"6\" width=\"18\" height=\"12\" rx=\"2\"/><path d=\"M7 12h.01M17 12h.01\"/></svg></span></div><strong class=\"ds-kpi-value\">۲۱٬۳۴۷٬۷۱۳٬۹۱۰ ریال</strong><p>مجموع مبلغ پرداخت‌های موفق در این بازه</p></article>",
"css": ".ds-kpi { padding:16px; border:1px solid var(--border,#e4e4e7); border-radius:8px; background:var(--card,#fff); color:var(--card-foreground,#18181b); font-family:IRANYekanX,Tahoma,sans-serif; box-shadow:0 1px 2px rgb(0 0 0 / .05); transition:transform 200ms ease; } .ds-kpi:hover { transform:translateY(-2px); } .ds-kpi-top { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; margin-bottom:12px; color:var(--muted-foreground,#71717a); font-size:14px; } .ds-kpi-icon { color:var(--primary,#0a33ff); } .ds-kpi-value { display:block; font-size:24px; line-height:1.25; font-weight:800; font-variant-numeric:tabular-nums; letter-spacing:-.025em; } .ds-kpi p { margin:6px 0 0; color:var(--muted-foreground,#71717a); font-size:12px; line-height:1.66; } .ds-kpi:focus-within { outline:3px solid var(--ring,#0a33ff); outline-offset:3px; } @media (min-width:640px) { .ds-kpi { padding:20px; } .ds-kpi-top { margin-bottom:20px; } .ds-kpi p { margin-top:8px; line-height:2; } }"
},
{
"name": "Active Navigation Item",
"kind": "nav",
"refersTo": "nav-active",
"description": "Communicates the current routed merchant task in the physical-right sidebar.",
"html": "<a class=\"ds-nav-active\" href=\"#\" aria-current=\"page\"><svg width=\"19\" height=\"19\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\"><rect x=\"3\" y=\"3\" width=\"7\" height=\"7\"/><rect x=\"14\" y=\"3\" width=\"7\" height=\"7\"/><rect x=\"3\" y=\"14\" width=\"7\" height=\"7\"/><rect x=\"14\" y=\"14\" width=\"7\" height=\"7\"/></svg><span>پیشخوان</span></a>",
"css": ".ds-nav-active { display:flex; align-items:center; justify-content:flex-start; gap:8px; height:44px; padding:8px 12px; border-radius:6px; background:var(--sidebar-accent,#eef1ff); color:var(--sidebar-accent-foreground,#0a33ff); font:500 13px/1.5 IRANYekanX,Tahoma,sans-serif; text-decoration:none; direction:rtl; transition:filter 160ms ease; } .ds-nav-active:hover { filter:brightness(.97); } .ds-nav-active:focus-visible { outline:3px solid var(--sidebar-ring,#0a33ff); outline-offset:2px; }"
},
{
"name": "Ready Status Badge",
"kind": "chip",
"description": "Pairs compact semantic color with explicit readiness language.",
"html": "<span class=\"ds-status-ready\">✓ آماده</span>",
"css": ".ds-status-ready { display:inline-flex; align-items:center; justify-content:center; min-height:20px; padding:2px 12px; border-radius:9999px; background:color-mix(in srgb,var(--success,#248061) 10%,transparent); color:var(--success,#248061); font:600 12px/1.5 IRANYekanX,Tahoma,sans-serif; white-space:nowrap; } .ds-status-ready:focus-visible { outline:3px solid var(--ring,#0a33ff); outline-offset:3px; }"
},
{
"name": "Fee Share Donut",
"kind": "custom",
"description": "Shows the effective fee share beside a plain-language definition and exact percentage.",
"html": "<article class=\"ds-fee\" dir=\"rtl\"><div class=\"ds-fee-donut\" role=\"img\" aria-label=\"سهم کارمزد ۲٫۴ درصد\"><svg viewBox=\"0 0 42 42\" aria-hidden=\"true\"><circle class=\"ds-fee-track\" cx=\"21\" cy=\"21\" r=\"15.9\"/><circle class=\"ds-fee-value\" cx=\"21\" cy=\"21\" r=\"15.9\" pathLength=\"100\"/></svg><strong>۲٫۴٪</strong></div><div><h3>سهم کارمزد از پرداخت موفق</h3><p>بخش آبی، سهم واقعی کارمزد را از کل مبلغ موفق این بازه نشان می‌دهد.</p></div></article>",
"css": ".ds-fee { display:flex; align-items:center; gap:16px; padding:16px; border:1px solid var(--border,#e4e4e7); border-radius:8px; background:var(--card,#fff); color:var(--card-foreground,#18181b); font-family:IRANYekanX,Tahoma,sans-serif; box-shadow:0 1px 2px rgb(0 0 0 / .05); } .ds-fee-donut { position:relative; display:grid; width:96px; height:96px; flex:none; place-items:center; } .ds-fee-donut svg { width:100%; height:100%; transform:rotate(-90deg); } .ds-fee-track,.ds-fee-value { fill:none; stroke-width:4; } .ds-fee-track { stroke:var(--muted,#f3f4f6); } .ds-fee-value { stroke:var(--primary,#0a33ff); stroke-linecap:round; stroke-dasharray:2.4 97.6; transition:stroke-dasharray 700ms ease; } .ds-fee-donut strong { position:absolute; font-size:14px; font-variant-numeric:tabular-nums; } .ds-fee h3 { margin:0; font-size:14px; font-weight:600; } .ds-fee p { margin:8px 0 0; color:var(--muted-foreground,#71717a); font-size:12px; line-height:2; } .ds-fee:focus-within { outline:3px solid var(--ring,#0a33ff); outline-offset:3px; } @media (min-width:640px) { .ds-fee { padding:20px; } } @media (prefers-reduced-motion:reduce) { .ds-fee-value { transition:none; } }"
},
{
"name": "Transaction Row",
"kind": "custom",
"description": "A dense merchant-owned record with a semantic detail link and explicit status tone.",
"html": "<table class=\"ds-table\" dir=\"rtl\"><thead><tr><th>شناسه پرداخت</th><th>مبلغ</th><th>وضعیت</th></tr></thead><tbody><tr><td dir=\"ltr\"><a href=\"#\">SES-639-042</a></td><td>۱٬۲۵۰٬۰۰۰ ریال</td><td><span class=\"ds-verified\">تأییدشده</span></td></tr></tbody></table>",
"css": ".ds-table { width:100%; border-collapse:collapse; border:1px solid var(--border,#e4e4e7); border-radius:8px; overflow:hidden; background:var(--card,#fff); color:var(--card-foreground,#18181b); font:400 14px/1.75 IRANYekanX,Tahoma,sans-serif; } .ds-table th,.ds-table td { padding:12px; text-align:right; border-bottom:1px solid var(--border,#e4e4e7); } .ds-table th { background:var(--muted,#f3f4f6); color:var(--muted-foreground,#71717a); font-size:12px; font-weight:600; } .ds-table tbody tr { transition:background-color 160ms ease; } .ds-table tbody tr:hover { background:color-mix(in srgb,var(--muted,#f3f4f6) 60%,transparent); } .ds-table td[dir=ltr] { text-align:left; font:500 12px/1.5 ui-monospace,monospace; } .ds-table a { border-radius:4px; color:inherit; text-decoration:none; } .ds-table a:hover { color:var(--primary,#0a33ff); text-decoration:underline; text-underline-offset:4px; } .ds-table a:focus-visible { outline:3px solid var(--ring,#0a33ff); outline-offset:2px; } .ds-verified { display:inline-flex; min-height:20px; align-items:center; padding:2px 8px; border:1px solid #a7f3d0; border-radius:6px; background:#ecfdf5; color:#047857; font-size:12px; }"
}
],
"narrative": {
"northStar": "The Calm Merchant Control Room",
"overview": "This is a Persian-first merchant workspace built for repeated operational scanning. It feels calm rather than promotional: compact controls, exact figures, plain-language explanations, and crisp bordered surfaces let a merchant understand recent performance before drilling into transactions, payment attempts, fees, or peer context.\n\nThe world is recognizably ZarinPal through its wordmark, action blue, small yellow brand accent, right-to-left shell, and familiar right-side navigation. Density is deliberate but never cramped. The first viewport establishes scope in the fixed top bar and physical-right sidebar, then leads with a page heading, period control, KPI row, and payment-history chart. Motion is limited to state feedback, shell transitions, and the initial chart trace.",
"keyCharacteristics": [
"Persian-first RTL structure with Iran Yekan X FaNum and tabular merchant-facing numerals.",
"Fixed 56px top bar, physical-right desktop sidebar, and mobile terminal selector in the navbar.",
"Low-radius white or near-black financial surfaces separated primarily by 1px borders.",
"ZarinPal blue reserved for actions, selection, navigation, chart emphasis, and focus.",
"Semantic success, warning, and danger color always accompanied by words, icons, or both.",
"Encouraging, factual summaries that move from recent performance to inspectable evidence."
],
"rules": [
{
"name": "The One Blue Voice Rule",
"body": "Blue signals an action, current scope, active navigation, focus, or the primary data series; it does not wash large passive regions.",
"section": "colors"
},
{
"name": "The Meaning Needs Language Rule",
"body": "Success, warning, and danger color reinforce a visible label, icon, or explanation and never carry status alone.",
"section": "colors"
},
{
"name": "The Persian Measure Rule",
"body": "Merchant-facing amounts, counts, percentages, dates, and chart values use Persian digits; technical identifiers remain canonical Latin inside isolated left-to-right spans.",
"section": "typography"
},
{
"name": "The Weight Before Scale Rule",
"body": "The dense workspace creates hierarchy first with 500, 700, and 800 weights, then with a compact 12/14/15/20/24px size ladder.",
"section": "typography"
},
{
"name": "The Scope Before Evidence Rule",
"body": "Terminal and period scope must be visible before KPIs, charts, tables, or attempt history; desktop terminal scope belongs in the right sidebar and narrow-screen terminal scope belongs in the navbar.",
"section": "layout"
},
{
"name": "The Border Carries Structure Rule",
"body": "Use the 1px border and tonal layers for resting structure; reserve conspicuous shadows for content that actually floats above the shell.",
"section": "elevation"
},
{
"name": "The Data Surface Radius Rule",
"body": "Repeated financial surfaces use 8px corners and a visible border; rounding must not make tables, KPIs, or filters feel like marketing cards.",
"section": "shapes"
}
],
"dos": [
"Do keep the terminal selector in the physical-right desktop sidebar and in the narrow-screen navbar.",
"Do lead with the selected period, four ordered KPIs, a plain-language takeaway, and then inspectable chart or table evidence.",
"Do use action blue for the current scope, primary series, focus, and actionable controls.",
"Do keep cards at 8px with a 1px border and use 16px KPI padding on phones, increasing to 20px from 640px.",
"Do preserve Persian digits, Rial units, Jalali dates, RTL flow, and left-to-right isolation for technical identifiers.",
"Do provide text, icons, focus states, and accessible alternatives anywhere color or a chart carries meaning."
],
"donts": [
"Don't turn yellow into a broad dashboard fill or use it for body text on light surfaces.",
"Don't use pill geometry for cards, tables, inputs, or ordinary navigation rows.",
"Don't add decorative gradients, oversized hero type, or marketing-page whitespace to authenticated analytics routes.",
"Don't use shadow as the primary boundary between resting financial surfaces.",
"Don't animate the page as a spectacle; keep motion to state feedback, the shell, overlays, and the chart trace, and honor reduced motion.",
"Don't hide status, scope, units, formulas, or important chart values behind color or pointer-only interaction."
]
}
}

445
DESIGN.md
View File

@@ -1,211 +1,318 @@
# Design System — ZarinPal Merchant Analytics
---
name: ZarinPal Merchant Analytics
description: Calm Persian-first merchant intelligence in a crisp, dense financial workspace.
colors:
action-blue: '#0A33FF'
action-blue-night: '#6F8DFF'
action-tint-light: '#EEF1FF'
action-tint-night: '#202950'
brand-yellow: '#FFD400'
workspace-light: '#F5F5F6'
surface-light: '#FFFFFF'
nested-light: '#F3F4F6'
ink-light: '#18181B'
muted-ink-light: '#71717A'
border-light: '#E4E4E7'
workspace-night: '#101012'
surface-night: '#19191C'
ink-night: '#FAFAFA'
muted-ink-night: '#A1A1AA'
border-night: '#3A3A3D'
success-light: '#248061'
warning-light: '#8A5300'
danger-light: '#C93637'
success-night: '#6FD6AE'
warning-night: '#F3BE5B'
danger-night: '#FF8A8B'
typography:
page-heading:
fontFamily: 'IRANYekanX, Tahoma, sans-serif'
fontSize: '1.25rem'
fontWeight: 800
lineHeight: 1.4
letterSpacing: '-0.025em'
section-heading:
fontFamily: 'IRANYekanX, Tahoma, sans-serif'
fontSize: '1.25rem'
fontWeight: 700
lineHeight: 1.5
metric:
fontFamily: 'IRANYekanX, Tahoma, sans-serif'
fontSize: '1.5rem'
fontWeight: 800
lineHeight: 1.25
fontFeature: 'tnum'
body:
fontFamily: 'IRANYekanX, Tahoma, sans-serif'
fontSize: '0.9375rem'
fontWeight: 400
lineHeight: 1.8
supporting:
fontFamily: 'IRANYekanX, Tahoma, sans-serif'
fontSize: '0.875rem'
fontWeight: 400
lineHeight: 1.75
label:
fontFamily: 'IRANYekanX, Tahoma, sans-serif'
fontSize: '0.75rem'
fontWeight: 500
lineHeight: 1.5
rounded:
compact: '6px'
control: '7px'
surface: '8px'
pill: '9999px'
spacing:
1: '4px'
2: '8px'
3: '12px'
4: '16px'
5: '20px'
6: '24px'
8: '32px'
components:
button-primary:
backgroundColor: '{colors.action-blue}'
textColor: '{colors.surface-light}'
typography: '{typography.label}'
rounded: '{rounded.surface}'
padding: '0 10px'
height: '32px'
button-ghost:
backgroundColor: 'transparent'
textColor: '{colors.ink-light}'
typography: '{typography.label}'
rounded: '{rounded.surface}'
padding: '0 10px'
height: '32px'
card:
backgroundColor: '{colors.surface-light}'
textColor: '{colors.ink-light}'
typography: '{typography.body}'
rounded: '{rounded.surface}'
padding: '20px'
input:
backgroundColor: 'transparent'
textColor: '{colors.ink-light}'
typography: '{typography.supporting}'
rounded: '{rounded.surface}'
padding: '4px 10px'
height: '32px'
nav-active:
backgroundColor: '{colors.action-tint-light}'
textColor: '{colors.action-blue}'
typography: '{typography.supporting}'
rounded: '{rounded.compact}'
padding: '8px 12px'
height: '44px'
---
- Status: Approved baseline — implementation authorized 2026-08-20
- Version: 0.4.0
- Last updated: 2026-08-20
- Direction: Persian-first RTL
# Design System: ZarinPal Merchant Analytics
## Design intent
## Overview
Match the recognizable product language of ZarinPal without copying inaccessible proprietary assets: calm financial confidence, generous whitespace, crisp white surfaces, strong blue actions, restrained yellow accents, dark ink sections, rounded geometry, and dense data presented in plain language.
**Creative North Star: "The Calm Merchant Control Room"**
The authenticated panel screenshots are the primary behavioral reference: fixed right navigation on desktop, right-side drawer on narrow screens, light gray workspace, white cards, compact outlined controls, blue active states, and direct numerical summaries. The public-site screenshots inform larger radius, dark promotional surfaces, and spacing—but the dashboard prioritizes speed and legibility over marketing spectacle.
This is a Persian-first merchant workspace built for repeated operational scanning. It feels calm rather than promotional: compact controls, exact figures, plain-language explanations, and crisp bordered surfaces let a merchant understand recent performance before drilling into transactions, payment attempts, fees, or peer context.
## Foundations
The world is recognizably ZarinPal through its wordmark, action blue, small yellow brand accent, right-to-left shell, and familiar right-side navigation. Density is deliberate but never cramped. The first viewport establishes scope in the fixed top bar and physical-right sidebar, then leads with a page heading, period control, KPI row, and payment-history chart. Motion is limited to state feedback, shell transitions, and the initial chart trace.
### Color tokens
**Key Characteristics:**
| Token | Value | Use |
| ----------------------- | --------: | ---------------------------------------------------------------- |
| `--color-brand-600` | `#0A33FF` | Primary actions, active navigation, links, selected chart series |
| `--color-brand-700` | `#0828CC` | Hover/pressed brand state |
| `--color-accent-400` | `#FFD400` | Small ZarinPal-like accent, highlights; never body text on white |
| `--color-ink-950` | `#19191A` | Primary text, dark panels |
| `--color-ink-700` | `#3D3D42` | Secondary strong text |
| `--color-ink-500` | `#6B6B73` | Supporting text |
| `--color-canvas` | `#F1F1F3` | Application background |
| `--color-surface` | `#FFFFFF` | Cards, drawer, dialogs |
| `--color-surface-muted` | `#F7F7F8` | Nested panels, table headers |
| `--color-border` | `#E3E3E7` | Dividers and neutral outlines |
| `--color-success-600` | `#248061` | Success states and positive semantic data |
| `--color-warning-600` | `#A86700` | Warnings; paired with text/icon, never color alone |
| `--color-danger-500` | `#FF5C5D` | Errors and destructive actions |
| `--color-info-surface` | `#E7F2FF` | Informational callouts |
- Persian-first RTL structure with Iran Yekan X FaNum and tabular merchant-facing numerals.
- Fixed 56px top bar, physical-right desktop sidebar, and mobile terminal selector in the navbar.
- Low-radius white or near-black financial surfaces separated primarily by 1px borders.
- ZarinPal blue reserved for actions, selection, navigation, chart emphasis, and focus.
- Semantic success, warning, and danger color always accompanied by words, icons, or both.
- Encouraging, factual summaries that move from recent performance to inspectable evidence.
All text/background pairs must meet WCAG 2.2 AA contrast. Chart palettes require patterns, labels, or shapes in addition to hue.
### Approved baseline provenance
### Theme contract
The approved Persian-first baseline remains authoritative where the shipped artifact confirms it: the licensed font and license file stay tracked; light and dark themes remain semantic variants; values use Persian digits and Rial; merchant dates use the Jalali calendar and Asia/Tehran; RTL, bidi isolation, WCAG 2.2 AA, keyboard operation, reduced motion, safe peer comparisons, and traceable metrics remain binding. The artifact now resolves provisional visual guidance into the concrete tokens and component rules recorded here.
Light and dark modes are first-class variants of the same semantic token system. Components consume role tokens rather than raw palette values; dark mode is not a color inversion or an after-launch skin.
| Version | Date | Decision |
| ------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 0.6.0 | 2026-08-20 | Added full-span Jalali period selection, unified transaction status/amount controls, persistent skeleton states, custom RTL dropdowns, semantic peer/retry tones, and fee-share visualization; tightened the narrow navbar and 600px KPI breakpoint. |
| 0.5.0 | 2026-08-20 | Reconciled the approved baseline with the shipped routed frontend and desktop, mobile, and 639px review captures; converted the file to canonical DESIGN.md format and added the design sidecar. |
| 0.4.0 | 2026-08-20 | Adopted the current ZarinPal panel as the closer interaction reference: top wordmark bar, 256/72px collapsible right sidebar, 8px data surfaces, routed sections, URL-backed period and terminal filters, payment-history chart, transaction table, attempt history, formula help, and supportive insights. |
| 0.3.3 | 2026-08-20 | Replaced merchant/cohort revision freshness language with the approved immutable completed-import dataset snapshot. |
| 0.3.2 | 2026-08-20 | Made the licensed Iran Yekan X FaNum variable WOFF2 and its required license file Git-tracked frontend assets. |
| 0.3.1 | 2026-08-20 | Verified the purchased Iran Yekan X Professional FaNum variable assets and added merchant-owned detail CSV export as an MVP contract. |
| 0.3.0 | 2026-08-20 | Approved Iran Yekan X Professional FaNum, Persian digits, Rial-only values, first-class light/dark themes, and the deterministic insight threshold. |
| 0.2.4 | 2026-08-20 | Fixed the default period, comparison fallback, KPI priority, read-only drill-down scope, and accurate NoAttempt/InBank stage language. |
| 0.2.3 | 2026-08-20 | Kept authentication UX intentionally demo-sized with a clean replacement boundary for ZarinPal SSO. |
| 0.2.2 | 2026-08-20 | Defined backend-owned session UX and generic accessible expiry/revocation handling. |
| 0.2.1 | 2026-08-20 | Defined peer cards as an equal-per-merchant median plus direction-aware percentile. |
| 0.2.0 | 2026-08-20 | Corrected payment lifecycle semantics: Paid and Verified are bank-payment successes, Paid remains unverified, and session fees are real Rial amounts. |
| 0.1.9 | 2026-08-20 | Added rate-limit UX, SSE heartbeat/recovery, replay, and failed-section-only retry. |
| 0.1.8 | 2026-08-20 | Fixed the parallel analytics section identifiers, cache, idempotent start/SSE flow, and section timeout/retry behavior. |
| 0.1.7 | 2026-08-20 | Defined partial, parallel section delivery through authenticated SSE. |
| 0.1.6 | 2026-08-20 | Replaced time-based analytics freshness with lazy calculation and defined Saturday–Friday weeks. |
| 0.1.5 | 2026-08-20 | Limited period controls to daily, weekly, Jalali-month, and Persian-season views. |
| 0.1.2 | 2026-08-20 | Added the insufficient-data state and visibility threshold for anonymized peer comparisons. |
| 0.1.1 | 2026-08-20 | Fixed merchant-facing time presentation to the Jalali calendar in Asia/Tehran with UTC API instants. |
| 0.1.0 | 2026-08-20 | Established the ZarinPal-inspired RTL foundation, responsive shell, core components, accessibility, chart, and motion rules. |
| Semantic role | Light | Dark | Notes |
| -------------------------- | --------: | --------: | -------------------------------------------------- |
| `--color-canvas` | `#F1F1F3` | `#0F0F10` | Application workspace |
| `--color-surface` | `#FFFFFF` | `#19191A` | Cards, drawers, dialogs |
| `--color-surface-muted` | `#F7F7F8` | `#28282A` | Nested panels and table headers |
| `--color-text-primary` | `#19191A` | `#FFFFFF` | Primary content |
| `--color-text-secondary` | `#3D3D42` | `#D1D1D6` | Supporting content |
| `--color-text-muted` | `#6B6B73` | `#A7A7AF` | Metadata; contrast must be verified at actual size |
| `--color-border` | `#E3E3E7` | `#3A3A3D` | Dividers and neutral outlines |
| `--color-brand-action` | `#0A33FF` | `#90AEF4` | Primary action and active state |
| `--color-on-brand` | `#FFFFFF` | `#0A0A0A` | Content on brand action |
| `--color-focus-ring` | `#0A33FF` | `#90AEF4` | Keyboard focus |
| `--color-semantic-success` | `#248061` | `#6FD6AE` | Positive values with text/icon support |
| `--color-semantic-warning` | `#8A5300` | `#F3BE5B` | Caution and incomplete verification |
| `--color-semantic-danger` | `#C93637` | `#FF8A8B` | Error and destructive meaning |
| `--color-semantic-info` | `#0A33FF` | `#90AEF4` | Informational emphasis |
## Colors
- The initial theme follows `prefers-color-scheme` unless the user has explicitly selected light or dark. The explicit choice is persisted locally because it is presentation-only and non-sensitive.
- Theme selection is applied before first paint to avoid a light/dark flash. The toggle remains keyboard and screen-reader operable and exposes its current state.
- Semantic success, warning, danger, information, chart, hover, pressed, disabled, skeleton, scrim, and elevation tokens have separately tested light/dark values before delivery.
- Every data series maintains at least 3:1 non-text contrast against its chart surface. Text maintains WCAG 2.2 AA, and status/trend meaning never depends on color alone.
The palette is a neutral financial canvas with one decisive blue voice, a very small yellow identity accent, and restrained semantic color.
### Typography
### Primary
- Required Persian UI family: licensed **Iran Yekan X Professional** (`IRANYekanX`), matching the current ZarinPal panel's `IRANYekanXVFaNumVF` variable font.
- Required asset: the Persian-number (`FaNum`) variable webfont in WOFF2, covering the `wght` axis from 100–1000. The product uses 400 for body/value text, 500 for labels and controls, 600 for emphasis, and 700 for section/page headings. No separate static weights are required when the variable file is supplied.
- The purchased font is a Git-tracked project asset. Keep `static/fonts/IRANYekanXVFaNumVF.woff2` and its required adjacent `static/fonts/FontLicense.txt` in the frontend repository so local, CI, and Docker builds use the same reviewed bytes without downloading from ZarinPal or another CDN.
- The tracked font is the only approved primary font asset for implementation. Do not replace, rename, subset, regenerate, or add other files from the purchased package without updating this specification and confirming license compliance.
- Runtime fallback while the licensed asset is unavailable: `Tahoma`, system sans-serif. The fallback is for resilience, not the approved visual baseline.
- Evidence: inspection of the current [ZarinPal panel](https://next.zarinpal.com/beta/panel) found `IRANYekanXVFaNumVF` declared for weights 100–1000. The licensed [Iran Yekan X product page](https://fontiran.com/fonts/iranyekan) identifies the professional package as the option containing all weights, the variable font, Persian-number assets, and WOFF/WOFF2 webfonts.
- Use tabular numerals for tables/KPIs where supported.
- Body: 14–16 px, line-height 1.7–1.9 for Persian.
- Labels/meta: 12–14 px; never below 12 px.
- Section heading: 20–24 px, weight 700.
- Page heading: 28–32 px desktop, 24–28 px mobile, weight 700–800.
- Avoid ultralight weights and excessive bolding. Keep Persian punctuation and half-spaces correct.
- **ZarinPal Action Blue:** Drives selected range controls, active navigation, interactive chart marks, links, and primary focus. Its night-theme companion is brighter so the same roles stay legible on near-black surfaces.
- **Action Tint:** Supports active navigation and lightly emphasized information without turning whole cards blue.
### Spacing, shape, elevation
### Secondary
- Base spacing unit: 4 px; common sequence `4, 8, 12, 16, 24, 32, 48, 64`.
- Control height: 40 px desktop; interactive touch target at least 44×44 px on touch layouts.
- Card radius: 8 px for dense dashboard cards and tables; 12 px only for spacious explanatory or empty-state surfaces.
- Button/input radius: 6–8 px; pill radius only for compact statuses and segmented controls.
- Card border is preferred over shadow. Default elevation is subtle (`0 1px 3px rgb(25 25 26 / 0.08)`); overlays may use stronger elevation.
- Content width is bounded for scanning; tables/charts may use the full available dashboard column.
- **ZarinPal Yellow:** Appears in the supplied wordmark and text selection. It is an identity spark, not a general dashboard accent or text color.
## Layout and responsiveness
### Tertiary
### Desktop (≥ 1024 px)
- **Success:** Successful payments and ready states.
- **Warning:** Incomplete verification, suppression, or caution.
- **Danger:** Errors, failed states, and destructive meaning.
- Fixed right sidebar, 256 px expanded and 72 px collapsed, with icon-only collapse matching the supplied panel reference.
- A 56 px top utility bar spans the viewport and carries the supplied ZarinPal wordmark, sidebar trigger, theme, notification, and account actions.
- Main content uses a centered, fluid container with 24–32 px gutters.
- Overview uses a 12-column grid. Primary analytics get 8 columns; secondary summaries get 4 where useful.
- Do not create large empty regions merely to mimic the reference screenshot; use the density required by real content.
### Neutral
### Tablet (768–1023 px)
- **Workspace:** A cool, near-white light canvas and a near-black night canvas frame the application.
- **Surface:** Cards, navbar, sidebar, menus, and overlays sit one tonal step above the workspace.
- **Nested Surface:** Table headers, secondary controls, progress tracks, and quiet hover states use the muted neutral layer.
- **Ink and Muted Ink:** Strong figures and headings use high-contrast ink; explanations, metadata, and control labels use muted ink.
- **Border:** A cool 1px line is the default separator between data surfaces and within tables.
- Sidebar becomes an overlay drawer from the right.
- Two-column cards collapse according to content, not arbitrary device labels.
- Tables may scroll horizontally only after priority columns and responsive alternatives are considered.
### Named Rules
### Mobile (< 768 px)
**The One Blue Voice Rule.** Blue signals an action, current scope, active navigation, focus, or the primary data series; it does not wash large passive regions.
- Single-column content, 16 px outer gutter.
- Right-side navigation drawer traps focus, closes with Escape/backdrop, restores focus to its trigger, and locks background scroll.
- KPI pairs stack; charts remain at least 240 px tall and expose an accessible data table/summary.
- Sticky actions must not cover content or browser safe areas.
**The Meaning Needs Language Rule.** Success, warning, and danger color reinforce a visible label, icon, or explanation and never carry status alone.
## Navigation and information architecture
## Typography
Authenticated routes:
**Display Font:** Iran Yekan X Professional FaNum variable (IRANYekanX), with Tahoma and sans-serif fallbacks.
1. ورود (login)
2. پیشخوان (`/dashboard`)
3. تراکنش‌ها (`/transactions` and `/transactions/[sessionId]`)
4. عملکرد پرداخت (`/payments`)
5. کارمزد و درآمد (`/fees`)
6. مقایسه با هم‌گروه‌ها (`/peers`)
7. کیفیت داده / درباره شاخص‌ها (`/methodology`)
8. حساب کاربری / خروج
**Body Font:** Iran Yekan X Professional FaNum variable (IRANYekanX), with Tahoma and sans-serif fallbacks.
Navigation labels should describe merchant tasks, not internal database concepts. Active location is conveyed by color plus a bar/background and `aria-current`.
**Character:** One Persian UI family keeps the workspace cohesive and highly legible. Weight, size, tabular figures, and spacing create hierarchy; ornamental display typography is outside this product world.
## Core component contracts
### Hierarchy
- **App shell:** sidebar/drawer, utility header, page title, merchant identity, analytics progress/status. First access idempotently starts a run, then opens its authenticated SSE stream; the page reveals overview, payment performance, fees, and peer comparison independently as each becomes ready.
- **Theme control:** accessible light/dark toggle in the utility area, initialized from the persisted choice or system preference and applied without a first-paint flash.
- **Metric card:** label, formatted value/unit, comparison and period, semantic trend, definition link, independent queued/calculating/ready/failed states.
- **Chart card:** title, period/filter controls, concise takeaway, plot, legend, accessible tabular alternative, provenance link, and section-local loading/error behavior.
- **Insight card:** deterministic finding, numeric evidence, suggested merchant action, confidence/limitation; AI-generated copy is visibly labeled later.
- **Data table:** semantic headers, server pagination/sorting, row actions named for screen readers, responsive priority, empty/error states.
- **Analytics export:** eligible merchant-owned detail views expose an explicit CSV export action that applies the current approved filters and clearly states period, unit, generation state, and failure/retry behavior. Aggregate-only cards and peer data are not exported unless separately specified.
- **Filter bar:** URL-backed state, explicit apply/reset behavior, and approved daily, weekly, Jalali-month, and Persian-season periods with unambiguous comparison baselines.
- **Status badge:** text plus icon/shape; color reinforces but never carries meaning alone.
- **Feedback:** inline field errors, page-level error boundary, skeleton only when layout is known, retry action for recoverable failures.
- **Rate-limited state:** explain when an analytics start, stream, or manual retry is temporarily limited; preserve ready content, honor the server `Retry-After`, and never run a client-side retry loop.
- **Page Heading** (800, 20px; 24px from 640px, 1.4): Routed page identity at the top of the workspace.
- **Section Heading** (700, 20px; 24px from 640px, 1.5): Major analytics modules and methodology groups.
- **Metric** (800, 24px, 1.25): KPI values, amounts, rates, and transaction totals; use tabular numerals.
- **Body** (400, 15px, 1.8): Default interface copy and longer Persian explanations.
- **Supporting** (400, 14px, usually 1.75): Descriptions, filters, table content, and insight summaries.
- **Label** (500–600, 12px, 1.5): Metadata, compact controls, badges, terminal context, and chart annotations; never go below 11px, which is reserved for the two-line terminal identity in the desktop sidebar.
## Data visualization
### Named Rules
- Lead with a written takeaway and exact KPI; charts support the conclusion rather than decorate it.
- Default comparisons: current period vs previous equivalent period and merchant vs eligible merchants in the same category and selected period. Peer cards show the merchant-weighted cohort median and the active merchant's performance percentile; each peer contributes one value regardless of transaction volume. A higher displayed percentile always means better, so processing-time and fee-rate ranks are direction-adjusted. Peer values appear only when at least 10 other merchants and 1,000 combined peer sessions contribute; otherwise show a clear insufficient-data state.
- Always show unit, timezone/calendar, exact reporting period, comparison baseline, sample size, freshness, and missing-data caveat where material.
- Success/failure colors remain semantic and consistent. Never use a rainbow categorical palette for ordered values.
- Tooltips are keyboard reachable and not the sole location of important values.
- Charts have a textual summary and downloadable/visible data table when practical.
- Zero, unavailable, suppressed, and not-yet-calculated are visually and linguistically distinct.
- A deterministic insight card appears only with at least 100 relevant sessions and a qualifying change: at least 3 percentage points for rates or 20 percent for amounts/counts. Smaller comparisons remain visible in metrics without being promoted into an action claim.
- A ready section appears immediately without layout-shifting or blocking on unfinished siblings. Overall progress remains understandable without relying on animation.
- Source statuses `Paid` and `Verified` both count as successful bank payments because funds were deducted. Keep them visually distinguishable: `Paid` means merchant verification is incomplete and is an actionable gap; `Verified` is finalized.
- Present `fee_amount_rial` as the merchant's real fee, summed once per session. Never multiply it by retry attempts or label it as obfuscated/relative-only in the product.
- First load selects the latest complete Jalali month containing data and compares only with the immediately previous equivalent period. An absent baseline has an explicit unavailable state.
- Overview leads with bank-paid amount, bank-paid session count, success rate, and verification completion. Retry, timing, and fee modules follow in that order of detail.
- Drill-down tables for unverified-paid, failed, and retried sessions are read-only. They never reveal payer tokens or peer rows; `NoAttempt` and `InBank` are stage labels, not invented failure causes.
**The Persian Measure Rule.** Merchant-facing amounts, counts, percentages, dates, and chart values use Persian digits; technical identifiers remain canonical Latin inside isolated left-to-right spans.
## Motion
**The Weight Before Scale Rule.** The dense workspace creates hierarchy first with 500, 700, and 800 weights, then with a compact 12/14/15/20/24px size ladder.
- Functional motion only: drawer/dialog transitions, focus/selection feedback, and short chart/filter state changes.
- Typical duration 120–220 ms; enter may be slightly slower than exit.
- Prefer opacity and transform. Avoid layout-heavy animation and dashboard-wide scroll reveals.
- Honor `prefers-reduced-motion` by removing nonessential movement.
- No third-party scroll-reveal library is planned for the authenticated panel. Reconsider only for a separately scoped marketing surface.
## Layout
## Persian, direction, and formatting
The application is a bounded RTL shell. A fixed 56px top navbar spans the viewport. At 768px and wider, a physical-right sidebar is 256px expanded and 72px collapsed; its top card holds merchant identity and terminal selection. The content column is centered at a maximum of 1220px with 16px gutters, increasing to 24px at 640px and 32px at 1024px.
- Root UI is `lang="fa" dir="rtl"`.
- Technical identifiers, URLs, hashes, and mixed Latin values use isolated LTR spans (`dir="ltr"`) to prevent bidi corruption.
- Merchant-facing numbers, percentages, chart axes, counts, dates, and monetary values use Persian digits through locale-aware formatting (`fa-IR`). Technical identifiers and machine-facing values retain their canonical Latin representation inside isolated LTR spans.
- Display money in **Rial only**, with an explicit `ریال` unit. Do not silently convert to Toman or mix the two units.
- Use `Intl.NumberFormat`/`Intl.DateTimeFormat`. Display merchant-facing dates in the Jalali calendar and `Asia/Tehran`; API instants remain ISO-8601 UTC.
- Do not concatenate translated fragments. Write complete Persian strings with correct نیم‌فاصله.
- Authentication errors must not reveal whether a username exists.
The overview begins with a compact title block and full-width range selector. KPI cards form two columns from 600px and four columns from 1024px; the payment-history chart follows as the dominant full-width surface. Deeper pages reuse the same header, filters, bounded cards, and horizontally scrollable tables.
## Accessibility and interaction
Below 768px, the sidebar yields to the navbar. Terminal selection moves into the top bar beside the wordmark and theme utility, while content uses 16px gutters. The account initial is intentionally hidden below 640px so terminal scope and navigation never overflow. KPI cards remain stacked on phone widths and become a compact 2×2 set from 600px, preserving scan order while bringing the payment-history chart into view sooner.
- WCAG 2.2 AA target.
- Visible `:focus-visible` ring on every interactive element.
- Semantic buttons/links/forms before ARIA; icons have accessible names or are hidden when decorative.
- All functionality is keyboard operable. Dialogs/drawers manage focus correctly.
- Validation is associated with fields and announced; asynchronous updates use restrained live regions.
- Loading indicators expose status text; avoid endless skeletons.
- Destructive or consequential actions require clear confirmation and recovery where possible.
**The Scope Before Evidence Rule.** Terminal and period scope must be visible before KPIs, charts, tables, or attempt history; desktop terminal scope belongs in the right sidebar and narrow-screen terminal scope belongs in the navbar.
## Current decisions and change log
## Elevation & Depth
| Version | Date | Decision |
| ------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 0.4.0 | 2026-08-20 | Adopted the supplied current ZarinPal panel as the closer interaction reference: added the top wordmark bar, 256/72 px collapsible right sidebar, less-rounded 8 px data surfaces, routed sections, URL-backed period/terminal filters, payment-history chart, filtered transaction table, routed attempt history, formula help, and supportive human-readable insights. |
| 0.3.3 | 2026-08-20 | Replaced merchant/cohort revision freshness language with the approved immutable completed-import dataset snapshot. This changes trace labels only; cache age still does not imply stale data. |
| 0.3.2 | 2026-08-20 | Made the licensed Iran Yekan X FaNum variable WOFF2 and its required license file Git-tracked frontend assets, eliminating a separate private build-injection path. |
| 0.3.1 | 2026-08-20 | Verified the purchased Iran Yekan X Professional FaNum variable assets and required a private, reproducible injection path for public repositories. Added merchant-owned detail CSV export as an MVP component contract. |
| 0.3.0 | 2026-08-20 | Approved Iran Yekan X Professional FaNum variable typography, Persian digits, Rial-only values, and first-class light/dark semantic themes. Added the deterministic insight eligibility threshold. |
| 0.2.4 | 2026-08-20 | Fixed the static-demo default period, comparison fallback, overview KPI priority, and safe read-only drill-down scope; added accurate `NoAttempt`/`InBank` stage language from the CTO data guide. |
| 0.2.3 | 2026-08-20 | Kept authentication UX intentionally demo-sized: generic and throttled username/password login now, with a clean replacement boundary for ZarinPal SSO after acceptance. |
| 0.2.2 | 2026-08-20 | Defined backend-owned session UX: authentication tokens remain inaccessible to the frontend, and expiry/revocation returns the user to login with a generic, accessible message. |
| 0.2.1 | 2026-08-20 | Defined same-category peer cards as an equal-per-merchant median plus direction-aware percentile. This prevents the dataset's largest merchants from dominating what should represent a typical peer. |
| 0.2.0 | 2026-08-20 | Corrected payment lifecycle semantics from CTO guidance: `Paid` and `Verified` are bank-payment successes, `Paid` remains unverified, and session-level fees are real Rial amounts. |
| 0.1.9 | 2026-08-20 | Added rate-limit UX, 15-second SSE heartbeat/recovery, one-hour replay, and failed-section-only manual retry. |
| 0.1.8 | 2026-08-20 | Fixed the four parallel section ids, 30-day cache, idempotent start/SSE flow, and section timeout/retry behavior. |
| 0.1.7 | 2026-08-20 | Moved analytics results to high-TTL Redis and defined partial, parallel section delivery through authenticated SSE. |
| 0.1.6 | 2026-08-20 | Replaced time-based analytics freshness with lazy first-access calculation and revision-based merchant/cohort cache states; defined Saturday–Friday weeks. |
| 0.1.5 | 2026-08-20 | Removed holiday/event reporting; limited period controls to daily, weekly, Jalali-month, and Persian-season views. |
| 0.1.2 | 2026-08-20 | Added the insufficient-data state and visibility threshold for anonymized peer comparisons. |
| 0.1.1 | 2026-08-20 | Fixed merchant-facing time presentation to the Jalali calendar in `Asia/Tehran`, with explicit UTC API instants. |
| 0.1.0 | 2026-08-20 | Established ZarinPal-inspired RTL foundation from supplied public/panel screenshots; fixed SvelteKit/Svelte/TypeScript/Tailwind stack; defined responsive shell, core components, accessibility, chart, and motion rules. |
The system is flat by default. Workspace, card, nested surface, and border tones establish hierarchy; the repeated low shadow is only a small reinforcement on cards and controls. The fixed top bar uses an even quieter separator shadow, while tooltips, sheets, and dialogs can use stronger overlay elevation.
Any material token/component/interaction change increments this version and adds a row describing why it changed. A surprising or difficult-to-reverse change also receives an ADR.
### Shadow Vocabulary
## Open design decisions before implementation planning
- **Surface Low** (0 1px 2px 0 rgb(0 0 0 / 0.05)): KPI cards, charts, filter bars, and repeated dashboard containers.
- **Navbar Hairline** (0 1px 2px rgba(0, 0, 0, 0.03)): Fixed top bar only.
- **Overlay High** (0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10)): Floating chart tooltip and modal-level content only.
None. The approved licensed font and its license file are already tracked at the specified frontend paths.
### Named Rules
**The Border Carries Structure Rule.** Use the 1px border and tonal layers for resting structure; reserve conspicuous shadows for content that actually floats above the shell.
## Shapes
The form language is low-radius and precise. Data cards, filter bars, inputs, selects, and standard buttons resolve around 8px; dense internal controls and active navigation use 6–7px. Status badges may be pills because their silhouette communicates compact state. Avatars and small identity marks may be circular. Large soft containers are limited to modal-scale overlays or empty states and must not migrate into the dense dashboard.
**The Data Surface Radius Rule.** Repeated financial surfaces use 8px corners and a visible border; rounding must not make tables, KPIs, or filters feel like marketing cards.
## Components
### Buttons
- **Shape:** Compact, bordered or filled, with 6–8px corners and 32–36px desktop height; touch layouts raise interactive targets to at least 44px.
- **Primary:** Action blue with white content in light mode and dark ink on the brighter night action blue.
- **Hover / Focus:** Hover changes tone; active state moves down by 1px where the action is press-like. Keyboard focus uses a visible 3px semantic blue ring. State transitions stay around 160ms.
- **Ghost:** Transparent at rest, muted-neutral on hover, used for inactive range choices and utility icons.
### Chips
- **Style:** Compact 20px badges use 12px medium text. Semantic badges may be pill-shaped; date and percentile chips use the tighter control radius.
- **State:** Selected filters use filled blue plus aria-pressed; ready, warning, and failure labels pair color with explicit text.
### Cards / Containers
- **Corner Style:** Dense 8px corners.
- **Background:** Theme surface over theme workspace; lightly emphasized insight cards may use an action or semantic tint.
- **Shadow Strategy:** Surface Low only, with border/ring structure always present.
- **Border:** One cool neutral pixel; semantic borders are translucent accents on error or insight surfaces.
- **Internal Padding:** 20px is the desktop KPI rhythm and contracts to 16px on phones; 24px supports explanatory cards, and charts may use 12px horizontally on narrow screens.
### Inputs / Fields
- **Style:** Transparent or workspace-toned field, 1px input border, 6–8px radius, 32–36px desktop height, 44px touch height, and compact 12–14px text. Terminal and status selects use the same RTL popover primitive rather than the browser-native menu.
- **Focus:** Border shifts to the ring color and gains a visible 3px half-opacity blue ring.
- **Error / Disabled:** Error uses danger border plus ring; disabled fields reduce opacity and preserve legible text.
### Navigation
- **Desktop:** The fixed right sidebar is white/near-black with 44px task rows, 19px outline icons, 13px labels, and an action-tint active background with blue content.
- **Mobile:** The fixed top bar retains the wordmark, terminal selector, theme action, and menu trigger. The account initial appears from 640px and is intentionally omitted on narrower phones.
- **State:** Current routes use a background plus blue text and remain identified with aria-current or equivalent active state.
### KPI Card
The signature KPI card pairs a muted label and optional formula help with a semantic 20px outline icon, a 24px extra-bold tabular value, and one short 12px explanatory line. It uses 16px padding on phones and 20px above that, and may lift 2px over 200ms on hover without changing its information hierarchy.
### Reporting Scope Filter
Daily, Saturday–Friday weekly, Jalali-month, and Persian-season controls share one URL-backed bar. Exact period selection opens a compact RTL popover: a four-digit Jalali year input, month navigation plus calendar grid for day/week, whole-week hover and selection, and named month/season choices. Transaction status and a two-thumb RTL amount range join the same bar after a divider. Backend-offered complete periods are the selectable authority.
### Loading State
Scope changes immediately replace data-dependent cards, tables, detail panels, and charts with geometry-matched skeletons. The title, selected scope, and navigation stay visible; no routed data surface collapses into an empty page while the API is working.
### Payment-History Chart
The signature chart is a blue line with outlined points, a low-opacity blue area, and dashed neutral gridlines. It follows a written title and reporting-period chip, provides focusable points with spoken values, and animates its initial trace once over 900ms while reduced-motion collapses the effect.
### Transaction Table
The table stays inside a bordered 8px surface, receives its compact filters from the shared page bar, uses aligned 14px rows and 12px identifiers/status, and exposes semantic detail links with a pointer and neutral row hover. Verified badges are green; Paid remains neutral gray. Narrow viewports may scroll the table horizontally after surrounding filters and summaries have reflowed.
### Fee Share and Comparative Progress
The fee page pairs numeric KPIs with a compact circular share chart. Peer progress bars move from rose through amber and sky to emerald as the direction-adjusted percentile improves. Retry guidance uses amber, sky, or emerald surfaces and changes its supportive sentence at tested recovery/sample thresholds.
## Do's and Don'ts
### Do:
- **Do** keep the terminal selector in the physical-right desktop sidebar and in the narrow-screen navbar.
- **Do** lead with the selected period, four ordered KPIs, a plain-language takeaway, and then inspectable chart or table evidence.
- **Do** use action blue for the current scope, primary series, focus, and actionable controls.
- **Do** keep cards at 8px with a 1px border and use 16px KPI padding on phones, increasing to 20px from 640px.
- **Do** preserve Persian digits, Rial units, Jalali dates, RTL flow, and left-to-right isolation for technical identifiers.
- **Do** provide text, icons, focus states, and accessible alternatives anywhere color or a chart carries meaning.
### Don't:
- **Don't** turn yellow into a broad dashboard fill or use it for body text on light surfaces.
- **Don't** use pill geometry for cards, tables, inputs, or ordinary navigation rows.
- **Don't** add decorative gradients, oversized hero type, or marketing-page whitespace to authenticated analytics routes.
- **Don't** use shadow as the primary boundary between resting financial surfaces.
- **Don't** animate the page as a spectacle; keep motion to state feedback, the shell, overlays, and the chart trace, and honor reduced motion.
- **Don't** hide status, scope, units, formulas, or important chart values behind color or pointer-only interaction.

View File

@@ -274,3 +274,13 @@ select {
@apply font-sans;
}
}
@layer base {
a[href],
button:not(:disabled),
select:not(:disabled),
summary,
[role='button']:not([aria-disabled='true']) {
cursor: pointer;
}
}

View File

@@ -0,0 +1,173 @@
<script lang="ts">
import SlidersHorizontalIcon from '@lucide/svelte/icons/sliders-horizontal';
import * as Popover from '$lib/components/ui/popover';
import { formatValue } from '$lib/format';
const LIMIT = 10_000_000_000;
const STEP = 100_000;
let {
minAmount,
maxAmount,
onchange,
}: {
minAmount: number | null;
maxAmount: number | null;
onchange: (minAmount: number | null, maxAmount: number | null) => void;
} = $props();
let open = $state(false);
let localMin = $state(0);
let localMax = $state(LIMIT);
const startPercent = $derived((localMin / LIMIT) * 100);
const endPercent = $derived((localMax / LIMIT) * 100);
const hasFilter = $derived(minAmount !== null || maxAmount !== null);
$effect(() => {
localMin = Math.max(0, Math.min(minAmount ?? 0, LIMIT));
localMax = Math.max(localMin, Math.min(maxAmount ?? LIMIT, LIMIT));
});
function updateMin(value: number) {
localMin = Math.min(value, localMax - STEP);
}
function updateMax(value: number) {
localMax = Math.max(value, localMin + STEP);
}
function apply() {
onchange(localMin === 0 ? null : localMin, localMax === LIMIT ? null : localMax);
open = false;
}
</script>
<Popover.Root bind:open>
<Popover.Trigger
class="border-input bg-background hover:bg-muted focus-visible:ring-ring inline-flex min-h-11 cursor-pointer items-center gap-1.5 rounded-md border px-3 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none sm:min-h-9"
aria-label="فیلتر بازه مبلغ"
aria-pressed={hasFilter}
>
<SlidersHorizontalIcon class={hasFilter ? 'text-primary size-4' : 'text-muted-foreground size-4'} />
مبلغ
{#if hasFilter}<span class="bg-primary size-1.5 rounded-full" aria-hidden="true"></span>{/if}
</Popover.Trigger>
<Popover.Content align="end" class="w-[min(22rem,calc(100vw-2rem))] gap-4 p-4" dir="rtl">
<div>
<h3 class="font-semibold">بازه مبلغ تراکنش</h3>
<p class="text-muted-foreground mt-1 text-xs">دو نشانگر را برای تعیین کمینه و بیشینه جابه‌جا کنید.</p>
</div>
<div class="flex items-center justify-between gap-3 text-xs tabular-nums">
<output class="bg-muted rounded-md px-2 py-1">از {formatValue(localMin)} ریال</output>
<output class="bg-muted rounded-md px-2 py-1">تا {formatValue(localMax)} ریال</output>
</div>
<div class="range-shell" style={`--range-start:${startPercent}%; --range-end:${endPercent}%`} dir="rtl">
<div class="range-track" aria-hidden="true"></div>
<input
class="range-input"
type="range"
min="0"
max={LIMIT}
step={STEP}
value={localMin}
aria-label="حداقل مبلغ"
oninput={(event) => updateMin(Number(event.currentTarget.value))}
/>
<input
class="range-input"
type="range"
min="0"
max={LIMIT}
step={STEP}
value={localMax}
aria-label="حداکثر مبلغ"
oninput={(event) => updateMax(Number(event.currentTarget.value))}
/>
</div>
<div class="flex gap-2">
<button
type="button"
class="bg-primary text-primary-foreground min-h-11 flex-1 cursor-pointer rounded-md px-3 text-sm font-semibold sm:min-h-9"
onclick={apply}>اعمال بازه</button
>
<button
type="button"
class="border-input hover:bg-muted min-h-11 cursor-pointer rounded-md border px-3 text-sm sm:min-h-9"
onclick={() => {
localMin = 0;
localMax = LIMIT;
onchange(null, null);
open = false;
}}>پاک‌کردن</button
>
</div>
</Popover.Content>
</Popover.Root>
<style>
.range-shell {
position: relative;
height: 2.75rem;
}
.range-track {
position: absolute;
inset: 1.25rem 0 auto;
height: 0.25rem;
border-radius: 999px;
background: linear-gradient(
to left,
var(--muted) 0 var(--range-start),
var(--primary) var(--range-start) var(--range-end),
var(--muted) var(--range-end) 100%
);
}
.range-input {
pointer-events: none;
position: absolute;
inset: 0;
width: 100%;
appearance: none;
background: transparent;
}
.range-input:first-of-type {
z-index: 3;
}
.range-input:last-of-type {
z-index: 2;
}
.range-input::-webkit-slider-thumb {
pointer-events: auto;
width: 1.25rem;
height: 1.25rem;
cursor: grab;
appearance: none;
border: 3px solid var(--background);
border-radius: 999px;
background: var(--primary);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 45%, transparent);
}
.range-input::-moz-range-thumb {
pointer-events: auto;
width: 1.25rem;
height: 1.25rem;
cursor: grab;
border: 3px solid var(--background);
border-radius: 999px;
background: var(--primary);
}
.range-input:focus-visible::-webkit-slider-thumb {
outline: 3px solid color-mix(in srgb, var(--primary) 28%, transparent);
outline-offset: 2px;
}
</style>

View File

@@ -17,13 +17,15 @@
import type { AnalyticsRun } from '$lib/api/client';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Skeleton } from '$lib/components/ui/skeleton';
import { useDashboardContext } from '$lib/dashboard-context.svelte';
import { parseDashboardFilters } from '$lib/dashboard-filters';
import { formatMoney, formatMilliseconds, formatPercent, formatValue } from '$lib/format';
import { describePercentile, formulas } from '$lib/insights';
import { describePercentile, formulas, peerProgressTone, retryOpportunity } from '$lib/insights';
import { formatPeerMetric, peerMetricLabel } from '$lib/peer-metrics';
import KpiCard from './KpiCard.svelte';
import FeeShareDonut from './FeeShareDonut.svelte';
import PageHeader from './PageHeader.svelte';
import PaymentHistoryChart from './PaymentHistoryChart.svelte';
@@ -54,7 +56,7 @@
loading = true;
error = '';
try {
const next = await dashboard.runFor(filters.range, filters.terminal);
const next = await dashboard.runFor(filters.range, filters.terminal, filters.period);
if (sequence === loadSequence) run = next;
} catch {
if (sequence === loadSequence) error = 'گزارش این بازه آماده نشد. لطفاً دوباره تلاش کنید.';
@@ -66,12 +68,40 @@
$effect(() => {
filters.range;
filters.terminal;
filters.period;
void loadRun();
});
function percentile(value: string) {
return Math.max(0, Math.min(100, Number(value)));
}
function peerProgressClass(value: string) {
const tone = peerProgressTone(percentile(value));
if (tone === 'leading') return 'bg-emerald-500';
if (tone === 'strong') return 'bg-sky-500';
if (tone === 'developing') return 'bg-amber-500';
return 'bg-rose-500';
}
function retryOpportunityClass(tone: ReturnType<typeof retryOpportunity>['tone']) {
if (tone === 'excellent') {
return {
card: 'border-emerald-200/70 bg-emerald-50/60 dark:border-emerald-900 dark:bg-emerald-950/15',
icon: 'text-emerald-600 dark:text-emerald-400',
};
}
if (tone === 'positive') {
return {
card: 'border-sky-200/70 bg-sky-50/60 dark:border-sky-900 dark:bg-sky-950/15',
icon: 'text-sky-600 dark:text-sky-400',
};
}
return {
card: 'border-amber-200/80 bg-amber-50/60 dark:border-amber-900 dark:bg-amber-950/15',
icon: 'text-amber-600 dark:text-amber-400',
};
}
</script>
<svelte:head><title>{titles[kind][0]} | تحلیل زرین‌پال</title></svelte:head>
@@ -85,7 +115,10 @@
<Skeleton class="mt-4 h-80 rounded-lg" />
{:else if error}
<Card class="border-destructive/30 rounded-lg"
><CardContent class="p-8 text-center"><p role="alert">{error}</p></CardContent></Card
><CardContent class="space-y-4 p-8 text-center"
><p role="alert">{error}</p>
<Button variant="outline" onclick={loadRun}><RefreshCcwIcon /> تلاش دوباره</Button></CardContent
></Card
>
{:else if run}
{@const overview = run.sections.overview}
@@ -94,7 +127,7 @@
{@const peers = run.sections.peerComparison}
{#if kind === 'overview' && overview.state === 'READY'}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div class="grid gap-3 min-[600px]:grid-cols-2 sm:gap-4 lg:grid-cols-4">
<KpiCard
label="پرداخت موفق"
value={formatValue(overview.data.bankPaidSessions.value)}
@@ -162,7 +195,12 @@
</CardContent>
</Card>
{:else if kind === 'payments' && performance.state === 'READY'}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{@const opportunity = retryOpportunity(
Number(performance.data.retryRecoveryRate.value ?? 0),
performance.data.retriedSessions.value,
)}
{@const opportunityClass = retryOpportunityClass(opportunity.tone)}
<div class="grid gap-3 min-[600px]:grid-cols-2 sm:gap-4 lg:grid-cols-4">
<KpiCard
label="رسیده به بانک"
value={formatValue(performance.data.reachedBankSessions.value)}
@@ -207,26 +245,26 @@
{/each}
</CardContent>
</Card>
<Card class="rounded-lg border-emerald-200/70 bg-emerald-50/50 shadow-xs dark:bg-emerald-950/10">
<Card class={`rounded-lg shadow-xs ${opportunityClass.card}`}>
<CardContent class="p-6">
<LightbulbIcon class="mb-5 size-7 text-emerald-600" />
<h2 class="font-bold">یک فرصت روشن</h2>
<LightbulbIcon class={`mb-5 size-7 ${opportunityClass.icon}`} />
<h2 class="font-bold">{opportunity.title}</h2>
<p class="text-muted-foreground mt-2 text-sm leading-7">
{formatValue(performance.data.retriedSessions.value)} مشتری دوباره تلاش کرده‌اند و نرخ بازیابی آن‌ها
{formatPercent(performance.data.retryRecoveryRate.value)} بوده است. جزئیات تراکنش‌های ناموفق می‌تواند
نقاط بهبود مسیر را نشان دهد.
{formatPercent(performance.data.retryRecoveryRate.value)} بوده است. {opportunity.copy}
</p>
</CardContent>
</Card>
</div>
{:else if kind === 'fees' && fees.state === 'READY'}
<div class="grid gap-4 md:grid-cols-3">
<div class="grid gap-4 min-[600px]:grid-cols-2 xl:grid-cols-4">
<KpiCard
label="کارمزد پرداخت‌شده"
value={formatMoney(fees.data.paidFeeAmountRial.value)}
note="مجموع کارمزد ثبت‌شده برای پرداخت‌های این بازه"
icon={HandCoinsIcon}
/>
<FeeShareDonut value={fees.data.paidFeeRate.value} />
<KpiCard
label="مبلغ پرداخت موفق"
value={formatMoney(fees.data.bankPaidAmountRial.value)}
@@ -280,7 +318,7 @@
</div>
<div class="bg-muted mt-5 h-2 overflow-hidden rounded-sm" dir="ltr">
<div
class="bg-primary h-full rounded-sm transition-[width] duration-700"
class={`h-full rounded-sm transition-[width] duration-700 ${peerProgressClass(metric.merchantPerformancePercentile)}`}
style={`width:${percentile(metric.merchantPerformancePercentile)}%`}
></div>
</div>

View File

@@ -4,7 +4,6 @@
import ActivityIcon from '@lucide/svelte/icons/activity';
import ArrowUpDownIcon from '@lucide/svelte/icons/arrow-up-down';
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import ChartNoAxesCombinedIcon from '@lucide/svelte/icons/chart-no-axes-combined';
import HandCoinsIcon from '@lucide/svelte/icons/hand-coins';
import LogOutIcon from '@lucide/svelte/icons/log-out';
import MenuIcon from '@lucide/svelte/icons/menu';
@@ -15,11 +14,11 @@
import WalletCardsIcon from '@lucide/svelte/icons/wallet-cards';
import BrandLogo from '$lib/components/BrandLogo.svelte';
import TerminalSelect from '$lib/components/TerminalSelect.svelte';
import { Button } from '$lib/components/ui/button';
import * as Sidebar from '$lib/components/ui/sidebar';
import { useDashboardContext } from '$lib/dashboard-context.svelte';
import { setDashboardQuery } from '$lib/dashboard-filters';
import { formatValue } from '$lib/format';
let { children } = $props();
const dashboard = useDashboardContext();
@@ -37,7 +36,7 @@
const url = new URL(page.url);
url.pathname = path;
for (const key of [...url.searchParams.keys()]) {
if (!['range', 'terminal'].includes(key)) url.searchParams.delete(key);
if (!['range', 'period', 'terminal'].includes(key)) url.searchParams.delete(key);
}
return `${url.pathname}${url.search}`;
}
@@ -49,9 +48,9 @@
);
}
function terminalChanged(event: Event) {
function terminalChanged(value: string) {
const next = setDashboardQuery(page.url, {
terminal: (event.currentTarget as HTMLSelectElement).value,
terminal: value,
});
void goto(`${next.pathname}${next.search}`, { keepFocus: true, noScroll: true });
}
@@ -83,19 +82,12 @@
<label class="text-muted-foreground mb-1 block text-[11px]" for="sidebar-terminal-select"
>درگاه فعال گزارش</label
>
<select
<TerminalSelect
id="sidebar-terminal-select"
value={page.url.searchParams.get('terminal') ?? 'all'}
onchange={terminalChanged}
class="border-input bg-background focus:ring-ring/30 h-9 w-full rounded-md border px-2 text-xs outline-none focus:ring-2"
>
<option value="all">همه درگاه‌ها</option>
{#each dashboard.terminals as terminal}
<option value={terminal.terminalId}
>{terminal.displayName} · {formatValue(terminal.sessionCount)}</option
>
{/each}
</select>
terminals={dashboard.terminals}
onValueChange={terminalChanged}
/>
</div>
</Sidebar.Header>
<Sidebar.Content class="px-2 py-3">
@@ -107,11 +99,18 @@
<Sidebar.MenuButton
isActive={isActive(item.href)}
tooltipContent={item.label}
class="data-[active=true]:text-primary h-10 justify-start px-3 text-[13px]"
onclick={() => goto(navUrl(item.href))}
class="data-[active=true]:text-primary h-11 justify-start px-3 text-[13px]"
>
<item.icon class="size-[19px]" strokeWidth={1.8} />
<span>{item.label}</span>
{#snippet child({ props })}
<a
{...props}
href={navUrl(item.href)}
aria-current={isActive(item.href) ? 'page' : undefined}
>
<item.icon class="size-[19px]" strokeWidth={1.8} />
<span>{item.label}</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/each}
@@ -126,11 +125,18 @@
<Sidebar.MenuButton
isActive={isActive('/methodology')}
tooltipContent="روش محاسبه"
class="data-[active=true]:text-primary h-10 justify-start px-3 text-[13px]"
onclick={() => goto(navUrl('/methodology'))}
class="data-[active=true]:text-primary h-11 justify-start px-3 text-[13px]"
>
<BookOpenIcon class="size-[19px]" strokeWidth={1.8} />
<span>روش محاسبه</span>
{#snippet child({ props })}
<a
{...props}
href={navUrl('/methodology')}
aria-current={isActive('/methodology') ? 'page' : undefined}
>
<BookOpenIcon class="size-[19px]" strokeWidth={1.8} />
<span>روش محاسبه</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
@@ -140,7 +146,7 @@
<Sidebar.Footer class="border-sidebar-border border-t p-2">
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton tooltipContent="خروج" class="h-10 px-3" onclick={logout}>
<Sidebar.MenuButton tooltipContent="خروج" class="h-11 px-3" onclick={logout}>
<LogOutIcon class="size-[18px]" />
<span>خروج از حساب</span>
</Sidebar.MenuButton>
@@ -155,27 +161,25 @@
>
<div class="flex w-full items-center justify-between">
<div class="flex items-center gap-2">
<Sidebar.Trigger class="size-9" aria-label="باز و بسته کردن منو"><MenuIcon /></Sidebar.Trigger>
<Sidebar.Trigger class="size-11" aria-label="باز و بسته کردن منو"><MenuIcon /></Sidebar.Trigger>
<BrandLogo />
</div>
<div class="flex items-center gap-1">
<label class="sr-only" for="mobile-terminal-select">درگاه فعال گزارش</label>
<select
<TerminalSelect
id="mobile-terminal-select"
value={page.url.searchParams.get('terminal') ?? 'all'}
onchange={terminalChanged}
class="border-input bg-background h-8 max-w-36 rounded-md border px-2 text-xs md:hidden"
>
<option value="all">همه درگاه‌ها</option>
{#each dashboard.terminals as terminal}
<option value={terminal.terminalId}>{terminal.displayName}</option>
{/each}
</select>
<Button variant="ghost" size="icon" onclick={toggleTheme} aria-label="تغییر پوسته">
terminals={dashboard.terminals}
onValueChange={terminalChanged}
mobile
/>
<Button variant="ghost" size="icon" class="size-11" onclick={toggleTheme} aria-label="تغییر پوسته">
{#if dark}<SunIcon />{:else}<MoonIcon />{/if}
</Button>
<span class="bg-border mx-1 h-6 w-px"></span>
<div class="grid size-8 place-items-center rounded-full bg-zinc-900 text-xs font-bold text-white">
<span class="bg-border mx-1 hidden h-6 w-px sm:block"></span>
<div
class="hidden size-8 place-items-center rounded-full bg-zinc-900 text-xs font-bold text-white sm:grid"
>
{dashboard.session?.user.username.slice(0, 1).toUpperCase()}
</div>
</div>

View File

@@ -30,8 +30,10 @@
background-position: -106px -9px;
}
:global(html.dark) .brand-wordmark {
width: 117px;
height: 34px;
background-position: -15px -84px;
width: 124px;
height: 31px;
background-image: url('/brand/zarinpal-wordmark-dark.png');
background-size: contain;
background-position: center;
}
</style>

View File

@@ -2,8 +2,10 @@
import { goto } from '$app/navigation';
import { page } from '$app/state';
import CalendarRangeIcon from '@lucide/svelte/icons/calendar-range';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import { Button } from '$lib/components/ui/button';
import * as Select from '$lib/components/ui/select';
import { useDashboardContext } from '$lib/dashboard-context.svelte';
import {
parseDashboardFilters,
@@ -11,28 +13,109 @@
setDashboardQuery,
type DashboardRange,
} from '$lib/dashboard-filters';
import AmountRangeFilter from './AmountRangeFilter.svelte';
import PeriodPicker from './PeriodPicker.svelte';
const dashboard = useDashboardContext();
const terminalIds = $derived(dashboard.terminals.map((terminal) => terminal.terminalId));
const filters = $derived(parseDashboardFilters(page.url.searchParams, terminalIds));
const isTransactionsPage = $derived(page.url.pathname === '/transactions');
function update(updates: Record<string, string | null>) {
const next = setDashboardQuery(page.url, updates);
void goto(`${next.pathname}${next.search}`, { keepFocus: true, noScroll: true });
}
function selectRange(range: DashboardRange) {
const offeredPeriod = dashboard.periodFor(range);
update({ range, period: offeredPeriod?.periodId ?? null });
}
</script>
<div class="bg-card flex flex-wrap items-center justify-end gap-3 rounded-lg border p-2.5 shadow-xs">
<div class="flex items-center gap-1" aria-label="بازه گزارش">
<div
class="bg-card flex flex-wrap items-center justify-end gap-2 rounded-lg border p-2 shadow-xs sm:gap-3 sm:p-2.5"
>
<div class="flex max-w-full items-center gap-1 overflow-x-auto" aria-label="بازه گزارش">
<CalendarRangeIcon class="text-muted-foreground mx-1 hidden size-4 sm:block" />
{#each Object.entries(rangeLabels) as [range, label]}
<Button
variant={filters.range === range ? 'default' : 'ghost'}
size="sm"
class="h-8 rounded-md px-2.5 text-xs"
class="min-h-11 rounded-md px-2.5 text-xs sm:min-h-9"
aria-pressed={filters.range === range}
onclick={() => update({ range: range as DashboardRange })}>{label}</Button
onclick={() => selectRange(range as DashboardRange)}>{label}</Button
>
{/each}
</div>
<PeriodPicker
periods={dashboard.periods}
range={filters.range}
periodId={filters.period}
onselect={(period) => update({ period })}
/>
{#if isTransactionsPage}
<span class="border-border hidden h-8 border-s sm:block" aria-hidden="true"></span>
<div class="flex max-w-full flex-1 flex-wrap items-center justify-end gap-2 sm:flex-none">
<Select.Root
type="single"
value={filters.status}
onValueChange={(value) => update({ status: value })}
items={[
{ value: 'all', label: 'همه وضعیت‌ها' },
{ value: 'Verified', label: 'تأییدشده' },
{ value: 'Paid', label: 'پرداخت‌شده' },
{ value: 'Failed', label: 'ناموفق' },
{ value: 'Reversed', label: 'برگشت‌خورده' },
]}
>
<Select.Trigger
id="transaction-status-filter"
aria-label="وضعیت تراکنش"
class="min-h-11 cursor-pointer rounded-md px-3 text-xs font-medium sm:min-h-9"
>
<span data-slot="select-value">
{filters.status === 'Verified'
? 'تأییدشده'
: filters.status === 'Paid'
? 'پرداخت‌شده'
: filters.status === 'Failed'
? 'ناموفق'
: filters.status === 'Reversed'
? 'برگشت‌خورده'
: 'همه وضعیت‌ها'}
</span>
</Select.Trigger>
<Select.Content align="end" dir="rtl" class="min-w-40 rounded-lg p-1 text-right">
<Select.Item value="all" label="همه وضعیت‌ها" class="min-h-11 cursor-pointer text-right" />
<Select.Item value="Verified" label="تأییدشده" class="min-h-11 cursor-pointer text-right" />
<Select.Item value="Paid" label="پرداخت‌شده" class="min-h-11 cursor-pointer text-right" />
<Select.Item value="Failed" label="ناموفق" class="min-h-11 cursor-pointer text-right" />
<Select.Item value="Reversed" label="برگشت‌خورده" class="min-h-11 cursor-pointer text-right" />
</Select.Content>
</Select.Root>
<AmountRangeFilter
minAmount={filters.minAmount}
maxAmount={filters.maxAmount}
onchange={(minAmount, maxAmount) =>
update({
minAmount: minAmount === null ? null : String(minAmount),
maxAmount: maxAmount === null ? null : String(maxAmount),
})}
/>
{#if filters.status !== 'all' || filters.minAmount !== null || filters.maxAmount !== null}
<Button
variant="ghost"
size="sm"
class="min-h-11 cursor-pointer rounded-md px-2.5 text-xs sm:min-h-9"
aria-label="پاک‌کردن فیلترهای تراکنش"
onclick={() => update({ status: null, minAmount: null, maxAmount: null })}
><RotateCcwIcon /> پاک‌کردن</Button
>
{/if}
</div>
{/if}
</div>

View File

@@ -0,0 +1,55 @@
<script lang="ts">
import CircleHelpIcon from '@lucide/svelte/icons/circle-help';
import { Card, CardContent } from '$lib/components/ui/card';
import * as Tooltip from '$lib/components/ui/tooltip';
import { formatPercent } from '$lib/format';
let { value }: { value: string | null } = $props();
const percent = $derived(value === null ? 0 : Math.max(0, Math.min(100, Number(value))));
</script>
<Card class="rounded-lg shadow-xs">
<CardContent class="flex h-full items-center gap-4 p-4 sm:p-5">
<div
class="relative grid size-24 shrink-0 place-items-center"
role="img"
aria-label={`سهم کارمزد ${formatPercent(value)}`}
>
<svg class="size-full -rotate-90" viewBox="0 0 42 42" aria-hidden="true">
<circle cx="21" cy="21" r="15.9" fill="none" stroke="var(--muted)" stroke-width="4" />
<circle
cx="21"
cy="21"
r="15.9"
fill="none"
stroke="var(--primary)"
stroke-width="4"
stroke-linecap="round"
pathLength="100"
stroke-dasharray={`${percent} ${100 - percent}`}
class="transition-[stroke-dasharray] duration-700 motion-reduce:transition-none"
/>
</svg>
<strong class="absolute text-sm tabular-nums">{formatPercent(value)}</strong>
</div>
<div class="min-w-0">
<div class="flex items-center gap-1.5">
<h2 class="text-sm font-semibold">سهم کارمزد از پرداخت موفق</h2>
<Tooltip.Root>
<Tooltip.Trigger
aria-label="روش محاسبه سهم کارمزد"
class="text-muted-foreground hover:text-primary -m-3 inline-flex size-11 items-center justify-center"
><CircleHelpIcon class="size-4" /></Tooltip.Trigger
>
<Tooltip.Content class="max-w-72 text-right leading-6"
>کارمزد ثبت‌شده تقسیم بر مبلغ پرداخت‌های موفق در همین بازه.</Tooltip.Content
>
</Tooltip.Root>
</div>
<p class="text-muted-foreground mt-2 text-xs leading-6">
بخش آبی، سهم واقعی کارمزد را از کل مبلغ موفق این بازه نشان می‌دهد.
</p>
</div>
</CardContent>
</Card>

View File

@@ -6,7 +6,10 @@
</script>
<Tooltip.Root>
<Tooltip.Trigger aria-label="روش محاسبه" class="text-muted-foreground hover:text-primary inline-flex">
<Tooltip.Trigger
aria-label="روش محاسبه"
class="text-muted-foreground hover:text-primary -m-3 inline-flex size-11 items-center justify-center"
>
<CircleHelpIcon class="size-4" strokeWidth={1.8} />
</Tooltip.Trigger>
<Tooltip.Content side="top" class="max-w-72 text-right leading-6">{text}</Tooltip.Content>

View File

@@ -25,8 +25,8 @@
</script>
<Card class="group rounded-lg shadow-xs transition-transform duration-200 hover:-translate-y-0.5">
<CardContent class="p-5">
<div class="mb-5 flex items-start justify-between gap-3">
<CardContent class="p-4 sm:p-5">
<div class="mb-3 flex items-start justify-between gap-3 sm:mb-5">
<div class="text-muted-foreground flex items-center gap-1.5 text-sm">
<span>{label}</span>
{#if formula}<FormulaHelp text={formula} />{/if}
@@ -34,6 +34,6 @@
<Icon class={iconClass} strokeWidth={1.8} />
</div>
<p class="text-2xl font-extrabold tracking-tight tabular-nums">{value}</p>
<p class="text-muted-foreground mt-2 text-xs leading-6">{note}</p>
<p class="text-muted-foreground mt-1.5 text-xs leading-5 sm:mt-2 sm:leading-6">{note}</p>
</CardContent>
</Card>

View File

@@ -0,0 +1,245 @@
<script lang="ts">
import CalendarDaysIcon from '@lucide/svelte/icons/calendar-days';
import type { ReportingPeriod } from '$lib/api/client';
import * as Popover from '$lib/components/ui/popover';
import * as Select from '$lib/components/ui/select';
import type { DashboardRange } from '$lib/dashboard-filters';
import {
buildPersianMonthGrid,
buildPersianWeekMonthGrid,
groupPeriodsByJalaliYear,
periodJalaliDateParts,
periodJalaliYear,
} from '$lib/reporting-period-selection';
const monthNames = [
'فروردین',
'اردیبهشت',
'خرداد',
'تیر',
'مرداد',
'شهریور',
'مهر',
'آبان',
'آذر',
'دی',
'بهمن',
'اسفند',
];
const seasonNames = ['بهار', 'تابستان', 'پاییز', 'زمستان'];
let {
periods,
range,
periodId,
onselect,
}: {
periods: readonly ReportingPeriod[];
range: DashboardRange;
periodId: string;
onselect: (periodId: string) => void;
} = $props();
let open = $state(false);
let selectedYear = $state(0);
let yearInput = $state('');
let calendarMonth = $state(1);
let hoveredWeek = $state('');
const grouped = $derived(groupPeriodsByJalaliYear(periods, range));
const years = $derived([...grouped.keys()]);
const offered = $derived(grouped.get(selectedYear) ?? []);
const matchingPeriods = $derived(
[...periods]
.filter((period) => period.periodType === periodTypeForRange(range) && period.isComplete)
.sort((left, right) => right.localStartAt.localeCompare(left.localStartAt)),
);
const selectedPeriod = $derived(
matchingPeriods.find((period) => period.periodId === periodId) ?? matchingPeriods[0],
);
const calendarGrid = $derived(
!selectedYear || (range !== 'day' && range !== 'week')
? []
: range === 'day'
? buildPersianMonthGrid(selectedYear, calendarMonth, offered)
: buildPersianWeekMonthGrid(selectedYear, calendarMonth, offered),
);
const namedOptions = $derived(
(range === 'month' ? monthNames : seasonNames).map((label, index) => {
const startMonth = range === 'month' ? index + 1 : index * 3 + 1;
const prefix = range === 'month' ? 'jm' : 'ps';
const expectedId = `${prefix}-${String(selectedYear).padStart(4, '0')}-${String(startMonth).padStart(2, '0')}`;
const period = offered.find((candidate) => candidate.periodId === expectedId);
return {
label,
period,
value: period?.periodId ?? `unavailable-${range}-${selectedYear}-${startMonth}`,
disabled: !period,
};
}),
);
$effect(() => {
const preferred = selectedPeriod ? periodJalaliYear(selectedPeriod) : years[0];
selectedYear = preferred ?? 0;
yearInput = preferred ? String(preferred) : '';
calendarMonth = selectedPeriod ? periodJalaliDateParts(selectedPeriod).month : 1;
});
function periodTypeForRange(value: DashboardRange) {
if (value === 'day') return 'DAY';
if (value === 'week') return 'WEEK';
if (value === 'month') return 'JALALI_MONTH';
return 'PERSIAN_SEASON';
}
function choose(nextPeriodId: string) {
onselect(nextPeriodId);
open = false;
}
function updateYear(value: string) {
const digits = value
.replace(/[۰-۹]/g, (digit) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(digit)))
.replace(/\D/g, '')
.slice(0, 4);
yearInput = digits;
selectedYear = digits.length === 4 ? Number(digits) : 0;
}
function shortPeriodLabel(period?: ReportingPeriod) {
return period?.jalaliLabelFa.replace(/[۰-۹\d]{4}/g, '').trim() ?? '';
}
</script>
<Popover.Root bind:open>
<Popover.Trigger
class="border-input bg-background hover:bg-muted focus-visible:ring-ring inline-flex min-h-11 max-w-64 cursor-pointer items-center gap-2 rounded-md border px-3 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none sm:min-h-9"
aria-label="انتخاب تاریخ دقیق گزارش"
>
<CalendarDaysIcon class="text-primary size-4 shrink-0" />
<span class="truncate">{selectedPeriod?.jalaliLabelFa ?? 'انتخاب تاریخ'}</span>
</Popover.Trigger>
<Popover.Content align="end" class="w-[min(24rem,calc(100vw-2rem))] gap-4 p-4" dir="rtl">
<div>
<h3 class="font-semibold">انتخاب بازه دقیق</h3>
<p class="text-muted-foreground mt-1 text-xs">فقط بازه‌هایی که داده کامل دارند قابل انتخاب‌اند.</p>
</div>
<div class={range === 'day' || range === 'week' ? 'grid grid-cols-2 gap-2' : ''}>
<label class="grid gap-1.5 text-xs font-medium" for="reporting-year">
سال گزارش
<input
id="reporting-year"
class="border-input bg-background focus-visible:ring-ring min-h-11 rounded-md border px-3 text-sm outline-none focus-visible:ring-2 sm:min-h-9"
type="text"
inputmode="numeric"
maxlength="4"
pattern="[0-9۰-۹]{4}"
placeholder="۱۴۰۵"
value={yearInput}
oninput={(event) => updateYear(event.currentTarget.value)}
/>
</label>
{#if range === 'day' || range === 'week'}
<label class="grid gap-1.5 text-xs font-medium" for="reporting-month">
ماه
<select
id="reporting-month"
class="border-input bg-background focus-visible:ring-ring min-h-11 cursor-pointer rounded-md border px-3 text-sm outline-none focus-visible:ring-2 sm:min-h-9"
value={calendarMonth}
onchange={(event) => (calendarMonth = Number(event.currentTarget.value))}
>
{#each monthNames as month, index}<option value={index + 1}>{month}</option>{/each}
</select>
</label>
{/if}
</div>
{#if !offered.length}
<p class="bg-muted text-muted-foreground rounded-md p-4 text-center text-xs" role="status">
برای این سال بازه کامل دیگری موجود نیست.
</p>
{:else if range === 'month' || range === 'season'}
<div class="grid gap-1.5 text-xs font-medium">
<span>{range === 'month' ? 'ماه' : 'فصل'}</span>
<Select.Root
type="single"
value={offered.some((period) => period.periodId === selectedPeriod?.periodId)
? selectedPeriod?.periodId
: ''}
onValueChange={(value) => {
if (namedOptions.some((option) => option.period?.periodId === value)) choose(value);
}}
items={namedOptions.map((option) => ({
value: option.value,
label: option.label,
disabled: option.disabled,
}))}
>
<Select.Trigger class="min-h-11 w-full cursor-pointer rounded-md px-3 text-sm sm:min-h-9">
<span data-slot="select-value">
{offered.find((period) => period.periodId === selectedPeriod?.periodId)
? shortPeriodLabel(selectedPeriod)
: range === 'month'
? 'انتخاب ماه'
: 'انتخاب فصل'}
</span>
</Select.Trigger>
<Select.Content align="end" dir="rtl" class="min-w-48 rounded-lg p-1 text-right">
{#each namedOptions as option}
<Select.Item
value={option.value}
label={option.label}
disabled={option.disabled}
class="min-h-11 cursor-pointer text-right"
/>
{/each}
</Select.Content>
</Select.Root>
</div>
{:else if range === 'day' || range === 'week'}
<div>
<div class="text-muted-foreground mb-2 grid grid-cols-7 text-center text-[0.7rem]" aria-hidden="true">
{#each ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'] as weekday}<span>{weekday}</span>{/each}
</div>
<div
class="grid grid-cols-7 gap-1"
role="grid"
aria-label={range === 'day' ? 'تقویم روزهای قابل گزارش' : 'تقویم هفته‌های قابل گزارش'}
>
{#each calendarGrid as day}
<button
type="button"
role="gridcell"
disabled={!day.periodId}
aria-label={day.periodId
? range === 'day'
? `انتخاب روز ${day.label}`
: `انتخاب هفته شامل روز ${day.label}`
: `روز ${day.label}، بدون داده کامل`}
aria-selected={day.periodId === selectedPeriod?.periodId}
class:bg-primary={day.periodId === selectedPeriod?.periodId ||
(range === 'week' && day.periodId === hoveredWeek)}
class:text-primary-foreground={day.periodId === selectedPeriod?.periodId ||
(range === 'week' && day.periodId === hoveredWeek)}
class:opacity-25={!day.inCurrentMonth || !day.periodId}
class="hover:bg-primary/10 focus-visible:ring-ring aspect-square min-h-10 rounded-md text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none enabled:cursor-pointer disabled:cursor-not-allowed"
onmouseenter={() => {
if (range === 'week' && day.periodId) hoveredWeek = day.periodId;
}}
onmouseleave={() => (hoveredWeek = '')}
onfocus={() => {
if (range === 'week' && day.periodId) hoveredWeek = day.periodId;
}}
onblur={() => (hoveredWeek = '')}
onclick={() => day.periodId && choose(day.periodId)}>{day.label}</button
>
{/each}
</div>
</div>
{/if}
</Popover.Content>
</Popover.Root>

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import WalletCardsIcon from '@lucide/svelte/icons/wallet-cards';
import type { Terminal } from '$lib/api/client';
import * as Select from '$lib/components/ui/select';
import { formatValue } from '$lib/format';
let {
id,
value,
terminals,
mobile = false,
onValueChange,
}: {
id: string;
value: string;
terminals: Terminal[];
mobile?: boolean;
onValueChange: (value: string) => void;
} = $props();
const selectedLabel = $derived(
value === 'all'
? 'همه درگاه‌ها'
: (terminals.find((terminal) => terminal.terminalId === value)?.displayName ?? 'همه درگاه‌ها'),
);
const items = $derived([
{ value: 'all', label: 'همه درگاه‌ها' },
...terminals.map((terminal) => ({ value: terminal.terminalId, label: terminal.displayName })),
]);
</script>
<Select.Root type="single" {value} {items} {onValueChange}>
<Select.Trigger
{id}
aria-label="درگاه فعال گزارش"
class={mobile
? 'h-11 max-w-36 min-w-0 rounded-md px-2 text-xs md:hidden'
: 'h-10 w-full rounded-md px-2.5 text-xs'}
>
<span data-slot="select-value" class="min-w-0 flex-1 truncate text-start">
{#if !mobile}<WalletCardsIcon class="text-primary size-4" />{/if}
{selectedLabel}
</span>
</Select.Trigger>
<Select.Content align="end" dir="rtl" class="min-w-48 rounded-lg p-1 text-right">
<Select.Item value="all" label="همه درگاه‌ها" class="min-h-11 cursor-pointer text-right" />
{#each terminals as terminal}
<Select.Item value={terminal.terminalId} class="min-h-11 cursor-pointer text-right">
<span class="flex w-full items-center justify-between gap-4">
<span>{terminal.displayName}</span>
<span class="text-muted-foreground text-[11px]" dir="rtl"
>{formatValue(terminal.sessionCount)} تراکنش</span
>
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>

View File

@@ -14,6 +14,7 @@
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Skeleton } from '$lib/components/ui/skeleton';
import { formatDate, formatMoney, formatMilliseconds } from '$lib/format';
let detail = $state<SessionDetail | null>(null);
@@ -47,6 +48,14 @@
function back() {
void goto(`/transactions${page.url.search}`);
}
function statusClass(status: keyof typeof statusLabels) {
if (status === 'Verified') {
return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-300';
}
if (status === 'Paid') return 'bg-muted text-muted-foreground';
return '';
}
</script>
<svelte:head><title>جزئیات تراکنش | تحلیل زرین‌پال</title></svelte:head>
@@ -60,7 +69,13 @@
</div>
{#if loading}
<div class="text-muted-foreground grid min-h-80 place-items-center text-sm">در حال دریافت جزئیات…</div>
<div class="space-y-4" role="status" aria-label="در حال دریافت جزئیات تراکنش">
<Skeleton class="h-24 rounded-lg" />
<div class="grid gap-4 lg:grid-cols-[1.25fr_.75fr]">
<Skeleton class="h-80 rounded-lg" />
<Skeleton class="h-80 rounded-lg" />
</div>
</div>
{:else if error || !detail}
<Card class="rounded-lg"><CardContent class="p-10 text-center" role="alert">{error}</CardContent></Card>
{:else}
@@ -73,8 +88,9 @@
<p class="text-muted-foreground mt-1 font-mono text-xs" dir="ltr">{detail.sessionId}</p>
</div>
</div>
<Badge variant={detail.sessionStatus === 'Failed' ? 'destructive' : 'secondary'} class="rounded-md"
>{statusLabels[detail.sessionStatus]}</Badge
<Badge
variant={detail.sessionStatus === 'Failed' ? 'destructive' : 'outline'}
class={`rounded-md ${statusClass(detail.sessionStatus)}`}>{statusLabels[detail.sessionStatus]}</Badge
>
</CardContent>
</Card>
@@ -116,7 +132,7 @@
<div class="flex items-center justify-between gap-2">
<strong class="text-sm">تلاش {attempt.sequence}</strong><Badge
variant="outline"
class="rounded-md">{statusLabels[attempt.status]}</Badge
class={`rounded-md ${statusClass(attempt.status)}`}>{statusLabels[attempt.status]}</Badge
>
</div>
{#if attempt.createdAt}<p class="text-muted-foreground mt-1 text-xs">

View File

@@ -1,19 +1,17 @@
<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 { Skeleton } from '$lib/components/ui/skeleton';
import * as Table from '$lib/components/ui/table';
import { useDashboardContext } from '$lib/dashboard-context.svelte';
import { parseDashboardFilters, setDashboardQuery } from '$lib/dashboard-filters';
import { parseDashboardFilters } from '$lib/dashboard-filters';
import { formatDate, formatMoney, formatValue } from '$lib/format';
import PageHeader from './PageHeader.svelte';
@@ -24,8 +22,6 @@
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> = {
@@ -36,11 +32,17 @@
};
function badgeVariant(status: SessionStatus) {
return status === 'Verified' || status === 'Paid'
? 'secondary'
: status === 'Failed'
? 'destructive'
: 'outline';
return status === 'Paid' ? 'secondary' : status === 'Failed' ? 'destructive' : 'outline';
}
function badgeClass(status: SessionStatus) {
if (status === 'Verified') {
return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-300';
}
if (status === 'Reversed') {
return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-300';
}
return '';
}
async function fetchRows(reset = false) {
@@ -48,7 +50,7 @@
if (reset) cursor = undefined;
loading = true;
error = '';
const period = dashboard.periodFor(filters.range);
const period = dashboard.periodFor(filters.range, filters.period);
if (!period) return;
try {
const next = await api.details(period.periodId, 'ALL', {
@@ -67,18 +69,8 @@
}
}
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}`);
function detailUrl(sessionId: string) {
return `/transactions/${encodeURIComponent(sessionId)}${page.url.search}`;
}
async function nextPage() {
@@ -90,12 +82,11 @@
$effect(() => {
filters.range;
filters.period;
filters.terminal;
filters.status;
filters.minAmount;
filters.maxAmount;
minAmount = filters.minAmount?.toString() ?? '';
maxAmount = filters.maxAmount?.toString() ?? '';
void fetchRows(true);
});
</script>
@@ -109,91 +100,75 @@
<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 class="overflow-hidden" role="status" aria-label="در حال دریافت تراکنش‌ها">
<div class="grid grid-cols-7 gap-3 border-b px-4 py-3">
{#each Array(7) as _}<Skeleton class="h-4 rounded-sm" />{/each}
</div>
{#each Array(8) as _, index}
<div class="grid min-h-14 grid-cols-7 items-center gap-3 border-b px-4 py-3 last:border-0">
<Skeleton class="h-3.5 w-20 rounded-sm" />
<Skeleton class="h-3.5 w-12 rounded-sm" />
<Skeleton class="h-3.5 w-24 rounded-sm" />
<Skeleton class="h-3.5 w-24 rounded-sm" />
<Skeleton class="h-3.5 w-8 rounded-sm" />
<Skeleton class="h-7 w-20 rounded-md" />
<Skeleton class="size-8 justify-self-end rounded-md" />
</div>
{/each}
</div>
{:else if error}
<div class="grid min-h-72 place-items-center text-sm" role="alert">{error}</div>
<div class="grid min-h-72 place-items-center text-sm">
<div class="space-y-4 text-center">
<p role="alert">{error}</p>
<Button variant="outline" onclick={() => fetchRows(true)}><RotateCcwIcon /> تلاش دوباره</Button>
</div>
</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 class="text-right">شناسه پرداخت</Table.Head><Table.Head class="text-right"
>درگاه</Table.Head
><Table.Head class="text-right">زمان</Table.Head><Table.Head class="text-right">مبلغ</Table.Head
><Table.Head class="text-right">تلاش‌ها</Table.Head><Table.Head class="text-right"
>وضعیت</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.Row class="hover:bg-muted/60 transition-colors">
<Table.Cell class="text-right font-mono text-xs" dir="ltr"
><a
class="hover:text-primary focus-visible:ring-ring rounded-sm underline-offset-4 hover:underline focus-visible:ring-2"
href={detailUrl(item.sessionId)}>{item.sessionId}</a
></Table.Cell
>
<Table.Cell>{formatValue(item.attemptCount)}</Table.Cell>
<Table.Cell
><Badge variant={badgeVariant(item.sessionStatus)} class="rounded-md"
<Table.Cell class="text-right">{item.terminalId}</Table.Cell>
<Table.Cell class="text-right whitespace-nowrap">{formatDate(item.createdAt)}</Table.Cell>
<Table.Cell class="text-right font-semibold whitespace-nowrap"
>{formatMoney(item.amountRial)}</Table.Cell
>
<Table.Cell class="text-right">{formatValue(item.attemptCount)}</Table.Cell>
<Table.Cell class="text-right"
><Badge
variant={badgeVariant(item.sessionStatus)}
class={`rounded-md ${badgeClass(item.sessionStatus)}`}
>{statusLabels[item.sessionStatus]}</Badge
></Table.Cell
>
<Table.Cell><ArrowLeftIcon class="text-muted-foreground size-4" /></Table.Cell>
<Table.Cell
><Button
variant="ghost"
size="icon"
class="size-11"
href={detailUrl(item.sessionId)}
aria-label={`دیدن جزئیات تراکنش ${item.sessionId}`}
><ArrowLeftIcon class="text-muted-foreground size-4" /></Button
></Table.Cell
>
</Table.Row>
{/each}
</Table.Body>

View File

@@ -38,12 +38,13 @@ export class DashboardContext {
}
}
periodFor(range: DashboardRange): ReportingPeriod | undefined {
return this.periods.find((period) => period.periodType === rangePeriodTypes[range]);
periodFor(range: DashboardRange, periodId = ''): ReportingPeriod | undefined {
const matching = this.periods.filter((period) => period.periodType === rangePeriodTypes[range]);
return matching.find((period) => period.periodId === periodId) ?? matching[0];
}
async runFor(range: DashboardRange, terminal: string): Promise<AnalyticsRun> {
const period = this.periodFor(range);
async runFor(range: DashboardRange, terminal: string, periodId = ''): Promise<AnalyticsRun> {
const period = this.periodFor(range, periodId);
if (!this.session || !period) throw new Error('Dashboard context is not ready');
return api.startRun(period.periodId, this.session.csrfToken, terminal === 'all' ? undefined : terminal);
}

View File

@@ -10,6 +10,7 @@ describe('dashboard filters', () => {
expect(parseDashboardFilters(query, terminals)).toEqual({
range: 'month',
period: '',
terminal: 'all',
status: 'all',
minAmount: null,
@@ -29,6 +30,34 @@ describe('dashboard filters', () => {
},
);
it('keeps an offered reporting period in the copied URL without dropping other filters', () => {
const filters = parseDashboardFilters(
new URLSearchParams('range=month&period=jm-1405-05&terminal=T83&status=Verified'),
terminals,
);
expect(filters.period).toBe('jm-1405-05');
const next = setDashboardQuery(
new URL('https://example.test/transactions?range=month&terminal=T83&status=Verified'),
{ period: filters.period },
);
expect(next.searchParams.get('period')).toBe('jm-1405-05');
expect(next.searchParams.get('range')).toBe('month');
expect(next.searchParams.get('terminal')).toBe('T83');
expect(next.searchParams.get('status')).toBe('Verified');
});
it('rejects reporting period identifiers outside the server formats', () => {
const filters = parseDashboardFilters(
new URLSearchParams('range=day&period=javascript:alert(1)'),
terminals,
);
expect(filters.period).toBe('');
});
it('normalizes an inverted amount range', () => {
const filters = parseDashboardFilters(
new URLSearchParams('minAmount=900000&maxAmount=100000'),

View File

@@ -3,6 +3,7 @@ export type TransactionStatus = 'all' | 'Paid' | 'Verified' | 'Failed' | 'Revers
export interface DashboardFilters {
range: DashboardRange;
period: string;
terminal: string;
status: TransactionStatus;
minAmount: number | null;
@@ -12,6 +13,13 @@ export interface DashboardFilters {
const ranges = new Set<DashboardRange>(['day', 'week', 'month', 'season']);
const statuses = new Set<TransactionStatus>(['all', 'Paid', 'Verified', 'Failed', 'Reversed']);
const periodPatterns: Record<DashboardRange, RegExp> = {
day: /^d-\d{4}-\d{2}-\d{2}$/,
week: /^w-\d{4}-\d{2}-\d{2}$/,
month: /^jm-\d{4}-\d{2}$/,
season: /^ps-\d{4}-(?:01|04|07|10)$/,
};
function positiveAmount(value: string | null): number | null {
if (!value) return null;
const parsed = Number(value);
@@ -23,6 +31,8 @@ export function parseDashboardFilters(
terminalIds: readonly string[] = [],
): DashboardFilters {
const rawRange = query.get('range') as DashboardRange | null;
const range = rawRange && ranges.has(rawRange) ? rawRange : 'month';
const rawPeriod = query.get('period');
const rawStatus = query.get('status') as TransactionStatus | null;
const rawTerminal = query.get('terminal');
let minAmount = positiveAmount(query.get('minAmount'));
@@ -33,7 +43,8 @@ export function parseDashboardFilters(
}
return {
range: rawRange && ranges.has(rawRange) ? rawRange : 'month',
range,
period: rawPeriod && periodPatterns[range].test(rawPeriod) ? rawPeriod : '',
terminal: rawTerminal && terminalIds.includes(rawTerminal) ? rawTerminal : 'all',
status: rawStatus && statuses.has(rawStatus) ? rawStatus : 'all',
minAmount,

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { describePercentile, formulas } from './insights';
import { describePercentile, formulas, peerProgressTone, retryOpportunity } from './insights';
describe('merchant-friendly insights', () => {
it('celebrates strong peer performance without claiming causality', () => {
@@ -19,4 +19,19 @@ describe('merchant-friendly insights', () => {
expect(formulas.paymentSuccess).toContain('پرداخت‌های موفق بانکی');
expect(formulas.paymentSuccess).toContain('همه نشست‌های پرداخت');
});
it('uses semantic progress tones as peer performance improves', () => {
expect(peerProgressTone(18)).toBe('needs-attention');
expect(peerProgressTone(42)).toBe('developing');
expect(peerProgressTone(61)).toBe('strong');
expect(peerProgressTone(88)).toBe('leading');
});
it('changes recovery tone and copy at meaningful retry thresholds', () => {
expect(retryOpportunity(72, 150).tone).toBe('excellent');
expect(retryOpportunity(48, 80).tone).toBe('positive');
expect(retryOpportunity(24, 80).tone).toBe('opportunity');
expect(retryOpportunity(72, 20).tone).toBe('opportunity');
expect(retryOpportunity(72, 150).copy).not.toBe(retryOpportunity(24, 80).copy);
});
});

View File

@@ -26,3 +26,40 @@ export function describePercentile(metricId: string, percentile: number): string
}
return `در ${name} هنوز فرصت رشد خوبی دارید؛ بررسی مسیرهای ناموفق می‌تواند قدم بعدی روشنی نشان دهد.`;
}
export type PeerProgressTone = 'needs-attention' | 'developing' | 'strong' | 'leading';
export function peerProgressTone(percentile: number): PeerProgressTone {
if (percentile >= 75) return 'leading';
if (percentile >= 50) return 'strong';
if (percentile >= 25) return 'developing';
return 'needs-attention';
}
export type RetryOpportunity = {
tone: 'excellent' | 'positive' | 'opportunity';
title: string;
copy: string;
};
export function retryOpportunity(rate: number, retriedSessions: number): RetryOpportunity {
if (rate >= 65 && retriedSessions >= 100) {
return {
tone: 'excellent',
title: 'بازگشت قدرتمند مشتریان',
copy: 'بخش بزرگی از مشتریانی که دوباره تلاش کرده‌اند به پرداخت موفق رسیده‌اند؛ این روند نشان می‌دهد مسیر بازگشت فعلی قابل اتکاست.',
};
}
if (rate >= 35 && retriedSessions >= 50) {
return {
tone: 'positive',
title: 'روند بازیابی رو به جلوست',
copy: 'بخشی از مشتریان در تلاش دوباره موفق شده‌اند. مرور مرحله‌های ناموفق می‌تواند همین مسیر مثبت را تقویت کند.',
};
}
return {
tone: 'opportunity',
title: 'فرصتی برای ساده‌تر کردن بازگشت',
copy: 'مشتریان دوباره امتحان کرده‌اند؛ کوتاه‌تر کردن مسیر خطا و روشن‌تر کردن راهنمای تلاش مجدد می‌تواند شانس موفقیت بعدی را بیشتر کند.',
};
}

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest';
import type { ReportingPeriod } from '$lib/api/client';
import {
availableJalaliMonths,
buildPersianMonthGrid,
buildPersianWeekMonthGrid,
groupPeriodsByJalaliYear,
weekDaysForPeriod,
} from './reporting-period-selection';
const periods: ReportingPeriod[] = [
{
periodId: 'd-2026-08-20',
periodType: 'DAY',
startAt: '2026-08-19T20:30:00Z',
endAt: '2026-08-20T20:30:00Z',
localStartAt: '2026-08-20T00:00:00+03:30',
localEndAt: '2026-08-21T00:00:00+03:30',
timezone: 'Asia/Tehran',
jalaliLabelFa: 'روز ۲۹ مرداد ۱۴۰۵',
isComplete: true,
isDefault: false,
},
{
periodId: 'w-2026-08-15',
periodType: 'WEEK',
startAt: '2026-08-14T20:30:00Z',
endAt: '2026-08-21T20:30:00Z',
localStartAt: '2026-08-15T00:00:00+03:30',
localEndAt: '2026-08-22T00:00:00+03:30',
timezone: 'Asia/Tehran',
jalaliLabelFa: 'هفته ۲۴ تا ۳۰ مرداد ۱۴۰۵',
isComplete: true,
isDefault: false,
},
{
periodId: 'jm-1405-05',
periodType: 'JALALI_MONTH',
startAt: '2026-07-22T20:30:00Z',
endAt: '2026-08-22T20:30:00Z',
localStartAt: '2026-07-23T00:00:00+03:30',
localEndAt: '2026-08-23T00:00:00+03:30',
timezone: 'Asia/Tehran',
jalaliLabelFa: 'مرداد ۱۴۰۵',
isComplete: true,
isDefault: true,
},
{
periodId: 'ps-1404-10',
periodType: 'PERSIAN_SEASON',
startAt: '2025-12-21T20:30:00Z',
endAt: '2026-03-20T20:30:00Z',
localStartAt: '2025-12-22T00:00:00+03:30',
localEndAt: '2026-03-21T00:00:00+03:30',
timezone: 'Asia/Tehran',
jalaliLabelFa: 'زمستان ۱۴۰۴',
isComplete: true,
isDefault: false,
},
];
describe('reporting period selection', () => {
it('groups only server-offered periods by Jalali year and range', () => {
const result = groupPeriodsByJalaliYear(periods, 'month');
expect([...result.keys()]).toEqual([1405]);
expect(result.get(1405)?.map((period) => period.periodId)).toEqual(['jm-1405-05']);
});
it('builds a Saturday-first Persian month grid and enables only offered days', () => {
const grid = buildPersianMonthGrid(1405, 5, periods);
const enabled = grid.filter((day) => day.periodId);
expect(grid).toHaveLength(42);
expect(grid[0]?.weekday).toBe(0);
expect(enabled).toEqual([
expect.objectContaining({ day: 29, inCurrentMonth: true, periodId: 'd-2026-08-20' }),
]);
});
it('lists only Jalali months that contain an offered day in the chosen year', () => {
expect(availableJalaliMonths(periods, 1405)).toEqual([5]);
expect(availableJalaliMonths(periods, 1404)).toEqual([]);
});
it('returns the complete Saturday-to-Friday row for a server-offered week', () => {
const days = weekDaysForPeriod(periods[1]);
expect(days).toHaveLength(7);
expect(days[0]).toEqual(expect.objectContaining({ weekday: 0, periodId: 'w-2026-08-15' }));
expect(days[6]).toEqual(expect.objectContaining({ weekday: 6, periodId: 'w-2026-08-15' }));
});
it('maps every selectable day in a week calendar to the same offered week', () => {
const grid = buildPersianWeekMonthGrid(1405, 5, periods);
const weekCells = grid.filter((day) => day.periodId === 'w-2026-08-15');
expect(weekCells).toHaveLength(7);
expect(new Set(weekCells.map((day) => day.weekday))).toEqual(new Set([0, 1, 2, 3, 4, 5, 6]));
});
});

View File

@@ -0,0 +1,139 @@
import { CalendarDate, PersianCalendar, fromDate, toCalendar } from '@internationalized/date';
import type { ReportingPeriod } from '$lib/api/client';
import { rangePeriodTypes, type DashboardRange } from '$lib/dashboard-filters';
export interface PersianCalendarDay {
key: string;
year: number;
month: number;
day: number;
weekday: number;
inCurrentMonth: boolean;
periodId: string | null;
label: string;
}
const persianCalendar = new PersianCalendar();
const persianNumber = new Intl.NumberFormat('fa-IR', { useGrouping: false });
function toPersianDate(value: string, timezone = 'Asia/Tehran') {
return toCalendar(fromDate(new Date(value), timezone), persianCalendar);
}
export function periodJalaliDateParts(period: ReportingPeriod) {
const date = toPersianDate(period.localStartAt, period.timezone);
return { year: date.year, month: date.month, day: date.day };
}
export function periodJalaliYear(period: ReportingPeriod): number {
const encodedYear = /^(?:jm|ps)-(\d{4})-/.exec(period.periodId)?.[1];
return encodedYear ? Number(encodedYear) : toPersianDate(period.localStartAt, period.timezone).year;
}
export function groupPeriodsByJalaliYear(
periods: readonly ReportingPeriod[],
range: DashboardRange,
): Map<number, ReportingPeriod[]> {
const grouped = new Map<number, ReportingPeriod[]>();
for (const period of periods) {
if (period.periodType !== rangePeriodTypes[range] || !period.isComplete) continue;
const year = periodJalaliYear(period);
grouped.set(year, [...(grouped.get(year) ?? []), period]);
}
return new Map(
[...grouped.entries()]
.sort(([left], [right]) => right - left)
.map(([year, offered]) => [
year,
offered.sort((left, right) => left.localStartAt.localeCompare(right.localStartAt)),
]),
);
}
function dayKey(date: { year: number; month: number; day: number }) {
return `${date.year}-${date.month}-${date.day}`;
}
export function buildPersianMonthGrid(
year: number,
month: number,
periods: readonly ReportingPeriod[],
): PersianCalendarDay[] {
const offeredDays = new Map(
periods
.filter((period) => period.periodType === 'DAY' && period.isComplete)
.map((period) => [dayKey(toPersianDate(period.localStartAt, period.timezone)), period.periodId]),
);
return buildMonthGrid(year, month, offeredDays);
}
function buildMonthGrid(year: number, month: number, offeredDays: ReadonlyMap<string, string>) {
const firstDay = new CalendarDate(persianCalendar, year, month, 1);
const leadingDays = (firstDay.toDate('Asia/Tehran').getDay() + 1) % 7;
const gridStart = firstDay.subtract({ days: leadingDays });
return Array.from({ length: 42 }, (_, index) => {
const date = gridStart.add({ days: index });
return {
key: dayKey(date),
year: date.year,
month: date.month,
day: date.day,
weekday: index % 7,
inCurrentMonth: date.year === year && date.month === month,
periodId: offeredDays.get(dayKey(date)) ?? null,
label: persianNumber.format(date.day),
};
});
}
export function buildPersianWeekMonthGrid(
year: number,
month: number,
periods: readonly ReportingPeriod[],
): PersianCalendarDay[] {
const offeredDays = new Map<string, string>();
for (const period of periods) {
if (period.periodType !== 'WEEK' || !period.isComplete) continue;
for (const day of weekDaysForPeriod(period)) offeredDays.set(dayKey(day), period.periodId);
}
return buildMonthGrid(year, month, offeredDays);
}
export function availableJalaliMonths(
periods: readonly ReportingPeriod[],
year: number,
range: 'day' | 'week' = 'day',
): number[] {
const months = new Set<number>();
for (const period of periods) {
if (!period.isComplete) continue;
if (range === 'day' && period.periodType === 'DAY') {
const day = periodJalaliDateParts(period);
if (day.year === year) months.add(day.month);
}
if (range === 'week' && period.periodType === 'WEEK') {
for (const day of weekDaysForPeriod(period)) if (day.year === year) months.add(day.month);
}
}
return [...months].sort((left, right) => left - right);
}
export function weekDaysForPeriod(period: ReportingPeriod): PersianCalendarDay[] {
const firstDay = toPersianDate(period.localStartAt, period.timezone);
return Array.from({ length: 7 }, (_, index) => {
const date = firstDay.add({ days: index });
return {
key: `${period.periodId}-${index}`,
year: date.year,
month: date.month,
day: date.day,
weekday: index,
inCurrentMonth: true,
periodId: period.periodId,
label: persianNumber.format(date.day),
};
});
}

View File

@@ -4,6 +4,7 @@
import AppShell from '$lib/components/AppShell.svelte';
import { ApiError } from '$lib/api/client';
import { Skeleton } from '$lib/components/ui/skeleton';
import { provideDashboardContext } from '$lib/dashboard-context.svelte';
let { children } = $props();
@@ -19,12 +20,24 @@
</script>
{#if !dashboard.ready}
<main class="grid min-h-screen place-items-center" role="status">
<div class="text-center">
<span
class="border-border border-t-primary mx-auto mb-4 block size-9 animate-spin rounded-full border-2"
></span>
<p class="text-muted-foreground text-sm">در حال آماده‌سازی پنل…</p>
<main class="min-h-screen" role="status" aria-label="در حال آماده‌سازی پنل">
<div class="bg-card flex h-14 items-center justify-between border-b px-4">
<Skeleton class="h-8 w-36" />
<div class="flex gap-2"><Skeleton class="size-9" /><Skeleton class="size-9" /></div>
</div>
<div class="ms-auto hidden h-[calc(100vh-3.5rem)] w-64 border-s p-3 md:block">
<Skeleton class="h-16 w-full" />
<div class="mt-5 space-y-3">
{#each Array(6) as _}<Skeleton class="h-11 w-full" />{/each}
</div>
</div>
<div class="p-5 md:me-64 md:p-8">
<Skeleton class="mb-5 h-14 w-64 max-w-full" />
<Skeleton class="mb-4 h-14 w-full" />
<div class="grid gap-3 min-[600px]:grid-cols-2 lg:grid-cols-4">
{#each Array(4) as _}<Skeleton class="h-40 rounded-lg" />{/each}
</div>
<Skeleton class="mt-4 h-80 rounded-lg" />
</div>
</main>
{:else if dashboard.session}

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB