chore: remove legacy Astro + Sanity + Fastify stack
All checks were successful
Build & Deploy to K3s / build-and-deploy (push) Successful in 34s
The site has fully migrated to Next.js 15 + Payload CMS 3 + Postgres (under ./nextjs). Delete the old root-level Astro app, Sanity Studio, Fastify server and their Docker/nginx plumbing. CI already builds nextjs/Dockerfile and deploys a single rebours-web pod; drop the legacy-pod teardown step now that the old workloads are long gone. Removed: - src/, public/, sanity/ (Astro pages/layouts/lib, Sanity studio) - server.mjs, astro.config.mjs (Fastify API, Astro config) - Dockerfile.ssr, Dockerfile.api, nginx.conf (old 3-pod topology) - package.json, pnpm-lock.yaml (root, replaced by nextjs/) - seed-sanity*.mjs, migrate-images.mjs, clean-duplicates.mjs - .env.example, .dockerignore (root, superseded by nextjs/) - .astro/ build artifacts Updated: - CLAUDE.md rewritten for the Next.js/Payload/Postgres stack - .gitignore trimmed (no more Astro/Sanity entries) - .gitea/workflows/deploy.yml: drop "Tear down legacy workloads" Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@ -1 +0,0 @@
|
|||||||
export default new Map();
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export default new Map();
|
|
||||||
199
.astro/content.d.ts
vendored
@ -1,199 +0,0 @@
|
|||||||
declare module 'astro:content' {
|
|
||||||
export interface RenderResult {
|
|
||||||
Content: import('astro/runtime/server/index.js').AstroComponentFactory;
|
|
||||||
headings: import('astro').MarkdownHeading[];
|
|
||||||
remarkPluginFrontmatter: Record<string, any>;
|
|
||||||
}
|
|
||||||
interface Render {
|
|
||||||
'.md': Promise<RenderResult>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RenderedContent {
|
|
||||||
html: string;
|
|
||||||
metadata?: {
|
|
||||||
imagePaths: Array<string>;
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module 'astro:content' {
|
|
||||||
type Flatten<T> = T extends { [K: string]: infer U } ? U : never;
|
|
||||||
|
|
||||||
export type CollectionKey = keyof AnyEntryMap;
|
|
||||||
export type CollectionEntry<C extends CollectionKey> = Flatten<AnyEntryMap[C]>;
|
|
||||||
|
|
||||||
export type ContentCollectionKey = keyof ContentEntryMap;
|
|
||||||
export type DataCollectionKey = keyof DataEntryMap;
|
|
||||||
|
|
||||||
type AllValuesOf<T> = T extends any ? T[keyof T] : never;
|
|
||||||
type ValidContentEntrySlug<C extends keyof ContentEntryMap> = AllValuesOf<
|
|
||||||
ContentEntryMap[C]
|
|
||||||
>['slug'];
|
|
||||||
|
|
||||||
export type ReferenceDataEntry<
|
|
||||||
C extends CollectionKey,
|
|
||||||
E extends keyof DataEntryMap[C] = string,
|
|
||||||
> = {
|
|
||||||
collection: C;
|
|
||||||
id: E;
|
|
||||||
};
|
|
||||||
export type ReferenceContentEntry<
|
|
||||||
C extends keyof ContentEntryMap,
|
|
||||||
E extends ValidContentEntrySlug<C> | (string & {}) = string,
|
|
||||||
> = {
|
|
||||||
collection: C;
|
|
||||||
slug: E;
|
|
||||||
};
|
|
||||||
export type ReferenceLiveEntry<C extends keyof LiveContentConfig['collections']> = {
|
|
||||||
collection: C;
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** @deprecated Use `getEntry` instead. */
|
|
||||||
export function getEntryBySlug<
|
|
||||||
C extends keyof ContentEntryMap,
|
|
||||||
E extends ValidContentEntrySlug<C> | (string & {}),
|
|
||||||
>(
|
|
||||||
collection: C,
|
|
||||||
// Note that this has to accept a regular string too, for SSR
|
|
||||||
entrySlug: E,
|
|
||||||
): E extends ValidContentEntrySlug<C>
|
|
||||||
? Promise<CollectionEntry<C>>
|
|
||||||
: Promise<CollectionEntry<C> | undefined>;
|
|
||||||
|
|
||||||
/** @deprecated Use `getEntry` instead. */
|
|
||||||
export function getDataEntryById<C extends keyof DataEntryMap, E extends keyof DataEntryMap[C]>(
|
|
||||||
collection: C,
|
|
||||||
entryId: E,
|
|
||||||
): Promise<CollectionEntry<C>>;
|
|
||||||
|
|
||||||
export function getCollection<C extends keyof AnyEntryMap, E extends CollectionEntry<C>>(
|
|
||||||
collection: C,
|
|
||||||
filter?: (entry: CollectionEntry<C>) => entry is E,
|
|
||||||
): Promise<E[]>;
|
|
||||||
export function getCollection<C extends keyof AnyEntryMap>(
|
|
||||||
collection: C,
|
|
||||||
filter?: (entry: CollectionEntry<C>) => unknown,
|
|
||||||
): Promise<CollectionEntry<C>[]>;
|
|
||||||
|
|
||||||
export function getLiveCollection<C extends keyof LiveContentConfig['collections']>(
|
|
||||||
collection: C,
|
|
||||||
filter?: LiveLoaderCollectionFilterType<C>,
|
|
||||||
): Promise<
|
|
||||||
import('astro').LiveDataCollectionResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>
|
|
||||||
>;
|
|
||||||
|
|
||||||
export function getEntry<
|
|
||||||
C extends keyof ContentEntryMap,
|
|
||||||
E extends ValidContentEntrySlug<C> | (string & {}),
|
|
||||||
>(
|
|
||||||
entry: ReferenceContentEntry<C, E>,
|
|
||||||
): E extends ValidContentEntrySlug<C>
|
|
||||||
? Promise<CollectionEntry<C>>
|
|
||||||
: Promise<CollectionEntry<C> | undefined>;
|
|
||||||
export function getEntry<
|
|
||||||
C extends keyof DataEntryMap,
|
|
||||||
E extends keyof DataEntryMap[C] | (string & {}),
|
|
||||||
>(
|
|
||||||
entry: ReferenceDataEntry<C, E>,
|
|
||||||
): E extends keyof DataEntryMap[C]
|
|
||||||
? Promise<DataEntryMap[C][E]>
|
|
||||||
: Promise<CollectionEntry<C> | undefined>;
|
|
||||||
export function getEntry<
|
|
||||||
C extends keyof ContentEntryMap,
|
|
||||||
E extends ValidContentEntrySlug<C> | (string & {}),
|
|
||||||
>(
|
|
||||||
collection: C,
|
|
||||||
slug: E,
|
|
||||||
): E extends ValidContentEntrySlug<C>
|
|
||||||
? Promise<CollectionEntry<C>>
|
|
||||||
: Promise<CollectionEntry<C> | undefined>;
|
|
||||||
export function getEntry<
|
|
||||||
C extends keyof DataEntryMap,
|
|
||||||
E extends keyof DataEntryMap[C] | (string & {}),
|
|
||||||
>(
|
|
||||||
collection: C,
|
|
||||||
id: E,
|
|
||||||
): E extends keyof DataEntryMap[C]
|
|
||||||
? string extends keyof DataEntryMap[C]
|
|
||||||
? Promise<DataEntryMap[C][E]> | undefined
|
|
||||||
: Promise<DataEntryMap[C][E]>
|
|
||||||
: Promise<CollectionEntry<C> | undefined>;
|
|
||||||
export function getLiveEntry<C extends keyof LiveContentConfig['collections']>(
|
|
||||||
collection: C,
|
|
||||||
filter: string | LiveLoaderEntryFilterType<C>,
|
|
||||||
): Promise<import('astro').LiveDataEntryResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>>;
|
|
||||||
|
|
||||||
/** Resolve an array of entry references from the same collection */
|
|
||||||
export function getEntries<C extends keyof ContentEntryMap>(
|
|
||||||
entries: ReferenceContentEntry<C, ValidContentEntrySlug<C>>[],
|
|
||||||
): Promise<CollectionEntry<C>[]>;
|
|
||||||
export function getEntries<C extends keyof DataEntryMap>(
|
|
||||||
entries: ReferenceDataEntry<C, keyof DataEntryMap[C]>[],
|
|
||||||
): Promise<CollectionEntry<C>[]>;
|
|
||||||
|
|
||||||
export function render<C extends keyof AnyEntryMap>(
|
|
||||||
entry: AnyEntryMap[C][string],
|
|
||||||
): Promise<RenderResult>;
|
|
||||||
|
|
||||||
export function reference<C extends keyof AnyEntryMap>(
|
|
||||||
collection: C,
|
|
||||||
): import('astro/zod').ZodEffects<
|
|
||||||
import('astro/zod').ZodString,
|
|
||||||
C extends keyof ContentEntryMap
|
|
||||||
? ReferenceContentEntry<C, ValidContentEntrySlug<C>>
|
|
||||||
: ReferenceDataEntry<C, keyof DataEntryMap[C]>
|
|
||||||
>;
|
|
||||||
// Allow generic `string` to avoid excessive type errors in the config
|
|
||||||
// if `dev` is not running to update as you edit.
|
|
||||||
// Invalid collection names will be caught at build time.
|
|
||||||
export function reference<C extends string>(
|
|
||||||
collection: C,
|
|
||||||
): import('astro/zod').ZodEffects<import('astro/zod').ZodString, never>;
|
|
||||||
|
|
||||||
type ReturnTypeOrOriginal<T> = T extends (...args: any[]) => infer R ? R : T;
|
|
||||||
type InferEntrySchema<C extends keyof AnyEntryMap> = import('astro/zod').infer<
|
|
||||||
ReturnTypeOrOriginal<Required<ContentConfig['collections'][C]>['schema']>
|
|
||||||
>;
|
|
||||||
|
|
||||||
type ContentEntryMap = {
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
type DataEntryMap = {
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
type AnyEntryMap = ContentEntryMap & DataEntryMap;
|
|
||||||
|
|
||||||
type ExtractLoaderTypes<T> = T extends import('astro/loaders').LiveLoader<
|
|
||||||
infer TData,
|
|
||||||
infer TEntryFilter,
|
|
||||||
infer TCollectionFilter,
|
|
||||||
infer TError
|
|
||||||
>
|
|
||||||
? { data: TData; entryFilter: TEntryFilter; collectionFilter: TCollectionFilter; error: TError }
|
|
||||||
: { data: never; entryFilter: never; collectionFilter: never; error: never };
|
|
||||||
type ExtractDataType<T> = ExtractLoaderTypes<T>['data'];
|
|
||||||
type ExtractEntryFilterType<T> = ExtractLoaderTypes<T>['entryFilter'];
|
|
||||||
type ExtractCollectionFilterType<T> = ExtractLoaderTypes<T>['collectionFilter'];
|
|
||||||
type ExtractErrorType<T> = ExtractLoaderTypes<T>['error'];
|
|
||||||
|
|
||||||
type LiveLoaderDataType<C extends keyof LiveContentConfig['collections']> =
|
|
||||||
LiveContentConfig['collections'][C]['schema'] extends undefined
|
|
||||||
? ExtractDataType<LiveContentConfig['collections'][C]['loader']>
|
|
||||||
: import('astro/zod').infer<
|
|
||||||
Exclude<LiveContentConfig['collections'][C]['schema'], undefined>
|
|
||||||
>;
|
|
||||||
type LiveLoaderEntryFilterType<C extends keyof LiveContentConfig['collections']> =
|
|
||||||
ExtractEntryFilterType<LiveContentConfig['collections'][C]['loader']>;
|
|
||||||
type LiveLoaderCollectionFilterType<C extends keyof LiveContentConfig['collections']> =
|
|
||||||
ExtractCollectionFilterType<LiveContentConfig['collections'][C]['loader']>;
|
|
||||||
type LiveLoaderErrorType<C extends keyof LiveContentConfig['collections']> = ExtractErrorType<
|
|
||||||
LiveContentConfig['collections'][C]['loader']
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type ContentConfig = typeof import("../src/content.config.mjs");
|
|
||||||
export type LiveContentConfig = never;
|
|
||||||
}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
[["Map",1,2],"meta::meta",["Map",3,4,5,6],"astro-version","5.18.1","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"server\",\"scopedStyleStrategy\":\"attribute\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":false,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":false,\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\",\"entrypoint\":\"astro/assets/endpoint/dev\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[],\"responsiveStyles\":false},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true,\"allowedDomains\":[],\"actionBodySizeLimit\":1048576},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false,\"liveContentCollections\":false,\"csp\":false,\"staticImportMetaEnv\":false,\"chromeDevtoolsWorkspace\":false,\"failOnPrerenderConflict\":false,\"svgo\":false},\"legacy\":{\"collections\":false},\"session\":{\"driver\":\"fs-lite\",\"options\":{\"base\":\"/Users/arthurbarre/dev/freelance/rebours/node_modules/.astro/sessions\"}}}"]
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"_variables": {
|
|
||||||
"lastUpdateCheck": 1774871601244
|
|
||||||
}
|
|
||||||
}
|
|
||||||
2
.astro/types.d.ts
vendored
@ -1,2 +0,0 @@
|
|||||||
/// <reference types="astro/client" />
|
|
||||||
/// <reference path="content.d.ts" />
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
*.md
|
|
||||||
.vscode
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
|
||||||
.astro
|
|
||||||
sanity
|
|
||||||
k8s
|
|
||||||
.gitea
|
|
||||||
migrate-images.mjs
|
|
||||||
seed-sanity.mjs
|
|
||||||
seed-sanity-homepage.mjs
|
|
||||||
clean-duplicates.mjs
|
|
||||||
12
.env.example
@ -1,12 +0,0 @@
|
|||||||
# ── Sanity ─────────────────────────────────────────────────────────────────────
|
|
||||||
SANITY_PROJECT_ID=your_project_id
|
|
||||||
SANITY_DATASET=production
|
|
||||||
SANITY_API_TOKEN= # Optional: for authenticated server-side reads
|
|
||||||
|
|
||||||
# ── Stripe ────────────────────────────────────────────────────────────────────
|
|
||||||
STRIPE_SECRET_KEY=sk_test_...
|
|
||||||
STRIPE_WEBHOOK_SECRET=whsec_... # stripe listen --print-secret
|
|
||||||
|
|
||||||
# ── App ───────────────────────────────────────────────────────────────────────
|
|
||||||
DOMAIN=http://localhost:4321 # Dev: http://localhost:4321 | Prod: https://rebours.studio
|
|
||||||
PORT=8888 # Dev: 8888 | Prod: 3000
|
|
||||||
@ -49,13 +49,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
kubectl apply -f k8s/namespace.yml
|
kubectl apply -f k8s/namespace.yml
|
||||||
|
|
||||||
- name: Tear down legacy workloads
|
|
||||||
run: |
|
|
||||||
# Old split-service topology (Astro SSR + Fastify API + nginx proxy).
|
|
||||||
# Must run before applying new service.yml — old rebours-proxy used NodePort 30083.
|
|
||||||
kubectl -n rebours delete deployment rebours-ssr rebours-api rebours-proxy --ignore-not-found
|
|
||||||
kubectl -n rebours delete service rebours-ssr rebours-api rebours-proxy --ignore-not-found
|
|
||||||
|
|
||||||
- name: Apply configmap + service
|
- name: Apply configmap + service
|
||||||
run: |
|
run: |
|
||||||
kubectl apply -f k8s/configmap.yml
|
kubectl apply -f k8s/configmap.yml
|
||||||
|
|||||||
9
.gitignore
vendored
@ -1,17 +1,12 @@
|
|||||||
# dependencies
|
# dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
# output
|
|
||||||
dist/
|
|
||||||
out/
|
|
||||||
|
|
||||||
# env
|
# env
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
# caches
|
# caches
|
||||||
.astro/
|
|
||||||
.cache/
|
.cache/
|
||||||
.eslintcache
|
.eslintcache
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
@ -21,7 +16,3 @@ out/
|
|||||||
|
|
||||||
# logs
|
# logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# Sanity Studio
|
|
||||||
sanity/node_modules/
|
|
||||||
sanity/dist/
|
|
||||||
|
|||||||
222
CLAUDE.md
@ -4,46 +4,38 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
rebours/
|
rebours/
|
||||||
├── src/
|
├── nextjs/ # App live (Next.js 15 + Payload CMS 3 + Postgres)
|
||||||
│ ├── layouts/
|
│ ├── src/
|
||||||
│ │ └── Base.astro # Layout HTML commun (SEO, fonts, CSS)
|
│ │ ├── app/
|
||||||
│ ├── lib/
|
│ │ │ ├── (frontend)/ # Site public (/, /collection/[slug], /success)
|
||||||
│ │ └── sanity.mjs # Client Sanity + queries + image helper
|
│ │ │ └── (payload)/admin/ # Back-office Payload (/admin)
|
||||||
│ ├── pages/
|
│ │ ├── collections/ # Schémas Payload (Products, Media, Users)
|
||||||
│ │ ├── index.astro # Page principale (hero, collection, newsletter)
|
│ │ ├── components/admin/ # Custom admin UI (visual product editor)
|
||||||
│ │ ├── collection/
|
│ │ ├── lib/payload.ts # Helpers Payload (mediaUrl, mediaAlt)
|
||||||
│ │ │ └── [slug].astro # Pages produits statiques (SSG)
|
│ │ ├── scripts/main.js # JS client (cursor CAD, grid, panel, carousel)
|
||||||
│ │ ├── success.astro # Page de confirmation Stripe
|
│ │ ├── payload.config.ts # Config Payload (collections, plugins, drafts)
|
||||||
│ │ ├── robots.txt.ts # robots.txt généré au build
|
│ │ └── migrations/ # Migrations Postgres (auto-générées)
|
||||||
│ │ └── sitemap.xml.ts # sitemap.xml généré au build
|
│ ├── public/
|
||||||
│ └── scripts/
|
│ │ ├── style.css # CSS global du site public
|
||||||
│ └── main.js # JS client (cursor CAD, grid, panel, routing, checkout)
|
│ │ └── assets/ # Favicons, son ambiant, fallback images
|
||||||
├── public/
|
│ ├── Dockerfile # Image Docker unique (build + runtime)
|
||||||
│ ├── style.css # CSS global
|
│ ├── next.config.mjs
|
||||||
│ └── assets/ # Images statiques (fallback), favicon, son ambiant
|
│ └── package.json
|
||||||
├── sanity/ # Sanity Studio (projet séparé)
|
├── k8s/ # Manifests K3s (namespace, configmap, service, deployment, postgres)
|
||||||
│ ├── sanity.config.ts
|
├── .gitea/workflows/deploy.yml # CI/CD : build Docker + déploiement K3s
|
||||||
│ ├── sanity.cli.ts
|
└── CLAUDE.md
|
||||||
│ └── schemas/
|
|
||||||
│ ├── product.ts # Schéma produit
|
|
||||||
│ └── index.ts
|
|
||||||
├── server.mjs # Serveur API Fastify (Stripe uniquement)
|
|
||||||
├── astro.config.mjs # Config Astro (SSG, proxy dev)
|
|
||||||
├── nginx.conf # Config nginx de référence
|
|
||||||
└── .env # Variables d'environnement (non versionné)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
| Couche | Techno |
|
| Couche | Techno |
|
||||||
|--------|--------|
|
|--------|--------|
|
||||||
| Front (SSR) | Astro + HTML/CSS/JS vanilla + GSAP |
|
| Front + Back | Next.js 15 (App Router, SSR) |
|
||||||
| CMS | Sanity (headless, hébergé) |
|
| CMS | Payload 3 (self-hosted, monté sur `/admin`) |
|
||||||
| API | Fastify (Node.js) |
|
| Base de données | PostgreSQL (StatefulSet K3s) |
|
||||||
| Paiement | Stripe Checkout (price_data inline) |
|
| Paiement | Stripe Checkout (`price_data` inline) + plugin-stripe |
|
||||||
| Images | Sanity CDN (avec transformations) |
|
| Images | Payload Media → stockage local + `sharp` |
|
||||||
| Reverse proxy | Nginx |
|
| Hébergement | K3s sur Proxmox (Traefik en front) |
|
||||||
| Hébergement | VPS (Debian) |
|
|
||||||
| Fonts | Space Mono (Google Fonts) |
|
| Fonts | Space Mono (Google Fonts) |
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -51,144 +43,121 @@ rebours/
|
|||||||
## Développement local
|
## Développement local
|
||||||
|
|
||||||
### Prérequis
|
### Prérequis
|
||||||
- Node.js >= 18
|
- Node.js >= 20
|
||||||
- Un compte Sanity avec un projet créé
|
- pnpm
|
||||||
- Un fichier `.env` à la racine (voir `.env.example`)
|
- Docker (pour Postgres local) ou une instance Postgres accessible
|
||||||
|
- Un fichier `nextjs/.env` (voir `nextjs/.env.example`)
|
||||||
|
|
||||||
### Variables d'environnement (.env)
|
### Variables d'environnement (`nextjs/.env`)
|
||||||
```env
|
```env
|
||||||
SANITY_PROJECT_ID=your_project_id
|
DATABASE_URI=postgres://rebours:rebours@localhost:5432/rebours
|
||||||
SANITY_DATASET=production
|
PAYLOAD_SECRET=your_32_char_secret
|
||||||
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
|
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
|
||||||
FASTIFY_PORT=3000 # Port Fastify API (prod)
|
|
||||||
ASTRO_PORT=4321 # Port Astro SSR (prod)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Lancer le projet
|
### Lancer le projet
|
||||||
```bash
|
```bash
|
||||||
|
cd nextjs
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Cela lance en parallèle (via `concurrently`) :
|
- Site public : http://localhost:3000
|
||||||
- `astro dev` sur http://localhost:4321
|
- Admin Payload : http://localhost:3000/admin
|
||||||
- `node --watch server.mjs` (mode dev)
|
|
||||||
|
|
||||||
Le proxy Vite dans `astro.config.mjs` redirige `/api/*` vers le serveur Fastify.
|
|
||||||
|
|
||||||
### Sanity Studio
|
|
||||||
```bash
|
|
||||||
cd sanity
|
|
||||||
npm install
|
|
||||||
npx sanity dev
|
|
||||||
```
|
|
||||||
Accessible sur http://localhost:3333
|
|
||||||
|
|
||||||
### Build
|
### Build
|
||||||
```bash
|
```bash
|
||||||
pnpm build
|
cd nextjs && pnpm build
|
||||||
# Génère ./dist/ (serveur Astro SSR + assets client)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CMS — Sanity
|
## CMS — Payload
|
||||||
|
|
||||||
### Accès
|
### Accès
|
||||||
- Studio local : `npx sanity dev`
|
- Admin : https://rebours.studio/admin (prod) / http://localhost:3000/admin (dev)
|
||||||
- Studio déployé : `npx sanity deploy`
|
- Premier démarrage : la page `/admin/create-first-user` demande la création du premier user.
|
||||||
- Dashboard : https://www.sanity.io/manage
|
|
||||||
|
|
||||||
### Schéma Produit
|
### Collection `products`
|
||||||
Champs principaux :
|
Éditeur visuel sur-mesure (`src/components/admin/ProductPreviewEditor.tsx`) : la page d'édition reproduit exactement le panneau produit public. Tous les champs éditables au clic (texte inline, carousel d'images, prix). Drawer « Réglages avancés » en bas pour les champs techniques (slug, SEO, Stripe ID).
|
||||||
- **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
|
Champs principaux : `productDisplayName`, `name`, `slug`, `index`, `type`, `materials`, `year`, `status`, `description`, `specs`, `notes`, `images[]` (array d'uploads), `price` (centimes), `currency`, `availability`, `isPublished`, `seoTitle`, `seoDescription`.
|
||||||
1. Ouvrir Sanity Studio
|
|
||||||
2. Créer un nouveau document "Produit"
|
|
||||||
3. Remplir les champs, uploader l'image
|
|
||||||
4. Publier → visible immédiatement sur le site (SSR, pas de rebuild nécessaire)
|
|
||||||
|
|
||||||
### Images
|
Autosave activé (interval 800 ms) via `versions.drafts.autosave`.
|
||||||
Les images sont servies via le CDN Sanity avec transformations automatiques.
|
|
||||||
|
### Sync Stripe
|
||||||
|
Le plugin `@payloadcms/plugin-stripe` synchronise automatiquement les produits et prix avec Stripe dès que `STRIPE_SECRET_KEY` est valide. Un hook `beforeValidate` court-circuite la sync tant que `productDisplayName` est vide (évite l'erreur Stripe sur autosave de doc neuf).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Stripe
|
## Stripe
|
||||||
|
|
||||||
### Architecture
|
### Checkout
|
||||||
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`
|
1. Le front envoie le slug du produit à `/api/checkout`
|
||||||
2. Le serveur fetch le produit depuis Sanity (prix, nom, image)
|
2. Le serveur fetch le produit via Payload Local API
|
||||||
3. Le serveur crée une session Stripe Checkout avec `price_data`
|
3. Création d'une session Stripe avec `price_data` inline
|
||||||
4. Le client est redirigé vers Stripe
|
4. Redirect vers Stripe
|
||||||
5. Après paiement : `/success?session_id=...`
|
5. Retour : `/success?session_id=...`
|
||||||
|
|
||||||
### Endpoints API
|
### Endpoints
|
||||||
| Route | Méthode | Description |
|
| Route | Méthode | Description |
|
||||||
|-------|---------|-------------|
|
|-------|---------|-------------|
|
||||||
| `/api/checkout` | POST | Crée une session Stripe Checkout |
|
| `/api/checkout` | POST | Crée une session Stripe Checkout |
|
||||||
| `/api/session/:id` | GET | Vérifie le statut d'une session |
|
| `/api/session/[id]` | GET | Statut d'une session |
|
||||||
| `/api/webhook` | POST | Reçoit les événements Stripe |
|
| `/api/stripe/webhook` | POST | Webhook Stripe (plugin-stripe) |
|
||||||
| `/api/health` | GET | Health check |
|
|
||||||
|
|
||||||
### Configuration
|
Webhook prod : `https://rebours.studio/api/stripe/webhook`
|
||||||
- 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
|
### Architecture K3s
|
||||||
|
|
||||||
### Architecture prod (SSR)
|
|
||||||
```
|
```
|
||||||
Internet -> Nginx (port 80) -> / -> proxy -> Astro SSR :4321
|
Internet :443 → Traefik (10.10.10.2) → Service rebours-web → Pod rebours-web :3000
|
||||||
-> /_astro/* -> fichiers statiques (dist/client/)
|
↘ Service rebours-postgres :5432
|
||||||
-> /api/* -> proxy -> Fastify :3000
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Services systemd
|
### Pods (namespace `rebours`)
|
||||||
| Service | Port | Rôle |
|
| Workload | Port | Rôle |
|
||||||
|---------|------|------|
|
|----------|------|------|
|
||||||
| `rebours-ssr` | 4321 | Astro SSR (pages dynamiques) |
|
| `rebours-web` (Deployment) | 3000 | Next.js + Payload (tout-en-un) |
|
||||||
| `rebours` | 3000 | Fastify API (Stripe, checkout) |
|
| `rebours-postgres` (StatefulSet) | 5432 | Postgres + PVC |
|
||||||
|
|
||||||
### Variables d'environnement en prod
|
### Variables d'environnement en prod
|
||||||
```env
|
- **ConfigMap** (`k8s/configmap.yml`) : `NEXT_PUBLIC_SERVER_URL`, `DATABASE_URI`
|
||||||
SANITY_PROJECT_ID=...
|
- **Secret `rebours-db-secret`** : `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD`
|
||||||
SANITY_DATASET=production
|
- **Secret `rebours-app-secret`** : `PAYLOAD_SECRET`, `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`
|
||||||
STRIPE_SECRET_KEY=sk_live_...
|
- **Secret `gitea-registry-secret`** : pull de l'image depuis le registre Gitea
|
||||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
|
||||||
DOMAIN=https://rebours.studio
|
Les secrets d'app sont créés par la CI depuis les Gitea Actions secrets.
|
||||||
FASTIFY_PORT=3000
|
|
||||||
ASTRO_PORT=4321
|
|
||||||
```
|
|
||||||
|
|
||||||
### Déploiement
|
### Déploiement
|
||||||
|
Push sur `main` → Gitea Actions build l'image Docker puis applique les manifests K3s.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm build
|
git push gitea main
|
||||||
scp -r dist/* ordinarthur@10.10.0.13:/tmp/rebours-dist/
|
|
||||||
ssh ordinarthur@10.10.0.13 "sudo rm -rf /var/www/html/rebours/dist && sudo mkdir -p /var/www/html/rebours/dist && sudo cp -r /tmp/rebours-dist/* /var/www/html/rebours/dist/ && sudo chown -R ordinarthur:ordinarthur /var/www/html/rebours/dist && sudo systemctl restart rebours-ssr"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Si server.mjs a changé :
|
Repo Gitea : `https://git.arthurbarre.fr/ordinarthur/rebours`
|
||||||
|
|
||||||
|
### Debugging prod
|
||||||
```bash
|
```bash
|
||||||
scp server.mjs ordinarthur@10.10.0.13:/tmp/server.mjs
|
# Pods
|
||||||
ssh ordinarthur@10.10.0.13 "sudo cp /tmp/server.mjs /var/www/html/rebours/server.mjs && sudo systemctl restart rebours"
|
ssh arthur@100.78.207.119 "sudo kubectl -n rebours get pods"
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
ssh arthur@100.78.207.119 "sudo kubectl -n rebours logs deployment/rebours-web --tail=50"
|
||||||
|
|
||||||
|
# Redémarrer
|
||||||
|
ssh arthur@100.78.207.119 "sudo kubectl -n rebours rollout restart deployment/rebours-web"
|
||||||
|
|
||||||
|
# Shell Postgres
|
||||||
|
ssh arthur@100.78.207.119 "sudo kubectl -n rebours exec -it rebours-postgres-0 -- psql -U rebours"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -197,16 +166,15 @@ ssh ordinarthur@10.10.0.13 "sudo cp /tmp/server.mjs /var/www/html/rebours/server
|
|||||||
|
|
||||||
| URL | Comportement |
|
| URL | Comportement |
|
||||||
|-----|-------------|
|
|-----|-------------|
|
||||||
| `/` | Page principale Astro (SSR, données Sanity live) |
|
| `/` | Page principale (SSR, Payload Local API) |
|
||||||
| `/collection/{slug}` | Page produit (SSR), auto-open panel via `window.__OPEN_PANEL__` |
|
| `/collection/[slug]` | Page produit (SSR), auto-open panel via `window.__OPEN_PANEL__` |
|
||||||
| `/success?session_id=...` | Page de confirmation Stripe |
|
| `/success?session_id=...` | Confirmation Stripe |
|
||||||
| `/robots.txt` | Généré au build |
|
| `/admin` | Back-office Payload |
|
||||||
| `/sitemap.xml` | Généré au build depuis Sanity |
|
| `/api/*` | Routes Payload (REST + GraphQL) + endpoints custom (checkout, webhook) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Fichiers à ne jamais versionner
|
## Fichiers à ne jamais versionner
|
||||||
- `.env`
|
- `nextjs/.env`
|
||||||
- `node_modules/`
|
- `node_modules/`
|
||||||
- `dist/`
|
- `nextjs/.next/`
|
||||||
- `sanity/node_modules/`
|
|
||||||
|
|||||||
@ -1,15 +0,0 @@
|
|||||||
# Fastify API — no build step, runs server.mjs directly
|
|
||||||
FROM node:22-alpine
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml ./
|
|
||||||
RUN pnpm install --frozen-lockfile --prod
|
|
||||||
|
|
||||||
COPY server.mjs ./
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
CMD ["node", "server.mjs"]
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
# --- Stage 1: Build Astro SSR ---
|
|
||||||
FROM node:22-alpine AS build
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml ./
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
RUN pnpm build
|
|
||||||
|
|
||||||
# --- Stage 2: Runtime ---
|
|
||||||
FROM node:22-alpine AS runtime
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY --from=build /app/dist ./dist
|
|
||||||
COPY --from=build /app/node_modules ./node_modules
|
|
||||||
COPY --from=build /app/package.json ./
|
|
||||||
|
|
||||||
ENV HOST=0.0.0.0
|
|
||||||
ENV PORT=4321
|
|
||||||
EXPOSE 4321
|
|
||||||
|
|
||||||
CMD ["node", "dist/server/entry.mjs"]
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
// @ts-check
|
|
||||||
import { defineConfig } from 'astro/config';
|
|
||||||
import node from '@astrojs/node';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
output: 'server',
|
|
||||||
adapter: node({ mode: 'standalone' }),
|
|
||||||
outDir: './dist',
|
|
||||||
server: { port: 4321 },
|
|
||||||
vite: {
|
|
||||||
server: {
|
|
||||||
proxy: {
|
|
||||||
'/api': 'http://127.0.0.1:8888',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
/**
|
|
||||||
* Migration: product.image (single) → product.images (array)
|
|
||||||
* Run: node migrate-images.mjs
|
|
||||||
*/
|
|
||||||
import 'dotenv/config'
|
|
||||||
import { createClient } from '@sanity/client'
|
|
||||||
|
|
||||||
const sanity = 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,
|
|
||||||
})
|
|
||||||
|
|
||||||
const products = await sanity.fetch('*[_type == "product" && defined(image)]{ _id, image }')
|
|
||||||
|
|
||||||
console.log(`Found ${products.length} products to migrate`)
|
|
||||||
|
|
||||||
for (const p of products) {
|
|
||||||
console.log(`Migrating ${p._id}...`)
|
|
||||||
await sanity
|
|
||||||
.patch(p._id)
|
|
||||||
.set({ images: [p.image] })
|
|
||||||
.unset(['image'])
|
|
||||||
.commit()
|
|
||||||
console.log(` ✓ done`)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Migration complete!')
|
|
||||||
33
nginx.conf
@ -1,33 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name rebours.studio;
|
|
||||||
|
|
||||||
# ── API proxy → Fastify ────────────────<EFBFBD><EFBFBD><EFBFBD>─────────────────────────────────
|
|
||||||
location /api/ {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Static assets from Astro client build ──────────────────<EFBFBD><EFBFBD><EFBFBD>─────────────
|
|
||||||
location /_astro/ {
|
|
||||||
root /var/www/html/rebours/dist/client;
|
|
||||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~* \.(css|js|jpg|jpeg|png|gif|webp|svg|woff2|woff|ttf|ico|mp3)$ {
|
|
||||||
root /var/www/html/rebours/dist/client;
|
|
||||||
add_header Cache-Control "public, max-age=604800";
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── SSR → Astro Node server ─────────────────────<EFBFBD><EFBFBD><EFBFBD>────────────────────────
|
|
||||||
location / {
|
|
||||||
proxy_pass http://127.0.0.1:4321;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
25
package.json
@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "rebours",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "concurrently \"astro dev\" \"NODE_ENV=development node --watch server.mjs\"",
|
|
||||||
"build": "astro build",
|
|
||||||
"preview": "astro preview",
|
|
||||||
"server": "NODE_ENV=production node server.mjs",
|
|
||||||
"start": "NODE_ENV=production node dist/server/entry.mjs",
|
|
||||||
"astro": "astro"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@astrojs/node": "^9.5.5",
|
|
||||||
"@fastify/cors": "^10.0.2",
|
|
||||||
"@sanity/client": "^7",
|
|
||||||
"@sanity/image-url": "^1",
|
|
||||||
"astro": "^5.17.1",
|
|
||||||
"concurrently": "^9.0.0",
|
|
||||||
"dotenv": "^17.3.1",
|
|
||||||
"fastify": "^5.3.2",
|
|
||||||
"gsap": "^3.14.2",
|
|
||||||
"stripe": "^20.3.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
4273
pnpm-lock.yaml
generated
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 544 B |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 16 KiB |
1311
public/style.css
@ -1,2 +0,0 @@
|
|||||||
SANITY_STUDIO_PROJECT_ID=your_project_id
|
|
||||||
SANITY_STUDIO_DATASET=production
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
|
|
||||||
// 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: "/"}
|
|
||||||
)
|
|
||||||
@ -1,252 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"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.99.0",
|
|
||||||
"react": "^19.2.4",
|
|
||||||
"react-dom": "^19.2.4",
|
|
||||||
"sanity": "^3.99.0",
|
|
||||||
"styled-components": "^6"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@sanity/eslint-config-studio": "^4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
11606
sanity/pnpm-lock.yaml
generated
@ -1,9 +0,0 @@
|
|||||||
import { defineCliConfig } from 'sanity/cli'
|
|
||||||
|
|
||||||
export default defineCliConfig({
|
|
||||||
api: {
|
|
||||||
projectId: 'y821x5qu',
|
|
||||||
dataset: 'production',
|
|
||||||
},
|
|
||||||
studioHost: 'rebours',
|
|
||||||
})
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
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({
|
|
||||||
structure: (S) =>
|
|
||||||
S.list()
|
|
||||||
.title('Contenu')
|
|
||||||
.items([
|
|
||||||
S.listItem()
|
|
||||||
.title('Page d\'accueil')
|
|
||||||
.id('homePage')
|
|
||||||
.child(
|
|
||||||
S.document()
|
|
||||||
.schemaType('homePage')
|
|
||||||
.documentId('homePage')
|
|
||||||
.title('Page d\'accueil')
|
|
||||||
),
|
|
||||||
S.divider(),
|
|
||||||
S.documentTypeListItem('product').title('Produits'),
|
|
||||||
]),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
schema: { types: schemaTypes },
|
|
||||||
})
|
|
||||||
@ -1,167 +0,0 @@
|
|||||||
import { defineType, defineField } from 'sanity'
|
|
||||||
|
|
||||||
export default defineType({
|
|
||||||
name: 'homePage',
|
|
||||||
title: 'Page d\'accueil',
|
|
||||||
type: 'document',
|
|
||||||
groups: [
|
|
||||||
{ name: 'hero', title: 'Hero' },
|
|
||||||
{ name: 'collection', title: 'Collection' },
|
|
||||||
{ name: 'contact', title: 'Contact / WhatsApp' },
|
|
||||||
{ name: 'footer', title: 'Footer' },
|
|
||||||
{ name: 'seo', title: 'SEO' },
|
|
||||||
],
|
|
||||||
fields: [
|
|
||||||
// ── HERO ──────────────────────────────────────────────────
|
|
||||||
defineField({
|
|
||||||
name: 'heroLabel',
|
|
||||||
title: 'Label hero',
|
|
||||||
type: 'string',
|
|
||||||
initialValue: '// ARCHIVE_001 — 2026',
|
|
||||||
group: 'hero',
|
|
||||||
}),
|
|
||||||
defineField({
|
|
||||||
name: 'heroTitle',
|
|
||||||
title: 'Titre hero',
|
|
||||||
type: 'string',
|
|
||||||
initialValue: 'REBOURS STUDIO',
|
|
||||||
description: 'Utiliser | pour un retour à la ligne (ex: REBOURS|STUDIO)',
|
|
||||||
group: 'hero',
|
|
||||||
validation: (rule) => rule.required(),
|
|
||||||
}),
|
|
||||||
defineField({
|
|
||||||
name: 'heroSubtitle',
|
|
||||||
title: 'Sous-titre hero',
|
|
||||||
type: 'text',
|
|
||||||
rows: 2,
|
|
||||||
initialValue: 'Mobilier d\'art contemporain.\nSpace Age × Memphis.',
|
|
||||||
group: 'hero',
|
|
||||||
}),
|
|
||||||
defineField({
|
|
||||||
name: 'heroStatus',
|
|
||||||
title: 'Status hero',
|
|
||||||
type: 'text',
|
|
||||||
rows: 2,
|
|
||||||
initialValue: 'STATUS: [PROTOTYPE EN COURS]\nCOLLECTION_001 — BIENTÔT DISPONIBLE',
|
|
||||||
group: 'hero',
|
|
||||||
}),
|
|
||||||
defineField({
|
|
||||||
name: 'heroImage',
|
|
||||||
title: 'Image hero',
|
|
||||||
type: 'image',
|
|
||||||
options: { hotspot: true },
|
|
||||||
description: 'Image principale du hero. Si vide, utilise l\'image du premier produit.',
|
|
||||||
group: 'hero',
|
|
||||||
fields: [
|
|
||||||
defineField({
|
|
||||||
name: 'alt',
|
|
||||||
title: 'Texte alternatif',
|
|
||||||
type: 'string',
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
|
|
||||||
// ── COLLECTION ────────────────────────────────────────────
|
|
||||||
defineField({
|
|
||||||
name: 'collectionLabel',
|
|
||||||
title: 'Label collection',
|
|
||||||
type: 'string',
|
|
||||||
initialValue: '// COLLECTION_001',
|
|
||||||
group: 'collection',
|
|
||||||
}),
|
|
||||||
defineField({
|
|
||||||
name: 'collectionCta',
|
|
||||||
title: 'Texte d\'action collection',
|
|
||||||
type: 'string',
|
|
||||||
initialValue: 'CLIQUER POUR OUVRIR',
|
|
||||||
description: 'Affiché après le nombre d\'objets',
|
|
||||||
group: 'collection',
|
|
||||||
}),
|
|
||||||
|
|
||||||
// ── CONTACT / WHATSAPP ────────────────────────────────────
|
|
||||||
defineField({
|
|
||||||
name: 'contactLabel',
|
|
||||||
title: 'Label contact',
|
|
||||||
type: 'string',
|
|
||||||
initialValue: '// CONTACT',
|
|
||||||
group: 'contact',
|
|
||||||
}),
|
|
||||||
defineField({
|
|
||||||
name: 'contactTitle',
|
|
||||||
title: 'Titre contact',
|
|
||||||
type: 'string',
|
|
||||||
initialValue: 'UNE QUESTION ? PARLONS-EN',
|
|
||||||
description: 'Utiliser | pour un retour à la ligne',
|
|
||||||
group: 'contact',
|
|
||||||
}),
|
|
||||||
defineField({
|
|
||||||
name: 'contactDescription',
|
|
||||||
title: 'Description contact',
|
|
||||||
type: 'text',
|
|
||||||
rows: 2,
|
|
||||||
initialValue: 'Commandes sur mesure, questions techniques,\nou simplement dire bonjour.',
|
|
||||||
group: 'contact',
|
|
||||||
}),
|
|
||||||
defineField({
|
|
||||||
name: 'whatsappNumber',
|
|
||||||
title: 'Numéro WhatsApp',
|
|
||||||
type: 'string',
|
|
||||||
initialValue: '33651755191',
|
|
||||||
description: 'Format international sans + (ex: 33612345678)',
|
|
||||||
group: 'contact',
|
|
||||||
validation: (rule) => rule.required(),
|
|
||||||
}),
|
|
||||||
defineField({
|
|
||||||
name: 'whatsappButtonText',
|
|
||||||
title: 'Texte bouton WhatsApp',
|
|
||||||
type: 'string',
|
|
||||||
initialValue: 'CONTACTEZ-NOUS SUR WHATSAPP',
|
|
||||||
group: 'contact',
|
|
||||||
}),
|
|
||||||
defineField({
|
|
||||||
name: 'contactResponseTime',
|
|
||||||
title: 'Temps de réponse',
|
|
||||||
type: 'string',
|
|
||||||
initialValue: 'RÉPONSE SOUS 24H',
|
|
||||||
group: 'contact',
|
|
||||||
}),
|
|
||||||
|
|
||||||
// ── FOOTER ────────────────────────────────────────────────
|
|
||||||
defineField({
|
|
||||||
name: 'footerText',
|
|
||||||
title: 'Texte footer',
|
|
||||||
type: 'string',
|
|
||||||
initialValue: '© 2026 REBOURS STUDIO — PARIS',
|
|
||||||
group: 'footer',
|
|
||||||
}),
|
|
||||||
defineField({
|
|
||||||
name: 'instagramUrl',
|
|
||||||
title: 'Lien Instagram',
|
|
||||||
type: 'url',
|
|
||||||
initialValue: 'https://instagram.com/rebour.studio',
|
|
||||||
group: 'footer',
|
|
||||||
}),
|
|
||||||
|
|
||||||
// ── SEO ───────────────────────────────────────────────────
|
|
||||||
defineField({
|
|
||||||
name: 'seoTitle',
|
|
||||||
title: 'Titre SEO',
|
|
||||||
type: 'string',
|
|
||||||
initialValue: 'REBOURS — Mobilier d\'art contemporain | Collection 001',
|
|
||||||
group: 'seo',
|
|
||||||
}),
|
|
||||||
defineField({
|
|
||||||
name: 'seoDescription',
|
|
||||||
title: 'Description SEO',
|
|
||||||
type: 'text',
|
|
||||||
rows: 3,
|
|
||||||
initialValue: '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.',
|
|
||||||
group: 'seo',
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
preview: {
|
|
||||||
prepare() {
|
|
||||||
return { title: 'Page d\'accueil' }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import product from './product'
|
|
||||||
import homePage from './homePage'
|
|
||||||
|
|
||||||
export const schemaTypes = [product, homePage]
|
|
||||||
@ -1,186 +0,0 @@
|
|||||||
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: 'images',
|
|
||||||
title: 'Images produit',
|
|
||||||
type: 'array',
|
|
||||||
description: 'La première image est utilisée comme image principale',
|
|
||||||
validation: (rule) => rule.required().min(1),
|
|
||||||
of: [
|
|
||||||
{
|
|
||||||
type: 'image',
|
|
||||||
options: { hotspot: true },
|
|
||||||
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: 'images.0',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"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,54 +0,0 @@
|
|||||||
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,
|
|
||||||
})
|
|
||||||
|
|
||||||
const homePage = {
|
|
||||||
_id: 'homePage',
|
|
||||||
_type: 'homePage',
|
|
||||||
|
|
||||||
// Hero
|
|
||||||
heroLabel: '// ARCHIVE_001 — 2026',
|
|
||||||
heroTitle: 'REBOURS|STUDIO',
|
|
||||||
heroSubtitle: 'Mobilier d\'art contemporain.\nSpace Age × Memphis.',
|
|
||||||
heroStatus: 'STATUS: [PROTOTYPE EN COURS]\nCOLLECTION_001 — BIENTÔT DISPONIBLE',
|
|
||||||
|
|
||||||
// Collection
|
|
||||||
collectionLabel: '// COLLECTION_001',
|
|
||||||
collectionCta: 'CLIQUER POUR OUVRIR',
|
|
||||||
|
|
||||||
// Contact / WhatsApp
|
|
||||||
contactLabel: '// CONTACT',
|
|
||||||
contactTitle: 'UNE QUESTION ?|PARLONS-EN',
|
|
||||||
contactDescription: 'Commandes sur mesure, questions techniques,\nou simplement dire bonjour.',
|
|
||||||
whatsappNumber: '33651755191',
|
|
||||||
whatsappButtonText: 'CONTACTEZ-NOUS SUR WHATSAPP',
|
|
||||||
contactResponseTime: 'RÉPONSE SOUS 24H',
|
|
||||||
|
|
||||||
// Footer
|
|
||||||
footerText: '© 2026 REBOURS STUDIO — PARIS',
|
|
||||||
instagramUrl: 'https://instagram.com/rebour.studio',
|
|
||||||
|
|
||||||
// SEO
|
|
||||||
seoTitle: 'REBOURS — Mobilier d\'art contemporain | Collection 001',
|
|
||||||
seoDescription: '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.',
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seed() {
|
|
||||||
console.log('Creating homePage document...')
|
|
||||||
const result = await client.createOrReplace(homePage)
|
|
||||||
console.log(`✓ homePage created: ${result._id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
seed().catch((err) => {
|
|
||||||
console.error('Error:', err.message)
|
|
||||||
process.exit(1)
|
|
||||||
})
|
|
||||||
128
seed-sanity.mjs
@ -1,128 +0,0 @@
|
|||||||
import { createClient } from '@sanity/client'
|
|
||||||
import { readFileSync } from 'fs'
|
|
||||||
import { resolve } from 'path'
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ── 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 = [
|
|
||||||
{
|
|
||||||
name: 'Solar_Altar',
|
|
||||||
productDisplayName: 'Solar Altar',
|
|
||||||
slug: { _type: 'slug', current: 'solar-altar' },
|
|
||||||
sortOrder: 0,
|
|
||||||
index: 'PROJET_001',
|
|
||||||
type: 'LAMPE DE TABLE',
|
|
||||||
materials: 'BÉTON TEXTURÉ + DÔME CÉRAMIQUE LAQUÉ',
|
|
||||||
year: '2026',
|
|
||||||
status: 'PROTOTYPE [80%]',
|
|
||||||
description: 'Exploration de la lumière à travers des contraintes géométriques. Le dôme sphérique en céramique laquée coiffe un corps en béton texturé peint à la main. Chaque pièce est unique.',
|
|
||||||
specs: 'H: 45cm / Ø: 18cm\nPoids: 3.2kg\nAlimentation: 220V — E27\nCâble: tressé rouge 2m',
|
|
||||||
notes: 'Inspiré des lampadaires soviétiques des années 60. Le béton est coulé à la main dans des moules uniques. La peinture acrylique est appliquée au spalter.',
|
|
||||||
imageFile: 'public/assets/lamp-violet.jpg',
|
|
||||||
imageAlt: 'Solar Altar — Lampe béton violet, dôme céramique bleu, REBOURS 2026',
|
|
||||||
seoTitle: 'REBOURS — Solar Altar | Collection 001',
|
|
||||||
seoDescription: 'Lampe de table unique. Béton texturé coulé à la main + dôme céramique laqué. Pièce unique fabriquée à Paris.',
|
|
||||||
price: 180000,
|
|
||||||
currency: 'EUR',
|
|
||||||
availability: 'https://schema.org/LimitedAvailability',
|
|
||||||
isPublished: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'TABLE_TERRAZZO',
|
|
||||||
productDisplayName: 'Table Terrazzo',
|
|
||||||
slug: { _type: 'slug', current: 'table-terrazzo' },
|
|
||||||
sortOrder: 1,
|
|
||||||
index: 'PROJET_002',
|
|
||||||
type: 'TABLE BASSE + ÉTAGÈRE MODULAIRE',
|
|
||||||
materials: 'TERRAZZO + ACIER TUBULAIRE + RÉSINE',
|
|
||||||
year: '2026',
|
|
||||||
status: 'STRUCTURAL_TEST',
|
|
||||||
description: "Collision du brutalisme et de la couleur Memphis. Le plateau en terrazzo fait à la main intègre des inclusions de marbre rose et bleu. Les colonnes cylindriques bicolores sont en acier peint au four.",
|
|
||||||
specs: 'Table: L120 × P60 × H38cm\nPoids plateau: 28kg\nPieds: acier Ø60mm\nÉtagère: H180 × L80 × P35cm',
|
|
||||||
notes: "Le terrazzo est réalisé dans l'atelier de Pantin. Chaque dalle est unique. L'étagère est assemblée à partir de tubes industriels récupérés et de panneaux laqués.",
|
|
||||||
imageFile: 'public/assets/table-terrazzo.jpg',
|
|
||||||
imageAlt: 'TABLE TERRAZZO — Table basse terrazzo et étagère acier, REBOURS 2026',
|
|
||||||
seoTitle: 'REBOURS — TABLE TERRAZZO | Collection 001',
|
|
||||||
seoDescription: 'Table basse et étagère modulaire. Terrazzo fait main + acier tubulaire. Pièce unique fabriquée à Paris.',
|
|
||||||
price: null,
|
|
||||||
currency: 'EUR',
|
|
||||||
availability: 'https://schema.org/PreOrder',
|
|
||||||
isPublished: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'MODULE_SÉRIE',
|
|
||||||
productDisplayName: 'Module Série',
|
|
||||||
slug: { _type: 'slug', current: 'module-serie' },
|
|
||||||
sortOrder: 2,
|
|
||||||
index: 'PROJET_003',
|
|
||||||
type: 'LAMPES — SÉRIE LIMITÉE',
|
|
||||||
materials: 'BÉTON COLORÉ + DÔME LAQUÉ + NÉON',
|
|
||||||
year: '2026',
|
|
||||||
status: 'FINAL_ASSEMBLY',
|
|
||||||
description: "Série de 7 lampes aux corps béton colorés, chacune avec un dôme d'une couleur différente. Les néons horizontaux créent un anneau lumineux entre le dôme et le corps.",
|
|
||||||
specs: 'H: 35–65cm (7 tailles)\nDôme: Ø15–28cm\nAnneau néon: 8W — 3000K\nÉdition: 7 ex. par coloris',
|
|
||||||
notes: 'Les corps sont coulés en série mais peints individuellement. Les dômes sont réalisés par un souffleur de verre artisanal. Le câble tressé rouge est la signature de la série.',
|
|
||||||
imageFile: 'public/assets/lampes-serie.jpg',
|
|
||||||
imageAlt: 'MODULE SÉRIE — Collection de 7 lampes béton colorées, REBOURS 2026',
|
|
||||||
seoTitle: 'REBOURS — MODULE SÉRIE | Collection 001',
|
|
||||||
seoDescription: 'Série de 7 lampes béton colorées, dôme laqué et néon. Édition limitée fabriquée à Paris.',
|
|
||||||
price: null,
|
|
||||||
currency: 'EUR',
|
|
||||||
availability: 'https://schema.org/PreOrder',
|
|
||||||
isPublished: true,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
// ── Seed ───────────────────────────────────────────────────────────────────
|
|
||||||
async function seed() {
|
|
||||||
console.log('🌱 Seeding Sanity...\n')
|
|
||||||
|
|
||||||
for (const product of PRODUCTS) {
|
|
||||||
const { imageFile, imageAlt, ...data } = product
|
|
||||||
|
|
||||||
// Upload image
|
|
||||||
console.log(`📦 ${product.name}`)
|
|
||||||
const imageAssetId = await uploadImage(imageFile, imageFile.split('/').pop())
|
|
||||||
|
|
||||||
// Create document
|
|
||||||
const doc = {
|
|
||||||
_type: 'product',
|
|
||||||
...data,
|
|
||||||
image: {
|
|
||||||
_type: 'image',
|
|
||||||
alt: imageAlt,
|
|
||||||
asset: { _type: 'reference', _ref: imageAssetId },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await client.create(doc)
|
|
||||||
console.log(` ✓ Created: ${result._id}\n`)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✅ Seed complete!')
|
|
||||||
}
|
|
||||||
|
|
||||||
seed().catch((err) => {
|
|
||||||
console.error('❌ Seed failed:', err.message)
|
|
||||||
process.exit(1)
|
|
||||||
})
|
|
||||||
138
server.mjs
@ -1,138 +0,0 @@
|
|||||||
import Fastify from 'fastify'
|
|
||||||
import cors from '@fastify/cors'
|
|
||||||
import Stripe from 'stripe'
|
|
||||||
import { createClient } from '@sanity/client'
|
|
||||||
import dotenv from 'dotenv'
|
|
||||||
|
|
||||||
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 DOMAIN = process.env.DOMAIN ?? 'http://localhost:4321'
|
|
||||||
|
|
||||||
// ── Fastify ────────────────────────────────────────────────────────────────
|
|
||||||
const app = Fastify({ logger: true, trustProxy: true })
|
|
||||||
await app.register(cors, { origin: '*', methods: ['GET', 'POST'] })
|
|
||||||
|
|
||||||
// ── Webhook Stripe ─────────────────────────────────────────────────────────
|
|
||||||
app.post('/api/webhook', {
|
|
||||||
config: { rawBody: true },
|
|
||||||
onRequest: (request, reply, done) => {
|
|
||||||
request.rawBody = ''
|
|
||||||
request.req.on('data', chunk => { request.rawBody += chunk })
|
|
||||||
request.req.on('end', done)
|
|
||||||
},
|
|
||||||
}, async (request, reply) => {
|
|
||||||
const sig = request.headers['stripe-signature']
|
|
||||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET
|
|
||||||
if (!sig || !webhookSecret) return reply.code(400).send('Missing signature')
|
|
||||||
|
|
||||||
let event
|
|
||||||
try {
|
|
||||||
event = stripe.webhooks.constructEvent(request.rawBody, sig, webhookSecret)
|
|
||||||
} catch {
|
|
||||||
return reply.code(400).send('Webhook Error')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === 'checkout.session.completed') {
|
|
||||||
const session = event.data.object
|
|
||||||
if (session.payment_status === 'paid') {
|
|
||||||
app.log.info(
|
|
||||||
`✓ Paiement confirmé — ${session.id} — ${session.customer_details?.email} — ${session.metadata?.product}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { received: true }
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Health check ────────────────────────────────────────────────────────────
|
|
||||||
app.get('/api/health', async () => ({ status: 'ok' }))
|
|
||||||
|
|
||||||
// ── Checkout Stripe ─────────────────────────────────────────────────────────
|
|
||||||
app.post('/api/checkout', async (request, reply) => {
|
|
||||||
const { product: slug, email } = request.body ?? {}
|
|
||||||
|
|
||||||
if (!slug) return reply.code(400).send({ error: 'Produit manquant' })
|
|
||||||
|
|
||||||
// Fetch product from Sanity
|
|
||||||
const product = await sanity.fetch(
|
|
||||||
`*[_type == "product" && slug.current == $slug && isPublished == true][0]{
|
|
||||||
productDisplayName,
|
|
||||||
description,
|
|
||||||
price,
|
|
||||||
currency,
|
|
||||||
"imageUrl": images[0].asset->url,
|
|
||||||
"slug": slug.current
|
|
||||||
}`,
|
|
||||||
{ slug }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!product || !product.price) {
|
|
||||||
return reply.code(404).send({ error: 'Produit non disponible' })
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const session = await stripe.checkout.sessions.create({
|
|
||||||
mode: 'payment',
|
|
||||||
payment_method_types: ['card', 'link'],
|
|
||||||
line_items: [{
|
|
||||||
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,
|
|
||||||
}],
|
|
||||||
metadata: { product: slug },
|
|
||||||
success_url: `${DOMAIN}/success?session_id={CHECKOUT_SESSION_ID}`,
|
|
||||||
cancel_url: `${DOMAIN}/#collection`,
|
|
||||||
locale: 'fr',
|
|
||||||
customer_email: email ?? undefined,
|
|
||||||
custom_text: {
|
|
||||||
submit: { message: 'Pièce unique — fabriquée à Paris. Délai : 6 à 8 semaines.' },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return { url: session.url }
|
|
||||||
} catch (err) {
|
|
||||||
app.log.error(err)
|
|
||||||
return reply.code(500).send({ error: err.message })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Vérification session ────────────────────────────────────────────────────
|
|
||||||
app.get('/api/session/:id', async (request) => {
|
|
||||||
const session = await stripe.checkout.sessions.retrieve(request.params.id, {
|
|
||||||
expand: ['payment_intent.latest_charge'],
|
|
||||||
})
|
|
||||||
const charge = session.payment_intent?.latest_charge
|
|
||||||
return {
|
|
||||||
status: session.payment_status,
|
|
||||||
amount: session.amount_total,
|
|
||||||
currency: session.currency,
|
|
||||||
customer_email: session.customer_details?.email ?? null,
|
|
||||||
product: session.metadata?.product ?? null,
|
|
||||||
receipt_url: charge?.receipt_url ?? null,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Start ───────────────────────────────────────────────────────────────────
|
|
||||||
try {
|
|
||||||
await app.listen({ port: process.env.FASTIFY_PORT ?? process.env.PORT ?? 3000, host: '0.0.0.0' })
|
|
||||||
} catch (err) {
|
|
||||||
app.log.error(err)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
---
|
|
||||||
export interface Props {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
ogImage?: string;
|
|
||||||
canonical?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
title,
|
|
||||||
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.',
|
|
||||||
ogImage = 'https://rebours.studio/assets/lamp-violet.jpg',
|
|
||||||
canonical = 'https://rebours.studio/',
|
|
||||||
} = Astro.props;
|
|
||||||
|
|
||||||
const cssVersion = Date.now();
|
|
||||||
---
|
|
||||||
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="fr">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
|
|
||||||
<!-- SEO Primary -->
|
|
||||||
<title>{title}</title>
|
|
||||||
<meta name="description" content={description}>
|
|
||||||
<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={canonical}>
|
|
||||||
|
|
||||||
<!-- Open Graph -->
|
|
||||||
<meta property="og:type" content="website">
|
|
||||||
<meta property="og:url" content={canonical}>
|
|
||||||
<meta property="og:title" content={title}>
|
|
||||||
<meta property="og:description" content={description}>
|
|
||||||
<meta property="og:image" content={ogImage}>
|
|
||||||
<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={title}>
|
|
||||||
<meta name="twitter:description" content={description}>
|
|
||||||
<meta name="twitter:image" content={ogImage}>
|
|
||||||
|
|
||||||
<!-- Favicon -->
|
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
|
||||||
|
|
||||||
<!-- 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">
|
|
||||||
|
|
||||||
<slot name="head" />
|
|
||||||
|
|
||||||
<link rel="stylesheet" href={`/style.css?v=${cssVersion}`}>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<slot />
|
|
||||||
<script>import '../scripts/main.js';</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
import { createClient } from '@sanity/client'
|
|
||||||
import imageUrlBuilder from '@sanity/image-url'
|
|
||||||
|
|
||||||
export const sanity = createClient({
|
|
||||||
projectId: process.env.SANITY_PROJECT_ID || import.meta.env.SANITY_PROJECT_ID,
|
|
||||||
dataset: process.env.SANITY_DATASET || import.meta.env.SANITY_DATASET || 'production',
|
|
||||||
apiVersion: '2024-01-01',
|
|
||||||
useCdn: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
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,
|
|
||||||
images,
|
|
||||||
"imageAlt": images[0].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 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getHomePage() {
|
|
||||||
return sanity.fetch(
|
|
||||||
`*[_type == "homePage" && _id == "homePage"][0] {
|
|
||||||
heroLabel,
|
|
||||||
heroTitle,
|
|
||||||
heroSubtitle,
|
|
||||||
heroStatus,
|
|
||||||
heroImage,
|
|
||||||
"heroImageAlt": heroImage.alt,
|
|
||||||
collectionLabel,
|
|
||||||
collectionCta,
|
|
||||||
contactLabel,
|
|
||||||
contactTitle,
|
|
||||||
contactDescription,
|
|
||||||
whatsappNumber,
|
|
||||||
whatsappButtonText,
|
|
||||||
contactResponseTime,
|
|
||||||
footerText,
|
|
||||||
instagramUrl,
|
|
||||||
seoTitle,
|
|
||||||
seoDescription
|
|
||||||
}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,303 +0,0 @@
|
|||||||
---
|
|
||||||
import Base from '../../layouts/Base.astro';
|
|
||||||
import { getPublishedProducts, getProductBySlug, urlFor } from '../../lib/sanity.mjs';
|
|
||||||
|
|
||||||
const { slug } = Astro.params;
|
|
||||||
const product = await getProductBySlug(slug);
|
|
||||||
|
|
||||||
if (!product) {
|
|
||||||
return Astro.redirect('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = product.name;
|
|
||||||
const title = product.seoTitle || `REBOURS — ${product.productDisplayName} | Collection 001`;
|
|
||||||
const description = product.seoDescription || product.description?.substring(0, 155) || '';
|
|
||||||
const ogImage = product.images?.[0] ? urlFor(product.images[0]).width(1200).url() : '';
|
|
||||||
const productName = product.productDisplayName;
|
|
||||||
const price = product.price ? String(product.price / 100) : null;
|
|
||||||
const availability = product.availability || 'https://schema.org/PreOrder';
|
|
||||||
|
|
||||||
const allProducts = await getPublishedProducts();
|
|
||||||
|
|
||||||
const schemaProduct = {
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "Product",
|
|
||||||
"name": productName,
|
|
||||||
"description": description,
|
|
||||||
"image": ogImage,
|
|
||||||
"brand": { "@type": "Brand", "name": "REBOURS Studio" },
|
|
||||||
"url": `https://rebours.studio/collection/${slug}/`,
|
|
||||||
...(price ? {
|
|
||||||
"offers": {
|
|
||||||
"@type": "Offer",
|
|
||||||
"price": price,
|
|
||||||
"priceCurrency": "EUR",
|
|
||||||
"availability": availability,
|
|
||||||
"seller": { "@type": "Organization", "name": "REBOURS Studio" }
|
|
||||||
}
|
|
||||||
} : {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const schemaBreadcrumb = {
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "BreadcrumbList",
|
|
||||||
"itemListElement": [
|
|
||||||
{ "@type": "ListItem", "position": 1, "name": "Accueil", "item": "https://rebours.studio/" },
|
|
||||||
{ "@type": "ListItem", "position": 2, "name": "Collection 001", "item": "https://rebours.studio/#collection" },
|
|
||||||
{ "@type": "ListItem", "position": 3, "name": productName, "item": `https://rebours.studio/collection/${slug}/` },
|
|
||||||
]
|
|
||||||
};
|
|
||||||
---
|
|
||||||
|
|
||||||
<Base
|
|
||||||
title={title}
|
|
||||||
description={description}
|
|
||||||
ogImage={ogImage}
|
|
||||||
canonical={`https://rebours.studio/collection/${slug}/`}
|
|
||||||
>
|
|
||||||
<Fragment slot="head">
|
|
||||||
<script type="application/ld+json" set:html={JSON.stringify(schemaProduct)} />
|
|
||||||
<script type="application/ld+json" set:html={JSON.stringify(schemaBreadcrumb)} />
|
|
||||||
</Fragment>
|
|
||||||
|
|
||||||
<meta name="x-open-panel" content={name} />
|
|
||||||
|
|
||||||
<script is:inline>
|
|
||||||
window.__OPEN_PANEL__ = document.querySelector('meta[name="x-open-panel"]')?.content;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- 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">
|
|
||||||
<div class="panel-gallery" id="panel-gallery">
|
|
||||||
<img id="panel-img" src="" alt="Image produit REBOURS Studio">
|
|
||||||
</div>
|
|
||||||
<div class="panel-gallery-nav" id="panel-gallery-nav"></div>
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<details class="accordion" open>
|
|
||||||
<summary>SPÉCIFICATIONS TECHNIQUES <span>↓</span></summary>
|
|
||||||
<div class="accordion-body" id="panel-specs"></div>
|
|
||||||
</details>
|
|
||||||
<details class="accordion" open>
|
|
||||||
<summary>NOTES DE CONCEPTION <span>↓</span></summary>
|
|
||||||
<div class="accordion-body" id="panel-notes"></div>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<div id="checkout-section" style="display:none;">
|
|
||||||
<div class="checkout-price-line">
|
|
||||||
<span class="checkout-price"></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="#" class="contact-trigger">CONTACT</a>
|
|
||||||
<span class="wip-tag"><span class="blink">■</span> W.I.P</span>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
{allProducts[0]?.images?.[0] && (
|
|
||||||
<img
|
|
||||||
src={urlFor(allProducts[0].images[0]).width(1024).url()}
|
|
||||||
alt="REBOURS — Mobilier d'art contemporain, Paris 2026"
|
|
||||||
class="hero-img"
|
|
||||||
width="1024" height="1024"
|
|
||||||
fetchpriority="high">
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<section class="collection" id="collection" aria-label="Collection 001">
|
|
||||||
<div class="collection-header">
|
|
||||||
<p class="label">// COLLECTION_001</p>
|
|
||||||
<span class="label">{allProducts.length} OBJETS — CLIQUER POUR OUVRIR</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="product-grid">
|
|
||||||
{allProducts.map((p, i) => {
|
|
||||||
const mainImg = p.images?.[0];
|
|
||||||
const imgUrl = mainImg ? urlFor(mainImg).width(800).url() : '';
|
|
||||||
const alt = p.imageAlt || `${p.productDisplayName} — mobilier d'art contemporain, REBOURS Studio Paris`;
|
|
||||||
const allImgs = (p.images || []).map(img => ({
|
|
||||||
url: urlFor(img).width(1200).url(),
|
|
||||||
alt: img.alt || alt,
|
|
||||||
}));
|
|
||||||
return (
|
|
||||||
<article class="product-card"
|
|
||||||
data-index={p.index}
|
|
||||||
data-name={p.name}
|
|
||||||
data-type={p.type}
|
|
||||||
data-mat={p.materials}
|
|
||||||
data-year={p.year}
|
|
||||||
data-status={p.status}
|
|
||||||
data-desc={p.description}
|
|
||||||
data-specs={p.specs || ''}
|
|
||||||
data-notes={p.notes || ''}
|
|
||||||
data-img={mainImg ? urlFor(mainImg).width(1200).url() : ''}
|
|
||||||
data-images={JSON.stringify(allImgs)}
|
|
||||||
data-price={p.price ? String(p.price) : ''}
|
|
||||||
data-slug={p.slug}
|
|
||||||
data-img-alt={alt}
|
|
||||||
aria-label={`Ouvrir le détail de ${p.productDisplayName}`}>
|
|
||||||
<div class="card-img-wrap">
|
|
||||||
<img src={imgUrl}
|
|
||||||
alt={alt}
|
|
||||||
width="600" height="600"
|
|
||||||
loading={i === 0 ? "eager" : "lazy"}>
|
|
||||||
</div>
|
|
||||||
<div class="card-meta">
|
|
||||||
<span class="card-index">{String(i + 1).padStart(3, '0')}</span>
|
|
||||||
<span class="card-name">{p.name}</span>
|
|
||||||
<span class="card-arrow">↗</span>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<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="#" class="contact-trigger">CONTACT</a>
|
|
||||||
</nav>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- CONTACT MODAL -->
|
|
||||||
<div id="contact-modal" class="contact-modal" aria-hidden="true">
|
|
||||||
<div class="contact-modal-backdrop"></div>
|
|
||||||
<div class="contact-modal-content">
|
|
||||||
<div class="contact-modal-header">
|
|
||||||
<p class="label">// CONTACT</p>
|
|
||||||
<button class="contact-modal-close" aria-label="Fermer">✕</button>
|
|
||||||
</div>
|
|
||||||
<hr>
|
|
||||||
<h2 class="contact-modal-title">ENVOYER<br>UN MESSAGE</h2>
|
|
||||||
<form id="contact-form" class="contact-form">
|
|
||||||
<div class="contact-field">
|
|
||||||
<label for="contact-name">NOM *</label>
|
|
||||||
<input type="text" id="contact-name" name="name" placeholder="Votre nom" required autocomplete="name">
|
|
||||||
</div>
|
|
||||||
<div class="contact-field">
|
|
||||||
<label for="contact-email">EMAIL *</label>
|
|
||||||
<input type="email" id="contact-email" name="email" placeholder="votre@email.com" required autocomplete="email">
|
|
||||||
</div>
|
|
||||||
<div class="contact-field">
|
|
||||||
<label for="contact-subject">OBJET</label>
|
|
||||||
<input type="text" id="contact-subject" name="subject" placeholder="Objet du message" autocomplete="off">
|
|
||||||
</div>
|
|
||||||
<div class="contact-field">
|
|
||||||
<label for="contact-message">MESSAGE *</label>
|
|
||||||
<textarea id="contact-message" name="message" rows="5" placeholder="Votre message..." required></textarea>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="contact-submit">
|
|
||||||
ENVOYER LE MESSAGE →
|
|
||||||
</button>
|
|
||||||
<p class="mono-sm contact-note"><span class="blink">■</span> REDIRECTION VERS VOTRE CLIENT MAIL</p>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cursor-dot"></div>
|
|
||||||
<div class="cursor-outline"></div>
|
|
||||||
</Base>
|
|
||||||
@ -1,310 +0,0 @@
|
|||||||
---
|
|
||||||
import Base from '../layouts/Base.astro';
|
|
||||||
import { getPublishedProducts, getHomePage, urlFor } from '../lib/sanity.mjs';
|
|
||||||
|
|
||||||
const [products, home] = await Promise.all([getPublishedProducts(), getHomePage()]);
|
|
||||||
|
|
||||||
const heroImg = home?.heroImage
|
|
||||||
? urlFor(home.heroImage).width(1024).url()
|
|
||||||
: products[0]?.images?.[0]
|
|
||||||
? urlFor(products[0].images[0]).width(1024).url()
|
|
||||||
: '/assets/table-terrazzo.jpg';
|
|
||||||
const heroImgAlt = home?.heroImageAlt || 'REBOURS — Mobilier d\'art contemporain, Paris 2026';
|
|
||||||
|
|
||||||
const heroLabel = home?.heroLabel || '// ARCHIVE_001 — 2026';
|
|
||||||
const heroTitleParts = (home?.heroTitle || 'REBOURS|STUDIO').split('|');
|
|
||||||
const heroSubtitle = home?.heroSubtitle || 'Mobilier d\'art contemporain.\nSpace Age × Memphis.';
|
|
||||||
const heroStatus = home?.heroStatus || 'STATUS: [PROTOTYPE EN COURS]\nCOLLECTION_001 — BIENTÔT DISPONIBLE';
|
|
||||||
|
|
||||||
const collectionLabel = home?.collectionLabel || '// COLLECTION_001';
|
|
||||||
const collectionCta = home?.collectionCta || 'CLIQUER POUR OUVRIR';
|
|
||||||
|
|
||||||
const contactLabel = home?.contactLabel || '// CONTACT';
|
|
||||||
const contactTitleParts = (home?.contactTitle || 'UNE QUESTION ?|PARLONS-EN').split('|');
|
|
||||||
const contactDesc = home?.contactDescription || 'Commandes sur mesure, questions techniques,\nou simplement dire bonjour.';
|
|
||||||
const whatsappNumber = home?.whatsappNumber || '33651755191';
|
|
||||||
const whatsappBtnText = home?.whatsappButtonText || 'CONTACTEZ-NOUS SUR WHATSAPP';
|
|
||||||
const contactResponseTime = home?.contactResponseTime || 'RÉPONSE SOUS 24H';
|
|
||||||
|
|
||||||
const footerText = home?.footerText || '© 2026 REBOURS STUDIO — PARIS';
|
|
||||||
const instagramUrl = home?.instagramUrl || 'https://instagram.com/rebour.studio';
|
|
||||||
|
|
||||||
const seoTitle = home?.seoTitle || 'REBOURS — Mobilier d\'art contemporain | Collection 001';
|
|
||||||
const seoDesc = home?.seoDescription || '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.';
|
|
||||||
|
|
||||||
const firstImage = heroImg;
|
|
||||||
|
|
||||||
const schemaOrg = {
|
|
||||||
"@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": firstImage,
|
|
||||||
"address": { "@type": "PostalAddress", "addressLocality": "Paris", "addressCountry": "FR" },
|
|
||||||
"hasOfferCatalog": {
|
|
||||||
"@type": "OfferCatalog",
|
|
||||||
"name": "Collection 001",
|
|
||||||
"itemListElement": products.filter(p => p.price).map(p => ({
|
|
||||||
"@type": "Offer",
|
|
||||||
"itemOffered": {
|
|
||||||
"@type": "Product",
|
|
||||||
"name": p.productDisplayName,
|
|
||||||
"description": p.seoDescription || p.description?.substring(0, 155),
|
|
||||||
"image": p.images?.[0] ? urlFor(p.images[0]).width(1024).url() : undefined,
|
|
||||||
},
|
|
||||||
"price": String(p.price / 100),
|
|
||||||
"priceCurrency": p.currency || 'EUR',
|
|
||||||
"availability": p.availability,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
---
|
|
||||||
|
|
||||||
<Base
|
|
||||||
title={seoTitle}
|
|
||||||
description={seoDesc}
|
|
||||||
canonical="https://rebours.studio/"
|
|
||||||
>
|
|
||||||
<Fragment slot="head">
|
|
||||||
<script type="application/ld+json" set:html={JSON.stringify(schemaOrg)} />
|
|
||||||
</Fragment>
|
|
||||||
|
|
||||||
<!-- 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">
|
|
||||||
<div class="panel-gallery" id="panel-gallery">
|
|
||||||
<img id="panel-img" src="" alt="Image produit REBOURS Studio">
|
|
||||||
</div>
|
|
||||||
<div class="panel-gallery-nav" id="panel-gallery-nav"></div>
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<details class="accordion" open>
|
|
||||||
<summary>SPÉCIFICATIONS TECHNIQUES <span>↓</span></summary>
|
|
||||||
<div class="accordion-body" id="panel-specs"></div>
|
|
||||||
</details>
|
|
||||||
<details class="accordion" open>
|
|
||||||
<summary>NOTES DE CONCEPTION <span>↓</span></summary>
|
|
||||||
<div class="accordion-body" id="panel-notes"></div>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<div id="checkout-section" style="display:none;">
|
|
||||||
<div class="checkout-price-line">
|
|
||||||
<span class="checkout-price"></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="#" class="contact-trigger">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">{heroLabel}</p>
|
|
||||||
<h1>{heroTitleParts.map((part, i) => <>{i > 0 && <br/>}{part}</>)}</h1>
|
|
||||||
<p class="hero-sub" set:html={heroSubtitle.replace(/\n/g, '<br>')} />
|
|
||||||
<p class="hero-sub mono-sm" set:html={heroStatus.replace(/\n/g, '<br>')} />
|
|
||||||
</div>
|
|
||||||
<div class="hero-right">
|
|
||||||
<img
|
|
||||||
src={heroImg}
|
|
||||||
alt={heroImgAlt}
|
|
||||||
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">{collectionLabel}</p>
|
|
||||||
<span class="label">{products.length} OBJETS — {collectionCta}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="product-grid">
|
|
||||||
{products.map((p, i) => {
|
|
||||||
const mainImg = p.images?.[0];
|
|
||||||
const imgUrl = mainImg ? urlFor(mainImg).width(800).url() : '';
|
|
||||||
const alt = p.imageAlt || `${p.productDisplayName} — mobilier d'art contemporain, REBOURS Studio Paris`;
|
|
||||||
const allImgs = (p.images || []).map(img => ({
|
|
||||||
url: urlFor(img).width(1200).url(),
|
|
||||||
alt: img.alt || alt,
|
|
||||||
}));
|
|
||||||
return (
|
|
||||||
<article class="product-card"
|
|
||||||
data-index={p.index}
|
|
||||||
data-name={p.name}
|
|
||||||
data-type={p.type}
|
|
||||||
data-mat={p.materials}
|
|
||||||
data-year={p.year}
|
|
||||||
data-status={p.status}
|
|
||||||
data-desc={p.description}
|
|
||||||
data-specs={p.specs || ''}
|
|
||||||
data-notes={p.notes || ''}
|
|
||||||
data-img={mainImg ? urlFor(mainImg).width(1200).url() : ''}
|
|
||||||
data-images={JSON.stringify(allImgs)}
|
|
||||||
data-price={p.price ? String(p.price) : ''}
|
|
||||||
data-slug={p.slug}
|
|
||||||
data-img-alt={alt}
|
|
||||||
aria-label={`Ouvrir le détail de ${p.productDisplayName}`}>
|
|
||||||
<div class="card-img-wrap">
|
|
||||||
<img src={imgUrl}
|
|
||||||
alt={alt}
|
|
||||||
width="600" height="600"
|
|
||||||
loading={i === 0 ? "eager" : "lazy"}>
|
|
||||||
</div>
|
|
||||||
<div class="card-meta">
|
|
||||||
<span class="card-index">{String(i + 1).padStart(3, '0')}</span>
|
|
||||||
<span class="card-name">{p.name}</span>
|
|
||||||
<span class="card-arrow">↗</span>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- CONTACT WHATSAPP -->
|
|
||||||
<section class="newsletter" id="contact" aria-label="Contact WhatsApp">
|
|
||||||
<div class="nl-left">
|
|
||||||
<p class="label">{contactLabel}</p>
|
|
||||||
<h2>{contactTitleParts.map((part, i) => <>{i > 0 && <br/>}{part}</>)}</h2>
|
|
||||||
</div>
|
|
||||||
<div class="nl-right">
|
|
||||||
<div class="nl-form" style="pointer-events: auto;">
|
|
||||||
<p class="mono-sm" style="line-height: 1.9; margin-bottom: 0.5rem;" set:html={contactDesc.replace(/\n/g, '<br>')} />
|
|
||||||
<a href={`https://wa.me/${whatsappNumber}`} target="_blank" rel="noopener" class="whatsapp-btn" aria-label="Nous contacter sur WhatsApp">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" style="flex-shrink:0;">
|
|
||||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
|
|
||||||
</svg>
|
|
||||||
{whatsappBtnText}
|
|
||||||
</a>
|
|
||||||
<p class="mono-sm"><span class="blink">■</span> {contactResponseTime}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="footer">
|
|
||||||
<span>{footerText}</span>
|
|
||||||
<nav aria-label="Liens secondaires">
|
|
||||||
<a href={instagramUrl} rel="noopener" target="_blank">INSTAGRAM</a>
|
|
||||||
/
|
|
||||||
<a href="#" class="contact-trigger">CONTACT</a>
|
|
||||||
</nav>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- CONTACT MODAL -->
|
|
||||||
<div id="contact-modal" class="contact-modal" aria-hidden="true" data-whatsapp={whatsappNumber}>
|
|
||||||
<div class="contact-modal-backdrop"></div>
|
|
||||||
<div class="contact-modal-content">
|
|
||||||
<div class="contact-modal-header">
|
|
||||||
<p class="label">// CONTACT</p>
|
|
||||||
<button class="contact-modal-close" aria-label="Fermer">✕</button>
|
|
||||||
</div>
|
|
||||||
<hr>
|
|
||||||
<h2 class="contact-modal-title">ENVOYER<br>UN MESSAGE</h2>
|
|
||||||
<form id="contact-form" class="contact-form">
|
|
||||||
<div class="contact-field">
|
|
||||||
<label for="contact-name">NOM *</label>
|
|
||||||
<input type="text" id="contact-name" name="name" placeholder="Votre nom" required autocomplete="name">
|
|
||||||
</div>
|
|
||||||
<div class="contact-field">
|
|
||||||
<label for="contact-email">EMAIL *</label>
|
|
||||||
<input type="email" id="contact-email" name="email" placeholder="votre@email.com" required autocomplete="email">
|
|
||||||
</div>
|
|
||||||
<div class="contact-field">
|
|
||||||
<label for="contact-subject">OBJET</label>
|
|
||||||
<input type="text" id="contact-subject" name="subject" placeholder="Objet du message" autocomplete="off">
|
|
||||||
</div>
|
|
||||||
<div class="contact-field">
|
|
||||||
<label for="contact-message">MESSAGE *</label>
|
|
||||||
<textarea id="contact-message" name="message" rows="5" placeholder="Votre message..." required></textarea>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="contact-submit">
|
|
||||||
ENVOYER LE MESSAGE →
|
|
||||||
</button>
|
|
||||||
<p class="mono-sm contact-note"><span class="blink">■</span> REDIRECTION VERS WHATSAPP</p>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cursor-dot"></div>
|
|
||||||
<div class="cursor-outline"></div>
|
|
||||||
</Base>
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
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' },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
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,179 +0,0 @@
|
|||||||
---
|
|
||||||
import Base from '../layouts/Base.astro';
|
|
||||||
---
|
|
||||||
|
|
||||||
<Base
|
|
||||||
title="REBOURS — COMMANDE CONFIRMÉE"
|
|
||||||
description="Votre commande REBOURS Studio a été confirmée."
|
|
||||||
canonical="https://rebours.studio/success"
|
|
||||||
>
|
|
||||||
<meta name="robots" content="noindex, nofollow" slot="head">
|
|
||||||
<style>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
.left::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(255, 255, 255, 0.25);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.slabel { font-size: 0.75rem; color: #888; }
|
|
||||||
h1 {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
.right {
|
|
||||||
padding: 5rem 2rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
.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, color 0.15s;
|
|
||||||
align-self: flex-start;
|
|
||||||
margin-top: 1rem;
|
|
||||||
cursor: none;
|
|
||||||
}
|
|
||||||
a.back:hover { background: var(--clr-black); color: var(--clr-white); }
|
|
||||||
#receipt-btn:hover { background: var(--clr-red) !important; color: var(--clr-black) !important; border-color: var(--clr-red) !important; }
|
|
||||||
#loading { color: #888; font-size: 0.78rem; }
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
main { grid-template-columns: 1fr; }
|
|
||||||
.left {
|
|
||||||
padding: 2rem 1.5rem;
|
|
||||||
min-height: 40vw;
|
|
||||||
border-right: none;
|
|
||||||
border-bottom: var(--border);
|
|
||||||
}
|
|
||||||
h1 { font-size: clamp(2rem, 12vw, 3rem); }
|
|
||||||
.right { padding: 2rem 1.5rem; justify-content: flex-start; }
|
|
||||||
a.back { align-self: stretch; text-align: center; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<!-- Grid background -->
|
|
||||||
<div id="interactive-grid" class="interactive-grid"></div>
|
|
||||||
|
|
||||||
<div style="display:flex; flex-direction:column; min-height:100vh; position:relative; z-index:1;">
|
|
||||||
<header class="header">
|
|
||||||
<a href="/" class="logo-text">REBOURS</a>
|
|
||||||
<span style="font-size:0.78rem;color:#888">COLLECTION_001</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<div class="left">
|
|
||||||
<p class="slabel" 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="slabel">// 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 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">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:var(--clr-red); 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 pièce est fabriquée à la main à Paris.
|
|
||||||
</p>
|
|
||||||
<div style="display:flex; gap:0.8rem; flex-wrap:wrap;">
|
|
||||||
<a href="/" class="back">← RETOUR</a>
|
|
||||||
<a id="receipt-btn" href="#" target="_blank" rel="noopener" class="back" style="display:none; background:var(--clr-black); color:#e8a800; border-color:var(--clr-black);">↓ FACTURE PDF</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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@rebours.studio">CONTACT</a>
|
|
||||||
</nav>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cursor-dot"></div>
|
|
||||||
<div class="cursor-outline"></div>
|
|
||||||
|
|
||||||
<script is:inline>
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const sessionId = params.get('session_id');
|
|
||||||
|
|
||||||
if (sessionId) {
|
|
||||||
fetch(`/api/session/${sessionId}`)
|
|
||||||
.then(r => r.json())
|
|
||||||
.then((data) => {
|
|
||||||
const loading = document.getElementById('loading');
|
|
||||||
const orderDetails = document.getElementById('order-details');
|
|
||||||
if (loading) loading.style.display = 'none';
|
|
||||||
if (orderDetails) orderDetails.style.display = 'flex';
|
|
||||||
|
|
||||||
const amount = data.amount ? `${(data.amount / 100).toLocaleString('fr-FR')} €` : '—';
|
|
||||||
const amountEl = document.getElementById('amount-display');
|
|
||||||
const emailEl = document.getElementById('email-display');
|
|
||||||
const productEl = document.getElementById('product-display');
|
|
||||||
if (amountEl) amountEl.textContent = amount;
|
|
||||||
if (emailEl) emailEl.textContent = data.customer_email ?? '—';
|
|
||||||
if (productEl && data.product) productEl.textContent = data.product.replace(/-/g, '_').toUpperCase();
|
|
||||||
|
|
||||||
if (data.receipt_url) {
|
|
||||||
const btn = document.getElementById('receipt-btn');
|
|
||||||
if (btn) { btn.href = data.receipt_url; btn.style.display = 'inline-block'; }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
const loading = document.getElementById('loading');
|
|
||||||
if (loading) loading.textContent = 'Commande enregistrée.';
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const loading = document.getElementById('loading');
|
|
||||||
if (loading) loading.textContent = 'Commande enregistrée.';
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</Base>
|
|
||||||
@ -1,679 +0,0 @@
|
|||||||
/**
|
|
||||||
* REBOURS — Main Script
|
|
||||||
* CAD/CAO-inspired interface · GSAP ScrollTrigger · Technical drawing overlays · Ambient sound
|
|
||||||
*/
|
|
||||||
|
|
||||||
import gsap from 'gsap';
|
|
||||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
|
||||||
|
|
||||||
gsap.registerPlugin(ScrollTrigger);
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
|
|
||||||
// ---- CONFIG ----
|
|
||||||
const isMobile = window.innerWidth <= 600;
|
|
||||||
const isTouch = 'ontouchstart' in window;
|
|
||||||
|
|
||||||
// ---- HEADER HEIGHT → CSS VAR ----
|
|
||||||
const setHeaderHeight = () => {
|
|
||||||
const h = document.querySelector('.header')?.offsetHeight || 44;
|
|
||||||
document.documentElement.style.setProperty('--header-h', h + 'px');
|
|
||||||
};
|
|
||||||
setHeaderHeight();
|
|
||||||
window.addEventListener('resize', setHeaderHeight);
|
|
||||||
|
|
||||||
// ==========================================================
|
|
||||||
// 1. CAD CROSSHAIR CURSOR WITH X/Y COORDINATES
|
|
||||||
// ==========================================================
|
|
||||||
|
|
||||||
let attachCursorHover = () => {};
|
|
||||||
|
|
||||||
if (!isMobile && !isTouch) {
|
|
||||||
const cursorH = document.createElement('div');
|
|
||||||
cursorH.className = 'cad-h';
|
|
||||||
const cursorV = document.createElement('div');
|
|
||||||
cursorV.className = 'cad-v';
|
|
||||||
const cursorCenter = document.createElement('div');
|
|
||||||
cursorCenter.className = 'cad-center';
|
|
||||||
const cursorCoords = document.createElement('div');
|
|
||||||
cursorCoords.className = 'cad-coords';
|
|
||||||
|
|
||||||
[cursorH, cursorV, cursorCenter, cursorCoords].forEach(el => document.body.appendChild(el));
|
|
||||||
|
|
||||||
let visible = false;
|
|
||||||
|
|
||||||
window.addEventListener('mousemove', (e) => {
|
|
||||||
const x = e.clientX;
|
|
||||||
const y = e.clientY;
|
|
||||||
|
|
||||||
cursorH.style.left = (x - 16) + 'px';
|
|
||||||
cursorH.style.top = y + 'px';
|
|
||||||
cursorV.style.left = x + 'px';
|
|
||||||
cursorV.style.top = (y - 16) + 'px';
|
|
||||||
cursorCenter.style.left = x + 'px';
|
|
||||||
cursorCenter.style.top = y + 'px';
|
|
||||||
cursorCoords.style.left = (x + 16) + 'px';
|
|
||||||
cursorCoords.style.top = (y + 14) + 'px';
|
|
||||||
|
|
||||||
cursorCoords.textContent =
|
|
||||||
'X:' + String(Math.round(x)).padStart(4, '0') +
|
|
||||||
' Y:' + String(Math.round(y)).padStart(4, '0');
|
|
||||||
|
|
||||||
if (!visible) {
|
|
||||||
visible = true;
|
|
||||||
[cursorH, cursorV, cursorCenter, cursorCoords].forEach(el => {
|
|
||||||
el.style.opacity = '1';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
attachCursorHover = (elements) => {
|
|
||||||
elements.forEach(el => {
|
|
||||||
el.addEventListener('mouseenter', () => {
|
|
||||||
cursorH.classList.add('cad-hover');
|
|
||||||
cursorV.classList.add('cad-hover');
|
|
||||||
cursorCenter.classList.add('cad-hover');
|
|
||||||
});
|
|
||||||
el.addEventListener('mouseleave', () => {
|
|
||||||
cursorH.classList.remove('cad-hover');
|
|
||||||
cursorV.classList.remove('cad-hover');
|
|
||||||
cursorCenter.classList.remove('cad-hover');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
attachCursorHover(document.querySelectorAll(
|
|
||||||
'a, button, input, .product-card, summary, .panel-close'
|
|
||||||
));
|
|
||||||
|
|
||||||
// WhatsApp hover — green center dot
|
|
||||||
document.querySelectorAll('.whatsapp-btn').forEach(el => {
|
|
||||||
el.addEventListener('mouseenter', () => cursorCenter.classList.add('cad-whatsapp'));
|
|
||||||
el.addEventListener('mouseleave', () => cursorCenter.classList.remove('cad-whatsapp'));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================================
|
|
||||||
// 2. INTERACTIVE GRID
|
|
||||||
// ==========================================================
|
|
||||||
|
|
||||||
const gridContainer = document.getElementById('interactive-grid');
|
|
||||||
const COLORS = [
|
|
||||||
'rgba(232,168,0,0.45)',
|
|
||||||
'rgba(232,168,0,0.32)',
|
|
||||||
'rgba(232,168,0,0.18)',
|
|
||||||
];
|
|
||||||
|
|
||||||
function buildGrid() {
|
|
||||||
if (!gridContainer) return;
|
|
||||||
gridContainer.innerHTML = '';
|
|
||||||
const CELL = isMobile ? 38 : 60;
|
|
||||||
const cols = Math.ceil(window.innerWidth / CELL);
|
|
||||||
const rows = Math.ceil(window.innerHeight / CELL);
|
|
||||||
gridContainer.style.display = 'grid';
|
|
||||||
gridContainer.style.gridTemplateColumns = `repeat(${cols}, ${CELL}px)`;
|
|
||||||
gridContainer.style.gridTemplateRows = `repeat(${rows}, ${CELL}px)`;
|
|
||||||
|
|
||||||
for (let i = 0; i < cols * rows; i++) {
|
|
||||||
const cell = document.createElement('div');
|
|
||||||
cell.className = 'grid-cell';
|
|
||||||
cell.addEventListener('mouseenter', () => {
|
|
||||||
cell.style.transition = 'none';
|
|
||||||
cell.style.backgroundColor = COLORS[Math.floor(Math.random() * COLORS.length)];
|
|
||||||
});
|
|
||||||
cell.addEventListener('mouseleave', () => {
|
|
||||||
cell.style.transition = 'background-color 1.4s ease-out';
|
|
||||||
cell.style.backgroundColor = 'transparent';
|
|
||||||
});
|
|
||||||
gridContainer.appendChild(cell);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildGrid();
|
|
||||||
let rt;
|
|
||||||
window.addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(buildGrid, 150); });
|
|
||||||
|
|
||||||
// ==========================================================
|
|
||||||
// 3. GSAP SCROLL ANIMATIONS — CAD REVEAL
|
|
||||||
// ==========================================================
|
|
||||||
|
|
||||||
// ---- Header fade in ----
|
|
||||||
const header = document.querySelector('.header');
|
|
||||||
if (header) {
|
|
||||||
gsap.fromTo(header,
|
|
||||||
{ opacity: 0, y: -10 },
|
|
||||||
{ opacity: 1, y: 0, duration: 0.5, ease: 'power2.out' }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Hero animations (scroll-triggered, replay in/out) ----
|
|
||||||
const heroLabel = document.querySelector('.hero-left .label');
|
|
||||||
const heroH1 = document.querySelector('.hero-left h1');
|
|
||||||
const heroSubs = document.querySelectorAll('.hero-sub');
|
|
||||||
const heroImg = document.querySelector('.hero-img');
|
|
||||||
const heroRight = document.querySelector('.hero-right');
|
|
||||||
|
|
||||||
const heroTl = gsap.timeline({
|
|
||||||
defaults: { ease: 'power3.out' },
|
|
||||||
scrollTrigger: {
|
|
||||||
trigger: '.hero',
|
|
||||||
start: 'top 95%',
|
|
||||||
end: 'bottom 5%',
|
|
||||||
toggleActions: 'play reverse play reverse',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (heroLabel) {
|
|
||||||
heroTl.fromTo(heroLabel,
|
|
||||||
{ opacity: 0, x: -20 },
|
|
||||||
{ opacity: 1, x: 0, duration: 0.6 },
|
|
||||||
0.1
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (heroH1) {
|
|
||||||
heroTl.fromTo(heroH1,
|
|
||||||
{ opacity: 0, y: 40, clipPath: 'inset(0 0 100% 0)' },
|
|
||||||
{ opacity: 1, y: 0, clipPath: 'inset(0 0 0% 0)', duration: 1 },
|
|
||||||
0.2
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
heroSubs.forEach((sub, i) => {
|
|
||||||
heroTl.fromTo(sub,
|
|
||||||
{ opacity: 0, y: 20 },
|
|
||||||
{ opacity: 1, y: 0, duration: 0.6 },
|
|
||||||
0.5 + i * 0.15
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (heroImg && heroRight) {
|
|
||||||
heroTl.fromTo(heroRight,
|
|
||||||
{ clipPath: 'inset(0 0 0 100%)' },
|
|
||||||
{ clipPath: 'inset(0 0 0 0%)', duration: 1.2, ease: 'power4.inOut' },
|
|
||||||
0.3
|
|
||||||
);
|
|
||||||
heroTl.fromTo(heroImg,
|
|
||||||
{ scale: 1.15, opacity: 0 },
|
|
||||||
{ scale: 1, opacity: 0.92, duration: 1.4, ease: 'power2.out' },
|
|
||||||
0.4
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hero parallax on scroll
|
|
||||||
if (heroImg) {
|
|
||||||
gsap.to(heroImg, {
|
|
||||||
yPercent: 15,
|
|
||||||
ease: 'none',
|
|
||||||
scrollTrigger: {
|
|
||||||
trigger: '.hero',
|
|
||||||
start: 'top top',
|
|
||||||
end: 'bottom top',
|
|
||||||
scrub: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Collection header: construction line draw-in ----
|
|
||||||
const collectionHeader = document.querySelector('.collection-header');
|
|
||||||
if (collectionHeader) {
|
|
||||||
const line = document.createElement('div');
|
|
||||||
line.className = 'cad-construction-line';
|
|
||||||
collectionHeader.appendChild(line);
|
|
||||||
|
|
||||||
gsap.fromTo(line,
|
|
||||||
{ scaleX: 0 },
|
|
||||||
{
|
|
||||||
scaleX: 1,
|
|
||||||
transformOrigin: 'left center',
|
|
||||||
duration: 0.8,
|
|
||||||
ease: 'power2.out',
|
|
||||||
scrollTrigger: {
|
|
||||||
trigger: collectionHeader,
|
|
||||||
start: 'top 95%',
|
|
||||||
end: 'bottom 5%',
|
|
||||||
toggleActions: 'play reverse play reverse',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Product cards: scale + fade reveal on scroll (replays) ----
|
|
||||||
const cards = document.querySelectorAll('.product-card');
|
|
||||||
|
|
||||||
cards.forEach((card, i) => {
|
|
||||||
const imgWrap = card.querySelector('.card-img-wrap');
|
|
||||||
const img = card.querySelector('.card-img-wrap img');
|
|
||||||
const meta = card.querySelector('.card-meta');
|
|
||||||
|
|
||||||
if (!img) return;
|
|
||||||
|
|
||||||
const tl = gsap.timeline({
|
|
||||||
scrollTrigger: {
|
|
||||||
trigger: card,
|
|
||||||
start: 'top 95%',
|
|
||||||
end: 'bottom 5%',
|
|
||||||
toggleActions: 'play reverse play reverse',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clip-path reveal + scale + fade
|
|
||||||
tl.fromTo(imgWrap,
|
|
||||||
{ clipPath: 'inset(8% 8% 8% 8%)' },
|
|
||||||
{ clipPath: 'inset(0% 0% 0% 0%)', duration: 0.8, ease: 'power3.out' },
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
tl.fromTo(img,
|
|
||||||
{ opacity: 0, scale: 1.12 },
|
|
||||||
{ opacity: 1, scale: 1, duration: 0.9, ease: 'power2.out' },
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
if (meta) {
|
|
||||||
tl.fromTo(meta,
|
|
||||||
{ opacity: 0, y: 15 },
|
|
||||||
{ opacity: 1, y: 0, duration: 0.5, ease: 'power2.out' },
|
|
||||||
0.25
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---- Newsletter section: slide in (replays) ----
|
|
||||||
const nlLeft = document.querySelector('.nl-left');
|
|
||||||
const nlRight = document.querySelector('.nl-right');
|
|
||||||
if (nlLeft && nlRight) {
|
|
||||||
gsap.fromTo(nlLeft,
|
|
||||||
{ opacity: 0, x: -40 },
|
|
||||||
{
|
|
||||||
opacity: 1, x: 0, duration: 0.7, ease: 'power2.out',
|
|
||||||
scrollTrigger: { trigger: '.newsletter', start: 'top 95%', end: 'bottom 5%', toggleActions: 'play reverse play reverse' },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
gsap.fromTo(nlRight,
|
|
||||||
{ opacity: 0, x: 40 },
|
|
||||||
{
|
|
||||||
opacity: 1, x: 0, duration: 0.7, ease: 'power2.out', delay: 0.15,
|
|
||||||
scrollTrigger: { trigger: '.newsletter', start: 'top 95%', end: 'bottom 5%', toggleActions: 'play reverse play reverse' },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================================
|
|
||||||
// 4. (REMOVED) — no overlay effects on panel image
|
|
||||||
// ==========================================================
|
|
||||||
|
|
||||||
// ==========================================================
|
|
||||||
// 5. PRODUCT PANEL
|
|
||||||
// ==========================================================
|
|
||||||
|
|
||||||
const panel = document.getElementById('product-panel');
|
|
||||||
const panelClose = document.getElementById('panel-close');
|
|
||||||
const panelCards = document.querySelectorAll('.product-card');
|
|
||||||
|
|
||||||
const fields = {
|
|
||||||
img: document.getElementById('panel-img'),
|
|
||||||
gallery: document.getElementById('panel-gallery'),
|
|
||||||
galleryNav: document.getElementById('panel-gallery-nav'),
|
|
||||||
index: document.getElementById('panel-index'),
|
|
||||||
name: document.getElementById('panel-name'),
|
|
||||||
type: document.getElementById('panel-type'),
|
|
||||||
mat: document.getElementById('panel-mat'),
|
|
||||||
year: document.getElementById('panel-year'),
|
|
||||||
status: document.getElementById('panel-status'),
|
|
||||||
desc: document.getElementById('panel-desc'),
|
|
||||||
specs: document.getElementById('panel-specs'),
|
|
||||||
notes: document.getElementById('panel-notes'),
|
|
||||||
};
|
|
||||||
|
|
||||||
let currentGalleryIndex = 0;
|
|
||||||
let currentGalleryImages = [];
|
|
||||||
|
|
||||||
// ---- CHECKOUT LOGIC ----
|
|
||||||
const checkoutSection = document.getElementById('checkout-section');
|
|
||||||
const checkoutToggleBtn = document.getElementById('checkout-toggle-btn');
|
|
||||||
const checkoutFormWrap = document.getElementById('checkout-form-wrap');
|
|
||||||
const checkoutForm = document.getElementById('checkout-form');
|
|
||||||
const checkoutSubmitBtn = document.getElementById('checkout-submit-btn');
|
|
||||||
const checkoutPriceEl = document.querySelector('.checkout-price');
|
|
||||||
let currentSlug = null;
|
|
||||||
|
|
||||||
function formatPrice(cents) {
|
|
||||||
return (cents / 100).toLocaleString('fr-FR') + ' €';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checkoutToggleBtn) {
|
|
||||||
checkoutToggleBtn.addEventListener('click', () => {
|
|
||||||
const isOpen = checkoutFormWrap.style.display !== 'none';
|
|
||||||
checkoutFormWrap.style.display = isOpen ? 'none' : 'block';
|
|
||||||
checkoutToggleBtn.textContent = isOpen
|
|
||||||
? '[ COMMANDER CETTE PIÈCE ]'
|
|
||||||
: '[ ANNULER ]';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checkoutForm) {
|
|
||||||
checkoutForm.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!currentSlug) return;
|
|
||||||
const email = document.getElementById('checkout-email').value;
|
|
||||||
|
|
||||||
checkoutSubmitBtn.disabled = true;
|
|
||||||
checkoutSubmitBtn.textContent = 'CONNEXION STRIPE...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/checkout', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ product: currentSlug, email }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.url) {
|
|
||||||
window.location.href = data.url;
|
|
||||||
} else {
|
|
||||||
throw new Error('No URL returned');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
checkoutSubmitBtn.disabled = false;
|
|
||||||
checkoutSubmitBtn.textContent = 'ERREUR — RÉESSAYER';
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function toSlug(name) {
|
|
||||||
return name
|
|
||||||
.toLowerCase()
|
|
||||||
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
|
||||||
.replace(/_/g, '-')
|
|
||||||
.replace(/[^a-z0-9-]/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function showGalleryImage(index) {
|
|
||||||
if (!currentGalleryImages.length) return;
|
|
||||||
currentGalleryIndex = index;
|
|
||||||
fields.img.src = currentGalleryImages[index].url;
|
|
||||||
fields.img.alt = currentGalleryImages[index].alt;
|
|
||||||
|
|
||||||
// Update nav dots
|
|
||||||
fields.galleryNav.querySelectorAll('.gallery-dot').forEach((dot, i) => {
|
|
||||||
dot.classList.toggle('active', i === index);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function openPanel(card, pushState = true) {
|
|
||||||
// Gallery setup
|
|
||||||
try {
|
|
||||||
currentGalleryImages = JSON.parse(card.dataset.images || '[]');
|
|
||||||
} catch { currentGalleryImages = []; }
|
|
||||||
|
|
||||||
if (currentGalleryImages.length > 0) {
|
|
||||||
currentGalleryIndex = 0;
|
|
||||||
fields.img.src = currentGalleryImages[0].url;
|
|
||||||
fields.img.alt = currentGalleryImages[0].alt;
|
|
||||||
} else {
|
|
||||||
fields.img.src = card.dataset.img;
|
|
||||||
fields.img.alt = card.dataset.imgAlt || card.dataset.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build nav dots
|
|
||||||
fields.galleryNav.innerHTML = '';
|
|
||||||
if (currentGalleryImages.length > 1) {
|
|
||||||
fields.galleryNav.style.display = 'flex';
|
|
||||||
currentGalleryImages.forEach((_, i) => {
|
|
||||||
const dot = document.createElement('button');
|
|
||||||
dot.className = 'gallery-dot' + (i === 0 ? ' active' : '');
|
|
||||||
dot.setAttribute('aria-label', `Image ${i + 1}`);
|
|
||||||
dot.addEventListener('click', () => showGalleryImage(i));
|
|
||||||
fields.galleryNav.appendChild(dot);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Arrow buttons
|
|
||||||
const prevBtn = document.createElement('button');
|
|
||||||
prevBtn.className = 'gallery-arrow gallery-prev';
|
|
||||||
prevBtn.textContent = '←';
|
|
||||||
prevBtn.setAttribute('aria-label', 'Image précédente');
|
|
||||||
prevBtn.addEventListener('click', () => {
|
|
||||||
showGalleryImage((currentGalleryIndex - 1 + currentGalleryImages.length) % currentGalleryImages.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
const nextBtn = document.createElement('button');
|
|
||||||
nextBtn.className = 'gallery-arrow gallery-next';
|
|
||||||
nextBtn.textContent = '→';
|
|
||||||
nextBtn.setAttribute('aria-label', 'Image suivante');
|
|
||||||
nextBtn.addEventListener('click', () => {
|
|
||||||
showGalleryImage((currentGalleryIndex + 1) % currentGalleryImages.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
fields.galleryNav.prepend(prevBtn);
|
|
||||||
fields.galleryNav.appendChild(nextBtn);
|
|
||||||
} else {
|
|
||||||
fields.galleryNav.style.display = 'none';
|
|
||||||
}
|
|
||||||
fields.index.textContent = card.dataset.index;
|
|
||||||
fields.name.textContent = card.dataset.name;
|
|
||||||
fields.type.textContent = card.dataset.type;
|
|
||||||
fields.mat.textContent = card.dataset.mat;
|
|
||||||
fields.year.textContent = card.dataset.year;
|
|
||||||
fields.status.textContent = card.dataset.status;
|
|
||||||
fields.desc.textContent = card.dataset.desc;
|
|
||||||
fields.specs.textContent = card.dataset.specs;
|
|
||||||
fields.notes.textContent = card.dataset.notes;
|
|
||||||
|
|
||||||
// Checkout
|
|
||||||
const price = card.dataset.price;
|
|
||||||
const slug = card.dataset.slug;
|
|
||||||
const isOrderable = price && slug;
|
|
||||||
|
|
||||||
checkoutSection.style.display = 'block';
|
|
||||||
|
|
||||||
if (isOrderable) {
|
|
||||||
currentSlug = slug;
|
|
||||||
checkoutPriceEl.textContent = formatPrice(parseInt(price, 10));
|
|
||||||
checkoutToggleBtn.textContent = '[ COMMANDER CETTE PIÈCE ]';
|
|
||||||
checkoutToggleBtn.disabled = false;
|
|
||||||
checkoutToggleBtn.classList.remove('checkout-btn--disabled');
|
|
||||||
} else {
|
|
||||||
currentSlug = null;
|
|
||||||
checkoutPriceEl.textContent = '';
|
|
||||||
checkoutToggleBtn.textContent = 'PROCHAINEMENT DISPONIBLE';
|
|
||||||
checkoutToggleBtn.disabled = true;
|
|
||||||
checkoutToggleBtn.classList.add('checkout-btn--disabled');
|
|
||||||
}
|
|
||||||
|
|
||||||
checkoutFormWrap.style.display = 'none';
|
|
||||||
checkoutSubmitBtn.disabled = false;
|
|
||||||
checkoutSubmitBtn.textContent = 'PROCÉDER AU PAIEMENT →';
|
|
||||||
checkoutForm.reset();
|
|
||||||
|
|
||||||
panel.querySelectorAll('details').forEach(d => d.setAttribute('open', ''));
|
|
||||||
panel.classList.add('is-open');
|
|
||||||
panel.setAttribute('aria-hidden', 'false');
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
|
|
||||||
attachCursorHover(panel.querySelectorAll(
|
|
||||||
'summary, .panel-close, .checkout-btn, .checkout-submit'
|
|
||||||
));
|
|
||||||
|
|
||||||
if (pushState) {
|
|
||||||
const cardSlug = card.dataset.slug || toSlug(card.dataset.name);
|
|
||||||
history.pushState({ slug: cardSlug }, '', `/collection/${cardSlug}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closePanel(pushState = true) {
|
|
||||||
panel.classList.remove('is-open');
|
|
||||||
panel.setAttribute('aria-hidden', 'true');
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
|
|
||||||
if (pushState) {
|
|
||||||
history.pushState({}, '', '/');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
panelCards.forEach(card => {
|
|
||||||
card.addEventListener('click', () => openPanel(card));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-open from direct URL (/collection/[slug])
|
|
||||||
if (window.__OPEN_PANEL__) {
|
|
||||||
const name = window.__OPEN_PANEL__;
|
|
||||||
const card = [...panelCards].find(c => c.dataset.name === name);
|
|
||||||
if (card) openPanel(card, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (panelClose) panelClose.addEventListener('click', () => closePanel());
|
|
||||||
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape') closePanel();
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('popstate', () => {
|
|
||||||
if (panel.classList.contains('is-open')) {
|
|
||||||
closePanel(false);
|
|
||||||
} else {
|
|
||||||
const match = location.pathname.match(/^\/collection\/(.+)$/);
|
|
||||||
if (match) {
|
|
||||||
const slug = match[1];
|
|
||||||
const card = [...panelCards].find(c => c.dataset.slug === slug || toSlug(c.dataset.name) === slug);
|
|
||||||
if (card) openPanel(card, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================================
|
|
||||||
// 6. AMBIENT WORKSHOP SOUND (MP3)
|
|
||||||
// ==========================================================
|
|
||||||
|
|
||||||
let soundOn = true;
|
|
||||||
|
|
||||||
// Create audio element (preloaded, looped, low volume)
|
|
||||||
const ambientAudio = new Audio('/assets/atelier-ambiance.mp3');
|
|
||||||
ambientAudio.loop = true;
|
|
||||||
ambientAudio.volume = 0;
|
|
||||||
ambientAudio.preload = 'auto';
|
|
||||||
|
|
||||||
const headerNav = document.querySelector('.header-nav');
|
|
||||||
if (headerNav) {
|
|
||||||
const soundBtn = document.createElement('button');
|
|
||||||
soundBtn.className = 'sound-toggle sound-active';
|
|
||||||
soundBtn.setAttribute('aria-label', 'Couper le son ambiant');
|
|
||||||
soundBtn.innerHTML =
|
|
||||||
'<span class="sound-bars">' +
|
|
||||||
'<span class="sound-bar"></span>' +
|
|
||||||
'<span class="sound-bar"></span>' +
|
|
||||||
'<span class="sound-bar"></span>' +
|
|
||||||
'</span>' +
|
|
||||||
'<span class="sound-label">SON</span>';
|
|
||||||
soundBtn.addEventListener('click', toggleSound);
|
|
||||||
headerNav.appendChild(soundBtn);
|
|
||||||
attachCursorHover([soundBtn]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Autoplay on first user interaction (browsers require it)
|
|
||||||
let autoStarted = false;
|
|
||||||
function autoStartSound() {
|
|
||||||
if (autoStarted || !soundOn) return;
|
|
||||||
autoStarted = true;
|
|
||||||
startAmbientSound();
|
|
||||||
window.removeEventListener('click', autoStartSound);
|
|
||||||
window.removeEventListener('scroll', autoStartSound);
|
|
||||||
window.removeEventListener('mousemove', autoStartSound);
|
|
||||||
}
|
|
||||||
window.addEventListener('click', autoStartSound, { once: false });
|
|
||||||
window.addEventListener('scroll', autoStartSound, { once: false });
|
|
||||||
window.addEventListener('mousemove', autoStartSound, { once: false });
|
|
||||||
|
|
||||||
// Smooth volume fade using GSAP
|
|
||||||
function startAmbientSound() {
|
|
||||||
ambientAudio.play().then(() => {
|
|
||||||
gsap.to(ambientAudio, { volume: 0.04, duration: 2, ease: 'power2.out' });
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopAmbientSound() {
|
|
||||||
gsap.to(ambientAudio, {
|
|
||||||
volume: 0, duration: 1.2, ease: 'power2.in',
|
|
||||||
onComplete: () => ambientAudio.pause(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSound() {
|
|
||||||
soundOn = !soundOn;
|
|
||||||
const btn = document.querySelector('.sound-toggle');
|
|
||||||
if (!btn) return;
|
|
||||||
|
|
||||||
if (soundOn) {
|
|
||||||
startAmbientSound();
|
|
||||||
btn.classList.add('sound-active');
|
|
||||||
btn.setAttribute('aria-label', 'Couper le son ambiant');
|
|
||||||
} else {
|
|
||||||
stopAmbientSound();
|
|
||||||
btn.classList.remove('sound-active');
|
|
||||||
btn.setAttribute('aria-label', 'Activer le son ambiant');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==========================================================
|
|
||||||
// 7. CONTACT MODAL
|
|
||||||
// ==========================================================
|
|
||||||
|
|
||||||
const contactModal = document.getElementById('contact-modal');
|
|
||||||
const contactForm = document.getElementById('contact-form');
|
|
||||||
|
|
||||||
function openContactModal() {
|
|
||||||
if (!contactModal) return;
|
|
||||||
contactModal.classList.add('is-open');
|
|
||||||
contactModal.setAttribute('aria-hidden', 'false');
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
attachCursorHover(contactModal.querySelectorAll('button, input, textarea'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeContactModal() {
|
|
||||||
if (!contactModal) return;
|
|
||||||
contactModal.classList.remove('is-open');
|
|
||||||
contactModal.setAttribute('aria-hidden', 'true');
|
|
||||||
if (!panel.classList.contains('is-open')) {
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger links
|
|
||||||
document.querySelectorAll('.contact-trigger').forEach(link => {
|
|
||||||
link.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
openContactModal();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close button
|
|
||||||
const contactCloseBtn = contactModal?.querySelector('.contact-modal-close');
|
|
||||||
if (contactCloseBtn) contactCloseBtn.addEventListener('click', closeContactModal);
|
|
||||||
|
|
||||||
// Backdrop click
|
|
||||||
const contactBackdrop = contactModal?.querySelector('.contact-modal-backdrop');
|
|
||||||
if (contactBackdrop) contactBackdrop.addEventListener('click', closeContactModal);
|
|
||||||
|
|
||||||
// Escape key
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape' && contactModal?.classList.contains('is-open')) {
|
|
||||||
closeContactModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Form → WhatsApp
|
|
||||||
if (contactForm) {
|
|
||||||
contactForm.addEventListener('submit', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const name = document.getElementById('contact-name').value.trim();
|
|
||||||
const email = document.getElementById('contact-email').value.trim();
|
|
||||||
const subject = document.getElementById('contact-subject').value.trim() || 'Contact depuis rebours.studio';
|
|
||||||
const message = document.getElementById('contact-message').value.trim();
|
|
||||||
|
|
||||||
const waNumber = contactModal.dataset.whatsapp || '33651755191';
|
|
||||||
const text = `*${subject}*\n\n${message}\n\n— ${name}\n${email}`;
|
|
||||||
window.open(`https://wa.me/${waNumber}?text=${encodeURIComponent(text)}`, '_blank');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||