diff --git a/nginx.conf b/nginx.conf index 62b2783..8c09e5b 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,113 +1,36 @@ -# ───────────────────────────────────────────────────────────────────────────── -# REBOUR — nginx.conf -# nginx sert public/ directement + proxifie /api/ vers Bun -# ───────────────────────────────────────────────────────────────────────────── +server { + listen 80; + server_name rebours.studio; -user nginx; -worker_processes auto; -error_log /var/log/nginx/error.log warn; -pid /var/run/nginx.pid; + root /var/www/rebours/public; + index index.html; -events { - worker_connections 1024; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - 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; - - # ── 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; - - # ── 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"; } - - # ── Redirection HTTP → HTTPS (décommenter en prod) ──────────────────────── - # server { - # listen 80; - # server_name rebour.studio www.rebour.studio; - # return 301 https://rebour.studio$request_uri; - # } - - server { - listen 80; - server_name _; - - # 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; - - # ── 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"; - } - - # ── 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; - 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 "no-store"; - } - - # ── Webhook Stripe : pas de buffering ───────────────────────────────── - location = /api/webhook { - 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; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header stripe-signature $http_stripe_signature; - proxy_request_buffering off; - add_header Cache-Control "no-store"; - } - - # ── 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"; - } - - # ── HTML : index.html pour toutes les routes (SPA-style) ───────────── - location / { - try_files $uri $uri/ $uri.html =404; - add_header Cache-Control "public, max-age=3600, stale-while-revalidate=86400"; - } + # HTML : jamais caché + location ~* \.html$ { + add_header Cache-Control "no-store"; } -} + + # CSS / JS : revalidation obligatoire à chaque requête + location ~* \.(css|js)$ { + add_header Cache-Control "no-cache"; + } + + # Assets (images, fonts) : cache long + location ~* \.(jpg|jpeg|png|gif|webp|svg|woff2|woff|ttf)$ { + add_header Cache-Control "public, max-age=31536000, immutable"; + } + + location = /success { + try_files /success.html =404; + } + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} \ No newline at end of file diff --git a/public/main.js b/public/main.js index f8d2a35..8a23c76 100644 --- a/public/main.js +++ b/public/main.js @@ -1,206 +1,115 @@ -/** - * REBOUR — Main Script - */ +import Fastify from 'fastify' +import cors from '@fastify/cors' +import Stripe from 'stripe' +import { readFileSync } from 'node:fs' +import dotenv from 'dotenv'; +dotenv.config() -document.addEventListener('DOMContentLoaded', () => { +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? '') +const DOMAIN = process.env.DOMAIN ?? 'http://localhost:3000' - // ---- CUSTOM CURSOR ---- - const cursorDot = document.querySelector('.cursor-dot'); - const cursorOutline = document.querySelector('.cursor-outline'); +const PRODUCTS = { + lumiere_orbitale: { + name: 'LUMIÈRE_ORBITALE — REBOUR', + description: 'Lampe de table unique. Béton texturé coulé à la main + dôme céramique laqué. Collection 001.', + amount: 180000, + currency: 'eur', + }, +} - let mouseX = 0, mouseY = 0; - let outlineX = 0, outlineY = 0; - let rafId = null; +const app = Fastify({ logger: true }) - 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 }); +await app.register(cors, { origin: '*', methods: ['GET', 'POST'] }) - function animateOutline() { - rafId = null; - outlineX += (mouseX - outlineX) * 0.18; - outlineY += (mouseY - outlineY) * 0.18; - cursorOutline.style.transform = `translate(calc(-50% + ${outlineX}px), calc(-50% + ${outlineY}px))`; - if (Math.abs(mouseX - outlineX) > 0.1 || Math.abs(mouseY - outlineY) > 0.1) { - rafId = requestAnimationFrame(animateOutline); - } +// ── SEO ─────────────────────────────────────────────────────────────────────── +app.get('/robots.txt', (_, reply) => { + reply + .type('text/plain') + .header('Cache-Control', 'public, max-age=86400') + .send(`User-agent: *\nAllow: /\nSitemap: ${DOMAIN}/sitemap.xml\n`) +}) + +app.get('/sitemap.xml', (_, reply) => { + const today = new Date().toISOString().split('T')[0] + reply + .type('application/xml') + .header('Cache-Control', 'public, max-age=86400') + .send( + `\n\n ${DOMAIN}/${today}weekly1.0\n` + ) +}) + +// ── Checkout Stripe ─────────────────────────────────────────────────────────── +app.post('/api/checkout', async (request, reply) => { + const { product, email } = request.body ?? {} + const p = PRODUCTS[product] + if (!p) return reply.code(404).send({ error: 'Produit inconnu' }) + + const session = await stripe.checkout.sessions.create({ + mode: 'payment', + payment_method_types: ['card'], + line_items: [{ + price_data: { + currency: p.currency, + unit_amount: p.amount, + product_data: { name: p.name, description: p.description }, + }, + quantity: 1, + }], + success_url: `${DOMAIN}/success?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${DOMAIN}/#collection`, + locale: 'fr', + customer_email: email ?? undefined, + custom_text: { + submit: { message: 'Pièce unique — fabriquée à Paris. Délai : 6 à 8 semaines.' }, + }, + }) + + return { url: session.url } +}) + +// ── Vérification session ────────────────────────────────────────────────────── +app.get('/api/session/:id', async (request) => { + const session = await stripe.checkout.sessions.retrieve(request.params.id) + return { + status: session.payment_status, + amount: session.amount_total, + currency: session.currency, + customer_email: session.customer_details?.email ?? null, + } +}) + +// ── Webhook Stripe ──────────────────────────────────────────────────────────── +app.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => { + done(null, body) +}) + +app.post('/api/webhook', async (request, reply) => { + const sig = request.headers['stripe-signature'] + const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET + if (!sig || !webhookSecret) return reply.code(400).send('Missing signature') + + let event + try { + event = stripe.webhooks.constructEvent(request.body, sig, webhookSecret) + } catch { + return reply.code(400).send('Webhook Error') + } + + if (event.type === 'checkout.session.completed') { + const session = event.data.object + if (session.payment_status === 'paid') { + app.log.info(`✓ Paiement — ${session.id} — ${session.customer_details?.email}`) } + } - function attachCursorHover(elements) { - elements.forEach(el => { - el.addEventListener('mouseenter', () => { - cursorOutline.style.width = '38px'; - cursorOutline.style.height = '38px'; - cursorDot.style.opacity = '0'; - }); - el.addEventListener('mouseleave', () => { - cursorOutline.style.width = '26px'; - cursorOutline.style.height = '26px'; - cursorDot.style.opacity = '1'; - }); - }); - } + return { received: true } +}) - attachCursorHover(document.querySelectorAll('a, button, input, .product-card, summary, .panel-close')); - - // ---- INTERACTIVE GRID ---- - const gridContainer = document.getElementById('interactive-grid'); - const CELL = 60; - 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 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 - attachCursorHover(panel.querySelectorAll('summary, .panel-close, .checkout-btn, .checkout-submit')); - } - - 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(); - }); - -}); +// ── Start ───────────────────────────────────────────────────────────────────── +try { + await app.listen({ port: 3000, host: '127.0.0.1' }) +} catch (err) { + app.log.error(err) + process.exit(1) +} \ No newline at end of file diff --git a/public/success.html b/public/success.html index f73e50a..66b6c20 100644 --- a/public/success.html +++ b/public/success.html @@ -45,6 +45,16 @@ flex-direction: column; justify-content: flex-end; gap: 2rem; + position: relative; + overflow: hidden; + } + .product-img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0.55; } .label { font-size: 0.75rem; color: #888; } h1 { @@ -114,9 +124,10 @@
-

