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
+24 -4
View File
@@ -10,9 +10,11 @@ import {
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'
import { DuesCard, type DuesMethod } from '@/components/billing/dues-card'
import { ProfileForm, type ProfileInitial } from './profile-form'
import { CancelAction } from './cancel-action'
import { NotificationPrefs } from './notification-prefs'
import type { PrefMap } from '@/lib/notifications-prefs-logic'
import { cancelMySignup, cancelReservation } from '@/lib/actions'
const STATUS_VARIANT: Record<CertStatus, 'default' | 'outline'> = {
@@ -54,15 +56,17 @@ export type MyVisit = { id: string; checked_in_at: string; checked_out_at: strin
export type MyNotif = { id: string; type: string; subject: string; status: string; createdAt: string; sentAt: string | null }
export type MyPayment = { id: string; amount: number; currency: string; platform: string; description: string | null; status: string; date: string }
export type HeldPermission = { code: string; label: string }
export type Billing = { status: string | null; currentPeriodEnd: string | null; hasCustomer: boolean } | null
export type Billing = { status: string | null; currentPeriodEnd: string | null; hasCustomer: boolean; configured: boolean } | null
type Props = {
profile: ProfileInitial
grants: Grant[]
held: HeldPermission[]
billing: Billing
duesMethods: DuesMethod[]
payments: MyPayment[]
notifs: MyNotif[]
notifPrefs: PrefMap
classSignups: ClassSignup[]
reservations: Reservation[]
myCards: MyCard[]
@@ -76,8 +80,10 @@ export function MePortalClient({
grants,
held,
billing,
duesMethods,
payments,
notifs,
notifPrefs,
classSignups,
reservations,
myCards,
@@ -135,7 +141,7 @@ export function MePortalClient({
<TabsContent value="membership" className="space-y-8">
<section>
<h2 className={sectionH}>Dues</h2>
<DuesCard billing={billing} />
<DuesCard billing={billing} methods={duesMethods} />
</section>
<section>
@@ -210,6 +216,14 @@ export function MePortalClient({
</TabsContent>
<TabsContent value="activity" className="space-y-8">
<section>
<h2 className={sectionH}>Email preferences</h2>
<NotificationPrefs initial={notifPrefs} />
<p className="font-sans text-xs text-muted-foreground mt-3">
Billing emails (dues receipts, payment alerts) are always sent.
</p>
</section>
<section>
<h2 className={sectionH}>Notifications</h2>
{notifs.length === 0 ? (
@@ -228,7 +242,13 @@ export function MePortalClient({
</p>
</div>
<Badge variant={n.status === 'sent' ? 'default' : 'outline'}>
{n.status === 'sent' ? 'Sent' : n.status === 'failed' ? 'Failed' : 'Queued'}
{n.status === 'sent'
? 'Sent'
: n.status === 'failed'
? 'Failed'
: n.status === 'skipped'
? 'Muted'
: 'Queued'}
</Badge>
</li>
))}