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.
This commit is contained in:
Manohar Gupta 2026-08-15 08:57:40 +05:30
commit 596cfcca34
160 changed files with 12475 additions and 0 deletions

21
.gitignore vendored Normal file
View file

@ -0,0 +1,21 @@
# .gitignore — 3dct-client-mirror
# ── Secrets: staged on disk, deliberately NOT in git history ──
# Live values are in snapshot-*/secrets-DO-NOT-COMMIT/ on the local
# filesystem only. Git history is permanent; a committed key can only be
# removed by rewriting history, which breaks every clone. See README.
secrets-DO-NOT-COMMIT/
.env
.env.local
.env.*.local
*.pem
*.key
id_ed25519*
# ── Regenerable, excluded at rsync time ──
node_modules/
.next/
# ── OS / editor detritus ──
.DS_Store
Thumbs.db

86
README.md Normal file
View file

@ -0,0 +1,86 @@
# 3dct-client-mirror
Read-only snapshots of **the client's existing production system** for `3dcaketopper.nl`, `birthdaycaketopper.nl` and `weddingcaketopper.nl`.
> [!important] This is not our code
> Everything here is the property of the client. It is mirrored with written permission, solely so that we have a reproducible baseline of the system as it stood before our engagement began. **Nothing in this repository is transferred to us, and nothing here is covered by the IP assignment in our proposal.** Do not develop against this tree — our delivery code lives in `3D_Cake_topper`.
---
## Snapshots
| Snapshot | Taken | Source |
| --- | --- | --- |
| `snapshot-2026-08-15/` | 2026-08-15 | `matthijsexpand@149.210.159.152` (TransIP VPS, Lelystad NL) |
Each snapshot is a point-in-time copy. Never edit one — take a new snapshot instead. The value of this repository is that the old ones remain untouched.
### Layout
```
snapshot-YYYY-MM-DD/
├── home-weddingcaketopper/ ~/weddingcaketopper — Next.js app + proxy.py
├── var-www/ /var/www — static webroots for all three domains
├── etc-nginx/ /etc/nginx/sites-available — vhost configs
└── secrets-DO-NOT-COMMIT/ env files — gitignored, local disk only
```
### Deliberate exclusions
| Excluded | Why |
| --- | --- |
| `node_modules/` (704 MB) | Third-party packages, regenerable with `npm install`. Not client IP, and permanent git bloat. |
| `.next/` (17 MB) | Build output, regenerable with `npm run build`. |
| Env file **contents** | See below. |
---
## Why the secrets are not committed
The two env files were pulled and **are present on local disk** at `snapshot-*/secrets-DO-NOT-COMMIT/`. Nothing was lost. They are excluded from git only.
| File | Contains |
| --- | --- |
| `etc-weddingcaketopper.env` | `ANTHROPIC_API_KEY`, `OPENAI_API_KEY` |
| `weddingcaketopper.env.local` | `SUPABASE_SERVICE_ROLE_KEY`, `ADYEN_API_KEY`, `ADYEN_CLIENT_KEY`, `ADYEN_MERCHANT_ACCOUNT`, `OPENAI_API_KEY` |
Three reasons this line is drawn at the commit rather than the pull:
1. **Git history is permanent.** "We'll decide later" works for a file on disk. It does not work for a commit — removal requires `filter-repo`, which rewrites every hash and breaks every clone. The reversible choice is to keep them out now and add them deliberately later if wanted; the irreversible choice is the reverse.
2. **These are the client's live credentials, not ours.** A Supabase *service-role* key bypasses all row-level security on the customer database. The Adyen keys are payment infrastructure. Committing them to our git server materially increases *the client's* exposure, and that is not a risk we can accept on their behalf.
3. **They are already flagged as compromised.** Risk S-01 in the project Risk Register: an open unauthenticated relay served these keys to the public internet with no logging. They should be rotated, which makes the current values worth preserving as evidence — not as configuration.
**If you later decide they must be versioned**, the correct mechanism is encryption at rest — `git-crypt`, `age`, or SOPS — so the repository holds ciphertext and the key lives elsewhere. Say the word and it can be set up.
`.env.example` **is** committed: it records which variables the system requires, without any value.
---
## How a snapshot is taken
```bash
BASE=snapshot-$(date +%F)
mkdir -p "$BASE"/{home-weddingcaketopper,var-www,etc-nginx,secrets-DO-NOT-COMMIT}
rsync -az --exclude node_modules --exclude .next \
3dct:/home/matthijsexpand/weddingcaketopper/ "$BASE/home-weddingcaketopper/"
rsync -az 3dct:/var/www/ "$BASE/var-www/"
rsync -az 3dct:/etc/nginx/sites-available/ "$BASE/etc-nginx/"
rsync -az 3dct:/etc/weddingcaketopper.env "$BASE/secrets-DO-NOT-COMMIT/etc-weddingcaketopper.env"
mv "$BASE/home-weddingcaketopper/.env.local" "$BASE/secrets-DO-NOT-COMMIT/weddingcaketopper.env.local"
```
`3dct` is the SSH host alias in `~/.ssh/config`.
---
## What the 2026-08-15 snapshot documents
Full analysis in the project vault: `01-context/Infrastructure and Live Stack Audit`.
- `proxy/proxy.py` — 60-line `http.server` script; the open relay behind finding S-01
- `app/api/` — Adyen session/webhook, OpenAI preview, order creation routes
- `supabase/schema.sql``orders` and `uploads` tables, no row-level security
- `README.md` — the previous developer's own list of six items outstanding before production
- `var-www/3dcaketopper.nl/ontwerp/` — the live vanilla-JS configurator being replaced
- `etc-nginx/` — the three vhosts, previously existing only on the server with no history

