feat(web): show persistent trial-progress banner for days 8-30

Trial users previously got no reminder of remaining days until the urgent
(<=7d) amber banner appeared, leaving most of the 30-day window with no
conversion nudge. Added a calm, brand-toned TrialProgressBanner shown when
more than 7 days remain: days left, a progress bar (elapsed share of the
30-day trial), and a "Planını seç" CTA that scrolls to checkout and fires
trial_progress_cta_clicked. The urgent banner still owns the final 7 days.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 15:29:42 +03:00
parent 2912dc6eb2
commit 0bae337c69
4 changed files with 116 additions and 0 deletions

View File

@@ -270,6 +270,11 @@
"description": "{days} days left. Upgrade now to continue accessing all features.",
"cta": "Upgrade Plan"
},
"trialProgress": {
"title": "{days} days left in your Full Package trial",
"description": "Pick a plan now to keep your access without interruption.",
"cta": "Choose a plan"
},
"pendingBanner": {
"title": "You have a pending payment",
"description": "Checkout for {plan} ({brands}) was left incomplete. Resume it or cancel to pick a different plan.",

View File

@@ -270,6 +270,11 @@
"description": "{days} gününüz kaldı. Tüm özelliklere erişmeye devam etmek için hemen yükseltin.",
"cta": "Plana Yükselt"
},
"trialProgress": {
"title": "Full Paket denemende {days} gün kaldı",
"description": "Kesintisiz devam etmek için planını şimdi seç.",
"cta": "Planını seç"
},
"pendingBanner": {
"title": "Bekleyen ödemen var",
"description": "{plan} planı, {brands} için ödeme yarıda kaldı. Devam edebilir veya farklı plan seçmek için vazgeçebilirsin.",

View File

@@ -0,0 +1,52 @@
/**
* Tests for TrialProgressBanner — the calm, persistent trial-status strip
* shown for days 830 of a trial. Previously trial users saw no reminder of
* remaining days until the urgent (<=7d) banner appeared, leaving the bulk of
* the trial window with no conversion nudge.
*/
import { fireEvent, render } from "@testing-library/react";
import { vi } from "vitest";
vi.mock("@/lib/i18n", () => ({
useTranslation: () => ({
t: (key: string, vars?: Record<string, unknown>) =>
vars ? `${key}:${JSON.stringify(vars)}` : key,
locale: "tr",
setLocale: vi.fn(),
}),
}));
import { TrialProgressBanner } from "@/routes/dashboard/subscription/index";
describe("TrialProgressBanner", () => {
test("renders remaining days, description and CTA", () => {
const { container } = render(<TrialProgressBanner daysRemaining={20} onCta={vi.fn()} />);
const text = container.textContent ?? "";
expect(text).toContain("subscription.trialProgress.title");
expect(text).toContain('"days":20');
expect(text).toContain("subscription.trialProgress.description");
expect(text).toContain("subscription.trialProgress.cta");
});
test("progress bar width reflects elapsed share of a 30-day trial", () => {
// 20 days left of 30 => 10 elapsed => 33%
const { container } = render(<TrialProgressBanner daysRemaining={20} onCta={vi.fn()} />);
const bar = container.querySelector('[style*="width"]') as HTMLElement | null;
expect(bar).not.toBeNull();
expect(bar!.style.width).toBe("33%");
});
test("clamps progress for out-of-range day counts", () => {
const { container } = render(<TrialProgressBanner daysRemaining={40} onCta={vi.fn()} />);
const bar = container.querySelector('[style*="width"]') as HTMLElement;
expect(bar.style.width).toBe("0%");
});
test("CTA click invokes onCta", () => {
const onCta = vi.fn();
const { getByRole } = render(<TrialProgressBanner daysRemaining={15} onCta={onCta} />);
fireEvent.click(getByRole("button"));
expect(onCta).toHaveBeenCalledTimes(1);
});
});

View File

@@ -589,6 +589,16 @@ export function SubscriptionPage() {
/>
)}
{trialDaysRemaining !== null && trialDaysRemaining > 7 && (
<TrialProgressBanner
daysRemaining={trialDaysRemaining}
onCta={() => {
capture("trial_progress_cta_clicked", { days_remaining: trialDaysRemaining });
document.getElementById("checkout-flow")?.scrollIntoView({ behavior: "smooth" });
}}
/>
)}
{isPending && subscription && (
<PendingPaymentBanner
planName={subscription.plan?.name ?? ""}
@@ -1611,6 +1621,50 @@ function TrialUrgencyBanner({
);
}
export function TrialProgressBanner({
daysRemaining,
totalDays = 30,
onCta,
}: {
daysRemaining: number;
totalDays?: number;
onCta: () => void;
}) {
const { t } = useTranslation();
const elapsed = Math.min(totalDays, Math.max(0, totalDays - daysRemaining));
const pct = Math.round((elapsed / totalDays) * 100);
return (
<div
role="status"
className="animate-fade-in-up flex flex-col gap-3 rounded-2xl border border-brand/25 bg-brand/5 px-5 py-4 sm:flex-row sm:items-center"
>
<div className="flex flex-1 items-start gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand/15 text-brand">
<Sparkles className="h-4 w-4" />
</div>
<div className="flex-1">
<p className="text-sm font-semibold leading-5 text-foreground">
{t("subscription.trialProgress.title", { days: daysRemaining })}
</p>
<p className="text-xs leading-5 text-muted-foreground">
{t("subscription.trialProgress.description")}
</p>
<div className="mt-2 h-1.5 w-full max-w-xs overflow-hidden rounded-full bg-brand/15">
<div
className="h-full rounded-full bg-brand transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</div>
</div>
<Button size="sm" className="shrink-0" onClick={onCta}>
{t("subscription.trialProgress.cta")}
<ArrowRight className="ml-1.5 h-3.5 w-3.5" />
</Button>
</div>
);
}
interface TrialMutation {
isPending: boolean;
isError: boolean;