Compare commits
7 Commits
88a373da80
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fafe357329 | ||
|
|
7dab35f896 | ||
|
|
46195142f3 | ||
|
|
52fe7a013f | ||
|
|
0c7c803aee | ||
|
|
fd79069bf7 | ||
|
|
8dfb9444f4 |
@@ -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>
|
||||
|
||||
10
public/images/contradiction-cargo.svg
Normal file
10
public/images/contradiction-cargo.svg
Normal 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 |
10
public/images/duality-jacket.svg
Normal file
10
public/images/duality-jacket.svg
Normal 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 |
10
public/images/inverse-tee.svg
Normal file
10
public/images/inverse-tee.svg
Normal 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 |
10
public/images/paradox-cap.svg
Normal file
10
public/images/paradox-cap.svg
Normal 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 |
10
public/images/shadow-hoodie.svg
Normal file
10
public/images/shadow-hoodie.svg
Normal 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 |
@@ -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)')
|
||||
|
||||
@@ -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() }) })
|
||||
|
||||
|
||||
26
server/routes/adminNostr.ts
Normal file
26
server/routes/adminNostr.ts
Normal 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' } })
|
||||
}
|
||||
})
|
||||
45
server/routes/ordersByPubkey.ts
Normal file
45
server/routes/ordersByPubkey.ts
Normal 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,
|
||||
})))
|
||||
})
|
||||
44
src/components/SeoMeta.vue
Normal file
44
src/components/SeoMeta.vue
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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') },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
91
src/views/MyOrdersView.vue
Normal file
91
src/views/MyOrdersView.vue
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
62
src/views/admin/MessagesView.vue
Normal file
62
src/views/admin/MessagesView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user