diff --git a/.DS_Store b/.DS_Store index 3d6c9b3..7882958 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc b/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc new file mode 100644 index 0000000..ebda995 --- /dev/null +++ b/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc @@ -0,0 +1,111 @@ +--- +description: Use Bun instead of Node.js, npm, pnpm, or vite. +globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json" +alwaysApply: false +--- + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; +import { createRoot } from "react-dom/client"; + +// import .css files directly and it works +import './index.css'; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..eed70d3 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +STRIPE_SECRET_KEY=sk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... +DOMAIN=http://localhost:3000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14702c --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..9296b37 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# rebours + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run +``` + +This project was created using `bun init` in bun v1.3.9. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..5dfb10a --- /dev/null +++ b/bun.lock @@ -0,0 +1,72 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "rebours", + "dependencies": { + "@elysiajs/cors": "^1.4.1", + "@elysiajs/static": "^1.4.7", + "elysia": "^1.4.25", + "stripe": "^20.3.1", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@borewit/text-codec": ["@borewit/text-codec@0.2.1", "", {}, "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw=="], + + "@elysiajs/cors": ["@elysiajs/cors@1.4.1", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-lQfad+F3r4mNwsxRKbXyJB8Jg43oAOXjRwn7sKUL6bcOW3KjUqUimTS+woNpO97efpzjtDE0tEjGk9DTw8lqTQ=="], + + "@elysiajs/static": ["@elysiajs/static@1.4.7", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-Go4kIXZ0G3iWfkAld07HmLglqIDMVXdyRKBQK/sVEjtpDdjHNb+rUIje73aDTWpZYg4PEVHUpi9v4AlNEwrQug=="], + + "@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="], + + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], + + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + + "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + + "@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], + + "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "elysia": ["elysia@1.4.25", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-liKjavH99Gpzrv9cDil6uYWmPuqESfPFV1FIaFSd3iNqo3y7e29sN43VxFIK8tWWnyi6eDAmi2SZk8hNAMQMyg=="], + + "exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="], + + "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], + + "file-type": ["file-type@21.3.0", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + + "stripe": ["stripe@20.3.1", "", { "peerDependencies": { "@types/node": ">=16" }, "optionalPeers": ["@types/node"] }, "sha512-k990yOT5G5rhX3XluRPw5Y8RLdJDW4dzQ29wWT66piHrbnM2KyamJ1dKgPsw4HzGHRWjDiSSdcI2WdxQUPV3aQ=="], + + "strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="], + + "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + } +} diff --git a/index.html b/index.html index 86cc464..7059413 100644 --- a/index.html +++ b/index.html @@ -63,6 +63,33 @@
+ + + + diff --git a/main.js b/main.js index a88a04d..918f506 100644 --- a/main.js +++ b/main.js @@ -87,6 +87,49 @@ document.addEventListener('DOMContentLoaded', () => { 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; @@ -100,6 +143,16 @@ document.addEventListener('DOMContentLoaded', () => { 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')); @@ -108,7 +161,7 @@ document.addEventListener('DOMContentLoaded', () => { document.body.style.overflow = 'hidden'; // Refresh cursor sur les nouveaux éléments - panel.querySelectorAll('summary, .panel-close').forEach(el => { + panel.querySelectorAll('summary, .panel-close, .checkout-btn, .checkout-submit').forEach(el => { el.addEventListener('mouseenter', () => { cursorOutline.style.width = '38px'; cursorOutline.style.height = '38px'; diff --git a/package.json b/package.json new file mode 100644 index 0000000..1035e29 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "rebours", + "private": true, + "scripts": { + "dev": "bun run --hot server.ts", + "start": "bun run server.ts" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "@elysiajs/cors": "^1.4.1", + "@elysiajs/static": "^1.4.7", + "elysia": "^1.4.25", + "stripe": "^20.3.1" + } +} diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..dad2d0a --- /dev/null +++ b/server.ts @@ -0,0 +1,140 @@ +import { Elysia, t } from 'elysia' +import { cors } from '@elysiajs/cors' +import { staticPlugin } from '@elysiajs/static' +import Stripe from 'stripe' + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? '', { + apiVersion: '2025-01-27.acacia', +}) + +const DOMAIN = process.env.DOMAIN ?? 'http://localhost:3000' + +// Produit LUMIÈRE_ORBITALE — prix en centimes (EUR) +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, // 1800 EUR + currency: 'eur', + }, +} + +const app = new Elysia() + .use(cors({ + origin: DOMAIN, + methods: ['GET', 'POST'], + })) + // Sert les fichiers statiques (html, css, js, assets) + .use(staticPlugin({ + assets: '.', + prefix: '/', + indexHTML: true, + })) + + // ── Créer une session de checkout Stripe ────────────────────────────────── + .post( + '/api/checkout', + async ({ body }) => { + const product = PRODUCTS[body.product as keyof typeof PRODUCTS] + if (!product) { + throw new Error('Produit inconnu') + } + + const session = await stripe.checkout.sessions.create({ + mode: 'payment', + payment_method_types: ['card'], + line_items: [ + { + price_data: { + currency: product.currency, + unit_amount: product.amount, + product_data: { + name: product.name, + description: product.description, + }, + }, + quantity: 1, + }, + ], + success_url: `${DOMAIN}/success.html?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${DOMAIN}/#collection`, + locale: 'fr', + custom_text: { + submit: { message: 'Pièce unique — fabriquée à Paris. Délai de fabrication : 6 à 8 semaines.' }, + }, + }) + + return { url: session.url } + }, + { + body: t.Object({ + product: t.String(), + email: t.Optional(t.String({ format: 'email' })), + }), + } + ) + + // ── Vérification de session (page success) ──────────────────────────────── + .get( + '/api/session/:id', + async ({ params }) => { + const session = await stripe.checkout.sessions.retrieve(params.id) + return { + status: session.payment_status, + amount: session.amount_total, + currency: session.currency, + customer_email: session.customer_details?.email ?? null, + } + }, + { + params: t.Object({ id: t.String() }), + } + ) + + // ── Webhook Stripe ──────────────────────────────────────────────────────── + .post('/api/webhook', async ({ request, headers }) => { + const sig = headers['stripe-signature'] + const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET + + if (!sig || !webhookSecret) { + return new Response('Missing signature', { status: 400 }) + } + + let event: Stripe.Event + const rawBody = await request.arrayBuffer() + try { + event = stripe.webhooks.constructEvent( + Buffer.from(rawBody), + sig, + webhookSecret + ) + } catch (err) { + console.error('⚠ Webhook signature invalide:', err) + return new Response('Webhook Error', { status: 400 }) + } + + if (event.type === 'checkout.session.completed') { + const session = event.data.object as Stripe.Checkout.Session + if (session.payment_status === 'paid') { + console.log(`✓ Paiement reçu — session: ${session.id}`) + console.log(` Client: ${session.customer_details?.email}`) + console.log(` Montant: ${(session.amount_total ?? 0) / 100} EUR`) + // → ici : envoyer email confirmation, mettre à jour BDD, etc. + } + } + + return { received: true } + }) + + .listen(3000) + +console.log(` + ┌──────────────────────────────────────┐ + │ REBOUR — SERVEUR DÉMARRÉ │ + │ http://localhost:3000 │ + │ │ + │ POST /api/checkout │ + │ GET /api/session/:id │ + │ POST /api/webhook │ + └──────────────────────────────────────┘ +`) diff --git a/style.css b/style.css index c7540cf..9d77938 100644 --- a/style.css +++ b/style.css @@ -387,6 +387,112 @@ hr { border: none; border-top: var(--border); margin: 0; } 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; diff --git a/success.html b/success.html new file mode 100644 index 0000000..f73e50a --- /dev/null +++ b/success.html @@ -0,0 +1,168 @@ + + + + + + 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 +
+
+ +
+ © 2026 REBOUR STUDIO — PARIS + INSTAGRAM / CONTACT +
+ + + + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}