Openstatus www.openstatus.dev
at 4c0f4c00a38753a5d0dfd7e7b7b7706dec6f1503 239 lines 9.0 kB view raw
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 &#8208; 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}