Compare commits

...

36 Commits

Author SHA1 Message Date
d3a4554b0d fix: links 2026-04-06 18:05:29 +02:00
71b85d6874 chore: fmt 2026-04-01 14:42:24 +02:00
7b3d5461ef feat: kamp page 2026-04-01 14:18:07 +02:00
56942275b8 feat: add homepage selector 2026-03-29 16:28:12 +02:00
845624dfd3 feat(admin): improve export to put guests in their own row 2026-03-19 10:31:12 +01:00
ee0e9e68a9 feat(admin): add birthdate & postcode 2026-03-19 10:28:07 +01:00
f113770348 feat(registration): add birthdate & postcode 2026-03-19 10:15:45 +01:00
19d784b2e5 feat(drinkkaart): implement QR scan and balance deduction interface
Convert /drinkkaart route from a redirect to a full admin scanning interface.
Add flow: scan QR → resolve user → select amount → confirm deduction → success.
Refactor QrScanner to use Html5Qrcode API for better control and cleanup.
Update event date/time to April 24, 2026 at 19:30.

BREAKING CHANGE: /drinkkaart now requires admin role and provides scanner UI
instead of redirecting to /account.
2026-03-15 14:47:36 +01:00
0d99f7c5f5 feat(registration): add watcher capacity limits and update pricing
Add 70-person capacity limit for watchers with real-time availability checks.
Update drink card pricing to €5 per person (was €5 base + €2 per guest).
Add feature flag to bypass registration countdown.
Show under-review notice for performer registrations.
Update FAQ performance duration from 5-7 to 5 minutes.
2026-03-14 19:37:52 +01:00
3a2e403ee9 Update event date to 24 april, add venue, add Ichtus Antwerpen as co-sponsor
- Change event date from 18 to 24 april across all pages, hero, meta and constants
- Add venue (Lange Winkelstraat 5, 2000 Antwerpen) to contact page, Q&A, and transactional emails
- Add LOCATION constant to packages/api/src/constants.ts
- Add Ichtus Antwerpen (ichtusantwerpen.com) as co-sponsor in footer and contact page
- Add location info card to confirmation, update, payment reminder and payment confirmation emails
2026-03-11 18:17:40 +01:00
e5d2b13b21 feat(email): route all email sends through Cloudflare Queue
Introduces a CF Queue binding (kk-email-queue) to decouple email
delivery from request handlers, preventing slow responses and
providing automatic retries. All send*Email calls now go through
the queue when the binding is available, with direct-send fallbacks
for local dev. Reminder fan-outs mark DB rows optimistically before
enqueueing to prevent re-enqueue on subsequent cron ticks.
2026-03-11 17:13:35 +01:00
7f431c0091 fix: email 2026-03-11 16:34:51 +01:00
17aa8956a7 feat(log): improve logging for emails 2026-03-11 14:23:39 +01:00
5e5efcaf6f feat: simplify mails and add reminders 2026-03-11 13:39:27 +01:00
0c8e827cda feat: set opening date! 2026-03-11 12:37:01 +01:00
42156209d8 fix(signup): when signing up for the event and already logged in, set the email of the account 2026-03-11 12:29:37 +01:00
569ee4ec40 fix(admin): fix search (or vs and), remove sticky header 2026-03-11 12:03:08 +01:00
29250f8694 fix: resolve biome lint warnings in admin page (useless fragment, non-null assertion) 2026-03-11 11:55:00 +01:00
f214660ab2 feat: redesign admin page with dense ops-tool aesthetic and expandable rows
- Replace generic card layout with near-black background and monospace data display
- Add expandable rows for guest details, notes/questions, and performer details
- Fix duplicate stat cards (artiesten/bezoekers were shown twice)
- Fix gift revenue card showing wrong value (stats.today → totalGiftRevenue)
- Fix mobile card view to use accessible <button> for expandable rows
- Remove unused Users and Card component imports
2026-03-11 11:52:26 +01:00
ba0804db30 fix: performers shouldnt see payment badge 2026-03-11 11:47:54 +01:00
d25f4aa813 feat: show real attendee count with guests in admin, expandable guest details, fix CSV export 2026-03-11 11:37:51 +01:00
8a6d3035cb fix: small issues 2026-03-11 11:29:11 +01:00
0a849bd9c5 fix: Hide payment badge for performers with no payment obligation
Performers have nothing to pay unless they added a voluntary gift.
Only show PaymentBadge when registrationType is 'watcher' or giftAmount
> 0.
2026-03-11 11:27:19 +01:00
17c6315dad Simplify registration flow: mandatory signup redirect, payment emails, payment reminder cron
- EventRegistrationForm/WatcherForm/PerformerForm: remove session/login nudge/SuccessScreen;
  both roles redirect to /login?signup=1&email=<email>&next=/account after submit
- SuccessScreen.tsx deleted (no longer used)
- account.tsx: add 'Betaal nu' CTA via checkoutMutation (shown when watcher + paymentStatus pending)
- DB schema: add paymentReminderSentAt column to registration table
- Migration: 0008_payment_reminder_sent_at.sql
- email.ts: update registrationConfirmationHtml / sendConfirmationEmail with signupUrl param;
  add sendPaymentReminderEmail and sendPaymentConfirmationEmail functions
- routers/index.ts: wire payment reminder into runSendReminders() — queries watchers
  with paymentStatus pending, paymentReminderSentAt IS NULL, createdAt <= now-3days
- mollie.ts webhook: call sendPaymentConfirmationEmail after marking registration paid
2026-03-11 11:17:24 +01:00
e59a588a9d feat: confetti
Closes #5
2026-03-11 10:45:40 +01:00
dfc8ace186 feat: add password reset flow
- Add /forgot-password and /reset-password routes (Dutch UI)
- Wire sendResetPassword into emailAndPassword config (not a plugin)
- Send styled HTML email via nodemailer with 1-hour expiry
- Add 'Wachtwoord vergeten?' link on login page
- Update routeTree.gen.ts with new routes
2026-03-11 09:43:32 +01:00
439bbc5545 feat(web): set test open date 2026-03-11 09:22:01 +01:00
89043c60a3 feat(web): add Cloudflare Worker server entry with scheduled reminders
Sets up the server handler using TanStack React Start and implements
a scheduled event handler to run reminder tasks via Cloudflare cron triggers.
2026-03-10 16:29:56 +01:00
7eabe88d30 feat: reminder email opt-in 1 hour before registration opens 2026-03-10 15:48:41 +01:00
d180a33d6e feat: hoe inschrijven 2026-03-10 14:31:36 +01:00
cf47f25a4d feat: gate registration behind 16 March 2026 19:00 opening date
- Add REGISTRATION_OPENS_AT const in lib/opening.ts as single source of truth
- Add useRegistrationOpen hook with live 1s countdown ticker
- Add CountdownBanner component (DD/HH/MM/SS) with canvas-confetti on open
- EventRegistrationForm shows countdown instead of form while closed
- Login page hides/disables signup while closed; login always available
- WatcherForm AccountModal guards signUp call as defence-in-depth

Closes #2
2026-03-10 13:18:30 +01:00
2f0dc46469 fix:small ux&content fixes 2026-03-05 16:28:22 +01:00
3f5bb61e35 feat:mollie and footer 2026-03-05 16:08:33 +01:00
835f0941dc fix:improved admin dashboard on mobile 2026-03-04 16:25:46 +01:00
f9d79c3879 feat:UX and fix drinkkaart payment logic 2026-03-04 14:22:42 +01:00
79869a9a21 feat:drinkkaart 2026-03-03 23:52:49 +01:00
87 changed files with 11243 additions and 1726 deletions

View File

@@ -30,12 +30,15 @@
"@tanstack/react-start": "^1.141.1",
"@tanstack/router-plugin": "^1.141.1",
"better-auth": "catalog:",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "catalog:",
"html5-qrcode": "^2.3.8",
"libsql": "catalog:",
"lucide-react": "^0.525.0",
"next-themes": "^0.4.6",
"qrcode": "^1.5.4",
"react": "19.2.3",
"react-dom": "19.2.3",
"shadcn": "^3.6.2",
@@ -49,11 +52,14 @@
},
"devDependencies": {
"@cloudflare/vite-plugin": "^1.17.1",
"@tanstack/router-core": "^1.141.1",
"@kk/config": "workspace:*",
"@tanstack/react-query-devtools": "^5.91.1",
"@tanstack/react-router-devtools": "^1.141.1",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0",
"@types/canvas-confetti": "^1.9.0",
"@types/qrcode": "^1.5.6",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@types/three": "^0.183.1",

View File

@@ -0,0 +1,129 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 1571.7 205.8" style="enable-background:new 0 0 1571.7 205.8;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st1{fill:#41BEE8;}
.st2{fill:#ED1C24;}
.st3{fill:#8DC63F;}
.st4{fill:#F7941D;}
</style>
<g>
<path class="st0" d="M441.9,102.9c0,55.7-45.2,100.9-100.9,100.9c-55.7,0-100.8-45.2-100.8-100.9C240.1,47.1,285.3,2,341,2
C396.7,2,441.9,47.1,441.9,102.9z"/>
<g>
<path class="st1" d="M286.4,178.8l6.6-33.2c0,0,28.1,11.6,36.2-5.8c6.6-14.2,30.7-119.7,34.3-135.3C356.2,2.9,348.7,2,341,2
c-55.7,0-100.9,45.2-100.9,100.9c0,40.6,24,75.6,58.6,91.6C291.1,187,286.4,178.8,286.4,178.8z"/>
<path class="st1" d="M411.6,30.8c-8.1,32.8-27.2,110.6-33,131.5c-3.8,13.8-12.5,32.7-29,41.1c51.7-4.3,92.4-47.7,92.4-100.5
C441.9,74.6,430.3,49.1,411.6,30.8z"/>
</g>
</g>
<g>
<path class="st0" d="M680.9,102.8c0,55.7-45.1,100.9-100.8,100.9c-55.7,0-100.9-45.2-100.9-100.9C479.2,47.2,524.3,2,580,2
C635.7,2,680.9,47.1,680.9,102.8z"/>
<g>
<path class="st2" d="M555.6,168.7c-15-21.9-53.7-71.2-72.3-94.7c-2.8,9.2-4.2,18.9-4.2,28.9c0,55.7,45.1,100.8,100.9,100.8
c0.1,0,0.3,0,0.4,0C575.5,197.2,567.4,185.8,555.6,168.7z"/>
<path class="st2" d="M534.1,55.3c0,0,58,90.7,59.8,93.3c1.7,2.7,6.1,2.8,6.2-3c0.1-5.9,2.7-115.7,2.7-115.7l40.6-5.4
C625.9,10.4,603.9,2,579.9,2c-40.2,0-74.9,23.5-91.1,57.5L534.1,55.3z"/>
<path class="st2" d="M661.1,43c-4.9,22.4-12,55.6-13.7,67c-2.1,13.4-9.2,56.9-12.5,77.4c27.6-18,45.9-49.1,45.9-84.5
C680.8,80.5,673.5,59.8,661.1,43z"/>
</g>
</g>
<g>
<path class="st0" d="M202.9,103c0,55.7-45.2,100.8-100.8,100.8C46.4,203.8,1.2,158.6,1.2,103C1.2,47.3,46.4,2.2,102,2.1
C157.7,2.2,202.9,47.3,202.9,103z"/>
<path class="st3" d="M102.1,2C46.3,2,1.2,47.1,1.2,102.9c0,38.3,21.3,71.6,52.7,88.6L33.9,41c0,0,95.8-24.1,107.6-23.2l7.8,40.7
c0,0-40.5,9.6-61,13c0,0,0.6,18.2,1.9,22.1c9.7-0.5,29.2-4.2,38.7-4.9l5.4,39.1c0,0-35.3,4.2-39,5.4c0.7,4.9,1.9,19.4,2.3,23.6
c13-0.1,47.5-7.8,63.2-9.1l7.2,31.7c21.5-18.5,35.2-45.9,35.2-76.5C203,47.2,157.8,2,102.1,2z"/>
</g>
<g>
<path class="st0" d="M919.8,103c0,55.6-45.2,100.8-100.9,100.8c-55.7,0-100.9-45.2-100.9-100.8C718,47.3,763.2,2.2,818.9,2.1
C874.6,2.1,919.8,47.3,919.8,103z"/>
<g>
<path class="st4" d="M830,127.1c0,21.1,0.8,53.3,1.4,75.7c5-0.6,9.9-1.6,14.6-2.9c1-9.2,6-53.1,6.2-54.5
c0.1-0.9,0.2-10.2,0.3-18.6C845.3,127.4,837.8,127.5,830,127.1z"/>
<path class="st4" d="M801.5,105.2c-0.5-14.9-1.1-33-1.1-33l30.2,1c0,0,0.2,11.2-0.3,36.2c25.8,1.6,55.7-2.2,89.2-15.8
C914.8,42.2,871.6,2,819,2c-42.3,0-78.7,26.1-93.6,63.1C730.6,70.1,757.5,94.8,801.5,105.2z M862.7,60.1
c10.8,0,19.5,8.7,19.5,19.5c0,10.7-8.7,19.5-19.5,19.5c-10.8,0-19.5-8.7-19.5-19.5C843.3,68.8,852,60.1,862.7,60.1z M817,21.2
c10.8,0,19.5,8.7,19.5,19.5c0,10.8-8.7,19.4-19.5,19.4c-10.8,0-19.5-8.7-19.5-19.5C797.5,29.9,806.2,21.2,817,21.2z
M764.4,44.6c10.8,0,19.5,8.7,19.5,19.5c0,10.8-8.7,19.5-19.5,19.5c-10.8,0-19.5-8.7-19.5-19.5
C744.9,53.3,753.6,44.6,764.4,44.6z"/>
<path class="st4" d="M802,124.4c0-0.1,0-0.4,0-0.7c-9.8-2.1-19.7-5-29.6-9.2c0.4,4.4,0.9,9.8,1.4,16.3
c1.1,14.6,5.5,46.1,8.3,65.9c5.2,2,10.6,3.6,16.1,4.8C799.6,174.8,801.9,126.3,802,124.4z"/>
<path class="st4" d="M875.1,124.2c-0.2,6.9-0.6,16.2-1.4,25.4c-0.7,8.5-1.2,24.4-1.5,38.7c26-16.2,44.1-43.9,47.2-76
C910.7,115.8,894.9,120.9,875.1,124.2z"/>
<path class="st4" d="M749.7,134.4c-0.1-1.6-3.8-26.5-5.1-35.5c-8.1-5.7-16-12.5-23.6-20.5c-1.9,7.8-3,16-3,24.4
c0,30,13.1,56.9,33.9,75.4C750.9,158.9,749.7,135.6,749.7,134.4z"/>
</g>
</g>
<g>
<path class="st2" d="M972.5,164.6c0.8,1,1.7,1.9,2.9,2.5c1.2,0.8,2.6,1.2,4.2,1.2c2.1,0,4.2-0.7,6.1-2.3c0.9-0.8,1.7-1.8,2.2-3.1
c0.6-1.2,0.8-2.8,0.8-4.6v-37h8v37.8c-0.1,5.5-1.8,9.8-5.2,12.8c-3.4,3.1-7.3,4.6-11.7,4.7c-6.2-0.1-10.9-2.5-14-7.1L972.5,164.6z
"/>
<path class="st2" d="M1013.6,121.3h33.9v7.8h-25.9v15.7h22.1v7.4h-22.1v16.1h25.9v7.8h-33.9V121.3z"/>
<path class="st2" d="M1060.5,121.3h8v35.4c0,3.7,1.1,6.6,3.1,8.5c2,2.1,4.6,3.1,7.7,3.1c3.1,0,5.7-1,7.8-3.1c2-2,3-4.8,3.1-8.5
v-35.4h8v36.2c-0.1,5.8-1.9,10.3-5.4,13.7c-3.5,3.5-8,5.2-13.4,5.3c-5.3-0.1-9.8-1.8-13.3-5.3c-3.6-3.4-5.5-7.9-5.5-13.7V121.3z"
/>
<path class="st2" d="M1132.2,146h18.9v11.2c-0.1,5.5-1.9,10.1-5.5,13.8c-3.6,3.7-8.1,5.6-13.5,5.7c-4.2-0.1-7.7-1.2-10.4-3.2
c-2.8-1.9-4.9-4.2-6.2-6.7c-0.4-0.8-0.7-1.6-1.1-2.4c-0.3-0.8-0.5-1.8-0.7-2.9c-0.4-2.2-0.5-6.4-0.5-12.7c0-6.4,0.2-10.6,0.5-12.8
c0.4-2.2,1-3.9,1.8-5.2c1.3-2.5,3.3-4.8,6.2-6.8c2.8-2,6.3-3,10.4-3.1c5.1,0.1,9.3,1.6,12.5,4.7c3.2,3.1,5.3,7,6.1,11.5h-8.5
c-0.7-2.2-1.8-4.1-3.6-5.7c-1.8-1.5-4-2.3-6.6-2.3c-1.9,0.1-3.5,0.4-4.9,1.2c-1.4,0.8-2.5,1.7-3.4,2.8c-1,1.2-1.7,2.7-2.1,4.6
c-0.4,2-0.6,5.6-0.6,11c0,5.4,0.2,9.1,0.6,11c0.3,1.9,1,3.5,2.1,4.6c0.9,1.1,2,2,3.4,2.8c1.4,0.8,3,1.2,4.9,1.2
c3.1,0,5.7-1.1,7.8-3.3c2-2.1,3.1-4.9,3.1-8.3v-2.9h-10.8V146z"/>
<path class="st2" d="M1166.9,121.3h18.6c7.2,0.1,12.6,3.1,16.2,9.2c1.3,2.1,2.1,4.2,2.4,6.5c0.3,2.3,0.4,6.2,0.4,11.7
c0,5.9-0.2,10-0.6,12.3c-0.2,1.2-0.5,2.2-0.9,3.2c-0.4,0.9-0.9,1.8-1.5,2.8c-1.6,2.6-3.7,4.7-6.5,6.4c-2.7,1.8-6.1,2.7-10.1,2.7
h-18V121.3z M1174.9,168.3h9.6c4.5,0,7.8-1.6,9.8-4.8c0.9-1.2,1.5-2.7,1.7-4.5c0.2-1.8,0.3-5.1,0.3-10c0-4.8-0.1-8.2-0.3-10.2
c-0.3-2.1-1-3.7-2.1-5c-2.2-3.2-5.4-4.7-9.5-4.6h-9.6V168.3z"/>
<path class="st2" d="M1214.9,121.3h8.5l12,41.8h0.1l12.1-41.8h8.5l-17.5,54.8h-6.3L1214.9,121.3z"/>
<path class="st2" d="M1268.2,121.3h33.9v7.8h-25.9v15.7h22.1v7.4h-22.1v16.1h25.9v7.8h-33.9V121.3z"/>
<path class="st2" d="M1316.1,121.3h20.7c4.4,0,8,1.2,10.8,3.6c3.4,2.7,5.1,6.6,5.3,11.7c-0.1,7.4-3.5,12.5-10.3,15.2l12.3,24.3
h-9.6l-11-23.4h-10.3v23.4h-8V121.3z M1324.1,145.3h12.2c3,0,5.3-1,6.8-2.7c1.5-1.6,2.2-3.6,2.2-6c0-2.9-0.9-4.9-2.7-6.2
c-1.4-1.1-3.3-1.7-5.9-1.7h-12.6V145.3z"/>
<path class="st2" d="M1368,121.3h20.6c5.2,0.1,9.1,1.4,11.8,4.1c2.6,2.7,4,6.2,4,10.6c0,2.3-0.5,4.4-1.6,6.5
c-0.6,1-1.4,1.9-2.4,2.8c-1,0.9-2.3,1.8-3.9,2.5v0.2c2.9,0.8,5.1,2.3,6.6,4.7c1.4,2.4,2.1,5.1,2.1,7.9c0,4.7-1.5,8.5-4.3,11.2
c-2.8,2.9-6.3,4.3-10.5,4.3H1368V121.3z M1376.1,128.6v15.8h11.6c2.9,0,5.1-0.8,6.5-2.4c1.4-1.5,2.1-3.3,2.1-5.5s-0.7-4.1-2.1-5.6
c-1.4-1.4-3.5-2.2-6.5-2.2H1376.1z M1376.1,151.8v16.5h12.4c3-0.1,5.2-0.9,6.6-2.5c1.4-1.6,2.1-3.5,2.1-5.7c0-2.3-0.7-4.2-2.1-5.8
c-1.4-1.6-3.6-2.4-6.6-2.5H1376.1z"/>
<path class="st2" d="M1418.2,148.7c0-6.4,0.2-10.6,0.5-12.8c0.4-2.2,1-3.9,1.8-5.2c1.3-2.5,3.3-4.8,6.2-6.7c2.8-2,6.3-3,10.4-3.1
c4.2,0.1,7.7,1.1,10.6,3.1c2.8,2,4.8,4.2,6,6.8c0.9,1.3,1.5,3.1,1.9,5.2c0.3,2.2,0.5,6.4,0.5,12.8c0,6.3-0.2,10.5-0.5,12.7
c-0.3,2.2-1,4-1.9,5.3c-1.2,2.5-3.3,4.8-6,6.7c-2.8,2-6.3,3.1-10.6,3.2c-4.2-0.1-7.7-1.2-10.4-3.2c-2.8-1.9-4.9-4.2-6.2-6.7
c-0.4-0.8-0.7-1.6-1.1-2.4c-0.3-0.8-0.5-1.8-0.7-2.9C1418.4,159.2,1418.2,155,1418.2,148.7z M1426.2,148.7c0,5.4,0.2,9.1,0.6,11
c0.3,1.9,1,3.5,2.1,4.6c0.9,1.1,2,2,3.4,2.8c1.4,0.8,3,1.2,4.9,1.2c1.9,0,3.6-0.4,5-1.2c1.3-0.7,2.4-1.6,3.2-2.8
c1-1.2,1.8-2.7,2.2-4.6c0.4-1.9,0.5-5.6,0.5-11c0-5.4-0.2-9.1-0.5-11c-0.4-1.9-1.1-3.4-2.2-4.6c-0.8-1.1-1.9-2.1-3.2-2.8
c-1.4-0.7-3.1-1.1-5-1.2c-1.9,0.1-3.5,0.4-4.9,1.2c-1.4,0.8-2.5,1.7-3.4,2.8c-1,1.2-1.7,2.7-2.1,4.6
C1426.4,139.6,1426.2,143.3,1426.2,148.7z"/>
<path class="st2" d="M1472,121.3h7.6l24,39h0.1v-39h8v54.8h-7.6l-24-39h-0.1v39h-8V121.3z"/>
<path class="st2" d="M1529.6,121.3h18.6c7.2,0.1,12.6,3.1,16.2,9.2c1.3,2.1,2.1,4.2,2.4,6.5c0.3,2.3,0.4,6.2,0.4,11.7
c0,5.9-0.2,10-0.6,12.3c-0.2,1.2-0.5,2.2-0.9,3.2c-0.4,0.9-0.9,1.8-1.5,2.8c-1.6,2.6-3.7,4.7-6.5,6.4c-2.7,1.8-6.1,2.7-10.1,2.7
h-18V121.3z M1537.7,168.3h9.6c4.5,0,7.8-1.6,9.8-4.8c0.9-1.2,1.5-2.7,1.7-4.5c0.2-1.8,0.3-5.1,0.3-10c0-4.8-0.1-8.2-0.3-10.2
c-0.3-2.1-1-3.7-2.1-5c-2.2-3.2-5.4-4.7-9.5-4.6h-9.6V168.3z"/>
</g>
<g>
<path class="st2" d="M987.6,32.3h33.9v7.8h-25.9v15.7h22.1v7.4h-22.1v16.1h25.9v7.8h-33.9V32.3z"/>
<path class="st2" d="M1029.9,32.3h8.5l12,41.8h0.1l12.1-41.8h8.5l-17.5,54.8h-6.3L1029.9,32.3z"/>
<path class="st2" d="M1110.4,74.8h-20.1l-4.1,12.3h-8.5l19.3-54.8h6.7l19.3,54.8h-8.5L1110.4,74.8z M1108.1,67.5l-7.7-24h-0.1
l-7.7,24H1108.1z"/>
<path class="st2" d="M1135.2,32.3h7.6l24,39h0.1v-39h8v54.8h-7.6l-24-39h-0.1v39h-8V32.3z"/>
<path class="st2" d="M1210.1,57h18.9v11.2c-0.1,5.5-1.9,10.1-5.5,13.8c-3.6,3.7-8.1,5.6-13.5,5.7c-4.2-0.1-7.7-1.2-10.4-3.2
c-2.8-1.9-4.9-4.2-6.2-6.7c-0.4-0.8-0.7-1.6-1.1-2.4c-0.3-0.8-0.5-1.8-0.7-2.9c-0.4-2.2-0.5-6.4-0.5-12.7c0-6.4,0.2-10.6,0.5-12.8
c0.4-2.2,1-3.9,1.8-5.2c1.3-2.5,3.3-4.8,6.2-6.8c2.8-2,6.3-3,10.4-3.1c5.1,0.1,9.3,1.6,12.5,4.7c3.2,3.1,5.3,7,6.1,11.5h-8.5
c-0.7-2.2-1.8-4.1-3.6-5.7c-1.8-1.5-4-2.3-6.6-2.3c-1.9,0.1-3.5,0.4-4.9,1.2c-1.4,0.8-2.5,1.7-3.4,2.8c-1,1.2-1.7,2.7-2.1,4.6
c-0.4,2-0.6,5.6-0.6,11c0,5.4,0.2,9.1,0.6,11c0.3,1.9,1,3.5,2.1,4.6c0.9,1.1,2,2,3.4,2.8c1.4,0.8,3,1.2,4.9,1.2
c3.1,0,5.7-1.1,7.8-3.3c2-2.1,3.1-4.9,3.1-8.3v-2.9h-10.8V57z"/>
<path class="st2" d="M1244.8,32.3h33.9v7.8h-25.9v15.7h22.1v7.4h-22.1v16.1h25.9v7.8h-33.9V32.3z"/>
<path class="st2" d="M1292.7,32.3h8v47h25.9v7.8h-33.9V32.3z"/>
<path class="st2" d="M1340.5,32.3h8v54.8h-8V32.3z"/>
<path class="st2" d="M1366,73.8c4.2,3.6,9.3,5.4,15.1,5.5c6.9-0.1,10.4-2.6,10.5-7.6c0-4.1-2.3-6.5-6.9-7.4c-2.1-0.3-4.5-0.6-7-1
c-4.6-0.8-8-2.5-10.4-5.2c-2.5-2.8-3.7-6.2-3.7-10.2c0-4.8,1.5-8.7,4.6-11.6c3-2.9,7.2-4.4,12.7-4.4c6.6,0.2,12.3,2,17.2,5.7
l-4.5,6.8c-4-2.7-8.4-4.1-13.2-4.2c-2.5,0-4.5,0.6-6.2,1.9c-1.7,1.3-2.6,3.3-2.7,5.9c0,1.6,0.6,3,1.8,4.3c1.2,1.3,3.1,2.2,5.7,2.7
c1.5,0.3,3.4,0.5,6,0.9c5,0.7,8.7,2.5,11,5.5c2.4,2.9,3.5,6.3,3.5,10.2c-0.3,10.5-6.5,15.8-18.6,16c-7.9,0-14.6-2.5-20.3-7.5
L1366,73.8z"/>
<path class="st2" d="M1450.3,71.8c-1.2,5.1-3.4,9-6.7,11.7c-3.4,2.7-7.4,4.1-12,4.1c-4.2-0.1-7.7-1.2-10.4-3.2
c-2.8-1.9-4.9-4.2-6.2-6.7c-0.4-0.8-0.7-1.6-1.1-2.4c-0.3-0.8-0.5-1.8-0.7-2.9c-0.4-2.2-0.5-6.4-0.5-12.7c0-6.4,0.2-10.6,0.5-12.8
c0.4-2.2,1-3.9,1.8-5.2c1.3-2.5,3.3-4.8,6.2-6.8c2.8-2,6.3-3,10.5-3.1c5.1,0.1,9.3,1.6,12.5,4.7c3.2,3.1,5.3,6.9,6.1,11.5h-8.5
c-0.7-2.2-1.8-4.1-3.6-5.7c-1.8-1.5-4-2.2-6.6-2.3c-1.9,0.1-3.5,0.4-4.9,1.2c-1.4,0.8-2.5,1.7-3.4,2.8c-1,1.2-1.7,2.7-2.1,4.6
c-0.4,2-0.6,5.6-0.6,11c0,5.4,0.2,9.1,0.6,11c0.3,1.9,1,3.5,2.1,4.6c0.9,1.1,2,2,3.4,2.8c1.4,0.8,3,1.2,4.9,1.2
c4.5,0,7.9-2.5,10.1-7.5H1450.3z"/>
<path class="st2" d="M1491.5,63.1h-21.1v24h-8V32.3h8v23.5h21.1V32.3h8v54.8h-8V63.1z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

View File

@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 27.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 283.5 283.5" style="enable-background:new 0 0 283.5 283.5;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFED00;}
.st1{fill:#F6F6F3;}
.st2{fill:#373636;}
</style>
<g id="Laag_1">
</g>
<g id="Layer_1">
<g>
<g>
<polygon class="st0" points="79.8,99.2 108.7,184.2 32.5,184.2 32.5,99.2 "/>
<g>
<polygon class="st1" points="251,184.2 108.7,184.2 79.8,99.2 251,99.2 "/>
<g>
<path class="st2" d="M122,124.7l-5.6,16.3H113l-5.6-16.3h3l4.3,12.4l4.3-12.4H122z"/>
<path class="st2" d="M126.4,123.2h0.4v17.6h-2.8v-17.4L126.4,123.2z"/>
<path class="st2" d="M139.5,141c-0.9,0-1.9-0.3-2.8-1.3c-0.5,0.4-0.9,0.7-1.5,0.9c-0.5,0.2-1.1,0.4-1.9,0.4
c-0.9,0-1.9-0.2-2.7-0.8c-0.8-0.6-1.4-1.6-1.4-3.3c0-1.1,0.3-2,0.9-2.6c0.7-0.6,1.8-0.9,3.5-0.9c0.7,0,1.5,0,2.6,0.1v-0.6
c0-0.9-0.3-1.5-0.7-1.8c-0.5-0.4-1.1-0.5-1.7-0.5c-1.1,0-2.4,0.4-3.4,0.9l-0.7-2.5c1.3-0.6,3-1.1,4.6-1.1
c1.3,0,2.4,0.3,3.3,0.9c0.9,0.7,1.4,1.7,1.4,3.2v4.7c0,0.6,0.2,1,0.5,1.3c0.3,0.3,0.8,0.5,1.3,0.6L139.5,141z M136.1,135.7
c-1.3,0-2.4,0-3.1,0.1s-1.1,0.5-1.1,1.3c0,1,0.7,1.6,1.8,1.6c0.4,0,0.9-0.1,1.5-0.4c0.5-0.3,0.9-0.9,0.9-1.9V135.7z"/>
<path class="st2" d="M152,141c-0.9,0-1.9-0.3-2.8-1.3c-0.5,0.4-0.9,0.7-1.5,0.9c-0.5,0.2-1.1,0.4-1.9,0.4
c-0.9,0-1.9-0.2-2.7-0.8c-0.8-0.6-1.4-1.6-1.4-3.3c0-1.1,0.3-2,0.9-2.6c0.7-0.6,1.8-0.9,3.5-0.9c0.7,0,1.5,0,2.6,0.1v-0.6
c0-0.9-0.3-1.5-0.7-1.8c-0.5-0.4-1.1-0.5-1.7-0.5c-1.1,0-2.4,0.4-3.4,0.9l-0.7-2.5c1.3-0.6,3-1.1,4.6-1.1
c1.3,0,2.4,0.3,3.3,0.9c0.9,0.7,1.4,1.7,1.4,3.2v4.7c0,0.6,0.2,1,0.5,1.3c0.3,0.3,0.8,0.5,1.3,0.6L152,141z M148.6,135.7
c-1.3,0-2.4,0-3.1,0.1c-0.7,0.2-1.1,0.5-1.1,1.3c0,1,0.7,1.6,1.8,1.6c0.4,0,0.9-0.1,1.5-0.4c0.5-0.3,0.9-0.9,0.9-1.9V135.7z"/>
<path class="st2" d="M155,128.1h2.4l0.3,1c1.2-0.8,2.6-1.2,4-1.2c1.2,0,2.4,0.3,3.2,1.1c0.8,0.7,1.4,1.8,1.4,3.4v8.4h-2.8v-7.5
c0-0.9-0.3-1.6-0.9-2c-0.5-0.4-1.2-0.6-2-0.6c-1,0-2,0.3-2.8,0.8v9.4H155V128.1z"/>
<path class="st2" d="M180,140.8h-2.2l-0.5-0.9c-1,0.7-2,1.2-3.4,1.2c-1.8,0-3.2-0.7-4.1-1.8c-0.9-1.1-1.3-2.6-1.3-4.2
c0-2.4,0.7-4.2,1.9-5.3c1.2-1.2,2.9-1.7,4.8-1.7c0.6,0,1.2,0,1.9,0.1v-4.6l2.4-0.2h0.4V140.8z M177.1,130.8
c-0.7-0.2-1.4-0.3-2-0.3c-1.1,0-2,0.3-2.6,1c-0.6,0.7-1,1.6-1,2.9c0,1,0.3,1.9,0.8,2.6c0.5,0.7,1.3,1.2,2.3,1.2
c0.9,0,1.8-0.3,2.6-0.8V130.8z"/>
<path class="st2" d="M192.4,137.5v2.8c-1.1,0.5-2.5,0.8-3.6,0.8c-1.8,0-3.4-0.5-4.6-1.6c-1.2-1.1-1.9-2.7-1.9-4.9
c0-2.1,0.7-3.8,1.8-4.9c1.1-1.1,2.5-1.7,3.9-1.7c0.4,0,1.1,0,1.9,0.3c0.7,0.3,1.6,0.7,2.2,1.6c0.6,0.8,1,2.1,1,3.8v1.9h-7.7
c0.2,1,0.8,1.7,1.4,2.2c0.7,0.4,1.5,0.6,2.3,0.6c1.1,0,2.2-0.3,3.2-0.8L192.4,137.5z M190.4,133.3c-0.1-0.9-0.4-1.6-0.8-2
c-0.4-0.4-1-0.6-1.5-0.6c-0.6,0-1.2,0.2-1.7,0.6c-0.5,0.4-0.9,1.1-1.1,2H190.4z"/>
<path class="st2" d="M202.8,131.3c-0.7-0.4-1.4-0.6-2.1-0.6c-0.9,0-1.8,0.3-2.5,0.9v9.2h-2.8v-12.6h2.4l0.3,1
c0.9-0.7,1.9-1.3,3.2-1.3c0.3,0,0.8,0,1.3,0.1c0.5,0.1,0.9,0.2,1.2,0.4L202.8,131.3z"/>
<path class="st2" d="M214.5,137.5v2.8c-1.1,0.5-2.5,0.8-3.6,0.8c-1.8,0-3.4-0.5-4.6-1.6c-1.2-1.1-1.9-2.7-1.9-4.9
c0-2.1,0.7-3.8,1.8-4.9c1.1-1.1,2.5-1.7,3.9-1.7c0.4,0,1.1,0,1.9,0.3c0.7,0.3,1.6,0.7,2.2,1.6c0.6,0.8,1,2.1,1,3.8v1.9h-7.7
c0.2,1,0.8,1.7,1.4,2.2c0.7,0.4,1.5,0.6,2.3,0.6c1.1,0,2.2-0.3,3.2-0.8L214.5,137.5z M212.6,133.3c-0.1-0.9-0.4-1.6-0.8-2
c-0.4-0.4-1-0.6-1.5-0.6c-0.6,0-1.2,0.2-1.7,0.6c-0.5,0.4-0.9,1.1-1.1,2H212.6z"/>
<path class="st2" d="M217.4,128.1h2.4l0.3,1c1.2-0.8,2.6-1.2,4-1.2c1.2,0,2.4,0.3,3.2,1.1c0.8,0.7,1.4,1.8,1.4,3.4v8.4h-2.8
v-7.5c0-0.9-0.3-1.6-0.9-2c-0.5-0.4-1.2-0.6-2-0.6c-1,0-2,0.3-2.8,0.8v9.4h-2.8V128.1z"/>
</g>
<g>
<path class="st2" d="M79.9,142.5c-2.1-1.6-3.1,0-4.4-0.1c-1.1-0.1-2.1-1.9-3-1.4c-1.6,0.8,0.7,3.7,1.6,4.3c0.9,0.5,2,1,2.3,1.1
c1.3,0.6,1.8,1.5,2,3c0,0.3,0,1.1-0.1,1.4c-0.6,2.5-5,4.8-7.6,3.1c-1.2-0.8-2.3-1.9-2.7-3.8c-0.7-3.3-3.1-5.8-3.9-9
c-0.5-2-0.8-4.1-1.3-6c-0.5-2.1-1.1-4.1-1.5-6c-0.4-1.7-1.2-4.3-1.8-5.9c-2.4-6.3-3.5-5.9-3.5-5.9s0.9,1.8,4.1,17.3
c0.1,0.6,1.3,7.5,2.2,9.7c0.3,0.8,0.8,2.5,1.2,3.3c1,2.2,3.7,5.6,3.9,8.6c0.1,1.8,0.3,3.2,0.3,4.5c0,0.4,0.2,2,0.5,2.7
c0.7,1.9,6,7.8,11.6,7.8v-4.5c-5.6,0-10.9-3.1-11-3.5c-0.1-0.1,0.3-2.3,0.7-3.3c0.7-1.8,2.1-3.5,4.6-3.7
c2.7-0.2,4.4,0.6,5.8,0.6L79.9,142.5z"/>
<path class="st2" d="M55.4,126c-0.2,3.4-5.5,8-7.1,11c-0.8,1.5-1.8,4-2,5.4c-0.7,4.1,0.2,6.6,1.2,9c1.7,4.2-0.2,5.7,1.1,4.9
c1.7-1.4,1.4-4.6,1.3-6.6c-0.1-1.6-0.2-3.5,0-5.4c0.5-3.6,2.7-7.5,4.5-10.2C56.7,130.8,55.9,127.5,55.4,126L55.4,126z"/>
<path class="st2" d="M56.5,135.6c0,0,0.6,2.7-1.8,9.6c-6.2,18.1,5.7,19.8,9,24.1c0,0,1.3-1.8-4.1-7.7c-2-2.2-3.7-6.9-2.2-13.7
C59.7,138,56.5,135.6,56.5,135.6L56.5,135.6z"/>
<path class="st2" d="M44.8,123.7c-0.3-1.1-0.4-2-0.3-2.9c0.3-4.3,4.2-6,5.1-6.6c0,0,1.7-1.2,2-2.4c0,0,1.8,4.5-2.3,7
C47.1,120.2,45.7,121.7,44.8,123.7L44.8,123.7z"/>
<path class="st2" d="M54.5,119.6c0.2,0.4,1.8,2.8-4.7,8.2c-6.5,5.4-4.5,9.3-4.5,9.3s-7-3.9,1.1-10.6c8-6.7,6.4-8.1,6.4-8.1
S53.9,118.4,54.5,119.6L54.5,119.6z"/>
<path class="st2" d="M63,121.7c1.1,0.1,1.9,3.7,4.5,4.5c1.9,0.6,4,0.3,4.4,1.4c-0.9,0.5-0.2,2,0.7,1.8
C73.4,127.3,73.7,119.3,63,121.7z M66.5,123.3c0.1-0.3,0.2,0,0.5-0.2c0.3-0.3,0.6-0.8,1.1-0.9c0.4-0.1,0.8-0.1,1.2,0.1
c0.2,0.1,0.1,0.6-0.1,0.8c-0.4,0.3-1.4-0.2-1.4,0.6c0,1.3,1.7,0,2.6,0C70.9,126.7,65.8,125.9,66.5,123.3z"/>
</g>
</g>
</g>
<g>
<path class="st2" d="M115.2,158.2h-1.5l-2.6-7.6h1.2l2.2,6.4l2.2-6.4h1.2L115.2,158.2z"/>
<path class="st2" d="M124.4,154.7h-4.7c0.1,1.6,1.1,2.6,2.5,2.6c0.7,0,1.4-0.2,2.1-0.5l0,0v1c-0.8,0.4-1.5,0.5-2.2,0.5
c-2.2,0-3.5-1.6-3.5-4c0-2.2,1-3.9,3.3-3.9c1.9,0,2.6,1.5,2.6,3.2V154.7z M119.8,153.7h3.6c0-1.2-0.3-2.2-1.5-2.2
C120.6,151.5,119.9,152.5,119.8,153.7z"/>
<path class="st2" d="M126.9,150.6l0.3,0.9c0.6-0.7,1.2-1,1.9-1c0.2,0,0.4,0,0.6,0.1c0.2,0,0.4,0.1,0.6,0.2l-0.4,1
c-0.3-0.1-0.7-0.2-1-0.2c-0.7,0-1.2,0.4-1.6,1v5.7h-1.1v-7.6H126.9z"/>
<path class="st2" d="M132.7,147.6L132.7,147.6l0.1,3.5c0.5-0.4,1.2-0.7,2-0.7c2.1,0,3.3,1.8,3.3,3.9c0,2.3-1.4,4-3.8,4
c-0.9,0-1.8-0.2-2.5-0.5v-10.1L132.7,147.6z M132.8,152.3v4.7c0.5,0.2,1,0.2,1.5,0.2c1.6,0,2.6-1.3,2.6-3.1
c0-1.6-0.7-2.7-2.3-2.7C133.8,151.5,133.2,151.8,132.8,152.3z"/>
<path class="st2" d="M145.1,154.7h-4.7c0.1,1.6,1.1,2.6,2.5,2.6c0.7,0,1.4-0.2,2.1-0.5l0,0v1c-0.8,0.4-1.5,0.5-2.2,0.5
c-2.2,0-3.5-1.6-3.5-4c0-2.2,1-3.9,3.3-3.9c1.9,0,2.6,1.5,2.6,3.2V154.7z M140.4,153.7h3.6c0-1.2-0.3-2.2-1.5-2.2
C141.3,151.5,140.6,152.5,140.4,153.7z"/>
<path class="st2" d="M152.1,154.7h-4.7c0.1,1.6,1.1,2.6,2.5,2.6c0.7,0,1.4-0.2,2.1-0.5l0,0v1c-0.8,0.4-1.5,0.5-2.2,0.5
c-2.2,0-3.5-1.6-3.5-4c0-2.2,1-3.9,3.3-3.9c1.9,0,2.6,1.5,2.6,3.2V154.7z M147.4,153.7h3.6c0-1.2-0.3-2.2-1.5-2.2
C148.3,151.5,147.6,152.5,147.4,153.7z"/>
<path class="st2" d="M154.9,147.6L154.9,147.6l0.1,10.6h-1.1v-10.5L154.9,147.6z"/>
<path class="st2" d="M162.3,158.2l-0.2-0.7c-0.6,0.5-1.3,0.8-2.1,0.8c-1,0-1.8-0.5-2.4-1.2c-0.6-0.7-0.9-1.7-0.9-2.9
c0-1.1,0.3-2,0.9-2.7s1.6-1.1,2.9-1.1c0.5,0,1,0.1,1.4,0.2v-2.9l1.1-0.1h0.1v10.6H162.3z M161.9,156.5v-4.7
c-0.5-0.2-1-0.3-1.5-0.3c-0.8,0-1.4,0.2-1.9,0.7c-0.5,0.5-0.7,1.2-0.7,2.2c0,0.8,0.2,1.5,0.5,2c0.4,0.5,0.9,0.9,1.8,0.9
C160.9,157.3,161.5,157,161.9,156.5z"/>
<path class="st2" d="M165.4,148.5c0-0.4,0.3-0.8,0.8-0.8c0.4,0,0.8,0.3,0.8,0.8c0,0.4-0.4,0.8-0.8,0.8
C165.8,149.3,165.4,149,165.4,148.5z M166.8,150.6v7.6h-1.1v-7.6H166.8z"/>
<path class="st2" d="M170.1,150.6l0.2,0.7c0.3-0.2,0.7-0.4,1.1-0.6c0.4-0.2,0.8-0.3,1.2-0.3c0.7,0,1.4,0.2,1.9,0.6
c0.5,0.5,0.8,1.2,0.8,2.2v4.9h-1.1v-4.8c0-0.7-0.2-1.2-0.5-1.5c-0.3-0.3-0.7-0.4-1.2-0.4c-0.4,0-0.7,0.1-1.1,0.2
c-0.3,0.1-0.7,0.3-1,0.5v6h-1.1v-7.6H170.1z"/>
<path class="st2" d="M179.1,156.6h1.4c1,0,1.8,0.2,2.3,0.6c0.5,0.4,0.7,0.9,0.7,1.5c0,0.8-0.5,1.6-1.2,2.1
c-0.7,0.5-1.7,0.9-2.7,0.9c-0.9,0-1.6-0.3-2.1-0.7c-0.5-0.4-0.8-1-0.8-1.7c0-0.6,0.3-1.3,0.9-1.9c-0.2-0.1-0.4-0.3-0.5-0.5
c-0.1-0.2-0.2-0.5-0.2-0.7c0-0.4,0.2-0.9,0.5-1.3c-0.2-0.2-0.4-0.5-0.5-0.8c-0.1-0.3-0.2-0.7-0.2-1c0-0.7,0.3-1.4,0.8-1.8
c0.5-0.5,1.2-0.7,2-0.7c0.6,0,1.1,0.1,1.5,0.4c0.3-0.2,0.7-0.4,1-0.6c0.3-0.1,0.7-0.2,0.9-0.2l0.3,1c-0.4,0.1-0.9,0.2-1.4,0.5
c0.3,0.4,0.4,0.9,0.4,1.4c0,0.7-0.3,1.4-0.8,1.9c-0.5,0.5-1.2,0.8-2,0.8c-0.5,0-1-0.1-1.4-0.3c-0.2,0.2-0.3,0.4-0.3,0.6
C178.2,156.3,178.5,156.6,179.1,156.6z M179.5,157.6h-0.9c-0.4,0.5-0.7,1.1-0.7,1.6c0,0.4,0.1,0.8,0.4,1.1s0.8,0.4,1.4,0.4
c0.8,0,1.4-0.2,1.9-0.6c0.5-0.4,0.7-0.8,0.7-1.3c0-0.6-0.3-0.9-0.9-1C181,157.6,180.3,157.6,179.5,157.6z M179.9,151.5
c-0.6,0-1,0.2-1.3,0.5c-0.3,0.3-0.4,0.7-0.4,1.1c0,0.4,0.2,0.8,0.4,1.1c0.3,0.3,0.7,0.5,1.3,0.5c0.6,0,1-0.2,1.3-0.5
c0.3-0.3,0.4-0.7,0.4-1.1s-0.1-0.8-0.4-1.1C180.9,151.7,180.4,151.5,179.9,151.5z"/>
<path class="st2" d="M196.8,158.2h-1.4l-1.7-5.8l-1.7,5.8h-1.4l-2.4-7.6h1.1l2,6.4l1.9-6.4h1l1.9,6.4l2-6.4h1.1L196.8,158.2z"/>
<path class="st2" d="M205.9,154.7h-4.7c0.1,1.6,1.1,2.6,2.5,2.6c0.7,0,1.4-0.2,2.1-0.5l0,0v1c-0.8,0.4-1.5,0.5-2.2,0.5
c-2.2,0-3.5-1.6-3.5-4c0-2.2,1-3.9,3.3-3.9c1.9,0,2.6,1.5,2.6,3.2V154.7z M201.2,153.7h3.6c0-1.2-0.3-2.2-1.5-2.2
C202.1,151.5,201.4,152.5,201.2,153.7z"/>
<path class="st2" d="M208.4,150.6l0.3,0.9c0.6-0.7,1.2-1,1.9-1c0.2,0,0.4,0,0.6,0.1c0.2,0,0.4,0.1,0.6,0.2l-0.4,1
c-0.3-0.1-0.7-0.2-1-0.2c-0.7,0-1.2,0.4-1.6,1v5.7h-1.1v-7.6H208.4z"/>
<path class="st2" d="M218.9,157.4l-0.5,1c-0.4,0-0.8-0.3-1.3-0.6c-0.4-0.3-0.9-0.8-1.3-1.3c-0.4-0.5-0.8-1-1.1-1.3
c-0.1-0.2-0.3-0.4-0.3-0.5c-0.1-0.1-0.1-0.2-0.2-0.3v3.9h-1.1v-10.4l1.1-0.2h0.1v6.6l3-3.6h1.5l-3.1,3.7c0,0.2,0.3,0.5,0.6,0.9
c0.4,0.4,0.8,0.9,1.3,1.3C218,156.9,218.5,157.2,218.9,157.4z"/>
<path class="st2" d="M220.2,150.6v-1.4l1.1-0.2h0.1v1.6h2.1v1.1h-2.1v3.7c0,0.7,0.2,1.2,0.4,1.5c0.2,0.3,0.6,0.4,1,0.4
c0.4,0,0.9-0.2,1.4-0.5l0.3,1c-0.6,0.3-1.3,0.5-1.9,0.5c-0.7,0-1.2-0.2-1.7-0.7c-0.4-0.5-0.7-1.2-0.7-2.3v-3.7h-0.8v-1.1H220.2z"
/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,104 @@
import { Link, useNavigate } from "@tanstack/react-router";
import { LogOut, User } from "lucide-react";
import { authClient } from "@/lib/auth-client";
interface HeaderProps {
userName?: string;
isAdmin?: boolean;
/** When true, the user is not authenticated — show login/signup CTAs */
isGuest?: boolean;
isVisible?: boolean;
/** When true, uses fixed positioning (for homepage scroll behavior) */
isHomepage?: boolean;
}
export function Header({
userName,
isAdmin,
isGuest,
isVisible,
isHomepage,
}: HeaderProps) {
const navigate = useNavigate();
const handleSignOut = async () => {
await authClient.signOut();
navigate({ to: "/" });
};
return (
<header
className={`border-white/10 border-b bg-[#214e51]/95 backdrop-blur-sm transition-transform duration-300 ${
isVisible ? "translate-y-0" : "-translate-y-full"
} ${isHomepage ? "fixed" : "sticky"} top-0 right-0 left-0 z-50`}
>
<div className="mx-auto flex max-w-8xl items-center justify-between px-3 py-2 sm:px-6 sm:py-3">
<nav className="flex items-center gap-3 sm:gap-6">
<Link
to="/"
className="font-['Intro',sans-serif] text-base text-white/80 transition-opacity hover:opacity-100 sm:text-lg"
>
Kunstenkamp
</Link>
{!isGuest && (
<>
<Link
to="/account"
className="text-sm text-white/70 transition-colors hover:text-white"
activeProps={{ className: "text-white font-medium" }}
>
<span className="hidden sm:inline">Mijn Account</span>
<User className="h-4 w-4 sm:hidden" />
</Link>
{isAdmin && (
<Link
to="/admin"
className="text-sm text-white/70 transition-colors hover:text-white"
activeProps={{ className: "text-white font-medium" }}
>
Admin
</Link>
)}
</>
)}
</nav>
{isGuest ? (
<div className="flex items-center gap-2 sm:gap-3">
<Link
to="/login"
className="text-sm text-white/70 transition-colors hover:text-white"
>
<span className="hidden sm:inline">Inloggen</span>
<span className="sm:hidden">Login</span>
</Link>
<span className="hidden text-white/20 sm:inline">·</span>
<Link
to="/login"
search={{ signup: "1" }}
className="rounded bg-white/10 px-2 py-1 text-white/80 text-xs transition-colors hover:bg-white/20 hover:text-white sm:px-3 sm:py-1.5 sm:text-sm"
>
<span className="hidden sm:inline">Account aanmaken</span>
<span className="sm:hidden">Registreer</span>
</Link>
</div>
) : (
<div className="flex items-center gap-2 sm:gap-3">
<span className="max-w-[80px] truncate text-white/60 text-xs sm:max-w-none sm:text-sm">
{userName}
</span>
<button
type="button"
onClick={handleSignOut}
className="flex items-center gap-1 rounded px-1.5 py-1 text-white/50 transition-colors hover:bg-white/10 hover:text-white sm:px-2"
title="Uitloggen"
>
<LogOut className="h-4 w-4" />
<span className="hidden text-xs sm:inline">Uitloggen</span>
</button>
</div>
)}
</div>
</header>
);
}

View File

@@ -0,0 +1,48 @@
"use client";
import { useRouterState } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { authClient } from "@/lib/auth-client";
import { Header } from "./Header";
export function SiteHeader() {
const { data: session } = authClient.useSession();
const routerState = useRouterState();
const pathname = routerState.location.pathname;
const isHomepage = pathname === "/";
const isKamp = pathname === "/kamp";
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
if (isKamp) return;
if (!isHomepage) {
setIsVisible(true);
return;
}
const handleScroll = () => {
setIsVisible(window.scrollY > 50);
};
handleScroll();
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, [isHomepage, isKamp]);
if (isKamp) return null;
if (!session?.user) {
return <Header isGuest isVisible={isVisible} isHomepage={isHomepage} />;
}
const user = session.user as { name: string; role?: string };
return (
<Header
userName={user.name}
isAdmin={user.role === "admin"}
isVisible={isVisible}
isHomepage={isHomepage}
/>
);
}

View File

@@ -0,0 +1,197 @@
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { formatCents } from "@/lib/drinkkaart";
import { orpc } from "@/utils/orpc";
import { ReverseDialog } from "./ReverseDialog";
interface TransactionRow {
id: string;
type: "deduction" | "reversal";
userId: string;
userName: string;
userEmail: string;
adminId: string;
adminName: string;
amountCents: number;
balanceBefore: number;
balanceAfter: number;
note: string | null;
reversedBy: string | null;
reverses: string | null;
createdAt: Date;
}
export function AdminTransactionLog() {
const [page, setPage] = useState(1);
const [reverseTarget, setReverseTarget] = useState<TransactionRow | null>(
null,
);
const { data, isLoading, refetch } = useQuery({
...orpc.drinkkaart.getTransactionLog.queryOptions({
input: { page, pageSize: 50 },
}),
refetchInterval: 30_000,
});
const transactions = (data?.transactions ?? []) as TransactionRow[];
const total = data?.total ?? 0;
const totalPages = Math.ceil(total / 50);
if (isLoading) {
return <p className="text-sm text-white/40">Laden...</p>;
}
if (transactions.length === 0) {
return <p className="text-sm text-white/40">Geen transacties gevonden.</p>;
}
return (
<div>
<div className="mb-3 flex items-center justify-between">
<p className="text-sm text-white/50">{total} transacties</p>
<Button
onClick={() => refetch()}
size="sm"
variant="outline"
className="border-white/20 bg-transparent text-sm text-white hover:bg-white/10"
>
Vernieuwen
</Button>
</div>
<div className="overflow-x-auto rounded-xl border border-white/10">
<table className="w-full text-sm">
<thead>
<tr className="border-white/10 border-b bg-white/5">
<th className="px-4 py-3 text-left font-medium text-white/50">
Tijdstip
</th>
<th className="px-4 py-3 text-left font-medium text-white/50">
Gebruiker
</th>
<th className="px-4 py-3 text-left font-medium text-white/50">
Bedrag
</th>
<th className="px-4 py-3 text-left font-medium text-white/50">
Type
</th>
<th className="px-4 py-3 text-left font-medium text-white/50">
Admin
</th>
<th className="px-4 py-3 text-left font-medium text-white/50">
Opmerking
</th>
<th className="px-4 py-3 text-left font-medium text-white/50">
Actie
</th>
</tr>
</thead>
<tbody>
{transactions.map((t) => {
const date = new Date(t.createdAt).toLocaleString("nl-BE", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
const isReversal = t.type === "reversal";
const canReverse = t.type === "deduction" && !t.reversedBy;
return (
<tr
key={t.id}
className={`border-white/5 border-b ${isReversal ? "bg-green-400/5" : ""}`}
>
<td className="px-4 py-3 text-white/60 tabular-nums">
{date}
</td>
<td className="px-4 py-3">
<p className="font-medium text-white">{t.userName}</p>
<p className="text-white/40 text-xs">{t.userEmail}</p>
</td>
<td className="px-4 py-3">
<span
className={`font-medium ${isReversal ? "text-green-400" : "text-red-400"}`}
>
{isReversal ? "+ " : " "}
{formatCents(t.amountCents)}
</span>
</td>
<td className="px-4 py-3">
{isReversal ? (
<span className="rounded-full border border-green-400/30 bg-green-400/10 px-2 py-0.5 text-green-300 text-xs">
Teruggedraaid
</span>
) : t.reversedBy ? (
<span className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-white/40 text-xs">
Teruggedraaid
</span>
) : (
<span className="rounded-full border border-red-400/20 bg-red-400/10 px-2 py-0.5 text-red-300 text-xs">
Afschrijving
</span>
)}
</td>
<td className="px-4 py-3 text-sm text-white/60">
{t.adminName}
</td>
<td className="px-4 py-3 text-sm text-white/50">
{t.note || "—"}
</td>
<td className="px-4 py-3">
{canReverse && (
<Button
size="sm"
variant="outline"
onClick={() => setReverseTarget(t)}
className="border-orange-400/30 bg-transparent text-orange-300 hover:bg-orange-400/10"
>
Draai terug
</Button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-center gap-3">
<Button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
variant="outline"
size="sm"
className="border-white/20 bg-transparent text-white hover:bg-white/10 disabled:opacity-50"
>
Vorige
</Button>
<span className="text-sm text-white/60">
{page} / {totalPages}
</span>
<Button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
variant="outline"
size="sm"
className="border-white/20 bg-transparent text-white hover:bg-white/10 disabled:opacity-50"
>
Volgende
</Button>
</div>
)}
{reverseTarget && (
<ReverseDialog
transaction={reverseTarget}
onClose={() => setReverseTarget(null)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,191 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { DEDUCTION_PRESETS_CENTS, formatCents } from "@/lib/drinkkaart";
import { orpc } from "@/utils/orpc";
interface DeductionPanelProps {
drinkkaartId: string;
userName: string;
userEmail: string;
balance: number;
onSuccess: (transactionId: string, balanceAfter: number) => void;
onCancel: () => void;
}
export function DeductionPanel({
drinkkaartId,
userName,
balance,
onSuccess,
onCancel,
}: DeductionPanelProps) {
const [selectedCents, setSelectedCents] = useState<number>(200);
const [customCents, setCustomCents] = useState("");
const [useCustom, setUseCustom] = useState(false);
const [note, setNote] = useState("");
const [confirming, setConfirming] = useState(false);
const amountCents = useCustom
? Math.round((Number.parseFloat(customCents || "0") || 0) * 100)
: selectedCents;
const isValid =
Number.isInteger(amountCents) && amountCents >= 1 && amountCents <= balance;
const deductMutation = useMutation(
orpc.drinkkaart.deductBalance.mutationOptions(),
);
const handleConfirm = () => {
if (!isValid) return;
deductMutation.mutate(
{ drinkkaartId, amountCents, note: note || undefined },
{
onSuccess: (data) => {
onSuccess(data.transactionId, data.balanceAfter);
},
},
);
};
if (confirming) {
return (
<div className="rounded-xl border border-red-400/20 bg-red-400/5 p-5">
<p className="mb-2 text-white">
Bevestig afschrijving van{" "}
<strong className="text-red-300">{formatCents(amountCents)}</strong>{" "}
voor <strong>{userName}</strong>
</p>
<p className="mb-4 text-sm text-white/50">
Nieuw saldo: {formatCents(balance - amountCents)}
</p>
{deductMutation.isError && (
<p className="mb-3 text-red-400 text-sm">
{(deductMutation.error as Error)?.message ??
"Fout bij afschrijving"}
</p>
)}
<div className="flex gap-3">
<Button
onClick={handleConfirm}
disabled={deductMutation.isPending}
className="flex-1 bg-red-500 text-white hover:bg-red-600"
>
{deductMutation.isPending ? "Verwerken..." : "Bevestigen"}
</Button>
<Button
onClick={() => {
setConfirming(false);
deductMutation.reset();
}}
variant="outline"
disabled={deductMutation.isPending}
className="border-white/20 bg-transparent text-white hover:bg-white/10"
>
Terug
</Button>
</div>
</div>
);
}
return (
<div className="space-y-4">
{/* Preset amounts */}
<div>
<p className="mb-2 text-sm text-white/60">Bedrag</p>
<div className="grid grid-cols-4 gap-2">
{DEDUCTION_PRESETS_CENTS.map((cents) => (
<button
key={cents}
type="button"
onClick={() => {
setSelectedCents(cents);
setUseCustom(false);
}}
className={`rounded-lg border py-3 font-medium text-sm transition-colors ${
!useCustom && selectedCents === cents
? "border-white bg-white text-[#214e51]"
: "border-white/20 text-white hover:border-white/50"
}`}
>
{formatCents(cents)}
</button>
))}
</div>
</div>
{/* Custom amount */}
<div>
<label
htmlFor="deduct-custom"
className="mb-1 block text-sm text-white/60"
>
Eigen bedrag ()
</label>
<div className="relative">
<span className="absolute top-1/2 left-3 -translate-y-1/2 text-white/50">
</span>
<Input
id="deduct-custom"
type="number"
min="0.01"
step="0.01"
placeholder="Bijv. 2.50"
value={customCents}
onChange={(e) => {
setCustomCents(e.target.value);
setUseCustom(true);
}}
className="border-white/20 bg-white/10 pl-8 text-white placeholder:text-white/30"
/>
</div>
</div>
{/* Note */}
<div>
<label
htmlFor="deduct-note"
className="mb-1 block text-sm text-white/60"
>
Opmerking (optioneel)
</label>
<Input
id="deduct-note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Bijv. 'Wijn'"
maxLength={200}
className="border-white/20 bg-white/10 text-white placeholder:text-white/30"
/>
</div>
{/* Insufficient balance warning */}
{amountCents > balance && (
<p className="text-red-400 text-sm">
Onvoldoende saldo. Huidig saldo: {formatCents(balance)}
</p>
)}
<div className="flex gap-3">
<Button
onClick={() => setConfirming(true)}
disabled={!isValid}
className="flex-1 bg-white font-semibold text-[#214e51] hover:bg-white/90 disabled:opacity-50"
>
Afschrijven {isValid ? formatCents(amountCents) : "—"}
</Button>
<Button
onClick={onCancel}
variant="outline"
className="border-white/20 bg-transparent text-white hover:bg-white/10"
>
Annuleren
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,204 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { formatCents } from "@/lib/drinkkaart";
import { orpc } from "@/utils/orpc";
interface ManualCreditFormProps {
onDone: () => void;
}
export function ManualCreditForm({ onDone }: ManualCreditFormProps) {
const [query, setQuery] = useState("");
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [selectedUserName, setSelectedUserName] = useState("");
const [amountEuros, setAmountEuros] = useState("");
const [reason, setReason] = useState("");
const searchQuery = useQuery({
...orpc.drinkkaart.searchUsers.queryOptions({ input: { query } }),
enabled: query.length >= 2,
});
const getDrinkkaartQuery = useQuery({
...orpc.drinkkaart.getDrinkkaartByUserId.queryOptions({
input: { userId: selectedUserId ?? "" },
}),
enabled: !!selectedUserId,
});
const creditMutation = useMutation(
orpc.drinkkaart.adminCreditBalance.mutationOptions(),
);
const amountCents = Math.round(
(Number.parseFloat(amountEuros || "0") || 0) * 100,
);
const isValid =
selectedUserId &&
getDrinkkaartQuery.data &&
amountCents >= 1 &&
reason.trim().length >= 1;
const handleSubmit = () => {
if (!isValid || !getDrinkkaartQuery.data) return;
creditMutation.mutate(
{
drinkkaartId: getDrinkkaartQuery.data.drinkkaartId,
amountCents,
reason: reason.trim(),
},
{
onSuccess: () => {
toast.success(
`${formatCents(amountCents)} opgeladen voor ${selectedUserName}`,
);
onDone();
},
onError: (err: Error) => {
toast.error(err.message ?? "Fout bij opladen");
},
},
);
};
return (
<div className="space-y-4">
<h3 className="font-['Intro',sans-serif] text-lg text-white">
Handmatig opladen
</h3>
{/* User search */}
<div>
<label
htmlFor="user-search"
className="mb-1 block text-sm text-white/60"
>
Gebruiker zoeken (naam of email)
</label>
<Input
id="user-search"
value={selectedUserId ? selectedUserName : query}
onChange={(e) => {
setQuery(e.target.value);
setSelectedUserId(null);
}}
placeholder="Naam of email..."
className="border-white/20 bg-white/10 text-white placeholder:text-white/30"
/>
{!selectedUserId && query.length >= 2 && searchQuery.data && (
<ul className="mt-1 rounded-lg border border-white/10 bg-[#1a3d40]">
{searchQuery.data.length === 0 ? (
<li className="px-4 py-2 text-sm text-white/40">
Geen gebruikers gevonden
</li>
) : (
searchQuery.data.map((u) => (
<li key={u.id}>
<button
type="button"
className="w-full px-4 py-2 text-left text-sm text-white hover:bg-white/10"
onClick={() => {
setSelectedUserId(u.id);
setSelectedUserName(u.name);
setQuery("");
}}
>
<span className="font-medium">{u.name}</span>
<span className="ml-2 text-white/50">{u.email}</span>
</button>
</li>
))
)}
</ul>
)}
{selectedUserId && getDrinkkaartQuery.data && (
<p className="mt-1 text-sm text-white/50">
Huidig saldo: {formatCents(getDrinkkaartQuery.data.balance)}
{" · "}
<button
type="button"
className="text-white/60 underline"
onClick={() => {
setSelectedUserId(null);
setSelectedUserName("");
}}
>
Wijzigen
</button>
</p>
)}
</div>
{/* Amount */}
<div>
<label
htmlFor="credit-amount"
className="mb-1 block text-sm text-white/60"
>
Bedrag ()
</label>
<div className="relative">
<span className="absolute top-1/2 left-3 -translate-y-1/2 text-white/50">
</span>
<Input
id="credit-amount"
type="number"
min="0.01"
step="0.01"
placeholder="Bijv. 10"
value={amountEuros}
onChange={(e) => setAmountEuros(e.target.value)}
className="border-white/20 bg-white/10 pl-8 text-white placeholder:text-white/30"
/>
</div>
</div>
{/* Reason */}
<div>
<label
htmlFor="credit-reason"
className="mb-1 block text-sm text-white/60"
>
Reden (verplicht)
</label>
<Input
id="credit-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Bijv. 'Cash betaling', 'Gift', 'Terugbetaling'"
maxLength={200}
className="border-white/20 bg-white/10 text-white placeholder:text-white/30"
/>
</div>
{creditMutation.isError && (
<p className="text-red-400 text-sm">
{(creditMutation.error as Error)?.message ?? "Fout bij opladen"}
</p>
)}
<div className="flex gap-3">
<Button
onClick={handleSubmit}
disabled={!isValid || creditMutation.isPending}
className="flex-1 bg-white font-semibold text-[#214e51] hover:bg-white/90 disabled:opacity-50"
>
{creditMutation.isPending
? "Verwerken..."
: `Opladen — ${amountCents >= 1 ? formatCents(amountCents) : "—"}`}
</Button>
<Button
onClick={onDone}
variant="outline"
className="border-white/20 bg-transparent text-white hover:bg-white/10"
>
Annuleren
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,127 @@
import { useQuery } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { orpc } from "@/utils/orpc";
interface QrCodeCanvasProps {
token: string;
}
function QrCodeCanvas({ token }: QrCodeCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (!canvasRef.current || !token) return;
// Dynamic import to avoid SSR issues
import("qrcode").then((QRCode) => {
if (canvasRef.current) {
QRCode.toCanvas(canvasRef.current, token, {
width: 280,
margin: 2,
color: { dark: "#214e51", light: "#ffffff" },
});
}
});
}, [token]);
return <canvas ref={canvasRef} className="rounded-xl" />;
}
function useCountdown(expiresAt: Date | undefined) {
const [secondsLeft, setSecondsLeft] = useState<number>(0);
useEffect(() => {
if (!expiresAt) return;
const tick = () => {
const diff = Math.max(
0,
Math.floor((expiresAt.getTime() - Date.now()) / 1000),
);
setSecondsLeft(diff);
};
tick();
const interval = setInterval(tick, 1000);
return () => clearInterval(interval);
}, [expiresAt]);
return secondsLeft;
}
export function QrCodeDisplay() {
const { data, refetch, isLoading, isError } = useQuery(
orpc.drinkkaart.getMyQrToken.queryOptions(),
);
const expiresAt = data?.expiresAt ? new Date(data.expiresAt) : undefined;
const secondsLeft = useCountdown(expiresAt);
// Auto-refresh 60s before expiry
useEffect(() => {
if (!expiresAt) return;
const refreshAt = expiresAt.getTime() - 60_000;
const delay = refreshAt - Date.now();
if (delay <= 0) {
refetch();
return;
}
const timer = setTimeout(() => refetch(), delay);
return () => clearTimeout(timer);
}, [expiresAt, refetch]);
// Refresh on tab focus if token is about to expire
useEffect(() => {
const onVisibility = () => {
if (document.visibilityState === "visible" && expiresAt) {
if (Date.now() >= expiresAt.getTime() - 10_000) refetch();
}
};
document.addEventListener("visibilitychange", onVisibility);
return () => document.removeEventListener("visibilitychange", onVisibility);
}, [expiresAt, refetch]);
const minutes = Math.floor(secondsLeft / 60);
const seconds = secondsLeft % 60;
const countdownLabel = `${minutes}:${String(seconds).padStart(2, "0")}`;
if (isLoading) {
return (
<div className="flex h-[280px] w-[280px] items-center justify-center rounded-xl bg-white/10">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-white/20 border-t-white" />
</div>
);
}
if (isError || !data?.token) {
return (
<div className="flex h-[280px] w-[280px] flex-col items-center justify-center gap-2 rounded-xl bg-white/10">
<p className="text-sm text-white/60">Kon QR-code niet laden</p>
<button
type="button"
onClick={() => refetch()}
className="text-sm text-white underline"
>
Opnieuw proberen
</button>
</div>
);
}
return (
<div className="flex flex-col items-center gap-3">
<div className="rounded-xl bg-white p-3 shadow-lg">
<QrCodeCanvas token={data.token} />
</div>
<p className="text-sm text-white/60">
{secondsLeft > 0 ? (
<>
Vervalt over{" "}
<span className="text-white/80 tabular-nums">{countdownLabel}</span>
</>
) : (
<span className="text-yellow-400">Verlopen vernieuwen...</span>
)}
</p>
</div>
);
}

View File

@@ -0,0 +1,133 @@
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
interface QrScannerProps {
onScan: (token: string) => void;
onCancel: () => void;
/** When true the scanner takes up more vertical space (mobile full-screen mode) */
fullHeight?: boolean;
}
export function QrScanner({ onScan, onCancel, fullHeight }: QrScannerProps) {
const videoContainerId = "qr-video-container";
const scannerRef = useRef<unknown>(null);
const [hasError, setHasError] = useState(false);
const [manualToken, setManualToken] = useState("");
// Keep a stable ref so the effect never re-runs due to callback identity changes
const onScanRef = useRef(onScan);
useEffect(() => {
onScanRef.current = onScan;
}, [onScan]);
useEffect(() => {
let stopped = false;
import("html5-qrcode")
.then(({ Html5Qrcode }) => {
if (stopped) return;
const scanner = new Html5Qrcode(videoContainerId);
scannerRef.current = scanner;
scanner
.start(
{ facingMode: "environment" },
{ fps: 15, qrbox: { width: 240, height: 240 } },
(decodedText: string) => {
// Stop immediately so we don't fire multiple times
scanner
.stop()
.catch(console.error)
.finally(() => {
if (!stopped) onScanRef.current(decodedText);
});
},
// Per-frame error (QR not found) — suppress
() => {},
)
.catch((err: unknown) => {
if (!stopped) {
console.error("Camera start failed:", err);
setHasError(true);
}
});
})
.catch((err) => {
console.error("Failed to load html5-qrcode:", err);
setHasError(true);
});
return () => {
stopped = true;
if (scannerRef.current) {
(
scannerRef.current as {
stop: () => Promise<void>;
clear: () => Promise<void>;
}
)
.stop()
.catch(() => {})
.finally(() => {
(scannerRef.current as { clear: () => Promise<void> })
?.clear?.()
.catch(() => {});
});
}
};
}, []); // runs once on mount
const handleManualSubmit = () => {
const token = manualToken.trim();
if (token) onScan(token);
};
const containerHeight = fullHeight ? "h-64 sm:h-80" : "h-52";
return (
<div className="space-y-4">
{!hasError ? (
<div
id={videoContainerId}
className={`w-full overflow-hidden rounded-xl bg-black ${containerHeight} [&_#qr-shaded-region]:!border-white/30 [&>video]:h-full [&>video]:w-full [&>video]:object-cover`}
/>
) : (
<div className="rounded-xl border border-white/10 bg-white/5 p-6 text-center">
<p className="text-sm text-white/60">
Camera niet beschikbaar. Plak het QR-token hieronder.
</p>
</div>
)}
{/* Manual fallback */}
<div>
<p className="mb-2 text-white/40 text-xs">Of plak token handmatig:</p>
<div className="flex gap-2">
<Input
value={manualToken}
onChange={(e) => setManualToken(e.target.value)}
placeholder="QR token..."
className="border-white/20 bg-white/10 text-white placeholder:text-white/30"
onKeyDown={(e) => e.key === "Enter" && handleManualSubmit()}
/>
<Button
onClick={handleManualSubmit}
disabled={!manualToken.trim()}
className="bg-white text-[#214e51] hover:bg-white/90"
>
OK
</Button>
</div>
</div>
<Button
onClick={onCancel}
variant="outline"
className="w-full border-white/20 bg-transparent text-white hover:bg-white/10"
>
Annuleren
</Button>
</div>
);
}

View File

@@ -0,0 +1,99 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { formatCents } from "@/lib/drinkkaart";
import { orpc } from "@/utils/orpc";
interface TransactionRow {
id: string;
amountCents: number;
userName: string;
}
interface ReverseDialogProps {
transaction: TransactionRow;
onClose: () => void;
}
export function ReverseDialog({ transaction, onClose }: ReverseDialogProps) {
const queryClient = useQueryClient();
const reverseMutation = useMutation(
orpc.drinkkaart.reverseTransaction.mutationOptions(),
);
const handleConfirm = () => {
reverseMutation.mutate(
{ transactionId: transaction.id },
{
onSuccess: () => {
toast.success(
`${formatCents(transaction.amountCents)} teruggestort aan ${transaction.userName}`,
);
queryClient.invalidateQueries({
queryKey: orpc.drinkkaart.getTransactionLog.key(),
});
onClose();
},
onError: (err: Error) => {
toast.error(err.message ?? "Fout bij terugdraaien");
},
},
);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<button
type="button"
className="absolute inset-0 bg-black/60"
aria-label="Sluiten"
onClick={onClose}
/>
<div className="relative z-10 w-full max-w-sm rounded-2xl bg-[#1a3d40] p-6">
<h3 className="mb-3 font-['Intro',sans-serif] text-white text-xl">
Transactie terugdraaien
</h3>
<p className="mb-1 text-white/80">
Weet je zeker dat je{" "}
<strong className="text-white">
{formatCents(transaction.amountCents)}
</strong>{" "}
wil terugdraaien voor{" "}
<strong className="text-white">{transaction.userName}</strong>?
</p>
<p className="mb-5 text-sm text-white/50">
Dit voegt {formatCents(transaction.amountCents)} terug toe aan het
saldo.
</p>
{reverseMutation.isError && (
<p className="mb-3 text-red-400 text-sm">
{(reverseMutation.error as Error)?.message ??
"Fout bij terugdraaien"}
</p>
)}
<div className="flex gap-3">
<Button
onClick={handleConfirm}
disabled={reverseMutation.isPending}
className="flex-1 bg-white font-semibold text-[#214e51] hover:bg-white/90"
>
{reverseMutation.isPending
? "Verwerken..."
: "Terugdraaien bevestigen"}
</Button>
<Button
onClick={onClose}
variant="outline"
disabled={reverseMutation.isPending}
className="border-white/20 bg-transparent text-white hover:bg-white/10"
>
Annuleren
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { formatCents } from "@/lib/drinkkaart";
interface ScanResultCardProps {
userName: string;
userEmail: string;
balance: number;
}
export function ScanResultCard({
userName,
userEmail,
balance,
}: ScanResultCardProps) {
return (
<div className="rounded-xl border border-white/10 bg-white/5 p-5">
<div className="mb-4 flex items-start justify-between">
<div>
<p className="font-semibold text-lg text-white">{userName}</p>
<p className="text-sm text-white/50">{userEmail}</p>
</div>
<div className="text-right">
<p className="text-white/40 text-xs">Saldo</p>
<p className="font-['Intro',sans-serif] text-2xl text-white">
{formatCents(balance)}
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { formatCents } from "@/lib/drinkkaart";
interface TopupRow {
id: string;
type: "payment" | "admin_credit";
amountCents: number;
balanceBefore: number;
balanceAfter: number;
reason: string | null;
paidAt: Date;
}
interface TopUpHistoryProps {
topups: TopupRow[];
}
export function TopUpHistory({ topups }: TopUpHistoryProps) {
if (topups.length === 0) {
return <p className="text-sm text-white/40">Nog geen opladingen.</p>;
}
return (
<ul className="space-y-2">
{topups.map((t) => {
const date = new Date(t.paidAt).toLocaleDateString("nl-BE", {
day: "numeric",
month: "short",
year: "numeric",
});
return (
<li
key={t.id}
className="flex items-start justify-between rounded-lg border border-white/10 bg-white/5 px-4 py-3"
>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-green-400">
+ {formatCents(t.amountCents)}
</span>
{t.type === "admin_credit" && (
<span className="rounded-full border border-yellow-400/30 bg-yellow-400/10 px-2 py-0.5 text-xs text-yellow-300">
Admin credit
</span>
)}
</div>
{t.reason && (
<p className="mt-0.5 text-white/50 text-xs">{t.reason}</p>
)}
</div>
<span className="text-sm text-white/50 tabular-nums">{date}</span>
</li>
);
})}
</ul>
);
}

View File

@@ -0,0 +1,144 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
formatCents,
TOPUP_MAX_CENTS,
TOPUP_MIN_CENTS,
TOPUP_PRESETS_CENTS,
} from "@/lib/drinkkaart";
import { orpc } from "@/utils/orpc";
interface TopUpModalProps {
onClose: () => void;
}
export function TopUpModal({ onClose }: TopUpModalProps) {
const [selectedCents, setSelectedCents] = useState<number>(1000);
const [customEuros, setCustomEuros] = useState("");
const [useCustom, setUseCustom] = useState(false);
const checkoutMutation = useMutation({
...orpc.drinkkaart.getTopUpCheckoutUrl.mutationOptions(),
onSuccess: (data: { checkoutUrl: string }) => {
window.location.href = data.checkoutUrl;
},
onError: (err: Error) => {
console.error("Checkout error:", err);
},
});
const amountCents = useCustom
? Math.round((Number.parseFloat(customEuros || "0") || 0) * 100)
: selectedCents;
const isValidAmount =
Number.isInteger(amountCents) &&
amountCents >= TOPUP_MIN_CENTS &&
amountCents <= TOPUP_MAX_CENTS;
const handleSubmit = () => {
if (!isValidAmount) return;
checkoutMutation.mutate({ amountCents });
};
return (
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center">
<button
type="button"
className="absolute inset-0 bg-black/60"
aria-label="Sluiten"
onClick={onClose}
/>
<div className="relative z-10 w-full max-w-sm rounded-t-2xl bg-[#1a3d40] p-6 sm:rounded-2xl">
<div className="mb-5 flex items-center justify-between">
<h2 className="font-['Intro',sans-serif] text-white text-xl">
Drinkkaart opladen
</h2>
<button
type="button"
onClick={onClose}
className="text-white/50 hover:text-white"
aria-label="Sluiten"
>
</button>
</div>
{/* Preset amounts */}
<div className="mb-4 grid grid-cols-4 gap-2">
{TOPUP_PRESETS_CENTS.map((cents) => (
<button
key={cents}
type="button"
onClick={() => {
setSelectedCents(cents);
setUseCustom(false);
}}
className={`rounded-lg border py-3 font-medium text-sm transition-colors ${
!useCustom && selectedCents === cents
? "border-white bg-white text-[#214e51]"
: "border-white/20 text-white hover:border-white/50"
}`}
>
{formatCents(cents)}
</button>
))}
</div>
{/* Custom amount */}
<div className="mb-5">
<label
htmlFor="custom-amount"
className="mb-2 block text-sm text-white/60"
>
Eigen bedrag ( 1 500)
</label>
<div className="relative">
<span className="absolute top-1/2 left-3 -translate-y-1/2 text-white/50">
</span>
<Input
id="custom-amount"
type="number"
min="1"
max="500"
step="0.50"
placeholder="Bijv. 15"
value={customEuros}
onChange={(e) => {
setCustomEuros(e.target.value);
setUseCustom(true);
}}
className="border-white/20 bg-white/10 pl-8 text-white placeholder:text-white/30"
/>
</div>
{useCustom && !isValidAmount && customEuros !== "" && (
<p className="mt-1 text-red-400 text-sm">
Bedrag moet tussen {formatCents(TOPUP_MIN_CENTS)} en{" "}
{formatCents(TOPUP_MAX_CENTS)} liggen
</p>
)}
</div>
<div className="mb-3 rounded-lg bg-white/5 px-4 py-3">
<span className="text-sm text-white/60">Te betalen: </span>
<span className="font-semibold text-white">
{isValidAmount ? formatCents(amountCents) : "—"}
</span>
</div>
<Button
onClick={handleSubmit}
disabled={!isValidAmount || checkoutMutation.isPending}
className="w-full bg-white font-semibold text-[#214e51] hover:bg-white/90 disabled:opacity-50"
>
{checkoutMutation.isPending
? "Doorsturen naar betaling..."
: "Naar betaling"}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { formatCents } from "@/lib/drinkkaart";
interface TransactionRow {
id: string;
type: "deduction" | "reversal";
amountCents: number;
balanceBefore: number;
balanceAfter: number;
note: string | null;
reversedBy: string | null;
reverses: string | null;
createdAt: Date;
}
interface TransactionHistoryProps {
transactions: TransactionRow[];
}
export function TransactionHistory({ transactions }: TransactionHistoryProps) {
if (transactions.length === 0) {
return <p className="text-sm text-white/40">Nog geen transacties.</p>;
}
return (
<ul className="space-y-2">
{transactions.map((t) => {
const date = new Date(t.createdAt).toLocaleString("nl-BE", {
day: "numeric",
month: "short",
hour: "2-digit",
minute: "2-digit",
});
const isReversal = t.type === "reversal";
const isReversed = !!t.reversedBy;
return (
<li
key={t.id}
className={`flex items-start justify-between rounded-lg border px-4 py-3 ${
isReversal
? "border-green-400/20 bg-green-400/5"
: isReversed
? "border-white/5 bg-white/3 opacity-60"
: "border-white/10 bg-white/5"
}`}
>
<div>
<div className="flex items-center gap-2">
<span
className={`font-medium ${isReversal ? "text-green-400" : "text-red-400"}`}
>
{isReversal ? "+ " : " "}
{formatCents(t.amountCents)}
</span>
{isReversal && (
<span className="rounded-full border border-green-400/30 bg-green-400/10 px-2 py-0.5 text-green-300 text-xs">
Teruggedraaid
</span>
)}
{isReversed && !isReversal && (
<span className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-white/40 text-xs">
Teruggedraaid
</span>
)}
</div>
{t.note && (
<p className="mt-0.5 text-white/50 text-xs">{t.note}</p>
)}
</div>
<span className="text-sm text-white/50 tabular-nums">{date}</span>
</li>
);
})}
</ul>
);
}

View File

@@ -0,0 +1,260 @@
import { useState } from "react";
import { REGISTRATION_OPENS_AT } from "@/lib/opening";
import { useRegistrationOpen } from "@/lib/useRegistrationOpen";
import { client } from "@/utils/orpc";
function pad(n: number): string {
return String(n).padStart(2, "0");
}
interface UnitBoxProps {
value: string;
label: string;
}
function UnitBox({ value, label }: UnitBoxProps) {
return (
<div className="flex flex-col items-center gap-1">
<div className="flex w-14 items-center justify-center rounded-sm bg-white/10 px-2 py-2 font-['Intro',sans-serif] text-3xl text-white tabular-nums sm:w-auto sm:px-4 sm:py-3 sm:text-5xl md:text-6xl lg:text-7xl">
{value}
</div>
<span className="font-['Intro',sans-serif] text-[9px] text-white/50 uppercase tracking-widest sm:text-xs">
{label}
</span>
</div>
);
}
// Reminder opt-in form — sits at the very bottom of the banner
function ReminderForm() {
const [email, setEmail] = useState("");
const [status, setStatus] = useState<
"idle" | "loading" | "subscribed" | "already_subscribed" | "error"
>("idle");
const [errorMessage, setErrorMessage] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!email || status === "loading") return;
setStatus("loading");
try {
const result = await client.subscribeReminder({ email });
if (!result.ok && result.reason === "already_open") {
setStatus("idle");
return;
}
setStatus("subscribed");
} catch (err) {
setErrorMessage(err instanceof Error ? err.message : "Er ging iets mis.");
setStatus("error");
}
}
const reminderTime = REGISTRATION_OPENS_AT.toLocaleTimeString("nl-BE", {
hour: "2-digit",
minute: "2-digit",
});
return (
<div
className="w-full"
style={{
animation: "reminderFadeIn 0.6s ease both",
animationDelay: "0.3s",
}}
>
<style>{`
@keyframes reminderFadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes reminderCheck {
from { opacity: 0; transform: scale(0.7); }
to { opacity: 1; transform: scale(1); }
}
`}</style>
{/* Hairline divider */}
<div
className="mx-auto mb-6 h-px w-24"
style={{
background:
"linear-gradient(to right, transparent, rgba(255,255,255,0.15), transparent)",
}}
/>
{status === "subscribed" || status === "already_subscribed" ? (
<div
className="flex flex-col items-center gap-2"
style={{
animation: "reminderCheck 0.4s cubic-bezier(0.34,1.56,0.64,1) both",
}}
>
{/* Checkmark glyph */}
<div
className="flex h-8 w-8 items-center justify-center rounded-full"
style={{
background: "rgba(255,255,255,0.12)",
border: "1px solid rgba(255,255,255,0.2)",
}}
>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
aria-label="Vinkje"
role="img"
>
<path
d="M2.5 7L5.5 10L11.5 4"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<p
className="font-['Intro',sans-serif] text-sm tracking-wide"
style={{ color: "rgba(255,255,255,0.65)" }}
>
Herinnering ingepland voor {reminderTime}
</p>
</div>
) : (
<div className="flex flex-col items-center gap-3">
<p
className="font-['Intro',sans-serif] text-xs uppercase tracking-widest"
style={{ color: "rgba(255,255,255,0.35)", letterSpacing: "0.14em" }}
>
Herinnering ontvangen?
</p>
{/* Fused pill input + button */}
<form
onSubmit={handleSubmit}
className="flex w-full max-w-xs overflow-hidden"
style={{
borderRadius: "3px",
border: "1px solid rgba(255,255,255,0.18)",
background: "rgba(255,255,255,0.07)",
backdropFilter: "blur(6px)",
}}
>
<input
type="email"
required
placeholder="jouw@email.be"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={status === "loading"}
className="min-w-0 flex-1 bg-transparent px-4 py-2.5 text-sm text-white outline-none disabled:opacity-50"
style={{
fontFamily: "'Intro', sans-serif",
fontSize: "0.8rem",
letterSpacing: "0.02em",
}}
/>
{/* Vertical separator */}
<div
style={{
width: "1px",
background: "rgba(255,255,255,0.15)",
flexShrink: 0,
}}
/>
<button
type="submit"
disabled={status === "loading" || !email}
className="shrink-0 px-4 py-2.5 font-semibold text-xs transition-all disabled:opacity-40"
style={{
fontFamily: "'Intro', sans-serif",
letterSpacing: "0.06em",
color:
status === "loading"
? "rgba(255,255,255,0.5)"
: "rgba(255,255,255,0.9)",
background: "transparent",
cursor:
status === "loading" || !email ? "not-allowed" : "pointer",
whiteSpace: "nowrap",
}}
>
{status === "loading" ? "…" : "Stuur mij"}
</button>
</form>
{status === "error" && (
<p
className="font-['Intro',sans-serif] text-xs"
style={{ color: "rgba(255,140,140,0.8)" }}
>
{errorMessage}
</p>
)}
</div>
)}
</div>
);
}
/**
* Shown in place of the registration form while registration is not yet open.
*/
export function CountdownBanner() {
const { isOpen, days, hours, minutes, seconds } = useRegistrationOpen();
// Once open the parent component will unmount this — but render nothing just in case
if (isOpen) return null;
const openDate = REGISTRATION_OPENS_AT.toLocaleDateString("nl-BE", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
});
const openTime = REGISTRATION_OPENS_AT.toLocaleTimeString("nl-BE", {
hour: "2-digit",
minute: "2-digit",
});
return (
<div className="flex flex-col items-center gap-6 py-4 text-center sm:gap-8">
<div>
<p className="font-['Intro',sans-serif] text-base text-white/70 sm:text-lg md:text-xl">
Inschrijvingen openen op
</p>
<p className="mt-1 font-['Intro',sans-serif] text-lg text-white capitalize sm:text-xl md:text-2xl">
{openDate} om {openTime}
</p>
</div>
{/* Countdown — single row forced via grid, scales down on small screens */}
<div className="grid grid-cols-[auto_auto_auto_auto_auto_auto_auto] items-center gap-1.5 sm:flex sm:gap-4 md:gap-6">
<UnitBox value={String(days)} label="dagen" />
<span className="mb-6 font-['Intro',sans-serif] text-2xl text-white/40 sm:text-4xl">
:
</span>
<UnitBox value={pad(hours)} label="uren" />
<span className="mb-6 font-['Intro',sans-serif] text-2xl text-white/40 sm:text-4xl">
:
</span>
<UnitBox value={pad(minutes)} label="minuten" />
<span className="mb-6 font-['Intro',sans-serif] text-2xl text-white/40 sm:text-4xl">
:
</span>
<UnitBox value={pad(seconds)} label="seconden" />
</div>
<p className="max-w-md px-4 text-sm text-white/50">
Kom snel terug! Zodra de inschrijvingen openen kun je je hier
registreren als toeschouwer of als artiest.
</p>
{/* Email reminder opt-in — always last */}
<ReminderForm />
</div>
);
}

View File

@@ -1,26 +1,78 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import confetti from "canvas-confetti";
import { useEffect, useRef, useState } from "react";
import { CountdownBanner } from "@/components/homepage/CountdownBanner";
import { PerformerForm } from "@/components/registration/PerformerForm";
import { SuccessScreen } from "@/components/registration/SuccessScreen";
import { TypeSelector } from "@/components/registration/TypeSelector";
import { WatcherForm } from "@/components/registration/WatcherForm";
import { useRegistrationOpen } from "@/lib/useRegistrationOpen";
import { orpc } from "@/utils/orpc";
function fireConfetti() {
const colors = ["#d82560", "#52979b", "#d09035", "#214e51", "#ffffff"];
confetti({
particleCount: 120,
spread: 80,
origin: { x: 0.3, y: 0.6 },
colors,
});
setTimeout(() => {
confetti({
particleCount: 120,
spread: 80,
origin: { x: 0.7, y: 0.6 },
colors,
});
}, 200);
setTimeout(() => {
confetti({
particleCount: 80,
spread: 100,
origin: { x: 0.5, y: 0.5 },
colors,
});
}, 400);
}
type RegistrationType = "performer" | "watcher";
function redirectToSignup(email: string) {
const params = new URLSearchParams({
signup: "1",
email,
next: "/account",
});
window.location.href = `/login?${params.toString()}`;
}
export default function EventRegistrationForm() {
const { isOpen } = useRegistrationOpen();
const confettiFired = useRef(false);
const { data: capacity } = useQuery(orpc.getWatcherCapacity.queryOptions());
useEffect(() => {
if (isOpen && !confettiFired.current) {
confettiFired.current = true;
fireConfetti();
}
}, [isOpen]);
const [selectedType, setSelectedType] = useState<RegistrationType | null>(
null,
);
const [successToken, setSuccessToken] = useState<string | null>(null);
if (successToken) {
if (!isOpen) {
return (
<SuccessScreen
token={successToken}
onReset={() => {
setSuccessToken(null);
setSelectedType(null);
}}
/>
<section
id="registration"
className="relative z-30 w-full bg-[#214e51]/96 px-6 py-16 md:px-12"
>
<div className="mx-auto w-full max-w-6xl">
<CountdownBanner />
</div>
</section>
);
}
@@ -33,21 +85,31 @@ export default function EventRegistrationForm() {
<h2 className="mb-2 font-['Intro',sans-serif] text-3xl text-white md:text-4xl">
Schrijf je nu in!
</h2>
<p className="mb-12 max-w-3xl text-lg text-white/80 md:text-xl">
<p className="mb-2 max-w-3xl text-lg text-white/80 md:text-xl">
De Kunstenkamp jaarwerking organiseert een Open Mic
</p>
<p className="mb-8 max-w-3xl text-lg text-white/80 md:text-xl">
Doe je mee of kom je kijken? Kies je rol en vul het formulier in.
</p>
{!selectedType && <TypeSelector onSelect={setSelectedType} />}
{!selectedType && (
<TypeSelector
onSelect={setSelectedType}
watcherIsFull={capacity?.isFull ?? false}
watcherAvailable={capacity?.available ?? null}
/>
)}
{selectedType === "performer" && (
<PerformerForm
onBack={() => setSelectedType(null)}
onSuccess={setSuccessToken}
onSuccess={(_token, email, _name) => redirectToSignup(email)}
/>
)}
{selectedType === "watcher" && (
<WatcherForm onBack={() => setSelectedType(null)} />
<WatcherForm
onBack={() => setSelectedType(null)}
onSuccess={(_token, email, _name) => redirectToSignup(email)}
/>
)}
</div>
</section>

View File

@@ -17,63 +17,161 @@ export default function Footer() {
}, []);
return (
<footer className="relative z-40 flex h-[250px] flex-col items-center justify-center bg-[#d09035]">
<div className="text-center">
<h3 className="mb-4 flex items-center justify-center gap-2 font-['Intro',sans-serif] text-2xl text-white">
<img
src="/favicon.png"
alt=""
className="h-8 w-8 rounded bg-[#0B1C1F]"
/>
Kunstenkamp
</h3>
<p className="mb-6 font-['Intro',sans-serif] text-white/80">
Waar creativiteit tot leven komt
</p>
<footer className="relative z-40 overflow-hidden bg-[#d09035]">
{/* Magenta top accent line */}
<div className="h-1 w-full bg-[#d82560]" />
<div className="flex items-center justify-center gap-8 text-sm text-white/70">
{/* Diagonal texture overlay */}
<div
className="pointer-events-none absolute inset-0 opacity-[0.04]"
style={{
backgroundImage: `repeating-linear-gradient(
-45deg,
#000 0px,
#000 1px,
transparent 1px,
transparent 12px
)`,
}}
/>
<div className="relative mx-auto max-w-5xl px-6 py-12">
{/* Main brand row */}
<div className="mb-10 flex flex-col items-center gap-1">
<a
href="/privacy"
className="link-hover transition-colors hover:text-white"
href="https://ejv.be/jong/kampen/kunstenkamp/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 opacity-90 transition-opacity hover:opacity-100"
>
Privacy Beleid
<img src="/favicon.png" alt="" className="h-10 w-10 bg-[#0B1C1F]" />
<span
className="font-['Intro',sans-serif] text-3xl text-white tracking-wide"
style={{ textShadow: "0 2px 8px rgba(0,0,0,0.18)" }}
>
Kunstenkamp
</span>
</a>
<span className="text-white/40">|</span>
<a
href="/terms"
className="link-hover transition-colors hover:text-white"
>
Algemene Voorwaarden
</a>
<span className="text-white/40">|</span>
<a
href="/contact"
className="link-hover transition-colors hover:text-white"
>
Contact
</a>
{!isLoading && isAdmin && (
<>
<span className="text-white/40">|</span>
<Link
to="/admin"
className="link-hover transition-colors hover:text-white"
>
Admin
</Link>
</>
)}
<p className="font-['DM_Sans',sans-serif] font-medium text-sm text-white/70 uppercase tracking-[0.2em]">
Waar creativiteit tot leven komt
</p>
</div>
<div className="mt-6 text-white/50 text-xs">
© {new Date().getFullYear()} Kunstenkamp. Alle rechten voorbehouden.
{/* Thin divider */}
<div className="mb-10 flex items-center gap-4">
<div className="h-px flex-1 bg-white/20" />
<div className="h-1 w-1 rotate-45 bg-white/40" />
<div className="h-px flex-1 bg-white/20" />
</div>
<div className="text-white/50 text-xs transition-colors hover:text-white">
{/* Partners row */}
<div className="mb-10 flex flex-col items-center justify-center gap-10 md:flex-row md:gap-16">
{/* EJV */}
<a
href="https://ejv.be"
target="_blank"
rel="noopener noreferrer"
className="flex flex-col items-center gap-3 opacity-90 transition-opacity hover:opacity-100"
>
<img
src="/assets/ejv.svg"
alt="Evangelisch Jeugdverbond"
className="h-14 w-auto"
/>
<p className="font-['DM_Sans',sans-serif] font-medium text-white/60 text-xs uppercase tracking-[0.15em]">
Onderdeel van EJV.be
</p>
</a>
{/* Vertical rule */}
<div className="hidden h-28 w-px bg-white/20 md:block" />
{/* Ichtus Antwerpen */}
<a
href="https://ichtusantwerpen.com"
target="_blank"
rel="noopener noreferrer"
className="flex flex-col items-center gap-3 opacity-90 transition-opacity hover:opacity-100"
>
<img
src="/assets/ichtusantwerpen.png"
alt="Ichtus Antwerpen"
className="h-14 w-auto"
/>
<p className="text-center font-['DM_Sans',sans-serif] font-medium text-white/60 text-xs uppercase tracking-[0.15em]">
Ichtus Antwerpen
</p>
</a>
{/* Vertical rule */}
<div className="hidden h-28 w-px bg-white/20 md:block" />
{/* Vlaanderen */}
<a
href="https://www.vlaanderen.be/cjm/nl"
target="_blank"
rel="noopener noreferrer"
className="flex flex-col items-center gap-3 opacity-90 transition-opacity hover:opacity-100"
>
<img
src="/assets/vlaanderen.svg"
alt="Met steun van de Vlaamse overheid"
className="h-40 w-auto drop-shadow-md"
/>
</a>
</div>
{/* Thin divider */}
<div className="mb-6 flex items-center gap-4">
<div className="h-px flex-1 bg-white/20" />
<div className="h-1 w-1 rotate-45 bg-white/40" />
<div className="h-px flex-1 bg-white/20" />
</div>
{/* Legal links */}
<div className="flex flex-col items-center gap-4 md:flex-row md:justify-center md:gap-0">
<div className="flex flex-wrap items-center justify-center gap-x-6 gap-y-2">
{[
{ href: "/privacy", label: "Privacy Beleid" },
{ href: "/terms", label: "Algemene Voorwaarden" },
{ href: "/contact", label: "Contact" },
].map((link, i, arr) => (
<span key={link.href} className="flex items-center gap-6">
<a
href={link.href}
className="link-hover font-['DM_Sans',sans-serif] font-medium text-white/60 text-xs uppercase tracking-[0.15em]"
>
{link.label}
</a>
{i < arr.length - 1 && (
<span className="hidden text-white/30 md:inline">·</span>
)}
</span>
))}
{!isLoading && isAdmin && (
<span className="flex items-center gap-6">
<span className="hidden text-white/30 md:inline">·</span>
<Link
to="/admin"
className="link-hover font-['DM_Sans',sans-serif] font-medium text-white/60 text-xs uppercase tracking-[0.15em]"
>
Admin
</Link>
</span>
)}
</div>
</div>
{/* Copyright */}
<div className="mt-6 flex flex-col items-center gap-1">
<p className="font-['DM_Sans',sans-serif] text-white/60 text-xs">
© {new Date().getFullYear()} Kunstenkamp. Alle rechten voorbehouden.
</p>
<a
href="https://zias.be"
target="_blank"
rel="noopener noreferrer"
className="link-hover"
className="link-hover font-['DM_Sans',sans-serif] text-white/60 text-xs"
>
Gemaakt met door zias.be
</a>

View File

@@ -61,7 +61,7 @@ export default function Hero() {
type="button"
className="link-hover mt-4 cursor-pointer text-left font-['Intro',sans-serif] font-normal text-[clamp(1.25rem,2.5vw,2.625rem)] text-white/70 transition-colors duration-200 hover:text-white"
>
Ongedesemd brood
Ongedesemd Woord
</button>
</div>
@@ -83,7 +83,7 @@ export default function Hero() {
{/* Bottom Right - Dark Teal with date - above mic */}
<div className="relative flex flex-1 items-center justify-end bg-[#214e51]">
<p className="px-12 text-right font-['Intro',sans-serif] font-normal text-[clamp(2rem,5vw,6rem)] text-white uppercase leading-[1.1]">
VRIJDAG 18
VRIJDAG 24
<br />
april
</p>
@@ -114,7 +114,7 @@ export default function Hero() {
NIGHT
</h1>
<p className="mt-4 font-['Intro',sans-serif] font-normal text-[5vw] text-white/90">
Ongedesemd brood
Ongedesemd Woord
</p>
{/* Mobile Microphone - positioned inside magenta section, clipped by overflow */}
@@ -153,7 +153,7 @@ export default function Hero() {
<p className="text-right font-['Intro',sans-serif] font-normal text-[8vw] text-white uppercase leading-tight">
VRIJDAG
<br />
18 april
24 april
</p>
</div>
</div>

View File

@@ -0,0 +1,74 @@
export default function HoeInschrijven() {
return (
<section className="relative z-25 w-full bg-[#f8f8f8] px-6 py-16 md:px-12">
<div className="mx-auto max-w-6xl">
<h2 className="mb-4 font-['Intro',sans-serif] text-4xl text-[#214e51] md:text-5xl">
Hoe schrijf ik mij in?
</h2>
<p className="mb-10 max-w-3xl text-[#214e51]/70 text-lg">
Inschrijven doe je eenvoudig via deze website zodra de inschrijvingen
openen.
</p>
{/* Step cards */}
<div className="grid gap-6 md:grid-cols-3">
{/* Step 1 */}
<div className="flex flex-col gap-3 rounded-2xl border border-[#214e51]/15 bg-white p-6 shadow-sm">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#d82560] font-['Intro',sans-serif] font-bold text-lg text-white">
1
</div>
<h3 className="font-['Intro',sans-serif] text-[#214e51] text-xl">
Kies je rol
</h3>
<p className="text-[#214e51]/70 text-base leading-relaxed">
Registreer je als{" "}
<strong className="text-[#214e51]">artiest</strong> als je wil
optreden, of als{" "}
<strong className="text-[#214e51]">bezoeker</strong> als je wil
komen kijken. Je kunt ook aangeven met hoeveel mensen je komt.
</p>
</div>
{/* Step 2 */}
<div className="flex flex-col gap-3 rounded-2xl border border-[#214e51]/15 bg-white p-6 shadow-sm">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#d82560] font-['Intro',sans-serif] font-bold text-lg text-white">
2
</div>
<h3 className="font-['Intro',sans-serif] text-[#214e51] text-xl">
Bevestig je plaats
</h3>
<p className="text-[#214e51]/70 text-base leading-relaxed">
We vragen een bijdrage van{" "}
<strong className="text-[#214e51]">5 per persoon</strong> voor
jou én iedereen die je meebrengt.
</p>
</div>
{/* Step 3 */}
<div className="flex flex-col gap-3 rounded-2xl border border-[#214e51]/15 bg-white p-6 shadow-sm">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#d82560] font-['Intro',sans-serif] font-bold text-lg text-white">
3
</div>
<h3 className="font-['Intro',sans-serif] text-[#214e51] text-xl">
Geniet van de avond
</h3>
<p className="text-[#214e51]/70 text-base leading-relaxed">
Je bijdrage gaat{" "}
<strong className="text-[#214e51]">niet verloren</strong> het
wordt volledig op{" "}
<strong className="text-[#214e51]">je drankkaart</strong> gezet.
Zo kun je tijdens de avond iets drinken of een snack nemen.
</p>
</div>
</div>
{/* Bottom note */}
<p className="mt-8 max-w-3xl text-[#214e51]/60 text-base leading-relaxed">
Zo zorgen we ervoor dat iedereen die zich inschrijft ook echt een
plaats heeft en dat we samen kunnen genieten van een gezellige,
inspirerende avond vol muziek, poëzie en andere kunst.
</p>
</div>
</section>
);
}

View File

@@ -12,7 +12,13 @@ const faqQuestions = [
{
question: "Hoelang mag mijn optreden duren?",
answer:
"Elke deelnemer krijgt 5-7 minuten podiumtijd. Zo houden we de avond dynamisch en krijgt iedereen een kans om te shinen.",
"Elke deelnemer krijgt 5 minuten podiumtijd. Zo houden we de avond dynamisch en krijgt iedereen een kans om te shinen.",
},
{
question: "Waar vindt het plaats?",
answer:
"De Open Mic Night vindt plaats op Lange Winkelstraat 5, 2000 Antwerpen.",
mapUrl: "https://maps.app.goo.gl/kU6iug3QVKwWD1vR7",
},
{
question: "Wat moet ik meenemen?",
@@ -24,7 +30,7 @@ const faqQuestions = [
export default function Info() {
return (
<section id="info" className="relative z-20 flex flex-col bg-[#d82560]/96">
{/* Hero Section - Ongedesemd Brood */}
{/* Hero Section - Ongedesemd Woord */}
<div className="relative w-full border-white/20 border-b-4">
{/* Background pattern */}
<div
@@ -43,7 +49,7 @@ export default function Info() {
viewBox="0 0 80 56"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-label="Ongedesemd brood"
aria-label="Ongedesemd Woord"
>
{/* Flat matzo cracker - slightly rounded rect */}
<rect
@@ -88,10 +94,16 @@ export default function Info() {
</div>
{/* Main Title */}
<h2 className="font-['Intro',sans-serif] font-black text-6xl text-white uppercase tracking-tight md:text-8xl lg:text-9xl">
<h2
className="font-['Intro',sans-serif] font-black text-white uppercase tracking-tight"
style={{ fontSize: "clamp(1rem, 8vw + 1rem, 8rem)" }}
>
Ongedesemd
<span className="block text-5xl md:text-7xl lg:text-8xl">
Brood?!
<span
className="block"
style={{ fontSize: "clamp(1rem, 6vw + 1rem, 6rem)" }}
>
Woord?!
</span>
</h2>
@@ -110,9 +122,17 @@ export default function Info() {
</div>
{/* Content Section */}
<div className="w-full px-12 py-16">
<div className="mx-auto flex w-full max-w-6xl flex-1 flex-col justify-center gap-16">
{/* Ongedesemd Brood Explanation - Full Width Special Treatment */}
<div className="relative w-full px-12 py-16">
{/* Background texture */}
<div
className="pointer-events-none absolute inset-0 opacity-10"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
}}
/>
<div className="relative mx-auto flex w-full max-w-6xl flex-1 flex-col justify-center gap-16">
{/* Ongedesemd Woord Explanation - Full Width Special Treatment */}
<div className="relative flex flex-col gap-6 rounded-2xl border-2 border-white/20 bg-white/5 p-8 md:p-12">
<div className="absolute top-0 -left-4 h-full w-1 bg-gradient-to-b from-white/0 via-white/60 to-white/0" />
@@ -165,6 +185,16 @@ export default function Info() {
{item.question}
</h3>
<p className="max-w-xl text-white/80 text-xl">{item.answer}</p>
{"mapUrl" in item && (
<a
href={item.mapUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-white/60 underline hover:text-white/90"
>
Bekijk op Google Maps
</a>
)}
</div>
))}
</div>

View File

@@ -245,6 +245,169 @@ export function GuestList({
</span>
)}
</div>
{/* Birthdate */}
<div className="flex flex-col gap-2 md:col-span-2">
<p className="text-white/80">
Geboortedatum <span className="text-red-300">*</span>
</p>
<fieldset className="m-0 flex gap-2 border-0 p-0">
<legend className="sr-only">
Geboortedatum medebezoeker {idx + 1}
</legend>
<div className="flex flex-1 flex-col gap-1">
<label
htmlFor={`guest-${idx}-birthdateDay`}
className="sr-only"
>
Dag
</label>
<select
id={`guest-${idx}-birthdateDay`}
value={guest.birthdate?.split("-")[2] ?? ""}
onChange={(e) => {
const parts = (guest.birthdate || "--").split("-");
onChange(
idx,
"birthdate",
`${parts[0] || ""}-${parts[1] || ""}-${e.target.value.padStart(2, "0")}`,
);
}}
aria-label="Dag"
className={`border-b bg-transparent pb-2 text-sm text-white transition-colors focus:outline-none ${errors[idx]?.birthdate ? "border-red-400" : "border-white/30 focus:border-white"}`}
>
<option value="" className="bg-[#214e51]">
DD
</option>
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
<option
key={d}
value={String(d).padStart(2, "0")}
className="bg-[#214e51]"
>
{String(d).padStart(2, "0")}
</option>
))}
</select>
</div>
<div className="flex flex-1 flex-col gap-1">
<label
htmlFor={`guest-${idx}-birthdateMonth`}
className="sr-only"
>
Maand
</label>
<select
id={`guest-${idx}-birthdateMonth`}
value={guest.birthdate?.split("-")[1] ?? ""}
onChange={(e) => {
const parts = (guest.birthdate || "--").split("-");
onChange(
idx,
"birthdate",
`${parts[0] || ""}-${e.target.value.padStart(2, "0")}-${parts[2] || ""}`,
);
}}
aria-label="Maand"
className={`border-b bg-transparent pb-2 text-sm text-white transition-colors focus:outline-none ${errors[idx]?.birthdate ? "border-red-400" : "border-white/30 focus:border-white"}`}
>
<option value="" className="bg-[#214e51]">
MM
</option>
{[
"Jan",
"Feb",
"Mrt",
"Apr",
"Mei",
"Jun",
"Jul",
"Aug",
"Sep",
"Okt",
"Nov",
"Dec",
].map((m, i) => (
<option
key={m}
value={String(i + 1).padStart(2, "0")}
className="bg-[#214e51]"
>
{m}
</option>
))}
</select>
</div>
<div className="flex flex-1 flex-col gap-1">
<label
htmlFor={`guest-${idx}-birthdateYear`}
className="sr-only"
>
Jaar
</label>
<select
id={`guest-${idx}-birthdateYear`}
value={guest.birthdate?.split("-")[0] ?? ""}
onChange={(e) => {
const parts = (guest.birthdate || "--").split("-");
onChange(
idx,
"birthdate",
`${e.target.value}-${parts[1] || ""}-${parts[2] || ""}`,
);
}}
aria-label="Jaar"
className={`border-b bg-transparent pb-2 text-sm text-white transition-colors focus:outline-none ${errors[idx]?.birthdate ? "border-red-400" : "border-white/30 focus:border-white"}`}
>
<option value="" className="bg-[#214e51]">
JJJJ
</option>
{Array.from(
{ length: 100 },
(_, i) => new Date().getFullYear() - i,
).map((y) => (
<option
key={y}
value={String(y)}
className="bg-[#214e51]"
>
{y}
</option>
))}
</select>
</div>
</fieldset>
{errors[idx]?.birthdate && (
<span className="text-red-300 text-sm" role="alert">
{errors[idx].birthdate}
</span>
)}
</div>
{/* Postcode */}
<div className="flex flex-col gap-2">
<label
htmlFor={`guest-${idx}-postcode`}
className="text-white/80"
>
Postcode <span className="text-red-300">*</span>
</label>
<input
type="text"
id={`guest-${idx}-postcode`}
value={guest.postcode}
onChange={(e) => onChange(idx, "postcode", e.target.value)}
placeholder="bv. 9000"
autoComplete="off"
inputMode="numeric"
className={inputCls(!!errors[idx]?.postcode)}
/>
{errors[idx]?.postcode && (
<span className="text-red-300 text-sm" role="alert">
{errors[idx].postcode}
</span>
)}
</div>
</div>
</div>
))}

View File

@@ -1,10 +1,13 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import {
inputCls,
validateBirthdate,
validateEmail,
validatePhone,
validatePostcode,
validateTextField,
} from "@/lib/registration";
import { orpc } from "@/utils/orpc";
@@ -15,21 +18,30 @@ interface PerformerErrors {
lastName?: string;
email?: string;
phone?: string;
birthdate?: string;
postcode?: string;
artForm?: string;
isOver16?: string;
}
interface Props {
onBack: () => void;
onSuccess: (token: string) => void;
onSuccess: (token: string, email: string, name: string) => void;
}
export function PerformerForm({ onBack, onSuccess }: Props) {
const { data: session } = authClient.useSession();
const sessionEmail = session?.user?.email ?? "";
const [data, setData] = useState({
firstName: "",
lastName: "",
email: "",
phone: "",
birthdateDay: "",
birthdateMonth: "",
birthdateYear: "",
postcode: "",
artForm: "",
experience: "",
isOver16: false,
@@ -39,22 +51,42 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
const [errors, setErrors] = useState<PerformerErrors>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
useEffect(() => {
if (sessionEmail) {
setData((prev) => ({ ...prev, email: sessionEmail }));
}
}, [sessionEmail]);
const submitMutation = useMutation({
...orpc.submitRegistration.mutationOptions(),
onSuccess: (result) => {
if (result.managementToken) onSuccess(result.managementToken);
if (result.managementToken)
onSuccess(
result.managementToken,
data.email.trim(),
`${data.firstName.trim()} ${data.lastName.trim()}`.trim(),
);
},
onError: (error) => {
toast.error(`Er is iets misgegaan: ${error.message}`);
},
});
function getBirthdate(): string {
const { birthdateYear, birthdateMonth, birthdateDay } = data;
if (!birthdateYear || !birthdateMonth || !birthdateDay) return "";
return `${birthdateYear}-${birthdateMonth.padStart(2, "0")}-${birthdateDay.padStart(2, "0")}`;
}
function validate(): boolean {
const birthdate = getBirthdate();
const errs: PerformerErrors = {
firstName: validateTextField(data.firstName, true, "Voornaam"),
lastName: validateTextField(data.lastName, true, "Achternaam"),
email: validateEmail(data.email),
phone: validatePhone(data.phone),
birthdate: validateBirthdate(birthdate),
postcode: validatePostcode(data.postcode),
artForm: !data.artForm.trim() ? "Kunstvorm is verplicht" : undefined,
isOver16: !data.isOver16
? "Je moet 16 jaar of ouder zijn om op te treden"
@@ -66,6 +98,8 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
lastName: true,
email: true,
phone: true,
birthdate: true,
postcode: true,
artForm: true,
isOver16: true,
});
@@ -129,6 +163,8 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
lastName: data.lastName.trim(),
email: data.email.trim(),
phone: data.phone.trim() || undefined,
birthdate: getBirthdate(),
postcode: data.postcode.trim(),
registrationType: "performer",
artForm: data.artForm.trim() || undefined,
experience: data.experience.trim() || undefined,
@@ -249,14 +285,15 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
id="p-email"
name="email"
value={data.email}
onChange={handleChange}
onBlur={handleBlur}
onChange={sessionEmail ? undefined : handleChange}
onBlur={sessionEmail ? undefined : handleBlur}
readOnly={!!sessionEmail}
placeholder="jouw@email.be"
autoComplete="email"
inputMode="email"
aria-required="true"
aria-invalid={touched.email && !!errors.email}
className={inputCls(!!touched.email && !!errors.email)}
className={`${inputCls(!!touched.email && !!errors.email)}${sessionEmail ? "cursor-not-allowed opacity-75" : ""}`}
/>
{touched.email && errors.email && (
<span className="text-red-300 text-sm" role="alert">
@@ -289,6 +326,149 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
</div>
</div>
{/* Birthdate + Postcode row */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="flex flex-col gap-2">
<p className="text-white text-xl">
Geboortedatum <span className="text-red-300">*</span>
</p>
<fieldset className="m-0 flex gap-2 border-0 p-0">
<legend className="sr-only">Geboortedatum</legend>
<div className="flex flex-1 flex-col gap-1">
<label htmlFor="p-birthdateDay" className="sr-only">
Dag
</label>
<select
id="p-birthdateDay"
name="birthdateDay"
value={data.birthdateDay}
onChange={(e) =>
setData((prev) => ({
...prev,
birthdateDay: e.target.value,
}))
}
aria-label="Dag"
className={`border-b bg-transparent pb-2 text-lg text-white transition-colors focus:outline-none ${touched.birthdate && errors.birthdate ? "border-red-400" : "border-white/30 focus:border-white"}`}
>
<option value="" className="bg-[#214e51]">
DD
</option>
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
<option key={d} value={String(d)} className="bg-[#214e51]">
{String(d).padStart(2, "0")}
</option>
))}
</select>
</div>
<div className="flex flex-1 flex-col gap-1">
<label htmlFor="p-birthdateMonth" className="sr-only">
Maand
</label>
<select
id="p-birthdateMonth"
name="birthdateMonth"
value={data.birthdateMonth}
onChange={(e) =>
setData((prev) => ({
...prev,
birthdateMonth: e.target.value,
}))
}
aria-label="Maand"
className={`border-b bg-transparent pb-2 text-lg text-white transition-colors focus:outline-none ${touched.birthdate && errors.birthdate ? "border-red-400" : "border-white/30 focus:border-white"}`}
>
<option value="" className="bg-[#214e51]">
MM
</option>
{[
"Jan",
"Feb",
"Mrt",
"Apr",
"Mei",
"Jun",
"Jul",
"Aug",
"Sep",
"Okt",
"Nov",
"Dec",
].map((m, i) => (
<option
key={m}
value={String(i + 1)}
className="bg-[#214e51]"
>
{m}
</option>
))}
</select>
</div>
<div className="flex flex-1 flex-col gap-1">
<label htmlFor="p-birthdateYear" className="sr-only">
Jaar
</label>
<select
id="p-birthdateYear"
name="birthdateYear"
value={data.birthdateYear}
onChange={(e) =>
setData((prev) => ({
...prev,
birthdateYear: e.target.value,
}))
}
aria-label="Jaar"
className={`border-b bg-transparent pb-2 text-lg text-white transition-colors focus:outline-none ${touched.birthdate && errors.birthdate ? "border-red-400" : "border-white/30 focus:border-white"}`}
>
<option value="" className="bg-[#214e51]">
JJJJ
</option>
{Array.from(
{ length: 100 },
(_, i) => new Date().getFullYear() - i,
).map((y) => (
<option key={y} value={String(y)} className="bg-[#214e51]">
{y}
</option>
))}
</select>
</div>
</fieldset>
{touched.birthdate && errors.birthdate && (
<span className="text-red-300 text-sm" role="alert">
{errors.birthdate}
</span>
)}
</div>
<div className="flex flex-col gap-2">
<label htmlFor="p-postcode" className="text-white text-xl">
Postcode <span className="text-red-300">*</span>
</label>
<input
type="text"
id="p-postcode"
name="postcode"
value={data.postcode}
onChange={handleChange}
onBlur={handleBlur}
placeholder="bv. 9000"
autoComplete="postal-code"
inputMode="numeric"
aria-required="true"
aria-invalid={touched.postcode && !!errors.postcode}
className={inputCls(!!touched.postcode && !!errors.postcode)}
/>
{touched.postcode && errors.postcode && (
<span className="text-red-300 text-sm" role="alert">
{errors.postcode}
</span>
)}
</div>
</div>
{/* Performer-specific fields */}
<div className="border border-amber-400/20 bg-amber-400/5 p-6">
<p className="mb-5 text-amber-300/80 text-sm uppercase tracking-wider">
@@ -425,6 +605,33 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
<GiftSelector value={giftAmount} onChange={setGiftAmount} />
</div>
{/* Under review notice */}
<div className="flex items-start gap-3 rounded-lg border border-amber-400/30 bg-amber-400/10 p-4">
<svg
className="mt-0.5 h-5 w-5 shrink-0 text-amber-300"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
<div>
<p className="font-semibold text-amber-300 text-sm">
Jouw inschrijving staat onder voorbehoud
</p>
<p className="mt-1 text-sm text-white/70">
Na het indienen wordt je aanvraag beoordeeld. Je ontvangt een
bevestiging of je effectief uitgenodigd wordt om op te treden.
</p>
</div>
</div>
<div className="flex flex-col items-center gap-4 pt-4">
<button
type="submit"

View File

@@ -1,72 +0,0 @@
interface Props {
token: string;
onReset: () => void;
}
export function SuccessScreen({ token, onReset }: Props) {
const manageUrl =
typeof window !== "undefined"
? `${window.location.origin}/manage/${token}`
: `/manage/${token}`;
return (
<section
id="registration"
className="relative z-30 flex w-full items-center justify-center bg-[#214e51]/96 px-6 py-16 md:px-12"
>
<div className="mx-auto w-full max-w-3xl">
<div className="rounded-lg border border-white/20 bg-white/5 p-8 md:p-12">
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-green-500/20 text-green-400">
<svg
className="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-label="Succes"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h2 className="mb-4 font-['Intro',sans-serif] text-3xl text-white md:text-4xl">
Gelukt!
</h2>
<p className="mb-6 text-lg text-white/80">
Je inschrijving is bevestigd. We sturen je zo dadelijk een
bevestigingsmail.
</p>
<div className="mb-8 rounded-lg border border-white/10 bg-white/5 p-6">
<p className="mb-2 text-sm text-white/60">
Geen mail ontvangen? Gebruik deze link:
</p>
<a
href={manageUrl}
className="break-all text-sm text-white/80 underline underline-offset-2 hover:text-white"
>
{manageUrl}
</a>
</div>
<div className="flex flex-wrap items-center gap-4">
<a
href={manageUrl}
className="inline-flex items-center bg-white px-6 py-3 font-['Intro',sans-serif] text-[#214e51] text-lg transition-all hover:scale-105 hover:bg-gray-100"
>
Bekijk mijn inschrijving
</a>
<button
type="button"
onClick={onReset}
className="text-white/60 underline underline-offset-2 transition-colors hover:text-white"
>
Nog een inschrijving
</button>
</div>
</div>
</div>
</section>
);
}

View File

@@ -2,9 +2,15 @@ type RegistrationType = "performer" | "watcher";
interface Props {
onSelect: (type: RegistrationType) => void;
watcherIsFull?: boolean;
watcherAvailable?: number | null;
}
export function TypeSelector({ onSelect }: Props) {
export function TypeSelector({
onSelect,
watcherIsFull = false,
watcherAvailable = null,
}: Props) {
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* Performer card */}
@@ -59,10 +65,17 @@ export function TypeSelector({ onSelect }: Props) {
{/* Watcher card */}
<button
type="button"
onClick={() => onSelect("watcher")}
className="group relative flex flex-col overflow-hidden border border-white/20 bg-white/5 p-8 text-left transition-all duration-300 hover:border-white/50 hover:bg-white/10 md:p-10"
onClick={() => !watcherIsFull && onSelect("watcher")}
disabled={watcherIsFull}
className={`group relative flex flex-col overflow-hidden border p-8 text-left transition-all duration-300 md:p-10 ${
watcherIsFull
? "cursor-not-allowed border-white/10 bg-white/3 opacity-60"
: "border-white/20 bg-white/5 hover:border-white/50 hover:bg-white/10"
}`}
>
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-teal-400 to-cyan-400 opacity-80" />
<div
className={`absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-teal-400 to-cyan-400 ${watcherIsFull ? "opacity-30" : "opacity-80"}`}
/>
<div className="mb-6 flex h-14 w-14 items-center justify-center rounded-full bg-teal-400/15 text-teal-300">
<svg
className="h-7 w-7"
@@ -83,31 +96,47 @@ export function TypeSelector({ onSelect }: Props) {
Ik wil komen kijken
</h3>
<p className="mb-4 text-white/70">
Geniet van een avond vol talent en kunst. Je betaald 5EUR inkom dat je
mag gebruiken op je drinkkaart.
Geniet van een avond vol talent en kunst. Je betaalt 5 per persoon
dit gaat volledig naar je drinkkaart.
</p>
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-teal-400/40 bg-teal-400/10 px-4 py-1.5">
<span className="font-semibold text-sm text-teal-300">
Drinkkaart 5 EUR te betalen bij registratie
</span>
</div>
<div className="mt-auto flex items-center gap-2 font-medium text-sm text-teal-300">
<span>Inschrijven als bezoeker</span>
<svg
className="h-4 w-4 transition-transform group-hover:translate-x-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"
/>
</svg>
</div>
{watcherIsFull ? (
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-red-400/40 bg-red-400/10 px-4 py-1.5">
<span className="font-semibold text-red-300 text-sm">
Volzet geen plaatsen meer beschikbaar
</span>
</div>
) : (
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-teal-400/40 bg-teal-400/10 px-4 py-1.5">
<span className="font-semibold text-sm text-teal-300">
5 per persoon drinkkaart inbegrepen
{watcherAvailable !== null && watcherAvailable <= 10 && (
<span className="ml-1 text-amber-300">
({watcherAvailable}{" "}
{watcherAvailable === 1 ? "plaats" : "plaatsen"} vrij)
</span>
)}
</span>
</div>
)}
{!watcherIsFull && (
<div className="mt-auto flex items-center gap-2 font-medium text-sm text-teal-300">
<span>Inschrijven als bezoeker</span>
<svg
className="h-4 w-4 transition-transform group-hover:translate-x-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"
/>
</svg>
</div>
)}
</button>
</div>
);

View File

@@ -1,17 +1,20 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import {
calculateDrinkCard,
type GuestEntry,
type GuestErrors,
inputCls,
validateBirthdate,
validateEmail,
validateGuests,
validatePhone,
validatePostcode,
validateTextField,
} from "@/lib/registration";
import { client, orpc } from "@/utils/orpc";
import { orpc } from "@/utils/orpc";
import { GiftSelector } from "./GiftSelector";
import { GuestList } from "./GuestList";
@@ -20,18 +23,30 @@ interface WatcherErrors {
lastName?: string;
email?: string;
phone?: string;
birthdate?: string;
postcode?: string;
}
interface Props {
onBack: () => void;
onSuccess: (token: string, email: string, name: string) => void;
}
export function WatcherForm({ onBack }: Props) {
// ── Main watcher form ──────────────────────────────────────────────────────
export function WatcherForm({ onBack, onSuccess }: Props) {
const { data: session } = authClient.useSession();
const sessionEmail = session?.user?.email ?? "";
const [data, setData] = useState({
firstName: "",
lastName: "",
email: "",
phone: "",
birthdateDay: "",
birthdateMonth: "",
birthdateYear: "",
postcode: "",
extraQuestions: "",
});
const [errors, setErrors] = useState<WatcherErrors>({});
@@ -40,19 +55,21 @@ export function WatcherForm({ onBack }: Props) {
const [guestErrors, setGuestErrors] = useState<GuestErrors[]>([]);
const [giftAmount, setGiftAmount] = useState(0);
useEffect(() => {
if (sessionEmail) {
setData((prev) => ({ ...prev, email: sessionEmail }));
}
}, [sessionEmail]);
const submitMutation = useMutation({
...orpc.submitRegistration.mutationOptions(),
onSuccess: async (result) => {
if (!result.managementToken) return;
// Redirect to Lemon Squeezy checkout immediately after registration
try {
const checkout = await client.getCheckoutUrl({
token: result.managementToken,
});
window.location.href = checkout.checkoutUrl;
} catch (error) {
console.error("Checkout error:", error);
toast.error("Er is iets misgegaan bij het aanmaken van de betaling");
onSuccess: (result) => {
if (result.managementToken) {
onSuccess(
result.managementToken,
data.email.trim(),
`${data.firstName.trim()} ${data.lastName.trim()}`.trim(),
);
}
},
onError: (error) => {
@@ -60,12 +77,21 @@ export function WatcherForm({ onBack }: Props) {
},
});
function getBirthdate(): string {
const { birthdateYear, birthdateMonth, birthdateDay } = data;
if (!birthdateYear || !birthdateMonth || !birthdateDay) return "";
return `${birthdateYear}-${birthdateMonth.padStart(2, "0")}-${birthdateDay.padStart(2, "0")}`;
}
function validate(): boolean {
const birthdate = getBirthdate();
const fieldErrs: WatcherErrors = {
firstName: validateTextField(data.firstName, true, "Voornaam"),
lastName: validateTextField(data.lastName, true, "Achternaam"),
email: validateEmail(data.email),
phone: validatePhone(data.phone),
birthdate: validateBirthdate(birthdate),
postcode: validatePostcode(data.postcode),
};
setErrors(fieldErrs);
setTouched({
@@ -73,6 +99,8 @@ export function WatcherForm({ onBack }: Props) {
lastName: true,
email: true,
phone: true,
birthdate: true,
postcode: true,
});
const { errors: gErrs, valid: gValid } = validateGuests(guests);
setGuestErrors(gErrs);
@@ -125,7 +153,14 @@ export function WatcherForm({ onBack }: Props) {
if (guests.length >= 9) return;
setGuests((prev) => [
...prev,
{ firstName: "", lastName: "", email: "", phone: "" },
{
firstName: "",
lastName: "",
email: "",
phone: "",
birthdate: "",
postcode: "",
},
]);
setGuestErrors((prev) => [...prev, {}]);
}
@@ -146,12 +181,16 @@ export function WatcherForm({ onBack }: Props) {
lastName: data.lastName.trim(),
email: data.email.trim(),
phone: data.phone.trim() || undefined,
birthdate: getBirthdate(),
postcode: data.postcode.trim(),
registrationType: "watcher",
guests: guests.map((g) => ({
firstName: g.firstName.trim(),
lastName: g.lastName.trim(),
email: g.email.trim() || undefined,
phone: g.phone.trim() || undefined,
birthdate: g.birthdate.trim(),
postcode: g.postcode.trim(),
})),
extraQuestions: data.extraQuestions.trim() || undefined,
giftAmount,
@@ -217,8 +256,8 @@ export function WatcherForm({ onBack }: Props) {
<div>
<p className="font-semibold text-white">Drinkkaart inbegrepen</p>
<p className="text-sm text-white/70">
Je betaald bij registratie{" "}
<strong className="text-teal-300">5</strong> (+ 2 per
Je betaald bij registratie
<strong className="text-teal-300"> 5</strong> (+ 5 per
medebezoeker) dat gaat naar je drinkkaart.
{guests.length > 0 && (
<span className="ml-1 font-semibold text-teal-300">
@@ -291,14 +330,15 @@ export function WatcherForm({ onBack }: Props) {
id="w-email"
name="email"
value={data.email}
onChange={handleChange}
onBlur={handleBlur}
onChange={sessionEmail ? undefined : handleChange}
onBlur={sessionEmail ? undefined : handleBlur}
readOnly={!!sessionEmail}
placeholder="jouw@email.be"
autoComplete="email"
inputMode="email"
aria-required="true"
aria-invalid={touched.email && !!errors.email}
className={inputCls(!!touched.email && !!errors.email)}
className={`${inputCls(!!touched.email && !!errors.email)}${sessionEmail ? "cursor-not-allowed opacity-75" : ""}`}
/>
{touched.email && errors.email && (
<span className="text-red-300 text-sm" role="alert">
@@ -331,6 +371,146 @@ export function WatcherForm({ onBack }: Props) {
</div>
</div>
{/* Birthdate + Postcode row */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="flex flex-col gap-2">
<p className="text-white text-xl">
Geboortedatum <span className="text-red-300">*</span>
</p>
<fieldset className="m-0 flex gap-2 border-0 p-0">
<legend className="sr-only">Geboortedatum</legend>
<div className="flex flex-1 flex-col gap-1">
<label htmlFor="w-birthdateDay" className="sr-only">
Dag
</label>
<select
id="w-birthdateDay"
value={data.birthdateDay}
onChange={(e) =>
setData((prev) => ({
...prev,
birthdateDay: e.target.value,
}))
}
aria-label="Dag"
className={`border-b bg-transparent pb-2 text-lg text-white transition-colors focus:outline-none ${touched.birthdate && errors.birthdate ? "border-red-400" : "border-white/30 focus:border-white"}`}
>
<option value="" className="bg-[#214e51]">
DD
</option>
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
<option key={d} value={String(d)} className="bg-[#214e51]">
{String(d).padStart(2, "0")}
</option>
))}
</select>
</div>
<div className="flex flex-1 flex-col gap-1">
<label htmlFor="w-birthdateMonth" className="sr-only">
Maand
</label>
<select
id="w-birthdateMonth"
value={data.birthdateMonth}
onChange={(e) =>
setData((prev) => ({
...prev,
birthdateMonth: e.target.value,
}))
}
aria-label="Maand"
className={`border-b bg-transparent pb-2 text-lg text-white transition-colors focus:outline-none ${touched.birthdate && errors.birthdate ? "border-red-400" : "border-white/30 focus:border-white"}`}
>
<option value="" className="bg-[#214e51]">
MM
</option>
{[
"Jan",
"Feb",
"Mrt",
"Apr",
"Mei",
"Jun",
"Jul",
"Aug",
"Sep",
"Okt",
"Nov",
"Dec",
].map((m, i) => (
<option
key={m}
value={String(i + 1)}
className="bg-[#214e51]"
>
{m}
</option>
))}
</select>
</div>
<div className="flex flex-1 flex-col gap-1">
<label htmlFor="w-birthdateYear" className="sr-only">
Jaar
</label>
<select
id="w-birthdateYear"
value={data.birthdateYear}
onChange={(e) =>
setData((prev) => ({
...prev,
birthdateYear: e.target.value,
}))
}
aria-label="Jaar"
className={`border-b bg-transparent pb-2 text-lg text-white transition-colors focus:outline-none ${touched.birthdate && errors.birthdate ? "border-red-400" : "border-white/30 focus:border-white"}`}
>
<option value="" className="bg-[#214e51]">
JJJJ
</option>
{Array.from(
{ length: 100 },
(_, i) => new Date().getFullYear() - i,
).map((y) => (
<option key={y} value={String(y)} className="bg-[#214e51]">
{y}
</option>
))}
</select>
</div>
</fieldset>
{touched.birthdate && errors.birthdate && (
<span className="text-red-300 text-sm" role="alert">
{errors.birthdate}
</span>
)}
</div>
<div className="flex flex-col gap-2">
<label htmlFor="w-postcode" className="text-white text-xl">
Postcode <span className="text-red-300">*</span>
</label>
<input
type="text"
id="w-postcode"
name="postcode"
value={data.postcode}
onChange={handleChange}
onBlur={handleBlur}
placeholder="bv. 9000"
autoComplete="postal-code"
inputMode="numeric"
aria-required="true"
aria-invalid={touched.postcode && !!errors.postcode}
className={inputCls(!!touched.postcode && !!errors.postcode)}
/>
{touched.postcode && errors.postcode && (
<span className="text-red-300 text-sm" role="alert">
{errors.postcode}
</span>
)}
</div>
</div>
{/* Guests */}
<GuestList
guests={guests}
@@ -399,7 +579,7 @@ export function WatcherForm({ onBack }: Props) {
Bezig...
</span>
) : (
"Bevestigen & betalen"
"Bevestigen"
)}
</button>
<a

View File

@@ -25,9 +25,9 @@ const Toaster = ({ ...props }: ToasterProps) => {
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--normal-bg": "hsl(var(--popover))",
"--normal-text": "hsl(var(--popover-foreground))",
"--normal-border": "hsl(var(--border))",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}

View File

@@ -109,6 +109,32 @@ html {
border-radius: 3px;
}
/* ─── Theme Colors (CSS Variables for shadcn/ui) ─────────────────────────────
These variables are used by shadcn/ui components and the toast system.
─────────────────────────────────────────────────────────────────────────── */
:root {
--background: 0 0% 100%;
--foreground: 0 0% 10%;
--card: 0 0% 100%;
--card-foreground: 0 0% 10%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 10%;
--primary: 340 70% 49%;
--primary-foreground: 0 0% 100%;
--secondary: 183 40% 23%;
--secondary-foreground: 0 0% 100%;
--muted: 0 0% 96%;
--muted-foreground: 0 0% 45%;
--accent: 340 70% 49%;
--accent-foreground: 0 0% 100%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--border: 0 0% 90%;
--input: 0 0% 90%;
--ring: 340 70% 49%;
--radius: 0rem;
}
/* ─── Text Selection Colors ─────────────────────────────────────────────────
Each section gets a ::selection style that harmonizes with its background.
The goal: selection feels native to each section, not a browser default.

View File

@@ -1,3 +1,3 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({});
export const authClient = createAuthClient();

View File

@@ -0,0 +1,18 @@
// Frontend constants and utilities for the Drinkkaart feature.
export const TOPUP_PRESETS_CENTS = [500, 1000, 2000, 5000] as const;
// = €5, €10, €20, €50
export const DEDUCTION_PRESETS_CENTS = [150, 200, 300, 500] as const;
// = €1,50 / €2,00 / €3,00 / €5,00 — typical drink prices
export const TOPUP_MIN_CENTS = 100; // €1
export const TOPUP_MAX_CENTS = 50000; // €500
/** Format a cents integer as a Belgian euro string, e.g. 1250 → "€ 12,50". */
export function formatCents(cents: number): string {
return new Intl.NumberFormat("nl-BE", {
style: "currency",
currency: "EUR",
}).format(cents / 100);
}

View File

@@ -0,0 +1,12 @@
/**
* Single source-of-truth for when registration opens.
* Change this date to reschedule — all gating logic imports from here.
*/
export const REGISTRATION_OPENS_AT = new Date("2026-03-16T19:00:00+01:00");
/**
* Feature flag for the registration countdown gate.
* Set to `false` to bypass the countdown and always show the registration form.
* Set to `true` to enforce the countdown until REGISTRATION_OPENS_AT.
*/
export const COUNTDOWN_ENABLED = true;

View File

@@ -6,8 +6,9 @@
// ---------------------------------------------------------------------------
export const DRINK_CARD_BASE = 5; // €5 for primary registrant
export const DRINK_CARD_PER_GUEST = 2; // €2 per additional guest
export const DRINK_CARD_PER_GUEST = 5; // €5 per additional guest (same as primary)
export const MAX_GUESTS = 9;
export const MAX_WATCHERS = 70; // max total watcher spots (people, not registrations)
/** Returns drink card value in euros for a given number of extra guests. */
export function calculateDrinkCard(guestCount: number): number {
@@ -28,6 +29,8 @@ export interface GuestEntry {
lastName: string;
email: string;
phone: string;
birthdate: string;
postcode: string;
}
export interface GuestErrors {
@@ -35,6 +38,8 @@ export interface GuestErrors {
lastName?: string;
email?: string;
phone?: string;
birthdate?: string;
postcode?: string;
}
/**
@@ -51,6 +56,8 @@ export function parseGuests(raw: string | null | undefined): GuestEntry[] {
lastName: g.lastName ?? "",
email: g.email ?? "",
phone: g.phone ?? "",
birthdate: g.birthdate ?? "",
postcode: g.postcode ?? "",
}));
} catch {
return [];
@@ -86,6 +93,22 @@ export function validatePhone(value: string): string | undefined {
return undefined;
}
export function validateBirthdate(value: string): string | undefined {
if (!value.trim()) return "Geboortedatum is verplicht";
// Expect YYYY-MM-DD format
if (!/^\d{4}-\d{2}-\d{2}$/.test(value))
return "Voer een geldige geboortedatum in";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "Voer een geldige geboortedatum in";
if (date > new Date()) return "Geboortedatum mag niet in de toekomst liggen";
return undefined;
}
export function validatePostcode(value: string): string | undefined {
if (!value.trim()) return "Postcode is verplicht";
return undefined;
}
/** Validates all guests and returns errors array + overall validity flag. */
export function validateGuests(guests: GuestEntry[]): {
errors: GuestErrors[];
@@ -102,6 +125,8 @@ export function validateGuests(guests: GuestEntry[]): {
g.phone.trim() && !/^[\d\s\-+()]{10,}$/.test(g.phone.replace(/\s/g, ""))
? "Voer een geldig telefoonnummer in"
: undefined,
birthdate: validateBirthdate(g.birthdate),
postcode: validatePostcode(g.postcode),
}));
const valid = !errors.some((e) => Object.values(e).some(Boolean));
return { errors, valid };

View File

@@ -0,0 +1,48 @@
import { useEffect, useState } from "react";
import { COUNTDOWN_ENABLED, REGISTRATION_OPENS_AT } from "./opening";
interface RegistrationOpenState {
isOpen: boolean;
days: number;
hours: number;
minutes: number;
seconds: number;
}
function compute(): RegistrationOpenState {
if (!COUNTDOWN_ENABLED) {
return { isOpen: true, days: 0, hours: 0, minutes: 0, seconds: 0 };
}
const diff = REGISTRATION_OPENS_AT.getTime() - Date.now();
if (diff <= 0) {
return { isOpen: true, days: 0, hours: 0, minutes: 0, seconds: 0 };
}
const totalSeconds = Math.floor(diff / 1000);
const days = Math.floor(totalSeconds / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return { isOpen: false, days, hours, minutes, seconds };
}
/**
* Returns live countdown state to {@link REGISTRATION_OPENS_AT}.
* Ticks every second; clears the interval once registration is open.
*/
export function useRegistrationOpen(): RegistrationOpenState {
const [state, setState] = useState<RegistrationOpenState>(compute);
useEffect(() => {
if (state.isOpen) return;
const id = setInterval(() => {
const next = compute();
setState(next);
if (next.isOpen) clearInterval(id);
}, 1000);
return () => clearInterval(id);
}, [state.isOpen]);
return state;
}

View File

@@ -10,14 +10,21 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as TermsRouteImport } from './routes/terms'
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
import { Route as PrivacyRouteImport } from './routes/privacy'
import { Route as LoginRouteImport } from './routes/login'
import { Route as KampRouteImport } from './routes/kamp'
import { Route as ForgotPasswordRouteImport } from './routes/forgot-password'
import { Route as DrinkkaartRouteImport } from './routes/drinkkaart'
import { Route as ContactRouteImport } from './routes/contact'
import { Route as AdminRouteImport } from './routes/admin'
import { Route as AccountRouteImport } from './routes/account'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AdminIndexRouteImport } from './routes/admin/index'
import { Route as ManageTokenRouteImport } from './routes/manage.$token'
import { Route as ApiWebhookLemonsqueezyRouteImport } from './routes/api/webhook/lemonsqueezy'
import { Route as AdminDrinkkaartRouteImport } from './routes/admin/drinkkaart'
import { Route as ApiWebhookMollieRouteImport } from './routes/api/webhook/mollie'
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc/$'
import { Route as ApiCronRemindersRouteImport } from './routes/api/cron/reminders'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
const TermsRoute = TermsRouteImport.update({
@@ -25,6 +32,11 @@ const TermsRoute = TermsRouteImport.update({
path: '/terms',
getParentRoute: () => rootRouteImport,
} as any)
const ResetPasswordRoute = ResetPasswordRouteImport.update({
id: '/reset-password',
path: '/reset-password',
getParentRoute: () => rootRouteImport,
} as any)
const PrivacyRoute = PrivacyRouteImport.update({
id: '/privacy',
path: '/privacy',
@@ -35,14 +47,29 @@ const LoginRoute = LoginRouteImport.update({
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const KampRoute = KampRouteImport.update({
id: '/kamp',
path: '/kamp',
getParentRoute: () => rootRouteImport,
} as any)
const ForgotPasswordRoute = ForgotPasswordRouteImport.update({
id: '/forgot-password',
path: '/forgot-password',
getParentRoute: () => rootRouteImport,
} as any)
const DrinkkaartRoute = DrinkkaartRouteImport.update({
id: '/drinkkaart',
path: '/drinkkaart',
getParentRoute: () => rootRouteImport,
} as any)
const ContactRoute = ContactRouteImport.update({
id: '/contact',
path: '/contact',
getParentRoute: () => rootRouteImport,
} as any)
const AdminRoute = AdminRouteImport.update({
id: '/admin',
path: '/admin',
const AccountRoute = AccountRouteImport.update({
id: '/account',
path: '/account',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
@@ -50,14 +77,24 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const AdminIndexRoute = AdminIndexRouteImport.update({
id: '/admin/',
path: '/admin/',
getParentRoute: () => rootRouteImport,
} as any)
const ManageTokenRoute = ManageTokenRouteImport.update({
id: '/manage/$token',
path: '/manage/$token',
getParentRoute: () => rootRouteImport,
} as any)
const ApiWebhookLemonsqueezyRoute = ApiWebhookLemonsqueezyRouteImport.update({
id: '/api/webhook/lemonsqueezy',
path: '/api/webhook/lemonsqueezy',
const AdminDrinkkaartRoute = AdminDrinkkaartRouteImport.update({
id: '/admin/drinkkaart',
path: '/admin/drinkkaart',
getParentRoute: () => rootRouteImport,
} as any)
const ApiWebhookMollieRoute = ApiWebhookMollieRouteImport.update({
id: '/api/webhook/mollie',
path: '/api/webhook/mollie',
getParentRoute: () => rootRouteImport,
} as any)
const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({
@@ -65,6 +102,11 @@ const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({
path: '/api/rpc/$',
getParentRoute: () => rootRouteImport,
} as any)
const ApiCronRemindersRoute = ApiCronRemindersRouteImport.update({
id: '/api/cron/reminders',
path: '/api/cron/reminders',
getParentRoute: () => rootRouteImport,
} as any)
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
id: '/api/auth/$',
path: '/api/auth/$',
@@ -73,91 +115,140 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/admin': typeof AdminRoute
'/account': typeof AccountRoute
'/contact': typeof ContactRoute
'/drinkkaart': typeof DrinkkaartRoute
'/forgot-password': typeof ForgotPasswordRoute
'/kamp': typeof KampRoute
'/login': typeof LoginRoute
'/privacy': typeof PrivacyRoute
'/reset-password': typeof ResetPasswordRoute
'/terms': typeof TermsRoute
'/admin/drinkkaart': typeof AdminDrinkkaartRoute
'/manage/$token': typeof ManageTokenRoute
'/admin/': typeof AdminIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/cron/reminders': typeof ApiCronRemindersRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
'/api/webhook/lemonsqueezy': typeof ApiWebhookLemonsqueezyRoute
'/api/webhook/mollie': typeof ApiWebhookMollieRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/admin': typeof AdminRoute
'/account': typeof AccountRoute
'/contact': typeof ContactRoute
'/drinkkaart': typeof DrinkkaartRoute
'/forgot-password': typeof ForgotPasswordRoute
'/kamp': typeof KampRoute
'/login': typeof LoginRoute
'/privacy': typeof PrivacyRoute
'/reset-password': typeof ResetPasswordRoute
'/terms': typeof TermsRoute
'/admin/drinkkaart': typeof AdminDrinkkaartRoute
'/manage/$token': typeof ManageTokenRoute
'/admin': typeof AdminIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/cron/reminders': typeof ApiCronRemindersRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
'/api/webhook/lemonsqueezy': typeof ApiWebhookLemonsqueezyRoute
'/api/webhook/mollie': typeof ApiWebhookMollieRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/admin': typeof AdminRoute
'/account': typeof AccountRoute
'/contact': typeof ContactRoute
'/drinkkaart': typeof DrinkkaartRoute
'/forgot-password': typeof ForgotPasswordRoute
'/kamp': typeof KampRoute
'/login': typeof LoginRoute
'/privacy': typeof PrivacyRoute
'/reset-password': typeof ResetPasswordRoute
'/terms': typeof TermsRoute
'/admin/drinkkaart': typeof AdminDrinkkaartRoute
'/manage/$token': typeof ManageTokenRoute
'/admin/': typeof AdminIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/cron/reminders': typeof ApiCronRemindersRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
'/api/webhook/lemonsqueezy': typeof ApiWebhookLemonsqueezyRoute
'/api/webhook/mollie': typeof ApiWebhookMollieRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/admin'
| '/account'
| '/contact'
| '/drinkkaart'
| '/forgot-password'
| '/kamp'
| '/login'
| '/privacy'
| '/reset-password'
| '/terms'
| '/admin/drinkkaart'
| '/manage/$token'
| '/admin/'
| '/api/auth/$'
| '/api/cron/reminders'
| '/api/rpc/$'
| '/api/webhook/lemonsqueezy'
| '/api/webhook/mollie'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/admin'
| '/account'
| '/contact'
| '/drinkkaart'
| '/forgot-password'
| '/kamp'
| '/login'
| '/privacy'
| '/reset-password'
| '/terms'
| '/admin/drinkkaart'
| '/manage/$token'
| '/admin'
| '/api/auth/$'
| '/api/cron/reminders'
| '/api/rpc/$'
| '/api/webhook/lemonsqueezy'
| '/api/webhook/mollie'
id:
| '__root__'
| '/'
| '/admin'
| '/account'
| '/contact'
| '/drinkkaart'
| '/forgot-password'
| '/kamp'
| '/login'
| '/privacy'
| '/reset-password'
| '/terms'
| '/admin/drinkkaart'
| '/manage/$token'
| '/admin/'
| '/api/auth/$'
| '/api/cron/reminders'
| '/api/rpc/$'
| '/api/webhook/lemonsqueezy'
| '/api/webhook/mollie'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AdminRoute: typeof AdminRoute
AccountRoute: typeof AccountRoute
ContactRoute: typeof ContactRoute
DrinkkaartRoute: typeof DrinkkaartRoute
ForgotPasswordRoute: typeof ForgotPasswordRoute
KampRoute: typeof KampRoute
LoginRoute: typeof LoginRoute
PrivacyRoute: typeof PrivacyRoute
ResetPasswordRoute: typeof ResetPasswordRoute
TermsRoute: typeof TermsRoute
AdminDrinkkaartRoute: typeof AdminDrinkkaartRoute
ManageTokenRoute: typeof ManageTokenRoute
AdminIndexRoute: typeof AdminIndexRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiCronRemindersRoute: typeof ApiCronRemindersRoute
ApiRpcSplatRoute: typeof ApiRpcSplatRoute
ApiWebhookLemonsqueezyRoute: typeof ApiWebhookLemonsqueezyRoute
ApiWebhookMollieRoute: typeof ApiWebhookMollieRoute
}
declare module '@tanstack/react-router' {
@@ -169,6 +260,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof TermsRouteImport
parentRoute: typeof rootRouteImport
}
'/reset-password': {
id: '/reset-password'
path: '/reset-password'
fullPath: '/reset-password'
preLoaderRoute: typeof ResetPasswordRouteImport
parentRoute: typeof rootRouteImport
}
'/privacy': {
id: '/privacy'
path: '/privacy'
@@ -183,6 +281,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/kamp': {
id: '/kamp'
path: '/kamp'
fullPath: '/kamp'
preLoaderRoute: typeof KampRouteImport
parentRoute: typeof rootRouteImport
}
'/forgot-password': {
id: '/forgot-password'
path: '/forgot-password'
fullPath: '/forgot-password'
preLoaderRoute: typeof ForgotPasswordRouteImport
parentRoute: typeof rootRouteImport
}
'/drinkkaart': {
id: '/drinkkaart'
path: '/drinkkaart'
fullPath: '/drinkkaart'
preLoaderRoute: typeof DrinkkaartRouteImport
parentRoute: typeof rootRouteImport
}
'/contact': {
id: '/contact'
path: '/contact'
@@ -190,11 +309,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ContactRouteImport
parentRoute: typeof rootRouteImport
}
'/admin': {
id: '/admin'
path: '/admin'
fullPath: '/admin'
preLoaderRoute: typeof AdminRouteImport
'/account': {
id: '/account'
path: '/account'
fullPath: '/account'
preLoaderRoute: typeof AccountRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
@@ -204,6 +323,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/admin/': {
id: '/admin/'
path: '/admin'
fullPath: '/admin/'
preLoaderRoute: typeof AdminIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/manage/$token': {
id: '/manage/$token'
path: '/manage/$token'
@@ -211,11 +337,18 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ManageTokenRouteImport
parentRoute: typeof rootRouteImport
}
'/api/webhook/lemonsqueezy': {
id: '/api/webhook/lemonsqueezy'
path: '/api/webhook/lemonsqueezy'
fullPath: '/api/webhook/lemonsqueezy'
preLoaderRoute: typeof ApiWebhookLemonsqueezyRouteImport
'/admin/drinkkaart': {
id: '/admin/drinkkaart'
path: '/admin/drinkkaart'
fullPath: '/admin/drinkkaart'
preLoaderRoute: typeof AdminDrinkkaartRouteImport
parentRoute: typeof rootRouteImport
}
'/api/webhook/mollie': {
id: '/api/webhook/mollie'
path: '/api/webhook/mollie'
fullPath: '/api/webhook/mollie'
preLoaderRoute: typeof ApiWebhookMollieRouteImport
parentRoute: typeof rootRouteImport
}
'/api/rpc/$': {
@@ -225,6 +358,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiRpcSplatRouteImport
parentRoute: typeof rootRouteImport
}
'/api/cron/reminders': {
id: '/api/cron/reminders'
path: '/api/cron/reminders'
fullPath: '/api/cron/reminders'
preLoaderRoute: typeof ApiCronRemindersRouteImport
parentRoute: typeof rootRouteImport
}
'/api/auth/$': {
id: '/api/auth/$'
path: '/api/auth/$'
@@ -237,15 +377,22 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AdminRoute: AdminRoute,
AccountRoute: AccountRoute,
ContactRoute: ContactRoute,
DrinkkaartRoute: DrinkkaartRoute,
ForgotPasswordRoute: ForgotPasswordRoute,
KampRoute: KampRoute,
LoginRoute: LoginRoute,
PrivacyRoute: PrivacyRoute,
ResetPasswordRoute: ResetPasswordRoute,
TermsRoute: TermsRoute,
AdminDrinkkaartRoute: AdminDrinkkaartRoute,
ManageTokenRoute: ManageTokenRoute,
AdminIndexRoute: AdminIndexRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiCronRemindersRoute: ApiCronRemindersRoute,
ApiRpcSplatRoute: ApiRpcSplatRoute,
ApiWebhookLemonsqueezyRoute: ApiWebhookLemonsqueezyRoute,
ApiWebhookMollieRoute: ApiWebhookMollieRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View File

@@ -1,3 +1,4 @@
import type { EmailMessage } from "@kk/api/email-queue";
import { QueryClientProvider } from "@tanstack/react-query";
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
@@ -6,6 +7,18 @@ import Loader from "./components/loader";
import { routeTree } from "./routeTree.gen";
import { orpc, queryClient } from "./utils/orpc";
// Minimal CF Queue binding shape needed for type inference
type Queue = {
send(
message: EmailMessage,
options?: { contentType?: string },
): Promise<void>;
sendBatch(
messages: Array<{ body: EmailMessage }>,
options?: { contentType?: string },
): Promise<void>;
};
export const getRouter = () => {
const router = createTanStackRouter({
routeTree,
@@ -26,3 +39,9 @@ declare module "@tanstack/react-router" {
router: ReturnType<typeof getRouter>;
}
}
declare module "@tanstack/router-core" {
interface Register {
server: { requestContext: { emailQueue?: Queue } };
}
}

View File

@@ -1,5 +1,4 @@
import type { QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import {
createRootRouteWithContext,
@@ -9,15 +8,16 @@ import {
} from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { CookieConsent } from "@/components/CookieConsent";
import { SiteHeader } from "@/components/SiteHeader";
import { Toaster } from "@/components/ui/sonner";
import type { orpc } from "@/utils/orpc";
import appCss from "../index.css?url";
const siteUrl = "https://kunstenkamp.be";
const siteTitle = "Kunstenkamp Open Mic Night - Ongedesemd Brood";
const siteTitle = "Kunstenkamp Open Mic Night - Ongedesemd Woord";
const siteDescription =
"Doe mee met de Open Mic Night op 18 april! Een avond vol muziek, theater, dans, woordkunst en meer. Iedereen is welkom - van beginner tot professional.";
"Doe mee met de Open Mic Night op 24 april! Een avond vol muziek, theater, dans, woordkunst en meer. Iedereen is welkom - van beginner tot professional.";
const eventImage = `${siteUrl}/assets/og-image.jpg`;
export interface RouterAppContext {
@@ -128,7 +128,7 @@ export const Route = createRootRouteWithContext<RouterAppContext>()({
name: "Kunstenkamp Open Mic Night",
description:
"Een avond vol muziek, theater, dans, woordkunst en meer. Iedereen is welkom om zijn of haar talent te tonen.",
startDate: "2026-04-18T19:00:00+02:00",
startDate: "2026-04-24T19:30:00+02:00",
eventStatus: "https://schema.org/EventScheduled",
eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode",
organizer: {
@@ -142,7 +142,7 @@ export const Route = createRootRouteWithContext<RouterAppContext>()({
price: "0",
priceCurrency: "EUR",
availability: "https://schema.org/InStock",
validFrom: "2026-03-01T00:00:00+02:00",
validFrom: "2026-03-16T19:00:00+01:00",
},
}),
},
@@ -158,16 +158,17 @@ function RootDocument() {
<head>
<HeadContent />
</head>
<body>
<body className="flex min-h-screen flex-col bg-[#214e51]">
<a
href="#main-content"
className="fixed top-0 left-0 z-[100] -translate-y-full bg-[#d82560] px-4 py-2 text-white transition-transform focus:translate-y-0"
>
Ga naar hoofdinhoud
</a>
<div id="main-content">
<SiteHeader />
<main id="main-content" className="flex-1">
<Outlet />
</div>
</main>
<Toaster />
<CookieConsent />
<TanStackRouterDevtools position="bottom-left" />

View File

@@ -0,0 +1,468 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import {
Calendar,
CreditCard,
Music,
QrCode,
Users,
Wallet,
} from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { QrCodeDisplay } from "@/components/drinkkaart/QrCodeDisplay";
import { TopUpHistory } from "@/components/drinkkaart/TopUpHistory";
import { TopUpModal } from "@/components/drinkkaart/TopUpModal";
import { TransactionHistory } from "@/components/drinkkaart/TransactionHistory";
import { authClient } from "@/lib/auth-client";
import { formatCents } from "@/lib/drinkkaart";
import { client, orpc } from "@/utils/orpc";
interface AccountSearch {
topup?: string;
welkom?: string;
}
export const Route = createFileRoute("/account")({
validateSearch: (search: Record<string, unknown>): AccountSearch => ({
topup: typeof search.topup === "string" ? search.topup : undefined,
welkom: typeof search.welkom === "string" ? search.welkom : undefined,
}),
component: AccountPage,
});
function PaymentBadge({ status }: { status: string | null }) {
if (status === "paid") {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-green-500/20 px-2 py-0.5 font-medium text-green-300 text-xs">
<svg
className="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
Betaald
</span>
);
}
if (status === "extra_payment_pending") {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-yellow-500/20 px-2 py-0.5 font-medium text-xs text-yellow-300">
Extra betaling verwacht
</span>
);
}
return (
<span className="inline-flex items-center gap-1 rounded-full bg-orange-500/20 px-2 py-0.5 font-medium text-orange-300 text-xs">
In behandeling
</span>
);
}
function AccountPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const search = Route.useSearch();
const [showQr, setShowQr] = useState(false);
const [showTopUp, setShowTopUp] = useState(false);
const checkoutMutation = useMutation({
mutationFn: async (token: string) => {
const result = await client.getCheckoutUrl({
token,
redirectUrl: `${window.location.origin}/account?topup=success`,
});
return result;
},
onSuccess: (data) => {
window.location.href = data.checkoutUrl;
},
onError: () => {
toast.error("Er is iets misgegaan bij het aanmaken van de betaling");
},
});
const sessionQuery = useQuery({
queryKey: ["session"],
queryFn: () => authClient.getSession(),
});
// Client-side auth check: redirect to login if not authenticated
useEffect(() => {
if (!sessionQuery.isLoading && !sessionQuery.data?.data?.user) {
navigate({ to: "/login" });
}
}, [sessionQuery.isLoading, sessionQuery.data, navigate]);
const drinkkaartQuery = useQuery(
orpc.drinkkaart.getMyDrinkkaart.queryOptions(),
);
const registrationQuery = useQuery(orpc.getMyRegistration.queryOptions());
// Handle topup=success redirect (from Lemon Squeezy returning to /account)
useEffect(() => {
if (search.topup === "success") {
toast.success("Oplading geslaagd! Je saldo is bijgewerkt.");
queryClient.invalidateQueries({
queryKey: orpc.drinkkaart.getMyDrinkkaart.key(),
});
const url = new URL(window.location.href);
url.searchParams.delete("topup");
window.history.replaceState({}, "", url.toString());
}
}, [search.topup, queryClient]);
// Silent background call to claim any pending registration fee credit.
// This catches cases where: webhook didn't fire/failed, or user signed up after paying.
// No UI feedback needed — if there's nothing to claim, it does nothing.
useEffect(() => {
client.claimRegistrationCredit().catch((err) => {
console.error("claimRegistrationCredit failed:", err);
});
}, []);
// Welcome toast after fresh signup
useEffect(() => {
if (search.welkom === "1") {
toast.success("Welkom! Je account is aangemaakt.");
const url = new URL(window.location.href);
url.searchParams.delete("welkom");
window.history.replaceState({}, "", url.toString());
}
}, [search.welkom]);
const user = sessionQuery.data?.data?.user as
| { name?: string; email?: string }
| undefined;
const registration = registrationQuery.data;
const drinkkaart = drinkkaartQuery.data;
const isLoading =
sessionQuery.isLoading ||
drinkkaartQuery.isLoading ||
registrationQuery.isLoading;
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#214e51]">
<div className="h-10 w-10 animate-spin rounded-full border-2 border-white/20 border-t-white" />
</div>
);
}
return (
<div>
<main className="mx-auto max-w-5xl px-4 py-12">
{/* Page header */}
<div className="mb-8">
<h1 className="mb-1 font-['Intro',sans-serif] text-4xl text-white">
Mijn Account
</h1>
<p className="text-white/50">
{user?.name && (
<span className="mr-2 font-medium text-white/70">
{user.name}
</span>
)}
{user?.email}
</p>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* ── Left column: Registration ── */}
<section>
<h2 className="mb-3 flex items-center gap-2 font-['Intro',sans-serif] text-lg text-white/80">
<Calendar className="h-4 w-4 shrink-0 text-white/40" />
Mijn Inschrijving
</h2>
{registration ? (
<div className="rounded-2xl border border-white/10 bg-white/5 p-6">
{/* Type badge */}
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
{registration.registrationType === "performer" ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-amber-500/20 px-3 py-1 font-medium text-amber-300 text-sm">
<Music className="h-3.5 w-3.5" />
Artiest
</span>
) : (
<span className="inline-flex items-center gap-1.5 rounded-full bg-teal-500/20 px-3 py-1 font-medium text-sm text-teal-300">
<Users className="h-3.5 w-3.5" />
Bezoeker
</span>
)}
</div>
{/* Only show a payment badge when there's actually something to pay:
watchers always have a drinkcard fee; anyone with a gift amount does too.
Performers with no gift have nothing to pay → hide the badge. */}
{(registration.registrationType === "watcher" ||
(registration.giftAmount ?? 0) > 0) && (
<PaymentBadge status={registration.paymentStatus} />
)}
</div>
{/* Name */}
<p className="mb-1 font-medium text-lg text-white">
{registration.firstName} {registration.lastName}
</p>
{/* Art form (performer only) */}
{registration.registrationType === "performer" &&
registration.artForm && (
<p className="mb-1 text-sm text-white/60">
Kunstvorm:{" "}
<span className="text-white/80">
{registration.artForm}
</span>
</p>
)}
{/* Guests (watcher only) */}
{registration.registrationType === "watcher" &&
registration.guests.length > 0 && (
<p className="mb-1 text-sm text-white/60">
{registration.guests.length + 1} personen (jij +{" "}
{registration.guests.length} gast
{registration.guests.length > 1 ? "en" : ""})
</p>
)}
{/* Drink card value */}
{registration.registrationType === "watcher" &&
(registration.drinkCardValue ?? 0) > 0 && (
<p className="mb-1 text-sm text-white/60">
Drinkkaart:{" "}
<span className="text-white/80">
{registration.drinkCardValue}
</span>
</p>
)}
{/* Gift */}
{(registration.giftAmount ?? 0) > 0 && (
<p className="mb-1 text-sm text-white/60">
Gift:{" "}
<span className="text-white/80">
{(registration.giftAmount ?? 0) / 100}
</span>
</p>
)}
{/* Date */}
<p className="mt-3 text-white/40 text-xs">
Ingeschreven op{" "}
{new Date(registration.createdAt).toLocaleDateString(
"nl-BE",
{
day: "numeric",
month: "long",
year: "numeric",
},
)}
</p>
{/* Action */}
{registration.managementToken && (
<div className="mt-5">
<Link
to="/manage/$token"
params={{ token: registration.managementToken }}
className="inline-flex items-center gap-2 rounded-lg bg-white/10 px-4 py-2 text-sm text-white transition-colors hover:bg-white/20"
>
Beheer inschrijving
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"
/>
</svg>
</Link>
</div>
)}
{/* Pay now CTA — shown for watchers with pending payment */}
{registration.registrationType === "watcher" &&
registration.paymentStatus === "pending" &&
registration.managementToken && (
<div className="mt-5 rounded-lg border border-teal-400/30 bg-teal-400/10 p-4">
<p className="mb-3 text-sm text-white/80">
Je inschrijving is bevestigd maar de betaling staat nog
open. Betaal nu om je Drinkkaart te activeren.
</p>
<button
type="button"
disabled={checkoutMutation.isPending}
onClick={() =>
checkoutMutation.mutate(
registration.managementToken ?? "",
)
}
className="inline-flex items-center gap-2 rounded-lg bg-white px-4 py-2 font-semibold text-[#214e51] text-sm transition-colors hover:bg-white/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{checkoutMutation.isPending ? (
<>
<svg
className="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Laden...
</>
) : (
<>
<Wallet className="h-4 w-4" />
Betaal nu
</>
)}
</button>
</div>
)}
</div>
) : (
<div className="rounded-2xl border border-white/10 bg-white/5 p-6">
<p className="mb-3 text-sm text-white/60">
We vonden geen actieve inschrijving voor dit e-mailadres.
</p>
<a
href="/#registration"
className="inline-flex items-center gap-2 rounded-lg bg-white/10 px-4 py-2 text-sm text-white transition-colors hover:bg-white/20"
>
Inschrijven voor het evenement
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"
/>
</svg>
</a>
</div>
)}
</section>
{/* ── Right column: Drinkkaart ── */}
<section>
<h2 className="mb-3 flex items-center gap-2 font-['Intro',sans-serif] text-lg text-white/80">
<CreditCard className="h-4 w-4 shrink-0 text-white/40" />
Mijn Drinkkaart
</h2>
{/* Balance card */}
<div className="mb-4 rounded-2xl border border-white/10 bg-white/5 p-6">
<p className="mb-1 text-sm text-white/50">Huidig saldo</p>
<p className="mb-4 font-['Intro',sans-serif] text-5xl text-white">
{drinkkaart ? formatCents(drinkkaart.balance) : "—"}
</p>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() => setShowQr((v) => !v)}
className="inline-flex items-center gap-2 rounded-lg border border-white/30 bg-transparent px-4 py-2 text-sm text-white transition-colors hover:bg-white/10"
>
<QrCode className="h-4 w-4" />
{showQr ? "Verberg QR-code" : "Toon QR-code"}
</button>
<button
type="button"
onClick={() => setShowTopUp(true)}
className="inline-flex items-center gap-2 rounded-lg bg-white px-4 py-2 font-semibold text-[#214e51] text-sm transition-colors hover:bg-white/90"
>
<Wallet className="h-4 w-4" />
Opladen
</button>
</div>
</div>
{/* QR code */}
{showQr && (
<div className="mb-4 flex justify-center rounded-2xl border border-white/10 bg-white/5 p-6">
<QrCodeDisplay />
</div>
)}
{/* Top-up history */}
<section className="mb-6">
<h3 className="mb-2 font-medium text-sm text-white/50 uppercase tracking-wider">
Opladingen
</h3>
{drinkkaart ? (
<TopUpHistory
topups={
drinkkaart.topups as Parameters<
typeof TopUpHistory
>[0]["topups"]
}
/>
) : (
<p className="text-sm text-white/40">Laden...</p>
)}
</section>
{/* Transaction history */}
<section>
<h3 className="mb-2 font-medium text-sm text-white/50 uppercase tracking-wider">
Transacties
</h3>
{drinkkaart ? (
<TransactionHistory
transactions={
drinkkaart.transactions as Parameters<
typeof TransactionHistory
>[0]["transactions"]
}
/>
) : (
<p className="text-sm text-white/40">Laden...</p>
)}
</section>
</section>
</div>
</main>
{showTopUp && <TopUpModal onClose={() => setShowTopUp(false)} />}
</div>
);
}

View File

@@ -1,809 +0,0 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import {
createFileRoute,
Link,
redirect,
useNavigate,
} from "@tanstack/react-router";
import {
Check,
ChevronDown,
ChevronsUpDown,
ChevronUp,
Clipboard,
ClipboardCheck,
Download,
LogOut,
Search,
Users,
X,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { orpc } from "@/utils/orpc";
export const Route = createFileRoute("/admin")({
component: AdminPage,
beforeLoad: async () => {
const session = await authClient.getSession();
if (!session.data?.user) {
throw redirect({ to: "/login" });
}
const user = session.data.user as { role?: string };
if (user.role !== "admin") {
throw redirect({ to: "/login" });
}
},
});
function AdminPage() {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [registrationType, setRegistrationType] = useState<
"performer" | "watcher" | ""
>("");
const [artForm, setArtForm] = useState("");
const [fromDate, setFromDate] = useState("");
const [toDate, setToDate] = useState("");
const [page, setPage] = useState(1);
const pageSize = 20;
const [copiedId, setCopiedId] = useState<string | null>(null);
const handleCopyManageUrl = (token: string, id: string) => {
const url = `${window.location.origin}/manage/${token}`;
navigator.clipboard.writeText(url).then(() => {
setCopiedId(id);
toast.success("Beheerlink gekopieerd");
setTimeout(() => setCopiedId(null), 2000);
});
};
type SortKey =
| "naam"
| "email"
| "type"
| "details"
| "gasten"
| "gift"
| "betaling"
| "datum";
type SortDir = "asc" | "desc";
const [sortKey, setSortKey] = useState<SortKey>("datum");
const [sortDir, setSortDir] = useState<SortDir>("desc");
const handleSort = (key: SortKey) => {
if (sortKey === key) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortKey(key);
setSortDir("asc");
}
};
const statsQuery = useQuery(orpc.getRegistrationStats.queryOptions());
const registrationsQuery = useQuery(
orpc.getRegistrations.queryOptions({
input: {
search: search || undefined,
registrationType: registrationType || undefined,
artForm: artForm || undefined,
fromDate: fromDate || undefined,
toDate: toDate || undefined,
page,
pageSize,
},
}),
);
const adminRequestsQuery = useQuery(orpc.getAdminRequests.queryOptions());
const exportMutation = useMutation({
...orpc.exportRegistrations.mutationOptions(),
onSuccess: (data: { csv: string; filename: string }) => {
const blob = new Blob([data.csv], { type: "text/csv" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = data.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
},
});
const approveRequestMutation = useMutation({
...orpc.approveAdminRequest.mutationOptions(),
onSuccess: () => {
toast.success("Admin toegang goedgekeurd");
adminRequestsQuery.refetch();
},
onError: (error) => {
toast.error(`Fout: ${error.message}`);
},
});
const rejectRequestMutation = useMutation({
...orpc.rejectAdminRequest.mutationOptions(),
onSuccess: () => {
toast.success("Admin toegang geweigerd");
adminRequestsQuery.refetch();
},
onError: (error) => {
toast.error(`Fout: ${error.message}`);
},
});
const handleSignOut = async () => {
await authClient.signOut();
navigate({ to: "/" });
};
const handleExport = () => {
exportMutation.mutate(undefined);
};
const handleApprove = (requestId: string) => {
approveRequestMutation.mutate({ requestId });
};
const handleReject = (requestId: string) => {
rejectRequestMutation.mutate({ requestId });
};
const stats = statsQuery.data;
const registrations = registrationsQuery.data?.data ?? [];
const pagination = registrationsQuery.data?.pagination;
const adminRequests = adminRequestsQuery.data ?? [];
const pendingRequests = adminRequests.filter((r) => r.status === "pending");
const performerCount =
stats?.byType.find((t) => t.registrationType === "performer")?.count ?? 0;
const watcherCount =
stats?.byType.find((t) => t.registrationType === "watcher")?.count ?? 0;
// Calculate total attendees including guests
const totalRegistrations = registrationsQuery.data?.data ?? [];
const watcherWithGuests = totalRegistrations.filter(
(r) => r.registrationType === "watcher" && r.guests,
);
const totalGuestCount = watcherWithGuests.reduce((sum, r) => {
try {
const guests = JSON.parse(r.guests as string);
return sum + (Array.isArray(guests) ? guests.length : 0);
} catch {
return sum;
}
}, 0);
const totalWatcherAttendees = watcherCount + totalGuestCount;
const totalDrinkCardValue = totalRegistrations
.filter((r) => r.registrationType === "watcher")
.reduce((sum, r) => sum + (r.drinkCardValue ?? 0), 0);
const totalGiftRevenue = (stats?.totalGiftRevenue as number | undefined) ?? 0;
const sortedRegistrations = useMemo(() => {
const sorted = [...registrations].sort((a, b) => {
let valA: string | number = 0;
let valB: string | number = 0;
switch (sortKey) {
case "naam":
valA = `${a.firstName} ${a.lastName}`.toLowerCase();
valB = `${b.firstName} ${b.lastName}`.toLowerCase();
break;
case "email":
valA = a.email.toLowerCase();
valB = b.email.toLowerCase();
break;
case "type":
valA = a.registrationType ?? "";
valB = b.registrationType ?? "";
break;
case "details":
valA =
(a.registrationType === "performer"
? a.artForm
: `${a.drinkCardValue ?? 5}`) ?? "";
valB =
(b.registrationType === "performer"
? b.artForm
: `${b.drinkCardValue ?? 5}`) ?? "";
break;
case "gasten": {
const countGuests = (g: string | null) => {
if (!g) return 0;
try {
const p = JSON.parse(g);
return Array.isArray(p) ? p.length : 0;
} catch {
return 0;
}
};
valA = countGuests(a.guests as string | null);
valB = countGuests(b.guests as string | null);
break;
}
case "gift":
valA = a.giftAmount ?? 0;
valB = b.giftAmount ?? 0;
break;
case "betaling":
valA = a.paymentStatus ?? "pending";
valB = b.paymentStatus ?? "pending";
break;
case "datum":
valA = new Date(a.createdAt).getTime();
valB = new Date(b.createdAt).getTime();
break;
}
if (valA < valB) return sortDir === "asc" ? -1 : 1;
if (valA > valB) return sortDir === "asc" ? 1 : -1;
return 0;
});
return sorted;
}, [registrations, sortKey, sortDir]);
const SortIcon = ({ col }: { col: SortKey }) => {
if (sortKey !== col)
return <ChevronsUpDown className="ml-1 inline h-3.5 w-3.5 opacity-40" />;
return sortDir === "asc" ? (
<ChevronUp className="ml-1 inline h-3.5 w-3.5" />
) : (
<ChevronDown className="ml-1 inline h-3.5 w-3.5" />
);
};
const thClass =
"px-4 py-3 text-left font-medium text-xs text-white/60 uppercase tracking-wider cursor-pointer select-none hover:text-white/90 whitespace-nowrap";
const formatCents = (cents: number | null | undefined) => {
if (!cents || cents <= 0) return "-";
const euros = cents / 100;
return `${euros.toFixed(euros % 1 === 0 ? 0 : 2)}`;
};
return (
<div className="min-h-screen bg-[#214e51]">
{/* Header */}
<header className="border-white/10 border-b bg-[#214e51]/95 px-8 py-6">
<div className="mx-auto flex max-w-7xl items-center justify-between">
<div className="flex items-center gap-4">
<Link to="/" className="text-white hover:opacity-80">
Terug naar website
</Link>
<h1 className="font-['Intro',sans-serif] text-3xl text-white">
Admin Dashboard
</h1>
</div>
<Button
onClick={handleSignOut}
variant="outline"
className="border-white/30 bg-transparent text-white hover:bg-white/10"
>
<LogOut className="mr-2 h-4 w-4" />
Uitloggen
</Button>
</div>
</header>
{/* Main Content */}
<main className="mx-auto max-w-7xl p-8">
{/* Pending Admin Requests */}
{pendingRequests.length > 0 && (
<Card className="mb-6 border-yellow-500/30 bg-yellow-500/10">
<CardHeader>
<CardTitle className="font-['Intro',sans-serif] text-xl text-yellow-200">
Openstaande Admin Aanvragen ({pendingRequests.length})
</CardTitle>
<CardDescription className="text-yellow-200/60">
Gebruikers die admin toegang hebben aangevraagd
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{pendingRequests.map((request) => (
<div
key={request.id}
className="flex items-center justify-between rounded-lg border border-yellow-500/20 bg-yellow-500/5 p-4"
>
<div>
<p className="font-medium text-white">
{request.userName}
</p>
<p className="text-sm text-white/60">
{request.userEmail}
</p>
<p className="text-white/40 text-xs">
Aangevraagd:{" "}
{new Date(request.requestedAt).toLocaleDateString(
"nl-BE",
)}
</p>
</div>
<div className="flex gap-2">
<Button
onClick={() => handleApprove(request.id)}
disabled={approveRequestMutation.isPending}
size="sm"
className="bg-green-600 text-white hover:bg-green-700"
>
<Check className="mr-1 h-4 w-4" />
Goedkeuren
</Button>
<Button
onClick={() => handleReject(request.id)}
disabled={rejectRequestMutation.isPending}
size="sm"
variant="outline"
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
>
<X className="mr-1 h-4 w-4" />
Weigeren
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Stats Cards */}
<div className="mb-8 grid grid-cols-1 gap-6 md:grid-cols-4">
<Card className="border-white/10 bg-white/5">
<CardHeader className="pb-2">
<CardDescription className="text-white/60">
Totaal inschrijvingen
</CardDescription>
<CardTitle className="font-['Intro',sans-serif] text-4xl text-white">
{stats?.total ?? 0}
</CardTitle>
</CardHeader>
<CardContent>
<Users className="h-5 w-5 text-white/40" />
</CardContent>
</Card>
<Card className="border-white/10 bg-white/5">
<CardHeader className="pb-2">
<CardDescription className="text-white/60">
Vandaag ingeschreven
</CardDescription>
<CardTitle className="font-['Intro',sans-serif] text-4xl text-white">
{stats?.today ?? 0}
</CardTitle>
</CardHeader>
<CardContent>
<span className="text-sm text-white/40">
Nieuwe registraties vandaag
</span>
</CardContent>
</Card>
<Card className="border-amber-400/20 bg-amber-400/5">
<CardHeader className="pb-2">
<CardDescription className="text-amber-300/70">
Artiesten
</CardDescription>
<CardTitle className="font-['Intro',sans-serif] text-4xl text-amber-300">
{performerCount}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1">
{stats?.byArtForm.slice(0, 4).map((item) => (
<div
key={item.artForm}
className="flex items-center justify-between text-xs"
>
<span className="text-amber-300/70">
{item.artForm || "Onbekend"}
</span>
<span className="text-amber-300">{item.count}</span>
</div>
))}
</div>
</CardContent>
</Card>
<Card className="border-teal-400/20 bg-teal-400/5">
<CardHeader className="pb-2">
<CardDescription className="text-teal-300/70">
Bezoekers
</CardDescription>
<CardTitle className="font-['Intro',sans-serif] text-4xl text-teal-300">
{watcherCount}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1 text-xs">
{totalGuestCount > 0 && (
<div className="flex items-center justify-between">
<span className="text-teal-300/70">Inclusief gasten</span>
<span className="text-teal-300">+{totalGuestCount}</span>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-teal-300/70">Totaal aanwezig</span>
<span className="font-semibold text-teal-300">
{totalWatcherAttendees}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-teal-300/70">Drinkkaart</span>
<span className="text-teal-300">{totalDrinkCardValue}</span>
</div>
</div>
</CardContent>
</Card>
<Card className="border-pink-400/20 bg-pink-400/5">
<CardHeader className="pb-2">
<CardDescription className="text-pink-300/70">
Vrijwillige Gifts
</CardDescription>
<CardTitle className="font-['Intro',sans-serif] text-4xl text-pink-300">
{Math.round(totalGiftRevenue / 100)}
</CardTitle>
</CardHeader>
<CardContent>
<span className="text-sm text-white/40">
Totale gift opbrengst
</span>
</CardContent>
</Card>
</div>
{/* Filters */}
<Card className="mb-6 border-white/10 bg-white/5">
<CardHeader>
<CardTitle className="font-['Intro',sans-serif] text-white text-xl">
Filters
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-5">
<div>
<label
htmlFor="search"
className="mb-2 block text-sm text-white/60"
>
Zoeken
</label>
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-white/40" />
<Input
id="search"
placeholder="Naam of email..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="border-white/20 bg-white/10 pl-10 text-white placeholder:text-white/40"
/>
</div>
</div>
<div>
<label
htmlFor="typeFilter"
className="mb-2 block text-sm text-white/60"
>
Type
</label>
<select
id="typeFilter"
value={registrationType}
onChange={(e) =>
setRegistrationType(
e.target.value as "performer" | "watcher" | "",
)
}
className="w-full rounded-md border border-white/20 bg-white/10 px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-white/20"
>
<option value="" className="bg-[#214e51]">
Alle types
</option>
<option value="performer" className="bg-[#214e51]">
Artiesten
</option>
<option value="watcher" className="bg-[#214e51]">
Bezoekers
</option>
</select>
</div>
<div>
<label
htmlFor="artForm"
className="mb-2 block text-sm text-white/60"
>
Kunstvorm
</label>
<Input
id="artForm"
placeholder="Filter op kunstvorm..."
value={artForm}
onChange={(e) => setArtForm(e.target.value)}
className="border-white/20 bg-white/10 text-white placeholder:text-white/40"
/>
</div>
<div>
<label
htmlFor="fromDate"
className="mb-2 block text-sm text-white/60"
>
Vanaf
</label>
<Input
id="fromDate"
type="date"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
className="border-white/20 bg-white/10 text-white [color-scheme:dark]"
/>
</div>
<div>
<label
htmlFor="toDate"
className="mb-2 block text-sm text-white/60"
>
Tot
</label>
<Input
id="toDate"
type="date"
value={toDate}
onChange={(e) => setToDate(e.target.value)}
className="border-white/20 bg-white/10 text-white [color-scheme:dark]"
/>
</div>
</div>
</CardContent>
</Card>
{/* Export Button */}
<div className="mb-6 flex items-center justify-between">
<p className="text-white/60">
{pagination?.total ?? 0} registraties gevonden
</p>
<Button
onClick={handleExport}
disabled={exportMutation.isPending}
className="bg-white text-[#214e51] hover:bg-white/90"
>
<Download className="mr-2 h-4 w-4" />
{exportMutation.isPending ? "Exporteren..." : "Exporteer CSV"}
</Button>
</div>
{/* Registrations Table */}
<Card className="border-white/10 bg-white/5">
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-white/10 border-b">
<th className={thClass} onClick={() => handleSort("naam")}>
Naam <SortIcon col="naam" />
</th>
<th className={thClass} onClick={() => handleSort("email")}>
Email <SortIcon col="email" />
</th>
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
Telefoon
</th>
<th className={thClass} onClick={() => handleSort("type")}>
Type <SortIcon col="type" />
</th>
<th
className={thClass}
onClick={() => handleSort("details")}
>
Details <SortIcon col="details" />
</th>
<th
className={thClass}
onClick={() => handleSort("gasten")}
>
Gasten <SortIcon col="gasten" />
</th>
<th className={thClass} onClick={() => handleSort("gift")}>
Gift <SortIcon col="gift" />
</th>
<th
className={thClass}
onClick={() => handleSort("betaling")}
>
Betaling <SortIcon col="betaling" />
</th>
<th className={thClass} onClick={() => handleSort("datum")}>
Datum <SortIcon col="datum" />
</th>
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
Link
</th>
</tr>
</thead>
<tbody>
{registrationsQuery.isLoading ? (
<tr>
<td
colSpan={10}
className="px-4 py-8 text-center text-white/60"
>
Laden...
</td>
</tr>
) : sortedRegistrations.length === 0 ? (
<tr>
<td
colSpan={10}
className="px-4 py-8 text-center text-white/60"
>
Geen registraties gevonden
</td>
</tr>
) : (
sortedRegistrations.map((reg) => {
const isPerformer = reg.registrationType === "performer";
const guestCount = (() => {
if (!reg.guests) return 0;
try {
const g = JSON.parse(reg.guests as string);
return Array.isArray(g) ? g.length : 0;
} catch {
return 0;
}
})();
const detailLabel = isPerformer
? reg.artForm || "-"
: `${reg.drinkCardValue ?? 5} drinkkaart`;
const dateLabel = (() => {
try {
return new Date(reg.createdAt).toLocaleDateString(
"nl-BE",
{
day: "2-digit",
month: "2-digit",
year: "numeric",
},
);
} catch {
return "-";
}
})();
return (
<tr
key={reg.id}
className="border-white/5 border-b hover:bg-white/5"
>
<td className="px-4 py-3 font-medium text-white">
{reg.firstName} {reg.lastName}
</td>
<td className="px-4 py-3 text-sm text-white/70">
{reg.email}
</td>
<td className="px-4 py-3 text-sm text-white/60">
{reg.phone || "-"}
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 font-semibold text-xs ${isPerformer ? "bg-amber-400/15 text-amber-300" : "bg-teal-400/15 text-teal-300"}`}
>
{isPerformer ? "Artiest" : "Bezoeker"}
</span>
</td>
<td className="px-4 py-3 text-sm text-white/70">
{detailLabel}
</td>
<td className="px-4 py-3 text-sm text-white/70">
{guestCount > 0
? `${guestCount} gast${guestCount === 1 ? "" : "en"}`
: "-"}
</td>
<td className="px-4 py-3 text-pink-300/70 text-sm">
{formatCents(reg.giftAmount)}
</td>
<td className="px-4 py-3">
{isPerformer ? (
<span className="text-sm text-white/30">-</span>
) : reg.paymentStatus === "paid" ? (
<span className="inline-flex items-center gap-1 font-medium text-green-400 text-sm">
<Check className="h-3.5 w-3.5" />
Betaald
</span>
) : reg.paymentStatus ===
"extra_payment_pending" ? (
<span className="inline-flex items-center gap-1 font-medium text-orange-400 text-sm">
<span className="h-1.5 w-1.5 rounded-full bg-orange-400" />
Extra (
{((reg.paymentAmount ?? 0) / 100).toFixed(0)})
</span>
) : (
<span className="text-sm text-yellow-400">
Open
</span>
)}
</td>
<td className="px-4 py-3 text-sm text-white/50 tabular-nums">
{dateLabel}
</td>
<td className="px-4 py-3">
{reg.managementToken ? (
<button
type="button"
title="Kopieer beheerlink"
onClick={() =>
handleCopyManageUrl(
reg.managementToken as string,
reg.id,
)
}
className="inline-flex items-center justify-center rounded p-1 text-white/40 transition-colors hover:bg-white/10 hover:text-white"
>
{copiedId === reg.id ? (
<ClipboardCheck className="h-4 w-4 text-green-400" />
) : (
<Clipboard className="h-4 w-4" />
)}
</button>
) : (
<span className="text-sm text-white/20"></span>
)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* Pagination */}
{pagination && pagination.totalPages > 1 && (
<div className="mt-6 flex items-center justify-center gap-2">
<Button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
variant="outline"
className="border-white/20 bg-transparent text-white hover:bg-white/10 disabled:opacity-50"
>
Vorige
</Button>
<span className="mx-4 text-white">
Pagina {page} van {pagination.totalPages}
</span>
<Button
onClick={() =>
setPage((p) => Math.min(pagination.totalPages, p + 1))
}
disabled={page === pagination.totalPages}
variant="outline"
className="border-white/20 bg-transparent text-white hover:bg-white/10 disabled:opacity-50"
>
Volgende
</Button>
</div>
)}
</main>
</div>
);
}

View File

@@ -0,0 +1,236 @@
import { useMutation } from "@tanstack/react-query";
import { createFileRoute, Link, redirect } from "@tanstack/react-router";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { AdminTransactionLog } from "@/components/drinkkaart/AdminTransactionLog";
import { DeductionPanel } from "@/components/drinkkaart/DeductionPanel";
import { ManualCreditForm } from "@/components/drinkkaart/ManualCreditForm";
import { QrScanner } from "@/components/drinkkaart/QrScanner";
import { ScanResultCard } from "@/components/drinkkaart/ScanResultCard";
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth-client";
import { formatCents } from "@/lib/drinkkaart";
import { orpc } from "@/utils/orpc";
export const Route = createFileRoute("/admin/drinkkaart")({
component: AdminDrinkkaartPage,
beforeLoad: async () => {
const session = await authClient.getSession();
if (!session.data?.user) {
throw redirect({ to: "/login" });
}
const user = session.data.user as { role?: string };
if (user.role !== "admin") {
throw redirect({ to: "/" });
}
},
});
type ScanState =
| { step: "idle" }
| { step: "scanning" }
| { step: "resolving" }
| {
step: "resolved";
drinkkaartId: string;
userId: string;
userName: string;
userEmail: string;
balance: number;
}
| {
step: "success";
transactionId: string;
userName: string;
balanceAfter: number;
}
| { step: "error"; message: string; retryable: boolean }
| { step: "manual_credit" };
function AdminDrinkkaartPage() {
const [scanState, setScanState] = useState<ScanState>({ step: "idle" });
const resolveQrMutation = useMutation(
orpc.drinkkaart.resolveQrToken.mutationOptions(),
);
const handleScan = useCallback(
(token: string) => {
setScanState({ step: "resolving" });
resolveQrMutation.mutate(
{ token },
{
onSuccess: (data) => {
setScanState({
step: "resolved",
drinkkaartId: data.drinkkaartId,
userId: data.userId,
userName: data.userName,
userEmail: data.userEmail,
balance: data.balance,
});
},
onError: (err: Error) => {
setScanState({
step: "error",
message: err.message ?? "Ongeldig QR-token",
retryable: true,
});
},
},
);
},
[resolveQrMutation],
);
const handleDeductSuccess = (transactionId: string, balanceAfter: number) => {
const userName =
scanState.step === "resolved" ? scanState.userName : "Gebruiker";
const amountDeducted =
scanState.step === "resolved"
? scanState.balance - balanceAfter
: undefined;
toast.success(
`Afschrijving verwerkt${amountDeducted !== undefined ? `${formatCents(amountDeducted)}` : ""}`,
);
setScanState({ step: "success", transactionId, userName, balanceAfter });
};
return (
<div>
{/* Header */}
<header className="border-white/10 border-b bg-[#214e51]/95 px-4 py-4 sm:px-6 sm:py-5">
<div className="mx-auto flex max-w-2xl items-center gap-3 sm:gap-4">
<Link to="/admin" className="text-sm text-white/60 hover:text-white">
Admin
</Link>
<h1 className="font-['Intro',sans-serif] text-white text-xl sm:text-2xl">
Drinkkaart Beheer
</h1>
</div>
</header>
<main className="mx-auto max-w-2xl space-y-6 px-4 py-4 sm:space-y-8 sm:px-6 sm:py-8">
{/* Scan / State machine */}
<div className="rounded-2xl border border-white/10 bg-white/5 p-6">
{scanState.step === "idle" && (
<div className="space-y-4">
<p className="text-sm text-white/60">
Scan een QR-code of laad handmatig op.
</p>
<div className="flex flex-col gap-3 sm:flex-row">
<Button
onClick={() => setScanState({ step: "scanning" })}
className="flex-1 bg-white font-semibold text-[#214e51] hover:bg-white/90"
>
Scan QR
</Button>
<Button
onClick={() => setScanState({ step: "manual_credit" })}
variant="outline"
className="flex-1 border-white/30 bg-transparent text-white hover:bg-white/10"
>
Handmatig opladen
</Button>
</div>
</div>
)}
{scanState.step === "scanning" && (
<QrScanner
onScan={handleScan}
onCancel={() => setScanState({ step: "idle" })}
/>
)}
{scanState.step === "resolving" && (
<div className="py-8 text-center">
<p className="text-white/60">QR-code wordt gecontroleerd...</p>
</div>
)}
{scanState.step === "resolved" && (
<div className="space-y-5">
<ScanResultCard
userName={scanState.userName}
userEmail={scanState.userEmail}
balance={scanState.balance}
/>
<DeductionPanel
drinkkaartId={scanState.drinkkaartId}
userName={scanState.userName}
userEmail={scanState.userEmail}
balance={scanState.balance}
onSuccess={handleDeductSuccess}
onCancel={() => setScanState({ step: "idle" })}
/>
</div>
)}
{scanState.step === "success" && (
<div className="space-y-5">
<div className="rounded-xl border border-green-400/20 bg-green-400/5 p-5 text-center">
<p className="font-semibold text-green-300 text-lg">
Betaling verwerkt
</p>
<p className="mt-1 text-sm text-white/60">
{scanState.userName} nieuw saldo:{" "}
<strong className="text-white">
{formatCents(scanState.balanceAfter)}
</strong>
</p>
</div>
<Button
onClick={() => setScanState({ step: "idle" })}
className="w-full bg-white font-semibold text-[#214e51] hover:bg-white/90"
>
Volgende scan
</Button>
</div>
)}
{scanState.step === "error" && (
<div className="space-y-4">
<div className="rounded-xl border border-red-400/20 bg-red-400/5 p-4">
<p className="font-medium text-red-300">Fout</p>
<p className="mt-1 text-sm text-white/60">
{scanState.message}
</p>
</div>
<div className="flex gap-3">
{scanState.retryable && (
<Button
onClick={() => setScanState({ step: "scanning" })}
className="flex-1 bg-white font-semibold text-[#214e51] hover:bg-white/90"
>
Opnieuw proberen
</Button>
)}
<Button
onClick={() => setScanState({ step: "idle" })}
variant="outline"
className="flex-1 border-white/20 bg-transparent text-white hover:bg-white/10"
>
Annuleren
</Button>
</div>
</div>
)}
{scanState.step === "manual_credit" && (
<ManualCreditForm onDone={() => setScanState({ step: "idle" })} />
)}
</div>
{/* Transaction log */}
<div>
<h2 className="mb-4 font-['Intro',sans-serif] text-white text-xl">
Transactieoverzicht
</h2>
<AdminTransactionLog />
</div>
</main>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
import { runSendReminders } from "@kk/api/routers/index";
import { env } from "@kk/env/server";
import { createFileRoute } from "@tanstack/react-router";
async function handleCronReminders({ request }: { request: Request }) {
const secret = env.CRON_SECRET;
if (!secret) {
return new Response("CRON_SECRET not configured", { status: 500 });
}
// Accept the secret via Authorization header (Bearer) or JSON body
const authHeader = request.headers.get("Authorization");
let providedSecret: string | null = null;
if (authHeader?.startsWith("Bearer ")) {
providedSecret = authHeader.slice(7);
} else {
try {
const body = await request.json();
providedSecret = (body as { secret?: string }).secret ?? null;
} catch {
// ignore parse errors
}
}
if (!providedSecret || providedSecret !== secret) {
return new Response("Unauthorized", { status: 401 });
}
const result = await runSendReminders();
return Response.json(result);
}
export const Route = createFileRoute("/api/cron/reminders")({
server: {
handlers: {
POST: handleCronReminders,
},
},
});

View File

@@ -1,4 +1,5 @@
import { createContext } from "@kk/api/context";
import type { EmailMessage } from "@kk/api/email-queue";
import { appRouter } from "@kk/api/routers/index";
import { OpenAPIHandler } from "@orpc/openapi/fetch";
import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins";
@@ -7,6 +8,18 @@ import { RPCHandler } from "@orpc/server/fetch";
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
import { createFileRoute } from "@tanstack/react-router";
// Minimal CF Queue binding shape — mirrors the declaration in router.tsx / context.ts
type Queue = {
send(
message: EmailMessage,
options?: { contentType?: string },
): Promise<void>;
sendBatch(
messages: Array<{ body: EmailMessage }>,
options?: { contentType?: string },
): Promise<void>;
};
const rpcHandler = new RPCHandler(appRouter, {
interceptors: [
onError((error) => {
@@ -28,16 +41,21 @@ const apiHandler = new OpenAPIHandler(appRouter, {
],
});
async function handle({ request }: { request: Request }) {
const rpcResult = await rpcHandler.handle(request, {
async function handle(ctx: { request: Request; context?: unknown }) {
// emailQueue is threaded in via the fetch wrapper in server.ts
const emailQueue = (ctx.context as { emailQueue?: Queue } | undefined)
?.emailQueue;
const context = await createContext({ req: ctx.request, emailQueue });
const rpcResult = await rpcHandler.handle(ctx.request, {
prefix: "/api/rpc",
context: await createContext({ req: request }),
context,
});
if (rpcResult.response) return rpcResult.response;
const apiResult = await apiHandler.handle(request, {
const apiResult = await apiHandler.handle(ctx.request, {
prefix: "/api/rpc/api-reference",
context: await createContext({ req: request }),
context,
});
if (apiResult.response) return apiResult.response;

View File

@@ -1,111 +0,0 @@
import { createHmac } from "node:crypto";
import { db } from "@kk/db";
import { registration } from "@kk/db/schema";
import { env } from "@kk/env/server";
import { createFileRoute } from "@tanstack/react-router";
import { eq } from "drizzle-orm";
// Webhook payload types
interface LemonSqueezyWebhookPayload {
meta: {
event_name: string;
custom_data?: {
registration_token?: string;
};
};
data: {
id: string;
type: string;
attributes: {
customer_id: number;
order_number: number;
status: string;
};
};
}
function verifyWebhookSignature(
payload: string,
signature: string,
secret: string,
): boolean {
const hmac = createHmac("sha256", secret);
hmac.update(payload);
const digest = hmac.digest("hex");
return signature === digest;
}
async function handleWebhook({ request }: { request: Request }) {
// Get the raw body as text for signature verification
const payload = await request.text();
const signature = request.headers.get("X-Signature");
if (!signature) {
return new Response("Missing signature", { status: 401 });
}
if (!env.LEMON_SQUEEZY_WEBHOOK_SECRET) {
console.error("LEMON_SQUEEZY_WEBHOOK_SECRET not configured");
return new Response("Webhook secret not configured", { status: 500 });
}
// Verify the signature
if (
!verifyWebhookSignature(
payload,
signature,
env.LEMON_SQUEEZY_WEBHOOK_SECRET,
)
) {
return new Response("Invalid signature", { status: 401 });
}
try {
const event: LemonSqueezyWebhookPayload = JSON.parse(payload);
// Only handle order_created events
if (event.meta.event_name !== "order_created") {
return new Response("Event ignored", { status: 200 });
}
const registrationToken = event.meta.custom_data?.registration_token;
if (!registrationToken) {
console.error("No registration token in webhook payload");
return new Response("Missing registration token", { status: 400 });
}
const orderId = event.data.id;
const customerId = String(event.data.attributes.customer_id);
// Update registration in database.
// Covers both "pending" (initial payment) and "extra_payment_pending"
// (delta payment after adding guests to an already-paid registration).
await db
.update(registration)
.set({
paymentStatus: "paid",
paymentAmount: 0, // delta has been settled
lemonsqueezyOrderId: orderId,
lemonsqueezyCustomerId: customerId,
paidAt: new Date(),
})
.where(eq(registration.managementToken, registrationToken));
console.log(
`Payment successful for registration ${registrationToken}, order ${orderId}`,
);
return new Response("OK", { status: 200 });
} catch (error) {
console.error("Webhook processing error:", error);
return new Response("Internal error", { status: 500 });
}
}
export const Route = createFileRoute("/api/webhook/lemonsqueezy")({
server: {
handlers: {
POST: handleWebhook,
},
},
});

View File

@@ -0,0 +1,301 @@
import { randomUUID } from "node:crypto";
import { sendPaymentConfirmationEmail } from "@kk/api/email";
import type { EmailMessage } from "@kk/api/email-queue";
import { creditRegistrationToAccount } from "@kk/api/routers/index";
import { db } from "@kk/db";
import { drinkkaart, drinkkaartTopup, registration } from "@kk/db/schema";
import { user } from "@kk/db/schema/auth";
import { env } from "@kk/env/server";
import { createFileRoute } from "@tanstack/react-router";
import { and, eq } from "drizzle-orm";
// Minimal CF Queue binding shape used to enqueue emails
type EmailQueue = {
send(
message: EmailMessage,
options?: { contentType?: string },
): Promise<void>;
};
// Mollie payment object (relevant fields only)
interface MolliePayment {
id: string;
status: string;
amount: { value: string; currency: string };
customerId?: string;
metadata?: {
registration_token?: string;
type?: string;
drinkkaartId?: string;
userId?: string;
};
}
async function fetchMolliePayment(paymentId: string): Promise<MolliePayment> {
const response = await fetch(
`https://api.mollie.com/v2/payments/${paymentId}`,
{
headers: {
Authorization: `Bearer ${env.MOLLIE_API_KEY}`,
},
},
);
if (!response.ok) {
throw new Error(
`Failed to fetch Mollie payment ${paymentId}: ${response.status}`,
);
}
return response.json() as Promise<MolliePayment>;
}
async function handleWebhook({
request,
context,
}: {
request: Request;
context?: unknown;
}) {
const emailQueue = (context as { emailQueue?: EmailQueue } | undefined)
?.emailQueue;
if (!env.MOLLIE_API_KEY) {
console.error("MOLLIE_API_KEY not configured");
return new Response("Payment provider not configured", { status: 500 });
}
// Mollie sends application/x-www-form-urlencoded with a single "id" field
let paymentId: string | null = null;
try {
const body = await request.text();
const params = new URLSearchParams(body);
paymentId = params.get("id");
} catch {
return new Response("Invalid request body", { status: 400 });
}
if (!paymentId) {
return new Response("Missing payment id", { status: 400 });
}
// Fetch-to-verify: retrieve the actual payment from Mollie to confirm its
// status. A malicious webhook cannot fake a paid status this way.
let payment: MolliePayment;
try {
payment = await fetchMolliePayment(paymentId);
} catch (err) {
console.error("Failed to fetch Mollie payment:", err);
return new Response("Failed to fetch payment", { status: 500 });
}
// Only process paid payments
if (payment.status !== "paid") {
return new Response("Payment status ignored", { status: 200 });
}
const metadata = payment.metadata;
try {
// -------------------------------------------------------------------------
// Branch: Drinkkaart top-up
// -------------------------------------------------------------------------
if (metadata?.type === "drinkkaart_topup") {
const { drinkkaartId, userId } = metadata;
if (!drinkkaartId || !userId) {
console.error(
"Missing drinkkaartId or userId in drinkkaart_topup payment metadata",
);
return new Response("Missing drinkkaart data", { status: 400 });
}
// Amount in cents — Mollie returns e.g. "10.00"; parse to integer cents
const amountCents = Math.round(
Number.parseFloat(payment.amount.value) * 100,
);
// Idempotency: skip if already processed
const existing = await db
.select({ id: drinkkaartTopup.id })
.from(drinkkaartTopup)
.where(eq(drinkkaartTopup.molliePaymentId, payment.id))
.limit(1)
.then((r) => r[0]);
if (existing) {
console.log(
`Drinkkaart topup already processed for payment ${payment.id}`,
);
return new Response("OK", { status: 200 });
}
const card = await db
.select()
.from(drinkkaart)
.where(eq(drinkkaart.id, drinkkaartId))
.limit(1)
.then((r) => r[0]);
if (!card) {
console.error(`Drinkkaart not found: ${drinkkaartId}`);
return new Response("Not found", { status: 404 });
}
const balanceBefore = card.balance;
const balanceAfter = balanceBefore + amountCents;
// Optimistic lock update
const result = await db
.update(drinkkaart)
.set({
balance: balanceAfter,
version: card.version + 1,
updatedAt: new Date(),
})
.where(
and(
eq(drinkkaart.id, drinkkaartId),
eq(drinkkaart.version, card.version),
),
);
if (result.rowsAffected === 0) {
// Return 500 so Mollie retries; idempotency check prevents double-credit
console.error(
`Drinkkaart optimistic lock conflict for ${drinkkaartId}`,
);
return new Response("Conflict — please retry", { status: 500 });
}
await db.insert(drinkkaartTopup).values({
id: randomUUID(),
drinkkaartId,
userId,
amountCents,
balanceBefore,
balanceAfter,
type: "payment",
molliePaymentId: payment.id,
adminId: null,
reason: null,
paidAt: new Date(),
});
console.log(
`Drinkkaart topup successful: drinkkaart=${drinkkaartId}, amount=${amountCents}c, payment=${payment.id}`,
);
return new Response("OK", { status: 200 });
}
// -------------------------------------------------------------------------
// Branch: Registration payment
// -------------------------------------------------------------------------
const registrationToken = metadata?.registration_token;
if (!registrationToken) {
console.error("No registration token in payment metadata");
return new Response("Missing registration token", { status: 400 });
}
// Fetch the registration row
const regRow = await db
.select()
.from(registration)
.where(eq(registration.managementToken, registrationToken))
.limit(1)
.then((r) => r[0]);
if (!regRow) {
console.error(`Registration not found for token ${registrationToken}`);
return new Response("Registration not found", { status: 404 });
}
// Mark the registration as paid
await db
.update(registration)
.set({
paymentStatus: "paid",
paymentAmount: 0,
molliePaymentId: payment.id,
paidAt: new Date(),
})
.where(eq(registration.managementToken, registrationToken));
console.log(
`Payment successful for registration ${registrationToken}, payment ${payment.id}`,
);
// Send payment confirmation email via queue when available, otherwise direct.
const confirmMsg: EmailMessage = {
type: "paymentConfirmation",
to: regRow.email,
firstName: regRow.firstName,
managementToken: regRow.managementToken ?? registrationToken,
drinkCardValue: regRow.drinkCardValue ?? undefined,
giftAmount: regRow.giftAmount ?? undefined,
};
if (emailQueue) {
await emailQueue.send(confirmMsg);
} else {
sendPaymentConfirmationEmail(confirmMsg).catch((err) =>
console.error(
`Failed to send payment confirmation email for ${regRow.email}:`,
err,
),
);
}
// If this is a watcher with a drink card value, try to credit their
// drinkkaart immediately — but only if they already have an account.
if (
regRow.registrationType === "watcher" &&
(regRow.drinkCardValue ?? 0) > 0
) {
const accountUser = await db
.select({ id: user.id })
.from(user)
.where(eq(user.email, regRow.email))
.limit(1)
.then((r) => r[0]);
if (accountUser) {
const creditResult = await creditRegistrationToAccount(
regRow.email,
accountUser.id,
).catch((err) => {
console.error(
`Failed to credit drinkkaart for ${regRow.email} after registration payment:`,
err,
);
return null;
});
if (creditResult?.credited) {
console.log(
`Drinkkaart credited ${creditResult.amountCents}c for user ${accountUser.id} (registration ${registrationToken})`,
);
} else if (creditResult) {
console.log(
`Drinkkaart credit skipped for ${regRow.email}: ${creditResult.status}`,
);
}
} else {
console.log(
`No account for ${regRow.email} — drinkkaart credit deferred until signup`,
);
}
}
return new Response("OK", { status: 200 });
} catch (error) {
console.error("Webhook processing error:", error);
return new Response("Internal error", { status: 500 });
}
}
export const Route = createFileRoute("/api/webhook/mollie")({
server: {
handlers: {
POST: handleWebhook,
},
},
});

View File

@@ -6,8 +6,8 @@ export const Route = createFileRoute("/contact")({
function ContactPage() {
return (
<div className="min-h-screen bg-[#214e51]">
<div className="mx-auto max-w-3xl px-6 py-16">
<div>
<div className="mx-auto max-w-3xl px-6 py-12">
<Link
to="/"
className="link-hover mb-8 inline-block text-white/80 hover:text-white"
@@ -38,10 +38,10 @@ function ContactPage() {
<section className="rounded-lg bg-white/5 p-6">
<h3 className="mb-3 text-white text-xl">Open Mic Night</h3>
<p>Vrijdag 18 april 2026</p>
<p>Aanvang: 19:00 uur</p>
<p>Vrijdag 24 april 2026</p>
<p>Aanvang: 19:30 uur</p>
<p className="mt-2 text-white/60">
Locatie wordt later bekendgemaakt aan geregistreerde deelnemers.
Lange Winkelstraat 5, 2000 Antwerpen
</p>
</section>
@@ -53,6 +53,36 @@ function ContactPage() {
</p>
</section>
<section className="rounded-lg bg-white/5 p-6">
<h3 className="mb-4 text-white text-xl">Partners</h3>
<div className="flex flex-col gap-3 sm:flex-row sm:gap-6">
<a
href="https://ejv.be"
target="_blank"
rel="noopener noreferrer"
className="link-hover text-white/80 hover:text-white"
>
Evangelisch Jeugdverbond (EJV.be)
</a>
<a
href="https://www.vlaanderen.be/cjm/nl"
target="_blank"
rel="noopener noreferrer"
className="link-hover text-white/80 hover:text-white"
>
Vlaanderen Cultuur, Jeugd & Media
</a>
<a
href="https://ichtusantwerpen.com"
target="_blank"
rel="noopener noreferrer"
className="link-hover text-white/80 hover:text-white"
>
Ichtus Antwerpen
</a>
</div>
</section>
<section className="mt-8">
<p className="text-sm text-white/60">
We proberen je e-mail binnen 48 uur te beantwoorden.

View File

@@ -0,0 +1,575 @@
import { useMutation } from "@tanstack/react-query";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { DEDUCTION_PRESETS_CENTS, formatCents } from "@/lib/drinkkaart";
import { orpc } from "@/utils/orpc";
export const Route = createFileRoute("/drinkkaart")({
beforeLoad: async () => {
const session = await authClient.getSession();
if (!session.data?.user) {
throw redirect({ to: "/login" });
}
const user = session.data.user as { role?: string };
if (user.role !== "admin") {
throw redirect({ to: "/" });
}
},
component: DrinkkaartScanPage,
});
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ScanState =
| { step: "scanning" }
| { step: "resolving" }
| {
step: "resolved";
drinkkaartId: string;
userId: string;
userName: string;
userEmail: string;
balance: number;
}
| {
step: "confirming";
drinkkaartId: string;
userName: string;
userEmail: string;
balance: number;
amountCents: number;
}
| {
step: "success";
userName: string;
amountCents: number;
balanceAfter: number;
}
| { step: "error"; message: string };
// ---------------------------------------------------------------------------
// ScanningView — full-screen camera with manual fallback
// ---------------------------------------------------------------------------
function ScanningView({ onScan }: { onScan: (token: string) => void }) {
const videoContainerId = "qr-video-container";
const scannerRef = useRef<unknown>(null);
const [hasError, setHasError] = useState(false);
const [manualToken, setManualToken] = useState("");
const [showManual, setShowManual] = useState(false);
const onScanRef = useRef(onScan);
useEffect(() => {
onScanRef.current = onScan;
}, [onScan]);
useEffect(() => {
if (hasError) return;
let stopped = false;
import("html5-qrcode")
.then(({ Html5Qrcode }) => {
if (stopped) return;
const scanner = new Html5Qrcode(videoContainerId);
scannerRef.current = scanner;
scanner
.start(
{ facingMode: "environment" },
{ fps: 15, qrbox: { width: 240, height: 240 } },
(decodedText: string) => {
scanner
.stop()
.catch(console.error)
.finally(() => {
if (!stopped) onScanRef.current(decodedText);
});
},
() => {},
)
.catch((err: unknown) => {
if (!stopped) {
console.error("Camera start failed:", err);
setHasError(true);
setShowManual(true);
}
});
})
.catch((err) => {
console.error("Failed to load html5-qrcode:", err);
setHasError(true);
setShowManual(true);
});
return () => {
stopped = true;
const s = scannerRef.current as {
stop: () => Promise<void>;
clear: () => Promise<void>;
} | null;
s?.stop()
.catch(() => {})
.finally(() => s?.clear().catch(() => {}));
};
}, [hasError]);
const handleManualSubmit = () => {
const token = manualToken.trim();
if (token) onScan(token);
};
return (
<div className="flex flex-1 flex-col">
{/* Camera — fills all available space above the hint */}
{!hasError && (
<div
id={videoContainerId}
className="[&_#qr-shaded-region]:!border-white/40 flex-1 overflow-hidden bg-black [&>video]:h-full [&>video]:w-full [&>video]:object-cover"
/>
)}
{hasError && (
<div className="flex flex-1 flex-col items-center justify-center px-6">
<div className="w-full max-w-sm rounded-2xl border border-white/10 bg-white/5 p-6 text-center">
<p className="text-white/60">Camera niet beschikbaar.</p>
</div>
</div>
)}
{/* Bottom controls */}
<div className="shrink-0 space-y-3 px-4 pt-4 pb-8">
{showManual ? (
<div className="flex gap-2">
<Input
autoFocus
value={manualToken}
onChange={(e) => setManualToken(e.target.value)}
placeholder="Plak QR token..."
className="border-white/20 bg-white/10 text-white placeholder:text-white/30"
onKeyDown={(e) => e.key === "Enter" && handleManualSubmit()}
/>
<Button
onClick={handleManualSubmit}
disabled={!manualToken.trim()}
className="shrink-0 bg-white text-[#214e51] hover:bg-white/90"
>
OK
</Button>
</div>
) : (
<div className="flex items-center justify-between">
<p className="text-sm text-white/40">
Richt de camera op de QR-code
</p>
<button
type="button"
onClick={() => setShowManual(true)}
className="text-sm text-white/30 hover:text-white/60"
>
Handmatig
</button>
</div>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
function DrinkkaartScanPage() {
const [scanState, setScanState] = useState<ScanState>({ step: "scanning" });
const [customCents, setCustomCents] = useState("");
const [useCustom, setUseCustom] = useState(false);
const [selectedCents, setSelectedCents] = useState<number>(
DEDUCTION_PRESETS_CENTS[1],
);
const successTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Auto-return to scanner 1.5s after success
useEffect(() => {
if (scanState.step === "success") {
successTimerRef.current = setTimeout(() => {
setScanState({ step: "scanning" });
setCustomCents("");
setUseCustom(false);
setSelectedCents(DEDUCTION_PRESETS_CENTS[1]);
}, 1500);
}
return () => {
if (successTimerRef.current) clearTimeout(successTimerRef.current);
};
}, [scanState.step]);
// --- Mutations ---
const resolveQrMutation = useMutation(
orpc.drinkkaart.resolveQrToken.mutationOptions(),
);
const deductMutation = useMutation(
orpc.drinkkaart.deductBalance.mutationOptions(),
);
// --- Handlers ---
const handleScan = useCallback(
(token: string) => {
setScanState({ step: "resolving" });
resolveQrMutation.mutate(
{ token },
{
onSuccess: (data) => {
setScanState({
step: "resolved",
drinkkaartId: data.drinkkaartId,
userId: data.userId,
userName: data.userName,
userEmail: data.userEmail,
balance: data.balance,
});
},
onError: (err: Error) => {
setScanState({
step: "error",
message: err.message ?? "Ongeldig QR-token",
});
},
},
);
},
[resolveQrMutation],
);
const handlePresetSelect = (cents: number) => {
setSelectedCents(cents);
setUseCustom(false);
};
const getAmountCents = () => {
if (useCustom) {
return Math.round((Number.parseFloat(customCents || "0") || 0) * 100);
}
return selectedCents;
};
const handleDeductTap = () => {
if (scanState.step !== "resolved") return;
const amountCents = getAmountCents();
if (!amountCents || amountCents < 1 || amountCents > scanState.balance)
return;
setScanState({
step: "confirming",
drinkkaartId: scanState.drinkkaartId,
userName: scanState.userName,
userEmail: scanState.userEmail,
balance: scanState.balance,
amountCents,
});
};
const handleConfirm = () => {
if (scanState.step !== "confirming") return;
deductMutation.mutate(
{
drinkkaartId: scanState.drinkkaartId,
amountCents: scanState.amountCents,
},
{
onSuccess: (data) => {
toast.success(`${formatCents(scanState.amountCents)} afgeschreven`);
setScanState({
step: "success",
userName: scanState.userName,
amountCents: scanState.amountCents,
balanceAfter: data.balanceAfter,
});
deductMutation.reset();
},
onError: (err: Error) => {
setScanState({
step: "error",
message: err.message ?? "Fout bij afschrijving",
});
deductMutation.reset();
},
},
);
};
const restartScanner = () => {
setScanState({ step: "scanning" });
setCustomCents("");
setUseCustom(false);
setSelectedCents(DEDUCTION_PRESETS_CENTS[1]);
deductMutation.reset();
};
// Determine the display amount for the deduct button (only in resolved step)
const resolvedAmountCents =
scanState.step === "resolved" ? getAmountCents() : 0;
const resolvedBalance = scanState.step === "resolved" ? scanState.balance : 0;
const deductIsValid =
scanState.step === "resolved" &&
Number.isInteger(resolvedAmountCents) &&
resolvedAmountCents >= 1 &&
resolvedAmountCents <= resolvedBalance;
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<div className="flex h-dvh flex-col bg-[#214e51]">
{/* ------------------------------------------------------------------ */}
{/* Header */}
{/* ------------------------------------------------------------------ */}
<header className="flex shrink-0 items-center justify-between border-white/10 border-b px-4 py-3">
<h1 className="font-['Intro',sans-serif] text-lg text-white">
Drinkkaart
</h1>
{/* Only show the admin link when not mid-flow */}
{(scanState.step === "scanning" || scanState.step === "error") && (
<a
href="/admin/drinkkaart"
className="rounded-lg px-3 py-1.5 text-sm text-white/50 hover:bg-white/10 hover:text-white"
>
Beheer
</a>
)}
{scanState.step === "resolved" && (
<button
type="button"
onClick={restartScanner}
className="rounded-lg px-3 py-1.5 text-sm text-white/50 hover:bg-white/10 hover:text-white"
>
Annuleer
</button>
)}
{scanState.step === "confirming" && (
<button
type="button"
onClick={() => {
// Go back to resolved state
const s = scanState;
setScanState({
step: "resolved",
drinkkaartId: s.drinkkaartId,
userId: "",
userName: s.userName,
userEmail: s.userEmail,
balance: s.balance,
});
}}
className="rounded-lg px-3 py-1.5 text-sm text-white/50 hover:bg-white/10 hover:text-white"
>
Terug
</button>
)}
</header>
{/* ------------------------------------------------------------------ */}
{/* Main content area */}
{/* ------------------------------------------------------------------ */}
<main className="flex flex-1 flex-col overflow-hidden">
{/* ---- SCANNING ---- */}
{scanState.step === "scanning" && <ScanningView onScan={handleScan} />}
{/* ---- RESOLVING ---- */}
{scanState.step === "resolving" && (
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-6">
<div className="h-12 w-12 animate-spin rounded-full border-4 border-white/20 border-t-white" />
<p className="text-white/60">Controleren...</p>
</div>
)}
{/* ---- RESOLVED: show person + deduct controls ---- */}
{scanState.step === "resolved" && (
<div className="flex flex-1 flex-col overflow-y-auto">
{/* Person card */}
<div className="mx-4 mt-4 rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-lg text-white">
{scanState.userName}
</p>
<p className="truncate text-sm text-white/50">
{scanState.userEmail}
</p>
</div>
<div className="ml-4 shrink-0 text-right">
<p className="text-white/40 text-xs">Saldo</p>
<p className="font-['Intro',sans-serif] text-3xl text-white">
{formatCents(scanState.balance)}
</p>
</div>
</div>
</div>
{/* Amount selection */}
<div className="mx-4 mt-4 space-y-4">
<p className="text-sm text-white/60">Bedrag kiezen</p>
{/* Preset grid */}
<div className="grid grid-cols-4 gap-2">
{DEDUCTION_PRESETS_CENTS.map((cents) => (
<button
key={cents}
type="button"
onClick={() => handlePresetSelect(cents)}
className={`rounded-xl border py-4 font-semibold text-sm transition-colors ${
!useCustom && selectedCents === cents
? "border-white bg-white text-[#214e51]"
: "border-white/20 text-white hover:border-white/50 active:bg-white/10"
}`}
>
{formatCents(cents)}
</button>
))}
</div>
{/* Custom amount */}
<div className="relative">
<span className="absolute top-1/2 left-3.5 -translate-y-1/2 text-white/50">
</span>
<Input
type="number"
min="0.01"
step="0.01"
placeholder="Eigen bedrag"
value={customCents}
onChange={(e) => {
setCustomCents(e.target.value);
setUseCustom(true);
}}
onFocus={() => setUseCustom(true)}
className="border-white/20 bg-white/10 py-4 pl-8 text-base text-white placeholder:text-white/30"
/>
</div>
{/* Insufficient balance warning */}
{resolvedAmountCents > resolvedBalance && (
<p className="text-red-400 text-sm">
Onvoldoende saldo ({formatCents(resolvedBalance)})
</p>
)}
</div>
{/* Deduct button — sticky at bottom */}
<div className="mt-auto px-4 pt-4 pb-8">
<Button
onClick={handleDeductTap}
disabled={!deductIsValid}
className="h-14 w-full bg-white font-semibold text-[#214e51] text-lg hover:bg-white/90 disabled:opacity-40"
>
{deductIsValid
? `Afschrijven — ${formatCents(resolvedAmountCents)}`
: "Kies een bedrag"}
</Button>
</div>
</div>
)}
{/* ---- CONFIRMING ---- */}
{scanState.step === "confirming" && (
<div className="flex flex-1 flex-col items-center justify-center px-6">
<div className="w-full max-w-sm space-y-6">
{/* Summary card */}
<div className="rounded-2xl border border-white/10 bg-white/5 p-6 text-center">
<p className="text-sm text-white/60">Afschrijving voor</p>
<p className="mt-1 font-semibold text-white text-xl">
{scanState.userName}
</p>
<p className="mt-4 font-['Intro',sans-serif] text-5xl text-white">
{formatCents(scanState.amountCents)}
</p>
<p className="mt-3 text-sm text-white/50">
Nieuw saldo:{" "}
<span className="text-white">
{formatCents(scanState.balance - scanState.amountCents)}
</span>
</p>
</div>
{deductMutation.isError && (
<p className="text-center text-red-400 text-sm">
{(deductMutation.error as Error)?.message ??
"Fout bij afschrijving"}
</p>
)}
{/* Confirm button */}
<Button
onClick={handleConfirm}
disabled={deductMutation.isPending}
className="h-14 w-full bg-white font-semibold text-[#214e51] text-lg hover:bg-white/90 disabled:opacity-50"
>
{deductMutation.isPending ? "Verwerken..." : "Bevestigen"}
</Button>
</div>
</div>
)}
{/* ---- SUCCESS ---- */}
{scanState.step === "success" && (
<div className="flex flex-1 flex-col items-center justify-center px-6">
<div className="w-full max-w-sm space-y-4 text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-green-400/20">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
className="h-8 w-8 text-green-400"
aria-label="Betaling geslaagd"
>
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<div>
<p className="font-semibold text-green-300 text-xl">
{formatCents(scanState.amountCents)} afgeschreven
</p>
<p className="mt-1 text-sm text-white/60">
{scanState.userName} nieuw saldo:{" "}
<strong className="text-white">
{formatCents(scanState.balanceAfter)}
</strong>
</p>
</div>
<p className="text-white/30 text-xs">
Volgende scan wordt gestart
</p>
</div>
</div>
)}
{/* ---- ERROR ---- */}
{scanState.step === "error" && (
<div className="flex flex-1 flex-col items-center justify-center px-6">
<div className="w-full max-w-sm space-y-5">
<div className="rounded-2xl border border-red-400/20 bg-red-400/5 p-5 text-center">
<p className="font-semibold text-lg text-red-300">Fout</p>
<p className="mt-2 text-sm text-white/60">
{scanState.message}
</p>
</div>
<Button
onClick={restartScanner}
className="h-14 w-full bg-white font-semibold text-[#214e51] text-lg hover:bg-white/90"
>
Opnieuw scannen
</Button>
</div>
</div>
)}
</main>
</div>
);
}

View File

@@ -0,0 +1,118 @@
import { useMutation } from "@tanstack/react-query";
import { createFileRoute, Link } from "@tanstack/react-router";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
export const Route = createFileRoute("/forgot-password")({
component: ForgotPasswordPage,
});
function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
const mutation = useMutation({
mutationFn: async (email: string) => {
const result = await authClient.requestPasswordReset({
email,
redirectTo: "/reset-password",
});
if (result.error) {
throw new Error(result.error.message);
}
return result.data;
},
onSuccess: () => {
setSent(true);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutation.mutate(email);
};
return (
<div className="flex min-h-[calc(100dvh-2.75rem)] items-center justify-center overflow-y-auto px-4 py-8 sm:min-h-[calc(100dvh-3.5rem)]">
<Card className="my-auto w-full max-w-md border-white/10 bg-white/5">
<CardHeader className="text-center">
<CardTitle className="font-['Intro',sans-serif] text-3xl text-white">
Wachtwoord vergeten
</CardTitle>
<CardDescription className="text-white/60">
{sent
? "Controleer je inbox"
: "Vul je e-mailadres in en we sturen je een link"}
</CardDescription>
</CardHeader>
<CardContent>
{sent ? (
<div className="space-y-4">
<div className="rounded-lg border border-white/10 bg-white/5 p-5 text-center">
<p className="text-sm text-white/80 leading-relaxed">
Als er een account bestaat voor{" "}
<span className="font-medium text-white">{email}</span>, is er
een e-mail verstuurd met een link om je wachtwoord opnieuw in
te stellen. De link is 1 uur geldig.
</p>
</div>
<Link
to="/login"
className="block text-center text-sm text-white/60 hover:text-white"
>
Terug naar inloggen
</Link>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="email"
className="mb-2 block text-sm text-white/60"
>
E-mailadres
</label>
<Input
id="email"
type="email"
placeholder="jouw@email.be"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="border-white/20 bg-white/10 text-white placeholder:text-white/40"
/>
</div>
{mutation.isError && (
<p className="text-red-400 text-sm">{mutation.error.message}</p>
)}
<Button
type="submit"
disabled={mutation.isPending}
className="w-full bg-white text-[#214e51] hover:bg-white/90"
>
{mutation.isPending ? "Bezig..." : "Stuur reset-link"}
</Button>
<div className="text-center">
<Link
to="/login"
className="text-sm text-white/60 hover:text-white"
>
Terug naar inloggen
</Link>
</div>
</form>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,25 +1,172 @@
import { createFileRoute } from "@tanstack/react-router";
import ArtForms from "@/components/homepage/ArtForms";
import { createFileRoute, Link } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import EventRegistrationForm from "@/components/homepage/EventRegistrationForm";
import Footer from "@/components/homepage/Footer";
import Hero from "@/components/homepage/Hero";
import HoeInschrijven from "@/components/homepage/HoeInschrijven";
import Info from "@/components/homepage/Info";
const KAMP_BANNER_KEY = "kk_kamp_banner_dismissed";
export const Route = createFileRoute("/")({
component: HomePage,
});
function HomePage() {
function KampBanner({ onDismiss }: { onDismiss: () => void }) {
const [visible, setVisible] = useState(false);
useEffect(() => {
if (!document.getElementById("kamp-banner-font")) {
const el = document.createElement("link");
el.id = "kamp-banner-font";
el.rel = "stylesheet";
el.href =
"https://fonts.googleapis.com/css2?family=Special+Elite&display=swap";
document.head.appendChild(el);
}
const t = setTimeout(() => setVisible(true), 600);
return () => clearTimeout(t);
}, []);
const handleDismiss = () => {
setVisible(false);
setTimeout(onDismiss, 300);
};
return (
<div className="relative">
<main className="relative">
<Hero />
<Info />
<ArtForms />
<EventRegistrationForm />
<Footer />
</main>
</div>
<header
style={{
position: "fixed",
bottom: "1.25rem",
left: "50%",
transform: visible
? "translateX(-50%) translateY(0)"
: "translateX(-50%) translateY(120%)",
transition: "transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1)",
zIndex: 50,
width: "min(calc(100vw - 2rem), 480px)",
}}
>
<div
style={{
background: "#ede4c8",
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)' opacity='0.06'/%3E%3C/svg%3E")`,
border: "1px solid #12100e",
outline: "3px solid rgba(18,16,14,0.1)",
outlineOffset: "3px",
borderRadius: 0,
padding: "12px 14px 14px",
boxShadow: "3px 5px 20px rgba(0,0,0,0.25)",
}}
>
{/* Top double rule */}
<div style={{ marginBottom: "10px" }}>
<div
style={{
height: "2px",
background: "#12100e",
marginBottom: "3px",
}}
/>
<div style={{ height: "1px", background: "rgba(18,16,14,0.35)" }} />
</div>
<div style={{ display: "flex", alignItems: "flex-start", gap: "10px" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<p
style={{
fontFamily: "'Special Elite', cursive",
fontSize: "8px",
letterSpacing: "0.3em",
textTransform: "uppercase",
color: "rgba(18,16,14,0.45)",
marginBottom: "6px",
}}
>
Aankondiging · Zomerkamp 2026
</p>
<p
style={{
fontFamily: "'Playfair Display', serif",
fontWeight: 700,
fontSize: "14px",
color: "#12100e",
lineHeight: 1.35,
marginBottom: "12px",
}}
>
Inschrijvingen voor de zomerkampen zijn open!
</p>
<Link
to="/kamp"
style={{
display: "inline-block",
fontFamily: "'Special Elite', cursive",
fontSize: "9px",
letterSpacing: "0.22em",
textTransform: "uppercase",
textDecoration: "none",
color: "#ede4c8",
background: "#12100e",
border: "1px solid rgba(18,16,14,0.4)",
outline: "2px solid rgba(18,16,14,0.08)",
outlineOffset: "3px",
padding: "7px 12px 6px",
}}
>
Schrijf je in
</Link>
</div>
<button
type="button"
onClick={handleDismiss}
aria-label="Sluit deze melding"
style={{
background: "none",
border: "none",
color: "rgba(18,16,14,0.3)",
cursor: "pointer",
padding: "2px",
flexShrink: 0,
fontSize: "18px",
lineHeight: 1,
fontFamily: "'Special Elite', cursive",
}}
>
×
</button>
</div>
</div>
</header>
);
}
function HomePage() {
const [showBanner, setShowBanner] = useState(false);
useEffect(() => {
if (!localStorage.getItem(KAMP_BANNER_KEY)) {
setShowBanner(true);
}
}, []);
const dismissBanner = () => {
localStorage.setItem(KAMP_BANNER_KEY, "1");
setShowBanner(false);
};
return (
<>
<div className="relative">
<main className="relative">
<Hero />
<Info />
<HoeInschrijven />
<EventRegistrationForm />
<Footer />
</main>
</div>
{showBanner && <KampBanner onDismiss={dismissBanner} />}
</>
);
}

View File

@@ -0,0 +1,473 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useEffect, useState } from "react";
const KAMP_URL = "https://ejv.be/jong/kampen/kunstenkamp/";
export const Route = createFileRoute("/kamp")({
component: KampPage,
});
const STYLES = `
@import url('https://fonts.googleapis.com/css2?family=UnifrakturMaguntia&family=Playfair+Display:ital,wght@0,400;0,700;0,900;1,400;1,700&family=EB+Garamond:ital,wght@0,400;0,500;1,400;1,500&family=Special+Elite&display=swap');
@keyframes kampPressIn {
from { opacity: 0; }
to { opacity: 1; }
}
body.kamp-page {
background-color: #d6cdb0 !important;
overflow: hidden;
}
body.kamp-page ::selection {
background: #12100e;
color: #ede4c8;
}
.kamp-scroll::-webkit-scrollbar {
width: 6px;
}
.kamp-scroll::-webkit-scrollbar-track {
background: #c9c0a4;
}
.kamp-scroll::-webkit-scrollbar-thumb {
background: #12100e;
}
`;
const PAPER = "#ede4c8";
const INK = "#12100e";
const INK_MID = "rgba(18,16,14,0.5)";
const INK_GHOST = "rgba(18,16,14,0.2)";
const RULE_W = "rgba(18,16,14,0.75)";
function TripleRule() {
return (
<div style={{ display: "flex", flexDirection: "column", gap: "3px" }}>
<div style={{ height: "3px", background: INK }} />
<div style={{ height: "1px", background: RULE_W }} />
<div style={{ height: "2px", background: INK }} />
</div>
);
}
function DoubleRule() {
return (
<div style={{ display: "flex", flexDirection: "column", gap: "3px" }}>
<div style={{ height: "2px", background: RULE_W }} />
<div style={{ height: "1px", background: RULE_W }} />
</div>
);
}
function KampPage() {
const [mounted, setMounted] = useState(false);
const [ctaHovered, setCtaHovered] = useState(false);
useEffect(() => {
if (!document.getElementById("kamp-newspaper-styles")) {
const el = document.createElement("style");
el.id = "kamp-newspaper-styles";
el.textContent = STYLES;
document.head.appendChild(el);
}
document.body.classList.add("kamp-page");
const t = setTimeout(() => setMounted(true), 60);
return () => {
document.body.classList.remove("kamp-page");
clearTimeout(t);
};
}, []);
return (
<div
style={{
height: "100dvh",
background: "#d6cdb0",
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='400'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='400' height='400' filter='url(%23n)' opacity='0.12'/%3E%3C/svg%3E")`,
backgroundRepeat: "repeat",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "1.5rem 1rem",
}}
>
{/* Newspaper page */}
<article
className="kamp-scroll"
style={{
width: "100%",
maxWidth: "700px",
height: "100%",
background: PAPER,
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='300' height='300' filter='url(%23n)' opacity='0.06'/%3E%3C/svg%3E")`,
border: `1px solid ${INK}`,
boxShadow:
"3px 5px 24px rgba(0,0,0,0.18), 0 1px 3px rgba(0,0,0,0.12)",
opacity: mounted ? 1 : 0,
animation: mounted ? "kampPressIn 0.9s ease both" : undefined,
overflowY: "auto",
}}
>
{/* Outer decorative border inset */}
<div
style={{
margin: "6px",
border: `1px solid ${INK}`,
}}
>
{/* Top flag strip */}
<div
style={{
borderBottom: `1px solid ${INK}`,
padding: "7px 20px 6px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Link
to="/"
style={{
fontFamily: "'Special Elite', cursive",
fontSize: "10px",
letterSpacing: "0.18em",
color: INK,
textDecoration: "none",
borderBottom: `1px solid ${INK}`,
paddingBottom: "1px",
}}
>
Open Mic Night
</Link>
<span
style={{
fontFamily: "'Special Elite', cursive",
fontSize: "9px",
letterSpacing: "0.18em",
color: INK_MID,
}}
>
· ·
</span>
<span
style={{
fontFamily: "'Special Elite', cursive",
fontSize: "9px",
letterSpacing: "0.22em",
textTransform: "uppercase",
color: INK_MID,
}}
>
Anno 2026
</span>
</div>
{/* Masthead */}
<div
style={{
padding: "20px 24px 16px",
textAlign: "center",
borderBottom: `1px solid ${INK}`,
}}
>
<h1
style={{
fontFamily: "'UnifrakturMaguntia', cursive",
fontSize: "clamp(2.4rem, 9vw, 4.5rem)",
color: INK,
margin: 0,
lineHeight: 1,
letterSpacing: "0.02em",
}}
>
De Kunstenkamp Gazet
</h1>
</div>
{/* Publication bar */}
<div
style={{
display: "flex",
justifyContent: "space-between",
padding: "6px 20px 5px",
borderBottom: `1px solid ${INK}`,
}}
>
{[
"Vol. CCXXVI",
"Zomerkamp · Juli 2026",
"Prijs: uw aanwezigheid",
].map((t) => (
<span
key={t}
style={{
fontFamily: "'Special Elite', cursive",
fontSize: "10px",
color: INK_MID,
letterSpacing: "0.1em",
}}
>
{t}
</span>
))}
</div>
{/* Main content area */}
<div style={{ padding: "28px 28px 24px" }}>
{/* Triple rule */}
<div style={{ marginBottom: "24px" }}>
<TripleRule />
</div>
{/* Main headline */}
<div style={{ textAlign: "center", marginBottom: "16px" }}>
<h2
style={{
fontFamily: "'Playfair Display', serif",
fontWeight: 900,
fontSize: "clamp(2.6rem, 9vw, 4.5rem)",
color: INK,
textTransform: "uppercase",
letterSpacing: "0.06em",
lineHeight: 0.95,
margin: "0 0 14px",
}}
>
Wat Is Waar?
</h2>
<p
style={{
fontFamily: "'Playfair Display', serif",
fontStyle: "italic",
fontSize: "clamp(1rem, 2.5vw, 1.2rem)",
color: INK_MID,
margin: 0,
lineHeight: 1.45,
}}
>
Een krant. Een tijdmachine. De waarheid doorheen de eeuwen.
</p>
</div>
{/* Triple rule */}
<div style={{ marginBottom: "22px" }}>
<TripleRule />
</div>
{/* Editorial body copy */}
<p
style={{
fontFamily: "'EB Garamond', serif",
fontSize: "clamp(1.05rem, 2.2vw, 1.2rem)",
color: INK,
lineHeight: 1.75,
margin: "0 0 10px",
textAlign: "justify",
hyphens: "auto",
}}
>
In de zomer van 1826 en opnieuw in 2026 reizen onze
verslaggevers terug naar de roerige redactiezalen van de
negentiende eeuw. Waar de drukpers ronkt, de rookmachines tieren
en elke kop een mening verbergt, stellen wij de vraag die door
alle eeuwen galmt: <em>wat mogen wij geloven?</em>
</p>
<p
style={{
fontFamily: "'EB Garamond', serif",
fontStyle: "italic",
fontSize: "clamp(0.95rem, 2vw, 1.08rem)",
color: INK_MID,
lineHeight: 1.7,
margin: "0 0 24px",
textAlign: "justify",
hyphens: "auto",
}}
>
Twee waarheden en een leugen. Vier bolhoeden en één typmachine.
Bereid u voor op een week journalistiek, theater, dans en
woordkunst alles gehuld in inkt en papier-maché.
</p>
{/* Ornamental divider */}
<p
style={{
textAlign: "center",
fontFamily: "'EB Garamond', serif",
fontSize: "13px",
color: INK_GHOST,
margin: "0 0 24px",
letterSpacing: "0.4em",
}}
>
· ·
</p>
{/* Details grid */}
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
border: `1px solid ${INK}`,
marginBottom: "28px",
}}
>
{[
{ label: "Primo", value: "20 25 Juli 2026" },
{ label: "Secundo", value: "27 Juli 1 Aug 2026" },
{
label: "Leeftijd",
value: "9 18 jaar",
sub: "* geboortejaar dat je 10 wordt",
},
{
label: "Locatie",
value: "Camp de Limauges",
sub: "Ceroux-Mousty · België",
},
].map(({ label, value, sub }, i) => (
<div
key={label}
style={{
padding: "16px 18px",
borderRight: i % 2 === 0 ? `1px solid ${INK}` : undefined,
borderTop: i >= 2 ? `1px solid ${INK}` : undefined,
}}
>
<p
style={{
fontFamily: "'Special Elite', cursive",
fontSize: "9px",
letterSpacing: "0.3em",
textTransform: "uppercase",
color: INK_MID,
margin: "0 0 6px",
}}
>
{label}
</p>
<p
style={{
fontFamily: "'Playfair Display', serif",
fontWeight: 700,
fontSize: "clamp(1rem, 2.5vw, 1.15rem)",
color: INK,
margin: 0,
lineHeight: 1.3,
}}
>
{value}
</p>
{sub && (
<p
style={{
fontFamily: "'EB Garamond', serif",
fontStyle: "italic",
fontSize: "13px",
color: INK_MID,
margin: "4px 0 0",
}}
>
{sub}
</p>
)}
</div>
))}
</div>
{/* Double rule */}
<div style={{ marginBottom: "24px" }}>
<DoubleRule />
</div>
{/* CTA — inverted ink block */}
<div style={{ marginBottom: "4px" }}>
<p
style={{
fontFamily: "'Special Elite', cursive",
fontSize: "9px",
letterSpacing: "0.35em",
textTransform: "uppercase",
color: INK_MID,
textAlign: "center",
margin: "0 0 10px",
}}
>
Aankondiging
</p>
<a
href={KAMP_URL}
target="_blank"
rel="noopener noreferrer"
onMouseEnter={() => setCtaHovered(true)}
onMouseLeave={() => setCtaHovered(false)}
style={{
display: "block",
background: ctaHovered ? "#2a2418" : INK,
color: PAPER,
textDecoration: "none",
textAlign: "center",
padding: "28px 32px 26px",
outline: `2px solid ${INK}`,
outlineOffset: "4px",
transition: "background 0.2s ease",
}}
>
<span
style={{
display: "block",
fontFamily: "'Playfair Display', serif",
fontWeight: 900,
fontSize: "clamp(1.5rem, 5vw, 2.4rem)",
textTransform: "uppercase",
letterSpacing: "0.1em",
lineHeight: 1,
marginBottom: "10px",
}}
>
Schrijf U In
</span>
<span
style={{
display: "block",
fontFamily: "'Special Elite', cursive",
fontSize: "clamp(0.7rem, 2vw, 0.85rem)",
letterSpacing: "0.25em",
opacity: 0.65,
}}
>
via ejv.be
</span>
</a>
</div>
</div>
{/* Footer */}
<div
style={{
borderTop: `1px solid ${INK}`,
padding: "7px 20px 8px",
textAlign: "center",
}}
>
<p
style={{
fontFamily: "'EB Garamond', serif",
fontStyle: "italic",
fontSize: "12px",
color: INK_GHOST,
margin: 0,
letterSpacing: "0.06em",
}}
>
Kunst · Expressie · Avontuur · Waar is de waarheid?
</p>
</div>
</div>
</article>
</div>
);
}

View File

@@ -12,16 +12,30 @@ import {
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { REGISTRATION_OPENS_AT } from "@/lib/opening";
import { useRegistrationOpen } from "@/lib/useRegistrationOpen";
import { orpc } from "@/utils/orpc";
export const Route = createFileRoute("/login")({
validateSearch: (search: Record<string, unknown>) => {
const signup =
search.signup === "1" || search.signup === "true" ? "1" : undefined;
const email = typeof search.email === "string" ? search.email : undefined;
// Only return defined keys so redirects without search still typecheck
return {
...(signup !== undefined && { signup }),
...(email !== undefined && { email }),
} as { signup?: "1"; email?: string };
},
component: LoginPage,
});
function LoginPage() {
const navigate = useNavigate();
const [isSignup, setIsSignup] = useState(false);
const [email, setEmail] = useState("");
const search = Route.useSearch();
const { isOpen } = useRegistrationOpen();
const [isSignup, setIsSignup] = useState(() => search.signup === "1");
const [email, setEmail] = useState(() => search.email ?? "");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
@@ -49,13 +63,12 @@ function LoginPage() {
},
onSuccess: () => {
toast.success("Succesvol ingelogd!");
// Check role and redirect
authClient.getSession().then((session) => {
const user = session.data?.user as { role?: string } | undefined;
if (user?.role === "admin") {
navigate({ to: "/admin" });
} else {
navigate({ to: "/" });
navigate({ to: "/account" });
}
});
},
@@ -85,11 +98,7 @@ function LoginPage() {
return result.data;
},
onSuccess: () => {
toast.success("Account aangemaakt! Wacht op goedkeuring van een admin.");
setIsSignup(false);
setName("");
setEmail("");
setPassword("");
navigate({ to: "/account", search: { welkom: "1" } });
},
onError: (error) => {
toast.error(`Registratie mislukt: ${error.message}`);
@@ -119,13 +128,15 @@ function LoginPage() {
requestAdminMutation.mutate(undefined);
};
// If already logged in as admin, redirect
// If already logged in, redirect to appropriate page
if (sessionQuery.data?.data?.user) {
const user = sessionQuery.data.data.user as { role?: string };
if (user.role === "admin") {
navigate({ to: "/admin" });
return null;
} else {
navigate({ to: "/account" });
}
return null;
}
const isLoggedIn = !!sessionQuery.data?.data?.user;
@@ -134,55 +145,105 @@ function LoginPage() {
| undefined;
return (
<div className="flex min-h-screen items-center justify-center bg-[#214e51] px-4">
<Card className="w-full max-w-md border-white/10 bg-white/5">
<div className="flex min-h-[calc(100dvh-2.75rem)] items-center justify-center overflow-y-auto px-4 py-8 sm:min-h-[calc(100dvh-3.5rem)]">
<Card className="my-auto w-full max-w-md border-white/10 bg-white/5">
<CardHeader className="text-center">
<CardTitle className="font-['Intro',sans-serif] text-3xl text-white">
{isLoggedIn
? "Admin Toegang"
? `Welkom, ${user?.name}`
: isSignup
? "Account Aanmaken"
: "Inloggen"}
</CardTitle>
<CardDescription className="text-white/60">
{isLoggedIn
? `Welkom, ${user?.name}`
? "Je bent al ingelogd"
: isSignup
? "Maak een account aan om toegang te krijgen"
: "Log in om toegang te krijgen tot het admin dashboard"}
? "Maak een gratis account aan voor je Drinkkaart en inschrijving"
: "Log in om je account te bekijken"}
</CardDescription>
</CardHeader>
<CardContent>
{isLoggedIn ? (
<div className="space-y-4">
<div className="rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-4">
<p className="text-center text-yellow-200">
Je bent ingelogd maar hebt geen admin toegang.
</p>
</div>
{/* Primary CTA: go to account */}
<Button
onClick={handleRequestAdmin}
disabled={requestAdminMutation.isPending}
onClick={() => navigate({ to: "/account" })}
className="w-full bg-white text-[#214e51] hover:bg-white/90"
>
{requestAdminMutation.isPending
? "Bezig..."
: "Vraag Admin Toegang Aan"}
Ga naar mijn account
</Button>
{/* Secondary: request admin access */}
<div className="rounded-lg border border-white/10 bg-white/5 p-4">
<p className="mb-3 text-center text-sm text-white/60">
Admin-toegang aanvragen?
</p>
<Button
onClick={handleRequestAdmin}
disabled={requestAdminMutation.isPending}
variant="outline"
className="w-full border-white/20 bg-transparent text-white hover:bg-white/10"
>
{requestAdminMutation.isPending
? "Bezig..."
: "Vraag Admin Toegang Aan"}
</Button>
</div>
<Button
onClick={() => authClient.signOut()}
onClick={() =>
authClient.signOut().then(() => navigate({ to: "/" }))
}
variant="outline"
className="w-full border-white/20 bg-transparent text-white hover:bg-white/10"
className="w-full border-white/20 bg-transparent text-white/60 hover:bg-white/10 hover:text-white"
>
Uitloggen
</Button>
<Link
to="/"
className="block text-center text-sm text-white/60 hover:text-white"
className="block text-center text-sm text-white/40 hover:text-white"
>
Terug naar website
</Link>
</div>
) : isSignup && !isOpen ? (
/* Signup is closed until registration opens */
<div className="space-y-4">
<div className="rounded-lg border border-white/10 bg-white/5 p-5 text-center">
<p className="mb-1 font-['Intro',sans-serif] text-white">
Registratie nog niet open
</p>
<p className="text-sm text-white/60">
Accounts aanmaken kan vanaf{" "}
<span className="text-teal-300">
{REGISTRATION_OPENS_AT.toLocaleDateString("nl-BE", {
weekday: "long",
day: "numeric",
month: "long",
})}{" "}
om{" "}
{REGISTRATION_OPENS_AT.toLocaleTimeString("nl-BE", {
hour: "2-digit",
minute: "2-digit",
})}
</span>
.
</p>
</div>
<div className="flex flex-col gap-2 text-center">
<button
type="button"
onClick={() => setIsSignup(false)}
className="text-sm text-white/60 hover:text-white"
>
Al een account? Log in
</button>
<Link to="/" className="text-sm text-white/60 hover:text-white">
Terug naar website
</Link>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
{isSignup && (
@@ -238,6 +299,16 @@ function LoginPage() {
className="border-white/20 bg-white/10 text-white placeholder:text-white/40"
/>
</div>
{!isSignup && (
<div className="text-right">
<Link
to="/forgot-password"
className="text-sm text-white/50 hover:text-white"
>
Wachtwoord vergeten?
</Link>
</div>
)}
<Button
type="submit"
disabled={loginMutation.isPending || signupMutation.isPending}
@@ -250,15 +321,34 @@ function LoginPage() {
: "Inloggen"}
</Button>
<div className="flex flex-col gap-2 text-center">
<button
type="button"
onClick={() => setIsSignup(!isSignup)}
className="text-sm text-white/60 hover:text-white"
>
{isSignup
? "Al een account? Log in"
: "Nog geen account? Registreer"}
</button>
{/* Only show signup toggle when registration is open */}
{isOpen && (
<button
type="button"
onClick={() => setIsSignup(!isSignup)}
className="text-sm text-white/60 hover:text-white"
>
{isSignup
? "Al een account? Log in"
: "Nog geen account? Registreer"}
</button>
)}
{!isOpen && isSignup === false && (
<button
type="button"
onClick={() => setIsSignup(true)}
className="cursor-not-allowed text-sm text-white/30"
disabled
title={`Registratie opent op ${REGISTRATION_OPENS_AT.toLocaleString("nl-BE", { day: "numeric", month: "long", hour: "2-digit", minute: "2-digit" })}`}
>
Nog geen account? (opent{" "}
{REGISTRATION_OPENS_AT.toLocaleString("nl-BE", {
day: "numeric",
month: "long",
})}
)
</button>
)}
<Link to="/" className="text-sm text-white/60 hover:text-white">
Terug naar website
</Link>

View File

@@ -22,8 +22,8 @@ export const Route = createFileRoute("/manage/$token")({
function PageShell({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-[#214e51]">
<div className="mx-auto max-w-3xl px-6 py-16">{children}</div>
<div>
<div className="mx-auto max-w-5xl px-6 py-12">{children}</div>
</div>
);
}
@@ -31,10 +31,10 @@ function PageShell({ children }: { children: React.ReactNode }) {
function BackLink() {
return (
<Link
to="/"
to="/account"
className="link-hover mb-8 inline-block text-white/80 hover:text-white"
>
Terug naar home
Terug naar account
</Link>
);
}
@@ -117,6 +117,8 @@ interface EditFormProps {
lastName: string;
email: string;
phone: string | null;
birthdate: string;
postcode: string;
registrationType: string;
artForm: string | null;
experience: string | null;
@@ -140,6 +142,8 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
lastName: initialData.lastName,
email: initialData.email,
phone: initialData.phone ?? "",
birthdate: initialData.birthdate ?? "",
postcode: initialData.postcode ?? "",
registrationType: initialType,
artForm: initialData.artForm ?? "",
experience: initialData.experience ?? "",
@@ -185,6 +189,8 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
? formData.experience.trim() || undefined
: undefined,
isOver16: performer ? formData.isOver16 : false,
birthdate: formData.birthdate.trim(),
postcode: formData.postcode.trim(),
guests: performer
? []
: formGuests.map((g) => ({
@@ -192,6 +198,8 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
lastName: g.lastName.trim(),
email: g.email.trim() || undefined,
phone: g.phone.trim() || undefined,
birthdate: g.birthdate.trim(),
postcode: g.postcode.trim(),
})),
extraQuestions: formData.extraQuestions.trim() || undefined,
giftAmount,
@@ -393,7 +401,14 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
if (formGuests.length >= 9) return;
setFormGuests((prev) => [
...prev,
{ firstName: "", lastName: "", email: "", phone: "" },
{
firstName: "",
lastName: "",
email: "",
phone: "",
birthdate: "",
postcode: "",
},
]);
}}
onRemove={(idx) =>
@@ -427,7 +442,7 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
onChange={(e) =>
setFormData((p) => ({ ...p, extraQuestions: e.target.value }))
}
className="w-full resize-none bg-transparent py-2 text-lg text-white placeholder:text-white/40 focus:outline-none"
className="w-full resize-none border border-white/10 bg-transparent p-2 text-lg text-white placeholder:text-white/40 focus:outline-none"
/>
</div>
@@ -569,7 +584,7 @@ function ManageRegistrationPage() {
Jouw inschrijving
</h1>
<p className="mb-8 text-white/60">
Open Mic Night vrijdag 18 april 2026
Open Mic Night vrijdag 24 april 2026
</p>
{/* Type badge */}
@@ -585,8 +600,10 @@ function ManageRegistrationPage() {
)}
</div>
{/* Payment status - shown for everyone with pending/extra payment or gift */}
{(data.paymentStatus !== "paid" || (data.giftAmount ?? 0) > 0) && (
{/* Payment status badge:
- performers only see a badge if they have a gift to pay, or already paid one
- watchers always have a payment (drink card), so always show a badge */}
{(isPerformer ? (data.giftAmount ?? 0) > 0 : true) && (
<div className="mb-6">
{data.paymentStatus === "paid" ? (
<PaidBadge />

View File

@@ -6,8 +6,8 @@ export const Route = createFileRoute("/privacy")({
function PrivacyPage() {
return (
<div className="min-h-screen bg-[#214e51]">
<div className="mx-auto max-w-3xl px-6 py-16">
<div>
<div className="mx-auto max-w-3xl px-6 py-12">
<Link
to="/"
className="link-hover mb-8 inline-block text-white/80 hover:text-white"
@@ -26,7 +26,7 @@ function PrivacyPage() {
<p>
We verzamelen alleen de gegevens die je zelf invoert bij de
registratie: voornaam, achternaam, e-mailadres, telefoonnummer,
kunstvorm en ervaring.
geboortedatum, postcode, kunstvorm en ervaring.
</p>
</section>
@@ -39,6 +39,12 @@ function PrivacyPage() {
Mic Night, om het programma samen te stellen en om je te
informeren over aanvullende details over het evenement.
</p>
<p className="mt-3">
<strong className="text-white">Geboortedatum en postcode</strong>{" "}
worden gevraagd ter naleving van de rapportageverplichtingen van{" "}
<strong className="text-white">EJV</strong> en{" "}
<strong className="text-white">de Vlaamse Overheid</strong>.
</p>
</section>
<section>

View File

@@ -0,0 +1,154 @@
import { useMutation } from "@tanstack/react-query";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
export const Route = createFileRoute("/reset-password")({
validateSearch: (search: Record<string, unknown>) => {
const token = typeof search.token === "string" ? search.token : undefined;
return { ...(token !== undefined && { token }) } as { token?: string };
},
component: ResetPasswordPage,
});
function ResetPasswordPage() {
const navigate = useNavigate();
const search = Route.useSearch();
const token = search.token;
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const mutation = useMutation({
mutationFn: async ({
token,
newPassword,
}: {
token: string;
newPassword: string;
}) => {
const result = await authClient.resetPassword({ token, newPassword });
if (result.error) {
throw new Error(result.error.message);
}
return result.data;
},
onSuccess: () => {
toast.success("Wachtwoord succesvol gewijzigd!");
navigate({ to: "/login" });
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (password !== confirm) {
toast.error("De wachtwoorden komen niet overeen.");
return;
}
if (!token) {
toast.error("Ongeldige reset-link. Vraag een nieuwe aan.");
return;
}
mutation.mutate({ token, newPassword: password });
};
if (!token) {
return (
<div className="flex min-h-[calc(100dvh-2.75rem)] items-center justify-center px-4 sm:min-h-[calc(100dvh-3.5rem)]">
<Card className="w-full max-w-md border-white/10 bg-white/5">
<CardHeader className="text-center">
<CardTitle className="font-['Intro',sans-serif] text-3xl text-white">
Ongeldige link
</CardTitle>
<CardDescription className="text-white/60">
Deze reset-link is ongeldig of verlopen.
</CardDescription>
</CardHeader>
<CardContent className="text-center">
<Link
to="/forgot-password"
className="text-sm text-white/60 hover:text-white"
>
Vraag een nieuwe reset-link aan
</Link>
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex min-h-[calc(100dvh-2.75rem)] items-center justify-center overflow-y-auto px-4 py-8 sm:min-h-[calc(100dvh-3.5rem)]">
<Card className="my-auto w-full max-w-md border-white/10 bg-white/5">
<CardHeader className="text-center">
<CardTitle className="font-['Intro',sans-serif] text-3xl text-white">
Nieuw wachtwoord
</CardTitle>
<CardDescription className="text-white/60">
Kies een nieuw wachtwoord voor je account
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="password"
className="mb-2 block text-sm text-white/60"
>
Nieuw wachtwoord
</label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="border-white/20 bg-white/10 text-white placeholder:text-white/40"
/>
</div>
<div>
<label
htmlFor="confirm"
className="mb-2 block text-sm text-white/60"
>
Bevestig wachtwoord
</label>
<Input
id="confirm"
type="password"
placeholder="••••••••"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
minLength={8}
className="border-white/20 bg-white/10 text-white placeholder:text-white/40"
/>
</div>
{mutation.isError && (
<p className="text-red-400 text-sm">{mutation.error.message}</p>
)}
<Button
type="submit"
disabled={mutation.isPending}
className="w-full bg-white text-[#214e51] hover:bg-white/90"
>
{mutation.isPending ? "Bezig..." : "Stel wachtwoord in"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -6,8 +6,8 @@ export const Route = createFileRoute("/terms")({
function TermsPage() {
return (
<div className="min-h-screen bg-[#214e51]">
<div className="mx-auto max-w-3xl px-6 py-16">
<div>
<div className="mx-auto max-w-3xl px-6 py-12">
<Link
to="/"
className="link-hover mb-8 inline-block text-white/80 hover:text-white"

72
apps/web/src/server.ts Normal file
View File

@@ -0,0 +1,72 @@
import type { EmailMessage } from "@kk/api/email-queue";
import { dispatchEmailMessage } from "@kk/api/email-queue";
import { runSendReminders } from "@kk/api/routers/index";
import {
createStartHandler,
defaultStreamHandler,
} from "@tanstack/react-start/server";
// ---------------------------------------------------------------------------
// CF Workers globals — not in DOM/ES2022 lib
// ---------------------------------------------------------------------------
type MessageItem<Body> = {
body: Body;
ack(): void;
retry(): void;
};
type MessageBatch<Body> = { messages: Array<MessageItem<Body>> };
type ExecutionContext = { waitUntil(promise: Promise<unknown>): void };
// ---------------------------------------------------------------------------
// Minimal CF Queue binding shape
// ---------------------------------------------------------------------------
type EmailQueue = {
send(
message: EmailMessage,
options?: { contentType?: string },
): Promise<void>;
sendBatch(
messages: Array<{ body: EmailMessage }>,
options?: { contentType?: string },
): Promise<void>;
};
type Env = {
EMAIL_QUEUE?: EmailQueue;
};
const startHandler = createStartHandler(defaultStreamHandler);
export default {
fetch(request: Request, env: Env) {
// Cast required: TanStack Start's BaseContext doesn't know about emailQueue,
// but it threads the value through to route handlers via requestContext.
return startHandler(request, {
context: { emailQueue: env.EMAIL_QUEUE } as Record<string, unknown>,
});
},
async queue(
batch: MessageBatch<EmailMessage>,
_env: Env,
_ctx: ExecutionContext,
) {
for (const msg of batch.messages) {
try {
await dispatchEmailMessage(msg.body);
msg.ack();
} catch (err) {
console.error("Queue dispatch failed:", err);
msg.retry();
}
}
},
async scheduled(
_event: { cron: string; scheduledTime: number },
env: Env,
ctx: ExecutionContext,
) {
ctx.waitUntil(runSendReminders(env.EMAIL_QUEUE));
},
};

123
bun.lock
View File

@@ -42,12 +42,15 @@
"@tanstack/react-start": "^1.141.1",
"@tanstack/router-plugin": "^1.141.1",
"better-auth": "catalog:",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "catalog:",
"html5-qrcode": "^2.3.8",
"libsql": "catalog:",
"lucide-react": "^0.525.0",
"next-themes": "^0.4.6",
"qrcode": "^1.5.4",
"react": "19.2.3",
"react-dom": "19.2.3",
"shadcn": "^3.6.2",
@@ -64,8 +67,11 @@
"@kk/config": "workspace:*",
"@tanstack/react-query-devtools": "^5.91.1",
"@tanstack/react-router-devtools": "^1.141.1",
"@tanstack/router-core": "^1.141.1",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0",
"@types/canvas-confetti": "^1.9.0",
"@types/qrcode": "^1.5.6",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@types/three": "^0.183.1",
@@ -84,14 +90,13 @@
"@kk/auth": "workspace:*",
"@kk/db": "workspace:*",
"@kk/env": "workspace:*",
"@lemonsqueezy/lemonsqueezy.js": "^4.00.0",
"@orpc/client": "catalog:",
"@orpc/openapi": "catalog:",
"@orpc/server": "catalog:",
"@orpc/zod": "catalog:",
"dotenv": "catalog:",
"drizzle-orm": "^0.45.1",
"nodemailer": "^8.0.1",
"nodemailer": "^8.0.2",
"zod": "catalog:",
},
"devDependencies": {
@@ -107,10 +112,12 @@
"@kk/env": "workspace:*",
"better-auth": "catalog:",
"dotenv": "catalog:",
"nodemailer": "^8.0.2",
"zod": "catalog:",
},
"devDependencies": {
"@kk/config": "workspace:*",
"@types/nodemailer": "^7.0.11",
"typescript": "catalog:",
},
},
@@ -527,8 +534,6 @@
"@kk/infra": ["@kk/infra@workspace:packages/infra"],
"@lemonsqueezy/lemonsqueezy.js": ["@lemonsqueezy/lemonsqueezy.js@4.0.0", "", {}, "sha512-xcY1/lDrY7CpIF98WKiL1ElsfoVhddP7FT0fw7ssOzrFqQsr44HgolKrQZxd9SywsCPn12OTOUieqDIokI3mFg=="],
"@libsql/client": ["@libsql/client@0.15.15", "", { "dependencies": { "@libsql/core": "^0.15.14", "@libsql/hrana-client": "^0.7.0", "js-base64": "^3.7.5", "libsql": "^0.5.22", "promise-limit": "^2.7.0" } }, "sha512-twC0hQxPNHPKfeOv3sNT6u2pturQjLcI+CnpTM0SjRpocEGgfiZ7DWKXLNnsothjyJmDqEsBQJ5ztq9Wlu470w=="],
"@libsql/core": ["@libsql/core@0.15.15", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-C88Z6UKl+OyuKKPwz224riz02ih/zHYI3Ho/LAcVOgjsunIRZoBw7fjRfaH9oPMmSNeQfhGklSG2il1URoOIsA=="],
@@ -909,6 +914,8 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/canvas-confetti": ["@types/canvas-confetti@1.9.0", "", {}, "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg=="],
"@types/draco3d": ["@types/draco3d@1.4.10", "", {}, "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@@ -919,6 +926,8 @@
"@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="],
"@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="],
"@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
@@ -1019,10 +1028,14 @@
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
"camera-controls": ["camera-controls@3.1.2", "", { "peerDependencies": { "three": ">=0.126.1" } }, "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001774", "", {}, "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA=="],
"canvas-confetti": ["canvas-confetti@1.9.4", "", {}, "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="],
@@ -1039,7 +1052,7 @@
"cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
@@ -1089,6 +1102,8 @@
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
"dedent": ["dedent@1.7.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg=="],
@@ -1113,6 +1128,8 @@
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
"dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
@@ -1141,7 +1158,7 @@
"electron-to-chromium": ["electron-to-chromium@1.5.302", "", {}, "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
@@ -1217,6 +1234,8 @@
"find-process": ["find-process@2.0.0", "", { "dependencies": { "chalk": "~4.1.2", "commander": "^12.1.0", "loglevel": "^1.9.2" }, "bin": { "find-process": "dist/bin/find-process.js" } }, "sha512-YUBQnteWGASJoEVVsOXy6XtKAY2O1FCsWnnvQ8y0YwgY1rZiKeVptnFvMu6RSELZAJOGklqseTnUGGs5D0bKmg=="],
"find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
@@ -1283,6 +1302,8 @@
"html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="],
"html5-qrcode": ["html5-qrcode@2.3.8", "", {}, "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ=="],
"htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
@@ -1421,6 +1442,8 @@
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
"loglevel": ["loglevel@1.9.2", "", {}, "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg=="],
@@ -1489,7 +1512,7 @@
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"nodemailer": ["nodemailer@8.0.1", "", {}, "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg=="],
"nodemailer": ["nodemailer@8.0.2", "", {}, "sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
@@ -1521,6 +1544,12 @@
"outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="],
"p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
"package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
@@ -1543,6 +1572,8 @@
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
@@ -1557,6 +1588,8 @@
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="],
@@ -1585,6 +1618,8 @@
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
"qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
@@ -1615,6 +1650,8 @@
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
@@ -1659,6 +1696,8 @@
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
@@ -1705,7 +1744,7 @@
"strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -1867,13 +1906,15 @@
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
"wildcard-match": ["wildcard-match@5.1.4", "", {}, "sha512-wldeCaczs8XXq7hj+5d/F38JE2r7EXgb6WQDM84RVwxy81T/sxB5e9+uZLK9Q9oNz1mlvjut+QtvgaOQFPVq/g=="],
"workerd": ["workerd@1.20260302.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260302.0", "@cloudflare/workerd-darwin-arm64": "1.20260302.0", "@cloudflare/workerd-linux-64": "1.20260302.0", "@cloudflare/workerd-linux-arm64": "1.20260302.0", "@cloudflare/workerd-windows-64": "1.20260302.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-FhNdC8cenMDllI6bTktFgxP5Bn5ZEnGtofgKipY6pW9jtq708D1DeGI6vGad78KQLBGaDwFy1eThjCoLYgFfog=="],
"wrangler": ["wrangler@4.68.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.14.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260302.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260302.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260302.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-DCjl2ZfjwWV10iH4Zn+97isitPkb7BYxpbt4E/Okd/QKLFTp9xdwoa999UN9lugToqPm5Zz/UsRu6hpKZuT8BA=="],
"wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
@@ -1889,15 +1930,15 @@
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
@@ -1939,10 +1980,10 @@
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"@jridgewell/gen-mapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@jridgewell/remapping/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
@@ -1993,12 +2034,8 @@
"cheerio/undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="],
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"cosmiconfig/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@@ -2027,10 +2064,14 @@
"msw/tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="],
"msw/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
"ora/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"ora/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
@@ -2059,7 +2100,7 @@
"stats-gl/three": ["three@0.170.0", "", {}, "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ=="],
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -2079,18 +2120,14 @@
"wrangler/unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"wrap-ansi/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
@@ -2153,26 +2190,26 @@
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"@inquirer/core/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"@inquirer/core/wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"@inquirer/core/wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="],
"@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="],
"cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"msw/tough-cookie/tldts": ["tldts@7.0.23", "", { "dependencies": { "tldts-core": "^7.0.23" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw=="],
"msw/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"msw/yargs/y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"msw/yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"ora/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"shadcn/open/wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
@@ -2331,20 +2368,16 @@
"wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"wrap-ansi/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@inquirer/core/wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"msw/tough-cookie/tldts/tldts-core": ["tldts-core@7.0.23", "", {}, "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ=="],
"msw/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"msw/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"msw/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
}
}

View File

@@ -14,14 +14,13 @@
"@kk/auth": "workspace:*",
"@kk/db": "workspace:*",
"@kk/env": "workspace:*",
"@lemonsqueezy/lemonsqueezy.js": "^4.00.0",
"@orpc/client": "catalog:",
"@orpc/openapi": "catalog:",
"@orpc/server": "catalog:",
"@orpc/zod": "catalog:",
"dotenv": "catalog:",
"drizzle-orm": "^0.45.1",
"nodemailer": "^8.0.1",
"nodemailer": "^8.0.2",
"zod": "catalog:"
},
"devDependencies": {

View File

@@ -0,0 +1,10 @@
// ---------------------------------------------------------------------------
// Single source-of-truth for event details used across the API
// ---------------------------------------------------------------------------
export const EVENT = "Open Mic Night — vrijdag 24 april 2026";
export const LOCATION = "Lange Winkelstraat 5, 2000 Antwerpen";
export const OPENS = "maandag 16 maart 2026 om 19:00";
// Registration opens — used for reminder scheduling windows
export const REGISTRATION_OPENS_AT = new Date("2026-03-16T19:00:00+01:00");

View File

@@ -1,11 +1,33 @@
import { auth } from "@kk/auth";
import { env } from "@kk/env/server";
import type { EmailMessage } from "./email-queue";
export async function createContext({ req }: { req: Request }) {
// CF Workers runtime Queue type (not the alchemy resource type)
type Queue = {
send(
message: EmailMessage,
options?: { contentType?: string },
): Promise<void>;
sendBatch(
messages: Array<{ body: EmailMessage }>,
options?: { contentType?: string },
): Promise<void>;
};
export async function createContext({
req,
emailQueue,
}: {
req: Request;
emailQueue?: Queue;
}) {
const session = await auth.api.getSession({
headers: req.headers,
});
return {
session,
env,
emailQueue,
};
}

View File

@@ -0,0 +1,147 @@
/**
* Email queue types and dispatcher.
*
* All email sends are modelled as a discriminated union so the Cloudflare Queue
* consumer can pattern-match on `msg.type` and call the right send*Email().
*
* The `Queue<EmailMessage>` type used in context.ts / server.ts refers to the
* CF runtime binding type (`import type { Queue } from "@cloudflare/workers-types"`).
*/
import {
sendCancellationEmail,
sendConfirmationEmail,
sendPaymentConfirmationEmail,
sendPaymentReminderEmail,
sendReminder24hEmail,
sendReminderEmail,
sendSubscriptionConfirmationEmail,
sendUpdateEmail,
} from "./email";
import { sendDeductionEmail } from "./lib/drinkkaart-email";
// ---------------------------------------------------------------------------
// Message types — one variant per send*Email function
// ---------------------------------------------------------------------------
export type ConfirmationMessage = {
type: "registrationConfirmation";
to: string;
firstName: string;
managementToken: string;
wantsToPerform: boolean;
artForm?: string | null;
giftAmount?: number;
drinkCardValue?: number;
signupUrl?: string;
};
export type UpdateMessage = {
type: "updateConfirmation";
to: string;
firstName: string;
managementToken: string;
wantsToPerform: boolean;
artForm?: string | null;
giftAmount?: number;
drinkCardValue?: number;
};
export type CancellationMessage = {
type: "cancellation";
to: string;
firstName: string;
};
export type SubscriptionConfirmationMessage = {
type: "subscriptionConfirmation";
to: string;
};
export type Reminder24hMessage = {
type: "reminder24h";
to: string;
firstName?: string | null;
};
export type Reminder1hMessage = {
type: "reminder1h";
to: string;
firstName?: string | null;
};
export type PaymentReminderMessage = {
type: "paymentReminder";
to: string;
firstName: string;
managementToken: string;
drinkCardValue?: number;
giftAmount?: number;
};
export type PaymentConfirmationMessage = {
type: "paymentConfirmation";
to: string;
firstName: string;
managementToken: string;
drinkCardValue?: number;
giftAmount?: number;
};
export type DeductionMessage = {
type: "deduction";
to: string;
firstName: string;
amountCents: number;
newBalanceCents: number;
};
export type EmailMessage =
| ConfirmationMessage
| UpdateMessage
| CancellationMessage
| SubscriptionConfirmationMessage
| Reminder24hMessage
| Reminder1hMessage
| PaymentReminderMessage
| PaymentConfirmationMessage
| DeductionMessage;
// ---------------------------------------------------------------------------
// Consumer-side dispatcher — called once per queue message
// ---------------------------------------------------------------------------
export async function dispatchEmailMessage(msg: EmailMessage): Promise<void> {
switch (msg.type) {
case "registrationConfirmation":
await sendConfirmationEmail(msg);
break;
case "updateConfirmation":
await sendUpdateEmail(msg);
break;
case "cancellation":
await sendCancellationEmail(msg);
break;
case "subscriptionConfirmation":
await sendSubscriptionConfirmationEmail({ to: msg.to });
break;
case "reminder24h":
await sendReminder24hEmail(msg);
break;
case "reminder1h":
await sendReminderEmail(msg);
break;
case "paymentReminder":
await sendPaymentReminderEmail(msg);
break;
case "paymentConfirmation":
await sendPaymentConfirmationEmail(msg);
break;
case "deduction":
await sendDeductionEmail(msg);
break;
default:
// Exhaustiveness check — TypeScript will catch unhandled variants at compile time
msg satisfies never;
}
}

View File

@@ -1,276 +1,186 @@
import { env } from "@kk/env/server";
import nodemailer from "nodemailer";
import { EVENT, LOCATION, OPENS } from "./constants";
// Singleton transport — created once per module, reused across all email sends.
// Re-creating it on every call causes EventEmitter listener accumulation in
// long-lived Cloudflare Worker processes, triggering memory leak warnings.
let _transport: nodemailer.Transporter | null | undefined;
// ---------------------------------------------------------------------------
// Transport — singleton so a warm CF isolate reuses the open TCP connection
// ---------------------------------------------------------------------------
let _transport: nodemailer.Transporter | undefined;
function getTransport(): nodemailer.Transporter | null {
if (_transport !== undefined) return _transport;
if (_transport) return _transport;
if (!env.SMTP_HOST || !env.SMTP_USER || !env.SMTP_PASS) {
_transport = null;
// Not cached — re-evaluated on every call so a cold isolate that
// receives vars after module init can still pick them up.
return null;
}
_transport = nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT,
secure: env.SMTP_PORT === 465,
auth: {
user: env.SMTP_USER,
pass: env.SMTP_PASS,
},
auth: { user: env.SMTP_USER, pass: env.SMTP_PASS },
});
return _transport;
}
const from = env.SMTP_FROM ?? "Kunstenkamp <info@kunstenkamp.be>";
const baseUrl = env.BETTER_AUTH_URL ?? "https://kunstenkamp.be";
// ---------------------------------------------------------------------------
// Logging — console.log(JSON) is the only thing visible in CF dashboard
// ---------------------------------------------------------------------------
function registrationConfirmationHtml(params: {
firstName: string;
manageUrl: string;
wantsToPerform: boolean;
artForm?: string | null;
giftAmount?: number;
drinkCardValue?: number;
}) {
const role = params.wantsToPerform
? `Optreden${params.artForm ? `${params.artForm}` : ""}`
export function emailLog(
level: "info" | "warn" | "error",
event: string,
extra?: Record<string, unknown>,
): void {
console.log(JSON.stringify({ level, event, ...extra }));
}
// ---------------------------------------------------------------------------
// HTML helpers
// ---------------------------------------------------------------------------
/** Primary or secondary CTA button */
function btn(
href: string,
label: string,
variant: "primary" | "secondary" = "primary",
): string {
const bg = variant === "primary" ? "#fff" : "rgba(255,255,255,0.15)";
const color = variant === "primary" ? "#214e51" : "#fff";
return `<table cellpadding="0" cellspacing="0" style="margin:0 0 16px;">
<tr><td style="border-radius:2px;background:${bg};">
<a href="${href}" style="display:inline-block;padding:14px 32px;font-size:16px;font-weight:600;color:${color};text-decoration:none;">${label}</a>
</td></tr>
</table>`;
}
/** Info card — single label + value */
function card(label: string, value: string): string {
return `<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(255,255,255,0.08);border-radius:4px;margin:24px 0;">
<tr><td style="padding:20px 24px;">
<p style="margin:0 0 8px;font-size:12px;color:rgba(255,255,255,0.5);text-transform:uppercase;letter-spacing:0.06em;">${label}</p>
<p style="margin:0;font-size:18px;color:#fff;font-weight:600;">${value}</p>
</td></tr>
</table>`;
}
/** Payment breakdown card — renders only when totalCents > 0 */
function paymentCard(drinkCardCents: number, giftCents: number): string {
const total = drinkCardCents + giftCents;
if (total === 0) return "";
return `<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(255,255,255,0.08);border-radius:4px;margin:16px 0;">
<tr><td style="padding:20px 24px;">
<p style="margin:0 0 12px;font-size:12px;color:rgba(255,255,255,0.5);text-transform:uppercase;letter-spacing:0.06em;">Betaling</p>
${drinkCardCents > 0 ? `<p style="margin:0 0 8px;font-size:16px;color:rgba(255,255,255,0.85);">Drinkkaart: <strong style="color:#fff;">${euro(drinkCardCents)}</strong></p>` : ""}
${giftCents > 0 ? `<p style="margin:0 0 8px;font-size:16px;color:rgba(255,255,255,0.85);">Vrijwillige gift: <strong style="color:#fff;">${euro(giftCents)}</strong></p>` : ""}
<p style="margin:16px 0 0;padding-top:12px;border-top:1px solid rgba(255,255,255,0.1);font-size:18px;color:#fff;font-weight:600;">Totaal: ${euro(total)}</p>
</td></tr>
</table>`;
}
/** Shared text paragraph */
function p(text: string): string {
return `<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">${text}</p>`;
}
/** Muted footnote */
function note(html: string): string {
return `<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.4);">${html}</p>`;
}
// ---------------------------------------------------------------------------
// Email layout shell
// ---------------------------------------------------------------------------
function emailLayout(heading: string, body: string): string {
return `<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>${heading}</title>
</head>
<body style="margin:0;padding:0;background:#f4f4f5;font-family:sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 0;">
<tr><td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background:#214e51;border-radius:4px;overflow:hidden;">
<tr><td style="padding:40px 48px 32px;">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.5);letter-spacing:0.08em;text-transform:uppercase;">Kunstenkamp</p>
<h1 style="margin:12px 0 0;font-size:28px;color:#fff;font-weight:700;">${heading}</h1>
</td></tr>
<tr><td style="padding:0 48px 32px;">${body}</td></tr>
<tr><td style="padding:24px 48px;border-top:1px solid rgba(255,255,255,0.1);">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.4);line-height:1.6;">
Vragen? Mail ons op <a href="mailto:info@kunstenkamp.be" style="color:rgba(255,255,255,0.6);">info@kunstenkamp.be</a><br/>
Kunstenkamp vzw — Een initiatief voor en door kunstenaars.
</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
// ---------------------------------------------------------------------------
// Shared send helper — logs attempt/sent/error, skips when SMTP not configured
// ---------------------------------------------------------------------------
async function send(
type: string,
to: string,
subject: string,
html: string,
): Promise<void> {
emailLog("info", "email.attempt", { type, to });
const transport = getTransport();
if (!transport) {
emailLog("warn", "email.skipped", {
type,
to,
reason: "smtp_not_configured",
// Which vars are missing — helps diagnose CF env issues
hasHost: !!env.SMTP_HOST,
hasUser: !!env.SMTP_USER,
hasPass: !!env.SMTP_PASS,
});
return;
}
try {
await transport.sendMail({ from: env.SMTP_FROM, to, subject, html });
emailLog("info", "email.sent", { type, to });
} catch (err) {
emailLog("error", "email.error", { type, to, error: String(err) });
throw err;
}
}
// ---------------------------------------------------------------------------
// Euro formatter
// ---------------------------------------------------------------------------
function euro(cents: number): string {
return `${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2).replace(".", ",")}`;
}
// ---------------------------------------------------------------------------
// Helpers for registration emails
// ---------------------------------------------------------------------------
function roleLabel(wantsToPerform: boolean, artForm?: string | null): string {
return wantsToPerform
? `Optreden${artForm ? `${artForm}` : ""}`
: "Toeschouwer";
const giftCents = params.giftAmount ?? 0;
// drinkCardValue is stored in euros (e.g., 5), giftAmount in cents (e.g., 5000)
const drinkCardCents = (params.drinkCardValue ?? 0) * 100;
const hasPayment = drinkCardCents > 0 || giftCents > 0;
const formatEuro = (cents: number) =>
`${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2).replace(".", ",")}`;
const paymentSummary = hasPayment
? `<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(255,255,255,0.08);border-radius:4px;margin:16px 0;">
<tr>
<td style="padding:20px 24px;">
<p style="margin:0 0 12px;font-size:12px;color:rgba(255,255,255,0.5);text-transform:uppercase;letter-spacing:0.06em;">Betaling</p>
${drinkCardCents > 0 ? `<p style="margin:0 0 8px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.5;">Drinkkaart: <span style="color:#ffffff;font-weight:500;">${formatEuro(drinkCardCents)}</span></p>` : ""}
${giftCents > 0 ? `<p style="margin:0 0 8px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.5;">Vrijwillige gift: <span style="color:#ffffff;font-weight:500;">${formatEuro(giftCents)}</span></p>` : ""}
<p style="margin:16px 0 0;padding-top:12px;border-top:1px solid rgba(255,255,255,0.1);font-size:18px;color:#ffffff;font-weight:600;">Totaal: ${formatEuro(drinkCardCents + giftCents)}</p>
</td>
</tr>
</table>`
: "";
return `<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bevestiging inschrijving</title>
</head>
<body style="margin:0;padding:0;background:#f4f4f5;font-family:sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background:#214e51;border-radius:4px;overflow:hidden;">
<!-- Header -->
<tr>
<td style="padding:40px 48px 32px;">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.5);letter-spacing:0.08em;text-transform:uppercase;">Kunstenkamp</p>
<h1 style="margin:12px 0 0;font-size:28px;color:#ffffff;font-weight:700;">Je inschrijving is bevestigd!</h1>
</td>
</tr>
<!-- Body -->
<tr>
<td style="padding:0 48px 32px;">
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Hoi ${params.firstName},
</p>
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
We hebben je inschrijving voor <strong style="color:#ffffff;">Open Mic Night — vrijdag 18 april 2026</strong> in goede orde ontvangen.
</p>
<!-- Registration summary -->
<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(255,255,255,0.08);border-radius:4px;margin:24px 0;">
<tr>
<td style="padding:20px 24px;">
<p style="margin:0 0 8px;font-size:12px;color:rgba(255,255,255,0.5);text-transform:uppercase;letter-spacing:0.06em;">Jouw rol</p>
<p style="margin:0;font-size:18px;color:#ffffff;font-weight:600;">${role}</p>
</td>
</tr>
</table>
${paymentSummary}
<p style="margin:0 0 24px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Wil je je gegevens later nog aanpassen of je inschrijving annuleren? Gebruik dan de knop hieronder. De link is uniek voor jou — deel hem niet.
</p>
<!-- CTA button -->
<table cellpadding="0" cellspacing="0">
<tr>
<td style="border-radius:2px;background:#ffffff;">
<a href="${params.manageUrl}" style="display:inline-block;padding:14px 32px;font-size:16px;font-weight:600;color:#214e51;text-decoration:none;">
Beheer mijn inschrijving
</a>
</td>
</tr>
</table>
<p style="margin:16px 0 0;font-size:13px;color:rgba(255,255,255,0.4);">
Of kopieer deze link: <span style="color:rgba(255,255,255,0.6);">${params.manageUrl}</span>
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding:24px 48px;border-top:1px solid rgba(255,255,255,0.1);">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.4);line-height:1.6;">
Vragen? Mail ons op <a href="mailto:info@kunstenkamp.be" style="color:rgba(255,255,255,0.6);">info@kunstenkamp.be</a><br/>
Kunstenkamp vzw — Een initiatief voor en door kunstenaars.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
function updateConfirmationHtml(params: {
firstName: string;
manageUrl: string;
wantsToPerform: boolean;
artForm?: string | null;
giftAmount?: number;
drinkCardValue?: number;
}) {
const role = params.wantsToPerform
? `Optreden${params.artForm ? `${params.artForm}` : ""}`
: "Toeschouwer";
const giftCents = params.giftAmount ?? 0;
// drinkCardValue is stored in euros (e.g., 5), giftAmount in cents (e.g., 5000)
const drinkCardCents = (params.drinkCardValue ?? 0) * 100;
const hasPayment = drinkCardCents > 0 || giftCents > 0;
const formatEuro = (cents: number) =>
`${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2).replace(".", ",")}`;
const paymentSummary = hasPayment
? `<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(255,255,255,0.08);border-radius:4px;margin:16px 0;">
<tr>
<td style="padding:20px 24px;">
<p style="margin:0 0 12px;font-size:12px;color:rgba(255,255,255,0.5);text-transform:uppercase;letter-spacing:0.06em;">Betaling</p>
${drinkCardCents > 0 ? `<p style="margin:0 0 8px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.5;">Drinkkaart: <span style="color:#ffffff;font-weight:500;">${formatEuro(drinkCardCents)}</span></p>` : ""}
${giftCents > 0 ? `<p style="margin:0 0 8px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.5;">Vrijwillige gift: <span style="color:#ffffff;font-weight:500;">${formatEuro(giftCents)}</span></p>` : ""}
<p style="margin:16px 0 0;padding-top:12px;border-top:1px solid rgba(255,255,255,0.1);font-size:18px;color:#ffffff;font-weight:600;">Totaal: ${formatEuro(drinkCardCents + giftCents)}</p>
</td>
</tr>
</table>`
: "";
return `<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<title>Inschrijving bijgewerkt</title>
</head>
<body style="margin:0;padding:0;background:#f4f4f5;font-family:sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background:#214e51;border-radius:4px;overflow:hidden;">
<tr>
<td style="padding:40px 48px 32px;">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.5);letter-spacing:0.08em;text-transform:uppercase;">Kunstenkamp</p>
<h1 style="margin:12px 0 0;font-size:28px;color:#ffffff;font-weight:700;">Inschrijving bijgewerkt</h1>
</td>
</tr>
<tr>
<td style="padding:0 48px 32px;">
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Hoi ${params.firstName},
</p>
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Je inschrijving voor <strong style="color:#ffffff;">Open Mic Night — vrijdag 18 april 2026</strong> is succesvol bijgewerkt.
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(255,255,255,0.08);border-radius:4px;margin:24px 0;">
<tr>
<td style="padding:20px 24px;">
<p style="margin:0 0 8px;font-size:12px;color:rgba(255,255,255,0.5);text-transform:uppercase;letter-spacing:0.06em;">Jouw rol</p>
<p style="margin:0;font-size:18px;color:#ffffff;font-weight:600;">${role}</p>
</td>
</tr>
</table>
${paymentSummary}
<table cellpadding="0" cellspacing="0">
<tr>
<td style="border-radius:2px;background:#ffffff;">
<a href="${params.manageUrl}" style="display:inline-block;padding:14px 32px;font-size:16px;font-weight:600;color:#214e51;text-decoration:none;">
Bekijk mijn inschrijving
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:24px 48px;border-top:1px solid rgba(255,255,255,0.1);">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.4);">
Vragen? Mail ons op <a href="mailto:info@kunstenkamp.be" style="color:rgba(255,255,255,0.6);">info@kunstenkamp.be</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
function manageUrl(token: string): string {
return `${env.BETTER_AUTH_URL}/manage/${token}`;
}
function cancellationHtml(params: { firstName: string }) {
return `<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<title>Inschrijving geannuleerd</title>
</head>
<body style="margin:0;padding:0;background:#f4f4f5;font-family:sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background:#214e51;border-radius:4px;overflow:hidden;">
<tr>
<td style="padding:40px 48px 32px;">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.5);letter-spacing:0.08em;text-transform:uppercase;">Kunstenkamp</p>
<h1 style="margin:12px 0 0;font-size:28px;color:#ffffff;font-weight:700;">Inschrijving geannuleerd</h1>
</td>
</tr>
<tr>
<td style="padding:0 48px 40px;">
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Hoi ${params.firstName},
</p>
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Je inschrijving voor <strong style="color:#ffffff;">Open Mic Night — vrijdag 18 april 2026</strong> is geannuleerd.
</p>
<p style="margin:0;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Van gedachten veranderd? Je kunt je altijd opnieuw inschrijven via <a href="${baseUrl}/#registration" style="color:#ffffff;">kunstenkamp.be</a>.
</p>
</td>
</tr>
<tr>
<td style="padding:24px 48px;border-top:1px solid rgba(255,255,255,0.1);">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.4);">
Vragen? Mail ons op <a href="mailto:info@kunstenkamp.be" style="color:rgba(255,255,255,0.6);">info@kunstenkamp.be</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
// ---------------------------------------------------------------------------
// Emails
// ---------------------------------------------------------------------------
export async function sendConfirmationEmail(params: {
to: string;
@@ -280,26 +190,41 @@ export async function sendConfirmationEmail(params: {
artForm?: string | null;
giftAmount?: number;
drinkCardValue?: number;
signupUrl?: string;
}) {
const transport = getTransport();
if (!transport) {
console.warn("SMTP not configured — skipping confirmation email");
return;
}
const manageUrl = `${baseUrl}/manage/${params.managementToken}`;
await transport.sendMail({
from,
to: params.to,
subject: "Bevestiging inschrijving — Open Mic Night",
html: registrationConfirmationHtml({
firstName: params.firstName,
manageUrl,
wantsToPerform: params.wantsToPerform,
artForm: params.artForm,
giftAmount: params.giftAmount,
drinkCardValue: params.drinkCardValue,
}),
});
const manage = manageUrl(params.managementToken);
const signup =
params.signupUrl ??
`${env.BETTER_AUTH_URL}/login?signup=1&email=${encodeURIComponent(params.to)}&next=/account`;
const drinkCardCents = (params.drinkCardValue ?? 0) * 100;
const giftCents = params.giftAmount ?? 0;
await send(
"registrationConfirmation",
params.to,
"Bevestiging inschrijving — Open Mic Night",
emailLayout(
"Je inschrijving is bevestigd!",
p(`Hoi ${params.firstName},`) +
p(
`We hebben je inschrijving voor <strong style="color:#fff;">${EVENT}</strong> in goede orde ontvangen.`,
) +
card("Locatie", LOCATION) +
card("Jouw rol", roleLabel(params.wantsToPerform, params.artForm)) +
paymentCard(drinkCardCents, giftCents) +
p(
"Maak een account aan om je inschrijving te beheren en je Drinkkaart te activeren.",
) +
btn(signup, "Account aanmaken") +
p(
"Wil je je gegevens aanpassen of je inschrijving annuleren? De link hieronder is uniek voor jou — deel hem niet.",
) +
btn(manage, "Beheer mijn inschrijving", "secondary") +
note(
`Of kopieer: <span style="color:rgba(255,255,255,0.6);">${manage}</span>`,
),
),
);
}
export async function sendUpdateEmail(params: {
@@ -311,40 +236,183 @@ export async function sendUpdateEmail(params: {
giftAmount?: number;
drinkCardValue?: number;
}) {
const transport = getTransport();
if (!transport) {
console.warn("SMTP not configured — skipping update email");
return;
}
const manageUrl = `${baseUrl}/manage/${params.managementToken}`;
await transport.sendMail({
from,
to: params.to,
subject: "Inschrijving bijgewerkt — Open Mic Night",
html: updateConfirmationHtml({
firstName: params.firstName,
manageUrl,
wantsToPerform: params.wantsToPerform,
artForm: params.artForm,
giftAmount: params.giftAmount,
drinkCardValue: params.drinkCardValue,
}),
});
const manage = manageUrl(params.managementToken);
const drinkCardCents = (params.drinkCardValue ?? 0) * 100;
const giftCents = params.giftAmount ?? 0;
await send(
"updateConfirmation",
params.to,
"Inschrijving bijgewerkt — Open Mic Night",
emailLayout(
"Inschrijving bijgewerkt",
p(`Hoi ${params.firstName},`) +
p(
`Je inschrijving voor <strong style="color:#fff;">${EVENT}</strong> is succesvol bijgewerkt.`,
) +
card("Locatie", LOCATION) +
card("Jouw rol", roleLabel(params.wantsToPerform, params.artForm)) +
paymentCard(drinkCardCents, giftCents) +
btn(manage, "Bekijk mijn inschrijving"),
),
);
}
export async function sendCancellationEmail(params: {
to: string;
firstName: string;
}) {
const transport = getTransport();
if (!transport) {
console.warn("SMTP not configured — skipping cancellation email");
return;
}
await transport.sendMail({
from,
to: params.to,
subject: "Inschrijving geannuleerd — Open Mic Night",
html: cancellationHtml({ firstName: params.firstName }),
});
await send(
"cancellation",
params.to,
"Inschrijving geannuleerd — Open Mic Night",
emailLayout(
"Inschrijving geannuleerd",
p(`Hoi ${params.firstName},`) +
p(
`Je inschrijving voor <strong style="color:#fff;">${EVENT}</strong> is geannuleerd.`,
) +
p(
`Van gedachten veranderd? Je kunt je altijd opnieuw inschrijven via <a href="${env.BETTER_AUTH_URL}/#registration" style="color:#fff;">kunstenkamp.be</a>.`,
),
),
);
}
export async function sendSubscriptionConfirmationEmail(params: {
to: string;
}) {
await send(
"subscriptionConfirmation",
params.to,
"Herinnering ingepland — Open Mic Night",
emailLayout(
"Je herinnering is ingepland!",
p(
`We sturen een herinnering naar <strong style="color:#fff;">${params.to}</strong> wanneer de inschrijvingen openen.`,
) +
card("Inschrijvingen openen", OPENS) +
p(
"Je ontvangt twee herinneringen: één 24 uur van tevoren en één 1 uur voor de opening.",
) +
p("Wees er snel bij — de plaatsen zijn beperkt!") +
btn(`${env.BETTER_AUTH_URL}/#registration`, "Bekijk de pagina"),
),
);
}
export async function sendReminder24hEmail(params: {
to: string;
firstName?: string | null;
}) {
const greeting = params.firstName ? `Hoi ${params.firstName},` : "Hoi!";
await send(
"reminder24h",
params.to,
"Nog 24 uur — inschrijvingen openen morgen!",
emailLayout(
"Nog 24 uur!",
p(greeting) +
p(
`Morgen openen de inschrijvingen voor <strong style="color:#fff;">${EVENT}</strong>. Zet je wekker!`,
) +
card("Inschrijvingen openen", OPENS) +
p("Wees er snel bij — de plaatsen zijn beperkt!") +
btn(`${env.BETTER_AUTH_URL}/#registration`, "Bekijk de pagina") +
note(
"Je ontvangt dit bericht omdat je een herinnering hebt aangevraagd op kunstenkamp.be.",
),
),
);
}
export async function sendReminderEmail(params: {
to: string;
firstName?: string | null;
}) {
const greeting = params.firstName ? `Hoi ${params.firstName},` : "Hoi!";
await send(
"reminder1h",
params.to,
"Nog 1 uur — inschrijvingen openen straks!",
emailLayout(
"Nog 1 uur!",
p(greeting) +
p(
`Over ongeveer <strong style="color:#fff;">1 uur</strong> openen de inschrijvingen voor <strong style="color:#fff;">${EVENT}</strong>.`,
) +
card("Inschrijvingen openen", OPENS) +
p("Wees er snel bij — de plaatsen zijn beperkt!") +
btn(`${env.BETTER_AUTH_URL}/#registration`, "Schrijf je in") +
note(
"Je ontvangt dit bericht omdat je een herinnering hebt aangevraagd op kunstenkamp.be.",
),
),
);
}
export async function sendPaymentReminderEmail(params: {
to: string;
firstName: string;
managementToken: string;
drinkCardValue?: number;
giftAmount?: number;
}) {
const manage = manageUrl(params.managementToken);
const drinkCardCents = (params.drinkCardValue ?? 0) * 100;
const giftCents = params.giftAmount ?? 0;
const total = drinkCardCents + giftCents;
const amountNote =
total > 0
? `Je betaling van <strong style="color:#fff;">${euro(total)}</strong> staat nog open.`
: "Je betaling staat nog open.";
await send(
"paymentReminder",
params.to,
"Herinnering: betaling open — Open Mic Night",
emailLayout(
"Betaling nog open",
p(`Hoi ${params.firstName},`) +
p(
`Je hebt je ingeschreven voor <strong style="color:#fff;">${EVENT}</strong>, maar we hebben nog geen betaling ontvangen.`,
) +
card("Locatie", LOCATION) +
p(`${amountNote} Log in op je account om te betalen.`) +
btn(`${env.BETTER_AUTH_URL}/account`, "Betaal nu") +
note(
`Je inschrijving beheren? <a href="${manage}" style="color:rgba(255,255,255,0.6);">Klik hier</a>`,
),
),
);
}
export async function sendPaymentConfirmationEmail(params: {
to: string;
firstName: string;
managementToken: string;
drinkCardValue?: number;
giftAmount?: number;
}) {
const manage = manageUrl(params.managementToken);
const drinkCardCents = (params.drinkCardValue ?? 0) * 100;
const giftCents = params.giftAmount ?? 0;
await send(
"paymentConfirmation",
params.to,
"Betaling ontvangen — Open Mic Night",
emailLayout(
"Betaling ontvangen!",
p(`Hoi ${params.firstName},`) +
p(
`We hebben je betaling voor <strong style="color:#fff;">${EVENT}</strong> in goede orde ontvangen. Tot dan!`,
) +
card("Locatie", LOCATION) +
paymentCard(drinkCardCents, giftCents) +
btn(manage, "Beheer mijn inschrijving", "secondary"),
),
);
}

View File

@@ -13,6 +13,7 @@ const requireAuth = o.middleware(async ({ context, next }) => {
return next({
context: {
session: context.session,
env: context.env,
},
});
});
@@ -29,6 +30,7 @@ const requireAdmin = o.middleware(async ({ context, next }) => {
return next({
context: {
session: context.session,
env: context.env,
},
});
});

View File

@@ -0,0 +1,177 @@
import { env } from "@kk/env/server";
import nodemailer from "nodemailer";
import { emailLog } from "../email";
// Re-use the same SMTP transport strategy as email.ts:
// only cache a successfully-created transporter; never cache null so that a
// cold isolate that receives env vars after module init can still pick them up.
let _transport: nodemailer.Transporter | undefined;
function getTransport(): nodemailer.Transporter | null {
if (_transport) return _transport;
if (!env.SMTP_HOST || !env.SMTP_USER || !env.SMTP_PASS) {
return null;
}
_transport = nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT,
secure: env.SMTP_PORT === 465,
auth: {
user: env.SMTP_USER,
pass: env.SMTP_PASS,
},
});
return _transport;
}
function formatEuro(cents: number): string {
return new Intl.NumberFormat("nl-BE", {
style: "currency",
currency: "EUR",
}).format(cents / 100);
}
function deductionHtml(params: {
firstName: string;
amountCents: number;
newBalanceCents: number;
dateTime: string;
drinkkaartUrl: string;
}) {
return `<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Drinkkaart afschrijving</title>
</head>
<body style="margin:0;padding:0;background:#f4f4f5;font-family:sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background:#214e51;border-radius:4px;overflow:hidden;">
<!-- Header -->
<tr>
<td style="padding:40px 48px 32px;">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.5);letter-spacing:0.08em;text-transform:uppercase;">Kunstenkamp</p>
<h1 style="margin:12px 0 0;font-size:28px;color:#ffffff;font-weight:700;">Drinkkaart afschrijving</h1>
</td>
</tr>
<!-- Body -->
<tr>
<td style="padding:0 48px 32px;">
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Hoi ${params.firstName},
</p>
<p style="margin:0 0 24px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Er is zojuist een bedrag afgeschreven van je Drinkkaart.
</p>
<!-- Summary -->
<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(255,255,255,0.08);border-radius:4px;margin:0 0 24px;">
<tr>
<td style="padding:20px 24px;">
<p style="margin:0 0 12px;font-size:12px;color:rgba(255,255,255,0.5);text-transform:uppercase;letter-spacing:0.06em;">Afschrijving</p>
<p style="margin:0 0 8px;font-size:22px;color:#ffffff;font-weight:700;">&minus; ${formatEuro(params.amountCents)}</p>
<p style="margin:8px 0 0;font-size:14px;color:rgba(255,255,255,0.55);">${params.dateTime}</p>
</td>
</tr>
<tr>
<td style="padding:0 24px 20px;border-top:1px solid rgba(255,255,255,0.08);">
<p style="margin:12px 0 4px;font-size:12px;color:rgba(255,255,255,0.5);text-transform:uppercase;letter-spacing:0.06em;">Nieuw saldo</p>
<p style="margin:0;font-size:20px;color:#ffffff;font-weight:600;">${formatEuro(params.newBalanceCents)}</p>
</td>
</tr>
</table>
<!-- CTA -->
<table cellpadding="0" cellspacing="0">
<tr>
<td style="border-radius:2px;background:#ffffff;">
<a href="${params.drinkkaartUrl}" style="display:inline-block;padding:14px 32px;font-size:16px;font-weight:600;color:#214e51;text-decoration:none;">
Bekijk mijn Drinkkaart
</a>
</td>
</tr>
</table>
<p style="margin:16px 0 0;font-size:13px;color:rgba(255,255,255,0.4);">
Klopt er iets niet? Neem contact op via <a href="mailto:info@kunstenkamp.be" style="color:rgba(255,255,255,0.6);">info@kunstenkamp.be</a>.
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding:24px 48px;border-top:1px solid rgba(255,255,255,0.1);">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.4);line-height:1.6;">
Kunstenkamp vzw — Een initiatief voor en door kunstenaars.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
/**
* Send a deduction notification email to the cardholder.
* Throws on SMTP error so the queue consumer can retry.
*/
export async function sendDeductionEmail(params: {
to: string;
firstName: string;
amountCents: number;
newBalanceCents: number;
}): Promise<void> {
emailLog("info", "email.attempt", { type: "deduction", to: params.to });
const transport = getTransport();
if (!transport) {
emailLog("warn", "email.skipped", {
type: "deduction",
to: params.to,
reason: "smtp_not_configured",
hasHost: !!env.SMTP_HOST,
hasUser: !!env.SMTP_USER,
hasPass: !!env.SMTP_PASS,
});
return;
}
const now = new Date();
const dateTime = now.toLocaleString("nl-BE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
const amountFormatted = new Intl.NumberFormat("nl-BE", {
style: "currency",
currency: "EUR",
}).format(params.amountCents / 100);
try {
await transport.sendMail({
from: env.SMTP_FROM,
to: params.to,
subject: `Drinkkaart — ${amountFormatted} afgeschreven`,
html: deductionHtml({
firstName: params.firstName,
amountCents: params.amountCents,
newBalanceCents: params.newBalanceCents,
dateTime,
drinkkaartUrl: `${env.BETTER_AUTH_URL}/drinkkaart`,
}),
});
emailLog("info", "email.sent", { type: "deduction", to: params.to });
} catch (err) {
emailLog("error", "email.error", {
type: "deduction",
to: params.to,
error: String(err),
});
throw err;
}
}

View File

@@ -0,0 +1,120 @@
// QR token utilities for Drinkkaart using Web Crypto API
// Compatible with Cloudflare Workers (no node:crypto dependency).
const QR_TOKEN_TTL_MS = 5 * 60 * 1000; // 5 minutes
interface QrPayload {
drinkkaartId: string;
userId: string;
iat: number;
exp: number;
}
function toBase64Url(buffer: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
function base64UrlToBuffer(str: string): Uint8Array<ArrayBuffer> {
const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
// Ensure the underlying buffer is a plain ArrayBuffer (required by crypto.subtle)
return new Uint8Array(bytes.buffer.slice(0));
}
/** Generate a cryptographically secure 32-byte hex secret for a new Drinkkaart. */
export function generateQrSecret(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
/**
* Sign a QR token for the given drinkkaart / user.
* Returns the compact token string and the expiry date.
*/
export async function signQrToken(
payload: Omit<QrPayload, "iat" | "exp">,
qrSecret: string,
authSecret: string,
): Promise<{ token: string; expiresAt: Date }> {
const now = Date.now();
const exp = now + QR_TOKEN_TTL_MS;
const full: QrPayload = { ...payload, iat: now, exp };
const encodedPayload = btoa(JSON.stringify(full))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
const keyData = new TextEncoder().encode(qrSecret + authSecret);
const key = await crypto.subtle.importKey(
"raw",
keyData,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sigBuffer = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(encodedPayload),
);
const sig = toBase64Url(sigBuffer);
return { token: `${encodedPayload}.${sig}`, expiresAt: new Date(exp) };
}
/**
* Verify a QR token. Throws if the signature is invalid or the token is expired.
* Returns the decoded payload.
*/
export async function verifyQrToken(
token: string,
qrSecret: string,
authSecret: string,
): Promise<QrPayload> {
const parts = token.split(".");
if (parts.length !== 2) throw new Error("MALFORMED");
const [encodedPayload, providedSig] = parts as [string, string];
const keyData = new TextEncoder().encode(qrSecret + authSecret);
const key = await crypto.subtle.importKey(
"raw",
keyData,
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"],
);
const sigBytes = base64UrlToBuffer(providedSig);
const valid = await crypto.subtle.verify(
"HMAC",
key,
sigBytes,
new TextEncoder().encode(encodedPayload),
);
if (!valid) throw new Error("INVALID_SIGNATURE");
const payload: QrPayload = JSON.parse(
atob(encodedPayload.replace(/-/g, "+").replace(/_/g, "/")),
);
if (Date.now() > payload.exp) throw new Error("EXPIRED");
return payload;
}
/** Format a cents integer as a Belgian euro string, e.g. 1250 → "€ 12,50". */
export function formatCents(cents: number): string {
return new Intl.NumberFormat("nl-BE", {
style: "currency",
currency: "EUR",
}).format(cents / 100);
}

View File

@@ -0,0 +1,717 @@
import { randomUUID } from "node:crypto";
import { db } from "@kk/db";
import {
drinkkaart,
drinkkaartTopup,
drinkkaartTransaction,
} from "@kk/db/schema";
import { user } from "@kk/db/schema/auth";
import { ORPCError } from "@orpc/server";
import { and, desc, eq, gte, lte, sql } from "drizzle-orm";
import { z } from "zod";
import type { EmailMessage } from "../email-queue";
import { adminProcedure, protectedProcedure } from "../index";
import { sendDeductionEmail } from "../lib/drinkkaart-email";
import {
formatCents,
generateQrSecret,
signQrToken,
verifyQrToken,
} from "../lib/drinkkaart-utils";
// ---------------------------------------------------------------------------
// Internal helper: get or create the drinkkaart row for a user
// ---------------------------------------------------------------------------
async function getOrCreateDrinkkaart(userId: string) {
const rows = await db
.select()
.from(drinkkaart)
.where(eq(drinkkaart.userId, userId))
.limit(1);
if (rows[0]) return rows[0];
const now = new Date();
const newCard = {
id: randomUUID(),
userId,
balance: 0,
version: 0,
qrSecret: generateQrSecret(),
createdAt: now,
updatedAt: now,
};
await db.insert(drinkkaart).values(newCard);
return newCard;
}
// ---------------------------------------------------------------------------
// Router procedures
// ---------------------------------------------------------------------------
export const drinkkaartRouter = {
// -------------------------------------------------------------------------
// 4.1 getMyDrinkkaart
// -------------------------------------------------------------------------
getMyDrinkkaart: protectedProcedure.handler(async ({ context }) => {
const userId = context.session.user.id;
const card = await getOrCreateDrinkkaart(userId);
const [topups, transactions] = await Promise.all([
db
.select()
.from(drinkkaartTopup)
.where(eq(drinkkaartTopup.drinkkaartId, card.id))
.orderBy(desc(drinkkaartTopup.paidAt))
.limit(50),
db
.select()
.from(drinkkaartTransaction)
.where(eq(drinkkaartTransaction.drinkkaartId, card.id))
.orderBy(desc(drinkkaartTransaction.createdAt))
.limit(50),
]);
return {
id: card.id,
balance: card.balance,
balanceFormatted: formatCents(card.balance),
createdAt: card.createdAt,
updatedAt: card.updatedAt,
topups: topups.map((t) => ({
id: t.id,
type: t.type,
amountCents: t.amountCents,
balanceBefore: t.balanceBefore,
balanceAfter: t.balanceAfter,
reason: t.reason,
paidAt: t.paidAt,
})),
transactions: transactions.map((t) => ({
id: t.id,
type: t.type,
amountCents: t.amountCents,
balanceBefore: t.balanceBefore,
balanceAfter: t.balanceAfter,
note: t.note,
reversedBy: t.reversedBy,
reverses: t.reverses,
createdAt: t.createdAt,
})),
};
}),
// -------------------------------------------------------------------------
// 4.2 getMyQrToken
// -------------------------------------------------------------------------
getMyQrToken: protectedProcedure.handler(async ({ context }) => {
const userId = context.session.user.id;
const card = await getOrCreateDrinkkaart(userId);
const authSecret = context.env.BETTER_AUTH_SECRET;
const { token, expiresAt } = await signQrToken(
{ drinkkaartId: card.id, userId },
card.qrSecret,
authSecret,
);
return { token, expiresAt };
}),
// -------------------------------------------------------------------------
// 4.3 getTopUpCheckoutUrl
// -------------------------------------------------------------------------
getTopUpCheckoutUrl: protectedProcedure
.input(
z.object({
amountCents: z
.number()
.int()
.min(100, "Minimumbedrag is € 1,00")
.max(50000, "Maximumbedrag is € 500,00"),
}),
)
.handler(async ({ input, context }) => {
const { env: serverEnv, session } = context;
if (!serverEnv.MOLLIE_API_KEY) {
throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Mollie is niet geconfigureerd",
});
}
const card = await getOrCreateDrinkkaart(session.user.id);
// Mollie amounts must be formatted as "10.00" (string, 2 decimal places)
const amountValue = (input.amountCents / 100).toFixed(2);
const response = await fetch("https://api.mollie.com/v2/payments", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${serverEnv.MOLLIE_API_KEY}`,
},
body: JSON.stringify({
amount: { value: amountValue, currency: "EUR" },
description: `Drinkkaart Opladen — ${formatCents(input.amountCents)}`,
redirectUrl: `${serverEnv.BETTER_AUTH_URL}/account?topup=success`,
webhookUrl: `${serverEnv.BETTER_AUTH_URL}/api/webhook/mollie`,
locale: "nl_NL",
metadata: {
type: "drinkkaart_topup",
drinkkaartId: card.id,
userId: session.user.id,
},
}),
});
if (!response.ok) {
const errorData = await response.json();
console.error("Mollie Drinkkaart checkout error:", errorData);
throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Kon checkout niet aanmaken",
});
}
const data = (await response.json()) as {
_links?: { checkout?: { href?: string } };
};
const checkoutUrl = data._links?.checkout?.href;
if (!checkoutUrl) {
throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Geen checkout URL ontvangen",
});
}
return { checkoutUrl };
}),
// -------------------------------------------------------------------------
// 4.4 resolveQrToken (admin)
// -------------------------------------------------------------------------
resolveQrToken: adminProcedure
.input(z.object({ token: z.string().min(1) }))
.handler(async ({ input, context }) => {
let drinkkaartId: string;
let userId: string;
// Decode payload first to get the drinkkaartId for key lookup
const parts = input.token.split(".");
if (parts.length !== 2) {
throw new ORPCError("BAD_REQUEST", {
message: "Ongeldig QR-token formaat",
});
}
let rawPayload: { drinkkaartId: string; userId: string; exp: number };
try {
rawPayload = JSON.parse(
atob((parts[0] as string).replace(/-/g, "+").replace(/_/g, "/")),
);
drinkkaartId = rawPayload.drinkkaartId;
userId = rawPayload.userId;
} catch {
throw new ORPCError("BAD_REQUEST", { message: "Ongeldig QR-token" });
}
// Fetch the card to get the per-user secret
const card = await db
.select()
.from(drinkkaart)
.where(eq(drinkkaart.id, drinkkaartId))
.limit(1)
.then((r) => r[0]);
if (!card) {
throw new ORPCError("NOT_FOUND", {
message: "Drinkkaart niet gevonden",
});
}
// Verify HMAC and expiry
try {
await verifyQrToken(
input.token,
card.qrSecret,
context.env.BETTER_AUTH_SECRET,
);
} catch (err: unknown) {
const msg = (err as Error).message;
if (msg === "EXPIRED") {
throw new ORPCError("GONE", { message: "QR-token is verlopen" });
}
throw new ORPCError("UNAUTHORIZED", { message: "Ongeldig QR-token" });
}
// Fetch user
const cardUser = await db
.select({ id: user.id, name: user.name, email: user.email })
.from(user)
.where(eq(user.id, userId))
.limit(1)
.then((r) => r[0]);
if (!cardUser) {
throw new ORPCError("NOT_FOUND", {
message: "Gebruiker niet gevonden",
});
}
return {
drinkkaartId: card.id,
userId: cardUser.id,
userName: cardUser.name,
userEmail: cardUser.email,
balance: card.balance,
balanceFormatted: formatCents(card.balance),
};
}),
// -------------------------------------------------------------------------
// 4.5 deductBalance (admin)
// -------------------------------------------------------------------------
deductBalance: adminProcedure
.input(
z.object({
drinkkaartId: z.string().min(1),
amountCents: z.number().int().min(1),
note: z.string().max(200).optional(),
}),
)
.handler(async ({ input, context }) => {
const adminId = context.session.user.id;
const card = await db
.select()
.from(drinkkaart)
.where(eq(drinkkaart.id, input.drinkkaartId))
.limit(1)
.then((r) => r[0]);
if (!card) {
throw new ORPCError("NOT_FOUND", {
message: "Drinkkaart niet gevonden",
});
}
if (card.balance < input.amountCents) {
throw new ORPCError("UNPROCESSABLE_CONTENT", {
message: `Onvoldoende saldo. Huidig saldo: ${formatCents(card.balance)}`,
});
}
const balanceBefore = card.balance;
const balanceAfter = balanceBefore - input.amountCents;
const now = new Date();
// Optimistic lock update
const result = await db
.update(drinkkaart)
.set({
balance: balanceAfter,
version: card.version + 1,
updatedAt: now,
})
.where(
and(
eq(drinkkaart.id, input.drinkkaartId),
eq(drinkkaart.version, card.version),
),
);
if (result.rowsAffected === 0) {
throw new ORPCError("CONFLICT", {
message: "Gelijktijdige wijziging gedetecteerd. Probeer opnieuw.",
});
}
const transactionId = randomUUID();
await db.insert(drinkkaartTransaction).values({
id: transactionId,
drinkkaartId: card.id,
userId: card.userId,
adminId,
amountCents: input.amountCents,
balanceBefore,
balanceAfter,
type: "deduction",
note: input.note ?? null,
createdAt: now,
});
// Fire-and-forget deduction email (via queue when available)
const cardUser = await db
.select({ email: user.email, name: user.name })
.from(user)
.where(eq(user.id, card.userId))
.limit(1)
.then((r) => r[0]);
if (cardUser) {
const deductionMsg: EmailMessage = {
type: "deduction",
to: cardUser.email,
firstName: cardUser.name.split(" ")[0] ?? cardUser.name,
amountCents: input.amountCents,
newBalanceCents: balanceAfter,
};
if (context.emailQueue) {
await context.emailQueue.send(deductionMsg);
} else {
await sendDeductionEmail(deductionMsg).catch((err) =>
console.error("Failed to send deduction email:", err),
);
}
}
return {
transactionId,
balanceBefore,
balanceAfter,
balanceFormatted: formatCents(balanceAfter),
};
}),
// -------------------------------------------------------------------------
// 4.6 adminCreditBalance (admin)
// -------------------------------------------------------------------------
adminCreditBalance: adminProcedure
.input(
z.object({
drinkkaartId: z.string().min(1),
amountCents: z.number().int().min(1),
reason: z.string().min(1).max(200),
}),
)
.handler(async ({ input, context }) => {
const adminId = context.session.user.id;
const card = await db
.select()
.from(drinkkaart)
.where(eq(drinkkaart.id, input.drinkkaartId))
.limit(1)
.then((r) => r[0]);
if (!card) {
throw new ORPCError("NOT_FOUND", {
message: "Drinkkaart niet gevonden",
});
}
const balanceBefore = card.balance;
const balanceAfter = balanceBefore + input.amountCents;
const now = new Date();
const result = await db
.update(drinkkaart)
.set({
balance: balanceAfter,
version: card.version + 1,
updatedAt: now,
})
.where(
and(
eq(drinkkaart.id, input.drinkkaartId),
eq(drinkkaart.version, card.version),
),
);
if (result.rowsAffected === 0) {
throw new ORPCError("CONFLICT", {
message: "Gelijktijdige wijziging gedetecteerd. Probeer opnieuw.",
});
}
const topupId = randomUUID();
await db.insert(drinkkaartTopup).values({
id: topupId,
drinkkaartId: card.id,
userId: card.userId,
amountCents: input.amountCents,
balanceBefore,
balanceAfter,
type: "admin_credit",
adminId,
reason: input.reason,
paidAt: now,
});
return {
topupId,
balanceBefore,
balanceAfter,
balanceFormatted: formatCents(balanceAfter),
};
}),
// -------------------------------------------------------------------------
// 4.7 reverseTransaction (admin)
// -------------------------------------------------------------------------
reverseTransaction: adminProcedure
.input(
z.object({
transactionId: z.string().min(1),
note: z.string().max(200).optional(),
}),
)
.handler(async ({ input, context }) => {
const adminId = context.session.user.id;
const original = await db
.select()
.from(drinkkaartTransaction)
.where(eq(drinkkaartTransaction.id, input.transactionId))
.limit(1)
.then((r) => r[0]);
if (!original) {
throw new ORPCError("NOT_FOUND", {
message: "Transactie niet gevonden",
});
}
if (original.type !== "deduction") {
throw new ORPCError("BAD_REQUEST", {
message: "Alleen afschrijvingen kunnen worden teruggedraaid",
});
}
if (original.reversedBy) {
throw new ORPCError("CONFLICT", {
message: "Deze transactie is al teruggedraaid",
});
}
const card = await db
.select()
.from(drinkkaart)
.where(eq(drinkkaart.id, original.drinkkaartId))
.limit(1)
.then((r) => r[0]);
if (!card) {
throw new ORPCError("NOT_FOUND", {
message: "Drinkkaart niet gevonden",
});
}
const balanceBefore = card.balance;
const balanceAfter = balanceBefore + original.amountCents;
const now = new Date();
const result = await db
.update(drinkkaart)
.set({
balance: balanceAfter,
version: card.version + 1,
updatedAt: now,
})
.where(
and(eq(drinkkaart.id, card.id), eq(drinkkaart.version, card.version)),
);
if (result.rowsAffected === 0) {
throw new ORPCError("CONFLICT", {
message: "Gelijktijdige wijziging gedetecteerd. Probeer opnieuw.",
});
}
const reversalId = randomUUID();
await db.insert(drinkkaartTransaction).values({
id: reversalId,
drinkkaartId: card.id,
userId: card.userId,
adminId,
amountCents: original.amountCents,
balanceBefore,
balanceAfter,
type: "reversal",
reverses: original.id,
note: input.note ?? null,
createdAt: now,
});
// Mark original as reversed
await db
.update(drinkkaartTransaction)
.set({ reversedBy: reversalId })
.where(eq(drinkkaartTransaction.id, original.id));
return {
reversalId,
balanceBefore,
balanceAfter,
balanceFormatted: formatCents(balanceAfter),
};
}),
// -------------------------------------------------------------------------
// 4.8 getTransactionLog (admin) — paginated audit log
// -------------------------------------------------------------------------
getTransactionLog: adminProcedure
.input(
z.object({
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(200).default(50),
userId: z.string().optional(),
adminId: z.string().optional(),
type: z.enum(["deduction", "reversal"]).optional(),
dateFrom: z.string().optional(),
dateTo: z.string().optional(),
}),
)
.handler(async ({ input }) => {
// Build conditions
const conditions = [];
if (input.userId) {
conditions.push(eq(drinkkaartTransaction.userId, input.userId));
}
if (input.adminId) {
conditions.push(eq(drinkkaartTransaction.adminId, input.adminId));
}
if (input.type) {
conditions.push(eq(drinkkaartTransaction.type, input.type));
}
if (input.dateFrom) {
conditions.push(
gte(drinkkaartTransaction.createdAt, new Date(input.dateFrom)),
);
}
if (input.dateTo) {
conditions.push(
lte(drinkkaartTransaction.createdAt, new Date(input.dateTo)),
);
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
// Paginated results with user names
const offset = (input.page - 1) * input.pageSize;
const rows = await db
.select({
id: drinkkaartTransaction.id,
type: drinkkaartTransaction.type,
userId: drinkkaartTransaction.userId,
adminId: drinkkaartTransaction.adminId,
amountCents: drinkkaartTransaction.amountCents,
balanceBefore: drinkkaartTransaction.balanceBefore,
balanceAfter: drinkkaartTransaction.balanceAfter,
note: drinkkaartTransaction.note,
reversedBy: drinkkaartTransaction.reversedBy,
reverses: drinkkaartTransaction.reverses,
createdAt: drinkkaartTransaction.createdAt,
})
.from(drinkkaartTransaction)
.where(where)
.orderBy(desc(drinkkaartTransaction.createdAt))
.limit(input.pageSize)
.offset(offset);
// Collect unique user IDs
const userIds = [
...new Set([
...rows.map((r) => r.userId),
...rows.map((r) => r.adminId),
]),
];
const users =
userIds.length > 0
? await db
.select({ id: user.id, name: user.name, email: user.email })
.from(user)
.where(
userIds.length === 1
? eq(user.id, userIds[0] as string)
: sql`${user.id} IN (${sql.join(
userIds.map((id) => sql`${id}`),
sql`, `,
)})`,
)
: [];
const userMap = new Map(users.map((u) => [u.id, u]));
// Count total
const countResult = await db
.select({ count: sql<number>`count(*)` })
.from(drinkkaartTransaction)
.where(where);
const total = Number(countResult[0]?.count ?? 0);
return {
transactions: rows.map((r) => ({
id: r.id,
type: r.type,
userId: r.userId,
userName: userMap.get(r.userId)?.name ?? "Onbekend",
userEmail: userMap.get(r.userId)?.email ?? "",
adminId: r.adminId,
adminName: userMap.get(r.adminId)?.name ?? "Onbekend",
amountCents: r.amountCents,
balanceBefore: r.balanceBefore,
balanceAfter: r.balanceAfter,
note: r.note,
reversedBy: r.reversedBy,
reverses: r.reverses,
createdAt: r.createdAt,
})),
total,
page: input.page,
pageSize: input.pageSize,
};
}),
// -------------------------------------------------------------------------
// Extra: getDrinkkaartByUserId (admin) — for manual credit user search
// -------------------------------------------------------------------------
getDrinkkaartByUserId: adminProcedure
.input(z.object({ userId: z.string().min(1) }))
.handler(async ({ input }) => {
const cardUser = await db
.select({ id: user.id, name: user.name, email: user.email })
.from(user)
.where(eq(user.id, input.userId))
.limit(1)
.then((r) => r[0]);
if (!cardUser) {
throw new ORPCError("NOT_FOUND", {
message: "Gebruiker niet gevonden",
});
}
const card = await getOrCreateDrinkkaart(input.userId);
return {
drinkkaartId: card.id,
userId: cardUser.id,
userName: cardUser.name,
userEmail: cardUser.email,
balance: card.balance,
balanceFormatted: formatCents(card.balance),
};
}),
// -------------------------------------------------------------------------
// Extra: searchUsers (admin) — for manual credit user search UI
// -------------------------------------------------------------------------
searchUsers: adminProcedure
.input(z.object({ query: z.string().min(1) }))
.handler(async ({ input }) => {
const term = `%${input.query}%`;
const results = await db
.select({ id: user.id, name: user.name, email: user.email })
.from(user)
.where(
sql`lower(${user.name}) LIKE lower(${term}) OR lower(${user.email}) LIKE lower(${term})`,
)
.limit(20);
return results;
}),
};

File diff suppressed because it is too large Load Diff

View File

@@ -15,10 +15,12 @@
"@kk/env": "workspace:*",
"better-auth": "catalog:",
"dotenv": "catalog:",
"nodemailer": "^8.0.2",
"zod": "catalog:"
},
"devDependencies": {
"@kk/config": "workspace:*",
"@types/nodemailer": "^7.0.11",
"typescript": "catalog:"
}
}

View File

@@ -5,6 +5,96 @@ import { env } from "@kk/env/server";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { tanstackStartCookies } from "better-auth/tanstack-start";
import nodemailer from "nodemailer";
const _smtpFrom = env.SMTP_FROM ?? "Kunstenkamp <info@kunstenkamp.be>";
let _transport: nodemailer.Transporter | null | undefined;
function getTransport(): nodemailer.Transporter | null {
if (_transport !== undefined) return _transport;
if (!env.SMTP_HOST || !env.SMTP_USER || !env.SMTP_PASS) {
_transport = null;
return null;
}
_transport = nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT,
secure: env.SMTP_PORT === 465,
auth: { user: env.SMTP_USER, pass: env.SMTP_PASS },
});
return _transport;
}
async function sendPasswordResetEmail(to: string, resetUrl: string) {
const transport = getTransport();
if (!transport) {
console.warn("SMTP not configured — skipping password reset email");
return;
}
const html = `<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Wachtwoord opnieuw instellen</title>
</head>
<body style="margin:0;padding:0;background:#f4f4f5;font-family:sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background:#214e51;border-radius:4px;overflow:hidden;">
<tr>
<td style="padding:40px 48px 32px;">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.5);letter-spacing:0.08em;text-transform:uppercase;">Kunstenkamp</p>
<h1 style="margin:12px 0 0;font-size:28px;color:#ffffff;font-weight:700;">Wachtwoord opnieuw instellen</h1>
</td>
</tr>
<tr>
<td style="padding:0 48px 32px;">
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
We hebben een aanvraag ontvangen om het wachtwoord van je Kunstenkamp-account opnieuw in te stellen.
</p>
<p style="margin:0 0 24px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Klik op de knop hieronder om een nieuw wachtwoord in te stellen. Deze link is <strong style="color:#ffffff;">1 uur geldig</strong>.
</p>
<table cellpadding="0" cellspacing="0">
<tr>
<td style="border-radius:2px;background:#ffffff;">
<a href="${resetUrl}" style="display:inline-block;padding:14px 32px;font-size:16px;font-weight:600;color:#214e51;text-decoration:none;">
Stel wachtwoord in
</a>
</td>
</tr>
</table>
<p style="margin:16px 0 0;font-size:13px;color:rgba(255,255,255,0.4);">
Of kopieer deze link: <span style="color:rgba(255,255,255,0.6);">${resetUrl}</span>
</p>
<p style="margin:24px 0 0;font-size:14px;color:rgba(255,255,255,0.5);line-height:1.6;">
Heb je dit niet aangevraagd? Dan hoef je niets te doen — je wachtwoord blijft ongewijzigd.
</p>
</td>
</tr>
<tr>
<td style="padding:24px 48px;border-top:1px solid rgba(255,255,255,0.1);">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.4);line-height:1.6;">
Vragen? Mail ons op <a href="mailto:info@kunstenkamp.be" style="color:rgba(255,255,255,0.6);">info@kunstenkamp.be</a><br/>
Kunstenkamp vzw — Een initiatief voor en door kunstenaars.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
await transport.sendMail({
from: _smtpFrom,
to,
subject: "Wachtwoord opnieuw instellen — Kunstenkamp",
html,
});
}
export const auth = betterAuth({
database: drizzleAdapter(db, {
@@ -12,7 +102,11 @@ export const auth = betterAuth({
schema: schema,
}),
trustedOrigins: [env.CORS_ORIGIN],
trustedOrigins: [
env.CORS_ORIGIN,
"http://localhost:3000",
"http://localhost:3001",
],
emailAndPassword: {
enabled: true,
// Use Cloudflare's native scrypt via node:crypto for better performance
@@ -31,6 +125,9 @@ export const auth = betterAuth({
return keyBuffer.equals(hashBuffer);
},
},
sendResetPassword: async ({ user, url }) => {
await sendPasswordResetEmail(user.email, url);
},
},
user: {
additionalFields: {

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,56 @@
-- Migration: Add Drinkkaart digital balance card tables
CREATE TABLE `drinkkaart` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`balance` integer DEFAULT 0 NOT NULL,
`version` integer DEFAULT 0 NOT NULL,
`qr_secret` text NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
UNIQUE(`user_id`)
);
--> statement-breakpoint
CREATE TABLE `drinkkaart_transaction` (
`id` text PRIMARY KEY NOT NULL,
`drinkkaart_id` text NOT NULL,
`user_id` text NOT NULL,
`admin_id` text NOT NULL,
`amount_cents` integer NOT NULL,
`balance_before` integer NOT NULL,
`balance_after` integer NOT NULL,
`type` text NOT NULL,
`reversed_by` text,
`reverses` text,
`note` text,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `drinkkaart_topup` (
`id` text PRIMARY KEY NOT NULL,
`drinkkaart_id` text NOT NULL,
`user_id` text NOT NULL,
`amount_cents` integer NOT NULL,
`balance_before` integer NOT NULL,
`balance_after` integer NOT NULL,
`type` text NOT NULL,
`lemonsqueezy_order_id` text,
`lemonsqueezy_customer_id` text,
`admin_id` text,
`reason` text,
`paid_at` integer NOT NULL,
UNIQUE(`lemonsqueezy_order_id`)
);
--> statement-breakpoint
CREATE INDEX `idx_drinkkaart_user_id` ON `drinkkaart` (`user_id`);
--> statement-breakpoint
CREATE INDEX `idx_dkt_drinkkaart_id` ON `drinkkaart_transaction` (`drinkkaart_id`);
--> statement-breakpoint
CREATE INDEX `idx_dkt_user_id` ON `drinkkaart_transaction` (`user_id`);
--> statement-breakpoint
CREATE INDEX `idx_dkt_admin_id` ON `drinkkaart_transaction` (`admin_id`);
--> statement-breakpoint
CREATE INDEX `idx_dkt_created_at` ON `drinkkaart_transaction` (`created_at`);
--> statement-breakpoint
CREATE INDEX `idx_dktu_drinkkaart_id` ON `drinkkaart_topup` (`drinkkaart_id`);
--> statement-breakpoint
CREATE INDEX `idx_dktu_user_id` ON `drinkkaart_topup` (`user_id`);

View File

@@ -0,0 +1,2 @@
-- Migration: Track when a watcher's registration fee has been credited to their drinkkaart
ALTER TABLE `registration` ADD COLUMN `drinkkaart_credited_at` integer;

View File

@@ -0,0 +1,17 @@
-- Migrate from Lemonsqueezy to Mollie
-- Renames payment provider columns in registration and drinkkaart_topup tables.
-- registration table:
-- lemonsqueezy_order_id -> mollie_payment_id
-- lemonsqueezy_customer_id -> dropped (Mollie does not return a persistent
-- customer ID for one-off payments)
ALTER TABLE registration RENAME COLUMN lemonsqueezy_order_id TO mollie_payment_id;
ALTER TABLE registration DROP COLUMN lemonsqueezy_customer_id;
-- drinkkaart_topup table:
-- lemonsqueezy_order_id -> mollie_payment_id
-- lemonsqueezy_customer_id -> dropped
ALTER TABLE drinkkaart_topup RENAME COLUMN lemonsqueezy_order_id TO mollie_payment_id;
ALTER TABLE drinkkaart_topup DROP COLUMN lemonsqueezy_customer_id;

View File

@@ -0,0 +1,9 @@
-- Add reminder table for email reminders 1 hour before registration opens
CREATE TABLE `reminder` (
`id` text PRIMARY KEY NOT NULL,
`email` text NOT NULL,
`sent_at` integer,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
);
--> statement-breakpoint
CREATE INDEX `reminder_email_idx` ON `reminder` (`email`);

View File

@@ -0,0 +1,101 @@
ALTER TABLE `registration` RENAME COLUMN "wants_to_perform" TO "registration_type";--> statement-breakpoint
CREATE TABLE `drinkkaart` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`balance` integer DEFAULT 0 NOT NULL,
`version` integer DEFAULT 0 NOT NULL,
`qr_secret` text NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `drinkkaart_user_id_unique` ON `drinkkaart` (`user_id`);--> statement-breakpoint
CREATE TABLE `drinkkaart_topup` (
`id` text PRIMARY KEY NOT NULL,
`drinkkaart_id` text NOT NULL,
`user_id` text NOT NULL,
`amount_cents` integer NOT NULL,
`balance_before` integer NOT NULL,
`balance_after` integer NOT NULL,
`type` text NOT NULL,
`mollie_payment_id` text,
`admin_id` text,
`reason` text,
`paid_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `drinkkaart_topup_mollie_payment_id_unique` ON `drinkkaart_topup` (`mollie_payment_id`);--> statement-breakpoint
CREATE TABLE `drinkkaart_transaction` (
`id` text PRIMARY KEY NOT NULL,
`drinkkaart_id` text NOT NULL,
`user_id` text NOT NULL,
`admin_id` text NOT NULL,
`amount_cents` integer NOT NULL,
`balance_before` integer NOT NULL,
`balance_after` integer NOT NULL,
`type` text NOT NULL,
`reversed_by` text,
`reverses` text,
`note` text,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `reminder` (
`id` text PRIMARY KEY NOT NULL,
`email` text NOT NULL,
`sent_24h_at` integer,
`sent_at` integer,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
);
--> statement-breakpoint
CREATE INDEX `reminder_email_idx` ON `reminder` (`email`);--> statement-breakpoint
DROP INDEX "admin_request_user_id_unique";--> statement-breakpoint
DROP INDEX "admin_request_userId_idx";--> statement-breakpoint
DROP INDEX "admin_request_status_idx";--> statement-breakpoint
DROP INDEX "account_userId_idx";--> statement-breakpoint
DROP INDEX "session_token_unique";--> statement-breakpoint
DROP INDEX "session_userId_idx";--> statement-breakpoint
DROP INDEX "user_email_unique";--> statement-breakpoint
DROP INDEX "verification_identifier_idx";--> statement-breakpoint
DROP INDEX "drinkkaart_user_id_unique";--> statement-breakpoint
DROP INDEX "drinkkaart_topup_mollie_payment_id_unique";--> statement-breakpoint
DROP INDEX "registration_management_token_unique";--> statement-breakpoint
DROP INDEX "registration_email_idx";--> statement-breakpoint
DROP INDEX "registration_registrationType_idx";--> statement-breakpoint
DROP INDEX "registration_artForm_idx";--> statement-breakpoint
DROP INDEX "registration_createdAt_idx";--> statement-breakpoint
DROP INDEX "registration_managementToken_idx";--> statement-breakpoint
DROP INDEX "registration_paymentStatus_idx";--> statement-breakpoint
DROP INDEX "registration_giftAmount_idx";--> statement-breakpoint
DROP INDEX "registration_molliePaymentId_idx";--> statement-breakpoint
DROP INDEX "reminder_email_idx";--> statement-breakpoint
ALTER TABLE `registration` ALTER COLUMN "registration_type" TO "registration_type" text NOT NULL DEFAULT 'watcher';--> statement-breakpoint
CREATE UNIQUE INDEX `admin_request_user_id_unique` ON `admin_request` (`user_id`);--> statement-breakpoint
CREATE INDEX `admin_request_userId_idx` ON `admin_request` (`user_id`);--> statement-breakpoint
CREATE INDEX `admin_request_status_idx` ON `admin_request` (`status`);--> statement-breakpoint
CREATE INDEX `account_userId_idx` ON `account` (`user_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint
CREATE INDEX `session_userId_idx` ON `session` (`user_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint
CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`);--> statement-breakpoint
CREATE UNIQUE INDEX `registration_management_token_unique` ON `registration` (`management_token`);--> statement-breakpoint
CREATE INDEX `registration_email_idx` ON `registration` (`email`);--> statement-breakpoint
CREATE INDEX `registration_registrationType_idx` ON `registration` (`registration_type`);--> statement-breakpoint
CREATE INDEX `registration_artForm_idx` ON `registration` (`art_form`);--> statement-breakpoint
CREATE INDEX `registration_createdAt_idx` ON `registration` (`created_at`);--> statement-breakpoint
CREATE INDEX `registration_managementToken_idx` ON `registration` (`management_token`);--> statement-breakpoint
CREATE INDEX `registration_paymentStatus_idx` ON `registration` (`payment_status`);--> statement-breakpoint
CREATE INDEX `registration_giftAmount_idx` ON `registration` (`gift_amount`);--> statement-breakpoint
CREATE INDEX `registration_molliePaymentId_idx` ON `registration` (`mollie_payment_id`);--> statement-breakpoint
ALTER TABLE `registration` ADD `is_over_16` integer DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE `registration` ADD `drink_card_value` integer DEFAULT 0;--> statement-breakpoint
ALTER TABLE `registration` ADD `guests` text;--> statement-breakpoint
ALTER TABLE `registration` ADD `birthdate` text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE `registration` ADD `postcode` text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE `registration` ADD `payment_status` text DEFAULT 'pending' NOT NULL;--> statement-breakpoint
ALTER TABLE `registration` ADD `payment_amount` integer DEFAULT 0;--> statement-breakpoint
ALTER TABLE `registration` ADD `gift_amount` integer DEFAULT 0;--> statement-breakpoint
ALTER TABLE `registration` ADD `mollie_payment_id` text;--> statement-breakpoint
ALTER TABLE `registration` ADD `paid_at` integer;--> statement-breakpoint
ALTER TABLE `registration` ADD `drinkkaart_credited_at` integer;--> statement-breakpoint
ALTER TABLE `registration` ADD `payment_reminder_sent_at` integer;

View File

@@ -0,0 +1,2 @@
-- Migration: add payment_reminder_sent_at column to registration table
ALTER TABLE registration ADD COLUMN payment_reminder_sent_at INTEGER;

View File

@@ -0,0 +1,2 @@
-- Add sent_24h_at column to track whether the 24-hour-before reminder was sent
ALTER TABLE `reminder` ADD `sent_24h_at` integer;

View File

@@ -0,0 +1,971 @@
{
"version": "6",
"dialect": "sqlite",
"id": "7994e607-3835-43a5-9251-fca48f0aa19a",
"prevId": "d9a4f07e-e6ae-45d0-be82-c919ae7fbe09",
"tables": {
"admin_request": {
"name": "admin_request",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"requested_at": {
"name": "requested_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"reviewed_at": {
"name": "reviewed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reviewed_by": {
"name": "reviewed_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"admin_request_user_id_unique": {
"name": "admin_request_user_id_unique",
"columns": ["user_id"],
"isUnique": true
},
"admin_request_userId_idx": {
"name": "admin_request_userId_idx",
"columns": ["user_id"],
"isUnique": false
},
"admin_request_status_idx": {
"name": "admin_request_status_idx",
"columns": ["status"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"account": {
"name": "account",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"account_userId_idx": {
"name": "account_userId_idx",
"columns": ["user_id"],
"isUnique": false
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session": {
"name": "session",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"session_token_unique": {
"name": "session_token_unique",
"columns": ["token"],
"isUnique": true
},
"session_userId_idx": {
"name": "session_userId_idx",
"columns": ["user_id"],
"isUnique": false
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_verified": {
"name": "email_verified",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": ["email"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"verification": {
"name": "verification",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"verification_identifier_idx": {
"name": "verification_identifier_idx",
"columns": ["identifier"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"drinkkaart": {
"name": "drinkkaart",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"balance": {
"name": "balance",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"qr_secret": {
"name": "qr_secret",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"drinkkaart_user_id_unique": {
"name": "drinkkaart_user_id_unique",
"columns": ["user_id"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"drinkkaart_topup": {
"name": "drinkkaart_topup",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"drinkkaart_id": {
"name": "drinkkaart_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"amount_cents": {
"name": "amount_cents",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"balance_before": {
"name": "balance_before",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"balance_after": {
"name": "balance_after",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"mollie_payment_id": {
"name": "mollie_payment_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"admin_id": {
"name": "admin_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reason": {
"name": "reason",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"paid_at": {
"name": "paid_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"drinkkaart_topup_mollie_payment_id_unique": {
"name": "drinkkaart_topup_mollie_payment_id_unique",
"columns": ["mollie_payment_id"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"drinkkaart_transaction": {
"name": "drinkkaart_transaction",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"drinkkaart_id": {
"name": "drinkkaart_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"admin_id": {
"name": "admin_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"amount_cents": {
"name": "amount_cents",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"balance_before": {
"name": "balance_before",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"balance_after": {
"name": "balance_after",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reversed_by": {
"name": "reversed_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reverses": {
"name": "reverses",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"note": {
"name": "note",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"registration": {
"name": "registration",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"first_name": {
"name": "first_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"last_name": {
"name": "last_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"phone": {
"name": "phone",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"registration_type": {
"name": "registration_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'watcher'"
},
"art_form": {
"name": "art_form",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"experience": {
"name": "experience",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_over_16": {
"name": "is_over_16",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"drink_card_value": {
"name": "drink_card_value",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"guests": {
"name": "guests",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"birthdate": {
"name": "birthdate",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"postcode": {
"name": "postcode",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"extra_questions": {
"name": "extra_questions",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"management_token": {
"name": "management_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cancelled_at": {
"name": "cancelled_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payment_status": {
"name": "payment_status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"payment_amount": {
"name": "payment_amount",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"gift_amount": {
"name": "gift_amount",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"mollie_payment_id": {
"name": "mollie_payment_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"paid_at": {
"name": "paid_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"drinkkaart_credited_at": {
"name": "drinkkaart_credited_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"payment_reminder_sent_at": {
"name": "payment_reminder_sent_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"registration_management_token_unique": {
"name": "registration_management_token_unique",
"columns": ["management_token"],
"isUnique": true
},
"registration_email_idx": {
"name": "registration_email_idx",
"columns": ["email"],
"isUnique": false
},
"registration_registrationType_idx": {
"name": "registration_registrationType_idx",
"columns": ["registration_type"],
"isUnique": false
},
"registration_artForm_idx": {
"name": "registration_artForm_idx",
"columns": ["art_form"],
"isUnique": false
},
"registration_createdAt_idx": {
"name": "registration_createdAt_idx",
"columns": ["created_at"],
"isUnique": false
},
"registration_managementToken_idx": {
"name": "registration_managementToken_idx",
"columns": ["management_token"],
"isUnique": false
},
"registration_paymentStatus_idx": {
"name": "registration_paymentStatus_idx",
"columns": ["payment_status"],
"isUnique": false
},
"registration_giftAmount_idx": {
"name": "registration_giftAmount_idx",
"columns": ["gift_amount"],
"isUnique": false
},
"registration_molliePaymentId_idx": {
"name": "registration_molliePaymentId_idx",
"columns": ["mollie_payment_id"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"reminder": {
"name": "reminder",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sent_24h_at": {
"name": "sent_24h_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sent_at": {
"name": "sent_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"reminder_email_idx": {
"name": "reminder_email_idx",
"columns": ["email"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {
"\"registration\".\"wants_to_perform\"": "\"registration\".\"registration_type\""
}
},
"internal": {
"indexes": {}
}
}

View File

@@ -22,6 +22,48 @@
"when": 1772520000000,
"tag": "0002_registration_type_redesign",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1772530000000,
"tag": "0003_add_guests",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1772536320000,
"tag": "0004_drinkkaart",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1772596240000,
"tag": "0005_registration_drinkkaart_credit",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1772672880000,
"tag": "0006_mollie_migration",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1772931300000,
"tag": "0007_reminder",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1773910007494,
"tag": "0008_melodic_marrow",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,68 @@
import type { InferInsertModel, InferSelectModel } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
// ---------------------------------------------------------------------------
// drinkkaart — one row per user account (1:1 with user)
// ---------------------------------------------------------------------------
export const drinkkaart = sqliteTable("drinkkaart", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().unique(),
balance: integer("balance").notNull().default(0), // in cents
version: integer("version").notNull().default(0), // optimistic lock counter
qrSecret: text("qr_secret").notNull(), // CSPRNG 32-byte hex; signs QR tokens
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
});
// ---------------------------------------------------------------------------
// drinkkaart_transaction — immutable audit log of deductions and reversals
// ---------------------------------------------------------------------------
export const drinkkaartTransaction = sqliteTable("drinkkaart_transaction", {
id: text("id").primaryKey(),
drinkkaartId: text("drinkkaart_id").notNull(),
userId: text("user_id").notNull(), // denormalized for fast audit queries
adminId: text("admin_id").notNull(), // FK → user.id (admin who processed it)
amountCents: integer("amount_cents").notNull(),
balanceBefore: integer("balance_before").notNull(),
balanceAfter: integer("balance_after").notNull(),
type: text("type", { enum: ["deduction", "reversal"] }).notNull(),
reversedBy: text("reversed_by"), // nullable; id of the reversal transaction
reverses: text("reverses"), // nullable; id of the original deduction
note: text("note"),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
});
// ---------------------------------------------------------------------------
// drinkkaart_topup — immutable log of every credited amount
// ---------------------------------------------------------------------------
export const drinkkaartTopup = sqliteTable("drinkkaart_topup", {
id: text("id").primaryKey(),
drinkkaartId: text("drinkkaart_id").notNull(),
userId: text("user_id").notNull(), // denormalized
amountCents: integer("amount_cents").notNull(),
balanceBefore: integer("balance_before").notNull(),
balanceAfter: integer("balance_after").notNull(),
type: text("type", { enum: ["payment", "admin_credit"] }).notNull(),
molliePaymentId: text("mollie_payment_id").unique(), // nullable; only for type="payment"
adminId: text("admin_id"), // nullable; only for type="admin_credit"
reason: text("reason"),
paidAt: integer("paid_at", { mode: "timestamp_ms" }).notNull(),
});
// ---------------------------------------------------------------------------
// Inferred Drizzle types
// ---------------------------------------------------------------------------
export type Drinkkaart = InferSelectModel<typeof drinkkaart>;
export type NewDrinkkaart = InferInsertModel<typeof drinkkaart>;
export type DrinkkaartTransaction = InferSelectModel<
typeof drinkkaartTransaction
>;
export type NewDrinkkaartTransaction = InferInsertModel<
typeof drinkkaartTransaction
>;
export type DrinkkaartTopup = InferSelectModel<typeof drinkkaartTopup>;
export type NewDrinkkaartTopup = InferInsertModel<typeof drinkkaartTopup>;

View File

@@ -1,3 +1,5 @@
export * from "./admin-requests";
export * from "./auth";
export * from "./drinkkaart";
export * from "./registrations";
export * from "./reminders";

View File

@@ -19,9 +19,11 @@ export const registration = sqliteTable(
.default(false),
// Watcher-specific fields
drinkCardValue: integer("drink_card_value").default(0),
// Guests: JSON array of {firstName, lastName, email?, phone?} objects
// Guests: JSON array of {firstName, lastName, email?, phone?, birthdate, postcode} objects
guests: text("guests"),
// Shared
birthdate: text("birthdate").notNull().default(""),
postcode: text("postcode").notNull().default(""),
extraQuestions: text("extra_questions"),
managementToken: text("management_token").unique(),
cancelledAt: integer("cancelled_at", { mode: "timestamp_ms" }),
@@ -29,9 +31,17 @@ export const registration = sqliteTable(
paymentStatus: text("payment_status").notNull().default("pending"),
paymentAmount: integer("payment_amount").default(0),
giftAmount: integer("gift_amount").default(0),
lemonsqueezyOrderId: text("lemonsqueezy_order_id"),
lemonsqueezyCustomerId: text("lemonsqueezy_customer_id"),
molliePaymentId: text("mollie_payment_id"),
paidAt: integer("paid_at", { mode: "timestamp_ms" }),
// Set when the drinkCardValue has been credited to the user's drinkkaart.
// Null means not yet credited (either unpaid, account doesn't exist yet, or
// the registration is a performer). Used to prevent double-crediting.
drinkkaartCreditedAt: integer("drinkkaart_credited_at", {
mode: "timestamp_ms",
}),
paymentReminderSentAt: integer("payment_reminder_sent_at", {
mode: "timestamp_ms",
}),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
@@ -44,6 +54,6 @@ export const registration = sqliteTable(
index("registration_managementToken_idx").on(table.managementToken),
index("registration_paymentStatus_idx").on(table.paymentStatus),
index("registration_giftAmount_idx").on(table.giftAmount),
index("registration_lemonsqueezyOrderId_idx").on(table.lemonsqueezyOrderId),
index("registration_molliePaymentId_idx").on(table.molliePaymentId),
],
);

View File

@@ -0,0 +1,21 @@
import { sql } from "drizzle-orm";
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const reminder = sqliteTable(
"reminder",
{
id: text("id").primaryKey(), // UUID
email: text("email").notNull(),
/** Set when the 24-hours-before reminder has been sent. */
sent24hAt: integer("sent_24h_at", { mode: "timestamp_ms" }),
/** Set when the 1-hour-before reminder has been sent. */
sentAt: integer("sent_at", { mode: "timestamp_ms" }),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
},
(t) => [index("reminder_email_idx").on(t.email)],
);
export type Reminder = typeof reminder.$inferSelect;
export type NewReminder = typeof reminder.$inferInsert;

View File

@@ -15,20 +15,18 @@ export const env = createEnv({
server: {
DATABASE_URL: z.string().min(1),
BETTER_AUTH_SECRET: z.string().min(32),
BETTER_AUTH_URL: z.url(),
BETTER_AUTH_URL: z.url().default("https://kunstenkamp.be"),
CORS_ORIGIN: z.url(),
SMTP_HOST: z.string().min(1).optional(),
SMTP_PORT: z.coerce.number().default(587),
SMTP_USER: z.string().min(1).optional(),
SMTP_PASS: z.string().min(1).optional(),
SMTP_FROM: z.string().min(1).optional(),
SMTP_FROM: z.string().min(1).default("Kunstenkamp <info@kunstenkamp.be>"),
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
LEMON_SQUEEZY_API_KEY: z.string().min(1).optional(),
LEMON_SQUEEZY_STORE_ID: z.string().min(1).optional(),
LEMON_SQUEEZY_VARIANT_ID: z.string().min(1).optional(),
LEMON_SQUEEZY_WEBHOOK_SECRET: z.string().min(1).optional(),
MOLLIE_API_KEY: z.string().min(1).optional(),
CRON_SECRET: z.string().min(1).optional(),
},
runtimeEnv: process.env,
emptyStringAsUndefined: true,

View File

@@ -1,5 +1,5 @@
import alchemy from "alchemy";
import { TanStackStart } from "alchemy/cloudflare";
import { Queue, TanStackStart } from "alchemy/cloudflare";
import { config } from "dotenv";
config({ path: "./.env" });
@@ -16,6 +16,10 @@ function getEnvVar(name: string): string {
return value;
}
const emailQueue = await Queue("email-queue", {
name: "kk-email-queue",
});
export const web = await TanStackStart("web", {
cwd: "../../apps/web",
bindings: {
@@ -30,12 +34,27 @@ export const web = await TanStackStart("web", {
SMTP_USER: getEnvVar("SMTP_USER"),
SMTP_PASS: getEnvVar("SMTP_PASS"),
SMTP_FROM: getEnvVar("SMTP_FROM"),
// Payments (Lemon Squeezy)
LEMON_SQUEEZY_API_KEY: getEnvVar("LEMON_SQUEEZY_API_KEY"),
LEMON_SQUEEZY_STORE_ID: getEnvVar("LEMON_SQUEEZY_STORE_ID"),
LEMON_SQUEEZY_VARIANT_ID: getEnvVar("LEMON_SQUEEZY_VARIANT_ID"),
LEMON_SQUEEZY_WEBHOOK_SECRET: getEnvVar("LEMON_SQUEEZY_WEBHOOK_SECRET"),
// Payments (Mollie)
MOLLIE_API_KEY: getEnvVar("MOLLIE_API_KEY"),
// Cron secret for protected scheduled endpoints
CRON_SECRET: getEnvVar("CRON_SECRET"),
// Queue binding for async email sends
EMAIL_QUEUE: emailQueue,
},
// Queue consumer: the worker's queue() handler processes EmailMessage batches
eventSources: [
{
queue: emailQueue,
settings: {
batchSize: 10,
maxRetries: 3,
retryDelay: 60, // seconds before retrying a failed message
maxWaitTimeMs: 1000,
},
},
],
// Fire every hour so reminder checks can run at 19:00 on 2026-03-15 (24h) and 18:00 on 2026-03-16 (1h)
crons: ["0 * * * *"],
domains: ["kunstenkamp.be", "www.kunstenkamp.be"],
});

View File

@@ -18,14 +18,17 @@
"persistent": true
},
"db:push": {
"cache": false
"cache": false,
"interactive": true
},
"db:generate": {
"cache": false
"cache": false,
"interactive": true
},
"db:migrate": {
"cache": false,
"persistent": true
"persistent": true,
"interactive": true
},
"db:studio": {
"cache": false,