Openstatus www.openstatus.dev

feat: geolocation currency (#1324)

* feat: geolocation currency

* chore: inr

* update stripe pricing

---------

Co-authored-by: Thibault Le Ouay <thibaultleouay@gmail.com>

authored by

Maximilian Kaske
Thibault Le Ouay
and committed by
GitHub
b5c4f879 b8451e41

+118 -27
+10 -1
apps/dashboard/src/components/data-table/billing/data-table.tsx
··· 15 15 } from "@/components/ui/table"; 16 16 17 17 import { config as featureGroups, plans } from "@/data/plans"; 18 + import { useCookieState } from "@/hooks/use-cookie-state"; 18 19 import { getStripe } from "@/lib/stripe"; 19 20 import { useTRPC } from "@/lib/trpc/client"; 20 21 import { cn } from "@/lib/utils"; 21 22 import type { WorkspacePlan } from "@openstatus/db/src/schema"; 23 + import { getPriceConfig } from "@openstatus/db/src/schema/plan/utils"; 22 24 import { useMutation, useQuery } from "@tanstack/react-query"; 23 25 24 26 const BASE_URL = ··· 27 29 : "http://localhost:3000"; 28 30 29 31 export function DataTable({ restrictTo }: { restrictTo?: WorkspacePlan[] }) { 32 + const [currency] = useCookieState("x-currency", "USD"); 30 33 const trpc = useTRPC(); 31 34 const [isPending, startTransition] = useTransition(); 32 35 const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); ··· 60 63 </TableHead> 61 64 {filteredPlans.map(({ id, ...plan }) => { 62 65 const isCurrentPlan = workspace.plan === id; 66 + const price = getPriceConfig(id, currency); 63 67 return ( 64 68 <TableHead 65 69 key={id} ··· 76 80 </p> 77 81 </div> 78 82 <p className="text-right"> 79 - <span className="font-cal text-lg">{plan.price}€</span>{" "} 83 + <span className="font-cal text-lg"> 84 + {new Intl.NumberFormat(price.locale, { 85 + style: "currency", 86 + currency: price.currency, 87 + }).format(price.value)} 88 + </span> 80 89 <span className="font-light text-muted-foreground text-sm"> 81 90 /month 82 91 </span>
+8
apps/dashboard/src/middleware.ts
··· 4 4 5 5 import { db, eq } from "@openstatus/db"; 6 6 import { user, usersToWorkspaces, workspace } from "@openstatus/db/src/schema"; 7 + import { getCurrency } from "@openstatus/db/src/schema/plan/utils"; 7 8 8 9 export default auth(async (req) => { 9 10 const url = req.nextUrl.clone(); 10 11 const response = NextResponse.next(); 12 + 13 + const continent = req.headers.get("x-vercel-ip-continent") || "NA"; 14 + const country = req.headers.get("x-vercel-ip-country") || "US"; 15 + const currency = getCurrency({ continent, country }); 16 + 17 + // NOTE: used in the pricing table to display the currency based on user's location 18 + response.cookies.set("x-currency", currency); 11 19 12 20 if (url.pathname.includes("api/trpc")) { 13 21 return response;
+2 -2
apps/web/src/app/(pages)/(content)/page.tsx
··· 24 24 }, 25 25 offers: Object.entries(allPlans).map(([_, value]) => ({ 26 26 "@type": "Offer", 27 - price: value.price, 27 + price: value.price.USD, 28 28 name: value.title, 29 - priceCurrency: "EUR", 29 + priceCurrency: "USD", 30 30 availability: "https://schema.org/InStock", 31 31 })), 32 32 };
+27 -17
apps/web/src/components/marketing/pricing/pricing-plan-radio.tsx
··· 8 8 RadioGroupItem, 9 9 } from "@openstatus/ui/src/components/radio-group"; 10 10 11 + import { useCookieState } from "@/hooks/use-cookie-state"; 11 12 import { cn } from "@/lib/utils"; 12 13 import type { WorkspacePlan } from "@openstatus/db/src/schema"; 14 + import { getPriceConfig } from "@openstatus/db/src/schema/plan/utils"; 13 15 14 16 export function PricingPlanRadio({ 15 17 onChange, 16 18 }: { 17 19 onChange(value: WorkspacePlan): void; 18 20 }) { 21 + const [currency] = useCookieState("x-currency", "USD"); 19 22 return ( 20 23 <RadioGroup 21 24 defaultValue="team" 22 25 className="grid grid-cols-3 gap-4" 23 26 onValueChange={onChange} 24 27 > 25 - {workspacePlans.map((key) => ( 26 - <div key={key}> 27 - <RadioGroupItem value={key} id={key} className="peer sr-only" /> 28 - <Label 29 - htmlFor={key} 30 - className={cn( 31 - "flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary", 32 - key === "team" && "bg-muted/50", 33 - )} 34 - > 35 - <span className="text-sm capitalize">{allPlans[key].title}</span> 36 - <span className="mt-1 font-light text-muted-foreground text-xs"> 37 - {allPlans[key].price}€/month 38 - </span> 39 - </Label> 40 - </div> 41 - ))} 28 + {workspacePlans.map((key) => { 29 + const price = getPriceConfig(key, currency); 30 + return ( 31 + <div key={key}> 32 + <RadioGroupItem value={key} id={key} className="peer sr-only" /> 33 + <Label 34 + htmlFor={key} 35 + className={cn( 36 + "flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary", 37 + key === "team" && "bg-muted/50", 38 + )} 39 + > 40 + <span className="text-sm capitalize">{allPlans[key].title}</span> 41 + <span className="mt-1 font-light text-muted-foreground text-xs"> 42 + {new Intl.NumberFormat(price.locale, { 43 + style: "currency", 44 + currency: price.currency, 45 + }).format(price.value)} 46 + /month 47 + </span> 48 + </Label> 49 + </div> 50 + ); 51 + })} 42 52 </RadioGroup> 43 53 ); 44 54 }
+11 -1
apps/web/src/components/marketing/pricing/pricing-table.tsx
··· 20 20 import { pricingTableConfig } from "../../../config/pricing-table"; 21 21 22 22 import { LoadingAnimation } from "@/components/loading-animation"; 23 + import { useCookieState } from "@/hooks/use-cookie-state"; 23 24 import { cn } from "@/lib/utils"; 24 25 import { allPlans } from "@openstatus/db/src/schema/plan/config"; 26 + import { getPriceConfig } from "@openstatus/db/src/schema/plan/utils"; 25 27 import { 26 28 Tooltip, 27 29 TooltipContent, ··· 41 43 isLoading?: boolean; 42 44 }) { 43 45 const router = useRouter(); 46 + const [currency] = useCookieState("x-currency", "USD"); 44 47 const selectedPlans = Object.entries(allPlans) 45 48 .filter(([key, _]) => plans.includes(key as keyof typeof allPlans)) 46 49 .map(([key, value]) => ({ key: key as keyof typeof allPlans, ...value })); ··· 56 59 </TableHead> 57 60 {selectedPlans.map(({ key, ...plan }) => { 58 61 const isCurrentPlan = key === currentPlan; 62 + const price = getPriceConfig(key, currency); 63 + console.log(price); 59 64 return ( 60 65 <TableHead 61 66 key={key} ··· 71 76 {plan.description} 72 77 </p> 73 78 <p className="mb-2 text-right"> 74 - <span className="font-cal text-xl">{plan.price}€</span>{" "} 79 + <span className="font-cal text-xl"> 80 + {new Intl.NumberFormat(price.locale, { 81 + style: "currency", 82 + currency: price.currency, 83 + }).format(price.value)} 84 + </span> 75 85 <span className="font-light text-muted-foreground text-sm"> 76 86 /month 77 87 </span>
+9
apps/web/src/middleware.ts
··· 2 2 3 3 import { db } from "@openstatus/db/src/db"; 4 4 import { user, usersToWorkspaces, workspace } from "@openstatus/db/src/schema"; 5 + import { getCurrency } from "@openstatus/db/src/schema/plan/utils"; 5 6 6 7 import { auth } from "@/lib/auth"; 7 8 import { eq } from "@openstatus/db"; ··· 48 49 49 50 export default auth(async (req) => { 50 51 const url = req.nextUrl.clone(); 52 + const continent = req.headers.get("x-vercel-ip-continent") || "NA"; 53 + const country = req.headers.get("x-vercel-ip-country") || "US"; 54 + const currency = getCurrency({ continent, country }); 51 55 52 56 if (url.pathname.includes("api/trpc")) { 53 57 return NextResponse.next(); ··· 129 133 // reset workspace slug cookie if no auth 130 134 if (!req.auth && req.cookies.has("workspace-slug")) { 131 135 const response = NextResponse.next(); 136 + response.cookies.set("x-currency", currency); 132 137 response.cookies.delete("workspace-slug"); 133 138 return response; 134 139 } 140 + 141 + const response = NextResponse.next(); 142 + response.cookies.set("x-currency", currency); 143 + return response; 135 144 }); 136 145 137 146 export const config = {
+2 -2
packages/api/src/router/stripe/utils.ts
··· 20 20 monthly: { 21 21 priceIds: { 22 22 test: "price_1OVHQDBXJcTfzsyJjfiXl10Y", 23 - production: "price_1PdJ5MBXJcTfzsyJqWXeMUEQ", 23 + production: "price_1RxsLNBXJcTfzsyJ7La5Jn5y", 24 24 }, 25 25 }, 26 26 }, ··· 31 31 monthly: { 32 32 priceIds: { 33 33 test: "price_1OVHPlBXJcTfzsyJvPlB1kNb", 34 - production: "price_1PiBNnBXJcTfzsyJu65D9wfN", 34 + production: "price_1RxsJzBXJcTfzsyJBOztaKlR", 35 35 }, 36 36 }, 37 37 },
+20 -4
packages/db/src/schema/plan/config.ts
··· 8 8 title: "Hobby" | "Starter" | "Pro"; 9 9 id: WorkspacePlan; 10 10 description: string; 11 - price: number; 11 + price: { 12 + USD: number; 13 + EUR: number; 14 + INR: number; 15 + }; 12 16 limits: Limits; 13 17 } 14 18 > = { ··· 16 20 title: "Hobby", 17 21 id: "free", 18 22 description: "Perfect for personal projects", 19 - price: 0, 23 + price: { 24 + USD: 0, 25 + EUR: 0, 26 + INR: 0, 27 + }, 20 28 limits: { 21 29 monitors: 1, 22 30 "synthetic-checks": 30, ··· 49 57 title: "Starter", 50 58 id: "starter", 51 59 description: "Perfect for uptime monitoring", 52 - price: 30, 60 + price: { 61 + USD: 30, 62 + EUR: 30, 63 + INR: 3000, 64 + }, 53 65 limits: { 54 66 monitors: 20, 55 67 "synthetic-checks": 100, ··· 118 130 title: "Pro", 119 131 id: "team", 120 132 description: "Perfect for global synthetic monitoring", 121 - price: 100, 133 + price: { 134 + USD: 100, 135 + EUR: 100, 136 + INR: 10000, 137 + }, 122 138 limits: { 123 139 monitors: 50, 124 140 "synthetic-checks": 300,
+29
packages/db/src/schema/plan/utils.ts
··· 13 13 export function getPlanConfig(plan: WorkspacePlan | null) { 14 14 return allPlans[plan || "free"]; 15 15 } 16 + 17 + export function getCurrency({ 18 + continent, 19 + country, 20 + }: { 21 + continent: string; 22 + country: string; 23 + }) { 24 + if (country === "IN") { 25 + return "INR"; 26 + } 27 + if (continent === "EU") { 28 + return "EUR"; 29 + } 30 + return "USD"; 31 + } 32 + 33 + export function getPriceConfig(plan: WorkspacePlan, currency?: string) { 34 + const planConfig = allPlans[plan]; 35 + if (!currency) { 36 + return { value: planConfig.price.USD, locale: "en-US", currency: "USD" }; 37 + } 38 + if (currency in planConfig.price) { 39 + const value = planConfig.price[currency as keyof typeof planConfig.price]; 40 + const locale = currency === "EUR" ? "fr-FR" : "en-US"; 41 + return { value, locale, currency }; 42 + } 43 + return { value: planConfig.price.USD, locale: "en-US", currency: "USD" }; 44 + }