feat: Add database with Prisma, AdminJS panel, Dockerization, Stripe integration, and migrate to pnpm.
This commit is contained in:
parent
dd087d8826
commit
4fdeacd19e
7
.adminjs/bundle.js
Normal file
7
.adminjs/bundle.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
AdminJS.UserComponents = {};
|
||||||
|
|
||||||
|
})();
|
||||||
|
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYnVuZGxlLmpzIiwic291cmNlcyI6WyJlbnRyeS5qcyJdLCJzb3VyY2VzQ29udGVudCI6WyJBZG1pbkpTLlVzZXJDb21wb25lbnRzID0ge31cbiJdLCJuYW1lcyI6WyJBZG1pbkpTIiwiVXNlckNvbXBvbmVudHMiXSwibWFwcGluZ3MiOiI7OztDQUFBQSxPQUFPLENBQUNDLGNBQWMsR0FBRyxFQUFFOzs7Ozs7In0=
|
||||||
1
.adminjs/entry.js
Normal file
1
.adminjs/entry.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
AdminJS.UserComponents = {}
|
||||||
@ -1 +1 @@
|
|||||||
[["Map",1,2],"meta::meta",["Map",3,4,5,6],"astro-version","5.18.0","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"static\",\"scopedStyleStrategy\":\"attribute\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":true,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":false,\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[],\"responsiveStyles\":false},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true,\"allowedDomains\":[],\"actionBodySizeLimit\":1048576},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false,\"liveContentCollections\":false,\"csp\":false,\"staticImportMetaEnv\":false,\"chromeDevtoolsWorkspace\":false,\"failOnPrerenderConflict\":false,\"svgo\":false},\"legacy\":{\"collections\":false}}"]
|
[["Map",1,2],"meta::meta",["Map",3,4,5,6],"astro-version","5.18.1","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"static\",\"scopedStyleStrategy\":\"attribute\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":true,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":false,\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[],\"responsiveStyles\":false},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true,\"allowedDomains\":[],\"actionBodySizeLimit\":1048576},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false,\"liveContentCollections\":false,\"csp\":false,\"staticImportMetaEnv\":false,\"chromeDevtoolsWorkspace\":false,\"failOnPrerenderConflict\":false,\"svgo\":false},\"legacy\":{\"collections\":false}}"]
|
||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"_variables": {
|
"_variables": {
|
||||||
"lastUpdateCheck": 1772211077674
|
"lastUpdateCheck": 1773267396416
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
1
.astro/types.d.ts
vendored
1
.astro/types.d.ts
vendored
@ -1,2 +1 @@
|
|||||||
/// <reference types="astro/client" />
|
/// <reference types="astro/client" />
|
||||||
/// <reference path="content.d.ts" />
|
|
||||||
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
.astro
|
||||||
|
*.md
|
||||||
19
.env.example
19
.env.example
@ -7,4 +7,21 @@ STRIPE_WEBHOOK_SECRET=whsec_...
|
|||||||
|
|
||||||
# ── App ───────────────────────────────────────────────────────────────────────
|
# ── App ───────────────────────────────────────────────────────────────────────
|
||||||
# URL publique du site (sans slash final)
|
# URL publique du site (sans slash final)
|
||||||
DOMAIN=https://rebour.studio
|
DOMAIN=https://rebours.studio
|
||||||
|
|
||||||
|
# Port du serveur Fastify (dev: 8888, prod: 3000)
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# ── Database ──────────────────────────────────────────────────────────────────
|
||||||
|
# PostgreSQL connection string
|
||||||
|
# Dev: postgresql://rebours:rebours@localhost:5432/rebours
|
||||||
|
# Prod: postgresql://rebours:PASSWORD@postgres:5432/rebours (Coolify internal)
|
||||||
|
DATABASE_URL=postgresql://rebours:rebours@localhost:5432/rebours
|
||||||
|
|
||||||
|
# ── Admin ─────────────────────────────────────────────────────────────────────
|
||||||
|
# Email et mot de passe du compte admin (utilisé par le seed)
|
||||||
|
ADMIN_EMAIL=admin@rebours.studio
|
||||||
|
ADMIN_PASSWORD=changeme
|
||||||
|
|
||||||
|
# Secret pour les cookies de session AdminJS (min 32 caractères)
|
||||||
|
COOKIE_SECRET=change-me-to-a-random-string-at-least-32-chars
|
||||||
|
|||||||
202
DEPLOY.md
Normal file
202
DEPLOY.md
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
# REBOURS — Guide de déploiement
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Internet → Traefik (Coolify, SSL auto)
|
||||||
|
├── /api/*, /admin/* → Fastify (Node.js + AdminJS)
|
||||||
|
│ └── PostgreSQL
|
||||||
|
└── /* → Nginx (fichiers statiques Astro)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environnement de développement
|
||||||
|
|
||||||
|
### Prérequis
|
||||||
|
- Node.js >= 22
|
||||||
|
- Docker (pour PostgreSQL local)
|
||||||
|
- pnpm
|
||||||
|
|
||||||
|
### 1. Lancer PostgreSQL
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
PostgreSQL sera accessible sur `localhost:5432` avec :
|
||||||
|
- DB: `rebours`
|
||||||
|
- User: `rebours`
|
||||||
|
- Password: `rebours`
|
||||||
|
|
||||||
|
### 2. Configurer le `.env`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Éditer les valeurs (Stripe keys, etc.)
|
||||||
|
```
|
||||||
|
|
||||||
|
Variables dev minimales :
|
||||||
|
```env
|
||||||
|
STRIPE_SECRET_KEY=sk_test_...
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||||
|
DOMAIN=http://localhost:4321
|
||||||
|
PORT=8888
|
||||||
|
DATABASE_URL=postgresql://rebours:rebours@localhost:5432/rebours
|
||||||
|
ADMIN_EMAIL=admin@rebours.studio
|
||||||
|
ADMIN_PASSWORD=changeme
|
||||||
|
COOKIE_SECRET=dev-cookie-secret-at-least-32-characters-long
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Initialiser la base de données
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Première fois : créer les tables
|
||||||
|
pnpm db:migrate
|
||||||
|
|
||||||
|
# Insérer les produits et le compte admin
|
||||||
|
pnpm db:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Lancer le projet
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
- Site : http://localhost:4321
|
||||||
|
- Admin : http://localhost:4321/admin (proxié vers Fastify :8888)
|
||||||
|
- Prisma Studio : `pnpm db:studio` → http://localhost:5555
|
||||||
|
|
||||||
|
### Commandes utiles
|
||||||
|
|
||||||
|
| Commande | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `pnpm dev` | Astro dev + Fastify (hot reload) |
|
||||||
|
| `pnpm build` | Build statique (prisma generate + astro build) |
|
||||||
|
| `pnpm db:migrate` | Créer/appliquer les migrations |
|
||||||
|
| `pnpm db:seed` | Seeder les produits + admin |
|
||||||
|
| `pnpm db:studio` | Prisma Studio (UI pour la DB) |
|
||||||
|
| `docker compose up -d` | Lancer PostgreSQL local |
|
||||||
|
| `docker compose down` | Stopper PostgreSQL local |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Déploiement sur Coolify
|
||||||
|
|
||||||
|
### Prérequis
|
||||||
|
- Un serveur avec Coolify installé
|
||||||
|
- Un domaine pointant vers le serveur (rebours.studio)
|
||||||
|
|
||||||
|
### 1. Créer la ressource sur Coolify
|
||||||
|
|
||||||
|
1. **New Resource** → **Docker Compose**
|
||||||
|
2. **Source** : connecter le repo Git
|
||||||
|
3. **Compose file** : sélectionner `docker-compose.prod.yml`
|
||||||
|
|
||||||
|
### 2. Configurer les variables d'environnement
|
||||||
|
|
||||||
|
Dans l'onglet **Environment Variables** de Coolify, ajouter :
|
||||||
|
|
||||||
|
| Variable | Valeur | Build Variable ? |
|
||||||
|
|----------|--------|:---:|
|
||||||
|
| `DATABASE_URL` | `postgresql://rebours:MOT_DE_PASSE@postgres:5432/rebours` | Oui |
|
||||||
|
| `POSTGRES_PASSWORD` | `MOT_DE_PASSE` (même que dans DATABASE_URL) | Non |
|
||||||
|
| `STRIPE_SECRET_KEY` | `sk_live_...` | Non |
|
||||||
|
| `STRIPE_WEBHOOK_SECRET` | `whsec_...` | Non |
|
||||||
|
| `DOMAIN` | `https://rebours.studio` | Non |
|
||||||
|
| `PORT` | `3000` | Non |
|
||||||
|
| `ADMIN_EMAIL` | `admin@rebours.studio` | Non |
|
||||||
|
| `ADMIN_PASSWORD` | `MOT_DE_PASSE_ADMIN` | Non |
|
||||||
|
| `COOKIE_SECRET` | Chaîne aléatoire 64 chars | Non |
|
||||||
|
|
||||||
|
> `DATABASE_URL` doit être coché **Build Variable** car `astro build` en a besoin pour requêter les produits au build time.
|
||||||
|
|
||||||
|
### 3. Configurer les domaines
|
||||||
|
|
||||||
|
Dans l'onglet **Domains** :
|
||||||
|
- Service `nginx` : `rebours.studio` (port 80)
|
||||||
|
- Service `fastify` : `rebours.studio` (port 3000)
|
||||||
|
|
||||||
|
Coolify générera automatiquement les labels Traefik. Si tu utilises les labels du `docker-compose.prod.yml`, assure-toi qu'ils ne sont pas en conflit avec ceux générés par Coolify.
|
||||||
|
|
||||||
|
### 4. Déployer
|
||||||
|
|
||||||
|
Cliquer **Deploy**. Coolify va :
|
||||||
|
1. Builder l'image Docker (install deps + prisma generate + astro build)
|
||||||
|
2. Démarrer PostgreSQL + attendre le healthcheck
|
||||||
|
3. Démarrer Fastify (qui lance `prisma migrate deploy` automatiquement)
|
||||||
|
4. Démarrer Nginx (qui sert les fichiers statiques)
|
||||||
|
|
||||||
|
### 5. Seeder la base (première fois)
|
||||||
|
|
||||||
|
Après le premier déploiement, exécuter le seed dans le container Fastify :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Via Coolify terminal ou SSH
|
||||||
|
docker exec -it <container-fastify> sh -c "node prisma/seed.mjs"
|
||||||
|
```
|
||||||
|
|
||||||
|
Ou dans le terminal Coolify du service `fastify` :
|
||||||
|
```bash
|
||||||
|
node prisma/seed.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Vérifier
|
||||||
|
|
||||||
|
- Site : https://rebours.studio
|
||||||
|
- Admin : https://rebours.studio/admin
|
||||||
|
- Login avec `ADMIN_EMAIL` / `ADMIN_PASSWORD`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fonctionnement de l'auto-build
|
||||||
|
|
||||||
|
Quand tu modifies un produit dans l'admin (`/admin`) :
|
||||||
|
1. AdminJS sauvegarde en DB (PostgreSQL)
|
||||||
|
2. Un hook `after` déclenche `pnpm build` (debounce 5s)
|
||||||
|
3. Astro rebuild les pages statiques depuis la DB
|
||||||
|
4. Les fichiers `dist/` sont écrits dans le volume partagé
|
||||||
|
5. Nginx sert les nouvelles pages immédiatement
|
||||||
|
|
||||||
|
> En dev (`NODE_ENV !== production`), le build auto est désactivé.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stripe
|
||||||
|
|
||||||
|
### Webhook en production
|
||||||
|
|
||||||
|
Le webhook doit pointer vers :
|
||||||
|
```
|
||||||
|
https://rebours.studio/api/webhook
|
||||||
|
```
|
||||||
|
|
||||||
|
Events écoutés : `checkout.session.completed`
|
||||||
|
|
||||||
|
### Ajouter un produit achetable
|
||||||
|
|
||||||
|
1. Créer le prix dans le **Dashboard Stripe**
|
||||||
|
2. Dans l'admin `/admin` → Products → éditer le produit
|
||||||
|
3. Remplir `stripePriceId` avec le `price_xxx` de Stripe
|
||||||
|
4. Remplir `stripeKey` avec l'identifiant utilisé par le front (ex: `mon_produit`)
|
||||||
|
5. Le rebuild auto mettra à jour le site
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migrations DB
|
||||||
|
|
||||||
|
### En dev
|
||||||
|
```bash
|
||||||
|
pnpm db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### En prod (automatique)
|
||||||
|
Le `CMD` du Dockerfile exécute `prisma migrate deploy` au démarrage du container. Les nouvelles migrations sont appliquées automatiquement à chaque déploiement.
|
||||||
|
|
||||||
|
### Ajouter une migration manuellement
|
||||||
|
```bash
|
||||||
|
# Modifier prisma/schema.prisma puis :
|
||||||
|
pnpm db:migrate
|
||||||
|
# Commit le dossier prisma/migrations/
|
||||||
|
```
|
||||||
31
Dockerfile
Normal file
31
Dockerfile
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
FROM node:22-alpine AS base
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# ── Dependencies ─────────────────────────────────────────────────────────────
|
||||||
|
FROM base AS deps
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
COPY prisma ./prisma/
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# ── Build static site ────────────────────────────────────────────────────────
|
||||||
|
FROM base AS build
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm prisma generate
|
||||||
|
# Astro build needs DATABASE_URL at build time (passed as build arg by Coolify)
|
||||||
|
ARG DATABASE_URL
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# ── Production ───────────────────────────────────────────────────────────────
|
||||||
|
FROM base AS production
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
COPY --from=build /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Migrate DB + seed if needed + start server
|
||||||
|
CMD ["sh", "-c", "pnpm prisma migrate deploy && node server.mjs"]
|
||||||
108
admin.mjs
Normal file
108
admin.mjs
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import AdminJS from 'adminjs'
|
||||||
|
import AdminJSFastify from '@adminjs/fastify'
|
||||||
|
import { Database, Resource, getModelByName } from '@adminjs/prisma'
|
||||||
|
import bcrypt from 'bcrypt'
|
||||||
|
import { exec } from 'child_process'
|
||||||
|
import { prisma } from './src/lib/db.mjs'
|
||||||
|
|
||||||
|
AdminJS.registerAdapter({ Database, Resource })
|
||||||
|
|
||||||
|
// ── Auto-build (prod only) ──────────────────────────────────────────────────
|
||||||
|
let buildTimeout = null
|
||||||
|
let buildInProgress = false
|
||||||
|
|
||||||
|
function triggerBuild() {
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
console.log('[admin] Dev mode — skipping build')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (buildTimeout) clearTimeout(buildTimeout)
|
||||||
|
buildTimeout = setTimeout(() => {
|
||||||
|
if (buildInProgress) return
|
||||||
|
buildInProgress = true
|
||||||
|
console.log('[admin] Building site...')
|
||||||
|
exec('pnpm build', { cwd: process.cwd(), timeout: 120_000 }, (err, stdout, stderr) => {
|
||||||
|
buildInProgress = false
|
||||||
|
if (err) console.error('[admin] Build FAILED:', stderr)
|
||||||
|
else console.log('[admin] Build OK')
|
||||||
|
})
|
||||||
|
}, 5_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const afterBuildHook = async (response) => { triggerBuild(); return response }
|
||||||
|
|
||||||
|
// ── AdminJS setup ───────────────────────────────────────────────────────────
|
||||||
|
export async function setupAdmin(app) {
|
||||||
|
const admin = new AdminJS({
|
||||||
|
rootPath: '/admin',
|
||||||
|
resources: [
|
||||||
|
{
|
||||||
|
resource: { model: getModelByName('Product'), client: prisma },
|
||||||
|
options: {
|
||||||
|
navigation: { name: 'Contenu', icon: 'Package' },
|
||||||
|
listProperties: ['sortOrder', 'name', 'type', 'price', 'isPublished', 'updatedAt'],
|
||||||
|
editProperties: [
|
||||||
|
'slug', 'sortOrder', 'index', 'name', 'type', 'materials',
|
||||||
|
'year', 'status', 'description', 'specs', 'notes',
|
||||||
|
'imagePath', 'imageAlt',
|
||||||
|
'seoTitle', 'seoDescription', 'ogImage',
|
||||||
|
'productDisplayName', 'price', 'currency', 'availability',
|
||||||
|
'stripePriceId', 'stripeKey', 'isPublished',
|
||||||
|
],
|
||||||
|
properties: {
|
||||||
|
description: { type: 'textarea' },
|
||||||
|
specs: { type: 'textarea' },
|
||||||
|
notes: { type: 'textarea' },
|
||||||
|
seoDescription: { type: 'textarea' },
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
new: { after: [afterBuildHook] },
|
||||||
|
edit: { after: [afterBuildHook] },
|
||||||
|
delete: { after: [afterBuildHook] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resource: { model: getModelByName('Order'), client: prisma },
|
||||||
|
options: {
|
||||||
|
navigation: { name: 'Commerce', icon: 'CreditCard' },
|
||||||
|
listProperties: ['stripeSessionId', 'status', 'amount', 'customerEmail', 'productSlug', 'createdAt'],
|
||||||
|
actions: {
|
||||||
|
new: { isAccessible: false },
|
||||||
|
edit: { isAccessible: false },
|
||||||
|
delete: { isAccessible: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
branding: {
|
||||||
|
companyName: 'REBOURS Studio',
|
||||||
|
logo: false,
|
||||||
|
withMadeWithLove: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await AdminJSFastify.buildAuthenticatedRouter(
|
||||||
|
admin,
|
||||||
|
{
|
||||||
|
authenticate: async (email, password) => {
|
||||||
|
const user = await prisma.adminUser.findUnique({ where: { email } })
|
||||||
|
if (!user) return null
|
||||||
|
const valid = await bcrypt.compare(password, user.passwordHash)
|
||||||
|
return valid ? { email: user.email, id: user.id } : null
|
||||||
|
},
|
||||||
|
cookiePassword: process.env.COOKIE_SECRET ?? 'super-secret-cookie-password-at-least-32-chars',
|
||||||
|
cookieName: 'adminjs',
|
||||||
|
},
|
||||||
|
app,
|
||||||
|
{
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return admin
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ 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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
67
docker-compose.prod.yml
Normal file
67
docker-compose.prod.yml
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
services:
|
||||||
|
fastify:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
|
volumes:
|
||||||
|
- static:/app/dist
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL
|
||||||
|
- STRIPE_SECRET_KEY
|
||||||
|
- STRIPE_WEBHOOK_SECRET
|
||||||
|
- DOMAIN
|
||||||
|
- PORT=3000
|
||||||
|
- ADMIN_EMAIL
|
||||||
|
- ADMIN_PASSWORD
|
||||||
|
- COOKIE_SECRET
|
||||||
|
- NODE_ENV=production
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.api.rule=Host(`rebours.studio`) && (PathPrefix(`/api`) || PathPrefix(`/admin`))
|
||||||
|
- traefik.http.routers.api.entrypoints=https
|
||||||
|
- traefik.http.routers.api.tls=true
|
||||||
|
- traefik.http.routers.api.tls.certresolver=letsencrypt
|
||||||
|
- traefik.http.services.api.loadbalancer.server.port=3000
|
||||||
|
- traefik.http.routers.api.priority=2
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=rebours
|
||||||
|
- POSTGRES_USER=rebours
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U rebours -d rebours"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
volumes:
|
||||||
|
- static:/usr/share/nginx/html:ro
|
||||||
|
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
depends_on:
|
||||||
|
- fastify
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.static.rule=Host(`rebours.studio`)
|
||||||
|
- traefik.http.routers.static.entrypoints=https
|
||||||
|
- traefik.http.routers.static.tls=true
|
||||||
|
- traefik.http.routers.static.tls.certresolver=letsencrypt
|
||||||
|
- traefik.http.services.static.loadbalancer.server.port=80
|
||||||
|
- traefik.http.routers.static.priority=1
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
static:
|
||||||
|
pgdata:
|
||||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: rebours
|
||||||
|
POSTGRES_USER: rebours
|
||||||
|
POSTGRES_PASSWORD: rebours
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
31
docker/nginx.conf
Normal file
31
docker/nginx.conf
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# HTML : jamais caché
|
||||||
|
location ~* \.html$ {
|
||||||
|
add_header Cache-Control "no-store";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fichiers Astro avec hash dans _astro/ : cache long immutable
|
||||||
|
location ~* ^/_astro/ {
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# CSS / JS sans hash (style.css, main.js) : revalidation
|
||||||
|
location ~* \.(css|js)$ {
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Assets (images, fonts) : cache 7 jours
|
||||||
|
location ~* \.(jpg|jpeg|png|gif|webp|svg|woff2|woff|ttf|ico)$ {
|
||||||
|
add_header Cache-Control "public, max-age=604800";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ $uri.html /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
6686
package-lock.json
generated
6686
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
56
package.json
56
package.json
@ -4,21 +4,71 @@
|
|||||||
"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": "astro build",
|
"build": "prisma generate && 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",
|
||||||
"@fastify/cors": "^10.0.2",
|
"@fastify/cors": "^10.0.2",
|
||||||
"@fastify/static": "^9.0.0",
|
"@fastify/static": "^9.0.0",
|
||||||
|
"@prisma/client": "^6.19.2",
|
||||||
|
"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",
|
||||||
"stripe": "^20.3.1"
|
"stripe": "^20.3.1",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.3.0"
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/node": "^25.3.0",
|
||||||
|
"prisma": "^6.19.2"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"@prisma/client",
|
||||||
|
"@prisma/engines",
|
||||||
|
"bcrypt",
|
||||||
|
"esbuild",
|
||||||
|
"prisma",
|
||||||
|
"sharp"
|
||||||
|
],
|
||||||
|
"overrides": {
|
||||||
|
"@tiptap/core": "2.1.13",
|
||||||
|
"@tiptap/pm": "2.1.13",
|
||||||
|
"@tiptap/extension-bold": "2.1.13",
|
||||||
|
"@tiptap/extension-blockquote": "2.1.13",
|
||||||
|
"@tiptap/extension-bullet-list": "2.1.13",
|
||||||
|
"@tiptap/extension-code": "2.1.13",
|
||||||
|
"@tiptap/extension-code-block": "2.1.13",
|
||||||
|
"@tiptap/extension-document": "2.1.13",
|
||||||
|
"@tiptap/extension-dropcursor": "2.1.13",
|
||||||
|
"@tiptap/extension-gapcursor": "2.1.13",
|
||||||
|
"@tiptap/extension-hard-break": "2.1.13",
|
||||||
|
"@tiptap/extension-heading": "2.1.13",
|
||||||
|
"@tiptap/extension-history": "2.1.13",
|
||||||
|
"@tiptap/extension-horizontal-rule": "2.1.13",
|
||||||
|
"@tiptap/extension-italic": "2.1.13",
|
||||||
|
"@tiptap/extension-list-item": "2.1.13",
|
||||||
|
"@tiptap/extension-ordered-list": "2.1.13",
|
||||||
|
"@tiptap/extension-paragraph": "2.1.13",
|
||||||
|
"@tiptap/extension-strike": "2.1.13",
|
||||||
|
"@tiptap/extension-text": "2.1.13"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8721
pnpm-lock.yaml
generated
Normal file
8721
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
pnpm-workspace.yaml
Normal file
1
pnpm-workspace.yaml
Normal file
@ -0,0 +1 @@
|
|||||||
|
approveBuildsForScope: '@prisma/engines bcrypt prisma esbuild sharp'
|
||||||
72
prisma/migrations/20260312104833_init/migration.sql
Normal file
72
prisma/migrations/20260312104833_init/migration.sql
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Product" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"index" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"materials" TEXT NOT NULL,
|
||||||
|
"year" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"specs" TEXT NOT NULL,
|
||||||
|
"notes" TEXT NOT NULL,
|
||||||
|
"imagePath" TEXT NOT NULL,
|
||||||
|
"imageAlt" TEXT NOT NULL DEFAULT '',
|
||||||
|
"seoTitle" TEXT NOT NULL,
|
||||||
|
"seoDescription" TEXT NOT NULL,
|
||||||
|
"ogImage" TEXT NOT NULL,
|
||||||
|
"productDisplayName" TEXT NOT NULL,
|
||||||
|
"price" INTEGER,
|
||||||
|
"currency" TEXT NOT NULL DEFAULT 'EUR',
|
||||||
|
"availability" TEXT NOT NULL DEFAULT 'https://schema.org/PreOrder',
|
||||||
|
"stripePriceId" TEXT,
|
||||||
|
"stripeKey" TEXT,
|
||||||
|
"isPublished" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Order" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"stripeSessionId" TEXT NOT NULL,
|
||||||
|
"stripePaymentIntent" TEXT,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
"amount" INTEGER NOT NULL,
|
||||||
|
"currency" TEXT NOT NULL DEFAULT 'EUR',
|
||||||
|
"customerEmail" TEXT,
|
||||||
|
"receiptUrl" TEXT,
|
||||||
|
"productId" TEXT,
|
||||||
|
"productSlug" TEXT,
|
||||||
|
"metadata" JSONB,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Order_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AdminUser" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "AdminUser_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Product_slug_key" ON "Product"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Order_stripeSessionId_key" ON "Order"("stripeSessionId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "AdminUser_email_key" ON "AdminUser"("email");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Order" ADD CONSTRAINT "Order_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
73
prisma/schema.prisma
Normal file
73
prisma/schema.prisma
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Product {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
slug String @unique
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
|
||||||
|
// Card data (data-* attributes)
|
||||||
|
index String // e.g. "PROJET_001"
|
||||||
|
name String // e.g. "Solar_Altar"
|
||||||
|
type String // e.g. "LAMPE DE TABLE"
|
||||||
|
materials String
|
||||||
|
year String
|
||||||
|
status String // e.g. "PROTOTYPE [80%]"
|
||||||
|
description String
|
||||||
|
specs String
|
||||||
|
notes String
|
||||||
|
imagePath String
|
||||||
|
imageAlt String @default("")
|
||||||
|
|
||||||
|
// SEO
|
||||||
|
seoTitle String
|
||||||
|
seoDescription String
|
||||||
|
ogImage String
|
||||||
|
|
||||||
|
// Commerce
|
||||||
|
productDisplayName String
|
||||||
|
price Int? // cents, null = not for sale
|
||||||
|
currency String @default("EUR")
|
||||||
|
availability String @default("https://schema.org/PreOrder")
|
||||||
|
stripePriceId String? // Stripe price_id
|
||||||
|
stripeKey String? // compat frontend e.g. "lumiere_orbitale"
|
||||||
|
isPublished Boolean @default(true)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
orders Order[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Order {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
stripeSessionId String @unique
|
||||||
|
stripePaymentIntent String?
|
||||||
|
status String @default("pending")
|
||||||
|
amount Int
|
||||||
|
currency String @default("EUR")
|
||||||
|
customerEmail String?
|
||||||
|
receiptUrl String?
|
||||||
|
|
||||||
|
productId String?
|
||||||
|
product Product? @relation(fields: [productId], references: [id])
|
||||||
|
productSlug String?
|
||||||
|
|
||||||
|
metadata Json?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model AdminUser {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
passwordHash String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
113
prisma/seed.mjs
Normal file
113
prisma/seed.mjs
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
import bcrypt from 'bcrypt'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
const PRODUCTS = [
|
||||||
|
{
|
||||||
|
slug: 'solar-altar',
|
||||||
|
sortOrder: 0,
|
||||||
|
index: 'PROJET_001',
|
||||||
|
name: 'Solar_Altar',
|
||||||
|
type: 'LAMPE DE TABLE',
|
||||||
|
materials: 'BÉTON TEXTURÉ + DÔME CÉRAMIQUE LAQUÉ',
|
||||||
|
year: '2026',
|
||||||
|
status: 'PROTOTYPE [80%]',
|
||||||
|
description: 'Exploration de la lumière à travers des contraintes géométriques. Le dôme sphérique en céramique laquée coiffe un corps en béton texturé peint à la main. Chaque pièce est unique.',
|
||||||
|
specs: 'H: 45cm / Ø: 18cm\nPoids: 3.2kg\nAlimentation: 220V — E27\nCâble: tressé rouge 2m',
|
||||||
|
notes: 'Inspiré des lampadaires soviétiques des années 60. Le béton est coulé à la main dans des moules uniques. La peinture acrylique est appliquée au spalter.',
|
||||||
|
imagePath: '/assets/lamp-violet.jpg',
|
||||||
|
imageAlt: 'Solar Altar — Lampe béton violet, dôme céramique bleu, REBOURS 2026',
|
||||||
|
seoTitle: 'REBOURS — Solar Altar | Collection 001',
|
||||||
|
seoDescription: 'Lampe de table unique. Béton texturé coulé à la main + dôme céramique laqué. Pièce unique fabriquée à Paris.',
|
||||||
|
ogImage: 'https://rebours.studio/assets/lamp-violet.jpg',
|
||||||
|
productDisplayName: 'Solar Altar',
|
||||||
|
price: 180000, // 1800 EUR in cents
|
||||||
|
currency: 'EUR',
|
||||||
|
availability: 'https://schema.org/LimitedAvailability',
|
||||||
|
stripePriceId: 'price_1T5SBlE5wMMoCUP5ZcjEStwe',
|
||||||
|
stripeKey: 'lumiere_orbitale',
|
||||||
|
isPublished: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'table-terrazzo',
|
||||||
|
sortOrder: 1,
|
||||||
|
index: 'PROJET_002',
|
||||||
|
name: 'TABLE_TERRAZZO',
|
||||||
|
type: 'TABLE BASSE + ÉTAGÈRE MODULAIRE',
|
||||||
|
materials: 'TERRAZZO + ACIER TUBULAIRE + RÉSINE',
|
||||||
|
year: '2026',
|
||||||
|
status: 'STRUCTURAL_TEST',
|
||||||
|
description: "Collision du brutalisme et de la couleur Memphis. Le plateau en terrazzo fait à la main intègre des inclusions de marbre rose et bleu. Les colonnes cylindriques bicolores sont en acier peint au four.",
|
||||||
|
specs: 'Table: L120 × P60 × H38cm\nPoids plateau: 28kg\nPieds: acier Ø60mm\nÉtagère: H180 × L80 × P35cm',
|
||||||
|
notes: "Le terrazzo est réalisé dans l'atelier de Pantin. Chaque dalle est unique. L'étagère est assemblée à partir de tubes industriels récupérés et de panneaux laqués.",
|
||||||
|
imagePath: '/assets/table-terrazzo.jpg',
|
||||||
|
imageAlt: 'TABLE TERRAZZO — Table basse terrazzo et étagère acier, REBOURS 2026',
|
||||||
|
seoTitle: 'REBOURS — TABLE TERRAZZO | Collection 001',
|
||||||
|
seoDescription: 'Table basse et étagère modulaire. Terrazzo fait main + acier tubulaire. Pièce unique fabriquée à Paris.',
|
||||||
|
ogImage: 'https://rebours.studio/assets/table-terrazzo.jpg',
|
||||||
|
productDisplayName: 'Table Terrazzo',
|
||||||
|
price: null,
|
||||||
|
currency: 'EUR',
|
||||||
|
availability: 'https://schema.org/PreOrder',
|
||||||
|
stripePriceId: null,
|
||||||
|
stripeKey: 'table_terrazzo',
|
||||||
|
isPublished: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'module-serie',
|
||||||
|
sortOrder: 2,
|
||||||
|
index: 'PROJET_003',
|
||||||
|
name: 'MODULE_SÉRIE',
|
||||||
|
type: 'LAMPES — SÉRIE LIMITÉE',
|
||||||
|
materials: 'BÉTON COLORÉ + DÔME LAQUÉ + NÉON',
|
||||||
|
year: '2026',
|
||||||
|
status: 'FINAL_ASSEMBLY',
|
||||||
|
description: "Série de 7 lampes aux corps béton colorés, chacune avec un dôme d'une couleur différente. Les néons horizontaux créent un anneau lumineux entre le dôme et le corps.",
|
||||||
|
specs: 'H: 35–65cm (7 tailles)\nDôme: Ø15–28cm\nAnneau néon: 8W — 3000K\nÉdition: 7 ex. par coloris',
|
||||||
|
notes: 'Les corps sont coulés en série mais peints individuellement. Les dômes sont réalisés par un souffleur de verre artisanal. Le câble tressé rouge est la signature de la série.',
|
||||||
|
imagePath: '/assets/lampes-serie.jpg',
|
||||||
|
imageAlt: 'MODULE SÉRIE — Collection de 7 lampes béton colorées, REBOURS 2026',
|
||||||
|
seoTitle: 'REBOURS — MODULE SÉRIE | Collection 001',
|
||||||
|
seoDescription: 'Série de 7 lampes béton colorées, dôme laqué et néon. Édition limitée fabriquée à Paris.',
|
||||||
|
ogImage: 'https://rebours.studio/assets/lampes-serie.jpg',
|
||||||
|
productDisplayName: 'Module Série',
|
||||||
|
price: null,
|
||||||
|
currency: 'EUR',
|
||||||
|
availability: 'https://schema.org/PreOrder',
|
||||||
|
stripePriceId: null,
|
||||||
|
stripeKey: 'module_serie',
|
||||||
|
isPublished: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// Seed products
|
||||||
|
for (const product of PRODUCTS) {
|
||||||
|
await prisma.product.upsert({
|
||||||
|
where: { slug: product.slug },
|
||||||
|
update: product,
|
||||||
|
create: product,
|
||||||
|
})
|
||||||
|
console.log(`✓ Product: ${product.name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed admin user
|
||||||
|
const email = process.env.ADMIN_EMAIL ?? 'admin@rebours.studio'
|
||||||
|
const password = process.env.ADMIN_PASSWORD ?? 'changeme'
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10)
|
||||||
|
|
||||||
|
await prisma.adminUser.upsert({
|
||||||
|
where: { email },
|
||||||
|
update: { passwordHash },
|
||||||
|
create: { email, passwordHash },
|
||||||
|
})
|
||||||
|
console.log(`✓ Admin user: ${email}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect())
|
||||||
@ -124,8 +124,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const checkoutFormWrap = document.getElementById('checkout-form-wrap');
|
const checkoutFormWrap = document.getElementById('checkout-form-wrap');
|
||||||
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');
|
||||||
|
|
||||||
|
// Current product's stripe key (set dynamically when opening panel)
|
||||||
|
let currentStripeKey = null;
|
||||||
|
|
||||||
|
// Format price in cents to display string
|
||||||
|
function formatPrice(cents) {
|
||||||
|
return (cents / 100).toLocaleString('fr-FR') + ' €';
|
||||||
|
}
|
||||||
|
|
||||||
// Toggle affichage du form
|
// Toggle affichage du form
|
||||||
|
if (checkoutToggleBtn) {
|
||||||
checkoutToggleBtn.addEventListener('click', () => {
|
checkoutToggleBtn.addEventListener('click', () => {
|
||||||
const isOpen = checkoutFormWrap.style.display !== 'none';
|
const isOpen = checkoutFormWrap.style.display !== 'none';
|
||||||
checkoutFormWrap.style.display = isOpen ? 'none' : 'block';
|
checkoutFormWrap.style.display = isOpen ? 'none' : 'block';
|
||||||
@ -133,10 +143,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
? '[ COMMANDER CETTE PIÈCE ]'
|
? '[ COMMANDER CETTE PIÈCE ]'
|
||||||
: '[ ANNULER ]';
|
: '[ ANNULER ]';
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Submit → appel API Elysia → redirect Stripe
|
// Submit → appel API → redirect Stripe
|
||||||
|
if (checkoutForm) {
|
||||||
checkoutForm.addEventListener('submit', async (e) => {
|
checkoutForm.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!currentStripeKey) return;
|
||||||
const email = document.getElementById('checkout-email').value;
|
const email = document.getElementById('checkout-email').value;
|
||||||
|
|
||||||
checkoutSubmitBtn.disabled = true;
|
checkoutSubmitBtn.disabled = true;
|
||||||
@ -146,7 +159,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: 'lumiere_orbitale', email }),
|
body: JSON.stringify({ product: currentStripeKey, email }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.url) {
|
if (data.url) {
|
||||||
@ -160,8 +173,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Slug à partir du nom du produit : "Solar_Altar" → "lumiere-orbitale"
|
// Slug à partir du nom du produit : "Solar_Altar" → "solar-altar"
|
||||||
function toSlug(name) {
|
function toSlug(name) {
|
||||||
return name
|
return name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@ -183,9 +197,20 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
fields.specs.textContent = card.dataset.specs;
|
fields.specs.textContent = card.dataset.specs;
|
||||||
fields.notes.textContent = card.dataset.notes;
|
fields.notes.textContent = card.dataset.notes;
|
||||||
|
|
||||||
// Affiche le bouton de commande uniquement pour PROJET_001
|
// Checkout : afficher uniquement si le produit a un prix et un stripe key
|
||||||
const isOrderable = card.dataset.index === 'PROJET_001';
|
const price = card.dataset.price;
|
||||||
checkoutSection.style.display = isOrderable ? 'block' : 'none';
|
const stripeKey = card.dataset.stripeKey;
|
||||||
|
const isOrderable = price && stripeKey;
|
||||||
|
|
||||||
|
if (isOrderable) {
|
||||||
|
currentStripeKey = stripeKey;
|
||||||
|
checkoutPriceEl.textContent = formatPrice(parseInt(price, 10));
|
||||||
|
checkoutSection.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
currentStripeKey = null;
|
||||||
|
checkoutSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
// Reset form state
|
// Reset form state
|
||||||
checkoutFormWrap.style.display = 'none';
|
checkoutFormWrap.style.display = 'none';
|
||||||
checkoutToggleBtn.textContent = '[ COMMANDER CETTE PIÈCE ]';
|
checkoutToggleBtn.textContent = '[ COMMANDER CETTE PIÈCE ]';
|
||||||
@ -231,7 +256,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (card) openPanel(card, false);
|
if (card) openPanel(card, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
panelClose.addEventListener('click', () => closePanel());
|
if (panelClose) panelClose.addEventListener('click', () => closePanel());
|
||||||
|
|
||||||
// Echap pour fermer
|
// Echap pour fermer
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', (e) => {
|
||||||
|
|||||||
155
scripts/stripe-purge.mjs
Normal file
155
scripts/stripe-purge.mjs
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* stripe-purge.mjs
|
||||||
|
* ─────────────────
|
||||||
|
* Supprime TOUS les produits (et leurs prix) du compte Stripe.
|
||||||
|
*
|
||||||
|
* Usage :
|
||||||
|
* node scripts/stripe-purge.mjs # mode dry-run (affiche sans supprimer)
|
||||||
|
* node scripts/stripe-purge.mjs --confirm # suppression réelle
|
||||||
|
*
|
||||||
|
* ⚠️ IRRÉVERSIBLE — utiliser avec précaution
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'dotenv/config'
|
||||||
|
import Stripe from 'stripe'
|
||||||
|
|
||||||
|
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY
|
||||||
|
if (!STRIPE_SECRET_KEY) {
|
||||||
|
console.error('❌ STRIPE_SECRET_KEY manquant dans .env')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = new Stripe(STRIPE_SECRET_KEY)
|
||||||
|
const dryRun = !process.argv.includes('--confirm')
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
console.log('🔍 MODE DRY-RUN — aucun produit ne sera supprimé')
|
||||||
|
console.log(' Ajouter --confirm pour supprimer réellement\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archivePrices(productId) {
|
||||||
|
let hasMore = true
|
||||||
|
let startingAfter = undefined
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
const params = { product: productId, active: true, limit: 100 }
|
||||||
|
if (startingAfter) params.starting_after = startingAfter
|
||||||
|
|
||||||
|
const prices = await stripe.prices.list(params)
|
||||||
|
|
||||||
|
for (const price of prices.data) {
|
||||||
|
if (dryRun) {
|
||||||
|
console.log(` 📌 Prix ${price.id} — ${price.unit_amount / 100} ${price.currency.toUpperCase()} (serait archivé)`)
|
||||||
|
} else {
|
||||||
|
await stripe.prices.update(price.id, { active: false })
|
||||||
|
console.log(` ✓ Prix archivé: ${price.id}`)
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
hasMore = prices.has_more
|
||||||
|
if (prices.data.length > 0) {
|
||||||
|
startingAfter = prices.data[prices.data.length - 1].id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
let hasMore = true
|
||||||
|
let startingAfter = undefined
|
||||||
|
let totalProducts = 0
|
||||||
|
let totalPrices = 0
|
||||||
|
|
||||||
|
console.log('🗑️ Récupération des produits Stripe...\n')
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
const params = { limit: 100, active: true }
|
||||||
|
if (startingAfter) params.starting_after = startingAfter
|
||||||
|
|
||||||
|
const products = await stripe.products.list(params)
|
||||||
|
|
||||||
|
for (const product of products.data) {
|
||||||
|
console.log(`📦 ${product.name || product.id}`)
|
||||||
|
|
||||||
|
// 1. Archiver les prix (obligatoire avant suppression)
|
||||||
|
const priceCount = await archivePrices(product.id)
|
||||||
|
totalPrices += priceCount
|
||||||
|
|
||||||
|
// 2. Archiver puis supprimer le produit
|
||||||
|
if (dryRun) {
|
||||||
|
console.log(` 🗑️ Produit serait supprimé\n`)
|
||||||
|
} else {
|
||||||
|
// D'abord archiver (désactiver)
|
||||||
|
await stripe.products.update(product.id, { active: false })
|
||||||
|
// Puis supprimer (possible uniquement si aucune facture liée)
|
||||||
|
try {
|
||||||
|
await stripe.products.del(product.id)
|
||||||
|
console.log(` ✓ Produit supprimé\n`)
|
||||||
|
} catch (err) {
|
||||||
|
// Si le produit a des factures, on ne peut que l'archiver
|
||||||
|
console.log(` ⚠️ Produit archivé (suppression impossible: ${err.message})\n`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalProducts++
|
||||||
|
}
|
||||||
|
|
||||||
|
hasMore = products.has_more
|
||||||
|
if (products.data.length > 0) {
|
||||||
|
startingAfter = products.data[products.data.length - 1].id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aussi traiter les produits déjà archivés
|
||||||
|
hasMore = true
|
||||||
|
startingAfter = undefined
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
const params = { limit: 100, active: false }
|
||||||
|
if (startingAfter) params.starting_after = startingAfter
|
||||||
|
|
||||||
|
const products = await stripe.products.list(params)
|
||||||
|
|
||||||
|
for (const product of products.data) {
|
||||||
|
console.log(`📦 (archivé) ${product.name || product.id}`)
|
||||||
|
|
||||||
|
const priceCount = await archivePrices(product.id)
|
||||||
|
totalPrices += priceCount
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
console.log(` 🗑️ Produit serait supprimé\n`)
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await stripe.products.del(product.id)
|
||||||
|
console.log(` ✓ Produit supprimé\n`)
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` ⚠️ Non supprimable: ${err.message}\n`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalProducts++
|
||||||
|
}
|
||||||
|
|
||||||
|
hasMore = products.has_more
|
||||||
|
if (products.data.length > 0) {
|
||||||
|
startingAfter = products.data[products.data.length - 1].id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('─'.repeat(50))
|
||||||
|
if (dryRun) {
|
||||||
|
console.log(`📊 ${totalProducts} produits et ${totalPrices} prix SERAIENT supprimés`)
|
||||||
|
console.log('\n👉 Relancer avec --confirm pour exécuter')
|
||||||
|
} else {
|
||||||
|
console.log(`✅ ${totalProducts} produits et ${totalPrices} prix traités`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error('❌ Erreur:', err.message)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
222
scripts/stripe-sync.mjs
Normal file
222
scripts/stripe-sync.mjs
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* stripe-sync.mjs
|
||||||
|
* ────────────────
|
||||||
|
* Synchronise les produits de la BDD vers Stripe :
|
||||||
|
* 1. Pour chaque produit avec un prix (price != null) :
|
||||||
|
* - Crée le Product Stripe (ou le retrouve via metadata.slug)
|
||||||
|
* - Crée le Price Stripe
|
||||||
|
* - Met à jour le stripePriceId dans la BDD
|
||||||
|
* 2. Les produits sans prix sont ignorés
|
||||||
|
*
|
||||||
|
* Usage :
|
||||||
|
* node scripts/stripe-sync.mjs # mode dry-run
|
||||||
|
* node scripts/stripe-sync.mjs --confirm # exécution réelle
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'dotenv/config'
|
||||||
|
import Stripe from 'stripe'
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY
|
||||||
|
if (!STRIPE_SECRET_KEY) {
|
||||||
|
console.error('❌ STRIPE_SECRET_KEY manquant dans .env')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = new Stripe(STRIPE_SECRET_KEY)
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
const dryRun = !process.argv.includes('--confirm')
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
console.log('🔍 MODE DRY-RUN — rien ne sera créé ni modifié')
|
||||||
|
console.log(' Ajouter --confirm pour exécuter réellement\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cherche un produit Stripe existant par metadata.slug
|
||||||
|
*/
|
||||||
|
async function findExistingStripeProduct(slug) {
|
||||||
|
let hasMore = true
|
||||||
|
let startingAfter = undefined
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
const params = { limit: 100, active: true }
|
||||||
|
if (startingAfter) params.starting_after = startingAfter
|
||||||
|
|
||||||
|
const products = await stripe.products.list(params)
|
||||||
|
|
||||||
|
for (const product of products.data) {
|
||||||
|
if (product.metadata?.slug === slug) {
|
||||||
|
return product
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hasMore = products.has_more
|
||||||
|
if (products.data.length > 0) {
|
||||||
|
startingAfter = products.data[products.data.length - 1].id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cherche un prix actif existant pour un produit Stripe
|
||||||
|
* avec le bon montant et la bonne devise
|
||||||
|
*/
|
||||||
|
async function findExistingPrice(stripeProductId, amount, currency) {
|
||||||
|
const prices = await stripe.prices.list({
|
||||||
|
product: stripeProductId,
|
||||||
|
active: true,
|
||||||
|
limit: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
return prices.data.find(
|
||||||
|
p => p.unit_amount === amount && p.currency === currency.toLowerCase()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// Récupérer tous les produits publiés avec un prix
|
||||||
|
const products = await prisma.product.findMany({
|
||||||
|
where: {
|
||||||
|
isPublished: true,
|
||||||
|
price: { not: null },
|
||||||
|
},
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (products.length === 0) {
|
||||||
|
console.log('ℹ️ Aucun produit avec un prix trouvé dans la BDD')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📦 ${products.length} produit(s) à synchroniser vers Stripe\n`)
|
||||||
|
|
||||||
|
let created = 0
|
||||||
|
let updated = 0
|
||||||
|
let skipped = 0
|
||||||
|
|
||||||
|
for (const product of products) {
|
||||||
|
const priceCents = product.price
|
||||||
|
const currency = product.currency.toLowerCase()
|
||||||
|
|
||||||
|
console.log(`── ${product.productDisplayName} (${product.slug})`)
|
||||||
|
console.log(` Prix: ${priceCents / 100} ${currency.toUpperCase()}`)
|
||||||
|
|
||||||
|
// 1. Chercher ou créer le Product Stripe
|
||||||
|
let stripeProduct = await findExistingStripeProduct(product.slug)
|
||||||
|
|
||||||
|
if (stripeProduct) {
|
||||||
|
console.log(` ✓ Produit Stripe existant: ${stripeProduct.id}`)
|
||||||
|
|
||||||
|
if (!dryRun) {
|
||||||
|
// Mettre à jour les infos du produit
|
||||||
|
stripeProduct = await stripe.products.update(stripeProduct.id, {
|
||||||
|
name: product.productDisplayName,
|
||||||
|
description: product.description,
|
||||||
|
images: product.ogImage ? [product.ogImage] : undefined,
|
||||||
|
metadata: {
|
||||||
|
slug: product.slug,
|
||||||
|
stripeKey: product.stripeKey || product.slug,
|
||||||
|
dbId: product.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
console.log(` ✓ Produit Stripe mis à jour`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (dryRun) {
|
||||||
|
console.log(` 📌 Produit Stripe SERAIT créé`)
|
||||||
|
} else {
|
||||||
|
stripeProduct = await stripe.products.create({
|
||||||
|
name: product.productDisplayName,
|
||||||
|
description: product.description,
|
||||||
|
images: product.ogImage ? [product.ogImage] : [],
|
||||||
|
metadata: {
|
||||||
|
slug: product.slug,
|
||||||
|
stripeKey: product.stripeKey || product.slug,
|
||||||
|
dbId: product.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
console.log(` ✓ Produit Stripe créé: ${stripeProduct.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Chercher ou créer le Price Stripe
|
||||||
|
let stripePrice = null
|
||||||
|
|
||||||
|
if (stripeProduct) {
|
||||||
|
stripePrice = await findExistingPrice(stripeProduct.id, priceCents, currency)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stripePrice) {
|
||||||
|
console.log(` ✓ Prix Stripe existant: ${stripePrice.id} (${priceCents / 100} ${currency.toUpperCase()})`)
|
||||||
|
|
||||||
|
// Vérifier si le stripePriceId dans la BDD est à jour
|
||||||
|
if (product.stripePriceId === stripePrice.id) {
|
||||||
|
console.log(` ✓ BDD déjà à jour`)
|
||||||
|
skipped++
|
||||||
|
} else {
|
||||||
|
if (dryRun) {
|
||||||
|
console.log(` 📌 stripePriceId SERAIT mis à jour: ${product.stripePriceId || '(vide)'} → ${stripePrice.id}`)
|
||||||
|
updated++
|
||||||
|
} else {
|
||||||
|
await prisma.product.update({
|
||||||
|
where: { id: product.id },
|
||||||
|
data: { stripePriceId: stripePrice.id },
|
||||||
|
})
|
||||||
|
console.log(` ✓ BDD mise à jour: stripePriceId = ${stripePrice.id}`)
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Créer un nouveau prix
|
||||||
|
if (dryRun) {
|
||||||
|
console.log(` 📌 Prix Stripe SERAIT créé: ${priceCents / 100} ${currency.toUpperCase()}`)
|
||||||
|
console.log(` 📌 stripePriceId SERAIT mis à jour dans la BDD`)
|
||||||
|
created++
|
||||||
|
} else {
|
||||||
|
stripePrice = await stripe.prices.create({
|
||||||
|
product: stripeProduct.id,
|
||||||
|
unit_amount: priceCents,
|
||||||
|
currency: currency,
|
||||||
|
metadata: {
|
||||||
|
slug: product.slug,
|
||||||
|
dbId: product.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
console.log(` ✓ Prix Stripe créé: ${stripePrice.id}`)
|
||||||
|
|
||||||
|
// 3. Mettre à jour le stripePriceId dans la BDD
|
||||||
|
await prisma.product.update({
|
||||||
|
where: { id: product.id },
|
||||||
|
data: { stripePriceId: stripePrice.id },
|
||||||
|
})
|
||||||
|
console.log(` ✓ BDD mise à jour: stripePriceId = ${stripePrice.id}`)
|
||||||
|
created++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log()
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('─'.repeat(50))
|
||||||
|
if (dryRun) {
|
||||||
|
console.log(`📊 Résumé (dry-run) :`)
|
||||||
|
console.log(` ${created} produit(s)/prix SERAIENT créés`)
|
||||||
|
console.log(` ${updated} stripePriceId SERAIENT mis à jour`)
|
||||||
|
console.log(` ${skipped} déjà synchronisé(s)`)
|
||||||
|
console.log(`\n👉 Relancer avec --confirm pour exécuter`)
|
||||||
|
} else {
|
||||||
|
console.log(`✅ Synchronisation terminée :`)
|
||||||
|
console.log(` ${created} créé(s), ${updated} mis à jour, ${skipped} inchangé(s)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch(err => {
|
||||||
|
console.error('❌ Erreur:', err.message)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect())
|
||||||
185
server.mjs
185
server.mjs
@ -2,88 +2,18 @@ import Fastify from 'fastify'
|
|||||||
import cors from '@fastify/cors'
|
import cors from '@fastify/cors'
|
||||||
import Stripe from 'stripe'
|
import Stripe from 'stripe'
|
||||||
import dotenv from 'dotenv'
|
import dotenv from 'dotenv'
|
||||||
|
import { setupAdmin } from './admin.mjs'
|
||||||
|
import { prisma } from './src/lib/db.mjs'
|
||||||
|
|
||||||
dotenv.config()
|
dotenv.config()
|
||||||
|
|
||||||
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'
|
||||||
|
|
||||||
const PRODUCTS = {
|
|
||||||
lumiere_orbitale: {
|
|
||||||
price_id: 'price_1T5SBlE5wMMoCUP5ZcjEStwe',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = Fastify({ logger: true })
|
const app = Fastify({ logger: true })
|
||||||
await app.register(cors, { origin: '*', methods: ['GET', 'POST'] })
|
await app.register(cors, { origin: '*', methods: ['GET', 'POST'] })
|
||||||
|
|
||||||
// ── SEO ───────────────────────────────────────────────────────────────────────
|
// ── Webhook Stripe (AVANT AdminJS pour éviter les conflits de body parsing) ─
|
||||||
app.get('/robots.txt', (_, reply) => {
|
|
||||||
reply
|
|
||||||
.type('text/plain')
|
|
||||||
.header('Cache-Control', 'public, max-age=86400')
|
|
||||||
.send(`User-agent: *\nAllow: /\nSitemap: ${DOMAIN}/sitemap.xml\n`)
|
|
||||||
})
|
|
||||||
|
|
||||||
app.get('/sitemap.xml', (_, reply) => {
|
|
||||||
const today = new Date().toISOString().split('T')[0]
|
|
||||||
reply
|
|
||||||
.type('application/xml')
|
|
||||||
.header('Cache-Control', 'public, max-age=86400')
|
|
||||||
.send(
|
|
||||||
`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n <url><loc>${DOMAIN}/</loc><lastmod>${today}</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>\n</urlset>`
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Checkout Stripe ───────────────────────────────────────────────────────────
|
|
||||||
app.post('/api/checkout', async (request, reply) => {
|
|
||||||
const { product, email } = request.body ?? {}
|
|
||||||
const p = PRODUCTS[product]
|
|
||||||
if (!p) return reply.code(404).send({ error: 'Produit inconnu' })
|
|
||||||
app.log.info(`Stripe key prefix: ${process.env.STRIPE_SECRET_KEY?.slice(0, 20)}`)
|
|
||||||
app.log.info(`Price ID: ${p.price_id}`)
|
|
||||||
|
|
||||||
let session
|
|
||||||
try {
|
|
||||||
session = await stripe.checkout.sessions.create({
|
|
||||||
mode: 'payment',
|
|
||||||
payment_method_types: ['card', 'link'],
|
|
||||||
line_items: [{
|
|
||||||
price: p.price_id,
|
|
||||||
quantity: 1,
|
|
||||||
}],
|
|
||||||
metadata: { product },
|
|
||||||
success_url: `${DOMAIN}/success?session_id={CHECKOUT_SESSION_ID}`,
|
|
||||||
cancel_url: `${DOMAIN}/#collection`,
|
|
||||||
locale: 'fr',
|
|
||||||
customer_email: email ?? undefined,
|
|
||||||
custom_text: {
|
|
||||||
submit: { message: 'Pièce unique — fabriquée à Paris. Délai : 6 à 8 semaines.' },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
app.log.error(err)
|
|
||||||
return reply.code(500).send({ error: err.message })
|
|
||||||
}
|
|
||||||
return { url: session.url }
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Vérification session ──────────────────────────────────────────────────────
|
|
||||||
app.get('/api/session/:id', async (request) => {
|
|
||||||
const session = await stripe.checkout.sessions.retrieve(request.params.id, {
|
|
||||||
expand: ['payment_intent.latest_charge'],
|
|
||||||
})
|
|
||||||
const charge = session.payment_intent?.latest_charge
|
|
||||||
return {
|
|
||||||
status: session.payment_status,
|
|
||||||
amount: session.amount_total,
|
|
||||||
currency: session.currency,
|
|
||||||
customer_email: session.customer_details?.email ?? null,
|
|
||||||
product: session.metadata?.product ?? null,
|
|
||||||
receipt_url: charge?.receipt_url ?? null,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Webhook Stripe ────────────────────────────────────────────────────────────
|
|
||||||
app.post('/api/webhook', {
|
app.post('/api/webhook', {
|
||||||
config: { rawBody: true },
|
config: { rawBody: true },
|
||||||
onRequest: (request, reply, done) => {
|
onRequest: (request, reply, done) => {
|
||||||
@ -107,14 +37,117 @@ app.post('/api/webhook', {
|
|||||||
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 — ${session.id} — ${session.customer_details?.email}`)
|
||||||
|
|
||||||
|
// Find product by stripeKey
|
||||||
|
const stripeKey = session.metadata?.product
|
||||||
|
const product = stripeKey
|
||||||
|
? await prisma.product.findFirst({ where: { stripeKey } })
|
||||||
|
: null
|
||||||
|
|
||||||
|
await prisma.order.upsert({
|
||||||
|
where: { stripeSessionId: session.id },
|
||||||
|
create: {
|
||||||
|
stripeSessionId: session.id,
|
||||||
|
stripePaymentIntent: typeof session.payment_intent === 'string' ? session.payment_intent : null,
|
||||||
|
status: 'paid',
|
||||||
|
amount: session.amount_total ?? 0,
|
||||||
|
currency: session.currency ?? 'eur',
|
||||||
|
customerEmail: session.customer_details?.email ?? null,
|
||||||
|
productId: product?.id ?? null,
|
||||||
|
productSlug: product?.slug ?? stripeKey ?? null,
|
||||||
|
metadata: session.metadata ?? null,
|
||||||
|
},
|
||||||
|
update: { status: 'paid' },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { received: true }
|
return { received: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
// ── AdminJS ─────────────────────────────────────────────────────────────────
|
||||||
|
await setupAdmin(app)
|
||||||
|
|
||||||
|
// ── SEO ─────────────────────────────────────────────────────────────────────
|
||||||
|
app.get('/robots.txt', (_, reply) => {
|
||||||
|
reply
|
||||||
|
.type('text/plain')
|
||||||
|
.header('Cache-Control', 'public, max-age=86400')
|
||||||
|
.send(`User-agent: *\nAllow: /\nSitemap: ${DOMAIN}/sitemap.xml\n`)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/sitemap.xml', async (_, reply) => {
|
||||||
|
const today = new Date().toISOString().split('T')[0]
|
||||||
|
const products = await prisma.product.findMany({
|
||||||
|
where: { isPublished: true },
|
||||||
|
select: { slug: true },
|
||||||
|
})
|
||||||
|
const productUrls = products
|
||||||
|
.map(p => ` <url><loc>${DOMAIN}/collection/${p.slug}</loc><lastmod>${today}</lastmod><changefreq>weekly</changefreq><priority>0.8</priority></url>`)
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
reply
|
||||||
|
.type('application/xml')
|
||||||
|
.header('Cache-Control', 'public, max-age=86400')
|
||||||
|
.send(
|
||||||
|
`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n <url><loc>${DOMAIN}/</loc><lastmod>${today}</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>\n${productUrls}\n</urlset>`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Checkout Stripe ─────────────────────────────────────────────────────────
|
||||||
|
app.post('/api/checkout', async (request, reply) => {
|
||||||
|
const { product, email } = request.body ?? {}
|
||||||
|
|
||||||
|
// Lookup by stripeKey (compat frontend: "lumiere_orbitale")
|
||||||
|
const p = await prisma.product.findFirst({
|
||||||
|
where: { stripeKey: product, isPublished: true },
|
||||||
|
select: { stripePriceId: true, stripeKey: true },
|
||||||
|
})
|
||||||
|
if (!p || !p.stripePriceId) return reply.code(404).send({ error: 'Produit inconnu' })
|
||||||
|
|
||||||
|
let session
|
||||||
try {
|
try {
|
||||||
await app.listen({ port: process.env.PORT ?? 3000, host: '127.0.0.1' })
|
session = await stripe.checkout.sessions.create({
|
||||||
|
mode: 'payment',
|
||||||
|
payment_method_types: ['card', 'link'],
|
||||||
|
line_items: [{
|
||||||
|
price: p.stripePriceId,
|
||||||
|
quantity: 1,
|
||||||
|
}],
|
||||||
|
metadata: { product },
|
||||||
|
success_url: `${DOMAIN}/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||||
|
cancel_url: `${DOMAIN}/#collection`,
|
||||||
|
locale: 'fr',
|
||||||
|
customer_email: email ?? undefined,
|
||||||
|
custom_text: {
|
||||||
|
submit: { message: 'Pièce unique — fabriquée à Paris. Délai : 6 à 8 semaines.' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(err)
|
||||||
|
return reply.code(500).send({ error: err.message })
|
||||||
|
}
|
||||||
|
return { url: session.url }
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Vérification session ────────────────────────────────────────────────────
|
||||||
|
app.get('/api/session/:id', async (request) => {
|
||||||
|
const session = await stripe.checkout.sessions.retrieve(request.params.id, {
|
||||||
|
expand: ['payment_intent.latest_charge'],
|
||||||
|
})
|
||||||
|
const charge = session.payment_intent?.latest_charge
|
||||||
|
return {
|
||||||
|
status: session.payment_status,
|
||||||
|
amount: session.amount_total,
|
||||||
|
currency: session.currency,
|
||||||
|
customer_email: session.customer_details?.email ?? null,
|
||||||
|
product: session.metadata?.product ?? null,
|
||||||
|
receipt_url: charge?.receipt_url ?? null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Start ───────────────────────────────────────────────────────────────────
|
||||||
|
try {
|
||||||
|
await app.listen({ port: process.env.PORT ?? 3000, host: '0.0.0.0' })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
app.log.error(err)
|
app.log.error(err)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
|
|||||||
5
src/lib/db.mjs
Normal file
5
src/lib/db.mjs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis
|
||||||
|
export const prisma = globalForPrisma.__prisma ?? new PrismaClient()
|
||||||
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.__prisma = prisma
|
||||||
@ -1,44 +1,35 @@
|
|||||||
---
|
---
|
||||||
import Base from '../../layouts/Base.astro';
|
import Base from '../../layouts/Base.astro';
|
||||||
|
import { prisma } from '../../lib/db.mjs';
|
||||||
|
|
||||||
export function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const PRODUCTS = [
|
const products = await prisma.product.findMany({
|
||||||
{
|
where: { isPublished: true },
|
||||||
slug: 'solar-altar',
|
orderBy: { sortOrder: 'asc' },
|
||||||
name: 'Solar_Altar',
|
});
|
||||||
title: 'REBOURS — Solar Altar | Collection 001',
|
|
||||||
description: 'Lampe de table unique. Béton texturé coulé à la main + dôme céramique laqué. Pièce unique fabriquée à Paris.',
|
return products.map(p => ({
|
||||||
ogImage: 'https://rebours.studio/assets/lamp-violet.jpg',
|
params: { slug: p.slug },
|
||||||
productName: 'Solar Altar',
|
props: {
|
||||||
price: '1800',
|
slug: p.slug,
|
||||||
availability: 'https://schema.org/LimitedAvailability',
|
name: p.name,
|
||||||
|
title: p.seoTitle,
|
||||||
|
description: p.seoDescription,
|
||||||
|
ogImage: p.ogImage,
|
||||||
|
productName: p.productDisplayName,
|
||||||
|
price: p.price ? String(p.price / 100) : null,
|
||||||
|
availability: p.availability,
|
||||||
},
|
},
|
||||||
{
|
}));
|
||||||
slug: 'table-terrazzo',
|
|
||||||
name: 'TABLE_TERRAZZO',
|
|
||||||
title: 'REBOURS — TABLE TERRAZZO | Collection 001',
|
|
||||||
description: 'Table basse et étagère modulaire. Terrazzo fait main + acier tubulaire. Pièce unique fabriquée à Paris.',
|
|
||||||
ogImage: 'https://rebours.studio/assets/table-terrazzo.jpg',
|
|
||||||
productName: 'Table Terrazzo',
|
|
||||||
price: null,
|
|
||||||
availability: 'https://schema.org/PreOrder',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: 'module-serie',
|
|
||||||
name: 'MODULE_SÉRIE',
|
|
||||||
title: 'REBOURS — MODULE SÉRIE | Collection 001',
|
|
||||||
description: 'Série de 7 lampes béton colorées, dôme laqué et néon. Édition limitée fabriquée à Paris.',
|
|
||||||
ogImage: 'https://rebours.studio/assets/lampes-serie.jpg',
|
|
||||||
productName: 'Module Série',
|
|
||||||
price: null,
|
|
||||||
availability: 'https://schema.org/PreOrder',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
return PRODUCTS.map(p => ({ params: { slug: p.slug }, props: p }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { slug, title, description, ogImage, name, productName, price, availability } = Astro.props;
|
const { slug, title, description, ogImage, name, productName, price, availability } = Astro.props;
|
||||||
|
|
||||||
|
const allProducts = await prisma.product.findMany({
|
||||||
|
where: { isPublished: true },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
const schemaProduct = {
|
const schemaProduct = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Product",
|
"@type": "Product",
|
||||||
@ -83,9 +74,8 @@ const schemaBreadcrumb = {
|
|||||||
<!-- On charge index.html entier et on ouvre le panel via JS au load -->
|
<!-- 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} />
|
||||||
|
|
||||||
<!-- Même contenu que index.astro — redirige vers / avec panel ouvert -->
|
<!-- Avant le DOM : on note quel panel ouvrir -->
|
||||||
<script is:inline>
|
<script is:inline>
|
||||||
// Avant le DOM : on note quel panel ouvrir
|
|
||||||
window.__OPEN_PANEL__ = document.querySelector('meta[name="x-open-panel"]')?.content;
|
window.__OPEN_PANEL__ = document.querySelector('meta[name="x-open-panel"]')?.content;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -140,7 +130,7 @@ const schemaBreadcrumb = {
|
|||||||
|
|
||||||
<div id="checkout-section" style="display:none;">
|
<div id="checkout-section" style="display:none;">
|
||||||
<div class="checkout-price-line">
|
<div class="checkout-price-line">
|
||||||
<span class="checkout-price">1 800 €</span>
|
<span class="checkout-price"></span>
|
||||||
<span class="checkout-edition">ÉDITION UNIQUE — 1/1</span>
|
<span class="checkout-edition">ÉDITION UNIQUE — 1/1</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="checkout-toggle-btn" class="checkout-btn">
|
<button id="checkout-toggle-btn" class="checkout-btn">
|
||||||
@ -205,77 +195,38 @@ const schemaBreadcrumb = {
|
|||||||
<section class="collection" id="collection" aria-label="Collection 001">
|
<section class="collection" id="collection" aria-label="Collection 001">
|
||||||
<div class="collection-header">
|
<div class="collection-header">
|
||||||
<p class="label">// COLLECTION_001</p>
|
<p class="label">// COLLECTION_001</p>
|
||||||
<span class="label">3 OBJETS — CLIQUER POUR OUVRIR</span>
|
<span class="label">{allProducts.length} OBJETS — CLIQUER POUR OUVRIR</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="product-grid">
|
<div class="product-grid">
|
||||||
|
{allProducts.map((p, i) => (
|
||||||
<article class="product-card"
|
<article class="product-card"
|
||||||
data-index="PROJET_001"
|
data-index={p.index}
|
||||||
data-name="Solar_Altar"
|
data-name={p.name}
|
||||||
data-type="LAMPE DE TABLE"
|
data-type={p.type}
|
||||||
data-mat="BÉTON TEXTURÉ + DÔME CÉRAMIQUE LAQUÉ"
|
data-mat={p.materials}
|
||||||
data-year="2026"
|
data-year={p.year}
|
||||||
data-status="PROTOTYPE [80%]"
|
data-status={p.status}
|
||||||
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-desc={p.description}
|
||||||
data-specs="H: 45cm / Ø: 18cm Poids: 3.2kg Alimentation: 220V — E27 Câble: tressé rouge 2m"
|
data-specs={p.specs}
|
||||||
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-notes={p.notes}
|
||||||
data-img="/assets/lamp-violet.jpg"
|
data-img={p.imagePath}
|
||||||
aria-label="Ouvrir le détail de Solar Altar">
|
data-price={p.price ? String(p.price) : ''}
|
||||||
|
data-stripe-key={p.stripeKey ?? ''}
|
||||||
|
aria-label={`Ouvrir le détail de ${p.productDisplayName}`}>
|
||||||
<div class="card-img-wrap">
|
<div class="card-img-wrap">
|
||||||
<img src="/assets/lamp-violet.jpg" alt="Solar Altar — Lampe béton violet, dôme céramique bleu, REBOURS 2026" width="600" height="600" loading="lazy">
|
<img src={p.imagePath}
|
||||||
|
alt={p.imageAlt || p.productDisplayName}
|
||||||
|
width="600" height="600"
|
||||||
|
loading={i === 0 ? "eager" : "lazy"}>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-meta">
|
<div class="card-meta">
|
||||||
<span class="card-index">001</span>
|
<span class="card-index">{String(i + 1).padStart(3, '0')}</span>
|
||||||
<span class="card-name">Solar_Altar</span>
|
<span class="card-name">{p.name}</span>
|
||||||
<span class="card-arrow">↗</span>
|
<span class="card-arrow">↗</span>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</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 Poids plateau: 28kg Pieds: acier Ø60mm É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: 35–65cm (7 tailles) Dôme: Ø15–28cm Anneau néon: 8W — 3000K É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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
---
|
---
|
||||||
import Base from '../layouts/Base.astro';
|
import Base from '../layouts/Base.astro';
|
||||||
|
import { prisma } from '../lib/db.mjs';
|
||||||
|
|
||||||
|
const products = await prisma.product.findMany({
|
||||||
|
where: { isPublished: true },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
const schemaOrg = {
|
const schemaOrg = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
@ -12,18 +18,18 @@ const schemaOrg = {
|
|||||||
"hasOfferCatalog": {
|
"hasOfferCatalog": {
|
||||||
"@type": "OfferCatalog",
|
"@type": "OfferCatalog",
|
||||||
"name": "Collection 001",
|
"name": "Collection 001",
|
||||||
"itemListElement": [{
|
"itemListElement": products.filter(p => p.price).map(p => ({
|
||||||
"@type": "Offer",
|
"@type": "Offer",
|
||||||
"itemOffered": {
|
"itemOffered": {
|
||||||
"@type": "Product",
|
"@type": "Product",
|
||||||
"name": "Solar Altar",
|
"name": p.productDisplayName,
|
||||||
"description": "Lampe de table unique. Béton texturé coulé à la main + dôme céramique laqué.",
|
"description": p.seoDescription,
|
||||||
"image": "https://rebours.studio/assets/lamp-violet.jpg"
|
"image": p.ogImage,
|
||||||
},
|
},
|
||||||
"price": "1800",
|
"price": String(p.price! / 100),
|
||||||
"priceCurrency": "EUR",
|
"priceCurrency": p.currency,
|
||||||
"availability": "https://schema.org/LimitedAvailability"
|
"availability": p.availability,
|
||||||
}]
|
})),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
---
|
---
|
||||||
@ -88,7 +94,7 @@ const schemaOrg = {
|
|||||||
|
|
||||||
<div id="checkout-section" style="display:none;">
|
<div id="checkout-section" style="display:none;">
|
||||||
<div class="checkout-price-line">
|
<div class="checkout-price-line">
|
||||||
<span class="checkout-price">1 800 €</span>
|
<span class="checkout-price"></span>
|
||||||
<span class="checkout-edition">ÉDITION UNIQUE — 1/1</span>
|
<span class="checkout-edition">ÉDITION UNIQUE — 1/1</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="checkout-toggle-btn" class="checkout-btn">
|
<button id="checkout-toggle-btn" class="checkout-btn">
|
||||||
@ -155,86 +161,38 @@ const schemaOrg = {
|
|||||||
<section class="collection" id="collection" aria-label="Collection 001">
|
<section class="collection" id="collection" aria-label="Collection 001">
|
||||||
<div class="collection-header">
|
<div class="collection-header">
|
||||||
<p class="label">// COLLECTION_001</p>
|
<p class="label">// COLLECTION_001</p>
|
||||||
<span class="label">3 OBJETS — CLIQUER POUR OUVRIR</span>
|
<span class="label">{products.length} OBJETS — CLIQUER POUR OUVRIR</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="product-grid">
|
<div class="product-grid">
|
||||||
|
{products.map((p, i) => (
|
||||||
<article class="product-card"
|
<article class="product-card"
|
||||||
data-index="PROJET_001"
|
data-index={p.index}
|
||||||
data-name="Solar_Altar"
|
data-name={p.name}
|
||||||
data-type="LAMPE DE TABLE"
|
data-type={p.type}
|
||||||
data-mat="BÉTON TEXTURÉ + DÔME CÉRAMIQUE LAQUÉ"
|
data-mat={p.materials}
|
||||||
data-year="2026"
|
data-year={p.year}
|
||||||
data-status="PROTOTYPE [80%]"
|
data-status={p.status}
|
||||||
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-desc={p.description}
|
||||||
data-specs="H: 45cm / Ø: 18cm Poids: 3.2kg Alimentation: 220V — E27 Câble: tressé rouge 2m"
|
data-specs={p.specs}
|
||||||
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-notes={p.notes}
|
||||||
data-img="/assets/lamp-violet.jpg"
|
data-img={p.imagePath}
|
||||||
aria-label="Ouvrir le détail de Solar Altar">
|
data-price={p.price ? String(p.price) : ''}
|
||||||
|
data-stripe-key={p.stripeKey ?? ''}
|
||||||
|
aria-label={`Ouvrir le détail de ${p.productDisplayName}`}>
|
||||||
<div class="card-img-wrap">
|
<div class="card-img-wrap">
|
||||||
<img src="/assets/lamp-violet.jpg"
|
<img src={p.imagePath}
|
||||||
alt="Solar Altar — Lampe béton violet, dôme céramique bleu, REBOURS 2026"
|
alt={p.imageAlt || p.productDisplayName}
|
||||||
width="600" height="600"
|
width="600" height="600"
|
||||||
loading="lazy">
|
loading={i === 0 ? "eager" : "lazy"}>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-meta">
|
<div class="card-meta">
|
||||||
<span class="card-index">001</span>
|
<span class="card-index">{String(i + 1).padStart(3, '0')}</span>
|
||||||
<span class="card-name">Solar_Altar</span>
|
<span class="card-name">{p.name}</span>
|
||||||
<span class="card-arrow">↗</span>
|
<span class="card-arrow">↗</span>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</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 Poids plateau: 28kg Pieds: acier Ø60mm É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: 35–65cm (7 tailles) Dôme: Ø15–28cm Anneau néon: 8W — 3000K É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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,13 @@
|
|||||||
---
|
---
|
||||||
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
|
||||||
@ -30,7 +38,14 @@ import Base from '../layouts/Base.astro';
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
opacity: 0.55;
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.left::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.slabel { font-size: 0.75rem; color: #888; }
|
.slabel { font-size: 0.75rem; color: #888; }
|
||||||
h1 {
|
h1 {
|
||||||
@ -76,11 +91,13 @@ import Base from '../layouts/Base.astro';
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--clr-black);
|
color: var(--clr-black);
|
||||||
transition: background 0.15s;
|
transition: background 0.15s, color 0.15s;
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
|
cursor: none;
|
||||||
}
|
}
|
||||||
a.back:hover { background: var(--clr-black); color: var(--clr-white); }
|
a.back:hover { background: var(--clr-black); color: var(--clr-white); }
|
||||||
|
#receipt-btn:hover { background: var(--clr-red) !important; color: var(--clr-black) !important; border-color: var(--clr-red) !important; }
|
||||||
#loading { color: #888; font-size: 0.78rem; }
|
#loading { color: #888; font-size: 0.78rem; }
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
@ -97,7 +114,10 @@ import Base from '../layouts/Base.astro';
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div style="display:flex; flex-direction:column; min-height:100vh;">
|
<!-- Grid background -->
|
||||||
|
<div id="interactive-grid" class="interactive-grid"></div>
|
||||||
|
|
||||||
|
<div style="display:flex; flex-direction:column; min-height:100vh; position:relative; z-index:1;">
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<a href="/" class="logo-text">REBOURS</a>
|
<a href="/" class="logo-text">REBOURS</a>
|
||||||
<span style="font-size:0.78rem;color:#888">COLLECTION_001</span>
|
<span style="font-size:0.78rem;color:#888">COLLECTION_001</span>
|
||||||
@ -142,20 +162,21 @@ import Base from '../layouts/Base.astro';
|
|||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<div class="cursor-dot"></div>
|
||||||
|
<div class="cursor-outline"></div>
|
||||||
|
|
||||||
|
<script src="/main.js" is:inline></script>
|
||||||
|
|
||||||
|
<script define:vars={{ productImages }}>
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const sessionId = params.get('session_id');
|
const sessionId = params.get('session_id');
|
||||||
|
|
||||||
const PRODUCT_IMAGES: Record<string, string> = {
|
const PRODUCT_IMAGES = productImages;
|
||||||
lumiere_orbitale: '/assets/lamp-violet.jpg',
|
|
||||||
table_terrazzo: '/assets/table-terrazzo.jpg',
|
|
||||||
module_serie: '/assets/lampes-serie.jpg',
|
|
||||||
};
|
|
||||||
|
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
fetch(`/api/session/${sessionId}`)
|
fetch(`/api/session/${sessionId}`)
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then((data: { amount?: number; customer_email?: string; product?: string; receipt_url?: string }) => {
|
.then((data) => {
|
||||||
const loading = document.getElementById('loading');
|
const loading = document.getElementById('loading');
|
||||||
const orderDetails = document.getElementById('order-details');
|
const orderDetails = document.getElementById('order-details');
|
||||||
if (loading) loading.style.display = 'none';
|
if (loading) loading.style.display = 'none';
|
||||||
@ -168,12 +189,12 @@ import Base from '../layouts/Base.astro';
|
|||||||
if (emailEl) emailEl.textContent = data.customer_email ?? '—';
|
if (emailEl) emailEl.textContent = data.customer_email ?? '—';
|
||||||
|
|
||||||
if (data.product && PRODUCT_IMAGES[data.product]) {
|
if (data.product && PRODUCT_IMAGES[data.product]) {
|
||||||
const img = document.getElementById('product-img') as HTMLImageElement;
|
const img = document.getElementById('product-img');
|
||||||
if (img) img.src = PRODUCT_IMAGES[data.product]!;
|
if (img) img.src = PRODUCT_IMAGES[data.product];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.receipt_url) {
|
if (data.receipt_url) {
|
||||||
const btn = document.getElementById('receipt-btn') as HTMLAnchorElement;
|
const btn = document.getElementById('receipt-btn');
|
||||||
if (btn) { btn.href = data.receipt_url; btn.style.display = 'inline-block'; }
|
if (btn) { btn.href = data.receipt_url; btn.style.display = 'inline-block'; }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user