View file

@ -0,0 +1,44 @@
server {
server_name 3dcaketopper.nl www.3dcaketopper.nl;
client_max_body_size 25M;
root /var/www/3dcaketopper.nl;
index index.html;
location /api/ {
proxy_pass http://localhost:5000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_connect_timeout 600s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
send_timeout 600s;
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/3dcaketopper.nl/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/3dcaketopper.nl/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = www.3dcaketopper.nl) {
return 301 https://$host$request_uri;
} # managed by Certbot
if ($host = 3dcaketopper.nl) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name 3dcaketopper.nl www.3dcaketopper.nl;
return 404; # managed by Certbot
}

View file

@ -0,0 +1,34 @@
server {
server_name 3dcaketopper.nl www.3dcaketopper.nl;
root /var/www/3dcaketopper.nl;
index index.html;
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/3dcaketopper.nl/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/3dcaketopper.nl/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = www.3dcaketopper.nl) {
return 301 https://$host$request_uri;
} # managed by Certbot
if ($host = 3dcaketopper.nl) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name 3dcaketopper.nl www.3dcaketopper.nl;
return 404; # managed by Certbot
}

View file

@ -0,0 +1,32 @@
server {
server_name birthdaycaketopper.nl www.birthdaycaketopper.nl;
return 301 https://www.3dcaketopper.nl/verjaardag/;
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/birthdaycaketopper.nl/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/birthdaycaketopper.nl/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = www.birthdaycaketopper.nl) {
return 301 https://$host$request_uri;
} # managed by Certbot
if ($host = birthdaycaketopper.nl) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name birthdaycaketopper.nl www.birthdaycaketopper.nl;
return 404; # managed by Certbot
}

View file

@ -0,0 +1,91 @@
##
# You should look at the following URL's in order to grasp a solid understanding
# of Nginx configuration files in order to fully unleash the power of Nginx.
# https://www.nginx.com/resources/wiki/start/
# https://www.nginx.com/resources/wiki/start/topics/tutorials/config_pitfalls/
# https://wiki.debian.org/Nginx/DirectoryStructure
#
# In most cases, administrators will remove this file from sites-enabled/ and
# leave it as reference inside of sites-available where it will continue to be
# updated by the nginx packaging team.
#
# This file will automatically load configuration files provided by other
# applications, such as Drupal or Wordpress. These applications will be made
# available underneath a path with that package name, such as /drupal8.
#
# Please see /usr/share/doc/nginx-doc/examples/ for more detailed examples.
##
# Default server configuration
#
server {
listen 80 default_server;
listen [::]:80 default_server;
# SSL configuration
#
# listen 443 ssl default_server;
# listen [::]:443 ssl default_server;
#
# Note: You should disable gzip for SSL traffic.
# See: https://bugs.debian.org/773332
#
# Read up on ssl_ciphers to ensure a secure configuration.
# See: https://bugs.debian.org/765782
#
# Self signed certs generated by the ssl-cert package
# Don't use them in a production server!
#
# include snippets/snakeoil.conf;
root /var/www/html;
# Add index.php to the list if you are using PHP
index index.html index.htm index.nginx-debian.html;
server_name _;
location / {
# First attempt to serve request as file, then
# as directory, then fall back to displaying a 404.
try_files $uri $uri/ =404;
}
# pass PHP scripts to FastCGI server
#
#location ~ \.php$ {
# include snippets/fastcgi-php.conf;
#
# # With php-fpm (or other unix sockets):
# fastcgi_pass unix:/run/php/php7.4-fpm.sock;
# # With php-cgi (or other tcp sockets):
# fastcgi_pass 127.0.0.1:9000;
#}
# deny access to .htaccess files, if Apache's document root
# concurs with nginx's one
#
#location ~ /\.ht {
# deny all;
#}
}
# Virtual Host configuration for example.com
#
# You can move that to a different file under sites-available/ and symlink that
# to sites-enabled/ to enable it.
#
#server {
# listen 80;
# listen [::]:80;
#
# server_name example.com;
#
# root /var/www/example.com;
# index index.html;
#
# location / {
# try_files $uri $uri/ =404;
# }
#}

View file

