Skip to content

Commit 9fb5401

Browse files
committed
Add Coupon Card in Billing Page (#4895)
## Problem solved Short description of the bug fixed or feature added <!-- start pr-codex --> --- ## PR-Codex overview This PR focuses on enhancing the `SettingsBillingPage` component by adding a `teamId` prop to manage billing settings for different teams. It also introduces a new `CouponCard` component that allows users to apply coupon codes with relevant feedback. ### Detailed summary - Updated `Page` component to pass `teamId` to `SettingsBillingPage`. - Modified `SettingsBillingPage` to accept `teamId` as a prop. - Adjusted `Billing` component to use `teamId` in its rendering. - Introduced `CouponCard` component for applying coupon codes. - Implemented logic in `CouponCard` to handle coupon submissions and display feedback based on response status. - Updated `Billing` component to include `CouponCard`. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex -->
1 parent e548675 commit 9fb5401

File tree

6 files changed

+236
-6
lines changed

6 files changed

+236
-6
lines changed

apps/dashboard/src/app/team/[team_slug]/(team)/~/settings/billing/BillingSettingsPage.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import { Spinner } from "@/components/ui/Spinner/Spinner";
44
import { AccountStatus, useAccount } from "@3rdweb-sdk/react/hooks/useApi";
55
import { Billing } from "components/settings/Account/Billing";
66

7-
export const SettingsBillingPage = () => {
7+
export const SettingsBillingPage = (props: {
8+
teamId: string | undefined;
9+
}) => {
810
const meQuery = useAccount({
911
refetchInterval: (query) =>
1012
[
@@ -25,5 +27,5 @@ export const SettingsBillingPage = () => {
2527
);
2628
}
2729

28-
return <Billing account={account} />;
30+
return <Billing account={account} teamId={props.teamId} />;
2931
};
Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
1+
import { getTeamBySlug } from "@/api/team";
12
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
3+
import { notFound } from "next/navigation";
24
import { SettingsBillingPage } from "./BillingSettingsPage";
35

4-
export default function Page() {
6+
export default async function Page(props: {
7+
params: {
8+
team_slug: string;
9+
};
10+
}) {
11+
const team = await getTeamBySlug(props.params.team_slug);
12+
13+
if (!team) {
14+
notFound();
15+
}
16+
517
return (
618
<ChakraProviderSetup>
7-
<SettingsBillingPage />
19+
<SettingsBillingPage teamId={team.id} />
820
</ChakraProviderSetup>
921
);
1022
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { Toaster } from "sonner";
3+
import { BadgeContainer, mobileViewport } from "../../../../stories/utils";
4+
import { CouponCardUI } from "./CouponCard";
5+
6+
const meta = {
7+
title: "billing/CouponCard",
8+
component: Story,
9+
parameters: {
10+
nextjs: {
11+
appDirectory: true,
12+
},
13+
},
14+
} satisfies Meta<typeof Story>;
15+
16+
export default meta;
17+
type Story = StoryObj<typeof meta>;
18+
19+
export const Desktop: Story = {
20+
args: {},
21+
};
22+
23+
export const Mobile: Story = {
24+
args: {},
25+
parameters: {
26+
viewport: mobileViewport("iphone14"),
27+
},
28+
};
29+
30+
function statusStub(status: number) {
31+
return async () => {
32+
await new Promise((resolve) => setTimeout(resolve, 1000));
33+
return status;
34+
};
35+
}
36+
37+
function Story() {
38+
return (
39+
<div className="container flex max-w-[1100px] flex-col gap-10 py-10">
40+
<BadgeContainer label="Success - 200">
41+
<CouponCardUI submit={statusStub(200)} />
42+
</BadgeContainer>
43+
44+
<BadgeContainer label="Invalid - 400">
45+
<CouponCardUI submit={statusStub(400)} />
46+
</BadgeContainer>
47+
48+
<BadgeContainer label="Not Authorized - 401">
49+
<CouponCardUI submit={statusStub(401)} />
50+
</BadgeContainer>
51+
52+
<BadgeContainer label="Already applied - 409">
53+
<CouponCardUI submit={statusStub(409)} />
54+
</BadgeContainer>
55+
56+
<BadgeContainer label="Rate Limited - 429">
57+
<CouponCardUI submit={statusStub(429)} />
58+
</BadgeContainer>
59+
60+
<BadgeContainer label="Other - 500">
61+
<CouponCardUI submit={statusStub(500)} />
62+
</BadgeContainer>
63+
<Toaster richColors />
64+
</div>
65+
);
66+
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"use client";
2+
3+
import { Spinner } from "@/components/ui/Spinner/Spinner";
4+
import { Button } from "@/components/ui/button";
5+
import {
6+
Form,
7+
FormControl,
8+
FormField,
9+
FormItem,
10+
FormLabel,
11+
FormMessage,
12+
} from "@/components/ui/form";
13+
import { Input } from "@/components/ui/input";
14+
import { zodResolver } from "@hookform/resolvers/zod";
15+
import { useMutation } from "@tanstack/react-query";
16+
import { useForm } from "react-hook-form";
17+
import { toast } from "sonner";
18+
import { z } from "zod";
19+
20+
export function CouponCard(props: {
21+
teamId: string | undefined;
22+
}) {
23+
return (
24+
<CouponCardUI
25+
submit={async (promoCode: string) => {
26+
const res = await fetch("/api/server-proxy/api/v1/coupons/redeem", {
27+
method: "POST",
28+
headers: {
29+
"Content-Type": "application/json",
30+
},
31+
body: JSON.stringify({
32+
promoCode,
33+
teamId: props.teamId,
34+
}),
35+
});
36+
37+
return res.status;
38+
}}
39+
/>
40+
);
41+
}
42+
43+
const couponFormSchema = z.object({
44+
promoCode: z.string().min(1, "Coupon code is required"),
45+
});
46+
47+
export function CouponCardUI(props: {
48+
submit: (promoCode: string) => Promise<number>;
49+
}) {
50+
const form = useForm<z.infer<typeof couponFormSchema>>({
51+
resolver: zodResolver(couponFormSchema),
52+
defaultValues: {
53+
promoCode: "",
54+
},
55+
});
56+
57+
const applyCoupon = useMutation({
58+
mutationFn: (promoCode: string) => props.submit(promoCode),
59+
});
60+
61+
async function onSubmit(values: z.infer<typeof couponFormSchema>) {
62+
try {
63+
const status = await applyCoupon.mutateAsync(values.promoCode);
64+
switch (status) {
65+
case 200: {
66+
toast.success("Coupon applied successfully");
67+
break;
68+
}
69+
case 400: {
70+
toast.error("Coupon code is invalid");
71+
break;
72+
}
73+
case 401: {
74+
toast.error("You are not authorized to apply coupons", {
75+
description: "Login to dashboard and try again",
76+
});
77+
break;
78+
}
79+
case 409: {
80+
toast.error("Coupon already applied");
81+
break;
82+
}
83+
case 429: {
84+
toast.error("Too many coupons applied in a short period", {
85+
description: "Please try again after some time",
86+
});
87+
break;
88+
}
89+
default: {
90+
toast.error("Failed to apply coupon");
91+
}
92+
}
93+
} catch {
94+
toast.error("Failed to apply coupon");
95+
}
96+
97+
form.reset();
98+
}
99+
100+
return (
101+
<section className="relative rounded-lg border border-border bg-muted/50">
102+
{/* header */}
103+
<div className="px-4 pt-6 lg:px-6">
104+
<h3 className="mb-1 font-semibold text-xl tracking-tight">
105+
Apply Coupon
106+
</h3>
107+
<p className="text-muted-foreground text-sm">
108+
Enter your coupon code to apply discounts or free trials on thirdweb
109+
products
110+
</p>
111+
</div>
112+
113+
<div className="h-5" />
114+
115+
<Form {...form}>
116+
<form onSubmit={form.handleSubmit(onSubmit)}>
117+
{/* Body */}
118+
<div className="px-4 lg:px-6">
119+
<FormField
120+
control={form.control}
121+
name="promoCode"
122+
render={({ field }) => (
123+
<FormItem>
124+
<FormLabel>Coupon Code</FormLabel>
125+
<FormControl>
126+
<Input {...field} className="lg:max-w-[450px]" />
127+
</FormControl>
128+
<FormMessage />
129+
</FormItem>
130+
)}
131+
/>
132+
</div>
133+
<div className="h-7" />
134+
135+
{/* Footer */}
136+
<div className="flex justify-end border-border border-t px-4 py-4 lg:px-6">
137+
<Button type="submit" className="gap-2">
138+
{applyCoupon.isPending && <Spinner className="size-4" />}
139+
Apply Coupon
140+
</Button>
141+
</div>
142+
</form>
143+
</Form>
144+
</section>
145+
);
146+
}

apps/dashboard/src/components/settings/Account/Billing/index.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,18 @@ import { FiExternalLink } from "react-icons/fi";
1818
import { Button, Heading, Text, TrackedLink } from "tw-components";
1919
import { PLANS } from "utils/pricing";
2020
import { LazyOnboardingBilling } from "../../../onboarding/LazyOnboardingBilling";
21+
import { CouponCard } from "./CouponCard";
2122
import { BillingDowngradeDialog } from "./DowngradeDialog";
2223
import { BillingHeader } from "./Header";
2324
import { BillingPlanCard } from "./PlanCard";
2425
import { BillingPricing } from "./Pricing";
2526

2627
interface BillingProps {
2728
account: Account;
29+
teamId: string | undefined;
2830
}
2931

30-
export const Billing: React.FC<BillingProps> = ({ account }) => {
32+
export const Billing: React.FC<BillingProps> = ({ account, teamId }) => {
3133
const updatePlanMutation = useUpdateAccountPlan(
3234
account?.plan === AccountPlan.Free,
3335
);
@@ -301,6 +303,8 @@ export const Billing: React.FC<BillingProps> = ({ account }) => {
301303
loading={updatePlanMutation.isPending}
302304
/>
303305
)}
306+
307+
<CouponCard teamId={teamId} />
304308
</Flex>
305309
);
306310
};

apps/dashboard/src/pages/dashboard/settings/billing.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { ThirdwebNextPage } from "utils/types";
55
import { SettingsBillingPage } from "../../../app/team/[team_slug]/(team)/~/settings/billing/BillingSettingsPage";
66

77
const Page: ThirdwebNextPage = () => {
8-
return <SettingsBillingPage />;
8+
return <SettingsBillingPage teamId={undefined} />;
99
};
1010

1111
Page.pageId = PageId.SettingsUsage;

0 commit comments

Comments
 (0)