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

@ -54,6 +54,10 @@ export const appConfig: AppConfig = {
enabled: true, enabled: true,
lazy: false, lazy: false,
config: { config: {
apiConfig: {
baseUrl: 'http://lnbits',
apiKey: 'your-api-key-here'
},
ticketValidationEndpoint: '/api/tickets/validate', ticketValidationEndpoint: '/api/tickets/validate',
maxTicketsPerUser: 10 maxTicketsPerUser: 10
} }

View file

@ -12,6 +12,9 @@ import appConfig from './app.config'
// Base modules // Base modules
import baseModule from './modules/base' import baseModule from './modules/base'
import nostrFeedModule from './modules/nostr-feed' import nostrFeedModule from './modules/nostr-feed'
import chatModule from './modules/chat'
import eventsModule from './modules/events'
import marketModule from './modules/market'
// Root component // Root component
import App from './App.vue' import App from './App.vue'
@ -81,10 +84,26 @@ export async function createAppInstance() {
) )
} }
// TODO: Register other modules as they're converted // Register chat module
// - market module if (appConfig.modules.chat.enabled) {
// - chat module moduleRegistrations.push(
// - events module pluginManager.register(chatModule, appConfig.modules.chat)
)
}
// Register events module
if (appConfig.modules.events.enabled) {
moduleRegistrations.push(
pluginManager.register(eventsModule, appConfig.modules.events)
)
}
// Register market module
if (appConfig.modules.market.enabled) {
moduleRegistrations.push(
pluginManager.register(marketModule, appConfig.modules.market)
)
}
// Wait for all modules to register // Wait for all modules to register
await Promise.all(moduleRegistrations) await Promise.all(moduleRegistrations)

View file

@ -1,231 +1,4 @@
<template> <script lang="ts">
<div class="bg-card border rounded-lg p-6 shadow-sm"> import CartSummaryComponent from '@/modules/market/components/CartSummary.vue'
<!-- Cart Summary Header --> export default CartSummaryComponent
<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> </script>

View file

@ -1,334 +1,4 @@
<template> <script lang="ts">
<div class="bg-background border rounded-lg p-6"> import PaymentDisplayComponent from '@/modules/market/components/PaymentDisplay.vue'
<div class="flex items-center justify-between mb-4"> export default PaymentDisplayComponent
<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> </script>

View file

@ -1,250 +1,4 @@
<template> <script lang="ts">
<div class="space-y-6"> import ShoppingCartComponent from '@/modules/market/components/ShoppingCart.vue'
<!-- Cart Header --> export default ShoppingCartComponent
<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> </script>

View file

@ -1,534 +1,3 @@
import { ref, computed, onMounted, onUnmounted, readonly } from 'vue' // Compatibility re-export for the moved useMarket composable
import { useNostrStore } from '@/stores/nostr' export * from '@/modules/market/composables/useMarket'
import { useMarketStore } from '@/stores/market' export { useMarket } from '@/modules/market/composables/useMarket'
import { relayHubComposable } from './useRelayHub'
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 = relayHubComposable
// 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

@ -1,60 +1,3 @@
import { ref, readonly } from 'vue' // Compatibility re-export for the moved useMarketPreloader composable
import { useMarket } from './useMarket' export * from '@/modules/market/composables/useMarketPreloader'
import { useMarketStore } from '@/stores/market' export { useMarketPreloader } from '@/modules/market/composables/useMarketPreloader'
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
}
}

View file

