feat: replace Astro + Sanity + Fastify with Next.js + Payload CMS
All checks were successful
Build & Deploy to K3s / build-and-deploy (push) Successful in 4m13s

Single Next.js 15 app now serves frontend SSR, admin CMS, and Stripe API.
Replaces the Sanity quota-limited headless CMS with self-hosted Payload 3.0
on Postgres, removing the split-service topology (ssr/api/proxy → web).

- nextjs/: Next.js 15 app with Payload 3.0, Postgres adapter, Stripe plugin
- k8s/: new single-pod deployment + Postgres StatefulSet + PVCs (media, db)
- .gitea/workflows/deploy.yml: single-image build, tears down legacy pods

New Gitea secrets required: PAYLOAD_SECRET, POSTGRES_PASSWORD.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-04-21 10:28:29 +02:00
parent e14732ef2c
commit bf5bf977e9
53 changed files with 12058 additions and 208 deletions

View File

@ -6,8 +6,7 @@ on:
env:
REGISTRY: git.arthurbarre.fr
SSR_IMAGE: git.arthurbarre.fr/ordinarthur/rebours-ssr
API_IMAGE: git.arthurbarre.fr/ordinarthur/rebours-api
WEB_IMAGE: git.arthurbarre.fr/ordinarthur/rebours-web
REGISTRY_USER: ordinarthur
jobs:
@ -22,31 +21,18 @@ jobs:
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
docker login ${{ env.REGISTRY }} -u ${{ env.REGISTRY_USER }} --password-stdin
- name: Build SSR image
- name: Build web image
run: |
docker build \
-f Dockerfile.ssr \
-t ${{ env.SSR_IMAGE }}:${{ github.sha }} \
-t ${{ env.SSR_IMAGE }}:latest \
.
-f nextjs/Dockerfile \
-t ${{ env.WEB_IMAGE }}:${{ github.sha }} \
-t ${{ env.WEB_IMAGE }}:latest \
./nextjs
- name: Build API image
- name: Push web image
run: |
docker build \
-f Dockerfile.api \
-t ${{ env.API_IMAGE }}:${{ github.sha }} \
-t ${{ env.API_IMAGE }}:latest \
.
- name: Push SSR image
run: |
docker push ${{ env.SSR_IMAGE }}:${{ github.sha }}
docker push ${{ env.SSR_IMAGE }}:latest
- name: Push API image
run: |
docker push ${{ env.API_IMAGE }}:${{ github.sha }}
docker push ${{ env.API_IMAGE }}:latest
docker push ${{ env.WEB_IMAGE }}:${{ github.sha }}
docker push ${{ env.WEB_IMAGE }}:latest
- name: Install kubectl
run: |
@ -59,9 +45,19 @@ jobs:
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG }}" | base64 -d > ~/.kube/config
- name: Apply namespace and shared resources
- name: Apply namespace
run: |
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
run: |
kubectl apply -f k8s/configmap.yml
kubectl apply -f k8s/service.yml
@ -73,26 +69,34 @@ jobs:
--docker-password="${{ secrets.REGISTRY_PASSWORD }}" \
--dry-run=client -o yaml | kubectl apply -f -
- name: Create database secret
run: |
kubectl -n rebours create secret generic rebours-db-secret \
--from-literal=POSTGRES_DB="rebours" \
--from-literal=POSTGRES_USER="rebours" \
--from-literal=POSTGRES_PASSWORD="${{ secrets.POSTGRES_PASSWORD }}" \
--dry-run=client -o yaml | kubectl apply -f -
- name: Create app secrets
run: |
kubectl -n rebours create secret generic rebours-secrets \
--from-literal=PAYLOAD_SECRET="${{ secrets.PAYLOAD_SECRET }}" \
--from-literal=DATABASE_URL="postgres://rebours:${{ secrets.POSTGRES_PASSWORD }}@rebours-postgres:5432/rebours" \
--from-literal=STRIPE_SECRET_KEY="${{ secrets.STRIPE_SECRET_KEY }}" \
--from-literal=STRIPE_WEBHOOK_SECRET="${{ secrets.STRIPE_WEBHOOK_SECRET }}" \
--from-literal=SANITY_API_TOKEN="${{ secrets.SANITY_API_TOKEN }}" \
--dry-run=client -o yaml | kubectl apply -f -
- name: Deploy workloads
- name: Deploy Postgres
run: |
kubectl apply -f k8s/postgres.yml
kubectl -n rebours rollout status statefulset/rebours-postgres --timeout=180s
- name: Deploy web
run: |
kubectl apply -f k8s/deployment.yml
kubectl -n rebours set image deployment/rebours-ssr \
rebours-ssr=${{ env.SSR_IMAGE }}:${{ github.sha }}
kubectl -n rebours set image deployment/rebours-api \
rebours-api=${{ env.API_IMAGE }}:${{ github.sha }}
kubectl -n rebours rollout status deployment/rebours-api --timeout=120s
kubectl -n rebours rollout status deployment/rebours-ssr --timeout=180s
kubectl -n rebours rollout status deployment/rebours-proxy --timeout=60s
kubectl -n rebours set image deployment/rebours-web \
rebours-web=${{ env.WEB_IMAGE }}:${{ github.sha }}
kubectl -n rebours rollout status deployment/rebours-web --timeout=300s
- name: Cleanup old images
run: |

View File

