diff --git a/.impeccable/design.json b/.impeccable/design.json new file mode 100644 index 0000000..e19d81e --- /dev/null +++ b/.impeccable/design.json @@ -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": "", + "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": "", + "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": "
همه درگاه‌ها
فروشگاه اصلی۶۳۹ تراکنش
", + "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": "
انتخاب بازه دقیقفقط بازه‌هایی که داده کامل دارند قابل انتخاب‌اند.
شیدسچپج
", + "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": "
بازه مبلغ تراکنش

دو نشانگر را برای تعیین کمینه و بیشینه جابه‌جا کنید.

از ۱۰۰٬۰۰۰ ریالتا ۱۰٬۰۰۰٬۰۰۰٬۰۰۰ ریال
", + "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": "
مبلغ پرداخت موفق
۲۱٬۳۴۷٬۷۱۳٬۹۱۰ ریال

مجموع مبلغ پرداخت‌های موفق در این بازه

", + "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": "پیشخوان", + "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": "✓ آماده", + "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": "
۲٫۴٪

سهم کارمزد از پرداخت موفق

بخش آبی، سهم واقعی کارمزد را از کل مبلغ موفق این بازه نشان می‌دهد.

", + "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": "
شناسه پرداختمبلغوضعیت
SES-639-042۱٬۲۵۰٬۰۰۰ ریالتأییدشده
", + "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." + ] + } +} diff --git a/DESIGN.md b/DESIGN.md index 5c86dd3..505bd10 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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. diff --git a/src/app.css b/src/app.css index 639c2f0..742727c 100644 --- a/src/app.css +++ b/src/app.css @@ -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; + } +} diff --git a/src/lib/components/AmountRangeFilter.svelte b/src/lib/components/AmountRangeFilter.svelte new file mode 100644 index 0000000..26bdfcf --- /dev/null +++ b/src/lib/components/AmountRangeFilter.svelte @@ -0,0 +1,173 @@ + + + + + + مبلغ + {#if hasFilter}{/if} + + +
+

بازه مبلغ تراکنش

+

دو نشانگر را برای تعیین کمینه و بیشینه جابه‌جا کنید.

+
+ +
+ از {formatValue(localMin)} ریال + تا {formatValue(localMax)} ریال +
+ +
+ + updateMin(Number(event.currentTarget.value))} + /> + updateMax(Number(event.currentTarget.value))} + /> +
+ +
+ + +
+
+
+ + diff --git a/src/lib/components/AnalyticsPage.svelte b/src/lib/components/AnalyticsPage.svelte index 6f2d798..fcf3b92 100644 --- a/src/lib/components/AnalyticsPage.svelte +++ b/src/lib/components/AnalyticsPage.svelte @@ -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['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', + }; + } {titles[kind][0]} | تحلیل زرین‌پال @@ -85,7 +115,10 @@ {:else if error}

{error}

{error}