@ -1,460 +1,3 @@
import { finalizeEvent, type EventTemplate, nip04 } from 'nostr-tools' // Compatibility re-export for the moved nostrmarketService
import { relayHub } from '@/lib/nostr/relayHub' export * from '@/modules/market/services/nostrmarketService'
import { auth } from '@/composables/useAuth' export { nostrmarketService } from '@/modules/market/services/nostrmarketService'
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,627 @@
<template>
<div class="flex flex-col h-full">
<!-- Mobile: Peer List View -->
<div v-if="isMobile && (!selectedPeer || !showChat)" class="flex flex-col h-full">
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b">
<div class="flex items-center space-x-3">
<h2 class="text-lg font-semibold">Chat</h2>
<Badge v-if="isConnected" variant="default" class="text-xs">
Connected
</Badge>
<Badge v-else variant="secondary" class="text-xs">
Disconnected
</Badge>
<!-- Total unread count -->
<Badge v-if="totalUnreadCount > 0" class="bg-blue-500 text-white text-xs">
{{ totalUnreadCount }} unread
</Badge>
</div>
<Button @click="refreshPeers" :disabled="isLoading" size="sm">
<RefreshCw v-if="isLoading" class="h-4 w-4 animate-spin" />
<RefreshCw v-else class="h-4 w-4" />
<span class="hidden sm:inline ml-2">Refresh</span>
</Button>
</div>
<!-- Peer List -->
<div class="flex-1 flex flex-col overflow-hidden">
<div class="p-4 border-b flex-shrink-0">
<div class="flex items-center justify-between mb-2">
<h3 class="font-medium">Peers ({{ filteredPeers.length }})</h3>
<span v-if="isSearching" class="text-xs text-muted-foreground">
{{ resultCount }} found
</span>
</div>
<!-- Search Input -->
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search class="h-4 w-4 text-muted-foreground" />
</div>
<Input
v-model="searchQuery"
placeholder="Search peers by name or pubkey..."
class="pl-10 pr-10"
/>
<div v-if="searchQuery" class="absolute inset-y-0 right-0 pr-3 flex items-center">
<Button
variant="ghost"
size="sm"
@click="clearSearch"
class="h-6 w-6 p-0 hover:bg-muted"
>
<X class="h-3 w-3" />
<span class="sr-only">Clear search</span>
</Button>
</div>
</div>
</div>
<ScrollArea class="flex-1">
<div class="p-2 space-y-1">
<!-- No results message -->
<div v-if="isSearching && filteredPeers.length === 0" class="text-center py-8 text-muted-foreground">
<Search class="h-8 w-8 mx-auto mb-2 opacity-50" />
<p class="text-sm">No peers found matching "{{ searchQuery }}"</p>
<p class="text-xs mt-1">Try searching by username or pubkey</p>
</div>
<!-- Peer list -->
<div
v-for="peer in filteredPeers"
:key="peer.user_id"
@click="selectPeer(peer)"
:class="[
'flex items-center space-x-3 p-3 rounded-lg cursor-pointer transition-colors touch-manipulation relative',
selectedPeer?.user_id === peer.user_id
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted active:bg-muted/80'
]"
>
<Avatar class="h-10 w-10 sm:h-8 sm:w-8">
<AvatarImage v-if="getPeerAvatar(peer)" :src="getPeerAvatar(peer)!" />
<AvatarFallback>{{ getPeerInitials(peer) }}</AvatarFallback>
</Avatar>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">
{{ peer.username || 'Unknown User' }}
</p>
<p class="text-xs text-muted-foreground truncate">
{{ formatPubkey(peer.pubkey) }}
</p>
</div>
<!-- Unread message indicator -->
<div v-if="getUnreadCount(peer.pubkey) > 0" class="flex-shrink-0">
<Badge class="bg-blue-500 text-white h-6 w-6 rounded-full p-0 flex items-center justify-center text-xs font-bold">
{{ getUnreadCount(peer.pubkey) > 99 ? '99+' : getUnreadCount(peer.pubkey) }}
</Badge>
</div>
</div>
</div>
</ScrollArea>
</div>
</div>
<!-- Mobile: Chat View -->
<div v-else-if="isMobile && showChat" class="flex flex-col h-full">
<!-- Chat Header with Back Button -->
<div class="flex items-center justify-between p-4 border-b">
<div class="flex items-center space-x-3">
<Button
variant="ghost"
size="icon"
@click="goBackToPeers"
class="mr-2"
>
<ArrowLeft class="h-5 w-5" />
</Button>
<Avatar class="h-8 w-8">
<AvatarImage v-if="selectedPeer && getPeerAvatar(selectedPeer)" :src="getPeerAvatar(selectedPeer)!" />
<AvatarFallback>{{ selectedPeer ? getPeerInitials(selectedPeer) : 'U' }}</AvatarFallback>
</Avatar>
<div>
<h3 class="font-medium">{{ selectedPeer?.username || 'Unknown User' }}</h3>
<p class="text-xs text-muted-foreground">
{{ selectedPeer ? formatPubkey(selectedPeer.pubkey) : '' }}
</p>
</div>
</div>
<div class="flex items-center space-x-2">
<Badge v-if="isConnected" variant="default" class="text-xs">
Connected
</Badge>
<Badge v-else variant="secondary" class="text-xs">
Disconnected
</Badge>
<!-- Unread count for current peer -->
<Badge v-if="selectedPeer && getUnreadCount(selectedPeer.pubkey) > 0" class="bg-blue-500 text-white text-xs">
{{ getUnreadCount(selectedPeer.pubkey) }} unread
</Badge>
</div>
</div>
<!-- Messages -->
<ScrollArea class="flex-1 p-4" ref="messagesScrollArea">
<div class="space-y-4">
<div
v-for="message in currentMessages"
:key="message.id"
:class="[
'flex',
message.sent ? 'justify-end' : 'justify-start'
]"
>
<div
:class="[
'max-w-xs lg:max-w-md px-4 py-2 rounded-lg',
message.sent
? 'bg-primary text-primary-foreground'
: 'bg-muted'
]"
>
<p class="text-sm">{{ message.content }}</p>
<p class="text-xs opacity-70 mt-1">
{{ formatTime(message.created_at) }}
</p>
</div>
</div>
</div>
<!-- Hidden element at bottom for scrolling -->
<div ref="scrollTarget" class="h-1" />
</ScrollArea>
<!-- Message Input -->
<div class="p-4 border-t">
<form @submit.prevent="sendMessage" class="flex space-x-2">
<Input
v-model="messageInput"
placeholder="Type a message..."
:disabled="!isConnected || !selectedPeer"
class="flex-1"
/>
<Button type="submit" :disabled="!isConnected || !selectedPeer || !messageInput.trim()">
<Send class="h-4 w-4" />
</Button>
</form>
</div>
</div>
<!-- Desktop: Full Layout -->
<div v-else-if="!isMobile" class="flex flex-col h-full">
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b">
<div class="flex items-center space-x-3">
<h2 class="text-lg font-semibold">Chat</h2>
<Badge v-if="isConnected" variant="default" class="text-xs">
Connected
</Badge>
<Badge v-else variant="secondary" class="text-xs">
Disconnected
</Badge>
<!-- Total unread count -->
<Badge v-if="totalUnreadCount > 0" class="bg-blue-500 text-white text-xs">
{{ totalUnreadCount }} unread
</Badge>
</div>
<Button @click="refreshPeers" :disabled="isLoading" size="sm">
<RefreshCw v-if="isLoading" class="h-4 w-4 animate-spin" />
<RefreshCw v-else class="h-4 w-4" />
Refresh Peers
</Button>
</div>
<!-- Main Content -->
<div class="flex flex-1 overflow-hidden">
<!-- Peer List -->
<div class="w-80 border-r bg-muted/30 flex flex-col">
<div class="p-4 border-b flex-shrink-0">
<div class="flex items-center justify-between mb-2">
<h3 class="font-medium">Peers ({{ filteredPeers.length }})</h3>
<span v-if="isSearching" class="text-xs text-muted-foreground">
{{ resultCount }} found
</span>
</div>
<!-- Search Input -->
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search class="h-4 w-4 text-muted-foreground" />
</div>
<Input
v-model="searchQuery"
placeholder="Search peers by name or pubkey..."
class="pl-10 pr-10"
/>
<div v-if="searchQuery" class="absolute inset-y-0 right-0 pr-3 flex items-center">
<Button
variant="ghost"
size="sm"
@click="clearSearch"
class="h-6 w-6 p-0 hover:bg-muted"
>
<X class="h-3 w-3" />
<span class="sr-only">Clear search</span>
</Button>
</div>
</div>
</div>
<ScrollArea class="flex-1">
<div class="p-2 space-y-1">
<!-- No results message -->
<div v-if="isSearching && filteredPeers.length === 0" class="text-center py-8 text-muted-foreground">
<Search class="h-8 w-8 mx-auto mb-2 opacity-50" />
<p class="text-sm">No peers found matching "{{ searchQuery }}"</p>
<p class="text-xs mt-1">Try searching by username or pubkey</p>
</div>
<!-- Peer list -->
<div
v-for="peer in filteredPeers"
:key="peer.user_id"
@click="selectPeer(peer)"
:class="[
'flex items-center space-x-3 p-3 rounded-lg cursor-pointer transition-colors relative',
selectedPeer?.user_id === peer.user_id
? 'bg-primary text-primary-foreground'
: 'hover:bg-muted'
]"
>
<Avatar class="h-8 w-8">
<AvatarImage v-if="getPeerAvatar(peer)" :src="getPeerAvatar(peer)!" />
<AvatarFallback>{{ getPeerInitials(peer) }}</AvatarFallback>
</Avatar>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">
{{ peer.username || 'Unknown User' }}
</p>
<p class="text-xs text-muted-foreground truncate">
{{ formatPubkey(peer.pubkey) }}
</p>
</div>
<!-- Unread message indicator -->
<div v-if="getUnreadCount(peer.pubkey) > 0" class="flex-shrink-0">
<Badge class="bg-blue-500 text-white h-6 w-6 rounded-full p-0 flex items-center justify-center text-xs font-bold">
{{ getUnreadCount(peer.pubkey) > 99 ? '99+' : getUnreadCount(peer.pubkey) }}
</Badge>
</div>
</div>
</div>
</ScrollArea>
</div>
<!-- Chat Area -->
<div class="flex-1 flex flex-col">
<!-- Chat Header - Always present to maintain layout -->
<div class="p-4 border-b" :class="{ 'h-16': !selectedPeer }">
<div v-if="selectedPeer" class="flex items-center space-x-3">
<Avatar class="h-8 w-8">
<AvatarImage v-if="getPeerAvatar(selectedPeer)" :src="getPeerAvatar(selectedPeer)!" />
<AvatarFallback>{{ getPeerInitials(selectedPeer) }}</AvatarFallback>
</Avatar>
<div>
<h3 class="font-medium">{{ selectedPeer.username || 'Unknown User' }}</h3>
<p class="text-sm text-muted-foreground">
{{ formatPubkey(selectedPeer.pubkey) }}
</p>
</div>
</div>
<div v-else class="h-8"></div>
</div>
<!-- Messages -->
<ScrollArea v-if="selectedPeer" class="flex-1 p-4" ref="messagesScrollArea">
<div class="space-y-4" ref="messagesContainer">
<div
v-for="message in currentMessages"
:key="message.id"
:class="[
'flex',
message.sent ? 'justify-end' : 'justify-start'
]"
>
<div
:class="[
'max-w-xs lg:max-w-md px-4 py-2 rounded-lg',
message.sent
? 'bg-primary text-primary-foreground'
: 'bg-muted'
]"
>
<p class="text-sm">{{ message.content }}</p>
<p class="text-xs opacity-70 mt-1">
{{ formatTime(message.created_at) }}
</p>
</div>
</div>
</div>
<!-- Hidden element at bottom for scrolling -->
<div ref="scrollTarget" class="h-1" />
</ScrollArea>
<!-- Message Input -->
<div v-if="selectedPeer" class="p-4 border-t">
<form @submit.prevent="sendMessage" class="flex space-x-2">
<Input
v-model="messageInput"
placeholder="Type a message..."
:disabled="!isConnected || !selectedPeer"
class="flex-1"
/>
<Button type="submit" :disabled="!isConnected || !selectedPeer || !messageInput.trim()">
<Send class="h-4 w-4" />
</Button>
</form>
</div>
<!-- No Peer Selected -->
<div v-else class="flex-1 flex items-center justify-center">
<div class="text-center">
<MessageSquare class="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 class="text-lg font-medium mb-2">No peer selected</h3>
<p class="text-muted-foreground">
Select a peer from the list to start chatting
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { Send, RefreshCw, MessageSquare, ArrowLeft, Search, X } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { nostrChat } from '@/composables/useNostrChat'
import { useFuzzySearch } from '@/composables/useFuzzySearch'
// Types
interface Peer {
user_id: string
username: string
pubkey: string
}
// State
const peers = computed(() => nostrChat.peers.value)
const selectedPeer = ref<Peer | null>(null)
const messageInput = ref('')
const isLoading = ref(false)
const showChat = ref(false)
const messagesScrollArea = ref<HTMLElement | null>(null)
const messagesContainer = ref<HTMLElement | null>(null)
const scrollTarget = ref<HTMLElement | null>(null)
// Mobile detection
const isMobile = ref(false)
// Nostr chat composable (singleton)
const {
isConnected,
messages,
connect,
disconnect,
subscribeToPeer,
sendMessage: sendNostrMessage,
onMessageAdded,
markMessagesAsRead,
getUnreadCount,
totalUnreadCount,
getLatestMessageTimestamp
} = nostrChat
// Computed
const currentMessages = computed(() => {
if (!selectedPeer.value) return []
const peerMessages = messages.value.get(selectedPeer.value.pubkey) || []
// Sort messages by timestamp (oldest first) to ensure chronological order
const sortedMessages = [...peerMessages].sort((a, b) => a.created_at - b.created_at)
return sortedMessages
})
// Sort peers by latest message timestamp (newest first) and unread status
const sortedPeers = computed(() => {
const sorted = [...peers.value].sort((a, b) => {
const aTimestamp = getLatestMessageTimestamp(a.pubkey)
const bTimestamp = getLatestMessageTimestamp(b.pubkey)
const aUnreadCount = getUnreadCount(a.pubkey)
const bUnreadCount = getUnreadCount(b.pubkey)
// First, sort by unread count (peers with unread messages appear first)
if (aUnreadCount > 0 && bUnreadCount === 0) return -1
if (aUnreadCount === 0 && bUnreadCount > 0) return 1
// Then, sort by latest message timestamp (newest first)
if (aTimestamp !== bTimestamp) {
return bTimestamp - aTimestamp
}
// Finally, sort alphabetically by username for peers with same timestamp
return (a.username || '').localeCompare(b.username || '')
})
return sorted
})
// Fuzzy search for peers
// This integrates the useFuzzySearch composable to provide intelligent search functionality
// for finding peers by username or pubkey with typo tolerance and scoring
const {
searchQuery,
filteredItems: filteredPeers,
isSearching,
resultCount,
clearSearch
} = useFuzzySearch(sortedPeers, {
fuseOptions: {
keys: [
{ name: 'username', weight: 0.7 }, // Username has higher weight for better UX
{ name: 'pubkey', weight: 0.3 } // Pubkey has lower weight but still searchable
],
threshold: 0.3, // Fuzzy matching threshold (0.0 = perfect, 1.0 = match anything)
distance: 100, // Maximum distance for fuzzy matching
ignoreLocation: true, // Ignore location for better performance
useExtendedSearch: false, // Don't use extended search syntax
minMatchCharLength: 1, // Minimum characters to match
shouldSort: true, // Sort results by relevance
findAllMatches: false, // Don't find all matches for performance
location: 0, // Start search from beginning
isCaseSensitive: false, // Case insensitive search
},
resultLimit: 50, // Limit results for performance
matchAllWhenSearchEmpty: true, // Show all peers when search is empty
minSearchLength: 1, // Start searching after 1 character
})
// Check if device is mobile
const checkMobile = () => {
isMobile.value = window.innerWidth < 768 // md breakpoint
}
// Mobile navigation
const goBackToPeers = () => {
showChat.value = false
selectedPeer.value = null
}
// Methods
const refreshPeers = async () => {
isLoading.value = true
try {
await nostrChat.loadPeers()
} catch (error) {
console.error('Failed to refresh peers:', error)
} finally {
isLoading.value = false
}
}
const selectPeer = async (peer: Peer) => {
selectedPeer.value = peer
messageInput.value = ''
// Mark messages as read for this peer
markMessagesAsRead(peer.pubkey)
// On mobile, show chat view
if (isMobile.value) {
showChat.value = true
}
// Subscribe to messages from this peer
await subscribeToPeer(peer.pubkey)
// Scroll to bottom to show latest messages when selecting a peer
nextTick(() => {
scrollToBottom()
})
}
const sendMessage = async () => {
if (!selectedPeer.value || !messageInput.value.trim()) return
try {
await sendNostrMessage(selectedPeer.value.pubkey, messageInput.value)
messageInput.value = ''
// Scroll to bottom
nextTick(() => {
scrollToBottom()
})
} catch (error) {
console.error('Failed to send message:', error)
}
}
const scrollToBottom = () => {
nextTick(() => {
if (scrollTarget.value) {
// Use scrollIntoView on the target element
scrollTarget.value.scrollIntoView({
behavior: 'smooth',
block: 'end'
})
}
})
}
const formatPubkey = (pubkey: string) => {
return pubkey.slice(0, 8) + '...' + pubkey.slice(-8)
}
const formatTime = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})
}
const getPeerAvatar = (_peer: Peer) => {
// You can implement avatar logic here
return null
}
const getPeerInitials = (peer: Peer) => {
if (peer.username) {
return peer.username.slice(0, 2).toUpperCase()
}
return peer.pubkey.slice(0, 2).toUpperCase()
}
// Lifecycle
onMounted(async () => {
checkMobile()
window.addEventListener('resize', checkMobile)
// Set up message callback
onMessageAdded.value = (peerPubkey: string) => {
if (selectedPeer.value && selectedPeer.value.pubkey === peerPubkey) {
nextTick(() => {
scrollToBottom()
})
}
}
// If not connected, connect
if (!isConnected.value) {
await connect()
}
// If no peers loaded, load them
if (peers.value.length === 0) {
await nostrChat.loadPeers()
}
})
onUnmounted(() => {
window.removeEventListener('resize', checkMobile)
disconnect()
})
// Watch for connection state changes
watch(isConnected, async () => {
// Note: Peer subscriptions are handled by the preloader
})
// Watch for new messages and scroll to bottom
watch(currentMessages, (newMessages, oldMessages) => {
// Scroll to bottom when new messages are added
if (newMessages.length > 0 && (!oldMessages || newMessages.length > oldMessages.length)) {
nextTick(() => {
scrollToBottom()
})
}
})
</script>

