feat: configuratie centraliseren + ThankYou betalingsbevestiging

- Alle workshopdetails (datum, tijd, locatie, prijs, email) gecentraliseerd in workshop.js
- ThankYou.jsx bijgewerkt: betalingsbevestiging tekst + WORKSHOP_CONFIG variabelen
- Signup.jsx open punt opgelost: samenvattingsregel gebruikt nu config-variabelen
- TallyForm gedeeld component toegevoegd (fase 3)
- deploy.sh post-deploy HTTP-check toegevoegd (fase 4)
- PRD.md en workshop materiaal (slides, tips, introtimer) toegevoegd
- vite.svg verwijderd (ongebruikt)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Frank Meeuwsen 2026-04-09 21:48:25 +02:00
parent 44f75914cc
commit 539e72aca1
26 changed files with 399 additions and 132 deletions

View file

@ -70,6 +70,25 @@ Deployment flow: `./preflight.sh` → `npm run build` → rsync naar server →
- **Live URL:** https://frankmeeuwsen.com/workshopclaudecode/
- **SSH key:** `~/.ssh/id_rsa_no_pass` (passphrase-loos, specifiek voor automated deploys)
## Actief project: Configuratie centraliseren
Zie `PRD.md` voor het volledige bouwplan. Korte samenvatting:
**Doel:** Alle workshopdetails (datum, tijd, locatie, prijs, e-mail) centraliseren in `src/config/workshop.js` zodat een nieuwe editie een one-stop-change is.
| Fase | Beschrijving | Status |
|------|-------------|--------|
| Fase 1 | Snelle fixes + config completeren (bug Footer, ongebruikte assets, Tally ID in config, ESLint blokkerend) | Afgerond |
| Fase 2 | Workshop details centraliseren in workshop.js - 7 componenten bijwerken | Afgerond |
| Fase 3 | Code deduplicatie - gedeeld TallyForm component | Afgerond |
| Fase 4 | Deployment en kwaliteit - post-deploy check, valuta-inconsistentie | Afgerond |
### Openstaande punten (volgende sessie)
- **Signup.jsx workshopsamenvatting:** Regel ~75 bevat hardcoded datum/tijd/locatie ("Vrijdag 3 april 2026 | 9:00 - 14:00 | Utrecht"). Kan naar WORKSHOP_CONFIG als die pagina hergebruikt wordt.
---
## Conventions
- Components use static data arrays + `.map()` for list rendering (benefits, FAQ items, timeline)

163
PRD.md Normal file
View file

