fix: refine payment retry guidance

This commit is contained in:
2026-08-20 23:19:04 +03:30
parent 760e5c6752
commit b983eb37e8
6 changed files with 92 additions and 30 deletions

View File

@@ -132,6 +132,7 @@ The approved Persian-first baseline remains authoritative where the shipped arti
| Version | Date | Decision | | 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.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.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. | | 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. 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 ### 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. 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.

View File

@@ -22,7 +22,13 @@
import { useDashboardContext } from '$lib/dashboard-context.svelte'; import { useDashboardContext } from '$lib/dashboard-context.svelte';
import { parseDashboardFilters } from '$lib/dashboard-filters'; import { parseDashboardFilters } from '$lib/dashboard-filters';
import { formatMoney, formatMilliseconds, formatPercent, formatValue } from '$lib/format'; 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 { formatPeerMetric, peerMetricLabel } from '$lib/peer-metrics';
import KpiCard from './KpiCard.svelte'; import KpiCard from './KpiCard.svelte';
import FeeShareDonut from './FeeShareDonut.svelte'; import FeeShareDonut from './FeeShareDonut.svelte';
@@ -102,6 +108,10 @@
icon: 'text-amber-600 dark:text-amber-400', 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;
}
</script> </script>
<svelte:head><title>{titles[kind][0]} | تحلیل زرین‌پال</title></svelte:head> <svelte:head><title>{titles[kind][0]} | تحلیل زرین‌پال</title></svelte:head>
@@ -195,11 +205,17 @@
</CardContent> </CardContent>
</Card> </Card>
{:else if kind === 'payments' && performance.state === 'READY'} {:else if kind === 'payments' && performance.state === 'READY'}
{@const opportunity = retryOpportunity( {@const retryRate = retryEngagementRate(
Number(performance.data.retryRecoveryRate.value ?? 0),
performance.data.retriedSessions.value, performance.data.retriedSessions.value,
performance.data.failedSessions.value,
performance.data.retryRecoveryRate.numerator,
)} )}
{@const opportunity = retryOpportunity(Number(retryRate ?? 0))}
{@const opportunityClass = retryOpportunityClass(opportunity.tone)} {@const opportunityClass = retryOpportunityClass(opportunity.tone)}
{@const flowTotal =
performance.data.bankPaidSessions.value +
performance.data.failedSessions.value +
performance.data.noAttemptSessions.value}
<div class="grid gap-3 min-[600px]:grid-cols-2 sm:gap-4 lg:grid-cols-4"> <div class="grid gap-3 min-[600px]:grid-cols-2 sm:gap-4 lg:grid-cols-4">
<KpiCard <KpiCard
label="رسیده به بانک" label="رسیده به بانک"
@@ -215,11 +231,11 @@
tone="good" tone="good"
/> />
<KpiCard <KpiCard
label="بازیابی تلاش دوباره" label="نرخ تلاش دوباره"
value={formatPercent(performance.data.retryRecoveryRate.value)} value={formatPercent(retryRate)}
note="موفقیت مشتریانی که پرداخت را دوباره امتحان کرده‌اند" note="سهم پرداخت‌های ناموفق که دوباره امتحان شده‌اند"
icon={RefreshCcwIcon} icon={RefreshCcwIcon}
formula={formulas.retryRecovery} formula={formulas.retryEngagement}
/> />
<KpiCard <KpiCard
label="میانه زمان تأیید" label="میانه زمان تأیید"
@@ -233,13 +249,16 @@
<Card class="rounded-lg shadow-xs"> <Card class="rounded-lg shadow-xs">
<CardHeader><CardTitle class="text-base">جریان مسیر پرداخت</CardTitle></CardHeader> <CardHeader><CardTitle class="text-base">جریان مسیر پرداخت</CardTitle></CardHeader>
<CardContent class="space-y-4 pb-6"> <CardContent class="space-y-4 pb-6">
{#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}
<div class="flow-step" style={`width:${100 - index * 9}%`}> <div>
<div class="mb-1.5 flex items-center justify-between text-xs"> <div class="mb-1.5 flex items-center justify-between text-xs">
<span>{step[0]}</span><strong>{formatValue(Number(step[1]))}</strong> <span>{step[0]}</span><strong>{formatValue(Number(step[1]))}</strong>
</div> </div>
<div class="bg-muted h-2 overflow-hidden rounded-sm"> <div class="bg-muted h-2 overflow-hidden rounded-sm">
<div class={`h-full ${step[2]}`}></div> <div
class={`h-full rounded-sm transition-[width] duration-500 ${step[2]}`}
style={`width:${flowWidth(Number(step[1]), flowTotal)}%`}
></div>
</div> </div>
</div> </div>
{/each} {/each}
@@ -250,8 +269,18 @@
<LightbulbIcon class={`mb-5 size-7 ${opportunityClass.icon}`} /> <LightbulbIcon class={`mb-5 size-7 ${opportunityClass.icon}`} />
<h2 class="font-bold">{opportunity.title}</h2> <h2 class="font-bold">{opportunity.title}</h2>
<p class="text-muted-foreground mt-2 text-sm leading-7"> <p class="text-muted-foreground mt-2 text-sm leading-7">
{formatValue(performance.data.retriedSessions.value)} مشتری دوباره تلاش کرده‌اند و نرخ بازیابی آن‌ها از میان
{formatPercent(performance.data.retryRecoveryRate.value)} بوده است. {opportunity.copy} <strong class="text-foreground font-extrabold tabular-nums"
>{formatValue(
performance.data.failedSessions.value + performance.data.retryRecoveryRate.numerator,
)} مسیر ناموفق</strong
>،
<strong class="text-foreground font-extrabold tabular-nums"
>{formatValue(performance.data.retriedSessions.value)} مورد</strong
>
دوباره امتحان شده‌اند؛ یعنی
<strong class="text-foreground font-extrabold tabular-nums">{formatPercent(retryRate)}</strong>.
{opportunity.copy}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -124,7 +124,7 @@
<Sidebar.MenuItem> <Sidebar.MenuItem>
<Sidebar.MenuButton <Sidebar.MenuButton
tooltipContent="خروج" tooltipContent="خروج"
class="h-11 px-3 group-data-[collapsible=icon]:size-11! group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:p-0! group-data-[collapsible=icon]:[&_svg]:size-5! group-data-[collapsible=icon]:[&>span]:hidden" class="text-destructive hover:bg-destructive/10 hover:text-destructive h-11 px-3 group-data-[collapsible=icon]:size-11! group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:p-0! group-data-[collapsible=icon]:[&_svg]:size-5! group-data-[collapsible=icon]:[&>span]:hidden"
onclick={logout} onclick={logout}
> >
<LogOutIcon class="size-[18px]" /> <LogOutIcon class="size-[18px]" />

View File

@@ -226,7 +226,8 @@
class:text-primary-foreground={day.periodId === selectedPeriod?.periodId || class:text-primary-foreground={day.periodId === selectedPeriod?.periodId ||
(range === 'week' && day.periodId === hoveredWeek)} (range === 'week' && day.periodId === hoveredWeek)}
class:opacity-25={!day.inCurrentMonth || !day.periodId} 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={() => { onmouseenter={() => {
if (range === 'week' && day.periodId) hoveredWeek = day.periodId; if (range === 'week' && day.periodId) hoveredWeek = day.periodId;
}} }}
@@ -243,3 +244,9 @@
{/if} {/if}
</Popover.Content> </Popover.Content>
</Popover.Root> </Popover.Root>
<style>
.day-hover:hover {
background: color-mix(in srgb, var(--primary) 10%, transparent);
}
</style>

View File

@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest'; 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', () => { describe('merchant-friendly insights', () => {
it('celebrates strong peer performance without claiming causality', () => { it('celebrates strong peer performance without claiming causality', () => {
@@ -27,11 +33,15 @@ describe('merchant-friendly insights', () => {
expect(peerProgressTone(88)).toBe('leading'); expect(peerProgressTone(88)).toBe('leading');
}); });
it('changes recovery tone and copy at meaningful retry thresholds', () => { it('returns zero retry engagement when failures exist but nobody retries', () => {
expect(retryOpportunity(72, 150).tone).toBe('excellent'); expect(retryEngagementRate(0, 8, 0)).toBe('0.00');
expect(retryOpportunity(48, 80).tone).toBe('positive'); expect(retryEngagementRate(0, 0, 0)).toBeNull();
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('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);
}); });
}); });

View File

@@ -1,7 +1,8 @@
export const formulas = { export const formulas = {
paymentSuccess: 'تعداد پرداخت‌های موفق بانکی ÷ همه نشست‌های پرداخت × ۱۰۰', paymentSuccess: 'تعداد پرداخت‌های موفق بانکی ÷ همه نشست‌های پرداخت × ۱۰۰',
verification: 'پرداخت‌های تأییدشده ÷ پرداخت‌های موفق بانکی × ۱۰۰', verification: 'پرداخت‌های تأییدشده ÷ پرداخت‌های موفق بانکی × ۱۰۰',
retryRecovery: 'پرداخت‌های موفق پس از تلاش دوباره ÷ همه پرداخت‌های دارای تلاش دوباره × ۱۰۰', retryEngagement:
'پرداخت‌های دارای تلاش دوباره ÷ مجموع پرداخت‌های ناموفق نهایی و پرداخت‌های بازیابی‌شده × ۱۰۰',
feeRate: 'مجموع کارمزد پرداخت‌شده ÷ مبلغ پرداخت‌های موفق بانکی × ۱۰۰', feeRate: 'مجموع کارمزد پرداخت‌شده ÷ مبلغ پرداخت‌های موفق بانکی × ۱۰۰',
}; };
@@ -42,24 +43,34 @@ export type RetryOpportunity = {
copy: string; copy: string;
}; };
export function retryOpportunity(rate: number, retriedSessions: number): RetryOpportunity { export function retryEngagementRate(
if (rate >= 65 && retriedSessions >= 100) { 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 { return {
tone: 'excellent', tone: 'excellent',
title: 'بازگشت قدرتمند مشتریان', title: 'مسیر بازگشت فعال است',
copy: 'بخش بزرگی از مشتریانی که دوباره تلاش کرده‌اند به پرداخت موفق رسیده‌اند؛ این روند نشان می‌دهد مسیر بازگشت فعلی قابل اتکاست.', copy: 'بخش بزرگی از پرداخت‌های ناموفق دوباره امتحان شده‌اند؛ مسیر تلاش مجدد شما برای مشتریان روشن و در دسترس است.',
}; };
} }
if (rate >= 35 && retriedSessions >= 50) { if (rate >= 20) {
return { return {
tone: 'positive', tone: 'positive',
title: 'روند بازیابی رو به جلوست', title: 'مشتریان مسیر بازگشت را پیدا می‌کنند',
copy: 'بخشی از مشتریان در تلاش دوباره موفق شده‌اند. مرور مرحله‌های ناموفق می‌تواند همین مسیر مثبت را تقویت کند.', copy: 'بخشی از پرداخت‌های ناموفق دوباره امتحان شده‌اند. روشن‌تر کردن پیام خطا می‌تواند این روند مثبت را تقویت کند.',
}; };
} }
return { return {
tone: 'opportunity', tone: 'opportunity',
title: 'فرصتی برای ساده‌تر کردن بازگشت', title: 'فرصتی برای ساده‌تر کردن بازگشت',
copy: 'مشتریان دوباره امتحان کرده‌اند؛ کوتاه‌تر کردن مسیر خطا و روشن‌تر کردن راهنمای تلاش مجدد می‌تواند شانس موفقیت بعدی را بیشتر کند.', copy: 'هنوز سهم کمی از پرداخت‌های ناموفق دوباره امتحان می‌شوند؛ کوتاه‌تر کردن مسیر خطا و نمایش یک راهنمای روشن می‌تواند بازگشت را آسان‌تر کند.',
}; };
} }