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>
This commit is contained in:
Dorian
2026-03-17 01:28:36 +00:00
parent 0c7c803aee
commit 52fe7a013f
19 changed files with 342 additions and 6 deletions

View File

@@ -5,6 +5,12 @@
<link rel="icon" type="image/svg+xml" href="/logos/logo.svg" /> <link rel="icon" type="image/svg+xml" href="/logos/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Antonym</title> <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.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <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" /> <link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Playfair+Display:ital,wght@1,900&display=swap" rel="stylesheet" />

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 if (count.n > 0) return
const products = [ 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: '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.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: '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.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: '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.jpg']), sizes: JSON.stringify([{ size: 'ONE SIZE', stock: 30 }]), category: 'accessories' }, { 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.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: '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)') 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 { adminProductsRouter } from './routes/adminProducts.js'
import { adminOrdersRouter } from './routes/adminOrders.js' import { adminOrdersRouter } from './routes/adminOrders.js'
import { uploadRouter } from './routes/upload.js' import { uploadRouter } from './routes/upload.js'
import { adminNostrRouter } from './routes/adminNostr.js'
import { ordersByPubkeyRouter } from './routes/ordersByPubkey.js'
const app = express() const app = express()
const port = Number(process.env.PORT) || 3141 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/products', adminProductsRouter)
app.use('/api/admin/orders', adminOrdersRouter) app.use('/api/admin/orders', adminOrdersRouter)
app.use('/api/admin/upload', uploadRouter) 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() }) }) 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"> <nav class="sidebar-nav">
<router-link :to="{ name: 'admin-orders' }" class="nav-item">Orders</router-link> <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-products' }" class="nav-item">Products</router-link>
<router-link :to="{ name: 'admin-messages' }" class="nav-item">Messages</router-link>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">

View File

@@ -14,6 +14,7 @@ const { itemCount } = useCart()
<nav class="nav-links"> <nav class="nav-links">
<router-link to="/" class="nav-link">Shop</router-link> <router-link to="/" class="nav-link">Shop</router-link>
<router-link to="/my-orders" class="nav-link">My Orders</router-link>
</nav> </nav>
<div class="header-actions"> <div class="header-actions">

View File

@@ -107,7 +107,7 @@ onMounted(() => {
/* --- Logo phase --- */ /* --- Logo phase --- */
.splash-logo { .splash-logo {
width: min(60vw, 340px); width: min(80vw, 680px);
height: auto; height: auto;
} }

View File

@@ -13,6 +13,7 @@ export const router = createRouter({
{ path: 'cart', name: 'cart', component: () => import('@/views/CartView.vue') }, { path: 'cart', name: 'cart', component: () => import('@/views/CartView.vue') },
{ path: 'checkout', name: 'checkout', component: () => import('@/views/CheckoutView.vue') }, { path: 'checkout', name: 'checkout', component: () => import('@/views/CheckoutView.vue') },
{ path: 'order/:id', name: 'order', component: () => import('@/views/OrderView.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', name: 'admin-products', component: () => import('@/views/admin/ProductsView.vue') },
{ path: 'products/new', name: 'admin-product-new', component: () => import('@/views/admin/ProductFormView.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: '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

@@ -4,6 +4,7 @@ import { api } from '@/api/client'
import type { Product } from '@shared/types' import type { Product } from '@shared/types'
import ProductGrid from '@/components/product/ProductGrid.vue' import ProductGrid from '@/components/product/ProductGrid.vue'
import LoadingSpinner from '@/components/ui/LoadingSpinner.vue' import LoadingSpinner from '@/components/ui/LoadingSpinner.vue'
import SeoMeta from '@/components/SeoMeta.vue'
const products = ref<Product[]>([]) const products = ref<Product[]>([])
const isLoading = ref(true) const isLoading = ref(true)
@@ -30,6 +31,7 @@ onMounted(async () => {
</script> </script>
<template> <template>
<SeoMeta title="Shop" description="Fashion for the sovereign individual. Bitcoin only. No accounts. No tracking." />
<div class="home"> <div class="home">
<section class="hero"> <section class="hero">
<h1>Antonym</h1> <h1>Antonym</h1>

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