2026-10-09 14:31:24 +01:00
< 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 )
2026-10-09 17:30:04 +01:00
const topicTags = computed (() => props . kind === 'phone' ? [ 'pixel' ] : [ 'openwrt' , 'router' , 'gl-inet' , 'beryl' , 'flint' ])
2026-10-09 14:31:24 +01:00
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'
2026-10-09 17:30:04 +01:00
? /\bpixel\b/i . test ( text )
2026-10-09 14:31:24 +01:00
: /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 : 16 px ; }
. 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 : 14 px ; }
. router - marketplace -- expanded . router - marketplace__results { flex : 1 1 0 ; min - height : 0 ; }
. router - marketplace__cta { display : inline - flex ; align - items : center ; gap : 10 px ; }
. router - marketplace__nav { display : grid ; place - items : center ; width : 26 px ; height : 26 px ; border : 1 px solid rgba ( 255 , 255 , 255 , .14 ); border - radius : 50 % ; color : rgba ( 255 , 255 , 255 , .72 ); font - size : 18 px ; line - height : 1 ; }
. router - marketplace__nav : hover { background : rgba ( 255 , 255 , 255 , .09 ); }
@ media ( max - width : 1023 px ) {
. 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 : 64 px ; }
}
. router - marketplace__results { display : flex ; align - items : stretch ; gap : 16 px ; 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 % - 52 px ), 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 : 12 px ; min - width : 0 ; min - height : 0 ; padding : 12 px ; border : 1 px solid rgba ( 255 , 255 , 255 , .09 ); border - radius : 11 px ; 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 : 8 px ; }
. router - marketplace__price { display : block ; color : rgba ( 180 , 244 , 235 , .9 ); font - size : 18 px ; }
. router - marketplace__actions { display : flex ; gap : 8 px ; margin - top : 8 px ; }
. router - marketplace__action { flex : 1 ; padding : 8 px 12 px ; border : 1 px solid rgba ( 255 , 255 , 255 , .14 ); border - radius : 8 px ; color : rgba ( 255 , 255 , 255 , .55 ); font - size : 12 px ; }
. router - marketplace__action -- buy { border - color : rgba ( 118 , 230 , 191 , .35 ); color : rgba ( 180 , 244 , 235 , .9 ); }
@ media ( min - width : 768 px ) {
. router - marketplace__card { flex - basis : min ( 360 px , 44 % ); }
}
. router - marketplace__image { width : 100 % ; height : 0 ; min - height : 0 ; flex : 1 1 0 ; object - fit : contain ; border - radius : 8 px ; background : linear - gradient ( 135 deg , 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 : 34 px ; }
</ style >