This commit is contained in:
ordinarthur 2026-03-30 18:03:12 +02:00
parent 39f7db84fb
commit ea865574b7
58 changed files with 12756 additions and 9101 deletions

View File

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

View File

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

View File

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

View File

@ -1,111 +0,0 @@
---
description: Use Bun instead of Node.js, npm, pnpm, or vite.
globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json"
alwaysApply: false
---
Default to using Bun instead of Node.js.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Use `bunx <package> <command>` instead of `npx <package> <command>`
- Bun automatically loads .env, so don't use dotenv.
## APIs
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.
## Testing
Use `bun test` to run tests.
```ts#index.test.ts
import { test, expect } from "bun:test";
test("hello world", () => {
expect(1).toBe(1);
});
```
## Frontend
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
Server:
```ts#index.ts
import index from "./index.html"
Bun.serve({
routes: {
"/": index,
"/api/users/:id": {
GET: (req) => {
return new Response(JSON.stringify({ id: req.params.id }));
},
},
},
// optional websocket support
websocket: {
open: (ws) => {
ws.send("Hello, world!");
},
message: (ws, message) => {
ws.send(message);
},
close: (ws) => {
// handle close
}
},
development: {
hmr: true,
console: true,
}
})
```
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
```html#index.html
<html>
<body>
<h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>
```
With the following `frontend.tsx`:
```tsx#frontend.tsx
import React from "react";
import { createRoot } from "react-dom/client";
// import .css files directly and it works
import './index.css';
const root = createRoot(document.body);
export default function Frontend() {
return <h1>Hello, world!</h1>;
}
root.render(<Frontend />);
```
Then, run index.ts
```sh
bun --hot ./index.ts
```
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.

View File

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

View File

@ -1,27 +1,12 @@
# ── Stripe ──────────────────────────────────────────────────────────────────── # ── Sanity ─────────────────────────────────────────────────────────────────────
# Clé secrète Stripe (test: sk_test_... / prod: sk_live_...) SANITY_PROJECT_ID=your_project_id
STRIPE_SECRET_KEY=sk_test_... SANITY_DATASET=production
SANITY_API_TOKEN= # Optional: for authenticated server-side reads
# Secret webhook (obtenu via: stripe listen --print-secret) # ── Stripe ────────────────────────────────────────────────────────────────────
STRIPE_WEBHOOK_SECRET=whsec_... STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_... # stripe listen --print-secret
# ── App ─────────────────────────────────────────────────────────────────────── # ── App ───────────────────────────────────────────────────────────────────────
# URL publique du site (sans slash final) DOMAIN=http://localhost:4321 # Dev: http://localhost:4321 | Prod: https://rebours.studio
DOMAIN=https://rebours.studio PORT=8888 # Dev: 8888 | Prod: 3000
# 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

59
.gitignore vendored
View File

