feat: Add database with Prisma, AdminJS panel, Dockerization, Stripe integration, and migrate to pnpm.

This commit is contained in:
ordinarthur 2026-03-12 14:09:55 +01:00
parent dd087d8826
commit 4fdeacd19e
30 changed files with 10208 additions and 7007 deletions

7
.adminjs/bundle.js Normal file
View File

@ -0,0 +1,7 @@
(function () {
'use strict';
AdminJS.UserComponents = {};
})();
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYnVuZGxlLmpzIiwic291cmNlcyI6WyJlbnRyeS5qcyJdLCJzb3VyY2VzQ29udGVudCI6WyJBZG1pbkpTLlVzZXJDb21wb25lbnRzID0ge31cbiJdLCJuYW1lcyI6WyJBZG1pbkpTIiwiVXNlckNvbXBvbmVudHMiXSwibWFwcGluZ3MiOiI7OztDQUFBQSxPQUFPLENBQUNDLGNBQWMsR0FBRyxFQUFFOzs7Ozs7In0=

1
.adminjs/entry.js Normal file
View File

@ -0,0 +1 @@
AdminJS.UserComponents = {}

View File

@ -1 +1 @@
[["Map",1,2],"meta::meta",["Map",3,4,5,6],"astro-version","5.18.0","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"static\",\"scopedStyleStrategy\":\"attribute\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":true,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":false,\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[],\"responsiveStyles\":false},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true,\"allowedDomains\":[],\"actionBodySizeLimit\":1048576},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false,\"liveContentCollections\":false,\"csp\":false,\"staticImportMetaEnv\":false,\"chromeDevtoolsWorkspace\":false,\"failOnPrerenderConflict\":false,\"svgo\":false},\"legacy\":{\"collections\":false}}"]
[["Map",1,2],"meta::meta",["Map",3,4,5,6],"astro-version","5.18.1","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"static\",\"scopedStyleStrategy\":\"attribute\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":true,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":false,\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[],\"responsiveStyles\":false},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true,\"allowedDomains\":[],\"actionBodySizeLimit\":1048576},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false,\"liveContentCollections\":false,\"csp\":false,\"staticImportMetaEnv\":false,\"chromeDevtoolsWorkspace\":false,\"failOnPrerenderConflict\":false,\"svgo\":false},\"legacy\":{\"collections\":false}}"]

View File

@ -1,5 +1,5 @@
{
"_variables": {
"lastUpdateCheck": 1772211077674
"lastUpdateCheck": 1773267396416
}
}

1
.astro/types.d.ts vendored
View File

@ -1,2 +1 @@
/// <reference types="astro/client" />
/// <reference path="content.d.ts" />

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
node_modules
dist
.env
.git
.astro
*.md

View File

@ -7,4 +7,21 @@ STRIPE_WEBHOOK_SECRET=whsec_...
# ── App ───────────────────────────────────────────────────────────────────────
# URL publique du site (sans slash final)
DOMAIN=https://rebour.studio
DOMAIN=https://rebours.studio
# Port du serveur Fastify (dev: 8888, prod: 3000)
PORT=3000
# ── Database ──────────────────────────────────────────────────────────────────
# PostgreSQL connection string
# Dev: postgresql://rebours:rebours@localhost:5432/rebours
# Prod: postgresql://rebours:PASSWORD@postgres:5432/rebours (Coolify internal)
DATABASE_URL=postgresql://rebours:rebours@localhost:5432/rebours
# ── Admin ─────────────────────────────────────────────────────────────────────
# Email et mot de passe du compte admin (utilisé par le seed)
ADMIN_EMAIL=admin@rebours.studio
ADMIN_PASSWORD=changeme
# Secret pour les cookies de session AdminJS (min 32 caractères)
COOKIE_SECRET=change-me-to-a-random-string-at-least-32-chars

202
DEPLOY.md Normal file
View File

@ -0,0 +1,202 @@
# REBOURS — Guide de déploiement
## Architecture
```
Internet → Traefik (Coolify, SSL auto)
├── /api/*, /admin/* → Fastify (Node.js + AdminJS)
│ └── PostgreSQL
└── /* → Nginx (fichiers statiques Astro)
```
---
## Environnement de développement
### Prérequis
- Node.js >= 22
- Docker (pour PostgreSQL local)
- pnpm
### 1. Lancer PostgreSQL
```bash
docker compose up -d
```
PostgreSQL sera accessible sur `localhost:5432` avec :
- DB: `rebours`
- User: `rebours`
- Password: `rebours`
### 2. Configurer le `.env`
```bash
cp .env.example .env
# Éditer les valeurs (Stripe keys, etc.)
```
Variables dev minimales :
```env
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
DOMAIN=http://localhost:4321
PORT=8888
DATABASE_URL=postgresql://rebours:rebours@localhost:5432/rebours
ADMIN_EMAIL=admin@rebours.studio
ADMIN_PASSWORD=changeme
COOKIE_SECRET=dev-cookie-secret-at-least-32-characters-long
```
### 3. Initialiser la base de données
```bash
# Première fois : créer les tables
pnpm db:migrate
# Insérer les produits et le compte admin
pnpm db:seed
```
### 4. Lancer le projet
```bash
pnpm dev
```
- Site : http://localhost:4321
- Admin : http://localhost:4321/admin (proxié vers Fastify :8888)
- Prisma Studio : `pnpm db:studio` → http://localhost:5555
### Commandes utiles
| Commande | Description |
|----------|-------------|
| `pnpm dev` | Astro dev + Fastify (hot reload) |
| `pnpm build` | Build statique (prisma generate + astro build) |
| `pnpm db:migrate` | Créer/appliquer les migrations |
| `pnpm db:seed` | Seeder les produits + admin |
| `pnpm db:studio` | Prisma Studio (UI pour la DB) |
| `docker compose up -d` | Lancer PostgreSQL local |
| `docker compose down` | Stopper PostgreSQL local |
---
## Déploiement sur Coolify
### Prérequis
- Un serveur avec Coolify installé
- Un domaine pointant vers le serveur (rebours.studio)
### 1. Créer la ressource sur Coolify
1. **New Resource** → **Docker Compose**
2. **Source** : connecter le repo Git
3. **Compose file** : sélectionner `docker-compose.prod.yml`
### 2. Configurer les variables d'environnement
Dans l'onglet **Environment Variables** de Coolify, ajouter :
| Variable | Valeur | Build Variable ? |
|----------|--------|:---:|
| `DATABASE_URL` | `postgresql://rebours:MOT_DE_PASSE@postgres:5432/rebours` | Oui |
| `POSTGRES_PASSWORD` | `MOT_DE_PASSE` (même que dans DATABASE_URL) | Non |
| `STRIPE_SECRET_KEY` | `sk_live_...` | Non |
| `STRIPE_WEBHOOK_SECRET` | `whsec_...` | Non |
| `DOMAIN` | `https://rebours.studio` | Non |
| `PORT` | `3000` | Non |
| `ADMIN_EMAIL` | `admin@rebours.studio` | Non |
| `ADMIN_PASSWORD` | `MOT_DE_PASSE_ADMIN` | Non |
| `COOKIE_SECRET` | Chaîne aléatoire 64 chars | Non |
> `DATABASE_URL` doit être coché **Build Variable** car `astro build` en a besoin pour requêter les produits au build time.
### 3. Configurer les domaines
Dans l'onglet **Domains** :
- Service `nginx` : `rebours.studio` (port 80)
- Service `fastify` : `rebours.studio` (port 3000)
Coolify générera automatiquement les labels Traefik. Si tu utilises les labels du `docker-compose.prod.yml`, assure-toi qu'ils ne sont pas en conflit avec ceux générés par Coolify.
### 4. Déployer
Cliquer **Deploy**. Coolify va :
1. Builder l'image Docker (install deps + prisma generate + astro build)
2. Démarrer PostgreSQL + attendre le healthcheck
3. Démarrer Fastify (qui lance `prisma migrate deploy` automatiquement)
4. Démarrer Nginx (qui sert les fichiers statiques)
### 5. Seeder la base (première fois)
Après le premier déploiement, exécuter le seed dans le container Fastify :
```bash
# Via Coolify terminal ou SSH
docker exec -it <container-fastify> sh -c "node prisma/seed.mjs"
```
Ou dans le terminal Coolify du service `fastify` :
```bash
node prisma/seed.mjs
```
### 6. Vérifier
- Site : https://rebours.studio
- Admin : https://rebours.studio/admin
- Login avec `ADMIN_EMAIL` / `ADMIN_PASSWORD`
---
## Fonctionnement de l'auto-build
Quand tu modifies un produit dans l'admin (`/admin`) :
1. AdminJS sauvegarde en DB (PostgreSQL)
2. Un hook `after` déclenche `pnpm build` (debounce 5s)
3. Astro rebuild les pages statiques depuis la DB
4. Les fichiers `dist/` sont écrits dans le volume partagé
5. Nginx sert les nouvelles pages immédiatement
> En dev (`NODE_ENV !== production`), le build auto est désactivé.
---
## Stripe
### Webhook en production
Le webhook doit pointer vers :
```
https://rebours.studio/api/webhook
```
Events écoutés : `checkout.session.completed`
### Ajouter un produit achetable
1. Créer le prix dans le **Dashboard Stripe**
2. Dans l'admin `/admin` → Products → éditer le produit
3. Remplir `stripePriceId` avec le `price_xxx` de Stripe
4. Remplir `stripeKey` avec l'identifiant utilisé par le front (ex: `mon_produit`)
5. Le rebuild auto mettra à jour le site
---
## Migrations DB
### En dev
```bash
pnpm db:migrate
```
### En prod (automatique)
Le `CMD` du Dockerfile exécute `prisma migrate deploy` au démarrage du container. Les nouvelles migrations sont appliquées automatiquement à chaque déploiement.
### Ajouter une migration manuellement
```bash
# Modifier prisma/schema.prisma puis :
pnpm db:migrate
# Commit le dossier prisma/migrations/
```

