diff --git a/Dockerfile b/Dockerfile
index b6fbe96..58304cb 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,40 +1,23 @@
# ─────────────────────────────────────────────────────────────────────────────
-# REBOUR — Dockerfile
-# Runtime : Bun + Elysia (pas de SSR, le HTML est statique dans public/)
-# Stratégie : multi-stage pour image finale la plus légère possible
+# REBOUR — Dockerfile (API uniquement)
+# Le front est servi par nginx directement depuis public/
# ─────────────────────────────────────────────────────────────────────────────
-# ── Stage 1 : installation des dépendances ────────────────────────────────────
FROM oven/bun:1.3-alpine AS deps
-
WORKDIR /app
-
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production
-# ── Stage 2 : image de production ─────────────────────────────────────────────
FROM oven/bun:1.3-alpine AS runner
-
WORKDIR /app
-
-# Dépendances uniquement runtime
COPY --from=deps /app/node_modules ./node_modules
-
-# Code serveur
COPY server.ts ./
-# Fichiers publics (HTML statique + assets + CSS/JS)
-# Le HTML est pré-écrit, pas de build step nécessaire (pas de SSR/bundler)
-COPY public/ ./public/
-
-# Utilisateur non-root (sécurité)
USER bun
-
EXPOSE 3000
-
ENV NODE_ENV=production
-HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
+HEALTHCHECK --interval=5s --timeout=3s --start-period=5s --retries=5 \
CMD wget -qO- http://localhost:3000/robots.txt || exit 1
CMD ["bun", "run", "server.ts"]
diff --git a/Dockerfile.dev b/Dockerfile.dev
index ad2ed09..c2f754e 100644
--- a/Dockerfile.dev
+++ b/Dockerfile.dev
@@ -1,24 +1,13 @@
# ─────────────────────────────────────────────────────────────────────────────
-# REBOUR — Dockerfile.dev
-# Hot reload via `bun --hot` : redémarre le serveur à chaque changement de
-# server.ts. Les fichiers statiques (public/) sont montés en volume, donc
-# toute modif HTML/CSS/JS est immédiatement visible sans rebuild.
+# REBOUR — Dockerfile.dev (API avec hot reload)
+# Le front est servi par nginx ou accédé directement via bun dev
# ─────────────────────────────────────────────────────────────────────────────
+
FROM oven/bun:1.3-alpine
-
WORKDIR /app
-
-# Installe les dépendances (dev incluses pour les types)
COPY package.json bun.lock* ./
RUN bun install
-
-# Le code source est monté en volume (voir docker-compose.dev.yml),
-# on copie uniquement pour que l'image soit autonome si besoin.
COPY server.ts ./
-COPY public/ ./public/
-
EXPOSE 3000
-
ENV NODE_ENV=development
-
CMD ["bun", "--watch", "server.ts"]
diff --git a/assets/lamp-violet.jpg b/assets/lamp-violet.jpg
deleted file mode 100644
index 520c3d0..0000000
Binary files a/assets/lamp-violet.jpg and /dev/null differ
diff --git a/assets/lampes-serie.jpg b/assets/lampes-serie.jpg
deleted file mode 100644
index dec503d..0000000
Binary files a/assets/lampes-serie.jpg and /dev/null differ
diff --git a/assets/table-terrazzo.jpg b/assets/table-terrazzo.jpg
deleted file mode 100644
index 39ea02f..0000000
Binary files a/assets/table-terrazzo.jpg and /dev/null differ
diff --git a/docker-compose.yml b/docker-compose.yml
index e2c008c..e487eeb 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,13 +1,12 @@
services:
- # ── App Elysia/Bun ────────────────────────────────────────────────────────
+ # ── API Bun/Elysia — uniquement les routes /api/ ──────────────────────────
app:
build:
context: .
dockerfile: Dockerfile
target: runner
restart: unless-stopped
- # Port NON exposé publiquement : nginx est le seul point d'entrée
expose:
- "3000"
environment:
@@ -24,15 +23,16 @@ services:
retries: 5
start_period: 5s
- # ── Nginx : reverse proxy, gzip, cache headers, rate-limit API ───────────
+ # ── Nginx : sert public/ + proxifie /api/ vers app ────────────────────────
nginx:
image: nginx:1.27-alpine
- restart: on-failure
+ restart: unless-stopped
ports:
- "0.0.0.0:80:80"
- "0.0.0.0:443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
+ - ./public:/srv/public:ro
# En prod : décommenter + monter les certificats Let's Encrypt
# - /etc/letsencrypt:/etc/letsencrypt:ro
- nginx-logs:/var/log/nginx
diff --git a/index.html b/index.html
deleted file mode 100644
index fa70ac1..0000000
--- a/index.html
+++ /dev/null
@@ -1,310 +0,0 @@
-
-
-
-
-
-
-
- REBOUR — Mobilier d'art contemporain | Collection 001
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ← RETOUR
-
-
-
-
![]()
-
-
-
-
-
-
-
-
-
-
-
-
- SPÉCIFICATIONS TECHNIQUES ↓
-
-
-
- NOTES DE CONCEPTION ↓
-
-
-
-
-
-
-
-
- 1 800 €
- ÉDITION UNIQUE — 1/1
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
// ARCHIVE_001 — 2026
-
REBOUR
STUDIO
-
Mobilier d'art contemporain.
Space Age × Memphis.
-
STATUS: [PROTOTYPE EN COURS]
COLLECTION_001 — BIENTÔT DISPONIBLE
-
-
-