// COMMANDE_CONFIRMÉE

-

MERCI
POUR
VOTRE
COMMANDE

-

Vérification du paiement...

+ +

// COMMANDE_CONFIRMÉE

+

MERCI
POUR
VOTRE
COMMANDE

+

Vérification du paiement...

// RÉCAPITULATIF

@@ -146,6 +157,12 @@ const params = new URLSearchParams(window.location.search) const sessionId = params.get('session_id') + const PRODUCT_IMAGES = { + lumiere_orbitale: '/assets/lamp-violet.jpg', + table_terrazzo: '/assets/table-terrazzo.jpg', + module_serie: '/assets/lampes-serie.jpg', + } + if (sessionId) { fetch(`/api/session/${sessionId}`) .then(r => r.json()) @@ -156,6 +173,10 @@ const amount = data.amount ? `${(data.amount / 100).toLocaleString('fr-FR')} €` : '—' document.getElementById('amount-display').textContent = amount document.getElementById('email-display').textContent = data.customer_email ?? '—' + + if (data.product && PRODUCT_IMAGES[data.product]) { + document.getElementById('product-img').src = PRODUCT_IMAGES[data.product] + } }) .catch(() => { document.getElementById('loading').textContent = 'Commande enregistrée.' diff --git a/server.mjs b/server.mjs index e7c46d0..19a2c66 100644 --- a/server.mjs +++ b/server.mjs @@ -10,21 +10,16 @@ dotenv.config() const __dirname = dirname(fileURLToPath(import.meta.url)) const isDev = process.env.NODE_ENV !== 'production' - const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? '') const DOMAIN = process.env.DOMAIN ?? 'http://localhost:3000' const PRODUCTS = { lumiere_orbitale: { - name: 'LUMIÈRE_ORBITALE — REBOUR', - description: 'Lampe de table unique. Béton texturé coulé à la main + dôme céramique laqué. Collection 001.', - amount: 180000, - currency: 'eur', + price_id: 'price_1T5SBlE5wMMoCUP5ZcjEStwe', }, } const app = Fastify({ logger: true }) - await app.register(cors, { origin: '*', methods: ['GET', 'POST'] }) // ── Statique en dev uniquement (en prod c'est nginx qui sert public/) ───────── @@ -59,27 +54,31 @@ app.post('/api/checkout', async (request, reply) => { const { product, email } = request.body ?? {} const p = PRODUCTS[product] if (!p) return reply.code(404).send({ error: 'Produit inconnu' }) + app.log.info(`Stripe key prefix: ${process.env.STRIPE_SECRET_KEY?.slice(0, 20)}`) + app.log.info(`Price ID: ${p.price_id}`) - const session = await stripe.checkout.sessions.create({ - mode: 'payment', - payment_method_types: ['card'], - line_items: [{ - price_data: { - currency: p.currency, - unit_amount: p.amount, - product_data: { name: p.name, description: p.description }, + let session + try { + session = await stripe.checkout.sessions.create({ + mode: 'payment', + payment_method_types: ['card', 'link'], + line_items: [{ + price: p.price_id, + quantity: 1, + }], + metadata: { product }, + success_url: `${DOMAIN}/success?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${DOMAIN}/#collection`, + locale: 'fr', + customer_email: email ?? undefined, + custom_text: { + submit: { message: 'Pièce unique — fabriquée à Paris. Délai : 6 à 8 semaines.' }, }, - quantity: 1, - }], - success_url: `${DOMAIN}/success?session_id={CHECKOUT_SESSION_ID}`, - cancel_url: `${DOMAIN}/#collection`, - locale: 'fr', - customer_email: email ?? undefined, - custom_text: { - submit: { message: 'Pièce unique — fabriquée à Paris. Délai : 6 à 8 semaines.' }, - }, - }) - + }) + } catch (err) { + app.log.error(err) + return reply.code(500).send({ error: err.message }) + } return { url: session.url } }) @@ -91,6 +90,7 @@ app.get('/api/session/:id', async (request) => { amount: session.amount_total, currency: session.currency, customer_email: session.customer_details?.email ?? null, + product: session.metadata?.product ?? null, } }) @@ -120,13 +120,12 @@ app.post('/api/webhook', { app.log.info(`✓ Paiement — ${session.id} — ${session.customer_details?.email}`) } } - return { received: true } }) // ── Start ───────────────────────────────────────────────────────────────────── try { - await app.listen({ port: 8000, host: '127.0.0.1' }) + await app.listen({ port: process.env.PORT ?? 8888, host: '127.0.0.1' }) } catch (err) { app.log.error(err) process.exit(1)