Skip to content

Commit 36b32b9

Browse files
committed
[Dashboard] Add crypto top-up functionality for account credits
1 parent 3d1c321 commit 36b32b9

File tree

6 files changed

+372
-71
lines changed

6 files changed

+372
-71
lines changed

apps/dashboard/src/@/actions/stripe-actions.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,16 @@ export async function getTeamInvoices(
5959
throw new Error("Failed to fetch billing history");
6060
}
6161
}
62+
63+
async function getStripeCustomer(customerId: string) {
64+
return await getStripe().customers.retrieve(customerId);
65+
}
66+
67+
export async function getStripeBalance(customerId: string) {
68+
const customer = await getStripeCustomer(customerId);
69+
if (customer.deleted) {
70+
return 0;
71+
}
72+
// Stripe returns a positive balance for credits, so we need to multiply by -1 to get the actual balance (as long as the balance is not 0)
73+
return customer.balance === 0 ? 0 : customer.balance * -1;
74+
}

apps/dashboard/src/app/(app)/(stripe)/checkout/[team_slug]/[sku]/page.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,44 @@
11
import type { ProductSKU } from "@/lib/billing";
22
import { redirect } from "next/navigation";
33
import { StripeRedirectErrorPage } from "../../../_components/StripeRedirectErrorPage";
4-
import { getBillingCheckoutUrl } from "../../../utils/billing";
4+
import {
5+
getBillingCheckoutUrl,
6+
getCryptoTopupUrl,
7+
} from "../../../utils/billing";
58

