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.
21
.gitignore
vendored
Normal 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
|
|
@ -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
|
||||
44
snapshot-2026-08-15/etc-nginx/3dcaketopper.nl
Normal 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
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
34
snapshot-2026-08-15/etc-nginx/3dcaketopper.nl.save
Normal 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
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
32
snapshot-2026-08-15/etc-nginx/birthdaycaketopper.nl
Normal 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
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
91
snapshot-2026-08-15/etc-nginx/default
Normal 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;
|
||||
# }
|
||||
#}
|
||||
59
snapshot-2026-08-15/etc-nginx/weddingcaketopper
Normal 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
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
41
snapshot-2026-08-15/home-weddingcaketopper/.gitignore
vendored
Normal 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
|
||||
5
snapshot-2026-08-15/home-weddingcaketopper/AGENTS.md
Normal 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 -->
|
||||
1
snapshot-2026-08-15/home-weddingcaketopper/CLAUDE.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
@AGENTS.md
|
||||
58
snapshot-2026-08-15/home-weddingcaketopper/README.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# WeddingCakeTopper.nl MVP
|
||||
|
||||
Next.js MVP met:
|
||||
- Bestelpagina
|
||||
- Upload 3–6 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
|
||||
|
|
@ -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 foto’s.'},{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})}
|
||||
}
|
||||
|
|
@ -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})}
|
||||
}
|
||||
|
|
@ -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]'});
|
||||
}
|
||||
|
|
@ -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})}
|
||||
}
|
||||
BIN
snapshot-2026-08-15/home-weddingcaketopper/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 25 KiB |
|
|
@ -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}}
|
||||
|
|
@ -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>}
|
||||
|
|
@ -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 foto’s.'); 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>Foto’s 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>
|
||||
}
|
||||
197
snapshot-2026-08-15/home-weddingcaketopper/app/page.tsx
Normal 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'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's", "Voeg foto's toe van beide partners."],
|
||||
["??", "Kies jullie stijl", "Romantisch, modern of speelse karikatuur."],
|
||||
["????????", "Voeg extra'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'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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
18
snapshot-2026-08-15/home-weddingcaketopper/eslint.config.mjs
Normal 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;
|
||||
|
|
@ -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 } });
|
||||
}
|
||||
2
snapshot-2026-08-15/home-weddingcaketopper/lib/types.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export const STYLES = ['klassiek','grappig','cartoon','luxe','3d-printbaar'] as const;
|
||||
export type TopperStyle = typeof STYLES[number];
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import type { NextConfig } from 'next';
|
||||
const nextConfig: NextConfig = { images: { remotePatterns: [{ protocol: 'https', hostname: '**.supabase.co' }] } };
|
||||
export default nextConfig;
|
||||
6862
snapshot-2026-08-15/home-weddingcaketopper/package-lock.json
generated
Normal file
1
snapshot-2026-08-15/home-weddingcaketopper/package.json
Normal 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"}}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1062
snapshot-2026-08-15/home-weddingcaketopper/proxy/nohup.out
Normal file
62
snapshot-2026-08-15/home-weddingcaketopper/proxy/proxy.py
Normal 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()
|
||||
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
|
@ -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>
|
||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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.
|
||||
41
snapshot-2026-08-15/home-weddingcaketopper/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
536
snapshot-2026-08-15/var-www/3dcaketopper.nl/index.html
Normal file
563
snapshot-2026-08-15/var-www/3dcaketopper.nl/ontwerp/index.html
Normal 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">© 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>
|
||||
21
snapshot-2026-08-15/var-www/3dcaketopper.nl/pad_images.py
Normal 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))
|
||||
23
snapshot-2026-08-15/var-www/3dcaketopper.nl/reset_images.py
Normal 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))
|
||||
|
After Width: | Height: | Size: 2 MiB |
BIN
snapshot-2026-08-15/var-www/3dcaketopper.nl/verjaardag/images/Abraham/Man1.png
Executable file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
snapshot-2026-08-15/var-www/3dcaketopper.nl/verjaardag/images/Abraham/Man2.png
Executable file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
snapshot-2026-08-15/var-www/3dcaketopper.nl/verjaardag/images/Abraham/man4.png
Executable file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
snapshot-2026-08-15/var-www/3dcaketopper.nl/verjaardag/images/Abraham/man5.png
Executable file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
snapshot-2026-08-15/var-www/3dcaketopper.nl/verjaardag/images/Abraham/man6.png
Executable file
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 2 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 2 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 2 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 2 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.8 MiB |