feat(web): billing & Stripe — empty state CTA, failed-payment hint, inline retry, card-brand trust

- Billing empty state now has a ReceiptText avatar and (when no subscription)
  a "choose plan" CTA — beats a bare "no payments yet" line
- Receipt buttons promoted from ghost to outline so users actually find them
- Failed payments show a help icon with a hint (card declined → bank / retry)
- Stripe button: Clock → Loader2 (Clock-as-spinner was awkward)
- Stripe init failure now also surfaces inline with a retry button — a toast
  vanishing in 4s left users with no recovery affordance
- Adds an accepted-cards trust strip (VISA / Mastercard / Troy / 3D Secure)
  below the pay button to ground the secure-checkout copy
This commit is contained in:
2026-05-31 23:06:46 +03:00
parent 611b41c793
commit 35871403c4
4 changed files with 77 additions and 12 deletions

View File

@@ -7,7 +7,7 @@ import { toast } from "@/lib/toast";
import { formatTRY } from "@sase/shared";
import { Button } from "@sase/ui";
import { useMutation } from "@tanstack/react-query";
import { Clock, CreditCard, Lock } from "lucide-react";
import { AlertCircle, CreditCard, Loader2, Lock } from "lucide-react";
interface StripeCheckoutButtonProps {
planKey: string;
@@ -89,7 +89,7 @@ export function StripeCheckoutButton({
>
{mutation.isPending ? (
<>
<Clock className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("payment.paying")}
</>
) : (
@@ -99,6 +99,41 @@ export function StripeCheckoutButton({
</>
)}
</Button>
{/* Inline error + retry — a toast alone vanishes in 4s and a user
retrying via the main button gets no clue what failed. */}
{mutation.isError && !mutation.isPending && (
<div
role="alert"
className="flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/5 p-3 text-sm"
>
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div className="flex flex-1 flex-col gap-2">
<p className="leading-5 text-foreground">{t("payment.initializeFailed")}</p>
<Button variant="outline" size="sm" onClick={handleClick}>
{t("payment.stripe.retry")}
</Button>
</div>
</div>
)}
{/* Accepted-card row — somatic trust beats abstract padlock copy.
Plain text marks keep the bundle lean (no logo SVG cost). */}
<div className="flex flex-wrap items-center justify-center gap-2 text-[11px] uppercase tracking-wider text-muted-foreground">
<span>{t("payment.stripe.cardsAccepted")}</span>
<span className="rounded border border-border bg-muted px-1.5 py-0.5 font-semibold">
VISA
</span>
<span className="rounded border border-border bg-muted px-1.5 py-0.5 font-semibold">
MASTER&shy;CARD
</span>
<span className="rounded border border-border bg-muted px-1.5 py-0.5 font-semibold">
TROY
</span>
<span className="rounded border border-border bg-muted px-1.5 py-0.5 font-semibold">
3D&nbsp;SECURE
</span>
</div>
</div>
);
}

View File

@@ -367,7 +367,9 @@
"cancelled": "Payment cancelled. You can try again.",
"verifyingTitle": "Verifying your payment",
"verifyingDescription": "Your subscription will activate as soon as Stripe confirms. Stay on this page for a few seconds.",
"trustProvider": "Stripe payment infrastructure"
"trustProvider": "Stripe payment infrastructure",
"retry": "Retry",
"cardsAccepted": "We accept:"
}
},
"billing": {
@@ -385,6 +387,7 @@
"downloadReceipt": "Download Receipt",
"viewReceipt": "Your receipt",
"receiptUnavailable": "No receipt available for this payment.",
"failedHint": "Card declined. Contact your bank or retry with a different card.",
"filterByStatus": "Filter by Status",
"filterByMethod": "Filter by Method",
"statusLabels": {

View File

@@ -367,7 +367,9 @@
"cancelled": "Ödeme iptal edildi. Tekrar deneyebilirsin.",
"verifyingTitle": "Ödemen doğrulanıyor",
"verifyingDescription": "Stripe onayı geldiğinde aboneliğin otomatik aktifleşecek. Bu sayfayı kapatmadan birkaç saniye bekle.",
"trustProvider": "Stripe ödeme altyapısı"
"trustProvider": "Stripe ödeme altyapısı",
"retry": "Yeniden dene",
"cardsAccepted": "Kabul edilen:"
}
},
"billing": {
@@ -385,6 +387,7 @@
"downloadReceipt": "Dekontu İndir",
"viewReceipt": "Makbuzunuz",
"receiptUnavailable": "Bu ödeme için makbuz bulunamadı.",
"failedHint": "Kart ödemesi reddedildi. Bankanızı arayabilir veya farklı bir kartla yeniden deneyebilirsiniz.",
"filterByStatus": "Duruma Göre Filtrele",
"filterByMethod": "Yönteme Göre Filtrele",
"statusLabels": {

View File

@@ -8,7 +8,7 @@ import { Button } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { ArrowRight, Download, Filter, Receipt } from "lucide-react";
import { ArrowRight, Download, Filter, HelpCircle, Receipt, ReceiptText } from "lucide-react";
import { useState } from "react";
export const Route = createFileRoute("/dashboard/billing")({
@@ -237,8 +237,21 @@ function BillingPage() {
</Card>
) : !filteredPayments || filteredPayments.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-12 text-center text-muted-foreground">
<p>{hasActiveFilter ? t("billing.noResults") : t("billing.noPayments")}</p>
<CardContent className="flex flex-col items-center gap-3 py-12 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
<ReceiptText className="h-6 w-6" />
</div>
<p className="text-sm text-muted-foreground">
{hasActiveFilter ? t("billing.noResults") : t("billing.noPayments")}
</p>
{!hasActiveFilter && !subscription && (
<Button asChild size="sm">
<Link to="/dashboard/subscription">
{t("billing.summary.choosePlan")}
<ArrowRight className="ml-1.5 size-4" />
</Link>
</Button>
)}
{hasActiveFilter && (
<Button variant="outline" size="sm" onClick={clearFilters}>
{t("billing.clearFilters")}
@@ -300,13 +313,24 @@ function BillingPage() {
</Badge>
</td>
<td data-label={t("billing.status")} className={`${TD} sm:text-center`}>
<Badge variant={statusVariants[payment.status] || "secondary"}>
{t(`billing.statusLabels.${payment.status}`)}
</Badge>
<span className="inline-flex items-center gap-1.5">
<Badge variant={statusVariants[payment.status] || "secondary"}>
{t(`billing.statusLabels.${payment.status}`)}
</Badge>
{payment.status === "failed" && (
<span
className="inline-flex cursor-help text-muted-foreground"
title={t("billing.failedHint")}
aria-label={t("billing.failedHint")}
>
<HelpCircle className="h-3.5 w-3.5" />
</span>
)}
</span>
</td>
<td className="block pt-3 text-right sm:table-cell sm:py-4 sm:pt-4">
{payment.eftReceiptUrl ? (
<Button variant="ghost" size="sm" asChild>
<Button variant="outline" size="sm" asChild>
<a
href={payment.eftReceiptUrl}
target="_blank"
@@ -319,7 +343,7 @@ function BillingPage() {
</Button>
) : payment.hasStripeReceipt ? (
<Button
variant="ghost"
variant="outline"
size="sm"
disabled={receiptLoadingId === payment.id}
onClick={() => openReceipt(payment.id)}