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>
|
</details>
|
||||||
|
|
||||||
<hr>
|
<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">
|
<div class="panel-footer">
|
||||||
<span class="blink">■</span> COLLECTION_001 — W.I.P
|
<span class="blink">■</span> COLLECTION_001 — W.I.P
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
55
main.js
55
main.js
@ -87,6 +87,49 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
notes: document.getElementById('panel-notes'),
|
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) {
|
function openPanel(card) {
|
||||||
fields.img.src = card.dataset.img;
|
fields.img.src = card.dataset.img;
|
||||||
fields.img.alt = card.dataset.name;
|
fields.img.alt = card.dataset.name;
|
||||||
@ -100,6 +143,16 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
fields.specs.textContent = card.dataset.specs;
|
fields.specs.textContent = card.dataset.specs;
|
||||||
fields.notes.textContent = card.dataset.notes;
|
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
|
// Ferme les accordéons
|
||||||
panel.querySelectorAll('details').forEach(d => d.removeAttribute('open'));
|
panel.querySelectorAll('details').forEach(d => d.removeAttribute('open'));
|
||||||
|
|
||||||
@ -108,7 +161,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
// Refresh cursor sur les nouveaux éléments
|
// 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', () => {
|
el.addEventListener('mouseenter', () => {
|
||||||
cursorOutline.style.width = '38px';
|
cursorOutline.style.width = '38px';
|
||||||
cursorOutline.style.height = '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;
|
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 ---- */
|
||||||
.newsletter {
|
.newsletter {
|
||||||
display: grid;
|
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