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
+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>
)
}