31
Dockerfile Normal file
View File

@ -0,0 +1,31 @@
FROM node:22-alpine AS base
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
# ── Dependencies ─────────────────────────────────────────────────────────────
FROM base AS deps
COPY package.json pnpm-lock.yaml ./
COPY prisma ./prisma/
RUN pnpm install --frozen-lockfile
# ── Build static site ────────────────────────────────────────────────────────
FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm prisma generate
# Astro build needs DATABASE_URL at build time (passed as build arg by Coolify)
ARG DATABASE_URL
RUN pnpm build
# ── Production ───────────────────────────────────────────────────────────────
FROM base AS production
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules/.prisma ./node_modules/.prisma
EXPOSE 3000
# Migrate DB + seed if needed + start server
CMD ["sh", "-c", "pnpm prisma migrate deploy && node server.mjs"]

108
admin.mjs Normal file
View File

@ -0,0 +1,108 @@
import AdminJS from 'adminjs'
import AdminJSFastify from '@adminjs/fastify'
import { Database, Resource, getModelByName } from '@adminjs/prisma'
import bcrypt from 'bcrypt'
import { exec } from 'child_process'
import { prisma } from './src/lib/db.mjs'
AdminJS.registerAdapter({ Database, Resource })
// ── Auto-build (prod only) ──────────────────────────────────────────────────
let buildTimeout = null
let buildInProgress = false
function triggerBuild() {
if (process.env.NODE_ENV !== 'production') {
console.log('[admin] Dev mode — skipping build')
return
}
if (buildTimeout) clearTimeout(buildTimeout)
buildTimeout = setTimeout(() => {
if (buildInProgress) return
buildInProgress = true
console.log('[admin] Building site...')
exec('pnpm build', { cwd: process.cwd(), timeout: 120_000 }, (err, stdout, stderr) => {
buildInProgress = false
if (err) console.error('[admin] Build FAILED:', stderr)
else console.log('[admin] Build OK')
})
}, 5_000)
}
const afterBuildHook = async (response) => { triggerBuild(); return response }
// ── AdminJS setup ───────────────────────────────────────────────────────────
export async function setupAdmin(app) {
const admin = new AdminJS({
rootPath: '/admin',
resources: [
{
resource: { model: getModelByName('Product'), client: prisma },
options: {
navigation: { name: 'Contenu', icon: 'Package' },
listProperties: ['sortOrder', 'name', 'type', 'price', 'isPublished', 'updatedAt'],
editProperties: [
'slug', 'sortOrder', 'index', 'name', 'type', 'materials',
'year', 'status', 'description', 'specs', 'notes',
'imagePath', 'imageAlt',
'seoTitle', 'seoDescription', 'ogImage',
'productDisplayName', 'price', 'currency', 'availability',
'stripePriceId', 'stripeKey', 'isPublished',
],
properties: {
description: { type: 'textarea' },
specs: { type: 'textarea' },
notes: { type: 'textarea' },
seoDescription: { type: 'textarea' },
},
actions: {
new: { after: [afterBuildHook] },
edit: { after: [afterBuildHook] },
delete: { after: [afterBuildHook] },
},
},
},
{
resource: { model: getModelByName('Order'), client: prisma },
options: {
navigation: { name: 'Commerce', icon: 'CreditCard' },
listProperties: ['stripeSessionId', 'status', 'amount', 'customerEmail', 'productSlug', 'createdAt'],
actions: {
new: { isAccessible: false },
edit: { isAccessible: false },
delete: { isAccessible: false },
},
},
},
],
branding: {
companyName: 'REBOURS Studio',
logo: false,
withMadeWithLove: false,
},
})
await AdminJSFastify.buildAuthenticatedRouter(
admin,
{
authenticate: async (email, password) => {
const user = await prisma.adminUser.findUnique({ where: { email } })
if (!user) return null
const valid = await bcrypt.compare(password, user.passwordHash)
return valid ? { email: user.email, id: user.id } : null
},
cookiePassword: process.env.COOKIE_SECRET ?? 'super-secret-cookie-password-at-least-32-chars',
cookieName: 'adminjs',
},
app,
{
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
},
}
)
return admin
}

View File

@ -9,6 +9,7 @@ export default defineConfig({
server: {
proxy: {
'/api': 'http://127.0.0.1:8888',
'/admin': 'http://127.0.0.1:8888',
},
},
},

67
docker-compose.prod.yml Normal file
View File

@ -0,0 +1,67 @@
services:
fastify:
build:
context: .
dockerfile: Dockerfile
args:
- DATABASE_URL=${DATABASE_URL}
volumes:
- static:/app/dist
environment:
- DATABASE_URL
- STRIPE_SECRET_KEY
- STRIPE_WEBHOOK_SECRET
- DOMAIN
- PORT=3000
- ADMIN_EMAIL
- ADMIN_PASSWORD
- COOKIE_SECRET
- NODE_ENV=production
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
labels:
- traefik.enable=true
- traefik.http.routers.api.rule=Host(`rebours.studio`) && (PathPrefix(`/api`) || PathPrefix(`/admin`))
- traefik.http.routers.api.entrypoints=https
- traefik.http.routers.api.tls=true
- traefik.http.routers.api.tls.certresolver=letsencrypt
- traefik.http.services.api.loadbalancer.server.port=3000
- traefik.http.routers.api.priority=2
postgres:
image: postgres:16-alpine
volumes:
- pgdata:/var/lib/postgresql/data
environment:
- POSTGRES_DB=rebours
- POSTGRES_USER=rebours
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U rebours -d rebours"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
nginx:
image: nginx:alpine
volumes:
- static:/usr/share/nginx/html:ro
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- fastify
restart: unless-stopped
labels:
- traefik.enable=true
- traefik.http.routers.static.rule=Host(`rebours.studio`)
- traefik.http.routers.static.entrypoints=https
- traefik.http.routers.static.tls=true
- traefik.http.routers.static.tls.certresolver=letsencrypt
- traefik.http.services.static.loadbalancer.server.port=80
- traefik.http.routers.static.priority=1
volumes:
static:
pgdata:

14
docker-compose.yml Normal file
View File

@ -0,0 +1,14 @@
services:
postgres:
image: postgres:16-alpine
ports:
- "5432:5432"
environment:
POSTGRES_DB: rebours
POSTGRES_USER: rebours
POSTGRES_PASSWORD: rebours
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:

31
docker/nginx.conf Normal file
View File

@ -0,0 +1,31 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# HTML : jamais caché
location ~* \.html$ {
add_header Cache-Control "no-store";
}
# Fichiers Astro avec hash dans _astro/ : cache long immutable
location ~* ^/_astro/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# CSS / JS sans hash (style.css, main.js) : revalidation
location ~* \.(css|js)$ {
add_header Cache-Control "no-cache";
}
# Assets (images, fonts) : cache 7 jours
location ~* \.(jpg|jpeg|png|gif|webp|svg|woff2|woff|ttf|ico)$ {
add_header Cache-Control "public, max-age=604800";
}
location / {
try_files $uri $uri/ $uri.html /index.html;
}
}