+
{:else if run} {@const overview = run.sections.overview} @@ -94,7 +127,7 @@ {@const peers = run.sections.peerComparison} {#if kind === 'overview' && overview.state === 'READY'} -
+
{:else if kind === 'payments' && performance.state === 'READY'} -
+ {@const opportunity = retryOpportunity( + Number(performance.data.retryRecoveryRate.value ?? 0), + performance.data.retriedSessions.value, + )} + {@const opportunityClass = retryOpportunityClass(opportunity.tone)} +
- + - -

یک فرصت روشن

+ +

{opportunity.title}

{formatValue(performance.data.retriedSessions.value)} مشتری دوباره تلاش کرده‌اند و نرخ بازیابی آن‌ها - {formatPercent(performance.data.retryRecoveryRate.value)} بوده است. جزئیات تراکنش‌های ناموفق می‌تواند - نقاط بهبود مسیر را نشان دهد. + {formatPercent(performance.data.retryRecoveryRate.value)} بوده است. {opportunity.copy}

{:else if kind === 'fees' && fees.state === 'READY'} -
+
+
diff --git a/src/lib/components/AppShell.svelte b/src/lib/components/AppShell.svelte index 8962c3b..292b391 100644 --- a/src/lib/components/AppShell.svelte +++ b/src/lib/components/AppShell.svelte @@ -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 @@ - + terminals={dashboard.terminals} + onValueChange={terminalChanged} + />
@@ -107,11 +99,18 @@ goto(navUrl(item.href))} + class="data-[active=true]:text-primary h-11 justify-start px-3 text-[13px]" > - - {item.label} + {#snippet child({ props })} + + + {item.label} + + {/snippet} {/each} @@ -126,11 +125,18 @@ goto(navUrl('/methodology'))} + class="data-[active=true]:text-primary h-11 justify-start px-3 text-[13px]" > - - روش محاسبه + {#snippet child({ props })} + + + روش محاسبه + + {/snippet} @@ -140,7 +146,7 @@ - + خروج از حساب @@ -155,27 +161,25 @@ >
- +
- - - -
+ +
diff --git a/src/lib/components/BrandLogo.svelte b/src/lib/components/BrandLogo.svelte index 14f7354..58f19c8 100644 --- a/src/lib/components/BrandLogo.svelte +++ b/src/lib/components/BrandLogo.svelte @@ -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; } diff --git a/src/lib/components/DashboardControls.svelte b/src/lib/components/DashboardControls.svelte index 3e1c8e4..b575edf 100644 --- a/src/lib/components/DashboardControls.svelte +++ b/src/lib/components/DashboardControls.svelte @@ -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) { 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 }); + } -
-
+
+
+ + update({ period })} + /> + + {#if isTransactionsPage} + +
+ update({ status: value })} + items={[ + { value: 'all', label: 'همه وضعیت‌ها' }, + { value: 'Verified', label: 'تأییدشده' }, + { value: 'Paid', label: 'پرداخت‌شده' }, + { value: 'Failed', label: 'ناموفق' }, + { value: 'Reversed', label: 'برگشت‌خورده' }, + ]} + > + + + {filters.status === 'Verified' + ? 'تأییدشده' + : filters.status === 'Paid' + ? 'پرداخت‌شده' + : filters.status === 'Failed' + ? 'ناموفق' + : filters.status === 'Reversed' + ? 'برگشت‌خورده' + : 'همه وضعیت‌ها'} + + + + + + + + + + + + + update({ + minAmount: minAmount === null ? null : String(minAmount), + maxAmount: maxAmount === null ? null : String(maxAmount), + })} + /> + + {#if filters.status !== 'all' || filters.minAmount !== null || filters.maxAmount !== null} + + {/if} +
+ {/if}
diff --git a/src/lib/components/FeeShareDonut.svelte b/src/lib/components/FeeShareDonut.svelte new file mode 100644 index 0000000..906e475 --- /dev/null +++ b/src/lib/components/FeeShareDonut.svelte @@ -0,0 +1,55 @@ + + + + + +
+
+

سهم کارمزد از پرداخت موفق

+ + + کارمزد ثبت‌شده تقسیم بر مبلغ پرداخت‌های موفق در همین بازه. + +
+

+ بخش آبی، سهم واقعی کارمزد را از کل مبلغ موفق این بازه نشان می‌دهد. +

+
+
+
diff --git a/src/lib/components/FormulaHelp.svelte b/src/lib/components/FormulaHelp.svelte index ae569e4..55be2f6 100644 --- a/src/lib/components/FormulaHelp.svelte +++ b/src/lib/components/FormulaHelp.svelte @@ -6,7 +6,10 @@ - + {text} diff --git a/src/lib/components/KpiCard.svelte b/src/lib/components/KpiCard.svelte index 33e0128..bfe8852 100644 --- a/src/lib/components/KpiCard.svelte +++ b/src/lib/components/KpiCard.svelte @@ -25,8 +25,8 @@ - -
+ +
{label} {#if formula}{/if} @@ -34,6 +34,6 @@

{value}

-

{note}

+

{note}

diff --git a/src/lib/components/PeriodPicker.svelte b/src/lib/components/PeriodPicker.svelte new file mode 100644 index 0000000..128532f --- /dev/null +++ b/src/lib/components/PeriodPicker.svelte @@ -0,0 +1,245 @@ + + + + + + {selectedPeriod?.jalaliLabelFa ?? 'انتخاب تاریخ'} + + + +
+

انتخاب بازه دقیق

+

فقط بازه‌هایی که داده کامل دارند قابل انتخاب‌اند.

+
+ +
+ + + {#if range === 'day' || range === 'week'} + + {/if} +
+ + {#if !offered.length} +

+ برای این سال بازه کامل دیگری موجود نیست. +

+ {:else if range === 'month' || range === 'season'} +
+ {range === 'month' ? 'ماه' : 'فصل'} + 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, + }))} + > + + + {offered.find((period) => period.periodId === selectedPeriod?.periodId) + ? shortPeriodLabel(selectedPeriod) + : range === 'month' + ? 'انتخاب ماه' + : 'انتخاب فصل'} + + + + {#each namedOptions as option} + + {/each} + + +
+ {:else if range === 'day' || range === 'week'} +
+ +
+ {#each calendarGrid as day} + + {/each} +
+
+ {/if} +
+
diff --git a/src/lib/components/TerminalSelect.svelte b/src/lib/components/TerminalSelect.svelte new file mode 100644 index 0000000..12c7b0f --- /dev/null +++ b/src/lib/components/TerminalSelect.svelte @@ -0,0 +1,59 @@ + + + + + + {#if !mobile}{/if} + {selectedLabel} + + + + + {#each terminals as terminal} + + + {terminal.displayName} + {formatValue(terminal.sessionCount)} تراکنش + + + {/each} + + diff --git a/src/lib/components/TransactionDetailPage.svelte b/src/lib/components/TransactionDetailPage.svelte index 88d2de5..f19303e 100644 --- a/src/lib/components/TransactionDetailPage.svelte +++ b/src/lib/components/TransactionDetailPage.svelte @@ -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(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 ''; + } جزئیات تراکنش | تحلیل زرین‌پال @@ -60,7 +69,13 @@
{#if loading} -
در حال دریافت جزئیات…
+
+ +
+ + +
+
{:else if error || !detail} {error} {:else} @@ -73,8 +88,9 @@

{detail.sessionId}

- {statusLabels[detail.sessionStatus]}{statusLabels[detail.sessionStatus]} @@ -116,7 +132,7 @@
تلاش {attempt.sequence}{statusLabels[attempt.status]}{statusLabels[attempt.status]}
{#if attempt.createdAt}

diff --git a/src/lib/components/TransactionsPage.svelte b/src/lib/components/TransactionsPage.svelte index d289a9d..bf180de 100644 --- a/src/lib/components/TransactionsPage.svelte +++ b/src/lib/components/TransactionsPage.svelte @@ -1,19 +1,17 @@ @@ -109,91 +100,75 @@ -

-
- فیلترها -
- -
- - - - {#if filters.status !== 'all' || filters.minAmount !== null || filters.maxAmount !== null} - - {/if} -
-
- {#if loading} -
- در حال دریافت تراکنش‌ها… +
+
+ {#each Array(7) as _}{/each} +
+ {#each Array(8) as _, index} +
+ + + + + + + +
+ {/each}
{:else if error} - +
+
+

{error}

+ +
+
{:else if result && result.items.length}
شناسه پرداختدرگاهزمانمبلغتلاش‌هاوضعیتشناسه پرداختدرگاهزمانمبلغتلاش‌هاوضعیتجزئیات {#each result.items as item} - openRow(item.sessionId)} - onkeydown={(event) => { - if (event.key === 'Enter') openRow(item.sessionId); - }} - > - {item.sessionId} - {item.terminalId} - {formatDate(item.createdAt)} - {formatMoney(item.amountRial)} + {item.sessionId} - {formatValue(item.attemptCount)} - {item.terminalId} + {formatDate(item.createdAt)} + {formatMoney(item.amountRial)} + {formatValue(item.attemptCount)} + {statusLabels[item.sessionStatus]} - + {/each} diff --git a/src/lib/dashboard-context.svelte.ts b/src/lib/dashboard-context.svelte.ts index 83f2e37..8acacef 100644 --- a/src/lib/dashboard-context.svelte.ts +++ b/src/lib/dashboard-context.svelte.ts @@ -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 { - const period = this.periodFor(range); + async runFor(range: DashboardRange, terminal: string, periodId = ''): Promise { + 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); } diff --git a/src/lib/dashboard-filters.test.ts b/src/lib/dashboard-filters.test.ts index 5d4803b..e91df67 100644 --- a/src/lib/dashboard-filters.test.ts +++ b/src/lib/dashboard-filters.test.ts @@ -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'), diff --git a/src/lib/dashboard-filters.ts b/src/lib/dashboard-filters.ts index c0b017d..2d39ff4 100644 --- a/src/lib/dashboard-filters.ts +++ b/src/lib/dashboard-filters.ts @@ -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(['day', 'week', 'month', 'season']); const statuses = new Set(['all', 'Paid', 'Verified', 'Failed', 'Reversed']); +const periodPatterns: Record = { + 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, diff --git a/src/lib/insights.test.ts b/src/lib/insights.test.ts index 0308dbb..8525e32 100644 --- a/src/lib/insights.test.ts +++ b/src/lib/insights.test.ts @@ -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); + }); }); diff --git a/src/lib/insights.ts b/src/lib/insights.ts index d058b2d..4b673d8 100644 --- a/src/lib/insights.ts +++ b/src/lib/insights.ts @@ -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: 'مشتریان دوباره امتحان کرده‌اند؛ کوتاه‌تر کردن مسیر خطا و روشن‌تر کردن راهنمای تلاش مجدد می‌تواند شانس موفقیت بعدی را بیشتر کند.', + }; +} diff --git a/src/lib/reporting-period-selection.test.ts b/src/lib/reporting-period-selection.test.ts new file mode 100644 index 0000000..5bed15f --- /dev/null +++ b/src/lib/reporting-period-selection.test.ts @@ -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])); + }); +}); diff --git a/src/lib/reporting-period-selection.ts b/src/lib/reporting-period-selection.ts new file mode 100644 index 0000000..ffd32bf --- /dev/null +++ b/src/lib/reporting-period-selection.ts @@ -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 { + const grouped = new Map(); + 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) { + 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(); + 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(); + 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), + }; + }); +} diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 2e5d2c9..b07ec19 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -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 @@ {#if !dashboard.ready} -
-
- -

در حال آماده‌سازی پنل…

+
+
+ +
+
+ +
+ + +
+ {#each Array(4) as _}{/each} +
+
{:else if dashboard.session} diff --git a/static/brand/zarinpal-wordmark-dark.png b/static/brand/zarinpal-wordmark-dark.png new file mode 100644 index 0000000..c1b9ea6 Binary files /dev/null and b/static/brand/zarinpal-wordmark-dark.png differ