Add notification preferences and alternate dues payment options

Notification preferences (migration 048):
- Per-member opt-out of muteable categories (bookings, classes, forms,
  admin alerts); billing is always-on. Opt-out model, default on.
- Dispatcher reads prefs and marks muted outbox rows 'skipped' instead
  of sending; pure isMuted logic, fail-open on a prefs lookup error.
- notification_preferences table (default-deny RLS); /me toggles and
  self-view via validated service-client actions.

Alternate dues payment methods (migration 049):
- Admin sets external pay-here URLs per platform (PayPal/Zeffy/Venmo)
  in /settings; members see active ones on /me and pay off-platform;
  the treasurer reconciles manually (platform pre-tagged). https-only.
- dues_payment_methods table (SELECT space member, write admin/board).

/me visual pass:
- Restore the chip/preset profile editor (skills/interests/willing-to/
  affiliations); promote ChipInput and presets to shared modules.
- Gate the Stripe pay-dues button on whether the space has Stripe
  configured; show external links or a contact-admin note otherwise.
This commit is contained in:
Moheeb Zara
2026-05-19 20:26:32 -07:00
parent addedfdb0f
commit ab8c22ae1d
29 changed files with 1281 additions and 124 deletions
+80 -17
View File
@@ -5,25 +5,45 @@ import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { startDuesCheckout, startBillingPortal } from '@/lib/actions'
import { DUES_PLATFORM_LABEL, type DuesLinkPlatform } from '@/lib/dues-payments-logic'
type Billing = { status: string | null; currentPeriodEnd: string | null; hasCustomer: boolean } | null
type Billing = {
status: string | null
currentPeriodEnd: string | null
hasCustomer: boolean
configured: boolean
} | null
export type DuesMethod = {
platform: string
url: string
instructions: string | null
}
const PAID = new Set(['active', 'trialing'])
export function DuesCard({ billing }: { billing: Billing }) {
const platformLabel = (p: string) =>
DUES_PLATFORM_LABEL[p as DuesLinkPlatform] ?? p
export function DuesCard({ billing, methods = [] }: { billing: Billing; methods?: DuesMethod[] }) {
const [busy, setBusy] = useState(false)
const paid = !!billing?.status && PAID.has(billing.status)
const stripeReady = !!billing?.configured
const hasAltMethods = methods.length > 0
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) }
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 className="bg-card rounded-lg border border-border p-5 space-y-4">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div>
<p className="font-sans text-sm font-medium text-foreground">Membership dues</p>
{billing?.status ? (
@@ -41,18 +61,61 @@ export function DuesCard({ billing }: { billing: Billing }) {
<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>
{/* Stripe Checkout / Portal: only when the space has Stripe set up. */}
{(stripeReady && (!paid || billing?.hasCustomer)) && (
<div className="flex gap-2">
{!paid && (
<Button size="sm" disabled={busy} onClick={() => go(startDuesCheckout)}>
{busy ? 'Opening…' : 'Pay dues with card'}
</Button>
)}
{billing?.hasCustomer && (
<Button size="sm" variant="outline" disabled={busy} onClick={() => go(startBillingPortal)}>
Manage billing
</Button>
)}
</div>
)}
{/* Admin-configured external links. Shown when the member still owes dues. */}
{!paid && hasAltMethods && (
<div className="space-y-2">
<p className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
{stripeReady ? 'Other ways to pay' : 'Pay your dues'}
</p>
<div className="flex flex-col gap-2">
{methods.map(m => (
<a
key={m.platform}
href={m.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between gap-3 rounded border border-border bg-background px-3 py-2 hover:border-primary transition"
>
<div className="min-w-0">
<span className="font-sans text-sm text-foreground">Pay with {platformLabel(m.platform)}</span>
{m.instructions && (
<p className="font-sans text-xs text-muted-foreground mt-0.5">{m.instructions}</p>
)}
</div>
<span className="font-mono text-[10px] text-muted-foreground shrink-0">open ↗</span>
</a>
))}
</div>
<p className="font-sans text-xs text-muted-foreground">
These open an external payment page. Your payment is recorded by a treasurer after it
clears.
</p>
</div>
)}
{/* Nothing set up at all. */}
{!paid && !stripeReady && !hasAltMethods && (
<p className="font-sans text-sm text-muted-foreground">
Online dues payment isn&rsquo;t set up for this space yet. Contact an admin to pay your dues.
</p>
)}
</div>
)
}
+100
View File
@@ -0,0 +1,100 @@
'use client'
import { useState, type KeyboardEvent } from 'react'
type Props = {
values: string[]
onChange: (next: string[]) => void
placeholder?: string
maxLength?: number
maxItems?: number
suggestions?: string[]
}
/**
* Minimal chip / token input. Press Enter or comma to commit the current
* value. Backspace on an empty input removes the last chip. Optional
* suggestion list shows un-used suggestions as click-to-add chips below.
*/
export function ChipInput({
values,
onChange,
placeholder,
maxLength = 80,
maxItems = 50,
suggestions,
}: Props) {
const [draft, setDraft] = useState('')
function commit(raw: string) {
const v = raw.trim()
if (!v) return
if (values.includes(v)) return
if (values.length >= maxItems) return
onChange([...values, v.slice(0, maxLength)])
setDraft('')
}
function remove(idx: number) {
onChange(values.filter((_, i) => i !== idx))
}
function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
commit(draft)
} else if (e.key === 'Backspace' && draft === '' && values.length > 0) {
e.preventDefault()
onChange(values.slice(0, -1))
}
}
const remainingSuggestions = (suggestions ?? []).filter(s => !values.includes(s))
return (
<div>
<div className="flex flex-wrap items-center gap-1.5 bg-background border border-border rounded px-2 py-1.5 focus-within:border-primary">
{values.map((v, i) => (
<span
key={`${v}-${i}`}
className="inline-flex items-center gap-1 bg-muted rounded px-2 py-0.5 font-mono text-xs"
>
{v}
<button
type="button"
onClick={() => remove(i)}
className="text-muted-foreground hover:text-foreground"
aria-label={`Remove ${v}`}
>
×
</button>
</span>
))}
<input
type="text"
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => commit(draft)}
placeholder={values.length === 0 ? placeholder : ''}
maxLength={maxLength}
className="flex-1 min-w-[120px] bg-transparent text-foreground font-sans text-sm py-1 px-1 focus:outline-none"
/>
</div>
{remainingSuggestions.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{remainingSuggestions.map(s => (
<button
key={s}
type="button"
onClick={() => commit(s)}
className="font-mono text-[10px] tracking-widest border border-border text-muted-foreground hover:text-foreground hover:border-primary rounded px-2 py-0.5"
>
+ {s}
</button>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,149 @@
'use client'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import {
DUES_LINK_PLATFORMS,
DUES_PLATFORM_LABEL,
type DuesLinkPlatform,
} from '@/lib/dues-payments-logic'
import {
listDuesPaymentMethods,
upsertDuesPaymentMethod,
deleteDuesPaymentMethod,
} from '@/lib/actions'
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 Row = { url: string; instructions: string; isActive: boolean; exists: boolean }
type RowMap = Record<DuesLinkPlatform, Row>
const emptyRows = (): RowMap =>
Object.fromEntries(
DUES_LINK_PLATFORMS.map(p => [p, { url: '', instructions: '', isActive: true, exists: false }]),
) as RowMap
export function DuesPaymentMethodsPanel() {
const [rows, setRows] = useState<RowMap | null>(null)
const [busy, setBusy] = useState<DuesLinkPlatform | null>(null)
async function load() {
const next = emptyRows()
const r = await listDuesPaymentMethods()
if ('data' in r) {
for (const m of r.data) {
const p = m.platform as DuesLinkPlatform
if (next[p]) next[p] = { url: m.url, instructions: m.instructions ?? '', isActive: m.isActive, exists: true }
}
}
setRows(next)
}
useEffect(() => {
load()
}, [])
if (!rows) return null
function set(p: DuesLinkPlatform, patch: Partial<Row>) {
setRows(prev => (prev ? { ...prev, [p]: { ...prev[p], ...patch } } : prev))
}
async function save(p: DuesLinkPlatform) {
const row = rows![p]
if (!row.url.trim()) return toast.error('Enter a payment URL first.')
setBusy(p)
const res = await upsertDuesPaymentMethod({
platform: p,
url: row.url.trim(),
instructions: row.instructions.trim() || null,
is_active: row.isActive,
sort_order: DUES_LINK_PLATFORMS.indexOf(p),
})
setBusy(null)
if ('error' in res) return toast.error(res.error)
toast.success(`${DUES_PLATFORM_LABEL[p]} saved`)
load()
}
async function remove(p: DuesLinkPlatform) {
setBusy(p)
const res = await deleteDuesPaymentMethod({ platform: p })
setBusy(null)
if ('error' in res) return toast.error(res.error)
toast.success(`${DUES_PLATFORM_LABEL[p]} removed`)
load()
}
return (
<div className="p-4 md:p-6 border-t border-border">
<div className="max-w-2xl space-y-3">
<h2 className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
Other ways to pay dues
</h2>
<p className="font-sans text-xs text-muted-foreground">
Add an external payment link per platform (PayPal, Zeffy, Venmo). Members see active links
on their membership page and pay you directly there. Payments are not recorded
automatically: reconcile them later in Payments.
</p>
<div className="space-y-4">
{DUES_LINK_PLATFORMS.map(p => {
const row = rows[p]
return (
<div key={p} className="rounded border border-border bg-background p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="font-sans text-sm font-medium text-foreground">
{DUES_PLATFORM_LABEL[p]}
</span>
<label className="flex items-center gap-2">
<span className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
{row.isActive ? 'shown' : 'hidden'}
</span>
<Switch
checked={row.isActive}
onCheckedChange={next => set(p, { isActive: next })}
aria-label={`Show ${DUES_PLATFORM_LABEL[p]} to members`}
/>
</label>
</div>
<input
className={input}
value={row.url}
onChange={e => set(p, { url: e.target.value })}
placeholder="https://… payment link"
inputMode="url"
maxLength={500}
/>
<input
className={input}
value={row.instructions}
onChange={e => set(p, { instructions: e.target.value })}
placeholder="Instructions, e.g. put your member name in the note (optional)"
maxLength={300}
/>
<div className="flex gap-2">
<Button size="sm" disabled={busy === p} onClick={() => save(p)}>
{busy === p ? 'Saving…' : 'Save'}
</Button>
{row.exists && (
<Button
size="sm"
variant="outline"
disabled={busy === p}
onClick={() => remove(p)}
>
Remove
</Button>
)}
</div>
</div>
)
})}
</div>
</div>
</div>
)
}