manage-billing-button.tsx71 lines · main
1'use client';
2
3import { useState } from 'react';
4
5interface Props {
6 label?: string;
7 variant?: 'primary' | 'secondary';
8}
9
10/**
11 * Opens a Polar customer portal session for the signed-in user. Only
12 * rendered when the user already has a polar_customer_id (i.e. they've
13 * completed a checkout at least once).
14 */
15export function ManageBillingButton({
16 label = 'manage billing on polar',
17 variant = 'secondary',
18}: Props) {
19 const [pending, setPending] = useState(false);
20 const [errMsg, setErrMsg] = useState<string | null>(null);
21
22 async function openPortal(): Promise<void> {
23 setPending(true);
24 setErrMsg(null);
25 try {
26 const res = await fetch('/api/v1/billing/portal', {
27 method: 'POST',
28 credentials: 'include',
29 headers: { 'content-type': 'application/json' },
30 body: JSON.stringify({
31 returnURL: `${window.location.origin}/dashboard/billing`,
32 }),
33 });
34 if (!res.ok) {
35 const body = (await res.json().catch(() => ({}))) as {
36 code?: string;
37 message?: string;
38 };
39 throw new Error(body.message ?? body.code ?? `http ${res.status}`);
40 }
41 const { url } = (await res.json()) as { url: string };
42 // Open the portal in a new tab so the user stays on briven.tech and
43 // can return to the dashboard without a back-navigation round-trip.
44 // noopener/noreferrer hardens against tabnabbing from a third-party.
45 window.open(url, '_blank', 'noopener,noreferrer');
46 setPending(false);
47 } catch (err) {
48 setErrMsg(err instanceof Error ? err.message : 'portal failed to open');
49 setPending(false);
50 }
51 }
52
53 const classes =
54 variant === 'primary'
55 ? 'rounded-md bg-[var(--color-primary)] px-4 py-2 font-mono text-xs font-medium text-[var(--color-text-inverse)] transition hover:bg-[var(--color-primary-hover)] disabled:opacity-50'
56 : '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';
57
58 return (
59 <div className="flex flex-col gap-2">
60 <button
61 type="button"
62 onClick={() => void openPortal()}
63 disabled={pending}
64 className={`self-start ${classes}`}
65 >
66 {pending ? 'opening portal…' : label}
67 </button>
68 {errMsg ? <p className="font-mono text-xs text-[var(--color-error)]">{errMsg}</p> : null}
69 </div>
70 );
71}