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 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - -
- - - - -
- -
- REBOUR - -
- -
- - -
-
-

// ARCHIVE_001 — 2026

-

REBOUR
STUDIO

-

Mobilier d'art contemporain.
Space Age × Memphis.

-

STATUS: [PROTOTYPE EN COURS]
COLLECTION_001 — BIENTÔT DISPONIBLE

-
-
- REBOUR — Lampe Orbitale, prototype béton laqué violet, Paris 2026 -
-
- -
- - -
-
-

// COLLECTION_001

- 3 OBJETS — CLIQUER POUR OUVRIR -
- -
- -
-
- LUMIÈRE ORBITALE — Lampe béton violet, dôme céramique bleu, REBOUR 2026 -
-
- 001 - LUMIÈRE_ORBITALE - -
-
- -
-
- TABLE TERRAZZO — Table basse terrazzo et étagère acier, REBOUR 2026 -
-
- 002 - TABLE_TERRAZZO - -
-
- -
-
- MODULE SÉRIE — Collection de 7 lampes béton colorées, REBOUR 2026 -
-
- 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 - - - - - - -
- - COLLECTION_001 -
- -
-
-

// COMMANDE_CONFIRMÉE

-

MERCI
POUR
VOTRE
COMMANDE

-

Vérification du paiement...

-
-
-

// RÉCAPITULATIF

-
- -

- Un email de confirmation vous sera envoyé.
- Votre lampe est fabriquée à la main à Paris. -

- ← RETOUR À LA COLLECTION -
-
- - - - - -