-
-
-
-
-
-
-
-
-
-
-
-
-
-

-
-
- 001
- LUMIÈRE_ORBITALE
- ↗
-
-
-
-
-
-

-
-
- 002
- TABLE_TERRAZZO
- ↗
-
-
-
-
-
-

-
-
- 003
- MODULE_SÉRIE
- ↗
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/main.js b/main.js
deleted file mode 100644
index 918f506..0000000
--- a/main.js
+++ /dev/null
@@ -1,195 +0,0 @@
-/**
- * REBOUR — Main Script
- */
-
-document.addEventListener('DOMContentLoaded', () => {
-
- // ---- CUSTOM CURSOR ----
- const cursorDot = document.querySelector('.cursor-dot');
- const cursorOutline = document.querySelector('.cursor-outline');
-
- window.addEventListener('mousemove', (e) => {
- cursorDot.style.left = `${e.clientX}px`;
- cursorDot.style.top = `${e.clientY}px`;
- cursorOutline.animate(
- { left: `${e.clientX}px`, top: `${e.clientY}px` },
- { duration: 100, fill: 'forwards' }
- );
- });
-
- const interactives = document.querySelectorAll('a, button, input, .product-card, summary, .panel-close');
- interactives.forEach(el => {
- el.addEventListener('mouseenter', () => {
- cursorOutline.style.width = '38px';
- cursorOutline.style.height = '38px';
- cursorDot.style.transform = 'translate(-50%, -50%) scale(0)';
- });
- el.addEventListener('mouseleave', () => {
- cursorOutline.style.width = '26px';
- cursorOutline.style.height = '26px';
- cursorDot.style.transform = 'translate(-50%, -50%) scale(1)';
- });
- });
-
- // ---- INTERACTIVE GRID ----
- const gridContainer = document.getElementById('interactive-grid');
- const CELL = 60;
- const COLORS = [
- 'rgba(160,160,155,0.3)',
- 'rgba(140,140,135,0.22)',
- 'rgba(120,120,115,0.18)',
- ];
-
- function buildGrid() {
- if (!gridContainer) return;
- gridContainer.innerHTML = '';
- 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); });
-
- // ---- PRODUCT PANEL ----
- const panel = document.getElementById('product-panel');
- const panelClose = document.getElementById('panel-close');
- const cards = document.querySelectorAll('.product-card');
-
- // Champs du panel
- const fields = {
- img: document.getElementById('panel-img'),
- 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'),
- };
-
- // ---- 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');
-
- // Toggle affichage du form
- checkoutToggleBtn.addEventListener('click', () => {
- const isOpen = checkoutFormWrap.style.display !== 'none';
- checkoutFormWrap.style.display = isOpen ? 'none' : 'block';
- checkoutToggleBtn.textContent = isOpen
- ? '[ COMMANDER CETTE PIÈCE ]'
- : '[ ANNULER ]';
- });
-
- // Submit → appel API Elysia → redirect Stripe
- checkoutForm.addEventListener('submit', async (e) => {
- e.preventDefault();
- 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: 'lumiere_orbitale', 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 openPanel(card) {
- fields.img.src = card.dataset.img;
- fields.img.alt = card.dataset.name;
- 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;
-
- // Affiche le bouton de commande uniquement pour PROJET_001
- const isOrderable = card.dataset.index === 'PROJET_001';
- checkoutSection.style.display = isOrderable ? 'block' : 'none';
- // Reset form state
- checkoutFormWrap.style.display = 'none';
- checkoutToggleBtn.textContent = '[ COMMANDER CETTE PIÈCE ]';
- checkoutSubmitBtn.disabled = false;
- checkoutSubmitBtn.textContent = 'PROCÉDER AU PAIEMENT →';
- checkoutForm.reset();
-
- // Ferme les accordéons
- panel.querySelectorAll('details').forEach(d => d.removeAttribute('open'));
-
- panel.classList.add('is-open');
- panel.setAttribute('aria-hidden', 'false');
- document.body.style.overflow = 'hidden';
-
- // Refresh cursor sur les nouveaux éléments
- panel.querySelectorAll('summary, .panel-close, .checkout-btn, .checkout-submit').forEach(el => {
- el.addEventListener('mouseenter', () => {
- cursorOutline.style.width = '38px';
- cursorOutline.style.height = '38px';
- cursorDot.style.transform = 'translate(-50%, -50%) scale(0)';
- });
- el.addEventListener('mouseleave', () => {
- cursorOutline.style.width = '26px';
- cursorOutline.style.height = '26px';
- cursorDot.style.transform = 'translate(-50%, -50%) scale(1)';
- });
- });
- }
-
- function closePanel() {
- panel.classList.remove('is-open');
- panel.setAttribute('aria-hidden', 'true');
- document.body.style.overflow = '';
- }
-
- cards.forEach(card => {
- card.addEventListener('click', () => openPanel(card));
- });
-
- panelClose.addEventListener('click', closePanel);
-
- // Echap pour fermer
- document.addEventListener('keydown', (e) => {
- if (e.key === 'Escape') closePanel();
- });
-
-});
diff --git a/nginx.conf b/nginx.conf
index 3ba32e4..62b2783 100644
--- a/nginx.conf
+++ b/nginx.conf
@@ -1,8 +1,6 @@
# ─────────────────────────────────────────────────────────────────────────────
# REBOUR — nginx.conf
-# Rôle : reverse proxy devant Elysia/Bun
-# SEO : pas de SSR → HTML statique pré-rendu, on optimise le delivery
-# (gzip, cache, headers) et la crawlabilité
+# nginx sert public/ directement + proxifie /api/ vers Bun
# ─────────────────────────────────────────────────────────────────────────────
user nginx;
@@ -12,52 +10,39 @@ pid /var/run/nginx.pid;
events {
worker_connections 1024;
- use epoll;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
- log_format main '$remote_addr "$request" $status $body_bytes_sent '
- '"$http_referer" "$http_user_agent" ${request_time}s';
+ log_format main '$remote_addr "$request" $status $body_bytes_sent "${request_time}s"';
access_log /var/log/nginx/access.log main;
- sendfile on;
- tcp_nopush on;
- tcp_nodelay on;
- keepalive_timeout 65;
- server_tokens off;
+ sendfile on;
+ tcp_nopush on;
+ tcp_nodelay on;
+ keepalive_timeout 65;
+ server_tokens off;
- # ── Gzip : réduit poids HTML/CSS/JS → Core Web Vitals ───────────────────
- gzip on;
- gzip_vary on;
- gzip_proxied any;
- gzip_comp_level 5;
- gzip_min_length 256;
+ # ── Gzip ─────────────────────────────────────────────────────────────────
+ gzip on;
+ gzip_vary on;
+ gzip_comp_level 5;
+ gzip_min_length 256;
gzip_types
text/plain text/css text/javascript text/xml
application/javascript application/json application/xml
image/svg+xml font/woff2;
- # ── Rate limiting ────────────────────────────────────────────────────────
- limit_req_zone $binary_remote_addr zone=api:10m rate=10r/m;
- limit_req_zone $binary_remote_addr zone=general:10m rate=60r/s;
+ # ── Rate limiting ─────────────────────────────────────────────────────────
+ limit_req_zone $binary_remote_addr zone=api:10m rate=10r/m;
- # ── Resolver DNS Docker ───────────────────────────────────────────────────
- # 127.0.0.11 = resolver interne Docker. La résolution dynamique via variable
- # $backend évite le crash "host not found in upstream" au démarrage nginx
- # si le container "app" n'est pas encore prêt (upstream statique résout au
- # boot, pas à la requête — d'où le crash).
+ # ── Resolver Docker (résolution dynamique → pas de crash au boot) ─────────
resolver 127.0.0.11 valid=5s ipv6=off;
+ map $host $api_backend { default "http://app:3000"; }
- # Variable résolue dynamiquement à chaque requête (pas au démarrage nginx)
- # Permet à nginx de démarrer même si "app" n'existe pas encore en DNS.
- map $host $elysia_backend {
- default "http://app:3000";
- }
-
- # ── Redirection HTTP → HTTPS (décommenter en prod) ──────────────────────
+ # ── Redirection HTTP → HTTPS (décommenter en prod) ────────────────────────
# server {
# listen 80;
# server_name rebour.studio www.rebour.studio;
@@ -68,44 +53,27 @@ http {
listen 80;
server_name _;
- # ── SSL / prod (décommenter avec Certbot) ────────────────────────────
- # listen 443 ssl http2;
- # ssl_certificate /etc/letsencrypt/live/rebour.studio/fullchain.pem;
- # ssl_certificate_key /etc/letsencrypt/live/rebour.studio/privkey.pem;
- # ssl_protocols TLSv1.2 TLSv1.3;
- # ssl_session_cache shared:SSL:10m;
+ # Dossier public servi directement par nginx
+ root /srv/public;
+ index index.html;
- # ── Headers sécurité ────────────────────────────────────────────────
- add_header X-Frame-Options "SAMEORIGIN" always;
- add_header X-Content-Type-Options "nosniff" always;
- add_header Referrer-Policy "strict-origin-when-cross-origin" always;
- add_header Permissions-Policy "camera=(), microphone=()" always;
- # HSTS — décommenter en prod UNIQUEMENT après config SSL
- # add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
+ # ── Headers sécurité ─────────────────────────────────────────────────
+ add_header X-Frame-Options "SAMEORIGIN" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+ add_header Permissions-Policy "camera=(), microphone=()" always;
- # ── Images / fonts : cache 1 an immutable ───────────────────────────
- # Les crawlers sociaux (og:image) et Google téléchargent ces fichiers
- location ~* \.(jpg|jpeg|png|webp|svg|ico|woff2)$ {
- proxy_pass $elysia_backend;
- proxy_set_header Host $host;
- proxy_set_header X-Forwarded-Proto $scheme;
- add_header Cache-Control "public, max-age=31536000, immutable";
- add_header Vary "Accept-Encoding";
+ # ── Assets statiques : cache 1 an immutable ───────────────────────────
+ location ~* \.(jpg|jpeg|png|webp|svg|ico|woff2|css|js)$ {
+ expires 1y;
+ add_header Cache-Control "public, max-age=31536000, immutable";
+ add_header Vary "Accept-Encoding";
}
- # ── CSS / JS : cache 1 an ────────────────────────────────────────────
- location ~* \.(css|js)$ {
- proxy_pass $elysia_backend;
- proxy_set_header Host $host;
- proxy_set_header X-Forwarded-Proto $scheme;
- add_header Cache-Control "public, max-age=31536000, immutable";
- add_header Vary "Accept-Encoding";
- }
-
- # ── Checkout Stripe : rate-limit strict, no-cache ───────────────────
- location = /api/checkout {
- limit_req zone=api burst=3 nodelay;
- proxy_pass $elysia_backend;
+ # ── API → proxy vers Bun ──────────────────────────────────────────────
+ location /api/ {
+ limit_req zone=api burst=10 nodelay;
+ proxy_pass $api_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
@@ -115,9 +83,10 @@ http {
add_header Cache-Control "no-store";
}
- # ── Webhook Stripe : body raw, pas de buffering ──────────────────────
+ # ── Webhook Stripe : pas de buffering ─────────────────────────────────
location = /api/webhook {
- proxy_pass $elysia_backend;
+ limit_req zone=api burst=5 nodelay;
+ proxy_pass $api_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -127,39 +96,18 @@ http {
add_header Cache-Control "no-store";
}
- # ── API (session verify, etc.) ───────────────────────────────────────
- location /api/ {
- limit_req zone=api burst=10 nodelay;
- proxy_pass $elysia_backend;
- proxy_http_version 1.1;
- proxy_set_header Connection "";
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-Proto $scheme;
- add_header Cache-Control "no-store";
- }
-
- # ── SEO : sitemap + robots ───────────────────────────────────────────
- location ~* ^/(sitemap\.xml|robots\.txt)$ {
- proxy_pass $elysia_backend;
+ # ── SEO dynamique (robots/sitemap générés par Bun) ────────────────────
+ location ~* ^/(robots\.txt|sitemap\.xml)$ {
+ proxy_pass $api_backend;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
add_header Cache-Control "public, max-age=86400";
}
- # ── Pages HTML ───────────────────────────────────────────────────────
- # Cache 1h côté client, stale-while-revalidate pour UX fluide
- # Google re-crawle dès que le cache expire → pas de contenu périmé indexé
+ # ── HTML : index.html pour toutes les routes (SPA-style) ─────────────
location / {
- limit_req zone=general burst=20 nodelay;
- proxy_pass $elysia_backend;
- proxy_http_version 1.1;
- proxy_set_header Connection "";
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
- add_header Cache-Control "public, max-age=3600, stale-while-revalidate=86400";
+ try_files $uri $uri/ $uri.html =404;
+ add_header Cache-Control "public, max-age=3600, stale-while-revalidate=86400";
}
}
}
diff --git a/public/main.js b/public/main.js
index a43027c..4e4a189 100644
--- a/public/main.js
+++ b/public/main.js
@@ -15,9 +15,16 @@ document.addEventListener('DOMContentLoaded', () => {
window.addEventListener('mousemove', (e) => {
mouseX = e.clientX;
mouseY = e.clientY;
+ // Première fois : initialise l'outline à la position courante et rend visible
+ if (outlineX === 0 && outlineY === 0) {
+ outlineX = mouseX;
+ outlineY = mouseY;
+ cursorDot.style.opacity = '1';
+ cursorOutline.style.opacity = '1';
+ }
cursorDot.style.transform = `translate(calc(-50% + ${mouseX}px), calc(-50% + ${mouseY}px))`;
if (!rafId) rafId = requestAnimationFrame(animateOutline);
- });
+ }, { once: false });
function animateOutline() {
rafId = null;
@@ -35,13 +42,11 @@ document.addEventListener('DOMContentLoaded', () => {
cursorOutline.style.width = '38px';
cursorOutline.style.height = '38px';
cursorDot.style.opacity = '0';
- cursorDot.style.scale = '0';
});
el.addEventListener('mouseleave', () => {
cursorOutline.style.width = '26px';
cursorOutline.style.height = '26px';
cursorDot.style.opacity = '1';
- cursorDot.style.scale = '1';
});
});
}
diff --git a/public/style.css b/public/style.css
index 2c688dc..42fc073 100644
--- a/public/style.css
+++ b/public/style.css
@@ -34,7 +34,7 @@ body {
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 99999;
- transition: opacity 0.15s, scale 0.15s;
+ opacity: 0;
will-change: transform;
}
.cursor-outline {
@@ -44,7 +44,8 @@ body {
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 99998;
- transition: width 0.15s, height 0.15s, border-color 0.15s;
+ opacity: 0;
+ transition: width 0.15s, height 0.15s, opacity 0.15s;
will-change: transform;
}
diff --git a/server.ts b/server.ts
index f25d0a3..610cad5 100644
--- a/server.ts
+++ b/server.ts
@@ -1,15 +1,12 @@
import { Elysia, t } from 'elysia'
import { cors } from '@elysiajs/cors'
import Stripe from 'stripe'
-import { readFileSync, existsSync, statSync } from 'fs'
-import { join, extname } from 'path'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? '', {
apiVersion: '2025-01-27.acacia',
})
-const DOMAIN = process.env.DOMAIN ?? 'http://localhost:3000'
-const PUBLIC_DIR = join(import.meta.dir, 'public')
+const DOMAIN = process.env.DOMAIN ?? 'http://localhost:3000'
const PRODUCTS = {
lumiere_orbitale: {
@@ -20,63 +17,10 @@ const PRODUCTS = {
},
}
-// Map extensions → MIME types
-const MIME: Record = {
- '.html': 'text/html; charset=utf-8',
- '.css': 'text/css',
- '.js': 'application/javascript',
- '.json': 'application/json',
- '.jpg': 'image/jpeg',
- '.jpeg': 'image/jpeg',
- '.png': 'image/png',
- '.webp': 'image/webp',
- '.svg': 'image/svg+xml',
- '.ico': 'image/x-icon',
- '.woff2':'font/woff2',
- '.txt': 'text/plain',
- '.xml': 'application/xml',
-}
-
-const HTML_HEADERS = {
- 'Content-Type': 'text/html; charset=utf-8',
- 'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
- 'X-Content-Type-Options': 'nosniff',
- 'X-Frame-Options': 'SAMEORIGIN',
- 'Referrer-Policy': 'strict-origin-when-cross-origin',
-}
-
-// Sert un fichier statique depuis public/
-function serveStatic(relativePath: string): Response {
- const filePath = join(PUBLIC_DIR, relativePath)
- if (!existsSync(filePath) || statSync(filePath).isDirectory()) {
- return new Response('Not Found', { status: 404 })
- }
- const ext = extname(filePath).toLowerCase()
- const mime = MIME[ext] ?? 'application/octet-stream'
- const isAsset = ['.jpg', '.jpeg', '.png', '.webp', '.svg', '.ico', '.woff2', '.css', '.js'].includes(ext)
-
- return new Response(Bun.file(filePath), {
- headers: {
- 'Content-Type': mime,
- 'Cache-Control': isAsset
- ? 'public, max-age=31536000, immutable'
- : 'public, max-age=3600',
- },
- })
-}
-
const app = new Elysia()
.use(cors({ origin: '*', methods: ['GET', 'POST'] }))
- // ── Pages HTML ─────────────────────────────────────────────────────────────
- .get('/', () =>
- new Response(readFileSync(join(PUBLIC_DIR, 'index.html'), 'utf-8'), { headers: HTML_HEADERS })
- )
- .get('/success', () =>
- new Response(readFileSync(join(PUBLIC_DIR, 'success.html'), 'utf-8'), { headers: HTML_HEADERS })
- )
-
- // ── SEO : robots + sitemap ─────────────────────────────────────────────────
+ // ── SEO : robots + sitemap (nginx ne les génère pas dynamiquement) ──────────
.get('/robots.txt', () =>
new Response(`User-agent: *\nAllow: /\nSitemap: ${DOMAIN}/sitemap.xml\n`, {
headers: { 'Content-Type': 'text/plain', 'Cache-Control': 'public, max-age=86400' },
@@ -90,11 +34,6 @@ const app = new Elysia()
)
})
- // ── Fichiers statiques : /assets/*, /style.css, /main.js, etc. ────────────
- .get('/assets/*', ({ params }) => serveStatic(`assets/${(params as any)['*']}`))
- .get('/style.css', () => serveStatic('style.css'))
- .get('/main.js', () => serveStatic('main.js'))
-
// ── API Stripe : créer session checkout ───────────────────────────────────
.post(
'/api/checkout',
@@ -175,7 +114,7 @@ const app = new Elysia()
console.log(`
┌──────────────────────────────────────┐
- │ REBOUR — http://localhost:3000 │
+ │ REBOUR API — http://localhost:3000 │
│ NODE_ENV: ${process.env.NODE_ENV ?? 'development'}
└──────────────────────────────────────┘
`)
diff --git a/style.css b/style.css
deleted file mode 100644
index 9d77938..0000000
--- a/style.css
+++ /dev/null
@@ -1,590 +0,0 @@
-/* ==========================================================================
- REBOUR — RAW HTML 2000s + GRILLE GUFRAM + PANEL PRODUIT
- ========================================================================== */
-
-:root {
- --clr-bg: #e8e8e4;
- --clr-black: #111;
- --clr-white: #f5f5f0;
- --clr-card-bg: #f0f0ec;
- --clr-grid: rgba(0,0,0,0.055);
- --clr-red: #e8a800;
- --font-mono: 'Space Mono', monospace;
- --border: 1px solid #111;
- --pad: 2rem;
-}
-
-* { margin: 0; padding: 0; box-sizing: border-box; }
-
-html { font-size: 13px; }
-
-body {
- background: var(--clr-bg);
- color: var(--clr-black);
- font-family: var(--font-mono);
- overflow-x: hidden;
- cursor: none;
-}
-
-/* ---- CURSOR ---- */
-.cursor-dot {
- width: 5px; height: 5px;
- background: var(--clr-black);
- position: fixed; top: 0; left: 0;
- transform: translate(-50%, -50%);
- pointer-events: none;
- z-index: 99999;
- transition: transform 0.1s;
-}
-.cursor-outline {
- width: 26px; height: 26px;
- border: 1px solid var(--clr-black);
- position: fixed; top: 0; left: 0;
- transform: translate(-50%, -50%);
- pointer-events: none;
- z-index: 99998;
- transition: width 0.15s, height 0.15s, border-color 0.15s;
-}
-
-/* ---- INTERACTIVE GRID (derrière tout) ---- */
-.interactive-grid {
- position: fixed;
- top: 0; left: 0;
- width: 100vw; height: 100vh;
- z-index: 0;
- pointer-events: auto;
- overflow: hidden;
-}
-.grid-cell {
- border-right: 1px solid var(--clr-grid);
- border-bottom: 1px solid var(--clr-grid);
- pointer-events: auto;
- transition: background-color 1.3s ease-out;
-}
-
-/* ---- PAGE WRAPPER ---- */
-.page-wrapper {
- position: relative;
- z-index: 1;
- pointer-events: none; /* laisse passer vers la grid */
- min-height: 100vh;
- display: flex;
- flex-direction: column;
-}
-/* Re-active pointer-events sur les éléments interactifs */
-.page-wrapper a,
-.page-wrapper button,
-.page-wrapper input,
-.page-wrapper form,
-.page-wrapper nav,
-.page-wrapper .product-card,
-.page-wrapper summary,
-.page-wrapper details {
- pointer-events: auto;
-}
-
-/* ---- HEADER ---- */
-.header {
- display: flex;
- justify-content: space-between;
- align-items: baseline;
- padding: 1.1rem var(--pad);
- border-bottom: var(--border);
- background: var(--clr-bg);
- pointer-events: auto;
-}
-.logo-text {
- font-size: 1rem;
- font-weight: 700;
- letter-spacing: 0.18em;
-}
-.header-nav {
- display: flex;
- align-items: baseline;
- gap: 2.5rem;
- font-size: 0.82rem;
-}
-.header-nav a {
- color: var(--clr-black);
- text-decoration: none;
-}
-.header-nav a:hover { text-decoration: underline; }
-.wip-tag { color: #999; font-size: 0.78rem; }
-
-/* ---- UTILS ---- */
-.label { font-size: 0.75rem; color: #888; letter-spacing: 0.04em; }
-.mono-sm { font-size: 0.75rem; line-height: 1.9; color: #777; }
-.red { color: var(--clr-red); font-weight: 700; }
-.blink { animation: blink 1.4s step-end infinite; color: var(--clr-red); }
-@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
-hr { border: none; border-top: var(--border); margin: 0; }
-
-/* ---- HERO ---- */
-.hero {
- display: grid;
- grid-template-columns: 1fr 1fr;
- min-height: 88vh;
- pointer-events: none;
-}
-.hero-left {
- padding: 5rem var(--pad) 3rem;
- border-right: var(--border);
- display: flex;
- flex-direction: column;
- justify-content: flex-end;
- gap: 1.6rem;
-}
-.hero-left h1 {
- font-size: clamp(3.5rem, 7vw, 6rem);
- font-weight: 700;
- line-height: 0.92;
- letter-spacing: -0.02em;
-}
-.hero-sub { font-size: 0.85rem; line-height: 1.75; max-width: 300px; }
-.hero-right { overflow: hidden; background: #1c1c1c; }
-.hero-img {
- width: 100%; height: 100%;
- object-fit: cover; display: block;
- filter: grayscale(10%);
- opacity: 0.92;
- transition: opacity 0.5s, filter 0.5s;
-}
-.hero-right:hover .hero-img { opacity: 1; filter: grayscale(0%); }
-
-/* ---- COLLECTION ---- */
-.collection { pointer-events: none; }
-.collection-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 1rem var(--pad);
- border-bottom: var(--border);
-}
-
-/* ---- PRODUCT GRID — style Gufram ---- */
-/* 3 colonnes, image centrée sur fond neutre, bordure fine */
-.product-grid {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- border-left: var(--border);
-}
-
-.product-card {
- border-right: var(--border);
- border-bottom: var(--border);
- background: var(--clr-card-bg);
- cursor: none;
- pointer-events: auto;
- display: flex;
- flex-direction: column;
- transition: background 0.2s;
- position: relative;
-}
-.product-card:hover {
- background: var(--clr-white);
-}
-
-/* Image : objet centré sur fond neutre, comme Gufram */
-.card-img-wrap {
- aspect-ratio: 1 / 1;
- overflow: hidden;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 2rem;
- border-bottom: var(--border);
- background: var(--clr-card-bg);
- transition: background 0.2s;
-}
-.product-card:hover .card-img-wrap {
- background: var(--clr-white);
-}
-.card-img-wrap img {
- width: 100%;
- height: 100%;
- object-fit: cover;
- display: block;
- filter: grayscale(15%);
- transition: filter 0.4s, transform 0.5s ease;
-}
-.product-card:hover .card-img-wrap img {
- filter: grayscale(0%);
- transform: scale(1.04);
-}
-
-/* Bas de la card : index + nom + flèche, tout en ligne */
-.card-meta {
- display: flex;
- align-items: baseline;
- justify-content: space-between;
- padding: 0.9rem 1.1rem;
- gap: 0.8rem;
-}
-.card-index {
- font-size: 0.72rem;
- color: #999;
- flex-shrink: 0;
-}
-.card-name {
- font-size: 0.82rem;
- font-weight: 700;
- letter-spacing: 0.04em;
- flex-grow: 1;
-}
-.card-arrow {
- font-size: 1rem;
- opacity: 0;
- transform: translateY(3px);
- transition: opacity 0.2s, transform 0.2s;
-}
-.product-card:hover .card-arrow {
- opacity: 1;
- transform: translateY(0);
-}
-
-/* ---- PRODUCT PANEL (overlay) ---- */
-.product-panel {
- position: fixed;
- inset: 0;
- z-index: 1000;
- background: var(--clr-bg);
- display: flex;
- flex-direction: column;
- transform: translateY(100%);
- transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
- pointer-events: none;
- overflow: hidden;
-}
-.product-panel.is-open {
- transform: translateY(0);
- pointer-events: auto;
-}
-
-.panel-close {
- display: flex;
- align-items: center;
- padding: 1.1rem var(--pad);
- border-bottom: var(--border);
- font-size: 0.78rem;
- font-weight: 700;
- cursor: none;
- background: var(--clr-bg);
- letter-spacing: 0.05em;
- pointer-events: auto;
- flex-shrink: 0;
-}
-.panel-close:hover { background: var(--clr-white); }
-.panel-close span { pointer-events: none; }
-
-.panel-inner {
- display: grid;
- grid-template-columns: 1fr 1fr;
- flex-grow: 1;
- overflow: hidden;
-}
-
-/* Colonne image : sticky, fond sombre, image centrée */
-.panel-img-col {
- border-right: var(--border);
- background: #1a1a1a;
- display: flex;
- align-items: center;
- justify-content: center;
- overflow: hidden;
- position: relative;
-}
-#panel-img {
- width: 100%;
- height: 100%;
- object-fit: cover;
- display: block;
- opacity: 0.92;
-}
-
-/* Colonne infos : scrollable */
-.panel-info-col {
- overflow-y: auto;
- padding: 2.5rem var(--pad);
- display: flex;
- flex-direction: column;
- gap: 1.4rem;
- background: var(--clr-bg);
-}
-
-.panel-index {
- font-size: 0.72rem;
- color: #999;
- letter-spacing: 0.05em;
-}
-
-.panel-info-col h2 {
- font-size: clamp(1.8rem, 3vw, 2.8rem);
- font-weight: 700;
- line-height: 1;
- letter-spacing: -0.01em;
-}
-
-/* Meta table */
-.panel-meta {
- display: flex;
- flex-direction: column;
- gap: 0;
-}
-.panel-meta-row {
- display: flex;
- gap: 1.5rem;
- padding: 0.55rem 0;
- border-bottom: 1px solid rgba(0,0,0,0.1);
- font-size: 0.8rem;
- align-items: baseline;
-}
-.meta-key {
- color: #888;
- width: 7rem;
- flex-shrink: 0;
- font-size: 0.72rem;
-}
-
-.panel-desc {
- font-size: 0.85rem;
- line-height: 1.85;
- color: #333;
-}
-
-/* Accordion */
-.accordion {
- border-bottom: var(--border);
-}
-.accordion summary {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 1rem 0;
- font-size: 0.78rem;
- font-weight: 700;
- letter-spacing: 0.04em;
- cursor: none;
- list-style: none;
- pointer-events: auto;
-}
-.accordion summary::-webkit-details-marker { display: none; }
-.accordion summary span {
- transition: transform 0.2s;
- display: inline-block;
-}
-.accordion[open] summary span { transform: rotate(180deg); }
-.accordion-body {
- font-size: 0.78rem;
- line-height: 2;
- color: #444;
- padding-bottom: 1rem;
- white-space: pre-line;
-}
-
-.panel-footer {
- font-size: 0.75rem;
- color: #888;
- padding-top: 0.5rem;
-}
-
-/* ---- CHECKOUT SECTION ---- */
-.checkout-price-line {
- display: flex;
- align-items: baseline;
- gap: 1.2rem;
- margin-bottom: 1rem;
-}
-.checkout-price {
- font-size: 1.6rem;
- font-weight: 700;
- letter-spacing: -0.01em;
-}
-.checkout-edition {
- font-size: 0.72rem;
- color: #888;
-}
-
-/* Bouton jaune parking rectangulaire — aucun border-radius */
-.checkout-btn {
- display: block;
- width: 100%;
- background: #e8a800;
- color: var(--clr-black);
- border: var(--border);
- font-family: var(--font-mono);
- font-size: 0.82rem;
- font-weight: 700;
- letter-spacing: 0.04em;
- padding: 1.1rem 1.5rem;
- text-align: center;
- cursor: none;
- transition: background 0.15s, color 0.15s;
- pointer-events: auto;
-}
-.checkout-btn:hover {
- background: var(--clr-black);
- color: #e8a800;
-}
-
-/* Form qui se déploie */
-.checkout-form-wrap {
- border: var(--border);
- border-top: none;
- background: var(--clr-white);
-}
-.checkout-form {
- display: flex;
- flex-direction: column;
- gap: 0;
-}
-.checkout-form-field {
- display: flex;
- flex-direction: column;
- gap: 0.4rem;
- padding: 1.1rem;
- border-bottom: var(--border);
-}
-.checkout-form-field label {
- font-size: 0.68rem;
- font-weight: 700;
- color: #888;
- letter-spacing: 0.05em;
-}
-.checkout-form-field input {
- border: none;
- background: transparent;
- font-family: var(--font-mono);
- font-size: 0.85rem;
- outline: none;
- cursor: none;
- padding: 0;
- color: var(--clr-black);
- pointer-events: auto;
-}
-.checkout-form-field input::placeholder { color: #bbb; }
-.checkout-form-field input:focus { outline: none; }
-.checkout-form-note {
- padding: 0.9rem 1.1rem;
- font-size: 0.72rem;
- line-height: 1.8;
- color: #777;
- border-bottom: var(--border);
-}
-.checkout-submit {
- background: var(--clr-black);
- color: #e8a800;
- border: none;
- font-family: var(--font-mono);
- font-size: 0.78rem;
- font-weight: 700;
- letter-spacing: 0.04em;
- padding: 1.1rem 1.5rem;
- cursor: none;
- text-align: center;
- transition: background 0.15s, color 0.15s;
- pointer-events: auto;
-}
-.checkout-submit:hover {
- background: #e8a800;
- color: var(--clr-black);
-}
-.checkout-submit:disabled {
- opacity: 0.5;
- pointer-events: none;
-}
-
-/* ---- NEWSLETTER ---- */
-.newsletter {
- display: grid;
- grid-template-columns: 1fr 1fr;
- pointer-events: none;
-}
-.nl-left {
- padding: 3rem var(--pad);
- border-right: var(--border);
- display: flex;
- flex-direction: column;
- justify-content: flex-end;
- gap: 1rem;
-}
-.nl-left h2 {
- font-size: clamp(2rem, 4vw, 3rem);
- font-weight: 700;
- line-height: 0.95;
- letter-spacing: -0.02em;
-}
-.nl-right {
- padding: 3rem var(--pad);
- display: flex;
- align-items: flex-end;
-}
-.nl-form {
- width: 100%;
- display: flex;
- flex-direction: column;
- gap: 0.7rem;
- pointer-events: auto;
-}
-.nl-form label { font-size: 0.75rem; font-weight: 700; }
-.nl-row {
- display: flex;
- border: var(--border);
- background: var(--clr-white);
-}
-.nl-row input {
- flex-grow: 1;
- border: none;
- background: transparent;
- font-family: var(--font-mono);
- font-size: 0.85rem;
- padding: 0.9rem;
- outline: none;
- cursor: none;
-}
-.nl-row input::placeholder { color: #bbb; }
-.nl-row input:focus { background: rgba(0,0,0,0.03); }
-.nl-row button {
- border: none;
- border-left: var(--border);
- background: var(--clr-black);
- color: var(--clr-white);
- font-family: var(--font-mono);
- font-size: 0.75rem;
- font-weight: 700;
- padding: 0 1.3rem;
- cursor: none;
- transition: background 0.15s;
- pointer-events: auto;
-}
-.nl-row button:hover { background: var(--clr-red); }
-
-/* ---- FOOTER ---- */
-.footer {
- margin-top: auto;
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 1.1rem var(--pad);
- border-top: var(--border);
- font-size: 0.75rem;
- pointer-events: auto;
- background: var(--clr-bg);
-}
-.footer a { color: var(--clr-black); text-decoration: none; }
-.footer a:hover { text-decoration: underline; }
-
-/* ---- RESPONSIVE ---- */
-@media (max-width: 900px) {
- .hero, .newsletter { grid-template-columns: 1fr; }
- .hero-left { border-right: none; border-bottom: var(--border); min-height: 55vw; padding: 3rem var(--pad); }
- .hero-right { height: 55vw; }
- .nl-left { border-right: none; border-bottom: var(--border); }
- .product-grid { grid-template-columns: 1fr 1fr; }
- .panel-inner { grid-template-columns: 1fr; }
- .panel-img-col { height: 50vw; }
- .panel-info-col { overflow-y: auto; }
-}
-@media (max-width: 600px) {
- .product-grid { grid-template-columns: 1fr; }
- .header-nav { gap: 1rem; }
-}
diff --git a/success.html b/success.html
deleted file mode 100644
index f73e50a..0000000
--- a/success.html
+++ /dev/null
@@ -1,168 +0,0 @@
-
-
-
-
-
- REBOUR — COMMANDE CONFIRMÉE
-
-
-
-
-
-
-
- REBOUR
- COLLECTION_001
-
-
-
-
-
// COMMANDE_CONFIRMÉE
-
MERCI
POUR
VOTRE
COMMANDE
-
Vérification du paiement...
-
-
-
// RÉCAPITULATIF
-
-
-
PRODUITLUMIÈRE_ORBITALE
-
COLLECTION001 — ÉDITION UNIQUE
-
MONTANT
-
EMAIL
-
DÉLAI6 À 8 SEMAINES
-
STATUSCONFIRMÉ ■
-
-
- Un email de confirmation vous sera envoyé.
- Votre lampe est fabriquée à la main à Paris.
-
-
← RETOUR À LA COLLECTION
-
-
-
-
-
-
-
-