From 1f61029f32c66e8dff47dcbe55523819e52861cf Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sun, 17 May 2026 03:38:00 -0700 Subject: [PATCH] =?UTF-8?q?feat(billing):=20P1f=20Stripe=20UI=20=E2=80=94?= =?UTF-8?q?=20admin=20config=20panel=20+=20member=20dues=20card?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings: StripeBillingPanel (admin) — mode, publishable key, write-only secret/webhook secret, per-tier price ids, grace days; shows the per-space webhook URL + which events to subscribe + Portal-activation reminder. /me: DuesCard — status from getMyBilling, 'Pay dues' (startDuesCheckout redirect) when not active, 'Manage billing' (startBillingPortal) when a customer exists. Self-contained client components; settings page composes the panel after SettingsClient. --- app/(app)/me/page.tsx | 14 ++- app/(app)/settings/page.tsx | 16 ++- components/billing/dues-card.tsx | 58 +++++++++ components/settings/stripe-billing-panel.tsx | 117 +++++++++++++++++++ 4 files changed, 198 insertions(+), 7 deletions(-) create mode 100644 components/billing/dues-card.tsx create mode 100644 components/settings/stripe-billing-panel.tsx diff --git a/app/(app)/me/page.tsx b/app/(app)/me/page.tsx index 0ef548c..2a36d2e 100644 --- a/app/(app)/me/page.tsx +++ b/app/(app)/me/page.tsx @@ -8,10 +8,11 @@ import { type CertStatus, } from '@/lib/certifications-logic' import { PERMISSIONS, PERMISSION_CODES } from '@/lib/permissions-catalog' -import { getMyClassSignups, getMyReservations, getMyCards, getMyVisits } from '@/lib/actions' +import { getMyClassSignups, getMyReservations, getMyCards, getMyVisits, getMyBilling } from '@/lib/actions' import { SIGNUP_STATUS_LABEL, SESSION_STATUS_LABEL } from '@/lib/classes-logic' import { RESERVATION_STATUS_LABEL } from '@/lib/equipment-logic' import { presenceStatus } from '@/lib/presence-logic' +import { DuesCard } from '@/components/billing/dues-card' export const dynamic = 'force-dynamic' @@ -111,6 +112,10 @@ export default async function MePage() { const visitsRes = await getMyVisits() type MyVisit = { id: string; checked_in_at: string; checked_out_at: string | null; is_host: boolean; check_in_note: string | null; check_out_note: string | null } const myVisits: MyVisit[] = 'data' in visitsRes ? (visitsRes.data as MyVisit[]) : [] + const billingRes = await getMyBilling() + const billing = ('data' in billingRes ? billingRes.data : null) as + | { status: string | null; currentPeriodEnd: string | null; hasCustomer: boolean } + | null const titleOf = (cs: ClassSignup) => { const sess = sessionOf(cs) const c = sess ? (Array.isArray(sess.classes) ? sess.classes[0] : sess.classes) : null @@ -124,6 +129,13 @@ export default async function MePage() {
+
+

+ Dues +

+ +
+

Certifications diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx index 15add88..059aa49 100644 --- a/app/(app)/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -1,5 +1,6 @@ import { createClient } from '@/lib/supabase/server' import SettingsClient from './settings-client' +import { StripeBillingPanel } from '@/components/settings/stripe-billing-panel' import { redirect } from 'next/navigation' export default async function SettingsPage() { @@ -25,11 +26,14 @@ export default async function SettingsPage() { .from('integrations').select('*').eq('space_id', member.space_id) return ( - + <> + + + ) } diff --git a/components/billing/dues-card.tsx b/components/billing/dues-card.tsx new file mode 100644 index 0000000..3807302 --- /dev/null +++ b/components/billing/dues-card.tsx @@ -0,0 +1,58 @@ +'use client' + +import { useState } from 'react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { startDuesCheckout, startBillingPortal } from '@/lib/actions' + +type Billing = { status: string | null; currentPeriodEnd: string | null; hasCustomer: boolean } | null + +const PAID = new Set(['active', 'trialing']) + +export function DuesCard({ billing }: { billing: Billing }) { + const [busy, setBusy] = useState(false) + const paid = !!billing?.status && PAID.has(billing.status) + + async function go(fn: () => Promise<{ error?: string } | { data: { url: string } }>) { + setBusy(true) + const res = await fn() + if ('error' in res && res.error) { setBusy(false); return toast.error(res.error) } + window.location.href = (res as { data: { url: string } }).data.url + } + + return ( +
+
+
+

Membership dues

+ {billing?.status ? ( +

+ {billing.status} + {billing.currentPeriodEnd + ? ` · renews ${new Date(billing.currentPeriodEnd).toLocaleDateString()}` + : ''} +

+ ) : ( +

No active dues subscription.

+ )} +
+ {billing?.status && ( + {paid ? 'Current' : 'Action needed'} + )} +
+
+ {!paid && ( + + )} + {billing?.hasCustomer && ( + + )} +
+
+ ) +} diff --git a/components/settings/stripe-billing-panel.tsx b/components/settings/stripe-billing-panel.tsx new file mode 100644 index 0000000..b05c80d --- /dev/null +++ b/components/settings/stripe-billing-panel.tsx @@ -0,0 +1,117 @@ +'use client' + +import { useEffect, useState } from 'react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { getStripeSettings, saveStripeSettings } from '@/lib/actions' + +const TIERS = ['plus', 'basic', 'associate'] as const +const input = + 'w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary' + +type Settings = { + mode: 'test' | 'live' + publishable_key: string + grace_days: number + prices: Record + hasSecretKey: boolean + hasWebhookSecret: boolean + configured: boolean +} + +export function StripeBillingPanel({ spaceId }: { spaceId: string }) { + const [s, setS] = useState(null) + const [secretKey, setSecretKey] = useState('') + const [webhookSecret, setWebhookSecret] = useState('') + const [busy, setBusy] = useState(false) + const [webhookUrl, setWebhookUrl] = useState('') + + useEffect(() => { + setWebhookUrl(`${window.location.origin}/api/stripe/webhook/${spaceId}`) + getStripeSettings().then(r => { + if ('data' in r) setS(r.data as Settings) + }) + }, [spaceId]) + + if (!s) return null + + async function save() { + if (!s) return + setBusy(true) + const res = await saveStripeSettings({ + mode: s.mode, + publishable_key: s.publishable_key || null, + secret_key: secretKey || null, + webhook_secret: webhookSecret || null, + grace_days: s.grace_days, + prices: s.prices, + }) + setBusy(false) + if ('error' in res && res.error) return toast.error(res.error) + setSecretKey('') + setWebhookSecret('') + const r = await getStripeSettings() + if ('data' in r) setS(r.data as Settings) + toast.success('Stripe settings saved') + } + + return ( +
+
+
+

+ Stripe recurring dues +

+ + {s.configured ? 'configured' : 'not configured'} + +
+

+ Uses your space’s own Stripe account. Keys are stored encrypted and never shown + again. After saving, add this webhook endpoint in your Stripe Dashboard and paste back + its signing secret, and activate the Customer Portal there. +

+ +
+ + setS({ ...s, grace_days: Number(e.target.value) })} + placeholder="Grace days before 'late'" /> + setS({ ...s, publishable_key: e.target.value })} + placeholder="Publishable key (pk_…)" /> + setSecretKey(e.target.value)} + placeholder={s.hasSecretKey ? 'Secret key — set (blank = keep)' : 'Secret key (sk_…)'} /> + setWebhookSecret(e.target.value)} + placeholder={s.hasWebhookSecret ? 'Webhook secret — set (blank = keep)' : 'Webhook signing secret (whsec_…)'} /> +
+ +
+

Tier → Stripe Price

+
+ {TIERS.map(t => ( + setS({ ...s, prices: { ...s.prices, [t]: e.target.value } })} + placeholder={`${t} price_…`} /> + ))} +
+
+ +
+

Webhook endpoint (add in Stripe → Developers → Webhooks):

+ {webhookUrl} +

+ Subscribe to: checkout.session.completed, customer.subscription.*, invoice.paid, invoice.payment_failed. +

+
+ + +
+
+ ) +}