@ -0,0 +1,163 @@
# PRD: Configuratie centraliseren - Claude Code Workshop Sales Page
## Context
De sales page heeft workshopdetails (datum, tijd, locatie, prijs, e-mail) hardcoded staan in 8+ componenten tegelijk. Als de workshop een nieuwe datum krijgt, moet je nu 20+ plekken aanpassen in 8 bestanden - en de kans op fouten is groot. De audit bevestigt dit: Footer.jsx zegt al "Max 7" terwijl alles "Max 8" is.
Doel: één plek om de workshop te updaten, alle componenten lezen daaruit.
---
## Wat het doet (gewone taal)
Je wijzigt de datum van de workshop. Nu open je `src/config/workshop.js`, typt de nieuwe datum, en alle tekst op de pagina klopt automatisch: de hero, de sticky balk, de footer, de FAQ, de pricing-sectie, de e-maillink. Geen "zoek en vervang" meer door 8 bestanden. Geen inconsistentie meer zoals "Max 7 vs Max 8".
---
## Hoe het eruitziet (de config-interface)
Na implementatie ziet `src/config/workshop.js` er zo uit:
```js
export const WORKSHOP_CONFIG = {
// Beschikbaarheid
totalSpots: 8,
availableSpots: 0,
isSoldOut: true,
// Workshopdetails (pas hier aan voor nieuwe editie)
date: 'vrijdag 3 april 2026',
dateShort: '3 april',
time: '9:00 - 14:00',
timeStart: '9:00',
timeEnd: '14:00',
location: 'Utrecht',
venue: 'Wonders of Work, Utrecht',
maxParticipants: 8,
// Prijs
price: '€399',
priceExclBtw: 'excl. BTW',
// Trainer
email: 'frank@frankmeeuwsen.com',
};
```
En `src/config/payment.js` bevat:
```js
export const PAYMENT_CONFIG = {
SIGNUP_URL: '/inschrijven',
WAITLIST_URL: '/wachtlijst-inschrijven',
SIGNUP_TALLY_ID: 'XxGBrV', // was hardcoded in Signup.jsx
WAITLIST_TALLY_ID: 'kdyPJZ',
};
```
Componenten importeren wat ze nodig hebben:
```js
import { WORKSHOP_CONFIG } from '../config/workshop';
const { date, price, email } = WORKSHOP_CONFIG;
```
---
## Bouwfasen
### Fase 1 - Snelle fixes en config completeren (minimum waarde)
**Wat:** Los de bekende bugs op, verwijder rommel, zet de Tally form ID in config.
**Wijzigingen:**
- `Footer.jsx:25` - "Max 7" corrigeren naar "Max 8" (bug fix)
- `public/og-image.old.png` verwijderen (700KB ongebruikt)
- `public/vite.svg` verwijderen (ongebruikt)
- `src/config/payment.js` - voeg `SIGNUP_TALLY_ID: 'XxGBrV'` toe
- `src/pages/Signup.jsx` - vervang hardcoded form ID door `PAYMENT_CONFIG.SIGNUP_TALLY_ID`
- `preflight.sh` - maak ESLint-fouten blokkerend (stop script bij lint-fouten)
**Resultaat:** Geen bugs, geen rommel, form ID beheerbaar vanuit config.
**Verificatie:** `./preflight.sh` slaagt, Footer toont "Max 8", Signup.jsx heeft geen hardcoded Tally ID meer.
---
### Fase 2 - Workshop details centraliseren (kern van het project)
**Wat:** Breid `workshop.js` uit met alle workshopdetails en laat alle componenten daaruit lezen.
**Wijzigingen in `src/config/workshop.js`:**
- Voeg toe: `date`, `dateShort`, `time`, `timeStart`, `timeEnd`, `location`, `venue`, `maxParticipants`, `price`, `priceExclBtw`, `email`
**Componenten bijwerken (hardcoded waarden vervangen door config-import):**
- `Hero.jsx` - datum, tijd, locatie (3 plekken)
- `FinalCTA.jsx` - datum, tijd, max deelnemers (3 plekken)
- `Footer.jsx` - datum, tijd, locatie/venue, email (5 plekken)
- `StickyBar.jsx` - datum, locatie, prijs (4 plekken)
- `Pricing.jsx` - prijs, max deelnemers (3 plekken)
- `Program.jsx` - tijden in de samenvatting-tekst (1 plek)
- `FAQ.jsx` - prijs, refund verwijzingen (2 plekken)
**Resultaat:** Eén config-bestand om een nieuwe editie te lanceren. Geen inconsistente waarden meer.
**Verificatie:** Wijzig `date` in workshop.js en verifieer dat Hero, Footer, StickyBar en FinalCTA allemaal de nieuwe datum tonen. `./preflight.sh` slaagt.
---
### Fase 3 - Code deduplicatie
**Wat:** Verwijder dubbele code in de inschrijfpagina's.
**Wijzigingen:**
- Maak `src/components/TallyForm.jsx` - gedeeld component met `formId` prop
- Vervangt identieke Tally embed-logica in `Signup.jsx` en `WaitlistSignup.jsx`
**Resultaat:** Tally embed-logica op één plek beheerbaar.
**Verificatie:** Inschrijfflow en wachtlijstflow werken beide nog correct. `./preflight.sh` slaagt.
---
### Fase 4 - Deployment en kwaliteitsverbetering
**Wat:** Maak het deployen veiliger en los kleine inconsistenties op.
**Wijzigingen:**
- `deploy.sh` - voeg post-deploy HTTP-check toe (curl naar live URL, verwacht HTTP 200)
- Valutainconsistentie: `FAQ.jsx:34` en `Installatie.jsx:60` uniformeren naar "€"
**Resultaat:** Deploy geeft automatisch feedback of de live URL bereikbaar is. Alle valuta consistent.
**Verificatie:** Deploy uitvoeren en curl-check zien slagen in de output.
---
## Kritieke bestanden
| Bestand | Rol |
|---------|-----|
| `src/config/workshop.js` | Centrale config - wordt uitgebreid |
| `src/config/payment.js` | Betaalconfig - SIGNUP_TALLY_ID toevoegen |
| `src/components/Hero.jsx` | Hardcoded datum/tijd/locatie |
| `src/components/Footer.jsx` | Hardcoded datum/tijd/venue/email + bug "Max 7" |
| `src/components/StickyBar.jsx` | Hardcoded datum/locatie/prijs |
| `src/components/FinalCTA.jsx` | Hardcoded datum/tijd/max deelnemers |
| `src/components/Pricing.jsx` | Hardcoded prijs/max deelnemers |
| `src/components/FAQ.jsx` | Hardcoded prijs + valutainconsistentie |
| `src/pages/Signup.jsx` | Hardcoded Tally form ID |
| `preflight.sh` | ESLint niet blokkerend |
| `deploy.sh` | Geen post-deploy check |
| `public/og-image.old.png` | Ongebruikt, 700KB |
| `public/vite.svg` | Ongebruikt |
---
## Status bouwfasen
| Fase | Beschrijving | Status |
|------|-------------|--------|
| Fase 1 | Snelle fixes + config completeren | Nog niet gestart |
| Fase 2 | Workshop details centraliseren | Nog niet gestart |
| Fase 3 | Code deduplicatie | Nog niet gestart |
| Fase 4 | Deployment en kwaliteit | Nog niet gestart |