@ -5,40 +5,7 @@ metadata:
namespace: rebours
data:
NODE_ENV: "production"
SANITY_PROJECT_ID: "y821x5qu"
SANITY_DATASET: "production"
DOMAIN: "https://rebours.studio"
FASTIFY_PORT: "3000"
ASTRO_PORT: "4321"
proxy.conf: |
server {
listen 80;
server_name _;
client_max_body_size 10M;
# API → Fastify
location /api/ {
proxy_pass http://rebours-api: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 (cached)
location /_astro/ {
proxy_pass http://rebours-ssr:4321;
proxy_set_header Host $host;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# SSR → Astro
location / {
proxy_pass http://rebours-ssr: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;
}
}
NEXT_PUBLIC_SERVER_URL: "https://rebours.studio"
PAYLOAD_PUBLIC_SERVER_URL: "https://rebours.studio"
PORT: "3000"
HOSTNAME: "0.0.0.0"

View File

@ -1,76 +1,42 @@
# --- Astro SSR ---
apiVersion: apps/v1
kind: Deployment
# ─── Media uploads volume (Payload writes to /app/media) ─────────────────────
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: rebours-ssr
name: rebours-media-data
namespace: rebours
labels:
app: rebours-ssr
spec:
replicas: 1
selector:
matchLabels:
app: rebours-ssr
template:
metadata:
labels:
app: rebours-ssr
spec:
imagePullSecrets:
- name: gitea-registry-secret
containers:
- name: rebours-ssr
image: git.arthurbarre.fr/ordinarthur/rebours-ssr:latest
ports:
- containerPort: 4321
envFrom:
- configMapRef:
name: rebours-config
- secretRef:
name: rebours-secrets
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
readinessProbe:
httpGet:
path: /
port: 4321
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /
port: 4321
initialDelaySeconds: 15
periodSeconds: 30
accessModes: [ReadWriteOnce]
storageClassName: local-path
resources:
requests:
storage: 5Gi
---
# --- Fastify API ---
# ─── Next.js + Payload CMS (single process) ───────────────────────────────────
apiVersion: apps/v1
kind: Deployment
metadata:
name: rebours-api
name: rebours-web
namespace: rebours
labels:
app: rebours-api
app: rebours-web
spec:
replicas: 1
strategy:
# Single-replica app writing to a RWO volume — recreate instead of rolling
type: Recreate
selector:
matchLabels:
app: rebours-api
app: rebours-web
template:
metadata:
labels:
app: rebours-api
app: rebours-web
spec:
imagePullSecrets:
- name: gitea-registry-secret
containers:
- name: rebours-api
image: git.arthurbarre.fr/ordinarthur/rebours-api:latest
- name: rebours-web
image: git.arthurbarre.fr/ordinarthur/rebours-web:latest
ports:
- containerPort: 3000
envFrom:
@ -78,67 +44,30 @@ spec:
name: rebours-config
- secretRef:
name: rebours-secrets
volumeMounts:
- name: media
mountPath: /app/media
resources:
requests:
memory: "128Mi"
cpu: "100m"
memory: "384Mi"
cpu: "200m"
limits:
memory: "256Mi"
cpu: "300m"
memory: "1Gi"
cpu: "1000m"
readinessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 5
initialDelaySeconds: 15
periodSeconds: 10
failureThreshold: 6
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 10
initialDelaySeconds: 30
periodSeconds: 30
---
# --- Nginx Proxy ---
apiVersion: apps/v1
kind: Deployment
metadata:
name: rebours-proxy
namespace: rebours
labels:
app: rebours-proxy
spec:
replicas: 1
selector:
matchLabels:
app: rebours-proxy
template:
metadata:
labels:
app: rebours-proxy
spec:
containers:
- name: nginx
image: nginx:alpine
ports:
- containerPort: 80
volumeMounts:
- name: proxy-config
mountPath: /etc/nginx/conf.d/default.conf
subPath: proxy.conf
resources:
requests:
memory: "32Mi"
cpu: "25m"
limits:
memory: "64Mi"
cpu: "100m"
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 5
periodSeconds: 10
volumes:
- name: proxy-config
configMap:
name: rebours-config
- name: media
persistentVolumeClaim:
claimName: rebours-media-data

92
k8s/postgres.yml Normal file
View File

@ -0,0 +1,92 @@
# ─── Postgres data volume ─────────────────────────────────────────────────────
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: rebours-postgres-data
namespace: rebours
spec:
accessModes: [ReadWriteOnce]
storageClassName: local-path
resources:
requests:
storage: 5Gi
---
# ─── Postgres headless service (stable DNS inside the cluster) ────────────────
apiVersion: v1
kind: Service
metadata:
name: rebours-postgres
namespace: rebours
spec:
selector:
app: rebours-postgres
ports:
- name: postgres
port: 5432
targetPort: 5432
clusterIP: None
---
# ─── Postgres StatefulSet ─────────────────────────────────────────────────────
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: rebours-postgres
namespace: rebours
spec:
serviceName: rebours-postgres
replicas: 1
selector:
matchLabels:
app: rebours-postgres
template:
metadata:
labels:
app: rebours-postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
env:
- name: POSTGRES_DB
valueFrom:
secretKeyRef:
name: rebours-db-secret
key: POSTGRES_DB
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: rebours-db-secret
key: POSTGRES_USER
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: rebours-db-secret
key: POSTGRES_PASSWORD
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "1Gi"
cpu: "1000m"
readinessProbe:
exec:
command: ["sh", "-c", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
exec:
command: ["sh", "-c", "pg_isready -U $POSTGRES_USER -d $POSTGRES_DB"]
initialDelaySeconds: 30
periodSeconds: 30
volumes:
- name: data
persistentVolumeClaim:
claimName: rebours-postgres-data

View File

@ -1,43 +1,15 @@
# --- SSR (ClusterIP — internal) ---
# ─── Web (NodePort — entry point exposed to Traefik) ──────────────────────────
apiVersion: v1
kind: Service
metadata:
name: rebours-ssr
namespace: rebours
spec:
selector:
app: rebours-ssr
ports:
- name: http
port: 4321
targetPort: 4321
---
# --- API (ClusterIP — internal) ---
apiVersion: v1
kind: Service
metadata:
name: rebours-api
namespace: rebours
spec:
selector:
app: rebours-api
ports:
- name: http
port: 3000
targetPort: 3000
---
# --- Proxy (NodePort — external access) ---
apiVersion: v1
kind: Service
metadata:
name: rebours-proxy
name: rebours-web
namespace: rebours
spec:
type: NodePort
selector:
app: rebours-proxy
app: rebours-web
ports:
- name: http
port: 80
targetPort: 80
port: 3000
targetPort: 3000
nodePort: 30083

18
nextjs/.dockerignore Normal file
View File

@ -0,0 +1,18 @@
node_modules
.next
out
dist
build
media
.env
.env.*
!.env.example
.git
.gitignore
.vscode
.idea
.DS_Store
*.tsbuildinfo
next-env.d.ts
README.md
*.log

10
nextjs/.env.example Normal file
View File

@ -0,0 +1,10 @@
# ── Payload ───────────────────────────────────────────────────────────────────
PAYLOAD_SECRET=change-me-to-a-long-random-string
DATABASE_URL=postgres://rebours:rebours@localhost:5434/rebours
# ── Public URL ────────────────────────────────────────────────────────────────
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
# ── Stripe ────────────────────────────────────────────────────────────────────
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...

24
nextjs/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# deps
node_modules/
# next.js
.next/
out/
# env
.env
.env.local
.env.*.local
# build
build/
dist/
# payload
media/
payload-types.ts.bak
# misc
.DS_Store
*.tsbuildinfo
next-env.d.ts

63
nextjs/Dockerfile Normal file
View File

@ -0,0 +1,63 @@
# syntax=docker/dockerfile:1
# ─── Stage 1: install deps ────────────────────────────────────────────────────
FROM node:22-alpine AS deps
WORKDIR /app
RUN apk add --no-cache libc6-compat \
&& corepack enable \
&& corepack prepare pnpm@latest --activate
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# ─── Stage 2: build ───────────────────────────────────────────────────────────
FROM node:22-alpine AS builder
WORKDIR /app
RUN apk add --no-cache libc6-compat \
&& corepack enable \
&& corepack prepare pnpm@latest --activate
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build-time placeholders — real values injected at runtime.
# Payload reads env during `next build` (import map / type generation), so these
# must parse but never need to resolve.
ENV NEXT_TELEMETRY_DISABLED=1
ENV PAYLOAD_SECRET=build-time-placeholder
ENV DATABASE_URL=postgres://placeholder:placeholder@localhost:5432/placeholder
ENV STRIPE_SECRET_KEY=sk_test_placeholder
ENV NEXT_PUBLIC_SERVER_URL=https://rebours.studio
RUN pnpm build
# Trim dev deps to shrink the runtime image
RUN pnpm prune --prod
# ─── Stage 3: runtime ─────────────────────────────────────────────────────────
FROM node:22-alpine AS runtime
WORKDIR /app
RUN apk add --no-cache libc6-compat
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
# Non-root user for the runtime
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
COPY --from=builder --chown=nextjs:nodejs /app/next.config.mjs ./next.config.mjs
COPY --from=builder --chown=nextjs:nodejs /app/src ./src
COPY --from=builder --chown=nextjs:nodejs /app/tsconfig.json ./tsconfig.json
# Media uploads live on a mounted volume in K8s; create the dir so Payload can write to it
RUN mkdir -p /app/media && chown -R nextjs:nodejs /app/media
USER nextjs
EXPOSE 3000
CMD ["node", "node_modules/next/dist/bin/next", "start"]

15
nextjs/next.config.mjs Normal file
View File

@ -0,0 +1,15 @@
import { withPayload } from '@payloadcms/next/withPayload'
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
outputFileTracingRoot: new URL('.', import.meta.url).pathname,
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'cdn.sanity.io' },
{ protocol: 'https', hostname: 'rebours.studio' },
],
},
}
export default withPayload(nextConfig, { devBundleServerPackages: false })

44
nextjs/package.json Normal file
View File

@ -0,0 +1,44 @@
{
"name": "rebours",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"payload": "payload",
"generate:types": "payload generate:types",
"seed": "node --experimental-vm-modules --loader ts-node/esm scripts/seed.ts"
},
"dependencies": {
"@payloadcms/db-postgres": "^3.0.0",
"@payloadcms/live-preview-react": "^3.0.0",
"@payloadcms/next": "^3.0.0",
"@payloadcms/plugin-stripe": "^3.0.0",
"@payloadcms/richtext-lexical": "^3.0.0",
"@payloadcms/ui": "^3.0.0",
"cross-env": "^7.0.3",
"graphql": "^16.8.1",
"gsap": "^3.14.2",
"next": "^15.0.0",
"payload": "^3.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sharp": "0.33.5",
"stripe": "^10.17.0"
},
"devDependencies": {
"@types/node": "^22.5.4",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"eslint": "^9.16.0",
"eslint-config-next": "^15.0.0",
"ts-node": "^10.9.2",
"typescript": "5.7.3"
},
"engines": {
"node": ">=20"
}
}