6686
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,21 +4,71 @@
"type": "module",
"scripts": {
"dev": "concurrently \"astro dev\" \"NODE_ENV=development node --watch server.mjs\"",
"build": "astro build",
"build": "prisma generate && astro build",
"preview": "astro preview",
"server": "NODE_ENV=production node server.mjs",
"db:migrate": "prisma migrate dev",
"db:seed": "prisma db seed",
"db:studio": "prisma studio",
"stripe:purge": "node scripts/stripe-purge.mjs",
"stripe:purge:confirm": "node scripts/stripe-purge.mjs --confirm",
"stripe:sync": "node scripts/stripe-sync.mjs",
"stripe:sync:confirm": "node scripts/stripe-sync.mjs --confirm",
"astro": "astro"
},
"prisma": {
"seed": "node prisma/seed.mjs"
},
"dependencies": {
"@adminjs/fastify": "^4.2.0",
"@adminjs/prisma": "^5.0.4",
"@fastify/cors": "^10.0.2",
"@fastify/static": "^9.0.0",
"@prisma/client": "^6.19.2",
"adminjs": "^7.8.17",
"astro": "^5.17.1",
"bcrypt": "^6.0.0",
"concurrently": "^9.0.0",
"dotenv": "^17.3.1",
"fastify": "^5.3.2",
"stripe": "^20.3.1"
"stripe": "^20.3.1",
"tslib": "^2.8.1"
},
"devDependencies": {
"@types/node": "^25.3.0"
"@types/bcrypt": "^6.0.0",
"@types/node": "^25.3.0",
"prisma": "^6.19.2"
},
"pnpm": {
"onlyBuiltDependencies": [
"@prisma/client",
"@prisma/engines",
"bcrypt",
"esbuild",
"prisma",
"sharp"
],
"overrides": {
"@tiptap/core": "2.1.13",
"@tiptap/pm": "2.1.13",
"@tiptap/extension-bold": "2.1.13",
"@tiptap/extension-blockquote": "2.1.13",
"@tiptap/extension-bullet-list": "2.1.13",
"@tiptap/extension-code": "2.1.13",
"@tiptap/extension-code-block": "2.1.13",
"@tiptap/extension-document": "2.1.13",
"@tiptap/extension-dropcursor": "2.1.13",
"@tiptap/extension-gapcursor": "2.1.13",
"@tiptap/extension-hard-break": "2.1.13",
"@tiptap/extension-heading": "2.1.13",
"@tiptap/extension-history": "2.1.13",
"@tiptap/extension-horizontal-rule": "2.1.13",
"@tiptap/extension-italic": "2.1.13",
"@tiptap/extension-list-item": "2.1.13",
"@tiptap/extension-ordered-list": "2.1.13",
"@tiptap/extension-paragraph": "2.1.13",
"@tiptap/extension-strike": "2.1.13",
"@tiptap/extension-text": "2.1.13"
}
}
}

8721
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

1
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1 @@
approveBuildsForScope: '@prisma/engines bcrypt prisma esbuild sharp'

View File

@ -0,0 +1,72 @@
-- CreateTable
CREATE TABLE "Product" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"index" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL,
"materials" TEXT NOT NULL,
"year" TEXT NOT NULL,
"status" TEXT NOT NULL,
"description" TEXT NOT NULL,
"specs" TEXT NOT NULL,
"notes" TEXT NOT NULL,
"imagePath" TEXT NOT NULL,
"imageAlt" TEXT NOT NULL DEFAULT '',
"seoTitle" TEXT NOT NULL,
"seoDescription" TEXT NOT NULL,
"ogImage" TEXT NOT NULL,
"productDisplayName" TEXT NOT NULL,
"price" INTEGER,
"currency" TEXT NOT NULL DEFAULT 'EUR',
"availability" TEXT NOT NULL DEFAULT 'https://schema.org/PreOrder',
"stripePriceId" TEXT,
"stripeKey" TEXT,
"isPublished" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Order" (
"id" TEXT NOT NULL,
"stripeSessionId" TEXT NOT NULL,
"stripePaymentIntent" TEXT,
"status" TEXT NOT NULL DEFAULT 'pending',
"amount" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'EUR',
"customerEmail" TEXT,
"receiptUrl" TEXT,
"productId" TEXT,
"productSlug" TEXT,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Order_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AdminUser" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AdminUser_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Product_slug_key" ON "Product"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "Order_stripeSessionId_key" ON "Order"("stripeSessionId");
-- CreateIndex
CREATE UNIQUE INDEX "AdminUser_email_key" ON "AdminUser"("email");
-- AddForeignKey
ALTER TABLE "Order" ADD CONSTRAINT "Order_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

73
prisma/schema.prisma Normal file
View File