Binary file not shown.

View file

@ -3,6 +3,11 @@ title: Workshop Materiaal
subtitle: Alles wat je nodig hebt tijdens de Claude Code Workshop
---
## Download het materiaal van de workshop
- [Slides workshop (PDF)](20260403-slides-Claude-Code-workshop-3-april.pdf)
- [Tips en learning (PDF)](tips-learnings-Claude-Code-Workshop-3-april.pdf)
- [Broncode introtimer](introtimer.zip)
## Download Superpowers
Superpowers is een plugin voor Claude Code die extra mogelijkheden toevoegt. Denk aan gestructureerd plannen, test-driven development en slimmer debuggen. Tijdens de workshop gebruiken we deze plugin.
@ -40,6 +45,47 @@ Beperkingen:
- Python alleen als Node.js echt niet volstaat
:::
## Mijn personal preferences
Zet dit in Instellingen/Settings > Account > Personal Preferences
:::command[Start prompt]
### Professionele Identiteit
- Zelfstandig ondernemer op gebied van AI, automation, workflows, digitale vaardigheden en slimmer werken, voor MKB in Nederland
- Pionier blogger (sinds 2000) en auteur van "Bloghelden" (release 2010)
- Liefhebber van PKM (Personal Knowledge Management) in Obsidian
- Gebruikt veel Claude Code en Claude Cowork
- Hobbyist programmeur die projecten bouwt voor plezier, niet perfectionisme
### Sterke Punten
- Verhalend denken en contentcreatie (tekst, audio, beeld)
- Bruggen bouwen tussen technologie en toegankelijke communicatie
- Lange termijnvisie op digitale ontwikkelingen en ethiek
- Experimenteren met nieuwe platforms en tools
- Balans tussen professionaliteit en persoonlijke interesses
### Schrijfwijze
- Gebruik mijn voornaam (Frank) om me aan te spreken
- Vermijd Amerikaanse stijlkenmerken, zoals overdreven enthousiasme en hoofdletters in titels.
- Als ik je vraag om mij te interviewen of vragen te stellen, dan MOET je die vragen één voor één stellen in plaats van in 1x bij elkaar.
- Hou je antwoorden duidelijk en to the point
- Houd de toon nuchter en in lijn met Nederlandse schrijfstijl.
- Gebruik bullet points als het nodig is voor verduidelijking
- Geen emoji's.
- Gebruik geen — (Em-dash) in je antwoorden
- Het is niet nodig om je antwoord af te sluiten met een vraag, tenzij de prompt of de instructies daar expliciet om vragen
- Refereer niet te expliciet naar mijn sterke punten of professionele identiteit. Zie dat als een gegeven.
- Informeel en bondig, op B1-taalniveau en inclusief
- Praat niet te veel met mijn ideeën mee, maar ben eerlijk en kritisch op mijn ideeën.
- Als ik redeneerfouten maak, te kort door de bocht iets stel, noem dat dan expliciet 'lui denkwerk.'
### Voorkeuren
- Ik gebruik vi als standaard editor, geen nano
- Ik gebruik markdown als standaard schrijftaal
- Mijn projecten staan altijd in /Users/frank/Projecten.
- Losse scripts staan in /Users/frank/Documents/Hobbies/scripts
:::
## Tips tijdens het bouwen
### Sessiemanagement

View file

@ -26,4 +26,13 @@ ssh "$SERVER" "docker cp $TMP_PATH/. $CONTAINER:$REMOTE_PATH/ && docker exec $CO
echo "4/4 - Opruimen..."
ssh "$SERVER" "rm -rf $TMP_PATH"
echo "Done! https://frankmeeuwsen.com/workshopclaudecode/"
echo "5/5 - Post-deploy check..."
LIVE_URL="https://frankmeeuwsen.com/workshopclaudecode/"
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$LIVE_URL")
if [ "$HTTP_STATUS" = "200" ]; then
echo "OK - Live URL bereikbaar (HTTP $HTTP_STATUS)"
else
echo "WAARSCHUWING - Live URL geeft HTTP $HTTP_STATUS: $LIVE_URL"
fi
echo "Done! $LIVE_URL"

View file

