new site
This commit is contained in:
parent
39f7db84fb
commit
ea865574b7
@ -1,7 +0,0 @@
|
|||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
AdminJS.UserComponents = {};
|
|
||||||
|
|
||||||
})();
|
|
||||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYnVuZGxlLmpzIiwic291cmNlcyI6WyJlbnRyeS5qcyJdLCJzb3VyY2VzQ29udGVudCI6WyJBZG1pbkpTLlVzZXJDb21wb25lbnRzID0ge31cbiJdLCJuYW1lcyI6WyJBZG1pbkpTIiwiVXNlckNvbXBvbmVudHMiXSwibWFwcGluZ3MiOiI7OztDQUFBQSxPQUFPLENBQUNDLGNBQWMsR0FBRyxFQUFFOzs7Ozs7In0=
|
|
||||||
@ -1 +0,0 @@
|
|||||||
AdminJS.UserComponents = {}
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"_variables": {
|
"_variables": {
|
||||||
"lastUpdateCheck": 1773267396416
|
"lastUpdateCheck": 1774871601244
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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`.
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
.env
|
|
||||||
.git
|
|
||||||
.astro
|
|
||||||
*.md
|
|
||||||
33
.env.example
33
.env.example
@ -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
59
.gitignore
vendored
@ -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
253
CLAUDE.md
@ -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
202
DEPLOY.md
@ -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/
|
|
||||||
```
|
|
||||||
22
Dockerfile
22
Dockerfile
@ -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"]
|
|
||||||
15
README.md
15
README.md
@ -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
307
admin.mjs
@ -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
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
72
bun.lock
72
bun.lock
@ -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
65
clean-duplicates.mjs
Normal 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)
|
||||||
|
})
|
||||||
24
deploy.sh
24
deploy.sh
@ -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"
|
|
||||||
@ -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
|
|
||||||
@ -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"
|
|
||||||
@ -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
|
|
||||||
@ -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:
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
FROM nginx:alpine
|
|
||||||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
EXPOSE 80
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
30
nginx.conf
30
nginx.conf
@ -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";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
62
package.json
62
package.json
@ -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
6580
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1 +0,0 @@
|
|||||||
approveBuildsForScope: '@prisma/engines bcrypt prisma esbuild sharp'
|
|
||||||
@ -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");
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "Product" ADD COLUMN "imageKey" TEXT;
|
|
||||||
ALTER TABLE "Product" ADD COLUMN "imageMime" TEXT;
|
|
||||||
@ -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.
@ -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())
|
|
||||||
}
|
|
||||||
@ -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 Poids: 3.2kg Alimentation: 220V — E27 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 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>
|
|
||||||
</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>
|
|
||||||
/
|
|
||||||
<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>
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
User-agent: *
|
|
||||||
Allow: /
|
|
||||||
Disallow: /success
|
|
||||||
|
|
||||||
Sitemap: https://rebours.studio/sitemap.xml
|
|
||||||
@ -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>
|
|
||||||
@ -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
2
sanity/.env.example
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
SANITY_STUDIO_PROJECT_ID=your_project_id
|
||||||
|
SANITY_STUDIO_DATASET=production
|
||||||
11
sanity/.sanity/runtime/app.js
Normal file
11
sanity/.sanity/runtime/app.js
Normal 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: "/"}
|
||||||
|
)
|
||||||
252
sanity/.sanity/runtime/index.html
Normal file
252
sanity/.sanity/runtime/index.html
Normal 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
21
sanity/package.json
Normal 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
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
9
sanity/sanity.cli.ts
Normal 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
14
sanity/sanity.config.ts
Normal 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
3
sanity/schemas/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import product from './product'
|
||||||
|
|
||||||
|
export const schemaTypes = [product]
|
||||||
180
sanity/schemas/product.ts
Normal file
180
sanity/schemas/product.ts
Normal 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
18
sanity/tsconfig.json
Normal 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": ["."]
|
||||||
|
}
|
||||||
@ -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)
|
|
||||||
})
|
|
||||||
@ -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())
|
|
||||||
@ -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: 35–65cm (7 tailles)\nDôme: Ø15–28cm\nAnneau néon: 8W — 3000K\nÉdition: 7 ex. par coloris',
|
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.',
|
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({
|
|
||||||
where: { email },
|
|
||||||
update: { passwordHash },
|
|
||||||
create: { email, passwordHash },
|
|
||||||
})
|
|
||||||
console.log(`✓ Admin user: ${email}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
console.log('✅ Seed complete!')
|
||||||
.catch((e) => {
|
}
|
||||||
console.error(e)
|
|
||||||
|
seed().catch((err) => {
|
||||||
|
console.error('❌ Seed failed:', err.message)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
})
|
})
|
||||||
.finally(() => prisma.$disconnect())
|
|
||||||
128
server.mjs
128
server.mjs
@ -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 ────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -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
53
src/lib/sanity.mjs
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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
11
src/pages/robots.txt.ts
Normal 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
34
src/pages/sitemap.xml.ts
Normal 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' },
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -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>
|
||||||
/
|
/
|
||||||
<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');
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "astro/tsconfigs/strict",
|
|
||||||
"include": [".astro/types.d.ts", "**/*"],
|
|
||||||
"exclude": ["dist", "node_modules"]
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user