@ -0,0 +1,73 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Product {
id String @id @default(cuid())
slug String @unique
sortOrder Int @default(0)
// Card data (data-* attributes)
index String // e.g. "PROJET_001"
name String // e.g. "Solar_Altar"
type String // e.g. "LAMPE DE TABLE"
materials String
year String
status String // e.g. "PROTOTYPE [80%]"
description String
specs String
notes String
imagePath String
imageAlt String @default("")
// SEO
seoTitle String
seoDescription String
ogImage String
// Commerce
productDisplayName String
price Int? // cents, null = not for sale
currency String @default("EUR")
availability String @default("https://schema.org/PreOrder")
stripePriceId String? // Stripe price_id
stripeKey String? // compat frontend e.g. "lumiere_orbitale"
isPublished Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
orders Order[]
}
model Order {
id String @id @default(cuid())
stripeSessionId String @unique
stripePaymentIntent String?
status String @default("pending")
amount Int
currency String @default("EUR")
customerEmail String?
receiptUrl String?
productId String?
product Product? @relation(fields: [productId], references: [id])
productSlug String?
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model AdminUser {
id String @id @default(cuid())
email String @unique
passwordHash String
createdAt DateTime @default(now())
}

113
prisma/seed.mjs Normal file
View File

@ -0,0 +1,113 @@
import { PrismaClient } from '@prisma/client'
import bcrypt from 'bcrypt'
const prisma = new PrismaClient()
const PRODUCTS = [
{
slug: 'solar-altar',
sortOrder: 0,
index: 'PROJET_001',
name: 'Solar_Altar',
type: 'LAMPE DE TABLE',
materials: 'BÉTON TEXTURÉ + DÔME CÉRAMIQUE LAQUÉ',
year: '2026',
status: 'PROTOTYPE [80%]',
description: 'Exploration de la lumière à travers des contraintes géométriques. Le dôme sphérique en céramique laquée coiffe un corps en béton texturé peint à la main. Chaque pièce est unique.',
specs: 'H: 45cm / Ø: 18cm\nPoids: 3.2kg\nAlimentation: 220V — E27\nCâble: tressé rouge 2m',
notes: 'Inspiré des lampadaires soviétiques des années 60. Le béton est coulé à la main dans des moules uniques. La peinture acrylique est appliquée au spalter.',
imagePath: '/assets/lamp-violet.jpg',
imageAlt: 'Solar Altar — Lampe béton violet, dôme céramique bleu, REBOURS 2026',
seoTitle: 'REBOURS — Solar Altar | Collection 001',
seoDescription: 'Lampe de table unique. Béton texturé coulé à la main + dôme céramique laqué. Pièce unique fabriquée à Paris.',
ogImage: 'https://rebours.studio/assets/lamp-violet.jpg',
productDisplayName: 'Solar Altar',
price: 180000, // 1800 EUR in cents
currency: 'EUR',
availability: 'https://schema.org/LimitedAvailability',
stripePriceId: 'price_1T5SBlE5wMMoCUP5ZcjEStwe',
stripeKey: 'lumiere_orbitale',
isPublished: true,
},
{
slug: 'table-terrazzo',
sortOrder: 1,
index: 'PROJET_002',
name: 'TABLE_TERRAZZO',
type: 'TABLE BASSE + ÉTAGÈRE MODULAIRE',
materials: 'TERRAZZO + ACIER TUBULAIRE + RÉSINE',
year: '2026',
status: 'STRUCTURAL_TEST',
description: "Collision du brutalisme et de la couleur Memphis. Le plateau en terrazzo fait à la main intègre des inclusions de marbre rose et bleu. Les colonnes cylindriques bicolores sont en acier peint au four.",
specs: 'Table: L120 × P60 × H38cm\nPoids plateau: 28kg\nPieds: acier Ø60mm\nÉtagère: H180 × L80 × P35cm',
notes: "Le terrazzo est réalisé dans l'atelier de Pantin. Chaque dalle est unique. L'étagère est assemblée à partir de tubes industriels récupérés et de panneaux laqués.",
imagePath: '/assets/table-terrazzo.jpg',
imageAlt: 'TABLE TERRAZZO — Table basse terrazzo et étagère acier, REBOURS 2026',
seoTitle: 'REBOURS — TABLE TERRAZZO | Collection 001',
seoDescription: 'Table basse et étagère modulaire. Terrazzo fait main + acier tubulaire. Pièce unique fabriquée à Paris.',
ogImage: 'https://rebours.studio/assets/table-terrazzo.jpg',
productDisplayName: 'Table Terrazzo',
price: null,
currency: 'EUR',
availability: 'https://schema.org/PreOrder',
stripePriceId: null,
stripeKey: 'table_terrazzo',
isPublished: true,
},
{
slug: 'module-serie',
sortOrder: 2,
index: 'PROJET_003',
name: 'MODULE_SÉRIE',
type: 'LAMPES — SÉRIE LIMITÉE',
materials: 'BÉTON COLORÉ + DÔME LAQUÉ + NÉON',
year: '2026',
status: 'FINAL_ASSEMBLY',
description: "Série de 7 lampes aux corps béton colorés, chacune avec un dôme d'une couleur différente. Les néons horizontaux créent un anneau lumineux entre le dôme et le corps.",
specs: 'H: 3565cm (7 tailles)\nDôme: Ø1528cm\nAnneau néon: 8W — 3000K\nÉdition: 7 ex. par coloris',
notes: 'Les corps sont coulés en série mais peints individuellement. Les dômes sont réalisés par un souffleur de verre artisanal. Le câble tressé rouge est la signature de la série.',
imagePath: '/assets/lampes-serie.jpg',
imageAlt: 'MODULE SÉRIE — Collection de 7 lampes béton colorées, REBOURS 2026',
seoTitle: 'REBOURS — MODULE SÉRIE | Collection 001',
seoDescription: 'Série de 7 lampes béton colorées, dôme laqué et néon. Édition limitée fabriquée à Paris.',
ogImage: 'https://rebours.studio/assets/lampes-serie.jpg',
productDisplayName: 'Module Série',
price: null,
currency: 'EUR',
availability: 'https://schema.org/PreOrder',
stripePriceId: null,
stripeKey: 'module_serie',
isPublished: true,
},
]
async function main() {
// Seed products
for (const product of PRODUCTS) {
await prisma.product.upsert({
where: { slug: product.slug },
update: product,
create: product,
})
console.log(`✓ Product: ${product.name}`)
}
// Seed admin user
const email = process.env.ADMIN_EMAIL ?? 'admin@rebours.studio'
const password = process.env.ADMIN_PASSWORD ?? 'changeme'
const passwordHash = await bcrypt.hash(password, 10)
await prisma.adminUser.upsert({
where: { email },
update: { passwordHash },
create: { email, passwordHash },
})
console.log(`✓ Admin user: ${email}`)
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(() => prisma.$disconnect())

View File

@ -124,8 +124,18 @@ document.addEventListener('DOMContentLoaded', () => {
const checkoutFormWrap = document.getElementById('checkout-form-wrap');
const checkoutForm = document.getElementById('checkout-form');
const checkoutSubmitBtn = document.getElementById('checkout-submit-btn');
const checkoutPriceEl = document.querySelector('.checkout-price');
// Current product's stripe key (set dynamically when opening panel)
let currentStripeKey = null;
// Format price in cents to display string
function formatPrice(cents) {
return (cents / 100).toLocaleString('fr-FR') + ' €';
}
// Toggle affichage du form
if (checkoutToggleBtn) {
checkoutToggleBtn.addEventListener('click', () => {
const isOpen = checkoutFormWrap.style.display !== 'none';
checkoutFormWrap.style.display = isOpen ? 'none' : 'block';
@ -133,10 +143,13 @@ document.addEventListener('DOMContentLoaded', () => {
? '[ COMMANDER CETTE PIÈCE ]'
: '[ ANNULER ]';
});
}
// Submit → appel API Elysia → redirect Stripe
// Submit → appel API → redirect Stripe
if (checkoutForm) {
checkoutForm.addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentStripeKey) return;
const email = document.getElementById('checkout-email').value;
checkoutSubmitBtn.disabled = true;
@ -146,7 +159,7 @@ document.addEventListener('DOMContentLoaded', () => {
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ product: 'lumiere_orbitale', email }),
body: JSON.stringify({ product: currentStripeKey, email }),
});
const data = await res.json();
if (data.url) {
@ -160,8 +173,9 @@ document.addEventListener('DOMContentLoaded', () => {
console.error(err);
}
});
}
// Slug à partir du nom du produit : "Solar_Altar" → "lumiere-orbitale"
// Slug à partir du nom du produit : "Solar_Altar" → "solar-altar"
function toSlug(name) {
return name
.toLowerCase()
@ -183,9 +197,20 @@ document.addEventListener('DOMContentLoaded', () => {
fields.specs.textContent = card.dataset.specs;
fields.notes.textContent = card.dataset.notes;
// Affiche le bouton de commande uniquement pour PROJET_001
const isOrderable = card.dataset.index === 'PROJET_001';
checkoutSection.style.display = isOrderable ? 'block' : 'none';
// Checkout : afficher uniquement si le produit a un prix et un stripe key
const price = card.dataset.price;
const stripeKey = card.dataset.stripeKey;
const isOrderable = price && stripeKey;
if (isOrderable) {
currentStripeKey = stripeKey;
checkoutPriceEl.textContent = formatPrice(parseInt(price, 10));
checkoutSection.style.display = 'block';
} else {
currentStripeKey = null;
checkoutSection.style.display = 'none';
}
// Reset form state
checkoutFormWrap.style.display = 'none';
checkoutToggleBtn.textContent = '[ COMMANDER CETTE PIÈCE ]';
@ -231,7 +256,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (card) openPanel(card, false);
}
panelClose.addEventListener('click', () => closePanel());
if (panelClose) panelClose.addEventListener('click', () => closePanel());
// Echap pour fermer
document.addEventListener('keydown', (e) => {

155
scripts/stripe-purge.mjs Normal file
View File

@ -0,0 +1,155 @@
#!/usr/bin/env node
/**
* stripe-purge.mjs
*
* Supprime TOUS les produits (et leurs prix) du compte Stripe.
*
* Usage :
* node scripts/stripe-purge.mjs # mode dry-run (affiche sans supprimer)
* node scripts/stripe-purge.mjs --confirm # suppression réelle
*
* IRRÉVERSIBLE utiliser avec précaution
*/
import 'dotenv/config'
import Stripe from 'stripe'
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY
if (!STRIPE_SECRET_KEY) {
console.error('❌ STRIPE_SECRET_KEY manquant dans .env')
process.exit(1)
}
const stripe = new Stripe(STRIPE_SECRET_KEY)
const dryRun = !process.argv.includes('--confirm')
if (dryRun) {
console.log('🔍 MODE DRY-RUN — aucun produit ne sera supprimé')
console.log(' Ajouter --confirm pour supprimer réellement\n')
}
async function archivePrices(productId) {
let hasMore = true
let startingAfter = undefined
let count = 0
while (hasMore) {
const params = { product: productId, active: true, limit: 100 }
if (startingAfter) params.starting_after = startingAfter
const prices = await stripe.prices.list(params)
for (const price of prices.data) {
if (dryRun) {
console.log(` 📌 Prix ${price.id}${price.unit_amount / 100} ${price.currency.toUpperCase()} (serait archivé)`)
} else {
await stripe.prices.update(price.id, { active: false })
console.log(` ✓ Prix archivé: ${price.id}`)
}
count++
}
hasMore = prices.has_more
if (prices.data.length > 0) {
startingAfter = prices.data[prices.data.length - 1].id
}
}
return count
}
async function main() {
let hasMore = true
let startingAfter = undefined
let totalProducts = 0
let totalPrices = 0
console.log('🗑️ Récupération des produits Stripe...\n')
while (hasMore) {
const params = { limit: 100, active: true }
if (startingAfter) params.starting_after = startingAfter
const products = await stripe.products.list(params)
for (const product of products.data) {
console.log(`📦 ${product.name || product.id}`)
// 1. Archiver les prix (obligatoire avant suppression)
const priceCount = await archivePrices(product.id)
totalPrices += priceCount
// 2. Archiver puis supprimer le produit
if (dryRun) {
console.log(` 🗑️ Produit serait supprimé\n`)
} else {
// D'abord archiver (désactiver)
await stripe.products.update(product.id, { active: false })
// Puis supprimer (possible uniquement si aucune facture liée)
try {
await stripe.products.del(product.id)
console.log(` ✓ Produit supprimé\n`)
} catch (err) {
// Si le produit a des factures, on ne peut que l'archiver
console.log(` ⚠️ Produit archivé (suppression impossible: ${err.message})\n`)
}
}
totalProducts++
}
hasMore = products.has_more
if (products.data.length > 0) {
startingAfter = products.data[products.data.length - 1].id
}
}
// Aussi traiter les produits déjà archivés
hasMore = true
startingAfter = undefined
while (hasMore) {
const params = { limit: 100, active: false }
if (startingAfter) params.starting_after = startingAfter
const products = await stripe.products.list(params)
for (const product of products.data) {
console.log(`📦 (archivé) ${product.name || product.id}`)
const priceCount = await archivePrices(product.id)
totalPrices += priceCount
if (dryRun) {
console.log(` 🗑️ Produit serait supprimé\n`)
} else {
try {
await stripe.products.del(product.id)
console.log(` ✓ Produit supprimé\n`)
} catch (err) {
console.log(` ⚠️ Non supprimable: ${err.message}\n`)
}
}
totalProducts++
}
hasMore = products.has_more
if (products.data.length > 0) {
startingAfter = products.data[products.data.length - 1].id
}
}
console.log('─'.repeat(50))
if (dryRun) {
console.log(`📊 ${totalProducts} produits et ${totalPrices} prix SERAIENT supprimés`)
console.log('\n👉 Relancer avec --confirm pour exécuter')
} else {
console.log(`${totalProducts} produits et ${totalPrices} prix traités`)
}
}
main().catch(err => {
console.error('❌ Erreur:', err.message)
process.exit(1)
})

222
scripts/stripe-sync.mjs Normal file
View File

@ -0,0 +1,222 @@
#!/usr/bin/env node
/**
* stripe-sync.mjs
*
* Synchronise les produits de la BDD vers Stripe :
* 1. Pour chaque produit avec un prix (price != null) :
* - Crée le Product Stripe (ou le retrouve via metadata.slug)
* - Crée le Price Stripe
* - Met à jour le stripePriceId dans la BDD
* 2. Les produits sans prix sont ignorés
*
* Usage :
* node scripts/stripe-sync.mjs # mode dry-run
* node scripts/stripe-sync.mjs --confirm # exécution réelle
*/
import 'dotenv/config'
import Stripe from 'stripe'
import { PrismaClient } from '@prisma/client'
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY
if (!STRIPE_SECRET_KEY) {
console.error('❌ STRIPE_SECRET_KEY manquant dans .env')
process.exit(1)
}
const stripe = new Stripe(STRIPE_SECRET_KEY)
const prisma = new PrismaClient()
const dryRun = !process.argv.includes('--confirm')
if (dryRun) {
console.log('🔍 MODE DRY-RUN — rien ne sera créé ni modifié')
console.log(' Ajouter --confirm pour exécuter réellement\n')
}
/**
* Cherche un produit Stripe existant par metadata.slug
*/
async function findExistingStripeProduct(slug) {
let hasMore = true
let startingAfter = undefined
while (hasMore) {
const params = { limit: 100, active: true }
if (startingAfter) params.starting_after = startingAfter
const products = await stripe.products.list(params)
for (const product of products.data) {
if (product.metadata?.slug === slug) {
return product
}
}
hasMore = products.has_more
if (products.data.length > 0) {
startingAfter = products.data[products.data.length - 1].id
}
}
return null
}
/**
* Cherche un prix actif existant pour un produit Stripe
* avec le bon montant et la bonne devise
*/
async function findExistingPrice(stripeProductId, amount, currency) {
const prices = await stripe.prices.list({
product: stripeProductId,
active: true,
limit: 100,
})
return prices.data.find(
p => p.unit_amount === amount && p.currency === currency.toLowerCase()
)
}
async function main() {
// Récupérer tous les produits publiés avec un prix
const products = await prisma.product.findMany({
where: {
isPublished: true,
price: { not: null },
},
orderBy: { sortOrder: 'asc' },
})
if (products.length === 0) {
console.log(' Aucun produit avec un prix trouvé dans la BDD')
return
}
console.log(`📦 ${products.length} produit(s) à synchroniser vers Stripe\n`)
let created = 0
let updated = 0
let skipped = 0
for (const product of products) {
const priceCents = product.price
const currency = product.currency.toLowerCase()
console.log(`── ${product.productDisplayName} (${product.slug})`)
console.log(` Prix: ${priceCents / 100} ${currency.toUpperCase()}`)
// 1. Chercher ou créer le Product Stripe
let stripeProduct = await findExistingStripeProduct(product.slug)
if (stripeProduct) {
console.log(` ✓ Produit Stripe existant: ${stripeProduct.id}`)
if (!dryRun) {
// Mettre à jour les infos du produit
stripeProduct = await stripe.products.update(stripeProduct.id, {
name: product.productDisplayName,
description: product.description,
images: product.ogImage ? [product.ogImage] : undefined,
metadata: {
slug: product.slug,
stripeKey: product.stripeKey || product.slug,
dbId: product.id,
},
})
console.log(` ✓ Produit Stripe mis à jour`)
}
} else {
if (dryRun) {
console.log(` 📌 Produit Stripe SERAIT créé`)
} else {
stripeProduct = await stripe.products.create({
name: product.productDisplayName,
description: product.description,
images: product.ogImage ? [product.ogImage] : [],
metadata: {
slug: product.slug,
stripeKey: product.stripeKey || product.slug,
dbId: product.id,
},
})
console.log(` ✓ Produit Stripe créé: ${stripeProduct.id}`)
}
}
// 2. Chercher ou créer le Price Stripe
let stripePrice = null
if (stripeProduct) {
stripePrice = await findExistingPrice(stripeProduct.id, priceCents, currency)
}
if (stripePrice) {
console.log(` ✓ Prix Stripe existant: ${stripePrice.id} (${priceCents / 100} ${currency.toUpperCase()})`)
// Vérifier si le stripePriceId dans la BDD est à jour
if (product.stripePriceId === stripePrice.id) {
console.log(` ✓ BDD déjà à jour`)
skipped++
} else {
if (dryRun) {
console.log(` 📌 stripePriceId SERAIT mis à jour: ${product.stripePriceId || '(vide)'}${stripePrice.id}`)
updated++
} else {
await prisma.product.update({
where: { id: product.id },
data: { stripePriceId: stripePrice.id },
})
console.log(` ✓ BDD mise à jour: stripePriceId = ${stripePrice.id}`)
updated++
}
}
} else {
// Créer un nouveau prix
if (dryRun) {
console.log(` 📌 Prix Stripe SERAIT créé: ${priceCents / 100} ${currency.toUpperCase()}`)
console.log(` 📌 stripePriceId SERAIT mis à jour dans la BDD`)
created++
} else {
stripePrice = await stripe.prices.create({
product: stripeProduct.id,
unit_amount: priceCents,
currency: currency,
metadata: {
slug: product.slug,
dbId: product.id,
},
})
console.log(` ✓ Prix Stripe créé: ${stripePrice.id}`)
// 3. Mettre à jour le stripePriceId dans la BDD
await prisma.product.update({
where: { id: product.id },
data: { stripePriceId: stripePrice.id },
})
console.log(` ✓ BDD mise à jour: stripePriceId = ${stripePrice.id}`)
created++
}
}
console.log()
}
console.log('─'.repeat(50))
if (dryRun) {
console.log(`📊 Résumé (dry-run) :`)
console.log(` ${created} produit(s)/prix SERAIENT créés`)
console.log(` ${updated} stripePriceId SERAIENT mis à jour`)
console.log(` ${skipped} déjà synchronisé(s)`)
console.log(`\n👉 Relancer avec --confirm pour exécuter`)
} else {
console.log(`✅ Synchronisation terminée :`)
console.log(` ${created} créé(s), ${updated} mis à jour, ${skipped} inchangé(s)`)
}
}
main()
.catch(err => {
console.error('❌ Erreur:', err.message)
process.exit(1)
})
.finally(() => prisma.$disconnect())

View File

@ -2,88 +2,18 @@ import Fastify from 'fastify'
import cors from '@fastify/cors'
import Stripe from 'stripe'
import dotenv from 'dotenv'
import { setupAdmin } from './admin.mjs'
import { prisma } from './src/lib/db.mjs'
dotenv.config()
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? '')
const DOMAIN = process.env.DOMAIN ?? 'http://localhost:4321'
const PRODUCTS = {
lumiere_orbitale: {
price_id: 'price_1T5SBlE5wMMoCUP5ZcjEStwe',
},
}
const app = Fastify({ logger: true })
await app.register(cors, { origin: '*', methods: ['GET', 'POST'] })
// ── SEO ───────────────────────────────────────────────────────────────────────
app.get('/robots.txt', (_, reply) => {
reply
.type('text/plain')
.header('Cache-Control', 'public, max-age=86400')
.send(`User-agent: *\nAllow: /\nSitemap: ${DOMAIN}/sitemap.xml\n`)
})
app.get('/sitemap.xml', (_, reply) => {
const today = new Date().toISOString().split('T')[0]
reply
.type('application/xml')
.header('Cache-Control', 'public, max-age=86400')
.send(
`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n <url><loc>${DOMAIN}/</loc><lastmod>${today}</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>\n</urlset>`
)
})
// ── Checkout Stripe ───────────────────────────────────────────────────────────
app.post('/api/checkout', async (request, reply) => {
const { product, email } = request.body ?? {}
const p = PRODUCTS[product]
if (!p) return reply.code(404).send({ error: 'Produit inconnu' })
app.log.info(`Stripe key prefix: ${process.env.STRIPE_SECRET_KEY?.slice(0, 20)}`)
app.log.info(`Price ID: ${p.price_id}`)
let session
try {
session = await stripe.checkout.sessions.create({
mode: 'payment',
payment_method_types: ['card', 'link'],
line_items: [{
price: p.price_id,
quantity: 1,
}],
metadata: { product },
success_url: `${DOMAIN}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${DOMAIN}/#collection`,
locale: 'fr',
customer_email: email ?? undefined,
custom_text: {
submit: { message: 'Pièce unique — fabriquée à Paris. Délai : 6 à 8 semaines.' },
},
})
} catch (err) {
app.log.error(err)
return reply.code(500).send({ error: err.message })
}
return { url: session.url }
})
// ── Vérification session ──────────────────────────────────────────────────────
app.get('/api/session/:id', async (request) => {
const session = await stripe.checkout.sessions.retrieve(request.params.id, {
expand: ['payment_intent.latest_charge'],
})
const charge = session.payment_intent?.latest_charge
return {
status: session.payment_status,
amount: session.amount_total,
currency: session.currency,
customer_email: session.customer_details?.email ?? null,
product: session.metadata?.product ?? null,
receipt_url: charge?.receipt_url ?? null,
}
})
// ── Webhook Stripe ────────────────────────────────────────────────────────────
// ── Webhook Stripe (AVANT AdminJS pour éviter les conflits de body parsing) ─
app.post('/api/webhook', {
config: { rawBody: true },
onRequest: (request, reply, done) => {
@ -107,14 +37,117 @@ app.post('/api/webhook', {
const session = event.data.object
if (session.payment_status === 'paid') {
app.log.info(`✓ Paiement — ${session.id}${session.customer_details?.email}`)
// Find product by stripeKey
const stripeKey = session.metadata?.product
const product = stripeKey
? await prisma.product.findFirst({ where: { stripeKey } })
: null
await prisma.order.upsert({
where: { stripeSessionId: session.id },
create: {
stripeSessionId: session.id,
stripePaymentIntent: typeof session.payment_intent === 'string' ? session.payment_intent : null,
status: 'paid',
amount: session.amount_total ?? 0,
currency: session.currency ?? 'eur',
customerEmail: session.customer_details?.email ?? null,
productId: product?.id ?? null,
productSlug: product?.slug ?? stripeKey ?? null,
metadata: session.metadata ?? null,
},
update: { status: 'paid' },
})
}
}
return { received: true }
})
// ── Start ─────────────────────────────────────────────────────────────────────
// ── AdminJS ─────────────────────────────────────────────────────────────────
await setupAdmin(app)
// ── SEO ─────────────────────────────────────────────────────────────────────
app.get('/robots.txt', (_, reply) => {
reply
.type('text/plain')
.header('Cache-Control', 'public, max-age=86400')
.send(`User-agent: *\nAllow: /\nSitemap: ${DOMAIN}/sitemap.xml\n`)
})
app.get('/sitemap.xml', async (_, reply) => {
const today = new Date().toISOString().split('T')[0]
const products = await prisma.product.findMany({
where: { isPublished: true },
select: { slug: true },
})
const productUrls = products
.map(p => ` <url><loc>${DOMAIN}/collection/${p.slug}</loc><lastmod>${today}</lastmod><changefreq>weekly</changefreq><priority>0.8</priority></url>`)
.join('\n')
reply
.type('application/xml')
.header('Cache-Control', 'public, max-age=86400')
.send(
`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n <url><loc>${DOMAIN}/</loc><lastmod>${today}</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>\n${productUrls}\n</urlset>`
)
})
// ── Checkout Stripe ─────────────────────────────────────────────────────────
app.post('/api/checkout', async (request, reply) => {
const { product, email } = request.body ?? {}
// Lookup by stripeKey (compat frontend: "lumiere_orbitale")
const p = await prisma.product.findFirst({
where: { stripeKey: product, isPublished: true },
select: { stripePriceId: true, stripeKey: true },
})
if (!p || !p.stripePriceId) return reply.code(404).send({ error: 'Produit inconnu' })
let session
try {
session = await stripe.checkout.sessions.create({
mode: 'payment',
payment_method_types: ['card', 'link'],
line_items: [{
price: p.stripePriceId,
quantity: 1,
}],
metadata: { product },
success_url: `${DOMAIN}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${DOMAIN}/#collection`,
locale: 'fr',
customer_email: email ?? undefined,
custom_text: {
submit: { message: 'Pièce unique — fabriquée à Paris. Délai : 6 à 8 semaines.' },
},
})
} catch (err) {
app.log.error(err)
return reply.code(500).send({ error: err.message })
}
return { url: session.url }
})
// ── Vérification session ────────────────────────────────────────────────────
app.get('/api/session/:id', async (request) => {
const session = await stripe.checkout.sessions.retrieve(request.params.id, {
expand: ['payment_intent.latest_charge'],
})
const charge = session.payment_intent?.latest_charge
return {
status: session.payment_status,
amount: session.amount_total,
currency: session.currency,
customer_email: session.customer_details?.email ?? null,
product: session.metadata?.product ?? null,
receipt_url: charge?.receipt_url ?? null,
}
})
// ── Start ───────────────────────────────────────────────────────────────────
try {
await app.listen({ port: process.env.PORT ?? 3000, host: '127.0.0.1' })
await app.listen({ port: process.env.PORT ?? 3000, host: '0.0.0.0' })
} catch (err) {
app.log.error(err)
process.exit(1)

5
src/lib/db.mjs Normal file
View File

@ -0,0 +1,5 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis
export const prisma = globalForPrisma.__prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.__prisma = prisma

View File

@ -1,44 +1,35 @@
---
import Base from '../../layouts/Base.astro';
import { prisma } from '../../lib/db.mjs';
export function getStaticPaths() {
const PRODUCTS = [
{
slug: 'solar-altar',
name: 'Solar_Altar',
title: 'REBOURS — Solar Altar | Collection 001',
description: 'Lampe de table unique. Béton texturé coulé à la main + dôme céramique laqué. Pièce unique fabriquée à Paris.',
ogImage: 'https://rebours.studio/assets/lamp-violet.jpg',
productName: 'Solar Altar',
price: '1800',
availability: 'https://schema.org/LimitedAvailability',
export async function getStaticPaths() {
const products = await prisma.product.findMany({
where: { isPublished: true },
orderBy: { sortOrder: 'asc' },
});
return products.map(p => ({
params: { slug: p.slug },
props: {
slug: p.slug,
name: p.name,
title: p.seoTitle,
description: p.seoDescription,
ogImage: p.ogImage,
productName: p.productDisplayName,
price: p.price ? String(p.price / 100) : null,
availability: p.availability,
},
{
slug: 'table-terrazzo',
name: 'TABLE_TERRAZZO',
title: 'REBOURS — TABLE TERRAZZO | Collection 001',
description: 'Table basse et étagère modulaire. Terrazzo fait main + acier tubulaire. Pièce unique fabriquée à Paris.',
ogImage: 'https://rebours.studio/assets/table-terrazzo.jpg',
productName: 'Table Terrazzo',
price: null,
availability: 'https://schema.org/PreOrder',
},
{
slug: 'module-serie',
name: 'MODULE_SÉRIE',
title: 'REBOURS — MODULE SÉRIE | Collection 001',
description: 'Série de 7 lampes béton colorées, dôme laqué et néon. Édition limitée fabriquée à Paris.',
ogImage: 'https://rebours.studio/assets/lampes-serie.jpg',
productName: 'Module Série',
price: null,
availability: 'https://schema.org/PreOrder',
},
];
return PRODUCTS.map(p => ({ params: { slug: p.slug }, props: p }));
}));
}
const { slug, title, description, ogImage, name, productName, price, availability } = Astro.props;
const allProducts = await prisma.product.findMany({
where: { isPublished: true },
orderBy: { sortOrder: 'asc' },
});
const schemaProduct = {
"@context": "https://schema.org",
"@type": "Product",
@ -83,9 +74,8 @@ const schemaBreadcrumb = {
<!-- On charge index.html entier et on ouvre le panel via JS au load -->
<meta name="x-open-panel" content={name} />
<!-- Même contenu que index.astro — redirige vers / avec panel ouvert -->
<!-- Avant le DOM : on note quel panel ouvrir -->
<script is:inline>
// Avant le DOM : on note quel panel ouvrir
window.__OPEN_PANEL__ = document.querySelector('meta[name="x-open-panel"]')?.content;
</script>
@ -140,7 +130,7 @@ const schemaBreadcrumb = {
<div id="checkout-section" style="display:none;">
<div class="checkout-price-line">
<span class="checkout-price">1 800 €</span>
<span class="checkout-price"></span>
<span class="checkout-edition">ÉDITION UNIQUE — 1/1</span>
</div>
<button id="checkout-toggle-btn" class="checkout-btn">
@ -205,77 +195,38 @@ const schemaBreadcrumb = {
<section class="collection" id="collection" aria-label="Collection 001">
<div class="collection-header">
<p class="label">// COLLECTION_001</p>
<span class="label">3 OBJETS — CLIQUER POUR OUVRIR</span>
<span class="label">{allProducts.length} OBJETS — CLIQUER POUR OUVRIR</span>
</div>
<div class="product-grid">
{allProducts.map((p, i) => (
<article class="product-card"
data-index="PROJET_001"
data-name="Solar_Altar"
data-type="LAMPE DE TABLE"
data-mat="BÉTON TEXTURÉ + DÔME CÉRAMIQUE LAQUÉ"
data-year="2026"
data-status="PROTOTYPE [80%]"
data-desc="Exploration de la lumière à travers des contraintes géométriques. Le dôme sphérique en céramique laquée coiffe un corps en béton texturé peint à la main. Chaque pièce est unique."
data-specs="H: 45cm / Ø: 18cm&#10;Poids: 3.2kg&#10;Alimentation: 220V — E27&#10;Câble: tressé rouge 2m"
data-notes="Inspiré des lampadaires soviétiques des années 60. Le béton est coulé à la main dans des moules uniques. La peinture acrylique est appliquée au spalter."
data-img="/assets/lamp-violet.jpg"
aria-label="Ouvrir le détail de Solar Altar">
data-index={p.index}
data-name={p.name}
data-type={p.type}
data-mat={p.materials}
data-year={p.year}
data-status={p.status}
data-desc={p.description}
data-specs={p.specs}
data-notes={p.notes}
data-img={p.imagePath}
data-price={p.price ? String(p.price) : ''}
data-stripe-key={p.stripeKey ?? ''}
aria-label={`Ouvrir le détail de ${p.productDisplayName}`}>
<div class="card-img-wrap">
<img src="/assets/lamp-violet.jpg" alt="Solar Altar — Lampe béton violet, dôme céramique bleu, REBOURS 2026" width="600" height="600" loading="lazy">
<img src={p.imagePath}
alt={p.imageAlt || p.productDisplayName}
width="600" height="600"
loading={i === 0 ? "eager" : "lazy"}>
</div>
<div class="card-meta">
<span class="card-index">001</span>
<span class="card-name">Solar_Altar</span>
<span class="card-index">{String(i + 1).padStart(3, '0')}</span>
<span class="card-name">{p.name}</span>
<span class="card-arrow">↗</span>
</div>
</article>
<article class="product-card"
data-index="PROJET_002"
data-name="TABLE_TERRAZZO"
data-type="TABLE BASSE + ÉTAGÈRE MODULAIRE"
data-mat="TERRAZZO + ACIER TUBULAIRE + RÉSINE"
data-year="2026"
data-status="STRUCTURAL_TEST"
data-desc="Collision du brutalisme et de la couleur Memphis. Le plateau en terrazzo fait à la main intègre des inclusions de marbre rose et bleu. Les colonnes cylindriques bicolores sont en acier peint au four."
data-specs="Table: L120 × P60 × H38cm&#10;Poids plateau: 28kg&#10;Pieds: acier Ø60mm&#10;Étagère: H180 × L80 × P35cm"
data-notes="Le terrazzo est réalisé dans l'atelier de Pantin. Chaque dalle est unique. L'étagère est assemblée à partir de tubes industriels récupérés et de panneaux laqués."
data-img="/assets/table-terrazzo.jpg"
aria-label="Ouvrir le détail de TABLE TERRAZZO">
<div class="card-img-wrap">
<img src="/assets/table-terrazzo.jpg" alt="TABLE TERRAZZO — Table basse terrazzo et étagère acier, REBOUR 2026" width="600" height="600" loading="lazy">
</div>
<div class="card-meta">
<span class="card-index">002</span>
<span class="card-name">TABLE_TERRAZZO</span>
<span class="card-arrow">↗</span>
</div>
</article>
<article class="product-card"
data-index="PROJET_003"
data-name="MODULE_SÉRIE"
data-type="LAMPES — SÉRIE LIMITÉE"
data-mat="BÉTON COLORÉ + DÔME LAQUÉ + NÉON"
data-year="2026"
data-status="FINAL_ASSEMBLY"
data-desc="Série de 7 lampes aux corps béton colorés, chacune avec un dôme d'une couleur différente. Les néons horizontaux créent un anneau lumineux entre le dôme et le corps."
data-specs="H: 3565cm (7 tailles)&#10;Dôme: Ø1528cm&#10;Anneau néon: 8W — 3000K&#10;Édition: 7 ex. par coloris"
data-notes="Les corps sont coulés en série mais peints individuellement. Les dômes sont réalisés par un souffleur de verre artisanal. Le câble tressé rouge est la signature de la série."
data-img="/assets/lampes-serie.jpg"
aria-label="Ouvrir le détail de MODULE SÉRIE">
<div class="card-img-wrap">
<img src="/assets/lampes-serie.jpg" alt="MODULE SÉRIE — Collection de 7 lampes béton colorées, REBOUR 2026" width="600" height="600" loading="lazy">
</div>
<div class="card-meta">
<span class="card-index">003</span>
<span class="card-name">MODULE_SÉRIE</span>
<span class="card-arrow">↗</span>
</div>
</article>
))}
</div>
</section>

View File

@ -1,5 +1,11 @@
---
import Base from '../layouts/Base.astro';
import { prisma } from '../lib/db.mjs';
const products = await prisma.product.findMany({
where: { isPublished: true },
orderBy: { sortOrder: 'asc' },
});
const schemaOrg = {
"@context": "https://schema.org",
@ -12,18 +18,18 @@ const schemaOrg = {
"hasOfferCatalog": {
"@type": "OfferCatalog",
"name": "Collection 001",
"itemListElement": [{
"itemListElement": products.filter(p => p.price).map(p => ({
"@type": "Offer",
"itemOffered": {
"@type": "Product",
"name": "Solar Altar",
"description": "Lampe de table unique. Béton texturé coulé à la main + dôme céramique laqué.",
"image": "https://rebours.studio/assets/lamp-violet.jpg"
"name": p.productDisplayName,
"description": p.seoDescription,
"image": p.ogImage,
},
"price": "1800",
"priceCurrency": "EUR",
"availability": "https://schema.org/LimitedAvailability"
}]
"price": String(p.price! / 100),
"priceCurrency": p.currency,
"availability": p.availability,
})),
}
};
---
@ -88,7 +94,7 @@ const schemaOrg = {
<div id="checkout-section" style="display:none;">
<div class="checkout-price-line">
<span class="checkout-price">1 800 €</span>
<span class="checkout-price"></span>
<span class="checkout-edition">ÉDITION UNIQUE — 1/1</span>
</div>
<button id="checkout-toggle-btn" class="checkout-btn">
@ -155,86 +161,38 @@ const schemaOrg = {
<section class="collection" id="collection" aria-label="Collection 001">
<div class="collection-header">
<p class="label">// COLLECTION_001</p>
<span class="label">3 OBJETS — CLIQUER POUR OUVRIR</span>
<span class="label">{products.length} OBJETS — CLIQUER POUR OUVRIR</span>
</div>
<div class="product-grid">
{products.map((p, i) => (
<article class="product-card"
data-index="PROJET_001"
data-name="Solar_Altar"
data-type="LAMPE DE TABLE"
data-mat="BÉTON TEXTURÉ + DÔME CÉRAMIQUE LAQUÉ"
data-year="2026"
data-status="PROTOTYPE [80%]"
data-desc="Exploration de la lumière à travers des contraintes géométriques. Le dôme sphérique en céramique laquée coiffe un corps en béton texturé peint à la main. Chaque pièce est unique."
data-specs="H: 45cm / Ø: 18cm&#10;Poids: 3.2kg&#10;Alimentation: 220V — E27&#10;Câble: tressé rouge 2m"
data-notes="Inspiré des lampadaires soviétiques des années 60. Le béton est coulé à la main dans des moules uniques. La peinture acrylique est appliquée au spalter."
data-img="/assets/lamp-violet.jpg"
aria-label="Ouvrir le détail de Solar Altar">
data-index={p.index}
data-name={p.name}
data-type={p.type}
data-mat={p.materials}
data-year={p.year}
data-status={p.status}
data-desc={p.description}
data-specs={p.specs}
data-notes={p.notes}
data-img={p.imagePath}
data-price={p.price ? String(p.price) : ''}
data-stripe-key={p.stripeKey ?? ''}
aria-label={`Ouvrir le détail de ${p.productDisplayName}`}>
<div class="card-img-wrap">
<img src="/assets/lamp-violet.jpg"
alt="Solar Altar — Lampe béton violet, dôme céramique bleu, REBOURS 2026"
<img src={p.imagePath}
alt={p.imageAlt || p.productDisplayName}
width="600" height="600"
loading="lazy">
loading={i === 0 ? "eager" : "lazy"}>
</div>
<div class="card-meta">
<span class="card-index">001</span>
<span class="card-name">Solar_Altar</span>
<span class="card-index">{String(i + 1).padStart(3, '0')}</span>
<span class="card-name">{p.name}</span>
<span class="card-arrow">↗</span>
</div>
</article>
<article class="product-card"
data-index="PROJET_002"
data-name="TABLE_TERRAZZO"
data-type="TABLE BASSE + ÉTAGÈRE MODULAIRE"
data-mat="TERRAZZO + ACIER TUBULAIRE + RÉSINE"
data-year="2026"
data-status="STRUCTURAL_TEST"
data-desc="Collision du brutalisme et de la couleur Memphis. Le plateau en terrazzo fait à la main intègre des inclusions de marbre rose et bleu. Les colonnes cylindriques bicolores sont en acier peint au four."
data-specs="Table: L120 × P60 × H38cm&#10;Poids plateau: 28kg&#10;Pieds: acier Ø60mm&#10;Étagère: H180 × L80 × P35cm"
data-notes="Le terrazzo est réalisé dans l'atelier de Pantin. Chaque dalle est unique. L'étagère est assemblée à partir de tubes industriels récupérés et de panneaux laqués."
data-img="/assets/table-terrazzo.jpg"
aria-label="Ouvrir le détail de TABLE TERRAZZO">
<div class="card-img-wrap">
<img src="/assets/table-terrazzo.jpg"
alt="TABLE TERRAZZO — Table basse terrazzo et étagère acier, REBOUR 2026"
width="600" height="600"
loading="lazy">
</div>
<div class="card-meta">
<span class="card-index">002</span>
<span class="card-name">TABLE_TERRAZZO</span>
<span class="card-arrow">↗</span>
</div>
</article>
<article class="product-card"
data-index="PROJET_003"
data-name="MODULE_SÉRIE"
data-type="LAMPES — SÉRIE LIMITÉE"
data-mat="BÉTON COLORÉ + DÔME LAQUÉ + NÉON"
data-year="2026"
data-status="FINAL_ASSEMBLY"
data-desc="Série de 7 lampes aux corps béton colorés, chacune avec un dôme d'une couleur différente. Les néons horizontaux créent un anneau lumineux entre le dôme et le corps."
data-specs="H: 3565cm (7 tailles)&#10;Dôme: Ø1528cm&#10;Anneau néon: 8W — 3000K&#10;Édition: 7 ex. par coloris"
data-notes="Les corps sont coulés en série mais peints individuellement. Les dômes sont réalisés par un souffleur de verre artisanal. Le câble tressé rouge est la signature de la série."
data-img="/assets/lampes-serie.jpg"
aria-label="Ouvrir le détail de MODULE SÉRIE">
<div class="card-img-wrap">
<img src="/assets/lampes-serie.jpg"
alt="MODULE SÉRIE — Collection de 7 lampes béton colorées, REBOUR 2026"
width="600" height="600"
loading="lazy">
</div>
<div class="card-meta">
<span class="card-index">003</span>
<span class="card-name">MODULE_SÉRIE</span>
<span class="card-arrow">↗</span>
</div>
</article>
))}
</div>
</section>

View File

@ -1,5 +1,13 @@
---
import Base from '../layouts/Base.astro';
import { prisma } from '../lib/db.mjs';
const products = await prisma.product.findMany({
select: { stripeKey: true, imagePath: true },
});
const productImages = Object.fromEntries(
products.filter(p => p.stripeKey).map(p => [p.stripeKey, p.imagePath])
);
---
<Base
@ -30,7 +38,14 @@ import Base from '../layouts/Base.astro';
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.55;
opacity: 1;
}
.left::after {
content: '';
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.25);
pointer-events: none;
}
.slabel { font-size: 0.75rem; color: #888; }
h1 {
@ -76,11 +91,13 @@ import Base from '../layouts/Base.astro';
font-weight: 700;
text-decoration: none;
color: var(--clr-black);
transition: background 0.15s;
transition: background 0.15s, color 0.15s;
align-self: flex-start;
margin-top: 1rem;
cursor: none;
}
a.back:hover { background: var(--clr-black); color: var(--clr-white); }
#receipt-btn:hover { background: var(--clr-red) !important; color: var(--clr-black) !important; border-color: var(--clr-red) !important; }
#loading { color: #888; font-size: 0.78rem; }
@media (max-width: 600px) {
@ -97,7 +114,10 @@ import Base from '../layouts/Base.astro';
}
</style>
<div style="display:flex; flex-direction:column; min-height:100vh;">
<!-- Grid background -->
<div id="interactive-grid" class="interactive-grid"></div>
<div style="display:flex; flex-direction:column; min-height:100vh; position:relative; z-index:1;">
<header class="header">
<a href="/" class="logo-text">REBOURS</a>
<span style="font-size:0.78rem;color:#888">COLLECTION_001</span>
@ -142,20 +162,21 @@ import Base from '../layouts/Base.astro';
</footer>
</div>
<script>
<div class="cursor-dot"></div>
<div class="cursor-outline"></div>
<script src="/main.js" is:inline></script>
<script define:vars={{ productImages }}>
const params = new URLSearchParams(window.location.search);
const sessionId = params.get('session_id');
const PRODUCT_IMAGES: Record<string, string> = {
lumiere_orbitale: '/assets/lamp-violet.jpg',
table_terrazzo: '/assets/table-terrazzo.jpg',
module_serie: '/assets/lampes-serie.jpg',
};
const PRODUCT_IMAGES = productImages;
if (sessionId) {
fetch(`/api/session/${sessionId}`)
.then(r => r.json())
.then((data: { amount?: number; customer_email?: string; product?: string; receipt_url?: string }) => {
.then((data) => {
const loading = document.getElementById('loading');
const orderDetails = document.getElementById('order-details');
if (loading) loading.style.display = 'none';
@ -168,12 +189,12 @@ import Base from '../layouts/Base.astro';
if (emailEl) emailEl.textContent = data.customer_email ?? '—';
if (data.product && PRODUCT_IMAGES[data.product]) {
const img = document.getElementById('product-img') as HTMLImageElement;
if (img) img.src = PRODUCT_IMAGES[data.product]!;
const img = document.getElementById('product-img');
if (img) img.src = PRODUCT_IMAGES[data.product];
}
if (data.receipt_url) {
const btn = document.getElementById('receipt-btn') as HTMLAnchorElement;
const btn = document.getElementById('receipt-btn');
if (btn) { btn.href = data.receipt_url; btn.style.display = 'inline-block'; }
}
})