Openstatus
www.openstatus.dev
1"use client";
2
3import { Check } from "lucide-react";
4import { Fragment, useTransition } from "react";
5
6import { Button } from "@/components/ui/button";
7import {
8 Table,
9 TableBody,
10 TableCaption,
11 TableCell,
12 TableHead,
13 TableHeader,
14 TableRow,
15} from "@/components/ui/table";
16
17import { Badge } from "@/components/ui/badge";
18import { config as featureGroups, plans } from "@/data/plans";
19import { useCookieState } from "@/hooks/use-cookie-state";
20import { getStripe } from "@/lib/stripe";
21import { useTRPC } from "@/lib/trpc/client";
22import { cn } from "@/lib/utils";
23import type { WorkspacePlan } from "@openstatus/db/src/schema";
24import {
25 getAddonPriceConfig,
26 getPriceConfig,
27} from "@openstatus/db/src/schema/plan/utils";
28import { useMutation, useQuery } from "@tanstack/react-query";
29
30const BASE_URL =
31 process.env.NODE_ENV === "production"
32 ? "https://app.openstatus.dev"
33 : "http://localhost:3000";
34
35export function DataTable({ restrictTo }: { restrictTo?: WorkspacePlan[] }) {
36 const [currency] = useCookieState("x-currency", "USD");
37 const trpc = useTRPC();
38 const [isPending, startTransition] = useTransition();
39 const { data: workspace } = useQuery(trpc.workspace.get.queryOptions());
40
41 const checkoutSessionMutation = useMutation(
42 trpc.stripeRouter.getCheckoutSession.mutationOptions({
43 onSuccess: async (data) => {
44 if (!data) return;
45
46 const stripe = await getStripe();
47 stripe?.redirectToCheckout({ sessionId: data.id });
48 },
49 }),
50 );
51
52 if (!workspace) return null;
53
54 const filteredPlans = Object.values(plans).filter((plan) =>
55 restrictTo ? restrictTo.includes(plan.id) : true,
56 );
57
58 return (
59 <Table className="relative table-fixed">
60 <TableCaption>
61 A list to compare the different features by plan.
62 </TableCaption>
63 <TableHeader>
64 <TableRow className="hover:bg-transparent">
65 <TableHead className="p-2 align-bottom">
66 Features comparison
67 </TableHead>
68 {filteredPlans.map(({ id, ...plan }) => {
69 const isCurrentPlan = workspace.plan === id;
70 const price = getPriceConfig(id, currency);
71 return (
72 <TableHead
73 key={id}
74 className={cn(
75 "h-auto p-2 align-bottom text-foreground",
76 id === "starter" ? "bg-muted/30" : "",
77 )}
78 >
79 <div className="flex h-full flex-col justify-between gap-1">
80 <div className="flex flex-1 flex-col gap-1">
81 <p className="font-cal text-lg">{plan.title}</p>
82 <p className="text-wrap font-normal text-muted-foreground text-xs">
83 {plan.description}
84 </p>
85 </div>
86 <p className="text-right">
87 <span className="font-mono text-lg">
88 {new Intl.NumberFormat(price.locale, {
89 style: "currency",
90 currency: price.currency,
91 }).format(price.value)}
92 </span>
93 <span className="text-muted-foreground text-sm">
94 /month
95 </span>
96 </p>
97 <Button
98 size="sm"
99 type="button"
100 variant={id === "starter" ? "default" : "outline"}
101 onClick={() => {
102 startTransition(async () => {
103 await checkoutSessionMutation.mutateAsync({
104 plan: id,
105 // TODO: move to the server as we have the current workspace
106 workspaceSlug: workspace.slug,
107 successUrl: `${BASE_URL}/settings/billing?success=true`,
108 cancelUrl: `${BASE_URL}/settings/billing`,
109 });
110 });
111 }}
112 disabled={isPending || isCurrentPlan}
113 >
114 {isCurrentPlan
115 ? "Current Plan"
116 : isPending
117 ? "Choosing..."
118 : "Choose"}
119 </Button>
120 </div>
121 </TableHead>
122 );
123 })}
124 </TableRow>
125 </TableHeader>
126 <TableBody>
127 {Object.entries(featureGroups).map(
128 ([groupKey, { label, features }]) => (
129 <Fragment key={groupKey}>
130 <TableRow className="bg-muted/50">
131 <TableCell
132 colSpan={filteredPlans.length + 1}
133 className="font-medium"
134 >
135 {label}
136 </TableCell>
137 </TableRow>
138 {features.map(
139 ({ value, label: featureLabel, monthly, badge }) => (
140 <TableRow key={groupKey + value}>
141 <TableCell>
142 <div className="flex items-center gap-2 text-wrap">
143 {featureLabel}{" "}
144 {badge ? (
145 <Badge variant="outline">{badge}</Badge>
146 ) : null}
147 </div>
148 </TableCell>
149 {filteredPlans.map((plan) => {
150 const limitValue =
151 plan.limits[value as keyof typeof plan.limits];
152 const isAddon = value in plan.addons;
153
154 function renderContent() {
155 if (isAddon) {
156 const price = getAddonPriceConfig(
157 plan.id,
158 value as keyof typeof plan.addons,
159 currency,
160 );
161 if (!price) return null;
162
163 const isNumber = typeof limitValue === "number";
164 return (
165 <div>
166 <span>
167 {isNumber
168 ? new Intl.NumberFormat("us")
169 .format(limitValue)
170 .toString()
171 : null}
172 </span>
173 <span>
174 <span className="text-muted-foreground">
175 {isNumber ? " + " : ""}
176 </span>
177 <span>
178 {new Intl.NumberFormat(price.locale, {
179 style: "currency",
180 currency: price.currency,
181 }).format(price.value)}
182 {isNumber ? "/mo./each" : "/mo."}
183 </span>
184 </span>
185 </div>
186 );
187 }
188 if (typeof limitValue === "boolean") {
189 return limitValue ? (
190 <Check className="h-4 w-4 text-foreground" />
191 ) : (
192 <span className="text-muted-foreground/50">
193 ‐
194 </span>
195 );
196 }
197 if (typeof limitValue === "number") {
198 return new Intl.NumberFormat("us")
199 .format(limitValue)
200 .toString();
201 }
202
203 // TODO: create a format function for this in @data/plans
204 if (value === "regions" && Array.isArray(limitValue)) {
205 return limitValue?.length ?? 0;
206 }
207
208 if (
209 Array.isArray(limitValue) &&
210 limitValue.length > 0
211 ) {
212 return limitValue[0];
213 }
214 return limitValue;
215 }
216
217 return (
218 <TableCell
219 key={plan.id + value}
220 className={cn(
221 "font-mono",
222 plan.id === "starter" && "bg-muted/30",
223 )}
224 >
225 {renderContent()}
226 {monthly ? "/mo." : ""}
227 </TableCell>
228 );
229 })}
230 </TableRow>
231 ),
232 )}
233 </Fragment>
234 ),
235 )}
236 </TableBody>
237 </Table>
238 );
239}