fix: refine payment retry guidance
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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]" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: 'هنوز سهم کمی از پرداختهای ناموفق دوباره امتحان میشوند؛ کوتاهتر کردن مسیر خطا و نمایش یک راهنمای روشن میتواند بازگشت را آسانتر کند.',
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user