7483
nextjs/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
nextjs/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

1311
nextjs/public/style.css Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,14 @@
'use client'
import { useEffect } from 'react'
import { usePathname } from 'next/navigation'
export default function ClientScripts() {
const pathname = usePathname()
useEffect(() => {
import('@/scripts/main.js')
}, [pathname])
return null
}

View File

@ -0,0 +1,323 @@
import { mediaUrl, mediaAlt } from '@/lib/payload'
import type { Product, HomePage } from '@/payload-types'
type Props = {
products: Product[]
home: HomePage | null
openPanelSlug?: string
}
function splitOn(str: string | undefined | null, fallback: string): string[] {
return (str || fallback).split('|')
}
function multiLine(str: string | undefined | null, fallback: string): string {
return (str || fallback).replace(/\n/g, '<br>')
}
export default function ReboursPage({ products, home, openPanelSlug }: Props) {
const heroImg =
mediaUrl(home?.heroImage as never, 'feature') ||
(products[0]?.images?.[0] ? mediaUrl((products[0].images[0] as { image: unknown }).image as never, 'feature') : null) ||
'/assets/table-terrazzo.jpg'
const heroImgAlt = mediaAlt(home?.heroImage as never, 'REBOURS — Mobilier d\u2019art contemporain, Paris 2026')
const heroLabel = home?.heroLabel || '// ARCHIVE_001 — 2026'
const heroTitleParts = splitOn(home?.heroTitle, 'REBOURS|STUDIO')
const heroSubtitle = multiLine(home?.heroSubtitle, 'Mobilier d\u2019art contemporain.\nSpace Age × Memphis.')
const heroStatus = multiLine(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 = splitOn(home?.contactTitle, 'UNE QUESTION ?|PARLONS-EN')
const contactDesc = multiLine(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'
return (
<>
{openPanelSlug && (
<script
dangerouslySetInnerHTML={{
__html: `window.__OPEN_PANEL__ = ${JSON.stringify(openPanelSlug)};`,
}}
/>
)}
<div id="interactive-grid" className="interactive-grid"></div>
<div id="product-panel" className="product-panel" aria-hidden="true">
<div className="panel-close" id="panel-close">
<span> RETOUR</span>
</div>
<div className="panel-inner">
<div className="panel-img-col">
<div className="panel-gallery" id="panel-gallery">
<img id="panel-img" src="" alt="Image produit REBOURS Studio" />
</div>
<div className="panel-gallery-nav" id="panel-gallery-nav"></div>
</div>
<div className="panel-info-col">
<p className="panel-index" id="panel-index"></p>
<h2 id="panel-name"></h2>
<hr />
<div className="panel-meta">
<div className="panel-meta-row">
<span className="meta-key">TYPE</span>
<span id="panel-type"></span>
</div>
<div className="panel-meta-row">
<span className="meta-key">MATÉRIAUX</span>
<span id="panel-mat"></span>
</div>
<div className="panel-meta-row">
<span className="meta-key">ANNÉE</span>
<span id="panel-year"></span>
</div>
<div className="panel-meta-row">
<span className="meta-key">STATUS</span>
<span id="panel-status" className="red"></span>
</div>
</div>
<hr />
<p id="panel-desc" className="panel-desc"></p>
<hr />
<details className="accordion" open>
<summary>
SPÉCIFICATIONS TECHNIQUES <span></span>
</summary>
<div className="accordion-body" id="panel-specs"></div>
</details>
<details className="accordion" open>
<summary>
NOTES DE CONCEPTION <span></span>
</summary>
<div className="accordion-body" id="panel-notes"></div>
</details>
<hr />
<div id="checkout-section" style={{ display: 'none' }}>
<div className="checkout-price-line">
<span className="checkout-price"></span>
<span className="checkout-edition">ÉDITION UNIQUE 1/1</span>
</div>
<button id="checkout-toggle-btn" className="checkout-btn">
[ COMMANDER CETTE PIÈCE ]
</button>
<div id="checkout-form-wrap" className="checkout-form-wrap" style={{ display: 'none' }}>
<form id="checkout-form" className="checkout-form">
<div className="checkout-form-field">
<label htmlFor="checkout-email">EMAIL *</label>
<input type="email" id="checkout-email" name="email" placeholder="votre@email.com" required autoComplete="off" />
</div>
<div className="checkout-form-note">
Pièce fabriquée à Paris. Délai : 6 à 8 semaines.<br />
Paiement sécurisé via Stripe.
</div>
<button type="submit" className="checkout-submit" id="checkout-submit-btn">
PROCÉDER AU PAIEMENT
</button>
</form>
</div>
</div>
<div className="panel-footer">
<span className="blink"></span> COLLECTION_001 W.I.P
</div>
</div>
</div>
</div>
<div className="page-wrapper">
<header className="header">
<a href="/" className="logo-text" aria-label="REBOURS — Accueil">
REBOURS
</a>
<nav className="header-nav" aria-label="Navigation principale">
<a href="#collection">COLLECTION_001</a>
<a href="#" className="contact-trigger">
CONTACT
</a>
<span className="wip-tag">
<span className="blink"></span> W.I.P
</span>
</nav>
</header>
<main>
<section className="hero" aria-label="Introduction">
<div className="hero-left">
<p className="label">{heroLabel}</p>
<h1>
{heroTitleParts.map((part, i) => (
<span key={i}>
{i > 0 && <br />}
{part}
</span>
))}
</h1>
<p className="hero-sub" dangerouslySetInnerHTML={{ __html: heroSubtitle }} />
<p className="hero-sub mono-sm" dangerouslySetInnerHTML={{ __html: heroStatus }} />
</div>
<div className="hero-right">
<img src={heroImg} alt={heroImgAlt} className="hero-img" width="1024" height="1024" fetchPriority="high" />
</div>
</section>
<hr />
<section className="collection" id="collection" aria-label="Collection 001">
<div className="collection-header">
<p className="label">{collectionLabel}</p>
<span className="label">
{products.length} OBJETS {collectionCta}
</span>
</div>
<div className="product-grid">
{products.map((p, i) => {
const imageEntries = (p.images || []) as Array<{ image: unknown }>
const mainImage = imageEntries[0]?.image
const imgUrl = mediaUrl(mainImage as never, 'card') || ''
const fullImgUrl = mediaUrl(mainImage as never, 'feature') || ''
const alt = mediaAlt(mainImage as never, `${p.productDisplayName} — mobilier d\u2019art contemporain, REBOURS Studio Paris`)
const allImgs = imageEntries
.map((entry) => {
const url = mediaUrl(entry.image as never, 'feature')
if (!url) return null
return { url, alt: mediaAlt(entry.image as never, alt) }
})
.filter((x): x is { url: string; alt: string } => !!x)
return (
<article
key={p.id}
className="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={fullImgUrl}
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 className="card-img-wrap">
<img src={imgUrl} alt={alt} width="600" height="600" loading={i === 0 ? 'eager' : 'lazy'} />
</div>
<div className="card-meta">
<span className="card-index">{String(i + 1).padStart(3, '0')}</span>
<span className="card-name">{p.name}</span>
<span className="card-arrow"></span>
</div>
</article>
)
})}
</div>
</section>
<section className="newsletter" id="contact" aria-label="Contact WhatsApp">
<div className="nl-left">
<p className="label">{contactLabel}</p>
<h2>
{contactTitleParts.map((part, i) => (
<span key={i}>
{i > 0 && <br />}
{part}
</span>
))}
</h2>
</div>
<div className="nl-right">
<div className="nl-form" style={{ pointerEvents: 'auto' }}>
<p className="mono-sm" style={{ lineHeight: 1.9, marginBottom: '0.5rem' }} dangerouslySetInnerHTML={{ __html: contactDesc }} />
<a href={`https://wa.me/${whatsappNumber}`} target="_blank" rel="noopener" className="whatsapp-btn" aria-label="Nous contacter sur WhatsApp">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" style={{ flexShrink: 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 className="mono-sm">
<span className="blink"></span> {contactResponseTime}
</p>
</div>
</div>
</section>
</main>
<footer className="footer">
<span>{footerText}</span>
<nav aria-label="Liens secondaires">
<a href={instagramUrl} rel="noopener" target="_blank">
INSTAGRAM
</a>
&nbsp;/&nbsp;
<a href="#" className="contact-trigger">
CONTACT
</a>
</nav>
</footer>
</div>
<div id="contact-modal" className="contact-modal" aria-hidden="true" data-whatsapp={whatsappNumber}>
<div className="contact-modal-backdrop"></div>
<div className="contact-modal-content">
<div className="contact-modal-header">
<p className="label">// CONTACT</p>
<button className="contact-modal-close" aria-label="Fermer">
</button>
</div>
<hr />
<h2 className="contact-modal-title">
ENVOYER
<br />
UN MESSAGE
</h2>
<form id="contact-form" className="contact-form">
<div className="contact-field">
<label htmlFor="contact-name">NOM *</label>
<input type="text" id="contact-name" name="name" placeholder="Votre nom" required autoComplete="name" />
</div>
<div className="contact-field">
<label htmlFor="contact-email">EMAIL *</label>
<input type="email" id="contact-email" name="email" placeholder="votre@email.com" required autoComplete="email" />
</div>
<div className="contact-field">
<label htmlFor="contact-subject">OBJET</label>
<input type="text" id="contact-subject" name="subject" placeholder="Objet du message" autoComplete="off" />
</div>
<div className="contact-field">
<label htmlFor="contact-message">MESSAGE *</label>
<textarea id="contact-message" name="message" rows={5} placeholder="Votre message..." required />
</div>
<button type="submit" className="contact-submit">
ENVOYER LE MESSAGE
</button>
<p className="mono-sm contact-note">
<span className="blink"></span> REDIRECTION VERS WHATSAPP
</p>
</form>
</div>
</div>
<div className="cursor-dot"></div>
<div className="cursor-outline"></div>
</>
)
}

View File

@ -0,0 +1,197 @@
import { NextResponse } from 'next/server'
import { readFile } from 'fs/promises'
import path from 'path'
import { getPayload } from 'payload'
import config from '@payload-config'
export const runtime = 'nodejs'
export const maxDuration = 300
type SeedProduct = {
name: string
productDisplayName: string
slug: string
sortOrder: number
index: string
type: string
materials: string
year: string
status: string
description: string
specs: string
notes: string
imageFile: string
imageAlt: string
seoTitle: string
seoDescription: string
price: number | null
currency: 'EUR' | 'USD'
availability: string
isPublished: boolean
}
const PRODUCTS: SeedProduct[] = [
{
name: 'Solar_Altar',
productDisplayName: 'Solar Altar',
slug: '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: '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: '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: '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: '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: 3565cm (7 tailles)\nDôme: Ø1528cm\nAnneau néon: 8W — 3000K\nÉdition: 7 ex. par coloris',
notes: 'Les corps sont coulés en série mais peints individuellement. Les dômes sont réalisés par un souffleur de verre artisanal. Le câble tressé rouge est la signature de la série.',
imageFile: '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,
},
]
const HOMEPAGE = {
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',
collectionLabel: '// COLLECTION_001',
collectionCta: 'CLIQUER POUR OUVRIR',
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',
footerText: '© 2026 REBOURS STUDIO — PARIS',
instagramUrl: 'https://instagram.com/rebour.studio',
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.",
}
const ASSETS_DIR = path.join(process.cwd(), 'public', 'assets')
export async function POST(request: Request) {
const auth = request.headers.get('authorization') || ''
const token = auth.replace(/^Bearer\s+/i, '')
if (!token || token !== process.env.PAYLOAD_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const payload = await getPayload({ config })
const log: string[] = []
try {
for (const p of PRODUCTS) {
const existing = await payload.find({
collection: 'products',
where: { slug: { equals: p.slug } },
limit: 1,
})
if (existing.docs.length) {
log.push(`Skip (exists): ${p.slug}`)
continue
}
const filePath = path.join(ASSETS_DIR, p.imageFile)
let mediaId: number | string | null = null
try {
const buffer = await readFile(filePath)
const media = await payload.create({
collection: 'media',
data: { alt: p.imageAlt },
file: { data: buffer, mimetype: 'image/jpeg', name: p.imageFile, size: buffer.byteLength },
})
mediaId = media.id
log.push(` img uploaded: ${p.imageFile}`)
} catch (err) {
log.push(` img FAILED ${p.imageFile}: ${(err as Error).message}`)
continue
}
await payload.create({
collection: 'products',
data: {
name: p.name,
productDisplayName: p.productDisplayName,
slug: p.slug,
sortOrder: p.sortOrder,
index: p.index,
type: p.type,
materials: p.materials,
year: p.year,
status: p.status,
description: p.description,
specs: p.specs,
notes: p.notes,
seoTitle: p.seoTitle,
seoDescription: p.seoDescription,
price: p.price ?? undefined,
currency: p.currency,
availability: p.availability as never,
isPublished: p.isPublished,
images: mediaId != null ? [{ image: mediaId as number }] : [],
},
})
log.push(` CREATED: ${p.slug}`)
}
await payload.updateGlobal({ slug: 'homePage', data: HOMEPAGE })
log.push(`HomePage: updated`)
return NextResponse.json({ ok: true, log })
} catch (err) {
log.push(`ERROR: ${(err as Error).message}`)
return NextResponse.json({ ok: false, log, error: (err as Error).message }, { status: 500 })
}
}

View File

@ -0,0 +1,63 @@
import { NextResponse } from 'next/server'
import Stripe from 'stripe'
import { getProductBySlug, mediaUrl } from '@/lib/payload'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? '', {
apiVersion: '2022-08-01',
})
export async function POST(request: Request) {
const body = await request.json().catch(() => ({}))
const slug = body?.product as string | undefined
const email = body?.email as string | undefined
if (!slug) return NextResponse.json({ error: 'Produit manquant' }, { status: 400 })
const product = await getProductBySlug(slug)
if (!product || !product.price) {
return NextResponse.json({ error: 'Produit non disponible' }, { status: 404 })
}
const img = (product.images?.[0] as { image: unknown } | undefined)?.image
const baseUrl =
process.env.NEXT_PUBLIC_SERVER_URL ?? `${new URL(request.url).origin}`
const imgUrl = mediaUrl(img as never, 'feature')
const absoluteImgUrl = imgUrl && imgUrl.startsWith('http') ? imgUrl : imgUrl ? `${baseUrl}${imgUrl}` : undefined
try {
// custom_text is supported at runtime on API versions >= 2022-11-15;
// the stripe@10 SDK types don't know about it, so we cast the params.
const params: Stripe.Checkout.SessionCreateParams = {
mode: 'payment',
payment_method_types: ['card'],
line_items: [
{
price_data: {
currency: (product.currency || 'EUR').toLowerCase(),
product_data: {
name: product.productDisplayName,
description: product.description?.substring(0, 500) || undefined,
images: absoluteImgUrl ? [absoluteImgUrl] : [],
},
unit_amount: product.price,
},
quantity: 1,
},
],
metadata: { product: slug },
success_url: `${baseUrl}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${baseUrl}/#collection`,
locale: 'fr',
customer_email: email || undefined,
}
;(params as Stripe.Checkout.SessionCreateParams & { custom_text: unknown }).custom_text = {
submit: { message: 'Pièce unique — fabriquée à Paris. Délai : 6 à 8 semaines.' },
}
const session = await stripe.checkout.sessions.create(params)
return NextResponse.json({ url: session.url })
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur Stripe'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@ -0,0 +1,5 @@
import { NextResponse } from 'next/server'
export async function GET() {
return NextResponse.json({ status: 'ok' })
}

View File

@ -0,0 +1,32 @@
import { NextResponse } from 'next/server'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? '', {
apiVersion: '2022-08-01',
})
export async function GET(_request: Request, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params
try {
const session = await stripe.checkout.sessions.retrieve(id, {
expand: ['payment_intent.latest_charge'],
})
// latest_charge was added in API version 2022-11-15; stripe@10 types omit it.
const paymentIntent = session.payment_intent as (Stripe.PaymentIntent & { latest_charge?: Stripe.Charge | string | null }) | null
const charge =
paymentIntent?.latest_charge && typeof paymentIntent.latest_charge === 'object'
? paymentIntent.latest_charge
: null
return NextResponse.json({
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,
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur session'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@ -0,0 +1,36 @@
import { NextResponse } from 'next/server'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? '', {
apiVersion: '2022-08-01',
})
export const runtime = 'nodejs'
export async function POST(request: Request) {
const sig = request.headers.get('stripe-signature')
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET
if (!sig || !webhookSecret) {
return new NextResponse('Missing signature', { status: 400 })
}
const rawBody = await request.text()
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(rawBody, sig, webhookSecret)
} catch {
return new NextResponse('Webhook Error', { status: 400 })
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session
if (session.payment_status === 'paid') {
console.log(
`✓ Paiement confirmé — ${session.id}${session.customer_details?.email}${session.metadata?.product}`,
)
}
}
return NextResponse.json({ received: true })
}

View File

@ -0,0 +1,77 @@
import type { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
import ReboursPage from '../../ReboursPage'
import { getHomePage, getProductBySlug, getPublishedProducts, mediaUrl, mediaAlt } from '@/lib/payload'
type Params = { slug: string }
export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
const { slug } = await params
const product = await getProductBySlug(slug)
if (!product) return { title: 'Produit introuvable' }
const img = (product.images?.[0] as { image: unknown } | undefined)?.image
const imgUrl = mediaUrl(img as never, 'feature') ?? undefined
return {
title: product.seoTitle || `${product.productDisplayName} — REBOURS`,
description: product.seoDescription || product.description?.substring(0, 155),
alternates: { canonical: `/collection/${slug}` },
openGraph: {
title: product.productDisplayName,
description: product.seoDescription || product.description?.substring(0, 155),
images: imgUrl ? [imgUrl] : undefined,
},
}
}
export const dynamic = 'force-dynamic'
export default async function ProductPage({ params }: { params: Promise<Params> }) {
const { slug } = await params
const product = await getProductBySlug(slug)
if (!product) redirect('/')
const [products, home] = await Promise.all([getPublishedProducts(), getHomePage()])
const img = (product.images?.[0] as { image: unknown } | undefined)?.image
const imgUrl = mediaUrl(img as never, 'feature')
const imgAlt = mediaAlt(img as never, product.productDisplayName)
const productSchema = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.productDisplayName,
description: product.description,
image: imgUrl ?? undefined,
sku: product.name,
brand: { '@type': 'Brand', name: 'REBOURS Studio' },
offers: product.price
? {
'@type': 'Offer',
price: String(product.price / 100),
priceCurrency: product.currency || 'EUR',
availability: product.availability,
seller: { '@type': 'Organization', name: 'REBOURS Studio' },
url: `https://rebours.studio/collection/${slug}`,
}
: undefined,
}
const breadcrumbSchema = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Accueil', item: 'https://rebours.studio/' },
{ '@type': 'ListItem', position: 2, name: 'Collection', item: 'https://rebours.studio/#collection' },
{ '@type': 'ListItem', position: 3, name: product.productDisplayName, item: `https://rebours.studio/collection/${slug}` },
],
}
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(productSchema) }} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }} />
<ReboursPage products={products} home={home} openPanelSlug={product.name} />
</>
)
}

