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:
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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -107,7 +107,7 @@ onMounted(() => {
|
||||
|
||||
/* --- Logo phase --- */
|
||||
.splash-logo {
|
||||
width: min(60vw, 340px);
|
||||
width: min(80vw, 680px);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -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') },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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)
|
||||
@@ -30,6 +31,7 @@ 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>
|
||||
|
||||
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