Compare commits

..

7 Commits

Author SHA1 Message Date
Dorian
fafe357329 fix: show one product per category on home (4 total)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 02:15:05 +00:00
Dorian
7dab35f896 fix: flat product grid, 4 columns, no category sections
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 02:09:28 +00:00
Dorian
46195142f3 fix: transparent header, global text colors, remove hero text
- Header fully transparent with no border
- Dark mode: #0A0A0A bg, #FAFAFA text
- Light mode: #FAFAFA bg, #0A0A0A text
- Removed "Antonym / Fashion for the sovereign individual" hero section
- Delete stale DB to re-seed with .svg image paths

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 01:59:25 +00:00
Dorian
52fe7a013f feat: placeholder images, Nostr inbox, order lookup, SEO, bigger logo
- 5 SVG placeholder product images (minimal dark style with watermark initials)
- Seed data updated to reference .svg placeholders
- Nostr DM inbox in admin (Messages tab) with shop npub display
- GET /api/admin/nostr-info endpoint for shop pubkey
- My Orders page: customers look up orders by NIP-07 Nostr identity
- GET /api/orders/by-pubkey/:pubkey endpoint with hex validation
- SeoMeta component for OG/Twitter meta tags
- SEO meta on HomeView and ProductView
- Base OG meta tags in index.html
- "My Orders" link in shop header nav
- Splash logo doubled in size on desktop (680px max)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 01:28:36 +00:00
Dorian
0c7c803aee fix: PSYOP now shows — separated from word class, TBF cinematic style
- PSYOP was broken because it shared .word class with conflicting animations
- Now uses its own .psyop-word class with independent animation
- TBF-style cinematic blur reveals (blur 20px -> 0, 1.2-1.4s durations)
- Material Design easing cubic-bezier(0.4, 0, 0.2, 1) throughout
- PSYOP sized at clamp(5rem, 18vw, 14rem) — massive on desktop
- Bounce-settle keyframes on PSYOP (30%/50%/70%/85%/100% steps)
- Logo and tagline are separate phases, both vertically centered

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 01:12:34 +00:00
Dorian
fd79069bf7 feat: splash redesign — logo crossfade to viewport-filling tagline
- Logo draws in centered, then fades out
- Tagline replaces logo in viewport center
- Bebas Neue (condensed hipster block) for main words
- Playfair Display italic 900 for PSYOP in Bitcoin orange
- Desktop: one line. Tablet (900px): 2 lines. Mobile (500px): 4 lines
- Words slam in with blur-to-sharp + scaleY bounce
- PSYOP lands with extra bounce-slam keyframes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 00:57:14 +00:00
Dorian
8dfb9444f4 fix: single-line tagline on desktop, wrap on mobile
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 00:52:14 +00:00
21 changed files with 509 additions and 156 deletions

View File

@@ -5,6 +5,15 @@
<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>
<meta name="description" content="Fashion for the sovereign individual. Bitcoin only. No accounts. No tracking." />
<meta property="og:title" content="Antonym" />
<meta property="og:description" content="Fashion for the sovereign individual. Bitcoin only. No accounts. No tracking." />
<meta property="og:image" content="/logos/logo.svg" />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Playfair+Display:ital,wght@1,900&display=swap" rel="stylesheet" />
</head>
<body>
<div id="app"></div>

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 800" width="600" height="800">
<rect width="600" height="800" fill="#141414"/>
<text x="300" y="420" text-anchor="middle" dominant-baseline="central"
font-family="Georgia, 'Times New Roman', serif" font-weight="100"
font-size="400" fill="rgba(255,255,255,0.08)" letter-spacing="-10">C</text>
<line x1="270" y1="480" x2="330" y2="480" stroke="#F7931A" stroke-width="1"/>
<text x="300" y="510" text-anchor="middle"
font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" font-weight="300"
font-size="24" fill="rgba(255,255,255,0.5)" letter-spacing="4">CONTRADICTION CARGO</text>
</svg>

After