View File

@ -0,0 +1 @@
/* Empty — frontend styles are served from /public/style.css */

View File

@ -0,0 +1,54 @@
import type { Metadata } from 'next'
import ClientScripts from './ClientScripts'
import './frontend.css'
export const metadata: Metadata = {
title: {
default: 'REBOURS — Mobilier d\u2019art contemporain | Collection 001',
template: '%s — REBOURS',
},
description:
'REBOURS Studio crée du mobilier d\u2019art contemporain inspiré du Space Age et du mouvement Memphis. Pièces uniques fabriquées à Paris. Collection 001 en cours.',
keywords: 'mobilier art, design contemporain, space age, memphis design, lampe béton, Paris, pièce unique',
authors: [{ name: 'REBOURS Studio' }],
robots: 'index, follow',
metadataBase: new URL(process.env.NEXT_PUBLIC_SERVER_URL || 'https://rebours.studio'),
openGraph: {
type: 'website',
locale: 'fr_FR',
siteName: 'REBOURS Studio',
images: ['/assets/lamp-violet.jpg'],
},
twitter: {
card: 'summary_large_image',
images: ['/assets/lamp-violet.jpg'],
},
icons: {
icon: [
{ url: '/favicon.ico' },
{ url: '/favicon-32x32.png', sizes: '32x32', type: 'image/png' },
{ url: '/favicon-16x16.png', sizes: '16x16', type: 'image/png' },
],
apple: '/apple-touch-icon.png',
},
}
export default function FrontendLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="fr">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400&display=swap"
/>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
{children}
<ClientScripts />
</body>
</html>
)
}

