From e7338d1a70a7be733871ae375e68865bd3e8bd66 Mon Sep 17 00:00:00 2001
From: Matt
Date: Sun, 25 Jan 2026 02:19:49 +0100
Subject: [PATCH] Initial production deployment setup
- Production docker-compose with nginx support
- Nginx configuration for portal.monacousa.org
- Deployment script with backup/restore
- Gitea CI/CD workflow
- Fix CountryFlag reactivity for dropdown flags
Co-Authored-By: Claude Opus 4.5
---
.dockerignore | 47 +
.env.example | 89 +
.gitea/workflows/build.yml | 130 +
.gitignore | 23 +
.npmrc | 1 +
ARCHITECTURE.md | 3710 ++++++++++++++
DEPLOYMENT.md | 230 +
Dockerfile | 77 +
README.md | 38 +
build.log | 0
components.json | 17 +
deploy.sh | 339 ++
dev-output.txt | 0
dev.log | 0
docker-compose.nginx.yml | 386 ++
docker-compose.prod.yml | 440 ++
docker-compose.yml | 318 ++
nginx/portal.monacousa.org.conf | 244 +
nginx/portal.monacousa.org.initial.conf | 161 +
npm.log | 0
package-lock.json | 4514 +++++++++++++++++
package.json | 41 +
postcss.config.js | 7 +
src/app.css | 263 +
src/app.d.ts | 27 +
src/app.html | 11 +
src/hooks.server.ts | 134 +
src/lib/assets/favicon.svg | 1 +
.../components/EmailVerificationBanner.svelte | 92 +
src/lib/components/auth/FormField.svelte | 51 +
src/lib/components/auth/FormMessage.svelte | 29 +
src/lib/components/auth/LoadingSpinner.svelte | 28 +
src/lib/components/auth/index.ts | 3 +
.../dashboard/DuesStatusCard.svelte | 113 +
.../dashboard/QuickActionsCard.svelte | 97 +
src/lib/components/dashboard/StatsCard.svelte | 48 +
.../dashboard/UpcomingEventsCard.svelte | 107 +
.../components/dashboard/WelcomeCard.svelte | 78 +
src/lib/components/dashboard/index.ts | 5 +
.../documents/CreateFolderModal.svelte | 142 +
.../documents/DocumentPreviewModal.svelte | 246 +
.../documents/FolderBreadcrumbs.svelte | 49 +
.../components/documents/FolderItem.svelte | 120 +
src/lib/components/documents/index.ts | 4 +
src/lib/components/layout/Header.svelte | 152 +
src/lib/components/layout/MobileMenu.svelte | 203 +
src/lib/components/layout/MobileNav.svelte | 53 +
src/lib/components/layout/Sidebar.svelte | 245 +
src/lib/components/layout/index.ts | 4 +
.../components/ui/AddToCalendarButton.svelte | 171 +
src/lib/components/ui/CountryFlag.svelte | 40 +
src/lib/components/ui/CountrySelect.svelte | 173 +
src/lib/components/ui/DatePicker.svelte | 190 +
.../components/ui/NationalitySelect.svelte | 198 +
src/lib/components/ui/PhoneInput.svelte | 260 +
src/lib/components/ui/button/button.svelte | 65 +
src/lib/components/ui/button/index.ts | 15 +
.../components/ui/card/card-content.svelte | 13 +
.../ui/card/card-description.svelte | 13 +
src/lib/components/ui/card/card-footer.svelte | 13 +
src/lib/components/ui/card/card-header.svelte | 13 +
src/lib/components/ui/card/card-title.svelte | 13 +
src/lib/components/ui/card/card.svelte | 16 +
src/lib/components/ui/card/index.ts | 21 +
src/lib/components/ui/index.ts | 19 +
src/lib/components/ui/input/index.ts | 3 +
src/lib/components/ui/input/input.svelte | 24 +
src/lib/components/ui/label/index.ts | 3 +
src/lib/components/ui/label/label.svelte | 19 +
src/lib/index.ts | 3 +
src/lib/server/audit.ts | 233 +
src/lib/server/dues.ts | 881 ++++
src/lib/server/email.ts | 394 ++
src/lib/server/event-reminders.ts | 339 ++
src/lib/server/ical.ts | 299 ++
src/lib/server/poste.ts | 303 ++
src/lib/server/storage.ts | 901 ++++
src/lib/server/supabase.ts | 42 +
src/lib/supabase.ts | 11 +
src/lib/types/database.ts | 806 +++
src/lib/utils/countries.ts | 262 +
src/lib/utils/index.ts | 59 +
src/lib/utils/phoneCountries.ts | 255 +
src/routes/(app)/+error.svelte | 179 +
src/routes/(app)/+layout.server.ts | 27 +
src/routes/(app)/+layout.svelte | 87 +
src/routes/(app)/admin/+layout.server.ts | 13 +
.../(app)/admin/dashboard/+page.server.ts | 91 +
src/routes/(app)/admin/dashboard/+page.svelte | 295 ++
.../admin/email-templates/+page.server.ts | 83 +
.../(app)/admin/email-templates/+page.svelte | 566 +++
.../(app)/admin/email-testing/+page.server.ts | 425 ++
.../(app)/admin/email-testing/+page.svelte | 561 ++
.../(app)/admin/members/+page.server.ts | 430 ++
src/routes/(app)/admin/members/+page.svelte | 596 +++
.../(app)/admin/settings/+page.server.ts | 709 +++
src/routes/(app)/admin/settings/+page.svelte | 1703 +++++++
src/routes/(app)/board/+layout.server.ts | 13 +
.../(app)/board/documents/+page.server.ts | 480 ++
src/routes/(app)/board/documents/+page.svelte | 719 +++
src/routes/(app)/board/dues/+page.server.ts | 345 ++
src/routes/(app)/board/dues/+page.svelte | 679 +++
.../(app)/board/dues/reports/+page.server.ts | 169 +
.../(app)/board/dues/reports/+page.svelte | 346 ++
src/routes/(app)/board/events/+page.server.ts | 161 +
src/routes/(app)/board/events/+page.svelte | 580 +++
.../events/[id]/attendees/+page.server.ts | 449 ++
.../board/events/[id]/attendees/+page.svelte | 919 ++++
.../board/events/[id]/edit/+page.server.ts | 124 +
.../(app)/board/events/[id]/edit/+page.svelte | 416 ++
.../events/[id]/roll-call/+page.server.ts | 281 +
.../board/events/[id]/roll-call/+page.svelte | 513 ++
.../(app)/board/members/+page.server.ts | 62 +
src/routes/(app)/board/members/+page.svelte | 374 ++
.../(app)/board/reports/+page.server.ts | 141 +
src/routes/(app)/board/reports/+page.svelte | 511 ++
src/routes/(app)/dashboard/+page.server.ts | 84 +
src/routes/(app)/dashboard/+page.svelte | 110 +
src/routes/(app)/documents/+page.server.ts | 49 +
src/routes/(app)/documents/+page.svelte | 270 +
src/routes/(app)/events/+page.server.ts | 31 +
src/routes/(app)/events/+page.svelte | 340 ++
src/routes/(app)/events/[id]/+page.server.ts | 314 ++
src/routes/(app)/events/[id]/+page.svelte | 483 ++
src/routes/(app)/payments/+page.server.ts | 42 +
src/routes/(app)/payments/+page.svelte | 286 ++
src/routes/(app)/profile/+page.server.ts | 167 +
src/routes/(app)/profile/+page.svelte | 374 ++
src/routes/(app)/settings/+page.server.ts | 422 ++
src/routes/(app)/settings/+page.svelte | 1186 +++++
src/routes/(auth)/+layout.svelte | 57 +
.../(auth)/forgot-password/+page.server.ts | 34 +
.../(auth)/forgot-password/+page.svelte | 83 +
src/routes/(auth)/login/+page.server.ts | 90 +
src/routes/(auth)/login/+page.svelte | 110 +
src/routes/(auth)/signup/+page.server.ts | 235 +
src/routes/(auth)/signup/+page.svelte | 256 +
src/routes/+error.svelte | 143 +
src/routes/+layout.svelte | 22 +
src/routes/+page.server.ts | 13 +
src/routes/+page.svelte | 222 +
.../api/auth/check-verification/+server.ts | 16 +
.../api/auth/resend-verification/+server.ts | 30 +
.../api/calendar/events/[id]/+server.ts | 97 +
src/routes/api/calendar/feed/+server.ts | 117 +
.../calendar/public/events/[id]/+server.ts | 77 +
src/routes/api/cron/dues-reminders/+server.ts | 256 +
.../api/cron/event-reminders/+server.ts | 158 +
src/routes/auth/callback/+server.ts | 32 +
.../auth/reset-password/+page.server.ts | 111 +
src/routes/auth/reset-password/+page.svelte | 125 +
src/routes/auth/verify/+server.ts | 66 +
src/routes/join/+layout.svelte | 57 +
src/routes/join/+page.server.ts | 350 ++
src/routes/join/+page.svelte | 773 +++
src/routes/logout/+server.ts | 12 +
src/routes/public/events/[id]/+page.server.ts | 105 +
src/routes/public/events/[id]/+page.svelte | 369 ++
static/MONACOUSA-Flags_376x376.png | Bin 0 -> 144854 bytes
static/apple-touch-icon.png | Bin 0 -> 36419 bytes
static/favicon-32x32.png | Bin 0 -> 2067 bytes
static/flags/ad.svg | 150 +
static/flags/ae.svg | 6 +
static/flags/af.svg | 81 +
static/flags/ag.svg | 14 +
static/flags/ai.svg | 29 +
static/flags/al.svg | 5 +
static/flags/am.svg | 5 +
static/flags/ao.svg | 13 +
static/flags/aq.svg | 5 +
static/flags/ar.svg | 32 +
static/flags/arab.svg | 109 +
static/flags/as.svg | 72 +
static/flags/asean.svg | 13 +
static/flags/at.svg | 4 +
static/flags/au.svg | 8 +
static/flags/aw.svg | 186 +
static/flags/ax.svg | 18 +
static/flags/az.svg | 8 +
static/flags/ba.svg | 12 +
static/flags/bb.svg | 6 +
static/flags/bd.svg | 4 +
static/flags/be.svg | 7 +
static/flags/bf.svg | 7 +
static/flags/bg.svg | 5 +
static/flags/bh.svg | 4 +
static/flags/bi.svg | 15 +
static/flags/bj.svg | 14 +
static/flags/bl.svg | 5 +
static/flags/bm.svg | 97 +
static/flags/bn.svg | 36 +
static/flags/bo.svg | 673 +++
static/flags/bq.svg | 5 +
static/flags/br.svg | 45 +
static/flags/bs.svg | 13 +
static/flags/bt.svg | 89 +
static/flags/bv.svg | 13 +
static/flags/bw.svg | 7 +
static/flags/by.svg | 18 +
static/flags/bz.svg | 145 +
static/flags/ca.svg | 4 +
static/flags/cc.svg | 19 +
static/flags/cd.svg | 5 +
static/flags/cefta.svg | 13 +
static/flags/cf.svg | 15 +
static/flags/cg.svg | 12 +
static/flags/ch.svg | 9 +
static/flags/ci.svg | 7 +
static/flags/ck.svg | 9 +
static/flags/cl.svg | 13 +
static/flags/cm.svg | 15 +
static/flags/cn.svg | 11 +
static/flags/co.svg | 7 +
static/flags/cp.svg | 7 +
static/flags/cr.svg | 7 +
static/flags/cu.svg | 13 +
static/flags/cv.svg | 13 +
static/flags/cw.svg | 14 +
static/flags/cx.svg | 15 +
static/flags/cy.svg | 6 +
static/flags/cz.svg | 5 +
static/flags/de.svg | 5 +
static/flags/dg.svg | 130 +
static/flags/dj.svg | 13 +
static/flags/dk.svg | 5 +
static/flags/dm.svg | 152 +
static/flags/do.svg | 121 +
static/flags/dz.svg | 5 +
static/flags/eac.svg | 48 +
static/flags/ec.svg | 138 +
static/flags/ee.svg | 5 +
static/flags/eg.svg | 38 +
static/flags/eh.svg | 16 +
static/flags/er.svg | 8 +
static/flags/es-ct.svg | 4 +
static/flags/es-ga.svg | 187 +
static/flags/es-pv.svg | 5 +
static/flags/es.svg | 544 ++
static/flags/et.svg | 14 +
static/flags/eu.svg | 28 +
static/flags/fi.svg | 5 +
static/flags/fj.svg | 120 +
static/flags/fk.svg | 90 +
static/flags/fm.svg | 11 +
static/flags/fo.svg | 12 +
static/flags/fr.svg | 5 +
static/flags/ga.svg | 7 +
static/flags/gb-eng.svg | 5 +
static/flags/gb-nir.svg | 132 +
static/flags/gb-sct.svg | 4 +
static/flags/gb-wls.svg | 9 +
static/flags/gb.svg | 7 +
static/flags/gd.svg | 27 +
static/flags/ge.svg | 6 +
static/flags/gf.svg | 5 +
static/flags/gg.svg | 9 +
static/flags/gh.svg | 6 +
static/flags/gi.svg | 32 +
static/flags/gl.svg | 4 +
static/flags/gm.svg | 14 +
static/flags/gn.svg | 7 +
static/flags/gp.svg | 5 +
static/flags/gq.svg | 23 +
static/flags/gr.svg | 16 +
static/flags/gs.svg | 133 +
static/flags/gt.svg | 204 +
static/flags/gu.svg | 19 +
static/flags/gw.svg | 13 +
static/flags/gy.svg | 9 +
static/flags/hk.svg | 8 +
static/flags/hm.svg | 8 +
static/flags/hn.svg | 18 +
static/flags/hr.svg | 58 +
static/flags/ht.svg | 116 +
static/flags/hu.svg | 7 +
static/flags/ic.svg | 7 +
static/flags/id.svg | 4 +
static/flags/ie.svg | 7 +
static/flags/il.svg | 14 +
static/flags/im.svg | 36 +
static/flags/in.svg | 25 +
static/flags/io.svg | 130 +
static/flags/iq.svg | 10 +
static/flags/ir.svg | 219 +
static/flags/is.svg | 12 +
static/flags/it.svg | 7 +
static/flags/je.svg | 62 +
static/flags/jm.svg | 8 +
static/flags/jo.svg | 16 +
static/flags/jp.svg | 11 +
static/flags/ke.svg | 23 +
static/flags/kg.svg | 4 +
static/flags/kh.svg | 61 +
static/flags/ki.svg | 36 +
static/flags/km.svg | 16 +
static/flags/kn.svg | 14 +
static/flags/kp.svg | 15 +
static/flags/kr.svg | 24 +
static/flags/kw.svg | 13 +
static/flags/ky.svg | 103 +
static/flags/kz.svg | 36 +
static/flags/la.svg | 12 +
static/flags/lb.svg | 15 +
static/flags/lc.svg | 8 +
static/flags/li.svg | 43 +
static/flags/lk.svg | 22 +
static/flags/lr.svg | 14 +
static/flags/ls.svg | 8 +
static/flags/lt.svg | 7 +
static/flags/lu.svg | 5 +
static/flags/lv.svg | 6 +
static/flags/ly.svg | 13 +
static/flags/ma.svg | 4 +
static/flags/mc.svg | 6 +
static/flags/md.svg | 70 +
static/flags/me.svg | 116 +
static/flags/mf.svg | 5 +
static/flags/mg.svg | 7 +
static/flags/mh.svg | 7 +
static/flags/mk.svg | 5 +
static/flags/ml.svg | 7 +
static/flags/mm.svg | 12 +
static/flags/mn.svg | 14 +
static/flags/mo.svg | 9 +
static/flags/mp.svg | 86 +
static/flags/mq.svg | 5 +
static/flags/mr.svg | 6 +
static/flags/ms.svg | 29 +
static/flags/mt.svg | 58 +
static/flags/mu.svg | 8 +
static/flags/mv.svg | 6 +
static/flags/mw.svg | 10 +
static/flags/mx.svg | 382 ++
static/flags/my.svg | 26 +
static/flags/mz.svg | 21 +
static/flags/na.svg | 16 +
static/flags/nc.svg | 13 +
static/flags/ne.svg | 6 +
static/flags/nf.svg | 9 +
static/flags/ng.svg | 6 +
static/flags/ni.svg | 129 +
static/flags/nl.svg | 5 +
static/flags/no.svg | 7 +
static/flags/np.svg | 13 +
static/flags/nr.svg | 12 +
static/flags/nu.svg | 10 +
static/flags/nz.svg | 36 +
static/flags/om.svg | 115 +
static/flags/pa.svg | 14 +
static/flags/pc.svg | 33 +
static/flags/pe.svg | 4 +
static/flags/pf.svg | 19 +
static/flags/pg.svg | 9 +
static/flags/ph.svg | 6 +
static/flags/pk.svg | 15 +
static/flags/pl.svg | 6 +
static/flags/pm.svg | 5 +
static/flags/pn.svg | 53 +
static/flags/pr.svg | 13 +
static/flags/ps.svg | 6 +
static/flags/pt.svg | 57 +
static/flags/pw.svg | 11 +
static/flags/py.svg | 157 +
static/flags/qa.svg | 4 +
static/flags/re.svg | 5 +
static/flags/ro.svg | 7 +
static/flags/rs.svg | 292 ++
static/flags/ru.svg | 5 +
static/flags/rw.svg | 13 +
static/flags/sa.svg | 25 +
static/flags/sb.svg | 13 +
static/flags/sc.svg | 7 +
static/flags/sd.svg | 13 +
static/flags/se.svg | 4 +
static/flags/sg.svg | 13 +
static/flags/sh-ac.svg | 689 +++
static/flags/sh-hl.svg | 164 +
static/flags/sh-ta.svg | 76 +
static/flags/sh.svg | 7 +
static/flags/si.svg | 18 +
static/flags/sj.svg | 7 +
static/flags/sk.svg | 9 +
static/flags/sl.svg | 7 +
static/flags/sm.svg | 75 +
static/flags/sn.svg | 8 +
static/flags/so.svg | 11 +
static/flags/sr.svg | 6 +
static/flags/ss.svg | 8 +
static/flags/st.svg | 16 +
static/flags/sv.svg | 593 +++
static/flags/sx.svg | 56 +
static/flags/sy.svg | 6 +
static/flags/sz.svg | 34 +
static/flags/tc.svg | 50 +
static/flags/td.svg | 7 +
static/flags/tf.svg | 15 +
static/flags/tg.svg | 14 +
static/flags/th.svg | 7 +
static/flags/tj.svg | 22 +
static/flags/tk.svg | 5 +
static/flags/tl.svg | 13 +
static/flags/tm.svg | 204 +
static/flags/tn.svg | 4 +
static/flags/to.svg | 10 +
static/flags/tr.svg | 8 +
static/flags/tt.svg | 5 +
static/flags/tv.svg | 9 +
static/flags/tw.svg | 34 +
static/flags/tz.svg | 13 +
static/flags/ua.svg | 6 +
static/flags/ug.svg | 30 +
static/flags/um.svg | 9 +
static/flags/un.svg | 16 +
static/flags/us.svg | 9 +
static/flags/uy.svg | 28 +
static/flags/uz.svg | 30 +
static/flags/va.svg | 190 +
static/flags/vc.svg | 8 +
static/flags/ve.svg | 26 +
static/flags/vg.svg | 59 +
static/flags/vi.svg | 28 +
static/flags/vn.svg | 11 +
static/flags/vu.svg | 21 +
static/flags/wf.svg | 5 +
static/flags/ws.svg | 7 +
static/flags/xk.svg | 5 +
static/flags/xx.svg | 4 +
static/flags/ye.svg | 7 +
static/flags/yt.svg | 5 +
static/flags/za.svg | 17 +
static/flags/zm.svg | 27 +
static/flags/zw.svg | 21 +
static/icon-192x192.png | Bin 0 -> 40917 bytes
static/icon-512x512.png | Bin 0 -> 260525 bytes
static/monaco_high_res.jpg | Bin 0 -> 651114 bytes
static/robots.txt | 3 +
supabase/docker/kong.yml | 196 +
supabase/fix_rls_now.sql | 146 +
supabase/migrations/001_initial_schema.sql | 786 +++
.../002_admin_integrations_settings.sql | 35 +
.../003_storage_buckets_and_audit.sql | 504 ++
.../004_user_notification_preferences.sql | 102 +
.../005_fix_avatars_storage_policy.sql | 37 +
supabase/migrations/006_document_folders.sql | 100 +
supabase/migrations/007_dues_reminders.sql | 307 ++
.../migrations/008_s3_public_endpoint.sql | 11 +
supabase/migrations/009_dual_avatar_urls.sql | 22 +
.../010_storage_service_role_policies.sql | 79 +
.../migrations/011_fix_service_role_rls.sql | 98 +
.../migrations/012_dual_document_urls.sql | 49 +
.../013_email_background_images.sql | 671 +++
supabase/migrations/014_event_reminders.sql | 133 +
.../015_fix_email_template_styling.sql | 192 +
.../016_onboarding_payment_tracking.sql | 237 +
svelte.config.js | 18 +
tsconfig.json | 20 +
vite.config.ts | 11 +
457 files changed, 54912 insertions(+)
create mode 100644 .dockerignore
create mode 100644 .env.example
create mode 100644 .gitea/workflows/build.yml
create mode 100644 .gitignore
create mode 100644 .npmrc
create mode 100644 ARCHITECTURE.md
create mode 100644 DEPLOYMENT.md
create mode 100644 Dockerfile
create mode 100644 README.md
create mode 100644 build.log
create mode 100644 components.json
create mode 100644 deploy.sh
create mode 100644 dev-output.txt
create mode 100644 dev.log
create mode 100644 docker-compose.nginx.yml
create mode 100644 docker-compose.prod.yml
create mode 100644 docker-compose.yml
create mode 100644 nginx/portal.monacousa.org.conf
create mode 100644 nginx/portal.monacousa.org.initial.conf
create mode 100644 npm.log
create mode 100644 package-lock.json
create mode 100644 package.json
create mode 100644 postcss.config.js
create mode 100644 src/app.css
create mode 100644 src/app.d.ts
create mode 100644 src/app.html
create mode 100644 src/hooks.server.ts
create mode 100644 src/lib/assets/favicon.svg
create mode 100644 src/lib/components/EmailVerificationBanner.svelte
create mode 100644 src/lib/components/auth/FormField.svelte
create mode 100644 src/lib/components/auth/FormMessage.svelte
create mode 100644 src/lib/components/auth/LoadingSpinner.svelte
create mode 100644 src/lib/components/auth/index.ts
create mode 100644 src/lib/components/dashboard/DuesStatusCard.svelte
create mode 100644 src/lib/components/dashboard/QuickActionsCard.svelte
create mode 100644 src/lib/components/dashboard/StatsCard.svelte
create mode 100644 src/lib/components/dashboard/UpcomingEventsCard.svelte
create mode 100644 src/lib/components/dashboard/WelcomeCard.svelte
create mode 100644 src/lib/components/dashboard/index.ts
create mode 100644 src/lib/components/documents/CreateFolderModal.svelte
create mode 100644 src/lib/components/documents/DocumentPreviewModal.svelte
create mode 100644 src/lib/components/documents/FolderBreadcrumbs.svelte
create mode 100644 src/lib/components/documents/FolderItem.svelte
create mode 100644 src/lib/components/documents/index.ts
create mode 100644 src/lib/components/layout/Header.svelte
create mode 100644 src/lib/components/layout/MobileMenu.svelte
create mode 100644 src/lib/components/layout/MobileNav.svelte
create mode 100644 src/lib/components/layout/Sidebar.svelte
create mode 100644 src/lib/components/layout/index.ts
create mode 100644 src/lib/components/ui/AddToCalendarButton.svelte
create mode 100644 src/lib/components/ui/CountryFlag.svelte
create mode 100644 src/lib/components/ui/CountrySelect.svelte
create mode 100644 src/lib/components/ui/DatePicker.svelte
create mode 100644 src/lib/components/ui/NationalitySelect.svelte
create mode 100644 src/lib/components/ui/PhoneInput.svelte
create mode 100644 src/lib/components/ui/button/button.svelte
create mode 100644 src/lib/components/ui/button/index.ts
create mode 100644 src/lib/components/ui/card/card-content.svelte
create mode 100644 src/lib/components/ui/card/card-description.svelte
create mode 100644 src/lib/components/ui/card/card-footer.svelte
create mode 100644 src/lib/components/ui/card/card-header.svelte
create mode 100644 src/lib/components/ui/card/card-title.svelte
create mode 100644 src/lib/components/ui/card/card.svelte
create mode 100644 src/lib/components/ui/card/index.ts
create mode 100644 src/lib/components/ui/index.ts
create mode 100644 src/lib/components/ui/input/index.ts
create mode 100644 src/lib/components/ui/input/input.svelte
create mode 100644 src/lib/components/ui/label/index.ts
create mode 100644 src/lib/components/ui/label/label.svelte
create mode 100644 src/lib/index.ts
create mode 100644 src/lib/server/audit.ts
create mode 100644 src/lib/server/dues.ts
create mode 100644 src/lib/server/email.ts
create mode 100644 src/lib/server/event-reminders.ts
create mode 100644 src/lib/server/ical.ts
create mode 100644 src/lib/server/poste.ts
create mode 100644 src/lib/server/storage.ts
create mode 100644 src/lib/server/supabase.ts
create mode 100644 src/lib/supabase.ts
create mode 100644 src/lib/types/database.ts
create mode 100644 src/lib/utils/countries.ts
create mode 100644 src/lib/utils/index.ts
create mode 100644 src/lib/utils/phoneCountries.ts
create mode 100644 src/routes/(app)/+error.svelte
create mode 100644 src/routes/(app)/+layout.server.ts
create mode 100644 src/routes/(app)/+layout.svelte
create mode 100644 src/routes/(app)/admin/+layout.server.ts
create mode 100644 src/routes/(app)/admin/dashboard/+page.server.ts
create mode 100644 src/routes/(app)/admin/dashboard/+page.svelte
create mode 100644 src/routes/(app)/admin/email-templates/+page.server.ts
create mode 100644 src/routes/(app)/admin/email-templates/+page.svelte
create mode 100644 src/routes/(app)/admin/email-testing/+page.server.ts
create mode 100644 src/routes/(app)/admin/email-testing/+page.svelte
create mode 100644 src/routes/(app)/admin/members/+page.server.ts
create mode 100644 src/routes/(app)/admin/members/+page.svelte
create mode 100644 src/routes/(app)/admin/settings/+page.server.ts
create mode 100644 src/routes/(app)/admin/settings/+page.svelte
create mode 100644 src/routes/(app)/board/+layout.server.ts
create mode 100644 src/routes/(app)/board/documents/+page.server.ts
create mode 100644 src/routes/(app)/board/documents/+page.svelte
create mode 100644 src/routes/(app)/board/dues/+page.server.ts
create mode 100644 src/routes/(app)/board/dues/+page.svelte
create mode 100644 src/routes/(app)/board/dues/reports/+page.server.ts
create mode 100644 src/routes/(app)/board/dues/reports/+page.svelte
create mode 100644 src/routes/(app)/board/events/+page.server.ts
create mode 100644 src/routes/(app)/board/events/+page.svelte
create mode 100644 src/routes/(app)/board/events/[id]/attendees/+page.server.ts
create mode 100644 src/routes/(app)/board/events/[id]/attendees/+page.svelte
create mode 100644 src/routes/(app)/board/events/[id]/edit/+page.server.ts
create mode 100644 src/routes/(app)/board/events/[id]/edit/+page.svelte
create mode 100644 src/routes/(app)/board/events/[id]/roll-call/+page.server.ts
create mode 100644 src/routes/(app)/board/events/[id]/roll-call/+page.svelte
create mode 100644 src/routes/(app)/board/members/+page.server.ts
create mode 100644 src/routes/(app)/board/members/+page.svelte
create mode 100644 src/routes/(app)/board/reports/+page.server.ts
create mode 100644 src/routes/(app)/board/reports/+page.svelte
create mode 100644 src/routes/(app)/dashboard/+page.server.ts
create mode 100644 src/routes/(app)/dashboard/+page.svelte
create mode 100644 src/routes/(app)/documents/+page.server.ts
create mode 100644 src/routes/(app)/documents/+page.svelte
create mode 100644 src/routes/(app)/events/+page.server.ts
create mode 100644 src/routes/(app)/events/+page.svelte
create mode 100644 src/routes/(app)/events/[id]/+page.server.ts
create mode 100644 src/routes/(app)/events/[id]/+page.svelte
create mode 100644 src/routes/(app)/payments/+page.server.ts
create mode 100644 src/routes/(app)/payments/+page.svelte
create mode 100644 src/routes/(app)/profile/+page.server.ts
create mode 100644 src/routes/(app)/profile/+page.svelte
create mode 100644 src/routes/(app)/settings/+page.server.ts
create mode 100644 src/routes/(app)/settings/+page.svelte
create mode 100644 src/routes/(auth)/+layout.svelte
create mode 100644 src/routes/(auth)/forgot-password/+page.server.ts
create mode 100644 src/routes/(auth)/forgot-password/+page.svelte
create mode 100644 src/routes/(auth)/login/+page.server.ts
create mode 100644 src/routes/(auth)/login/+page.svelte
create mode 100644 src/routes/(auth)/signup/+page.server.ts
create mode 100644 src/routes/(auth)/signup/+page.svelte
create mode 100644 src/routes/+error.svelte
create mode 100644 src/routes/+layout.svelte
create mode 100644 src/routes/+page.server.ts
create mode 100644 src/routes/+page.svelte
create mode 100644 src/routes/api/auth/check-verification/+server.ts
create mode 100644 src/routes/api/auth/resend-verification/+server.ts
create mode 100644 src/routes/api/calendar/events/[id]/+server.ts
create mode 100644 src/routes/api/calendar/feed/+server.ts
create mode 100644 src/routes/api/calendar/public/events/[id]/+server.ts
create mode 100644 src/routes/api/cron/dues-reminders/+server.ts
create mode 100644 src/routes/api/cron/event-reminders/+server.ts
create mode 100644 src/routes/auth/callback/+server.ts
create mode 100644 src/routes/auth/reset-password/+page.server.ts
create mode 100644 src/routes/auth/reset-password/+page.svelte
create mode 100644 src/routes/auth/verify/+server.ts
create mode 100644 src/routes/join/+layout.svelte
create mode 100644 src/routes/join/+page.server.ts
create mode 100644 src/routes/join/+page.svelte
create mode 100644 src/routes/logout/+server.ts
create mode 100644 src/routes/public/events/[id]/+page.server.ts
create mode 100644 src/routes/public/events/[id]/+page.svelte
create mode 100644 static/MONACOUSA-Flags_376x376.png
create mode 100644 static/apple-touch-icon.png
create mode 100644 static/favicon-32x32.png
create mode 100644 static/flags/ad.svg
create mode 100644 static/flags/ae.svg
create mode 100644 static/flags/af.svg
create mode 100644 static/flags/ag.svg
create mode 100644 static/flags/ai.svg
create mode 100644 static/flags/al.svg
create mode 100644 static/flags/am.svg
create mode 100644 static/flags/ao.svg
create mode 100644 static/flags/aq.svg
create mode 100644 static/flags/ar.svg
create mode 100644 static/flags/arab.svg
create mode 100644 static/flags/as.svg
create mode 100644 static/flags/asean.svg
create mode 100644 static/flags/at.svg
create mode 100644 static/flags/au.svg
create mode 100644 static/flags/aw.svg
create mode 100644 static/flags/ax.svg
create mode 100644 static/flags/az.svg
create mode 100644 static/flags/ba.svg
create mode 100644 static/flags/bb.svg
create mode 100644 static/flags/bd.svg
create mode 100644 static/flags/be.svg
create mode 100644 static/flags/bf.svg
create mode 100644 static/flags/bg.svg
create mode 100644 static/flags/bh.svg
create mode 100644 static/flags/bi.svg
create mode 100644 static/flags/bj.svg
create mode 100644 static/flags/bl.svg
create mode 100644 static/flags/bm.svg
create mode 100644 static/flags/bn.svg
create mode 100644 static/flags/bo.svg
create mode 100644 static/flags/bq.svg
create mode 100644 static/flags/br.svg
create mode 100644 static/flags/bs.svg
create mode 100644 static/flags/bt.svg
create mode 100644 static/flags/bv.svg
create mode 100644 static/flags/bw.svg
create mode 100644 static/flags/by.svg
create mode 100644 static/flags/bz.svg
create mode 100644 static/flags/ca.svg
create mode 100644 static/flags/cc.svg
create mode 100644 static/flags/cd.svg
create mode 100644 static/flags/cefta.svg
create mode 100644 static/flags/cf.svg
create mode 100644 static/flags/cg.svg
create mode 100644 static/flags/ch.svg
create mode 100644 static/flags/ci.svg
create mode 100644 static/flags/ck.svg
create mode 100644 static/flags/cl.svg
create mode 100644 static/flags/cm.svg
create mode 100644 static/flags/cn.svg
create mode 100644 static/flags/co.svg
create mode 100644 static/flags/cp.svg
create mode 100644 static/flags/cr.svg
create mode 100644 static/flags/cu.svg
create mode 100644 static/flags/cv.svg
create mode 100644 static/flags/cw.svg
create mode 100644 static/flags/cx.svg
create mode 100644 static/flags/cy.svg
create mode 100644 static/flags/cz.svg
create mode 100644 static/flags/de.svg
create mode 100644 static/flags/dg.svg
create mode 100644 static/flags/dj.svg
create mode 100644 static/flags/dk.svg
create mode 100644 static/flags/dm.svg
create mode 100644 static/flags/do.svg
create mode 100644 static/flags/dz.svg
create mode 100644 static/flags/eac.svg
create mode 100644 static/flags/ec.svg
create mode 100644 static/flags/ee.svg
create mode 100644 static/flags/eg.svg
create mode 100644 static/flags/eh.svg
create mode 100644 static/flags/er.svg
create mode 100644 static/flags/es-ct.svg
create mode 100644 static/flags/es-ga.svg
create mode 100644 static/flags/es-pv.svg
create mode 100644 static/flags/es.svg
create mode 100644 static/flags/et.svg
create mode 100644 static/flags/eu.svg
create mode 100644 static/flags/fi.svg
create mode 100644 static/flags/fj.svg
create mode 100644 static/flags/fk.svg
create mode 100644 static/flags/fm.svg
create mode 100644 static/flags/fo.svg
create mode 100644 static/flags/fr.svg
create mode 100644 static/flags/ga.svg
create mode 100644 static/flags/gb-eng.svg
create mode 100644 static/flags/gb-nir.svg
create mode 100644 static/flags/gb-sct.svg
create mode 100644 static/flags/gb-wls.svg
create mode 100644 static/flags/gb.svg
create mode 100644 static/flags/gd.svg
create mode 100644 static/flags/ge.svg
create mode 100644 static/flags/gf.svg
create mode 100644 static/flags/gg.svg
create mode 100644 static/flags/gh.svg
create mode 100644 static/flags/gi.svg
create mode 100644 static/flags/gl.svg
create mode 100644 static/flags/gm.svg
create mode 100644 static/flags/gn.svg
create mode 100644 static/flags/gp.svg
create mode 100644 static/flags/gq.svg
create mode 100644 static/flags/gr.svg
create mode 100644 static/flags/gs.svg
create mode 100644 static/flags/gt.svg
create mode 100644 static/flags/gu.svg
create mode 100644 static/flags/gw.svg
create mode 100644 static/flags/gy.svg
create mode 100644 static/flags/hk.svg
create mode 100644 static/flags/hm.svg
create mode 100644 static/flags/hn.svg
create mode 100644 static/flags/hr.svg
create mode 100644 static/flags/ht.svg
create mode 100644 static/flags/hu.svg
create mode 100644 static/flags/ic.svg
create mode 100644 static/flags/id.svg
create mode 100644 static/flags/ie.svg
create mode 100644 static/flags/il.svg
create mode 100644 static/flags/im.svg
create mode 100644 static/flags/in.svg
create mode 100644 static/flags/io.svg
create mode 100644 static/flags/iq.svg
create mode 100644 static/flags/ir.svg
create mode 100644 static/flags/is.svg
create mode 100644 static/flags/it.svg
create mode 100644 static/flags/je.svg
create mode 100644 static/flags/jm.svg
create mode 100644 static/flags/jo.svg
create mode 100644 static/flags/jp.svg
create mode 100644 static/flags/ke.svg
create mode 100644 static/flags/kg.svg
create mode 100644 static/flags/kh.svg
create mode 100644 static/flags/ki.svg
create mode 100644 static/flags/km.svg
create mode 100644 static/flags/kn.svg
create mode 100644 static/flags/kp.svg
create mode 100644 static/flags/kr.svg
create mode 100644 static/flags/kw.svg
create mode 100644 static/flags/ky.svg
create mode 100644 static/flags/kz.svg
create mode 100644 static/flags/la.svg
create mode 100644 static/flags/lb.svg
create mode 100644 static/flags/lc.svg
create mode 100644 static/flags/li.svg
create mode 100644 static/flags/lk.svg
create mode 100644 static/flags/lr.svg
create mode 100644 static/flags/ls.svg
create mode 100644 static/flags/lt.svg
create mode 100644 static/flags/lu.svg
create mode 100644 static/flags/lv.svg
create mode 100644 static/flags/ly.svg
create mode 100644 static/flags/ma.svg
create mode 100644 static/flags/mc.svg
create mode 100644 static/flags/md.svg
create mode 100644 static/flags/me.svg
create mode 100644 static/flags/mf.svg
create mode 100644 static/flags/mg.svg
create mode 100644 static/flags/mh.svg
create mode 100644 static/flags/mk.svg
create mode 100644 static/flags/ml.svg
create mode 100644 static/flags/mm.svg
create mode 100644 static/flags/mn.svg
create mode 100644 static/flags/mo.svg
create mode 100644 static/flags/mp.svg
create mode 100644 static/flags/mq.svg
create mode 100644 static/flags/mr.svg
create mode 100644 static/flags/ms.svg
create mode 100644 static/flags/mt.svg
create mode 100644 static/flags/mu.svg
create mode 100644 static/flags/mv.svg
create mode 100644 static/flags/mw.svg
create mode 100644 static/flags/mx.svg
create mode 100644 static/flags/my.svg
create mode 100644 static/flags/mz.svg
create mode 100644 static/flags/na.svg
create mode 100644 static/flags/nc.svg
create mode 100644 static/flags/ne.svg
create mode 100644 static/flags/nf.svg
create mode 100644 static/flags/ng.svg
create mode 100644 static/flags/ni.svg
create mode 100644 static/flags/nl.svg
create mode 100644 static/flags/no.svg
create mode 100644 static/flags/np.svg
create mode 100644 static/flags/nr.svg
create mode 100644 static/flags/nu.svg
create mode 100644 static/flags/nz.svg
create mode 100644 static/flags/om.svg
create mode 100644 static/flags/pa.svg
create mode 100644 static/flags/pc.svg
create mode 100644 static/flags/pe.svg
create mode 100644 static/flags/pf.svg
create mode 100644 static/flags/pg.svg
create mode 100644 static/flags/ph.svg
create mode 100644 static/flags/pk.svg
create mode 100644 static/flags/pl.svg
create mode 100644 static/flags/pm.svg
create mode 100644 static/flags/pn.svg
create mode 100644 static/flags/pr.svg
create mode 100644 static/flags/ps.svg
create mode 100644 static/flags/pt.svg
create mode 100644 static/flags/pw.svg
create mode 100644 static/flags/py.svg
create mode 100644 static/flags/qa.svg
create mode 100644 static/flags/re.svg
create mode 100644 static/flags/ro.svg
create mode 100644 static/flags/rs.svg
create mode 100644 static/flags/ru.svg
create mode 100644 static/flags/rw.svg
create mode 100644 static/flags/sa.svg
create mode 100644 static/flags/sb.svg
create mode 100644 static/flags/sc.svg
create mode 100644 static/flags/sd.svg
create mode 100644 static/flags/se.svg
create mode 100644 static/flags/sg.svg
create mode 100644 static/flags/sh-ac.svg
create mode 100644 static/flags/sh-hl.svg
create mode 100644 static/flags/sh-ta.svg
create mode 100644 static/flags/sh.svg
create mode 100644 static/flags/si.svg
create mode 100644 static/flags/sj.svg
create mode 100644 static/flags/sk.svg
create mode 100644 static/flags/sl.svg
create mode 100644 static/flags/sm.svg
create mode 100644 static/flags/sn.svg
create mode 100644 static/flags/so.svg
create mode 100644 static/flags/sr.svg
create mode 100644 static/flags/ss.svg
create mode 100644 static/flags/st.svg
create mode 100644 static/flags/sv.svg
create mode 100644 static/flags/sx.svg
create mode 100644 static/flags/sy.svg
create mode 100644 static/flags/sz.svg
create mode 100644 static/flags/tc.svg
create mode 100644 static/flags/td.svg
create mode 100644 static/flags/tf.svg
create mode 100644 static/flags/tg.svg
create mode 100644 static/flags/th.svg
create mode 100644 static/flags/tj.svg
create mode 100644 static/flags/tk.svg
create mode 100644 static/flags/tl.svg
create mode 100644 static/flags/tm.svg
create mode 100644 static/flags/tn.svg
create mode 100644 static/flags/to.svg
create mode 100644 static/flags/tr.svg
create mode 100644 static/flags/tt.svg
create mode 100644 static/flags/tv.svg
create mode 100644 static/flags/tw.svg
create mode 100644 static/flags/tz.svg
create mode 100644 static/flags/ua.svg
create mode 100644 static/flags/ug.svg
create mode 100644 static/flags/um.svg
create mode 100644 static/flags/un.svg
create mode 100644 static/flags/us.svg
create mode 100644 static/flags/uy.svg
create mode 100644 static/flags/uz.svg
create mode 100644 static/flags/va.svg
create mode 100644 static/flags/vc.svg
create mode 100644 static/flags/ve.svg
create mode 100644 static/flags/vg.svg
create mode 100644 static/flags/vi.svg
create mode 100644 static/flags/vn.svg
create mode 100644 static/flags/vu.svg
create mode 100644 static/flags/wf.svg
create mode 100644 static/flags/ws.svg
create mode 100644 static/flags/xk.svg
create mode 100644 static/flags/xx.svg
create mode 100644 static/flags/ye.svg
create mode 100644 static/flags/yt.svg
create mode 100644 static/flags/za.svg
create mode 100644 static/flags/zm.svg
create mode 100644 static/flags/zw.svg
create mode 100644 static/icon-192x192.png
create mode 100644 static/icon-512x512.png
create mode 100644 static/monaco_high_res.jpg
create mode 100644 static/robots.txt
create mode 100644 supabase/docker/kong.yml
create mode 100644 supabase/fix_rls_now.sql
create mode 100644 supabase/migrations/001_initial_schema.sql
create mode 100644 supabase/migrations/002_admin_integrations_settings.sql
create mode 100644 supabase/migrations/003_storage_buckets_and_audit.sql
create mode 100644 supabase/migrations/004_user_notification_preferences.sql
create mode 100644 supabase/migrations/005_fix_avatars_storage_policy.sql
create mode 100644 supabase/migrations/006_document_folders.sql
create mode 100644 supabase/migrations/007_dues_reminders.sql
create mode 100644 supabase/migrations/008_s3_public_endpoint.sql
create mode 100644 supabase/migrations/009_dual_avatar_urls.sql
create mode 100644 supabase/migrations/010_storage_service_role_policies.sql
create mode 100644 supabase/migrations/011_fix_service_role_rls.sql
create mode 100644 supabase/migrations/012_dual_document_urls.sql
create mode 100644 supabase/migrations/013_email_background_images.sql
create mode 100644 supabase/migrations/014_event_reminders.sql
create mode 100644 supabase/migrations/015_fix_email_template_styling.sql
create mode 100644 supabase/migrations/016_onboarding_payment_tracking.sql
create mode 100644 svelte.config.js
create mode 100644 tsconfig.json
create mode 100644 vite.config.ts
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..10254b3
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,47 @@
+# Dependencies
+node_modules
+.pnpm-store
+
+# Build output
+build
+.svelte-kit
+
+# Environment files (we pass these at runtime)
+.env
+.env.*
+!.env.example
+
+# Git
+.git
+.gitignore
+
+# IDE
+.vscode
+.idea
+*.swp
+*.swo
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Logs
+*.log
+npm-debug.log*
+
+# Test
+coverage
+.nyc_output
+
+# Docker
+Dockerfile*
+docker-compose*.yml
+.dockerignore
+
+# Supabase local
+supabase/.temp
+supabase/.branches
+
+# Misc
+*.md
+LICENSE
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..2de8f49
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,89 @@
+# Monaco USA Portal - Docker Environment Configuration
+# ===================================================
+# Copy this file to .env and configure your values
+
+# ===========================================
+# POSTGRES DATABASE
+# ===========================================
+POSTGRES_USER=postgres
+POSTGRES_PASSWORD=change-this-to-a-secure-password
+POSTGRES_DB=postgres
+POSTGRES_PORT=5435
+
+# ===========================================
+# JWT CONFIGURATION
+# ===========================================
+# IMPORTANT: Generate a new secret for production!
+# Use: openssl rand -base64 32
+JWT_SECRET=generate-a-new-secret-at-least-32-characters
+JWT_EXPIRY=3600
+
+# ===========================================
+# API KEYS
+# ===========================================
+# Generate these at: https://supabase.com/docs/guides/self-hosting#api-keys
+# They must be signed with your JWT_SECRET
+
+# Anonymous key - for public access (limited permissions)
+ANON_KEY=your-generated-anon-key
+
+# Service role key - for admin access (full permissions, keep secret!)
+SERVICE_ROLE_KEY=your-generated-service-role-key
+
+# ===========================================
+# URLS & PORTS
+# ===========================================
+KONG_HTTP_PORT=7455
+KONG_HTTPS_PORT=7456
+STUDIO_PORT=7454
+PORTAL_PORT=7453
+
+SITE_URL=http://localhost:7453
+API_EXTERNAL_URL=http://localhost:7455
+SUPABASE_PUBLIC_URL=http://localhost:7455
+
+PUBLIC_SUPABASE_URL=http://localhost:7455
+PUBLIC_SUPABASE_ANON_KEY=same-as-anon-key-above
+
+# Service role key for admin operations (server-side only)
+SUPABASE_SERVICE_ROLE_KEY=same-as-service-role-key-above
+
+# ===========================================
+# AUTH CONFIGURATION
+# ===========================================
+DISABLE_SIGNUP=false
+ENABLE_EMAIL_AUTOCONFIRM=true
+ADDITIONAL_REDIRECT_URLS=http://localhost:7453/auth/callback
+
+# ===========================================
+# SMTP EMAIL (Optional)
+# ===========================================
+SMTP_HOST=
+SMTP_PORT=587
+SMTP_USER=
+SMTP_PASS=
+SMTP_ADMIN_EMAIL=noreply@example.org
+SMTP_SENDER_NAME=Monaco USA
+
+MAILER_URLPATHS_INVITE=/auth/verify
+MAILER_URLPATHS_CONFIRMATION=/auth/verify
+MAILER_URLPATHS_RECOVERY=/auth/verify
+MAILER_URLPATHS_EMAIL_CHANGE=/auth/verify
+RATE_LIMIT_EMAIL_SENT=100
+
+# ===========================================
+# REALTIME
+# ===========================================
+SECRET_KEY_BASE=generate-a-new-secret-key-base
+
+# ===========================================
+# POSTGREST
+# ===========================================
+PGRST_DB_SCHEMAS=public,storage,graphql_public
+
+# ===========================================
+# SVELTEKIT CONFIGURATION
+# ===========================================
+# Body size limit for file uploads (avatars, documents)
+# 50MB = 52428800 bytes
+BODY_SIZE_LIMIT=52428800
diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml
new file mode 100644
index 0000000..34e88e6
--- /dev/null
+++ b/.gitea/workflows/build.yml
@@ -0,0 +1,130 @@
+# Gitea Actions - Monaco USA Portal Build & Deploy
+# This workflow builds and optionally deploys the portal
+#
+# Triggers:
+# - Push to main branch
+# - Pull requests to main
+# - Manual trigger (workflow_dispatch)
+#
+# Required Secrets (configure in Gitea repo settings):
+# - DEPLOY_HOST: Production server hostname/IP
+# - DEPLOY_USER: SSH username
+# - DEPLOY_KEY: SSH private key for deployment
+# - DEPLOY_PATH: Path to project on server (e.g., /opt/monacousa-portal)
+
+name: Build and Deploy
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+ workflow_dispatch:
+ inputs:
+ deploy:
+ description: 'Deploy to production'
+ required: false
+ default: 'false'
+
+jobs:
+ # =============================================
+ # Build Job - Builds Docker image
+ # =============================================
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Build Docker image
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ file: ./Dockerfile
+ push: false
+ load: true
+ tags: monacousa-portal:${{ github.sha }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+ build-args: |
+ PUBLIC_SUPABASE_URL=https://api.portal.monacousa.org
+ PUBLIC_SUPABASE_ANON_KEY=placeholder
+ SUPABASE_SERVICE_ROLE_KEY=placeholder
+
+ - name: Test Docker image starts
+ run: |
+ docker run -d --name test-portal \
+ -e PUBLIC_SUPABASE_URL=https://api.portal.monacousa.org \
+ -e PUBLIC_SUPABASE_ANON_KEY=placeholder \
+ monacousa-portal:${{ github.sha }}
+ sleep 5
+ docker logs test-portal
+ docker stop test-portal
+
+ # =============================================
+ # Lint Job - Code quality checks
+ # =============================================
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci --legacy-peer-deps
+
+ - name: Run Svelte check
+ run: npm run check || true
+
+ - name: Run ESLint
+ run: npm run lint || true
+
+ # =============================================
+ # Deploy Job - Deploys to production server
+ # =============================================
+ deploy:
+ runs-on: ubuntu-latest
+ needs: [build, lint]
+ if: |
+ (github.event_name == 'push' && github.ref == 'refs/heads/main') ||
+ (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy == 'true')
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Deploy to production
+ uses: appleboy/ssh-action@v1.0.3
+ with:
+ host: ${{ secrets.DEPLOY_HOST }}
+ username: ${{ secrets.DEPLOY_USER }}
+ key: ${{ secrets.DEPLOY_KEY }}
+ script: |
+ cd ${{ secrets.DEPLOY_PATH }}
+ git pull origin main
+ ./deploy.sh update
+ echo "Deployment completed at $(date)"
+
+ - name: Notify deployment success
+ if: success()
+ run: |
+ echo "Successfully deployed to production!"
+ echo "Commit: ${{ github.sha }}"
+ echo "Branch: ${{ github.ref_name }}"
+
+ - name: Notify deployment failure
+ if: failure()
+ run: |
+ echo "Deployment failed!"
+ echo "Check logs for details."
+ exit 1
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3b462cb
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,23 @@
+node_modules
+
+# Output
+.output
+.vercel
+.netlify
+.wrangler
+/.svelte-kit
+/build
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Env
+.env
+.env.*
+!.env.example
+!.env.test
+
+# Vite
+vite.config.js.timestamp-*
+vite.config.ts.timestamp-*
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..b6f27f1
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1 @@
+engine-strict=true
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
new file mode 100644
index 0000000..6f79650
--- /dev/null
+++ b/ARCHITECTURE.md
@@ -0,0 +1,3710 @@
+# Monaco USA Portal 2026 - Complete Rebuild
+
+## Project Overview
+Rebuild the Monaco USA member portal from scratch in `monacousa-portal-2026/` with modern architecture, beautiful UI, and improved functionality.
+
+---
+
+# DETAILED FEATURE SPECIFICATIONS
+
+## 1. MEMBER SYSTEM (Detailed)
+
+### 1.1 Member ID Format
+- **Format**: `MUSA-XXXX` (sequential 4-digit number)
+- **Examples**: MUSA-0001, MUSA-0042, MUSA-1234
+- **Auto-generated** on member creation
+- **Immutable** once assigned
+- **Unique constraint** in database
+
+### 1.2 Membership Statuses (Admin-Configurable)
+Admin can create, edit, and delete statuses via Settings.
+
+**Default Statuses (seeded on first run):**
+| Status | Color | Description | Is Default |
+|--------|-------|-------------|------------|
+| `pending` | Yellow | New member, awaiting dues payment | Yes (for new signups) |
+| `active` | Green | Dues paid, full access | No |
+| `inactive` | Gray | Lapsed membership or suspended | No |
+| `expired` | Red | Membership terminated | No |
+
+**Status Configuration Table:**
+```sql
+CREATE TABLE public.membership_statuses (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL UNIQUE,
+ display_name TEXT NOT NULL,
+ color TEXT NOT NULL DEFAULT '#6b7280', -- Tailwind gray-500
+ description TEXT,
+ is_default BOOLEAN DEFAULT FALSE, -- Used for new signups
+ sort_order INTEGER DEFAULT 0,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+```
+
+### 1.3 Roles/Tiers
+**Fixed 3-tier system (not configurable):**
+| Role | Access Level | Capabilities |
+|------|--------------|--------------|
+| `member` | Basic | View own profile, events, pay dues |
+| `board` | Elevated | + Member directory, record payments, manage events |
+| `admin` | Full | + User management, system settings, all data |
+
+### 1.4 Required Member Fields
+All fields marked as required during signup:
+
+| Field | Type | Validation | Notes |
+|-------|------|------------|-------|
+| `first_name` | Text | Min 2 chars | Required |
+| `last_name` | Text | Min 2 chars | Required |
+| `email` | Email | Valid email format | Required, unique |
+| `phone` | Text | International format | Required |
+| `date_of_birth` | Date | Must be 18+ years old | Required |
+| `address` | Text | Min 10 chars | Required |
+| `nationality` | Array | At least 1 country | Required, multiple allowed |
+
+### 1.5 Optional Member Fields
+| Field | Type | Notes |
+|-------|------|-------|
+| `avatar_url` | Text | Supabase Storage path |
+| `membership_type_id` | UUID | Links to membership_types table |
+| `notes` | Text | Admin-only notes about member |
+
+### 1.6 Nationality Handling
+- **Multiple nationalities allowed**
+- Stored as PostgreSQL `TEXT[]` array
+- Uses ISO 3166-1 alpha-2 country codes: `['FR', 'US', 'MC']`
+- UI shows country flags + names
+- Searchable/filterable in directory
+
+### 1.7 Profile Features
+- **Profile photo**: Upload via Supabase Storage
+ - Max size: 5MB
+ - Formats: JPG, PNG, WebP
+ - Auto-resized to 256x256
+ - Stored at: `avatars/{member_id}/profile.{ext}`
+- **No bio field** (simplified profile)
+- Members can edit: name, phone, address, nationality, photo
+
+### 1.8 Member Directory
+**Visibility controlled by admin settings:**
+
+```sql
+CREATE TABLE public.directory_settings (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ field_name TEXT NOT NULL UNIQUE,
+ visible_to_members BOOLEAN DEFAULT FALSE,
+ visible_to_board BOOLEAN DEFAULT TRUE,
+ visible_to_admin BOOLEAN DEFAULT TRUE,
+ updated_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Default visibility settings
+INSERT INTO directory_settings (field_name, visible_to_members, visible_to_board) VALUES
+ ('first_name', true, true),
+ ('last_name', true, true),
+ ('avatar_url', true, true),
+ ('nationality', true, true),
+ ('email', false, true),
+ ('phone', false, true),
+ ('address', false, true),
+ ('date_of_birth', false, true),
+ ('member_since', true, true),
+ ('membership_status', false, true);
+```
+
+### 1.9 Member Signup Flow
+```
+┌─────────────┐ ┌──────────────┐ ┌─────────────┐
+│ /signup │────▶│ Create Auth │────▶│ Email Verify│
+│ Form │ │ User + Member│ │ Link Sent │
+└─────────────┘ └──────────────┘ └─────────────┘
+ │
+ ▼
+ ┌──────────────┐ ┌─────────────┐
+ │ Status = │────▶│ Wait for │
+ │ 'pending' │ │ Dues Payment│
+ └──────────────┘ └─────────────┘
+ │
+ ▼
+ ┌─────────────┐
+ │ Board/Admin │
+ │ Records Dues│
+ └─────────────┘
+ │
+ ▼
+ ┌─────────────┐
+ │ Status = │
+ │ 'active' │
+ └─────────────┘
+```
+
+**Key Points:**
+- Email verification required
+- Status starts as `pending`
+- Member gains `active` status ONLY when first dues payment recorded
+- Pending members can log in but see limited dashboard
+
+### 1.10 Admin Member Management
+**Two ways to add members:**
+
+**Option A: Direct Add**
+1. Admin fills out member form
+2. Admin sets temporary password OR sends password setup email
+3. Member record created with chosen status
+4. Member can log in immediately
+
+**Option B: Invite**
+1. Admin enters email + basic info
+2. System sends invitation email with signup link
+3. Invitee completes signup form
+4. Status set based on invite settings
+
+### 1.11 Membership Types (Admin-Configurable)
+Admin can create membership tiers with different pricing:
+
+```sql
+CREATE TABLE public.membership_types (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL UNIQUE, -- 'regular', 'student', 'senior'
+ display_name TEXT NOT NULL, -- 'Regular Member', 'Student'
+ annual_dues DECIMAL(10,2) NOT NULL, -- 50.00, 25.00, etc.
+ description TEXT,
+ is_default BOOLEAN DEFAULT FALSE, -- Default for new signups
+ is_active BOOLEAN DEFAULT TRUE, -- Can be assigned
+ sort_order INTEGER DEFAULT 0,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Default membership types
+INSERT INTO membership_types (name, display_name, annual_dues, is_default) VALUES
+ ('regular', 'Regular Member', 50.00, true),
+ ('student', 'Student', 25.00, false),
+ ('senior', 'Senior (65+)', 35.00, false),
+ ('family', 'Family', 75.00, false),
+ ('honorary', 'Honorary Member', 0.00, false);
+```
+
+### 1.12 Complete Member Schema
+
+```sql
+CREATE TABLE public.members (
+ -- Identity
+ id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
+ member_id TEXT UNIQUE NOT NULL, -- MUSA-0001 format (auto-generated)
+
+ -- Required Personal Info
+ first_name TEXT NOT NULL,
+ last_name TEXT NOT NULL,
+ email TEXT UNIQUE NOT NULL,
+ phone TEXT NOT NULL,
+ date_of_birth DATE NOT NULL,
+ address TEXT NOT NULL,
+ nationality TEXT[] NOT NULL DEFAULT '{}',
+
+ -- Membership
+ role TEXT NOT NULL DEFAULT 'member'
+ CHECK (role IN ('member', 'board', 'admin')),
+ membership_status_id UUID REFERENCES public.membership_statuses(id),
+ membership_type_id UUID REFERENCES public.membership_types(id),
+ member_since DATE DEFAULT CURRENT_DATE,
+
+ -- Profile
+ avatar_url TEXT,
+
+ -- Admin
+ notes TEXT, -- Admin-only notes
+
+ -- Timestamps
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Auto-generate member_id trigger
+CREATE OR REPLACE FUNCTION generate_member_id()
+RETURNS TRIGGER AS $$
+DECLARE
+ next_num INTEGER;
+BEGIN
+ SELECT COALESCE(MAX(CAST(SUBSTRING(member_id FROM 6) AS INTEGER)), 0) + 1
+ INTO next_num
+ FROM public.members;
+
+ NEW.member_id := 'MUSA-' || LPAD(next_num::TEXT, 4, '0');
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER set_member_id
+ BEFORE INSERT ON public.members
+ FOR EACH ROW
+ WHEN (NEW.member_id IS NULL)
+ EXECUTE FUNCTION generate_member_id();
+```
+
+---
+
+## 2. DUES/PAYMENTS SYSTEM (Detailed)
+
+### 2.1 Dues Cycle
+- **Due date calculation**: Payment date + 365 days
+- **Example**: Payment on Jan 15, 2026 → Due Jan 15, 2027
+- **No proration**: Full annual dues regardless of join date
+
+### 2.2 Payment Methods
+**Bank transfer only** (no online payments):
+- IBAN tracking
+- Reference number for matching
+- Manual recording by Board/Admin
+
+### 2.3 Payment Recording
+**Who can record payments:**
+- Board members
+- Admins
+
+**Standard payment data tracked:**
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `member_id` | UUID | Yes | Which member |
+| `amount` | Decimal | Yes | Payment amount (€) |
+| `payment_date` | Date | Yes | When payment was made |
+| `due_date` | Date | Yes | When this payment period ends (auto-calculated) |
+| `reference` | Text | No | Bank transfer reference |
+| `payment_method` | Text | Yes | Always 'bank_transfer' for now |
+| `recorded_by` | UUID | Yes | Board/Admin who recorded |
+| `notes` | Text | No | Optional notes |
+
+### 2.4 Dues Settings (Admin-Configurable)
+
+```sql
+CREATE TABLE public.dues_settings (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ setting_key TEXT UNIQUE NOT NULL,
+ setting_value TEXT NOT NULL,
+ description TEXT,
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_by UUID REFERENCES public.members(id)
+);
+
+-- Default settings
+INSERT INTO dues_settings (setting_key, setting_value, description) VALUES
+ ('reminder_days_before', '30,7', 'Days before due date to send reminders (comma-separated)'),
+ ('grace_period_days', '30', 'Days after due date before auto-inactive'),
+ ('overdue_reminder_interval', '14', 'Days between overdue reminder emails'),
+ ('payment_iban', 'MC58 1756 9000 0104 0050 1001 860', 'IBAN for dues payment'),
+ ('payment_account_holder', 'ASSOCIATION MONACO USA', 'Account holder name'),
+ ('payment_instructions', 'Please include your Member ID in the reference', 'Payment instructions');
+```
+
+### 2.5 Automatic Reminders
+
+**Reminder Schedule (configurable via settings):**
+1. **30 days before** due date: "Your dues are coming up"
+2. **7 days before** due date: "Reminder: dues due in 1 week"
+3. **On due date**: "Your dues are now due"
+4. **Every 14 days overdue**: "Your dues are overdue" (until grace period ends)
+
+**Email Content Includes:**
+- Member name
+- Amount due (from membership_type)
+- Due date
+- IBAN and account holder
+- Payment reference suggestion (Member ID)
+- Link to portal
+
+**Technical Implementation:**
+- Supabase Edge Function runs daily
+- Checks all members for reminder triggers
+- Logs sent emails in `email_logs` table
+- Respects settings for intervals
+
+### 2.6 Overdue Handling
+
+**Grace Period Flow:**
+```
+Due Date Passed
+ │
+ ▼
+┌─────────────────────────────────────────┐
+│ GRACE PERIOD (configurable, default 30 days) │
+│ - Status remains 'active' │
+│ - Overdue reminders sent │
+│ - Flagged in dashboard │
+└─────────────────────────────────────────┘
+ │
+ ▼ (grace period ends)
+┌─────────────────────────────────────────┐
+│ AUTO STATUS CHANGE │
+│ - Status → 'inactive' │
+│ - Final notification email │
+│ - Member loses active access │
+└─────────────────────────────────────────┘
+```
+
+**Supabase Edge Function for Auto-Update:**
+```typescript
+// Runs daily via cron
+async function updateOverdueMembers() {
+ const gracePeriodDays = await getSetting('grace_period_days');
+ const cutoffDate = new Date();
+ cutoffDate.setDate(cutoffDate.getDate() - gracePeriodDays);
+
+ // Find members past grace period
+ const { data: overdueMembers } = await supabase
+ .from('members_with_dues')
+ .select('*')
+ .eq('membership_status', 'active')
+ .lt('current_due_date', cutoffDate.toISOString());
+
+ // Update each to inactive
+ for (const member of overdueMembers) {
+ await supabase
+ .from('members')
+ .update({ membership_status_id: inactiveStatusId })
+ .eq('id', member.id);
+
+ // Send final notification
+ await sendEmail(member.email, 'membership_lapsed', { ... });
+ }
+}
+```
+
+### 2.7 Payment History (Member Visible)
+
+Members can see their complete payment history:
+
+**Display includes:**
+- Payment date
+- Amount paid
+- Due date (period covered)
+- Reference number
+- Payment method
+
+**Members CANNOT see:**
+- Who recorded the payment
+- Internal notes
+- Other members' payments
+
+### 2.8 Dues Dashboard (Board/Admin)
+
+**Overview Stats:**
+- Total members with current dues
+- Members with dues due soon (next 30 days)
+- Overdue members count
+- Total collected this year
+
+**Filterable Member List:**
+- Filter by: status (current, due soon, overdue, never paid)
+- Sort by: due date, days overdue, member name
+- Quick actions: Record payment, Send reminder
+
+**Individual Member View:**
+- Full payment history
+- Current dues status
+- Quick record payment form
+- Send manual reminder button
+
+### 2.9 Complete Dues Schema
+
+```sql
+-- Dues payments table
+CREATE TABLE public.dues_payments (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ member_id UUID NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
+
+ amount DECIMAL(10,2) NOT NULL,
+ currency TEXT DEFAULT 'EUR',
+ payment_date DATE NOT NULL,
+ due_date DATE NOT NULL, -- Calculated: payment_date + 1 year
+ payment_method TEXT DEFAULT 'bank_transfer',
+ reference TEXT, -- Bank transfer reference
+ notes TEXT, -- Internal notes
+
+ recorded_by UUID NOT NULL REFERENCES public.members(id),
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Trigger to auto-calculate due_date
+CREATE OR REPLACE FUNCTION calculate_due_date()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.due_date := NEW.payment_date + INTERVAL '1 year';
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER set_due_date
+ BEFORE INSERT ON public.dues_payments
+ FOR EACH ROW
+ WHEN (NEW.due_date IS NULL)
+ EXECUTE FUNCTION calculate_due_date();
+
+-- After payment: update member status to active
+CREATE OR REPLACE FUNCTION update_member_status_on_payment()
+RETURNS TRIGGER AS $$
+DECLARE
+ active_status_id UUID;
+BEGIN
+ -- Get active status ID
+ SELECT id INTO active_status_id
+ FROM public.membership_statuses
+ WHERE name = 'active';
+
+ -- Update member status
+ UPDATE public.members
+ SET membership_status_id = active_status_id,
+ updated_at = NOW()
+ WHERE id = NEW.member_id;
+
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER activate_member_on_payment
+ AFTER INSERT ON public.dues_payments
+ FOR EACH ROW
+ EXECUTE FUNCTION update_member_status_on_payment();
+
+-- Computed view for dues status
+CREATE VIEW public.members_with_dues AS
+SELECT
+ m.*,
+ ms.name as status_name,
+ ms.display_name as status_display_name,
+ ms.color as status_color,
+ mt.display_name as membership_type_name,
+ mt.annual_dues,
+ dp.last_payment_date,
+ dp.current_due_date,
+ CASE
+ WHEN dp.current_due_date IS NULL THEN 'never_paid'
+ WHEN dp.current_due_date < CURRENT_DATE THEN 'overdue'
+ WHEN dp.current_due_date < CURRENT_DATE + INTERVAL '30 days' THEN 'due_soon'
+ ELSE 'current'
+ END as dues_status,
+ CASE
+ WHEN dp.current_due_date < CURRENT_DATE
+ THEN (CURRENT_DATE - dp.current_due_date)::INTEGER
+ ELSE NULL
+ END as days_overdue,
+ CASE
+ WHEN dp.current_due_date >= CURRENT_DATE
+ THEN (dp.current_due_date - CURRENT_DATE)::INTEGER
+ ELSE NULL
+ END as days_until_due
+FROM public.members m
+LEFT JOIN public.membership_statuses ms ON m.membership_status_id = ms.id
+LEFT JOIN public.membership_types mt ON m.membership_type_id = mt.id
+LEFT JOIN LATERAL (
+ SELECT
+ payment_date as last_payment_date,
+ due_date as current_due_date
+ FROM public.dues_payments
+ WHERE member_id = m.id
+ ORDER BY due_date DESC
+ LIMIT 1
+) dp ON true;
+```
+
+### 2.10 Email Templates for Dues
+
+**Types:**
+1. `dues_reminder` - Upcoming dues reminder
+2. `dues_due_today` - Dues due today
+3. `dues_overdue` - Overdue reminder
+4. `dues_lapsed` - Membership lapsed (grace period ended)
+5. `dues_received` - Payment confirmation
+
+**Template Variables:**
+- `{{member_name}}` - Full name
+- `{{member_id}}` - MUSA-XXXX
+- `{{amount}}` - Due amount
+- `{{due_date}}` - Formatted date
+- `{{days_until_due}}` or `{{days_overdue}}`
+- `{{iban}}` - Payment IBAN
+- `{{account_holder}}` - Account name
+- `{{portal_link}}` - Link to portal
+
+---
+
+## 3. EVENTS SYSTEM (Detailed)
+
+### 3.1 Event Types (Admin-Configurable)
+
+```sql
+CREATE TABLE public.event_types (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL UNIQUE,
+ display_name TEXT NOT NULL,
+ color TEXT NOT NULL DEFAULT '#3b82f6', -- Tailwind blue-500
+ icon TEXT, -- Lucide icon name
+ description TEXT,
+ is_active BOOLEAN DEFAULT TRUE,
+ sort_order INTEGER DEFAULT 0,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Default event types
+INSERT INTO event_types (name, display_name, color, icon) VALUES
+ ('social', 'Social Event', '#10b981', 'party-popper'),
+ ('meeting', 'Meeting', '#6366f1', 'users'),
+ ('fundraiser', 'Fundraiser', '#f59e0b', 'heart-handshake'),
+ ('workshop', 'Workshop', '#8b5cf6', 'graduation-cap'),
+ ('gala', 'Gala/Formal', '#ec4899', 'sparkles'),
+ ('other', 'Other', '#6b7280', 'calendar');
+```
+
+### 3.2 Event Visibility
+
+**Visibility Options:**
+| Level | Who Can See | Description |
+|-------|-------------|-------------|
+| `public` | Anyone | Visible on public events page (no login) |
+| `members` | All logged-in members | Default for most events |
+| `board` | Board + Admin only | Board meetings, internal events |
+| `admin` | Admin only | Administrative events |
+
+### 3.3 Event Pricing
+
+**Pricing Model:**
+- Each event can be free or paid
+- Paid events have **member price** and **non-member price**
+- Member pricing determined by `membership_type_id` (if tiered pricing enabled)
+- Non-members pay non-member price always
+
+**Pricing Fields:**
+```sql
+is_paid BOOLEAN DEFAULT FALSE,
+member_price DECIMAL(10,2) DEFAULT 0,
+non_member_price DECIMAL(10,2) DEFAULT 0,
+pricing_notes TEXT -- "Includes dinner and drinks"
+```
+
+### 3.4 Guest/+1 Handling
+
+**Per-Event Configuration:**
+- `max_guests_per_member` - 0, 1, 2, 3, or unlimited
+- Each RSVP tracks guest count and guest names
+- Guests count toward total capacity
+- Non-members can bring guests too (if enabled)
+
+### 3.5 Non-Member (Public) RSVP
+
+**Flow for public events:**
+```
+┌─────────────────┐ ┌──────────────────┐
+│ Public Events │────▶│ Event Detail │
+│ Page (no login) │ │ (public visible) │
+└─────────────────┘ └──────────────────┘
+ │
+ ▼
+ ┌──────────────────┐
+ │ RSVP Form │
+ │ (no account) │
+ │ - Name │
+ │ - Email │
+ │ - Phone │
+ │ - Guest count │
+ │ - Guest names │
+ └──────────────────┘
+ │
+ ▼
+ ┌──────────────────┐
+ │ Payment Info │
+ │ (if paid event) │
+ │ - IBAN shown │
+ │ - Reference # │
+ └──────────────────┘
+ │
+ ▼
+ ┌──────────────────┐
+ │ RSVP Confirmed │
+ │ (pending payment)│
+ │ Email sent │
+ └──────────────────┘
+```
+
+**Non-Member RSVP Table:**
+```sql
+CREATE TABLE public.event_rsvps_public (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ event_id UUID NOT NULL REFERENCES public.events(id) ON DELETE CASCADE,
+
+ -- Contact info (required)
+ full_name TEXT NOT NULL,
+ email TEXT NOT NULL,
+ phone TEXT,
+
+ -- RSVP details
+ status TEXT NOT NULL DEFAULT 'confirmed'
+ CHECK (status IN ('confirmed', 'declined', 'maybe', 'waitlist', 'cancelled')),
+ guest_count INTEGER DEFAULT 0,
+ guest_names TEXT[],
+
+ -- Payment (for paid events)
+ payment_status TEXT DEFAULT 'not_required'
+ CHECK (payment_status IN ('not_required', 'pending', 'paid')),
+ payment_reference TEXT,
+ payment_amount DECIMAL(10,2),
+
+ -- Attendance
+ attended BOOLEAN DEFAULT FALSE,
+
+ -- Timestamps
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+
+ UNIQUE(event_id, email) -- One RSVP per email per event
+);
+```
+
+### 3.6 RSVP Status Options
+
+**For Members and Non-Members:**
+| Status | Description |
+|--------|-------------|
+| `confirmed` | Attending the event |
+| `declined` | Not attending |
+| `maybe` | Tentative/undecided |
+| `waitlist` | Event full, on waitlist |
+| `cancelled` | Cancelled RSVP |
+
+### 3.7 Capacity & Waitlist
+
+**Capacity Management:**
+- `max_attendees` - Total spots (null = unlimited)
+- Includes members + guests + non-members + their guests
+- When full, new RSVPs go to waitlist
+
+**Auto-Promote Waitlist:**
+```typescript
+// Trigger when RSVP is cancelled or declined
+async function promoteFromWaitlist(eventId: string) {
+ // Get event capacity
+ const event = await getEvent(eventId);
+ const currentCount = await getCurrentAttendeeCount(eventId);
+
+ if (event.max_attendees && currentCount >= event.max_attendees) {
+ return; // Still full
+ }
+
+ // Get oldest waitlist entry
+ const waitlisted = await supabase
+ .from('event_rsvps')
+ .select('*')
+ .eq('event_id', eventId)
+ .eq('status', 'waitlist')
+ .order('created_at', { ascending: true })
+ .limit(1)
+ .single();
+
+ if (waitlisted) {
+ // Promote to confirmed
+ await supabase
+ .from('event_rsvps')
+ .update({ status: 'confirmed' })
+ .eq('id', waitlisted.id);
+
+ // Send notification email
+ await sendEmail(waitlisted.member.email, 'waitlist_promoted', {
+ event_title: event.title,
+ event_date: event.start_datetime
+ });
+ }
+}
+```
+
+### 3.8 Attendance Tracking
+
+**Check-in System:**
+- Board/Admin can mark attendance after event
+- Checkbox per RSVP: attended yes/no
+- Track attendance rate per event
+- Member attendance history viewable
+
+```sql
+-- Add to RSVPs
+attended BOOLEAN DEFAULT FALSE,
+checked_in_at TIMESTAMPTZ,
+checked_in_by UUID REFERENCES public.members(id)
+```
+
+### 3.9 Calendar Views
+
+**Available Views:**
+1. **Month** - Traditional calendar grid
+2. **Week** - Weekly schedule view
+3. **Day** - Single day detailed view
+4. **List** - Upcoming events list
+
+**Using FullCalendar (SvelteKit compatible):**
+```typescript
+import Calendar from '@event-calendar/core';
+import TimeGrid from '@event-calendar/time-grid';
+import DayGrid from '@event-calendar/day-grid';
+import List from '@event-calendar/list';
+```
+
+### 3.10 Event Schema
+
+```sql
+CREATE TABLE public.events (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ -- Basic Info
+ title TEXT NOT NULL,
+ description TEXT,
+ event_type_id UUID REFERENCES public.event_types(id),
+
+ -- Date/Time
+ start_datetime TIMESTAMPTZ NOT NULL,
+ end_datetime TIMESTAMPTZ NOT NULL,
+ all_day BOOLEAN DEFAULT FALSE,
+ timezone TEXT DEFAULT 'Europe/Monaco',
+
+ -- Location
+ location TEXT,
+ location_url TEXT, -- Google Maps link, etc.
+
+ -- Capacity
+ max_attendees INTEGER, -- null = unlimited
+ max_guests_per_member INTEGER DEFAULT 1,
+
+ -- Pricing
+ is_paid BOOLEAN DEFAULT FALSE,
+ member_price DECIMAL(10,2) DEFAULT 0,
+ non_member_price DECIMAL(10,2) DEFAULT 0,
+ pricing_notes TEXT,
+
+ -- Visibility
+ visibility TEXT NOT NULL DEFAULT 'members'
+ CHECK (visibility IN ('public', 'members', 'board', 'admin')),
+
+ -- Status
+ status TEXT NOT NULL DEFAULT 'published'
+ CHECK (status IN ('draft', 'published', 'cancelled', 'completed')),
+
+ -- Media
+ cover_image_url TEXT, -- Event banner/cover image
+
+ -- Meta
+ created_by UUID NOT NULL REFERENCES public.members(id),
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Member RSVPs
+CREATE TABLE public.event_rsvps (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ event_id UUID NOT NULL REFERENCES public.events(id) ON DELETE CASCADE,
+ member_id UUID NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
+
+ status TEXT NOT NULL DEFAULT 'confirmed'
+ CHECK (status IN ('confirmed', 'declined', 'maybe', 'waitlist', 'cancelled')),
+ guest_count INTEGER DEFAULT 0,
+ guest_names TEXT[],
+ notes TEXT,
+
+ -- Payment (for paid events)
+ payment_status TEXT DEFAULT 'not_required'
+ CHECK (payment_status IN ('not_required', 'pending', 'paid')),
+ payment_reference TEXT,
+ payment_amount DECIMAL(10,2),
+
+ -- Attendance
+ attended BOOLEAN DEFAULT FALSE,
+ checked_in_at TIMESTAMPTZ,
+ checked_in_by UUID REFERENCES public.members(id),
+
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+
+ UNIQUE(event_id, member_id)
+);
+
+-- View for event with counts
+CREATE VIEW public.events_with_counts AS
+SELECT
+ e.*,
+ et.display_name as event_type_name,
+ et.color as event_type_color,
+ et.icon as event_type_icon,
+ COALESCE(member_rsvps.confirmed_count, 0) +
+ COALESCE(member_rsvps.guest_count, 0) +
+ COALESCE(public_rsvps.confirmed_count, 0) +
+ COALESCE(public_rsvps.guest_count, 0) as total_attendees,
+ COALESCE(member_rsvps.confirmed_count, 0) as member_count,
+ COALESCE(public_rsvps.confirmed_count, 0) as non_member_count,
+ COALESCE(member_rsvps.waitlist_count, 0) +
+ COALESCE(public_rsvps.waitlist_count, 0) as waitlist_count,
+ CASE
+ WHEN e.max_attendees IS NULL THEN FALSE
+ WHEN (COALESCE(member_rsvps.confirmed_count, 0) +
+ COALESCE(member_rsvps.guest_count, 0) +
+ COALESCE(public_rsvps.confirmed_count, 0) +
+ COALESCE(public_rsvps.guest_count, 0)) >= e.max_attendees THEN TRUE
+ ELSE FALSE
+ END as is_full
+FROM public.events e
+LEFT JOIN public.event_types et ON e.event_type_id = et.id
+LEFT JOIN LATERAL (
+ SELECT
+ COUNT(*) FILTER (WHERE status = 'confirmed') as confirmed_count,
+ COALESCE(SUM(guest_count) FILTER (WHERE status = 'confirmed'), 0) as guest_count,
+ COUNT(*) FILTER (WHERE status = 'waitlist') as waitlist_count
+ FROM public.event_rsvps
+ WHERE event_id = e.id
+) member_rsvps ON true
+LEFT JOIN LATERAL (
+ SELECT
+ COUNT(*) FILTER (WHERE status = 'confirmed') as confirmed_count,
+ COALESCE(SUM(guest_count) FILTER (WHERE status = 'confirmed'), 0) as guest_count,
+ COUNT(*) FILTER (WHERE status = 'waitlist') as waitlist_count
+ FROM public.event_rsvps_public
+ WHERE event_id = e.id
+) public_rsvps ON true;
+```
+
+### 3.11 Event Permissions
+
+| Action | Member | Board | Admin |
+|--------|--------|-------|-------|
+| View public events | - | - | - |
+| View member events | ✓ | ✓ | ✓ |
+| View board events | - | ✓ | ✓ |
+| View admin events | - | - | ✓ |
+| RSVP to events | ✓ | ✓ | ✓ |
+| Create events | - | ✓ | ✓ |
+| Edit own events | - | ✓ | ✓ |
+| Edit any event | - | - | ✓ |
+| Delete events | - | - | ✓ |
+| Manage RSVPs | - | ✓ | ✓ |
+| Track attendance | - | ✓ | ✓ |
+
+### 3.12 Event Email Notifications
+
+**Email Types:**
+1. `event_created` - New event announcement (for public/member events)
+2. `event_reminder` - Reminder before event (configurable: 1 day, 1 hour)
+3. `event_updated` - Event details changed
+4. `event_cancelled` - Event cancelled
+5. `rsvp_confirmation` - RSVP received
+6. `waitlist_promoted` - Promoted from waitlist
+7. `event_payment_reminder` - Payment reminder for paid events
+
+**Template Variables:**
+- `{{event_title}}`, `{{event_date}}`, `{{event_time}}`
+- `{{event_location}}`, `{{event_description}}`
+- `{{member_name}}`, `{{guest_count}}`
+- `{{payment_amount}}`, `{{payment_iban}}`
+- `{{rsvp_status}}`, `{{portal_link}}`
+
+---
+
+## 4. AUTH & DASHBOARDS (Detailed)
+
+### 4.1 Authentication Method
+
+**Email/Password only** (no social login):
+- Standard email + password signup/login
+- Email verification required
+- Password reset via email
+- Remember me option (extended session)
+
+### 4.2 Login Page Design
+
+**Branded login with:**
+- Monaco USA logo
+- Association tagline
+- Login form (email, password, remember me)
+- Links: Forgot password, Sign up
+- Glass-morphism styling
+- Responsive (mobile-friendly)
+
+### 4.3 Auth Flow
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│ SIGNUP FLOW │
+├──────────────────────────────────────────────────────────────┤
+│ /signup │
+│ ├── Full form (all required fields) │
+│ ├── Supabase Auth: signUp(email, password) │
+│ ├── Create member record (status: pending) │
+│ ├── Send verification email │
+│ └── Show "Check your email" message │
+│ │
+│ /auth/callback (email verification link) │
+│ ├── Verify email token │
+│ ├── Update email_verified = true │
+│ └── Redirect to /login with success message │
+└──────────────────────────────────────────────────────────────┘
+
+┌──────────────────────────────────────────────────────────────┐
+│ LOGIN FLOW │
+├──────────────────────────────────────────────────────────────┤
+│ /login │
+│ ├── Email + Password form │
+│ ├── Supabase Auth: signInWithPassword() │
+│ ├── Set session cookie (via Supabase SSR) │
+│ ├── Fetch member record │
+│ └── Redirect to /dashboard │
+└──────────────────────────────────────────────────────────────┘
+
+┌──────────────────────────────────────────────────────────────┐
+│ PASSWORD RESET │
+├──────────────────────────────────────────────────────────────┤
+│ /forgot-password │
+│ ├── Email input form │
+│ ├── Supabase Auth: resetPasswordForEmail() │
+│ └── Show "Check your email" message │
+│ │
+│ /auth/reset-password (from email link) │
+│ ├── New password form │
+│ ├── Supabase Auth: updateUser({ password }) │
+│ └── Redirect to /login with success │
+└──────────────────────────────────────────────────────────────┘
+```
+
+### 4.4 Session Management
+
+**Supabase SSR Configuration:**
+```typescript
+// src/hooks.server.ts
+export const handle: Handle = async ({ event, resolve }) => {
+ event.locals.supabase = createServerClient(
+ PUBLIC_SUPABASE_URL,
+ PUBLIC_SUPABASE_ANON_KEY,
+ {
+ cookies: {
+ getAll: () => event.cookies.getAll(),
+ setAll: (cookies) => cookies.forEach(({ name, value, options }) =>
+ event.cookies.set(name, value, { ...options, path: '/' })
+ )
+ }
+ }
+ );
+
+ event.locals.safeGetSession = async () => {
+ const { data: { session } } = await event.locals.supabase.auth.getSession();
+ if (!session) return { session: null, user: null, member: null };
+
+ const { data: { user } } = await event.locals.supabase.auth.getUser();
+ if (!user) return { session: null, user: null, member: null };
+
+ // Fetch member record
+ const { data: member } = await event.locals.supabase
+ .from('members_with_dues')
+ .select('*')
+ .eq('id', user.id)
+ .single();
+
+ return { session, user, member };
+ };
+
+ return resolve(event);
+};
+```
+
+### 4.5 Navigation Structure
+
+**Desktop: Collapsible Sidebar**
+```
+┌─────────────────────────────────────────────────────┐
+│ ┌─────┐ │
+│ │ │ Dashboard │
+│ │LOGO │ ───────────────────────────────────── │
+│ │ │ │
+│ └─────┘ [Sidebar Navigation] [Content] │
+│ │
+│ 📊 Dashboard │
+│ 👤 My Profile │
+│ 📅 Events │
+│ 💳 Payments │
+│ │
+│ ── Board ──────── (if board/admin) │
+│ 👥 Members │
+│ 📋 Dues Management │
+│ 📅 Event Management │
+│ │
+│ ── Admin ──────── (if admin) │
+│ ⚙️ Settings │
+│ 👥 User Management │
+│ 📄 Documents │
+│ │
+│ ───────────────── │
+│ 🚪 Logout │
+└─────────────────────────────────────────────────────┘
+```
+
+**Mobile: Bottom Navigation Bar**
+```
+┌─────────────────────────────────────┐
+│ │
+│ [Main Content] │
+│ │
+│ │
+├─────────────────────────────────────┤
+│ 🏠 📅 👤 ⚙️ ☰ │
+│ Home Events Profile Settings More │
+└─────────────────────────────────────┘
+```
+
+### 4.6 Unified Dashboard with Role Sections
+
+**Single `/dashboard` route with role-based sections:**
+
+```svelte
+
+
+
+
+
+
+
+
+
+{#if isBoard}
+
+
+
+
+{/if}
+
+
+{#if isAdmin}
+
+
+
+
+{/if}
+```
+
+### 4.7 Member Dashboard Section
+
+**Components:**
+1. **Welcome Card** - Greeting with name, membership status badge
+2. **Dues Status Card** - Current status, next due date, quick pay info
+3. **Upcoming Events Card** - Next 3-5 events with RSVP status
+4. **Profile Quick View** - Photo, basic info, edit link
+
+**Data Loaded:**
+```typescript
+// routes/(app)/dashboard/+page.server.ts
+export const load = async ({ locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ const upcomingEvents = await getUpcomingEventsForMember(member.id, 5);
+
+ return {
+ member,
+ upcomingEvents
+ };
+};
+```
+
+### 4.8 Board Dashboard Section
+
+**Additional Components (visible to board/admin):**
+1. **Member Stats Card** - Total, active, pending, inactive counts
+2. **Pending Members Card** - New signups awaiting approval/payment
+3. **Dues Overview Card** - Current, due soon, overdue breakdown
+4. **Recent RSVPs Card** - Latest event RSVPs
+
+**Board Stats:**
+```typescript
+interface BoardStats {
+ totalMembers: number;
+ activeMembers: number;
+ pendingMembers: number;
+ inactiveMembers: number;
+ duesSoon: number; // Due in next 30 days
+ duesOverdue: number; // Past due date
+ upcomingEvents: number;
+ pendingRsvps: number;
+}
+```
+
+### 4.9 Admin Dashboard Section
+
+**Additional Components (admin only):**
+1. **System Health Card** - Supabase status, email status
+2. **Recent Activity Card** - Latest logins, signups, payments
+3. **Quick Actions Card** - Add member, create event, send broadcast
+4. **Alerts Card** - Issues requiring attention
+
+**Admin Stats:**
+```typescript
+interface AdminStats extends BoardStats {
+ totalUsers: number; // Auth users
+ recentLogins: number; // Last 24 hours
+ failedLogins: number; // Last 24 hours
+ emailsSent: number; // This month
+ storageUsed: number; // MB
+}
+```
+
+### 4.10 Route Protection
+
+**Layout-level guards using SvelteKit:**
+
+```typescript
+// routes/(app)/+layout.server.ts
+import { redirect } from '@sveltejs/kit';
+
+export const load = async ({ locals }) => {
+ const { session, member } = await locals.safeGetSession();
+
+ if (!session) {
+ throw redirect(303, '/login');
+ }
+
+ return { member };
+};
+
+// routes/(app)/board/+layout.server.ts
+export const load = async ({ locals, parent }) => {
+ const { member } = await parent();
+
+ if (member.role !== 'board' && member.role !== 'admin') {
+ throw redirect(303, '/dashboard');
+ }
+
+ return {};
+};
+
+// routes/(app)/admin/+layout.server.ts
+export const load = async ({ locals, parent }) => {
+ const { member } = await parent();
+
+ if (member.role !== 'admin') {
+ throw redirect(303, '/dashboard');
+ }
+
+ return {};
+};
+```
+
+### 4.11 Responsive Breakpoints
+
+| Breakpoint | Width | Layout |
+|------------|-------|--------|
+| Mobile | < 640px | Bottom nav, stacked cards |
+| Tablet | 640-1024px | Collapsed sidebar rail, 2-column |
+| Desktop | > 1024px | Full sidebar, 3-column grid |
+
+### 4.12 Dashboard Glass-Morphism Design
+
+**Glass Card Base Style:**
+```css
+.glass-card {
+ background: rgba(255, 255, 255, 0.7);
+ backdrop-filter: blur(10px);
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ border-radius: 16px;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
+}
+
+.glass-card-dark {
+ background: rgba(0, 0, 0, 0.3);
+ backdrop-filter: blur(10px);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+}
+```
+
+**Monaco Red Accent:**
+```css
+:root {
+ --monaco-red: #dc2626;
+ --monaco-red-light: #fee2e2;
+ --monaco-red-dark: #991b1b;
+}
+```
+
+---
+
+## 5. DOCUMENT STORAGE (Detailed)
+
+### 5.1 Document Categories (Admin-Configurable)
+
+```sql
+CREATE TABLE public.document_categories (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name TEXT NOT NULL UNIQUE,
+ display_name TEXT NOT NULL,
+ description TEXT,
+ icon TEXT, -- Lucide icon name
+ sort_order INTEGER DEFAULT 0,
+ is_active BOOLEAN DEFAULT TRUE,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Default categories
+INSERT INTO document_categories (name, display_name, icon) VALUES
+ ('meeting_minutes', 'Meeting Minutes', 'file-text'),
+ ('governance', 'Governance & Bylaws', 'scale'),
+ ('legal', 'Legal Documents', 'briefcase'),
+ ('financial', 'Financial Reports', 'dollar-sign'),
+ ('member_resources', 'Member Resources', 'book-open'),
+ ('forms', 'Forms & Templates', 'clipboard'),
+ ('other', 'Other Documents', 'file');
+```
+
+### 5.2 Upload Permissions
+
+**Who can upload:**
+- Board members
+- Administrators
+
+**Members cannot upload** - they can only view documents shared with them.
+
+### 5.3 Document Visibility (Per-Document)
+
+**Visibility Options:**
+| Level | Who Can View |
+|-------|--------------|
+| `public` | Anyone (no login required) |
+| `members` | All logged-in members |
+| `board` | Board + Admin only |
+| `admin` | Admin only |
+
+**Custom permissions** can also specify specific member IDs for restricted access.
+
+### 5.4 Document Schema
+
+```sql
+CREATE TABLE public.documents (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ -- Basic Info
+ title TEXT NOT NULL,
+ description TEXT,
+ category_id UUID REFERENCES public.document_categories(id),
+
+ -- File Info (Supabase Storage)
+ file_path TEXT NOT NULL, -- Storage path
+ file_name TEXT NOT NULL, -- Original filename
+ file_size INTEGER NOT NULL, -- Bytes
+ mime_type TEXT NOT NULL, -- 'application/pdf', etc.
+
+ -- Visibility
+ visibility TEXT NOT NULL DEFAULT 'members'
+ CHECK (visibility IN ('public', 'members', 'board', 'admin')),
+
+ -- Optional: Specific member access (for restricted docs)
+ allowed_member_ids UUID[], -- If set, only these members can view
+
+ -- Version tracking
+ version INTEGER DEFAULT 1,
+ replaces_document_id UUID REFERENCES public.documents(id),
+
+ -- Metadata
+ uploaded_by UUID NOT NULL REFERENCES public.members(id),
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Document access log (for audit)
+CREATE TABLE public.document_access_log (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ document_id UUID NOT NULL REFERENCES public.documents(id) ON DELETE CASCADE,
+ accessed_by UUID REFERENCES public.members(id), -- null if public access
+ access_type TEXT NOT NULL CHECK (access_type IN ('view', 'download')),
+ ip_address TEXT,
+ accessed_at TIMESTAMPTZ DEFAULT NOW()
+);
+```
+
+### 5.5 File Storage (Supabase Storage)
+
+**Bucket Configuration:**
+```typescript
+// Storage bucket: 'documents'
+// Path structure: documents/{category}/{year}/{filename}
+
+// Example paths:
+// documents/meeting_minutes/2026/board-meeting-2026-01-15.pdf
+// documents/governance/bylaws-v2.pdf
+// documents/financial/2025/annual-report-2025.pdf
+```
+
+**Upload Limits:**
+- Max file size: 50MB
+- Allowed types: PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT, JPG, PNG
+
+### 5.6 Document UI Features
+
+**Document Library View:**
+- Filter by category
+- Filter by visibility level
+- Search by title/description
+- Sort by date, name, category
+- Grid or list view toggle
+
+**Document Card:**
+```
+┌────────────────────────────────────────┐
+│ 📄 [Category Icon] │
+│ │
+│ Board Meeting Minutes - January 2026 │
+│ Meeting minutes from the monthly... │
+│ │
+│ 📅 Jan 15, 2026 | 📎 PDF | 1.2 MB │
+│ │
+│ [View] [Download] 👁️ Members │
+└────────────────────────────────────────┘
+```
+
+**Upload Form (Board/Admin):**
+- Title (required)
+- Description (optional)
+- Category (required, dropdown)
+- Visibility (required)
+- Custom access (optional, member multi-select)
+- File upload (drag & drop)
+
+### 5.7 Document Permissions (RLS)
+
+```sql
+-- RLS Policies for documents
+ALTER TABLE public.documents ENABLE ROW LEVEL SECURITY;
+
+-- Public documents viewable by anyone
+CREATE POLICY "Public documents are viewable"
+ ON public.documents FOR SELECT
+ USING (visibility = 'public');
+
+-- Member documents viewable by authenticated users
+CREATE POLICY "Member documents viewable by members"
+ ON public.documents FOR SELECT
+ TO authenticated
+ USING (
+ visibility = 'members'
+ OR visibility = 'public'
+ OR (visibility = 'board' AND EXISTS (
+ SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin')
+ ))
+ OR (visibility = 'admin' AND EXISTS (
+ SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin'
+ ))
+ OR (allowed_member_ids IS NOT NULL AND auth.uid() = ANY(allowed_member_ids))
+ );
+
+-- Board/Admin can manage documents
+CREATE POLICY "Board can upload documents"
+ ON public.documents FOR INSERT
+ TO authenticated
+ WITH CHECK (
+ EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin'))
+ );
+
+CREATE POLICY "Uploader or admin can update documents"
+ ON public.documents FOR UPDATE
+ TO authenticated
+ USING (
+ uploaded_by = auth.uid()
+ OR EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
+ );
+
+CREATE POLICY "Admin can delete documents"
+ ON public.documents FOR DELETE
+ TO authenticated
+ USING (
+ EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
+ );
+```
+
+### 5.8 Version History
+
+**Document versioning:**
+- When replacing a document, create new record with `replaces_document_id`
+- Previous versions remain accessible (archived)
+- View version history for any document
+
+```sql
+-- Get version history for a document
+SELECT d.*, m.first_name, m.last_name
+FROM public.documents d
+JOIN public.members m ON d.uploaded_by = m.id
+WHERE d.id = :document_id
+ OR d.replaces_document_id = :document_id
+ OR d.id IN (
+ SELECT replaces_document_id FROM public.documents
+ WHERE id = :document_id
+ )
+ORDER BY d.version DESC;
+```
+
+### 5.9 Meeting Minutes Special Handling
+
+**For meeting minutes category:**
+- Date field (meeting date)
+- Attendees list (optional)
+- Agenda reference (optional)
+- Quick template for consistency
+
+```sql
+-- Optional meeting minutes metadata
+ALTER TABLE public.documents ADD COLUMN meeting_date DATE;
+ALTER TABLE public.documents ADD COLUMN meeting_attendees UUID[];
+```
+
+---
+
+## 6. ADMIN SETTINGS SYSTEM (Detailed)
+
+### 6.1 Settings Architecture Overview
+
+**Centralized configuration** for all customizable aspects of the portal, accessible only to Admins via `/admin/settings`.
+
+**Settings Categories:**
+1. **Organization** - Association branding and info
+2. **Membership** - Statuses, types, and pricing
+3. **Dues** - Payment settings and reminders
+4. **Events** - Event types and defaults
+5. **Documents** - Categories and storage
+6. **Directory** - Visibility controls
+7. **Email** - SMTP and template settings
+8. **System** - Technical settings
+
+### 6.2 Settings Storage (Unified Table)
+
+```sql
+-- Flexible key-value settings with JSON support
+CREATE TABLE public.app_settings (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ category TEXT NOT NULL, -- 'organization', 'dues', 'email', etc.
+ setting_key TEXT NOT NULL, -- 'payment_iban', 'reminder_days', etc.
+ setting_value JSONB NOT NULL, -- Supports strings, numbers, arrays, objects
+ setting_type TEXT NOT NULL DEFAULT 'text' -- 'text', 'number', 'boolean', 'json', 'array'
+ CHECK (setting_type IN ('text', 'number', 'boolean', 'json', 'array')),
+ display_name TEXT NOT NULL, -- Human-readable label
+ description TEXT, -- Help text for admins
+ is_public BOOLEAN DEFAULT FALSE, -- If true, accessible without auth
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_by UUID REFERENCES public.members(id),
+
+ UNIQUE(category, setting_key)
+);
+
+-- Audit log for settings changes
+CREATE TABLE public.settings_audit_log (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ setting_id UUID NOT NULL REFERENCES public.app_settings(id),
+ old_value JSONB,
+ new_value JSONB NOT NULL,
+ changed_by UUID NOT NULL REFERENCES public.members(id),
+ changed_at TIMESTAMPTZ DEFAULT NOW(),
+ change_reason TEXT
+);
+
+-- RLS: Only admins can read/write settings
+ALTER TABLE public.app_settings ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "Only admins can manage settings"
+ ON public.app_settings FOR ALL
+ TO authenticated
+ USING (
+ EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
+ OR is_public = TRUE
+ );
+```
+
+### 6.3 Default Settings (Seeded on First Run)
+
+```sql
+-- Organization Settings
+INSERT INTO app_settings (category, setting_key, setting_value, setting_type, display_name, description, is_public) VALUES
+ ('organization', 'association_name', '"Monaco USA"', 'text', 'Association Name', 'Official name of the association', true),
+ ('organization', 'tagline', '"Americans in Monaco"', 'text', 'Tagline', 'Association tagline shown on login', true),
+ ('organization', 'contact_email', '"contact@monacousa.org"', 'text', 'Contact Email', 'Public contact email address', true),
+ ('organization', 'address', '"Monaco"', 'text', 'Address', 'Association physical address', true),
+ ('organization', 'logo_url', '"/logo.png"', 'text', 'Logo URL', 'Path to association logo', true),
+ ('organization', 'primary_color', '"#dc2626"', 'text', 'Primary Color', 'Brand primary color (hex)', true),
+
+-- Dues Settings
+ ('dues', 'payment_iban', '"MC58 1756 9000 0104 0050 1001 860"', 'text', 'Payment IBAN', 'Bank IBAN for dues payment', false),
+ ('dues', 'payment_account_holder', '"ASSOCIATION MONACO USA"', 'text', 'Account Holder', 'Bank account holder name', false),
+ ('dues', 'payment_bank_name', '"Credit Foncier de Monaco"', 'text', 'Bank Name', 'Name of the bank', false),
+ ('dues', 'payment_instructions', '"Please include your Member ID (MUSA-XXXX) in the reference"', 'text', 'Payment Instructions', 'Instructions shown to members', false),
+ ('dues', 'reminder_days_before', '[30, 7, 1]', 'array', 'Reminder Days', 'Days before due date to send reminders', false),
+ ('dues', 'grace_period_days', '30', 'number', 'Grace Period', 'Days after due date before auto-inactive', false),
+ ('dues', 'overdue_reminder_interval', '14', 'number', 'Overdue Reminder Interval', 'Days between overdue reminder emails', false),
+ ('dues', 'auto_inactive_enabled', 'true', 'boolean', 'Auto Inactive', 'Automatically set members inactive after grace period', false),
+
+-- Event Settings
+ ('events', 'default_max_guests', '2', 'number', 'Default Max Guests', 'Default maximum guests per RSVP', false),
+ ('events', 'reminder_hours_before', '[24, 1]', 'array', 'Event Reminder Hours', 'Hours before event to send reminders', false),
+ ('events', 'allow_public_rsvp', 'true', 'boolean', 'Allow Public RSVP', 'Allow non-members to RSVP to public events', false),
+ ('events', 'auto_close_rsvp_hours', '0', 'number', 'Auto Close RSVP', 'Hours before event to close RSVP (0 = never)', false),
+
+-- Directory Settings
+ ('directory', 'member_visible_fields', '["first_name", "last_name", "avatar_url", "nationality", "member_since"]', 'array', 'Member Visible Fields', 'Fields visible to regular members', false),
+ ('directory', 'board_visible_fields', '["first_name", "last_name", "avatar_url", "nationality", "email", "phone", "address", "date_of_birth", "member_since", "membership_status"]', 'array', 'Board Visible Fields', 'Fields visible to board members', false),
+ ('directory', 'show_membership_status', 'false', 'boolean', 'Show Status to Members', 'Show membership status in directory for regular members', false),
+
+-- System Settings
+ ('system', 'maintenance_mode', 'false', 'boolean', 'Maintenance Mode', 'Put the portal in maintenance mode', false),
+ ('system', 'maintenance_message', '"The portal is currently undergoing maintenance. Please check back soon."', 'text', 'Maintenance Message', 'Message shown during maintenance', false),
+ ('system', 'session_timeout_hours', '168', 'number', 'Session Timeout', 'Hours until session expires (default: 7 days)', false),
+ ('system', 'max_upload_size_mb', '50', 'number', 'Max Upload Size', 'Maximum file upload size in MB', false),
+ ('system', 'allowed_file_types', '["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "jpg", "jpeg", "png", "webp"]', 'array', 'Allowed File Types', 'Allowed file extensions for uploads', false);
+```
+
+### 6.4 Settings UI Layout
+
+**Navigation Tabs:**
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ ⚙️ Settings │
+├──────────────────────────────────────────────────────────────────┤
+│ [Organization] [Membership] [Dues] [Events] [Documents] │
+│ [Directory] [Email] [System] │
+├──────────────────────────────────────────────────────────────────┤
+│ │
+│ ┌─────────────────────────────────────────────────────────────┐ │
+│ │ Organization Settings │ │
+│ │ ───────────────────────────────────────────────────────── │ │
+│ │ │ │
+│ │ Association Name │ │
+│ │ ┌──────────────────────────────────────────────────────┐ │ │
+│ │ │ Monaco USA │ │ │
+│ │ └──────────────────────────────────────────────────────┘ │ │
+│ │ Official name of the association │ │
+│ │ │ │
+│ │ Tagline │ │
+│ │ ┌──────────────────────────────────────────────────────┐ │ │
+│ │ │ Americans in Monaco │ │ │
+│ │ └──────────────────────────────────────────────────────┘ │ │
+│ │ Association tagline shown on login │ │
+│ │ │ │
+│ │ Primary Color │ │
+│ │ ┌────────┐ ┌──────────────────────────────────────────┐ │ │
+│ │ │ 🎨 │ │ #dc2626 │ │ │
+│ │ └────────┘ └──────────────────────────────────────────┘ │ │
+│ │ │ │
+│ │ [Save Changes] │ │
+│ └─────────────────────────────────────────────────────────────┘ │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+### 6.5 Membership Settings Tab
+
+**Manages configurable membership statuses and types:**
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ Membership Settings │
+├──────────────────────────────────────────────────────────────────┤
+│ │
+│ MEMBERSHIP STATUSES │
+│ ───────────────────────────────────────────────────────────── │
+│ │
+│ ┌───────────┬─────────────┬──────────┬────────────┬──────────┐ │
+│ │ Name │ Display │ Color │ Is Default │ Actions │ │
+│ ├───────────┼─────────────┼──────────┼────────────┼──────────┤ │
+│ │ pending │ Pending │ 🟡 Yellow│ ✓ │ ✏️ 🗑️ │ │
+│ │ active │ Active │ 🟢 Green │ │ ✏️ 🗑️ │ │
+│ │ inactive │ Inactive │ ⚪ Gray │ │ ✏️ 🗑️ │ │
+│ │ expired │ Expired │ 🔴 Red │ │ ✏️ 🗑️ │ │
+│ └───────────┴─────────────┴──────────┴────────────┴──────────┘ │
+│ │
+│ [+ Add Status] │
+│ │
+│ ───────────────────────────────────────────────────────────── │
+│ │
+│ MEMBERSHIP TYPES │
+│ ───────────────────────────────────────────────────────────── │
+│ │
+│ ┌───────────┬───────────────┬──────────┬────────────┬────────┐ │
+│ │ Name │ Display │ Annual € │ Is Default │Actions │ │
+│ ├───────────┼───────────────┼──────────┼────────────┼────────┤ │
+│ │ regular │ Regular │ €50.00 │ ✓ │ ✏️ 🗑️ │ │
+│ │ student │ Student │ €25.00 │ │ ✏️ 🗑️ │ │
+│ │ senior │ Senior (65+) │ €35.00 │ │ ✏️ 🗑️ │ │
+│ │ family │ Family │ €75.00 │ │ ✏️ 🗑️ │ │
+│ │ honorary │ Honorary │ €0.00 │ │ ✏️ 🗑️ │ │
+│ └───────────┴───────────────┴──────────┴────────────┴────────┘ │
+│ │
+│ [+ Add Membership Type] │
+│ │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+### 6.6 Event Types Settings
+
+**Admin can manage event types with colors and icons:**
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ Event Types │
+├──────────────────────────────────────────────────────────────────┤
+│ │
+│ ┌─────────────┬───────────────┬────────────┬────────┬────────┐ │
+│ │ Name │ Display │ Color │ Icon │Actions │ │
+│ ├─────────────┼───────────────┼────────────┼────────┼────────┤ │
+│ │ social │ Social Event │ 🟢 #10b981 │ 🎉 │ ✏️ 🗑️ │ │
+│ │ meeting │ Meeting │ 🔵 #6366f1 │ 👥 │ ✏️ 🗑️ │ │
+│ │ fundraiser │ Fundraiser │ 🟠 #f59e0b │ 💝 │ ✏️ 🗑️ │ │
+│ │ workshop │ Workshop │ 🟣 #8b5cf6 │ 🎓 │ ✏️ 🗑️ │ │
+│ │ gala │ Gala/Formal │ 🌸 #ec4899 │ ✨ │ ✏️ 🗑️ │ │
+│ │ other │ Other │ ⚫ #6b7280 │ 📅 │ ✏️ 🗑️ │ │
+│ └─────────────┴───────────────┴────────────┴────────┴────────┘ │
+│ │
+│ [+ Add Event Type] │
+│ │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+### 6.7 Document Categories Settings
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ Document Categories │
+├──────────────────────────────────────────────────────────────────┤
+│ │
+│ ┌─────────────────┬─────────────────────┬────────┬────────────┐ │
+│ │ Name │ Display │ Icon │ Actions │ │
+│ ├─────────────────┼─────────────────────┼────────┼────────────┤ │
+│ │ meeting_minutes │ Meeting Minutes │ 📄 │ ✏️ 🗑️ │ │
+│ │ governance │ Governance & Bylaws │ ⚖️ │ ✏️ 🗑️ │ │
+│ │ legal │ Legal Documents │ 💼 │ ✏️ 🗑️ │ │
+│ │ financial │ Financial Reports │ 💰 │ ✏️ 🗑️ │ │
+│ │ member_resources│ Member Resources │ 📚 │ ✏️ 🗑️ │ │
+│ │ forms │ Forms & Templates │ 📋 │ ✏️ 🗑️ │ │
+│ │ other │ Other Documents │ 📁 │ ✏️ 🗑️ │ │
+│ └─────────────────┴─────────────────────┴────────┴────────────┘ │
+│ │
+│ [+ Add Category] │
+│ │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+### 6.8 Directory Visibility Settings
+
+**Admin controls what fields are visible to different roles:**
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ Directory Visibility │
+├──────────────────────────────────────────────────────────────────┤
+│ │
+│ Configure which member fields are visible in the directory. │
+│ Admin always sees all fields. │
+│ │
+│ ┌─────────────────┬──────────────────┬──────────────────┐ │
+│ │ Field │ Visible to │ Visible to │ │
+│ │ │ Members │ Board │ │
+│ ├─────────────────┼──────────────────┼──────────────────┤ │
+│ │ First Name │ ☑️ Always shown │ ☑️ Always shown │ │
+│ │ Last Name │ ☑️ Always shown │ ☑️ Always shown │ │
+│ │ Profile Photo │ ☑️ │ ☑️ │ │
+│ │ Nationality │ ☑️ │ ☑️ │ │
+│ │ Email │ ☐ │ ☑️ │ │
+│ │ Phone │ ☐ │ ☑️ │ │
+│ │ Address │ ☐ │ ☑️ │ │
+│ │ Date of Birth │ ☐ │ ☑️ │ │
+│ │ Member Since │ ☑️ │ ☑️ │ │
+│ │ Status │ ☐ │ ☑️ │ │
+│ │ Membership Type │ ☐ │ ☑️ │ │
+│ └─────────────────┴──────────────────┴──────────────────┘ │
+│ │
+│ [Save Visibility Settings] │
+│ │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+### 6.9 System Settings Tab
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ System Settings │
+├──────────────────────────────────────────────────────────────────┤
+│ │
+│ MAINTENANCE │
+│ ───────────────────────────────────────────────────────────── │
+│ │
+│ ☐ Enable Maintenance Mode │
+│ │
+│ Maintenance Message: │
+│ ┌──────────────────────────────────────────────────────────┐ │
+│ │ The portal is currently undergoing maintenance. │ │
+│ │ Please check back soon. │ │
+│ └──────────────────────────────────────────────────────────┘ │
+│ │
+│ ───────────────────────────────────────────────────────────── │
+│ │
+│ SECURITY │
+│ ───────────────────────────────────────────────────────────── │
+│ │
+│ Session Timeout (hours): │
+│ ┌────────────┐ │
+│ │ 168 │ (7 days) │
+│ └────────────┘ │
+│ │
+│ ───────────────────────────────────────────────────────────── │
+│ │
+│ FILE UPLOADS │
+│ ───────────────────────────────────────────────────────────── │
+│ │
+│ Max Upload Size (MB): │
+│ ┌────────────┐ │
+│ │ 50 │ │
+│ └────────────┘ │
+│ │
+│ Allowed File Types: │
+│ [PDF] [DOC] [DOCX] [XLS] [XLSX] [PPT] [PPTX] │
+│ [TXT] [JPG] [PNG] [WEBP] [+ Add Type] │
+│ │
+│ [Save System Settings] │
+│ │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+### 6.10 Settings Access Pattern
+
+```typescript
+// src/lib/server/settings.ts
+
+// Get a single setting with type safety
+export async function getSetting(
+ supabase: SupabaseClient,
+ category: string,
+ key: string,
+ defaultValue: T
+): Promise {
+ const { data } = await supabase
+ .from('app_settings')
+ .select('setting_value')
+ .eq('category', category)
+ .eq('setting_key', key)
+ .single();
+
+ return data?.setting_value ?? defaultValue;
+}
+
+// Get all settings for a category
+export async function getCategorySettings(
+ supabase: SupabaseClient,
+ category: string
+): Promise> {
+ const { data } = await supabase
+ .from('app_settings')
+ .select('setting_key, setting_value')
+ .eq('category', category);
+
+ return Object.fromEntries(
+ (data ?? []).map(s => [s.setting_key, s.setting_value])
+ );
+}
+
+// Update a setting (admin only)
+export async function updateSetting(
+ supabase: SupabaseClient,
+ category: string,
+ key: string,
+ value: any,
+ userId: string
+): Promise {
+ await supabase
+ .from('app_settings')
+ .update({
+ setting_value: value,
+ updated_at: new Date().toISOString(),
+ updated_by: userId
+ })
+ .eq('category', category)
+ .eq('setting_key', key);
+}
+```
+
+### 6.11 Settings Permissions
+
+| Action | Member | Board | Admin |
+|--------|--------|-------|-------|
+| View public settings | ✓ | ✓ | ✓ |
+| View all settings | - | - | ✓ |
+| Edit settings | - | - | ✓ |
+| Manage statuses | - | - | ✓ |
+| Manage membership types | - | - | ✓ |
+| Manage event types | - | - | ✓ |
+| Manage document categories | - | - | ✓ |
+| View settings audit log | - | - | ✓ |
+
+---
+
+## 7. EMAIL SYSTEM (Detailed)
+
+### 7.1 Email Architecture
+
+**Provider**: Supabase Edge Functions + external SMTP (Resend, SendGrid, or Mailgun)
+
+**Why external SMTP:**
+- Supabase built-in email is limited to auth emails only
+- External SMTP provides better deliverability, tracking, and templates
+- Resend recommended for simplicity and modern API
+
+### 7.2 Email Provider Configuration
+
+```sql
+-- Email settings (stored in app_settings)
+INSERT INTO app_settings (category, setting_key, setting_value, setting_type, display_name, description) VALUES
+ ('email', 'provider', '"resend"', 'text', 'Email Provider', 'Email service provider (resend, sendgrid, mailgun)'),
+ ('email', 'api_key', '""', 'text', 'API Key', 'Email provider API key (stored securely)'),
+ ('email', 'from_address', '"noreply@monacousa.org"', 'text', 'From Address', 'Default sender email address'),
+ ('email', 'from_name', '"Monaco USA"', 'text', 'From Name', 'Default sender name'),
+ ('email', 'reply_to', '"contact@monacousa.org"', 'text', 'Reply-To Address', 'Reply-to email address'),
+ ('email', 'enable_tracking', 'true', 'boolean', 'Enable Tracking', 'Track email opens and clicks'),
+ ('email', 'batch_size', '50', 'number', 'Batch Size', 'Max emails per batch send'),
+ ('email', 'rate_limit_per_hour', '100', 'number', 'Rate Limit', 'Maximum emails per hour');
+```
+
+### 7.3 Email Types & Triggers
+
+| Email Type | Trigger | Recipients | Automated |
+|------------|---------|------------|-----------|
+| `welcome` | New signup verified | New member | Yes |
+| `email_verification` | Signup | New member | Yes (Supabase) |
+| `password_reset` | Password reset request | Member | Yes (Supabase) |
+| `dues_reminder` | X days before due | Member | Yes (cron) |
+| `dues_due_today` | Due date | Member | Yes (cron) |
+| `dues_overdue` | Every X days overdue | Member | Yes (cron) |
+| `dues_lapsed` | Grace period ends | Member | Yes (cron) |
+| `dues_received` | Payment recorded | Member | Yes |
+| `event_created` | New event published | All/visibility | Optional |
+| `event_reminder` | X hours before event | RSVP'd members | Yes (cron) |
+| `event_updated` | Event details changed | RSVP'd members | Yes |
+| `event_cancelled` | Event cancelled | RSVP'd members | Yes |
+| `rsvp_confirmation` | RSVP submitted | Member | Yes |
+| `waitlist_promoted` | Spot opens up | Waitlisted member | Yes |
+| `member_invite` | Admin invites member | Invitee | Manual |
+| `broadcast` | Admin sends message | Selected members | Manual |
+
+### 7.4 Email Templates Schema
+
+```sql
+CREATE TABLE public.email_templates (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ -- Template identification
+ template_key TEXT UNIQUE NOT NULL, -- 'dues_reminder', 'welcome', etc.
+ template_name TEXT NOT NULL, -- 'Dues Reminder Email'
+ category TEXT NOT NULL, -- 'dues', 'events', 'system'
+
+ -- Template content
+ subject TEXT NOT NULL, -- Subject line with {{variables}}
+ body_html TEXT NOT NULL, -- HTML body with {{variables}}
+ body_text TEXT, -- Plain text fallback
+
+ -- Settings
+ is_active BOOLEAN DEFAULT TRUE,
+ is_system BOOLEAN DEFAULT FALSE, -- System templates can't be deleted
+
+ -- Metadata
+ variables_schema JSONB, -- Available variables documentation
+ preview_data JSONB, -- Sample data for preview
+
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_by UUID REFERENCES public.members(id)
+);
+
+-- Default email templates
+INSERT INTO email_templates (template_key, template_name, category, subject, body_html, is_system, variables_schema) VALUES
+
+-- Welcome Email
+('welcome', 'Welcome Email', 'system',
+ 'Welcome to Monaco USA, {{member_name}}!',
+ '
+
+
+
+
+
+
Welcome to Monaco USA!
+
Dear {{member_name}},
+
Thank you for joining Monaco USA! Your Member ID is {{member_id}} .
+
To complete your membership, please pay your annual dues of €{{dues_amount}} .
+
Payment Details:
+
+ Bank: {{bank_name}}
+ IBAN: {{iban}}
+ Account Holder: {{account_holder}}
+ Reference: {{member_id}}
+
+
Access Your Portal
+
Best regards, Monaco USA Team
+
+
+',
+ TRUE,
+ '{"member_name":"string","member_id":"string","dues_amount":"number","bank_name":"string","iban":"string","account_holder":"string","portal_link":"string","logo_url":"string"}'
+),
+
+-- Dues Reminder
+('dues_reminder', 'Dues Reminder', 'dues',
+ 'Your Monaco USA dues are due in {{days_until_due}} days',
+ '
+
+
+
+
Dues Reminder
+
Dear {{member_name}},
+
This is a friendly reminder that your Monaco USA membership dues of €{{dues_amount}} are due on {{due_date}} ({{days_until_due}} days from now).
+
Payment Details:
+
+ IBAN: {{iban}}
+ Account Holder: {{account_holder}}
+ Reference: {{member_id}}
+
+
View Payment Details
+
Thank you for being a valued member!
+
+
+',
+ TRUE,
+ '{"member_name":"string","member_id":"string","dues_amount":"number","due_date":"date","days_until_due":"number","iban":"string","account_holder":"string","portal_link":"string"}'
+),
+
+-- Dues Overdue
+('dues_overdue', 'Dues Overdue Notice', 'dues',
+ 'OVERDUE: Your Monaco USA dues are {{days_overdue}} days past due',
+ '
+
+
+
+
Payment Overdue
+
Dear {{member_name}},
+
Your Monaco USA membership dues of €{{dues_amount}} are now {{days_overdue}} days overdue .
+
Please make your payment as soon as possible to maintain your membership benefits.
+ {{#if grace_period_remaining}}
+
Note: You have {{grace_period_remaining}} days remaining in your grace period before your membership is set to inactive.
+ {{/if}}
+
Payment Details:
+
+ IBAN: {{iban}}
+ Account Holder: {{account_holder}}
+ Reference: {{member_id}}
+
+
Pay Now
+
+
+',
+ TRUE,
+ '{"member_name":"string","member_id":"string","dues_amount":"number","days_overdue":"number","grace_period_remaining":"number","iban":"string","account_holder":"string","portal_link":"string"}'
+),
+
+-- Dues Received
+('dues_received', 'Payment Confirmation', 'dues',
+ 'Thank you! Your Monaco USA dues payment has been received',
+ '
+
+
+
+
Payment Received!
+
Dear {{member_name}},
+
Thank you! We have received your membership dues payment.
+
Payment Details:
+
+ Amount: €{{amount_paid}}
+ Payment Date: {{payment_date}}
+ Next Due Date: {{next_due_date}}
+ Reference: {{payment_reference}}
+
+
Your membership is now active until {{next_due_date}}.
+
View Payment History
+
+
+',
+ TRUE,
+ '{"member_name":"string","amount_paid":"number","payment_date":"date","next_due_date":"date","payment_reference":"string","portal_link":"string"}'
+),
+
+-- Event RSVP Confirmation
+('rsvp_confirmation', 'RSVP Confirmation', 'events',
+ 'You''re registered: {{event_title}}',
+ '
+
+
+
+
You''re Registered!
+
Dear {{member_name}},
+
Your RSVP for {{event_title}} has been confirmed.
+
Event Details:
+
+ Date: {{event_date}}
+ Time: {{event_time}}
+ Location: {{event_location}}
+ {{#if guest_count}}Additional Guests: {{guest_count}} {{/if}}
+
+ {{#if is_paid}}
+
Payment Required:
+
Total: €{{total_amount}}
+
+ IBAN: {{iban}}
+ Reference: {{payment_reference}}
+
+ {{/if}}
+
View Event
+
+
+',
+ TRUE,
+ '{"member_name":"string","event_title":"string","event_date":"date","event_time":"string","event_location":"string","guest_count":"number","is_paid":"boolean","total_amount":"number","iban":"string","payment_reference":"string","event_id":"string","portal_link":"string"}'
+),
+
+-- Event Reminder
+('event_reminder', 'Event Reminder', 'events',
+ 'Reminder: {{event_title}} is {{time_until_event}}',
+ '
+
+
+
+
Event Reminder
+
Dear {{member_name}},
+
This is a reminder that {{event_title}} is {{time_until_event}}.
+
Event Details:
+
+ Date: {{event_date}}
+ Time: {{event_time}}
+ Location: {{event_location}}
+
+ {{#if event_description}}
+
{{event_description}}
+ {{/if}}
+
We look forward to seeing you there!
+
+
+',
+ TRUE,
+ '{"member_name":"string","event_title":"string","event_date":"date","event_time":"string","event_location":"string","event_description":"string","time_until_event":"string"}'
+);
+```
+
+### 7.5 Email Logging Schema
+
+```sql
+-- Enhanced email logs with tracking
+CREATE TABLE public.email_logs (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ -- Recipients
+ recipient_id UUID REFERENCES public.members(id),
+ recipient_email TEXT NOT NULL,
+ recipient_name TEXT,
+
+ -- Email details
+ template_key TEXT REFERENCES public.email_templates(template_key),
+ subject TEXT NOT NULL,
+ email_type TEXT NOT NULL,
+
+ -- Status tracking
+ status TEXT NOT NULL DEFAULT 'queued'
+ CHECK (status IN ('queued', 'sent', 'delivered', 'opened', 'clicked', 'bounced', 'failed')),
+
+ -- Provider data
+ provider TEXT, -- 'resend', 'sendgrid', etc.
+ provider_message_id TEXT, -- External message ID for tracking
+
+ -- Engagement tracking
+ opened_at TIMESTAMPTZ,
+ clicked_at TIMESTAMPTZ,
+
+ -- Error handling
+ error_message TEXT,
+ retry_count INTEGER DEFAULT 0,
+
+ -- Metadata
+ template_variables JSONB, -- Variables used in template
+ sent_by UUID REFERENCES public.members(id), -- For manual sends
+
+ -- Timestamps
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ sent_at TIMESTAMPTZ,
+ delivered_at TIMESTAMPTZ
+);
+
+-- Index for common queries
+CREATE INDEX idx_email_logs_recipient ON public.email_logs(recipient_id);
+CREATE INDEX idx_email_logs_status ON public.email_logs(status);
+CREATE INDEX idx_email_logs_type ON public.email_logs(email_type);
+CREATE INDEX idx_email_logs_created ON public.email_logs(created_at DESC);
+```
+
+### 7.6 Automated Email Scheduler (Supabase Edge Function)
+
+```typescript
+// supabase/functions/email-scheduler/index.ts
+
+import { createClient } from '@supabase/supabase-js';
+import { Resend } from 'resend';
+
+// Runs daily via pg_cron
+Deno.serve(async (req) => {
+ const supabase = createClient(
+ Deno.env.get('SUPABASE_URL')!,
+ Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
+ );
+
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+
+ // 1. Get settings
+ const settings = await getSettings(supabase);
+ const reminderDays = settings.reminder_days_before as number[];
+ const gracePeriod = settings.grace_period_days as number;
+
+ // 2. Find members needing reminders
+ const { data: membersWithDues } = await supabase
+ .from('members_with_dues')
+ .select('*')
+ .in('dues_status', ['current', 'due_soon', 'overdue']);
+
+ for (const member of membersWithDues || []) {
+ const dueDate = new Date(member.current_due_date);
+ const daysUntil = Math.ceil((dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
+ const daysOverdue = member.days_overdue || 0;
+
+ // Check if we need to send a reminder
+ if (daysUntil > 0 && reminderDays.includes(daysUntil)) {
+ // Send upcoming reminder
+ await sendEmail(supabase, 'dues_reminder', member, {
+ days_until_due: daysUntil
+ });
+ } else if (daysUntil === 0) {
+ // Due today
+ await sendEmail(supabase, 'dues_due_today', member, {});
+ } else if (daysOverdue > 0 && daysOverdue <= gracePeriod) {
+ // Overdue but in grace period
+ if (daysOverdue % settings.overdue_reminder_interval === 0) {
+ await sendEmail(supabase, 'dues_overdue', member, {
+ days_overdue: daysOverdue,
+ grace_period_remaining: gracePeriod - daysOverdue
+ });
+ }
+ } else if (daysOverdue === gracePeriod + 1) {
+ // Grace period just ended
+ await sendEmail(supabase, 'dues_lapsed', member, {});
+ }
+ }
+
+ // 3. Send event reminders
+ await sendEventReminders(supabase, settings);
+
+ return new Response(JSON.stringify({ success: true }), {
+ headers: { 'Content-Type': 'application/json' }
+ });
+});
+
+async function sendEmail(
+ supabase: any,
+ templateKey: string,
+ member: any,
+ extraVariables: Record
+) {
+ // Get template
+ const { data: template } = await supabase
+ .from('email_templates')
+ .select('*')
+ .eq('template_key', templateKey)
+ .eq('is_active', true)
+ .single();
+
+ if (!template) return;
+
+ // Build variables
+ const variables = {
+ member_name: `${member.first_name} ${member.last_name}`,
+ member_id: member.member_id,
+ dues_amount: member.annual_dues || 50,
+ due_date: member.current_due_date,
+ portal_link: Deno.env.get('PORTAL_URL'),
+ ...extraVariables
+ };
+
+ // Add payment settings
+ const settings = await getSettings(supabase);
+ variables.iban = settings.payment_iban;
+ variables.account_holder = settings.payment_account_holder;
+ variables.bank_name = settings.payment_bank_name;
+
+ // Render template
+ const subject = renderTemplate(template.subject, variables);
+ const html = renderTemplate(template.body_html, variables);
+
+ // Send via provider
+ const resend = new Resend(Deno.env.get('RESEND_API_KEY'));
+ const result = await resend.emails.send({
+ from: `${settings.from_name} <${settings.from_address}>`,
+ to: member.email,
+ subject,
+ html
+ });
+
+ // Log email
+ await supabase.from('email_logs').insert({
+ recipient_id: member.id,
+ recipient_email: member.email,
+ recipient_name: variables.member_name,
+ template_key: templateKey,
+ subject,
+ email_type: templateKey,
+ status: result.error ? 'failed' : 'sent',
+ provider: 'resend',
+ provider_message_id: result.data?.id,
+ error_message: result.error?.message,
+ template_variables: variables,
+ sent_at: new Date().toISOString()
+ });
+}
+```
+
+### 7.7 Email Settings UI (Admin)
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ Email Settings │
+├──────────────────────────────────────────────────────────────────┤
+│ │
+│ PROVIDER CONFIGURATION │
+│ ───────────────────────────────────────────────────────────── │
+│ │
+│ Email Provider: │
+│ ┌────────────────────────────────────────┐ │
+│ │ Resend ▼ │ │
+│ └────────────────────────────────────────┘ │
+│ │
+│ API Key: │
+│ ┌────────────────────────────────────────┐ │
+│ │ re_••••••••••••••• │ [Test Connection] │
+│ └────────────────────────────────────────┘ │
+│ │
+│ From Address: │
+│ ┌────────────────────────────────────────┐ │
+│ │ noreply@monacousa.org │ │
+│ └────────────────────────────────────────┘ │
+│ │
+│ From Name: │
+│ ┌────────────────────────────────────────┐ │
+│ │ Monaco USA │ │
+│ └────────────────────────────────────────┘ │
+│ │
+│ Reply-To: │
+│ ┌────────────────────────────────────────┐ │
+│ │ contact@monacousa.org │ │
+│ └────────────────────────────────────────┘ │
+│ │
+│ ───────────────────────────────────────────────────────────── │
+│ │
+│ TRACKING │
+│ ───────────────────────────────────────────────────────────── │
+│ │
+│ ☑️ Enable open tracking │
+│ ☑️ Enable click tracking │
+│ │
+│ [Save Email Settings] │
+│ │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+### 7.8 Email Templates Editor (Admin)
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ Email Templates │
+├──────────────────────────────────────────────────────────────────┤
+│ │
+│ ┌───────────────────┬────────────────┬─────────┬──────────────┐ │
+│ │ Template │ Category │ Active │ Actions │ │
+│ ├───────────────────┼────────────────┼─────────┼──────────────┤ │
+│ │ Welcome Email │ System │ ✓ │ ✏️ 👁️ 📧 │ │
+│ │ Dues Reminder │ Dues │ ✓ │ ✏️ 👁️ 📧 │ │
+│ │ Dues Due Today │ Dues │ ✓ │ ✏️ 👁️ 📧 │ │
+│ │ Dues Overdue │ Dues │ ✓ │ ✏️ 👁️ 📧 │ │
+│ │ Dues Lapsed │ Dues │ ✓ │ ✏️ 👁️ 📧 │ │
+│ │ Dues Received │ Dues │ ✓ │ ✏️ 👁️ 📧 │ │
+│ │ RSVP Confirmation │ Events │ ✓ │ ✏️ 👁️ 📧 │ │
+│ │ Event Reminder │ Events │ ✓ │ ✏️ 👁️ 📧 │ │
+│ │ Event Cancelled │ Events │ ✓ │ ✏️ 👁️ 📧 │ │
+│ │ Waitlist Promoted │ Events │ ✓ │ ✏️ 👁️ 📧 │ │
+│ └───────────────────┴────────────────┴─────────┴──────────────┘ │
+│ │
+│ Legend: ✏️ Edit | 👁️ Preview | 📧 Send Test │
+│ │
+│ [+ Create Custom Template] │
+│ │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+### 7.9 Template Editor View
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ Edit Template: Dues Reminder │
+├──────────────────────────────────────────────────────────────────┤
+│ │
+│ Template Name: │
+│ ┌──────────────────────────────────────────────────────────┐ │
+│ │ Dues Reminder │ │
+│ └──────────────────────────────────────────────────────────┘ │
+│ │
+│ Subject Line: │
+│ ┌──────────────────────────────────────────────────────────┐ │
+│ │ Your Monaco USA dues are due in {{days_until_due}} days │ │
+│ └──────────────────────────────────────────────────────────┘ │
+│ │
+│ ┌──────────────────────┬───────────────────────────────────┐ │
+│ │ HTML Editor │ Preview │ │
+│ ├──────────────────────┼───────────────────────────────────┤ │
+│ │ Dues Reminder Dear {{member_nam │ ┌─────────────────────────────┐ │ │
+│ │ e}},
│ │ Dues Reminder │ │ │
+│ │ This is a friendl │ │ │ │ │
+│ │ y reminder that your │ │ Dear John Doe, │ │ │
+│ │ Monaco USA membershi │ │ │ │ │
+│ │ p dues...
│ │ This is a friendly │ │ │
+│ │ ... │ │ reminder that your Monaco │ │ │
+│ │ │ │ USA membership dues of │ │ │
+│ │ │ │ €50.00 are due on... │ │ │
+│ │ │ └─────────────────────────────┘ │ │
+│ └──────────────────────┴───────────────────────────────────┘ │
+│ │
+│ AVAILABLE VARIABLES: │
+│ ┌──────────────────────────────────────────────────────────┐ │
+│ │ {{member_name}} {{member_id}} {{dues_amount}} {{due_date}}│ │
+│ │ {{days_until_due}} {{iban}} {{account_holder}} │ │
+│ │ {{portal_link}} │ │
+│ └──────────────────────────────────────────────────────────┘ │
+│ │
+│ [Cancel] [Send Test Email] [Save Changes] │
+│ │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+### 7.10 Email Logs View (Admin)
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ Email Logs │
+├──────────────────────────────────────────────────────────────────┤
+│ │
+│ Filter: [All Types ▼] [All Status ▼] [Last 30 days ▼] [Search] │
+│ │
+│ ┌────────┬────────────────┬──────────────┬────────┬──────────┐ │
+│ │ Date │ Recipient │ Subject │ Type │ Status │ │
+│ ├────────┼────────────────┼──────────────┼────────┼──────────┤ │
+│ │ Jan 9 │ john@email.com │ Your Monaco │ dues_ │ 📬 Opened │ │
+│ │ 14:30 │ John Doe │ USA dues... │ remind │ │ │
+│ ├────────┼────────────────┼──────────────┼────────┼──────────┤ │
+│ │ Jan 9 │ jane@email.com │ You're regis │ rsvp_ │ ✅ Sent │ │
+│ │ 10:15 │ Jane Smith │ tered: Gala │ conf │ │ │
+│ ├────────┼────────────────┼──────────────┼────────┼──────────┤ │
+│ │ Jan 8 │ bob@email.com │ OVERDUE: You │ dues_ │ 🔴 Bounce │ │
+│ │ 09:00 │ Bob Wilson │ r Monaco... │ overdu │ │ │
+│ └────────┴────────────────┴──────────────┴────────┴──────────┘ │
+│ │
+│ Status Legend: │
+│ ✅ Sent | 📬 Opened | 🔗 Clicked | 🔴 Bounced | ❌ Failed │
+│ │
+│ Stats: 156 sent this month | 78% open rate | 2 bounces │
+│ │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+### 7.11 Manual Broadcast Feature (Admin)
+
+**Admin can send broadcast emails to selected members:**
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ Send Broadcast Email │
+├──────────────────────────────────────────────────────────────────┤
+│ │
+│ Recipients: │
+│ ○ All active members (45) │
+│ ○ All members (52) │
+│ ○ Board members only (5) │
+│ ● Select specific members │
+│ │
+│ ┌──────────────────────────────────────────────────────────┐ │
+│ │ 🔍 Search members... │ │
+│ ├──────────────────────────────────────────────────────────┤ │
+│ │ ☑️ John Doe (john@email.com) │ │
+│ │ ☑️ Jane Smith (jane@email.com) │ │
+│ │ ☐ Bob Wilson (bob@email.com) │ │
+│ │ ... │ │
+│ └──────────────────────────────────────────────────────────┘ │
+│ Selected: 2 members │
+│ │
+│ Subject: │
+│ ┌──────────────────────────────────────────────────────────┐ │
+│ │ Important Update from Monaco USA │ │
+│ └──────────────────────────────────────────────────────────┘ │
+│ │
+│ Message: │
+│ ┌──────────────────────────────────────────────────────────┐ │
+│ │ [Rich text editor with formatting options] │ │
+│ │ │ │
+│ │ Dear {{member_name}}, │ │
+│ │ │ │
+│ │ We wanted to inform you about... │ │
+│ │ │ │
+│ └──────────────────────────────────────────────────────────┘ │
+│ │
+│ [Preview] [Send Test to Myself] [Send to 2 Recipients] │
+│ │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+### 7.12 Email Cron Jobs (pg_cron in Supabase)
+
+```sql
+-- Enable pg_cron extension
+CREATE EXTENSION IF NOT EXISTS pg_cron;
+
+-- Schedule daily email checks (runs at 9 AM Monaco time)
+SELECT cron.schedule(
+ 'daily-email-scheduler',
+ '0 9 * * *', -- Every day at 9:00 AM
+ $$
+ SELECT net.http_post(
+ url := 'https://your-project.supabase.co/functions/v1/email-scheduler',
+ headers := '{"Authorization": "Bearer ' || current_setting('app.service_role_key') || '"}'::jsonb,
+ body := '{}'::jsonb
+ );
+ $$
+);
+
+-- Schedule event reminders (runs every hour)
+SELECT cron.schedule(
+ 'hourly-event-reminders',
+ '0 * * * *', -- Every hour
+ $$
+ SELECT net.http_post(
+ url := 'https://your-project.supabase.co/functions/v1/event-reminders',
+ headers := '{"Authorization": "Bearer ' || current_setting('app.service_role_key') || '"}'::jsonb,
+ body := '{}'::jsonb
+ );
+ $$
+);
+```
+
+### 7.13 Email Permissions
+
+| Action | Member | Board | Admin |
+|--------|--------|-------|-------|
+| Receive automated emails | ✓ | ✓ | ✓ |
+| View own email history | ✓ | ✓ | ✓ |
+| View all email logs | - | - | ✓ |
+| Edit email templates | - | - | ✓ |
+| Send broadcast emails | - | - | ✓ |
+| Send manual reminders | - | ✓ | ✓ |
+| Configure email settings | - | - | ✓ |
+| Test email connection | - | - | ✓ |
+
+---
+
+## Phase 1: Current System Analysis (COMPLETED)
+
+### Current Tech Stack
+| Layer | Technology |
+|-------|------------|
+| Framework | Nuxt 3 (Vue 3) - SSR disabled, CSR-only |
+| UI Components | Vuetify 3 + Tailwind CSS + Custom SCSS |
+| Database | NocoDB (REST API over PostgreSQL) |
+| Authentication | Keycloak (OAuth2/OIDC) |
+| File Storage | MinIO (S3-compatible) |
+| Email | Nodemailer + Handlebars templates |
+| State | Vue Composition API (no Pinia) |
+
+### Current Features Inventory
+
+#### 1. Member Management
+- **Data Model**: Member with 20+ fields (name, email, phone, DOB, nationality, address, etc.)
+- **Member ID Format**: `MUSA-YYYY-XXXX` (auto-generated)
+- **Status States**: Active, Inactive, Pending, Expired
+- **Portal Tiers**: admin, board, user
+- **Profile Images**: Stored in MinIO
+
+#### 2. Dues/Subscription System
+- **Calculation**: Due 1 year after last payment
+- **Fields Tracked**: `membership_date_paid`, `payment_due_date`, `current_year_dues_paid`
+- **Reminders**: 30-day advance warning, overdue notifications
+- **Auto-Status**: Members 1+ year overdue marked Inactive
+- **Rates**: €50 regular, €25 student, €35 senior, €75 family, €200 corporate
+
+#### 3. Events System
+- **Event Types**: meeting, social, fundraiser, workshop, board-only
+- **RSVP System**: confirmed, declined, pending, waitlist
+- **Guest Management**: Extra guests per RSVP
+- **Pricing**: Member vs non-member pricing
+- **Visibility**: public, board-only, admin-only
+- **Calendar**: FullCalendar integration with iCal feed
+
+#### 4. Authentication & Authorization
+- **Login Methods**: OAuth (Keycloak) + Direct login (ROPC)
+- **Role System**: Keycloak realm roles (monaco-admin, monaco-board, monaco-user)
+- **Session**: Server-side with HTTP-only cookies (7-30 days)
+- **Rate Limiting**: 5 attempts/15min, 1-hour IP block
+- **Signup Flow**: Form → reCAPTCHA → Keycloak user → NocoDB member → Verification email
+
+#### 5. Three Dashboard Types
+- **Admin**: Full system control, user management, settings, all features
+- **Board**: Member directory, dues management, events, meetings, governance
+- **Member**: Personal profile, events, payments, resources
+
+### Current Pain Points (to address in rebuild)
+1. CSR-only limits SEO and initial load performance
+2. NocoDB adds complexity vs direct database access
+3. String booleans ("true"/"false") cause type issues
+4. No payment history table (only last payment tracked)
+5. Vuetify + Tailwind overlap creates CSS conflicts
+6. Large monolithic layout files (700-800+ lines each)
+
+---
+
+## Phase 2: New System Requirements
+
+### Core Requirements (confirmed)
+- [x] Beautiful, modern, responsive frontend (not generic Vue look)
+- [x] Member tracking with subscription/dues management
+- [x] Dues due 1 year after last payment
+- [x] Event calendar with RSVP system
+- [x] Board members can create/manage events
+- [x] Event features: capacity, +1 guests, member/non-member pricing
+- [x] Three dashboard types: Admin, Board, Member
+- [x] Signup system similar to current
+- [x] **Manual payment tracking only** (no Stripe integration)
+- [x] **Email notifications** (dues reminders, event updates)
+- [x] **Document storage** (meeting minutes, governance docs)
+
+### Deployment Strategy
+- **Replace entirely** - Switch over when ready (no parallel systems)
+- **Manual data entry** - Members will be entered manually (no migration scripts)
+
+---
+
+## FRONTEND FRAMEWORK OPTIONS
+
+### Tier 1: Modern & Distinctive (Recommended)
+
+#### 1. **Qwik + QwikCity** ⭐ MOST INNOVATIVE
+| Aspect | Details |
+|--------|---------|
+| **What it is** | Resumable framework - HTML loads instantly, JS loads on-demand |
+| **Unique Feature** | Zero hydration - fastest possible load times |
+| **Learning Curve** | Medium (JSX-like but different mental model) |
+| **Ecosystem** | Growing - Auth.js, Drizzle, Modular Forms integrations |
+| **Benchmark Score** | 93.8 (highest among all frameworks) |
+
+**Pros:**
+- Blazing fast initial load (no hydration delay)
+- Feels like React/JSX but with better performance model
+- Built-in form handling with Zod validation
+- Server functions with `"use server"` directive
+- Excellent TypeScript support
+- Unique - won't look like every other site
+
+**Cons:**
+- Smaller community than React/Vue
+- Fewer pre-built component libraries
+- Newer framework (less battle-tested in production)
+- Some patterns feel unfamiliar at first
+
+**Best For:** Performance-focused apps where first impression matters
+
+---
+
+#### 2. **SolidStart (Solid.js)** ⭐ MOST PERFORMANT REACTIVITY
+| Aspect | Details |
+|--------|---------|
+| **What it is** | Fine-grained reactive framework with meta-framework |
+| **Unique Feature** | No Virtual DOM - direct DOM updates via signals |
+| **Learning Curve** | Medium (React-like JSX, different reactivity) |
+| **Ecosystem** | Good - Ark UI, Kobalte for components |
+| **Benchmark Score** | 92.2 |
+
+**Pros:**
+- Smallest bundle sizes in the industry
+- React-like syntax (easy transition)
+- True reactivity (no re-renders, just updates)
+- Server functions and data loading built-in
+- Growing rapidly in popularity
+- Unique performance characteristics
+
+**Cons:**
+- Smaller ecosystem than React
+- Fewer tutorials and resources
+- Some React patterns don't translate directly
+- Component libraries less mature
+
+**Best For:** Highly interactive dashboards with lots of real-time updates
+
+---
+
+#### 3. **SvelteKit** ⭐ BEST DEVELOPER EXPERIENCE
+| Aspect | Details |
+|--------|---------|
+| **What it is** | Compiler-based framework with full-stack capabilities |
+| **Unique Feature** | No virtual DOM, compiles to vanilla JS |
+| **Learning Curve** | Low (closest to vanilla HTML/CSS/JS) |
+| **Ecosystem** | Strong - Skeleton UI, Melt UI, Shadcn-Svelte |
+| **Benchmark Score** | 91.0 |
+
+**Pros:**
+- Simplest syntax - looks like enhanced HTML
+- Smallest learning curve
+- Excellent built-in animations/transitions
+- Strong TypeScript integration
+- Great form handling
+- Active, helpful community
+- Svelte 5 runes make state even simpler
+
+**Cons:**
+- Different mental model from React/Vue
+- Smaller job market (if that matters)
+- Some advanced patterns less documented
+- Breaking changes between Svelte 4 and 5
+
+**Best For:** Clean, maintainable code with minimal boilerplate
+
+---
+
+#### 4. **Astro + React/Vue/Svelte Islands** ⭐ MOST FLEXIBLE
+| Aspect | Details |
+|--------|---------|
+| **What it is** | Content-focused framework with "islands" of interactivity |
+| **Unique Feature** | Mix multiple frameworks, zero JS by default |
+| **Learning Curve** | Low-Medium |
+| **Ecosystem** | Excellent - use ANY UI library |
+| **Benchmark Score** | 90.2 |
+
+**Pros:**
+- Use React, Vue, Svelte, or Solid components together
+- Zero JavaScript shipped by default
+- Excellent for content + interactive sections
+- Built-in image optimization
+- Great Supabase integration documented
+- View Transitions API support
+
+**Cons:**
+- Not ideal for highly interactive SPAs
+- Island architecture adds complexity
+- More configuration for full interactivity
+- Less unified than single-framework approach
+
+**Best For:** Marketing site + member portal hybrid
+
+---
+
+### Tier 2: Battle-Tested Mainstream
+
+#### 5. **Next.js 15 (React)**
+| Aspect | Details |
+|--------|---------|
+| **What it is** | Most popular React meta-framework |
+| **Unique Feature** | App Router, Server Components, huge ecosystem |
+| **Learning Curve** | Medium-High (lots of concepts) |
+| **Ecosystem** | Largest - shadcn/ui, Radix, everything |
+| **Benchmark Score** | N/A (didn't query) |
+
+**Pros:**
+- Largest ecosystem and community
+- Most job opportunities
+- shadcn/ui provides beautiful, customizable components
+- Excellent documentation
+- Vercel hosting optimized
+
+**Cons:**
+- Can feel "generic" - many sites use it
+- Complex mental model (Server vs Client components)
+- Heavier than alternatives
+- Vercel-centric development
+
+---
+
+#### 6. **Remix**
+| Aspect | Details |
+|--------|---------|
+| **What it is** | Full-stack React framework focused on web standards |
+| **Unique Feature** | Nested routing, progressive enhancement |
+| **Learning Curve** | Medium |
+| **Ecosystem** | Good - React ecosystem compatible |
+| **Benchmark Score** | 89.4 |
+
+**Pros:**
+- Web standards focused (works without JS)
+- Excellent data loading patterns
+- Great error handling
+- Form handling is first-class
+- Can deploy anywhere (not Vercel-locked)
+
+**Cons:**
+- Smaller community than Next.js
+- Less "magic" means more manual work
+- Merged with React Router (transition period)
+
+---
+
+#### 7. **TanStack Start (React)**
+| Aspect | Details |
+|--------|---------|
+| **What it is** | New full-stack framework from TanStack team |
+| **Unique Feature** | Type-safe from database to UI |
+| **Learning Curve** | Medium |
+| **Ecosystem** | TanStack Query, Form, Router built-in |
+| **Benchmark Score** | 80.7 |
+
+**Pros:**
+- Built by TanStack (Query, Router, Form authors)
+- End-to-end type safety
+- Modern patterns throughout
+- Excellent data fetching built-in
+
+**Cons:**
+- Very new (beta/early stage)
+- Smaller community
+- Less documentation
+- Rapidly evolving API
+
+---
+
+#### 8. **Nuxt 4 (Vue 3)**
+| Aspect | Details |
+|--------|---------|
+| **What it is** | Latest Vue meta-framework |
+| **Unique Feature** | Familiar from current system |
+| **Learning Curve** | Low (you know it) |
+| **Ecosystem** | Good - Nuxt UI, PrimeVue |
+
+**Pros:**
+- Familiar - no learning curve
+- Can reuse some current code/patterns
+- Strong conventions
+- Good TypeScript support now
+
+**Cons:**
+- User specifically wants to avoid "generic Vue look"
+- Similar limitations to current system
+- Less innovative than alternatives
+
+---
+
+#### 9. **Angular 19**
+| Aspect | Details |
+|--------|---------|
+| **What it is** | Google's enterprise framework |
+| **Unique Feature** | Signals, standalone components, full framework |
+| **Learning Curve** | High |
+| **Ecosystem** | Enterprise-grade |
+| **Benchmark Score** | 90.3 |
+
+**Pros:**
+- Complete framework (no decisions to make)
+- Excellent for large applications
+- Strong typing throughout
+- Signals in Angular 19 are modern
+
+**Cons:**
+- Steeper learning curve
+- More verbose
+- "Enterprise" feel may not fit small org
+- Overkill for this scale
+
+---
+
+### Tier 3: Experimental/Niche
+
+#### 10. **Leptos (Rust)**
+| Aspect | Details |
+|--------|---------|
+| **What it is** | Full-stack Rust framework |
+| **Unique Feature** | WASM-based, extremely fast |
+| **Benchmark Score** | 89.7 |
+
+**Pros:**
+- Blazing fast (Rust + WASM)
+- Type safety at compile time
+- Innovative approach
+
+**Cons:**
+- Requires learning Rust
+- Small ecosystem
+- Harder to find developers
+- Overkill for this use case
+
+---
+
+#### 11. **Hono + HTMX**
+| Aspect | Details |
+|--------|---------|
+| **What it is** | Lightweight backend + hypermedia frontend |
+| **Unique Feature** | Server-rendered, minimal JS |
+| **Benchmark Score** | 92.8 |
+
+**Pros:**
+- Extremely lightweight
+- Simple mental model
+- Works on edge (Cloudflare Workers)
+- Fast development
+
+**Cons:**
+- Less rich interactivity
+- Different paradigm (hypermedia)
+- Limited complex UI patterns
+- Manual work for dashboards
+
+---
+
+## UI COMPONENT LIBRARY OPTIONS
+
+### For React-based Frameworks (Next.js, Remix, TanStack)
+
+| Library | Style | Customizable | Notes |
+|---------|-------|--------------|-------|
+| **shadcn/ui** | Modern, clean | Fully (copy/paste) | Most popular, highly customizable |
+| **Radix Themes** | Polished | Theme-based | Beautiful defaults, less work |
+| **Radix Primitives** | Unstyled | Fully | Build completely custom |
+| **Ark UI** | Unstyled | Fully | Works with multiple frameworks |
+| **Park UI** | Pre-styled Ark | Moderate | Ark + beautiful defaults |
+
+### For Solid.js
+
+| Library | Style | Notes |
+|---------|-------|-------|
+| **Kobalte** | Unstyled | Radix-like primitives for Solid |
+| **Ark UI Solid** | Unstyled | Same Ark, Solid version |
+| **Solid UI** | Various | Community components |
+
+### For Svelte
+
+| Library | Style | Notes |
+|---------|-------|-------|
+| **shadcn-svelte** | Modern | Port of shadcn for Svelte |
+| **Skeleton UI** | Tailwind | Full design system |
+| **Melt UI** | Unstyled | Primitives for Svelte |
+| **Bits UI** | Unstyled | Headless components |
+
+### For Qwik
+
+| Library | Style | Notes |
+|---------|-------|-------|
+| **Qwik UI** | Official | Growing component library |
+| **Custom + Tailwind** | Any | Build from scratch |
+
+### For Vue/Nuxt
+
+| Library | Style | Notes |
+|---------|-------|-------|
+| **shadcn-vue** | Modern | Port of shadcn for Vue |
+| **Radix Vue** | Unstyled | Radix primitives for Vue |
+| **Nuxt UI** | Tailwind | Official Nuxt components |
+| **PrimeVue** | Various | Comprehensive but generic |
+
+---
+
+## DATABASE OPTIONS - DETAILED COMPARISON
+
+### Option 1: **Supabase** ⭐ RECOMMENDED
+| Aspect | Details |
+|--------|---------|
+| **Type** | PostgreSQL + Auth + Storage + Realtime |
+| **Hosting** | Managed cloud or self-hosted |
+| **Pricing** | Free tier, then $25/mo |
+
+**Pros:**
+- All-in-one: Database + Auth + File Storage + Realtime
+- PostgreSQL (industry standard, powerful)
+- Row-level security built-in
+- Excellent TypeScript support
+- Auto-generated APIs
+- Real-time subscriptions
+- Built-in auth (replaces Keycloak)
+- Dashboard for data management
+- Can self-host if needed
+
+**Cons:**
+- Vendor lock-in (mitigated by self-host option)
+- Learning curve for RLS policies
+- Free tier has limits
+- Less control than raw PostgreSQL
+
+**Best For:** Rapid development with full-stack features
+
+---
+
+### Option 2: **PostgreSQL + Prisma**
+| Aspect | Details |
+|--------|---------|
+| **Type** | Direct database + Type-safe ORM |
+| **Hosting** | Any PostgreSQL host (Neon, Railway, etc.) |
+| **Pricing** | Database hosting costs only |
+
+**Pros:**
+- Full control over database
+- Prisma schema is very readable
+- Excellent TypeScript types
+- Migrations handled automatically
+- Works with any PostgreSQL
+- Large community
+
+**Cons:**
+- Need separate auth solution
+- Need separate file storage
+- More setup work
+- Prisma can be slow for complex queries
+
+**Best For:** Maximum control and flexibility
+
+---
+
+### Option 3: **PostgreSQL + Drizzle ORM**
+| Aspect | Details |
+|--------|---------|
+| **Type** | Direct database + Lightweight ORM |
+| **Hosting** | Any PostgreSQL host |
+| **Pricing** | Database hosting costs only |
+
+**Pros:**
+- Closer to SQL (less abstraction)
+- Faster than Prisma
+- Smaller bundle size
+- TypeScript-first
+- Better for complex queries
+- Growing rapidly
+
+**Cons:**
+- Newer, smaller community
+- Less documentation
+- Need separate auth/storage
+- More manual migration work
+
+**Best For:** Performance-critical apps, SQL-comfortable teams
+
+---
+
+### Option 4: **PlanetScale + Drizzle**
+| Aspect | Details |
+|--------|---------|
+| **Type** | Serverless MySQL |
+| **Hosting** | Managed cloud only |
+| **Pricing** | Free tier, then usage-based |
+
+**Pros:**
+- Serverless scaling
+- Branching (like git for databases)
+- No connection limits
+- Fast globally
+
+**Cons:**
+- MySQL not PostgreSQL
+- No foreign keys (by design)
+- Vendor lock-in
+- Can get expensive at scale
+
+**Best For:** Serverless deployments, edge functions
+
+---
+
+### Option 5: **Keep NocoDB**
+| Aspect | Details |
+|--------|---------|
+| **Type** | Spreadsheet-like interface over database |
+| **Hosting** | Self-hosted or cloud |
+
+**Pros:**
+- Already configured
+- Non-technical users can edit data
+- Flexible schema changes
+- API already exists
+
+**Cons:**
+- Adds complexity layer
+- String booleans issue
+- Less type safety
+- Performance overhead
+- Limited query capabilities
+
+**Best For:** Non-technical admin users need direct access
+
+---
+
+## AUTHENTICATION OPTIONS - DETAILED COMPARISON
+
+### Option 1: **Supabase Auth** ⭐ IF USING SUPABASE
+| Aspect | Details |
+|--------|---------|
+| **Type** | Built into Supabase |
+| **Providers** | Email, OAuth (Google, GitHub, etc.), Magic Link |
+
+**Pros:**
+- Integrated with Supabase (one platform)
+- Row-level security integration
+- Simple setup
+- Built-in user management
+- Social logins included
+- Magic link support
+
+**Cons:**
+- Tied to Supabase
+- Less customizable than Keycloak
+- No SAML/enterprise SSO on free tier
+
+---
+
+### Option 2: **Keep Keycloak**
+| Aspect | Details |
+|--------|---------|
+| **Type** | Self-hosted identity provider |
+| **Providers** | Everything (OIDC, SAML, social, etc.) |
+
+**Pros:**
+- Already configured and working
+- Enterprise-grade features
+- Full control
+- SAML support
+- Custom themes
+- User federation
+
+**Cons:**
+- Complex to maintain
+- Heavy resource usage
+- Overkill for small org
+- Requires Java expertise
+- Self-hosted burden
+
+---
+
+### Option 3: **Better Auth** ⭐ MODERN CHOICE
+| Aspect | Details |
+|--------|---------|
+| **Type** | Framework-agnostic TypeScript auth |
+| **Providers** | Email, OAuth, Magic Link, Passkeys |
+
+**Pros:**
+- Modern, TypeScript-first
+- Works with any framework
+- Plugin system for features
+- Session management built-in
+- Two-factor auth support
+- Lightweight
+
+**Cons:**
+- Newer (less battle-tested)
+- Self-implemented
+- Need own user storage
+
+---
+
+### Option 4: **Auth.js (NextAuth)**
+| Aspect | Details |
+|--------|---------|
+| **Type** | Framework-agnostic auth library |
+| **Providers** | 50+ OAuth providers |
+
+**Pros:**
+- Massive provider support
+- Well documented
+- Active development
+- Works with Qwik, SvelteKit, etc.
+
+**Cons:**
+- Complex configuration
+- Database adapter setup
+- v5 migration issues
+- Can be finicky
+
+---
+
+### Option 5: **Clerk**
+| Aspect | Details |
+|--------|---------|
+| **Type** | Auth-as-a-service |
+| **Providers** | Everything + beautiful UI |
+
+**Pros:**
+- Beautiful pre-built components
+- Zero config setup
+- Great DX
+- Organizations/teams built-in
+
+**Cons:**
+- Expensive at scale
+- Vendor lock-in
+- Less control
+- Monthly costs
+
+---
+
+### Option 6: **Lucia Auth**
+| Aspect | Details |
+|--------|---------|
+| **Type** | Low-level auth library |
+| **Note** | Being deprecated in favor of guides |
+
+**Pros:**
+- Full control
+- Lightweight
+- Educational
+
+**Cons:**
+- Being sunset
+- More DIY work
+
+---
+
+## CHOSEN STACK (FINAL)
+
+| Layer | Technology | Rationale |
+|-------|------------|-----------|
+| **Framework** | SvelteKit 2 | Best DX, simple syntax, excellent performance |
+| **UI Components** | shadcn-svelte + Bits UI | Beautiful, customizable, accessible |
+| **Styling** | Tailwind CSS 4 | Utility-first, works great with shadcn |
+| **Database** | Supabase (PostgreSQL) | All-in-one, managed, real-time capable |
+| **Auth** | Supabase Auth | Integrated with database, simple setup |
+| **File Storage** | Supabase Storage | Profile images, documents |
+| **Design** | Glass-morphism (evolved) | Modern, distinctive, refined |
+| **Language** | TypeScript | Type safety throughout |
+
+---
+
+## Phase 3: Architecture Design
+
+### Project Structure
+
+```
+monacousa-portal-2026/
+├── src/
+│ ├── lib/
+│ │ ├── components/ # Reusable UI components
+│ │ │ ├── ui/ # shadcn-svelte base components
+│ │ │ ├── dashboard/ # Dashboard widgets
+│ │ │ ├── members/ # Member-related components
+│ │ │ ├── events/ # Event components
+│ │ │ └── layout/ # Layout components (sidebar, header)
+│ │ ├── server/ # Server-only utilities
+│ │ │ ├── supabase.ts # Supabase server client
+│ │ │ └── auth.ts # Auth helpers
+│ │ ├── stores/ # Svelte stores for state
+│ │ ├── utils/ # Shared utilities
+│ │ │ ├── types.ts # TypeScript types
+│ │ │ ├── constants.ts # App constants
+│ │ │ └── helpers.ts # Helper functions
+│ │ └── supabase.ts # Supabase client (browser)
+│ │
+│ ├── routes/
+│ │ ├── +layout.svelte # Root layout
+│ │ ├── +layout.server.ts # Root server load (auth)
+│ │ ├── +page.svelte # Landing page
+│ │ │
+│ │ ├── (auth)/ # Auth group (guest only)
+│ │ │ ├── login/
+│ │ │ ├── signup/
+│ │ │ ├── forgot-password/
+│ │ │ └── callback/ # OAuth callback
+│ │ │
+│ │ ├── (app)/ # Protected app group
+│ │ │ ├── +layout.svelte # App layout with sidebar
+│ │ │ ├── +layout.server.ts # Auth guard
+│ │ │ │
+│ │ │ ├── dashboard/ # User dashboard
+│ │ │ ├── profile/ # User profile
+│ │ │ ├── events/ # Events calendar/list
+│ │ │ ├── payments/ # Dues/payments view
+│ │ │ │
+│ │ │ ├── board/ # Board-only routes
+│ │ │ │ ├── +layout.server.ts # Board guard
+│ │ │ │ ├── dashboard/
+│ │ │ │ ├── members/
+│ │ │ │ ├── events/ # Event management
+│ │ │ │ └── meetings/
+│ │ │ │
+│ │ │ └── admin/ # Admin-only routes
+│ │ │ ├── +layout.server.ts # Admin guard
+│ │ │ ├── dashboard/
+│ │ │ ├── members/
+│ │ │ ├── users/
+│ │ │ ├── events/
+│ │ │ └── settings/
+│ │ │
+│ │ └── api/ # API routes (if needed)
+│ │
+│ ├── hooks.server.ts # Server hooks (Supabase SSR)
+│ └── app.d.ts # TypeScript declarations
+│
+├── static/ # Static assets
+├── supabase/ # Supabase local dev
+│ └── migrations/ # Database migrations
+├── tests/ # Test files
+├── svelte.config.js
+├── tailwind.config.ts
+├── vite.config.ts
+└── package.json
+```
+
+---
+
+### Database Schema (Supabase/PostgreSQL)
+
+```sql
+-- USERS (managed by Supabase Auth)
+-- auth.users table is automatic
+
+-- MEMBERS (extends auth.users)
+CREATE TABLE public.members (
+ id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
+ member_id TEXT UNIQUE NOT NULL, -- MUSA-2026-0001 format
+ first_name TEXT NOT NULL,
+ last_name TEXT NOT NULL,
+ email TEXT UNIQUE NOT NULL,
+ phone TEXT,
+ date_of_birth DATE,
+ address TEXT,
+ nationality TEXT[], -- Array of country codes ['FR', 'US']
+
+ -- Membership
+ role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('member', 'board', 'admin')),
+ membership_status TEXT NOT NULL DEFAULT 'pending'
+ CHECK (membership_status IN ('active', 'inactive', 'pending', 'expired')),
+ member_since DATE DEFAULT CURRENT_DATE,
+
+ -- Profile
+ avatar_url TEXT,
+ bio TEXT,
+
+ -- Timestamps
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- DUES/PAYMENTS (tracks payment history)
+CREATE TABLE public.dues_payments (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ member_id UUID NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
+
+ amount DECIMAL(10,2) NOT NULL,
+ currency TEXT DEFAULT 'EUR',
+ payment_date DATE NOT NULL,
+ due_date DATE NOT NULL, -- When this payment period ends
+ payment_method TEXT, -- 'bank_transfer', 'cash', etc.
+ reference TEXT, -- Transaction reference
+ notes TEXT,
+
+ recorded_by UUID REFERENCES public.members(id), -- Who recorded this payment
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- EVENTS
+CREATE TABLE public.events (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ title TEXT NOT NULL,
+ description TEXT,
+ event_type TEXT NOT NULL CHECK (event_type IN ('social', 'meeting', 'fundraiser', 'workshop', 'other')),
+
+ start_datetime TIMESTAMPTZ NOT NULL,
+ end_datetime TIMESTAMPTZ NOT NULL,
+ location TEXT,
+
+ -- Capacity & Pricing
+ max_attendees INTEGER,
+ max_guests_per_member INTEGER DEFAULT 1,
+ member_price DECIMAL(10,2) DEFAULT 0,
+ non_member_price DECIMAL(10,2) DEFAULT 0,
+
+ -- Visibility
+ visibility TEXT NOT NULL DEFAULT 'members'
+ CHECK (visibility IN ('public', 'members', 'board', 'admin')),
+ status TEXT NOT NULL DEFAULT 'published'
+ CHECK (status IN ('draft', 'published', 'cancelled', 'completed')),
+
+ -- Metadata
+ created_by UUID NOT NULL REFERENCES public.members(id),
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- EVENT RSVPs
+CREATE TABLE public.event_rsvps (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ event_id UUID NOT NULL REFERENCES public.events(id) ON DELETE CASCADE,
+ member_id UUID NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
+
+ status TEXT NOT NULL DEFAULT 'confirmed'
+ CHECK (status IN ('confirmed', 'declined', 'waitlist', 'cancelled')),
+ guest_count INTEGER DEFAULT 0,
+ guest_names TEXT[],
+
+ payment_status TEXT DEFAULT 'not_required'
+ CHECK (payment_status IN ('not_required', 'pending', 'paid')),
+
+ attended BOOLEAN DEFAULT FALSE,
+
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW(),
+
+ UNIQUE(event_id, member_id)
+);
+
+-- DOCUMENTS (meeting minutes, governance, etc.)
+CREATE TABLE public.documents (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ title TEXT NOT NULL,
+ description TEXT,
+ category TEXT NOT NULL CHECK (category IN ('meeting_minutes', 'governance', 'financial', 'other')),
+
+ file_path TEXT NOT NULL, -- Supabase Storage path
+ file_name TEXT NOT NULL,
+ file_size INTEGER,
+ mime_type TEXT,
+
+ visibility TEXT NOT NULL DEFAULT 'board'
+ CHECK (visibility IN ('members', 'board', 'admin')),
+
+ uploaded_by UUID NOT NULL REFERENCES public.members(id),
+ created_at TIMESTAMPTZ DEFAULT NOW(),
+ updated_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- EMAIL NOTIFICATIONS LOG
+CREATE TABLE public.email_logs (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+
+ recipient_id UUID REFERENCES public.members(id),
+ recipient_email TEXT NOT NULL,
+ email_type TEXT NOT NULL, -- 'dues_reminder', 'event_invite', etc.
+ subject TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'sent'
+ CHECK (status IN ('sent', 'failed', 'bounced')),
+
+ sent_at TIMESTAMPTZ DEFAULT NOW(),
+ error_message TEXT
+);
+
+-- COMPUTED VIEW: Member with dues status
+CREATE VIEW public.members_with_dues AS
+SELECT
+ m.*,
+ dp.payment_date as last_payment_date,
+ dp.due_date as current_due_date,
+ CASE
+ WHEN dp.due_date IS NULL THEN 'never_paid'
+ WHEN dp.due_date < CURRENT_DATE THEN 'overdue'
+ WHEN dp.due_date < CURRENT_DATE + INTERVAL '30 days' THEN 'due_soon'
+ ELSE 'current'
+ END as dues_status,
+ CASE
+ WHEN dp.due_date < CURRENT_DATE
+ THEN CURRENT_DATE - dp.due_date
+ ELSE NULL
+ END as days_overdue
+FROM public.members m
+LEFT JOIN LATERAL (
+ SELECT payment_date, due_date
+ FROM public.dues_payments
+ WHERE member_id = m.id
+ ORDER BY due_date DESC
+ LIMIT 1
+) dp ON true;
+
+-- ROW LEVEL SECURITY
+ALTER TABLE public.members ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.dues_payments ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.events ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.event_rsvps ENABLE ROW LEVEL SECURITY;
+
+-- Members: Users can read all, update own, admins can do anything
+CREATE POLICY "Members are viewable by authenticated users"
+ ON public.members FOR SELECT
+ TO authenticated
+ USING (true);
+
+CREATE POLICY "Users can update own profile"
+ ON public.members FOR UPDATE
+ TO authenticated
+ USING (auth.uid() = id);
+
+CREATE POLICY "Admins can insert members"
+ ON public.members FOR INSERT
+ TO authenticated
+ WITH CHECK (
+ EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin')
+ OR auth.uid() = id -- Self-registration
+ );
+
+-- Events: Based on visibility
+CREATE POLICY "Events viewable based on visibility"
+ ON public.events FOR SELECT
+ TO authenticated
+ USING (
+ visibility = 'members'
+ OR visibility = 'public'
+ OR (visibility = 'board' AND EXISTS (
+ SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin')
+ ))
+ OR (visibility = 'admin' AND EXISTS (
+ SELECT 1 FROM public.members WHERE id = auth.uid() AND role = 'admin'
+ ))
+ );
+
+-- Board/Admin can manage events
+CREATE POLICY "Board can manage events"
+ ON public.events FOR ALL
+ TO authenticated
+ USING (
+ EXISTS (SELECT 1 FROM public.members WHERE id = auth.uid() AND role IN ('board', 'admin'))
+ );
+```
+
+---
+
+### Authentication Flow
+
+```
+1. SIGNUP FLOW
+ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐
+ │ /signup │────▶│ Supabase Auth│────▶│ Email Verify│
+ │ Form │ │ signUp() │ │ Link Sent │
+ └─────────────┘ └──────────────┘ └─────────────┘
+ │
+ ▼
+ ┌──────────────┐
+ │ Create Member│
+ │ Record (RLS) │
+ └──────────────┘
+
+2. LOGIN FLOW
+ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐
+ │ /login │────▶│ Supabase Auth│────▶│ Set Session │
+ │ Form │ │ signIn() │ │ Cookie │
+ └─────────────┘ └──────────────┘ └─────────────┘
+ │
+ ▼
+ ┌──────────────┐
+ │ Redirect to │
+ │ Dashboard │
+ └──────────────┘
+
+3. PROTECTED ROUTES
+ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐
+ │ Request │────▶│ hooks.server │────▶│ Check Role │
+ │ /admin/* │ │ getSession() │ │ in members │
+ └─────────────┘ └──────────────┘ └─────────────┘
+ │ │
+ ▼ ▼
+ ┌──────────────┐ ┌─────────────┐
+ │ Valid? │ │ Redirect or │
+ │ Yes → Render │ │ 403 Error │
+ └──────────────┘ └─────────────┘
+```
+
+---
+
+### UI Component Library
+
+Using **shadcn-svelte** with custom glass-morphism theme:
+
+```typescript
+// tailwind.config.ts - Glass theme extensions
+export default {
+ theme: {
+ extend: {
+ colors: {
+ monaco: {
+ 50: '#fef2f2',
+ 100: '#fee2e2',
+ 500: '#ef4444',
+ 600: '#dc2626', // Primary
+ 700: '#b91c1c',
+ 900: '#7f1d1d',
+ }
+ },
+ backdropBlur: {
+ xs: '2px',
+ },
+ boxShadow: {
+ 'glass': '0 8px 32px rgba(0, 0, 0, 0.1)',
+ 'glass-lg': '0 25px 50px rgba(0, 0, 0, 0.15)',
+ }
+ }
+ }
+}
+```
+
+**Custom Glass Components:**
+- `GlassCard` - Frosted glass container
+- `GlassSidebar` - Navigation sidebar
+- `GlassButton` - Glass-effect buttons
+- `GlassInput` - Form inputs with glass styling
+- `StatCard` - Dashboard stat display
+- `EventCard` - Event display card
+- `MemberCard` - Member profile card
+- `DuesStatusBadge` - Dues status indicator
+
+---
+
+### Key Features Implementation
+
+#### 1. Member Management
+- View all members (admin/board)
+- Edit member details
+- Upload profile photos (Supabase Storage)
+- Track membership status
+- Filter by status, nationality, dues
+
+#### 2. Dues Tracking
+- Payment history table
+- Auto-calculate due dates (1 year from payment)
+- Visual status indicators
+- Overdue notifications
+- Manual payment recording
+
+#### 3. Event System
+- Calendar view (FullCalendar or custom)
+- List view with filters
+- RSVP with guest management
+- Attendance tracking
+- Event creation (board/admin)
+
+#### 4. Three Dashboards
+| Dashboard | Features |
+|-----------|----------|
+| **Member** | Profile, upcoming events, dues status, quick actions |
+| **Board** | Member stats, pending applications, dues overview, event management |
+| **Admin** | System stats, user management, all member data, settings |
+
+#### 5. Email Notifications
+- Dues reminder emails (30 days before, on due date, overdue)
+- Event invitation/updates
+- Welcome email on signup
+- Password reset emails (Supabase built-in)
+
+#### 6. Document Storage
+- Upload meeting minutes, governance docs
+- Organize by category
+- Visibility controls (members/board/admin)
+- Download/preview functionality
+
+---
+
+## Phase 4: Implementation Roadmap
+
+### Stage 1: Foundation (Week 1-2)
+1. Initialize SvelteKit project with TypeScript
+2. Set up Tailwind CSS 4 + shadcn-svelte
+3. Configure Supabase project
+4. Create database schema + migrations
+5. Implement Supabase SSR hooks
+6. Build base layout components
+
+### Stage 2: Authentication (Week 2-3)
+1. Login/Signup pages
+2. Email verification flow
+3. Password reset
+4. Protected route guards
+5. Role-based access control
+
+### Stage 3: Core Features (Week 3-5)
+1. Member dashboard
+2. Profile management
+3. Member directory (board/admin)
+4. Dues tracking system
+5. Payment recording
+
+### Stage 4: Events (Week 5-6)
+1. Event listing/calendar
+2. Event detail view
+3. RSVP system
+4. Event creation (board)
+5. Attendance tracking
+
+### Stage 5: Admin Features (Week 6-7)
+1. Admin dashboard
+2. User management
+3. System settings
+4. Data export
+
+### Stage 6: Polish (Week 7-8)
+1. Glass-morphism styling refinement
+2. Responsive design
+3. Performance optimization
+4. Testing
+5. Documentation
+
+---
+
+## Verification Plan
+
+### Development Testing
+```bash
+# Start Supabase local
+npx supabase start
+
+# Run dev server
+npm run dev
+
+# Type checking
+npm run check
+```
+
+### Manual Testing Checklist
+- [ ] User can sign up and receive verification email
+- [ ] User can log in and see dashboard
+- [ ] Member can view/edit profile
+- [ ] Member can view events and RSVP
+- [ ] Board member can access board dashboard
+- [ ] Board member can create/manage events
+- [ ] Board member can view member directory
+- [ ] Board member can record dues payments
+- [ ] Admin can access all features
+- [ ] Admin can manage user roles
+- [ ] Role-based routing works correctly
+- [ ] Responsive on mobile/tablet/desktop
+
+### Browser Testing
+- Chrome, Firefox, Safari, Edge
+- iOS Safari, Android Chrome
+
+---
+
+## Data Entry Strategy
+
+Since members will be entered manually (no automated migration):
+
+### Admin Setup
+1. Create first admin account via Supabase dashboard
+2. Manually set `role = 'admin'` in members table
+3. Admin can then add other members through the portal
+
+### Member Entry Options
+1. **Admin adds members** - Admin creates accounts for existing members
+2. **Self-registration** - Members sign up themselves
+3. **Invite system** - Admin sends email invites with signup links
+
+### Initial Launch Checklist
+- [ ] Admin account created and verified
+- [ ] Board member accounts created
+- [ ] Test member account for verification
+- [ ] Email templates configured (Supabase)
+
+---
+
+## Files to Create
+
+| Path | Purpose |
+|------|---------|
+| `monacousa-portal-2026/` | New project root |
+| `src/hooks.server.ts` | Supabase SSR setup |
+| `src/lib/supabase.ts` | Client initialization |
+| `src/lib/server/supabase.ts` | Server client |
+| `src/routes/+layout.svelte` | Root layout |
+| `src/routes/(auth)/login/+page.svelte` | Login page |
+| `src/routes/(auth)/signup/+page.svelte` | Signup page |
+| `src/routes/(app)/+layout.svelte` | App layout |
+| `src/routes/(app)/dashboard/+page.svelte` | Member dashboard |
+| `supabase/migrations/001_schema.sql` | Database schema |
diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md
new file mode 100644
index 0000000..43f85a0
--- /dev/null
+++ b/DEPLOYMENT.md
@@ -0,0 +1,230 @@
+# Monaco USA Portal - Production Deployment Guide
+
+## Prerequisites
+
+- Debian/Ubuntu server with root access
+- Domain DNS configured (portal.monacousa.org, api.monacousa.org, studio.monacousa.org)
+- Ports 80 and 443 open in firewall
+
+## Quick Start
+
+### 1. First-Time Server Setup
+
+```bash
+# Clone the repository
+git clone https://code.letsbe.solutions/matt/monacousa-portal.git
+cd monacousa-portal
+
+# Make deploy script executable
+chmod +x deploy.sh
+
+# Run first-time setup (installs Docker, configures firewall)
+sudo ./deploy.sh setup
+```
+
+### 2. Configure Environment
+
+```bash
+# Copy environment template
+cp .env.production.example .env
+
+# Generate secrets
+./deploy.sh generate-secrets
+
+# Edit environment file with your values
+nano .env
+```
+
+**Important environment variables to configure:**
+- `DOMAIN` - Your domain (e.g., portal.monacousa.org)
+- `POSTGRES_PASSWORD` - Strong database password
+- `JWT_SECRET` - 32+ character random string
+- `ANON_KEY` / `SERVICE_ROLE_KEY` - Generate at supabase.com/docs/guides/self-hosting#api-keys
+- `SMTP_*` - Email server settings
+
+### 3. Install and Configure Nginx
+
+```bash
+# Install nginx
+sudo apt install nginx certbot python3-certbot-nginx -y
+
+# Copy nginx config
+sudo cp nginx/portal.monacousa.org.initial.conf /etc/nginx/sites-available/portal.monacousa.org
+
+# Enable the site
+sudo ln -s /etc/nginx/sites-available/portal.monacousa.org /etc/nginx/sites-enabled/
+
+# Remove default site if exists
+sudo rm -f /etc/nginx/sites-enabled/default
+
+# Test config
+sudo nginx -t
+
+# Reload nginx
+sudo systemctl reload nginx
+```
+
+### 4. Deploy Docker Services
+
+```bash
+# Deploy all services
+./deploy.sh deploy
+
+# Wait for services to be healthy (check status)
+./deploy.sh status
+```
+
+### 5. Get SSL Certificate
+
+```bash
+# Get SSL certificate (after Docker services are running)
+sudo certbot --nginx -d portal.monacousa.org -d api.monacousa.org -d studio.monacousa.org
+
+# Test auto-renewal
+sudo certbot renew --dry-run
+```
+
+## Common Commands
+
+```bash
+# View logs
+./deploy.sh logs # All services
+./deploy.sh logs portal # Portal only
+./deploy.sh logs db # Database only
+
+# Service management
+./deploy.sh status # Check status
+./deploy.sh restart # Restart all services
+./deploy.sh stop # Stop all services
+
+# Database
+./deploy.sh backup # Backup database
+./deploy.sh restore backup.sql.gz # Restore from backup
+
+# Updates
+./deploy.sh update # Pull latest code and rebuild portal
+
+# Cleanup
+./deploy.sh cleanup # Remove unused Docker resources
+```
+
+## Architecture
+
+```
+ ┌─────────────────┐
+ │ Internet │
+ └────────┬────────┘
+ │
+ ┌────────┴────────┐
+ │ Nginx (Host) │
+ │ :80 / :443 │
+ │ SSL Termination│
+ └────────┬────────┘
+ │
+ ┌────────────────────┼────────────────────┐
+ │ │ │
+ ▼ ▼ ▼
+ ┌─────────┐ ┌─────────┐ ┌─────────┐
+ │ Portal │ │ API │ │ Studio │
+ │ :7453 │ │ :7455 │ │ :7454 │
+ └────┬────┘ └────┬────┘ └────┬────┘
+ │ │ │
+ │ ┌────┴────┐ │
+ │ │ Kong │ │
+ │ │ Gateway │ │
+ │ └────┬────┘ │
+ │ │ │
+ ▼ ▼ ▼
+ ┌─────────────────────────────────────────────────┐
+ │ Docker Network │
+ │ ┌──────┐ ┌──────┐ ┌─────────┐ ┌──────────┐ │
+ │ │ DB │ │ Auth │ │ Storage │ │ Realtime │ │
+ │ └──────┘ └──────┘ └─────────┘ └──────────┘ │
+ └─────────────────────────────────────────────────┘
+```
+
+## Ports
+
+| Service | Internal Port | External (localhost) |
+|---------|---------------|---------------------|
+| Portal | 3000 | 7453 |
+| Studio | 3000 | 7454 |
+| Kong | 8000 | 7455 |
+
+## Troubleshooting
+
+### Services not starting
+
+```bash
+# Check Docker logs
+docker logs monacousa-portal
+docker logs monacousa-db
+docker logs monacousa-kong
+
+# Check if ports are in use
+sudo netstat -tlnp | grep -E '7453|7454|7455'
+```
+
+### Database connection issues
+
+```bash
+# Check database health
+docker exec monacousa-db pg_isready -U postgres
+
+# View database logs
+docker logs monacousa-db --tail=50
+```
+
+### Nginx issues
+
+```bash
+# Test config
+sudo nginx -t
+
+# Check error log
+sudo tail -f /var/log/nginx/error.log
+
+# Check portal access log
+sudo tail -f /var/log/nginx/portal.monacousa.org.error.log
+```
+
+### SSL certificate issues
+
+```bash
+# Renew certificates manually
+sudo certbot renew
+
+# Check certificate status
+sudo certbot certificates
+```
+
+## Backup Strategy
+
+### Automated Daily Backups
+
+Add to crontab (`crontab -e`):
+
+```bash
+# Daily database backup at 3 AM
+0 3 * * * /path/to/monacousa-portal/deploy.sh backup 2>&1 | logger -t monacousa-backup
+```
+
+### Backup Storage
+
+Backups are saved to the project directory as `backup_YYYYMMDD_HHMMSS.sql.gz`.
+
+Consider copying to remote storage:
+```bash
+# Copy to remote server
+scp backup_*.sql.gz user@backup-server:/backups/monacousa/
+```
+
+## Security Checklist
+
+- [ ] Strong passwords in .env file
+- [ ] Firewall enabled (only 80, 443, 22 open)
+- [ ] SSL certificate installed
+- [ ] Studio protected with basic auth
+- [ ] Regular backups configured
+- [ ] Log rotation configured
+- [ ] Fail2ban installed (optional)
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..0a6b0b8
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,77 @@
+# Monaco USA Portal - SvelteKit Application
+# Multi-stage build for optimized production image
+
+# ============================================
+# Stage 1: Dependencies
+# ============================================
+FROM node:20-alpine AS deps
+WORKDIR /app
+
+# Copy package files
+COPY package.json package-lock.json* ./
+
+# Install dependencies
+RUN npm ci
+
+# ============================================
+# Stage 2: Builder
+# ============================================
+FROM node:20-alpine AS builder
+WORKDIR /app
+
+# Copy package files first
+COPY package.json package-lock.json* ./
+
+# Install dependencies - use npm install instead of npm ci to properly
+# resolve platform-specific optional dependencies (rollup binaries)
+RUN rm -rf node_modules && npm install --legacy-peer-deps
+
+# Copy source files
+COPY . .
+
+# Build arguments for environment variables
+ARG PUBLIC_SUPABASE_URL
+ARG PUBLIC_SUPABASE_ANON_KEY
+ARG SUPABASE_SERVICE_ROLE_KEY
+
+# Set environment variables for build
+ENV PUBLIC_SUPABASE_URL=$PUBLIC_SUPABASE_URL
+ENV PUBLIC_SUPABASE_ANON_KEY=$PUBLIC_SUPABASE_ANON_KEY
+ENV SUPABASE_SERVICE_ROLE_KEY=$SUPABASE_SERVICE_ROLE_KEY
+
+# Build the application
+RUN npm run build
+
+# Prune dev dependencies
+RUN npm prune --production
+
+# ============================================
+# Stage 3: Runner (Production)
+# ============================================
+FROM node:20-alpine AS runner
+WORKDIR /app
+
+# Set production environment
+ENV NODE_ENV=production
+
+# Create non-root user for security
+RUN addgroup --system --gid 1001 nodejs
+RUN adduser --system --uid 1001 sveltekit
+
+# Copy built application
+COPY --from=builder --chown=sveltekit:nodejs /app/build ./build
+COPY --from=builder --chown=sveltekit:nodejs /app/node_modules ./node_modules
+COPY --from=builder --chown=sveltekit:nodejs /app/package.json ./package.json
+
+# Switch to non-root user
+USER sveltekit
+
+# Expose port
+EXPOSE 3000
+
+# Set runtime environment variables
+ENV HOST=0.0.0.0
+ENV PORT=3000
+
+# Start the application
+CMD ["node", "build"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..75842c4
--- /dev/null
+++ b/README.md
@@ -0,0 +1,38 @@
+# sv
+
+Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
+
+## Creating a project
+
+If you're seeing this, you've probably already done this step. Congrats!
+
+```sh
+# create a new project in the current directory
+npx sv create
+
+# create a new project in my-app
+npx sv create my-app
+```
+
+## Developing
+
+Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
+
+```sh
+npm run dev
+
+# or start the server and open the app in a new browser tab
+npm run dev -- --open
+```
+
+## Building
+
+To create a production version of your app:
+
+```sh
+npm run build
+```
+
+You can preview the production build with `npm run preview`.
+
+> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
diff --git a/build.log b/build.log
new file mode 100644
index 0000000..e69de29
diff --git a/components.json b/components.json
new file mode 100644
index 0000000..05e10cb
--- /dev/null
+++ b/components.json
@@ -0,0 +1,17 @@
+{
+ "$schema": "https://shadcn-svelte.com/schema.json",
+ "style": "new-york",
+ "tailwind": {
+ "config": "",
+ "css": "src/app.css",
+ "baseColor": "slate"
+ },
+ "aliases": {
+ "components": "$lib/components",
+ "utils": "$lib/utils",
+ "ui": "$lib/components/ui",
+ "hooks": "$lib/hooks"
+ },
+ "typescript": true,
+ "registry": "https://next.shadcn-svelte.com/registry"
+}
diff --git a/deploy.sh b/deploy.sh
new file mode 100644
index 0000000..1201afe
--- /dev/null
+++ b/deploy.sh
@@ -0,0 +1,339 @@
+#!/bin/bash
+# Monaco USA Portal - Production Deployment Script
+# For Debian/Ubuntu Linux servers
+#
+# Usage: ./deploy.sh [command]
+# Commands:
+# setup - First-time setup (install Docker, configure firewall)
+# deploy - Build and start all services
+# update - Pull latest changes and rebuild portal
+# logs - View logs
+# status - Check service status
+# backup - Backup database
+# restore - Restore database from backup
+
+set -e
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+# Configuration
+COMPOSE_FILE="docker-compose.nginx.yml"
+PROJECT_NAME="monacousa"
+
+log_info() {
+ echo -e "${GREEN}[INFO]${NC} $1"
+}
+
+log_warn() {
+ echo -e "${YELLOW}[WARN]${NC} $1"
+}
+
+log_error() {
+ echo -e "${RED}[ERROR]${NC} $1"
+}
+
+# Check if running as root
+check_root() {
+ if [ "$EUID" -ne 0 ]; then
+ log_error "Please run as root (sudo ./deploy.sh)"
+ exit 1
+ fi
+}
+
+# Install Docker and Docker Compose on Debian
+install_docker() {
+ log_info "Installing Docker..."
+
+ # Remove old versions
+ apt-get remove -y docker docker-engine docker.io containerd runc 2>/dev/null || true
+
+ # Install dependencies
+ apt-get update
+ apt-get install -y \
+ apt-transport-https \
+ ca-certificates \
+ curl \
+ gnupg \
+ lsb-release
+
+ # Add Docker's official GPG key
+ install -m 0755 -d /etc/apt/keyrings
+ curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
+ chmod a+r /etc/apt/keyrings/docker.gpg
+
+ # Add repository
+ echo \
+ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \
+ $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
+ tee /etc/apt/sources.list.d/docker.list > /dev/null
+
+ # Install Docker
+ apt-get update
+ apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
+
+ # Start and enable Docker
+ systemctl start docker
+ systemctl enable docker
+
+ log_info "Docker installed successfully"
+}
+
+# Configure firewall
+configure_firewall() {
+ log_info "Configuring firewall..."
+
+ # Install ufw if not present
+ apt-get install -y ufw
+
+ # Allow SSH, HTTP, HTTPS
+ ufw allow ssh
+ ufw allow http
+ ufw allow https
+
+ # Enable firewall
+ ufw --force enable
+
+ log_info "Firewall configured (SSH, HTTP, HTTPS allowed)"
+}
+
+# First-time setup
+setup() {
+ check_root
+ log_info "Starting first-time setup..."
+
+ # Update system
+ apt-get update && apt-get upgrade -y
+
+ # Install Docker
+ install_docker
+
+ # Configure firewall
+ configure_firewall
+
+ # Install useful tools
+ apt-get install -y htop nano git apache2-utils
+
+ # Check for .env file
+ if [ ! -f .env ]; then
+ log_warn ".env file not found!"
+ log_info "Copy .env.production.example to .env and configure it:"
+ echo " cp .env.production.example .env"
+ echo " nano .env"
+ fi
+
+ log_info "Setup complete! Next steps:"
+ echo " 1. Configure .env file: nano .env"
+ echo " 2. Deploy: ./deploy.sh deploy"
+}
+
+# Generate secrets helper
+generate_secrets() {
+ log_info "Generating secrets..."
+ echo ""
+ echo "JWT_SECRET=$(openssl rand -base64 32)"
+ echo "POSTGRES_PASSWORD=$(openssl rand -base64 32)"
+ echo "SECRET_KEY_BASE=$(openssl rand -base64 64)"
+ echo ""
+ log_info "Copy these values to your .env file"
+}
+
+# Deploy/start services
+deploy() {
+ log_info "Deploying Monaco USA Portal..."
+
+ # Check for .env file
+ if [ ! -f .env ]; then
+ log_error ".env file not found! Copy .env.production.example to .env first."
+ exit 1
+ fi
+
+ # Build and start
+ docker compose -f $COMPOSE_FILE -p $PROJECT_NAME build --no-cache portal
+ docker compose -f $COMPOSE_FILE -p $PROJECT_NAME up -d
+
+ log_info "Deployment complete!"
+ log_info "Waiting for services to be healthy..."
+ sleep 10
+
+ # Show status
+ docker compose -f $COMPOSE_FILE -p $PROJECT_NAME ps
+
+ log_info "Portal should be available at https://\$(grep DOMAIN .env | cut -d '=' -f2)"
+}
+
+# Update and rebuild
+update() {
+ log_info "Updating Monaco USA Portal..."
+
+ # Pull latest code (if git repo)
+ if [ -d .git ]; then
+ git pull origin main
+ fi
+
+ # Rebuild only the portal service
+ docker compose -f $COMPOSE_FILE -p $PROJECT_NAME build --no-cache portal
+
+ # Restart portal with zero downtime
+ docker compose -f $COMPOSE_FILE -p $PROJECT_NAME up -d --no-deps portal
+
+ log_info "Update complete!"
+}
+
+# View logs
+logs() {
+ local service=${1:-""}
+ if [ -z "$service" ]; then
+ docker compose -f $COMPOSE_FILE -p $PROJECT_NAME logs -f --tail=100
+ else
+ docker compose -f $COMPOSE_FILE -p $PROJECT_NAME logs -f --tail=100 $service
+ fi
+}
+
+# Check status
+status() {
+ log_info "Service Status:"
+ docker compose -f $COMPOSE_FILE -p $PROJECT_NAME ps
+ echo ""
+ log_info "Resource Usage:"
+ docker stats --no-stream $(docker compose -f $COMPOSE_FILE -p $PROJECT_NAME ps -q)
+}
+
+# Backup database
+backup() {
+ local backup_file="backup_$(date +%Y%m%d_%H%M%S).sql"
+ log_info "Backing up database to $backup_file..."
+
+ docker compose -f $COMPOSE_FILE -p $PROJECT_NAME exec -T db \
+ pg_dump -U postgres postgres > "$backup_file"
+
+ # Compress
+ gzip "$backup_file"
+
+ log_info "Backup complete: ${backup_file}.gz"
+}
+
+# Restore database
+restore() {
+ local backup_file=$1
+ if [ -z "$backup_file" ]; then
+ log_error "Usage: ./deploy.sh restore "
+ exit 1
+ fi
+
+ log_warn "This will overwrite the current database!"
+ read -p "Are you sure? (y/N) " -n 1 -r
+ echo
+ if [[ ! $REPLY =~ ^[Yy]$ ]]; then
+ exit 1
+ fi
+
+ log_info "Restoring database from $backup_file..."
+
+ # Decompress if needed
+ if [[ "$backup_file" == *.gz ]]; then
+ gunzip -c "$backup_file" | docker compose -f $COMPOSE_FILE -p $PROJECT_NAME exec -T db \
+ psql -U postgres postgres
+ else
+ cat "$backup_file" | docker compose -f $COMPOSE_FILE -p $PROJECT_NAME exec -T db \
+ psql -U postgres postgres
+ fi
+
+ log_info "Restore complete!"
+}
+
+# Stop all services
+stop() {
+ log_info "Stopping all services..."
+ docker compose -f $COMPOSE_FILE -p $PROJECT_NAME down
+ log_info "All services stopped"
+}
+
+# Restart all services
+restart() {
+ log_info "Restarting all services..."
+ docker compose -f $COMPOSE_FILE -p $PROJECT_NAME restart
+ log_info "All services restarted"
+}
+
+# Clean up unused Docker resources
+cleanup() {
+ log_info "Cleaning up unused Docker resources..."
+ docker system prune -af --volumes
+ log_info "Cleanup complete"
+}
+
+# Show help
+help() {
+ echo "Monaco USA Portal - Deployment Script"
+ echo ""
+ echo "Usage: ./deploy.sh [command]"
+ echo ""
+ echo "Commands:"
+ echo " setup First-time server setup (install Docker, firewall)"
+ echo " generate-secrets Generate random secrets for .env"
+ echo " deploy Build and start all services"
+ echo " update Pull latest code and rebuild portal"
+ echo " stop Stop all services"
+ echo " restart Restart all services"
+ echo " status Show service status and resource usage"
+ echo " logs [service] View logs (optionally for specific service)"
+ echo " backup Backup database to file"
+ echo " restore Restore database from backup"
+ echo " cleanup Remove unused Docker resources"
+ echo " help Show this help message"
+ echo ""
+ echo "Examples:"
+ echo " sudo ./deploy.sh setup # First-time setup"
+ echo " ./deploy.sh deploy # Deploy the portal"
+ echo " ./deploy.sh logs portal # View portal logs"
+ echo " ./deploy.sh backup # Backup database"
+}
+
+# Main command handler
+case "${1:-help}" in
+ setup)
+ setup
+ ;;
+ generate-secrets)
+ generate_secrets
+ ;;
+ deploy)
+ deploy
+ ;;
+ update)
+ update
+ ;;
+ stop)
+ stop
+ ;;
+ restart)
+ restart
+ ;;
+ status)
+ status
+ ;;
+ logs)
+ logs $2
+ ;;
+ backup)
+ backup
+ ;;
+ restore)
+ restore $2
+ ;;
+ cleanup)
+ cleanup
+ ;;
+ help|--help|-h)
+ help
+ ;;
+ *)
+ log_error "Unknown command: $1"
+ help
+ exit 1
+ ;;
+esac
diff --git a/dev-output.txt b/dev-output.txt
new file mode 100644
index 0000000..e69de29
diff --git a/dev.log b/dev.log
new file mode 100644
index 0000000..e69de29
diff --git a/docker-compose.nginx.yml b/docker-compose.nginx.yml
new file mode 100644
index 0000000..7864136
--- /dev/null
+++ b/docker-compose.nginx.yml
@@ -0,0 +1,386 @@
+# Monaco USA Portal - Production Docker Compose (with Nginx on host)
+# For deployment on Debian/Linux servers using Nginx as reverse proxy
+#
+# Usage:
+# 1. Copy .env.production.example to .env
+# 2. Configure all environment variables
+# 3. Run: docker compose -f docker-compose.nginx.yml up -d
+#
+# Ports exposed to localhost (nginx proxies to these):
+# - 7453: Portal (SvelteKit)
+# - 7454: Studio (Supabase Dashboard)
+# - 7455: Kong (API Gateway)
+
+services:
+ # ============================================
+ # PostgreSQL Database
+ # ============================================
+ db:
+ image: supabase/postgres:15.8.1.060
+ container_name: monacousa-db
+ restart: unless-stopped
+ environment:
+ POSTGRES_USER: ${POSTGRES_USER}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ POSTGRES_DB: ${POSTGRES_DB}
+ JWT_SECRET: ${JWT_SECRET}
+ JWT_EXP: ${JWT_EXPIRY}
+ volumes:
+ - db-data:/var/lib/postgresql/data
+ - ./supabase/migrations:/docker-entrypoint-initdb.d
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - monacousa-network
+ deploy:
+ resources:
+ limits:
+ memory: 2G
+ reservations:
+ memory: 512M
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # Supabase Studio (Dashboard)
+ # ============================================
+ studio:
+ image: supabase/studio:20241202-71e5240
+ container_name: monacousa-studio
+ restart: unless-stopped
+ ports:
+ - "127.0.0.1:7454:3000"
+ environment:
+ STUDIO_PG_META_URL: http://meta:8080
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ DEFAULT_ORGANIZATION_NAME: Monaco USA
+ DEFAULT_PROJECT_NAME: Monaco USA Portal
+ SUPABASE_URL: http://kong:8000
+ SUPABASE_PUBLIC_URL: https://api.${DOMAIN}
+ SUPABASE_ANON_KEY: ${ANON_KEY}
+ SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
+ depends_on:
+ meta:
+ condition: service_healthy
+ networks:
+ - monacousa-network
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # Kong API Gateway
+ # ============================================
+ kong:
+ image: kong:2.8.1
+ container_name: monacousa-kong
+ restart: unless-stopped
+ ports:
+ - "127.0.0.1:7455:8000"
+ environment:
+ KONG_DATABASE: "off"
+ KONG_DECLARATIVE_CONFIG: /var/lib/kong/kong.yml
+ KONG_DNS_ORDER: LAST,A,CNAME
+ KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth
+ KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
+ KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
+ volumes:
+ - ./supabase/docker/kong.yml:/var/lib/kong/kong.yml:ro
+ depends_on:
+ auth:
+ condition: service_healthy
+ networks:
+ - monacousa-network
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # GoTrue (Auth)
+ # ============================================
+ auth:
+ image: supabase/gotrue:v2.164.0
+ container_name: monacousa-auth
+ restart: unless-stopped
+ environment:
+ GOTRUE_API_HOST: 0.0.0.0
+ GOTRUE_API_PORT: 9999
+ API_EXTERNAL_URL: https://api.${DOMAIN}
+
+ GOTRUE_DB_DRIVER: postgres
+ GOTRUE_DB_DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?search_path=auth
+
+ GOTRUE_SITE_URL: https://${DOMAIN}
+ GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS}
+ GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP}
+
+ GOTRUE_JWT_ADMIN_ROLES: service_role
+ GOTRUE_JWT_AUD: authenticated
+ GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
+ GOTRUE_JWT_EXP: ${JWT_EXPIRY}
+ GOTRUE_JWT_SECRET: ${JWT_SECRET}
+
+ GOTRUE_EXTERNAL_EMAIL_ENABLED: true
+ GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: false
+ GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM}
+
+ GOTRUE_SMTP_HOST: ${SMTP_HOST}
+ GOTRUE_SMTP_PORT: ${SMTP_PORT}
+ GOTRUE_SMTP_USER: ${SMTP_USER}
+ GOTRUE_SMTP_PASS: ${SMTP_PASS}
+ GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL}
+ GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME}
+ GOTRUE_MAILER_URLPATHS_INVITE: /auth/verify
+ GOTRUE_MAILER_URLPATHS_CONFIRMATION: /auth/verify
+ GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/verify
+ GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/verify
+
+ GOTRUE_RATE_LIMIT_EMAIL_SENT: ${RATE_LIMIT_EMAIL_SENT}
+ depends_on:
+ db:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9999/health"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - monacousa-network
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # PostgREST (REST API)
+ # ============================================
+ rest:
+ image: postgrest/postgrest:v12.2.0
+ container_name: monacousa-rest
+ restart: unless-stopped
+ environment:
+ PGRST_DB_URI: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
+ PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS}
+ PGRST_DB_ANON_ROLE: anon
+ PGRST_JWT_SECRET: ${JWT_SECRET}
+ PGRST_DB_USE_LEGACY_GUCS: "false"
+ PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
+ PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY}
+ depends_on:
+ db:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD-SHELL", "exit 0"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 10s
+ networks:
+ - monacousa-network
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # Realtime
+ # ============================================
+ realtime:
+ image: supabase/realtime:v2.33.58
+ container_name: monacousa-realtime
+ restart: unless-stopped
+ environment:
+ PORT: 4000
+ DB_HOST: db
+ DB_PORT: 5432
+ DB_USER: supabase_admin
+ DB_PASSWORD: ${POSTGRES_PASSWORD}
+ DB_NAME: ${POSTGRES_DB}
+ DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime'
+ DB_ENC_KEY: supabaserealtime
+ API_JWT_SECRET: ${JWT_SECRET}
+ SECRET_KEY_BASE: ${SECRET_KEY_BASE}
+ ERL_AFLAGS: -proto_dist inet_tcp
+ DNS_NODES: "''"
+ RLIMIT_NOFILE: "10000"
+ APP_NAME: realtime
+ SEED_SELF_HOST: true
+ depends_on:
+ db:
+ condition: service_healthy
+ networks:
+ - monacousa-network
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # Storage API
+ # ============================================
+ storage:
+ image: supabase/storage-api:v1.11.13
+ container_name: monacousa-storage
+ restart: unless-stopped
+ environment:
+ ANON_KEY: ${ANON_KEY}
+ SERVICE_KEY: ${SERVICE_ROLE_KEY}
+ POSTGREST_URL: http://rest:3000
+ PGRST_JWT_SECRET: ${JWT_SECRET}
+ DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
+ FILE_SIZE_LIMIT: 52428800
+ STORAGE_BACKEND: file
+ FILE_STORAGE_BACKEND_PATH: /var/lib/storage
+ TENANT_ID: stub
+ REGION: stub
+ GLOBAL_S3_BUCKET: stub
+ ENABLE_IMAGE_TRANSFORMATION: "true"
+ IMGPROXY_URL: http://imgproxy:8080
+ volumes:
+ - storage-data:/var/lib/storage
+ depends_on:
+ db:
+ condition: service_healthy
+ rest:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5000/status"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - monacousa-network
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # Image Proxy (for storage transformations)
+ # ============================================
+ imgproxy:
+ image: darthsim/imgproxy:v3.8.0
+ container_name: monacousa-imgproxy
+ restart: unless-stopped
+ environment:
+ IMGPROXY_BIND: ":8080"
+ IMGPROXY_LOCAL_FILESYSTEM_ROOT: /
+ IMGPROXY_USE_ETAG: "true"
+ IMGPROXY_ENABLE_WEBP_DETECTION: "true"
+ volumes:
+ - storage-data:/var/lib/storage
+ healthcheck:
+ test: ["CMD", "imgproxy", "health"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - monacousa-network
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # Postgres Meta (for Studio)
+ # ============================================
+ meta:
+ image: supabase/postgres-meta:v0.84.2
+ container_name: monacousa-meta
+ restart: unless-stopped
+ environment:
+ PG_META_PORT: 8080
+ PG_META_DB_HOST: db
+ PG_META_DB_PORT: 5432
+ PG_META_DB_NAME: ${POSTGRES_DB}
+ PG_META_DB_USER: supabase_admin
+ PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
+ depends_on:
+ db:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD-SHELL", "exit 0"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 10s
+ networks:
+ - monacousa-network
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # Monaco USA Portal (SvelteKit App)
+ # ============================================
+ portal:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ args:
+ PUBLIC_SUPABASE_URL: https://api.${DOMAIN}
+ PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY}
+ SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
+ container_name: monacousa-portal
+ restart: unless-stopped
+ ports:
+ - "127.0.0.1:7453:3000"
+ environment:
+ PUBLIC_SUPABASE_URL: https://api.${DOMAIN}
+ PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY}
+ SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
+ SUPABASE_INTERNAL_URL: http://kong:8000
+ NODE_ENV: production
+ ORIGIN: https://${DOMAIN}
+ BODY_SIZE_LIMIT: ${BODY_SIZE_LIMIT}
+ depends_on:
+ kong:
+ condition: service_started
+ db:
+ condition: service_healthy
+ networks:
+ - monacousa-network
+ deploy:
+ resources:
+ limits:
+ memory: 1G
+ reservations:
+ memory: 256M
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+# ============================================
+# Networks
+# ============================================
+networks:
+ monacousa-network:
+ driver: bridge
+
+# ============================================
+# Volumes
+# ============================================
+volumes:
+ db-data:
+ driver: local
+ storage-data:
+ driver: local
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
new file mode 100644
index 0000000..aa8219b
--- /dev/null
+++ b/docker-compose.prod.yml
@@ -0,0 +1,440 @@
+# Monaco USA Portal - Production Docker Compose
+# For deployment on Debian/Linux servers with Traefik reverse proxy
+#
+# Usage:
+# 1. Copy .env.production.example to .env
+# 2. Configure all environment variables
+# 3. Run: docker compose -f docker-compose.prod.yml up -d
+#
+# Prerequisites:
+# - Docker and Docker Compose installed
+# - Domain DNS pointing to server IP
+# - Ports 80 and 443 open
+
+services:
+ # ============================================
+ # Traefik Reverse Proxy (SSL/HTTPS)
+ # ============================================
+ traefik:
+ image: traefik:v3.0
+ container_name: monacousa-traefik
+ restart: unless-stopped
+ command:
+ - "--api.dashboard=true"
+ - "--providers.docker=true"
+ - "--providers.docker.exposedbydefault=false"
+ - "--entrypoints.web.address=:80"
+ - "--entrypoints.websecure.address=:443"
+ - "--entrypoints.web.http.redirections.entryPoint.to=websecure"
+ - "--entrypoints.web.http.redirections.entryPoint.scheme=https"
+ - "--certificatesresolvers.letsencrypt.acme.httpchallenge=true"
+ - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
+ - "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}"
+ - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json"
+ - "--log.level=INFO"
+ ports:
+ - "80:80"
+ - "443:443"
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock:ro
+ - traefik-certs:/letsencrypt
+ networks:
+ - monacousa-network
+ labels:
+ # Traefik dashboard (optional - remove in production if not needed)
+ - "traefik.enable=true"
+ - "traefik.http.routers.traefik.rule=Host(`traefik.${DOMAIN}`)"
+ - "traefik.http.routers.traefik.entrypoints=websecure"
+ - "traefik.http.routers.traefik.tls.certresolver=letsencrypt"
+ - "traefik.http.routers.traefik.service=api@internal"
+ - "traefik.http.routers.traefik.middlewares=traefik-auth"
+ - "traefik.http.middlewares.traefik-auth.basicauth.users=${TRAEFIK_DASHBOARD_AUTH}"
+
+ # ============================================
+ # PostgreSQL Database
+ # ============================================
+ db:
+ image: supabase/postgres:15.8.1.060
+ container_name: monacousa-db
+ restart: unless-stopped
+ environment:
+ POSTGRES_USER: ${POSTGRES_USER}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ POSTGRES_DB: ${POSTGRES_DB}
+ JWT_SECRET: ${JWT_SECRET}
+ JWT_EXP: ${JWT_EXPIRY}
+ volumes:
+ - db-data:/var/lib/postgresql/data
+ - ./supabase/migrations:/docker-entrypoint-initdb.d
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - monacousa-network
+ deploy:
+ resources:
+ limits:
+ memory: 2G
+ reservations:
+ memory: 512M
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # Supabase Studio (Dashboard) - Optional
+ # ============================================
+ studio:
+ image: supabase/studio:20241202-71e5240
+ container_name: monacousa-studio
+ restart: unless-stopped
+ environment:
+ STUDIO_PG_META_URL: http://meta:8080
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ DEFAULT_ORGANIZATION_NAME: Monaco USA
+ DEFAULT_PROJECT_NAME: Monaco USA Portal
+ SUPABASE_URL: http://kong:8000
+ SUPABASE_PUBLIC_URL: https://api.${DOMAIN}
+ SUPABASE_ANON_KEY: ${ANON_KEY}
+ SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
+ depends_on:
+ meta:
+ condition: service_healthy
+ networks:
+ - monacousa-network
+ labels:
+ - "traefik.enable=true"
+ - "traefik.http.routers.studio.rule=Host(`studio.${DOMAIN}`)"
+ - "traefik.http.routers.studio.entrypoints=websecure"
+ - "traefik.http.routers.studio.tls.certresolver=letsencrypt"
+ - "traefik.http.services.studio.loadbalancer.server.port=3000"
+ - "traefik.http.routers.studio.middlewares=studio-auth"
+ - "traefik.http.middlewares.studio-auth.basicauth.users=${STUDIO_AUTH}"
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # Kong API Gateway
+ # ============================================
+ kong:
+ image: kong:2.8.1
+ container_name: monacousa-kong
+ restart: unless-stopped
+ environment:
+ KONG_DATABASE: "off"
+ KONG_DECLARATIVE_CONFIG: /var/lib/kong/kong.yml
+ KONG_DNS_ORDER: LAST,A,CNAME
+ KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth
+ KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
+ KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
+ volumes:
+ - ./supabase/docker/kong.yml:/var/lib/kong/kong.yml:ro
+ depends_on:
+ auth:
+ condition: service_healthy
+ networks:
+ - monacousa-network
+ labels:
+ - "traefik.enable=true"
+ - "traefik.http.routers.kong.rule=Host(`api.${DOMAIN}`)"
+ - "traefik.http.routers.kong.entrypoints=websecure"
+ - "traefik.http.routers.kong.tls.certresolver=letsencrypt"
+ - "traefik.http.services.kong.loadbalancer.server.port=8000"
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # GoTrue (Auth)
+ # ============================================
+ auth:
+ image: supabase/gotrue:v2.164.0
+ container_name: monacousa-auth
+ restart: unless-stopped
+ environment:
+ GOTRUE_API_HOST: 0.0.0.0
+ GOTRUE_API_PORT: 9999
+ API_EXTERNAL_URL: https://api.${DOMAIN}
+
+ GOTRUE_DB_DRIVER: postgres
+ GOTRUE_DB_DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?search_path=auth
+
+ GOTRUE_SITE_URL: https://${DOMAIN}
+ GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS}
+ GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP}
+
+ GOTRUE_JWT_ADMIN_ROLES: service_role
+ GOTRUE_JWT_AUD: authenticated
+ GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
+ GOTRUE_JWT_EXP: ${JWT_EXPIRY}
+ GOTRUE_JWT_SECRET: ${JWT_SECRET}
+
+ GOTRUE_EXTERNAL_EMAIL_ENABLED: true
+ GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: false
+ GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM}
+
+ GOTRUE_SMTP_HOST: ${SMTP_HOST}
+ GOTRUE_SMTP_PORT: ${SMTP_PORT}
+ GOTRUE_SMTP_USER: ${SMTP_USER}
+ GOTRUE_SMTP_PASS: ${SMTP_PASS}
+ GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL}
+ GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME}
+ GOTRUE_MAILER_URLPATHS_INVITE: /auth/verify
+ GOTRUE_MAILER_URLPATHS_CONFIRMATION: /auth/verify
+ GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/verify
+ GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/verify
+
+ GOTRUE_RATE_LIMIT_EMAIL_SENT: ${RATE_LIMIT_EMAIL_SENT}
+ depends_on:
+ db:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9999/health"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - monacousa-network
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # PostgREST (REST API)
+ # ============================================
+ rest:
+ image: postgrest/postgrest:v12.2.0
+ container_name: monacousa-rest
+ restart: unless-stopped
+ environment:
+ PGRST_DB_URI: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
+ PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS}
+ PGRST_DB_ANON_ROLE: anon
+ PGRST_JWT_SECRET: ${JWT_SECRET}
+ PGRST_DB_USE_LEGACY_GUCS: "false"
+ PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
+ PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY}
+ depends_on:
+ db:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD-SHELL", "exit 0"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 10s
+ networks:
+ - monacousa-network
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # Realtime
+ # ============================================
+ realtime:
+ image: supabase/realtime:v2.33.58
+ container_name: monacousa-realtime
+ restart: unless-stopped
+ environment:
+ PORT: 4000
+ DB_HOST: db
+ DB_PORT: 5432
+ DB_USER: supabase_admin
+ DB_PASSWORD: ${POSTGRES_PASSWORD}
+ DB_NAME: ${POSTGRES_DB}
+ DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime'
+ DB_ENC_KEY: supabaserealtime
+ API_JWT_SECRET: ${JWT_SECRET}
+ SECRET_KEY_BASE: ${SECRET_KEY_BASE}
+ ERL_AFLAGS: -proto_dist inet_tcp
+ DNS_NODES: "''"
+ RLIMIT_NOFILE: "10000"
+ APP_NAME: realtime
+ SEED_SELF_HOST: true
+ depends_on:
+ db:
+ condition: service_healthy
+ networks:
+ - monacousa-network
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # Storage API
+ # ============================================
+ storage:
+ image: supabase/storage-api:v1.11.13
+ container_name: monacousa-storage
+ restart: unless-stopped
+ environment:
+ ANON_KEY: ${ANON_KEY}
+ SERVICE_KEY: ${SERVICE_ROLE_KEY}
+ POSTGREST_URL: http://rest:3000
+ PGRST_JWT_SECRET: ${JWT_SECRET}
+ DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
+ FILE_SIZE_LIMIT: 52428800
+ STORAGE_BACKEND: file
+ FILE_STORAGE_BACKEND_PATH: /var/lib/storage
+ TENANT_ID: stub
+ REGION: stub
+ GLOBAL_S3_BUCKET: stub
+ ENABLE_IMAGE_TRANSFORMATION: "true"
+ IMGPROXY_URL: http://imgproxy:8080
+ volumes:
+ - storage-data:/var/lib/storage
+ depends_on:
+ db:
+ condition: service_healthy
+ rest:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5000/status"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - monacousa-network
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # Image Proxy (for storage transformations)
+ # ============================================
+ imgproxy:
+ image: darthsim/imgproxy:v3.8.0
+ container_name: monacousa-imgproxy
+ restart: unless-stopped
+ environment:
+ IMGPROXY_BIND: ":8080"
+ IMGPROXY_LOCAL_FILESYSTEM_ROOT: /
+ IMGPROXY_USE_ETAG: "true"
+ IMGPROXY_ENABLE_WEBP_DETECTION: "true"
+ volumes:
+ - storage-data:/var/lib/storage
+ healthcheck:
+ test: ["CMD", "imgproxy", "health"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - monacousa-network
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # Postgres Meta (for Studio)
+ # ============================================
+ meta:
+ image: supabase/postgres-meta:v0.84.2
+ container_name: monacousa-meta
+ restart: unless-stopped
+ environment:
+ PG_META_PORT: 8080
+ PG_META_DB_HOST: db
+ PG_META_DB_PORT: 5432
+ PG_META_DB_NAME: ${POSTGRES_DB}
+ PG_META_DB_USER: supabase_admin
+ PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
+ depends_on:
+ db:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD-SHELL", "exit 0"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 10s
+ networks:
+ - monacousa-network
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+ # ============================================
+ # Monaco USA Portal (SvelteKit App)
+ # ============================================
+ portal:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ args:
+ PUBLIC_SUPABASE_URL: https://api.${DOMAIN}
+ PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY}
+ SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
+ container_name: monacousa-portal
+ restart: unless-stopped
+ environment:
+ PUBLIC_SUPABASE_URL: https://api.${DOMAIN}
+ PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY}
+ SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
+ SUPABASE_INTERNAL_URL: http://kong:8000
+ NODE_ENV: production
+ ORIGIN: https://${DOMAIN}
+ BODY_SIZE_LIMIT: ${BODY_SIZE_LIMIT}
+ depends_on:
+ kong:
+ condition: service_started
+ db:
+ condition: service_healthy
+ networks:
+ - monacousa-network
+ labels:
+ - "traefik.enable=true"
+ - "traefik.http.routers.portal.rule=Host(`${DOMAIN}`)"
+ - "traefik.http.routers.portal.entrypoints=websecure"
+ - "traefik.http.routers.portal.tls.certresolver=letsencrypt"
+ - "traefik.http.services.portal.loadbalancer.server.port=3000"
+ deploy:
+ resources:
+ limits:
+ memory: 1G
+ reservations:
+ memory: 256M
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "10m"
+ max-file: "3"
+
+# ============================================
+# Networks
+# ============================================
+networks:
+ monacousa-network:
+ driver: bridge
+
+# ============================================
+# Volumes
+# ============================================
+volumes:
+ db-data:
+ driver: local
+ storage-data:
+ driver: local
+ traefik-certs:
+ driver: local
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..cd12f0f
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,318 @@
+# Monaco USA Portal - Full Stack Docker Compose
+# Includes: PostgreSQL, Supabase Services, and SvelteKit App
+
+services:
+ # ============================================
+ # PostgreSQL Database
+ # ============================================
+ db:
+ image: supabase/postgres:15.8.1.060
+ container_name: monacousa-db
+ restart: unless-stopped
+ ports:
+ - "${POSTGRES_PORT:-5435}:5432"
+ environment:
+ POSTGRES_USER: ${POSTGRES_USER:-postgres}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
+ POSTGRES_DB: ${POSTGRES_DB:-postgres}
+ JWT_SECRET: ${JWT_SECRET}
+ JWT_EXP: ${JWT_EXPIRY:-3600}
+ volumes:
+ - db-data:/var/lib/postgresql/data
+ - ./supabase/migrations:/docker-entrypoint-initdb.d
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - monacousa-network
+
+ # ============================================
+ # Supabase Studio (Dashboard)
+ # ============================================
+ studio:
+ image: supabase/studio:20241202-71e5240
+ container_name: monacousa-studio
+ restart: unless-stopped
+ ports:
+ - "${STUDIO_PORT:-7454}:3000"
+ environment:
+ STUDIO_PG_META_URL: http://meta:8080
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
+ DEFAULT_ORGANIZATION_NAME: Monaco USA
+ DEFAULT_PROJECT_NAME: Monaco USA Portal
+ SUPABASE_URL: http://kong:8000
+ SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL:-http://localhost:7455}
+ SUPABASE_ANON_KEY: ${ANON_KEY}
+ SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
+ depends_on:
+ meta:
+ condition: service_healthy
+ networks:
+ - monacousa-network
+
+ # ============================================
+ # Kong API Gateway
+ # ============================================
+ kong:
+ image: kong:2.8.1
+ container_name: monacousa-kong
+ restart: unless-stopped
+ ports:
+ - "${KONG_HTTP_PORT:-7455}:8000"
+ - "${KONG_HTTPS_PORT:-7456}:8443"
+ environment:
+ KONG_DATABASE: "off"
+ KONG_DECLARATIVE_CONFIG: /var/lib/kong/kong.yml
+ KONG_DNS_ORDER: LAST,A,CNAME
+ KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth
+ KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
+ KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
+ volumes:
+ - ./supabase/docker/kong.yml:/var/lib/kong/kong.yml:ro
+ depends_on:
+ auth:
+ condition: service_healthy
+ networks:
+ - monacousa-network
+
+ # ============================================
+ # GoTrue (Auth)
+ # ============================================
+ auth:
+ image: supabase/gotrue:v2.164.0
+ container_name: monacousa-auth
+ restart: unless-stopped
+ environment:
+ GOTRUE_API_HOST: 0.0.0.0
+ GOTRUE_API_PORT: 9999
+ API_EXTERNAL_URL: ${API_EXTERNAL_URL:-http://localhost:7455}
+
+ GOTRUE_DB_DRIVER: postgres
+ GOTRUE_DB_DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}?search_path=auth
+
+ GOTRUE_SITE_URL: ${SITE_URL:-http://localhost:3000}
+ GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS}
+ GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP:-false}
+
+ GOTRUE_JWT_ADMIN_ROLES: service_role
+ GOTRUE_JWT_AUD: authenticated
+ GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
+ GOTRUE_JWT_EXP: ${JWT_EXPIRY:-3600}
+ GOTRUE_JWT_SECRET: ${JWT_SECRET}
+
+ GOTRUE_EXTERNAL_EMAIL_ENABLED: true
+ GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: false
+ GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM:-false}
+
+ GOTRUE_SMTP_HOST: ${SMTP_HOST:-}
+ GOTRUE_SMTP_PORT: ${SMTP_PORT:-587}
+ GOTRUE_SMTP_USER: ${SMTP_USER:-}
+ GOTRUE_SMTP_PASS: ${SMTP_PASS:-}
+ GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL:-noreply@monacousa.org}
+ GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME:-Monaco USA}
+ GOTRUE_MAILER_URLPATHS_INVITE: ${MAILER_URLPATHS_INVITE:-/auth/verify}
+ GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${MAILER_URLPATHS_CONFIRMATION:-/auth/verify}
+ GOTRUE_MAILER_URLPATHS_RECOVERY: ${MAILER_URLPATHS_RECOVERY:-/auth/verify}
+ GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${MAILER_URLPATHS_EMAIL_CHANGE:-/auth/verify}
+
+ GOTRUE_RATE_LIMIT_EMAIL_SENT: ${RATE_LIMIT_EMAIL_SENT:-100}
+ depends_on:
+ db:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9999/health"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - monacousa-network
+
+ # ============================================
+ # PostgREST (REST API)
+ # ============================================
+ rest:
+ image: postgrest/postgrest:v12.2.0
+ container_name: monacousa-rest
+ restart: unless-stopped
+ environment:
+ PGRST_DB_URI: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}
+ PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS:-public,storage,graphql_public}
+ PGRST_DB_ANON_ROLE: anon
+ PGRST_JWT_SECRET: ${JWT_SECRET}
+ PGRST_DB_USE_LEGACY_GUCS: "false"
+ PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
+ PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY:-3600}
+ depends_on:
+ db:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD-SHELL", "exit 0"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 10s
+ networks:
+ - monacousa-network
+
+ # ============================================
+ # Realtime
+ # ============================================
+ realtime:
+ image: supabase/realtime:v2.33.58
+ container_name: monacousa-realtime
+ restart: unless-stopped
+ environment:
+ PORT: 4000
+ DB_HOST: db
+ DB_PORT: 5432
+ DB_USER: supabase_admin
+ DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
+ DB_NAME: ${POSTGRES_DB:-postgres}
+ DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime'
+ DB_ENC_KEY: supabaserealtime
+ API_JWT_SECRET: ${JWT_SECRET}
+ SECRET_KEY_BASE: ${SECRET_KEY_BASE:-UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq}
+ ERL_AFLAGS: -proto_dist inet_tcp
+ DNS_NODES: "''"
+ RLIMIT_NOFILE: "10000"
+ APP_NAME: realtime
+ SEED_SELF_HOST: true
+ depends_on:
+ db:
+ condition: service_healthy
+ networks:
+ - monacousa-network
+
+ # ============================================
+ # Storage API
+ # ============================================
+ storage:
+ image: supabase/storage-api:v1.11.13
+ container_name: monacousa-storage
+ restart: unless-stopped
+ environment:
+ ANON_KEY: ${ANON_KEY}
+ SERVICE_KEY: ${SERVICE_ROLE_KEY}
+ POSTGREST_URL: http://rest:3000
+ PGRST_JWT_SECRET: ${JWT_SECRET}
+ DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-postgres}
+ FILE_SIZE_LIMIT: 52428800
+ STORAGE_BACKEND: file
+ FILE_STORAGE_BACKEND_PATH: /var/lib/storage
+ TENANT_ID: stub
+ REGION: stub
+ GLOBAL_S3_BUCKET: stub
+ ENABLE_IMAGE_TRANSFORMATION: "true"
+ IMGPROXY_URL: http://imgproxy:8080
+ volumes:
+ - storage-data:/var/lib/storage
+ depends_on:
+ db:
+ condition: service_healthy
+ rest:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5000/status"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - monacousa-network
+
+ # ============================================
+ # Image Proxy (for storage transformations)
+ # ============================================
+ imgproxy:
+ image: darthsim/imgproxy:v3.8.0
+ container_name: monacousa-imgproxy
+ restart: unless-stopped
+ environment:
+ IMGPROXY_BIND: ":8080"
+ IMGPROXY_LOCAL_FILESYSTEM_ROOT: /
+ IMGPROXY_USE_ETAG: "true"
+ IMGPROXY_ENABLE_WEBP_DETECTION: "true"
+ volumes:
+ - storage-data:/var/lib/storage
+ healthcheck:
+ test: ["CMD", "imgproxy", "health"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - monacousa-network
+
+ # ============================================
+ # Postgres Meta (for Studio)
+ # ============================================
+ meta:
+ image: supabase/postgres-meta:v0.84.2
+ container_name: monacousa-meta
+ restart: unless-stopped
+ environment:
+ PG_META_PORT: 8080
+ PG_META_DB_HOST: db
+ PG_META_DB_PORT: 5432
+ PG_META_DB_NAME: ${POSTGRES_DB:-postgres}
+ PG_META_DB_USER: supabase_admin
+ PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
+ depends_on:
+ db:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD-SHELL", "exit 0"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 10s
+ networks:
+ - monacousa-network
+
+ # ============================================
+ # Monaco USA Portal (SvelteKit App)
+ # ============================================
+ portal:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ args:
+ PUBLIC_SUPABASE_URL: ${PUBLIC_SUPABASE_URL:-http://localhost:7455}
+ PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY}
+ SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
+ container_name: monacousa-portal
+ restart: unless-stopped
+ ports:
+ - "${PORTAL_PORT:-7453}:3000"
+ environment:
+ PUBLIC_SUPABASE_URL: ${PUBLIC_SUPABASE_URL:-http://localhost:7455}
+ PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY}
+ SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
+ SUPABASE_INTERNAL_URL: http://kong:8000
+ NODE_ENV: production
+ ORIGIN: http://localhost:7453
+ # Body size limit for file uploads (50MB)
+ BODY_SIZE_LIMIT: ${BODY_SIZE_LIMIT:-52428800}
+ depends_on:
+ kong:
+ condition: service_started
+ db:
+ condition: service_healthy
+ networks:
+ - monacousa-network
+
+# ============================================
+# Networks
+# ============================================
+networks:
+ monacousa-network:
+ driver: bridge
+
+# ============================================
+# Volumes
+# ============================================
+volumes:
+ db-data:
+ driver: local
+ storage-data:
+ driver: local
diff --git a/nginx/portal.monacousa.org.conf b/nginx/portal.monacousa.org.conf
new file mode 100644
index 0000000..503710f
--- /dev/null
+++ b/nginx/portal.monacousa.org.conf
@@ -0,0 +1,244 @@
+# Monaco USA Portal - Nginx Configuration
+# Location: /etc/nginx/sites-available/portal.monacousa.org
+#
+# Installation:
+# 1. Copy to /etc/nginx/sites-available/
+# 2. Create symlink: ln -s /etc/nginx/sites-available/portal.monacousa.org /etc/nginx/sites-enabled/
+# 3. Test config: nginx -t
+# 4. Get SSL cert: certbot --nginx -d portal.monacousa.org -d api.monacousa.org -d studio.monacousa.org
+# 5. Reload: systemctl reload nginx
+
+# Rate limiting zone
+limit_req_zone $binary_remote_addr zone=portal_limit:10m rate=10r/s;
+limit_req_zone $binary_remote_addr zone=api_limit:10m rate=30r/s;
+
+# Upstream definitions
+upstream portal_backend {
+ server 127.0.0.1:7453;
+ keepalive 32;
+}
+
+upstream api_backend {
+ server 127.0.0.1:7455;
+ keepalive 32;
+}
+
+upstream studio_backend {
+ server 127.0.0.1:7454;
+ keepalive 16;
+}
+
+# Main Portal - portal.monacousa.org
+server {
+ listen 80;
+ listen [::]:80;
+ server_name portal.monacousa.org;
+
+ # Redirect all HTTP to HTTPS
+ location / {
+ return 301 https://$host$request_uri;
+ }
+
+ # Let's Encrypt challenge
+ location /.well-known/acme-challenge/ {
+ root /var/www/html;
+ }
+}
+
+server {
+ listen 443 ssl http2;
+ listen [::]:443 ssl http2;
+ server_name portal.monacousa.org;
+
+ # SSL certificates (managed by certbot)
+ # ssl_certificate /etc/letsencrypt/live/portal.monacousa.org/fullchain.pem;
+ # ssl_certificate_key /etc/letsencrypt/live/portal.monacousa.org/privkey.pem;
+ # include /etc/letsencrypt/options-ssl-nginx.conf;
+ # ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+
+ # Temporary self-signed for testing (remove after certbot)
+ ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem;
+ ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key;
+
+ # Security headers
+ add_header X-Frame-Options "SAMEORIGIN" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header X-XSS-Protection "1; mode=block" always;
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+
+ # Logging
+ access_log /var/log/nginx/portal.monacousa.org.access.log;
+ error_log /var/log/nginx/portal.monacousa.org.error.log;
+
+ # Client body size (for file uploads)
+ client_max_body_size 50M;
+
+ # Gzip compression
+ gzip on;
+ gzip_vary on;
+ gzip_min_length 1024;
+ gzip_proxied any;
+ gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml+rss image/svg+xml;
+
+ # Rate limiting
+ limit_req zone=portal_limit burst=20 nodelay;
+
+ # Main application
+ location / {
+ proxy_pass http://portal_backend;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+
+ # Timeouts
+ proxy_connect_timeout 60s;
+ proxy_send_timeout 60s;
+ proxy_read_timeout 60s;
+
+ # Buffering
+ proxy_buffering on;
+ proxy_buffer_size 4k;
+ proxy_buffers 8 4k;
+ }
+
+ # Static assets with caching
+ location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
+ proxy_pass http://portal_backend;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # Cache static assets
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ }
+}
+
+# Supabase API - api.monacousa.org
+server {
+ listen 80;
+ listen [::]:80;
+ server_name api.monacousa.org;
+
+ location / {
+ return 301 https://$host$request_uri;
+ }
+
+ location /.well-known/acme-challenge/ {
+ root /var/www/html;
+ }
+}
+
+server {
+ listen 443 ssl http2;
+ listen [::]:443 ssl http2;
+ server_name api.monacousa.org;
+
+ # SSL certificates (managed by certbot)
+ # ssl_certificate /etc/letsencrypt/live/portal.monacousa.org/fullchain.pem;
+ # ssl_certificate_key /etc/letsencrypt/live/portal.monacousa.org/privkey.pem;
+ # include /etc/letsencrypt/options-ssl-nginx.conf;
+ # ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+
+ # Temporary self-signed for testing
+ ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem;
+ ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key;
+
+ # Security headers
+ add_header X-Frame-Options "DENY" always;
+ add_header X-Content-Type-Options "nosniff" always;
+
+ # Logging
+ access_log /var/log/nginx/api.monacousa.org.access.log;
+ error_log /var/log/nginx/api.monacousa.org.error.log;
+
+ # Client body size
+ client_max_body_size 50M;
+
+ # Rate limiting (higher for API)
+ limit_req zone=api_limit burst=50 nodelay;
+
+ # CORS preflight
+ location / {
+ if ($request_method = 'OPTIONS') {
+ add_header 'Access-Control-Allow-Origin' '*';
+ add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
+ add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, apikey, x-client-info';
+ add_header 'Access-Control-Max-Age' 1728000;
+ add_header 'Content-Type' 'text/plain charset=UTF-8';
+ add_header 'Content-Length' 0;
+ return 204;
+ }
+
+ proxy_pass http://api_backend;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+
+ # Longer timeout for realtime connections
+ proxy_connect_timeout 60s;
+ proxy_send_timeout 300s;
+ proxy_read_timeout 300s;
+ }
+}
+
+# Supabase Studio - studio.monacousa.org (optional, for admin access)
+server {
+ listen 80;
+ listen [::]:80;
+ server_name studio.monacousa.org;
+
+ location / {
+ return 301 https://$host$request_uri;
+ }
+
+ location /.well-known/acme-challenge/ {
+ root /var/www/html;
+ }
+}
+
+server {
+ listen 443 ssl http2;
+ listen [::]:443 ssl http2;
+ server_name studio.monacousa.org;
+
+ # SSL certificates (managed by certbot)
+ # ssl_certificate /etc/letsencrypt/live/portal.monacousa.org/fullchain.pem;
+ # ssl_certificate_key /etc/letsencrypt/live/portal.monacousa.org/privkey.pem;
+ # include /etc/letsencrypt/options-ssl-nginx.conf;
+ # ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+
+ # Temporary self-signed for testing
+ ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem;
+ ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key;
+
+ # Basic auth protection for studio
+ auth_basic "Monaco USA Admin";
+ auth_basic_user_file /etc/nginx/.htpasswd;
+
+ # Logging
+ access_log /var/log/nginx/studio.monacousa.org.access.log;
+ error_log /var/log/nginx/studio.monacousa.org.error.log;
+
+ location / {
+ proxy_pass http://studio_backend;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ }
+}
diff --git a/nginx/portal.monacousa.org.initial.conf b/nginx/portal.monacousa.org.initial.conf
new file mode 100644
index 0000000..5e49ff1
--- /dev/null
+++ b/nginx/portal.monacousa.org.initial.conf
@@ -0,0 +1,161 @@
+# Monaco USA Portal - Initial Nginx Configuration (HTTP only)
+# Location: /etc/nginx/sites-available/portal.monacousa.org
+#
+# This is the initial config before running certbot.
+#
+# Installation:
+# 1. sudo cp portal.monacousa.org.initial.conf /etc/nginx/sites-available/portal.monacousa.org
+# 2. sudo ln -s /etc/nginx/sites-available/portal.monacousa.org /etc/nginx/sites-enabled/
+# 3. sudo nginx -t
+# 4. sudo systemctl reload nginx
+# 5. sudo certbot --nginx -d portal.monacousa.org -d api.monacousa.org -d studio.monacousa.org
+#
+# After certbot succeeds, it will automatically update this config with SSL settings.
+
+# Rate limiting zones
+limit_req_zone $binary_remote_addr zone=portal_limit:10m rate=10r/s;
+limit_req_zone $binary_remote_addr zone=api_limit:10m rate=30r/s;
+
+# Upstream definitions
+upstream portal_backend {
+ server 127.0.0.1:7453;
+ keepalive 32;
+}
+
+upstream api_backend {
+ server 127.0.0.1:7455;
+ keepalive 32;
+}
+
+upstream studio_backend {
+ server 127.0.0.1:7454;
+ keepalive 16;
+}
+
+# Main Portal - portal.monacousa.org
+server {
+ listen 80;
+ listen [::]:80;
+ server_name portal.monacousa.org;
+
+ # Let's Encrypt challenge
+ location /.well-known/acme-challenge/ {
+ root /var/www/html;
+ }
+
+ # Logging
+ access_log /var/log/nginx/portal.monacousa.org.access.log;
+ error_log /var/log/nginx/portal.monacousa.org.error.log;
+
+ # Client body size (for file uploads)
+ client_max_body_size 50M;
+
+ # Gzip compression
+ gzip on;
+ gzip_vary on;
+ gzip_min_length 1024;
+ gzip_proxied any;
+ gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml+rss image/svg+xml;
+
+ # Rate limiting
+ limit_req zone=portal_limit burst=20 nodelay;
+
+ # Main application
+ location / {
+ proxy_pass http://portal_backend;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+
+ # Timeouts
+ proxy_connect_timeout 60s;
+ proxy_send_timeout 60s;
+ proxy_read_timeout 60s;
+
+ # Buffering
+ proxy_buffering on;
+ proxy_buffer_size 4k;
+ proxy_buffers 8 4k;
+ }
+
+ # Static assets with caching
+ location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
+ proxy_pass http://portal_backend;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # Cache static assets
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ }
+}
+
+# Supabase API - api.monacousa.org
+server {
+ listen 80;
+ listen [::]:80;
+ server_name api.monacousa.org;
+
+ location /.well-known/acme-challenge/ {
+ root /var/www/html;
+ }
+
+ # Logging
+ access_log /var/log/nginx/api.monacousa.org.access.log;
+ error_log /var/log/nginx/api.monacousa.org.error.log;
+
+ # Client body size
+ client_max_body_size 50M;
+
+ # Rate limiting
+ limit_req zone=api_limit burst=50 nodelay;
+
+ location / {
+ proxy_pass http://api_backend;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+
+ # Longer timeout for realtime connections
+ proxy_connect_timeout 60s;
+ proxy_send_timeout 300s;
+ proxy_read_timeout 300s;
+ }
+}
+
+# Supabase Studio - studio.monacousa.org (optional)
+server {
+ listen 80;
+ listen [::]:80;
+ server_name studio.monacousa.org;
+
+ location /.well-known/acme-challenge/ {
+ root /var/www/html;
+ }
+
+ # Logging
+ access_log /var/log/nginx/studio.monacousa.org.access.log;
+ error_log /var/log/nginx/studio.monacousa.org.error.log;
+
+ location / {
+ proxy_pass http://studio_backend;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ }
+}
diff --git a/npm.log b/npm.log
new file mode 100644
index 0000000..e69de29
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..4146dc5
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,4514 @@
+{
+ "name": "monacousa-portal-2026",
+ "version": "0.0.1",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "monacousa-portal-2026",
+ "version": "0.0.1",
+ "dependencies": {
+ "@aws-sdk/client-s3": "^3.700.0",
+ "@aws-sdk/s3-request-presigner": "^3.700.0",
+ "@supabase/ssr": "^0.8.0",
+ "@supabase/supabase-js": "^2.90.1",
+ "@sveltejs/adapter-node": "^5.5.1",
+ "flag-icons": "^7.4.0",
+ "nodemailer": "^6.9.0"
+ },
+ "devDependencies": {
+ "@sveltejs/adapter-auto": "^7.0.0",
+ "@sveltejs/kit": "^2.49.1",
+ "@sveltejs/vite-plugin-svelte": "^6.2.1",
+ "@tailwindcss/vite": "^4.1.18",
+ "bits-ui": "^2.15.4",
+ "clsx": "^2.1.1",
+ "lucide-svelte": "^0.562.0",
+ "svelte": "^5.45.6",
+ "svelte-check": "^4.3.4",
+ "tailwind-merge": "^3.4.0",
+ "tailwind-variants": "^3.2.2",
+ "tailwindcss": "^4.1.18",
+ "typescript": "^5.9.3",
+ "vite": "^7.2.6"
+ }
+ },
+ "node_modules/@aws-crypto/crc32": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz",
+ "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/util": "^5.2.0",
+ "@aws-sdk/types": "^3.222.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/crc32c": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz",
+ "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/util": "^5.2.0",
+ "@aws-sdk/types": "^3.222.0",
+ "tslib": "^2.6.2"
+ }
+ },
+ "node_modules/@aws-crypto/sha1-browser": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz",
+ "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/supports-web-crypto": "^5.2.0",
+ "@aws-crypto/util": "^5.2.0",
+ "@aws-sdk/types": "^3.222.0",
+ "@aws-sdk/util-locate-window": "^3.0.0",
+ "@smithy/util-utf8": "^2.0.0",
+ "tslib": "^2.6.2"
+ }
+ },
+ "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz",
+ "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz",
+ "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/is-array-buffer": "^2.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz",
+ "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/util-buffer-from": "^2.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/sha256-browser": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz",
+ "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/sha256-js": "^5.2.0",
+ "@aws-crypto/supports-web-crypto": "^5.2.0",
+ "@aws-crypto/util": "^5.2.0",
+ "@aws-sdk/types": "^3.222.0",
+ "@aws-sdk/util-locate-window": "^3.0.0",
+ "@smithy/util-utf8": "^2.0.0",
+ "tslib": "^2.6.2"
+ }
+ },
+ "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz",
+ "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz",
+ "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/is-array-buffer": "^2.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz",
+ "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/util-buffer-from": "^2.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/sha256-js": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz",
+ "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/util": "^5.2.0",
+ "@aws-sdk/types": "^3.222.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/supports-web-crypto": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz",
+ "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ }
+ },
+ "node_modules/@aws-crypto/util": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz",
+ "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.222.0",
+ "@smithy/util-utf8": "^2.0.0",
+ "tslib": "^2.6.2"
+ }
+ },
+ "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz",
+ "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz",
+ "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/is-array-buffer": "^2.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz",
+ "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/util-buffer-from": "^2.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/client-s3": {
+ "version": "3.974.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.974.0.tgz",
+ "integrity": "sha512-X+vpXNJ8cU8Iw1FtDgDHxo9z6RxlXfcTtpdGnKws4rk+tCYKSAor/DG6BRMzbh4E5xAA7DiU1Ny3BTrRRSt/Yg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/sha1-browser": "5.2.0",
+ "@aws-crypto/sha256-browser": "5.2.0",
+ "@aws-crypto/sha256-js": "5.2.0",
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/credential-provider-node": "^3.972.1",
+ "@aws-sdk/middleware-bucket-endpoint": "^3.972.1",
+ "@aws-sdk/middleware-expect-continue": "^3.972.1",
+ "@aws-sdk/middleware-flexible-checksums": "^3.972.1",
+ "@aws-sdk/middleware-host-header": "^3.972.1",
+ "@aws-sdk/middleware-location-constraint": "^3.972.1",
+ "@aws-sdk/middleware-logger": "^3.972.1",
+ "@aws-sdk/middleware-recursion-detection": "^3.972.1",
+ "@aws-sdk/middleware-sdk-s3": "^3.972.1",
+ "@aws-sdk/middleware-ssec": "^3.972.1",
+ "@aws-sdk/middleware-user-agent": "^3.972.1",
+ "@aws-sdk/region-config-resolver": "^3.972.1",
+ "@aws-sdk/signature-v4-multi-region": "3.972.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@aws-sdk/util-endpoints": "3.972.0",
+ "@aws-sdk/util-user-agent-browser": "^3.972.1",
+ "@aws-sdk/util-user-agent-node": "^3.972.1",
+ "@smithy/config-resolver": "^4.4.6",
+ "@smithy/core": "^3.21.0",
+ "@smithy/eventstream-serde-browser": "^4.2.8",
+ "@smithy/eventstream-serde-config-resolver": "^4.3.8",
+ "@smithy/eventstream-serde-node": "^4.2.8",
+ "@smithy/fetch-http-handler": "^5.3.9",
+ "@smithy/hash-blob-browser": "^4.2.9",
+ "@smithy/hash-node": "^4.2.8",
+ "@smithy/hash-stream-node": "^4.2.8",
+ "@smithy/invalid-dependency": "^4.2.8",
+ "@smithy/md5-js": "^4.2.8",
+ "@smithy/middleware-content-length": "^4.2.8",
+ "@smithy/middleware-endpoint": "^4.4.10",
+ "@smithy/middleware-retry": "^4.4.26",
+ "@smithy/middleware-serde": "^4.2.9",
+ "@smithy/middleware-stack": "^4.2.8",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/node-http-handler": "^4.4.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/smithy-client": "^4.10.11",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
+ "@smithy/util-base64": "^4.3.0",
+ "@smithy/util-body-length-browser": "^4.2.0",
+ "@smithy/util-body-length-node": "^4.2.1",
+ "@smithy/util-defaults-mode-browser": "^4.3.25",
+ "@smithy/util-defaults-mode-node": "^4.2.28",
+ "@smithy/util-endpoints": "^3.2.8",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-retry": "^4.2.8",
+ "@smithy/util-stream": "^4.5.10",
+ "@smithy/util-utf8": "^4.2.0",
+ "@smithy/util-waiter": "^4.2.8",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/client-sso": {
+ "version": "3.974.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.974.0.tgz",
+ "integrity": "sha512-ci+GiM0c4ULo4D79UMcY06LcOLcfvUfiyt8PzNY0vbt5O8BfCPYf4QomwVgkNcLLCYmroO4ge2Yy1EsLUlcD6g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/sha256-browser": "5.2.0",
+ "@aws-crypto/sha256-js": "5.2.0",
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/middleware-host-header": "^3.972.1",
+ "@aws-sdk/middleware-logger": "^3.972.1",
+ "@aws-sdk/middleware-recursion-detection": "^3.972.1",
+ "@aws-sdk/middleware-user-agent": "^3.972.1",
+ "@aws-sdk/region-config-resolver": "^3.972.1",
+ "@aws-sdk/types": "^3.973.0",
+ "@aws-sdk/util-endpoints": "3.972.0",
+ "@aws-sdk/util-user-agent-browser": "^3.972.1",
+ "@aws-sdk/util-user-agent-node": "^3.972.1",
+ "@smithy/config-resolver": "^4.4.6",
+ "@smithy/core": "^3.21.0",
+ "@smithy/fetch-http-handler": "^5.3.9",
+ "@smithy/hash-node": "^4.2.8",
+ "@smithy/invalid-dependency": "^4.2.8",
+ "@smithy/middleware-content-length": "^4.2.8",
+ "@smithy/middleware-endpoint": "^4.4.10",
+ "@smithy/middleware-retry": "^4.4.26",
+ "@smithy/middleware-serde": "^4.2.9",
+ "@smithy/middleware-stack": "^4.2.8",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/node-http-handler": "^4.4.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/smithy-client": "^4.10.11",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
+ "@smithy/util-base64": "^4.3.0",
+ "@smithy/util-body-length-browser": "^4.2.0",
+ "@smithy/util-body-length-node": "^4.2.1",
+ "@smithy/util-defaults-mode-browser": "^4.3.25",
+ "@smithy/util-defaults-mode-node": "^4.2.28",
+ "@smithy/util-endpoints": "^3.2.8",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-retry": "^4.2.8",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/core": {
+ "version": "3.973.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.0.tgz",
+ "integrity": "sha512-qy3Fmt8z4PRInM3ZqJmHihQ2tfCdj/MzbGaZpuHjYjgl1/Gcar4Pyp/zzHXh9hGEb61WNbWgsJcDUhnGIiX1TA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.0",
+ "@aws-sdk/xml-builder": "^3.972.1",
+ "@smithy/core": "^3.21.0",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/signature-v4": "^5.3.8",
+ "@smithy/smithy-client": "^4.10.11",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-base64": "^4.3.0",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/crc64-nvme": {
+ "version": "3.972.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.0.tgz",
+ "integrity": "sha512-ThlLhTqX68jvoIVv+pryOdb5coP1cX1/MaTbB9xkGDCbWbsqQcLqzPxuSoW1DCnAAIacmXCWpzUNOB9pv+xXQw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-env": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.1.tgz",
+ "integrity": "sha512-/etNHqnx96phy/SjI0HRC588o4vKH5F0xfkZ13yAATV7aNrb+5gYGNE6ePWafP+FuZ3HkULSSlJFj0AxgrAqYw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-http": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.1.tgz",
+ "integrity": "sha512-AeopObGW5lpWbDRZ+t4EAtS7wdfSrHPLeFts7jaBzgIaCCD7TL7jAyAB9Y5bCLOPF+17+GL54djCCsjePljUAw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/fetch-http-handler": "^5.3.9",
+ "@smithy/node-http-handler": "^4.4.8",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/smithy-client": "^4.10.11",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-stream": "^4.5.10",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-ini": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.1.tgz",
+ "integrity": "sha512-OdbJA3v+XlNDsrYzNPRUwr8l7gw1r/nR8l4r96MDzSBDU8WEo8T6C06SvwaXR8SpzsjO3sq5KMP86wXWg7Rj4g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/credential-provider-env": "^3.972.1",
+ "@aws-sdk/credential-provider-http": "^3.972.1",
+ "@aws-sdk/credential-provider-login": "^3.972.1",
+ "@aws-sdk/credential-provider-process": "^3.972.1",
+ "@aws-sdk/credential-provider-sso": "^3.972.1",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.1",
+ "@aws-sdk/nested-clients": "3.974.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/credential-provider-imds": "^4.2.8",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-login": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.1.tgz",
+ "integrity": "sha512-CccqDGL6ZrF3/EFWZefvKW7QwwRdxlHUO8NVBKNVcNq6womrPDvqB6xc9icACtE0XB0a7PLoSTkAg8bQVkTO2w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/nested-clients": "3.974.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-node": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.1.tgz",
+ "integrity": "sha512-DwXPk9GfuU/xG9tmCyXFVkCr6X3W8ZCoL5Ptb0pbltEx1/LCcg7T+PBqDlPiiinNCD6ilIoMJDWsnJ8ikzZA7Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/credential-provider-env": "^3.972.1",
+ "@aws-sdk/credential-provider-http": "^3.972.1",
+ "@aws-sdk/credential-provider-ini": "^3.972.1",
+ "@aws-sdk/credential-provider-process": "^3.972.1",
+ "@aws-sdk/credential-provider-sso": "^3.972.1",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.1",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/credential-provider-imds": "^4.2.8",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-process": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.1.tgz",
+ "integrity": "sha512-bi47Zigu3692SJwdBvo8y1dEwE6B61stCwCFnuRWJVTfiM84B+VTSCV661CSWJmIZzmcy7J5J3kWyxL02iHj0w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-sso": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.1.tgz",
+ "integrity": "sha512-dLZVNhM7wSgVUFsgVYgI5hb5Z/9PUkT46pk/SHrSmUqfx6YDvoV4YcPtaiRqviPpEGGiRtdQMEadyOKIRqulUQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/client-sso": "3.974.0",
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/token-providers": "3.974.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-web-identity": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.1.tgz",
+ "integrity": "sha512-YMDeYgi0u687Ay0dAq/pFPKuijrlKTgsaB/UATbxCs/FzZfMiG4If5ksywHmmW7MiYUF8VVv+uou3TczvLrN4w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/nested-clients": "3.974.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-bucket-endpoint": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.1.tgz",
+ "integrity": "sha512-YVvoitBdE8WOpHqIXvv49efT73F4bJ99XH2bi3Dn3mx7WngI4RwHwn/zF5i0q1Wdi5frGSCNF3vuh+pY817//w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.0",
+ "@aws-sdk/util-arn-parser": "^3.972.1",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-config-provider": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-expect-continue": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.1.tgz",
+ "integrity": "sha512-6lfl2/J/kutzw/RLu1kjbahsz4vrGPysrdxWaw8fkjLYG+6M6AswocIAZFS/LgAVi/IWRwPTx9YC0/NH2wDrSw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-flexible-checksums": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.972.1.tgz",
+ "integrity": "sha512-kjVVREpqeUkYQsXr78AcsJbEUlxGH7+H6yS7zkjrnu6HyEVxbdSndkKX6VpKneFOihjCAhIXlk4wf3butDHkNQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/crc32": "5.2.0",
+ "@aws-crypto/crc32c": "5.2.0",
+ "@aws-crypto/util": "5.2.0",
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/crc64-nvme": "3.972.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/is-array-buffer": "^4.2.0",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-stream": "^4.5.10",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-host-header": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.1.tgz",
+ "integrity": "sha512-/R82lXLPmZ9JaUGSUdKtBp2k/5xQxvBT3zZWyKiBOhyulFotlfvdlrO8TnqstBimsl4lYEYySDL+W6ldFh6ALg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-location-constraint": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.1.tgz",
+ "integrity": "sha512-YisPaCbvBk9gY5aUI8jDMDKXsLZ9Fet0WYj1MviK8tZYMgxBIYHM6l3O/OHaAIujojZvamd9F3haYYYWp5/V3w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-logger": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.1.tgz",
+ "integrity": "sha512-JGgFl6cHg9G2FHu4lyFIzmFN8KESBiRr84gLC3Aeni0Gt1nKm+KxWLBuha/RPcXxJygGXCcMM4AykkIwxor8RA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-recursion-detection": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.1.tgz",
+ "integrity": "sha512-taGzNRe8vPHjnliqXIHp9kBgIemLE/xCaRTMH1NH0cncHeaPcjxtnCroAAM9aOlPuKvBe2CpZESyvM1+D8oI7Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.0",
+ "@aws/lambda-invoke-store": "^0.2.2",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-sdk-s3": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.1.tgz",
+ "integrity": "sha512-q/hK0ZNf/aafFRv2wIlDM3p+izi5cXwktVNvRvW646A0MvVZmT4/vwadv/jPA9AORFbnpyf/0luxiMz181f9yg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@aws-sdk/util-arn-parser": "^3.972.1",
+ "@smithy/core": "^3.21.0",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/signature-v4": "^5.3.8",
+ "@smithy/smithy-client": "^4.10.11",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-config-provider": "^4.2.0",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-stream": "^4.5.10",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-ssec": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.1.tgz",
+ "integrity": "sha512-fLtRTPd/MxJT2drJKft2GVGKm35PiNEeQ1Dvz1vc/WhhgAteYrp4f1SfSgjgLaYWGMExESJL4bt8Dxqp6tVsog==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-user-agent": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.1.tgz",
+ "integrity": "sha512-6SVg4pY/9Oq9MLzO48xuM3lsOb8Rxg55qprEtFRpkUmuvKij31f5SQHEGxuiZ4RqIKrfjr2WMuIgXvqJ0eJsPA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@aws-sdk/util-endpoints": "3.972.0",
+ "@smithy/core": "^3.21.0",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/nested-clients": {
+ "version": "3.974.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.974.0.tgz",
+ "integrity": "sha512-k3dwdo/vOiHMJc9gMnkPl1BA5aQfTrZbz+8fiDkWrPagqAioZgmo5oiaOaeX0grObfJQKDtcpPFR4iWf8cgl8Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/sha256-browser": "5.2.0",
+ "@aws-crypto/sha256-js": "5.2.0",
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/middleware-host-header": "^3.972.1",
+ "@aws-sdk/middleware-logger": "^3.972.1",
+ "@aws-sdk/middleware-recursion-detection": "^3.972.1",
+ "@aws-sdk/middleware-user-agent": "^3.972.1",
+ "@aws-sdk/region-config-resolver": "^3.972.1",
+ "@aws-sdk/types": "^3.973.0",
+ "@aws-sdk/util-endpoints": "3.972.0",
+ "@aws-sdk/util-user-agent-browser": "^3.972.1",
+ "@aws-sdk/util-user-agent-node": "^3.972.1",
+ "@smithy/config-resolver": "^4.4.6",
+ "@smithy/core": "^3.21.0",
+ "@smithy/fetch-http-handler": "^5.3.9",
+ "@smithy/hash-node": "^4.2.8",
+ "@smithy/invalid-dependency": "^4.2.8",
+ "@smithy/middleware-content-length": "^4.2.8",
+ "@smithy/middleware-endpoint": "^4.4.10",
+ "@smithy/middleware-retry": "^4.4.26",
+ "@smithy/middleware-serde": "^4.2.9",
+ "@smithy/middleware-stack": "^4.2.8",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/node-http-handler": "^4.4.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/smithy-client": "^4.10.11",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
+ "@smithy/util-base64": "^4.3.0",
+ "@smithy/util-body-length-browser": "^4.2.0",
+ "@smithy/util-body-length-node": "^4.2.1",
+ "@smithy/util-defaults-mode-browser": "^4.3.25",
+ "@smithy/util-defaults-mode-node": "^4.2.28",
+ "@smithy/util-endpoints": "^3.2.8",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-retry": "^4.2.8",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/region-config-resolver": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.1.tgz",
+ "integrity": "sha512-voIY8RORpxLAEgEkYaTFnkaIuRwVBEc+RjVZYcSSllPV+ZEKAacai6kNhJeE3D70Le+JCfvRb52tng/AVHY+jQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/config-resolver": "^4.4.6",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/s3-request-presigner": {
+ "version": "3.974.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.974.0.tgz",
+ "integrity": "sha512-tApmJb4XXBdNQzxTYIBq9aYj8vjJqiMPyeUF25wzvGjLQfXgvcv5sTR4yyzXBxRc8+O7quWDBgMJGtcNerapRQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/signature-v4-multi-region": "3.972.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@aws-sdk/util-format-url": "^3.972.1",
+ "@smithy/middleware-endpoint": "^4.4.10",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/smithy-client": "^4.10.11",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/signature-v4-multi-region": {
+ "version": "3.972.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.972.0.tgz",
+ "integrity": "sha512-2udiRijmjpN81Pvajje4TsjbXDZNP6K9bYUanBYH8hXa/tZG5qfGCySD+TyX0sgDxCQmEDMg3LaQdfjNHBDEgQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/middleware-sdk-s3": "3.972.0",
+ "@aws-sdk/types": "3.972.0",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/signature-v4": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/signature-v4-multi-region/node_modules/@aws-sdk/core": {
+ "version": "3.972.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.972.0.tgz",
+ "integrity": "sha512-nEeUW2M9F+xdIaD98F5MBcQ4ITtykj3yKbgFZ6J0JtL3bq+Z90szQ6Yy8H/BLPYXTs3V4n9ifnBo8cprRDiE6A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "3.972.0",
+ "@aws-sdk/xml-builder": "3.972.0",
+ "@smithy/core": "^3.20.6",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/signature-v4": "^5.3.8",
+ "@smithy/smithy-client": "^4.10.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-base64": "^4.3.0",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/signature-v4-multi-region/node_modules/@aws-sdk/middleware-sdk-s3": {
+ "version": "3.972.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.0.tgz",
+ "integrity": "sha512-0bcKFXWx+NZ7tIlOo7KjQ+O2rydiHdIQahrq+fN6k9Osky29v17guy68urUKfhTobR6iY6KvxkroFWaFtTgS5w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "3.972.0",
+ "@aws-sdk/types": "3.972.0",
+ "@aws-sdk/util-arn-parser": "3.972.0",
+ "@smithy/core": "^3.20.6",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/signature-v4": "^5.3.8",
+ "@smithy/smithy-client": "^4.10.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-config-provider": "^4.2.0",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-stream": "^4.5.10",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/signature-v4-multi-region/node_modules/@aws-sdk/types": {
+ "version": "3.972.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.972.0.tgz",
+ "integrity": "sha512-U7xBIbLSetONxb2bNzHyDgND3oKGoIfmknrEVnoEU4GUSs+0augUOIn9DIWGUO2ETcRFdsRUnmx9KhPT9Ojbug==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/signature-v4-multi-region/node_modules/@aws-sdk/util-arn-parser": {
+ "version": "3.972.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.0.tgz",
+ "integrity": "sha512-RM5Mmo/KJ593iMSrALlHEOcc9YOIyOsDmS5x2NLOMdEmzv1o00fcpAkCQ02IGu1eFneBFT7uX0Mpag0HI+Cz2g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/signature-v4-multi-region/node_modules/@aws-sdk/xml-builder": {
+ "version": "3.972.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.0.tgz",
+ "integrity": "sha512-POaGMcXnozzqBUyJM3HLUZ9GR6OKJWPGJEmhtTnxZXt8B6JcJ/6K3xRJ5H/j8oovVLz8Wg6vFxAHv8lvuASxMg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "fast-xml-parser": "5.2.5",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/token-providers": {
+ "version": "3.974.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.974.0.tgz",
+ "integrity": "sha512-cBykL0LiccKIgNhGWvQRTPvsBLPZxnmJU3pYxG538jpFX8lQtrCy1L7mmIHNEdxIdIGEPgAEHF8/JQxgBToqUQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.973.0",
+ "@aws-sdk/nested-clients": "3.974.0",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/types": {
+ "version": "3.973.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.0.tgz",
+ "integrity": "sha512-jYIdB7a7jhRTvyb378nsjyvJh1Si+zVduJ6urMNGpz8RjkmHZ+9vM2H07XaIB2Cfq0GhJRZYOfUCH8uqQhqBkQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/util-arn-parser": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.1.tgz",
+ "integrity": "sha512-XnNit6H9PPHhqUXW/usjX6JeJ6Pm8ZNqivTjmNjgWHeOfVpblUc/MTic02UmCNR0jJLPjQ3mBKiMen0tnkNQjQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/util-endpoints": {
+ "version": "3.972.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.972.0.tgz",
+ "integrity": "sha512-6JHsl1V/a1ZW8D8AFfd4R52fwZPnZ5H4U6DS8m/bWT8qad72NvbOFAC7U2cDtFs2TShqUO3TEiX/EJibtY3ijg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "3.972.0",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
+ "@smithy/util-endpoints": "^3.2.8",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/util-endpoints/node_modules/@aws-sdk/types": {
+ "version": "3.972.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.972.0.tgz",
+ "integrity": "sha512-U7xBIbLSetONxb2bNzHyDgND3oKGoIfmknrEVnoEU4GUSs+0augUOIn9DIWGUO2ETcRFdsRUnmx9KhPT9Ojbug==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/util-format-url": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.972.1.tgz",
+ "integrity": "sha512-8wJ4/XOLU/RIYBHsXsIOTR04bNmalC8F2YPMyf3oL8YC750M3Rv5WGywW0Fo07HCv770KXJOzVq03Gyl68moFg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/querystring-builder": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/util-locate-window": {
+ "version": "3.965.3",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.3.tgz",
+ "integrity": "sha512-FNUqAjlKAGA7GM05kywE99q8wiPHPZqrzhq3wXRga6PRD6A0kzT85Pb0AzYBVTBRpSrKyyr6M92Y6bnSBVp2BA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/util-user-agent-browser": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.1.tgz",
+ "integrity": "sha512-IgF55NFmJX8d9Wql9M0nEpk2eYbuD8G4781FN4/fFgwTXBn86DvlZJuRWDCMcMqZymnBVX7HW9r+3r9ylqfW0w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/types": "^4.12.0",
+ "bowser": "^2.11.0",
+ "tslib": "^2.6.2"
+ }
+ },
+ "node_modules/@aws-sdk/util-user-agent-node": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.972.1.tgz",
+ "integrity": "sha512-oIs4JFcADzoZ0c915R83XvK2HltWupxNsXUIuZse2rgk7b97zTpkxaqXiH0h9ylh31qtgo/t8hp4tIqcsMrEbQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/middleware-user-agent": "^3.972.1",
+ "@aws-sdk/types": "^3.973.0",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "aws-crt": ">=1.0.0"
+ },
+ "peerDependenciesMeta": {
+ "aws-crt": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@aws-sdk/xml-builder": {
+ "version": "3.972.1",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.1.tgz",
+ "integrity": "sha512-6zZGlPOqn7Xb+25MAXGb1JhgvaC5HjZj6GzszuVrnEgbhvzBRFGKYemuHBV4bho+dtqeYKPgaZUv7/e80hIGNg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "fast-xml-parser": "5.2.5",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws/lambda-invoke-store": {
+ "version": "0.2.3",
+ "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz",
+ "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
+ "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
+ "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
+ "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
+ "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
+ "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
+ "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
+ "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
+ "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
+ "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
+ "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
+ "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
+ "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
+ "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
+ "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
+ "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
+ "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
+ "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
+ "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
+ "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
+ "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
+ "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
+ "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
+ "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
+ "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@floating-ui/core": {
+ "version": "1.7.3",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
+ "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.10"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.7.4",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
+ "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/core": "^1.7.3",
+ "@floating-ui/utils": "^0.2.10"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.10",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
+ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@internationalized/date": {
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.1.tgz",
+ "integrity": "sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "dependencies": {
+ "@swc/helpers": "^0.5.0"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@polka/url": {
+ "version": "1.0.0-next.29",
+ "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
+ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
+ "license": "MIT"
+ },
+ "node_modules/@rollup/plugin-commonjs": {
+ "version": "28.0.9",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.9.tgz",
+ "integrity": "sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA==",
+ "license": "MIT",
+ "dependencies": {
+ "@rollup/pluginutils": "^5.0.1",
+ "commondir": "^1.0.1",
+ "estree-walker": "^2.0.2",
+ "fdir": "^6.2.0",
+ "is-reference": "1.2.1",
+ "magic-string": "^0.30.3",
+ "picomatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">=16.0.0 || 14 >= 14.17"
+ },
+ "peerDependencies": {
+ "rollup": "^2.68.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/plugin-json": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz",
+ "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==",
+ "license": "MIT",
+ "dependencies": {
+ "@rollup/pluginutils": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/plugin-node-resolve": {
+ "version": "16.0.3",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz",
+ "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==",
+ "license": "MIT",
+ "dependencies": {
+ "@rollup/pluginutils": "^5.0.1",
+ "@types/resolve": "1.20.2",
+ "deepmerge": "^4.2.2",
+ "is-module": "^1.0.0",
+ "resolve": "^1.22.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^2.78.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/pluginutils": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
+ "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "estree-walker": "^2.0.2",
+ "picomatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz",
+ "integrity": "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz",
+ "integrity": "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz",
+ "integrity": "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz",
+ "integrity": "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz",
+ "integrity": "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz",
+ "integrity": "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz",
+ "integrity": "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz",
+ "integrity": "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz",
+ "integrity": "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz",
+ "integrity": "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz",
+ "integrity": "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz",
+ "integrity": "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==",
+ "cpu": [
+ "loong64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz",
+ "integrity": "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz",
+ "integrity": "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz",
+ "integrity": "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz",
+ "integrity": "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz",
+ "integrity": "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz",
+ "integrity": "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz",
+ "integrity": "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz",
+ "integrity": "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz",
+ "integrity": "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz",
+ "integrity": "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz",
+ "integrity": "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz",
+ "integrity": "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz",
+ "integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@smithy/abort-controller": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.8.tgz",
+ "integrity": "sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/chunked-blob-reader": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.0.tgz",
+ "integrity": "sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/chunked-blob-reader-native": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.1.tgz",
+ "integrity": "sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/util-base64": "^4.3.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/config-resolver": {
+ "version": "4.4.6",
+ "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.6.tgz",
+ "integrity": "sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-config-provider": "^4.2.0",
+ "@smithy/util-endpoints": "^3.2.8",
+ "@smithy/util-middleware": "^4.2.8",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/core": {
+ "version": "3.21.1",
+ "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.21.1.tgz",
+ "integrity": "sha512-NUH8R4O6FkN8HKMojzbGg/5pNjsfTjlMmeFclyPfPaXXUrbr5TzhWgbf7t92wfrpCHRgpjyz7ffASIS3wX28aA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/middleware-serde": "^4.2.9",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-base64": "^4.3.0",
+ "@smithy/util-body-length-browser": "^4.2.0",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-stream": "^4.5.10",
+ "@smithy/util-utf8": "^4.2.0",
+ "@smithy/uuid": "^1.1.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/credential-provider-imds": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.8.tgz",
+ "integrity": "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/eventstream-codec": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.8.tgz",
+ "integrity": "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-crypto/crc32": "5.2.0",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-hex-encoding": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/eventstream-serde-browser": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.8.tgz",
+ "integrity": "sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/eventstream-serde-universal": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/eventstream-serde-config-resolver": {
+ "version": "4.3.8",
+ "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.8.tgz",
+ "integrity": "sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/eventstream-serde-node": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.8.tgz",
+ "integrity": "sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/eventstream-serde-universal": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/eventstream-serde-universal": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.8.tgz",
+ "integrity": "sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/eventstream-codec": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/fetch-http-handler": {
+ "version": "5.3.9",
+ "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.9.tgz",
+ "integrity": "sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/querystring-builder": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-base64": "^4.3.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/hash-blob-browser": {
+ "version": "4.2.9",
+ "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.9.tgz",
+ "integrity": "sha512-m80d/iicI7DlBDxyQP6Th7BW/ejDGiF0bgI754+tiwK0lgMkcaIBgvwwVc7OFbY4eUzpGtnig52MhPAEJ7iNYg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/chunked-blob-reader": "^5.2.0",
+ "@smithy/chunked-blob-reader-native": "^4.2.1",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/hash-node": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.8.tgz",
+ "integrity": "sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-buffer-from": "^4.2.0",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/hash-stream-node": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.8.tgz",
+ "integrity": "sha512-v0FLTXgHrTeheYZFGhR+ehX5qUm4IQsjAiL9qehad2cyjMWcN2QG6/4mSwbSgEQzI7jwfoXj7z4fxZUx/Mhj2w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/invalid-dependency": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.8.tgz",
+ "integrity": "sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/is-array-buffer": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz",
+ "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/md5-js": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.8.tgz",
+ "integrity": "sha512-oGMaLj4tVZzLi3itBa9TCswgMBr7k9b+qKYowQ6x1rTyTuO1IU2YHdHUa+891OsOH+wCsH7aTPRsTJO3RMQmjQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/middleware-content-length": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.8.tgz",
+ "integrity": "sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/middleware-endpoint": {
+ "version": "4.4.11",
+ "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.11.tgz",
+ "integrity": "sha512-/WqsrycweGGfb9sSzME4CrsuayjJF6BueBmkKlcbeU5q18OhxRrvvKlmfw3tpDsK5ilx2XUJvoukwxHB0nHs/Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/core": "^3.21.1",
+ "@smithy/middleware-serde": "^4.2.9",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
+ "@smithy/url-parser": "^4.2.8",
+ "@smithy/util-middleware": "^4.2.8",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/middleware-retry": {
+ "version": "4.4.27",
+ "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.27.tgz",
+ "integrity": "sha512-xFUYCGRVsfgiN5EjsJJSzih9+yjStgMTCLANPlf0LVQkPDYCe0hz97qbdTZosFOiYlGBlHYityGRxrQ/hxhfVQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/service-error-classification": "^4.2.8",
+ "@smithy/smithy-client": "^4.10.12",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-retry": "^4.2.8",
+ "@smithy/uuid": "^1.1.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/middleware-serde": {
+ "version": "4.2.9",
+ "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.9.tgz",
+ "integrity": "sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/middleware-stack": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.8.tgz",
+ "integrity": "sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/node-config-provider": {
+ "version": "4.3.8",
+ "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.8.tgz",
+ "integrity": "sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/shared-ini-file-loader": "^4.4.3",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/node-http-handler": {
+ "version": "4.4.8",
+ "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.8.tgz",
+ "integrity": "sha512-q9u+MSbJVIJ1QmJ4+1u+cERXkrhuILCBDsJUBAW1MPE6sFonbCNaegFuwW9ll8kh5UdyY3jOkoOGlc7BesoLpg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/abort-controller": "^4.2.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/querystring-builder": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/property-provider": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.8.tgz",
+ "integrity": "sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/protocol-http": {
+ "version": "5.3.8",
+ "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.8.tgz",
+ "integrity": "sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/querystring-builder": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.8.tgz",
+ "integrity": "sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-uri-escape": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/querystring-parser": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.8.tgz",
+ "integrity": "sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/service-error-classification": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.8.tgz",
+ "integrity": "sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/shared-ini-file-loader": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.3.tgz",
+ "integrity": "sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/signature-v4": {
+ "version": "5.3.8",
+ "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.8.tgz",
+ "integrity": "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/is-array-buffer": "^4.2.0",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-hex-encoding": "^4.2.0",
+ "@smithy/util-middleware": "^4.2.8",
+ "@smithy/util-uri-escape": "^4.2.0",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/smithy-client": {
+ "version": "4.10.12",
+ "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.10.12.tgz",
+ "integrity": "sha512-VKO/HKoQ5OrSHW6AJUmEnUKeXI1/5LfCwO9cwyao7CmLvGnZeM1i36Lyful3LK1XU7HwTVieTqO1y2C/6t3qtA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/core": "^3.21.1",
+ "@smithy/middleware-endpoint": "^4.4.11",
+ "@smithy/middleware-stack": "^4.2.8",
+ "@smithy/protocol-http": "^5.3.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-stream": "^4.5.10",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/types": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz",
+ "integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/url-parser": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.8.tgz",
+ "integrity": "sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/querystring-parser": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-base64": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz",
+ "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/util-buffer-from": "^4.2.0",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-body-length-browser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz",
+ "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-body-length-node": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz",
+ "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-buffer-from": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz",
+ "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/is-array-buffer": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-config-provider": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz",
+ "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-defaults-mode-browser": {
+ "version": "4.3.26",
+ "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.26.tgz",
+ "integrity": "sha512-vva0dzYUTgn7DdE0uaha10uEdAgmdLnNFowKFjpMm6p2R0XDk5FHPX3CBJLzWQkQXuEprsb0hGz9YwbicNWhjw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/smithy-client": "^4.10.12",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-defaults-mode-node": {
+ "version": "4.2.29",
+ "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.29.tgz",
+ "integrity": "sha512-c6D7IUBsZt/aNnTBHMTf+OVh+h/JcxUUgfTcIJaWRe6zhOum1X+pNKSZtZ+7fbOn5I99XVFtmrnXKv8yHHErTQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/config-resolver": "^4.4.6",
+ "@smithy/credential-provider-imds": "^4.2.8",
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/property-provider": "^4.2.8",
+ "@smithy/smithy-client": "^4.10.12",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-endpoints": {
+ "version": "3.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.8.tgz",
+ "integrity": "sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/node-config-provider": "^4.3.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-hex-encoding": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz",
+ "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-middleware": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.8.tgz",
+ "integrity": "sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-retry": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.8.tgz",
+ "integrity": "sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/service-error-classification": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-stream": {
+ "version": "4.5.10",
+ "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.10.tgz",
+ "integrity": "sha512-jbqemy51UFSZSp2y0ZmRfckmrzuKww95zT9BYMmuJ8v3altGcqjwoV1tzpOwuHaKrwQrCjIzOib499ymr2f98g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/fetch-http-handler": "^5.3.9",
+ "@smithy/node-http-handler": "^4.4.8",
+ "@smithy/types": "^4.12.0",
+ "@smithy/util-base64": "^4.3.0",
+ "@smithy/util-buffer-from": "^4.2.0",
+ "@smithy/util-hex-encoding": "^4.2.0",
+ "@smithy/util-utf8": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-uri-escape": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz",
+ "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-utf8": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz",
+ "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/util-buffer-from": "^4.2.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/util-waiter": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.8.tgz",
+ "integrity": "sha512-n+lahlMWk+aejGuax7DPWtqav8HYnWxQwR+LCG2BgCUmaGcTe9qZCFsmw8TMg9iG75HOwhrJCX9TCJRLH+Yzqg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/abort-controller": "^4.2.8",
+ "@smithy/types": "^4.12.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/uuid": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz",
+ "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "license": "MIT"
+ },
+ "node_modules/@supabase/auth-js": {
+ "version": "2.91.0",
+ "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.91.0.tgz",
+ "integrity": "sha512-9ywvsKLsxTwv7fvN5fXzP3UfRreqrX2waylTBDu0lkmeHXa8WtSQS9e0WV9FBduiazYqQbgfBQXBNPRPsRgWOQ==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/functions-js": {
+ "version": "2.91.0",
+ "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.91.0.tgz",
+ "integrity": "sha512-WaakXOqLK1mLtBNFXp5o5T+LlI6KZuADSeXz+9ofPRG5OpVSvW148LVJB1DRZ16Phck1a0YqIUswOUgxCz6vMw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/postgrest-js": {
+ "version": "2.91.0",
+ "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.91.0.tgz",
+ "integrity": "sha512-5S41zv2euNpGucvtM4Wy+xOmLznqt/XO+Lh823LOFEQ00ov7QJfvqb6VzIxufvzhooZpmGR0BxvMcJtWxCIFdQ==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/realtime-js": {
+ "version": "2.91.0",
+ "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.91.0.tgz",
+ "integrity": "sha512-u2YuJFG35umw8DO9beC27L/jYXm3KhF+73WQwbynMpV0tXsFIA0DOGRM0NgRyy03hJIdO6mxTTwe8efW3yx3Tg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/phoenix": "^1.6.6",
+ "@types/ws": "^8.18.1",
+ "tslib": "2.8.1",
+ "ws": "^8.18.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/ssr": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.8.0.tgz",
+ "integrity": "sha512-/PKk8kNFSs8QvvJ2vOww1mF5/c5W8y42duYtXvkOSe+yZKRgTTZywYG2l41pjhNomqESZCpZtXuWmYjFRMV+dw==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.2"
+ },
+ "peerDependencies": {
+ "@supabase/supabase-js": "^2.76.1"
+ }
+ },
+ "node_modules/@supabase/storage-js": {
+ "version": "2.91.0",
+ "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.91.0.tgz",
+ "integrity": "sha512-CI7fsVIBQHfNObqU9kmyQ1GWr+Ug44y4rSpvxT4LdQB9tlhg1NTBov6z7Dlmt8d6lGi/8a9lf/epCDxyWI792g==",
+ "license": "MIT",
+ "dependencies": {
+ "iceberg-js": "^0.8.1",
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/supabase-js": {
+ "version": "2.91.0",
+ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.91.0.tgz",
+ "integrity": "sha512-Rjb0QqkKrmXMVwUOdEqysPBZ0ZDZakeptTkUa6k2d8r3strBdbWVDqjOdkCjAmvvZMtXecBeyTyMEXD1Zzjfvg==",
+ "license": "MIT",
+ "dependencies": {
+ "@supabase/auth-js": "2.91.0",
+ "@supabase/functions-js": "2.91.0",
+ "@supabase/postgrest-js": "2.91.0",
+ "@supabase/realtime-js": "2.91.0",
+ "@supabase/storage-js": "2.91.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@sveltejs/acorn-typescript": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz",
+ "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^8.9.0"
+ }
+ },
+ "node_modules/@sveltejs/adapter-auto": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.0.tgz",
+ "integrity": "sha512-ImDWaErTOCkRS4Gt+5gZuymKFBobnhChXUZ9lhUZLahUgvA4OOvRzi3sahzYgbxGj5nkA6OV0GAW378+dl/gyw==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@sveltejs/kit": "^2.0.0"
+ }
+ },
+ "node_modules/@sveltejs/adapter-node": {
+ "version": "5.5.2",
+ "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.2.tgz",
+ "integrity": "sha512-L15Djwpr7HrSAPj/Z8PYfc0pa9A1tllrr18phKI0WJHJeoWw45yinPf0IGgVTmakqx1B3JQ+C/OFl9ZwmxHU1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@rollup/plugin-commonjs": "^28.0.1",
+ "@rollup/plugin-json": "^6.1.0",
+ "@rollup/plugin-node-resolve": "^16.0.0",
+ "rollup": "^4.9.5"
+ },
+ "peerDependencies": {
+ "@sveltejs/kit": "^2.4.0"
+ }
+ },
+ "node_modules/@sveltejs/kit": {
+ "version": "2.50.1",
+ "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.50.1.tgz",
+ "integrity": "sha512-XRHD2i3zC4ukhz2iCQzO4mbsts081PAZnnMAQ7LNpWeYgeBmwMsalf0FGSwhFXBbtr2XViPKnFJBDCckWqrsLw==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@sveltejs/acorn-typescript": "^1.0.5",
+ "@types/cookie": "^0.6.0",
+ "acorn": "^8.14.1",
+ "cookie": "^0.6.0",
+ "devalue": "^5.6.2",
+ "esm-env": "^1.2.2",
+ "kleur": "^4.1.5",
+ "magic-string": "^0.30.5",
+ "mrmime": "^2.0.0",
+ "sade": "^1.8.1",
+ "set-cookie-parser": "^2.6.0",
+ "sirv": "^3.0.0"
+ },
+ "bin": {
+ "svelte-kit": "svelte-kit.js"
+ },
+ "engines": {
+ "node": ">=18.13"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.0.0",
+ "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0",
+ "svelte": "^4.0.0 || ^5.0.0-next.0",
+ "typescript": "^5.3.3",
+ "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@sveltejs/kit/node_modules/cookie": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
+ "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte": {
+ "version": "6.2.4",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz",
+ "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==",
+ "license": "MIT",
+ "dependencies": {
+ "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
+ "deepmerge": "^4.3.1",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.0",
+ "vitefu": "^1.1.1"
+ },
+ "engines": {
+ "node": "^20.19 || ^22.12 || >=24"
+ },
+ "peerDependencies": {
+ "svelte": "^5.0.0",
+ "vite": "^6.3.0 || ^7.0.0"
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte-inspector": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz",
+ "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==",
+ "license": "MIT",
+ "dependencies": {
+ "obug": "^2.1.0"
+ },
+ "engines": {
+ "node": "^20.19 || ^22.12 || >=24"
+ },
+ "peerDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0",
+ "svelte": "^5.0.0",
+ "vite": "^6.3.0 || ^7.0.0"
+ }
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.18",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
+ "integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
+ "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.4",
+ "enhanced-resolve": "^5.18.3",
+ "jiti": "^2.6.1",
+ "lightningcss": "1.30.2",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.1.18"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
+ "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.1.18",
+ "@tailwindcss/oxide-darwin-arm64": "4.1.18",
+ "@tailwindcss/oxide-darwin-x64": "4.1.18",
+ "@tailwindcss/oxide-freebsd-x64": "4.1.18",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.1.18",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
+ "@tailwindcss/oxide-linux-x64-musl": "4.1.18",
+ "@tailwindcss/oxide-wasm32-wasi": "4.1.18",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz",
+ "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz",
+ "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz",
+ "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz",
+ "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz",
+ "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz",
+ "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz",
+ "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz",
+ "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz",
+ "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz",
+ "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1",
+ "@emnapi/wasi-threads": "^1.1.0",
+ "@napi-rs/wasm-runtime": "^1.1.0",
+ "@tybys/wasm-util": "^0.10.1",
+ "tslib": "^2.4.0"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
+ "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz",
+ "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@tailwindcss/vite": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz",
+ "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.1.18",
+ "@tailwindcss/oxide": "4.1.18",
+ "tailwindcss": "4.1.18"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7"
+ }
+ },
+ "node_modules/@types/cookie": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
+ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "25.0.10",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz",
+ "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.16.0"
+ }
+ },
+ "node_modules/@types/phoenix": {
+ "version": "1.6.7",
+ "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
+ "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
+ "license": "MIT"
+ },
+ "node_modules/@types/resolve": {
+ "version": "1.20.2",
+ "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
+ "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
+ "license": "MIT"
+ },
+ "node_modules/@types/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
+ "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/axobject-query": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
+ "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/bits-ui": {
+ "version": "2.15.4",
+ "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.15.4.tgz",
+ "integrity": "sha512-7H9YUfp03KOk1LVDh8wPYSRPxlZgG/GRWLNSA8QC73/8Z8ytun+DWJhIuibyFyz7A0cP/RANVcB4iDrbY8q+Og==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/core": "^1.7.1",
+ "@floating-ui/dom": "^1.7.1",
+ "esm-env": "^1.1.2",
+ "runed": "^0.35.1",
+ "svelte-toolbelt": "^0.10.6",
+ "tabbable": "^6.2.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/huntabyte"
+ },
+ "peerDependencies": {
+ "@internationalized/date": "^3.8.1",
+ "svelte": "^5.33.0"
+ }
+ },
+ "node_modules/bowser": {
+ "version": "2.13.1",
+ "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.13.1.tgz",
+ "integrity": "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==",
+ "license": "MIT"
+ },
+ "node_modules/chokidar": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/commondir": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
+ "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
+ "license": "MIT"
+ },
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/devalue": {
+ "version": "5.6.2",
+ "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz",
+ "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==",
+ "license": "MIT"
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.18.4",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
+ "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
+ "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.2",
+ "@esbuild/android-arm": "0.27.2",
+ "@esbuild/android-arm64": "0.27.2",
+ "@esbuild/android-x64": "0.27.2",
+ "@esbuild/darwin-arm64": "0.27.2",
+ "@esbuild/darwin-x64": "0.27.2",
+ "@esbuild/freebsd-arm64": "0.27.2",
+ "@esbuild/freebsd-x64": "0.27.2",
+ "@esbuild/linux-arm": "0.27.2",
+ "@esbuild/linux-arm64": "0.27.2",
+ "@esbuild/linux-ia32": "0.27.2",
+ "@esbuild/linux-loong64": "0.27.2",
+ "@esbuild/linux-mips64el": "0.27.2",
+ "@esbuild/linux-ppc64": "0.27.2",
+ "@esbuild/linux-riscv64": "0.27.2",
+ "@esbuild/linux-s390x": "0.27.2",
+ "@esbuild/linux-x64": "0.27.2",
+ "@esbuild/netbsd-arm64": "0.27.2",
+ "@esbuild/netbsd-x64": "0.27.2",
+ "@esbuild/openbsd-arm64": "0.27.2",
+ "@esbuild/openbsd-x64": "0.27.2",
+ "@esbuild/openharmony-arm64": "0.27.2",
+ "@esbuild/sunos-x64": "0.27.2",
+ "@esbuild/win32-arm64": "0.27.2",
+ "@esbuild/win32-ia32": "0.27.2",
+ "@esbuild/win32-x64": "0.27.2"
+ }
+ },
+ "node_modules/esm-env": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
+ "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
+ "license": "MIT"
+ },
+ "node_modules/esrap": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz",
+ "integrity": "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.4.15"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "license": "MIT"
+ },
+ "node_modules/fast-xml-parser": {
+ "version": "5.2.5",
+ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz",
+ "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "strnum": "^2.1.0"
+ },
+ "bin": {
+ "fxparser": "src/cli/cli.js"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/flag-icons": {
+ "version": "7.5.0",
+ "resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz",
+ "integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==",
+ "license": "MIT"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/iceberg-js": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
+ "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/inline-style-parser": {
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
+ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+ "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-module": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
+ "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==",
+ "license": "MIT"
+ },
+ "node_modules/is-reference": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
+ "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "*"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
+ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+ "devOptional": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/kleur": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
+ "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
+ "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
+ "devOptional": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.30.2",
+ "lightningcss-darwin-arm64": "1.30.2",
+ "lightningcss-darwin-x64": "1.30.2",
+ "lightningcss-freebsd-x64": "1.30.2",
+ "lightningcss-linux-arm-gnueabihf": "1.30.2",
+ "lightningcss-linux-arm64-gnu": "1.30.2",
+ "lightningcss-linux-arm64-musl": "1.30.2",
+ "lightningcss-linux-x64-gnu": "1.30.2",
+ "lightningcss-linux-x64-musl": "1.30.2",
+ "lightningcss-win32-arm64-msvc": "1.30.2",
+ "lightningcss-win32-x64-msvc": "1.30.2"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
+ "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
+ "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
+ "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
+ "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
+ "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
+ "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
+ "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
+ "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
+ "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
+ "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.30.2",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
+ "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/locate-character": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
+ "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
+ "license": "MIT"
+ },
+ "node_modules/lucide-svelte": {
+ "version": "0.562.0",
+ "resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.562.0.tgz",
+ "integrity": "sha512-kSJDH/55lf0mun/o4nqWBXOcq0fWYzPeIjbTD97ywoeumAB9kWxtM06gC7oynqjtK3XhAljWSz5RafIzPEYIQA==",
+ "dev": true,
+ "license": "ISC",
+ "peerDependencies": {
+ "svelte": "^3 || ^4 || ^5.0.0-next.42"
+ }
+ },
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/mri": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
+ "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mrmime": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
+ "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/nodemailer": {
+ "version": "6.10.1",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
+ "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT"
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.18.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.11",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
+ "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.56.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz",
+ "integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.56.0",
+ "@rollup/rollup-android-arm64": "4.56.0",
+ "@rollup/rollup-darwin-arm64": "4.56.0",
+ "@rollup/rollup-darwin-x64": "4.56.0",
+ "@rollup/rollup-freebsd-arm64": "4.56.0",
+ "@rollup/rollup-freebsd-x64": "4.56.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.56.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.56.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.56.0",
+ "@rollup/rollup-linux-arm64-musl": "4.56.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.56.0",
+ "@rollup/rollup-linux-loong64-musl": "4.56.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.56.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.56.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.56.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.56.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.56.0",
+ "@rollup/rollup-linux-x64-gnu": "4.56.0",
+ "@rollup/rollup-linux-x64-musl": "4.56.0",
+ "@rollup/rollup-openbsd-x64": "4.56.0",
+ "@rollup/rollup-openharmony-arm64": "4.56.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.56.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.56.0",
+ "@rollup/rollup-win32-x64-gnu": "4.56.0",
+ "@rollup/rollup-win32-x64-msvc": "4.56.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/runed": {
+ "version": "0.35.1",
+ "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz",
+ "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/huntabyte",
+ "https://github.com/sponsors/tglide"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.3",
+ "esm-env": "^1.0.0",
+ "lz-string": "^1.5.0"
+ },
+ "peerDependencies": {
+ "@sveltejs/kit": "^2.21.0",
+ "svelte": "^5.7.0"
+ },
+ "peerDependenciesMeta": {
+ "@sveltejs/kit": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/sade": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
+ "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
+ "license": "MIT",
+ "dependencies": {
+ "mri": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "license": "MIT"
+ },
+ "node_modules/sirv": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
+ "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
+ "license": "MIT",
+ "dependencies": {
+ "@polka/url": "^1.0.0-next.24",
+ "mrmime": "^2.0.0",
+ "totalist": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/strnum": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
+ "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/NaturalIntelligence"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/style-to-object": {
+ "version": "1.0.14",
+ "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
+ "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "inline-style-parser": "0.2.7"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/svelte": {
+ "version": "5.48.0",
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.48.0.tgz",
+ "integrity": "sha512-+NUe82VoFP1RQViZI/esojx70eazGF4u0O/9ucqZ4rPcOZD+n5EVp17uYsqwdzjUjZyTpGKunHbDziW6AIAVkQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.4",
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@sveltejs/acorn-typescript": "^1.0.5",
+ "@types/estree": "^1.0.5",
+ "acorn": "^8.12.1",
+ "aria-query": "^5.3.1",
+ "axobject-query": "^4.1.0",
+ "clsx": "^2.1.1",
+ "devalue": "^5.6.2",
+ "esm-env": "^1.2.1",
+ "esrap": "^2.2.1",
+ "is-reference": "^3.0.3",
+ "locate-character": "^3.0.0",
+ "magic-string": "^0.30.11",
+ "zimmerframe": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/svelte-check": {
+ "version": "4.3.5",
+ "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.5.tgz",
+ "integrity": "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.25",
+ "chokidar": "^4.0.1",
+ "fdir": "^6.2.0",
+ "picocolors": "^1.0.0",
+ "sade": "^1.7.4"
+ },
+ "bin": {
+ "svelte-check": "bin/svelte-check"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "peerDependencies": {
+ "svelte": "^4.0.0 || ^5.0.0-next.0",
+ "typescript": ">=5.0.0"
+ }
+ },
+ "node_modules/svelte-toolbelt": {
+ "version": "0.10.6",
+ "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz",
+ "integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/huntabyte"
+ ],
+ "dependencies": {
+ "clsx": "^2.1.1",
+ "runed": "^0.35.1",
+ "style-to-object": "^1.0.8"
+ },
+ "engines": {
+ "node": ">=18",
+ "pnpm": ">=8.7.0"
+ },
+ "peerDependencies": {
+ "svelte": "^5.30.2"
+ }
+ },
+ "node_modules/svelte/node_modules/is-reference": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
+ "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.6"
+ }
+ },
+ "node_modules/tabbable": {
+ "version": "6.4.0",
+ "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
+ "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tailwind-merge": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
+ "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
+ "node_modules/tailwind-variants": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz",
+ "integrity": "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=16.x",
+ "pnpm": ">=7.x"
+ },
+ "peerDependencies": {
+ "tailwind-merge": ">=3.0.0",
+ "tailwindcss": "*"
+ },
+ "peerDependenciesMeta": {
+ "tailwind-merge": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.1.18",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
+ "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
+ "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/totalist": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
+ "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "7.3.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
+ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitefu": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz",
+ "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==",
+ "license": "MIT",
+ "workspaces": [
+ "tests/deps/*",
+ "tests/projects/*",
+ "tests/projects/workspace/packages/*"
+ ],
+ "peerDependencies": {
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0"
+ },
+ "peerDependenciesMeta": {
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
+ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/zimmerframe": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
+ "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
+ "license": "MIT"
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..e4d1dd8
--- /dev/null
+++ b/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "monacousa-portal-2026",
+ "private": true,
+ "version": "0.0.1",
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build",
+ "preview": "vite preview",
+ "prepare": "svelte-kit sync || echo ''",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
+ },
+ "devDependencies": {
+ "@sveltejs/adapter-auto": "^7.0.0",
+ "@sveltejs/kit": "^2.50.0",
+ "@sveltejs/vite-plugin-svelte": "^6.2.1",
+ "@tailwindcss/vite": "^4.1.18",
+ "bits-ui": "^2.15.4",
+ "clsx": "^2.1.1",
+ "lucide-svelte": "^0.562.0",
+ "svelte": "^5.47.0",
+ "svelte-check": "^4.3.4",
+ "tailwind-merge": "^3.4.0",
+ "tailwind-variants": "^3.2.2",
+ "tailwindcss": "^4.1.18",
+ "typescript": "^5.9.3",
+ "vite": "^7.3.1"
+ },
+ "dependencies": {
+ "@aws-sdk/client-s3": "^3.971.0",
+ "@aws-sdk/s3-request-presigner": "^3.971.0",
+ "@internationalized/date": "^3.7.0",
+ "@supabase/ssr": "^0.8.0",
+ "@supabase/supabase-js": "^2.90.1",
+ "@sveltejs/adapter-node": "^5.5.1",
+ "flag-icons": "^7.4.0",
+ "libphonenumber-js": "^1.12.8",
+ "nodemailer": "^6.10.0"
+ }
+}
diff --git a/postcss.config.js b/postcss.config.js
new file mode 100644
index 0000000..5f1fea1
--- /dev/null
+++ b/postcss.config.js
@@ -0,0 +1,7 @@
+// PostCSS config for monacousa-portal-2026
+// Tailwind CSS v4 is handled by @tailwindcss/vite plugin in vite.config.ts
+// This file exists to prevent Vite from picking up parent directory configs
+
+export default {
+ plugins: {}
+};
diff --git a/src/app.css b/src/app.css
new file mode 100644
index 0000000..ec4b959
--- /dev/null
+++ b/src/app.css
@@ -0,0 +1,263 @@
+@import "tailwindcss";
+
+/* Monaco USA Custom Theme */
+@theme {
+ /* Monaco Red - Primary Brand Color */
+ --color-monaco-50: #fef2f2;
+ --color-monaco-100: #fee2e2;
+ --color-monaco-200: #fecaca;
+ --color-monaco-300: #fca5a5;
+ --color-monaco-400: #f87171;
+ --color-monaco-500: #ef4444;
+ --color-monaco-600: #dc2626;
+ --color-monaco-700: #b91c1c;
+ --color-monaco-800: #991b1b;
+ --color-monaco-900: #7f1d1d;
+ --color-monaco-950: #450a0a;
+
+ /* Glass effect shadows */
+ --shadow-glass: 0 8px 32px rgba(0, 0, 0, 0.1);
+ --shadow-glass-lg: 0 25px 50px rgba(0, 0, 0, 0.15);
+ --shadow-glass-xl: 0 35px 60px rgba(0, 0, 0, 0.2);
+
+ /* Backdrop blur values */
+ --blur-glass: 10px;
+ --blur-glass-lg: 20px;
+
+ /* Border radius */
+ --radius-glass: 16px;
+ --radius-glass-lg: 24px;
+
+ /* Animation durations */
+ --duration-fast: 150ms;
+ --duration-normal: 300ms;
+ --duration-slow: 500ms;
+
+ /* Font family */
+ --font-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+}
+
+/* Base styles */
+@layer base {
+ :root {
+ --background: 0 0% 100%;
+ --foreground: 222.2 84% 4.9%;
+ --card: 0 0% 100%;
+ --card-foreground: 222.2 84% 4.9%;
+ --popover: 0 0% 100%;
+ --popover-foreground: 222.2 84% 4.9%;
+ --primary: 0 84% 50%;
+ --primary-foreground: 210 40% 98%;
+ --secondary: 210 40% 96.1%;
+ --secondary-foreground: 222.2 47.4% 11.2%;
+ --muted: 210 40% 96.1%;
+ --muted-foreground: 215.4 16.3% 46.9%;
+ --accent: 210 40% 96.1%;
+ --accent-foreground: 222.2 47.4% 11.2%;
+ --destructive: 0 84.2% 60.2%;
+ --destructive-foreground: 210 40% 98%;
+ --border: 214.3 31.8% 91.4%;
+ --input: 214.3 31.8% 91.4%;
+ --ring: 0 84% 50%;
+ --radius: 0.5rem;
+ }
+
+ .dark {
+ --background: 222.2 84% 4.9%;
+ --foreground: 210 40% 98%;
+ --card: 222.2 84% 4.9%;
+ --card-foreground: 210 40% 98%;
+ --popover: 222.2 84% 4.9%;
+ --popover-foreground: 210 40% 98%;
+ --primary: 0 84% 50%;
+ --primary-foreground: 210 40% 98%;
+ --secondary: 217.2 32.6% 17.5%;
+ --secondary-foreground: 210 40% 98%;
+ --muted: 217.2 32.6% 17.5%;
+ --muted-foreground: 215 20.2% 65.1%;
+ --accent: 217.2 32.6% 17.5%;
+ --accent-foreground: 210 40% 98%;
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 210 40% 98%;
+ --border: 217.2 32.6% 17.5%;
+ --input: 217.2 32.6% 17.5%;
+ --ring: 0 84% 50%;
+ }
+}
+
+@layer base {
+ * {
+ border-color: hsl(var(--border));
+ }
+
+ body {
+ background-color: hsl(var(--background));
+ color: hsl(var(--foreground));
+ font-family: var(--font-sans);
+ font-feature-settings: "rlig" 1, "calt" 1;
+ }
+}
+
+/* Glass-morphism utilities - in @layer components for proper ordering */
+@layer components {
+ .glass {
+ background: rgba(255, 255, 255, 0.7);
+ backdrop-filter: blur(var(--blur-glass));
+ -webkit-backdrop-filter: blur(var(--blur-glass));
+ border: 1px solid rgba(255, 255, 255, 0.3);
+ box-shadow: var(--shadow-glass);
+ }
+
+ .glass-dark {
+ background: rgba(0, 0, 0, 0.3);
+ backdrop-filter: blur(var(--blur-glass));
+ -webkit-backdrop-filter: blur(var(--blur-glass));
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ box-shadow: var(--shadow-glass);
+ }
+
+ .glass-card {
+ background: rgba(255, 255, 255, 0.97);
+ backdrop-filter: blur(var(--blur-glass));
+ -webkit-backdrop-filter: blur(var(--blur-glass));
+ border: 1px solid rgba(226, 232, 240, 0.8);
+ box-shadow: var(--shadow-glass);
+ border-radius: var(--radius-glass);
+ }
+
+ .glass-card-dark {
+ background: rgba(0, 0, 0, 0.3);
+ backdrop-filter: blur(var(--blur-glass));
+ -webkit-backdrop-filter: blur(var(--blur-glass));
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ box-shadow: var(--shadow-glass);
+ border-radius: var(--radius-glass);
+ }
+
+ .gradient-monaco {
+ background: linear-gradient(135deg, var(--color-monaco-600) 0%, var(--color-monaco-700) 100%);
+ }
+
+ .gradient-monaco-light {
+ background: linear-gradient(135deg, var(--color-monaco-50) 0%, var(--color-monaco-100) 100%);
+ }
+
+ .text-gradient-monaco {
+ background: linear-gradient(135deg, var(--color-monaco-600) 0%, var(--color-monaco-800) 100%);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ }
+}
+
+/* Custom utilities for shadcn-svelte compatibility */
+@layer utilities {
+ /* Border color utility using CSS variable */
+ .border-border {
+ border-color: hsl(var(--border));
+ }
+
+ /* Background utilities using CSS variables */
+ .bg-background {
+ background-color: hsl(var(--background));
+ }
+
+ .bg-foreground {
+ background-color: hsl(var(--foreground));
+ }
+
+ .bg-card {
+ background-color: hsl(var(--card));
+ }
+
+ .bg-popover {
+ background-color: hsl(var(--popover));
+ }
+
+ .bg-primary {
+ background-color: hsl(var(--primary));
+ }
+
+ .bg-secondary {
+ background-color: hsl(var(--secondary));
+ }
+
+ .bg-muted {
+ background-color: hsl(var(--muted));
+ }
+
+ .bg-accent {
+ background-color: hsl(var(--accent));
+ }
+
+ .bg-destructive {
+ background-color: hsl(var(--destructive));
+ }
+
+ /* Text color utilities using CSS variables */
+ .text-foreground {
+ color: hsl(var(--foreground));
+ }
+
+ .text-card-foreground {
+ color: hsl(var(--card-foreground));
+ }
+
+ .text-popover-foreground {
+ color: hsl(var(--popover-foreground));
+ }
+
+ .text-primary-foreground {
+ color: hsl(var(--primary-foreground));
+ }
+
+ .text-secondary-foreground {
+ color: hsl(var(--secondary-foreground));
+ }
+
+ .text-muted-foreground {
+ color: hsl(var(--muted-foreground));
+ }
+
+ .text-accent-foreground {
+ color: hsl(var(--accent-foreground));
+ }
+
+ .text-destructive-foreground {
+ color: hsl(var(--destructive-foreground));
+ }
+
+ /* Ring utility */
+ .ring-ring {
+ --tw-ring-color: hsl(var(--ring));
+ }
+
+ /* Input border utility */
+ .border-input {
+ border-color: hsl(var(--input));
+ }
+
+ /* Scrollbar styling */
+ .scrollbar-thin {
+ scrollbar-width: thin;
+ scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
+ }
+
+ .scrollbar-thin::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+ }
+
+ .scrollbar-thin::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ .scrollbar-thin::-webkit-scrollbar-thumb {
+ background-color: hsl(var(--muted-foreground) / 0.3);
+ border-radius: 3px;
+ }
+
+ .scrollbar-thin::-webkit-scrollbar-thumb:hover {
+ background-color: hsl(var(--muted-foreground) / 0.5);
+ }
+}
diff --git a/src/app.d.ts b/src/app.d.ts
new file mode 100644
index 0000000..df65d88
--- /dev/null
+++ b/src/app.d.ts
@@ -0,0 +1,27 @@
+// See https://svelte.dev/docs/kit/types#app.d.ts
+// for information about these interfaces
+import type { SupabaseClient, Session, User } from '@supabase/supabase-js';
+import type { Database, MemberWithDues } from '$lib/types/database';
+
+declare global {
+ namespace App {
+ // interface Error {}
+ interface Locals {
+ supabase: SupabaseClient;
+ safeGetSession: () => Promise<{
+ session: Session | null;
+ user: User | null;
+ member: MemberWithDues | null;
+ }>;
+ }
+ interface PageData {
+ session: Session | null;
+ user: User | null;
+ member: MemberWithDues | null;
+ }
+ // interface PageState {}
+ // interface Platform {}
+ }
+}
+
+export {};
diff --git a/src/app.html b/src/app.html
new file mode 100644
index 0000000..f273cc5
--- /dev/null
+++ b/src/app.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+ %sveltekit.head%
+
+
+ %sveltekit.body%
+
+
diff --git a/src/hooks.server.ts b/src/hooks.server.ts
new file mode 100644
index 0000000..eb422c7
--- /dev/null
+++ b/src/hooks.server.ts
@@ -0,0 +1,134 @@
+import pkg from '@supabase/ssr';
+const { createServerClient } = pkg;
+import { type Handle, redirect } from '@sveltejs/kit';
+import { sequence } from '@sveltejs/kit/hooks';
+import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
+import { env } from '$env/dynamic/private';
+import type { Database } from '$lib/types/database';
+
+// Use internal URL for server-side operations (Docker network), fallback to public URL
+const SERVER_SUPABASE_URL = env.SUPABASE_INTERNAL_URL || PUBLIC_SUPABASE_URL;
+
+/**
+ * Supabase authentication hook
+ * Sets up the Supabase client with cookie handling for SSR
+ */
+const supabaseHandle: Handle = async ({ event, resolve }) => {
+ event.locals.supabase = createServerClient(
+ SERVER_SUPABASE_URL,
+ PUBLIC_SUPABASE_ANON_KEY,
+ {
+ cookies: {
+ getAll: () => event.cookies.getAll(),
+ setAll: (cookiesToSet) => {
+ cookiesToSet.forEach(({ name, value, options }) => {
+ event.cookies.set(name, value, { ...options, path: '/' });
+ });
+ }
+ }
+ }
+ );
+
+ /**
+ * Safe session getter that validates the JWT
+ * Returns session, user, and member data
+ */
+ event.locals.safeGetSession = async () => {
+ const {
+ data: { session }
+ } = await event.locals.supabase.auth.getSession();
+
+ if (!session) {
+ return { session: null, user: null, member: null };
+ }
+
+ // Validate the session by getting the user
+ const {
+ data: { user },
+ error: userError
+ } = await event.locals.supabase.auth.getUser();
+
+ if (userError || !user) {
+ return { session: null, user: null, member: null };
+ }
+
+ // Fetch member profile with dues status
+ const { data: member } = await event.locals.supabase
+ .from('members_with_dues')
+ .select('*')
+ .eq('id', user.id)
+ .single();
+
+ return { session, user, member };
+ };
+
+ return resolve(event, {
+ filterSerializedResponseHeaders(name) {
+ return name === 'content-range' || name === 'x-supabase-api-version';
+ }
+ });
+};
+
+/**
+ * Authorization hook
+ * Protects routes based on authentication and role requirements
+ */
+const authorizationHandle: Handle = async ({ event, resolve }) => {
+ const { session, member } = await event.locals.safeGetSession();
+ const path = event.url.pathname;
+
+ // API routes handle their own authentication
+ if (path.startsWith('/api/')) {
+ return resolve(event);
+ }
+
+ // Auth callback routes should always be accessible
+ if (path.startsWith('/auth/')) {
+ return resolve(event);
+ }
+
+ // Logout route should always be accessible
+ if (path === '/logout') {
+ return resolve(event);
+ }
+
+ // Protected routes - require authentication
+ const protectedPrefixes = ['/dashboard', '/profile', '/payments', '/documents', '/board', '/admin'];
+ const isProtectedRoute = protectedPrefixes.some((prefix) => path.startsWith(prefix));
+
+ if (isProtectedRoute && !session) {
+ throw redirect(303, `/login?redirectTo=${encodeURIComponent(path)}`);
+ }
+
+ // Handle authenticated users without a member profile
+ // This can happen if member record creation failed or was deleted
+ if (isProtectedRoute && session && !member) {
+ console.error('Authenticated user has no member profile:', session.user?.id);
+ // Sign them out and redirect to login with an error
+ await event.locals.supabase.auth.signOut();
+ throw redirect(303, '/login?error=no_profile');
+ }
+
+ // Board routes - require board or admin role
+ if (path.startsWith('/board') && member) {
+ if (member.role !== 'board' && member.role !== 'admin') {
+ throw redirect(303, '/dashboard');
+ }
+ }
+
+ // Admin routes - require admin role
+ if (path.startsWith('/admin') && member) {
+ if (member.role !== 'admin') {
+ throw redirect(303, '/dashboard');
+ }
+ }
+
+ // Redirect authenticated users away from auth pages
+ if (session && (path === '/login' || path === '/signup')) {
+ throw redirect(303, '/dashboard');
+ }
+
+ return resolve(event);
+};
+
+export const handle: Handle = sequence(supabaseHandle, authorizationHandle);
diff --git a/src/lib/assets/favicon.svg b/src/lib/assets/favicon.svg
new file mode 100644
index 0000000..cc5dc66
--- /dev/null
+++ b/src/lib/assets/favicon.svg
@@ -0,0 +1 @@
+svelte-logo
\ No newline at end of file
diff --git a/src/lib/components/EmailVerificationBanner.svelte b/src/lib/components/EmailVerificationBanner.svelte
new file mode 100644
index 0000000..5175a26
--- /dev/null
+++ b/src/lib/components/EmailVerificationBanner.svelte
@@ -0,0 +1,92 @@
+
+
+{#if !dismissed}
+
+
+
+
+
+
+
+
+
+ Please verify your email address
+
+
+ Check {email} for verification link
+
+
+
+
+
+ {#if resendSuccess}
+
+
+ Email sent!
+
+ {:else}
+
+ {#if resending}
+
+ Sending...
+ {:else}
+
+ Resend
+ {/if}
+
+ {/if}
+ (dismissed = true)}
+ class="text-amber-600 hover:bg-amber-100 hover:text-amber-800 p-1.5"
+ aria-label="Dismiss"
+ >
+
+
+
+
+
+
+{/if}
diff --git a/src/lib/components/auth/FormField.svelte b/src/lib/components/auth/FormField.svelte
new file mode 100644
index 0000000..7e95d1a
--- /dev/null
+++ b/src/lib/components/auth/FormField.svelte
@@ -0,0 +1,51 @@
+
+
+
+
+ {label}
+ {#if required}
+ *
+ {/if}
+
+
+ {#if error}
+
{error}
+ {/if}
+
diff --git a/src/lib/components/auth/FormMessage.svelte b/src/lib/components/auth/FormMessage.svelte
new file mode 100644
index 0000000..a5a8390
--- /dev/null
+++ b/src/lib/components/auth/FormMessage.svelte
@@ -0,0 +1,29 @@
+
+
+{#if message}
+
+{/if}
diff --git a/src/lib/components/auth/LoadingSpinner.svelte b/src/lib/components/auth/LoadingSpinner.svelte
new file mode 100644
index 0000000..d9ca774
--- /dev/null
+++ b/src/lib/components/auth/LoadingSpinner.svelte
@@ -0,0 +1,28 @@
+
+
+
+
+
+
diff --git a/src/lib/components/auth/index.ts b/src/lib/components/auth/index.ts
new file mode 100644
index 0000000..1593171
--- /dev/null
+++ b/src/lib/components/auth/index.ts
@@ -0,0 +1,3 @@
+export { default as FormField } from './FormField.svelte';
+export { default as FormMessage } from './FormMessage.svelte';
+export { default as LoadingSpinner } from './LoadingSpinner.svelte';
diff --git a/src/lib/components/dashboard/DuesStatusCard.svelte b/src/lib/components/dashboard/DuesStatusCard.svelte
new file mode 100644
index 0000000..63dbc08
--- /dev/null
+++ b/src/lib/components/dashboard/DuesStatusCard.svelte
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+ Dues Status
+
+
+
+
+
+
+
+
{duesInfo.label}
+
{duesInfo.description}
+
+
+
+
+
+
Annual Dues
+
+ {member.annual_dues ? `€${member.annual_dues.toFixed(2)}` : '€50.00'}
+
+
+
+
Membership Type
+
{member.membership_type_name || 'Regular'}
+
+
+
Last Payment
+
{formatDate(member.last_payment_date)}
+
+
+
Next Due Date
+
{formatDate(member.current_due_date)}
+
+
+
+
+
+
+ View Payment Details
+
+
+
+
diff --git a/src/lib/components/dashboard/QuickActionsCard.svelte b/src/lib/components/dashboard/QuickActionsCard.svelte
new file mode 100644
index 0000000..93a394f
--- /dev/null
+++ b/src/lib/components/dashboard/QuickActionsCard.svelte
@@ -0,0 +1,97 @@
+
+
+
diff --git a/src/lib/components/dashboard/StatsCard.svelte b/src/lib/components/dashboard/StatsCard.svelte
new file mode 100644
index 0000000..26e0892
--- /dev/null
+++ b/src/lib/components/dashboard/StatsCard.svelte
@@ -0,0 +1,48 @@
+
+
+
+
+
+
{title}
+
{value}
+ {#if description}
+
{description}
+ {/if}
+ {#if trend}
+
+ = 0 ? 'text-green-600' : 'text-red-600'}>
+ {trend.value >= 0 ? '+' : ''}{trend.value}%
+
+ {trend.label}
+
+ {/if}
+
+
+
+
+
+
diff --git a/src/lib/components/dashboard/UpcomingEventsCard.svelte b/src/lib/components/dashboard/UpcomingEventsCard.svelte
new file mode 100644
index 0000000..b5446c2
--- /dev/null
+++ b/src/lib/components/dashboard/UpcomingEventsCard.svelte
@@ -0,0 +1,107 @@
+
+
+
diff --git a/src/lib/components/dashboard/WelcomeCard.svelte b/src/lib/components/dashboard/WelcomeCard.svelte
new file mode 100644
index 0000000..36f7d53
--- /dev/null
+++ b/src/lib/components/dashboard/WelcomeCard.svelte
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+ {#if member.avatar_url}
+
+ {:else}
+
+ {member.first_name[0]}{member.last_name[0]}
+
+ {/if}
+
+
+
{getGreeting()},
+
+ {member.first_name}
+ {member.last_name}
+
+
+ {member.member_id}
+ |
+
+ {member.status_display_name || 'Pending'}
+
+
+
+
+
+
+ {#if member.nationality && member.nationality.length > 0}
+
+ {#each member.nationality as code}
+
+ {/each}
+
+ {/if}
+
+
diff --git a/src/lib/components/dashboard/index.ts b/src/lib/components/dashboard/index.ts
new file mode 100644
index 0000000..49c1a5d
--- /dev/null
+++ b/src/lib/components/dashboard/index.ts
@@ -0,0 +1,5 @@
+export { default as WelcomeCard } from './WelcomeCard.svelte';
+export { default as DuesStatusCard } from './DuesStatusCard.svelte';
+export { default as UpcomingEventsCard } from './UpcomingEventsCard.svelte';
+export { default as QuickActionsCard } from './QuickActionsCard.svelte';
+export { default as StatsCard } from './StatsCard.svelte';
diff --git a/src/lib/components/documents/CreateFolderModal.svelte b/src/lib/components/documents/CreateFolderModal.svelte
new file mode 100644
index 0000000..5a3658c
--- /dev/null
+++ b/src/lib/components/documents/CreateFolderModal.svelte
@@ -0,0 +1,142 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Create New Folder
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/lib/components/documents/DocumentPreviewModal.svelte b/src/lib/components/documents/DocumentPreviewModal.svelte
new file mode 100644
index 0000000..d2ffbcb
--- /dev/null
+++ b/src/lib/components/documents/DocumentPreviewModal.svelte
@@ -0,0 +1,246 @@
+
+
+
+
+
+
+
+
+
+
+
+ {#if isPdf}
+
+ {:else if isImage}
+
+ {:else}
+
+ {/if}
+
+
{document.title}
+
{document.file_name} · {formatFileSize(document.file_size)}
+
+
+
+
+
+ {#if isImage}
+
+
+
+
+ {zoom}%
+
+
+
+
+
+
+
+ {/if}
+
+
+
+
+ Download
+
+
+
+
+
+
+
+
+
+
+
+ {#if isLoading && !isImage && !isPdf}
+
+ {:else if loadError}
+
+
+
Unable to load preview
+
+
+ Download File
+
+
+ {:else if isPdf}
+
+
+ {:else if isImage}
+
+
+
isLoading = false}
+ onerror={() => { loadError = true; isLoading = false; }}
+ />
+
+ {:else if isText && textContent !== null}
+
+
+ {:else if isOffice}
+
+
+
+
Office documents cannot be previewed directly
+
Download the file to view it in Microsoft Office or compatible application
+
+
+ Download {document.file_name}
+
+
+ {:else}
+
+
+
+
Preview not available for this file type
+
+
+ Download File
+
+
+ {/if}
+
+
+
diff --git a/src/lib/components/documents/FolderBreadcrumbs.svelte b/src/lib/components/documents/FolderBreadcrumbs.svelte
new file mode 100644
index 0000000..0320925
--- /dev/null
+++ b/src/lib/components/documents/FolderBreadcrumbs.svelte
@@ -0,0 +1,49 @@
+
+
+
+ {#each breadcrumbs as crumb, index}
+ {#if index > 0}
+
+ {/if}
+
+ {#if index === breadcrumbs.length - 1}
+
+
+ {#if index === 0}
+
+ {:else}
+
+ {/if}
+ {crumb.name}
+
+ {:else}
+
+ onNavigate(crumb.id)}
+ class="flex items-center gap-1.5 rounded-md px-2 py-1 text-slate-600 hover:bg-slate-100 hover:text-slate-900 transition-colors"
+ >
+ {#if index === 0}
+
+ {:else}
+
+ {/if}
+ {crumb.name}
+
+ {/if}
+ {/each}
+
diff --git a/src/lib/components/documents/FolderItem.svelte b/src/lib/components/documents/FolderItem.svelte
new file mode 100644
index 0000000..8cbd7ae
--- /dev/null
+++ b/src/lib/components/documents/FolderItem.svelte
@@ -0,0 +1,120 @@
+
+
+
+
+
+
+
+
+
+
+
{folder.name}
+
+
+ {folder.visibility}
+
+ {#if folder.creator}
+ ·
+ {folder.creator.first_name} {folder.creator.last_name}
+ {/if}
+
+
+
+
+ {#if canEdit || canDelete}
+
e.stopPropagation()}
+ onkeydown={(e) => e.stopPropagation()}
+ >
+
+
+
+
+
+
+ {#if canEdit && onRename}
+ onRename(folder)}
+ >
+
+ Rename
+
+ {/if}
+ {#if canDelete && onDelete}
+ onDelete(folder)}
+ >
+
+ Delete
+
+ {/if}
+
+
+
+
+ {/if}
+
diff --git a/src/lib/components/documents/index.ts b/src/lib/components/documents/index.ts
new file mode 100644
index 0000000..7da6c72
--- /dev/null
+++ b/src/lib/components/documents/index.ts
@@ -0,0 +1,4 @@
+export { default as DocumentPreviewModal } from './DocumentPreviewModal.svelte';
+export { default as FolderItem } from './FolderItem.svelte';
+export { default as FolderBreadcrumbs } from './FolderBreadcrumbs.svelte';
+export { default as CreateFolderModal } from './CreateFolderModal.svelte';
diff --git a/src/lib/components/layout/Header.svelte b/src/lib/components/layout/Header.svelte
new file mode 100644
index 0000000..3b01046
--- /dev/null
+++ b/src/lib/components/layout/Header.svelte
@@ -0,0 +1,152 @@
+
+
+
+
+
diff --git a/src/lib/components/layout/MobileMenu.svelte b/src/lib/components/layout/MobileMenu.svelte
new file mode 100644
index 0000000..9cf0a41
--- /dev/null
+++ b/src/lib/components/layout/MobileMenu.svelte
@@ -0,0 +1,203 @@
+
+
+{#if open}
+
+
+
+
+
+
+
+
+ Menu
+
+
+
+
+
+
+
+
+
+
+
+ {#if isBoard}
+
+
+ Board
+
+
+ {/if}
+
+
+ {#if isAdmin}
+
+
+ Admin
+
+
+ {/if}
+
+
+
+
+ {#if member}
+
+ {#if member.avatar_url}
+
+ {:else}
+
+ {member.first_name[0]}{member.last_name[0]}
+
+ {/if}
+
+
+ {member.first_name}
+ {member.last_name}
+
+
{member.member_id}
+
+
+ {/if}
+
+
+
+
+
+{/if}
diff --git a/src/lib/components/layout/MobileNav.svelte b/src/lib/components/layout/MobileNav.svelte
new file mode 100644
index 0000000..a8fbf07
--- /dev/null
+++ b/src/lib/components/layout/MobileNav.svelte
@@ -0,0 +1,53 @@
+
+
+
+
+ {#each navItems as item}
+ {@const Icon = item.icon}
+
+
+ {item.label}
+
+ {/each}
+
+
+ More
+
+
+
diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte
new file mode 100644
index 0000000..3189fcf
--- /dev/null
+++ b/src/lib/components/layout/Sidebar.svelte
@@ -0,0 +1,245 @@
+
+
+
diff --git a/src/lib/components/layout/index.ts b/src/lib/components/layout/index.ts
new file mode 100644
index 0000000..1bdcf7c
--- /dev/null
+++ b/src/lib/components/layout/index.ts
@@ -0,0 +1,4 @@
+export { default as Sidebar } from './Sidebar.svelte';
+export { default as Header } from './Header.svelte';
+export { default as MobileNav } from './MobileNav.svelte';
+export { default as MobileMenu } from './MobileMenu.svelte';
diff --git a/src/lib/components/ui/AddToCalendarButton.svelte b/src/lib/components/ui/AddToCalendarButton.svelte
new file mode 100644
index 0000000..f625291
--- /dev/null
+++ b/src/lib/components/ui/AddToCalendarButton.svelte
@@ -0,0 +1,171 @@
+
+
+
+
(isOpen = !isOpen)}
+ class="gap-2"
+ >
+
+ Add to Calendar
+
+
+
+ {#if isOpen}
+
+ {/if}
+
diff --git a/src/lib/components/ui/CountryFlag.svelte b/src/lib/components/ui/CountryFlag.svelte
new file mode 100644
index 0000000..42d12cf
--- /dev/null
+++ b/src/lib/components/ui/CountryFlag.svelte
@@ -0,0 +1,40 @@
+
+
+
diff --git a/src/lib/components/ui/CountrySelect.svelte b/src/lib/components/ui/CountrySelect.svelte
new file mode 100644
index 0000000..704a06f
--- /dev/null
+++ b/src/lib/components/ui/CountrySelect.svelte
@@ -0,0 +1,173 @@
+
+
+
+
({ value: c.code, label: c.name }))}
+ bind:value
+ onValueChange={(v) => v && handleSelect(v)}
+ >
+
+
+ {#if selectedCountry}
+
+
+ {selectedCountry.name}
+
+ {:else}
+ {placeholder}
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ {
+ searchValue = e.currentTarget.value;
+ }}
+ />
+
+
+
+
+ {#if filteredCountries.length === 0}
+
+ No countries found
+
+ {:else}
+ {#each filteredCountries as country (country.code)}
+ {@const isSelected = value === country.code}
+
+ {#snippet children({ selected })}
+
+ {country.name}
+ {#if selected}
+
+ {/if}
+ {/snippet}
+
+ {/each}
+ {/if}
+
+
+
+
+
+
+ {#if name}
+
+ {/if}
+
diff --git a/src/lib/components/ui/DatePicker.svelte b/src/lib/components/ui/DatePicker.svelte
new file mode 100644
index 0000000..91dcc9d
--- /dev/null
+++ b/src/lib/components/ui/DatePicker.svelte
@@ -0,0 +1,190 @@
+
+
+
+
+ {#snippet children({ segments })}
+
+ {#each segments as { part, value: segValue }}
+ {#if part === 'literal'}
+ {segValue}
+ {:else}
+
+ {segValue}
+
+ {/if}
+ {/each}
+
+
+
+
+ {/snippet}
+
+
+
+
+ {#snippet children({ months, weekdays })}
+
+
+
+
+
+
+
+
+
+
+ {#each months as month}
+
+
+
+ {#each weekdays as day}
+
+ {day.slice(0, 2)}
+
+ {/each}
+
+
+
+ {#each month.weeks as weekDates}
+
+ {#each weekDates as date}
+
+
+
+ {/each}
+
+ {/each}
+
+
+ {/each}
+ {/snippet}
+
+
+
+
+
+{#if name}
+
+{/if}
diff --git a/src/lib/components/ui/NationalitySelect.svelte b/src/lib/components/ui/NationalitySelect.svelte
new file mode 100644
index 0000000..c8f542c
--- /dev/null
+++ b/src/lib/components/ui/NationalitySelect.svelte
@@ -0,0 +1,198 @@
+
+
+
+
+ {#if value.length > 0}
+
+ {#each value as code}
+ {@const country = getCountry(code)}
+ {#if country}
+
+
+ {country.name}
+ removeSelection(code)}
+ class="ml-0.5 rounded-full p-0.5 transition-colors hover:bg-monaco-200"
+ {disabled}
+ >
+
+
+
+ {/if}
+ {/each}
+
+ {/if}
+
+
+
onValueChange?.(v)}
+ >
+
+
+
+ 0 ? 'Add more...' : placeholder}
+ {disabled}
+ oninput={(e) => {
+ searchValue = e.currentTarget.value;
+ if (!open) open = true;
+ }}
+ />
+
+
+
+
+
+
+
+ {#if filteredCountries.length === 0}
+
+ No countries found
+
+ {:else}
+ {#each filteredCountries as country (country.code)}
+ {@const isSelected = value.includes(country.code)}
+ {@const isDisabled = !isSelected && maxSelections > 0 && value.length >= maxSelections}
+
+ {#snippet children({ selected })}
+
+ {country.name}
+ {country.code}
+ {#if selected}
+
+ {/if}
+ {/snippet}
+
+ {/each}
+ {/if}
+
+
+
+
+
+
+ {#if name}
+
+ {/if}
+
diff --git a/src/lib/components/ui/PhoneInput.svelte b/src/lib/components/ui/PhoneInput.svelte
new file mode 100644
index 0000000..3e7e9a0
--- /dev/null
+++ b/src/lib/components/ui/PhoneInput.svelte
@@ -0,0 +1,260 @@
+
+
+
+
+
({ value: c.code, label: c.name }))}
+ bind:value={countryCode}
+ onValueChange={(v) => v && handleCountryChange(v)}
+ >
+
+
+
+ {selectedCountry.dialCode}
+
+
+
+
+
+
+
+
+
+
+ {
+ searchValue = e.currentTarget.value;
+ }}
+ />
+
+
+
+
+ {#if filteredCountries.length === 0}
+
+ No countries found
+
+ {:else}
+ {#each filteredCountries as country (country.code)}
+ {@const isSelected = countryCode === country.code}
+
+ {#snippet children({ selected })}
+
+ {country.name}
+ {country.dialCode}
+ {/snippet}
+
+ {/each}
+ {/if}
+
+
+
+
+
+
+
+
+
+ {#if name}
+
+ {/if}
+
diff --git a/src/lib/components/ui/button/button.svelte b/src/lib/components/ui/button/button.svelte
new file mode 100644
index 0000000..a6711e0
--- /dev/null
+++ b/src/lib/components/ui/button/button.svelte
@@ -0,0 +1,65 @@
+
+
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/button/index.ts b/src/lib/components/ui/button/index.ts
new file mode 100644
index 0000000..6086c50
--- /dev/null
+++ b/src/lib/components/ui/button/index.ts
@@ -0,0 +1,15 @@
+import Root, {
+ type ButtonProps,
+ type ButtonSize,
+ type ButtonVariant,
+ buttonVariants
+} from './button.svelte';
+
+export {
+ Root,
+ type ButtonProps,
+ type ButtonSize,
+ type ButtonVariant,
+ buttonVariants,
+ Root as Button
+};
diff --git a/src/lib/components/ui/card/card-content.svelte b/src/lib/components/ui/card/card-content.svelte
new file mode 100644
index 0000000..fba5c6b
--- /dev/null
+++ b/src/lib/components/ui/card/card-content.svelte
@@ -0,0 +1,13 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/card/card-description.svelte b/src/lib/components/ui/card/card-description.svelte
new file mode 100644
index 0000000..2da58ab
--- /dev/null
+++ b/src/lib/components/ui/card/card-description.svelte
@@ -0,0 +1,13 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/card/card-footer.svelte b/src/lib/components/ui/card/card-footer.svelte
new file mode 100644
index 0000000..739f801
--- /dev/null
+++ b/src/lib/components/ui/card/card-footer.svelte
@@ -0,0 +1,13 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/card/card-header.svelte b/src/lib/components/ui/card/card-header.svelte
new file mode 100644
index 0000000..80801c6
--- /dev/null
+++ b/src/lib/components/ui/card/card-header.svelte
@@ -0,0 +1,13 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/card/card-title.svelte b/src/lib/components/ui/card/card-title.svelte
new file mode 100644
index 0000000..9b4e997
--- /dev/null
+++ b/src/lib/components/ui/card/card-title.svelte
@@ -0,0 +1,13 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/card/card.svelte b/src/lib/components/ui/card/card.svelte
new file mode 100644
index 0000000..29e1c6f
--- /dev/null
+++ b/src/lib/components/ui/card/card.svelte
@@ -0,0 +1,16 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/components/ui/card/index.ts b/src/lib/components/ui/card/index.ts
new file mode 100644
index 0000000..2f3ee7a
--- /dev/null
+++ b/src/lib/components/ui/card/index.ts
@@ -0,0 +1,21 @@
+import Root from './card.svelte';
+import Content from './card-content.svelte';
+import Description from './card-description.svelte';
+import Footer from './card-footer.svelte';
+import Header from './card-header.svelte';
+import Title from './card-title.svelte';
+
+export {
+ Root,
+ Content,
+ Description,
+ Footer,
+ Header,
+ Title,
+ Root as Card,
+ Content as CardContent,
+ Description as CardDescription,
+ Footer as CardFooter,
+ Header as CardHeader,
+ Title as CardTitle
+};
diff --git a/src/lib/components/ui/index.ts b/src/lib/components/ui/index.ts
new file mode 100644
index 0000000..41c3320
--- /dev/null
+++ b/src/lib/components/ui/index.ts
@@ -0,0 +1,19 @@
+// UI Components - shadcn-svelte style
+export { Button, buttonVariants, type ButtonProps, type ButtonSize, type ButtonVariant } from './button';
+export {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle
+} from './card';
+export { Input } from './input';
+export { Label } from './label';
+
+// Custom components
+export { default as DatePicker } from './DatePicker.svelte';
+export { default as NationalitySelect } from './NationalitySelect.svelte';
+export { default as CountryFlag } from './CountryFlag.svelte';
+export { default as PhoneInput } from './PhoneInput.svelte';
+export { default as CountrySelect } from './CountrySelect.svelte';
diff --git a/src/lib/components/ui/input/index.ts b/src/lib/components/ui/input/index.ts
new file mode 100644
index 0000000..ba5ce62
--- /dev/null
+++ b/src/lib/components/ui/input/index.ts
@@ -0,0 +1,3 @@
+import Root from './input.svelte';
+
+export { Root, Root as Input };
diff --git a/src/lib/components/ui/input/input.svelte b/src/lib/components/ui/input/input.svelte
new file mode 100644
index 0000000..640c347
--- /dev/null
+++ b/src/lib/components/ui/input/input.svelte
@@ -0,0 +1,24 @@
+
+
+
diff --git a/src/lib/components/ui/label/index.ts b/src/lib/components/ui/label/index.ts
new file mode 100644
index 0000000..af72692
--- /dev/null
+++ b/src/lib/components/ui/label/index.ts
@@ -0,0 +1,3 @@
+import Root from './label.svelte';
+
+export { Root, Root as Label };
diff --git a/src/lib/components/ui/label/label.svelte b/src/lib/components/ui/label/label.svelte
new file mode 100644
index 0000000..9442ac9
--- /dev/null
+++ b/src/lib/components/ui/label/label.svelte
@@ -0,0 +1,19 @@
+
+
+
+ {@render children?.()}
+
diff --git a/src/lib/index.ts b/src/lib/index.ts
new file mode 100644
index 0000000..68d31ef
--- /dev/null
+++ b/src/lib/index.ts
@@ -0,0 +1,3 @@
+// Monaco USA Portal 2026 - Library exports
+export * from './utils';
+
diff --git a/src/lib/server/audit.ts b/src/lib/server/audit.ts
new file mode 100644
index 0000000..9ebea9a
--- /dev/null
+++ b/src/lib/server/audit.ts
@@ -0,0 +1,233 @@
+import { supabaseAdmin } from './supabase';
+
+export type AuditAction =
+ | 'member.create'
+ | 'member.update'
+ | 'member.delete'
+ | 'member.role_change'
+ | 'member.status_change'
+ | 'member.invite'
+ | 'event.create'
+ | 'event.update'
+ | 'event.delete'
+ | 'event.cancel'
+ | 'rsvp.create'
+ | 'rsvp.update'
+ | 'rsvp.cancel'
+ | 'rsvp.waitlist_promote'
+ | 'payment.record'
+ | 'payment.delete'
+ | 'document.upload'
+ | 'document.delete'
+ | 'document.visibility_change'
+ | 'settings.update'
+ | 'email.send'
+ | 'auth.login'
+ | 'auth.logout'
+ | 'auth.password_reset';
+
+export interface AuditLogEntry {
+ userId?: string;
+ userEmail?: string;
+ action: AuditAction;
+ resourceType?: string;
+ resourceId?: string;
+ details?: Record;
+ ipAddress?: string;
+ userAgent?: string;
+}
+
+/**
+ * Log an audit event to the database
+ */
+export async function logAudit(entry: AuditLogEntry): Promise<{ success: boolean; error?: string }> {
+ try {
+ const { error } = await supabaseAdmin.from('audit_logs').insert({
+ user_id: entry.userId || null,
+ user_email: entry.userEmail || null,
+ action: entry.action,
+ resource_type: entry.resourceType || null,
+ resource_id: entry.resourceId || null,
+ details: entry.details || {},
+ ip_address: entry.ipAddress || null,
+ user_agent: entry.userAgent || null
+ });
+
+ if (error) {
+ console.error('Audit log error:', error);
+ return { success: false, error: error.message };
+ }
+
+ return { success: true };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ console.error('Audit log exception:', error);
+ return { success: false, error: errorMessage };
+ }
+}
+
+/**
+ * Log a member-related action
+ */
+export async function logMemberAction(
+ action: 'create' | 'update' | 'delete' | 'role_change' | 'status_change' | 'invite',
+ performedBy: { id: string; email: string },
+ targetMember: { id: string; email?: string },
+ details?: Record,
+ requestInfo?: { ip?: string; userAgent?: string }
+): Promise {
+ await logAudit({
+ userId: performedBy.id,
+ userEmail: performedBy.email,
+ action: `member.${action}` as AuditAction,
+ resourceType: 'member',
+ resourceId: targetMember.id,
+ details: {
+ target_email: targetMember.email,
+ ...details
+ },
+ ipAddress: requestInfo?.ip,
+ userAgent: requestInfo?.userAgent
+ });
+}
+
+/**
+ * Log an event-related action
+ */
+export async function logEventAction(
+ action: 'create' | 'update' | 'delete' | 'cancel',
+ performedBy: { id: string; email: string },
+ event: { id: string; title?: string },
+ details?: Record,
+ requestInfo?: { ip?: string; userAgent?: string }
+): Promise {
+ await logAudit({
+ userId: performedBy.id,
+ userEmail: performedBy.email,
+ action: `event.${action}` as AuditAction,
+ resourceType: 'event',
+ resourceId: event.id,
+ details: {
+ event_title: event.title,
+ ...details
+ },
+ ipAddress: requestInfo?.ip,
+ userAgent: requestInfo?.userAgent
+ });
+}
+
+/**
+ * Log a payment-related action
+ */
+export async function logPaymentAction(
+ action: 'record' | 'delete',
+ performedBy: { id: string; email: string },
+ payment: { id?: string; memberId: string; amount?: number },
+ details?: Record,
+ requestInfo?: { ip?: string; userAgent?: string }
+): Promise {
+ await logAudit({
+ userId: performedBy.id,
+ userEmail: performedBy.email,
+ action: `payment.${action}` as AuditAction,
+ resourceType: 'payment',
+ resourceId: payment.id,
+ details: {
+ member_id: payment.memberId,
+ amount: payment.amount,
+ ...details
+ },
+ ipAddress: requestInfo?.ip,
+ userAgent: requestInfo?.userAgent
+ });
+}
+
+/**
+ * Log a document-related action
+ */
+export async function logDocumentAction(
+ action: 'upload' | 'delete' | 'visibility_change',
+ performedBy: { id: string; email: string },
+ document: { id: string; title?: string },
+ details?: Record,
+ requestInfo?: { ip?: string; userAgent?: string }
+): Promise {
+ await logAudit({
+ userId: performedBy.id,
+ userEmail: performedBy.email,
+ action: `document.${action}` as AuditAction,
+ resourceType: 'document',
+ resourceId: document.id,
+ details: {
+ document_title: document.title,
+ ...details
+ },
+ ipAddress: requestInfo?.ip,
+ userAgent: requestInfo?.userAgent
+ });
+}
+
+/**
+ * Log settings update
+ */
+export async function logSettingsUpdate(
+ performedBy: { id: string; email: string },
+ category: string,
+ details?: Record,
+ requestInfo?: { ip?: string; userAgent?: string }
+): Promise {
+ await logAudit({
+ userId: performedBy.id,
+ userEmail: performedBy.email,
+ action: 'settings.update',
+ resourceType: 'settings',
+ resourceId: category,
+ details,
+ ipAddress: requestInfo?.ip,
+ userAgent: requestInfo?.userAgent
+ });
+}
+
+/**
+ * Get recent audit logs
+ */
+export async function getRecentAuditLogs(
+ limit: number = 50,
+ filters?: {
+ action?: string;
+ resourceType?: string;
+ userId?: string;
+ startDate?: string;
+ endDate?: string;
+ }
+): Promise<{ logs: any[]; error: string | null }> {
+ let query = supabaseAdmin
+ .from('audit_logs')
+ .select('*')
+ .order('created_at', { ascending: false })
+ .limit(limit);
+
+ if (filters?.action) {
+ query = query.eq('action', filters.action);
+ }
+ if (filters?.resourceType) {
+ query = query.eq('resource_type', filters.resourceType);
+ }
+ if (filters?.userId) {
+ query = query.eq('user_id', filters.userId);
+ }
+ if (filters?.startDate) {
+ query = query.gte('created_at', filters.startDate);
+ }
+ if (filters?.endDate) {
+ query = query.lte('created_at', filters.endDate);
+ }
+
+ const { data, error } = await query;
+
+ if (error) {
+ return { logs: [], error: error.message };
+ }
+
+ return { logs: data || [], error: null };
+}
diff --git a/src/lib/server/dues.ts b/src/lib/server/dues.ts
new file mode 100644
index 0000000..1c0ba73
--- /dev/null
+++ b/src/lib/server/dues.ts
@@ -0,0 +1,881 @@
+/**
+ * Dues Management Service
+ * Handles dues reminders, bulk operations, and analytics
+ */
+
+import { supabaseAdmin } from './supabase';
+import { sendTemplatedEmail } from './email';
+import type { MemberWithDues } from '$lib/types/database';
+
+// ============================================
+// TYPES
+// ============================================
+
+export type ReminderType = 'due_soon_30' | 'due_soon_7' | 'due_soon_1' | 'overdue' | 'grace_period' | 'inactive_notice';
+
+// Onboarding reminder types (for new members with payment_deadline)
+export type OnboardingReminderType = 'onboarding_reminder_7' | 'onboarding_reminder_1' | 'onboarding_expired';
+
+export interface DuesSettings {
+ reminder_days_before: number[];
+ grace_period_days: number;
+ auto_inactive_enabled: boolean;
+ payment_iban: string;
+ payment_account_holder: string;
+ payment_bank_name: string;
+}
+
+export interface DuesReminderResult {
+ sent: number;
+ skipped: number;
+ errors: string[];
+ members: Array<{ id: string; name: string; email: string; status: 'sent' | 'skipped' | 'error'; error?: string }>;
+}
+
+export interface DuesAnalytics {
+ totalMembers: number;
+ current: number;
+ dueSoon: number;
+ overdue: number;
+ neverPaid: number;
+ totalCollectedThisMonth: number;
+ totalCollectedThisYear: number;
+ totalOutstanding: number;
+ paymentsByMonth: Array<{ month: string; amount: number; count: number }>;
+ remindersSentThisMonth: number;
+ statusBreakdown: Array<{ status: string; count: number; percentage: number }>;
+}
+
+// ============================================
+// SETTINGS
+// ============================================
+
+/**
+ * Get dues-related settings from the database
+ */
+export async function getDuesSettings(): Promise {
+ const { data: settings } = await supabaseAdmin
+ .from('app_settings')
+ .select('setting_key, setting_value')
+ .eq('category', 'dues');
+
+ const config: Record = {};
+ for (const s of settings || []) {
+ config[s.setting_key] = s.setting_value;
+ }
+
+ return {
+ reminder_days_before: Array.isArray(config.reminder_days_before)
+ ? config.reminder_days_before
+ : [30, 7, 1],
+ grace_period_days: typeof config.grace_period_days === 'number' ? config.grace_period_days : 30,
+ auto_inactive_enabled:
+ typeof config.auto_inactive_enabled === 'boolean' ? config.auto_inactive_enabled : true,
+ payment_iban: config.payment_iban || '',
+ payment_account_holder: config.payment_account_holder || '',
+ payment_bank_name: config.payment_bank_name || ''
+ };
+}
+
+// ============================================
+// MEMBER QUERIES
+// ============================================
+
+/**
+ * Get members who need a specific type of reminder
+ * Excludes members who have already received this reminder for their current due date
+ */
+export async function getMembersNeedingReminder(reminderType: ReminderType): Promise {
+ const settings = await getDuesSettings();
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+
+ // Get all members with dues info
+ const { data: members, error } = await supabaseAdmin
+ .from('members_with_dues')
+ .select('*')
+ .not('email', 'is', null);
+
+ if (error || !members) {
+ console.error('Error fetching members:', error);
+ return [];
+ }
+
+ // Filter based on reminder type
+ let filteredMembers: MemberWithDues[] = [];
+
+ if (reminderType.startsWith('due_soon_')) {
+ const daysMatch = reminderType.match(/due_soon_(\d+)/);
+ if (!daysMatch) return [];
+ const daysBefore = parseInt(daysMatch[1]);
+
+ filteredMembers = members.filter((m) => {
+ if (!m.current_due_date || m.dues_status === 'never_paid') return false;
+ const dueDate = new Date(m.current_due_date);
+ const daysUntil = Math.ceil((dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
+ // Member is due within the specified window (e.g., 30 days means dues_until <= 30)
+ return daysUntil > 0 && daysUntil <= daysBefore;
+ });
+ } else if (reminderType === 'overdue') {
+ filteredMembers = members.filter((m) => {
+ if (!m.current_due_date) return false;
+ const daysOverdue = m.days_overdue || 0;
+ // Overdue but still within grace period
+ return m.dues_status === 'overdue' && daysOverdue <= settings.grace_period_days;
+ });
+ } else if (reminderType === 'grace_period') {
+ filteredMembers = members.filter((m) => {
+ if (!m.current_due_date) return false;
+ const daysOverdue = m.days_overdue || 0;
+ // In final week of grace period
+ const graceDaysRemaining = settings.grace_period_days - daysOverdue;
+ return m.dues_status === 'overdue' && graceDaysRemaining > 0 && graceDaysRemaining <= 7;
+ });
+ }
+
+ // Exclude members who already received this reminder for their current due period
+ if (filteredMembers.length > 0) {
+ const memberIds = filteredMembers.map((m) => m.id);
+
+ // Get reminders already sent
+ const { data: existingReminders } = await supabaseAdmin
+ .from('dues_reminder_logs')
+ .select('member_id, due_date')
+ .eq('reminder_type', reminderType)
+ .in('member_id', memberIds);
+
+ if (existingReminders && existingReminders.length > 0) {
+ const sentSet = new Set(
+ existingReminders.map((r) => `${r.member_id}-${r.due_date}`)
+ );
+
+ filteredMembers = filteredMembers.filter((m) => {
+ const key = `${m.id}-${m.current_due_date}`;
+ return !sentSet.has(key);
+ });
+ }
+ }
+
+ return filteredMembers;
+}
+
+/**
+ * Get overdue members who have exceeded the grace period and should be marked inactive
+ */
+export async function getMembersForInactivation(): Promise {
+ const settings = await getDuesSettings();
+
+ if (!settings.auto_inactive_enabled) {
+ return [];
+ }
+
+ const { data: members } = await supabaseAdmin
+ .from('members_with_dues')
+ .select('*')
+ .eq('dues_status', 'overdue')
+ .not('status_name', 'eq', 'inactive');
+
+ if (!members) return [];
+
+ // Filter to those past grace period
+ return members.filter((m) => {
+ const daysOverdue = m.days_overdue || 0;
+ return daysOverdue > settings.grace_period_days;
+ });
+}
+
+// ============================================
+// REMINDER SENDING
+// ============================================
+
+/**
+ * Send a dues reminder to a specific member
+ */
+export async function sendDuesReminder(
+ member: MemberWithDues,
+ reminderType: ReminderType,
+ baseUrl: string = 'https://monacousa.org'
+): Promise<{ success: boolean; error?: string; emailLogId?: string }> {
+ const settings = await getDuesSettings();
+
+ // Determine template key based on reminder type
+ const templateKey =
+ reminderType === 'overdue'
+ ? 'dues_overdue'
+ : reminderType === 'grace_period'
+ ? 'dues_grace_warning'
+ : reminderType === 'inactive_notice'
+ ? 'dues_inactive_notice'
+ : `dues_reminder_${reminderType.replace('due_soon_', '')}`;
+
+ // Calculate variables
+ const dueDate = member.current_due_date
+ ? new Date(member.current_due_date).toLocaleDateString('en-US', {
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric'
+ })
+ : 'N/A';
+
+ const daysOverdue = member.days_overdue || 0;
+ const graceDaysRemaining = Math.max(0, settings.grace_period_days - daysOverdue);
+ const graceEndDate = member.current_due_date
+ ? new Date(
+ new Date(member.current_due_date).getTime() +
+ settings.grace_period_days * 24 * 60 * 60 * 1000
+ ).toLocaleDateString('en-US', {
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric'
+ })
+ : 'N/A';
+
+ const variables: Record = {
+ first_name: member.first_name,
+ last_name: member.last_name,
+ member_id: member.member_id,
+ due_date: dueDate,
+ amount: `€${(member.annual_dues || 50).toFixed(2)}`,
+ days_overdue: daysOverdue.toString(),
+ grace_days_remaining: graceDaysRemaining.toString(),
+ grace_end_date: graceEndDate,
+ account_holder: settings.payment_account_holder,
+ bank_name: settings.payment_bank_name,
+ iban: settings.payment_iban,
+ portal_url: `${baseUrl}/payments`
+ };
+
+ // Send email
+ const result = await sendTemplatedEmail(templateKey, member.email, variables, {
+ recipientId: member.id,
+ recipientName: `${member.first_name} ${member.last_name}`,
+ baseUrl
+ });
+
+ if (!result.success) {
+ return { success: false, error: result.error };
+ }
+
+ // Log the reminder
+ const { error: logError } = await supabaseAdmin.from('dues_reminder_logs').insert({
+ member_id: member.id,
+ reminder_type: reminderType,
+ due_date: member.current_due_date || new Date().toISOString().split('T')[0]
+ });
+
+ if (logError) {
+ console.error('Error logging reminder:', logError);
+ }
+
+ return { success: true };
+}
+
+/**
+ * Send bulk reminders of a specific type
+ */
+export async function sendBulkReminders(
+ reminderType: ReminderType,
+ baseUrl: string = 'https://monacousa.org'
+): Promise {
+ const members = await getMembersNeedingReminder(reminderType);
+
+ const result: DuesReminderResult = {
+ sent: 0,
+ skipped: 0,
+ errors: [],
+ members: []
+ };
+
+ for (const member of members) {
+ try {
+ const sendResult = await sendDuesReminder(member, reminderType, baseUrl);
+
+ if (sendResult.success) {
+ result.sent++;
+ result.members.push({
+ id: member.id,
+ name: `${member.first_name} ${member.last_name}`,
+ email: member.email,
+ status: 'sent'
+ });
+ } else {
+ result.errors.push(`${member.email}: ${sendResult.error}`);
+ result.members.push({
+ id: member.id,
+ name: `${member.first_name} ${member.last_name}`,
+ email: member.email,
+ status: 'error',
+ error: sendResult.error
+ });
+ }
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ result.errors.push(`${member.email}: ${errorMessage}`);
+ result.members.push({
+ id: member.id,
+ name: `${member.first_name} ${member.last_name}`,
+ email: member.email,
+ status: 'error',
+ error: errorMessage
+ });
+ }
+ }
+
+ return result;
+}
+
+// ============================================
+// GRACE PERIOD & INACTIVATION
+// ============================================
+
+/**
+ * Process members who have exceeded grace period and mark them inactive
+ */
+export async function processGracePeriodExpirations(
+ baseUrl: string = 'https://monacousa.org'
+): Promise<{ processed: number; members: Array<{ id: string; name: string; email: string }> }> {
+ const settings = await getDuesSettings();
+
+ if (!settings.auto_inactive_enabled) {
+ return { processed: 0, members: [] };
+ }
+
+ const members = await getMembersForInactivation();
+ const processed: Array<{ id: string; name: string; email: string }> = [];
+
+ // Get inactive status ID
+ const { data: inactiveStatus } = await supabaseAdmin
+ .from('membership_statuses')
+ .select('id')
+ .eq('name', 'inactive')
+ .single();
+
+ if (!inactiveStatus) {
+ console.error('Inactive status not found');
+ return { processed: 0, members: [] };
+ }
+
+ for (const member of members) {
+ // Update member status to inactive
+ const { error: updateError } = await supabaseAdmin
+ .from('members')
+ .update({ membership_status_id: inactiveStatus.id })
+ .eq('id', member.id);
+
+ if (updateError) {
+ console.error(`Error updating member ${member.id}:`, updateError);
+ continue;
+ }
+
+ // Send inactive notice
+ await sendDuesReminder(member, 'inactive_notice', baseUrl);
+
+ // Log the reminder
+ await supabaseAdmin.from('dues_reminder_logs').insert({
+ member_id: member.id,
+ reminder_type: 'inactive_notice',
+ due_date: member.current_due_date || new Date().toISOString().split('T')[0]
+ });
+
+ processed.push({
+ id: member.id,
+ name: `${member.first_name} ${member.last_name}`,
+ email: member.email
+ });
+ }
+
+ return { processed: processed.length, members: processed };
+}
+
+// ============================================
+// ANALYTICS
+// ============================================
+
+/**
+ * Get comprehensive dues analytics
+ */
+export async function getDuesAnalytics(): Promise {
+ const now = new Date();
+ const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
+ const startOfYear = new Date(now.getFullYear(), 0, 1);
+
+ // Get members with dues
+ const { data: members } = await supabaseAdmin.from('members_with_dues').select('*');
+
+ const allMembers = members || [];
+ const totalMembers = allMembers.length;
+ const current = allMembers.filter((m) => m.dues_status === 'current').length;
+ const dueSoon = allMembers.filter((m) => m.dues_status === 'due_soon').length;
+ const overdue = allMembers.filter((m) => m.dues_status === 'overdue').length;
+ const neverPaid = allMembers.filter((m) => m.dues_status === 'never_paid').length;
+
+ // Calculate total outstanding (overdue + due_soon + never_paid)
+ const totalOutstanding = allMembers
+ .filter((m) => m.dues_status !== 'current')
+ .reduce((sum, m) => sum + (m.annual_dues || 0), 0);
+
+ // Get payments this month
+ const { data: monthPayments } = await supabaseAdmin
+ .from('dues_payments')
+ .select('amount')
+ .gte('payment_date', startOfMonth.toISOString().split('T')[0]);
+
+ const totalCollectedThisMonth = (monthPayments || []).reduce((sum, p) => sum + p.amount, 0);
+
+ // Get payments this year
+ const { data: yearPayments } = await supabaseAdmin
+ .from('dues_payments')
+ .select('amount')
+ .gte('payment_date', startOfYear.toISOString().split('T')[0]);
+
+ const totalCollectedThisYear = (yearPayments || []).reduce((sum, p) => sum + p.amount, 0);
+
+ // Get payments by month (last 12 months)
+ const twelveMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 11, 1);
+ const { data: allPayments } = await supabaseAdmin
+ .from('dues_payments')
+ .select('amount, payment_date')
+ .gte('payment_date', twelveMonthsAgo.toISOString().split('T')[0])
+ .order('payment_date', { ascending: true });
+
+ const paymentsByMonth: Array<{ month: string; amount: number; count: number }> = [];
+ const monthMap = new Map();
+
+ for (const payment of allPayments || []) {
+ const date = new Date(payment.payment_date);
+ const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
+
+ const existing = monthMap.get(monthKey) || { amount: 0, count: 0 };
+ existing.amount += payment.amount;
+ existing.count += 1;
+ monthMap.set(monthKey, existing);
+ }
+
+ // Fill in missing months
+ for (let i = 0; i < 12; i++) {
+ const date = new Date(now.getFullYear(), now.getMonth() - 11 + i, 1);
+ const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
+ const data = monthMap.get(monthKey) || { amount: 0, count: 0 };
+ paymentsByMonth.push({
+ month: date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }),
+ amount: data.amount,
+ count: data.count
+ });
+ }
+
+ // Get reminders sent this month
+ const { count: remindersSentThisMonth } = await supabaseAdmin
+ .from('dues_reminder_logs')
+ .select('*', { count: 'exact', head: true })
+ .gte('sent_at', startOfMonth.toISOString());
+
+ // Status breakdown with percentages
+ const statusBreakdown = [
+ { status: 'current', count: current, percentage: totalMembers > 0 ? (current / totalMembers) * 100 : 0 },
+ { status: 'due_soon', count: dueSoon, percentage: totalMembers > 0 ? (dueSoon / totalMembers) * 100 : 0 },
+ { status: 'overdue', count: overdue, percentage: totalMembers > 0 ? (overdue / totalMembers) * 100 : 0 },
+ { status: 'never_paid', count: neverPaid, percentage: totalMembers > 0 ? (neverPaid / totalMembers) * 100 : 0 }
+ ];
+
+ return {
+ totalMembers,
+ current,
+ dueSoon,
+ overdue,
+ neverPaid,
+ totalCollectedThisMonth,
+ totalCollectedThisYear,
+ totalOutstanding,
+ paymentsByMonth,
+ remindersSentThisMonth: remindersSentThisMonth || 0,
+ statusBreakdown
+ };
+}
+
+/**
+ * Get detailed dues report data for CSV export
+ */
+export async function getDuesReportData(): Promise<{
+ members: Array<{
+ member_id: string;
+ name: string;
+ email: string;
+ membership_type: string;
+ status: string;
+ dues_status: string;
+ annual_dues: number;
+ last_payment_date: string | null;
+ current_due_date: string | null;
+ days_overdue: number | null;
+ }>;
+ payments: Array<{
+ member_id: string;
+ member_name: string;
+ amount: number;
+ payment_date: string;
+ payment_method: string;
+ reference: string | null;
+ recorded_by: string | null;
+ }>;
+}> {
+ // Get members with dues
+ const { data: members } = await supabaseAdmin.from('members_with_dues').select('*');
+
+ const memberReport = (members || []).map((m) => ({
+ member_id: m.member_id,
+ name: `${m.first_name} ${m.last_name}`,
+ email: m.email,
+ membership_type: m.membership_type_name || 'Regular',
+ status: m.status_display_name || 'Unknown',
+ dues_status: m.dues_status,
+ annual_dues: m.annual_dues || 0,
+ last_payment_date: m.last_payment_date,
+ current_due_date: m.current_due_date,
+ days_overdue: m.days_overdue
+ }));
+
+ // Get all payments with member info
+ const { data: payments } = await supabaseAdmin
+ .from('dues_payments')
+ .select(
+ `
+ *,
+ member:members(member_id, first_name, last_name),
+ recorder:members!dues_payments_recorded_by_fkey(first_name, last_name)
+ `
+ )
+ .order('payment_date', { ascending: false });
+
+ const paymentReport = (payments || []).map((p: any) => ({
+ member_id: p.member?.member_id || 'Unknown',
+ member_name: p.member ? `${p.member.first_name} ${p.member.last_name}` : 'Unknown',
+ amount: p.amount,
+ payment_date: p.payment_date,
+ payment_method: p.payment_method,
+ reference: p.reference,
+ recorded_by: p.recorder ? `${p.recorder.first_name} ${p.recorder.last_name}` : null
+ }));
+
+ return {
+ members: memberReport,
+ payments: paymentReport
+ };
+}
+
+/**
+ * Get reminder effectiveness stats
+ */
+export async function getReminderEffectiveness(): Promise<{
+ totalRemindersSent: number;
+ paidWithin7Days: number;
+ paidWithin30Days: number;
+ effectivenessRate: number;
+}> {
+ // Get all reminder logs with payment data
+ const { data: reminders } = await supabaseAdmin
+ .from('dues_reminder_logs')
+ .select('member_id, sent_at, due_date');
+
+ if (!reminders || reminders.length === 0) {
+ return {
+ totalRemindersSent: 0,
+ paidWithin7Days: 0,
+ paidWithin30Days: 0,
+ effectivenessRate: 0
+ };
+ }
+
+ let paidWithin7Days = 0;
+ let paidWithin30Days = 0;
+
+ for (const reminder of reminders) {
+ const sentDate = new Date(reminder.sent_at);
+ const sevenDaysLater = new Date(sentDate.getTime() + 7 * 24 * 60 * 60 * 1000);
+ const thirtyDaysLater = new Date(sentDate.getTime() + 30 * 24 * 60 * 60 * 1000);
+
+ // Check if member paid within windows
+ const { data: payments } = await supabaseAdmin
+ .from('dues_payments')
+ .select('payment_date')
+ .eq('member_id', reminder.member_id)
+ .gte('payment_date', sentDate.toISOString().split('T')[0])
+ .lte('payment_date', thirtyDaysLater.toISOString().split('T')[0])
+ .limit(1);
+
+ if (payments && payments.length > 0) {
+ const paymentDate = new Date(payments[0].payment_date);
+ if (paymentDate <= sevenDaysLater) {
+ paidWithin7Days++;
+ }
+ paidWithin30Days++;
+ }
+ }
+
+ return {
+ totalRemindersSent: reminders.length,
+ paidWithin7Days,
+ paidWithin30Days,
+ effectivenessRate: reminders.length > 0 ? (paidWithin30Days / reminders.length) * 100 : 0
+ };
+}
+
+// ============================================
+// ONBOARDING REMINDERS
+// ============================================
+
+interface OnboardingMember {
+ id: string;
+ first_name: string;
+ last_name: string;
+ email: string;
+ member_id: string;
+ payment_deadline: string;
+}
+
+/**
+ * Get new members who need onboarding payment reminders
+ * These are members with a payment_deadline set from onboarding
+ */
+export async function getMembersNeedingOnboardingReminder(
+ reminderType: OnboardingReminderType
+): Promise {
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+
+ // Get pending status ID
+ const { data: pendingStatus } = await supabaseAdmin
+ .from('membership_statuses')
+ .select('id')
+ .eq('name', 'pending')
+ .single();
+
+ if (!pendingStatus) {
+ console.error('Pending status not found');
+ return [];
+ }
+
+ // Get members with payment_deadline set (from onboarding)
+ const { data: members, error } = await supabaseAdmin
+ .from('members')
+ .select('id, first_name, last_name, email, member_id, payment_deadline')
+ .eq('membership_status_id', pendingStatus.id)
+ .not('payment_deadline', 'is', null)
+ .not('email', 'is', null);
+
+ if (error || !members) {
+ console.error('Error fetching onboarding members:', error);
+ return [];
+ }
+
+ // Filter based on reminder type
+ let filteredMembers: OnboardingMember[] = [];
+
+ if (reminderType === 'onboarding_reminder_7') {
+ // 7 days or less until deadline
+ filteredMembers = members.filter((m) => {
+ if (!m.payment_deadline) return false;
+ const deadline = new Date(m.payment_deadline);
+ const daysUntil = Math.ceil((deadline.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
+ return daysUntil > 0 && daysUntil <= 7;
+ }) as OnboardingMember[];
+ } else if (reminderType === 'onboarding_reminder_1') {
+ // 1 day or less until deadline (final reminder)
+ filteredMembers = members.filter((m) => {
+ if (!m.payment_deadline) return false;
+ const deadline = new Date(m.payment_deadline);
+ const daysUntil = Math.ceil((deadline.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
+ return daysUntil === 1;
+ }) as OnboardingMember[];
+ } else if (reminderType === 'onboarding_expired') {
+ // Deadline has passed
+ filteredMembers = members.filter((m) => {
+ if (!m.payment_deadline) return false;
+ const deadline = new Date(m.payment_deadline);
+ return deadline < today;
+ }) as OnboardingMember[];
+ }
+
+ // Exclude members who already received this reminder
+ if (filteredMembers.length > 0) {
+ const memberIds = filteredMembers.map((m) => m.id);
+
+ const { data: existingReminders } = await supabaseAdmin
+ .from('dues_reminder_logs')
+ .select('member_id')
+ .eq('reminder_type', reminderType)
+ .in('member_id', memberIds);
+
+ if (existingReminders && existingReminders.length > 0) {
+ const sentSet = new Set(existingReminders.map((r) => r.member_id));
+ filteredMembers = filteredMembers.filter((m) => !sentSet.has(m.id));
+ }
+ }
+
+ return filteredMembers;
+}
+
+/**
+ * Send an onboarding reminder to a specific member
+ */
+export async function sendOnboardingReminder(
+ member: OnboardingMember,
+ reminderType: OnboardingReminderType,
+ baseUrl: string = 'https://monacousa.org'
+): Promise<{ success: boolean; error?: string }> {
+ const settings = await getDuesSettings();
+
+ // Calculate days until deadline
+ const deadline = new Date(member.payment_deadline);
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+ const daysLeft = Math.ceil((deadline.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
+
+ // Get default membership dues amount
+ const { data: defaultType } = await supabaseAdmin
+ .from('membership_types')
+ .select('annual_dues')
+ .eq('is_default', true)
+ .single();
+
+ const variables: Record = {
+ first_name: member.first_name,
+ last_name: member.last_name,
+ member_id: member.member_id || 'N/A',
+ payment_deadline: deadline.toLocaleDateString('en-US', {
+ weekday: 'long',
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric'
+ }),
+ days_remaining: Math.max(0, daysLeft).toString(),
+ amount: `€${defaultType?.annual_dues || 150}`,
+ account_holder: settings.payment_account_holder || 'Monaco USA',
+ bank_name: settings.payment_bank_name || 'Credit Foncier de Monaco',
+ iban: settings.payment_iban || 'Contact for details',
+ portal_url: `${baseUrl}/payments`
+ };
+
+ // Send email
+ const result = await sendTemplatedEmail(reminderType, member.email, variables, {
+ recipientId: member.id,
+ recipientName: `${member.first_name} ${member.last_name}`,
+ baseUrl
+ });
+
+ if (!result.success) {
+ return { success: false, error: result.error };
+ }
+
+ // Log the reminder
+ const { error: logError } = await supabaseAdmin.from('dues_reminder_logs').insert({
+ member_id: member.id,
+ reminder_type: reminderType,
+ due_date: member.payment_deadline
+ });
+
+ if (logError) {
+ console.error('Error logging onboarding reminder:', logError);
+ }
+
+ return { success: true };
+}
+
+/**
+ * Send bulk onboarding reminders of a specific type
+ */
+export async function sendOnboardingReminders(
+ reminderType: OnboardingReminderType,
+ baseUrl: string = 'https://monacousa.org'
+): Promise {
+ const members = await getMembersNeedingOnboardingReminder(reminderType);
+
+ const result: DuesReminderResult = {
+ sent: 0,
+ skipped: 0,
+ errors: [],
+ members: []
+ };
+
+ for (const member of members) {
+ try {
+ const sendResult = await sendOnboardingReminder(member, reminderType, baseUrl);
+
+ if (sendResult.success) {
+ result.sent++;
+ result.members.push({
+ id: member.id,
+ name: `${member.first_name} ${member.last_name}`,
+ email: member.email,
+ status: 'sent'
+ });
+ } else {
+ result.errors.push(`${member.email}: ${sendResult.error}`);
+ result.members.push({
+ id: member.id,
+ name: `${member.first_name} ${member.last_name}`,
+ email: member.email,
+ status: 'error',
+ error: sendResult.error
+ });
+ }
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ result.errors.push(`${member.email}: ${errorMessage}`);
+ result.members.push({
+ id: member.id,
+ name: `${member.first_name} ${member.last_name}`,
+ email: member.email,
+ status: 'error',
+ error: errorMessage
+ });
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Process expired onboarding payment deadlines and mark members as inactive
+ */
+export async function processOnboardingExpirations(
+ baseUrl: string = 'https://monacousa.org'
+): Promise<{ processed: number; members: Array<{ id: string; name: string; email: string }> }> {
+ const members = await getMembersNeedingOnboardingReminder('onboarding_expired');
+ const processed: Array<{ id: string; name: string; email: string }> = [];
+
+ // Get inactive status ID
+ const { data: inactiveStatus } = await supabaseAdmin
+ .from('membership_statuses')
+ .select('id')
+ .eq('name', 'inactive')
+ .single();
+
+ if (!inactiveStatus) {
+ console.error('Inactive status not found');
+ return { processed: 0, members: [] };
+ }
+
+ for (const member of members) {
+ // Update member status to inactive
+ const { error: updateError } = await supabaseAdmin
+ .from('members')
+ .update({ membership_status_id: inactiveStatus.id })
+ .eq('id', member.id);
+
+ if (updateError) {
+ console.error(`Error updating member ${member.id}:`, updateError);
+ continue;
+ }
+
+ // Send expired notice
+ await sendOnboardingReminder(member, 'onboarding_expired', baseUrl);
+
+ processed.push({
+ id: member.id,
+ name: `${member.first_name} ${member.last_name}`,
+ email: member.email
+ });
+ }
+
+ return { processed: processed.length, members: processed };
+}
diff --git a/src/lib/server/email.ts b/src/lib/server/email.ts
new file mode 100644
index 0000000..6b5d884
--- /dev/null
+++ b/src/lib/server/email.ts
@@ -0,0 +1,394 @@
+import nodemailer from 'nodemailer';
+import type { Transporter } from 'nodemailer';
+import { supabaseAdmin } from './supabase';
+
+export interface SmtpConfig {
+ host: string;
+ port: number;
+ secure: boolean;
+ username: string;
+ password: string;
+ from_address: string;
+ from_name: string;
+}
+
+export interface SendEmailOptions {
+ to: string;
+ subject: string;
+ html: string;
+ text?: string;
+ recipientId?: string;
+ recipientName?: string;
+ templateKey?: string;
+ emailType?: string;
+ sentBy?: string;
+}
+
+/**
+ * Get SMTP configuration from app_settings table
+ */
+export async function getSmtpConfig(): Promise {
+ const { data: settings } = await supabaseAdmin
+ .from('app_settings')
+ .select('setting_key, setting_value')
+ .eq('category', 'email');
+
+ if (!settings || settings.length === 0) {
+ return null;
+ }
+
+ const config: Record = {};
+ for (const s of settings) {
+ // Parse the value - it might be JSON stringified or plain
+ let value = s.setting_value;
+ if (typeof value === 'string') {
+ // Remove surrounding quotes if present
+ value = value.replace(/^"|"$/g, '');
+ }
+ config[s.setting_key] = value as string;
+ }
+
+ // Validate required fields
+ if (!config.smtp_host || !config.smtp_username || !config.smtp_password) {
+ return null;
+ }
+
+ return {
+ host: config.smtp_host,
+ port: parseInt(config.smtp_port || '587'),
+ secure: config.smtp_secure === 'true' || parseInt(config.smtp_port || '587') === 465,
+ username: config.smtp_username,
+ password: config.smtp_password,
+ from_address: config.smtp_from_address || 'noreply@monacousa.org',
+ from_name: config.smtp_from_name || 'Monaco USA'
+ };
+}
+
+/**
+ * Create a nodemailer transporter with the configured SMTP settings
+ */
+export async function createTransporter(): Promise {
+ const config = await getSmtpConfig();
+ if (!config) {
+ console.error('SMTP configuration not found or incomplete');
+ return null;
+ }
+
+ return nodemailer.createTransport({
+ host: config.host,
+ port: config.port,
+ secure: config.secure,
+ auth: {
+ user: config.username,
+ pass: config.password
+ }
+ });
+}
+
+/**
+ * Send an email using the configured SMTP settings
+ */
+export async function sendEmail(options: SendEmailOptions): Promise<{ success: boolean; error?: string; messageId?: string }> {
+ const config = await getSmtpConfig();
+ if (!config) {
+ return { success: false, error: 'SMTP not configured. Please configure email settings first.' };
+ }
+
+ const transporter = await createTransporter();
+ if (!transporter) {
+ return { success: false, error: 'Failed to create email transporter' };
+ }
+
+ try {
+ const result = await transporter.sendMail({
+ from: `"${config.from_name}" <${config.from_address}>`,
+ to: options.to,
+ subject: options.subject,
+ html: options.html,
+ text: options.text || stripHtml(options.html)
+ });
+
+ // Log to email_logs table
+ await supabaseAdmin.from('email_logs').insert({
+ recipient_id: options.recipientId || null,
+ recipient_email: options.to,
+ recipient_name: options.recipientName || null,
+ template_key: options.templateKey || null,
+ subject: options.subject,
+ email_type: options.emailType || 'manual',
+ status: 'sent',
+ provider: 'smtp',
+ provider_message_id: result.messageId,
+ sent_by: options.sentBy || null,
+ sent_at: new Date().toISOString()
+ });
+
+ return { success: true, messageId: result.messageId };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ console.error('Email send error:', error);
+
+ // Log failed attempt
+ await supabaseAdmin.from('email_logs').insert({
+ recipient_id: options.recipientId || null,
+ recipient_email: options.to,
+ recipient_name: options.recipientName || null,
+ template_key: options.templateKey || null,
+ subject: options.subject,
+ email_type: options.emailType || 'manual',
+ status: 'failed',
+ provider: 'smtp',
+ error_message: errorMessage,
+ sent_by: options.sentBy || null
+ });
+
+ return { success: false, error: errorMessage };
+ }
+}
+
+/**
+ * Send a templated email with variable substitution
+ * Templates should contain content only (no full HTML wrapper) - will be wrapped automatically
+ */
+export async function sendTemplatedEmail(
+ templateKey: string,
+ to: string,
+ variables: Record,
+ options?: {
+ recipientId?: string;
+ recipientName?: string;
+ sentBy?: string;
+ baseUrl?: string;
+ }
+): Promise<{ success: boolean; error?: string; messageId?: string }> {
+ // Fetch template from database
+ const { data: template, error: templateError } = await supabaseAdmin
+ .from('email_templates')
+ .select('*')
+ .eq('template_key', templateKey)
+ .eq('is_active', true)
+ .single();
+
+ if (templateError || !template) {
+ return { success: false, error: `Email template "${templateKey}" not found or inactive` };
+ }
+
+ // Get site URL for logo
+ const baseUrl = options?.baseUrl || process.env.SITE_URL || 'https://monacousa.org';
+ const logoUrl = `${baseUrl}/MONACOUSA-Flags_376x376.png`;
+
+ // Add default variables
+ const allVariables: Record = {
+ logo_url: logoUrl,
+ site_url: baseUrl,
+ ...variables
+ };
+
+ // Replace variables in subject and body
+ let subject = template.subject;
+ let bodyContent = template.body_html;
+ let text = template.body_text || '';
+
+ for (const [key, value] of Object.entries(allVariables)) {
+ const regex = new RegExp(`{{${key}}}`, 'g');
+ subject = subject.replace(regex, value);
+ bodyContent = bodyContent.replace(regex, value);
+ text = text.replace(regex, value);
+ }
+
+ // Extract title from template or use subject
+ // Look for title in template metadata or first h2 tag
+ let emailTitle = template.email_title || subject;
+ // Try to extract from first h2 in content
+ const h2Match = bodyContent.match(/]*>([^<]+)<\/h2>/i);
+ if (h2Match) {
+ emailTitle = h2Match[1].replace(/{{[^}]+}}/g, '').trim();
+ }
+
+ // Check if template already has full HTML wrapper (legacy templates)
+ const hasFullWrapper = bodyContent.includes('/g,
+ ` `
+ );
+ } else {
+ // Content-only template - wrap with Monaco template
+ html = wrapInMonacoTemplate({
+ title: emailTitle,
+ content: bodyContent,
+ logoUrl
+ });
+ }
+
+ return sendEmail({
+ to,
+ subject,
+ html,
+ text: text || undefined,
+ recipientId: options?.recipientId,
+ recipientName: options?.recipientName,
+ templateKey,
+ emailType: template.category,
+ sentBy: options?.sentBy
+ });
+}
+
+/**
+ * Test SMTP connection and optionally send a test email
+ */
+export async function testSmtpConnection(
+ sendTo?: string,
+ sentBy?: string
+): Promise<{ success: boolean; error?: string }> {
+ const config = await getSmtpConfig();
+ if (!config) {
+ return { success: false, error: 'SMTP not configured. Please configure and save email settings first.' };
+ }
+
+ const transporter = await createTransporter();
+ if (!transporter) {
+ return { success: false, error: 'Failed to create email transporter' };
+ }
+
+ try {
+ // Verify connection
+ await transporter.verify();
+
+ // If a recipient is provided, send a test email
+ if (sendTo) {
+ const testContent = `
+ This is a test email from your Monaco USA Portal.
+
+
✓ Configuration Verified
+
Your SMTP settings are working correctly!
+
+ Sent at ${new Date().toLocaleString()}
`;
+
+ const result = await sendEmail({
+ to: sendTo,
+ subject: 'Monaco USA Portal - SMTP Test Email',
+ html: wrapInMonacoTemplate({
+ title: 'SMTP Test Successful!',
+ content: testContent
+ }),
+ emailType: 'test',
+ sentBy
+ });
+
+ if (!result.success) {
+ return { success: false, error: result.error };
+ }
+ }
+
+ return { success: true };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ console.error('SMTP test error:', error);
+ return { success: false, error: `SMTP connection failed: ${errorMessage}` };
+ }
+}
+
+// S3-hosted background image URL matching login screen
+const EMAIL_BACKGROUND_IMAGE_URL = 'https://s3.monacousa.org/public/monaco_high_res.jpg';
+
+/**
+ * Wrap email content in Monaco-branded template
+ * This creates a consistent look matching the login page styling with background image
+ */
+export function wrapInMonacoTemplate(options: {
+ title: string;
+ content: string;
+ logoUrl?: string;
+ backgroundImageUrl?: string;
+}): string {
+ const baseUrl = process.env.SITE_URL || 'http://localhost:7453';
+ const logoUrl = options.logoUrl || `${baseUrl}/MONACOUSA-Flags_376x376.png`;
+ const bgImageUrl = options.backgroundImageUrl || EMAIL_BACKGROUND_IMAGE_URL;
+
+ return `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Monaco USA
+ Americans in Monaco
+
+
+
+
+
+
+
+
+ ${options.title}
+ ${options.content}
+
+
+
+
+
+
+
+
+ © 2026 Monaco USA. All rights reserved.
+
+
+
+
+
+
+
+
+
+
+
+`;
+}
+
+/**
+ * Strip HTML tags from a string to create plain text version
+ */
+function stripHtml(html: string): string {
+ return html
+ .replace(/<[^>]*>/g, '')
+ .replace(/ /g, ' ')
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/\s+/g, ' ')
+ .trim();
+}
diff --git a/src/lib/server/event-reminders.ts b/src/lib/server/event-reminders.ts
new file mode 100644
index 0000000..33fe494
--- /dev/null
+++ b/src/lib/server/event-reminders.ts
@@ -0,0 +1,339 @@
+/**
+ * Event Reminder Service
+ * Handles sending automated reminder emails 24 hours before events
+ */
+
+import { supabaseAdmin } from './supabase';
+import { sendTemplatedEmail } from './email';
+
+// ============================================
+// TYPES
+// ============================================
+
+export interface EventReminderSettings {
+ event_reminders_enabled: boolean;
+ event_reminder_hours_before: number;
+}
+
+export interface EventReminderResult {
+ sent: number;
+ skipped: number;
+ errors: string[];
+ reminders: Array<{
+ eventId: string;
+ eventTitle: string;
+ memberId: string;
+ memberName: string;
+ email: string;
+ status: 'sent' | 'skipped' | 'error';
+ error?: string;
+ }>;
+}
+
+export interface EventNeedingReminder {
+ event_id: string;
+ event_title: string;
+ start_datetime: string;
+ end_datetime: string;
+ location: string | null;
+ timezone: string;
+ rsvp_id: string;
+ member_id: string;
+ guest_count: number;
+ rsvp_status: string;
+ first_name: string;
+ last_name: string;
+ email: string;
+}
+
+// ============================================
+// SETTINGS
+// ============================================
+
+/**
+ * Get event reminder settings from the database
+ */
+export async function getEventReminderSettings(): Promise {
+ const { data: settings } = await supabaseAdmin
+ .from('app_settings')
+ .select('setting_key, setting_value')
+ .eq('category', 'events')
+ .in('setting_key', ['event_reminders_enabled', 'event_reminder_hours_before']);
+
+ const config: Record = {};
+ for (const s of settings || []) {
+ config[s.setting_key] = s.setting_value as string;
+ }
+
+ return {
+ event_reminders_enabled: config.event_reminders_enabled !== 'false',
+ event_reminder_hours_before: parseInt(config.event_reminder_hours_before || '24')
+ };
+}
+
+// ============================================
+// QUERIES
+// ============================================
+
+/**
+ * Get events with confirmed RSVPs that need reminders sent
+ * Uses the events_needing_reminders view for efficient querying
+ */
+export async function getEventsNeedingReminders(): Promise {
+ const settings = await getEventReminderSettings();
+
+ if (!settings.event_reminders_enabled) {
+ return [];
+ }
+
+ // Calculate the time window based on settings
+ const hoursBeforeEvent = settings.event_reminder_hours_before;
+ const now = new Date();
+ const windowStart = new Date(now.getTime() + (hoursBeforeEvent - 1) * 60 * 60 * 1000);
+ const windowEnd = new Date(now.getTime() + (hoursBeforeEvent + 1) * 60 * 60 * 1000);
+
+ // Query events starting within the reminder window
+ const { data: events, error } = await supabaseAdmin
+ .from('events')
+ .select(`
+ id,
+ title,
+ start_datetime,
+ end_datetime,
+ location,
+ timezone
+ `)
+ .eq('status', 'published')
+ .gt('start_datetime', windowStart.toISOString())
+ .lte('start_datetime', windowEnd.toISOString());
+
+ if (error || !events || events.length === 0) {
+ return [];
+ }
+
+ // Get RSVPs for these events
+ const eventIds = events.map(e => e.id);
+ const { data: rsvps, error: rsvpError } = await supabaseAdmin
+ .from('event_rsvps')
+ .select(`
+ id,
+ event_id,
+ member_id,
+ guest_count,
+ status,
+ member:members(first_name, last_name, email)
+ `)
+ .in('event_id', eventIds)
+ .eq('status', 'confirmed');
+
+ if (rsvpError || !rsvps) {
+ return [];
+ }
+
+ // Get already sent reminders
+ const { data: sentReminders } = await supabaseAdmin
+ .from('event_reminder_logs')
+ .select('event_id, member_id')
+ .in('event_id', eventIds)
+ .eq('reminder_type', '24hr');
+
+ const sentSet = new Set(
+ (sentReminders || []).map(r => `${r.event_id}-${r.member_id}`)
+ );
+
+ // Build the result array
+ const result: EventNeedingReminder[] = [];
+
+ for (const event of events) {
+ const eventRsvps = rsvps.filter(r => r.event_id === event.id);
+
+ for (const rsvp of eventRsvps) {
+ const member = rsvp.member as { first_name: string; last_name: string; email: string } | null;
+ if (!member?.email) continue;
+
+ // Skip if reminder already sent
+ const key = `${event.id}-${rsvp.member_id}`;
+ if (sentSet.has(key)) continue;
+
+ result.push({
+ event_id: event.id,
+ event_title: event.title,
+ start_datetime: event.start_datetime,
+ end_datetime: event.end_datetime,
+ location: event.location,
+ timezone: event.timezone || 'Europe/Monaco',
+ rsvp_id: rsvp.id,
+ member_id: rsvp.member_id,
+ guest_count: rsvp.guest_count || 0,
+ rsvp_status: rsvp.status,
+ first_name: member.first_name,
+ last_name: member.last_name,
+ email: member.email
+ });
+ }
+ }
+
+ return result;
+}
+
+// ============================================
+// REMINDER SENDING
+// ============================================
+
+/**
+ * Send a single event reminder email
+ */
+export async function sendEventReminder(
+ reminder: EventNeedingReminder,
+ baseUrl: string = 'https://monacousa.org'
+): Promise<{ success: boolean; error?: string }> {
+ // Format date and time
+ const eventDate = new Date(reminder.start_datetime);
+ const formattedDate = eventDate.toLocaleDateString('en-US', {
+ weekday: 'long',
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric'
+ });
+ const formattedTime = eventDate.toLocaleTimeString('en-US', {
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true,
+ timeZone: reminder.timezone
+ });
+
+ const variables: Record = {
+ first_name: reminder.first_name,
+ event_title: reminder.event_title,
+ event_date: formattedDate,
+ event_time: formattedTime,
+ event_location: reminder.location || 'TBD',
+ guest_count: reminder.guest_count > 0 ? reminder.guest_count.toString() : '',
+ portal_url: `${baseUrl}/events/${reminder.event_id}`
+ };
+
+ // Send email
+ const result = await sendTemplatedEmail('event_reminder_24hr', reminder.email, variables, {
+ recipientId: reminder.member_id,
+ recipientName: `${reminder.first_name} ${reminder.last_name}`,
+ baseUrl
+ });
+
+ if (!result.success) {
+ return { success: false, error: result.error };
+ }
+
+ // Log the reminder
+ const { error: logError } = await supabaseAdmin.from('event_reminder_logs').insert({
+ event_id: reminder.event_id,
+ rsvp_id: reminder.rsvp_id,
+ member_id: reminder.member_id,
+ reminder_type: '24hr'
+ });
+
+ if (logError) {
+ console.error('Error logging event reminder:', logError);
+ }
+
+ return { success: true };
+}
+
+/**
+ * Send all pending event reminders
+ */
+export async function sendEventReminders(
+ baseUrl: string = 'https://monacousa.org'
+): Promise {
+ const remindersNeeded = await getEventsNeedingReminders();
+
+ const result: EventReminderResult = {
+ sent: 0,
+ skipped: 0,
+ errors: [],
+ reminders: []
+ };
+
+ for (const reminder of remindersNeeded) {
+ try {
+ const sendResult = await sendEventReminder(reminder, baseUrl);
+
+ if (sendResult.success) {
+ result.sent++;
+ result.reminders.push({
+ eventId: reminder.event_id,
+ eventTitle: reminder.event_title,
+ memberId: reminder.member_id,
+ memberName: `${reminder.first_name} ${reminder.last_name}`,
+ email: reminder.email,
+ status: 'sent'
+ });
+ } else {
+ result.errors.push(`${reminder.email}: ${sendResult.error}`);
+ result.reminders.push({
+ eventId: reminder.event_id,
+ eventTitle: reminder.event_title,
+ memberId: reminder.member_id,
+ memberName: `${reminder.first_name} ${reminder.last_name}`,
+ email: reminder.email,
+ status: 'error',
+ error: sendResult.error
+ });
+ }
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ result.errors.push(`${reminder.email}: ${errorMessage}`);
+ result.reminders.push({
+ eventId: reminder.event_id,
+ eventTitle: reminder.event_title,
+ memberId: reminder.member_id,
+ memberName: `${reminder.first_name} ${reminder.last_name}`,
+ email: reminder.email,
+ status: 'error',
+ error: errorMessage
+ });
+ }
+ }
+
+ return result;
+}
+
+// ============================================
+// ANALYTICS
+// ============================================
+
+/**
+ * Get statistics about event reminders
+ */
+export async function getEventReminderStats(): Promise<{
+ totalRemindersSent: number;
+ remindersSentThisMonth: number;
+ eventsWithReminders: number;
+}> {
+ const now = new Date();
+ const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
+
+ // Get total reminders sent
+ const { count: totalRemindersSent } = await supabaseAdmin
+ .from('event_reminder_logs')
+ .select('*', { count: 'exact', head: true });
+
+ // Get reminders sent this month
+ const { count: remindersSentThisMonth } = await supabaseAdmin
+ .from('event_reminder_logs')
+ .select('*', { count: 'exact', head: true })
+ .gte('sent_at', startOfMonth.toISOString());
+
+ // Get unique events with reminders
+ const { data: uniqueEvents } = await supabaseAdmin
+ .from('event_reminder_logs')
+ .select('event_id')
+ .limit(10000);
+
+ const uniqueEventIds = new Set((uniqueEvents || []).map(e => e.event_id));
+
+ return {
+ totalRemindersSent: totalRemindersSent || 0,
+ remindersSentThisMonth: remindersSentThisMonth || 0,
+ eventsWithReminders: uniqueEventIds.size
+ };
+}
diff --git a/src/lib/server/ical.ts b/src/lib/server/ical.ts
new file mode 100644
index 0000000..44a4c7a
--- /dev/null
+++ b/src/lib/server/ical.ts
@@ -0,0 +1,299 @@
+/**
+ * iCal Calendar Generation Utilities
+ * Generate .ics files for events and calendar feeds
+ */
+
+export interface ICalEvent {
+ id: string;
+ title: string;
+ description?: string;
+ start_datetime: string;
+ end_datetime: string;
+ location?: string | null;
+ location_url?: string | null;
+ timezone?: string;
+ status?: 'published' | 'cancelled' | 'draft';
+ event_type_name?: string | null;
+ organizer_name?: string;
+ organizer_email?: string;
+ url?: string;
+ all_day?: boolean;
+}
+
+/**
+ * Escape special characters for iCal format
+ */
+function escapeICalText(text: string): string {
+ return text
+ .replace(/\\/g, '\\\\')
+ .replace(/;/g, '\\;')
+ .replace(/,/g, '\\,')
+ .replace(/\n/g, '\\n')
+ .replace(/\r/g, '');
+}
+
+/**
+ * Format a date for iCal (YYYYMMDDTHHMMSSZ format for UTC)
+ */
+function formatICalDate(dateStr: string, timezone?: string): string {
+ const date = new Date(dateStr);
+ // Format as UTC
+ const year = date.getUTCFullYear();
+ const month = String(date.getUTCMonth() + 1).padStart(2, '0');
+ const day = String(date.getUTCDate()).padStart(2, '0');
+ const hours = String(date.getUTCHours()).padStart(2, '0');
+ const minutes = String(date.getUTCMinutes()).padStart(2, '0');
+ const seconds = String(date.getUTCSeconds()).padStart(2, '0');
+ return `${year}${month}${day}T${hours}${minutes}${seconds}Z`;
+}
+
+/**
+ * Format a date for all-day events (YYYYMMDD format)
+ */
+function formatICalDateOnly(dateStr: string): string {
+ const date = new Date(dateStr);
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ return `${year}${month}${day}`;
+}
+
+/**
+ * Map event status to iCal status
+ */
+function getICalStatus(status?: string): string {
+ switch (status) {
+ case 'cancelled':
+ return 'CANCELLED';
+ case 'draft':
+ return 'TENTATIVE';
+ default:
+ return 'CONFIRMED';
+ }
+}
+
+/**
+ * Fold long lines according to iCal spec (max 75 chars per line)
+ */
+function foldLine(line: string): string {
+ const maxLength = 75;
+ if (line.length <= maxLength) {
+ return line;
+ }
+
+ const result: string[] = [];
+ let remaining = line;
+
+ // First line can be full length
+ result.push(remaining.substring(0, maxLength));
+ remaining = remaining.substring(maxLength);
+
+ // Continuation lines start with a space and have maxLength-1 content
+ while (remaining.length > 0) {
+ result.push(' ' + remaining.substring(0, maxLength - 1));
+ remaining = remaining.substring(maxLength - 1);
+ }
+
+ return result.join('\r\n');
+}
+
+/**
+ * Generate a single iCal event entry
+ */
+export function generateICalEvent(event: ICalEvent, baseUrl: string = 'https://monacousa.org'): string {
+ const uid = `${event.id}@monacousa.org`;
+ const dtstamp = formatICalDate(new Date().toISOString());
+ const created = dtstamp;
+ const lastModified = dtstamp;
+
+ const lines: string[] = [
+ 'BEGIN:VEVENT',
+ `UID:${uid}`,
+ `DTSTAMP:${dtstamp}`,
+ `CREATED:${created}`,
+ `LAST-MODIFIED:${lastModified}`
+ ];
+
+ // Date/time
+ if (event.all_day) {
+ lines.push(`DTSTART;VALUE=DATE:${formatICalDateOnly(event.start_datetime)}`);
+ // For all-day events, end date is exclusive, so add one day
+ const endDate = new Date(event.end_datetime);
+ endDate.setDate(endDate.getDate() + 1);
+ lines.push(`DTEND;VALUE=DATE:${formatICalDateOnly(endDate.toISOString())}`);
+ } else {
+ lines.push(`DTSTART:${formatICalDate(event.start_datetime, event.timezone)}`);
+ lines.push(`DTEND:${formatICalDate(event.end_datetime, event.timezone)}`);
+ }
+
+ // Summary (title)
+ lines.push(foldLine(`SUMMARY:${escapeICalText(event.title)}`));
+
+ // Description
+ if (event.description) {
+ lines.push(foldLine(`DESCRIPTION:${escapeICalText(event.description)}`));
+ }
+
+ // Location
+ if (event.location) {
+ lines.push(foldLine(`LOCATION:${escapeICalText(event.location)}`));
+ }
+
+ // URL
+ const eventUrl = event.url || `${baseUrl}/events/${event.id}`;
+ lines.push(`URL:${eventUrl}`);
+
+ // Status
+ lines.push(`STATUS:${getICalStatus(event.status)}`);
+
+ // Categories
+ if (event.event_type_name) {
+ lines.push(`CATEGORIES:${escapeICalText(event.event_type_name)}`);
+ }
+
+ // Organizer
+ if (event.organizer_email) {
+ const organizerName = event.organizer_name || 'Monaco USA';
+ lines.push(`ORGANIZER;CN=${escapeICalText(organizerName)}:mailto:${event.organizer_email}`);
+ }
+
+ // Sequence (for updates)
+ lines.push('SEQUENCE:0');
+
+ lines.push('END:VEVENT');
+
+ return lines.join('\r\n');
+}
+
+/**
+ * Generate a complete iCal calendar file for a single event
+ */
+export function generateSingleEventIcal(event: ICalEvent, baseUrl: string = 'https://monacousa.org'): string {
+ const lines = [
+ 'BEGIN:VCALENDAR',
+ 'VERSION:2.0',
+ 'PRODID:-//Monaco USA//Event Calendar//EN',
+ 'CALSCALE:GREGORIAN',
+ 'METHOD:PUBLISH',
+ `X-WR-CALNAME:${escapeICalText(event.title)}`,
+ 'X-WR-TIMEZONE:Europe/Monaco',
+ generateICalEvent(event, baseUrl),
+ 'END:VCALENDAR'
+ ];
+
+ return lines.join('\r\n') + '\r\n';
+}
+
+/**
+ * Generate a complete iCal calendar feed for multiple events
+ */
+export function generateCalendarFeed(
+ events: ICalEvent[],
+ calendarName: string = 'Monaco USA Events',
+ baseUrl: string = 'https://monacousa.org'
+): string {
+ const lines = [
+ 'BEGIN:VCALENDAR',
+ 'VERSION:2.0',
+ 'PRODID:-//Monaco USA//Event Calendar//EN',
+ 'CALSCALE:GREGORIAN',
+ 'METHOD:PUBLISH',
+ `X-WR-CALNAME:${escapeICalText(calendarName)}`,
+ 'X-WR-TIMEZONE:Europe/Monaco',
+ // Refresh interval hint (1 hour)
+ 'REFRESH-INTERVAL;VALUE=DURATION:PT1H',
+ 'X-PUBLISHED-TTL:PT1H'
+ ];
+
+ // Add each event
+ for (const event of events) {
+ lines.push(generateICalEvent(event, baseUrl));
+ }
+
+ lines.push('END:VCALENDAR');
+
+ return lines.join('\r\n') + '\r\n';
+}
+
+/**
+ * Generate a Google Calendar URL for an event
+ */
+export function generateGoogleCalendarUrl(event: ICalEvent, baseUrl: string = 'https://monacousa.org'): string {
+ const start = new Date(event.start_datetime);
+ const end = new Date(event.end_datetime);
+
+ // Format: YYYYMMDDTHHMMSSZ
+ const formatGoogleDate = (date: Date) => {
+ return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
+ };
+
+ const params = new URLSearchParams({
+ action: 'TEMPLATE',
+ text: event.title,
+ dates: `${formatGoogleDate(start)}/${formatGoogleDate(end)}`,
+ details: event.description || '',
+ location: event.location || '',
+ sprop: `website:${baseUrl}`,
+ sf: 'true',
+ output: 'xml'
+ });
+
+ return `https://www.google.com/calendar/render?${params.toString()}`;
+}
+
+/**
+ * Generate an Outlook.com calendar URL for an event
+ */
+export function generateOutlookCalendarUrl(event: ICalEvent, baseUrl: string = 'https://monacousa.org'): string {
+ const start = new Date(event.start_datetime);
+ const end = new Date(event.end_datetime);
+
+ // Outlook uses ISO format with URL encoding
+ const params = new URLSearchParams({
+ rru: 'addevent',
+ startdt: start.toISOString(),
+ enddt: end.toISOString(),
+ subject: event.title,
+ body: event.description || '',
+ location: event.location || '',
+ path: '/calendar/action/compose'
+ });
+
+ return `https://outlook.live.com/calendar/0/deeplink/compose?${params.toString()}`;
+}
+
+/**
+ * Generate a Yahoo Calendar URL for an event
+ */
+export function generateYahooCalendarUrl(event: ICalEvent): string {
+ const start = new Date(event.start_datetime);
+ const end = new Date(event.end_datetime);
+
+ // Duration in hours and minutes
+ const durationMs = end.getTime() - start.getTime();
+ const hours = Math.floor(durationMs / (1000 * 60 * 60));
+ const minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60));
+ const duration = `${String(hours).padStart(2, '0')}${String(minutes).padStart(2, '0')}`;
+
+ // Format: YYYYMMDDTHHMMSS
+ const formatYahooDate = (date: Date) => {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ const h = String(date.getHours()).padStart(2, '0');
+ const m = String(date.getMinutes()).padStart(2, '0');
+ const s = String(date.getSeconds()).padStart(2, '0');
+ return `${year}${month}${day}T${h}${m}${s}`;
+ };
+
+ const params = new URLSearchParams({
+ v: '60',
+ title: event.title,
+ st: formatYahooDate(start),
+ dur: duration,
+ desc: event.description || '',
+ in_loc: event.location || ''
+ });
+
+ return `https://calendar.yahoo.com/?${params.toString()}`;
+}
diff --git a/src/lib/server/poste.ts b/src/lib/server/poste.ts
new file mode 100644
index 0000000..509c135
--- /dev/null
+++ b/src/lib/server/poste.ts
@@ -0,0 +1,303 @@
+/**
+ * Poste.io Mail Server API Client
+ * Documentation: https://mail.monacousa.org/admin/api/doc
+ */
+
+export interface PosteConfig {
+ host: string;
+ adminEmail: string;
+ adminPassword: string;
+}
+
+export interface Mailbox {
+ address: string;
+ name: string;
+ disabled: boolean;
+ super_admin: boolean;
+ created?: string;
+ storage_limit?: number;
+ storage_usage?: number;
+}
+
+export interface MailboxQuota {
+ storageLimit: number;
+ storageUsed: number;
+}
+
+interface ApiResponse {
+ success: boolean;
+ data?: T;
+ error?: string;
+}
+
+/**
+ * Make an authenticated request to the Poste API
+ */
+async function makeRequest(
+ config: PosteConfig,
+ method: string,
+ endpoint: string,
+ body?: Record
+): Promise> {
+ const baseUrl = `https://${config.host}/admin/api/v1`;
+ const auth = Buffer.from(`${config.adminEmail}:${config.adminPassword}`).toString('base64');
+
+ try {
+ const response = await fetch(`${baseUrl}${endpoint}`, {
+ method,
+ headers: {
+ 'Authorization': `Basic ${auth}`,
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json'
+ },
+ body: body ? JSON.stringify(body) : undefined
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ let errorMessage = `HTTP ${response.status}`;
+ try {
+ const errorJson = JSON.parse(errorText);
+ errorMessage = errorJson.message || errorJson.error || errorMessage;
+ } catch {
+ errorMessage = errorText || errorMessage;
+ }
+ return { success: false, error: errorMessage };
+ }
+
+ // Handle empty responses (e.g., DELETE)
+ const text = await response.text();
+ if (!text) {
+ return { success: true };
+ }
+
+ const data = JSON.parse(text) as T;
+ return { success: true, data };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Unknown error';
+ return { success: false, error: message };
+ }
+}
+
+/**
+ * Test connection to the Poste API
+ */
+export async function testConnection(config: PosteConfig): Promise<{ success: boolean; error?: string }> {
+ const result = await makeRequest<{ results: unknown[] }>(config, 'GET', '/domains');
+ if (!result.success) {
+ return { success: false, error: result.error };
+ }
+ return { success: true };
+}
+
+/**
+ * List all mailboxes
+ */
+export async function listMailboxes(
+ config: PosteConfig,
+ options?: { query?: string; page?: number; limit?: number }
+): Promise<{ success: boolean; mailboxes?: Mailbox[]; total?: number; error?: string }> {
+ let endpoint = '/boxes';
+ const params = new URLSearchParams();
+
+ if (options?.query) params.set('query', options.query);
+ if (options?.page) params.set('page', options.page.toString());
+ if (options?.limit) params.set('paging', options.limit.toString());
+
+ const queryString = params.toString();
+ if (queryString) endpoint += `?${queryString}`;
+
+ const result = await makeRequest<{ results: Mailbox[]; results_count: number }>(
+ config,
+ 'GET',
+ endpoint
+ );
+
+ if (!result.success) {
+ return { success: false, error: result.error };
+ }
+
+ return {
+ success: true,
+ mailboxes: result.data?.results || [],
+ total: result.data?.results_count || 0
+ };
+}
+
+/**
+ * Get a single mailbox
+ */
+export async function getMailbox(
+ config: PosteConfig,
+ email: string
+): Promise<{ success: boolean; mailbox?: Mailbox; error?: string }> {
+ const result = await makeRequest(config, 'GET', `/boxes/${encodeURIComponent(email)}`);
+
+ if (!result.success) {
+ return { success: false, error: result.error };
+ }
+
+ return { success: true, mailbox: result.data };
+}
+
+/**
+ * Create a new mailbox
+ */
+export async function createMailbox(
+ config: PosteConfig,
+ options: {
+ email: string;
+ name: string;
+ password: string;
+ disabled?: boolean;
+ superAdmin?: boolean;
+ }
+): Promise<{ success: boolean; error?: string }> {
+ const result = await makeRequest(config, 'POST', '/boxes', {
+ email: options.email,
+ name: options.name,
+ passwordPlaintext: options.password,
+ disabled: options.disabled ?? false,
+ superAdmin: options.superAdmin ?? false
+ });
+
+ if (!result.success) {
+ return { success: false, error: result.error };
+ }
+
+ return { success: true };
+}
+
+/**
+ * Update a mailbox
+ */
+export async function updateMailbox(
+ config: PosteConfig,
+ email: string,
+ updates: {
+ name?: string;
+ password?: string;
+ disabled?: boolean;
+ superAdmin?: boolean;
+ }
+): Promise<{ success: boolean; error?: string }> {
+ const body: Record = {};
+
+ if (updates.name !== undefined) body.name = updates.name;
+ if (updates.password !== undefined) body.passwordPlaintext = updates.password;
+ if (updates.disabled !== undefined) body.disabled = updates.disabled;
+ if (updates.superAdmin !== undefined) body.superAdmin = updates.superAdmin;
+
+ const result = await makeRequest(
+ config,
+ 'PATCH',
+ `/boxes/${encodeURIComponent(email)}`,
+ body
+ );
+
+ if (!result.success) {
+ return { success: false, error: result.error };
+ }
+
+ return { success: true };
+}
+
+/**
+ * Delete a mailbox
+ */
+export async function deleteMailbox(
+ config: PosteConfig,
+ email: string
+): Promise<{ success: boolean; error?: string }> {
+ const result = await makeRequest(
+ config,
+ 'DELETE',
+ `/boxes/${encodeURIComponent(email)}`
+ );
+
+ if (!result.success) {
+ return { success: false, error: result.error };
+ }
+
+ return { success: true };
+}
+
+/**
+ * Get mailbox storage quota
+ */
+export async function getMailboxQuota(
+ config: PosteConfig,
+ email: string
+): Promise<{ success: boolean; quota?: MailboxQuota; error?: string }> {
+ const result = await makeRequest<{ storageLimit: number; storageUsed: number }>(
+ config,
+ 'GET',
+ `/boxes/${encodeURIComponent(email)}/quota`
+ );
+
+ if (!result.success) {
+ return { success: false, error: result.error };
+ }
+
+ return {
+ success: true,
+ quota: {
+ storageLimit: result.data?.storageLimit || 0,
+ storageUsed: result.data?.storageUsed || 0
+ }
+ };
+}
+
+/**
+ * Set mailbox storage quota
+ */
+export async function setMailboxQuota(
+ config: PosteConfig,
+ email: string,
+ storageLimitMB: number
+): Promise<{ success: boolean; error?: string }> {
+ const result = await makeRequest(
+ config,
+ 'PATCH',
+ `/boxes/${encodeURIComponent(email)}/quota`,
+ { storageLimit: storageLimitMB }
+ );
+
+ if (!result.success) {
+ return { success: false, error: result.error };
+ }
+
+ return { success: true };
+}
+
+/**
+ * List all domains
+ */
+export async function listDomains(
+ config: PosteConfig
+): Promise<{ success: boolean; domains?: string[]; error?: string }> {
+ const result = await makeRequest<{ results: { name: string }[] }>(config, 'GET', '/domains');
+
+ if (!result.success) {
+ return { success: false, error: result.error };
+ }
+
+ return {
+ success: true,
+ domains: result.data?.results?.map(d => d.name) || []
+ };
+}
+
+/**
+ * Generate a random password
+ */
+export function generatePassword(length = 16): string {
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
+ let password = '';
+ const randomValues = new Uint32Array(length);
+ crypto.getRandomValues(randomValues);
+ for (let i = 0; i < length; i++) {
+ password += chars[randomValues[i] % chars.length];
+ }
+ return password;
+}
diff --git a/src/lib/server/storage.ts b/src/lib/server/storage.ts
new file mode 100644
index 0000000..ce7d7d1
--- /dev/null
+++ b/src/lib/server/storage.ts
@@ -0,0 +1,901 @@
+import { supabaseAdmin } from './supabase';
+import { PUBLIC_SUPABASE_URL } from '$env/static/public';
+import {
+ S3Client,
+ PutObjectCommand,
+ GetObjectCommand,
+ DeleteObjectCommand,
+ DeleteObjectsCommand,
+ ListObjectsV2Command,
+ HeadBucketCommand
+} from '@aws-sdk/client-s3';
+import { getSignedUrl as getS3SignedUrl } from '@aws-sdk/s3-request-presigner';
+
+export type StorageBucket = 'documents' | 'avatars' | 'event-images';
+
+/**
+ * Generate a browser-accessible public URL for Supabase Storage
+ * This uses PUBLIC_SUPABASE_URL instead of the internal Docker URL
+ */
+function getBrowserAccessibleUrl(bucket: StorageBucket, path: string): string {
+ return `${PUBLIC_SUPABASE_URL}/storage/v1/object/public/${bucket}/${path}`;
+}
+
+export interface UploadResult {
+ success: boolean;
+ path?: string;
+ publicUrl?: string;
+ localUrl?: string;
+ s3Url?: string;
+ error?: string;
+}
+
+export interface S3Config {
+ endpoint: string;
+ bucket: string;
+ accessKey: string;
+ secretKey: string;
+ region: string;
+ useSSL: boolean;
+ forcePathStyle: boolean;
+ enabled: boolean;
+}
+
+let s3ClientCache: S3Client | null = null;
+let s3ConfigCache: S3Config | null = null;
+let s3ConfigCacheTime: number = 0;
+const S3_CONFIG_CACHE_TTL = 60000; // 1 minute cache
+
+/**
+ * Get S3 configuration from app_settings table
+ */
+export async function getS3Config(): Promise {
+ // Check cache
+ if (s3ConfigCache && Date.now() - s3ConfigCacheTime < S3_CONFIG_CACHE_TTL) {
+ return s3ConfigCache;
+ }
+
+ const { data: settings } = await supabaseAdmin
+ .from('app_settings')
+ .select('setting_key, setting_value')
+ .eq('category', 'storage');
+
+ if (!settings || settings.length === 0) {
+ return null;
+ }
+
+ const config: Record = {};
+ for (const s of settings) {
+ let value = s.setting_value;
+ if (typeof value === 'string') {
+ // Remove surrounding quotes if present (from JSON stringified values)
+ value = value.replace(/^"|"$/g, '');
+ }
+ config[s.setting_key] = value;
+ }
+
+ // Check if S3 is enabled - handle both boolean true and string 'true'
+ const isEnabled = config.s3_enabled === true || config.s3_enabled === 'true';
+
+ // Check if S3 is enabled and configured
+ if (!isEnabled || !config.s3_endpoint || !config.s3_access_key || !config.s3_secret_key) {
+ console.log('S3 config check failed:', {
+ isEnabled,
+ hasEndpoint: !!config.s3_endpoint,
+ hasAccessKey: !!config.s3_access_key,
+ hasSecretKey: !!config.s3_secret_key
+ });
+ return null;
+ }
+
+ s3ConfigCache = {
+ endpoint: config.s3_endpoint,
+ bucket: config.s3_bucket || 'monacousa-documents',
+ accessKey: config.s3_access_key,
+ secretKey: config.s3_secret_key,
+ region: config.s3_region || 'us-east-1',
+ useSSL: config.s3_use_ssl === true || config.s3_use_ssl === 'true',
+ forcePathStyle: config.s3_force_path_style === true || config.s3_force_path_style === 'true' || config.s3_force_path_style === undefined,
+ enabled: true
+ };
+ s3ConfigCacheTime = Date.now();
+
+ return s3ConfigCache;
+}
+
+/**
+ * Get or create S3 client
+ */
+export async function getS3Client(): Promise {
+ const config = await getS3Config();
+ if (!config) {
+ return null;
+ }
+
+ // Return cached client if config hasn't changed
+ if (s3ClientCache && s3ConfigCache) {
+ return s3ClientCache;
+ }
+
+ s3ClientCache = new S3Client({
+ endpoint: config.endpoint,
+ region: config.region,
+ credentials: {
+ accessKeyId: config.accessKey,
+ secretAccessKey: config.secretKey
+ },
+ forcePathStyle: config.forcePathStyle
+ });
+
+ return s3ClientCache;
+}
+
+/**
+ * Clear S3 client cache (call when settings change)
+ */
+export function clearS3ClientCache(): void {
+ s3ClientCache = null;
+ s3ConfigCache = null;
+ s3ConfigCacheTime = 0;
+}
+
+/**
+ * Test S3 connection
+ */
+export async function testS3Connection(): Promise<{ success: boolean; error?: string }> {
+ const config = await getS3Config();
+ if (!config) {
+ return { success: false, error: 'S3 not configured. Please configure and enable S3 storage settings first.' };
+ }
+
+ const client = await getS3Client();
+ if (!client) {
+ return { success: false, error: 'Failed to create S3 client' };
+ }
+
+ try {
+ await client.send(new HeadBucketCommand({ Bucket: config.bucket }));
+ return { success: true };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ console.error('S3 connection test error:', error);
+ return { success: false, error: `S3 connection failed: ${errorMessage}` };
+ }
+}
+
+/**
+ * Check if S3 storage is enabled
+ */
+export async function isS3Enabled(): Promise {
+ const config = await getS3Config();
+ return config !== null && config.enabled;
+}
+
+/**
+ * Get the S3 key with bucket prefix for organization
+ */
+function getS3Key(bucket: StorageBucket, path: string): string {
+ return `${bucket}/${path}`;
+}
+
+/**
+ * Upload a file to S3
+ */
+async function uploadToS3(
+ bucket: StorageBucket,
+ path: string,
+ file: File | ArrayBuffer | Buffer,
+ options?: {
+ contentType?: string;
+ }
+): Promise {
+ const config = await getS3Config();
+ const client = await getS3Client();
+
+ if (!config || !client) {
+ return { success: false, error: 'S3 not configured' };
+ }
+
+ try {
+ const key = getS3Key(bucket, path);
+ let body: Buffer;
+
+ if (file instanceof ArrayBuffer) {
+ body = Buffer.from(file);
+ } else if (Buffer.isBuffer(file)) {
+ body = file;
+ } else {
+ // It's a File object
+ body = Buffer.from(await file.arrayBuffer());
+ }
+
+ await client.send(
+ new PutObjectCommand({
+ Bucket: config.bucket,
+ Key: key,
+ Body: body,
+ ContentType: options?.contentType
+ })
+ );
+
+ // Construct public URL
+ const protocol = config.useSSL ? 'https' : 'http';
+ let publicUrl: string;
+ if (config.forcePathStyle) {
+ publicUrl = `${config.endpoint}/${config.bucket}/${key}`;
+ } else {
+ publicUrl = `${protocol}://${config.bucket}.${new URL(config.endpoint).host}/${key}`;
+ }
+
+ return {
+ success: true,
+ path: key,
+ publicUrl
+ };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ console.error('S3 upload error:', error);
+ return { success: false, error: errorMessage };
+ }
+}
+
+/**
+ * Get a signed URL from S3
+ */
+async function getS3PresignedUrl(
+ bucket: StorageBucket,
+ path: string,
+ expiresIn: number = 3600
+): Promise<{ url: string | null; error: string | null }> {
+ const config = await getS3Config();
+ const client = await getS3Client();
+
+ if (!config || !client) {
+ return { url: null, error: 'S3 not configured' };
+ }
+
+ try {
+ const key = getS3Key(bucket, path);
+ const command = new GetObjectCommand({
+ Bucket: config.bucket,
+ Key: key
+ });
+
+ const url = await getS3SignedUrl(client, command, { expiresIn });
+ return { url, error: null };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ console.error('S3 signed URL error:', error);
+ return { url: null, error: errorMessage };
+ }
+}
+
+/**
+ * Delete a file from S3
+ */
+async function deleteFromS3(
+ bucket: StorageBucket,
+ path: string
+): Promise<{ success: boolean; error?: string }> {
+ const config = await getS3Config();
+ const client = await getS3Client();
+
+ if (!config || !client) {
+ return { success: false, error: 'S3 not configured' };
+ }
+
+ try {
+ const key = getS3Key(bucket, path);
+ await client.send(
+ new DeleteObjectCommand({
+ Bucket: config.bucket,
+ Key: key
+ })
+ );
+ return { success: true };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ console.error('S3 delete error:', error);
+ return { success: false, error: errorMessage };
+ }
+}
+
+/**
+ * Delete multiple files from S3
+ */
+async function deleteMultipleFromS3(
+ bucket: StorageBucket,
+ paths: string[]
+): Promise<{ success: boolean; error?: string }> {
+ const config = await getS3Config();
+ const client = await getS3Client();
+
+ if (!config || !client) {
+ return { success: false, error: 'S3 not configured' };
+ }
+
+ try {
+ const objects = paths.map((p) => ({ Key: getS3Key(bucket, p) }));
+ await client.send(
+ new DeleteObjectsCommand({
+ Bucket: config.bucket,
+ Delete: { Objects: objects }
+ })
+ );
+ return { success: true };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ console.error('S3 delete multiple error:', error);
+ return { success: false, error: errorMessage };
+ }
+}
+
+/**
+ * List files from S3
+ */
+async function listFilesFromS3(
+ bucket: StorageBucket,
+ folder?: string,
+ options?: {
+ limit?: number;
+ }
+): Promise<{ files: any[]; error: string | null }> {
+ const config = await getS3Config();
+ const client = await getS3Client();
+
+ if (!config || !client) {
+ return { files: [], error: 'S3 not configured' };
+ }
+
+ try {
+ const prefix = folder ? `${bucket}/${folder}/` : `${bucket}/`;
+ const response = await client.send(
+ new ListObjectsV2Command({
+ Bucket: config.bucket,
+ Prefix: prefix,
+ MaxKeys: options?.limit || 100
+ })
+ );
+
+ const files = (response.Contents || []).map((obj) => ({
+ name: obj.Key?.replace(prefix, '') || '',
+ size: obj.Size,
+ updated_at: obj.LastModified?.toISOString(),
+ created_at: obj.LastModified?.toISOString()
+ }));
+
+ return { files, error: null };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ console.error('S3 list error:', error);
+ return { files: [], error: errorMessage };
+ }
+}
+
+// ===========================================
+// PUBLIC API - Uses S3 or Supabase based on settings
+// ===========================================
+
+/**
+ * Upload a file to storage (S3 or Supabase)
+ */
+export async function uploadFile(
+ bucket: StorageBucket,
+ path: string,
+ file: File | ArrayBuffer,
+ options?: {
+ contentType?: string;
+ cacheControl?: string;
+ upsert?: boolean;
+ }
+): Promise {
+ // Check if S3 is enabled
+ if (await isS3Enabled()) {
+ return uploadToS3(bucket, path, file, options);
+ }
+
+ // Fall back to Supabase Storage
+ try {
+ const { data, error } = await supabaseAdmin.storage.from(bucket).upload(path, file, {
+ contentType: options?.contentType,
+ cacheControl: options?.cacheControl || '3600',
+ upsert: options?.upsert || false
+ });
+
+ if (error) {
+ console.error('Storage upload error:', error);
+ return { success: false, error: error.message };
+ }
+
+ // Generate browser-accessible public URL (not the internal Docker URL)
+ const publicUrl = getBrowserAccessibleUrl(bucket, path);
+
+ return {
+ success: true,
+ path: data.path,
+ publicUrl
+ };
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ console.error('Storage upload exception:', error);
+ return { success: false, error: errorMessage };
+ }
+}
+
+/**
+ * Get the public URL for a file in storage
+ */
+export async function getPublicUrl(bucket: StorageBucket, path: string): Promise {
+ // Check if S3 is enabled
+ if (await isS3Enabled()) {
+ const config = await getS3Config();
+ if (config) {
+ const key = getS3Key(bucket, path);
+ if (config.forcePathStyle) {
+ return `${config.endpoint}/${config.bucket}/${key}`;
+ }
+ const protocol = config.useSSL ? 'https' : 'http';
+ return `${protocol}://${config.bucket}.${new URL(config.endpoint).host}/${key}`;
+ }
+ }
+
+ // Fall back to Supabase Storage - use browser-accessible URL
+ return getBrowserAccessibleUrl(bucket, path);
+}
+
+/**
+ * Get a signed URL for private file access
+ */
+export async function getSignedUrl(
+ bucket: StorageBucket,
+ path: string,
+ expiresIn: number = 3600
+): Promise<{ url: string | null; error: string | null }> {
+ // Check if S3 is enabled
+ if (await isS3Enabled()) {
+ return getS3PresignedUrl(bucket, path, expiresIn);
+ }
+
+ // Fall back to Supabase Storage
+ const { data, error } = await supabaseAdmin.storage
+ .from(bucket)
+ .createSignedUrl(path, expiresIn);
+
+ if (error) {
+ return { url: null, error: error.message };
+ }
+
+ return { url: data.signedUrl, error: null };
+}
+
+/**
+ * Delete a file from storage
+ */
+export async function deleteFile(
+ bucket: StorageBucket,
+ path: string
+): Promise<{ success: boolean; error?: string }> {
+ // Check if S3 is enabled
+ if (await isS3Enabled()) {
+ return deleteFromS3(bucket, path);
+ }
+
+ // Fall back to Supabase Storage
+ const { error } = await supabaseAdmin.storage.from(bucket).remove([path]);
+
+ if (error) {
+ console.error('Storage delete error:', error);
+ return { success: false, error: error.message };
+ }
+
+ return { success: true };
+}
+
+/**
+ * Delete multiple files from storage
+ */
+export async function deleteFiles(
+ bucket: StorageBucket,
+ paths: string[]
+): Promise<{ success: boolean; error?: string }> {
+ // Check if S3 is enabled
+ if (await isS3Enabled()) {
+ return deleteMultipleFromS3(bucket, paths);
+ }
+
+ // Fall back to Supabase Storage
+ const { error } = await supabaseAdmin.storage.from(bucket).remove(paths);
+
+ if (error) {
+ console.error('Storage delete error:', error);
+ return { success: false, error: error.message };
+ }
+
+ return { success: true };
+}
+
+/**
+ * List files in a bucket/folder
+ */
+export async function listFiles(
+ bucket: StorageBucket,
+ folder?: string,
+ options?: {
+ limit?: number;
+ offset?: number;
+ sortBy?: { column: string; order: 'asc' | 'desc' };
+ }
+): Promise<{ files: any[]; error: string | null }> {
+ // Check if S3 is enabled
+ if (await isS3Enabled()) {
+ return listFilesFromS3(bucket, folder, options);
+ }
+
+ // Fall back to Supabase Storage
+ const { data, error } = await supabaseAdmin.storage.from(bucket).list(folder || '', {
+ limit: options?.limit || 100,
+ offset: options?.offset || 0,
+ sortBy: options?.sortBy || { column: 'created_at', order: 'desc' }
+ });
+
+ if (error) {
+ return { files: [], error: error.message };
+ }
+
+ return { files: data || [], error: null };
+}
+
+/**
+ * Generate a unique filename with timestamp
+ */
+export function generateUniqueFilename(originalName: string): string {
+ const timestamp = Date.now();
+ const randomStr = Math.random().toString(36).substring(2, 8);
+ const safeName = originalName.replace(/[^a-zA-Z0-9.-]/g, '_').substring(0, 50);
+ const ext = safeName.split('.').pop() || '';
+ const nameWithoutExt = safeName.replace(`.${ext}`, '');
+ return `${timestamp}-${randomStr}-${nameWithoutExt}.${ext}`;
+}
+
+/**
+ * Upload an avatar image for a member
+ * Returns both S3 and local URLs for storage flexibility
+ */
+export async function uploadAvatar(
+ memberId: string,
+ file: File,
+ userSupabase?: ReturnType
+): Promise {
+ // Validate file type
+ const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
+ if (!allowedTypes.includes(file.type)) {
+ return { success: false, error: 'Invalid image type. Allowed: JPEG, PNG, WebP, GIF' };
+ }
+
+ // Validate file size (max 5MB)
+ const maxSize = 5 * 1024 * 1024;
+ if (file.size > maxSize) {
+ return { success: false, error: 'Image size must be less than 5MB' };
+ }
+
+ // Generate path - memberId must match auth.uid() for RLS
+ const ext = file.name.split('.').pop() || 'jpg';
+ const path = `${memberId}/avatar.${ext}`;
+
+ // Convert to ArrayBuffer
+ const arrayBuffer = await file.arrayBuffer();
+
+ // Check if S3 is enabled
+ const s3Enabled = await isS3Enabled();
+
+ // Result object
+ const result: UploadResult = {
+ success: false,
+ path
+ };
+
+ // Upload to S3 if enabled
+ if (s3Enabled) {
+ const s3Result = await uploadToS3('avatars', path, arrayBuffer, {
+ contentType: file.type
+ });
+
+ if (!s3Result.success) {
+ return s3Result;
+ }
+
+ result.s3Url = s3Result.publicUrl;
+ result.publicUrl = s3Result.publicUrl;
+ result.success = true;
+ }
+
+ // Always upload to Supabase Storage as well (for fallback)
+ try {
+ // First try to delete existing avatar (ignore errors)
+ await supabaseAdmin.storage.from('avatars').remove([path]);
+
+ const { data, error } = await supabaseAdmin.storage.from('avatars').upload(path, arrayBuffer, {
+ contentType: file.type,
+ cacheControl: '3600',
+ upsert: true
+ });
+
+ if (error) {
+ // If S3 succeeded, this is okay - just log
+ if (result.success) {
+ console.warn('Local storage upload failed (S3 succeeded):', error);
+ } else {
+ console.error('Avatar upload error:', error);
+ return { success: false, error: error.message };
+ }
+ } else {
+ // Generate browser-accessible public URL (not the internal Docker URL)
+ result.localUrl = getBrowserAccessibleUrl('avatars', path);
+
+ // If S3 is not enabled, use local URL as the public URL
+ if (!s3Enabled) {
+ result.publicUrl = result.localUrl;
+ result.success = true;
+ }
+ }
+ } catch (error) {
+ // If S3 succeeded, this is okay
+ if (!result.success) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ console.error('Avatar upload exception:', error);
+ return { success: false, error: errorMessage };
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Delete a member's avatar from ALL storage backends
+ * Always attempts to delete from both S3 and Supabase Storage
+ */
+export async function deleteAvatar(
+ memberId: string,
+ avatarPath?: string
+): Promise<{ success: boolean; error?: string }> {
+ // If we have a specific path, use it; otherwise try common extensions
+ let paths: string[];
+ if (avatarPath) {
+ paths = [avatarPath];
+ } else {
+ const extensions = ['jpg', 'jpeg', 'png', 'webp', 'gif'];
+ paths = extensions.map((ext) => `${memberId}/avatar.${ext}`);
+ }
+
+ const errors: string[] = [];
+
+ // Always try to delete from S3 (in case it was uploaded when S3 was enabled)
+ try {
+ const s3Config = await getS3Config();
+ if (s3Config) {
+ const result = await deleteMultipleFromS3('avatars', paths);
+ if (!result.success && result.error) {
+ console.warn('S3 avatar delete warning:', result.error);
+ }
+ }
+ } catch (error) {
+ console.warn('S3 avatar delete error (non-critical):', error);
+ }
+
+ // Always try to delete from Supabase Storage
+ try {
+ await supabaseAdmin.storage.from('avatars').remove(paths);
+ } catch (error) {
+ console.warn('Local storage avatar delete error (non-critical):', error);
+ }
+
+ return { success: true };
+}
+
+/**
+ * Get the appropriate avatar URL based on current storage settings
+ * Useful for getting the right URL when storage setting is toggled
+ */
+export async function getActiveAvatarUrl(member: {
+ avatar_url_s3?: string | null;
+ avatar_url_local?: string | null;
+ avatar_url?: string | null;
+}): Promise {
+ // Check if S3 is enabled
+ if (await isS3Enabled()) {
+ return member.avatar_url_s3 || member.avatar_url || null;
+ }
+ return member.avatar_url_local || member.avatar_url || null;
+}
+
+/**
+ * Upload a document to storage
+ * Returns both S3 and local URLs for storage flexibility (same pattern as avatars)
+ */
+export async function uploadDocument(
+ file: File,
+ options?: {
+ folder?: string;
+ }
+): Promise {
+ // Validate file type
+ const allowedTypes = [
+ 'application/pdf',
+ 'application/msword',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'application/vnd.ms-excel',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'application/vnd.ms-powerpoint',
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+ 'text/plain',
+ 'text/csv',
+ 'application/json',
+ 'image/jpeg',
+ 'image/png',
+ 'image/webp',
+ 'image/gif'
+ ];
+
+ if (!allowedTypes.includes(file.type)) {
+ return {
+ success: false,
+ error:
+ 'File type not allowed. Supported: PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT, CSV, JSON, JPG, PNG, WebP, GIF'
+ };
+ }
+
+ // Validate file size (max 50MB)
+ const maxSize = 50 * 1024 * 1024;
+ if (file.size > maxSize) {
+ return { success: false, error: 'File size must be less than 50MB' };
+ }
+
+ // Generate unique storage path
+ const timestamp = Date.now();
+ const randomStr = Math.random().toString(36).substring(2, 8);
+ const safeName = file.name.replace(/[^a-zA-Z0-9.-]/g, '_').substring(0, 50);
+ const path = options?.folder ? `${options.folder}/${timestamp}-${randomStr}-${safeName}` : `${timestamp}-${randomStr}-${safeName}`;
+
+ // Convert to ArrayBuffer
+ const arrayBuffer = await file.arrayBuffer();
+
+ // Check if S3 is enabled
+ const s3Enabled = await isS3Enabled();
+
+ // Result object
+ const result: UploadResult = {
+ success: false,
+ path
+ };
+
+ // Upload to S3 if enabled
+ if (s3Enabled) {
+ const s3Result = await uploadToS3('documents', path, arrayBuffer, {
+ contentType: file.type
+ });
+
+ if (!s3Result.success) {
+ return s3Result;
+ }
+
+ result.s3Url = s3Result.publicUrl;
+ result.publicUrl = s3Result.publicUrl;
+ result.success = true;
+ }
+
+ // Always upload to Supabase Storage as well (for fallback)
+ try {
+ const { data, error } = await supabaseAdmin.storage.from('documents').upload(path, arrayBuffer, {
+ contentType: file.type,
+ cacheControl: '3600',
+ upsert: false
+ });
+
+ if (error) {
+ // If S3 succeeded, this is okay - just log
+ if (result.success) {
+ console.warn('Local storage upload failed (S3 succeeded):', error);
+ } else {
+ console.error('Document upload error:', error);
+ return { success: false, error: error.message };
+ }
+ } else {
+ // Generate browser-accessible public URL (not the internal Docker URL)
+ result.localUrl = getBrowserAccessibleUrl('documents', path);
+
+ // If S3 is not enabled, use local URL as the public URL
+ if (!s3Enabled) {
+ result.publicUrl = result.localUrl;
+ result.success = true;
+ }
+ }
+ } catch (error) {
+ // If S3 succeeded, this is okay
+ if (!result.success) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ console.error('Document upload exception:', error);
+ return { success: false, error: errorMessage };
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Delete a document from ALL storage backends
+ * Always attempts to delete from both S3 and Supabase Storage
+ */
+export async function deleteDocument(
+ storagePath: string
+): Promise<{ success: boolean; error?: string }> {
+ const errors: string[] = [];
+
+ // Always try to delete from S3 (in case it was uploaded when S3 was enabled)
+ try {
+ const s3Config = await getS3Config();
+ if (s3Config) {
+ const result = await deleteFromS3('documents', storagePath);
+ if (!result.success && result.error) {
+ console.warn('S3 document delete warning:', result.error);
+ }
+ }
+ } catch (error) {
+ console.warn('S3 document delete error (non-critical):', error);
+ }
+
+ // Always try to delete from Supabase Storage
+ try {
+ await supabaseAdmin.storage.from('documents').remove([storagePath]);
+ } catch (error) {
+ console.warn('Local storage document delete error (non-critical):', error);
+ }
+
+ return { success: true };
+}
+
+/**
+ * Get the appropriate document URL based on current storage settings
+ * Useful for getting the right URL when storage setting is toggled
+ */
+export async function getActiveDocumentUrl(document: {
+ file_url_s3?: string | null;
+ file_url_local?: string | null;
+ file_path?: string | null;
+}): Promise {
+ // Check if S3 is enabled
+ if (await isS3Enabled()) {
+ return document.file_url_s3 || document.file_path || null;
+ }
+ return document.file_url_local || document.file_path || null;
+}
+
+/**
+ * Upload an event cover image
+ */
+export async function uploadEventImage(eventId: string, file: File): Promise {
+ // Validate file type
+ const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
+ if (!allowedTypes.includes(file.type)) {
+ return { success: false, error: 'Invalid image type. Allowed: JPEG, PNG, WebP' };
+ }
+
+ // Validate file size (max 10MB)
+ const maxSize = 10 * 1024 * 1024;
+ if (file.size > maxSize) {
+ return { success: false, error: 'Image size must be less than 10MB' };
+ }
+
+ // Generate path
+ const ext = file.name.split('.').pop() || 'jpg';
+ const path = `${eventId}/cover.${ext}`;
+
+ // Convert to ArrayBuffer
+ const arrayBuffer = await file.arrayBuffer();
+
+ // Upload with upsert to replace existing cover
+ return uploadFile('event-images', path, arrayBuffer, {
+ contentType: file.type,
+ cacheControl: '3600',
+ upsert: true
+ });
+}
diff --git a/src/lib/server/supabase.ts b/src/lib/server/supabase.ts
new file mode 100644
index 0000000..72136e1
--- /dev/null
+++ b/src/lib/server/supabase.ts
@@ -0,0 +1,42 @@
+import pkg from '@supabase/ssr';
+const { createServerClient } = pkg;
+import { createClient as createSupabaseClient } from '@supabase/supabase-js';
+import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
+import { SUPABASE_SERVICE_ROLE_KEY } from '$env/static/private';
+import { env } from '$env/dynamic/private';
+import type { Cookies } from '@sveltejs/kit';
+import type { Database } from '$lib/types/database';
+
+// Use internal URL for server-side operations (Docker network), fallback to public URL
+const SERVER_SUPABASE_URL = env.SUPABASE_INTERNAL_URL || PUBLIC_SUPABASE_URL;
+
+/**
+ * Create a Supabase client for server-side operations with cookie handling
+ */
+export function createSupabaseServerClient(cookies: Cookies) {
+ return createServerClient(SERVER_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
+ cookies: {
+ getAll: () => cookies.getAll(),
+ setAll: (cookiesToSet) => {
+ cookiesToSet.forEach(({ name, value, options }) => {
+ cookies.set(name, value, { ...options, path: '/' });
+ });
+ }
+ }
+ });
+}
+
+/**
+ * Supabase Admin client with service role key
+ * Use this for administrative operations that bypass RLS
+ */
+export const supabaseAdmin = createSupabaseClient(
+ SERVER_SUPABASE_URL,
+ SUPABASE_SERVICE_ROLE_KEY,
+ {
+ auth: {
+ autoRefreshToken: false,
+ persistSession: false
+ }
+ }
+);
diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts
new file mode 100644
index 0000000..0d101a0
--- /dev/null
+++ b/src/lib/supabase.ts
@@ -0,0 +1,11 @@
+import pkg from '@supabase/ssr';
+const { createBrowserClient } = pkg;
+import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
+import type { Database } from './types/database';
+
+/**
+ * Create a Supabase client for browser-side operations
+ */
+export function createClient() {
+ return createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY);
+}
diff --git a/src/lib/types/database.ts b/src/lib/types/database.ts
new file mode 100644
index 0000000..6535339
--- /dev/null
+++ b/src/lib/types/database.ts
@@ -0,0 +1,806 @@
+/**
+ * Database Types for Monaco USA Portal 2026
+ * Generated based on the architecture plan schema
+ */
+
+export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[];
+
+export type Database = {
+ public: {
+ Tables: {
+ members: {
+ Row: {
+ id: string;
+ member_id: string;
+ first_name: string;
+ last_name: string;
+ email: string;
+ phone: string;
+ date_of_birth: string;
+ address: string;
+ nationality: string[];
+ role: 'member' | 'board' | 'admin';
+ membership_status_id: string | null;
+ membership_type_id: string | null;
+ member_since: string;
+ avatar_url: string | null;
+ notes: string | null;
+ created_at: string;
+ updated_at: string;
+ };
+ Insert: {
+ id: string;
+ member_id?: string;
+ first_name: string;
+ last_name: string;
+ email: string;
+ phone: string;
+ date_of_birth: string;
+ address: string;
+ nationality: string[];
+ role?: 'member' | 'board' | 'admin';
+ membership_status_id?: string | null;
+ membership_type_id?: string | null;
+ member_since?: string;
+ avatar_url?: string | null;
+ notes?: string | null;
+ created_at?: string;
+ updated_at?: string;
+ };
+ Update: {
+ id?: string;
+ member_id?: string;
+ first_name?: string;
+ last_name?: string;
+ email?: string;
+ phone?: string;
+ date_of_birth?: string;
+ address?: string;
+ nationality?: string[];
+ role?: 'member' | 'board' | 'admin';
+ membership_status_id?: string | null;
+ membership_type_id?: string | null;
+ member_since?: string;
+ avatar_url?: string | null;
+ notes?: string | null;
+ created_at?: string;
+ updated_at?: string;
+ };
+ };
+ membership_statuses: {
+ Row: {
+ id: string;
+ name: string;
+ display_name: string;
+ color: string;
+ description: string | null;
+ is_default: boolean;
+ sort_order: number;
+ created_at: string;
+ };
+ Insert: {
+ id?: string;
+ name: string;
+ display_name: string;
+ color?: string;
+ description?: string | null;
+ is_default?: boolean;
+ sort_order?: number;
+ created_at?: string;
+ };
+ Update: {
+ id?: string;
+ name?: string;
+ display_name?: string;
+ color?: string;
+ description?: string | null;
+ is_default?: boolean;
+ sort_order?: number;
+ created_at?: string;
+ };
+ };
+ membership_types: {
+ Row: {
+ id: string;
+ name: string;
+ display_name: string;
+ annual_dues: number;
+ description: string | null;
+ is_default: boolean;
+ is_active: boolean;
+ sort_order: number;
+ created_at: string;
+ };
+ Insert: {
+ id?: string;
+ name: string;
+ display_name: string;
+ annual_dues: number;
+ description?: string | null;
+ is_default?: boolean;
+ is_active?: boolean;
+ sort_order?: number;
+ created_at?: string;
+ };
+ Update: {
+ id?: string;
+ name?: string;
+ display_name?: string;
+ annual_dues?: number;
+ description?: string | null;
+ is_default?: boolean;
+ is_active?: boolean;
+ sort_order?: number;
+ created_at?: string;
+ };
+ };
+ dues_payments: {
+ Row: {
+ id: string;
+ member_id: string;
+ amount: number;
+ currency: string;
+ payment_date: string;
+ due_date: string;
+ payment_method: string;
+ reference: string | null;
+ notes: string | null;
+ recorded_by: string;
+ created_at: string;
+ };
+ Insert: {
+ id?: string;
+ member_id: string;
+ amount: number;
+ currency?: string;
+ payment_date: string;
+ due_date?: string;
+ payment_method?: string;
+ reference?: string | null;
+ notes?: string | null;
+ recorded_by: string;
+ created_at?: string;
+ };
+ Update: {
+ id?: string;
+ member_id?: string;
+ amount?: number;
+ currency?: string;
+ payment_date?: string;
+ due_date?: string;
+ payment_method?: string;
+ reference?: string | null;
+ notes?: string | null;
+ recorded_by?: string;
+ created_at?: string;
+ };
+ };
+ events: {
+ Row: {
+ id: string;
+ title: string;
+ description: string | null;
+ event_type_id: string | null;
+ start_datetime: string;
+ end_datetime: string;
+ all_day: boolean;
+ timezone: string;
+ location: string | null;
+ location_url: string | null;
+ max_attendees: number | null;
+ max_guests_per_member: number;
+ is_paid: boolean;
+ member_price: number;
+ non_member_price: number;
+ pricing_notes: string | null;
+ visibility: 'public' | 'members' | 'board' | 'admin';
+ status: 'draft' | 'published' | 'cancelled' | 'completed';
+ cover_image_url: string | null;
+ created_by: string;
+ created_at: string;
+ updated_at: string;
+ };
+ Insert: {
+ id?: string;
+ title: string;
+ description?: string | null;
+ event_type_id?: string | null;
+ start_datetime: string;
+ end_datetime: string;
+ all_day?: boolean;
+ timezone?: string;
+ location?: string | null;
+ location_url?: string | null;
+ max_attendees?: number | null;
+ max_guests_per_member?: number;
+ is_paid?: boolean;
+ member_price?: number;
+ non_member_price?: number;
+ pricing_notes?: string | null;
+ visibility?: 'public' | 'members' | 'board' | 'admin';
+ status?: 'draft' | 'published' | 'cancelled' | 'completed';
+ cover_image_url?: string | null;
+ created_by: string;
+ created_at?: string;
+ updated_at?: string;
+ };
+ Update: {
+ id?: string;
+ title?: string;
+ description?: string | null;
+ event_type_id?: string | null;
+ start_datetime?: string;
+ end_datetime?: string;
+ all_day?: boolean;
+ timezone?: string;
+ location?: string | null;
+ location_url?: string | null;
+ max_attendees?: number | null;
+ max_guests_per_member?: number;
+ is_paid?: boolean;
+ member_price?: number;
+ non_member_price?: number;
+ pricing_notes?: string | null;
+ visibility?: 'public' | 'members' | 'board' | 'admin';
+ status?: 'draft' | 'published' | 'cancelled' | 'completed';
+ cover_image_url?: string | null;
+ created_by?: string;
+ created_at?: string;
+ updated_at?: string;
+ };
+ };
+ event_types: {
+ Row: {
+ id: string;
+ name: string;
+ display_name: string;
+ color: string;
+ icon: string | null;
+ description: string | null;
+ is_active: boolean;
+ sort_order: number;
+ created_at: string;
+ };
+ Insert: {
+ id?: string;
+ name: string;
+ display_name: string;
+ color?: string;
+ icon?: string | null;
+ description?: string | null;
+ is_active?: boolean;
+ sort_order?: number;
+ created_at?: string;
+ };
+ Update: {
+ id?: string;
+ name?: string;
+ display_name?: string;
+ color?: string;
+ icon?: string | null;
+ description?: string | null;
+ is_active?: boolean;
+ sort_order?: number;
+ created_at?: string;
+ };
+ };
+ event_rsvps: {
+ Row: {
+ id: string;
+ event_id: string;
+ member_id: string;
+ status: 'confirmed' | 'declined' | 'maybe' | 'waitlist' | 'cancelled';
+ guest_count: number;
+ guest_names: string[] | null;
+ notes: string | null;
+ payment_status: 'not_required' | 'pending' | 'paid';
+ payment_reference: string | null;
+ payment_amount: number | null;
+ attended: boolean;
+ checked_in_at: string | null;
+ checked_in_by: string | null;
+ created_at: string;
+ updated_at: string;
+ };
+ Insert: {
+ id?: string;
+ event_id: string;
+ member_id: string;
+ status?: 'confirmed' | 'declined' | 'maybe' | 'waitlist' | 'cancelled';
+ guest_count?: number;
+ guest_names?: string[] | null;
+ notes?: string | null;
+ payment_status?: 'not_required' | 'pending' | 'paid';
+ payment_reference?: string | null;
+ payment_amount?: number | null;
+ attended?: boolean;
+ checked_in_at?: string | null;
+ checked_in_by?: string | null;
+ created_at?: string;
+ updated_at?: string;
+ };
+ Update: {
+ id?: string;
+ event_id?: string;
+ member_id?: string;
+ status?: 'confirmed' | 'declined' | 'maybe' | 'waitlist' | 'cancelled';
+ guest_count?: number;
+ guest_names?: string[] | null;
+ notes?: string | null;
+ payment_status?: 'not_required' | 'pending' | 'paid';
+ payment_reference?: string | null;
+ payment_amount?: number | null;
+ attended?: boolean;
+ checked_in_at?: string | null;
+ checked_in_by?: string | null;
+ created_at?: string;
+ updated_at?: string;
+ };
+ };
+ event_rsvps_public: {
+ Row: {
+ id: string;
+ event_id: string;
+ full_name: string;
+ email: string;
+ phone: string | null;
+ status: 'confirmed' | 'declined' | 'maybe' | 'waitlist' | 'cancelled';
+ guest_count: number;
+ guest_names: string[] | null;
+ payment_status: 'not_required' | 'pending' | 'paid';
+ payment_reference: string | null;
+ payment_amount: number | null;
+ attended: boolean;
+ created_at: string;
+ updated_at: string;
+ };
+ Insert: {
+ id?: string;
+ event_id: string;
+ full_name: string;
+ email: string;
+ phone?: string | null;
+ status?: 'confirmed' | 'declined' | 'maybe' | 'waitlist' | 'cancelled';
+ guest_count?: number;
+ guest_names?: string[] | null;
+ payment_status?: 'not_required' | 'pending' | 'paid';
+ payment_reference?: string | null;
+ payment_amount?: number | null;
+ attended?: boolean;
+ created_at?: string;
+ updated_at?: string;
+ };
+ Update: {
+ id?: string;
+ event_id?: string;
+ full_name?: string;
+ email?: string;
+ phone?: string | null;
+ status?: 'confirmed' | 'declined' | 'maybe' | 'waitlist' | 'cancelled';
+ guest_count?: number;
+ guest_names?: string[] | null;
+ payment_status?: 'not_required' | 'pending' | 'paid';
+ payment_reference?: string | null;
+ payment_amount?: number | null;
+ attended?: boolean;
+ created_at?: string;
+ updated_at?: string;
+ };
+ };
+ documents: {
+ Row: {
+ id: string;
+ title: string;
+ description: string | null;
+ category_id: string | null;
+ file_path: string;
+ file_name: string;
+ file_size: number;
+ mime_type: string;
+ visibility: 'public' | 'members' | 'board' | 'admin';
+ allowed_member_ids: string[] | null;
+ version: number;
+ replaces_document_id: string | null;
+ meeting_date: string | null;
+ meeting_attendees: string[] | null;
+ uploaded_by: string;
+ created_at: string;
+ updated_at: string;
+ };
+ Insert: {
+ id?: string;
+ title: string;
+ description?: string | null;
+ category_id?: string | null;
+ file_path: string;
+ file_name: string;
+ file_size: number;
+ mime_type: string;
+ visibility?: 'public' | 'members' | 'board' | 'admin';
+ allowed_member_ids?: string[] | null;
+ version?: number;
+ replaces_document_id?: string | null;
+ meeting_date?: string | null;
+ meeting_attendees?: string[] | null;
+ uploaded_by: string;
+ created_at?: string;
+ updated_at?: string;
+ };
+ Update: {
+ id?: string;
+ title?: string;
+ description?: string | null;
+ category_id?: string | null;
+ file_path?: string;
+ file_name?: string;
+ file_size?: number;
+ mime_type?: string;
+ visibility?: 'public' | 'members' | 'board' | 'admin';
+ allowed_member_ids?: string[] | null;
+ version?: number;
+ replaces_document_id?: string | null;
+ meeting_date?: string | null;
+ meeting_attendees?: string[] | null;
+ uploaded_by?: string;
+ created_at?: string;
+ updated_at?: string;
+ };
+ };
+ document_categories: {
+ Row: {
+ id: string;
+ name: string;
+ display_name: string;
+ description: string | null;
+ icon: string | null;
+ sort_order: number;
+ is_active: boolean;
+ created_at: string;
+ };
+ Insert: {
+ id?: string;
+ name: string;
+ display_name: string;
+ description?: string | null;
+ icon?: string | null;
+ sort_order?: number;
+ is_active?: boolean;
+ created_at?: string;
+ };
+ Update: {
+ id?: string;
+ name?: string;
+ display_name?: string;
+ description?: string | null;
+ icon?: string | null;
+ sort_order?: number;
+ is_active?: boolean;
+ created_at?: string;
+ };
+ };
+ app_settings: {
+ Row: {
+ id: string;
+ category: string;
+ setting_key: string;
+ setting_value: Json;
+ setting_type: 'text' | 'number' | 'boolean' | 'json' | 'array';
+ display_name: string;
+ description: string | null;
+ is_public: boolean;
+ updated_at: string;
+ updated_by: string | null;
+ };
+ Insert: {
+ id?: string;
+ category: string;
+ setting_key: string;
+ setting_value: Json;
+ setting_type?: 'text' | 'number' | 'boolean' | 'json' | 'array';
+ display_name: string;
+ description?: string | null;
+ is_public?: boolean;
+ updated_at?: string;
+ updated_by?: string | null;
+ };
+ Update: {
+ id?: string;
+ category?: string;
+ setting_key?: string;
+ setting_value?: Json;
+ setting_type?: 'text' | 'number' | 'boolean' | 'json' | 'array';
+ display_name?: string;
+ description?: string | null;
+ is_public?: boolean;
+ updated_at?: string;
+ updated_by?: string | null;
+ };
+ };
+ email_templates: {
+ Row: {
+ id: string;
+ template_key: string;
+ template_name: string;
+ category: string;
+ subject: string;
+ body_html: string;
+ body_text: string | null;
+ is_active: boolean;
+ is_system: boolean;
+ variables_schema: Json | null;
+ preview_data: Json | null;
+ created_at: string;
+ updated_at: string;
+ updated_by: string | null;
+ };
+ Insert: {
+ id?: string;
+ template_key: string;
+ template_name: string;
+ category: string;
+ subject: string;
+ body_html: string;
+ body_text?: string | null;
+ is_active?: boolean;
+ is_system?: boolean;
+ variables_schema?: Json | null;
+ preview_data?: Json | null;
+ created_at?: string;
+ updated_at?: string;
+ updated_by?: string | null;
+ };
+ Update: {
+ id?: string;
+ template_key?: string;
+ template_name?: string;
+ category?: string;
+ subject?: string;
+ body_html?: string;
+ body_text?: string | null;
+ is_active?: boolean;
+ is_system?: boolean;
+ variables_schema?: Json | null;
+ preview_data?: Json | null;
+ created_at?: string;
+ updated_at?: string;
+ updated_by?: string | null;
+ };
+ };
+ email_logs: {
+ Row: {
+ id: string;
+ recipient_id: string | null;
+ recipient_email: string;
+ recipient_name: string | null;
+ template_key: string | null;
+ subject: string;
+ email_type: string;
+ status: 'queued' | 'sent' | 'delivered' | 'opened' | 'clicked' | 'bounced' | 'failed';
+ provider: string | null;
+ provider_message_id: string | null;
+ opened_at: string | null;
+ clicked_at: string | null;
+ error_message: string | null;
+ retry_count: number;
+ template_variables: Json | null;
+ sent_by: string | null;
+ created_at: string;
+ sent_at: string | null;
+ delivered_at: string | null;
+ };
+ Insert: {
+ id?: string;
+ recipient_id?: string | null;
+ recipient_email: string;
+ recipient_name?: string | null;
+ template_key?: string | null;
+ subject: string;
+ email_type: string;
+ status?: 'queued' | 'sent' | 'delivered' | 'opened' | 'clicked' | 'bounced' | 'failed';
+ provider?: string | null;
+ provider_message_id?: string | null;
+ opened_at?: string | null;
+ clicked_at?: string | null;
+ error_message?: string | null;
+ retry_count?: number;
+ template_variables?: Json | null;
+ sent_by?: string | null;
+ created_at?: string;
+ sent_at?: string | null;
+ delivered_at?: string | null;
+ };
+ Update: {
+ id?: string;
+ recipient_id?: string | null;
+ recipient_email?: string;
+ recipient_name?: string | null;
+ template_key?: string | null;
+ subject?: string;
+ email_type?: string;
+ status?: 'queued' | 'sent' | 'delivered' | 'opened' | 'clicked' | 'bounced' | 'failed';
+ provider?: string | null;
+ provider_message_id?: string | null;
+ opened_at?: string | null;
+ clicked_at?: string | null;
+ error_message?: string | null;
+ retry_count?: number;
+ template_variables?: Json | null;
+ sent_by?: string | null;
+ created_at?: string;
+ sent_at?: string | null;
+ delivered_at?: string | null;
+ };
+ };
+ audit_logs: {
+ Row: {
+ id: string;
+ user_id: string | null;
+ user_email: string | null;
+ action: string;
+ resource_type: string | null;
+ resource_id: string | null;
+ details: Json;
+ ip_address: string | null;
+ user_agent: string | null;
+ created_at: string;
+ };
+ Insert: {
+ id?: string;
+ user_id?: string | null;
+ user_email?: string | null;
+ action: string;
+ resource_type?: string | null;
+ resource_id?: string | null;
+ details?: Json;
+ ip_address?: string | null;
+ user_agent?: string | null;
+ created_at?: string;
+ };
+ Update: {
+ id?: string;
+ user_id?: string | null;
+ user_email?: string | null;
+ action?: string;
+ resource_type?: string | null;
+ resource_id?: string | null;
+ details?: Json;
+ ip_address?: string | null;
+ user_agent?: string | null;
+ created_at?: string;
+ };
+ };
+ dues_reminder_logs: {
+ Row: {
+ id: string;
+ member_id: string;
+ reminder_type: 'due_soon_30' | 'due_soon_7' | 'due_soon_1' | 'overdue' | 'grace_period' | 'inactive_notice';
+ due_date: string;
+ sent_at: string;
+ email_log_id: string | null;
+ };
+ Insert: {
+ id?: string;
+ member_id: string;
+ reminder_type: 'due_soon_30' | 'due_soon_7' | 'due_soon_1' | 'overdue' | 'grace_period' | 'inactive_notice';
+ due_date: string;
+ sent_at?: string;
+ email_log_id?: string | null;
+ };
+ Update: {
+ id?: string;
+ member_id?: string;
+ reminder_type?: 'due_soon_30' | 'due_soon_7' | 'due_soon_1' | 'overdue' | 'grace_period' | 'inactive_notice';
+ due_date?: string;
+ sent_at?: string;
+ email_log_id?: string | null;
+ };
+ };
+ };
+ Views: {
+ members_with_dues: {
+ Row: {
+ id: string;
+ member_id: string;
+ first_name: string;
+ last_name: string;
+ email: string;
+ phone: string;
+ date_of_birth: string;
+ address: string;
+ nationality: string[];
+ role: 'member' | 'board' | 'admin';
+ membership_status_id: string | null;
+ membership_type_id: string | null;
+ member_since: string;
+ avatar_url: string | null;
+ notes: string | null;
+ created_at: string;
+ updated_at: string;
+ status_name: string | null;
+ status_display_name: string | null;
+ status_color: string | null;
+ membership_type_name: string | null;
+ annual_dues: number | null;
+ last_payment_date: string | null;
+ current_due_date: string | null;
+ dues_status: 'never_paid' | 'overdue' | 'due_soon' | 'current';
+ days_overdue: number | null;
+ days_until_due: number | null;
+ };
+ };
+ events_with_counts: {
+ Row: {
+ id: string;
+ title: string;
+ description: string | null;
+ event_type_id: string | null;
+ start_datetime: string;
+ end_datetime: string;
+ all_day: boolean;
+ timezone: string;
+ location: string | null;
+ location_url: string | null;
+ max_attendees: number | null;
+ max_guests_per_member: number;
+ is_paid: boolean;
+ member_price: number;
+ non_member_price: number;
+ pricing_notes: string | null;
+ visibility: 'public' | 'members' | 'board' | 'admin';
+ status: 'draft' | 'published' | 'cancelled' | 'completed';
+ cover_image_url: string | null;
+ created_by: string;
+ created_at: string;
+ updated_at: string;
+ event_type_name: string | null;
+ event_type_color: string | null;
+ event_type_icon: string | null;
+ total_attendees: number;
+ member_count: number;
+ non_member_count: number;
+ waitlist_count: number;
+ is_full: boolean;
+ };
+ };
+ };
+ Functions: Record;
+ Enums: Record;
+ };
+};
+
+// Convenience types
+export type Member = Database['public']['Tables']['members']['Row'];
+export type MemberInsert = Database['public']['Tables']['members']['Insert'];
+export type MemberUpdate = Database['public']['Tables']['members']['Update'];
+
+export type MemberWithDues = Database['public']['Views']['members_with_dues']['Row'];
+
+export type Event = Database['public']['Tables']['events']['Row'];
+export type EventInsert = Database['public']['Tables']['events']['Insert'];
+export type EventUpdate = Database['public']['Tables']['events']['Update'];
+
+export type EventWithCounts = Database['public']['Views']['events_with_counts']['Row'];
+
+export type EventRSVP = Database['public']['Tables']['event_rsvps']['Row'];
+export type EventRSVPInsert = Database['public']['Tables']['event_rsvps']['Insert'];
+
+export type DuesPayment = Database['public']['Tables']['dues_payments']['Row'];
+export type DuesPaymentInsert = Database['public']['Tables']['dues_payments']['Insert'];
+
+export type Document = Database['public']['Tables']['documents']['Row'];
+export type DocumentInsert = Database['public']['Tables']['documents']['Insert'];
+
+export type AppSetting = Database['public']['Tables']['app_settings']['Row'];
+export type EmailTemplate = Database['public']['Tables']['email_templates']['Row'];
+export type EmailLog = Database['public']['Tables']['email_logs']['Row'];
+export type AuditLog = Database['public']['Tables']['audit_logs']['Row'];
+export type DuesReminderLog = Database['public']['Tables']['dues_reminder_logs']['Row'];
+
+// Role type
+export type MemberRole = 'member' | 'board' | 'admin';
+
+// Event visibility
+export type EventVisibility = 'public' | 'members' | 'board' | 'admin';
+
+// Dues status
+export type DuesStatus = 'never_paid' | 'overdue' | 'due_soon' | 'current';
diff --git a/src/lib/utils/countries.ts b/src/lib/utils/countries.ts
new file mode 100644
index 0000000..0db4d26
--- /dev/null
+++ b/src/lib/utils/countries.ts
@@ -0,0 +1,262 @@
+// Complete ISO 3166-1 alpha-2 country codes with names and flags
+export const countries = [
+ { code: 'AF', name: 'Afghanistan', flag: '🇦🇫' },
+ { code: 'AL', name: 'Albania', flag: '🇦🇱' },
+ { code: 'DZ', name: 'Algeria', flag: '🇩🇿' },
+ { code: 'AS', name: 'American Samoa', flag: '🇦🇸' },
+ { code: 'AD', name: 'Andorra', flag: '🇦🇩' },
+ { code: 'AO', name: 'Angola', flag: '🇦🇴' },
+ { code: 'AI', name: 'Anguilla', flag: '🇦🇮' },
+ { code: 'AQ', name: 'Antarctica', flag: '🇦🇶' },
+ { code: 'AG', name: 'Antigua and Barbuda', flag: '🇦🇬' },
+ { code: 'AR', name: 'Argentina', flag: '🇦🇷' },
+ { code: 'AM', name: 'Armenia', flag: '🇦🇲' },
+ { code: 'AW', name: 'Aruba', flag: '🇦🇼' },
+ { code: 'AU', name: 'Australia', flag: '🇦🇺' },
+ { code: 'AT', name: 'Austria', flag: '🇦🇹' },
+ { code: 'AZ', name: 'Azerbaijan', flag: '🇦🇿' },
+ { code: 'BS', name: 'Bahamas', flag: '🇧🇸' },
+ { code: 'BH', name: 'Bahrain', flag: '🇧🇭' },
+ { code: 'BD', name: 'Bangladesh', flag: '🇧🇩' },
+ { code: 'BB', name: 'Barbados', flag: '🇧🇧' },
+ { code: 'BY', name: 'Belarus', flag: '🇧🇾' },
+ { code: 'BE', name: 'Belgium', flag: '🇧🇪' },
+ { code: 'BZ', name: 'Belize', flag: '🇧🇿' },
+ { code: 'BJ', name: 'Benin', flag: '🇧🇯' },
+ { code: 'BM', name: 'Bermuda', flag: '🇧🇲' },
+ { code: 'BT', name: 'Bhutan', flag: '🇧🇹' },
+ { code: 'BO', name: 'Bolivia', flag: '🇧🇴' },
+ { code: 'BA', name: 'Bosnia and Herzegovina', flag: '🇧🇦' },
+ { code: 'BW', name: 'Botswana', flag: '🇧🇼' },
+ { code: 'BR', name: 'Brazil', flag: '🇧🇷' },
+ { code: 'IO', name: 'British Indian Ocean Territory', flag: '🇮🇴' },
+ { code: 'VG', name: 'British Virgin Islands', flag: '🇻🇬' },
+ { code: 'BN', name: 'Brunei', flag: '🇧🇳' },
+ { code: 'BG', name: 'Bulgaria', flag: '🇧🇬' },
+ { code: 'BF', name: 'Burkina Faso', flag: '🇧🇫' },
+ { code: 'BI', name: 'Burundi', flag: '🇧🇮' },
+ { code: 'CV', name: 'Cabo Verde', flag: '🇨🇻' },
+ { code: 'KH', name: 'Cambodia', flag: '🇰🇭' },
+ { code: 'CM', name: 'Cameroon', flag: '🇨🇲' },
+ { code: 'CA', name: 'Canada', flag: '🇨🇦' },
+ { code: 'KY', name: 'Cayman Islands', flag: '🇰🇾' },
+ { code: 'CF', name: 'Central African Republic', flag: '🇨🇫' },
+ { code: 'TD', name: 'Chad', flag: '🇹🇩' },
+ { code: 'CL', name: 'Chile', flag: '🇨🇱' },
+ { code: 'CN', name: 'China', flag: '🇨🇳' },
+ { code: 'CX', name: 'Christmas Island', flag: '🇨🇽' },
+ { code: 'CC', name: 'Cocos (Keeling) Islands', flag: '🇨🇨' },
+ { code: 'CO', name: 'Colombia', flag: '🇨🇴' },
+ { code: 'KM', name: 'Comoros', flag: '🇰🇲' },
+ { code: 'CG', name: 'Congo', flag: '🇨🇬' },
+ { code: 'CD', name: 'Congo (DRC)', flag: '🇨🇩' },
+ { code: 'CK', name: 'Cook Islands', flag: '🇨🇰' },
+ { code: 'CR', name: 'Costa Rica', flag: '🇨🇷' },
+ { code: 'CI', name: "Côte d'Ivoire", flag: '🇨🇮' },
+ { code: 'HR', name: 'Croatia', flag: '🇭🇷' },
+ { code: 'CU', name: 'Cuba', flag: '🇨🇺' },
+ { code: 'CW', name: 'Curaçao', flag: '🇨🇼' },
+ { code: 'CY', name: 'Cyprus', flag: '🇨🇾' },
+ { code: 'CZ', name: 'Czech Republic', flag: '🇨🇿' },
+ { code: 'DK', name: 'Denmark', flag: '🇩🇰' },
+ { code: 'DJ', name: 'Djibouti', flag: '🇩🇯' },
+ { code: 'DM', name: 'Dominica', flag: '🇩🇲' },
+ { code: 'DO', name: 'Dominican Republic', flag: '🇩🇴' },
+ { code: 'EC', name: 'Ecuador', flag: '🇪🇨' },
+ { code: 'EG', name: 'Egypt', flag: '🇪🇬' },
+ { code: 'SV', name: 'El Salvador', flag: '🇸🇻' },
+ { code: 'GQ', name: 'Equatorial Guinea', flag: '🇬🇶' },
+ { code: 'ER', name: 'Eritrea', flag: '🇪🇷' },
+ { code: 'EE', name: 'Estonia', flag: '🇪🇪' },
+ { code: 'SZ', name: 'Eswatini', flag: '🇸🇿' },
+ { code: 'ET', name: 'Ethiopia', flag: '🇪🇹' },
+ { code: 'FK', name: 'Falkland Islands', flag: '🇫🇰' },
+ { code: 'FO', name: 'Faroe Islands', flag: '🇫🇴' },
+ { code: 'FJ', name: 'Fiji', flag: '🇫🇯' },
+ { code: 'FI', name: 'Finland', flag: '🇫🇮' },
+ { code: 'FR', name: 'France', flag: '🇫🇷' },
+ { code: 'GF', name: 'French Guiana', flag: '🇬🇫' },
+ { code: 'PF', name: 'French Polynesia', flag: '🇵🇫' },
+ { code: 'TF', name: 'French Southern Territories', flag: '🇹🇫' },
+ { code: 'GA', name: 'Gabon', flag: '🇬🇦' },
+ { code: 'GM', name: 'Gambia', flag: '🇬🇲' },
+ { code: 'GE', name: 'Georgia', flag: '🇬🇪' },
+ { code: 'DE', name: 'Germany', flag: '🇩🇪' },
+ { code: 'GH', name: 'Ghana', flag: '🇬🇭' },
+ { code: 'GI', name: 'Gibraltar', flag: '🇬🇮' },
+ { code: 'GR', name: 'Greece', flag: '🇬🇷' },
+ { code: 'GL', name: 'Greenland', flag: '🇬🇱' },
+ { code: 'GD', name: 'Grenada', flag: '🇬🇩' },
+ { code: 'GP', name: 'Guadeloupe', flag: '🇬🇵' },
+ { code: 'GU', name: 'Guam', flag: '🇬🇺' },
+ { code: 'GT', name: 'Guatemala', flag: '🇬🇹' },
+ { code: 'GG', name: 'Guernsey', flag: '🇬🇬' },
+ { code: 'GN', name: 'Guinea', flag: '🇬🇳' },
+ { code: 'GW', name: 'Guinea-Bissau', flag: '🇬🇼' },
+ { code: 'GY', name: 'Guyana', flag: '🇬🇾' },
+ { code: 'HT', name: 'Haiti', flag: '🇭🇹' },
+ { code: 'HN', name: 'Honduras', flag: '🇭🇳' },
+ { code: 'HK', name: 'Hong Kong', flag: '🇭🇰' },
+ { code: 'HU', name: 'Hungary', flag: '🇭🇺' },
+ { code: 'IS', name: 'Iceland', flag: '🇮🇸' },
+ { code: 'IN', name: 'India', flag: '🇮🇳' },
+ { code: 'ID', name: 'Indonesia', flag: '🇮🇩' },
+ { code: 'IR', name: 'Iran', flag: '🇮🇷' },
+ { code: 'IQ', name: 'Iraq', flag: '🇮🇶' },
+ { code: 'IE', name: 'Ireland', flag: '🇮🇪' },
+ { code: 'IM', name: 'Isle of Man', flag: '🇮🇲' },
+ { code: 'IL', name: 'Israel', flag: '🇮🇱' },
+ { code: 'IT', name: 'Italy', flag: '🇮🇹' },
+ { code: 'JM', name: 'Jamaica', flag: '🇯🇲' },
+ { code: 'JP', name: 'Japan', flag: '🇯🇵' },
+ { code: 'JE', name: 'Jersey', flag: '🇯🇪' },
+ { code: 'JO', name: 'Jordan', flag: '🇯🇴' },
+ { code: 'KZ', name: 'Kazakhstan', flag: '🇰🇿' },
+ { code: 'KE', name: 'Kenya', flag: '🇰🇪' },
+ { code: 'KI', name: 'Kiribati', flag: '🇰🇮' },
+ { code: 'KW', name: 'Kuwait', flag: '🇰🇼' },
+ { code: 'KG', name: 'Kyrgyzstan', flag: '🇰🇬' },
+ { code: 'LA', name: 'Laos', flag: '🇱🇦' },
+ { code: 'LV', name: 'Latvia', flag: '🇱🇻' },
+ { code: 'LB', name: 'Lebanon', flag: '🇱🇧' },
+ { code: 'LS', name: 'Lesotho', flag: '🇱🇸' },
+ { code: 'LR', name: 'Liberia', flag: '🇱🇷' },
+ { code: 'LY', name: 'Libya', flag: '🇱🇾' },
+ { code: 'LI', name: 'Liechtenstein', flag: '🇱🇮' },
+ { code: 'LT', name: 'Lithuania', flag: '🇱🇹' },
+ { code: 'LU', name: 'Luxembourg', flag: '🇱🇺' },
+ { code: 'MO', name: 'Macao', flag: '🇲🇴' },
+ { code: 'MG', name: 'Madagascar', flag: '🇲🇬' },
+ { code: 'MW', name: 'Malawi', flag: '🇲🇼' },
+ { code: 'MY', name: 'Malaysia', flag: '🇲🇾' },
+ { code: 'MV', name: 'Maldives', flag: '🇲🇻' },
+ { code: 'ML', name: 'Mali', flag: '🇲🇱' },
+ { code: 'MT', name: 'Malta', flag: '🇲🇹' },
+ { code: 'MH', name: 'Marshall Islands', flag: '🇲🇭' },
+ { code: 'MQ', name: 'Martinique', flag: '🇲🇶' },
+ { code: 'MR', name: 'Mauritania', flag: '🇲🇷' },
+ { code: 'MU', name: 'Mauritius', flag: '🇲🇺' },
+ { code: 'YT', name: 'Mayotte', flag: '🇾🇹' },
+ { code: 'MX', name: 'Mexico', flag: '🇲🇽' },
+ { code: 'FM', name: 'Micronesia', flag: '🇫🇲' },
+ { code: 'MD', name: 'Moldova', flag: '🇲🇩' },
+ { code: 'MC', name: 'Monaco', flag: '🇲🇨' },
+ { code: 'MN', name: 'Mongolia', flag: '🇲🇳' },
+ { code: 'ME', name: 'Montenegro', flag: '🇲🇪' },
+ { code: 'MS', name: 'Montserrat', flag: '🇲🇸' },
+ { code: 'MA', name: 'Morocco', flag: '🇲🇦' },
+ { code: 'MZ', name: 'Mozambique', flag: '🇲🇿' },
+ { code: 'MM', name: 'Myanmar', flag: '🇲🇲' },
+ { code: 'NA', name: 'Namibia', flag: '🇳🇦' },
+ { code: 'NR', name: 'Nauru', flag: '🇳🇷' },
+ { code: 'NP', name: 'Nepal', flag: '🇳🇵' },
+ { code: 'NL', name: 'Netherlands', flag: '🇳🇱' },
+ { code: 'NC', name: 'New Caledonia', flag: '🇳🇨' },
+ { code: 'NZ', name: 'New Zealand', flag: '🇳🇿' },
+ { code: 'NI', name: 'Nicaragua', flag: '🇳🇮' },
+ { code: 'NE', name: 'Niger', flag: '🇳🇪' },
+ { code: 'NG', name: 'Nigeria', flag: '🇳🇬' },
+ { code: 'NU', name: 'Niue', flag: '🇳🇺' },
+ { code: 'NF', name: 'Norfolk Island', flag: '🇳🇫' },
+ { code: 'KP', name: 'North Korea', flag: '🇰🇵' },
+ { code: 'MK', name: 'North Macedonia', flag: '🇲🇰' },
+ { code: 'MP', name: 'Northern Mariana Islands', flag: '🇲🇵' },
+ { code: 'NO', name: 'Norway', flag: '🇳🇴' },
+ { code: 'OM', name: 'Oman', flag: '🇴🇲' },
+ { code: 'PK', name: 'Pakistan', flag: '🇵🇰' },
+ { code: 'PW', name: 'Palau', flag: '🇵🇼' },
+ { code: 'PS', name: 'Palestine', flag: '🇵🇸' },
+ { code: 'PA', name: 'Panama', flag: '🇵🇦' },
+ { code: 'PG', name: 'Papua New Guinea', flag: '🇵🇬' },
+ { code: 'PY', name: 'Paraguay', flag: '🇵🇾' },
+ { code: 'PE', name: 'Peru', flag: '🇵🇪' },
+ { code: 'PH', name: 'Philippines', flag: '🇵🇭' },
+ { code: 'PN', name: 'Pitcairn Islands', flag: '🇵🇳' },
+ { code: 'PL', name: 'Poland', flag: '🇵🇱' },
+ { code: 'PT', name: 'Portugal', flag: '🇵🇹' },
+ { code: 'PR', name: 'Puerto Rico', flag: '🇵🇷' },
+ { code: 'QA', name: 'Qatar', flag: '🇶🇦' },
+ { code: 'RE', name: 'Réunion', flag: '🇷🇪' },
+ { code: 'RO', name: 'Romania', flag: '🇷🇴' },
+ { code: 'RU', name: 'Russia', flag: '🇷🇺' },
+ { code: 'RW', name: 'Rwanda', flag: '🇷🇼' },
+ { code: 'BL', name: 'Saint Barthélemy', flag: '🇧🇱' },
+ { code: 'SH', name: 'Saint Helena', flag: '🇸🇭' },
+ { code: 'KN', name: 'Saint Kitts and Nevis', flag: '🇰🇳' },
+ { code: 'LC', name: 'Saint Lucia', flag: '🇱🇨' },
+ { code: 'MF', name: 'Saint Martin', flag: '🇲🇫' },
+ { code: 'PM', name: 'Saint Pierre and Miquelon', flag: '🇵🇲' },
+ { code: 'VC', name: 'Saint Vincent and the Grenadines', flag: '🇻🇨' },
+ { code: 'WS', name: 'Samoa', flag: '🇼🇸' },
+ { code: 'SM', name: 'San Marino', flag: '🇸🇲' },
+ { code: 'ST', name: 'São Tomé and Príncipe', flag: '🇸🇹' },
+ { code: 'SA', name: 'Saudi Arabia', flag: '🇸🇦' },
+ { code: 'SN', name: 'Senegal', flag: '🇸🇳' },
+ { code: 'RS', name: 'Serbia', flag: '🇷🇸' },
+ { code: 'SC', name: 'Seychelles', flag: '🇸🇨' },
+ { code: 'SL', name: 'Sierra Leone', flag: '🇸🇱' },
+ { code: 'SG', name: 'Singapore', flag: '🇸🇬' },
+ { code: 'SX', name: 'Sint Maarten', flag: '🇸🇽' },
+ { code: 'SK', name: 'Slovakia', flag: '🇸🇰' },
+ { code: 'SI', name: 'Slovenia', flag: '🇸🇮' },
+ { code: 'SB', name: 'Solomon Islands', flag: '🇸🇧' },
+ { code: 'SO', name: 'Somalia', flag: '🇸🇴' },
+ { code: 'ZA', name: 'South Africa', flag: '🇿🇦' },
+ { code: 'GS', name: 'South Georgia and the South Sandwich Islands', flag: '🇬🇸' },
+ { code: 'KR', name: 'South Korea', flag: '🇰🇷' },
+ { code: 'SS', name: 'South Sudan', flag: '🇸🇸' },
+ { code: 'ES', name: 'Spain', flag: '🇪🇸' },
+ { code: 'LK', name: 'Sri Lanka', flag: '🇱🇰' },
+ { code: 'SD', name: 'Sudan', flag: '🇸🇩' },
+ { code: 'SR', name: 'Suriname', flag: '🇸🇷' },
+ { code: 'SJ', name: 'Svalbard and Jan Mayen', flag: '🇸🇯' },
+ { code: 'SE', name: 'Sweden', flag: '🇸🇪' },
+ { code: 'CH', name: 'Switzerland', flag: '🇨🇭' },
+ { code: 'SY', name: 'Syria', flag: '🇸🇾' },
+ { code: 'TW', name: 'Taiwan', flag: '🇹🇼' },
+ { code: 'TJ', name: 'Tajikistan', flag: '🇹🇯' },
+ { code: 'TZ', name: 'Tanzania', flag: '🇹🇿' },
+ { code: 'TH', name: 'Thailand', flag: '🇹🇭' },
+ { code: 'TL', name: 'Timor-Leste', flag: '🇹🇱' },
+ { code: 'TG', name: 'Togo', flag: '🇹🇬' },
+ { code: 'TK', name: 'Tokelau', flag: '🇹🇰' },
+ { code: 'TO', name: 'Tonga', flag: '🇹🇴' },
+ { code: 'TT', name: 'Trinidad and Tobago', flag: '🇹🇹' },
+ { code: 'TN', name: 'Tunisia', flag: '🇹🇳' },
+ { code: 'TR', name: 'Turkey', flag: '🇹🇷' },
+ { code: 'TM', name: 'Turkmenistan', flag: '🇹🇲' },
+ { code: 'TC', name: 'Turks and Caicos Islands', flag: '🇹🇨' },
+ { code: 'TV', name: 'Tuvalu', flag: '🇹🇻' },
+ { code: 'UG', name: 'Uganda', flag: '🇺🇬' },
+ { code: 'UA', name: 'Ukraine', flag: '🇺🇦' },
+ { code: 'AE', name: 'United Arab Emirates', flag: '🇦🇪' },
+ { code: 'GB', name: 'United Kingdom', flag: '🇬🇧' },
+ { code: 'US', name: 'United States', flag: '🇺🇸' },
+ { code: 'UM', name: 'United States Minor Outlying Islands', flag: '🇺🇲' },
+ { code: 'VI', name: 'United States Virgin Islands', flag: '🇻🇮' },
+ { code: 'UY', name: 'Uruguay', flag: '🇺🇾' },
+ { code: 'UZ', name: 'Uzbekistan', flag: '🇺🇿' },
+ { code: 'VU', name: 'Vanuatu', flag: '🇻🇺' },
+ { code: 'VA', name: 'Vatican City', flag: '🇻🇦' },
+ { code: 'VE', name: 'Venezuela', flag: '🇻🇪' },
+ { code: 'VN', name: 'Vietnam', flag: '🇻🇳' },
+ { code: 'WF', name: 'Wallis and Futuna', flag: '🇼🇫' },
+ { code: 'EH', name: 'Western Sahara', flag: '🇪🇭' },
+ { code: 'YE', name: 'Yemen', flag: '🇾🇪' },
+ { code: 'ZM', name: 'Zambia', flag: '🇿🇲' },
+ { code: 'ZW', name: 'Zimbabwe', flag: '🇿🇼' }
+] as const;
+
+export type CountryCode = (typeof countries)[number]['code'];
+
+export function getCountryByCode(code: string) {
+ return countries.find((c) => c.code === code);
+}
+
+export function getCountryName(code: string) {
+ return getCountryByCode(code)?.name || code;
+}
+
+export function getCountryFlag(code: string) {
+ return getCountryByCode(code)?.flag || '';
+}
diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts
new file mode 100644
index 0000000..5532ada
--- /dev/null
+++ b/src/lib/utils/index.ts
@@ -0,0 +1,59 @@
+import { type ClassValue, clsx } from 'clsx';
+import { twMerge } from 'tailwind-merge';
+
+/**
+ * Merge Tailwind CSS classes with proper precedence
+ */
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
+
+/**
+ * Format a date to a human-readable string
+ */
+export function formatDate(date: Date | string, options?: Intl.DateTimeFormatOptions): string {
+ const d = typeof date === 'string' ? new Date(date) : date;
+ return d.toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ ...options
+ });
+}
+
+/**
+ * Format currency amount
+ */
+export function formatCurrency(amount: number, currency = 'EUR'): string {
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency
+ }).format(amount);
+}
+
+/**
+ * Generate a random ID
+ */
+export function generateId(prefix = ''): string {
+ const id = Math.random().toString(36).substring(2, 9);
+ return prefix ? `${prefix}_${id}` : id;
+}
+
+/**
+ * Debounce a function
+ */
+export function debounce unknown>(
+ fn: T,
+ delay: number
+): (...args: Parameters) => void {
+ let timeoutId: ReturnType;
+ return (...args: Parameters) => {
+ clearTimeout(timeoutId);
+ timeoutId = setTimeout(() => fn(...args), delay);
+ };
+}
+
+/**
+ * Check if running in browser
+ */
+export const isBrowser = typeof window !== 'undefined';
diff --git a/src/lib/utils/phoneCountries.ts b/src/lib/utils/phoneCountries.ts
new file mode 100644
index 0000000..a260690
--- /dev/null
+++ b/src/lib/utils/phoneCountries.ts
@@ -0,0 +1,255 @@
+// Complete phone country codes with dial codes
+// Prioritized: US, Monaco, France first, then common countries, then alphabetical
+export const phoneCountries = [
+ // Priority countries for Monaco USA
+ { code: 'US', name: 'United States', dialCode: '+1' },
+ { code: 'MC', name: 'Monaco', dialCode: '+377' },
+ { code: 'FR', name: 'France', dialCode: '+33' },
+ { code: 'GB', name: 'United Kingdom', dialCode: '+44' },
+ { code: 'IT', name: 'Italy', dialCode: '+39' },
+ { code: 'CH', name: 'Switzerland', dialCode: '+41' },
+ { code: 'DE', name: 'Germany', dialCode: '+49' },
+ { code: 'ES', name: 'Spain', dialCode: '+34' },
+ // All other countries alphabetically
+ { code: 'AF', name: 'Afghanistan', dialCode: '+93' },
+ { code: 'AL', name: 'Albania', dialCode: '+355' },
+ { code: 'DZ', name: 'Algeria', dialCode: '+213' },
+ { code: 'AS', name: 'American Samoa', dialCode: '+1684' },
+ { code: 'AD', name: 'Andorra', dialCode: '+376' },
+ { code: 'AO', name: 'Angola', dialCode: '+244' },
+ { code: 'AI', name: 'Anguilla', dialCode: '+1264' },
+ { code: 'AG', name: 'Antigua and Barbuda', dialCode: '+1268' },
+ { code: 'AR', name: 'Argentina', dialCode: '+54' },
+ { code: 'AM', name: 'Armenia', dialCode: '+374' },
+ { code: 'AW', name: 'Aruba', dialCode: '+297' },
+ { code: 'AU', name: 'Australia', dialCode: '+61' },
+ { code: 'AT', name: 'Austria', dialCode: '+43' },
+ { code: 'AZ', name: 'Azerbaijan', dialCode: '+994' },
+ { code: 'BS', name: 'Bahamas', dialCode: '+1242' },
+ { code: 'BH', name: 'Bahrain', dialCode: '+973' },
+ { code: 'BD', name: 'Bangladesh', dialCode: '+880' },
+ { code: 'BB', name: 'Barbados', dialCode: '+1246' },
+ { code: 'BY', name: 'Belarus', dialCode: '+375' },
+ { code: 'BE', name: 'Belgium', dialCode: '+32' },
+ { code: 'BZ', name: 'Belize', dialCode: '+501' },
+ { code: 'BJ', name: 'Benin', dialCode: '+229' },
+ { code: 'BM', name: 'Bermuda', dialCode: '+1441' },
+ { code: 'BT', name: 'Bhutan', dialCode: '+975' },
+ { code: 'BO', name: 'Bolivia', dialCode: '+591' },
+ { code: 'BA', name: 'Bosnia and Herzegovina', dialCode: '+387' },
+ { code: 'BW', name: 'Botswana', dialCode: '+267' },
+ { code: 'BR', name: 'Brazil', dialCode: '+55' },
+ { code: 'IO', name: 'British Indian Ocean Territory', dialCode: '+246' },
+ { code: 'VG', name: 'British Virgin Islands', dialCode: '+1284' },
+ { code: 'BN', name: 'Brunei', dialCode: '+673' },
+ { code: 'BG', name: 'Bulgaria', dialCode: '+359' },
+ { code: 'BF', name: 'Burkina Faso', dialCode: '+226' },
+ { code: 'BI', name: 'Burundi', dialCode: '+257' },
+ { code: 'CV', name: 'Cabo Verde', dialCode: '+238' },
+ { code: 'KH', name: 'Cambodia', dialCode: '+855' },
+ { code: 'CM', name: 'Cameroon', dialCode: '+237' },
+ { code: 'CA', name: 'Canada', dialCode: '+1' },
+ { code: 'KY', name: 'Cayman Islands', dialCode: '+1345' },
+ { code: 'CF', name: 'Central African Republic', dialCode: '+236' },
+ { code: 'TD', name: 'Chad', dialCode: '+235' },
+ { code: 'CL', name: 'Chile', dialCode: '+56' },
+ { code: 'CN', name: 'China', dialCode: '+86' },
+ { code: 'CX', name: 'Christmas Island', dialCode: '+61' },
+ { code: 'CC', name: 'Cocos (Keeling) Islands', dialCode: '+61' },
+ { code: 'CO', name: 'Colombia', dialCode: '+57' },
+ { code: 'KM', name: 'Comoros', dialCode: '+269' },
+ { code: 'CG', name: 'Congo', dialCode: '+242' },
+ { code: 'CD', name: 'Congo (DRC)', dialCode: '+243' },
+ { code: 'CK', name: 'Cook Islands', dialCode: '+682' },
+ { code: 'CR', name: 'Costa Rica', dialCode: '+506' },
+ { code: 'CI', name: "Côte d'Ivoire", dialCode: '+225' },
+ { code: 'HR', name: 'Croatia', dialCode: '+385' },
+ { code: 'CU', name: 'Cuba', dialCode: '+53' },
+ { code: 'CW', name: 'Curaçao', dialCode: '+599' },
+ { code: 'CY', name: 'Cyprus', dialCode: '+357' },
+ { code: 'CZ', name: 'Czech Republic', dialCode: '+420' },
+ { code: 'DK', name: 'Denmark', dialCode: '+45' },
+ { code: 'DJ', name: 'Djibouti', dialCode: '+253' },
+ { code: 'DM', name: 'Dominica', dialCode: '+1767' },
+ { code: 'DO', name: 'Dominican Republic', dialCode: '+1' },
+ { code: 'EC', name: 'Ecuador', dialCode: '+593' },
+ { code: 'EG', name: 'Egypt', dialCode: '+20' },
+ { code: 'SV', name: 'El Salvador', dialCode: '+503' },
+ { code: 'GQ', name: 'Equatorial Guinea', dialCode: '+240' },
+ { code: 'ER', name: 'Eritrea', dialCode: '+291' },
+ { code: 'EE', name: 'Estonia', dialCode: '+372' },
+ { code: 'SZ', name: 'Eswatini', dialCode: '+268' },
+ { code: 'ET', name: 'Ethiopia', dialCode: '+251' },
+ { code: 'FK', name: 'Falkland Islands', dialCode: '+500' },
+ { code: 'FO', name: 'Faroe Islands', dialCode: '+298' },
+ { code: 'FJ', name: 'Fiji', dialCode: '+679' },
+ { code: 'FI', name: 'Finland', dialCode: '+358' },
+ { code: 'GF', name: 'French Guiana', dialCode: '+594' },
+ { code: 'PF', name: 'French Polynesia', dialCode: '+689' },
+ { code: 'GA', name: 'Gabon', dialCode: '+241' },
+ { code: 'GM', name: 'Gambia', dialCode: '+220' },
+ { code: 'GE', name: 'Georgia', dialCode: '+995' },
+ { code: 'GH', name: 'Ghana', dialCode: '+233' },
+ { code: 'GI', name: 'Gibraltar', dialCode: '+350' },
+ { code: 'GR', name: 'Greece', dialCode: '+30' },
+ { code: 'GL', name: 'Greenland', dialCode: '+299' },
+ { code: 'GD', name: 'Grenada', dialCode: '+1473' },
+ { code: 'GP', name: 'Guadeloupe', dialCode: '+590' },
+ { code: 'GU', name: 'Guam', dialCode: '+1671' },
+ { code: 'GT', name: 'Guatemala', dialCode: '+502' },
+ { code: 'GG', name: 'Guernsey', dialCode: '+44' },
+ { code: 'GN', name: 'Guinea', dialCode: '+224' },
+ { code: 'GW', name: 'Guinea-Bissau', dialCode: '+245' },
+ { code: 'GY', name: 'Guyana', dialCode: '+592' },
+ { code: 'HT', name: 'Haiti', dialCode: '+509' },
+ { code: 'HN', name: 'Honduras', dialCode: '+504' },
+ { code: 'HK', name: 'Hong Kong', dialCode: '+852' },
+ { code: 'HU', name: 'Hungary', dialCode: '+36' },
+ { code: 'IS', name: 'Iceland', dialCode: '+354' },
+ { code: 'IN', name: 'India', dialCode: '+91' },
+ { code: 'ID', name: 'Indonesia', dialCode: '+62' },
+ { code: 'IR', name: 'Iran', dialCode: '+98' },
+ { code: 'IQ', name: 'Iraq', dialCode: '+964' },
+ { code: 'IE', name: 'Ireland', dialCode: '+353' },
+ { code: 'IM', name: 'Isle of Man', dialCode: '+44' },
+ { code: 'IL', name: 'Israel', dialCode: '+972' },
+ { code: 'JM', name: 'Jamaica', dialCode: '+1' },
+ { code: 'JP', name: 'Japan', dialCode: '+81' },
+ { code: 'JE', name: 'Jersey', dialCode: '+44' },
+ { code: 'JO', name: 'Jordan', dialCode: '+962' },
+ { code: 'KZ', name: 'Kazakhstan', dialCode: '+7' },
+ { code: 'KE', name: 'Kenya', dialCode: '+254' },
+ { code: 'KI', name: 'Kiribati', dialCode: '+686' },
+ { code: 'KW', name: 'Kuwait', dialCode: '+965' },
+ { code: 'KG', name: 'Kyrgyzstan', dialCode: '+996' },
+ { code: 'LA', name: 'Laos', dialCode: '+856' },
+ { code: 'LV', name: 'Latvia', dialCode: '+371' },
+ { code: 'LB', name: 'Lebanon', dialCode: '+961' },
+ { code: 'LS', name: 'Lesotho', dialCode: '+266' },
+ { code: 'LR', name: 'Liberia', dialCode: '+231' },
+ { code: 'LY', name: 'Libya', dialCode: '+218' },
+ { code: 'LI', name: 'Liechtenstein', dialCode: '+423' },
+ { code: 'LT', name: 'Lithuania', dialCode: '+370' },
+ { code: 'LU', name: 'Luxembourg', dialCode: '+352' },
+ { code: 'MO', name: 'Macao', dialCode: '+853' },
+ { code: 'MG', name: 'Madagascar', dialCode: '+261' },
+ { code: 'MW', name: 'Malawi', dialCode: '+265' },
+ { code: 'MY', name: 'Malaysia', dialCode: '+60' },
+ { code: 'MV', name: 'Maldives', dialCode: '+960' },
+ { code: 'ML', name: 'Mali', dialCode: '+223' },
+ { code: 'MT', name: 'Malta', dialCode: '+356' },
+ { code: 'MH', name: 'Marshall Islands', dialCode: '+692' },
+ { code: 'MQ', name: 'Martinique', dialCode: '+596' },
+ { code: 'MR', name: 'Mauritania', dialCode: '+222' },
+ { code: 'MU', name: 'Mauritius', dialCode: '+230' },
+ { code: 'YT', name: 'Mayotte', dialCode: '+262' },
+ { code: 'MX', name: 'Mexico', dialCode: '+52' },
+ { code: 'FM', name: 'Micronesia', dialCode: '+691' },
+ { code: 'MD', name: 'Moldova', dialCode: '+373' },
+ { code: 'MN', name: 'Mongolia', dialCode: '+976' },
+ { code: 'ME', name: 'Montenegro', dialCode: '+382' },
+ { code: 'MS', name: 'Montserrat', dialCode: '+1664' },
+ { code: 'MA', name: 'Morocco', dialCode: '+212' },
+ { code: 'MZ', name: 'Mozambique', dialCode: '+258' },
+ { code: 'MM', name: 'Myanmar', dialCode: '+95' },
+ { code: 'NA', name: 'Namibia', dialCode: '+264' },
+ { code: 'NR', name: 'Nauru', dialCode: '+674' },
+ { code: 'NP', name: 'Nepal', dialCode: '+977' },
+ { code: 'NL', name: 'Netherlands', dialCode: '+31' },
+ { code: 'NC', name: 'New Caledonia', dialCode: '+687' },
+ { code: 'NZ', name: 'New Zealand', dialCode: '+64' },
+ { code: 'NI', name: 'Nicaragua', dialCode: '+505' },
+ { code: 'NE', name: 'Niger', dialCode: '+227' },
+ { code: 'NG', name: 'Nigeria', dialCode: '+234' },
+ { code: 'NU', name: 'Niue', dialCode: '+683' },
+ { code: 'NF', name: 'Norfolk Island', dialCode: '+672' },
+ { code: 'KP', name: 'North Korea', dialCode: '+850' },
+ { code: 'MK', name: 'North Macedonia', dialCode: '+389' },
+ { code: 'MP', name: 'Northern Mariana Islands', dialCode: '+1670' },
+ { code: 'NO', name: 'Norway', dialCode: '+47' },
+ { code: 'OM', name: 'Oman', dialCode: '+968' },
+ { code: 'PK', name: 'Pakistan', dialCode: '+92' },
+ { code: 'PW', name: 'Palau', dialCode: '+680' },
+ { code: 'PS', name: 'Palestine', dialCode: '+970' },
+ { code: 'PA', name: 'Panama', dialCode: '+507' },
+ { code: 'PG', name: 'Papua New Guinea', dialCode: '+675' },
+ { code: 'PY', name: 'Paraguay', dialCode: '+595' },
+ { code: 'PE', name: 'Peru', dialCode: '+51' },
+ { code: 'PH', name: 'Philippines', dialCode: '+63' },
+ { code: 'PL', name: 'Poland', dialCode: '+48' },
+ { code: 'PT', name: 'Portugal', dialCode: '+351' },
+ { code: 'PR', name: 'Puerto Rico', dialCode: '+1' },
+ { code: 'QA', name: 'Qatar', dialCode: '+974' },
+ { code: 'RE', name: 'Réunion', dialCode: '+262' },
+ { code: 'RO', name: 'Romania', dialCode: '+40' },
+ { code: 'RU', name: 'Russia', dialCode: '+7' },
+ { code: 'RW', name: 'Rwanda', dialCode: '+250' },
+ { code: 'BL', name: 'Saint Barthélemy', dialCode: '+590' },
+ { code: 'SH', name: 'Saint Helena', dialCode: '+290' },
+ { code: 'KN', name: 'Saint Kitts and Nevis', dialCode: '+1869' },
+ { code: 'LC', name: 'Saint Lucia', dialCode: '+1758' },
+ { code: 'MF', name: 'Saint Martin', dialCode: '+590' },
+ { code: 'PM', name: 'Saint Pierre and Miquelon', dialCode: '+508' },
+ { code: 'VC', name: 'Saint Vincent and the Grenadines', dialCode: '+1784' },
+ { code: 'WS', name: 'Samoa', dialCode: '+685' },
+ { code: 'SM', name: 'San Marino', dialCode: '+378' },
+ { code: 'ST', name: 'São Tomé and Príncipe', dialCode: '+239' },
+ { code: 'SA', name: 'Saudi Arabia', dialCode: '+966' },
+ { code: 'SN', name: 'Senegal', dialCode: '+221' },
+ { code: 'RS', name: 'Serbia', dialCode: '+381' },
+ { code: 'SC', name: 'Seychelles', dialCode: '+248' },
+ { code: 'SL', name: 'Sierra Leone', dialCode: '+232' },
+ { code: 'SG', name: 'Singapore', dialCode: '+65' },
+ { code: 'SX', name: 'Sint Maarten', dialCode: '+1721' },
+ { code: 'SK', name: 'Slovakia', dialCode: '+421' },
+ { code: 'SI', name: 'Slovenia', dialCode: '+386' },
+ { code: 'SB', name: 'Solomon Islands', dialCode: '+677' },
+ { code: 'SO', name: 'Somalia', dialCode: '+252' },
+ { code: 'ZA', name: 'South Africa', dialCode: '+27' },
+ { code: 'KR', name: 'South Korea', dialCode: '+82' },
+ { code: 'SS', name: 'South Sudan', dialCode: '+211' },
+ { code: 'LK', name: 'Sri Lanka', dialCode: '+94' },
+ { code: 'SD', name: 'Sudan', dialCode: '+249' },
+ { code: 'SR', name: 'Suriname', dialCode: '+597' },
+ { code: 'SJ', name: 'Svalbard and Jan Mayen', dialCode: '+47' },
+ { code: 'SE', name: 'Sweden', dialCode: '+46' },
+ { code: 'SY', name: 'Syria', dialCode: '+963' },
+ { code: 'TW', name: 'Taiwan', dialCode: '+886' },
+ { code: 'TJ', name: 'Tajikistan', dialCode: '+992' },
+ { code: 'TZ', name: 'Tanzania', dialCode: '+255' },
+ { code: 'TH', name: 'Thailand', dialCode: '+66' },
+ { code: 'TL', name: 'Timor-Leste', dialCode: '+670' },
+ { code: 'TG', name: 'Togo', dialCode: '+228' },
+ { code: 'TK', name: 'Tokelau', dialCode: '+690' },
+ { code: 'TO', name: 'Tonga', dialCode: '+676' },
+ { code: 'TT', name: 'Trinidad and Tobago', dialCode: '+1' },
+ { code: 'TN', name: 'Tunisia', dialCode: '+216' },
+ { code: 'TR', name: 'Turkey', dialCode: '+90' },
+ { code: 'TM', name: 'Turkmenistan', dialCode: '+993' },
+ { code: 'TC', name: 'Turks and Caicos Islands', dialCode: '+1649' },
+ { code: 'TV', name: 'Tuvalu', dialCode: '+688' },
+ { code: 'UG', name: 'Uganda', dialCode: '+256' },
+ { code: 'UA', name: 'Ukraine', dialCode: '+380' },
+ { code: 'AE', name: 'United Arab Emirates', dialCode: '+971' },
+ { code: 'UY', name: 'Uruguay', dialCode: '+598' },
+ { code: 'VI', name: 'United States Virgin Islands', dialCode: '+1340' },
+ { code: 'UZ', name: 'Uzbekistan', dialCode: '+998' },
+ { code: 'VU', name: 'Vanuatu', dialCode: '+678' },
+ { code: 'VA', name: 'Vatican City', dialCode: '+39' },
+ { code: 'VE', name: 'Venezuela', dialCode: '+58' },
+ { code: 'VN', name: 'Vietnam', dialCode: '+84' },
+ { code: 'WF', name: 'Wallis and Futuna', dialCode: '+681' },
+ { code: 'YE', name: 'Yemen', dialCode: '+967' },
+ { code: 'ZM', name: 'Zambia', dialCode: '+260' },
+ { code: 'ZW', name: 'Zimbabwe', dialCode: '+263' }
+] as const;
+
+export type PhoneCountryCode = (typeof phoneCountries)[number]['code'];
+
+export function getPhoneCountry(code: string) {
+ return phoneCountries.find((c) => c.code === code);
+}
+
+export function getDialCode(code: string) {
+ return getPhoneCountry(code)?.dialCode || '';
+}
diff --git a/src/routes/(app)/+error.svelte b/src/routes/(app)/+error.svelte
new file mode 100644
index 0000000..d5fc1a5
--- /dev/null
+++ b/src/routes/(app)/+error.svelte
@@ -0,0 +1,179 @@
+
+
+
+ {status} - {errorInfo.title} | Monaco USA
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Error {status}
+
+
+
+
{errorInfo.title}
+
+
+
{errorInfo.message}
+
+
+
+
+
+
+
+ Dashboard
+
+
+
history.back()}>
+
+
+
+ Go Back
+
+
+ {#if status === 401}
+
+
+
+
+ Sign In
+
+ {/if}
+
+
+
+
+
+ Need assistance?
+
+ Contact support
+
+
+
+
+ {#if $page.error?.message && import.meta.env.DEV}
+
+
+ Technical details
+
+ {$page.error.message}
+
+ {/if}
+
diff --git a/src/routes/(app)/+layout.server.ts b/src/routes/(app)/+layout.server.ts
new file mode 100644
index 0000000..2e97741
--- /dev/null
+++ b/src/routes/(app)/+layout.server.ts
@@ -0,0 +1,27 @@
+import { redirect } from '@sveltejs/kit';
+import type { LayoutServerLoad } from './$types';
+
+export const load: LayoutServerLoad = async ({ locals, url }) => {
+ const { session, user, member } = await locals.safeGetSession();
+
+ // Require authentication for all app routes
+ if (!session) {
+ throw redirect(303, `/login?redirectTo=${encodeURIComponent(url.pathname)}`);
+ }
+
+ // Require member profile to exist
+ if (!member) {
+ // User is authenticated but has no member profile - unusual situation
+ await locals.supabase.auth.signOut();
+ throw redirect(303, '/login?error=no_profile');
+ }
+
+ // Check if user's email is verified
+ const emailVerified = user?.email_confirmed_at !== null && user?.email_confirmed_at !== undefined;
+
+ return {
+ session,
+ member,
+ emailVerified
+ };
+};
diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte
new file mode 100644
index 0000000..c453b4f
--- /dev/null
+++ b/src/routes/(app)/+layout.svelte
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if !data.emailVerified}
+
+ {/if}
+
+
+
+
+ {@render children()}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/routes/(app)/admin/+layout.server.ts b/src/routes/(app)/admin/+layout.server.ts
new file mode 100644
index 0000000..0f15a57
--- /dev/null
+++ b/src/routes/(app)/admin/+layout.server.ts
@@ -0,0 +1,13 @@
+import { redirect } from '@sveltejs/kit';
+import type { LayoutServerLoad } from './$types';
+
+export const load: LayoutServerLoad = async ({ parent }) => {
+ const { member } = await parent();
+
+ // Only admins can access admin pages
+ if (member?.role !== 'admin') {
+ throw redirect(303, '/dashboard');
+ }
+
+ return {};
+};
diff --git a/src/routes/(app)/admin/dashboard/+page.server.ts b/src/routes/(app)/admin/dashboard/+page.server.ts
new file mode 100644
index 0000000..1e16518
--- /dev/null
+++ b/src/routes/(app)/admin/dashboard/+page.server.ts
@@ -0,0 +1,91 @@
+import type { PageServerLoad } from './$types';
+import { getRecentAuditLogs } from '$lib/server/audit';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ // Get all members with dues info for stats
+ const { data: members } = await locals.supabase
+ .from('members_with_dues')
+ .select('*');
+
+ // Get recent payments (this month and last month)
+ const now = new Date();
+ const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
+ const startOfLastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
+
+ const { data: recentPayments } = await locals.supabase
+ .from('dues_payments')
+ .select(`
+ *,
+ member:members(first_name, last_name, email)
+ `)
+ .gte('payment_date', startOfLastMonth.toISOString())
+ .order('payment_date', { ascending: false });
+
+ // Get upcoming events (next 30 days)
+ const thirtyDaysFromNow = new Date();
+ thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30);
+
+ const { data: upcomingEvents } = await locals.supabase
+ .from('events_with_counts')
+ .select('*')
+ .eq('status', 'published')
+ .gte('start_datetime', now.toISOString())
+ .lte('start_datetime', thirtyDaysFromNow.toISOString())
+ .order('start_datetime', { ascending: true })
+ .limit(5);
+
+ // Get recent audit logs
+ const { logs: auditLogs } = await getRecentAuditLogs(20);
+
+ // Calculate member stats
+ const memberStats = {
+ total: members?.length || 0,
+ byRole: {
+ admin: members?.filter(m => m.role === 'admin').length || 0,
+ board: members?.filter(m => m.role === 'board').length || 0,
+ member: members?.filter(m => m.role === 'member').length || 0
+ },
+ byDuesStatus: {
+ current: members?.filter(m => m.dues_status === 'current').length || 0,
+ due_soon: members?.filter(m => m.dues_status === 'due_soon').length || 0,
+ overdue: members?.filter(m => m.dues_status === 'overdue').length || 0,
+ never_paid: members?.filter(m => m.dues_status === 'never_paid').length || 0
+ }
+ };
+
+ // Calculate revenue stats
+ const thisMonthPayments = recentPayments?.filter(p =>
+ new Date(p.payment_date) >= startOfMonth
+ ) || [];
+ const lastMonthPayments = recentPayments?.filter(p =>
+ new Date(p.payment_date) >= startOfLastMonth &&
+ new Date(p.payment_date) < startOfMonth
+ ) || [];
+
+ const revenueStats = {
+ thisMonth: thisMonthPayments.reduce((sum, p) => sum + (p.amount || 0), 0),
+ lastMonth: lastMonthPayments.reduce((sum, p) => sum + (p.amount || 0), 0),
+ thisMonthCount: thisMonthPayments.length,
+ lastMonthCount: lastMonthPayments.length
+ };
+
+ // Get recent payments for display
+ const { data: latestPayments } = await locals.supabase
+ .from('dues_payments')
+ .select(`
+ *,
+ member:members(first_name, last_name, email),
+ recorder:members!dues_payments_recorded_by_fkey(first_name, last_name)
+ `)
+ .order('created_at', { ascending: false })
+ .limit(5);
+
+ return {
+ memberStats,
+ revenueStats,
+ upcomingEvents: upcomingEvents || [],
+ recentPayments: latestPayments || [],
+ auditLogs: auditLogs || [],
+ overdueMembers: members?.filter(m => m.dues_status === 'overdue').slice(0, 5) || []
+ };
+};
diff --git a/src/routes/(app)/admin/dashboard/+page.svelte b/src/routes/(app)/admin/dashboard/+page.svelte
new file mode 100644
index 0000000..3e1634e
--- /dev/null
+++ b/src/routes/(app)/admin/dashboard/+page.svelte
@@ -0,0 +1,295 @@
+
+
+
+ Admin Dashboard | Monaco USA
+
+
+
+
+
+
Admin Dashboard
+
Overview of Monaco USA portal activity and metrics
+
+
+
+
+
+
+
+
+
Total Members
+
{memberStats.total}
+
+
+
+
+
+
+ {memberStats.byRole.admin} Admin
+ {memberStats.byRole.board} Board
+ {memberStats.byRole.member} Members
+
+
+
+
+
+
+
+
Revenue This Month
+
{formatCurrency(revenueStats.thisMonth)}
+
+
+
+
+
+
+ {#if revenueUp}
+
+ +{revenueTrend}%
+ {:else}
+
+ {revenueTrend}%
+ {/if}
+ vs last month
+
+
+
+
+
+
+
+
Dues Status
+
{memberStats.byDuesStatus.current}
+
+
+
+
+ Current
+ {memberStats.byDuesStatus.due_soon} Due Soon
+ {memberStats.byDuesStatus.overdue} Overdue
+
+
+
+
+
+
+
+
Upcoming Events
+
{upcomingEvents.length}
+
+
+
+
+
+
Next 30 days
+
+
+
+
+
+
+
+
+
+ {#if recentPayments.length > 0}
+
+ {#each recentPayments as payment}
+
+
+
+ {payment.member?.first_name} {payment.member?.last_name}
+
+
{formatDate(payment.payment_date)}
+
+
{formatCurrency(payment.amount)}
+
+ {/each}
+
+ {:else}
+
No recent payments
+ {/if}
+
+
+
+
+
+
+ {#if upcomingEvents.length > 0}
+
+ {#each upcomingEvents as event}
+
+
+
{event.title}
+
+ {formatDateTime(event.start_datetime)}
+ {#if event.location}
+ | {event.location}
+ {/if}
+
+
+
+
{event.total_attendees}
+ {#if event.max_attendees}
+
/{event.max_attendees}
+ {/if}
+
attendees
+
+
+ {/each}
+
+ {:else}
+
No upcoming events
+ {/if}
+
+
+
+
+
+
+ {#if overdueMembers.length > 0}
+
+ {#each overdueMembers as member}
+
+
+
+ {member.first_name} {member.last_name}
+
+
{member.email}
+
+
+
+ {member.days_overdue} days
+
+
overdue
+
+
+ {/each}
+
+ {:else}
+
+
+
All members are current!
+
+ {/if}
+
+
+
+
+
+
+ {#if auditLogs.length > 0}
+
+ {#each auditLogs.slice(0, 8) as log}
+
+
+ {#if log.action.startsWith('member')}
+
+ {:else if log.action.startsWith('event')}
+
+ {:else if log.action.startsWith('payment')}
+
+ {:else if log.action.startsWith('document')}
+
+ {:else}
+
+ {/if}
+
+
+
+ {formatAuditAction(log.action)}
+ {#if log.details?.target_email}
+ ({log.details.target_email})
+ {/if}
+
+
+ {log.user_email || 'System'} · {formatDateTime(log.created_at)}
+
+
+
+ {/each}
+
+ {:else}
+
No recent activity
+ {/if}
+
+
+
diff --git a/src/routes/(app)/admin/email-templates/+page.server.ts b/src/routes/(app)/admin/email-templates/+page.server.ts
new file mode 100644
index 0000000..5535763
--- /dev/null
+++ b/src/routes/(app)/admin/email-templates/+page.server.ts
@@ -0,0 +1,83 @@
+import { fail, redirect } from '@sveltejs/kit';
+import type { PageServerLoad, Actions } from './$types';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ // Check admin access
+ const { data: { user } } = await locals.supabase.auth.getUser();
+ if (!user) throw redirect(303, '/login');
+
+ const { data: member } = await locals.supabase
+ .from('members')
+ .select('role')
+ .eq('id', user.id)
+ .single();
+
+ if (!member || !['admin', 'board'].includes(member.role)) {
+ throw redirect(303, '/dashboard');
+ }
+
+ // Load all email templates
+ const { data: templates, error } = await locals.supabase
+ .from('email_templates')
+ .select('*')
+ .order('category', { ascending: true })
+ .order('template_name', { ascending: true });
+
+ if (error) {
+ console.error('Error loading email templates:', error);
+ return { templates: [] };
+ }
+
+ return { templates: templates || [] };
+};
+
+export const actions: Actions = {
+ updateTemplate: async ({ request, locals }) => {
+ const { data: { user } } = await locals.supabase.auth.getUser();
+ if (!user) {
+ return fail(401, { error: 'Unauthorized' });
+ }
+
+ // Check admin access
+ const { data: member } = await locals.supabase
+ .from('members')
+ .select('role')
+ .eq('id', user.id)
+ .single();
+
+ if (!member || !['admin', 'board'].includes(member.role)) {
+ return fail(403, { error: 'Access denied' });
+ }
+
+ const formData = await request.formData();
+ const template_key = formData.get('template_key') as string;
+ const subject = formData.get('subject') as string;
+ const body_html = formData.get('body_html') as string;
+ const body_text = formData.get('body_text') as string;
+ const is_active = formData.get('is_active') === 'true';
+
+ if (!template_key) {
+ return fail(400, { error: 'Template key is required' });
+ }
+
+ // Update the template
+ const { error } = await locals.supabase
+ .from('email_templates')
+ .update({
+ subject,
+ body_html,
+ body_text,
+ is_active,
+ updated_at: new Date().toISOString(),
+ updated_by: user.id
+ })
+ .eq('template_key', template_key);
+
+ if (error) {
+ console.error('Error updating template:', error);
+ return fail(500, { error: 'Failed to update template' });
+ }
+
+ return { success: true, message: 'Template updated successfully' };
+ }
+};
diff --git a/src/routes/(app)/admin/email-templates/+page.svelte b/src/routes/(app)/admin/email-templates/+page.svelte
new file mode 100644
index 0000000..e0af0d4
--- /dev/null
+++ b/src/routes/(app)/admin/email-templates/+page.svelte
@@ -0,0 +1,566 @@
+
+
+
+ Email Templates | Monaco USA Admin
+
+
+
+
+
+
+
+
Email Templates
+
Edit the text content of email notifications sent by the system
+
+
+
+ {#if form?.success}
+
+
+ {form.message || 'Template updated successfully'}
+
+ {/if}
+
+ {#if form?.error}
+
+
+ {form.error}
+
+ {/if}
+
+
+
+
+
+
+ Category:
+
+ {#each categories as cat}
+ {cat === 'all' ? 'All Categories' : formatCategory(cat)}
+ {/each}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#each filteredTemplates as template}
+
+
+
+
+
+ {formatCategory(template.category)}
+
+ {#if !template.is_active}
+
+ Inactive
+
+ {/if}
+
+
{template.template_name}
+
{template.subject}
+
+
+
+
+ {template.template_key}
+ openEditor(template)}
+ >
+ Edit
+
+
+
+ {:else}
+
+ {/each}
+
+
+
+
+{#if editingTemplate}
+ { if (e.target === e.currentTarget) closeEditor(); }}
+ onkeydown={(e) => { if (e.key === 'Escape') closeEditor(); }}
+ role="dialog"
+ aria-modal="true"
+ tabindex="-1"
+ >
+
+
+
+
+
Edit: {editingTemplate.template_name}
+
{editingTemplate.template_key}
+
+
+
+
+
+
+
+
+
+
+{/if}
diff --git a/src/routes/(app)/admin/email-testing/+page.server.ts b/src/routes/(app)/admin/email-testing/+page.server.ts
new file mode 100644
index 0000000..46d760d
--- /dev/null
+++ b/src/routes/(app)/admin/email-testing/+page.server.ts
@@ -0,0 +1,425 @@
+import { fail } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+import { supabaseAdmin } from '$lib/server/supabase';
+import { sendEmail, sendTemplatedEmail, getSmtpConfig, wrapInMonacoTemplate } from '$lib/server/email';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || member.role !== 'admin') {
+ return {
+ templates: [],
+ recentLogs: [],
+ smtpConfigured: false
+ };
+ }
+
+ // Check if SMTP is configured
+ const smtpConfig = await getSmtpConfig();
+ const smtpConfigured = !!smtpConfig;
+
+ // Fetch all email templates
+ const { data: templates } = await supabaseAdmin
+ .from('email_templates')
+ .select('*')
+ .order('category', { ascending: true })
+ .order('name', { ascending: true });
+
+ // Fetch recent email logs
+ const { data: recentLogs } = await supabaseAdmin
+ .from('email_logs')
+ .select(`
+ *,
+ sender:members!email_logs_sent_by_fkey(first_name, last_name)
+ `)
+ .order('created_at', { ascending: false })
+ .limit(20);
+
+ // Group templates by category
+ const templatesByCategory: Record = {};
+ for (const template of templates || []) {
+ const category = template.category || 'other';
+ if (!templatesByCategory[category]) {
+ templatesByCategory[category] = [];
+ }
+ templatesByCategory[category].push(template);
+ }
+
+ return {
+ templates: templates || [],
+ templatesByCategory,
+ recentLogs: recentLogs || [],
+ smtpConfigured,
+ adminEmail: member.email
+ };
+};
+
+export const actions: Actions = {
+ /**
+ * Send a test email using a template
+ */
+ sendTestTemplate: async ({ request, locals, url }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || member.role !== 'admin') {
+ return fail(403, { error: 'Only admins can send test emails' });
+ }
+
+ const formData = await request.formData();
+ const templateKey = formData.get('template_key') as string;
+ const recipientEmail = formData.get('recipient_email') as string;
+
+ if (!templateKey || !recipientEmail) {
+ return fail(400, { error: 'Template and recipient email are required' });
+ }
+
+ // Validate email format
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(recipientEmail)) {
+ return fail(400, { error: 'Please enter a valid email address' });
+ }
+
+ // Build test variables based on template type
+ const testVariables = getTestVariables(templateKey, member, url.origin);
+
+ const result = await sendTemplatedEmail(templateKey, recipientEmail, testVariables, {
+ recipientId: member.id,
+ recipientName: `${member.first_name} ${member.last_name}`,
+ sentBy: member.id,
+ baseUrl: url.origin
+ });
+
+ if (!result.success) {
+ return fail(500, { error: result.error || 'Failed to send test email' });
+ }
+
+ return { success: `Test email sent successfully to ${recipientEmail}!` };
+ },
+
+ /**
+ * Send a custom test email
+ */
+ sendCustomEmail: async ({ request, locals, url }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || member.role !== 'admin') {
+ return fail(403, { error: 'Only admins can send test emails' });
+ }
+
+ const formData = await request.formData();
+ const recipientEmail = formData.get('recipient_email') as string;
+ const subject = formData.get('subject') as string;
+ const messageContent = formData.get('message') as string;
+
+ if (!recipientEmail || !subject || !messageContent) {
+ return fail(400, { error: 'Recipient, subject, and message are required' });
+ }
+
+ // Validate email format
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(recipientEmail)) {
+ return fail(400, { error: 'Please enter a valid email address' });
+ }
+
+ // Wrap the message in the Monaco template
+ const logoUrl = `${url.origin}/MONACOUSA-Flags_376x376.png`;
+ const html = wrapInMonacoTemplate({
+ title: subject,
+ content: `${messageContent.replace(/\n/g, ' ')}
`,
+ logoUrl
+ });
+
+ const result = await sendEmail({
+ to: recipientEmail,
+ subject,
+ html,
+ emailType: 'test_custom',
+ sentBy: member.id
+ });
+
+ if (!result.success) {
+ return fail(500, { error: result.error || 'Failed to send email' });
+ }
+
+ return { success: `Custom email sent successfully to ${recipientEmail}!` };
+ },
+
+ /**
+ * Test all notification types to a single recipient
+ */
+ sendAllTests: async ({ request, locals, url }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || member.role !== 'admin') {
+ return fail(403, { error: 'Only admins can send test emails' });
+ }
+
+ const formData = await request.formData();
+ const recipientEmail = formData.get('recipient_email') as string;
+
+ if (!recipientEmail) {
+ return fail(400, { error: 'Recipient email is required' });
+ }
+
+ // Get all active templates
+ const { data: templates } = await supabaseAdmin
+ .from('email_templates')
+ .select('template_key')
+ .eq('is_active', true);
+
+ if (!templates || templates.length === 0) {
+ return fail(400, { error: 'No active email templates found' });
+ }
+
+ let successCount = 0;
+ let failCount = 0;
+ const errors: string[] = [];
+
+ for (const template of templates) {
+ const testVariables = getTestVariables(template.template_key, member, url.origin);
+
+ const result = await sendTemplatedEmail(template.template_key, recipientEmail, testVariables, {
+ recipientId: member.id,
+ recipientName: `${member.first_name} ${member.last_name}`,
+ sentBy: member.id,
+ baseUrl: url.origin
+ });
+
+ if (result.success) {
+ successCount++;
+ } else {
+ failCount++;
+ errors.push(`${template.template_key}: ${result.error}`);
+ }
+
+ // Small delay between emails to avoid rate limiting
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ }
+
+ if (failCount === 0) {
+ return { success: `All ${successCount} test emails sent successfully!` };
+ } else if (successCount === 0) {
+ return fail(500, { error: `All emails failed to send. Errors: ${errors.join('; ')}` });
+ } else {
+ return {
+ success: `Sent ${successCount} emails, ${failCount} failed.`,
+ errors
+ };
+ }
+ },
+
+ /**
+ * Preview a template (returns HTML)
+ */
+ previewTemplate: async ({ request, locals, url }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || member.role !== 'admin') {
+ return fail(403, { error: 'Unauthorized' });
+ }
+
+ const formData = await request.formData();
+ const templateKey = formData.get('template_key') as string;
+
+ if (!templateKey) {
+ return fail(400, { error: 'Template key is required' });
+ }
+
+ // Fetch the template
+ const { data: template } = await supabaseAdmin
+ .from('email_templates')
+ .select('*')
+ .eq('template_key', templateKey)
+ .single();
+
+ if (!template) {
+ return fail(404, { error: 'Template not found' });
+ }
+
+ // Get test variables
+ const testVariables = getTestVariables(templateKey, member, url.origin);
+ const logoUrl = `${url.origin}/MONACOUSA-Flags_376x376.png`;
+
+ // Replace variables in the template
+ let html = template.body_html;
+ let subject = template.subject;
+
+ const allVariables: Record = {
+ logo_url: logoUrl,
+ site_url: url.origin,
+ ...testVariables
+ };
+
+ for (const [key, value] of Object.entries(allVariables)) {
+ const regex = new RegExp(`{{${key}}}`, 'g');
+ html = html.replace(regex, value);
+ subject = subject.replace(regex, value);
+ }
+
+ return {
+ preview: {
+ subject,
+ html,
+ templateName: template.name
+ }
+ };
+ }
+};
+
+/**
+ * Get test variables for different template types
+ */
+function getTestVariables(templateKey: string, member: any, baseUrl: string): Record {
+ const commonVars = {
+ first_name: member.first_name || 'Test',
+ last_name: member.last_name || 'User',
+ member_name: `${member.first_name || 'Test'} ${member.last_name || 'User'}`,
+ member_id: member.member_id || 'TEST-001',
+ email: member.email || 'test@example.com',
+ site_url: baseUrl,
+ portal_url: baseUrl,
+ logo_url: `${baseUrl}/MONACOUSA-Flags_376x376.png`
+ };
+
+ // Template-specific variables
+ switch (templateKey) {
+ case 'welcome':
+ return {
+ ...commonVars,
+ login_url: `${baseUrl}/login`
+ };
+
+ case 'payment_received':
+ return {
+ ...commonVars,
+ amount: '100.00',
+ payment_date: new Date().toLocaleDateString('en-US', {
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric'
+ }),
+ reference: 'TEST-REF-123',
+ due_date: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US', {
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric'
+ })
+ };
+
+ case 'dues_reminder_30':
+ case 'dues_reminder_7':
+ case 'dues_reminder_1':
+ const daysMap: Record = {
+ dues_reminder_30: '30',
+ dues_reminder_7: '7',
+ dues_reminder_1: '1'
+ };
+ return {
+ ...commonVars,
+ days_until_due: daysMap[templateKey] || '30',
+ due_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US', {
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric'
+ }),
+ amount_due: '100.00',
+ payment_url: `${baseUrl}/payments`,
+ bank_name: 'Monaco Bank',
+ iban: 'MC00 0000 0000 0000 0000 0000 000',
+ bic: 'MONACOXX',
+ payment_reference: `DUES-${member.member_id || 'TEST001'}`
+ };
+
+ case 'dues_overdue':
+ return {
+ ...commonVars,
+ days_overdue: '15',
+ due_date: new Date(Date.now() - 15 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US', {
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric'
+ }),
+ amount_due: '100.00',
+ payment_url: `${baseUrl}/payments`,
+ bank_name: 'Monaco Bank',
+ iban: 'MC00 0000 0000 0000 0000 0000 000',
+ bic: 'MONACOXX',
+ payment_reference: `DUES-${member.member_id || 'TEST001'}`
+ };
+
+ case 'dues_grace_warning':
+ return {
+ ...commonVars,
+ grace_days_remaining: '7',
+ grace_end_date: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US', {
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric'
+ }),
+ amount_due: '100.00',
+ payment_url: `${baseUrl}/payments`
+ };
+
+ case 'dues_inactive_notice':
+ return {
+ ...commonVars,
+ inactive_date: new Date().toLocaleDateString('en-US', {
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric'
+ }),
+ reactivation_url: `${baseUrl}/payments`
+ };
+
+ case 'event_invitation':
+ return {
+ ...commonVars,
+ event_title: 'Monaco USA Annual Gala',
+ event_date: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US', {
+ weekday: 'long',
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric'
+ }),
+ event_time: '7:00 PM',
+ event_location: 'Hotel de Paris, Monaco',
+ event_description: 'Join us for our annual celebration bringing together Americans living in Monaco.',
+ event_url: `${baseUrl}/events/test-event`,
+ rsvp_url: `${baseUrl}/events/test-event`
+ };
+
+ case 'event_reminder':
+ return {
+ ...commonVars,
+ event_title: 'Monaco USA Monthly Meetup',
+ event_date: new Date(Date.now() + 1 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US', {
+ weekday: 'long',
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric'
+ }),
+ event_time: '6:00 PM',
+ event_location: 'Stars n Bars, Monaco',
+ event_url: `${baseUrl}/events/test-event`
+ };
+
+ case 'waitlist_promotion':
+ return {
+ ...commonVars,
+ event_title: 'Exclusive Wine Tasting Event',
+ event_date: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US', {
+ weekday: 'long',
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric'
+ }),
+ event_time: '8:00 PM',
+ event_location: 'Cave Princesse, Monaco',
+ event_url: `${baseUrl}/events/test-event`,
+ confirm_url: `${baseUrl}/events/test-event`
+ };
+
+ default:
+ return commonVars;
+ }
+}
diff --git a/src/routes/(app)/admin/email-testing/+page.svelte b/src/routes/(app)/admin/email-testing/+page.svelte
new file mode 100644
index 0000000..3001952
--- /dev/null
+++ b/src/routes/(app)/admin/email-testing/+page.svelte
@@ -0,0 +1,561 @@
+
+
+
+ Email Testing | Monaco USA Admin
+
+
+
+
+
+
+
+ {#if !smtpConfigured}
+
+
+
+
+
SMTP Not Configured
+
+ Email sending is not configured. Please configure SMTP settings in the
+ Admin Settings
+ before testing emails.
+
+
+
+
+ {/if}
+
+
+ {#if form?.success}
+
+ {/if}
+
+ {#if form?.error}
+
+ {/if}
+
+
+
+ (activeTab = 'templates')}
+ class="px-4 py-2 text-sm font-medium transition-colors {activeTab === 'templates'
+ ? 'border-b-2 border-monaco-600 text-monaco-600'
+ : 'text-slate-600 hover:text-slate-900'}"
+ >
+
+
+ Email Templates
+
+
+ (activeTab = 'custom')}
+ class="px-4 py-2 text-sm font-medium transition-colors {activeTab === 'custom'
+ ? 'border-b-2 border-monaco-600 text-monaco-600'
+ : 'text-slate-600 hover:text-slate-900'}"
+ >
+
+
+ Custom Email
+
+
+ (activeTab = 'logs')}
+ class="px-4 py-2 text-sm font-medium transition-colors {activeTab === 'logs'
+ ? 'border-b-2 border-monaco-600 text-monaco-600'
+ : 'text-slate-600 hover:text-slate-900'}"
+ >
+
+
+ Recent Logs ({recentLogs.length})
+
+
+
+
+
+ {#if activeTab === 'templates'}
+
+
+
+ {#each Object.entries(templatesByCategory || {}) as [category, categoryTemplates]}
+
+
+
{getCategoryName(category)}
+
+
+ {#each categoryTemplates as template}
+
(selectedTemplate = template.template_key)}
+ onkeydown={(e) => e.key === 'Enter' && (selectedTemplate = template.template_key)}
+ role="button"
+ tabindex="0"
+ >
+
+
{template.name}
+
{template.subject}
+
+
+ {#if template.is_active}
+
+ Active
+
+ {:else}
+
+ Inactive
+
+ {/if}
+
+
+ {/each}
+
+
+ {/each}
+
+ {#if !templates || templates.length === 0}
+
+
+
No Email Templates
+
+ No email templates have been configured yet.
+
+
+ {/if}
+
+
+
+
+
+
Send Test Email
+
+
{
+ isLoading = true;
+ return async ({ update }) => {
+ isLoading = false;
+ await update();
+ await invalidateAll();
+ };
+ }}
+ class="space-y-4"
+ >
+
+ Recipient Email
+
+
+
+
+ Selected Template
+
+ Select a template...
+ {#each Object.entries(templatesByCategory || {}) as [category, categoryTemplates]}
+
+ {#each categoryTemplates as template}
+ {template.name}
+ {/each}
+
+ {/each}
+
+
+
+
+
+ {#if isLoading}
+
+ Sending...
+ {:else}
+
+ Send Test
+ {/if}
+
+
+
+
+
+ {#if selectedTemplate}
+
{
+ isLoadingPreview = true;
+ return async ({ result, update }) => {
+ isLoadingPreview = false;
+ if (result.type === 'success' && result.data?.preview) {
+ previewHtml = result.data.preview.html;
+ previewSubject = result.data.preview.subject;
+ showPreview = true;
+ }
+ await update();
+ };
+ }}
+ class="mt-4"
+ >
+
+
+ {#if isLoadingPreview}
+
+ Loading...
+ {:else}
+
+ Preview Template
+ {/if}
+
+
+ {/if}
+
+
+
+
Bulk Testing
+
{
+ if (!confirm(`This will send ALL active email templates to ${recipientEmail}. Continue?`)) {
+ return async () => {};
+ }
+ isLoading = true;
+ return async ({ update }) => {
+ isLoading = false;
+ await update();
+ await invalidateAll();
+ };
+ }}
+ >
+
+
+
+ Send All Templates
+
+
+
+ Sends all active templates to test the complete notification system.
+
+
+
+
+
+ {/if}
+
+
+ {#if activeTab === 'custom'}
+
+
+
Send Custom Test Email
+
+ Send a custom email using the Monaco USA branding template.
+
+
+
{
+ isLoading = true;
+ return async ({ update }) => {
+ isLoading = false;
+ await update();
+ await invalidateAll();
+ };
+ }}
+ class="space-y-4"
+ >
+
+ Recipient Email
+
+
+
+
+ Subject
+
+
+
+
+
Message
+
+
+ The message will be wrapped in the Monaco USA email template.
+
+
+
+
+ {#if isLoading}
+
+ Sending...
+ {:else}
+
+ Send Custom Email
+ {/if}
+
+
+
+
+ {/if}
+
+
+ {#if activeTab === 'logs'}
+
+
+
Recent Email Logs
+ invalidateAll()}
+ class="flex items-center gap-1 text-sm text-slate-600 hover:text-slate-900"
+ >
+
+ Refresh
+
+
+
+ {#if recentLogs.length === 0}
+
+
+
No Email Logs
+
No emails have been sent yet.
+
+ {:else}
+
+
+
+
+ Status
+ Recipient
+ Subject
+ Type
+ Sent
+ By
+
+
+
+ {#each recentLogs as log}
+ {@const statusBadge = getStatusBadge(log.status)}
+
+
+
+
+ {log.status}
+
+
+
+
+
{log.recipient_email}
+ {#if log.recipient_name}
+
{log.recipient_name}
+ {/if}
+
+
+
+
+ {log.subject}
+
+
+
+
+ {log.email_type || 'manual'}
+
+
+
+ {log.sent_at ? formatDate(log.sent_at) : formatDate(log.created_at)}
+
+
+ {#if log.sender}
+ {log.sender.first_name} {log.sender.last_name}
+ {:else}
+ System
+ {/if}
+
+
+ {#if log.status === 'failed' && log.error_message}
+
+
+
+ Error: {log.error_message}
+
+
+
+ {/if}
+ {/each}
+
+
+
+ {/if}
+
+ {/if}
+
+
+
+{#if showPreview}
+
+
+
+
+
Email Preview
+
{previewSubject}
+
+
(showPreview = false)}
+ class="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
+ >
+
+
+
+
+
+ {@html previewHtml}
+
+
+
+ (showPreview = false)}>
+ Close Preview
+
+
+
+
+{/if}
diff --git a/src/routes/(app)/admin/members/+page.server.ts b/src/routes/(app)/admin/members/+page.server.ts
new file mode 100644
index 0000000..a56b1ad
--- /dev/null
+++ b/src/routes/(app)/admin/members/+page.server.ts
@@ -0,0 +1,430 @@
+import { fail } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+import { supabaseAdmin } from '$lib/server/supabase';
+import { sendEmail } from '$lib/server/email';
+
+export const load: PageServerLoad = async ({ locals, url }) => {
+ const searchQuery = url.searchParams.get('search') || '';
+ const roleFilter = url.searchParams.get('role') || 'all';
+ const statusFilter = url.searchParams.get('status') || 'all';
+
+ // Load all members with dues info using admin client (bypasses RLS for admin page)
+ const { data: members } = await supabaseAdmin
+ .from('members_with_dues')
+ .select('*')
+ .order('created_at', { ascending: false });
+
+ // Filter members
+ let filteredMembers = members || [];
+
+ if (searchQuery) {
+ const lowerSearch = searchQuery.toLowerCase();
+ filteredMembers = filteredMembers.filter(
+ (m: any) =>
+ m.first_name?.toLowerCase().includes(lowerSearch) ||
+ m.last_name?.toLowerCase().includes(lowerSearch) ||
+ m.email?.toLowerCase().includes(lowerSearch) ||
+ m.member_id?.toLowerCase().includes(lowerSearch)
+ );
+ }
+
+ if (roleFilter !== 'all') {
+ filteredMembers = filteredMembers.filter((m: any) => m.role === roleFilter);
+ }
+
+ if (statusFilter !== 'all') {
+ filteredMembers = filteredMembers.filter((m: any) => m.status_name === statusFilter);
+ }
+
+ // Load membership statuses for dropdown
+ const { data: statuses } = await supabaseAdmin
+ .from('membership_statuses')
+ .select('*')
+ .order('sort_order', { ascending: true });
+
+ // Calculate stats
+ const stats = {
+ total: members?.length || 0,
+ admins: members?.filter((m: any) => m.role === 'admin').length || 0,
+ board: members?.filter((m: any) => m.role === 'board').length || 0,
+ members: members?.filter((m: any) => m.role === 'member').length || 0
+ };
+
+ return {
+ members: filteredMembers,
+ statuses: statuses || [],
+ stats,
+ filters: {
+ search: searchQuery,
+ role: roleFilter,
+ status: statusFilter
+ }
+ };
+};
+
+export const actions: Actions = {
+ updateRole: async ({ request, locals }) => {
+ const formData = await request.formData();
+ const memberId = formData.get('member_id') as string;
+ const newRole = formData.get('role') as string;
+
+ if (!memberId || !newRole) {
+ return fail(400, { error: 'Member ID and role are required' });
+ }
+
+ if (!['member', 'board', 'admin'].includes(newRole)) {
+ return fail(400, { error: 'Invalid role' });
+ }
+
+ const { error } = await locals.supabase
+ .from('members')
+ .update({ role: newRole, updated_at: new Date().toISOString() })
+ .eq('id', memberId);
+
+ if (error) {
+ console.error('Update role error:', error);
+ return fail(500, { error: 'Failed to update role' });
+ }
+
+ return { success: 'Role updated successfully!' };
+ },
+
+ updateStatus: async ({ request, locals }) => {
+ const formData = await request.formData();
+ const memberId = formData.get('member_id') as string;
+ const statusId = formData.get('status_id') as string;
+
+ if (!memberId || !statusId) {
+ return fail(400, { error: 'Member ID and status are required' });
+ }
+
+ const { error } = await locals.supabase
+ .from('members')
+ .update({
+ membership_status_id: statusId,
+ updated_at: new Date().toISOString()
+ })
+ .eq('id', memberId);
+
+ if (error) {
+ console.error('Update status error:', error);
+ return fail(500, { error: 'Failed to update status' });
+ }
+
+ return { success: 'Status updated successfully!' };
+ },
+
+ deleteMember: async ({ request, locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || member.role !== 'admin') {
+ return fail(403, { error: 'Only admins can delete members' });
+ }
+
+ const formData = await request.formData();
+ const memberId = formData.get('member_id') as string;
+
+ if (!memberId) {
+ return fail(400, { error: 'Member ID is required' });
+ }
+
+ // Prevent admin from deleting themselves
+ if (memberId === member.id) {
+ return fail(400, { error: 'You cannot delete your own account' });
+ }
+
+ // First handle related records that have foreign keys to members
+
+ // Reassign events created by this member to the current admin
+ await supabaseAdmin
+ .from('events')
+ .update({ created_by: member.id })
+ .eq('created_by', memberId);
+
+ // Reassign app_settings updated by this member to the current admin
+ await supabaseAdmin
+ .from('app_settings')
+ .update({ updated_by: member.id })
+ .eq('updated_by', memberId);
+
+ // Delete dues payments
+ await supabaseAdmin.from('dues_payments').delete().eq('member_id', memberId);
+
+ // Delete event RSVPs
+ await supabaseAdmin.from('event_rsvps').delete().eq('member_id', memberId);
+
+ // Delete email logs
+ await supabaseAdmin.from('email_logs').delete().eq('recipient_id', memberId);
+ await supabaseAdmin.from('email_logs').delete().eq('sent_by', memberId);
+
+ // Now delete from members table using admin client (bypasses RLS)
+ const { error } = await supabaseAdmin.from('members').delete().eq('id', memberId);
+
+ if (error) {
+ console.error('Delete member error:', error);
+ console.error('Error details:', JSON.stringify(error, null, 2));
+ return fail(500, { error: `Failed to delete member: ${error.message}` });
+ }
+
+ // Also delete the auth user using admin client
+ const { error: authError } = await supabaseAdmin.auth.admin.deleteUser(memberId);
+
+ if (authError) {
+ console.error('Delete auth user error:', authError);
+ // Member is already deleted, just log this
+ }
+
+ return { success: 'Member deleted successfully!' };
+ },
+
+ inviteMember: async ({ request, locals, url }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || member.role !== 'admin') {
+ return fail(403, { error: 'Only admins can invite members' });
+ }
+
+ const formData = await request.formData();
+ const email = (formData.get('email') as string)?.trim().toLowerCase();
+ const firstName = (formData.get('first_name') as string)?.trim() || '';
+ const lastName = (formData.get('last_name') as string)?.trim() || '';
+ const role = (formData.get('role') as string) || 'member';
+ const duesPaidDate = formData.get('dues_paid_date') as string;
+
+ if (!email) {
+ return fail(400, { error: 'Email is required' });
+ }
+
+ // Validate role
+ if (!['member', 'board', 'admin'].includes(role)) {
+ return fail(400, { error: 'Invalid role' });
+ }
+
+ // Validate email format
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(email)) {
+ return fail(400, { error: 'Please enter a valid email address' });
+ }
+
+ // Check if email already exists
+ const { data: existingMember } = await locals.supabase
+ .from('members')
+ .select('id')
+ .eq('email', email)
+ .single();
+
+ if (existingMember) {
+ return fail(400, { error: 'A member with this email already exists' });
+ }
+
+ // Get default status (pending)
+ const { data: defaultStatus } = await locals.supabase
+ .from('membership_statuses')
+ .select('id')
+ .eq('is_default', true)
+ .single();
+
+ // Get default membership type
+ const { data: defaultType } = await locals.supabase
+ .from('membership_types')
+ .select('id, annual_dues')
+ .eq('is_default', true)
+ .single();
+
+ // Create auth user with a temporary password using admin client (requires service_role)
+ // The user will reset their password when they first log in
+ const tempPassword = crypto.randomUUID();
+
+ const { data: authData, error: authError } = await supabaseAdmin.auth.admin.createUser({
+ email,
+ password: tempPassword,
+ email_confirm: true, // Auto-confirm the email since admin is inviting
+ user_metadata: {
+ first_name: firstName || 'New',
+ last_name: lastName || 'Member',
+ invited_by: member.id
+ }
+ });
+
+ if (authError) {
+ console.error('Auth user creation error:', authError);
+ if (authError.message.includes('already registered')) {
+ return fail(400, { error: 'This email is already registered' });
+ }
+ return fail(500, { error: 'Failed to create user account. Please try again.' });
+ }
+
+ if (!authData.user) {
+ return fail(500, { error: 'Failed to create user account' });
+ }
+
+ // Get active status if dues are paid
+ let statusId = defaultStatus?.id;
+ if (duesPaidDate) {
+ const { data: activeStatus } = await locals.supabase
+ .from('membership_statuses')
+ .select('id')
+ .eq('name', 'active')
+ .single();
+ if (activeStatus) {
+ statusId = activeStatus.id;
+ }
+ }
+
+ // Create member record
+ const { error: memberError } = await locals.supabase.from('members').insert({
+ id: authData.user.id,
+ first_name: firstName || 'New',
+ last_name: lastName || 'Member',
+ email: email,
+ phone: '',
+ date_of_birth: '1990-01-01', // Placeholder - member will update
+ address: 'TBD', // Placeholder
+ nationality: [],
+ role: role,
+ membership_status_id: statusId,
+ membership_type_id: defaultType?.id
+ });
+
+ if (memberError) {
+ console.error('Member creation error:', memberError);
+ // Clean up auth user using admin client
+ await supabaseAdmin.auth.admin.deleteUser(authData.user.id);
+ return fail(500, { error: 'Failed to create member record. Please try again.' });
+ }
+
+ // Create dues payment record if dues paid date is provided
+ // The dues_paid_date is the date when dues are NEXT due (not when paid)
+ // So we calculate payment_date as 1 year before the due date
+ if (duesPaidDate) {
+ const dueDate = new Date(duesPaidDate);
+ const paymentDate = new Date(dueDate);
+ paymentDate.setFullYear(paymentDate.getFullYear() - 1);
+
+ const { error: duesError } = await locals.supabase.from('dues_payments').insert({
+ member_id: authData.user.id,
+ amount: defaultType?.annual_dues || 0,
+ payment_date: paymentDate.toISOString().split('T')[0],
+ due_date: duesPaidDate,
+ payment_method: 'other',
+ notes: 'Initial dues set by admin during member invitation',
+ recorded_by: member.id
+ });
+
+ if (duesError) {
+ console.error('Dues payment creation error:', duesError);
+ // Non-critical - member still created
+ }
+ }
+
+ // Send welcome email with Monaco branding
+ const baseUrl = url.origin;
+ const logoUrl = `${baseUrl}/MONACOUSA-Flags_376x376.png`;
+ const memberFirstName = firstName || 'New Member';
+
+ // Format dues paid date for display
+ const formattedDuesPaidDate = duesPaidDate
+ ? new Date(duesPaidDate).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric'
+ })
+ : null;
+
+ // Dues section for the email
+ const duesSection = duesPaidDate
+ ? `
+
Membership Status
+
Status: Active Member
+
Dues Paid Through: ${formattedDuesPaidDate}
+
`
+ : '';
+
+ const welcomeEmailResult = await sendEmail({
+ to: email,
+ subject: `Welcome to Monaco USA, ${memberFirstName}!`,
+ html: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Monaco USA
+ Americans in Monaco
+
+
+
+
+
+
+
+
+ Welcome to Monaco USA!
+ Dear ${memberFirstName},
+ We are thrilled to welcome you to the Monaco USA community! Your membership account has been created and you are now part of our growing network of Americans in Monaco.
+ ${duesSection}
+
+
To get started:
+
+ You will receive a separate email shortly to set up your password
+ Log in to your member portal at ${baseUrl}
+ Complete your profile with your details
+ Explore upcoming events and connect with fellow members
+
+
+ If you have any questions, please don't hesitate to reach out to our board members.
+ Best regards,The Monaco USA Team
+
+
+
+
+
+
+
+
+ © 2026 Monaco USA. All rights reserved.
+
+
+
+
+
+
+
+`,
+ recipientId: authData.user.id,
+ recipientName: `${firstName} ${lastName}`.trim() || 'New Member',
+ emailType: 'welcome',
+ sentBy: member.id
+ });
+
+ if (!welcomeEmailResult.success) {
+ console.error('Welcome email error:', welcomeEmailResult.error);
+ // Non-critical - member still created
+ }
+
+ // Send password reset email so user can set their own password
+ const { error: resetError } = await locals.supabase.auth.resetPasswordForEmail(email, {
+ redirectTo: `${url.origin}/auth/reset-password`
+ });
+
+ if (resetError) {
+ console.error('Password reset email error:', resetError);
+ // Member created but email failed - not critical
+ }
+
+ return {
+ success: `Invitation sent to ${email}! They will receive a welcome email and instructions to set up their password.`
+ };
+ }
+};
diff --git a/src/routes/(app)/admin/members/+page.svelte b/src/routes/(app)/admin/members/+page.svelte
new file mode 100644
index 0000000..74ff84d
--- /dev/null
+++ b/src/routes/(app)/admin/members/+page.svelte
@@ -0,0 +1,596 @@
+
+
+
+ User Management | Monaco USA Admin
+
+
+
+
+
+
User Management
+
Manage member accounts, roles, and statuses
+
+
+
+ Invite Member
+
+
+
+ {#if form?.error}
+
+ {form.error}
+
+ {/if}
+
+ {#if form?.success}
+
+ {form.success}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
{stats.total}
+
Total Users
+
+
+
+
+
+
+
+
+
+
{stats.admins}
+
Admins
+
+
+
+
+
+
+
+
+
+
{stats.board}
+
Board Members
+
+
+
+
+
+
+
+
+
+
{stats.members}
+
Members
+
+
+
+
+
+
+
+
+
+
+ {
+ searchQuery = e.currentTarget.value;
+ handleSearch(e.currentTarget.value);
+ }}
+ class="h-10 pl-9"
+ />
+
+
+
updateFilters({ role: roleFilter })}
+ class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
+ >
+ All Roles
+ Admin
+ Board
+ Member
+
+
+
updateFilters({ status: statusFilter })}
+ class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
+ >
+ All Statuses
+ {#each statuses as status}
+ {status.display_name}
+ {/each}
+
+
+
+
+
+
+ {#if members.length === 0}
+
+
+
No users found
+
Try adjusting your search or filters.
+
+ {:else}
+
+
+
+
+ User
+ Contact
+ Role
+ Status
+ Joined
+ Actions
+
+
+
+ {#each members as member}
+ {@const roleInfo = getRoleInfo(member.role)}
+ {@const statusInfo = getStatusInfo(member.status_name)}
+
+
+
+ {#if member.avatar_url}
+
+ {:else}
+
+ {member.first_name?.[0]}{member.last_name?.[0]}
+
+ {/if}
+
+
+
+ {member.first_name} {member.last_name}
+
+ {#if member.nationality && member.nationality.length > 0}
+
+ {#each member.nationality as code}
+
+ {/each}
+
+ {/if}
+
+
{member.member_id}
+
+
+
+
+
+
+
+ {member.email}
+
+ {#if member.phone}
+
+ {/if}
+
+
+
+ {
+ return async ({ update }) => {
+ await invalidateAll();
+ await update();
+ };
+ }}
+ class="inline"
+ >
+
+ e.currentTarget.form?.requestSubmit()}
+ class="rounded-lg border-0 bg-transparent py-1 pr-8 text-sm font-medium {roleInfo.color} cursor-pointer hover:bg-slate-100"
+ >
+ Member
+ Board
+ Admin
+
+
+
+
+ {
+ return async ({ update }) => {
+ await invalidateAll();
+ await update();
+ };
+ }}
+ class="inline"
+ >
+
+ e.currentTarget.form?.requestSubmit()}
+ class="rounded-lg border-0 bg-transparent py-1 pr-8 text-sm cursor-pointer hover:bg-slate-100"
+ >
+ {#each statuses as status}
+ {status.display_name}
+ {/each}
+
+
+
+
+ {formatDate(member.member_since)}
+
+
+ confirmDelete(member)}
+ class="rounded p-1.5 text-slate-400 hover:bg-red-100 hover:text-red-600"
+ title="Delete Member"
+ >
+
+
+
+
+ {/each}
+
+
+
+ {/if}
+
+
+
+
+{#if showDeleteConfirm && memberToDelete}
+
+
+
+
+
+ Are you sure you want to delete {memberToDelete.first_name} {memberToDelete.last_name}
+ ({memberToDelete.member_id})? This action cannot be undone.
+
+
+
+ This will permanently delete their account, payment history, and all associated data.
+
+
+
+ {
+ showDeleteConfirm = false;
+ memberToDelete = null;
+ }}
+ class="flex-1 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
+ >
+ Cancel
+
+
{
+ return async ({ update, result }) => {
+ if (result.type === 'success') {
+ showDeleteConfirm = false;
+ memberToDelete = null;
+ await invalidateAll();
+ }
+ await update();
+ };
+ }}
+ class="flex-1"
+ >
+
+
+ Delete Member
+
+
+
+
+
+{/if}
+
+
+{#if showInviteModal}
+
+
+
+
+
Invite New Member
+
+
+
+ Send an invitation email to a new member. They will receive instructions to set up their account.
+
+
+
{
+ inviteLoading = true;
+ return async ({ update, result }) => {
+ inviteLoading = false;
+ if (result.type === 'success') {
+ await invalidateAll();
+ }
+ await update();
+ };
+ }}
+ class="space-y-4"
+ >
+
+ Email Address *
+
+
+
+
+
+
+
+ Role
+
+ Member
+ Board
+ Admin
+
+
+
+ Dues Paid Until
+ inviteDuesPaidDate = e.currentTarget.value}
+ disabled={inviteLoading}
+ class="h-11 w-full rounded-md border border-slate-200 bg-white px-3 text-sm focus:border-monaco-500 focus:outline-none focus:ring-1 focus:ring-monaco-500"
+ />
+
+
+
+
+ * Required field. Other fields are optional - the member can update their profile after joining.
+
+
+
+ {
+ showInviteModal = false;
+ inviteEmail = '';
+ inviteFirstName = '';
+ inviteLastName = '';
+ inviteRole = 'member';
+ inviteDuesPaidDate = getDefaultDuesPaidDate();
+ }}
+ disabled={inviteLoading}
+ class="flex-1"
+ >
+ Cancel
+
+
+ {#if inviteLoading}
+
+ Sending...
+ {:else}
+
+ Send Invitation
+ {/if}
+
+
+
+
+
+{/if}
diff --git a/src/routes/(app)/admin/settings/+page.server.ts b/src/routes/(app)/admin/settings/+page.server.ts
new file mode 100644
index 0000000..47105ab
--- /dev/null
+++ b/src/routes/(app)/admin/settings/+page.server.ts
@@ -0,0 +1,709 @@
+import { fail } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+import { testSmtpConnection, sendTemplatedEmail } from '$lib/server/email';
+import { testS3Connection, clearS3ClientCache } from '$lib/server/storage';
+import * as poste from '$lib/server/poste';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ // Load all configurable data
+ const [
+ { data: membershipStatuses },
+ { data: membershipTypes },
+ { data: eventTypes },
+ { data: documentCategories },
+ { data: appSettings },
+ { data: emailTemplates }
+ ] = await Promise.all([
+ locals.supabase.from('membership_statuses').select('*').order('sort_order', { ascending: true }),
+ locals.supabase.from('membership_types').select('*').order('sort_order', { ascending: true }),
+ locals.supabase.from('event_types').select('*').order('sort_order', { ascending: true }),
+ locals.supabase.from('document_categories').select('*').order('sort_order', { ascending: true }),
+ locals.supabase.from('app_settings').select('*'),
+ locals.supabase.from('email_templates').select('template_key, template_name, category').eq('is_active', true).order('category').order('template_name')
+ ]);
+
+ // Convert settings to object by category
+ const settings: Record> = {};
+ for (const setting of appSettings || []) {
+ if (!settings[setting.category]) {
+ settings[setting.category] = {};
+ }
+ settings[setting.category][setting.setting_key] = setting.setting_value;
+ }
+
+ return {
+ membershipStatuses: membershipStatuses || [],
+ membershipTypes: membershipTypes || [],
+ eventTypes: eventTypes || [],
+ documentCategories: documentCategories || [],
+ settings,
+ emailTemplates: emailTemplates || []
+ };
+};
+
+export const actions: Actions = {
+ // Membership Status actions
+ createStatus: async ({ request, locals }) => {
+ const formData = await request.formData();
+ const name = formData.get('name') as string;
+ const displayName = formData.get('display_name') as string;
+ const color = formData.get('color') as string;
+ const description = formData.get('description') as string;
+
+ if (!name || !displayName) {
+ return fail(400, { error: 'Name and display name are required' });
+ }
+
+ const { error } = await locals.supabase.from('membership_statuses').insert({
+ name: name.toLowerCase().replace(/\s+/g, '_'),
+ display_name: displayName,
+ color: color || '#6b7280',
+ description: description || null
+ });
+
+ if (error) {
+ console.error('Create status error:', error);
+ return fail(500, { error: 'Failed to create status' });
+ }
+
+ return { success: 'Status created successfully!' };
+ },
+
+ deleteStatus: async ({ request, locals }) => {
+ const formData = await request.formData();
+ const id = formData.get('id') as string;
+
+ const { error } = await locals.supabase.from('membership_statuses').delete().eq('id', id);
+
+ if (error) {
+ console.error('Delete status error:', error);
+ return fail(500, { error: 'Failed to delete status' });
+ }
+
+ return { success: 'Status deleted!' };
+ },
+
+ // Membership Type actions
+ createType: async ({ request, locals }) => {
+ const formData = await request.formData();
+ const name = formData.get('name') as string;
+ const displayName = formData.get('display_name') as string;
+ const annualDues = formData.get('annual_dues') as string;
+ const description = formData.get('description') as string;
+
+ if (!name || !displayName || !annualDues) {
+ return fail(400, { error: 'Name, display name, and annual dues are required' });
+ }
+
+ const { error } = await locals.supabase.from('membership_types').insert({
+ name: name.toLowerCase().replace(/\s+/g, '_'),
+ display_name: displayName,
+ annual_dues: parseFloat(annualDues),
+ description: description || null
+ });
+
+ if (error) {
+ console.error('Create type error:', error);
+ return fail(500, { error: 'Failed to create membership type' });
+ }
+
+ return { success: 'Membership type created successfully!' };
+ },
+
+ deleteType: async ({ request, locals }) => {
+ const formData = await request.formData();
+ const id = formData.get('id') as string;
+
+ const { error } = await locals.supabase.from('membership_types').delete().eq('id', id);
+
+ if (error) {
+ console.error('Delete type error:', error);
+ return fail(500, { error: 'Failed to delete membership type' });
+ }
+
+ return { success: 'Membership type deleted!' };
+ },
+
+ // Event Type actions
+ createEventType: async ({ request, locals }) => {
+ const formData = await request.formData();
+ const name = formData.get('name') as string;
+ const displayName = formData.get('display_name') as string;
+ const color = formData.get('color') as string;
+
+ if (!name || !displayName) {
+ return fail(400, { error: 'Name and display name are required' });
+ }
+
+ const { error } = await locals.supabase.from('event_types').insert({
+ name: name.toLowerCase().replace(/\s+/g, '_'),
+ display_name: displayName,
+ color: color || '#3b82f6'
+ });
+
+ if (error) {
+ console.error('Create event type error:', error);
+ return fail(500, { error: 'Failed to create event type' });
+ }
+
+ return { success: 'Event type created successfully!' };
+ },
+
+ deleteEventType: async ({ request, locals }) => {
+ const formData = await request.formData();
+ const id = formData.get('id') as string;
+
+ const { error } = await locals.supabase.from('event_types').delete().eq('id', id);
+
+ if (error) {
+ console.error('Delete event type error:', error);
+ return fail(500, { error: 'Failed to delete event type' });
+ }
+
+ return { success: 'Event type deleted!' };
+ },
+
+ // Document Category actions
+ createCategory: async ({ request, locals }) => {
+ const formData = await request.formData();
+ const name = formData.get('name') as string;
+ const displayName = formData.get('display_name') as string;
+ const description = formData.get('description') as string;
+
+ if (!name || !displayName) {
+ return fail(400, { error: 'Name and display name are required' });
+ }
+
+ const { error } = await locals.supabase.from('document_categories').insert({
+ name: name.toLowerCase().replace(/\s+/g, '_'),
+ display_name: displayName,
+ description: description || null
+ });
+
+ if (error) {
+ console.error('Create category error:', error);
+ return fail(500, { error: 'Failed to create document category' });
+ }
+
+ return { success: 'Document category created successfully!' };
+ },
+
+ deleteCategory: async ({ request, locals }) => {
+ const formData = await request.formData();
+ const id = formData.get('id') as string;
+
+ const { error } = await locals.supabase.from('document_categories').delete().eq('id', id);
+
+ if (error) {
+ console.error('Delete category error:', error);
+ return fail(500, { error: 'Failed to delete document category' });
+ }
+
+ return { success: 'Document category deleted!' };
+ },
+
+ // Update app settings
+ updateSettings: async ({ request, locals }) => {
+ const { member } = await locals.safeGetSession();
+ const formData = await request.formData();
+ const category = formData.get('category') as string;
+
+ // Get existing boolean settings for this category to handle unchecked checkboxes
+ const { data: existingSettings } = await locals.supabase
+ .from('app_settings')
+ .select('setting_key, setting_type')
+ .eq('category', category);
+
+ const existingBooleanKeys = new Set(
+ (existingSettings || [])
+ .filter(s => s.setting_type === 'boolean')
+ .map(s => s.setting_key)
+ );
+
+ // Get all settings from form data
+ const settingsToUpdate: Array<{ key: string; value: any; type: string }> = [];
+ const processedKeys = new Set();
+
+ for (const [key, value] of formData.entries()) {
+ if (key !== 'category' && key.startsWith('setting_')) {
+ const settingKey = key.replace('setting_', '');
+ processedKeys.add(settingKey);
+ // Handle checkbox values - they come as 'on' when checked
+ const isCheckbox = value === 'on' || existingBooleanKeys.has(settingKey);
+ settingsToUpdate.push({
+ key: settingKey,
+ value: isCheckbox ? (value === 'on' || value === 'true') : value,
+ type: isCheckbox ? 'boolean' : 'text'
+ });
+ }
+ }
+
+ // Handle unchecked checkboxes - they don't send any value
+ // For any existing boolean setting NOT in the form data, set to false
+ for (const booleanKey of existingBooleanKeys) {
+ if (!processedKeys.has(booleanKey)) {
+ settingsToUpdate.push({
+ key: booleanKey,
+ value: false,
+ type: 'boolean'
+ });
+ }
+ }
+
+ // Update or insert each setting
+ for (const setting of settingsToUpdate) {
+ // Try to update first
+ const { data: existing } = await locals.supabase
+ .from('app_settings')
+ .select('id')
+ .eq('category', category)
+ .eq('setting_key', setting.key)
+ .single();
+
+ if (existing) {
+ // Update existing
+ await locals.supabase
+ .from('app_settings')
+ .update({
+ setting_value: setting.type === 'boolean' ? setting.value : JSON.stringify(setting.value),
+ updated_at: new Date().toISOString(),
+ updated_by: member?.id
+ })
+ .eq('category', category)
+ .eq('setting_key', setting.key);
+ } else {
+ // Insert new setting
+ await locals.supabase
+ .from('app_settings')
+ .insert({
+ category,
+ setting_key: setting.key,
+ setting_value: setting.type === 'boolean' ? setting.value : JSON.stringify(setting.value),
+ setting_type: setting.type,
+ display_name: setting.key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
+ updated_at: new Date().toISOString(),
+ updated_by: member?.id
+ });
+ }
+ }
+
+ // Clear caches if storage settings were updated
+ if (category === 'storage') {
+ clearS3ClientCache();
+ }
+
+ return { success: 'Settings updated successfully!' };
+ },
+
+ // Test SMTP connection
+ testSmtp: async ({ request, locals }) => {
+ const { member } = await locals.safeGetSession();
+ const formData = await request.formData();
+ const testEmail = formData.get('test_email') as string;
+
+ // Use the member's email if no test email is provided
+ const recipientEmail = testEmail || member?.email;
+
+ if (!recipientEmail) {
+ return fail(400, { error: 'No email address provided for test' });
+ }
+
+ // Test SMTP connection and send a test email
+ const result = await testSmtpConnection(recipientEmail, member?.id);
+
+ if (!result.success) {
+ return fail(400, { error: result.error || 'SMTP test failed' });
+ }
+
+ return {
+ success: `Test email sent successfully to ${recipientEmail}! Check your inbox.`
+ };
+ },
+
+ // Test S3/MinIO connection
+ testS3: async () => {
+ // Clear cache to ensure fresh settings are used
+ clearS3ClientCache();
+
+ // Test S3 connection using the actual client
+ const result = await testS3Connection();
+
+ if (!result.success) {
+ return fail(400, { error: result.error || 'S3 connection test failed' });
+ }
+
+ return {
+ success: 'S3/MinIO connection successful! Bucket is accessible.'
+ };
+ },
+
+ // Test email template
+ testEmailTemplate: async ({ request, locals, url }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member?.email) {
+ return fail(400, { error: 'No email address found for your account' });
+ }
+
+ const formData = await request.formData();
+ const templateKey = formData.get('template_key') as string;
+
+ if (!templateKey) {
+ return fail(400, { error: 'Template key is required' });
+ }
+
+ // Get full member details including member_id
+ const { data: fullMember } = await locals.supabase
+ .from('members')
+ .select('member_id')
+ .eq('id', member.id)
+ .single();
+
+ const memberId = fullMember?.member_id || 'MUSA-0001';
+
+ // Create sample variables for each template type
+ const sampleVariables: Record> = {
+ // Welcome/Auth templates
+ welcome: {
+ first_name: member.first_name || 'Test',
+ last_name: member.last_name || 'User',
+ member_id: memberId,
+ portal_url: url.origin
+ },
+ password_reset: {
+ first_name: member.first_name || 'Test',
+ reset_link: `${url.origin}/reset-password?token=sample-token`
+ },
+ email_verification: {
+ first_name: member.first_name || 'Test',
+ verification_link: `${url.origin}/verify?token=sample-token`
+ },
+ // Event templates
+ rsvp_confirmation: {
+ first_name: member.first_name || 'Test',
+ event_title: 'Monaco USA Annual Gala',
+ event_date: 'Saturday, March 15, 2026',
+ event_time: '7:00 PM',
+ event_location: 'Hotel Hermitage, Monaco',
+ guest_count: '2',
+ portal_url: `${url.origin}/events`
+ },
+ waitlist_promotion: {
+ first_name: member.first_name || 'Test',
+ event_title: 'Monaco USA Annual Gala',
+ event_date: 'Saturday, March 15, 2026',
+ event_location: 'Hotel Hermitage, Monaco',
+ portal_url: `${url.origin}/events`
+ },
+ event_reminder_24hr: {
+ first_name: member.first_name || 'Test',
+ event_title: 'Monaco USA Monthly Meetup',
+ event_date: 'Tomorrow, January 25, 2026',
+ event_time: '6:30 PM',
+ event_location: 'Stars\'n\'Bars, Monaco',
+ guest_count: '1',
+ portal_url: `${url.origin}/events/sample-event-id`
+ },
+ // Payment/Dues templates
+ payment_received: {
+ first_name: member.first_name || 'Test',
+ amount: '€50.00',
+ payment_date: 'January 24, 2026',
+ payment_method: 'Bank Transfer',
+ new_due_date: 'January 24, 2027',
+ member_id: memberId
+ },
+ dues_reminder_30: {
+ first_name: member.first_name || 'Test',
+ due_date: 'February 24, 2026',
+ amount: '€50.00',
+ member_id: memberId,
+ account_holder: 'Monaco USA Association',
+ bank_name: 'CMB Monaco',
+ iban: 'MC00 0000 0000 0000 0000 0000 000',
+ portal_url: `${url.origin}/payments`
+ },
+ dues_reminder_7: {
+ first_name: member.first_name || 'Test',
+ due_date: 'January 31, 2026',
+ amount: '€50.00',
+ member_id: memberId,
+ iban: 'MC00 0000 0000 0000 0000 0000 000',
+ portal_url: `${url.origin}/payments`
+ },
+ dues_reminder_1: {
+ first_name: member.first_name || 'Test',
+ due_date: 'January 25, 2026',
+ amount: '€50.00',
+ member_id: memberId,
+ iban: 'MC00 0000 0000 0000 0000 0000 000',
+ portal_url: `${url.origin}/payments`
+ },
+ dues_overdue: {
+ first_name: member.first_name || 'Test',
+ due_date: 'January 15, 2026',
+ amount: '€50.00',
+ days_overdue: '9',
+ grace_days_remaining: '21',
+ member_id: memberId,
+ account_holder: 'Monaco USA Association',
+ iban: 'MC00 0000 0000 0000 0000 0000 000',
+ portal_url: `${url.origin}/payments`
+ },
+ dues_grace_warning: {
+ first_name: member.first_name || 'Test',
+ due_date: 'December 24, 2025',
+ amount: '€50.00',
+ days_overdue: '31',
+ grace_days_remaining: '7',
+ grace_end_date: 'February 1, 2026',
+ member_id: memberId,
+ iban: 'MC00 0000 0000 0000 0000 0000 000',
+ portal_url: `${url.origin}/payments`
+ },
+ dues_inactive_notice: {
+ first_name: member.first_name || 'Test',
+ amount: '€50.00',
+ member_id: memberId,
+ account_holder: 'Monaco USA Association',
+ iban: 'MC00 0000 0000 0000 0000 0000 000',
+ portal_url: `${url.origin}/payments`
+ }
+ };
+
+ // Get variables for this template, or use defaults
+ const variables = sampleVariables[templateKey] || {
+ first_name: member.first_name || 'Test',
+ last_name: member.last_name || 'User',
+ portal_url: url.origin
+ };
+
+ // Send test email
+ const result = await sendTemplatedEmail(templateKey, member.email, variables, {
+ recipientId: member.id,
+ recipientName: `${member.first_name} ${member.last_name}`,
+ baseUrl: url.origin
+ });
+
+ if (!result.success) {
+ return fail(400, { error: result.error || 'Failed to send test email' });
+ }
+
+ return {
+ success: `Test email "${templateKey}" sent to ${member.email}`
+ };
+ },
+
+ // ============================================
+ // Poste Mail Server Actions
+ // ============================================
+
+ testPoste: async ({ locals }) => {
+ // Get Poste settings
+ const { data: settings } = await locals.supabase
+ .from('app_settings')
+ .select('setting_key, setting_value')
+ .eq('category', 'poste');
+
+ if (!settings || settings.length === 0) {
+ return fail(400, { error: 'Poste mail server not configured. Please save settings first.' });
+ }
+
+ const config: Record = {};
+ for (const s of settings) {
+ let value = s.setting_value;
+ if (typeof value === 'string') {
+ value = value.replace(/^"|"$/g, '');
+ }
+ config[s.setting_key] = value as string;
+ }
+
+ if (!config.poste_api_host || !config.poste_admin_email || !config.poste_admin_password) {
+ return fail(400, { error: 'Poste configuration incomplete. Host, admin email, and password are required.' });
+ }
+
+ const result = await poste.testConnection({
+ host: config.poste_api_host,
+ adminEmail: config.poste_admin_email,
+ adminPassword: config.poste_admin_password
+ });
+
+ if (!result.success) {
+ return fail(400, { error: result.error || 'Connection test failed' });
+ }
+
+ return { success: 'Connection to Poste mail server successful!' };
+ },
+
+ listMailboxes: async ({ locals }) => {
+ // Get Poste settings
+ const { data: settings } = await locals.supabase
+ .from('app_settings')
+ .select('setting_key, setting_value')
+ .eq('category', 'poste');
+
+ if (!settings || settings.length === 0) {
+ return fail(400, { error: 'Poste not configured' });
+ }
+
+ const config: Record = {};
+ for (const s of settings) {
+ let value = s.setting_value;
+ if (typeof value === 'string') {
+ value = value.replace(/^"|"$/g, '');
+ }
+ config[s.setting_key] = value as string;
+ }
+
+ const result = await poste.listMailboxes({
+ host: config.poste_api_host,
+ adminEmail: config.poste_admin_email,
+ adminPassword: config.poste_admin_password
+ });
+
+ if (!result.success) {
+ return fail(400, { error: result.error });
+ }
+
+ return { mailboxes: result.mailboxes };
+ },
+
+ createMailbox: async ({ request, locals }) => {
+ const formData = await request.formData();
+ const emailPrefix = formData.get('email_prefix') as string;
+ const displayName = formData.get('display_name') as string;
+ const password = formData.get('password') as string;
+
+ if (!emailPrefix || !displayName) {
+ return fail(400, { error: 'Email prefix and display name are required' });
+ }
+
+ // Get Poste settings
+ const { data: settings } = await locals.supabase
+ .from('app_settings')
+ .select('setting_key, setting_value')
+ .eq('category', 'poste');
+
+ const config: Record = {};
+ for (const s of settings || []) {
+ let value = s.setting_value;
+ if (typeof value === 'string') {
+ value = value.replace(/^"|"$/g, '');
+ }
+ config[s.setting_key] = value as string;
+ }
+
+ const domain = config.poste_domain || 'monacousa.org';
+ const fullEmail = `${emailPrefix}@${domain}`;
+ const actualPassword = password || poste.generatePassword();
+
+ const result = await poste.createMailbox(
+ {
+ host: config.poste_api_host,
+ adminEmail: config.poste_admin_email,
+ adminPassword: config.poste_admin_password
+ },
+ {
+ email: fullEmail,
+ name: displayName,
+ password: actualPassword
+ }
+ );
+
+ if (!result.success) {
+ return fail(400, { error: result.error });
+ }
+
+ return {
+ success: `Mailbox ${fullEmail} created successfully!`,
+ generatedPassword: password ? undefined : actualPassword
+ };
+ },
+
+ updateMailbox: async ({ request, locals }) => {
+ const formData = await request.formData();
+ const email = formData.get('email') as string;
+ const displayName = formData.get('display_name') as string;
+ const newPassword = formData.get('new_password') as string;
+ const disabled = formData.get('disabled') === 'true';
+
+ if (!email) {
+ return fail(400, { error: 'Email is required' });
+ }
+
+ // Get Poste settings
+ const { data: settings } = await locals.supabase
+ .from('app_settings')
+ .select('setting_key, setting_value')
+ .eq('category', 'poste');
+
+ const config: Record = {};
+ for (const s of settings || []) {
+ let value = s.setting_value;
+ if (typeof value === 'string') {
+ value = value.replace(/^"|"$/g, '');
+ }
+ config[s.setting_key] = value as string;
+ }
+
+ const updates: { name?: string; password?: string; disabled?: boolean } = {};
+ if (displayName) updates.name = displayName;
+ if (newPassword) updates.password = newPassword;
+ updates.disabled = disabled;
+
+ const result = await poste.updateMailbox(
+ {
+ host: config.poste_api_host,
+ adminEmail: config.poste_admin_email,
+ adminPassword: config.poste_admin_password
+ },
+ email,
+ updates
+ );
+
+ if (!result.success) {
+ return fail(400, { error: result.error });
+ }
+
+ return { success: `Mailbox ${email} updated successfully!` };
+ },
+
+ deleteMailbox: async ({ request, locals }) => {
+ const formData = await request.formData();
+ const email = formData.get('email') as string;
+
+ if (!email) {
+ return fail(400, { error: 'Email is required' });
+ }
+
+ // Get Poste settings
+ const { data: settings } = await locals.supabase
+ .from('app_settings')
+ .select('setting_key, setting_value')
+ .eq('category', 'poste');
+
+ const config: Record = {};
+ for (const s of settings || []) {
+ let value = s.setting_value;
+ if (typeof value === 'string') {
+ value = value.replace(/^"|"$/g, '');
+ }
+ config[s.setting_key] = value as string;
+ }
+
+ const result = await poste.deleteMailbox(
+ {
+ host: config.poste_api_host,
+ adminEmail: config.poste_admin_email,
+ adminPassword: config.poste_admin_password
+ },
+ email
+ );
+
+ if (!result.success) {
+ return fail(400, { error: result.error });
+ }
+
+ return { success: `Mailbox ${email} deleted successfully!` };
+ }
+};
diff --git a/src/routes/(app)/admin/settings/+page.svelte b/src/routes/(app)/admin/settings/+page.svelte
new file mode 100644
index 0000000..de8a85e
--- /dev/null
+++ b/src/routes/(app)/admin/settings/+page.svelte
@@ -0,0 +1,1703 @@
+
+
+
+ Settings | Monaco USA Admin
+
+
+
+
+
Settings
+
Configure membership types, dues, events, and more
+
+
+ {#if form?.error}
+
+ {form.error}
+
+ {/if}
+
+ {#if form?.success}
+
+ {form.success}
+
+ {/if}
+
+
+
+
+ {#each tabs as tab}
+ (activeTab = tab.id)}
+ class="flex items-center gap-2 border-b-2 px-1 pb-3 text-sm font-medium transition-colors {activeTab ===
+ tab.id
+ ? 'border-monaco-600 text-monaco-600'
+ : 'border-transparent text-slate-500 hover:text-slate-700'}"
+ >
+
+ {tab.label}
+
+ {/each}
+
+
+
+
+ {#if activeTab === 'membership'}
+
+
+
+
+
+
Membership Statuses
+
Define the different states a membership can be in
+
+
(showAddStatusModal = true)}
+ class="flex items-center gap-1.5 rounded-lg bg-monaco-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-monaco-700"
+ >
+
+ Add Status
+
+
+
+
+
+
+
+ Name
+ Display
+ Color
+ Default
+ Actions
+
+
+
+ {#each membershipStatuses as status}
+
+ {status.name}
+ {status.display_name}
+
+
+
+
+ {status.is_default ? 'Yes' : ''}
+
+
+
+
+
+
+
+
+
+
+ {/each}
+
+
+
+
+
+
+
+
+
+
Membership Types
+
Define different membership tiers with pricing
+
+
(showAddTypeModal = true)}
+ class="flex items-center gap-1.5 rounded-lg bg-monaco-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-monaco-700"
+ >
+
+ Add Type
+
+
+
+
+
+
+
+ Name
+ Display
+ Annual Dues
+ Default
+ Actions
+
+
+
+ {#each membershipTypes as type}
+
+ {type.name}
+ {type.display_name}
+ €{type.annual_dues.toFixed(2)}
+
+ {type.is_default ? 'Yes' : ''}
+
+
+
+
+
+
+
+
+
+
+ {/each}
+
+
+
+
+
+ {/if}
+
+
+ {#if activeTab === 'dues'}
+
+
Payment Settings
+
Configure bank details and payment instructions shown to members
+
+
+
+
+
+
+
+ Save Payment Settings
+
+
+
+ {/if}
+
+
+ {#if activeTab === 'events'}
+
+
+
+
Event Types
+
Define the different types of events
+
+
(showAddEventTypeModal = true)}
+ class="flex items-center gap-1.5 rounded-lg bg-monaco-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-monaco-700"
+ >
+
+ Add Event Type
+
+
+
+
+
+
+
+ Name
+ Display
+ Color
+ Actions
+
+
+
+ {#each eventTypes as eventType}
+
+ {eventType.name}
+ {eventType.display_name}
+
+
+ {eventType.display_name}
+
+
+
+
+
+
+
+
+
+
+
+ {/each}
+
+
+
+
+ {/if}
+
+
+ {#if activeTab === 'documents'}
+
+
+
+
Document Categories
+
Define categories for organizing documents
+
+
(showAddCategoryModal = true)}
+ class="flex items-center gap-1.5 rounded-lg bg-monaco-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-monaco-700"
+ >
+
+ Add Category
+
+
+
+
+
+
+
+ Name
+ Display
+ Description
+ Actions
+
+
+
+ {#each documentCategories as category}
+
+ {category.name}
+ {category.display_name}
+ {category.description || '-'}
+
+
+
+
+
+
+
+
+
+ {/each}
+
+
+
+
+ {/if}
+
+
+ {#if activeTab === 'email'}
+
+
+
SMTP Email Configuration
+
Configure outgoing email settings for notifications and reminders
+
+
+ {#if smtpTestResult}
+
+ {#if smtpTestResult.success}
+
+ {:else}
+
+ {/if}
+ {smtpTestResult.message}
+
+ {/if}
+
+
+
+
+
+
+
+
+
Enable Email Sending
+
When enabled, the system will send email notifications
+
+
+
+
+
+
Server Settings
+
+
+
+ Use TLS/SSL (recommended for ports 465, 587)
+
+
+
+
+
+
Authentication
+
+
+ Username
+
+
+
+
Password
+
+
+ (showSmtpPassword = !showSmtpPassword)}
+ class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
+ >
+ {#if showSmtpPassword}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+ Save Email Settings
+
+
+
+
+
{
+ testingSmtp = true;
+ smtpTestResult = null;
+ return async ({ result }) => {
+ testingSmtp = false;
+ if (result.type === 'success' && result.data?.success) {
+ smtpTestResult = { success: true, message: result.data.success };
+ } else if (result.type === 'failure' && result.data?.error) {
+ smtpTestResult = { success: false, message: result.data.error };
+ } else {
+ smtpTestResult = { success: false, message: 'Test failed. Please check your settings.' };
+ }
+ };
+ }}
+ >
+
+ {#if testingSmtp}
+
+ {:else}
+
+ {/if}
+ Test Connection
+
+
+
+
+
+
+
+
+
Test Email Templates
+
Send test versions of each email template to yourself
+
+
+
+ Edit Templates
+
+
+
+ {#if templateTestResult}
+
+ {#if templateTestResult.success}
+
+ {:else}
+
+ {/if}
+ {templateTestResult.message}
+
+ {/if}
+
+
+ {#each Object.entries(templatesByCategory) as [category, templates]}
+
+
+ {category === 'events' ? 'Events' : category === 'payment' ? 'Payments & Dues' : category === 'auth' ? 'Authentication' : category === 'member' ? 'Membership' : 'Other'}
+
+
+ {#each templates as template}
+
{
+ testingTemplate = template.template_key;
+ templateTestResult = null;
+ return async ({ result }) => {
+ testingTemplate = null;
+ if (result.type === 'success' && result.data?.success) {
+ templateTestResult = { success: true, message: result.data.success };
+ } else if (result.type === 'failure' && result.data?.error) {
+ templateTestResult = { success: false, message: result.data.error };
+ } else {
+ templateTestResult = { success: false, message: 'Failed to send test email' };
+ }
+ };
+ }}
+ >
+
+
+
+
{template.template_name}
+
{template.template_key}
+
+ {#if testingTemplate === template.template_key}
+
+ {:else}
+
+ {/if}
+
+
+ {/each}
+
+
+ {/each}
+
+ {#if !emailTemplates || emailTemplates.length === 0}
+
No email templates found in the database.
+ {/if}
+
+
+ {/if}
+
+
+ {#if activeTab === 'poste'}
+
+
+
+
+
Poste Mail Server Configuration
+
Configure connection to your Poste.io mail server to manage @monacousa.org email accounts
+
+
+ {#if posteTestResult}
+
+ {#if posteTestResult.success}
+
+ {:else}
+
+ {/if}
+ {posteTestResult.message}
+
+ {/if}
+
+
+
+
+
+
+
API Host
+
+
The hostname of your Poste mail server
+
+
+ Admin Email
+
+
+
+
Admin Password
+
+
+ (showPostePassword = !showPostePassword)}
+ class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
+ >
+ {#if showPostePassword}
+
+ {:else}
+
+ {/if}
+
+
+
+
+ Email Domain
+
+
+
+
+
+
+ Save Configuration
+
+
+
+
+
{
+ testingPoste = true;
+ posteTestResult = null;
+ return async ({ result }) => {
+ testingPoste = false;
+ if (result.type === 'success' && result.data?.success) {
+ posteTestResult = { success: true, message: result.data.success };
+ await loadMailboxes();
+ } else if (result.type === 'failure' && result.data?.error) {
+ posteTestResult = { success: false, message: result.data.error };
+ } else {
+ posteTestResult = { success: false, message: 'Connection test failed.' };
+ }
+ };
+ }}
+ >
+
+ {#if testingPoste}
+
+ {:else}
+
+ {/if}
+ Test Connection
+
+
+
+
+
+
+
+
+
Email Accounts
+
Manage @monacousa.org email accounts for board members
+
+
+
+
+ Refresh
+
+
(showCreateMailboxModal = true)}
+ class="flex items-center gap-1.5 rounded-lg bg-monaco-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-monaco-700"
+ >
+
+ Create Mailbox
+
+
+
+
+ {#if generatedPassword}
+
+
+
+
Mailbox created! Generated password:
+
{generatedPassword}
+
+
{ copyPassword(generatedPassword!); generatedPassword = null; }}
+ class="flex items-center gap-1.5 rounded-lg bg-green-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-green-700"
+ >
+
+ Copy & Dismiss
+
+
+
Save this password now - it cannot be retrieved later.
+
+ {/if}
+
+ {#if loadingMailboxes}
+
+
+
+ {:else if mailboxes.length === 0}
+
+
+
No mailboxes loaded. Click "Test Connection" to load mailboxes.
+
+ {:else}
+
+
+
+
+ Email
+ Display Name
+ Status
+ Actions
+
+
+
+ {#each mailboxes as mailbox}
+
+
+ {mailbox.address}
+ {#if mailbox.super_admin}
+ Admin
+ {/if}
+
+ {mailbox.name || '-'}
+
+ {#if mailbox.disabled}
+
+
+ Disabled
+
+ {:else}
+
+
+ Active
+
+ {/if}
+
+
+ {
+ if (!confirm(`Delete mailbox ${mailbox.address}? This cannot be undone.`)) {
+ return async () => {};
+ }
+ return async ({ result }) => {
+ if (result.type === 'success') {
+ await loadMailboxes();
+ }
+ };
+ }}
+ >
+
+
+
+
+
+
+
+ {/each}
+
+
+
+ {/if}
+
+
+ {/if}
+
+
+ {#if activeTab === 'storage'}
+
+
+
S3/MinIO Storage Configuration
+
Configure external S3-compatible storage for documents and files
+
+
+ {#if s3TestResult}
+
+ {#if s3TestResult.success}
+
+ {:else}
+
+ {/if}
+ {s3TestResult.message}
+
+ {/if}
+
+
+
+
+
+
+
+
+
Enable S3 Storage
+
Use external S3/MinIO instead of Supabase Storage
+
+
+
+
+
+
Connection Settings
+
+
+
Endpoint URL
+
+
For MinIO, use your MinIO server URL. For AWS S3, leave empty or use regional endpoint.
+
+
+ Bucket Name
+
+
+
+
Region
+
+
Use us-east-1 for MinIO
+
+
+
+
+
+
+
Credentials
+
+
+ Access Key ID
+
+
+
+
Secret Access Key
+
+
+ (showS3SecretKey = !showS3SecretKey)}
+ class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
+ >
+ {#if showS3SecretKey}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+
+
+
+
Options
+
+
+
+ Use SSL/HTTPS
+
+
+
+
+
Force Path Style
+
Required for MinIO and some S3-compatible services
+
+
+
+
+
+
+
+ Save Storage Settings
+
+
+
+
+
{
+ testingS3 = true;
+ s3TestResult = null;
+ return async ({ result }) => {
+ testingS3 = false;
+ if (result.type === 'success' && result.data?.success) {
+ s3TestResult = { success: true, message: result.data.success };
+ } else if (result.type === 'failure' && result.data?.error) {
+ s3TestResult = { success: false, message: result.data.error };
+ } else {
+ s3TestResult = { success: false, message: 'S3 test failed. Please check your settings.' };
+ }
+ };
+ }}
+ >
+
+ {#if testingS3}
+
+ {:else}
+
+ {/if}
+ Test Connection
+
+
+
+ {/if}
+
+
+ {#if activeTab === 'system'}
+
+
+
+
Maintenance Mode
+
+
+
+
+
+
+
Enable Maintenance Mode
+
When enabled, non-admin users will see the maintenance message
+
+
+
+
+ Maintenance Message
+ {getSetting('system', 'maintenance_message', 'The portal is currently undergoing maintenance. Please check back soon.')}
+
+
+
+ Save Maintenance Settings
+
+
+
+
+
+
+
Session & Security
+
+
+
+
+
Session Timeout (hours)
+
+
Default is 168 hours (7 days). Max is 720 hours (30 days).
+
+
+
+ Save Security Settings
+
+
+
+
+
+
+
File Uploads
+
+
+
+
+ Maximum Upload Size (MB)
+
+
+
+
+
Allowed File Types
+
+ PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT, JPG, JPEG, PNG, WEBP
+
+
File type restrictions are enforced on upload
+
+
+
+ Save Upload Settings
+
+
+
+
+
+
+
Public Access
+
+
+
+
+
+
+
+
Enable Public Events Page
+
Allow non-members to view events marked as public
+
+
+
+
+
+
+
Enable Public RSVP
+
Allow non-members to RSVP to public events
+
+
+
+
+
+ Save Public Access Settings
+
+
+
+
+ {/if}
+
+
+
+{#if showAddStatusModal}
+
+{/if}
+
+
+{#if showAddTypeModal}
+
+{/if}
+
+
+{#if showAddEventTypeModal}
+
+{/if}
+
+
+{#if showAddCategoryModal}
+
+{/if}
+
+
+{#if showCreateMailboxModal}
+
+
+
+
+
+
+
+
Create Email Account
+
+
(showCreateMailboxModal = false)} class="rounded p-1 hover:bg-slate-100">
+
+
+
+
+
{
+ creatingMailbox = true;
+ return async ({ result }) => {
+ creatingMailbox = false;
+ if (result.type === 'success') {
+ showCreateMailboxModal = false;
+ if (result.data?.generatedPassword) {
+ generatedPassword = result.data.generatedPassword;
+ }
+ await loadMailboxes();
+ }
+ };
+ }}
+ class="space-y-4"
+ >
+
+
Email Address
+
+
+
+ @{getSetting('poste', 'poste_domain', 'monacousa.org')}
+
+
+
Letters, numbers, dots, dashes, and underscores only
+
+
+
+ Display Name
+
+
+
+
+
Password (optional)
+
+
+
+
+
If left empty, a secure password will be generated
+
+
+
+ (showCreateMailboxModal = false)}
+ class="flex-1 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
+ >
+ Cancel
+
+
+ {#if creatingMailbox}
+
+
+ Creating...
+
+ {:else}
+ Create Mailbox
+ {/if}
+
+
+
+
+
+{/if}
diff --git a/src/routes/(app)/board/+layout.server.ts b/src/routes/(app)/board/+layout.server.ts
new file mode 100644
index 0000000..41f7dc7
--- /dev/null
+++ b/src/routes/(app)/board/+layout.server.ts
@@ -0,0 +1,13 @@
+import { redirect } from '@sveltejs/kit';
+import type { LayoutServerLoad } from './$types';
+
+export const load: LayoutServerLoad = async ({ parent }) => {
+ const { member } = await parent();
+
+ // Only board and admin can access board pages
+ if (member?.role !== 'board' && member?.role !== 'admin') {
+ throw redirect(303, '/dashboard');
+ }
+
+ return {};
+};
diff --git a/src/routes/(app)/board/documents/+page.server.ts b/src/routes/(app)/board/documents/+page.server.ts
new file mode 100644
index 0000000..f1bb830
--- /dev/null
+++ b/src/routes/(app)/board/documents/+page.server.ts
@@ -0,0 +1,480 @@
+import { fail } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+import { uploadDocument, deleteDocument, getSignedUrl, isS3Enabled, getActiveDocumentUrl } from '$lib/server/storage';
+import { supabaseAdmin } from '$lib/server/supabase';
+
+export const load: PageServerLoad = async ({ locals, url }) => {
+ const folderId = url.searchParams.get('folder');
+
+ // Load folders in current directory
+ let foldersQuery = locals.supabase
+ .from('document_folders')
+ .select(`
+ *,
+ creator:members!document_folders_created_by_fkey(first_name, last_name)
+ `)
+ .order('name', { ascending: true });
+
+ if (folderId) {
+ foldersQuery = foldersQuery.eq('parent_id', folderId);
+ } else {
+ foldersQuery = foldersQuery.is('parent_id', null);
+ }
+
+ const { data: folders } = await foldersQuery;
+
+ // Load documents in current folder
+ let documentsQuery = locals.supabase
+ .from('documents')
+ .select(`
+ *,
+ category:document_categories(id, name, display_name, icon),
+ uploader:members!documents_uploaded_by_fkey(first_name, last_name)
+ `)
+ .order('created_at', { ascending: false });
+
+ if (folderId) {
+ documentsQuery = documentsQuery.eq('folder_id', folderId);
+ } else {
+ documentsQuery = documentsQuery.is('folder_id', null);
+ }
+
+ const { data: documents } = await documentsQuery;
+
+ // Load current folder details for breadcrumbs
+ let currentFolder = null;
+ let breadcrumbs: { id: string | null; name: string }[] = [{ id: null, name: 'Documents' }];
+
+ if (folderId) {
+ const { data: folder } = await locals.supabase
+ .from('document_folders')
+ .select('*')
+ .eq('id', folderId)
+ .single();
+
+ currentFolder = folder;
+
+ // Build breadcrumb path
+ if (folder?.path) {
+ const pathParts = folder.path.split('/');
+ let currentPath = '';
+
+ // Get all ancestor folders
+ for (let i = 0; i < pathParts.length - 1; i++) {
+ currentPath = currentPath ? `${currentPath}/${pathParts[i]}` : pathParts[i];
+ const { data: ancestorFolder } = await locals.supabase
+ .from('document_folders')
+ .select('id, name')
+ .eq('path', currentPath)
+ .single();
+
+ if (ancestorFolder) {
+ breadcrumbs.push({ id: ancestorFolder.id, name: ancestorFolder.name });
+ }
+ }
+ breadcrumbs.push({ id: folder.id, name: folder.name });
+ }
+ }
+
+ // Load categories
+ const { data: categories } = await locals.supabase
+ .from('document_categories')
+ .select('*')
+ .eq('is_active', true)
+ .order('sort_order', { ascending: true });
+
+ // Resolve active URL for each document based on current storage settings
+ const s3Enabled = await isS3Enabled();
+ const documentsWithActiveUrl = (documents || []).map((doc: any) => ({
+ ...doc,
+ // Compute active URL based on storage setting
+ active_url: s3Enabled
+ ? (doc.file_url_s3 || doc.file_path)
+ : (doc.file_url_local || doc.file_path)
+ }));
+
+ return {
+ documents: documentsWithActiveUrl,
+ folders: folders || [],
+ categories: categories || [],
+ currentFolder,
+ currentFolderId: folderId,
+ breadcrumbs
+ };
+};
+
+export const actions: Actions = {
+ createFolder: async ({ request, locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || (member.role !== 'board' && member.role !== 'admin')) {
+ return fail(403, { error: 'You do not have permission to create folders' });
+ }
+
+ const formData = await request.formData();
+ const name = (formData.get('name') as string)?.trim();
+ const parentId = formData.get('parent_id') as string | null;
+ const visibility = (formData.get('visibility') as string) || 'members';
+
+ if (!name) {
+ return fail(400, { error: 'Folder name is required' });
+ }
+
+ // Validate folder name
+ if (name.includes('/') || name.includes('\\')) {
+ return fail(400, { error: 'Folder name cannot contain slashes' });
+ }
+
+ const { error } = await locals.supabase.from('document_folders').insert({
+ name,
+ parent_id: parentId || null,
+ visibility,
+ created_by: member.id
+ });
+
+ if (error) {
+ console.error('Create folder error:', error);
+ if (error.code === '23505') {
+ return fail(400, { error: 'A folder with this name already exists here' });
+ }
+ return fail(500, { error: 'Failed to create folder' });
+ }
+
+ return { success: 'Folder created!' };
+ },
+
+ renameFolder: async ({ request, locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || (member.role !== 'board' && member.role !== 'admin')) {
+ return fail(403, { error: 'You do not have permission to rename folders' });
+ }
+
+ const formData = await request.formData();
+ const folderId = formData.get('folder_id') as string;
+ const name = (formData.get('name') as string)?.trim();
+
+ if (!folderId || !name) {
+ return fail(400, { error: 'Folder ID and name are required' });
+ }
+
+ if (name.includes('/') || name.includes('\\')) {
+ return fail(400, { error: 'Folder name cannot contain slashes' });
+ }
+
+ const { error } = await locals.supabase
+ .from('document_folders')
+ .update({ name })
+ .eq('id', folderId);
+
+ if (error) {
+ console.error('Rename folder error:', error);
+ if (error.code === '23505') {
+ return fail(400, { error: 'A folder with this name already exists here' });
+ }
+ return fail(500, { error: 'Failed to rename folder' });
+ }
+
+ return { success: 'Folder renamed!' };
+ },
+
+ deleteFolder: async ({ request, locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || member.role !== 'admin') {
+ return fail(403, { error: 'Only admins can delete folders' });
+ }
+
+ const formData = await request.formData();
+ const folderId = formData.get('folder_id') as string;
+
+ if (!folderId) {
+ return fail(400, { error: 'Folder ID is required' });
+ }
+
+ // Check if folder has documents
+ const { data: docs } = await locals.supabase
+ .from('documents')
+ .select('id')
+ .eq('folder_id', folderId)
+ .limit(1);
+
+ if (docs && docs.length > 0) {
+ return fail(400, { error: 'Cannot delete folder with documents. Move or delete documents first.' });
+ }
+
+ // Check if folder has subfolders
+ const { data: subfolders } = await locals.supabase
+ .from('document_folders')
+ .select('id')
+ .eq('parent_id', folderId)
+ .limit(1);
+
+ if (subfolders && subfolders.length > 0) {
+ return fail(400, { error: 'Cannot delete folder with subfolders. Delete subfolders first.' });
+ }
+
+ const { error } = await locals.supabase
+ .from('document_folders')
+ .delete()
+ .eq('id', folderId);
+
+ if (error) {
+ console.error('Delete folder error:', error);
+ return fail(500, { error: 'Failed to delete folder' });
+ }
+
+ return { success: 'Folder deleted!' };
+ },
+
+ moveDocument: async ({ request, locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || (member.role !== 'board' && member.role !== 'admin')) {
+ return fail(403, { error: 'You do not have permission to move documents' });
+ }
+
+ const formData = await request.formData();
+ const documentId = formData.get('document_id') as string;
+ const folderId = formData.get('folder_id') as string | null;
+
+ if (!documentId) {
+ return fail(400, { error: 'Document ID is required' });
+ }
+
+ const { error } = await locals.supabase
+ .from('documents')
+ .update({
+ folder_id: folderId || null,
+ updated_at: new Date().toISOString()
+ })
+ .eq('id', documentId);
+
+ if (error) {
+ console.error('Move document error:', error);
+ return fail(500, { error: 'Failed to move document' });
+ }
+
+ return { success: 'Document moved!' };
+ },
+
+ upload: async ({ request, locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || (member.role !== 'board' && member.role !== 'admin')) {
+ return fail(403, { error: 'You do not have permission to upload documents' });
+ }
+
+ const formData = await request.formData();
+ const file = formData.get('file') as File;
+ const title = formData.get('title') as string;
+ const description = formData.get('description') as string;
+ const categoryId = formData.get('category_id') as string;
+ const visibility = formData.get('visibility') as string;
+ const folderId = formData.get('folder_id') as string | null;
+
+ // Validation
+ if (!file || !file.size) {
+ return fail(400, { error: 'Please select a file to upload' });
+ }
+
+ if (!title) {
+ return fail(400, { error: 'Title is required' });
+ }
+
+ // Upload using dual-storage document service (uploads to both S3 and Supabase Storage)
+ const uploadResult = await uploadDocument(file);
+
+ if (!uploadResult.success) {
+ console.error('Upload error:', uploadResult.error);
+ return fail(500, { error: uploadResult.error || 'Failed to upload file. Please try again.' });
+ }
+
+ // Create document record with both URLs for storage flexibility
+ const { error: insertError } = await locals.supabase.from('documents').insert({
+ title,
+ description: description || null,
+ category_id: categoryId || null,
+ folder_id: folderId || null,
+ // Primary URL (computed based on active storage setting)
+ file_path: uploadResult.publicUrl || uploadResult.path,
+ // Dual storage URLs
+ file_url_local: uploadResult.localUrl || null,
+ file_url_s3: uploadResult.s3Url || null,
+ storage_path: uploadResult.path,
+ // File metadata
+ file_name: file.name,
+ file_size: file.size,
+ mime_type: file.type,
+ visibility: visibility || 'members',
+ uploaded_by: member.id
+ });
+
+ if (insertError) {
+ // Try to clean up uploaded files from both backends
+ if (uploadResult.path) {
+ await deleteDocument(uploadResult.path);
+ }
+ console.error('Insert error:', insertError);
+ return fail(500, { error: 'Failed to save document. Please try again.' });
+ }
+
+ return { success: 'Document uploaded successfully!' };
+ },
+
+ delete: async ({ request, locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || member.role !== 'admin') {
+ return fail(403, { error: 'Only admins can delete documents' });
+ }
+
+ const formData = await request.formData();
+ const documentId = formData.get('document_id') as string;
+
+ if (!documentId) {
+ return fail(400, { error: 'Document ID is required' });
+ }
+
+ // Get document to find storage path
+ const { data: doc } = await locals.supabase
+ .from('documents')
+ .select('storage_path, file_path')
+ .eq('id', documentId)
+ .single();
+
+ // Delete from database
+ const { error: deleteError } = await locals.supabase
+ .from('documents')
+ .delete()
+ .eq('id', documentId);
+
+ if (deleteError) {
+ console.error('Delete error:', deleteError);
+ return fail(500, { error: 'Failed to delete document' });
+ }
+
+ // Delete from ALL storage backends (both S3 and Supabase Storage)
+ if (doc?.storage_path) {
+ // Use the storage_path directly
+ await deleteDocument(doc.storage_path);
+ } else if (doc?.file_path) {
+ // Fallback for older documents without storage_path
+ try {
+ let storagePath = doc.file_path;
+
+ // If it's a URL, extract the path
+ if (doc.file_path.startsWith('http')) {
+ const url = new URL(doc.file_path);
+ // Handle Supabase storage URL format
+ const supabaseMatch = url.pathname.match(/\/storage\/v1\/object\/public\/documents\/(.+)/);
+ if (supabaseMatch) {
+ storagePath = supabaseMatch[1];
+ } else {
+ // Handle S3 URL format
+ const s3Match = url.pathname.match(/\/documents\/(.+)/);
+ if (s3Match) {
+ storagePath = s3Match[1];
+ }
+ }
+ }
+
+ await deleteDocument(storagePath);
+ } catch (e) {
+ console.error('Failed to delete file from storage:', e);
+ }
+ }
+
+ return { success: 'Document deleted successfully!' };
+ },
+
+ updateVisibility: async ({ request, locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || (member.role !== 'board' && member.role !== 'admin')) {
+ return fail(403, { error: 'You do not have permission to update documents' });
+ }
+
+ const formData = await request.formData();
+ const documentId = formData.get('document_id') as string;
+ const visibility = formData.get('visibility') as string;
+
+ if (!documentId || !visibility) {
+ return fail(400, { error: 'Document ID and visibility are required' });
+ }
+
+ const { error: updateError } = await locals.supabase
+ .from('documents')
+ .update({
+ visibility,
+ updated_at: new Date().toISOString()
+ })
+ .eq('id', documentId);
+
+ if (updateError) {
+ console.error('Update error:', updateError);
+ return fail(500, { error: 'Failed to update document' });
+ }
+
+ return { success: 'Visibility updated!' };
+ },
+
+ getPreviewUrl: async ({ request, locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member) {
+ return fail(401, { error: 'Not authenticated' });
+ }
+
+ const formData = await request.formData();
+ const documentId = formData.get('document_id') as string;
+
+ if (!documentId) {
+ return fail(400, { error: 'Document ID is required' });
+ }
+
+ // Get document with all URL columns
+ const { data: doc } = await locals.supabase
+ .from('documents')
+ .select('*')
+ .eq('id', documentId)
+ .single();
+
+ if (!doc) {
+ return fail(404, { error: 'Document not found' });
+ }
+
+ // Check visibility permissions
+ const canAccess =
+ doc.visibility === 'public' ||
+ (doc.visibility === 'members') ||
+ (doc.visibility === 'board' && ['board', 'admin'].includes(member.role)) ||
+ (doc.visibility === 'admin' && member.role === 'admin');
+
+ if (!canAccess) {
+ return fail(403, { error: 'You do not have permission to view this document' });
+ }
+
+ // Get the active URL based on current storage settings
+ const activeUrl = await getActiveDocumentUrl({
+ file_url_s3: doc.file_url_s3,
+ file_url_local: doc.file_url_local,
+ file_path: doc.file_path
+ });
+
+ // If we have a public URL, return it
+ if (activeUrl && activeUrl.startsWith('http')) {
+ return { previewUrl: activeUrl };
+ }
+
+ // Generate signed URL for private storage using storage_path or file_path
+ const storagePath = doc.storage_path || doc.file_path;
+ const { url, error } = await getSignedUrl('documents', storagePath, 3600);
+
+ if (error || !url) {
+ return fail(500, { error: 'Failed to generate preview URL' });
+ }
+
+ return { previewUrl: url };
+ }
+};
diff --git a/src/routes/(app)/board/documents/+page.svelte b/src/routes/(app)/board/documents/+page.svelte
new file mode 100644
index 0000000..2eb1750
--- /dev/null
+++ b/src/routes/(app)/board/documents/+page.svelte
@@ -0,0 +1,719 @@
+
+
+
+ Document Management | Monaco USA
+
+
+
+
+
+
+
Document Management
+
Upload and manage association documents
+
+
+
+ {#if canEdit}
+ (showCreateFolderModal = true)}
+ class="flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2 font-medium text-slate-700 hover:bg-slate-50"
+ >
+
+ New Folder
+
+ {/if}
+
+ (showUploadModal = true)}
+ class="flex items-center gap-2 rounded-lg bg-monaco-600 px-4 py-2 font-medium text-white hover:bg-monaco-700"
+ >
+
+ Upload Document
+
+
+
+
+ {#if form?.error}
+
+ {form.error}
+
+ {/if}
+
+ {#if form?.success}
+
+ {form.success}
+
+ {/if}
+
+
+ {#if breadcrumbs && breadcrumbs.length > 0}
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ All Categories
+ {#each categories as category}
+ {category.display_name}
+ {/each}
+
+
+
+
+
+ {#if filteredFolders.length > 0}
+
+
Folders
+
+ {#each filteredFolders as folder}
+
+ {/each}
+
+
+ {/if}
+
+
+
+ {#if filteredFolders.length === 0 && filteredDocuments.length === 0}
+
+
+
+ {currentFolderId ? 'This folder is empty' : 'No documents found'}
+
+
+ {searchQuery || selectedCategory
+ ? 'Try adjusting your search or filters.'
+ : currentFolderId
+ ? 'Upload documents or create subfolders to get started.'
+ : 'Upload your first document to get started.'}
+
+
+ {:else if filteredDocuments.length === 0}
+
+
+
No documents in this location
+
+ Documents you upload here will appear in this list.
+
+
+ {:else}
+
+
+ Documents ({filteredDocuments.length})
+
+
+
+
+
+
+ Document
+
+
+ Category
+
+
+ Visibility
+
+
+ Uploaded
+
+
+ Actions
+
+
+
+
+ {#each filteredDocuments as doc}
+ {@const visInfo = getVisibilityLabel(doc.visibility)}
+
+
+ openPreview(doc)}
+ class="flex items-center gap-3 text-left hover:text-monaco-600 transition-colors"
+ >
+
+
+
{doc.title}
+
+ {doc.file_name} ({formatFileSize(doc.file_size)})
+
+
+
+
+
+ {doc.category?.display_name || '-'}
+
+
+ {
+ return async ({ update }) => {
+ await invalidateAll();
+ await update();
+ };
+ }}
+ class="inline"
+ >
+
+ e.currentTarget.form?.requestSubmit()}
+ class="rounded-lg border-0 bg-transparent py-1 pr-8 text-xs font-medium cursor-pointer hover:bg-slate-100 {visInfo.color}"
+ >
+ Public
+ Members
+ Board
+ Admin
+
+
+
+
+
+
{formatDate(doc.created_at)}
+
+ by {doc.uploader?.first_name} {doc.uploader?.last_name}
+
+
+
+
+
+
openPreview(doc)}
+ class="rounded p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
+ title="Preview"
+ >
+
+
+
+
+
+ {#if canDelete}
+
confirmDelete(doc)}
+ class="rounded p-1.5 text-slate-400 hover:bg-red-100 hover:text-red-600"
+ title="Delete"
+ >
+
+
+ {/if}
+
+
+
+ {/each}
+
+
+
+
+ {/if}
+
+
+
+
+{#if showCreateFolderModal}
+ (showCreateFolderModal = false)}
+ />
+{/if}
+
+
+{#if showPreviewModal && documentToPreview}
+ {
+ showPreviewModal = false;
+ documentToPreview = null;
+ }}
+ />
+{/if}
+
+
+{#if showUploadModal}
+
+
+
+
Upload Document
+
+
+
+
+
+
{
+ isSubmitting = true;
+ return async ({ update, result }) => {
+ await invalidateAll();
+ isSubmitting = false;
+ if (result.type === 'success') {
+ resetUploadForm();
+ }
+ await update();
+ };
+ }}
+ class="space-y-4"
+ >
+
+ {#if currentFolderId}
+
+ {/if}
+
+
+
+
+
+ {#if selectedFile}
+
+
+
+
{selectedFile.name}
+
{formatFileSize(selectedFile.size)}
+
+
{
+ e.stopPropagation();
+ selectedFile = null;
+ }}
+ class="rounded p-1 hover:bg-slate-100"
+ >
+
+
+
+ {:else}
+
+
+ Drag and drop or click to select
+
+
+ PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT, CSV, JSON, JPG, PNG, GIF (max 50MB)
+
+ {/if}
+
+
+
+ Title *
+
+
+
+
+ Description
+
+
+
+
+
+ Category
+
+ Select category...
+ {#each categories as category}
+ {category.display_name}
+ {/each}
+
+
+
+
+ Visibility
+
+ Members Only
+ Board Only
+ Admin Only
+ Public
+
+
+
+
+
+
+ Cancel
+
+
+ {#if isSubmitting}
+
+ Uploading...
+ {:else}
+
+ Upload Document
+ {/if}
+
+
+
+
+
+{/if}
+
+
+{#if showDeleteConfirm && documentToDelete}
+
+
+
+
+
+ Delete {documentToDelete.isFolder ? 'Folder' : 'Document'}
+
+
+
+
+ Are you sure you want to delete {documentToDelete.title || documentToDelete.name} ? This action cannot be undone.
+
+
+
+ {
+ showDeleteConfirm = false;
+ documentToDelete = null;
+ }}
+ class="flex-1 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
+ >
+ Cancel
+
+
{
+ return async ({ update, result }) => {
+ if (result.type === 'success') {
+ showDeleteConfirm = false;
+ documentToDelete = null;
+ }
+ await invalidateAll();
+ await update();
+ };
+ }}
+ class="flex-1"
+ >
+
+
+ Delete {documentToDelete.isFolder ? 'Folder' : 'Document'}
+
+
+
+
+
+{/if}
+
+
+{#if showRenameFolderModal && renamingFolder}
+
+
+
+
Rename Folder
+ {
+ showRenameFolderModal = false;
+ renamingFolder = null;
+ }}
+ class="rounded p-1 hover:bg-slate-100"
+ >
+
+
+
+
+
{
+ isSubmitting = true;
+ return async ({ update, result }) => {
+ await invalidateAll();
+ isSubmitting = false;
+ if (result.type === 'success') {
+ showRenameFolderModal = false;
+ renamingFolder = null;
+ }
+ await update();
+ };
+ }}
+ class="space-y-4"
+ >
+
+
+
+ Folder Name
+
+
+
+
+
{
+ showRenameFolderModal = false;
+ renamingFolder = null;
+ }}
+ class="flex-1 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
+ >
+ Cancel
+
+
+ {#if isSubmitting}
+
+ Saving...
+ {:else}
+ Save
+ {/if}
+
+
+
+
+
+{/if}
diff --git a/src/routes/(app)/board/dues/+page.server.ts b/src/routes/(app)/board/dues/+page.server.ts
new file mode 100644
index 0000000..b6d6bb4
--- /dev/null
+++ b/src/routes/(app)/board/dues/+page.server.ts
@@ -0,0 +1,345 @@
+import { fail } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+import { sendTemplatedEmail } from '$lib/server/email';
+import { logPaymentAction } from '$lib/server/audit';
+import {
+ sendBulkReminders,
+ sendDuesReminder,
+ getMembersNeedingReminder,
+ getDuesSettings,
+ type ReminderType
+} from '$lib/server/dues';
+import { supabaseAdmin } from '$lib/server/supabase';
+
+export const load: PageServerLoad = async ({ locals, url }) => {
+ const statusFilter = url.searchParams.get('status') || 'all';
+ const searchQuery = url.searchParams.get('search') || '';
+ const memberId = url.searchParams.get('member') || null;
+
+ // If specific member requested, load their details
+ let selectedMember = null;
+ if (memberId) {
+ const { data } = await locals.supabase
+ .from('members_with_dues')
+ .select('*')
+ .eq('id', memberId)
+ .single();
+ selectedMember = data;
+ }
+
+ // Load all members with dues status
+ const { data: members } = await locals.supabase
+ .from('members_with_dues')
+ .select('*')
+ .order('last_name', { ascending: true });
+
+ // Filter by dues status
+ let filteredMembers = members || [];
+ if (statusFilter !== 'all') {
+ filteredMembers = filteredMembers.filter((m: any) => m.dues_status === statusFilter);
+ }
+
+ // Filter by search
+ if (searchQuery) {
+ const lowerSearch = searchQuery.toLowerCase();
+ filteredMembers = filteredMembers.filter(
+ (m: any) =>
+ m.first_name?.toLowerCase().includes(lowerSearch) ||
+ m.last_name?.toLowerCase().includes(lowerSearch) ||
+ m.member_id?.toLowerCase().includes(lowerSearch)
+ );
+ }
+
+ // Calculate stats
+ const stats = {
+ total: members?.length || 0,
+ current: members?.filter((m: any) => m.dues_status === 'current').length || 0,
+ dueSoon: members?.filter((m: any) => m.dues_status === 'due_soon').length || 0,
+ overdue: members?.filter((m: any) => m.dues_status === 'overdue').length || 0,
+ neverPaid: members?.filter((m: any) => m.dues_status === 'never_paid').length || 0
+ };
+
+ // Get recent payments
+ const { data: recentPayments } = await locals.supabase
+ .from('dues_payments')
+ .select(
+ `
+ *,
+ member:members(id, first_name, last_name, member_id),
+ recorder:members!dues_payments_recorded_by_fkey(first_name, last_name)
+ `
+ )
+ .order('created_at', { ascending: false })
+ .limit(10);
+
+ // Get membership types for payment recording
+ const { data: membershipTypes } = await locals.supabase
+ .from('membership_types')
+ .select('*')
+ .eq('is_active', true)
+ .order('sort_order', { ascending: true });
+
+ return {
+ members: filteredMembers,
+ selectedMember,
+ stats,
+ recentPayments: recentPayments || [],
+ membershipTypes: membershipTypes || [],
+ filters: {
+ status: statusFilter,
+ search: searchQuery,
+ memberId
+ }
+ };
+};
+
+export const actions: Actions = {
+ recordPayment: async ({ request, locals, url }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || (member.role !== 'board' && member.role !== 'admin')) {
+ return fail(403, { error: 'You do not have permission to record payments' });
+ }
+
+ const formData = await request.formData();
+ const memberId = formData.get('member_id') as string;
+ const amount = parseFloat(formData.get('amount') as string);
+ const paymentDate = formData.get('payment_date') as string;
+ const paymentMethod = formData.get('payment_method') as string;
+ const reference = formData.get('reference') as string;
+ const notes = formData.get('notes') as string;
+ const sendNotification = formData.get('send_notification') === 'on';
+
+ if (!memberId || !amount || !paymentDate) {
+ return fail(400, { error: 'Member, amount, and payment date are required' });
+ }
+
+ // Get member details for notification
+ const { data: payingMember } = await locals.supabase
+ .from('members')
+ .select('first_name, last_name, email')
+ .eq('id', memberId)
+ .single();
+
+ // Calculate due date (1 year from payment date)
+ const dueDate = new Date(paymentDate);
+ dueDate.setFullYear(dueDate.getFullYear() + 1);
+ const dueDateStr = dueDate.toISOString().split('T')[0];
+
+ // Record the payment
+ const { data: paymentData, error: paymentError } = await locals.supabase
+ .from('dues_payments')
+ .insert({
+ member_id: memberId,
+ amount,
+ payment_date: paymentDate,
+ due_date: dueDateStr,
+ payment_method: paymentMethod || 'bank_transfer',
+ reference: reference || null,
+ notes: notes || null,
+ recorded_by: member.id
+ })
+ .select('id')
+ .single();
+
+ if (paymentError) {
+ console.error('Payment recording error:', paymentError);
+ return fail(500, { error: 'Failed to record payment. Please try again.' });
+ }
+
+ // Log audit
+ await logPaymentAction(
+ 'record',
+ { id: member.id, email: member.email },
+ { id: paymentData?.id, memberId, amount },
+ { payment_date: paymentDate, payment_method: paymentMethod }
+ );
+
+ // Send email notification if requested
+ if (sendNotification && payingMember?.email) {
+ const formattedDate = new Date(paymentDate).toLocaleDateString('en-US', {
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric'
+ });
+ const formattedDueDate = dueDate.toLocaleDateString('en-US', {
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric'
+ });
+
+ await sendTemplatedEmail(
+ 'payment_received',
+ payingMember.email,
+ {
+ first_name: payingMember.first_name,
+ amount: amount.toFixed(2),
+ payment_date: formattedDate,
+ reference: reference || 'N/A',
+ due_date: formattedDueDate
+ },
+ {
+ recipientId: memberId,
+ recipientName: `${payingMember.first_name} ${payingMember.last_name}`,
+ sentBy: member.id,
+ baseUrl: url.origin
+ }
+ );
+ }
+
+ return { success: 'Payment recorded successfully!' + (sendNotification ? ' Notification sent.' : '') };
+ },
+
+ /**
+ * Send individual reminder to a specific member
+ */
+ sendReminder: async ({ request, locals, url }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || (member.role !== 'board' && member.role !== 'admin')) {
+ return fail(403, { error: 'You do not have permission to send reminders' });
+ }
+
+ const formData = await request.formData();
+ const memberId = formData.get('member_id') as string;
+ const reminderType = formData.get('reminder_type') as ReminderType;
+
+ if (!memberId) {
+ return fail(400, { error: 'Member ID is required' });
+ }
+
+ // Get member details
+ const { data: targetMember } = await supabaseAdmin
+ .from('members_with_dues')
+ .select('*')
+ .eq('id', memberId)
+ .single();
+
+ if (!targetMember) {
+ return fail(404, { error: 'Member not found' });
+ }
+
+ // Determine the appropriate reminder type based on their status
+ let effectiveReminderType = reminderType;
+ if (!effectiveReminderType) {
+ if (targetMember.dues_status === 'overdue') {
+ effectiveReminderType = 'overdue';
+ } else if (targetMember.dues_status === 'due_soon') {
+ effectiveReminderType = 'due_soon_7';
+ } else {
+ effectiveReminderType = 'due_soon_30';
+ }
+ }
+
+ const result = await sendDuesReminder(targetMember, effectiveReminderType, url.origin);
+
+ if (!result.success) {
+ return fail(500, { error: result.error || 'Failed to send reminder' });
+ }
+
+ return {
+ success: `Reminder sent to ${targetMember.first_name} ${targetMember.last_name}`
+ };
+ },
+
+ /**
+ * Send bulk reminders to all members in due_soon status
+ */
+ sendBulkDueSoonReminders: async ({ locals, url }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || (member.role !== 'board' && member.role !== 'admin')) {
+ return fail(403, { error: 'You do not have permission to send bulk reminders' });
+ }
+
+ const results = {
+ due_soon_30: { sent: 0, errors: [] as string[] },
+ due_soon_7: { sent: 0, errors: [] as string[] },
+ due_soon_1: { sent: 0, errors: [] as string[] }
+ };
+
+ // Get settings to determine which reminder tiers to process
+ const settings = await getDuesSettings();
+ const reminderDays = settings.reminder_days_before || [30, 7, 1];
+
+ let totalSent = 0;
+ const allErrors: string[] = [];
+
+ for (const days of reminderDays) {
+ const reminderType = `due_soon_${days}` as ReminderType;
+ const result = await sendBulkReminders(reminderType, url.origin);
+
+ results[reminderType as keyof typeof results] = {
+ sent: result.sent,
+ errors: result.errors
+ };
+ totalSent += result.sent;
+ allErrors.push(...result.errors);
+ }
+
+ if (totalSent === 0 && allErrors.length === 0) {
+ return { success: 'No members need due soon reminders at this time.' };
+ }
+
+ const errorMsg = allErrors.length > 0 ? ` (${allErrors.length} errors)` : '';
+ return {
+ success: `Sent ${totalSent} due soon reminder(s)${errorMsg}`,
+ bulkResult: results
+ };
+ },
+
+ /**
+ * Send bulk reminders to all overdue members
+ */
+ sendBulkOverdueReminders: async ({ locals, url }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || (member.role !== 'board' && member.role !== 'admin')) {
+ return fail(403, { error: 'You do not have permission to send bulk reminders' });
+ }
+
+ const result = await sendBulkReminders('overdue', url.origin);
+
+ if (result.sent === 0 && result.errors.length === 0) {
+ return { success: 'No overdue members need reminders at this time.' };
+ }
+
+ const errorMsg = result.errors.length > 0 ? ` (${result.errors.length} errors)` : '';
+ return {
+ success: `Sent ${result.sent} overdue reminder(s)${errorMsg}`,
+ bulkResult: result
+ };
+ },
+
+ /**
+ * Get preview counts for bulk operations
+ */
+ getBulkPreview: async ({ locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || (member.role !== 'board' && member.role !== 'admin')) {
+ return fail(403, { error: 'Unauthorized' });
+ }
+
+ const settings = await getDuesSettings();
+ const reminderDays = settings.reminder_days_before || [30, 7, 1];
+
+ const preview = {
+ dueSoon: 0,
+ overdue: 0,
+ breakdown: {} as Record
+ };
+
+ for (const days of reminderDays) {
+ const reminderType = `due_soon_${days}` as ReminderType;
+ const members = await getMembersNeedingReminder(reminderType);
+ preview.breakdown[reminderType] = members.length;
+ preview.dueSoon += members.length;
+ }
+
+ const overdueMembers = await getMembersNeedingReminder('overdue');
+ preview.overdue = overdueMembers.length;
+
+ return { preview };
+ }
+};
diff --git a/src/routes/(app)/board/dues/+page.svelte b/src/routes/(app)/board/dues/+page.svelte
new file mode 100644
index 0000000..f787000
--- /dev/null
+++ b/src/routes/(app)/board/dues/+page.svelte
@@ -0,0 +1,679 @@
+
+
+
+ Dues Management | Monaco USA
+
+
+
+
+
+
Dues Management
+
Track and record membership dues payments
+
+
+
+
+ Reports
+
+
openBulkModal('dueSoon')}
+ disabled={stats.dueSoon === 0}
+ class="flex items-center gap-1.5 rounded-lg border border-yellow-200 bg-yellow-50 px-3 py-1.5 text-sm font-medium text-yellow-700 hover:bg-yellow-100 disabled:cursor-not-allowed disabled:opacity-50"
+ >
+
+ Remind Due Soon ({stats.dueSoon})
+
+
openBulkModal('overdue')}
+ disabled={stats.overdue === 0}
+ class="flex items-center gap-1.5 rounded-lg border border-red-200 bg-red-50 px-3 py-1.5 text-sm font-medium text-red-700 hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50"
+ >
+
+ Remind Overdue ({stats.overdue})
+
+
+
+
+
+
+
+
+
+
+
+
+
{stats.total}
+
Total
+
+
+
+
+
+
+
+
+
+
{stats.current}
+
Current
+
+
+
+
+
+
+
+
+
+
{stats.dueSoon}
+
Due Soon
+
+
+
+
+
+
+
+
{stats.overdue}
+
Overdue
+
+
+
+
+
+
+
+
+
+
{stats.neverPaid}
+
Never Paid
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ searchQuery = e.currentTarget.value;
+ handleSearch(e.currentTarget.value);
+ }}
+ class="h-10 pl-9"
+ />
+
+
+
updateFilters({ status: statusFilter })}
+ class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
+ >
+ All Status
+ Current
+ Due Soon
+ Overdue
+ Never Paid
+
+
+
+
+ {#if members.length === 0}
+
+
+
No members found
+
Try adjusting your search or filters.
+
+ {:else}
+
+ {#each members as member}
+ {@const duesInfo = getDuesInfo(member.dues_status)}
+
+
+ {#if member.avatar_url}
+
+ {:else}
+
+ {member.first_name?.[0]}{member.last_name?.[0]}
+
+ {/if}
+
+
+ {member.first_name} {member.last_name}
+
+
{member.member_id}
+
+
+
+
+
+
+
+ {duesInfo.label}
+
+ {#if member.current_due_date}
+
+ {member.dues_status === 'overdue'
+ ? `${member.days_overdue} days overdue`
+ : `Due: ${formatDate(member.current_due_date)}`}
+
+ {/if}
+
+
+ {#if member.dues_status !== 'current' && member.dues_status !== 'never_paid'}
+
{
+ sendingReminderId = member.id;
+ return async ({ update }) => {
+ sendingReminderId = null;
+ await update();
+ await invalidateAll();
+ };
+ }}
+ >
+
+
+ {#if sendingReminderId === member.id}
+
+ Sending...
+ {:else}
+
+ Remind
+ {/if}
+
+
+ {/if}
+
+
openPaymentModalWithReset(member)}
+ class="flex items-center gap-1.5 rounded-lg bg-monaco-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-monaco-700"
+ >
+
+ Record
+
+
+
+ {/each}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+ Recent Payments
+
+
+
+ {#if recentPayments.length === 0}
+
+
No payments recorded yet.
+
+ {:else}
+
+ {#each recentPayments as payment}
+
+
+
+
+ {payment.member?.first_name} {payment.member?.last_name}
+
+
{payment.member?.member_id}
+
+
€{payment.amount.toFixed(2)}
+
+
+ {formatDate(payment.payment_date)}
+ by {payment.recorder?.first_name}
+
+
+ {/each}
+
+ {/if}
+
+
+
+
+
+
+{#if showPaymentModal && selectedMemberForPayment}
+
+
+
+
Record Payment
+
+
+
+
+
+
+
+ {#if selectedMemberForPayment.avatar_url}
+
+ {:else}
+
+ {selectedMemberForPayment.first_name?.[0]}{selectedMemberForPayment.last_name?.[0]}
+
+ {/if}
+
+
+ {selectedMemberForPayment.first_name} {selectedMemberForPayment.last_name}
+
+
+ {selectedMemberForPayment.member_id} •
+ {selectedMemberForPayment.membership_type_name || 'Regular Member'}
+
+
+
+
+ {#if form?.error}
+
+ {form.error}
+
+ {/if}
+
+ {#if form?.success}
+
+ {form.success}
+
+ {/if}
+
+
{
+ isSubmitting = true;
+ return async ({ update, result }) => {
+ isSubmitting = false;
+ if (result.type === 'success') {
+ await invalidateAll();
+ setTimeout(closePaymentModal, 1500);
+ }
+ await update();
+ };
+ }}
+ class="space-y-4"
+ >
+
+
+
+ Amount (€)
+
+
+
+
+
+
+ Payment Method
+
+ Bank Transfer
+ Cash
+ Check
+ Other
+
+
+
+
+ Reference (optional)
+
+
+
+
+ Notes (optional)
+
+
+
+
+
+ Cancel
+
+
+ {#if isSubmitting}
+
+ Recording...
+ {:else}
+
+ Record Payment
+ {/if}
+
+
+
+
+
+{/if}
+
+
+{#if showBulkModal && bulkActionType}
+
+
+
+
+ {bulkActionType === 'dueSoon' ? 'Send Due Soon Reminders' : 'Send Overdue Reminders'}
+
+
+
+
+
+
+ {#if bulkResult}
+
+
+
+
{bulkResult.success || bulkResult}
+
+
+ Close
+
+
+ {:else}
+
+
+
+
+ {#if bulkActionType === 'dueSoon'}
+
+ {:else}
+
+ {/if}
+
+
+ {bulkActionType === 'dueSoon' ? stats.dueSoon : stats.overdue} member(s) will receive reminders
+
+
+ {bulkActionType === 'dueSoon'
+ ? 'Members with dues due within 30 days'
+ : 'Members with overdue payments'}
+
+
+
+
+
+
+ This will send email reminders to all eligible members who haven't already received a reminder for their current dues period.
+
+
+
{
+ isBulkSubmitting = true;
+ return async ({ result, update }) => {
+ isBulkSubmitting = false;
+ if (result.type === 'success' && result.data) {
+ bulkResult = result.data;
+ await invalidateAll();
+ }
+ await update();
+ };
+ }}
+ >
+
+
+ Cancel
+
+
+ {#if isBulkSubmitting}
+
+ Sending...
+ {:else}
+
+ Send Reminders
+ {/if}
+
+
+
+
+ {/if}
+
+
+{/if}
+
+
+{#if form?.success && !showPaymentModal && !showBulkModal}
+
+{/if}
+
+{#if form?.error && !showPaymentModal && !showBulkModal}
+
+{/if}
diff --git a/src/routes/(app)/board/dues/reports/+page.server.ts b/src/routes/(app)/board/dues/reports/+page.server.ts
new file mode 100644
index 0000000..8f2a4cb
--- /dev/null
+++ b/src/routes/(app)/board/dues/reports/+page.server.ts
@@ -0,0 +1,169 @@
+import { fail } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+import { getDuesAnalytics, getDuesReportData, getReminderEffectiveness } from '$lib/server/dues';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || (member.role !== 'board' && member.role !== 'admin')) {
+ return { error: 'Unauthorized' };
+ }
+
+ // Load analytics data
+ const analytics = await getDuesAnalytics();
+ const effectiveness = await getReminderEffectiveness();
+
+ return {
+ analytics,
+ effectiveness
+ };
+};
+
+export const actions: Actions = {
+ /**
+ * Export members report as CSV
+ */
+ exportMembers: async ({ locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || (member.role !== 'board' && member.role !== 'admin')) {
+ return fail(403, { error: 'Unauthorized' });
+ }
+
+ const data = await getDuesReportData();
+
+ // Generate CSV
+ const headers = [
+ 'Member ID',
+ 'Name',
+ 'Email',
+ 'Membership Type',
+ 'Status',
+ 'Dues Status',
+ 'Annual Dues',
+ 'Last Payment',
+ 'Due Date',
+ 'Days Overdue'
+ ];
+
+ const rows = data.members.map((m) => [
+ m.member_id,
+ m.name,
+ m.email,
+ m.membership_type,
+ m.status,
+ m.dues_status,
+ m.annual_dues.toFixed(2),
+ m.last_payment_date || 'Never',
+ m.current_due_date || 'N/A',
+ m.days_overdue?.toString() || '0'
+ ]);
+
+ const csv = [headers.join(','), ...rows.map((r) => r.map(escapeCSV).join(','))].join('\n');
+
+ return {
+ csv,
+ filename: `dues-members-${new Date().toISOString().split('T')[0]}.csv`
+ };
+ },
+
+ /**
+ * Export payments report as CSV
+ */
+ exportPayments: async ({ locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || (member.role !== 'board' && member.role !== 'admin')) {
+ return fail(403, { error: 'Unauthorized' });
+ }
+
+ const data = await getDuesReportData();
+
+ // Generate CSV
+ const headers = [
+ 'Member ID',
+ 'Member Name',
+ 'Amount',
+ 'Payment Date',
+ 'Payment Method',
+ 'Reference',
+ 'Recorded By'
+ ];
+
+ const rows = data.payments.map((p) => [
+ p.member_id,
+ p.member_name,
+ p.amount.toFixed(2),
+ p.payment_date,
+ p.payment_method,
+ p.reference || '',
+ p.recorded_by || ''
+ ]);
+
+ const csv = [headers.join(','), ...rows.map((r) => r.map(escapeCSV).join(','))].join('\n');
+
+ return {
+ csv,
+ filename: `dues-payments-${new Date().toISOString().split('T')[0]}.csv`
+ };
+ },
+
+ /**
+ * Export overdue members report as CSV
+ */
+ exportOverdue: async ({ locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || (member.role !== 'board' && member.role !== 'admin')) {
+ return fail(403, { error: 'Unauthorized' });
+ }
+
+ const data = await getDuesReportData();
+
+ // Filter to overdue only
+ const overdueMembers = data.members.filter(
+ (m) => m.dues_status === 'overdue' || m.dues_status === 'never_paid'
+ );
+
+ // Generate CSV
+ const headers = [
+ 'Member ID',
+ 'Name',
+ 'Email',
+ 'Membership Type',
+ 'Dues Status',
+ 'Amount Owed',
+ 'Due Date',
+ 'Days Overdue'
+ ];
+
+ const rows = overdueMembers.map((m) => [
+ m.member_id,
+ m.name,
+ m.email,
+ m.membership_type,
+ m.dues_status,
+ m.annual_dues.toFixed(2),
+ m.current_due_date || 'N/A',
+ m.days_overdue?.toString() || 'N/A'
+ ]);
+
+ const csv = [headers.join(','), ...rows.map((r) => r.map(escapeCSV).join(','))].join('\n');
+
+ return {
+ csv,
+ filename: `dues-overdue-${new Date().toISOString().split('T')[0]}.csv`
+ };
+ }
+};
+
+/**
+ * Escape a value for CSV
+ */
+function escapeCSV(value: string | number): string {
+ const str = String(value);
+ if (str.includes(',') || str.includes('"') || str.includes('\n')) {
+ return `"${str.replace(/"/g, '""')}"`;
+ }
+ return str;
+}
diff --git a/src/routes/(app)/board/dues/reports/+page.svelte b/src/routes/(app)/board/dues/reports/+page.svelte
new file mode 100644
index 0000000..b4ff149
--- /dev/null
+++ b/src/routes/(app)/board/dues/reports/+page.svelte
@@ -0,0 +1,346 @@
+
+
+
+ Dues Reports | Monaco USA
+
+
+
+
+
+
+
+
+
+
+
Dues Reports
+
Analytics and financial reporting
+
+
+
+
+
+
+
+
+
+
Collected This Month
+
+ {formatCurrency(analytics?.totalCollectedThisMonth || 0)}
+
+
+
+
+
+
+
+
+
+
+
+
Collected This Year
+
+ {formatCurrency(analytics?.totalCollectedThisYear || 0)}
+
+
+
+
+
+
+
+
+
+
+
+
Outstanding
+
+ {formatCurrency(analytics?.totalOutstanding || 0)}
+
+
+
+
+
+
+
+
+
+
Reminders Sent
+
+ {analytics?.remindersSentThisMonth || 0}
+
+
This month
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Collection Trend (12 Months)
+
+
+
+
+ {#if analytics?.paymentsByMonth && analytics.paymentsByMonth.length > 0}
+
+ {#each analytics.paymentsByMonth as month, i}
+
+
+
+ {month.month.split(' ')[0].slice(0, 3)}
+
+
+ {/each}
+
+ {:else}
+
+
No payment data available
+
+ {/if}
+
+
+
+
+
+
+
+
+ Member Status Breakdown
+
+
+
+
+ {#if analytics?.statusBreakdown}
+ {@const statusConfig = {
+ current: { icon: CheckCircle2, color: 'bg-green-500', textColor: 'text-green-600', label: 'Current' },
+ due_soon: { icon: Clock, color: 'bg-yellow-500', textColor: 'text-yellow-600', label: 'Due Soon' },
+ overdue: { icon: AlertCircle, color: 'bg-red-500', textColor: 'text-red-600', label: 'Overdue' },
+ never_paid: { icon: XCircle, color: 'bg-slate-400', textColor: 'text-slate-500', label: 'Never Paid' }
+ }}
+ {#each analytics.statusBreakdown as status}
+ {@const config = statusConfig[status.status as keyof typeof statusConfig]}
+
+
+
+
+ {config.label}
+
+ {status.count} ({status.percentage.toFixed(1)}%)
+
+
+
+
+
+ {/each}
+ {/if}
+
+
+
+
+ Total Members
+ {analytics?.totalMembers || 0}
+
+
+
+
+
+
+
+
+
+
+ Reminder Effectiveness
+
+
+
+
+
+
Total Reminders Sent
+
{effectiveness?.totalRemindersSent || 0}
+
+
+
Paid Within 7 Days
+
{effectiveness?.paidWithin7Days || 0}
+
+
+
Paid Within 30 Days
+
{effectiveness?.paidWithin30Days || 0}
+
+
+
Effectiveness Rate
+
+ {(effectiveness?.effectivenessRate || 0).toFixed(1)}%
+
+
+
+
+
+
+
+
+
+
+ Export Reports
+
+
Download detailed reports as CSV files
+
+
+
+
{
+ isExporting = 'members';
+ return async ({ update }) => {
+ isExporting = null;
+ await update();
+ };
+ }}
+ >
+
+ {#if isExporting === 'members'}
+
+ {:else}
+
+ {/if}
+ All Members Report
+
+
+
+
{
+ isExporting = 'payments';
+ return async ({ update }) => {
+ isExporting = null;
+ await update();
+ };
+ }}
+ >
+
+ {#if isExporting === 'payments'}
+
+ {:else}
+
+ {/if}
+ Payment History
+
+
+
+
{
+ isExporting = 'overdue';
+ return async ({ update }) => {
+ isExporting = null;
+ await update();
+ };
+ }}
+ >
+
+ {#if isExporting === 'overdue'}
+
+ {:else}
+
+ {/if}
+ Overdue Members
+
+
+
+
+
diff --git a/src/routes/(app)/board/events/+page.server.ts b/src/routes/(app)/board/events/+page.server.ts
new file mode 100644
index 0000000..a70f45b
--- /dev/null
+++ b/src/routes/(app)/board/events/+page.server.ts
@@ -0,0 +1,161 @@
+import { fail } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async ({ locals, url }) => {
+ const statusFilter = url.searchParams.get('status') || 'all';
+
+ // Load events with counts
+ let query = locals.supabase
+ .from('events_with_counts')
+ .select('*')
+ .order('start_datetime', { ascending: true });
+
+ if (statusFilter !== 'all') {
+ query = query.eq('status', statusFilter);
+ }
+
+ const { data: events } = await query;
+
+ // Load event types
+ const { data: eventTypes } = await locals.supabase
+ .from('event_types')
+ .select('*')
+ .eq('is_active', true)
+ .order('sort_order', { ascending: true });
+
+ // Calculate stats
+ const now = new Date();
+ const stats = {
+ total: events?.length || 0,
+ upcoming: events?.filter((e: any) => new Date(e.start_datetime) > now && e.status === 'published').length || 0,
+ draft: events?.filter((e: any) => e.status === 'draft').length || 0,
+ past: events?.filter((e: any) => new Date(e.end_datetime) < now).length || 0
+ };
+
+ return {
+ events: events || [],
+ eventTypes: eventTypes || [],
+ stats,
+ filters: {
+ status: statusFilter
+ }
+ };
+};
+
+export const actions: Actions = {
+ create: async ({ request, locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || (member.role !== 'board' && member.role !== 'admin')) {
+ return fail(403, { error: 'You do not have permission to create events' });
+ }
+
+ const formData = await request.formData();
+
+ const title = formData.get('title') as string;
+ const description = formData.get('description') as string;
+ const eventTypeId = formData.get('event_type_id') as string;
+ const startDate = formData.get('start_date') as string;
+ const startTime = formData.get('start_time') as string;
+ const endDate = formData.get('end_date') as string;
+ const endTime = formData.get('end_time') as string;
+ const location = formData.get('location') as string;
+ const maxAttendees = formData.get('max_attendees') as string;
+ const maxGuests = formData.get('max_guests_per_member') as string;
+ const isPaid = formData.get('is_paid') === 'true';
+ const memberPrice = formData.get('member_price') as string;
+ const nonMemberPrice = formData.get('non_member_price') as string;
+ const visibility = formData.get('visibility') as string;
+ const status = formData.get('status') as string;
+
+ // Validation
+ if (!title || !startDate || !startTime || !endDate || !endTime) {
+ return fail(400, { error: 'Title, start date/time, and end date/time are required' });
+ }
+
+ // Construct datetime strings
+ const startDatetime = `${startDate}T${startTime}:00`;
+ const endDatetime = `${endDate}T${endTime}:00`;
+
+ // Validate end is after start
+ if (new Date(endDatetime) <= new Date(startDatetime)) {
+ return fail(400, { error: 'End date/time must be after start date/time' });
+ }
+
+ const { error: insertError } = await locals.supabase.from('events').insert({
+ title,
+ description: description || null,
+ event_type_id: eventTypeId || null,
+ start_datetime: startDatetime,
+ end_datetime: endDatetime,
+ location: location || null,
+ max_attendees: maxAttendees ? parseInt(maxAttendees) : null,
+ max_guests_per_member: maxGuests ? parseInt(maxGuests) : 1,
+ is_paid: isPaid,
+ member_price: isPaid && memberPrice ? parseFloat(memberPrice) : 0,
+ non_member_price: isPaid && nonMemberPrice ? parseFloat(nonMemberPrice) : 0,
+ visibility: visibility || 'members',
+ status: status || 'published',
+ created_by: member.id
+ });
+
+ if (insertError) {
+ console.error('Event creation error:', insertError);
+ return fail(500, { error: 'Failed to create event. Please try again.' });
+ }
+
+ return { success: 'Event created successfully!' };
+ },
+
+ delete: async ({ request, locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || member.role !== 'admin') {
+ return fail(403, { error: 'Only admins can delete events' });
+ }
+
+ const formData = await request.formData();
+ const eventId = formData.get('event_id') as string;
+
+ if (!eventId) {
+ return fail(400, { error: 'Event ID is required' });
+ }
+
+ const { error: deleteError } = await locals.supabase.from('events').delete().eq('id', eventId);
+
+ if (deleteError) {
+ console.error('Event deletion error:', deleteError);
+ return fail(500, { error: 'Failed to delete event. Please try again.' });
+ }
+
+ return { success: 'Event deleted successfully!' };
+ },
+
+ updateStatus: async ({ request, locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || (member.role !== 'board' && member.role !== 'admin')) {
+ return fail(403, { error: 'You do not have permission to update events' });
+ }
+
+ const formData = await request.formData();
+ const eventId = formData.get('event_id') as string;
+ const status = formData.get('status') as string;
+
+ if (!eventId || !status) {
+ return fail(400, { error: 'Event ID and status are required' });
+ }
+
+ const { error: updateError } = await locals.supabase
+ .from('events')
+ .update({ status, updated_at: new Date().toISOString() })
+ .eq('id', eventId);
+
+ if (updateError) {
+ console.error('Event status update error:', updateError);
+ return fail(500, { error: 'Failed to update event status. Please try again.' });
+ }
+
+ return { success: 'Event status updated!' };
+ }
+};
diff --git a/src/routes/(app)/board/events/+page.svelte b/src/routes/(app)/board/events/+page.svelte
new file mode 100644
index 0000000..9d88e83
--- /dev/null
+++ b/src/routes/(app)/board/events/+page.svelte
@@ -0,0 +1,580 @@
+
+
+
+ Event Management | Monaco USA
+
+
+
+
+
+
Event Management
+
Create and manage association events
+
+
+
+
+ Create Event
+
+
+
+
+
+
+
+
+
+
+
+
{stats.total}
+
Total Events
+
+
+
+
+
+
+
+
+
+
{stats.upcoming}
+
Upcoming
+
+
+
+
+
+
+
+
+
+
{stats.draft}
+
Drafts
+
+
+
+
+
+
+
+
+
+
{stats.past}
+
Past Events
+
+
+
+
+
+
+
+ updateFilters({ status: statusFilter })}
+ class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
+ >
+ All Events
+ Published
+ Draft
+ Cancelled
+ Completed
+
+
+
+ {#if form?.error}
+
+ {form.error}
+
+ {/if}
+
+ {#if form?.success}
+
+ {form.success}
+
+ {/if}
+
+
+
+ {#if events.length === 0}
+
+
+
No events found
+
+ {filters.status !== 'all'
+ ? 'Try adjusting your filters.'
+ : 'Create your first event to get started.'}
+
+
+ {:else}
+
+
+
+
+
+ Event
+
+
+ Date & Time
+
+
+ Attendees
+
+
+ Visibility
+
+
+ Status
+
+
+ Actions
+
+
+
+
+ {#each events as event}
+ {@const statusInfo = getStatusInfo(event.status)}
+ {@const eventPast = isPast(event.end_datetime)}
+ {
+ // Don't navigate if clicking on action buttons
+ if ((e.target as HTMLElement).closest('.actions-cell')) return;
+ goto(`/events/${event.id}`);
+ }}
+ onkeydown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ goto(`/events/${event.id}`);
+ }
+ }}
+ tabindex="0"
+ role="button"
+ >
+
+
+
{event.title}
+ {#if event.event_type_name}
+
+ {event.event_type_name}
+
+ {/if}
+
+
+
+
+
{formatDate(event.start_datetime)}
+
+ {formatTime(event.start_datetime)} - {formatTime(event.end_datetime)}
+
+
+
+
+
+
+ {event.total_attendees || 0}
+ {#if event.max_attendees}
+ / {event.max_attendees}
+ {/if}
+
+ {#if event.waitlist_count > 0}
+ {event.waitlist_count} on waitlist
+ {/if}
+
+
+
+ {getVisibilityLabel(event.visibility)}
+
+
+
+
+
+ {statusInfo.label}
+
+
+ e.stopPropagation()}>
+
+
+
+
+
+
+
+
+
+
+ {#if event.status === 'draft'}
+
+
+
+
+
+
+
+ {/if}
+
+
+
+ {/each}
+
+
+
+ {/if}
+
+
+
+
+{#if showCreateModal}
+
+
+
+
+
Create New Event
+ (showCreateModal = false)} class="rounded p-1 hover:bg-slate-100">
+
+
+
+
+
{
+ isSubmitting = true;
+ return async ({ update, result }) => {
+ isSubmitting = false;
+ if (result.type === 'success') {
+ showCreateModal = false;
+ await invalidateAll();
+ }
+ await update();
+ };
+ }}
+ class="space-y-4"
+ >
+
+
+ Event Title *
+
+
+
+
+ Description
+
+
+
+
+ Event Type
+
+ Select type...
+ {#each eventTypes as type}
+ {type.display_name}
+ {/each}
+
+
+
+
+ Location
+
+
+
+
+
+
+ Start Time *
+
+
+
+
+
+
+ End Time *
+
+
+
+
+ Max Attendees
+
+
+
+
+ Max Guests per Member
+
+
+
+
+
+
+ This is a paid event
+
+
+
+ {#if isPaid}
+
+ Member Price (€)
+
+
+
+
+ Non-Member Price (€)
+
+
+ {/if}
+
+
+ Visibility
+
+ Members Only
+ Public
+ Board Only
+ Admin Only
+
+
+
+
+ Status
+
+ Published
+ Draft
+
+
+
+
+
+
(showCreateModal = false)}
+ class="flex-1 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
+ >
+ Cancel
+
+
+ {#if isSubmitting}
+
+ Creating...
+ {:else}
+
+ Create Event
+ {/if}
+
+
+
+
+
+
+{/if}
diff --git a/src/routes/(app)/board/events/[id]/attendees/+page.server.ts b/src/routes/(app)/board/events/[id]/attendees/+page.server.ts
new file mode 100644
index 0000000..6ddb2d4
--- /dev/null
+++ b/src/routes/(app)/board/events/[id]/attendees/+page.server.ts
@@ -0,0 +1,449 @@
+import { fail, error } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+import { supabaseAdmin } from '$lib/server/supabase';
+import { sendEmail } from '$lib/server/email';
+
+export const load: PageServerLoad = async ({ locals, params }) => {
+ // Fetch the event
+ const { data: event } = await locals.supabase
+ .from('events_with_counts')
+ .select('*')
+ .eq('id', params.id)
+ .single();
+
+ if (!event) {
+ throw error(404, 'Event not found');
+ }
+
+ // Fetch all RSVPs with member details
+ // Note: Using explicit foreign key reference because event_rsvps has two FKs to members (member_id, checked_in_by)
+ const { data: rsvps, error: rsvpError } = await locals.supabase
+ .from('event_rsvps')
+ .select(`
+ *,
+ member:members!event_rsvps_member_id_fkey(
+ id,
+ first_name,
+ last_name,
+ email,
+ phone,
+ member_id
+ )
+ `)
+ .eq('event_id', params.id)
+ .order('created_at', { ascending: true });
+
+ if (rsvpError) {
+ console.error('RSVP fetch error:', rsvpError);
+ }
+
+ // Fetch public RSVPs (non-members)
+ const { data: publicRsvps } = await locals.supabase
+ .from('event_rsvps_public')
+ .select('*')
+ .eq('event_id', params.id)
+ .order('created_at', { ascending: true });
+
+ // Calculate stats
+ const memberRsvps = rsvps || [];
+ const nonMemberRsvps = publicRsvps || [];
+
+ const stats = {
+ confirmed: memberRsvps.filter(r => r.status === 'confirmed').length +
+ nonMemberRsvps.filter(r => r.status === 'confirmed').length,
+ confirmedGuests: memberRsvps.filter(r => r.status === 'confirmed').reduce((sum, r) => sum + (r.guest_count || 0), 0) +
+ nonMemberRsvps.filter(r => r.status === 'confirmed').reduce((sum, r) => sum + (r.guest_count || 0), 0),
+ waitlist: memberRsvps.filter(r => r.status === 'waitlist').length +
+ nonMemberRsvps.filter(r => r.status === 'waitlist').length,
+ maybe: memberRsvps.filter(r => r.status === 'maybe').length,
+ declined: memberRsvps.filter(r => r.status === 'declined').length +
+ nonMemberRsvps.filter(r => r.status === 'declined').length,
+ attended: memberRsvps.filter(r => r.attended).length +
+ nonMemberRsvps.filter(r => r.attended).length,
+ totalMembers: memberRsvps.length,
+ totalNonMembers: nonMemberRsvps.length
+ };
+
+ stats.confirmedGuests; // Suppress unused warning
+
+ // Get all members for invitation feature
+ const { data: allMembers } = await supabaseAdmin
+ .from('members')
+ .select('id, first_name, last_name, email, member_id')
+ .order('first_name', { ascending: true });
+
+ // Filter out members who have already RSVPed
+ const rsvpedMemberIds = new Set(memberRsvps.map(r => r.member_id));
+ const uninvitedMembers = (allMembers || []).filter(m => !rsvpedMemberIds.has(m.id));
+
+ return {
+ event,
+ memberRsvps,
+ publicRsvps: nonMemberRsvps,
+ stats,
+ uninvitedMembers
+ };
+};
+
+export const actions: Actions = {
+ checkIn: async ({ request, locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || (member.role !== 'board' && member.role !== 'admin')) {
+ return fail(403, { error: 'You do not have permission to check in attendees' });
+ }
+
+ const formData = await request.formData();
+ const rsvpId = formData.get('rsvp_id') as string;
+ const isPublic = formData.get('is_public') === 'true';
+ const attended = formData.get('attended') === 'true';
+
+ if (!rsvpId) {
+ return fail(400, { error: 'RSVP ID is required' });
+ }
+
+ const table = isPublic ? 'event_rsvps_public' : 'event_rsvps';
+
+ const updateData: Record = {
+ attended,
+ updated_at: new Date().toISOString()
+ };
+
+ // Only set checked_in fields if checking in
+ if (attended) {
+ updateData.checked_in_at = new Date().toISOString();
+ if (!isPublic) {
+ updateData.checked_in_by = member.id;
+ }
+ } else {
+ updateData.checked_in_at = null;
+ if (!isPublic) {
+ updateData.checked_in_by = null;
+ }
+ }
+
+ const { error: updateError } = await locals.supabase
+ .from(table)
+ .update(updateData)
+ .eq('id', rsvpId);
+
+ if (updateError) {
+ console.error('Check-in error:', updateError);
+ return fail(500, { error: 'Failed to update attendance. Please try again.' });
+ }
+
+ return { success: attended ? 'Checked in successfully!' : 'Check-in removed.' };
+ },
+
+ updateRsvpStatus: async ({ request, locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || (member.role !== 'board' && member.role !== 'admin')) {
+ return fail(403, { error: 'You do not have permission to update RSVPs' });
+ }
+
+ const formData = await request.formData();
+ const rsvpId = formData.get('rsvp_id') as string;
+ const isPublic = formData.get('is_public') === 'true';
+ const status = formData.get('status') as string;
+
+ if (!rsvpId || !status) {
+ return fail(400, { error: 'RSVP ID and status are required' });
+ }
+
+ const validStatuses = ['confirmed', 'declined', 'maybe', 'waitlist', 'cancelled'];
+ if (!validStatuses.includes(status)) {
+ return fail(400, { error: 'Invalid status' });
+ }
+
+ const table = isPublic ? 'event_rsvps_public' : 'event_rsvps';
+
+ const { error: updateError } = await locals.supabase
+ .from(table)
+ .update({
+ status,
+ updated_at: new Date().toISOString()
+ })
+ .eq('id', rsvpId);
+
+ if (updateError) {
+ console.error('RSVP status update error:', updateError);
+ return fail(500, { error: 'Failed to update RSVP status. Please try again.' });
+ }
+
+ return { success: 'RSVP status updated!' };
+ },
+
+ promoteFromWaitlist: async ({ request, locals, params }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || (member.role !== 'board' && member.role !== 'admin')) {
+ return fail(403, { error: 'You do not have permission to manage waitlist' });
+ }
+
+ const formData = await request.formData();
+ const rsvpId = formData.get('rsvp_id') as string;
+ const isPublic = formData.get('is_public') === 'true';
+
+ if (!rsvpId) {
+ return fail(400, { error: 'RSVP ID is required' });
+ }
+
+ // Get event to check capacity
+ const { data: event } = await locals.supabase
+ .from('events_with_counts')
+ .select('*')
+ .eq('id', params.id)
+ .single();
+
+ if (!event) {
+ return fail(404, { error: 'Event not found' });
+ }
+
+ // Get the RSVP to promote
+ const table = isPublic ? 'event_rsvps_public' : 'event_rsvps';
+ const { data: rsvp } = await locals.supabase
+ .from(table)
+ .select('*')
+ .eq('id', rsvpId)
+ .single();
+
+ if (!rsvp) {
+ return fail(404, { error: 'RSVP not found' });
+ }
+
+ if (rsvp.status !== 'waitlist') {
+ return fail(400, { error: 'Only waitlisted RSVPs can be promoted' });
+ }
+
+ // Check capacity
+ const spotsNeeded = 1 + (rsvp.guest_count || 0);
+ if (event.max_attendees && event.total_attendees + spotsNeeded > event.max_attendees) {
+ return fail(400, { error: 'Not enough capacity for this attendee and their guests' });
+ }
+
+ // Promote to confirmed
+ const { error: updateError } = await locals.supabase
+ .from(table)
+ .update({
+ status: 'confirmed',
+ updated_at: new Date().toISOString()
+ })
+ .eq('id', rsvpId);
+
+ if (updateError) {
+ console.error('Promotion error:', updateError);
+ return fail(500, { error: 'Failed to promote from waitlist. Please try again.' });
+ }
+
+ return { success: 'Successfully promoted from waitlist!' };
+ },
+
+ deleteRsvp: async ({ request, locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || (member.role !== 'board' && member.role !== 'admin')) {
+ return fail(403, { error: 'You do not have permission to delete RSVPs' });
+ }
+
+ const formData = await request.formData();
+ const rsvpId = formData.get('rsvp_id') as string;
+ const isPublic = formData.get('is_public') === 'true';
+
+ if (!rsvpId) {
+ return fail(400, { error: 'RSVP ID is required' });
+ }
+
+ const table = isPublic ? 'event_rsvps_public' : 'event_rsvps';
+
+ const { error: deleteError } = await locals.supabase
+ .from(table)
+ .delete()
+ .eq('id', rsvpId);
+
+ if (deleteError) {
+ console.error('Delete RSVP error:', deleteError);
+ return fail(500, { error: 'Failed to delete RSVP. Please try again.' });
+ }
+
+ return { success: 'RSVP removed successfully!' };
+ },
+
+ markAsPaid: async ({ request, locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || (member.role !== 'board' && member.role !== 'admin')) {
+ return fail(403, { error: 'You do not have permission to manage payments' });
+ }
+
+ const formData = await request.formData();
+ const rsvpId = formData.get('rsvp_id') as string;
+ const isPublic = formData.get('is_public') === 'true';
+
+ if (!rsvpId) {
+ return fail(400, { error: 'RSVP ID is required' });
+ }
+
+ const table = isPublic ? 'event_rsvps_public' : 'event_rsvps';
+
+ const { error: updateError } = await locals.supabase
+ .from(table)
+ .update({
+ payment_status: 'paid',
+ updated_at: new Date().toISOString()
+ })
+ .eq('id', rsvpId);
+
+ if (updateError) {
+ console.error('Mark as paid error:', updateError);
+ return fail(500, { error: 'Failed to update payment status. Please try again.' });
+ }
+
+ return { success: 'Payment marked as received!' };
+ },
+
+ inviteMembers: async ({ request, locals, params, url }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || (member.role !== 'board' && member.role !== 'admin')) {
+ return fail(403, { error: 'You do not have permission to invite members' });
+ }
+
+ const formData = await request.formData();
+ const memberIds = formData.getAll('member_ids') as string[];
+
+ if (!memberIds || memberIds.length === 0) {
+ return fail(400, { error: 'Please select at least one member to invite' });
+ }
+
+ // Get the event details
+ const { data: event } = await locals.supabase
+ .from('events')
+ .select('*')
+ .eq('id', params.id)
+ .single();
+
+ if (!event) {
+ return fail(404, { error: 'Event not found' });
+ }
+
+ // Get the selected members
+ const { data: members } = await supabaseAdmin
+ .from('members')
+ .select('id, first_name, last_name, email')
+ .in('id', memberIds);
+
+ if (!members || members.length === 0) {
+ return fail(400, { error: 'No valid members found' });
+ }
+
+ // Prepare email content
+ const baseUrl = url.origin;
+ const eventUrl = `${baseUrl}/events/${event.id}`;
+ const logoUrl = `${baseUrl}/MONACOUSA-Flags_376x376.png`;
+
+ // Format event date and time
+ const eventDate = new Date(event.start_time).toLocaleDateString('en-US', {
+ weekday: 'long',
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric'
+ });
+ const eventTime = new Date(event.start_time).toLocaleTimeString('en-US', {
+ hour: 'numeric',
+ minute: '2-digit'
+ });
+
+ let successCount = 0;
+ let failCount = 0;
+
+ // Send invitations to each member
+ for (const invitee of members) {
+ const emailResult = await sendEmail({
+ to: invitee.email,
+ subject: `You're Invited: ${event.title}`,
+ html: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Monaco USA
+ Americans in Monaco
+
+
+
+
+
+
+
+
+ You're Invited!
+ Dear ${invitee.first_name},
+ We'd love for you to join us at an upcoming Monaco USA event!
+
+
+
${event.title}
+
Date: ${eventDate}
+
Time: ${eventTime}
+ ${event.location ? `
Location: ${event.location}
` : ''}
+
+
+ ${event.description ? `${event.description.substring(0, 200)}${event.description.length > 200 ? '...' : ''}
` : ''}
+
+
+
+ Click the button above to view the event details and RSVP.
+
+
+
+
+
+
+
+
+ © 2026 Monaco USA. All rights reserved.
+
+
+
+
+
+
+
+`,
+ recipientId: invitee.id,
+ recipientName: `${invitee.first_name} ${invitee.last_name}`,
+ emailType: 'event_invitation',
+ sentBy: member.id
+ });
+
+ if (emailResult.success) {
+ successCount++;
+ } else {
+ failCount++;
+ console.error(`Failed to send invitation to ${invitee.email}:`, emailResult.error);
+ }
+ }
+
+ if (failCount === 0) {
+ return { success: `Successfully sent ${successCount} invitation${successCount !== 1 ? 's' : ''}!` };
+ } else if (successCount === 0) {
+ return fail(500, { error: 'Failed to send all invitations. Please check your email settings.' });
+ } else {
+ return { success: `Sent ${successCount} invitation${successCount !== 1 ? 's' : ''}, but ${failCount} failed.` };
+ }
+ }
+};
diff --git a/src/routes/(app)/board/events/[id]/attendees/+page.svelte b/src/routes/(app)/board/events/[id]/attendees/+page.svelte
new file mode 100644
index 0000000..ffc2297
--- /dev/null
+++ b/src/routes/(app)/board/events/[id]/attendees/+page.svelte
@@ -0,0 +1,919 @@
+
+
+
+ Attendees: {event?.title || 'Event'} | Monaco USA
+
+
+
+
+
+
+
+
(showInviteModal = true)}
+ class="inline-flex items-center gap-2 rounded-lg bg-monaco-600 px-4 py-2 text-sm font-medium text-white hover:bg-monaco-700"
+ >
+
+ Invite Members
+
+
+ Edit Event
+
+
+
+
+ {#if form?.error}
+
+ {/if}
+
+ {#if form?.success}
+
+ {/if}
+
+ {#if event}
+
+
+
+
+
+ {formatDate(event.start_datetime)}
+ {formatTime(event.start_datetime)} - {formatTime(event.end_datetime)}
+
+ {#if event.location}
+
+
+ {event.location}
+
+ {/if}
+
+
+ {event.total_attendees}{event.max_attendees ? ` / ${event.max_attendees}` : ''} attendees
+
+
+
+
+
+
+
+
+
+
+
+
+
{stats.confirmed}
+
Confirmed
+
+
+
+ {#if event?.is_paid}
+
+
+
+
+
+
+
{pendingPaymentsCount}
+
Pending Payment
+
+
+
+ {/if}
+
+
+
+
+
+
+
{stats.waitlist}
+
Waitlist
+
+
+
+
+
+
+
+
+
+
{stats.maybe}
+
Maybe
+
+
+
+
+
+
+
+
+
+
{stats.declined}
+
Declined
+
+
+
+
+
+
+
+
+
+
{stats.attended}
+
Checked In
+
+
+
+
+
+
+
+ {#each [
+ { value: 'all', label: 'All' },
+ { value: 'confirmed', label: 'Confirmed' },
+ { value: 'waitlist', label: 'Waitlist' },
+ { value: 'maybe', label: 'Maybe' },
+ { value: 'attended', label: 'Checked In' },
+ { value: 'not_attended', label: 'Not Checked In' },
+ ...(event?.is_paid ? [
+ { value: 'pending_payment', label: 'Pending Payment' },
+ { value: 'paid', label: 'Paid' }
+ ] : [])
+ ] as filter}
+ statusFilter = filter.value}
+ class="rounded-lg px-4 py-2 text-sm font-medium transition-colors {statusFilter === filter.value
+ ? 'bg-monaco-600 text-white'
+ : 'bg-slate-100 text-slate-700 hover:bg-slate-200'}"
+ >
+ {filter.label}
+
+ {/each}
+
+
+
+ {#if memberRsvps.length > 0}
+
+
+
Members ({stats.totalMembers})
+
+
+ {#each filterRsvps(memberRsvps, statusFilter) as rsvp}
+
+
+
+
+
{
+ loading = true;
+ return async ({ update }) => {
+ await invalidateAll();
+ loading = false;
+ await update();
+ };
+ }}
+ >
+
+
+
+
+ {#if rsvp.attended}
+
+ {/if}
+
+
+
+
+
+
+
+ {rsvp.member?.first_name} {rsvp.member?.last_name}
+
+
+ {rsvp.status}
+
+ {#if event?.is_paid && rsvp.payment_status === 'pending'}
+
+
+ Payment Pending
+
+ {:else if event?.is_paid && rsvp.payment_status === 'paid'}
+
+
+ Paid
+
+ {/if}
+
+
{rsvp.member?.member_id}
+
+
+
+
+ {#if rsvp.guest_count > 0}
+
+ +{rsvp.guest_count} guest{rsvp.guest_count > 1 ? 's' : ''}
+
+ {/if}
+
+
+ {#if event?.is_paid && rsvp.payment_status === 'pending'}
+
{
+ loading = true;
+ return async ({ update, result }) => {
+ loading = false;
+ if (result.type === 'success') {
+ await invalidateAll();
+ }
+ await update();
+ };
+ }}
+ >
+
+
+
+
+ Mark Paid
+
+
+ {/if}
+
+
+ toggleExpand(rsvp.id)}
+ class="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
+ >
+ {#if expandedRsvp === rsvp.id}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+ {#if expandedRsvp === rsvp.id}
+
+
+ {#if rsvp.member?.email}
+
+
+ {rsvp.member.email}
+
+ {/if}
+ {#if rsvp.member?.phone}
+
+
+ {rsvp.member.phone}
+
+ {/if}
+
+
+
+ {#if event?.is_paid && rsvp.payment_amount}
+
+ Amount: €{rsvp.payment_amount.toFixed(2)}
+
+ {/if}
+
+
+
+ {#if event?.is_paid && rsvp.payment_status === 'pending'}
+
{
+ loading = true;
+ return async ({ update, result }) => {
+ loading = false;
+ if (result.type === 'success') {
+ await invalidateAll();
+ }
+ await update();
+ };
+ }}
+ >
+
+
+
+
+ Mark as Paid
+
+
+ {/if}
+ {#if rsvp.status === 'waitlist'}
+ {
+ loading = true;
+ return async ({ update, result }) => {
+ loading = false;
+ if (result.type === 'success') {
+ await invalidateAll();
+ }
+ await update();
+ };
+ }}
+ >
+
+
+
+
+ Promote to Confirmed
+
+
+ {/if}
+ {
+ if (!confirm('Remove this RSVP? This cannot be undone.')) {
+ return async () => {};
+ }
+ loading = true;
+ return async ({ update, result }) => {
+ loading = false;
+ if (result.type === 'success') {
+ await invalidateAll();
+ }
+ await update();
+ };
+ }}
+ >
+
+
+
+
+ Remove RSVP
+
+
+
+
+ {/if}
+
+ {/each}
+
+
+ {/if}
+
+
+ {#if publicRsvps.length > 0}
+
+
+
Non-Members ({stats.totalNonMembers})
+
+
+ {#each filterRsvps(publicRsvps, statusFilter) as rsvp}
+
+
+
+
+
{
+ loading = true;
+ return async ({ update }) => {
+ await invalidateAll();
+ loading = false;
+ await update();
+ };
+ }}
+ >
+
+
+
+
+ {#if rsvp.attended}
+
+ {/if}
+
+
+
+
+
+
+
+ {rsvp.full_name}
+
+
+ Guest
+
+
+ {rsvp.status}
+
+ {#if event?.is_paid && rsvp.payment_status === 'pending'}
+
+
+ Payment Pending
+
+ {:else if event?.is_paid && rsvp.payment_status === 'paid'}
+
+
+ Paid
+
+ {/if}
+
+
{rsvp.email}
+
+
+
+
+ {#if rsvp.guest_count > 0}
+
+ +{rsvp.guest_count} guest{rsvp.guest_count > 1 ? 's' : ''}
+
+ {/if}
+
+
+ {#if event?.is_paid && rsvp.payment_status === 'pending'}
+
{
+ loading = true;
+ return async ({ update, result }) => {
+ loading = false;
+ if (result.type === 'success') {
+ await invalidateAll();
+ }
+ await update();
+ };
+ }}
+ >
+
+
+
+
+ Mark Paid
+
+
+ {/if}
+
+
+ toggleExpand(`public-${rsvp.id}`)}
+ class="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
+ >
+ {#if expandedRsvp === `public-${rsvp.id}`}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+ {#if expandedRsvp === `public-${rsvp.id}`}
+
+
+
+
+ {#if event?.is_paid && rsvp.payment_amount}
+
+ Amount: €{rsvp.payment_amount.toFixed(2)}
+
+ {/if}
+
+
+
+ {#if event?.is_paid && rsvp.payment_status === 'pending'}
+
{
+ loading = true;
+ return async ({ update, result }) => {
+ loading = false;
+ if (result.type === 'success') {
+ await invalidateAll();
+ }
+ await update();
+ };
+ }}
+ >
+
+
+
+
+ Mark as Paid
+
+
+ {/if}
+ {#if rsvp.status === 'waitlist'}
+ {
+ loading = true;
+ return async ({ update, result }) => {
+ loading = false;
+ if (result.type === 'success') {
+ await invalidateAll();
+ }
+ await update();
+ };
+ }}
+ >
+
+
+
+
+ Promote to Confirmed
+
+
+ {/if}
+ {
+ if (!confirm('Remove this RSVP? This cannot be undone.')) {
+ return async () => {};
+ }
+ loading = true;
+ return async ({ update, result }) => {
+ loading = false;
+ if (result.type === 'success') {
+ await invalidateAll();
+ }
+ await update();
+ };
+ }}
+ >
+
+
+
+
+ Remove RSVP
+
+
+
+
+ {/if}
+
+ {/each}
+
+
+ {/if}
+
+
+ {#if memberRsvps.length === 0 && publicRsvps.length === 0}
+
+
+
No RSVPs yet
+
No one has RSVP'd to this event yet.
+
+ {/if}
+ {:else}
+
+
Event not found.
+
+ View all events
+
+
+ {/if}
+
+
+
+{#if showInviteModal}
+
+
+
+
+
+
Invite Members to Event
+
+
+
+
+
+
+
+ Select members to send an email invitation with a link to RSVP for "{event?.title}".
+
+
+
+
+
+
+
+
+ {#if uninvitedMembers.length === 0}
+
+
+
All members have already been invited or RSVPed.
+
+ {:else}
+
+
+
+ {selectedMembers.size === filteredUninvitedMembers.length ? 'Deselect All' : 'Select All'}
+
+
+ {selectedMembers.size} of {filteredUninvitedMembers.length} selected
+
+
+
+
+
+ {#each filteredUninvitedMembers as member (member.id)}
+
+ toggleMemberSelection(member.id)}
+ class="h-4 w-4 rounded border-slate-300 text-monaco-600 focus:ring-monaco-500"
+ />
+
+
+ {member.first_name} {member.last_name}
+
+
{member.email}
+
+ {member.member_id}
+
+ {:else}
+
+ No members match your search.
+
+ {/each}
+
+ {/if}
+
+
+
+
+ Cancel
+
+
{
+ inviteLoading = true;
+ return async ({ update, result }) => {
+ inviteLoading = false;
+ if (result.type === 'success') {
+ closeInviteModal();
+ await invalidateAll();
+ }
+ await update();
+ };
+ }}
+ class="flex-1"
+ >
+ {#each Array.from(selectedMembers) as memberId}
+
+ {/each}
+
+ {#if inviteLoading}
+
+
+ Sending...
+
+ {:else}
+ Send {selectedMembers.size} Invitation{selectedMembers.size !== 1 ? 's' : ''}
+ {/if}
+
+
+
+
+
+{/if}
diff --git a/src/routes/(app)/board/events/[id]/edit/+page.server.ts b/src/routes/(app)/board/events/[id]/edit/+page.server.ts
new file mode 100644
index 0000000..9702e43
--- /dev/null
+++ b/src/routes/(app)/board/events/[id]/edit/+page.server.ts
@@ -0,0 +1,124 @@
+import { fail, error, redirect } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async ({ locals, params }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || (member.role !== 'board' && member.role !== 'admin')) {
+ throw redirect(303, '/dashboard');
+ }
+
+ // Fetch the event
+ const { data: event } = await locals.supabase
+ .from('events')
+ .select('*')
+ .eq('id', params.id)
+ .single();
+
+ if (!event) {
+ throw error(404, 'Event not found');
+ }
+
+ // Load event types
+ const { data: eventTypes } = await locals.supabase
+ .from('event_types')
+ .select('*')
+ .eq('is_active', true)
+ .order('sort_order', { ascending: true });
+
+ return {
+ event,
+ eventTypes: eventTypes || []
+ };
+};
+
+export const actions: Actions = {
+ update: async ({ request, locals, params }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || (member.role !== 'board' && member.role !== 'admin')) {
+ return fail(403, { error: 'You do not have permission to edit events' });
+ }
+
+ const formData = await request.formData();
+
+ const title = formData.get('title') as string;
+ const description = formData.get('description') as string;
+ const eventTypeId = formData.get('event_type_id') as string;
+ const startDate = formData.get('start_date') as string;
+ const startTime = formData.get('start_time') as string;
+ const endDate = formData.get('end_date') as string;
+ const endTime = formData.get('end_time') as string;
+ const location = formData.get('location') as string;
+ const locationUrl = formData.get('location_url') as string;
+ const maxAttendees = formData.get('max_attendees') as string;
+ const maxGuests = formData.get('max_guests_per_member') as string;
+ const isPaid = formData.get('is_paid') === 'true';
+ const memberPrice = formData.get('member_price') as string;
+ const nonMemberPrice = formData.get('non_member_price') as string;
+ const visibility = formData.get('visibility') as string;
+ const status = formData.get('status') as string;
+
+ // Validation
+ if (!title || !startDate || !startTime || !endDate || !endTime) {
+ return fail(400, { error: 'Title, start date/time, and end date/time are required' });
+ }
+
+ // Construct datetime strings
+ const startDatetime = `${startDate}T${startTime}:00`;
+ const endDatetime = `${endDate}T${endTime}:00`;
+
+ // Validate end is after start
+ if (new Date(endDatetime) <= new Date(startDatetime)) {
+ return fail(400, { error: 'End date/time must be after start date/time' });
+ }
+
+ const { error: updateError } = await locals.supabase
+ .from('events')
+ .update({
+ title,
+ description: description || null,
+ event_type_id: eventTypeId || null,
+ start_datetime: startDatetime,
+ end_datetime: endDatetime,
+ location: location || null,
+ location_url: locationUrl || null,
+ max_attendees: maxAttendees ? parseInt(maxAttendees) : null,
+ max_guests_per_member: maxGuests ? parseInt(maxGuests) : 1,
+ is_paid: isPaid,
+ member_price: isPaid && memberPrice ? parseFloat(memberPrice) : 0,
+ non_member_price: isPaid && nonMemberPrice ? parseFloat(nonMemberPrice) : 0,
+ visibility: visibility || 'members',
+ status: status || 'published',
+ updated_at: new Date().toISOString()
+ })
+ .eq('id', params.id);
+
+ if (updateError) {
+ console.error('Event update error:', updateError);
+ return fail(500, { error: 'Failed to update event. Please try again.' });
+ }
+
+ return { success: 'Event updated successfully!' };
+ },
+
+ delete: async ({ locals, params }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || member.role !== 'admin') {
+ return fail(403, { error: 'Only admins can delete events' });
+ }
+
+ const { error: deleteError } = await locals.supabase
+ .from('events')
+ .delete()
+ .eq('id', params.id);
+
+ if (deleteError) {
+ console.error('Event deletion error:', deleteError);
+ return fail(500, { error: 'Failed to delete event. Please try again.' });
+ }
+
+ throw redirect(303, '/board/events?deleted=true');
+ }
+};
diff --git a/src/routes/(app)/board/events/[id]/edit/+page.svelte b/src/routes/(app)/board/events/[id]/edit/+page.svelte
new file mode 100644
index 0000000..6551aa1
--- /dev/null
+++ b/src/routes/(app)/board/events/[id]/edit/+page.svelte
@@ -0,0 +1,416 @@
+
+
+
+ Edit Event | Monaco USA
+
+
+
+
+
+
+ Back to events
+
+
+
+
+
Edit Event
+
Update event details and settings
+
+
+ {#if form?.error}
+
+ {form.error}
+
+ {/if}
+
+ {#if form?.success}
+
+ {form.success}
+
+ {/if}
+
+
{
+ isSubmitting = true;
+ return async ({ update }) => {
+ await invalidateAll();
+ await update();
+ isSubmitting = false;
+ };
+ }}
+ class="space-y-6"
+ >
+
+
+
+
+ Event Details
+
+
+
+
+ Event Title *
+
+
+
+
+ Description
+ {event.description || ''}
+
+
+
+ Event Type
+
+ Select type...
+ {#each eventTypes as type}
+ {type.display_name}
+ {/each}
+
+
+
+
+ Status
+
+ Published
+ Draft
+ Cancelled
+ Completed
+
+
+
+
+
+
+
+
Date & Time
+
+
+
+
+
+ Start Time *
+
+
+
+
+
+
+ End Time *
+
+
+
+
+
+
+
+
+
+
+
+
+ Capacity
+
+
+
+
+
Max Attendees
+
+
Leave empty for unlimited
+
+
+
+ Max Guests per Member
+
+
+
+
+
+
+
+
+
+ Pricing
+
+
+
+
+ This is a paid event
+
+
+ {#if isPaid}
+
+ {/if}
+
+
+
+
+
+
+ Visibility
+
+
+
+ Who can see this event?
+
+ Members Only
+ Public (Anyone)
+ Board Only
+ Admin Only
+
+
+
+
+
+
+ {#if member?.role === 'admin'}
+
(showDeleteConfirm = true)}
+ class="flex items-center justify-center gap-2 rounded-lg border border-red-200 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50"
+ >
+
+ Delete Event
+
+ {:else}
+
+ {/if}
+
+
+
+ Cancel
+
+
+ {#if isSubmitting}
+
+ Saving...
+ {:else}
+
+ Save Changes
+ {/if}
+
+
+
+
+
+
+
+
+{#if showDeleteConfirm}
+
+
+
+
Delete Event
+ (showDeleteConfirm = false)} class="rounded p-1 hover:bg-slate-100">
+
+
+
+
+
+ Are you sure you want to delete {event.title} ? This action cannot be undone.
+
+
+
+ All RSVPs and associated data will also be deleted.
+
+
+
+ (showDeleteConfirm = false)}
+ class="flex-1 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
+ >
+ Cancel
+
+
+
+ Delete Event
+
+
+
+
+
+{/if}
diff --git a/src/routes/(app)/board/events/[id]/roll-call/+page.server.ts b/src/routes/(app)/board/events/[id]/roll-call/+page.server.ts
new file mode 100644
index 0000000..a1e74ed
--- /dev/null
+++ b/src/routes/(app)/board/events/[id]/roll-call/+page.server.ts
@@ -0,0 +1,281 @@
+import { fail, error, redirect } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+import { supabaseAdmin } from '$lib/server/supabase';
+
+export const load: PageServerLoad = async ({ locals, params }) => {
+ const { member } = await locals.safeGetSession();
+
+ // Only board and admin can access roll call
+ if (!member || (member.role !== 'board' && member.role !== 'admin')) {
+ throw redirect(303, `/board/events/${params.id}`);
+ }
+
+ // Fetch the event
+ const { data: event, error: eventError } = await locals.supabase
+ .from('events_with_counts')
+ .select('*')
+ .eq('id', params.id)
+ .single();
+
+ if (eventError || !event) {
+ throw error(404, 'Event not found');
+ }
+
+ // Fetch all RSVPs with member details (only confirmed status for roll call)
+ const { data: rsvps, error: rsvpError } = await locals.supabase
+ .from('event_rsvps')
+ .select(`
+ *,
+ member:members!event_rsvps_member_id_fkey(
+ id,
+ first_name,
+ last_name,
+ email,
+ phone,
+ member_id,
+ avatar_url
+ )
+ `)
+ .eq('event_id', params.id)
+ .in('status', ['confirmed', 'waitlist'])
+ .order('created_at', { ascending: true });
+
+ if (rsvpError) {
+ console.error('RSVP fetch error:', rsvpError);
+ }
+
+ // Fetch public RSVPs (non-members with confirmed status)
+ const { data: publicRsvps, error: publicError } = await locals.supabase
+ .from('event_rsvps_public')
+ .select('*')
+ .eq('event_id', params.id)
+ .in('status', ['confirmed', 'waitlist'])
+ .order('created_at', { ascending: true });
+
+ if (publicError) {
+ console.error('Public RSVP fetch error:', publicError);
+ }
+
+ // Calculate stats
+ const allMemberRsvps = rsvps || [];
+ const allPublicRsvps = publicRsvps || [];
+ const allRsvps = [...allMemberRsvps, ...allPublicRsvps];
+
+ const stats = {
+ total: allRsvps.length,
+ confirmed: allRsvps.filter(r => r.status === 'confirmed').length,
+ checkedIn: allRsvps.filter(r => r.attended).length,
+ waitlist: allRsvps.filter(r => r.status === 'waitlist').length
+ };
+
+ // Get all members for walk-in feature
+ const { data: allMembers } = await supabaseAdmin
+ .from('members')
+ .select('id, first_name, last_name, email, member_id, avatar_url')
+ .order('first_name', { ascending: true });
+
+ // Filter out members who have already RSVPed
+ const rsvpedMemberIds = new Set(allMemberRsvps.map(r => r.member_id));
+ const availableMembers = (allMembers || []).filter(m => !rsvpedMemberIds.has(m.id));
+
+ return {
+ event,
+ memberRsvps: allMemberRsvps,
+ publicRsvps: allPublicRsvps,
+ stats,
+ availableMembers
+ };
+};
+
+export const actions: Actions = {
+ checkIn: async ({ request, locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || (member.role !== 'board' && member.role !== 'admin')) {
+ return fail(403, { error: 'Permission denied' });
+ }
+
+ const formData = await request.formData();
+ const rsvpId = formData.get('rsvp_id') as string;
+ const isPublic = formData.get('is_public') === 'true';
+ const attended = formData.get('attended') === 'true';
+
+ if (!rsvpId) {
+ return fail(400, { error: 'RSVP ID required' });
+ }
+
+ const table = isPublic ? 'event_rsvps_public' : 'event_rsvps';
+
+ const updateData: Record = {
+ attended,
+ updated_at: new Date().toISOString()
+ };
+
+ if (attended) {
+ updateData.checked_in_at = new Date().toISOString();
+ if (!isPublic) {
+ updateData.checked_in_by = member.id;
+ }
+ } else {
+ updateData.checked_in_at = null;
+ if (!isPublic) {
+ updateData.checked_in_by = null;
+ }
+ }
+
+ const { error: updateError } = await locals.supabase
+ .from(table)
+ .update(updateData)
+ .eq('id', rsvpId);
+
+ if (updateError) {
+ console.error('Check-in error:', updateError);
+ return fail(500, { error: 'Check-in failed' });
+ }
+
+ return { success: true, attended };
+ },
+
+ addWalkIn: async ({ request, locals, params }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || (member.role !== 'board' && member.role !== 'admin')) {
+ return fail(403, { error: 'Permission denied' });
+ }
+
+ const formData = await request.formData();
+ const memberId = formData.get('member_id') as string;
+ const guestName = formData.get('guest_name') as string;
+ const guestEmail = formData.get('guest_email') as string;
+
+ const eventId = params.id;
+
+ // If it's an existing member
+ if (memberId) {
+ // Check if member already has an RSVP
+ const { data: existing } = await locals.supabase
+ .from('event_rsvps')
+ .select('id')
+ .eq('event_id', eventId)
+ .eq('member_id', memberId)
+ .single();
+
+ if (existing) {
+ return fail(400, { error: 'Member already has an RSVP for this event' });
+ }
+
+ // Create RSVP with immediate check-in
+ const { error: insertError } = await locals.supabase
+ .from('event_rsvps')
+ .insert({
+ event_id: eventId,
+ member_id: memberId,
+ status: 'confirmed',
+ attended: true,
+ checked_in_at: new Date().toISOString(),
+ checked_in_by: member.id,
+ rsvp_source: 'walk_in',
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString()
+ });
+
+ if (insertError) {
+ console.error('Walk-in RSVP error:', insertError);
+ return fail(500, { error: 'Failed to add walk-in' });
+ }
+
+ return { success: true, message: 'Member added as walk-in' };
+ }
+
+ // If it's a non-member guest
+ if (guestName) {
+ const { error: insertError } = await locals.supabase
+ .from('event_rsvps_public')
+ .insert({
+ event_id: eventId,
+ full_name: guestName,
+ email: guestEmail || null,
+ status: 'confirmed',
+ attended: true,
+ checked_in_at: new Date().toISOString(),
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString()
+ });
+
+ if (insertError) {
+ console.error('Walk-in guest error:', insertError);
+ return fail(500, { error: 'Failed to add guest' });
+ }
+
+ return { success: true, message: 'Guest added as walk-in' };
+ }
+
+ return fail(400, { error: 'Please provide a member or guest information' });
+ },
+
+ bulkCheckIn: async ({ request, locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member || (member.role !== 'board' && member.role !== 'admin')) {
+ return fail(403, { error: 'Permission denied' });
+ }
+
+ const formData = await request.formData();
+ const rsvpIdsString = formData.get('rsvp_ids') as string;
+ const isPublicString = formData.get('is_public_ids') as string;
+ const attended = formData.get('attended') === 'true';
+
+ if (!rsvpIdsString) {
+ return fail(400, { error: 'No RSVPs selected' });
+ }
+
+ const rsvpIds = rsvpIdsString.split(',');
+ const isPublicIds = isPublicString ? isPublicString.split(',') : [];
+
+ const updateData: Record = {
+ attended,
+ updated_at: new Date().toISOString()
+ };
+
+ if (attended) {
+ updateData.checked_in_at = new Date().toISOString();
+ } else {
+ updateData.checked_in_at = null;
+ }
+
+ // Update member RSVPs
+ const memberRsvpIds = rsvpIds.filter(id => !isPublicIds.includes(id));
+ const publicRsvpIds = rsvpIds.filter(id => isPublicIds.includes(id));
+
+ if (memberRsvpIds.length > 0) {
+ const memberUpdateData = { ...updateData };
+ if (attended) {
+ memberUpdateData.checked_in_by = member.id;
+ } else {
+ memberUpdateData.checked_in_by = null;
+ }
+
+ const { error: memberError } = await locals.supabase
+ .from('event_rsvps')
+ .update(memberUpdateData)
+ .in('id', memberRsvpIds);
+
+ if (memberError) {
+ console.error('Bulk member check-in error:', memberError);
+ }
+ }
+
+ if (publicRsvpIds.length > 0) {
+ const { error: publicError } = await locals.supabase
+ .from('event_rsvps_public')
+ .update(updateData)
+ .in('id', publicRsvpIds);
+
+ if (publicError) {
+ console.error('Bulk public check-in error:', publicError);
+ }
+ }
+
+ return { success: true, count: rsvpIds.length };
+ }
+};
diff --git a/src/routes/(app)/board/events/[id]/roll-call/+page.svelte b/src/routes/(app)/board/events/[id]/roll-call/+page.svelte
new file mode 100644
index 0000000..eda6f21
--- /dev/null
+++ b/src/routes/(app)/board/events/[id]/roll-call/+page.svelte
@@ -0,0 +1,513 @@
+
+
+
+ Roll Call: {event?.title || 'Event'} | Monaco USA
+
+
+
+
+
+
+
+
+
+
+ Back
+
+
+
+ {event?.title || 'Roll Call'}
+
+
+ {#if event?.start_datetime}
+ {formatEventDate(event.start_datetime)}
+ {/if}
+ {#if event?.location}
+ • {event.location}
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
{stats.checkedIn}
+
Checked In
+
+
+
+
{stats.confirmed}
+
Confirmed
+
+ {#if stats.waitlist > 0}
+
+
+
{stats.waitlist}
+
Waitlist
+
+ {/if}
+
+
+
+
+
+
+
+ {#if searchQuery}
+ searchQuery = ''}
+ class="absolute right-3 top-1/2 -translate-y-1/2 rounded-full p-1 text-slate-400 hover:bg-slate-200 hover:text-slate-600"
+ >
+
+
+ {/if}
+
+
+
+
+
+ {#each [
+ { value: 'all', label: 'All', count: stats.total },
+ { value: 'confirmed', label: 'Confirmed', count: stats.confirmed },
+ { value: 'checked', label: 'Here', count: stats.checkedIn },
+ { value: 'unchecked', label: 'Not Here', count: stats.confirmed - stats.checkedIn }
+ ] as tab}
+ filter = tab.value as typeof filter}
+ class="flex shrink-0 items-center gap-1.5 rounded-full px-4 py-2 text-sm font-medium transition-all
+ {filter === tab.value
+ ? 'bg-monaco-600 text-white shadow-sm'
+ : 'bg-slate-100 text-slate-600 active:bg-slate-200'}"
+ >
+ {tab.label}
+
+ {tab.count}
+
+
+ {/each}
+
+
+
+
+
+ {#if form?.error}
+
+ {form.error}
+
+ {/if}
+
+ {#if filteredRsvps().length === 0}
+
+
+
+ {searchQuery ? 'No attendees match your search' : 'No attendees to show'}
+
+
+ {:else}
+
+ {#each filteredRsvps() as rsvp (rsvp.id)}
+ {@const isMember = rsvp.type === 'member'}
+ {@const name = isMember ? `${rsvp.member?.first_name} ${rsvp.member?.last_name}` : rsvp.full_name}
+ {@const memberId = isMember ? rsvp.member?.member_id : null}
+ {@const avatarUrl = isMember ? rsvp.member?.avatar_url : null}
+
+
{
+ return async ({ update }) => {
+ await invalidateAll();
+ await update({ reset: false });
+ };
+ }}
+ >
+
+
+
+
+
+
+
+ {#if avatarUrl}
+
+ {:else}
+
+ {getInitials(isMember ? rsvp.member?.first_name : rsvp.full_name?.split(' ')[0] || '', isMember ? rsvp.member?.last_name : rsvp.full_name?.split(' ')[1] || '')}
+
+ {/if}
+ {#if rsvp.status === 'waitlist'}
+
+ WL
+
+ {/if}
+
+
+
+
+
+
{name}
+ {#if !isMember}
+
+ Guest
+
+ {/if}
+
+ {#if memberId}
+
{memberId}
+ {:else if !isMember && rsvp.email}
+
{rsvp.email}
+ {/if}
+ {#if rsvp.guest_count > 0}
+
+{rsvp.guest_count} guest{rsvp.guest_count > 1 ? 's' : ''}
+ {/if}
+
+
+
+
+ {#if rsvp.attended}
+
+ {:else}
+ +
+ {/if}
+
+
+
+ {/each}
+
+ {/if}
+
+
+
+
showWalkInModal = true}
+ class="fixed bottom-24 right-4 z-10 flex h-14 items-center gap-2 rounded-full bg-monaco-600 px-5 text-white shadow-lg active:bg-monaco-700 lg:bottom-6"
+ >
+
+ Walk-in
+
+
+
+
+{#if showWalkInModal}
+
+
+
+
+
Add Walk-in
+
+
+
+
+
+
+
+ walkInTab = 'member'}
+ class="flex-1 border-b-2 px-4 py-3 text-sm font-medium transition-colors
+ {walkInTab === 'member'
+ ? 'border-monaco-600 text-monaco-600'
+ : 'border-transparent text-slate-500 hover:text-slate-700'}"
+ >
+ Existing Member
+
+ walkInTab = 'guest'}
+ class="flex-1 border-b-2 px-4 py-3 text-sm font-medium transition-colors
+ {walkInTab === 'guest'
+ ? 'border-monaco-600 text-monaco-600'
+ : 'border-transparent text-slate-500 hover:text-slate-700'}"
+ >
+ New Guest
+
+
+
+
+
+ {#if walkInTab === 'member'}
+
+
+
+
+
+
+ {#if filteredWalkInMembers.length === 0}
+
+ {walkInMemberSearch ? 'No members found' : 'All members have already RSVPed'}
+
+ {:else}
+
+ {#each filteredWalkInMembers as member (member.id)}
+
selectedWalkInMember = selectedWalkInMember === member.id ? null : member.id}
+ class="flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors
+ {selectedWalkInMember === member.id
+ ? 'border-monaco-500 bg-monaco-50'
+ : 'border-slate-200 hover:bg-slate-50'}"
+ >
+ {#if member.avatar_url}
+
+ {:else}
+
+ {getInitials(member.first_name, member.last_name)}
+
+ {/if}
+
+
{member.first_name} {member.last_name}
+
{member.member_id}
+
+ {#if selectedWalkInMember === member.id}
+
+ {/if}
+
+ {/each}
+
+ {/if}
+
+
+ {#if selectedWalkInMember}
+
{
+ walkInLoading = true;
+ return async ({ update, result }) => {
+ walkInLoading = false;
+ if (result.type === 'success') {
+ closeWalkInModal();
+ await invalidateAll();
+ }
+ await update({ reset: false });
+ };
+ }}
+ class="mt-4"
+ >
+
+
+ {walkInLoading ? 'Adding...' : 'Add & Check In'}
+
+
+ {/if}
+ {:else}
+
+
{
+ walkInLoading = true;
+ return async ({ update, result }) => {
+ walkInLoading = false;
+ if (result.type === 'success') {
+ closeWalkInModal();
+ await invalidateAll();
+ }
+ await update({ reset: false });
+ };
+ }}
+ class="space-y-4"
+ >
+
+
+ Guest Name *
+
+
+
+
+
+ Email (optional)
+
+
+
+
+ {walkInLoading ? 'Adding...' : 'Add Guest & Check In'}
+
+
+ {/if}
+
+
+
+{/if}
+
+
diff --git a/src/routes/(app)/board/members/+page.server.ts b/src/routes/(app)/board/members/+page.server.ts
new file mode 100644
index 0000000..0b06b0e
--- /dev/null
+++ b/src/routes/(app)/board/members/+page.server.ts
@@ -0,0 +1,62 @@
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async ({ locals, url }) => {
+ const searchQuery = url.searchParams.get('search') || '';
+ const statusFilter = url.searchParams.get('status') || 'all';
+ const roleFilter = url.searchParams.get('role') || 'all';
+
+ // Build the query
+ let query = locals.supabase
+ .from('members_with_dues')
+ .select('*')
+ .order('last_name', { ascending: true });
+
+ // Apply filters
+ if (statusFilter !== 'all') {
+ query = query.eq('status_name', statusFilter);
+ }
+
+ if (roleFilter !== 'all') {
+ query = query.eq('role', roleFilter);
+ }
+
+ const { data: members } = await query;
+
+ // Filter by search query in application (for name/email search)
+ let filteredMembers = members || [];
+ if (searchQuery) {
+ const lowerSearch = searchQuery.toLowerCase();
+ filteredMembers = filteredMembers.filter(
+ (m: any) =>
+ m.first_name?.toLowerCase().includes(lowerSearch) ||
+ m.last_name?.toLowerCase().includes(lowerSearch) ||
+ m.email?.toLowerCase().includes(lowerSearch) ||
+ m.member_id?.toLowerCase().includes(lowerSearch)
+ );
+ }
+
+ // Get membership statuses for filter dropdown
+ const { data: statuses } = await locals.supabase
+ .from('membership_statuses')
+ .select('*')
+ .order('sort_order', { ascending: true });
+
+ // Calculate stats
+ const stats = {
+ total: members?.length || 0,
+ active: members?.filter((m: any) => m.status_name === 'active').length || 0,
+ pending: members?.filter((m: any) => m.status_name === 'pending').length || 0,
+ inactive: members?.filter((m: any) => m.status_name === 'inactive').length || 0
+ };
+
+ return {
+ members: filteredMembers,
+ statuses: statuses || [],
+ stats,
+ filters: {
+ search: searchQuery,
+ status: statusFilter,
+ role: roleFilter
+ }
+ };
+};
diff --git a/src/routes/(app)/board/members/+page.svelte b/src/routes/(app)/board/members/+page.svelte
new file mode 100644
index 0000000..de21553
--- /dev/null
+++ b/src/routes/(app)/board/members/+page.svelte
@@ -0,0 +1,374 @@
+
+
+
+ Members Directory | Monaco USA
+
+
+
+
+
+
Members Directory
+
View and manage association members
+
+
+
+
+
+
+
+
+
+
+
+
{stats.total}
+
Total Members
+
+
+
+
+
+
+
+
+
+
{stats.active}
+
Active
+
+
+
+
+
+
+
+
+
+
{stats.pending}
+
Pending
+
+
+
+
+
+
+
+
+
+
{stats.inactive}
+
Inactive
+
+
+
+
+
+
+
+
+
+
+ {
+ searchQuery = e.currentTarget.value;
+ handleSearch(e.currentTarget.value);
+ }}
+ class="h-10 pl-9"
+ />
+
+
+
(showFilters = !showFilters)}
+ class="flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
+ >
+
+ Filters
+
+
+
+
+ {#if showFilters}
+
+
+ Status
+ updateFilters({ status: statusFilter })}
+ class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
+ >
+ All Statuses
+ {#each statuses as status}
+ {status.display_name}
+ {/each}
+
+
+
+
+ Role
+ updateFilters({ role: roleFilter })}
+ class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
+ >
+ All Roles
+ Member
+ Board
+ Admin
+
+
+
+ {/if}
+
+
+
+
+ {#if members.length === 0}
+
+
+
No members found
+
+ {filters.search || filters.status !== 'all' || filters.role !== 'all'
+ ? 'Try adjusting your search or filters.'
+ : 'Members will appear here when added.'}
+
+
+ {:else}
+
+
+
+
+
+ Member
+
+
+ Contact
+
+
+ Status
+
+
+ Dues
+
+
+ Member Since
+
+
+ Actions
+
+
+
+
+ {#each members as member}
+ {@const statusInfo = getStatusInfo(member.status_name)}
+ {@const duesInfo = getDuesInfo(member.dues_status)}
+ {@const roleBadge = getRoleBadge(member.role)}
+
+
+
+ {#if member.avatar_url}
+
+ {:else}
+
+ {member.first_name?.[0]}{member.last_name?.[0]}
+
+ {/if}
+
+
+
+ {member.first_name} {member.last_name}
+
+
+ {roleBadge.label}
+
+ {#if member.nationality && member.nationality.length > 0}
+
+ {#each member.nationality as code}
+
+ {/each}
+
+ {/if}
+
+
{member.member_id}
+
+
+
+
+
+
+ {#if member.phone}
+
+ {/if}
+
+
+
+
+
+ {member.status_display_name || member.status_name || 'Unknown'}
+
+
+
+
+
+ {duesInfo.label}
+
+ {#if member.current_due_date}
+
+ Due: {formatDate(member.current_due_date)}
+
+ {/if}
+
+
+
+ {formatDate(member.member_since)}
+
+
+
+
+
+ {/each}
+
+
+
+ {/if}
+
+
diff --git a/src/routes/(app)/board/reports/+page.server.ts b/src/routes/(app)/board/reports/+page.server.ts
new file mode 100644
index 0000000..34f3055
--- /dev/null
+++ b/src/routes/(app)/board/reports/+page.server.ts
@@ -0,0 +1,141 @@
+import type { PageServerLoad, Actions } from './$types';
+
+export const load: PageServerLoad = async ({ locals, url }) => {
+ const reportType = url.searchParams.get('type') || 'membership';
+ const year = parseInt(url.searchParams.get('year') || new Date().getFullYear().toString());
+
+ // Get all members with dues info
+ const { data: members } = await locals.supabase
+ .from('members_with_dues')
+ .select('*')
+ .order('last_name', { ascending: true });
+
+ // Get all payments for the year
+ const startOfYear = new Date(year, 0, 1).toISOString();
+ const endOfYear = new Date(year, 11, 31, 23, 59, 59).toISOString();
+
+ const { data: payments } = await locals.supabase
+ .from('dues_payments')
+ .select(`
+ *,
+ member:members(first_name, last_name, email, member_id)
+ `)
+ .gte('payment_date', startOfYear)
+ .lte('payment_date', endOfYear)
+ .order('payment_date', { ascending: false });
+
+ // Get all events for the year with attendance data
+ const { data: events } = await locals.supabase
+ .from('events_with_counts')
+ .select('*')
+ .gte('start_datetime', startOfYear)
+ .lte('start_datetime', endOfYear)
+ .order('start_datetime', { ascending: false });
+
+ // Get event RSVPs for attendance report
+ const { data: rsvps } = await locals.supabase
+ .from('event_rsvps')
+ .select(`
+ *,
+ event:events(title, start_datetime),
+ member:members(first_name, last_name, email)
+ `)
+ .eq('status', 'confirmed');
+
+ // Calculate membership statistics
+ const membershipStats = {
+ total: members?.length || 0,
+ byStatus: {} as Record,
+ byRole: {
+ admin: members?.filter(m => m.role === 'admin').length || 0,
+ board: members?.filter(m => m.role === 'board').length || 0,
+ member: members?.filter(m => m.role === 'member').length || 0
+ },
+ byDuesStatus: {
+ current: members?.filter(m => m.dues_status === 'current').length || 0,
+ due_soon: members?.filter(m => m.dues_status === 'due_soon').length || 0,
+ overdue: members?.filter(m => m.dues_status === 'overdue').length || 0,
+ never_paid: members?.filter(m => m.dues_status === 'never_paid').length || 0
+ }
+ };
+
+ // Group by status
+ for (const member of members || []) {
+ const status = member.status_display_name || 'Unknown';
+ membershipStats.byStatus[status] = (membershipStats.byStatus[status] || 0) + 1;
+ }
+
+ // Calculate dues collection statistics
+ const duesStats = {
+ totalCollected: payments?.reduce((sum, p) => sum + (p.amount || 0), 0) || 0,
+ paymentCount: payments?.length || 0,
+ byMonth: {} as Record,
+ byMethod: {} as Record
+ };
+
+ // Group payments by month
+ for (const payment of payments || []) {
+ const month = new Date(payment.payment_date).toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
+ if (!duesStats.byMonth[month]) {
+ duesStats.byMonth[month] = { amount: 0, count: 0 };
+ }
+ duesStats.byMonth[month].amount += payment.amount || 0;
+ duesStats.byMonth[month].count++;
+
+ const method = payment.payment_method || 'Unknown';
+ if (!duesStats.byMethod[method]) {
+ duesStats.byMethod[method] = { amount: 0, count: 0 };
+ }
+ duesStats.byMethod[method].amount += payment.amount || 0;
+ duesStats.byMethod[method].count++;
+ }
+
+ // Calculate event attendance statistics
+ const eventStats = {
+ totalEvents: events?.length || 0,
+ totalAttendees: events?.reduce((sum, e) => sum + (e.total_attendees || 0), 0) || 0,
+ averageAttendance: events?.length
+ ? Math.round((events.reduce((sum, e) => sum + (e.total_attendees || 0), 0) / events.length))
+ : 0,
+ byType: {} as Record
+ };
+
+ // Group events by type
+ for (const event of events || []) {
+ const type = event.event_type_name || 'General';
+ if (!eventStats.byType[type]) {
+ eventStats.byType[type] = { count: 0, attendees: 0 };
+ }
+ eventStats.byType[type].count++;
+ eventStats.byType[type].attendees += event.total_attendees || 0;
+ }
+
+ // Available years for dropdown
+ const currentYear = new Date().getFullYear();
+ const availableYears = Array.from({ length: 5 }, (_, i) => currentYear - i);
+
+ return {
+ reportType,
+ year,
+ availableYears,
+ members: members || [],
+ payments: payments || [],
+ events: events || [],
+ rsvps: rsvps || [],
+ membershipStats,
+ duesStats,
+ eventStats
+ };
+};
+
+export const actions: Actions = {
+ exportCsv: async ({ request, locals }) => {
+ const formData = await request.formData();
+ const reportType = formData.get('report_type') as string;
+ const year = parseInt(formData.get('year') as string);
+
+ // Data will be generated client-side for CSV export
+ // This action is a placeholder for server-side export if needed
+ return { success: true };
+ }
+};
diff --git a/src/routes/(app)/board/reports/+page.svelte b/src/routes/(app)/board/reports/+page.svelte
new file mode 100644
index 0000000..67e9e7d
--- /dev/null
+++ b/src/routes/(app)/board/reports/+page.svelte
@@ -0,0 +1,511 @@
+
+
+
+ Reports | Monaco USA
+
+
+
+
+
+
+
Reports
+
Generate and export membership, dues, and event reports
+
+
+
+
+ changeYear(parseInt(e.currentTarget.value))}
+ class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm focus:border-monaco-500 focus:outline-none focus:ring-2 focus:ring-monaco-500/20"
+ >
+ {#each availableYears as y}
+ {y}
+ {/each}
+
+
+
+ exportToCsv(reportType)}>
+
+ Export CSV
+
+
+
+
+
+
+ {#each reportTabs as tab}
+ changeReport(tab.id)}
+ class="flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors {reportType === tab.id
+ ? 'bg-monaco-600 text-white'
+ : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}"
+ >
+
+ {tab.label}
+
+ {/each}
+
+
+
+ {#if reportType === 'membership'}
+
+
+
+
+
+
+
Total Members
+
{membershipStats.total}
+
+
+
+
+
+
+
+
+
Current Dues
+
{membershipStats.byDuesStatus.current}
+
+
+
+
+
+
+
+
+
Overdue
+
{membershipStats.byDuesStatus.overdue}
+
+
+
+
+
+
+
+
+
Never Paid
+
{membershipStats.byDuesStatus.never_paid}
+
+
+
+
+
+
+
+
+
+
+
Members by Role
+
+
+ Administrators
+ {membershipStats.byRole.admin}
+
+
+ Board Members
+ {membershipStats.byRole.board}
+
+
+ Regular Members
+ {membershipStats.byRole.member}
+
+
+
+
+
+
+
Members by Status
+
+ {#each Object.entries(membershipStats.byStatus) as [status, count]}
+
+ {status}
+ {count}
+
+ {/each}
+
+
+
+
+
+
+
+
All Members ({members.length})
+
+
+
+
+
+ Member
+ ID
+ Status
+ Dues
+ Since
+
+
+
+ {#each members.slice(0, 20) as member}
+
+
+
+
{member.first_name} {member.last_name}
+
{member.email}
+
+
+ {member.member_id}
+
+
+ {member.status_display_name || 'Unknown'}
+
+
+
+
+ {member.dues_status.replace('_', ' ')}
+
+
+ {formatDate(member.member_since)}
+
+ {/each}
+
+
+
+ {#if members.length > 20}
+
+ Showing 20 of {members.length} members. Export CSV for full list.
+
+ {/if}
+
+
+ {/if}
+
+
+ {#if reportType === 'dues'}
+
+
+
+
+
+
+
Total Collected ({year})
+
{formatCurrency(duesStats.totalCollected)}
+
+
+
+
+
+
+
+
+
Payments
+
{duesStats.paymentCount}
+
+
+
+
+
+
+
+
+
Avg. Payment
+
+ {formatCurrency(duesStats.paymentCount > 0 ? duesStats.totalCollected / duesStats.paymentCount : 0)}
+
+
+
+
+
+
+
+
+
+
Collection Rate
+
+ {Math.round((membershipStats.byDuesStatus.current / membershipStats.total) * 100)}%
+
+
+
+
+
+
+
+
+
+
+
+
Collection by Month
+
+ {#each Object.entries(duesStats.byMonth).slice(0, 12) as [month, data]}
+
+
{month}
+
+ {formatCurrency(data.amount)}
+ ({data.count})
+
+
+ {/each}
+
+
+
+
+
+
Collection by Payment Method
+
+ {#each Object.entries(duesStats.byMethod) as [method, data]}
+
+
{method.replace('_', ' ')}
+
+ {formatCurrency(data.amount)}
+ ({data.count})
+
+
+ {/each}
+
+
+
+
+
+
+
+
All Payments ({payments.length})
+
+
+
+
+
+ Date
+ Member
+ Amount
+ Method
+ Reference
+
+
+
+ {#each payments.slice(0, 20) as payment}
+
+ {formatDate(payment.payment_date)}
+
+ {payment.member?.first_name} {payment.member?.last_name}
+
+ {formatCurrency(payment.amount)}
+ {payment.payment_method?.replace('_', ' ')}
+ {payment.reference || '-'}
+
+ {/each}
+
+
+
+ {#if payments.length > 20}
+
+ Showing 20 of {payments.length} payments. Export CSV for full list.
+
+ {/if}
+
+
+ {/if}
+
+
+ {#if reportType === 'events'}
+
+
+
+
+
+
+
Total Events ({year})
+
{eventStats.totalEvents}
+
+
+
+
+
+
+
+
+
Total Attendees
+
{eventStats.totalAttendees}
+
+
+
+
+
+
+
+
+
Avg. Attendance
+
{eventStats.averageAttendance}
+
+
+
+
+
+
+
+
+
Event Types
+
{Object.keys(eventStats.byType).length}
+
+
+
+
+
+
+
+
+
Attendance by Event Type
+
+ {#each Object.entries(eventStats.byType) as [type, data]}
+
+
{type}
+
+ {data.count} events
+ {data.attendees} attendees
+
+
+ {/each}
+
+
+
+
+
+
+
All Events ({events.length})
+
+
+
+
+
+ Date
+ Event
+ Type
+ Attendees
+ Capacity
+ Waitlist
+
+
+
+ {#each events.slice(0, 20) as event}
+
+ {formatDate(event.start_datetime)}
+ {event.title}
+
+
+ {event.event_type_name || 'General'}
+
+
+ {event.total_attendees}
+ {event.max_attendees || 'Unlimited'}
+ {event.waitlist_count}
+
+ {/each}
+
+
+
+ {#if events.length > 20}
+
+ Showing 20 of {events.length} events. Export CSV for full list.
+
+ {/if}
+
+
+ {/if}
+
diff --git a/src/routes/(app)/dashboard/+page.server.ts b/src/routes/(app)/dashboard/+page.server.ts
new file mode 100644
index 0000000..63b8887
--- /dev/null
+++ b/src/routes/(app)/dashboard/+page.server.ts
@@ -0,0 +1,84 @@
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async ({ locals, parent }) => {
+ const { member } = await parent();
+
+ // Fetch upcoming events
+ const { data: upcomingEvents } = await locals.supabase
+ .from('events_with_counts')
+ .select('*')
+ .in('visibility', getVisibleLevels(member?.role))
+ .eq('status', 'published')
+ .gte('start_datetime', new Date().toISOString())
+ .order('start_datetime', { ascending: true })
+ .limit(5);
+
+ // Fetch stats for board/admin
+ let stats = null;
+
+ if (member?.role === 'board' || member?.role === 'admin') {
+ const isAdmin = member?.role === 'admin';
+
+ // Get member counts by status
+ const { data: memberCounts } = await locals.supabase
+ .from('members_with_dues')
+ .select('status_name, dues_status');
+
+ const totalMembers = memberCounts?.length || 0;
+ const activeMembers = memberCounts?.filter((m) => m.status_name === 'active').length || 0;
+ const pendingMembers = memberCounts?.filter((m) => m.status_name === 'pending').length || 0;
+ const inactiveMembers = memberCounts?.filter((m) => m.status_name === 'inactive').length || 0;
+ const duesOverdue = memberCounts?.filter((m) => m.dues_status === 'overdue').length || 0;
+ const duesSoon = memberCounts?.filter((m) => m.dues_status === 'due_soon').length || 0;
+
+ // Get upcoming events count (next 30 days)
+ const thirtyDaysFromNow = new Date();
+ thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30);
+
+ const { count: upcomingEventsCount } = await locals.supabase
+ .from('events')
+ .select('*', { count: 'exact', head: true })
+ .eq('status', 'published')
+ .gte('start_datetime', new Date().toISOString())
+ .lte('start_datetime', thirtyDaysFromNow.toISOString());
+
+ // Get total dues collected this year (admin only)
+ let totalDuesCollected = 0;
+ if (isAdmin) {
+ const startOfYear = new Date(new Date().getFullYear(), 0, 1).toISOString();
+ const { data: payments } = await locals.supabase
+ .from('dues_payments')
+ .select('amount')
+ .gte('payment_date', startOfYear);
+
+ totalDuesCollected = payments?.reduce((sum, p) => sum + (p.amount || 0), 0) || 0;
+ }
+
+ stats = {
+ totalMembers,
+ activeMembers,
+ pendingMembers,
+ inactiveMembers,
+ duesOverdue,
+ duesSoon,
+ upcomingEventsCount: upcomingEventsCount || 0,
+ totalDuesCollected
+ };
+ }
+
+ return {
+ upcomingEvents: upcomingEvents || [],
+ stats
+ };
+};
+
+function getVisibleLevels(role: string | undefined): string[] {
+ switch (role) {
+ case 'admin':
+ return ['public', 'members', 'board', 'admin'];
+ case 'board':
+ return ['public', 'members', 'board'];
+ default:
+ return ['public', 'members'];
+ }
+}
diff --git a/src/routes/(app)/dashboard/+page.svelte b/src/routes/(app)/dashboard/+page.svelte
new file mode 100644
index 0000000..0188d8c
--- /dev/null
+++ b/src/routes/(app)/dashboard/+page.svelte
@@ -0,0 +1,110 @@
+
+
+
+ Dashboard | Monaco USA
+
+
+
+
+ {#if member}
+
+ {/if}
+
+
+ {#if member}
+
+ {/if}
+
+
+
+
+ {#if member}
+
+ {/if}
+
+
+
+
+
+
+ {#if isBoard && stats}
+
+
Board Overview
+
+
+
+
+
+
+
+ {/if}
+
+
+ {#if isAdmin && stats}
+
+
Admin Overview
+
+
+
+
+
+
+ {/if}
+
diff --git a/src/routes/(app)/documents/+page.server.ts b/src/routes/(app)/documents/+page.server.ts
new file mode 100644
index 0000000..9bce5ae
--- /dev/null
+++ b/src/routes/(app)/documents/+page.server.ts
@@ -0,0 +1,49 @@
+import type { PageServerLoad } from './$types';
+import { isS3Enabled } from '$lib/server/storage';
+
+export const load: PageServerLoad = async ({ locals, parent }) => {
+ const { member } = await parent();
+
+ // Get visible visibility levels
+ const visibleLevels = getVisibleLevels(member?.role);
+
+ // Fetch documents with all URL columns
+ const { data: documents } = await locals.supabase
+ .from('documents')
+ .select('*')
+ .in('visibility', visibleLevels)
+ .order('created_at', { ascending: false });
+
+ // Fetch categories
+ const { data: categories } = await locals.supabase
+ .from('document_categories')
+ .select('*')
+ .eq('is_active', true)
+ .order('sort_order', { ascending: true });
+
+ // Resolve active URL for each document based on current storage settings
+ const s3Enabled = await isS3Enabled();
+ const documentsWithActiveUrl = (documents || []).map((doc: any) => ({
+ ...doc,
+ // Compute active URL based on storage setting
+ active_url: s3Enabled
+ ? (doc.file_url_s3 || doc.file_path)
+ : (doc.file_url_local || doc.file_path)
+ }));
+
+ return {
+ documents: documentsWithActiveUrl,
+ categories: categories || []
+ };
+};
+
+function getVisibleLevels(role: string | undefined): string[] {
+ switch (role) {
+ case 'admin':
+ return ['public', 'members', 'board', 'admin'];
+ case 'board':
+ return ['public', 'members', 'board'];
+ default:
+ return ['public', 'members'];
+ }
+}
diff --git a/src/routes/(app)/documents/+page.svelte b/src/routes/(app)/documents/+page.svelte
new file mode 100644
index 0000000..b5fbe80
--- /dev/null
+++ b/src/routes/(app)/documents/+page.svelte
@@ -0,0 +1,270 @@
+
+
+
+ Documents | Monaco USA
+
+
+
+
+
+
Documents
+
Meeting minutes, bylaws, and resources
+
+
+
+
+ (viewMode = 'grid')}
+ class="rounded-md p-1.5 {viewMode === 'grid' ? 'bg-monaco-100 text-monaco-700' : 'text-slate-500'}"
+ >
+
+
+ (viewMode = 'list')}
+ class="rounded-md p-1.5 {viewMode === 'list' ? 'bg-monaco-100 text-monaco-700' : 'text-slate-500'}"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ (selectedCategory = null)}
+ class="rounded-full px-3 py-1.5 text-sm font-medium transition-colors {selectedCategory ===
+ null
+ ? 'bg-monaco-100 text-monaco-700'
+ : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}"
+ >
+ All
+
+ {#each categories || [] as category}
+ (selectedCategory = category.id)}
+ class="rounded-full px-3 py-1.5 text-sm font-medium transition-colors {selectedCategory ===
+ category.id
+ ? 'bg-monaco-100 text-monaco-700'
+ : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}"
+ >
+ {category.display_name}
+
+ {/each}
+
+
+
+
+ {#if filteredDocuments.length === 0}
+
+
+
No documents found
+
+ {searchQuery || selectedCategory
+ ? 'Try adjusting your search or filters.'
+ : 'Documents will appear here when added.'}
+
+
+ {:else if viewMode === 'grid'}
+
+ {#each filteredDocuments as doc}
+ {@const category = getCategory(doc.category_id)}
+
+
+
+
+
+ {getFileIcon(doc.mime_type)}
+
+ {#if category}
+
+ {category.display_name}
+
+ {/if}
+
+
+
+
+
{doc.title}
+ {#if doc.description}
+
{doc.description}
+ {/if}
+
+
+
+
+ {formatDate(doc.created_at)}
+
+ {formatFileSize(doc.file_size)}
+
+
+
+
+
+ {/each}
+
+ {:else}
+
+
+
+
+
+
+ Document
+
+
+ Category
+
+
+ Date
+
+
+ Size
+
+
+ Actions
+
+
+
+
+ {#each filteredDocuments as doc}
+ {@const category = getCategory(doc.category_id)}
+
+
+
+
+
+
{doc.title}
+
{doc.file_name}
+
+
+
+
+ {category?.display_name || '-'}
+
+
+ {formatDate(doc.created_at)}
+
+
+ {formatFileSize(doc.file_size)}
+
+
+
+
+
+ {/each}
+
+
+
+ {/if}
+
diff --git a/src/routes/(app)/events/+page.server.ts b/src/routes/(app)/events/+page.server.ts
new file mode 100644
index 0000000..175d1ac
--- /dev/null
+++ b/src/routes/(app)/events/+page.server.ts
@@ -0,0 +1,31 @@
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async ({ locals, parent }) => {
+ const { member } = await parent();
+
+ // Get visible events based on user role
+ const visibleLevels = getVisibleLevels(member?.role);
+
+ const { data: events } = await locals.supabase
+ .from('events_with_counts')
+ .select('*')
+ .in('visibility', visibleLevels)
+ .eq('status', 'published')
+ .gte('start_datetime', new Date().toISOString())
+ .order('start_datetime', { ascending: true });
+
+ return {
+ events: events || []
+ };
+};
+
+function getVisibleLevels(role: string | undefined): string[] {
+ switch (role) {
+ case 'admin':
+ return ['public', 'members', 'board', 'admin'];
+ case 'board':
+ return ['public', 'members', 'board'];
+ default:
+ return ['public', 'members'];
+ }
+}
diff --git a/src/routes/(app)/events/+page.svelte b/src/routes/(app)/events/+page.svelte
new file mode 100644
index 0000000..ce3777b
--- /dev/null
+++ b/src/routes/(app)/events/+page.svelte
@@ -0,0 +1,340 @@
+
+
+
+ Events | Monaco USA
+
+
+
+
+
+
+
Events
+
Upcoming events and activities
+
+
+
+
+ (viewMode = 'list')}
+ class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors {viewMode ===
+ 'list'
+ ? 'bg-monaco-100 text-monaco-700'
+ : 'text-slate-600 hover:bg-slate-50'}"
+ >
+
+ List
+
+ (viewMode = 'calendar')}
+ class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors {viewMode ===
+ 'calendar'
+ ? 'bg-monaco-100 text-monaco-700'
+ : 'text-slate-600 hover:bg-slate-50'}"
+ >
+
+ Calendar
+
+
+
+
+
+ {#if viewMode === 'list'}
+
+
+ {#if groupedEvents.length === 0}
+
+
+
No upcoming events
+
Check back later for new events and activities.
+
+ {:else}
+ {#each groupedEvents as group}
+ {@const groupDate = new Date(group.date)}
+
+
+
+
+
+ {groupDate.toLocaleDateString('en-US', { month: 'short' })}
+
+
+ {groupDate.getDate()}
+
+
+
+
+ {isToday(group.date)
+ ? 'Today'
+ : groupDate.toLocaleDateString('en-US', { weekday: 'long' })}
+
+
+ {groupDate.toLocaleDateString('en-US', {
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric'
+ })}
+
+
+
+
+
+
+
+ {/each}
+ {/if}
+
+ {:else}
+
+
+
+
+
+
+
+
{monthName}
+
+
+
+
+
+
+
+
+
+
Sun
+
Mon
+
Tue
+
Wed
+
Thu
+
Fri
+
Sat
+
+
+
+
+ {#each calendarDays as day}
+ {@const dayEvents = getEventsForDate(day)}
+
+ {#if day}
+
+ {day.getDate()}
+
+ {#each dayEvents.slice(0, 2) as event}
+
+ {event.title}
+
+ {/each}
+ {#if dayEvents.length > 2}
+
+{dayEvents.length - 2} more
+ {/if}
+ {/if}
+
+ {/each}
+
+
+
+ {/if}
+
diff --git a/src/routes/(app)/events/[id]/+page.server.ts b/src/routes/(app)/events/[id]/+page.server.ts
new file mode 100644
index 0000000..e772021
--- /dev/null
+++ b/src/routes/(app)/events/[id]/+page.server.ts
@@ -0,0 +1,314 @@
+import { fail, error } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+import { sendTemplatedEmail } from '$lib/server/email';
+
+export const load: PageServerLoad = async ({ locals, params, parent }) => {
+ const { member } = await parent();
+
+ // Fetch the event
+ const { data: event } = await locals.supabase
+ .from('events_with_counts')
+ .select('*')
+ .eq('id', params.id)
+ .single();
+
+ if (!event) {
+ throw error(404, 'Event not found');
+ }
+
+ // Check visibility permissions
+ const visibleLevels = getVisibleLevels(member?.role);
+ if (!visibleLevels.includes(event.visibility)) {
+ throw error(403, 'You do not have permission to view this event');
+ }
+
+ // Fetch user's RSVP if they have one
+ let rsvp = null;
+ if (member) {
+ const { data } = await locals.supabase
+ .from('event_rsvps')
+ .select('*')
+ .eq('event_id', params.id)
+ .eq('member_id', member.id)
+ .single();
+ rsvp = data;
+ }
+
+ return {
+ event,
+ rsvp
+ };
+};
+
+export const actions: Actions = {
+ rsvp: async ({ request, locals, params }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member) {
+ return fail(401, { error: 'You must be logged in to RSVP' });
+ }
+
+ const formData = await request.formData();
+ const guestCount = parseInt(formData.get('guest_count') as string) || 0;
+
+ // Fetch the event to check capacity and guest limits
+ const { data: event } = await locals.supabase
+ .from('events_with_counts')
+ .select('*')
+ .eq('id', params.id)
+ .single();
+
+ if (!event) {
+ return fail(404, { error: 'Event not found' });
+ }
+
+ // Server-side guest count validation
+ if (event.max_guests_per_member !== null && guestCount > event.max_guests_per_member) {
+ return fail(400, {
+ error: `Maximum ${event.max_guests_per_member} guest${event.max_guests_per_member === 1 ? '' : 's'} allowed per member`
+ });
+ }
+
+ // Check if event is full
+ const totalAttending = event.total_attendees + 1 + guestCount;
+ const isFull = event.max_attendees && totalAttending > event.max_attendees;
+
+ // Create RSVP
+ const { error: rsvpError } = await locals.supabase.from('event_rsvps').insert({
+ event_id: params.id,
+ member_id: member.id,
+ status: isFull ? 'waitlist' : 'confirmed',
+ guest_count: guestCount,
+ payment_status: event.is_paid ? 'pending' : 'not_required',
+ payment_amount: event.is_paid ? event.member_price * (1 + guestCount) : null
+ });
+
+ if (rsvpError) {
+ if (rsvpError.code === '23505') {
+ return fail(400, { error: 'You have already RSVP\'d to this event' });
+ }
+ console.error('RSVP error:', rsvpError);
+ return fail(500, { error: 'Failed to submit RSVP. Please try again.' });
+ }
+
+ return {
+ success: isFull
+ ? 'You have been added to the waitlist. We will notify you if a spot opens up.'
+ : 'RSVP submitted successfully!'
+ };
+ },
+
+ updateRsvp: async ({ request, locals, params }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member) {
+ return fail(401, { error: 'You must be logged in' });
+ }
+
+ const formData = await request.formData();
+ const newStatus = formData.get('status') as string;
+ const guestCount = parseInt(formData.get('guest_count') as string) || 0;
+
+ // Validate status
+ const validStatuses = ['confirmed', 'declined', 'maybe'];
+ if (!validStatuses.includes(newStatus)) {
+ return fail(400, { error: 'Invalid RSVP status' });
+ }
+
+ // Fetch the event for validation
+ const { data: event } = await locals.supabase
+ .from('events_with_counts')
+ .select('*')
+ .eq('id', params.id)
+ .single();
+
+ if (!event) {
+ return fail(404, { error: 'Event not found' });
+ }
+
+ // Get current RSVP
+ const { data: currentRsvp } = await locals.supabase
+ .from('event_rsvps')
+ .select('*')
+ .eq('event_id', params.id)
+ .eq('member_id', member.id)
+ .single();
+
+ if (!currentRsvp) {
+ return fail(404, { error: 'RSVP not found' });
+ }
+
+ // Validate guest count
+ if (event.max_guests_per_member !== null && guestCount > event.max_guests_per_member) {
+ return fail(400, {
+ error: `Maximum ${event.max_guests_per_member} guest${event.max_guests_per_member === 1 ? '' : 's'} allowed`
+ });
+ }
+
+ // If changing from waitlist/declined to confirmed, check capacity
+ if (newStatus === 'confirmed' && currentRsvp.status !== 'confirmed') {
+ const spotsNeeded = 1 + guestCount;
+ const currentConfirmed = event.total_attendees;
+ if (event.max_attendees && currentConfirmed + spotsNeeded > event.max_attendees) {
+ return fail(400, { error: 'Event is at capacity. You will remain on the waitlist.' });
+ }
+ }
+
+ // Update RSVP
+ const { error: updateError } = await locals.supabase
+ .from('event_rsvps')
+ .update({
+ status: newStatus,
+ guest_count: guestCount,
+ payment_amount: event.is_paid ? event.member_price * (1 + guestCount) : null,
+ updated_at: new Date().toISOString()
+ })
+ .eq('event_id', params.id)
+ .eq('member_id', member.id);
+
+ if (updateError) {
+ console.error('Update RSVP error:', updateError);
+ return fail(500, { error: 'Failed to update RSVP. Please try again.' });
+ }
+
+ // If changing to declined from confirmed, try to promote someone from waitlist
+ if (newStatus === 'declined' && currentRsvp.status === 'confirmed') {
+ await promoteFromWaitlist(locals.supabase, params.id as string, event);
+ }
+
+ const statusMessages: Record = {
+ confirmed: 'Great! You\'re now confirmed for this event.',
+ declined: 'You have declined this event.',
+ maybe: 'Your tentative response has been recorded.'
+ };
+
+ return { success: statusMessages[newStatus] || 'RSVP updated successfully.' };
+ },
+
+ cancel: async ({ locals, params }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member) {
+ return fail(401, { error: 'You must be logged in' });
+ }
+
+ // Get current RSVP status before deleting
+ const { data: currentRsvp } = await locals.supabase
+ .from('event_rsvps')
+ .select('status')
+ .eq('event_id', params.id)
+ .eq('member_id', member.id)
+ .single();
+
+ const { error: deleteError } = await locals.supabase
+ .from('event_rsvps')
+ .delete()
+ .eq('event_id', params.id)
+ .eq('member_id', member.id);
+
+ if (deleteError) {
+ console.error('Cancel RSVP error:', deleteError);
+ return fail(500, { error: 'Failed to cancel RSVP. Please try again.' });
+ }
+
+ // If cancelling a confirmed RSVP, try to promote someone from waitlist
+ if (currentRsvp?.status === 'confirmed') {
+ // Fetch event for capacity check
+ const { data: event } = await locals.supabase
+ .from('events_with_counts')
+ .select('*')
+ .eq('id', params.id)
+ .single();
+
+ if (event) {
+ await promoteFromWaitlist(locals.supabase, params.id as string, event);
+ }
+ }
+
+ return { success: 'RSVP cancelled successfully.' };
+ }
+};
+
+/**
+ * Promote the oldest waitlisted member to confirmed status
+ */
+async function promoteFromWaitlist(
+ supabase: typeof import('@supabase/supabase-js').SupabaseClient,
+ eventId: string,
+ event: { max_attendees: number | null; total_attendees: number; is_paid: boolean; member_price: number; title?: string; start_datetime?: string; location?: string }
+) {
+ // Check if there's room
+ if (event.max_attendees && event.total_attendees >= event.max_attendees) {
+ return; // Still full
+ }
+
+ // Get oldest waitlisted member with their info
+ const { data: waitlisted } = await supabase
+ .from('event_rsvps')
+ .select('id, member_id, guest_count, member:members(first_name, last_name, email)')
+ .eq('event_id', eventId)
+ .eq('status', 'waitlist')
+ .order('created_at', { ascending: true })
+ .limit(1)
+ .single();
+
+ if (!waitlisted) {
+ return; // No one on waitlist
+ }
+
+ // Check if promoting them (plus guests) would exceed capacity
+ const spotsNeeded = 1 + (waitlisted.guest_count || 0);
+ if (event.max_attendees && event.total_attendees + spotsNeeded > event.max_attendees) {
+ return; // Not enough room for this person + guests
+ }
+
+ // Promote to confirmed
+ await supabase
+ .from('event_rsvps')
+ .update({
+ status: 'confirmed',
+ updated_at: new Date().toISOString()
+ })
+ .eq('id', waitlisted.id);
+
+ // Send email notification to promoted member
+ const member = waitlisted.member as { first_name: string; last_name: string; email: string } | null;
+ if (member?.email) {
+ const eventDate = event.start_datetime
+ ? new Date(event.start_datetime).toLocaleDateString('en-US', {
+ weekday: 'long',
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric'
+ })
+ : 'TBD';
+
+ await sendTemplatedEmail(
+ 'waitlist_promotion',
+ member.email,
+ {
+ first_name: member.first_name,
+ event_title: event.title || 'Event',
+ event_date: eventDate,
+ event_location: event.location || 'TBD'
+ },
+ {
+ recipientId: waitlisted.member_id,
+ recipientName: `${member.first_name} ${member.last_name}`
+ }
+ );
+
+ console.log(`Promoted member ${waitlisted.member_id} from waitlist for event ${eventId} and sent notification`);
+ }
+}
+
+function getVisibleLevels(role: string | undefined): string[] {
+ switch (role) {
+ case 'admin':
+ return ['public', 'members', 'board', 'admin'];
+ case 'board':
+ return ['public', 'members', 'board'];
+ default:
+ return ['public', 'members'];
+ }
+}
diff --git a/src/routes/(app)/events/[id]/+page.svelte b/src/routes/(app)/events/[id]/+page.svelte
new file mode 100644
index 0000000..0862ff9
--- /dev/null
+++ b/src/routes/(app)/events/[id]/+page.svelte
@@ -0,0 +1,483 @@
+
+
+
+ {event?.title || 'Event'} | Monaco USA
+
+
+
+
+
+
+ Back to events
+
+
+ {#if event}
+
+
+ {#if event.cover_image_url}
+
+
+
+ {/if}
+
+
+
+
+ {event.event_type_name || 'Event'}
+
+ {#if event.is_paid}
+
+
+ €{event.member_price}
+
+ {:else}
+
+ Free
+
+ {/if}
+ {#if event.is_full}
+
+ Full
+
+ {/if}
+ {#if isPast}
+
+ Past Event
+
+ {/if}
+
+
+
{event.title}
+
+ {#if event.description}
+
{event.description}
+ {/if}
+
+
+
+
+
+
+
+
+
+
Date & Time
+ {#if event && !isPast}
+
+ {/if}
+
+
+
+
+
+
{startDateTime.date}
+
+ {startDateTime.time} - {endDateTime.time}
+
+
+
+ {#if event.location}
+
+ {/if}
+
+
+
+
+
+
Attendees
+
+
+
+
+ {event.total_attendees}
+ {event.max_attendees ? ` / ${event.max_attendees}` : ''} attending
+
+ {#if event.waitlist_count > 0}
+
{event.waitlist_count} on waitlist
+ {/if}
+
+
+
+
+
+
+
+
+
RSVP
+
+ {#if form?.error}
+
+
+
+ {/if}
+
+ {#if form?.success}
+
+
+
+ {/if}
+
+ {#if isPast}
+
This event has already ended.
+ {:else if rsvp}
+
+
+
+ {#if isPendingPayment}
+
+ Processing - Payment Required
+ {:else if rsvp.status === 'confirmed'}
+
+ You're attending!
+ {:else if rsvp.status === 'waitlist'}
+
+ You're on the waitlist
+ {:else if rsvp.status === 'maybe'}
+
+ You're tentative
+ {:else if rsvp.status === 'declined'}
+
+ You declined
+ {/if}
+
+ {#if isPendingPayment && rsvp.payment_amount}
+
+
+ Amount Due:
+ €{rsvp.payment_amount.toFixed(2)}
+
+
+ Your RSVP will be confirmed once payment is received.
+
+
+ {/if}
+ {#if rsvp.guest_count > 0}
+
+ +{rsvp.guest_count} guest{rsvp.guest_count > 1 ? 's' : ''}
+
+ {/if}
+
+
+
+ {#if rsvp.status !== 'cancelled'}
+
+
Change your response:
+
+ {#if rsvp.status !== 'confirmed' && !event.is_full}
+
{
+ loading = true;
+ return async ({ update, result }) => {
+ loading = false;
+ if (result.type === 'success') {
+ await invalidateAll();
+ }
+ await update();
+ };
+ }}
+ class="inline"
+ >
+
+
+
+
+ Going
+
+
+ {/if}
+ {#if rsvp.status !== 'maybe'}
+ {
+ loading = true;
+ return async ({ update, result }) => {
+ loading = false;
+ if (result.type === 'success') {
+ await invalidateAll();
+ }
+ await update();
+ };
+ }}
+ class="inline"
+ >
+
+
+
+
+ Maybe
+
+
+ {/if}
+ {#if rsvp.status !== 'declined'}
+ {
+ loading = true;
+ return async ({ update, result }) => {
+ loading = false;
+ if (result.type === 'success') {
+ await invalidateAll();
+ }
+ await update();
+ };
+ }}
+ class="inline"
+ >
+
+
+
+
+ Can't Go
+
+
+ {/if}
+
+
+
+
{
+ loading = true;
+ return async ({ update, result }) => {
+ loading = false;
+ if (result.type === 'success') {
+ await invalidateAll();
+ }
+ await update();
+ };
+ }}
+ >
+
+ {#if loading}
+
+ {/if}
+ Remove my RSVP
+
+
+
+ {/if}
+ {:else}
+
+
{
+ loading = true;
+ return async ({ update, result }) => {
+ loading = false;
+ if (result.type === 'success') {
+ await invalidateAll();
+ }
+ await update();
+ };
+ }}
+ class="space-y-4"
+ >
+ {#if event.max_guests_per_member > 0}
+
+
Additional guests
+
+
+ Max {event.max_guests_per_member} guest{event.max_guests_per_member > 1
+ ? 's'
+ : ''} allowed
+
+
+ {/if}
+
+ {#if event.is_paid}
+
+
+ Total: €{(event.member_price * (1 + guestCount)).toFixed(2)}
+
+
+ Payment instructions will be sent after RSVP
+
+
+ {/if}
+
+
+ {#if loading}
+
+ Submitting...
+ {:else if event.is_full}
+ Join Waitlist
+ {:else}
+
+ RSVP Now
+ {/if}
+
+
+ {/if}
+
+
+
+ {:else}
+
+
Event not found.
+
+ View all events
+
+
+ {/if}
+
diff --git a/src/routes/(app)/payments/+page.server.ts b/src/routes/(app)/payments/+page.server.ts
new file mode 100644
index 0000000..de90623
--- /dev/null
+++ b/src/routes/(app)/payments/+page.server.ts
@@ -0,0 +1,42 @@
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async ({ locals, parent }) => {
+ const { member } = await parent();
+
+ // Fetch payment history
+ const { data: payments } = await locals.supabase
+ .from('dues_payments')
+ .select('*')
+ .eq('member_id', member?.id)
+ .order('payment_date', { ascending: false });
+
+ // Fetch payment settings
+ const { data: settings } = await locals.supabase
+ .from('app_settings')
+ .select('setting_key, setting_value')
+ .eq('category', 'dues');
+
+ // Convert settings array to object
+ const paymentSettings: Record = {};
+ if (settings) {
+ for (const setting of settings) {
+ const key = setting.setting_key.replace('payment_', '');
+ const value = setting.setting_value;
+ // Handle JSON-encoded strings
+ if (typeof value === 'string' && value.startsWith('"')) {
+ try {
+ paymentSettings[key] = JSON.parse(value);
+ } catch {
+ paymentSettings[key] = value;
+ }
+ } else {
+ paymentSettings[key] = String(value);
+ }
+ }
+ }
+
+ return {
+ payments: payments || [],
+ paymentSettings
+ };
+};
diff --git a/src/routes/(app)/payments/+page.svelte b/src/routes/(app)/payments/+page.svelte
new file mode 100644
index 0000000..417d4dd
--- /dev/null
+++ b/src/routes/(app)/payments/+page.svelte
@@ -0,0 +1,286 @@
+
+
+
+ Payments | Monaco USA
+
+
+
+
+
Payments
+
View your dues status and payment history
+
+
+
+
+
+
+
+
+ Dues Status
+
+
+
+
+
+
{duesInfo.label}
+
{duesInfo.description}
+
+
+
+
+
+
Annual Dues
+
+ €{(member?.annual_dues || 50).toFixed(2)}
+
+
+
+
Membership Type
+
+ {member?.membership_type_name || 'Regular'}
+
+
+
+
Last Payment
+
+ {formatDate(member?.last_payment_date)}
+
+
+
+
Next Due Date
+
+ {formatDate(member?.current_due_date)}
+
+
+
+
+
+
+
+
+
+
+
+ Payment Details
+
+
+
+ Please make your payment via bank transfer to:
+
+
+
+ {#if paymentSettings?.bank_name}
+
+
Bank
+
{paymentSettings.bank_name}
+
+ {/if}
+
+
+
Account Holder
+
+ {paymentSettings?.account_holder || 'ASSOCIATION MONACO USA'}
+
+
+
+
+
IBAN
+
+
+ {paymentSettings?.iban || 'MC58 1756 9000 0104 0050 1001 860'}
+
+
+ copyToClipboard(
+ paymentSettings?.iban || 'MC58 1756 9000 0104 0050 1001 860',
+ 'iban'
+ )}
+ class="rounded p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
+ aria-label="Copy IBAN"
+ >
+ {#if copiedField === 'iban'}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+
Reference
+
+
+ {member?.member_id}
+
+ copyToClipboard(member?.member_id || '', 'reference')}
+ class="rounded p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
+ aria-label="Copy reference"
+ >
+ {#if copiedField === 'reference'}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+
+ Please include your Member ID ({member?.member_id}) in the payment reference.
+
+
+
+
+
+
+
+
+
+
+ Payment History
+
+
+
+ {#if payments && payments.length > 0}
+
+
+
+
+
+ Date
+
+
+ Amount
+
+
+ Period Covered
+
+
+ Reference
+
+
+ Method
+
+
+
+
+ {#each payments as payment}
+
+
+ {formatDate(payment.payment_date)}
+
+
+ €{payment.amount.toFixed(2)}
+
+
+ Until {formatDate(payment.due_date)}
+
+
+ {payment.reference || '-'}
+
+
+ {payment.payment_method?.replace('_', ' ') || 'Bank Transfer'}
+
+
+ {/each}
+
+
+
+ {:else}
+
+
+
No payments yet
+
+ Your payment history will appear here once you make your first payment.
+
+
+ {/if}
+
+
diff --git a/src/routes/(app)/profile/+page.server.ts b/src/routes/(app)/profile/+page.server.ts
new file mode 100644
index 0000000..abb2668
--- /dev/null
+++ b/src/routes/(app)/profile/+page.server.ts
@@ -0,0 +1,167 @@
+import { fail } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+import { uploadAvatar, deleteAvatar, isS3Enabled, getActiveAvatarUrl } from '$lib/server/storage';
+import { supabaseAdmin } from '$lib/server/supabase';
+
+export const load: PageServerLoad = async ({ parent }) => {
+ const { member } = await parent();
+
+ // Resolve the correct avatar URL based on current storage settings
+ if (member) {
+ const activeAvatarUrl = await getActiveAvatarUrl(member);
+ return {
+ member: {
+ ...member,
+ avatar_url: activeAvatarUrl
+ }
+ };
+ }
+
+ return { member };
+};
+
+export const actions: Actions = {
+ updateProfile: async ({ request, locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member) {
+ return fail(401, { error: 'Not authenticated' });
+ }
+
+ const formData = await request.formData();
+ const firstName = formData.get('first_name') as string;
+ const lastName = formData.get('last_name') as string;
+ const phone = formData.get('phone') as string;
+ const address = formData.get('address') as string;
+ const nationalityString = formData.get('nationality') as string;
+
+ // Validation
+ if (!firstName || firstName.length < 2) {
+ return fail(400, { error: 'First name must be at least 2 characters' });
+ }
+
+ if (!lastName || lastName.length < 2) {
+ return fail(400, { error: 'Last name must be at least 2 characters' });
+ }
+
+ if (!phone) {
+ return fail(400, { error: 'Phone number is required' });
+ }
+
+ if (!address || address.length < 10) {
+ return fail(400, { error: 'Please enter a complete address' });
+ }
+
+ const nationality = nationalityString ? nationalityString.split(',').filter(Boolean) : [];
+ if (nationality.length === 0) {
+ return fail(400, { error: 'Please select at least one nationality' });
+ }
+
+ // Update member profile (use admin client to bypass RLS)
+ const { error } = await supabaseAdmin
+ .from('members')
+ .update({
+ first_name: firstName,
+ last_name: lastName,
+ phone,
+ address,
+ nationality,
+ updated_at: new Date().toISOString()
+ })
+ .eq('id', member.id);
+
+ if (error) {
+ console.error('Failed to update profile:', error);
+ return fail(500, { error: 'Failed to update profile. Please try again.' });
+ }
+
+ return { success: 'Profile updated successfully!' };
+ },
+
+ uploadAvatar: async ({ request, locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member) {
+ return fail(401, { error: 'Not authenticated' });
+ }
+
+ const formData = await request.formData();
+ const file = formData.get('avatar') as File;
+
+ if (!file || !file.size) {
+ return fail(400, { error: 'Please select an image to upload' });
+ }
+
+ // First delete any existing avatar from both storage backends
+ if (member.avatar_path) {
+ await deleteAvatar(member.id, member.avatar_path);
+ } else {
+ await deleteAvatar(member.id);
+ }
+
+ // Upload the avatar to appropriate storage (or both)
+ const result = await uploadAvatar(member.id, file);
+
+ if (!result.success) {
+ return fail(400, { error: result.error || 'Failed to upload avatar' });
+ }
+
+ // Determine active URL based on current S3 setting
+ const s3Active = await isS3Enabled();
+ const activeUrl = s3Active ? result.s3Url : result.localUrl;
+
+ // Update member record with all avatar URLs (use admin client to bypass RLS)
+ const { error: updateError } = await supabaseAdmin
+ .from('members')
+ .update({
+ avatar_url: activeUrl || result.publicUrl,
+ avatar_url_local: result.localUrl || null,
+ avatar_url_s3: result.s3Url || null,
+ avatar_path: result.path,
+ updated_at: new Date().toISOString()
+ })
+ .eq('id', member.id);
+
+ if (updateError) {
+ console.error('Failed to update avatar URL:', updateError);
+ return fail(500, { error: 'Failed to update profile with new avatar' });
+ }
+
+ return { success: 'Avatar uploaded successfully!' };
+ },
+
+ removeAvatar: async ({ locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member) {
+ return fail(401, { error: 'Not authenticated' });
+ }
+
+ // Delete the avatar from BOTH storage backends using the stored path
+ if (member.avatar_path) {
+ await deleteAvatar(member.id, member.avatar_path);
+ } else {
+ // Fallback: try to delete common extensions
+ await deleteAvatar(member.id);
+ }
+
+ // Update member record to clear all avatar URLs (use admin client to bypass RLS)
+ const { error: updateError } = await supabaseAdmin
+ .from('members')
+ .update({
+ avatar_url: null,
+ avatar_url_local: null,
+ avatar_url_s3: null,
+ avatar_path: null,
+ updated_at: new Date().toISOString()
+ })
+ .eq('id', member.id);
+
+ if (updateError) {
+ console.error('Failed to remove avatar URL:', updateError);
+ return fail(500, { error: 'Failed to update profile' });
+ }
+
+ return { success: 'Avatar removed successfully!' };
+ }
+};
diff --git a/src/routes/(app)/profile/+page.svelte b/src/routes/(app)/profile/+page.svelte
new file mode 100644
index 0000000..039c996
--- /dev/null
+++ b/src/routes/(app)/profile/+page.svelte
@@ -0,0 +1,374 @@
+
+
+
+ My Profile | Monaco USA
+
+
+
+
+
+
+
+
+ {#if member?.avatar_url}
+
+ {:else}
+
+ {member?.first_name[0]}{member?.last_name[0]}
+
+ {/if}
+
+
+
{
+ avatarLoading = true;
+ return async ({ update }) => {
+ await invalidateAll();
+ avatarLoading = false;
+ await update();
+ };
+ }}
+ class="absolute bottom-0 right-0"
+ >
+ e.currentTarget.form?.requestSubmit()}
+ class="hidden"
+ />
+
+ {#if avatarLoading}
+
+ {:else}
+
+ {/if}
+
+
+
+
+ {#if member?.avatar_url}
+
{
+ avatarLoading = true;
+ return async ({ update }) => {
+ await invalidateAll();
+ avatarLoading = false;
+ await update();
+ };
+ }}
+ class="absolute -bottom-2 -right-2"
+ >
+
+
+
+
+ {/if}
+
+
+
+
+ {member?.first_name}
+ {member?.last_name}
+
+
{member?.email}
+
+
+ {member?.member_id}
+
+
+ {member?.status_display_name || 'Pending'}
+
+ {#if member?.role !== 'member'}
+
+ {member?.role}
+
+ {/if}
+
+
+
+
+
+
+
+
Personal Information
+
+ {#if form?.error}
+
+
+
+ {/if}
+
+ {#if form?.success}
+
+
+
+ {/if}
+
+
{
+ loading = true;
+ return async ({ update }) => {
+ await invalidateAll();
+ loading = false;
+ await update();
+ };
+ }}
+ class="space-y-6"
+ >
+
+
+
+
+
+
+
+
+
+
+
+ Street address
+
+
+
+
+
+
+ City
+
+
+
+ Country of residence
+
+
+
+
+
+
+
+
+
+ Nationality
+
+
+
+
+
+ {#if loading}
+
+ Saving...
+ {:else}
+
+ Save changes
+ {/if}
+
+
+
+
+
+
+
+
Membership Details
+
+
+
+
Member ID
+ {member?.member_id}
+
+
+
Membership Type
+ {member?.membership_type_name || 'Regular'}
+
+
+
Member Since
+
+ {member?.member_since
+ ? new Date(member.member_since).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric'
+ })
+ : 'N/A'}
+
+
+
+
Date of Birth
+
+ {member?.date_of_birth
+ ? new Date(member.date_of_birth).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric'
+ })
+ : 'N/A'}
+
+
+
+
+
diff --git a/src/routes/(app)/settings/+page.server.ts b/src/routes/(app)/settings/+page.server.ts
new file mode 100644
index 0000000..b45e978
--- /dev/null
+++ b/src/routes/(app)/settings/+page.server.ts
@@ -0,0 +1,422 @@
+import { fail } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+import { uploadAvatar, deleteAvatar } from '$lib/server/storage';
+import { sendTemplatedEmail, wrapInMonacoTemplate, sendEmail } from '$lib/server/email';
+import { getMailbox, updateMailbox, type PosteConfig } from '$lib/server/poste';
+
+export const load: PageServerLoad = async ({ parent, locals }) => {
+ const { member } = await parent();
+
+ // Load notification preferences
+ const { data: notificationPrefs } = await locals.supabase
+ .from('user_notification_preferences')
+ .select('*')
+ .eq('member_id', member.id)
+ .single();
+
+ // Check if member is board/admin and has a monacousa.org email
+ let monacoEmail: string | null = null;
+ let hasMonacoEmailAccount = false;
+
+ if (member.role === 'board' || member.role === 'admin') {
+ // Check if they have a monacousa.org email stored
+ const { data: emailRecord } = await locals.supabase
+ .from('members')
+ .select('monaco_email')
+ .eq('id', member.id)
+ .single();
+
+ if (emailRecord?.monaco_email) {
+ monacoEmail = emailRecord.monaco_email;
+ hasMonacoEmailAccount = true;
+ } else if (member.email?.endsWith('@monacousa.org')) {
+ // Their primary email is a monacousa.org email
+ monacoEmail = member.email;
+ hasMonacoEmailAccount = true;
+ }
+ }
+
+ return {
+ member,
+ notificationPrefs: notificationPrefs || {
+ email_event_rsvp_confirmation: true,
+ email_event_reminder: true,
+ email_event_updates: true,
+ email_waitlist_promotion: true,
+ email_dues_reminder: true,
+ email_payment_confirmation: true,
+ email_membership_updates: true,
+ email_announcements: true,
+ email_newsletter: true,
+ newsletter_frequency: 'monthly'
+ },
+ monacoEmail,
+ hasMonacoEmailAccount
+ };
+};
+
+export const actions: Actions = {
+ updateProfile: async ({ request, locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member) {
+ return fail(401, { error: 'Not authenticated' });
+ }
+
+ const formData = await request.formData();
+ const firstName = formData.get('first_name') as string;
+ const lastName = formData.get('last_name') as string;
+ const phone = formData.get('phone') as string;
+ const address = formData.get('address') as string;
+ const nationalityString = formData.get('nationality') as string;
+
+ // Validation
+ if (!firstName || firstName.length < 2) {
+ return fail(400, { error: 'First name must be at least 2 characters' });
+ }
+
+ if (!lastName || lastName.length < 2) {
+ return fail(400, { error: 'Last name must be at least 2 characters' });
+ }
+
+ const nationality = nationalityString ? nationalityString.split(',').filter(Boolean) : [];
+
+ // Update member profile
+ const { error } = await locals.supabase
+ .from('members')
+ .update({
+ first_name: firstName,
+ last_name: lastName,
+ phone: phone || null,
+ address: address || null,
+ nationality,
+ updated_at: new Date().toISOString()
+ })
+ .eq('id', member.id);
+
+ if (error) {
+ console.error('Failed to update profile:', error);
+ return fail(500, { error: 'Failed to update profile. Please try again.' });
+ }
+
+ return { success: 'Profile updated successfully!' };
+ },
+
+ uploadAvatar: async ({ request, locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member) {
+ return fail(401, { error: 'Not authenticated' });
+ }
+
+ const formData = await request.formData();
+ const file = formData.get('avatar') as File;
+
+ if (!file || !file.size) {
+ return fail(400, { error: 'Please select an image to upload' });
+ }
+
+ // Validate file type
+ const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
+ if (!allowedTypes.includes(file.type)) {
+ return fail(400, { error: 'Please upload a valid image (JPEG, PNG, WebP, or GIF)' });
+ }
+
+ // Validate file size (max 5MB)
+ if (file.size > 5 * 1024 * 1024) {
+ return fail(400, { error: 'Image must be less than 5MB' });
+ }
+
+ // Upload the avatar - pass user's supabase client for RLS
+ const result = await uploadAvatar(member.id, file, locals.supabase);
+
+ if (!result.success) {
+ return fail(400, { error: result.error || 'Failed to upload avatar' });
+ }
+
+ // Update member record with avatar URL
+ const { error: updateError } = await locals.supabase
+ .from('members')
+ .update({
+ avatar_url: result.publicUrl,
+ updated_at: new Date().toISOString()
+ })
+ .eq('id', member.id);
+
+ if (updateError) {
+ console.error('Failed to update avatar URL:', updateError);
+ return fail(500, { error: 'Failed to update profile with new avatar' });
+ }
+
+ return { success: 'Profile picture updated successfully!' };
+ },
+
+ removeAvatar: async ({ locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member) {
+ return fail(401, { error: 'Not authenticated' });
+ }
+
+ // Delete the avatar from storage - pass user's supabase client for RLS
+ await deleteAvatar(member.id, locals.supabase);
+
+ // Update member record to remove avatar URL
+ const { error: updateError } = await locals.supabase
+ .from('members')
+ .update({
+ avatar_url: null,
+ updated_at: new Date().toISOString()
+ })
+ .eq('id', member.id);
+
+ if (updateError) {
+ console.error('Failed to remove avatar URL:', updateError);
+ return fail(500, { error: 'Failed to update profile' });
+ }
+
+ return { success: 'Profile picture removed!' };
+ },
+
+ updateNotifications: async ({ request, locals }) => {
+ const { member } = await locals.safeGetSession();
+
+ if (!member) {
+ return fail(401, { error: 'Not authenticated' });
+ }
+
+ const formData = await request.formData();
+
+ const notificationPrefs = {
+ email_event_rsvp_confirmation: formData.get('email_event_rsvp_confirmation') === 'on',
+ email_event_reminder: formData.get('email_event_reminder') === 'on',
+ email_event_updates: formData.get('email_event_updates') === 'on',
+ email_waitlist_promotion: formData.get('email_waitlist_promotion') === 'on',
+ email_dues_reminder: formData.get('email_dues_reminder') === 'on',
+ email_payment_confirmation: formData.get('email_payment_confirmation') === 'on',
+ email_membership_updates: formData.get('email_membership_updates') === 'on',
+ email_announcements: formData.get('email_announcements') === 'on',
+ email_newsletter: formData.get('email_newsletter') === 'on',
+ newsletter_frequency: formData.get('newsletter_frequency') as string || 'monthly'
+ };
+
+ // Upsert notification preferences
+ const { error } = await locals.supabase
+ .from('user_notification_preferences')
+ .upsert({
+ member_id: member.id,
+ ...notificationPrefs,
+ updated_at: new Date().toISOString()
+ }, {
+ onConflict: 'member_id'
+ });
+
+ if (error) {
+ console.error('Failed to update notification preferences:', error);
+ return fail(500, { error: 'Failed to update notification preferences' });
+ }
+
+ return { success: 'Notification preferences saved!' };
+ },
+
+ updateEmail: async ({ request, locals }) => {
+ const { member, session } = await locals.safeGetSession();
+
+ if (!member || !session) {
+ return fail(401, { error: 'Not authenticated' });
+ }
+
+ const formData = await request.formData();
+ const newEmail = formData.get('email') as string;
+
+ if (!newEmail || !newEmail.includes('@')) {
+ return fail(400, { error: 'Please enter a valid email address' });
+ }
+
+ if (newEmail === member.email) {
+ return fail(400, { error: 'New email is the same as current email' });
+ }
+
+ // Update email in Supabase Auth (will send verification email)
+ const { error } = await locals.supabase.auth.updateUser({
+ email: newEmail
+ });
+
+ if (error) {
+ console.error('Failed to update email:', error);
+ if (error.message.includes('already registered')) {
+ return fail(400, { error: 'This email is already in use by another account' });
+ }
+ return fail(500, { error: 'Failed to update email. Please try again.' });
+ }
+
+ return { success: 'Verification email sent to your new address. Please check your inbox.' };
+ },
+
+ updatePassword: async ({ request, locals }) => {
+ const { member, session } = await locals.safeGetSession();
+
+ if (!member || !session) {
+ return fail(401, { error: 'Not authenticated' });
+ }
+
+ const formData = await request.formData();
+ const currentPassword = formData.get('current_password') as string;
+ const newPassword = formData.get('new_password') as string;
+ const confirmPassword = formData.get('confirm_password') as string;
+
+ // Validate current password is provided
+ if (!currentPassword) {
+ return fail(400, { error: 'Current password is required' });
+ }
+
+ if (!newPassword || newPassword.length < 8) {
+ return fail(400, { error: 'New password must be at least 8 characters' });
+ }
+
+ if (newPassword !== confirmPassword) {
+ return fail(400, { error: 'New passwords do not match' });
+ }
+
+ // Verify current password by re-authenticating
+ const { error: authError } = await locals.supabase.auth.signInWithPassword({
+ email: member.email,
+ password: currentPassword
+ });
+
+ if (authError) {
+ return fail(400, { error: 'Current password is incorrect' });
+ }
+
+ // Update password in Supabase Auth
+ const { error } = await locals.supabase.auth.updateUser({
+ password: newPassword
+ });
+
+ if (error) {
+ console.error('Failed to update password:', error);
+ return fail(500, { error: 'Failed to update password. Please try again.' });
+ }
+
+ // Send password changed notification email
+ const changedAt = new Date().toLocaleString('en-US', {
+ weekday: 'long',
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ timeZoneName: 'short'
+ });
+
+ // Try to send templated email, fall back to inline email if template doesn't exist
+ const templateResult = await sendTemplatedEmail(
+ 'password_changed',
+ member.email,
+ {
+ first_name: member.first_name,
+ changed_at: changedAt
+ },
+ {
+ recipientId: member.id,
+ recipientName: `${member.first_name} ${member.last_name}`
+ }
+ );
+
+ // If template doesn't exist, send a fallback email
+ if (!templateResult.success && templateResult.error?.includes('not found')) {
+ const fallbackContent = `
+ Hi ${member.first_name},
+ Your Monaco USA account password was successfully changed on ${changedAt} .
+
+
⚠️ Didn't make this change?
+
If you did not change your password, please contact us immediately at info@monacousa.org .
+
+ This is an automated security notification.
`;
+
+ await sendEmail({
+ to: member.email,
+ subject: 'Your Monaco USA Password Was Changed',
+ html: wrapInMonacoTemplate({
+ title: 'Password Changed',
+ content: fallbackContent
+ }),
+ recipientId: member.id,
+ recipientName: `${member.first_name} ${member.last_name}`,
+ emailType: 'account'
+ });
+ }
+
+ return { success: 'Password updated successfully!' };
+ },
+
+ updateMonacoEmailPassword: async ({ request, locals }) => {
+ const { member, session } = await locals.safeGetSession();
+
+ if (!member || !session) {
+ return fail(401, { error: 'Not authenticated' });
+ }
+
+ // Check if member has access to Monaco email
+ if (member.role !== 'board' && member.role !== 'admin') {
+ return fail(403, { error: 'Monaco email is only available for board members and admins' });
+ }
+
+ const formData = await request.formData();
+ const monacoEmail = formData.get('monaco_email') as string;
+ const newPassword = formData.get('monaco_new_password') as string;
+ const confirmPassword = formData.get('monaco_confirm_password') as string;
+
+ if (!monacoEmail || !monacoEmail.endsWith('@monacousa.org')) {
+ return fail(400, { error: 'Invalid Monaco USA email address' });
+ }
+
+ if (!newPassword || newPassword.length < 8) {
+ return fail(400, { error: 'Password must be at least 8 characters' });
+ }
+
+ if (newPassword !== confirmPassword) {
+ return fail(400, { error: 'Passwords do not match' });
+ }
+
+ // Get Poste configuration from app_settings
+ const { data: posteSettings } = await locals.supabase
+ .from('app_settings')
+ .select('setting_key, setting_value')
+ .eq('category', 'poste');
+
+ if (!posteSettings || posteSettings.length === 0) {
+ return fail(500, { error: 'Email server not configured. Please contact an administrator.' });
+ }
+
+ const config: PosteConfig = {
+ host: '',
+ adminEmail: '',
+ adminPassword: ''
+ };
+
+ for (const setting of posteSettings) {
+ let value = setting.setting_value;
+ if (typeof value === 'string') {
+ value = value.replace(/^"|"$/g, '');
+ }
+ if (setting.setting_key === 'poste_api_host') config.host = value as string;
+ if (setting.setting_key === 'poste_admin_email') config.adminEmail = value as string;
+ if (setting.setting_key === 'poste_admin_password') config.adminPassword = value as string;
+ }
+
+ if (!config.host || !config.adminEmail || !config.adminPassword) {
+ return fail(500, { error: 'Email server not properly configured. Please contact an administrator.' });
+ }
+
+ // Update the mailbox password
+ const result = await updateMailbox(config, monacoEmail, { password: newPassword });
+
+ if (!result.success) {
+ console.error('Failed to update Monaco email password:', result.error);
+ return fail(500, { error: result.error || 'Failed to update email password' });
+ }
+
+ return { success: 'Monaco USA email password updated successfully!' };
+ }
+};
diff --git a/src/routes/(app)/settings/+page.svelte b/src/routes/(app)/settings/+page.svelte
new file mode 100644
index 0000000..411f83a
--- /dev/null
+++ b/src/routes/(app)/settings/+page.svelte
@@ -0,0 +1,1186 @@
+
+
+
+
+
+ Settings | Monaco USA
+
+
+
+
+
Settings
+
Manage your profile, notifications, and account security
+
+
+ {#if form?.error}
+
+ {form.error}
+
+ {/if}
+
+ {#if form?.success}
+
+ {form.success}
+
+ {/if}
+
+
+
+
+ {#each tabs as tab}
+ {@const Icon = tab.icon}
+ (activeTab = tab.id)}
+ class="flex items-center gap-2 border-b-2 px-1 pb-3 text-sm font-medium transition-colors {activeTab ===
+ tab.id
+ ? 'border-monaco-600 text-monaco-600'
+ : 'border-transparent text-slate-500 hover:text-slate-700'}"
+ >
+
+ {tab.label}
+
+ {/each}
+
+
+
+
+ {#if activeTab === 'profile'}
+
+
+
+
Profile Picture
+
+
+ {#if avatarPreview || member?.avatar_url}
+
+ {:else}
+
+ {getInitials(member?.first_name || '', member?.last_name || '')}
+
+ {/if}
+
+
+
+
{
+ isSubmitting = true;
+ return async ({ update }) => {
+ await invalidateAll();
+ await update();
+ isSubmitting = false;
+ avatarPreview = null;
+ };
+ }}
+ >
+
+ {#if avatarPreview}
+
+ {isSubmitting ? 'Uploading...' : 'Save Photo'}
+
+ {:else}
+
+
+ Change
+
+ {/if}
+
+
+ {#if member?.avatar_url}
+ {
+ isSubmitting = true;
+ return async ({ update }) => {
+ await invalidateAll();
+ await update();
+ isSubmitting = false;
+ };
+ }}
+ >
+
+
+ Remove
+
+
+ {/if}
+
+
+ JPG, PNG, WebP or GIF. Max 5MB.
+
+
+
+
+
+
+
Personal Information
+
{
+ isSubmitting = true;
+ return async ({ update }) => {
+ await invalidateAll();
+ await update();
+ isSubmitting = false;
+ };
+ }}
+ class="space-y-4"
+ >
+
+
+
+
+
Nationality
+
Select all nationalities/citizenships that apply
+
+
+
+ {#if selectedNationalities.length > 0}
+
+ {#each selectedNationalities as code}
+ {@const lowerCode = code.toLowerCase()}
+
+
+ {getCountryName(code)}
+ removeNationality(code)}
+ class="ml-1 rounded-full p-0.5 hover:bg-monaco-200"
+ >
+
+
+
+ {/each}
+
+ {/if}
+
+
+
+
(nationalityDropdownOpen = !nationalityDropdownOpen)}
+ class="flex w-full items-center justify-between rounded-lg border border-slate-200 px-3 py-2 text-left text-sm transition-colors hover:border-slate-300 focus:border-monaco-500 focus:outline-none focus:ring-1 focus:ring-monaco-500"
+ >
+
+ {selectedNationalities.length === 0
+ ? 'Select nationalities...'
+ : `${selectedNationalities.length} selected`}
+
+
+
+
+ {#if nationalityDropdownOpen}
+
+
+
+
+
+
+ {#each filteredCountries as country}
+
toggleNationality(country.code)}
+ class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-slate-50 {selectedNationalities.includes(country.code) ? 'bg-monaco-50 text-monaco-700' : 'text-slate-700'}"
+ >
+
+ {country.name}
+ {#if selectedNationalities.includes(country.code)}
+ ✓
+ {/if}
+
+ {/each}
+ {#if filteredCountries.length === 0}
+
No countries found
+ {/if}
+
+
+ {/if}
+
+
+
+
+
+
+ Street Address
+
+
+
+
+
+ City
+
+
+
+ Country of Residence
+
+
+
+
+
+
+
+
+
+ {isSubmitting ? 'Saving...' : 'Save Changes'}
+
+
+
+
+
+ {/if}
+
+
+ {#if activeTab === 'notifications'}
+
{
+ isSubmitting = true;
+ return async ({ update }) => {
+ await invalidateAll();
+ await update();
+ isSubmitting = false;
+ };
+ }}
+ class="space-y-6"
+ >
+
+
+
+
+
+
+
+
Event Notifications
+
Control emails about events and RSVPs
+
+
+
+
+
+
+
RSVP Confirmations
+
Receive confirmation when you RSVP to an event
+
+
+
+
+
+
+
Event Reminders
+
Get reminded before events you're attending
+
+
+
+
+
+
+
Event Updates
+
Be notified of changes to events you're attending
+
+
+
+
+
+
+
Waitlist Promotions
+
Get notified when you're promoted from a waitlist
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Membership & Payments
+
Notifications about your membership and dues
+
+
+
+
+
+
+
Dues Reminders
+
Receive reminders when membership dues are due
+
+
+
+
+
+
+
Payment Confirmations
+
Receive confirmation when payments are recorded
+
+
+
+
+
+
+
Membership Updates
+
Important updates about your membership status
+
+
+
+
+
+
+
+
+
+
+
+
+
+
News & Updates
+
Stay informed about Monaco USA activities
+
+
+
+
+
+
+
Announcements
+
Important announcements from Monaco USA
+
+
+
+
+
+
+
Newsletter
+
Receive the Monaco USA newsletter
+
+
+
+
+
+
+
+
+ {isSubmitting ? 'Saving...' : 'Save Notification Preferences'}
+
+
+
+ {/if}
+
+
+ {#if activeTab === 'account'}
+
+
+ {#if data.hasMonacoEmailAccount && data.monacoEmail}
+
+
+
+
+
+
+
Monaco USA Email
+
Manage your @monacousa.org email account
+
+
+
+
+
+
{
+ isSubmitting = true;
+ return async ({ update }) => {
+ await invalidateAll();
+ await update();
+ isSubmitting = false;
+ };
+ }}
+ class="space-y-4"
+ >
+
+
+
+
New Email Password
+
+
+ (showPassword = !showPassword)}
+ class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
+ >
+ {#if showPassword}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+
Confirm New Password
+
+
+ (showConfirmPassword = !showConfirmPassword)}
+ class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
+ >
+ {#if showConfirmPassword}
+
+ {:else}
+
+ {/if}
+
+
+
+ Password must be at least 8 characters long.
+
+
+
+
+ {isSubmitting ? 'Updating...' : 'Update Email Password'}
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
Email Address
+
Update your email address
+
+
+
+
{
+ isSubmitting = true;
+ return async ({ update }) => {
+ await invalidateAll();
+ await update();
+ isSubmitting = false;
+ };
+ }}
+ class="space-y-4"
+ >
+
+ Current Email
+
+
+
+
+
New Email Address
+
+
+ A verification link will be sent to your new email address.
+
+
+
+
+ {isSubmitting ? 'Updating...' : 'Update Email'}
+
+
+
+
+
+
+
+
+
+
+
+
Password
+
Change your account password
+
+
+
+
{
+ isSubmitting = true;
+ return async ({ update }) => {
+ await invalidateAll();
+ await update();
+ isSubmitting = false;
+ };
+ }}
+ class="space-y-4"
+ >
+
+
Current Password
+
+
+ (showCurrentPassword = !showCurrentPassword)}
+ class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
+ >
+ {#if showCurrentPassword}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+
New Password
+
+
+ (showPassword = !showPassword)}
+ class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
+ >
+ {#if showPassword}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+
Confirm New Password
+
+
+ (showConfirmPassword = !showConfirmPassword)}
+ class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
+ >
+ {#if showConfirmPassword}
+
+ {:else}
+
+ {/if}
+
+
+
+ Password must be at least 8 characters long.
+
+
+
+
+ {isSubmitting ? 'Updating...' : 'Change Password'}
+
+
+
+
+
+
+
Membership Information
+
+
+
Member ID
+
{member?.member_id || 'N/A'}
+
+
+
Membership Type
+
{member?.membership_type?.display_name || 'N/A'}
+
+
+
Member Since
+
+ {member?.member_since
+ ? new Date(member.member_since).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric'
+ })
+ : 'N/A'}
+
+
+
+
Status
+
{member?.membership_status?.display_name || 'N/A'}
+
+
+
+
+ {/if}
+
diff --git a/src/routes/(auth)/+layout.svelte b/src/routes/(auth)/+layout.svelte
new file mode 100644
index 0000000..4a673fa
--- /dev/null
+++ b/src/routes/(auth)/+layout.svelte
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {@render children()}
+
+
+
+
+ © 2026 Monaco USA. All rights reserved.
+
+
+
diff --git a/src/routes/(auth)/forgot-password/+page.server.ts b/src/routes/(auth)/forgot-password/+page.server.ts
new file mode 100644
index 0000000..22734f7
--- /dev/null
+++ b/src/routes/(auth)/forgot-password/+page.server.ts
@@ -0,0 +1,34 @@
+import { fail } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async () => {
+ return {};
+};
+
+export const actions: Actions = {
+ default: async ({ request, locals, url }) => {
+ const formData = await request.formData();
+ const email = formData.get('email') as string;
+
+ if (!email || !email.includes('@')) {
+ return fail(400, {
+ error: 'Please enter a valid email address',
+ email
+ });
+ }
+
+ const { error } = await locals.supabase.auth.resetPasswordForEmail(email, {
+ redirectTo: `${url.origin}/auth/reset-password`
+ });
+
+ if (error) {
+ // Don't reveal if email exists or not for security
+ console.error('Password reset error:', error);
+ }
+
+ // Always show success message (don't reveal if email exists)
+ return {
+ success: 'If an account exists with this email, you will receive a password reset link shortly.'
+ };
+ }
+};
diff --git a/src/routes/(auth)/forgot-password/+page.svelte b/src/routes/(auth)/forgot-password/+page.svelte
new file mode 100644
index 0000000..d242b31
--- /dev/null
+++ b/src/routes/(auth)/forgot-password/+page.svelte
@@ -0,0 +1,83 @@
+
+
+
+ Forgot Password | Monaco USA
+
+
+
+
+
Forgot your password?
+
+ Enter your email and we'll send you a reset link
+
+
+
+ {#if form?.error}
+
+ {/if}
+
+ {#if form?.success}
+
+
+ {:else}
+
{
+ loading = true;
+ return async ({ update }) => {
+ loading = false;
+ await update();
+ };
+ }}
+ class="space-y-4"
+ >
+
+
+
+ {#if loading}
+
+ Sending reset link...
+ {:else}
+ Send reset link
+ {/if}
+
+
+
+
+ {/if}
+
diff --git a/src/routes/(auth)/login/+page.server.ts b/src/routes/(auth)/login/+page.server.ts
new file mode 100644
index 0000000..b2cd18c
--- /dev/null
+++ b/src/routes/(auth)/login/+page.server.ts
@@ -0,0 +1,90 @@
+import { fail, redirect } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async ({ url, locals }) => {
+ const { session } = await locals.safeGetSession();
+
+ // If already logged in, redirect to dashboard
+ if (session) {
+ throw redirect(303, '/dashboard');
+ }
+
+ // Handle URL-based error messages
+ const errorCode = url.searchParams.get('error');
+ let errorMessage: string | null = null;
+
+ if (errorCode === 'no_profile') {
+ errorMessage = 'Your account is not properly configured. Please contact support or try signing up again.';
+ } else if (errorCode === 'expired') {
+ errorMessage = 'Your session has expired. Please sign in again.';
+ } else if (errorCode) {
+ errorMessage = decodeURIComponent(errorCode);
+ }
+
+ return {
+ redirectTo: url.searchParams.get('redirectTo') || '/dashboard',
+ urlError: errorMessage
+ };
+};
+
+export const actions: Actions = {
+ default: async ({ request, locals, url }) => {
+ const formData = await request.formData();
+ const email = formData.get('email') as string;
+ const password = formData.get('password') as string;
+ const redirectTo = url.searchParams.get('redirectTo') || '/dashboard';
+
+ if (!email || !password) {
+ return fail(400, {
+ error: 'Please enter your email and password',
+ email
+ });
+ }
+
+ const { data, error } = await locals.supabase.auth.signInWithPassword({
+ email,
+ password
+ });
+
+ if (error) {
+ // Handle specific error cases
+ if (error.message.includes('Invalid login credentials')) {
+ return fail(400, {
+ error: 'Invalid email or password. Please try again.',
+ email
+ });
+ }
+
+ if (error.message.includes('Email not confirmed')) {
+ return fail(400, {
+ error: 'Please verify your email address before signing in. Check your inbox for the verification link.',
+ email
+ });
+ }
+
+ return fail(400, {
+ error: error.message,
+ email
+ });
+ }
+
+ // Check if member profile exists
+ const { data: member } = await locals.supabase
+ .from('members')
+ .select('id')
+ .eq('id', data.user.id)
+ .single();
+
+ if (!member) {
+ // User exists in auth but not in members table - unusual situation
+ // They may have been deleted or there was a signup issue
+ await locals.supabase.auth.signOut();
+ return fail(400, {
+ error: 'Your account is not properly configured. Please contact support.',
+ email
+ });
+ }
+
+ throw redirect(303, redirectTo);
+ }
+};
diff --git a/src/routes/(auth)/login/+page.svelte b/src/routes/(auth)/login/+page.svelte
new file mode 100644
index 0000000..0929262
--- /dev/null
+++ b/src/routes/(auth)/login/+page.svelte
@@ -0,0 +1,110 @@
+
+
+
+ Sign In | Monaco USA
+
+
+
+
+
Welcome back
+
Sign in to your member account
+
+
+ {#if data.urlError}
+
+ {/if}
+
+ {#if form?.error}
+
+ {/if}
+
+ {#if form?.success}
+
+ {/if}
+
+
{
+ loading = true;
+ return async ({ update }) => {
+ loading = false;
+ await update();
+ };
+ }}
+ class="space-y-4"
+ >
+
+
+
+
+
+
+
+ {#if loading}
+
+ Signing in...
+ {:else}
+ Sign in
+ {/if}
+
+
+
+
+
+ Don't have an account?
+
+ Sign up
+
+
+
+
diff --git a/src/routes/(auth)/signup/+page.server.ts b/src/routes/(auth)/signup/+page.server.ts
new file mode 100644
index 0000000..1b5cf88
--- /dev/null
+++ b/src/routes/(auth)/signup/+page.server.ts
@@ -0,0 +1,235 @@
+import { fail, redirect } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+import { supabaseAdmin } from '$lib/server/supabase';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ const { session } = await locals.safeGetSession();
+
+ // If already logged in, redirect to dashboard
+ if (session) {
+ throw redirect(303, '/dashboard');
+ }
+
+ return {};
+};
+
+export const actions: Actions = {
+ default: async ({ request, locals, url }) => {
+ const formData = await request.formData();
+
+ // Extract form fields
+ const firstName = formData.get('first_name') as string;
+ const lastName = formData.get('last_name') as string;
+ const email = formData.get('email') as string;
+ const phone = formData.get('phone') as string;
+ const dateOfBirth = formData.get('date_of_birth') as string;
+ const address = formData.get('address') as string;
+ const nationalityString = formData.get('nationality') as string;
+ const password = formData.get('password') as string;
+ const confirmPassword = formData.get('confirm_password') as string;
+ const terms = formData.get('terms');
+
+ // Validation
+ const errors: Record = {};
+
+ if (!firstName || firstName.length < 2) {
+ errors.first_name = 'First name must be at least 2 characters';
+ }
+
+ if (!lastName || lastName.length < 2) {
+ errors.last_name = 'Last name must be at least 2 characters';
+ }
+
+ if (!email || !email.includes('@')) {
+ errors.email = 'Please enter a valid email address';
+ }
+
+ if (!phone) {
+ errors.phone = 'Phone number is required';
+ }
+
+ if (!dateOfBirth) {
+ errors.date_of_birth = 'Date of birth is required';
+ } else {
+ // Check if 18+
+ const birthDate = new Date(dateOfBirth);
+ const today = new Date();
+ const age = today.getFullYear() - birthDate.getFullYear();
+ const monthDiff = today.getMonth() - birthDate.getMonth();
+ const dayDiff = today.getDate() - birthDate.getDate();
+ const actualAge = monthDiff < 0 || (monthDiff === 0 && dayDiff < 0) ? age - 1 : age;
+
+ if (actualAge < 18) {
+ errors.date_of_birth = 'You must be at least 18 years old to join';
+ }
+ }
+
+ if (!address || address.length < 10) {
+ errors.address = 'Please enter a complete address';
+ }
+
+ const nationality = nationalityString ? nationalityString.split(',').filter(Boolean) : [];
+ if (nationality.length === 0) {
+ errors.nationality = 'Please select at least one nationality';
+ }
+
+ if (!password || password.length < 8) {
+ errors.password = 'Password must be at least 8 characters';
+ }
+
+ if (password !== confirmPassword) {
+ errors.confirm_password = 'Passwords do not match';
+ }
+
+ if (!terms) {
+ errors.terms = 'You must accept the terms and conditions';
+ }
+
+ // Return validation errors
+ if (Object.keys(errors).length > 0) {
+ return fail(400, {
+ error: Object.values(errors)[0],
+ first_name: firstName,
+ last_name: lastName,
+ email,
+ phone,
+ date_of_birth: dateOfBirth,
+ address
+ });
+ }
+
+ // Create Supabase auth user
+ const { data: authData, error: authError } = await locals.supabase.auth.signUp({
+ email,
+ password,
+ options: {
+ emailRedirectTo: `${url.origin}/auth/callback`,
+ data: {
+ first_name: firstName,
+ last_name: lastName
+ }
+ }
+ });
+
+ if (authError) {
+ if (authError.message.includes('already registered')) {
+ return fail(400, {
+ error: 'An account with this email already exists. Try signing in instead.',
+ first_name: firstName,
+ last_name: lastName,
+ email,
+ phone,
+ date_of_birth: dateOfBirth,
+ address
+ });
+ }
+
+ return fail(400, {
+ error: authError.message,
+ first_name: firstName,
+ last_name: lastName,
+ email,
+ phone,
+ date_of_birth: dateOfBirth,
+ address
+ });
+ }
+
+ if (!authData.user) {
+ return fail(500, {
+ error: 'Failed to create account. Please try again.',
+ first_name: firstName,
+ last_name: lastName,
+ email,
+ phone,
+ date_of_birth: dateOfBirth,
+ address
+ });
+ }
+
+ // Get the default membership status (pending)
+ const { data: defaultStatus, error: statusError } = await locals.supabase
+ .from('membership_statuses')
+ .select('id')
+ .eq('is_default', true)
+ .single();
+
+ // Get the default membership type
+ const { data: defaultType, error: typeError } = await locals.supabase
+ .from('membership_types')
+ .select('id')
+ .eq('is_default', true)
+ .single();
+
+ // Validate that default status and type exist
+ if (statusError || !defaultStatus?.id) {
+ console.error('No default membership status found:', statusError);
+ // Clean up the auth user since we can't complete registration
+ await supabaseAdmin.auth.admin.deleteUser(authData.user.id);
+ return fail(500, {
+ error: 'System configuration error. Please contact support.',
+ first_name: firstName,
+ last_name: lastName,
+ email,
+ phone,
+ date_of_birth: dateOfBirth,
+ address
+ });
+ }
+
+ if (typeError || !defaultType?.id) {
+ console.error('No default membership type found:', typeError);
+ // Clean up the auth user since we can't complete registration
+ await supabaseAdmin.auth.admin.deleteUser(authData.user.id);
+ return fail(500, {
+ error: 'System configuration error. Please contact support.',
+ first_name: firstName,
+ last_name: lastName,
+ email,
+ phone,
+ date_of_birth: dateOfBirth,
+ address
+ });
+ }
+
+ // Create member profile
+ const { error: memberError } = await locals.supabase.from('members').insert({
+ id: authData.user.id,
+ first_name: firstName,
+ last_name: lastName,
+ email,
+ phone,
+ date_of_birth: dateOfBirth,
+ address,
+ nationality,
+ role: 'member',
+ membership_status_id: defaultStatus.id,
+ membership_type_id: defaultType.id
+ });
+
+ if (memberError) {
+ // Clean up the auth user since member profile creation failed
+ console.error('Failed to create member profile:', memberError);
+ try {
+ await supabaseAdmin.auth.admin.deleteUser(authData.user.id);
+ } catch (deleteError) {
+ console.error('Failed to clean up auth user:', deleteError);
+ }
+ return fail(500, {
+ error: 'Failed to create member profile. Please try again or contact support.',
+ first_name: firstName,
+ last_name: lastName,
+ email,
+ phone,
+ date_of_birth: dateOfBirth,
+ address
+ });
+ }
+
+ // Return success - user needs to verify email
+ return {
+ success:
+ 'Account created! Please check your email to verify your account before signing in.'
+ };
+ }
+};
diff --git a/src/routes/(auth)/signup/+page.svelte b/src/routes/(auth)/signup/+page.svelte
new file mode 100644
index 0000000..fec2221
--- /dev/null
+++ b/src/routes/(auth)/signup/+page.svelte
@@ -0,0 +1,256 @@
+
+
+
+ Sign Up | Monaco USA
+
+
+
+
+
Create your account
+
Join the Monaco USA community
+
+
+ {#if form?.error}
+
+ {/if}
+
+ {#if form?.success}
+
+ {:else}
+
{
+ loading = true;
+ return async ({ update }) => {
+ loading = false;
+ await update();
+ };
+ }}
+ class="space-y-4"
+ >
+
+
+
+
+
+
+ Email address *
+
+
+
+
+
+
+
+ Phone number *
+
+
+
+
+
+
+
+ Date of birth *
+
+
+
You must be at least 18 years old to join.
+
+
+
+
+
+ Address *
+
+
+
+
+
+
+
+ Nationality *
+
+
+ {#if selectedNationalities.length === 0}
+
Select at least one nationality.
+ {/if}
+
+
+
+
+
+ Password *
+
+
+
At least 8 characters.
+
+
+
+
+
+ Confirm password *
+
+
+
+
+
+
+
+
+ {#if loading}
+
+ Creating account...
+ {:else}
+ Create account
+ {/if}
+
+
+ {/if}
+
+
+
+ Already have an account?
+ Sign in
+
+
+
diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte
new file mode 100644
index 0000000..a42a112
--- /dev/null
+++ b/src/routes/+error.svelte
@@ -0,0 +1,143 @@
+
+
+
+ {status} - {errorInfo.title} | Monaco USA
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{status}
+
+
+
{errorInfo.title}
+
+
+
{errorInfo.message}
+
+
+
+
+
+
+
+ Go Home
+
+
+
history.back()}>
+
+
+
+ Go Back
+
+
+
+
+
+
+ Need help?
+
+ Contact support
+
+
+
+
+
© 2026 Monaco USA. All rights reserved.
+
+
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
new file mode 100644
index 0000000..a424b90
--- /dev/null
+++ b/src/routes/+layout.svelte
@@ -0,0 +1,22 @@
+
+
+
+
+ Monaco USA Portal
+
+
+
+
+
+
+
+ {@render children()}
+
diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts
new file mode 100644
index 0000000..915bea8
--- /dev/null
+++ b/src/routes/+page.server.ts
@@ -0,0 +1,13 @@
+import { redirect } from '@sveltejs/kit';
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async ({ locals }) => {
+ const { session } = await locals.safeGetSession();
+
+ // If logged in, go to dashboard; otherwise go to login
+ if (session) {
+ throw redirect(303, '/dashboard');
+ } else {
+ throw redirect(303, '/login');
+ }
+};
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
new file mode 100644
index 0000000..50beda5
--- /dev/null
+++ b/src/routes/+page.svelte
@@ -0,0 +1,222 @@
+
+
+
+
+
+
+
+
+
+
+
+ Member Portal 2026
+
+
+
+ Monaco USA
+
+
+
+ Americans in Monaco - Your gateway to community events, member resources, and association
+ management.
+
+
+
+
+ Sign In
+
+
+ Learn More
+
+
+
+
+
+
+
+
+
+ Member Directory
+
+ Connect with fellow Americans in Monaco through our comprehensive member directory.
+
+
+
+
+
+
+
+ Events Calendar
+
+ Stay updated with social gatherings, meetings, and special events in the community.
+
+
+
+
+
+
+
+ Documents & Resources
+
+ Access meeting minutes, bylaws, and important association documents anytime.
+
+
+
+
+
+
+
+ Dues Management
+
+ Track your membership dues, view payment history, and manage your subscription.
+
+
+
+
+
+
+
+ Notifications
+
+ Receive timely reminders about events, dues, and important announcements.
+
+
+
+
+
+
+
+ Secure Access
+
+ Role-based access ensures members, board, and admins see exactly what they need.
+
+
+
+
+
+
+
+
+
+
150+
+
Active Members
+
+
+
50+
+
Events Per Year
+
+
+
+
100%
+
Community Driven
+
+
+
+
+
+
+ © 2026 Monaco USA. All rights reserved.
+ Americans in Monaco since 1999
+
+
+
diff --git a/src/routes/api/auth/check-verification/+server.ts b/src/routes/api/auth/check-verification/+server.ts
new file mode 100644
index 0000000..b781e4e
--- /dev/null
+++ b/src/routes/api/auth/check-verification/+server.ts
@@ -0,0 +1,16 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+
+export const GET: RequestHandler = async ({ locals }) => {
+ const { session, user } = await locals.safeGetSession();
+
+ if (!session || !user) {
+ return json({ verified: false, error: 'Not authenticated' }, { status: 401 });
+ }
+
+ // Check if email is verified
+ // In Supabase, this is stored in user metadata
+ const emailVerified = user.email_confirmed_at !== null;
+
+ return json({ verified: emailVerified });
+};
diff --git a/src/routes/api/auth/resend-verification/+server.ts b/src/routes/api/auth/resend-verification/+server.ts
new file mode 100644
index 0000000..36b1de7
--- /dev/null
+++ b/src/routes/api/auth/resend-verification/+server.ts
@@ -0,0 +1,30 @@
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+
+export const POST: RequestHandler = async ({ locals, url }) => {
+ const { session, user } = await locals.safeGetSession();
+
+ if (!session || !user) {
+ return json({ error: 'Not authenticated' }, { status: 401 });
+ }
+
+ if (!user.email) {
+ return json({ error: 'No email associated with account' }, { status: 400 });
+ }
+
+ // Resend verification email
+ const { error } = await locals.supabase.auth.resend({
+ type: 'signup',
+ email: user.email,
+ options: {
+ emailRedirectTo: `${url.origin}/join?verified=true`
+ }
+ });
+
+ if (error) {
+ console.error('Failed to resend verification email:', error);
+ return json({ error: error.message }, { status: 500 });
+ }
+
+ return json({ success: true, message: 'Verification email sent' });
+};
diff --git a/src/routes/api/calendar/events/[id]/+server.ts b/src/routes/api/calendar/events/[id]/+server.ts
new file mode 100644
index 0000000..c4c8c5f
--- /dev/null
+++ b/src/routes/api/calendar/events/[id]/+server.ts
@@ -0,0 +1,97 @@
+/**
+ * API endpoint for downloading a single event as an .ics file
+ * Requires authentication for non-public events
+ */
+
+import { error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { generateSingleEventIcal } from '$lib/server/ical';
+
+export const GET: RequestHandler = async ({ params, locals, url }) => {
+ const eventId = params.id;
+
+ // Get user session
+ const { member } = await locals.safeGetSession();
+
+ // Fetch the event
+ const { data: event, error: fetchError } = await locals.supabase
+ .from('events')
+ .select(`
+ id,
+ title,
+ description,
+ start_datetime,
+ end_datetime,
+ location,
+ location_url,
+ timezone,
+ status,
+ visibility,
+ all_day,
+ event_type:event_types(name)
+ `)
+ .eq('id', eventId)
+ .single();
+
+ if (fetchError || !event) {
+ throw error(404, 'Event not found');
+ }
+
+ // Check visibility permissions
+ const canView = checkVisibility(event.visibility, member?.role);
+ if (!canView) {
+ throw error(403, 'You do not have permission to view this event');
+ }
+
+ // Generate iCal content
+ const baseUrl = url.origin || 'https://monacousa.org';
+ const icalContent = generateSingleEventIcal(
+ {
+ id: event.id,
+ title: event.title,
+ description: event.description || undefined,
+ start_datetime: event.start_datetime,
+ end_datetime: event.end_datetime,
+ location: event.location,
+ location_url: event.location_url,
+ timezone: event.timezone || 'Europe/Monaco',
+ status: event.status as 'published' | 'cancelled' | 'draft',
+ event_type_name: (event.event_type as { name: string } | null)?.name,
+ organizer_name: 'Monaco USA',
+ organizer_email: 'events@monacousa.org',
+ all_day: event.all_day || false
+ },
+ baseUrl
+ );
+
+ // Generate filename
+ const sanitizedTitle = event.title
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-|-$/g, '')
+ .substring(0, 50);
+ const filename = `monaco-usa-${sanitizedTitle}.ics`;
+
+ return new Response(icalContent, {
+ headers: {
+ 'Content-Type': 'text/calendar; charset=utf-8',
+ 'Content-Disposition': `attachment; filename="${filename}"`,
+ 'Cache-Control': 'no-cache, no-store, must-revalidate'
+ }
+ });
+};
+
+function checkVisibility(visibility: string, role?: string): boolean {
+ switch (visibility) {
+ case 'public':
+ return true;
+ case 'members':
+ return !!role; // Any authenticated member
+ case 'board':
+ return role === 'board' || role === 'admin';
+ case 'admin':
+ return role === 'admin';
+ default:
+ return false;
+ }
+}
diff --git a/src/routes/api/calendar/feed/+server.ts b/src/routes/api/calendar/feed/+server.ts
new file mode 100644
index 0000000..0fa16bf
--- /dev/null
+++ b/src/routes/api/calendar/feed/+server.ts
@@ -0,0 +1,117 @@
+/**
+ * API endpoint for subscribing to Monaco USA events calendar feed
+ * Returns iCal feed of upcoming events
+ *
+ * Usage:
+ * - /api/calendar/feed - Public events only (no auth required)
+ * - /api/calendar/feed?token=xxx - Member events with auth token
+ *
+ * Subscribe URL: webcal://yourdomain.com/api/calendar/feed
+ */
+
+import type { RequestHandler } from './$types';
+import { generateCalendarFeed, type ICalEvent } from '$lib/server/ical';
+import { supabaseAdmin } from '$lib/server/supabase';
+
+export const GET: RequestHandler = async ({ url, locals }) => {
+ const token = url.searchParams.get('token');
+ const includePrivate = url.searchParams.get('private') === 'true';
+
+ // Determine visibility level based on authentication
+ let visibilityLevels = ['public'];
+ let calendarName = 'Monaco USA Public Events';
+
+ // Check if user is authenticated (via session or token)
+ const { member } = await locals.safeGetSession();
+
+ if (member) {
+ // Authenticated user - include member events
+ visibilityLevels = getVisibilityLevels(member.role);
+ calendarName = 'Monaco USA Events';
+ } else if (token) {
+ // Token-based access (for calendar subscriptions)
+ // Verify the token against a member's calendar token
+ const { data: memberWithToken } = await supabaseAdmin
+ .from('members')
+ .select('id, role')
+ .eq('calendar_token', token)
+ .single();
+
+ if (memberWithToken) {
+ visibilityLevels = getVisibilityLevels(memberWithToken.role);
+ calendarName = 'Monaco USA Events';
+ }
+ }
+
+ // Fetch upcoming events
+ const now = new Date();
+ const threeMonthsFromNow = new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000);
+
+ const { data: events, error: fetchError } = await supabaseAdmin
+ .from('events')
+ .select(`
+ id,
+ title,
+ description,
+ start_datetime,
+ end_datetime,
+ location,
+ location_url,
+ timezone,
+ status,
+ visibility,
+ all_day,
+ event_type:event_types(name)
+ `)
+ .in('visibility', visibilityLevels)
+ .eq('status', 'published')
+ .gte('start_datetime', now.toISOString())
+ .lte('start_datetime', threeMonthsFromNow.toISOString())
+ .order('start_datetime', { ascending: true });
+
+ if (fetchError) {
+ console.error('Error fetching events for feed:', fetchError);
+ return new Response('Error fetching events', { status: 500 });
+ }
+
+ // Convert to ICalEvent format
+ const baseUrl = url.origin || 'https://monacousa.org';
+ const icalEvents: ICalEvent[] = (events || []).map(event => ({
+ id: event.id,
+ title: event.title,
+ description: event.description || undefined,
+ start_datetime: event.start_datetime,
+ end_datetime: event.end_datetime,
+ location: event.location,
+ location_url: event.location_url,
+ timezone: event.timezone || 'Europe/Monaco',
+ status: event.status as 'published' | 'cancelled' | 'draft',
+ event_type_name: (event.event_type as { name: string } | null)?.name,
+ organizer_name: 'Monaco USA',
+ organizer_email: 'events@monacousa.org',
+ all_day: event.all_day || false
+ }));
+
+ // Generate iCal feed
+ const icalContent = generateCalendarFeed(icalEvents, calendarName, baseUrl);
+
+ return new Response(icalContent, {
+ headers: {
+ 'Content-Type': 'text/calendar; charset=utf-8',
+ 'Cache-Control': 'public, max-age=3600', // Cache for 1 hour
+ 'X-WR-CALNAME': calendarName
+ }
+ });
+};
+
+function getVisibilityLevels(role?: string): string[] {
+ switch (role) {
+ case 'admin':
+ return ['public', 'members', 'board', 'admin'];
+ case 'board':
+ return ['public', 'members', 'board'];
+ case 'member':
+ default:
+ return ['public', 'members'];
+ }
+}
diff --git a/src/routes/api/calendar/public/events/[id]/+server.ts b/src/routes/api/calendar/public/events/[id]/+server.ts
new file mode 100644
index 0000000..689b53b
--- /dev/null
+++ b/src/routes/api/calendar/public/events/[id]/+server.ts
@@ -0,0 +1,77 @@
+/**
+ * API endpoint for downloading a public event as an .ics file
+ * No authentication required - only works for public visibility events
+ */
+
+import { error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { generateSingleEventIcal } from '$lib/server/ical';
+
+export const GET: RequestHandler = async ({ params, url }) => {
+ const eventId = params.id;
+
+ // Fetch the event using supabaseAdmin since this is a public endpoint
+ const { supabaseAdmin } = await import('$lib/server/supabase');
+
+ const { data: event, error: fetchError } = await supabaseAdmin
+ .from('events')
+ .select(`
+ id,
+ title,
+ description,
+ start_datetime,
+ end_datetime,
+ location,
+ location_url,
+ timezone,
+ status,
+ visibility,
+ all_day,
+ event_type:event_types(name)
+ `)
+ .eq('id', eventId)
+ .eq('visibility', 'public')
+ .eq('status', 'published')
+ .single();
+
+ if (fetchError || !event) {
+ throw error(404, 'Event not found or not publicly accessible');
+ }
+
+ // Generate iCal content
+ const baseUrl = url.origin || 'https://monacousa.org';
+ const icalContent = generateSingleEventIcal(
+ {
+ id: event.id,
+ title: event.title,
+ description: event.description || undefined,
+ start_datetime: event.start_datetime,
+ end_datetime: event.end_datetime,
+ location: event.location,
+ location_url: event.location_url,
+ timezone: event.timezone || 'Europe/Monaco',
+ status: event.status as 'published' | 'cancelled' | 'draft',
+ event_type_name: (event.event_type as { name: string } | null)?.name,
+ organizer_name: 'Monaco USA',
+ organizer_email: 'events@monacousa.org',
+ all_day: event.all_day || false
+ },
+ baseUrl
+ );
+
+ // Generate filename
+ const sanitizedTitle = event.title
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-|-$/g, '')
+ .substring(0, 50);
+ const filename = `monaco-usa-${sanitizedTitle}.ics`;
+
+ return new Response(icalContent, {
+ headers: {
+ 'Content-Type': 'text/calendar; charset=utf-8',
+ 'Content-Disposition': `attachment; filename="${filename}"`,
+ 'Cache-Control': 'public, max-age=300' // Cache for 5 minutes
+ }
+ });
+};
diff --git a/src/routes/api/cron/dues-reminders/+server.ts b/src/routes/api/cron/dues-reminders/+server.ts
new file mode 100644
index 0000000..1a7902d
--- /dev/null
+++ b/src/routes/api/cron/dues-reminders/+server.ts
@@ -0,0 +1,256 @@
+/**
+ * Cron API Endpoint for Automated Dues Reminders
+ *
+ * This endpoint should be called daily by an external cron service
+ * (e.g., Vercel Cron, GitHub Actions, or a server cron job)
+ *
+ * Security: Requires CRON_SECRET header for authentication
+ *
+ * Example cron setup (daily at 9 AM):
+ * 0 9 * * * curl -X POST https://yourdomain.com/api/cron/dues-reminders \
+ * -H "Authorization: Bearer YOUR_CRON_SECRET"
+ */
+
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { env } from '$env/dynamic/private';
+import {
+ getDuesSettings,
+ sendBulkReminders,
+ processGracePeriodExpirations,
+ getMembersNeedingReminder,
+ getMembersNeedingOnboardingReminder,
+ sendOnboardingReminders,
+ processOnboardingExpirations,
+ type ReminderType,
+ type OnboardingReminderType
+} from '$lib/server/dues';
+
+const CRON_SECRET = env.CRON_SECRET;
+
+export const POST: RequestHandler = async ({ request, url }) => {
+ // Verify cron secret
+ const authHeader = request.headers.get('authorization');
+ const token = authHeader?.replace('Bearer ', '');
+
+ if (!CRON_SECRET) {
+ console.error('CRON_SECRET not configured');
+ return json({ error: 'Server not configured for cron jobs' }, { status: 500 });
+ }
+
+ if (token !== CRON_SECRET) {
+ return json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const baseUrl = url.origin || env.SITE_URL || 'https://monacousa.org';
+ const dryRun = url.searchParams.get('dry_run') === 'true';
+
+ try {
+ // Load reminder settings
+ const settings = await getDuesSettings();
+ const reminderDays = settings.reminder_days_before || [30, 7, 1];
+
+ const results = {
+ timestamp: new Date().toISOString(),
+ dryRun,
+ settings: {
+ reminder_days_before: reminderDays,
+ grace_period_days: settings.grace_period_days,
+ auto_inactive_enabled: settings.auto_inactive_enabled
+ },
+ reminders: {
+ due_soon_30: { eligible: 0, sent: 0, skipped: 0, errors: [] as string[] },
+ due_soon_7: { eligible: 0, sent: 0, skipped: 0, errors: [] as string[] },
+ due_soon_1: { eligible: 0, sent: 0, skipped: 0, errors: [] as string[] }
+ } as Record,
+ overdue: { eligible: 0, sent: 0, skipped: 0, errors: [] as string[] },
+ graceWarning: { eligible: 0, sent: 0, skipped: 0, errors: [] as string[] },
+ // Onboarding reminders for new members with payment_deadline
+ onboarding: {
+ reminder_7: { eligible: 0, sent: 0, skipped: 0, errors: [] as string[] },
+ reminder_1: { eligible: 0, sent: 0, skipped: 0, errors: [] as string[] },
+ expired: { eligible: 0, sent: 0, skipped: 0, errors: [] as string[] }
+ },
+ inactivated: [] as Array<{ id: string; name: string; email: string }>,
+ onboardingInactivated: [] as Array<{ id: string; name: string; email: string }>,
+ summary: {
+ totalRemindersSent: 0,
+ totalErrors: 0
+ }
+ };
+
+ // Process each reminder tier
+ for (const days of reminderDays) {
+ const reminderType = `due_soon_${days}` as ReminderType;
+
+ // Get eligible members
+ const eligibleMembers = await getMembersNeedingReminder(reminderType);
+ results.reminders[reminderType] = {
+ eligible: eligibleMembers.length,
+ sent: 0,
+ skipped: 0,
+ errors: []
+ };
+
+ if (!dryRun && eligibleMembers.length > 0) {
+ const result = await sendBulkReminders(reminderType, baseUrl);
+ results.reminders[reminderType].sent = result.sent;
+ results.reminders[reminderType].skipped = result.skipped;
+ results.reminders[reminderType].errors = result.errors;
+ results.summary.totalRemindersSent += result.sent;
+ results.summary.totalErrors += result.errors.length;
+ }
+ }
+
+ // Process overdue notifications
+ const overdueMembers = await getMembersNeedingReminder('overdue');
+ results.overdue.eligible = overdueMembers.length;
+
+ if (!dryRun && overdueMembers.length > 0) {
+ const overdueResult = await sendBulkReminders('overdue', baseUrl);
+ results.overdue.sent = overdueResult.sent;
+ results.overdue.skipped = overdueResult.skipped;
+ results.overdue.errors = overdueResult.errors;
+ results.summary.totalRemindersSent += overdueResult.sent;
+ results.summary.totalErrors += overdueResult.errors.length;
+ }
+
+ // Process grace period warnings
+ const graceMembers = await getMembersNeedingReminder('grace_period');
+ results.graceWarning.eligible = graceMembers.length;
+
+ if (!dryRun && graceMembers.length > 0) {
+ const graceResult = await sendBulkReminders('grace_period', baseUrl);
+ results.graceWarning.sent = graceResult.sent;
+ results.graceWarning.skipped = graceResult.skipped;
+ results.graceWarning.errors = graceResult.errors;
+ results.summary.totalRemindersSent += graceResult.sent;
+ results.summary.totalErrors += graceResult.errors.length;
+ }
+
+ // Process grace period expirations (mark members as inactive)
+ if (!dryRun && settings.auto_inactive_enabled) {
+ const inactivationResult = await processGracePeriodExpirations(baseUrl);
+ results.inactivated = inactivationResult.members;
+ }
+
+ // ============================================
+ // ONBOARDING REMINDERS (new members with payment_deadline)
+ // ============================================
+
+ // Process 7-day onboarding reminders
+ const onboarding7Members = await getMembersNeedingOnboardingReminder('onboarding_reminder_7');
+ results.onboarding.reminder_7.eligible = onboarding7Members.length;
+
+ if (!dryRun && onboarding7Members.length > 0) {
+ const onboarding7Result = await sendOnboardingReminders('onboarding_reminder_7', baseUrl);
+ results.onboarding.reminder_7.sent = onboarding7Result.sent;
+ results.onboarding.reminder_7.skipped = onboarding7Result.skipped;
+ results.onboarding.reminder_7.errors = onboarding7Result.errors;
+ results.summary.totalRemindersSent += onboarding7Result.sent;
+ results.summary.totalErrors += onboarding7Result.errors.length;
+ }
+
+ // Process 1-day onboarding reminders (final warning)
+ const onboarding1Members = await getMembersNeedingOnboardingReminder('onboarding_reminder_1');
+ results.onboarding.reminder_1.eligible = onboarding1Members.length;
+
+ if (!dryRun && onboarding1Members.length > 0) {
+ const onboarding1Result = await sendOnboardingReminders('onboarding_reminder_1', baseUrl);
+ results.onboarding.reminder_1.sent = onboarding1Result.sent;
+ results.onboarding.reminder_1.skipped = onboarding1Result.skipped;
+ results.onboarding.reminder_1.errors = onboarding1Result.errors;
+ results.summary.totalRemindersSent += onboarding1Result.sent;
+ results.summary.totalErrors += onboarding1Result.errors.length;
+ }
+
+ // Process expired onboarding deadlines (mark as inactive)
+ if (!dryRun && settings.auto_inactive_enabled) {
+ const onboardingExpiredResult = await processOnboardingExpirations(baseUrl);
+ results.onboardingInactivated = onboardingExpiredResult.members;
+ }
+
+ return json(results);
+ } catch (error) {
+ console.error('Cron job error:', error);
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ return json(
+ {
+ error: 'Internal server error',
+ message: errorMessage,
+ timestamp: new Date().toISOString()
+ },
+ { status: 500 }
+ );
+ }
+};
+
+/**
+ * GET endpoint for checking cron status and getting preview of pending actions
+ */
+export const GET: RequestHandler = async ({ request, url }) => {
+ // Verify cron secret
+ const authHeader = request.headers.get('authorization');
+ const token = authHeader?.replace('Bearer ', '');
+
+ if (!CRON_SECRET || token !== CRON_SECRET) {
+ return json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const settings = await getDuesSettings();
+ const reminderDays = settings.reminder_days_before || [30, 7, 1];
+
+ const preview = {
+ timestamp: new Date().toISOString(),
+ settings: {
+ reminder_days_before: reminderDays,
+ grace_period_days: settings.grace_period_days,
+ auto_inactive_enabled: settings.auto_inactive_enabled
+ },
+ pendingActions: {
+ due_soon_30: 0,
+ due_soon_7: 0,
+ due_soon_1: 0,
+ overdue: 0,
+ grace_period: 0
+ } as Record,
+ pendingOnboarding: {
+ onboarding_reminder_7: 0,
+ onboarding_reminder_1: 0,
+ onboarding_expired: 0
+ } as Record
+ };
+
+ // Count pending for each type
+ for (const days of reminderDays) {
+ const reminderType = `due_soon_${days}` as ReminderType;
+ const members = await getMembersNeedingReminder(reminderType);
+ preview.pendingActions[reminderType] = members.length;
+ }
+
+ // Count overdue
+ const overdueMembers = await getMembersNeedingReminder('overdue');
+ preview.pendingActions.overdue = overdueMembers.length;
+
+ // Count grace period warnings
+ const graceMembers = await getMembersNeedingReminder('grace_period');
+ preview.pendingActions.grace_period = graceMembers.length;
+
+ // Count onboarding reminders
+ const onboarding7Members = await getMembersNeedingOnboardingReminder('onboarding_reminder_7');
+ preview.pendingOnboarding.onboarding_reminder_7 = onboarding7Members.length;
+
+ const onboarding1Members = await getMembersNeedingOnboardingReminder('onboarding_reminder_1');
+ preview.pendingOnboarding.onboarding_reminder_1 = onboarding1Members.length;
+
+ const onboardingExpiredMembers = await getMembersNeedingOnboardingReminder('onboarding_expired');
+ preview.pendingOnboarding.onboarding_expired = onboardingExpiredMembers.length;
+
+ return json(preview);
+ } catch (error) {
+ console.error('Cron preview error:', error);
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ return json({ error: 'Internal server error', message: errorMessage }, { status: 500 });
+ }
+};
diff --git a/src/routes/api/cron/event-reminders/+server.ts b/src/routes/api/cron/event-reminders/+server.ts
new file mode 100644
index 0000000..cfbd32f
--- /dev/null
+++ b/src/routes/api/cron/event-reminders/+server.ts
@@ -0,0 +1,158 @@
+/**
+ * Cron API Endpoint for Automated Event Reminders
+ *
+ * This endpoint should be called hourly by an external cron service
+ * to send reminder emails to members 24 hours before events.
+ *
+ * Security: Requires CRON_SECRET header for authentication
+ *
+ * Example cron setup (hourly):
+ * 0 * * * * curl -X POST https://yourdomain.com/api/cron/event-reminders \
+ * -H "Authorization: Bearer YOUR_CRON_SECRET"
+ */
+
+import { json } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+import { env } from '$env/dynamic/private';
+import {
+ getEventReminderSettings,
+ getEventsNeedingReminders,
+ sendEventReminders,
+ getEventReminderStats
+} from '$lib/server/event-reminders';
+
+const CRON_SECRET = env.CRON_SECRET;
+
+export const POST: RequestHandler = async ({ request, url }) => {
+ // Verify cron secret
+ const authHeader = request.headers.get('authorization');
+ const token = authHeader?.replace('Bearer ', '');
+
+ if (!CRON_SECRET) {
+ console.error('CRON_SECRET not configured');
+ return json({ error: 'Server not configured for cron jobs' }, { status: 500 });
+ }
+
+ if (token !== CRON_SECRET) {
+ return json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const baseUrl = url.origin || env.SITE_URL || 'https://monacousa.org';
+ const dryRun = url.searchParams.get('dry_run') === 'true';
+
+ try {
+ // Load settings
+ const settings = await getEventReminderSettings();
+
+ const results = {
+ timestamp: new Date().toISOString(),
+ dryRun,
+ settings: {
+ event_reminders_enabled: settings.event_reminders_enabled,
+ event_reminder_hours_before: settings.event_reminder_hours_before
+ },
+ eligible: 0,
+ sent: 0,
+ skipped: 0,
+ errors: [] as string[],
+ reminders: [] as Array<{
+ eventId: string;
+ eventTitle: string;
+ memberId: string;
+ memberName: string;
+ email: string;
+ status: string;
+ error?: string;
+ }>
+ };
+
+ if (!settings.event_reminders_enabled) {
+ return json({
+ ...results,
+ message: 'Event reminders are disabled'
+ });
+ }
+
+ // Get events needing reminders
+ const eventsNeeding = await getEventsNeedingReminders();
+ results.eligible = eventsNeeding.length;
+
+ if (dryRun) {
+ // Just show what would be sent
+ results.reminders = eventsNeeding.map(e => ({
+ eventId: e.event_id,
+ eventTitle: e.event_title,
+ memberId: e.member_id,
+ memberName: `${e.first_name} ${e.last_name}`,
+ email: e.email,
+ status: 'would_send'
+ }));
+ return json(results);
+ }
+
+ // Send the reminders
+ if (eventsNeeding.length > 0) {
+ const sendResult = await sendEventReminders(baseUrl);
+ results.sent = sendResult.sent;
+ results.skipped = sendResult.skipped;
+ results.errors = sendResult.errors;
+ results.reminders = sendResult.reminders;
+ }
+
+ return json(results);
+ } catch (error) {
+ console.error('Event reminders cron job error:', error);
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ return json(
+ {
+ error: 'Internal server error',
+ message: errorMessage,
+ timestamp: new Date().toISOString()
+ },
+ { status: 500 }
+ );
+ }
+};
+
+/**
+ * GET endpoint for checking status and getting preview of pending reminders
+ */
+export const GET: RequestHandler = async ({ request }) => {
+ // Verify cron secret
+ const authHeader = request.headers.get('authorization');
+ const token = authHeader?.replace('Bearer ', '');
+
+ if (!CRON_SECRET || token !== CRON_SECRET) {
+ return json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const settings = await getEventReminderSettings();
+ const eventsNeeding = await getEventsNeedingReminders();
+ const stats = await getEventReminderStats();
+
+ const preview = {
+ timestamp: new Date().toISOString(),
+ settings: {
+ event_reminders_enabled: settings.event_reminders_enabled,
+ event_reminder_hours_before: settings.event_reminder_hours_before
+ },
+ pendingReminders: eventsNeeding.length,
+ pendingDetails: eventsNeeding.map(e => ({
+ eventId: e.event_id,
+ eventTitle: e.event_title,
+ eventStart: e.start_datetime,
+ memberName: `${e.first_name} ${e.last_name}`,
+ email: e.email,
+ guestCount: e.guest_count
+ })),
+ stats
+ };
+
+ return json(preview);
+ } catch (error) {
+ console.error('Event reminders preview error:', error);
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ return json({ error: 'Internal server error', message: errorMessage }, { status: 500 });
+ }
+};
diff --git a/src/routes/auth/callback/+server.ts b/src/routes/auth/callback/+server.ts
new file mode 100644
index 0000000..ca00b0d
--- /dev/null
+++ b/src/routes/auth/callback/+server.ts
@@ -0,0 +1,32 @@
+import { redirect } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+
+/**
+ * Auth callback handler for email verification and OAuth redirects
+ * This endpoint exchanges the auth code for a session
+ */
+export const GET: RequestHandler = async ({ url, locals }) => {
+ const code = url.searchParams.get('code');
+ const next = url.searchParams.get('next') || '/dashboard';
+ const error = url.searchParams.get('error');
+ const errorDescription = url.searchParams.get('error_description');
+
+ // Handle error from Supabase auth
+ if (error) {
+ console.error('Auth callback error:', error, errorDescription);
+ throw redirect(303, `/login?error=${encodeURIComponent(errorDescription || error)}`);
+ }
+
+ // Exchange the code for a session
+ if (code) {
+ const { error: exchangeError } = await locals.supabase.auth.exchangeCodeForSession(code);
+
+ if (exchangeError) {
+ console.error('Failed to exchange code for session:', exchangeError);
+ throw redirect(303, `/login?error=${encodeURIComponent('Failed to verify email. Please try again.')}`);
+ }
+ }
+
+ // Redirect to the next page or dashboard
+ throw redirect(303, next);
+};
diff --git a/src/routes/auth/reset-password/+page.server.ts b/src/routes/auth/reset-password/+page.server.ts
new file mode 100644
index 0000000..9c90f85
--- /dev/null
+++ b/src/routes/auth/reset-password/+page.server.ts
@@ -0,0 +1,111 @@
+import { fail, redirect } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async ({ url, locals }) => {
+ // Check for token in URL (from email link via /auth/verify redirect)
+ const token = url.searchParams.get('token');
+ const type = url.searchParams.get('type');
+ const error = url.searchParams.get('error');
+
+ // If there's an error from a previous attempt, show it
+ if (error) {
+ return { error };
+ }
+
+ // If there's a token, we need to verify it to establish a session
+ if (token) {
+ try {
+ // For recovery/invite tokens, verify the OTP
+ const otpType = type === 'invite' ? 'invite' : 'recovery';
+
+ const { data, error: verifyError } = await locals.supabase.auth.verifyOtp({
+ token_hash: token,
+ type: otpType
+ });
+
+ if (verifyError) {
+ console.error('Token verification error:', verifyError);
+ // Token invalid or expired
+ throw redirect(
+ 303,
+ `/forgot-password?error=${encodeURIComponent(verifyError.message || 'Invalid or expired reset link. Please request a new one.')}`
+ );
+ }
+
+ if (data.session) {
+ // Session established - user can now reset password
+ return {
+ isInvite: type === 'invite',
+ email: data.user?.email
+ };
+ }
+ } catch (e) {
+ // Check if it's a redirect (which is expected)
+ if (e && typeof e === 'object' && 'status' in e) {
+ throw e;
+ }
+ console.error('Verification error:', e);
+ throw redirect(303, '/forgot-password?error=expired');
+ }
+ }
+
+ // No token - check if user has an existing session (from successful verification)
+ const { session } = await locals.safeGetSession();
+
+ if (!session) {
+ // No session and no token - invalid access
+ throw redirect(303, '/forgot-password?error=expired');
+ }
+
+ return {
+ email: session.user?.email
+ };
+};
+
+export const actions: Actions = {
+ default: async ({ request, locals }) => {
+ const formData = await request.formData();
+ const password = formData.get('password') as string;
+ const confirmPassword = formData.get('confirm_password') as string;
+
+ // Validation
+ if (!password || password.length < 8) {
+ return fail(400, {
+ error: 'Password must be at least 8 characters long'
+ });
+ }
+
+ if (password !== confirmPassword) {
+ return fail(400, {
+ error: 'Passwords do not match'
+ });
+ }
+
+ // Check if user has a session
+ const { session } = await locals.safeGetSession();
+ if (!session) {
+ return fail(401, {
+ error: 'Session expired. Please request a new password reset link.'
+ });
+ }
+
+ // Update the password
+ const { error } = await locals.supabase.auth.updateUser({
+ password
+ });
+
+ if (error) {
+ console.error('Password update error:', error);
+ return fail(400, {
+ error: error.message
+ });
+ }
+
+ // Sign out after password change so they can sign in fresh
+ await locals.supabase.auth.signOut();
+
+ return {
+ success: 'Your password has been set successfully! You can now sign in with your new password.'
+ };
+ }
+};
diff --git a/src/routes/auth/reset-password/+page.svelte b/src/routes/auth/reset-password/+page.svelte
new file mode 100644
index 0000000..0c42dd5
--- /dev/null
+++ b/src/routes/auth/reset-password/+page.svelte
@@ -0,0 +1,125 @@
+
+
+
+ {isInvite ? 'Set Your Password' : 'Reset Password'} | Monaco USA
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isInvite ? 'Welcome to Monaco USA!' : 'Reset your password'}
+
+
+ {#if isInvite}
+ Set a password to activate your account
+ {:else}
+ Enter a new password for your account
+ {/if}
+
+ {#if email}
+
{email}
+ {/if}
+
+
+ {#if form?.error}
+
+ {/if}
+
+ {#if form?.success}
+
+
+ {:else}
+
{
+ loading = true;
+ return async ({ update }) => {
+ loading = false;
+ await update();
+ };
+ }}
+ class="space-y-4"
+ >
+
+
+
+
+ Password must be at least 8 characters long.
+
+
+ {#if loading}
+
+ Resetting password...
+ {:else}
+ Reset password
+ {/if}
+
+
+ {/if}
+
+
+
+
diff --git a/src/routes/auth/verify/+server.ts b/src/routes/auth/verify/+server.ts
new file mode 100644
index 0000000..40200cd
--- /dev/null
+++ b/src/routes/auth/verify/+server.ts
@@ -0,0 +1,66 @@
+import { redirect } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+
+/**
+ * Auth verify handler for email links from Supabase/GoTrue
+ * This handles invite, recovery, confirmation, and email change tokens
+ *
+ * Flow:
+ * 1. User clicks link in email (e.g., password reset)
+ * 2. Link goes to /auth/verify?token=...&type=recovery&redirect_to=...
+ * 3. This handler extracts parameters and redirects to the appropriate SvelteKit page
+ */
+export const GET: RequestHandler = async ({ url, locals }) => {
+ const token = url.searchParams.get('token');
+ const type = url.searchParams.get('type');
+ const redirectTo = url.searchParams.get('redirect_to');
+
+ console.log('Auth verify handler:', { token: token?.substring(0, 20) + '...', type, redirectTo });
+
+ // Handle different verification types
+ if (type === 'recovery' || type === 'rec') {
+ // Password reset - redirect to reset password page with token
+ const resetUrl = new URL('/auth/reset-password', url.origin);
+ if (token) resetUrl.searchParams.set('token', token);
+ if (type) resetUrl.searchParams.set('type', type);
+ throw redirect(303, resetUrl.toString());
+ }
+
+ if (type === 'invite' || type === 'inv') {
+ // Member invitation - redirect to set password page
+ const resetUrl = new URL('/auth/reset-password', url.origin);
+ if (token) resetUrl.searchParams.set('token', token);
+ resetUrl.searchParams.set('type', 'invite');
+ throw redirect(303, resetUrl.toString());
+ }
+
+ if (type === 'signup' || type === 'confirmation' || type === 'email_change') {
+ // Email confirmation - try to verify directly then redirect
+ if (token) {
+ try {
+ const { error } = await locals.supabase.auth.verifyOtp({
+ token_hash: token,
+ type: type === 'email_change' ? 'email_change' : 'signup'
+ });
+
+ if (error) {
+ console.error('Email verification error:', error);
+ throw redirect(303, `/login?error=${encodeURIComponent(error.message)}`);
+ }
+
+ // Success - redirect to dashboard
+ throw redirect(303, redirectTo || '/dashboard');
+ } catch (e) {
+ if (e && typeof e === 'object' && 'status' in e) {
+ // This is a redirect, rethrow it
+ throw e;
+ }
+ console.error('Verification error:', e);
+ throw redirect(303, `/login?error=${encodeURIComponent('Verification failed. Please try again.')}`);
+ }
+ }
+ }
+
+ // Default: redirect to login with error
+ throw redirect(303, `/login?error=${encodeURIComponent('Invalid verification link')}`);
+};
diff --git a/src/routes/join/+layout.svelte b/src/routes/join/+layout.svelte
new file mode 100644
index 0000000..911074a
--- /dev/null
+++ b/src/routes/join/+layout.svelte
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {@render children()}
+
+
+
+ © 2026 Monaco USA. All rights reserved.
+
+
+
diff --git a/src/routes/join/+page.server.ts b/src/routes/join/+page.server.ts
new file mode 100644
index 0000000..992871c
--- /dev/null
+++ b/src/routes/join/+page.server.ts
@@ -0,0 +1,350 @@
+import { fail, redirect } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+import { supabaseAdmin } from '$lib/server/supabase';
+import { sendTemplatedEmail } from '$lib/server/email';
+
+export const load: PageServerLoad = async ({ locals, url }) => {
+ const { session } = await locals.safeGetSession();
+
+ // If already logged in, check if onboarding is completed
+ if (session) {
+ // Get member profile to check onboarding status
+ const { data: member } = await locals.supabase
+ .from('members')
+ .select('onboarding_completed_at')
+ .eq('id', session.user.id)
+ .single();
+
+ // Only redirect to dashboard if onboarding is completed
+ if (member?.onboarding_completed_at) {
+ throw redirect(303, '/dashboard');
+ }
+ // Otherwise, let them continue the onboarding wizard
+ }
+
+ // Get payment settings for the payment step
+ const { data: settings } = await locals.supabase
+ .from('app_settings')
+ .select('setting_key, setting_value')
+ .eq('category', 'dues');
+
+ const paymentSettings: Record = {};
+ if (settings) {
+ for (const s of settings) {
+ let value = s.setting_value;
+ if (typeof value === 'string') {
+ value = value.replace(/^"|"$/g, '');
+ }
+ paymentSettings[s.setting_key.replace('payment_', '')] = value as string;
+ }
+ }
+
+ // Get default membership type for dues amount
+ const { data: defaultType } = await locals.supabase
+ .from('membership_types')
+ .select('annual_dues')
+ .eq('is_default', true)
+ .single();
+
+ // If logged in but not completed onboarding, get member data
+ let member = null;
+ if (session) {
+ const { data } = await locals.supabase
+ .from('members')
+ .select('id, first_name, email, member_id')
+ .eq('id', session.user.id)
+ .single();
+ member = data;
+ }
+
+ return {
+ paymentSettings,
+ duesAmount: defaultType?.annual_dues || 150,
+ session: session || null,
+ member
+ };
+};
+
+export const actions: Actions = {
+ createAccount: async ({ request, locals, url }) => {
+ const formData = await request.formData();
+
+ // Extract form fields
+ const firstName = formData.get('first_name') as string;
+ const lastName = formData.get('last_name') as string;
+ const email = formData.get('email') as string;
+ const phone = formData.get('phone') as string;
+ const dateOfBirth = formData.get('date_of_birth') as string;
+ const address = formData.get('address') as string;
+ const nationalityString = formData.get('nationality') as string;
+ const password = formData.get('password') as string;
+ const confirmPassword = formData.get('confirm_password') as string;
+ const terms = formData.get('terms');
+
+ // Validation
+ if (!firstName || firstName.length < 2) {
+ return fail(400, { error: 'First name must be at least 2 characters', step: 2 });
+ }
+
+ if (!lastName || lastName.length < 2) {
+ return fail(400, { error: 'Last name must be at least 2 characters', step: 2 });
+ }
+
+ if (!email || !email.includes('@')) {
+ return fail(400, { error: 'Please enter a valid email address', step: 2 });
+ }
+
+ if (!phone) {
+ return fail(400, { error: 'Phone number is required', step: 2 });
+ }
+
+ if (!dateOfBirth) {
+ return fail(400, { error: 'Date of birth is required', step: 2 });
+ } else {
+ // Check if 18+
+ const birthDate = new Date(dateOfBirth);
+ const today = new Date();
+ const age = today.getFullYear() - birthDate.getFullYear();
+ const monthDiff = today.getMonth() - birthDate.getMonth();
+ const dayDiff = today.getDate() - birthDate.getDate();
+ const actualAge = monthDiff < 0 || (monthDiff === 0 && dayDiff < 0) ? age - 1 : age;
+
+ if (actualAge < 18) {
+ return fail(400, { error: 'You must be at least 18 years old to join', step: 2 });
+ }
+ }
+
+ if (!address || address.length < 10) {
+ return fail(400, { error: 'Please enter a complete address', step: 2 });
+ }
+
+ const nationality = nationalityString ? nationalityString.split(',').filter(Boolean) : [];
+ if (nationality.length === 0) {
+ return fail(400, { error: 'Please select at least one nationality', step: 2 });
+ }
+
+ if (!password || password.length < 8) {
+ return fail(400, { error: 'Password must be at least 8 characters', step: 2 });
+ }
+
+ if (password !== confirmPassword) {
+ return fail(400, { error: 'Passwords do not match', step: 2 });
+ }
+
+ if (!terms) {
+ return fail(400, { error: 'You must accept the terms and conditions', step: 2 });
+ }
+
+ // Create Supabase auth user
+ const { data: authData, error: authError } = await locals.supabase.auth.signUp({
+ email,
+ password,
+ options: {
+ emailRedirectTo: `${url.origin}/join?verified=true`,
+ data: {
+ first_name: firstName,
+ last_name: lastName
+ }
+ }
+ });
+
+ if (authError) {
+ if (authError.message.includes('already registered')) {
+ return fail(400, {
+ error: 'An account with this email already exists. Try signing in instead.',
+ step: 2
+ });
+ }
+ return fail(400, { error: authError.message, step: 2 });
+ }
+
+ if (!authData.user) {
+ return fail(500, { error: 'Failed to create account. Please try again.', step: 2 });
+ }
+
+ // Get the pending membership status
+ const { data: pendingStatus } = await locals.supabase
+ .from('membership_statuses')
+ .select('id')
+ .eq('name', 'pending')
+ .single();
+
+ // Get the default membership type
+ const { data: defaultType } = await locals.supabase
+ .from('membership_types')
+ .select('id')
+ .eq('is_default', true)
+ .single();
+
+ if (!pendingStatus?.id || !defaultType?.id) {
+ console.error('Missing default status or type');
+ await supabaseAdmin.auth.admin.deleteUser(authData.user.id);
+ return fail(500, { error: 'System configuration error. Please contact support.', step: 2 });
+ }
+
+ // Generate member ID
+ const year = new Date().getFullYear();
+ const { count } = await locals.supabase
+ .from('members')
+ .select('*', { count: 'exact', head: true });
+
+ const memberNumber = String((count || 0) + 1).padStart(4, '0');
+ const memberId = `MUSA-${year}-${memberNumber}`;
+
+ // Create member profile
+ const { error: memberError } = await locals.supabase.from('members').insert({
+ id: authData.user.id,
+ first_name: firstName,
+ last_name: lastName,
+ email,
+ phone,
+ date_of_birth: dateOfBirth,
+ address,
+ nationality,
+ member_id: memberId,
+ role: 'member',
+ membership_status_id: pendingStatus.id,
+ membership_type_id: defaultType.id
+ });
+
+ if (memberError) {
+ console.error('Failed to create member profile:', memberError);
+ try {
+ await supabaseAdmin.auth.admin.deleteUser(authData.user.id);
+ } catch (deleteError) {
+ console.error('Failed to clean up auth user:', deleteError);
+ }
+ return fail(500, {
+ error: 'Failed to create member profile. Please try again.',
+ step: 2
+ });
+ }
+
+ // Sign in the user so they can continue the wizard
+ const { error: signInError } = await locals.supabase.auth.signInWithPassword({
+ email,
+ password
+ });
+
+ if (signInError) {
+ console.error('Failed to sign in after account creation:', signInError);
+ // Continue anyway - they can verify email and sign in later
+ }
+
+ return {
+ success: true,
+ step: 2,
+ memberId,
+ email
+ };
+ },
+
+ uploadPhoto: async ({ request, locals }) => {
+ const { session } = await locals.safeGetSession();
+ if (!session) {
+ return fail(401, { error: 'Not authenticated', step: 3 });
+ }
+
+ // For now, just proceed to next step
+ // Avatar upload can be handled via the profile page later
+ // or we can add proper file handling here
+
+ return {
+ success: true,
+ step: 3
+ };
+ },
+
+ complete: async ({ locals }) => {
+ const { session } = await locals.safeGetSession();
+ if (!session) {
+ return fail(401, { error: 'Not authenticated', step: 6 });
+ }
+
+ // Set payment deadline (30 days from now)
+ const paymentDeadline = new Date();
+ paymentDeadline.setDate(paymentDeadline.getDate() + 30);
+
+ // Update member with onboarding completion
+ const { error: updateError } = await locals.supabase
+ .from('members')
+ .update({
+ payment_deadline: paymentDeadline.toISOString(),
+ onboarding_completed_at: new Date().toISOString()
+ })
+ .eq('id', session.user.id);
+
+ if (updateError) {
+ console.error('Failed to update member:', updateError);
+ return fail(500, { error: 'Failed to complete onboarding', step: 6 });
+ }
+
+ // Get member data for email
+ const { data: member } = await locals.supabase
+ .from('members')
+ .select('first_name, member_id, email')
+ .eq('id', session.user.id)
+ .single();
+
+ // Get payment settings
+ const { data: settings } = await locals.supabase
+ .from('app_settings')
+ .select('setting_key, setting_value')
+ .eq('category', 'dues');
+
+ const paymentSettings: Record = {};
+ if (settings) {
+ for (const s of settings) {
+ let value = s.setting_value;
+ if (typeof value === 'string') {
+ value = value.replace(/^"|"$/g, '');
+ }
+ paymentSettings[s.setting_key.replace('payment_', '')] = value as string;
+ }
+ }
+
+ // Get default membership dues amount
+ const { data: defaultType } = await locals.supabase
+ .from('membership_types')
+ .select('annual_dues')
+ .eq('is_default', true)
+ .single();
+
+ // Send welcome email with payment instructions
+ if (member) {
+ try {
+ await sendTemplatedEmail(
+ 'onboarding_welcome',
+ member.email,
+ {
+ first_name: member.first_name,
+ member_id: member.member_id || 'N/A',
+ amount: `€${defaultType?.annual_dues || 150}`,
+ payment_deadline: paymentDeadline.toLocaleDateString('en-US', {
+ weekday: 'long',
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric'
+ }),
+ account_holder: paymentSettings.account_holder || 'Monaco USA',
+ bank_name: paymentSettings.bank_name || 'Credit Foncier de Monaco',
+ iban: paymentSettings.iban || 'Contact for details'
+ },
+ {
+ recipientId: session.user.id,
+ recipientName: `${member.first_name}`,
+ sentBy: 'system'
+ }
+ );
+ } catch (emailError) {
+ console.error('Failed to send welcome email:', emailError);
+ // Continue anyway - not critical
+ }
+ }
+
+ return {
+ success: true,
+ step: 6
+ };
+ }
+};
diff --git a/src/routes/join/+page.svelte b/src/routes/join/+page.svelte
new file mode 100644
index 0000000..2faae76
--- /dev/null
+++ b/src/routes/join/+page.svelte
@@ -0,0 +1,773 @@
+
+
+
+ Join Monaco USA
+
+
+
+
+
+
+ {#each steps as step}
+
+
+ {#if currentStep > step.num}
+
+ {:else}
+ {step.num}
+ {/if}
+
+ {#if step.num < 6}
+
+ {/if}
+
+ {/each}
+
+
+ Step {currentStep} of 6: {steps[currentStep - 1].title}
+
+
+
+
+
+ {#key currentStep}
+
+
+ {#if currentStep === 1}
+
+
+
Welcome to Monaco USA
+
Join our vibrant community of Americans in Monaco
+
+
+
+ {#each benefits as benefit, i}
+
+
+
+
+
{benefit.title}
+
{benefit.description}
+
+ {/each}
+
+
+
+
+
+ Already have an account?
+ Sign in
+
+
+ {/if}
+
+
+ {#if currentStep === 2}
+
+
+
+
+
+
Your Information
+
Tell us about yourself to create your account
+
+
+ {#if form?.error}
+
+
+
+ {/if}
+
+
{
+ loading = true;
+ return async ({ update }) => {
+ loading = false;
+ await update();
+ };
+ }}
+ class="mt-4 space-y-3"
+ >
+
+
+
+
+
+
+ Email *
+
+
+
+
+
+
+
+
+
+
+ Nationality *
+
+
+
+
+
+
+
+ Date of birth *
+
+
+
You must be at least 18 years old.
+
+
+
+
+
+ Street address *
+
+
+
+
+
+
+
+
+ City *
+
+
+
+
+
+ Country of residence *
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Back
+
+
+ {#if loading}
+
+ Creating...
+ {:else}
+ Continue
+
+ {/if}
+
+
+
+
+ {/if}
+
+
+ {#if currentStep === 3}
+
+
+
Add a Profile Photo
+
Help other members recognize you (optional)
+
+
+
+
+ {#if avatarPreview}
+
+ {:else}
+
+
+
+ {/if}
+
+
+
+
+
+
+
+ JPEG, PNG, or WebP. Max 5MB.
+
+
+
{
+ avatarUploading = true;
+ return async ({ update }) => {
+ avatarUploading = false;
+ await update();
+ };
+ }}
+ class="mt-6 w-full"
+ >
+ {#if avatarFile}
+
+ {/if}
+
+
+
+
+ Back
+
+
+ Skip
+
+
+ {#if avatarFile}
+
+ {#if avatarUploading}
+
+ Uploading...
+ {:else}
+ Continue
+
+ {/if}
+
+ {/if}
+
+
+
+
+ {/if}
+
+
+ {#if currentStep === 4}
+
+
+
Explore Your New Home
+
Here's what you'll have access to as a member
+
+
+
+ {#each features as feature, i}
+
+
+
+
+
+
{feature.title}
+
{feature.description}
+
+
+ {/each}
+
+
+
+
+
+ Back
+
+
+ Continue
+
+
+
+
+ {/if}
+
+
+ {#if currentStep === 5}
+
+
+
+
+
+
Verify Your Email
+
+ We sent a verification link to
+ {memberEmail || email}
+
+
+
+
+
+ Click the link in your email to verify your address, then click the button below.
+
+
+
+
+
+ {#if verificationChecking}
+
+ Checking...
+ {:else}
+ I've Verified My Email
+
+ {/if}
+
+
+
+ {#if resendingEmail}
+
+ Sending...
+ {:else}
+
+ Resend Verification Email
+ {/if}
+
+
+
+
+
+ {/if}
+
+
+ {#if currentStep === 6}
+
+
+
+
+
+
You're Almost There!
+
+ Complete your membership by paying your annual dues within 30 days
+
+
+
+
+
+
Annual Membership
+
€{data?.duesAmount || '150'}
+
+
+
+
+
Bank Transfer Details
+
+
+ Account Holder:
+ {data?.paymentSettings?.account_holder || 'Monaco USA'}
+
+
+ Bank:
+ {data?.paymentSettings?.bank_name || 'Credit Foncier de Monaco'}
+
+
+ IBAN:
+ {data?.paymentSettings?.iban || 'MC58...'}
+
+
+ Reference:
+ {memberId || 'MUSA-2026-XXXX'}
+
+
+
+
+
+
What Happens Next?
+
+ • Check your email for confirmation
+ • Make your bank transfer within 30 days
+ • We'll activate your account once payment is received
+
+
+
+
{
+ loading = true;
+ return async ({ update }) => {
+ loading = false;
+ await update();
+ };
+ }}
+ class="mt-4"
+ >
+
+ {#if loading}
+
+ Completing...
+ {:else}
+ Go to Dashboard
+
+ {/if}
+
+
+
+ {/if}
+
+ {/key}
+
diff --git a/src/routes/logout/+server.ts b/src/routes/logout/+server.ts
new file mode 100644
index 0000000..f0e03b2
--- /dev/null
+++ b/src/routes/logout/+server.ts
@@ -0,0 +1,12 @@
+import { redirect } from '@sveltejs/kit';
+import type { RequestHandler } from './$types';
+
+export const POST: RequestHandler = async ({ locals }) => {
+ await locals.supabase.auth.signOut();
+ throw redirect(303, '/login');
+};
+
+export const GET: RequestHandler = async ({ locals }) => {
+ await locals.supabase.auth.signOut();
+ throw redirect(303, '/login');
+};
diff --git a/src/routes/public/events/[id]/+page.server.ts b/src/routes/public/events/[id]/+page.server.ts
new file mode 100644
index 0000000..79b8aab
--- /dev/null
+++ b/src/routes/public/events/[id]/+page.server.ts
@@ -0,0 +1,105 @@
+import { error, fail } from '@sveltejs/kit';
+import type { Actions, PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async ({ locals, params }) => {
+ // Fetch the event (only public events)
+ const { data: event } = await locals.supabase
+ .from('events_with_counts')
+ .select('*')
+ .eq('id', params.id)
+ .eq('visibility', 'public')
+ .eq('status', 'published')
+ .single();
+
+ if (!event) {
+ throw error(404, 'Event not found or not publicly accessible');
+ }
+
+ return {
+ event
+ };
+};
+
+export const actions: Actions = {
+ rsvp: async ({ request, locals, params }) => {
+ const formData = await request.formData();
+ const fullName = (formData.get('full_name') as string)?.trim();
+ const email = (formData.get('email') as string)?.trim().toLowerCase();
+ const phone = (formData.get('phone') as string)?.trim() || null;
+ const guestCount = parseInt(formData.get('guest_count') as string) || 0;
+
+ // Validation
+ if (!fullName || fullName.length < 2) {
+ return fail(400, { error: 'Please enter your full name' });
+ }
+
+ if (!email) {
+ return fail(400, { error: 'Please enter your email address' });
+ }
+
+ // Email validation
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(email)) {
+ return fail(400, { error: 'Please enter a valid email address' });
+ }
+
+ // Fetch the event to check capacity
+ const { data: event } = await locals.supabase
+ .from('events_with_counts')
+ .select('*')
+ .eq('id', params.id)
+ .eq('visibility', 'public')
+ .eq('status', 'published')
+ .single();
+
+ if (!event) {
+ return fail(404, { error: 'Event not found' });
+ }
+
+ // Validate guest count
+ if (event.max_guests_per_member !== null && guestCount > event.max_guests_per_member) {
+ return fail(400, {
+ error: `Maximum ${event.max_guests_per_member} guest${event.max_guests_per_member === 1 ? '' : 's'} allowed`
+ });
+ }
+
+ // Check if this email already RSVP'd
+ const { data: existingRsvp } = await locals.supabase
+ .from('event_rsvps_public')
+ .select('id')
+ .eq('event_id', params.id)
+ .eq('email', email)
+ .single();
+
+ if (existingRsvp) {
+ return fail(400, { error: 'This email has already registered for this event' });
+ }
+
+ // Check if event is full
+ const totalAttending = event.total_attendees + 1 + guestCount;
+ const isFull = event.max_attendees && totalAttending > event.max_attendees;
+
+ // Create public RSVP
+ const { error: rsvpError } = await locals.supabase.from('event_rsvps_public').insert({
+ event_id: params.id,
+ full_name: fullName,
+ email,
+ phone,
+ status: isFull ? 'waitlist' : 'confirmed',
+ guest_count: guestCount,
+ payment_status: event.is_paid ? 'pending' : 'not_required',
+ payment_amount: event.is_paid ? event.non_member_price * (1 + guestCount) : null
+ });
+
+ if (rsvpError) {
+ console.error('Public RSVP error:', rsvpError);
+ return fail(500, { error: 'Failed to submit RSVP. Please try again.' });
+ }
+
+ return {
+ success: isFull
+ ? 'You have been added to the waitlist. We will notify you if a spot opens up.'
+ : 'Registration successful! We look forward to seeing you at the event.'
+ };
+ }
+};
diff --git a/src/routes/public/events/[id]/+page.svelte b/src/routes/public/events/[id]/+page.svelte
new file mode 100644
index 0000000..891ef93
--- /dev/null
+++ b/src/routes/public/events/[id]/+page.svelte
@@ -0,0 +1,369 @@
+
+
+
+ {event.title} | Monaco USA
+
+
+
+
+
+
+
+
+
+
+ {#if event.event_type_name}
+
+ {event.event_type_name}
+
+ {/if}
+
+
{event.title}
+
+
+
+
+ {formatDate(eventDate)}
+
+ {#if !event.all_day}
+
+
+ {formatTime(eventDate)} - {formatTime(eventEndDate)}
+
+ {/if}
+ {#if event.location}
+
+
+ {event.location}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ {#if event.description}
+
+
About This Event
+
+ {@html event.description.replace(/\n/g, ' ')}
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
Date & Time
+
+ {formatDate(eventDate)}
+ {#if !event.all_day}
+ {formatTime(eventDate)} - {formatTime(eventEndDate)} ({event.timezone})
+ {:else}
+ All day event
+ {/if}
+
+
+
+
+ {#if event.location}
+
+
+
+
+
+
Location
+
{event.location}
+ {#if event.location_url}
+
+ View on map
+
+ {/if}
+
+
+ {/if}
+
+ {#if event.is_paid}
+
+
+
+
+
+
Price
+
+ Non-members: {formatCurrency(event.non_member_price)}
+ {#if event.pricing_notes}
+ {event.pricing_notes}
+ {/if}
+
+
+
+ {/if}
+
+
+
+
+
+
+
Attendance
+
+ {event.total_attendees} registered
+ {#if event.max_attendees}
+ / {event.max_attendees} capacity
+ {/if}
+
+ {#if event.waitlist_count > 0}
+
{event.waitlist_count} on waitlist
+ {/if}
+
+
+
+
+
+
+
+
+
+ {#if form?.success}
+
+
+
You're Registered!
+
{form.success}
+
+ {:else}
+
Register for This Event
+
+ {#if spotsRemaining !== null && spotsRemaining <= 5}
+
+
+
+ {#if spotsRemaining === 0}
+ Event is full - Join waitlist
+ {:else}
+ Only {spotsRemaining} spot{spotsRemaining === 1 ? '' : 's'} left!
+ {/if}
+
+
+ {/if}
+
+ {#if form?.error}
+
+
+
+ {/if}
+
+
{
+ loading = true;
+ return async ({ update }) => {
+ loading = false;
+ await update();
+ };
+ }}
+ class="mt-6 space-y-4"
+ >
+
+ Full Name *
+
+
+
+
+ Email *
+
+
+
+
+ Phone (optional)
+
+
+
+ {#if event.max_guests_per_member !== 0}
+
+ Additional Guests
+
+ {#each Array(Math.min((event.max_guests_per_member || 5) + 1, 6)) as _, i}
+ {i} guest{i !== 1 ? 's' : ''}
+ {/each}
+
+
+ {/if}
+
+ {#if event.is_paid}
+
+
+ Total
+
+ {formatCurrency(event.non_member_price * (1 + guestCount))}
+
+
+
Payment details will be provided after registration
+
+ {/if}
+
+
+ {#if loading}
+
+ Registering...
+ {:else if spotsRemaining === 0}
+ Join Waitlist
+ {:else}
+ Register Now
+ {/if}
+
+
+
+ Already a Monaco USA member?
+ Sign in
+ to RSVP with your account.
+
+
+ {/if}
+
+
+
+
+
+
+
+
diff --git a/static/MONACOUSA-Flags_376x376.png b/static/MONACOUSA-Flags_376x376.png
new file mode 100644
index 0000000000000000000000000000000000000000..24314cffd750daf03e2f29f881a6bf1f2d4e48ca
GIT binary patch
literal 144854
zcmeFY1yG&M@;3;C1cDO?5JGSd?he5nf&@sA9N+*4mxEhy3lQAhA!rEh8X&m4yByqQ
zpX8N$@7BG0|GV|oepOpb)q&^C(=$CY-TmvHo+e0HQ3@S}5Cskn4qZlCTm=pe-s#r|
z`3caXo3fG%{CR3Etz`!Xhl=y-0}q#&OaKRmmI6`Jgla0t^BGuKFo6uM^ubI{7S;eY
z9Grl#lQqb|3=AdJ2OC2y1<7Ic&E%vILqT#4P6ZYPYca41MB2p`tm>kuX5eCGz-ve@
zEJP~c#0Lyu0fvG|oh-~P?f9Go$$yW_2R#33W+o^7O#(F&B!BaZA*rTV8zVr$XyVY+>jCnoUOskDquS+ds_prgagZ9Z8S%fg%FxOHV#xRRWcl=Yxp@rPxY-!l
zS@c1S21Xp5j39j;Rz^-vFt0&Ze^=r
zWe64|XaDDR88I
z{2E;YVhA-6B@W_Gr}nw5WP17M%F5Ca)YDA?B87O>S{1|dk!_GeF3
zkcG85*iIR2s|;}hoBy8PZ$_HGG}+w#4{B~UX4XGDX#I!we>AIGLV#@m82nNRJ_+F0
zJFu}tC?7%p`+kfwfDgZ{n%KvKnJq1m12%rsr(EncHZ}Q)-wEpSU?^OZ<<`c68
zgP>q>-~%uPfQ6BTn~{}6jg5tmgPo6!`!x$2->-f8MG6Q2V&weqNC6CD<@y&=V5ba0
zP|$xP{soS|wOATr2eqE)KwJOl?`Q)4CCq90AqKy7klDe~@K4bF+fM#7
zBSWw)2En*x6Y)xVbsm
z!90I$<^OIm{ny3lgN^jrd3p62Il!E3j9hFS282TK1(-Tt4e-2WPW|5Zr;GdK4Cx{#QEMOS|S9`pYh3;*~o{)h2O
z{=x%0C=lE5{X5+IpX%VR{y>EAcgz6rU@`=}{QuM-fAzQef8ynTY0BSF^>^6v1_(BO
zLBHLf7%2)e{u?cY{_o69;P*zFLxA4DpZ*w#`Tv^ZZ(l76f&TK~fB5o0ng6o~65{!%
zv42SmVDev@{#mI1md9^`e~PjHnl%u(|9brU!~lHx`^*8h1iXnYaH9C+{VD?vfp$?w
z{EeDZ@?N83JdtrswY#uVPFPeFN3T|_c~1qU+{qJWY#NWm$t^Y?+)Up6-;pm(IsH
zDn?!dpGN~-(~%Zhyi>h4l36_kb{cL?!iB~hu@v!laJ7~?_Gj`J`FVwTHyU{Zb^|cv
z99-;QKi$N~%#DwJKR)n_Bai@3HlLq8brTmC?_iasJp4^V8_^;KG!SL|{{;VE*oSL9
z7bNognWyyCF`6Qw&eDjVFlD{4V)!}T^hmL2(cM~3$PV4~WBpaIXp!V(5uTHZUw-pK
z`68Mb?GL+=F32*x+(kgB?1gD~g;R6Fx#?w?dB^D+a_e0W<3G%y!c=LW3uXF#B~pj?
z6MZ71jPP89lyvA7(O~Pvqi64))6yCStL40s&54Be>Q*#1HrCx(OQn4AGS4q!o){ZD
z9iQYjDk6OE7ERpLk?4hwNSpJ;zXg>DT0o(m6fHggp$=&Cbe76krY7PBRgL@#Q4DqoS_!!@`i
z9*o2<@&3H@2XEkJ`T}p#9+7omN92)TKVQ~p#rAS^$5-XXuA|iZNr50{WoA}1+qj!R
zCnzY`%?pNIrtmus9Kg0yg*fIGwSVGAo@@>_ZzkIfe3+i&@aT{3VLz8obG#*O66J1-
z3R3#SWZ$cp;G1x*$KMrbK*D-V9Tw;W(WUmrmm+@YjQNxdIhqm~R|UlvS%QzWg^8DP
zg&MKsEfYTVTE#S&#deOB(U8&`f#4Qbrc=WAbLQHv~f|eQQW3ck)y@tK1cLpGP`Wu-!NS-
zAuU#uC-pEL3XG_b3t$fLtgbzRxh~!izpuKjtba*JIPs!xyfj*cKwoX?ISTr|G#`?m
z+l!~J#Rw$nx^F4n>0i(uBfe3_cEXlCLa8(iv#n)7@f)#OygGlFyKI@q)EOBaWgle=
zF0ZPBM?)9D-1un$nm(p_!)7!*JzX#}gAr6x!Zba)RgnzIb(i;dyx=S5k9(PN$k{
zDQOy=EMUWaUxD!OJ_zwXQ~?0IQt3nbh)fs>_5~^OV?DLwBUw6m)u4rik}lB<>xG^^
zMHe$NRU!Min#9DJI#b8%)9s3q+;`cdkh9&zxkBDB)w;`-K8L{scH{AaytJzy-j7iy
za*Issa=sKQGhl~zM~Jqc2qHcK(QlrOKW&(+_oNg(SC21zf$g+T-HqJPruu5DPUs*&
z_;%U#;Y9f1WTQq`c)P{x(Fi8O2!v<g@pV*
z*f|2Oki}N8qF~tV?8g%b9q~;%7D@}n;VbRxq038(i{|~rA}govIo{PxkD~{V3Fk?EOD=wXOjAr@>*j{d
z68_F}ix0eW!(Gqgbm&VOZdHta#uFm<&g>Ycnz@`t1Z?2i`b6!R78)wp7Y+_dV3Xm}
zYgeM<93O+v&%=$O*!DkzdL7-7alLFsL6~T_Ei}ji*~d>CN@5ppkMm$29;4TGn6I|R
zM`c1T?$R7j<{z$@75VpZQ(Zmy^{#p~E;l?MXv`)A`@&w$@*?hEW^AF*u}a06i?O1FeI>oLI3-S(7^q>G?Tsjj
z=phc*Zi?6K(C${Ra@j-@TaAo4XqUZ@TbjmZ(@V42pCkaQnzj6f^u#q%?;*T2$YQp7
zWR_DzK_OC{+)E`mHulJEON?a28({y?D<(hl70VE!mdPW1uX91t4;tfIo7ZOepyhNcJIgHbc
zoMpGDf!N!rA>?&@6w`Ks+e8BwRPd7a`4X>$fG{(%EPH9=#xZ8PEGn#Smx~HP`0T*3
zQB2@y>K#-|e<%7#q-CriiY_i>Z?-wSo5a)Cg5QC~c!1$(qk4gM<7Z+a2FYBB;Q5d1
z341eQ3%;|{Q6y@m#7cGP0_Z$k2D=0Ivc2Ttj^FOnH(2Qn%0FZ9a?OaO{58;1rC5bY
zCk0#=pMT&MILRHT0-ewZ-!^!*&n~`()ttq-T^f*Vv?7s^rhSLxTV>br`R78=FfpV0
zo)?sqP!Ly3Q*mPK?QS-?9<1NNT$>wiGehJSFT-K+C!30|i9F|$jS(QVqJhH_>k-jY
zW>kNeNOP+*T`NKH!#flJ5fHt?RfJkiEcqH1hZKmq$?%pO*S
zn{4jxE*1@Pid1^!nCdiVzZb>T+7X(Vf6ZxbPAe>=zrMMF52ShKl-gaj&+ohvL=%{R
zXH|PdZF4LWGPcn8VY<5XTZUCSca`~e#iMru&QmN5>h!Z)b?^-yUL-FY!^IC}>3p9p
zyLj6kx|1x9woUe3*zD`yF17Z1HaoBSM-lGWA035zY6~2<$R165UJ=dT33707D67tY
zf+W(eNAzGsF&iEVV0POCcCUVZIVns3!eBFuDb?fJ=BPaU{t>I(jPS$N;E0Bv`&pDx
zX0DzrZ`}ZEmH6p*EC4T)qyZJ_Kt)`LqSeEuCDCw3^uiBrEJa4}vv8HB3wps-KuuDX50VeY7#dw7Jx*N^3;HG#QV1n4Pi}dV^7+{qw8?f
z#t_ZS`cGfaQKMW5Sk)bi+VuEsetT#kwz+u~PNSq-@zHvwa(nlyW*d5Eqe|XA4a)7X
z^F=v!g9^&F56s!gE9B)Rq8ElJRt<(OB!6T0#`8)^FaU$2KT#;C|K&s=i5m>RLzUHN
zFi8&UGEhXB={YMDy_;znDL6hxRw+PB9ePg7pa&PaD-Th-pv6T4q7B-#xK
zfbA+A+D{+wqLgE(0f}*CcR`5OfI|#juEiOuH?cU^s~IF-&7+vudN(L;<21LsP@U
zHY43a=jE3g&DZ0n6yfH$I5fH2Fj!t~ZGt9)By|*7f;>4`bH`+sD|Hj_g$X?6iR}S5
zm%SQIy>UVh&Zb@A_zy=U56i`x<>G?JKPXV;a#TbgQ*d53ES=|st%viTm%qIY{1Gmw
ztFC2ctrKTkYi{r8sFc70Lg1c^<#dEC>@(^$&B;(c%b4w#W8flwwSLdq
zeXkPoRcdoByiN}_I|77G$rkPolIXu*^96}}tI_-I)&VHT4#<%y
z?xlN!CIg$zig<7*xu2+)AzRP=-OY5ZU0yN^sI8I|xkK&%)pN#1vY|W>KzxZ6&UZg!
zNYZa9R#qMh^Ct^z`VD
zCbT}_PU*ZN>$$SA@}h5X?XTvyMo9I1RfbzX=SgQ5VkLTkcd}K>Y{!X-UAEpt2KjFC
zGJ)%9qmRX;y36@W?`yuEybf}~Ld++H%Rxps6!d#}=|nFg2HGM7)MDrLnl`cfmXPyS
zZK#@3%Fp_RD+g+&1Zir1UlQDck|6BxZ8VJ2k8vkXC2~wjIT=l1ykKP_0=>qR8>{QnBuZD836Fw-q|5fQB>84
z6G47MACp;F7;Ibn6ELwL-yRxa8!_vhf;<=3^F8du+^0H|F}VB|1HuDBq^7HF5T3hKK9cB00*%@qnY1Sqd0-UJ_eU
zEq{bjEr3Ba|720@!u@MHVwJ+M+)}rZ;SNn&MWMJwt98=Ucl$WnI4gKqwYJl}U&Cr0
zw*kJ6`6kOFKc>@}_x?UdL_{2Pgz)vVRjdNg_{4YxgSs?sy~a<>9?NO!AEX96+~zZu
z*1j!%V^wFl-Ld@~Z~Uw#vLTQ5@{1#tvy?u6%<6@xtH&a<>UjX|Xh6G2&JEX`WEgM0
z;nDMjct##mS8!f`U^6iB>k6Em}{`uZf>S^<|FWd^)qj!+;F1#97=NND3T)hrsjS;3m~(|-c|+;FN&?Owv!X&9SDvoH!*3;q6(k@ddUU?^
ztn^iDIG?0inIa{;Jemv>=)^EsJU7B_jDH%KFi
z$MNss7X4R7_uU?R(u}%Rn^$(i8&fB7Qrc&TLPEr;w98S{r0G?bxA&biDYe%Tg6{16b+e<*x8pcG^sTj%ovi#jEIeL8
z=vr#<40)fI>4<33t7IrZrcfv^&qzi_NlaoPVbfhGN$}>x#rwQe>sXzwS0gt&4h#`J
zn$>xyWRpyIp|(13OEbi$P7Tp>Dot{9#8_iIqd*4aa;_%L+L-4yTCHod$Z1@}IeF
z7eYi^njz#~53e_s?6KY8Qv*=kSU7xhvmo!Mg6I*$R{h5rK3U;o*M+GFRav0Y<(#@N
z(#G`_ETonk2@0lkJ67(0XaKj@J+?r)7{F_q)dcTI@`0{U@tM$azEmhu3-P6Ebpa;=_kUg8MgimUb_te
z)*6%HisKovkS9<4C8(n^>>hNRE*jHDr>A>Pw?}!c4@oVTKyi{M7e7<@19DI!iRP-4
z78ZWdz-*RU$XH$9qXQt#YSnyq?#NNCsF`S3AK*;EFJxp^Zt%RFIaDc(QUDgYP%&3KS_W@d(fn?qVojAH+0=FRpP
zM`(9NE8`*nfaiWojpxWI8>wH;;($#p=f
zTbC^7T`sN@eS{(wQ-wbbiYk3J9#I7IbNX4i=i{{YhHocM8nSvt^)A1!`n`MHHn@r;
z(a_TQ)QzZ`PaYf-(smk~so%^WN
zJZd89XCh|^4jxtQLEUbCffZ=7L}$)p@?xsS2D4958}EAVW*Gy6AH$WOPrXUA(s&?c
zvEHQTIE4|1y7=WJn@xWuguS{e6dFL?FY=K-+AoroL$$kKkzaMIKn^*OH
zchAhLjsjfuu&^?D>?gw!(g9h?(grtIoW(AnpogT
zI_<5H0Te0P+`_7ilq;S(yEYL1CS?zG5q*1V&Y>W7Nu0W${`$>a^wRYR-MVUL#&XX~
z2Z3tCvzhTc%b7~cw|aVuHC;!c16sx@{UfSzUDs!BnYmoxSpVadpl*`!^U_6n7e`o*
z)g+t@QX*RXySR-y!$(oZoKcnf`H^DuG33P`C~wK+`?KxV->@k;zNL-+B9XNE^1aD`
z%a1PVgHxm}#TI4dwq7&jesT(`J3%o95`Wdf8g2Gs9vvN>pc((#8iZajJ`e?<`-vdf
zC|~CX-bJh*GKZ)vulT8DSUhTz2r4^9#Qt~_T(hvWxnHc`mF(OaJw3X2+1r0Kv(ZkT
zHaMy}C6_59w&t!+Fh=jiJtvo<)r}J_)OsH#5M`ub+!=#fvsajuUz8-@-Rj_A^`R$K
zXO`9b39HAglabO(8lwqLmOMIA4f@;wJAS;|`_0#zFUvRj@&+`^-0z?ErKRw>x3VkX
zP+Jz55lyD#7dlWb)lY1#6k_?}U9bGOF;qJNi_~bYs#!{rKU@nBqP{r7Cwq2j@Tt6Y
z7nP6qaYjwJ4fz>7FKJi@j&Cj&kt?s__gL&Sf+@>zR)z7Mx5+QgmaPk!7
z5xs)<%@%1$X6jT*J~Y3F9>CJa9xL!MB#riN=P{Fl_~m6MN7B1xt|Mzmbg?g+!Z)3v
z(2L=vY1|NrAt-P?0=oeM(wCVRq(FVfCu8ljy(``q16-GRV}NAYW8qkC5(`mCuyEoq8k
zUQqYy>BU9V=YUsDe9v7UHU9)6852{}6$L97oThh5T};Q9tIgnt`#ZM{l7}F0NZg8E
zU2^eDu5!~0Kxed#Un&-fGmG`CrN1asv{=oh2`_1gNQr+G8>?U5HqYjVz
zfz51On!`k0ElqM+NmZ4@X2Y`>9H(u0l69@OpMy29#jq7M$jY-m!Tk(WOo?rOe6
zhh;LWtJ%4RMqk5j`<2!ho3D$l`^uaMm+z-b*8yz>CpEZ{XnLjl?NK^PfTu7qVn!XzS&_%ZH(km_u8>@Zqd7LJxL3j~^>y
z)VT)GMhg@c;>7M_C^+;wQ($vjWM_V1{Madg$VSwp1A(9;(!O{X-Y_<_gD+VorA^q)
zM!u%5D{#eF(qlepBM_pd~
zm9d45BX%xEp_3J3MeQ0VdsWp@hdy}>Ix@IN^k+vD+x2iV=_0|DDEPuGL)rcqS?GEc
zCssbBPV?WaDefNt^JrG-k_NuQGG`SFVp
zJCp6B#^MBR%V`V8yMx<@tJVvpGhgoe;MdHf%#Ek2`UA@4`8ny2Uf^g2zVX8DPmX%8iprg#_NyM
z7pD#G)OU)E{fjHW;f9S#}*@-v71r*o-d{AL_m9z$|y10;Hf@&S0bm`*1@zQTY}+j2NfDR
z`q6l)N6etdx!h+nQ={#Xoa9Ptu3U)iZn%f|g9tZ!ZolDCq+ha4Yb~k3xy9no83nVB
zLiHl?CN=S$SaZvi>;Zw3pG$Mk<9Kxf>JrlyhaDz>gnBU!;PvaAfUPilr)}L+c!Wc3
z4lyNKM6HqagS}|aPbTnz%kXpPNF0-#bPU5gDcA7D_M8_-c+hBh{WO>a*%ywkJx~=T
zh`K#7p+z-5x8+PbIZ`xEh)zyr;DXF|s4&X5?O<~<%>{@FlOGyM?zf^fXUv^X>K8I~
zT&DdBGpeYoEOx2)zmZ(kIqfWPYJ%gn(9?u0xinJjh0xVG*Un!_63ql?Fp;A^KGhG3
z-UK1b$SY;i*dI^JgO#XcQKH8VADhs+J=LQ4Dif9RKnYiV59gj!bM5@5tSy
zEqEgVuM&|!ggZe#KlAsG6Gv{Y&np!Zg{li?5?M6q>7p^E(fttV-ohK0u1I+-hfrLW
zt8HO!
z`=GkZEjU?sa>XCF?{<*7TUvkaUo6`(p_s~H*WcjRm-;9RpTUr{+%RWPZF|b{lCaZ&
z*+an>E91i_OKOQ9XvDVg+vZH3|||4Q-dEkto*PBO2m~rjx}*+}WygWwVyAcq!Z|9>jf{
z16f@WhWsv5XLDT7oWnI?aBRqOPdT4Afdm$_KJo*3%b#DPDd@3tCA)|;oJ>j?;F@4S
zcrHzefZ|+VVNK}pjXH{zkN@QK^fa*UD9Ruu1&JCD#ZRli)U!JJ{St84W~Ju7mon||
zvoC2H86rShDqgq%+sE1ee>7IsjC3o?XslB@UBz1{o~PX*+06KxdM0@#w>
z&!>|;#??s^$*duk7NB7|niH#>#iJpLBGyojSn%-TqLP#p5>lG*C&6S2YO2jt54K#Q
z?vlo$1UAObwQb0+Gx);o2KVUrLVN7@`l&LI&r}5@ZvxsIbew2b^7P4*C!Ja00=&x;
zAFM3Y$*UUZ=gW+TBR2tE-na^{tW=yO2&W3@UAI=z_^Ds%;jy>Nc3!x5rp6?3`yd76
zdO(swEX)_6r)DqR4AZ};v^%yxmRInGTnDz~W^hRgac3LbPD27_&QqQHikk%<%DaiR
z2tzt|Ebm*;e>u^p-qW*A*yVWmn!&{aO_kMtoP^~ThdI`Mg
z{-?2z7>gG@IriS9@~zTz*e;r38OSMkeKB_zZ!h)bsW3C(G<7eXM&5$5KRnHMr-LVH
zcSv|CEk4tF(ubC*`bx<_4xgK8+cKctg7`$yv>a+@CpHX8)1&Ry=uuz24&BqP6WHU0JqQ(6RdI)Vw7vs{
z5C&px-uH7XioAU_I|NE49-jh8`q(@~1-a)A@lg2D>RjvNd0!9gZKKv)7LN8{oR+!Y
zI84CisLw5ROF1XFdtMKI$n_mkqt+{QHXTolf?cjRr;ehcbp_fLEh%WHqQSmT~122oA@`$8%vf>_EbY6V2d<4G~8{sB^#W
zyxIEr2s<;)Ei;voC^Atuo@ih~E~M-I`w#2cGJ+!)WO04NbJ`LL3g~{K@RMf+C1Kcz
z@RneU<-_InrA{E5I<{^LwXYgoSP)8FbRY`Q6p$=n@%^g3&7jSb*2lz?^7A7mrl2h*
za4+x4+B9x>RnQkNXv}9eS(3A9&EKc^z9j))yCq>#yJtsT>G(2BDR2?6F8$lfr}7Cx
za=K2-eytU%gDfPT#|c+J?Eq%p-HwK1szcBEq>x7^m4n2ilNZPT)PL8_rDz_qL4CkSG6?~Cc_`NfaOc*19=S@@n8vx@?*4_-Pr
z!1i4PUo=0!#=}xR6nkFH1$?jfEGHcGiluZr98s;k|9q05sS{wj`*Cv+M&nq6KmV;j
zDjhdXZGOyjyEouNCPXR`EGc}LiW$t}PJw6(E$4J=_)6E(eGHFK)@Cqz
zIE)ZjAGsqk;)`Z>`Y60|Q{_-+Xa)NA<6R=L1I$@ByNH1`dEX?}vOf^1Bt#h@+6$%a
z_JQQ4t~nOh=L@muReGhYi=J3|##_X7EX33Uo`lu7Rvm6Tm_g
z=NFP#dpv?udz^`rpTLP)cf!DbM|r<_6K)Oj`EYf$FLWVTo-olv-KV72A_R3P)mDJ)
z!dupDQ^!86qLd*j&AW162tw1Gx|;O(f#`f)VKSwN<2A8fqsywd-u;?h*v*~AV#8Wj
zb+g@i@CmcqR3+gAUWmFNa2$qaa6UV2W(4dA^+Z;Y3BVr>4K1*M-T23O$$=qLoN8iRUTz)nqUSQ~QgyV=
zQ&va|nM{6E9upIj`FsvkR9*jpXl^ktJ9=z#GOEXrfQTTSUI%{$Q(A^8jv_(60@VKG
ziEF)#Eb3@9TFfQt9)z@D5J{9rYhQ@|Bh
zK)7PRxc5E9tN%%>fa^$y#m|onjz8Z?Q=Q0C+$5C6Vg92U
zo)m_(Yld>KtmItOvmu$;$OicNp2Pc%R=9RHo?4Bg%GFW2$36~T1?mc?4)_Gp3hBba
z9m}cxnN`E1YDSr#r~7powo#&seZRIvExni?>X4I0BO6vDpX+Ajgi%y8a|#$x-myeF
zd{KF~b7iLNrC`qzxaqnkfuMIeMNXhjQ^56NdMWJwyavNu!YSm{Jy|8EcAB~
zGU!5n2j4s9e9+EM-wv3`3!e3FDU!T8&HyfC8h%>@3s>TYVX{bu80
z(rr=NV%=>D%x$w*j!AG|=Cw}a+so68@k>Rf&;+!*a|>Z}@`>yK6|TCWr$p=w&2Dcb
zM&-!#tX)qbTzq~FLV#`RZAhSVNX^fbgp}+eT23YVj>rEcG}m(jR^y*MLOQzX7BLuTnLboPzj~m@$0fKmnYN9Hjf3;2ie*(o=s^$
z>bEPLq!gl<0@ax;W>e@b2{|WhDC>H%395y3@+VLz2zb)_iDqy{NXvgM<@V0VAS*5{
z4c9N1W=Lr{5g;Ta9OAGT(-59ZJ4DQa5GIJi3qJ3@XfGn0ymmsB4AfwgxuSSvpmG;>O)s>x&j%wD|_xY9|}102-c
zNsAZ*?}PS0?WHIllhw*R?An!ycN1D=KGjiufAit^mkF*kl6W{Cb~1asb-f*KT0Llf
zxPOW%;MBK0spolDc=Ar$0rRjjVJOo-=fQoQ2WO3xpTNb`ByN4PS{}zQPR$p3TlT|0Ry(rH
z34hXKhzRZsk_&=4Aq$I4_V;RLVd?o}mlCpXs@>qI5>tE@2-m>=1jm9O?|r@!AEx
z0WJ1!M6+3HOdk`eo5p?^3bL#qmLtki4I3s$tHr0v2x(;2)e&kq(r)%?x~TI!V}&Ry
z6NbYEK=MCTEw-~mjdI^98SUpjx;^MIF6WdXx1_K(k&o^nnvsc0+ML=I{ww~wg
z(aF!ok8pFxkSj{8buO}zNa}7Fg)fgV7aU;j7pd&72VajKE|nfkfchY!hb<`~+?#99
zTQ#(Ey!bi9w7VbmS1tSxVbL7ZO~)@V_U5ImT>1S{b>($vV&pTP$}G7^w+ul!$l9;$
zDQ@^PBUYqMSI66pins6mIJSGb=r=Z)UC=
zdNigNIz3#@5Q2m{xnK0NcA|h)o;CmLoU#*~lSZUL?P|+;u-?s#Cu8Gr?`t!wnq{03
z`K@v*&(+t*_{|MclO!or
zPEGnO?4nqkkdk|72yRXIxbh6B%K$UVT}-}0s=fCg}z{gF3Y)9
z`xe~3hVZ9D)7M`jMd-X`W4`1HAiiNpK%*9_woJR4iV~ZuQh*(j`h)c`jilpkvfp;G
ztq>HvXY--@Jj0hA4AjSwmdUsfdq6%B3MxTp14?mYGsx!1J|{}n1rwJ;BO~>?6Ysce
z-lC_5AK;eBojF~b`kx*?wt~#WN(q6t@}-8GRvK
z9Hv=FJSk_;`C+>8b+<-yL(=qU{Zutl?oVm!Yd_ViDF|$%B4lu5fFHItk#?sRmL|Z;
zYIXl~;dvah-f2gau$9wV&hC6-n~XNQ@|XYz
zbqv&+QY9$n6`#H2?p5-*E^52T$i*Ab6o}(avEkb6*OTA$XFRQfWxDT6>W5{r#$;+U
zh3Ar)&pr`O{uWFvh4lWG6Syn_DpZt#LT(jb&J~rA(Xp|PWo@s-L_&SmsbA>!up3Qq(w2_0GHbgiS
z3Ax%#23mNlFL#1E^(oEFY%acMy!Kn=$>LwV5t5}-EPn(
z`c#E;85aF)(q5*EBN}|t
z<7oZOffT5{J$6y(s*J2u6jwq
zYA@GNW2>}p&bj*r!m2KTO25J5b3H-w_XsuT9X$=3YpR#G>)foxr5zcr#O;f90W0V3
z*(Xe^26TJo7~M>&>M?DDaabOoGIrz337&cF*VZ5+>ri|erlyi0s6}lv`4Glgc}o=JL&VB;ypW
zEm>FVJ{LZ^2@MUM7WszF23J7lr!Ld~naA=SJ-cpBIb`iTM};F57g09DRmcnB17SKY
z0?C_lqs1MqM?EZgTuK2M{duP7etKV
zc(0d=;3N`LJwo_BCKZL)Q`$dpm0hl>#yiZZ7%3)8D(F0NJ%{1$a((5%XH0w{e6_~g
zpEN1V>ppIPS@|=~G1;oq42kmv^Rj=xl@zL5OT@*@l*O6_weD%uTloiC`~lC!U*4eB~XqNnRB`3R3FG8-SF1-0kZ1k+|JC
zBD{RRyWG%;>O7mHF=9$JRRu=9<9Ap+NBf8?n=Kzz^CC}^A#;~62pSB;o+BJm-9s^`
ze%8>m3_r2N2NZ&yC{(&5^Kc*!T*rshUhZB?^ag8ab~XfA0Jwz)3PFMDLuOnQg*RBx
zhMAM_(3y4@k56$`$R3P0eAf?c$P0u71SVfgOEF^^wAwcMlfLWQd{P;fK=uL=WK@Si
zfa*3NV(wV6keZ?}xihFG2=
zyb&;3;lXIaQI0Cf&{i`sN_Hy`j0lV9!(5%#itM4hySo#(-pcwJADvg55#hh{#G7>K
zNQz{=ATO0XB~`TNL^{WmUNM!cO(Xi4NG>4&JkO;(pv(Xu_9WU
zQ9Snuv-bJ23L>m)Dn;lpHJ)2XhjSDn3p}b;3yIEj=*tZlxW0omUu?K}oV;K+`kCK+
zl_)p$%{BZ!mD^KQpxOe7UDbN0;Yiz~w>FvAf@@+~pz-AdIVN-@QviYHRPZ7@gko+e
z@v+}-ObagV_>VmK6%FzkYm_nRJI9*Eijh!yT$9#_njZI*H02Cja-NEOzL_}6+hKfe
zYr!3f$jXr~f{*gQD&&m@#eN3GD>FQQYA&Os5m9I;3)F9r6IXAFf3!7$n*0)_59>a#DC!N1UqHn8?)-e|!;*@)!Xd$anjzk*B>f-`Sqk6?VUd
zHW%f6MaRXt^cPLwLw`potk+h7uwo-IxZIw-$}0O9X_-!iE-I{DM?OB{zn@3aK<
ztW4G3a1x!(#&eiq_X2~HnQ~Z5w9Q
z#nmEY*v4j75jLDG7TV~|o|!43Np&xnQj}Qpsg0who3Rk{91*$rlxBDK>r>nU+sRn8`675Xi
zfB5hn@CvRo+)YPF!;|xaUlZ7Cc5eN&ovssprU<08`t0vHIXp@@Z{M=~L@JUW{C(o>
zI5Wj(&N)JhYit7&VO##l)ChymFMNdCO^co_X_OEEcg7K!BXBtZuPRt`R$aYg#<}Kw
zUigl^O%#!4QNR&tvABdR%*ASrzj_578zarMU&u;gET>L^Vm}t!-%u-
zjdJ>&LwXvx{tNn2Db4%59uZ=phRudoNyLrUKiLOy#oC>(TExOp1a>$m%;}
zpjl2Sip-$%5&s*9Mt1_Y74c@OBXMZIf}w?sCs)Xj{j(rMFAXE7nk1Esq`elb8%M`7
z=T)XUS6j-$q@ZyQWrtU`es7-9(Hdh^Sl>I{!;FR79^>oDTc|%D%~e>f?9_-1q|vH|
z;Cx7R4SQAZ$i^|&{W(Fk_D~`2Z4}FNqbqmw)fwY^wg_2m=rH(uK)nm~-dtnecQuEf
zv0W1&3cQyNvf%8DEHi49q3n*6M~3U}M=Q9YF2jP@o&rGZgq%Bpu~X24l&UuN-ll+NyW!WO#6La&2dwy87;+y_4s(jnJnp-A8x~-U(#wY%DqG3eMmu**
z`JR;a1<1Xgxjjlmna)q(`VrM@UdIg4H^jqdldlgmj~i0alsH}1uiP0)zkDQRb@TK0
zN1=R_IGAA@tMXICB;zO^thLofn$)kK$i$vI$XxY({~~WN@!W)b8Ynu)+M*@H8j+Co
z!ve*DH6J~Q;&+r^oomc`%jzmTW)h9c7x5u&!Ll@;-H5STD8KSjwW?coah&w3xZmpK
z6n#%UZ64-A(RoY9Zt}G{zgXeqN7TzVVC=STT&~`pESW$Ed@nwlk>GUzI@>g1G*?JW
zIMi)!!lzJFiqz~i7u;i4)~WXT1-_JUPZQvZMBciWT*h5T#BvD^2@|?B?R~w2
zr>!m(o+$n<>GSU7+bRaN_t=_`gXw)x%i
zCfH9T5fGg_Umh917}&<0e_tiEDE9Cv#M&U)ZsIvZLUmx9NUAHW4uE9)V*Bx=0Y%O*
z%-p$)rNP|m$BaT21E*vPE_b4|eusxTFUfFKMkN=$n4dY>kGB
z(j-E5R|hUO^Vd^mJ782fZDeZ|2_>mMA_9I5uV7
z-Ny}*qQH;tjA+|0K(wK6UPt{O9G!DiWq%ikCwsDOTa#_OCc7rvHQBaZ6DHfXG1<1Q
z_x#@0x_`7*f3)ts=bZ1}`|~_|m;GG7J-6%PZKS~^?vYHcEgZTJO_(K}3^nO9fw4Ci
zg4)!ghyol4hB$La{|&X9)TC3t>BQ7b5!2AXEK{XfuJzf1Uu)9-4MsZ6N-}0xJRZrl
zT6ULA7dX5runkj%cvtbEp(b45=p!_x}w+
zhw@xrosAmjnI^%Z5E~q>w}n`0e>Yk!IbP=h0rHg7QwI|byS2zobE0H{A!^zLv(z{N7-$eQqvsQxCK@&pyCBd2FI9*uR^
zX}`{kfBK)tS&@uge-5+e1u9)Hj#;`tW538cN$)UME
zDdGzzoBzf{l7z~)at{$_pvrGx;$EW3Zz71=pQAN1NJfjPJiiR1UY0aqh?#4O5Az{Q
zy!B*d^CXAipJQU5Tlrbuaff65&A$-Mblx}s)4UDUn1}%B8FVSJB}WuOjBdVvbWus4
zJs1YHy_~(!DZ){~T^x%gD+-jzCXQb?nE#Pfx5C(iTuA^&sq7F5CFxcOhK+j0XWF^0
ze=ie@jSpWRo!N-UCbg5t%}9!n_Fq3bCjM~oQdfBRe5oN^bmR2p-l&?k
z$6*=$`PtkFbrH;?^ql3OB8QBheneY|c*J1;fH4I*V>q-otVbq{3Wd^|YX7H?)d}x2
zZzv+6*=V}o@Cxl_UzLv-0G;Kf-PyAv2C{@)OG$-OwSv>g$V#)33T;2975yoz%1=5o
zd5bw>I%*pzmn*kYW|R9>9Ot&uu!odh7f$J7ZX4#vl16)rEn@EGi^Ao9(FfST
zCvtN_>({{DUnc4zzV6+s`U~U$GD_TR5yneJiks7MQW*C?`pqf=(A#S5K$?
za47iU{e1ntN~3|(2D@(&JVATsSQXEBFe>0mh#-f%A)vcNZ7MzL&A(;e2SZ6UeK!Ck
zqa1WGEkn9K0M_5Vm86k6a|5D?HIaC
z%#}ZWlH*&h1unlBmN%fp7~lFMdjH0?^MteeJcfEcz<0Y|^!sAl`g|0@QP
zbeU9!MSxB+G`*FdJogx`2IGbZFep;IJsvECji2e}FTi`R$WvtWkP@iliV3t?%a>G}
z$dR2FU2*{ksULgSm-?IUi+&$(Pxp^0sU27S`u{5Tb@p<*uSH(&^w%j>VSWV`_0k{u--mMHVAlDCYPhyk(o30XT6;DUiVc%NuU<)DJMfO!Fx}ZHf>8yF#hq79|My#>%UH%F|
zmJS{Xe$nk~SXstxJui8c%DBIr5-FXE=OpCgj~Yp}g`vvMiOY3+O>_
z-sr|9%^}mr>1oRUr>06-p2lj-56q|wWH4A=+T^+3c>B{Y7C08?7M3T5{@U<1x)Pc#
zvNed3lAG>Ii}j8s(l#*5pA}8fZF^Bu9Wf;hxF|1+U~?lr6a=uz(GTy1W3M}GQP}z!
zu!2E=gtJMX4zxS5VczL@44Lr1^d79=V?++ZjggfjDpQ^JkFxJ1d6%QiW8+wxXlRFI
zEeTF#O_WE2t$R(&yIvhb)%*8E5$`7KI6{pb8-2c$adsWZR<+-Hmoxw=%V}SN_l=LN
z)^j}h?rrXJ=V%E6->6azzA}Zh8ATqefA_C3{h>db?6#EDG&HoO0SLXEROXG*eG3nJ
z^YeAn&(663`cn;y7L>HvxI0C;Ky41J>a4&L=f*t?_*UdT{zpA$s6F`|6eKF}d)zMC
zea1DL-Ch0%R^}QlRRxm4r>(UiQT*V=TzFOOoIoM7E-&v=S+9{K7QadYd(lkB;%o
zle06P&AX0rBIpR?Tssp%>E{m_xc6o6kMRpkyjo-eud}zV_
z=sWY1Cs~+K6Jw$DtgNXVASeJ@@H;;DKn@?p7#(sN&Zuh2XMRmZgDOM3B$@D4hEcqU
zG^GZOlD77E8C>k%f{H@1PfPHcU3d28rfpk$yT{{7c2Sh=PaL|#iS+)O&RiaXRbjho
zA>9p=h-9VkR-Kr{)T|!uO-BfT#u8-sqN-I*I9#EubLLwA90#q)Sr=^85!V1EueV;A
zoBa2o{a-2(+6*;vF^?|NupQb=bFKS$z{C3ym6PQ!wSO)(llh!Aj&0f)YHa0UA>juV
z;n#Vo`_kLiGz2EY4r}fU{MN_i*Kw)M7S~1P9fLIf>9LhA&|5A_j9&0TklR}VN&ou$
zw1k^&%pXAbVoj)(P3XkqihC#Gdmw{opF|Re({)cxi@ApSEKK4Z+e42E2YTN#JS{aR
z4%?mBe{-S#x#4x?caMqrnVZ?Y++lL!@j31LQ2*TRdxP6YKv1F-n25U#r=dHH
zP%q!>?uofM-yR1A^@qljOv!z2ScxMCGDn>`6qzP#5+}$ZR^4(1et(&sE*LoqijZThcdiZPbpAyV9>
zg(oQb9nPl*7M!ECYV3^5eiHd`s0ZjwE(l_ztyzVHo7c6mmY?~1YFr8b+@sP`GZ`Z&
z`%bqJm}6--Pe%1$XjrGzfsuxR3&%rpUq~prLa!m0bHs5od>49&W3wa)#mRX%fSM?@LjR|LxOLO%PXIM
zo%Ug2iqNn^;h@^wTxU2wP&b7IF
z2)Fm8yUlq8fxkYl`d>o#_vkfaOpE@s@)?sn@~vs%dRc*Pe1Y5x(b^1ozz4WQb%s*~
zdkr%$a7c^L|AH&1hLaH>O>=Ubx`AZaZ~z4h-N*e-lvi>*uSKWw9o%1ibPY2yNty#V
z!&$Gi1%{(iLh>wCb2;od$oTDLs?UaLNuqCi@k=Ly|G_La-WhSuZ%cQxqbL^_2YCNfxb0=4h
zy|O##WG`8ATz>*GZjOH@C|3a~a5iZCowEshE9Q_p`0rN*Zh5)Ny*mSU40%)11uLd(
zO1}juGhJrbqRSh=#0kjjlE4s#84a{BM5qJ3mM%?t+$4`Jf8tWc$&>x5z=bM?ROjIH^kLSf@aQ4I8(G&cJ^>+a%+pSV=9v?bpXReHz6LW
z^%&-GeLzsO%^Lrg51=S&gBg@LN3^*zH^~aVQL9pJb*>kXU7rF^;ase)r2^M6ZqpZ<
zAEJ#IJj)~$@*Qd;tOOi#iNA(G6dmE&5XfNl(c@VJQ@N2=Q3`2LI38(qzV%hD-wZMI
z^FE3U2n1lVw}J~g$ARbD@3&p6|A9X{Y8
z-_#m?{#+;Ho5sDc!jjT>=@OV~vgriEBXXG>qeKR58Sd+FKC{+1!ml_w=uZ&r|!B&r?rN
z4{Vub(yfOg>^wZFs~l=t8+MgH)fai@cT&9x=G;I`_vD|4V-(P-#}ds{rQ`}=
zysv$AvTHRVXwkwMIdpE59_fuu!lfjxyQaMxpZ&R_QQV_p8y0~ukg+fR!7G
z1S!8V%tDVb@yEP3VDOiHjmflr&2zkSLDw4QHZ&&4KvHpBbR@6
zRcYnYb{LILHxA#QLg@-h46^V@@E>_6-2P`MRJgQ;C<_0SS=b$`EWj8u?70QFN=AfgQeb1apn%f)B{|+IS=25825MDF*1COqTT>ot)B&oFcXM|dfW
z8Id&REq9y>ciUH2^O*G726TKo`z2nsAG)49Sspo5GV?6u?j5yi*#yx^VQ}?9H#)^FgFPouB%!piKy=#B(!BuKz
zDX$#NqIjA+*i?zPk+6js$jUB48c)SJsJ||&b|Zv7WwmOn(N^`a8A`Bxns27f%^7LbL@ZLEgCI`4e4xn;^wano)D{w+h1;;y@EKv1g{y?ZLau
zF4m_$ig+DgToiv8FvbeJ!oRJx*D60Gz1PU7<=JPGP)=qJK=EI5q~y4;gdjaj@)qCu
zBl?1h$kbl0Orkk@+`bqB6`H()0^vzHHxkT*CJJjxj{_bgR6Cb%uj=RJ72{4x^JY(F
z8C;s;QqMUAC6no)PR`nj^u
zUvju}0bY%%0NWy(vs{E_2P
z|5AHea@uCp!M_LwJMDCx3`f!cg=}%HtP(5{eg8hzY-jB+@aS}1*l~aVC>Ts~MXD&h
z4F&RR!t{M-0R+&qjFiL)u_Leqvz|wLJ*I1`ufL5<+buVIjjC)uJ~oqKOtcj}7f<^P
zdmh$C*TuJljoZp+<{Yuwc~b64M+7$WNm?!m$uA^6cPVK2QL4E#!tZRSIW9Pl+e4(l
zl{vQPzbxqQ55Cs3a_==dc0B}tBW7-tM#G>2-6D?T_Wd>qAtYiKZ<|bv1QHkwuPhx@
zqJ%e9XYS{J*;No`Jn&?v-+dg^I_`V+YYbiJ$G84o2@452IK9!B?Slf3VzjDTdoaw&
zu)S8(c>LqE)!MI!)kw7@W;CSd2&>7NiQ+^yii~hPne)1J=q5XNOal5@879qt77wcqT!c0MqfR%LQaxQ6C7Kk
zEU}ZuO>w9UBZSHWNm6szQsEAl=0DG4R~&!HlIHlH%4PW-SXSz`W^{=_jka@F=?~v|
zX|K#0CQKuuMk*BNlgAuk)IdR|bJ~#PzG#xtRw)E#8yXNzM@?{9Z`4J8y&ntQ#83Og
zH2a<6WniSf`00Ow-Jc0Tl+lQck8^kb7TKE=|0D%tc#?Q
zV;q3N(Ba8|MUl*$XzW!@E>w)T8{Gx@28r^VDqcoPa6t-ZYmB)g&fAHgj1?lZ_9+6F
z>v>vMk)!3ebO6=%NbnTQ=&MIlyZ~G?{sWIkA!Wp3@PT`=3J!aV8l-r=C33jfm*@HN
z^R+S@%F=Tu_NTFRqGGCl_Kk(8{gZx@Q@(L_jlI%DMOc!f7oQoD##@GbG6I#li5ku@
zrbR|bx}@^hA$4lM?T#j|={*ACG$(xjOwg65dm;Oo;pb2FDThJ=Em)va=nod?EtiIZl;n^@`~$z#i1eX0?vG=nh1a%PX`
zD`e$!ym#*umtMc%^b8Eh{zK43N#u!_6i9XeRbrG-u6k36V7%DQDE4hZ7kO;ZBus7Mv
zZ*0gZ!eaWt1%rkY5fQ|O0-WgY49zGLxrnYEAs~;?Mhvt;mV;{?kNe;l_n-G%IR5?=
z(|)tvJt2$4oZ6c^t3Ikws61jNd?(8NeD!L5j-Zt+p|~#dd-ntAL)N;|($drHuP&1G
zr|efCj?9BP#lJUJHlCX8pulU$4;7^sWM$|4;EV7+3HL7Z8rnhh7m2`V5Uu9c4+}do
z;z~pMfrfue8FoTii=eMCPfdk`Wu&iWbFJy5PGJ2pI&%rQ3m+C?&nxS{&I#O}-zmQ>
zl=73W2gr>{&siUN+|7w^`c7%FJ>J(o&2B7s@HfT~LF@412SHOse4`|GprwX8fg{6l
zAcIWie!whF{p78l{>lYUrokZ==XK~)II!6GNb3TZlU(l+`33pF2XRRGYuTI{@|LU4
z6^Lv=zIwgRc#CPBn81SUWy04Fjmpv{$vE&~)6$UH=8N(w;d1~m*SO~_o-RA9E3xao
z6{}g?^bvBGNFT~#!FnxO4bL1xqS?)Qm+LB`>&)t^w6Sr9zdr~|DC={E+|xD@IUx!c
z7;72wJiB-D;NADS>j8EC&z(WMZhH<*ZgcBerJGLP=X+OKb_5mKK71xAoJ8v7of@QO
zi@YRxB5R{IBO(nV-Q*rb*&y}la`R?ZA0u?RMLFnsBD0|%mN&uMgYJi;2qsPerx|Si
z#Md7rFhOsGQ|0D)V2U#ozM;y^PH*Vmk5O$EWu8fpU!ngZNq>hiPy3*sL6KtJjudIf{&Xysr+SHY
z+vV2N>H3%IvP4L9P_y{AJZNa~4Z>t)3tXLOrfqF)WC~NLI
zivBFeb9%=9DaeVDm#2nKhWEV(1CT)WCKST~nFzdfpSagmPl~9_o?fs}hrLNEYQ?eA
z<|`(ZpJ(n*JXL!WrcSjNpf5U*mZw>ckLa2w4%W*JQ0*3q+19{uL39EJ43V;uX!L*u
zt0V*?HaN1*EMt94BYmWCX+!tYbPnH-Kwjg2$A6x-@
zCR0J-)P-VH)nOFy7zvGGL!(hW<&z(CakA+g$*ZeB|5bUVX!|vW(p^Uu$<>l$AeQ+YXBo#bdmGa+$juLp5+U<
z+V>WW>Nw1?8QFv`zaIPInD8wRUO(ksH`dS?o8+<=OSUk2!_nkf2kFs&8`^SbUk-~r
zkR0IO^+AS~2WaU@f9;;C%LGH;)=ygXf9Cxar%Qhhi(Sx>0ha*28O%2!1QD?wN
z@LGM8EC2Op1vL`azwE?a-lJjQ_LwF;wIa)d8YoLsObsz*>Y6)^<%d5@OD8jyN0L^Y
z7GgTHw*#-9dAogZg`uZ39bp^U5%4ZghRdss|CAG33$l<0_Y{}ZX0+B*=6Q(hw?EFT
z%?t4on18lX)|yD83p9Hn6G$fa&G|U