feat: scaffold Antonym fashion store
Anonymous Bitcoin-only fashion e-commerce with: - Vue 3 + Tailwind 4 frontend with glassmorphism dark/light design system - Express 5 + SQLite backend with BTCPay Server integration - Nostr identity (NIP-07/keypair) for anonymous purchase tracking - ChaCha20-Poly1305 encrypted shipping addresses - Admin panel with order/product/stock management - SVG logo splash animation with clip-path reveal - 5 seeded products across 4 categories Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
node_modules/
|
||||
dist/
|
||||
target/
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.env.local
|
||||
.DS_Store
|
||||
loop/loop.log
|
||||
data/
|
||||
24
env.example.txt
Normal file
24
env.example.txt
Normal file
@@ -0,0 +1,24 @@
|
||||
# BTCPay Server
|
||||
BTCPAY_URL=https://btcpay.example.com
|
||||
BTCPAY_API_KEY=
|
||||
BTCPAY_STORE_ID=
|
||||
BTCPAY_WEBHOOK_SECRET=
|
||||
|
||||
# Encryption (32-byte hex string for ChaCha20-Poly1305)
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
# Admin
|
||||
ADMIN_PASSWORD=
|
||||
|
||||
# SMTP (optional, for email notifications)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
SMTP_FROM=orders@example.com
|
||||
|
||||
# Nostr (shop's private key hex for sending DMs)
|
||||
NOSTR_PRIVATE_KEY=
|
||||
|
||||
# Server
|
||||
PORT=3141
|
||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logos/logo.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Antonym</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
58
package.json
Normal file
58
package.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "antonym",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently -n app,api -c blue,green \"vite\" \"tsx watch server/index.ts\"",
|
||||
"dev:app": "vite",
|
||||
"dev:server": "tsx watch server/index.ts",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "vue-tsc --noEmit && tsc -p server/tsconfig.json --noEmit",
|
||||
"lint": "eslint src/ server/ shared/",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"clean": "rm -r dist/"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0",
|
||||
"pinia": "^2.3.1",
|
||||
"@vueuse/core": "^12.7.0",
|
||||
"express": "^5.1.0",
|
||||
"better-sqlite3": "^11.9.1",
|
||||
"nodemailer": "^6.10.1",
|
||||
"nostr-tools": "^2.12.0",
|
||||
"nanoid": "^5.1.5",
|
||||
"helmet": "^8.1.0",
|
||||
"cors": "^2.8.5",
|
||||
"cookie-parser": "^1.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.2",
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"vite": "^6.3.5",
|
||||
"vue-tsc": "^2.2.8",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"vitest": "^3.1.1",
|
||||
"tsx": "^4.19.4",
|
||||
"concurrently": "^9.1.2",
|
||||
"@types/express": "^5.0.2",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/node": "^22.14.1",
|
||||
"eslint": "^9.24.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependenciesFile": "",
|
||||
"onlyBuiltDependencies": [
|
||||
"better-sqlite3",
|
||||
"esbuild",
|
||||
"vue-demi"
|
||||
]
|
||||
}
|
||||
}
|
||||
3855
pnpm-lock.yaml
generated
Normal file
3855
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
8
public/logos/logo.svg
Normal file
8
public/logos/logo.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg width="309" height="121" viewBox="0 0 309 121" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M162.632 33.2121C163.232 32.2421 163.772 31.5121 163.912 30.5021L135.492 31.8321L122.392 32.4621L119.402 37.3121L112.622 47.5121L104.132 60.8621L98.9019 69.7521C98.6619 70.1621 98.8519 71.0821 98.9619 71.7221C99.2819 73.6221 96.2519 75.7121 94.4619 74.8021C93.9719 74.5521 93.1219 73.6421 92.6319 73.2221L93.0919 70.5921L95.4019 68.9721C101.212 58.7821 107.562 49.1821 113.992 39.3721C115.932 36.4121 116.642 35.8421 118.382 32.6921L107.602 33.1421C106.832 33.1721 105.662 34.4921 105.092 35.0021L93.2019 45.5121L88.3219 49.9921C83.1919 54.7021 79.0419 60.0821 74.3019 65.2421L69.0919 70.9121C68.6119 71.4321 67.3019 72.0121 66.6919 71.8521C66.0819 71.6921 65.1519 70.8321 64.5619 70.1221C64.1519 69.6221 64.9619 68.1621 65.5919 67.8621C67.6819 66.8721 69.1019 65.5921 70.6019 63.9421L77.3919 56.4521C84.8719 48.2021 93.0619 41.1021 101.802 33.5421L79.5719 34.3221L60.4419 35.1421L35.2419 36.4021C25.4019 36.8921 15.8619 37.5221 6.10188 39.2721C6.37188 39.9121 6.09188 41.1021 5.30188 41.3021C3.98188 41.6521 2.46188 41.7021 1.05188 41.3021C0.231881 41.0621 -0.268119 38.9721 0.151881 38.1721C2.80188 36.7521 14.1019 34.9321 16.9519 34.7521L51.0919 32.5921L61.3219 32.0921L75.6519 31.5021L105.632 30.2321L123.502 14.8521L127.712 11.2321L134.672 5.46214C137.862 2.82214 139.972 -1.68786 142.382 0.652144C142.912 1.17214 143.712 2.67214 143.162 3.25214C141.882 4.61214 140.752 5.73214 139.812 7.12214L135.402 13.6521L127.162 25.7721C126.322 27.0021 125.532 27.8621 125.042 29.2821L135.952 28.7421L157.692 27.8221L165.922 27.5521L170.822 19.0121L175.202 10.9721C175.672 10.1121 177.182 9.56214 177.952 9.70214C178.982 9.89214 180.102 11.4321 179.522 12.5221C179.082 13.3521 177.792 14.3221 177.342 15.0721L175.182 18.6321L170.062 27.3421L178.322 27.0621L230.682 25.7121L237.122 25.6121C238.832 25.5821 240.382 25.6121 242.032 25.5421L254.252 24.9721L268.542 23.8721C275.472 23.3421 280.512 23.1521 287.102 21.2221C288.122 20.9221 289.982 20.8921 290.732 21.3321C291.072 21.5221 291.502 22.5021 291.542 22.8621C291.592 23.3021 290.692 23.9621 290.222 24.0321L278.152 25.7921C269.252 27.0921 260.532 27.6521 251.462 27.9121L210.192 29.0921L180.442 29.9021L168.252 30.3221L153.402 54.2721C151.782 56.8821 150.262 59.0721 148.792 62.0521C151.352 60.3221 155.772 56.1821 158.492 54.4321L170.452 46.7321C171.992 45.7421 172.572 43.6721 175.132 44.9121C175.692 45.1821 176.842 45.6821 176.862 46.2821C176.962 49.4921 174.502 48.2021 168.652 51.8621C156.952 59.1821 154.642 61.9021 144.532 70.4221C143.702 71.1221 141.992 70.6021 141.372 69.9021C139.832 68.1321 142.502 66.4321 143.582 64.4721C145.602 60.8321 147.612 57.5021 149.792 53.9721L162.622 33.2121H162.632ZM111.132 29.9221L120.442 29.7121C124.772 23.9321 128.792 17.9721 132.782 11.4321C129.922 13.5521 127.422 15.7721 124.712 18.0821L118.862 23.0621L116.482 25.1321L111.132 29.9221Z" fill="black"/>
|
||||
<path d="M163.772 110.492C155.402 104.552 146.472 99.9124 137.042 95.7124L118.342 87.3824C117.392 86.9624 116.192 86.7024 115.042 86.3124L114.252 83.5724C115.732 82.1024 117.632 83.0524 122.442 82.7624L139.072 81.7524L161.582 80.9524L180.202 80.8524L207.362 80.4624L213.392 75.8924L218.322 71.9624L222.272 68.7724L221.162 67.3924C223.622 61.3724 225.872 55.2924 227.402 48.8524C228.292 45.0824 226.602 44.3824 227.932 43.1124C228.802 42.2824 229.932 42.1124 230.912 42.5124C231.632 42.8124 232.522 44.2224 232.262 45.1124L230.052 52.9224L226.872 62.7824C228.402 62.2824 229.282 61.1424 230.372 60.0024L232.752 57.5224C236.662 53.4324 251.712 39.6824 253.082 39.7424C254.112 39.7824 255.882 41.2324 255.882 42.2424C255.882 43.1924 254.512 44.0924 253.962 44.6024L227.682 68.7924L220.252 75.1724C218.262 76.8824 215.552 78.8524 213.822 80.7224C216.292 80.9124 219.352 80.7224 221.702 80.6224C228.882 80.3024 235.822 80.5424 243.012 80.5924L253.442 80.6624L258.962 80.8524L278.862 81.9924L292.332 83.1824C296.032 83.5124 299.632 84.0424 303.282 83.8024C304.272 83.7324 308.252 83.3024 308.152 85.9424C308.112 86.9524 307.082 88.0524 305.952 87.9424L284.122 85.7724L258.232 84.1324L230.192 83.7324L212.692 83.7024C211.472 83.7024 210.292 84.3924 209.322 85.2124C200.122 92.9624 191.302 100.852 182.932 109.472C180.382 112.092 177.872 114.402 175.572 117.172C174.492 118.482 173.822 120.672 171.402 119.932C171.402 119.932 165.642 111.832 163.762 110.492H163.772ZM165.982 108.462L171.362 112.892C171.762 113.222 172.492 113.752 172.892 113.652C173.292 113.552 174.062 113.122 174.412 112.782L204.192 83.6224L195.722 83.9124L182.092 84.0624L170.632 84.1724L147.362 84.9124L124.272 86.3624C124.812 87.1424 125.632 87.5524 126.522 87.9424L138.402 93.1724L145.712 96.5724C149.592 98.3724 163.172 106.152 165.972 108.462H165.982Z" fill="black"/>
|
||||
<path d="M264.651 59.0018C262.841 60.6418 258.061 64.5218 255.831 63.8618C255.061 63.6318 253.901 62.6418 253.811 61.8118L257.191 56.2218C252.531 60.2818 248.581 64.6118 244.801 69.3518C244.131 70.2018 244.081 71.3618 242.811 71.5118C241.661 71.6518 240.811 70.9318 240.231 69.8718C239.501 68.5218 240.811 68.0418 241.641 67.1318C247.621 60.5618 254.021 54.7518 260.401 48.6018L266.911 42.3318C268.051 41.2318 269.391 42.3618 269.871 43.2718C270.761 44.9318 269.451 45.0818 268.621 46.1618C265.601 50.0518 262.641 53.9718 260.201 58.3318C266.371 53.0918 272.601 49.2318 278.481 44.3418L281.961 41.4418C282.391 41.0818 283.761 41.3618 284.251 41.5718C284.741 41.7818 285.271 43.1818 284.821 43.8118C282.051 47.6618 278.801 52.5018 275.761 57.0218L268.631 67.6118C268.191 68.2618 267.861 69.5318 267.451 70.4018C267.141 71.0618 265.421 71.3418 264.731 71.1018C264.041 70.8618 262.801 69.8118 262.931 69.0118C263.101 67.9018 265.221 65.5918 266.021 64.7718C269.381 61.3018 273.481 54.5818 276.161 50.0618C274.711 50.7018 273.891 51.3718 272.801 52.2418C270.021 54.4718 267.311 56.6218 264.671 59.0218L264.651 59.0018Z" fill="black"/>
|
||||
<path d="M209.662 67.2223C209.172 68.3723 208.772 69.6523 208.032 69.9323C207.012 70.3123 205.152 69.8823 204.842 69.0623C204.152 67.2423 205.622 66.3423 206.382 65.1223C208.662 61.4523 210.792 57.9623 212.652 54.0223L204.742 60.7123C200.482 64.3123 197.082 68.6323 193.462 72.9023C192.822 73.6523 191.642 74.1523 190.952 73.9323C190.032 73.6323 188.702 72.4323 189.222 71.6023C190.542 69.4823 191.642 67.6823 192.762 65.6123L198.762 54.5823L205.352 41.6423C205.702 40.9623 207.722 40.8323 208.402 41.1823C209.122 41.5523 209.982 42.8323 210.222 43.7423C208.302 44.5423 207.522 45.5123 206.672 47.0823L203.622 52.7123L198.892 61.2723C200.202 60.3423 201.302 59.3723 202.602 58.2723L210.842 51.2523L216.502 46.2123C217.412 45.4023 217.462 43.8323 219.022 43.9523C220.002 44.0223 221.962 45.1123 221.152 46.5223L214.312 58.4923C212.682 61.3523 211.012 64.0723 209.662 67.2623V67.2223Z" fill="black"/>
|
||||
<path d="M120.041 66.0921C117.531 68.3021 111.991 74.6621 108.871 72.9321C108.351 72.6421 107.691 71.3221 108.041 70.8321L112.331 64.8221C115.871 59.8621 119.131 54.8321 121.681 49.2721C122.051 48.4721 122.331 47.5821 122.841 47.1021C123.431 46.5521 124.801 46.4221 125.351 47.0021C125.901 47.5821 126.431 48.3021 126.561 48.7921C126.691 49.2821 125.741 50.1221 125.471 50.5621C123.221 54.2521 121.191 57.9321 118.611 61.4021C117.751 62.5621 117.001 63.6221 116.431 64.9021L125.361 57.0921L132.551 51.6021L138.361 46.9521C139.331 46.1821 141.691 48.4621 141.121 49.4321C139.251 51.9821 137.581 54.3121 136.021 56.9421L133.511 61.1721L129.901 67.8321C129.421 68.7121 127.711 69.2521 127.151 68.5421L125.041 65.8421C127.761 63.5021 129.731 60.8521 131.371 57.7821L132.571 55.5521L126.361 60.5221L120.041 66.0821V66.0921Z" fill="black"/>
|
||||
<path d="M189.921 51.7524C184.441 56.3024 180.001 61.3624 175.351 66.5424L172.661 69.5424C172.151 70.1124 171.031 70.6424 170.181 70.5524C169.551 70.4924 168.661 69.9524 168.321 69.5124C167.981 69.0724 168.111 67.9324 168.491 67.3624L175.621 56.7024L179.141 51.9724C181.891 48.2824 184.051 44.5524 186.211 40.3724C187.151 39.7624 188.831 40.6424 189.881 41.3024C190.281 41.5524 194.551 41.6324 196.871 43.1824L197.911 45.7624C197.311 47.0624 195.961 47.2324 194.921 47.7924C193.161 48.7324 191.621 50.3524 189.911 51.7724L189.921 51.7524ZM182.321 54.0124L190.771 46.3624C190.281 45.3424 188.511 45.4324 187.761 45.8424C187.201 46.1424 186.671 47.0824 186.161 47.8124L181.181 54.8824C181.911 55.3924 181.951 54.3624 182.321 54.0224V54.0124Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.2 KiB |
22
server/db/connection.ts
Normal file
22
server/db/connection.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const DB_PATH = path.join(__dirname, '..', '..', 'data', 'antonym.db')
|
||||
|
||||
let db: Database.Database | null = null
|
||||
|
||||
export function getDb(): Database.Database {
|
||||
if (!db) {
|
||||
const dir = path.dirname(DB_PATH)
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
db = new Database(DB_PATH)
|
||||
db.pragma('journal_mode = WAL')
|
||||
db.pragma('foreign_keys = ON')
|
||||
}
|
||||
return db
|
||||
}
|
||||
53
server/db/schema.ts
Normal file
53
server/db/schema.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type Database from 'better-sqlite3'
|
||||
|
||||
export function initSchema(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
price_sats INTEGER NOT NULL,
|
||||
images TEXT NOT NULL DEFAULT '[]',
|
||||
sizes TEXT NOT NULL DEFAULT '[]',
|
||||
category TEXT NOT NULL DEFAULT 'general',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id TEXT PRIMARY KEY,
|
||||
nostr_pubkey TEXT,
|
||||
email TEXT,
|
||||
btcpay_invoice_id TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
shipping_address_encrypted TEXT,
|
||||
items TEXT NOT NULL,
|
||||
total_sats INTEGER NOT NULL,
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id TEXT NOT NULL REFERENCES orders(id),
|
||||
status TEXT NOT NULL,
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_orders_nostr ON orders(nostr_pubkey);
|
||||
CREATE INDEX IF NOT EXISTS idx_order_events_order ON order_events(order_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_slug ON products(slug);
|
||||
CREATE INDEX IF NOT EXISTS idx_products_category ON products(category);
|
||||
`)
|
||||
}
|
||||
19
server/db/seed.ts
Normal file
19
server/db/seed.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type Database from 'better-sqlite3'
|
||||
import { nanoid } from 'nanoid'
|
||||
|
||||
export function seedProducts(db: Database.Database): void {
|
||||
const count = db.prepare('SELECT COUNT(*) as n FROM products').get() as { n: number }
|
||||
if (count.n > 0) return
|
||||
|
||||
const products = [
|
||||
{ id: nanoid(), name: 'Shadow Hoodie', slug: 'shadow-hoodie', description: 'Heavyweight cotton hoodie with embroidered Antonym logo. Oversized fit.', price_sats: 250_000, images: JSON.stringify(['/images/shadow-hoodie.jpg']), sizes: JSON.stringify([{ size: 'S', stock: 10 }, { size: 'M', stock: 15 }, { size: 'L', stock: 12 }, { size: 'XL', stock: 8 }]), category: 'tops' },
|
||||
{ id: nanoid(), name: 'Inverse Tee', slug: 'inverse-tee', description: 'Premium organic cotton t-shirt. Minimalist design with contrast stitching.', price_sats: 85_000, images: JSON.stringify(['/images/inverse-tee.jpg']), sizes: JSON.stringify([{ size: 'S', stock: 20 }, { size: 'M', stock: 25 }, { size: 'L', stock: 18 }, { size: 'XL', stock: 10 }]), category: 'tops' },
|
||||
{ id: nanoid(), name: 'Contradiction Cargo', slug: 'contradiction-cargo', description: 'Relaxed cargo pants with hidden zip pockets. Washed black.', price_sats: 180_000, images: JSON.stringify(['/images/contradiction-cargo.jpg']), sizes: JSON.stringify([{ size: '28', stock: 8 }, { size: '30', stock: 12 }, { size: '32', stock: 15 }, { size: '34', stock: 10 }, { size: '36', stock: 6 }]), category: 'bottoms' },
|
||||
{ id: nanoid(), name: 'Paradox Cap', slug: 'paradox-cap', description: 'Unstructured six-panel cap with tonal embroidery. One size.', price_sats: 45_000, images: JSON.stringify(['/images/paradox-cap.jpg']), sizes: JSON.stringify([{ size: 'ONE SIZE', stock: 30 }]), category: 'accessories' },
|
||||
{ id: nanoid(), name: 'Duality Jacket', slug: 'duality-jacket', description: 'Reversible bomber jacket. Matte black one side, reflective silver the other.', price_sats: 450_000, images: JSON.stringify(['/images/duality-jacket.jpg']), sizes: JSON.stringify([{ size: 'S', stock: 5 }, { size: 'M', stock: 8 }, { size: 'L', stock: 6 }, { size: 'XL', stock: 4 }]), category: 'outerwear' },
|
||||
]
|
||||
|
||||
const insert = db.prepare('INSERT INTO products (id, name, slug, description, price_sats, images, sizes, category) VALUES (@id, @name, @slug, @description, @price_sats, @images, @sizes, @category)')
|
||||
const tx = db.transaction(() => { for (const p of products) insert.run(p) })
|
||||
tx()
|
||||
}
|
||||
36
server/index.ts
Normal file
36
server/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import express from 'express'
|
||||
import helmet from 'helmet'
|
||||
import cors from 'cors'
|
||||
import cookieParser from 'cookie-parser'
|
||||
import { getDb } from './db/connection.js'
|
||||
import { initSchema } from './db/schema.js'
|
||||
import { seedProducts } from './db/seed.js'
|
||||
import { productsRouter } from './routes/products.js'
|
||||
import { ordersRouter } from './routes/orders.js'
|
||||
import { webhooksRouter } from './routes/webhooks.js'
|
||||
import { adminRouter } from './routes/admin.js'
|
||||
import { adminProductsRouter } from './routes/adminProducts.js'
|
||||
import { adminOrdersRouter } from './routes/adminOrders.js'
|
||||
|
||||
const app = express()
|
||||
const port = Number(process.env.PORT) || 3141
|
||||
|
||||
app.use(helmet({ contentSecurityPolicy: false }))
|
||||
app.use(cors({ origin: true, credentials: true }))
|
||||
app.use(cookieParser())
|
||||
app.use(express.json({ limit: '1mb' }))
|
||||
|
||||
const db = getDb()
|
||||
initSchema(db)
|
||||
seedProducts(db)
|
||||
|
||||
app.use('/api/products', productsRouter)
|
||||
app.use('/api/orders', ordersRouter)
|
||||
app.use('/api/webhooks', webhooksRouter)
|
||||
app.use('/api/admin', adminRouter)
|
||||
app.use('/api/admin/products', adminProductsRouter)
|
||||
app.use('/api/admin/orders', adminOrdersRouter)
|
||||
|
||||
app.get('/api/health', (_req, res) => { res.json({ ok: true, timestamp: new Date().toISOString() }) })
|
||||
|
||||
app.listen(port, () => { console.log(`Antonym API running on http://localhost:${port}`) })
|
||||
50
server/lib/btcpay.ts
Normal file
50
server/lib/btcpay.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
const TIMEOUT = 10_000
|
||||
|
||||
function getConfig() {
|
||||
const url = process.env.BTCPAY_URL
|
||||
const apiKey = process.env.BTCPAY_API_KEY
|
||||
const storeId = process.env.BTCPAY_STORE_ID
|
||||
if (!url || !apiKey || !storeId) throw new Error('BTCPay configuration missing: BTCPAY_URL, BTCPAY_API_KEY, BTCPAY_STORE_ID required')
|
||||
return { url: url.replace(/\/$/, ''), apiKey, storeId }
|
||||
}
|
||||
|
||||
async function btcpayFetch(path: string, options: RequestInit = {}): Promise<Response> {
|
||||
const { url, apiKey } = getConfig()
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT)
|
||||
try {
|
||||
return await fetch(`${url}${path}`, { ...options, signal: controller.signal, headers: { Authorization: `token ${apiKey}`, 'Content-Type': 'application/json', ...options.headers } })
|
||||
} finally { clearTimeout(timer) }
|
||||
}
|
||||
|
||||
export interface CreateInvoiceParams { amountSats: number; orderId: string; redirectUrl: string }
|
||||
export interface BtcPayInvoice { id: string; checkoutLink: string; status: string; amount: string; currency: string }
|
||||
|
||||
export async function createInvoice(params: CreateInvoiceParams): Promise<BtcPayInvoice> {
|
||||
const { storeId } = getConfig()
|
||||
const res = await btcpayFetch(`/api/v1/stores/${storeId}/invoices`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ amount: String(params.amountSats), currency: 'SATS', metadata: { orderId: params.orderId }, checkout: { redirectURL: params.redirectUrl, redirectAutomatically: true } }),
|
||||
})
|
||||
if (!res.ok) { const text = await res.text(); throw new Error(`BTCPay invoice creation failed: ${res.status} ${text}`) }
|
||||
return res.json() as Promise<BtcPayInvoice>
|
||||
}
|
||||
|
||||
export async function getInvoice(invoiceId: string): Promise<BtcPayInvoice> {
|
||||
const { storeId } = getConfig()
|
||||
const res = await btcpayFetch(`/api/v1/stores/${storeId}/invoices/${invoiceId}`)
|
||||
if (!res.ok) throw new Error(`BTCPay invoice fetch failed: ${res.status}`)
|
||||
return res.json() as Promise<BtcPayInvoice>
|
||||
}
|
||||
|
||||
export function validateWebhookSignature(body: string, signature: string): boolean {
|
||||
const secret = process.env.BTCPAY_WEBHOOK_SECRET
|
||||
if (!secret) return false
|
||||
const expected = crypto.createHmac('sha256', secret).update(body).digest('hex')
|
||||
const sigBuf = Buffer.from(signature.replace('sha256=', ''), 'hex')
|
||||
const expectedBuf = Buffer.from(expected, 'hex')
|
||||
if (sigBuf.length !== expectedBuf.length) return false
|
||||
return crypto.timingSafeEqual(sigBuf, expectedBuf)
|
||||
}
|
||||
33
server/lib/crypto.ts
Normal file
33
server/lib/crypto.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
const ALGORITHM = 'chacha20-poly1305'
|
||||
const NONCE_LENGTH = 12
|
||||
const TAG_LENGTH = 16
|
||||
|
||||
function getKey(): Buffer {
|
||||
const hex = process.env.ENCRYPTION_KEY
|
||||
if (!hex || hex.length !== 64) {
|
||||
throw new Error('ENCRYPTION_KEY must be a 64-character hex string (32 bytes)')
|
||||
}
|
||||
return Buffer.from(hex, 'hex')
|
||||
}
|
||||
|
||||
export function encrypt(plaintext: string): string {
|
||||
const key = getKey()
|
||||
const nonce = crypto.randomBytes(NONCE_LENGTH)
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, nonce, { authTagLength: TAG_LENGTH })
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
||||
const tag = cipher.getAuthTag()
|
||||
return Buffer.concat([nonce, tag, encrypted]).toString('hex')
|
||||
}
|
||||
|
||||
export function decrypt(ciphertext: string): string {
|
||||
const key = getKey()
|
||||
const buf = Buffer.from(ciphertext, 'hex')
|
||||
const nonce = buf.subarray(0, NONCE_LENGTH)
|
||||
const tag = buf.subarray(NONCE_LENGTH, NONCE_LENGTH + TAG_LENGTH)
|
||||
const encrypted = buf.subarray(NONCE_LENGTH + TAG_LENGTH)
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, nonce, { authTagLength: TAG_LENGTH })
|
||||
decipher.setAuthTag(tag)
|
||||
return decipher.update(encrypted) + decipher.final('utf8')
|
||||
}
|
||||
24
server/lib/mailer.ts
Normal file
24
server/lib/mailer.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
|
||||
let transporter: nodemailer.Transporter | null = null
|
||||
|
||||
function getTransporter(): nodemailer.Transporter | null {
|
||||
if (transporter) return transporter
|
||||
const host = process.env.SMTP_HOST
|
||||
if (!host) return null
|
||||
transporter = nodemailer.createTransport({ host, port: Number(process.env.SMTP_PORT) || 587, secure: Number(process.env.SMTP_PORT) === 465, auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } })
|
||||
return transporter
|
||||
}
|
||||
|
||||
export async function sendOrderConfirmation(email: string, orderId: string, totalSats: number): Promise<void> {
|
||||
const t = getTransporter()
|
||||
if (!t) return
|
||||
await t.sendMail({ from: process.env.SMTP_FROM || 'orders@antonym.fashion', to: email, subject: `Order Confirmed - ${orderId}`, text: `Your order ${orderId} for ${totalSats.toLocaleString()} sats has been confirmed.\n\nTrack your order: ${process.env.SITE_URL || 'http://localhost:3333'}/order/${orderId}` })
|
||||
}
|
||||
|
||||
export async function sendStatusUpdate(email: string, orderId: string, status: string, note?: string): Promise<void> {
|
||||
const t = getTransporter()
|
||||
if (!t) return
|
||||
const noteText = note ? `\n\nNote: ${note}` : ''
|
||||
await t.sendMail({ from: process.env.SMTP_FROM || 'orders@antonym.fashion', to: email, subject: `Order ${orderId} - ${status.charAt(0).toUpperCase() + status.slice(1)}`, text: `Your order ${orderId} status has been updated to: ${status}${noteText}\n\nTrack your order: ${process.env.SITE_URL || 'http://localhost:3333'}/order/${orderId}` })
|
||||
}
|
||||
28
server/lib/nostr.ts
Normal file
28
server/lib/nostr.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export async function sendDm(recipientPubkey: string, message: string): Promise<void> {
|
||||
const privkeyHex = process.env.NOSTR_PRIVATE_KEY
|
||||
if (!privkeyHex || !recipientPubkey) return
|
||||
|
||||
try {
|
||||
const { finalizeEvent } = await import('nostr-tools/pure')
|
||||
const { encrypt } = await import('nostr-tools/nip04')
|
||||
const privkey = Uint8Array.from(Buffer.from(privkeyHex, 'hex'))
|
||||
const ciphertext = await encrypt(privkey, recipientPubkey, message)
|
||||
const event = finalizeEvent({ kind: 4, created_at: Math.floor(Date.now() / 1000), tags: [['p', recipientPubkey]], content: ciphertext }, privkey)
|
||||
privkey.fill(0)
|
||||
|
||||
const { Relay } = await import('nostr-tools/relay')
|
||||
const relays = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.nostr.band']
|
||||
for (const url of relays) {
|
||||
try { const relay = await Relay.connect(url); await relay.publish(event); relay.close() } catch {}
|
||||
}
|
||||
} catch { console.error('Failed to send Nostr DM') }
|
||||
}
|
||||
|
||||
export async function notifyOrderConfirmed(pubkey: string, orderId: string, totalSats: number): Promise<void> {
|
||||
await sendDm(pubkey, `Your Antonym order ${orderId} for ${totalSats.toLocaleString()} sats has been confirmed. Track it at: ${process.env.SITE_URL || 'http://localhost:3333'}/order/${orderId}`)
|
||||
}
|
||||
|
||||
export async function notifyStatusUpdate(pubkey: string, orderId: string, status: string, note?: string): Promise<void> {
|
||||
const noteText = note ? ` — ${note}` : ''
|
||||
await sendDm(pubkey, `Your Antonym order ${orderId} has been updated to: ${status}${noteText}`)
|
||||
}
|
||||
48
server/middleware/adminAuth.ts
Normal file
48
server/middleware/adminAuth.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
import crypto from 'node:crypto'
|
||||
import { getDb } from '../db/connection.js'
|
||||
|
||||
export function adminAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
const token = req.cookies?.admin_session
|
||||
if (!token) { res.status(401).json({ error: { code: 'UNAUTHORIZED', message: 'Authentication required' } }); return }
|
||||
const db = getDb()
|
||||
const session = db.prepare("SELECT token FROM admin_sessions WHERE token = ? AND expires_at > datetime('now')").get(token) as { token: string } | undefined
|
||||
if (!session) { res.status(401).json({ error: { code: 'SESSION_EXPIRED', message: 'Session expired' } }); return }
|
||||
next()
|
||||
}
|
||||
|
||||
const loginAttempts = new Map<string, { count: number; resetAt: number }>()
|
||||
|
||||
export function rateLimit(req: Request, res: Response, next: NextFunction): void {
|
||||
const ip = req.ip || 'unknown'
|
||||
const now = Date.now()
|
||||
const entry = loginAttempts.get(ip)
|
||||
if (entry) {
|
||||
if (now > entry.resetAt) { loginAttempts.set(ip, { count: 1, resetAt: now + 60_000 }) }
|
||||
else if (entry.count >= 5) { res.status(429).json({ error: { code: 'RATE_LIMITED', message: 'Too many attempts' } }); return }
|
||||
else { entry.count++ }
|
||||
} else { loginAttempts.set(ip, { count: 1, resetAt: now + 60_000 }) }
|
||||
next()
|
||||
}
|
||||
|
||||
export function createSession(): string {
|
||||
const token = crypto.randomBytes(32).toString('hex')
|
||||
const db = getDb()
|
||||
db.prepare("INSERT INTO admin_sessions (token, expires_at) VALUES (?, datetime('now', '+24 hours'))").run(token)
|
||||
db.prepare("DELETE FROM admin_sessions WHERE expires_at < datetime('now')").run()
|
||||
return token
|
||||
}
|
||||
|
||||
export function deleteSession(token: string): void {
|
||||
const db = getDb()
|
||||
db.prepare('DELETE FROM admin_sessions WHERE token = ?').run(token)
|
||||
}
|
||||
|
||||
export function verifyPassword(input: string): boolean {
|
||||
const expected = process.env.ADMIN_PASSWORD
|
||||
if (!expected) return false
|
||||
const inputBuf = Buffer.from(input)
|
||||
const expectedBuf = Buffer.from(expected)
|
||||
if (inputBuf.length !== expectedBuf.length) return false
|
||||
return crypto.timingSafeEqual(inputBuf, expectedBuf)
|
||||
}
|
||||
20
server/middleware/validate.ts
Normal file
20
server/middleware/validate.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
|
||||
export function requireBody(...fields: string[]) {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
const missing = fields.filter((f) => req.body[f] === undefined || req.body[f] === null)
|
||||
if (missing.length > 0) { res.status(400).json({ error: { code: 'MISSING_FIELDS', message: `Missing required fields: ${missing.join(', ')}` } }); return }
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeString(val: unknown): string {
|
||||
if (typeof val !== 'string') return ''
|
||||
return val.trim().slice(0, 10_000)
|
||||
}
|
||||
|
||||
export function sanitizeInt(val: unknown): number | null {
|
||||
const n = Number(val)
|
||||
if (!Number.isInteger(n) || n < 0) return null
|
||||
return n
|
||||
}
|
||||
22
server/routes/admin.ts
Normal file
22
server/routes/admin.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Router } from 'express'
|
||||
import { rateLimit, createSession, deleteSession, verifyPassword, adminAuth } from '../middleware/adminAuth.js'
|
||||
import { requireBody } from '../middleware/validate.js'
|
||||
|
||||
export const adminRouter = Router()
|
||||
|
||||
adminRouter.post('/login', rateLimit, requireBody('password'), (req, res) => {
|
||||
const { password } = req.body as { password: string }
|
||||
if (!verifyPassword(password)) { res.status(401).json({ error: { code: 'INVALID_PASSWORD', message: 'Invalid password' } }); return }
|
||||
const token = createSession()
|
||||
res.cookie('admin_session', token, { httpOnly: true, sameSite: 'strict', secure: process.env.NODE_ENV === 'production', maxAge: 24 * 60 * 60 * 1000 })
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
adminRouter.post('/logout', adminAuth, (req, res) => {
|
||||
const token = req.cookies?.admin_session
|
||||
if (token) deleteSession(token)
|
||||
res.clearCookie('admin_session')
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
adminRouter.get('/verify', adminAuth, (_req, res) => { res.json({ ok: true }) })
|
||||
49
server/routes/adminOrders.ts
Normal file
49
server/routes/adminOrders.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Router } from 'express'
|
||||
import { getDb } from '../db/connection.js'
|
||||
import { adminAuth } from '../middleware/adminAuth.js'
|
||||
import { requireBody, sanitizeString } from '../middleware/validate.js'
|
||||
import { decrypt } from '../lib/crypto.js'
|
||||
import { sendStatusUpdate } from '../lib/mailer.js'
|
||||
import { notifyStatusUpdate } from '../lib/nostr.js'
|
||||
import type { Order, OrderItem, OrderEvent, OrderStatus, ShippingAddress } from '../../shared/types.js'
|
||||
|
||||
export const adminOrdersRouter = Router()
|
||||
adminOrdersRouter.use(adminAuth)
|
||||
|
||||
interface OrderRow { id: string; nostr_pubkey: string | null; email: string | null; btcpay_invoice_id: string | null; status: string; shipping_address_encrypted: string | null; items: string; total_sats: number; note: string | null; created_at: string; updated_at: string }
|
||||
|
||||
const VALID_STATUSES: OrderStatus[] = ['pending', 'paid', 'confirmed', 'shipped', 'delivered', 'cancelled']
|
||||
|
||||
adminOrdersRouter.get('/', (req, res) => {
|
||||
const db = getDb()
|
||||
const status = req.query.status as string | undefined
|
||||
let rows: OrderRow[]
|
||||
if (status) { rows = db.prepare('SELECT * FROM orders WHERE status = ? ORDER BY created_at DESC').all(status) as OrderRow[] }
|
||||
else { rows = db.prepare('SELECT * FROM orders ORDER BY created_at DESC').all() as OrderRow[] }
|
||||
res.json(rows.map((row) => ({ id: row.id, nostrPubkey: row.nostr_pubkey, email: row.email, btcpayInvoiceId: row.btcpay_invoice_id, status: row.status as OrderStatus, items: JSON.parse(row.items) as OrderItem[], totalSats: row.total_sats, note: row.note, createdAt: row.created_at, updatedAt: row.updated_at })))
|
||||
})
|
||||
|
||||
adminOrdersRouter.get('/:id', (req, res) => {
|
||||
const db = getDb()
|
||||
const row = db.prepare('SELECT * FROM orders WHERE id = ?').get(req.params.id) as OrderRow | undefined
|
||||
if (!row) { res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Order not found' } }); return }
|
||||
let shippingAddress: ShippingAddress | null = null
|
||||
if (row.shipping_address_encrypted) { try { shippingAddress = JSON.parse(decrypt(row.shipping_address_encrypted)) as ShippingAddress } catch { shippingAddress = null } }
|
||||
const events = db.prepare('SELECT * FROM order_events WHERE order_id = ? ORDER BY created_at ASC').all(req.params.id) as OrderEvent[]
|
||||
const order: Order & { shippingAddress: ShippingAddress | null; events: OrderEvent[] } = { id: row.id, nostrPubkey: row.nostr_pubkey, email: row.email, btcpayInvoiceId: row.btcpay_invoice_id, status: row.status as OrderStatus, items: JSON.parse(row.items) as OrderItem[], totalSats: row.total_sats, note: row.note, createdAt: row.created_at, updatedAt: row.updated_at, shippingAddress, events }
|
||||
res.json(order)
|
||||
})
|
||||
|
||||
adminOrdersRouter.patch('/:id/status', requireBody('status'), (req, res) => {
|
||||
const db = getDb()
|
||||
const { status, note } = req.body as { status: string; note?: string }
|
||||
if (!VALID_STATUSES.includes(status as OrderStatus)) { res.status(400).json({ error: { code: 'INVALID_STATUS', message: `Status must be one of: ${VALID_STATUSES.join(', ')}` } }); return }
|
||||
const order = db.prepare('SELECT * FROM orders WHERE id = ?').get(req.params.id) as OrderRow | undefined
|
||||
if (!order) { res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Order not found' } }); return }
|
||||
const noteText = note ? sanitizeString(note) : null
|
||||
db.prepare("UPDATE orders SET status = ?, updated_at = datetime('now') WHERE id = ?").run(status, req.params.id)
|
||||
db.prepare('INSERT INTO order_events (order_id, status, note) VALUES (?, ?, ?)').run(req.params.id, status, noteText)
|
||||
if (order.email) sendStatusUpdate(order.email, order.id, status, noteText ?? undefined).catch(() => {})
|
||||
if (order.nostr_pubkey) notifyStatusUpdate(order.nostr_pubkey, order.id, status, noteText ?? undefined).catch(() => {})
|
||||
res.json({ ok: true, status })
|
||||
})
|
||||
67
server/routes/adminProducts.ts
Normal file
67
server/routes/adminProducts.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Router } from 'express'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { getDb } from '../db/connection.js'
|
||||
import { adminAuth } from '../middleware/adminAuth.js'
|
||||
import { requireBody, sanitizeString, sanitizeInt } from '../middleware/validate.js'
|
||||
import { rowToProduct } from './products.js'
|
||||
|
||||
export const adminProductsRouter = Router()
|
||||
adminProductsRouter.use(adminAuth)
|
||||
|
||||
interface ProductRow { id: string; name: string; slug: string; description: string; price_sats: number; images: string; sizes: string; category: string; is_active: number; created_at: string; updated_at: string }
|
||||
|
||||
adminProductsRouter.get('/', (_req, res) => {
|
||||
const db = getDb()
|
||||
const rows = db.prepare('SELECT * FROM products ORDER BY created_at DESC').all() as ProductRow[]
|
||||
res.json(rows.map(rowToProduct))
|
||||
})
|
||||
|
||||
adminProductsRouter.post('/', requireBody('name', 'slug', 'priceSats'), (req, res) => {
|
||||
const db = getDb()
|
||||
const { name, slug, description, priceSats, images, sizes, category } = req.body
|
||||
const price = sanitizeInt(priceSats)
|
||||
if (price === null || price <= 0) { res.status(400).json({ error: { code: 'INVALID_PRICE', message: 'Price must be a positive integer (sats)' } }); return }
|
||||
const id = nanoid()
|
||||
try {
|
||||
db.prepare('INSERT INTO products (id, name, slug, description, price_sats, images, sizes, category) VALUES (?, ?, ?, ?, ?, ?, ?, ?)').run(id, sanitizeString(name), sanitizeString(slug), sanitizeString(description || ''), price, JSON.stringify(images || []), JSON.stringify(sizes || []), sanitizeString(category || 'general'))
|
||||
const row = db.prepare('SELECT * FROM products WHERE id = ?').get(id) as ProductRow
|
||||
res.status(201).json(rowToProduct(row))
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.message.includes('UNIQUE')) { res.status(409).json({ error: { code: 'SLUG_EXISTS', message: 'A product with this slug already exists' } }); return }
|
||||
throw err
|
||||
}
|
||||
})
|
||||
|
||||
adminProductsRouter.put('/:id', (req, res) => {
|
||||
const db = getDb()
|
||||
const { name, slug, description, priceSats, images, sizes, category, isActive } = req.body
|
||||
const existing = db.prepare('SELECT id FROM products WHERE id = ?').get(req.params.id)
|
||||
if (!existing) { res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Product not found' } }); return }
|
||||
const price = sanitizeInt(priceSats)
|
||||
if (price !== null && price <= 0) { res.status(400).json({ error: { code: 'INVALID_PRICE', message: 'Price must be a positive integer (sats)' } }); return }
|
||||
try {
|
||||
db.prepare("UPDATE products SET name = COALESCE(?, name), slug = COALESCE(?, slug), description = COALESCE(?, description), price_sats = COALESCE(?, price_sats), images = COALESCE(?, images), sizes = COALESCE(?, sizes), category = COALESCE(?, category), is_active = COALESCE(?, is_active), updated_at = datetime('now') WHERE id = ?").run(name ? sanitizeString(name) : null, slug ? sanitizeString(slug) : null, description !== undefined ? sanitizeString(description) : null, price, images ? JSON.stringify(images) : null, sizes ? JSON.stringify(sizes) : null, category ? sanitizeString(category) : null, isActive !== undefined ? (isActive ? 1 : 0) : null, req.params.id)
|
||||
const row = db.prepare('SELECT * FROM products WHERE id = ?').get(req.params.id) as ProductRow
|
||||
res.json(rowToProduct(row))
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.message.includes('UNIQUE')) { res.status(409).json({ error: { code: 'SLUG_EXISTS', message: 'A product with this slug already exists' } }); return }
|
||||
throw err
|
||||
}
|
||||
})
|
||||
|
||||
adminProductsRouter.delete('/:id', (req, res) => {
|
||||
const db = getDb()
|
||||
const result = db.prepare("UPDATE products SET is_active = 0, updated_at = datetime('now') WHERE id = ?").run(req.params.id)
|
||||
if (result.changes === 0) { res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Product not found' } }); return }
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
adminProductsRouter.patch('/:id/stock', requireBody('sizes'), (req, res) => {
|
||||
const db = getDb()
|
||||
const { sizes } = req.body
|
||||
if (!Array.isArray(sizes)) { res.status(400).json({ error: { code: 'INVALID_SIZES', message: 'Sizes must be an array' } }); return }
|
||||
const result = db.prepare("UPDATE products SET sizes = ?, updated_at = datetime('now') WHERE id = ?").run(JSON.stringify(sizes), req.params.id)
|
||||
if (result.changes === 0) { res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Product not found' } }); return }
|
||||
const row = db.prepare('SELECT * FROM products WHERE id = ?').get(req.params.id) as ProductRow
|
||||
res.json(rowToProduct(row))
|
||||
})
|
||||
56
server/routes/orders.ts
Normal file
56
server/routes/orders.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Router } from 'express'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { getDb } from '../db/connection.js'
|
||||
import { encrypt } from '../lib/crypto.js'
|
||||
import { createInvoice } from '../lib/btcpay.js'
|
||||
import { requireBody, sanitizeString } from '../middleware/validate.js'
|
||||
import type { CreateOrderRequest, Order, OrderItem, OrderEvent, OrderStatus } from '../../shared/types.js'
|
||||
|
||||
export const ordersRouter = Router()
|
||||
|
||||
interface OrderRow { id: string; nostr_pubkey: string | null; email: string | null; btcpay_invoice_id: string | null; status: string; shipping_address_encrypted: string | null; items: string; total_sats: number; note: string | null; created_at: string; updated_at: string }
|
||||
|
||||
function rowToOrder(row: OrderRow): Order {
|
||||
return { id: row.id, nostrPubkey: row.nostr_pubkey, email: row.email, btcpayInvoiceId: row.btcpay_invoice_id, status: row.status as OrderStatus, items: JSON.parse(row.items) as OrderItem[], totalSats: row.total_sats, note: row.note, createdAt: row.created_at, updatedAt: row.updated_at }
|
||||
}
|
||||
|
||||
ordersRouter.post('/', requireBody('items', 'shippingAddress'), async (req, res) => {
|
||||
try {
|
||||
const body = req.body as CreateOrderRequest
|
||||
const db = getDb()
|
||||
if (!Array.isArray(body.items) || body.items.length === 0) { res.status(400).json({ error: { code: 'INVALID_ITEMS', message: 'Order must contain items' } }); return }
|
||||
|
||||
const orderItems: OrderItem[] = []
|
||||
let totalSats = 0
|
||||
|
||||
for (const item of body.items) {
|
||||
const product = db.prepare('SELECT * FROM products WHERE id = ? AND is_active = 1').get(item.productId) as { name: string; price_sats: number; sizes: string } | undefined
|
||||
if (!product) { res.status(400).json({ error: { code: 'INVALID_PRODUCT', message: `Product ${item.productId} not found` } }); return }
|
||||
const sizes = JSON.parse(product.sizes) as { size: string; stock: number }[]
|
||||
const sizeEntry = sizes.find((s) => s.size === item.size)
|
||||
if (!sizeEntry || sizeEntry.stock < item.quantity) { res.status(400).json({ error: { code: 'INSUFFICIENT_STOCK', message: `Insufficient stock for ${product.name} size ${item.size}` } }); return }
|
||||
const lineTotal = product.price_sats * item.quantity
|
||||
totalSats += lineTotal
|
||||
orderItems.push({ productId: item.productId, productName: product.name, size: item.size, quantity: item.quantity, priceSats: product.price_sats })
|
||||
}
|
||||
|
||||
const orderId = nanoid()
|
||||
const encryptedAddress = encrypt(JSON.stringify(body.shippingAddress))
|
||||
const siteUrl = process.env.SITE_URL || 'http://localhost:3333'
|
||||
const invoice = await createInvoice({ amountSats: totalSats, orderId, redirectUrl: `${siteUrl}/order/${orderId}` })
|
||||
|
||||
db.prepare('INSERT INTO orders (id, nostr_pubkey, email, btcpay_invoice_id, status, shipping_address_encrypted, items, total_sats, note) VALUES (?, ?, ?, ?, \'pending\', ?, ?, ?, ?)').run(orderId, body.nostrPubkey ? sanitizeString(body.nostrPubkey) : null, body.email ? sanitizeString(body.email) : null, invoice.id, encryptedAddress, JSON.stringify(orderItems), totalSats, body.note ? sanitizeString(body.note) : null)
|
||||
db.prepare('INSERT INTO order_events (order_id, status, note) VALUES (?, ?, ?)').run(orderId, 'pending', 'Order created')
|
||||
|
||||
res.status(201).json({ orderId, invoiceUrl: invoice.checkoutLink, invoiceId: invoice.id })
|
||||
} catch (err) { console.error('Order creation failed:', err); res.status(500).json({ error: { code: 'ORDER_FAILED', message: 'Failed to create order' } }) }
|
||||
})
|
||||
|
||||
ordersRouter.get('/:id', (req, res) => {
|
||||
const db = getDb()
|
||||
const row = db.prepare('SELECT * FROM orders WHERE id = ?').get(req.params.id) as OrderRow | undefined
|
||||
if (!row) { res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Order not found' } }); return }
|
||||
const order = rowToOrder(row)
|
||||
const events = db.prepare('SELECT * FROM order_events WHERE order_id = ? ORDER BY created_at ASC').all(req.params.id) as OrderEvent[]
|
||||
res.json({ ...order, events })
|
||||
})
|
||||
27
server/routes/products.ts
Normal file
27
server/routes/products.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Router } from 'express'
|
||||
import { getDb } from '../db/connection.js'
|
||||
import type { Product, SizeStock } from '../../shared/types.js'
|
||||
|
||||
export const productsRouter = Router()
|
||||
|
||||
interface ProductRow { id: string; name: string; slug: string; description: string; price_sats: number; images: string; sizes: string; category: string; is_active: number; created_at: string; updated_at: string }
|
||||
|
||||
export function rowToProduct(row: ProductRow): Product {
|
||||
return { id: row.id, name: row.name, slug: row.slug, description: row.description, priceSats: row.price_sats, images: JSON.parse(row.images) as string[], sizes: JSON.parse(row.sizes) as SizeStock[], category: row.category, isActive: row.is_active === 1, createdAt: row.created_at, updatedAt: row.updated_at }
|
||||
}
|
||||
|
||||
productsRouter.get('/', (req, res) => {
|
||||
const db = getDb()
|
||||
const category = req.query.category as string | undefined
|
||||
let rows: ProductRow[]
|
||||
if (category) { rows = db.prepare('SELECT * FROM products WHERE is_active = 1 AND category = ? ORDER BY created_at DESC').all(category) as ProductRow[] }
|
||||
else { rows = db.prepare('SELECT * FROM products WHERE is_active = 1 ORDER BY created_at DESC').all() as ProductRow[] }
|
||||
res.json(rows.map(rowToProduct))
|
||||
})
|
||||
|
||||
productsRouter.get('/:slug', (req, res) => {
|
||||
const db = getDb()
|
||||
const row = db.prepare('SELECT * FROM products WHERE slug = ? AND is_active = 1').get(req.params.slug) as ProductRow | undefined
|
||||
if (!row) { res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Product not found' } }); return }
|
||||
res.json(rowToProduct(row))
|
||||
})
|
||||
47
server/routes/webhooks.ts
Normal file
47
server/routes/webhooks.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Router } from 'express'
|
||||
import { getDb } from '../db/connection.js'
|
||||
import { validateWebhookSignature } from '../lib/btcpay.js'
|
||||
import { sendOrderConfirmation } from '../lib/mailer.js'
|
||||
import { notifyOrderConfirmed } from '../lib/nostr.js'
|
||||
import type { SizeStock } from '../../shared/types.js'
|
||||
|
||||
export const webhooksRouter = Router()
|
||||
|
||||
interface OrderRow { id: string; nostr_pubkey: string | null; email: string | null; items: string; total_sats: number; status: string }
|
||||
|
||||
webhooksRouter.post('/btcpay', async (req, res) => {
|
||||
try {
|
||||
const signature = req.headers['btcpay-sig'] as string | undefined
|
||||
const rawBody = JSON.stringify(req.body)
|
||||
if (!signature || !validateWebhookSignature(rawBody, signature)) { res.status(401).json({ error: { code: 'INVALID_SIGNATURE', message: 'Invalid webhook signature' } }); return }
|
||||
|
||||
const { type, invoiceId } = req.body as { type: string; invoiceId: string }
|
||||
const db = getDb()
|
||||
const order = db.prepare('SELECT * FROM orders WHERE btcpay_invoice_id = ?').get(invoiceId) as OrderRow | undefined
|
||||
if (!order) { res.status(404).json({ error: { code: 'ORDER_NOT_FOUND', message: 'Order not found for invoice' } }); return }
|
||||
|
||||
if (type === 'InvoiceSettled' || type === 'InvoicePaymentSettled') {
|
||||
if (order.status !== 'pending') { res.json({ ok: true, message: 'Already processed' }); return }
|
||||
const items = JSON.parse(order.items) as { productId: string; size: string; quantity: number }[]
|
||||
const decrementStock = db.transaction(() => {
|
||||
for (const item of items) {
|
||||
const product = db.prepare('SELECT sizes FROM products WHERE id = ?').get(item.productId) as { sizes: string } | undefined
|
||||
if (!product) continue
|
||||
const sizes = JSON.parse(product.sizes) as SizeStock[]
|
||||
const sizeEntry = sizes.find((s) => s.size === item.size)
|
||||
if (sizeEntry) sizeEntry.stock = Math.max(0, sizeEntry.stock - item.quantity)
|
||||
db.prepare("UPDATE products SET sizes = ?, updated_at = datetime('now') WHERE id = ?").run(JSON.stringify(sizes), item.productId)
|
||||
}
|
||||
db.prepare("UPDATE orders SET status = ?, updated_at = datetime('now') WHERE id = ?").run('paid', order.id)
|
||||
db.prepare('INSERT INTO order_events (order_id, status, note) VALUES (?, ?, ?)').run(order.id, 'paid', 'Payment confirmed via BTCPay')
|
||||
})
|
||||
decrementStock()
|
||||
if (order.email) sendOrderConfirmation(order.email, order.id, order.total_sats).catch(() => {})
|
||||
if (order.nostr_pubkey) notifyOrderConfirmed(order.nostr_pubkey, order.id, order.total_sats).catch(() => {})
|
||||
} else if (type === 'InvoiceExpired') {
|
||||
db.prepare("UPDATE orders SET status = ?, updated_at = datetime('now') WHERE id = ?").run('cancelled', order.id)
|
||||
db.prepare('INSERT INTO order_events (order_id, status, note) VALUES (?, ?, ?)').run(order.id, 'cancelled', 'Invoice expired')
|
||||
}
|
||||
res.json({ ok: true })
|
||||
} catch (err) { console.error('Webhook processing failed:', err); res.status(500).json({ error: { code: 'WEBHOOK_FAILED', message: 'Webhook processing failed' } }) }
|
||||
})
|
||||
20
server/tsconfig.json
Normal file
20
server/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@shared/*": ["../shared/*"]
|
||||
},
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["./**/*.ts", "../shared/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
96
shared/types.ts
Normal file
96
shared/types.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
export interface Product {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
description: string
|
||||
priceSats: number
|
||||
images: string[]
|
||||
sizes: SizeStock[]
|
||||
category: string
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface SizeStock {
|
||||
size: string
|
||||
stock: number
|
||||
}
|
||||
|
||||
export type OrderStatus =
|
||||
| 'pending'
|
||||
| 'paid'
|
||||
| 'confirmed'
|
||||
| 'shipped'
|
||||
| 'delivered'
|
||||
| 'cancelled'
|
||||
|
||||
export interface Order {
|
||||
id: string
|
||||
nostrPubkey: string | null
|
||||
email: string | null
|
||||
btcpayInvoiceId: string | null
|
||||
status: OrderStatus
|
||||
items: OrderItem[]
|
||||
totalSats: number
|
||||
note: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface OrderItem {
|
||||
productId: string
|
||||
productName: string
|
||||
size: string
|
||||
quantity: number
|
||||
priceSats: number
|
||||
}
|
||||
|
||||
export interface OrderEvent {
|
||||
id: number
|
||||
orderId: string
|
||||
status: OrderStatus
|
||||
note: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface CartItem {
|
||||
productId: string
|
||||
slug: string
|
||||
name: string
|
||||
size: string
|
||||
quantity: number
|
||||
priceSats: number
|
||||
image: string
|
||||
}
|
||||
|
||||
export interface CreateOrderRequest {
|
||||
items: { productId: string; size: string; quantity: number }[]
|
||||
shippingAddress: ShippingAddress
|
||||
email?: string
|
||||
nostrPubkey?: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
export interface ShippingAddress {
|
||||
name: string
|
||||
line1: string
|
||||
line2?: string
|
||||
city: string
|
||||
state?: string
|
||||
postalCode: string
|
||||
country: string
|
||||
}
|
||||
|
||||
export interface CreateOrderResponse {
|
||||
orderId: string
|
||||
invoiceUrl: string
|
||||
invoiceId: string
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: {
|
||||
code: string
|
||||
message: string
|
||||
}
|
||||
}
|
||||
26
src/App.vue
Normal file
26
src/App.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import LogoSplash from '@/components/splash/LogoSplash.vue'
|
||||
|
||||
const showSplash = ref(false)
|
||||
const splashDone = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
if (!sessionStorage.getItem('antonym-splash-seen')) {
|
||||
showSplash.value = true
|
||||
} else {
|
||||
splashDone.value = true
|
||||
}
|
||||
})
|
||||
|
||||
function onSplashComplete() {
|
||||
sessionStorage.setItem('antonym-splash-seen', '1')
|
||||
showSplash.value = false
|
||||
splashDone.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LogoSplash v-if="showSplash" @complete="onSplashComplete" />
|
||||
<router-view v-if="splashDone" />
|
||||
</template>
|
||||
55
src/api/client.ts
Normal file
55
src/api/client.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
const TIMEOUT = 10_000
|
||||
|
||||
async function request(url: string, options: RequestInit = {}): Promise<Response> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT)
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
return res
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
async get(url: string): Promise<Response> {
|
||||
return request(url)
|
||||
},
|
||||
|
||||
async post(url: string, body: unknown): Promise<Response> {
|
||||
return request(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
},
|
||||
|
||||
async put(url: string, body: unknown): Promise<Response> {
|
||||
return request(url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
},
|
||||
|
||||
async patch(url: string, body: unknown): Promise<Response> {
|
||||
return request(url, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
},
|
||||
|
||||
async delete(url: string): Promise<Response> {
|
||||
return request(url, { method: 'DELETE' })
|
||||
},
|
||||
|
||||
async json<T>(res: Response): Promise<T> {
|
||||
return res.json() as Promise<T>
|
||||
},
|
||||
}
|
||||
107
src/components/admin/AdminShell.vue
Normal file
107
src/components/admin/AdminShell.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAdmin } from '@/composables/useAdmin'
|
||||
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { logout } = useAdmin()
|
||||
|
||||
async function handleLogout() {
|
||||
await logout()
|
||||
router.push({ name: 'admin-login' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-shell">
|
||||
<aside class="admin-sidebar glass-strong">
|
||||
<div class="sidebar-header">
|
||||
<router-link to="/" class="brand">Antonym</router-link>
|
||||
<span class="admin-badge">Admin</span>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<router-link :to="{ name: 'admin-orders' }" class="nav-item">Orders</router-link>
|
||||
<router-link :to="{ name: 'admin-products' }" class="nav-item">Products</router-link>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<ThemeToggle />
|
||||
<button class="logout-btn" @click="handleLogout">Logout</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="admin-main">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-shell { display: flex; min-height: 100vh; }
|
||||
|
||||
.admin-sidebar {
|
||||
width: 220px;
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 0;
|
||||
border-top: none;
|
||||
border-bottom: none;
|
||||
border-left: none;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 2rem; }
|
||||
|
||||
.brand { font-weight: 700; font-size: 1.125rem; color: var(--text-primary); text-decoration: none; }
|
||||
|
||||
.admin-badge {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.sidebar-nav { display: flex; flex-direction: column; gap: 0.25rem; flex: 1; }
|
||||
|
||||
.nav-item {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.nav-item:hover { background: var(--glass-bg); color: var(--text-primary); }
|
||||
.nav-item.router-link-active { background: var(--glass-bg-strong); color: var(--text-primary); }
|
||||
|
||||
.sidebar-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.logout-btn:hover { color: var(--error); }
|
||||
|
||||
.admin-main { flex: 1; padding: 2rem; max-width: 1000px; }
|
||||
</style>
|
||||
62
src/components/checkout/NostrIdentity.vue
Normal file
62
src/components/checkout/NostrIdentity.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useNostr } from '@/composables/useNostr'
|
||||
import GlassButton from '@/components/ui/GlassButton.vue'
|
||||
|
||||
const { pubkey, hasExtension, connectExtension, generateKeypair, disconnect } = useNostr()
|
||||
|
||||
const nsecBackup = ref<string | null>(null)
|
||||
|
||||
async function handleConnect() {
|
||||
await connectExtension()
|
||||
}
|
||||
|
||||
async function handleGenerate() {
|
||||
const result = await generateKeypair()
|
||||
if (result) {
|
||||
nsecBackup.value = result.nsec
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="nostr-identity glass-card">
|
||||
<h4>Nostr Identity (optional)</h4>
|
||||
<p class="hint">Connect to receive order updates via Nostr DM.</p>
|
||||
|
||||
<div v-if="pubkey" class="connected">
|
||||
<div class="pubkey-display">
|
||||
<span class="label">Connected:</span>
|
||||
<code class="pubkey">{{ pubkey.slice(0, 12) }}...{{ pubkey.slice(-8) }}</code>
|
||||
</div>
|
||||
<div v-if="nsecBackup" class="nsec-warning glass-card">
|
||||
<strong>Save your private key!</strong>
|
||||
<p>This will only be shown once. Store it securely.</p>
|
||||
<code class="nsec">{{ nsecBackup }}</code>
|
||||
</div>
|
||||
<GlassButton variant="ghost" @click="disconnect">Disconnect</GlassButton>
|
||||
</div>
|
||||
|
||||
<div v-else class="actions">
|
||||
<GlassButton v-if="hasExtension" variant="ghost" @click="handleConnect">
|
||||
Connect Extension (NIP-07)
|
||||
</GlassButton>
|
||||
<GlassButton variant="ghost" @click="handleGenerate">
|
||||
Generate Keypair
|
||||
</GlassButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h4 { font-size: 0.9375rem; font-weight: 600; margin-bottom: 0.25rem; }
|
||||
.hint { font-size: 0.8125rem; color: var(--text-muted); margin-bottom: 1rem; }
|
||||
.connected { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.pubkey-display { display: flex; align-items: center; gap: 0.5rem; font-size: 0.8125rem; }
|
||||
.label { color: var(--text-muted); }
|
||||
.pubkey { font-size: 0.75rem; color: var(--accent); }
|
||||
.nsec-warning { background: rgba(239, 68, 68, 0.1); border-color: var(--error); font-size: 0.8125rem; }
|
||||
.nsec-warning p { color: var(--text-muted); margin: 0.25rem 0 0.5rem; }
|
||||
.nsec { font-size: 0.6875rem; word-break: break-all; color: var(--error); }
|
||||
.actions { display: flex; gap: 0.75rem; flex-wrap: wrap; }
|
||||
</style>
|
||||
30
src/components/layout/AppShell.vue
Normal file
30
src/components/layout/AppShell.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import ShopHeader from './ShopHeader.vue'
|
||||
import ShopFooter from './ShopFooter.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<ShopHeader />
|
||||
<main class="main-content">
|
||||
<router-view />
|
||||
</main>
|
||||
<ShopFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
</style>
|
||||
68
src/components/layout/ShopFooter.vue
Normal file
68
src/components/layout/ShopFooter.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="shop-footer">
|
||||
<div class="footer-inner">
|
||||
<div class="footer-left">
|
||||
<span class="footer-brand">Antonym</span>
|
||||
<span class="footer-sep">·</span>
|
||||
<span class="footer-note">Bitcoin only. No accounts. No tracking.</span>
|
||||
</div>
|
||||
<div class="footer-right">
|
||||
<router-link to="/admin" class="footer-link">Admin</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shop-footer {
|
||||
border-top: 1px solid var(--glass-border);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.footer-inner {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.footer-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.footer-brand {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.footer-sep {
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.footer-right {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.8125rem;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.footer-link:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
118
src/components/layout/ShopHeader.vue
Normal file
118
src/components/layout/ShopHeader.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import { useCart } from '@/composables/useCart'
|
||||
import ThemeToggle from '@/components/ui/ThemeToggle.vue'
|
||||
|
||||
const { itemCount } = useCart()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="shop-header glass-strong">
|
||||
<div class="header-inner">
|
||||
<router-link to="/" class="logo-link">
|
||||
<img src="/logos/logo.svg" alt="Antonym" class="logo" />
|
||||
</router-link>
|
||||
|
||||
<nav class="nav-links">
|
||||
<router-link to="/" class="nav-link">Shop</router-link>
|
||||
</nav>
|
||||
|
||||
<div class="header-actions">
|
||||
<ThemeToggle />
|
||||
<router-link to="/cart" class="cart-link">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z" />
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<path d="M16 10a4 4 0 01-8 0" />
|
||||
</svg>
|
||||
<span v-if="itemCount > 0" class="cart-badge">{{ itemCount }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shop-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
border-radius: 0;
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.header-inner {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0.75rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.logo-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 32px;
|
||||
width: auto;
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
[data-theme="light"] .logo {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.nav-link:hover,
|
||||
.nav-link.router-link-active {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.cart-link {
|
||||
position: relative;
|
||||
color: var(--text-secondary);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.cart-link:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.cart-badge {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -8px;
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
78
src/components/product/ProductCard.vue
Normal file
78
src/components/product/ProductCard.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import type { Product } from '@shared/types'
|
||||
import SatsDisplay from '@/components/ui/SatsDisplay.vue'
|
||||
|
||||
defineProps<{
|
||||
product: Product
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-link :to="`/product/${product.slug}`" class="product-card glass-card">
|
||||
<div class="product-image">
|
||||
<img v-if="product.images[0]" :src="product.images[0]" :alt="product.name" loading="lazy" />
|
||||
<div v-else class="product-placeholder"><span>{{ product.name[0] }}</span></div>
|
||||
</div>
|
||||
<div class="product-info">
|
||||
<h3 class="product-name">{{ product.name }}</h3>
|
||||
<SatsDisplay :sats="product.priceSats" />
|
||||
</div>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-card {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
transition: border-color var(--transition-fast), transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.product-card:hover {
|
||||
border-color: var(--glass-highlight);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.product-image {
|
||||
aspect-ratio: 3 / 4;
|
||||
overflow: hidden;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.product-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform var(--transition-slow);
|
||||
}
|
||||
|
||||
.product-card:hover .product-image img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.product-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.product-info {
|
||||
padding: 1rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
22
src/components/product/ProductGrid.vue
Normal file
22
src/components/product/ProductGrid.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { Product } from '@shared/types'
|
||||
import ProductCard from './ProductCard.vue'
|
||||
|
||||
defineProps<{
|
||||
products: Product[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="product-grid">
|
||||
<ProductCard v-for="product in products" :key="product.id" :product="product" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
60
src/components/product/SizeSelector.vue
Normal file
60
src/components/product/SizeSelector.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import type { SizeStock } from '@shared/types'
|
||||
|
||||
defineProps<{
|
||||
sizes: SizeStock[]
|
||||
}>()
|
||||
|
||||
const selected = defineModel<string>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="size-selector">
|
||||
<button
|
||||
v-for="s in sizes"
|
||||
:key="s.size"
|
||||
class="size-btn"
|
||||
:class="{ active: selected === s.size, 'out-of-stock': s.stock === 0 }"
|
||||
:disabled="s.stock === 0"
|
||||
@click="selected = s.size"
|
||||
>
|
||||
{{ s.size }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.size-selector {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.size-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.size-btn:hover:not(:disabled) {
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.size-btn.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.size-btn.out-of-stock {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
</style>
|
||||
163
src/components/splash/LogoSplash.vue
Normal file
163
src/components/splash/LogoSplash.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const emit = defineEmits<{ complete: [] }>()
|
||||
|
||||
const isAnimating = ref(false)
|
||||
const isFading = ref(false)
|
||||
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
onMounted(() => {
|
||||
if (prefersReducedMotion) {
|
||||
isAnimating.value = true
|
||||
setTimeout(() => emit('complete'), 500)
|
||||
return
|
||||
}
|
||||
|
||||
// Trigger animations on next frame
|
||||
requestAnimationFrame(() => {
|
||||
isAnimating.value = true
|
||||
})
|
||||
|
||||
// Total animation: 6 paths staggered at 200ms each, each taking 700ms
|
||||
// Last path starts at 1000ms, finishes at 1700ms, plus 600ms hold
|
||||
setTimeout(() => {
|
||||
isFading.value = true
|
||||
}, 2300)
|
||||
|
||||
setTimeout(() => {
|
||||
emit('complete')
|
||||
}, 2700)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="splash-overlay"
|
||||
:class="{ 'splash-fade-out': isFading }"
|
||||
>
|
||||
<svg
|
||||
width="309"
|
||||
height="121"
|
||||
viewBox="0 0 309 121"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="splash-logo"
|
||||
>
|
||||
<!-- Path 1: Main top word -->
|
||||
<path
|
||||
class="logo-path"
|
||||
:class="{ animating: isAnimating }"
|
||||
style="--i: 0"
|
||||
d="M162.632 33.2121C163.232 32.2421 163.772 31.5121 163.912 30.5021L135.492 31.8321L122.392 32.4621L119.402 37.3121L112.622 47.5121L104.132 60.8621L98.9019 69.7521C98.6619 70.1621 98.8519 71.0821 98.9619 71.7221C99.2819 73.6221 96.2519 75.7121 94.4619 74.8021C93.9719 74.5521 93.1219 73.6421 92.6319 73.2221L93.0919 70.5921L95.4019 68.9721C101.212 58.7821 107.562 49.1821 113.992 39.3721C115.932 36.4121 116.642 35.8421 118.382 32.6921L107.602 33.1421C106.832 33.1721 105.662 34.4921 105.092 35.0021L93.2019 45.5121L88.3219 49.9921C83.1919 54.7021 79.0419 60.0821 74.3019 65.2421L69.0919 70.9121C68.6119 71.4321 67.3019 72.0121 66.6919 71.8521C66.0819 71.6921 65.1519 70.8321 64.5619 70.1221C64.1519 69.6221 64.9619 68.1621 65.5919 67.8621C67.6819 66.8721 69.1019 65.5921 70.6019 63.9421L77.3919 56.4521C84.8719 48.2021 93.0619 41.1021 101.802 33.5421L79.5719 34.3221L60.4419 35.1421L35.2419 36.4021C25.4019 36.8921 15.8619 37.5221 6.10188 39.2721C6.37188 39.9121 6.09188 41.1021 5.30188 41.3021C3.98188 41.6521 2.46188 41.7021 1.05188 41.3021C0.231881 41.0621 -0.268119 38.9721 0.151881 38.1721C2.80188 36.7521 14.1019 34.9321 16.9519 34.7521L51.0919 32.5921L61.3219 32.0921L75.6519 31.5021L105.632 30.2321L123.502 14.8521L127.712 11.2321L134.672 5.46214C137.862 2.82214 139.972 -1.68786 142.382 0.652144C142.912 1.17214 143.712 2.67214 143.162 3.25214C141.882 4.61214 140.752 5.73214 139.812 7.12214L135.402 13.6521L127.162 25.7721C126.322 27.0021 125.532 27.8621 125.042 29.2821L135.952 28.7421L157.692 27.8221L165.922 27.5521L170.822 19.0121L175.202 10.9721C175.672 10.1121 177.182 9.56214 177.952 9.70214C178.982 9.89214 180.102 11.4321 179.522 12.5221C179.082 13.3521 177.792 14.3221 177.342 15.0721L175.182 18.6321L170.062 27.3421L178.322 27.0621L230.682 25.7121L237.122 25.6121C238.832 25.5821 240.382 25.6121 242.032 25.5421L254.252 24.9721L268.542 23.8721C275.472 23.3421 280.512 23.1521 287.102 21.2221C288.122 20.9221 289.982 20.8921 290.732 21.3321C291.072 21.5221 291.502 22.5021 291.542 22.8621C291.592 23.3021 290.692 23.9621 290.222 24.0321L278.152 25.7921C269.252 27.0921 260.532 27.6521 251.462 27.9121L210.192 29.0921L180.442 29.9021L168.252 30.3221L153.402 54.2721C151.782 56.8821 150.262 59.0721 148.792 62.0521C151.352 60.3221 155.772 56.1821 158.492 54.4321L170.452 46.7321C171.992 45.7421 172.572 43.6721 175.132 44.9121C175.692 45.1821 176.842 45.6821 176.862 46.2821C176.962 49.4921 174.502 48.2021 168.652 51.8621C156.952 59.1821 154.642 61.9021 144.532 70.4221C143.702 71.1221 141.992 70.6021 141.372 69.9021C139.832 68.1321 142.502 66.4321 143.582 64.4721C145.602 60.8321 147.612 57.5021 149.792 53.9721L162.622 33.2121H162.632ZM111.132 29.9221L120.442 29.7121C124.772 23.9321 128.792 17.9721 132.782 11.4321C129.922 13.5521 127.422 15.7721 124.712 18.0821L118.862 23.0621L116.482 25.1321L111.132 29.9221Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<!-- Path 2: Bottom word -->
|
||||
<path
|
||||
class="logo-path"
|
||||
:class="{ animating: isAnimating }"
|
||||
style="--i: 1"
|
||||
d="M163.772 110.492C155.402 104.552 146.472 99.9124 137.042 95.7124L118.342 87.3824C117.392 86.9624 116.192 86.7024 115.042 86.3124L114.252 83.5724C115.732 82.1024 117.632 83.0524 122.442 82.7624L139.072 81.7524L161.582 80.9524L180.202 80.8524L207.362 80.4624L213.392 75.8924L218.322 71.9624L222.272 68.7724L221.162 67.3924C223.622 61.3724 225.872 55.2924 227.402 48.8524C228.292 45.0824 226.602 44.3824 227.932 43.1124C228.802 42.2824 229.932 42.1124 230.912 42.5124C231.632 42.8124 232.522 44.2224 232.262 45.1124L230.052 52.9224L226.872 62.7824C228.402 62.2824 229.282 61.1424 230.372 60.0024L232.752 57.5224C236.662 53.4324 251.712 39.6824 253.082 39.7424C254.112 39.7824 255.882 41.2324 255.882 42.2424C255.882 43.1924 254.512 44.0924 253.962 44.6024L227.682 68.7924L220.252 75.1724C218.262 76.8824 215.552 78.8524 213.822 80.7224C216.292 80.9124 219.352 80.7224 221.702 80.6224C228.882 80.3024 235.822 80.5424 243.012 80.5924L253.442 80.6624L258.962 80.8524L278.862 81.9924L292.332 83.1824C296.032 83.5124 299.632 84.0424 303.282 83.8024C304.272 83.7324 308.252 83.3024 308.152 85.9424C308.112 86.9524 307.082 88.0524 305.952 87.9424L284.122 85.7724L258.232 84.1324L230.192 83.7324L212.692 83.7024C211.472 83.7024 210.292 84.3924 209.322 85.2124C200.122 92.9624 191.302 100.852 182.932 109.472C180.382 112.092 177.872 114.402 175.572 117.172C174.492 118.482 173.822 120.672 171.402 119.932C171.402 119.932 165.642 111.832 163.762 110.492H163.772ZM165.982 108.462L171.362 112.892C171.762 113.222 172.492 113.752 172.892 113.652C173.292 113.552 174.062 113.122 174.412 112.782L204.192 83.6224L195.722 83.9124L182.092 84.0624L170.632 84.1724L147.362 84.9124L124.272 86.3624C124.812 87.1424 125.632 87.5524 126.522 87.9424L138.402 93.1724L145.712 96.5724C149.592 98.3724 163.172 106.152 165.972 108.462H165.982Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<!-- Path 3 -->
|
||||
<path
|
||||
class="logo-path"
|
||||
:class="{ animating: isAnimating }"
|
||||
style="--i: 2"
|
||||
d="M264.651 59.0018C262.841 60.6418 258.061 64.5218 255.831 63.8618C255.061 63.6318 253.901 62.6418 253.811 61.8118L257.191 56.2218C252.531 60.2818 248.581 64.6118 244.801 69.3518C244.131 70.2018 244.081 71.3618 242.811 71.5118C241.661 71.6518 240.811 70.9318 240.231 69.8718C239.501 68.5218 240.811 68.0418 241.641 67.1318C247.621 60.5618 254.021 54.7518 260.401 48.6018L266.911 42.3318C268.051 41.2318 269.391 42.3618 269.871 43.2718C270.761 44.9318 269.451 45.0818 268.621 46.1618C265.601 50.0518 262.641 53.9718 260.201 58.3318C266.371 53.0918 272.601 49.2318 278.481 44.3418L281.961 41.4418C282.391 41.0818 283.761 41.3618 284.251 41.5718C284.741 41.7818 285.271 43.1818 284.821 43.8118C282.051 47.6618 278.801 52.5018 275.761 57.0218L268.631 67.6118C268.191 68.2618 267.861 69.5318 267.451 70.4018C267.141 71.0618 265.421 71.3418 264.731 71.1018C264.041 70.8618 262.801 69.8118 262.931 69.0118C263.101 67.9018 265.221 65.5918 266.021 64.7718C269.381 61.3018 273.481 54.5818 276.161 50.0618C274.711 50.7018 273.891 51.3718 272.801 52.2418C270.021 54.4718 267.311 56.6218 264.671 59.0218L264.651 59.0018Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<!-- Path 4 -->
|
||||
<path
|
||||
class="logo-path"
|
||||
:class="{ animating: isAnimating }"
|
||||
style="--i: 3"
|
||||
d="M209.662 67.2223C209.172 68.3723 208.772 69.6523 208.032 69.9323C207.012 70.3123 205.152 69.8823 204.842 69.0623C204.152 67.2423 205.622 66.3423 206.382 65.1223C208.662 61.4523 210.792 57.9623 212.652 54.0223L204.742 60.7123C200.482 64.3123 197.082 68.6323 193.462 72.9023C192.822 73.6523 191.642 74.1523 190.952 73.9323C190.032 73.6323 188.702 72.4323 189.222 71.6023C190.542 69.4823 191.642 67.6823 192.762 65.6123L198.762 54.5823L205.352 41.6423C205.702 40.9623 207.722 40.8323 208.402 41.1823C209.122 41.5523 209.982 42.8323 210.222 43.7423C208.302 44.5423 207.522 45.5123 206.672 47.0823L203.622 52.7123L198.892 61.2723C200.202 60.3423 201.302 59.3723 202.602 58.2723L210.842 51.2523L216.502 46.2123C217.412 45.4023 217.462 43.8323 219.022 43.9523C220.002 44.0223 221.962 45.1123 221.152 46.5223L214.312 58.4923C212.682 61.3523 211.012 64.0723 209.662 67.2623V67.2223Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<!-- Path 5 -->
|
||||
<path
|
||||
class="logo-path"
|
||||
:class="{ animating: isAnimating }"
|
||||
style="--i: 4"
|
||||
d="M120.041 66.0921C117.531 68.3021 111.991 74.6621 108.871 72.9321C108.351 72.6421 107.691 71.3221 108.041 70.8321L112.331 64.8221C115.871 59.8621 119.131 54.8321 121.681 49.2721C122.051 48.4721 122.331 47.5821 122.841 47.1021C123.431 46.5521 124.801 46.4221 125.351 47.0021C125.901 47.5821 126.431 48.3021 126.561 48.7921C126.691 49.2821 125.741 50.1221 125.471 50.5621C123.221 54.2521 121.191 57.9321 118.611 61.4021C117.751 62.5621 117.001 63.6221 116.431 64.9021L125.361 57.0921L132.551 51.6021L138.361 46.9521C139.331 46.1821 141.691 48.4621 141.121 49.4321C139.251 51.9821 137.581 54.3121 136.021 56.9421L133.511 61.1721L129.901 67.8321C129.421 68.7121 127.711 69.2521 127.151 68.5421L125.041 65.8421C127.761 63.5021 129.731 60.8521 131.371 57.7821L132.571 55.5521L126.361 60.5221L120.041 66.0821V66.0921Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<!-- Path 6 -->
|
||||
<path
|
||||
class="logo-path"
|
||||
:class="{ animating: isAnimating }"
|
||||
style="--i: 5"
|
||||
d="M189.921 51.7524C184.441 56.3024 180.001 61.3624 175.351 66.5424L172.661 69.5424C172.151 70.1124 171.031 70.6424 170.181 70.5524C169.551 70.4924 168.661 69.9524 168.321 69.5124C167.981 69.0724 168.111 67.9324 168.491 67.3624L175.621 56.7024L179.141 51.9724C181.891 48.2824 184.051 44.5524 186.211 40.3724C187.151 39.7624 188.831 40.6424 189.881 41.3024C190.281 41.5524 194.551 41.6324 196.871 43.1824L197.911 45.7624C197.311 47.0624 195.961 47.2324 194.921 47.7924C193.161 48.7324 191.621 50.3524 189.911 51.7724L189.921 51.7524ZM182.321 54.0124L190.771 46.3624C190.281 45.3424 188.511 45.4324 187.761 45.8424C187.201 46.1424 186.671 47.0824 186.161 47.8124L181.181 54.8824C181.911 55.3924 181.951 54.3624 182.321 54.0224V54.0124Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.splash-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-primary);
|
||||
transition: opacity 400ms ease;
|
||||
}
|
||||
|
||||
.splash-fade-out {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.splash-logo {
|
||||
width: min(70vw, 400px);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.logo-path {
|
||||
fill: currentColor;
|
||||
color: var(--text-primary);
|
||||
clip-path: inset(0 100% 0 0);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.logo-path.animating {
|
||||
animation:
|
||||
revealPath 700ms cubic-bezier(0.16, 1, 0.3, 1) calc(var(--i) * 200ms) forwards,
|
||||
fadeInPath 200ms ease calc(var(--i) * 200ms) forwards;
|
||||
}
|
||||
|
||||
@keyframes revealPath {
|
||||
from {
|
||||
clip-path: inset(0 100% 0 0);
|
||||
}
|
||||
to {
|
||||
clip-path: inset(0 0 0 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInPath {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.logo-path {
|
||||
clip-path: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.splash-overlay {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
19
src/components/ui/GlassButton.vue
Normal file
19
src/components/ui/GlassButton.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
variant?: 'accent' | 'ghost' | 'danger'
|
||||
isDisabled?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="btn" :class="[`btn-${variant || 'accent'}`]" :disabled="isDisabled">
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
21
src/components/ui/GlassCard.vue
Normal file
21
src/components/ui/GlassCard.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
isHoverable?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="glass-card" :class="{ hoverable: isHoverable }">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hoverable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hoverable:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
</style>
|
||||
19
src/components/ui/GlassInput.vue
Normal file
19
src/components/ui/GlassInput.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
const model = defineModel<string>()
|
||||
|
||||
defineProps<{
|
||||
type?: string
|
||||
placeholder?: string
|
||||
isRequired?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
v-model="model"
|
||||
class="glass-input"
|
||||
:type="type || 'text'"
|
||||
:placeholder="placeholder"
|
||||
:required="isRequired"
|
||||
/>
|
||||
</template>
|
||||
30
src/components/ui/LoadingSpinner.vue
Normal file
30
src/components/ui/LoadingSpinner.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="spinner-container">
|
||||
<div class="spinner" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.spinner-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 2px solid var(--glass-border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
31
src/components/ui/SatsDisplay.vue
Normal file
31
src/components/ui/SatsDisplay.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
sats: number
|
||||
}>()
|
||||
|
||||
const formatted = computed(() => props.sats.toLocaleString())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="sats-display">
|
||||
<span class="sats-amount">{{ formatted }}</span>
|
||||
<span class="sats-unit"> sats</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sats-display {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sats-amount {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sats-unit {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875em;
|
||||
}
|
||||
</style>
|
||||
44
src/components/ui/StatusBadge.vue
Normal file
44
src/components/ui/StatusBadge.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { OrderStatus } from '@shared/types'
|
||||
|
||||
const props = defineProps<{
|
||||
status: OrderStatus
|
||||
}>()
|
||||
|
||||
const colorClass = computed(() => {
|
||||
const map: Record<OrderStatus, string> = {
|
||||
pending: 'badge-warning',
|
||||
paid: 'badge-info',
|
||||
confirmed: 'badge-info',
|
||||
shipped: 'badge-accent',
|
||||
delivered: 'badge-success',
|
||||
cancelled: 'badge-error',
|
||||
}
|
||||
return map[props.status] || 'badge-default'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="status-badge" :class="colorClass">
|
||||
{{ status }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.badge-warning { background: rgba(245, 158, 11, 0.15); color: var(--warning); }
|
||||
.badge-info { background: rgba(59, 130, 246, 0.15); color: var(--info); }
|
||||
.badge-success { background: rgba(74, 222, 128, 0.15); color: var(--success); }
|
||||
.badge-error { background: rgba(239, 68, 68, 0.15); color: var(--error); }
|
||||
.badge-accent { background: rgba(247, 147, 26, 0.15); color: var(--accent); }
|
||||
</style>
|
||||
41
src/components/ui/ThemeToggle.vue
Normal file
41
src/components/ui/ThemeToggle.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
|
||||
const { isDark, toggle } = useTheme()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="theme-toggle" @click="toggle" :aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'">
|
||||
<svg v-if="isDark" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<line x1="12" y1="1" x2="12" y2="3" />
|
||||
<line x1="12" y1="21" x2="12" y2="23" />
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
||||
<line x1="1" y1="12" x2="3" y2="12" />
|
||||
<line x1="21" y1="12" x2="23" y2="12" />
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
||||
</svg>
|
||||
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.theme-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
38
src/composables/useAdmin.ts
Normal file
38
src/composables/useAdmin.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { ref } from 'vue'
|
||||
import { api } from '@/api/client'
|
||||
|
||||
const isAuthenticated = ref(false)
|
||||
const isLoading = ref(false)
|
||||
|
||||
export function useAdmin() {
|
||||
async function login(password: string): Promise<boolean> {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await api.post('/api/admin/login', { password })
|
||||
isAuthenticated.value = res.ok
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
await api.post('/api/admin/logout', {})
|
||||
isAuthenticated.value = false
|
||||
}
|
||||
|
||||
async function verify(): Promise<boolean> {
|
||||
try {
|
||||
const res = await api.get('/api/admin/verify')
|
||||
isAuthenticated.value = res.ok
|
||||
return res.ok
|
||||
} catch {
|
||||
isAuthenticated.value = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return { isAuthenticated, isLoading, login, logout, verify }
|
||||
}
|
||||
77
src/composables/useCart.ts
Normal file
77
src/composables/useCart.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { CartItem } from '@shared/types'
|
||||
|
||||
const STORAGE_KEY = 'antonym-cart'
|
||||
|
||||
function loadCart(): CartItem[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
return raw ? JSON.parse(raw) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const items = ref<CartItem[]>(loadCart())
|
||||
|
||||
watch(items, (val) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(val))
|
||||
}, { deep: true })
|
||||
|
||||
export function useCart() {
|
||||
const totalSats = computed(() =>
|
||||
items.value.reduce((sum, item) => sum + item.priceSats * item.quantity, 0),
|
||||
)
|
||||
|
||||
const itemCount = computed(() =>
|
||||
items.value.reduce((sum, item) => sum + item.quantity, 0),
|
||||
)
|
||||
|
||||
function addItem(
|
||||
product: { id: string; slug: string; name: string; priceSats: number; images: string[] },
|
||||
size: string,
|
||||
quantity = 1,
|
||||
) {
|
||||
const existing = items.value.find(
|
||||
(i) => i.productId === product.id && i.size === size,
|
||||
)
|
||||
if (existing) {
|
||||
existing.quantity += quantity
|
||||
} else {
|
||||
items.value.push({
|
||||
productId: product.id,
|
||||
slug: product.slug,
|
||||
name: product.name,
|
||||
size,
|
||||
quantity,
|
||||
priceSats: product.priceSats,
|
||||
image: product.images[0] ?? '',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function removeItem(productId: string, size: string) {
|
||||
items.value = items.value.filter(
|
||||
(i) => !(i.productId === productId && i.size === size),
|
||||
)
|
||||
}
|
||||
|
||||
function updateQuantity(productId: string, size: string, quantity: number) {
|
||||
const item = items.value.find(
|
||||
(i) => i.productId === productId && i.size === size,
|
||||
)
|
||||
if (item) {
|
||||
if (quantity <= 0) {
|
||||
removeItem(productId, size)
|
||||
} else {
|
||||
item.quantity = quantity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearCart() {
|
||||
items.value = []
|
||||
}
|
||||
|
||||
return { items, totalSats, itemCount, addItem, removeItem, updateQuantity, clearCart }
|
||||
}
|
||||
52
src/composables/useNostr.ts
Normal file
52
src/composables/useNostr.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'antonym-nostr-pubkey'
|
||||
|
||||
const pubkey = ref<string | null>(localStorage.getItem(STORAGE_KEY))
|
||||
|
||||
export function useNostr() {
|
||||
const hasExtension = computed(() => typeof window.nostr !== 'undefined')
|
||||
|
||||
const npub = computed(() => {
|
||||
if (!pubkey.value) return null
|
||||
return pubkey.value
|
||||
})
|
||||
|
||||
async function connectExtension(): Promise<string | null> {
|
||||
if (!window.nostr) return null
|
||||
try {
|
||||
const pk = await window.nostr.getPublicKey()
|
||||
pubkey.value = pk
|
||||
localStorage.setItem(STORAGE_KEY, pk)
|
||||
return pk
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function generateKeypair(): Promise<{ pubkey: string; nsec: string } | null> {
|
||||
try {
|
||||
const { generateSecretKey, getPublicKey } = await import('nostr-tools/pure')
|
||||
const { nsecEncode, npubEncode } = await import('nostr-tools/nip19')
|
||||
const sk = generateSecretKey()
|
||||
const pk = getPublicKey(sk)
|
||||
const nsec = nsecEncode(sk)
|
||||
|
||||
pubkey.value = pk
|
||||
localStorage.setItem(STORAGE_KEY, pk)
|
||||
|
||||
sk.fill(0)
|
||||
|
||||
return { pubkey: npubEncode(pk), nsec }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
pubkey.value = null
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
return { pubkey, hasExtension, npub, connectExtension, generateKeypair, disconnect }
|
||||
}
|
||||
32
src/composables/useTheme.ts
Normal file
32
src/composables/useTheme.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ref, computed, watchEffect } from 'vue'
|
||||
|
||||
type Theme = 'dark' | 'light'
|
||||
|
||||
const theme = ref<Theme>('dark')
|
||||
|
||||
function init() {
|
||||
const stored = localStorage.getItem('antonym-theme') as Theme | null
|
||||
if (stored === 'dark' || stored === 'light') {
|
||||
theme.value = stored
|
||||
} else if (window.matchMedia('(prefers-color-scheme: light)').matches) {
|
||||
theme.value = 'light'
|
||||
}
|
||||
}
|
||||
|
||||
init()
|
||||
|
||||
watchEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme.value)
|
||||
localStorage.setItem('antonym-theme', theme.value)
|
||||
})
|
||||
|
||||
export function useTheme() {
|
||||
const isDark = computed(() => theme.value === 'dark')
|
||||
const isLight = computed(() => theme.value === 'light')
|
||||
|
||||
function toggle() {
|
||||
theme.value = theme.value === 'dark' ? 'light' : 'dark'
|
||||
}
|
||||
|
||||
return { theme, isDark, isLight, toggle }
|
||||
}
|
||||
10
src/main.ts
Normal file
10
src/main.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import { router } from './router'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
44
src/router/index.ts
Normal file
44
src/router/index.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAdmin } from '@/composables/useAdmin'
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/components/layout/AppShell.vue'),
|
||||
children: [
|
||||
{ path: '', name: 'home', component: () => import('@/views/HomeView.vue') },
|
||||
{ path: 'product/:slug', name: 'product', component: () => import('@/views/ProductView.vue') },
|
||||
{ path: 'cart', name: 'cart', component: () => import('@/views/CartView.vue') },
|
||||
{ path: 'checkout', name: 'checkout', component: () => import('@/views/CheckoutView.vue') },
|
||||
{ path: 'order/:id', name: 'order', component: () => import('@/views/OrderView.vue') },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'admin-login',
|
||||
component: () => import('@/views/admin/AdminLoginView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: () => import('@/components/admin/AdminShell.vue'),
|
||||
meta: { requiresAdmin: true },
|
||||
children: [
|
||||
{ path: 'orders', name: 'admin-orders', component: () => import('@/views/admin/OrdersView.vue') },
|
||||
{ path: 'orders/:id', name: 'admin-order', component: () => import('@/views/admin/OrderDetailView.vue') },
|
||||
{ path: 'products', name: 'admin-products', component: () => import('@/views/admin/ProductsView.vue') },
|
||||
{ path: 'products/new', name: 'admin-product-new', component: () => import('@/views/admin/ProductFormView.vue') },
|
||||
{ path: 'products/:id/edit', name: 'admin-product-edit', component: () => import('@/views/admin/ProductFormView.vue') },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
if (to.matched.some((r) => r.meta.requiresAdmin)) {
|
||||
const { verify } = useAdmin()
|
||||
const valid = await verify()
|
||||
if (!valid) return { name: 'admin-login' }
|
||||
}
|
||||
})
|
||||
179
src/style.css
Normal file
179
src/style.css
Normal file
@@ -0,0 +1,179 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: "Inter", system-ui, -apple-system, sans-serif;
|
||||
--font-display: "Inter", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-primary: #0A0A0A;
|
||||
--bg-secondary: #1A1A1A;
|
||||
--bg-tertiary: #141414;
|
||||
--accent: #F7931A;
|
||||
--accent-hover: #e8841a;
|
||||
--text-primary: rgba(255, 255, 255, 0.9);
|
||||
--text-secondary: rgba(255, 255, 255, 0.7);
|
||||
--text-muted: rgba(255, 255, 255, 0.6);
|
||||
--text-placeholder: rgba(255, 255, 255, 0.25);
|
||||
--text-interactive: rgba(255, 255, 255, 0.7);
|
||||
--success: #4ade80;
|
||||
--error: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
--info: #3b82f6;
|
||||
|
||||
--glass-bg: rgba(0, 0, 0, 0.5);
|
||||
--glass-bg-strong: rgba(0, 0, 0, 0.75);
|
||||
--glass-bg-darker: rgba(0, 0, 0, 0.6);
|
||||
--glass-border: rgba(255, 255, 255, 0.18);
|
||||
--glass-highlight: rgba(255, 255, 255, 0.22);
|
||||
--glass-blur: 18px;
|
||||
--glass-blur-strong: 24px;
|
||||
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 24px;
|
||||
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-normal: 250ms ease;
|
||||
--transition-slow: 400ms ease;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-primary: #FAFAFA;
|
||||
--bg-secondary: #F0F0F0;
|
||||
--bg-tertiary: #F5F5F5;
|
||||
--text-primary: #0A0A0A;
|
||||
--text-secondary: rgba(0, 0, 0, 0.7);
|
||||
--text-muted: rgba(0, 0, 0, 0.5);
|
||||
--text-placeholder: rgba(0, 0, 0, 0.25);
|
||||
--text-interactive: rgba(0, 0, 0, 0.7);
|
||||
--glass-bg: rgba(255, 255, 255, 0.5);
|
||||
--glass-bg-strong: rgba(255, 255, 255, 0.65);
|
||||
--glass-bg-darker: rgba(255, 255, 255, 0.55);
|
||||
--glass-border: rgba(0, 0, 0, 0.12);
|
||||
--glass-highlight: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
transition: background-color var(--transition-normal), color var(--transition-normal);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.glass-strong {
|
||||
background: var(--glass-bg-strong);
|
||||
backdrop-filter: blur(var(--glass-blur-strong));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur-strong));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
transition: border-color var(--transition-fast), transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
border-color: var(--glass-highlight);
|
||||
}
|
||||
|
||||
.glass-input {
|
||||
background: var(--glass-bg-darker);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.625rem 0.875rem;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
transition: border-color var(--transition-fast);
|
||||
outline: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.glass-input::placeholder {
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
.glass-input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all var(--transition-fast);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-accent {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn-accent:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: var(--glass-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--error);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
73
src/views/CartView.vue
Normal file
73
src/views/CartView.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { useCart } from '@/composables/useCart'
|
||||
import SatsDisplay from '@/components/ui/SatsDisplay.vue'
|
||||
import GlassButton from '@/components/ui/GlassButton.vue'
|
||||
|
||||
const { items, totalSats, itemCount, removeItem, updateQuantity } = useCart()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cart-page">
|
||||
<h1>Cart</h1>
|
||||
<div v-if="itemCount === 0" class="empty">
|
||||
<p>Your cart is empty.</p>
|
||||
<router-link to="/" class="btn btn-ghost">Continue Shopping</router-link>
|
||||
</div>
|
||||
<div v-else class="cart-layout">
|
||||
<div class="cart-items">
|
||||
<div v-for="item in items" :key="`${item.productId}-${item.size}`" class="cart-item glass-card">
|
||||
<div class="item-image">
|
||||
<img v-if="item.image" :src="item.image" :alt="item.name" />
|
||||
<div v-else class="item-placeholder">{{ item.name[0] }}</div>
|
||||
</div>
|
||||
<div class="item-info">
|
||||
<router-link :to="`/product/${item.slug}`" class="item-name">{{ item.name }}</router-link>
|
||||
<span class="item-size">Size: {{ item.size }}</span>
|
||||
<SatsDisplay :sats="item.priceSats" />
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<div class="quantity-control">
|
||||
<button @click="updateQuantity(item.productId, item.size, item.quantity - 1)">-</button>
|
||||
<span>{{ item.quantity }}</span>
|
||||
<button @click="updateQuantity(item.productId, item.size, item.quantity + 1)">+</button>
|
||||
</div>
|
||||
<button class="remove-btn" @click="removeItem(item.productId, item.size)">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cart-summary glass-card">
|
||||
<h3>Summary</h3>
|
||||
<div class="summary-row"><span>Items</span><span>{{ itemCount }}</span></div>
|
||||
<div class="summary-row total"><span>Total</span><SatsDisplay :sats="totalSats" /></div>
|
||||
<router-link to="/checkout"><GlassButton class="checkout-btn">Proceed to Checkout</GlassButton></router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 { font-size: 1.75rem; font-weight: 700; margin-bottom: 1.5rem; }
|
||||
.empty { text-align: center; padding: 3rem 0; }
|
||||
.empty p { color: var(--text-muted); margin-bottom: 1rem; }
|
||||
.cart-layout { display: grid; grid-template-columns: 1fr 320px; gap: 2rem; align-items: start; }
|
||||
@media (max-width: 768px) { .cart-layout { grid-template-columns: 1fr; } }
|
||||
.cart-items { display: flex; flex-direction: column; gap: 1rem; }
|
||||
.cart-item { display: flex; gap: 1rem; align-items: center; }
|
||||
.item-image { width: 80px; height: 100px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-tertiary); flex-shrink: 0; }
|
||||
.item-image img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.item-placeholder { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-weight: 700; color: var(--text-placeholder); }
|
||||
.item-info { flex: 1; display: flex; flex-direction: column; gap: 0.2rem; }
|
||||
.item-name { font-weight: 600; color: var(--text-primary); text-decoration: none; }
|
||||
.item-name:hover { color: var(--accent); }
|
||||
.item-size { font-size: 0.8125rem; color: var(--text-muted); }
|
||||
.item-actions { display: flex; flex-direction: column; align-items: flex-end; gap: 0.5rem; }
|
||||
.quantity-control { display: flex; align-items: center; gap: 0.75rem; }
|
||||
.quantity-control button { width: 28px; height: 28px; border: 1px solid var(--glass-border); border-radius: var(--radius-sm); background: transparent; color: var(--text-primary); cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
||||
.quantity-control span { min-width: 1.5rem; text-align: center; font-weight: 600; }
|
||||
.remove-btn { background: none; border: none; color: var(--error); font-size: 0.75rem; cursor: pointer; }
|
||||
.cart-summary { position: sticky; top: 80px; }
|
||||
.cart-summary h3 { font-size: 1rem; font-weight: 600; margin-bottom: 1rem; }
|
||||
.summary-row { display: flex; justify-content: space-between; padding: 0.5rem 0; font-size: 0.875rem; color: var(--text-secondary); }
|
||||
.summary-row.total { border-top: 1px solid var(--glass-border); margin-top: 0.5rem; padding-top: 1rem; font-weight: 600; font-size: 1rem; color: var(--text-primary); }
|
||||
.checkout-btn { width: 100%; margin-top: 1.25rem; }
|
||||
</style>
|
||||
126
src/views/CheckoutView.vue
Normal file
126
src/views/CheckoutView.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { api } from '@/api/client'
|
||||
import { useCart } from '@/composables/useCart'
|
||||
import { useNostr } from '@/composables/useNostr'
|
||||
import type { CreateOrderRequest, CreateOrderResponse, ApiError } from '@shared/types'
|
||||
import GlassInput from '@/components/ui/GlassInput.vue'
|
||||
import GlassButton from '@/components/ui/GlassButton.vue'
|
||||
import SatsDisplay from '@/components/ui/SatsDisplay.vue'
|
||||
import NostrIdentity from '@/components/checkout/NostrIdentity.vue'
|
||||
|
||||
const { items, totalSats, clearCart } = useCart()
|
||||
const { pubkey } = useNostr()
|
||||
|
||||
const name = ref('')
|
||||
const line1 = ref('')
|
||||
const line2 = ref('')
|
||||
const city = ref('')
|
||||
const state = ref('')
|
||||
const postalCode = ref('')
|
||||
const country = ref('')
|
||||
const email = ref('')
|
||||
const note = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const canSubmit = computed(() => name.value && line1.value && city.value && postalCode.value && country.value && items.value.length > 0)
|
||||
|
||||
async function handleCheckout() {
|
||||
if (!canSubmit.value) return
|
||||
isSubmitting.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
const order: CreateOrderRequest = {
|
||||
items: items.value.map((i) => ({ productId: i.productId, size: i.size, quantity: i.quantity })),
|
||||
shippingAddress: { name: name.value, line1: line1.value, line2: line2.value || undefined, city: city.value, state: state.value || undefined, postalCode: postalCode.value, country: country.value },
|
||||
email: email.value || undefined,
|
||||
nostrPubkey: pubkey.value || undefined,
|
||||
note: note.value || undefined,
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.post('/api/orders', order)
|
||||
if (res.ok) {
|
||||
const data = await api.json<CreateOrderResponse>(res)
|
||||
clearCart()
|
||||
window.location.href = data.invoiceUrl
|
||||
} else {
|
||||
const err = await api.json<ApiError>(res)
|
||||
errorMessage.value = err.error.message
|
||||
}
|
||||
} catch {
|
||||
errorMessage.value = 'Failed to create order. Please try again.'
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="checkout-page">
|
||||
<h1>Checkout</h1>
|
||||
<div v-if="items.length === 0" class="empty"><p>Your cart is empty.</p><router-link to="/" class="btn btn-ghost">Continue Shopping</router-link></div>
|
||||
<form v-else @submit.prevent="handleCheckout" class="checkout-layout">
|
||||
<div class="checkout-form">
|
||||
<section class="form-section glass-card">
|
||||
<h3>Shipping Address</h3>
|
||||
<div class="field"><label>Full Name</label><GlassInput v-model="name" placeholder="Name" is-required /></div>
|
||||
<div class="field"><label>Address Line 1</label><GlassInput v-model="line1" placeholder="Street address" is-required /></div>
|
||||
<div class="field"><label>Address Line 2</label><GlassInput v-model="line2" placeholder="Apartment, unit, etc. (optional)" /></div>
|
||||
<div class="field-row">
|
||||
<div class="field"><label>City</label><GlassInput v-model="city" placeholder="City" is-required /></div>
|
||||
<div class="field"><label>State/Province</label><GlassInput v-model="state" placeholder="State (optional)" /></div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field"><label>Postal Code</label><GlassInput v-model="postalCode" placeholder="Postal code" is-required /></div>
|
||||
<div class="field"><label>Country</label><GlassInput v-model="country" placeholder="Country" is-required /></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="form-section glass-card">
|
||||
<h3>Contact (optional)</h3>
|
||||
<p class="hint">Provide email and/or Nostr for order updates. Neither is required.</p>
|
||||
<div class="field"><label>Email</label><GlassInput v-model="email" type="email" placeholder="For shipping updates (optional)" /></div>
|
||||
<NostrIdentity />
|
||||
</section>
|
||||
<section class="form-section glass-card">
|
||||
<h3>Note (optional)</h3>
|
||||
<div class="field"><textarea v-model="note" class="glass-input note-input" placeholder="Any special instructions..." rows="3" /></div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="checkout-summary glass-card">
|
||||
<h3>Order Summary</h3>
|
||||
<div v-for="item in items" :key="`${item.productId}-${item.size}`" class="summary-item">
|
||||
<span>{{ item.name }} ({{ item.size }}) x{{ item.quantity }}</span>
|
||||
<SatsDisplay :sats="item.priceSats * item.quantity" />
|
||||
</div>
|
||||
<div class="summary-total"><span>Total</span><SatsDisplay :sats="totalSats" /></div>
|
||||
<div v-if="errorMessage" class="error-msg">{{ errorMessage }}</div>
|
||||
<GlassButton type="submit" :is-disabled="!canSubmit || isSubmitting" class="pay-btn">{{ isSubmitting ? 'Creating order...' : 'Pay with Bitcoin' }}</GlassButton>
|
||||
<p class="payment-note">You will be redirected to BTCPay Server to complete payment.</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 { font-size: 1.75rem; font-weight: 700; margin-bottom: 1.5rem; }
|
||||
.empty { text-align: center; padding: 3rem 0; }
|
||||
.empty p { color: var(--text-muted); margin-bottom: 1rem; }
|
||||
.checkout-layout { display: grid; grid-template-columns: 1fr 360px; gap: 2rem; align-items: start; }
|
||||
@media (max-width: 768px) { .checkout-layout { grid-template-columns: 1fr; } }
|
||||
.checkout-form { display: flex; flex-direction: column; gap: 1.5rem; }
|
||||
.form-section h3 { font-size: 1rem; font-weight: 600; margin-bottom: 1rem; }
|
||||
.hint { font-size: 0.8125rem; color: var(--text-muted); margin: -0.5rem 0 1rem; }
|
||||
.field { margin-bottom: 0.75rem; }
|
||||
.field label { display: block; font-size: 0.8125rem; font-weight: 500; color: var(--text-secondary); margin-bottom: 0.375rem; }
|
||||
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
|
||||
.note-input { resize: vertical; min-height: 80px; font-family: inherit; }
|
||||
.checkout-summary { position: sticky; top: 80px; }
|
||||
.checkout-summary h3 { font-size: 1rem; font-weight: 600; margin-bottom: 1rem; }
|
||||
.summary-item { display: flex; justify-content: space-between; font-size: 0.8125rem; padding: 0.375rem 0; color: var(--text-secondary); }
|
||||
.summary-total { display: flex; justify-content: space-between; border-top: 1px solid var(--glass-border); margin-top: 0.75rem; padding-top: 0.75rem; font-weight: 600; }
|
||||
.error-msg { margin-top: 1rem; padding: 0.5rem 0.75rem; background: rgba(239, 68, 68, 0.1); border-radius: var(--radius-sm); color: var(--error); font-size: 0.8125rem; }
|
||||
.pay-btn { width: 100%; margin-top: 1.25rem; }
|
||||
.payment-note { font-size: 0.75rem; color: var(--text-muted); text-align: center; margin-top: 0.75rem; }
|
||||
</style>
|
||||
67
src/views/HomeView.vue
Normal file
67
src/views/HomeView.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { api } from '@/api/client'
|
||||
import type { Product } from '@shared/types'
|
||||
import ProductGrid from '@/components/product/ProductGrid.vue'
|
||||
import LoadingSpinner from '@/components/ui/LoadingSpinner.vue'
|
||||
|
||||
const products = ref<Product[]>([])
|
||||
const isLoading = ref(true)
|
||||
const activeCategory = ref<string | null>(null)
|
||||
|
||||
const categories = computed(() => {
|
||||
const cats = new Set(products.value.map((p) => p.category))
|
||||
return Array.from(cats).sort()
|
||||
})
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (!activeCategory.value) return products.value
|
||||
return products.value.filter((p) => p.category === activeCategory.value)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await api.get('/api/products')
|
||||
products.value = await api.json<Product[]>(res)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="home">
|
||||
<section class="hero">
|
||||
<h1>Antonym</h1>
|
||||
<p class="tagline">Fashion for the sovereign individual. Bitcoin only.</p>
|
||||
</section>
|
||||
|
||||
<div v-if="categories.length > 1" class="category-filter">
|
||||
<button class="filter-btn" :class="{ active: !activeCategory }" @click="activeCategory = null">All</button>
|
||||
<button v-for="cat in categories" :key="cat" class="filter-btn" :class="{ active: activeCategory === cat }" @click="activeCategory = cat">{{ cat }}</button>
|
||||
</div>
|
||||
|
||||
<LoadingSpinner v-if="isLoading" />
|
||||
<ProductGrid v-else :products="filtered" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hero { text-align: center; padding: 3rem 0 2.5rem; }
|
||||
.hero h1 { font-size: 2.5rem; font-weight: 700; letter-spacing: -0.02em; margin-bottom: 0.5rem; }
|
||||
.tagline { color: var(--text-muted); font-size: 1rem; }
|
||||
.category-filter { display: flex; gap: 0.5rem; margin-bottom: 2rem; flex-wrap: wrap; }
|
||||
.filter-btn {
|
||||
padding: 0.375rem 1rem;
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 9999px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.filter-btn:hover { border-color: var(--text-muted); }
|
||||
.filter-btn.active { background: var(--accent); border-color: var(--accent); color: #000; }
|
||||
</style>
|
||||
80
src/views/OrderView.vue
Normal file
80
src/views/OrderView.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { api } from '@/api/client'
|
||||
import type { Order, OrderEvent } from '@shared/types'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import SatsDisplay from '@/components/ui/SatsDisplay.vue'
|
||||
import LoadingSpinner from '@/components/ui/LoadingSpinner.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const order = ref<(Order & { events: OrderEvent[] }) | null>(null)
|
||||
const isLoading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await api.get(`/api/orders/${route.params.id}`)
|
||||
if (res.ok) order.value = await api.json<Order & { events: OrderEvent[] }>(res)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="order-page">
|
||||
<h1>Order Tracking</h1>
|
||||
<LoadingSpinner v-if="isLoading" />
|
||||
<div v-else-if="order" class="order-detail">
|
||||
<div class="order-header glass-card">
|
||||
<div class="order-id"><span class="label">Order</span><code>{{ order.id }}</code></div>
|
||||
<StatusBadge :status="order.status" />
|
||||
</div>
|
||||
<div class="order-body">
|
||||
<section class="glass-card">
|
||||
<h3>Items</h3>
|
||||
<div v-for="item in order.items" :key="item.productId + item.size" class="order-item">
|
||||
<span>{{ item.productName }} ({{ item.size }}) x{{ item.quantity }}</span>
|
||||
<SatsDisplay :sats="item.priceSats * item.quantity" />
|
||||
</div>
|
||||
<div class="order-total"><span>Total</span><SatsDisplay :sats="order.totalSats" /></div>
|
||||
</section>
|
||||
<section v-if="order.events.length" class="glass-card">
|
||||
<h3>Timeline</h3>
|
||||
<div class="timeline">
|
||||
<div v-for="event in order.events" :key="event.id" class="timeline-item">
|
||||
<div class="timeline-dot" />
|
||||
<div class="timeline-content">
|
||||
<StatusBadge :status="event.status" />
|
||||
<span v-if="event.note" class="timeline-note">{{ event.note }}</span>
|
||||
<time class="timeline-time">{{ new Date(event.createdAt).toLocaleString() }}</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="not-found"><h2>Order not found</h2><p>Check the order ID and try again.</p></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 { font-size: 1.75rem; font-weight: 700; margin-bottom: 1.5rem; }
|
||||
.order-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; }
|
||||
.order-id { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.label { color: var(--text-muted); font-size: 0.875rem; }
|
||||
.order-id code { font-size: 0.8125rem; color: var(--accent); }
|
||||
.order-body { display: flex; flex-direction: column; gap: 1.5rem; }
|
||||
.order-body h3 { font-size: 1rem; font-weight: 600; margin-bottom: 1rem; }
|
||||
.order-item { display: flex; justify-content: space-between; padding: 0.375rem 0; font-size: 0.875rem; color: var(--text-secondary); }
|
||||
.order-total { display: flex; justify-content: space-between; border-top: 1px solid var(--glass-border); margin-top: 0.5rem; padding-top: 0.75rem; font-weight: 600; }
|
||||
.timeline { display: flex; flex-direction: column; gap: 1rem; position: relative; padding-left: 1.5rem; }
|
||||
.timeline::before { content: ''; position: absolute; left: 5px; top: 8px; bottom: 8px; width: 1px; background: var(--glass-border); }
|
||||
.timeline-item { position: relative; display: flex; gap: 1rem; }
|
||||
.timeline-dot { position: absolute; left: -1.5rem; top: 4px; width: 10px; height: 10px; border-radius: 50%; background: var(--accent); border: 2px solid var(--bg-primary); }
|
||||
.timeline-content { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.timeline-note { font-size: 0.8125rem; color: var(--text-secondary); }
|
||||
.timeline-time { font-size: 0.75rem; color: var(--text-muted); }
|
||||
.not-found { text-align: center; padding: 4rem 0; }
|
||||
.not-found p { color: var(--text-muted); margin-top: 0.5rem; }
|
||||
</style>
|
||||
73
src/views/ProductView.vue
Normal file
73
src/views/ProductView.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { api } from '@/api/client'
|
||||
import { useCart } from '@/composables/useCart'
|
||||
import type { Product } from '@shared/types'
|
||||
import SizeSelector from '@/components/product/SizeSelector.vue'
|
||||
import SatsDisplay from '@/components/ui/SatsDisplay.vue'
|
||||
import GlassButton from '@/components/ui/GlassButton.vue'
|
||||
import LoadingSpinner from '@/components/ui/LoadingSpinner.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const { addItem } = useCart()
|
||||
|
||||
const product = ref<Product | null>(null)
|
||||
const selectedSize = ref<string>('')
|
||||
const isLoading = ref(true)
|
||||
const isAdded = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await api.get(`/api/products/${route.params.slug}`)
|
||||
if (res.ok) product.value = await api.json<Product>(res)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function handleAddToCart() {
|
||||
if (!product.value || !selectedSize.value) return
|
||||
addItem({ id: product.value.id, slug: product.value.slug, name: product.value.name, priceSats: product.value.priceSats, images: product.value.images }, selectedSize.value)
|
||||
isAdded.value = true
|
||||
setTimeout(() => { isAdded.value = false }, 2000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LoadingSpinner v-if="isLoading" />
|
||||
<div v-else-if="product" class="product-detail">
|
||||
<div class="product-image">
|
||||
<img v-if="product.images[0]" :src="product.images[0]" :alt="product.name" />
|
||||
<div v-else class="placeholder"><span>{{ product.name[0] }}</span></div>
|
||||
</div>
|
||||
<div class="product-info">
|
||||
<span class="category">{{ product.category }}</span>
|
||||
<h1>{{ product.name }}</h1>
|
||||
<SatsDisplay :sats="product.priceSats" class="price" />
|
||||
<p class="description">{{ product.description }}</p>
|
||||
<div class="size-section">
|
||||
<label>Size</label>
|
||||
<SizeSelector v-model="selectedSize" :sizes="product.sizes" />
|
||||
</div>
|
||||
<GlassButton :is-disabled="!selectedSize" @click="handleAddToCart">{{ isAdded ? 'Added!' : 'Add to Cart' }}</GlassButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="not-found"><h2>Product not found</h2><router-link to="/">Back to shop</router-link></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-detail { display: grid; grid-template-columns: 1fr 1fr; gap: 3rem; align-items: start; }
|
||||
@media (max-width: 768px) { .product-detail { grid-template-columns: 1fr; gap: 1.5rem; } }
|
||||
.product-image { aspect-ratio: 3 / 4; border-radius: var(--radius-lg); overflow: hidden; background: var(--bg-tertiary); }
|
||||
.product-image img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.placeholder { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 5rem; font-weight: 700; color: var(--text-placeholder); }
|
||||
.category { text-transform: uppercase; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.08em; color: var(--accent); }
|
||||
h1 { font-size: 2rem; font-weight: 700; margin: 0.25rem 0 0.75rem; }
|
||||
.price { font-size: 1.25rem; }
|
||||
.description { color: var(--text-secondary); margin: 1.25rem 0; line-height: 1.7; }
|
||||
.size-section { margin-bottom: 1.5rem; }
|
||||
.size-section label { display: block; font-size: 0.8125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-secondary); }
|
||||
.not-found { text-align: center; padding: 4rem 0; }
|
||||
.not-found a { color: var(--accent); }
|
||||
</style>
|
||||
39
src/views/admin/AdminLoginView.vue
Normal file
39
src/views/admin/AdminLoginView.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAdmin } from '@/composables/useAdmin'
|
||||
import GlassInput from '@/components/ui/GlassInput.vue'
|
||||
import GlassButton from '@/components/ui/GlassButton.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { login, isLoading } = useAdmin()
|
||||
const password = ref('')
|
||||
const errorMessage = ref('')
|
||||
|
||||
async function handleLogin() {
|
||||
errorMessage.value = ''
|
||||
const success = await login(password.value)
|
||||
if (success) { router.push({ name: 'admin-orders' }) }
|
||||
else { errorMessage.value = 'Invalid password'; password.value = '' }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<form class="login-form glass-card" @submit.prevent="handleLogin">
|
||||
<h1>Admin</h1>
|
||||
<div class="field"><GlassInput v-model="password" type="password" placeholder="Password" is-required /></div>
|
||||
<div v-if="errorMessage" class="error">{{ errorMessage }}</div>
|
||||
<GlassButton :is-disabled="isLoading || !password" class="login-btn">{{ isLoading ? 'Signing in...' : 'Sign In' }}</GlassButton>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page { min-height: 80vh; display: flex; align-items: center; justify-content: center; }
|
||||
.login-form { width: 100%; max-width: 360px; text-align: center; }
|
||||
h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: 1.5rem; }
|
||||
.field { margin-bottom: 1rem; }
|
||||
.error { color: var(--error); font-size: 0.8125rem; margin-bottom: 1rem; }
|
||||
.login-btn { width: 100%; }
|
||||
</style>
|
||||
115
src/views/admin/OrderDetailView.vue
Normal file
115
src/views/admin/OrderDetailView.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { api } from '@/api/client'
|
||||
import type { Order, OrderEvent, OrderStatus, ShippingAddress } from '@shared/types'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import SatsDisplay from '@/components/ui/SatsDisplay.vue'
|
||||
import GlassButton from '@/components/ui/GlassButton.vue'
|
||||
import GlassInput from '@/components/ui/GlassInput.vue'
|
||||
import LoadingSpinner from '@/components/ui/LoadingSpinner.vue'
|
||||
|
||||
const route = useRoute()
|
||||
type AdminOrder = Order & { shippingAddress: ShippingAddress | null; events: OrderEvent[] }
|
||||
const order = ref<AdminOrder | null>(null)
|
||||
const isLoading = ref(true)
|
||||
const newStatus = ref<OrderStatus>('confirmed')
|
||||
const statusNote = ref('')
|
||||
const isUpdating = ref(false)
|
||||
const statuses: OrderStatus[] = ['pending', 'paid', 'confirmed', 'shipped', 'delivered', 'cancelled']
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await api.get(`/api/admin/orders/${route.params.id}`)
|
||||
if (res.ok) order.value = await api.json<AdminOrder>(res)
|
||||
} finally { isLoading.value = false }
|
||||
})
|
||||
|
||||
async function updateStatus() {
|
||||
if (!order.value) return
|
||||
isUpdating.value = true
|
||||
try {
|
||||
const res = await api.patch(`/api/admin/orders/${order.value.id}/status`, { status: newStatus.value, note: statusNote.value || undefined })
|
||||
if (res.ok) {
|
||||
order.value.status = newStatus.value
|
||||
order.value.events.push({ id: Date.now(), orderId: order.value.id, status: newStatus.value, note: statusNote.value || null, createdAt: new Date().toISOString() })
|
||||
statusNote.value = ''
|
||||
}
|
||||
} finally { isUpdating.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<router-link :to="{ name: 'admin-orders' }" class="back-link">← Orders</router-link>
|
||||
<LoadingSpinner v-if="isLoading" />
|
||||
<div v-else-if="order" class="order-detail">
|
||||
<div class="detail-header"><h1>Order <code>{{ order.id }}</code></h1><StatusBadge :status="order.status" /></div>
|
||||
<div class="detail-grid">
|
||||
<section class="glass-card">
|
||||
<h3>Items</h3>
|
||||
<div v-for="item in order.items" :key="item.productId + item.size" class="line-item"><span>{{ item.productName }} ({{ item.size }}) x{{ item.quantity }}</span><SatsDisplay :sats="item.priceSats * item.quantity" /></div>
|
||||
<div class="total-row"><span>Total</span><SatsDisplay :sats="order.totalSats" /></div>
|
||||
</section>
|
||||
<section class="glass-card">
|
||||
<h3>Shipping Address</h3>
|
||||
<div v-if="order.shippingAddress" class="address">
|
||||
<p>{{ order.shippingAddress.name }}</p><p>{{ order.shippingAddress.line1 }}</p>
|
||||
<p v-if="order.shippingAddress.line2">{{ order.shippingAddress.line2 }}</p>
|
||||
<p>{{ order.shippingAddress.city }}<span v-if="order.shippingAddress.state">, {{ order.shippingAddress.state }}</span> {{ order.shippingAddress.postalCode }}</p>
|
||||
<p>{{ order.shippingAddress.country }}</p>
|
||||
</div>
|
||||
<p v-else class="muted">Address could not be decrypted</p>
|
||||
</section>
|
||||
<section class="glass-card">
|
||||
<h3>Contact</h3>
|
||||
<div class="contact-info">
|
||||
<div v-if="order.email" class="contact-row"><span class="contact-label">Email</span><span>{{ order.email }}</span></div>
|
||||
<div v-if="order.nostrPubkey" class="contact-row"><span class="contact-label">Nostr</span><code class="pubkey">{{ order.nostrPubkey.slice(0, 16) }}...</code></div>
|
||||
<p v-if="!order.email && !order.nostrPubkey" class="muted">No contact info provided</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="glass-card">
|
||||
<h3>Update Status</h3>
|
||||
<div class="status-form">
|
||||
<select v-model="newStatus" class="glass-input"><option v-for="s in statuses" :key="s" :value="s">{{ s }}</option></select>
|
||||
<GlassInput v-model="statusNote" placeholder="Note (optional)" />
|
||||
<GlassButton :is-disabled="isUpdating" @click="updateStatus">{{ isUpdating ? 'Updating...' : 'Update Status' }}</GlassButton>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="order.events.length" class="glass-card full-width">
|
||||
<h3>Timeline</h3>
|
||||
<div v-for="event in order.events" :key="event.id" class="event-row">
|
||||
<StatusBadge :status="event.status" />
|
||||
<span v-if="event.note" class="event-note">{{ event.note }}</span>
|
||||
<time class="event-time">{{ new Date(event.createdAt).toLocaleString() }}</time>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.back-link { color: var(--text-muted); text-decoration: none; font-size: 0.8125rem; display: inline-block; margin-bottom: 1rem; }
|
||||
.back-link:hover { color: var(--text-primary); }
|
||||
.detail-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; }
|
||||
h1 { font-size: 1.25rem; font-weight: 700; }
|
||||
h1 code { color: var(--accent); font-size: 0.875rem; }
|
||||
h3 { font-size: 0.9375rem; font-weight: 600; margin-bottom: 0.75rem; }
|
||||
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
|
||||
.full-width { grid-column: 1 / -1; }
|
||||
.line-item { display: flex; justify-content: space-between; font-size: 0.8125rem; padding: 0.25rem 0; color: var(--text-secondary); }
|
||||
.total-row { display: flex; justify-content: space-between; border-top: 1px solid var(--glass-border); margin-top: 0.5rem; padding-top: 0.5rem; font-weight: 600; }
|
||||
.address p { font-size: 0.875rem; color: var(--text-secondary); line-height: 1.5; }
|
||||
.muted { color: var(--text-muted); font-size: 0.8125rem; }
|
||||
.contact-row { display: flex; gap: 0.75rem; font-size: 0.875rem; padding: 0.25rem 0; }
|
||||
.contact-label { color: var(--text-muted); min-width: 60px; }
|
||||
.pubkey { font-size: 0.75rem; color: var(--accent); }
|
||||
.status-form { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.status-form select { text-transform: capitalize; }
|
||||
.event-row { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0; border-bottom: 1px solid var(--glass-border); }
|
||||
.event-row:last-child { border-bottom: none; }
|
||||
.event-note { flex: 1; font-size: 0.8125rem; color: var(--text-secondary); }
|
||||
.event-time { font-size: 0.75rem; color: var(--text-muted); white-space: nowrap; }
|
||||
</style>
|
||||
65
src/views/admin/OrdersView.vue
Normal file
65
src/views/admin/OrdersView.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { api } from '@/api/client'
|
||||
import type { Order, OrderStatus } from '@shared/types'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import SatsDisplay from '@/components/ui/SatsDisplay.vue'
|
||||
import LoadingSpinner from '@/components/ui/LoadingSpinner.vue'
|
||||
|
||||
const orders = ref<Order[]>([])
|
||||
const isLoading = ref(true)
|
||||
const filterStatus = ref<OrderStatus | ''>('')
|
||||
const statuses: OrderStatus[] = ['pending', 'paid', 'confirmed', 'shipped', 'delivered', 'cancelled']
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (!filterStatus.value) return orders.value
|
||||
return orders.value.filter((o) => o.status === filterStatus.value)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await api.get('/api/admin/orders')
|
||||
if (res.ok) orders.value = await api.json<Order[]>(res)
|
||||
} finally { isLoading.value = false }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1>Orders</h1>
|
||||
<select v-model="filterStatus" class="glass-input filter-select">
|
||||
<option value="">All statuses</option>
|
||||
<option v-for="s in statuses" :key="s" :value="s">{{ s }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<LoadingSpinner v-if="isLoading" />
|
||||
<div v-else-if="filtered.length === 0" class="empty">No orders found.</div>
|
||||
<table v-else class="orders-table">
|
||||
<thead><tr><th>Order</th><th>Status</th><th>Items</th><th>Total</th><th>Date</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="order in filtered" :key="order.id" class="order-row" @click="$router.push({ name: 'admin-order', params: { id: order.id } })">
|
||||
<td><code class="order-id">{{ order.id.slice(0, 10) }}...</code></td>
|
||||
<td><StatusBadge :status="order.status" /></td>
|
||||
<td>{{ order.items.reduce((s, i) => s + i.quantity, 0) }}</td>
|
||||
<td><SatsDisplay :sats="order.totalSats" /></td>
|
||||
<td class="date">{{ new Date(order.createdAt).toLocaleDateString() }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; }
|
||||
h1 { font-size: 1.5rem; font-weight: 700; }
|
||||
.filter-select { width: auto; min-width: 160px; text-transform: capitalize; }
|
||||
.empty { color: var(--text-muted); text-align: center; padding: 3rem 0; }
|
||||
.orders-table { width: 100%; border-collapse: collapse; }
|
||||
.orders-table th { text-align: left; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); padding: 0.75rem 1rem; border-bottom: 1px solid var(--glass-border); }
|
||||
.orders-table td { padding: 0.75rem 1rem; font-size: 0.875rem; border-bottom: 1px solid var(--glass-border); }
|
||||
.order-row { cursor: pointer; transition: background var(--transition-fast); }
|
||||
.order-row:hover { background: var(--glass-bg); }
|
||||
.order-id { font-size: 0.8125rem; color: var(--accent); }
|
||||
.date { color: var(--text-muted); font-size: 0.8125rem; }
|
||||
</style>
|
||||
96
src/views/admin/ProductFormView.vue
Normal file
96
src/views/admin/ProductFormView.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '@/api/client'
|
||||
import type { Product, SizeStock, ApiError } from '@shared/types'
|
||||
import GlassInput from '@/components/ui/GlassInput.vue'
|
||||
import GlassButton from '@/components/ui/GlassButton.vue'
|
||||
import LoadingSpinner from '@/components/ui/LoadingSpinner.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const isEdit = computed(() => route.name === 'admin-product-edit')
|
||||
const isLoading = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const name = ref('')
|
||||
const slug = ref('')
|
||||
const description = ref('')
|
||||
const priceSats = ref(0)
|
||||
const category = ref('general')
|
||||
const sizes = ref<SizeStock[]>([{ size: 'S', stock: 0 }, { size: 'M', stock: 0 }, { size: 'L', stock: 0 }, { size: 'XL', stock: 0 }])
|
||||
|
||||
onMounted(async () => {
|
||||
if (isEdit.value && route.params.id) {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await api.get(`/api/admin/products`)
|
||||
if (res.ok) {
|
||||
const products = await api.json<Product[]>(res)
|
||||
const product = products.find((p) => p.id === route.params.id)
|
||||
if (product) { name.value = product.name; slug.value = product.slug; description.value = product.description; priceSats.value = product.priceSats; category.value = product.category; sizes.value = product.sizes }
|
||||
}
|
||||
} finally { isLoading.value = false }
|
||||
}
|
||||
})
|
||||
|
||||
function addSize() { sizes.value.push({ size: '', stock: 0 }) }
|
||||
function removeSize(index: number) { sizes.value.splice(index, 1) }
|
||||
function autoSlug() { if (!isEdit.value) slug.value = name.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') }
|
||||
|
||||
async function handleSave() {
|
||||
isSaving.value = true; errorMessage.value = ''
|
||||
const data = { name: name.value, slug: slug.value, description: description.value, priceSats: priceSats.value, category: category.value, sizes: sizes.value, images: [] }
|
||||
try {
|
||||
const res = isEdit.value ? await api.put(`/api/admin/products/${route.params.id}`, data) : await api.post('/api/admin/products', data)
|
||||
if (res.ok) router.push({ name: 'admin-products' })
|
||||
else { const err = await api.json<ApiError>(res); errorMessage.value = err.error.message }
|
||||
} catch { errorMessage.value = 'Failed to save product' }
|
||||
finally { isSaving.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<router-link :to="{ name: 'admin-products' }" class="back-link">← Products</router-link>
|
||||
<h1>{{ isEdit ? 'Edit Product' : 'New Product' }}</h1>
|
||||
<LoadingSpinner v-if="isLoading" />
|
||||
<form v-else class="product-form" @submit.prevent="handleSave">
|
||||
<div class="form-grid">
|
||||
<div class="field"><label>Name</label><GlassInput v-model="name" placeholder="Product name" is-required @blur="autoSlug" /></div>
|
||||
<div class="field"><label>Slug</label><GlassInput v-model="slug" placeholder="url-friendly-name" is-required /></div>
|
||||
<div class="field"><label>Price (sats)</label><input v-model.number="priceSats" type="number" min="1" step="1" class="glass-input" required /></div>
|
||||
<div class="field"><label>Category</label><GlassInput v-model="category" placeholder="e.g. tops, bottoms, accessories" /></div>
|
||||
</div>
|
||||
<div class="field"><label>Description</label><textarea v-model="description" class="glass-input" rows="4" placeholder="Product description..." /></div>
|
||||
<div class="field">
|
||||
<div class="sizes-header"><label>Sizes & Stock</label><button type="button" class="add-size-btn" @click="addSize">+ Add Size</button></div>
|
||||
<div v-for="(s, i) in sizes" :key="i" class="size-row">
|
||||
<input v-model="s.size" class="glass-input size-input" placeholder="Size" />
|
||||
<input v-model.number="s.stock" type="number" min="0" step="1" class="glass-input stock-input" placeholder="Stock" />
|
||||
<button type="button" class="remove-btn" @click="removeSize(i)">x</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="errorMessage" class="error">{{ errorMessage }}</div>
|
||||
<GlassButton :is-disabled="isSaving">{{ isSaving ? 'Saving...' : (isEdit ? 'Update Product' : 'Create Product') }}</GlassButton>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.back-link { color: var(--text-muted); text-decoration: none; font-size: 0.8125rem; display: inline-block; margin-bottom: 1rem; }
|
||||
.back-link:hover { color: var(--text-primary); }
|
||||
h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: 1.5rem; }
|
||||
.product-form { max-width: 640px; }
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem; }
|
||||
.field { margin-bottom: 1rem; }
|
||||
.field label { display: block; font-size: 0.8125rem; font-weight: 500; color: var(--text-secondary); margin-bottom: 0.375rem; }
|
||||
.field textarea { resize: vertical; font-family: inherit; }
|
||||
.sizes-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.375rem; }
|
||||
.add-size-btn { background: none; border: none; color: var(--accent); font-size: 0.8125rem; cursor: pointer; }
|
||||
.size-row { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; align-items: center; }
|
||||
.size-input { flex: 1; }
|
||||
.stock-input { width: 100px; }
|
||||
.remove-btn { background: none; border: none; color: var(--error); cursor: pointer; font-size: 1rem; padding: 0.25rem 0.5rem; }
|
||||
.error { color: var(--error); font-size: 0.8125rem; margin-bottom: 1rem; }
|
||||
</style>
|
||||
61
src/views/admin/ProductsView.vue
Normal file
61
src/views/admin/ProductsView.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '@/api/client'
|
||||
import type { Product } from '@shared/types'
|
||||
import SatsDisplay from '@/components/ui/SatsDisplay.vue'
|
||||
import GlassButton from '@/components/ui/GlassButton.vue'
|
||||
import LoadingSpinner from '@/components/ui/LoadingSpinner.vue'
|
||||
|
||||
const products = ref<Product[]>([])
|
||||
const isLoading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await api.get('/api/admin/products')
|
||||
if (res.ok) products.value = await api.json<Product[]>(res)
|
||||
} finally { isLoading.value = false }
|
||||
})
|
||||
|
||||
async function toggleActive(product: Product) {
|
||||
const res = await api.put(`/api/admin/products/${product.id}`, { isActive: !product.isActive })
|
||||
if (res.ok) product.isActive = !product.isActive
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1>Products</h1>
|
||||
<router-link :to="{ name: 'admin-product-new' }"><GlassButton>Add Product</GlassButton></router-link>
|
||||
</div>
|
||||
<LoadingSpinner v-if="isLoading" />
|
||||
<table v-else class="products-table">
|
||||
<thead><tr><th>Name</th><th>Category</th><th>Price</th><th>Stock</th><th>Active</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="product in products" :key="product.id" :class="{ inactive: !product.isActive }">
|
||||
<td class="product-name">{{ product.name }}</td>
|
||||
<td class="category">{{ product.category }}</td>
|
||||
<td><SatsDisplay :sats="product.priceSats" /></td>
|
||||
<td>{{ product.sizes.reduce((s, sz) => s + sz.stock, 0) }}</td>
|
||||
<td><button class="toggle-btn" @click="toggleActive(product)">{{ product.isActive ? 'Active' : 'Hidden' }}</button></td>
|
||||
<td><router-link :to="{ name: 'admin-product-edit', params: { id: product.id } }" class="edit-link">Edit</router-link></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; }
|
||||
h1 { font-size: 1.5rem; font-weight: 700; }
|
||||
.products-table { width: 100%; border-collapse: collapse; }
|
||||
.products-table th { text-align: left; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); padding: 0.75rem 1rem; border-bottom: 1px solid var(--glass-border); }
|
||||
.products-table td { padding: 0.75rem 1rem; font-size: 0.875rem; border-bottom: 1px solid var(--glass-border); }
|
||||
.inactive { opacity: 0.5; }
|
||||
.product-name { font-weight: 600; }
|
||||
.category { text-transform: capitalize; color: var(--text-muted); }
|
||||
.toggle-btn { background: none; border: none; color: var(--text-secondary); font-size: 0.8125rem; cursor: pointer; }
|
||||
.toggle-btn:hover { color: var(--accent); }
|
||||
.edit-link { color: var(--accent); font-size: 0.8125rem; text-decoration: none; }
|
||||
.edit-link:hover { text-decoration: underline; }
|
||||
</style>
|
||||
12
src/vite-env.d.ts
vendored
Normal file
12
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface Window {
|
||||
nostr?: {
|
||||
getPublicKey(): Promise<string>
|
||||
signEvent(event: object): Promise<object>
|
||||
nip04?: {
|
||||
encrypt(pubkey: string, plaintext: string): Promise<string>
|
||||
decrypt(pubkey: string, ciphertext: string): Promise<string>
|
||||
}
|
||||
}
|
||||
}
|
||||
21
tsconfig.app.json
Normal file
21
tsconfig.app.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@shared/*": ["./shared/*"]
|
||||
},
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "shared/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
8
tsconfig.json
Normal file
8
tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" },
|
||||
{ "path": "./server/tsconfig.json" }
|
||||
]
|
||||
}
|
||||
11
tsconfig.node.json
Normal file
11
tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
24
vite.config.ts
Normal file
24
vite.config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
'@shared': fileURLToPath(new URL('./shared', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3333,
|
||||
host: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3141',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user