View File

@ -0,0 +1,62 @@
import type { Metadata } from 'next'
import ReboursPage from './ReboursPage'
import { getHomePage, getPublishedProducts, mediaUrl } from '@/lib/payload'
export async function generateMetadata(): Promise<Metadata> {
const home = await getHomePage()
return {
title: home?.seoTitle || 'REBOURS — Mobilier d\u2019art contemporain | Collection 001',
description:
home?.seoDescription ||
'REBOURS Studio crée du mobilier d\u2019art contemporain inspiré du Space Age et du mouvement Memphis. Pièces uniques fabriquées à Paris. Collection 001 en cours.',
alternates: { canonical: '/' },
}
}
export const dynamic = 'force-dynamic'
export default async function Page() {
const [products, home] = await Promise.all([getPublishedProducts(), getHomePage()])
const firstImage =
mediaUrl(home?.heroImage as never, 'feature') ||
(products[0]?.images?.[0] ? mediaUrl((products[0].images[0] as { image: unknown }).image as never, 'feature') : null)
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) => {
const img = (p.images?.[0] as { image: unknown } | undefined)?.image
return {
'@type': 'Offer',
itemOffered: {
'@type': 'Product',
name: p.productDisplayName,
description: p.seoDescription || p.description?.substring(0, 155),
image: mediaUrl(img as never, 'feature') ?? undefined,
},
price: String((p.price || 0) / 100),
priceCurrency: p.currency || 'EUR',
availability: p.availability,
}
}),
},
}
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaOrg) }} />
<ReboursPage products={products} home={home} />
</>
)
}

View File

@ -0,0 +1,45 @@
'use client'
import { useEffect } from 'react'
export default function SuccessClient() {
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const sessionId = params.get('session_id')
const loading = document.getElementById('loading')
if (!sessionId) {
if (loading) loading.textContent = 'Commande enregistrée.'
return
}
fetch(`/api/session/${sessionId}`)
.then((r) => r.json())
.then((data) => {
const orderDetails = document.getElementById('order-details') as HTMLElement | null
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 = String(data.product).replace(/-/g, '_').toUpperCase()
if (data.receipt_url) {
const btn = document.getElementById('receipt-btn') as HTMLAnchorElement | null
if (btn) {
btn.href = data.receipt_url
btn.style.display = 'inline-block'
}
}
})
.catch(() => {
if (loading) loading.textContent = 'Commande enregistrée.'
})
}, [])
return null
}

View File

