Enhance market module with new chat and events features
- Introduce chat module with components, services, and composables for real-time messaging. - Implement events module with API service, components, and ticket purchasing functionality. - Update app configuration to include new modules and their respective settings. - Refactor existing components to integrate with the new chat and events features. - Enhance market store and services to support new functionalities and improve order management. - Update routing to accommodate new views for chat and events, ensuring seamless navigation.
This commit is contained in:
parent
519a9003d4
commit
e40ac91417
46 changed files with 6305 additions and 3264 deletions
250
src/modules/market/components/CartItem.vue
Normal file
250
src/modules/market/components/CartItem.vue
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
<template>
|
||||
<div class="p-3 bg-muted rounded-lg">
|
||||
<!-- Desktop Layout (horizontal) -->
|
||||
<div class="hidden md:flex items-center space-x-4">
|
||||
<!-- Product Image -->
|
||||
<div class="flex-shrink-0">
|
||||
<img
|
||||
:src="item.product.images?.[0] || '/placeholder-product.png'"
|
||||
:alt="item.product.name"
|
||||
class="w-16 h-16 object-cover rounded-md"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Product Details -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h4 class="text-sm font-medium text-foreground truncate">
|
||||
{{ item.product.name }}
|
||||
</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ item.product.stallName }}
|
||||
</p>
|
||||
<div class="flex items-center space-x-2 mt-1">
|
||||
<Badge
|
||||
v-for="category in item.product.categories?.slice(0, 2)"
|
||||
:key="category"
|
||||
variant="secondary"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ category }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Price -->
|
||||
<div class="text-right ml-4">
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
{{ formatPrice(item.product.price, item.product.currency) }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
each
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quantity Controls -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button
|
||||
@click="decreaseQuantity"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="item.quantity <= 1"
|
||||
class="w-8 h-8 p-0"
|
||||
>
|
||||
<Minus class="w-3 h-3" />
|
||||
</Button>
|
||||
|
||||
<div class="w-12 text-center">
|
||||
<span class="text-sm font-medium text-foreground">
|
||||
{{ item.quantity }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@click="increaseQuantity"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="item.quantity >= item.product.quantity"
|
||||
class="w-8 h-8 p-0"
|
||||
>
|
||||
<Plus class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Total Price -->
|
||||
<div class="text-right min-w-[80px]">
|
||||
<p class="text-sm font-semibold text-green-600">
|
||||
{{ formatPrice(item.product.price * item.quantity, item.product.currency) }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
total
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Remove Button -->
|
||||
<Button
|
||||
@click="removeItem"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-red-500 hover:text-red-700 hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Layout (stacked) -->
|
||||
<div class="md:hidden space-y-3">
|
||||
<!-- Product Image and Details Row -->
|
||||
<div class="flex items-start space-x-3">
|
||||
<!-- Product Image -->
|
||||
<div class="flex-shrink-0">
|
||||
<img
|
||||
:src="item.product.images?.[0] || '/placeholder-product.png'"
|
||||
:alt="item.product.name"
|
||||
class="w-16 h-16 object-cover rounded-md"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Product Details -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-medium text-foreground">
|
||||
{{ item.product.name }}
|
||||
</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ item.product.stallName }}
|
||||
</p>
|
||||
<div class="flex items-center space-x-2 mt-1">
|
||||
<Badge
|
||||
v-for="category in item.product.categories?.slice(0, 2)"
|
||||
:key="category"
|
||||
variant="secondary"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ category }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remove Button -->
|
||||
<Button
|
||||
@click="removeItem"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-red-500 hover:text-red-700 hover:bg-red-500/10 flex-shrink-0"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Price and Quantity Row -->
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Price per item -->
|
||||
<div class="text-left">
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
{{ formatPrice(item.product.price, item.product.currency) }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
each
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Quantity Controls -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button
|
||||
@click="decreaseQuantity"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="item.quantity <= 1"
|
||||
class="w-8 h-8 p-0"
|
||||
>
|
||||
<Minus class="w-3 h-3" />
|
||||
</Button>
|
||||
|
||||
<div class="w-12 text-center">
|
||||
<span class="text-sm font-medium text-foreground">
|
||||
{{ item.quantity }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@click="increaseQuantity"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="item.quantity >= item.product.quantity"
|
||||
class="w-8 h-8 p-0"
|
||||
>
|
||||
<Plus class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Total Price -->
|
||||
<div class="text-right">
|
||||
<p class="text-sm font-semibold text-green-600">
|
||||
{{ formatPrice(item.product.price * item.quantity, item.product.currency) }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
total
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Minus, Plus, Trash2 } from 'lucide-vue-next'
|
||||
import type { CartItem as CartItemType } from '../types/market'
|
||||
|
||||
interface Props {
|
||||
item: CartItemType
|
||||
stallId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update-quantity': [stallId: string, productId: string, quantity: number]
|
||||
'remove-item': [stallId: string, productId: string]
|
||||
}>()
|
||||
|
||||
// Methods
|
||||
const increaseQuantity = () => {
|
||||
const newQuantity = props.item.quantity + 1
|
||||
if (newQuantity <= props.item.product.quantity) {
|
||||
emit('update-quantity', props.stallId, props.item.product.id, newQuantity)
|
||||
}
|
||||
}
|
||||
|
||||
const decreaseQuantity = () => {
|
||||
const newQuantity = props.item.quantity - 1
|
||||
if (newQuantity > 0) {
|
||||
emit('update-quantity', props.stallId, props.item.product.id, newQuantity)
|
||||
}
|
||||
}
|
||||
|
||||
const removeItem = () => {
|
||||
emit('remove-item', props.stallId, props.item.product.id)
|
||||
}
|
||||
|
||||
const handleImageError = (event: Event) => {
|
||||
const target = event.target as HTMLImageElement
|
||||
target.src = '/placeholder-product.png'
|
||||
}
|
||||
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
if (currency === 'sats' || currency === 'sat') {
|
||||
return `${price.toLocaleString()} sats`
|
||||
}
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase()
|
||||
}).format(price)
|
||||
}
|
||||
</script>
|
||||
231
src/modules/market/components/CartSummary.vue
Normal file
231
src/modules/market/components/CartSummary.vue
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
<template>
|
||||
<div class="bg-card border rounded-lg p-6 shadow-sm">
|
||||
<!-- Cart Summary Header -->
|
||||
<div class="border-b border-border pb-4 mb-4">
|
||||
<h3 class="text-lg font-semibold text-foreground">Order Summary</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ itemCount }} item{{ itemCount !== 1 ? 's' : '' }} in cart
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Cart Items Summary -->
|
||||
<div class="space-y-3 mb-4">
|
||||
<div
|
||||
v-for="item in cartItems"
|
||||
:key="item.product.id"
|
||||
class="flex items-center justify-between text-sm"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<img
|
||||
:src="item.product.images?.[0] || '/placeholder-product.png'"
|
||||
:alt="item.product.name"
|
||||
class="w-8 h-8 object-cover rounded"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-medium text-foreground">{{ item.product.name }}</p>
|
||||
<p class="text-muted-foreground">Qty: {{ item.quantity }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="font-medium text-foreground">
|
||||
{{ formatPrice(item.product.price * item.quantity, item.product.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shipping Zone Selection -->
|
||||
<div class="border-t border-border pt-4 mb-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label class="text-sm font-medium text-foreground">Shipping Zone</label>
|
||||
<Button
|
||||
v-if="availableShippingZones.length > 1"
|
||||
@click="showShippingSelector = !showShippingSelector"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
{{ selectedShippingZone ? 'Change' : 'Select' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedShippingZone" class="flex items-center justify-between p-3 bg-muted rounded">
|
||||
<div>
|
||||
<p class="font-medium text-foreground">{{ selectedShippingZone.name }}</p>
|
||||
<p v-if="selectedShippingZone.description" class="text-sm text-muted-foreground">
|
||||
{{ selectedShippingZone.description }}
|
||||
</p>
|
||||
<p v-if="selectedShippingZone.estimatedDays" class="text-xs text-muted-foreground">
|
||||
Estimated: {{ selectedShippingZone.estimatedDays }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="font-semibold text-foreground">
|
||||
{{ formatPrice(selectedShippingZone.cost, selectedShippingZone.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Shipping Zone Selector -->
|
||||
<div v-if="showShippingSelector && availableShippingZones.length > 1" class="mt-2">
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="zone in availableShippingZones"
|
||||
:key="zone.id"
|
||||
@click="selectShippingZone(zone)"
|
||||
class="flex items-center justify-between p-3 border rounded cursor-pointer hover:bg-muted/50"
|
||||
:class="{ 'border-primary bg-primary/10': selectedShippingZone?.id === zone.id }"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium text-foreground">{{ zone.name }}</p>
|
||||
<p v-if="zone.description" class="text-sm text-muted-foreground">
|
||||
{{ zone.description }}
|
||||
</p>
|
||||
<p v-if="zone.estimatedDays" class="text-xs text-muted-foreground">
|
||||
Estimated: {{ zone.estimatedDays }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="font-semibold text-foreground">
|
||||
{{ formatPrice(zone.cost, zone.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!selectedShippingZone" class="text-sm text-red-600">
|
||||
Please select a shipping zone
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Price Breakdown -->
|
||||
<div class="border-t border-border pt-4 mb-6">
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Subtotal</span>
|
||||
<span class="text-foreground">{{ formatPrice(subtotal, currency) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedShippingZone" class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Shipping</span>
|
||||
<span class="text-foreground">
|
||||
{{ formatPrice(selectedShippingZone.cost, selectedShippingZone.currency) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-border pt-2 flex justify-between font-semibold text-lg">
|
||||
<span class="text-foreground">Total</span>
|
||||
<span class="text-green-600">{{ formatPrice(total, currency) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Checkout Actions -->
|
||||
<div class="space-y-3">
|
||||
<Button
|
||||
@click="continueShopping"
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
>
|
||||
Back to Cart
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Security Note -->
|
||||
<div class="mt-4 p-3 bg-muted/50 rounded-lg">
|
||||
<div class="flex items-start space-x-2">
|
||||
<Shield class="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<p class="font-medium text-foreground">Secure Checkout</p>
|
||||
<p>Your order will be encrypted and sent securely to the merchant using Nostr.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Shield } from 'lucide-vue-next'
|
||||
import type { ShippingZone } from '@/stores/market'
|
||||
|
||||
interface Props {
|
||||
stallId: string
|
||||
cartItems: readonly {
|
||||
readonly product: {
|
||||
readonly id: string
|
||||
readonly stall_id: string
|
||||
readonly stallName: string
|
||||
readonly name: string
|
||||
readonly description?: string
|
||||
readonly price: number
|
||||
readonly currency: string
|
||||
readonly quantity: number
|
||||
readonly images?: readonly string[]
|
||||
readonly categories?: readonly string[]
|
||||
readonly createdAt: number
|
||||
readonly updatedAt: number
|
||||
}
|
||||
readonly quantity: number
|
||||
readonly stallId: string
|
||||
}[]
|
||||
subtotal: number
|
||||
currency: string
|
||||
availableShippingZones: readonly {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly cost: number
|
||||
readonly currency: string
|
||||
readonly description?: string
|
||||
readonly estimatedDays?: string
|
||||
readonly requiresPhysicalShipping?: boolean
|
||||
}[]
|
||||
selectedShippingZone?: {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly cost: number
|
||||
readonly currency: string
|
||||
readonly description?: string
|
||||
readonly estimatedDays?: string
|
||||
readonly requiresPhysicalShipping?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'shipping-zone-selected': [shippingZone: ShippingZone]
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
// const marketStore = useMarketStore()
|
||||
|
||||
// Local state
|
||||
const showShippingSelector = ref(false)
|
||||
|
||||
// Computed properties
|
||||
const itemCount = computed(() =>
|
||||
props.cartItems.reduce((total, item) => total + item.quantity, 0)
|
||||
)
|
||||
|
||||
const total = computed(() => {
|
||||
const shippingCost = props.selectedShippingZone?.cost || 0
|
||||
return props.subtotal + shippingCost
|
||||
})
|
||||
|
||||
// Methods
|
||||
const selectShippingZone = (shippingZone: ShippingZone) => {
|
||||
emit('shipping-zone-selected', shippingZone)
|
||||
showShippingSelector.value = false
|
||||
}
|
||||
|
||||
const continueShopping = () => {
|
||||
router.push('/cart')
|
||||
}
|
||||
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
if (currency === 'sats' || currency === 'sat') {
|
||||
return `${price.toLocaleString()} sats`
|
||||
}
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase()
|
||||
}).format(price)
|
||||
}
|
||||
</script>
|
||||
311
src/modules/market/components/DashboardOverview.vue
Normal file
311
src/modules/market/components/DashboardOverview.vue
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<!-- Total Orders -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Total Orders</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ orderStats.total }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center">
|
||||
<Package class="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center text-sm text-muted-foreground">
|
||||
<span>{{ orderStats.pending }} pending</span>
|
||||
<span class="mx-2">•</span>
|
||||
<span>{{ orderStats.paid }} paid</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pending Payments -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Pending Payments</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ orderStats.pendingPayments }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-yellow-500/10 rounded-lg flex items-center justify-center">
|
||||
<DollarSign class="w-6 h-6 text-yellow-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center text-sm text-muted-foreground">
|
||||
<span>Total: {{ formatPrice(orderStats.pendingAmount, 'sat') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Sales (Merchant) -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Recent Sales</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ orderStats.recentSales }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-green-500/10 rounded-lg flex items-center justify-center">
|
||||
<Store class="w-6 h-6 text-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center text-sm text-muted-foreground">
|
||||
<span>Last 7 days</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Market Activity -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Market Activity</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ orderStats.active }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-purple-500/10 rounded-lg flex items-center justify-center">
|
||||
<BarChart3 class="w-6 h-6 text-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center text-sm text-muted-foreground">
|
||||
<span>{{ orderStats.connected ? 'Connected' : 'Disconnected' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Customer Actions -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<ShoppingCart class="w-5 h-5 text-primary" />
|
||||
Customer Actions
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<Button
|
||||
@click="navigateToMarket"
|
||||
variant="default"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<Store class="w-4 h-4 mr-2" />
|
||||
Browse Market
|
||||
</Button>
|
||||
<Button
|
||||
@click="navigateToOrders"
|
||||
variant="outline"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<Package class="w-4 h-4 mr-2" />
|
||||
View All Orders
|
||||
</Button>
|
||||
<Button
|
||||
@click="navigateToCart"
|
||||
variant="outline"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<ShoppingCart class="w-4 h-4 mr-2" />
|
||||
Shopping Cart
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Merchant Actions -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<Store class="w-5 h-5 text-green-500" />
|
||||
Merchant Actions
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<Button
|
||||
@click="navigateToStore"
|
||||
variant="default"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<Store class="w-4 h-4 mr-2" />
|
||||
Manage Store
|
||||
</Button>
|
||||
<Button
|
||||
@click="navigateToProducts"
|
||||
variant="outline"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<Package class="w-4 h-4 mr-2" />
|
||||
Manage Products
|
||||
</Button>
|
||||
<Button
|
||||
@click="navigateToOrders"
|
||||
variant="outline"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<Package class="w-4 h-4 mr-2" />
|
||||
View Orders
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Recent Activity</h3>
|
||||
<div v-if="recentActivity.length > 0" class="space-y-3">
|
||||
<div
|
||||
v-for="activity in recentActivity"
|
||||
:key="activity.id"
|
||||
class="flex items-center justify-between p-3 bg-muted rounded-lg"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<component :is="getActivityIcon(activity.type)" class="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">{{ activity.title }}</p>
|
||||
<p class="text-xs text-muted-foreground">{{ formatDate(activity.timestamp) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge :variant="getActivityVariant(activity.type)">
|
||||
{{ activity.status }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center py-8 text-muted-foreground">
|
||||
<Package class="w-12 h-12 mx-auto mb-3 text-muted-foreground/50" />
|
||||
<p>No recent activity</p>
|
||||
<p class="text-sm">Start shopping or selling to see activity here</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Market Status -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Market Status</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
:class="orderEvents.isSubscribed ? 'bg-green-500' : 'bg-yellow-500'"
|
||||
></div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
Order Events: {{ orderEvents.isSubscribed ? 'Connected' : 'Connecting...' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
:class="isConnected ? 'bg-green-500' : 'bg-red-500'"
|
||||
></div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
Market: {{ isConnected ? 'Connected' : 'Disconnected' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
:class="auth.isAuthenticated ? 'bg-green-500' : 'bg-red-500'"
|
||||
></div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
Auth: {{ auth.isAuthenticated ? 'Authenticated' : 'Not Authenticated' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useMarket } from '@/composables/useMarket'
|
||||
import { useOrderEvents } from '@/composables/useOrderEvents'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Package,
|
||||
Store,
|
||||
ShoppingCart,
|
||||
DollarSign,
|
||||
BarChart3,
|
||||
Clock
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
const marketStore = useMarketStore()
|
||||
const auth = useAuth()
|
||||
const { isConnected } = useMarket()
|
||||
const orderEvents = useOrderEvents()
|
||||
|
||||
// Computed properties
|
||||
const orderStats = computed(() => {
|
||||
const orders = Object.values(marketStore.orders)
|
||||
const now = Date.now() / 1000
|
||||
const sevenDaysAgo = now - (7 * 24 * 60 * 60)
|
||||
|
||||
return {
|
||||
total: orders.length,
|
||||
pending: orders.filter(o => o.status === 'pending').length,
|
||||
paid: orders.filter(o => o.status === 'paid').length,
|
||||
pendingPayments: orders.filter(o => o.paymentStatus === 'pending').length,
|
||||
pendingAmount: orders
|
||||
.filter(o => o.paymentStatus === 'pending')
|
||||
.reduce((sum, o) => sum + o.total, 0),
|
||||
recentSales: orders.filter(o =>
|
||||
o.status === 'paid' && o.createdAt > sevenDaysAgo
|
||||
).length,
|
||||
active: orders.filter(o =>
|
||||
['pending', 'paid', 'processing'].includes(o.status)
|
||||
).length,
|
||||
connected: false
|
||||
}
|
||||
})
|
||||
|
||||
const recentActivity = computed(() => {
|
||||
const orders = Object.values(marketStore.orders)
|
||||
const now = Date.now() / 1000
|
||||
const recentOrders = orders
|
||||
.filter(o => o.updatedAt > now - (24 * 60 * 60)) // Last 24 hours
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
.slice(0, 5)
|
||||
|
||||
return recentOrders.map(order => ({
|
||||
id: order.id,
|
||||
type: 'order',
|
||||
title: `Order ${order.id.slice(-8)} - ${order.status}`,
|
||||
status: order.status,
|
||||
timestamp: order.updatedAt
|
||||
}))
|
||||
})
|
||||
|
||||
// Methods
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
return `${price} ${currency}`
|
||||
}
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
return new Date(timestamp * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
const getActivityIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'order': return Package
|
||||
default: return Clock
|
||||
}
|
||||
}
|
||||
|
||||
const getActivityVariant = (type: string) => {
|
||||
switch (type) {
|
||||
case 'order': return 'secondary'
|
||||
default: return 'outline'
|
||||
}
|
||||
}
|
||||
|
||||
const navigateToMarket = () => router.push('/market')
|
||||
const navigateToOrders = () => router.push('/market-dashboard?tab=orders')
|
||||
const navigateToCart = () => router.push('/cart')
|
||||
const navigateToStore = () => router.push('/market-dashboard?tab=store')
|
||||
const navigateToProducts = () => router.push('/market-dashboard?tab=store')
|
||||
</script>
|
||||
|
||||
331
src/modules/market/components/MarketSettings.vue
Normal file
331
src/modules/market/components/MarketSettings.vue
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-foreground">Market Settings</h2>
|
||||
<p class="text-muted-foreground mt-1">Configure your store and market preferences</p>
|
||||
</div>
|
||||
|
||||
<!-- Settings Tabs -->
|
||||
<div class="border-b border-border">
|
||||
<nav class="flex space-x-8">
|
||||
<button
|
||||
v-for="tab in settingsTabs"
|
||||
:key="tab.id"
|
||||
@click="activeSettingsTab = tab.id"
|
||||
:class="[
|
||||
'py-2 px-1 border-b-2 font-medium text-sm transition-colors',
|
||||
activeSettingsTab === tab.id
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground'
|
||||
]"
|
||||
>
|
||||
{{ tab.name }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Settings Content -->
|
||||
<div class="min-h-[500px]">
|
||||
<!-- Store Settings Tab -->
|
||||
<div v-if="activeSettingsTab === 'store'" class="space-y-6">
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Store Information</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Store Name</label>
|
||||
<Input v-model="storeSettings.name" placeholder="Enter store name" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Store Description</label>
|
||||
<Input v-model="storeSettings.description" placeholder="Enter store description" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Contact Email</label>
|
||||
<Input v-model="storeSettings.contactEmail" type="email" placeholder="Enter contact email" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Store Category</label>
|
||||
<select v-model="storeSettings.category" class="w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground">
|
||||
<option value="">Select category</option>
|
||||
<option value="electronics">Electronics</option>
|
||||
<option value="clothing">Clothing</option>
|
||||
<option value="books">Books</option>
|
||||
<option value="food">Food & Beverages</option>
|
||||
<option value="services">Services</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<Button @click="saveStoreSettings" variant="default">
|
||||
Save Store Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Settings Tab -->
|
||||
<div v-else-if="activeSettingsTab === 'payment'" class="space-y-6">
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Payment Configuration</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Default Currency</label>
|
||||
<select v-model="paymentSettings.defaultCurrency" class="w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground">
|
||||
<option value="sat">Satoshi (sats)</option>
|
||||
<option value="btc">Bitcoin (BTC)</option>
|
||||
<option value="usd">US Dollar (USD)</option>
|
||||
<option value="eur">Euro (EUR)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Invoice Expiry (minutes)</label>
|
||||
<Input v-model="paymentSettings.invoiceExpiry" type="number" min="5" max="1440" placeholder="60" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Auto-generate Invoices</label>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
v-model="paymentSettings.autoGenerateInvoices"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 text-primary focus:ring-primary border-input rounded"
|
||||
/>
|
||||
<label class="ml-2 text-sm text-foreground">
|
||||
Automatically generate Lightning invoices for new orders
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<Button @click="savePaymentSettings" variant="default">
|
||||
Save Payment Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nostr Settings Tab -->
|
||||
<div v-else-if="activeSettingsTab === 'nostr'" class="space-y-6">
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Nostr Network Configuration</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Relay Connections</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="relay in nostrSettings.relays" :key="relay" class="flex items-center gap-2">
|
||||
<Input :value="relay" readonly class="flex-1" />
|
||||
<Button @click="removeRelay(relay)" variant="outline" size="sm">
|
||||
<X class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Input v-model="newRelay" placeholder="wss://relay.example.com" class="flex-1" />
|
||||
<Button @click="addRelay" variant="outline">
|
||||
Add Relay
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Nostr Public Key</label>
|
||||
<Input :value="nostrSettings.pubkey" readonly class="font-mono text-sm" />
|
||||
<p class="text-xs text-muted-foreground mt-1">Your Nostr public key for receiving orders</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-2">Connection Status</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
:class="orderEvents.isSubscribed ? 'bg-green-500' : 'bg-yellow-500'"
|
||||
></div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ orderEvents.isSubscribed ? 'Connected to Nostr network' : 'Connecting to Nostr network...' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<Button @click="saveNostrSettings" variant="default">
|
||||
Save Nostr Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shipping Settings Tab -->
|
||||
<div v-else-if="activeSettingsTab === 'shipping'" class="space-y-6">
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Shipping Zones</h3>
|
||||
<div class="space-y-4">
|
||||
<div v-for="zone in shippingSettings.zones" :key="zone.id" class="border border-border rounded-lg p-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Zone Name</label>
|
||||
<Input v-model="zone.name" placeholder="Zone name" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Cost</label>
|
||||
<Input v-model="zone.cost" type="number" min="0" step="0.01" placeholder="0.00" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-foreground mb-1">Currency</label>
|
||||
<select v-model="zone.currency" class="w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground">
|
||||
<option value="sat">Satoshi (sats)</option>
|
||||
<option value="btc">Bitcoin (BTC)</option>
|
||||
<option value="usd">US Dollar (USD)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex justify-end">
|
||||
<Button @click="removeShippingZone(zone.id)" variant="outline" size="sm">
|
||||
Remove Zone
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button @click="addShippingZone" variant="outline">
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Add Shipping Zone
|
||||
</Button>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<Button @click="saveShippingSettings" variant="default">
|
||||
Save Shipping Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useOrderEvents } from '@/composables/useOrderEvents'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Plus, X } from 'lucide-vue-next'
|
||||
|
||||
// const marketStore = useMarketStore()
|
||||
const orderEvents = useOrderEvents()
|
||||
|
||||
// Local state
|
||||
const activeSettingsTab = ref('store')
|
||||
const newRelay = ref('')
|
||||
|
||||
// Settings data
|
||||
const storeSettings = ref({
|
||||
name: 'My Store',
|
||||
description: 'A great place to shop',
|
||||
contactEmail: 'store@example.com',
|
||||
category: 'other'
|
||||
})
|
||||
|
||||
const paymentSettings = ref({
|
||||
defaultCurrency: 'sat',
|
||||
invoiceExpiry: 60,
|
||||
autoGenerateInvoices: true
|
||||
})
|
||||
|
||||
const nostrSettings = ref({
|
||||
relays: [
|
||||
'wss://relay.damus.io',
|
||||
'wss://relay.snort.social',
|
||||
'wss://nostr-pub.wellorder.net'
|
||||
],
|
||||
pubkey: 'npub1...' // TODO: Get from auth
|
||||
})
|
||||
|
||||
const shippingSettings = ref({
|
||||
zones: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Local',
|
||||
cost: 0,
|
||||
currency: 'sat',
|
||||
estimatedDays: '1-2 days'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Domestic',
|
||||
cost: 1000,
|
||||
currency: 'sat',
|
||||
estimatedDays: '3-5 days'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'International',
|
||||
cost: 5000,
|
||||
currency: 'sat',
|
||||
estimatedDays: '7-14 days'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Settings tabs
|
||||
const settingsTabs = [
|
||||
{ id: 'store', name: 'Store Settings' },
|
||||
{ id: 'payment', name: 'Payment Settings' },
|
||||
{ id: 'nostr', name: 'Nostr Network' },
|
||||
{ id: 'shipping', name: 'Shipping Zones' }
|
||||
]
|
||||
|
||||
// Methods
|
||||
const saveStoreSettings = () => {
|
||||
// TODO: Save store settings
|
||||
console.log('Saving store settings:', storeSettings.value)
|
||||
}
|
||||
|
||||
const savePaymentSettings = () => {
|
||||
// TODO: Save payment settings
|
||||
console.log('Saving payment settings:', paymentSettings.value)
|
||||
}
|
||||
|
||||
const saveNostrSettings = () => {
|
||||
// TODO: Save Nostr settings
|
||||
console.log('Saving Nostr settings:', nostrSettings.value)
|
||||
}
|
||||
|
||||
const saveShippingSettings = () => {
|
||||
// TODO: Save shipping settings
|
||||
console.log('Saving shipping settings:', shippingSettings.value)
|
||||
}
|
||||
|
||||
const addRelay = () => {
|
||||
if (newRelay.value && !nostrSettings.value.relays.includes(newRelay.value)) {
|
||||
nostrSettings.value.relays.push(newRelay.value)
|
||||
newRelay.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const removeRelay = (relay: string) => {
|
||||
const index = nostrSettings.value.relays.indexOf(relay)
|
||||
if (index > -1) {
|
||||
nostrSettings.value.relays.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const addShippingZone = () => {
|
||||
const newZone = {
|
||||
id: Date.now().toString(),
|
||||
name: 'New Zone',
|
||||
cost: 0,
|
||||
currency: 'sat',
|
||||
estimatedDays: '3-5 days'
|
||||
}
|
||||
shippingSettings.value.zones.push(newZone)
|
||||
}
|
||||
|
||||
const removeShippingZone = (zoneId: string) => {
|
||||
const index = shippingSettings.value.zones.findIndex(z => z.id === zoneId)
|
||||
if (index > -1) {
|
||||
shippingSettings.value.zones.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
console.log('Market Settings component loaded')
|
||||
})
|
||||
</script>
|
||||
|
||||
550
src/modules/market/components/MerchantStore.vue
Normal file
550
src/modules/market/components/MerchantStore.vue
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-foreground">My Store</h2>
|
||||
<p class="text-muted-foreground mt-1">Manage incoming orders and your products</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<Button @click="navigateToMarket" variant="outline">
|
||||
<Store class="w-4 h-4 mr-2" />
|
||||
Browse Market
|
||||
</Button>
|
||||
<Button @click="addProduct" variant="default">
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Add Product
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Store Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<!-- Incoming Orders -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Incoming Orders</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ storeStats.incomingOrders }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center">
|
||||
<Package class="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center text-sm text-muted-foreground">
|
||||
<span>{{ storeStats.pendingOrders }} pending</span>
|
||||
<span class="mx-2">•</span>
|
||||
<span>{{ storeStats.paidOrders }} paid</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Sales -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Total Sales</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ formatPrice(storeStats.totalSales, 'sat') }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-green-500/10 rounded-lg flex items-center justify-center">
|
||||
<DollarSign class="w-6 h-6 text-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center text-sm text-muted-foreground">
|
||||
<span>Last 30 days</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Products -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Products</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ storeStats.totalProducts }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-purple-500/10 rounded-lg flex items-center justify-center">
|
||||
<Store class="w-6 h-6 text-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center text-sm text-muted-foreground">
|
||||
<span>{{ storeStats.activeProducts }} active</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Customer Satisfaction -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-muted-foreground">Satisfaction</p>
|
||||
<p class="text-2xl font-bold text-foreground">{{ storeStats.satisfaction }}%</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-yellow-500/10 rounded-lg flex items-center justify-center">
|
||||
<Star class="w-6 h-6 text-yellow-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center text-sm text-muted-foreground">
|
||||
<span>{{ storeStats.totalReviews }} reviews</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Incoming Orders Section -->
|
||||
<div class="bg-card rounded-lg border shadow-sm">
|
||||
<div class="p-6 border-b border-border">
|
||||
<h3 class="text-lg font-semibold text-foreground">Incoming Orders</h3>
|
||||
<p class="text-sm text-muted-foreground mt-1">Orders waiting for your attention</p>
|
||||
</div>
|
||||
|
||||
<div v-if="incomingOrders.length > 0" class="divide-y divide-border">
|
||||
<div
|
||||
v-for="order in incomingOrders"
|
||||
:key="order.id"
|
||||
class="p-6 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<!-- Order Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<Package class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-foreground">Order #{{ order.id.slice(-8) }}</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ formatDate(order.createdAt) }} • {{ formatPrice(order.total, order.currency) }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<Badge :variant="getStatusVariant(order.status)">
|
||||
{{ formatStatus(order.status) }}
|
||||
</Badge>
|
||||
<Badge v-if="order.paymentStatus === 'pending'" variant="secondary">
|
||||
Payment Pending
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Wallet Indicator -->
|
||||
<div v-if="order.status === 'pending' && !order.lightningInvoice" class="text-xs text-muted-foreground mr-2">
|
||||
<span>Wallet: {{ getFirstWalletName() }}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="order.status === 'pending' && !order.lightningInvoice"
|
||||
@click="generateInvoice(order.id)"
|
||||
:disabled="isGeneratingInvoice === order.id"
|
||||
size="sm"
|
||||
variant="default"
|
||||
>
|
||||
<div v-if="isGeneratingInvoice === order.id" class="flex items-center space-x-2">
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
<span>Generating...</span>
|
||||
</div>
|
||||
<div v-else class="flex items-center space-x-2">
|
||||
<Zap class="w-4 h-4" />
|
||||
<span>Generate Invoice</span>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="order.lightningInvoice"
|
||||
@click="viewOrderDetails(order.id)"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Eye class="w-4 h-4 mr-2" />
|
||||
View Details
|
||||
</Button>
|
||||
<Button
|
||||
@click="processOrder(order.id)"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Check class="w-4 h-4 mr-2" />
|
||||
Process
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order Items -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<h5 class="font-medium text-foreground mb-2">Items</h5>
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
v-for="item in order.items"
|
||||
:key="item.productId"
|
||||
class="text-sm text-muted-foreground"
|
||||
>
|
||||
{{ item.productName }} × {{ item.quantity }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h5 class="font-medium text-foreground mb-2">Customer Info</h5>
|
||||
<div class="space-y-1 text-sm text-muted-foreground">
|
||||
<p v-if="order.contactInfo.email">
|
||||
<span class="font-medium">Email:</span> {{ order.contactInfo.email }}
|
||||
</p>
|
||||
<p v-if="order.contactInfo.message">
|
||||
<span class="font-medium">Message:</span> {{ order.contactInfo.message }}
|
||||
</p>
|
||||
<p v-if="order.contactInfo.address">
|
||||
<span class="font-medium">Address:</span> {{ order.contactInfo.address }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Status -->
|
||||
<div v-if="order.lightningInvoice" class="p-4 bg-green-500/10 border border-green-200 rounded-lg">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<CheckCircle class="w-5 h-5 text-green-600" />
|
||||
<span class="text-sm font-medium text-green-900">Lightning Invoice Generated</span>
|
||||
</div>
|
||||
<div class="text-sm text-green-700">
|
||||
Amount: {{ formatPrice(order.total, order.currency) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="p-4 bg-yellow-500/10 border border-yellow-200 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<AlertCircle class="w-5 h-5 text-yellow-600" />
|
||||
<span class="text-sm font-medium text-yellow-900">Invoice Required</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="p-6 text-center text-muted-foreground">
|
||||
<Package class="w-12 h-12 mx-auto mb-3 text-muted-foreground/50" />
|
||||
<p>No incoming orders</p>
|
||||
<p class="text-sm">Orders from customers will appear here</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Order Management -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Order Management</h3>
|
||||
<div class="space-y-3">
|
||||
<Button
|
||||
@click="viewAllOrders"
|
||||
variant="default"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<Package class="w-4 h-4 mr-2" />
|
||||
View All Orders
|
||||
</Button>
|
||||
<Button
|
||||
@click="generateBulkInvoices"
|
||||
variant="outline"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<Zap class="w-4 h-4 mr-2" />
|
||||
Generate Bulk Invoices
|
||||
</Button>
|
||||
<Button
|
||||
@click="exportOrders"
|
||||
variant="outline"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<Download class="w-4 h-4 mr-2" />
|
||||
Export Orders
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Store Management -->
|
||||
<div class="bg-card p-6 rounded-lg border shadow-sm">
|
||||
<h3 class="text-lg font-semibold text-foreground mb-4">Store Management</h3>
|
||||
<div class="space-y-3">
|
||||
<Button
|
||||
@click="manageProducts"
|
||||
variant="default"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<Store class="w-4 h-4 mr-2" />
|
||||
Manage Products
|
||||
</Button>
|
||||
<Button
|
||||
@click="storeSettings"
|
||||
variant="outline"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<Settings class="w-4 h-4 mr-2" />
|
||||
Store Settings
|
||||
</Button>
|
||||
<Button
|
||||
@click="analytics"
|
||||
variant="outline"
|
||||
class="w-full justify-start"
|
||||
>
|
||||
<BarChart3 class="w-4 h-4 mr-2" />
|
||||
View Analytics
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Package,
|
||||
Store,
|
||||
DollarSign,
|
||||
Star,
|
||||
Plus,
|
||||
Zap,
|
||||
Eye,
|
||||
Check,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Download,
|
||||
Settings,
|
||||
BarChart3
|
||||
} from 'lucide-vue-next'
|
||||
import type { OrderStatus } from '@/stores/market'
|
||||
import { nostrOrders } from '@/composables/useNostrOrders'
|
||||
import { auth } from '@/composables/useAuth'
|
||||
|
||||
const router = useRouter()
|
||||
const marketStore = useMarketStore()
|
||||
const nostrOrdersComposable = nostrOrders
|
||||
|
||||
// Local state
|
||||
const isGeneratingInvoice = ref<string | null>(null)
|
||||
|
||||
// Computed properties
|
||||
const incomingOrders = computed(() => {
|
||||
// For now, show all orders as "incoming" since we don't have merchant filtering yet
|
||||
// In a real implementation, this would filter orders where the current user is the seller
|
||||
return Object.values(marketStore.orders)
|
||||
.filter(order => order.status === 'pending')
|
||||
.sort((a, b) => b.createdAt - a.createdAt)
|
||||
})
|
||||
|
||||
const storeStats = computed(() => {
|
||||
const orders = Object.values(marketStore.orders)
|
||||
const now = Date.now() / 1000
|
||||
const thirtyDaysAgo = now - (30 * 24 * 60 * 60)
|
||||
|
||||
return {
|
||||
incomingOrders: orders.filter(o => o.status === 'pending').length,
|
||||
pendingOrders: orders.filter(o => o.status === 'pending').length,
|
||||
paidOrders: orders.filter(o => o.status === 'paid').length,
|
||||
totalSales: orders
|
||||
.filter(o => o.status === 'paid' && o.createdAt > thirtyDaysAgo)
|
||||
.reduce((sum, o) => sum + o.total, 0),
|
||||
totalProducts: 0, // TODO: Implement product management
|
||||
activeProducts: 0, // TODO: Implement product management
|
||||
satisfaction: 95, // TODO: Implement review system
|
||||
totalReviews: 0 // TODO: Implement review system
|
||||
}
|
||||
})
|
||||
|
||||
// Methods
|
||||
const formatDate = (timestamp: number) => {
|
||||
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const formatStatus = (status: OrderStatus) => {
|
||||
const statusMap: Record<OrderStatus, string> = {
|
||||
pending: 'Pending',
|
||||
paid: 'Paid',
|
||||
processing: 'Processing',
|
||||
shipped: 'Shipped',
|
||||
delivered: 'Delivered',
|
||||
cancelled: 'Cancelled'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
const getStatusVariant = (status: OrderStatus) => {
|
||||
const variantMap: Record<OrderStatus, 'default' | 'secondary' | 'outline' | 'destructive'> = {
|
||||
pending: 'outline',
|
||||
paid: 'secondary',
|
||||
processing: 'secondary',
|
||||
shipped: 'default',
|
||||
delivered: 'default',
|
||||
cancelled: 'destructive'
|
||||
}
|
||||
return variantMap[status] || 'outline'
|
||||
}
|
||||
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
return marketStore.formatPrice(price, currency)
|
||||
}
|
||||
|
||||
const generateInvoice = async (orderId: string) => {
|
||||
console.log('Generating invoice for order:', orderId)
|
||||
isGeneratingInvoice.value = orderId
|
||||
|
||||
try {
|
||||
// Get the order from the store
|
||||
const order = marketStore.orders[orderId]
|
||||
if (!order) {
|
||||
throw new Error('Order not found')
|
||||
}
|
||||
|
||||
// Temporary fix: If buyerPubkey is missing, try to get it from auth
|
||||
if (!order.buyerPubkey && auth.currentUser?.value?.pubkey) {
|
||||
console.log('Fixing missing buyerPubkey for existing order')
|
||||
marketStore.updateOrder(order.id, { buyerPubkey: auth.currentUser.value.pubkey })
|
||||
}
|
||||
|
||||
// Temporary fix: If sellerPubkey is missing, use current user's pubkey
|
||||
if (!order.sellerPubkey && auth.currentUser?.value?.pubkey) {
|
||||
console.log('Fixing missing sellerPubkey for existing order')
|
||||
marketStore.updateOrder(order.id, { sellerPubkey: auth.currentUser.value.pubkey })
|
||||
}
|
||||
|
||||
// Get the updated order
|
||||
const updatedOrder = marketStore.orders[orderId]
|
||||
|
||||
console.log('Order details for invoice generation:', {
|
||||
orderId: updatedOrder.id,
|
||||
orderFields: Object.keys(updatedOrder),
|
||||
buyerPubkey: updatedOrder.buyerPubkey,
|
||||
sellerPubkey: updatedOrder.sellerPubkey,
|
||||
status: updatedOrder.status,
|
||||
total: updatedOrder.total
|
||||
})
|
||||
|
||||
// Get the user's wallet list
|
||||
const userWallets = auth.currentUser?.value?.wallets || []
|
||||
console.log('Available wallets:', userWallets)
|
||||
|
||||
if (userWallets.length === 0) {
|
||||
throw new Error('No wallet available to generate invoice. Please ensure you have at least one wallet configured.')
|
||||
}
|
||||
|
||||
// Use the first available wallet for invoice generation
|
||||
const walletId = userWallets[0].id
|
||||
const walletName = userWallets[0].name
|
||||
const adminKey = userWallets[0].adminkey
|
||||
console.log('Using wallet for invoice generation:', { walletId, walletName, balance: userWallets[0].balance_msat })
|
||||
|
||||
const invoice = await marketStore.createLightningInvoice(orderId, adminKey)
|
||||
|
||||
if (invoice) {
|
||||
console.log('Lightning invoice created:', invoice)
|
||||
|
||||
// Send the invoice to the customer via Nostr
|
||||
await sendInvoiceToCustomer(updatedOrder, invoice)
|
||||
|
||||
console.log('Invoice sent to customer successfully')
|
||||
|
||||
// Show success message (you could add a toast notification here)
|
||||
alert(`Invoice generated successfully using wallet: ${walletName}`)
|
||||
} else {
|
||||
throw new Error('Failed to create Lightning invoice')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to generate invoice:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
|
||||
// Show error message to user
|
||||
alert(`Failed to generate invoice: ${errorMessage}`)
|
||||
} finally {
|
||||
isGeneratingInvoice.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const sendInvoiceToCustomer = async (order: any, invoice: any) => {
|
||||
try {
|
||||
console.log('Sending invoice to customer for order:', {
|
||||
orderId: order.id,
|
||||
buyerPubkey: order.buyerPubkey,
|
||||
sellerPubkey: order.sellerPubkey,
|
||||
invoiceFields: Object.keys(invoice)
|
||||
})
|
||||
|
||||
// Check if we have the buyer's public key
|
||||
if (!order.buyerPubkey) {
|
||||
console.error('Missing buyerPubkey in order:', order)
|
||||
throw new Error('Cannot send invoice: buyer public key not found')
|
||||
}
|
||||
|
||||
// Update the order with the invoice details
|
||||
const updatedOrder = {
|
||||
...order,
|
||||
lightningInvoice: invoice,
|
||||
paymentHash: invoice.payment_hash,
|
||||
paymentStatus: 'pending',
|
||||
paymentRequest: invoice.bolt11, // Use bolt11 field from LNBits response
|
||||
updatedAt: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
// Update the order in the store
|
||||
marketStore.updateOrder(order.id, updatedOrder)
|
||||
|
||||
// Send the updated order to the customer via Nostr
|
||||
// This will include the invoice information
|
||||
await nostrOrdersComposable.publishOrderEvent(updatedOrder, order.buyerPubkey)
|
||||
|
||||
console.log('Updated order with invoice sent via Nostr to customer:', order.buyerPubkey)
|
||||
} catch (error) {
|
||||
console.error('Failed to send invoice to customer:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const viewOrderDetails = (orderId: string) => {
|
||||
// TODO: Navigate to detailed order view
|
||||
console.log('Viewing order details:', orderId)
|
||||
}
|
||||
|
||||
const processOrder = (orderId: string) => {
|
||||
// TODO: Implement order processing
|
||||
console.log('Processing order:', orderId)
|
||||
}
|
||||
|
||||
const addProduct = () => {
|
||||
// TODO: Navigate to add product form
|
||||
console.log('Adding new product')
|
||||
}
|
||||
|
||||
const navigateToMarket = () => router.push('/market')
|
||||
const viewAllOrders = () => router.push('/market-dashboard?tab=orders')
|
||||
const generateBulkInvoices = () => console.log('Generate bulk invoices')
|
||||
const exportOrders = () => console.log('Export orders')
|
||||
const manageProducts = () => console.log('Manage products')
|
||||
const storeSettings = () => router.push('/market-dashboard?tab=settings')
|
||||
const analytics = () => console.log('View analytics')
|
||||
|
||||
const getFirstWalletName = () => {
|
||||
const userWallets = auth.currentUser?.value?.wallets || []
|
||||
if (userWallets.length > 0) {
|
||||
return userWallets[0].name
|
||||
}
|
||||
return 'N/A'
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
console.log('Merchant Store component loaded')
|
||||
})
|
||||
</script>
|
||||
|
||||
500
src/modules/market/components/OrderHistory.vue
Normal file
500
src/modules/market/components/OrderHistory.vue
Normal file
|
|
@ -0,0 +1,500 @@
|
|||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-foreground">My Orders</h2>
|
||||
<p class="text-muted-foreground mt-1">Track all your market orders and payments</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Order Events Status -->
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div class="w-2 h-2 rounded-full" :class="orderEvents.isSubscribed ? 'bg-green-500' : 'bg-yellow-500'"></div>
|
||||
<span>{{ orderEvents.isSubscribed ? 'Live updates' : 'Connecting...' }}</span>
|
||||
</div>
|
||||
<Button @click="navigateToMarket" variant="outline">
|
||||
<Store class="w-4 h-4 mr-2" />
|
||||
Browse Market
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters and Stats -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||
<!-- Order Stats -->
|
||||
<div class="flex gap-4 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground">Total:</span>
|
||||
<Badge variant="secondary">{{ totalOrders }}</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground">Pending:</span>
|
||||
<Badge variant="outline" class="text-amber-600">{{ pendingOrders }}</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground">Paid:</span>
|
||||
<Badge variant="outline" class="text-green-600">{{ paidOrders }}</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground">Payment Due:</span>
|
||||
<Badge variant="outline" class="text-red-600">{{ pendingPayments }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Controls -->
|
||||
<div class="flex gap-2">
|
||||
<select v-model="statusFilter"
|
||||
class="px-3 py-2 border border-input rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="paid">Paid</option>
|
||||
<option value="processing">Processing</option>
|
||||
<option value="shipped">Shipped</option>
|
||||
<option value="delivered">Delivered</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
<select v-model="sortBy"
|
||||
class="px-3 py-2 border border-input rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground">
|
||||
<option value="createdAt">Date Created</option>
|
||||
<option value="total">Order Total</option>
|
||||
<option value="status">Status</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Orders List -->
|
||||
<div v-if="filteredOrders.length > 0" class="space-y-4">
|
||||
<div v-for="order in sortedOrders" :key="order.id"
|
||||
class="bg-card border rounded-lg p-6 hover:shadow-md transition-shadow">
|
||||
<!-- Order Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<Package class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-foreground">Order #{{ order.id.slice(-8) }}</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ formatDate(order.createdAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<Badge :variant="getStatusVariant(order.status)" :class="getStatusColor(order.status)">
|
||||
{{ formatStatus(order.status) }}
|
||||
</Badge>
|
||||
<div class="text-right">
|
||||
<p class="text-lg font-semibold text-foreground">
|
||||
{{ formatPrice(order.total, order.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order Items -->
|
||||
<div class="mb-4">
|
||||
<h4 class="font-medium text-foreground mb-2">Items</h4>
|
||||
<div class="space-y-1">
|
||||
<div v-for="item in order.items.slice(0, 3)" :key="item.productId" class="text-sm text-muted-foreground">
|
||||
{{ item.productName }} × {{ item.quantity }}
|
||||
</div>
|
||||
<div v-if="order.items.length > 3" class="text-sm text-muted-foreground">
|
||||
+{{ order.items.length - 3 }} more items
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Section -->
|
||||
<div v-if="order.lightningInvoice" class="mb-4 p-4 bg-muted/50 border border-border rounded-lg">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Zap class="w-4 h-4 text-yellow-500" />
|
||||
<span class="font-medium text-foreground">Payment Required</span>
|
||||
</div>
|
||||
<Badge :variant="order.paymentStatus === 'paid' ? 'default' : 'secondary'" class="text-xs"
|
||||
:class="order.paymentStatus === 'paid' ? 'text-green-600' : 'text-amber-600'">
|
||||
{{ order.paymentStatus === 'paid' ? 'Paid' : 'Pending' }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Payment Details -->
|
||||
<div class="space-y-3">
|
||||
<!-- Payment Request -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-muted-foreground mb-1">
|
||||
Payment Request
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input :value="order.lightningInvoice?.bolt11 || ''" readonly disabled
|
||||
class="flex-1 font-mono text-xs bg-muted border border-input rounded-md px-3 py-1 text-foreground" />
|
||||
<Button @click="copyPaymentRequest(order.lightningInvoice?.bolt11 || '')" variant="outline" size="sm">
|
||||
<Copy class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Actions -->
|
||||
<div class="flex gap-2">
|
||||
<Button @click="openLightningWallet(order.lightningInvoice?.bolt11 || '')" variant="default" size="sm"
|
||||
class="flex-1" :disabled="!order.lightningInvoice?.bolt11">
|
||||
<Zap class="w-3 h-3 mr-1" />
|
||||
Pay with Lightning
|
||||
</Button>
|
||||
<Button @click="toggleQRCode(order.id)" variant="outline" size="sm">
|
||||
<QrCode class="w-3 h-3" />
|
||||
{{ order.showQRCode ? 'Hide QR' : 'Show QR' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code -->
|
||||
<div v-if="order.showQRCode" class="flex justify-center">
|
||||
<div class="w-48 h-48">
|
||||
<div v-if="order.qrCodeDataUrl && !order.qrCodeError" class="w-full h-full">
|
||||
<img :src="order.qrCodeDataUrl"
|
||||
:alt="`QR Code for ${formatPrice(order.total, order.currency)} payment`"
|
||||
class="w-full h-full border border-border rounded-lg" />
|
||||
</div>
|
||||
<div v-else-if="order.qrCodeLoading"
|
||||
class="w-full h-full bg-muted rounded-lg flex items-center justify-center">
|
||||
<div class="text-center text-muted-foreground">
|
||||
<div class="text-4xl mb-2 animate-pulse">⚡</div>
|
||||
<div class="text-sm">Generating QR...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full h-full bg-muted rounded-lg flex items-center justify-center">
|
||||
<div class="text-center text-muted-foreground">
|
||||
<div class="text-4xl mb-2">⚡</div>
|
||||
<div class="text-sm">No QR</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Waiting for Invoice -->
|
||||
<div v-else-if="order.status === 'pending'" class="mb-4 p-4 bg-muted/50 border border-border rounded-lg">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<div class="w-2 h-2 bg-amber-500 rounded-full animate-pulse"></div>
|
||||
<span class="font-medium text-foreground">Waiting for Payment Invoice</span>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
The merchant will send you a Lightning invoice via Nostr once they process your order
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-2 pt-4 border-t border-border">
|
||||
<Button v-if="order.status === 'pending'" variant="outline" size="sm" @click="cancelOrder(order.id)">
|
||||
Cancel Order
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" @click="copyOrderId(order.id)">
|
||||
Copy Order ID
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Debug Information (Development Only) -->
|
||||
<div v-if="isDevelopment" class="mt-8 p-4 bg-gray-100 rounded-lg">
|
||||
<h4 class="font-medium mb-2">Debug Information</h4>
|
||||
<div class="text-sm space-y-1">
|
||||
<div>Total Orders in Store: {{ Object.keys(marketStore.orders).length }}</div>
|
||||
<div>Filtered Orders: {{ filteredOrders.length }}</div>
|
||||
<div>Order Events Subscribed: {{ orderEvents.isSubscribed ? 'Yes' : 'No' }}</div>
|
||||
<div>Relay Hub Connected: {{ relayHub.isConnected ? 'Yes' : 'No' }}</div>
|
||||
<div>Auth Status: {{ auth.isAuthenticated ? 'Authenticated' : 'Not Authenticated' }}</div>
|
||||
<div>Current User: {{ auth.currentUser?.value?.pubkey ? 'Yes' : 'No' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-12">
|
||||
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Package class="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-foreground mb-2">No orders yet</h3>
|
||||
<p class="text-muted-foreground mb-6">
|
||||
Start shopping in the market to see your order history here
|
||||
</p>
|
||||
<Button @click="navigateToMarket" variant="default">
|
||||
Browse Market
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { useOrderEvents } from '@/composables/useOrderEvents'
|
||||
import { relayHubComposable } from '@/composables/useRelayHub'
|
||||
import { auth } from '@/composables/useAuth'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Package, Store, Zap, Copy, QrCode } from 'lucide-vue-next'
|
||||
import { toast } from 'vue-sonner'
|
||||
import type { OrderStatus } from '@/stores/market'
|
||||
|
||||
const router = useRouter()
|
||||
const marketStore = useMarketStore()
|
||||
const relayHub = relayHubComposable
|
||||
const orderEvents = useOrderEvents()
|
||||
|
||||
// Local state
|
||||
const statusFilter = ref('')
|
||||
const sortBy = ref('createdAt')
|
||||
|
||||
// Computed properties
|
||||
const allOrders = computed(() => Object.values(marketStore.orders))
|
||||
|
||||
const filteredOrders = computed(() => {
|
||||
if (!statusFilter.value) return allOrders.value
|
||||
return allOrders.value.filter(order => order.status === statusFilter.value)
|
||||
})
|
||||
|
||||
const sortedOrders = computed(() => {
|
||||
const orders = [...filteredOrders.value]
|
||||
|
||||
switch (sortBy.value) {
|
||||
case 'total':
|
||||
return orders.sort((a, b) => b.total - a.total)
|
||||
case 'status':
|
||||
return orders.sort((a, b) => a.status.localeCompare(b.status))
|
||||
case 'createdAt':
|
||||
default:
|
||||
return orders.sort((a, b) => b.createdAt - a.createdAt)
|
||||
}
|
||||
})
|
||||
|
||||
const totalOrders = computed(() => allOrders.value.length)
|
||||
const pendingOrders = computed(() => allOrders.value.filter(o => o.status === 'pending').length)
|
||||
const paidOrders = computed(() => allOrders.value.filter(o => o.status === 'paid').length)
|
||||
const pendingPayments = computed(() => allOrders.value.filter(o => o.paymentStatus === 'pending').length)
|
||||
|
||||
const isDevelopment = computed(() => import.meta.env.DEV)
|
||||
|
||||
// Methods
|
||||
const formatDate = (timestamp: number) => {
|
||||
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const formatStatus = (status: OrderStatus) => {
|
||||
const statusMap: Record<OrderStatus, string> = {
|
||||
pending: 'Pending',
|
||||
paid: 'Paid',
|
||||
processing: 'Processing',
|
||||
shipped: 'Shipped',
|
||||
delivered: 'Delivered',
|
||||
cancelled: 'Cancelled'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
const getStatusVariant = (status: OrderStatus) => {
|
||||
const variantMap: Record<OrderStatus, 'default' | 'secondary' | 'outline' | 'destructive'> = {
|
||||
pending: 'outline',
|
||||
paid: 'secondary',
|
||||
processing: 'secondary',
|
||||
shipped: 'default',
|
||||
delivered: 'default',
|
||||
cancelled: 'destructive'
|
||||
}
|
||||
return variantMap[status] || 'outline'
|
||||
}
|
||||
|
||||
const getStatusColor = (status: OrderStatus) => {
|
||||
const colorMap: Record<OrderStatus, string> = {
|
||||
pending: 'text-amber-600',
|
||||
paid: 'text-green-600',
|
||||
processing: 'text-blue-600',
|
||||
shipped: 'text-blue-600',
|
||||
delivered: 'text-green-600',
|
||||
cancelled: 'text-red-600'
|
||||
}
|
||||
return colorMap[status] || 'text-muted-foreground'
|
||||
}
|
||||
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
return marketStore.formatPrice(price, currency)
|
||||
}
|
||||
|
||||
const cancelOrder = (orderId: string) => {
|
||||
// TODO: Implement order cancellation
|
||||
console.log('Cancelling order:', orderId)
|
||||
}
|
||||
|
||||
const copyOrderId = async (orderId: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(orderId)
|
||||
toast.success('Order ID copied to clipboard')
|
||||
console.log('Order ID copied to clipboard')
|
||||
} catch (err) {
|
||||
console.error('Failed to copy order ID:', err)
|
||||
toast.error('Failed to copy order ID')
|
||||
}
|
||||
}
|
||||
|
||||
const copyPaymentRequest = async (paymentRequest: string) => {
|
||||
console.log('Copying payment request:', {
|
||||
paymentRequest: paymentRequest?.substring(0, 50) + '...',
|
||||
hasValue: !!paymentRequest,
|
||||
length: paymentRequest?.length
|
||||
})
|
||||
|
||||
if (!paymentRequest) {
|
||||
toast.error('No payment request available', {
|
||||
description: 'Please wait for the merchant to send the payment request'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(paymentRequest)
|
||||
toast.success('Payment request copied to clipboard', {
|
||||
description: 'You can now paste it into your Lightning wallet'
|
||||
})
|
||||
console.log('Payment request copied to clipboard')
|
||||
} catch (err) {
|
||||
console.error('Failed to copy payment request:', err)
|
||||
toast.error('Failed to copy payment request', {
|
||||
description: 'Please try again or copy manually'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const openLightningWallet = (paymentRequest: string) => {
|
||||
// Try to open with common Lightning wallet protocols
|
||||
const protocols = [
|
||||
`lightning:${paymentRequest}`,
|
||||
`bitcoin:${paymentRequest}`,
|
||||
paymentRequest
|
||||
]
|
||||
|
||||
// Try each protocol
|
||||
for (const protocol of protocols) {
|
||||
try {
|
||||
window.open(protocol, '_blank')
|
||||
toast.success('Opening Lightning wallet', {
|
||||
description: 'If your wallet doesn\'t open, copy the payment request manually'
|
||||
})
|
||||
return
|
||||
} catch (err) {
|
||||
console.warn('Failed to open protocol:', protocol, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: copy to clipboard
|
||||
copyPaymentRequest(paymentRequest)
|
||||
}
|
||||
|
||||
const toggleQRCode = async (orderId: string) => {
|
||||
// Toggle QR code visibility for the order
|
||||
const order = marketStore.orders[orderId]
|
||||
if (order) {
|
||||
// If showing QR code and it doesn't exist yet, generate it
|
||||
if (!order.showQRCode && order.lightningInvoice?.bolt11 && !order.qrCodeDataUrl) {
|
||||
await generateQRCode(orderId, order.lightningInvoice.bolt11)
|
||||
}
|
||||
|
||||
marketStore.updateOrder(orderId, {
|
||||
showQRCode: !order.showQRCode
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const generateQRCode = async (orderId: string, bolt11: string) => {
|
||||
try {
|
||||
// Set loading state
|
||||
marketStore.updateOrder(orderId, {
|
||||
qrCodeLoading: true,
|
||||
qrCodeError: null
|
||||
})
|
||||
|
||||
// Import QRCode library dynamically
|
||||
const QRCode = await import('qrcode')
|
||||
|
||||
// Generate QR code
|
||||
const qrCodeDataUrl = await QRCode.toDataURL(bolt11, {
|
||||
width: 192,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF'
|
||||
}
|
||||
})
|
||||
|
||||
// Update order with QR code
|
||||
marketStore.updateOrder(orderId, {
|
||||
qrCodeDataUrl,
|
||||
qrCodeLoading: false,
|
||||
qrCodeError: null
|
||||
})
|
||||
|
||||
console.log('QR code generated for order:', orderId)
|
||||
} catch (error) {
|
||||
console.error('Failed to generate QR code:', error)
|
||||
marketStore.updateOrder(orderId, {
|
||||
qrCodeLoading: false,
|
||||
qrCodeError: error instanceof Error ? error.message : 'Failed to generate QR code'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const navigateToMarket = () => router.push('/market')
|
||||
|
||||
// Load orders on mount
|
||||
onMounted(() => {
|
||||
// Orders are already loaded in the market store
|
||||
console.log('Order History component loaded with', allOrders.value.length, 'orders')
|
||||
console.log('Market store orders:', marketStore.orders)
|
||||
|
||||
// Debug: Log order details for orders with payment requests
|
||||
allOrders.value.forEach(order => {
|
||||
if (order.paymentRequest) {
|
||||
console.log('Order with payment request:', {
|
||||
id: order.id,
|
||||
paymentRequest: order.paymentRequest.substring(0, 50) + '...',
|
||||
hasPaymentRequest: !!order.paymentRequest,
|
||||
status: order.status,
|
||||
paymentStatus: order.paymentStatus
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
console.log('Order events status:', orderEvents.isSubscribed.value)
|
||||
console.log('Relay hub connected:', relayHub.isConnected.value)
|
||||
console.log('Auth status:', auth.isAuthenticated)
|
||||
console.log('Current user:', auth.currentUser?.value?.pubkey)
|
||||
|
||||
// Start listening for order events if not already listening
|
||||
if (!orderEvents.isSubscribed.value) {
|
||||
console.log('Starting order events listener...')
|
||||
orderEvents.initialize()
|
||||
} else {
|
||||
console.log('Order events already listening')
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for authentication and relay hub readiness
|
||||
watch(
|
||||
[() => auth.isAuthenticated, () => relayHub.isConnected.value],
|
||||
([isAuth, isConnected]) => {
|
||||
if (isAuth && isConnected && !orderEvents.isSubscribed.value) {
|
||||
console.log('Auth and relay hub ready, starting order events listener...')
|
||||
orderEvents.initialize()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
334
src/modules/market/components/PaymentDisplay.vue
Normal file
334
src/modules/market/components/PaymentDisplay.vue
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
<template>
|
||||
<div class="bg-background border rounded-lg p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-foreground">Payment</h3>
|
||||
<Badge :variant="getPaymentStatusVariant(paymentStatus)">
|
||||
{{ formatPaymentStatus(paymentStatus) }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<!-- Invoice Information -->
|
||||
<div v-if="invoice" class="space-y-4">
|
||||
<!-- Amount and Status -->
|
||||
<div class="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">Amount</p>
|
||||
<p class="text-2xl font-bold text-foreground">
|
||||
{{ invoice.amount }} {{ currency }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-muted-foreground">Status</p>
|
||||
<p class="text-lg font-semibold" :class="getStatusColor(paymentStatus)">
|
||||
{{ formatPaymentStatus(paymentStatus) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lightning Invoice QR Code -->
|
||||
<div class="text-center">
|
||||
<div class="mb-4">
|
||||
<h4 class="font-medium text-foreground mb-2">Lightning Invoice</h4>
|
||||
<p class="text-sm text-muted-foreground">Scan with your Lightning wallet to pay</p>
|
||||
</div>
|
||||
|
||||
<!-- QR Code -->
|
||||
<div class="w-48 h-48 mx-auto mb-4">
|
||||
<div v-if="qrCodeDataUrl && !qrCodeError" class="w-full h-full">
|
||||
<img
|
||||
:src="qrCodeDataUrl"
|
||||
:alt="`QR Code for ${invoice.amount} ${currency} payment`"
|
||||
class="w-full h-full border border-border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="qrCodeLoading" class="w-full h-full bg-muted rounded-lg flex items-center justify-center">
|
||||
<div class="text-center text-muted-foreground">
|
||||
<div class="text-4xl mb-2 animate-pulse">⚡</div>
|
||||
<div class="text-sm">Generating QR...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="qrCodeError" class="w-full h-full bg-destructive/10 border border-destructive/20 rounded-lg flex items-center justify-center">
|
||||
<div class="text-center text-destructive">
|
||||
<div class="text-4xl mb-2">⚠️</div>
|
||||
<div class="text-sm">{{ qrCodeError }}</div>
|
||||
<Button
|
||||
@click="retryQRCode"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="mt-2"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full h-full bg-muted rounded-lg flex items-center justify-center">
|
||||
<div class="text-center text-muted-foreground">
|
||||
<div class="text-4xl mb-2">⚡</div>
|
||||
<div class="text-sm">No invoice</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code Actions -->
|
||||
<div v-if="qrCodeDataUrl && !qrCodeError" class="mb-4">
|
||||
<!-- Download button removed -->
|
||||
</div>
|
||||
|
||||
<!-- Payment Request -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-foreground mb-2">
|
||||
Payment Request
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
:value="invoice.bolt11"
|
||||
readonly
|
||||
class="flex-1 font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
@click="copyPaymentRequest"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Copy class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Copy to Wallet Button -->
|
||||
<Button
|
||||
@click="openInWallet"
|
||||
variant="default"
|
||||
class="w-full"
|
||||
>
|
||||
<Wallet class="w-4 h-4 mr-2" />
|
||||
Open in Lightning Wallet
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Payment Details -->
|
||||
<div class="border-t pt-4">
|
||||
<h4 class="font-medium text-foreground mb-3">Payment Details</h4>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Payment Hash:</span>
|
||||
<span class="font-mono text-foreground">{{ formatHash(invoice.payment_hash) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Created:</span>
|
||||
<span class="text-foreground">{{ formatDate(invoice.created_at) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Expires:</span>
|
||||
<span class="text-foreground">{{ formatDate(invoice.expiry) }}</span>
|
||||
</div>
|
||||
<div v-if="paidAt" class="flex justify-between">
|
||||
<span class="text-muted-foreground">Paid At:</span>
|
||||
<span class="text-foreground">{{ formatDate(paidAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Invoice State -->
|
||||
<div v-else class="text-center py-8">
|
||||
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Wallet class="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h4 class="text-lg font-medium text-foreground mb-2">No Payment Invoice</h4>
|
||||
<p class="text-muted-foreground mb-4">
|
||||
A Lightning invoice will be sent by the merchant once they process your order.
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
You'll receive the invoice via Nostr when it's ready.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Payment Instructions -->
|
||||
<div v-if="paymentStatus === 'pending'" class="mt-6 p-4 bg-muted/50 border border-border rounded-lg">
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="w-5 h-5 bg-muted rounded-full flex items-center justify-center mt-0.5">
|
||||
<Info class="w-3 h-3 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<h5 class="font-medium mb-1 text-foreground">Payment Instructions</h5>
|
||||
<ul class="space-y-1">
|
||||
<li>• Use a Lightning-compatible wallet (e.g., Phoenix, Breez, Alby)</li>
|
||||
<li>• Scan the QR code or copy the payment request</li>
|
||||
<li>• Confirm the payment amount and send</li>
|
||||
<li>• Your order will be processed once payment is confirmed</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Success -->
|
||||
<div v-if="paymentStatus === 'paid'" class="mt-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-5 h-5 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<CheckCircle class="w-3 h-3 text-green-600" />
|
||||
</div>
|
||||
<div class="text-sm text-green-800">
|
||||
<h5 class="font-medium">Payment Confirmed!</h5>
|
||||
<p>Your order is being processed. You'll receive updates via Nostr.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Copy,
|
||||
Wallet,
|
||||
Info,
|
||||
CheckCircle
|
||||
} from 'lucide-vue-next'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
|
||||
interface Props {
|
||||
orderId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
// Computed properties
|
||||
const order = computed(() => marketStore.orders[props.orderId])
|
||||
const invoice = computed(() => order.value?.lightningInvoice)
|
||||
const paymentStatus = computed(() => order.value?.paymentStatus || 'pending')
|
||||
const currency = computed(() => order.value?.currency || 'sat')
|
||||
const paidAt = computed(() => order.value?.paidAt)
|
||||
|
||||
// QR Code generation
|
||||
const qrCodeDataUrl = ref<string | null>(null)
|
||||
const qrCodeLoading = ref(false)
|
||||
const qrCodeError = ref<string | null>(null)
|
||||
|
||||
const generateQRCode = async (paymentRequest: string) => {
|
||||
try {
|
||||
qrCodeLoading.value = true
|
||||
qrCodeError.value = null
|
||||
|
||||
const dataUrl = await QRCode.toDataURL(paymentRequest, {
|
||||
width: 192, // 48 * 4 for high DPI displays
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF'
|
||||
}
|
||||
})
|
||||
qrCodeDataUrl.value = dataUrl
|
||||
} catch (error) {
|
||||
console.error('Failed to generate QR code:', error)
|
||||
qrCodeError.value = 'Failed to generate QR code'
|
||||
qrCodeDataUrl.value = null
|
||||
} finally {
|
||||
qrCodeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Methods
|
||||
const getPaymentStatusVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid': return 'default'
|
||||
case 'pending': return 'secondary'
|
||||
case 'expired': return 'destructive'
|
||||
default: return 'outline'
|
||||
}
|
||||
}
|
||||
|
||||
const formatPaymentStatus = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid': return 'Paid'
|
||||
case 'pending': return 'Pending'
|
||||
case 'expired': return 'Expired'
|
||||
default: return 'Unknown'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid': return 'text-green-600'
|
||||
case 'pending': return 'text-amber-600'
|
||||
case 'expired': return 'text-destructive'
|
||||
default: return 'text-muted-foreground'
|
||||
}
|
||||
}
|
||||
|
||||
const formatHash = (hash: string) => {
|
||||
if (!hash) return 'N/A'
|
||||
return `${hash.substring(0, 8)}...${hash.substring(hash.length - 8)}`
|
||||
}
|
||||
|
||||
const formatDate = (dateValue: string | number | undefined) => {
|
||||
if (!dateValue) return 'N/A'
|
||||
|
||||
let timestamp: number
|
||||
|
||||
if (typeof dateValue === 'string') {
|
||||
// Handle ISO date strings from LNBits API
|
||||
timestamp = new Date(dateValue).getTime()
|
||||
} else {
|
||||
// Handle Unix timestamps (seconds) from our store
|
||||
timestamp = dateValue * 1000
|
||||
}
|
||||
|
||||
return new Date(timestamp).toLocaleString()
|
||||
}
|
||||
|
||||
const copyPaymentRequest = async () => {
|
||||
if (!invoice.value?.bolt11) return
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(invoice.value.bolt11)
|
||||
// TODO: Show toast notification
|
||||
console.log('Payment request copied to clipboard')
|
||||
} catch (error) {
|
||||
console.error('Failed to copy payment request:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const openInWallet = () => {
|
||||
if (!invoice.value?.bolt11) return
|
||||
|
||||
// Open in Lightning wallet
|
||||
const walletUrl = `lightning:${invoice.value.bolt11}`
|
||||
window.open(walletUrl, '_blank')
|
||||
}
|
||||
|
||||
const retryQRCode = () => {
|
||||
if (invoice.value?.bolt11) {
|
||||
generateQRCode(invoice.value.bolt11)
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
// Set up payment monitoring if invoice exists
|
||||
if (invoice.value && props.orderId) {
|
||||
// Payment monitoring is handled by the market store
|
||||
console.log('Payment display mounted for order:', props.orderId)
|
||||
|
||||
// Generate QR code for the invoice
|
||||
if (invoice.value.bolt11) {
|
||||
generateQRCode(invoice.value.bolt11)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for invoice changes to regenerate QR code
|
||||
watch(() => invoice.value?.bolt11, (newPaymentRequest) => {
|
||||
if (newPaymentRequest) {
|
||||
generateQRCode(newPaymentRequest)
|
||||
} else {
|
||||
qrCodeDataUrl.value = null
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
287
src/modules/market/components/PaymentRequestDialog.vue
Normal file
287
src/modules/market/components/PaymentRequestDialog.vue
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
<template>
|
||||
<Dialog :open="modelValue" @update:open="updateOpen">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Payment Request</DialogTitle>
|
||||
<DialogDescription>
|
||||
Complete your payment to finalize your order
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div v-if="paymentRequest" class="space-y-4">
|
||||
<!-- Order Details -->
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-sm font-medium">Order Details</h4>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
Order ID: {{ paymentRequest.id }}
|
||||
</div>
|
||||
<div v-if="paymentRequest.message" class="text-sm">
|
||||
{{ paymentRequest.message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Options -->
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-sm font-medium">Payment Options</h4>
|
||||
<div v-for="option in paymentRequest.payment_options" :key="option.type" class="space-y-2">
|
||||
<Card>
|
||||
<CardContent class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">{{ getPaymentTypeLabel(option.type) }}</div>
|
||||
<div class="text-xs text-muted-foreground truncate">{{ option.link }}</div>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<Button
|
||||
:variant="option.type === 'ln' ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
@click="handlePayment(option)"
|
||||
>
|
||||
<component :is="getPaymentTypeIcon(option.type)" class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lightning Invoice QR Code -->
|
||||
<div v-if="lightningInvoice" class="space-y-4">
|
||||
<h4 class="text-sm font-medium">Lightning Invoice</h4>
|
||||
<div class="flex justify-center">
|
||||
<div class="bg-white p-4 rounded-lg border">
|
||||
<div v-if="qrCodeLoading" class="w-48 h-48 flex items-center justify-center">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
|
||||
</div>
|
||||
<img
|
||||
v-else-if="qrCodeDataUrl"
|
||||
:src="qrCodeDataUrl"
|
||||
alt="Lightning payment QR code"
|
||||
class="w-48 h-48"
|
||||
/>
|
||||
<div v-else-if="qrCodeError" class="w-48 h-48 flex items-center justify-center text-red-500 text-sm">
|
||||
{{ qrCodeError }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="copyInvoice"
|
||||
>
|
||||
Copy Invoice
|
||||
</Button>
|
||||
<Button
|
||||
@click="payWithWallet"
|
||||
>
|
||||
Pay with Wallet
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center text-muted-foreground py-8">
|
||||
No payment request available
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="updateOpen(false)">
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import QRCode from 'qrcode'
|
||||
import type { NostrmarketPaymentRequest } from '@/lib/services/nostrmarketService'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Zap, Bitcoin, Link, QrCode, CreditCard } from 'lucide-vue-next'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
paymentRequest?: NostrmarketPaymentRequest
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'payment-completed': [orderId: string]
|
||||
}>()
|
||||
|
||||
// Computed
|
||||
const lightningInvoice = computed(() => {
|
||||
if (!props.paymentRequest) return null
|
||||
|
||||
const lightningOption = props.paymentRequest.payment_options.find(opt => opt.type === 'ln')
|
||||
return lightningOption?.link || null
|
||||
})
|
||||
|
||||
// QR Code generation
|
||||
const qrCodeDataUrl = ref<string | null>(null)
|
||||
const qrCodeLoading = ref(false)
|
||||
const qrCodeError = ref<string | null>(null)
|
||||
|
||||
const generateQRCode = async (paymentRequest: string) => {
|
||||
try {
|
||||
qrCodeLoading.value = true
|
||||
qrCodeError.value = null
|
||||
|
||||
const dataUrl = await QRCode.toDataURL(paymentRequest, {
|
||||
width: 200,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF'
|
||||
}
|
||||
})
|
||||
qrCodeDataUrl.value = dataUrl
|
||||
} catch (error) {
|
||||
console.error('Failed to generate QR code:', error)
|
||||
qrCodeError.value = 'Failed to generate QR code'
|
||||
qrCodeDataUrl.value = null
|
||||
} finally {
|
||||
qrCodeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for lightning invoice changes and generate QR code
|
||||
watch(lightningInvoice, (newInvoice) => {
|
||||
if (newInvoice) {
|
||||
generateQRCode(newInvoice)
|
||||
} else {
|
||||
qrCodeDataUrl.value = null
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Methods
|
||||
const updateOpen = (open: boolean) => {
|
||||
emit('update:modelValue', open)
|
||||
}
|
||||
|
||||
const getPaymentTypeLabel = (type: string): string => {
|
||||
switch (type) {
|
||||
case 'ln':
|
||||
return 'Lightning Network'
|
||||
case 'btc':
|
||||
return 'Bitcoin On-Chain'
|
||||
case 'url':
|
||||
return 'Payment URL'
|
||||
case 'lnurl':
|
||||
return 'LNURL-Pay'
|
||||
default:
|
||||
return type.toUpperCase()
|
||||
}
|
||||
}
|
||||
|
||||
const getPaymentTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'ln':
|
||||
return Zap
|
||||
case 'btc':
|
||||
return Bitcoin
|
||||
case 'url':
|
||||
return Link
|
||||
case 'lnurl':
|
||||
return QrCode
|
||||
default:
|
||||
return CreditCard
|
||||
}
|
||||
}
|
||||
|
||||
const handlePayment = async (option: { type: string; link: string }) => {
|
||||
try {
|
||||
switch (option.type) {
|
||||
case 'ln':
|
||||
// For Lightning invoices, we can either show QR code or pay directly
|
||||
if (lightningInvoice.value) {
|
||||
await payWithWallet()
|
||||
}
|
||||
break
|
||||
case 'url':
|
||||
// Open payment URL in new tab
|
||||
window.open(option.link, '_blank')
|
||||
break
|
||||
case 'btc':
|
||||
// Copy Bitcoin address
|
||||
await navigator.clipboard.writeText(option.link)
|
||||
toast.success('Bitcoin address copied to clipboard')
|
||||
break
|
||||
default:
|
||||
console.warn('Unknown payment type:', option.type)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Payment handling error:', error)
|
||||
toast.error('Failed to process payment')
|
||||
}
|
||||
}
|
||||
|
||||
const copyInvoice = async () => {
|
||||
if (!lightningInvoice.value) return
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(lightningInvoice.value)
|
||||
toast.success('Lightning invoice copied to clipboard')
|
||||
} catch (error) {
|
||||
console.error('Failed to copy invoice:', error)
|
||||
toast.error('Failed to copy invoice')
|
||||
}
|
||||
}
|
||||
|
||||
const payWithWallet = async () => {
|
||||
if (!lightningInvoice.value) return
|
||||
|
||||
try {
|
||||
// Import the payment API
|
||||
const { payInvoiceWithWallet } = await import('@/lib/api/events')
|
||||
|
||||
// Get the current user's wallet info
|
||||
const { useAuth } = await import('@/composables/useAuth')
|
||||
const auth = useAuth()
|
||||
|
||||
if (!auth.currentUser.value?.wallets?.[0]?.id || !auth.currentUser.value?.wallets?.[0]?.adminkey) {
|
||||
toast.error('Please connect your wallet to pay')
|
||||
return
|
||||
}
|
||||
|
||||
// Pay the invoice
|
||||
const result = await payInvoiceWithWallet(
|
||||
lightningInvoice.value,
|
||||
auth.currentUser.value.wallets[0].id,
|
||||
auth.currentUser.value.wallets[0].adminkey
|
||||
)
|
||||
|
||||
console.log('Payment result:', result)
|
||||
|
||||
toast.success('Payment successful!')
|
||||
|
||||
// Emit payment completed event
|
||||
if (props.paymentRequest) {
|
||||
emit('payment-completed', props.paymentRequest.id)
|
||||
}
|
||||
|
||||
// Close the dialog
|
||||
updateOpen(false)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Payment failed:', error)
|
||||
toast.error('Payment failed: ' + (error instanceof Error ? error.message : 'Unknown error'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
151
src/modules/market/components/ProductCard.vue
Normal file
151
src/modules/market/components/ProductCard.vue
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
<template>
|
||||
<Card class="overflow-hidden hover:shadow-lg transition-shadow duration-200">
|
||||
<!-- Product Image -->
|
||||
<div class="relative">
|
||||
<img
|
||||
:src="product.images?.[0] || '/placeholder-product.png'"
|
||||
:alt="product.name"
|
||||
class="w-full h-48 object-cover"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
<!-- Add to Cart Button -->
|
||||
<Button
|
||||
@click="addToCart"
|
||||
:disabled="product.quantity < 1"
|
||||
size="sm"
|
||||
class="absolute top-2 right-2 bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
<ShoppingCart class="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<!-- Out of Stock Badge -->
|
||||
<Badge
|
||||
v-if="product.quantity < 1"
|
||||
variant="destructive"
|
||||
class="absolute top-2 left-2"
|
||||
>
|
||||
Out of Stock
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<CardContent class="p-4">
|
||||
<!-- Product Name -->
|
||||
<CardTitle class="text-lg font-semibold mb-2 line-clamp-2">
|
||||
{{ product.name }}
|
||||
</CardTitle>
|
||||
|
||||
<!-- Product Description -->
|
||||
<p v-if="product.description" class="text-gray-600 text-sm mb-3 line-clamp-2">
|
||||
{{ product.description }}
|
||||
</p>
|
||||
|
||||
<!-- Price and Quantity -->
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="text-xl font-bold text-green-600">
|
||||
{{ formatPrice(product.price, product.currency) }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500">
|
||||
{{ product.quantity }} left
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Categories -->
|
||||
<div v-if="product.categories && product.categories.length > 0" class="mb-3">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Badge
|
||||
v-for="category in product.categories.slice(0, 3)"
|
||||
:key="category"
|
||||
variant="secondary"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ category }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="product.categories.length > 3"
|
||||
variant="outline"
|
||||
class="text-xs"
|
||||
>
|
||||
+{{ product.categories.length - 3 }} more
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stall Name -->
|
||||
<div class="text-sm text-gray-500 mb-3">
|
||||
{{ product.stallName }}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter class="p-4 pt-0">
|
||||
<div class="flex w-full space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
@click="$emit('view-stall', product.stall_id)"
|
||||
>
|
||||
Visit Stall
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
class="flex-1"
|
||||
@click="$emit('view-details', product)"
|
||||
>
|
||||
View Details
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { Card, CardContent, CardFooter, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { ShoppingCart } from 'lucide-vue-next'
|
||||
import type { Product } from '@/stores/market'
|
||||
|
||||
interface Props {
|
||||
product: Product
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// const emit = defineEmits<{
|
||||
// 'view-details': [product: Product]
|
||||
// 'view-stall': [stallId: string]
|
||||
// }>()
|
||||
|
||||
const marketStore = useMarketStore()
|
||||
const imageError = ref(false)
|
||||
|
||||
const addToCart = () => {
|
||||
marketStore.addToStallCart(props.product, 1)
|
||||
}
|
||||
|
||||
const handleImageError = () => {
|
||||
imageError.value = true
|
||||
}
|
||||
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
if (currency === 'sat' || currency === 'sats') {
|
||||
return `${price.toLocaleString('en-US')} sats`
|
||||
}
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase()
|
||||
}).format(price)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
250
src/modules/market/components/ShoppingCart.vue
Normal file
250
src/modules/market/components/ShoppingCart.vue
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Cart Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-foreground">Shopping Cart</h2>
|
||||
<p class="text-muted-foreground">
|
||||
{{ totalCartItems }} items across {{ allStallCarts.length }} stalls
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-muted-foreground">Total Value</p>
|
||||
<p class="text-xl font-bold text-green-600">
|
||||
{{ formatPrice(totalCartValue, 'sats') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="allStallCarts.length > 0"
|
||||
@click="clearAllCarts"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty Cart State -->
|
||||
<div v-if="allStallCarts.length === 0" class="text-center py-12">
|
||||
<ShoppingCart class="mx-auto h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<h3 class="text-lg font-medium text-foreground mb-2">Your cart is empty</h3>
|
||||
<p class="text-muted-foreground mb-6">Start shopping to add items to your cart</p>
|
||||
<Button @click="$router.push('/market')" variant="default">
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Stall Carts -->
|
||||
<div v-else class="space-y-6">
|
||||
<div
|
||||
v-for="cart in allStallCarts"
|
||||
:key="cart.id"
|
||||
class="border border-border rounded-lg p-6 bg-card shadow-sm"
|
||||
>
|
||||
<!-- Stall Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<Store class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-foreground">
|
||||
{{ getStallName(cart.id) }}
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ cart.products.length }} item{{ cart.products.length !== 1 ? 's' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-muted-foreground">Stall Total</p>
|
||||
<p class="text-lg font-semibold text-green-600">
|
||||
{{ formatPrice(cart.subtotal, cart.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cart Items -->
|
||||
<div class="space-y-3 mb-4">
|
||||
<CartItem
|
||||
v-for="item in cart.products"
|
||||
:key="item.product.id"
|
||||
:item="item"
|
||||
:stall-id="cart.id"
|
||||
@update-quantity="updateQuantity"
|
||||
@remove-item="removeItem"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Stall Cart Actions -->
|
||||
<div class="pt-4 border-t border-border">
|
||||
<!-- Desktop Layout -->
|
||||
<div class="hidden md:flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<Button
|
||||
@click="clearStallCart(cart.id)"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Clear Stall
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@click="viewStall(cart.id)"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
View Stall
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<!-- Cart Summary for this stall -->
|
||||
<div class="text-right mr-4">
|
||||
<p class="text-sm text-muted-foreground">Total</p>
|
||||
<p class="text-lg font-semibold text-green-600">
|
||||
{{ formatPrice(cart.subtotal, cart.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@click="proceedToCheckout(cart.id)"
|
||||
:disabled="!canProceedToCheckout(cart.id)"
|
||||
variant="default"
|
||||
>
|
||||
Checkout
|
||||
<ArrowRight class="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Layout -->
|
||||
<div class="md:hidden space-y-4">
|
||||
<!-- Action Buttons Row -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button
|
||||
@click="clearStallCart(cart.id)"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Clear Stall
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@click="viewStall(cart.id)"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
View Stall
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total and Checkout Row -->
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Cart Summary for this stall -->
|
||||
<div class="text-left">
|
||||
<p class="text-sm text-muted-foreground">Total</p>
|
||||
<p class="text-lg font-semibold text-green-600">
|
||||
{{ formatPrice(cart.subtotal, cart.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@click="proceedToCheckout(cart.id)"
|
||||
:disabled="!canProceedToCheckout(cart.id)"
|
||||
variant="default"
|
||||
class="flex items-center"
|
||||
>
|
||||
Checkout
|
||||
<ArrowRight class="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Continue Shopping Button -->
|
||||
<div v-if="allStallCarts.length > 0" class="text-center mt-8">
|
||||
<Button @click="$router.push('/market')" variant="outline" size="lg">
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
ShoppingCart,
|
||||
Store,
|
||||
ArrowRight
|
||||
} from 'lucide-vue-next'
|
||||
import CartItem from './CartItem.vue'
|
||||
|
||||
|
||||
const router = useRouter()
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
// Computed properties
|
||||
const allStallCarts = computed(() => marketStore.allStallCarts)
|
||||
const totalCartItems = computed(() => marketStore.totalCartItems)
|
||||
const totalCartValue = computed(() => marketStore.totalCartValue)
|
||||
|
||||
// Methods
|
||||
const getStallName = (stallId: string) => {
|
||||
const stall = marketStore.stalls.find(s => s.id === stallId)
|
||||
return stall?.name || 'Unknown Stall'
|
||||
}
|
||||
|
||||
const updateQuantity = (stallId: string, productId: string, quantity: number) => {
|
||||
marketStore.updateStallCartQuantity(stallId, productId, quantity)
|
||||
}
|
||||
|
||||
const removeItem = (stallId: string, productId: string) => {
|
||||
marketStore.removeFromStallCart(stallId, productId)
|
||||
}
|
||||
|
||||
const clearStallCart = (stallId: string) => {
|
||||
marketStore.clearStallCart(stallId)
|
||||
}
|
||||
|
||||
const clearAllCarts = () => {
|
||||
marketStore.clearAllStallCarts()
|
||||
}
|
||||
|
||||
const viewStall = (stallId: string) => {
|
||||
// TODO: Navigate to stall page
|
||||
console.log('View stall:', stallId)
|
||||
}
|
||||
|
||||
const proceedToCheckout = (stallId: string) => {
|
||||
marketStore.setCheckoutCart(stallId)
|
||||
router.push(`/checkout/${stallId}`)
|
||||
}
|
||||
|
||||
const canProceedToCheckout = (stallId: string) => {
|
||||
const cart = marketStore.stallCarts[stallId]
|
||||
return cart && cart.products.length > 0
|
||||
}
|
||||
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
if (currency === 'sats' || currency === 'sat') {
|
||||
return `${price.toLocaleString()} sats`
|
||||
}
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase()
|
||||
}).format(price)
|
||||
}
|
||||
</script>
|
||||
2
src/modules/market/composables/index.ts
Normal file
2
src/modules/market/composables/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { useMarket } from './useMarket'
|
||||
export { useMarketPreloader } from './useMarketPreloader'
|
||||
538
src/modules/market/composables/useMarket.ts
Normal file
538
src/modules/market/composables/useMarket.ts
Normal file
|
|
@ -0,0 +1,538 @@
|
|||
import { ref, computed, onMounted, onUnmounted, readonly } from 'vue'
|
||||
import { useNostrStore } from '@/stores/nostr'
|
||||
import { useMarketStore } from '../stores/market'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import { config } from '@/lib/config'
|
||||
|
||||
// Nostr event kinds for market functionality
|
||||
const MARKET_EVENT_KINDS = {
|
||||
MARKET: 30019,
|
||||
STALL: 30017,
|
||||
PRODUCT: 30018,
|
||||
ORDER: 30020
|
||||
} as const
|
||||
|
||||
export function useMarket() {
|
||||
const nostrStore = useNostrStore()
|
||||
const marketStore = useMarketStore()
|
||||
const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) as any
|
||||
|
||||
if (!relayHub) {
|
||||
throw new Error('RelayHub not available. Make sure base module is installed.')
|
||||
}
|
||||
|
||||
// State
|
||||
const isLoading = ref(false)
|
||||
const error = ref<Error | null>(null)
|
||||
const isConnected = ref(false)
|
||||
const activeMarket = computed(() => marketStore.activeMarket)
|
||||
const markets = computed(() => marketStore.markets)
|
||||
const stalls = computed(() => marketStore.stalls)
|
||||
const products = computed(() => marketStore.products)
|
||||
const orders = computed(() => marketStore.orders)
|
||||
|
||||
// Connection state
|
||||
const connectionStatus = computed(() => {
|
||||
if (isConnected.value) return 'connected'
|
||||
if (nostrStore.isConnecting) return 'connecting'
|
||||
if (nostrStore.error) return 'error'
|
||||
return 'disconnected'
|
||||
})
|
||||
|
||||
// Load market from naddr
|
||||
const loadMarket = async (naddr: string) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
// Load market from naddr
|
||||
|
||||
// Parse naddr to get market data
|
||||
const marketData = {
|
||||
identifier: naddr.split(':')[2] || 'default',
|
||||
pubkey: naddr.split(':')[1] || nostrStore.account?.pubkey || ''
|
||||
}
|
||||
|
||||
if (!marketData.pubkey) {
|
||||
throw new Error('No pubkey available for market')
|
||||
}
|
||||
|
||||
await loadMarketData(marketData)
|
||||
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err : new Error('Failed to load market')
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Load market data from Nostr events
|
||||
const loadMarketData = async (marketData: any) => {
|
||||
try {
|
||||
// Load market data from Nostr events
|
||||
|
||||
// Fetch market configuration event
|
||||
const events = await relayHub.queryEvents([
|
||||
{
|
||||
kinds: [MARKET_EVENT_KINDS.MARKET],
|
||||
authors: [marketData.pubkey],
|
||||
'#d': [marketData.identifier]
|
||||
}
|
||||
])
|
||||
|
||||
// Process market events
|
||||
|
||||
if (events.length > 0) {
|
||||
const marketEvent = events[0]
|
||||
// Process market event
|
||||
|
||||
const market = {
|
||||
d: marketData.identifier,
|
||||
pubkey: marketData.pubkey,
|
||||
relays: config.market.supportedRelays,
|
||||
selected: true,
|
||||
opts: JSON.parse(marketEvent.content)
|
||||
}
|
||||
|
||||
marketStore.addMarket(market)
|
||||
marketStore.setActiveMarket(market)
|
||||
} else {
|
||||
// No market events found, create default
|
||||
// Create a default market if none exists
|
||||
const market = {
|
||||
d: marketData.identifier,
|
||||
pubkey: marketData.pubkey,
|
||||
relays: config.market.supportedRelays,
|
||||
selected: true,
|
||||
opts: {
|
||||
name: 'Ariège Market',
|
||||
description: 'A communal market to sell your goods',
|
||||
merchants: [],
|
||||
ui: {}
|
||||
}
|
||||
}
|
||||
|
||||
marketStore.addMarket(market)
|
||||
marketStore.setActiveMarket(market)
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
// Don't throw error, create default market instead
|
||||
const market = {
|
||||
d: marketData.identifier,
|
||||
pubkey: marketData.pubkey,
|
||||
relays: config.market.supportedRelays,
|
||||
selected: true,
|
||||
opts: {
|
||||
name: 'Default Market',
|
||||
description: 'A default market',
|
||||
merchants: [],
|
||||
ui: {}
|
||||
}
|
||||
}
|
||||
|
||||
marketStore.addMarket(market)
|
||||
marketStore.setActiveMarket(market)
|
||||
}
|
||||
}
|
||||
|
||||
// Load stalls from market merchants
|
||||
const loadStalls = async () => {
|
||||
try {
|
||||
// Get the active market to filter by its merchants
|
||||
const activeMarket = marketStore.activeMarket
|
||||
if (!activeMarket) {
|
||||
return
|
||||
}
|
||||
|
||||
const merchants = [...(activeMarket.opts.merchants || [])]
|
||||
|
||||
if (merchants.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch stall events from market merchants only
|
||||
const events = await relayHub.queryEvents([
|
||||
{
|
||||
kinds: [MARKET_EVENT_KINDS.STALL],
|
||||
authors: merchants
|
||||
}
|
||||
])
|
||||
|
||||
// Process stall events
|
||||
|
||||
// Group events by stall ID and keep only the most recent version
|
||||
const stallGroups = new Map<string, any[]>()
|
||||
events.forEach((event: any) => {
|
||||
const stallId = event.tags.find((tag: any) => tag[0] === 'd')?.[1]
|
||||
if (stallId) {
|
||||
if (!stallGroups.has(stallId)) {
|
||||
stallGroups.set(stallId, [])
|
||||
}
|
||||
stallGroups.get(stallId)!.push(event)
|
||||
}
|
||||
})
|
||||
|
||||
// Process each stall group
|
||||
stallGroups.forEach((stallEvents, stallId) => {
|
||||
// Sort by created_at and take the most recent
|
||||
const latestEvent = stallEvents.sort((a: any, b: any) => b.created_at - a.created_at)[0]
|
||||
|
||||
try {
|
||||
const stallData = JSON.parse(latestEvent.content)
|
||||
const stall = {
|
||||
id: stallId,
|
||||
pubkey: latestEvent.pubkey,
|
||||
name: stallData.name || 'Unnamed Stall',
|
||||
description: stallData.description || '',
|
||||
created_at: latestEvent.created_at,
|
||||
...stallData
|
||||
}
|
||||
|
||||
marketStore.addStall(stall)
|
||||
} catch (err) {
|
||||
// Silently handle parse errors
|
||||
}
|
||||
})
|
||||
|
||||
} catch (err) {
|
||||
// Silently handle stall loading errors
|
||||
}
|
||||
}
|
||||
|
||||
// Load products from market stalls
|
||||
const loadProducts = async () => {
|
||||
try {
|
||||
const activeMarket = marketStore.activeMarket
|
||||
if (!activeMarket) {
|
||||
return
|
||||
}
|
||||
|
||||
const merchants = [...(activeMarket.opts.merchants || [])]
|
||||
if (merchants.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch product events from market merchants
|
||||
const events = await relayHub.queryEvents([
|
||||
{
|
||||
kinds: [MARKET_EVENT_KINDS.PRODUCT],
|
||||
authors: merchants
|
||||
}
|
||||
])
|
||||
|
||||
// Process product events
|
||||
|
||||
// Group events by product ID and keep only the most recent version
|
||||
const productGroups = new Map<string, any[]>()
|
||||
events.forEach((event: any) => {
|
||||
const productId = event.tags.find((tag: any) => tag[0] === 'd')?.[1]
|
||||
if (productId) {
|
||||
if (!productGroups.has(productId)) {
|
||||
productGroups.set(productId, [])
|
||||
}
|
||||
productGroups.get(productId)!.push(event)
|
||||
}
|
||||
})
|
||||
|
||||
// Process each product group
|
||||
productGroups.forEach((productEvents, productId) => {
|
||||
// Sort by created_at and take the most recent
|
||||
const latestEvent = productEvents.sort((a: any, b: any) => b.created_at - a.created_at)[0]
|
||||
|
||||
try {
|
||||
const productData = JSON.parse(latestEvent.content)
|
||||
const product = {
|
||||
id: productId,
|
||||
stall_id: productData.stall_id || 'unknown',
|
||||
stallName: productData.stallName || 'Unknown Stall',
|
||||
name: productData.name || 'Unnamed Product',
|
||||
description: productData.description || '',
|
||||
price: productData.price || 0,
|
||||
currency: productData.currency || 'sats',
|
||||
quantity: productData.quantity || 1,
|
||||
images: productData.images || [],
|
||||
categories: productData.categories || [],
|
||||
createdAt: latestEvent.created_at,
|
||||
updatedAt: latestEvent.created_at
|
||||
}
|
||||
|
||||
marketStore.addProduct(product)
|
||||
} catch (err) {
|
||||
// Silently handle parse errors
|
||||
}
|
||||
})
|
||||
|
||||
} catch (err) {
|
||||
// Silently handle product loading errors
|
||||
}
|
||||
}
|
||||
|
||||
// Add sample products for testing
|
||||
const addSampleProducts = () => {
|
||||
const sampleProducts = [
|
||||
{
|
||||
id: 'sample-1',
|
||||
stall_id: 'sample-stall',
|
||||
stallName: 'Sample Stall',
|
||||
pubkey: nostrStore.account?.pubkey || '',
|
||||
name: 'Sample Product 1',
|
||||
description: 'This is a sample product for testing',
|
||||
price: 1000,
|
||||
currency: 'sats',
|
||||
quantity: 1,
|
||||
images: [],
|
||||
categories: [],
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
updatedAt: Math.floor(Date.now() / 1000)
|
||||
},
|
||||
{
|
||||
id: 'sample-2',
|
||||
stall_id: 'sample-stall',
|
||||
stallName: 'Sample Stall',
|
||||
pubkey: nostrStore.account?.pubkey || '',
|
||||
name: 'Sample Product 2',
|
||||
description: 'Another sample product for testing',
|
||||
price: 2000,
|
||||
currency: 'sats',
|
||||
quantity: 1,
|
||||
images: [],
|
||||
categories: [],
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
updatedAt: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
]
|
||||
|
||||
sampleProducts.forEach(product => {
|
||||
marketStore.addProduct(product)
|
||||
})
|
||||
}
|
||||
|
||||
// Subscribe to market updates
|
||||
const subscribeToMarketUpdates = (): (() => void) | null => {
|
||||
try {
|
||||
const activeMarket = marketStore.activeMarket
|
||||
if (!activeMarket) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Subscribe to market events
|
||||
const unsubscribe = relayHub.subscribe({
|
||||
id: `market-${activeMarket.d}`,
|
||||
filters: [
|
||||
{ kinds: [MARKET_EVENT_KINDS.MARKET] },
|
||||
{ kinds: [MARKET_EVENT_KINDS.STALL] },
|
||||
{ kinds: [MARKET_EVENT_KINDS.PRODUCT] },
|
||||
{ kinds: [MARKET_EVENT_KINDS.ORDER] }
|
||||
],
|
||||
onEvent: (event: any) => {
|
||||
handleMarketEvent(event)
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Handle incoming market events
|
||||
const handleMarketEvent = (event: any) => {
|
||||
// Process market event
|
||||
|
||||
switch (event.kind) {
|
||||
case MARKET_EVENT_KINDS.MARKET:
|
||||
// Handle market updates
|
||||
break
|
||||
case MARKET_EVENT_KINDS.STALL:
|
||||
// Handle stall updates
|
||||
handleStallEvent(event)
|
||||
break
|
||||
case MARKET_EVENT_KINDS.PRODUCT:
|
||||
// Handle product updates
|
||||
handleProductEvent(event)
|
||||
break
|
||||
case MARKET_EVENT_KINDS.ORDER:
|
||||
// Handle order updates
|
||||
handleOrderEvent(event)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Process pending products (products without stalls)
|
||||
const processPendingProducts = () => {
|
||||
const productsWithoutStalls = products.value.filter(product => {
|
||||
// Check if product has a stall tag
|
||||
return !product.stall_id
|
||||
})
|
||||
|
||||
if (productsWithoutStalls.length > 0) {
|
||||
// You could create default stalls or handle this as needed
|
||||
}
|
||||
}
|
||||
|
||||
// Handle stall events
|
||||
const handleStallEvent = (event: any) => {
|
||||
try {
|
||||
const stallId = event.tags.find((tag: any) => tag[0] === 'd')?.[1]
|
||||
if (stallId) {
|
||||
const stallData = JSON.parse(event.content)
|
||||
const stall = {
|
||||
id: stallId,
|
||||
pubkey: event.pubkey,
|
||||
name: stallData.name || 'Unnamed Stall',
|
||||
description: stallData.description || '',
|
||||
created_at: event.created_at,
|
||||
...stallData
|
||||
}
|
||||
|
||||
marketStore.addStall(stall)
|
||||
}
|
||||
} catch (err) {
|
||||
// Silently handle stall event errors
|
||||
}
|
||||
}
|
||||
|
||||
// Handle product events
|
||||
const handleProductEvent = (event: any) => {
|
||||
try {
|
||||
const productId = event.tags.find((tag: any) => tag[0] === 'd')?.[1]
|
||||
if (productId) {
|
||||
const productData = JSON.parse(event.content)
|
||||
const product = {
|
||||
id: productId,
|
||||
stall_id: productData.stall_id || 'unknown',
|
||||
stallName: productData.stallName || 'Unknown Stall',
|
||||
pubkey: event.pubkey,
|
||||
name: productData.name || 'Unnamed Product',
|
||||
description: productData.description || '',
|
||||
price: productData.price || 0,
|
||||
currency: productData.currency || 'sats',
|
||||
quantity: productData.quantity || 1,
|
||||
images: productData.images || [],
|
||||
categories: productData.categories || [],
|
||||
createdAt: event.created_at,
|
||||
updatedAt: event.created_at
|
||||
}
|
||||
|
||||
marketStore.addProduct(product)
|
||||
}
|
||||
} catch (err) {
|
||||
// Silently handle product event errors
|
||||
}
|
||||
}
|
||||
|
||||
// Handle order events
|
||||
const handleOrderEvent = (_event: any) => {
|
||||
try {
|
||||
// const orderData = JSON.parse(event.content)
|
||||
// const order = {
|
||||
// id: event.id,
|
||||
// stall_id: orderData.stall_id || 'unknown',
|
||||
// product_id: orderData.product_id || 'unknown',
|
||||
// buyer_pubkey: event.pubkey,
|
||||
// seller_pubkey: orderData.seller_pubkey || '',
|
||||
// quantity: orderData.quantity || 1,
|
||||
// total_price: orderData.total_price || 0,
|
||||
// currency: orderData.currency || 'sats',
|
||||
// status: orderData.status || 'pending',
|
||||
// payment_request: orderData.payment_request,
|
||||
// created_at: event.created_at,
|
||||
// updated_at: event.created_at
|
||||
// }
|
||||
|
||||
// Note: addOrder method doesn't exist in the store, so we'll just handle it silently
|
||||
} catch (err) {
|
||||
// Silently handle order event errors
|
||||
}
|
||||
}
|
||||
|
||||
// Publish a product
|
||||
const publishProduct = async (_productData: any) => {
|
||||
// Implementation would depend on your event creation logic
|
||||
// TODO: Implement product publishing
|
||||
}
|
||||
|
||||
// Publish a stall
|
||||
const publishStall = async (_stallData: any) => {
|
||||
// Implementation would depend on your event creation logic
|
||||
// TODO: Implement stall publishing
|
||||
}
|
||||
|
||||
// Connect to market
|
||||
const connectToMarket = async () => {
|
||||
try {
|
||||
// Connect to market
|
||||
|
||||
// Connect to relay hub
|
||||
await relayHub.connect()
|
||||
isConnected.value = relayHub.isConnected.value
|
||||
|
||||
if (!isConnected.value) {
|
||||
throw new Error('Failed to connect to Nostr relays')
|
||||
}
|
||||
|
||||
// Market connected successfully
|
||||
|
||||
// Load market data
|
||||
await loadMarketData({
|
||||
identifier: 'default',
|
||||
pubkey: nostrStore.account?.pubkey || ''
|
||||
})
|
||||
|
||||
// Load stalls and products
|
||||
await loadStalls()
|
||||
await loadProducts()
|
||||
|
||||
// Subscribe to updates
|
||||
subscribeToMarketUpdates()
|
||||
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err : new Error('Failed to connect to market')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnect from market
|
||||
const disconnectFromMarket = () => {
|
||||
isConnected.value = false
|
||||
error.value = null
|
||||
// Market disconnected
|
||||
}
|
||||
|
||||
// Initialize market on mount
|
||||
onMounted(async () => {
|
||||
if (nostrStore.isConnected) {
|
||||
await connectToMarket()
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup on unmount
|
||||
onUnmounted(() => {
|
||||
disconnectFromMarket()
|
||||
})
|
||||
|
||||
return {
|
||||
// State
|
||||
isLoading: readonly(isLoading),
|
||||
error: readonly(error),
|
||||
isConnected: readonly(isConnected),
|
||||
connectionStatus: readonly(connectionStatus),
|
||||
activeMarket: readonly(activeMarket),
|
||||
markets: readonly(markets),
|
||||
stalls: readonly(stalls),
|
||||
products: readonly(products),
|
||||
orders: readonly(orders),
|
||||
|
||||
// Actions
|
||||
loadMarket,
|
||||
connectToMarket,
|
||||
disconnectFromMarket,
|
||||
addSampleProducts,
|
||||
processPendingProducts,
|
||||
publishProduct,
|
||||
publishStall,
|
||||
subscribeToMarketUpdates
|
||||
}
|
||||
}
|
||||
60
src/modules/market/composables/useMarketPreloader.ts
Normal file
60
src/modules/market/composables/useMarketPreloader.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { ref, readonly } from 'vue'
|
||||
import { useMarket } from './useMarket'
|
||||
import { useMarketStore } from '../stores/market'
|
||||
import { config } from '@/lib/config'
|
||||
|
||||
export function useMarketPreloader() {
|
||||
const isPreloading = ref(false)
|
||||
const isPreloaded = ref(false)
|
||||
const preloadError = ref<string | null>(null)
|
||||
|
||||
const market = useMarket()
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
const preloadMarket = async () => {
|
||||
// Don't preload if already done or currently preloading
|
||||
if (isPreloaded.value || isPreloading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isPreloading.value = true
|
||||
preloadError.value = null
|
||||
|
||||
const naddr = config.market.defaultNaddr
|
||||
if (!naddr) {
|
||||
return
|
||||
}
|
||||
|
||||
// Connect to market
|
||||
await market.connectToMarket()
|
||||
|
||||
// Load market data
|
||||
await market.loadMarket(naddr)
|
||||
|
||||
// Clear any error state since preloading succeeded
|
||||
marketStore.setError(null)
|
||||
|
||||
isPreloaded.value = true
|
||||
|
||||
} catch (error) {
|
||||
preloadError.value = error instanceof Error ? error.message : 'Failed to preload market'
|
||||
// Don't throw error, let the UI handle it gracefully
|
||||
} finally {
|
||||
isPreloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetPreload = () => {
|
||||
isPreloaded.value = false
|
||||
preloadError.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
isPreloading: readonly(isPreloading),
|
||||
isPreloaded: readonly(isPreloaded),
|
||||
preloadError: readonly(preloadError),
|
||||
preloadMarket,
|
||||
resetPreload
|
||||
}
|
||||
}
|
||||
143
src/modules/market/index.ts
Normal file
143
src/modules/market/index.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import type { App } from 'vue'
|
||||
import type { ModulePlugin } from '@/core/types'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { container } from '@/core/di-container'
|
||||
import { eventBus } from '@/core/event-bus'
|
||||
|
||||
// Import components
|
||||
import MarketSettings from './components/MarketSettings.vue'
|
||||
import MerchantStore from './components/MerchantStore.vue'
|
||||
import ShoppingCart from './components/ShoppingCart.vue'
|
||||
|
||||
// Import services
|
||||
import { NostrmarketService } from './services/nostrmarketService'
|
||||
|
||||
// Store will be imported when needed
|
||||
|
||||
// Import composables
|
||||
import { useMarket } from './composables/useMarket'
|
||||
import { useMarketPreloader } from './composables/useMarketPreloader'
|
||||
|
||||
// Define service tokens
|
||||
export const MARKET_SERVICE_TOKEN = Symbol('marketService')
|
||||
export const NOSTRMARKET_SERVICE_TOKEN = Symbol('nostrmarketService')
|
||||
|
||||
export interface MarketModuleConfig {
|
||||
defaultCurrency: string
|
||||
paymentTimeout: number
|
||||
maxOrderHistory: number
|
||||
supportedRelays?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Market Module Plugin
|
||||
* Provides market, stall, and product management functionality
|
||||
*/
|
||||
export const marketModule: ModulePlugin = {
|
||||
name: 'market',
|
||||
version: '1.0.0',
|
||||
dependencies: ['base'],
|
||||
|
||||
async install(app: App, options?: { config?: MarketModuleConfig }) {
|
||||
console.log('🛒 Installing market module...')
|
||||
|
||||
const config = options?.config
|
||||
if (!config) {
|
||||
throw new Error('Market module requires configuration')
|
||||
}
|
||||
|
||||
// Create and register services
|
||||
const nostrmarketService = new NostrmarketService()
|
||||
container.provide(NOSTRMARKET_SERVICE_TOKEN, nostrmarketService)
|
||||
|
||||
// Register global components
|
||||
app.component('MarketSettings', MarketSettings)
|
||||
app.component('MerchantStore', MerchantStore)
|
||||
app.component('ShoppingCart', ShoppingCart)
|
||||
|
||||
// Market store will be initialized when first used
|
||||
|
||||
// Set up event listeners for integration with other modules
|
||||
setupEventListeners()
|
||||
|
||||
console.log('✅ Market module installed successfully')
|
||||
},
|
||||
|
||||
async uninstall() {
|
||||
console.log('🗑️ Uninstalling market module...')
|
||||
|
||||
// Clean up services
|
||||
container.remove(NOSTRMARKET_SERVICE_TOKEN)
|
||||
|
||||
console.log('✅ Market module uninstalled')
|
||||
},
|
||||
|
||||
routes: [
|
||||
{
|
||||
path: '/market',
|
||||
name: 'market',
|
||||
component: () => import('./views/MarketPage.vue'),
|
||||
meta: {
|
||||
title: 'Market',
|
||||
requiresAuth: false
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/market/dashboard',
|
||||
name: 'market-dashboard',
|
||||
component: () => import('./views/MarketDashboard.vue'),
|
||||
meta: {
|
||||
title: 'Market Dashboard',
|
||||
requiresAuth: true
|
||||
}
|
||||
}
|
||||
] as RouteRecordRaw[],
|
||||
|
||||
components: {
|
||||
MarketSettings,
|
||||
MerchantStore,
|
||||
ShoppingCart
|
||||
},
|
||||
|
||||
composables: {
|
||||
useMarket,
|
||||
useMarketPreloader
|
||||
},
|
||||
|
||||
services: {
|
||||
nostrmarket: NOSTRMARKET_SERVICE_TOKEN
|
||||
}
|
||||
}
|
||||
|
||||
// Set up event listeners for integration with other modules
|
||||
function setupEventListeners() {
|
||||
// Listen for auth events
|
||||
eventBus.on('auth:logout', () => {
|
||||
console.log('Market module: user logged out, clearing market data')
|
||||
// Could clear market-specific user data here
|
||||
})
|
||||
|
||||
// Listen for payment events from other modules
|
||||
eventBus.on('payment:completed', (event) => {
|
||||
console.log('Market module: payment completed', event.data)
|
||||
// Could update order status or refresh market data here
|
||||
})
|
||||
|
||||
// Emit market-specific events
|
||||
eventBus.on('market:order-placed', (event) => {
|
||||
console.log('Market order placed:', event.data)
|
||||
// Other modules can listen to this event
|
||||
})
|
||||
|
||||
eventBus.on('market:product-added', (event) => {
|
||||
console.log('Market product added:', event.data)
|
||||
// Other modules can listen to this event
|
||||
})
|
||||
}
|
||||
|
||||
export default marketModule
|
||||
|
||||
// Re-export types and composables for external use
|
||||
export type * from './types/market'
|
||||
export { useMarket, useMarketPreloader } from './composables'
|
||||
export { useMarketStore } from './stores/market'
|
||||
460
src/modules/market/services/nostrmarketService.ts
Normal file
460
src/modules/market/services/nostrmarketService.ts
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
import { finalizeEvent, type EventTemplate, nip04 } from 'nostr-tools'
|
||||
import { relayHub } from '@/lib/nostr/relayHub'
|
||||
import { auth } from '@/composables/useAuth'
|
||||
import type { Stall, Product, Order } from '@/stores/market'
|
||||
|
||||
export interface NostrmarketStall {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
currency: string
|
||||
shipping: Array<{
|
||||
id: string
|
||||
name: string
|
||||
cost: number
|
||||
countries: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
export interface NostrmarketProduct {
|
||||
id: string
|
||||
stall_id: string
|
||||
name: string
|
||||
description?: string
|
||||
images: string[]
|
||||
categories: string[]
|
||||
price: number
|
||||
quantity: number
|
||||
currency: string
|
||||
}
|
||||
|
||||
export interface NostrmarketOrder {
|
||||
id: string
|
||||
items: Array<{
|
||||
product_id: string
|
||||
quantity: number
|
||||
}>
|
||||
contact: {
|
||||
name: string
|
||||
email?: string
|
||||
phone?: string
|
||||
}
|
||||
address?: {
|
||||
street: string
|
||||
city: string
|
||||
state: string
|
||||
country: string
|
||||
postal_code: string
|
||||
}
|
||||
shipping_id: string
|
||||
}
|
||||
|
||||
export interface NostrmarketPaymentRequest {
|
||||
type: 1
|
||||
id: string
|
||||
message?: string
|
||||
payment_options: Array<{
|
||||
type: string
|
||||
link: string
|
||||
}>
|
||||
}
|
||||
|
||||
export interface NostrmarketOrderStatus {
|
||||
type: 2
|
||||
id: string
|
||||
message?: string
|
||||
paid?: boolean
|
||||
shipped?: boolean
|
||||
}
|
||||
|
||||
export class NostrmarketService {
|
||||
/**
|
||||
* Convert hex string to Uint8Array (browser-compatible)
|
||||
*/
|
||||
private hexToUint8Array(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2)
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
private getAuth() {
|
||||
if (!auth.isAuthenticated.value || !auth.currentUser.value?.prvkey) {
|
||||
throw new Error('User not authenticated or private key not available')
|
||||
}
|
||||
|
||||
const pubkey = auth.currentUser.value.pubkey
|
||||
const prvkey = auth.currentUser.value.prvkey
|
||||
|
||||
if (!pubkey || !prvkey) {
|
||||
throw new Error('Public key or private key is missing')
|
||||
}
|
||||
|
||||
// Validate that we have proper hex strings
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(pubkey)) {
|
||||
throw new Error(`Invalid public key format: ${pubkey.substring(0, 10)}...`)
|
||||
}
|
||||
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(prvkey)) {
|
||||
throw new Error(`Invalid private key format: ${prvkey.substring(0, 10)}...`)
|
||||
}
|
||||
|
||||
console.log('🔑 Key debug:', {
|
||||
pubkey: pubkey.substring(0, 10) + '...',
|
||||
prvkey: prvkey.substring(0, 10) + '...',
|
||||
pubkeyIsHex: /^[0-9a-fA-F]{64}$/.test(pubkey),
|
||||
prvkeyIsHex: /^[0-9a-fA-F]{64}$/.test(prvkey),
|
||||
pubkeyLength: pubkey.length,
|
||||
prvkeyLength: prvkey.length,
|
||||
pubkeyType: typeof pubkey,
|
||||
prvkeyType: typeof prvkey,
|
||||
pubkeyIsString: typeof pubkey === 'string',
|
||||
prvkeyIsString: typeof prvkey === 'string'
|
||||
})
|
||||
|
||||
return {
|
||||
pubkey,
|
||||
prvkey
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a stall event (kind 30017) to Nostr
|
||||
*/
|
||||
async publishStall(stall: Stall): Promise<string> {
|
||||
const { prvkey } = this.getAuth()
|
||||
|
||||
const stallData: NostrmarketStall = {
|
||||
id: stall.id,
|
||||
name: stall.name,
|
||||
description: stall.description,
|
||||
currency: stall.currency,
|
||||
shipping: (stall.shipping || []).map(zone => ({
|
||||
id: zone.id,
|
||||
name: zone.name,
|
||||
cost: zone.cost,
|
||||
countries: []
|
||||
}))
|
||||
}
|
||||
|
||||
const eventTemplate: EventTemplate = {
|
||||
kind: 30017,
|
||||
tags: [
|
||||
['t', 'stall'],
|
||||
['t', 'nostrmarket']
|
||||
],
|
||||
content: JSON.stringify(stallData),
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
const prvkeyBytes = this.hexToUint8Array(prvkey)
|
||||
const event = finalizeEvent(eventTemplate, prvkeyBytes)
|
||||
const result = await relayHub.publishEvent(event)
|
||||
|
||||
console.log('Stall published to nostrmarket:', {
|
||||
stallId: stall.id,
|
||||
eventId: result,
|
||||
content: stallData
|
||||
})
|
||||
|
||||
return result.success.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a product event (kind 30018) to Nostr
|
||||
*/
|
||||
async publishProduct(product: Product): Promise<string> {
|
||||
const { prvkey } = this.getAuth()
|
||||
|
||||
const productData: NostrmarketProduct = {
|
||||
id: product.id,
|
||||
stall_id: product.stall_id,
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
images: product.images || [],
|
||||
categories: product.categories || [],
|
||||
price: product.price,
|
||||
quantity: product.quantity,
|
||||
currency: product.currency
|
||||
}
|
||||
|
||||
const eventTemplate: EventTemplate = {
|
||||
kind: 30018,
|
||||
tags: [
|
||||
['t', 'product'],
|
||||
['t', 'nostrmarket'],
|
||||
['t', 'stall', product.stall_id],
|
||||
...(product.categories || []).map(cat => ['t', cat])
|
||||
],
|
||||
content: JSON.stringify(productData),
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
const prvkeyBytes = this.hexToUint8Array(prvkey)
|
||||
const event = finalizeEvent(eventTemplate, prvkeyBytes)
|
||||
const result = await relayHub.publishEvent(event)
|
||||
|
||||
console.log('Product published to nostrmarket:', {
|
||||
productId: product.id,
|
||||
eventId: result,
|
||||
content: productData
|
||||
})
|
||||
|
||||
return result.success.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish an order event (kind 4 encrypted DM) to nostrmarket
|
||||
*/
|
||||
async publishOrder(order: Order, merchantPubkey: string): Promise<string> {
|
||||
const { prvkey } = this.getAuth()
|
||||
|
||||
// Convert order to nostrmarket format - exactly matching the specification
|
||||
const orderData = {
|
||||
type: 0, // DirectMessageType.CUSTOMER_ORDER
|
||||
id: order.id,
|
||||
items: order.items.map(item => ({
|
||||
product_id: item.productId,
|
||||
quantity: item.quantity
|
||||
})),
|
||||
contact: {
|
||||
name: order.contactInfo?.message || order.contactInfo?.email || 'Unknown',
|
||||
email: order.contactInfo?.email || ''
|
||||
// Remove phone field - not in nostrmarket specification
|
||||
},
|
||||
// Only include address if it's a physical good and address is provided
|
||||
...(order.shippingZone?.requiresPhysicalShipping && order.contactInfo?.address ? {
|
||||
address: order.contactInfo.address
|
||||
} : {}),
|
||||
shipping_id: order.shippingZone?.id || 'online'
|
||||
}
|
||||
|
||||
// Encrypt the message using NIP-04
|
||||
console.log('🔐 NIP-04 encryption debug:', {
|
||||
prvkeyType: typeof prvkey,
|
||||
prvkeyIsString: typeof prvkey === 'string',
|
||||
prvkeyLength: prvkey.length,
|
||||
prvkeySample: prvkey.substring(0, 10) + '...',
|
||||
merchantPubkeyType: typeof merchantPubkey,
|
||||
merchantPubkeyLength: merchantPubkey.length,
|
||||
orderDataString: JSON.stringify(orderData).substring(0, 50) + '...'
|
||||
})
|
||||
|
||||
let encryptedContent: string
|
||||
try {
|
||||
encryptedContent = await nip04.encrypt(prvkey, merchantPubkey, JSON.stringify(orderData))
|
||||
console.log('🔐 NIP-04 encryption successful:', {
|
||||
encryptedContentLength: encryptedContent.length,
|
||||
encryptedContentSample: encryptedContent.substring(0, 50) + '...'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('🔐 NIP-04 encryption failed:', error)
|
||||
throw error
|
||||
}
|
||||
|
||||
const eventTemplate: EventTemplate = {
|
||||
kind: 4, // Encrypted DM
|
||||
tags: [['p', merchantPubkey]], // Recipient (merchant)
|
||||
content: encryptedContent, // Use encrypted content
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
console.log('🔧 finalizeEvent debug:', {
|
||||
prvkeyType: typeof prvkey,
|
||||
prvkeyIsString: typeof prvkey === 'string',
|
||||
prvkeyLength: prvkey.length,
|
||||
prvkeySample: prvkey.substring(0, 10) + '...',
|
||||
encodedPrvkeyType: typeof new TextEncoder().encode(prvkey),
|
||||
encodedPrvkeyLength: new TextEncoder().encode(prvkey).length,
|
||||
eventTemplate
|
||||
})
|
||||
|
||||
// Convert hex string to Uint8Array properly
|
||||
const prvkeyBytes = this.hexToUint8Array(prvkey)
|
||||
console.log('🔧 prvkeyBytes debug:', {
|
||||
prvkeyBytesType: typeof prvkeyBytes,
|
||||
prvkeyBytesLength: prvkeyBytes.length,
|
||||
prvkeyBytesIsUint8Array: prvkeyBytes instanceof Uint8Array
|
||||
})
|
||||
|
||||
const event = finalizeEvent(eventTemplate, prvkeyBytes)
|
||||
const result = await relayHub.publishEvent(event)
|
||||
|
||||
console.log('Order published to nostrmarket:', {
|
||||
orderId: order.id,
|
||||
eventId: result,
|
||||
merchantPubkey,
|
||||
content: orderData,
|
||||
encryptedContent: encryptedContent.substring(0, 50) + '...'
|
||||
})
|
||||
|
||||
return result.success.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming payment request from merchant (type 1)
|
||||
*/
|
||||
async handlePaymentRequest(paymentRequest: NostrmarketPaymentRequest): Promise<void> {
|
||||
console.log('Received payment request from merchant:', {
|
||||
orderId: paymentRequest.id,
|
||||
message: paymentRequest.message,
|
||||
paymentOptions: paymentRequest.payment_options
|
||||
})
|
||||
|
||||
// Find the Lightning payment option
|
||||
const lightningOption = paymentRequest.payment_options.find(option => option.type === 'ln')
|
||||
if (!lightningOption) {
|
||||
console.error('No Lightning payment option found in payment request')
|
||||
return
|
||||
}
|
||||
|
||||
// Update the order in the store with payment request
|
||||
const { useMarketStore } = await import('@/stores/market')
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
const order = Object.values(marketStore.orders).find(o =>
|
||||
o.id === paymentRequest.id || o.originalOrderId === paymentRequest.id
|
||||
)
|
||||
|
||||
if (order) {
|
||||
// Update order with payment request details
|
||||
const updatedOrder = {
|
||||
...order,
|
||||
paymentRequest: lightningOption.link,
|
||||
paymentStatus: 'pending' as const,
|
||||
status: 'pending' as const, // Ensure status is pending for payment
|
||||
updatedAt: Math.floor(Date.now() / 1000),
|
||||
items: [...order.items] // Convert readonly to mutable
|
||||
}
|
||||
|
||||
// Generate QR code for the payment request
|
||||
try {
|
||||
const QRCode = await import('qrcode')
|
||||
const qrCodeDataUrl = await QRCode.toDataURL(lightningOption.link, {
|
||||
width: 256,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF'
|
||||
}
|
||||
})
|
||||
updatedOrder.qrCodeDataUrl = qrCodeDataUrl
|
||||
updatedOrder.qrCodeLoading = false
|
||||
updatedOrder.qrCodeError = null
|
||||
} catch (error) {
|
||||
console.error('Failed to generate QR code:', error)
|
||||
updatedOrder.qrCodeError = 'Failed to generate QR code'
|
||||
updatedOrder.qrCodeLoading = false
|
||||
}
|
||||
|
||||
marketStore.updateOrder(order.id, updatedOrder)
|
||||
|
||||
console.log('Order updated with payment request:', {
|
||||
orderId: paymentRequest.id,
|
||||
paymentRequest: lightningOption.link.substring(0, 50) + '...',
|
||||
status: updatedOrder.status,
|
||||
paymentStatus: updatedOrder.paymentStatus,
|
||||
hasQRCode: !!updatedOrder.qrCodeDataUrl
|
||||
})
|
||||
} else {
|
||||
console.warn('Payment request received for unknown order:', paymentRequest.id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming order status update from merchant (type 2)
|
||||
*/
|
||||
async handleOrderStatusUpdate(statusUpdate: NostrmarketOrderStatus): Promise<void> {
|
||||
console.log('Received order status update from merchant:', {
|
||||
orderId: statusUpdate.id,
|
||||
message: statusUpdate.message,
|
||||
paid: statusUpdate.paid,
|
||||
shipped: statusUpdate.shipped
|
||||
})
|
||||
|
||||
const { useMarketStore } = await import('@/stores/market')
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
const order = Object.values(marketStore.orders).find(o =>
|
||||
o.id === statusUpdate.id || o.originalOrderId === statusUpdate.id
|
||||
)
|
||||
|
||||
if (order) {
|
||||
// Update order status
|
||||
if (statusUpdate.paid !== undefined) {
|
||||
const newStatus = statusUpdate.paid ? 'paid' : 'pending'
|
||||
marketStore.updateOrderStatus(order.id, newStatus)
|
||||
|
||||
// Also update payment status
|
||||
const updatedOrder = {
|
||||
...order,
|
||||
paymentStatus: (statusUpdate.paid ? 'paid' : 'pending') as 'paid' | 'pending' | 'expired',
|
||||
paidAt: statusUpdate.paid ? Math.floor(Date.now() / 1000) : undefined,
|
||||
updatedAt: Math.floor(Date.now() / 1000),
|
||||
items: [...order.items] // Convert readonly to mutable
|
||||
}
|
||||
marketStore.updateOrder(order.id, updatedOrder)
|
||||
}
|
||||
|
||||
if (statusUpdate.shipped !== undefined) {
|
||||
// Update shipping status if you have that field
|
||||
const updatedOrder = {
|
||||
...order,
|
||||
shipped: statusUpdate.shipped,
|
||||
status: statusUpdate.shipped ? 'shipped' : order.status,
|
||||
updatedAt: Math.floor(Date.now() / 1000),
|
||||
items: [...order.items] // Convert readonly to mutable
|
||||
}
|
||||
marketStore.updateOrder(order.id, updatedOrder)
|
||||
}
|
||||
|
||||
console.log('Order status updated:', {
|
||||
orderId: statusUpdate.id,
|
||||
paid: statusUpdate.paid,
|
||||
shipped: statusUpdate.shipped,
|
||||
newStatus: statusUpdate.paid ? 'paid' : 'pending'
|
||||
})
|
||||
} else {
|
||||
console.warn('Status update received for unknown order:', statusUpdate.id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish all stalls and products for a merchant
|
||||
*/
|
||||
async publishMerchantCatalog(stalls: Stall[], products: Product[]): Promise<{
|
||||
stalls: Record<string, string>, // stallId -> eventId
|
||||
products: Record<string, string> // productId -> eventId
|
||||
}> {
|
||||
const results = {
|
||||
stalls: {} as Record<string, string>,
|
||||
products: {} as Record<string, string>
|
||||
}
|
||||
|
||||
// Publish stalls first
|
||||
for (const stall of stalls) {
|
||||
try {
|
||||
const eventId = await this.publishStall(stall)
|
||||
results.stalls[stall.id] = eventId
|
||||
} catch (error) {
|
||||
console.error(`Failed to publish stall ${stall.id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Publish products
|
||||
for (const product of products) {
|
||||
try {
|
||||
const eventId = await this.publishProduct(product)
|
||||
results.products[product.id] = eventId
|
||||
} catch (error) {
|
||||
console.error(`Failed to publish product ${product.id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const nostrmarketService = new NostrmarketService()
|
||||
884
src/modules/market/stores/market.ts
Normal file
884
src/modules/market/stores/market.ts
Normal file
|
|
@ -0,0 +1,884 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, readonly, watch } from 'vue'
|
||||
import { nostrOrders } from '@/composables/useNostrOrders'
|
||||
import { invoiceService } from '@/lib/services/invoiceService'
|
||||
import { paymentMonitor } from '@/lib/services/paymentMonitor'
|
||||
import { nostrmarketService } from '../services/nostrmarketService'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import type { LightningInvoice } from '@/lib/services/invoiceService'
|
||||
|
||||
|
||||
import type {
|
||||
Market, Stall, Product, Order, ShippingZone,
|
||||
OrderStatus, StallCart, FilterData, SortOptions,
|
||||
PaymentRequest, PaymentStatus
|
||||
} from '../types/market'
|
||||
// Import types that are used in the store implementation
|
||||
|
||||
export const useMarketStore = defineStore('market', () => {
|
||||
const auth = useAuth()
|
||||
|
||||
// Helper function to get user-specific storage key
|
||||
const getUserStorageKey = (baseKey: string) => {
|
||||
const userPubkey = auth.currentUser?.value?.pubkey
|
||||
return userPubkey ? `${baseKey}_${userPubkey}` : baseKey
|
||||
}
|
||||
// Core market state
|
||||
const markets = ref<Market[]>([])
|
||||
const stalls = ref<Stall[]>([])
|
||||
const products = ref<Product[]>([])
|
||||
const orders = ref<Record<string, Order>>({})
|
||||
const profiles = ref<Record<string, any>>({})
|
||||
|
||||
// Active selections
|
||||
const activeMarket = ref<Market | null>(null)
|
||||
const activeStall = ref<Stall | null>(null)
|
||||
const activeProduct = ref<Product | null>(null)
|
||||
|
||||
// UI state
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const searchText = ref('')
|
||||
const showFilterDetails = ref(false)
|
||||
|
||||
// Filtering and sorting
|
||||
const filterData = ref<FilterData>({
|
||||
categories: [],
|
||||
merchants: [],
|
||||
stalls: [],
|
||||
currency: null,
|
||||
priceFrom: null,
|
||||
priceTo: null,
|
||||
inStock: null,
|
||||
paymentMethods: []
|
||||
})
|
||||
|
||||
const sortOptions = ref<SortOptions>({
|
||||
field: 'name',
|
||||
order: 'asc'
|
||||
})
|
||||
|
||||
// Enhanced shopping cart with stall-specific carts
|
||||
const stallCarts = ref<Record<string, StallCart>>({})
|
||||
|
||||
// Legacy shopping cart (to be deprecated)
|
||||
const shoppingCart = ref<Record<string, { product: Product; quantity: number }>>({})
|
||||
|
||||
// Checkout state
|
||||
const checkoutCart = ref<StallCart | null>(null)
|
||||
const checkoutStall = ref<Stall | null>(null)
|
||||
const activeOrder = ref<Order | null>(null)
|
||||
|
||||
// Payment state
|
||||
const paymentRequest = ref<PaymentRequest | null>(null)
|
||||
const paymentStatus = ref<PaymentStatus | null>(null)
|
||||
|
||||
// Computed properties
|
||||
const filteredProducts = computed(() => {
|
||||
let filtered = products.value
|
||||
|
||||
// Search filter
|
||||
if (searchText.value) {
|
||||
const searchLower = searchText.value.toLowerCase()
|
||||
filtered = filtered.filter(product =>
|
||||
product.name.toLowerCase().includes(searchLower) ||
|
||||
product.description?.toLowerCase().includes(searchLower) ||
|
||||
product.stallName.toLowerCase().includes(searchLower)
|
||||
)
|
||||
}
|
||||
|
||||
// Category filter
|
||||
if (filterData.value.categories.length > 0) {
|
||||
filtered = filtered.filter(product =>
|
||||
product.categories?.some(cat => filterData.value.categories.includes(cat))
|
||||
)
|
||||
}
|
||||
|
||||
// Merchant filter
|
||||
if (filterData.value.merchants.length > 0) {
|
||||
filtered = filtered.filter(product =>
|
||||
filterData.value.merchants.includes(product.stall_id)
|
||||
)
|
||||
}
|
||||
|
||||
// Stall filter
|
||||
if (filterData.value.stalls.length > 0) {
|
||||
filtered = filtered.filter(product =>
|
||||
filterData.value.stalls.includes(product.stall_id)
|
||||
)
|
||||
}
|
||||
|
||||
// Currency filter
|
||||
if (filterData.value.currency) {
|
||||
filtered = filtered.filter(product =>
|
||||
product.currency === filterData.value.currency
|
||||
)
|
||||
}
|
||||
|
||||
// Price range filter
|
||||
if (filterData.value.priceFrom !== null) {
|
||||
filtered = filtered.filter(product =>
|
||||
product.price >= filterData.value.priceFrom!
|
||||
)
|
||||
}
|
||||
|
||||
if (filterData.value.priceTo !== null) {
|
||||
filtered = filtered.filter(product =>
|
||||
product.price <= filterData.value.priceTo!
|
||||
)
|
||||
}
|
||||
|
||||
// In stock filter
|
||||
if (filterData.value.inStock !== null) {
|
||||
filtered = filtered.filter(product =>
|
||||
filterData.value.inStock ? product.quantity > 0 : product.quantity === 0
|
||||
)
|
||||
}
|
||||
|
||||
// Payment methods filter
|
||||
if (filterData.value.paymentMethods.length > 0) {
|
||||
// For now, assume all products support Lightning payments
|
||||
// This can be enhanced later with product-specific payment method support
|
||||
filtered = filtered.filter(_product => true)
|
||||
}
|
||||
|
||||
// Sort
|
||||
filtered.sort((a, b) => {
|
||||
const aVal = a[sortOptions.value.field as keyof Product]
|
||||
const bVal = b[sortOptions.value.field as keyof Product]
|
||||
|
||||
if (typeof aVal === 'string' && typeof bVal === 'string') {
|
||||
return sortOptions.value.order === 'asc'
|
||||
? aVal.localeCompare(bVal)
|
||||
: bVal.localeCompare(aVal)
|
||||
}
|
||||
|
||||
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||
return sortOptions.value.order === 'asc'
|
||||
? aVal - bVal
|
||||
: bVal - aVal
|
||||
}
|
||||
|
||||
return 0
|
||||
})
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
const allCategories = computed(() => {
|
||||
const categories = new Set<string>()
|
||||
products.value.forEach(product => {
|
||||
product.categories?.forEach(cat => categories.add(cat))
|
||||
})
|
||||
return Array.from(categories).map(category => ({
|
||||
category,
|
||||
count: products.value.filter(p => p.categories?.includes(category)).length,
|
||||
selected: filterData.value.categories.includes(category)
|
||||
}))
|
||||
})
|
||||
|
||||
// Enhanced cart computed properties
|
||||
const allStallCarts = computed(() => Object.values(stallCarts.value))
|
||||
|
||||
const totalCartItems = computed(() => {
|
||||
return allStallCarts.value.reduce((total, cart) => {
|
||||
return total + cart.products.reduce((cartTotal, item) => cartTotal + item.quantity, 0)
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const totalCartValue = computed(() => {
|
||||
return allStallCarts.value.reduce((total, cart) => {
|
||||
return total + cart.subtotal
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const activeStallCart = computed(() => {
|
||||
if (!checkoutStall.value) return null
|
||||
return stallCarts.value[checkoutStall.value.id] || null
|
||||
})
|
||||
|
||||
// Legacy cart computed properties (to be deprecated)
|
||||
const cartTotal = computed(() => {
|
||||
return Object.values(shoppingCart.value).reduce((total, item) => {
|
||||
return total + (item.product.price * item.quantity)
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const cartItemCount = computed(() => {
|
||||
return Object.values(shoppingCart.value).reduce((count, item) => {
|
||||
return count + item.quantity
|
||||
}, 0)
|
||||
})
|
||||
|
||||
// Actions
|
||||
const setLoading = (loading: boolean) => {
|
||||
isLoading.value = loading
|
||||
}
|
||||
|
||||
const setError = (errorMessage: string | null) => {
|
||||
error.value = errorMessage
|
||||
}
|
||||
|
||||
const setSearchText = (text: string) => {
|
||||
searchText.value = text
|
||||
}
|
||||
|
||||
const setActiveMarket = (market: Market | null) => {
|
||||
activeMarket.value = market
|
||||
}
|
||||
|
||||
const setActiveStall = (stall: Stall | null) => {
|
||||
activeStall.value = stall
|
||||
}
|
||||
|
||||
const setActiveProduct = (product: Product | null) => {
|
||||
activeProduct.value = product
|
||||
}
|
||||
|
||||
const addProduct = (product: Product) => {
|
||||
const existingIndex = products.value.findIndex(p => p.id === product.id)
|
||||
if (existingIndex >= 0) {
|
||||
products.value[existingIndex] = product
|
||||
} else {
|
||||
products.value.push(product)
|
||||
}
|
||||
}
|
||||
|
||||
const addStall = (stall: Stall) => {
|
||||
const existingIndex = stalls.value.findIndex(s => s.id === stall.id)
|
||||
if (existingIndex >= 0) {
|
||||
stalls.value[existingIndex] = stall
|
||||
} else {
|
||||
stalls.value.push(stall)
|
||||
}
|
||||
}
|
||||
|
||||
const addMarket = (market: Market) => {
|
||||
const existingIndex = markets.value.findIndex(m => m.d === market.d)
|
||||
if (existingIndex >= 0) {
|
||||
markets.value[existingIndex] = market
|
||||
} else {
|
||||
markets.value.push(market)
|
||||
}
|
||||
}
|
||||
|
||||
const addToCart = (product: Product, quantity: number = 1) => {
|
||||
const existing = shoppingCart.value[product.id]
|
||||
if (existing) {
|
||||
existing.quantity += quantity
|
||||
} else {
|
||||
shoppingCart.value[product.id] = { product, quantity }
|
||||
}
|
||||
}
|
||||
|
||||
const removeFromCart = (productId: string) => {
|
||||
delete shoppingCart.value[productId]
|
||||
}
|
||||
|
||||
const updateCartQuantity = (productId: string, quantity: number) => {
|
||||
if (quantity <= 0) {
|
||||
removeFromCart(productId)
|
||||
} else {
|
||||
const item = shoppingCart.value[productId]
|
||||
if (item) {
|
||||
item.quantity = quantity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const clearCart = () => {
|
||||
shoppingCart.value = {}
|
||||
}
|
||||
|
||||
// Enhanced cart management methods
|
||||
const addToStallCart = (product: Product, quantity: number = 1) => {
|
||||
const stallId = product.stall_id
|
||||
const stall = stalls.value.find(s => s.id === stallId)
|
||||
|
||||
if (!stall) {
|
||||
console.error('Stall not found for product:', product.id)
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize stall cart if it doesn't exist
|
||||
if (!stallCarts.value[stallId]) {
|
||||
stallCarts.value[stallId] = {
|
||||
id: stallId,
|
||||
merchant: stall.pubkey,
|
||||
products: [],
|
||||
subtotal: 0,
|
||||
currency: stall.currency || 'sats'
|
||||
}
|
||||
}
|
||||
|
||||
const cart = stallCarts.value[stallId]
|
||||
const existingItem = cart.products.find(item => item.product.id === product.id)
|
||||
|
||||
if (existingItem) {
|
||||
existingItem.quantity = Math.min(existingItem.quantity + quantity, product.quantity)
|
||||
} else {
|
||||
cart.products.push({
|
||||
product,
|
||||
quantity: Math.min(quantity, product.quantity),
|
||||
stallId
|
||||
})
|
||||
}
|
||||
|
||||
// Update cart subtotal
|
||||
updateStallCartSubtotal(stallId)
|
||||
}
|
||||
|
||||
const removeFromStallCart = (stallId: string, productId: string) => {
|
||||
const cart = stallCarts.value[stallId]
|
||||
if (cart) {
|
||||
cart.products = cart.products.filter(item => item.product.id !== productId)
|
||||
updateStallCartSubtotal(stallId)
|
||||
|
||||
// Remove empty carts
|
||||
if (cart.products.length === 0) {
|
||||
delete stallCarts.value[stallId]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateStallCartQuantity = (stallId: string, productId: string, quantity: number) => {
|
||||
const cart = stallCarts.value[stallId]
|
||||
if (cart) {
|
||||
if (quantity <= 0) {
|
||||
removeFromStallCart(stallId, productId)
|
||||
} else {
|
||||
const item = cart.products.find(item => item.product.id === productId)
|
||||
if (item) {
|
||||
item.quantity = Math.min(quantity, item.product.quantity)
|
||||
updateStallCartSubtotal(stallId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateStallCartSubtotal = (stallId: string) => {
|
||||
const cart = stallCarts.value[stallId]
|
||||
if (cart) {
|
||||
cart.subtotal = cart.products.reduce((total, item) => {
|
||||
return total + (item.product.price * item.quantity)
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
const clearStallCart = (stallId: string) => {
|
||||
delete stallCarts.value[stallId]
|
||||
}
|
||||
|
||||
const clearAllStallCarts = () => {
|
||||
stallCarts.value = {}
|
||||
}
|
||||
|
||||
const setCheckoutCart = (stallId: string) => {
|
||||
const cart = stallCarts.value[stallId]
|
||||
const stall = stalls.value.find(s => s.id === stallId)
|
||||
|
||||
if (cart && stall) {
|
||||
checkoutCart.value = cart
|
||||
checkoutStall.value = stall
|
||||
}
|
||||
}
|
||||
|
||||
const clearCheckout = () => {
|
||||
checkoutCart.value = null
|
||||
checkoutStall.value = null
|
||||
activeOrder.value = null
|
||||
paymentRequest.value = null
|
||||
paymentStatus.value = null
|
||||
}
|
||||
|
||||
const setShippingZone = (stallId: string, shippingZone: ShippingZone) => {
|
||||
const cart = stallCarts.value[stallId]
|
||||
if (cart) {
|
||||
cart.shippingZone = shippingZone
|
||||
}
|
||||
}
|
||||
|
||||
// Order management methods
|
||||
const createOrder = (orderData: Omit<Order, 'id' | 'createdAt' | 'updatedAt'> & { id?: string }) => {
|
||||
const order: Order = {
|
||||
...orderData,
|
||||
id: orderData.id || generateOrderId(),
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
updatedAt: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
orders.value[order.id] = order
|
||||
activeOrder.value = order
|
||||
|
||||
// Save to localStorage for persistence
|
||||
saveOrdersToStorage()
|
||||
|
||||
return order
|
||||
}
|
||||
|
||||
const createAndPlaceOrder = async (orderData: Omit<Order, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||
try {
|
||||
// Create the order
|
||||
const order = createOrder(orderData)
|
||||
|
||||
// Attempt to publish order via nostrmarket protocol
|
||||
let nostrmarketSuccess = false
|
||||
let nostrmarketError: string | undefined
|
||||
|
||||
try {
|
||||
// Publish the order event to nostrmarket using proper protocol
|
||||
const eventId = await nostrmarketService.publishOrder(order, order.sellerPubkey)
|
||||
nostrmarketSuccess = true
|
||||
order.sentViaNostr = true
|
||||
order.nostrEventId = eventId
|
||||
|
||||
console.log('Order published via nostrmarket successfully:', eventId)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown nostrmarket error'
|
||||
order.nostrError = errorMessage
|
||||
order.sentViaNostr = false
|
||||
console.error('Failed to publish order via nostrmarket:', errorMessage)
|
||||
}
|
||||
|
||||
// Update order status to 'pending'
|
||||
updateOrderStatus(order.id, 'pending')
|
||||
|
||||
// Clear the checkout cart
|
||||
if (checkoutCart.value) {
|
||||
clearStallCart(checkoutCart.value.id)
|
||||
}
|
||||
|
||||
// Clear checkout state
|
||||
clearCheckout()
|
||||
|
||||
// Show appropriate success/error message
|
||||
if (nostrmarketSuccess) {
|
||||
console.log('Order created and published via nostrmarket successfully')
|
||||
} else {
|
||||
console.warn('Order created but nostrmarket publishing failed:', nostrmarketError)
|
||||
}
|
||||
|
||||
return order
|
||||
} catch (error) {
|
||||
console.error('Failed to create and place order:', error)
|
||||
throw new Error('Failed to place order. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
// nostrmarket integration methods
|
||||
const publishToNostrmarket = async () => {
|
||||
try {
|
||||
console.log('Publishing merchant catalog to nostrmarket...')
|
||||
|
||||
// Get all stalls and products
|
||||
const allStalls = Object.values(stalls.value)
|
||||
const allProducts = Object.values(products.value)
|
||||
|
||||
if (allStalls.length === 0) {
|
||||
console.warn('No stalls to publish to nostrmarket')
|
||||
return null
|
||||
}
|
||||
|
||||
if (allProducts.length === 0) {
|
||||
console.warn('No products to publish to nostrmarket')
|
||||
return null
|
||||
}
|
||||
|
||||
// Publish to nostrmarket
|
||||
const result = await nostrmarketService.publishMerchantCatalog(allStalls, allProducts)
|
||||
|
||||
console.log('Successfully published to nostrmarket:', result)
|
||||
|
||||
// Update stalls and products with event IDs
|
||||
for (const [stallId, eventId] of Object.entries(result.stalls)) {
|
||||
const stall = stalls.value.find(s => s.id === stallId)
|
||||
if (stall) {
|
||||
stall.nostrEventId = eventId
|
||||
}
|
||||
}
|
||||
|
||||
for (const [productId, eventId] of Object.entries(result.products)) {
|
||||
const product = products.value.find(p => p.id === productId)
|
||||
if (product) {
|
||||
product.nostrEventId = eventId
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Failed to publish to nostrmarket:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Invoice management methods
|
||||
const createLightningInvoice = async (orderId: string, adminKey: string): Promise<LightningInvoice | null> => {
|
||||
try {
|
||||
const order = orders.value[orderId]
|
||||
if (!order) {
|
||||
throw new Error('Order not found')
|
||||
}
|
||||
|
||||
// Create Lightning invoice with admin key and nostrmarket tag
|
||||
// For nostrmarket compatibility, we need to use the original order ID if it exists
|
||||
// If no originalOrderId exists, this order was created in the web-app, so use the current orderId
|
||||
const orderIdForInvoice = order.originalOrderId || orderId
|
||||
console.log('Creating invoice with order ID:', {
|
||||
webAppOrderId: orderId,
|
||||
originalOrderId: order.originalOrderId,
|
||||
orderIdForInvoice: orderIdForInvoice,
|
||||
hasOriginalOrderId: !!order.originalOrderId
|
||||
})
|
||||
|
||||
const invoice = await invoiceService.createInvoice(order, adminKey, {
|
||||
tag: "nostrmarket",
|
||||
order_id: orderIdForInvoice, // Use original Nostr order ID for nostrmarket compatibility
|
||||
merchant_pubkey: order.sellerPubkey,
|
||||
buyer_pubkey: order.buyerPubkey
|
||||
})
|
||||
|
||||
// Update order with invoice details
|
||||
order.lightningInvoice = invoice
|
||||
order.paymentHash = invoice.payment_hash
|
||||
order.paymentStatus = 'pending'
|
||||
order.paymentRequest = invoice.bolt11 // Use bolt11 field from LNBits response
|
||||
|
||||
// Save to localStorage after invoice creation
|
||||
saveOrdersToStorage()
|
||||
|
||||
// Start monitoring payment
|
||||
await paymentMonitor.startMonitoring(order, invoice)
|
||||
|
||||
// Set up payment update callback
|
||||
paymentMonitor.onPaymentUpdate(orderId, (update) => {
|
||||
handlePaymentUpdate(orderId, update)
|
||||
})
|
||||
|
||||
console.log('Lightning invoice created for order:', {
|
||||
orderId,
|
||||
originalOrderId: order.originalOrderId,
|
||||
nostrmarketOrderId: order.originalOrderId || orderId,
|
||||
paymentHash: invoice.payment_hash,
|
||||
amount: invoice.amount
|
||||
})
|
||||
|
||||
return invoice
|
||||
} catch (error) {
|
||||
console.error('Failed to create Lightning invoice:', error)
|
||||
throw new Error('Failed to create payment invoice')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePaymentUpdate = (orderId: string, update: any) => {
|
||||
const order = orders.value[orderId]
|
||||
if (!order) return
|
||||
|
||||
// Update order payment status
|
||||
order.paymentStatus = update.status
|
||||
if (update.status === 'paid') {
|
||||
order.paidAt = update.paidAt
|
||||
updateOrderStatus(orderId, 'paid')
|
||||
|
||||
// Send payment confirmation via Nostr
|
||||
sendPaymentConfirmation(order)
|
||||
}
|
||||
|
||||
// Save to localStorage after payment update
|
||||
saveOrdersToStorage()
|
||||
|
||||
console.log('Payment status updated for order:', {
|
||||
orderId,
|
||||
status: update.status,
|
||||
amount: update.amount
|
||||
})
|
||||
}
|
||||
|
||||
const sendPaymentConfirmation = async (order: Order) => {
|
||||
try {
|
||||
if (!nostrOrders.isReady.value) {
|
||||
console.warn('Nostr not ready for payment confirmation')
|
||||
return
|
||||
}
|
||||
|
||||
// Create payment confirmation message
|
||||
// const confirmation = {
|
||||
// type: 'payment_confirmation',
|
||||
// orderId: order.id,
|
||||
// paymentHash: order.paymentHash,
|
||||
// amount: order.total,
|
||||
// currency: order.currency,
|
||||
// paidAt: order.paidAt,
|
||||
// message: 'Payment received! Your order is being processed.'
|
||||
// }
|
||||
|
||||
// Send confirmation to customer
|
||||
await nostrOrders.publishOrderEvent(order, order.buyerPubkey)
|
||||
|
||||
console.log('Payment confirmation sent via Nostr')
|
||||
} catch (error) {
|
||||
console.error('Failed to send payment confirmation:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getOrderInvoice = (orderId: string): LightningInvoice | null => {
|
||||
const order = orders.value[orderId]
|
||||
return order?.lightningInvoice || null
|
||||
}
|
||||
|
||||
const getOrderPaymentStatus = (orderId: string): 'pending' | 'paid' | 'expired' | null => {
|
||||
const order = orders.value[orderId]
|
||||
return order?.paymentStatus || null
|
||||
}
|
||||
|
||||
const updateOrderStatus = (orderId: string, status: OrderStatus) => {
|
||||
const order = orders.value[orderId]
|
||||
if (order) {
|
||||
order.status = status
|
||||
order.updatedAt = Date.now() / 1000
|
||||
saveOrdersToStorage()
|
||||
}
|
||||
}
|
||||
|
||||
const updateOrder = (orderId: string, updatedOrder: Partial<Order>) => {
|
||||
const order = orders.value[orderId]
|
||||
if (order) {
|
||||
Object.assign(order, updatedOrder)
|
||||
order.updatedAt = Date.now() / 1000
|
||||
saveOrdersToStorage()
|
||||
}
|
||||
}
|
||||
|
||||
const setPaymentRequest = (request: PaymentRequest) => {
|
||||
paymentRequest.value = request
|
||||
}
|
||||
|
||||
const setPaymentStatus = (status: PaymentStatus) => {
|
||||
paymentStatus.value = status
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
const generateOrderId = () => {
|
||||
return `order_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
}
|
||||
|
||||
// Persistence methods
|
||||
const saveOrdersToStorage = () => {
|
||||
try {
|
||||
const storageKey = getUserStorageKey('market_orders')
|
||||
localStorage.setItem(storageKey, JSON.stringify(orders.value))
|
||||
console.log('Saved orders to localStorage with key:', storageKey)
|
||||
} catch (error) {
|
||||
console.warn('Failed to save orders to localStorage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadOrdersFromStorage = () => {
|
||||
try {
|
||||
const storageKey = getUserStorageKey('market_orders')
|
||||
const stored = localStorage.getItem(storageKey)
|
||||
if (stored) {
|
||||
const parsedOrders = JSON.parse(stored)
|
||||
orders.value = parsedOrders
|
||||
console.log('Loaded orders from localStorage with key:', storageKey, 'Orders count:', Object.keys(parsedOrders).length)
|
||||
} else {
|
||||
console.log('No orders found in localStorage for key:', storageKey)
|
||||
// Clear any existing orders when switching users
|
||||
orders.value = {}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load orders from localStorage:', error)
|
||||
// Clear orders on error
|
||||
orders.value = {}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear orders when user changes
|
||||
const clearOrdersForUserChange = () => {
|
||||
orders.value = {}
|
||||
console.log('Cleared orders for user change')
|
||||
}
|
||||
|
||||
// Payment utility methods
|
||||
const calculateOrderTotal = (cart: StallCart, shippingZone?: ShippingZone) => {
|
||||
const subtotal = cart.subtotal
|
||||
const shippingCost = shippingZone?.cost || 0
|
||||
return subtotal + shippingCost
|
||||
}
|
||||
|
||||
const validateCartForCheckout = (stallId: string): { valid: boolean; errors: string[] } => {
|
||||
const cart = stallCarts.value[stallId]
|
||||
const errors: string[] = []
|
||||
|
||||
if (!cart || cart.products.length === 0) {
|
||||
errors.push('Cart is empty')
|
||||
return { valid: false, errors }
|
||||
}
|
||||
|
||||
// Check if all products are still in stock
|
||||
for (const item of cart.products) {
|
||||
if (item.quantity > item.product.quantity) {
|
||||
errors.push(`${item.product.name} is out of stock`)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if cart has shipping zone selected
|
||||
if (!cart.shippingZone) {
|
||||
errors.push('Please select a shipping zone')
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors }
|
||||
}
|
||||
|
||||
const getCartSummary = (stallId: string) => {
|
||||
const cart = stallCarts.value[stallId]
|
||||
if (!cart) return null
|
||||
|
||||
const itemCount = cart.products.reduce((total, item) => total + item.quantity, 0)
|
||||
const subtotal = cart.subtotal
|
||||
const shippingCost = cart.shippingZone?.cost || 0
|
||||
const total = subtotal + shippingCost
|
||||
|
||||
return {
|
||||
itemCount,
|
||||
subtotal,
|
||||
shippingCost,
|
||||
total,
|
||||
currency: cart.currency
|
||||
}
|
||||
}
|
||||
|
||||
const updateFilterData = (newFilterData: Partial<FilterData>) => {
|
||||
filterData.value = { ...filterData.value, ...newFilterData }
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
filterData.value = {
|
||||
categories: [],
|
||||
merchants: [],
|
||||
stalls: [],
|
||||
currency: null,
|
||||
priceFrom: null,
|
||||
priceTo: null,
|
||||
inStock: null,
|
||||
paymentMethods: []
|
||||
}
|
||||
}
|
||||
|
||||
const toggleCategoryFilter = (category: string) => {
|
||||
const index = filterData.value.categories.indexOf(category)
|
||||
if (index >= 0) {
|
||||
filterData.value.categories.splice(index, 1)
|
||||
} else {
|
||||
filterData.value.categories.push(category)
|
||||
}
|
||||
}
|
||||
|
||||
const updateSortOptions = (field: string, order: 'asc' | 'desc' = 'asc') => {
|
||||
sortOptions.value = { field, order }
|
||||
}
|
||||
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
if (currency === 'sat') {
|
||||
return `${price} sats`
|
||||
}
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase()
|
||||
}).format(price)
|
||||
}
|
||||
|
||||
// Initialize orders from localStorage
|
||||
loadOrdersFromStorage()
|
||||
|
||||
// Watch for user changes and reload orders
|
||||
watch(() => auth.currentUser?.value?.pubkey, (newPubkey, oldPubkey) => {
|
||||
if (newPubkey !== oldPubkey) {
|
||||
console.log('User changed, clearing and reloading orders. Old:', oldPubkey, 'New:', newPubkey)
|
||||
clearOrdersForUserChange()
|
||||
loadOrdersFromStorage()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
// State
|
||||
markets: readonly(markets),
|
||||
stalls: readonly(stalls),
|
||||
products: readonly(products),
|
||||
orders: readonly(orders),
|
||||
profiles: readonly(profiles),
|
||||
activeMarket: readonly(activeMarket),
|
||||
activeStall: readonly(activeStall),
|
||||
activeProduct: readonly(activeProduct),
|
||||
isLoading: readonly(isLoading),
|
||||
error: readonly(error),
|
||||
searchText: readonly(searchText),
|
||||
showFilterDetails: readonly(showFilterDetails),
|
||||
filterData: readonly(filterData),
|
||||
sortOptions: readonly(sortOptions),
|
||||
shoppingCart: readonly(shoppingCart),
|
||||
stallCarts: readonly(stallCarts),
|
||||
checkoutCart: readonly(checkoutCart),
|
||||
checkoutStall: readonly(checkoutStall),
|
||||
activeOrder: readonly(activeOrder),
|
||||
paymentRequest: readonly(paymentRequest),
|
||||
paymentStatus: readonly(paymentStatus),
|
||||
|
||||
// Computed
|
||||
filteredProducts,
|
||||
allCategories,
|
||||
allStallCarts,
|
||||
totalCartItems,
|
||||
totalCartValue,
|
||||
activeStallCart,
|
||||
cartTotal,
|
||||
cartItemCount,
|
||||
|
||||
// Actions
|
||||
setLoading,
|
||||
setError,
|
||||
setSearchText,
|
||||
setActiveMarket,
|
||||
setActiveStall,
|
||||
setActiveProduct,
|
||||
addProduct,
|
||||
addStall,
|
||||
addMarket,
|
||||
addToCart,
|
||||
removeFromCart,
|
||||
updateCartQuantity,
|
||||
clearCart,
|
||||
updateFilterData,
|
||||
clearFilters,
|
||||
toggleCategoryFilter,
|
||||
updateSortOptions,
|
||||
formatPrice,
|
||||
addToStallCart,
|
||||
removeFromStallCart,
|
||||
updateStallCartQuantity,
|
||||
updateStallCartSubtotal,
|
||||
clearStallCart,
|
||||
clearAllStallCarts,
|
||||
setCheckoutCart,
|
||||
clearCheckout,
|
||||
setShippingZone,
|
||||
createOrder,
|
||||
updateOrderStatus,
|
||||
setPaymentRequest,
|
||||
setPaymentStatus,
|
||||
generateOrderId,
|
||||
calculateOrderTotal,
|
||||
validateCartForCheckout,
|
||||
getCartSummary,
|
||||
createAndPlaceOrder,
|
||||
createLightningInvoice,
|
||||
handlePaymentUpdate,
|
||||
sendPaymentConfirmation,
|
||||
getOrderInvoice,
|
||||
getOrderPaymentStatus,
|
||||
updateOrder,
|
||||
saveOrdersToStorage,
|
||||
loadOrdersFromStorage,
|
||||
clearOrdersForUserChange,
|
||||
publishToNostrmarket
|
||||
}
|
||||
})
|
||||
150
src/modules/market/types/market.ts
Normal file
150
src/modules/market/types/market.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
export interface Market {
|
||||
d: string
|
||||
pubkey: string
|
||||
relays: string[]
|
||||
selected: boolean
|
||||
opts: {
|
||||
name: string
|
||||
description?: string
|
||||
logo?: string
|
||||
banner?: string
|
||||
merchants: string[]
|
||||
ui?: Record<string, any>
|
||||
}
|
||||
}
|
||||
|
||||
export interface Stall {
|
||||
id: string
|
||||
pubkey: string
|
||||
name: string
|
||||
description?: string
|
||||
logo?: string
|
||||
categories?: string[]
|
||||
shipping?: ShippingZone[]
|
||||
currency: string
|
||||
nostrEventId?: string
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: string
|
||||
stall_id: string
|
||||
stallName: string
|
||||
name: string
|
||||
description?: string
|
||||
price: number
|
||||
currency: string
|
||||
quantity: number
|
||||
images?: string[]
|
||||
categories?: string[]
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
nostrEventId?: string
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: string
|
||||
cartId: string
|
||||
stallId: string
|
||||
buyerPubkey: string
|
||||
sellerPubkey: string
|
||||
status: OrderStatus
|
||||
items: OrderItem[]
|
||||
contactInfo: ContactInfo
|
||||
shippingZone: ShippingZone
|
||||
paymentRequest?: string
|
||||
paymentMethod: PaymentMethod
|
||||
subtotal: number
|
||||
shippingCost: number
|
||||
total: number
|
||||
currency: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
nostrEventId?: string
|
||||
nostrEventSig?: string
|
||||
sentViaNostr?: boolean
|
||||
nostrError?: string
|
||||
originalOrderId?: string
|
||||
lightningInvoice?: any
|
||||
paymentHash?: string
|
||||
paidAt?: number
|
||||
paymentStatus?: 'pending' | 'paid' | 'expired'
|
||||
qrCodeDataUrl?: string
|
||||
qrCodeLoading?: boolean
|
||||
qrCodeError?: string | null
|
||||
showQRCode?: boolean
|
||||
}
|
||||
|
||||
export interface OrderItem {
|
||||
productId: string
|
||||
productName: string
|
||||
quantity: number
|
||||
price: number
|
||||
currency: string
|
||||
}
|
||||
|
||||
export interface ContactInfo {
|
||||
address?: string
|
||||
email?: string
|
||||
message?: string
|
||||
npub?: string
|
||||
}
|
||||
|
||||
export interface ShippingZone {
|
||||
id: string
|
||||
name: string
|
||||
cost: number
|
||||
currency: string
|
||||
description?: string
|
||||
estimatedDays?: string
|
||||
requiresPhysicalShipping?: boolean
|
||||
}
|
||||
|
||||
export type OrderStatus = 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled' | 'processing'
|
||||
|
||||
export type PaymentMethod = 'lightning' | 'btc_onchain'
|
||||
|
||||
export interface CartItem {
|
||||
product: Product
|
||||
quantity: number
|
||||
stallId: string
|
||||
}
|
||||
|
||||
export interface StallCart {
|
||||
id: string
|
||||
merchant: string
|
||||
products: CartItem[]
|
||||
subtotal: number
|
||||
shippingZone?: ShippingZone
|
||||
currency: string
|
||||
}
|
||||
|
||||
export interface FilterData {
|
||||
categories: string[]
|
||||
merchants: string[]
|
||||
stalls: string[]
|
||||
currency: string | null
|
||||
priceFrom: number | null
|
||||
priceTo: number | null
|
||||
inStock: boolean | null
|
||||
paymentMethods: PaymentMethod[]
|
||||
}
|
||||
|
||||
export interface SortOptions {
|
||||
field: string
|
||||
order: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export interface PaymentRequest {
|
||||
paymentRequest: string
|
||||
amount: number
|
||||
currency: string
|
||||
expiresAt: number
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface PaymentStatus {
|
||||
paid: boolean
|
||||
amountPaid: number
|
||||
paidAt?: number
|
||||
transactionId?: string
|
||||
}
|
||||
125
src/modules/market/views/MarketDashboard.vue
Normal file
125
src/modules/market/views/MarketDashboard.vue
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-foreground">Market Dashboard</h1>
|
||||
<p class="text-muted-foreground mt-2">
|
||||
Manage your market activities as both a customer and merchant
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Tabs -->
|
||||
<div class="mb-6">
|
||||
<nav class="flex space-x-8 border-b border-border">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
@click="activeTab = tab.id"
|
||||
:class="[
|
||||
'py-2 px-1 border-b-2 font-medium text-sm transition-colors',
|
||||
activeTab === tab.id
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<component :is="tab.icon" class="w-4 h-4" />
|
||||
<span>{{ tab.name }}</span>
|
||||
<Badge v-if="tab.badge" variant="secondary" class="text-xs">
|
||||
{{ tab.badge }}
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="min-h-[600px]">
|
||||
<!-- Overview Tab -->
|
||||
<div v-if="activeTab === 'overview'" class="space-y-6">
|
||||
<DashboardOverview />
|
||||
</div>
|
||||
|
||||
<!-- My Orders Tab (Customer) -->
|
||||
<div v-else-if="activeTab === 'orders'" class="space-y-6">
|
||||
<OrderHistory />
|
||||
</div>
|
||||
|
||||
<!-- My Store Tab (Merchant) -->
|
||||
<div v-else-if="activeTab === 'store'" class="space-y-6">
|
||||
<MerchantStore />
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<div v-else-if="activeTab === 'settings'" class="space-y-6">
|
||||
<MarketSettings />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
BarChart3,
|
||||
Package,
|
||||
Store,
|
||||
Settings,
|
||||
|
||||
} from 'lucide-vue-next'
|
||||
import DashboardOverview from '../components/DashboardOverview.vue'
|
||||
import OrderHistory from '../components/OrderHistory.vue'
|
||||
import MerchantStore from '../components/MerchantStore.vue'
|
||||
import MarketSettings from '../components/MarketSettings.vue'
|
||||
|
||||
// const auth = useAuth()
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
// Local state
|
||||
const activeTab = ref('overview')
|
||||
|
||||
// Computed properties for tab badges
|
||||
const orderCount = computed(() => Object.keys(marketStore.orders).length)
|
||||
const pendingOrders = computed(() =>
|
||||
Object.values(marketStore.orders).filter(o => o.status === 'pending').length
|
||||
)
|
||||
// const pendingPayments = computed(() =>
|
||||
// Object.values(marketStore.orders).filter(o => o.paymentStatus === 'pending').length
|
||||
// )
|
||||
|
||||
// Dashboard tabs
|
||||
const tabs = computed(() => [
|
||||
{
|
||||
id: 'overview',
|
||||
name: 'Overview',
|
||||
icon: BarChart3,
|
||||
badge: null
|
||||
},
|
||||
{
|
||||
id: 'orders',
|
||||
name: 'My Orders',
|
||||
icon: Package,
|
||||
badge: orderCount.value > 0 ? orderCount.value : null
|
||||
},
|
||||
{
|
||||
id: 'store',
|
||||
name: 'My Store',
|
||||
icon: Store,
|
||||
badge: pendingOrders.value > 0 ? pendingOrders.value : null
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
name: 'Settings',
|
||||
icon: Settings,
|
||||
badge: null
|
||||
}
|
||||
])
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
console.log('Market Dashboard mounted')
|
||||
})
|
||||
</script>
|
||||
|
||||
187
src/modules/market/views/MarketPage.vue
Normal file
187
src/modules/market/views/MarketPage.vue
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Loading State -->
|
||||
<div v-if="!isMarketReady && ((marketStore.isLoading ?? false) || marketPreloader.isPreloading)" class="flex justify-center items-center min-h-64">
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
<p class="text-gray-600">
|
||||
{{ marketPreloader.isPreloading ? 'Preloading market...' : 'Loading market...' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="(marketStore.error || marketPreloader.preloadError) && marketStore.products.length === 0" class="flex justify-center items-center min-h-64">
|
||||
<div class="text-center">
|
||||
<h2 class="text-2xl font-bold text-red-600 mb-4">Failed to load market</h2>
|
||||
<p class="text-gray-600 mb-4">{{ marketStore.error || marketPreloader.preloadError }}</p>
|
||||
<Button @click="retryLoadMarket" variant="outline">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Market Content -->
|
||||
<div v-else>
|
||||
<!-- Market Header -->
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div class="flex items-center space-x-4">
|
||||
<Avatar v-if="marketStore.activeMarket?.opts?.logo">
|
||||
<AvatarImage :src="marketStore.activeMarket.opts.logo" />
|
||||
<AvatarFallback>M</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">
|
||||
{{ marketStore.activeMarket?.opts?.name || 'Market' }}
|
||||
</h1>
|
||||
<p v-if="marketStore.activeMarket?.opts?.description" class="text-gray-600">
|
||||
{{ marketStore.activeMarket.opts.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="flex-1 max-w-md ml-8">
|
||||
<Input
|
||||
v-model="marketStore.searchText"
|
||||
type="text"
|
||||
placeholder="Search products..."
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category Filters -->
|
||||
<div v-if="marketStore.allCategories.length > 0" class="mb-6">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge
|
||||
v-for="category in marketStore.allCategories"
|
||||
:key="category.category"
|
||||
:variant="category.selected ? 'default' : 'secondary'"
|
||||
class="cursor-pointer hover:bg-blue-100"
|
||||
@click="marketStore.toggleCategoryFilter(category.category)"
|
||||
>
|
||||
{{ category.category }}
|
||||
<span class="ml-1 text-xs">({{ category.count }})</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Products State -->
|
||||
<div v-if="isMarketReady && marketStore.filteredProducts.length === 0 && !(marketStore.isLoading ?? false)" class="text-center py-12">
|
||||
<h3 class="text-xl font-semibold text-gray-600 mb-2">No products found</h3>
|
||||
<p class="text-gray-500">Try adjusting your search or filters</p>
|
||||
</div>
|
||||
|
||||
<!-- Product Grid -->
|
||||
<div v-if="isMarketReady && marketStore.filteredProducts.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<ProductCard
|
||||
v-for="product in marketStore.filteredProducts"
|
||||
:key="product.id"
|
||||
:product="product"
|
||||
@add-to-cart="addToCart"
|
||||
@view-details="viewProduct"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Cart Summary -->
|
||||
<div v-if="marketStore.totalCartItems > 0" class="fixed bottom-4 right-4">
|
||||
<Button @click="viewCart" class="shadow-lg">
|
||||
<ShoppingCart class="w-5 h-5 mr-2" />
|
||||
Cart ({{ marketStore.totalCartItems }})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { useMarket } from '@/composables/useMarket'
|
||||
import { useMarketPreloader } from '@/composables/useMarketPreloader'
|
||||
import { config } from '@/lib/config'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { ShoppingCart } from 'lucide-vue-next'
|
||||
import ProductCard from '../components/ProductCard.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const marketStore = useMarketStore()
|
||||
const market = useMarket()
|
||||
const marketPreloader = useMarketPreloader()
|
||||
|
||||
let unsubscribe: (() => void) | null = null
|
||||
|
||||
// Check if we need to load market data
|
||||
const needsToLoadMarket = computed(() => {
|
||||
return !marketPreloader.isPreloaded.value &&
|
||||
!marketPreloader.isPreloading.value &&
|
||||
marketStore.products.length === 0
|
||||
})
|
||||
|
||||
// Check if market data is ready (either preloaded or loaded)
|
||||
const isMarketReady = computed(() => {
|
||||
const isLoading = marketStore.isLoading ?? false
|
||||
const ready = marketPreloader.isPreloaded.value ||
|
||||
(marketStore.products.length > 0 && !isLoading)
|
||||
|
||||
return ready
|
||||
})
|
||||
|
||||
const loadMarket = async () => {
|
||||
try {
|
||||
const naddr = config.market.defaultNaddr
|
||||
if (!naddr) {
|
||||
throw new Error('No market naddr configured')
|
||||
}
|
||||
|
||||
await market.connectToMarket()
|
||||
await market.loadMarket(naddr)
|
||||
|
||||
// Subscribe to real-time updates
|
||||
unsubscribe = market.subscribeToMarketUpdates()
|
||||
|
||||
} catch (error) {
|
||||
marketStore.setError(error instanceof Error ? error.message : 'Failed to load market')
|
||||
}
|
||||
}
|
||||
|
||||
const retryLoadMarket = () => {
|
||||
marketStore.setError(null)
|
||||
marketPreloader.resetPreload()
|
||||
loadMarket()
|
||||
}
|
||||
|
||||
const addToCart = (product: any) => {
|
||||
marketStore.addToCart(product)
|
||||
}
|
||||
|
||||
const viewProduct = (_product: any) => {
|
||||
// TODO: Navigate to product detail page
|
||||
}
|
||||
|
||||
const viewCart = () => {
|
||||
router.push('/cart')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Only load market if it hasn't been preloaded
|
||||
if (needsToLoadMarket.value) {
|
||||
loadMarket()
|
||||
} else if (marketPreloader.isPreloaded.value) {
|
||||
// Subscribe to real-time updates if market was preloaded
|
||||
unsubscribe = market.subscribeToMarketUpdates()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unsubscribe) {
|
||||
unsubscribe()
|
||||
}
|
||||
market.disconnectFromMarket()
|
||||
})
|
||||
</script>
|
||||
Loading…
Add table
Add a link
Reference in a new issue