Compare commits

...

6 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
31 changed files with 1474 additions and 424 deletions

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

@@ -36,6 +36,7 @@ export default function EventRegistrationForm() {
token={successState.token} token={successState.token}
email={successState.email} email={successState.email}
name={successState.name} name={successState.name}
isLoggedIn={isLoggedIn}
onReset={() => { onReset={() => {
setSuccessState(null); setSuccessState(null);
setSelectedType(null); setSelectedType(null);
@@ -53,10 +54,12 @@ export default function EventRegistrationForm() {
<h2 className="mb-2 font-['Intro',sans-serif] text-3xl text-white md:text-4xl"> <h2 className="mb-2 font-['Intro',sans-serif] text-3xl text-white md:text-4xl">
Schrijf je nu in! Schrijf je nu in!
</h2> </h2>
<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"> <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. Doe je mee of kom je kijken? Kies je rol en vul het formulier in.
</p> </p>
{/* Login nudge — shown only to guests */} {/* Login nudge — shown only to guests */}
{!isLoggedIn && ( {!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"> <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">
@@ -85,9 +88,7 @@ export default function EventRegistrationForm() {
</div> </div>
</div> </div>
)} )}
{!selectedType && <TypeSelector onSelect={setSelectedType} />} {!selectedType && <TypeSelector onSelect={setSelectedType} />}
{selectedType === "performer" && ( {selectedType === "performer" && (
<PerformerForm <PerformerForm
onBack={() => setSelectedType(null)} onBack={() => setSelectedType(null)}
@@ -100,7 +101,6 @@ export default function EventRegistrationForm() {
isLoggedIn={isLoggedIn} isLoggedIn={isLoggedIn}
/> />
)} )}
{selectedType === "watcher" && ( {selectedType === "watcher" && (
<WatcherForm <WatcherForm
onBack={() => setSelectedType(null)} onBack={() => setSelectedType(null)}

View File

@@ -17,63 +17,141 @@ export default function Footer() {
}, []); }, []);
return ( return (
<footer className="relative z-40 flex h-[250px] flex-col items-center justify-center bg-[#d09035]"> <footer className="relative z-40 overflow-hidden bg-[#d09035]">
<div className="text-center"> {/* Magenta top accent line */}
<h3 className="mb-4 flex items-center justify-center gap-2 font-['Intro',sans-serif] text-2xl text-white"> <div className="h-1 w-full bg-[#d82560]" />
<img
src="/favicon.png" {/* Diagonal texture overlay */}
alt="" <div
className="h-8 w-8 rounded bg-[#0B1C1F]" 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="https://ejv.be/jong/kampen/kunstenkamp/"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 opacity-90 transition-opacity hover:opacity-100"
>
<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 Kunstenkamp
</h3> </span>
<p className="mb-6 font-['Intro',sans-serif] text-white/80"> </a>
<p className="font-['DM_Sans',sans-serif] font-medium text-sm text-white/70 uppercase tracking-[0.2em]">
Waar creativiteit tot leven komt Waar creativiteit tot leven komt
</p> </p>
</div>
<div className="flex flex-col items-center justify-center gap-2 text-sm text-white/70 md:flex-row md:gap-8"> {/* 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>
{/* Partners row */}
<div className="mb-10 flex flex-col items-center justify-center gap-10 md:flex-row md:gap-16">
{/* EJV */}
<a <a
href="/privacy" href="https://ejv.be"
className="link-hover transition-colors hover:text-white" target="_blank"
rel="noopener noreferrer"
className="flex flex-col items-center gap-3 opacity-90 transition-opacity hover:opacity-100"
> >
Privacy Beleid <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> </a>
<span className="hidden text-white/40 md:inline">|</span>
{/* Vertical rule */}
<div className="hidden h-28 w-px bg-white/20 md:block" />
{/* Vlaanderen */}
<a <a
href="/terms" href="https://www.vlaanderen.be/cjm/nl"
className="link-hover transition-colors hover:text-white" target="_blank"
rel="noopener noreferrer"
className="flex flex-col items-center gap-3 opacity-90 transition-opacity hover:opacity-100"
> >
Algemene Voorwaarden <img
src="/assets/vlaanderen.svg"
alt="Met steun van de Vlaamse overheid"
className="h-40 w-auto drop-shadow-md"
/>
</a> </a>
<span className="hidden text-white/40 md:inline">|</span> </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 <a
href="/contact" href={link.href}
className="link-hover transition-colors hover:text-white" className="link-hover font-['DM_Sans',sans-serif] font-medium text-white/60 text-xs uppercase tracking-[0.15em]"
> >
Contact {link.label}
</a> </a>
{i < arr.length - 1 && (
<span className="hidden text-white/30 md:inline">·</span>
)}
</span>
))}
{!isLoading && isAdmin && ( {!isLoading && isAdmin && (
<> <span className="flex items-center gap-6">
<span className="hidden text-white/40 md:inline">|</span> <span className="hidden text-white/30 md:inline">·</span>
<Link <Link
to="/admin" to="/admin"
className="link-hover transition-colors hover:text-white" className="link-hover font-['DM_Sans',sans-serif] font-medium text-white/60 text-xs uppercase tracking-[0.15em]"
> >
Admin Admin
</Link> </Link>
</> </span>
)} )}
</div> </div>
<div className="mt-6 text-white/50 text-xs">
© {new Date().getFullYear()} Kunstenkamp. Alle rechten voorbehouden.
</div> </div>
<div className="text-white/50 text-xs transition-colors hover:text-white">
{/* 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 <a
href="https://zias.be" href="https://zias.be"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="link-hover" className="link-hover font-['DM_Sans',sans-serif] text-white/60 text-xs"
> >
Gemaakt met door zias.be Gemaakt met door zias.be
</a> </a>

View File

@@ -61,7 +61,7 @@ export default function Hero() {
type="button" 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" 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> </button>
</div> </div>
@@ -83,7 +83,7 @@ export default function Hero() {
{/* Bottom Right - Dark Teal with date - above mic */} {/* Bottom Right - Dark Teal with date - above mic */}
<div className="relative flex flex-1 items-center justify-end bg-[#214e51]"> <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]"> <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 /> <br />
april april
</p> </p>
@@ -114,7 +114,7 @@ export default function Hero() {
NIGHT NIGHT
</h1> </h1>
<p className="mt-4 font-['Intro',sans-serif] font-normal text-[5vw] text-white/90"> <p className="mt-4 font-['Intro',sans-serif] font-normal text-[5vw] text-white/90">
Ongedesemd brood Ongedesemd Woord
</p> </p>
{/* Mobile Microphone - positioned inside magenta section, clipped by overflow */} {/* 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"> <p className="text-right font-['Intro',sans-serif] font-normal text-[8vw] text-white uppercase leading-tight">
VRIJDAG VRIJDAG
<br /> <br />
18 april 24 april
</p> </p>
</div> </div>
</div> </div>

View File

@@ -24,7 +24,7 @@ const faqQuestions = [
export default function Info() { export default function Info() {
return ( return (
<section id="info" className="relative z-20 flex flex-col bg-[#d82560]/96"> <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"> <div className="relative w-full border-white/20 border-b-4">
{/* Background pattern */} {/* Background pattern */}
<div <div
@@ -43,7 +43,7 @@ export default function Info() {
viewBox="0 0 80 56" viewBox="0 0 80 56"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
aria-label="Ongedesemd brood" aria-label="Ongedesemd Woord"
> >
{/* Flat matzo cracker - slightly rounded rect */} {/* Flat matzo cracker - slightly rounded rect */}
<rect <rect
@@ -97,7 +97,7 @@ export default function Info() {
className="block" className="block"
style={{ fontSize: "clamp(1rem, 6vw + 1rem, 6rem)" }} style={{ fontSize: "clamp(1rem, 6vw + 1rem, 6rem)" }}
> >
Brood?! Woord?!
</span> </span>
</h2> </h2>
@@ -126,7 +126,7 @@ export default function Info() {
/> />
<div className="relative mx-auto flex w-full max-w-6xl flex-1 flex-col justify-center gap-16"> <div className="relative mx-auto flex w-full max-w-6xl flex-1 flex-col justify-center gap-16">
{/* Ongedesemd Brood Explanation - Full Width Special Treatment */} {/* 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="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" /> <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>
<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"> <div className="flex flex-col gap-2">
<label htmlFor={`guest-${idx}-email`} className="text-white/80"> <label htmlFor={`guest-${idx}-email`} className="text-white/80">
E-mail E-mail

View File

@@ -15,6 +15,8 @@ interface PerformerErrors {
lastName?: string; lastName?: string;
email?: string; email?: string;
phone?: string; phone?: string;
postcode?: string;
birthdate?: string;
artForm?: string; artForm?: string;
isOver16?: string; isOver16?: string;
} }
@@ -41,6 +43,8 @@ export function PerformerForm({
lastName: prefillLastName, lastName: prefillLastName,
email: prefillEmail, email: prefillEmail,
phone: "", phone: "",
postcode: "",
birthdate: "",
artForm: "", artForm: "",
experience: "", experience: "",
isOver16: false, isOver16: false,
@@ -71,6 +75,8 @@ export function PerformerForm({
lastName: validateTextField(data.lastName, true, "Achternaam"), lastName: validateTextField(data.lastName, true, "Achternaam"),
email: validateEmail(data.email), email: validateEmail(data.email),
phone: validatePhone(data.phone), 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, artForm: !data.artForm.trim() ? "Kunstvorm is verplicht" : undefined,
isOver16: !data.isOver16 isOver16: !data.isOver16
? "Je moet 16 jaar of ouder zijn om op te treden" ? "Je moet 16 jaar of ouder zijn om op te treden"
@@ -82,6 +88,8 @@ export function PerformerForm({
lastName: true, lastName: true,
email: true, email: true,
phone: true, phone: true,
postcode: true,
birthdate: true,
artForm: true, artForm: true,
isOver16: true, isOver16: true,
}); });
@@ -110,6 +118,14 @@ export function PerformerForm({
), ),
email: validateEmail(name === "email" ? value : data.email), email: validateEmail(name === "email" ? value : data.email),
phone: validatePhone(name === "phone" ? value : data.phone), 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: artForm:
name === "artForm" && !value.trim() name === "artForm" && !value.trim()
? "Kunstvorm is verplicht" ? "Kunstvorm is verplicht"
@@ -129,6 +145,14 @@ export function PerformerForm({
lastName: validateTextField(value, true, "Achternaam"), lastName: validateTextField(value, true, "Achternaam"),
email: validateEmail(value), email: validateEmail(value),
phone: validatePhone(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, artForm: !value.trim() ? "Kunstvorm is verplicht" : undefined,
}; };
setErrors((prev) => ({ ...prev, [name]: errMap[name] })); setErrors((prev) => ({ ...prev, [name]: errMap[name] }));
@@ -145,6 +169,8 @@ export function PerformerForm({
lastName: data.lastName.trim(), lastName: data.lastName.trim(),
email: data.email.trim(), email: data.email.trim(),
phone: data.phone.trim() || undefined, phone: data.phone.trim() || undefined,
postcode: data.postcode.trim(),
birthdate: data.birthdate,
registrationType: "performer", registrationType: "performer",
artForm: data.artForm.trim() || undefined, artForm: data.artForm.trim() || undefined,
experience: data.experience.trim() || undefined, experience: data.experience.trim() || undefined,
@@ -306,6 +332,54 @@ export function PerformerForm({
</div> </div>
</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 */} {/* Performer-specific fields */}
<div className="border border-amber-400/20 bg-amber-400/5 p-6"> <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"> <p className="mb-5 text-amber-300/80 text-sm uppercase tracking-wider">

View File

@@ -5,9 +5,16 @@ interface Props {
email?: string; email?: string;
name?: string; name?: string;
onReset: () => void; onReset: () => void;
isLoggedIn?: boolean;
} }
export function SuccessScreen({ token, email, name, onReset }: Props) { export function SuccessScreen({
token,
email,
name,
onReset,
isLoggedIn,
}: Props) {
const manageUrl = const manageUrl =
typeof window !== "undefined" typeof window !== "undefined"
? `${window.location.origin}/manage/${token}` ? `${window.location.origin}/manage/${token}`
@@ -97,8 +104,8 @@ export function SuccessScreen({ token, email, name, onReset }: Props) {
</button> </button>
</div> </div>
{/* Account creation prompt */} {/* Account creation prompt — hidden when already logged in */}
{!drinkkaartPromptDismissed && ( {!isLoggedIn && !drinkkaartPromptDismissed && (
<div className="mt-8 rounded-xl border border-teal-400/30 bg-teal-400/10 p-6"> <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"> <h3 className="mb-1 font-['Intro',sans-serif] text-lg text-white">
Maak een gratis account aan Maak een gratis account aan

View File

@@ -21,6 +21,8 @@ interface WatcherErrors {
lastName?: string; lastName?: string;
email?: string; email?: string;
phone?: string; phone?: string;
postcode?: string;
birthdate?: string;
} }
interface Props { interface Props {
@@ -251,6 +253,8 @@ export function WatcherForm({
lastName: prefillLastName, lastName: prefillLastName,
email: prefillEmail, email: prefillEmail,
phone: "", phone: "",
postcode: "",
birthdate: "",
extraQuestions: "", extraQuestions: "",
}); });
const [errors, setErrors] = useState<WatcherErrors>({}); const [errors, setErrors] = useState<WatcherErrors>({});
@@ -306,6 +310,8 @@ export function WatcherForm({
lastName: validateTextField(data.lastName, true, "Achternaam"), lastName: validateTextField(data.lastName, true, "Achternaam"),
email: validateEmail(data.email), email: validateEmail(data.email),
phone: validatePhone(data.phone), phone: validatePhone(data.phone),
postcode: !data.postcode.trim() ? "Postcode is verplicht" : undefined,
birthdate: !data.birthdate ? "Geboortedatum is verplicht" : undefined,
}; };
setErrors(fieldErrs); setErrors(fieldErrs);
setTouched({ setTouched({
@@ -313,6 +319,8 @@ export function WatcherForm({
lastName: true, lastName: true,
email: true, email: true,
phone: true, phone: true,
postcode: true,
birthdate: true,
}); });
const { errors: gErrs, valid: gValid } = validateGuests(guests); const { errors: gErrs, valid: gValid } = validateGuests(guests);
setGuestErrors(gErrs); setGuestErrors(gErrs);
@@ -330,6 +338,14 @@ export function WatcherForm({
lastName: validateTextField(value, true, "Achternaam"), lastName: validateTextField(value, true, "Achternaam"),
email: validateEmail(value), email: validateEmail(value),
phone: validatePhone(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] })); setErrors((prev) => ({ ...prev, [name]: errMap[name] }));
} }
@@ -345,6 +361,14 @@ export function WatcherForm({
lastName: validateTextField(value, true, "Achternaam"), lastName: validateTextField(value, true, "Achternaam"),
email: validateEmail(value), email: validateEmail(value),
phone: validatePhone(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] })); setErrors((prev) => ({ ...prev, [name]: errMap[name] }));
} }
@@ -365,7 +389,14 @@ export function WatcherForm({
if (guests.length >= 9) return; if (guests.length >= 9) return;
setGuests((prev) => [ setGuests((prev) => [
...prev, ...prev,
{ firstName: "", lastName: "", email: "", phone: "" }, {
firstName: "",
lastName: "",
email: "",
phone: "",
birthdate: "",
postcode: "",
},
]); ]);
setGuestErrors((prev) => [...prev, {}]); setGuestErrors((prev) => [...prev, {}]);
} }
@@ -386,12 +417,16 @@ export function WatcherForm({
lastName: data.lastName.trim(), lastName: data.lastName.trim(),
email: data.email.trim(), email: data.email.trim(),
phone: data.phone.trim() || undefined, phone: data.phone.trim() || undefined,
postcode: data.postcode.trim(),
birthdate: data.birthdate,
registrationType: "watcher", registrationType: "watcher",
guests: guests.map((g) => ({ guests: guests.map((g) => ({
firstName: g.firstName.trim(), firstName: g.firstName.trim(),
lastName: g.lastName.trim(), lastName: g.lastName.trim(),
email: g.email.trim() || undefined, email: g.email.trim() || undefined,
phone: g.phone.trim() || undefined, phone: g.phone.trim() || undefined,
birthdate: g.birthdate.trim(),
postcode: g.postcode.trim(),
})), })),
extraQuestions: data.extraQuestions.trim() || undefined, extraQuestions: data.extraQuestions.trim() || undefined,
giftAmount, giftAmount,
@@ -585,6 +620,54 @@ export function WatcherForm({
</div> </div>
</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 */} {/* Guests */}
<GuestList <GuestList
guests={guests} guests={guests}

View File

@@ -28,6 +28,8 @@ export interface GuestEntry {
lastName: string; lastName: string;
email: string; email: string;
phone: string; phone: string;
birthdate: string;
postcode: string;
} }
export interface GuestErrors { export interface GuestErrors {
@@ -35,6 +37,8 @@ export interface GuestErrors {
lastName?: string; lastName?: string;
email?: string; email?: string;
phone?: string; phone?: string;
birthdate?: string;
postcode?: string;
} }
/** /**
@@ -51,6 +55,8 @@ export function parseGuests(raw: string | null | undefined): GuestEntry[] {
lastName: g.lastName ?? "", lastName: g.lastName ?? "",
email: g.email ?? "", email: g.email ?? "",
phone: g.phone ?? "", phone: g.phone ?? "",
birthdate: g.birthdate ?? "",
postcode: g.postcode ?? "",
})); }));
} catch { } catch {
return []; return [];
@@ -102,6 +108,8 @@ export function validateGuests(guests: GuestEntry[]): {
g.phone.trim() && !/^[\d\s\-+()]{10,}$/.test(g.phone.replace(/\s/g, "")) g.phone.trim() && !/^[\d\s\-+()]{10,}$/.test(g.phone.replace(/\s/g, ""))
? "Voer een geldig telefoonnummer in" ? "Voer een geldig telefoonnummer in"
: undefined, : 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)); const valid = !errors.some((e) => Object.values(e).some(Boolean));
return { errors, valid }; return { errors, valid };

View File

@@ -15,9 +15,9 @@ import type { orpc } from "@/utils/orpc";
import appCss from "../index.css?url"; import appCss from "../index.css?url";
const siteUrl = "https://kunstenkamp.be"; const siteUrl = "https://kunstenkamp.be";
const siteTitle = "Kunstenkamp Open Mic Night - Ongedesemd Brood"; const siteTitle = "Kunstenkamp Open Mic Night - Ongedesemd Woord";
const siteDescription = 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`; const eventImage = `${siteUrl}/assets/og-image.jpg`;
export interface RouterAppContext { export interface RouterAppContext {

View File

@@ -90,7 +90,7 @@ function AccountPage() {
orpc.drinkkaart.getMyDrinkkaart.queryOptions(), orpc.drinkkaart.getMyDrinkkaart.queryOptions(),
); );
const registrationQuery = useQuery(orpc.getMyRegistration.queryOptions()); const registrationQuery = useQuery(orpc.getMyRegistrations.queryOptions());
// Handle topup=success redirect (from Lemon Squeezy returning to /account) // Handle topup=success redirect (from Lemon Squeezy returning to /account)
useEffect(() => { useEffect(() => {
@@ -128,7 +128,7 @@ function AccountPage() {
| { name?: string; email?: string } | { name?: string; email?: string }
| undefined; | undefined;
const registration = registrationQuery.data; const registrations = registrationQuery.data ?? [];
const drinkkaart = drinkkaartQuery.data; const drinkkaart = drinkkaartQuery.data;
const isLoading = const isLoading =
@@ -170,8 +170,13 @@ function AccountPage() {
Mijn Inschrijving Mijn Inschrijving
</h2> </h2>
{registration ? ( {registrations.length > 0 ? (
<div className="rounded-2xl border border-white/10 bg-white/5 p-6"> <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 */} {/* Type badge */}
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -187,7 +192,10 @@ function AccountPage() {
</span> </span>
)} )}
</div> </div>
{(registration.registrationType !== "performer" ||
(registration.giftAmount ?? 0) > 0) && (
<PaymentBadge status={registration.paymentStatus} /> <PaymentBadge status={registration.paymentStatus} />
)}
</div> </div>
{/* Name */} {/* Name */}
@@ -277,6 +285,8 @@ function AccountPage() {
</div> </div>
)} )}
</div> </div>
))}
</div>
) : ( ) : (
<div className="rounded-2xl border border-white/10 bg-white/5 p-6"> <div className="rounded-2xl border border-white/10 bg-white/5 p-6">
<p className="mb-3 text-sm text-white/60"> <p className="mb-3 text-sm text-white/60">

View File

@@ -100,18 +100,18 @@ function AdminDrinkkaartPage() {
return ( return (
<div> <div>
{/* Header */} {/* Header */}
<header className="border-white/10 border-b bg-[#214e51]/95 px-6 py-5"> <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-4"> <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"> <Link to="/admin" className="text-sm text-white/60 hover:text-white">
Admin Admin
</Link> </Link>
<h1 className="font-['Intro',sans-serif] text-2xl text-white"> <h1 className="font-['Intro',sans-serif] text-white text-xl sm:text-2xl">
Drinkkaart Beheer Drinkkaart Beheer
</h1> </h1>
</div> </div>
</header> </header>
<main className="mx-auto max-w-2xl space-y-8 px-6 py-8"> <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 */} {/* Scan / State machine */}
<div className="rounded-2xl border border-white/10 bg-white/5 p-6"> <div className="rounded-2xl border border-white/10 bg-white/5 p-6">
{scanState.step === "idle" && ( {scanState.step === "idle" && (

View File

@@ -13,7 +13,7 @@ import {
Users, Users,
X, X,
} from "lucide-react"; } from "lucide-react";
import { useMemo, useState } from "react"; import { Fragment, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -44,6 +44,19 @@ function AdminPage() {
const pageSize = 20; const pageSize = 20;
const [copiedId, setCopiedId] = useState<string | null>(null); const [copiedId, setCopiedId] = useState<string | null>(null);
const [expandedGuests, setExpandedGuests] = useState<Set<string>>(new Set());
const toggleGuests = (id: string) => {
setExpandedGuests((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
// Get current session to check user role // Get current session to check user role
const sessionQuery = useQuery({ const sessionQuery = useQuery({
@@ -278,6 +291,25 @@ function AdminPage() {
return `${euros.toFixed(euros % 1 === 0 ? 0 : 2)}`; return `${euros.toFixed(euros % 1 === 0 ? 0 : 2)}`;
}; };
const parseGuestsJson = (
raw: string | null | undefined,
): Array<{
firstName: string;
lastName: string;
email?: string;
phone?: string;
birthdate?: string;
postcode?: string;
}> => {
if (!raw) return [];
try {
const parsed = JSON.parse(raw as string);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
// Check if user is admin // Check if user is admin
const user = sessionQuery.data?.data?.user as const user = sessionQuery.data?.data?.user as
| { role?: string; name?: string } | { role?: string; name?: string }
@@ -392,20 +424,23 @@ function AdminPage() {
return ( return (
<div> <div>
{/* Header */} {/* Header */}
<header className="border-white/10 border-b bg-[#214e51]/95 px-8 py-6"> <header className="border-white/10 border-b bg-[#214e51]/95 px-4 py-4 sm:px-8 sm:py-6">
<div className="mx-auto flex max-w-7xl items-center justify-between"> <div className="mx-auto flex max-w-7xl flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
<Link to="/" className="text-white hover:opacity-80"> <Link
to="/"
className="text-sm text-white hover:opacity-80 sm:text-base"
>
Terug naar website Terug naar website
</Link> </Link>
<h1 className="font-['Intro',sans-serif] text-3xl text-white"> <h1 className="font-['Intro',sans-serif] text-2xl text-white sm:text-3xl">
Admin Dashboard Admin Dashboard
</h1> </h1>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<Link <Link
to="/admin/drinkkaart" to="/admin/drinkkaart"
className="inline-flex items-center rounded-lg border border-white/30 px-4 py-2 text-sm text-white/80 transition-colors hover:bg-white/10 hover:text-white" className="inline-flex items-center justify-center rounded-lg border border-white/30 px-4 py-2 text-sm text-white/80 transition-colors hover:bg-white/10 hover:text-white"
> >
Drinkkaart beheer Drinkkaart beheer
</Link> </Link>
@@ -422,30 +457,30 @@ function AdminPage() {
</header> </header>
{/* Main Content */} {/* Main Content */}
<main className="mx-auto max-w-7xl p-8"> <main className="mx-auto max-w-7xl px-4 py-4 sm:px-6 sm:py-6 lg:px-8 lg:py-8">
{/* Pending Admin Requests */} {/* Pending Admin Requests */}
{pendingRequests.length > 0 && ( {pendingRequests.length > 0 && (
<Card className="mb-6 border-yellow-500/30 bg-yellow-500/10"> <Card className="mb-4 border-yellow-500/30 bg-yellow-500/10 sm:mb-6">
<CardHeader> <CardHeader className="px-4 py-4 sm:px-6">
<CardTitle className="font-['Intro',sans-serif] text-xl text-yellow-200"> <CardTitle className="font-['Intro',sans-serif] text-lg text-yellow-200 sm:text-xl">
Openstaande Admin Aanvragen ({pendingRequests.length}) Openstaande Admin Aanvragen ({pendingRequests.length})
</CardTitle> </CardTitle>
<CardDescription className="text-yellow-200/60"> <CardDescription className="text-sm text-yellow-200/60">
Gebruikers die admin toegang hebben aangevraagd Gebruikers die admin toegang hebben aangevraagd
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="px-4 pb-4 sm:px-6 sm:pb-6">
<div className="space-y-3"> <div className="space-y-3">
{pendingRequests.map((request) => ( {pendingRequests.map((request) => (
<div <div
key={request.id} key={request.id}
className="flex items-center justify-between rounded-lg border border-yellow-500/20 bg-yellow-500/5 p-4" className="flex flex-col gap-3 rounded-lg border border-yellow-500/20 bg-yellow-500/5 p-3 sm:flex-row sm:items-center sm:justify-between sm:p-4"
> >
<div> <div className="min-w-0">
<p className="font-medium text-white"> <p className="truncate font-medium text-white">
{request.userName} {request.userName}
</p> </p>
<p className="text-sm text-white/60"> <p className="truncate text-sm text-white/60">
{request.userEmail} {request.userEmail}
</p> </p>
<p className="text-white/40 text-xs"> <p className="text-white/40 text-xs">
@@ -455,7 +490,7 @@ function AdminPage() {
)} )}
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex flex-col gap-2 sm:flex-row">
<Button <Button
onClick={() => handleApprove(request.id)} onClick={() => handleApprove(request.id)}
disabled={approveRequestMutation.isPending} disabled={approveRequestMutation.isPending}
@@ -463,7 +498,8 @@ function AdminPage() {
className="bg-green-600 text-white hover:bg-green-700" className="bg-green-600 text-white hover:bg-green-700"
> >
<Check className="mr-1 h-4 w-4" /> <Check className="mr-1 h-4 w-4" />
Goedkeuren <span className="hidden sm:inline">Goedkeuren</span>
<span className="sm:hidden">Goedkeuren</span>
</Button> </Button>
<Button <Button
onClick={() => handleReject(request.id)} onClick={() => handleReject(request.id)}
@@ -482,15 +518,14 @@ function AdminPage() {
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Stats Cards */} {/* Stats Cards */}
<div className="mb-8 grid grid-cols-1 gap-6 md:grid-cols-4"> <div className="mb-8 grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 lg:grid-cols-5 lg:gap-6">
<Card className="border-white/10 bg-white/5"> <Card className="border-white/10 bg-white/5">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardDescription className="text-white/60"> <CardDescription className="text-white/60 text-xs sm:text-sm">
Totaal inschrijvingen Totaal inschrijvingen
</CardDescription> </CardDescription>
<CardTitle className="font-['Intro',sans-serif] text-4xl text-white"> <CardTitle className="font-['Intro',sans-serif] text-2xl text-white sm:text-3xl lg:text-4xl">
{stats?.total ?? 0} {stats?.total ?? 0}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@@ -501,15 +536,15 @@ function AdminPage() {
<Card className="border-white/10 bg-white/5"> <Card className="border-white/10 bg-white/5">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardDescription className="text-white/60"> <CardDescription className="text-white/60 text-xs sm:text-sm">
Vandaag ingeschreven Vandaag ingeschreven
</CardDescription> </CardDescription>
<CardTitle className="font-['Intro',sans-serif] text-4xl text-white"> <CardTitle className="font-['Intro',sans-serif] text-2xl text-white sm:text-3xl lg:text-4xl">
{stats?.today ?? 0} {stats?.today ?? 0}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<span className="text-sm text-white/40"> <span className="text-white/40 text-xs sm:text-sm">
Nieuwe registraties vandaag Nieuwe registraties vandaag
</span> </span>
</CardContent> </CardContent>
@@ -517,21 +552,21 @@ function AdminPage() {
<Card className="border-amber-400/20 bg-amber-400/5"> <Card className="border-amber-400/20 bg-amber-400/5">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardDescription className="text-amber-300/70"> <CardDescription className="text-amber-300/70 text-xs sm:text-sm">
Artiesten Artiesten
</CardDescription> </CardDescription>
<CardTitle className="font-['Intro',sans-serif] text-4xl text-amber-300"> <CardTitle className="font-['Intro',sans-serif] text-2xl text-amber-300 sm:text-3xl lg:text-4xl">
{performerCount} {performerCount}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-1"> <div className="space-y-1">
{stats?.byArtForm.slice(0, 4).map((item) => ( {stats?.byArtForm.slice(0, 2).map((item) => (
<div <div
key={item.artForm} key={item.artForm}
className="flex items-center justify-between text-xs" className="flex items-center justify-between text-xs"
> >
<span className="text-amber-300/70"> <span className="truncate text-amber-300/70">
{item.artForm || "Onbekend"} {item.artForm || "Onbekend"}
</span> </span>
<span className="text-amber-300">{item.count}</span> <span className="text-amber-300">{item.count}</span>
@@ -543,10 +578,10 @@ function AdminPage() {
<Card className="border-teal-400/20 bg-teal-400/5"> <Card className="border-teal-400/20 bg-teal-400/5">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardDescription className="text-teal-300/70"> <CardDescription className="text-teal-300/70 text-xs sm:text-sm">
Bezoekers Bezoekers
</CardDescription> </CardDescription>
<CardTitle className="font-['Intro',sans-serif] text-4xl text-teal-300"> <CardTitle className="font-['Intro',sans-serif] text-2xl text-teal-300 sm:text-3xl lg:text-4xl">
{watcherCount} {watcherCount}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@@ -564,7 +599,7 @@ function AdminPage() {
{totalWatcherAttendees} {totalWatcherAttendees}
</span> </span>
</div> </div>
<div className="flex items-center justify-between"> <div className="hidden sm:flex sm:items-center sm:justify-between">
<span className="text-teal-300/70">Drinkkaart</span> <span className="text-teal-300/70">Drinkkaart</span>
<span className="text-teal-300">{totalDrinkCardValue}</span> <span className="text-teal-300">{totalDrinkCardValue}</span>
</div> </div>
@@ -574,34 +609,33 @@ function AdminPage() {
<Card className="border-pink-400/20 bg-pink-400/5"> <Card className="border-pink-400/20 bg-pink-400/5">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardDescription className="text-pink-300/70"> <CardDescription className="text-pink-300/70 text-xs sm:text-sm">
Vrijwillige Gifts Vrijwillige Gifts
</CardDescription> </CardDescription>
<CardTitle className="font-['Intro',sans-serif] text-4xl text-pink-300"> <CardTitle className="font-['Intro',sans-serif] text-2xl text-pink-300 sm:text-3xl lg:text-4xl">
{Math.round(totalGiftRevenue / 100)} {Math.round(totalGiftRevenue / 100)}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<span className="text-sm text-white/40"> <span className="text-white/40 text-xs sm:text-sm">
Totale gift opbrengst Totale gift opbrengst
</span> </span>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Filters */} {/* Filters */}
<Card className="mb-6 border-white/10 bg-white/5"> <Card className="mb-6 border-white/10 bg-white/5">
<CardHeader> <CardHeader className="px-4 py-4 sm:px-6 sm:py-6">
<CardTitle className="font-['Intro',sans-serif] text-white text-xl"> <CardTitle className="font-['Intro',sans-serif] text-lg text-white sm:text-xl">
Filters Filters
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="px-4 pb-4 sm:px-6 sm:pb-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-5"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-5">
<div> <div>
<label <label
htmlFor="search" htmlFor="search"
className="mb-2 block text-sm text-white/60" className="mb-1.5 block text-white/60 text-xs sm:mb-2 sm:text-sm"
> >
Zoeken Zoeken
</label> </label>
@@ -612,7 +646,7 @@ function AdminPage() {
placeholder="Naam of email..." placeholder="Naam of email..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="border-white/20 bg-white/10 pl-10 text-white placeholder:text-white/40" className="border-white/20 bg-white/10 pl-10 text-sm text-white placeholder:text-white/40 sm:text-base"
/> />
</div> </div>
</div> </div>
@@ -620,7 +654,7 @@ function AdminPage() {
<div> <div>
<label <label
htmlFor="typeFilter" htmlFor="typeFilter"
className="mb-2 block text-sm text-white/60" className="mb-1.5 block text-white/60 text-xs sm:mb-2 sm:text-sm"
> >
Type Type
</label> </label>
@@ -632,7 +666,7 @@ function AdminPage() {
e.target.value as "performer" | "watcher" | "", 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" className="w-full rounded-md border border-white/20 bg-white/10 px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-white/20 sm:text-base"
> >
<option value="" className="bg-[#214e51]"> <option value="" className="bg-[#214e51]">
Alle types Alle types
@@ -649,7 +683,7 @@ function AdminPage() {
<div> <div>
<label <label
htmlFor="artForm" htmlFor="artForm"
className="mb-2 block text-sm text-white/60" className="mb-1.5 block text-white/60 text-xs sm:mb-2 sm:text-sm"
> >
Kunstvorm Kunstvorm
</label> </label>
@@ -658,14 +692,14 @@ function AdminPage() {
placeholder="Filter op kunstvorm..." placeholder="Filter op kunstvorm..."
value={artForm} value={artForm}
onChange={(e) => setArtForm(e.target.value)} onChange={(e) => setArtForm(e.target.value)}
className="border-white/20 bg-white/10 text-white placeholder:text-white/40" className="border-white/20 bg-white/10 text-sm text-white placeholder:text-white/40 sm:text-base"
/> />
</div> </div>
<div> <div>
<label <label
htmlFor="fromDate" htmlFor="fromDate"
className="mb-2 block text-sm text-white/60" className="mb-1.5 block text-white/60 text-xs sm:mb-2 sm:text-sm"
> >
Vanaf Vanaf
</label> </label>
@@ -674,14 +708,14 @@ function AdminPage() {
type="date" type="date"
value={fromDate} value={fromDate}
onChange={(e) => setFromDate(e.target.value)} onChange={(e) => setFromDate(e.target.value)}
className="border-white/20 bg-white/10 text-white [color-scheme:dark]" className="border-white/20 bg-white/10 text-sm text-white [color-scheme:dark] sm:text-base"
/> />
</div> </div>
<div> <div>
<label <label
htmlFor="toDate" htmlFor="toDate"
className="mb-2 block text-sm text-white/60" className="mb-1.5 block text-white/60 text-xs sm:mb-2 sm:text-sm"
> >
Tot Tot
</label> </label>
@@ -690,32 +724,31 @@ function AdminPage() {
type="date" type="date"
value={toDate} value={toDate}
onChange={(e) => setToDate(e.target.value)} onChange={(e) => setToDate(e.target.value)}
className="border-white/20 bg-white/10 text-white [color-scheme:dark]" className="border-white/20 bg-white/10 text-sm text-white [color-scheme:dark] sm:text-base"
/> />
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Export Button */} {/* Export Button */}
<div className="mb-6 flex items-center justify-between"> <div className="mb-4 flex flex-col gap-2 sm:mb-6 sm:flex-row sm:items-center sm:justify-between">
<p className="text-white/60"> <p className="text-sm text-white/60">
{pagination?.total ?? 0} registraties gevonden {pagination?.total ?? 0} registraties gevonden
</p> </p>
<Button <Button
onClick={handleExport} onClick={handleExport}
disabled={exportMutation.isPending} disabled={exportMutation.isPending}
className="bg-white text-[#214e51] hover:bg-white/90" className="w-full bg-white text-[#214e51] hover:bg-white/90 sm:w-auto"
> >
<Download className="mr-2 h-4 w-4" /> <Download className="mr-2 h-4 w-4" />
{exportMutation.isPending ? "Exporteren..." : "Exporteer CSV"} {exportMutation.isPending ? "Exporteren..." : "Exporteer CSV"}
</Button> </Button>
</div> </div>
{/* Registrations Table / Cards */}
{/* Registrations Table */}
<Card className="border-white/10 bg-white/5"> <Card className="border-white/10 bg-white/5">
<CardContent className="p-0"> <CardContent className="p-0">
<div className="overflow-x-auto"> {/* Desktop Table */}
<div className="hidden overflow-x-auto lg:block">
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="border-white/10 border-b"> <tr className="border-white/10 border-b">
@@ -755,6 +788,21 @@ function AdminPage() {
<th className={thClass} onClick={() => handleSort("datum")}> <th className={thClass} onClick={() => handleSort("datum")}>
Datum <SortIcon col="datum" /> Datum <SortIcon col="datum" />
</th> </th>
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
Postcode
</th>
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
Geboortedatum
</th>
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
Ervaring
</th>
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
16+
</th>
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
Opmerkingen
</th>
<th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider"> <th className="whitespace-nowrap px-4 py-3 text-left font-medium text-white/60 text-xs uppercase tracking-wider">
Link Link
</th> </th>
@@ -764,7 +812,7 @@ function AdminPage() {
{registrationsQuery.isLoading ? ( {registrationsQuery.isLoading ? (
<tr> <tr>
<td <td
colSpan={10} colSpan={15}
className="px-4 py-8 text-center text-white/60" className="px-4 py-8 text-center text-white/60"
> >
Laden... Laden...
@@ -773,7 +821,7 @@ function AdminPage() {
) : sortedRegistrations.length === 0 ? ( ) : sortedRegistrations.length === 0 ? (
<tr> <tr>
<td <td
colSpan={10} colSpan={15}
className="px-4 py-8 text-center text-white/60" className="px-4 py-8 text-center text-white/60"
> >
Geen registraties gevonden Geen registraties gevonden
@@ -783,15 +831,9 @@ function AdminPage() {
sortedRegistrations.map((reg) => { sortedRegistrations.map((reg) => {
const isPerformer = reg.registrationType === "performer"; const isPerformer = reg.registrationType === "performer";
const guestCount = (() => { const guests = parseGuestsJson(reg.guests);
if (!reg.guests) return 0; const guestCount = guests.length;
try { const isGuestsExpanded = expandedGuests.has(reg.id);
const g = JSON.parse(reg.guests as string);
return Array.isArray(g) ? g.length : 0;
} catch {
return 0;
}
})();
const detailLabel = isPerformer const detailLabel = isPerformer
? reg.artForm || "-" ? reg.artForm || "-"
@@ -813,6 +855,7 @@ function AdminPage() {
})(); })();
return ( return (
<Fragment key={reg.id}>
<tr <tr
key={reg.id} key={reg.id}
className="border-white/5 border-b hover:bg-white/5" className="border-white/5 border-b hover:bg-white/5"
@@ -837,9 +880,23 @@ function AdminPage() {
{detailLabel} {detailLabel}
</td> </td>
<td className="px-4 py-3 text-sm text-white/70"> <td className="px-4 py-3 text-sm text-white/70">
{guestCount > 0 {guestCount > 0 ? (
? `${guestCount} gast${guestCount === 1 ? "" : "en"}` <button
: "-"} type="button"
onClick={() => toggleGuests(reg.id)}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-sm text-white/70 transition-colors hover:bg-white/10 hover:text-white"
>
{guestCount} gast
{guestCount === 1 ? "" : "en"}
{isGuestsExpanded ? (
<ChevronUp className="h-3.5 w-3.5" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
</button>
) : (
"-"
)}
</td> </td>
<td className="px-4 py-3 text-pink-300/70 text-sm"> <td className="px-4 py-3 text-pink-300/70 text-sm">
{formatCents(reg.giftAmount)} {formatCents(reg.giftAmount)}
@@ -868,6 +925,34 @@ function AdminPage() {
<td className="px-4 py-3 text-sm text-white/50 tabular-nums"> <td className="px-4 py-3 text-sm text-white/50 tabular-nums">
{dateLabel} {dateLabel}
</td> </td>
<td className="px-4 py-3 text-sm text-white/70">
{reg.postcode || "-"}
</td>
<td className="px-4 py-3 text-sm text-white/70 tabular-nums">
{reg.birthdate || "-"}
</td>
<td className="px-4 py-3 text-sm text-white/70">
{isPerformer ? reg.experience || "-" : "-"}
</td>
<td className="px-4 py-3 text-sm text-white/70">
{isPerformer ? (
reg.isOver16 ? (
<span className="text-green-400">Ja</span>
) : (
<span className="text-red-400">Nee</span>
)
) : (
"-"
)}
</td>
<td className="max-w-[200px] px-4 py-3 text-sm text-white/60">
<span
className="block truncate"
title={reg.extraQuestions ?? undefined}
>
{reg.extraQuestions || "-"}
</span>
</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
{reg.managementToken ? ( {reg.managementToken ? (
<button <button
@@ -892,38 +977,310 @@ function AdminPage() {
)} )}
</td> </td>
</tr> </tr>
{isGuestsExpanded &&
guests.map((guest, gi) => (
<tr
key={`${reg.id}-guest-${gi}`}
className="border-white/5 border-b bg-white/[0.02]"
>
<td className="py-2 pr-4 pl-8 text-sm text-white/60 italic">
{guest.firstName} {guest.lastName}
</td>
<td className="px-4 py-2 text-sm text-white/50">
{guest.email || "-"}
</td>
<td className="px-4 py-2 text-sm text-white/50">
{guest.phone || "-"}
</td>
<td className="px-4 py-2">
<span className="inline-flex items-center rounded-full bg-white/5 px-2 py-0.5 font-semibold text-white/40 text-xs">
Gast
</span>
</td>
<td className="px-4 py-2 text-sm text-white/50">
-
</td>
<td className="px-4 py-2 text-sm text-white/30">
-
</td>
<td className="px-4 py-2 text-sm text-white/30">
-
</td>
<td className="px-4 py-2 text-sm text-white/30">
-
</td>
<td className="px-4 py-2 text-sm text-white/30">
-
</td>
<td className="px-4 py-2 text-sm text-white/50">
{guest.postcode || "-"}
</td>
<td className="px-4 py-2 text-sm text-white/50 tabular-nums">
{guest.birthdate || "-"}
</td>
<td className="px-4 py-2 text-sm text-white/30">
-
</td>
<td className="px-4 py-2 text-sm text-white/30">
-
</td>
<td className="px-4 py-2 text-sm text-white/30">
-
</td>
<td className="px-4 py-2 text-sm text-white/30">
-
</td>
</tr>
))}
</Fragment>
); );
}) })
)} )}
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="lg:hidden">
{registrationsQuery.isLoading ? (
<div className="px-4 py-8 text-center text-white/60">
Laden...
</div>
) : sortedRegistrations.length === 0 ? (
<div className="px-4 py-8 text-center text-white/60">
Geen registraties gevonden
</div>
) : (
<div className="divide-y divide-white/5">
{sortedRegistrations.map((reg) => {
const isPerformer = reg.registrationType === "performer";
const guests = parseGuestsJson(reg.guests);
const guestCount = guests.length;
const isGuestsExpanded = expandedGuests.has(reg.id);
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: "2-digit",
},
);
} catch {
return "-";
}
})();
return (
<div key={reg.id} className="hover:bg-white/5">
<div className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium text-white">
{reg.firstName} {reg.lastName}
</span>
</div>
<div className="mt-1 flex items-center gap-2 text-white/60 text-xs">
<span className="truncate">{reg.email}</span>
{reg.phone && (
<span className="shrink-0">
{reg.phone}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
<span
className={`shrink-0 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>
{reg.managementToken && (
<button
type="button"
title="Kopieer beheerlink"
onClick={() =>
handleCopyManageUrl(
reg.managementToken as string,
reg.id,
)
}
className="shrink-0 rounded p-1.5 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>
)}
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs">
<div className="text-white/70">
<span className="text-white/40">Details:</span>{" "}
{detailLabel}
</div>
{guestCount > 0 && (
<button
type="button"
onClick={() => toggleGuests(reg.id)}
className="inline-flex items-center gap-1 text-white/70 transition-colors hover:text-white"
>
<span className="text-white/40">Gasten:</span>{" "}
{guestCount}
{isGuestsExpanded ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
</button>
)}
{(reg.giftAmount ?? 0) > 0 && (
<div className="text-pink-300">
<span className="text-white/40">Gift:</span>{" "}
{formatCents(reg.giftAmount)}
</div>
)}
{!isPerformer && (
<div>
<span className="text-white/40">Betaling:</span>{" "}
{reg.paymentStatus === "paid" ? (
<span className="inline-flex items-center gap-1 font-medium text-green-400">
<Check className="h-3 w-3" />
Betaald
</span>
) : reg.paymentStatus ===
"extra_payment_pending" ? (
<span className="inline-flex items-center gap-1 font-medium text-orange-400">
<span className="h-1 w-1 rounded-full bg-orange-400" />
Extra (
{((reg.paymentAmount ?? 0) / 100).toFixed(
0,
)}
)
</span>
) : (
<span className="text-yellow-400">Open</span>
)}
</div>
)}
{reg.postcode && (
<div className="text-white/70">
<span className="text-white/40">Postcode:</span>{" "}
{reg.postcode}
</div>
)}
{reg.birthdate && (
<div className="text-white/70">
<span className="text-white/40">
Geboortedatum:
</span>{" "}
{reg.birthdate}
</div>
)}
{isPerformer && reg.experience && (
<div className="text-white/70">
<span className="text-white/40">Ervaring:</span>{" "}
{reg.experience}
</div>
)}
{isPerformer && (
<div className="text-white/70">
<span className="text-white/40">16+:</span>{" "}
{reg.isOver16 ? (
<span className="text-green-400">Ja</span>
) : (
<span className="text-red-400">Nee</span>
)}
</div>
)}
{reg.extraQuestions && (
<div className="w-full text-white/60">
<span className="text-white/40">
Opmerkingen:
</span>{" "}
{reg.extraQuestions}
</div>
)}
<div className="text-white/50">{dateLabel}</div>
</div>
</div>
{isGuestsExpanded && guestCount > 0 && (
<div className="border-white/5 border-t bg-white/[0.02] px-4 pt-2 pb-3">
<div className="mb-1 text-white/30 text-xs">
Gasten
</div>
<div className="flex flex-col gap-2">
{guests.map((guest, gi) => (
<div
key={`${reg.id}-guest-${gi}`}
className="rounded border border-white/10 bg-white/5 px-3 py-2 text-xs"
>
<div className="font-medium text-white/80">
{guest.firstName} {guest.lastName}
</div>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-white/50">
{guest.email && <span>{guest.email}</span>}
{guest.phone && <span>{guest.phone}</span>}
{guest.birthdate && (
<span>{guest.birthdate}</span>
)}
{guest.postcode && (
<span>{guest.postcode}</span>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</CardContent> </CardContent>
</Card> </Card>
{/* Pagination */}
{pagination && pagination.totalPages > 1 && ( {pagination && pagination.totalPages > 1 && (
<div className="mt-6 flex items-center justify-center gap-2"> <div className="mt-4 flex items-center justify-center gap-2 sm:mt-6">
<Button <Button
onClick={() => setPage((p) => Math.max(1, p - 1))} onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1} disabled={page === 1}
variant="outline" variant="outline"
className="border-white/20 bg-transparent text-white hover:bg-white/10 disabled:opacity-50" size="sm"
className="border-white/20 bg-transparent px-2 text-sm text-white hover:bg-white/10 disabled:opacity-50 sm:px-4"
> >
Vorige <span className="hidden sm:inline">Vorige</span>
<span className="sm:hidden"></span>
</Button> </Button>
<span className="mx-4 text-white"> <span className="mx-2 text-sm text-white sm:mx-4">
<span className="sm:hidden">
{page}/{pagination.totalPages}
</span>
<span className="hidden sm:inline">
Pagina {page} van {pagination.totalPages} Pagina {page} van {pagination.totalPages}
</span> </span>
</span>
<Button <Button
onClick={() => onClick={() =>
setPage((p) => Math.min(pagination.totalPages, p + 1)) setPage((p) => Math.min(pagination.totalPages, p + 1))
} }
disabled={page === pagination.totalPages} disabled={page === pagination.totalPages}
variant="outline" variant="outline"
className="border-white/20 bg-transparent text-white hover:bg-white/10 disabled:opacity-50" size="sm"
className="border-white/20 bg-transparent px-2 text-sm text-white hover:bg-white/10 disabled:opacity-50 sm:px-4"
> >
Volgende <span className="hidden sm:inline">Volgende</span>
<span className="sm:hidden"></span>
</Button> </Button>
</div> </div>
)} )}

View File

@@ -7,25 +7,23 @@ import { env } from "@kk/env/server";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
// Webhook payload types // LemonSqueezy webhook payload types (order_created event)
interface LemonSqueezyWebhookPayload { interface LemonSqueezyOrderCreatedPayload {
meta: { meta: {
event_name: string; event_name: string;
custom_data?: { custom_data?: {
registration_token?: string;
type?: string; type?: string;
registration_token?: string;
drinkkaartId?: string; drinkkaartId?: string;
userId?: string; userId?: string;
}; };
}; };
data: { data: {
id: string; id: string;
type: string;
attributes: { attributes: {
customer_id: number;
order_number: number;
status: string; status: string;
total: number; customer_id: number;
total: number; // amount in cents
}; };
}; };
} }
@@ -42,7 +40,11 @@ function verifyWebhookSignature(
} }
async function handleWebhook({ request }: { request: Request }) { 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 payload = await request.text();
const signature = request.headers.get("X-Signature"); const signature = request.headers.get("X-Signature");
@@ -50,12 +52,6 @@ async function handleWebhook({ request }: { request: Request }) {
return new Response("Missing signature", { status: 401 }); 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 ( if (
!verifyWebhookSignature( !verifyWebhookSignature(
payload, payload,
@@ -66,33 +62,36 @@ async function handleWebhook({ request }: { request: Request }) {
return new Response("Invalid signature", { status: 401 }); return new Response("Invalid signature", { status: 401 });
} }
let event: LemonSqueezyOrderCreatedPayload;
try { 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 // Only handle order_created events
if (event.meta.event_name !== "order_created") { if (event.meta.event_name !== "order_created") {
return new Response("Event ignored", { status: 200 }); 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; const customData = event.meta.custom_data;
// --------------------------------------------------------------------------- try {
// -------------------------------------------------------------------------
// Branch: Drinkkaart top-up // Branch: Drinkkaart top-up
// --------------------------------------------------------------------------- // -------------------------------------------------------------------------
if (customData?.type === "drinkkaart_topup") { if (customData?.type === "drinkkaart_topup") {
const { drinkkaartId, userId } = customData; const { drinkkaartId, userId } = customData;
if (!drinkkaartId || !userId) { if (!drinkkaartId || !userId) {
console.error( console.error(
"Missing drinkkaartId or userId in drinkkaart_topup webhook", "Missing drinkkaartId or userId in drinkkaart_topup custom_data",
); );
return new Response("Missing drinkkaart data", { status: 400 }); return new Response("Missing drinkkaart data", { status: 400 });
} }
const orderId = event.data.id;
const customerId = String(event.data.attributes.customer_id);
// Use Lemon Squeezy's confirmed total (in cents)
const amountCents = event.data.attributes.total;
// Idempotency: skip if already processed // Idempotency: skip if already processed
const existing = await db const existing = await db
.select({ id: drinkkaartTopup.id }) .select({ id: drinkkaartTopup.id })
@@ -153,7 +152,6 @@ async function handleWebhook({ request }: { request: Request }) {
balanceAfter, balanceAfter,
type: "payment", type: "payment",
lemonsqueezyOrderId: orderId, lemonsqueezyOrderId: orderId,
lemonsqueezyCustomerId: customerId,
adminId: null, adminId: null,
reason: null, reason: null,
paidAt: new Date(), paidAt: new Date(),
@@ -166,19 +164,16 @@ async function handleWebhook({ request }: { request: Request }) {
return new Response("OK", { status: 200 }); return new Response("OK", { status: 200 });
} }
// --------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Branch: Registration payment // Branch: Registration payment
// --------------------------------------------------------------------------- // -------------------------------------------------------------------------
const registrationToken = customData?.registration_token; const registrationToken = customData?.registration_token;
if (!registrationToken) { 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 }); return new Response("Missing registration token", { status: 400 });
} }
const orderId = event.data.id; // Fetch the registration row
const customerId = String(event.data.attributes.customer_id);
// Fetch the registration row first so we can use its email + drinkCardValue.
const regRow = await db const regRow = await db
.select() .select()
.from(registration) .from(registration)
@@ -191,13 +186,12 @@ async function handleWebhook({ request }: { request: Request }) {
return new Response("Registration not found", { status: 404 }); return new Response("Registration not found", { status: 404 });
} }
// Mark the registration as paid. Covers both "pending" (initial payment) and // Mark the registration as paid
// "extra_payment_pending" (delta after adding guests).
await db await db
.update(registration) .update(registration)
.set({ .set({
paymentStatus: "paid", paymentStatus: "paid",
paymentAmount: 0, // delta has been settled paymentAmount: 0,
lemonsqueezyOrderId: orderId, lemonsqueezyOrderId: orderId,
lemonsqueezyCustomerId: customerId, lemonsqueezyCustomerId: customerId,
paidAt: new Date(), paidAt: new Date(),
@@ -208,13 +202,12 @@ async function handleWebhook({ request }: { request: Request }) {
`Payment successful for registration ${registrationToken}, order ${orderId}`, `Payment successful for registration ${registrationToken}, order ${orderId}`,
); );
// If this is a watcher with a drink card value, try to credit their drinkkaart // If this is a watcher with a drink card value, try to credit their
// immediately — but only if they already have an account. // drinkkaart immediately — but only if they already have an account.
if ( if (
regRow.registrationType === "watcher" && regRow.registrationType === "watcher" &&
(regRow.drinkCardValue ?? 0) > 0 (regRow.drinkCardValue ?? 0) > 0
) { ) {
// Look up user account by email
const accountUser = await db const accountUser = await db
.select({ id: user.id }) .select({ id: user.id })
.from(user) .from(user)
@@ -244,8 +237,6 @@ async function handleWebhook({ request }: { request: Request }) {
); );
} }
} else { } else {
// No account yet — credit will be applied when the user signs up via
// claimRegistrationCredit.
console.log( console.log(
`No account for ${regRow.email} — drinkkaart credit deferred until signup`, `No account for ${regRow.email} — drinkkaart credit deferred until signup`,
); );

View File

@@ -38,7 +38,7 @@ function ContactPage() {
<section className="rounded-lg bg-white/5 p-6"> <section className="rounded-lg bg-white/5 p-6">
<h3 className="mb-3 text-white text-xl">Open Mic Night</h3> <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>Aanvang: 19:00 uur</p>
<p className="mt-2 text-white/60"> <p className="mt-2 text-white/60">
Locatie wordt later bekendgemaakt aan geregistreerde deelnemers. Locatie wordt later bekendgemaakt aan geregistreerde deelnemers.
@@ -53,6 +53,28 @@ function ContactPage() {
</p> </p>
</section> </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"> <section className="mt-8">
<p className="text-sm text-white/60"> <p className="text-sm text-white/60">
We proberen je e-mail binnen 48 uur te beantwoorden. We proberen je e-mail binnen 48 uur te beantwoorden.

View File

@@ -117,6 +117,8 @@ interface EditFormProps {
lastName: string; lastName: string;
email: string; email: string;
phone: string | null; phone: string | null;
postcode: string | null;
birthdate: string | null;
registrationType: string; registrationType: string;
artForm: string | null; artForm: string | null;
experience: string | null; experience: string | null;
@@ -140,6 +142,8 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
lastName: initialData.lastName, lastName: initialData.lastName,
email: initialData.email, email: initialData.email,
phone: initialData.phone ?? "", phone: initialData.phone ?? "",
postcode: initialData.postcode ?? "",
birthdate: initialData.birthdate ?? "",
registrationType: initialType, registrationType: initialType,
artForm: initialData.artForm ?? "", artForm: initialData.artForm ?? "",
experience: initialData.experience ?? "", experience: initialData.experience ?? "",
@@ -179,6 +183,8 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
lastName: formData.lastName.trim(), lastName: formData.lastName.trim(),
email: formData.email.trim(), email: formData.email.trim(),
phone: formData.phone.trim() || undefined, phone: formData.phone.trim() || undefined,
postcode: formData.postcode.trim() || "",
birthdate: formData.birthdate || "",
registrationType: formData.registrationType, registrationType: formData.registrationType,
artForm: performer ? formData.artForm.trim() || undefined : undefined, artForm: performer ? formData.artForm.trim() || undefined : undefined,
experience: performer experience: performer
@@ -192,6 +198,8 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
lastName: g.lastName.trim(), lastName: g.lastName.trim(),
email: g.email.trim() || undefined, email: g.email.trim() || undefined,
phone: g.phone.trim() || undefined, phone: g.phone.trim() || undefined,
birthdate: g.birthdate.trim(),
postcode: g.postcode.trim(),
})), })),
extraQuestions: formData.extraQuestions.trim() || undefined, extraQuestions: formData.extraQuestions.trim() || undefined,
giftAmount, giftAmount,
@@ -271,6 +279,42 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
/> />
</div> </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 */} {/* Registration type toggle */}
<div> <div>
<p className="mb-3 text-white">Type inschrijving</p> <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; if (formGuests.length >= 9) return;
setFormGuests((prev) => [ setFormGuests((prev) => [
...prev, ...prev,
{ firstName: "", lastName: "", email: "", phone: "" }, {
firstName: "",
lastName: "",
email: "",
phone: "",
birthdate: "",
postcode: "",
},
]); ]);
}} }}
onRemove={(idx) => onRemove={(idx) =>
@@ -427,7 +478,7 @@ function EditForm({ token, initialData, onCancel, onSaved }: EditFormProps) {
onChange={(e) => onChange={(e) =>
setFormData((p) => ({ ...p, extraQuestions: e.target.value })) setFormData((p) => ({ ...p, extraQuestions: e.target.value }))
} }
className="w-full border border-white/10 resize-none bg-transparent p-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> </div>
@@ -569,7 +620,7 @@ function ManageRegistrationPage() {
Jouw inschrijving Jouw inschrijving
</h1> </h1>
<p className="mb-8 text-white/60"> <p className="mb-8 text-white/60">
Open Mic Night vrijdag 18 april 2026 Open Mic Night vrijdag 24 april 2026
</p> </p>
{/* Type badge */} {/* Type badge */}
@@ -585,8 +636,9 @@ function ManageRegistrationPage() {
)} )}
</div> </div>
{/* Payment status - shown for everyone with pending/extra payment or gift */} {/* Payment status - not shown for performers without a gift */}
{(data.paymentStatus !== "paid" || (data.giftAmount ?? 0) > 0) && ( {(!isPerformer || (data.giftAmount ?? 0) > 0) &&
(data.paymentStatus !== "paid" || (data.giftAmount ?? 0) > 0) && (
<div className="mb-6"> <div className="mb-6">
{data.paymentStatus === "paid" ? ( {data.paymentStatus === "paid" ? (
<PaidBadge /> <PaidBadge />
@@ -626,6 +678,14 @@ function ManageRegistrationPage() {
<p className="text-sm text-white/50">Telefoon</p> <p className="text-sm text-white/50">Telefoon</p>
<p className="text-lg text-white">{data.phone || "—"}</p> <p className="text-lg text-white">{data.phone || "—"}</p>
</div> </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> </div>
{isPerformer && ( {isPerformer && (
@@ -660,6 +720,16 @@ function ManageRegistrationPage() {
<p className="text-white"> <p className="text-white">
{g.firstName} {g.lastName} {g.firstName} {g.lastName}
</p> </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 && ( {g.email && (
<p className="text-sm text-white/60">{g.email}</p> <p className="text-sm text-white/60">{g.email}</p>
)} )}

View File

@@ -87,7 +87,6 @@
"@kk/auth": "workspace:*", "@kk/auth": "workspace:*",
"@kk/db": "workspace:*", "@kk/db": "workspace:*",
"@kk/env": "workspace:*", "@kk/env": "workspace:*",
"@lemonsqueezy/lemonsqueezy.js": "^4.00.0",
"@orpc/client": "catalog:", "@orpc/client": "catalog:",
"@orpc/openapi": "catalog:", "@orpc/openapi": "catalog:",
"@orpc/server": "catalog:", "@orpc/server": "catalog:",
@@ -530,8 +529,6 @@
"@kk/infra": ["@kk/infra@workspace:packages/infra"], "@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/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=="], "@libsql/core": ["@libsql/core@0.15.15", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-C88Z6UKl+OyuKKPwz224riz02ih/zHYI3Ho/LAcVOgjsunIRZoBw7fjRfaH9oPMmSNeQfhGklSG2il1URoOIsA=="],

View File

@@ -14,7 +14,6 @@
"@kk/auth": "workspace:*", "@kk/auth": "workspace:*",
"@kk/db": "workspace:*", "@kk/db": "workspace:*",
"@kk/env": "workspace:*", "@kk/env": "workspace:*",
"@lemonsqueezy/lemonsqueezy.js": "^4.00.0",
"@orpc/client": "catalog:", "@orpc/client": "catalog:",
"@orpc/openapi": "catalog:", "@orpc/openapi": "catalog:",
"@orpc/server": "catalog:", "@orpc/server": "catalog:",

View File

@@ -86,7 +86,7 @@ function registrationConfirmationHtml(params: {
Hoi ${params.firstName}, Hoi ${params.firstName},
</p> </p>
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;"> <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> </p>
<!-- Registration summary --> <!-- Registration summary -->
<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(255,255,255,0.08);border-radius:4px;margin:24px 0;"> <table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(255,255,255,0.08);border-radius:4px;margin:24px 0;">

View File

@@ -137,10 +137,10 @@ export const drinkkaartRouter = {
if ( if (
!serverEnv.LEMON_SQUEEZY_API_KEY || !serverEnv.LEMON_SQUEEZY_API_KEY ||
!serverEnv.LEMON_SQUEEZY_STORE_ID || !serverEnv.LEMON_SQUEEZY_STORE_ID ||
!serverEnv.LEMON_SQUEEZY_DRINKKAART_VARIANT_ID !serverEnv.LEMON_SQUEEZY_VARIANT_ID
) { ) {
throw new ORPCError("INTERNAL_SERVER_ERROR", { throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Lemon Squeezy Drinkkaart variant is niet geconfigureerd", message: "LemonSqueezy is niet geconfigureerd",
}); });
} }
@@ -162,7 +162,7 @@ export const drinkkaartRouter = {
custom_price: input.amountCents, custom_price: input.amountCents,
product_options: { product_options: {
name: "Drinkkaart Opladen", name: "Drinkkaart Opladen",
description: `Drinkkaart top-up — ${formatCents(input.amountCents)}`, description: `Opwaardering van ${formatCents(input.amountCents)}`,
redirect_url: `${serverEnv.BETTER_AUTH_URL}/account?topup=success`, redirect_url: `${serverEnv.BETTER_AUTH_URL}/account?topup=success`,
}, },
checkout_data: { checkout_data: {
@@ -189,7 +189,7 @@ export const drinkkaartRouter = {
variant: { variant: {
data: { data: {
type: "variants", type: "variants",
id: serverEnv.LEMON_SQUEEZY_DRINKKAART_VARIANT_ID, id: serverEnv.LEMON_SQUEEZY_VARIANT_ID,
}, },
}, },
}, },

View File

@@ -36,6 +36,8 @@ function parseGuestsJson(raw: string | null): Array<{
lastName: string; lastName: string;
email?: string; email?: string;
phone?: string; phone?: string;
birthdate?: string;
postcode?: string;
}> { }> {
if (!raw) return []; if (!raw) return [];
try { try {
@@ -163,7 +165,7 @@ export async function creditRegistrationToAccount(
// Record the topup. Re-use the registration's lemonsqueezyOrderId so the // Record the topup. Re-use the registration's lemonsqueezyOrderId so the
// topup row is clearly linked to the original payment. We prefix with // topup row is clearly linked to the original payment. We prefix with
// "reg_" to distinguish from direct drinkkaart top-up orders if needed. // "reg_" to distinguish from direct drinkkaart top-up orders if needed.
const topupOrderId = reg.lemonsqueezyOrderId const topupPaymentId = reg.lemonsqueezyOrderId
? `reg_${reg.lemonsqueezyOrderId}` ? `reg_${reg.lemonsqueezyOrderId}`
: null; : null;
@@ -175,8 +177,7 @@ export async function creditRegistrationToAccount(
balanceBefore, balanceBefore,
balanceAfter, balanceAfter,
type: "payment", type: "payment",
lemonsqueezyOrderId: topupOrderId, lemonsqueezyOrderId: topupPaymentId,
lemonsqueezyCustomerId: reg.lemonsqueezyCustomerId ?? null,
adminId: null, adminId: null,
reason: "Drinkkaart bij registratie", reason: "Drinkkaart bij registratie",
paidAt: reg.paidAt ?? new Date(), paidAt: reg.paidAt ?? new Date(),
@@ -219,6 +220,8 @@ const guestSchema = z.object({
lastName: z.string().min(1), lastName: z.string().min(1),
email: z.string().email().optional().or(z.literal("")), email: z.string().email().optional().or(z.literal("")),
phone: z.string().optional(), phone: z.string().optional(),
birthdate: z.string().min(1),
postcode: z.string().min(1),
}); });
const coreRegistrationFields = { const coreRegistrationFields = {
@@ -226,6 +229,8 @@ const coreRegistrationFields = {
lastName: z.string().min(1), lastName: z.string().min(1),
email: z.string().email(), email: z.string().email(),
phone: z.string().optional(), phone: z.string().optional(),
postcode: z.string().min(1),
birthdate: z.string().min(1),
registrationType: registrationTypeSchema.default("watcher"), registrationType: registrationTypeSchema.default("watcher"),
artForm: z.string().optional(), artForm: z.string().optional(),
experience: z.string().optional(), experience: z.string().optional(),
@@ -278,6 +283,8 @@ export const appRouter = {
lastName: input.lastName, lastName: input.lastName,
email: input.email, email: input.email,
phone: input.phone || null, phone: input.phone || null,
postcode: input.postcode,
birthdate: input.birthdate,
registrationType: input.registrationType, registrationType: input.registrationType,
artForm: isPerformer ? input.artForm || null : null, artForm: isPerformer ? input.artForm || null : null,
experience: isPerformer ? input.experience || null : null, experience: isPerformer ? input.experience || null : null,
@@ -365,6 +372,8 @@ export const appRouter = {
lastName: input.lastName, lastName: input.lastName,
email: input.email, email: input.email,
phone: input.phone || null, phone: input.phone || null,
postcode: input.postcode,
birthdate: input.birthdate,
registrationType: input.registrationType, registrationType: input.registrationType,
artForm: isPerformer ? input.artForm || null : null, artForm: isPerformer ? input.artForm || null : null,
experience: isPerformer ? input.experience || null : null, experience: isPerformer ? input.experience || null : null,
@@ -516,6 +525,8 @@ export const appRouter = {
"Last Name", "Last Name",
"Email", "Email",
"Phone", "Phone",
"Postcode",
"Birthdate",
"Type", "Type",
"Art Form", "Art Form",
"Experience", "Experience",
@@ -535,7 +546,7 @@ export const appRouter = {
const guestSummary = guests const guestSummary = guests
.map( .map(
(g) => (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(" | "); .join(" | ");
return [ return [
@@ -544,6 +555,8 @@ export const appRouter = {
r.lastName, r.lastName,
r.email, r.email,
r.phone || "", r.phone || "",
r.postcode || "",
r.birthdate || "",
r.registrationType, r.registrationType,
r.artForm || "", r.artForm || "",
r.experience || "", r.experience || "",
@@ -584,7 +597,7 @@ export const appRouter = {
return result; return result;
}), }),
getMyRegistration: protectedProcedure.handler(async ({ context }) => { getMyRegistrations: protectedProcedure.handler(async ({ context }) => {
const email = context.session.user.email; const email = context.session.user.email;
const rows = await db const rows = await db
@@ -593,16 +606,12 @@ export const appRouter = {
.where( .where(
and(eq(registration.email, email), isNull(registration.cancelledAt)), and(eq(registration.email, email), isNull(registration.cancelledAt)),
) )
.orderBy(desc(registration.createdAt)) .orderBy(desc(registration.createdAt));
.limit(1);
const row = rows[0]; return rows.map((row) => ({
if (!row) return null;
return {
...row, ...row,
guests: parseGuestsJson(row.guests), guests: parseGuestsJson(row.guests),
}; }));
}), }),
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -797,6 +806,9 @@ export const appRouter = {
? `Vrijwillige gift${drinkCardTotal > 0 ? " + drinkkaart" : ""}` ? `Vrijwillige gift${drinkCardTotal > 0 ? " + drinkkaart" : ""}`
: `Toegangskaart${giftTotal > 0 ? " + gift" : ""}`; : `Toegangskaart${giftTotal > 0 ? " + gift" : ""}`;
const redirectUrl =
input.redirectUrl ?? `${env.CORS_ORIGIN}/manage/${input.token}`;
const response = await fetch( const response = await fetch(
"https://api.lemonsqueezy.com/v1/checkouts", "https://api.lemonsqueezy.com/v1/checkouts",
{ {
@@ -814,14 +826,14 @@ export const appRouter = {
product_options: { product_options: {
name: "Kunstenkamp Evenement", name: "Kunstenkamp Evenement",
description: productDescription, description: productDescription,
redirect_url: redirect_url: redirectUrl,
input.redirectUrl ??
`${env.CORS_ORIGIN}/manage/${input.token}`,
}, },
checkout_data: { checkout_data: {
email: row.email, email: row.email,
name: `${row.firstName} ${row.lastName}`, name: `${row.firstName} ${row.lastName}`,
custom: { registration_token: input.token }, custom: {
registration_token: input.token,
},
}, },
checkout_options: { checkout_options: {
embed: false, embed: false,
@@ -830,10 +842,16 @@ export const appRouter = {
}, },
relationships: { relationships: {
store: { store: {
data: { type: "stores", id: env.LEMON_SQUEEZY_STORE_ID }, data: {
type: "stores",
id: env.LEMON_SQUEEZY_STORE_ID,
},
}, },
variant: { variant: {
data: { type: "variants", id: env.LEMON_SQUEEZY_VARIANT_ID }, data: {
type: "variants",
id: env.LEMON_SQUEEZY_VARIANT_ID,
},
}, },
}, },
}, },

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

@@ -29,6 +29,13 @@
"when": 1772530000000, "when": 1772530000000,
"tag": "0003_add_guests", "tag": "0003_add_guests",
"breakpoints": true "breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1741388400000,
"tag": "0008_add_postcode_birthdate",
"breakpoints": true
} }
] ]
} }

View File

@@ -47,7 +47,6 @@ export const drinkkaartTopup = sqliteTable("drinkkaart_topup", {
balanceAfter: integer("balance_after").notNull(), balanceAfter: integer("balance_after").notNull(),
type: text("type", { enum: ["payment", "admin_credit"] }).notNull(), type: text("type", { enum: ["payment", "admin_credit"] }).notNull(),
lemonsqueezyOrderId: text("lemonsqueezy_order_id").unique(), // nullable; only for type="payment" lemonsqueezyOrderId: text("lemonsqueezy_order_id").unique(), // nullable; only for type="payment"
lemonsqueezyCustomerId: text("lemonsqueezy_customer_id"),
adminId: text("admin_id"), // nullable; only for type="admin_credit" adminId: text("admin_id"), // nullable; only for type="admin_credit"
reason: text("reason"), reason: text("reason"),
paidAt: integer("paid_at", { mode: "timestamp_ms" }).notNull(), paidAt: integer("paid_at", { mode: "timestamp_ms" }).notNull(),

View File

@@ -21,6 +21,9 @@ export const registration = sqliteTable(
drinkCardValue: integer("drink_card_value").default(0), drinkCardValue: integer("drink_card_value").default(0),
// Guests: JSON array of {firstName, lastName, email?, phone?} objects // Guests: JSON array of {firstName, lastName, email?, phone?} objects
guests: text("guests"), guests: text("guests"),
// Contact / demographic
postcode: text("postcode"),
birthdate: text("birthdate"),
// Shared // Shared
extraQuestions: text("extra_questions"), extraQuestions: text("extra_questions"),
managementToken: text("management_token").unique(), managementToken: text("management_token").unique(),

View File

@@ -28,7 +28,6 @@ export const env = createEnv({
LEMON_SQUEEZY_API_KEY: z.string().min(1).optional(), LEMON_SQUEEZY_API_KEY: z.string().min(1).optional(),
LEMON_SQUEEZY_STORE_ID: z.string().min(1).optional(), LEMON_SQUEEZY_STORE_ID: z.string().min(1).optional(),
LEMON_SQUEEZY_VARIANT_ID: z.string().min(1).optional(), LEMON_SQUEEZY_VARIANT_ID: z.string().min(1).optional(),
LEMON_SQUEEZY_DRINKKAART_VARIANT_ID: z.string().min(1).optional(),
LEMON_SQUEEZY_WEBHOOK_SECRET: z.string().min(1).optional(), LEMON_SQUEEZY_WEBHOOK_SECRET: z.string().min(1).optional(),
}, },
runtimeEnv: process.env, runtimeEnv: process.env,