diff --git a/DESIGN.md b/DESIGN.md index 505bd10..6eca8b8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -132,6 +132,7 @@ The approved Persian-first baseline remains authoritative where the shipped arti | Version | Date | Decision | | ------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 0.7.0 | 2026-08-20 | Made period changes confirm-on-selection, moved narrow-screen filters into a bottom sheet, enlarged collapsed navigation targets, simplified focus rings, and promoted the fee-share chart into a dedicated visual column. | | 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. | @@ -197,7 +198,7 @@ The palette is a neutral financial canvas with one decisive blue voice, a very s ### Hierarchy - **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. +- **Section Heading** (700, 20px; 24px from 640px, 1.5): Major analytics modules and explanatory 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. @@ -245,7 +246,7 @@ The form language is low-radius and precise. Data cards, filter bars, inputs, se - **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. +- **Hover / Focus:** Hover changes tone; active state moves down by 1px where the action is press-like. Keyboard focus uses a restrained 1px semantic blue ring plus the border shift. State transitions stay around 160ms. - **Ghost:** Transparent at rest, muted-neutral on hover, used for inactive range choices and utility icons. ### Chips @@ -264,7 +265,7 @@ The form language is low-radius and precise. Data cards, filter bars, inputs, se ### 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. +- **Focus:** Border shifts to the ring color and gains a restrained 1px low-opacity blue ring. - **Error / Disabled:** Error uses danger border plus ring; disabled fields reduce opacity and preserve legible text. ### Navigation @@ -279,7 +280,7 @@ The signature KPI card pairs a muted label and optional formula help with a sema ### 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. +Daily, Saturday–Friday weekly, Jalali-month, and Persian-season controls share one URL-backed bar. Clicking a period type first opens the exact-period picker; the URL and backend request change only after a concrete period is chosen. The compact RTL popover provides 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 sit to the physical right of the calendar control after a divider. Below 768px, the full set moves into a bottom sheet. Backend-offered complete periods are the selectable authority. ### Loading State @@ -295,7 +296,7 @@ The table stays inside a bordered 8px surface, receives its compact filters from ### 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. +The fee page gives the circular share chart a large physical-right column while three numeric KPI cards stack in the adjacent column. 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 diff --git a/src/lib/components/AmountRangeFilter.svelte b/src/lib/components/AmountRangeFilter.svelte index 26bdfcf..9ec47f0 100644 --- a/src/lib/components/AmountRangeFilter.svelte +++ b/src/lib/components/AmountRangeFilter.svelte @@ -2,6 +2,7 @@ import SlidersHorizontalIcon from '@lucide/svelte/icons/sliders-horizontal'; import * as Popover from '$lib/components/ui/popover'; + import { Slider } from '$lib/components/ui/slider'; import { formatValue } from '$lib/format'; const LIMIT = 10_000_000_000; @@ -20,21 +21,20 @@ let open = $state(false); let localMin = $state(0); let localMax = $state(LIMIT); - const startPercent = $derived((localMin / LIMIT) * 100); - const endPercent = $derived((localMax / LIMIT) * 100); const hasFilter = $derived(minAmount !== null || maxAmount !== null); + let sliderValue = $state([0, LIMIT]); $effect(() => { localMin = Math.max(0, Math.min(minAmount ?? 0, LIMIT)); localMax = Math.max(localMin, Math.min(maxAmount ?? LIMIT, LIMIT)); + sliderValue = [localMin, localMax]; }); - function updateMin(value: number) { - localMin = Math.min(value, localMax - STEP); - } - - function updateMax(value: number) { - localMax = Math.max(value, localMin + STEP); + function updateRange(value: number[]) { + const [nextMin = 0, nextMax = LIMIT] = [...value].sort((left, right) => left - right); + localMin = nextMin; + localMax = nextMax; + sliderValue = [nextMin, nextMax]; } function apply() { @@ -45,7 +45,7 @@ @@ -64,29 +64,16 @@ تا {formatValue(localMax)} ریال -
- - updateMin(Number(event.currentTarget.value))} - /> - updateMax(Number(event.currentTarget.value))} - /> -
+
{:else if kind === 'fees' && fees.state === 'READY'} -
- - - - +
+ +
+ + + +
-
- +
+
{dashboard.session?.merchant.displayName} حساب تحلیل پذیرنده @@ -99,7 +100,7 @@ {#snippet child({ props })} - - - - - - - {#snippet child({ props })} - - - روش محاسبه - - {/snippet} - - - - - - + خروج از حساب diff --git a/src/lib/components/DashboardControls.svelte b/src/lib/components/DashboardControls.svelte index b575edf..0dd042c 100644 --- a/src/lib/components/DashboardControls.svelte +++ b/src/lib/components/DashboardControls.svelte @@ -3,9 +3,11 @@ import { page } from '$app/state'; import CalendarRangeIcon from '@lucide/svelte/icons/calendar-range'; import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw'; + import SlidersHorizontalIcon from '@lucide/svelte/icons/sliders-horizontal'; import { Button } from '$lib/components/ui/button'; import * as Select from '$lib/components/ui/select'; + import * as Sheet from '$lib/components/ui/sheet'; import { useDashboardContext } from '$lib/dashboard-context.svelte'; import { parseDashboardFilters, @@ -20,102 +22,185 @@ const terminalIds = $derived(dashboard.terminals.map((terminal) => terminal.terminalId)); const filters = $derived(parseDashboardFilters(page.url.searchParams, terminalIds)); const isTransactionsPage = $derived(page.url.pathname === '/transactions'); + const currentPeriod = $derived(dashboard.periodFor(filters.range, filters.period)); + let pickerRange = $state('month'); + let desktopPickerOpen = $state(false); + let mobilePickerOpen = $state(false); + let mobileFiltersOpen = $state(false); + + $effect(() => { + if (!desktopPickerOpen && !mobilePickerOpen) pickerRange = filters.range; + }); 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 }); + function openPeriodPicker(range: DashboardRange, mobile: boolean) { + pickerRange = range; + if (mobile) mobilePickerOpen = true; + else desktopPickerOpen = true; + } + + function applyPeriod(period: string) { + update({ range: pickerRange, period }); + desktopPickerOpen = false; + mobilePickerOpen = false; + mobileFiltersOpen = false; } -
-
-
- - update({ period })} - /> - - {#if isTransactionsPage} - -
- update({ status: value })} - items={[ - { value: 'all', label: 'همه وضعیت‌ها' }, - { value: 'Verified', label: 'تأییدشده' }, - { value: 'Paid', label: 'پرداخت‌شده' }, - { value: 'Failed', label: 'ناموفق' }, - { value: 'Reversed', label: 'برگشت‌خورده' }, - ]} - > - +
+ {#if !mobile}
+ + {#if mobile} + + {:else} + + {/if} +
+{/snippet} + +{#snippet transactionFilters()} +
+ 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), - })} - /> + + 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 filters.status !== 'all' || filters.minAmount !== null || filters.maxAmount !== null} + + {/if} +
+{/snippet} + + + +
+
+ بازه گزارش + {currentPeriod?.jalaliLabelFa ?? 'انتخاب بازه'} +
+ + + + + فیلترها + + + + فیلتر گزارش + بازه دقیق و فیلترهای این صفحه را انتخاب کنید. + + + {#if isTransactionsPage} +
+

فیلتر تراکنش‌ها

+ {@render transactionFilters()} +
+
+ {/if} + +
+

بازه زمانی

+ {@render periodControls(true)} +
+
+
diff --git a/src/lib/components/FeeShareDonut.svelte b/src/lib/components/FeeShareDonut.svelte index 906e475..69bd434 100644 --- a/src/lib/components/FeeShareDonut.svelte +++ b/src/lib/components/FeeShareDonut.svelte @@ -4,15 +4,16 @@ import { Card, CardContent } from '$lib/components/ui/card'; import * as Tooltip from '$lib/components/ui/tooltip'; import { formatPercent } from '$lib/format'; + import { cn } from '$lib/utils'; - let { value }: { value: string | null } = $props(); + let { value, class: className }: { value: string | null; class?: string } = $props(); const percent = $derived(value === null ? 0 : Math.max(0, Math.min(100, Number(value)))); - - + + -
-
+
+

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

-

+

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

diff --git a/src/lib/components/PeriodPicker.svelte b/src/lib/components/PeriodPicker.svelte index 128532f..16f815f 100644 --- a/src/lib/components/PeriodPicker.svelte +++ b/src/lib/components/PeriodPicker.svelte @@ -33,15 +33,16 @@ periods, range, periodId, + open = $bindable(false), onselect, }: { periods: readonly ReportingPeriod[]; range: DashboardRange; periodId: string; + open?: boolean; onselect: (periodId: string) => void; } = $props(); - let open = $state(false); let selectedYear = $state(0); let yearInput = $state(''); let calendarMonth = $state(1); @@ -114,7 +115,7 @@ @@ -132,7 +133,7 @@ سال گزارش (calendarMonth = Number(event.currentTarget.value))} > @@ -226,7 +227,7 @@ class:text-primary-foreground={day.periodId === selectedPeriod?.periodId || (range === 'week' && day.periodId === hoveredWeek)} class:opacity-25={!day.inCurrentMonth || !day.periodId} - class="hover:bg-primary/10 focus-visible:ring-ring aspect-square min-h-10 rounded-md text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none enabled:cursor-pointer disabled:cursor-not-allowed" + class="hover:bg-primary/10 focus-visible:ring-ring/40 aspect-square min-h-10 rounded-md text-xs transition-colors focus-visible:ring-1 focus-visible:outline-none enabled:cursor-pointer disabled:cursor-not-allowed" onmouseenter={() => { if (range === 'week' && day.periodId) hoveredWeek = day.periodId; }} diff --git a/src/lib/components/ui/input/input.svelte b/src/lib/components/ui/input/input.svelte index 955e2ec..3ea81bb 100644 --- a/src/lib/components/ui/input/input.svelte +++ b/src/lib/components/ui/input/input.svelte @@ -25,7 +25,7 @@ bind:this={ref} data-slot={dataSlot} class={cn( - 'border-input focus-visible:border-ring focus-visible:ring-ring/50 disabled:bg-input/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 file:text-foreground placeholder:text-muted-foreground h-8 w-full min-w-0 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-3 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3 md:text-sm', + 'border-input focus-visible:border-ring focus-visible:ring-ring/30 disabled:bg-input/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 file:text-foreground placeholder:text-muted-foreground h-8 w-full min-w-0 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-1 md:text-sm', className, )} type="file" @@ -38,7 +38,7 @@ bind:this={ref} data-slot={dataSlot} class={cn( - 'border-input focus-visible:border-ring focus-visible:ring-ring/50 disabled:bg-input/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 file:text-foreground placeholder:text-muted-foreground h-8 w-full min-w-0 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-3 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3 md:text-sm', + 'border-input focus-visible:border-ring focus-visible:ring-ring/30 disabled:bg-input/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 file:text-foreground placeholder:text-muted-foreground h-8 w-full min-w-0 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-1 md:text-sm', className, )} {type} diff --git a/src/lib/components/ui/select/select-trigger.svelte b/src/lib/components/ui/select/select-trigger.svelte index 9c0b9ba..b78c2b4 100644 --- a/src/lib/components/ui/select/select-trigger.svelte +++ b/src/lib/components/ui/select/select-trigger.svelte @@ -19,7 +19,7 @@ data-slot="select-trigger" data-size={size} class={cn( - "border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 flex w-fit items-center justify-between gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:ring-3 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-3 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "border-input focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 flex w-fit items-center justify-between gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:ring-1 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-1 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className, )} {...restProps} diff --git a/src/lib/components/ui/slider/index.ts b/src/lib/components/ui/slider/index.ts new file mode 100644 index 0000000..9a438ed --- /dev/null +++ b/src/lib/components/ui/slider/index.ts @@ -0,0 +1,7 @@ +import Root from './slider.svelte'; + +export { + Root, + // + Root as Slider, +}; diff --git a/src/lib/components/ui/slider/slider.svelte b/src/lib/components/ui/slider/slider.svelte new file mode 100644 index 0000000..7cdd2ad --- /dev/null +++ b/src/lib/components/ui/slider/slider.svelte @@ -0,0 +1,50 @@ + + + + + {#snippet children({ thumbItems })} + + + + {#each thumbItems as thumb (thumb.index)} + + {/each} + {/snippet} + diff --git a/src/routes/(app)/methodology/+page.svelte b/src/routes/(app)/methodology/+page.svelte deleted file mode 100644 index 9e62a44..0000000 --- a/src/routes/(app)/methodology/+page.svelte +++ /dev/null @@ -1,35 +0,0 @@ - - -روش محاسبه | تحلیل زرین‌پال - -
- {#each [['نرخ موفقیت پرداخت', formulas.paymentSuccess, 'موفقیت بانکی معیار است و تأیید پذیرنده جداگانه گزارش می‌شود.'], ['تکمیل تأیید', formulas.verification, 'نشان می‌دهد چه سهمی از پرداخت‌های موفق به تأیید نهایی رسیده‌اند.'], ['بازیابی تلاش دوباره', formulas.retryRecovery, 'فقط نشست‌هایی در مخرج هستند که بیش از یک تلاش داشته‌اند.'], ['نرخ مؤثر کارمزد', formulas.feeRate, 'مبلغ واقعی کارمزد ثبت‌شده در داده مبنای محاسبه است.']] as item} -

{item[0]}

- {item[1]} -

{item[2]}

- {/each} -
- -

- مقایسه هم‌گروه فقط زمانی نمایش داده می‌شود که تعداد کسب‌وکارها و نشست‌ها به حد امن برسد. هیچ عددی از یک - پذیرنده دیگر به‌صورت جداگانه قابل مشاهده نیست. -