Skip to content

Commit 1ffdc60

Browse files
committed
[TOOL-3814] Dashboard: Show all active coupons in team billing page
1 parent ab738eb commit 1ffdc60

File tree

3 files changed

+284
-0
lines changed

3 files changed

+284
-0
lines changed
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import SubscriptionCouponsUI from "./SubscriptionCoupons";
3+
4+
const meta = {
5+
title: "Billing/Coupons/SubscriptionCoupons",
6+
component: SubscriptionCouponsUI,
7+
decorators: [
8+
(Story) => (
9+
<div className="container max-w-[1100px] py-10">
10+
<Story />
11+
</div>
12+
),
13+
],
14+
} satisfies Meta<typeof SubscriptionCouponsUI>;
15+
16+
export default meta;
17+
type Story = StoryObj<typeof SubscriptionCouponsUI>;
18+
19+
export const Default: Story = {
20+
args: {
21+
data: [
22+
// Forever
23+
{
24+
id: "di_1QAE7jCQUO4TBFqFQgnS4Qj4",
25+
start: 1729011691,
26+
end: null,
27+
coupon: {
28+
id: "forever_active",
29+
name: "Example 1",
30+
duration: "forever",
31+
duration_in_months: null,
32+
},
33+
},
34+
35+
// Once
36+
{
37+
id: "once",
38+
start: 1733391052,
39+
end: null,
40+
coupon: {
41+
id: "once_active",
42+
name: "Example 2",
43+
duration: "once",
44+
duration_in_months: null,
45+
},
46+
},
47+
48+
// Repeating - 3 months
49+
{
50+
id: "repeating-3",
51+
start: 1733391629,
52+
end: 1743391629, // Will end in the future
53+
coupon: {
54+
id: "repeating_active",
55+
name: "Example 3",
56+
duration: "repeating",
57+
duration_in_months: 3,
58+
},
59+
},
60+
61+
// Repeating - 1 month
62+
{
63+
id: "repeating-1",
64+
start: 1733391713,
65+
end: 1736391713,
66+
coupon: {
67+
id: "repeating_single",
68+
name: "Example 4",
69+
duration: "repeating",
70+
duration_in_months: 1,
71+
},
72+
},
73+
],
74+
status: "success",
75+
},
76+
};
77+
78+
export const EmptyState: Story = {
79+
args: {
80+
data: [],
81+
status: "success",
82+
},
83+
};
84+
85+
export const PendingState: Story = {
86+
args: {
87+
data: [],
88+
status: "pending",
89+
},
90+
};
91+
92+
export const ErrorState: Story = {
93+
args: {
94+
data: [],
95+
status: "error",
96+
},
97+
};
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { Spinner } from "@/components/ui/Spinner/Spinner";
2+
import {
3+
Table,
4+
TableBody,
5+
TableCell,
6+
TableContainer,
7+
TableHead,
8+
TableHeader,
9+
TableRow,
10+
} from "@/components/ui/table";
11+
import { API_SERVER_URL } from "@/constants/env";
12+
import { format, fromUnixTime } from "date-fns";
13+
import {
14+
CalendarIcon,
15+
CalendarX2Icon,
16+
ClockIcon,
17+
InfinityIcon,
18+
} from "lucide-react";
19+
import { Suspense } from "react";
20+
import { getAuthToken } from "../../../../../app/api/lib/getAuthToken";
21+
22+
interface Coupon {
23+
id: string;
24+
name: string;
25+
duration: "forever" | "once" | "repeating";
26+
duration_in_months: number | null;
27+
}
28+
29+
interface CouponData {
30+
id: string;
31+
start: number;
32+
end: number | null;
33+
coupon: Coupon;
34+
}
35+
36+
export default function SubscriptionCouponsUI(props: {
37+
data: CouponData[];
38+
status: "pending" | "error" | "success";
39+
}) {
40+
const formatUnixTime = (timestamp: number) => {
41+
const date = fromUnixTime(timestamp);
42+
return format(date, "MMM d, yyyy");
43+
};
44+
45+
// Function to render duration information with appropriate icon
46+
const renderDuration = (coupon: Coupon) => {
47+
if (coupon.duration === "forever") {
48+
return (
49+
<div className="flex items-center gap-2">
50+
<InfinityIcon className="size-4 text-muted-foreground" />
51+
<span>Forever</span>
52+
</div>
53+
);
54+
} else if (coupon.duration === "once") {
55+
return (
56+
<div className="flex items-center gap-2">
57+
<ClockIcon className="size-4 text-muted-foreground" />
58+
<span>One-time discount</span>
59+
</div>
60+
);
61+
} else if (coupon.duration === "repeating") {
62+
return (
63+
<div className="flex items-center gap-2">
64+
<ClockIcon className="size-4 text-muted-foreground" />
65+
<span>
66+
Repeats every {coupon.duration_in_months} month
67+
{coupon.duration_in_months !== 1 ? "s" : ""}
68+
</span>
69+
</div>
70+
);
71+
} else {
72+
return null;
73+
}
74+
};
75+
76+
return (
77+
<div className="overflow-hidden rounded-lg border">
78+
<div className="border-b bg-card px-6 py-4">
79+
<h3 className="font-semibold text-xl tracking-tight">
80+
Subscription Coupons
81+
</h3>
82+
</div>
83+
84+
{props.status === "success" && props.data.length > 0 ? (
85+
<TableContainer className="rounded-t-none border-none">
86+
<Table>
87+
<TableHeader>
88+
<TableRow>
89+
<TableHead>Name</TableHead>
90+
<TableHead>Starts On</TableHead>
91+
<TableHead>Ends On</TableHead>
92+
<TableHead>Duration </TableHead>
93+
</TableRow>
94+
</TableHeader>
95+
<TableBody>
96+
{props.data.map((item) => (
97+
<TableRow key={item.id}>
98+
{/* Name */}
99+
<TableCell className="font-medium">
100+
{item.coupon.name}
101+
</TableCell>
102+
103+
{/* Start Date */}
104+
<TableCell>
105+
<div className="flex items-center gap-2">
106+
<CalendarIcon className="size-4 text-muted-foreground" />
107+
{formatUnixTime(item.start)}
108+
</div>
109+
</TableCell>
110+
111+
{/* End Date */}
112+
<TableCell>
113+
{item.end !== null ? (
114+
<div className="flex items-center gap-2">
115+
<CalendarIcon className="size-4 text-muted-foreground" />
116+
{formatUnixTime(item.end)}
117+
</div>
118+
) : (
119+
<div className="flex items-center gap-2">
120+
<CalendarX2Icon className="size-4 text-muted-foreground" />
121+
<span className="text-foreground">Never </span>
122+
</div>
123+
)}
124+
</TableCell>
125+
126+
{/* Duration */}
127+
<TableCell>{renderDuration(item.coupon)}</TableCell>
128+
</TableRow>
129+
))}
130+
</TableBody>
131+
</Table>
132+
</TableContainer>
133+
) : (
134+
<div className="px-6 py-4">
135+
<p className="flex min-h-[150px] items-center justify-center text-muted-foreground">
136+
{props.status === "pending" ? (
137+
<Spinner className="size-8" />
138+
) : props.status === "error" ? (
139+
<span className="text-destructive-text">
140+
Failed to load coupons
141+
</span>
142+
) : (
143+
<span>No coupons</span>
144+
)}
145+
</p>
146+
</div>
147+
)}
148+
</div>
149+
);
150+
}
151+
152+
async function AsyncSubscriptionCoupons(props: {
153+
teamId: string;
154+
}) {
155+
const authToken = await getAuthToken();
156+
157+
const res = await fetch(
158+
`${API_SERVER_URL}/v1/active-coupons?teamId=${props.teamId}`,
159+
{
160+
headers: {
161+
authorization: `Bearer ${authToken}`,
162+
},
163+
},
164+
);
165+
166+
if (!res.ok) {
167+
return <SubscriptionCouponsUI status="error" data={[]} />;
168+
}
169+
170+
const data = (await res.json()) as {
171+
data: CouponData[];
172+
};
173+
174+
return <SubscriptionCouponsUI status="success" data={data.data} />;
175+
}
176+
177+
export function SubscriptionCoupons(props: {
178+
teamId: string;
179+
}) {
180+
return (
181+
<Suspense fallback={<SubscriptionCouponsUI status="pending" data={[]} />}>
182+
<AsyncSubscriptionCoupons teamId={props.teamId} />
183+
</Suspense>
184+
);
185+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { PlanInfoCard } from "../../../../app/team/[team_slug]/(team)/~/settings
99
import { CouponSection } from "./CouponCard";
1010
import { CreditsInfoCard } from "./PlanCard";
1111
import { BillingPricing } from "./Pricing";
12+
import { SubscriptionCoupons } from "./SubscriptionCoupons/SubscriptionCoupons";
1213

1314
// TODO - move this in app router folder in other pr
1415

@@ -69,6 +70,7 @@ export const Billing: React.FC<BillingProps> = ({
6970

7071
<CreditsInfoCard twAccount={twAccount} />
7172
<CouponSection teamId={team.id} isPaymentSetup={validPayment} />
73+
<SubscriptionCoupons teamId={team.id} />
7274
</div>
7375
);
7476
};

0 commit comments

Comments
 (0)