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.
101 lines
2.9 KiB
TypeScript
101 lines
2.9 KiB
TypeScript
'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>
|
||
)
|
||
}
|