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:
@@ -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­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 SECURE
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user