Width:  |  Height:  |  Size: 686 B

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 800" width="600" height="800">
<rect width="600" height="800" fill="#141414"/>
<text x="300" y="420" text-anchor="middle" dominant-baseline="central"
font-family="Georgia, 'Times New Roman', serif" font-weight="100"
font-size="400" fill="rgba(255,255,255,0.08)" letter-spacing="-10">D</text>
<line x1="270" y1="480" x2="330" y2="480" stroke="#F7931A" stroke-width="1"/>
<text x="300" y="510" text-anchor="middle"
font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" font-weight="300"
font-size="24" fill="rgba(255,255,255,0.5)" letter-spacing="4">DUALITY JACKET</text>
</svg>

After

Width:  |  Height:  |  Size: 681 B

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 800" width="600" height="800">
<rect width="600" height="800" fill="#141414"/>
<text x="300" y="420" text-anchor="middle" dominant-baseline="central"
font-family="Georgia, 'Times New Roman', serif" font-weight="100"
font-size="400" fill="rgba(255,255,255,0.08)" letter-spacing="-10">I</text>
<line x1="270" y1="480" x2="330" y2="480" stroke="#F7931A" stroke-width="1"/>
<text x="300" y="510" text-anchor="middle"
font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" font-weight="300"
font-size="24" fill="rgba(255,255,255,0.5)" letter-spacing="4">INVERSE TEE</text>
</svg>

After

Width:  |  Height:  |  Size: 678 B

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 800" width="600" height="800">
<rect width="600" height="800" fill="#141414"/>
<text x="300" y="420" text-anchor="middle" dominant-baseline="central"
font-family="Georgia, 'Times New Roman', serif" font-weight="100"
font-size="400" fill="rgba(255,255,255,0.08)" letter-spacing="-10">P</text>
<line x1="270" y1="480" x2="330" y2="480" stroke="#F7931A" stroke-width="1"/>
<text x="300" y="510" text-anchor="middle"
font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" font-weight="300"
font-size="24" fill="rgba(255,255,255,0.5)" letter-spacing="4">PARADOX CAP</text>
</svg>

After

Width:  |  Height:  |  Size: 678 B

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 800" width="600" height="800">
<rect width="600" height="800" fill="#141414"/>
<text x="300" y="420" text-anchor="middle" dominant-baseline="central"
font-family="Georgia, 'Times New Roman', serif" font-weight="100"
font-size="400" fill="rgba(255,255,255,0.08)" letter-spacing="-10">S</text>
<line x1="270" y1="480" x2="330" y2="480" stroke="#F7931A" stroke-width="1"/>
<text x="300" y="510" text-anchor="middle"
font-family="'Helvetica Neue', Helvetica, Arial, sans-serif" font-weight="300"
font-size="24" fill="rgba(255,255,255,0.5)" letter-spacing="4">SHADOW HOODIE</text>
</svg>

After

Width:  |  Height:  |  Size: 680 B

View File

@@ -6,11 +6,11 @@ export function seedProducts(db: Database.Database): void {
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' },
{ 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.svg']), 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.svg']), 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.svg']), 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.svg']), 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.svg']), 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)')

View File

@@ -12,6 +12,8 @@ import { adminRouter } from './routes/admin.js'
import { adminProductsRouter } from './routes/adminProducts.js'
import { adminOrdersRouter } from './routes/adminOrders.js'
import { uploadRouter } from './routes/upload.js'
import { adminNostrRouter } from './routes/adminNostr.js'
import { ordersByPubkeyRouter } from './routes/ordersByPubkey.js'
const app = express()
const port = Number(process.env.PORT) || 3141
@@ -32,6 +34,8 @@ app.use('/api/admin', adminRouter)
app.use('/api/admin/products', adminProductsRouter)
app.use('/api/admin/orders', adminOrdersRouter)
app.use('/api/admin/upload', uploadRouter)
app.use('/api/admin', adminNostrRouter)
app.use('/api/orders/by-pubkey', ordersByPubkeyRouter)
app.get('/api/health', (_req, res) => { res.json({ ok: true, timestamp: new Date().toISOString() }) })

View File

