upgrade-buttons.tsx91 lines · main
1'use client';
2
3import { useState } from 'react';
4
5interface Plan {
6 tier: 'pro' | 'team';
7 productId: string;
8}
9
10interface Props {
11 plans: Plan[];
12 currentTier: 'free' | 'pro' | 'team';
13}
14
15const TIER_RANK: Record<'free' | 'pro' | 'team', number> = { free: 0, pro: 1, team: 2 };
16
17function labelFor(target: Plan['tier'], current: 'free' | 'pro' | 'team'): string {
18 const direction = TIER_RANK[target] > TIER_RANK[current] ? 'upgrade' : 'downgrade';
19 return `${direction} to ${target}`;
20}
21
22/**
23 * Plan-switch buttons for the /dashboard/billing page. Opens a Polar
24 * checkout for the requested tier and redirects the user to the hosted
25 * URL. Label reflects direction (upgrade vs downgrade) relative to the
26 * user's current tier, and we surface the *adjacent* tiers only — jumping
27 * Free → Team is still possible (both shown on free), but Team users
28 * don't see a "downgrade to free" here; cancellation lives in the portal.
29 */
30export function UpgradeButtons({ plans, currentTier }: Props) {
31 const [pending, setPending] = useState<Plan['tier'] | null>(null);
32 const [errMsg, setErrMsg] = useState<string | null>(null);
33
34 async function startCheckout(tier: Plan['tier']): Promise<void> {
35 setPending(tier);
36 setErrMsg(null);
37 try {
38 const res = await fetch('/api/v1/billing/checkout', {
39 method: 'POST',
40 credentials: 'include',
41 headers: { 'content-type': 'application/json' },
42 body: JSON.stringify({
43 tier,
44 successURL: `${window.location.origin}/dashboard/billing?checkout=success`,
45 }),
46 });
47 if (!res.ok) {
48 const body = (await res.json().catch(() => ({}))) as { code?: string; message?: string };
49 throw new Error(body.message ?? body.code ?? `checkout failed: ${res.status}`);
50 }
51 const { url } = (await res.json()) as { url: string };
52 window.location.href = url;
53 } catch (err) {
54 setErrMsg(err instanceof Error ? err.message : 'checkout failed');
55 setPending(null);
56 }
57 }
58
59 // For free users we show both upgrade paths (pro + team).
60 // For pro we show the upward path only (team).
61 // For team we show the single step down (pro); cancellation is in the portal.
62 const pickable = plans.filter((p) => {
63 if (p.tier === currentTier) return false;
64 if (currentTier === 'team') return p.tier === 'pro';
65 if (currentTier === 'pro') return p.tier === 'team';
66 return true;
67 });
68 if (pickable.length === 0) return null;
69
70 return (
71 <div className="flex flex-col gap-2">
72 <div className="flex flex-wrap gap-2">
73 {pickable.map((p) => {
74 const label = labelFor(p.tier, currentTier);
75 return (
76 <button
77 key={p.tier}
78 type="button"
79 onClick={() => void startCheckout(p.tier)}
80 disabled={pending !== null}
81 className="rounded-md border border-[var(--color-border)] px-3 py-1.5 font-mono text-xs text-[var(--color-text-muted)] transition hover:border-[var(--color-primary)] hover:text-[var(--color-primary)] disabled:opacity-50"
82 >
83 {pending === p.tier ? `opening ${p.tier} checkout…` : label}
84 </button>
85 );
86 })}
87 </div>
88 {errMsg ? <p className="font-mono text-xs text-[var(--color-error)]">{errMsg}</p> : null}
89 </div>
90 );
91}