69
export default async function CheckoutPage(props: {
710
params: Promise<{
811
team_slug: string;
912
sku: string;
1013
}>;
14+
searchParams: Promise<{
15+
amount?: string;
16+
}>;
1117
}) {
1218
const params = await props.params;
1319

20+
// special case for crypto topup
21+
if (params.sku === "topup") {
22+
const amountUSD = Number.parseInt(
23+
(await props.searchParams).amount || "10",
24+
);
25+
if (Number.isNaN(amountUSD)) {
26+
return <StripeRedirectErrorPage errorMessage="Invalid amount" />;
27+
}
28+
const topupUrl = await getCryptoTopupUrl({
29+
teamSlug: params.team_slug,
30+
amountUSD,
31+
});
32+
if (!topupUrl) {
33+
// TODO: make a better error page
34+
return (
35+
<StripeRedirectErrorPage errorMessage="Failed to load topup page" />
36+
);
37+
}
38+
redirect(topupUrl);
39+
return null;
40+
}
41+
1442
const billingUrl = await getBillingCheckoutUrl({
1543
teamSlug: params.team_slug,
1644
sku: decodeURIComponent(params.sku) as Exclude<ProductSKU, null>,

apps/dashboard/src/app/(app)/(stripe)/utils/billing.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,39 @@ function getAbsoluteStripeRedirectUrl() {
120120
url.pathname = "/stripe-redirect";
121121
return url.toString();
122122
}
123+
124+
export async function getCryptoTopupUrl(options: {
125+
teamSlug: string;
126+
amountUSD: number;
127+
}): Promise<string | undefined> {
128+
const token = await getAuthToken();
129+
if (!token) {
130+
return undefined;
131+
}
132+
133+
const res = await fetch(
134+
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamSlug}/checkout/crypto-top-up`,
135+
{
136+
method: "POST",
137+
body: JSON.stringify({
138+
amountUSD: options.amountUSD,
139+
}),
140+
headers: {
141+
"Content-Type": "application/json",
142+
Authorization: `Bearer ${token}`,
143+
},
144+
},
145+
);
146+
147+
if (!res.ok) {
148+
return undefined;
149+
}
150+
151+
const json = await res.json();
152+
153+
if (!json.result) {
154+
return undefined;
155+
}
156+
157+
return json.result as string;
158+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
"use client";
2+
3+
import { Button } from "@/components/ui/button";
4+
import {
5+
Card,
6+
CardContent,
7+
CardDescription,
8+
CardHeader,
9+
CardTitle,
10+
} from "@/components/ui/card";
11+
import { Label } from "@/components/ui/label";
12+
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
13+
import { Separator } from "@/components/ui/separator";
14+
import { ArrowRightIcon, DollarSignIcon } from "lucide-react";
15+
import Link from "next/link";
16+
import { Suspense, use, useState } from "react";
17+
import { ErrorBoundary } from "react-error-boundary";
18+
import { Skeleton } from "../../../../../../../../../@/components/ui/skeleton";
19+
20+
const predefinedAmounts = [
21+
{ value: "25", label: "$25" },
22+
{ value: "100", label: "$100" },
23+
{ value: "500", label: "$500" },
24+
{ value: "1000", label: "$1,000" },
25+
] as const;
26+
27+
interface CreditBalanceSectionProps {
28+
balancePromise: Promise<number>;
29+
teamSlug: string;
30+
}
31+
32+
export function CreditBalanceSection({
33+
balancePromise,
34+
teamSlug,
35+
}: CreditBalanceSectionProps) {
36+
const [selectedAmount, setSelectedAmount] = useState<string>(
37+
predefinedAmounts[0].value,
38+
);
39+
40+
return (
41+
<Card className="w-full">
42+
<CardHeader>
43+
<CardTitle className="flex items-center gap-2">
44+
Credit Balance
45+
</CardTitle>
46+
<CardDescription className="mt-2">
47+
Your credit balance automatically applies to all invoices before your
48+
default payment method is charged.
49+
</CardDescription>
50+
</CardHeader>
51+
<CardContent className="space-y-6">
52+
{/* Current Balance */}
53+
<ErrorBoundary fallback={<CurrentBalanceErrorBoundary />}>
54+
<Suspense fallback={<CurrentBalanceSkeleton />}>
55+
<CurrentBalance balancePromise={balancePromise} />
56+
</Suspense>
57+
</ErrorBoundary>
58+
59+
<Separator />
60+
<div className="space-y-2">
61+
<h3 className="font-medium text-lg">Top Up Credits</h3>
62+
<p className="text-muted-foreground text-sm">
63+
Add credits to your account for future billing cycles. Credits are
64+
non-refundable and do not expire.
65+
</p>
66+
</div>
67+
68+
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
69+
{/* Amount Selection */}
70+
<div className="space-y-4 lg:col-span-2">
71+
<Label className="font-medium text-base">Select Amount</Label>
72+
<RadioGroup
73+
value={selectedAmount}
74+
onValueChange={setSelectedAmount}
75+
className="grid grid-cols-4 gap-3"
76+
>
77+
{predefinedAmounts.map((amount) => (
78+
<div key={amount.value}>
79+
<RadioGroupItem
80+
value={amount.value}
81+
id={amount.value}
82+
className="peer sr-only"
83+
/>
84+
<Label
85+
htmlFor={amount.value}
86+
className="flex cursor-pointer flex-col items-center justify-center rounded-md border-2 border-muted bg-popover p-4 transition-colors hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary"
87+
>
88+
<span className="font-semibold text-lg">
89+
{amount.label}
90+
</span>
91+
</Label>
92+
</div>
93+
))}
94+
</RadioGroup>
95+
</div>
96+
97+
{/* Top-up Summary and Button */}
98+
<div className="space-y-4">
99+
<ErrorBoundary
100+
fallback={
101+
<TopUpSummaryErrorBoundary selectedAmount={selectedAmount} />
102+
}
103+
>
104+
<Suspense
105+
fallback={
106+
<TopUpSummarySkeleton selectedAmount={selectedAmount} />
107+
}
108+
>
109+
<TopUpSummary
110+
selectedAmount={selectedAmount}
111+
currentBalancePromise={balancePromise}
112+
/>
113+
</Suspense>
114+
</ErrorBoundary>
115+
116+
<Button asChild className="w-full" size="lg">
117+
<Link
118+
href={`/checkout/${teamSlug}/topup?amount=${selectedAmount}`}
119+
prefetch={false}
120+
target="_blank"
121+
>
122+
Top Up Credits
123+
<ArrowRightIcon className="ml-2 h-4 w-4" />
124+
</Link>
125+
</Button>
126+
</div>
127+
</div>
128+
</CardContent>
129+
</Card>
130+
);
131+
}
132+
133+
function CurrentBalance({
134+
balancePromise,
135+
}: { balancePromise: Promise<number> }) {
136+
const currentBalance = use(balancePromise);
137+
138+
return (
139+
<div className="flex items-center justify-between rounded-lg bg-muted/50 p-4">
140+
<div className="flex items-center gap-2">
141+
<DollarSignIcon className="h-4 w-4 text-muted-foreground" />
142+
<span className="font-medium text-sm">Current Credit Balance</span>
143+
</div>
144+
<span className="font-semibold text-lg">{formatUsd(currentBalance)}</span>
145+
</div>
146+
);
147+
}
148+
149+
function CurrentBalanceSkeleton() {
150+
return (
151+
<div className="flex items-center justify-between rounded-lg bg-muted/50 p-4">
152+
<div className="flex items-center gap-2">
153+
<DollarSignIcon className="h-4 w-4 text-muted-foreground" />
154+
<span className="font-medium text-sm">Current Credit Balance</span>
155+
</div>
156+
<Skeleton className="h-6 w-24" />
157+
</div>
158+
);
159+
}
160+
function CurrentBalanceErrorBoundary() {
161+
return (
162+
<div className="flex items-center justify-between rounded-lg bg-muted/50 p-4">
163+
<div className="flex items-center gap-2">
164+
<DollarSignIcon className="h-4 w-4 text-muted-foreground" />
165+
<span className="font-medium text-sm">Current Credit Balance</span>
166+
</div>
167+
<span className="text-sm text-warning-text">
168+
Failed to load current balance, please try again later.
169+
</span>
170+
</div>
171+
);
172+
}
173+
174+
function TopUpSummary({
175+
selectedAmount,
176+
currentBalancePromise,
177+
}: {
178+
selectedAmount: string;
179+
currentBalancePromise: Promise<number>;
180+
}) {
181+
const currentBalance = use(currentBalancePromise);
182+
183+
return (
184+
<div className="space-y-3 rounded-lg bg-muted/30 p-4">
185+
<h3 className="font-medium text-sm">Summary</h3>
186+
<div className="flex justify-between text-sm">
187+
<span>Top-up amount:</span>
188+
<span className="font-medium">{formatUsd(Number(selectedAmount))}</span>
189+
</div>
190+
<div className="flex justify-between text-sm">
191+
<span>New balance:</span>
192+
<span className="font-medium">
193+
{formatUsd(currentBalance + Number(selectedAmount))}
194+
</span>
195+
</div>
196+
</div>
197+
);
198+
}
199+
200+
function TopUpSummarySkeleton({
201+
selectedAmount,
202+
}: {
203+
selectedAmount: string;
204+
}) {
205+
return (
206+
<div className="space-y-3 rounded-lg bg-muted/30 p-4">
207+
<h3 className="font-medium text-sm">Summary</h3>
208+
<div className="flex justify-between text-sm">
209+
<span>Top-up amount:</span>
210+
<span className="font-medium">{formatUsd(Number(selectedAmount))}</span>
211+
</div>
212+
<div className="flex justify-between text-sm">
213+
<span>New balance:</span>
214+
<Skeleton className="h-4 w-24" />
215+
</div>
216+
</div>
217+
);
218+
}
219+
220+
function TopUpSummaryErrorBoundary({
221+
selectedAmount,
222+
}: {
223+
selectedAmount: string;
224+
}) {
225+
return (
226+
<div className="space-y-3 rounded-lg bg-muted/30 p-4">
227+
<h3 className="font-medium text-sm">Summary</h3>
228+
<div className="flex justify-between text-sm">
229+
<span>Top-up amount:</span>
230+
<span className="font-medium">{formatUsd(Number(selectedAmount))}</span>
231+
</div>
232+
<div className="flex justify-between text-sm">
233+
<span>New balance:</span>
234+
<span className="font-medium">{formatUsd(Number(selectedAmount))}</span>
235+
</div>
236+
</div>
237+
);
238+
}
239+
240+
// utils
241+
function formatUsd(amount: number) {
242+
return amount.toLocaleString("en-US", {
243+
style: "currency",
244+
currency: "USD",
245+
});
246+
}

0 commit comments

Comments
 (0)