add stripe and elysia backend
This commit is contained in:
parent
f116ffae59
commit
f790cdedcc
111
.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc
Normal file
111
.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc
Normal file
@ -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 <file>` instead of `node <file>` or `ts-node <file>`
|
||||
- Use `bun test` instead of `jest` or `vitest`
|
||||
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
|
||||
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
||||
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
|
||||
- Use `bunx <package> <command>` instead of `npx <package> <command>`
|
||||
- Bun automatically loads .env, so don't use dotenv.
|
||||
|
||||
## APIs
|
||||
|
||||
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
|
||||
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
|
||||
- `Bun.redis` for Redis. Don't use `ioredis`.
|
||||
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
|
||||
- `WebSocket` is built-in. Don't use `ws`.
|
||||
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
||||
- Bun.$`ls` instead of execa.
|
||||
|
||||
## Testing
|
||||
|
||||
Use `bun test` to run tests.
|
||||
|
||||
```ts#index.test.ts
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
test("hello world", () => {
|
||||
expect(1).toBe(1);
|
||||
});
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
|
||||
|
||||
Server:
|
||||
|
||||
```ts#index.ts
|
||||
import index from "./index.html"
|
||||
|
||||
Bun.serve({
|
||||
routes: {
|
||||
"/": index,
|
||||
"/api/users/:id": {
|
||||
GET: (req) => {
|
||||
return new Response(JSON.stringify({ id: req.params.id }));
|
||||
},
|
||||
},
|
||||
},
|
||||
// optional websocket support
|
||||
websocket: {
|
||||
open: (ws) => {
|
||||
ws.send("Hello, world!");
|
||||
},
|
||||
message: (ws, message) => {
|
||||
ws.send(message);
|
||||
},
|
||||
close: (ws) => {
|
||||
// handle close
|
||||
}
|
||||
},
|
||||
development: {
|
||||
hmr: true,
|
||||
console: true,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
|
||||
|
||||
```html#index.html
|
||||
<html>
|
||||
<body>
|
||||
<h1>Hello, world!</h1>
|
||||
<script type="module" src="./frontend.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
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 <h1>Hello, world!</h1>;
|
||||
}
|
||||
|
||||
root.render(<Frontend />);
|
||||
```
|
||||
|
||||
Then, run index.ts
|
||||
|
||||
```sh
|
||||
bun --hot ./index.ts
|
||||
```
|
||||
|
||||
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
|
||||
3
.env.example
Normal file
3
.env.example
Normal file
@ -0,0 +1,3 @@
|
||||
STRIPE_SECRET_KEY=sk_test_...
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
DOMAIN=http://localhost:3000
|
||||
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
@ -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
|
||||
15
README.md
Normal file
15
README.md
Normal file
@ -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.
|
||||
72
bun.lock
Normal file
72
bun.lock
Normal file
@ -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=="],
|
||||
}
|
||||
}
|
||||
27
index.html
27
index.html
@ -63,6 +63,33 @@
|
||||
</details>
|
||||
|
||||
<hr>
|
||||
|
||||
<!-- Bouton + form commande — uniquement PROJET_001 -->
|
||||
<div id="checkout-section" style="display:none;">
|
||||
<div class="checkout-price-line">
|
||||
<span class="checkout-price">1 800 €</span>
|
||||
<span class="checkout-edition">ÉDITION UNIQUE — 1/1</span>
|
||||
</div>
|
||||
<button id="checkout-toggle-btn" class="checkout-btn">
|
||||
[ COMMANDER CETTE PIÈCE ]
|
||||
</button>
|
||||
<div id="checkout-form-wrap" class="checkout-form-wrap" style="display:none;">
|
||||
<form id="checkout-form" class="checkout-form">
|
||||
<div class="checkout-form-field">
|
||||
<label for="checkout-email">EMAIL *</label>
|
||||
<input type="email" id="checkout-email" name="email" placeholder="votre@email.com" required autocomplete="off">
|
||||
</div>
|
||||
<div class="checkout-form-note">
|
||||
Pièce fabriquée à Paris. Délai : 6 à 8 semaines.<br>
|
||||
Paiement sécurisé via Stripe.
|
||||
</div>
|
||||
<button type="submit" class="checkout-submit" id="checkout-submit-btn">
|
||||
PROCÉDER AU PAIEMENT →
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-footer">
|
||||
<span class="blink">■</span> COLLECTION_001 — W.I.P
|
||||
</div>
|
||||
|
||||
55
main.js
55
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';
|
||||
|
||||
20
package.json
Normal file
20
package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
140
server.ts
Normal file
140
server.ts
Normal file
@ -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 │
|
||||
└──────────────────────────────────────┘
|
||||
`)
|
||||
106
style.css
106
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;
|
||||
|
||||
168
success.html
Normal file
168
success.html
Normal file
@ -0,0 +1,168 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>REBOUR — COMMANDE CONFIRMÉE</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--clr-bg: #e8e8e4;
|
||||
--clr-black: #111;
|
||||
--clr-accent: #e8a800;
|
||||
--border: 1px solid #111;
|
||||
--font-mono: 'Space Mono', monospace;
|
||||
}
|
||||
* { 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);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 1.1rem 2rem;
|
||||
border-bottom: var(--border);
|
||||
}
|
||||
.logo { font-size: 1rem; font-weight: 700; letter-spacing: 0.18em; }
|
||||
main {
|
||||
flex-grow: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.left {
|
||||
border-right: var(--border);
|
||||
padding: 5rem 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
gap: 2rem;
|
||||
}
|
||||
.label { font-size: 0.75rem; color: #888; }
|
||||
h1 {
|
||||
font-size: clamp(2.5rem, 5vw, 4.5rem);
|
||||
font-weight: 700;
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.status-line {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.8;
|
||||
color: #555;
|
||||
}
|
||||
.amount {
|
||||
display: inline-block;
|
||||
background: var(--clr-accent);
|
||||
padding: 0.3rem 0.7rem;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.right {
|
||||
padding: 5rem 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
hr { border: none; border-top: var(--border); }
|
||||
.info-row {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
padding: 0.6rem 0;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.1);
|
||||
font-size: 0.8rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
.info-key { color: #888; width: 8rem; flex-shrink: 0; font-size: 0.72rem; }
|
||||
a.back {
|
||||
display: inline-block;
|
||||
border: var(--border);
|
||||
padding: 0.9rem 1.5rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
color: var(--clr-black);
|
||||
transition: background 0.15s;
|
||||
align-self: flex-start;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
a.back:hover { background: var(--clr-black); color: #f5f5f0; }
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 1.1rem 2rem;
|
||||
border-top: var(--border);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
#loading { color: #888; font-size: 0.78rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<span class="logo">REBOUR</span>
|
||||
<span style="font-size:0.78rem;color:#888">COLLECTION_001</span>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="left">
|
||||
<p class="label">// COMMANDE_CONFIRMÉE</p>
|
||||
<h1>MERCI<br>POUR<br>VOTRE<br>COMMANDE</h1>
|
||||
<p class="status-line" id="loading">Vérification du paiement...</p>
|
||||
</div>
|
||||
<div class="right">
|
||||
<p class="label">// RÉCAPITULATIF</p>
|
||||
<hr>
|
||||
<div id="order-details" style="display:none; flex-direction:column; gap:0;">
|
||||
<div class="info-row"><span class="info-key">PRODUIT</span><span>LUMIÈRE_ORBITALE</span></div>
|
||||
<div class="info-row"><span class="info-key">COLLECTION</span><span>001 — ÉDITION UNIQUE</span></div>
|
||||
<div class="info-row"><span class="info-key">MONTANT</span><span id="amount-display"></span></div>
|
||||
<div class="info-row"><span class="info-key">EMAIL</span><span id="email-display"></span></div>
|
||||
<div class="info-row"><span class="info-key">DÉLAI</span><span>6 À 8 SEMAINES</span></div>
|
||||
<div class="info-row"><span class="info-key">STATUS</span><span style="color:#e8a800; font-weight:700">CONFIRMÉ ■</span></div>
|
||||
</div>
|
||||
<p style="font-size:0.78rem; line-height:1.8; color:#555; margin-top:1rem;">
|
||||
Un email de confirmation vous sera envoyé.<br>
|
||||
Votre lampe est fabriquée à la main à Paris.
|
||||
</p>
|
||||
<a href="/" class="back">← RETOUR À LA COLLECTION</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<span>© 2026 REBOUR STUDIO — PARIS</span>
|
||||
<span>INSTAGRAM / CONTACT</span>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const sessionId = params.get('session_id')
|
||||
|
||||
if (sessionId) {
|
||||
fetch(`/api/session/${sessionId}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
document.getElementById('loading').style.display = 'none'
|
||||
document.getElementById('order-details').style.display = 'flex'
|
||||
|
||||
const amount = data.amount ? `${(data.amount / 100).toLocaleString('fr-FR')} €` : '—'
|
||||
document.getElementById('amount-display').textContent = amount
|
||||
document.getElementById('email-display').textContent = data.customer_email ?? '—'
|
||||
})
|
||||
.catch(() => {
|
||||
document.getElementById('loading').textContent = 'Commande enregistrée.'
|
||||
})
|
||||
} else {
|
||||
document.getElementById('loading').textContent = 'Commande enregistrée.'
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user