add prod data
This commit is contained in:
parent
c9d78e29ee
commit
45fd3c18c0
131
nginx.conf
131
nginx.conf
@ -1,113 +1,36 @@
|
|||||||
# ─────────────────────────────────────────────────────────────────────────────
|
server {
|
||||||
# REBOUR — nginx.conf
|
listen 80;
|
||||||
# nginx sert public/ directement + proxifie /api/ vers Bun
|
server_name rebours.studio;
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
user nginx;
|
root /var/www/rebours/public;
|
||||||
worker_processes auto;
|
index index.html;
|
||||||
error_log /var/log/nginx/error.log warn;
|
|
||||||
pid /var/run/nginx.pid;
|
|
||||||
|
|
||||||
events {
|
# HTML : jamais caché
|
||||||
worker_connections 1024;
|
location ~* \.html$ {
|
||||||
}
|
add_header Cache-Control "no-store";
|
||||||
|
}
|
||||||
|
|
||||||
http {
|
# CSS / JS : revalidation obligatoire à chaque requête
|
||||||
include /etc/nginx/mime.types;
|
location ~* \.(css|js)$ {
|
||||||
default_type application/octet-stream;
|
add_header Cache-Control "no-cache";
|
||||||
|
}
|
||||||
|
|
||||||
log_format main '$remote_addr "$request" $status $body_bytes_sent "${request_time}s"';
|
# Assets (images, fonts) : cache long
|
||||||
access_log /var/log/nginx/access.log main;
|
location ~* \.(jpg|jpeg|png|gif|webp|svg|woff2|woff|ttf)$ {
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
sendfile on;
|
location = /success {
|
||||||
tcp_nopush on;
|
try_files /success.html =404;
|
||||||
tcp_nodelay on;
|
}
|
||||||
keepalive_timeout 65;
|
|
||||||
server_tokens off;
|
|
||||||
|
|
||||||
# ── Gzip ─────────────────────────────────────────────────────────────────
|
location / {
|
||||||
gzip on;
|
try_files $uri $uri/ /index.html;
|
||||||
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 ─────────────────────────────────────────────────────────
|
location /api/ {
|
||||||
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/m;
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
# ── Resolver Docker (résolution dynamique → pas de crash au boot) ─────────
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
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";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
305
public/main.js
305
public/main.js
@ -1,206 +1,115 @@
|
|||||||
/**
|
import Fastify from 'fastify'
|
||||||
* REBOUR — Main Script
|
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 PRODUCTS = {
|
||||||
const cursorDot = document.querySelector('.cursor-dot');
|
lumiere_orbitale: {
|
||||||
const cursorOutline = document.querySelector('.cursor-outline');
|
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;
|
const app = Fastify({ logger: true })
|
||||||
let outlineX = 0, outlineY = 0;
|
|
||||||
let rafId = null;
|
|
||||||
|
|
||||||
window.addEventListener('mousemove', (e) => {
|
await app.register(cors, { origin: '*', methods: ['GET', 'POST'] })
|
||||||
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() {
|
// ── SEO ───────────────────────────────────────────────────────────────────────
|
||||||
rafId = null;
|
app.get('/robots.txt', (_, reply) => {
|
||||||
outlineX += (mouseX - outlineX) * 0.18;
|
reply
|
||||||
outlineY += (mouseY - outlineY) * 0.18;
|
.type('text/plain')
|
||||||
cursorOutline.style.transform = `translate(calc(-50% + ${outlineX}px), calc(-50% + ${outlineY}px))`;
|
.header('Cache-Control', 'public, max-age=86400')
|
||||||
if (Math.abs(mouseX - outlineX) > 0.1 || Math.abs(mouseY - outlineY) > 0.1) {
|
.send(`User-agent: *\nAllow: /\nSitemap: ${DOMAIN}/sitemap.xml\n`)
|
||||||
rafId = requestAnimationFrame(animateOutline);
|
})
|
||||||
}
|
|
||||||
|
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(
|
||||||
|
`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n <url><loc>${DOMAIN}/</loc><lastmod>${today}</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>\n</urlset>`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 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) {
|
return { received: true }
|
||||||
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';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
attachCursorHover(document.querySelectorAll('a, button, input, .product-card, summary, .panel-close'));
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||||
|
try {
|
||||||
// ---- INTERACTIVE GRID ----
|
await app.listen({ port: 3000, host: '127.0.0.1' })
|
||||||
const gridContainer = document.getElementById('interactive-grid');
|
} catch (err) {
|
||||||
const CELL = 60;
|
app.log.error(err)
|
||||||
const COLORS = [
|
process.exit(1)
|
||||||
'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();
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
@ -45,6 +45,16 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 2rem;
|
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; }
|
.label { font-size: 0.75rem; color: #888; }
|
||||||
h1 {
|
h1 {
|
||||||
@ -114,9 +124,10 @@
|
|||||||
|
|
||||||
<main>
|
<main>
|
||||||
<div class="left">
|
<div class="left">
|
||||||
<p class="label">// COMMANDE_CONFIRMÉE</p>
|
<img id="product-img" class="product-img" src="/assets/lamp-violet.jpg" alt="">
|
||||||
<h1>MERCI<br>POUR<br>VOTRE<br>COMMANDE</h1>
|
<p class="label" style="position:relative">// COMMANDE_CONFIRMÉE</p>
|
||||||
<p class="status-line" id="loading">Vérification du paiement...</p>
|
<h1 style="position:relative">MERCI<br>POUR<br>VOTRE<br>COMMANDE</h1>
|
||||||
|
<p class="status-line" id="loading" style="position:relative">Vérification du paiement...</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="right">
|
<div class="right">
|
||||||
<p class="label">// RÉCAPITULATIF</p>
|
<p class="label">// RÉCAPITULATIF</p>
|
||||||
@ -146,6 +157,12 @@
|
|||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
const sessionId = params.get('session_id')
|
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) {
|
if (sessionId) {
|
||||||
fetch(`/api/session/${sessionId}`)
|
fetch(`/api/session/${sessionId}`)
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
@ -156,6 +173,10 @@
|
|||||||
const amount = data.amount ? `${(data.amount / 100).toLocaleString('fr-FR')} €` : '—'
|
const amount = data.amount ? `${(data.amount / 100).toLocaleString('fr-FR')} €` : '—'
|
||||||
document.getElementById('amount-display').textContent = amount
|
document.getElementById('amount-display').textContent = amount
|
||||||
document.getElementById('email-display').textContent = data.customer_email ?? '—'
|
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(() => {
|
.catch(() => {
|
||||||
document.getElementById('loading').textContent = 'Commande enregistrée.'
|
document.getElementById('loading').textContent = 'Commande enregistrée.'
|
||||||
|
|||||||
53
server.mjs
53
server.mjs
@ -10,21 +10,16 @@ dotenv.config()
|
|||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
const isDev = process.env.NODE_ENV !== 'production'
|
const isDev = process.env.NODE_ENV !== 'production'
|
||||||
|
|
||||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? '')
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? '')
|
||||||
const DOMAIN = process.env.DOMAIN ?? 'http://localhost:3000'
|
const DOMAIN = process.env.DOMAIN ?? 'http://localhost:3000'
|
||||||
|
|
||||||
const PRODUCTS = {
|
const PRODUCTS = {
|
||||||
lumiere_orbitale: {
|
lumiere_orbitale: {
|
||||||
name: 'LUMIÈRE_ORBITALE — REBOUR',
|
price_id: 'price_1T5SBlE5wMMoCUP5ZcjEStwe',
|
||||||
description: 'Lampe de table unique. Béton texturé coulé à la main + dôme céramique laqué. Collection 001.',
|
|
||||||
amount: 180000,
|
|
||||||
currency: 'eur',
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = Fastify({ logger: true })
|
const app = Fastify({ logger: true })
|
||||||
|
|
||||||
await app.register(cors, { origin: '*', methods: ['GET', 'POST'] })
|
await app.register(cors, { origin: '*', methods: ['GET', 'POST'] })
|
||||||
|
|
||||||
// ── Statique en dev uniquement (en prod c'est nginx qui sert public/) ─────────
|
// ── 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 { product, email } = request.body ?? {}
|
||||||
const p = PRODUCTS[product]
|
const p = PRODUCTS[product]
|
||||||
if (!p) return reply.code(404).send({ error: 'Produit inconnu' })
|
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({
|
let session
|
||||||
mode: 'payment',
|
try {
|
||||||
payment_method_types: ['card'],
|
session = await stripe.checkout.sessions.create({
|
||||||
line_items: [{
|
mode: 'payment',
|
||||||
price_data: {
|
payment_method_types: ['card', 'link'],
|
||||||
currency: p.currency,
|
line_items: [{
|
||||||
unit_amount: p.amount,
|
price: p.price_id,
|
||||||
product_data: { name: p.name, description: p.description },
|
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,
|
})
|
||||||
}],
|
} catch (err) {
|
||||||
success_url: `${DOMAIN}/success?session_id={CHECKOUT_SESSION_ID}`,
|
app.log.error(err)
|
||||||
cancel_url: `${DOMAIN}/#collection`,
|
return reply.code(500).send({ error: err.message })
|
||||||
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 }
|
return { url: session.url }
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -91,6 +90,7 @@ app.get('/api/session/:id', async (request) => {
|
|||||||
amount: session.amount_total,
|
amount: session.amount_total,
|
||||||
currency: session.currency,
|
currency: session.currency,
|
||||||
customer_email: session.customer_details?.email ?? null,
|
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}`)
|
app.log.info(`✓ Paiement — ${session.id} — ${session.customer_details?.email}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { received: true }
|
return { received: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
app.log.error(err)
|
app.log.error(err)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user