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:
padreug 2025-09-05 00:01:40 +02:00
parent 519a9003d4
commit e40ac91417
46 changed files with 6305 additions and 3264 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View file

@ -0,0 +1,2 @@
export { useMarket } from './useMarket'
export { useMarketPreloader } from './useMarketPreloader'

View 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
}
}

View 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
View 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'

View 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()

View 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
}
})

View 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
}

View 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>

View 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>