@ -0,0 +1,155 @@
import type { Metadata } from 'next'
import SuccessClient from './SuccessClient'
export const metadata: Metadata = {
title: 'REBOURS — COMMANDE CONFIRMÉE',
description: 'Votre commande REBOURS Studio a été confirmée.',
robots: { index: false, follow: false },
alternates: { canonical: '/success' },
}
export default function SuccessPage() {
return (
<>
<style>{`
main.success-main {
flex-grow: 1;
display: grid;
grid-template-columns: 1fr 1fr;
}
.success-main .left {
border-right: var(--border);
padding: 5rem 2rem;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 2rem;
position: relative;
overflow: hidden;
}
.success-main .left::after {
content: '';
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.25);
pointer-events: none;
}
.success-main .slabel { font-size: 0.75rem; color: #888; }
.success-main h1 {
font-size: clamp(2.5rem, 5vw, 4.5rem);
font-weight: 700;
line-height: 0.95;
letter-spacing: -0.02em;
}
.success-main .status-line { font-size: 0.82rem; line-height: 1.8; color: #555; }
.success-main .right {
padding: 5rem 2rem;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 1.5rem;
}
.success-main .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;
}
.success-main .info-key { color: #888; width: 8rem; flex-shrink: 0; font-size: 0.72rem; }
.success-main 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;
}
.success-main a.back:hover { background: var(--clr-black); color: var(--clr-white); }
.success-main #receipt-btn:hover { background: var(--clr-red) !important; color: var(--clr-black) !important; border-color: var(--clr-red) !important; }
.success-main #loading { color: #888; font-size: 0.78rem; }
@media (max-width: 600px) {
main.success-main { grid-template-columns: 1fr; }
.success-main .left {
padding: 2rem 1.5rem;
min-height: 40vw;
border-right: none;
border-bottom: var(--border);
}
.success-main h1 { font-size: clamp(2rem, 12vw, 3rem); }
.success-main .right { padding: 2rem 1.5rem; justify-content: flex-start; }
.success-main a.back { align-self: stretch; text-align: center; }
}
`}</style>
<div id="interactive-grid" className="interactive-grid"></div>
<div style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh', position: 'relative', zIndex: 1 }}>
<header className="header">
<a href="/" className="logo-text">REBOURS</a>
<span style={{ fontSize: '0.78rem', color: '#888' }}>COLLECTION_001</span>
</header>
<main className="success-main">
<div className="left">
<p className="slabel" style={{ position: 'relative' }}>// COMMANDE_CONFIRMÉE</p>
<h1 style={{ position: 'relative' }}>
MERCI<br />POUR<br />VOTRE<br />COMMANDE
</h1>
<p className="status-line" id="loading" style={{ position: 'relative' }}>Vérification du paiement...</p>
</div>
<div className="right">
<p className="slabel">// RÉCAPITULATIF</p>
<hr />
<div id="order-details" style={{ display: 'none', flexDirection: 'column', gap: 0 }}>
<div className="info-row"><span className="info-key">PRODUIT</span><span id="product-display"></span></div>
<div className="info-row"><span className="info-key">COLLECTION</span><span>001 ÉDITION UNIQUE</span></div>
<div className="info-row"><span className="info-key">MONTANT</span><span id="amount-display"></span></div>
<div className="info-row"><span className="info-key">EMAIL</span><span id="email-display"></span></div>
<div className="info-row"><span className="info-key">DÉLAI</span><span>6 À 8 SEMAINES</span></div>
<div className="info-row"><span className="info-key">STATUS</span><span style={{ color: 'var(--clr-red)', fontWeight: 700 }}>CONFIRMÉ </span></div>
</div>
<p style={{ fontSize: '0.78rem', lineHeight: 1.8, color: '#555', marginTop: '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', flexWrap: 'wrap' }}>
<a href="/" className="back"> RETOUR</a>
<a
id="receipt-btn"
href="#"
target="_blank"
rel="noopener"
className="back"
style={{ display: 'none', background: 'var(--clr-black)', color: '#e8a800', borderColor: 'var(--clr-black)' }}
>
FACTURE PDF
</a>
</div>
</div>
</main>
<footer className="footer">
<span>© 2026 REBOURS STUDIO PARIS</span>
<nav aria-label="Liens secondaires">
<a href="https://instagram.com/rebour.studio" rel="noopener" target="_blank">INSTAGRAM</a>
&nbsp;/&nbsp;
<a href="mailto:contact@rebours.studio">CONTACT</a>
</nav>
</footer>
</div>
<div className="cursor-dot"></div>
<div className="cursor-outline"></div>
<SuccessClient />
</>
)
}

View File

@ -0,0 +1,17 @@
import type { Metadata } from 'next'
import config from '@payload-config'
import { NotFoundPage, generatePageMetadata } from '@payloadcms/next/views'
import { importMap } from '../importMap.js'
type Args = {
params: Promise<{ segments: string[] }>
searchParams: Promise<{ [key: string]: string | string[] }>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const NotFound = ({ params, searchParams }: Args) =>
NotFoundPage({ config, params, searchParams, importMap })
export default NotFound

View File

@ -0,0 +1,17 @@
import type { Metadata } from 'next'
import config from '@payload-config'
import { RootPage, generatePageMetadata } from '@payloadcms/next/views'
import { importMap } from '../importMap.js'
type Args = {
params: Promise<{ segments: string[] }>
searchParams: Promise<{ [key: string]: string | string[] }>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const Page = ({ params, searchParams }: Args) =>
RootPage({ config, params, searchParams, importMap })
export default Page

View File

@ -0,0 +1,6 @@
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
/** @type import('payload').ImportMap */
export const importMap = {
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
}

View File

@ -0,0 +1,16 @@
import config from '@payload-config'
import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} from '@payloadcms/next/routes'
export const GET = REST_GET(config)
export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config)
export const PUT = REST_PUT(config)
export const OPTIONS = REST_OPTIONS(config)

View File

@ -0,0 +1,4 @@
import config from '@payload-config'
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
export const GET = GRAPHQL_PLAYGROUND_GET(config)

View File

@ -0,0 +1,5 @@
import config from '@payload-config'
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes'
export const POST = GRAPHQL_POST(config)
export const OPTIONS = REST_OPTIONS(config)

View File

@ -0,0 +1,20 @@
import type { ServerFunctionClient } from 'payload'
import { RootLayout, handleServerFunctions } from '@payloadcms/next/layouts'
import config from '@payload-config'
import { importMap } from './admin/importMap.js'
import '@payloadcms/next/css'
type Args = { children: React.ReactNode }
const serverFunction: ServerFunctionClient = async function (args) {
'use server'
return handleServerFunctions({ ...args, config, importMap })
}
const Layout = ({ children }: Args) => (
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
{children}
</RootLayout>
)
export default Layout

11
nextjs/src/app/robots.ts Normal file
View File

@ -0,0 +1,11 @@
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
const base = process.env.NEXT_PUBLIC_SERVER_URL || 'https://rebours.studio'
return {
rules: [
{ userAgent: '*', allow: '/', disallow: ['/admin', '/api'] },
],
sitemap: `${base}/sitemap.xml`,
}
}

18
nextjs/src/app/sitemap.ts Normal file
View File

@ -0,0 +1,18 @@
import type { MetadataRoute } from 'next'
import { getPublishedProducts } from '@/lib/payload'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const base = process.env.NEXT_PUBLIC_SERVER_URL || 'https://rebours.studio'
const products = await getPublishedProducts().catch(() => [])
const now = new Date()
return [
{ url: `${base}/`, lastModified: now, changeFrequency: 'weekly', priority: 1.0 },
...products.map((p) => ({
url: `${base}/collection/${p.slug}`,
lastModified: now,
changeFrequency: 'weekly' as const,
priority: 0.8,
})),
]
}

View File

@ -0,0 +1,28 @@
import type { CollectionConfig } from 'payload'
export const Media: CollectionConfig = {
slug: 'media',
admin: {
group: 'Système',
},
access: {
read: () => true,
},
upload: {
staticDir: 'media',
imageSizes: [
{ name: 'thumbnail', width: 400, height: undefined, position: 'centre' },
{ name: 'card', width: 800, height: undefined, position: 'centre' },
{ name: 'feature', width: 1600, height: undefined, position: 'centre' },
],
mimeTypes: ['image/*'],
},
fields: [
{
name: 'alt',
type: 'text',
label: 'Texte alternatif',
required: true,
},
],
}

View File