@@ -0,0 +1,26 @@
import { Router } from 'express'
import { adminAuth } from '../middleware/adminAuth.js'
export const adminNostrRouter = Router()
adminNostrRouter.use(adminAuth)
adminNostrRouter.get('/nostr-info', async (_req, res) => {
const privkeyHex = process.env.NOSTR_PRIVATE_KEY
if (!privkeyHex) {
res.status(404).json({ error: { code: 'NOT_CONFIGURED', message: 'NOSTR_PRIVATE_KEY not set' } })
return
}
try {
const { getPublicKey } = await import('nostr-tools/pure')
const { npubEncode } = await import('nostr-tools/nip19')
const privkey = Uint8Array.from(Buffer.from(privkeyHex, 'hex'))
const pubkey = getPublicKey(privkey)
const npub = npubEncode(pubkey)
privkey.fill(0)
res.json({ npub, pubkey })
} catch {
res.status(500).json({ error: { code: 'NOSTR_ERROR', message: 'Failed to derive public key' } })
}
})

View File

@@ -0,0 +1,45 @@
import { Router } from 'express'
import { getDb } from '../db/connection.js'
import type { OrderItem, OrderStatus } from '../../shared/types.js'
export const ordersByPubkeyRouter = Router()
interface OrderRow {
id: string
nostr_pubkey: string | null
email: string | null
btcpay_invoice_id: string | null
status: string
items: string
total_sats: number
note: string | null
created_at: string
updated_at: string
}
ordersByPubkeyRouter.get('/:pubkey', (req, res) => {
const pubkey = req.params.pubkey
if (!/^[0-9a-f]{64}$/.test(pubkey)) {
res.status(400).json({ error: { code: 'INVALID_PUBKEY', message: 'Invalid hex pubkey' } })
return
}
const db = getDb()
const rows = db.prepare(
'SELECT * FROM orders WHERE nostr_pubkey = ? ORDER BY created_at DESC'
).all(pubkey) 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,
})))
})

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { watchEffect } from 'vue'
const props = defineProps<{
title?: string
description?: string
image?: string
}>()
const siteName = 'Antonym'
const defaultDescription = 'Fashion for the sovereign individual. Bitcoin only. No accounts. No tracking.'
const defaultImage = '/logos/logo.svg'
watchEffect(() => {
const fullTitle = props.title ? `${props.title}${siteName}` : siteName
document.title = fullTitle
setMeta('description', props.description || defaultDescription)
setMeta('og:title', fullTitle)
setMeta('og:description', props.description || defaultDescription)
setMeta('og:image', props.image || defaultImage)
setMeta('og:type', 'website')
setMeta('og:site_name', siteName)
setMeta('twitter:card', 'summary_large_image')
setMeta('twitter:title', fullTitle)
setMeta('twitter:description', props.description || defaultDescription)
setMeta('twitter:image', props.image || defaultImage)
})
function setMeta(name: string, content: string) {
const attr = name.startsWith('og:') || name.startsWith('twitter:') ? 'property' : 'name'
let el = document.querySelector(`meta[${attr}="${name}"]`)
if (!el) {
el = document.createElement('meta')
el.setAttribute(attr, name)
document.head.appendChild(el)
}
el.setAttribute('content', content)
}
</script>
<template>
<slot />
</template>

View File