@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
globalIgnores(['dist', '.claude/']),
{
files: ['**/*.{js,jsx}'],
extends: [

View file

@ -19,7 +19,8 @@ echo "[1/4] ESLint..."
if npm run lint --silent 2>&1; then
echo " OK"
else
echo " WAARSCHUWING: lint errors gevonden (niet-blokkerend)"
echo " FOUT: lint errors gevonden"
ERRORS=$((ERRORS + 1))
fi
echo ""

BIN
public/introtimer.zip Normal file

Binary file not shown.

Binary file not shown.

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -5,21 +5,14 @@
* Slaat voorkeur op in localStorage zodat de banner maar 1x verschijnt.
*/
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { Link } from 'react-router-dom';
const STORAGE_KEY = 'cookie-banner-dismissed';
function CookieBanner() {
const [visible, setVisible] = useState(false);
// Check bij laden of de banner al eerder is weggeklikt
useEffect(() => {
const dismissed = localStorage.getItem(STORAGE_KEY);
if (!dismissed) {
setVisible(true);
}
}, []);
// Lazy initializer: leest localStorage eénmalig bij mount (geen effect nodig)
const [visible, setVisible] = useState(() => !localStorage.getItem(STORAGE_KEY));
const dismiss = () => {
localStorage.setItem(STORAGE_KEY, 'true');

View file

@ -6,6 +6,9 @@
*/
import { useState } from 'react';
import { WORKSHOP_CONFIG } from '../config/workshop';
const { price } = WORKSHOP_CONFIG;
function FAQ() {
// State om bij te houden welke items open zijn
@ -31,7 +34,7 @@ function FAQ() {
},
{
question: "Wat is een Claude Pro of Max account en waarom heb ik dat nodig?",
answer: "Dit is een betaald abonnement bij Anthropic (het bedrijf achter Claude). Pro kost ongeveer 20 euro per maand, Max ongeveer 100 euro. Je hebt dit nodig om Claude Code te kunnen gebruiken. Aanmelden kan via claude.ai."
answer: "Dit is een betaald abonnement bij Anthropic (het bedrijf achter Claude). Pro kost ongeveer 20 per maand, Max ongeveer 100. Je hebt dit nodig om Claude Code te kunnen gebruiken. Aanmelden kan via claude.ai."
},
{
question: "Moet ik iets voorbereiden?",
@ -50,7 +53,7 @@ function FAQ() {
answer: "De workshop begint bij de basis, maar gaat vrij snel naar de interessantere features zoals agents en skills. Je werkt aan je eigen tempo en project, dus ook met voorkennis haal je er genoeg uit."
},
{
question: "Is 399 euro niet veel voor een halve dag?",
question: `Is ${price} niet veel voor een halve dag?`,
answer: "Je betaalt niet voor een halve dag, maar voor het overslaan van weken zelf uitzoeken. De meeste deelnemers besparen die investering binnen een maand door tools die ze zelf bouwen in plaats van inhuren. Plus: je hebt daarna toegang tot de community voor vragen."
},
{

View file

@ -10,7 +10,7 @@ import { PAYMENT_CONFIG } from '../config/payment';
import { WORKSHOP_CONFIG } from '../config/workshop';
function FinalCTA() {
const { availableSpots, isSoldOut } = WORKSHOP_CONFIG;
const { availableSpots, isSoldOut, date, dateShort, timeStart, timeEnd, location, maxParticipants, email } = WORKSHOP_CONFIG;
return (
<section className="py-16 lg:py-24 bg-coral-500 relative overflow-hidden">
@ -28,14 +28,14 @@ function FinalCTA() {
<div className="max-w-3xl mx-auto text-center">
{/* Headline */}
<h2 className="font-display text-3xl md:text-4xl font-bold text-white mb-6">
Op 3 april werk je anders
Op {dateShort} werk je anders
</h2>
{/* Closing text */}
<p className="text-xl text-coral-100 mb-8 leading-relaxed">
De volgende workshop is op{' '}
<span className="text-white font-semibold">vrijdag 3 april 2026</span>
{' '}in Utrecht. We starten om 9:00, rond 14:00 ga je naar huis met
<span className="text-white font-semibold">{date}</span>
{' '}in {location}. We starten om {timeStart}, rond {timeEnd} ga je naar huis met
je eigen werkende project.
</p>
@ -45,7 +45,7 @@ function FinalCTA() {
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>Maximaal 8 deelnemers per workshop</span>
<span>Maximaal {maxParticipants} deelnemers per workshop</span>
</div>
<div className="flex items-center gap-2 font-semibold text-white">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -72,7 +72,7 @@ function FinalCTA() {
to={isSoldOut ? PAYMENT_CONFIG.WAITLIST_URL : PAYMENT_CONFIG.SIGNUP_URL}
className="inline-flex items-center gap-2 px-8 py-4 bg-white text-coral-600 font-semibold text-lg rounded-xl shadow-lg hover:bg-coral-50 hover:shadow-xl active:bg-coral-100 transition-all duration-200"
>
{isSoldOut ? 'Zet me op de wachtlijst' : 'Doe mee op 3 april'}
{isSoldOut ? 'Zet me op de wachtlijst' : `Doe mee op ${dateShort}`}
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
@ -81,7 +81,7 @@ function FinalCTA() {
{/* Contact info */}
<p className="mt-8 text-coral-200">
Vragen? Mail naar{' '}
<a href="mailto:frank@frankmeeuwsen.com" className="text-white underline hover:no-underline">
<a href={`mailto:${email}`} className="text-white underline hover:no-underline">
Frank
</a>
. Ik help je graag.

View file

@ -6,6 +6,9 @@
*/
import { Link } from 'react-router-dom';
import { WORKSHOP_CONFIG } from '../config/workshop';
const { maxParticipants, dateFull, time, venue, email } = WORKSHOP_CONFIG;
function Footer() {
// Huidig jaar voor copyright
@ -22,7 +25,7 @@ function Footer() {
</h3>
<p className="text-sm leading-relaxed">
Van nieuwsgierig naar praktisch aan de slag met Claude Code.
Max 7 deelnemers, persoonlijke begeleiding.
Max {maxParticipants} deelnemers, persoonlijke begeleiding.
</p>
</div>
@ -36,20 +39,20 @@ function Footer() {
<svg className="w-4 h-4 text-coral-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Vrijdag 3 april 2026
{dateFull}
</li>
<li className="flex items-center gap-2">
<svg className="w-4 h-4 text-coral-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
9:00 - 14:00
{time}
</li>
<li className="flex items-center gap-2">
<svg className="w-4 h-4 text-coral-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Wonders of Work, Utrecht
{venue}
</li>
</ul>
</div>
@ -61,21 +64,21 @@ function Footer() {
</h3>
<p className="text-sm leading-relaxed mb-4">
Vragen? Mail naar{' '}
<a href="mailto:frank@frankmeeuwsen.com" className="text-coral-400 hover:text-coral-300 transition-colors">
frank@frankmeeuwsen.com
<a href={`mailto:${email}`} className="text-coral-400 hover:text-coral-300 transition-colors">
{email}
</a>
{' '}en ik help je graag!
</p>
<ul className="space-y-2 text-sm">
<li>
<a
href="mailto:frank@frankmeeuwsen.com"
href={`mailto:${email}`}
className="hover:text-coral-400 transition-colors flex items-center gap-2"
>
<svg className="w-4 h-4 text-coral-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
frank@frankmeeuwsen.com
{email}
</a>
</li>
<li>

View file

@ -11,7 +11,7 @@ import { PAYMENT_CONFIG } from '../config/payment';
function Hero() {
// Aantal beschikbare plaatsen - beheer via src/config/workshop.js
const { totalSpots, availableSpots, isSoldOut } = WORKSHOP_CONFIG;
const { totalSpots, availableSpots, isSoldOut, dateLabel, time, location } = WORKSHOP_CONFIG;
return (
<section className="section relative overflow-hidden">
@ -85,7 +85,7 @@ function Hero() {
</div>
<div>
<p className="text-xs text-warm-500 font-medium uppercase tracking-wide">Datum</p>
<p className="text-base font-semibold text-warm-800 whitespace-nowrap">Vrijdag 3 april</p>
<p className="text-base font-semibold text-warm-800">{dateLabel}</p>
</div>
</div>
<div className="flex items-center gap-3">
@ -97,7 +97,7 @@ function Hero() {
</div>
<div>
<p className="text-xs text-warm-500 font-medium uppercase tracking-wide">Tijd</p>
<p className="text-base font-semibold text-warm-800 whitespace-nowrap">9:00 - 14:00</p>
<p className="text-base font-semibold text-warm-800 whitespace-nowrap">{time}</p>
</div>
</div>
<div className="flex items-center gap-3">
@ -110,7 +110,7 @@ function Hero() {
</div>
<div>
<p className="text-xs text-warm-500 font-medium uppercase tracking-wide">Locatie</p>
<p className="text-base font-semibold text-warm-800 whitespace-nowrap">Utrecht</p>
<p className="text-base font-semibold text-warm-800 whitespace-nowrap">{location}</p>
</div>
</div>
</div>

View file

@ -10,10 +10,10 @@ import { PAYMENT_CONFIG } from '../config/payment';
import { WORKSHOP_CONFIG } from '../config/workshop';
function Pricing() {
const { isSoldOut } = WORKSHOP_CONFIG;
const { isSoldOut, price, priceExclBtw, maxParticipants, dateShort, time } = WORKSHOP_CONFIG;
// Wat is inbegrepen
const included = [
"Volledige workshop (9:00 - 14:00)",
`Volledige workshop (${time})`,
"Lunch en onbeperkt koffie/thee",
"Online werkboek met alle commando's en tips",
"Toegang tot de besloten community voor vragen achteraf",
@ -51,14 +51,14 @@ function Pricing() {
<div className="text-center mb-8">
<div className="flex items-baseline justify-center gap-2">
<span className="text-5xl md:text-6xl font-display font-bold text-warm-900">
399
{price}
</span>
<span className="text-warm-500 text-lg">
per persoon
</span>
</div>
<p className="text-warm-500 mt-1">
excl. BTW
{priceExclBtw}
</p>
</div>
@ -109,7 +109,7 @@ function Pricing() {
</svg>
<div>
<p className="font-semibold text-coral-700">
Maximaal 8 deelnemers per workshop
Maximaal {maxParticipants} deelnemers per workshop
</p>
<p className="text-coral-600 text-sm mt-1">
Dit is bewust klein gehouden zodat iedereen persoonlijke aandacht krijgt.
@ -123,7 +123,7 @@ function Pricing() {
to={isSoldOut ? PAYMENT_CONFIG.WAITLIST_URL : PAYMENT_CONFIG.SIGNUP_URL}
className="btn-primary w-full text-center block"
>
{isSoldOut ? 'Zet me op de wachtlijst' : 'Doe mee op 3 april'}
{isSoldOut ? 'Zet me op de wachtlijst' : `Doe mee op ${dateShort}`}
</Link>
{/* Annuleringsbeleid als vertrouwenssignaal */}

View file

@ -5,6 +5,10 @@
* Lunch moment wordt visueel uitgelicht.
*/
import { WORKSHOP_CONFIG } from '../config/workshop';
const { timeStart, timeEnd } = WORKSHOP_CONFIG;
function Program() {
// Programma onderdelen
const schedule = [
@ -45,13 +49,13 @@ function Program() {
isBreak: false
},
{
time: "13:45",
time: "14:30",
title: "Afronden en vervolgstappen",
description: "We kijken naar wat je hebt gebouwd. Je krijgt concrete vervolgstappen die passen bij waar jij nu staat.",
isBreak: false
},
{
time: "14:00",
time: "15:00",
title: "Einde",
description: null,
isBreak: false
@ -66,7 +70,7 @@ function Program() {
Zo ziet je dag eruit
</h2>
<p className="text-warm-600 text-center mb-12 max-w-2xl mx-auto">
Van 9:00 tot 14:00 werk je stap voor stap naar je eigen werkende project.
Van {timeStart} tot {timeEnd} werk je stap voor stap naar je eigen werkende project.
</p>
{/* Timeline */}

View file

@ -12,7 +12,7 @@ import { PAYMENT_CONFIG } from '../config/payment';
import { WORKSHOP_CONFIG } from '../config/workshop';
function StickyBar() {
const { availableSpots, isSoldOut } = WORKSHOP_CONFIG;
const { availableSpots, isSoldOut, dateStickyBar, location, price, priceExclBtw } = WORKSHOP_CONFIG;
// State om te bepalen of de bar zichtbaar moet zijn
const [isVisible, setIsVisible] = useState(false);
@ -51,14 +51,14 @@ function StickyBar() {
<svg className="w-4 h-4 text-coral-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
3 april 2026
{dateStickyBar}
</span>
<span className="flex items-center gap-1">
<svg className="w-4 h-4 text-coral-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Utrecht
{location}
</span>
<span className="flex items-center gap-1 text-coral-600 font-medium">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -72,7 +72,7 @@ function StickyBar() {
{/* Right: Price + CTA */}
<div className="flex items-center gap-4">
<span className="font-display font-bold text-xl text-warm-900">
399 <span className="text-sm font-normal text-warm-500">excl. BTW</span>
{price} <span className="text-sm font-normal text-warm-500">{priceExclBtw}</span>
</span>
<Link
to={isSoldOut ? PAYMENT_CONFIG.WAITLIST_URL : PAYMENT_CONFIG.SIGNUP_URL}
@ -92,10 +92,10 @@ function StickyBar() {
{/* Left: Prijs en datum */}
<div>
<div className="font-display font-bold text-lg text-warm-900">
399 <span className="text-xs font-normal text-warm-500">excl. BTW</span>
{price} <span className="text-xs font-normal text-warm-500">{priceExclBtw}</span>
</div>
<div className="text-xs text-warm-500">
3 april 2026 | Utrecht
{dateStickyBar} | {location}
</div>
<div className="text-xs text-coral-600 font-medium">
{isSoldOut ? 'Volgeboekt' : `Nog ${availableSpots} ${availableSpots === 1 ? 'plek' : 'plekken'} beschikbaar`}

View file

@ -0,0 +1,44 @@
/**
* TallyForm.jsx - Gedeeld component voor embedded Tally formulieren
*
* Gebruik: <TallyForm formId="XxGBrV" title="Inschrijving Claude Code Workshop" />
*/
import { useEffect } from 'react';
function TallyForm({ formId, title }) {
useEffect(() => {
const scriptUrl = 'https://tally.so/widgets/embed.js';
if (typeof window.Tally !== 'undefined') {
window.Tally.loadEmbeds();
return;
}
if (!document.querySelector(`script[src="${scriptUrl}"]`)) {
const script = document.createElement('script');
script.src = scriptUrl;
script.onload = () => {
if (typeof window.Tally !== 'undefined') {
window.Tally.loadEmbeds();
}
};
document.body.appendChild(script);
}
}, []);
return (
<div className="card">
<iframe
data-tally-src={`https://tally.so/embed/${formId}?alignLeft=1&hideTitle=1&transparentBackground=1&dynamicHeight=1`}
loading="lazy"
width="100%"
height="300"
frameBorder="0"
title={title}
/>
</div>
);
}
export default TallyForm;

View file

@ -19,3 +19,4 @@ export { default as FinalCTA } from './FinalCTA';
export { default as Footer } from './Footer';
export { default as StickyBar } from './StickyBar';
export { default as CookieBanner } from './CookieBanner';
export { default as TallyForm } from './TallyForm';

View file

@ -12,5 +12,6 @@
export const PAYMENT_CONFIG = {
SIGNUP_URL: '/inschrijven',
WAITLIST_URL: '/wachtlijst-inschrijven',
SIGNUP_TALLY_ID: 'kd7y81',
WAITLIST_TALLY_ID: 'kdyPJZ',
};

View file

@ -1,13 +1,44 @@
/**
* workshop.js - Workshop details configuratie
*
* Centrale plek voor beschikbaarheid en andere workshopdetails.
* Pas availableSpots hier aan als er een plek verkocht is.
* Zet isSoldOut op true als alle plekken weg zijn - activeert wachtlijstmodus op de hele site.
* CENTRALE PLEK voor alle workshopdetails.
* Bij een nieuwe editie: pas alleen dit bestand aan.
* Alle datum-velden tegelijk bijwerken (ze beschrijven dezelfde dag).
*
* Beschikbaarheid:
* Pas availableSpots aan als er een plek verkocht is.
* Zet isSoldOut op true als alle plekken weg zijn - activeert wachtlijstmodus.
*/
export const WORKSHOP_CONFIG = {
// Beschikbaarheid
totalSpots: 8,
availableSpots: 0,
isSoldOut: true,
availableSpots: 8,
isSoldOut: false,
// Datum - alle varianten beschrijven dezelfde dag, altijd samen bijwerken
date: 'woensdag 13 mei 2026', // lowercase, voor midden in een zin
dateShort: '13 mei', // voor buttons en korte verwijzingen
dateLabel: 'Woensdag 13 mei', // voor het Hero-datumblokje (geen jaar)
dateFull: 'Woensdag 13 mei 2026', // voor Footer-lijstitem (met jaar)
dateStickyBar: '13 mei 2026', // voor de sticky balk (geen weekdag)
// Tijd
time: '9:00 - 15:00',
timeStart: '9:00',
timeEnd: '15:00',
// Locatie
location: 'Utrecht',
venue: 'Wonders of Work, Utrecht',
// Deelnemers
maxParticipants: 8,
// Prijs
price: '€399',
priceExclBtw: 'excl. BTW',
// Contact
email: 'frank@frankmeeuwsen.com',
};

View file

@ -5,33 +5,12 @@
* Na het invullen van het formulier redirect Tally naar de Mollie betaalpagina.
*/
import { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { PAYMENT_CONFIG } from '../config/payment';
import { WORKSHOP_CONFIG } from '../config/workshop';
import TallyForm from '../components/TallyForm';
function Signup() {
// Laad het Tally embed script zodra de pagina mount
useEffect(() => {
const scriptUrl = 'https://tally.so/widgets/embed.js';
// Check of het script al geladen is
if (typeof window.Tally !== 'undefined') {
window.Tally.loadEmbeds();
return;
}
// Script nog niet geladen? Voeg het toe
if (!document.querySelector(`script[src="${scriptUrl}"]`)) {
const script = document.createElement('script');
script.src = scriptUrl;
script.onload = () => {
if (typeof window.Tally !== 'undefined') {
window.Tally.loadEmbeds();
}
};
document.body.appendChild(script);
}
}, []);
return (
<div className="min-h-screen bg-warm-50">
{/* Header */}
@ -58,21 +37,15 @@ function Signup() {
</p>
{/* Embedded Tally formulier */}
<div className="card">
<iframe
data-tally-src="https://tally.so/embed/XxGBrV?alignLeft=1&hideTitle=1&transparentBackground=1&dynamicHeight=1"
loading="lazy"
width="100%"
height="300"
frameBorder="0"
<TallyForm
formId={PAYMENT_CONFIG.SIGNUP_TALLY_ID}
title="Inschrijving Claude Code Workshop"
/>
</div>
{/* Workshop samenvatting */}
<div className="mt-8 text-center text-sm text-warm-500 space-y-1">
<p>Claude Code Workshop | Vrijdag 3 april 2026 | 9:00 - 14:00 | Utrecht</p>
<p>EUR 399 excl. BTW (EUR 482,79 incl. BTW)</p>
<p>Claude Code Workshop | {WORKSHOP_CONFIG.dateFull} | {WORKSHOP_CONFIG.time} | {WORKSHOP_CONFIG.location}</p>
<p>{WORKSHOP_CONFIG.price} {WORKSHOP_CONFIG.priceExclBtw}</p>
</div>
</div>
</main>

View file

@ -6,6 +6,7 @@
*/
import { Link } from 'react-router-dom';
import { WORKSHOP_CONFIG } from '../config/workshop';
function ThankYou() {
return (
@ -36,8 +37,11 @@ function ThankYou() {
</div>
<h1 className="heading-hero mb-4">Je bent erbij!</h1>
<p className="text-xl text-warm-600 mb-10">
Je inschrijving voor de Claude Code Workshop is bevestigd.
<p className="text-xl text-warm-600 mb-4">
Je betaling is ontvangen en je inschrijving is bevestigd.
</p>
<p className="text-warm-500 mb-10">
Binnen 24 uur ontvang je een factuur en bevestigingsmail.
</p>
{/* Workshop details */}
@ -48,20 +52,20 @@ function ThankYou() {
<svg className="w-5 h-5 text-coral-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span>Vrijdag 3 april 2026</span>
<span>{WORKSHOP_CONFIG.dateFull}</span>
</div>
<div className="flex gap-3">
<svg className="w-5 h-5 text-coral-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>9:00 - 14:00 uur</span>
<span>{WORKSHOP_CONFIG.time} uur</span>
</div>
<div className="flex gap-3">
<svg className="w-5 h-5 text-coral-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>Utrecht</span>
<span>{WORKSHOP_CONFIG.venue}</span>
</div>
</div>
</div>
@ -72,7 +76,7 @@ function ThankYou() {
<ol className="space-y-4 text-warm-600">
<li className="flex gap-3">
<span className="flex-shrink-0 w-7 h-7 bg-coral-100 text-coral-600 rounded-full flex items-center justify-center font-semibold text-sm">1</span>
<span>Je ontvangt een bevestigingsmail met je factuur.</span>
<span>Binnen 24 uur ontvang je een factuur en bevestigingsmail.</span>
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-7 h-7 bg-coral-100 text-coral-600 rounded-full flex items-center justify-center font-semibold text-sm">2</span>
@ -80,7 +84,7 @@ function ThankYou() {
</li>
<li className="flex gap-3">
<span className="flex-shrink-0 w-7 h-7 bg-coral-100 text-coral-600 rounded-full flex items-center justify-center font-semibold text-sm">3</span>
<span>Op 3 april neem je je laptop mee en gaan we aan de slag!</span>
<span>Op {WORKSHOP_CONFIG.dateShort} neem je je laptop mee en gaan we aan de slag!</span>
</li>
</ol>
</div>
@ -88,8 +92,8 @@ function ThankYou() {
{/* Contact */}
<p className="text-warm-500">
Vragen? Mail naar{' '}
<a href="mailto:frank@frankmeeuwsen.com" className="text-coral-500 hover:text-coral-600">
frank@frankmeeuwsen.com
<a href={`mailto:${WORKSHOP_CONFIG.email}`} className="text-coral-500 hover:text-coral-600">
{WORKSHOP_CONFIG.email}
</a>
</p>
</div>

View file

@ -6,32 +6,11 @@
* Tally ID: kdyPJZ (https://tally.so/r/kdyPJZ)
*/
import { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { PAYMENT_CONFIG } from '../config/payment';
import TallyForm from '../components/TallyForm';
function WaitlistSignup() {
// Laad het Tally embed script zodra de pagina mount
useEffect(() => {
const scriptUrl = 'https://tally.so/widgets/embed.js';
if (typeof window.Tally !== 'undefined') {
window.Tally.loadEmbeds();
return;
}
if (!document.querySelector(`script[src="${scriptUrl}"]`)) {
const script = document.createElement('script');
script.src = scriptUrl;
script.onload = () => {
if (typeof window.Tally !== 'undefined') {
window.Tally.loadEmbeds();
}
};
document.body.appendChild(script);
}
}, []);
return (
<div className="min-h-screen bg-warm-50">
{/* Header */}
@ -68,17 +47,11 @@ function WaitlistSignup() {
</p>
{/* Embedded Tally wachtlijst formulier */}
<div className="card">
<iframe
data-tally-src={`https://tally.so/embed/${PAYMENT_CONFIG.WAITLIST_TALLY_ID}?alignLeft=1&hideTitle=1&transparentBackground=1&dynamicHeight=1`}
loading="lazy"
width="100%"
height="300"
frameBorder="0"
<TallyForm
formId={PAYMENT_CONFIG.WAITLIST_TALLY_ID}
title="Wachtlijst Claude Code Workshop"
/>
</div>
</div>
</main>
{/* Footer */}