@ -0,0 +1,207 @@
import type { CollectionConfig } from 'payload'
export const Products: CollectionConfig = {
slug: 'products',
labels: {
singular: 'Produit',
plural: 'Produits',
},
admin: {
useAsTitle: 'productDisplayName',
defaultColumns: ['productDisplayName', 'index', 'type', 'price', 'isPublished'],
group: 'Contenu',
livePreview: {
url: ({ data }) =>
`${process.env.NEXT_PUBLIC_SERVER_URL ?? 'http://localhost:3000'}/collection/${data?.slug}?preview=true`,
},
},
access: {
read: () => true,
},
versions: {
drafts: {
autosave: { interval: 800 },
},
},
fields: [
{
type: 'tabs',
tabs: [
{
label: 'Identité',
fields: [
{
name: 'productDisplayName',
label: 'Nom affiché',
type: 'text',
required: true,
admin: { description: 'Le nom visible par le public (ex: Solar Altar)' },
},
{
name: 'name',
label: 'Nom technique',
type: 'text',
required: true,
admin: { description: 'Nom interne sans espaces (ex: Solar_Altar)' },
},
{
name: 'slug',
label: 'URL (slug)',
type: 'text',
required: true,
unique: true,
index: true,
admin: { description: 'Ex: solar-altar → /collection/solar-altar' },
},
{
name: 'index',
label: 'Index projet',
type: 'text',
required: true,
admin: { description: 'Ex: PROJET_001' },
},
{
name: 'type',
label: 'Type',
type: 'text',
required: true,
admin: { description: 'Ex: LAMPE DE TABLE' },
},
{
name: 'materials',
label: 'Matériaux',
type: 'text',
required: true,
},
{
name: 'year',
label: 'Année',
type: 'text',
required: true,
defaultValue: '2026',
},
{
name: 'status',
label: 'Statut',
type: 'text',
required: true,
admin: { description: 'Ex: PROTOTYPE [80%]' },
},
{
name: 'sortOrder',
label: 'Ordre d\u2019affichage',
type: 'number',
defaultValue: 0,
},
{
name: 'isPublished',
label: 'Publié',
type: 'checkbox',
defaultValue: true,
},
],
},
{
label: 'Contenu',
fields: [
{
name: 'description',
label: 'Description',
type: 'textarea',
required: true,
},
{
name: 'specs',
label: 'Spécifications',
type: 'textarea',
},
{
name: 'notes',
label: 'Notes',
type: 'textarea',
},
],
},
{
label: 'Images',
fields: [
{
name: 'images',
label: 'Images',
type: 'array',
minRows: 1,
required: true,
fields: [
{
name: 'image',
type: 'upload',
relationTo: 'media',
required: true,
},
],
},
],
},
{
label: 'Commerce',
fields: [
{
name: 'price',
label: 'Prix (en centimes)',
type: 'number',
admin: {
description: '180000 = 1 800 EUR. Vide = non disponible à la vente.',
},
},
{
name: 'currency',
label: 'Devise',
type: 'select',
defaultValue: 'EUR',
options: [
{ label: 'EUR', value: 'EUR' },
{ label: 'USD', value: 'USD' },
],
},
{
name: 'availability',
label: 'Disponibilité (schema.org)',
type: 'select',
defaultValue: 'https://schema.org/InStock',
options: [
{ label: 'En stock', value: 'https://schema.org/InStock' },
{ label: 'Disponibilité limitée', value: 'https://schema.org/LimitedAvailability' },
{ label: 'Sur commande', value: 'https://schema.org/PreOrder' },
{ label: 'Indisponible', value: 'https://schema.org/OutOfStock' },
],
},
{
name: 'stripeProductID',
type: 'text',
admin: {
readOnly: true,
description: 'Synchronisé automatiquement avec Stripe',
},
},
],
},
{
label: 'SEO',
fields: [
{
name: 'seoTitle',
label: 'Titre SEO',
type: 'text',
admin: { description: 'Laissez vide pour utiliser le nom du produit' },
},
{
name: 'seoDescription',
label: 'Description SEO',
type: 'textarea',
},
],
},
],
},
],
}

View File

@ -0,0 +1,16 @@
import type { CollectionConfig } from 'payload'
export const Users: CollectionConfig = {
slug: 'users',
admin: {
useAsTitle: 'email',
group: 'Système',
},
auth: true,
fields: [
{
name: 'name',
type: 'text',
},
],
}

View File

@ -0,0 +1,80 @@
import type { GlobalConfig } from 'payload'
export const HomePage: GlobalConfig = {
slug: 'homePage',
label: 'Page d\u2019accueil',
admin: {
group: 'Contenu',
livePreview: {
url: () => `${process.env.NEXT_PUBLIC_SERVER_URL ?? 'http://localhost:3000'}/?preview=true`,
},
},
access: {
read: () => true,
},
fields: [
{
type: 'tabs',
tabs: [
{
label: 'Hero',
fields: [
{ name: 'heroLabel', label: 'Label', type: 'text', defaultValue: '// ARCHIVE_001 — 2026' },
{
name: 'heroTitle',
label: 'Titre',
type: 'text',
admin: { description: 'Utilisez | pour passer à la ligne' },
},
{ name: 'heroSubtitle', label: 'Sous-titre', type: 'textarea' },
{ name: 'heroStatus', label: 'Statut', type: 'textarea' },
{
name: 'heroImage',
label: 'Image hero',
type: 'upload',
relationTo: 'media',
admin: { description: 'Si vide, utilise la première image de la collection' },
},
],
},
{
label: 'Collection',
fields: [
{ name: 'collectionLabel', type: 'text' },
{ name: 'collectionCta', label: 'Texte CTA', type: 'text', defaultValue: 'CLIQUER POUR OUVRIR' },
],
},
{
label: 'Contact',
fields: [
{ name: 'contactLabel', type: 'text' },
{
name: 'contactTitle',
label: 'Titre',
type: 'text',
admin: { description: 'Utilisez | pour passer à la ligne' },
},
{ name: 'contactDescription', type: 'textarea' },
{ name: 'whatsappNumber', label: 'Numéro WhatsApp', type: 'text', required: true, defaultValue: '33651755191' },
{ name: 'whatsappButtonText', type: 'text' },
{ name: 'contactResponseTime', type: 'text' },
],
},
{
label: 'Footer',
fields: [
{ name: 'footerText', type: 'text' },
{ name: 'instagramUrl', type: 'text' },
],
},
{
label: 'SEO',
fields: [
{ name: 'seoTitle', type: 'text' },
{ name: 'seoDescription', type: 'textarea' },
],
},
],
},
],
}

53
nextjs/src/lib/payload.ts Normal file
View File

@ -0,0 +1,53 @@
import { getPayload } from 'payload'
import config from '@payload-config'
import { cache } from 'react'
export const getPayloadClient = cache(async () => {
return await getPayload({ config })
})
export async function getPublishedProducts() {
const payload = await getPayloadClient()
const res = await payload.find({
collection: 'products',
where: { isPublished: { equals: true } },
sort: 'sortOrder',
limit: 100,
depth: 2,
})
return res.docs
}
export async function getProductBySlug(slug: string) {
const payload = await getPayloadClient()
const res = await payload.find({
collection: 'products',
where: { slug: { equals: slug }, isPublished: { equals: true } },
limit: 1,
depth: 2,
})
return res.docs[0] ?? null
}
export async function getHomePage() {
const payload = await getPayloadClient()
return await payload.findGlobal({ slug: 'homePage', depth: 2 })
}
type MediaLike = {
url?: string | null
alt?: string | null
sizes?: Record<string, { url?: string | null }>
} | string | null | undefined
export function mediaUrl(media: MediaLike, size: 'thumbnail' | 'card' | 'feature' | 'original' = 'original'): string | null {
if (!media) return null
if (typeof media === 'string') return media
if (size !== 'original' && media.sizes?.[size]?.url) return media.sizes[size]!.url!
return media.url ?? null
}
export function mediaAlt(media: MediaLike, fallback = ''): string {
if (!media || typeof media === 'string') return fallback
return media.alt ?? fallback
}

573
nextjs/src/payload-types.ts Normal file
View File

