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

@@ -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;
}
</script>
<svelte:head><title>{titles[kind][0]} | تحلیل زرین‌پال</title></svelte:head>
@@ -195,11 +205,17 @@
</CardContent>
</Card>
{: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}
<div class="grid gap-3 min-[600px]:grid-cols-2 sm:gap-4 lg:grid-cols-4">
<KpiCard
label="رسیده به بانک"
@@ -215,11 +231,11 @@
tone="good"
/>
<KpiCard
label="بازیابی تلاش دوباره"
value={formatPercent(performance.data.retryRecoveryRate.value)}
note="موفقیت مشتریانی که پرداخت را دوباره امتحان کرده‌اند"
label="نرخ تلاش دوباره"
value={formatPercent(retryRate)}
note="سهم پرداخت‌های ناموفق که دوباره امتحان شده‌اند"
icon={RefreshCcwIcon}
formula={formulas.retryRecovery}
formula={formulas.retryEngagement}
/>
<KpiCard
label="میانه زمان تأیید"
@@ -233,13 +249,16 @@
<Card class="rounded-lg shadow-xs">
<CardHeader><CardTitle class="text-base">جریان مسیر پرداخت</CardTitle></CardHeader>
<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}
<div class="flow-step" style={`width:${100 - index * 9}%`}>
{#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>
<div class="mb-1.5 flex items-center justify-between text-xs">
<span>{step[0]}</span><strong>{formatValue(Number(step[1]))}</strong>
</div>
<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>
{/each}
@@ -250,8 +269,18 @@
<LightbulbIcon class={`mb-5 size-7 ${opportunityClass.icon}`} />
<h2 class="font-bold">{opportunity.title}</h2>
<p class="text-muted-foreground mt-2 text-sm leading-7">
{formatValue(performance.data.retriedSessions.value)} مشتری دوباره تلاش کرده‌اند و نرخ بازیابی آن‌ها
{formatPercent(performance.data.retryRecoveryRate.value)} بوده است. {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>
</CardContent>
</Card>

View File

@@ -124,7 +124,7 @@
<Sidebar.MenuItem>
<Sidebar.MenuButton
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}
>
<LogOutIcon class="size-[18px]" />

View File

@@ -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}
</Popover.Content>
</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 { 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);
});
});

View File

@@ -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: 'هنوز سهم کمی از پرداخت‌های ناموفق دوباره امتحان می‌شوند؛ کوتاه‌تر کردن مسیر خطا و نمایش یک راهنمای روشن می‌تواند بازگشت را آسان‌تر کند.',
};
}