@ -1,52 +1,27 @@
# dependencies (bun install) # dependencies
node_modules node_modules/
# output # output
out dist/
dist out/
*.tgz
# code coverage # env
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
# Secrets
.env .env
.env.* .env.*
!.env.example !.env.example
# SSL certs # caches
ssl/ .astro/
.cache/
.eslintcache
*.tsbuildinfo
# Nginx logs volume # OS
nginx-logs/ .DS_Store
# Uploaded images (managed via admin) # logs
uploads/ *.log
# SQLite database files # Sanity Studio
prisma/*.db sanity/node_modules/
prisma/*.db-journal sanity/dist/

253
CLAUDE.md
View File

@ -7,18 +7,27 @@ rebours/
├── src/ ├── src/
│ ├── layouts/ │ ├── layouts/
│ │ └── Base.astro # Layout HTML commun (SEO, fonts, CSS) │ │ └── Base.astro # Layout HTML commun (SEO, fonts, CSS)
│ └── pages/ │ ├── lib/
│ ├── index.astro # Page principale (hero, collection, newsletter) │ │ └── sanity.mjs # Client Sanity + queries + image helper
│ ├── collection/ │ ├── pages/
│ │ └── [slug].astro # Pages produits statiques (SSG) │ │ ├── index.astro # Page principale (hero, collection, newsletter)
│ └── success.astro # Page de confirmation Stripe │ │ ├── collection/
│ │ │ └── [slug].astro # Pages produits statiques (SSG)
│ │ ├── success.astro # Page de confirmation Stripe
│ │ ├── robots.txt.ts # robots.txt généré au build
│ │ └── sitemap.xml.ts # sitemap.xml généré au build
│ └── scripts/
│ └── main.js # JS client (cursor CAD, grid, panel, routing, checkout)
├── public/ ├── public/
│ ├── style.css # CSS global │ ├── style.css # CSS global
│ ├── main.js # JS client (cursor, grid, panel, routing) │ └── assets/ # Images statiques (fallback), favicon, son ambiant
│ ├── robots.txt # SEO ├── sanity/ # Sanity Studio (projet séparé)
│ ├── sitemap.xml # SEO │ ├── sanity.config.ts
│ └── assets/ # Images produits │ ├── sanity.cli.ts
├── server.mjs # Serveur API Fastify (Stripe) │ └── schemas/
│ ├── product.ts # Schéma produit
│ └── index.ts
├── server.mjs # Serveur API Fastify (Stripe uniquement)
├── astro.config.mjs # Config Astro (SSG, proxy dev) ├── astro.config.mjs # Config Astro (SSG, proxy dev)
├── nginx.conf # Config nginx de référence ├── nginx.conf # Config nginx de référence
└── .env # Variables d'environnement (non versionné) └── .env # Variables d'environnement (non versionné)
@ -28,9 +37,11 @@ rebours/
| Couche | Techno | | Couche | Techno |
|--------|--------| |--------|--------|
| Front (SSG) | Astro + HTML/CSS/JS vanilla | | Front (SSG) | Astro + HTML/CSS/JS vanilla + GSAP |
| CMS | Sanity (headless, hébergé) |
| API | Fastify (Node.js) | | API | Fastify (Node.js) |
| Paiement | Stripe Checkout | | Paiement | Stripe Checkout (price_data inline) |
| Images | Sanity CDN (avec transformations) |
| Reverse proxy | Nginx | | Reverse proxy | Nginx |
| Hébergement | VPS (Debian) | | Hébergement | VPS (Debian) |
| Fonts | Space Mono (Google Fonts) | | Fonts | Space Mono (Google Fonts) |
@ -40,20 +51,23 @@ rebours/
## Développement local ## Développement local
### Prérequis ### Prérequis
- Node.js ≥ 18 - Node.js >= 18
- Un compte Sanity avec un projet créé
- Un fichier `.env` à la racine (voir `.env.example`) - Un fichier `.env` à la racine (voir `.env.example`)
### Variables d'environnement (.env) ### Variables d'environnement (.env)
```env ```env
SANITY_PROJECT_ID=your_project_id
SANITY_DATASET=production
SANITY_API_TOKEN= # Optionnel
STRIPE_SECRET_KEY=sk_test_... STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_... STRIPE_WEBHOOK_SECRET=whsec_...
DOMAIN=http://localhost:4321 DOMAIN=http://localhost:4321
PORT=8888 PORT=8888
``` ```
> En dev, le serveur Fastify tourne sur le port **8888** (pour ne pas entrer en conflit avec d'autres services).
> Le proxy Vite dans `astro.config.mjs` redirige `/api/*``http://127.0.0.1:8888`.
### Lancer le projet ### Lancer le projet
```bash ```bash
npm install npm install
@ -61,9 +75,19 @@ npm run dev
``` ```
Cela lance en parallèle (via `concurrently`) : Cela lance en parallèle (via `concurrently`) :
- `astro dev` http://localhost:4321 - `astro dev` sur http://localhost:4321
- `node --watch server.mjs` (mode dev, PORT=8888) - `node --watch server.mjs` (mode dev, PORT=8888)
Le proxy Vite dans `astro.config.mjs` redirige `/api/*` vers `http://127.0.0.1:8888`.
### Sanity Studio
```bash
cd sanity
npm install
npx sanity dev
```
Accessible sur http://localhost:3333
### Build ### Build
```bash ```bash
npm run build npm run build
@ -72,170 +96,109 @@ npm run build
--- ---
## CMS — Sanity
### Accès
- Studio local : `npx sanity dev`
- Studio déployé : `npx sanity deploy`
- Dashboard : https://www.sanity.io/manage
### Schéma Produit
Champs principaux :
- **name** : Nom technique (Solar_Altar)
- **productDisplayName** : Nom affiché (Solar Altar)
- **slug** : Auto-généré depuis le nom
- **image** : Image avec hotspot + texte alt
- **price** : En centimes (180000 = 1 800 EUR). Vide = non disponible
- **isPublished** : Toggle pour masquer sans supprimer
### Ajouter un produit
1. Ouvrir Sanity Studio
2. Créer un nouveau document "Produit"
3. Remplir les champs, uploader l'image
4. Publier
5. Rebuild le site : `npm run build` + déployer
### Images
Les images sont servies via le CDN Sanity avec transformations automatiques.
---
## Stripe
### Architecture
Le checkout utilise `price_data` inline — pas de prix pré-créés dans Stripe.
Quand un client clique "Commander" :
1. Le front envoie le slug du produit à `/api/checkout`
2. Le serveur fetch le produit depuis Sanity (prix, nom, image)
3. Le serveur crée une session Stripe Checkout avec `price_data`
4. Le client est redirigé vers Stripe
5. Après paiement : `/success?session_id=...`
### Endpoints API
| Route | Méthode | Description |
|-------|---------|-------------|
| `/api/checkout` | POST | Crée une session Stripe Checkout |
| `/api/session/:id` | GET | Vérifie le statut d'une session |
| `/api/webhook` | POST | Reçoit les événements Stripe |
| `/api/health` | GET | Health check |
### Configuration
- Test : clés `sk_test_...` dans `.env`
- Prod : clés `sk_live_...` dans `.env` sur le serveur
- Webhook : `https://rebours.studio/api/webhook`
---
## Production ## Production
### Serveur : ordinarthur@10.10.0.13 ### Serveur : ordinarthur@10.10.0.13
### Architecture prod ### Architecture prod
``` ```
Internet → Nginx (port 80) → /var/www/html/rebours/dist/ (fichiers statiques) Internet -> Nginx (port 80) -> /var/www/html/rebours/dist/ (statiques)
→ /api/* → proxy → Fastify :3000 -> /api/* -> proxy -> Fastify :3000
``` ```
### Chemins importants sur le serveur
| Quoi | Où |
|------|----|
| Fichiers web | `/var/www/html/rebours/dist/` |
| Projet complet | `/var/www/html/rebours/` |
| Config nginx | `/etc/nginx/sites-available/rebours` |
| Service systemd | `rebours.service` |
| Logs | `journalctl -u rebours -f` |
### Variables d'environnement en prod ### Variables d'environnement en prod
Le fichier `.env` est dans `/var/www/html/rebours/.env` :
```env ```env
SANITY_PROJECT_ID=...
SANITY_DATASET=production
STRIPE_SECRET_KEY=sk_live_... STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_... STRIPE_WEBHOOK_SECRET=whsec_...
DOMAIN=https://rebours.studio DOMAIN=https://rebours.studio
PORT=3000 PORT=3000
``` ```
### Service systemd ### Déploiement
Le serveur Fastify est géré par systemd :
```bash
sudo systemctl status rebours
sudo systemctl restart rebours
sudo systemctl stop rebours
journalctl -u rebours -f # logs en temps réel
```
---
## Déploiement (mise à jour du site)
### 1. Build en local
```bash ```bash
npm run build npm run build
# Génère ./dist/
```
### 2. Envoyer les fichiers sur le serveur
Les fichiers statiques Astro :
```bash
scp -r dist/* ordinarthur@10.10.0.13:/tmp/rebours-dist/ scp -r dist/* ordinarthur@10.10.0.13:/tmp/rebours-dist/
ssh ordinarthur@10.10.0.13 "sudo cp -r /tmp/rebours-dist/* /var/www/html/rebours/dist/" ssh ordinarthur@10.10.0.13 "sudo cp -r /tmp/rebours-dist/* /var/www/html/rebours/dist/"
``` ```
Si le server.mjs a changé : Si server.mjs a changé :
```bash ```bash
scp server.mjs ordinarthur@10.10.0.13:/tmp/server.mjs scp server.mjs ordinarthur@10.10.0.13:/tmp/server.mjs
ssh ordinarthur@10.10.0.13 "sudo cp /tmp/server.mjs /var/www/html/rebours/server.mjs && sudo systemctl restart rebours" ssh ordinarthur@10.10.0.13 "sudo cp /tmp/server.mjs /var/www/html/rebours/server.mjs && sudo systemctl restart rebours"
``` ```
### 3. Vérifier
```bash
ssh ordinarthur@10.10.0.13 "sudo nginx -t && sudo systemctl status rebours"
```
### Permissions (si problème 403 nginx)
```bash
ssh ordinarthur@10.10.0.13 "sudo chown -R www-data:www-data /var/www/html/rebours/dist"
```
---
## Nginx — référence
Le fichier `nginx.conf` à la racine du projet est la config de **référence**.
La config réelle sur le serveur est dans `/etc/nginx/sites-available/rebours`.
Pour mettre à jour la config nginx :
```bash
scp nginx.conf ordinarthur@10.10.0.13:/tmp/nginx-rebours.conf
ssh ordinarthur@10.10.0.13 "sudo cp /tmp/nginx-rebours.conf /etc/nginx/sites-available/rebours && sudo nginx -t && sudo systemctl reload nginx"
```
Points clés de la config :
- `root /var/www/html/rebours/dist` → fichiers statiques Astro
- `try_files $uri $uri/ $uri.html /index.html` → SPA fallback pour les routes Astro
- `/api/` → proxy vers Fastify sur `127.0.0.1:3000`
- HTML : `no-store` (jamais caché)
- CSS/JS/assets : `immutable` (hash dans le nom de fichier, cache 1 an)
--- ---
## Routing ## Routing
Le routing est hybride :
| URL | Comportement | | URL | Comportement |
|-----|-------------| |-----|-------------|
| `/` | Page principale Astro | | `/` | Page principale Astro (SSG) |
| `/collection/lumiere-orbitale` | Page Astro SSG générée statiquement, ouvre le panel auto via `window.__OPEN_PANEL__` | | `/collection/{slug}` | Page produit (SSG), auto-open panel via `window.__OPEN_PANEL__` |
| `/collection/table-terrazzo` | idem |
| `/collection/module-serie` | idem |
| `/success?session_id=...` | Page de confirmation Stripe | | `/success?session_id=...` | Page de confirmation Stripe |
| `/robots.txt` | Généré au build |
Quand on clique sur une carte produit depuis le navigateur (sans refresh) : | `/sitemap.xml` | Généré au build depuis Sanity |
- Le panel s'ouvre
- `history.pushState` change l'URL → `/collection/{slug}`
- Le bouton retour du navigateur ferme le panel et revient à `/`
Quand on arrive directement sur `/collection/{slug}` (lien partagé, refresh) :
- Astro sert la page statique correspondante
- Un script inline lit `<meta name="x-open-panel">` et set `window.__OPEN_PANEL__`
- `main.js` lit `window.__OPEN_PANEL__` au DOMContentLoaded et ouvre le bon panel
---
## Ajouter un produit
### 1. Ajouter la carte dans `src/pages/index.astro` et `src/pages/collection/[slug].astro`
Copier un `<article class="product-card">` existant et modifier les `data-*`.
### 2. Ajouter le slug dans `[slug].astro`
Dans `getStaticPaths()`, ajouter une entrée dans le tableau `PRODUCTS` :
```js
{
slug: 'mon-nouveau-produit',
name: 'MON_PRODUIT',
title: 'REBOURS — Mon Produit | Collection 001',
description: 'Description pour le SEO.',
ogImage: 'https://rebours.studio/assets/mon-produit.jpg',
},
```
### 3. Ajouter l'image dans `public/assets/`
Format recommandé : JPG 1024×1024, < 300 Ko.
### 4. Ajouter le prix Stripe dans `server.mjs`
```js
const PRODUCTS = {
lumiere_orbitale: { price_id: 'price_xxx' },
mon_nouveau_produit: { price_id: 'price_yyy' }, // ← ajouter
}
```
### 5. Rebuild et déployer
```bash
npm run build
# puis déployer (voir section Déploiement)
```
---
## Stripe
- **Test** : utiliser les clés `sk_test_...` dans `.env`
- **Prod** : utiliser les clés `sk_live_...` dans `.env` sur le serveur
- La redirection après paiement va vers `${DOMAIN}/success?session_id=...`
- Le webhook Stripe doit pointer vers `https://rebours.studio/api/webhook`
- Le `STRIPE_WEBHOOK_SECRET` correspond au secret généré dans le dashboard Stripe pour ce webhook
--- ---
## Fichiers à ne jamais versionner ## Fichiers à ne jamais versionner
- `.env` (clés Stripe, secrets) - `.env`
- `node_modules/` - `node_modules/`
- `dist/` (généré par le build) - `dist/`
- `sanity/node_modules/`

202
DEPLOY.md
View File

@ -1,202 +0,0 @@
# 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/
```

View File

@ -1,22 +0,0 @@
# ── Install dependencies ─────────────────────────────────────────────────────
FROM node:22-alpine AS deps
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
COPY prisma ./prisma/
RUN pnpm install --frozen-lockfile
# ── Production ───────────────────────────────────────────────────────────────
FROM node:22-alpine
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm prisma generate
EXPOSE 3001
# Build Astro (needs DB) then start Fastify API
CMD ["sh", "-c", "pnpm prisma migrate deploy && pnpm build && node server.mjs"]

View File

@ -1,15 +0,0 @@
# rebours
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run
```
This project was created using `bun init` in bun v1.3.9. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.

307
admin.mjs
View File

@ -1,307 +0,0 @@
import AdminJS, { ComponentLoader } from 'adminjs'
import AdminJSFastify from '@adminjs/fastify'
import { Database, Resource, getModelByName } from '@adminjs/prisma'
import uploadFeature from '@adminjs/upload'
import bcrypt from 'bcrypt'
import Stripe from 'stripe'
import path from 'path'
import { exec } from 'child_process'
import { prisma } from './src/lib/db.mjs'
AdminJS.registerAdapter({ Database, Resource })
const componentLoader = new ComponentLoader()
const stripe = process.env.STRIPE_SECRET_KEY
? new Stripe(process.env.STRIPE_SECRET_KEY)
: null
const DOMAIN = process.env.DOMAIN ?? 'https://rebours.studio'
const UPLOADS_DIR = path.resolve('uploads')
// ── 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 }
// ── Stripe price sync after product edit ─────────────────────────────────────
async function syncPriceToStripe(response) {
if (!stripe) return response
const product = await prisma.product.findUnique({
where: { id: response.record.params.id },
})
if (!product) return response
const priceCents = product.price
const currency = (product.currency || 'EUR').toLowerCase()
const slug = product.slug
try {
// 1. Find or create Stripe product by metadata.slug
let stripeProduct = null
const existing = await stripe.products.search({ query: `metadata["slug"]:"${slug}"` })
if (existing.data.length > 0) {
stripeProduct = existing.data[0]
await stripe.products.update(stripeProduct.id, {
name: product.productDisplayName,
description: product.description,
images: product.ogImage ? [product.ogImage] : undefined,
active: true,
})
} else if (priceCents) {
stripeProduct = await stripe.products.create({
name: product.productDisplayName,
description: product.description,
images: product.ogImage ? [product.ogImage] : [],
metadata: { slug, stripeKey: product.stripeKey || slug, dbId: product.id },
})
console.log(`[stripe] Product created: ${stripeProduct.id}`)
}
if (!stripeProduct) return response
// 2. If price is null, archive old price and clear DB
if (!priceCents) {
if (product.stripePriceId) {
await stripe.prices.update(product.stripePriceId, { active: false })
await prisma.product.update({ where: { id: product.id }, data: { stripePriceId: null } })
console.log(`[stripe] Price archived (product has no price now)`)
}
return response
}
// 3. Check if current Stripe price matches
if (product.stripePriceId) {
try {
const currentPrice = await stripe.prices.retrieve(product.stripePriceId)
if (currentPrice.unit_amount === priceCents && currentPrice.currency === currency && currentPrice.active) {
return response
}
await stripe.prices.update(product.stripePriceId, { active: false })
console.log(`[stripe] Old price archived: ${product.stripePriceId}`)
} catch {
// Old price doesn't exist, continue
}
}
// 4. Create new price
const newPrice = await stripe.prices.create({
product: stripeProduct.id,
unit_amount: priceCents,
currency,
metadata: { slug, dbId: product.id },
})
console.log(`[stripe] New price created: ${newPrice.id} (${priceCents / 100} ${currency.toUpperCase()})`)
// 5. Update DB
await prisma.product.update({
where: { id: product.id },
data: { stripePriceId: newPrice.id },
})
} catch (err) {
console.error(`[stripe] Sync error for ${slug}:`, err.message)
}
return response
}
// ── Auto-compute fields after save ───────────────────────────────────────────
async function autoComputeFields(response) {
const id = response.record.params.id
const product = await prisma.product.findUnique({ where: { id } })
if (!product) return response
const updates = {}
// Auto-generate slug from name if empty
if (!product.slug || product.slug === '') {
updates.slug = product.name
.toLowerCase()
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/_/g, '-')
.replace(/[^a-z0-9-]/g, '')
}
// Auto-set stripeKey from slug
if (!product.stripeKey) {
const slug = updates.slug || product.slug
updates.stripeKey = slug.replace(/-/g, '_')
}
// Auto-set imagePath and ogImage from uploaded image
if (product.imageKey) {
const newImagePath = `/uploads/${product.imageKey}`
if (product.imagePath !== newImagePath) {
updates.imagePath = newImagePath
updates.ogImage = `${DOMAIN}/uploads/${product.imageKey}`
}
}
// Auto-generate seoTitle if empty
if (!product.seoTitle || product.seoTitle === '') {
updates.seoTitle = `REBOURS — ${product.productDisplayName || product.name} | Collection 001`
}
// Auto-generate seoDescription if empty
if (!product.seoDescription || product.seoDescription === '') {
updates.seoDescription = product.description
? product.description.substring(0, 155) + '...'
: `${product.productDisplayName} — Pièce unique fabriquée à Paris. REBOURS Studio.`
}
if (Object.keys(updates).length > 0) {
const updated = await prisma.product.update({ where: { id }, data: updates })
// Reflect updates in AdminJS response
for (const [key, val] of Object.entries(updates)) {
response.record.params[key] = val
}
console.log(`[admin] Auto-computed fields for ${product.name}:`, Object.keys(updates).join(', '))
}
return response
}
// ── AdminJS setup ───────────────────────────────────────────────────────────
export async function setupAdmin(app) {
const admin = new AdminJS({
rootPath: '/admin',
componentLoader,
resources: [
{
resource: { model: getModelByName('Product'), client: prisma },
options: {
navigation: { name: 'Contenu', icon: 'Package' },
listProperties: ['sortOrder', 'name', 'type', 'price', 'isPublished', 'updatedAt'],
editProperties: [
'imageUpload',
'name', 'productDisplayName', 'slug',
'sortOrder', 'index', 'type', 'materials',
'year', 'status', 'description', 'specs', 'notes',
'imageAlt',
'seoTitle', 'seoDescription',
'price', 'currency', 'availability',
'isPublished',
],
showProperties: [
'name', 'productDisplayName', 'slug',
'imagePath', 'imageAlt', 'ogImage',
'index', 'type', 'materials', 'year', 'status',
'description', 'specs', 'notes',
'seoTitle', 'seoDescription',
'price', 'currency', 'availability',
'stripePriceId', 'stripeKey', 'isPublished',
'createdAt', 'updatedAt',
],
properties: {
description: { type: 'textarea' },
specs: { type: 'textarea' },
notes: { type: 'textarea' },
seoDescription: { type: 'textarea' },
price: {
description: 'Prix en centimes (ex: 180000 = 1800€). Laisser vide = non disponible.',
},
slug: {
description: 'Auto-généré depuis le nom si vide.',
},
seoTitle: {
description: 'Auto-généré si vide.',
},
seoDescription: {
description: 'Auto-généré depuis la description si vide.',
},
// Hide technical fields from edit
imagePath: { isVisible: { edit: false, new: false, list: false, show: true } },
ogImage: { isVisible: { edit: false, new: false, list: false, show: true } },
stripePriceId: { isVisible: { edit: false, new: false, list: false, show: true } },
stripeKey: { isVisible: { edit: false, new: false, list: false, show: true } },
imageKey: { isVisible: false },
imageMime: { isVisible: false },
},
actions: {
new: { after: [autoComputeFields, syncPriceToStripe, afterBuildHook] },
edit: { after: [autoComputeFields, syncPriceToStripe, afterBuildHook] },
delete: { after: [afterBuildHook] },
},
},
features: [
uploadFeature({
componentLoader,
provider: {
local: { bucket: UPLOADS_DIR },
},
properties: {
key: 'imageKey',
mimeType: 'imageMime',
file: 'imageUpload',
},
validation: {
mimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
maxSize: 5 * 1024 * 1024, // 5 MB
},
}),
],
},
{
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,11 +9,6 @@ export default defineConfig({
server: { server: {
proxy: { proxy: {
'/api': 'http://127.0.0.1:8888', '/api': 'http://127.0.0.1:8888',
'/admin': 'http://127.0.0.1:8888',
'/uploads': {
target: 'http://127.0.0.1:8888',
rewrite: (p) => p,
},
}, },
}, },
}, },

View File

@ -1,72 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "rebours",
"dependencies": {
"@elysiajs/cors": "^1.4.1",
"@elysiajs/static": "^1.4.7",
"elysia": "^1.4.25",
"stripe": "^20.3.1",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@borewit/text-codec": ["@borewit/text-codec@0.2.1", "", {}, "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw=="],
"@elysiajs/cors": ["@elysiajs/cors@1.4.1", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-lQfad+F3r4mNwsxRKbXyJB8Jg43oAOXjRwn7sKUL6bcOW3KjUqUimTS+woNpO97efpzjtDE0tEjGk9DTw8lqTQ=="],
"@elysiajs/static": ["@elysiajs/static@1.4.7", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-Go4kIXZ0G3iWfkAld07HmLglqIDMVXdyRKBQK/sVEjtpDdjHNb+rUIje73aDTWpZYg4PEVHUpi9v4AlNEwrQug=="],
"@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="],
"@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"elysia": ["elysia@1.4.25", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-liKjavH99Gpzrv9cDil6uYWmPuqESfPFV1FIaFSd3iNqo3y7e29sN43VxFIK8tWWnyi6eDAmi2SZk8hNAMQMyg=="],
"exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="],
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
"file-type": ["file-type@21.3.0", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
"stripe": ["stripe@20.3.1", "", { "peerDependencies": { "@types/node": ">=16" }, "optionalPeers": ["@types/node"] }, "sha512-k990yOT5G5rhX3XluRPw5Y8RLdJDW4dzQ29wWT66piHrbnM2KyamJ1dKgPsw4HzGHRWjDiSSdcI2WdxQUPV3aQ=="],
"strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
"token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}

65
clean-duplicates.mjs Normal file
View File

@ -0,0 +1,65 @@
import { createClient } from '@sanity/client'
import dotenv from 'dotenv'
dotenv.config()
const client = createClient({
projectId: process.env.SANITY_PROJECT_ID,
dataset: process.env.SANITY_DATASET || 'production',
apiVersion: '2024-01-01',
useCdn: false,
token: process.env.SANITY_API_TOKEN,
})
async function cleanDuplicates() {
console.log('🔍 Recherche des produits...\n')
const products = await client.fetch(
`*[_type == "product"] | order(name asc, _createdAt asc) { _id, name, slug, _createdAt }`
)
console.log(` Total: ${products.length} produits trouvés\n`)
// Group by slug
const grouped = {}
for (const p of products) {
const key = p.slug?.current || p.name
if (!grouped[key]) grouped[key] = []
grouped[key].push(p)
}
const toDelete = []
for (const [key, docs] of Object.entries(grouped)) {
if (docs.length > 1) {
console.log(` ⚠️ "${key}" — ${docs.length} exemplaires`)
// Keep the first (oldest), delete the rest
const [keep, ...duplicates] = docs
console.log(` ✓ Garder: ${keep._id} (${keep._createdAt})`)
for (const dup of duplicates) {
console.log(` ✗ Supprimer: ${dup._id} (${dup._createdAt})`)
toDelete.push(dup._id)
}
console.log()
}
}
if (toDelete.length === 0) {
console.log('✅ Aucun doublon trouvé !')
return
}
console.log(`\n🗑️ Suppression de ${toDelete.length} doublon(s)...\n`)
const tx = client.transaction()
for (const id of toDelete) {
tx.delete(id)
}
await tx.commit()
console.log('✅ Doublons supprimés !')
}
cleanDuplicates().catch((err) => {
console.error('❌ Erreur:', err.message)
process.exit(1)
})

View File

@ -1,24 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
VPS="ordinarthur@10.10.0.13"
APP_DIR="/var/www/html/rebours"
echo "🚀 Deploying rebours..."
# 1. Sync source code
rsync -avz --delete \
--exclude node_modules \
--exclude .env \
--exclude dist \
--exclude .git \
./ "$VPS:$APP_DIR/"
# 2. Install deps, migrate, build, restart
ssh "$VPS" "cd $APP_DIR && \
pnpm install --frozen-lockfile && \
pnpm prisma migrate deploy && \
pnpm build && \
sudo systemctl restart rebours"
echo "✅ Live → https://rebours.studio"

View File

@ -1,17 +0,0 @@
[Unit]
Description=Rebours Fastify Server
After=network.target
[Service]
Type=simple
User=ordinarthur
WorkingDirectory=/var/www/html/rebours
EnvironmentFile=/var/www/html/rebours/.env
Environment=NODE_ENV=production
Environment=PORT=3001
ExecStart=/usr/bin/node server.mjs
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target

View File

@ -1,65 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# First-time VPS setup for rebours
# Run: bash deploy/setup.sh
VPS="ordinarthur@10.10.0.13"
APP_DIR="/var/www/html/rebours"
echo "🔧 Setting up rebours on $VPS..."
# 1. Sync project files
rsync -avz --delete \
--exclude node_modules \
--exclude .env \
--exclude dist \
--exclude .git \
./ "$VPS:$APP_DIR/"
# 2. Setup on server
ssh "$VPS" << 'REMOTE'
set -euo pipefail
# Node.js 22 + pnpm (skip if already installed)
if ! command -v node &>/dev/null; then
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
fi
if ! command -v pnpm &>/dev/null; then
sudo corepack enable
sudo corepack prepare pnpm@latest --activate
fi
# nginx config
sudo cp /var/www/html/rebours/nginx.conf /etc/nginx/sites-available/rebours
sudo ln -sf /etc/nginx/sites-available/rebours /etc/nginx/sites-enabled/rebours
sudo nginx -t && sudo systemctl reload nginx
# systemd service
sudo cp /var/www/html/rebours/deploy/rebours.service /etc/systemd/system/rebours.service
sudo systemctl daemon-reload
sudo systemctl enable rebours
# permissions
sudo chown -R ordinarthur:www-data /var/www/html/rebours
echo "✅ VPS ready"
REMOTE
echo ""
echo "📝 Next steps:"
echo " 1. Create .env on the VPS:"
echo " ssh $VPS 'nano $APP_DIR/.env'"
echo ""
echo " DATABASE_URL=postgresql://user:pass@host:5432/rebours"
echo " STRIPE_SECRET_KEY=sk_live_..."
echo " STRIPE_WEBHOOK_SECRET=whsec_..."
echo " DOMAIN=https://rebours.studio"
echo " ADMIN_EMAIL=..."
echo " ADMIN_PASSWORD=..."
echo " COOKIE_SECRET=..."
echo ""
echo " 2. Deploy: bash deploy.sh"
echo ""
echo " 3. NPM: forward rebours.studio → 10.10.0.13:80"

View File

@ -1,45 +0,0 @@
services:
server:
build:
context: .
dockerfile: Dockerfile
expose:
- '3001'
environment:
- DATABASE_URL
- STRIPE_SECRET_KEY
- STRIPE_WEBHOOK_SECRET
- DOMAIN
- PORT=3001
- ADMIN_EMAIL
- ADMIN_PASSWORD
- COOKIE_SECRET
- NODE_ENV=production
volumes:
- dist-data:/app/dist
networks:
- coolify
- default
restart: unless-stopped
client:
build:
context: .
dockerfile: docker/Dockerfile.client
ports:
- '80:80'
volumes:
- dist-data:/usr/share/nginx/html:ro
depends_on:
- server
networks:
- coolify
- default
restart: unless-stopped
volumes:
dist-data:
networks:
coolify:
external: true

View File

@ -1,14 +0,0 @@
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:

View File

@ -1,3 +0,0 @@
FROM nginx:alpine
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

View File

@ -1,67 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# ── API proxy Fastify ──────────────────────────────────────────────────
location /api/ {
proxy_pass http://server:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# ── SEO proxy Fastify (dynamique depuis DB) ─────────────────────────────
location = /robots.txt {
proxy_pass http://server:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /sitemap.xml {
proxy_pass http://server:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# ── Admin proxy Fastify (AdminJS) ──────────────────────────────────────
location /admin {
proxy_pass http://server:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# ── Cache : Astro hashed files immutable ───────────────────────────────
location /_astro/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# ── Cache : CSS/JS sans hash revalidation ─────────────────────────────
location ~* \.(css|js)$ {
add_header Cache-Control "no-cache";
}
# ── Cache : assets (images, fonts) 7 jours ────────────────────────────
location ~* \.(jpg|jpeg|png|gif|webp|svg|woff2|woff|ttf|ico)$ {
add_header Cache-Control "public, max-age=604800";
}
# ── HTML : jamais caché ──────────────────────────────────────────────────
location ~* \.html$ {
add_header Cache-Control "no-store";
}
# ── SPA fallback pour les routes Astro ───────────────────────────────────
location / {
try_files $uri $uri/ $uri.html /index.html;
}
}

View File

@ -14,34 +14,6 @@ server {
proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-Proto https;
} }
# ── SEO (dynamique depuis DB) ────────────────────────────────────────────
location = /robots.txt {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location = /sitemap.xml {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# ── Admin proxy Fastify (AdminJS) ──────────────────────────────────────
location ^~ /admin {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
# ── Uploads (images produits depuis admin) ────────────────────────────────
location /uploads/ {
alias /var/www/html/rebours/uploads/;
add_header Cache-Control "public, max-age=604800";
}
# ── Cache : Astro hashed immutable ───────────────────────────────────── # ── Cache : Astro hashed immutable ─────────────────────────────────────
location /_astro/ { location /_astro/ {
add_header Cache-Control "public, max-age=31536000, immutable"; add_header Cache-Control "public, max-age=31536000, immutable";
@ -53,7 +25,7 @@ server {
} }
# ── Cache : assets 7 jours ──────────────────────────────────────────── # ── Cache : assets 7 jours ────────────────────────────────────────────
location ~* \.(jpg|jpeg|png|gif|webp|svg|woff2|woff|ttf|ico)$ { location ~* \.(jpg|jpeg|png|gif|webp|svg|woff2|woff|ttf|ico|mp3)$ {
add_header Cache-Control "public, max-age=604800"; add_header Cache-Control "public, max-age=604800";
} }

View File

@ -4,74 +4,20 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "concurrently \"astro dev\" \"NODE_ENV=development node --watch server.mjs\"", "dev": "concurrently \"astro dev\" \"NODE_ENV=development node --watch server.mjs\"",
"build": "prisma generate && astro build", "build": "astro build",
"preview": "astro preview", "preview": "astro preview",
"server": "NODE_ENV=production node server.mjs", "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" "astro": "astro"
}, },
"prisma": {
"seed": "node prisma/seed.mjs"
},
"dependencies": { "dependencies": {
"@adminjs/fastify": "^4.2.0",
"@adminjs/prisma": "^5.0.4",
"@adminjs/upload": "^4.0.2",
"@fastify/cors": "^10.0.2", "@fastify/cors": "^10.0.2",
"@fastify/static": "^9.0.0", "@sanity/client": "^7",
"@prisma/client": "^6.19.2", "@sanity/image-url": "^1",
"adminjs": "^7.8.17",
"astro": "^5.17.1", "astro": "^5.17.1",
"bcrypt": "^6.0.0",
"concurrently": "^9.0.0", "concurrently": "^9.0.0",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"fastify": "^5.3.2", "fastify": "^5.3.2",
"gsap": "^3.14.2", "gsap": "^3.14.2",
"stripe": "^20.3.1", "stripe": "^20.3.1"
"tslib": "^2.8.1"
},
"devDependencies": {
"@babel/plugin-syntax-import-assertions": "^7.28.6",
"@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"
}
} }
} }

6580
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,64 +0,0 @@
-- CreateTable
CREATE TABLE "Product" (
"id" TEXT NOT NULL PRIMARY KEY,
"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" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Order" (
"id" TEXT NOT NULL PRIMARY KEY,
"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" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Order_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "AdminUser" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- 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");

View File

@ -1,3 +0,0 @@
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "imageKey" TEXT;
ALTER TABLE "Product" ADD COLUMN "imageMime" TEXT;

View File

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

Binary file not shown.

View File

@ -1,75 +0,0 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
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("")
imageKey String? // upload filename
imageMime String? // upload mime type
// 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 String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model AdminUser {
id String @id @default(cuid())
email String @unique
passwordHash String
createdAt DateTime @default(now())
}

View File

@ -1,308 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- SEO Primary -->
<title>REBOURS — Mobilier d'art contemporain | Collection 001</title>
<meta name="description" content="REBOUR Studio crée du mobilier d'art contemporain inspiré du Space Age et du mouvement Memphis. Pièces uniques fabriquées à Paris. Collection 001 en cours.">
<meta name="keywords" content="mobilier art, design contemporain, space age, memphis design, lampe béton, Paris, pièce unique">
<meta name="author" content="REBOURS Studio">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://rebour.studio/">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://rebours.studio/">
<meta property="og:title" content="REBOURS — Mobilier d'art contemporain">
<meta property="og:description" content="Pièces uniques fabriquées à Paris. Space Age × Memphis. Collection 001.">
<meta property="og:image" content="https://rebours.studio/assets/lamp-violet.jpg">
<meta property="og:locale" content="fr_FR">
<meta property="og:site_name" content="REBOURS Studio">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="REBOURS — Mobilier d'art contemporain">
<meta name="twitter:description" content="Pièces uniques fabriquées à Paris. Space Age × Memphis. Collection 001.">
<meta name="twitter:image" content="https://rebours.studio/assets/lamp-violet.jpg">
<!-- Schema.org structured data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Store",
"name": "REBOURS Studio",
"description": "Mobilier d'art contemporain. Space Age × Memphis. Pièces uniques fabriquées à Paris.",
"url": "https://rebours.studio",
"image": "https://rebours.studio/assets/lamp-violet.jpg",
"address": { "@type": "PostalAddress", "addressLocality": "Paris", "addressCountry": "FR" },
"hasOfferCatalog": {
"@type": "OfferCatalog",
"name": "Collection 001",
"itemListElement": [
{
"@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"
},
"price": "1800",
"priceCurrency": "EUR",
"availability": "https://schema.org/LimitedAvailability"
}
]
}
}
</script>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- Grid background -->
<div id="interactive-grid" class="interactive-grid"></div>
<!-- PRODUCT PANEL (overlay) -->
<div id="product-panel" class="product-panel" aria-hidden="true">
<div class="panel-close" id="panel-close">
<span>← RETOUR</span>
</div>
<div class="panel-inner">
<div class="panel-img-col">
<img id="panel-img" src="" alt="">
</div>
<div class="panel-info-col">
<p class="panel-index" id="panel-index"></p>
<h2 id="panel-name"></h2>
<hr>
<div class="panel-meta">
<div class="panel-meta-row">
<span class="meta-key">TYPE</span>
<span id="panel-type"></span>
</div>
<div class="panel-meta-row">
<span class="meta-key">MATÉRIAUX</span>
<span id="panel-mat"></span>
</div>
<div class="panel-meta-row">
<span class="meta-key">ANNÉE</span>
<span id="panel-year"></span>
</div>
<div class="panel-meta-row">
<span class="meta-key">STATUS</span>
<span id="panel-status" class="red"></span>
</div>
</div>
<hr>
<p id="panel-desc" class="panel-desc"></p>
<hr>
<!-- Accordion specs -->
<details class="accordion">
<summary>SPÉCIFICATIONS TECHNIQUES <span></span></summary>
<div class="accordion-body" id="panel-specs"></div>
</details>
<details class="accordion">
<summary>NOTES DE CONCEPTION <span></span></summary>
<div class="accordion-body" id="panel-notes"></div>
</details>
<hr>
<!-- Bouton + form commande — uniquement PROJET_001 -->
<div id="checkout-section" style="display:none;">
<div class="checkout-price-line">
<span class="checkout-price">1 800 €</span>
<span class="checkout-edition">ÉDITION UNIQUE — 1/1</span>
</div>
<button id="checkout-toggle-btn" class="checkout-btn">
[ COMMANDER CETTE PIÈCE ]
</button>
<div id="checkout-form-wrap" class="checkout-form-wrap" style="display:none;">
<form id="checkout-form" class="checkout-form">
<div class="checkout-form-field">
<label for="checkout-email">EMAIL *</label>
<input type="email" id="checkout-email" name="email" placeholder="votre@email.com" required autocomplete="off">
</div>
<div class="checkout-form-note">
Pièce fabriquée à Paris. Délai : 6 à 8 semaines.<br>
Paiement sécurisé via Stripe.
</div>
<button type="submit" class="checkout-submit" id="checkout-submit-btn">
PROCÉDER AU PAIEMENT →
</button>
</form>
</div>
</div>
<div class="panel-footer">
<span class="blink"></span> COLLECTION_001 — W.I.P
</div>
</div>
</div>
</div>
<div class="page-wrapper">
<header class="header">
<a href="/" class="logo-text" aria-label="REBOURS — Accueil">REBOURS</a>
<nav class="header-nav" aria-label="Navigation principale">
<a href="#collection">COLLECTION_001</a>
<a href="#contact">CONTACT</a>
<span class="wip-tag"><span class="blink"></span> W.I.P</span>
</nav>
</header>
<main>
<!-- HERO -->
<section class="hero" aria-label="Introduction">
<div class="hero-left">
<p class="label">// ARCHIVE_001 — 2026</p>
<h1>REBOURS<br>STUDIO</h1>
<p class="hero-sub">Mobilier d'art contemporain.<br>Space Age × Memphis.</p>
<p class="hero-sub mono-sm">STATUS: [PROTOTYPE EN COURS]<br>COLLECTION_001 — BIENTÔT DISPONIBLE</p>
</div>
<div class="hero-right">
<img
src="/assets/table-terrazzo.jpg"
alt="REBOURS — Table Terrazzo, plateau terrazzo et acier tubulaire, Paris 2026"
class="hero-img"
width="1024" height="1024"
fetchpriority="high">
</div>
</section>
<hr>
<!-- COLLECTION GRID -->
<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>
</div>
<div class="product-grid">
<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">
<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">
</div>
<div class="card-meta">
<span class="card-index">001</span>
<span class="card-name">Solar_Altar</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>
<!-- NEWSLETTER -->
<section class="newsletter" id="contact" aria-label="Accès anticipé">
<div class="nl-left">
<p class="label">// ACCÈS_ANTICIPÉ</p>
<h2>REJOINDRE<br>L'EXPÉRIENCE</h2>
</div>
<div class="nl-right">
<form class="nl-form" onsubmit="event.preventDefault();" aria-label="Inscription newsletter">
<label for="nl-email">EMAIL :</label>
<div class="nl-row">
<input type="email" id="nl-email" name="email" placeholder="votre@email.com" autocomplete="email" required>
<button type="submit">ENVOYER →</button>
</div>
<p class="mono-sm"><span class="blink"></span> CONNECTION_STATUS: PENDING</p>
</form>
</div>
</section>
</main>
<footer class="footer">
<span>© 2026 REBOURS STUDIO — PARIS</span>
<nav aria-label="Liens secondaires">
<a href="https://instagram.com/rebour.studio" rel="noopener" target="_blank">INSTAGRAM</a>
&nbsp;/&nbsp;
<a href="mailto:contact@rebour.studio">CONTACT</a>
</nav>
</footer>
</div>
<div class="cursor-dot"></div>
<div class="cursor-outline"></div>
<script src="main.js"></script>
</body>
</html>

View File

@ -1,5 +0,0 @@
User-agent: *
Allow: /
Disallow: /success
Sitemap: https://rebours.studio/sitemap.xml

View File

@ -1,27 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://rebours.studio/</loc>
<lastmod>2026-02-27</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://rebours.studio/collection/solar-altar/</loc>
<lastmod>2026-02-27</lastmod>
<changefreq>monthly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://rebours.studio/collection/table-terrazzo/</loc>
<lastmod>2026-02-27</lastmod>
<changefreq>monthly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://rebours.studio/collection/module-serie/</loc>
<lastmod>2026-02-27</lastmod>
<changefreq>monthly</changefreq>
<priority>0.9</priority>
</url>
</urlset>

View File

@ -1,189 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>REBOUR — COMMANDE CONFIRMÉE</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet">
<style>
:root {
--clr-bg: #e8e8e4;
--clr-black: #111;
--clr-accent: #e8a800;
--border: 1px solid #111;
--font-mono: 'Space Mono', monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { font-size: 13px; }
body {
background: var(--clr-bg);
color: var(--clr-black);
font-family: var(--font-mono);
min-height: 100vh;
display: flex;
flex-direction: column;
}
header {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 1.1rem 2rem;
border-bottom: var(--border);
}
.logo { font-size: 1rem; font-weight: 700; letter-spacing: 0.18em; }
main {
flex-grow: 1;
display: grid;
grid-template-columns: 1fr 1fr;
}
.left {
border-right: var(--border);
padding: 5rem 2rem;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 2rem;
position: relative;
overflow: hidden;
}
.product-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.55;
}
.label { font-size: 0.75rem; color: #888; }
h1 {
font-size: clamp(2.5rem, 5vw, 4.5rem);
font-weight: 700;
line-height: 0.95;
letter-spacing: -0.02em;
}
.status-line {
font-size: 0.82rem;
line-height: 1.8;
color: #555;
}
.amount {
display: inline-block;
background: var(--clr-accent);
padding: 0.3rem 0.7rem;
font-weight: 700;
font-size: 1rem;
}
.right {
padding: 5rem 2rem;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 1.5rem;
}
hr { border: none; border-top: var(--border); }
.info-row {
display: flex;
gap: 1.5rem;
padding: 0.6rem 0;
border-bottom: 1px solid rgba(0,0,0,0.1);
font-size: 0.8rem;
align-items: baseline;
}
.info-key { color: #888; width: 8rem; flex-shrink: 0; font-size: 0.72rem; }
a.back {
display: inline-block;
border: var(--border);
padding: 0.9rem 1.5rem;
font-family: var(--font-mono);
font-size: 0.78rem;
font-weight: 700;
text-decoration: none;
color: var(--clr-black);
transition: background 0.15s;
align-self: flex-start;
margin-top: 1rem;
}
a.back:hover { background: var(--clr-black); color: #f5f5f0; }
footer {
display: flex;
justify-content: space-between;
padding: 1.1rem 2rem;
border-top: var(--border);
font-size: 0.75rem;
}
#loading { color: #888; font-size: 0.78rem; }
</style>
</head>
<body>
<header>
<span class="logo">REBOUR</span>
<span style="font-size:0.78rem;color:#888">COLLECTION_001</span>
</header>
<main>
<div class="left">
<img id="product-img" class="product-img" src="/assets/lamp-violet.jpg" alt="">
<p class="label" style="position:relative">// COMMANDE_CONFIRMÉE</p>
<h1 style="position:relative">MERCI<br>POUR<br>VOTRE<br>COMMANDE</h1>
<p class="status-line" id="loading" style="position:relative">Vérification du paiement...</p>
</div>
<div class="right">
<p class="label">// RÉCAPITULATIF</p>
<hr>
<div id="order-details" style="display:none; flex-direction:column; gap:0;">
<div class="info-row"><span class="info-key">PRODUIT</span><span>Solar_Altar</span></div>
<div class="info-row"><span class="info-key">COLLECTION</span><span>001 — ÉDITION UNIQUE</span></div>
<div class="info-row"><span class="info-key">MONTANT</span><span id="amount-display"></span></div>
<div class="info-row"><span class="info-key">EMAIL</span><span id="email-display"></span></div>
<div class="info-row"><span class="info-key">DÉLAI</span><span>6 À 8 SEMAINES</span></div>
<div class="info-row"><span class="info-key">STATUS</span><span style="color:#e8a800; font-weight:700">CONFIRMÉ ■</span></div>
</div>
<p style="font-size:0.78rem; line-height:1.8; color:#555; margin-top:1rem;">
Un email de confirmation vous sera envoyé.<br>
Votre lampe est fabriquée à la main à Paris.
</p>
<a href="/" class="back">← RETOUR À LA COLLECTION</a>
</div>
</main>
<footer>
<span>© 2026 REBOUR STUDIO — PARIS</span>
<span>INSTAGRAM / CONTACT</span>
</footer>
<script>
const params = new URLSearchParams(window.location.search)
const sessionId = params.get('session_id')
const PRODUCT_IMAGES = {
lumiere_orbitale: '/assets/lamp-violet.jpg',
table_terrazzo: '/assets/table-terrazzo.jpg',
module_serie: '/assets/lampes-serie.jpg',
}
if (sessionId) {
fetch(`/api/session/${sessionId}`)
.then(r => r.json())
.then(data => {
document.getElementById('loading').style.display = 'none'
document.getElementById('order-details').style.display = 'flex'
const amount = data.amount ? `${(data.amount / 100).toLocaleString('fr-FR')} €` : '—'
document.getElementById('amount-display').textContent = amount
document.getElementById('email-display').textContent = data.customer_email ?? '—'
if (data.product && PRODUCT_IMAGES[data.product]) {
document.getElementById('product-img').src = PRODUCT_IMAGES[data.product]
}
})
.catch(() => {
document.getElementById('loading').textContent = 'Commande enregistrée.'
})
} else {
document.getElementById('loading').textContent = 'Commande enregistrée.'
}
</script>
</body>
</html>

2
sanity/.env.example Normal file
View File

@ -0,0 +1,2 @@
SANITY_STUDIO_PROJECT_ID=your_project_id
SANITY_STUDIO_DATASET=production

View File

@ -0,0 +1,11 @@
// This file is auto-generated on 'sanity dev'
// Modifications to this file is automatically discarded
import studioConfig from "../../sanity.config.ts"
import {renderStudio} from "sanity"
renderStudio(
document.getElementById("sanity"),
studioConfig,
{reactStrictMode: false, basePath: "/"}
)

View File

@ -0,0 +1,252 @@
<!DOCTYPE html><html lang="en">
<!--
This file is auto-generated from "sanity dev".
Modifications to this file are automatically discarded.
-->
<head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover"/><meta name="robots" content="noindex"/><meta name="referrer" content="same-origin"/><link rel="icon" href="/static/favicon.ico" sizes="any"/><link rel="icon" href="/static/favicon.svg" type="image/svg+xml"/><link rel="apple-touch-icon" href="/static/apple-touch-icon.png"/><link rel="manifest" href="/static/manifest.webmanifest"/><title>Sanity Studio</title><script>
;(function () {
// The error channel is provided so that error handling can be delegated to a view component.
// If there is a subscriber to the error channel at the time the error happens, the error will be pushed to the subscriber instead of handled here.
var errorChannel = (function () {
var subscribers = Object.create(null)
var nextId = 0
function subscribe(subscriber) {
var id = nextId++
subscribers[id] = subscriber
return function unsubscribe() {
delete subscribers[id]
}
}
function publish(event) {
for (var id in subscribers) {
if (Object.hasOwn(subscribers, id)) {
subscribers[id](event)
}
}
}
return {
subscribers,
publish,
subscribe
}
})()
// NOTE: Store the error channel instance in the global scope so that the Studio application can
// access it and subscribe to errors.
window.__sanityErrorChannel = {
subscribe: errorChannel.subscribe
}
function _handleError(event) {
// If there are error channel subscribers, then we assume they will own error rendering,
// and we defer to them (no console error).
if (Object.keys(errorChannel.subscribers).length > 0) {
errorChannel.publish(event)
} else {
_renderErrorOverlay(event)
}
}
var ERROR_BOX_STYLE = [
'background: #fff',
'border-radius: 6px',
'box-sizing: border-box',
'color: #121923',
'flex: 1',
"font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue','Liberation Sans',Helvetica,Arial,system-ui,sans-serif",
'font-size: 16px',
'line-height: 21px',
'margin: 0 auto',
'max-width: 960px',
'overflow: auto',
'padding: 20px',
'width: 100%',
].join(';')
var ERROR_CODE_STYLE = [
'color: #972E2A',
"font-family: -apple-system-ui-monospace, 'SF Mono', Menlo, Monaco, Consolas, monospace",
'font-size: 13px',
'line-height: 17px',
'margin: 0',
].join(';')
function _renderErrorOverlay(event) {
var errorElement = document.querySelector('#__sanityError') || document.createElement('div')
var error = event.error
var colno = event.colno
var lineno = event.lineno
var filename = event.filename
errorElement.id = '__sanityError'
errorElement.innerHTML = [
'<div style="' + ERROR_BOX_STYLE + '">',
'<div style="font-weight: 700;">Uncaught error: ' + error.message + '</div>',
'<div style="color: #515E72; font-size: 13px; line-height: 17px; margin: 10px 0;">' +
filename +
':' +
lineno +
':' +
colno +
'</div>',
'<pre style="' + ERROR_CODE_STYLE + '">' + error.stack + '</pre>',
'</div>',
].join('')
errorElement.style.position = 'fixed'
errorElement.style.zIndex = 1000000
errorElement.style.top = 0
errorElement.style.left = 0
errorElement.style.right = 0
errorElement.style.bottom = 0
errorElement.style.padding = '20px'
errorElement.style.background = 'rgba(16,17,18,0.66)'
errorElement.style.display = 'flex'
errorElement.style.alignItems = 'center'
errorElement.style.justifyContent = 'center'
document.body.appendChild(errorElement)
}
// Error listener
window.addEventListener('error', (event) => {
_handleError({
type: 'error',
error: event.error,
lineno: event.lineno,
colno: event.colno,
filename: event.filename
})
})
// Error listener
window.addEventListener('unhandledrejection', (event) => {
_handleError({
type: 'rejection',
error: event.reason
})
})
})()
</script><style>
@font-face {
font-family: Inter;
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("https://studio-static.sanity.io/Inter-Regular.woff2") format("woff2");
}
@font-face {
font-family: Inter;
font-style: italic;
font-weight: 400;
font-display: swap;
src: url("https://studio-static.sanity.io/Inter-Italic.woff2") format("woff2");
}
@font-face {
font-family: Inter;
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("https://studio-static.sanity.io/Inter-Medium.woff2") format("woff2");
}
@font-face {
font-family: Inter;
font-style: italic;
font-weight: 500;
font-display: swap;
src: url("https://studio-static.sanity.io/Inter-MediumItalic.woff2") format("woff2");
}
@font-face {
font-family: Inter;
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("https://studio-static.sanity.io/Inter-SemiBold.woff2") format("woff2");
}
@font-face {
font-family: Inter;
font-style: italic;
font-weight: 600;
font-display: swap;
src: url("https://studio-static.sanity.io/Inter-SemiBoldItalic.woff2") format("woff2");
}
@font-face {
font-family: Inter;
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("https://studio-static.sanity.io/Inter-Bold.woff2") format("woff2");
}
@font-face {
font-family: Inter;
font-style: italic;
font-weight: 700;
font-display: swap;
src: url("https://studio-static.sanity.io/Inter-BoldItalic.woff2") format("woff2");
}
@font-face {
font-family: Inter;
font-style: normal;
font-weight: 800;
font-display: swap;
src: url("https://studio-static.sanity.io/Inter-ExtraBold.woff2") format("woff2");
}
@font-face {
font-family: Inter;
font-style: italic;
font-weight: 800;
font-display: swap;
src: url("https://studio-static.sanity.io/Inter-ExtraBoldItalic.woff2") format("woff2");
}
@font-face {
font-family: Inter;
font-style: normal;
font-weight: 900;
font-display: swap;
src: url("https://studio-static.sanity.io/Inter-Black.woff2") format("woff2");
}
@font-face {
font-family: Inter;
font-style: italic;
font-weight: 900;
font-display: swap;
src: url("https://studio-static.sanity.io/Inter-BlackItalic.woff2") format("woff2");
}
html {
@media (prefers-color-scheme: dark) {
background-color: #13141b;
}
@media (prefers-color-scheme: light) {
background-color: #ffffff;
}
}
html,
body,
#sanity {
height: 100%;
}
body {
margin: 0;
-webkit-font-smoothing: antialiased;
}
</style><script src="https://core.sanity-cdn.com/bridge.js" async type="module" data-sanity-core></script>
</head><body><div id="sanity"></div><script type="module" src="/.sanity/runtime/app.js"></script><noscript><div class="sanity-app-no-js__root"><div class="sanity-app-no-js__content"><style type="text/css">
.sanity-app-no-js__root {
position: absolute;
top: 0;
right: 0;
left: 0;
bottom: 0;
background: #fff;
}
.sanity-app-no-js__content {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
font-family: helvetica, arial, sans-serif;
}
</style><h1>JavaScript disabled</h1><p>Please <a href="https://www.enable-javascript.com/">enable JavaScript</a> in your browser and reload the page to proceed.</p></div></div></noscript></body></html>

21
sanity/package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "rebours-studio",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "sanity dev",
"build": "sanity build",
"deploy": "sanity deploy"
},
"dependencies": {
"@sanity/vision": "^3",
"react": "^18",
"react-dom": "^18",
"sanity": "^3",
"styled-components": "^6"
},
"devDependencies": {
"@sanity/eslint-config-studio": "^4"
}
}

11601
sanity/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

9
sanity/sanity.cli.ts Normal file
View File

@ -0,0 +1,9 @@
import { defineCliConfig } from 'sanity/cli'
export default defineCliConfig({
api: {
projectId: 'y821x5qu',
dataset: 'production',
},
studioHost: 'rebours',
})

14
sanity/sanity.config.ts Normal file
View File

@ -0,0 +1,14 @@
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
import { schemaTypes } from './schemas'
export default defineConfig({
name: 'rebours',
title: 'REBOURS Studio',
projectId: 'y821x5qu',
dataset: 'production',
plugins: [structureTool()],
schema: { types: schemaTypes },
})

3
sanity/schemas/index.ts Normal file
View File

@ -0,0 +1,3 @@
import product from './product'
export const schemaTypes = [product]

180
sanity/schemas/product.ts Normal file
View File

@ -0,0 +1,180 @@
import { defineType, defineField } from 'sanity'
export default defineType({
name: 'product',
title: 'Produit',
type: 'document',
orderings: [
{
title: 'Ordre d\'affichage',
name: 'sortOrder',
by: [{ field: 'sortOrder', direction: 'asc' }],
},
],
fields: [
defineField({
name: 'name',
title: 'Nom technique',
type: 'string',
description: 'Nom interne (ex: Solar_Altar)',
validation: (rule) => rule.required(),
}),
defineField({
name: 'productDisplayName',
title: 'Nom d\'affichage',
type: 'string',
description: 'Nom affiché au client (ex: Solar Altar)',
validation: (rule) => rule.required(),
}),
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
description: 'URL du produit (auto-généré depuis le nom)',
options: {
source: 'name',
slugify: (input: string) =>
input.toLowerCase()
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/_/g, '-')
.replace(/[^a-z0-9-]/g, ''),
},
validation: (rule) => rule.required(),
}),
defineField({
name: 'sortOrder',
title: 'Ordre d\'affichage',
type: 'number',
initialValue: 0,
description: 'Plus petit = affiché en premier',
}),
defineField({
name: 'index',
title: 'Index projet',
type: 'string',
description: 'Ex: PROJET_001',
validation: (rule) => rule.required(),
}),
defineField({
name: 'type',
title: 'Type de produit',
type: 'string',
description: 'Ex: LAMPE DE TABLE',
validation: (rule) => rule.required(),
}),
defineField({
name: 'materials',
title: 'Matériaux',
type: 'string',
description: 'Ex: BÉTON TEXTURÉ + DÔME CÉRAMIQUE LAQUÉ',
validation: (rule) => rule.required(),
}),
defineField({
name: 'year',
title: 'Année',
type: 'string',
initialValue: '2026',
validation: (rule) => rule.required(),
}),
defineField({
name: 'status',
title: 'Status',
type: 'string',
description: 'Ex: PROTOTYPE [80%]',
validation: (rule) => rule.required(),
}),
defineField({
name: 'description',
title: 'Description',
type: 'text',
rows: 4,
validation: (rule) => rule.required(),
}),
defineField({
name: 'specs',
title: 'Spécifications techniques',
type: 'text',
rows: 4,
}),
defineField({
name: 'notes',
title: 'Notes de conception',
type: 'text',
rows: 4,
}),
defineField({
name: 'image',
title: 'Image produit',
type: 'image',
options: { hotspot: true },
validation: (rule) => rule.required(),
fields: [
defineField({
name: 'alt',
title: 'Texte alternatif',
type: 'string',
description: 'Description de l\'image pour le SEO et l\'accessibilité',
}),
],
}),
defineField({
name: 'seoTitle',
title: 'Titre SEO',
type: 'string',
description: 'Auto: REBOURS — {Nom} | Collection 001',
}),
defineField({
name: 'seoDescription',
title: 'Description SEO',
type: 'text',
rows: 2,
description: 'Auto: tronqué depuis la description',
}),
defineField({
name: 'price',
title: 'Prix (centimes)',
type: 'number',
description: 'En centimes (180000 = 1 800 €). Laisser vide = non disponible.',
}),
defineField({
name: 'currency',
title: 'Devise',
type: 'string',
initialValue: 'EUR',
options: {
list: [
{ title: 'EUR', value: 'EUR' },
{ title: 'USD', value: 'USD' },
],
},
}),
defineField({
name: 'availability',
title: 'Disponibilité',
type: 'string',
initialValue: 'https://schema.org/PreOrder',
options: {
list: [
{ title: 'Pré-commande', value: 'https://schema.org/PreOrder' },
{ title: 'Disponible (limité)', value: 'https://schema.org/LimitedAvailability' },
{ title: 'En stock', value: 'https://schema.org/InStock' },
{ title: 'Épuisé', value: 'https://schema.org/SoldOut' },
],
},
}),
defineField({
name: 'isPublished',
title: 'Publié',
type: 'boolean',
initialValue: true,
description: 'Décocher pour masquer du site sans supprimer',
}),
],
preview: {
select: {
title: 'productDisplayName',
subtitle: 'type',
media: 'image',
},
},
})

18
sanity/tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx"
},
"include": ["."]
}

View File

@ -1,155 +0,0 @@
#!/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)
})

View File

@ -1,222 +0,0 @@
#!/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

@ -1,14 +1,36 @@
import { PrismaClient } from '@prisma/client' import { createClient } from '@sanity/client'
import bcrypt from 'bcrypt' import { readFileSync } from 'fs'
import { resolve } from 'path'
import dotenv from 'dotenv'
const prisma = new PrismaClient() dotenv.config()
const client = createClient({
projectId: process.env.SANITY_PROJECT_ID,
dataset: process.env.SANITY_DATASET || 'production',
apiVersion: '2024-01-01',
useCdn: false,
token: process.env.SANITY_API_TOKEN,
})
// ── Upload image to Sanity ─────────────────────────────────────────────────
async function uploadImage(filePath, filename) {
const buffer = readFileSync(resolve(filePath))
const asset = await client.assets.upload('image', buffer, { filename })
console.log(` ✓ Image uploaded: ${filename}${asset._id}`)
return asset._id
}
// ── Products data ──────────────────────────────────────────────────────────
const PRODUCTS = [ const PRODUCTS = [
{ {
slug: 'solar-altar', name: 'Solar_Altar',
productDisplayName: 'Solar Altar',
slug: { _type: 'slug', current: 'solar-altar' },
sortOrder: 0, sortOrder: 0,
index: 'PROJET_001', index: 'PROJET_001',
name: 'Solar_Altar',
type: 'LAMPE DE TABLE', type: 'LAMPE DE TABLE',
materials: 'BÉTON TEXTURÉ + DÔME CÉRAMIQUE LAQUÉ', materials: 'BÉTON TEXTURÉ + DÔME CÉRAMIQUE LAQUÉ',
year: '2026', year: '2026',
@ -16,24 +38,21 @@ const PRODUCTS = [
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.', 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', 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.', 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', imageFile: 'public/assets/lamp-violet.jpg',
imageAlt: 'Solar Altar — Lampe béton violet, dôme céramique bleu, REBOURS 2026', imageAlt: 'Solar Altar — Lampe béton violet, dôme céramique bleu, REBOURS 2026',
seoTitle: 'REBOURS — Solar Altar | Collection 001', 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.', 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', price: 180000,
productDisplayName: 'Solar Altar',
price: 180000, // 1800 EUR in cents
currency: 'EUR', currency: 'EUR',
availability: 'https://schema.org/LimitedAvailability', availability: 'https://schema.org/LimitedAvailability',
stripePriceId: null,
stripeKey: 'lumiere_orbitale',
isPublished: true, isPublished: true,
}, },
{ {
slug: 'table-terrazzo', name: 'TABLE_TERRAZZO',
productDisplayName: 'Table Terrazzo',
slug: { _type: 'slug', current: 'table-terrazzo' },
sortOrder: 1, sortOrder: 1,
index: 'PROJET_002', index: 'PROJET_002',
name: 'TABLE_TERRAZZO',
type: 'TABLE BASSE + ÉTAGÈRE MODULAIRE', type: 'TABLE BASSE + ÉTAGÈRE MODULAIRE',
materials: 'TERRAZZO + ACIER TUBULAIRE + RÉSINE', materials: 'TERRAZZO + ACIER TUBULAIRE + RÉSINE',
year: '2026', year: '2026',
@ -41,24 +60,21 @@ const PRODUCTS = [
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.", 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', 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.", 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', imageFile: 'public/assets/table-terrazzo.jpg',
imageAlt: 'TABLE TERRAZZO — Table basse terrazzo et étagère acier, REBOURS 2026', imageAlt: 'TABLE TERRAZZO — Table basse terrazzo et étagère acier, REBOURS 2026',
seoTitle: 'REBOURS — TABLE TERRAZZO | Collection 001', seoTitle: 'REBOURS — TABLE TERRAZZO | Collection 001',
seoDescription: 'Table basse et étagère modulaire. Terrazzo fait main + acier tubulaire. Pièce unique fabriquée à Paris.', 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, price: null,
currency: 'EUR', currency: 'EUR',
availability: 'https://schema.org/PreOrder', availability: 'https://schema.org/PreOrder',
stripePriceId: null,
stripeKey: 'table_terrazzo',
isPublished: true, isPublished: true,
}, },
{ {
slug: 'module-serie', name: 'MODULE_SÉRIE',
productDisplayName: 'Module Série',
slug: { _type: 'slug', current: 'module-serie' },
sortOrder: 2, sortOrder: 2,
index: 'PROJET_003', index: 'PROJET_003',
name: 'MODULE_SÉRIE',
type: 'LAMPES — SÉRIE LIMITÉE', type: 'LAMPES — SÉRIE LIMITÉE',
materials: 'BÉTON COLORÉ + DÔME LAQUÉ + NÉON', materials: 'BÉTON COLORÉ + DÔME LAQUÉ + NÉON',
year: '2026', year: '2026',
@ -66,48 +82,47 @@ const PRODUCTS = [
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.", 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', 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.', 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', imageFile: 'public/assets/lampes-serie.jpg',
imageAlt: 'MODULE SÉRIE — Collection de 7 lampes béton colorées, REBOURS 2026', imageAlt: 'MODULE SÉRIE — Collection de 7 lampes béton colorées, REBOURS 2026',
seoTitle: 'REBOURS — MODULE SÉRIE | Collection 001', 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.', 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, price: null,
currency: 'EUR', currency: 'EUR',
availability: 'https://schema.org/PreOrder', availability: 'https://schema.org/PreOrder',
stripePriceId: null,
stripeKey: 'module_serie',
isPublished: true, isPublished: true,
}, },
] ]
async function main() { // ── Seed ───────────────────────────────────────────────────────────────────
// Seed products async function seed() {
console.log('🌱 Seeding Sanity...\n')
for (const product of PRODUCTS) { for (const product of PRODUCTS) {
await prisma.product.upsert({ const { imageFile, imageAlt, ...data } = product
where: { slug: product.slug },
update: product, // Upload image
create: product, console.log(`📦 ${product.name}`)
}) const imageAssetId = await uploadImage(imageFile, imageFile.split('/').pop())
console.log(`✓ Product: ${product.name}`)
// Create document
const doc = {
_type: 'product',
...data,
image: {
_type: 'image',
alt: imageAlt,
asset: { _type: 'reference', _ref: imageAssetId },
},
} }
// Seed admin user const result = await client.create(doc)
const email = process.env.ADMIN_EMAIL ?? 'admin@rebours.studio' console.log(` ✓ Created: ${result._id}\n`)
const password = process.env.ADMIN_PASSWORD ?? 'changeme' }
const passwordHash = await bcrypt.hash(password, 10)
await prisma.adminUser.upsert({ console.log('✅ Seed complete!')
where: { email },
update: { passwordHash },
create: { email, passwordHash },
})
console.log(`✓ Admin user: ${email}`)
} }
main() seed().catch((err) => {
.catch((e) => { console.error('❌ Seed failed:', err.message)
console.error(e)
process.exit(1) process.exit(1)
}) })
.finally(() => prisma.$disconnect())

View File

@ -1,28 +1,29 @@
import Fastify from 'fastify' import Fastify from 'fastify'
import cors from '@fastify/cors' import cors from '@fastify/cors'
import fastifyStatic from '@fastify/static'
import Stripe from 'stripe' import Stripe from 'stripe'
import { createClient } from '@sanity/client'
import dotenv from 'dotenv' import dotenv from 'dotenv'
import path from 'path'
import { setupAdmin } from './admin.mjs'
import { prisma } from './src/lib/db.mjs'
dotenv.config() dotenv.config()
// ── Sanity client ──────────────────────────────────────────────────────────
const sanity = createClient({
projectId: process.env.SANITY_PROJECT_ID,
dataset: process.env.SANITY_DATASET || 'production',
apiVersion: '2024-01-01',
useCdn: false, // Server-side: always fresh data
token: process.env.SANITY_API_TOKEN, // Optional: for authenticated reads
})
// ── Stripe ─────────────────────────────────────────────────────────────────
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? '') const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? '')
const DOMAIN = process.env.DOMAIN ?? 'http://localhost:4321' const DOMAIN = process.env.DOMAIN ?? 'http://localhost:4321'
// ── Fastify ────────────────────────────────────────────────────────────────
const app = Fastify({ logger: true, trustProxy: true }) const app = Fastify({ logger: true, trustProxy: true })
await app.register(cors, { origin: '*', methods: ['GET', 'POST'] }) await app.register(cors, { origin: '*', methods: ['GET', 'POST'] })
// Serve uploaded images // ── Webhook Stripe ─────────────────────────────────────────────────────────
await app.register(fastifyStatic, {
root: path.resolve('uploads'),
prefix: '/uploads/',
decorateReply: false,
})
// ── Webhook Stripe (AVANT AdminJS pour éviter les conflits de body parsing) ─
app.post('/api/webhook', { app.post('/api/webhook', {
config: { rawBody: true }, config: { rawBody: true },
onRequest: (request, reply, done) => { onRequest: (request, reply, done) => {
@ -45,61 +46,13 @@ app.post('/api/webhook', {
if (event.type === 'checkout.session.completed') { if (event.type === 'checkout.session.completed') {
const session = event.data.object const session = event.data.object
if (session.payment_status === 'paid') { if (session.payment_status === 'paid') {
app.log.info(`✓ Paiement — ${session.id}${session.customer_details?.email}`) app.log.info(
`✓ Paiement confirmé — ${session.id}${session.customer_details?.email}${session.metadata?.product}`
// 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 }
})
// ── 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>`
) )
}
}
return { received: true }
}) })
// ── Health check ──────────────────────────────────────────────────────────── // ── Health check ────────────────────────────────────────────────────────────
@ -107,25 +60,44 @@ app.get('/api/health', async () => ({ status: 'ok' }))
// ── Checkout Stripe ───────────────────────────────────────────────────────── // ── Checkout Stripe ─────────────────────────────────────────────────────────
app.post('/api/checkout', async (request, reply) => { app.post('/api/checkout', async (request, reply) => {
const { product, email } = request.body ?? {} const { product: slug, email } = request.body ?? {}
// Lookup by stripeKey (compat frontend: "lumiere_orbitale") if (!slug) return reply.code(400).send({ error: 'Produit manquant' })
const p = await prisma.product.findFirst({
where: { stripeKey: product, isPublished: true }, // Fetch product from Sanity
select: { stripePriceId: true, stripeKey: true }, const product = await sanity.fetch(
}) `*[_type == "product" && slug.current == $slug && isPublished == true][0]{
if (!p || !p.stripePriceId) return reply.code(404).send({ error: 'Produit inconnu' }) productDisplayName,
description,
price,
currency,
"imageUrl": image.asset->url,
"slug": slug.current
}`,
{ slug }
)
if (!product || !product.price) {
return reply.code(404).send({ error: 'Produit non disponible' })
}
let session
try { try {
session = await stripe.checkout.sessions.create({ const session = await stripe.checkout.sessions.create({
mode: 'payment', mode: 'payment',
payment_method_types: ['card', 'link'], payment_method_types: ['card', 'link'],
line_items: [{ line_items: [{
price: p.stripePriceId, price_data: {
currency: (product.currency || 'EUR').toLowerCase(),
product_data: {
name: product.productDisplayName,
description: product.description?.substring(0, 500) || undefined,
images: product.imageUrl ? [product.imageUrl] : [],
},
unit_amount: product.price,
},
quantity: 1, quantity: 1,
}], }],
metadata: { product }, metadata: { product: slug },
success_url: `${DOMAIN}/success?session_id={CHECKOUT_SESSION_ID}`, success_url: `${DOMAIN}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${DOMAIN}/#collection`, cancel_url: `${DOMAIN}/#collection`,
locale: 'fr', locale: 'fr',
@ -134,11 +106,11 @@ app.post('/api/checkout', async (request, reply) => {
submit: { message: 'Pièce unique — fabriquée à Paris. Délai : 6 à 8 semaines.' }, submit: { message: 'Pièce unique — fabriquée à Paris. Délai : 6 à 8 semaines.' },
}, },
}) })
return { url: session.url }
} catch (err) { } catch (err) {
app.log.error(err) app.log.error(err)
return reply.code(500).send({ error: err.message }) return reply.code(500).send({ error: err.message })
} }
return { url: session.url }
}) })
// ── Vérification session ──────────────────────────────────────────────────── // ── Vérification session ────────────────────────────────────────────────────

View File

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

53
src/lib/sanity.mjs Normal file
View File

@ -0,0 +1,53 @@
import { createClient } from '@sanity/client'
import imageUrlBuilder from '@sanity/image-url'
export const sanity = createClient({
projectId: import.meta.env.SANITY_PROJECT_ID,
dataset: import.meta.env.SANITY_DATASET || 'production',
apiVersion: '2024-01-01',
useCdn: true,
})
const builder = imageUrlBuilder(sanity)
export function urlFor(source) {
return builder.image(source)
}
// ── Queries ────────────────────────────────────────────────────────────────
const PRODUCT_FIELDS = `
"slug": slug.current,
name,
productDisplayName,
sortOrder,
index,
type,
materials,
year,
status,
description,
specs,
notes,
image,
"imageAlt": image.alt,
seoTitle,
seoDescription,
price,
currency,
availability,
isPublished
`
export async function getPublishedProducts() {
return sanity.fetch(
`*[_type == "product" && isPublished == true] | order(sortOrder asc) { ${PRODUCT_FIELDS} }`
)
}
export async function getProductBySlug(slug) {
return sanity.fetch(
`*[_type == "product" && slug.current == $slug && isPublished == true][0] { ${PRODUCT_FIELDS} }`,
{ slug }
)
}

View File

@ -1,34 +1,28 @@
--- ---
import Base from '../../layouts/Base.astro'; import Base from '../../layouts/Base.astro';
import { prisma } from '../../lib/db.mjs'; import { getPublishedProducts, urlFor } from '../../lib/sanity.mjs';
export async function getStaticPaths() { export async function getStaticPaths() {
const products = await prisma.product.findMany({ const products = await getPublishedProducts();
where: { isPublished: true },
orderBy: { sortOrder: 'asc' },
});
return products.map(p => ({ return products.map(p => ({
params: { slug: p.slug }, params: { slug: p.slug },
props: { props: {
slug: p.slug, slug: p.slug,
name: p.name, name: p.name,
title: p.seoTitle, title: p.seoTitle || `REBOURS — ${p.productDisplayName} | Collection 001`,
description: p.seoDescription, description: p.seoDescription || p.description?.substring(0, 155) || '',
ogImage: p.ogImage, ogImage: p.image ? urlFor(p.image).width(1200).url() : '',
productName: p.productDisplayName, productName: p.productDisplayName,
price: p.price ? String(p.price / 100) : null, price: p.price ? String(p.price / 100) : null,
availability: p.availability, availability: p.availability || 'https://schema.org/PreOrder',
}, },
})); }));
} }
const { slug, title, description, ogImage, name, productName, price, availability } = Astro.props; const { slug, title, description, ogImage, name, productName, price, availability } = Astro.props;
const allProducts = await prisma.product.findMany({ const allProducts = await getPublishedProducts();
where: { isPublished: true },
orderBy: { sortOrder: 'asc' },
});
const schemaProduct = { const schemaProduct = {
"@context": "https://schema.org", "@context": "https://schema.org",
@ -71,10 +65,8 @@ const schemaBreadcrumb = {
<script type="application/ld+json" set:html={JSON.stringify(schemaBreadcrumb)} /> <script type="application/ld+json" set:html={JSON.stringify(schemaBreadcrumb)} />
</Fragment> </Fragment>
<!-- On charge index.html entier et on ouvre le panel via JS au load -->
<meta name="x-open-panel" content={name} /> <meta name="x-open-panel" content={name} />
<!-- Avant le DOM : on note quel panel ouvrir -->
<script is:inline> <script is:inline>
window.__OPEN_PANEL__ = document.querySelector('meta[name="x-open-panel"]')?.content; window.__OPEN_PANEL__ = document.querySelector('meta[name="x-open-panel"]')?.content;
</script> </script>
@ -181,12 +173,14 @@ const schemaBreadcrumb = {
<p class="hero-sub mono-sm">STATUS: [PROTOTYPE EN COURS]<br>COLLECTION_001 — BIENTÔT DISPONIBLE</p> <p class="hero-sub mono-sm">STATUS: [PROTOTYPE EN COURS]<br>COLLECTION_001 — BIENTÔT DISPONIBLE</p>
</div> </div>
<div class="hero-right"> <div class="hero-right">
{allProducts[0]?.image && (
<img <img
src="/assets/table-terrazzo.jpg" src={urlFor(allProducts[0].image).width(1024).url()}
alt="REBOURS — Table Terrazzo, plateau terrazzo et acier tubulaire, Paris 2026" alt="REBOURS — Mobilier d'art contemporain, Paris 2026"
class="hero-img" class="hero-img"
width="1024" height="1024" width="1024" height="1024"
fetchpriority="high"> fetchpriority="high">
)}
</div> </div>
</section> </section>
@ -199,7 +193,10 @@ const schemaBreadcrumb = {
</div> </div>
<div class="product-grid"> <div class="product-grid">
{allProducts.map((p, i) => ( {allProducts.map((p, i) => {
const imgUrl = p.image ? urlFor(p.image).width(800).url() : '';
const alt = p.imageAlt || `${p.productDisplayName} — mobilier d'art contemporain, REBOURS Studio Paris`;
return (
<article class="product-card" <article class="product-card"
data-index={p.index} data-index={p.index}
data-name={p.name} data-name={p.name}
@ -208,16 +205,16 @@ const schemaBreadcrumb = {
data-year={p.year} data-year={p.year}
data-status={p.status} data-status={p.status}
data-desc={p.description} data-desc={p.description}
data-specs={p.specs} data-specs={p.specs || ''}
data-notes={p.notes} data-notes={p.notes || ''}
data-img={p.imagePath} data-img={p.image ? urlFor(p.image).width(1200).url() : ''}
data-price={p.price ? String(p.price) : ''} data-price={p.price ? String(p.price) : ''}
data-stripe-key={p.stripeKey ?? ''} data-slug={p.slug}
data-img-alt={p.imageAlt || `${p.productDisplayName} — mobilier d'art contemporain, REBOURS Studio Paris`} data-img-alt={alt}
aria-label={`Ouvrir le détail de ${p.productDisplayName}`}> aria-label={`Ouvrir le détail de ${p.productDisplayName}`}>
<div class="card-img-wrap"> <div class="card-img-wrap">
<img src={p.imagePath} <img src={imgUrl}
alt={p.imageAlt || `${p.productDisplayName} — mobilier d'art contemporain, REBOURS Studio Paris`} alt={alt}
width="600" height="600" width="600" height="600"
loading={i === 0 ? "eager" : "lazy"}> loading={i === 0 ? "eager" : "lazy"}>
</div> </div>
@ -227,7 +224,8 @@ const schemaBreadcrumb = {
<span class="card-arrow">↗</span> <span class="card-arrow">↗</span>
</div> </div>
</article> </article>
))} );
})}
</div> </div>
</section> </section>

View File

@ -1,11 +1,12 @@
--- ---
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import { prisma } from '../lib/db.mjs'; import { getPublishedProducts, urlFor } from '../lib/sanity.mjs';
const products = await prisma.product.findMany({ const products = await getPublishedProducts();
where: { isPublished: true },
orderBy: { sortOrder: 'asc' }, const firstImage = products[0]?.image
}); ? urlFor(products[0].image).width(1024).url()
: '/assets/table-terrazzo.jpg';
const schemaOrg = { const schemaOrg = {
"@context": "https://schema.org", "@context": "https://schema.org",
@ -13,7 +14,7 @@ const schemaOrg = {
"name": "REBOURS Studio", "name": "REBOURS Studio",
"description": "Mobilier d'art contemporain. Space Age × Memphis. Pièces uniques fabriquées à Paris.", "description": "Mobilier d'art contemporain. Space Age × Memphis. Pièces uniques fabriquées à Paris.",
"url": "https://rebours.studio/", "url": "https://rebours.studio/",
"image": "https://rebours.studio/assets/lamp-violet.jpg", "image": firstImage,
"address": { "@type": "PostalAddress", "addressLocality": "Paris", "addressCountry": "FR" }, "address": { "@type": "PostalAddress", "addressLocality": "Paris", "addressCountry": "FR" },
"hasOfferCatalog": { "hasOfferCatalog": {
"@type": "OfferCatalog", "@type": "OfferCatalog",
@ -23,11 +24,11 @@ const schemaOrg = {
"itemOffered": { "itemOffered": {
"@type": "Product", "@type": "Product",
"name": p.productDisplayName, "name": p.productDisplayName,
"description": p.seoDescription, "description": p.seoDescription || p.description?.substring(0, 155),
"image": p.ogImage, "image": p.image ? urlFor(p.image).width(1024).url() : undefined,
}, },
"price": String(p.price! / 100), "price": String(p.price / 100),
"priceCurrency": p.currency, "priceCurrency": p.currency || 'EUR',
"availability": p.availability, "availability": p.availability,
})), })),
} }
@ -36,7 +37,7 @@ const schemaOrg = {
<Base <Base
title="REBOURS — Mobilier d'art contemporain | Collection 001" title="REBOURS — Mobilier d'art contemporain | Collection 001"
description="REBOUR Studio crée du mobilier d'art contemporain inspiré du Space Age et du mouvement Memphis. Pièces uniques fabriquées à Paris. Collection 001 en cours." description="REBOURS Studio crée du mobilier d'art contemporain inspiré du Space Age et du mouvement Memphis. Pièces uniques fabriquées à Paris. Collection 001 en cours."
canonical="https://rebours.studio/" canonical="https://rebours.studio/"
> >
<Fragment slot="head"> <Fragment slot="head">
@ -147,8 +148,8 @@ const schemaOrg = {
</div> </div>
<div class="hero-right"> <div class="hero-right">
<img <img
src="/assets/table-terrazzo.jpg" src={firstImage}
alt="REBOURS — Table Terrazzo, plateau terrazzo et acier tubulaire, Paris 2026" alt="REBOURS — Mobilier d'art contemporain, Paris 2026"
class="hero-img" class="hero-img"
width="1024" height="1024" width="1024" height="1024"
fetchpriority="high"> fetchpriority="high">
@ -165,7 +166,10 @@ const schemaOrg = {
</div> </div>
<div class="product-grid"> <div class="product-grid">
{products.map((p, i) => ( {products.map((p, i) => {
const imgUrl = p.image ? urlFor(p.image).width(800).url() : '';
const alt = p.imageAlt || `${p.productDisplayName} — mobilier d'art contemporain, REBOURS Studio Paris`;
return (
<article class="product-card" <article class="product-card"
data-index={p.index} data-index={p.index}
data-name={p.name} data-name={p.name}
@ -174,16 +178,16 @@ const schemaOrg = {
data-year={p.year} data-year={p.year}
data-status={p.status} data-status={p.status}
data-desc={p.description} data-desc={p.description}
data-specs={p.specs} data-specs={p.specs || ''}
data-notes={p.notes} data-notes={p.notes || ''}
data-img={p.imagePath} data-img={p.image ? urlFor(p.image).width(1200).url() : ''}
data-price={p.price ? String(p.price) : ''} data-price={p.price ? String(p.price) : ''}
data-stripe-key={p.stripeKey ?? ''} data-slug={p.slug}
data-img-alt={p.imageAlt || `${p.productDisplayName} — mobilier d'art contemporain, REBOURS Studio Paris`} data-img-alt={alt}
aria-label={`Ouvrir le détail de ${p.productDisplayName}`}> aria-label={`Ouvrir le détail de ${p.productDisplayName}`}>
<div class="card-img-wrap"> <div class="card-img-wrap">
<img src={p.imagePath} <img src={imgUrl}
alt={p.imageAlt || `${p.productDisplayName} — mobilier d'art contemporain, REBOURS Studio Paris`} alt={alt}
width="600" height="600" width="600" height="600"
loading={i === 0 ? "eager" : "lazy"}> loading={i === 0 ? "eager" : "lazy"}>
</div> </div>
@ -193,7 +197,8 @@ const schemaOrg = {
<span class="card-arrow">↗</span> <span class="card-arrow">↗</span>
</div> </div>
</article> </article>
))} );
})}
</div> </div>
</section> </section>

11
src/pages/robots.txt.ts Normal file
View File

@ -0,0 +1,11 @@
import type { APIRoute } from 'astro';
export const GET: APIRoute = () => {
const body = `User-agent: *
Allow: /
Sitemap: https://rebours.studio/sitemap.xml
`;
return new Response(body, {
headers: { 'Content-Type': 'text/plain' },
});
};

34
src/pages/sitemap.xml.ts Normal file
View File

@ -0,0 +1,34 @@
import type { APIRoute } from 'astro';
import { getPublishedProducts } from '../lib/sanity.mjs';
export const GET: APIRoute = async () => {
const products = await getPublishedProducts();
const today = new Date().toISOString().split('T')[0];
const productUrls = products
.map(
(p: any) =>
` <url>
<loc>https://rebours.studio/collection/${p.slug}</loc>
<lastmod>${today}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>`
)
.join('\n');
const body = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://rebours.studio/</loc>
<lastmod>${today}</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
${productUrls}
</urlset>`;
return new Response(body, {
headers: { 'Content-Type': 'application/xml' },
});
};

View File

@ -1,13 +1,5 @@
--- ---
import Base from '../layouts/Base.astro'; 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 <Base
@ -32,14 +24,6 @@ const productImages = Object.fromEntries(
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
.product-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 1;
}
.left::after { .left::after {
content: ''; content: '';
position: absolute; position: absolute;
@ -59,13 +43,6 @@ const productImages = Object.fromEntries(
line-height: 1.8; line-height: 1.8;
color: #555; color: #555;
} }
.amount {
display: inline-block;
background: var(--clr-red);
padding: 0.3rem 0.7rem;
font-weight: 700;
font-size: 1rem;
}
.right { .right {
padding: 5rem 2rem; padding: 5rem 2rem;
display: flex; display: flex;
@ -104,7 +81,7 @@ const productImages = Object.fromEntries(
main { grid-template-columns: 1fr; } main { grid-template-columns: 1fr; }
.left { .left {
padding: 2rem 1.5rem; padding: 2rem 1.5rem;
min-height: 55vw; min-height: 40vw;
border-right: none; border-right: none;
border-bottom: var(--border); border-bottom: var(--border);
} }
@ -125,7 +102,6 @@ const productImages = Object.fromEntries(
<main> <main>
<div class="left"> <div class="left">
<img id="product-img" class="product-img" src="/assets/lamp-violet.jpg" alt="Produit REBOURS Studio">
<p class="slabel" style="position:relative">// COMMANDE_CONFIRMÉE</p> <p class="slabel" style="position:relative">// COMMANDE_CONFIRMÉE</p>
<h1 style="position:relative">MERCI<br>POUR<br>VOTRE<br>COMMANDE</h1> <h1 style="position:relative">MERCI<br>POUR<br>VOTRE<br>COMMANDE</h1>
<p class="status-line" id="loading" style="position:relative">Vérification du paiement...</p> <p class="status-line" id="loading" style="position:relative">Vérification du paiement...</p>
@ -134,7 +110,7 @@ const productImages = Object.fromEntries(
<p class="slabel">// RÉCAPITULATIF</p> <p class="slabel">// RÉCAPITULATIF</p>
<hr> <hr>
<div id="order-details" style="display:none; flex-direction:column; gap:0;"> <div id="order-details" style="display:none; flex-direction:column; gap:0;">
<div class="info-row"><span class="info-key">PRODUIT</span><span>Solar_Altar</span></div> <div class="info-row"><span class="info-key">PRODUIT</span><span id="product-display">—</span></div>
<div class="info-row"><span class="info-key">COLLECTION</span><span>001 — ÉDITION UNIQUE</span></div> <div class="info-row"><span class="info-key">COLLECTION</span><span>001 — ÉDITION UNIQUE</span></div>
<div class="info-row"><span class="info-key">MONTANT</span><span id="amount-display"></span></div> <div class="info-row"><span class="info-key">MONTANT</span><span id="amount-display"></span></div>
<div class="info-row"><span class="info-key">EMAIL</span><span id="email-display"></span></div> <div class="info-row"><span class="info-key">EMAIL</span><span id="email-display"></span></div>
@ -143,7 +119,7 @@ const productImages = Object.fromEntries(
</div> </div>
<p style="font-size:0.78rem; line-height:1.8; color:#555; margin-top:1rem;"> <p style="font-size:0.78rem; line-height:1.8; color:#555; margin-top:1rem;">
Un email de confirmation vous sera envoyé.<br> Un email de confirmation vous sera envoyé.<br>
Votre lampe est fabriquée à la main à Paris. Votre pièce est fabriquée à la main à Paris.
</p> </p>
<div style="display:flex; gap:0.8rem; flex-wrap:wrap;"> <div style="display:flex; gap:0.8rem; flex-wrap:wrap;">
<a href="/" class="back">← RETOUR</a> <a href="/" class="back">← RETOUR</a>
@ -157,7 +133,7 @@ const productImages = Object.fromEntries(
<nav aria-label="Liens secondaires"> <nav aria-label="Liens secondaires">
<a href="https://instagram.com/rebour.studio" rel="noopener" target="_blank">INSTAGRAM</a> <a href="https://instagram.com/rebour.studio" rel="noopener" target="_blank">INSTAGRAM</a>
&nbsp;/&nbsp; &nbsp;/&nbsp;
<a href="mailto:contact@rebour.studio">CONTACT</a> <a href="mailto:contact@rebours.studio">CONTACT</a>
</nav> </nav>
</footer> </footer>
</div> </div>
@ -165,14 +141,10 @@ const productImages = Object.fromEntries(
<div class="cursor-dot"></div> <div class="cursor-dot"></div>
<div class="cursor-outline"></div> <div class="cursor-outline"></div>
<script src="/main.js" is:inline></script> <script is:inline>
<script define:vars={{ productImages }}>
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const sessionId = params.get('session_id'); const sessionId = params.get('session_id');
const PRODUCT_IMAGES = productImages;
if (sessionId) { if (sessionId) {
fetch(`/api/session/${sessionId}`) fetch(`/api/session/${sessionId}`)
.then(r => r.json()) .then(r => r.json())
@ -185,13 +157,10 @@ const productImages = Object.fromEntries(
const amount = data.amount ? `${(data.amount / 100).toLocaleString('fr-FR')} €` : '—'; const amount = data.amount ? `${(data.amount / 100).toLocaleString('fr-FR')} €` : '—';
const amountEl = document.getElementById('amount-display'); const amountEl = document.getElementById('amount-display');
const emailEl = document.getElementById('email-display'); const emailEl = document.getElementById('email-display');
const productEl = document.getElementById('product-display');
if (amountEl) amountEl.textContent = amount; if (amountEl) amountEl.textContent = amount;
if (emailEl) emailEl.textContent = data.customer_email ?? '—'; if (emailEl) emailEl.textContent = data.customer_email ?? '—';
if (productEl && data.product) productEl.textContent = data.product.replace(/-/g, '_').toUpperCase();
if (data.product && PRODUCT_IMAGES[data.product]) {
const img = document.getElementById('product-img');
if (img) img.src = PRODUCT_IMAGES[data.product];
}
if (data.receipt_url) { if (data.receipt_url) {
const btn = document.getElementById('receipt-btn'); const btn = document.getElementById('receipt-btn');

View File

@ -455,7 +455,7 @@ document.addEventListener('DOMContentLoaded', () => {
const checkoutForm = document.getElementById('checkout-form'); const checkoutForm = document.getElementById('checkout-form');
const checkoutSubmitBtn = document.getElementById('checkout-submit-btn'); const checkoutSubmitBtn = document.getElementById('checkout-submit-btn');
const checkoutPriceEl = document.querySelector('.checkout-price'); const checkoutPriceEl = document.querySelector('.checkout-price');
let currentStripeKey = null; let currentSlug = null;
function formatPrice(cents) { function formatPrice(cents) {
return (cents / 100).toLocaleString('fr-FR') + ' €'; return (cents / 100).toLocaleString('fr-FR') + ' €';
@ -474,7 +474,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (checkoutForm) { if (checkoutForm) {
checkoutForm.addEventListener('submit', async (e) => { checkoutForm.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
if (!currentStripeKey) return; if (!currentSlug) return;
const email = document.getElementById('checkout-email').value; const email = document.getElementById('checkout-email').value;
checkoutSubmitBtn.disabled = true; checkoutSubmitBtn.disabled = true;
@ -484,7 +484,7 @@ document.addEventListener('DOMContentLoaded', () => {
const res = await fetch('/api/checkout', { const res = await fetch('/api/checkout', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ product: currentStripeKey, email }), body: JSON.stringify({ product: currentSlug, email }),
}); });
const data = await res.json(); const data = await res.json();
if (data.url) { if (data.url) {
@ -523,19 +523,19 @@ document.addEventListener('DOMContentLoaded', () => {
// Checkout // Checkout
const price = card.dataset.price; const price = card.dataset.price;
const stripeKey = card.dataset.stripeKey; const slug = card.dataset.slug;
const isOrderable = price && stripeKey; const isOrderable = price && slug;
checkoutSection.style.display = 'block'; checkoutSection.style.display = 'block';
if (isOrderable) { if (isOrderable) {
currentStripeKey = stripeKey; currentSlug = slug;
checkoutPriceEl.textContent = formatPrice(parseInt(price, 10)); checkoutPriceEl.textContent = formatPrice(parseInt(price, 10));
checkoutToggleBtn.textContent = '[ COMMANDER CETTE PIÈCE ]'; checkoutToggleBtn.textContent = '[ COMMANDER CETTE PIÈCE ]';
checkoutToggleBtn.disabled = false; checkoutToggleBtn.disabled = false;
checkoutToggleBtn.classList.remove('checkout-btn--disabled'); checkoutToggleBtn.classList.remove('checkout-btn--disabled');
} else { } else {
currentStripeKey = null; currentSlug = null;
checkoutPriceEl.textContent = ''; checkoutPriceEl.textContent = '';
checkoutToggleBtn.textContent = 'PROCHAINEMENT DISPONIBLE'; checkoutToggleBtn.textContent = 'PROCHAINEMENT DISPONIBLE';
checkoutToggleBtn.disabled = true; checkoutToggleBtn.disabled = true;
@ -561,8 +561,8 @@ document.addEventListener('DOMContentLoaded', () => {
setTimeout(() => animateTechOverlay(), 350); setTimeout(() => animateTechOverlay(), 350);
if (pushState) { if (pushState) {
const slug = toSlug(card.dataset.name); const cardSlug = card.dataset.slug || toSlug(card.dataset.name);
history.pushState({ slug }, '', `/collection/${slug}`); history.pushState({ slug: cardSlug }, '', `/collection/${cardSlug}`);
} }
} }
@ -601,7 +601,7 @@ document.addEventListener('DOMContentLoaded', () => {
const match = location.pathname.match(/^\/collection\/(.+)$/); const match = location.pathname.match(/^\/collection\/(.+)$/);
if (match) { if (match) {
const slug = match[1]; const slug = match[1];
const card = [...panelCards].find(c => toSlug(c.dataset.name) === slug); const card = [...panelCards].find(c => c.dataset.slug === slug || toSlug(c.dataset.name) === slug);
if (card) openPanel(card, false); if (card) openPanel(card, false);
} }
} }

View File

@ -1,5 +0,0 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist", "node_modules"]
}