Add MarketProduct component and integrate into NostrFeed
- Introduced MarketProduct.vue to display market product details, including images, pricing, and availability status. - Enhanced NostrFeed.vue to render MarketProduct components for market events, allowing users to view and share products. - Implemented market data parsing in marketParser.ts to handle Nostr market events, ensuring structured data representation. These changes improve the marketplace functionality within the feed, enhancing user engagement with market products.
This commit is contained in:
parent
f05398fa9e
commit
77ed913e1b
3 changed files with 382 additions and 34 deletions
171
src/modules/nostr-feed/components/MarketProduct.vue
Normal file
171
src/modules/nostr-feed/components/MarketProduct.vue
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
<template>
|
||||||
|
<Card class="overflow-hidden">
|
||||||
|
<!-- Product Image -->
|
||||||
|
<div v-if="product.images && product.images.length > 0" class="relative">
|
||||||
|
<img
|
||||||
|
:src="product.images[0]"
|
||||||
|
:alt="product.name"
|
||||||
|
class="w-full h-48 object-cover"
|
||||||
|
@error="onImageError"
|
||||||
|
/>
|
||||||
|
<!-- Price Badge -->
|
||||||
|
<div class="absolute top-2 right-2 bg-black/80 text-white px-3 py-1 rounded-full text-sm font-semibold">
|
||||||
|
{{ formatPrice(product.price, product.currency) }}
|
||||||
|
</div>
|
||||||
|
<!-- Availability Badge -->
|
||||||
|
<div v-if="!product.active" class="absolute top-2 left-2 bg-red-500 text-white px-2 py-1 rounded text-xs font-medium">
|
||||||
|
Unavailable
|
||||||
|
</div>
|
||||||
|
<div v-else-if="product.quantity <= 0" class="absolute top-2 left-2 bg-orange-500 text-white px-2 py-1 rounded text-xs font-medium">
|
||||||
|
Out of Stock
|
||||||
|
</div>
|
||||||
|
<div v-else-if="product.quantity <= 5" class="absolute top-2 left-2 bg-yellow-500 text-white px-2 py-1 rounded text-xs font-medium">
|
||||||
|
Limited Stock
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardContent class="p-4">
|
||||||
|
<!-- Product Header -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<h3 class="font-semibold text-lg line-clamp-1">{{ product.name }}</h3>
|
||||||
|
<Badge variant="secondary" class="text-xs">
|
||||||
|
<ShoppingBag class="w-3 h-3 mr-1" />
|
||||||
|
Market
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<p class="text-muted-foreground text-sm line-clamp-2">
|
||||||
|
{{ product.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Product Details -->
|
||||||
|
<div class="mt-4 space-y-3">
|
||||||
|
<!-- Price and Quantity -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="text-2xl font-bold">
|
||||||
|
{{ formatPrice(product.price, product.currency) }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
{{ product.quantity > 0 ? `${product.quantity} available` : 'Out of stock' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Shipping Info -->
|
||||||
|
<div v-if="product.shipping && product.shipping.length > 0" class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Truck class="w-4 h-4" />
|
||||||
|
<span v-if="hasFreelShipping">Free shipping available</span>
|
||||||
|
<span v-else>Shipping from {{ formatPrice(minShippingCost, product.currency) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stall ID (for debugging/reference) -->
|
||||||
|
<div class="text-xs text-muted-foreground">
|
||||||
|
Stall: {{ product.stall_id }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="mt-4 flex gap-2">
|
||||||
|
<Button
|
||||||
|
class="flex-1"
|
||||||
|
:disabled="!product.active || product.quantity <= 0"
|
||||||
|
@click="onViewProduct"
|
||||||
|
>
|
||||||
|
<ShoppingCart class="w-4 h-4 mr-2" />
|
||||||
|
{{ product.active && product.quantity > 0 ? 'View Product' : 'Unavailable' }}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="icon" @click="onShareProduct">
|
||||||
|
<Share2 class="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { ShoppingBag, ShoppingCart, Truck, Share2 } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
export interface MarketProductData {
|
||||||
|
id: string
|
||||||
|
stall_id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
images?: string[]
|
||||||
|
currency: string
|
||||||
|
price: number
|
||||||
|
quantity: number
|
||||||
|
active: boolean
|
||||||
|
shipping?: Array<{
|
||||||
|
id: string
|
||||||
|
cost: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
product: MarketProductData
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'view-product', productId: string): void
|
||||||
|
(e: 'share-product', productId: string): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
// Computed properties
|
||||||
|
const hasFreelShipping = computed(() => {
|
||||||
|
return props.product.shipping?.some(shipping => shipping.cost === 0) || false
|
||||||
|
})
|
||||||
|
|
||||||
|
const minShippingCost = computed(() => {
|
||||||
|
if (!props.product.shipping?.length) return 0
|
||||||
|
return Math.min(...props.product.shipping.map(s => s.cost))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const formatPrice = (price: number, currency: string) => {
|
||||||
|
if (currency.toLowerCase() === 'sat' || currency.toLowerCase() === 'sats') {
|
||||||
|
return `${price.toLocaleString()} sats`
|
||||||
|
}
|
||||||
|
if (currency.toLowerCase() === 'btc') {
|
||||||
|
return `₿${(price / 100000000).toFixed(8)}`
|
||||||
|
}
|
||||||
|
return `${price} ${currency.toUpperCase()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const onImageError = (event: Event) => {
|
||||||
|
const img = event.target as HTMLImageElement
|
||||||
|
img.style.display = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
const onViewProduct = () => {
|
||||||
|
emit('view-product', props.product.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onShareProduct = () => {
|
||||||
|
emit('share-product', props.product.id)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.line-clamp-1 {
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-clamp-2 {
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -9,6 +9,8 @@ import { Megaphone, RefreshCw, AlertCircle } from 'lucide-vue-next'
|
||||||
import { useFeed } from '../composables/useFeed'
|
import { useFeed } from '../composables/useFeed'
|
||||||
import appConfig from '@/app.config'
|
import appConfig from '@/app.config'
|
||||||
import type { ContentFilter } from '../services/FeedService'
|
import type { ContentFilter } from '../services/FeedService'
|
||||||
|
import MarketProduct from './MarketProduct.vue'
|
||||||
|
import { parseMarketProduct, isMarketEvent, getMarketEventType } from '../utils/marketParser'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
relays?: string[]
|
relays?: string[]
|
||||||
|
|
@ -62,6 +64,34 @@ const feedDescription = computed(() => {
|
||||||
function isAdminPost(pubkey: string): boolean {
|
function isAdminPost(pubkey: string): boolean {
|
||||||
return adminPubkeys.includes(pubkey)
|
return adminPubkeys.includes(pubkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get market product data for market events
|
||||||
|
function getMarketProductData(note: any) {
|
||||||
|
if (note.kind === 30018) {
|
||||||
|
// Create a mock NostrEvent from our FeedPost
|
||||||
|
const mockEvent = {
|
||||||
|
id: note.id,
|
||||||
|
pubkey: note.pubkey,
|
||||||
|
content: note.content,
|
||||||
|
created_at: note.created_at,
|
||||||
|
kind: note.kind,
|
||||||
|
tags: note.tags
|
||||||
|
}
|
||||||
|
return parseMarketProduct(mockEvent)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle market product actions
|
||||||
|
function onViewProduct(productId: string) {
|
||||||
|
console.log('View product:', productId)
|
||||||
|
// TODO: Navigate to product detail page or open modal
|
||||||
|
}
|
||||||
|
|
||||||
|
function onShareProduct(productId: string) {
|
||||||
|
console.log('Share product:', productId)
|
||||||
|
// TODO: Implement sharing functionality
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -129,17 +159,13 @@ function isAdminPost(pubkey: string): boolean {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Notes List -->
|
<!-- Posts List -->
|
||||||
<ScrollArea v-else class="h-[400px] pr-4">
|
<ScrollArea v-else class="h-[400px] pr-4">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div
|
<div v-for="note in notes" :key="note.id">
|
||||||
v-for="note in notes"
|
<!-- Market Product Component (kind 30018) -->
|
||||||
:key="note.id"
|
<template v-if="note.kind === 30018">
|
||||||
class="p-4 border rounded-lg hover:bg-accent/50 transition-colors"
|
<div class="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
>
|
|
||||||
<!-- Note Header -->
|
|
||||||
<div class="flex items-start justify-between mb-2">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Badge
|
<Badge
|
||||||
v-if="isAdminPost(note.pubkey)"
|
v-if="isAdminPost(note.pubkey)"
|
||||||
variant="default"
|
variant="default"
|
||||||
|
|
@ -147,34 +173,84 @@ function isAdminPost(pubkey: string): boolean {
|
||||||
>
|
>
|
||||||
Admin
|
Admin
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<span>{{ formatDistanceToNow(note.created_at * 1000, { addSuffix: true }) }}</span>
|
||||||
v-if="note.isReply"
|
|
||||||
variant="secondary"
|
|
||||||
class="text-xs"
|
|
||||||
>
|
|
||||||
Reply
|
|
||||||
</Badge>
|
|
||||||
<span class="text-xs text-muted-foreground">
|
|
||||||
{{ formatDistanceToNow(note.created_at * 1000, { addSuffix: true }) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<MarketProduct
|
||||||
|
v-if="getMarketProductData(note)"
|
||||||
|
:product="getMarketProductData(note)!"
|
||||||
|
@view-product="onViewProduct"
|
||||||
|
@share-product="onShareProduct"
|
||||||
|
/>
|
||||||
|
<!-- Fallback for invalid market data -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="p-4 border rounded-lg hover:bg-accent/50 transition-colors border-destructive/20"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<Badge variant="destructive" class="text-xs">
|
||||||
|
Invalid Market Product
|
||||||
|
</Badge>
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
{{ formatDistanceToNow(note.created_at * 1000, { addSuffix: true }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
Unable to parse market product data
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Note Content -->
|
<!-- Regular Text Posts and Other Event Types -->
|
||||||
<div class="text-sm leading-relaxed whitespace-pre-wrap">
|
<div
|
||||||
{{ note.content }}
|
v-else
|
||||||
</div>
|
class="p-4 border rounded-lg hover:bg-accent/50 transition-colors"
|
||||||
|
>
|
||||||
|
<!-- Note Header -->
|
||||||
|
<div class="flex items-start justify-between mb-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
v-if="isAdminPost(note.pubkey)"
|
||||||
|
variant="default"
|
||||||
|
class="text-xs"
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-if="note.isReply"
|
||||||
|
variant="secondary"
|
||||||
|
class="text-xs"
|
||||||
|
>
|
||||||
|
Reply
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
v-if="isMarketEvent({ kind: note.kind })"
|
||||||
|
variant="outline"
|
||||||
|
class="text-xs"
|
||||||
|
>
|
||||||
|
{{ getMarketEventType({ kind: note.kind }) }}
|
||||||
|
</Badge>
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
{{ formatDistanceToNow(note.created_at * 1000, { addSuffix: true }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Note Footer -->
|
<!-- Note Content -->
|
||||||
<div v-if="note.mentions.length > 0" class="mt-2 pt-2 border-t">
|
<div class="text-sm leading-relaxed whitespace-pre-wrap">
|
||||||
<div class="flex items-center gap-1 text-xs text-muted-foreground">
|
{{ note.content }}
|
||||||
<span>Mentions:</span>
|
</div>
|
||||||
<span v-for="mention in note.mentions.slice(0, 3)" :key="mention" class="font-mono">
|
|
||||||
{{ mention.slice(0, 8) }}...
|
<!-- Note Footer -->
|
||||||
</span>
|
<div v-if="note.mentions.length > 0" class="mt-2 pt-2 border-t">
|
||||||
<span v-if="note.mentions.length > 3" class="text-muted-foreground">
|
<div class="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
+{{ note.mentions.length - 3 }} more
|
<span>Mentions:</span>
|
||||||
</span>
|
<span v-for="mention in note.mentions.slice(0, 3)" :key="mention" class="font-mono">
|
||||||
|
{{ mention.slice(0, 8) }}...
|
||||||
|
</span>
|
||||||
|
<span v-if="note.mentions.length > 3" class="text-muted-foreground">
|
||||||
|
+{{ note.mentions.length - 3 }} more
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
101
src/modules/nostr-feed/utils/marketParser.ts
Normal file
101
src/modules/nostr-feed/utils/marketParser.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import type { Event as NostrEvent } from 'nostr-tools'
|
||||||
|
import type { MarketProductData } from '../components/MarketProduct.vue'
|
||||||
|
|
||||||
|
export interface MarketStallData {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
currency: string
|
||||||
|
shipping: Array<{
|
||||||
|
id: string
|
||||||
|
cost: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a Nostr market product event (kind 30018) into structured data
|
||||||
|
*/
|
||||||
|
export function parseMarketProduct(event: NostrEvent): MarketProductData | null {
|
||||||
|
try {
|
||||||
|
if (event.kind !== 30018) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the JSON content
|
||||||
|
const productData = JSON.parse(event.content)
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!productData.id || !productData.name || typeof productData.price !== 'number') {
|
||||||
|
console.warn('Invalid market product data:', productData)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: productData.id,
|
||||||
|
stall_id: productData.stall_id || '',
|
||||||
|
name: productData.name,
|
||||||
|
description: productData.description || '',
|
||||||
|
images: Array.isArray(productData.images) ? productData.images : [],
|
||||||
|
currency: productData.currency || 'sat',
|
||||||
|
price: productData.price,
|
||||||
|
quantity: productData.quantity || 0,
|
||||||
|
active: productData.active !== false, // Default to true if not specified
|
||||||
|
shipping: Array.isArray(productData.shipping) ? productData.shipping : []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse market product event:', error, event)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a Nostr market stall event (kind 30017) into structured data
|
||||||
|
*/
|
||||||
|
export function parseMarketStall(event: NostrEvent): MarketStallData | null {
|
||||||
|
try {
|
||||||
|
if (event.kind !== 30017) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const stallData = JSON.parse(event.content)
|
||||||
|
|
||||||
|
if (!stallData.id || !stallData.name) {
|
||||||
|
console.warn('Invalid market stall data:', stallData)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: stallData.id,
|
||||||
|
name: stallData.name,
|
||||||
|
description: stallData.description || '',
|
||||||
|
currency: stallData.currency || 'sat',
|
||||||
|
shipping: Array.isArray(stallData.shipping) ? stallData.shipping : []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse market stall event:', error, event)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an event is a market-related event
|
||||||
|
*/
|
||||||
|
export function isMarketEvent(event: NostrEvent): boolean {
|
||||||
|
return event.kind === 30017 || event.kind === 30018 || event.kind === 30019
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a human-readable type for a market event
|
||||||
|
*/
|
||||||
|
export function getMarketEventType(event: NostrEvent): string {
|
||||||
|
switch (event.kind) {
|
||||||
|
case 30017:
|
||||||
|
return 'Market Stall'
|
||||||
|
case 30018:
|
||||||
|
return 'Product'
|
||||||
|
case 30019:
|
||||||
|
return 'Market Activity'
|
||||||
|
default:
|
||||||
|
return 'Unknown Market Event'
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue