From b983eb37e8bb425619fdec70d27b2a1d77f322d6 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Thu, 20 Aug 2026 23:19:04 +0330 Subject: [PATCH] fix: refine payment retry guidance --- DESIGN.md | 5 +++ src/lib/components/AnalyticsPage.svelte | 53 +++++++++++++++++++------ src/lib/components/AppShell.svelte | 2 +- src/lib/components/PeriodPicker.svelte | 9 ++++- src/lib/insights.test.ts | 24 +++++++---- src/lib/insights.ts | 29 +++++++++----- 6 files changed, 92 insertions(+), 30 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index a8f190f..604c9c4 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.10.0 | 2026-08-20 | Reframed retry guidance around the share of failed payment paths retried, including an explicit zero state; made payment-flow bars proportional to counts; and removed single-day hover styling from whole-week selection. | | 0.9.3 | 2026-08-20 | Replaced stacked field focus outlines with one narrow semantic-blue border across text fields, textareas, native selects, and custom select triggers; other keyboard targets retain a subtle 1px outline. | | 0.9.2 | 2026-08-20 | Pinned the initialization skeleton sidebar to the physical right and reserved matching right-side content space so loading and loaded shells share the same geometry. | | 0.9.1 | 2026-08-20 | Distributed the desktop period-type group and exact-period control across the available filter-bar width while preserving the compact mobile sheet layout. | @@ -295,6 +296,10 @@ Scope changes immediately replace data-dependent cards, tables, detail panels, a The signature chart is a blue line with outlined points, a low-opacity blue area, dashed neutral gridlines, labeled X/Y axes, Persian date ticks, and successful-payment count ticks. 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. Point tooltips use a short scale/fade transition, flip above or below according to vertical room, and clamp their center so the surface never leaves the chart wrapper. +### Payment Performance + +Retry engagement is the share of failed payment paths that customers tried again, using final failures plus recovered failed paths as the opportunity denominator. When opportunities exist and none are retried, the merchant sees `۰٪`, not an unavailable state. The adaptive guidance depends only on this percentage. Flow bars use the same total-session scale, so equal counts always render at equal widths. + ### 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. diff --git a/src/lib/components/AnalyticsPage.svelte b/src/lib/components/AnalyticsPage.svelte index 7f42cdb..ea28619 100644 --- a/src/lib/components/AnalyticsPage.svelte +++ b/src/lib/components/AnalyticsPage.svelte @@ -22,7 +22,13 @@ import { useDashboardContext } from '$lib/dashboard-context.svelte'; import { parseDashboardFilters } from '$lib/dashboard-filters'; import { formatMoney, formatMilliseconds, formatPercent, formatValue } from '$lib/format'; - import { describePercentile, formulas, peerProgressTone, retryOpportunity } from '$lib/insights'; + import { + describePercentile, + formulas, + peerProgressTone, + retryEngagementRate, + retryOpportunity, + } from '$lib/insights'; import { formatPeerMetric, peerMetricLabel } from '$lib/peer-metrics'; import KpiCard from './KpiCard.svelte'; import FeeShareDonut from './FeeShareDonut.svelte'; @@ -102,6 +108,10 @@ icon: 'text-amber-600 dark:text-amber-400', }; } + + function flowWidth(value: number, total: number) { + return total > 0 ? Math.max(0, Math.min(100, (value * 100) / total)) : 0; + } {titles[kind][0]} | تحلیل زرین‌پال @@ -195,11 +205,17 @@ {:else if kind === 'payments' && performance.state === 'READY'} - {@const opportunity = retryOpportunity( - Number(performance.data.retryRecoveryRate.value ?? 0), + {@const retryRate = retryEngagementRate( performance.data.retriedSessions.value, + performance.data.failedSessions.value, + performance.data.retryRecoveryRate.numerator, )} + {@const opportunity = retryOpportunity(Number(retryRate ?? 0))} {@const opportunityClass = retryOpportunityClass(opportunity.tone)} + {@const flowTotal = + performance.data.bankPaidSessions.value + + performance.data.failedSessions.value + + performance.data.noAttemptSessions.value}
جریان مسیر پرداخت - {#each [['همه شروع‌های پرداخت', performance.data.bankPaidSessions.value + performance.data.failedSessions.value + performance.data.noAttemptSessions.value, 'bg-primary'], ['رسیده به بانک', performance.data.reachedBankSessions.value, 'bg-blue-500'], ['موفق در بانک', performance.data.bankPaidSessions.value, 'bg-emerald-500'], ['تأیید نهایی', performance.data.verifiedSessions.value, 'bg-emerald-600']] as step, index} -
+ {#each [['همه شروع‌های پرداخت', flowTotal, 'bg-primary'], ['رسیده به بانک', performance.data.reachedBankSessions.value, 'bg-blue-500'], ['موفق در بانک', performance.data.bankPaidSessions.value, 'bg-emerald-500'], ['تأیید نهایی', performance.data.verifiedSessions.value, 'bg-emerald-600']] as step} +
{step[0]}{formatValue(Number(step[1]))}
-
+
{/each} @@ -250,8 +269,18 @@

{opportunity.title}

- {formatValue(performance.data.retriedSessions.value)} مشتری دوباره تلاش کرده‌اند و نرخ بازیابی آن‌ها - {formatPercent(performance.data.retryRecoveryRate.value)} بوده است. {opportunity.copy} + از میان + {formatValue( + performance.data.failedSessions.value + performance.data.retryRecoveryRate.numerator, + )} مسیر ناموفق، + {formatValue(performance.data.retriedSessions.value)} مورد + دوباره امتحان شده‌اند؛ یعنی + {formatPercent(retryRate)}. + {opportunity.copy}

diff --git a/src/lib/components/AppShell.svelte b/src/lib/components/AppShell.svelte index 5b425cf..9e06ab5 100644 --- a/src/lib/components/AppShell.svelte +++ b/src/lib/components/AppShell.svelte @@ -124,7 +124,7 @@ diff --git a/src/lib/components/PeriodPicker.svelte b/src/lib/components/PeriodPicker.svelte index b226916..09a57ac 100644 --- a/src/lib/components/PeriodPicker.svelte +++ b/src/lib/components/PeriodPicker.svelte @@ -226,7 +226,8 @@ 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/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" + class:day-hover={range === 'day'} + class="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; }} @@ -243,3 +244,9 @@ {/if} + + diff --git a/src/lib/insights.test.ts b/src/lib/insights.test.ts index 8525e32..ed06d2a 100644 --- a/src/lib/insights.test.ts +++ b/src/lib/insights.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { describePercentile, formulas, peerProgressTone, retryOpportunity } from './insights'; +import { + describePercentile, + formulas, + peerProgressTone, + retryEngagementRate, + retryOpportunity, +} from './insights'; describe('merchant-friendly insights', () => { it('celebrates strong peer performance without claiming causality', () => { @@ -27,11 +33,15 @@ describe('merchant-friendly insights', () => { 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); + it('returns zero retry engagement when failures exist but nobody retries', () => { + expect(retryEngagementRate(0, 8, 0)).toBe('0.00'); + expect(retryEngagementRate(0, 0, 0)).toBeNull(); + }); + + it('changes retry guidance only at percentage thresholds', () => { + expect(retryOpportunity(72).tone).toBe('excellent'); + expect(retryOpportunity(35).tone).toBe('positive'); + expect(retryOpportunity(12).tone).toBe('opportunity'); + expect(retryOpportunity(72).copy).not.toBe(retryOpportunity(12).copy); }); }); diff --git a/src/lib/insights.ts b/src/lib/insights.ts index 4b673d8..33ce249 100644 --- a/src/lib/insights.ts +++ b/src/lib/insights.ts @@ -1,7 +1,8 @@ export const formulas = { paymentSuccess: 'تعداد پرداخت‌های موفق بانکی ÷ همه نشست‌های پرداخت × ۱۰۰', verification: 'پرداخت‌های تأییدشده ÷ پرداخت‌های موفق بانکی × ۱۰۰', - retryRecovery: 'پرداخت‌های موفق پس از تلاش دوباره ÷ همه پرداخت‌های دارای تلاش دوباره × ۱۰۰', + retryEngagement: + 'پرداخت‌های دارای تلاش دوباره ÷ مجموع پرداخت‌های ناموفق نهایی و پرداخت‌های بازیابی‌شده × ۱۰۰', feeRate: 'مجموع کارمزد پرداخت‌شده ÷ مبلغ پرداخت‌های موفق بانکی × ۱۰۰', }; @@ -42,24 +43,34 @@ export type RetryOpportunity = { copy: string; }; -export function retryOpportunity(rate: number, retriedSessions: number): RetryOpportunity { - if (rate >= 65 && retriedSessions >= 100) { +export function retryEngagementRate( + retriedSessions: number, + failedSessions: number, + recoveredSessions: number, +): string | null { + const failedOpportunities = failedSessions + recoveredSessions; + if (failedOpportunities <= 0) return null; + return ((retriedSessions * 100) / failedOpportunities).toFixed(2); +} + +export function retryOpportunity(rate: number): RetryOpportunity { + if (rate >= 50) { return { tone: 'excellent', - title: 'بازگشت قدرتمند مشتریان', - copy: 'بخش بزرگی از مشتریانی که دوباره تلاش کرده‌اند به پرداخت موفق رسیده‌اند؛ این روند نشان می‌دهد مسیر بازگشت فعلی قابل اتکاست.', + title: 'مسیر بازگشت فعال است', + copy: 'بخش بزرگی از پرداخت‌های ناموفق دوباره امتحان شده‌اند؛ مسیر تلاش مجدد شما برای مشتریان روشن و در دسترس است.', }; } - if (rate >= 35 && retriedSessions >= 50) { + if (rate >= 20) { return { tone: 'positive', - title: 'روند بازیابی رو به جلوست', - copy: 'بخشی از مشتریان در تلاش دوباره موفق شده‌اند. مرور مرحله‌های ناموفق می‌تواند همین مسیر مثبت را تقویت کند.', + title: 'مشتریان مسیر بازگشت را پیدا می‌کنند', + copy: 'بخشی از پرداخت‌های ناموفق دوباره امتحان شده‌اند. روشن‌تر کردن پیام خطا می‌تواند این روند مثبت را تقویت کند.', }; } return { tone: 'opportunity', title: 'فرصتی برای ساده‌تر کردن بازگشت', - copy: 'مشتریان دوباره امتحان کرده‌اند؛ کوتاه‌تر کردن مسیر خطا و روشن‌تر کردن راهنمای تلاش مجدد می‌تواند شانس موفقیت بعدی را بیشتر کند.', + copy: 'هنوز سهم کمی از پرداخت‌های ناموفق دوباره امتحان می‌شوند؛ کوتاه‌تر کردن مسیر خطا و نمایش یک راهنمای روشن می‌تواند بازگشت را آسان‌تر کند.', }; }