Enhance market module with new chat and events features
- Introduce chat module with components, services, and composables for real-time messaging. - Implement events module with API service, components, and ticket purchasing functionality. - Update app configuration to include new modules and their respective settings. - Refactor existing components to integrate with the new chat and events features. - Enhance market store and services to support new functionalities and improve order management. - Update routing to accommodate new views for chat and events, ensuring seamless navigation.
This commit is contained in:
parent
519a9003d4
commit
e40ac91417
46 changed files with 6305 additions and 3264 deletions
|
|
@ -54,6 +54,10 @@ export const appConfig: AppConfig = {
|
|||
enabled: true,
|
||||
lazy: false,
|
||||
config: {
|
||||
apiConfig: {
|
||||
baseUrl: 'http://lnbits',
|
||||
apiKey: 'your-api-key-here'
|
||||
},
|
||||
ticketValidationEndpoint: '/api/tickets/validate',
|
||||
maxTicketsPerUser: 10
|
||||
}
|
||||
|
|
|
|||
27
src/app.ts
27
src/app.ts
|
|
@ -12,6 +12,9 @@ import appConfig from './app.config'
|
|||
// Base modules
|
||||
import baseModule from './modules/base'
|
||||
import nostrFeedModule from './modules/nostr-feed'
|
||||
import chatModule from './modules/chat'
|
||||
import eventsModule from './modules/events'
|
||||
import marketModule from './modules/market'
|
||||
|
||||
// Root component
|
||||
import App from './App.vue'
|
||||
|
|
@ -81,10 +84,26 @@ export async function createAppInstance() {
|
|||
)
|
||||
}
|
||||
|
||||
// TODO: Register other modules as they're converted
|
||||
// - market module
|
||||
// - chat module
|
||||
// - events module
|
||||
// Register chat module
|
||||
if (appConfig.modules.chat.enabled) {
|
||||
moduleRegistrations.push(
|
||||
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
|
||||
await Promise.all(moduleRegistrations)
|
||||
|
|
|
|||
|
|
@ -1,231 +1,4 @@
|
|||
<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>
|
||||
<script lang="ts">
|
||||
import CartSummaryComponent from '@/modules/market/components/CartSummary.vue'
|
||||
export default CartSummaryComponent
|
||||
</script>
|
||||
|
|
@ -1,334 +1,4 @@
|
|||
<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>
|
||||
<script lang="ts">
|
||||
import PaymentDisplayComponent from '@/modules/market/components/PaymentDisplay.vue'
|
||||
export default PaymentDisplayComponent
|
||||
</script>
|
||||
|
|
@ -1,250 +1,4 @@
|
|||
<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>
|
||||
<script lang="ts">
|
||||
import ShoppingCartComponent from '@/modules/market/components/ShoppingCart.vue'
|
||||
export default ShoppingCartComponent
|
||||
</script>
|
||||
|
|
@ -1,534 +1,3 @@
|
|||
import { ref, computed, onMounted, onUnmounted, readonly } from 'vue'
|
||||
import { useNostrStore } from '@/stores/nostr'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
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
|
||||
}
|
||||
}
|
||||
// Compatibility re-export for the moved useMarket composable
|
||||
export * from '@/modules/market/composables/useMarket'
|
||||
export { useMarket } from '@/modules/market/composables/useMarket'
|
||||
|
|
@ -1,60 +1,3 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
// Compatibility re-export for the moved useMarketPreloader composable
|
||||
export * from '@/modules/market/composables/useMarketPreloader'
|
||||
export { useMarketPreloader } from '@/modules/market/composables/useMarketPreloader'
|
||||
|
|
@ -1,460 +1,3 @@
|
|||
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()
|
||||
// Compatibility re-export for the moved nostrmarketService
|
||||
export * from '@/modules/market/services/nostrmarketService'
|
||||
export { nostrmarketService } from '@/modules/market/services/nostrmarketService'
|
||||
627
src/modules/chat/components/ChatComponent.vue
Normal file
627
src/modules/chat/components/ChatComponent.vue
Normal 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>
|
||||
82
src/modules/chat/composables/useChat.ts
Normal file
82
src/modules/chat/composables/useChat.ts
Normal 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
106
src/modules/chat/index.ts
Normal 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'
|
||||
240
src/modules/chat/services/chat-service.ts
Normal file
240
src/modules/chat/services/chat-service.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
57
src/modules/chat/types/index.ts
Normal file
57
src/modules/chat/types/index.ts
Normal 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
|
||||
}
|
||||
}
|
||||
9
src/modules/chat/views/ChatPage.vue
Normal file
9
src/modules/chat/views/ChatPage.vue
Normal 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>
|
||||
255
src/modules/events/components/PurchaseTicketDialog.vue
Normal file
255
src/modules/events/components/PurchaseTicketDialog.vue
Normal 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>
|
||||
65
src/modules/events/composables/useEvents.ts
Normal file
65
src/modules/events/composables/useEvents.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
242
src/modules/events/composables/useTicketPurchase.ts
Normal file
242
src/modules/events/composables/useTicketPurchase.ts
Normal 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
|
||||
}
|
||||
}
|
||||
123
src/modules/events/composables/useUserTickets.ts
Normal file
123
src/modules/events/composables/useUserTickets.ts
Normal 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
116
src/modules/events/index.ts
Normal 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'
|
||||
155
src/modules/events/services/events-api.ts
Normal file
155
src/modules/events/services/events-api.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/modules/events/types/event.ts
Normal file
36
src/modules/events/types/event.ts
Normal 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
|
||||
}>
|
||||
}
|
||||
168
src/modules/events/views/EventsPage.vue
Normal file
168
src/modules/events/views/EventsPage.vue
Normal 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>
|
||||
599
src/modules/events/views/MyTicketsPage.vue
Normal file
599
src/modules/events/views/MyTicketsPage.vue
Normal 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>
|
||||
|
|
@ -200,7 +200,7 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Minus, Plus, Trash2 } from 'lucide-vue-next'
|
||||
import type { CartItem as CartItemType } from '@/stores/market'
|
||||
import type { CartItem as CartItemType } from '../types/market'
|
||||
|
||||
interface Props {
|
||||
item: CartItemType
|
||||
231
src/modules/market/components/CartSummary.vue
Normal file
231
src/modules/market/components/CartSummary.vue
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
<template>
|
||||
<div class="bg-card border rounded-lg p-6 shadow-sm">
|
||||
<!-- Cart Summary Header -->
|
||||
<div class="border-b border-border pb-4 mb-4">
|
||||
<h3 class="text-lg font-semibold text-foreground">Order Summary</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ itemCount }} item{{ itemCount !== 1 ? 's' : '' }} in cart
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Cart Items Summary -->
|
||||
<div class="space-y-3 mb-4">
|
||||
<div
|
||||
v-for="item in cartItems"
|
||||
:key="item.product.id"
|
||||
class="flex items-center justify-between text-sm"
|
||||
>
|
||||
<div class="flex items-center space-x-3">
|
||||
<img
|
||||
:src="item.product.images?.[0] || '/placeholder-product.png'"
|
||||
:alt="item.product.name"
|
||||
class="w-8 h-8 object-cover rounded"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-medium text-foreground">{{ item.product.name }}</p>
|
||||
<p class="text-muted-foreground">Qty: {{ item.quantity }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="font-medium text-foreground">
|
||||
{{ formatPrice(item.product.price * item.quantity, item.product.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shipping Zone Selection -->
|
||||
<div class="border-t border-border pt-4 mb-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label class="text-sm font-medium text-foreground">Shipping Zone</label>
|
||||
<Button
|
||||
v-if="availableShippingZones.length > 1"
|
||||
@click="showShippingSelector = !showShippingSelector"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
{{ selectedShippingZone ? 'Change' : 'Select' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedShippingZone" class="flex items-center justify-between p-3 bg-muted rounded">
|
||||
<div>
|
||||
<p class="font-medium text-foreground">{{ selectedShippingZone.name }}</p>
|
||||
<p v-if="selectedShippingZone.description" class="text-sm text-muted-foreground">
|
||||
{{ selectedShippingZone.description }}
|
||||
</p>
|
||||
<p v-if="selectedShippingZone.estimatedDays" class="text-xs text-muted-foreground">
|
||||
Estimated: {{ selectedShippingZone.estimatedDays }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="font-semibold text-foreground">
|
||||
{{ formatPrice(selectedShippingZone.cost, selectedShippingZone.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Shipping Zone Selector -->
|
||||
<div v-if="showShippingSelector && availableShippingZones.length > 1" class="mt-2">
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="zone in availableShippingZones"
|
||||
:key="zone.id"
|
||||
@click="selectShippingZone(zone)"
|
||||
class="flex items-center justify-between p-3 border rounded cursor-pointer hover:bg-muted/50"
|
||||
:class="{ 'border-primary bg-primary/10': selectedShippingZone?.id === zone.id }"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium text-foreground">{{ zone.name }}</p>
|
||||
<p v-if="zone.description" class="text-sm text-muted-foreground">
|
||||
{{ zone.description }}
|
||||
</p>
|
||||
<p v-if="zone.estimatedDays" class="text-xs text-muted-foreground">
|
||||
Estimated: {{ zone.estimatedDays }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="font-semibold text-foreground">
|
||||
{{ formatPrice(zone.cost, zone.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!selectedShippingZone" class="text-sm text-red-600">
|
||||
Please select a shipping zone
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Price Breakdown -->
|
||||
<div class="border-t border-border pt-4 mb-6">
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Subtotal</span>
|
||||
<span class="text-foreground">{{ formatPrice(subtotal, currency) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedShippingZone" class="flex justify-between text-sm">
|
||||
<span class="text-muted-foreground">Shipping</span>
|
||||
<span class="text-foreground">
|
||||
{{ formatPrice(selectedShippingZone.cost, selectedShippingZone.currency) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-border pt-2 flex justify-between font-semibold text-lg">
|
||||
<span class="text-foreground">Total</span>
|
||||
<span class="text-green-600">{{ formatPrice(total, currency) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Checkout Actions -->
|
||||
<div class="space-y-3">
|
||||
<Button
|
||||
@click="continueShopping"
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
>
|
||||
Back to Cart
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Security Note -->
|
||||
<div class="mt-4 p-3 bg-muted/50 rounded-lg">
|
||||
<div class="flex items-start space-x-2">
|
||||
<Shield class="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<p class="font-medium text-foreground">Secure Checkout</p>
|
||||
<p>Your order will be encrypted and sent securely to the merchant using Nostr.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Shield } from 'lucide-vue-next'
|
||||
import type { ShippingZone } from '@/stores/market'
|
||||
|
||||
interface Props {
|
||||
stallId: string
|
||||
cartItems: readonly {
|
||||
readonly product: {
|
||||
readonly id: string
|
||||
readonly stall_id: string
|
||||
readonly stallName: string
|
||||
readonly name: string
|
||||
readonly description?: string
|
||||
readonly price: number
|
||||
readonly currency: string
|
||||
readonly quantity: number
|
||||
readonly images?: readonly string[]
|
||||
readonly categories?: readonly string[]
|
||||
readonly createdAt: number
|
||||
readonly updatedAt: number
|
||||
}
|
||||
readonly quantity: number
|
||||
readonly stallId: string
|
||||
}[]
|
||||
subtotal: number
|
||||
currency: string
|
||||
availableShippingZones: readonly {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly cost: number
|
||||
readonly currency: string
|
||||
readonly description?: string
|
||||
readonly estimatedDays?: string
|
||||
readonly requiresPhysicalShipping?: boolean
|
||||
}[]
|
||||
selectedShippingZone?: {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly cost: number
|
||||
readonly currency: string
|
||||
readonly description?: string
|
||||
readonly estimatedDays?: string
|
||||
readonly requiresPhysicalShipping?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'shipping-zone-selected': [shippingZone: ShippingZone]
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
// const marketStore = useMarketStore()
|
||||
|
||||
// Local state
|
||||
const showShippingSelector = ref(false)
|
||||
|
||||
// Computed properties
|
||||
const itemCount = computed(() =>
|
||||
props.cartItems.reduce((total, item) => total + item.quantity, 0)
|
||||
)
|
||||
|
||||
const total = computed(() => {
|
||||
const shippingCost = props.selectedShippingZone?.cost || 0
|
||||
return props.subtotal + shippingCost
|
||||
})
|
||||
|
||||
// Methods
|
||||
const selectShippingZone = (shippingZone: ShippingZone) => {
|
||||
emit('shipping-zone-selected', shippingZone)
|
||||
showShippingSelector.value = false
|
||||
}
|
||||
|
||||
const continueShopping = () => {
|
||||
router.push('/cart')
|
||||
}
|
||||
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
if (currency === 'sats' || currency === 'sat') {
|
||||
return `${price.toLocaleString()} sats`
|
||||
}
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase()
|
||||
}).format(price)
|
||||
}
|
||||
</script>
|
||||
334
src/modules/market/components/PaymentDisplay.vue
Normal file
334
src/modules/market/components/PaymentDisplay.vue
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
<template>
|
||||
<div class="bg-background border rounded-lg p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-foreground">Payment</h3>
|
||||
<Badge :variant="getPaymentStatusVariant(paymentStatus)">
|
||||
{{ formatPaymentStatus(paymentStatus) }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<!-- Invoice Information -->
|
||||
<div v-if="invoice" class="space-y-4">
|
||||
<!-- Amount and Status -->
|
||||
<div class="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">Amount</p>
|
||||
<p class="text-2xl font-bold text-foreground">
|
||||
{{ invoice.amount }} {{ currency }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-muted-foreground">Status</p>
|
||||
<p class="text-lg font-semibold" :class="getStatusColor(paymentStatus)">
|
||||
{{ formatPaymentStatus(paymentStatus) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lightning Invoice QR Code -->
|
||||
<div class="text-center">
|
||||
<div class="mb-4">
|
||||
<h4 class="font-medium text-foreground mb-2">Lightning Invoice</h4>
|
||||
<p class="text-sm text-muted-foreground">Scan with your Lightning wallet to pay</p>
|
||||
</div>
|
||||
|
||||
<!-- QR Code -->
|
||||
<div class="w-48 h-48 mx-auto mb-4">
|
||||
<div v-if="qrCodeDataUrl && !qrCodeError" class="w-full h-full">
|
||||
<img
|
||||
:src="qrCodeDataUrl"
|
||||
:alt="`QR Code for ${invoice.amount} ${currency} payment`"
|
||||
class="w-full h-full border border-border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="qrCodeLoading" class="w-full h-full bg-muted rounded-lg flex items-center justify-center">
|
||||
<div class="text-center text-muted-foreground">
|
||||
<div class="text-4xl mb-2 animate-pulse">⚡</div>
|
||||
<div class="text-sm">Generating QR...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="qrCodeError" class="w-full h-full bg-destructive/10 border border-destructive/20 rounded-lg flex items-center justify-center">
|
||||
<div class="text-center text-destructive">
|
||||
<div class="text-4xl mb-2">⚠️</div>
|
||||
<div class="text-sm">{{ qrCodeError }}</div>
|
||||
<Button
|
||||
@click="retryQRCode"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="mt-2"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full h-full bg-muted rounded-lg flex items-center justify-center">
|
||||
<div class="text-center text-muted-foreground">
|
||||
<div class="text-4xl mb-2">⚡</div>
|
||||
<div class="text-sm">No invoice</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code Actions -->
|
||||
<div v-if="qrCodeDataUrl && !qrCodeError" class="mb-4">
|
||||
<!-- Download button removed -->
|
||||
</div>
|
||||
|
||||
<!-- Payment Request -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-foreground mb-2">
|
||||
Payment Request
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
:value="invoice.bolt11"
|
||||
readonly
|
||||
class="flex-1 font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
@click="copyPaymentRequest"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Copy class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Copy to Wallet Button -->
|
||||
<Button
|
||||
@click="openInWallet"
|
||||
variant="default"
|
||||
class="w-full"
|
||||
>
|
||||
<Wallet class="w-4 h-4 mr-2" />
|
||||
Open in Lightning Wallet
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Payment Details -->
|
||||
<div class="border-t pt-4">
|
||||
<h4 class="font-medium text-foreground mb-3">Payment Details</h4>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Payment Hash:</span>
|
||||
<span class="font-mono text-foreground">{{ formatHash(invoice.payment_hash) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Created:</span>
|
||||
<span class="text-foreground">{{ formatDate(invoice.created_at) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Expires:</span>
|
||||
<span class="text-foreground">{{ formatDate(invoice.expiry) }}</span>
|
||||
</div>
|
||||
<div v-if="paidAt" class="flex justify-between">
|
||||
<span class="text-muted-foreground">Paid At:</span>
|
||||
<span class="text-foreground">{{ formatDate(paidAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Invoice State -->
|
||||
<div v-else class="text-center py-8">
|
||||
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Wallet class="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h4 class="text-lg font-medium text-foreground mb-2">No Payment Invoice</h4>
|
||||
<p class="text-muted-foreground mb-4">
|
||||
A Lightning invoice will be sent by the merchant once they process your order.
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
You'll receive the invoice via Nostr when it's ready.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Payment Instructions -->
|
||||
<div v-if="paymentStatus === 'pending'" class="mt-6 p-4 bg-muted/50 border border-border rounded-lg">
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="w-5 h-5 bg-muted rounded-full flex items-center justify-center mt-0.5">
|
||||
<Info class="w-3 h-3 text-muted-foreground" />
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
<h5 class="font-medium mb-1 text-foreground">Payment Instructions</h5>
|
||||
<ul class="space-y-1">
|
||||
<li>• Use a Lightning-compatible wallet (e.g., Phoenix, Breez, Alby)</li>
|
||||
<li>• Scan the QR code or copy the payment request</li>
|
||||
<li>• Confirm the payment amount and send</li>
|
||||
<li>• Your order will be processed once payment is confirmed</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Success -->
|
||||
<div v-if="paymentStatus === 'paid'" class="mt-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-5 h-5 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<CheckCircle class="w-3 h-3 text-green-600" />
|
||||
</div>
|
||||
<div class="text-sm text-green-800">
|
||||
<h5 class="font-medium">Payment Confirmed!</h5>
|
||||
<p>Your order is being processed. You'll receive updates via Nostr.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Copy,
|
||||
Wallet,
|
||||
Info,
|
||||
CheckCircle
|
||||
} from 'lucide-vue-next'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
|
||||
interface Props {
|
||||
orderId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
// Computed properties
|
||||
const order = computed(() => marketStore.orders[props.orderId])
|
||||
const invoice = computed(() => order.value?.lightningInvoice)
|
||||
const paymentStatus = computed(() => order.value?.paymentStatus || 'pending')
|
||||
const currency = computed(() => order.value?.currency || 'sat')
|
||||
const paidAt = computed(() => order.value?.paidAt)
|
||||
|
||||
// QR Code generation
|
||||
const qrCodeDataUrl = ref<string | null>(null)
|
||||
const qrCodeLoading = ref(false)
|
||||
const qrCodeError = ref<string | null>(null)
|
||||
|
||||
const generateQRCode = async (paymentRequest: string) => {
|
||||
try {
|
||||
qrCodeLoading.value = true
|
||||
qrCodeError.value = null
|
||||
|
||||
const dataUrl = await QRCode.toDataURL(paymentRequest, {
|
||||
width: 192, // 48 * 4 for high DPI displays
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF'
|
||||
}
|
||||
})
|
||||
qrCodeDataUrl.value = dataUrl
|
||||
} catch (error) {
|
||||
console.error('Failed to generate QR code:', error)
|
||||
qrCodeError.value = 'Failed to generate QR code'
|
||||
qrCodeDataUrl.value = null
|
||||
} finally {
|
||||
qrCodeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Methods
|
||||
const getPaymentStatusVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid': return 'default'
|
||||
case 'pending': return 'secondary'
|
||||
case 'expired': return 'destructive'
|
||||
default: return 'outline'
|
||||
}
|
||||
}
|
||||
|
||||
const formatPaymentStatus = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid': return 'Paid'
|
||||
case 'pending': return 'Pending'
|
||||
case 'expired': return 'Expired'
|
||||
default: return 'Unknown'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid': return 'text-green-600'
|
||||
case 'pending': return 'text-amber-600'
|
||||
case 'expired': return 'text-destructive'
|
||||
default: return 'text-muted-foreground'
|
||||
}
|
||||
}
|
||||
|
||||
const formatHash = (hash: string) => {
|
||||
if (!hash) return 'N/A'
|
||||
return `${hash.substring(0, 8)}...${hash.substring(hash.length - 8)}`
|
||||
}
|
||||
|
||||
const formatDate = (dateValue: string | number | undefined) => {
|
||||
if (!dateValue) return 'N/A'
|
||||
|
||||
let timestamp: number
|
||||
|
||||
if (typeof dateValue === 'string') {
|
||||
// Handle ISO date strings from LNBits API
|
||||
timestamp = new Date(dateValue).getTime()
|
||||
} else {
|
||||
// Handle Unix timestamps (seconds) from our store
|
||||
timestamp = dateValue * 1000
|
||||
}
|
||||
|
||||
return new Date(timestamp).toLocaleString()
|
||||
}
|
||||
|
||||
const copyPaymentRequest = async () => {
|
||||
if (!invoice.value?.bolt11) return
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(invoice.value.bolt11)
|
||||
// TODO: Show toast notification
|
||||
console.log('Payment request copied to clipboard')
|
||||
} catch (error) {
|
||||
console.error('Failed to copy payment request:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const openInWallet = () => {
|
||||
if (!invoice.value?.bolt11) return
|
||||
|
||||
// Open in Lightning wallet
|
||||
const walletUrl = `lightning:${invoice.value.bolt11}`
|
||||
window.open(walletUrl, '_blank')
|
||||
}
|
||||
|
||||
const retryQRCode = () => {
|
||||
if (invoice.value?.bolt11) {
|
||||
generateQRCode(invoice.value.bolt11)
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
// Set up payment monitoring if invoice exists
|
||||
if (invoice.value && props.orderId) {
|
||||
// Payment monitoring is handled by the market store
|
||||
console.log('Payment display mounted for order:', props.orderId)
|
||||
|
||||
// Generate QR code for the invoice
|
||||
if (invoice.value.bolt11) {
|
||||
generateQRCode(invoice.value.bolt11)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for invoice changes to regenerate QR code
|
||||
watch(() => invoice.value?.bolt11, (newPaymentRequest) => {
|
||||
if (newPaymentRequest) {
|
||||
generateQRCode(newPaymentRequest)
|
||||
} else {
|
||||
qrCodeDataUrl.value = null
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
250
src/modules/market/components/ShoppingCart.vue
Normal file
250
src/modules/market/components/ShoppingCart.vue
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Cart Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-foreground">Shopping Cart</h2>
|
||||
<p class="text-muted-foreground">
|
||||
{{ totalCartItems }} items across {{ allStallCarts.length }} stalls
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-muted-foreground">Total Value</p>
|
||||
<p class="text-xl font-bold text-green-600">
|
||||
{{ formatPrice(totalCartValue, 'sats') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="allStallCarts.length > 0"
|
||||
@click="clearAllCarts"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty Cart State -->
|
||||
<div v-if="allStallCarts.length === 0" class="text-center py-12">
|
||||
<ShoppingCart class="mx-auto h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<h3 class="text-lg font-medium text-foreground mb-2">Your cart is empty</h3>
|
||||
<p class="text-muted-foreground mb-6">Start shopping to add items to your cart</p>
|
||||
<Button @click="$router.push('/market')" variant="default">
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Stall Carts -->
|
||||
<div v-else class="space-y-6">
|
||||
<div
|
||||
v-for="cart in allStallCarts"
|
||||
:key="cart.id"
|
||||
class="border border-border rounded-lg p-6 bg-card shadow-sm"
|
||||
>
|
||||
<!-- Stall Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center">
|
||||
<Store class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-foreground">
|
||||
{{ getStallName(cart.id) }}
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ cart.products.length }} item{{ cart.products.length !== 1 ? 's' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-right">
|
||||
<p class="text-sm text-muted-foreground">Stall Total</p>
|
||||
<p class="text-lg font-semibold text-green-600">
|
||||
{{ formatPrice(cart.subtotal, cart.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cart Items -->
|
||||
<div class="space-y-3 mb-4">
|
||||
<CartItem
|
||||
v-for="item in cart.products"
|
||||
:key="item.product.id"
|
||||
:item="item"
|
||||
:stall-id="cart.id"
|
||||
@update-quantity="updateQuantity"
|
||||
@remove-item="removeItem"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Stall Cart Actions -->
|
||||
<div class="pt-4 border-t border-border">
|
||||
<!-- Desktop Layout -->
|
||||
<div class="hidden md:flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<Button
|
||||
@click="clearStallCart(cart.id)"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Clear Stall
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@click="viewStall(cart.id)"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
View Stall
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<!-- Cart Summary for this stall -->
|
||||
<div class="text-right mr-4">
|
||||
<p class="text-sm text-muted-foreground">Total</p>
|
||||
<p class="text-lg font-semibold text-green-600">
|
||||
{{ formatPrice(cart.subtotal, cart.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@click="proceedToCheckout(cart.id)"
|
||||
:disabled="!canProceedToCheckout(cart.id)"
|
||||
variant="default"
|
||||
>
|
||||
Checkout
|
||||
<ArrowRight class="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Layout -->
|
||||
<div class="md:hidden space-y-4">
|
||||
<!-- Action Buttons Row -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Button
|
||||
@click="clearStallCart(cart.id)"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Clear Stall
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@click="viewStall(cart.id)"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
>
|
||||
View Stall
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total and Checkout Row -->
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Cart Summary for this stall -->
|
||||
<div class="text-left">
|
||||
<p class="text-sm text-muted-foreground">Total</p>
|
||||
<p class="text-lg font-semibold text-green-600">
|
||||
{{ formatPrice(cart.subtotal, cart.currency) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@click="proceedToCheckout(cart.id)"
|
||||
:disabled="!canProceedToCheckout(cart.id)"
|
||||
variant="default"
|
||||
class="flex items-center"
|
||||
>
|
||||
Checkout
|
||||
<ArrowRight class="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Continue Shopping Button -->
|
||||
<div v-if="allStallCarts.length > 0" class="text-center mt-8">
|
||||
<Button @click="$router.push('/market')" variant="outline" size="lg">
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
ShoppingCart,
|
||||
Store,
|
||||
ArrowRight
|
||||
} from 'lucide-vue-next'
|
||||
import CartItem from './CartItem.vue'
|
||||
|
||||
|
||||
const router = useRouter()
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
// Computed properties
|
||||
const allStallCarts = computed(() => marketStore.allStallCarts)
|
||||
const totalCartItems = computed(() => marketStore.totalCartItems)
|
||||
const totalCartValue = computed(() => marketStore.totalCartValue)
|
||||
|
||||
// Methods
|
||||
const getStallName = (stallId: string) => {
|
||||
const stall = marketStore.stalls.find(s => s.id === stallId)
|
||||
return stall?.name || 'Unknown Stall'
|
||||
}
|
||||
|
||||
const updateQuantity = (stallId: string, productId: string, quantity: number) => {
|
||||
marketStore.updateStallCartQuantity(stallId, productId, quantity)
|
||||
}
|
||||
|
||||
const removeItem = (stallId: string, productId: string) => {
|
||||
marketStore.removeFromStallCart(stallId, productId)
|
||||
}
|
||||
|
||||
const clearStallCart = (stallId: string) => {
|
||||
marketStore.clearStallCart(stallId)
|
||||
}
|
||||
|
||||
const clearAllCarts = () => {
|
||||
marketStore.clearAllStallCarts()
|
||||
}
|
||||
|
||||
const viewStall = (stallId: string) => {
|
||||
// TODO: Navigate to stall page
|
||||
console.log('View stall:', stallId)
|
||||
}
|
||||
|
||||
const proceedToCheckout = (stallId: string) => {
|
||||
marketStore.setCheckoutCart(stallId)
|
||||
router.push(`/checkout/${stallId}`)
|
||||
}
|
||||
|
||||
const canProceedToCheckout = (stallId: string) => {
|
||||
const cart = marketStore.stallCarts[stallId]
|
||||
return cart && cart.products.length > 0
|
||||
}
|
||||
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
if (currency === 'sats' || currency === 'sat') {
|
||||
return `${price.toLocaleString()} sats`
|
||||
}
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase()
|
||||
}).format(price)
|
||||
}
|
||||
</script>
|
||||
2
src/modules/market/composables/index.ts
Normal file
2
src/modules/market/composables/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { useMarket } from './useMarket'
|
||||
export { useMarketPreloader } from './useMarketPreloader'
|
||||
538
src/modules/market/composables/useMarket.ts
Normal file
538
src/modules/market/composables/useMarket.ts
Normal file
|
|
@ -0,0 +1,538 @@
|
|||
import { ref, computed, onMounted, onUnmounted, readonly } from 'vue'
|
||||
import { useNostrStore } from '@/stores/nostr'
|
||||
import { useMarketStore } from '../stores/market'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import { config } from '@/lib/config'
|
||||
|
||||
// Nostr event kinds for market functionality
|
||||
const MARKET_EVENT_KINDS = {
|
||||
MARKET: 30019,
|
||||
STALL: 30017,
|
||||
PRODUCT: 30018,
|
||||
ORDER: 30020
|
||||
} as const
|
||||
|
||||
export function useMarket() {
|
||||
const nostrStore = useNostrStore()
|
||||
const marketStore = useMarketStore()
|
||||
const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) as any
|
||||
|
||||
if (!relayHub) {
|
||||
throw new Error('RelayHub not available. Make sure base module is installed.')
|
||||
}
|
||||
|
||||
// State
|
||||
const isLoading = ref(false)
|
||||
const error = ref<Error | null>(null)
|
||||
const isConnected = ref(false)
|
||||
const activeMarket = computed(() => marketStore.activeMarket)
|
||||
const markets = computed(() => marketStore.markets)
|
||||
const stalls = computed(() => marketStore.stalls)
|
||||
const products = computed(() => marketStore.products)
|
||||
const orders = computed(() => marketStore.orders)
|
||||
|
||||
// Connection state
|
||||
const connectionStatus = computed(() => {
|
||||
if (isConnected.value) return 'connected'
|
||||
if (nostrStore.isConnecting) return 'connecting'
|
||||
if (nostrStore.error) return 'error'
|
||||
return 'disconnected'
|
||||
})
|
||||
|
||||
// Load market from naddr
|
||||
const loadMarket = async (naddr: string) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
// Load market from naddr
|
||||
|
||||
// Parse naddr to get market data
|
||||
const marketData = {
|
||||
identifier: naddr.split(':')[2] || 'default',
|
||||
pubkey: naddr.split(':')[1] || nostrStore.account?.pubkey || ''
|
||||
}
|
||||
|
||||
if (!marketData.pubkey) {
|
||||
throw new Error('No pubkey available for market')
|
||||
}
|
||||
|
||||
await loadMarketData(marketData)
|
||||
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err : new Error('Failed to load market')
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Load market data from Nostr events
|
||||
const loadMarketData = async (marketData: any) => {
|
||||
try {
|
||||
// Load market data from Nostr events
|
||||
|
||||
// Fetch market configuration event
|
||||
const events = await relayHub.queryEvents([
|
||||
{
|
||||
kinds: [MARKET_EVENT_KINDS.MARKET],
|
||||
authors: [marketData.pubkey],
|
||||
'#d': [marketData.identifier]
|
||||
}
|
||||
])
|
||||
|
||||
// Process market events
|
||||
|
||||
if (events.length > 0) {
|
||||
const marketEvent = events[0]
|
||||
// Process market event
|
||||
|
||||
const market = {
|
||||
d: marketData.identifier,
|
||||
pubkey: marketData.pubkey,
|
||||
relays: config.market.supportedRelays,
|
||||
selected: true,
|
||||
opts: JSON.parse(marketEvent.content)
|
||||
}
|
||||
|
||||
marketStore.addMarket(market)
|
||||
marketStore.setActiveMarket(market)
|
||||
} else {
|
||||
// No market events found, create default
|
||||
// Create a default market if none exists
|
||||
const market = {
|
||||
d: marketData.identifier,
|
||||
pubkey: marketData.pubkey,
|
||||
relays: config.market.supportedRelays,
|
||||
selected: true,
|
||||
opts: {
|
||||
name: 'Ariège Market',
|
||||
description: 'A communal market to sell your goods',
|
||||
merchants: [],
|
||||
ui: {}
|
||||
}
|
||||
}
|
||||
|
||||
marketStore.addMarket(market)
|
||||
marketStore.setActiveMarket(market)
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
// Don't throw error, create default market instead
|
||||
const market = {
|
||||
d: marketData.identifier,
|
||||
pubkey: marketData.pubkey,
|
||||
relays: config.market.supportedRelays,
|
||||
selected: true,
|
||||
opts: {
|
||||
name: 'Default Market',
|
||||
description: 'A default market',
|
||||
merchants: [],
|
||||
ui: {}
|
||||
}
|
||||
}
|
||||
|
||||
marketStore.addMarket(market)
|
||||
marketStore.setActiveMarket(market)
|
||||
}
|
||||
}
|
||||
|
||||
// Load stalls from market merchants
|
||||
const loadStalls = async () => {
|
||||
try {
|
||||
// Get the active market to filter by its merchants
|
||||
const activeMarket = marketStore.activeMarket
|
||||
if (!activeMarket) {
|
||||
return
|
||||
}
|
||||
|
||||
const merchants = [...(activeMarket.opts.merchants || [])]
|
||||
|
||||
if (merchants.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch stall events from market merchants only
|
||||
const events = await relayHub.queryEvents([
|
||||
{
|
||||
kinds: [MARKET_EVENT_KINDS.STALL],
|
||||
authors: merchants
|
||||
}
|
||||
])
|
||||
|
||||
// Process stall events
|
||||
|
||||
// Group events by stall ID and keep only the most recent version
|
||||
const stallGroups = new Map<string, any[]>()
|
||||
events.forEach((event: any) => {
|
||||
const stallId = event.tags.find((tag: any) => tag[0] === 'd')?.[1]
|
||||
if (stallId) {
|
||||
if (!stallGroups.has(stallId)) {
|
||||
stallGroups.set(stallId, [])
|
||||
}
|
||||
stallGroups.get(stallId)!.push(event)
|
||||
}
|
||||
})
|
||||
|
||||
// Process each stall group
|
||||
stallGroups.forEach((stallEvents, stallId) => {
|
||||
// Sort by created_at and take the most recent
|
||||
const latestEvent = stallEvents.sort((a: any, b: any) => b.created_at - a.created_at)[0]
|
||||
|
||||
try {
|
||||
const stallData = JSON.parse(latestEvent.content)
|
||||
const stall = {
|
||||
id: stallId,
|
||||
pubkey: latestEvent.pubkey,
|
||||
name: stallData.name || 'Unnamed Stall',
|
||||
description: stallData.description || '',
|
||||
created_at: latestEvent.created_at,
|
||||
...stallData
|
||||
}
|
||||
|
||||
marketStore.addStall(stall)
|
||||
} catch (err) {
|
||||
// Silently handle parse errors
|
||||
}
|
||||
})
|
||||
|
||||
} catch (err) {
|
||||
// Silently handle stall loading errors
|
||||
}
|
||||
}
|
||||
|
||||
// Load products from market stalls
|
||||
const loadProducts = async () => {
|
||||
try {
|
||||
const activeMarket = marketStore.activeMarket
|
||||
if (!activeMarket) {
|
||||
return
|
||||
}
|
||||
|
||||
const merchants = [...(activeMarket.opts.merchants || [])]
|
||||
if (merchants.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch product events from market merchants
|
||||
const events = await relayHub.queryEvents([
|
||||
{
|
||||
kinds: [MARKET_EVENT_KINDS.PRODUCT],
|
||||
authors: merchants
|
||||
}
|
||||
])
|
||||
|
||||
// Process product events
|
||||
|
||||
// Group events by product ID and keep only the most recent version
|
||||
const productGroups = new Map<string, any[]>()
|
||||
events.forEach((event: any) => {
|
||||
const productId = event.tags.find((tag: any) => tag[0] === 'd')?.[1]
|
||||
if (productId) {
|
||||
if (!productGroups.has(productId)) {
|
||||
productGroups.set(productId, [])
|
||||
}
|
||||
productGroups.get(productId)!.push(event)
|
||||
}
|
||||
})
|
||||
|
||||
// Process each product group
|
||||
productGroups.forEach((productEvents, productId) => {
|
||||
// Sort by created_at and take the most recent
|
||||
const latestEvent = productEvents.sort((a: any, b: any) => b.created_at - a.created_at)[0]
|
||||
|
||||
try {
|
||||
const productData = JSON.parse(latestEvent.content)
|
||||
const product = {
|
||||
id: productId,
|
||||
stall_id: productData.stall_id || 'unknown',
|
||||
stallName: productData.stallName || 'Unknown Stall',
|
||||
name: productData.name || 'Unnamed Product',
|
||||
description: productData.description || '',
|
||||
price: productData.price || 0,
|
||||
currency: productData.currency || 'sats',
|
||||
quantity: productData.quantity || 1,
|
||||
images: productData.images || [],
|
||||
categories: productData.categories || [],
|
||||
createdAt: latestEvent.created_at,
|
||||
updatedAt: latestEvent.created_at
|
||||
}
|
||||
|
||||
marketStore.addProduct(product)
|
||||
} catch (err) {
|
||||
// Silently handle parse errors
|
||||
}
|
||||
})
|
||||
|
||||
} catch (err) {
|
||||
// Silently handle product loading errors
|
||||
}
|
||||
}
|
||||
|
||||
// Add sample products for testing
|
||||
const addSampleProducts = () => {
|
||||
const sampleProducts = [
|
||||
{
|
||||
id: 'sample-1',
|
||||
stall_id: 'sample-stall',
|
||||
stallName: 'Sample Stall',
|
||||
pubkey: nostrStore.account?.pubkey || '',
|
||||
name: 'Sample Product 1',
|
||||
description: 'This is a sample product for testing',
|
||||
price: 1000,
|
||||
currency: 'sats',
|
||||
quantity: 1,
|
||||
images: [],
|
||||
categories: [],
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
updatedAt: Math.floor(Date.now() / 1000)
|
||||
},
|
||||
{
|
||||
id: 'sample-2',
|
||||
stall_id: 'sample-stall',
|
||||
stallName: 'Sample Stall',
|
||||
pubkey: nostrStore.account?.pubkey || '',
|
||||
name: 'Sample Product 2',
|
||||
description: 'Another sample product for testing',
|
||||
price: 2000,
|
||||
currency: 'sats',
|
||||
quantity: 1,
|
||||
images: [],
|
||||
categories: [],
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
updatedAt: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
]
|
||||
|
||||
sampleProducts.forEach(product => {
|
||||
marketStore.addProduct(product)
|
||||
})
|
||||
}
|
||||
|
||||
// Subscribe to market updates
|
||||
const subscribeToMarketUpdates = (): (() => void) | null => {
|
||||
try {
|
||||
const activeMarket = marketStore.activeMarket
|
||||
if (!activeMarket) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Subscribe to market events
|
||||
const unsubscribe = relayHub.subscribe({
|
||||
id: `market-${activeMarket.d}`,
|
||||
filters: [
|
||||
{ kinds: [MARKET_EVENT_KINDS.MARKET] },
|
||||
{ kinds: [MARKET_EVENT_KINDS.STALL] },
|
||||
{ kinds: [MARKET_EVENT_KINDS.PRODUCT] },
|
||||
{ kinds: [MARKET_EVENT_KINDS.ORDER] }
|
||||
],
|
||||
onEvent: (event: any) => {
|
||||
handleMarketEvent(event)
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Handle incoming market events
|
||||
const handleMarketEvent = (event: any) => {
|
||||
// Process market event
|
||||
|
||||
switch (event.kind) {
|
||||
case MARKET_EVENT_KINDS.MARKET:
|
||||
// Handle market updates
|
||||
break
|
||||
case MARKET_EVENT_KINDS.STALL:
|
||||
// Handle stall updates
|
||||
handleStallEvent(event)
|
||||
break
|
||||
case MARKET_EVENT_KINDS.PRODUCT:
|
||||
// Handle product updates
|
||||
handleProductEvent(event)
|
||||
break
|
||||
case MARKET_EVENT_KINDS.ORDER:
|
||||
// Handle order updates
|
||||
handleOrderEvent(event)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Process pending products (products without stalls)
|
||||
const processPendingProducts = () => {
|
||||
const productsWithoutStalls = products.value.filter(product => {
|
||||
// Check if product has a stall tag
|
||||
return !product.stall_id
|
||||
})
|
||||
|
||||
if (productsWithoutStalls.length > 0) {
|
||||
// You could create default stalls or handle this as needed
|
||||
}
|
||||
}
|
||||
|
||||
// Handle stall events
|
||||
const handleStallEvent = (event: any) => {
|
||||
try {
|
||||
const stallId = event.tags.find((tag: any) => tag[0] === 'd')?.[1]
|
||||
if (stallId) {
|
||||
const stallData = JSON.parse(event.content)
|
||||
const stall = {
|
||||
id: stallId,
|
||||
pubkey: event.pubkey,
|
||||
name: stallData.name || 'Unnamed Stall',
|
||||
description: stallData.description || '',
|
||||
created_at: event.created_at,
|
||||
...stallData
|
||||
}
|
||||
|
||||
marketStore.addStall(stall)
|
||||
}
|
||||
} catch (err) {
|
||||
// Silently handle stall event errors
|
||||
}
|
||||
}
|
||||
|
||||
// Handle product events
|
||||
const handleProductEvent = (event: any) => {
|
||||
try {
|
||||
const productId = event.tags.find((tag: any) => tag[0] === 'd')?.[1]
|
||||
if (productId) {
|
||||
const productData = JSON.parse(event.content)
|
||||
const product = {
|
||||
id: productId,
|
||||
stall_id: productData.stall_id || 'unknown',
|
||||
stallName: productData.stallName || 'Unknown Stall',
|
||||
pubkey: event.pubkey,
|
||||
name: productData.name || 'Unnamed Product',
|
||||
description: productData.description || '',
|
||||
price: productData.price || 0,
|
||||
currency: productData.currency || 'sats',
|
||||
quantity: productData.quantity || 1,
|
||||
images: productData.images || [],
|
||||
categories: productData.categories || [],
|
||||
createdAt: event.created_at,
|
||||
updatedAt: event.created_at
|
||||
}
|
||||
|
||||
marketStore.addProduct(product)
|
||||
}
|
||||
} catch (err) {
|
||||
// Silently handle product event errors
|
||||
}
|
||||
}
|
||||
|
||||
// Handle order events
|
||||
const handleOrderEvent = (_event: any) => {
|
||||
try {
|
||||
// const orderData = JSON.parse(event.content)
|
||||
// const order = {
|
||||
// id: event.id,
|
||||
// stall_id: orderData.stall_id || 'unknown',
|
||||
// product_id: orderData.product_id || 'unknown',
|
||||
// buyer_pubkey: event.pubkey,
|
||||
// seller_pubkey: orderData.seller_pubkey || '',
|
||||
// quantity: orderData.quantity || 1,
|
||||
// total_price: orderData.total_price || 0,
|
||||
// currency: orderData.currency || 'sats',
|
||||
// status: orderData.status || 'pending',
|
||||
// payment_request: orderData.payment_request,
|
||||
// created_at: event.created_at,
|
||||
// updated_at: event.created_at
|
||||
// }
|
||||
|
||||
// Note: addOrder method doesn't exist in the store, so we'll just handle it silently
|
||||
} catch (err) {
|
||||
// Silently handle order event errors
|
||||
}
|
||||
}
|
||||
|
||||
// Publish a product
|
||||
const publishProduct = async (_productData: any) => {
|
||||
// Implementation would depend on your event creation logic
|
||||
// TODO: Implement product publishing
|
||||
}
|
||||
|
||||
// Publish a stall
|
||||
const publishStall = async (_stallData: any) => {
|
||||
// Implementation would depend on your event creation logic
|
||||
// TODO: Implement stall publishing
|
||||
}
|
||||
|
||||
// Connect to market
|
||||
const connectToMarket = async () => {
|
||||
try {
|
||||
// Connect to market
|
||||
|
||||
// Connect to relay hub
|
||||
await relayHub.connect()
|
||||
isConnected.value = relayHub.isConnected.value
|
||||
|
||||
if (!isConnected.value) {
|
||||
throw new Error('Failed to connect to Nostr relays')
|
||||
}
|
||||
|
||||
// Market connected successfully
|
||||
|
||||
// Load market data
|
||||
await loadMarketData({
|
||||
identifier: 'default',
|
||||
pubkey: nostrStore.account?.pubkey || ''
|
||||
})
|
||||
|
||||
// Load stalls and products
|
||||
await loadStalls()
|
||||
await loadProducts()
|
||||
|
||||
// Subscribe to updates
|
||||
subscribeToMarketUpdates()
|
||||
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err : new Error('Failed to connect to market')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnect from market
|
||||
const disconnectFromMarket = () => {
|
||||
isConnected.value = false
|
||||
error.value = null
|
||||
// Market disconnected
|
||||
}
|
||||
|
||||
// Initialize market on mount
|
||||
onMounted(async () => {
|
||||
if (nostrStore.isConnected) {
|
||||
await connectToMarket()
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup on unmount
|
||||
onUnmounted(() => {
|
||||
disconnectFromMarket()
|
||||
})
|
||||
|
||||
return {
|
||||
// State
|
||||
isLoading: readonly(isLoading),
|
||||
error: readonly(error),
|
||||
isConnected: readonly(isConnected),
|
||||
connectionStatus: readonly(connectionStatus),
|
||||
activeMarket: readonly(activeMarket),
|
||||
markets: readonly(markets),
|
||||
stalls: readonly(stalls),
|
||||
products: readonly(products),
|
||||
orders: readonly(orders),
|
||||
|
||||
// Actions
|
||||
loadMarket,
|
||||
connectToMarket,
|
||||
disconnectFromMarket,
|
||||
addSampleProducts,
|
||||
processPendingProducts,
|
||||
publishProduct,
|
||||
publishStall,
|
||||
subscribeToMarketUpdates
|
||||
}
|
||||
}
|
||||
60
src/modules/market/composables/useMarketPreloader.ts
Normal file
60
src/modules/market/composables/useMarketPreloader.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { ref, readonly } from 'vue'
|
||||
import { useMarket } from './useMarket'
|
||||
import { useMarketStore } from '../stores/market'
|
||||
import { config } from '@/lib/config'
|
||||
|
||||
export function useMarketPreloader() {
|
||||
const isPreloading = ref(false)
|
||||
const isPreloaded = ref(false)
|
||||
const preloadError = ref<string | null>(null)
|
||||
|
||||
const market = useMarket()
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
const preloadMarket = async () => {
|
||||
// Don't preload if already done or currently preloading
|
||||
if (isPreloaded.value || isPreloading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isPreloading.value = true
|
||||
preloadError.value = null
|
||||
|
||||
const naddr = config.market.defaultNaddr
|
||||
if (!naddr) {
|
||||
return
|
||||
}
|
||||
|
||||
// Connect to market
|
||||
await market.connectToMarket()
|
||||
|
||||
// Load market data
|
||||
await market.loadMarket(naddr)
|
||||
|
||||
// Clear any error state since preloading succeeded
|
||||
marketStore.setError(null)
|
||||
|
||||
isPreloaded.value = true
|
||||
|
||||
} catch (error) {
|
||||
preloadError.value = error instanceof Error ? error.message : 'Failed to preload market'
|
||||
// Don't throw error, let the UI handle it gracefully
|
||||
} finally {
|
||||
isPreloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetPreload = () => {
|
||||
isPreloaded.value = false
|
||||
preloadError.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
isPreloading: readonly(isPreloading),
|
||||
isPreloaded: readonly(isPreloaded),
|
||||
preloadError: readonly(preloadError),
|
||||
preloadMarket,
|
||||
resetPreload
|
||||
}
|
||||
}
|
||||
143
src/modules/market/index.ts
Normal file
143
src/modules/market/index.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import type { App } from 'vue'
|
||||
import type { ModulePlugin } from '@/core/types'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { container } from '@/core/di-container'
|
||||
import { eventBus } from '@/core/event-bus'
|
||||
|
||||
// Import components
|
||||
import MarketSettings from './components/MarketSettings.vue'
|
||||
import MerchantStore from './components/MerchantStore.vue'
|
||||
import ShoppingCart from './components/ShoppingCart.vue'
|
||||
|
||||
// Import services
|
||||
import { NostrmarketService } from './services/nostrmarketService'
|
||||
|
||||
// Store will be imported when needed
|
||||
|
||||
// Import composables
|
||||
import { useMarket } from './composables/useMarket'
|
||||
import { useMarketPreloader } from './composables/useMarketPreloader'
|
||||
|
||||
// Define service tokens
|
||||
export const MARKET_SERVICE_TOKEN = Symbol('marketService')
|
||||
export const NOSTRMARKET_SERVICE_TOKEN = Symbol('nostrmarketService')
|
||||
|
||||
export interface MarketModuleConfig {
|
||||
defaultCurrency: string
|
||||
paymentTimeout: number
|
||||
maxOrderHistory: number
|
||||
supportedRelays?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Market Module Plugin
|
||||
* Provides market, stall, and product management functionality
|
||||
*/
|
||||
export const marketModule: ModulePlugin = {
|
||||
name: 'market',
|
||||
version: '1.0.0',
|
||||
dependencies: ['base'],
|
||||
|
||||
async install(app: App, options?: { config?: MarketModuleConfig }) {
|
||||
console.log('🛒 Installing market module...')
|
||||
|
||||
const config = options?.config
|
||||
if (!config) {
|
||||
throw new Error('Market module requires configuration')
|
||||
}
|
||||
|
||||
// Create and register services
|
||||
const nostrmarketService = new NostrmarketService()
|
||||
container.provide(NOSTRMARKET_SERVICE_TOKEN, nostrmarketService)
|
||||
|
||||
// Register global components
|
||||
app.component('MarketSettings', MarketSettings)
|
||||
app.component('MerchantStore', MerchantStore)
|
||||
app.component('ShoppingCart', ShoppingCart)
|
||||
|
||||
// Market store will be initialized when first used
|
||||
|
||||
// Set up event listeners for integration with other modules
|
||||
setupEventListeners()
|
||||
|
||||
console.log('✅ Market module installed successfully')
|
||||
},
|
||||
|
||||
async uninstall() {
|
||||
console.log('🗑️ Uninstalling market module...')
|
||||
|
||||
// Clean up services
|
||||
container.remove(NOSTRMARKET_SERVICE_TOKEN)
|
||||
|
||||
console.log('✅ Market module uninstalled')
|
||||
},
|
||||
|
||||
routes: [
|
||||
{
|
||||
path: '/market',
|
||||
name: 'market',
|
||||
component: () => import('./views/MarketPage.vue'),
|
||||
meta: {
|
||||
title: 'Market',
|
||||
requiresAuth: false
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/market/dashboard',
|
||||
name: 'market-dashboard',
|
||||
component: () => import('./views/MarketDashboard.vue'),
|
||||
meta: {
|
||||
title: 'Market Dashboard',
|
||||
requiresAuth: true
|
||||
}
|
||||
}
|
||||
] as RouteRecordRaw[],
|
||||
|
||||
components: {
|
||||
MarketSettings,
|
||||
MerchantStore,
|
||||
ShoppingCart
|
||||
},
|
||||
|
||||
composables: {
|
||||
useMarket,
|
||||
useMarketPreloader
|
||||
},
|
||||
|
||||
services: {
|
||||
nostrmarket: NOSTRMARKET_SERVICE_TOKEN
|
||||
}
|
||||
}
|
||||
|
||||
// Set up event listeners for integration with other modules
|
||||
function setupEventListeners() {
|
||||
// Listen for auth events
|
||||
eventBus.on('auth:logout', () => {
|
||||
console.log('Market module: user logged out, clearing market data')
|
||||
// Could clear market-specific user data here
|
||||
})
|
||||
|
||||
// Listen for payment events from other modules
|
||||
eventBus.on('payment:completed', (event) => {
|
||||
console.log('Market module: payment completed', event.data)
|
||||
// Could update order status or refresh market data here
|
||||
})
|
||||
|
||||
// Emit market-specific events
|
||||
eventBus.on('market:order-placed', (event) => {
|
||||
console.log('Market order placed:', event.data)
|
||||
// Other modules can listen to this event
|
||||
})
|
||||
|
||||
eventBus.on('market:product-added', (event) => {
|
||||
console.log('Market product added:', event.data)
|
||||
// Other modules can listen to this event
|
||||
})
|
||||
}
|
||||
|
||||
export default marketModule
|
||||
|
||||
// Re-export types and composables for external use
|
||||
export type * from './types/market'
|
||||
export { useMarket, useMarketPreloader } from './composables'
|
||||
export { useMarketStore } from './stores/market'
|
||||
460
src/modules/market/services/nostrmarketService.ts
Normal file
460
src/modules/market/services/nostrmarketService.ts
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
import { finalizeEvent, type EventTemplate, nip04 } from 'nostr-tools'
|
||||
import { relayHub } from '@/lib/nostr/relayHub'
|
||||
import { auth } from '@/composables/useAuth'
|
||||
import type { Stall, Product, Order } from '@/stores/market'
|
||||
|
||||
export interface NostrmarketStall {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
currency: string
|
||||
shipping: Array<{
|
||||
id: string
|
||||
name: string
|
||||
cost: number
|
||||
countries: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
export interface NostrmarketProduct {
|
||||
id: string
|
||||
stall_id: string
|
||||
name: string
|
||||
description?: string
|
||||
images: string[]
|
||||
categories: string[]
|
||||
price: number
|
||||
quantity: number
|
||||
currency: string
|
||||
}
|
||||
|
||||
export interface NostrmarketOrder {
|
||||
id: string
|
||||
items: Array<{
|
||||
product_id: string
|
||||
quantity: number
|
||||
}>
|
||||
contact: {
|
||||
name: string
|
||||
email?: string
|
||||
phone?: string
|
||||
}
|
||||
address?: {
|
||||
street: string
|
||||
city: string
|
||||
state: string
|
||||
country: string
|
||||
postal_code: string
|
||||
}
|
||||
shipping_id: string
|
||||
}
|
||||
|
||||
export interface NostrmarketPaymentRequest {
|
||||
type: 1
|
||||
id: string
|
||||
message?: string
|
||||
payment_options: Array<{
|
||||
type: string
|
||||
link: string
|
||||
}>
|
||||
}
|
||||
|
||||
export interface NostrmarketOrderStatus {
|
||||
type: 2
|
||||
id: string
|
||||
message?: string
|
||||
paid?: boolean
|
||||
shipped?: boolean
|
||||
}
|
||||
|
||||
export class NostrmarketService {
|
||||
/**
|
||||
* Convert hex string to Uint8Array (browser-compatible)
|
||||
*/
|
||||
private hexToUint8Array(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2)
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
private getAuth() {
|
||||
if (!auth.isAuthenticated.value || !auth.currentUser.value?.prvkey) {
|
||||
throw new Error('User not authenticated or private key not available')
|
||||
}
|
||||
|
||||
const pubkey = auth.currentUser.value.pubkey
|
||||
const prvkey = auth.currentUser.value.prvkey
|
||||
|
||||
if (!pubkey || !prvkey) {
|
||||
throw new Error('Public key or private key is missing')
|
||||
}
|
||||
|
||||
// Validate that we have proper hex strings
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(pubkey)) {
|
||||
throw new Error(`Invalid public key format: ${pubkey.substring(0, 10)}...`)
|
||||
}
|
||||
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(prvkey)) {
|
||||
throw new Error(`Invalid private key format: ${prvkey.substring(0, 10)}...`)
|
||||
}
|
||||
|
||||
console.log('🔑 Key debug:', {
|
||||
pubkey: pubkey.substring(0, 10) + '...',
|
||||
prvkey: prvkey.substring(0, 10) + '...',
|
||||
pubkeyIsHex: /^[0-9a-fA-F]{64}$/.test(pubkey),
|
||||
prvkeyIsHex: /^[0-9a-fA-F]{64}$/.test(prvkey),
|
||||
pubkeyLength: pubkey.length,
|
||||
prvkeyLength: prvkey.length,
|
||||
pubkeyType: typeof pubkey,
|
||||
prvkeyType: typeof prvkey,
|
||||
pubkeyIsString: typeof pubkey === 'string',
|
||||
prvkeyIsString: typeof prvkey === 'string'
|
||||
})
|
||||
|
||||
return {
|
||||
pubkey,
|
||||
prvkey
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a stall event (kind 30017) to Nostr
|
||||
*/
|
||||
async publishStall(stall: Stall): Promise<string> {
|
||||
const { prvkey } = this.getAuth()
|
||||
|
||||
const stallData: NostrmarketStall = {
|
||||
id: stall.id,
|
||||
name: stall.name,
|
||||
description: stall.description,
|
||||
currency: stall.currency,
|
||||
shipping: (stall.shipping || []).map(zone => ({
|
||||
id: zone.id,
|
||||
name: zone.name,
|
||||
cost: zone.cost,
|
||||
countries: []
|
||||
}))
|
||||
}
|
||||
|
||||
const eventTemplate: EventTemplate = {
|
||||
kind: 30017,
|
||||
tags: [
|
||||
['t', 'stall'],
|
||||
['t', 'nostrmarket']
|
||||
],
|
||||
content: JSON.stringify(stallData),
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
const prvkeyBytes = this.hexToUint8Array(prvkey)
|
||||
const event = finalizeEvent(eventTemplate, prvkeyBytes)
|
||||
const result = await relayHub.publishEvent(event)
|
||||
|
||||
console.log('Stall published to nostrmarket:', {
|
||||
stallId: stall.id,
|
||||
eventId: result,
|
||||
content: stallData
|
||||
})
|
||||
|
||||
return result.success.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a product event (kind 30018) to Nostr
|
||||
*/
|
||||
async publishProduct(product: Product): Promise<string> {
|
||||
const { prvkey } = this.getAuth()
|
||||
|
||||
const productData: NostrmarketProduct = {
|
||||
id: product.id,
|
||||
stall_id: product.stall_id,
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
images: product.images || [],
|
||||
categories: product.categories || [],
|
||||
price: product.price,
|
||||
quantity: product.quantity,
|
||||
currency: product.currency
|
||||
}
|
||||
|
||||
const eventTemplate: EventTemplate = {
|
||||
kind: 30018,
|
||||
tags: [
|
||||
['t', 'product'],
|
||||
['t', 'nostrmarket'],
|
||||
['t', 'stall', product.stall_id],
|
||||
...(product.categories || []).map(cat => ['t', cat])
|
||||
],
|
||||
content: JSON.stringify(productData),
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
const prvkeyBytes = this.hexToUint8Array(prvkey)
|
||||
const event = finalizeEvent(eventTemplate, prvkeyBytes)
|
||||
const result = await relayHub.publishEvent(event)
|
||||
|
||||
console.log('Product published to nostrmarket:', {
|
||||
productId: product.id,
|
||||
eventId: result,
|
||||
content: productData
|
||||
})
|
||||
|
||||
return result.success.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish an order event (kind 4 encrypted DM) to nostrmarket
|
||||
*/
|
||||
async publishOrder(order: Order, merchantPubkey: string): Promise<string> {
|
||||
const { prvkey } = this.getAuth()
|
||||
|
||||
// Convert order to nostrmarket format - exactly matching the specification
|
||||
const orderData = {
|
||||
type: 0, // DirectMessageType.CUSTOMER_ORDER
|
||||
id: order.id,
|
||||
items: order.items.map(item => ({
|
||||
product_id: item.productId,
|
||||
quantity: item.quantity
|
||||
})),
|
||||
contact: {
|
||||
name: order.contactInfo?.message || order.contactInfo?.email || 'Unknown',
|
||||
email: order.contactInfo?.email || ''
|
||||
// Remove phone field - not in nostrmarket specification
|
||||
},
|
||||
// Only include address if it's a physical good and address is provided
|
||||
...(order.shippingZone?.requiresPhysicalShipping && order.contactInfo?.address ? {
|
||||
address: order.contactInfo.address
|
||||
} : {}),
|
||||
shipping_id: order.shippingZone?.id || 'online'
|
||||
}
|
||||
|
||||
// Encrypt the message using NIP-04
|
||||
console.log('🔐 NIP-04 encryption debug:', {
|
||||
prvkeyType: typeof prvkey,
|
||||
prvkeyIsString: typeof prvkey === 'string',
|
||||
prvkeyLength: prvkey.length,
|
||||
prvkeySample: prvkey.substring(0, 10) + '...',
|
||||
merchantPubkeyType: typeof merchantPubkey,
|
||||
merchantPubkeyLength: merchantPubkey.length,
|
||||
orderDataString: JSON.stringify(orderData).substring(0, 50) + '...'
|
||||
})
|
||||
|
||||
let encryptedContent: string
|
||||
try {
|
||||
encryptedContent = await nip04.encrypt(prvkey, merchantPubkey, JSON.stringify(orderData))
|
||||
console.log('🔐 NIP-04 encryption successful:', {
|
||||
encryptedContentLength: encryptedContent.length,
|
||||
encryptedContentSample: encryptedContent.substring(0, 50) + '...'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('🔐 NIP-04 encryption failed:', error)
|
||||
throw error
|
||||
}
|
||||
|
||||
const eventTemplate: EventTemplate = {
|
||||
kind: 4, // Encrypted DM
|
||||
tags: [['p', merchantPubkey]], // Recipient (merchant)
|
||||
content: encryptedContent, // Use encrypted content
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
console.log('🔧 finalizeEvent debug:', {
|
||||
prvkeyType: typeof prvkey,
|
||||
prvkeyIsString: typeof prvkey === 'string',
|
||||
prvkeyLength: prvkey.length,
|
||||
prvkeySample: prvkey.substring(0, 10) + '...',
|
||||
encodedPrvkeyType: typeof new TextEncoder().encode(prvkey),
|
||||
encodedPrvkeyLength: new TextEncoder().encode(prvkey).length,
|
||||
eventTemplate
|
||||
})
|
||||
|
||||
// Convert hex string to Uint8Array properly
|
||||
const prvkeyBytes = this.hexToUint8Array(prvkey)
|
||||
console.log('🔧 prvkeyBytes debug:', {
|
||||
prvkeyBytesType: typeof prvkeyBytes,
|
||||
prvkeyBytesLength: prvkeyBytes.length,
|
||||
prvkeyBytesIsUint8Array: prvkeyBytes instanceof Uint8Array
|
||||
})
|
||||
|
||||
const event = finalizeEvent(eventTemplate, prvkeyBytes)
|
||||
const result = await relayHub.publishEvent(event)
|
||||
|
||||
console.log('Order published to nostrmarket:', {
|
||||
orderId: order.id,
|
||||
eventId: result,
|
||||
merchantPubkey,
|
||||
content: orderData,
|
||||
encryptedContent: encryptedContent.substring(0, 50) + '...'
|
||||
})
|
||||
|
||||
return result.success.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming payment request from merchant (type 1)
|
||||
*/
|
||||
async handlePaymentRequest(paymentRequest: NostrmarketPaymentRequest): Promise<void> {
|
||||
console.log('Received payment request from merchant:', {
|
||||
orderId: paymentRequest.id,
|
||||
message: paymentRequest.message,
|
||||
paymentOptions: paymentRequest.payment_options
|
||||
})
|
||||
|
||||
// Find the Lightning payment option
|
||||
const lightningOption = paymentRequest.payment_options.find(option => option.type === 'ln')
|
||||
if (!lightningOption) {
|
||||
console.error('No Lightning payment option found in payment request')
|
||||
return
|
||||
}
|
||||
|
||||
// Update the order in the store with payment request
|
||||
const { useMarketStore } = await import('@/stores/market')
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
const order = Object.values(marketStore.orders).find(o =>
|
||||
o.id === paymentRequest.id || o.originalOrderId === paymentRequest.id
|
||||
)
|
||||
|
||||
if (order) {
|
||||
// Update order with payment request details
|
||||
const updatedOrder = {
|
||||
...order,
|
||||
paymentRequest: lightningOption.link,
|
||||
paymentStatus: 'pending' as const,
|
||||
status: 'pending' as const, // Ensure status is pending for payment
|
||||
updatedAt: Math.floor(Date.now() / 1000),
|
||||
items: [...order.items] // Convert readonly to mutable
|
||||
}
|
||||
|
||||
// Generate QR code for the payment request
|
||||
try {
|
||||
const QRCode = await import('qrcode')
|
||||
const qrCodeDataUrl = await QRCode.toDataURL(lightningOption.link, {
|
||||
width: 256,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF'
|
||||
}
|
||||
})
|
||||
updatedOrder.qrCodeDataUrl = qrCodeDataUrl
|
||||
updatedOrder.qrCodeLoading = false
|
||||
updatedOrder.qrCodeError = null
|
||||
} catch (error) {
|
||||
console.error('Failed to generate QR code:', error)
|
||||
updatedOrder.qrCodeError = 'Failed to generate QR code'
|
||||
updatedOrder.qrCodeLoading = false
|
||||
}
|
||||
|
||||
marketStore.updateOrder(order.id, updatedOrder)
|
||||
|
||||
console.log('Order updated with payment request:', {
|
||||
orderId: paymentRequest.id,
|
||||
paymentRequest: lightningOption.link.substring(0, 50) + '...',
|
||||
status: updatedOrder.status,
|
||||
paymentStatus: updatedOrder.paymentStatus,
|
||||
hasQRCode: !!updatedOrder.qrCodeDataUrl
|
||||
})
|
||||
} else {
|
||||
console.warn('Payment request received for unknown order:', paymentRequest.id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming order status update from merchant (type 2)
|
||||
*/
|
||||
async handleOrderStatusUpdate(statusUpdate: NostrmarketOrderStatus): Promise<void> {
|
||||
console.log('Received order status update from merchant:', {
|
||||
orderId: statusUpdate.id,
|
||||
message: statusUpdate.message,
|
||||
paid: statusUpdate.paid,
|
||||
shipped: statusUpdate.shipped
|
||||
})
|
||||
|
||||
const { useMarketStore } = await import('@/stores/market')
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
const order = Object.values(marketStore.orders).find(o =>
|
||||
o.id === statusUpdate.id || o.originalOrderId === statusUpdate.id
|
||||
)
|
||||
|
||||
if (order) {
|
||||
// Update order status
|
||||
if (statusUpdate.paid !== undefined) {
|
||||
const newStatus = statusUpdate.paid ? 'paid' : 'pending'
|
||||
marketStore.updateOrderStatus(order.id, newStatus)
|
||||
|
||||
// Also update payment status
|
||||
const updatedOrder = {
|
||||
...order,
|
||||
paymentStatus: (statusUpdate.paid ? 'paid' : 'pending') as 'paid' | 'pending' | 'expired',
|
||||
paidAt: statusUpdate.paid ? Math.floor(Date.now() / 1000) : undefined,
|
||||
updatedAt: Math.floor(Date.now() / 1000),
|
||||
items: [...order.items] // Convert readonly to mutable
|
||||
}
|
||||
marketStore.updateOrder(order.id, updatedOrder)
|
||||
}
|
||||
|
||||
if (statusUpdate.shipped !== undefined) {
|
||||
// Update shipping status if you have that field
|
||||
const updatedOrder = {
|
||||
...order,
|
||||
shipped: statusUpdate.shipped,
|
||||
status: statusUpdate.shipped ? 'shipped' : order.status,
|
||||
updatedAt: Math.floor(Date.now() / 1000),
|
||||
items: [...order.items] // Convert readonly to mutable
|
||||
}
|
||||
marketStore.updateOrder(order.id, updatedOrder)
|
||||
}
|
||||
|
||||
console.log('Order status updated:', {
|
||||
orderId: statusUpdate.id,
|
||||
paid: statusUpdate.paid,
|
||||
shipped: statusUpdate.shipped,
|
||||
newStatus: statusUpdate.paid ? 'paid' : 'pending'
|
||||
})
|
||||
} else {
|
||||
console.warn('Status update received for unknown order:', statusUpdate.id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish all stalls and products for a merchant
|
||||
*/
|
||||
async publishMerchantCatalog(stalls: Stall[], products: Product[]): Promise<{
|
||||
stalls: Record<string, string>, // stallId -> eventId
|
||||
products: Record<string, string> // productId -> eventId
|
||||
}> {
|
||||
const results = {
|
||||
stalls: {} as Record<string, string>,
|
||||
products: {} as Record<string, string>
|
||||
}
|
||||
|
||||
// Publish stalls first
|
||||
for (const stall of stalls) {
|
||||
try {
|
||||
const eventId = await this.publishStall(stall)
|
||||
results.stalls[stall.id] = eventId
|
||||
} catch (error) {
|
||||
console.error(`Failed to publish stall ${stall.id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Publish products
|
||||
for (const product of products) {
|
||||
try {
|
||||
const eventId = await this.publishProduct(product)
|
||||
results.products[product.id] = eventId
|
||||
} catch (error) {
|
||||
console.error(`Failed to publish product ${product.id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const nostrmarketService = new NostrmarketService()
|
||||
884
src/modules/market/stores/market.ts
Normal file
884
src/modules/market/stores/market.ts
Normal file
|
|
@ -0,0 +1,884 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, readonly, watch } from 'vue'
|
||||
import { nostrOrders } from '@/composables/useNostrOrders'
|
||||
import { invoiceService } from '@/lib/services/invoiceService'
|
||||
import { paymentMonitor } from '@/lib/services/paymentMonitor'
|
||||
import { nostrmarketService } from '../services/nostrmarketService'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import type { LightningInvoice } from '@/lib/services/invoiceService'
|
||||
|
||||
|
||||
import type {
|
||||
Market, Stall, Product, Order, ShippingZone,
|
||||
OrderStatus, StallCart, FilterData, SortOptions,
|
||||
PaymentRequest, PaymentStatus
|
||||
} from '../types/market'
|
||||
// Import types that are used in the store implementation
|
||||
|
||||
export const useMarketStore = defineStore('market', () => {
|
||||
const auth = useAuth()
|
||||
|
||||
// Helper function to get user-specific storage key
|
||||
const getUserStorageKey = (baseKey: string) => {
|
||||
const userPubkey = auth.currentUser?.value?.pubkey
|
||||
return userPubkey ? `${baseKey}_${userPubkey}` : baseKey
|
||||
}
|
||||
// Core market state
|
||||
const markets = ref<Market[]>([])
|
||||
const stalls = ref<Stall[]>([])
|
||||
const products = ref<Product[]>([])
|
||||
const orders = ref<Record<string, Order>>({})
|
||||
const profiles = ref<Record<string, any>>({})
|
||||
|
||||
// Active selections
|
||||
const activeMarket = ref<Market | null>(null)
|
||||
const activeStall = ref<Stall | null>(null)
|
||||
const activeProduct = ref<Product | null>(null)
|
||||
|
||||
// UI state
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const searchText = ref('')
|
||||
const showFilterDetails = ref(false)
|
||||
|
||||
// Filtering and sorting
|
||||
const filterData = ref<FilterData>({
|
||||
categories: [],
|
||||
merchants: [],
|
||||
stalls: [],
|
||||
currency: null,
|
||||
priceFrom: null,
|
||||
priceTo: null,
|
||||
inStock: null,
|
||||
paymentMethods: []
|
||||
})
|
||||
|
||||
const sortOptions = ref<SortOptions>({
|
||||
field: 'name',
|
||||
order: 'asc'
|
||||
})
|
||||
|
||||
// Enhanced shopping cart with stall-specific carts
|
||||
const stallCarts = ref<Record<string, StallCart>>({})
|
||||
|
||||
// Legacy shopping cart (to be deprecated)
|
||||
const shoppingCart = ref<Record<string, { product: Product; quantity: number }>>({})
|
||||
|
||||
// Checkout state
|
||||
const checkoutCart = ref<StallCart | null>(null)
|
||||
const checkoutStall = ref<Stall | null>(null)
|
||||
const activeOrder = ref<Order | null>(null)
|
||||
|
||||
// Payment state
|
||||
const paymentRequest = ref<PaymentRequest | null>(null)
|
||||
const paymentStatus = ref<PaymentStatus | null>(null)
|
||||
|
||||
// Computed properties
|
||||
const filteredProducts = computed(() => {
|
||||
let filtered = products.value
|
||||
|
||||
// Search filter
|
||||
if (searchText.value) {
|
||||
const searchLower = searchText.value.toLowerCase()
|
||||
filtered = filtered.filter(product =>
|
||||
product.name.toLowerCase().includes(searchLower) ||
|
||||
product.description?.toLowerCase().includes(searchLower) ||
|
||||
product.stallName.toLowerCase().includes(searchLower)
|
||||
)
|
||||
}
|
||||
|
||||
// Category filter
|
||||
if (filterData.value.categories.length > 0) {
|
||||
filtered = filtered.filter(product =>
|
||||
product.categories?.some(cat => filterData.value.categories.includes(cat))
|
||||
)
|
||||
}
|
||||
|
||||
// Merchant filter
|
||||
if (filterData.value.merchants.length > 0) {
|
||||
filtered = filtered.filter(product =>
|
||||
filterData.value.merchants.includes(product.stall_id)
|
||||
)
|
||||
}
|
||||
|
||||
// Stall filter
|
||||
if (filterData.value.stalls.length > 0) {
|
||||
filtered = filtered.filter(product =>
|
||||
filterData.value.stalls.includes(product.stall_id)
|
||||
)
|
||||
}
|
||||
|
||||
// Currency filter
|
||||
if (filterData.value.currency) {
|
||||
filtered = filtered.filter(product =>
|
||||
product.currency === filterData.value.currency
|
||||
)
|
||||
}
|
||||
|
||||
// Price range filter
|
||||
if (filterData.value.priceFrom !== null) {
|
||||
filtered = filtered.filter(product =>
|
||||
product.price >= filterData.value.priceFrom!
|
||||
)
|
||||
}
|
||||
|
||||
if (filterData.value.priceTo !== null) {
|
||||
filtered = filtered.filter(product =>
|
||||
product.price <= filterData.value.priceTo!
|
||||
)
|
||||
}
|
||||
|
||||
// In stock filter
|
||||
if (filterData.value.inStock !== null) {
|
||||
filtered = filtered.filter(product =>
|
||||
filterData.value.inStock ? product.quantity > 0 : product.quantity === 0
|
||||
)
|
||||
}
|
||||
|
||||
// Payment methods filter
|
||||
if (filterData.value.paymentMethods.length > 0) {
|
||||
// For now, assume all products support Lightning payments
|
||||
// This can be enhanced later with product-specific payment method support
|
||||
filtered = filtered.filter(_product => true)
|
||||
}
|
||||
|
||||
// Sort
|
||||
filtered.sort((a, b) => {
|
||||
const aVal = a[sortOptions.value.field as keyof Product]
|
||||
const bVal = b[sortOptions.value.field as keyof Product]
|
||||
|
||||
if (typeof aVal === 'string' && typeof bVal === 'string') {
|
||||
return sortOptions.value.order === 'asc'
|
||||
? aVal.localeCompare(bVal)
|
||||
: bVal.localeCompare(aVal)
|
||||
}
|
||||
|
||||
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||
return sortOptions.value.order === 'asc'
|
||||
? aVal - bVal
|
||||
: bVal - aVal
|
||||
}
|
||||
|
||||
return 0
|
||||
})
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
const allCategories = computed(() => {
|
||||
const categories = new Set<string>()
|
||||
products.value.forEach(product => {
|
||||
product.categories?.forEach(cat => categories.add(cat))
|
||||
})
|
||||
return Array.from(categories).map(category => ({
|
||||
category,
|
||||
count: products.value.filter(p => p.categories?.includes(category)).length,
|
||||
selected: filterData.value.categories.includes(category)
|
||||
}))
|
||||
})
|
||||
|
||||
// Enhanced cart computed properties
|
||||
const allStallCarts = computed(() => Object.values(stallCarts.value))
|
||||
|
||||
const totalCartItems = computed(() => {
|
||||
return allStallCarts.value.reduce((total, cart) => {
|
||||
return total + cart.products.reduce((cartTotal, item) => cartTotal + item.quantity, 0)
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const totalCartValue = computed(() => {
|
||||
return allStallCarts.value.reduce((total, cart) => {
|
||||
return total + cart.subtotal
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const activeStallCart = computed(() => {
|
||||
if (!checkoutStall.value) return null
|
||||
return stallCarts.value[checkoutStall.value.id] || null
|
||||
})
|
||||
|
||||
// Legacy cart computed properties (to be deprecated)
|
||||
const cartTotal = computed(() => {
|
||||
return Object.values(shoppingCart.value).reduce((total, item) => {
|
||||
return total + (item.product.price * item.quantity)
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const cartItemCount = computed(() => {
|
||||
return Object.values(shoppingCart.value).reduce((count, item) => {
|
||||
return count + item.quantity
|
||||
}, 0)
|
||||
})
|
||||
|
||||
// Actions
|
||||
const setLoading = (loading: boolean) => {
|
||||
isLoading.value = loading
|
||||
}
|
||||
|
||||
const setError = (errorMessage: string | null) => {
|
||||
error.value = errorMessage
|
||||
}
|
||||
|
||||
const setSearchText = (text: string) => {
|
||||
searchText.value = text
|
||||
}
|
||||
|
||||
const setActiveMarket = (market: Market | null) => {
|
||||
activeMarket.value = market
|
||||
}
|
||||
|
||||
const setActiveStall = (stall: Stall | null) => {
|
||||
activeStall.value = stall
|
||||
}
|
||||
|
||||
const setActiveProduct = (product: Product | null) => {
|
||||
activeProduct.value = product
|
||||
}
|
||||
|
||||
const addProduct = (product: Product) => {
|
||||
const existingIndex = products.value.findIndex(p => p.id === product.id)
|
||||
if (existingIndex >= 0) {
|
||||
products.value[existingIndex] = product
|
||||
} else {
|
||||
products.value.push(product)
|
||||
}
|
||||
}
|
||||
|
||||
const addStall = (stall: Stall) => {
|
||||
const existingIndex = stalls.value.findIndex(s => s.id === stall.id)
|
||||
if (existingIndex >= 0) {
|
||||
stalls.value[existingIndex] = stall
|
||||
} else {
|
||||
stalls.value.push(stall)
|
||||
}
|
||||
}
|
||||
|
||||
const addMarket = (market: Market) => {
|
||||
const existingIndex = markets.value.findIndex(m => m.d === market.d)
|
||||
if (existingIndex >= 0) {
|
||||
markets.value[existingIndex] = market
|
||||
} else {
|
||||
markets.value.push(market)
|
||||
}
|
||||
}
|
||||
|
||||
const addToCart = (product: Product, quantity: number = 1) => {
|
||||
const existing = shoppingCart.value[product.id]
|
||||
if (existing) {
|
||||
existing.quantity += quantity
|
||||
} else {
|
||||
shoppingCart.value[product.id] = { product, quantity }
|
||||
}
|
||||
}
|
||||
|
||||
const removeFromCart = (productId: string) => {
|
||||
delete shoppingCart.value[productId]
|
||||
}
|
||||
|
||||
const updateCartQuantity = (productId: string, quantity: number) => {
|
||||
if (quantity <= 0) {
|
||||
removeFromCart(productId)
|
||||
} else {
|
||||
const item = shoppingCart.value[productId]
|
||||
if (item) {
|
||||
item.quantity = quantity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const clearCart = () => {
|
||||
shoppingCart.value = {}
|
||||
}
|
||||
|
||||
// Enhanced cart management methods
|
||||
const addToStallCart = (product: Product, quantity: number = 1) => {
|
||||
const stallId = product.stall_id
|
||||
const stall = stalls.value.find(s => s.id === stallId)
|
||||
|
||||
if (!stall) {
|
||||
console.error('Stall not found for product:', product.id)
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize stall cart if it doesn't exist
|
||||
if (!stallCarts.value[stallId]) {
|
||||
stallCarts.value[stallId] = {
|
||||
id: stallId,
|
||||
merchant: stall.pubkey,
|
||||
products: [],
|
||||
subtotal: 0,
|
||||
currency: stall.currency || 'sats'
|
||||
}
|
||||
}
|
||||
|
||||
const cart = stallCarts.value[stallId]
|
||||
const existingItem = cart.products.find(item => item.product.id === product.id)
|
||||
|
||||
if (existingItem) {
|
||||
existingItem.quantity = Math.min(existingItem.quantity + quantity, product.quantity)
|
||||
} else {
|
||||
cart.products.push({
|
||||
product,
|
||||
quantity: Math.min(quantity, product.quantity),
|
||||
stallId
|
||||
})
|
||||
}
|
||||
|
||||
// Update cart subtotal
|
||||
updateStallCartSubtotal(stallId)
|
||||
}
|
||||
|
||||
const removeFromStallCart = (stallId: string, productId: string) => {
|
||||
const cart = stallCarts.value[stallId]
|
||||
if (cart) {
|
||||
cart.products = cart.products.filter(item => item.product.id !== productId)
|
||||
updateStallCartSubtotal(stallId)
|
||||
|
||||
// Remove empty carts
|
||||
if (cart.products.length === 0) {
|
||||
delete stallCarts.value[stallId]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateStallCartQuantity = (stallId: string, productId: string, quantity: number) => {
|
||||
const cart = stallCarts.value[stallId]
|
||||
if (cart) {
|
||||
if (quantity <= 0) {
|
||||
removeFromStallCart(stallId, productId)
|
||||
} else {
|
||||
const item = cart.products.find(item => item.product.id === productId)
|
||||
if (item) {
|
||||
item.quantity = Math.min(quantity, item.product.quantity)
|
||||
updateStallCartSubtotal(stallId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updateStallCartSubtotal = (stallId: string) => {
|
||||
const cart = stallCarts.value[stallId]
|
||||
if (cart) {
|
||||
cart.subtotal = cart.products.reduce((total, item) => {
|
||||
return total + (item.product.price * item.quantity)
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
const clearStallCart = (stallId: string) => {
|
||||
delete stallCarts.value[stallId]
|
||||
}
|
||||
|
||||
const clearAllStallCarts = () => {
|
||||
stallCarts.value = {}
|
||||
}
|
||||
|
||||
const setCheckoutCart = (stallId: string) => {
|
||||
const cart = stallCarts.value[stallId]
|
||||
const stall = stalls.value.find(s => s.id === stallId)
|
||||
|
||||
if (cart && stall) {
|
||||
checkoutCart.value = cart
|
||||
checkoutStall.value = stall
|
||||
}
|
||||
}
|
||||
|
||||
const clearCheckout = () => {
|
||||
checkoutCart.value = null
|
||||
checkoutStall.value = null
|
||||
activeOrder.value = null
|
||||
paymentRequest.value = null
|
||||
paymentStatus.value = null
|
||||
}
|
||||
|
||||
const setShippingZone = (stallId: string, shippingZone: ShippingZone) => {
|
||||
const cart = stallCarts.value[stallId]
|
||||
if (cart) {
|
||||
cart.shippingZone = shippingZone
|
||||
}
|
||||
}
|
||||
|
||||
// Order management methods
|
||||
const createOrder = (orderData: Omit<Order, 'id' | 'createdAt' | 'updatedAt'> & { id?: string }) => {
|
||||
const order: Order = {
|
||||
...orderData,
|
||||
id: orderData.id || generateOrderId(),
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
updatedAt: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
orders.value[order.id] = order
|
||||
activeOrder.value = order
|
||||
|
||||
// Save to localStorage for persistence
|
||||
saveOrdersToStorage()
|
||||
|
||||
return order
|
||||
}
|
||||
|
||||
const createAndPlaceOrder = async (orderData: Omit<Order, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||
try {
|
||||
// Create the order
|
||||
const order = createOrder(orderData)
|
||||
|
||||
// Attempt to publish order via nostrmarket protocol
|
||||
let nostrmarketSuccess = false
|
||||
let nostrmarketError: string | undefined
|
||||
|
||||
try {
|
||||
// Publish the order event to nostrmarket using proper protocol
|
||||
const eventId = await nostrmarketService.publishOrder(order, order.sellerPubkey)
|
||||
nostrmarketSuccess = true
|
||||
order.sentViaNostr = true
|
||||
order.nostrEventId = eventId
|
||||
|
||||
console.log('Order published via nostrmarket successfully:', eventId)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown nostrmarket error'
|
||||
order.nostrError = errorMessage
|
||||
order.sentViaNostr = false
|
||||
console.error('Failed to publish order via nostrmarket:', errorMessage)
|
||||
}
|
||||
|
||||
// Update order status to 'pending'
|
||||
updateOrderStatus(order.id, 'pending')
|
||||
|
||||
// Clear the checkout cart
|
||||
if (checkoutCart.value) {
|
||||
clearStallCart(checkoutCart.value.id)
|
||||
}
|
||||
|
||||
// Clear checkout state
|
||||
clearCheckout()
|
||||
|
||||
// Show appropriate success/error message
|
||||
if (nostrmarketSuccess) {
|
||||
console.log('Order created and published via nostrmarket successfully')
|
||||
} else {
|
||||
console.warn('Order created but nostrmarket publishing failed:', nostrmarketError)
|
||||
}
|
||||
|
||||
return order
|
||||
} catch (error) {
|
||||
console.error('Failed to create and place order:', error)
|
||||
throw new Error('Failed to place order. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
// nostrmarket integration methods
|
||||
const publishToNostrmarket = async () => {
|
||||
try {
|
||||
console.log('Publishing merchant catalog to nostrmarket...')
|
||||
|
||||
// Get all stalls and products
|
||||
const allStalls = Object.values(stalls.value)
|
||||
const allProducts = Object.values(products.value)
|
||||
|
||||
if (allStalls.length === 0) {
|
||||
console.warn('No stalls to publish to nostrmarket')
|
||||
return null
|
||||
}
|
||||
|
||||
if (allProducts.length === 0) {
|
||||
console.warn('No products to publish to nostrmarket')
|
||||
return null
|
||||
}
|
||||
|
||||
// Publish to nostrmarket
|
||||
const result = await nostrmarketService.publishMerchantCatalog(allStalls, allProducts)
|
||||
|
||||
console.log('Successfully published to nostrmarket:', result)
|
||||
|
||||
// Update stalls and products with event IDs
|
||||
for (const [stallId, eventId] of Object.entries(result.stalls)) {
|
||||
const stall = stalls.value.find(s => s.id === stallId)
|
||||
if (stall) {
|
||||
stall.nostrEventId = eventId
|
||||
}
|
||||
}
|
||||
|
||||
for (const [productId, eventId] of Object.entries(result.products)) {
|
||||
const product = products.value.find(p => p.id === productId)
|
||||
if (product) {
|
||||
product.nostrEventId = eventId
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Failed to publish to nostrmarket:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Invoice management methods
|
||||
const createLightningInvoice = async (orderId: string, adminKey: string): Promise<LightningInvoice | null> => {
|
||||
try {
|
||||
const order = orders.value[orderId]
|
||||
if (!order) {
|
||||
throw new Error('Order not found')
|
||||
}
|
||||
|
||||
// Create Lightning invoice with admin key and nostrmarket tag
|
||||
// For nostrmarket compatibility, we need to use the original order ID if it exists
|
||||
// If no originalOrderId exists, this order was created in the web-app, so use the current orderId
|
||||
const orderIdForInvoice = order.originalOrderId || orderId
|
||||
console.log('Creating invoice with order ID:', {
|
||||
webAppOrderId: orderId,
|
||||
originalOrderId: order.originalOrderId,
|
||||
orderIdForInvoice: orderIdForInvoice,
|
||||
hasOriginalOrderId: !!order.originalOrderId
|
||||
})
|
||||
|
||||
const invoice = await invoiceService.createInvoice(order, adminKey, {
|
||||
tag: "nostrmarket",
|
||||
order_id: orderIdForInvoice, // Use original Nostr order ID for nostrmarket compatibility
|
||||
merchant_pubkey: order.sellerPubkey,
|
||||
buyer_pubkey: order.buyerPubkey
|
||||
})
|
||||
|
||||
// Update order with invoice details
|
||||
order.lightningInvoice = invoice
|
||||
order.paymentHash = invoice.payment_hash
|
||||
order.paymentStatus = 'pending'
|
||||
order.paymentRequest = invoice.bolt11 // Use bolt11 field from LNBits response
|
||||
|
||||
// Save to localStorage after invoice creation
|
||||
saveOrdersToStorage()
|
||||
|
||||
// Start monitoring payment
|
||||
await paymentMonitor.startMonitoring(order, invoice)
|
||||
|
||||
// Set up payment update callback
|
||||
paymentMonitor.onPaymentUpdate(orderId, (update) => {
|
||||
handlePaymentUpdate(orderId, update)
|
||||
})
|
||||
|
||||
console.log('Lightning invoice created for order:', {
|
||||
orderId,
|
||||
originalOrderId: order.originalOrderId,
|
||||
nostrmarketOrderId: order.originalOrderId || orderId,
|
||||
paymentHash: invoice.payment_hash,
|
||||
amount: invoice.amount
|
||||
})
|
||||
|
||||
return invoice
|
||||
} catch (error) {
|
||||
console.error('Failed to create Lightning invoice:', error)
|
||||
throw new Error('Failed to create payment invoice')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePaymentUpdate = (orderId: string, update: any) => {
|
||||
const order = orders.value[orderId]
|
||||
if (!order) return
|
||||
|
||||
// Update order payment status
|
||||
order.paymentStatus = update.status
|
||||
if (update.status === 'paid') {
|
||||
order.paidAt = update.paidAt
|
||||
updateOrderStatus(orderId, 'paid')
|
||||
|
||||
// Send payment confirmation via Nostr
|
||||
sendPaymentConfirmation(order)
|
||||
}
|
||||
|
||||
// Save to localStorage after payment update
|
||||
saveOrdersToStorage()
|
||||
|
||||
console.log('Payment status updated for order:', {
|
||||
orderId,
|
||||
status: update.status,
|
||||
amount: update.amount
|
||||
})
|
||||
}
|
||||
|
||||
const sendPaymentConfirmation = async (order: Order) => {
|
||||
try {
|
||||
if (!nostrOrders.isReady.value) {
|
||||
console.warn('Nostr not ready for payment confirmation')
|
||||
return
|
||||
}
|
||||
|
||||
// Create payment confirmation message
|
||||
// const confirmation = {
|
||||
// type: 'payment_confirmation',
|
||||
// orderId: order.id,
|
||||
// paymentHash: order.paymentHash,
|
||||
// amount: order.total,
|
||||
// currency: order.currency,
|
||||
// paidAt: order.paidAt,
|
||||
// message: 'Payment received! Your order is being processed.'
|
||||
// }
|
||||
|
||||
// Send confirmation to customer
|
||||
await nostrOrders.publishOrderEvent(order, order.buyerPubkey)
|
||||
|
||||
console.log('Payment confirmation sent via Nostr')
|
||||
} catch (error) {
|
||||
console.error('Failed to send payment confirmation:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getOrderInvoice = (orderId: string): LightningInvoice | null => {
|
||||
const order = orders.value[orderId]
|
||||
return order?.lightningInvoice || null
|
||||
}
|
||||
|
||||
const getOrderPaymentStatus = (orderId: string): 'pending' | 'paid' | 'expired' | null => {
|
||||
const order = orders.value[orderId]
|
||||
return order?.paymentStatus || null
|
||||
}
|
||||
|
||||
const updateOrderStatus = (orderId: string, status: OrderStatus) => {
|
||||
const order = orders.value[orderId]
|
||||
if (order) {
|
||||
order.status = status
|
||||
order.updatedAt = Date.now() / 1000
|
||||
saveOrdersToStorage()
|
||||
}
|
||||
}
|
||||
|
||||
const updateOrder = (orderId: string, updatedOrder: Partial<Order>) => {
|
||||
const order = orders.value[orderId]
|
||||
if (order) {
|
||||
Object.assign(order, updatedOrder)
|
||||
order.updatedAt = Date.now() / 1000
|
||||
saveOrdersToStorage()
|
||||
}
|
||||
}
|
||||
|
||||
const setPaymentRequest = (request: PaymentRequest) => {
|
||||
paymentRequest.value = request
|
||||
}
|
||||
|
||||
const setPaymentStatus = (status: PaymentStatus) => {
|
||||
paymentStatus.value = status
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
const generateOrderId = () => {
|
||||
return `order_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
}
|
||||
|
||||
// Persistence methods
|
||||
const saveOrdersToStorage = () => {
|
||||
try {
|
||||
const storageKey = getUserStorageKey('market_orders')
|
||||
localStorage.setItem(storageKey, JSON.stringify(orders.value))
|
||||
console.log('Saved orders to localStorage with key:', storageKey)
|
||||
} catch (error) {
|
||||
console.warn('Failed to save orders to localStorage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadOrdersFromStorage = () => {
|
||||
try {
|
||||
const storageKey = getUserStorageKey('market_orders')
|
||||
const stored = localStorage.getItem(storageKey)
|
||||
if (stored) {
|
||||
const parsedOrders = JSON.parse(stored)
|
||||
orders.value = parsedOrders
|
||||
console.log('Loaded orders from localStorage with key:', storageKey, 'Orders count:', Object.keys(parsedOrders).length)
|
||||
} else {
|
||||
console.log('No orders found in localStorage for key:', storageKey)
|
||||
// Clear any existing orders when switching users
|
||||
orders.value = {}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load orders from localStorage:', error)
|
||||
// Clear orders on error
|
||||
orders.value = {}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear orders when user changes
|
||||
const clearOrdersForUserChange = () => {
|
||||
orders.value = {}
|
||||
console.log('Cleared orders for user change')
|
||||
}
|
||||
|
||||
// Payment utility methods
|
||||
const calculateOrderTotal = (cart: StallCart, shippingZone?: ShippingZone) => {
|
||||
const subtotal = cart.subtotal
|
||||
const shippingCost = shippingZone?.cost || 0
|
||||
return subtotal + shippingCost
|
||||
}
|
||||
|
||||
const validateCartForCheckout = (stallId: string): { valid: boolean; errors: string[] } => {
|
||||
const cart = stallCarts.value[stallId]
|
||||
const errors: string[] = []
|
||||
|
||||
if (!cart || cart.products.length === 0) {
|
||||
errors.push('Cart is empty')
|
||||
return { valid: false, errors }
|
||||
}
|
||||
|
||||
// Check if all products are still in stock
|
||||
for (const item of cart.products) {
|
||||
if (item.quantity > item.product.quantity) {
|
||||
errors.push(`${item.product.name} is out of stock`)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if cart has shipping zone selected
|
||||
if (!cart.shippingZone) {
|
||||
errors.push('Please select a shipping zone')
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors }
|
||||
}
|
||||
|
||||
const getCartSummary = (stallId: string) => {
|
||||
const cart = stallCarts.value[stallId]
|
||||
if (!cart) return null
|
||||
|
||||
const itemCount = cart.products.reduce((total, item) => total + item.quantity, 0)
|
||||
const subtotal = cart.subtotal
|
||||
const shippingCost = cart.shippingZone?.cost || 0
|
||||
const total = subtotal + shippingCost
|
||||
|
||||
return {
|
||||
itemCount,
|
||||
subtotal,
|
||||
shippingCost,
|
||||
total,
|
||||
currency: cart.currency
|
||||
}
|
||||
}
|
||||
|
||||
const updateFilterData = (newFilterData: Partial<FilterData>) => {
|
||||
filterData.value = { ...filterData.value, ...newFilterData }
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
filterData.value = {
|
||||
categories: [],
|
||||
merchants: [],
|
||||
stalls: [],
|
||||
currency: null,
|
||||
priceFrom: null,
|
||||
priceTo: null,
|
||||
inStock: null,
|
||||
paymentMethods: []
|
||||
}
|
||||
}
|
||||
|
||||
const toggleCategoryFilter = (category: string) => {
|
||||
const index = filterData.value.categories.indexOf(category)
|
||||
if (index >= 0) {
|
||||
filterData.value.categories.splice(index, 1)
|
||||
} else {
|
||||
filterData.value.categories.push(category)
|
||||
}
|
||||
}
|
||||
|
||||
const updateSortOptions = (field: string, order: 'asc' | 'desc' = 'asc') => {
|
||||
sortOptions.value = { field, order }
|
||||
}
|
||||
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
if (currency === 'sat') {
|
||||
return `${price} sats`
|
||||
}
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase()
|
||||
}).format(price)
|
||||
}
|
||||
|
||||
// Initialize orders from localStorage
|
||||
loadOrdersFromStorage()
|
||||
|
||||
// Watch for user changes and reload orders
|
||||
watch(() => auth.currentUser?.value?.pubkey, (newPubkey, oldPubkey) => {
|
||||
if (newPubkey !== oldPubkey) {
|
||||
console.log('User changed, clearing and reloading orders. Old:', oldPubkey, 'New:', newPubkey)
|
||||
clearOrdersForUserChange()
|
||||
loadOrdersFromStorage()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
// State
|
||||
markets: readonly(markets),
|
||||
stalls: readonly(stalls),
|
||||
products: readonly(products),
|
||||
orders: readonly(orders),
|
||||
profiles: readonly(profiles),
|
||||
activeMarket: readonly(activeMarket),
|
||||
activeStall: readonly(activeStall),
|
||||
activeProduct: readonly(activeProduct),
|
||||
isLoading: readonly(isLoading),
|
||||
error: readonly(error),
|
||||
searchText: readonly(searchText),
|
||||
showFilterDetails: readonly(showFilterDetails),
|
||||
filterData: readonly(filterData),
|
||||
sortOptions: readonly(sortOptions),
|
||||
shoppingCart: readonly(shoppingCart),
|
||||
stallCarts: readonly(stallCarts),
|
||||
checkoutCart: readonly(checkoutCart),
|
||||
checkoutStall: readonly(checkoutStall),
|
||||
activeOrder: readonly(activeOrder),
|
||||
paymentRequest: readonly(paymentRequest),
|
||||
paymentStatus: readonly(paymentStatus),
|
||||
|
||||
// Computed
|
||||
filteredProducts,
|
||||
allCategories,
|
||||
allStallCarts,
|
||||
totalCartItems,
|
||||
totalCartValue,
|
||||
activeStallCart,
|
||||
cartTotal,
|
||||
cartItemCount,
|
||||
|
||||
// Actions
|
||||
setLoading,
|
||||
setError,
|
||||
setSearchText,
|
||||
setActiveMarket,
|
||||
setActiveStall,
|
||||
setActiveProduct,
|
||||
addProduct,
|
||||
addStall,
|
||||
addMarket,
|
||||
addToCart,
|
||||
removeFromCart,
|
||||
updateCartQuantity,
|
||||
clearCart,
|
||||
updateFilterData,
|
||||
clearFilters,
|
||||
toggleCategoryFilter,
|
||||
updateSortOptions,
|
||||
formatPrice,
|
||||
addToStallCart,
|
||||
removeFromStallCart,
|
||||
updateStallCartQuantity,
|
||||
updateStallCartSubtotal,
|
||||
clearStallCart,
|
||||
clearAllStallCarts,
|
||||
setCheckoutCart,
|
||||
clearCheckout,
|
||||
setShippingZone,
|
||||
createOrder,
|
||||
updateOrderStatus,
|
||||
setPaymentRequest,
|
||||
setPaymentStatus,
|
||||
generateOrderId,
|
||||
calculateOrderTotal,
|
||||
validateCartForCheckout,
|
||||
getCartSummary,
|
||||
createAndPlaceOrder,
|
||||
createLightningInvoice,
|
||||
handlePaymentUpdate,
|
||||
sendPaymentConfirmation,
|
||||
getOrderInvoice,
|
||||
getOrderPaymentStatus,
|
||||
updateOrder,
|
||||
saveOrdersToStorage,
|
||||
loadOrdersFromStorage,
|
||||
clearOrdersForUserChange,
|
||||
publishToNostrmarket
|
||||
}
|
||||
})
|
||||
150
src/modules/market/types/market.ts
Normal file
150
src/modules/market/types/market.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
export interface Market {
|
||||
d: string
|
||||
pubkey: string
|
||||
relays: string[]
|
||||
selected: boolean
|
||||
opts: {
|
||||
name: string
|
||||
description?: string
|
||||
logo?: string
|
||||
banner?: string
|
||||
merchants: string[]
|
||||
ui?: Record<string, any>
|
||||
}
|
||||
}
|
||||
|
||||
export interface Stall {
|
||||
id: string
|
||||
pubkey: string
|
||||
name: string
|
||||
description?: string
|
||||
logo?: string
|
||||
categories?: string[]
|
||||
shipping?: ShippingZone[]
|
||||
currency: string
|
||||
nostrEventId?: string
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: string
|
||||
stall_id: string
|
||||
stallName: string
|
||||
name: string
|
||||
description?: string
|
||||
price: number
|
||||
currency: string
|
||||
quantity: number
|
||||
images?: string[]
|
||||
categories?: string[]
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
nostrEventId?: string
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: string
|
||||
cartId: string
|
||||
stallId: string
|
||||
buyerPubkey: string
|
||||
sellerPubkey: string
|
||||
status: OrderStatus
|
||||
items: OrderItem[]
|
||||
contactInfo: ContactInfo
|
||||
shippingZone: ShippingZone
|
||||
paymentRequest?: string
|
||||
paymentMethod: PaymentMethod
|
||||
subtotal: number
|
||||
shippingCost: number
|
||||
total: number
|
||||
currency: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
nostrEventId?: string
|
||||
nostrEventSig?: string
|
||||
sentViaNostr?: boolean
|
||||
nostrError?: string
|
||||
originalOrderId?: string
|
||||
lightningInvoice?: any
|
||||
paymentHash?: string
|
||||
paidAt?: number
|
||||
paymentStatus?: 'pending' | 'paid' | 'expired'
|
||||
qrCodeDataUrl?: string
|
||||
qrCodeLoading?: boolean
|
||||
qrCodeError?: string | null
|
||||
showQRCode?: boolean
|
||||
}
|
||||
|
||||
export interface OrderItem {
|
||||
productId: string
|
||||
productName: string
|
||||
quantity: number
|
||||
price: number
|
||||
currency: string
|
||||
}
|
||||
|
||||
export interface ContactInfo {
|
||||
address?: string
|
||||
email?: string
|
||||
message?: string
|
||||
npub?: string
|
||||
}
|
||||
|
||||
export interface ShippingZone {
|
||||
id: string
|
||||
name: string
|
||||
cost: number
|
||||
currency: string
|
||||
description?: string
|
||||
estimatedDays?: string
|
||||
requiresPhysicalShipping?: boolean
|
||||
}
|
||||
|
||||
export type OrderStatus = 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled' | 'processing'
|
||||
|
||||
export type PaymentMethod = 'lightning' | 'btc_onchain'
|
||||
|
||||
export interface CartItem {
|
||||
product: Product
|
||||
quantity: number
|
||||
stallId: string
|
||||
}
|
||||
|
||||
export interface StallCart {
|
||||
id: string
|
||||
merchant: string
|
||||
products: CartItem[]
|
||||
subtotal: number
|
||||
shippingZone?: ShippingZone
|
||||
currency: string
|
||||
}
|
||||
|
||||
export interface FilterData {
|
||||
categories: string[]
|
||||
merchants: string[]
|
||||
stalls: string[]
|
||||
currency: string | null
|
||||
priceFrom: number | null
|
||||
priceTo: number | null
|
||||
inStock: boolean | null
|
||||
paymentMethods: PaymentMethod[]
|
||||
}
|
||||
|
||||
export interface SortOptions {
|
||||
field: string
|
||||
order: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export interface PaymentRequest {
|
||||
paymentRequest: string
|
||||
amount: number
|
||||
currency: string
|
||||
expiresAt: number
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface PaymentStatus {
|
||||
paid: boolean
|
||||
amountPaid: number
|
||||
paidAt?: number
|
||||
transactionId?: string
|
||||
}
|
||||
125
src/modules/market/views/MarketDashboard.vue
Normal file
125
src/modules/market/views/MarketDashboard.vue
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-foreground">Market Dashboard</h1>
|
||||
<p class="text-muted-foreground mt-2">
|
||||
Manage your market activities as both a customer and merchant
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Tabs -->
|
||||
<div class="mb-6">
|
||||
<nav class="flex space-x-8 border-b border-border">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
@click="activeTab = tab.id"
|
||||
:class="[
|
||||
'py-2 px-1 border-b-2 font-medium text-sm transition-colors',
|
||||
activeTab === tab.id
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<component :is="tab.icon" class="w-4 h-4" />
|
||||
<span>{{ tab.name }}</span>
|
||||
<Badge v-if="tab.badge" variant="secondary" class="text-xs">
|
||||
{{ tab.badge }}
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="min-h-[600px]">
|
||||
<!-- Overview Tab -->
|
||||
<div v-if="activeTab === 'overview'" class="space-y-6">
|
||||
<DashboardOverview />
|
||||
</div>
|
||||
|
||||
<!-- My Orders Tab (Customer) -->
|
||||
<div v-else-if="activeTab === 'orders'" class="space-y-6">
|
||||
<OrderHistory />
|
||||
</div>
|
||||
|
||||
<!-- My Store Tab (Merchant) -->
|
||||
<div v-else-if="activeTab === 'store'" class="space-y-6">
|
||||
<MerchantStore />
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<div v-else-if="activeTab === 'settings'" class="space-y-6">
|
||||
<MarketSettings />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
BarChart3,
|
||||
Package,
|
||||
Store,
|
||||
Settings,
|
||||
|
||||
} from 'lucide-vue-next'
|
||||
import DashboardOverview from '../components/DashboardOverview.vue'
|
||||
import OrderHistory from '../components/OrderHistory.vue'
|
||||
import MerchantStore from '../components/MerchantStore.vue'
|
||||
import MarketSettings from '../components/MarketSettings.vue'
|
||||
|
||||
// const auth = useAuth()
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
// Local state
|
||||
const activeTab = ref('overview')
|
||||
|
||||
// Computed properties for tab badges
|
||||
const orderCount = computed(() => Object.keys(marketStore.orders).length)
|
||||
const pendingOrders = computed(() =>
|
||||
Object.values(marketStore.orders).filter(o => o.status === 'pending').length
|
||||
)
|
||||
// const pendingPayments = computed(() =>
|
||||
// Object.values(marketStore.orders).filter(o => o.paymentStatus === 'pending').length
|
||||
// )
|
||||
|
||||
// Dashboard tabs
|
||||
const tabs = computed(() => [
|
||||
{
|
||||
id: 'overview',
|
||||
name: 'Overview',
|
||||
icon: BarChart3,
|
||||
badge: null
|
||||
},
|
||||
{
|
||||
id: 'orders',
|
||||
name: 'My Orders',
|
||||
icon: Package,
|
||||
badge: orderCount.value > 0 ? orderCount.value : null
|
||||
},
|
||||
{
|
||||
id: 'store',
|
||||
name: 'My Store',
|
||||
icon: Store,
|
||||
badge: pendingOrders.value > 0 ? pendingOrders.value : null
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
name: 'Settings',
|
||||
icon: Settings,
|
||||
badge: null
|
||||
}
|
||||
])
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
console.log('Market Dashboard mounted')
|
||||
})
|
||||
</script>
|
||||
|
||||
187
src/modules/market/views/MarketPage.vue
Normal file
187
src/modules/market/views/MarketPage.vue
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
<template>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Loading State -->
|
||||
<div v-if="!isMarketReady && ((marketStore.isLoading ?? false) || marketPreloader.isPreloading)" class="flex justify-center items-center min-h-64">
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
<p class="text-gray-600">
|
||||
{{ marketPreloader.isPreloading ? 'Preloading market...' : 'Loading market...' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="(marketStore.error || marketPreloader.preloadError) && marketStore.products.length === 0" class="flex justify-center items-center min-h-64">
|
||||
<div class="text-center">
|
||||
<h2 class="text-2xl font-bold text-red-600 mb-4">Failed to load market</h2>
|
||||
<p class="text-gray-600 mb-4">{{ marketStore.error || marketPreloader.preloadError }}</p>
|
||||
<Button @click="retryLoadMarket" variant="outline">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Market Content -->
|
||||
<div v-else>
|
||||
<!-- Market Header -->
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div class="flex items-center space-x-4">
|
||||
<Avatar v-if="marketStore.activeMarket?.opts?.logo">
|
||||
<AvatarImage :src="marketStore.activeMarket.opts.logo" />
|
||||
<AvatarFallback>M</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">
|
||||
{{ marketStore.activeMarket?.opts?.name || 'Market' }}
|
||||
</h1>
|
||||
<p v-if="marketStore.activeMarket?.opts?.description" class="text-gray-600">
|
||||
{{ marketStore.activeMarket.opts.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="flex-1 max-w-md ml-8">
|
||||
<Input
|
||||
v-model="marketStore.searchText"
|
||||
type="text"
|
||||
placeholder="Search products..."
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category Filters -->
|
||||
<div v-if="marketStore.allCategories.length > 0" class="mb-6">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge
|
||||
v-for="category in marketStore.allCategories"
|
||||
:key="category.category"
|
||||
:variant="category.selected ? 'default' : 'secondary'"
|
||||
class="cursor-pointer hover:bg-blue-100"
|
||||
@click="marketStore.toggleCategoryFilter(category.category)"
|
||||
>
|
||||
{{ category.category }}
|
||||
<span class="ml-1 text-xs">({{ category.count }})</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Products State -->
|
||||
<div v-if="isMarketReady && marketStore.filteredProducts.length === 0 && !(marketStore.isLoading ?? false)" class="text-center py-12">
|
||||
<h3 class="text-xl font-semibold text-gray-600 mb-2">No products found</h3>
|
||||
<p class="text-gray-500">Try adjusting your search or filters</p>
|
||||
</div>
|
||||
|
||||
<!-- Product Grid -->
|
||||
<div v-if="isMarketReady && marketStore.filteredProducts.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<ProductCard
|
||||
v-for="product in marketStore.filteredProducts"
|
||||
:key="product.id"
|
||||
:product="product"
|
||||
@add-to-cart="addToCart"
|
||||
@view-details="viewProduct"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Cart Summary -->
|
||||
<div v-if="marketStore.totalCartItems > 0" class="fixed bottom-4 right-4">
|
||||
<Button @click="viewCart" class="shadow-lg">
|
||||
<ShoppingCart class="w-5 h-5 mr-2" />
|
||||
Cart ({{ marketStore.totalCartItems }})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import { useMarket } from '@/composables/useMarket'
|
||||
import { useMarketPreloader } from '@/composables/useMarketPreloader'
|
||||
import { config } from '@/lib/config'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { ShoppingCart } from 'lucide-vue-next'
|
||||
import ProductCard from '../components/ProductCard.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const marketStore = useMarketStore()
|
||||
const market = useMarket()
|
||||
const marketPreloader = useMarketPreloader()
|
||||
|
||||
let unsubscribe: (() => void) | null = null
|
||||
|
||||
// Check if we need to load market data
|
||||
const needsToLoadMarket = computed(() => {
|
||||
return !marketPreloader.isPreloaded.value &&
|
||||
!marketPreloader.isPreloading.value &&
|
||||
marketStore.products.length === 0
|
||||
})
|
||||
|
||||
// Check if market data is ready (either preloaded or loaded)
|
||||
const isMarketReady = computed(() => {
|
||||
const isLoading = marketStore.isLoading ?? false
|
||||
const ready = marketPreloader.isPreloaded.value ||
|
||||
(marketStore.products.length > 0 && !isLoading)
|
||||
|
||||
return ready
|
||||
})
|
||||
|
||||
const loadMarket = async () => {
|
||||
try {
|
||||
const naddr = config.market.defaultNaddr
|
||||
if (!naddr) {
|
||||
throw new Error('No market naddr configured')
|
||||
}
|
||||
|
||||
await market.connectToMarket()
|
||||
await market.loadMarket(naddr)
|
||||
|
||||
// Subscribe to real-time updates
|
||||
unsubscribe = market.subscribeToMarketUpdates()
|
||||
|
||||
} catch (error) {
|
||||
marketStore.setError(error instanceof Error ? error.message : 'Failed to load market')
|
||||
}
|
||||
}
|
||||
|
||||
const retryLoadMarket = () => {
|
||||
marketStore.setError(null)
|
||||
marketPreloader.resetPreload()
|
||||
loadMarket()
|
||||
}
|
||||
|
||||
const addToCart = (product: any) => {
|
||||
marketStore.addToCart(product)
|
||||
}
|
||||
|
||||
const viewProduct = (_product: any) => {
|
||||
// TODO: Navigate to product detail page
|
||||
}
|
||||
|
||||
const viewCart = () => {
|
||||
router.push('/cart')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Only load market if it hasn't been preloaded
|
||||
if (needsToLoadMarket.value) {
|
||||
loadMarket()
|
||||
} else if (marketPreloader.isPreloaded.value) {
|
||||
// Subscribe to real-time updates if market was preloaded
|
||||
unsubscribe = market.subscribeToMarketUpdates()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unsubscribe) {
|
||||
unsubscribe()
|
||||
}
|
||||
market.disconnectFromMarket()
|
||||
})
|
||||
</script>
|
||||
|
|
@ -1,187 +1,4 @@
|
|||
<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/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 lang="ts">
|
||||
import MarketPageComponent from '@/modules/market/views/MarketPage.vue'
|
||||
export default MarketPageComponent
|
||||
</script>
|
||||
|
|
@ -1,125 +1,4 @@
|
|||
<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/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 lang="ts">
|
||||
import MarketDashboardComponent from '@/modules/market/views/MarketDashboard.vue'
|
||||
export default MarketDashboardComponent
|
||||
</script>
|
||||
|
|
@ -22,33 +22,6 @@ const router = createRouter({
|
|||
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',
|
||||
name: 'cart',
|
||||
|
|
@ -73,21 +46,6 @@ const router = createRouter({
|
|||
component: () => import('@/pages/OrderHistory.vue'),
|
||||
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',
|
||||
name: 'relay-hub-status',
|
||||
|
|
|
|||
1040
src/stores/market.ts
1040
src/stores/market.ts
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue