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:
Dorian
2026-03-17 00:23:21 +00:00
commit 54500a68e6
64 changed files with 6983 additions and 0 deletions

11
.gitignore vendored Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

8
public/logos/logo.svg Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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}`)
}

View 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)
}

View 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
View 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 }) })

View 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 })
})

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>
},
}

View 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>

View 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>

View 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>

View 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">&middot;</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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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 }
}

View 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 }
}

View 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 }
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>

View 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>

View 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">&larr; 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>

View 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>

View 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">&larr; 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>

View 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
View 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
View 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
View 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
View 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
View 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,
},
},
},
})