"use client"; import { ChevronDownIcon, ClockIcon, PlusIcon, TriangleAlertIcon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type FormEvent, type ReactNode, useEffect, useId, useRef, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Input } from "@/components/ui/input"; import { Radio, RadioGroup } from "@/components/ui/radio-group"; import { cn } from "@/lib/utils"; export type CardBrand = "visa" | "mastercard" | "amex" | "card"; export interface PaymentCard { id: string; brand: CardBrand; last4: string; expiry: string; note?: string; } export interface NewCardDetails { number: string; expiry: string; cvc: string; } export interface PaymentFailedProps { amount?: number; currency?: string; plan?: string; cards?: PaymentCard[]; failedCardId?: string; declineReason?: string; declineCode?: string; declineExplanation?: string; declines?: Record; graceUntil?: string; graceSeconds?: number; graceTotalSeconds?: number; billingEmail?: string; status?: "due" | "processing" | "paid"; paidCardId?: string; reasons?: string[]; onPay?: (cardId: string) => void; onAddCard?: (details: NewCardDetails) => void; className?: string; } const brandNames: Record = { visa: "Visa", mastercard: "Mastercard", amex: "Amex", card: "Card" }; const defaultCards: PaymentCard[] = [ { id: "visa", brand: "visa", last4: "4242", expiry: "08/27" }, { id: "mc", brand: "mastercard", last4: "8812", expiry: "03/28", note: "Backup" }, ]; const defaultReasons = [ "The card hit its spending limit, or was replaced or reported lost.", "The bank flagged a new or larger charge as unusual and held it.", "The card expired, or the billing address doesn't match the bank's.", ]; const ease = [0.23, 1, 0.32, 1] as const; const roll = { type: "spring", stiffness: 320, damping: 32 } as const; const spring = { type: "spring", stiffness: 420, damping: 34 } as const; // Stays false until mounted so the server render and the first client render match. function useSafeReducedMotion() { const prefers = useReducedMotion(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); return mounted && !!prefers; } const pad = (n: number) => String(n).padStart(2, "0"); const cardName = (card: PaymentCard) => `${brandNames[card.brand]} •••• ${card.last4}`; function useCountdown(start: number, running: boolean) { const [left, setLeft] = useState(start); useEffect(() => { setLeft(start); if (!running || start <= 0) return; const began = Date.now(); const id = window.setInterval(() => setLeft(Math.max(0, start - Math.floor((Date.now() - began) / 1000))), 250); return () => window.clearInterval(id); }, [start, running]); return left; } function Roll({ text, reduce }: { text: string; reduce: boolean }) { const chars = text.split(""); return ( {chars.map((char, i) => { const key = chars.length - i; if (!/\d/.test(char)) return {char}; return ( {char} {Array.from({ length: 10 }, (_, n) => ( {n} ))} ); })} ); } function Collapse({ show, reduce, children }: { show: boolean; reduce: boolean; children: ReactNode }) { return ( {show && ( {children} )} ); } const ditherPixels = [ [0, 0, 0.3], [3, 0, 0.5], [9, 1, 0.35], [11, 0, 0.5], [11, 4, 0.3], [0, 7, 0.35], [11, 11, 0.5], [1, 11, 0.3], [7, 11, 0.4], ] as const; const cardPixels = (() => { const pixels: { x: number; y: number; o: number }[] = []; for (let y = 2; y <= 9; y++) { for (let x = 1; x <= 10; x++) { const corner = (x === 1 || x === 10) && (y === 2 || y === 9); if (corner) continue; const edge = x === 1 || x === 10 || y === 2 || y === 9; const stripe = y === 4; const digits = y === 7 && x >= 3 && x <= 5; if (edge || stripe || digits) pixels.push({ x, y, o: 1 }); else if ((x + y) % 2 === 0) pixels.push({ x, y, o: 0.35 }); } } return pixels; })(); function CardGlyph({ paid }: { paid: boolean }) { return ( {cardPixels.map(({ x, y, o }) => ( ))} {ditherPixels.map(([x, y, o]) => ( ))} ); } function BrandMark({ brand }: { brand: CardBrand }) { return ( {{ visa: "VISA", mastercard: "MC", amex: "AMEX", card: "CARD" }[brand]} ); } function DrawnCheck({ reduce }: { reduce: boolean }) { return ( ); } export function PaymentFailed({ amount = 96, currency = "USD", plan = "Team", cards = defaultCards, failedCardId = "visa", declineReason = "insufficient funds", declineCode = "insufficient_funds", declineExplanation, declines = { visa: "Declined Oct 28: insufficient funds" }, graceUntil = "Nov 4", graceSeconds = 6 * 86400 + 9 * 3600 + 41 * 60 + 12, graceTotalSeconds = 7 * 86400, billingEmail = "billing@ridgeline.app", status = "due", paidCardId, reasons = defaultReasons, onPay, onAddCard, className, }: PaymentFailedProps) { const reduce = useSafeReducedMotion(); const paid = status === "paid"; const processing = status === "processing"; const left = useCountdown(graceSeconds, !paid); const [selected, setSelected] = useState(failedCardId); const [adding, setAdding] = useState(false); const known = useRef(cards.map((c) => c.id)); useEffect(() => { const fresh = cards.find((c) => !known.current.includes(c.id)); known.current = cards.map((c) => c.id); if (fresh) { setSelected(fresh.id); setAdding(false); } }, [cards]); const price = new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount); const failedCard = cards.find((c) => c.id === failedCardId); const selectedCard = cards.find((c) => c.id === selected) ?? cards[0]; const chargedCard = cards.find((c) => c.id === paidCardId) ?? selectedCard; const d = Math.floor(left / 86400); const h = Math.floor((left % 86400) / 3600); const m = Math.floor((left % 3600) / 60); const s = left % 60; const graceLeft = graceTotalSeconds > 0 ? Math.min(1, left / graceTotalSeconds) : 0; return (

