3dct-client-mirror/snapshot-2026-08-15/home-weddingcaketopper/public-site/ontwerp/index.html
Mannu 596cfcca34 snapshot(2026-08-15): client production system, pre-engagement baseline
Read-only mirror of the client's existing system as it stood before our
work began. Taken from matthijsexpand@149.210.159.152 (TransIP VPS,
Lelystad NL) with the client's written permission.

Contents:
- home-weddingcaketopper/  Next.js app, proxy.py, supabase schema
- var-www/                 static webroots for all three domains
- etc-nginx/               vhost configs (previously unversioned, server-only)

Excluded: node_modules (704M, regenerable), .next (17M, regenerable),
and env file contents (present on local disk, gitignored — see README).

Not our IP. Not covered by the proposal's assignment clause.
2026-08-15 08:57:40 +05:30

1751 lines
No EOL
90 KiB
HTML

<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ontwerptool — WeddingCakeTopper.nl</title>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
<style>
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:'Playfair Display',Georgia,serif;background:#FAF8F4;min-height:100vh;}
.wizard{max-width:740px;margin:0 auto;padding:2rem 1rem 4rem;}
.progress-bar{display:flex;align-items:center;justify-content:center;gap:0;margin-bottom:2.5rem;}
.step-dot{width:32px;height:32px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:500;font-family:sans-serif;border:1.5px solid #C9A96E;color:#C9A96E;background:transparent;transition:all 0.3s;flex-shrink:0;}
.step-dot.active{background:#C9A96E;color:white;}
.step-dot.done{background:#C9A96E;color:white;}
.step-line{width:36px;height:1px;background:#C9A96E;opacity:0.35;flex-shrink:0;}
.step-line.done{opacity:1;}
h1{font-family:'Playfair Display',Georgia,serif;font-size:26px;font-weight:600;color:#2C1810;text-align:center;margin-bottom:0.3rem;letter-spacing:0.5px;}
.subtitle{font-family:sans-serif;font-size:13px;color:#aaa;text-align:center;margin-bottom:0.4rem;letter-spacing:1.5px;text-transform:uppercase;}
.gold-line{width:60px;height:1.5px;background:#C9A96E;margin:0.5rem auto 2rem;}
.section-label{font-family:sans-serif;font-size:11px;letter-spacing:2px;text-transform:uppercase;color:#C9A96E;margin-bottom:1rem;font-weight:500;}
.card-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:1.5rem;}
.style-card{border:1.5px solid #E8D5B7;border-radius:14px;padding:0;cursor:pointer;transition:all 0.2s;background:#FDFAF5;text-align:center;overflow:hidden;}
.style-card:hover{border-color:#C9A96E;box-shadow:0 4px 18px rgba(201,169,110,0.15);}
.style-card.selected{border-color:#C9A96E;box-shadow:0 0 0 3px rgba(201,169,110,0.2);}
.style-card img{width:100%;height:260px;object-fit:contain;object-position:center center;display:block;background:#F7F3EC;padding:0.5rem 0;}
.style-card-body{padding:0.75rem;}
.style-name{font-size:14px;font-weight:600;color:#2C1810;}
.style-desc{font-family:sans-serif;font-size:11px;color:#aaa;margin-top:2px;letter-spacing:0.5px;text-transform:uppercase;}
.selected-check{display:none;width:22px;height:22px;background:#C9A96E;border-radius:50%;margin:0 auto 0.4rem;align-items:center;justify-content:center;}
.style-card.selected .selected-check{display:flex;}
.size-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:1.5rem;}
.size-card{border:1.5px solid #E8D5B7;border-radius:12px;padding:1.2rem;cursor:pointer;transition:all 0.2s;background:#FDFAF5;position:relative;}
.size-card:hover{border-color:#C9A96E;}
.size-card.selected{border-color:#C9A96E;background:#FDF6EC;box-shadow:0 0 0 3px rgba(201,169,110,0.15);}
.size-badge{position:absolute;top:-10px;right:12px;background:#C9A96E;color:white;font-family:sans-serif;font-size:10px;padding:2px 10px;border-radius:10px;letter-spacing:1px;}
.size-name{font-size:17px;color:#2C1810;font-weight:600;margin-bottom:2px;}
.size-cm{font-family:sans-serif;font-size:13px;color:#888;margin-bottom:0.5rem;}
.size-price{font-family:sans-serif;font-size:22px;font-weight:600;color:#C9A96E;}
.size-desc{font-family:sans-serif;font-size:12px;color:#aaa;margin-top:4px;}
.color-section{margin-bottom:1.5rem;}
.color-label{font-family:sans-serif;font-size:13px;color:#555;margin-bottom:0.6rem;}
.color-row{display:flex;gap:10px;flex-wrap:wrap;}
.color-swatch{width:36px;height:36px;border-radius:50%;cursor:pointer;border:2.5px solid transparent;transition:all 0.2s;}
.color-swatch:hover{transform:scale(1.1);}
.color-swatch.selected{box-shadow:0 0 0 2px white,0 0 0 4px #C9A96E;}
.couple-grid{display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;margin-bottom:1.5rem;}
.upload-box{border:1.5px dashed #C9A96E;border-radius:12px;padding:1.25rem 1rem;text-align:center;cursor:pointer;background:#FDFAF5;transition:all 0.2s;position:relative;min-height:120px;display:flex;flex-direction:column;align-items:center;justify-content:center;margin-bottom:10px;}
.upload-box:hover{background:#FDF6EC;}
.upload-box input{position:absolute;opacity:0;width:100%;height:100%;top:0;left:0;cursor:pointer;}
.upload-icon{font-size:26px;color:#C9A96E;margin-bottom:0.4rem;}
.upload-label{font-family:sans-serif;font-size:13px;color:#888;}
.upload-sub{font-family:sans-serif;font-size:11px;color:#ccc;margin-top:3px;}
.upload-preview{width:70px;height:70px;object-fit:cover;border-radius:8px;margin-bottom:0.4rem;}
.person-title{font-family:sans-serif;font-size:12px;letter-spacing:1.5px;text-transform:uppercase;color:#C9A96E;margin-bottom:0.6rem;font-weight:500;}
.text-input{width:100%;padding:0.7rem 1rem;border:1.5px solid #E8D5B7;border-radius:8px;font-family:'Playfair Display',Georgia,serif;font-size:16px;color:#2C1810;background:#FDFAF5;outline:none;transition:border-color 0.2s;}
.text-input:focus{border-color:#C9A96E;}
.text-hint{font-family:sans-serif;font-size:12px;color:#ccc;margin-top:0.4rem;}
.btn-row{display:flex;justify-content:space-between;align-items:center;margin-top:2rem;}
.btn{padding:0.7rem 2rem;border-radius:30px;font-family:sans-serif;font-size:14px;font-weight:500;cursor:pointer;transition:all 0.2s;letter-spacing:0.5px;border:none;}
.btn-back{background:transparent;border:1.5px solid #E8D5B7;color:#888;}
.btn-back:hover{border-color:#C9A96E;color:#C9A96E;}
.btn-next{background:#C9A96E;color:white;box-shadow:0 2px 12px rgba(201,169,110,0.3);}
.btn-next:hover{background:#B8935A;}
.btn-next:disabled{background:#E8D5B7;color:#ccc;cursor:not-allowed;box-shadow:none;}
.step-panel{display:none;}
.step-panel.active{display:block;}
.preview-box{background:#FDFAF5;border:1.5px solid #E8D5B7;border-radius:12px;padding:1.5rem;text-align:center;min-height:200px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:0.75rem;}
.spinner{width:36px;height:36px;border:3px solid #E8D5B7;border-top-color:#C9A96E;border-radius:50%;animation:spin 1s linear infinite;}
@keyframes spin{to{transform:rotate(360deg);}}
.ai-preview-text{font-family:sans-serif;font-size:14px;color:#555;line-height:1.7;text-align:left;background:#FDF6EC;border-left:3px solid #C9A96E;padding:1rem 1.25rem;border-radius:0 8px 8px 0;margin-top:0.5rem;}
.approve-actions{display:flex;gap:12px;margin-top:1rem;width:100%;}
.btn-approve{flex:1;background:#C9A96E;color:white;border:none;padding:0.75rem;border-radius:8px;font-family:sans-serif;font-size:14px;cursor:pointer;}
.btn-revise{flex:1;background:transparent;color:#888;border:1.5px solid #E8D5B7;padding:0.75rem;border-radius:8px;font-family:sans-serif;font-size:14px;cursor:pointer;}
.summary-grid{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1.5rem;}
.summary-item{background:#FDFAF5;border:1px solid #E8D5B7;border-radius:8px;padding:0.75rem 1rem;}
.summary-key{font-family:sans-serif;font-size:11px;letter-spacing:1.5px;text-transform:uppercase;color:#C9A96E;margin-bottom:3px;}
.summary-val{font-size:15px;color:#2C1810;}
.contact-grid{display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem;}
.field-group{display:flex;flex-direction:column;gap:0.4rem;margin-bottom:0.75rem;}
.field-label{font-family:sans-serif;font-size:12px;letter-spacing:1px;text-transform:uppercase;color:#888;}
.success-box{text-align:center;padding:2.5rem 1rem;}
.price-tag{display:inline-block;background:#C9A96E;color:white;padding:0.4rem 1.5rem;border-radius:20px;font-family:sans-serif;font-size:15px;margin-top:1.25rem;}
@media(max-width:520px){
.card-grid{grid-template-columns:1fr;}
.couple-grid,.contact-grid,.size-grid,.summary-grid{grid-template-columns:1fr;}
.style-card img{height:220px;}
}
</style>
</head>
<body>
<div class="wizard">
<div class="progress-bar" id="progressBar"></div>
<!-- STAP 1 -->
<div id="panel1" class="step-panel active">
<p class="subtitle">Stap 1 van 6</p>
<h1>Kies jullie stijl</h1>
<div class="gold-line"></div>
<p class="section-label">Selecteer een ontwerp</p>
<div style="margin-bottom:0.5rem;">
<p style="font-family:sans-serif;font-size:11px;letter-spacing:2px;text-transform:uppercase;color:#C9A96E;margin-bottom:0.6rem;font-weight:500;">Bruid &amp; bruidegom</p>
<div class="card-grid">
<div class="style-card" onclick="selectStyle('bb_klassiek','Bruid en Bruidegom','Klassiek',this)">
<img src="/images/vb%20klassiek%20nieuw.png" alt="Klassiek bruid en bruidegom">
<div class="style-card-body">
<div class="selected-check"><i class="ti ti-check" style="font-size:12px;color:white;"></i></div>
<div class="style-name">Klassiek</div>
<div class="style-desc">Tijdloos &amp; elegant</div>
</div>
</div>
<div class="style-card" onclick="selectStyle('bb_karikatuur','Bruid en Bruidegom','Karikatuur',this)">
<img src="/images/vb%20karikatuur%20nieuw.png" alt="Karikatuur bruid en bruidegom">
<div class="style-card-body">
<div class="selected-check"><i class="ti ti-check" style="font-size:12px;color:white;"></i></div>
<div class="style-name">Karikatuur</div>
<div class="style-desc">Speels &amp; lief</div>
</div>
</div>
<div class="style-card" onclick="selectStyle('bb_afterparty','Bruid en Bruidegom','After Party',this)">
<img src="/images/vb%20afterparty%20nieuw.png" alt="After Party bruid en bruidegom">
<div class="style-card-body">
<div class="selected-check"><i class="ti ti-check" style="font-size:12px;color:white;"></i></div>
<div class="style-name">After Party</div>
<div class="style-desc">Gek &amp; vol karakter</div>
</div>
</div>
</div>
</div>
<div style="margin-bottom:0.5rem;margin-top:1rem;">
<p style="font-family:sans-serif;font-size:11px;letter-spacing:2px;text-transform:uppercase;color:#C9A96E;margin-bottom:0.6rem;font-weight:500;">Bruid &amp; bruid</p>
<div class="card-grid">
<div class="style-card" onclick="selectStyle('2b_klassiek','Bruid en Bruid','Klassiek',this)">
<img src="/images/2%20bruiden%20klassiek.png" alt="Klassiek twee bruiden">
<div class="style-card-body">
<div class="selected-check"><i class="ti ti-check" style="font-size:12px;color:white;"></i></div>
<div class="style-name">Klassiek</div>
<div class="style-desc">Tijdloos &amp; elegant</div>
</div>
</div>
<div class="style-card" onclick="selectStyle('2b_karikatuur','Bruid en Bruid','Karikatuur',this)">
<img src="/images/2%20bruiden%20karikatuur.png" alt="Karikatuur twee bruiden">
<div class="style-card-body">
<div class="selected-check"><i class="ti ti-check" style="font-size:12px;color:white;"></i></div>
<div class="style-name">Karikatuur</div>
<div class="style-desc">Speels &amp; lief</div>
</div>
</div>
<div class="style-card" onclick="selectStyle('2b_afterparty','Bruid en Bruid','After Party',this)">
<img src="/images/2%20bruiden%20afterparty.png" alt="After Party twee bruiden">
<div class="style-card-body">
<div class="selected-check"><i class="ti ti-check" style="font-size:12px;color:white;"></i></div>
<div class="style-name">After Party</div>
<div class="style-desc">Gek &amp; vol karakter</div>
</div>
</div>
</div>
</div>
<div style="margin-bottom:1.5rem;margin-top:1rem;">
<p style="font-family:sans-serif;font-size:11px;letter-spacing:2px;text-transform:uppercase;color:#C9A96E;margin-bottom:0.6rem;font-weight:500;">Bruidegom &amp; bruidegom</p>
<div class="card-grid">
<div class="style-card" onclick="selectStyle('2g_klassiek','Bruidegom en Bruidegom','Klassiek',this)">
<img src="/images/2%20grooms%20Klassiek.png" alt="Klassiek twee bruidegoms">
<div class="style-card-body">
<div class="selected-check"><i class="ti ti-check" style="font-size:12px;color:white;"></i></div>
<div class="style-name">Klassiek</div>
<div class="style-desc">Tijdloos &amp; elegant</div>
</div>
</div>
<div class="style-card" onclick="selectStyle('2g_karikatuur','Bruidegom en Bruidegom','Karikatuur',this)">
<img src="/images/2%20grooms%20karikatuur.png" alt="Karikatuur twee bruidegoms">
<div class="style-card-body">
<div class="selected-check"><i class="ti ti-check" style="font-size:12px;color:white;"></i></div>
<div class="style-name">Karikatuur</div>
<div class="style-desc">Speels &amp; lief</div>
</div>
</div>
<div class="style-card" onclick="selectStyle('2g_afterparty','Bruidegom en Bruidegom','After Party',this)">
<img src="/images/2%20grooms%20afterparty.png" alt="After Party twee bruidegoms">
<div class="style-card-body">
<div class="selected-check"><i class="ti ti-check" style="font-size:12px;color:white;"></i></div>
<div class="style-name">After Party</div>
<div class="style-desc">Gek &amp; vol karakter</div>
</div>
</div>
</div>
</div>
<p class="section-label" style="margin-top:0.5rem;">Kies jullie formaat</p>
<div class="size-grid">
<div class="size-card" onclick="selectSize('klein',69,this)">
<div class="size-name">Klein</div>
<div class="size-cm">12 cm hoog</div>
<div class="size-price">€69</div>
<div class="size-desc">Subtiele decoratie</div>
</div>
<div class="size-card selected" id="defaultSize" onclick="selectSize('groot',99,this)">
<div class="size-badge">Meest gekozen</div>
<div class="size-name">Groot</div>
<div class="size-cm">19 cm hoog</div>
<div class="size-price">€99</div>
<div class="size-desc">Echte eyecatcher</div>
</div>
</div>
<div class="btn-row">
<div></div>
<button class="btn btn-next" onclick="nextStep()">Volgende →</button>
</div>
</div>
<!-- STAP 2 -->
<div id="panel2" class="step-panel">
<p class="subtitle">Stap 2 van 9</p>
<h1>Kies de kleuren</h1>
<div class="gold-line"></div>
<div class="color-section">
<p class="section-label">Jurk / Bruid</p>
<p class="color-label">Kleur van de jurk</p>
<div class="color-row" id="dressColors">
<div class="color-swatch selected" style="background:#FFFFFF;border:1.5px solid #ddd;" onclick="selectColor('dress','Wit',this)" title="Wit"></div>
<div class="color-swatch" style="background:#222222;" onclick="selectColor('dress','Zwart',this)" title="Zwart"></div>
<div class="color-swatch" style="background:#CC0000;" onclick="selectColor('dress','Rood',this)" title="Rood"></div>
<div class="color-swatch" style="background:#6B2D8B;" onclick="selectColor('dress','Paars',this)" title="Paars"></div>
</div>
</div>
<div class="color-section" style="margin-top:1.25rem;">
<p class="section-label">Pak / Bruidegom</p>
<p class="color-label">Kleur van het pak</p>
<div class="color-row" id="suitColors">
<div class="color-swatch selected" style="background:#222222;" onclick="selectColor('suit','Zwart',this)" title="Zwart"></div>
<div class="color-swatch" style="background:#FFFFFF;border:1.5px solid #ddd;" onclick="selectColor('suit','Wit',this)" title="Wit"></div>
<div class="color-swatch" style="background:#C9A96E;" onclick="selectColor('suit','Beige',this)" title="Beige"></div>
<div class="color-swatch" style="background:#1A237E;" onclick="selectColor('suit','Blauw',this)" title="Blauw"></div>
</div>
</div>
<div class="btn-row">
<button class="btn btn-back" onclick="prevStep()">← Terug</button>
<button class="btn btn-next" onclick="nextStep()">Volgende →</button>
</div>
</div>
<!-- STAP 3: Kleur preview genereren -->
<div id="panel3" class="step-panel">
<p class="subtitle">Stap 3 van 9</p>
<h1>Preview topper</h1>
<div class="gold-line"></div>
<p style="font-family:sans-serif;font-size:14px;color:#888;text-align:center;margin-bottom:1.5rem;">We genereren een preview van jullie topper met de gekozen stijl en kleuren — nog zonder gezichten.</p>
<div id="p3Generating" style="text-align:center;padding:2rem;background:#FDFAF5;border:1.5px solid #E8D5B7;border-radius:14px;margin-bottom:1.5rem;">
<div class="spinner" style="margin:0 auto 1rem;"></div>
<p style="font-family:sans-serif;font-size:14px;color:#888;">Preview wordt gegenereerd...</p>
<p style="font-family:sans-serif;font-size:12px;color:#bbb;margin-top:0.5rem;">Dit kan wel een minuutje duren</p>
</div>
<div id="p3Result" style="display:none;">
<div style="background:#FDFAF5;border:1.5px solid #E8D5B7;border-radius:14px;margin-bottom:1rem;padding:0.5rem;">
<img id="p3Img" src="" alt="Kleur preview" style="width:100%;height:auto;display:block;border-radius:8px;">
</div>
<p style="font-family:sans-serif;font-size:14px;color:#888;text-align:center;margin-bottom:1rem;">Ziet jullie topper er goed uit?</p>
<div class="approve-actions">
<button class="btn-revise" onclick="generateColorPreview()"><i class="ti ti-refresh" style="margin-right:6px;"></i>Opnieuw genereren</button>
<button class="btn-approve" id="btn3next" onclick="nextStep()"><i class="ti ti-check" style="margin-right:6px;"></i>De topper is perfect zo! Verder!</button>
</div>
</div>
<div id="p3Error" style="display:none;background:#FFF5F5;border:1.5px solid #FFD0D0;border-radius:14px;padding:1.5rem;text-align:center;margin-bottom:1.5rem;">
<i class="ti ti-alert-circle" style="font-size:32px;color:#E57373;margin-bottom:0.5rem;"></i>
<p style="font-family:sans-serif;font-size:14px;color:#888;" id="p3ErrorMsg">Er ging iets mis.</p>
<button class="btn btn-next" style="margin-top:1rem;" onclick="generateColorPreview()">Opnieuw proberen</button>
</div>
<div class="btn-row">
<button class="btn btn-back" onclick="prevStep()">← Terug</button>
<div></div>
</div>
</div>
<!-- STAP 4: Foto persoon 1 uploaden -->
<div id="panel4" class="step-panel">
<p class="subtitle">Stap 4 van 9</p>
<h1>Foto's persoon 1</h1>
<div class="gold-line"></div>
<p style="font-family:sans-serif;font-size:14px;color:#888;text-align:center;margin-bottom:1rem;">Upload 2 foto's van persoon 1 voor het beste resultaat.</p>
<div style="background:#FDF6EC;border-radius:8px;padding:0.75rem 1rem;font-family:sans-serif;font-size:13px;color:#8B6914;margin-bottom:1.25rem;">
<i class="ti ti-info-circle" style="font-size:14px;vertical-align:-2px;margin-right:4px;"></i>
<strong>Tips:</strong> gebruik foto's met een <strong>lichte achtergrond</strong>, zo hoog mogelijke resolutie, gezicht goed zichtbaar. Geen zonnebril!
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:1.5rem;">
<div>
<p class="section-label" style="text-align:center;">Recht van voren</p>
<div class="upload-box" id="box1" data-label="Voorkant" data-sub="Recht van voren">
<input type="file" accept="image/*" onchange="previewImg(this,'box1')">
<i class="ti ti-camera upload-icon"></i>
<div class="upload-label">Voorkant</div>
<div class="upload-sub">Recht van voren</div>
</div>
</div>
<div>
<p class="section-label" style="text-align:center;">Schuin van voren</p>
<div class="upload-box" id="box2" data-label="Schuin voor" data-sub="45° hoek">
<input type="file" accept="image/*" onchange="previewImg(this,'box2')">
<i class="ti ti-camera upload-icon"></i>
<div class="upload-label">Schuin voor</div>
<div class="upload-sub">45° hoek</div>
</div>
</div>
</div>
<div class="btn-row">
<button class="btn btn-back" onclick="prevStep()">← Terug</button>
<button class="btn btn-next" onclick="nextStep()">Verder →</button>
</div>
</div>
<!-- STAP 5: Gezicht persoon 1 genereren + akkoord -->
<div id="panel5" class="step-panel">
<p class="subtitle">Stap 5 van 9</p>
<h1>Gezicht persoon 1</h1>
<div class="gold-line"></div>
<p style="font-family:sans-serif;font-size:14px;color:#888;text-align:center;margin-bottom:1.5rem;">We plaatsen het gezicht van persoon 1 op de topper.</p>
<div id="p5Generating" style="text-align:center;padding:2rem;background:#FDFAF5;border:1.5px solid #E8D5B7;border-radius:14px;margin-bottom:1.5rem;">
<div class="spinner" style="margin:0 auto 1rem;"></div>
<p style="font-family:sans-serif;font-size:14px;color:#888;">Gezicht persoon 1 wordt verwerkt...</p>
<p style="font-family:sans-serif;font-size:12px;color:#bbb;margin-top:0.5rem;">Dit kan wel een minuutje duren</p>
</div>
<div id="p5Result" style="display:none;">
<div style="background:#FDFAF5;border:1.5px solid #E8D5B7;border-radius:14px;margin-bottom:1rem;padding:0.5rem;">
<img id="p5Img" src="" alt="Persoon 1 preview" style="width:100%;height:auto;display:block;border-radius:8px;">
</div>
<p style="font-family:sans-serif;font-size:14px;color:#888;text-align:center;margin-bottom:1rem;">Hoe ziet persoon 1 eruit?</p>
<div class="approve-actions">
<button class="btn-revise" onclick="toggleP5Feedback()"><i class="ti ti-refresh" style="margin-right:6px;"></i>Opnieuw genereren</button>
<button class="btn-approve" onclick="approvePerson1()"><i class="ti ti-check" style="margin-right:6px;"></i>De topper is perfect zo! Verder!</button>
</div>
<div id="p5FeedbackBox" style="display:none;margin-top:1rem;background:#FDFAF5;border:1.5px solid #E8D5B7;border-radius:12px;padding:1.25rem;">
<p class="section-label">Nieuwe foto uploaden (optioneel)</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:0.75rem;">
<div>
<p style="font-family:sans-serif;font-size:12px;color:#888;text-align:center;margin-bottom:6px;">Recht van voren</p>
<div class="upload-box" id="p5rebox1" data-label="Voorkant" data-sub="Recht van voren">
<input type="file" accept="image/*" onchange="previewFeedbackPhoto(this,'person1new1')">
<i class="ti ti-camera upload-icon"></i>
<div class="upload-label">Voorkant</div>
<div class="upload-sub">Recht van voren</div>
</div>
</div>
<div>
<p style="font-family:sans-serif;font-size:12px;color:#888;text-align:center;margin-bottom:6px;">Schuin van voren</p>
<div class="upload-box" id="p5rebox2" data-label="Schuin voor" data-sub="45° hoek">
<input type="file" accept="image/*" onchange="previewFeedbackPhoto(this,'person1new2')">
<i class="ti ti-camera upload-icon"></i>
<div class="upload-label">Schuin voor</div>
<div class="upload-sub">45° hoek</div>
</div>
</div>
</div>
<button class="btn btn-next" style="width:100%;" onclick="regeneratePerson1()"><i class="ti ti-refresh" style="margin-right:6px;"></i>Genereer opnieuw</button>
</div>
</div>
<div id="p5Error" style="display:none;background:#FFF5F5;border:1.5px solid #FFD0D0;border-radius:14px;padding:1.5rem;text-align:center;margin-bottom:1.5rem;">
<i class="ti ti-alert-circle" style="font-size:32px;color:#E57373;margin-bottom:0.5rem;"></i>
<p style="font-family:sans-serif;font-size:14px;color:#888;" id="p5ErrorMsg">Er ging iets mis.</p>
<button class="btn btn-next" style="margin-top:1rem;" onclick="generatePerson1()">Opnieuw proberen</button>
</div>
<div class="btn-row">
<button class="btn btn-back" onclick="prevStep()">← Terug</button>
<div></div>
</div>
</div>
<!-- STAP 6: Foto persoon 2 uploaden -->
<div id="panel6" class="step-panel">
<p class="subtitle">Stap 6 van 9</p>
<h1>Foto's persoon 2</h1>
<div class="gold-line"></div>
<p style="font-family:sans-serif;font-size:14px;color:#888;text-align:center;margin-bottom:1rem;">Upload 2 foto's van persoon 2 voor het beste resultaat.</p>
<div style="background:#FDF6EC;border-radius:8px;padding:0.75rem 1rem;font-family:sans-serif;font-size:13px;color:#8B6914;margin-bottom:1.25rem;">
<i class="ti ti-info-circle" style="font-size:14px;vertical-align:-2px;margin-right:4px;"></i>
<strong>Tips:</strong> gebruik foto's met een <strong>lichte achtergrond</strong>, zo hoog mogelijke resolutie, gezicht goed zichtbaar. Geen zonnebril!
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:1.5rem;">
<div>
<p class="section-label" style="text-align:center;">Recht van voren</p>
<div class="upload-box" id="box5" data-label="Voorkant" data-sub="Recht van voren">
<input type="file" accept="image/*" onchange="previewImg(this,'box5')">
<i class="ti ti-camera upload-icon"></i>
<div class="upload-label">Voorkant</div>
<div class="upload-sub">Recht van voren</div>
</div>
</div>
<div>
<p class="section-label" style="text-align:center;">Schuin van voren</p>
<div class="upload-box" id="box6" data-label="Schuin voor" data-sub="45° hoek">
<input type="file" accept="image/*" onchange="previewImg(this,'box6')">
<i class="ti ti-camera upload-icon"></i>
<div class="upload-label">Schuin voor</div>
<div class="upload-sub">45° hoek</div>
</div>
</div>
</div>
<div class="btn-row">
<button class="btn btn-back" onclick="prevStep()">← Terug</button>
<button class="btn btn-next" onclick="nextStep()">Verder →</button>
</div>
</div>
<!-- STAP 7: Gezicht persoon 2 genereren + akkoord -->
<div id="panel7" class="step-panel">
<p class="subtitle">Stap 7 van 9</p>
<h1>Gezicht persoon 2</h1>
<div class="gold-line"></div>
<p style="font-family:sans-serif;font-size:14px;color:#888;text-align:center;margin-bottom:1.5rem;">We plaatsen het gezicht van persoon 2 op de topper.</p>
<div id="p7Generating" style="text-align:center;padding:2rem;background:#FDFAF5;border:1.5px solid #E8D5B7;border-radius:14px;margin-bottom:1.5rem;">
<div class="spinner" style="margin:0 auto 1rem;"></div>
<p style="font-family:sans-serif;font-size:14px;color:#888;">Gezicht persoon 2 wordt verwerkt...</p>
<p style="font-family:sans-serif;font-size:12px;color:#bbb;margin-top:0.5rem;">Dit kan wel een minuutje duren</p>
</div>
<div id="p7Result" style="display:none;">
<div style="background:#FDFAF5;border:1.5px solid #E8D5B7;border-radius:14px;margin-bottom:1rem;padding:0.5rem;">
<img id="p7Img" src="" alt="Persoon 2 preview" style="width:100%;height:auto;display:block;border-radius:8px;">
</div>
<p style="font-family:sans-serif;font-size:14px;color:#888;text-align:center;margin-bottom:1rem;">Ziet de topper er goed uit?</p>
<div class="approve-actions">
<button class="btn-revise" onclick="toggleP7Feedback()"><i class="ti ti-refresh" style="margin-right:6px;"></i>Opnieuw genereren</button>
<button class="btn-approve" onclick="approvePerson2()"><i class="ti ti-check" style="margin-right:6px;"></i>De topper is perfect zo! Verder!</button>
</div>
<div id="p7FeedbackBox" style="display:none;margin-top:1rem;background:#FDFAF5;border:1.5px solid #E8D5B7;border-radius:12px;padding:1.25rem;">
<p class="section-label">Nieuwe foto uploaden (optioneel)</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:0.75rem;">
<div>
<p style="font-family:sans-serif;font-size:12px;color:#888;text-align:center;margin-bottom:6px;">Recht van voren</p>
<div class="upload-box" id="p7rebox1" data-label="Voorkant" data-sub="Recht van voren">
<input type="file" accept="image/*" onchange="previewFeedbackPhoto(this,'person2new1')">
<i class="ti ti-camera upload-icon"></i>
<div class="upload-label">Voorkant</div>
<div class="upload-sub">Recht van voren</div>
</div>
</div>
<div>
<p style="font-family:sans-serif;font-size:12px;color:#888;text-align:center;margin-bottom:6px;">Schuin van voren</p>
<div class="upload-box" id="p7rebox2" data-label="Schuin voor" data-sub="45° hoek">
<input type="file" accept="image/*" onchange="previewFeedbackPhoto(this,'person2new2')">
<i class="ti ti-camera upload-icon"></i>
<div class="upload-label">Schuin voor</div>
<div class="upload-sub">45° hoek</div>
</div>
</div>
</div>
<button class="btn btn-next" style="width:100%;" onclick="regeneratePerson2()"><i class="ti ti-refresh" style="margin-right:6px;"></i>Genereer opnieuw</button>
</div>
</div>
<div id="p7Error" style="display:none;background:#FFF5F5;border:1.5px solid #FFD0D0;border-radius:14px;padding:1.5rem;text-align:center;margin-bottom:1.5rem;">
<i class="ti ti-alert-circle" style="font-size:32px;color:#E57373;margin-bottom:0.5rem;"></i>
<p style="font-family:sans-serif;font-size:14px;color:#888;" id="p7ErrorMsg">Er ging iets mis.</p>
<button class="btn btn-next" style="margin-top:1rem;" onclick="generatePerson2()">Opnieuw proberen</button>
</div>
<div class="btn-row">
<button class="btn btn-back" onclick="prevStep()">← Terug</button>
<div></div>
</div>
</div>
<!-- STAP 8: Persoonlijke tekst -->
<div id="panel8" class="step-panel">
<p class="subtitle">Stap 8 van 10</p>
<h1>Persoonlijke tekst</h1>
<div class="gold-line"></div>
<p class="section-label">Tekst op het voetstuk</p>
<p style="font-family:sans-serif;font-size:13px;color:#888;margin-bottom:1rem;">Optioneel — laat leeg als je geen tekst op het voetstuk wilt.</p>
<div class="field-group" style="margin-bottom:1rem;">
<label class="field-label">Namen <span style="color:#aaa;font-size:11px;">(optioneel)</span></label>
<input type="text" class="text-input" id="textNames" placeholder="bijv. Marjolein &amp; Matthijs" maxlength="40" oninput="updateTextPreview()">
<p class="text-hint">Voornamen van jullie beiden</p>
</div>
<div class="field-group" style="margin-bottom:1rem;">
<label class="field-label">Datum <span style="color:#aaa;font-size:11px;">(optioneel)</span></label>
<input type="text" class="text-input" id="textDate" placeholder="bijv. 17-12-2024" maxlength="20" oninput="updateTextPreview()">
<p class="text-hint">Jullie trouwdatum</p>
</div>
<div style="margin-top:1.5rem;background:#FDFAF5;border:1.5px solid #E8D5B7;border-radius:12px;padding:1.25rem;text-align:center;">
<p style="font-family:sans-serif;font-size:11px;letter-spacing:2px;text-transform:uppercase;color:#C9A96E;margin-bottom:0.75rem;">Voorbeeld voetstuk</p>
<div style="background:white;border-radius:6px;padding:0.75rem 1.5rem;display:inline-block;border:1px solid #E8D5B7;min-width:200px;">
<p id="textPreviewNames" style="font-family:'Playfair Display',Georgia,serif;font-size:15px;color:#2C1810;letter-spacing:1px;margin-bottom:2px;">Jullie namen</p>
<p id="textPreviewDate" style="font-family:'Playfair Display',Georgia,serif;font-size:13px;color:#C9A96E;letter-spacing:1px;">datum</p>
</div>
<p style="font-family:sans-serif;font-size:11px;color:#ccc;margin-top:0.5rem;">WeddingCakeTopper.nl</p>
</div>
<div class="btn-row">
<button class="btn btn-back" onclick="prevStep()">← Terug</button>
<button class="btn btn-next" onclick="nextStep()">Volgende →</button>
</div>
</div>
<!-- STAP 9 -->
<!-- STAP 9: Finale ontwerp preview met namen op sokkel -->
<div id="panel9" class="step-panel">
<p class="subtitle">Stap 9 van 10</p>
<h1>Jullie finale topper</h1>
<div class="gold-line"></div>
<p style="font-family:sans-serif;font-size:14px;color:#888;text-align:center;margin-bottom:1.5rem;">We verwerken de tekst op de sokkel en tonen jullie definitieve ontwerp.</p>
<div id="p9Generating" style="text-align:center;padding:2rem;background:#FDFAF5;border:1.5px solid #E8D5B7;border-radius:14px;margin-bottom:1.5rem;">
<div class="spinner" style="margin:0 auto 1rem;"></div>
<p style="font-family:sans-serif;font-size:14px;color:#888;">Finale topper wordt gegenereerd...</p>
<p style="font-family:sans-serif;font-size:12px;color:#bbb;margin-top:0.5rem;">Dit kan wel een minuutje duren</p>
</div>
<div id="p9Result" style="display:none;">
<div style="background:#FDFAF5;border:1.5px solid #E8D5B7;border-radius:14px;margin-bottom:1rem;padding:0.5rem;">
<img id="p9Img" src="" alt="Finale topper" style="width:100%;height:auto;display:block;border-radius:8px;">
</div>
<div style="text-align:center;margin-bottom:1rem;">
<a id="p9DownloadBtn" href="#" download="finale-topper.png" style="display:inline-flex;align-items:center;gap:6px;background:#C9A96E;color:white;padding:0.5rem 1.25rem;border-radius:20px;font-family:sans-serif;font-size:13px;text-decoration:none;"><i class="ti ti-download" style="font-size:14px;"></i> Download hoge resolutie</a>
</div>
<p style="font-family:sans-serif;font-size:14px;color:#888;text-align:center;margin-bottom:1rem;">Ziet jullie finale topper er goed uit?</p>
<div class="approve-actions">
<button class="btn-revise" onclick="generateFinalPreview()"><i class="ti ti-refresh" style="margin-right:6px;"></i>Opnieuw genereren</button>
<button class="btn-approve" id="btn9next" onclick="nextStep()"><i class="ti ti-heart" style="margin-right:6px;"></i>Bestellen!</button>
</div>
</div>
<div id="p9Error" style="display:none;background:#FFF5F5;border:1.5px solid #FFD0D0;border-radius:14px;padding:1.5rem;text-align:center;margin-bottom:1.5rem;">
<i class="ti ti-alert-circle" style="font-size:32px;color:#E57373;margin-bottom:0.5rem;"></i>
<p style="font-family:sans-serif;font-size:14px;color:#888;" id="p9ErrorMsg">Er ging iets mis.</p>
<button class="btn btn-next" style="margin-top:1rem;" onclick="generateFinalPreview()">Opnieuw proberen</button>
</div>
<div class="btn-row" style="margin-top:1.5rem;">
<button class="btn btn-back" onclick="prevStep()">← Terug</button>
<div></div>
</div>
</div>
<div id="panel10" class="step-panel">
<p class="subtitle">Stap 10 van 10</p>
<h1>Afronden & bestellen</h1>
<div class="gold-line"></div>
<p class="section-label">Jouw bestelling</p>
<div class="summary-grid" id="summaryGrid"></div>
<p class="section-label" style="margin-top:0.5rem;">Jouw gegevens</p>
<div class="contact-grid">
<div class="field-group">
<label class="field-label">Voornaam</label>
<input type="text" class="text-input" id="fname" placeholder="Anna">
</div>
<div class="field-group">
<label class="field-label">Achternaam</label>
<input type="text" class="text-input" id="lname" placeholder="de Vries">
</div>
</div>
<div class="field-group">
<label class="field-label">E-mailadres</label>
<input type="email" class="text-input" id="email" placeholder="anna@email.nl">
</div>
<div class="contact-grid">
<div class="field-group">
<label class="field-label">Telefoon</label>
<input type="tel" class="text-input" id="phone" placeholder="06 12345678">
</div>
</div>
<div class="field-group">
<label class="field-label">Eventuele opmerkingen</label>
<textarea class="text-input" id="remarks" rows="3" placeholder="Bijv. bijzondere wensen..." style="resize:vertical;"></textarea>
</div>
<div style="background:#FDFAF5;border:1.5px solid #E8D5B7;border-radius:12px;padding:1rem 1.25rem;display:flex;align-items:center;justify-content:space-between;margin:1rem 0 1.5rem;">
<span style="font-family:sans-serif;font-size:14px;color:#555;">Totaalbedrag</span>
<span style="font-size:24px;color:#C9A96E;font-weight:600;" id="totalPrice">€99</span>
</div>
<div style="background:#FDFAF5;border:1.5px solid #E8D5B7;border-radius:12px;padding:1rem 1.25rem;margin:1rem 0;">
<label style="display:flex;gap:10px;align-items:flex-start;font-family:sans-serif;font-size:13px;color:#555;margin-bottom:12px;">
<input type="checkbox" id="chkVariation" style="margin-top:2px;">
<span>Ik begrijp dat de getoonde AI-preview een digitale impressie is. Het uiteindelijke 3D-geprinte product kan kleine afwijkingen bevatten in vorm, details, afwerking en kleur ten opzichte van de preview.</span>
</label>
<label style="display:flex;gap:10px;align-items:flex-start;font-family:sans-serif;font-size:13px;color:#555;">
<input type="checkbox" id="chkCustomProduct" style="margin-top:2px;">
<span>Ik begrijp dat dit een gepersonaliseerd product betreft dat speciaal voor mij wordt gemaakt. Na het plaatsen van de bestelling en succesvolle betaling is herroeping, annulering of restitutie niet mogelijk, tenzij sprake is van een productiefout of transportschade.</span>
</label>
</div>
<button class="btn btn-next" style="width:100%;padding:0.9rem;font-size:15px;" onclick="placeOrder()">
<i class="ti ti-heart" style="margin-right:8px;"></i>Bestelling plaatsen
</button>
<p style="font-family:sans-serif;font-size:11px;color:#ccc;text-align:center;margin-top:0.75rem;">Betalen via iDEAL · Veilig & versleuteld</p>
<div class="btn-row" style="margin-top:1rem;">
<button class="btn btn-back" onclick="prevStep()">← Terug</button>
<div></div>
</div>
</div>
<!-- BEDANKT -->
<div id="panelDone" class="step-panel">
<div class="success-box">
<i class="ti ti-heart" style="font-size:54px;color:#C9A96E;"></i>
<h1 style="margin-top:1rem;">Bedankt voor jullie bestelling!</h1>
<div class="gold-line"></div>
<p style="font-family:sans-serif;font-size:15px;color:#888;line-height:1.8;">Pieter gaat aan de slag met jullie unieke topper.<br>Je ontvangt binnen 24 uur een bevestiging per e-mail.<br><br>Vragen? Stuur een berichtje naar<br><span style="color:#C9A96E;">info@weddingcaketopper.nl</span></p>
<div class="price-tag" id="finalPrice">€99 · Bestelling ontvangen</div>
</div>
</div>
</div>
<script>
const state = { feedbackText: '', feedbackFeatures: [], feedbackPhotoPerson1: null, feedbackPhotoPerson2: null, feedbackChoice: 'both', isRevision: false, revisionTarget: null, step:1, style:'', coupleType:'', styleName:'', size:'groot', price:99, dressColor:'Wit', suitColor:'Zwart', text:'', textNames:'', textDate:'', approved:false, colorPreviewUrl:null, person1ImageUrl:null, person1ApprovedMasterUrl:null, person1Approved:false, person2Approved:false, person1new1:null, person1new2:null, person2new1:null, person2new2:null, finalImageUrl:null };
const totalSteps = 10;
function buildProgress() {
const bar = document.getElementById('progressBar');
bar.innerHTML = '';
for (let i=1;i<=totalSteps;i++) {
const dot = document.createElement('div');
dot.className = 'step-dot' + (i < state.step ? ' done' : i === state.step ? ' active' : '');
dot.textContent = i;
bar.appendChild(dot);
if (i < totalSteps) {
const line = document.createElement('div');
line.className = 'step-line' + (i < state.step ? ' done' : '');
bar.appendChild(line);
}
}
}
function showPanel(n) {
document.querySelectorAll('.step-panel').forEach(p => p.classList.remove('active'));
document.getElementById(n <= totalSteps ? 'panel'+n : 'panelDone').classList.add('active');
buildProgress();
window.scrollTo(0,0);
}
function nextStep() {
state.step++;
showPanel(state.step);
if(state.step === 3) generateColorPreview();
if(state.step === 5) generatePerson1();
if(state.step === 7) generatePerson2();
if(state.step === 9) generateFinalPreview();
if(state.step === 10) buildSummary();
}
function prevStep() { state.step--; showPanel(state.step); }
function selectStyle(s, coupleType, styleName, el) {
state.style = s;
state.coupleType = coupleType;
state.styleName = styleName;
document.querySelectorAll('.style-card').forEach(c => c.classList.remove('selected'));
el.classList.add('selected');
}
function selectSize(s, p, el) {
state.size = s; state.price = p;
document.querySelectorAll('.size-card').forEach(c => c.classList.remove('selected'));
el.classList.add('selected');
const tp = document.getElementById('totalPrice');
if(tp) tp.textContent = '€'+p;
}
function selectColor(type, name, el) {
if(type==='dress') { state.dressColor = name; document.querySelectorAll('#dressColors .color-swatch').forEach(c=>c.classList.remove('selected')); }
else { state.suitColor = name; document.querySelectorAll('#suitColors .color-swatch').forEach(c=>c.classList.remove('selected')); }
el.classList.add('selected');
}
function previewImg(input, boxId) {
if(!input.files||!input.files[0]) return;
const reader = new FileReader();
reader.onload = e => {
const box = document.getElementById(boxId);
const btn = document.createElement('button');
btn.textContent = '✕ Verwijderen';
btn.style.cssText = 'margin-top:6px;background:transparent;border:1px solid #E8D5B7;border-radius:20px;padding:3px 10px;font-family:sans-serif;font-size:11px;color:#999;cursor:pointer;display:block;';
btn.onclick = function(ev) { ev.stopPropagation(); removeImg(boxId); };
box.innerHTML = '<img class="upload-preview" src="'+e.target.result+'" alt="preview" style="width:70px;height:70px;object-fit:cover;border-radius:8px;margin-bottom:4px;"><div class="upload-label" style="font-size:12px;color:#C9A96E;">✓ Geüpload</div>';
box.appendChild(btn);
};
reader.readAsDataURL(input.files[0]);
}
function removeImg(boxId) {
const box = document.getElementById(boxId);
const label = box.getAttribute('data-label') || 'Foto';
const sub = box.getAttribute('data-sub') || 'Klik om te uploaden';
box.innerHTML = '<input type="file" accept="image/*" onchange="previewImg(this,&quot;'+boxId+'&quot;)">'
+ '<i class="ti ti-camera upload-icon"></i>'
+ '<div class="upload-label">'+label+'</div>'
+ '<div class="upload-sub">'+sub+'</div>';
}
function updateTextPreview() {
const names = document.getElementById('textNames') ? document.getElementById('textNames').value : '';
const date = document.getElementById('textDate') ? document.getElementById('textDate').value : '';
state.text = names + (date ? '\n' + date : '');
state.textNames = names;
state.textDate = date;
if(document.getElementById('textPreviewNames')) {
document.getElementById('textPreviewNames').textContent = names || 'Jullie namen';
document.getElementById('textPreviewDate').textContent = date || 'datum';
}
}
function buildFeatureRevisionPrompt(features, target) {
if(!features || features.length === 0) return '';
const targetLabel = target === 'person1' ? 'Person 1' : target === 'person2' ? 'Person 2' : 'the selected person';
const lines = [
'',
'FEATURE-SPECIFIC REVISION PRIORITY',
targetLabel + ' only.',
'Do not redesign the entire face.',
'Make a targeted correction only to the selected facial feature(s).',
''
];
if(features.includes('eyes')) {
lines.push(
'EYES NEED IMPROVEMENT:',
'Focus primarily on the eyes.',
'Improve:',
'- eye shape',
'- eye distance',
'- eyelid shape',
'- eyebrow relation',
'Keep unchanged unless explicitly selected:',
'- nose',
'- mouth',
'- hairstyle',
'- face shape',
''
);
}
if(features.includes('nose')) {
lines.push(
'NOSE NEEDS IMPROVEMENT:',
'Focus primarily on the nose.',
'Improve:',
'- nose width',
'- nose length',
'- nostril shape',
'- nose bridge',
'Keep unchanged unless explicitly selected:',
'- eyes',
'- mouth',
'- hairstyle',
'- face shape',
''
);
}
if(features.includes('mouth')) {
lines.push(
'MOUTH NEEDS IMPROVEMENT:',
'Focus primarily on the mouth.',
'Improve:',
'- lip thickness',
'- mouth width',
'- smile shape',
'- mouth expression',
'Keep unchanged unless explicitly selected:',
'- eyes',
'- nose',
'- hairstyle',
'- face shape',
''
);
}
lines.push(
'Do not improve or alter facial features that were not selected.',
'Keep all approved facial features as stable as possible.',
'Keep the locked person completely unchanged.'
);
return lines.join('\n');
}
async function startAIPreview(options = {}) {
// Reset UI
document.getElementById('aiGenerating').style.display = 'block';
document.getElementById('aiImageResult').style.display = 'none';
document.getElementById('aiError').style.display = 'none';
document.getElementById('approveActions').style.display = 'none';
document.getElementById('aiInfoBox').style.display = 'none';
// (legacy btn5 ref removed)
const isRevisionRun = options.isRevision === true;
const revisionTarget = options.revisionTarget || null;
// Get uploaded photos as base64
const person1Boxes = ['box1','box2','box3','box4'];
const person2Boxes = ['box5','box6','box7','box8'];
const getPhotosFromBoxes = (boxIds) => {
const result = [];
for(const boxId of boxIds) {
const img = document.getElementById(boxId)?.querySelector('img');
if(img && img.src && img.src.startsWith('data:image')) result.push(img.src);
}
return result;
};
const person1Photos = getPhotosFromBoxes(person1Boxes);
const person2Photos = getPhotosFromBoxes(person2Boxes);
const photos = [...person1Photos, ...person2Photos];
// Build style descriptions
const styleMap = {
'bb_klassiek':'een klassiek, tijdloos bruidspaar (bruid en bruidegom)',
'bb_karikatuur':'een speels karikatuur bruidspaar (bruid en bruidegom) met grote hoofden in chibi stijl',
'bb_afterparty':'een feestelijk after-party bruidspaar (bruid en bruidegom) in een dansende pose',
'2b_klassiek':'twee bruiden in klassieke stijl',
'2b_karikatuur':'twee bruiden in speelse karikatuur chibi stijl',
'2b_afterparty':'twee bruiden in feestelijke after-party pose',
'2g_klassiek':'twee bruidegoms in klassieke stijl',
'2g_karikatuur':'twee bruidegoms in speelse karikatuur chibi stijl',
'2g_afterparty':'twee bruidegoms in feestelijke after-party pose',
};
const baseText = state.textNames ? `"${state.textNames}${state.textDate ? ' · ' + state.textDate : ''}"` : '';
// Skip Claude analysis, go straight to image generation
let faceDescription = '';
// Step 2: Generate image with gpt-image-1
const styleRefMap = {
'bb_klassiek': 'a classic elegant pose, standing upright side by side, the bride holding a bouquet',
'bb_karikatuur': 'a chibi cartoon style with large heads and small bodies, holding hands, cute and playful',
'bb_afterparty': 'a fun dancing after-party pose, the groom carrying the bride or both dancing energetically',
'2b_klassiek': 'two brides in a classic elegant pose, standing upright side by side, both holding bouquets',
'2b_karikatuur': 'two brides in chibi cartoon style with large heads and small bodies, holding hands',
'2b_afterparty': 'two brides in a fun dancing after-party pose',
'2g_klassiek': 'two grooms in a classic elegant pose, standing upright side by side',
'2g_karikatuur': 'two grooms in chibi cartoon style with large heads and small bodies',
'2g_afterparty': 'two grooms in a fun dancing after-party pose',
};
// Get base style image URL to use as reference
const baseImgMap = {
'bb_klassiek':'/images/vb%20klassiek%20nieuw.png',
'bb_karikatuur':'/images/vb%20karikatuur%20nieuw.png',
'bb_afterparty':'/images/vb%20afterparty%20nieuw.png',
'2b_klassiek':'/images/2%20bruiden%20klassiek.png',
'2b_karikatuur':'/images/2%20bruiden%20karikatuur.png',
'2b_afterparty':'/images/2%20bruiden%20afterparty.png',
'2g_klassiek':'/images/2%20grooms%20Klassiek.png',
'2g_karikatuur':'/images/2%20grooms%20karikatuur.png',
'2g_afterparty':'/images/2%20grooms%20afterparty.png',
};
const faceStyleMap = {
'bb_klassiek': 'small, realistic and proportional faces - do NOT make faces large or oversized',
'bb_karikatuur': 'large oversized cartoon chibi heads - exaggerated big heads are the style, make them very large compared to the body',
'bb_afterparty': 'natural sized expressive faces with big smiles and energy',
'2b_klassiek': 'small, realistic and proportional faces - do NOT make faces large or oversized',
'2b_karikatuur': 'large oversized cartoon chibi heads - exaggerated big heads are the style',
'2b_afterparty': 'natural sized expressive faces with big smiles',
'2g_klassiek': 'small, realistic and proportional faces - do NOT make faces large or oversized',
'2g_karikatuur': 'large oversized cartoon chibi heads - exaggerated big heads are the style',
'2g_afterparty': 'natural sized expressive faces with big smiles and energy',
};
const faceStyle = faceStyleMap[state.style] || 'proportional realistic faces';
// Extra instructie wanneer gebruiker Karikatuur kiest
const isCaricature = state.style && state.style.includes('karikatuur');
const caricatureInstruction = isCaricature
? [
'',
'PROFESSIONAL WEDDING CARICATURE MODE:',
'',
'Transform both people into a strong premium wedding caricature based on the uploaded face photos.',
'',
'CRITICAL CARICATURE REQUIREMENTS:',
'- Heads must be 50% to 70% larger than realistic proportions.',
'- Bodies should remain small, cute and cake-topper-like.',
'- Exaggerate the most recognizable facial features in a tasteful humorous way.',
'- Use large expressive eyes, wider smiles and clearly stylized facial proportions.',
'- Make the result playful, funny and instantly recognizable.',
'- The result should look like a luxury handcrafted caricature wedding cake topper.',
'',
'DO NOT:',
'- do not create realistic portraits',
'- do not create normal human proportions',
'- do not make the faces photorealistic',
'- do not make the caricature subtle',
'- do not simply paste realistic faces onto larger heads',
'',
'Everything below the neck should stay close to the original figurine.',
'Only the heads and faces should become a strong caricature.',
'The result should immediately make people smile and think: that is clearly us as a caricature.'
].join('\n')
: '';
const dressColorEn = {'Wit':'bright white','Zwart':'jet black','Rood':'bright red','Paars':'deep purple'}[state.dressColor] || state.dressColor;
const suitColorEn = {'Zwart':'jet black','Wit':'bright white','Beige':'beige/tan','Blauw':'navy blue'}[state.suitColor] || state.suitColor;
// Build revision prompt based on choice
let revisionPrompt = '';
if(isRevisionRun && revisionTarget === 'person1') {
revisionPrompt = [
'REVISION MODE — PERSON 1 ONLY',
'Treat the current image as the approved master image.',
'Person 1 is the person whose photos were uploaded in Step 4. Person 2 is the person whose photos were uploaded in Step 6.',
'Never identify persons by left/right position; use only the step/person assignment.',
'ONLY MODIFY PERSON 1 FACE, HEAD AND HAIR.',
'FREEZE PERSON 2 COMPLETELY: face, head, hair, expression, body, clothing, hands and pose must remain unchanged.',
'Do not modify Person 2 in any way. Treat Person 2 as a locked layer.',
'Use only the newly uploaded Person 1 reference photo to improve Person 1 likeness.',
'Everything else must remain identical: pose, clothing, colors, pedestal, text, composition, framing and camera distance.',
'If uncertain, leave the locked Person 2 unchanged.'
].join('\n');
} else if(isRevisionRun && revisionTarget === 'person2') {
revisionPrompt = [
'REVISION MODE — PERSON 2 ONLY',
'Treat the current image as the approved master image.',
'Person 1 is the person whose photos were uploaded in Step 4. Person 2 is the person whose photos were uploaded in Step 6.',
'Never identify persons by left/right position; use only the step/person assignment.',
'ONLY MODIFY PERSON 2 FACE, HEAD AND HAIR.',
'FREEZE PERSON 1 COMPLETELY: face, head, hair, expression, body, clothing, hands and pose must remain unchanged.',
'Do not modify Person 1 in any way. Treat Person 1 as a locked layer.',
'Use only the newly uploaded Person 2 reference photo to improve Person 2 likeness.',
'Everything else must remain identical: pose, clothing, colors, pedestal, text, composition, framing and camera distance.',
'If uncertain, leave the locked Person 1 unchanged.'
].join('\n');
} else {
revisionPrompt = [
'FIRST GENERATION MODE',
'Create the first personalised preview from the base topper template and the uploaded reference photos.',
'Person 1 reference photos are the first four uploaded photos.',
'Person 2 reference photos are the second four uploaded photos.',
'Keep the template pose, clothing shapes, pedestal, framing and composition as stable as possible.'
].join('\n');
}
const featureRevisionPrompt = buildFeatureRevisionPrompt(state.feedbackFeatures, revisionTarget);
if(featureRevisionPrompt) revisionPrompt += '\n' + featureRevisionPrompt;
if(state.feedbackText) revisionPrompt += '\nADDITIONAL NOTE FROM CUSTOMER: ' + state.feedbackText;
const feedbackInstruction = revisionPrompt;
const prompt = [
isRevisionRun ? 'PRECISE REVISION MODE: edit only the requested person.' : (isCaricature ? 'SPECIAL MODE: STRONG CARICATURE TRANSFORMATION.' : 'IMAGE EDITING MODE:'),
isRevisionRun ? 'Treat the uploaded current image as the locked master composition.' : (isCaricature ? 'You are allowed to redesign the heads and faces into a much stronger caricature.' : 'Treat the uploaded image as the master composition.'),
isRevisionRun ? 'Do not recreate the figurine; make a targeted face revision only.' : (isCaricature ? 'You are allowed to significantly increase head size and exaggerate facial proportions.' : 'Do not recreate the figurine.'),
isRevisionRun ? 'Do not redesign the figurine, do not change the locked person.' : (isCaricature ? 'Keep the pose, clothing, pedestal and overall topper layout, but make the heads clearly caricatured.' : 'Do not redesign the figurine.'),
'Do not reinterpret the pose.',
'Only modify:',
'- facial identity only: convert the uploaded facial features into the existing figurine face (' + faceStyle + ')'
,'- do not generate a new face from scratch'
,'- do not paste a photo onto the figurine'
,'- preserve the original figurine structure',
'',
'FACIAL ACCURACY IS THE HIGHEST PRIORITY.',
'',
'For each person reproduce as accurately as possible:',
'- eye shape',
'- eye distance',
'- eyebrow shape',
'- nose width',
'- nose length',
'- nostril shape',
'- lip thickness',
'- mouth width',
'- chin shape',
'- jawline',
'- hairline',
'- hairstyle',
'',
'Most important facial identifiers:',
'- eyes',
'- nose',
'- mouth',
'- hairline',
'- hairstyle',
'- face shape',
'',
'Preserve these characteristics even if it conflicts with stylistic choices.',
'',
'The goal is instant recognizability by family and friends.',
'Family members should immediately recognize who each person is.',
'Facial resemblance is more important than artistic style.',
'Do not average facial features.',
'Do not beautify facial features.',
'Do not replace distinctive features with generic attractive features.',
'Do not create idealized wedding faces.',
'Keep unique facial imperfections and distinctive characteristics.',
'These identifiers must remain faithful to the uploaded photographs.',
'',
'PERSON SEPARATION — CRITICAL:',
'Person 1 must match ONLY Person 1 reference photos.',
'Person 2 must match ONLY Person 2 reference photos.',
'Never blend facial features between the two people.',
'Never use Person 1 facial features on Person 2.',
'Never use Person 2 facial features on Person 1.',
'',
caricatureInstruction,
'- dress color: change to ' + dressColorEn,
'- suit color: change to ' + suitColorEn,
isCaricature ? 'Keep clothing, pose, pedestal and layout close to the original; only the heads/faces may be strongly redesigned.' : 'Everything else must remain pixel-for-pixel identical to the original image.',
'',
'CRITICAL:',
'Preserve exactly:',
'- composition',
'- framing',
'- camera distance',
'- pedestal size',
'- pedestal position',
'- text position: the gold text "' + (baseText || '') + '" must remain fully visible',
'- body pose',
'- hand position',
'- leg position',
'- clothing shape',
isCaricature ? 'The output should look like a clear professional caricature version of the original topper, not a realistic portrait.' : 'The output should look like the original image with only the faces and colors changed.',
'IMPORTANT: keep the complete figurine visible.',
'Do not crop any part of the figurine.',
'Do not crop heads, hands, feet, arms, legs, pedestal or text.',
'Keep the entire topper inside the frame with extra white margin around it.',
'Preserve the original composition and aspect ratio.',
'Expand the canvas if necessary.',
'Never zoom in.',
'Never crop.',
'Always show the full original figurine.',
feedbackInstruction
].join('\n');
// Build request - include photos if available
const requestBody = {
model: 'gpt-image-1',
prompt: prompt,
n: 1,
quality: 'high'
};
try {
let dalleRes;
// Always use edits endpoint with base image + face photos
const formData = new FormData();
formData.append('model', 'gpt-image-1');
formData.append('prompt', prompt);
formData.append('n', '1');
// no fixed size - let model preserve composition
formData.append('quality', 'high');
// First: add the image that should be edited.
// For first generation this is the style template.
// For revisions this is the latest generated image, so approved parts stay as stable as possible.
let baseImgUrl = baseImgMap[state.style];
if(isRevisionRun && state.generatedImageUrl) {
baseImgUrl = state.generatedImageUrl;
}
if(baseImgUrl) {
const baseRes = await fetch(baseImgUrl);
const baseBlob = await baseRes.blob();
formData.append('image[]', baseBlob, isRevisionRun ? 'approved-master.png' : 'base-template.png');
}
// Then add only the relevant face reference photos.
// First generation: all uploaded face photos.
// Revision: only the new photo for the selected person.
let referencePhotos = photos;
if(isRevisionRun && revisionTarget === 'person1') referencePhotos = state.feedbackPhotoPerson1 ? [state.feedbackPhotoPerson1] : [];
if(isRevisionRun && revisionTarget === 'person2') referencePhotos = state.feedbackPhotoPerson2 ? [state.feedbackPhotoPerson2] : [];
for(let i = 0; i < Math.min(referencePhotos.length, 8); i++) {
const res = await fetch(referencePhotos[i]);
const blob = await res.blob();
formData.append('image[]', blob, `reference-${revisionTarget || 'initial'}-${i}.jpg`);
}
dalleRes = await fetch('/api/imagine-edit', {
method: 'POST',
body: formData
});
const dalleData = await dalleRes.json();
// gpt-image-1 returns base64 in data[0].b64_json
let imageUrl = dalleData.data?.[0]?.url;
if(!imageUrl && dalleData.data?.[0]?.b64_json) {
imageUrl = 'data:image/png;base64,' + dalleData.data[0].b64_json;
}
// Also check for output_format response structure
if(!imageUrl && dalleData.output?.[0]?.b64_json) {
imageUrl = 'data:image/png;base64,' + dalleData.output[0].b64_json;
}
if(imageUrl) {
state.generatedImageUrl = imageUrl;
// Open image in new tab to inspect full size
console.log('Image URL length:', imageUrl.length);
document.getElementById('generatedImg').src = imageUrl;
const dlBtn = document.getElementById('downloadBtn');
if(dlBtn) dlBtn.href = imageUrl;
document.getElementById('aiGenerating').style.display = 'none';
document.getElementById('aiImageResult').style.display = 'block';
document.getElementById('aiInfoBox').style.display = 'block';
document.getElementById('approveActions').style.display = 'flex';
// Show details
const details = document.getElementById('previewDetails');
if(details) details.innerHTML =
'<div class="summary-item"><div class="summary-key">Combinatie</div><div class="summary-val">'+(state.coupleType||'—')+'</div></div>'+
'<div class="summary-item"><div class="summary-key">Stijl</div><div class="summary-val">'+(state.styleName||'—')+'</div></div>'+
'<div class="summary-item"><div class="summary-key">Jurk</div><div class="summary-val">'+state.dressColor+'</div></div>'+
'<div class="summary-item"><div class="summary-key">Pak</div><div class="summary-val">'+state.suitColor+'</div></div>';
if(state.textNames || state.textDate) {
document.getElementById('previewText').style.display = 'block';
document.getElementById('previewName').textContent = state.textNames || '';
document.getElementById('previewDateDisp').textContent = state.textDate || '';
}
} else {
throw new Error(dalleData.error?.message || 'Geen afbeelding ontvangen');
}
} catch(e) {
document.getElementById('aiGenerating').style.display = 'none';
document.getElementById('aiError').style.display = 'block';
document.getElementById('aiErrorMsg').textContent = 'Er ging iets mis: ' + e.message;
}
}
function updateFeedbackUI() {
const choice = document.querySelector('input[name="fixChoice"]:checked')?.value || 'both';
const p1 = document.getElementById('feedbackPhotoPerson1Wrap');
const p2 = document.getElementById('feedbackPhotoPerson2Wrap');
const hint = document.getElementById('bothRevisionHint');
if(p1) p1.style.display = (choice === 'person1' || choice === 'both') ? 'block' : 'none';
if(p2) p2.style.display = (choice === 'person2' || choice === 'both') ? 'block' : 'none';
if(hint) hint.style.display = choice === 'both' ? 'block' : 'none';
}
function previewFeedbackPhoto(input, person) {
if(!input.files||!input.files[0]) return;
const reader = new FileReader();
reader.onload = e => {
if(person === 'person1') state.feedbackPhotoPerson1 = e.target.result;
if(person === 'person2') state.feedbackPhotoPerson2 = e.target.result;
if(person === 'person1new1') state.person1new1 = e.target.result;
if(person === 'person1new2') state.person1new2 = e.target.result;
if(person === 'person2new1') state.person2new1 = e.target.result;
if(person === 'person2new2') state.person2new2 = e.target.result;
const boxId = person === 'person1' ? 'feedbackPhotoBoxPerson1' : 'feedbackPhotoBoxPerson2';
const label = person === 'person1' ? 'persoon 1' : 'persoon 2';
const box = document.getElementById(boxId);
box.innerHTML = '<img src="'+e.target.result+'" style="width:70px;height:70px;object-fit:cover;border-radius:8px;margin-bottom:4px;"><div class="upload-label" style="font-size:12px;color:#C9A96E;">✓ Foto voor '+label+' geüpload</div><button onclick="removeFeedbackPhoto(\''+person+'\')" style="margin-top:4px;background:transparent;border:1px solid #E8D5B7;border-radius:20px;padding:2px 8px;font-size:11px;color:#999;cursor:pointer;">✕ Verwijderen</button>';
};
reader.readAsDataURL(input.files[0]);
}
function removeFeedbackPhoto(person) {
if(person === 'person1') state.feedbackPhotoPerson1 = null;
if(person === 'person2') state.feedbackPhotoPerson2 = null;
const boxId = person === 'person1' ? 'feedbackPhotoBoxPerson1' : 'feedbackPhotoBoxPerson2';
const label = person === 'person1' ? 'persoon 1' : 'persoon 2';
document.getElementById(boxId).innerHTML = '<input type="file" accept="image/*" onchange="previewFeedbackPhoto(this,\''+person+'\')"><i class="ti ti-camera upload-icon" style="font-size:22px;"></i><div class="upload-label">Nieuwe foto '+label+' uploaden</div><div class="upload-sub">Deze foto wordt alleen gebruikt voor '+label+'</div>';
}
function showFeedback() {
const box = document.getElementById('feedbackBox');
box.style.display = box.style.display === 'none' ? 'block' : 'none';
updateFeedbackUI();
}
async function regenerateWithFeedback() {
const choice = document.querySelector('input[name="fixChoice"]:checked')?.value || 'both';
state.feedbackChoice = choice;
state.feedbackText = document.getElementById('feedbackText').value;
state.feedbackFeatures = Array.from(document.querySelectorAll('input[name="featureChoice"]:checked')).map(cb => cb.value);
if(choice === 'person1' && !state.feedbackPhotoPerson1) {
alert('Upload eerst een nieuwe foto voor persoon 1.');
return;
}
if(choice === 'person2' && !state.feedbackPhotoPerson2) {
alert('Upload eerst een nieuwe foto voor persoon 2.');
return;
}
if(choice === 'both' && (!state.feedbackPhotoPerson1 || !state.feedbackPhotoPerson2)) {
alert('Upload eerst een nieuwe foto voor persoon 1 én persoon 2.');
return;
}
document.getElementById('feedbackBox').style.display = 'none';
document.getElementById('approveActions').style.display = 'none';
if(choice === 'both') {
// Sequential revision: first person 1, then use that result as the new master for person 2.
await startAIPreview({ isRevision: true, revisionTarget: 'person1' });
await startAIPreview({ isRevision: true, revisionTarget: 'person2' });
} else {
await startAIPreview({ isRevision: true, revisionTarget: choice });
}
}
async function regenerateDesign() {
document.getElementById('approveActions').style.display = 'none';
await startAIPreview({ isRevision: true, revisionTarget: state.feedbackChoice });
}
// ── Kleur preview genereren (stap 3) ─────────────────────────────────────────
async function generateColorPreview() {
document.getElementById('p3Generating').style.display = 'block';
document.getElementById('p3Result').style.display = 'none';
document.getElementById('p3Error').style.display = 'none';
document.getElementById('btn3next').disabled = true;
const dressColorEn = {'Wit':'bright white','Zwart':'jet black','Rood':'bright red','Paars':'deep purple'}[state.dressColor] || state.dressColor;
const suitColorEn = {'Zwart':'jet black','Wit':'bright white','Beige':'beige/tan','Blauw':'navy blue'}[state.suitColor] || state.suitColor;
const baseImgMap = {
'bb_klassiek':'/images/vb%20klassiek%20nieuw.png','bb_karikatuur':'/images/vb%20karikatuur%20nieuw.png','bb_afterparty':'/images/vb%20afterparty%20nieuw.png',
'2b_klassiek':'/images/2%20bruiden%20klassiek.png','2b_karikatuur':'/images/2%20bruiden%20karikatuur.png','2b_afterparty':'/images/2%20bruiden%20afterparty.png',
'2g_klassiek':'/images/2%20grooms%20Klassiek.png','2g_karikatuur':'/images/2%20grooms%20karikatuur.png','2g_afterparty':'/images/2%20grooms%20afterparty.png',
};
const prompt = [
'IMAGE EDITING MODE: Adjust only the clothing colors of this wedding cake topper figurine.',
'Do not change faces, heads, hairstyles, body pose, or composition.',
'Only modify:',
'- dress/bride outfit color: change to ' + dressColorEn,
'- suit/groom outfit color: change to ' + suitColorEn,
'Keep the faces as blank/generic as they are in the template.',
'Preserve exactly: composition, framing, pedestal, body pose.',
'Keep the entire topper inside the frame with white margin around it.',
].join('\n');
try {
const formData = new FormData();
formData.append('model', 'gpt-image-1');
formData.append('prompt', prompt);
formData.append('n', '1');
formData.append('quality', 'high');
const baseImgUrl = baseImgMap[state.style];
if(baseImgUrl) {
const baseRes = await fetch(baseImgUrl);
const baseBlob = await baseRes.blob();
formData.append('image[]', baseBlob, 'base-template.png');
}
const res = await fetch('/api/imagine-edit', { method: 'POST', body: formData });
const data = await res.json();
let imageUrl = data.data?.[0]?.url || (data.data?.[0]?.b64_json ? 'data:image/png;base64,' + data.data[0].b64_json : null) || (data.output?.[0]?.b64_json ? 'data:image/png;base64,' + data.output[0].b64_json : null);
if(imageUrl) {
state.colorPreviewUrl = imageUrl;
document.getElementById('p3Img').src = imageUrl;
document.getElementById('p3Generating').style.display = 'none';
document.getElementById('p3Result').style.display = 'block';
document.getElementById('btn3next').disabled = false;
} else {
throw new Error(data.error?.message || 'Geen afbeelding ontvangen');
}
} catch(e) {
document.getElementById('p3Generating').style.display = 'none';
document.getElementById('p3Error').style.display = 'block';
document.getElementById('p3ErrorMsg').textContent = 'Er ging iets mis: ' + e.message;
}
}
// ── Gezicht persoon 1 genereren (stap 5) ─────────────────────────────────────
async function generatePerson1(useNewPhotos) {
document.getElementById('p5Generating').style.display = 'block';
document.getElementById('p5Result').style.display = 'none';
document.getElementById('p5Error').style.display = 'none';
const baseImg = state.colorPreviewUrl || state.generatedImageUrl;
const photos = [];
if(useNewPhotos) {
if(state.person1new1) photos.push(state.person1new1);
if(state.person1new2) photos.push(state.person1new2);
} else {
const b1 = document.getElementById('box1')?.querySelector('img');
const b2 = document.getElementById('box2')?.querySelector('img');
if(b1 && b1.src.startsWith('data:')) photos.push(b1.src);
if(b2 && b2.src.startsWith('data:')) photos.push(b2.src);
}
const faceStyleMap = {'bb_klassiek':'small realistic proportional faces','bb_karikatuur':'large oversized cartoon chibi heads','bb_afterparty':'natural sized expressive faces','2b_klassiek':'small realistic proportional faces','2b_karikatuur':'large oversized cartoon chibi heads','2b_afterparty':'natural sized expressive faces','2g_klassiek':'small realistic proportional faces','2g_karikatuur':'large oversized cartoon chibi heads','2g_afterparty':'natural sized expressive faces'};
const faceStyle = faceStyleMap[state.style] || 'proportional realistic faces';
const prompt = [
'IMAGE EDIT MODE — PERSON 1 ONLY',
'',
'The uploaded topper image is the MASTER IMAGE.',
'This is an image edit, not a new render.',
'',
'PERSON 1 IS UNLOCKED.',
'PERSON 2 IS LOCKED.',
'',
'Person 1 is the person whose photos were uploaded in Step 4.',
'Person 2 is the person whose photos will be uploaded in Step 6 or is still blank/generic.',
'Never identify persons by left/right position.',
'',
'EDIT ONLY THE FACE AND HAIR AREA OF PERSON 1.',
'',
'Do NOT redesign the topper.',
'Do NOT create a new image.',
'Do NOT reinterpret the pose.',
'Do NOT change the body.',
'Do NOT change the dress.',
'Do NOT change the suit.',
'Do NOT change hands, arms, legs, shoes or accessories.',
'Do NOT change the pedestal.',
'Do NOT change the text on the pedestal.',
'Do NOT change the composition, framing, camera angle, zoom level or colors.',
'',
'PERSON 2 MUST REMAIN COMPLETELY UNCHANGED.',
'Do not improve person 2.',
'Do not reinterpret person 2.',
'Do not regenerate person 2.',
'Do not add facial features to person 2.',
'If person 2 has a blurred, blank or generic face, keep that exact blurred/blank/generic face.',
'Leave person 2 hair, head, body, clothing, pose and expression exactly as shown.',
'',
'The only task:',
'Transfer the facial identity from the uploaded Person 1 reference photos onto the existing figurine face of Person 1.',
'',
'Do not paste a photo onto the figurine.',
'Do not make the face look like a flat photo cut-out.',
'Create a sculpted wedding cake topper version of Person 1.',
'Keep the same glossy 3D figurine material and lighting as the master image.',
'Preserve the original figurine style: ' + faceStyle + '.',
'',
'',
'CAKE TOPPER FACE STYLE — CRITICAL:',
'',
'Do NOT place, paste, overlay or blend a photo face onto the figurine.',
'The face must look sculpted into the same glossy 3D cake-topper material as the original model.',
'Use the uploaded photos only as identity reference — not as a face to paste.',
'',
'The result must look like the face was handcrafted as part of the original figurine:',
'- same skin/material style as the topper',
'- same lighting and shadows',
'- same smooth plastic/ceramic surface',
'- no flat photo texture',
'- no realistic photo skin',
'- no cut-out face',
'- no hard edges around the face',
'',
'Create a recognizable STYLIZED likeness, not a photorealistic face.',
'Keep the face simplified, smooth and sculpted.',
'Avoid photographic detail, pores, wrinkles or flat skin texture.',
'',
'Capture these identity cues from Person 1 photos (in order of priority):',
'1. face shape and jawline',
'2. hairstyle and hair color',
'3. eye shape and eyebrows',
'4. nose shape',
'5. mouth and smile',
'',
'Render each feature in the same smooth sculpted figurine style as the rest of the topper.',
'Herkenbaarheid is belangrijk, but figurine style always wins over photorealism.',
'',
'Everything outside the face and hair region of Person 1 must remain unchanged.',
'If uncertain, change less rather than more.',
].join('\n');
try {
const formData = new FormData();
formData.append('model', 'gpt-image-1');
formData.append('prompt', prompt);
formData.append('n', '1');
formData.append('quality', 'high');
if(baseImg) {
const baseRes = await fetch(baseImg);
const baseBlob = await baseRes.blob();
formData.append('image[]', baseBlob, 'approved-step5-person1-locked-master.png');
}
for(let i=0; i<photos.length; i++) {
const r = await fetch(photos[i]);
const b = await r.blob();
formData.append('image[]', b, 'person1-ref-' + i + '.jpg');
}
const res = await fetch('/api/imagine-edit', { method: 'POST', body: formData });
const data = await res.json();
let imageUrl = data.data?.[0]?.url || (data.data?.[0]?.b64_json ? 'data:image/png;base64,' + data.data[0].b64_json : null) || (data.output?.[0]?.b64_json ? 'data:image/png;base64,' + data.output[0].b64_json : null);
if(imageUrl) {
state.person1ImageUrl = imageUrl;
document.getElementById('p5Img').src = imageUrl;
document.getElementById('p5Generating').style.display = 'none';
document.getElementById('p5Result').style.display = 'block';
document.getElementById('p5FeedbackBox').style.display = 'none';
} else {
throw new Error(data.error?.message || 'Geen afbeelding ontvangen');
}
} catch(e) {
document.getElementById('p5Generating').style.display = 'none';
document.getElementById('p5Error').style.display = 'block';
document.getElementById('p5ErrorMsg').textContent = 'Er ging iets mis: ' + e.message;
}
}
function toggleP5Feedback() {
const box = document.getElementById('p5FeedbackBox');
box.style.display = box.style.display === 'none' ? 'block' : 'none';
}
async function regeneratePerson1() {
document.getElementById('p5FeedbackBox').style.display = 'none';
document.getElementById('p5Result').style.display = 'none';
await generatePerson1(true);
}
function approvePerson1() {
const approvedImg = document.getElementById('p5Img')?.src || state.person1ImageUrl;
state.person1ImageUrl = approvedImg;
state.person1ApprovedMasterUrl = approvedImg;
state.generatedImageUrl = approvedImg;
state.person1Approved = true;
nextStep();
}
// ── Gezicht persoon 2 genereren (stap 7) ─────────────────────────────────────
async function generatePerson2(useNewPhotos) {
document.getElementById('p7Generating').style.display = 'block';
document.getElementById('p7Result').style.display = 'none';
document.getElementById('p7Error').style.display = 'none';
const baseImg = state.person1ApprovedMasterUrl || state.person1ImageUrl || state.colorPreviewUrl;
if(!state.person1ApprovedMasterUrl && state.person1ImageUrl) {
state.person1ApprovedMasterUrl = state.person1ImageUrl;
}
const photos = [];
if(useNewPhotos) {
if(state.person2new1) photos.push(state.person2new1);
if(state.person2new2) photos.push(state.person2new2);
} else {
const b5 = document.getElementById('box5')?.querySelector('img');
const b6 = document.getElementById('box6')?.querySelector('img');
if(b5 && b5.src.startsWith('data:')) photos.push(b5.src);
if(b6 && b6.src.startsWith('data:')) photos.push(b6.src);
}
const faceStyleMap = {'bb_klassiek':'small realistic proportional faces','bb_karikatuur':'large oversized cartoon chibi heads','bb_afterparty':'natural sized expressive faces','2b_klassiek':'small realistic proportional faces','2b_karikatuur':'large oversized cartoon chibi heads','2b_afterparty':'natural sized expressive faces','2g_klassiek':'small realistic proportional faces','2g_karikatuur':'large oversized cartoon chibi heads','2g_afterparty':'natural sized expressive faces'};
const faceStyle = faceStyleMap[state.style] || 'proportional realistic faces';
if(!state.person1ApprovedMasterUrl) {
console.warn('Stap 7 gebruikt geen expliciet goedgekeurde Person 1 master; fallback wordt gebruikt.');
}
const prompt = [
'IMAGE EDIT MODE — PERSON 2 ONLY',
'',
'The uploaded topper image is the MASTER IMAGE.',
'This is an image edit, not a new render.',
'',
'PERSON 1 IS LOCKED.',
'PERSON 2 IS UNLOCKED.',
'',
'Person 1 is the approved person already generated in Step 5.',
'Person 2 is the person whose photos were uploaded in Step 6.',
'Never identify persons by left/right position.',
'',
'EDIT ONLY THE FACE AND HAIR AREA OF PERSON 2.',
'',
'Do NOT redesign the topper.',
'Do NOT create a new image.',
'Do NOT reinterpret the pose.',
'Do NOT change the body.',
'Do NOT change the dress.',
'Do NOT change the suit.',
'Do NOT change hands, arms, legs, shoes or accessories.',
'Do NOT change the pedestal.',
'Do NOT change the text on the pedestal.',
'Do NOT change the composition, framing, camera angle, zoom level or colors.',
'',
'PERSON 1 MUST REMAIN COMPLETELY UNCHANGED.',
'Person 1 was already approved by the customer in Step 5.',
'The uploaded master image already contains the approved Person 1.',
'Person 1 is a locked layer.',
'Do not improve Person 1.',
'Do not reinterpret Person 1.',
'Do not regenerate Person 1.',
'Do not change Person 1 in any way:',
'- not the face',
'- not the hair',
'- not the head shape',
'- not the eyes',
'- not the nose',
'- not the mouth',
'- not the smile',
'- not the expression',
'- not the body',
'- not the clothing',
'- not the pose',
'- not the hands',
'- not the lighting or shadows on Person 1',
'Leave Person 1 exactly pixel-stable compared to the uploaded master image.',
'If editing Person 2 would require changing Person 1, do not do it.',
'',
'The only task:',
'Transfer the facial identity from the uploaded Person 2 reference photos onto the existing figurine face of Person 2.',
'Use ONLY the Person 2 reference photos for this step.',
'Do not use Person 2 photos to alter or improve Person 1.',
'',
'Do not paste a photo onto the figurine.',
'Do not make the face look like a flat photo cut-out.',
'Create a sculpted wedding cake topper version of Person 2.',
'Keep the same glossy 3D figurine material and lighting as the master image.',
'Preserve the original figurine style: ' + faceStyle + '.',
'',
'',
'CAKE TOPPER FACE STYLE — CRITICAL:',
'',
'Do NOT place, paste, overlay or blend a photo face onto the figurine.',
'The face must look sculpted into the same glossy 3D cake-topper material as the original model.',
'Use the uploaded photos only as identity reference — not as a face to paste.',
'',
'The result must look like the face was handcrafted as part of the original figurine:',
'- same skin/material style as the topper',
'- same lighting and shadows',
'- same smooth plastic/ceramic surface',
'- no flat photo texture',
'- no realistic photo skin',
'- no cut-out face',
'- no hard edges around the face',
'',
'Create a recognizable STYLIZED likeness, not a photorealistic face.',
'Keep the face simplified, smooth and sculpted.',
'Avoid photographic detail, pores, wrinkles or flat skin texture.',
'',
'Capture these identity cues from Person 2 photos (in order of priority):',
'1. face shape and jawline',
'2. hairstyle and hair color',
'3. eye shape and eyebrows',
'4. nose shape',
'5. mouth and smile',
'',
'Render each feature in the same smooth sculpted figurine style as the rest of the topper.',
'Herkenbaarheid is belangrijk, but figurine style always wins over photorealism.',
'',
'Everything outside the face and hair region of Person 2 must remain unchanged.',
'If uncertain, change less rather than more.',
].join('\n');
try {
const formData = new FormData();
formData.append('model', 'gpt-image-1');
formData.append('prompt', prompt);
formData.append('n', '1');
formData.append('quality', 'high');
if(baseImg) {
const baseRes = await fetch(baseImg);
const baseBlob = await baseRes.blob();
formData.append('image[]', baseBlob, 'approved-step5-person1-locked-master.png');
}
for(let i=0; i<photos.length; i++) {
const r = await fetch(photos[i]);
const b = await r.blob();
formData.append('image[]', b, 'person2-ref-' + i + '.jpg');
}
const res = await fetch('/api/imagine-edit', { method: 'POST', body: formData });
const data = await res.json();
let imageUrl = data.data?.[0]?.url || (data.data?.[0]?.b64_json ? 'data:image/png;base64,' + data.data[0].b64_json : null) || (data.output?.[0]?.b64_json ? 'data:image/png;base64,' + data.output[0].b64_json : null);
if(imageUrl) {
state.generatedImageUrl = imageUrl;
document.getElementById('p7Img').src = imageUrl;
document.getElementById('p7Generating').style.display = 'none';
document.getElementById('p7Result').style.display = 'block';
document.getElementById('p7FeedbackBox').style.display = 'none';
} else {
throw new Error(data.error?.message || 'Geen afbeelding ontvangen');
}
} catch(e) {
document.getElementById('p7Generating').style.display = 'none';
document.getElementById('p7Error').style.display = 'block';
document.getElementById('p7ErrorMsg').textContent = 'Er ging iets mis: ' + e.message;
}
}
function toggleP7Feedback() {
const box = document.getElementById('p7FeedbackBox');
box.style.display = box.style.display === 'none' ? 'block' : 'none';
}
async function regeneratePerson2() {
document.getElementById('p7FeedbackBox').style.display = 'none';
document.getElementById('p7Result').style.display = 'none';
await generatePerson2(true);
}
function approvePerson2() {
state.person2Approved = true;
nextStep();
}
function approveDesign() {
state.approved = true;
// (legacy btn5 ref removed)
document.querySelector('.approve-actions').innerHTML = '<p style="font-family:sans-serif;font-size:14px;color:#C9A96E;margin:0.5rem auto;text-align:center;"><i class="ti ti-check" style="margin-right:4px;"></i>Ontwerp goedgekeurd! Je kunt verder naar stap 6.</p>';
}
async function generateFinalPreview() {
document.getElementById('p9Generating').style.display = 'block';
document.getElementById('p9Result').style.display = 'none';
document.getElementById('p9Error').style.display = 'none';
const baseImg = state.generatedImageUrl || state.person1ImageUrl || state.colorPreviewUrl;
const textLine = state.textNames ? state.textNames + (state.textDate ? ' · ' + state.textDate : '') : '';
const prompt = [
'IMAGE EDIT MODE',
'',
'The uploaded topper image is the MASTER IMAGE.',
'',
'Do NOT change the figurines.',
'Do NOT change the faces.',
'Do NOT change the pose.',
'Do NOT change the clothing.',
'Do NOT change the colors.',
'Do NOT change the composition.',
'',
textLine
? 'The only task is: update the text on the pedestal (sokkel) at the bottom of the topper.'
: 'No text changes needed. Keep the pedestal exactly as shown.',
'',
textLine
? 'Replace any existing text on the pedestal with the following text in the same elegant gold script style:'
: '',
textLine
? '"' + textLine + '"'
: '',
'',
'Keep the same font style, color (gold/bronze) and position on the pedestal.',
'Keep the WeddingCakeTopper.nl branding and heart symbol.',
'',
'Everything outside the pedestal text area must remain 100% unchanged.',
'Preserve 100% of the original image outside the pedestal text.',
].join('\n');
try {
const formData = new FormData();
formData.append('model', 'gpt-image-1');
formData.append('prompt', prompt);
formData.append('n', '1');
formData.append('quality', 'high');
if(baseImg) {
const baseRes = await fetch(baseImg);
const baseBlob = await baseRes.blob();
formData.append('image[]', baseBlob, 'master.png');
}
const res = await fetch('/api/imagine-edit', { method: 'POST', body: formData });
const data = await res.json();
let imageUrl = data.data?.[0]?.url
|| (data.data?.[0]?.b64_json ? 'data:image/png;base64,' + data.data[0].b64_json : null)
|| (data.output?.[0]?.b64_json ? 'data:image/png;base64,' + data.output[0].b64_json : null);
if(imageUrl) {
state.finalImageUrl = imageUrl;
document.getElementById('p9Img').src = imageUrl;
document.getElementById('p9DownloadBtn').href = imageUrl;
document.getElementById('p9Generating').style.display = 'none';
document.getElementById('p9Result').style.display = 'block';
document.getElementById('btn9next').disabled = false;
} else {
throw new Error(data.error?.message || 'Geen afbeelding ontvangen');
}
} catch(e) {
document.getElementById('p9Generating').style.display = 'none';
document.getElementById('p9Error').style.display = 'block';
document.getElementById('p9ErrorMsg').textContent = 'Er ging iets mis: ' + e.message;
}
}
function buildSummary() {
// Show final image in summary if available
if(state.finalImageUrl || state.generatedImageUrl) {
const imgUrl = state.finalImageUrl || state.generatedImageUrl;
const imgEl = document.createElement('div');
imgEl.style.cssText = 'grid-column:1/-1;text-align:center;margin-bottom:1rem;';
imgEl.innerHTML = '<img src="' + imgUrl + '" style="width:100%;max-width:300px;border-radius:12px;border:1.5px solid #E8D5B7;">';
document.getElementById('summaryGrid').appendChild(imgEl);
}
document.getElementById('summaryGrid').innerHTML +=
'<div class="summary-item"><div class="summary-key">Combinatie</div><div class="summary-val">'+(state.coupleType||'—')+'</div></div>'+
'<div class="summary-item"><div class="summary-key">Stijl</div><div class="summary-val">'+(state.styleName||'—')+'</div></div>'+
'<div class="summary-item"><div class="summary-key">Formaat</div><div class="summary-val">'+(state.size==='groot'?'Groot · 19 cm':'Klein · 12 cm')+'</div></div>'+
'<div class="summary-item"><div class="summary-key">Jurk</div><div class="summary-val">'+state.dressColor+'</div></div>'+
'<div class="summary-item"><div class="summary-key">Pak</div><div class="summary-val">'+state.suitColor+'</div></div>'+
'<div class="summary-item" style="grid-column:1/-1"><div class="summary-key">Namen</div><div class="summary-val">'+(state.textNames||'—')+'</div></div>'+
'<div class="summary-item" style="grid-column:1/-1"><div class="summary-key">Datum</div><div class="summary-val">'+(state.textDate||'—')+'</div></div>';
document.getElementById('totalPrice').textContent = '€'+state.price;
}
async function placeOrder() {
const email = document.getElementById('email').value;
const fname = document.getElementById('fname').value;
const lname = document.getElementById('lname').value;
const phone = document.getElementById('phone').value;
const wdate = document.getElementById('wdate') ? document.getElementById('wdate').value : '';
const remarks = document.getElementById('remarks').value;
if(!fname || !email){
alert('Vul minimaal je naam en e-mailadres in.');
return;
}
if(!document.getElementById('chkVariation')?.checked || !document.getElementById('chkCustomProduct')?.checked){
alert('Ga akkoord met beide voorwaarden voordat je de bestelling plaatst.');
return;
}
const order = {
firstName: fname,
lastName: lname,
email,
phone,
weddingDate: wdate,
remarks,
amount: state.price,
currency: 'EUR',
product: {
coupleType: state.coupleType,
style: state.styleName,
styleKey: state.style,
size: state.size,
dressColor: state.dressColor,
suitColor: state.suitColor,
textNames: state.textNames,
textDate: state.textDate
},
generatedImageUrl: state.generatedImageUrl || null
};
try {
// Deze endpoint moet server-side worden gemaakt met Adyen Checkout.
// Server response verwacht: { paymentUrl: "https://checkoutshopper-live.adyen.com/..." }
const res = await fetch('/api/create-ideal-checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(order)
});
const data = await res.json();
if(data.paymentUrl) {
window.location.href = data.paymentUrl;
return;
}
throw new Error(data.error || 'Geen betaallink ontvangen.');
} catch(e) {
alert('Betaling starten lukt nog niet: ' + e.message + '\n\nVoor nu wordt de bestelling als test opgeslagen.');
state.step = 7;
showPanel(7);
document.getElementById('finalPrice').textContent = '€'+state.price+' · Testbestelling ontvangen';
}
}
buildProgress();
</script>
</body>
</html>