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>
@ -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: |
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
accessModes: [ReadWriteOnce]
|
||||
storageClassName: local-path
|
||||
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
|
||||
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
@ -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
|
||||
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
BIN
nextjs/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
nextjs/public/assets/atelier-ambiance.mp3
Normal file
BIN
nextjs/public/assets/lamp-violet.jpg
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
nextjs/public/assets/lampes-serie.jpg
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
nextjs/public/assets/logo.png
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
nextjs/public/assets/table-terrazzo.jpg
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
nextjs/public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 544 B |
BIN
nextjs/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
nextjs/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
1311
nextjs/public/style.css
Normal file
14
nextjs/src/app/(frontend)/ClientScripts.tsx
Normal 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
|
||||
}
|
||||
323
nextjs/src/app/(frontend)/ReboursPage.tsx
Normal 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>
|
||||
/
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
197
nextjs/src/app/(frontend)/api/admin/seed/route.ts
Normal 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: 35–65cm (7 tailles)\nDôme: Ø15–28cm\nAnneau néon: 8W — 3000K\nÉdition: 7 ex. par coloris',
|
||||
notes: 'Les corps sont coulés en série mais peints individuellement. Les dômes sont réalisés par un souffleur de verre artisanal. Le câble tressé rouge est la signature de la série.',
|
||||
imageFile: '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 })
|
||||
}
|
||||
}
|
||||
63
nextjs/src/app/(frontend)/api/checkout/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
5
nextjs/src/app/(frontend)/api/health/route.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ status: 'ok' })
|
||||
}
|
||||
32
nextjs/src/app/(frontend)/api/session/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
36
nextjs/src/app/(frontend)/api/webhook/route.ts
Normal 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 })
|
||||
}
|
||||
77
nextjs/src/app/(frontend)/collection/[slug]/page.tsx
Normal 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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
1
nextjs/src/app/(frontend)/frontend.css
Normal file
@ -0,0 +1 @@
|
||||
/* Empty — frontend styles are served from /public/style.css */
|
||||
54
nextjs/src/app/(frontend)/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
nextjs/src/app/(frontend)/page.tsx
Normal 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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
45
nextjs/src/app/(frontend)/success/SuccessClient.tsx
Normal 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
|
||||
}
|
||||
155
nextjs/src/app/(frontend)/success/page.tsx
Normal 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>
|
||||
/
|
||||
<a href="mailto:contact@rebours.studio">CONTACT</a>
|
||||
</nav>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div className="cursor-dot"></div>
|
||||
<div className="cursor-outline"></div>
|
||||
|
||||
<SuccessClient />
|
||||
</>
|
||||
)
|
||||
}
|
||||
17
nextjs/src/app/(payload)/admin/[[...segments]]/not-found.tsx
Normal 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
|
||||
17
nextjs/src/app/(payload)/admin/[[...segments]]/page.tsx
Normal 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
|
||||
6
nextjs/src/app/(payload)/admin/importMap.js
Normal 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
|
||||
}
|
||||
16
nextjs/src/app/(payload)/api/[...slug]/route.ts
Normal 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)
|
||||
4
nextjs/src/app/(payload)/api/graphql-playground/route.ts
Normal 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)
|
||||
5
nextjs/src/app/(payload)/api/graphql/route.ts
Normal 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)
|
||||
20
nextjs/src/app/(payload)/layout.tsx
Normal 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
@ -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
@ -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,
|
||||
})),
|
||||
]
|
||||
}
|
||||
28
nextjs/src/collections/Media.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
}
|
||||
207
nextjs/src/collections/Products.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
16
nextjs/src/collections/Users.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
}
|
||||
80
nextjs/src/globals/HomePage.ts
Normal 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
@ -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
@ -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 {}
|
||||
}
|
||||
71
nextjs/src/payload.config.ts
Normal 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
@ -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
@ -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"]
|
||||
}
|
||||