@ -0,0 +1,573 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
/**
* Supported timezones in IANA format.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supportedTimezones".
*/
export type SupportedTimezones =
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji';
export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
collections: {
users: User;
media: Media;
products: Product;
'payload-kv': PayloadKv;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {};
collectionsSelect: {
users: UsersSelect<false> | UsersSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
products: ProductsSelect<false> | ProductsSelect<true>;
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: number;
};
fallbackLocale: null;
globals: {
homePage: HomePage;
};
globalsSelect: {
homePage: HomePageSelect<false> | HomePageSelect<true>;
};
locale: null;
widgets: {
collections: CollectionsWidget;
};
user: User;
jobs: {
tasks: unknown;
workflows: unknown;
};
}
export interface UserAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: number;
name?: string | null;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
collection: 'users';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
*/
export interface Media {
id: number;
alt: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
sizes?: {
thumbnail?: {
url?: string | null;
width?: number | null;
height?: number | null;
mimeType?: string | null;
filesize?: number | null;
filename?: string | null;
};
card?: {
url?: string | null;
width?: number | null;
height?: number | null;
mimeType?: string | null;
filesize?: number | null;
filename?: string | null;
};
feature?: {
url?: string | null;
width?: number | null;
height?: number | null;
mimeType?: string | null;
filesize?: number | null;
filename?: string | null;
};
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "products".
*/
export interface Product {
id: number;
/**
* Le nom visible par le public (ex: Solar Altar)
*/
productDisplayName: string;
/**
* Nom interne sans espaces (ex: Solar_Altar)
*/
name: string;
/**
* Ex: solar-altar /collection/solar-altar
*/
slug: string;
/**
* Ex: PROJET_001
*/
index: string;
/**
* Ex: LAMPE DE TABLE
*/
type: string;
materials: string;
year: string;
/**
* Ex: PROTOTYPE [80%]
*/
status: string;
sortOrder?: number | null;
isPublished?: boolean | null;
description: string;
specs?: string | null;
notes?: string | null;
images: {
image: number | Media;
id?: string | null;
}[];
/**
* 180000 = 1 800 EUR. Vide = non disponible à la vente.
*/
price?: number | null;
currency?: ('EUR' | 'USD') | null;
availability?:
| (
| 'https://schema.org/InStock'
| 'https://schema.org/LimitedAvailability'
| 'https://schema.org/PreOrder'
| 'https://schema.org/OutOfStock'
)
| null;
/**
* Synchronisé automatiquement avec Stripe
*/
stripeProductID?: string | null;
/**
* Laissez vide pour utiliser le nom du produit
*/
seoTitle?: string | null;
seoDescription?: string | null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv".
*/
export interface PayloadKv {
id: number;
key: string;
data:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: number;
document?:
| ({
relationTo: 'users';
value: number | User;
} | null)
| ({
relationTo: 'media';
value: number | Media;
} | null)
| ({
relationTo: 'products';
value: number | Product;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: number | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: number;
user: {
relationTo: 'users';
value: number | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: number;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
name?: T;
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media_select".
*/
export interface MediaSelect<T extends boolean = true> {
alt?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
sizes?:
| T
| {
thumbnail?:
| T
| {
url?: T;
width?: T;
height?: T;
mimeType?: T;
filesize?: T;
filename?: T;
};
card?:
| T
| {
url?: T;
width?: T;
height?: T;
mimeType?: T;
filesize?: T;
filename?: T;
};
feature?:
| T
| {
url?: T;
width?: T;
height?: T;
mimeType?: T;
filesize?: T;
filename?: T;
};
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "products_select".
*/
export interface ProductsSelect<T extends boolean = true> {
productDisplayName?: T;
name?: T;
slug?: T;
index?: T;
type?: T;
materials?: T;
year?: T;
status?: T;
sortOrder?: T;
isPublished?: T;
description?: T;
specs?: T;
notes?: T;
images?:
| T
| {
image?: T;
id?: T;
};
price?: T;
currency?: T;
availability?: T;
stripeProductID?: T;
seoTitle?: T;
seoDescription?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv_select".
*/
export interface PayloadKvSelect<T extends boolean = true> {
key?: T;
data?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
*/
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences_select".
*/
export interface PayloadPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select".
*/
export interface PayloadMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "homePage".
*/
export interface HomePage {
id: number;
heroLabel?: string | null;
/**
* Utilisez | pour passer à la ligne
*/
heroTitle?: string | null;
heroSubtitle?: string | null;
heroStatus?: string | null;
/**
* Si vide, utilise la première image de la collection
*/
heroImage?: (number | null) | Media;
collectionLabel?: string | null;
collectionCta?: string | null;
contactLabel?: string | null;
/**
* Utilisez | pour passer à la ligne
*/
contactTitle?: string | null;
contactDescription?: string | null;
whatsappNumber: string;
whatsappButtonText?: string | null;
contactResponseTime?: string | null;
footerText?: string | null;
instagramUrl?: string | null;
seoTitle?: string | null;
seoDescription?: string | null;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "homePage_select".
*/
export interface HomePageSelect<T extends boolean = true> {
heroLabel?: T;
heroTitle?: T;
heroSubtitle?: T;
heroStatus?: T;
heroImage?: T;
collectionLabel?: T;
collectionCta?: T;
contactLabel?: T;
contactTitle?: T;
contactDescription?: T;
whatsappNumber?: T;
whatsappButtonText?: T;
contactResponseTime?: T;
footerText?: T;
instagramUrl?: T;
seoTitle?: T;
seoDescription?: T;
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "collections_widget".
*/
export interface CollectionsWidget {
data?: {
[k: string]: unknown;
};
width: 'full';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
export interface GeneratedTypes extends Config {}
}

View File

@ -0,0 +1,71 @@
import path from 'path'
import { fileURLToPath } from 'url'
import { buildConfig } from 'payload'
import { postgresAdapter } from '@payloadcms/db-postgres'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { stripePlugin } from '@payloadcms/plugin-stripe'
import sharp from 'sharp'
import { Users } from './collections/Users'
import { Media } from './collections/Media'
import { Products } from './collections/Products'
import { HomePage } from './globals/HomePage'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
const STRIPE_KEY = process.env.STRIPE_SECRET_KEY || ''
const STRIPE_KEY_IS_REAL = /^sk_(test|live)_[A-Za-z0-9]{20,}$/.test(STRIPE_KEY)
export default buildConfig({
admin: {
user: Users.slug,
meta: {
titleSuffix: ' — REBOURS',
},
livePreview: {
breakpoints: [
{ label: 'Mobile', name: 'mobile', width: 375, height: 667 },
{ label: 'Tablette', name: 'tablet', width: 768, height: 1024 },
{ label: 'Desktop', name: 'desktop', width: 1440, height: 900 },
],
},
},
collections: [Users, Media, Products],
globals: [HomePage],
editor: lexicalEditor({}),
secret: process.env.PAYLOAD_SECRET || 'change-me',
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
db: postgresAdapter({
pool: {
connectionString: process.env.DATABASE_URL || '',
},
// Auto-sync schema on boot (small single-tenant CMS — no manual migrations)
push: true,
}),
sharp,
plugins: [
stripePlugin({
stripeSecretKey: STRIPE_KEY,
stripeWebhooksEndpointSecret: process.env.STRIPE_WEBHOOK_SECRET,
isTestKey: !STRIPE_KEY.startsWith('sk_live_'),
rest: false,
sync: STRIPE_KEY_IS_REAL
? [
{
collection: 'products',
stripeResourceType: 'products',
stripeResourceTypeSingular: 'product',
fields: [
{ fieldPath: 'productDisplayName', stripeProperty: 'name' },
{ fieldPath: 'description', stripeProperty: 'description' },
],
},
]
: [],
}),
],
cors: '*',
})

688
nextjs/src/scripts/main.js Normal file
View File

@ -0,0 +1,688 @@
/**
* 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);
function reboursInit() {
// ---- 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');
});
}
}
if (typeof window !== 'undefined' && !window.__reboursInitDone) {
window.__reboursInitDone = true;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', reboursInit);
} else {
reboursInit();
}
}

24
nextjs/tsconfig.json Normal file
View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"],
"@payload-config": ["./src/payload.config.ts"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}