- 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.
171 lines
No EOL
5 KiB
Vue
171 lines
No EOL
5 KiB
Vue
<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> |