3dct-client-mirror/snapshot-2026-08-15/var-www/3dcaketopper.nl/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

563 lines
36 KiB
HTML

<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Birthday Cake Topper — Ontwerptool</title>
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,600;0,700;1,600&family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0}
:root{
--pink:#E8477A;--pink-dark:#C9346A;--pink-light:#F9D0E0;--pink-pale:#FFF0F5;
--gold:#C9A96E;--dark:#1A0A10;--text:#3D1A26;--muted:#9B7A85;
--white:#fff;--bg:#FFFBFD;--soft:#FAF5F7;--ok:#26A269;--warn:#D99A21;
}
body{font-family:Inter,sans-serif;background:var(--bg);color:var(--text);overflow-x:hidden}
.header{text-align:center;padding:1.2rem 1rem;background:#fff;border-bottom:1px solid var(--pink-light)}
.logo{height:96px;object-fit:contain}
.hero{background:linear-gradient(160deg,#FFF0F5 0%,#FFFBF0 58%,#FFF0F5 100%);padding:2.1rem 1rem 1.3rem;text-align:center}
.tag{display:inline-block;background:var(--pink);color:#fff;font-size:10px;font-weight:900;letter-spacing:2.3px;text-transform:uppercase;padding:.38rem 1rem;border-radius:999px;margin-bottom:1rem}
h1{font-family:"Playfair Display",Georgia,serif;font-size:clamp(30px,5vw,48px);line-height:1.08;color:var(--dark);margin-bottom:.6rem}
h1 em{color:var(--pink)}
.hero p{color:var(--muted);font-size:15px;line-height:1.65;max-width:650px;margin:auto}
.divider{width:44px;height:2px;background:linear-gradient(90deg,var(--gold),var(--pink));border-radius:2px;margin:1.1rem auto 0}
.shell{max-width:1200px;margin:auto;padding:2rem 1rem 4rem}
.progress{background:#fff;border:1.5px solid var(--pink-light);border-radius:24px;padding:1rem;box-shadow:0 14px 40px rgba(232,71,122,.08);position:sticky;top:0;z-index:20}
.progress-top{display:flex;justify-content:space-between;gap:1rem;align-items:center;margin-bottom:.8rem}
.progress-title{font-weight:900;color:var(--dark)}
.progress-step{font-size:12px;color:var(--muted)}
.bar{height:8px;background:var(--pink-pale);border-radius:999px;overflow:hidden}
.fill{height:100%;width:12.5%;background:linear-gradient(90deg,var(--pink),var(--gold));border-radius:999px;transition:.25s}
.tabs{display:grid;grid-template-columns:repeat(8,1fr);gap:6px;margin-top:1rem}
.tab{border:1px solid var(--pink-light);background:#fff;border-radius:14px;padding:.62rem .3rem;text-align:center;font-size:10px;font-weight:900;color:var(--muted)}
.tab.active{background:var(--pink);border-color:var(--pink);color:#fff;box-shadow:0 10px 24px rgba(232,71,122,.23)}
.tab.done{background:var(--pink-pale);color:var(--pink)}
.panel{display:none;margin-top:1.8rem}
.panel.active{display:block}
.panel-head{text-align:center;margin-bottom:1.4rem}
.label{font-size:10px;font-weight:900;letter-spacing:2.5px;text-transform:uppercase;color:var(--gold);margin-bottom:.35rem}
h2{font-family:"Playfair Display",Georgia,serif;font-size:clamp(26px,3.5vw,38px);color:var(--dark)}
.desc{font-size:14px;color:var(--muted);line-height:1.6;margin:.45rem auto 0;max-width:680px}
.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:18px}
.card{background:#fff;border:1.5px solid var(--pink-light);border-radius:24px;overflow:hidden;cursor:pointer;transition:.2s;box-shadow:0 8px 30px rgba(232,71,122,.06);position:relative}
.card:hover{transform:translateY(-3px);border-color:var(--pink);box-shadow:0 18px 42px rgba(232,71,122,.14)}
.card.selected{border:3px solid var(--pink);box-shadow:0 18px 44px rgba(232,71,122,.22)}
.imgbox{height:285px;background:linear-gradient(180deg,#fff,#FAF5F7);display:flex;align-items:flex-end;justify-content:center;padding:12px 12px 0}
.imgbox img{max-width:100%;max-height:270px;object-fit:contain;object-position:center bottom}
.card-body{padding:1rem;text-align:center}
.card h3{font-family:"Playfair Display",Georgia,serif;font-size:22px;color:var(--dark);margin-bottom:.25rem}
.card p{font-size:12px;color:var(--muted);line-height:1.45;min-height:34px}
.pill{display:inline-flex;margin-top:.8rem;background:var(--pink-pale);color:var(--pink);border:1px solid var(--pink-light);border-radius:999px;padding:.35rem .7rem;font-size:11px;font-weight:900}
.check{position:absolute;top:12px;right:12px;width:30px;height:30px;border-radius:50%;background:#fff;border:2px solid var(--pink-light);display:flex;align-items:center;justify-content:center;color:#fff;font-weight:900}
.selected .check{background:var(--pink);border-color:var(--pink)}
.toolbar{display:flex;justify-content:space-between;align-items:center;gap:1rem;background:#fff;border:1.5px solid var(--pink-light);border-radius:18px;padding:.85rem 1rem;margin-bottom:1rem}
.backlink{border:0;background:transparent;color:var(--pink);font-weight:900;cursor:pointer;font-size:14px}
.design-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:18px}
.design-img{height:320px;background:linear-gradient(180deg,#fff,#FAF5F7);display:flex;align-items:flex-end;justify-content:center;padding:12px 12px 0}
.design-img img{max-width:100%;max-height:305px;object-fit:contain;object-position:center bottom}
.design-name{padding:.85rem;text-align:center;font-weight:900;color:var(--dark);font-size:14px;border-top:1px solid var(--pink-light)}
.form{background:#fff;border:1.5px solid var(--pink-light);border-radius:24px;padding:1.5rem;box-shadow:0 12px 36px rgba(232,71,122,.08);max-width:850px;margin:auto}
.colors{display:grid;grid-template-columns:repeat(6,1fr);gap:12px}
.color{border:1.5px solid var(--pink-light);background:#fff;border-radius:18px;padding:.9rem;text-align:center;cursor:pointer;font-weight:900;color:var(--text);font-size:14px}
.dot{width:34px;height:34px;border-radius:50%;margin:0 auto .55rem;border:2px solid rgba(0,0,0,.08)}
.color.selected{border:3px solid var(--pink);background:var(--pink-pale)}
input,textarea{width:100%;border:1.5px solid var(--pink-light);border-radius:14px;background:#fff;padding:1rem;font:15px Inter,sans-serif;color:var(--text);outline:none}
input:focus,textarea:focus{border-color:var(--pink);box-shadow:0 0 0 4px rgba(232,71,122,.1)}
label{display:block;font-size:12px;text-transform:uppercase;letter-spacing:1.4px;font-weight:900;color:var(--gold);margin-bottom:.45rem}
.two{display:grid;grid-template-columns:1fr 1fr;gap:1rem}
.upload{background:var(--pink-pale);border:2px dashed var(--pink);border-radius:22px;padding:2rem;text-align:center;position:relative}
.upload input{position:absolute;inset:0;opacity:0;cursor:pointer}
.upload-icon{font-size:42px;margin-bottom:.7rem}
.upload h3{font-family:"Playfair Display",Georgia,serif;color:var(--dark);font-size:24px}
.upload p{color:var(--muted);font-size:13px;line-height:1.6;margin:.5rem auto 0;max-width:540px}
.note{font-size:12px;color:var(--muted);margin-top:.85rem;line-height:1.5}
.summary{display:grid;grid-template-columns:330px 1fr;gap:1.4rem;align-items:start}
.summary-img{background:#fff;border:1.5px solid var(--pink-light);border-radius:24px;min-height:380px;display:flex;align-items:flex-end;justify-content:center;padding:16px}
.summary-img img{max-width:100%;max-height:350px;object-fit:contain}
.summary-list{background:#fff;border:1.5px solid var(--pink-light);border-radius:24px;padding:1.4rem}
.row{display:flex;justify-content:space-between;gap:1rem;padding:.85rem 0;border-bottom:1px solid var(--pink-light)}
.row:last-child{border-bottom:0}
.row span:first-child{font-size:12px;font-weight:900;color:var(--gold);text-transform:uppercase;letter-spacing:1.2px}
.row span:last-child{text-align:right;font-weight:800;color:var(--dark)}
.ai-card{background:#fff;border:1.5px solid var(--pink-light);border-radius:24px;padding:1.4rem;margin-top:1.25rem;box-shadow:0 12px 36px rgba(232,71,122,.08)}
.ai-card h3{font-family:"Playfair Display",Georgia,serif;color:var(--dark);font-size:25px;margin-bottom:.45rem}
.ai-card p{font-size:13px;color:var(--muted);line-height:1.6;margin-bottom:1rem}
.ai-actions{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
.status{font-size:13px;font-weight:800;color:var(--muted)}
.result{margin-top:1rem;background:var(--soft);border:1px solid var(--pink-light);border-radius:18px;min-height:220px;display:none;align-items:center;justify-content:center;padding:22px;text-align:center;overflow:hidden}
.result img{max-width:100%;max-height:760px;object-fit:contain;border-radius:12px;display:block;margin:auto}
/* Master goedkeuring */
.approve-box{background:#fff;border:2px solid var(--ok);border-radius:24px;padding:1.4rem;margin-top:1rem}
.approve-box h3{font-family:"Playfair Display",Georgia,serif;color:var(--ok);font-size:20px;margin-bottom:.4rem}
.approve-box p{font-size:13px;color:var(--muted);line-height:1.6;margin-bottom:1rem}
.approve-actions{display:flex;gap:10px;flex-wrap:wrap}
.btn-approve{background:var(--ok);color:#fff;border:0;border-radius:999px;padding:.85rem 1.6rem;font-weight:900;font-size:14px;cursor:pointer}
.btn-retry{background:#fff;color:var(--muted);border:1.5px solid var(--pink-light);border-radius:999px;padding:.85rem 1.6rem;font-weight:900;font-size:14px;cursor:pointer}
/* Upload foto stap 7 */
.foto-block{background:var(--pink-pale);border:2px dashed var(--pink);border-radius:22px;padding:2rem;text-align:center;position:relative;max-width:560px;margin:1rem auto}
.foto-block input{position:absolute;inset:0;opacity:0;cursor:pointer}
.foto-block .upload-icon{font-size:42px;margin-bottom:.7rem}
/* Eindpreview */
.final-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:1rem}
.final-card{background:#fff;border:1.5px solid var(--pink-light);border-radius:20px;padding:1rem;text-align:center}
.final-card h4{font-size:11px;font-weight:900;letter-spacing:1.5px;text-transform:uppercase;color:var(--gold);margin-bottom:.6rem}
.final-card img{max-width:100%;max-height:520px;object-fit:contain;border-radius:12px}
.missing{color:var(--muted);font-size:13px;padding:2rem;text-align:center}
.nav{display:flex;justify-content:space-between;gap:1rem;margin-top:1.4rem}
.btn{border:0;border-radius:999px;padding:1rem 1.6rem;font-weight:900;font-size:15px;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:.45rem;transition:.2s}
.btn-primary{background:linear-gradient(135deg,var(--pink),var(--pink-dark));color:#fff;box-shadow:0 8px 28px rgba(232,71,122,.35)}
.btn-secondary{background:#fff;color:var(--pink);border:1.5px solid var(--pink-light)}
.btn:disabled{opacity:.45;cursor:not-allowed;box-shadow:none}
.footer{background:var(--dark);padding:1.25rem 1rem;text-align:center;font-size:12px;color:rgba(255,255,255,.28)}
.footer a{color:var(--gold);text-decoration:none}
@media(max-width:980px){.grid,.design-grid{grid-template-columns:repeat(3,1fr)}.summary{grid-template-columns:1fr}.tabs{grid-template-columns:repeat(4,1fr)}.final-grid{grid-template-columns:1fr}}
@media(max-width:680px){.logo{height:78px}.grid,.design-grid{grid-template-columns:repeat(2,1fr);gap:12px}.imgbox{height:220px}.imgbox img{max-height:205px}.design-img{height:260px}.design-img img{max-height:245px}.colors,.two{grid-template-columns:1fr}.progress{position:relative}.nav{flex-direction:column-reverse}.btn{width:100%}.tabs{grid-template-columns:repeat(4,1fr)}}
</style>
</head>
<body>
<header class="header"><img src="/verjaardag/images/Logo.png" class="logo" alt="Birthday Cake Topper"></header>
<section class="hero">
<div class="tag">🎂 Ontwerptool</div>
<h1>Maak jouw eigen<br><em>Birthday Cake Topper</em></h1>
<p>Kies je ontwerp, kleur, leeftijd en naam — de AI genereert eerst een master zonder gezicht. Na goedkeuring upload je de foto.</p>
<div class="divider"></div>
</section>
<main class="shell">
<div class="progress">
<div class="progress-top"><div class="progress-title" id="progressTitle">Kies je ontwerp</div><div class="progress-step" id="progressStep">Stap 1 van 8</div></div>
<div class="bar"><div class="fill" id="progressFill"></div></div>
<div class="tabs" id="tabs"></div>
</div>
<!-- STAP 1: Ontwerp -->
<section class="panel active" data-panel="1">
<div class="panel-head"><p class="label">Stap 1</p><h2>Kies het type jarige</h2><p class="desc">Begin met de categorie. Daarna kies je het specifieke ontwerp.</p></div>
<div id="categoryGrid" class="grid"></div>
<div id="designArea" style="display:none;margin-top:1.4rem">
<div class="toolbar"><button class="backlink" id="backToCategories">← Terug naar categorieën</button><div id="chosenCat" style="font-size:13px;color:var(--muted)"></div></div>
<div id="designGrid" class="design-grid"></div>
</div>
</section>
<!-- STAP 2: Kleur -->
<section class="panel" data-panel="2">
<div class="panel-head"><p class="label">Stap 2</p><h2>Kies de kleur</h2><p class="desc">De AI past de kledingkleur aan in de master-topper.</p></div>
<div class="form"><div class="colors" id="colorGrid"></div><div class="note" id="selectedColorNote" style="text-align:center;font-weight:900;color:var(--pink)"></div></div>
</section>
<!-- STAP 3: Leeftijd -->
<section class="panel" data-panel="3">
<div class="panel-head"><p class="label">Stap 3</p><h2>Kies de leeftijd</h2><p class="desc">Alle zichtbare nummers worden naar deze leeftijd aangepast.</p></div>
<div class="form"><label for="age">Leeftijd</label><input id="age" type="number" min="1" max="120" placeholder="Bijv. 16, 30, 45 of 50"></div>
</section>
<!-- STAP 4: Naam -->
<section class="panel" data-panel="4">
<div class="panel-head"><p class="label">Stap 4</p><h2>Naam op de sokkel</h2><p class="desc">De naam komt op de voorkant van de bestaande sokkel.</p></div>
<div class="form"><div><label for="name">Naam jarige</label><input id="name" placeholder="Bijv. Marjolein"></div></div>
</section>
<!-- STAP 5: Controle voor generatie -->
<section class="panel" data-panel="5">
<div class="panel-head"><p class="label">Stap 5</p><h2>Controle</h2><p class="desc">Klopt alles? Dan genereert de AI een master-topper zonder gezicht.</p></div>
<div class="summary">
<div class="summary-img"><img id="summaryImage" src="" alt="Gekozen ontwerp"></div>
<div class="summary-list" id="summaryList"></div>
</div>
</section>
<!-- STAP 6: AI genereert master (geen gezicht) -->
<section class="panel" data-panel="6">
<div class="panel-head"><p class="label">Stap 6</p><h2>AI genereert master-topper</h2><p class="desc">De AI past kleur, leeftijd en naam aan. Het gezicht blijft blanco — dat voegen we pas toe na jouw goedkeuring.</p></div>
<div class="ai-card">
<h3>Master genereren</h3>
<p>Klik op de knop. De AI genereert de topper met jouw kleur, leeftijd en naam. Gezicht blijft leeg.</p>
<div class="ai-actions">
<button type="button" class="btn btn-primary" id="generateMasterBtn">✨ Genereer master-topper</button>
<span class="status" id="masterStatus">Nog niet gestart</span>
</div>
<div class="result" id="masterResult"></div>
</div>
<!-- Goedkeuring — verschijnt na generatie -->
<div class="approve-box" id="approveBox" style="display:none">
<h3>✅ Ziet de master er goed uit?</h3>
<p>Controleer de kleur, het leeftijdsnummer en de naam op de sokkel. Kloppen die? Dan ga je door naar de foto. Niet tevreden? Genereer opnieuw.</p>
<div class="approve-actions">
<button class="btn-approve" id="approveMasterBtn">👍 Goedkeuren — ga naar foto</button>
<button class="btn-retry" id="retryMasterBtn">🔄 Opnieuw genereren</button>
</div>
</div>
</section>
<!-- STAP 7: Foto uploaden -->
<section class="panel" data-panel="7">
<div class="panel-head"><p class="label">Stap 7</p><h2>Upload de foto</h2><p class="desc">De AI plaatst het gezicht en kapsel van de jarige op de goedgekeurde master-topper.</p></div>
<div class="form">
<div class="upload" id="uploadZone">
<input id="photo" type="file" accept="image/*">
<div class="upload-icon">📸</div>
<h3 id="uploadTitle">Foto van de jarige uploaden</h3>
<p id="uploadText">Klik hier of sleep een foto. Gebruik een duidelijke foto van voren, kapsel volledig zichtbaar.</p>
</div>
<div class="note">Tip: goed licht, gezicht van voren, geen zonnebril, kapsel volledig zichtbaar. De AI gebruikt de foto uitsluitend voor gezicht en kapsel.</div>
</div>
</section>
<!-- STAP 8: Eindpreview -->
<section class="panel" data-panel="8">
<div class="panel-head"><p class="label">Stap 8</p><h2>Eindpreview genereren</h2><p class="desc">De AI vervangt nu alleen hoofd en haar. Lichaam, huidskleur van body, pose, tas, sokkel en tekst blijven gelijk.</p></div>
<div class="ai-card">
<h3>Gezicht inplaatsen</h3>
<p>De goedgekeurde master blijft de basis. Alleen het hoofd- en haargebied wordt aangepast; body blijft volledig ongemoeid.</p>
<div class="ai-actions">
<button type="button" class="btn btn-primary" id="generateFaceBtn">✨ Genereer eindpreview</button>
<span class="status" id="faceStatus">Nog niet gestart</span>
</div>
<div class="result" id="faceResult"></div>
</div>
</section>
<div class="nav"><button class="btn btn-secondary" id="prevBtn">← Vorige stap</button><button class="btn btn-primary" id="nextBtn">Volgende stap →</button></div>
</main>
<footer class="footer">&copy; 2026 BirthdayCakeTopper.nl — onderdeel van <a href="https://www.3dcaketopper.nl">3DCakeTopper.nl</a></footer>
<script>
const enc=s=>s.split('/').map(part=>part.includes(' ')?encodeURIComponent(part):part).join('/');
const base='/verjaardag/images/';
const categories=[
{id:'meisjes',title:'Meisjes',desc:'Van unicorn tot fashion girl',folder:'kind meisje',cover:'kind meisje/Meisje1.png',files:['Meisje1.png','Meisje2.png','Meisje3.png','Meisje4.png','Meisje5.png','Meisje6.png','Meisje7.png','Meisje8.png','Meisje9.png']},
{id:'jongens',title:'Jongens',desc:'Stoer, sportief of speels',folder:'kind jongen',cover:'kind jongen/Jongen1.png',files:['Jongen1.png','Jongen2.png','Jongen3.png','Jongen4.png','Jongen5.png','Jongen6.png']},
{id:'tieners',title:'Tieners',desc:'Sweet Sixteen en ouder',folder:'sweet16',cover:'sweet16/Sweet2 16.png',files:['Sweet 16.png','Sweet2 16.png','Sweet3 16.png','Sweet4 Sixteen.png','Sweet2 16 blauw.png','Sweet2 16 roze.png','Sweet2 18 bauw.png','Sweet2 25 blauw.png']},
{id:'vrouwen',title:'Vrouwen',desc:'Voor iedere leeftijd en stijl',folder:'vrouwen',cover:'vrouwen/Jongedame1.png',files:['Jongedame1.png','Jongedame2.png','Jongedame3.png','Jongedame4.png','Jongedame5.png','Jongedame6.png','Vrouw 1.png','vrouw2.png','Vrouw3.png','Vrouw4.png','Vrouw5.png','Sarah1.png']},
{id:'mannen',title:'Mannen',desc:'Stoer, grappig of klassiek',folder:'mannen',cover:'mannen/Jongeman1.png',files:['Jongeman1.png','Jongeman2.png','Jongeman3.png','Jongeman4.png','Jongeman5.png','Jongeman6.png']},
{id:'sarah',title:'Sarah',desc:'50 jaar en fabulous',folder:'Sarah',cover:'Sarah/Sarah1.png',files:['Sarah1.png','Sarah 1.png','Sarah2.png','Sarah 2.png','Sarah3.png','Sarah 3.png','Sarah4.png','Sarah 4.png','Sarah5.png','Sarah 5.png','Sarah6.png','Sarah 6.png']},
{id:'abraham',title:'Abraham',desc:'Voor de echte levensgenieter',folder:'Abraham',cover:'Abraham/Abraham3.png',files:['Abraham1.png','Abraham 1.png','Abraham2.png','Abraham 2.png','Abraham3.png','Abraham 3.png','Abraham4.png','Abraham 4.png','Abraham5.png','Abraham 5.png','Abraham6.png','Abraham 6.png']}
];
const colors=[['Roze','#E8477A'],['Blauw','#2F69C9'],['Wit','#F0F0F0'],['Goud','#C9A96E'],['Groen','#2D8A4A'],['Zwart','#111']];
const steps=['Ontwerp','Kleur','Leeftijd','Naam','Controle','Master AI','Foto','Eindpreview'];
const state={step:1,category:null,design:null,color:null,age:'',name:'',baseText:'',photoName:'',photoFile:null,masterUrl:'',masterApproved:false,previewUrl:''};
const $=id=>document.getElementById(id);
function path(p){return p&&p.startsWith('/')?enc(p):enc(base+p);}
function renderTabs(){$('tabs').innerHTML=steps.map((s,i)=>`<div class="tab ${state.step===i+1?'active':state.step>i+1?'done':''}">${i+1}. ${s}</div>`).join('');}
function tryNextImage(img){img.style.display='none';const wrap=img.parentElement;if(wrap&&!wrap.querySelector('.missing'))wrap.insertAdjacentHTML('beforeend','<div class="missing">Afbeelding niet gevonden</div>');}
function renderCategories(){
$('categoryGrid').innerHTML=categories.map(c=>`<article class="card ${state.category===c.id?'selected':''}" data-cat="${c.id}"><div class="check">✓</div><div class="imgbox"><img src="${path(c.cover)}" alt="${c.title}" onerror="tryNextImage(this)"></div><div class="card-body"><h3>${c.title}</h3><p>${c.desc}</p><div class="pill">Bekijk ontwerpen</div></div></article>`).join('');
document.querySelectorAll('.card[data-cat]').forEach(el=>el.onclick=()=>selectCategory(el.dataset.cat));
}
function selectCategory(id){state.category=id;state.design=null;const cat=categories.find(c=>c.id===id);$('categoryGrid').style.display='none';$('designArea').style.display='block';$('chosenCat').textContent='Categorie: '+cat.title;renderDesigns(cat);update();}
function renderDesigns(cat){
$('designGrid').innerHTML=cat.files.map((file)=>{const src=path(cat.folder+'/'+file);return`<article class="card design-card ${state.design&&state.design.src===src?'selected':''}" data-src="${src}" data-name="${file}" data-folder="${cat.folder}"><div class="check">✓</div><div class="design-img"><img src="${src}" alt="${file}" onerror="this.closest('.design-card').style.display='none'"></div><div class="design-name">${file}</div></article>`}).join('');
document.querySelectorAll('.design-card').forEach(el=>el.onclick=()=>{state.design={src:el.dataset.src,name:el.dataset.name,folder:el.dataset.folder};renderDesigns(cat);update();});
}
function renderColors(){
$('colorGrid').innerHTML=colors.map(([name,color])=>`<button class="color ${state.color===name?'selected':''}" type="button" onclick="selectColor('${name}')"><div class="dot" style="background:${color}"></div>${name}</button>`).join('');
$('selectedColorNote').textContent=state.color?`Gekozen kleur: ${state.color}`:'Kies hierboven een kleur.';
}
function selectColor(name){state.color=name;renderColors();update();}
function showStep(n){
state.step=Math.max(1,Math.min(8,n));
document.querySelectorAll('.panel').forEach(p=>p.classList.toggle('active',Number(p.dataset.panel)===state.step));
if(state.step===2)renderColors();
if(state.step===5)renderSummary();
update();
window.scrollTo({top:0,behavior:'smooth'});
}
function canNext(){
if(state.step===1)return!!state.design;
if(state.step===2)return!!state.color;
if(state.step===3)return!!state.age;
if(state.step===4)return!!state.name.trim();
if(state.step===6)return state.masterApproved; // moet goedgekeurd zijn
if(state.step===7)return!!state.photoFile;
return true;
}
function update(){
renderTabs();
$('progressTitle').textContent=steps[state.step-1];
$('progressStep').textContent=`Stap ${state.step} van 8`;
$('progressFill').style.width=(state.step/8*100)+'%';
$('prevBtn').style.visibility=state.step===1?'hidden':'visible';
$('nextBtn').disabled=!canNext();
if(state.step===8){
$('nextBtn').style.display='none';
} else {
$('nextBtn').style.display='';
$('nextBtn').textContent='Volgende stap →';
}
}
function renderSummary(){
const cat=categories.find(c=>c.id===state.category);
$('summaryImage').src=state.design?state.design.src:'';
$('summaryList').innerHTML=`
<div class="row"><span>Categorie</span><span>${cat?cat.title:'-'}</span></div>
<div class="row"><span>Ontwerp</span><span>${state.design?state.design.name:'-'}</span></div>
<div class="row"><span>Kleur</span><span>${state.color||'-'}</span></div>
<div class="row"><span>Leeftijd</span><span>${state.age||'-'}</span></div>
<div class="row"><span>Naam sokkel</span><span>${state.name||'-'}</span></div>
`;
}
// ── Helpers ──────────────────────────────────────────────────────────────────
async function fileFromUrl(url,filename){const res=await fetch(url);if(!res.ok)throw new Error('Basisontwerp kon niet worden geladen.');const blob=await res.blob();return new File([blob],filename,{type:blob.type||'image/png'});}
async function fileFromDataUrl(dataUrl,filename){const res=await fetch(dataUrl);const blob=await res.blob();return new File([blob],filename,{type:blob.type||'image/png'});}
function imageFromOpenAIResponse(data){const item=data?.data?.[0];if(!item)return'';if(item.b64_json)return`data:image/png;base64,${item.b64_json}`;if(item.url)return item.url;return'';}
// ── Prompts ──────────────────────────────────────────────────────────────────
function buildMasterPrompt(){
const colorMap={'Zwart':'jet black','Blauw':'navy blue','Groen':'forest green','Roze':'bright pink','Goud':'gold','Wit':'bright white'};
const colorEn = colorMap[state.color] || state.color;
return [
'IMAGE EDITING MODE:',
'Treat the uploaded image as the master composition.',
'Do not recreate the figurine.',
'Do not redesign the figurine.',
'Do not reinterpret the pose.',
'',
'Only modify:',
`- outfit / clothing color only: change the sportswear/clothing to ${colorEn}. Do NOT change skin color, hair color, shoes color or accessory colors.`,
`- every visible age number on the figurine: replace with ${state.age}.`,
`- pedestal text: write "${state.name}" on the front of the existing pedestal in ${colorEn} color.`,
'',
'The face must remain blank, smooth and featureless — no eyes, nose, mouth or hair.',
'Keep the face completely blank as it is in the template.',
'',
`Everything else must remain pixel-for-pixel identical to the original image.`,
'',
'CRITICAL:',
'Preserve exactly:',
'- composition',
'- framing',
'- camera distance',
'- pedestal size',
'- pedestal position',
'- body pose',
'- hand position',
'- leg position',
'- clothing shape',
'The output should look like the original image with only the color, number and pedestal text 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 generous clean white margin around it.',
'Use the uploaded padded white canvas as the exact output frame.',
'Keep the topper centered on that canvas and keep visible white safety space above the head and below the pedestal.',
'Leave approximately 8-12% white margin around the complete topper, especially top and bottom.',
'Do not let the topper touch or cross the image borders.',
'Preserve the original composition and aspect ratio.',
'Expand the canvas if necessary.',
'Never zoom in.',
'Never crop.',
'Never fill the entire canvas with the topper.',
'Always show the full original figurine.',
].join('\n');
}
function buildFacePrompt(){
return [
'STRICT LOCAL EDIT MODE — HEAD AND HAIR ONLY.',
'',
'The FIRST image is the approved locked master topper.',
'The SECOND image is the uploaded photo reference.',
'',
'THIS IS NOT A NEW IMAGE.',
'THIS IS NOT A NEW RENDER.',
'THIS IS NOT A NEW CHARACTER.',
'The approved master is final and must remain the exact base image.',
'',
'EDITABLE AREA:',
'Only the transparent masked head-and-hair area may change.',
'Everything outside this mask is locked and must remain visually identical.',
'',
'TASK:',
'Replace the blank head with a sculpted 3D birthday cake topper head based on the uploaded person.',
'Use the uploaded person\'s facial identity, hair color, hairstyle, hair volume and hair texture.',
'The face and hair must look like part of the same glossy 3D topper material.',
'Do not paste a flat photo. Do not create a photographic cut-out.',
'',
'HEAD SIZE AND PLACEMENT:',
'The new head must fit inside the existing blank head position of the master.',
'Resize the generated head if needed so it fits the original topper proportions.',
'Never make the head oversized.',
'Never move, resize or reshape the body to fit the head.',
'The head must align naturally with the existing neck position.',
'',
'HAIR:',
'Hair may change to match the uploaded photo, but it must stay inside the masked head/hair region.',
'If the uploaded person has curls, create a compact sculpted topper version of those curls inside the head/hair region.',
'Do not let hair cover or redraw shoulders, torso, arms, clothing, bag, bottle or pedestal.',
'',
'BODY IS ABSOLUTELY LOCKED:',
'Do NOT edit the neck below the head connection.',
'Do NOT edit shoulders.',
'Do NOT edit chest, torso, waist, abdominal muscles, arms, hands, fingers, legs or feet.',
'Do NOT edit or recolor the body skin.',
'Do NOT change skin tone of arms, legs, torso or shoulders.',
'Do NOT edit clothing, outfit color, sportswear shape, fabric, bag, bottle, shoes, pedestal, text or background.',
'Do NOT change lighting, shadows, camera angle, zoom, crop or framing.',
'',
'IMPORTANT:',
'Ignore the uploaded photo\'s body, shoulders, clothing and pose completely.',
'Use the uploaded photo only for the head: face identity and hair.',
'The final image must remain exactly aligned with the master.',
'Outside the head-and-hair mask, the output must remain pixel-for-pixel identical to the approved master.',
'No zoom. No crop. No reframing. No redesign.',
].join('\n');
}
// Vergroot het canvas rondom de originele afbeelding
// Voeg 15% padding toe boven en onder zodat de AI nooit hoeft te croppen
async function padCanvas(imageFile, topPct=0.15, bottomPct=0.10){
const img = await new Promise((res,rej)=>{const i=new Image();i.onload=()=>res(i);i.onerror=rej;i.src=URL.createObjectURL(imageFile);});
const origW = img.naturalWidth||img.width;
const origH = img.naturalHeight||img.height;
const padTop = Math.round(origH * topPct);
const padBot = Math.round(origH * bottomPct);
const newH = origH + padTop + padBot;
const c = document.createElement('canvas');
c.width = origW; c.height = newH;
const ctx = c.getContext('2d');
// Witte achtergrond (of transparant — wit matcht de meeste toppers)
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, origW, newH);
// Teken origineel met offset naar beneden
ctx.drawImage(img, 0, padTop, origW, origH);
return new Promise(resolve => c.toBlob(blob => resolve(
new File([blob], 'padded-base.png', {type:'image/png'})
), 'image/png'));
}
// ── STAP 6: Master genereren ─────────────────────────────────────────────────
async function generateMaster(){
const btn=$('generateMasterBtn'),status=$('masterStatus'),result=$('masterResult');
btn.disabled=true;
$('approveBox').style.display='none';
state.masterApproved=false;
result.style.display='flex';
result.innerHTML='<div class="missing">⏳ AI genereert master-topper — kleur, leeftijd en naam worden verwerkt. Gezicht blijft blanco...</div>';
status.textContent='Bezig...';
try{
const fd=new FormData();
fd.append('model','gpt-image-1');
fd.append('prompt',buildMasterPrompt());
fd.append('n','1');
fd.append('quality','high');
const baseFile=await fileFromUrl(state.design.src,'base-topper.png');
// Zelfde frame-fix als bij WeddingCakeTopper: eerst extra wit canvas rondom de template,
// zodat de AI de volledige topper inclusief hoofd, sokkel en tekst binnen het kader houdt.
const paddedBaseFile=await padCanvas(baseFile,0.18,0.14);
fd.append('image[]',paddedBaseFile,'padded-base-topper.png');
const res=await fetch('/api/imagine-edit',{method:'POST',body:fd});
const data=await res.json().catch(()=>({}));
const masterUrl=imageFromOpenAIResponse(data);
if(!res.ok||!masterUrl)throw new Error(data?.error?.message||data?.error||'Geen afbeelding ontvangen.');
state.masterUrl=masterUrl;
result.innerHTML=`<img src="${masterUrl}" alt="Master topper">`;
status.textContent='Master gegenereerd.';
$('approveBox').style.display='block';
update();
}catch(err){
result.innerHTML=`<div class="missing">❌ Fout: ${err.message}</div>`;
status.textContent='Mislukt.';
}finally{
btn.disabled=false;
}
}
// ── STAP 8: Gezicht inplaatsen ───────────────────────────────────────────────
async function makeEditMask(imageFile){
const img=await new Promise((resolve,reject)=>{const i=new Image();i.onload=()=>resolve(i);i.onerror=reject;i.src=URL.createObjectURL(imageFile);});
const c=document.createElement('canvas');c.width=img.naturalWidth||img.width;c.height=img.naturalHeight||img.height;const ctx=c.getContext('2d');
// Wit = locked. Transparant = bewerkbaar.
// V5: extreem beperkte head/hair-mask. Geen nek, schouders, armen, torso of benen.
ctx.fillStyle='rgba(255,255,255,1)';ctx.fillRect(0,0,c.width,c.height);
ctx.globalCompositeOperation='destination-out';
const W=c.width,H=c.height;
const ellipse=(x,y,rx,ry,rot=0)=>{ctx.beginPath();ctx.ellipse(W*x,H*y,W*rx,H*ry,rot,0,Math.PI*2);ctx.fill();};
// Compact hoofd + haarzone. Bewust kleiner dan index46 om body/postuur 100% te locken.
ellipse(0.50,0.185,0.115,0.128,0);
// Beperkte zijruimte voor haar/krullen, zonder schouders of bovenlijf te raken.
ellipse(0.445,0.190,0.065,0.105,-0.15);
ellipse(0.555,0.190,0.065,0.105,0.15);
// Bovenhaar / volume, compact.
ellipse(0.50,0.120,0.105,0.060,0);
// Kleine kin/kaakzone, geen hals/nek-masker.
ellipse(0.50,0.278,0.048,0.035,0);
return new Promise(resolve=>c.toBlob(blob=>resolve(new File([blob],'head-hair-only-mask.png',{type:'image/png'})),'image/png'));
}
async function generateFace(){
if(!state.masterUrl){alert('Geen goedgekeurde master gevonden. Ga terug naar stap 6.');return;}
if(!state.photoFile){alert('Upload eerst een foto in stap 7.');return;}
const btn=$('generateFaceBtn'),status=$('faceStatus'),result=$('faceResult');
btn.disabled=true;
result.style.display='flex';
result.innerHTML='<div class="missing">⏳ AI vervangt alleen het hoofdgebied; body blijft gelockt...</div>';
status.textContent='Bezig...';
try{
const masterFile=await fileFromDataUrl(state.masterUrl,'locked-master.png');
const maskFile=await makeEditMask(masterFile);
const fd=new FormData();
fd.append('model','gpt-image-1');
fd.append('prompt',buildFacePrompt());
fd.append('quality','high');
fd.append('image[]',masterFile,'locked-master.png');
fd.append('image[]',state.photoFile,'face-reference.jpg');
fd.append('mask',maskFile,'head-hair-only-mask.png');
const res=await fetch('/api/imagine-edit',{method:'POST',body:fd});
const data=await res.json().catch(()=>({}));
const finalUrl=imageFromOpenAIResponse(data);
if(!res.ok||!finalUrl)throw new Error(data?.error?.message||data?.error||'Geen eindpreview ontvangen.');
state.previewUrl=finalUrl;
result.innerHTML=`
<div class="final-grid" style="width:100%">
<div class="final-card"><h4>✅ Goedgekeurde master</h4><img src="${state.masterUrl}" alt="Master"></div>
<div class="final-card"><h4>🎉 Eindpreview met gezicht</h4><img src="${finalUrl}" alt="Eindpreview"></div>
</div>`;
status.textContent='Eindpreview klaar!';
}catch(err){
result.innerHTML=`<div class="missing">❌ Fout: ${err.message}</div>`;
status.textContent='Mislukt.';
}finally{
btn.disabled=false;
}
}
// ── Events ───────────────────────────────────────────────────────────────────
$('backToCategories').onclick=()=>{$('categoryGrid').style.display='grid';$('designArea').style.display='none';renderCategories();};
$('prevBtn').onclick=()=>showStep(state.step-1);
$('nextBtn').onclick=()=>{
if(state.step===8)return;
showStep(state.step+1);
};
$('age').addEventListener('input',e=>{state.age=e.target.value;update();});
$('name').addEventListener('input',e=>{state.name=e.target.value;update();});
$('photo').addEventListener('change',e=>{
state.photoFile=e.target.files[0]||null;
state.photoName=state.photoFile?.name||'';
$('uploadTitle').textContent=state.photoName?'✓ Foto geüpload':'Foto van de jarige uploaden';
$('uploadText').textContent=state.photoName||'Klik hier of sleep een foto.';
update();
});
$('generateMasterBtn').addEventListener('click',generateMaster);
$('approveMasterBtn').addEventListener('click',()=>{
state.masterApproved=true;
update();
showStep(7);
});
$('retryMasterBtn').addEventListener('click',()=>{
state.masterApproved=false;
$('approveBox').style.display='none';
generateMaster();
});
$('generateFaceBtn').addEventListener('click',generateFace);
renderCategories();renderColors();update();
</script>
</body>
</html>