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,
|
enabled: true,
|
||||||
lazy: false,
|
lazy: false,
|
||||||
config: {
|
config: {
|
||||||
|
apiConfig: {
|
||||||
|
baseUrl: 'http://lnbits',
|
||||||
|
apiKey: 'your-api-key-here'
|
||||||
|
},
|
||||||
ticketValidationEndpoint: '/api/tickets/validate',
|
ticketValidationEndpoint: '/api/tickets/validate',
|
||||||
maxTicketsPerUser: 10
|
maxTicketsPerUser: 10
|
||||||
}
|
}
|
||||||
|
|
|
||||||
27
src/app.ts
27
src/app.ts
|
|
@ -12,6 +12,9 @@ import appConfig from './app.config'
|
||||||
// Base modules
|
// Base modules
|
||||||
import baseModule from './modules/base'
|
import baseModule from './modules/base'
|
||||||
import nostrFeedModule from './modules/nostr-feed'
|
import nostrFeedModule from './modules/nostr-feed'
|
||||||
|
import chatModule from './modules/chat'
|
||||||
|
import eventsModule from './modules/events'
|
||||||
|
import marketModule from './modules/market'
|
||||||
|
|
||||||
// Root component
|
// Root component
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
|
@ -81,10 +84,26 @@ export async function createAppInstance() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Register other modules as they're converted
|
// Register chat module
|
||||||
// - market module
|
if (appConfig.modules.chat.enabled) {
|
||||||
// - chat module
|
moduleRegistrations.push(
|
||||||
// - events module
|
pluginManager.register(chatModule, appConfig.modules.chat)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register events module
|
||||||
|
if (appConfig.modules.events.enabled) {
|
||||||
|
moduleRegistrations.push(
|
||||||
|
pluginManager.register(eventsModule, appConfig.modules.events)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register market module
|
||||||
|
if (appConfig.modules.market.enabled) {
|
||||||
|
moduleRegistrations.push(
|
||||||
|
pluginManager.register(marketModule, appConfig.modules.market)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for all modules to register
|
// Wait for all modules to register
|
||||||
await Promise.all(moduleRegistrations)
|
await Promise.all(moduleRegistrations)
|
||||||
|
|
|
||||||
|
|
@ -1,231 +1,4 @@
|
||||||
<template>
|
<script lang="ts">
|
||||||
<div class="bg-card border rounded-lg p-6 shadow-sm">
|
import CartSummaryComponent from '@/modules/market/components/CartSummary.vue'
|
||||||
<!-- Cart Summary Header -->
|
export default CartSummaryComponent
|
||||||
<div class="border-b border-border pb-4 mb-4">
|
|
||||||
<h3 class="text-lg font-semibold text-foreground">Order Summary</h3>
|
|
||||||
<p class="text-sm text-muted-foreground">
|
|
||||||
{{ itemCount }} item{{ itemCount !== 1 ? 's' : '' }} in cart
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Cart Items Summary -->
|
|
||||||
<div class="space-y-3 mb-4">
|
|
||||||
<div
|
|
||||||
v-for="item in cartItems"
|
|
||||||
:key="item.product.id"
|
|
||||||
class="flex items-center justify-between text-sm"
|
|
||||||
>
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<img
|
|
||||||
:src="item.product.images?.[0] || '/placeholder-product.png'"
|
|
||||||
:alt="item.product.name"
|
|
||||||
class="w-8 h-8 object-cover rounded"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<p class="font-medium text-foreground">{{ item.product.name }}</p>
|
|
||||||
<p class="text-muted-foreground">Qty: {{ item.quantity }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="font-medium text-foreground">
|
|
||||||
{{ formatPrice(item.product.price * item.quantity, item.product.currency) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Shipping Zone Selection -->
|
|
||||||
<div class="border-t border-border pt-4 mb-4">
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<label class="text-sm font-medium text-foreground">Shipping Zone</label>
|
|
||||||
<Button
|
|
||||||
v-if="availableShippingZones.length > 1"
|
|
||||||
@click="showShippingSelector = !showShippingSelector"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{{ selectedShippingZone ? 'Change' : 'Select' }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="selectedShippingZone" class="flex items-center justify-between p-3 bg-muted rounded">
|
|
||||||
<div>
|
|
||||||
<p class="font-medium text-foreground">{{ selectedShippingZone.name }}</p>
|
|
||||||
<p v-if="selectedShippingZone.description" class="text-sm text-muted-foreground">
|
|
||||||
{{ selectedShippingZone.description }}
|
|
||||||
</p>
|
|
||||||
<p v-if="selectedShippingZone.estimatedDays" class="text-xs text-muted-foreground">
|
|
||||||
Estimated: {{ selectedShippingZone.estimatedDays }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p class="font-semibold text-foreground">
|
|
||||||
{{ formatPrice(selectedShippingZone.cost, selectedShippingZone.currency) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Shipping Zone Selector -->
|
|
||||||
<div v-if="showShippingSelector && availableShippingZones.length > 1" class="mt-2">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div
|
|
||||||
v-for="zone in availableShippingZones"
|
|
||||||
:key="zone.id"
|
|
||||||
@click="selectShippingZone(zone)"
|
|
||||||
class="flex items-center justify-between p-3 border rounded cursor-pointer hover:bg-muted/50"
|
|
||||||
:class="{ 'border-primary bg-primary/10': selectedShippingZone?.id === zone.id }"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p class="font-medium text-foreground">{{ zone.name }}</p>
|
|
||||||
<p v-if="zone.description" class="text-sm text-muted-foreground">
|
|
||||||
{{ zone.description }}
|
|
||||||
</p>
|
|
||||||
<p v-if="zone.estimatedDays" class="text-xs text-muted-foreground">
|
|
||||||
Estimated: {{ zone.estimatedDays }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p class="font-semibold text-foreground">
|
|
||||||
{{ formatPrice(zone.cost, zone.currency) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="!selectedShippingZone" class="text-sm text-red-600">
|
|
||||||
Please select a shipping zone
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Price Breakdown -->
|
|
||||||
<div class="border-t border-border pt-4 mb-6">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex justify-between text-sm">
|
|
||||||
<span class="text-muted-foreground">Subtotal</span>
|
|
||||||
<span class="text-foreground">{{ formatPrice(subtotal, currency) }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="selectedShippingZone" class="flex justify-between text-sm">
|
|
||||||
<span class="text-muted-foreground">Shipping</span>
|
|
||||||
<span class="text-foreground">
|
|
||||||
{{ formatPrice(selectedShippingZone.cost, selectedShippingZone.currency) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border-t border-border pt-2 flex justify-between font-semibold text-lg">
|
|
||||||
<span class="text-foreground">Total</span>
|
|
||||||
<span class="text-green-600">{{ formatPrice(total, currency) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Checkout Actions -->
|
|
||||||
<div class="space-y-3">
|
|
||||||
<Button
|
|
||||||
@click="continueShopping"
|
|
||||||
variant="outline"
|
|
||||||
class="w-full"
|
|
||||||
>
|
|
||||||
Back to Cart
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Security Note -->
|
|
||||||
<div class="mt-4 p-3 bg-muted/50 rounded-lg">
|
|
||||||
<div class="flex items-start space-x-2">
|
|
||||||
<Shield class="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
|
||||||
<div class="text-sm text-muted-foreground">
|
|
||||||
<p class="font-medium text-foreground">Secure Checkout</p>
|
|
||||||
<p>Your order will be encrypted and sent securely to the merchant using Nostr.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Shield } from 'lucide-vue-next'
|
|
||||||
import type { ShippingZone } from '@/stores/market'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
stallId: string
|
|
||||||
cartItems: readonly {
|
|
||||||
readonly product: {
|
|
||||||
readonly id: string
|
|
||||||
readonly stall_id: string
|
|
||||||
readonly stallName: string
|
|
||||||
readonly name: string
|
|
||||||
readonly description?: string
|
|
||||||
readonly price: number
|
|
||||||
readonly currency: string
|
|
||||||
readonly quantity: number
|
|
||||||
readonly images?: readonly string[]
|
|
||||||
readonly categories?: readonly string[]
|
|
||||||
readonly createdAt: number
|
|
||||||
readonly updatedAt: number
|
|
||||||
}
|
|
||||||
readonly quantity: number
|
|
||||||
readonly stallId: string
|
|
||||||
}[]
|
|
||||||
subtotal: number
|
|
||||||
currency: string
|
|
||||||
availableShippingZones: readonly {
|
|
||||||
readonly id: string
|
|
||||||
readonly name: string
|
|
||||||
readonly cost: number
|
|
||||||
readonly currency: string
|
|
||||||
readonly description?: string
|
|
||||||
readonly estimatedDays?: string
|
|
||||||
readonly requiresPhysicalShipping?: boolean
|
|
||||||
}[]
|
|
||||||
selectedShippingZone?: {
|
|
||||||
readonly id: string
|
|
||||||
readonly name: string
|
|
||||||
readonly cost: number
|
|
||||||
readonly currency: string
|
|
||||||
readonly description?: string
|
|
||||||
readonly estimatedDays?: string
|
|
||||||
readonly requiresPhysicalShipping?: boolean
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'shipping-zone-selected': [shippingZone: ShippingZone]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
// const marketStore = useMarketStore()
|
|
||||||
|
|
||||||
// Local state
|
|
||||||
const showShippingSelector = ref(false)
|
|
||||||
|
|
||||||
// Computed properties
|
|
||||||
const itemCount = computed(() =>
|
|
||||||
props.cartItems.reduce((total, item) => total + item.quantity, 0)
|
|
||||||
)
|
|
||||||
|
|
||||||
const total = computed(() => {
|
|
||||||
const shippingCost = props.selectedShippingZone?.cost || 0
|
|
||||||
return props.subtotal + shippingCost
|
|
||||||
})
|
|
||||||
|
|
||||||
// Methods
|
|
||||||
const selectShippingZone = (shippingZone: ShippingZone) => {
|
|
||||||
emit('shipping-zone-selected', shippingZone)
|
|
||||||
showShippingSelector.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const continueShopping = () => {
|
|
||||||
router.push('/cart')
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatPrice = (price: number, currency: string) => {
|
|
||||||
if (currency === 'sats' || currency === 'sat') {
|
|
||||||
return `${price.toLocaleString()} sats`
|
|
||||||
}
|
|
||||||
return new Intl.NumberFormat('en-US', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: currency.toUpperCase()
|
|
||||||
}).format(price)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -1,334 +1,4 @@
|
||||||
<template>
|
<script lang="ts">
|
||||||
<div class="bg-background border rounded-lg p-6">
|
import PaymentDisplayComponent from '@/modules/market/components/PaymentDisplay.vue'
|
||||||
<div class="flex items-center justify-between mb-4">
|
export default PaymentDisplayComponent
|
||||||
<h3 class="text-lg font-semibold text-foreground">Payment</h3>
|
|
||||||
<Badge :variant="getPaymentStatusVariant(paymentStatus)">
|
|
||||||
{{ formatPaymentStatus(paymentStatus) }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Invoice Information -->
|
|
||||||
<div v-if="invoice" class="space-y-4">
|
|
||||||
<!-- Amount and Status -->
|
|
||||||
<div class="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm text-muted-foreground">Amount</p>
|
|
||||||
<p class="text-2xl font-bold text-foreground">
|
|
||||||
{{ invoice.amount }} {{ currency }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="text-right">
|
|
||||||
<p class="text-sm text-muted-foreground">Status</p>
|
|
||||||
<p class="text-lg font-semibold" :class="getStatusColor(paymentStatus)">
|
|
||||||
{{ formatPaymentStatus(paymentStatus) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Lightning Invoice QR Code -->
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="mb-4">
|
|
||||||
<h4 class="font-medium text-foreground mb-2">Lightning Invoice</h4>
|
|
||||||
<p class="text-sm text-muted-foreground">Scan with your Lightning wallet to pay</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- QR Code -->
|
|
||||||
<div class="w-48 h-48 mx-auto mb-4">
|
|
||||||
<div v-if="qrCodeDataUrl && !qrCodeError" class="w-full h-full">
|
|
||||||
<img
|
|
||||||
:src="qrCodeDataUrl"
|
|
||||||
:alt="`QR Code for ${invoice.amount} ${currency} payment`"
|
|
||||||
class="w-full h-full border border-border rounded-lg"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="qrCodeLoading" class="w-full h-full bg-muted rounded-lg flex items-center justify-center">
|
|
||||||
<div class="text-center text-muted-foreground">
|
|
||||||
<div class="text-4xl mb-2 animate-pulse">⚡</div>
|
|
||||||
<div class="text-sm">Generating QR...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="qrCodeError" class="w-full h-full bg-destructive/10 border border-destructive/20 rounded-lg flex items-center justify-center">
|
|
||||||
<div class="text-center text-destructive">
|
|
||||||
<div class="text-4xl mb-2">⚠️</div>
|
|
||||||
<div class="text-sm">{{ qrCodeError }}</div>
|
|
||||||
<Button
|
|
||||||
@click="retryQRCode"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
class="mt-2"
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="w-full h-full bg-muted rounded-lg flex items-center justify-center">
|
|
||||||
<div class="text-center text-muted-foreground">
|
|
||||||
<div class="text-4xl mb-2">⚡</div>
|
|
||||||
<div class="text-sm">No invoice</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- QR Code Actions -->
|
|
||||||
<div v-if="qrCodeDataUrl && !qrCodeError" class="mb-4">
|
|
||||||
<!-- Download button removed -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Payment Request -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="block text-sm font-medium text-foreground mb-2">
|
|
||||||
Payment Request
|
|
||||||
</label>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
:value="invoice.bolt11"
|
|
||||||
readonly
|
|
||||||
class="flex-1 font-mono text-sm"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
@click="copyPaymentRequest"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<Copy class="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Copy to Wallet Button -->
|
|
||||||
<Button
|
|
||||||
@click="openInWallet"
|
|
||||||
variant="default"
|
|
||||||
class="w-full"
|
|
||||||
>
|
|
||||||
<Wallet class="w-4 h-4 mr-2" />
|
|
||||||
Open in Lightning Wallet
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Payment Details -->
|
|
||||||
<div class="border-t pt-4">
|
|
||||||
<h4 class="font-medium text-foreground mb-3">Payment Details</h4>
|
|
||||||
<div class="space-y-2 text-sm">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-muted-foreground">Payment Hash:</span>
|
|
||||||
<span class="font-mono text-foreground">{{ formatHash(invoice.payment_hash) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-muted-foreground">Created:</span>
|
|
||||||
<span class="text-foreground">{{ formatDate(invoice.created_at) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-muted-foreground">Expires:</span>
|
|
||||||
<span class="text-foreground">{{ formatDate(invoice.expiry) }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="paidAt" class="flex justify-between">
|
|
||||||
<span class="text-muted-foreground">Paid At:</span>
|
|
||||||
<span class="text-foreground">{{ formatDate(paidAt) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- No Invoice State -->
|
|
||||||
<div v-else class="text-center py-8">
|
|
||||||
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mx-auto mb-4">
|
|
||||||
<Wallet class="w-8 h-8 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<h4 class="text-lg font-medium text-foreground mb-2">No Payment Invoice</h4>
|
|
||||||
<p class="text-muted-foreground mb-4">
|
|
||||||
A Lightning invoice will be sent by the merchant once they process your order.
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-muted-foreground">
|
|
||||||
You'll receive the invoice via Nostr when it's ready.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Payment Instructions -->
|
|
||||||
<div v-if="paymentStatus === 'pending'" class="mt-6 p-4 bg-muted/50 border border-border rounded-lg">
|
|
||||||
<div class="flex items-start space-x-3">
|
|
||||||
<div class="w-5 h-5 bg-muted rounded-full flex items-center justify-center mt-0.5">
|
|
||||||
<Info class="w-3 h-3 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-muted-foreground">
|
|
||||||
<h5 class="font-medium mb-1 text-foreground">Payment Instructions</h5>
|
|
||||||
<ul class="space-y-1">
|
|
||||||
<li>• Use a Lightning-compatible wallet (e.g., Phoenix, Breez, Alby)</li>
|
|
||||||
<li>• Scan the QR code or copy the payment request</li>
|
|
||||||
<li>• Confirm the payment amount and send</li>
|
|
||||||
<li>• Your order will be processed once payment is confirmed</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Payment Success -->
|
|
||||||
<div v-if="paymentStatus === 'paid'" class="mt-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<div class="w-5 h-5 bg-green-100 rounded-full flex items-center justify-center">
|
|
||||||
<CheckCircle class="w-3 h-3 text-green-600" />
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-green-800">
|
|
||||||
<h5 class="font-medium">Payment Confirmed!</h5>
|
|
||||||
<p>Your order is being processed. You'll receive updates via Nostr.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import {
|
|
||||||
Copy,
|
|
||||||
Wallet,
|
|
||||||
Info,
|
|
||||||
CheckCircle
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
import { useMarketStore } from '@/stores/market'
|
|
||||||
import QRCode from 'qrcode'
|
|
||||||
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
orderId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
const marketStore = useMarketStore()
|
|
||||||
|
|
||||||
// Computed properties
|
|
||||||
const order = computed(() => marketStore.orders[props.orderId])
|
|
||||||
const invoice = computed(() => order.value?.lightningInvoice)
|
|
||||||
const paymentStatus = computed(() => order.value?.paymentStatus || 'pending')
|
|
||||||
const currency = computed(() => order.value?.currency || 'sat')
|
|
||||||
const paidAt = computed(() => order.value?.paidAt)
|
|
||||||
|
|
||||||
// QR Code generation
|
|
||||||
const qrCodeDataUrl = ref<string | null>(null)
|
|
||||||
const qrCodeLoading = ref(false)
|
|
||||||
const qrCodeError = ref<string | null>(null)
|
|
||||||
|
|
||||||
const generateQRCode = async (paymentRequest: string) => {
|
|
||||||
try {
|
|
||||||
qrCodeLoading.value = true
|
|
||||||
qrCodeError.value = null
|
|
||||||
|
|
||||||
const dataUrl = await QRCode.toDataURL(paymentRequest, {
|
|
||||||
width: 192, // 48 * 4 for high DPI displays
|
|
||||||
margin: 2,
|
|
||||||
color: {
|
|
||||||
dark: '#000000',
|
|
||||||
light: '#FFFFFF'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
qrCodeDataUrl.value = dataUrl
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to generate QR code:', error)
|
|
||||||
qrCodeError.value = 'Failed to generate QR code'
|
|
||||||
qrCodeDataUrl.value = null
|
|
||||||
} finally {
|
|
||||||
qrCodeLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Methods
|
|
||||||
const getPaymentStatusVariant = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'paid': return 'default'
|
|
||||||
case 'pending': return 'secondary'
|
|
||||||
case 'expired': return 'destructive'
|
|
||||||
default: return 'outline'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatPaymentStatus = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'paid': return 'Paid'
|
|
||||||
case 'pending': return 'Pending'
|
|
||||||
case 'expired': return 'Expired'
|
|
||||||
default: return 'Unknown'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'paid': return 'text-green-600'
|
|
||||||
case 'pending': return 'text-amber-600'
|
|
||||||
case 'expired': return 'text-destructive'
|
|
||||||
default: return 'text-muted-foreground'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatHash = (hash: string) => {
|
|
||||||
if (!hash) return 'N/A'
|
|
||||||
return `${hash.substring(0, 8)}...${hash.substring(hash.length - 8)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDate = (dateValue: string | number | undefined) => {
|
|
||||||
if (!dateValue) return 'N/A'
|
|
||||||
|
|
||||||
let timestamp: number
|
|
||||||
|
|
||||||
if (typeof dateValue === 'string') {
|
|
||||||
// Handle ISO date strings from LNBits API
|
|
||||||
timestamp = new Date(dateValue).getTime()
|
|
||||||
} else {
|
|
||||||
// Handle Unix timestamps (seconds) from our store
|
|
||||||
timestamp = dateValue * 1000
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Date(timestamp).toLocaleString()
|
|
||||||
}
|
|
||||||
|
|
||||||
const copyPaymentRequest = async () => {
|
|
||||||
if (!invoice.value?.bolt11) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(invoice.value.bolt11)
|
|
||||||
// TODO: Show toast notification
|
|
||||||
console.log('Payment request copied to clipboard')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to copy payment request:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const openInWallet = () => {
|
|
||||||
if (!invoice.value?.bolt11) return
|
|
||||||
|
|
||||||
// Open in Lightning wallet
|
|
||||||
const walletUrl = `lightning:${invoice.value.bolt11}`
|
|
||||||
window.open(walletUrl, '_blank')
|
|
||||||
}
|
|
||||||
|
|
||||||
const retryQRCode = () => {
|
|
||||||
if (invoice.value?.bolt11) {
|
|
||||||
generateQRCode(invoice.value.bolt11)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lifecycle
|
|
||||||
onMounted(() => {
|
|
||||||
// Set up payment monitoring if invoice exists
|
|
||||||
if (invoice.value && props.orderId) {
|
|
||||||
// Payment monitoring is handled by the market store
|
|
||||||
console.log('Payment display mounted for order:', props.orderId)
|
|
||||||
|
|
||||||
// Generate QR code for the invoice
|
|
||||||
if (invoice.value.bolt11) {
|
|
||||||
generateQRCode(invoice.value.bolt11)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Watch for invoice changes to regenerate QR code
|
|
||||||
watch(() => invoice.value?.bolt11, (newPaymentRequest) => {
|
|
||||||
if (newPaymentRequest) {
|
|
||||||
generateQRCode(newPaymentRequest)
|
|
||||||
} else {
|
|
||||||
qrCodeDataUrl.value = null
|
|
||||||
}
|
|
||||||
}, { immediate: true })
|
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -1,250 +1,4 @@
|
||||||
<template>
|
<script lang="ts">
|
||||||
<div class="space-y-6">
|
import ShoppingCartComponent from '@/modules/market/components/ShoppingCart.vue'
|
||||||
<!-- Cart Header -->
|
export default ShoppingCartComponent
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-2xl font-bold text-foreground">Shopping Cart</h2>
|
|
||||||
<p class="text-muted-foreground">
|
|
||||||
{{ totalCartItems }} items across {{ allStallCarts.length }} stalls
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center space-x-4">
|
|
||||||
<div class="text-right">
|
|
||||||
<p class="text-sm text-muted-foreground">Total Value</p>
|
|
||||||
<p class="text-xl font-bold text-green-600">
|
|
||||||
{{ formatPrice(totalCartValue, 'sats') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
v-if="allStallCarts.length > 0"
|
|
||||||
@click="clearAllCarts"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
Clear All
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty Cart State -->
|
|
||||||
<div v-if="allStallCarts.length === 0" class="text-center py-12">
|
|
||||||
<ShoppingCart class="mx-auto h-12 w-12 text-muted-foreground/50 mb-4" />
|
|
||||||
<h3 class="text-lg font-medium text-foreground mb-2">Your cart is empty</h3>
|
|
||||||
<p class="text-muted-foreground mb-6">Start shopping to add items to your cart</p>
|
|
||||||
<Button @click="$router.push('/market')" variant="default">
|
|
||||||
Continue Shopping
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stall Carts -->
|
|
||||||
<div v-else class="space-y-6">
|
|
||||||
<div
|
|
||||||
v-for="cart in allStallCarts"
|
|
||||||
:key="cart.id"
|
|
||||||
class="border border-border rounded-lg p-6 bg-card shadow-sm"
|
|
||||||
>
|
|
||||||
<!-- Stall Header -->
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<div class="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center">
|
|
||||||
<Store class="w-5 h-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 class="font-semibold text-foreground">
|
|
||||||
{{ getStallName(cart.id) }}
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-muted-foreground">
|
|
||||||
{{ cart.products.length }} item{{ cart.products.length !== 1 ? 's' : '' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-right">
|
|
||||||
<p class="text-sm text-muted-foreground">Stall Total</p>
|
|
||||||
<p class="text-lg font-semibold text-green-600">
|
|
||||||
{{ formatPrice(cart.subtotal, cart.currency) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Cart Items -->
|
|
||||||
<div class="space-y-3 mb-4">
|
|
||||||
<CartItem
|
|
||||||
v-for="item in cart.products"
|
|
||||||
:key="item.product.id"
|
|
||||||
:item="item"
|
|
||||||
:stall-id="cart.id"
|
|
||||||
@update-quantity="updateQuantity"
|
|
||||||
@remove-item="removeItem"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stall Cart Actions -->
|
|
||||||
<div class="pt-4 border-t border-border">
|
|
||||||
<!-- Desktop Layout -->
|
|
||||||
<div class="hidden md:flex items-center justify-between">
|
|
||||||
<div class="flex items-center space-x-4">
|
|
||||||
<Button
|
|
||||||
@click="clearStallCart(cart.id)"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
Clear Stall
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
@click="viewStall(cart.id)"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
View Stall
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<!-- Cart Summary for this stall -->
|
|
||||||
<div class="text-right mr-4">
|
|
||||||
<p class="text-sm text-muted-foreground">Total</p>
|
|
||||||
<p class="text-lg font-semibold text-green-600">
|
|
||||||
{{ formatPrice(cart.subtotal, cart.currency) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
@click="proceedToCheckout(cart.id)"
|
|
||||||
:disabled="!canProceedToCheckout(cart.id)"
|
|
||||||
variant="default"
|
|
||||||
>
|
|
||||||
Checkout
|
|
||||||
<ArrowRight class="w-4 h-4 ml-2" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mobile Layout -->
|
|
||||||
<div class="md:hidden space-y-4">
|
|
||||||
<!-- Action Buttons Row -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<Button
|
|
||||||
@click="clearStallCart(cart.id)"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
Clear Stall
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
@click="viewStall(cart.id)"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
View Stall
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Total and Checkout Row -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<!-- Cart Summary for this stall -->
|
|
||||||
<div class="text-left">
|
|
||||||
<p class="text-sm text-muted-foreground">Total</p>
|
|
||||||
<p class="text-lg font-semibold text-green-600">
|
|
||||||
{{ formatPrice(cart.subtotal, cart.currency) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
@click="proceedToCheckout(cart.id)"
|
|
||||||
:disabled="!canProceedToCheckout(cart.id)"
|
|
||||||
variant="default"
|
|
||||||
class="flex items-center"
|
|
||||||
>
|
|
||||||
Checkout
|
|
||||||
<ArrowRight class="w-4 h-4 ml-2" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Continue Shopping Button -->
|
|
||||||
<div v-if="allStallCarts.length > 0" class="text-center mt-8">
|
|
||||||
<Button @click="$router.push('/market')" variant="outline" size="lg">
|
|
||||||
Continue Shopping
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useMarketStore } from '@/stores/market'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import {
|
|
||||||
ShoppingCart,
|
|
||||||
Store,
|
|
||||||
ArrowRight
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
import CartItem from './CartItem.vue'
|
|
||||||
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const marketStore = useMarketStore()
|
|
||||||
|
|
||||||
// Computed properties
|
|
||||||
const allStallCarts = computed(() => marketStore.allStallCarts)
|
|
||||||
const totalCartItems = computed(() => marketStore.totalCartItems)
|
|
||||||
const totalCartValue = computed(() => marketStore.totalCartValue)
|
|
||||||
|
|
||||||
// Methods
|
|
||||||
const getStallName = (stallId: string) => {
|
|
||||||
const stall = marketStore.stalls.find(s => s.id === stallId)
|
|
||||||
return stall?.name || 'Unknown Stall'
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateQuantity = (stallId: string, productId: string, quantity: number) => {
|
|
||||||
marketStore.updateStallCartQuantity(stallId, productId, quantity)
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeItem = (stallId: string, productId: string) => {
|
|
||||||
marketStore.removeFromStallCart(stallId, productId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearStallCart = (stallId: string) => {
|
|
||||||
marketStore.clearStallCart(stallId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearAllCarts = () => {
|
|
||||||
marketStore.clearAllStallCarts()
|
|
||||||
}
|
|
||||||
|
|
||||||
const viewStall = (stallId: string) => {
|
|
||||||
// TODO: Navigate to stall page
|
|
||||||
console.log('View stall:', stallId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const proceedToCheckout = (stallId: string) => {
|
|
||||||
marketStore.setCheckoutCart(stallId)
|
|
||||||
router.push(`/checkout/${stallId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const canProceedToCheckout = (stallId: string) => {
|
|
||||||
const cart = marketStore.stallCarts[stallId]
|
|
||||||
return cart && cart.products.length > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatPrice = (price: number, currency: string) => {
|
|
||||||
if (currency === 'sats' || currency === 'sat') {
|
|
||||||
return `${price.toLocaleString()} sats`
|
|
||||||
}
|
|
||||||
return new Intl.NumberFormat('en-US', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: currency.toUpperCase()
|
|
||||||
}).format(price)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -1,534 +1,3 @@
|
||||||
import { ref, computed, onMounted, onUnmounted, readonly } from 'vue'
|
// Compatibility re-export for the moved useMarket composable
|
||||||
import { useNostrStore } from '@/stores/nostr'
|
export * from '@/modules/market/composables/useMarket'
|
||||||
import { useMarketStore } from '@/stores/market'
|
export { useMarket } from '@/modules/market/composables/useMarket'
|
||||||
import { relayHubComposable } from './useRelayHub'
|
|
||||||
import { config } from '@/lib/config'
|
|
||||||
|
|
||||||
// Nostr event kinds for market functionality
|
|
||||||
const MARKET_EVENT_KINDS = {
|
|
||||||
MARKET: 30019,
|
|
||||||
STALL: 30017,
|
|
||||||
PRODUCT: 30018,
|
|
||||||
ORDER: 30020
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export function useMarket() {
|
|
||||||
const nostrStore = useNostrStore()
|
|
||||||
const marketStore = useMarketStore()
|
|
||||||
const relayHub = relayHubComposable
|
|
||||||
|
|
||||||
// State
|
|
||||||
const isLoading = ref(false)
|
|
||||||
const error = ref<Error | null>(null)
|
|
||||||
const isConnected = ref(false)
|
|
||||||
const activeMarket = computed(() => marketStore.activeMarket)
|
|
||||||
const markets = computed(() => marketStore.markets)
|
|
||||||
const stalls = computed(() => marketStore.stalls)
|
|
||||||
const products = computed(() => marketStore.products)
|
|
||||||
const orders = computed(() => marketStore.orders)
|
|
||||||
|
|
||||||
// Connection state
|
|
||||||
const connectionStatus = computed(() => {
|
|
||||||
if (isConnected.value) return 'connected'
|
|
||||||
if (nostrStore.isConnecting) return 'connecting'
|
|
||||||
if (nostrStore.error) return 'error'
|
|
||||||
return 'disconnected'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Load market from naddr
|
|
||||||
const loadMarket = async (naddr: string) => {
|
|
||||||
try {
|
|
||||||
isLoading.value = true
|
|
||||||
error.value = null
|
|
||||||
|
|
||||||
// Load market from naddr
|
|
||||||
|
|
||||||
// Parse naddr to get market data
|
|
||||||
const marketData = {
|
|
||||||
identifier: naddr.split(':')[2] || 'default',
|
|
||||||
pubkey: naddr.split(':')[1] || nostrStore.account?.pubkey || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!marketData.pubkey) {
|
|
||||||
throw new Error('No pubkey available for market')
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadMarketData(marketData)
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
error.value = err instanceof Error ? err : new Error('Failed to load market')
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load market data from Nostr events
|
|
||||||
const loadMarketData = async (marketData: any) => {
|
|
||||||
try {
|
|
||||||
// Load market data from Nostr events
|
|
||||||
|
|
||||||
// Fetch market configuration event
|
|
||||||
const events = await relayHub.queryEvents([
|
|
||||||
{
|
|
||||||
kinds: [MARKET_EVENT_KINDS.MARKET],
|
|
||||||
authors: [marketData.pubkey],
|
|
||||||
'#d': [marketData.identifier]
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
// Process market events
|
|
||||||
|
|
||||||
if (events.length > 0) {
|
|
||||||
const marketEvent = events[0]
|
|
||||||
// Process market event
|
|
||||||
|
|
||||||
const market = {
|
|
||||||
d: marketData.identifier,
|
|
||||||
pubkey: marketData.pubkey,
|
|
||||||
relays: config.market.supportedRelays,
|
|
||||||
selected: true,
|
|
||||||
opts: JSON.parse(marketEvent.content)
|
|
||||||
}
|
|
||||||
|
|
||||||
marketStore.addMarket(market)
|
|
||||||
marketStore.setActiveMarket(market)
|
|
||||||
} else {
|
|
||||||
// No market events found, create default
|
|
||||||
// Create a default market if none exists
|
|
||||||
const market = {
|
|
||||||
d: marketData.identifier,
|
|
||||||
pubkey: marketData.pubkey,
|
|
||||||
relays: config.market.supportedRelays,
|
|
||||||
selected: true,
|
|
||||||
opts: {
|
|
||||||
name: 'Ariège Market',
|
|
||||||
description: 'A communal market to sell your goods',
|
|
||||||
merchants: [],
|
|
||||||
ui: {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
marketStore.addMarket(market)
|
|
||||||
marketStore.setActiveMarket(market)
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
// Don't throw error, create default market instead
|
|
||||||
const market = {
|
|
||||||
d: marketData.identifier,
|
|
||||||
pubkey: marketData.pubkey,
|
|
||||||
relays: config.market.supportedRelays,
|
|
||||||
selected: true,
|
|
||||||
opts: {
|
|
||||||
name: 'Default Market',
|
|
||||||
description: 'A default market',
|
|
||||||
merchants: [],
|
|
||||||
ui: {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
marketStore.addMarket(market)
|
|
||||||
marketStore.setActiveMarket(market)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load stalls from market merchants
|
|
||||||
const loadStalls = async () => {
|
|
||||||
try {
|
|
||||||
// Get the active market to filter by its merchants
|
|
||||||
const activeMarket = marketStore.activeMarket
|
|
||||||
if (!activeMarket) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const merchants = [...(activeMarket.opts.merchants || [])]
|
|
||||||
|
|
||||||
if (merchants.length === 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch stall events from market merchants only
|
|
||||||
const events = await relayHub.queryEvents([
|
|
||||||
{
|
|
||||||
kinds: [MARKET_EVENT_KINDS.STALL],
|
|
||||||
authors: merchants
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
// Process stall events
|
|
||||||
|
|
||||||
// Group events by stall ID and keep only the most recent version
|
|
||||||
const stallGroups = new Map<string, any[]>()
|
|
||||||
events.forEach((event: any) => {
|
|
||||||
const stallId = event.tags.find((tag: any) => tag[0] === 'd')?.[1]
|
|
||||||
if (stallId) {
|
|
||||||
if (!stallGroups.has(stallId)) {
|
|
||||||
stallGroups.set(stallId, [])
|
|
||||||
}
|
|
||||||
stallGroups.get(stallId)!.push(event)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Process each stall group
|
|
||||||
stallGroups.forEach((stallEvents, stallId) => {
|
|
||||||
// Sort by created_at and take the most recent
|
|
||||||
const latestEvent = stallEvents.sort((a: any, b: any) => b.created_at - a.created_at)[0]
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stallData = JSON.parse(latestEvent.content)
|
|
||||||
const stall = {
|
|
||||||
id: stallId,
|
|
||||||
pubkey: latestEvent.pubkey,
|
|
||||||
name: stallData.name || 'Unnamed Stall',
|
|
||||||
description: stallData.description || '',
|
|
||||||
created_at: latestEvent.created_at,
|
|
||||||
...stallData
|
|
||||||
}
|
|
||||||
|
|
||||||
marketStore.addStall(stall)
|
|
||||||
} catch (err) {
|
|
||||||
// Silently handle parse errors
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
// Silently handle stall loading errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load products from market stalls
|
|
||||||
const loadProducts = async () => {
|
|
||||||
try {
|
|
||||||
const activeMarket = marketStore.activeMarket
|
|
||||||
if (!activeMarket) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const merchants = [...(activeMarket.opts.merchants || [])]
|
|
||||||
if (merchants.length === 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch product events from market merchants
|
|
||||||
const events = await relayHub.queryEvents([
|
|
||||||
{
|
|
||||||
kinds: [MARKET_EVENT_KINDS.PRODUCT],
|
|
||||||
authors: merchants
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
// Process product events
|
|
||||||
|
|
||||||
// Group events by product ID and keep only the most recent version
|
|
||||||
const productGroups = new Map<string, any[]>()
|
|
||||||
events.forEach((event: any) => {
|
|
||||||
const productId = event.tags.find((tag: any) => tag[0] === 'd')?.[1]
|
|
||||||
if (productId) {
|
|
||||||
if (!productGroups.has(productId)) {
|
|
||||||
productGroups.set(productId, [])
|
|
||||||
}
|
|
||||||
productGroups.get(productId)!.push(event)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Process each product group
|
|
||||||
productGroups.forEach((productEvents, productId) => {
|
|
||||||
// Sort by created_at and take the most recent
|
|
||||||
const latestEvent = productEvents.sort((a: any, b: any) => b.created_at - a.created_at)[0]
|
|
||||||
|
|
||||||
try {
|
|
||||||
const productData = JSON.parse(latestEvent.content)
|
|
||||||
const product = {
|
|
||||||
id: productId,
|
|
||||||
stall_id: productData.stall_id || 'unknown',
|
|
||||||
stallName: productData.stallName || 'Unknown Stall',
|
|
||||||
name: productData.name || 'Unnamed Product',
|
|
||||||
description: productData.description || '',
|
|
||||||
price: productData.price || 0,
|
|
||||||
currency: productData.currency || 'sats',
|
|
||||||
quantity: productData.quantity || 1,
|
|
||||||
images: productData.images || [],
|
|
||||||
categories: productData.categories || [],
|
|
||||||
createdAt: latestEvent.created_at,
|
|
||||||
updatedAt: latestEvent.created_at
|
|
||||||
}
|
|
||||||
|
|
||||||
marketStore.addProduct(product)
|
|
||||||
} catch (err) {
|
|
||||||
// Silently handle parse errors
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
// Silently handle product loading errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add sample products for testing
|
|
||||||
const addSampleProducts = () => {
|
|
||||||
const sampleProducts = [
|
|
||||||
{
|
|
||||||
id: 'sample-1',
|
|
||||||
stall_id: 'sample-stall',
|
|
||||||
stallName: 'Sample Stall',
|
|
||||||
pubkey: nostrStore.account?.pubkey || '',
|
|
||||||
name: 'Sample Product 1',
|
|
||||||
description: 'This is a sample product for testing',
|
|
||||||
price: 1000,
|
|
||||||
currency: 'sats',
|
|
||||||
quantity: 1,
|
|
||||||
images: [],
|
|
||||||
categories: [],
|
|
||||||
createdAt: Math.floor(Date.now() / 1000),
|
|
||||||
updatedAt: Math.floor(Date.now() / 1000)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'sample-2',
|
|
||||||
stall_id: 'sample-stall',
|
|
||||||
stallName: 'Sample Stall',
|
|
||||||
pubkey: nostrStore.account?.pubkey || '',
|
|
||||||
name: 'Sample Product 2',
|
|
||||||
description: 'Another sample product for testing',
|
|
||||||
price: 2000,
|
|
||||||
currency: 'sats',
|
|
||||||
quantity: 1,
|
|
||||||
images: [],
|
|
||||||
categories: [],
|
|
||||||
createdAt: Math.floor(Date.now() / 1000),
|
|
||||||
updatedAt: Math.floor(Date.now() / 1000)
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
sampleProducts.forEach(product => {
|
|
||||||
marketStore.addProduct(product)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe to market updates
|
|
||||||
const subscribeToMarketUpdates = (): (() => void) | null => {
|
|
||||||
try {
|
|
||||||
const activeMarket = marketStore.activeMarket
|
|
||||||
if (!activeMarket) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe to market events
|
|
||||||
const unsubscribe = relayHub.subscribe({
|
|
||||||
id: `market-${activeMarket.d}`,
|
|
||||||
filters: [
|
|
||||||
{ kinds: [MARKET_EVENT_KINDS.MARKET] },
|
|
||||||
{ kinds: [MARKET_EVENT_KINDS.STALL] },
|
|
||||||
{ kinds: [MARKET_EVENT_KINDS.PRODUCT] },
|
|
||||||
{ kinds: [MARKET_EVENT_KINDS.ORDER] }
|
|
||||||
],
|
|
||||||
onEvent: (event: any) => {
|
|
||||||
handleMarketEvent(event)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return unsubscribe
|
|
||||||
} catch (error) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle incoming market events
|
|
||||||
const handleMarketEvent = (event: any) => {
|
|
||||||
// Process market event
|
|
||||||
|
|
||||||
switch (event.kind) {
|
|
||||||
case MARKET_EVENT_KINDS.MARKET:
|
|
||||||
// Handle market updates
|
|
||||||
break
|
|
||||||
case MARKET_EVENT_KINDS.STALL:
|
|
||||||
// Handle stall updates
|
|
||||||
handleStallEvent(event)
|
|
||||||
break
|
|
||||||
case MARKET_EVENT_KINDS.PRODUCT:
|
|
||||||
// Handle product updates
|
|
||||||
handleProductEvent(event)
|
|
||||||
break
|
|
||||||
case MARKET_EVENT_KINDS.ORDER:
|
|
||||||
// Handle order updates
|
|
||||||
handleOrderEvent(event)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process pending products (products without stalls)
|
|
||||||
const processPendingProducts = () => {
|
|
||||||
const productsWithoutStalls = products.value.filter(product => {
|
|
||||||
// Check if product has a stall tag
|
|
||||||
return !product.stall_id
|
|
||||||
})
|
|
||||||
|
|
||||||
if (productsWithoutStalls.length > 0) {
|
|
||||||
// You could create default stalls or handle this as needed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle stall events
|
|
||||||
const handleStallEvent = (event: any) => {
|
|
||||||
try {
|
|
||||||
const stallId = event.tags.find((tag: any) => tag[0] === 'd')?.[1]
|
|
||||||
if (stallId) {
|
|
||||||
const stallData = JSON.parse(event.content)
|
|
||||||
const stall = {
|
|
||||||
id: stallId,
|
|
||||||
pubkey: event.pubkey,
|
|
||||||
name: stallData.name || 'Unnamed Stall',
|
|
||||||
description: stallData.description || '',
|
|
||||||
created_at: event.created_at,
|
|
||||||
...stallData
|
|
||||||
}
|
|
||||||
|
|
||||||
marketStore.addStall(stall)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Silently handle stall event errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle product events
|
|
||||||
const handleProductEvent = (event: any) => {
|
|
||||||
try {
|
|
||||||
const productId = event.tags.find((tag: any) => tag[0] === 'd')?.[1]
|
|
||||||
if (productId) {
|
|
||||||
const productData = JSON.parse(event.content)
|
|
||||||
const product = {
|
|
||||||
id: productId,
|
|
||||||
stall_id: productData.stall_id || 'unknown',
|
|
||||||
stallName: productData.stallName || 'Unknown Stall',
|
|
||||||
pubkey: event.pubkey,
|
|
||||||
name: productData.name || 'Unnamed Product',
|
|
||||||
description: productData.description || '',
|
|
||||||
price: productData.price || 0,
|
|
||||||
currency: productData.currency || 'sats',
|
|
||||||
quantity: productData.quantity || 1,
|
|
||||||
images: productData.images || [],
|
|
||||||
categories: productData.categories || [],
|
|
||||||
createdAt: event.created_at,
|
|
||||||
updatedAt: event.created_at
|
|
||||||
}
|
|
||||||
|
|
||||||
marketStore.addProduct(product)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Silently handle product event errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle order events
|
|
||||||
const handleOrderEvent = (_event: any) => {
|
|
||||||
try {
|
|
||||||
// const orderData = JSON.parse(event.content)
|
|
||||||
// const order = {
|
|
||||||
// id: event.id,
|
|
||||||
// stall_id: orderData.stall_id || 'unknown',
|
|
||||||
// product_id: orderData.product_id || 'unknown',
|
|
||||||
// buyer_pubkey: event.pubkey,
|
|
||||||
// seller_pubkey: orderData.seller_pubkey || '',
|
|
||||||
// quantity: orderData.quantity || 1,
|
|
||||||
// total_price: orderData.total_price || 0,
|
|
||||||
// currency: orderData.currency || 'sats',
|
|
||||||
// status: orderData.status || 'pending',
|
|
||||||
// payment_request: orderData.payment_request,
|
|
||||||
// created_at: event.created_at,
|
|
||||||
// updated_at: event.created_at
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Note: addOrder method doesn't exist in the store, so we'll just handle it silently
|
|
||||||
} catch (err) {
|
|
||||||
// Silently handle order event errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish a product
|
|
||||||
const publishProduct = async (_productData: any) => {
|
|
||||||
// Implementation would depend on your event creation logic
|
|
||||||
// TODO: Implement product publishing
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish a stall
|
|
||||||
const publishStall = async (_stallData: any) => {
|
|
||||||
// Implementation would depend on your event creation logic
|
|
||||||
// TODO: Implement stall publishing
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect to market
|
|
||||||
const connectToMarket = async () => {
|
|
||||||
try {
|
|
||||||
// Connect to market
|
|
||||||
|
|
||||||
// Connect to relay hub
|
|
||||||
await relayHub.connect()
|
|
||||||
isConnected.value = relayHub.isConnected.value
|
|
||||||
|
|
||||||
if (!isConnected.value) {
|
|
||||||
throw new Error('Failed to connect to Nostr relays')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Market connected successfully
|
|
||||||
|
|
||||||
// Load market data
|
|
||||||
await loadMarketData({
|
|
||||||
identifier: 'default',
|
|
||||||
pubkey: nostrStore.account?.pubkey || ''
|
|
||||||
})
|
|
||||||
|
|
||||||
// Load stalls and products
|
|
||||||
await loadStalls()
|
|
||||||
await loadProducts()
|
|
||||||
|
|
||||||
// Subscribe to updates
|
|
||||||
subscribeToMarketUpdates()
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
error.value = err instanceof Error ? err : new Error('Failed to connect to market')
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disconnect from market
|
|
||||||
const disconnectFromMarket = () => {
|
|
||||||
isConnected.value = false
|
|
||||||
error.value = null
|
|
||||||
// Market disconnected
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize market on mount
|
|
||||||
onMounted(async () => {
|
|
||||||
if (nostrStore.isConnected) {
|
|
||||||
await connectToMarket()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
onUnmounted(() => {
|
|
||||||
disconnectFromMarket()
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
// State
|
|
||||||
isLoading: readonly(isLoading),
|
|
||||||
error: readonly(error),
|
|
||||||
isConnected: readonly(isConnected),
|
|
||||||
connectionStatus: readonly(connectionStatus),
|
|
||||||
activeMarket: readonly(activeMarket),
|
|
||||||
markets: readonly(markets),
|
|
||||||
stalls: readonly(stalls),
|
|
||||||
products: readonly(products),
|
|
||||||
orders: readonly(orders),
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
loadMarket,
|
|
||||||
connectToMarket,
|
|
||||||
disconnectFromMarket,
|
|
||||||
addSampleProducts,
|
|
||||||
processPendingProducts,
|
|
||||||
publishProduct,
|
|
||||||
publishStall,
|
|
||||||
subscribeToMarketUpdates
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +1,3 @@
|
||||||
import { ref, readonly } from 'vue'
|
// Compatibility re-export for the moved useMarketPreloader composable
|
||||||
import { useMarket } from './useMarket'
|
export * from '@/modules/market/composables/useMarketPreloader'
|
||||||
import { useMarketStore } from '@/stores/market'
|
export { useMarketPreloader } from '@/modules/market/composables/useMarketPreloader'
|
||||||
import { config } from '@/lib/config'
|
|
||||||
|
|
||||||
export function useMarketPreloader() {
|
|
||||||
const isPreloading = ref(false)
|
|
||||||
const isPreloaded = ref(false)
|
|
||||||
const preloadError = ref<string | null>(null)
|
|
||||||
|
|
||||||
const market = useMarket()
|
|
||||||
const marketStore = useMarketStore()
|
|
||||||
|
|
||||||
const preloadMarket = async () => {
|
|
||||||
// Don't preload if already done or currently preloading
|
|
||||||
if (isPreloaded.value || isPreloading.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
isPreloading.value = true
|
|
||||||
preloadError.value = null
|
|
||||||
|
|
||||||
const naddr = config.market.defaultNaddr
|
|
||||||
if (!naddr) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect to market
|
|
||||||
await market.connectToMarket()
|
|
||||||
|
|
||||||
// Load market data
|
|
||||||
await market.loadMarket(naddr)
|
|
||||||
|
|
||||||
// Clear any error state since preloading succeeded
|
|
||||||
marketStore.setError(null)
|
|
||||||
|
|
||||||
isPreloaded.value = true
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
preloadError.value = error instanceof Error ? error.message : 'Failed to preload market'
|
|
||||||
// Don't throw error, let the UI handle it gracefully
|
|
||||||
} finally {
|
|
||||||
isPreloading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetPreload = () => {
|
|
||||||
isPreloaded.value = false
|
|
||||||
preloadError.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
isPreloading: readonly(isPreloading),
|
|
||||||
isPreloaded: readonly(isPreloaded),
|
|
||||||
preloadError: readonly(preloadError),
|
|
||||||
preloadMarket,
|
|
||||||
resetPreload
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,460 +1,3 @@
|
||||||
import { finalizeEvent, type EventTemplate, nip04 } from 'nostr-tools'
|
// Compatibility re-export for the moved nostrmarketService
|
||||||
import { relayHub } from '@/lib/nostr/relayHub'
|
export * from '@/modules/market/services/nostrmarketService'
|
||||||
import { auth } from '@/composables/useAuth'
|
export { nostrmarketService } from '@/modules/market/services/nostrmarketService'
|
||||||
import type { Stall, Product, Order } from '@/stores/market'
|
|
||||||
|
|
||||||
export interface NostrmarketStall {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
description?: string
|
|
||||||
currency: string
|
|
||||||
shipping: Array<{
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
cost: number
|
|
||||||
countries: string[]
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NostrmarketProduct {
|
|
||||||
id: string
|
|
||||||
stall_id: string
|
|
||||||
name: string
|
|
||||||
description?: string
|
|
||||||
images: string[]
|
|
||||||
categories: string[]
|
|
||||||
price: number
|
|
||||||
quantity: number
|
|
||||||
currency: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NostrmarketOrder {
|
|
||||||
id: string
|
|
||||||
items: Array<{
|
|
||||||
product_id: string
|
|
||||||
quantity: number
|
|
||||||
}>
|
|
||||||
contact: {
|
|
||||||
name: string
|
|
||||||
email?: string
|
|
||||||
phone?: string
|
|
||||||
}
|
|
||||||
address?: {
|
|
||||||
street: string
|
|
||||||
city: string
|
|
||||||
state: string
|
|
||||||
country: string
|
|
||||||
postal_code: string
|
|
||||||
}
|
|
||||||
shipping_id: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NostrmarketPaymentRequest {
|
|
||||||
type: 1
|
|
||||||
id: string
|
|
||||||
message?: string
|
|
||||||
payment_options: Array<{
|
|
||||||
type: string
|
|
||||||
link: string
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NostrmarketOrderStatus {
|
|
||||||
type: 2
|
|
||||||
id: string
|
|
||||||
message?: string
|
|
||||||
paid?: boolean
|
|
||||||
shipped?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export class NostrmarketService {
|
|
||||||
/**
|
|
||||||
* Convert hex string to Uint8Array (browser-compatible)
|
|
||||||
*/
|
|
||||||
private hexToUint8Array(hex: string): Uint8Array {
|
|
||||||
const bytes = new Uint8Array(hex.length / 2)
|
|
||||||
for (let i = 0; i < hex.length; i += 2) {
|
|
||||||
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
|
|
||||||
}
|
|
||||||
return bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
private getAuth() {
|
|
||||||
if (!auth.isAuthenticated.value || !auth.currentUser.value?.prvkey) {
|
|
||||||
throw new Error('User not authenticated or private key not available')
|
|
||||||
}
|
|
||||||
|
|
||||||
const pubkey = auth.currentUser.value.pubkey
|
|
||||||
const prvkey = auth.currentUser.value.prvkey
|
|
||||||
|
|
||||||
if (!pubkey || !prvkey) {
|
|
||||||
throw new Error('Public key or private key is missing')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate that we have proper hex strings
|
|
||||||
if (!/^[0-9a-fA-F]{64}$/.test(pubkey)) {
|
|
||||||
throw new Error(`Invalid public key format: ${pubkey.substring(0, 10)}...`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!/^[0-9a-fA-F]{64}$/.test(prvkey)) {
|
|
||||||
throw new Error(`Invalid private key format: ${prvkey.substring(0, 10)}...`)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('🔑 Key debug:', {
|
|
||||||
pubkey: pubkey.substring(0, 10) + '...',
|
|
||||||
prvkey: prvkey.substring(0, 10) + '...',
|
|
||||||
pubkeyIsHex: /^[0-9a-fA-F]{64}$/.test(pubkey),
|
|
||||||
prvkeyIsHex: /^[0-9a-fA-F]{64}$/.test(prvkey),
|
|
||||||
pubkeyLength: pubkey.length,
|
|
||||||
prvkeyLength: prvkey.length,
|
|
||||||
pubkeyType: typeof pubkey,
|
|
||||||
prvkeyType: typeof prvkey,
|
|
||||||
pubkeyIsString: typeof pubkey === 'string',
|
|
||||||
prvkeyIsString: typeof prvkey === 'string'
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
pubkey,
|
|
||||||
prvkey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publish a stall event (kind 30017) to Nostr
|
|
||||||
*/
|
|
||||||
async publishStall(stall: Stall): Promise<string> {
|
|
||||||
const { prvkey } = this.getAuth()
|
|
||||||
|
|
||||||
const stallData: NostrmarketStall = {
|
|
||||||
id: stall.id,
|
|
||||||
name: stall.name,
|
|
||||||
description: stall.description,
|
|
||||||
currency: stall.currency,
|
|
||||||
shipping: (stall.shipping || []).map(zone => ({
|
|
||||||
id: zone.id,
|
|
||||||
name: zone.name,
|
|
||||||
cost: zone.cost,
|
|
||||||
countries: []
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventTemplate: EventTemplate = {
|
|
||||||
kind: 30017,
|
|
||||||
tags: [
|
|
||||||
['t', 'stall'],
|
|
||||||
['t', 'nostrmarket']
|
|
||||||
],
|
|
||||||
content: JSON.stringify(stallData),
|
|
||||||
created_at: Math.floor(Date.now() / 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const prvkeyBytes = this.hexToUint8Array(prvkey)
|
|
||||||
const event = finalizeEvent(eventTemplate, prvkeyBytes)
|
|
||||||
const result = await relayHub.publishEvent(event)
|
|
||||||
|
|
||||||
console.log('Stall published to nostrmarket:', {
|
|
||||||
stallId: stall.id,
|
|
||||||
eventId: result,
|
|
||||||
content: stallData
|
|
||||||
})
|
|
||||||
|
|
||||||
return result.success.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publish a product event (kind 30018) to Nostr
|
|
||||||
*/
|
|
||||||
async publishProduct(product: Product): Promise<string> {
|
|
||||||
const { prvkey } = this.getAuth()
|
|
||||||
|
|
||||||
const productData: NostrmarketProduct = {
|
|
||||||
id: product.id,
|
|
||||||
stall_id: product.stall_id,
|
|
||||||
name: product.name,
|
|
||||||
description: product.description,
|
|
||||||
images: product.images || [],
|
|
||||||
categories: product.categories || [],
|
|
||||||
price: product.price,
|
|
||||||
quantity: product.quantity,
|
|
||||||
currency: product.currency
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventTemplate: EventTemplate = {
|
|
||||||
kind: 30018,
|
|
||||||
tags: [
|
|
||||||
['t', 'product'],
|
|
||||||
['t', 'nostrmarket'],
|
|
||||||
['t', 'stall', product.stall_id],
|
|
||||||
...(product.categories || []).map(cat => ['t', cat])
|
|
||||||
],
|
|
||||||
content: JSON.stringify(productData),
|
|
||||||
created_at: Math.floor(Date.now() / 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const prvkeyBytes = this.hexToUint8Array(prvkey)
|
|
||||||
const event = finalizeEvent(eventTemplate, prvkeyBytes)
|
|
||||||
const result = await relayHub.publishEvent(event)
|
|
||||||
|
|
||||||
console.log('Product published to nostrmarket:', {
|
|
||||||
productId: product.id,
|
|
||||||
eventId: result,
|
|
||||||
content: productData
|
|
||||||
})
|
|
||||||
|
|
||||||
return result.success.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publish an order event (kind 4 encrypted DM) to nostrmarket
|
|
||||||
*/
|
|
||||||
async publishOrder(order: Order, merchantPubkey: string): Promise<string> {
|
|
||||||
const { prvkey } = this.getAuth()
|
|
||||||
|
|
||||||
// Convert order to nostrmarket format - exactly matching the specification
|
|
||||||
const orderData = {
|
|
||||||
type: 0, // DirectMessageType.CUSTOMER_ORDER
|
|
||||||
id: order.id,
|
|
||||||
items: order.items.map(item => ({
|
|
||||||
product_id: item.productId,
|
|
||||||
quantity: item.quantity
|
|
||||||
})),
|
|
||||||
contact: {
|
|
||||||
name: order.contactInfo?.message || order.contactInfo?.email || 'Unknown',
|
|
||||||
email: order.contactInfo?.email || ''
|
|
||||||
// Remove phone field - not in nostrmarket specification
|
|
||||||
},
|
|
||||||
// Only include address if it's a physical good and address is provided
|
|
||||||
...(order.shippingZone?.requiresPhysicalShipping && order.contactInfo?.address ? {
|
|
||||||
address: order.contactInfo.address
|
|
||||||
} : {}),
|
|
||||||
shipping_id: order.shippingZone?.id || 'online'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encrypt the message using NIP-04
|
|
||||||
console.log('🔐 NIP-04 encryption debug:', {
|
|
||||||
prvkeyType: typeof prvkey,
|
|
||||||
prvkeyIsString: typeof prvkey === 'string',
|
|
||||||
prvkeyLength: prvkey.length,
|
|
||||||
prvkeySample: prvkey.substring(0, 10) + '...',
|
|
||||||
merchantPubkeyType: typeof merchantPubkey,
|
|
||||||
merchantPubkeyLength: merchantPubkey.length,
|
|
||||||
orderDataString: JSON.stringify(orderData).substring(0, 50) + '...'
|
|
||||||
})
|
|
||||||
|
|
||||||
let encryptedContent: string
|
|
||||||
try {
|
|
||||||
encryptedContent = await nip04.encrypt(prvkey, merchantPubkey, JSON.stringify(orderData))
|
|
||||||
console.log('🔐 NIP-04 encryption successful:', {
|
|
||||||
encryptedContentLength: encryptedContent.length,
|
|
||||||
encryptedContentSample: encryptedContent.substring(0, 50) + '...'
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('🔐 NIP-04 encryption failed:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventTemplate: EventTemplate = {
|
|
||||||
kind: 4, // Encrypted DM
|
|
||||||
tags: [['p', merchantPubkey]], // Recipient (merchant)
|
|
||||||
content: encryptedContent, // Use encrypted content
|
|
||||||
created_at: Math.floor(Date.now() / 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('🔧 finalizeEvent debug:', {
|
|
||||||
prvkeyType: typeof prvkey,
|
|
||||||
prvkeyIsString: typeof prvkey === 'string',
|
|
||||||
prvkeyLength: prvkey.length,
|
|
||||||
prvkeySample: prvkey.substring(0, 10) + '...',
|
|
||||||
encodedPrvkeyType: typeof new TextEncoder().encode(prvkey),
|
|
||||||
encodedPrvkeyLength: new TextEncoder().encode(prvkey).length,
|
|
||||||
eventTemplate
|
|
||||||
})
|
|
||||||
|
|
||||||
// Convert hex string to Uint8Array properly
|
|
||||||
const prvkeyBytes = this.hexToUint8Array(prvkey)
|
|
||||||
console.log('🔧 prvkeyBytes debug:', {
|
|
||||||
prvkeyBytesType: typeof prvkeyBytes,
|
|
||||||
prvkeyBytesLength: prvkeyBytes.length,
|
|
||||||
prvkeyBytesIsUint8Array: prvkeyBytes instanceof Uint8Array
|
|
||||||
})
|
|
||||||
|
|
||||||
const event = finalizeEvent(eventTemplate, prvkeyBytes)
|
|
||||||
const result = await relayHub.publishEvent(event)
|
|
||||||
|
|
||||||
console.log('Order published to nostrmarket:', {
|
|
||||||
orderId: order.id,
|
|
||||||
eventId: result,
|
|
||||||
merchantPubkey,
|
|
||||||
content: orderData,
|
|
||||||
encryptedContent: encryptedContent.substring(0, 50) + '...'
|
|
||||||
})
|
|
||||||
|
|
||||||
return result.success.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle incoming payment request from merchant (type 1)
|
|
||||||
*/
|
|
||||||
async handlePaymentRequest(paymentRequest: NostrmarketPaymentRequest): Promise<void> {
|
|
||||||
console.log('Received payment request from merchant:', {
|
|
||||||
orderId: paymentRequest.id,
|
|
||||||
message: paymentRequest.message,
|
|
||||||
paymentOptions: paymentRequest.payment_options
|
|
||||||
})
|
|
||||||
|
|
||||||
// Find the Lightning payment option
|
|
||||||
const lightningOption = paymentRequest.payment_options.find(option => option.type === 'ln')
|
|
||||||
if (!lightningOption) {
|
|
||||||
console.error('No Lightning payment option found in payment request')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the order in the store with payment request
|
|
||||||
const { useMarketStore } = await import('@/stores/market')
|
|
||||||
const marketStore = useMarketStore()
|
|
||||||
|
|
||||||
const order = Object.values(marketStore.orders).find(o =>
|
|
||||||
o.id === paymentRequest.id || o.originalOrderId === paymentRequest.id
|
|
||||||
)
|
|
||||||
|
|
||||||
if (order) {
|
|
||||||
// Update order with payment request details
|
|
||||||
const updatedOrder = {
|
|
||||||
...order,
|
|
||||||
paymentRequest: lightningOption.link,
|
|
||||||
paymentStatus: 'pending' as const,
|
|
||||||
status: 'pending' as const, // Ensure status is pending for payment
|
|
||||||
updatedAt: Math.floor(Date.now() / 1000),
|
|
||||||
items: [...order.items] // Convert readonly to mutable
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate QR code for the payment request
|
|
||||||
try {
|
|
||||||
const QRCode = await import('qrcode')
|
|
||||||
const qrCodeDataUrl = await QRCode.toDataURL(lightningOption.link, {
|
|
||||||
width: 256,
|
|
||||||
margin: 2,
|
|
||||||
color: {
|
|
||||||
dark: '#000000',
|
|
||||||
light: '#FFFFFF'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
updatedOrder.qrCodeDataUrl = qrCodeDataUrl
|
|
||||||
updatedOrder.qrCodeLoading = false
|
|
||||||
updatedOrder.qrCodeError = null
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to generate QR code:', error)
|
|
||||||
updatedOrder.qrCodeError = 'Failed to generate QR code'
|
|
||||||
updatedOrder.qrCodeLoading = false
|
|
||||||
}
|
|
||||||
|
|
||||||
marketStore.updateOrder(order.id, updatedOrder)
|
|
||||||
|
|
||||||
console.log('Order updated with payment request:', {
|
|
||||||
orderId: paymentRequest.id,
|
|
||||||
paymentRequest: lightningOption.link.substring(0, 50) + '...',
|
|
||||||
status: updatedOrder.status,
|
|
||||||
paymentStatus: updatedOrder.paymentStatus,
|
|
||||||
hasQRCode: !!updatedOrder.qrCodeDataUrl
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
console.warn('Payment request received for unknown order:', paymentRequest.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle incoming order status update from merchant (type 2)
|
|
||||||
*/
|
|
||||||
async handleOrderStatusUpdate(statusUpdate: NostrmarketOrderStatus): Promise<void> {
|
|
||||||
console.log('Received order status update from merchant:', {
|
|
||||||
orderId: statusUpdate.id,
|
|
||||||
message: statusUpdate.message,
|
|
||||||
paid: statusUpdate.paid,
|
|
||||||
shipped: statusUpdate.shipped
|
|
||||||
})
|
|
||||||
|
|
||||||
const { useMarketStore } = await import('@/stores/market')
|
|
||||||
const marketStore = useMarketStore()
|
|
||||||
|
|
||||||
const order = Object.values(marketStore.orders).find(o =>
|
|
||||||
o.id === statusUpdate.id || o.originalOrderId === statusUpdate.id
|
|
||||||
)
|
|
||||||
|
|
||||||
if (order) {
|
|
||||||
// Update order status
|
|
||||||
if (statusUpdate.paid !== undefined) {
|
|
||||||
const newStatus = statusUpdate.paid ? 'paid' : 'pending'
|
|
||||||
marketStore.updateOrderStatus(order.id, newStatus)
|
|
||||||
|
|
||||||
// Also update payment status
|
|
||||||
const updatedOrder = {
|
|
||||||
...order,
|
|
||||||
paymentStatus: (statusUpdate.paid ? 'paid' : 'pending') as 'paid' | 'pending' | 'expired',
|
|
||||||
paidAt: statusUpdate.paid ? Math.floor(Date.now() / 1000) : undefined,
|
|
||||||
updatedAt: Math.floor(Date.now() / 1000),
|
|
||||||
items: [...order.items] // Convert readonly to mutable
|
|
||||||
}
|
|
||||||
marketStore.updateOrder(order.id, updatedOrder)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (statusUpdate.shipped !== undefined) {
|
|
||||||
// Update shipping status if you have that field
|
|
||||||
const updatedOrder = {
|
|
||||||
...order,
|
|
||||||
shipped: statusUpdate.shipped,
|
|
||||||
status: statusUpdate.shipped ? 'shipped' : order.status,
|
|
||||||
updatedAt: Math.floor(Date.now() / 1000),
|
|
||||||
items: [...order.items] // Convert readonly to mutable
|
|
||||||
}
|
|
||||||
marketStore.updateOrder(order.id, updatedOrder)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Order status updated:', {
|
|
||||||
orderId: statusUpdate.id,
|
|
||||||
paid: statusUpdate.paid,
|
|
||||||
shipped: statusUpdate.shipped,
|
|
||||||
newStatus: statusUpdate.paid ? 'paid' : 'pending'
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
console.warn('Status update received for unknown order:', statusUpdate.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publish all stalls and products for a merchant
|
|
||||||
*/
|
|
||||||
async publishMerchantCatalog(stalls: Stall[], products: Product[]): Promise<{
|
|
||||||
stalls: Record<string, string>, // stallId -> eventId
|
|
||||||
products: Record<string, string> // productId -> eventId
|
|
||||||
}> {
|
|
||||||
const results = {
|
|
||||||
stalls: {} as Record<string, string>,
|
|
||||||
products: {} as Record<string, string>
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish stalls first
|
|
||||||
for (const stall of stalls) {
|
|
||||||
try {
|
|
||||||
const eventId = await this.publishStall(stall)
|
|
||||||
results.stalls[stall.id] = eventId
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to publish stall ${stall.id}:`, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish products
|
|
||||||
for (const product of products) {
|
|
||||||
try {
|
|
||||||
const eventId = await this.publishProduct(product)
|
|
||||||
results.products[product.id] = eventId
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to publish product ${product.id}:`, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export singleton instance
|
|
||||||
export const nostrmarketService = new NostrmarketService()
|
|
||||||
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 { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Minus, Plus, Trash2 } from 'lucide-vue-next'
|
import { Minus, Plus, Trash2 } from 'lucide-vue-next'
|
||||||
import type { CartItem as CartItemType } from '@/stores/market'
|
import type { CartItem as CartItemType } from '../types/market'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: CartItemType
|
item: CartItemType
|
||||||
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>
|
<script lang="ts">
|
||||||
<div class="container mx-auto px-4 py-8">
|
import MarketPageComponent from '@/modules/market/views/MarketPage.vue'
|
||||||
<!-- Loading State -->
|
export default MarketPageComponent
|
||||||
<div v-if="!isMarketReady && ((marketStore.isLoading ?? false) || marketPreloader.isPreloading)" class="flex justify-center items-center min-h-64">
|
|
||||||
<div class="flex flex-col items-center space-y-4">
|
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
|
||||||
<p class="text-gray-600">
|
|
||||||
{{ marketPreloader.isPreloading ? 'Preloading market...' : 'Loading market...' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error State -->
|
|
||||||
<div v-else-if="(marketStore.error || marketPreloader.preloadError) && marketStore.products.length === 0" class="flex justify-center items-center min-h-64">
|
|
||||||
<div class="text-center">
|
|
||||||
<h2 class="text-2xl font-bold text-red-600 mb-4">Failed to load market</h2>
|
|
||||||
<p class="text-gray-600 mb-4">{{ marketStore.error || marketPreloader.preloadError }}</p>
|
|
||||||
<Button @click="retryLoadMarket" variant="outline">
|
|
||||||
Try Again
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Market Content -->
|
|
||||||
<div v-else>
|
|
||||||
<!-- Market Header -->
|
|
||||||
<div class="flex items-center justify-between mb-8">
|
|
||||||
<div class="flex items-center space-x-4">
|
|
||||||
<Avatar v-if="marketStore.activeMarket?.opts?.logo">
|
|
||||||
<AvatarImage :src="marketStore.activeMarket.opts.logo" />
|
|
||||||
<AvatarFallback>M</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-bold">
|
|
||||||
{{ marketStore.activeMarket?.opts?.name || 'Market' }}
|
|
||||||
</h1>
|
|
||||||
<p v-if="marketStore.activeMarket?.opts?.description" class="text-gray-600">
|
|
||||||
{{ marketStore.activeMarket.opts.description }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Search Bar -->
|
|
||||||
<div class="flex-1 max-w-md ml-8">
|
|
||||||
<Input
|
|
||||||
v-model="marketStore.searchText"
|
|
||||||
type="text"
|
|
||||||
placeholder="Search products..."
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Category Filters -->
|
|
||||||
<div v-if="marketStore.allCategories.length > 0" class="mb-6">
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<Badge
|
|
||||||
v-for="category in marketStore.allCategories"
|
|
||||||
:key="category.category"
|
|
||||||
:variant="category.selected ? 'default' : 'secondary'"
|
|
||||||
class="cursor-pointer hover:bg-blue-100"
|
|
||||||
@click="marketStore.toggleCategoryFilter(category.category)"
|
|
||||||
>
|
|
||||||
{{ category.category }}
|
|
||||||
<span class="ml-1 text-xs">({{ category.count }})</span>
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- No Products State -->
|
|
||||||
<div v-if="isMarketReady && marketStore.filteredProducts.length === 0 && !(marketStore.isLoading ?? false)" class="text-center py-12">
|
|
||||||
<h3 class="text-xl font-semibold text-gray-600 mb-2">No products found</h3>
|
|
||||||
<p class="text-gray-500">Try adjusting your search or filters</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Product Grid -->
|
|
||||||
<div v-if="isMarketReady && marketStore.filteredProducts.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
|
||||||
<ProductCard
|
|
||||||
v-for="product in marketStore.filteredProducts"
|
|
||||||
:key="product.id"
|
|
||||||
:product="product"
|
|
||||||
@add-to-cart="addToCart"
|
|
||||||
@view-details="viewProduct"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Cart Summary -->
|
|
||||||
<div v-if="marketStore.totalCartItems > 0" class="fixed bottom-4 right-4">
|
|
||||||
<Button @click="viewCart" class="shadow-lg">
|
|
||||||
<ShoppingCart class="w-5 h-5 mr-2" />
|
|
||||||
Cart ({{ marketStore.totalCartItems }})
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { onMounted, onUnmounted, computed } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useMarketStore } from '@/stores/market'
|
|
||||||
import { useMarket } from '@/composables/useMarket'
|
|
||||||
import { useMarketPreloader } from '@/composables/useMarketPreloader'
|
|
||||||
import { config } from '@/lib/config'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
|
||||||
import { ShoppingCart } from 'lucide-vue-next'
|
|
||||||
import ProductCard from '@/components/market/ProductCard.vue'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const marketStore = useMarketStore()
|
|
||||||
const market = useMarket()
|
|
||||||
const marketPreloader = useMarketPreloader()
|
|
||||||
|
|
||||||
let unsubscribe: (() => void) | null = null
|
|
||||||
|
|
||||||
// Check if we need to load market data
|
|
||||||
const needsToLoadMarket = computed(() => {
|
|
||||||
return !marketPreloader.isPreloaded.value &&
|
|
||||||
!marketPreloader.isPreloading.value &&
|
|
||||||
marketStore.products.length === 0
|
|
||||||
})
|
|
||||||
|
|
||||||
// Check if market data is ready (either preloaded or loaded)
|
|
||||||
const isMarketReady = computed(() => {
|
|
||||||
const isLoading = marketStore.isLoading ?? false
|
|
||||||
const ready = marketPreloader.isPreloaded.value ||
|
|
||||||
(marketStore.products.length > 0 && !isLoading)
|
|
||||||
|
|
||||||
return ready
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadMarket = async () => {
|
|
||||||
try {
|
|
||||||
const naddr = config.market.defaultNaddr
|
|
||||||
if (!naddr) {
|
|
||||||
throw new Error('No market naddr configured')
|
|
||||||
}
|
|
||||||
|
|
||||||
await market.connectToMarket()
|
|
||||||
await market.loadMarket(naddr)
|
|
||||||
|
|
||||||
// Subscribe to real-time updates
|
|
||||||
unsubscribe = market.subscribeToMarketUpdates()
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
marketStore.setError(error instanceof Error ? error.message : 'Failed to load market')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const retryLoadMarket = () => {
|
|
||||||
marketStore.setError(null)
|
|
||||||
marketPreloader.resetPreload()
|
|
||||||
loadMarket()
|
|
||||||
}
|
|
||||||
|
|
||||||
const addToCart = (product: any) => {
|
|
||||||
marketStore.addToCart(product)
|
|
||||||
}
|
|
||||||
|
|
||||||
const viewProduct = (_product: any) => {
|
|
||||||
// TODO: Navigate to product detail page
|
|
||||||
}
|
|
||||||
|
|
||||||
const viewCart = () => {
|
|
||||||
router.push('/cart')
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
// Only load market if it hasn't been preloaded
|
|
||||||
if (needsToLoadMarket.value) {
|
|
||||||
loadMarket()
|
|
||||||
} else if (marketPreloader.isPreloaded.value) {
|
|
||||||
// Subscribe to real-time updates if market was preloaded
|
|
||||||
unsubscribe = market.subscribeToMarketUpdates()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (unsubscribe) {
|
|
||||||
unsubscribe()
|
|
||||||
}
|
|
||||||
market.disconnectFromMarket()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -1,125 +1,4 @@
|
||||||
<template>
|
<script lang="ts">
|
||||||
<div class="container mx-auto px-4 py-8">
|
import MarketDashboardComponent from '@/modules/market/views/MarketDashboard.vue'
|
||||||
<!-- Page Header -->
|
export default MarketDashboardComponent
|
||||||
<div class="mb-8">
|
|
||||||
<h1 class="text-3xl font-bold text-foreground">Market Dashboard</h1>
|
|
||||||
<p class="text-muted-foreground mt-2">
|
|
||||||
Manage your market activities as both a customer and merchant
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Dashboard Tabs -->
|
|
||||||
<div class="mb-6">
|
|
||||||
<nav class="flex space-x-8 border-b border-border">
|
|
||||||
<button
|
|
||||||
v-for="tab in tabs"
|
|
||||||
:key="tab.id"
|
|
||||||
@click="activeTab = tab.id"
|
|
||||||
:class="[
|
|
||||||
'py-2 px-1 border-b-2 font-medium text-sm transition-colors',
|
|
||||||
activeTab === tab.id
|
|
||||||
? 'border-primary text-primary'
|
|
||||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<component :is="tab.icon" class="w-4 h-4" />
|
|
||||||
<span>{{ tab.name }}</span>
|
|
||||||
<Badge v-if="tab.badge" variant="secondary" class="text-xs">
|
|
||||||
{{ tab.badge }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tab Content -->
|
|
||||||
<div class="min-h-[600px]">
|
|
||||||
<!-- Overview Tab -->
|
|
||||||
<div v-if="activeTab === 'overview'" class="space-y-6">
|
|
||||||
<DashboardOverview />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- My Orders Tab (Customer) -->
|
|
||||||
<div v-else-if="activeTab === 'orders'" class="space-y-6">
|
|
||||||
<OrderHistory />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- My Store Tab (Merchant) -->
|
|
||||||
<div v-else-if="activeTab === 'store'" class="space-y-6">
|
|
||||||
<MerchantStore />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Settings Tab -->
|
|
||||||
<div v-else-if="activeTab === 'settings'" class="space-y-6">
|
|
||||||
<MarketSettings />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
|
||||||
import { useMarketStore } from '@/stores/market'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import {
|
|
||||||
BarChart3,
|
|
||||||
Package,
|
|
||||||
Store,
|
|
||||||
Settings,
|
|
||||||
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
import DashboardOverview from '@/components/market/DashboardOverview.vue'
|
|
||||||
import OrderHistory from '@/components/market/OrderHistory.vue'
|
|
||||||
import MerchantStore from '@/components/market/MerchantStore.vue'
|
|
||||||
import MarketSettings from '@/components/market/MarketSettings.vue'
|
|
||||||
|
|
||||||
// const auth = useAuth()
|
|
||||||
const marketStore = useMarketStore()
|
|
||||||
|
|
||||||
// Local state
|
|
||||||
const activeTab = ref('overview')
|
|
||||||
|
|
||||||
// Computed properties for tab badges
|
|
||||||
const orderCount = computed(() => Object.keys(marketStore.orders).length)
|
|
||||||
const pendingOrders = computed(() =>
|
|
||||||
Object.values(marketStore.orders).filter(o => o.status === 'pending').length
|
|
||||||
)
|
|
||||||
// const pendingPayments = computed(() =>
|
|
||||||
// Object.values(marketStore.orders).filter(o => o.paymentStatus === 'pending').length
|
|
||||||
// )
|
|
||||||
|
|
||||||
// Dashboard tabs
|
|
||||||
const tabs = computed(() => [
|
|
||||||
{
|
|
||||||
id: 'overview',
|
|
||||||
name: 'Overview',
|
|
||||||
icon: BarChart3,
|
|
||||||
badge: null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'orders',
|
|
||||||
name: 'My Orders',
|
|
||||||
icon: Package,
|
|
||||||
badge: orderCount.value > 0 ? orderCount.value : null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'store',
|
|
||||||
name: 'My Store',
|
|
||||||
icon: Store,
|
|
||||||
badge: pendingOrders.value > 0 ? pendingOrders.value : null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'settings',
|
|
||||||
name: 'Settings',
|
|
||||||
icon: Settings,
|
|
||||||
badge: null
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
// Lifecycle
|
|
||||||
onMounted(() => {
|
|
||||||
console.log('Market Dashboard mounted')
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,33 +22,6 @@ const router = createRouter({
|
||||||
requiresAuth: false
|
requiresAuth: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/events',
|
|
||||||
name: 'events',
|
|
||||||
component: () => import('@/pages/events.vue'),
|
|
||||||
meta: {
|
|
||||||
title: 'Events',
|
|
||||||
requiresAuth: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/my-tickets',
|
|
||||||
name: 'my-tickets',
|
|
||||||
component: () => import('@/pages/MyTickets.vue'),
|
|
||||||
meta: {
|
|
||||||
title: 'My Tickets',
|
|
||||||
requiresAuth: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/market',
|
|
||||||
name: 'market',
|
|
||||||
component: () => import('@/pages/Market.vue'),
|
|
||||||
meta: {
|
|
||||||
title: 'Market',
|
|
||||||
requiresAuth: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/cart',
|
path: '/cart',
|
||||||
name: 'cart',
|
name: 'cart',
|
||||||
|
|
@ -73,21 +46,6 @@ const router = createRouter({
|
||||||
component: () => import('@/pages/OrderHistory.vue'),
|
component: () => import('@/pages/OrderHistory.vue'),
|
||||||
meta: { requiresAuth: true }
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/market-dashboard',
|
|
||||||
name: 'MarketDashboard',
|
|
||||||
component: () => import('@/pages/MarketDashboard.vue'),
|
|
||||||
meta: { requiresAuth: true }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/chat',
|
|
||||||
name: 'chat',
|
|
||||||
component: () => import('@/pages/ChatPage.vue'),
|
|
||||||
meta: {
|
|
||||||
title: 'Nostr Chat',
|
|
||||||
requiresAuth: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/relay-hub-status',
|
path: '/relay-hub-status',
|
||||||
name: 'relay-hub-status',
|
name: 'relay-hub-status',
|
||||||
|
|
|
||||||
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