|
| 1 | +import { useEffect, useMemo, useState } from "react"; |
| 2 | +import { |
| 3 | + BadgeCheck, |
| 4 | + Banknote, |
| 5 | + CheckCircle2, |
| 6 | + CreditCard, |
| 7 | + Landmark, |
| 8 | + Loader2, |
| 9 | + ShieldCheck, |
| 10 | + Smartphone, |
| 11 | + Wallet, |
| 12 | + X, |
| 13 | +} from "lucide-react"; |
| 14 | +import { MIN_ADD_RUPEES, RECHARGE_PACKS } from "../../constants/billing.constants"; |
| 15 | +import { useAddFunds } from "../../hooks/useWallet"; |
| 16 | +import { buildAddFundsPreview, formatCredits, formatRupees, validateMinAddFunds } from "../../utils/credits"; |
| 17 | +import type { AddFundsPayload, PaymentState } from "../../Billing.types"; |
| 18 | + |
| 19 | +const PAYMENT_METHODS: Array<{ |
| 20 | + id: AddFundsPayload["paymentMethod"]; |
| 21 | + label: string; |
| 22 | + icon: typeof Smartphone; |
| 23 | +}> = [ |
| 24 | + { id: "upi", label: "UPI", icon: Smartphone }, |
| 25 | + { id: "debit", label: "Debit Card", icon: CreditCard }, |
| 26 | + { id: "credit", label: "Credit Card", icon: CreditCard }, |
| 27 | + { id: "netbanking", label: "Net Banking", icon: Landmark }, |
| 28 | + { id: "wallet", label: "Wallets", icon: Wallet }, |
| 29 | +]; |
| 30 | + |
| 31 | +interface Props { |
| 32 | + isOpen: boolean; |
| 33 | + onClose: () => void; |
| 34 | + onSuccess?: (credits: number) => void; |
| 35 | + onError?: (message: string) => void; |
| 36 | +} |
| 37 | + |
| 38 | +export default function AddFundsModal({ isOpen, onClose, onSuccess, onError }: Props) { |
| 39 | + const addFunds = useAddFunds(); |
| 40 | + const [amountStr, setAmountStr] = useState("500"); |
| 41 | + const [paymentMethod, setPaymentMethod] = useState<AddFundsPayload["paymentMethod"]>("upi"); |
| 42 | + const [paymentState, setPaymentState] = useState<PaymentState>("idle"); |
| 43 | + |
| 44 | + const amount = Number(amountStr) || 0; |
| 45 | + const preview = useMemo(() => buildAddFundsPreview(amount), [amount]); |
| 46 | + const validation = useMemo(() => validateMinAddFunds(amount), [amount]); |
| 47 | + |
| 48 | + useEffect(() => { |
| 49 | + if (!isOpen) return; |
| 50 | + |
| 51 | + const handleKeyDown = (event: KeyboardEvent) => { |
| 52 | + if (event.key === "Escape" && paymentState !== "processing") onClose(); |
| 53 | + }; |
| 54 | + |
| 55 | + document.addEventListener("keydown", handleKeyDown); |
| 56 | + return () => document.removeEventListener("keydown", handleKeyDown); |
| 57 | + }, [isOpen, onClose, paymentState]); |
| 58 | + |
| 59 | + useEffect(() => { |
| 60 | + if (isOpen) setPaymentState("idle"); |
| 61 | + }, [isOpen]); |
| 62 | + |
| 63 | + if (!isOpen) return null; |
| 64 | + |
| 65 | + const handlePay = async () => { |
| 66 | + if (!validation.valid) { |
| 67 | + onError?.(validation.error ?? "Check amount"); |
| 68 | + return; |
| 69 | + } |
| 70 | + |
| 71 | + setPaymentState("processing"); |
| 72 | + try { |
| 73 | + await addFunds.mutateAsync({ |
| 74 | + amountRupees: amount, |
| 75 | + paymentMethod, |
| 76 | + idempotencyKey: `pay-${crypto.randomUUID()}`, |
| 77 | + }); |
| 78 | + setPaymentState("success"); |
| 79 | + onSuccess?.(preview.totalCredits); |
| 80 | + } catch { |
| 81 | + setPaymentState("failed"); |
| 82 | + onError?.("Please try again or use a different payment method."); |
| 83 | + } |
| 84 | + }; |
| 85 | + |
| 86 | + return ( |
| 87 | + <div className="fixed inset-0 z-[120] flex items-end justify-center p-3 sm:items-center sm:p-5"> |
| 88 | + <div |
| 89 | + className="absolute inset-0 bg-black/55 backdrop-blur-sm" |
| 90 | + onClick={() => paymentState !== "processing" && onClose()} |
| 91 | + /> |
| 92 | + <div |
| 93 | + className="relative max-h-[92vh] w-full max-w-4xl overflow-y-auto rounded-2xl border shadow-2xl" |
| 94 | + style={{ backgroundColor: "var(--cd-surface)", borderColor: "var(--cd-border-subtle)" }} |
| 95 | + role="dialog" |
| 96 | + aria-modal="true" |
| 97 | + aria-labelledby="add-funds-modal-title" |
| 98 | + > |
| 99 | + <button |
| 100 | + type="button" |
| 101 | + onClick={onClose} |
| 102 | + disabled={paymentState === "processing"} |
| 103 | + className="absolute right-4 top-4 z-10 rounded-lg p-2 transition-colors hover:bg-[var(--cd-hover)] disabled:opacity-50" |
| 104 | + aria-label="Close add funds form" |
| 105 | + > |
| 106 | + <X size={18} style={{ color: "var(--cd-text-muted)" }} /> |
| 107 | + </button> |
| 108 | + |
| 109 | + {paymentState === "success" ? ( |
| 110 | + <SuccessContent credits={preview.totalCredits} onClose={onClose} /> |
| 111 | + ) : ( |
| 112 | + <div className="grid gap-0 lg:grid-cols-[minmax(0,1fr)_340px]"> |
| 113 | + <form |
| 114 | + className="p-5 sm:p-6" |
| 115 | + onSubmit={(event) => { |
| 116 | + event.preventDefault(); |
| 117 | + void handlePay(); |
| 118 | + }} |
| 119 | + > |
| 120 | + <div className="pr-10"> |
| 121 | + <p className="text-xs font-black uppercase tracking-wide" style={{ color: "var(--cd-primary)" }}> |
| 122 | + Community wallet |
| 123 | + </p> |
| 124 | + <h2 id="add-funds-modal-title" className="mt-1 text-2xl font-black" style={{ color: "var(--cd-text)" }}> |
| 125 | + Add Funds |
| 126 | + </h2> |
| 127 | + <p className="mt-1 text-sm" style={{ color: "var(--cd-text-muted)" }}> |
| 128 | + Enter an amount, review credits, then confirm payment details. |
| 129 | + </p> |
| 130 | + </div> |
| 131 | + |
| 132 | + <section className="mt-6"> |
| 133 | + <SectionTitle title="Credit calculator" /> |
| 134 | + <div className="grid grid-cols-2 gap-2 sm:grid-cols-4"> |
| 135 | + {RECHARGE_PACKS.slice(0, 4).map((pack) => { |
| 136 | + const isSelected = amount === pack.amountRupees; |
| 137 | + return ( |
| 138 | + <button |
| 139 | + type="button" |
| 140 | + key={pack.id} |
| 141 | + onClick={() => setAmountStr(pack.amountRupees.toString())} |
| 142 | + className="min-h-24 rounded-xl border p-3 text-left transition-all hover:-translate-y-0.5" |
| 143 | + style={{ |
| 144 | + backgroundColor: isSelected ? "var(--cd-primary-subtle)" : "var(--cd-bg)", |
| 145 | + borderColor: isSelected ? "var(--cd-primary)" : "var(--cd-border-subtle)", |
| 146 | + color: isSelected ? "var(--cd-primary-text)" : "var(--cd-text)", |
| 147 | + }} |
| 148 | + > |
| 149 | + <div className="flex items-center justify-between gap-2"> |
| 150 | + <span className="text-xs font-bold">{pack.label}</span> |
| 151 | + {isSelected ? <BadgeCheck size={16} /> : null} |
| 152 | + </div> |
| 153 | + <span className="mt-3 block text-lg font-black">{formatRupees(pack.amountRupees)}</span> |
| 154 | + <span className="mt-1 block text-xs font-semibold"> |
| 155 | + {formatCredits(pack.baseCredits + pack.bonusCredits)} cr |
| 156 | + </span> |
| 157 | + </button> |
| 158 | + ); |
| 159 | + })} |
| 160 | + </div> |
| 161 | + |
| 162 | + <label className="mt-4 block text-sm font-bold" style={{ color: "var(--cd-text)" }}> |
| 163 | + Custom amount (Rs., min {MIN_ADD_RUPEES}) |
| 164 | + </label> |
| 165 | + <div className="relative mt-2"> |
| 166 | + <Banknote |
| 167 | + size={18} |
| 168 | + className="absolute left-4 top-1/2 -translate-y-1/2" |
| 169 | + style={{ color: "var(--cd-text-muted)" }} |
| 170 | + /> |
| 171 | + <input |
| 172 | + type="number" |
| 173 | + inputMode="numeric" |
| 174 | + pattern="[0-9]*" |
| 175 | + min={MIN_ADD_RUPEES} |
| 176 | + step={50} |
| 177 | + value={amountStr} |
| 178 | + onChange={(event) => { |
| 179 | + const digitsOnly = event.target.value.replace(/\D/g, ""); |
| 180 | + setAmountStr(digitsOnly.replace(/^0+(?=\d)/, "")); |
| 181 | + }} |
| 182 | + className="w-full rounded-xl border bg-transparent py-3 pl-11 pr-4 text-lg font-bold outline-none focus:ring-4 focus:ring-[var(--cd-primary-subtle)]" |
| 183 | + style={{ borderColor: "var(--cd-border-subtle)", color: "var(--cd-text)" }} |
| 184 | + /> |
| 185 | + </div> |
| 186 | + {!validation.valid && ( |
| 187 | + <p className="mt-2 text-xs font-semibold" style={{ color: "var(--cd-danger)" }}> |
| 188 | + {validation.error} |
| 189 | + </p> |
| 190 | + )} |
| 191 | + </section> |
| 192 | + |
| 193 | + <section className="mt-6"> |
| 194 | + <SectionTitle title="Payment method" /> |
| 195 | + <div className="grid grid-cols-2 gap-2 sm:grid-cols-5"> |
| 196 | + {PAYMENT_METHODS.map((method) => { |
| 197 | + const isSelected = paymentMethod === method.id; |
| 198 | + return ( |
| 199 | + <button |
| 200 | + type="button" |
| 201 | + key={method.id} |
| 202 | + onClick={() => setPaymentMethod(method.id)} |
| 203 | + className="min-h-20 rounded-xl border p-3 text-left transition-all hover:-translate-y-0.5" |
| 204 | + style={{ |
| 205 | + backgroundColor: isSelected ? "var(--cd-primary-subtle)" : "var(--cd-bg)", |
| 206 | + borderColor: isSelected ? "var(--cd-primary)" : "var(--cd-border-subtle)", |
| 207 | + color: isSelected ? "var(--cd-primary-text)" : "var(--cd-text)", |
| 208 | + }} |
| 209 | + > |
| 210 | + <method.icon size={18} /> |
| 211 | + <span className="mt-2 block text-xs font-bold">{method.label}</span> |
| 212 | + </button> |
| 213 | + ); |
| 214 | + })} |
| 215 | + </div> |
| 216 | + </section> |
| 217 | + </form> |
| 218 | + |
| 219 | + <aside |
| 220 | + className="border-t p-5 sm:p-6 lg:border-l lg:border-t-0" |
| 221 | + style={{ backgroundColor: "var(--cd-bg)", borderColor: "var(--cd-border-subtle)" }} |
| 222 | + > |
| 223 | + <SectionTitle title="Pay details" /> |
| 224 | + <div |
| 225 | + className="mt-3 rounded-xl border p-4" |
| 226 | + style={{ backgroundColor: "var(--cd-surface)", borderColor: "var(--cd-border-subtle)" }} |
| 227 | + > |
| 228 | + <div className="space-y-3 text-sm"> |
| 229 | + <SummaryRow label="Wallet amount" value={formatRupees(preview.amountRupees)} /> |
| 230 | + <SummaryRow label="GST (18%)" value={formatRupees(preview.gstRupees)} /> |
| 231 | + <SummaryRow label="Platform fee" value={formatRupees(preview.platformFeeRupees)} /> |
| 232 | + <SummaryRow label="Base credits" value={formatCredits(preview.baseCredits)} /> |
| 233 | + {preview.bonusCredits > 0 ? ( |
| 234 | + <SummaryRow label="Bonus credits" value={`+${formatCredits(preview.bonusCredits)}`} accent /> |
| 235 | + ) : null} |
| 236 | + </div> |
| 237 | + <div |
| 238 | + className="mt-4 border-t pt-4" |
| 239 | + style={{ borderColor: "var(--cd-border-subtle)" }} |
| 240 | + > |
| 241 | + <SummaryRow label="Credits added" value={formatCredits(preview.totalCredits)} accent strong /> |
| 242 | + <SummaryRow label="Total payable" value={formatRupees(preview.totalPayableRupees)} strong /> |
| 243 | + </div> |
| 244 | + </div> |
| 245 | + |
| 246 | + {paymentState === "failed" ? ( |
| 247 | + <div |
| 248 | + className="mt-4 rounded-xl border p-3 text-sm font-semibold" |
| 249 | + style={{ |
| 250 | + backgroundColor: "var(--cd-danger-subtle)", |
| 251 | + borderColor: "var(--cd-danger)", |
| 252 | + color: "var(--cd-danger)", |
| 253 | + }} |
| 254 | + > |
| 255 | + Payment failed. Please try again or use a different method. |
| 256 | + </div> |
| 257 | + ) : null} |
| 258 | + |
| 259 | + <button |
| 260 | + type="button" |
| 261 | + onClick={() => void handlePay()} |
| 262 | + disabled={!validation.valid || addFunds.isPending || paymentState === "processing"} |
| 263 | + className="cd-btn cd-btn-primary mt-4 flex w-full items-center justify-center gap-2 rounded-xl py-3.5 text-base font-bold disabled:opacity-50" |
| 264 | + > |
| 265 | + {addFunds.isPending || paymentState === "processing" ? ( |
| 266 | + <> |
| 267 | + <Loader2 size={18} className="animate-spin" /> Processing... |
| 268 | + </> |
| 269 | + ) : ( |
| 270 | + <>Pay {formatRupees(preview.totalPayableRupees)}</> |
| 271 | + )} |
| 272 | + </button> |
| 273 | + |
| 274 | + <div className="mt-3 flex items-center justify-center gap-2 text-xs" style={{ color: "var(--cd-text-muted)" }}> |
| 275 | + <ShieldCheck size={14} style={{ color: "var(--cd-success)" }} /> |
| 276 | + Encrypted payment simulation |
| 277 | + </div> |
| 278 | + </aside> |
| 279 | + </div> |
| 280 | + )} |
| 281 | + </div> |
| 282 | + </div> |
| 283 | + ); |
| 284 | +} |
| 285 | + |
| 286 | +function SectionTitle({ title }: { title: string }) { |
| 287 | + return ( |
| 288 | + <h3 className="mb-3 text-sm font-black uppercase tracking-wide" style={{ color: "var(--cd-text)" }}> |
| 289 | + {title} |
| 290 | + </h3> |
| 291 | + ); |
| 292 | +} |
| 293 | + |
| 294 | +function SummaryRow({ |
| 295 | + label, |
| 296 | + value, |
| 297 | + accent = false, |
| 298 | + strong = false, |
| 299 | +}: { |
| 300 | + label: string; |
| 301 | + value: string; |
| 302 | + accent?: boolean; |
| 303 | + strong?: boolean; |
| 304 | +}) { |
| 305 | + return ( |
| 306 | + <div className="flex items-center justify-between gap-4"> |
| 307 | + <span className={strong ? "font-bold" : ""} style={{ color: "var(--cd-text-muted)" }}> |
| 308 | + {label} |
| 309 | + </span> |
| 310 | + <span |
| 311 | + className={strong ? "font-black" : "font-semibold"} |
| 312 | + style={{ color: accent ? "var(--cd-primary)" : "var(--cd-text)" }} |
| 313 | + > |
| 314 | + {value} |
| 315 | + </span> |
| 316 | + </div> |
| 317 | + ); |
| 318 | +} |
| 319 | + |
| 320 | +function SuccessContent({ credits, onClose }: { credits: number; onClose: () => void }) { |
| 321 | + return ( |
| 322 | + <div className="flex flex-col items-center px-6 py-12 text-center"> |
| 323 | + <div className="rounded-full p-4" style={{ backgroundColor: "var(--cd-success-subtle)" }}> |
| 324 | + <CheckCircle2 size={48} style={{ color: "var(--cd-success)" }} /> |
| 325 | + </div> |
| 326 | + <h2 className="mt-5 text-2xl font-black" style={{ color: "var(--cd-text)" }}> |
| 327 | + Payment successful |
| 328 | + </h2> |
| 329 | + <p className="mt-2 text-sm" style={{ color: "var(--cd-text-muted)" }}> |
| 330 | + {formatCredits(credits)} credits have been added to your wallet. |
| 331 | + </p> |
| 332 | + <button type="button" onClick={onClose} className="cd-btn cd-btn-primary mt-8 rounded-xl px-8 py-2.5"> |
| 333 | + Done |
| 334 | + </button> |
| 335 | + </div> |
| 336 | + ); |
| 337 | +} |
0 commit comments