Skip to content

Commit 5c2628e

Browse files
committed
Add wallet add-funds popup
1 parent d5ff6f9 commit 5c2628e

6 files changed

Lines changed: 369 additions & 11 deletions

File tree

Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
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+
}

src/features/Billing/v1/components/layout/LowBalanceModal.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ interface Props {
88
availableCredits: number;
99
threshold: number;
1010
onDismiss: () => void;
11+
onAddFunds?: () => void;
1112
}
1213

13-
export default function LowBalanceModal({ isOpen, availableCredits, threshold, onDismiss }: Props) {
14+
export default function LowBalanceModal({ isOpen, availableCredits, threshold, onDismiss, onAddFunds }: Props) {
1415
const navigate = useNavigate();
1516
const ref = useRef<HTMLDivElement>(null);
1617

@@ -71,7 +72,11 @@ export default function LowBalanceModal({ isOpen, availableCredits, threshold, o
7172
<div className="flex flex-col sm:flex-row gap-3">
7273
<button
7374
onClick={() => {
74-
navigate("/org/billing/add-funds");
75+
if (onAddFunds) {
76+
onAddFunds();
77+
} else {
78+
navigate("/org/billing/add-funds");
79+
}
7580
onDismiss();
7681
}}
7782
data-testid="low-balance-add-funds"

src/features/Billing/v1/components/layout/WalletHeader.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ interface Props {
88
wallet: WalletType | undefined;
99
activeTab: string;
1010
onTabChange: (tab: string) => void;
11+
onAddFunds: () => void;
1112
}
1213

1314
const TABS = [
@@ -16,7 +17,7 @@ const TABS = [
1617
{ key: "team", label: "Team Usage", icon: Users },
1718
];
1819

19-
export default function WalletHeader({ wallet, activeTab, onTabChange }: Props) {
20+
export default function WalletHeader({ wallet, activeTab, onTabChange, onAddFunds }: Props) {
2021
const navigate = useNavigate();
2122

2223
return (
@@ -52,7 +53,7 @@ export default function WalletHeader({ wallet, activeTab, onTabChange }: Props)
5253
Usage
5354
</button>
5455
<button
55-
onClick={() => navigate("/org/billing/add-funds")}
56+
onClick={onAddFunds}
5657
className="cd-btn cd-btn-primary h-10 rounded-lg px-4 text-sm font-semibold shadow-none transition-all hover:-translate-y-0.5"
5758
>
5859
<Plus size={15} strokeWidth={2.5} />

src/features/Billing/v1/hooks/useWallet.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import type {
66
ConsumeCreditsPayload,
77
TransactionFilters,
88
PaginatedTransactions,
9-
CreditTransaction,
109
} from "../Billing.types";
1110
import { BillingService } from "../services/billingService";
1211
import { validateMinAddFunds, applyTransactionFilters } from "../utils/credits";

0 commit comments

Comments
 (0)