@ -0,0 +1,59 @@
server {
server_name weddingcaketopper.nl www.weddingcaketopper.nl;
client_max_body_size 25M;
proxy_read_timeout 300;
proxy_send_timeout 300;
proxy_connect_timeout 300;
root /home/matthijsexpand/weddingcaketopper/public-site;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/claude {
proxy_pass http://localhost:5000/api/claude;
}
location /api/imagine {
proxy_pass http://localhost:5000/api/imagine;
}
location /api/imagine-edit {
proxy_pass http://localhost:5000/api/imagine-edit;
proxy_read_timeout 300;
proxy_connect_timeout 300;
proxy_send_timeout 300;
client_max_body_size 20M;
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/weddingcaketopper.nl/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/weddingcaketopper.nl/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
server {
if ($host = www.weddingcaketopper.nl) {
return 301 https://$host$request_uri;
} # managed by Certbot
if ($host = weddingcaketopper.nl) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name weddingcaketopper.nl www.weddingcaketopper.nl;
return 404; # managed by Certbot
}

View file

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View file

@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

View file

@ -0,0 +1 @@
@AGENTS.md

View file

@ -0,0 +1,58 @@
# WeddingCakeTopper.nl MVP
Next.js MVP met:
- Bestelpagina
- Upload 36 foto's
- Supabase database + storage
- Adyen Sessions route
- OpenAI AI-preview route
- Simpele adminpagina
## Installatie lokaal
```bash
npm install
cp .env.example .env.local
npm run dev
```
## Supabase
1. Maak een project aan.
2. Draai `supabase/schema.sql` in SQL editor.
3. Maak bucket `order-uploads` aan.
4. Voor MVP: public bucket. Voor productie: private bucket + signed URLs.
## Adyen
Vul in `.env.local`:
- ADYEN_API_KEY
- ADYEN_MERCHANT_ACCOUNT
- ADYEN_CLIENT_KEY
- ADYEN_ENVIRONMENT=test
Webhook URL:
`https://weddingcaketopper.nl/api/payments/webhook`
## OpenAI
Vul `OPENAI_API_KEY` in.
## Deploy TransIP VPS
```bash
sudo apt update
sudo apt install -y nodejs npm nginx certbot python3-certbot-nginx
npm install
npm run build
npm install -g pm2
pm2 start npm --name weddingcaketopper -- start
pm2 save
```
Nginx reverse proxy naar `http://127.0.0.1:3000`.
## Belangrijk
Deze MVP is een startpunt. Voor live productie nog toevoegen:
- Echte admin login
- Adyen webhook HMAC-validatie
- Private Supabase uploads
- Email notificaties
- Rate limiting
- AVG/privacytekst

View file

@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { supabaseAdmin } from '@/lib/supabase';
import { STYLES } from '@/lib/types';
export async function POST(req: Request){
try{
const form=await req.formData(); const email=String(form.get('email')||''); const name=String(form.get('name')||''); const style=String(form.get('style')||'');
if(!email||!name||!STYLES.includes(style as any)) return NextResponse.json({error:'Ongeldige invoer.'},{status:400});
const files=form.getAll('photos').filter(Boolean) as File[]; if(files.length<3||files.length>6) return NextResponse.json({error:'Upload 3 tot 6 fotos.'},{status:400});
const sb=supabaseAdmin();
const {data:order,error}=await sb.from('orders').insert({email,name,style,product:String(form.get('product')||'bruidspaar-taarttopper'),wedding_date:String(form.get('wedding_date')||''),notes:String(form.get('notes')||''),status:'new',payment_status:'pending'}).select('id').single();
if(error) throw error;
const bucket=process.env.SUPABASE_BUCKET || 'order-uploads';
for(const file of files){ const path=`${order.id}/${crypto.randomUUID()}-${file.name}`; const buffer=Buffer.from(await file.arrayBuffer()); const up=await sb.storage.from(bucket).upload(path, buffer, {contentType:file.type, upsert:false}); if(up.error) throw up.error; const {data:pub}=sb.storage.from(bucket).getPublicUrl(path); await sb.from('uploads').insert({order_id:order.id,image_url:pub.publicUrl,path}); }
return NextResponse.json({orderId:order.id});
}catch(e:any){return NextResponse.json({error:e.message||'Serverfout'},{status:500})}
}

View file

@ -0,0 +1,12 @@
import { NextResponse } from 'next/server';
import { Client, CheckoutAPI } from '@adyen/api-library';
import { supabaseAdmin } from '@/lib/supabase';
export async function POST(req:Request){
try{ const {orderId}=await req.json(); if(!orderId) return NextResponse.json({error:'Order ontbreekt.'},{status:400});
const site=process.env.NEXT_PUBLIC_SITE_URL!; const sb=supabaseAdmin();
const client=new Client({apiKey:process.env.ADYEN_API_KEY!, environment:(process.env.ADYEN_ENVIRONMENT as any)||'TEST'}); const checkout=new CheckoutAPI(client);
const session=await checkout.PaymentsApi.sessions({merchantAccount:process.env.ADYEN_MERCHANT_ACCOUNT!, amount:{currency:'EUR',value:1995}, reference:orderId, returnUrl:`${site}/payment-result?orderId=${orderId}`, countryCode:'NL', shopperLocale:'nl-NL'} as any);
await sb.from('orders').update({adyen_session_id:(session as any).id}).eq('id',orderId);
return NextResponse.json({session});
}catch(e:any){return NextResponse.json({error:e.message||'Adyen fout'},{status:500})}
}

View file

@ -0,0 +1,8 @@
import { NextResponse } from 'next/server';
import { supabaseAdmin } from '@/lib/supabase';
export async function POST(req:Request){
const body=await req.json(); const sb=supabaseAdmin();
const items=body.notificationItems || [];
for(const item of items){ const n=item.NotificationRequestItem; if(n?.eventCode==='AUTHORISATION' && n?.success==='true'){ await sb.from('orders').update({payment_status:'paid',status:'paid'}).eq('id',n.merchantReference); await fetch(`${process.env.NEXT_PUBLIC_SITE_URL}/api/preview/generate`,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({orderId:n.merchantReference})}); }}
return NextResponse.json({notificationResponse:'[accepted]'});
}

View file

@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import OpenAI from 'openai';
import { supabaseAdmin } from '@/lib/supabase';
export async function POST(req:Request){
try{ const {orderId}=await req.json(); const sb=supabaseAdmin();
const {data:order}=await sb.from('orders').select('*').eq('id',orderId).single(); if(!order) return NextResponse.json({error:'Order niet gevonden'},{status:404});
const prompt=`Maak een hoogwaardige 2D-preview voor een gepersonaliseerde wedding cake topper. Product: bruidspaar taarttopper. Stijl: ${order.style}. Geschikt als basis voor 3D-print, witte achtergrond, front-facing, vriendelijk, premium, geen tekst in beeld. Opmerkingen klant: ${order.notes||'geen'}`;
const openai=new OpenAI({apiKey:process.env.OPENAI_API_KEY!});
const img=await openai.images.generate({model:'gpt-image-2',prompt,size:'1024x1024'} as any);
const b64=(img as any).data?.[0]?.b64_json; if(!b64) throw new Error('Geen afbeelding ontvangen van OpenAI');
const buffer=Buffer.from(b64,'base64'); const bucket=process.env.SUPABASE_BUCKET || 'order-uploads'; const path=`${orderId}/preview.png`;
const up=await sb.storage.from(bucket).upload(path,buffer,{contentType:'image/png',upsert:true}); if(up.error) throw up.error;
const {data:pub}=sb.storage.from(bucket).getPublicUrl(path); await sb.from('orders').update({preview_url:pub.publicUrl,status:'preview_ready'}).eq('id',orderId);
return NextResponse.json({previewUrl:pub.publicUrl});
}catch(e:any){return NextResponse.json({error:e.message||'Preview fout'},{status:500})}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -0,0 +1 @@
:root{--bg:#fff9f7;--ink:#2f2a2a;--muted:#7c6f6f;--brand:#c7956d;--soft:#f2ded5}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font-family:Arial,Helvetica,sans-serif}.container{max-width:1080px;margin:0 auto;padding:28px}.hero{display:grid;grid-template-columns:1.1fr .9fr;gap:36px;align-items:center;padding:64px 0}.card{background:#fff;border:1px solid #ead6ce;border-radius:24px;padding:24px;box-shadow:0 12px 30px rgba(80,40,20,.07)}h1{font-size:54px;line-height:1;margin:0 0 18px}.btn{background:var(--brand);color:white;border:0;border-radius:999px;padding:14px 22px;text-decoration:none;font-weight:700;cursor:pointer}.grid{display:grid;gap:18px}.styles{grid-template-columns:repeat(5,1fr)}input,select,textarea{width:100%;padding:13px;border:1px solid #ddd;border-radius:14px}label{font-weight:700}.muted{color:var(--muted)}.steps{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}.pill{background:var(--soft);border-radius:999px;padding:8px 12px;display:inline-block}.topbar{display:flex;justify-content:space-between;align-items:center}.logo{font-weight:900;letter-spacing:.03em}.logo span{font-family:Georgia,serif;font-style:italic;color:var(--brand)}@media(max-width:800px){.hero,.steps{grid-template-columns:1fr}.styles{grid-template-columns:1fr 1fr}h1{font-size:40px}}

View file

@ -0,0 +1,3 @@
import './globals.css';
export const metadata = { title: 'WeddingCakeTopper.nl', description: 'Gepersonaliseerde wedding cake toppers met AI-preview.' };
export default function RootLayout({children}:{children:React.ReactNode}){return <html lang="nl"><body>{children}</body></html>}

View file

@ -0,0 +1,13 @@
'use client';
import { useState } from 'react';
import { STYLES } from '@/lib/types';
export default function OrderPage(){
const [loading,setLoading]=useState(false); const [msg,setMsg]=useState('');
async function submit(e:React.FormEvent<HTMLFormElement>){e.preventDefault();setLoading(true);setMsg(''); const fd=new FormData(e.currentTarget);
const files=fd.getAll('photos') as File[]; if(files.length<3||files.length>6){setMsg('Upload minimaal 3 en maximaal 6 fotos.'); setLoading(false); return;}
const res=await fetch('/api/orders/create',{method:'POST',body:fd}); const data=await res.json(); if(!res.ok){setMsg(data.error||'Er ging iets mis.');setLoading(false);return;}
const pay=await fetch('/api/payments/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({orderId:data.orderId})}); const p=await pay.json(); if(!pay.ok){setMsg(p.error||'Betaling kon niet starten.');setLoading(false);return;}
window.location.href=p.redirectUrl || `/order/checkout?orderId=${data.orderId}`;
}
return <main className="container"><div className="topbar"><a className="logo" href="/">WEDDING<span> cake topper</span></a></div><div className="card"><h1>Start jouw ontwerp</h1><form onSubmit={submit} className="grid"><label>Product<select name="product" defaultValue="bruidspaar-taarttopper"><option value="bruidspaar-taarttopper">Bruidspaar taarttopper</option></select></label><label>Fotos uploaden<input name="photos" type="file" accept="image/*" multiple required /></label><label>Stijl<select name="style" required>{STYLES.map(s=><option key={s} value={s}>{s}</option>)}</select></label><label>Naam<input name="name" required placeholder="Voor- en achternaam" /></label><label>Email<input name="email" type="email" required /></label><label>Trouwdatum<input name="wedding_date" type="date" /></label><label>Opmerkingen<textarea name="notes" rows={4} placeholder="Bijv. kleding, pose, grappig detail..." /></label><button className="btn" disabled={loading}>{loading?'Even geduld...':'Ga naar betaling'}</button>{msg&&<p>{msg}</p>}</form></div></main>
}

View file

@ -0,0 +1,197 @@
import Link from "next/link";
export default function Home() {
return (
<main className="min-h-screen bg-[#fffaf7] text-[#2f2523]">
<section className="px-6 py-8 max-w-7xl mx-auto">
<header className="flex justify-between items-center mb-16">
<div>
<div className="text-2xl font-serif tracking-wide text-[#b88a44]">
WeddingCakeTopper.nl
</div>
<div className="text-sm text-[#8b6f61]">
Made for your love story
</div>
</div>
<Link
href="/ontwerp"
className="hidden md:inline-block rounded-full bg-[#b88a44] px-6 py-3 text-white font-medium hover:bg-[#9f7435]"
>
Start jullie ontwerp
</Link>
</header>
<section className="grid md:grid-cols-2 gap-12 items-center">
<div>
<p className="uppercase tracking-[0.25em] text-sm text-[#b88a44] mb-4">
Gepersonaliseerde wedding cake toppers
</p>
<h1 className="text-5xl md:text-7xl font-serif leading-tight mb-6">
Jullie liefde, vereeuwigd op de bruidstaart
</h1>
<p className="text-lg text-[#6f5b52] mb-8 max-w-xl">
Upload jullie foto&apos;s, kies jullie stijl en ontvang een unieke
3D-geprinte wedding cake topper die perfect past bij jullie grote dag.
</p>
<div className="flex flex-col sm:flex-row gap-4">
<Link
href="/ontwerp"
className="rounded-full bg-[#b88a44] px-8 py-4 text-white text-center font-medium hover:bg-[#9f7435]"
>
Start jullie ontwerp
</Link>
<a
href="#prijzen"
className="rounded-full border border-[#d8bfa3] px-8 py-4 text-center font-medium text-[#6f4f36] hover:bg-[#fff3eb]"
>
Bekijk prijzen
</a>
</div>
</div>
<div className="rounded-[3rem] bg-white shadow-xl p-8 text-center border border-[#f1dfd2]">
<div className="text-8xl mb-6">??????????</div>
<h2 className="text-3xl font-serif mb-3 text-[#b88a44]">
Made for your love story
</h2>
<p className="text-[#7a665e]">
Van klassiek bruidspaar tot gezin met kinderen of huisdieren.
Jullie verhaal, als unieke topper.
</p>
</div>
</section>
</section>
<section className="bg-white py-20 px-6">
<div className="max-w-6xl mx-auto">
<h2 className="text-4xl font-serif text-center mb-12">
Zo werkt het
</h2>
<div className="grid md:grid-cols-4 gap-6">
{[
["??", "Upload foto&apos;s", "Voeg foto&apos;s toe van beide partners."],
["??", "Kies jullie stijl", "Romantisch, modern of speelse karikatuur."],
["????????", "Voeg extra&apos;s toe", "Kinderen of huisdieren kunnen mee op de topper."],
["??", "Wij maken hem", "Ontworpen en 3D-geprint in Nederland."],
].map(([icon, title, text]) => (
<div
key={title}
className="rounded-3xl bg-[#fffaf7] p-6 border border-[#f1dfd2]"
>
<div className="text-4xl mb-4">{icon}</div>
<h3
className="text-xl font-semibold mb-2"
dangerouslySetInnerHTML={{ __html: title }}
/>
<p
className="text-[#7a665e]"
dangerouslySetInnerHTML={{ __html: text }}
/>
</div>
))}
</div>
</div>
</section>
<section id="prijzen" className="py-20 px-6 bg-[#fffaf7]">
<div className="max-w-6xl mx-auto">
<h2 className="text-4xl font-serif text-center mb-4">
Kies jullie formaat
</h2>
<p className="text-center text-[#7a665e] mb-12">
Gepersonaliseerd op basis van jullie foto&apos;s.
</p>
<div className="grid md:grid-cols-2 gap-8 max-w-4xl mx-auto">
<div className="rounded-3xl bg-white p-8 border border-[#f1dfd2] shadow-sm">
<h3 className="text-3xl font-serif mb-2">Kleine topper</h3>
<p className="text-[#7a665e] mb-6">12 cm hoog</p>
<div className="text-5xl font-serif text-[#b88a44] mb-6">
69
</div>
<p className="text-[#7a665e] mb-8">
Perfect voor kleinere bruidstaarten of subtiele decoratie.
</p>
<Link
href="/ontwerp"
className="block rounded-full bg-[#b88a44] px-6 py-4 text-white text-center font-medium hover:bg-[#9f7435]"
>
Start ontwerp
</Link>
</div>
<div className="rounded-3xl bg-white p-8 border-2 border-[#b88a44] shadow-xl relative">
<div className="absolute -top-4 left-8 bg-[#b88a44] text-white px-4 py-2 rounded-full text-sm">
Meest gekozen
</div>
<h3 className="text-3xl font-serif mb-2">Grote topper</h3>
<p className="text-[#7a665e] mb-6">19 cm hoog</p>
<div className="text-5xl font-serif text-[#b88a44] mb-6">
99
</div>
<p className="text-[#7a665e] mb-8">
Een echte eyecatcher bovenop jullie bruidstaart.
</p>
<Link
href="/ontwerp"
className="block rounded-full bg-[#b88a44] px-6 py-4 text-white text-center font-medium hover:bg-[#9f7435]"
>
Start ontwerp
</Link>
</div>
</div>
<div className="mt-12 text-center text-[#7a665e]">
Kind toevoegen + 19 per kind · Hond of kat toevoegen + 15
</div>
</div>
</section>
<section className="bg-white py-20 px-6">
<div className="max-w-5xl mx-auto text-center">
<h2 className="text-4xl font-serif mb-6">
Voor ieder liefdesverhaal
</h2>
<p className="text-lg text-[#7a665e] mb-10">
Bruid & bruidegom, bruid & bruid of bruidegom & bruidegom.
Iedere liefde verdient een unieke topper.
</p>
<div className="flex flex-wrap justify-center gap-4 text-lg">
<span className="rounded-full bg-[#fffaf7] px-6 py-3 border border-[#f1dfd2]">
????? ????? Bruid & bruidegom
</span>
<span className="rounded-full bg-[#fffaf7] px-6 py-3 border border-[#f1dfd2]">
????? ????? Bruid & bruid
</span>
<span className="rounded-full bg-[#fffaf7] px-6 py-3 border border-[#f1dfd2]">
????? ????? Bruidegom & bruidegom
</span>
</div>
</div>
</section>
<section className="py-20 px-6 bg-[#2f2523] text-white text-center">
<h2 className="text-4xl font-serif mb-6">
Klaar om jullie topper te ontwerpen?
</h2>
<p className="text-[#e8d7c9] mb-8">
De ontwerptool is bijna klaar. Binnenkort upload je hier jullie foto&apos;s
en maken we direct een eerste voorbeeld.
</p>
<Link
href="/ontwerp"
className="inline-block rounded-full bg-[#b88a44] px-8 py-4 text-white font-medium hover:bg-[#9f7435]"
>
Start jullie ontwerp
</Link>
</section>
</main>
);
}

View file

@ -0,0 +1,5 @@
import { supabaseAdmin } from '@/lib/supabase';
export default async function Admin(){
const sb=supabaseAdmin(); const {data:orders}=await sb.from('orders').select('*').order('created_at',{ascending:false}).limit(50);
return <main className="container"><h1>Admin orders</h1><div className="grid">{orders?.map((o:any)=><div className="card" key={o.id}><b>{o.name}</b><p>{o.email}</p><p>Status: {o.status} / betaling: {o.payment_status}</p><p>Stijl: {o.style}</p>{o.preview_url&&<a className="btn" href={o.preview_url}>Bekijk preview</a>}</div>)}</div></main>
}

View file

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View file

@ -0,0 +1,6 @@
import { createClient } from '@supabase/supabase-js';
export function supabaseAdmin(){
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!;
return createClient(url, key, { auth: { persistSession: false } });
}

View file

@ -0,0 +1,2 @@
export const STYLES = ['klassiek','grappig','cartoon','luxe','3d-printbaar'] as const;
export type TopperStyle = typeof STYLES[number];

View file

@ -0,0 +1,3 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = { images: { remotePatterns: [{ protocol: 'https', hostname: '**.supabase.co' }] } };
export default nextConfig;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
{"name":"weddingcaketopper","version":"0.1.0","private":true,"scripts":{"dev":"next dev","build":"next build","start":"next start","lint":"next lint"},"dependencies":{"@adyen/adyen-web":"latest","@adyen/api-library":"^30.1.0","@supabase/supabase-js":"latest","@tailwindcss/postcss":"^4.3.1","next":"latest","openai":"latest","react":"latest","react-dom":"latest","zod":"latest"},"devDependencies":{"@types/node":"latest","@types/react":"latest","@types/react-dom":"latest","eslint":"latest","eslint-config-next":"latest","typescript":"latest"}}

View file

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,62 @@
import http.server
import urllib.request
import json
env = {}
for line in open('/etc/weddingcaketopper.env').read().strip().split('\n'):
if '=' in line:
k, v = line.split('=', 1)
env[k.strip()] = v.strip()
ANTHROPIC_KEY = env.get('ANTHROPIC_API_KEY', '')
OPENAI_KEY = env.get('OPENAI_API_KEY', '')
class ProxyHandler(http.server.BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers['Content-Length'])
body = self.rfile.read(length)
if self.path == '/api/claude':
url = 'https://api.anthropic.com/v1/messages'
headers = {'Content-Type':'application/json','x-api-key':ANTHROPIC_KEY,'anthropic-version':'2023-06-01'}
elif self.path == '/api/imagine':
url = 'https://api.openai.com/v1/images/generations'
headers = {'Content-Type':'application/json','Authorization':f'Bearer {OPENAI_KEY}'}
elif self.path == '/api/imagine-edit':
url = 'https://api.openai.com/v1/images/edits'
headers = {'Content-Type':self.headers.get('Content-Type',''),'Authorization':f'Bearer {OPENAI_KEY}'}
else:
self.send_response(404); self.end_headers(); return
try:
req = urllib.request.Request(url, data=body, headers=headers)
resp = urllib.request.urlopen(req, timeout=400)
data = resp.read()
self.send_response(200)
self.send_header('Content-Type','application/json')
self.send_header('Access-Control-Allow-Origin','*')
self.end_headers()
self.wfile.write(data)
except urllib.error.HTTPError as e:
data = e.read()
self.send_response(e.code)
self.send_header('Content-Type','application/json')
self.send_header('Access-Control-Allow-Origin','*')
self.end_headers()
self.wfile.write(data)
except Exception as e:
self.send_response(500)
self.send_header('Content-Type','application/json')
self.send_header('Access-Control-Allow-Origin','*')
self.end_headers()
self.wfile.write(json.dumps({'error':{'message':str(e)}}).encode())
def do_OPTIONS(self):
self.send_response(200)
self.send_header('Access-Control-Allow-Origin','*')
self.send_header('Access-Control-Allow-Methods','POST')
self.send_header('Access-Control-Allow-Headers','Content-Type,Authorization')
self.end_headers()
def log_message(self, format, *args):
pass
httpd = http.server.HTTPServer(('localhost', 5000), ProxyHandler)
print('Proxy draait op poort 5000')
httpd.serve_forever()

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View file

@ -0,0 +1,129 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WeddingCakeTopper.nl</title>
<style>
body { margin:0; font-family: Arial, sans-serif; background:#fffaf7; color:#2f2523; }
h1,h2,h3 { font-family: Georgia, serif; font-weight:400; }
.container { max-width:1180px; margin:auto; padding:32px 24px; }
header { display:flex; justify-content:space-between; align-items:center; margin-bottom:80px; }
.brand { font-size:28px; color:#b88a44; font-family:Georgia,serif; }
.tagline { color:#8b6f61; font-size:14px; }
.btn { display:inline-block; background:#b88a44; color:white; padding:16px 28px; border-radius:999px; text-decoration:none; font-weight:bold; }
.btn:hover { background:#9f7435; }
.btn-outline { background:transparent; color:#6f4f36; border:1px solid #d8bfa3; }
.hero { display:grid; grid-template-columns:1fr 1fr; gap:60px; align-items:center; }
.eyebrow { text-transform:uppercase; letter-spacing:4px; color:#b88a44; font-size:13px; }
h1 { font-size:64px; line-height:1.05; margin:20px 0; }
p { color:#6f5b52; line-height:1.7; font-size:18px; }
.card { background:white; border:1px solid #f1dfd2; border-radius:36px; padding:36px; box-shadow:0 20px 50px rgba(0,0,0,.08); }
.section { padding:80px 24px; }
.white { background:white; }
.center { text-align:center; }
.grid4 { display:grid; grid-template-columns:repeat(4,1fr); gap:24px; }
.grid2 { display:grid; grid-template-columns:repeat(2,1fr); gap:32px; max-width:850px; margin:auto; }
.price { font-size:56px; color:#b88a44; font-family:Georgia,serif; margin:20px 0; }
.badge { display:inline-block; background:#b88a44; color:white; padding:8px 16px; border-radius:999px; font-size:14px; margin-bottom:16px; }
.pill { display:inline-block; background:#fffaf7; border:1px solid #f1dfd2; padding:14px 22px; border-radius:999px; margin:8px; }
.dark { background:#2f2523; color:white; }
.dark p { color:#e8d7c9; }
.icon { font-size:34px; color:#b88a44; margin-bottom:12px; }
@media(max-width:800px){ .hero,.grid2,.grid4{grid-template-columns:1fr;} h1{font-size:44px;} header{display:block;} header .btn{margin-top:20px;} }
</style>
</head>
<body>
<main>
<section class="container">
<header>
<div>
<div class="brand">WeddingCakeTopper.nl</div>
<div class="tagline">Made for your love story</div>
</div>
<a class="btn" href="/ontwerp/">Start jullie ontwerp</a>
</header>
<section class="hero">
<div>
<div class="eyebrow">Gepersonaliseerde wedding cake toppers</div>
<h1>Jullie liefde, vereeuwigd op de bruidstaart</h1>
<p>Upload jullie foto's, kies jullie stijl en ontvang een unieke 3D-geprinte wedding cake topper die perfect past bij jullie grote dag.</p>
<a class="btn" href="/ontwerp/">Start jullie ontwerp</a>
<a class="btn btn-outline" href="#prijzen">Bekijk prijzen</a>
</div>
<div class="card center">
<img
src="/images/logo.png"
alt="WeddingCakeTopper.nl"
style="max-width:420px;width:100%;display:block;margin:0 auto 20px auto;"
>
<p>Van klassiek bruidspaar tot gezin met kinderen of huisdieren. Jullie verhaal, als unieke topper.</p>
</div>
</section>
</section>
<section class="section white">
<div class="container center">
<h2>Zo werkt het</h2>
<div class="grid4">
<div class="card"><div class="icon">1</div><h3>Upload foto's</h3><p>Voeg foto's toe van beide partners.</p></div>
<div class="card"><div class="icon">2</div><h3>Kies jullie stijl</h3><p>Romantisch, modern of speelse karikatuur.</p></div>
<div class="card"><div class="icon">3</div><h3>Voeg extra's toe</h3><p>Kinderen of huisdieren kunnen mee op de topper.</p></div>
<div class="card"><div class="icon">4</div><h3>Wij maken hem</h3><p>Ontworpen en 3D-geprint in Nederland.</p></div>
</div>
</div>
</section>
<section id="prijzen" class="section">
<div class="container center">
<h2>Kies jullie formaat</h2>
<p>Gepersonaliseerd op basis van jullie foto's.</p>
<div class="grid2">
<div class="card">
<h3>Kleine topper</h3>
<p>12 cm hoog</p>
<div class="price">€69</div>
<p>Perfect voor kleinere bruidstaarten of subtiele decoratie.</p>
<a class="btn" href="/ontwerp/">Start ontwerp</a>
</div>
<div class="card" style="border:2px solid #b88a44;">
<span class="badge">Meest gekozen</span>
<h3>Grote topper</h3>
<p>19 cm hoog</p>
<div class="price">€99</div>
<p>Een echte eyecatcher bovenop jullie bruidstaart.</p>
<a class="btn" href="/ontwerp/">Start ontwerp</a>
</div>
</div>
</div>
</section>
<section class="section white center">
<div class="container">
<h2>Voor ieder liefdesverhaal</h2>
<p>Bruid & bruidegom, bruid & bruid of bruidegom & bruidegom. Iedere liefde verdient een unieke topper.</p>
<div>
<span class="pill">Bruid & bruidegom</span>
<span class="pill">Bruid & bruid</span>
<span class="pill">Bruidegom & bruidegom</span>
</div>
</div>
</section>
<section class="section dark center">
<div class="container">
<h2>Klaar om jullie topper te ontwerpen?</h2>
<p>De ontwerptool is bijna klaar. Binnenkort upload je hier jullie foto's en maken we direct een eerste voorbeeld.</p>
<a class="btn" href="/ontwerp/">Start jullie ontwerp</a>
</div>
</section>
</main>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View file

@ -0,0 +1,24 @@
create extension if not exists pgcrypto;
create table if not exists orders (
id uuid primary key default gen_random_uuid(),
created_at timestamptz default now(),
product text not null default 'bruidspaar-taarttopper',
email text not null,
name text not null,
style text not null,
wedding_date text,
notes text,
status text default 'new',
payment_status text default 'pending',
preview_url text,
adyen_session_id text
);
create table if not exists uploads (
id uuid primary key default gen_random_uuid(),
created_at timestamptz default now(),
order_id uuid references orders(id) on delete cascade,
image_url text not null,
path text
);
-- Maak in Supabase Storage een public bucket: order-uploads
-- Voor live gebruik: maak uploads privé en gebruik signed URLs.

View file

@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,563 @@
<!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>

View file

@ -0,0 +1,21 @@
from PIL import Image
import os, numpy as np
def pad_image(src):
img = Image.open(src).convert('RGBA')
w, h = img.size
arr = np.array(img)
non_white = np.where(np.any(arr[:,:,:3] < 230, axis=(1,2)))[0]
if len(non_white) == 0: return
if non_white[0] / h > 0.05:
print(f"Skip: {os.path.basename(src)}"); return
pad_top = int(h * 0.13)
pad_bot = int(h * 0.08)
new_img = Image.new('RGBA', (w, h + pad_top + pad_bot), (255,255,255,255))
new_img.paste(img, (0, pad_top))
new_img.save(src, 'PNG')
print(f"Padded: {os.path.basename(src)}")
for dirpath, dirs, files in os.walk('/var/www/3dcaketopper.nl/verjaardag/images'):
for f in files:
if f.lower().endswith('.png'): pad_image(os.path.join(dirpath, f))

View file

@ -0,0 +1,23 @@
from PIL import Image
import os, numpy as np
def reset_image(src):
img = Image.open(src).convert('RGBA')
w, h = img.size
arr = np.array(img)
non_white = np.where(np.any(arr[:,:,:3] < 230, axis=(1,2)))[0]
if len(non_white) == 0: return
top = non_white[0]
bottom = non_white[-1]
if top / h < 0.05:
print(f"Skip: {os.path.basename(src)}"); return
margin = int(h * 0.03)
new_top = max(0, top - margin)
new_bot = min(h, bottom + margin)
cropped = img.crop((0, new_top, w, new_bot))
cropped.save(src, 'PNG')
print(f"Reset: {os.path.basename(src)}")
for dirpath, dirs, files in os.walk('/var/www/3dcaketopper.nl/verjaardag/images'):
for f in files:
if f.lower().endswith('.png'): reset_image(os.path.join(dirpath, f))

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Some files were not shown because too many files have changed in this diff Show more