{paid ? "Payment received" : "Your payment didn't go through"}

{paid ? "Paid" : "Past due"}

{paid ? `${cardName(chargedCard)} was charged for the ${plan} plan.` : failedCard ? `${cardName(failedCard)} was declined: ${declineReason}. ${plan} plan, ${price} a month.` : `Your card was declined: ${declineReason}.`}

{!paid && ( Why was it declined?

{declineExplanation ?? `The account didn't have ${price} free when we tried. A retry works once the money clears.`}

Banks also decline when:

    {reasons.map((reason) => (
  • {reason}
  • ))}

Your bank can confirm which. Decline code {declineCode}

)}

Your workspace stays fully active until {graceUntil}

{`${d} days ${h} hours left`} d h m s

After {graceUntil} it turns read-only until it's paid. Nothing is deleted.

Pay with setSelected(String(v))} value={selected}> {cards.map((card) => ( ))} {adding ? ( setAdding(false)} onSubmit={(details) => onAddCard?.(details)} /> ) : ( )}
{paid ? ( Paid · {price} · receipt sent to {billingEmail} ) : ( {price} due )}
); } function CardOption({ card, decline, disabled, reduce }: { card: PaymentCard; decline?: string; disabled: boolean; reduce: boolean }) { const id = useId(); return ( {brandNames[card.brand]} •••• {card.last4} Expires {card.expiry} {card.note && ( {card.note} )} {decline && ( {decline} )} ); } function formatNumber(raw: string) { return raw .replace(/\D/g, "") .slice(0, 16) .replace(/(\d{4})(?=\d)/g, "$1 "); } function formatExpiry(raw: string) { const digits = raw.replace(/\D/g, "").slice(0, 4); return digits.length > 2 ? `${digits.slice(0, 2)} / ${digits.slice(2)}` : digits; } function NewCardForm({ onSubmit, onCancel }: { onSubmit: (details: NewCardDetails) => void; onCancel: () => void }) { const id = useId(); const [number, setNumber] = useState(""); const [expiry, setExpiry] = useState(""); const [cvc, setCvc] = useState(""); const digits = number.replace(/\D/g, ""); const month = Number(expiry.slice(0, 2)); const valid = digits.length >= 15 && expiry.replace(/\D/g, "").length === 4 && month >= 1 && month <= 12 && cvc.length >= 3; const submit = (event: FormEvent) => { event.preventDefault(); if (valid) onSubmit({ number: digits, expiry: expiry.replace(/\s/g, ""), cvc }); }; return (
setNumber(formatNumber(e.target.value))} placeholder="1234 1234 1234 1234" value={number} />
setExpiry(formatExpiry(e.target.value))} placeholder="MM / YY" value={expiry} />
setCvc(e.target.value.replace(/\D/g, "").slice(0, 4))} placeholder="123" value={cvc} />
); }