Files
archy/neode-ui/src/components/HardwareMarketplace.vue
T

234 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'
import { rpcClient } from '@/api/rpc-client'
const props = withDefaults(defineProps<{ expanded?: boolean; kind?: 'router' | 'phone' }>(), { expanded: false, kind: 'router' })
const emit = defineEmits<{ open: []; close: [] }>()
interface HardwareListing {
id: string
pubkey: string
title: string
summary: string
content: string
location: string
status: string
image: string
price?: { amount: string; currency: string; frequency?: string }
relay: string
}
const relayUrls = ref<string[]>([])
const DEV_ROUTER_LISTINGS: HardwareListing[] = [
{ id: 'dev-beryl-ax', pubkey: 'archipelago-demo-seller', title: 'GL.iNet Beryl AX (GL-MT3000)', summary: 'Wi‑Fi 6 travel router with OpenWrt, WireGuard and mesh-ready firmware.', content: '', location: 'London, UK', status: 'active', image: '/assets/img/router-mockups/beryl-ax.webp', price: { amount: '89', currency: 'EUR' }, relay: 'development mock' },
{ id: 'dev-flint-2', pubkey: 'openwrt-demo-seller', title: 'GL.iNet Flint 2', summary: 'Dual 2.5G Ethernet OpenWrt router for a home gateway or node controller.', content: '', location: 'Berlin, DE', status: 'active', image: '/assets/img/router-mockups/flint-2.webp', price: { amount: '159', currency: 'EUR' }, relay: 'development mock' },
{ id: 'dev-nano-pi', pubkey: 'community-demo-seller', title: 'NanoPi R5S OpenWrt gateway', summary: 'Compact RK3568 gateway with three Ethernet ports and community support.', content: '', location: 'Amsterdam, NL', status: 'active', image: '/assets/img/router-mockups/nanopi-r5s.webp', price: { amount: '74', currency: 'EUR' }, relay: 'development mock' },
]
const DEV_PHONE_LISTINGS: HardwareListing[] = [
{ id: 'dev-pixel-9', pubkey: 'privacy-demo-seller', title: 'Pixel 9 · GrapheneOS ready', summary: 'Unlocked Pixel with a long update runway and a fresh install path.', content: '', location: 'London, UK', status: 'active', image: '/assets/img/phone-mockups/pixel-9.webp', price: { amount: '499', currency: 'EUR' }, relay: 'development mock' },
{ id: 'dev-pixel-9a', pubkey: 'mobile-demo-seller', title: 'Pixel 9a · unlocked', summary: 'Compact everyday Pixel. Check the bootloader can be unlocked before buying.', content: '', location: 'Berlin, DE', status: 'active', image: '/assets/img/phone-mockups/pixel-9a.webp', price: { amount: '339', currency: 'EUR' }, relay: 'development mock' },
{ id: 'dev-pixel-10', pubkey: 'phone-demo-seller', title: 'Pixel 10 · GrapheneOS ready', summary: 'A newer Pixel for private, independent mobile computing.', content: '', location: 'Amsterdam, NL', status: 'active', image: '/assets/img/phone-mockups/pixel-10.webp', price: { amount: '699', currency: 'EUR' }, relay: 'development mock' },
]
const devListings = computed(() => props.kind === 'phone' ? DEV_PHONE_LISTINGS : DEV_ROUTER_LISTINGS)
const topicTags = computed(() => props.kind === 'phone' ? ['pixel'] : ['openwrt', 'router', 'gl-inet', 'beryl', 'flint'])
const open = ref(false)
const carousel = ref<HTMLElement>()
const listings = ref<HardwareListing[]>([])
const loading = ref(false)
const error = ref('')
let requestId = 0
let sockets: WebSocket[] = []
function tag(event: { tags?: string[][] }, name: string): string {
return event.tags?.find(item => item[0] === name)?.[1]?.trim() ?? ''
}
function imageTag(event: { tags?: string[][] }): string {
return event.tags?.find(item => item[0] === 'image')?.[1]?.trim() ?? ''
}
function matchesHardware(item: { title: string; summary: string; content: string; tags?: string[][] }) {
const tags = item.tags?.flat().join(' ') ?? ''
const text = `${item.title} ${item.summary} ${item.content} ${tags}`
return props.kind === 'phone'
? /\bpixel\b/i.test(text)
: /openwrt|gl[- .]?inet|beryl|flint|nanopi|gl-mt3000|gl-mt6000/i.test(text)
}
function parse(event: {
id?: string; pubkey?: string; content?: string; tags?: string[][]
}, relay: string): HardwareListing | null {
if (!event.id || !event.pubkey || !matchesHardware({ title: tag(event, 'title'), summary: tag(event, 'summary'), content: event.content ?? '', tags: event.tags })) return null
const price = event.tags?.find(item => item[0] === 'price')
return {
id: event.id,
pubkey: event.pubkey,
title: tag(event, 'title') || (props.kind === 'phone' ? 'Pixel phone' : 'OpenWrt router'),
summary: tag(event, 'summary') || (event.content ?? '').split('\n')[0]?.slice(0, 150) || 'Hardware listing',
content: event.content ?? '',
location: tag(event, 'location'),
status: tag(event, 'status') || 'active',
image: imageTag(event),
price: price?.[1] && price[2] ? { amount: price[1], currency: price[2], frequency: price[3] } : undefined,
relay,
}
}
function closeSockets() {
sockets.forEach(socket => socket.close())
sockets = []
}
async function search() {
const run = ++requestId
closeSockets(); loading.value = true; error.value = ''
try {
const configured = await rpcClient.call<{ relays?: Array<{ url?: string; enabled?: boolean }> }>({ method: 'nostr.list-relays', params: {} })
relayUrls.value = (configured.relays ?? [])
.filter(relay => relay.enabled !== false && typeof relay.url === 'string' && /^wss?:\/\//.test(relay.url))
.map(relay => relay.url!.replace(/\/$/, ''))
} catch {
relayUrls.value = []
}
if (run !== requestId) return
if (!relayUrls.value.length) {
if (import.meta.env.DEV) {
listings.value = devListings.value
loading.value = false
return
}
loading.value = false
error.value = 'No Archipelago Nostr relays are configured. Add a relay in Settings → Nostr Relays and try again.'
return
}
const found = new Map<string, HardwareListing>()
await Promise.all(relayUrls.value.map(relay => new Promise<void>(resolve => {
let settled = false
const finish = () => { if (!settled) { settled = true; resolve() } }
let socket: WebSocket
try { socket = new WebSocket(relay) } catch { finish(); return }
sockets.push(socket)
const timer = window.setTimeout(() => { socket.close(); finish() }, 8000)
socket.addEventListener('open', () => {
socket.send(JSON.stringify(['REQ', `archy-${props.kind}-${run}`, {
kinds: [30402],
'#t': topicTags.value,
limit: 100,
}, {
kinds: [30402],
limit: 200,
}]))
})
socket.addEventListener('message', message => {
try {
const packet = JSON.parse(String(message.data)) as [string, string, { id?: string; pubkey?: string; content?: string; tags?: string[][] }]
if (packet[0] === 'EVENT') {
const item = parse(packet[2], relay)
if (item && item.status !== 'sold') found.set(item.id, item)
}
if (packet[0] === 'EOSE') { window.clearTimeout(timer); socket.close(); finish() }
} catch { /* Ignore malformed relay frames. */ }
})
socket.addEventListener('error', () => { window.clearTimeout(timer); finish() })
socket.addEventListener('close', () => { window.clearTimeout(timer); finish() })
})))
if (run !== requestId) return
listings.value = found.size ? [...found.values()] : import.meta.env.DEV ? devListings.value : []
loading.value = false
if (!listings.value.length) error.value = `No active ${props.kind === 'phone' ? 'Pixel phone' : 'OpenWrt router'} listings found on your configured relays.`
}
function openShop() {
open.value = true
if (import.meta.env.DEV) listings.value = devListings.value
emit('open')
void search()
}
function closeShop() { requestId++; open.value = false; closeSockets(); emit('close') }
function moveCarousel(direction: number) {
const list = carousel.value
const card = list?.querySelector<HTMLElement>('.router-marketplace__card')
if (list && card) list.scrollBy({ left: direction * (card.offsetWidth + 16), behavior: 'smooth' })
}
function formatPrice(item: HardwareListing) {
if (!item.price) return 'Price on request'
return `${item.price.amount} ${item.price.currency.toUpperCase()}${item.price.frequency ? ` / ${item.price.frequency}` : ''}`
}
onBeforeUnmount(() => { requestId++; closeSockets() })
</script>
<template>
<section class="router-marketplace lg:mt-0" :class="{ 'router-marketplace--expanded': props.expanded }" aria-labelledby="router-marketplace-title">
<div class="glass-card p-5 md:p-6">
<template v-if="!open">
<p class="text-xs uppercase tracking-[.18em] text-white/40">{{ props.kind === 'phone' ? 'Private mobile' : 'OpenWrt gateway' }}</p>
<h2 id="router-marketplace-title" class="text-base font-semibold text-white mt-1">{{ props.kind === 'phone' ? 'Ready for a phone that answers to you?' : 'Not got an OpenWrt router?' }}</h2>
<p class="text-sm text-white/55 mt-2 max-w-2xl">{{ props.kind === 'phone' ? 'Choose an unlockable Pixel. Install GrapheneOS. Lock the bootloader for verified boot.' : 'Browse community-published router listings from Nostr marketplaces. We’ll add Archipelago checkout after discovery is in place.' }}</p>
<button type="button" class="glass-button router-marketplace__cta mt-4" @click="openShop">
{{ props.kind === 'phone' ? 'Buy phone' : 'Buy router' }}
<span aria-hidden="true">→</span>
</button>
</template>
<template v-else>
<div class="flex flex-wrap items-start justify-between gap-3 mb-2">
<div>
<h2 id="router-marketplace-title" class="text-base font-semibold text-white">{{ props.kind === 'phone' ? 'Pixel phones for sale' : 'OpenWrt routers for sale' }}</h2>
<p class="text-xs text-white/50 mt-1">{{ listings[0]?.relay === 'development mock' ? 'Development preview · conceptual images and sample prices' : 'Live NIP-99 listings from Archipelago relays.' }}</p>
</div>
<div class="flex items-center gap-2">
<button type="button" class="router-marketplace__nav" :aria-label="`Previous ${props.kind} listing`" @click="moveCarousel(-1)">‹</button>
<button type="button" class="router-marketplace__nav" :aria-label="`Next ${props.kind} listing`" @click="moveCarousel(1)">›</button>
<button type="button" class="text-xs text-white/45 hover:text-white ml-2" @click="closeShop">Close</button>
</div>
</div>
<div v-if="loading && !listings.length" class="sr-only" aria-live="polite">Loading {{ props.kind }} listings</div>
<div v-else-if="error && !listings.length" class="text-sm text-white/50 py-5 text-center">{{ error }}</div>
<div v-else ref="carousel" class="router-marketplace__results" tabindex="0" :aria-label="`${props.kind === 'phone' ? 'Phone' : 'Router'} listings carousel`">
<article v-for="item in listings" :key="item.id" class="router-marketplace__card">
<img v-if="item.image" :src="item.image" :alt="item.title" class="router-marketplace__image" loading="lazy" />
<div v-else class="router-marketplace__image router-marketplace__image--empty" aria-hidden="true">⌁</div>
<div class="router-marketplace__details">
<h3 class="text-sm font-medium text-white truncate">{{ item.title }}</h3>
<p class="text-xs text-white/55 mt-1 line-clamp-1">{{ item.summary }}</p>
<div class="flex flex-wrap gap-x-3 gap-y-1 mt-3 text-[11px] text-white/40">
<span v-if="item.location">{{ item.location }}</span>
<span>seller {{ item.pubkey.slice(0, 8) }}…</span>
</div>
<div class="router-marketplace__footer">
<strong class="router-marketplace__price">{{ formatPrice(item) }}</strong>
<div class="router-marketplace__actions">
<button type="button" class="router-marketplace__action" disabled title="Listing details are next">View</button>
<button type="button" class="router-marketplace__action router-marketplace__action--buy" disabled title="Checkout is the next step">Buy</button>
</div>
</div>
</div>
</article>
</div>
</template>
</div>
</section>
</template>
<style scoped>
.router-marketplace { margin-top: 16px; }
.router-marketplace--expanded { grid-column: 1 / -1; width: 100%; min-width: 0; height: 100%; min-height: 0; margin-top: 0; }
.router-marketplace--expanded > .glass-card { height: 100%; min-height: 0; display: flex; flex-direction: column; padding: 14px; }
.router-marketplace--expanded .router-marketplace__results { flex: 1 1 0; min-height: 0; }
.router-marketplace__cta { display: inline-flex; align-items: center; gap: 10px; }
.router-marketplace__nav { display: grid; place-items: center; width: 26px; height: 26px; border: 1px solid rgba(255,255,255,.14); border-radius: 50%; color: rgba(255,255,255,.72); font-size: 18px; line-height: 1; }
.router-marketplace__nav:hover { background: rgba(255,255,255,.09); }
@media (max-width: 1023px) {
.router-marketplace__cta { width: 100%; justify-content: center; }
/* The dashboard's fixed mobile Back bar must not cover card actions. */
.router-marketplace--expanded > .glass-card { padding-bottom: 64px; }
}
.router-marketplace__results { display: flex; align-items: stretch; gap: 16px; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scroll-snap-type: x mandatory; scrollbar-width: none; mask-image: linear-gradient(to right, #000 0%, #000 calc(100% - 52px), transparent 100%); }
.router-marketplace__results::-webkit-scrollbar { display: none; }
.router-marketplace__card { display: flex; flex: 0 0 88%; scroll-snap-align: start; flex-direction: column; gap: 12px; min-width: 0; min-height: 0; padding: 12px; border: 1px solid rgba(255,255,255,.09); border-radius: 11px; background: rgba(255,255,255,.035); }
.router-marketplace__details { display: flex; flex: 0 0 auto; min-width: 0; flex-direction: column; }
.router-marketplace__footer { margin-top: auto; padding-top: 8px; }
.router-marketplace__price { display: block; color: rgba(180,244,235,.9); font-size: 18px; }
.router-marketplace__actions { display: flex; gap: 8px; margin-top: 8px; }
.router-marketplace__action { flex: 1; padding: 8px 12px; border: 1px solid rgba(255,255,255,.14); border-radius: 8px; color: rgba(255,255,255,.55); font-size: 12px; }
.router-marketplace__action--buy { border-color: rgba(118,230,191,.35); color: rgba(180,244,235,.9); }
@media (min-width: 768px) {
.router-marketplace__card { flex-basis: min(360px, 44%); }
}
.router-marketplace__image { width: 100%; height: 0; min-height: 0; flex: 1 1 0; object-fit: contain; border-radius: 8px; background: linear-gradient(135deg, rgba(112,214,205,.18), rgba(255,255,255,.04)); }
.router-marketplace__image--empty { display: grid; place-items: center; color: rgba(121,205,215,.6); font-size: 34px; }
</style>