Compare commits

...

8 Commits

Author SHA1 Message Date
d5fde568da feat(registration): update event date and add required guest fields
Change event date from April 18 to April 24 across all pages and emails.
Add birthdate and postcode as required fields for guest registration.
Update API to support multiple registrations per user. Enhance admin
panel with expandable guest details view.
2026-03-07 15:49:56 +01:00
dcf21a80e2 feat:add birthdate and postcode 2026-03-07 02:46:14 +01:00
ac466a7f0e feat:switch back to lemonsqueezy 2026-03-07 02:28:03 +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
60 changed files with 6374 additions and 1062 deletions

View File

@@ -33,9 +33,11 @@
"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",
@@ -54,6 +56,7 @@
"@tanstack/react-router-devtools": "^1.141.1",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.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

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,43 @@
"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 isHomepage = routerState.location.pathname === "/";
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
if (!isHomepage) {
setIsVisible(true);
return;
}
const handleScroll = () => {
setIsVisible(window.scrollY > 50);
};
handleScroll();
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, [isHomepage]);
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,116 @@
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;
}
export function QrScanner({ onScan, onCancel }: QrScannerProps) {
const scannerDivId = "qr-reader-container";
const scannerRef = useRef<unknown>(null);
const [hasError, setHasError] = useState(false);
const [manualToken, setManualToken] = useState("");
// Keep a stable ref to onScan so the effect never re-runs due to identity changes
const onScanRef = useRef(onScan);
useEffect(() => {
onScanRef.current = onScan;
}, [onScan]);
useEffect(() => {
let stopped = false;
import("html5-qrcode")
.then(({ Html5QrcodeScanner }) => {
if (stopped) return;
const scanner = new Html5QrcodeScanner(
scannerDivId,
{
fps: 10,
qrbox: { width: 250, height: 250 },
},
false,
);
scannerRef.current = scanner;
scanner.render(
(decodedText: string) => {
scanner.clear().catch(console.error);
onScanRef.current(decodedText);
},
(error: string) => {
// Suppress routine "not found" scan errors
if (!error.includes("No QR code found")) {
console.debug("QR scan error:", error);
}
},
);
})
.catch((err) => {
console.error("Failed to load html5-qrcode:", err);
setHasError(true);
});
return () => {
stopped = true;
if (scannerRef.current) {
(scannerRef.current as { clear: () => Promise<void> })
.clear()
.catch(console.error);
}
};
}, []); // empty deps — runs once on mount, cleans up on unmount
const handleManualSubmit = () => {
const token = manualToken.trim();
if (token) {
onScan(token);
}
};
return (
<div className="space-y-4">
{!hasError ? (
<div id={scannerDivId} className="overflow-hidden rounded-xl" />
) : (
<div className="rounded-xl border border-white/10 bg-white/5 p-4 text-center">
<p className="mb-2 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

@@ -1,23 +1,44 @@
import { Link } from "@tanstack/react-router";
import { useState } from "react";
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 { authClient } from "@/lib/auth-client";
type RegistrationType = "performer" | "watcher";
interface SuccessState {
token: string;
email: string;
name: string;
}
export default function EventRegistrationForm() {
const { data: session } = authClient.useSession();
const [selectedType, setSelectedType] = useState<RegistrationType | null>(
null,
);
const [successToken, setSuccessToken] = useState<string | null>(null);
const [successState, setSuccessState] = useState<SuccessState | null>(null);
if (successToken) {
const isLoggedIn = !!session?.user;
const user = session?.user as { name?: string; email?: string } | undefined;
// Split "Jan De Smet" → firstName="Jan", lastName="De Smet"
const nameParts = (user?.name ?? "").trim().split(/\s+/);
const prefillFirstName = nameParts[0] ?? "";
const prefillLastName = nameParts.slice(1).join(" ");
const prefillEmail = user?.email ?? "";
if (successState) {
return (
<SuccessScreen
token={successToken}
token={successState.token}
email={successState.email}
name={successState.name}
isLoggedIn={isLoggedIn}
onReset={() => {
setSuccessToken(null);
setSuccessState(null);
setSelectedType(null);
}}
/>
@@ -33,21 +54,61 @@ 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>
{/* Login nudge — shown only to guests */}
{!isLoggedIn && (
<div className="mb-10 flex flex-col gap-3 rounded-lg border border-[#52979b]/40 bg-[#52979b]/10 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-white/80">
<span className="mr-1 font-semibold text-teal-300">
Al een account?
</span>
Log in om je gegevens automatisch in te vullen en je Drinkkaart
direct te activeren na registratie.
</p>
<div className="flex shrink-0 items-center gap-3 text-sm">
<Link
to="/login"
className="font-medium text-teal-300 underline-offset-2 transition-colors hover:underline"
>
Inloggen
</Link>
<span className="text-white/30">·</span>
<Link
to="/login"
search={{ signup: "1" }}
className="text-white/60 transition-colors hover:text-white"
>
Nog geen account? Aanmaken
</Link>
</div>
</div>
)}
{!selectedType && <TypeSelector onSelect={setSelectedType} />}
{selectedType === "performer" && (
<PerformerForm
onBack={() => setSelectedType(null)}
onSuccess={setSuccessToken}
onSuccess={(token, email, name) =>
setSuccessState({ token, email, name })
}
prefillFirstName={prefillFirstName}
prefillLastName={prefillLastName}
prefillEmail={prefillEmail}
isLoggedIn={isLoggedIn}
/>
)}
{selectedType === "watcher" && (
<WatcherForm onBack={() => setSelectedType(null)} />
<WatcherForm
onBack={() => setSelectedType(null)}
prefillFirstName={prefillFirstName}
prefillLastName={prefillLastName}
prefillEmail={prefillEmail}
isLoggedIn={isLoggedIn}
/>
)}
</div>
</section>

View File

@@ -17,63 +17,141 @@ 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" />
{/* 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

@@ -24,7 +24,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 +43,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 +88,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 +116,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" />

View File

@@ -204,6 +204,51 @@ export function GuestList({
)}
</div>
<div className="flex flex-col gap-2">
<label
htmlFor={`guest-${idx}-birthdate`}
className="text-white/80"
>
Geboortedatum <span className="text-red-300">*</span>
</label>
<input
type="date"
id={`guest-${idx}-birthdate`}
value={guest.birthdate}
onChange={(e) => onChange(idx, "birthdate", e.target.value)}
autoComplete="off"
className={inputCls(!!errors[idx]?.birthdate)}
/>
{errors[idx]?.birthdate && (
<span className="text-red-300 text-sm" role="alert">
{errors[idx].birthdate}
</span>
)}
</div>
<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="1234 AB"
autoComplete="off"
className={inputCls(!!errors[idx]?.postcode)}
/>
{errors[idx]?.postcode && (
<span className="text-red-300 text-sm" role="alert">
{errors[idx].postcode}
</span>
)}
</div>
<div className="flex flex-col gap-2">
<label htmlFor={`guest-${idx}-email`} className="text-white/80">
E-mail

View File

@@ -15,21 +15,36 @@ interface PerformerErrors {
lastName?: string;
email?: string;
phone?: string;
postcode?: string;
birthdate?: string;
artForm?: string;
isOver16?: string;
}
interface Props {
onBack: () => void;
onSuccess: (token: string) => void;
onSuccess: (token: string, email: string, name: string) => void;
prefillFirstName?: string;
prefillLastName?: string;
prefillEmail?: string;
isLoggedIn?: boolean;
}
export function PerformerForm({ onBack, onSuccess }: Props) {
export function PerformerForm({
onBack,
onSuccess,
prefillFirstName = "",
prefillLastName = "",
prefillEmail = "",
isLoggedIn = false,
}: Props) {
const [data, setData] = useState({
firstName: "",
lastName: "",
email: "",
firstName: prefillFirstName,
lastName: prefillLastName,
email: prefillEmail,
phone: "",
postcode: "",
birthdate: "",
artForm: "",
experience: "",
isOver16: false,
@@ -42,7 +57,12 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
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}`);
@@ -55,6 +75,8 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
lastName: validateTextField(data.lastName, true, "Achternaam"),
email: validateEmail(data.email),
phone: validatePhone(data.phone),
postcode: !data.postcode.trim() ? "Postcode is verplicht" : undefined,
birthdate: !data.birthdate ? "Geboortedatum is verplicht" : undefined,
artForm: !data.artForm.trim() ? "Kunstvorm is verplicht" : undefined,
isOver16: !data.isOver16
? "Je moet 16 jaar of ouder zijn om op te treden"
@@ -66,6 +88,8 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
lastName: true,
email: true,
phone: true,
postcode: true,
birthdate: true,
artForm: true,
isOver16: true,
});
@@ -94,6 +118,14 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
),
email: validateEmail(name === "email" ? value : data.email),
phone: validatePhone(name === "phone" ? value : data.phone),
postcode:
name === "postcode" && !value.trim()
? "Postcode is verplicht"
: undefined,
birthdate:
name === "birthdate" && !value
? "Geboortedatum is verplicht"
: undefined,
artForm:
name === "artForm" && !value.trim()
? "Kunstvorm is verplicht"
@@ -113,6 +145,14 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
lastName: validateTextField(value, true, "Achternaam"),
email: validateEmail(value),
phone: validatePhone(value),
postcode:
name === "postcode" && !value.trim()
? "Postcode is verplicht"
: undefined,
birthdate:
name === "birthdate" && !value
? "Geboortedatum is verplicht"
: undefined,
artForm: !value.trim() ? "Kunstvorm is verplicht" : undefined,
};
setErrors((prev) => ({ ...prev, [name]: errMap[name] }));
@@ -129,6 +169,8 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
lastName: data.lastName.trim(),
email: data.email.trim(),
phone: data.phone.trim() || undefined,
postcode: data.postcode.trim(),
birthdate: data.birthdate,
registrationType: "performer",
artForm: data.artForm.trim() || undefined,
experience: data.experience.trim() || undefined,
@@ -249,14 +291,15 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
id="p-email"
name="email"
value={data.email}
onChange={handleChange}
onBlur={handleBlur}
onChange={isLoggedIn ? undefined : handleChange}
onBlur={isLoggedIn ? undefined : handleBlur}
readOnly={isLoggedIn}
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)}${isLoggedIn ? "cursor-not-allowed opacity-60" : ""}`}
/>
{touched.email && errors.email && (
<span className="text-red-300 text-sm" role="alert">
@@ -289,6 +332,54 @@ export function PerformerForm({ onBack, onSuccess }: Props) {
</div>
</div>
{/* Postcode + Birthdate row */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<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="1234 AB"
autoComplete="postal-code"
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 className="flex flex-col gap-2">
<label htmlFor="p-birthdate" className="text-white text-xl">
Geboortedatum <span className="text-red-300">*</span>
</label>
<input
type="date"
id="p-birthdate"
name="birthdate"
value={data.birthdate}
onChange={handleChange}
onBlur={handleBlur}
autoComplete="bday"
aria-invalid={touched.birthdate && !!errors.birthdate}
className={`${inputCls(!!touched.birthdate && !!errors.birthdate)} [color-scheme:dark]`}
/>
{touched.birthdate && errors.birthdate && (
<span className="text-red-300 text-sm" role="alert">
{errors.birthdate}
</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">

View File

@@ -1,14 +1,52 @@
import { useState } from "react";
interface Props {
token: string;
email?: string;
name?: string;
onReset: () => void;
isLoggedIn?: boolean;
}
export function SuccessScreen({ token, onReset }: Props) {
export function SuccessScreen({
token,
email,
name,
onReset,
isLoggedIn,
}: Props) {
const manageUrl =
typeof window !== "undefined"
? `${window.location.origin}/manage/${token}`
: `/manage/${token}`;
const [drinkkaartPromptDismissed, setDrinkkaartPromptDismissed] = useState(
() => {
try {
return (
typeof localStorage !== "undefined" &&
localStorage.getItem("drinkkaart_prompt_dismissed") === "1"
);
} catch {
return false;
}
},
);
const handleDismissPrompt = () => {
try {
localStorage.setItem("drinkkaart_prompt_dismissed", "1");
} catch {
// ignore
}
setDrinkkaartPromptDismissed(true);
};
// Build signup URL with pre-filled email query param so /login can pre-fill
const signupUrl = email
? `/login?signup=1&email=${encodeURIComponent(email)}`
: "/login?signup=1";
return (
<section
id="registration"
@@ -65,6 +103,53 @@ export function SuccessScreen({ token, onReset }: Props) {
Nog een inschrijving
</button>
</div>
{/* Account creation prompt — hidden when already logged in */}
{!isLoggedIn && !drinkkaartPromptDismissed && (
<div className="mt-8 rounded-xl border border-teal-400/30 bg-teal-400/10 p-6">
<h3 className="mb-1 font-['Intro',sans-serif] text-lg text-white">
Maak een gratis account aan
</h3>
<p className="mb-4 text-sm text-white/70">
Met een account zie je je inschrijving, activeer je je
Drinkkaart en laad je saldo op vóór het evenement.
</p>
<ul className="mb-5 space-y-1.5 text-sm text-white/70">
<li className="flex items-center gap-2">
<span className="text-teal-300"></span>
Beheer je inschrijving op één plek
</li>
<li className="flex items-center gap-2">
<span className="text-teal-300"></span>
Digitale Drinkkaart met QR-code
</li>
<li className="flex items-center gap-2">
<span className="text-teal-300"></span>
Saldo opladen vóór het evenement
</li>
</ul>
<div className="flex flex-wrap items-center gap-3">
<a
href={signupUrl}
className="inline-flex items-center rounded-lg bg-white px-5 py-2.5 font-['Intro',sans-serif] text-[#214e51] text-sm transition-all hover:scale-105 hover:bg-gray-100"
>
Account aanmaken
{name && (
<span className="ml-1.5 font-normal text-xs opacity-60">
als {name}
</span>
)}
</a>
<button
type="button"
onClick={handleDismissPrompt}
className="text-sm text-white/40 underline underline-offset-2 transition-colors hover:text-white/70"
>
Overslaan
</button>
</div>
</div>
)}
</div>
</div>
</section>

View File

@@ -1,6 +1,7 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import {
calculateDrinkCard,
type GuestEntry,
@@ -20,18 +21,240 @@ interface WatcherErrors {
lastName?: string;
email?: string;
phone?: string;
postcode?: string;
birthdate?: string;
}
interface Props {
onBack: () => void;
prefillFirstName?: string;
prefillLastName?: string;
prefillEmail?: string;
isLoggedIn?: boolean;
}
export function WatcherForm({ onBack }: Props) {
// ── Account creation modal shown after successful registration ─────────────
interface AccountModalProps {
prefillName: string;
prefillEmail: string;
onDone: () => void;
}
function AccountModal({
prefillName,
prefillEmail,
onDone,
}: AccountModalProps) {
const [name, setName] = useState(prefillName);
const [email] = useState(prefillEmail); // email is fixed (tied to registration)
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [passwordError, setPasswordError] = useState<string | undefined>();
const signupMutation = useMutation({
mutationFn: async () => {
if (password.length < 8)
throw new Error("Wachtwoord moet minstens 8 tekens zijn");
if (password !== confirmPassword)
throw new Error("Wachtwoorden komen niet overeen");
const result = await authClient.signUp.email({ email, password, name });
if (result.error) throw new Error(result.error.message);
return result.data;
},
onSuccess: () => {
toast.success(
"Account aangemaakt! Je wordt nu doorgestuurd naar betaling.",
);
onDone();
},
onError: (error) => {
toast.error(`Account aanmaken mislukt: ${error.message}`);
},
});
const handlePasswordChange = (val: string) => {
setPassword(val);
if (passwordError) setPasswordError(undefined);
};
const handleConfirmChange = (val: string) => {
setConfirmPassword(val);
if (passwordError) setPasswordError(undefined);
};
const handleSignup = (e: React.FormEvent) => {
e.preventDefault();
if (password.length < 8) {
setPasswordError("Wachtwoord moet minstens 8 tekens zijn");
return;
}
if (password !== confirmPassword) {
setPasswordError("Wachtwoorden komen niet overeen");
return;
}
signupMutation.mutate();
};
// Receive the checkout URL from parent by exposing a setter via ref pattern
// (simpler: the parent renders the modal and passes a prop)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4">
<div className="w-full max-w-md rounded-2xl border border-white/10 bg-[#1a3d40] p-8 shadow-2xl">
{/* Header */}
<div className="mb-6 flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-teal-400/20 text-teal-300 text-xl">
</div>
<div>
<h2 className="font-['Intro',sans-serif] text-2xl text-white">
Inschrijving gelukt!
</h2>
<p className="mt-1 text-sm text-white/60">
Maak een gratis account aan om je Drinkkaart bij te houden.
</p>
</div>
</div>
{/* Value prop */}
<div className="mb-6 rounded-lg border border-teal-400/20 bg-teal-400/10 p-4">
<ul className="space-y-1.5 text-sm text-white/80">
<li className="flex items-center gap-2">
<span className="text-teal-300"></span>
Zie je drinkkaart-saldo en QR-code
</li>
<li className="flex items-center gap-2">
<span className="text-teal-300"></span>
Laad je kaart op vóór het evenement
</li>
<li className="flex items-center gap-2">
<span className="text-teal-300"></span>
Beheer je inschrijving op één plek
</li>
</ul>
</div>
{/* Signup form */}
<form onSubmit={handleSignup} className="space-y-4">
{/* Name */}
<div>
<label
className="mb-1.5 block text-sm text-white/60"
htmlFor="modal-name"
>
Naam
</label>
<input
id="modal-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
className="w-full rounded-lg border border-white/20 bg-white/10 px-3 py-2 text-white placeholder:text-white/30 focus:border-teal-400/60 focus:outline-none"
/>
</div>
{/* Email (read-only) */}
<div>
<label
className="mb-1.5 block text-sm text-white/60"
htmlFor="modal-email"
>
Email
</label>
<input
id="modal-email"
type="email"
value={email}
readOnly
className="w-full cursor-not-allowed rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-white/60"
/>
</div>
{/* Password */}
<div>
<label
className="mb-1.5 block text-sm text-white/60"
htmlFor="modal-password"
>
Wachtwoord
</label>
<input
id="modal-password"
type="password"
placeholder="Minstens 8 tekens"
value={password}
onChange={(e) => handlePasswordChange(e.target.value)}
required
minLength={8}
className="w-full rounded-lg border border-white/20 bg-white/10 px-3 py-2 text-white placeholder:text-white/30 focus:border-teal-400/60 focus:outline-none"
/>
</div>
{/* Confirm password */}
<div>
<label
className="mb-1.5 block text-sm text-white/60"
htmlFor="modal-confirm"
>
Bevestig wachtwoord
</label>
<input
id="modal-confirm"
type="password"
placeholder="Herhaal je wachtwoord"
value={confirmPassword}
onChange={(e) => handleConfirmChange(e.target.value)}
required
className="w-full rounded-lg border border-white/20 bg-white/10 px-3 py-2 text-white placeholder:text-white/30 focus:border-teal-400/60 focus:outline-none"
/>
</div>
{passwordError && (
<p className="text-red-300 text-sm">{passwordError}</p>
)}
<button
type="submit"
disabled={signupMutation.isPending}
className="w-full rounded-lg bg-white py-3 font-['Intro',sans-serif] text-[#214e51] transition-all hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50"
>
{signupMutation.isPending
? "Bezig..."
: "Account aanmaken & betalen"}
</button>
</form>
{/* Skip */}
<button
type="button"
onClick={onDone}
className="mt-4 w-full text-center text-sm text-white/40 transition-colors hover:text-white/70"
>
Overslaan, verder naar betaling
</button>
</div>
</div>
);
}
// ── Main watcher form ──────────────────────────────────────────────────────
export function WatcherForm({
onBack,
prefillFirstName = "",
prefillLastName = "",
prefillEmail = "",
isLoggedIn = false,
}: Props) {
const [data, setData] = useState({
firstName: "",
lastName: "",
email: "",
firstName: prefillFirstName,
lastName: prefillLastName,
email: prefillEmail,
phone: "",
postcode: "",
birthdate: "",
extraQuestions: "",
});
const [errors, setErrors] = useState<WatcherErrors>({});
@@ -40,16 +263,37 @@ export function WatcherForm({ onBack }: Props) {
const [guestErrors, setGuestErrors] = useState<GuestErrors[]>([]);
const [giftAmount, setGiftAmount] = useState(0);
// Modal state: shown after successful registration while we fetch checkout URL
const [modalState, setModalState] = useState<{
prefillName: string;
prefillEmail: string;
checkoutUrl: string;
} | null>(null);
const submitMutation = useMutation({
...orpc.submitRegistration.mutationOptions(),
onSuccess: async (result) => {
if (!result.managementToken) return;
// Redirect to Lemon Squeezy checkout immediately after registration
try {
const redirectUrl = isLoggedIn
? `${window.location.origin}/account?topup=success`
: undefined;
const checkout = await client.getCheckoutUrl({
token: result.managementToken,
redirectUrl,
});
window.location.href = checkout.checkoutUrl;
if (isLoggedIn) {
// Already logged in — skip the account-creation modal, go straight to checkout
window.location.href = checkout.checkoutUrl;
} else {
// Show the account-creation modal before redirecting to checkout
setModalState({
prefillName:
`${data.firstName.trim()} ${data.lastName.trim()}`.trim(),
prefillEmail: data.email.trim(),
checkoutUrl: checkout.checkoutUrl,
});
}
} catch (error) {
console.error("Checkout error:", error);
toast.error("Er is iets misgegaan bij het aanmaken van de betaling");
@@ -66,6 +310,8 @@ export function WatcherForm({ onBack }: Props) {
lastName: validateTextField(data.lastName, true, "Achternaam"),
email: validateEmail(data.email),
phone: validatePhone(data.phone),
postcode: !data.postcode.trim() ? "Postcode is verplicht" : undefined,
birthdate: !data.birthdate ? "Geboortedatum is verplicht" : undefined,
};
setErrors(fieldErrs);
setTouched({
@@ -73,6 +319,8 @@ export function WatcherForm({ onBack }: Props) {
lastName: true,
email: true,
phone: true,
postcode: true,
birthdate: true,
});
const { errors: gErrs, valid: gValid } = validateGuests(guests);
setGuestErrors(gErrs);
@@ -90,6 +338,14 @@ export function WatcherForm({ onBack }: Props) {
lastName: validateTextField(value, true, "Achternaam"),
email: validateEmail(value),
phone: validatePhone(value),
postcode:
name === "postcode" && !value.trim()
? "Postcode is verplicht"
: undefined,
birthdate:
name === "birthdate" && !value
? "Geboortedatum is verplicht"
: undefined,
};
setErrors((prev) => ({ ...prev, [name]: errMap[name] }));
}
@@ -105,6 +361,14 @@ export function WatcherForm({ onBack }: Props) {
lastName: validateTextField(value, true, "Achternaam"),
email: validateEmail(value),
phone: validatePhone(value),
postcode:
name === "postcode" && !value.trim()
? "Postcode is verplicht"
: undefined,
birthdate:
name === "birthdate" && !value
? "Geboortedatum is verplicht"
: undefined,
};
setErrors((prev) => ({ ...prev, [name]: errMap[name] }));
}
@@ -125,7 +389,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 +417,16 @@ export function WatcherForm({ onBack }: Props) {
lastName: data.lastName.trim(),
email: data.email.trim(),
phone: data.phone.trim() || undefined,
postcode: data.postcode.trim(),
birthdate: data.birthdate,
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,
@@ -162,6 +437,19 @@ export function WatcherForm({ onBack }: Props) {
return (
<div>
{/* Account-creation interstitial modal */}
{modalState && (
<AccountModal
prefillName={modalState.prefillName}
prefillEmail={modalState.prefillEmail}
onDone={() => {
const url = modalState.checkoutUrl;
setModalState(null);
window.location.href = url;
}}
/>
)}
{/* Back + type header */}
<div className="mb-8 flex items-center gap-4">
<button
@@ -217,7 +505,7 @@ 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{" "}
Je betaald bij registratie
<strong className="text-teal-300">5</strong> (+ 2 per
medebezoeker) dat gaat naar je drinkkaart.
{guests.length > 0 && (
@@ -291,14 +579,15 @@ export function WatcherForm({ onBack }: Props) {
id="w-email"
name="email"
value={data.email}
onChange={handleChange}
onBlur={handleBlur}
onChange={isLoggedIn ? undefined : handleChange}
onBlur={isLoggedIn ? undefined : handleBlur}
readOnly={isLoggedIn}
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)}${isLoggedIn ? "cursor-not-allowed opacity-60" : ""}`}
/>
{touched.email && errors.email && (
<span className="text-red-300 text-sm" role="alert">
@@ -331,6 +620,54 @@ export function WatcherForm({ onBack }: Props) {
</div>
</div>
{/* Postcode + Birthdate row */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<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="1234 AB"
autoComplete="postal-code"
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 className="flex flex-col gap-2">
<label htmlFor="w-birthdate" className="text-white text-xl">
Geboortedatum <span className="text-red-300">*</span>
</label>
<input
type="date"
id="w-birthdate"
name="birthdate"
value={data.birthdate}
onChange={handleChange}
onBlur={handleBlur}
autoComplete="bday"
aria-invalid={touched.birthdate && !!errors.birthdate}
className={`${inputCls(!!touched.birthdate && !!errors.birthdate)} [color-scheme:dark]`}
/>
{touched.birthdate && errors.birthdate && (
<span className="text-red-300 text-sm" role="alert">
{errors.birthdate}
</span>
)}
</div>
</div>
{/* Guests */}
<GuestList
guests={guests}

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

@@ -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

@@ -28,6 +28,8 @@ export interface GuestEntry {
lastName: string;
email: string;
phone: string;
birthdate: string;
postcode: string;
}
export interface GuestErrors {
@@ -35,6 +37,8 @@ export interface GuestErrors {
lastName?: string;
email?: string;
phone?: string;
birthdate?: string;
postcode?: string;
}
/**
@@ -51,6 +55,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 [];
@@ -102,6 +108,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: !g.birthdate.trim() ? "Geboortedatum is verplicht" : undefined,
postcode: !g.postcode.trim() ? "Postcode is verplicht" : undefined,
}));
const valid = !errors.some((e) => Object.values(e).some(Boolean));
return { errors, valid };

View File

@@ -12,10 +12,13 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as TermsRouteImport } from './routes/terms'
import { Route as PrivacyRouteImport } from './routes/privacy'
import { Route as LoginRouteImport } from './routes/login'
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 AdminDrinkkaartRouteImport } from './routes/admin/drinkkaart'
import { Route as ApiWebhookLemonsqueezyRouteImport } from './routes/api/webhook/lemonsqueezy'
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc/$'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
@@ -35,14 +38,19 @@ const LoginRoute = LoginRouteImport.update({
path: '/login',
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,11 +58,21 @@ 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 AdminDrinkkaartRoute = AdminDrinkkaartRouteImport.update({
id: '/admin/drinkkaart',
path: '/admin/drinkkaart',
getParentRoute: () => rootRouteImport,
} as any)
const ApiWebhookLemonsqueezyRoute = ApiWebhookLemonsqueezyRouteImport.update({
id: '/api/webhook/lemonsqueezy',
path: '/api/webhook/lemonsqueezy',
@@ -73,24 +91,30 @@ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/admin': typeof AdminRoute
'/account': typeof AccountRoute
'/contact': typeof ContactRoute
'/drinkkaart': typeof DrinkkaartRoute
'/login': typeof LoginRoute
'/privacy': typeof PrivacyRoute
'/terms': typeof TermsRoute
'/admin/drinkkaart': typeof AdminDrinkkaartRoute
'/manage/$token': typeof ManageTokenRoute
'/admin/': typeof AdminIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
'/api/webhook/lemonsqueezy': typeof ApiWebhookLemonsqueezyRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/admin': typeof AdminRoute
'/account': typeof AccountRoute
'/contact': typeof ContactRoute
'/drinkkaart': typeof DrinkkaartRoute
'/login': typeof LoginRoute
'/privacy': typeof PrivacyRoute
'/terms': typeof TermsRoute
'/admin/drinkkaart': typeof AdminDrinkkaartRoute
'/manage/$token': typeof ManageTokenRoute
'/admin': typeof AdminIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
'/api/webhook/lemonsqueezy': typeof ApiWebhookLemonsqueezyRoute
@@ -98,12 +122,15 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/admin': typeof AdminRoute
'/account': typeof AccountRoute
'/contact': typeof ContactRoute
'/drinkkaart': typeof DrinkkaartRoute
'/login': typeof LoginRoute
'/privacy': typeof PrivacyRoute
'/terms': typeof TermsRoute
'/admin/drinkkaart': typeof AdminDrinkkaartRoute
'/manage/$token': typeof ManageTokenRoute
'/admin/': typeof AdminIndexRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/rpc/$': typeof ApiRpcSplatRoute
'/api/webhook/lemonsqueezy': typeof ApiWebhookLemonsqueezyRoute
@@ -112,36 +139,45 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/admin'
| '/account'
| '/contact'
| '/drinkkaart'
| '/login'
| '/privacy'
| '/terms'
| '/admin/drinkkaart'
| '/manage/$token'
| '/admin/'
| '/api/auth/$'
| '/api/rpc/$'
| '/api/webhook/lemonsqueezy'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/admin'
| '/account'
| '/contact'
| '/drinkkaart'
| '/login'
| '/privacy'
| '/terms'
| '/admin/drinkkaart'
| '/manage/$token'
| '/admin'
| '/api/auth/$'
| '/api/rpc/$'
| '/api/webhook/lemonsqueezy'
id:
| '__root__'
| '/'
| '/admin'
| '/account'
| '/contact'
| '/drinkkaart'
| '/login'
| '/privacy'
| '/terms'
| '/admin/drinkkaart'
| '/manage/$token'
| '/admin/'
| '/api/auth/$'
| '/api/rpc/$'
| '/api/webhook/lemonsqueezy'
@@ -149,12 +185,15 @@ export interface FileRouteTypes {
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AdminRoute: typeof AdminRoute
AccountRoute: typeof AccountRoute
ContactRoute: typeof ContactRoute
DrinkkaartRoute: typeof DrinkkaartRoute
LoginRoute: typeof LoginRoute
PrivacyRoute: typeof PrivacyRoute
TermsRoute: typeof TermsRoute
AdminDrinkkaartRoute: typeof AdminDrinkkaartRoute
ManageTokenRoute: typeof ManageTokenRoute
AdminIndexRoute: typeof AdminIndexRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiRpcSplatRoute: typeof ApiRpcSplatRoute
ApiWebhookLemonsqueezyRoute: typeof ApiWebhookLemonsqueezyRoute
@@ -183,6 +222,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/drinkkaart': {
id: '/drinkkaart'
path: '/drinkkaart'
fullPath: '/drinkkaart'
preLoaderRoute: typeof DrinkkaartRouteImport
parentRoute: typeof rootRouteImport
}
'/contact': {
id: '/contact'
path: '/contact'
@@ -190,11 +236,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 +250,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,6 +264,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ManageTokenRouteImport
parentRoute: typeof rootRouteImport
}
'/admin/drinkkaart': {
id: '/admin/drinkkaart'
path: '/admin/drinkkaart'
fullPath: '/admin/drinkkaart'
preLoaderRoute: typeof AdminDrinkkaartRouteImport
parentRoute: typeof rootRouteImport
}
'/api/webhook/lemonsqueezy': {
id: '/api/webhook/lemonsqueezy'
path: '/api/webhook/lemonsqueezy'
@@ -237,12 +297,15 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AdminRoute: AdminRoute,
AccountRoute: AccountRoute,
ContactRoute: ContactRoute,
DrinkkaartRoute: DrinkkaartRoute,
LoginRoute: LoginRoute,
PrivacyRoute: PrivacyRoute,
TermsRoute: TermsRoute,
AdminDrinkkaartRoute: AdminDrinkkaartRoute,
ManageTokenRoute: ManageTokenRoute,
AdminIndexRoute: AdminIndexRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiRpcSplatRoute: ApiRpcSplatRoute,
ApiWebhookLemonsqueezyRoute: ApiWebhookLemonsqueezyRoute,

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 {
@@ -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,402 @@
import { 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 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.getMyRegistrations.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 registrations = 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>
{registrations.length > 0 ? (
<div className="flex flex-col gap-4">
{registrations.map((registration) => (
<div
key={registration.id}
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>
{(registration.registrationType !== "performer" ||
(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>
)}
</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

@@ -1,25 +1,29 @@
import { createHmac } from "node:crypto";
import { createHmac, randomUUID } from "node:crypto";
import { creditRegistrationToAccount } from "@kk/api/routers/index";
import { db } from "@kk/db";
import { registration } from "@kk/db/schema";
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 { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
// Webhook payload types
interface LemonSqueezyWebhookPayload {
// LemonSqueezy webhook payload types (order_created event)
interface LemonSqueezyOrderCreatedPayload {
meta: {
event_name: string;
custom_data?: {
type?: string;
registration_token?: string;
drinkkaartId?: string;
userId?: string;
};
};
data: {
id: string;
type: string;
attributes: {
customer_id: number;
order_number: number;
status: string;
customer_id: number;
total: number; // amount in cents
};
};
}
@@ -36,7 +40,11 @@ function verifyWebhookSignature(
}
async function handleWebhook({ request }: { request: Request }) {
// Get the raw body as text for signature verification
if (!env.LEMON_SQUEEZY_WEBHOOK_SECRET) {
console.error("LEMON_SQUEEZY_WEBHOOK_SECRET not configured");
return new Response("Payment provider not configured", { status: 500 });
}
const payload = await request.text();
const signature = request.headers.get("X-Signature");
@@ -44,12 +52,6 @@ async function handleWebhook({ request }: { request: Request }) {
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,
@@ -60,31 +62,136 @@ async function handleWebhook({ request }: { request: Request }) {
return new Response("Invalid signature", { status: 401 });
}
let event: LemonSqueezyOrderCreatedPayload;
try {
const event: LemonSqueezyWebhookPayload = JSON.parse(payload);
event = JSON.parse(payload) as LemonSqueezyOrderCreatedPayload;
} catch {
return new Response("Invalid JSON", { status: 400 });
}
// Only handle order_created events
if (event.meta.event_name !== "order_created") {
return new Response("Event ignored", { status: 200 });
// Only handle order_created events
if (event.meta.event_name !== "order_created") {
return new Response("Event ignored", { status: 200 });
}
const orderId = event.data.id;
const customerId = String(event.data.attributes.customer_id);
const amountCents = event.data.attributes.total;
const customData = event.meta.custom_data;
try {
// -------------------------------------------------------------------------
// Branch: Drinkkaart top-up
// -------------------------------------------------------------------------
if (customData?.type === "drinkkaart_topup") {
const { drinkkaartId, userId } = customData;
if (!drinkkaartId || !userId) {
console.error(
"Missing drinkkaartId or userId in drinkkaart_topup custom_data",
);
return new Response("Missing drinkkaart data", { status: 400 });
}
// Idempotency: skip if already processed
const existing = await db
.select({ id: drinkkaartTopup.id })
.from(drinkkaartTopup)
.where(eq(drinkkaartTopup.lemonsqueezyOrderId, orderId))
.limit(1)
.then((r) => r[0]);
if (existing) {
console.log(`Drinkkaart topup already processed for order ${orderId}`);
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 LemonSqueezy 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",
lemonsqueezyOrderId: orderId,
adminId: null,
reason: null,
paidAt: new Date(),
});
console.log(
`Drinkkaart topup successful: drinkkaart=${drinkkaartId}, amount=${amountCents}c, order=${orderId}`,
);
return new Response("OK", { status: 200 });
}
const registrationToken = event.meta.custom_data?.registration_token;
// -------------------------------------------------------------------------
// Branch: Registration payment
// -------------------------------------------------------------------------
const registrationToken = customData?.registration_token;
if (!registrationToken) {
console.error("No registration token in webhook payload");
console.error("No registration token in order custom_data");
return new Response("Missing registration token", { status: 400 });
}
const orderId = event.data.id;
const customerId = String(event.data.attributes.customer_id);
// Fetch the registration row
const regRow = await db
.select()
.from(registration)
.where(eq(registration.managementToken, registrationToken))
.limit(1)
.then((r) => r[0]);
// Update registration in database.
// Covers both "pending" (initial payment) and "extra_payment_pending"
// (delta payment after adding guests to an already-paid registration).
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, // delta has been settled
paymentAmount: 0,
lemonsqueezyOrderId: orderId,
lemonsqueezyCustomerId: customerId,
paidAt: new Date(),
@@ -95,6 +202,47 @@ async function handleWebhook({ request }: { request: Request }) {
`Payment successful for registration ${registrationToken}, order ${orderId}`,
);
// 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);

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,7 +38,7 @@ 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>Vrijdag 24 april 2026</p>
<p>Aanvang: 19:00 uur</p>
<p className="mt-2 text-white/60">
Locatie wordt later bekendgemaakt aan geregistreerde deelnemers.
@@ -53,6 +53,28 @@ 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>
</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,21 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { authClient } from "@/lib/auth-client";
// /drinkkaart redirects to /account, preserving ?topup=success for
// backward-compatibility with the Lemon Squeezy webhook return URL.
export const Route = createFileRoute("/drinkkaart")({
validateSearch: (search: Record<string, unknown>) => ({
topup: search.topup as string | undefined,
}),
beforeLoad: async ({ search }) => {
const session = await authClient.getSession();
if (!session.data?.user) {
throw redirect({ to: "/login" });
}
throw redirect({
to: "/account",
search: search.topup ? { topup: search.topup } : {},
});
},
component: () => null,
});

View File

@@ -15,13 +15,24 @@ import { authClient } from "@/lib/auth-client";
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 [isSignup, setIsSignup] = useState(() => search.signup === "1");
const [email, setEmail] = useState(() => search.email ?? "");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
@@ -49,13 +60,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 +95,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 +125,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,51 +142,64 @@ 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>

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;
postcode: string | null;
birthdate: string | null;
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 ?? "",
postcode: initialData.postcode ?? "",
birthdate: initialData.birthdate ?? "",
registrationType: initialType,
artForm: initialData.artForm ?? "",
experience: initialData.experience ?? "",
@@ -179,6 +183,8 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
lastName: formData.lastName.trim(),
email: formData.email.trim(),
phone: formData.phone.trim() || undefined,
postcode: formData.postcode.trim() || "",
birthdate: formData.birthdate || "",
registrationType: formData.registrationType,
artForm: performer ? formData.artForm.trim() || undefined : undefined,
experience: performer
@@ -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,
@@ -271,6 +279,42 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
/>
</div>
{/* Postcode + Birthdate */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<label htmlFor="postcode" className="mb-2 block text-white">
Postcode *
</label>
<input
id="postcode"
type="text"
required
value={formData.postcode}
onChange={(e) =>
setFormData((p) => ({ ...p, postcode: e.target.value }))
}
autoComplete="postal-code"
className={inputCls(false)}
/>
</div>
<div>
<label htmlFor="birthdate" className="mb-2 block text-white">
Geboortedatum *
</label>
<input
id="birthdate"
type="date"
required
value={formData.birthdate}
onChange={(e) =>
setFormData((p) => ({ ...p, birthdate: e.target.value }))
}
autoComplete="bday"
className={`${inputCls(false)} [color-scheme:dark]`}
/>
</div>
</div>
{/* Registration type toggle */}
<div>
<p className="mb-3 text-white">Type inschrijving</p>
@@ -393,7 +437,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 +478,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 +620,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,18 +636,19 @@ function ManageRegistrationPage() {
)}
</div>
{/* Payment status - shown for everyone with pending/extra payment or gift */}
{(data.paymentStatus !== "paid" || (data.giftAmount ?? 0) > 0) && (
<div className="mb-6">
{data.paymentStatus === "paid" ? (
<PaidBadge />
) : data.paymentStatus === "extra_payment_pending" ? (
<ExtraPaymentBadge amountCents={data.paymentAmount ?? 0} />
) : (
<PendingBadge />
)}
</div>
)}
{/* Payment status - not shown for performers without a gift */}
{(!isPerformer || (data.giftAmount ?? 0) > 0) &&
(data.paymentStatus !== "paid" || (data.giftAmount ?? 0) > 0) && (
<div className="mb-6">
{data.paymentStatus === "paid" ? (
<PaidBadge />
) : data.paymentStatus === "extra_payment_pending" ? (
<ExtraPaymentBadge amountCents={data.paymentAmount ?? 0} />
) : (
<PendingBadge />
)}
</div>
)}
{/* Gift display */}
{(data.giftAmount ?? 0) > 0 && (
@@ -626,6 +678,14 @@ function ManageRegistrationPage() {
<p className="text-sm text-white/50">Telefoon</p>
<p className="text-lg text-white">{data.phone || "—"}</p>
</div>
<div>
<p className="text-sm text-white/50">Postcode</p>
<p className="text-lg text-white">{data.postcode || "—"}</p>
</div>
<div>
<p className="text-sm text-white/50">Geboortedatum</p>
<p className="text-lg text-white">{data.birthdate || "—"}</p>
</div>
</div>
{isPerformer && (
@@ -660,6 +720,16 @@ function ManageRegistrationPage() {
<p className="text-white">
{g.firstName} {g.lastName}
</p>
{g.birthdate && (
<p className="text-sm text-white/60">
Geboortedatum: {g.birthdate}
</p>
)}
{g.postcode && (
<p className="text-sm text-white/60">
Postcode: {g.postcode}
</p>
)}
{g.email && (
<p className="text-sm text-white/60">{g.email}</p>
)}

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"

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"

110
bun.lock
View File

@@ -45,9 +45,11 @@
"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",
@@ -66,6 +68,7 @@
"@tanstack/react-router-devtools": "^1.141.1",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0",
"@types/qrcode": "^1.5.6",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@types/three": "^0.183.1",
@@ -84,7 +87,6 @@
"@kk/auth": "workspace:*",
"@kk/db": "workspace:*",
"@kk/env": "workspace:*",
"@lemonsqueezy/lemonsqueezy.js": "^4.00.0",
"@orpc/client": "catalog:",
"@orpc/openapi": "catalog:",
"@orpc/server": "catalog:",
@@ -527,8 +529,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=="],
@@ -919,6 +919,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,6 +1021,8 @@
"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=="],
@@ -1039,7 +1043,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 +1093,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 +1119,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 +1149,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 +1225,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 +1293,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 +1433,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=="],
@@ -1521,6 +1535,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 +1563,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 +1579,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 +1609,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 +1641,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 +1687,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 +1735,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 +1897,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 +1921,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 +1971,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 +2025,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 +2055,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 +2091,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 +2111,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 +2181,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 +2359,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,7 +14,6 @@
"@kk/auth": "workspace:*",
"@kk/db": "workspace:*",
"@kk/env": "workspace:*",
"@lemonsqueezy/lemonsqueezy.js": "^4.00.0",
"@orpc/client": "catalog:",
"@orpc/openapi": "catalog:",
"@orpc/server": "catalog:",

View File

@@ -1,4 +1,5 @@
import { auth } from "@kk/auth";
import { env } from "@kk/env/server";
export async function createContext({ req }: { req: Request }) {
const session = await auth.api.getSession({
@@ -6,6 +7,7 @@ export async function createContext({ req }: { req: Request }) {
});
return {
session,
env,
};
}

View File

@@ -86,7 +86,7 @@ function registrationConfirmationHtml(params: {
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.
We hebben je inschrijving voor <strong style="color:#ffffff;">Open Mic Night — vrijdag 24 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;">

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,159 @@
import { env } from "@kk/env/server";
import nodemailer from "nodemailer";
// Re-use the same SMTP transport strategy as email.ts.
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;
}
const from = env.SMTP_FROM ?? "Kunstenkamp <info@kunstenkamp.be>";
const baseUrl = env.BETTER_AUTH_URL ?? "https://kunstenkamp.be";
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.
* Fire-and-forget: errors are logged but not re-thrown.
*/
export async function sendDeductionEmail(params: {
to: string;
firstName: string;
amountCents: number;
newBalanceCents: number;
}): Promise<void> {
const transport = getTransport();
if (!transport) {
console.warn("SMTP not configured — skipping deduction email");
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);
await transport.sendMail({
from,
to: params.to,
subject: `Drinkkaart — ${amountFormatted} afgeschreven`,
html: deductionHtml({
firstName: params.firstName,
amountCents: params.amountCents,
newBalanceCents: params.newBalanceCents,
dateTime,
drinkkaartUrl: `${baseUrl}/drinkkaart`,
}),
});
}

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,742 @@
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 { 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.LEMON_SQUEEZY_API_KEY ||
!serverEnv.LEMON_SQUEEZY_STORE_ID ||
!serverEnv.LEMON_SQUEEZY_VARIANT_ID
) {
throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "LemonSqueezy is niet geconfigureerd",
});
}
const card = await getOrCreateDrinkkaart(session.user.id);
const response = await fetch(
"https://api.lemonsqueezy.com/v1/checkouts",
{
method: "POST",
headers: {
Accept: "application/vnd.api+json",
"Content-Type": "application/vnd.api+json",
Authorization: `Bearer ${serverEnv.LEMON_SQUEEZY_API_KEY}`,
},
body: JSON.stringify({
data: {
type: "checkouts",
attributes: {
custom_price: input.amountCents,
product_options: {
name: "Drinkkaart Opladen",
description: `Opwaardering van ${formatCents(input.amountCents)}`,
redirect_url: `${serverEnv.BETTER_AUTH_URL}/account?topup=success`,
},
checkout_data: {
email: session.user.email,
name: session.user.name,
custom: {
type: "drinkkaart_topup",
drinkkaartId: card.id,
userId: session.user.id,
},
},
checkout_options: {
embed: false,
locale: "nl",
},
},
relationships: {
store: {
data: {
type: "stores",
id: serverEnv.LEMON_SQUEEZY_STORE_ID,
},
},
variant: {
data: {
type: "variants",
id: serverEnv.LEMON_SQUEEZY_VARIANT_ID,
},
},
},
},
}),
},
);
if (!response.ok) {
const errorData = await response.json();
console.error("LemonSqueezy Drinkkaart checkout error:", errorData);
throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Kon checkout niet aanmaken",
});
}
const data = (await response.json()) as {
data?: { attributes?: { url?: string } };
};
const checkoutUrl = data.data?.attributes?.url;
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
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) {
sendDeductionEmail({
to: cardUser.email,
firstName: cardUser.name.split(" ")[0] ?? cardUser.name,
amountCents: input.amountCents,
newBalanceCents: balanceAfter,
}).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;
}),
};

View File

@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import { db } from "@kk/db";
import { adminRequest, registration } from "@kk/db/schema";
import { user } from "@kk/db/schema/auth";
import { drinkkaart, drinkkaartTopup } from "@kk/db/schema/drinkkaart";
import { env } from "@kk/env/server";
import type { RouterClient } from "@orpc/server";
import { and, count, desc, eq, gte, isNull, like, lte, sum } from "drizzle-orm";
@@ -12,6 +13,8 @@ import {
sendUpdateEmail,
} from "../email";
import { adminProcedure, protectedProcedure, publicProcedure } from "../index";
import { generateQrSecret } from "../lib/drinkkaart-utils";
import { drinkkaartRouter } from "./drinkkaart";
// ---------------------------------------------------------------------------
// Shared helpers
@@ -33,6 +36,8 @@ function parseGuestsJson(raw: string | null): Array<{
lastName: string;
email?: string;
phone?: string;
birthdate?: string;
postcode?: string;
}> {
if (!raw) return [];
try {
@@ -43,6 +48,150 @@ function parseGuestsJson(raw: string | null): Array<{
}
}
/**
* Credits a watcher's drinkCardValue to their drinkkaart account.
*
* Safe to call multiple times — the `drinkkaartCreditedAt IS NULL` guard and the
* `lemonsqueezy_order_id` UNIQUE constraint together prevent double-crediting even
* if called concurrently (webhook + signup racing each other).
*
* Returns a status string describing the outcome.
*/
export async function creditRegistrationToAccount(
email: string,
userId: string,
): Promise<{
credited: boolean;
amountCents: number;
status:
| "credited"
| "already_credited"
| "no_paid_registration"
| "zero_value";
}> {
// Find the most recent paid watcher registration for this email that hasn't
// been credited yet and has a non-zero drink card value.
const rows = await db
.select()
.from(registration)
.where(
and(
eq(registration.email, email),
eq(registration.registrationType, "watcher"),
eq(registration.paymentStatus, "paid"),
isNull(registration.cancelledAt),
isNull(registration.drinkkaartCreditedAt),
),
)
.orderBy(desc(registration.createdAt))
.limit(1);
const reg = rows[0];
if (!reg) {
// Either no paid registration exists, or it was already credited.
// Distinguish between the two for better logging.
const credited = await db
.select({ id: registration.id })
.from(registration)
.where(
and(
eq(registration.email, email),
eq(registration.registrationType, "watcher"),
eq(registration.paymentStatus, "paid"),
isNull(registration.cancelledAt),
),
)
.limit(1)
.then((r) => r[0]);
return {
credited: false,
amountCents: 0,
status: credited ? "already_credited" : "no_paid_registration",
};
}
const drinkCardValueEuros = reg.drinkCardValue ?? 0;
if (drinkCardValueEuros === 0) {
return { credited: false, amountCents: 0, status: "zero_value" };
}
const amountCents = drinkCardValueEuros * 100;
// Get or create the drinkkaart for this user.
let card = await db
.select()
.from(drinkkaart)
.where(eq(drinkkaart.userId, userId))
.limit(1)
.then((r) => r[0]);
if (!card) {
const now = new Date();
card = {
id: randomUUID(),
userId,
balance: 0,
version: 0,
qrSecret: generateQrSecret(),
createdAt: now,
updatedAt: now,
};
await db.insert(drinkkaart).values(card);
}
const balanceBefore = card.balance;
const balanceAfter = balanceBefore + amountCents;
// Optimistic-lock update — if concurrent, the second caller will see
// drinkkaartCreditedAt IS NOT NULL and return "already_credited" above.
const updateResult = await db
.update(drinkkaart)
.set({
balance: balanceAfter,
version: card.version + 1,
updatedAt: new Date(),
})
.where(
and(eq(drinkkaart.id, card.id), eq(drinkkaart.version, card.version)),
);
if (updateResult.rowsAffected === 0) {
// Lost the optimistic lock race — the other caller will handle it.
return { credited: false, amountCents: 0, status: "already_credited" };
}
// Record the topup. Re-use the registration's lemonsqueezyOrderId so the
// topup row is clearly linked to the original payment. We prefix with
// "reg_" to distinguish from direct drinkkaart top-up orders if needed.
const topupPaymentId = reg.lemonsqueezyOrderId
? `reg_${reg.lemonsqueezyOrderId}`
: null;
await db.insert(drinkkaartTopup).values({
id: randomUUID(),
drinkkaartId: card.id,
userId,
amountCents,
balanceBefore,
balanceAfter,
type: "payment",
lemonsqueezyOrderId: topupPaymentId,
adminId: null,
reason: "Drinkkaart bij registratie",
paidAt: reg.paidAt ?? new Date(),
});
// Mark the registration as credited — prevents any future double-credit.
await db
.update(registration)
.set({ drinkkaartCreditedAt: new Date() })
.where(eq(registration.id, reg.id));
return { credited: true, amountCents, status: "credited" };
}
/** Fetches a single registration by management token, throws if not found or cancelled. */
async function getActiveRegistration(token: string) {
const rows = await db
@@ -71,6 +220,8 @@ const guestSchema = z.object({
lastName: z.string().min(1),
email: z.string().email().optional().or(z.literal("")),
phone: z.string().optional(),
birthdate: z.string().min(1),
postcode: z.string().min(1),
});
const coreRegistrationFields = {
@@ -78,6 +229,8 @@ const coreRegistrationFields = {
lastName: z.string().min(1),
email: z.string().email(),
phone: z.string().optional(),
postcode: z.string().min(1),
birthdate: z.string().min(1),
registrationType: registrationTypeSchema.default("watcher"),
artForm: z.string().optional(),
experience: z.string().optional(),
@@ -110,6 +263,7 @@ const getRegistrationsSchema = z.object({
export const appRouter = {
healthCheck: publicProcedure.handler(() => "OK"),
drinkkaart: drinkkaartRouter,
privateData: protectedProcedure.handler(({ context }) => ({
message: "This is private",
@@ -129,6 +283,8 @@ export const appRouter = {
lastName: input.lastName,
email: input.email,
phone: input.phone || null,
postcode: input.postcode,
birthdate: input.birthdate,
registrationType: input.registrationType,
artForm: isPerformer ? input.artForm || null : null,
experience: isPerformer ? input.experience || null : null,
@@ -216,6 +372,8 @@ export const appRouter = {
lastName: input.lastName,
email: input.email,
phone: input.phone || null,
postcode: input.postcode,
birthdate: input.birthdate,
registrationType: input.registrationType,
artForm: isPerformer ? input.artForm || null : null,
experience: isPerformer ? input.experience || null : null,
@@ -367,6 +525,8 @@ export const appRouter = {
"Last Name",
"Email",
"Phone",
"Postcode",
"Birthdate",
"Type",
"Art Form",
"Experience",
@@ -386,7 +546,7 @@ export const appRouter = {
const guestSummary = guests
.map(
(g) =>
`${g.firstName} ${g.lastName}${g.email ? ` <${g.email}>` : ""}${g.phone ? ` (${g.phone})` : ""}`,
`${g.firstName} ${g.lastName}${g.birthdate ? ` (${g.birthdate})` : ""}${g.postcode ? ` [${g.postcode}]` : ""}${g.email ? ` <${g.email}>` : ""}${g.phone ? ` (${g.phone})` : ""}`,
)
.join(" | ");
return [
@@ -395,6 +555,8 @@ export const appRouter = {
r.lastName,
r.email,
r.phone || "",
r.postcode || "",
r.birthdate || "",
r.registrationType,
r.artForm || "",
r.experience || "",
@@ -423,6 +585,35 @@ export const appRouter = {
};
}),
// ---------------------------------------------------------------------------
// User account
// ---------------------------------------------------------------------------
claimRegistrationCredit: protectedProcedure.handler(async ({ context }) => {
const result = await creditRegistrationToAccount(
context.session.user.email,
context.session.user.id,
);
return result;
}),
getMyRegistrations: protectedProcedure.handler(async ({ context }) => {
const email = context.session.user.email;
const rows = await db
.select()
.from(registration)
.where(
and(eq(registration.email, email), isNull(registration.cancelledAt)),
)
.orderBy(desc(registration.createdAt));
return rows.map((row) => ({
...row,
guests: parseGuestsJson(row.guests),
}));
}),
// ---------------------------------------------------------------------------
// Admin access requests
// ---------------------------------------------------------------------------
@@ -553,14 +744,19 @@ export const appRouter = {
// ---------------------------------------------------------------------------
getCheckoutUrl: publicProcedure
.input(z.object({ token: z.string().uuid() }))
.input(
z.object({
token: z.string().uuid(),
redirectUrl: z.string().optional(),
}),
)
.handler(async ({ input }) => {
if (
!env.LEMON_SQUEEZY_API_KEY ||
!env.LEMON_SQUEEZY_STORE_ID ||
!env.LEMON_SQUEEZY_VARIANT_ID
) {
throw new Error("Lemon Squeezy is niet geconfigureerd");
throw new Error("LemonSqueezy is niet geconfigureerd");
}
const rows = await db
@@ -610,6 +806,9 @@ export const appRouter = {
? `Vrijwillige gift${drinkCardTotal > 0 ? " + drinkkaart" : ""}`
: `Toegangskaart${giftTotal > 0 ? " + gift" : ""}`;
const redirectUrl =
input.redirectUrl ?? `${env.CORS_ORIGIN}/manage/${input.token}`;
const response = await fetch(
"https://api.lemonsqueezy.com/v1/checkouts",
{
@@ -627,12 +826,14 @@ export const appRouter = {
product_options: {
name: "Kunstenkamp Evenement",
description: productDescription,
redirect_url: `${env.CORS_ORIGIN}/manage/${input.token}`,
redirect_url: redirectUrl,
},
checkout_data: {
email: row.email,
name: `${row.firstName} ${row.lastName}`,
custom: { registration_token: input.token },
custom: {
registration_token: input.token,
},
},
checkout_options: {
embed: false,
@@ -641,10 +842,16 @@ export const appRouter = {
},
relationships: {
store: {
data: { type: "stores", id: env.LEMON_SQUEEZY_STORE_ID },
data: {
type: "stores",
id: env.LEMON_SQUEEZY_STORE_ID,
},
},
variant: {
data: { type: "variants", id: env.LEMON_SQUEEZY_VARIANT_ID },
data: {
type: "variants",
id: env.LEMON_SQUEEZY_VARIANT_ID,
},
},
},
},
@@ -654,7 +861,7 @@ export const appRouter = {
if (!response.ok) {
const errorData = await response.json();
console.error("Lemon Squeezy checkout error:", errorData);
console.error("LemonSqueezy checkout error:", errorData);
throw new Error("Kon checkout niet aanmaken");
}

View File

@@ -12,7 +12,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

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,15 @@
-- Migrate from Mollie back to LemonSqueezy
-- Renames payment provider columns in registration and drinkkaart_topup tables,
-- and restores the lemonsqueezy_customer_id column on registration.
-- registration table:
-- mollie_payment_id -> lemonsqueezy_order_id
-- (re-add) lemonsqueezy_customer_id
ALTER TABLE registration RENAME COLUMN mollie_payment_id TO lemonsqueezy_order_id;
ALTER TABLE registration ADD COLUMN lemonsqueezy_customer_id text;
-- drinkkaart_topup table:
-- mollie_payment_id -> lemonsqueezy_order_id
ALTER TABLE drinkkaart_topup RENAME COLUMN mollie_payment_id TO lemonsqueezy_order_id;

View File

@@ -0,0 +1,2 @@
ALTER TABLE registration ADD COLUMN postcode TEXT;
ALTER TABLE registration ADD COLUMN birthdate TEXT;

View File

@@ -22,6 +22,20 @@
"when": 1772520000000,
"tag": "0002_registration_type_redesign",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1772530000000,
"tag": "0003_add_guests",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1741388400000,
"tag": "0008_add_postcode_birthdate",
"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(),
lemonsqueezyOrderId: text("lemonsqueezy_order_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,4 @@
export * from "./admin-requests";
export * from "./auth";
export * from "./drinkkaart";
export * from "./registrations";

View File

@@ -21,6 +21,9 @@ export const registration = sqliteTable(
drinkCardValue: integer("drink_card_value").default(0),
// Guests: JSON array of {firstName, lastName, email?, phone?} objects
guests: text("guests"),
// Contact / demographic
postcode: text("postcode"),
birthdate: text("birthdate"),
// Shared
extraQuestions: text("extra_questions"),
managementToken: text("management_token").unique(),
@@ -32,6 +35,12 @@ export const registration = sqliteTable(
lemonsqueezyOrderId: text("lemonsqueezy_order_id"),
lemonsqueezyCustomerId: text("lemonsqueezy_customer_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",
}),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),

View File

@@ -30,7 +30,7 @@ export const web = await TanStackStart("web", {
SMTP_USER: getEnvVar("SMTP_USER"),
SMTP_PASS: getEnvVar("SMTP_PASS"),
SMTP_FROM: getEnvVar("SMTP_FROM"),
// Payments (Lemon Squeezy)
// Payments (LemonSqueezy)
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"),