View file

@ -0,0 +1,82 @@
import { ref, computed } from 'vue'
import { injectService } from '@/core/di-container'
import type { ChatService } from '../services/chat-service'
import type { ChatPeer } from '../types'
// Service token for chat service
export const CHAT_SERVICE_TOKEN = Symbol('chatService')
export function useChat() {
const chatService = injectService<ChatService>(CHAT_SERVICE_TOKEN)
if (!chatService) {
throw new Error('ChatService not available. Make sure chat module is installed.')
}
const selectedPeer = ref<string | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
// Computed properties
const peers = computed(() => chatService.allPeers.value)
const totalUnreadCount = computed(() => chatService.totalUnreadCount.value)
const currentMessages = computed(() => {
return selectedPeer.value ? chatService.getMessages(selectedPeer.value) : []
})
const currentPeer = computed(() => {
return selectedPeer.value ? chatService.getPeer(selectedPeer.value) : undefined
})
// Methods
const selectPeer = (peerPubkey: string) => {
selectedPeer.value = peerPubkey
chatService.markAsRead(peerPubkey)
}
const sendMessage = async (content: string) => {
if (!selectedPeer.value || !content.trim()) {
return
}
isLoading.value = true
error.value = null
try {
await chatService.sendMessage(selectedPeer.value, content.trim())
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to send message'
console.error('Send message error:', err)
} finally {
isLoading.value = false
}
}
const addPeer = (pubkey: string, name?: string): ChatPeer => {
return chatService.addPeer(pubkey, name)
}
const markAsRead = (peerPubkey: string) => {
chatService.markAsRead(peerPubkey)
}
return {
// State
selectedPeer,
isLoading,
error,
// Computed
peers,
totalUnreadCount,
currentMessages,
currentPeer,
// Methods
selectPeer,
sendMessage,
addPeer,
markAsRead
}
}

106
src/modules/chat/index.ts Normal file
View file

@ -0,0 +1,106 @@
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 chat components and services
import ChatComponent from './components/ChatComponent.vue'
import { ChatService } from './services/chat-service'
import { useChat, CHAT_SERVICE_TOKEN } from './composables/useChat'
import type { ChatConfig } from './types'
/**
* Chat Module Plugin
* Provides Nostr-based encrypted chat functionality
*/
export const chatModule: ModulePlugin = {
name: 'chat',
version: '1.0.0',
dependencies: ['base'],
async install(app: App, options?: { config?: ChatConfig }) {
console.log('💬 Installing chat module...')
const config: ChatConfig = {
maxMessages: 500,
autoScroll: true,
showTimestamps: true,
notificationsEnabled: true,
soundEnabled: false,
...options?.config
}
// Create and register chat service
const chatService = new ChatService(config)
container.provide(CHAT_SERVICE_TOKEN, chatService)
// Register global components
app.component('ChatComponent', ChatComponent)
// Set up event listeners for integration with other modules
setupEventListeners(chatService)
console.log('✅ Chat module installed successfully')
},
async uninstall() {
console.log('🗑️ Uninstalling chat module...')
// Clean up chat service
const chatService = container.inject<ChatService>(CHAT_SERVICE_TOKEN)
if (chatService) {
chatService.destroy()
container.remove(CHAT_SERVICE_TOKEN)
}
console.log('✅ Chat module uninstalled')
},
routes: [
{
path: '/chat',
name: 'chat',
component: () => import('./views/ChatPage.vue'),
meta: {
title: 'Nostr Chat',
requiresAuth: true
}
}
] as RouteRecordRaw[],
components: {
ChatComponent
},
composables: {
useChat
},
services: {
chatService: CHAT_SERVICE_TOKEN
}
}
// Private function to set up event listeners
function setupEventListeners(chatService: ChatService) {
// Listen for auth events to clear chat data on logout
eventBus.on('auth:logout', () => {
chatService.destroy()
})
// Listen for Nostr events that might be chat messages
eventBus.on('nostr:event', (event) => {
// TODO: Process incoming Nostr events for encrypted DMs
console.log('Received Nostr event in chat module:', event)
})
// Emit chat events for other modules to listen to
// This is already handled by the ChatService via eventBus
}
export default chatModule
// Re-export types and composables for external use
export type { ChatMessage, ChatPeer, ChatConfig } from './types'
export { useChat } from './composables/useChat'

View file

@ -0,0 +1,240 @@
import { ref, computed } from 'vue'
import { eventBus } from '@/core/event-bus'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ChatMessage, ChatPeer, UnreadMessageData, ChatConfig } from '../types'
const UNREAD_MESSAGES_KEY = 'nostr-chat-unread-messages'
const PEERS_KEY = 'nostr-chat-peers'
export class ChatService {
private messages = ref<Map<string, ChatMessage[]>>(new Map())
private peers = ref<Map<string, ChatPeer>>(new Map())
private config: ChatConfig
constructor(config: ChatConfig) {
this.config = config
this.loadPeersFromStorage()
}
// Computed properties
get allPeers() {
return computed(() => Array.from(this.peers.value.values()))
}
get totalUnreadCount() {
return computed(() => {
return Array.from(this.peers.value.values())
.reduce((total, peer) => total + peer.unreadCount, 0)
})
}
// Get messages for a specific peer
getMessages(peerPubkey: string): ChatMessage[] {
return this.messages.value.get(peerPubkey) || []
}
// Get peer by pubkey
getPeer(pubkey: string): ChatPeer | undefined {
return this.peers.value.get(pubkey)
}
// Add or update a peer
addPeer(pubkey: string, name?: string): ChatPeer {
let peer = this.peers.value.get(pubkey)
if (!peer) {
peer = {
pubkey,
name: name || `User ${pubkey.slice(0, 8)}`,
unreadCount: 0,
lastSeen: Date.now()
}
this.peers.value.set(pubkey, peer)
this.savePeersToStorage()
eventBus.emit('chat:peer-added', { peer }, 'chat-service')
} else if (name && name !== peer.name) {
peer.name = name
this.savePeersToStorage()
}
return peer
}
// Add a message
addMessage(peerPubkey: string, message: ChatMessage): void {
if (!this.messages.value.has(peerPubkey)) {
this.messages.value.set(peerPubkey, [])
}
const peerMessages = this.messages.value.get(peerPubkey)!
// Avoid duplicates
if (!peerMessages.some(m => m.id === message.id)) {
peerMessages.push(message)
// Sort by timestamp
peerMessages.sort((a, b) => a.created_at - b.created_at)
// Limit message count
if (peerMessages.length > this.config.maxMessages) {
peerMessages.splice(0, peerMessages.length - this.config.maxMessages)
}
// Update peer info
const peer = this.addPeer(peerPubkey)
peer.lastMessage = message
peer.lastSeen = Date.now()
// Update unread count if message is not sent by us
if (!message.sent) {
this.updateUnreadCount(peerPubkey, message)
}
// Emit events
const eventType = message.sent ? 'chat:message-sent' : 'chat:message-received'
eventBus.emit(eventType, { message, peerPubkey }, 'chat-service')
}
}
// Mark messages as read for a peer
markAsRead(peerPubkey: string): void {
const peer = this.peers.value.get(peerPubkey)
if (peer && peer.unreadCount > 0) {
peer.unreadCount = 0
// Save unread state
const unreadData: UnreadMessageData = {
lastReadTimestamp: Date.now(),
unreadCount: 0,
processedMessageIds: new Set()
}
this.saveUnreadData(peerPubkey, unreadData)
eventBus.emit('chat:unread-count-changed', {
peerPubkey,
count: 0,
totalUnread: this.totalUnreadCount.value
}, 'chat-service')
}
}
// Send a message
async sendMessage(peerPubkey: string, content: string): Promise<void> {
try {
const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB)
const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) as any
if (!relayHub || !authService?.user?.value?.privkey) {
throw new Error('Required services not available')
}
// Create message
const message: ChatMessage = {
id: crypto.randomUUID(),
content,
created_at: Math.floor(Date.now() / 1000),
sent: true,
pubkey: authService.user.value.pubkey
}
// Add to local messages immediately
this.addMessage(peerPubkey, message)
// TODO: Implement actual Nostr message sending
// This would involve encrypting the message and publishing to relays
console.log('Sending message:', { peerPubkey, content })
} catch (error) {
console.error('Failed to send message:', error)
throw error
}
}
// Private methods
private updateUnreadCount(peerPubkey: string, message: ChatMessage): void {
const unreadData = this.getUnreadData(peerPubkey)
if (!unreadData.processedMessageIds.has(message.id)) {
unreadData.processedMessageIds.add(message.id)
unreadData.unreadCount++
const peer = this.peers.value.get(peerPubkey)
if (peer) {
peer.unreadCount = unreadData.unreadCount
this.savePeersToStorage()
}
this.saveUnreadData(peerPubkey, unreadData)
eventBus.emit('chat:unread-count-changed', {
peerPubkey,
count: unreadData.unreadCount,
totalUnread: this.totalUnreadCount.value
}, 'chat-service')
}
}
private getUnreadData(peerPubkey: string): UnreadMessageData {
try {
const stored = localStorage.getItem(`${UNREAD_MESSAGES_KEY}-${peerPubkey}`)
if (stored) {
const data = JSON.parse(stored)
return {
...data,
processedMessageIds: new Set(data.processedMessageIds || [])
}
}
} catch (error) {
console.warn('Failed to load unread data for peer:', peerPubkey, error)
}
return {
lastReadTimestamp: 0,
unreadCount: 0,
processedMessageIds: new Set()
}
}
private saveUnreadData(peerPubkey: string, data: UnreadMessageData): void {
try {
const serializable = {
...data,
processedMessageIds: Array.from(data.processedMessageIds)
}
localStorage.setItem(`${UNREAD_MESSAGES_KEY}-${peerPubkey}`, JSON.stringify(serializable))
} catch (error) {
console.warn('Failed to save unread data for peer:', peerPubkey, error)
}
}
private loadPeersFromStorage(): void {
try {
const stored = localStorage.getItem(PEERS_KEY)
if (stored) {
const peersArray = JSON.parse(stored) as ChatPeer[]
peersArray.forEach(peer => {
this.peers.value.set(peer.pubkey, peer)
})
}
} catch (error) {
console.warn('Failed to load peers from storage:', error)
}
}
private savePeersToStorage(): void {
try {
const peersArray = Array.from(this.peers.value.values())
localStorage.setItem(PEERS_KEY, JSON.stringify(peersArray))
} catch (error) {
console.warn('Failed to save peers to storage:', error)
}
}
// Cleanup
destroy(): void {
this.messages.value.clear()
this.peers.value.clear()
}
}

