feat(billing): P1f Stripe UI — admin config panel + member dues card
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.
This commit is contained in:
+13
-1
@@ -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() {
|
||||
</PageHeader>
|
||||
|
||||
<div className="p-4 md:p-6 space-y-8 max-w-3xl">
|
||||
<section>
|
||||
<h2 className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground mb-3">
|
||||
Dues
|
||||
</h2>
|
||||
<DuesCard billing={billing} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground mb-3">
|
||||
Certifications
|
||||
|
||||
@@ -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 (
|
||||
<SettingsClient
|
||||
space={space}
|
||||
isAdmin={isAdmin}
|
||||
integrations={integrations ?? []}
|
||||
currentRole={member.role}
|
||||
/>
|
||||
<>
|
||||
<SettingsClient
|
||||
space={space}
|
||||
isAdmin={isAdmin}
|
||||
integrations={integrations ?? []}
|
||||
currentRole={member.role}
|
||||
/>
|
||||
<StripeBillingPanel spaceId={member.space_id} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="bg-card rounded border border-border p-4">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<p className="font-sans text-sm font-medium text-foreground">Membership dues</p>
|
||||
{billing?.status ? (
|
||||
<p className="font-mono text-[10px] text-muted-foreground mt-0.5">
|
||||
{billing.status}
|
||||
{billing.currentPeriodEnd
|
||||
? ` · renews ${new Date(billing.currentPeriodEnd).toLocaleDateString()}`
|
||||
: ''}
|
||||
</p>
|
||||
) : (
|
||||
<p className="font-mono text-[10px] text-muted-foreground mt-0.5">No active dues subscription.</p>
|
||||
)}
|
||||
</div>
|
||||
{billing?.status && (
|
||||
<Badge variant={paid ? 'default' : 'outline'}>{paid ? 'Current' : 'Action needed'}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
{!paid && (
|
||||
<Button size="sm" disabled={busy} onClick={() => go(startDuesCheckout)}>
|
||||
{busy ? 'Opening…' : 'Pay dues'}
|
||||
</Button>
|
||||
)}
|
||||
{billing?.hasCustomer && (
|
||||
<Button size="sm" variant="outline" disabled={busy} onClick={() => go(startBillingPortal)}>
|
||||
Manage billing
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string, string>
|
||||
hasSecretKey: boolean
|
||||
hasWebhookSecret: boolean
|
||||
configured: boolean
|
||||
}
|
||||
|
||||
export function StripeBillingPanel({ spaceId }: { spaceId: string }) {
|
||||
const [s, setS] = useState<Settings | null>(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 (
|
||||
<div className="p-4 md:p-6 border-t border-border">
|
||||
<div className="max-w-2xl space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
|
||||
Stripe recurring dues
|
||||
</h2>
|
||||
<span className={`font-mono text-[10px] ${s.configured ? 'text-primary' : 'text-muted-foreground'}`}>
|
||||
{s.configured ? 'configured' : 'not configured'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-sans text-xs text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<select className={input} value={s.mode} onChange={e => setS({ ...s, mode: e.target.value as 'test' | 'live' })}>
|
||||
<option value="test">Test mode</option>
|
||||
<option value="live">Live mode</option>
|
||||
</select>
|
||||
<input className={input} type="number" min={0} max={90} value={s.grace_days}
|
||||
onChange={e => setS({ ...s, grace_days: Number(e.target.value) })}
|
||||
placeholder="Grace days before 'late'" />
|
||||
<input className={input} value={s.publishable_key}
|
||||
onChange={e => setS({ ...s, publishable_key: e.target.value })}
|
||||
placeholder="Publishable key (pk_…)" />
|
||||
<input className={input} type="password" value={secretKey}
|
||||
onChange={e => setSecretKey(e.target.value)}
|
||||
placeholder={s.hasSecretKey ? 'Secret key — set (blank = keep)' : 'Secret key (sk_…)'} />
|
||||
<input className={input} type="password" value={webhookSecret}
|
||||
onChange={e => setWebhookSecret(e.target.value)}
|
||||
placeholder={s.hasWebhookSecret ? 'Webhook secret — set (blank = keep)' : 'Webhook signing secret (whsec_…)'} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground mb-1.5">Tier → Stripe Price</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
{TIERS.map(t => (
|
||||
<input key={t} className={input} value={s.prices[t] ?? ''}
|
||||
onChange={e => setS({ ...s, prices: { ...s.prices, [t]: e.target.value } })}
|
||||
placeholder={`${t} price_…`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-border bg-background p-3">
|
||||
<p className="font-mono text-[10px] text-muted-foreground">Webhook endpoint (add in Stripe → Developers → Webhooks):</p>
|
||||
<code className="font-mono text-[11px] text-foreground break-all">{webhookUrl}</code>
|
||||
<p className="font-mono text-[10px] text-muted-foreground mt-1">
|
||||
Subscribe to: checkout.session.completed, customer.subscription.*, invoice.paid, invoice.payment_failed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button size="sm" disabled={busy} onClick={save}>{busy ? 'Saving…' : 'Save Stripe settings'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user