@@ -23,6 +23,7 @@ async function handleLogout() {
<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>
<router-link :to="{ name: 'admin-messages' }" class="nav-item">Messages</router-link>
</nav>
<div class="sidebar-footer">

View File

@@ -6,7 +6,7 @@ const { itemCount } = useCart()
</script>
<template>
<header class="shop-header glass-strong">
<header class="shop-header">
<div class="header-inner">
<router-link to="/" class="logo-link">
<img src="/logos/logo.svg" alt="Antonym" class="logo" />
@@ -14,6 +14,7 @@ const { itemCount } = useCart()
<nav class="nav-links">
<router-link to="/" class="nav-link">Shop</router-link>
<router-link to="/my-orders" class="nav-link">My Orders</router-link>
</nav>
<div class="header-actions">
@@ -36,10 +37,8 @@ const { itemCount } = useCart()
position: sticky;
top: 0;
z-index: 100;
border-radius: 0;
border-top: none;
border-left: none;
border-right: none;
background: transparent;
border: none;
}
.header-inner {

View File

@@ -16,7 +16,15 @@ defineProps<{
<style scoped>
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
grid-template-columns: repeat(4, 1fr);
gap: 1.5rem;
}
@media (max-width: 900px) {
.product-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 500px) {
.product-grid { grid-template-columns: 1fr; }
}
</style>

View File

@@ -4,6 +4,7 @@ import { ref, onMounted } from 'vue'
const emit = defineEmits<{ complete: [] }>()
const isAnimating = ref(false)
const logoGone = ref(false)
const showTagline = ref(false)
const showPsyop = ref(false)
const isFading = ref(false)
@@ -12,7 +13,7 @@ const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)
onMounted(() => {
if (prefersReducedMotion) {
isAnimating.value = true
logoGone.value = true
showTagline.value = true
showPsyop.value = true
setTimeout(() => emit('complete'), 800)
@@ -24,34 +25,37 @@ onMounted(() => {
isAnimating.value = true
})
// Phase 2: Logo shrinks up, tagline words slam in one by one
// Phase 2: Logo fades out
setTimeout(() => {
logoGone.value = true
}, 1900)
// Phase 3: Tagline words blur in
setTimeout(() => {
showTagline.value = true
}, 1800)
}, 2300)
// Phase 3: "PSYOP" lands last with extra punch
// Phase 4: PSYOP slams in
setTimeout(() => {
showPsyop.value = true
}, 2800)
}, 3100)
// Phase 4: Hold, then fade
// Phase 5: Fade everything out
setTimeout(() => {
isFading.value = true
}, 4200)
}, 4800)
setTimeout(() => {
emit('complete')
}, 4700)
}, 5400)
})
</script>
<template>
<div
class="splash-overlay"
:class="{ 'splash-fade-out': isFading }"
>
<div class="splash-content" :class="{ 'logo-shrunk': showTagline }">
<div class="splash-overlay" :class="{ 'splash-fade-out': isFading }">
<!-- Phase 1: Logo draws in centered -->
<svg
v-if="!logoGone"
width="309"
height="121"
viewBox="0 0 309 121"
@@ -67,17 +71,18 @@ onMounted(() => {
<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 class="tagline-block" :class="{ visible: showTagline }">
<span class="word" style="--w: 0">EVERYTHING</span>
<span class="word" style="--w: 1">YOU</span>
<span class="word" style="--w: 2">LOVE</span>
<span class="word" style="--w: 3">IS A</span>
<span
class="word psyop"
:class="{ landed: showPsyop }"
style="--w: 4"
>PSYOP</span>
<!-- Phase 2: Tagline replaces logo in center -->
<div v-if="logoGone" class="tagline-container">
<div class="tagline-line" :class="{ visible: showTagline }">
<span class="word" style="--w: 0">Everything</span>
<span class="word" style="--w: 1">you</span>
<span class="word" style="--w: 2">love</span>
<span class="word" style="--w: 3">is a</span>
</div>
<div
class="psyop-word"
:class="{ visible: showPsyop }"
>PSYOP</div>
</div>
</div>
</template>
@@ -91,7 +96,7 @@ onMounted(() => {
align-items: center;
justify-content: center;
background: var(--bg-primary);
transition: opacity 500ms ease;
transition: opacity 600ms cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
@@ -100,28 +105,10 @@ onMounted(() => {
pointer-events: none;
}
.splash-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 2rem;
transition: transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
}
.splash-content.logo-shrunk {
transform: translateY(-8vh);
}
/* --- Logo phase --- */
.splash-logo {
width: min(60vw, 340px);
width: min(80vw, 680px);
height: auto;
transition: transform 600ms cubic-bezier(0.16, 1, 0.3, 1),
opacity 600ms ease;
}
.logo-shrunk .splash-logo {
transform: scale(0.7);
opacity: 0.6;
}
.logo-path {
@@ -147,127 +134,147 @@ onMounted(() => {
to { opacity: 1; }
}
/* --- Tagline --- */
.tagline-block {
/* --- Tagline phase --- */
.tagline-container {
display: flex;
flex-wrap: wrap;
flex-direction: column;
align-items: center;
padding: 0 3vw;
animation: containerFadeIn 800ms cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
@keyframes containerFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.tagline-line {
display: flex;
flex-wrap: nowrap;
justify-content: center;
gap: 0 0.4em;
width: 90vw;
max-width: 900px;
text-align: center;
line-height: 1.05;
gap: 0 0.25em;
white-space: nowrap;
}
.word {
font-family: system-ui, -apple-system, 'Helvetica Neue', Arial, sans-serif;
font-size: clamp(2.5rem, 10vw, 7rem);
font-weight: 900;
font-family: 'Bebas Neue', 'Impact', 'Arial Black', sans-serif;
font-size: clamp(1.6rem, 4vw, 4.2rem);
font-weight: 400;
color: var(--text-primary);
text-transform: uppercase;
letter-spacing: -0.02em;
letter-spacing: 0.05em;
line-height: 1;
opacity: 0;
transform: translateY(40px) scale(1.1);
filter: blur(8px);
transform: translateY(30px);
filter: blur(20px);
}
.tagline-block.visible .word {
animation: slamIn 400ms cubic-bezier(0.16, 1, 0.3, 1) calc(var(--w) * 150ms) forwards;
.tagline-line.visible .word {
animation: cinematicReveal 1.2s cubic-bezier(0.4, 0, 0.2, 1) calc(var(--w) * 150ms) forwards;
}
@keyframes slamIn {
@keyframes cinematicReveal {
0% {
opacity: 0;
transform: translateY(40px) scale(1.1);
filter: blur(8px);
transform: translateY(30px);
filter: blur(20px);
}
60% {
opacity: 1;
transform: translateY(-4px) scale(1.0);
filter: blur(0);
}
100% {
opacity: 1;
transform: translateY(0) scale(1.0);
transform: translateY(0);
filter: blur(0);
}
}
/* PSYOP — different font, extra punch */
.psyop {
font-family: 'Georgia', 'Times New Roman', 'Playfair Display', serif;
/* --- PSYOP --- */
.psyop-word {
font-family: 'Playfair Display', 'Georgia', serif;
font-style: italic;
font-weight: 900;
color: var(--accent);
display: block;
width: 100%;
font-size: clamp(3.5rem, 15vw, 10rem);
letter-spacing: 0.08em;
margin-top: 0.1em;
transform: translateY(60px) scale(1.3);
filter: blur(12px);
font-size: clamp(5rem, 18vw, 14rem);
letter-spacing: 0.06em;
line-height: 0.85;
text-transform: uppercase;
margin-top: -0.02em;
opacity: 0;
transform: translateY(60px) scale(1.15);
filter: blur(20px);
}
.psyop.landed {
animation: psyopSlam 600ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
.psyop-word.visible {
animation: psyopCinematic 1.4s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
@keyframes psyopSlam {
@keyframes psyopCinematic {
0% {
opacity: 0;
transform: translateY(60px) scale(1.3);
filter: blur(12px);
transform: translateY(60px) scale(1.15);
filter: blur(20px);
}
40% {
30% {
opacity: 1;
transform: translateY(-6px) scale(1.02);
filter: blur(2px);
}
50% {
transform: translateY(-6px) scale(1.01);
filter: blur(0);
}
55% {
transform: translateY(2px) scale(0.99);
}
70% {
transform: translateY(-1px) scale(1.0);
transform: translateY(3px) scale(0.995);
}
85% {
transform: translateY(-1px) scale(1.002);
}
100% {
opacity: 1;
transform: translateY(0) scale(1.0);
transform: translateY(0) scale(1);
filter: blur(0);
}
}
/* Mobile: 4-line layout */
@media (max-width: 600px) {
/* Tablet: 2 lines */
@media (max-width: 900px) {
.tagline-line {
flex-wrap: wrap;
white-space: normal;
gap: 0 0.25em;
}
.word {
font-size: clamp(2rem, 14vw, 4rem);
font-size: clamp(1.8rem, 6vw, 3rem);
}
.psyop {
font-size: clamp(3rem, 20vw, 5.5rem);
.psyop-word {
font-size: clamp(4rem, 20vw, 8rem);
}
}
/* Mobile: each word stacks */
@media (max-width: 500px) {
.tagline-line {
flex-direction: column;
align-items: center;
}
.tagline-block {
width: 95vw;
.word {
font-size: clamp(2rem, 11vw, 3rem);
}
.psyop-word {
font-size: clamp(3.5rem, 22vw, 5.5rem);
}
}
@media (prefers-reduced-motion: reduce) {
.logo-path {
clip-path: none;
opacity: 1;
}
.word,
.psyop {
opacity: 1;
transform: none;
filter: none;
animation: none !important;
}
.splash-overlay,
.splash-content,
.splash-logo {
transition: none;
}
.logo-path { clip-path: none; opacity: 1; }
.word { opacity: 1; transform: none; filter: none; animation: none !important; }
.psyop-word { opacity: 1; transform: none; filter: none; animation: none !important; }
.tagline-container { animation: none; }
.splash-overlay { transition: none; }
}
</style>

View File

@@ -13,6 +13,7 @@ export const router = createRouter({
{ 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: 'my-orders', name: 'my-orders', component: () => import('@/views/MyOrdersView.vue') },
],
},
{
@@ -30,6 +31,7 @@ export const router = createRouter({
{ 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') },
{ path: 'messages', name: 'admin-messages', component: () => import('@/views/admin/MessagesView.vue') },
],
},
],

View File

@@ -11,11 +11,11 @@
--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);
--text-primary: #FAFAFA;
--text-secondary: rgba(250, 250, 250, 0.7);
--text-muted: rgba(250, 250, 250, 0.5);
--text-placeholder: rgba(250, 250, 250, 0.25);
--text-interactive: rgba(250, 250, 250, 0.7);
--success: #4ade80;
--error: #ef4444;
--warning: #f59e0b;
@@ -44,10 +44,10 @@
--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);
--text-secondary: rgba(10, 10, 10, 0.7);
--text-muted: rgba(10, 10, 10, 0.5);
--text-placeholder: rgba(10, 10, 10, 0.25);
--text-interactive: rgba(10, 10, 10, 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);

View File

@@ -4,6 +4,7 @@ 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'
import SeoMeta from '@/components/SeoMeta.vue'
const products = ref<Product[]>([])
const isLoading = ref(true)
@@ -14,8 +15,17 @@ const categories = computed(() => {
return Array.from(cats).sort()
})
const onePerCategory = computed(() => {
const seen = new Set<string>()
return products.value.filter((p) => {
if (seen.has(p.category)) return false
seen.add(p.category)
return true
})
})
const filtered = computed(() => {
if (!activeCategory.value) return products.value
if (!activeCategory.value) return onePerCategory.value
return products.value.filter((p) => p.category === activeCategory.value)
})
@@ -30,12 +40,8 @@ onMounted(async () => {
</script>
<template>
<SeoMeta title="Shop" description="Fashion for the sovereign individual. Bitcoin only. No accounts. No tracking." />
<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>
@@ -47,9 +53,6 @@ onMounted(async () => {
</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;

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '@/api/client'
import { useNostr } from '@/composables/useNostr'
import type { Order } 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 LoadingSpinner from '@/components/ui/LoadingSpinner.vue'
const { pubkey, hasExtension, connectExtension } = useNostr()
const orders = ref<Order[]>([])
const isLoading = ref(false)
async function fetchOrders() {
if (!pubkey.value) return
isLoading.value = true
try {
const res = await api.get(`/api/orders/by-pubkey/${pubkey.value}`)
if (res.ok) orders.value = await api.json<Order[]>(res)
} finally {
isLoading.value = false
}
}
onMounted(() => {
if (pubkey.value) fetchOrders()
})
async function handleConnect() {
await connectExtension()
if (pubkey.value) fetchOrders()
}
</script>
<template>
<div class="my-orders">
<h1>My Orders</h1>
<p class="subtitle">Look up your orders using your Nostr identity.</p>
<div v-if="!pubkey" class="connect-prompt glass-card">
<p>Connect your Nostr identity to view your order history.</p>
<div class="actions">
<GlassButton v-if="hasExtension" @click="handleConnect">Connect Extension (NIP-07)</GlassButton>
<p v-else class="hint">Install a NIP-07 browser extension (like nos2x or Alby) to connect.</p>
</div>
</div>
<div v-else>
<LoadingSpinner v-if="isLoading" />
<div v-else-if="orders.length === 0" class="empty">
<p>No orders found for this Nostr identity.</p>
</div>
<div v-else class="orders-list">
<router-link
v-for="order in orders"
:key="order.id"
:to="`/order/${order.id}`"
class="order-row glass-card"
>
<div class="order-meta">
<code class="order-id">{{ order.id.slice(0, 12) }}...</code>
<time>{{ new Date(order.createdAt).toLocaleDateString() }}</time>
</div>
<div class="order-info">
<span>{{ order.items.reduce((s, i) => s + i.quantity, 0) }} items</span>
<SatsDisplay :sats="order.totalSats" />
<StatusBadge :status="order.status" />
</div>
</router-link>
</div>
</div>
</div>
</template>
<style scoped>
h1 { font-size: 1.75rem; font-weight: 700; margin-bottom: 0.25rem; }
.subtitle { color: var(--text-muted); font-size: 0.875rem; margin-bottom: 1.5rem; }
.connect-prompt { text-align: center; }
.connect-prompt p { color: var(--text-secondary); margin-bottom: 1rem; }
.actions { display: flex; justify-content: center; gap: 0.75rem; }
.hint { color: var(--text-muted); font-size: 0.8125rem; }
.empty { text-align: center; padding: 3rem 0; color: var(--text-muted); }
.orders-list { display: flex; flex-direction: column; gap: 1rem; }
.order-row { display: flex; justify-content: space-between; align-items: center; text-decoration: none; color: inherit; cursor: pointer; }
.order-row:hover { border-color: var(--glass-highlight); transform: translateY(-1px); }
.order-meta { display: flex; flex-direction: column; gap: 0.25rem; }
.order-id { font-size: 0.8125rem; color: var(--accent); }
.order-meta time { font-size: 0.75rem; color: var(--text-muted); }
.order-info { display: flex; align-items: center; gap: 1rem; font-size: 0.875rem; }
</style>

View File

@@ -8,6 +8,7 @@ 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'
import SeoMeta from '@/components/SeoMeta.vue'
const route = useRoute()
const { addItem } = useCart()
@@ -35,6 +36,7 @@ function handleAddToCart() {
</script>
<template>
<SeoMeta v-if="product" :title="product.name" :description="product.description" :image="product.images[0]" />
<LoadingSpinner v-if="isLoading" />
<div v-else-if="product" class="product-detail">
<div class="product-image">

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '@/api/client'
import GlassCard from '@/components/ui/GlassCard.vue'
const shopNpub = ref('')
const isLoading = ref(true)
onMounted(async () => {
try {
const res = await api.get('/api/admin/nostr-info')
if (res.ok) {
const data = await res.json() as { npub: string; pubkey: string }
shopNpub.value = data.npub
}
} finally {
isLoading.value = false
}
})
</script>
<template>
<div>
<h1>Messages</h1>
<p class="subtitle">Customer communication via Nostr encrypted DMs</p>
<GlassCard v-if="shopNpub" class="info-card">
<h3>Shop Nostr Identity</h3>
<code class="npub">{{ shopNpub }}</code>
<p class="hint">Customers who provide their Nostr pubkey at checkout receive order updates as encrypted DMs from this identity.</p>
<div class="actions">
<a :href="`https://njump.me/${shopNpub}`" target="_blank" rel="noopener" class="btn btn-ghost">View on njump.me</a>
<a href="https://coracle.social" target="_blank" rel="noopener" class="btn btn-ghost">Open Coracle</a>
</div>
</GlassCard>
<GlassCard class="info-card">
<h3>Reading Customer DMs</h3>
<p>To read and reply to customer messages, import the shop's Nostr private key into any Nostr client:</p>
<ul>
<li><strong>Desktop:</strong> Coracle, Snort, or any NIP-04 compatible client</li>
<li><strong>iOS:</strong> Damus</li>
<li><strong>Android:</strong> Amethyst</li>
</ul>
<p class="warning">Never share the shop's private key. Only import it on trusted devices.</p>
</GlassCard>
</div>
</template>
<style scoped>
h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: 0.25rem; }
.subtitle { color: var(--text-muted); font-size: 0.875rem; margin-bottom: 1.5rem; }
.info-card { margin-bottom: 1.5rem; }
.info-card h3 { font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem; }
.info-card p { font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 0.75rem; line-height: 1.6; }
.npub { display: block; font-size: 0.75rem; color: var(--accent); word-break: break-all; margin-bottom: 0.75rem; padding: 0.75rem; background: var(--glass-bg-darker); border-radius: var(--radius-sm); }
.hint { font-size: 0.8125rem; color: var(--text-muted); }
.actions { display: flex; gap: 0.75rem; margin-top: 1rem; }
ul { font-size: 0.875rem; color: var(--text-secondary); padding-left: 1.25rem; margin-bottom: 0.75rem; }
li { margin-bottom: 0.375rem; }
.warning { color: var(--warning); font-size: 0.8125rem; font-weight: 500; }
</style>