View file

@ -0,0 +1,57 @@
// Chat module types
export interface ChatMessage {
id: string
content: string
created_at: number
sent: boolean
pubkey: string
}
export interface ChatPeer {
pubkey: string
name?: string
lastMessage?: ChatMessage
unreadCount: number
lastSeen: number
}
export interface NostrRelayConfig {
url: string
read?: boolean
write?: boolean
}
export interface UnreadMessageData {
lastReadTimestamp: number
unreadCount: number
processedMessageIds: Set<string>
}
export interface ChatConfig {
maxMessages: number
autoScroll: boolean
showTimestamps: boolean
notificationsEnabled: boolean
soundEnabled: boolean
}
// Events emitted by chat module
export interface ChatEvents {
'chat:message-received': {
message: ChatMessage
peerPubkey: string
}
'chat:message-sent': {
message: ChatMessage
peerPubkey: string
}
'chat:peer-added': {
peer: ChatPeer
}
'chat:unread-count-changed': {
peerPubkey: string
count: number
totalUnread: number
}
}

View file

@ -0,0 +1,9 @@
<template>
<div class="h-full w-full">
<ChatComponent />
</div>
</template>
<script setup lang="ts">
import ChatComponent from '../components/ChatComponent.vue'
</script>

View file

@ -0,0 +1,255 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script setup lang="ts">
import { onUnmounted } from 'vue'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useTicketPurchase } from '@/composables/useTicketPurchase'
import { useAuth } from '@/composables/useAuth'
import { User, Wallet, CreditCard, Zap, Ticket } from 'lucide-vue-next'
import { formatEventPrice, formatWalletBalance } from '@/lib/utils/formatting'
interface Props {
event: {
id: string
name: string
price_per_ticket: number
currency: string
}
isOpen: boolean
}
interface Emits {
(e: 'update:isOpen', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { isAuthenticated, userDisplay } = useAuth()
const {
isLoading,
error,
paymentHash,
qrCode,
isPaymentPending,
isPayingWithWallet,
canPurchase,
userWallets,
hasWalletWithBalance,
purchaseTicketForEvent,
handleOpenLightningWallet,
resetPaymentState,
cleanup,
ticketQRCode,
purchasedTicketId,
showTicketQR
} = useTicketPurchase()
async function handlePurchase() {
if (!canPurchase.value) return
try {
await purchaseTicketForEvent(props.event.id)
} catch (err) {
console.error('Error purchasing ticket:', err)
}
}
function handleClose() {
emit('update:isOpen', false)
resetPaymentState()
}
// Cleanup on unmount
onUnmounted(() => {
cleanup()
})
</script>
<template>
<Dialog :open="isOpen" @update:open="handleClose">
<DialogContent class="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<CreditCard class="w-5 h-5" />
Purchase Ticket
</DialogTitle>
<DialogDescription>
Purchase a ticket for <strong>{{ event.name }}</strong> for {{ formatEventPrice(event.price_per_ticket, event.currency) }}
</DialogDescription>
</DialogHeader>
<!-- Authentication Check -->
<div v-if="!isAuthenticated" class="py-4 text-center space-y-4">
<div class="flex justify-center">
<User class="w-12 h-12 text-muted-foreground" />
</div>
<div class="space-y-2">
<h3 class="text-lg font-semibold">Login Required</h3>
<p class="text-sm text-muted-foreground">
Please log in to your account to purchase tickets using your wallet.
</p>
</div>
<Button @click="handleClose" variant="outline">
Close
</Button>
</div>
<!-- User Info and Purchase -->
<div v-else-if="!paymentHash" class="py-4 space-y-4">
<div class="bg-muted/50 rounded-lg p-4 space-y-3">
<div class="flex items-center gap-2">
<User class="w-4 h-4 text-muted-foreground" />
<span class="text-sm font-medium">Purchasing as:</span>
</div>
<div class="space-y-1">
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">Name:</span>
<span class="text-sm font-medium">{{ userDisplay?.name }}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">User ID:</span>
<Badge variant="secondary" class="text-xs font-mono">{{ userDisplay?.shortId }}</Badge>
</div>
</div>
</div>
<!-- Wallet Information -->
<div class="bg-muted/50 rounded-lg p-4 space-y-3">
<div class="flex items-center gap-2">
<Wallet class="w-4 h-4 text-muted-foreground" />
<span class="text-sm font-medium">Wallet Status:</span>
</div>
<div class="space-y-2">
<div v-if="userWallets.length === 0" class="text-sm text-muted-foreground">
No wallets found
</div>
<div v-else class="space-y-2">
<div v-for="wallet in userWallets" :key="wallet.id" class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-sm font-medium">{{ wallet.name }}</span>
<Badge v-if="wallet.balance_msat > 0" variant="default" class="text-xs">
{{ formatWalletBalance(wallet.balance_msat) }}
</Badge>
<Badge v-else variant="secondary" class="text-xs">Empty</Badge>
</div>
</div>
<div v-if="hasWalletWithBalance" class="flex items-center gap-2 text-sm text-green-600">
<Zap class="w-4 h-4" />
<span>Auto-payment available</span>
</div>
<div v-else class="flex items-center gap-2 text-sm text-muted-foreground">
<span>No funds available, fill your wallet or pay with an external one</span>
</div>
</div>
</div>
</div>
<div class="bg-muted/50 rounded-lg p-4 space-y-3">
<div class="flex items-center gap-2">
<CreditCard class="w-4 h-4 text-muted-foreground" />
<span class="text-sm font-medium">Payment Details:</span>
</div>
<div class="space-y-1">
<div class="flex justify-between">
<span class="text-sm text-muted-foreground">Event:</span>
<span class="text-sm font-medium">{{ event.name }}</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-muted-foreground">Price:</span>
<span class="text-sm font-medium">{{ formatEventPrice(event.price_per_ticket, event.currency) }}</span>
</div>
</div>
</div>
<div v-if="error" class="text-sm text-destructive bg-destructive/10 p-3 rounded-lg">
{{ error }}
</div>
<Button
@click="handlePurchase"
:disabled="isLoading || !canPurchase"
class="w-full"
>
<span v-if="isLoading" class="animate-spin mr-2"></span>
<span v-else-if="hasWalletWithBalance" class="flex items-center gap-2">
<Zap class="w-4 h-4" />
Pay with Wallet
</span>
<span v-else>Generate Payment Request</span>
</Button>
</div>
<!-- Payment QR Code and Status -->
<div v-else-if="paymentHash && !showTicketQR" class="py-4 flex flex-col items-center gap-4">
<div class="text-center space-y-2">
<h3 class="text-lg font-semibold">Payment Required</h3>
<p v-if="isPayingWithWallet" class="text-sm text-muted-foreground">
Processing payment with your wallet...
</p>
<p v-else class="text-sm text-muted-foreground">
Scan the QR code with your Lightning wallet to complete the payment
</p>
</div>
<div v-if="!isPayingWithWallet && qrCode" class="bg-muted/50 rounded-lg p-4">
<img :src="qrCode" alt="Lightning payment QR code" class="w-64 h-64 mx-auto" />
</div>
<div class="space-y-3 w-full">
<Button v-if="!isPayingWithWallet" variant="outline" @click="handleOpenLightningWallet" class="w-full">
<Wallet class="w-4 h-4 mr-2" />
Open in Lightning Wallet
</Button>
<div v-if="isPaymentPending" class="text-center space-y-2">
<div class="flex items-center justify-center gap-2">
<div class="animate-spin w-4 h-4 border-2 border-primary border-t-transparent rounded-full"></div>
<span class="text-sm text-muted-foreground">
{{ isPayingWithWallet ? 'Processing payment...' : 'Waiting for payment...' }}
</span>
</div>
<p class="text-xs text-muted-foreground">
Payment will be confirmed automatically once received
</p>
</div>
</div>
</div>
<!-- Ticket QR Code (After Successful Purchase) -->
<div v-else-if="showTicketQR && ticketQRCode" class="py-4 flex flex-col items-center gap-4">
<div class="text-center space-y-2">
<h3 class="text-lg font-semibold text-green-600">Ticket Purchased Successfully!</h3>
<p class="text-sm text-muted-foreground">
Your ticket has been purchased and is now available in your tickets area.
</p>
</div>
<div class="bg-muted/50 rounded-lg p-4 w-full">
<div class="text-center space-y-3">
<div class="flex justify-center">
<Ticket class="w-12 h-12 text-green-600" />
</div>
<div>
<p class="text-sm font-medium">Ticket ID</p>
<div class="bg-background border rounded px-2 py-1 max-w-full mt-1">
<p class="text-xs font-mono break-all">{{ purchasedTicketId }}</p>
</div>
</div>
</div>
</div>
<div class="space-y-3 w-full">
<Button @click="() => $router.push('/my-tickets')" class="w-full">
View My Tickets
</Button>
<Button variant="outline" @click="handleClose" class="w-full">
Close
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</template>

View file

@ -0,0 +1,65 @@
import { computed } from 'vue'
import { useAsyncState } from '@vueuse/core'
import { injectService } from '@/core/di-container'
import type { Event } from '../types/event'
import type { EventsApiService } from '../services/events-api'
// Service token for events API
export const EVENTS_API_TOKEN = Symbol('eventsApi')
export function useEvents() {
const eventsApi = injectService<EventsApiService>(EVENTS_API_TOKEN)
if (!eventsApi) {
throw new Error('EventsApiService not available. Make sure events module is installed.')
}
const { state: events, isLoading, error: asyncError, execute: refresh } = useAsyncState(
() => eventsApi.fetchEvents(),
[] as Event[],
{
immediate: true,
resetOnExecute: false,
}
)
const error = computed(() => {
if (asyncError.value) {
return {
message: asyncError.value instanceof Error
? asyncError.value.message
: 'An error occurred while fetching events'
}
}
return null
})
const sortedEvents = computed(() => {
return [...events.value].sort((a, b) =>
new Date(b.time).getTime() - new Date(a.time).getTime()
)
})
const upcomingEvents = computed(() => {
const now = new Date()
return sortedEvents.value.filter(event =>
new Date(event.event_start_date) > now
)
})
const pastEvents = computed(() => {
const now = new Date()
return sortedEvents.value.filter(event =>
new Date(event.event_end_date) < now
)
})
return {
events: sortedEvents,
upcomingEvents,
pastEvents,
isLoading,
error,
refresh,
}
}

View file

@ -0,0 +1,242 @@
import { ref, computed, onUnmounted } from 'vue'
import { purchaseTicket, checkPaymentStatus, payInvoiceWithWallet } from '@/lib/api/events'
import { useAuth } from '@/composables/useAuth'
import { toast } from 'vue-sonner'
export function useTicketPurchase() {
const { isAuthenticated, currentUser } = useAuth()
// State
const isLoading = ref(false)
const error = ref<string | null>(null)
const paymentHash = ref<string | null>(null)
const paymentRequest = ref<string | null>(null)
const qrCode = ref<string | null>(null)
const isPaymentPending = ref(false)
const isPayingWithWallet = ref(false)
// Ticket QR code state
const ticketQRCode = ref<string | null>(null)
const purchasedTicketId = ref<string | null>(null)
const showTicketQR = ref(false)
// Computed properties
const canPurchase = computed(() => isAuthenticated.value && currentUser.value)
const userDisplay = computed(() => {
if (!currentUser.value) return null
return {
name: currentUser.value.username || currentUser.value.id,
shortId: currentUser.value.id.slice(0, 8)
}
})
const userWallets = computed(() => currentUser.value?.wallets || [])
const hasWalletWithBalance = computed(() =>
userWallets.value.some((wallet: any) => wallet.balance_msat > 0)
)
// Generate QR code for Lightning payment
async function generateQRCode(bolt11: string) {
try {
const qrcode = await import('qrcode')
const dataUrl = await qrcode.toDataURL(bolt11, {
width: 256,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF'
}
})
qrCode.value = dataUrl
} catch (err) {
console.error('Error generating QR code:', err)
error.value = 'Failed to generate QR code'
}
}
// Generate QR code for ticket
async function generateTicketQRCode(ticketId: string) {
try {
const qrcode = await import('qrcode')
const ticketUrl = `ticket://${ticketId}`
const dataUrl = await qrcode.toDataURL(ticketUrl, {
width: 128,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF'
}
})
ticketQRCode.value = dataUrl
return dataUrl
} catch (error) {
console.error('Error generating ticket QR code:', error)
return null
}
}
// Pay with wallet
async function payWithWallet(paymentRequest: string) {
const walletWithBalance = userWallets.value.find((wallet: any) => wallet.balance_msat > 0)
if (!walletWithBalance) {
throw new Error('No wallet with sufficient balance found')
}
try {
await payInvoiceWithWallet(paymentRequest, walletWithBalance.id, walletWithBalance.adminkey)
return true
} catch (error) {
console.error('Wallet payment failed:', error)
throw error
}
}
// Purchase ticket for event
async function purchaseTicketForEvent(eventId: string) {
if (!canPurchase.value) {
throw new Error('User must be authenticated to purchase tickets')
}
isLoading.value = true
error.value = null
paymentHash.value = null
paymentRequest.value = null
qrCode.value = null
ticketQRCode.value = null
purchasedTicketId.value = null
showTicketQR.value = false
try {
// Get the invoice
const invoice = await purchaseTicket(eventId)
paymentHash.value = invoice.payment_hash
paymentRequest.value = invoice.payment_request
// Generate QR code for payment
await generateQRCode(invoice.payment_request)
// Try to pay with wallet if available
if (hasWalletWithBalance.value) {
isPayingWithWallet.value = true
try {
await payWithWallet(invoice.payment_request)
// If wallet payment succeeds, proceed to check payment status
await startPaymentStatusCheck(eventId, invoice.payment_hash)
} catch (walletError) {
// If wallet payment fails, fall back to manual payment
console.log('Wallet payment failed, falling back to manual payment:', walletError)
isPayingWithWallet.value = false
await startPaymentStatusCheck(eventId, invoice.payment_hash)
}
} else {
// No wallet balance, proceed with manual payment
await startPaymentStatusCheck(eventId, invoice.payment_hash)
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to purchase ticket'
console.error('Error purchasing ticket:', err)
} finally {
isLoading.value = false
}
}
// Start payment status check
async function startPaymentStatusCheck(eventId: string, hash: string) {
isPaymentPending.value = true
let checkInterval: number | null = null
const checkPayment = async () => {
try {
const result = await checkPaymentStatus(eventId, hash)
if (result.paid) {
isPaymentPending.value = false
if (checkInterval) {
clearInterval(checkInterval)
}
// Generate ticket QR code
if (result.ticket_id) {
purchasedTicketId.value = result.ticket_id
await generateTicketQRCode(result.ticket_id)
showTicketQR.value = true
}
toast.success('Ticket purchased successfully!')
}
} catch (err) {
console.error('Error checking payment status:', err)
}
}
// Check immediately
await checkPayment()
// Then check every 2 seconds
checkInterval = setInterval(checkPayment, 2000) as unknown as number
}
// Stop payment status check
function stopPaymentStatusCheck() {
isPaymentPending.value = false
}
// Reset payment state
function resetPaymentState() {
isLoading.value = false
error.value = null
paymentHash.value = null
paymentRequest.value = null
qrCode.value = null
isPaymentPending.value = false
isPayingWithWallet.value = false
ticketQRCode.value = null
purchasedTicketId.value = null
showTicketQR.value = false
}
// Open Lightning wallet
function handleOpenLightningWallet() {
if (paymentRequest.value) {
window.open(`lightning:${paymentRequest.value}`, '_blank')
}
}
// Cleanup function
function cleanup() {
stopPaymentStatusCheck()
}
// Lifecycle
onUnmounted(() => {
cleanup()
})
return {
// State
isLoading,
error,
paymentHash,
paymentRequest,
qrCode,
isPaymentPending,
isPayingWithWallet,
ticketQRCode,
purchasedTicketId,
showTicketQR,
// Computed
canPurchase,
userDisplay,
userWallets,
hasWalletWithBalance,
// Actions
purchaseTicketForEvent,
handleOpenLightningWallet,
resetPaymentState,
cleanup,
generateTicketQRCode
}
}

View file

@ -0,0 +1,123 @@
import { computed } from 'vue'
import { useAsyncState } from '@vueuse/core'
import type { Ticket } from '@/lib/types/event'
import { fetchUserTickets } from '@/lib/api/events'
import { useAuth } from '@/composables/useAuth'
interface GroupedTickets {
eventId: string
tickets: Ticket[]
paidCount: number
pendingCount: number
registeredCount: number
}
export function useUserTickets() {
const { isAuthenticated, currentUser } = useAuth()
const { state: tickets, isLoading, error: asyncError, execute: refresh } = useAsyncState(
async () => {
if (!isAuthenticated.value || !currentUser.value) {
return []
}
return await fetchUserTickets(currentUser.value.id)
},
[] as Ticket[],
{
immediate: false,
resetOnExecute: false,
}
)
const error = computed(() => {
if (asyncError.value) {
return {
message: asyncError.value instanceof Error
? asyncError.value.message
: 'An error occurred while fetching tickets'
}
}
return null
})
const sortedTickets = computed(() => {
return [...tickets.value].sort((a, b) =>
new Date(b.time).getTime() - new Date(a.time).getTime()
)
})
const paidTickets = computed(() => {
return sortedTickets.value.filter(ticket => ticket.paid)
})
const pendingTickets = computed(() => {
return sortedTickets.value.filter(ticket => !ticket.paid)
})
const registeredTickets = computed(() => {
return sortedTickets.value.filter(ticket => ticket.registered)
})
const unregisteredTickets = computed(() => {
return sortedTickets.value.filter(ticket => ticket.paid && !ticket.registered)
})
// Group tickets by event
const groupedTickets = computed(() => {
const groups = new Map<string, GroupedTickets>()
sortedTickets.value.forEach(ticket => {
if (!groups.has(ticket.event)) {
groups.set(ticket.event, {
eventId: ticket.event,
tickets: [],
paidCount: 0,
pendingCount: 0,
registeredCount: 0
})
}
const group = groups.get(ticket.event)!
group.tickets.push(ticket)
if (ticket.paid) {
group.paidCount++
} else {
group.pendingCount++
}
if (ticket.registered) {
group.registeredCount++
}
})
// Convert to array and sort by most recent ticket in each group
return Array.from(groups.values()).sort((a, b) => {
const aLatest = Math.max(...a.tickets.map(t => new Date(t.time).getTime()))
const bLatest = Math.max(...b.tickets.map(t => new Date(t.time).getTime()))
return bLatest - aLatest
})
})
// Load tickets when authenticated
const loadTickets = async () => {
if (isAuthenticated.value && currentUser.value) {
await refresh()
}
}
return {
// State
tickets: sortedTickets,
paidTickets,
pendingTickets,
registeredTickets,
unregisteredTickets,
groupedTickets,
isLoading,
error,
// Actions
refresh: loadTickets,
}
}

116
src/modules/events/index.ts Normal file
View file

@ -0,0 +1,116 @@
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 and services
import PurchaseTicketDialog from './components/PurchaseTicketDialog.vue'
import { EventsApiService, type EventsApiConfig } from './services/events-api'
import { useEvents, EVENTS_API_TOKEN } from './composables/useEvents'
export interface EventsModuleConfig {
apiConfig: EventsApiConfig
ticketValidationEndpoint?: string
maxTicketsPerUser?: number
}
/**
* Events Module Plugin
* Provides event management and ticket purchasing functionality
*/
export const eventsModule: ModulePlugin = {
name: 'events',
version: '1.0.0',
dependencies: ['base'],
async install(app: App, options?: { config?: EventsModuleConfig }) {
console.log('🎫 Installing events module...')
const config = options?.config
if (!config) {
throw new Error('Events module requires configuration')
}
// Create and register events API service
const eventsApiService = new EventsApiService(config.apiConfig)
container.provide(EVENTS_API_TOKEN, eventsApiService)
// Register global components
app.component('PurchaseTicketDialog', PurchaseTicketDialog)
// Set up event listeners for integration with other modules
setupEventListeners()
console.log('✅ Events module installed successfully')
},
async uninstall() {
console.log('🗑️ Uninstalling events module...')
// Clean up services
container.remove(EVENTS_API_TOKEN)
console.log('✅ Events module uninstalled')
},
routes: [
{
path: '/events',
name: 'events',
component: () => import('./views/EventsPage.vue'),
meta: {
title: 'Events',
requiresAuth: true
}
},
{
path: '/my-tickets',
name: 'my-tickets',
component: () => import('./views/MyTicketsPage.vue'),
meta: {
title: 'My Tickets',
requiresAuth: true
}
}
] as RouteRecordRaw[],
components: {
PurchaseTicketDialog
},
composables: {
useEvents
},
services: {
eventsApi: EVENTS_API_TOKEN
}
}
// Set up event listeners for integration with other modules
function setupEventListeners() {
// Listen for auth events
eventBus.on('auth:logout', () => {
// Clear any cached event data if needed
console.log('Events module: user logged out, clearing cache')
})
// Listen for payment events from other modules
eventBus.on('payment:completed', (event) => {
console.log('Events module: payment completed', event.data)
// Could refresh events or ticket status here
})
// Emit events for other modules
eventBus.on('events:ticket-purchased', (event) => {
console.log('Ticket purchased:', event.data)
// Other modules can listen to this event
})
}
export default eventsModule
// Re-export types and composables for external use
export type { Event, Ticket } from './types/event'
export { useEvents } from './composables/useEvents'

View file

@ -0,0 +1,155 @@
// Events API service for the events module
import type { Event, Ticket } from '../types/event'
export interface EventsApiConfig {
baseUrl: string
apiKey: string
}
export class EventsApiService {
constructor(private config: EventsApiConfig) {}
async fetchEvents(): Promise<Event[]> {
try {
const response = await fetch(
`${this.config.baseUrl}/events/api/v1/events/public`,
{
headers: {
'accept': 'application/json',
},
}
)
if (!response.ok) {
const error = await response.json()
const errorMessage = typeof error.detail === 'string'
? error.detail
: error.detail[0]?.msg || 'Failed to fetch events'
throw new Error(errorMessage)
}
return await response.json() as Event[]
} catch (error) {
console.error('Error fetching events:', error)
throw error
}
}
async purchaseTicket(eventId: string, userId: string, accessToken: string): Promise<{ payment_hash: string; payment_request: string }> {
try {
const response = await fetch(
`${this.config.baseUrl}/events/api/v1/tickets/${eventId}/user/${userId}`,
{
method: 'GET',
headers: {
'accept': 'application/json',
'X-API-KEY': this.config.apiKey,
'Authorization': `Bearer ${accessToken}`,
},
}
)
if (!response.ok) {
const error = await response.json()
const errorMessage = typeof error.detail === 'string'
? error.detail
: error.detail[0]?.msg || 'Failed to purchase ticket'
throw new Error(errorMessage)
}
return await response.json()
} catch (error) {
console.error('Error purchasing ticket:', error)
throw error
}
}
async checkPaymentStatus(eventId: string, paymentHash: string): Promise<{ paid: boolean; ticket_id?: string }> {
try {
const response = await fetch(
`${this.config.baseUrl}/events/api/v1/tickets/${eventId}/${paymentHash}`,
{
method: 'POST',
headers: {
'accept': 'application/json',
'X-API-KEY': this.config.apiKey,
},
}
)
if (!response.ok) {
const error = await response.json()
const errorMessage = typeof error.detail === 'string'
? error.detail
: error.detail[0]?.msg || 'Failed to check payment status'
throw new Error(errorMessage)
}
return await response.json()
} catch (error) {
console.error('Error checking payment status:', error)
throw error
}
}
async fetchUserTickets(userId: string, accessToken: string): Promise<Ticket[]> {
try {
const response = await fetch(
`${this.config.baseUrl}/events/api/v1/tickets/user/${userId}`,
{
headers: {
'accept': 'application/json',
'X-API-KEY': this.config.apiKey,
'Authorization': `Bearer ${accessToken}`,
},
}
)
if (!response.ok) {
const error = await response.json()
const errorMessage = typeof error.detail === 'string'
? error.detail
: error.detail[0]?.msg || 'Failed to fetch user tickets'
throw new Error(errorMessage)
}
return await response.json()
} catch (error) {
console.error('Error fetching user tickets:', error)
throw error
}
}
async payInvoiceWithWallet(paymentRequest: string, adminKey: string): Promise<{ payment_hash: string; fee_msat: number; preimage: string }> {
try {
const response = await fetch(
`${this.config.baseUrl}/api/v1/payments`,
{
method: 'POST',
headers: {
'accept': 'application/json',
'Content-Type': 'application/json',
'X-API-KEY': adminKey,
},
body: JSON.stringify({
out: true,
bolt11: paymentRequest,
}),
}
)
if (!response.ok) {
const error = await response.json()
const errorMessage = typeof error.detail === 'string'
? error.detail
: error.detail[0]?.msg || 'Failed to pay invoice'
throw new Error(errorMessage)
}
return await response.json()
} catch (error) {
console.error('Error paying invoice:', error)
throw error
}
}
}

View file

@ -0,0 +1,36 @@
export interface Event {
id: string
wallet: string
name: string
info: string
closing_date: string
event_start_date: string
event_end_date: string
currency: string
amount_tickets: number
price_per_ticket: number
time: string
sold: number
banner: string | null
}
export interface Ticket {
id: string
wallet: string
event: string
name: string | null
email: string | null
user_id: string | null
registered: boolean
paid: boolean
time: string
reg_timestamp: string
}
export interface EventsApiError {
detail: Array<{
loc: [string, number]
msg: string
type: string
}>
}

View file

@ -0,0 +1,168 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script setup lang="ts">
import { ref } from 'vue'
import { useEvents } from '../composables/useEvents'
import { useAuth } from '@/composables/useAuth'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { format } from 'date-fns'
import PurchaseTicketDialog from '../components/PurchaseTicketDialog.vue'
import { RefreshCw, User, LogIn } from 'lucide-vue-next'
import { formatEventPrice } from '@/lib/utils/formatting'
const { upcomingEvents, pastEvents, isLoading, error, refresh } = useEvents()
const { isAuthenticated, userDisplay } = useAuth()
const showPurchaseDialog = ref(false)
const selectedEvent = ref<{
id: string
name: string
price_per_ticket: number
currency: string
} | null>(null)
function formatDate(dateStr: string) {
return format(new Date(dateStr), 'PPP')
}
function handlePurchaseClick(event: {
id: string
name: string
price_per_ticket: number
currency: string
}) {
if (!isAuthenticated.value) {
// Show login prompt or redirect to login
// You could emit an event to show login dialog here
return
}
selectedEvent.value = event
showPurchaseDialog.value = true
}
</script>
<template>
<div class="container mx-auto py-8 px-4">
<div class="flex justify-between items-center mb-6">
<div class="space-y-1">
<h1 class="text-3xl font-bold text-foreground">Events</h1>
<div v-if="isAuthenticated && userDisplay" class="flex items-center gap-2 text-sm text-muted-foreground">
<User class="w-4 h-4" />
<span>Logged in as {{ userDisplay.name }}</span>
<Badge variant="outline" class="text-xs">{{ userDisplay.shortId }}</Badge>
</div>
<div v-else class="flex items-center gap-2 text-sm text-muted-foreground">
<LogIn class="w-4 h-4" />
<span>Please log in to purchase tickets</span>
</div>
</div>
<Button variant="secondary" size="sm" @click="refresh" :disabled="isLoading">
<RefreshCw class="w-4 h-4 mr-2" :class="{ 'animate-spin': isLoading }" />
Refresh
</Button>
</div>
<Tabs default-value="upcoming" class="w-full">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="upcoming">Upcoming Events</TabsTrigger>
<TabsTrigger value="past">Past Events</TabsTrigger>
</TabsList>
<div v-if="error" class="mt-4 p-4 bg-destructive/15 text-destructive rounded-lg">
{{ error.message }}
</div>
<TabsContent value="upcoming">
<!-- {{ upcomingEvents }} -->
<ScrollArea class="h-[600px] w-full pr-4" v-if="upcomingEvents.length">
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="event in upcomingEvents" :key="event.id" class="flex flex-col">
<CardHeader>
<CardTitle class="text-foreground">{{ event.name }}</CardTitle>
<CardDescription>{{ event.info }}</CardDescription>
</CardHeader>
<CardContent class="flex-grow">
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-muted-foreground">Start Date:</span>
<span class="text-foreground">{{ formatDate(event.event_start_date) }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">End Date:</span>
<span class="text-foreground">{{ formatDate(event.event_end_date) }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Tickets Available:</span>
<span class="text-foreground">{{ event.amount_tickets - event.sold }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Price:</span>
<span class="text-foreground">{{ formatEventPrice(event.price_per_ticket, event.currency) }}</span>
</div>
</div>
</CardContent>
<CardFooter>
<Button
class="w-full"
variant="default"
:disabled="event.amount_tickets <= event.sold || !isAuthenticated"
@click="handlePurchaseClick(event)"
>
<span v-if="!isAuthenticated" class="flex items-center gap-2">
<LogIn class="w-4 h-4" />
Login to Purchase
</span>
<span v-else>Buy Ticket</span>
</Button>
</CardFooter>
</Card>
</div>
</ScrollArea>
<div v-else-if="!isLoading" class="text-center py-8 text-muted-foreground">
No upcoming events found
</div>
</TabsContent>
<TabsContent value="past">
<ScrollArea class="h-[600px] w-full pr-4" v-if="pastEvents.length">
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="event in pastEvents" :key="event.id" class="flex flex-col opacity-75">
<CardHeader>
<CardTitle class="text-foreground">{{ event.name }}</CardTitle>
<CardDescription>{{ event.info }}</CardDescription>
</CardHeader>
<CardContent class="flex-grow">
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-muted-foreground">Start Date:</span>
<span class="text-foreground">{{ formatDate(event.event_start_date) }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">End Date:</span>
<span class="text-foreground">{{ formatDate(event.event_end_date) }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Total Tickets:</span>
<span class="text-foreground">{{ event.amount_tickets }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Tickets Sold:</span>
<span class="text-foreground">{{ event.sold }}</span>
</div>
</div>
</CardContent>
</Card>
</div>
</ScrollArea>
<div v-else-if="!isLoading" class="text-center py-8 text-muted-foreground">
No past events found
</div>
</TabsContent>
</Tabs>
<PurchaseTicketDialog v-if="selectedEvent" :event="selectedEvent" v-model:is-open="showPurchaseDialog" />
</div>
</template>

View file

@ -0,0 +1,599 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { useUserTickets } from '@/composables/useUserTickets'
import { useAuth } from '@/composables/useAuth'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { format } from 'date-fns'
import { Ticket, User, CreditCard, CheckCircle, Clock, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-vue-next'
const { isAuthenticated, userDisplay } = useAuth()
const {
tickets,
paidTickets,
pendingTickets,
registeredTickets,
groupedTickets,
isLoading,
error,
refresh
} = useUserTickets()
// QR code state - now always generate QR codes for all tickets
const qrCodes = ref<Record<string, string>>({})
// Ticket cycling state
const currentTicketIndex = ref<Record<string, number>>({})
function formatDate(dateStr: string) {
return format(new Date(dateStr), 'PPP')
}
function formatTime(dateStr: string) {
return format(new Date(dateStr), 'HH:mm')
}
function getTicketStatus(ticket: any) {
if (!ticket.paid) return { status: 'pending', label: 'Payment Pending', icon: Clock, color: 'text-yellow-600' }
if (ticket.registered) return { status: 'registered', label: 'Registered', icon: CheckCircle, color: 'text-green-600' }
return { status: 'paid', label: 'Paid', icon: CreditCard, color: 'text-blue-600' }
}
async function generateQRCode(ticketId: string) {
if (qrCodes.value[ticketId]) return qrCodes.value[ticketId]
try {
const qrcode = await import('qrcode')
const ticketUrl = `ticket://${ticketId}`
const dataUrl = await qrcode.toDataURL(ticketUrl, {
width: 200, // Larger QR code for easier scanning
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF'
}
})
qrCodes.value[ticketId] = dataUrl
return dataUrl
} catch (error) {
console.error('Error generating QR code:', error)
return null
}
}
// Ticket cycling functions
function getCurrentTicketIndex(eventId: string) {
return currentTicketIndex.value[eventId] || 0
}
function setCurrentTicketIndex(eventId: string, index: number) {
currentTicketIndex.value[eventId] = index
}
async function nextTicket(eventId: string, totalTickets: number) {
const current = getCurrentTicketIndex(eventId)
const nextIndex = (current + 1) % totalTickets
setCurrentTicketIndex(eventId, nextIndex)
// Generate QR code for the new ticket if needed
const group = groupedTickets.value.find(g => g.eventId === eventId)
if (group) {
const newTicket = group.tickets[nextIndex]
if (newTicket && !qrCodes.value[newTicket.id]) {
await generateQRCode(newTicket.id)
}
}
}
async function prevTicket(eventId: string, totalTickets: number) {
const current = getCurrentTicketIndex(eventId)
const prevIndex = current === 0 ? totalTickets - 1 : current - 1
setCurrentTicketIndex(eventId, prevIndex)
// Generate QR code for the new ticket if needed
const group = groupedTickets.value.find(g => g.eventId === eventId)
if (group) {
const newTicket = group.tickets[prevIndex]
if (newTicket && !qrCodes.value[newTicket.id]) {
await generateQRCode(newTicket.id)
}
}
}
function getCurrentTicket(tickets: any[], eventId: string) {
const index = getCurrentTicketIndex(eventId)
return tickets[index] || tickets[0]
}
// Watch for changes in grouped tickets and generate QR codes
watch(groupedTickets, async (newGroups) => {
for (const group of newGroups) {
for (const ticket of group.tickets) {
if (!qrCodes.value[ticket.id]) {
await generateQRCode(ticket.id)
}
}
}
}, { immediate: true })
onMounted(async () => {
if (isAuthenticated.value) {
await refresh()
}
})
</script>
<template>
<div class="container mx-auto py-8 px-4">
<div class="flex justify-between items-center mb-6">
<div class="space-y-1">
<h1 class="text-3xl font-bold text-foreground">My Tickets</h1>
<div v-if="isAuthenticated && userDisplay" class="flex items-center gap-2 text-sm text-muted-foreground">
<User class="w-4 h-4" />
<span>Logged in as {{ userDisplay.name }}</span>
<Badge variant="outline" class="text-xs">{{ userDisplay.shortId }}</Badge>
</div>
<div v-else class="flex items-center gap-2 text-sm text-muted-foreground">
<AlertCircle class="w-4 h-4" />
<span>Please log in to view your tickets</span>
</div>
</div>
<Button variant="secondary" size="sm" @click="refresh" :disabled="isLoading">
<span v-if="isLoading" class="animate-spin mr-2"></span>
Refresh
</Button>
</div>
<div v-if="!isAuthenticated" class="text-center py-12">
<div class="flex justify-center mb-4">
<Ticket class="w-16 h-16 text-muted-foreground" />
</div>
<h2 class="text-xl font-semibold mb-2">No Tickets Found</h2>
<p class="text-muted-foreground mb-4">Please log in to view your tickets</p>
<Button @click="$router.push('/login')">Login</Button>
</div>
<div v-else-if="error" class="mt-4 p-4 bg-destructive/15 text-destructive rounded-lg">
{{ error.message }}
</div>
<div v-else-if="tickets.length === 0 && !isLoading" class="text-center py-12">
<div class="flex justify-center mb-4">
<Ticket class="w-16 h-16 text-muted-foreground" />
</div>
<h2 class="text-xl font-semibold mb-2">No Tickets Found</h2>
<p class="text-muted-foreground mb-4">You haven't purchased any tickets yet</p>
<Button @click="$router.push('/events')">Browse Events</Button>
</div>
<div v-else-if="tickets.length > 0">
<Tabs default-value="all" class="w-full">
<TabsList class="grid w-full grid-cols-4">
<TabsTrigger value="all">All ({{ tickets.length }})</TabsTrigger>
<TabsTrigger value="paid">Paid ({{ paidTickets.length }})</TabsTrigger>
<TabsTrigger value="pending">Pending ({{ pendingTickets.length }})</TabsTrigger>
<TabsTrigger value="registered">Registered ({{ registeredTickets.length }})</TabsTrigger>
</TabsList>
<TabsContent value="all">
<ScrollArea class="h-[600px] w-full pr-4">
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="group in groupedTickets" :key="group.eventId" class="flex flex-col">
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle class="text-foreground">Event: {{ group.eventId }}</CardTitle>
<Badge variant="outline">
{{ group.tickets.length }} ticket{{ group.tickets.length !== 1 ? 's' : '' }}
</Badge>
</div>
<CardDescription>
{{ group.paidCount }} paid {{ group.pendingCount }} pending {{ group.registeredCount }} registered
</CardDescription>
</CardHeader>
<CardContent class="flex-grow">
<div v-if="group.tickets.length > 0" class="space-y-4">
<!-- Ticket Navigation -->
<div class="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
@click="prevTicket(group.eventId, group.tickets.length)"
:disabled="group.tickets.length <= 1"
>
<ChevronLeft class="w-4 h-4" />
</Button>
<span class="text-sm text-muted-foreground">
{{ getCurrentTicketIndex(group.eventId) + 1 }} of {{ group.tickets.length }}
</span>
<Button
variant="ghost"
size="sm"
@click="nextTicket(group.eventId, group.tickets.length)"
:disabled="group.tickets.length <= 1"
>
<ChevronRight class="w-4 h-4" />
</Button>
</div>
<!-- Current Ticket Display -->
<div v-if="getCurrentTicket(group.tickets, group.eventId)" class="space-y-4">
<!-- QR Code - Always Visible -->
<div class="flex justify-center">
<div class="text-center space-y-2">
<img
v-if="qrCodes[getCurrentTicket(group.tickets, group.eventId).id]"
:src="qrCodes[getCurrentTicket(group.tickets, group.eventId).id]"
alt="Ticket QR Code"
class="w-48 h-48 border rounded-lg mx-auto"
/>
<div v-else class="w-48 h-48 border rounded-lg flex items-center justify-center mx-auto">
<span class="text-xs text-muted-foreground">Loading...</span>
</div>
<div class="text-center">
<p class="text-xs text-muted-foreground">Ticket ID</p>
<div class="bg-background border rounded px-2 py-1 max-w-full mt-1">
<p class="text-xs font-mono break-all">{{ getCurrentTicket(group.tickets, group.eventId).id }}</p>
</div>
</div>
</div>
</div>
<!-- Ticket Details -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm font-medium">
Ticket #{{ getCurrentTicket(group.tickets, group.eventId).id.slice(0, 8) }}
</span>
<Badge :variant="getTicketStatus(getCurrentTicket(group.tickets, group.eventId)).status === 'pending' ? 'secondary' : 'default'">
{{ getTicketStatus(getCurrentTicket(group.tickets, group.eventId)).label }}
</Badge>
</div>
<div class="space-y-1 text-sm">
<div class="flex justify-between">
<span class="text-muted-foreground">Status:</span>
<div class="flex items-center gap-1">
<component :is="getTicketStatus(getCurrentTicket(group.tickets, group.eventId)).icon" class="w-4 h-4" :class="getTicketStatus(getCurrentTicket(group.tickets, group.eventId)).color" />
<span>{{ getTicketStatus(getCurrentTicket(group.tickets, group.eventId)).label }}</span>
</div>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Purchased:</span>
<span>{{ formatDate(getCurrentTicket(group.tickets, group.eventId).time) }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Time:</span>
<span>{{ formatTime(getCurrentTicket(group.tickets, group.eventId).time) }}</span>
</div>
<div v-if="getCurrentTicket(group.tickets, group.eventId).reg_timestamp" class="flex justify-between">
<span class="text-muted-foreground">Registered:</span>
<span>{{ formatDate(getCurrentTicket(group.tickets, group.eventId).reg_timestamp) }}</span>
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="paid">
<ScrollArea class="h-[600px] w-full pr-4">
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="group in groupedTickets.filter(g => g.paidCount > 0)" :key="group.eventId" class="flex flex-col">
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle class="text-foreground">Event: {{ group.eventId }}</CardTitle>
<Badge variant="default">
{{ group.paidCount }} paid
</Badge>
</div>
<CardDescription>
{{ group.paidCount }} paid ticket{{ group.paidCount !== 1 ? 's' : '' }}
</CardDescription>
</CardHeader>
<CardContent class="flex-grow">
<div v-if="group.tickets.filter(t => t.paid).length > 0" class="space-y-4">
<!-- Ticket Navigation -->
<div class="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
@click="prevTicket(group.eventId, group.tickets.filter(t => t.paid).length)"
:disabled="group.tickets.filter(t => t.paid).length <= 1"
>
<ChevronLeft class="w-4 h-4" />
</Button>
<span class="text-sm text-muted-foreground">
{{ getCurrentTicketIndex(group.eventId) + 1 }} of {{ group.tickets.filter(t => t.paid).length }}
</span>
<Button
variant="ghost"
size="sm"
@click="nextTicket(group.eventId, group.tickets.filter(t => t.paid).length)"
:disabled="group.tickets.filter(t => t.paid).length <= 1"
>
<ChevronRight class="w-4 h-4" />
</Button>
</div>
<!-- Current Ticket Display -->
<div v-if="getCurrentTicket(group.tickets.filter(t => t.paid), group.eventId)" class="space-y-4">
<!-- QR Code - Always Visible -->
<div class="flex justify-center">
<div class="text-center space-y-2">
<img
v-if="qrCodes[getCurrentTicket(group.tickets.filter(t => t.paid), group.eventId).id]"
:src="qrCodes[getCurrentTicket(group.tickets.filter(t => t.paid), group.eventId).id]"
alt="Ticket QR Code"
class="w-48 h-48 border rounded-lg mx-auto"
/>
<div v-else class="w-48 h-48 border rounded-lg flex items-center justify-center mx-auto">
<span class="text-xs text-muted-foreground">Loading...</span>
</div>
<div class="text-center">
<p class="text-xs text-muted-foreground">Ticket ID</p>
<div class="bg-background border rounded px-2 py-1 max-w-full mt-1">
<p class="text-xs font-mono break-all">{{ getCurrentTicket(group.tickets.filter(t => t.paid), group.eventId).id }}</p>
</div>
</div>
</div>
</div>
<!-- Ticket Details -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm font-medium">
Ticket #{{ getCurrentTicket(group.tickets.filter(t => t.paid), group.eventId).id.slice(0, 8) }}
</span>
<Badge variant="default">
{{ getTicketStatus(getCurrentTicket(group.tickets.filter(t => t.paid), group.eventId)).label }}
</Badge>
</div>
<div class="space-y-1 text-sm">
<div class="flex justify-between">
<span class="text-muted-foreground">Status:</span>
<div class="flex items-center gap-1">
<component :is="getTicketStatus(getCurrentTicket(group.tickets.filter(t => t.paid), group.eventId)).icon" class="w-4 h-4" :class="getTicketStatus(getCurrentTicket(group.tickets.filter(t => t.paid), group.eventId)).color" />
<span>{{ getTicketStatus(getCurrentTicket(group.tickets.filter(t => t.paid), group.eventId)).label }}</span>
</div>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Purchased:</span>
<span>{{ formatDate(getCurrentTicket(group.tickets.filter(t => t.paid), group.eventId).time) }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Time:</span>
<span>{{ formatTime(getCurrentTicket(group.tickets.filter(t => t.paid), group.eventId).time) }}</span>
</div>
<div v-if="getCurrentTicket(group.tickets.filter(t => t.paid), group.eventId).reg_timestamp" class="flex justify-between">
<span class="text-muted-foreground">Registered:</span>
<span>{{ formatDate(getCurrentTicket(group.tickets.filter(t => t.paid), group.eventId).reg_timestamp) }}</span>
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="pending">
<ScrollArea class="h-[600px] w-full pr-4">
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="group in groupedTickets.filter(g => g.pendingCount > 0)" :key="group.eventId" class="flex flex-col opacity-75">
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle class="text-foreground">Event: {{ group.eventId }}</CardTitle>
<Badge variant="secondary">
{{ group.pendingCount }} pending
</Badge>
</div>
<CardDescription>
{{ group.pendingCount }} pending ticket{{ group.pendingCount !== 1 ? 's' : '' }}
</CardDescription>
</CardHeader>
<CardContent class="flex-grow">
<div v-if="group.tickets.filter(t => !t.paid).length > 0" class="space-y-4">
<!-- Ticket Navigation -->
<div class="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
@click="prevTicket(group.eventId, group.tickets.filter(t => !t.paid).length)"
:disabled="group.tickets.filter(t => !t.paid).length <= 1"
>
<ChevronLeft class="w-4 h-4" />
</Button>
<span class="text-sm text-muted-foreground">
{{ getCurrentTicketIndex(group.eventId) + 1 }} of {{ group.tickets.filter(t => !t.paid).length }}
</span>
<Button
variant="ghost"
size="sm"
@click="nextTicket(group.eventId, group.tickets.filter(t => !t.paid).length)"
:disabled="group.tickets.filter(t => !t.paid).length <= 1"
>
<ChevronRight class="w-4 h-4" />
</Button>
</div>
<!-- Current Ticket Display -->
<div v-if="getCurrentTicket(group.tickets.filter(t => !t.paid), group.eventId)" class="space-y-4">
<!-- QR Code - Always Visible -->
<div class="flex justify-center">
<div class="text-center space-y-2">
<img
v-if="qrCodes[getCurrentTicket(group.tickets.filter(t => !t.paid), group.eventId).id]"
:src="qrCodes[getCurrentTicket(group.tickets.filter(t => !t.paid), group.eventId).id]"
alt="Ticket QR Code"
class="w-48 h-48 border rounded-lg mx-auto"
/>
<div v-else class="w-48 h-48 border rounded-lg flex items-center justify-center mx-auto">
<span class="text-xs text-muted-foreground">Loading...</span>
</div>
<div class="text-center">
<p class="text-xs text-muted-foreground">Ticket ID</p>
<div class="bg-background border rounded px-2 py-1 max-w-full mt-1">
<p class="text-xs font-mono break-all">{{ getCurrentTicket(group.tickets.filter(t => !t.paid), group.eventId).id }}</p>
</div>
</div>
</div>
</div>
<!-- Ticket Details -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm font-medium">
Ticket #{{ getCurrentTicket(group.tickets.filter(t => !t.paid), group.eventId).id.slice(0, 8) }}
</span>
<Badge variant="secondary">
{{ getTicketStatus(getCurrentTicket(group.tickets.filter(t => !t.paid), group.eventId)).label }}
</Badge>
</div>
<div class="space-y-1 text-sm">
<div class="flex justify-between">
<span class="text-muted-foreground">Status:</span>
<div class="flex items-center gap-1">
<component :is="getTicketStatus(getCurrentTicket(group.tickets.filter(t => !t.paid), group.eventId)).icon" class="w-4 h-4" :class="getTicketStatus(getCurrentTicket(group.tickets.filter(t => !t.paid), group.eventId)).color" />
<span>{{ getTicketStatus(getCurrentTicket(group.tickets.filter(t => !t.paid), group.eventId)).label }}</span>
</div>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Created:</span>
<span>{{ formatDate(getCurrentTicket(group.tickets.filter(t => !t.paid), group.eventId).time) }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Time:</span>
<span>{{ formatTime(getCurrentTicket(group.tickets.filter(t => !t.paid), group.eventId).time) }}</span>
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</ScrollArea>
</TabsContent>
<TabsContent value="registered">
<ScrollArea class="h-[600px] w-full pr-4">
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Card v-for="group in groupedTickets.filter(g => g.registeredCount > 0)" :key="group.eventId" class="flex flex-col">
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle class="text-foreground">Event: {{ group.eventId }}</CardTitle>
<Badge variant="default">
{{ group.registeredCount }} registered
</Badge>
</div>
<CardDescription>
{{ group.registeredCount }} registered ticket{{ group.registeredCount !== 1 ? 's' : '' }}
</CardDescription>
</CardHeader>
<CardContent class="flex-grow">
<div v-if="group.tickets.filter(t => t.registered).length > 0" class="space-y-4">
<!-- Ticket Navigation -->
<div class="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
@click="prevTicket(group.eventId, group.tickets.filter(t => t.registered).length)"
:disabled="group.tickets.filter(t => t.registered).length <= 1"
>
<ChevronLeft class="w-4 h-4" />
</Button>
<span class="text-sm text-muted-foreground">
{{ getCurrentTicketIndex(group.eventId) + 1 }} of {{ group.tickets.filter(t => t.registered).length }}
</span>
<Button
variant="ghost"
size="sm"
@click="nextTicket(group.eventId, group.tickets.filter(t => t.registered).length)"
:disabled="group.tickets.filter(t => t.registered).length <= 1"
>
<ChevronRight class="w-4 h-4" />
</Button>
</div>
<!-- Current Ticket Display -->
<div v-if="getCurrentTicket(group.tickets.filter(t => t.registered), group.eventId)" class="space-y-4">
<!-- QR Code - Always Visible -->
<div class="flex justify-center">
<div class="text-center space-y-2">
<img
v-if="qrCodes[getCurrentTicket(group.tickets.filter(t => t.registered), group.eventId).id]"
:src="qrCodes[getCurrentTicket(group.tickets.filter(t => t.registered), group.eventId).id]"
alt="Ticket QR Code"
class="w-48 h-48 border rounded-lg mx-auto"
/>
<div v-else class="w-48 h-48 border rounded-lg flex items-center justify-center mx-auto">
<span class="text-xs text-muted-foreground">Loading...</span>
</div>
<div class="text-center">
<p class="text-xs text-muted-foreground">Ticket ID</p>
<div class="bg-background border rounded px-2 py-1 max-w-full mt-1">
<p class="text-xs font-mono break-all">{{ getCurrentTicket(group.tickets.filter(t => t.registered), group.eventId).id }}</p>
</div>
</div>
</div>
</div>
<!-- Ticket Details -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm font-medium">
Ticket #{{ getCurrentTicket(group.tickets.filter(t => t.registered), group.eventId).id.slice(0, 8) }}
</span>
<Badge variant="default">
{{ getTicketStatus(getCurrentTicket(group.tickets.filter(t => t.registered), group.eventId)).label }}
</Badge>
</div>
<div class="space-y-1 text-sm">
<div class="flex justify-between">
<span class="text-muted-foreground">Status:</span>
<div class="flex items-center gap-1">
<component :is="getTicketStatus(getCurrentTicket(group.tickets.filter(t => t.registered), group.eventId)).icon" class="w-4 h-4" :class="getTicketStatus(getCurrentTicket(group.tickets.filter(t => t.registered), group.eventId)).color" />
<span>{{ getTicketStatus(getCurrentTicket(group.tickets.filter(t => t.registered), group.eventId)).label }}</span>
</div>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Purchased:</span>
<span>{{ formatDate(getCurrentTicket(group.tickets.filter(t => t.registered), group.eventId).time) }}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">Time:</span>
<span>{{ formatTime(getCurrentTicket(group.tickets.filter(t => t.registered), group.eventId).time) }}</span>
</div>
<div v-if="getCurrentTicket(group.tickets.filter(t => t.registered), group.eventId).reg_timestamp" class="flex justify-between">
<span class="text-muted-foreground">Registered:</span>
<span>{{ formatDate(getCurrentTicket(group.tickets.filter(t => t.registered), group.eventId).reg_timestamp) }}</span>
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</ScrollArea>
</TabsContent>
</Tabs>
</div>
</div>
</template>

View file

@ -200,7 +200,7 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Minus, Plus, Trash2 } from 'lucide-vue-next' import { Minus, Plus, Trash2 } from 'lucide-vue-next'
import type { CartItem as CartItemType } from '@/stores/market' import type { CartItem as CartItemType } from '../types/market'
interface Props { interface Props {
item: CartItemType item: CartItemType

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

View file

@ -1,187 +1,4 @@
<template> <script lang="ts">
<div class="container mx-auto px-4 py-8"> import MarketPageComponent from '@/modules/market/views/MarketPage.vue'
<!-- Loading State --> export default MarketPageComponent
<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/market/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> </script>

View file

@ -1,125 +1,4 @@
<template> <script lang="ts">
<div class="container mx-auto px-4 py-8"> import MarketDashboardComponent from '@/modules/market/views/MarketDashboard.vue'
<!-- Page Header --> export default MarketDashboardComponent
<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/market/DashboardOverview.vue'
import OrderHistory from '@/components/market/OrderHistory.vue'
import MerchantStore from '@/components/market/MerchantStore.vue'
import MarketSettings from '@/components/market/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> </script>

View file

@ -22,33 +22,6 @@ const router = createRouter({
requiresAuth: false requiresAuth: false
} }
}, },
{
path: '/events',
name: 'events',
component: () => import('@/pages/events.vue'),
meta: {
title: 'Events',
requiresAuth: true
}
},
{
path: '/my-tickets',
name: 'my-tickets',
component: () => import('@/pages/MyTickets.vue'),
meta: {
title: 'My Tickets',
requiresAuth: true
}
},
{
path: '/market',
name: 'market',
component: () => import('@/pages/Market.vue'),
meta: {
title: 'Market',
requiresAuth: true
}
},
{ {
path: '/cart', path: '/cart',
name: 'cart', name: 'cart',
@ -73,21 +46,6 @@ const router = createRouter({
component: () => import('@/pages/OrderHistory.vue'), component: () => import('@/pages/OrderHistory.vue'),
meta: { requiresAuth: true } meta: { requiresAuth: true }
}, },
{
path: '/market-dashboard',
name: 'MarketDashboard',
component: () => import('@/pages/MarketDashboard.vue'),
meta: { requiresAuth: true }
},
{
path: '/chat',
name: 'chat',
component: () => import('@/pages/ChatPage.vue'),
meta: {
title: 'Nostr Chat',
requiresAuth: true
}
},
{ {
path: '/relay-hub-status', path: '/relay-hub-status',
name: 'relay-hub-status', name: 'relay-hub-status',

File diff suppressed because it is too large Load diff