Remove useNostrOrders composable and related Checkout page
- Delete the useNostrOrders composable as it is no longer needed. - Update MerchantStore.vue to utilize nostrmarketService for publishing orders instead of the removed composable. - Refactor market store to check the readiness of nostrmarketService instead of useNostrOrders. - Remove the Checkout.vue page, streamlining the checkout process and improving code maintainability.
This commit is contained in:
parent
e504b1f7e2
commit
36638d1080
6 changed files with 16 additions and 701 deletions
|
|
@ -1,248 +0,0 @@
|
||||||
import { ref, computed, readonly } from 'vue'
|
|
||||||
import { finalizeEvent, type EventTemplate, nip04 } from 'nostr-tools'
|
|
||||||
import { relayHub } from '@/lib/nostr/relayHub'
|
|
||||||
import { auth } from '@/composables/useAuth'
|
|
||||||
import { hexToBytes } from '@/lib/utils/crypto'
|
|
||||||
import type { Order } from '@/stores/market'
|
|
||||||
|
|
||||||
export function useNostrOrders() {
|
|
||||||
// State
|
|
||||||
const isPublishing = ref(false)
|
|
||||||
const lastError = ref<string | null>(null)
|
|
||||||
const publishedEvents = ref<Record<string, string>>({}) // orderId -> eventId
|
|
||||||
|
|
||||||
// Computed
|
|
||||||
const isReady = computed(() => {
|
|
||||||
return auth.isAuthenticated.value &&
|
|
||||||
!!auth.currentUser.value?.pubkey &&
|
|
||||||
!!auth.currentUser.value?.prvkey
|
|
||||||
})
|
|
||||||
|
|
||||||
const currentUserPubkey = computed(() => auth.currentUser.value?.pubkey || '')
|
|
||||||
const currentUserPrvkey = computed(() => auth.currentUser.value?.prvkey || '')
|
|
||||||
|
|
||||||
// Methods
|
|
||||||
const validateAuth = (): { valid: boolean; error?: string } => {
|
|
||||||
if (!auth.isAuthenticated.value) {
|
|
||||||
return { valid: false, error: 'User not authenticated' }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentUserPubkey.value) {
|
|
||||||
return { valid: false, error: 'User public key not available' }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentUserPrvkey.value) {
|
|
||||||
return { valid: false, error: 'User private key not available' }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate key formats
|
|
||||||
if (currentUserPubkey.value.length !== 64) {
|
|
||||||
return { valid: false, error: 'Invalid public key format' }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentUserPrvkey.value.length !== 64) {
|
|
||||||
return { valid: false, error: 'Invalid private key format' }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
const createEventTemplate = (recipientPubkey: string, content: string): EventTemplate => {
|
|
||||||
return {
|
|
||||||
kind: 4, // Encrypted Direct Message
|
|
||||||
tags: [['p', recipientPubkey]], // Recipient tag
|
|
||||||
content: content,
|
|
||||||
created_at: Math.floor(Date.now() / 1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const encryptOrderContent = async (order: Order, recipientPubkey: string): Promise<string> => {
|
|
||||||
try {
|
|
||||||
console.log('Encrypting order content:', {
|
|
||||||
orderId: order.id,
|
|
||||||
recipientPubkey,
|
|
||||||
hasPrivateKey: !!currentUserPrvkey.value,
|
|
||||||
privateKeyLength: currentUserPrvkey.value?.length
|
|
||||||
})
|
|
||||||
|
|
||||||
// Validate keys
|
|
||||||
if (!currentUserPrvkey.value || !recipientPubkey) {
|
|
||||||
throw new Error('Missing private key or recipient public key')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentUserPrvkey.value.length !== 64) {
|
|
||||||
throw new Error(`Invalid private key length: ${currentUserPrvkey.value.length} (expected 64)`)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recipientPubkey.length !== 64) {
|
|
||||||
throw new Error(`Invalid recipient public key length: ${recipientPubkey.length} (expected 64)`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the order payload
|
|
||||||
const orderPayload = {
|
|
||||||
type: 'market_order',
|
|
||||||
orderId: order.id,
|
|
||||||
items: order.items,
|
|
||||||
contactInfo: order.contactInfo,
|
|
||||||
shippingZone: order.shippingZone,
|
|
||||||
paymentMethod: order.paymentMethod,
|
|
||||||
subtotal: order.subtotal,
|
|
||||||
shippingCost: order.shippingCost,
|
|
||||||
total: order.total,
|
|
||||||
currency: order.currency,
|
|
||||||
createdAt: order.createdAt,
|
|
||||||
buyerPubkey: order.buyerPubkey
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to JSON string
|
|
||||||
const orderJson = JSON.stringify(orderPayload)
|
|
||||||
console.log('Order payload created:', orderPayload)
|
|
||||||
|
|
||||||
// Encrypt the order content using NIP-04
|
|
||||||
const encryptedContent = await nip04.encrypt(
|
|
||||||
hexToBytes(currentUserPrvkey.value),
|
|
||||||
recipientPubkey,
|
|
||||||
orderJson
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log('Order content encrypted successfully:', {
|
|
||||||
originalLength: orderJson.length,
|
|
||||||
encryptedLength: encryptedContent.length,
|
|
||||||
encryptedPreview: encryptedContent.substring(0, 50) + '...'
|
|
||||||
})
|
|
||||||
|
|
||||||
return encryptedContent
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to encrypt order content:', error)
|
|
||||||
throw new Error('Failed to encrypt order content')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const publishOrderEvent = async (order: Order, recipientPubkey: string): Promise<{ id: string; sig: string }> => {
|
|
||||||
try {
|
|
||||||
// Validate authentication
|
|
||||||
const authValidation = validateAuth()
|
|
||||||
if (!authValidation.valid) {
|
|
||||||
throw new Error(authValidation.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set publishing state
|
|
||||||
isPublishing.value = true
|
|
||||||
lastError.value = null
|
|
||||||
|
|
||||||
// Encrypt the order content
|
|
||||||
const encryptedContent = await encryptOrderContent(order, recipientPubkey)
|
|
||||||
|
|
||||||
// Create event template
|
|
||||||
const eventTemplate = createEventTemplate(recipientPubkey, encryptedContent)
|
|
||||||
|
|
||||||
// Finalize the event (sign and generate ID)
|
|
||||||
const event = finalizeEvent(eventTemplate, hexToBytes(currentUserPrvkey.value))
|
|
||||||
|
|
||||||
// Publish via relay hub
|
|
||||||
await relayHub.publishEvent(event)
|
|
||||||
|
|
||||||
// Store the published event
|
|
||||||
publishedEvents.value[order.id] = event.id
|
|
||||||
|
|
||||||
console.log('Order event published successfully:', {
|
|
||||||
orderId: order.id,
|
|
||||||
eventId: event.id,
|
|
||||||
recipient: recipientPubkey,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
})
|
|
||||||
|
|
||||||
return { id: event.id, sig: event.sig }
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
|
|
||||||
lastError.value = errorMessage
|
|
||||||
console.error('Failed to publish order event:', error)
|
|
||||||
throw new Error(`Failed to publish order: ${errorMessage}`)
|
|
||||||
} finally {
|
|
||||||
isPublishing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getPublishedEventId = (orderId: string): string | undefined => {
|
|
||||||
return publishedEvents.value[orderId]
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearError = () => {
|
|
||||||
lastError.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
isPublishing.value = false
|
|
||||||
lastError.value = null
|
|
||||||
publishedEvents.value = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const testEncryption = async (): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
if (!isReady.value) {
|
|
||||||
console.log('Nostr not ready for testing')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const testMessage = 'Hello, this is a test message for NIP-04 encryption!'
|
|
||||||
const testRecipient = currentUserPubkey.value // Encrypt to ourselves for testing
|
|
||||||
|
|
||||||
console.log('Testing NIP-04 encryption with:', {
|
|
||||||
message: testMessage,
|
|
||||||
recipient: testRecipient,
|
|
||||||
sender: currentUserPubkey.value
|
|
||||||
})
|
|
||||||
|
|
||||||
// Encrypt
|
|
||||||
const encrypted = await nip04.encrypt(
|
|
||||||
hexToBytes(currentUserPrvkey.value),
|
|
||||||
testRecipient,
|
|
||||||
testMessage
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log('Test message encrypted:', encrypted)
|
|
||||||
|
|
||||||
// Decrypt
|
|
||||||
const decrypted = await nip04.decrypt(
|
|
||||||
currentUserPrvkey.value,
|
|
||||||
currentUserPubkey.value,
|
|
||||||
encrypted
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log('Test message decrypted:', decrypted)
|
|
||||||
|
|
||||||
const success = decrypted === testMessage
|
|
||||||
console.log('NIP-04 test result:', success ? 'PASSED' : 'FAILED')
|
|
||||||
|
|
||||||
return success
|
|
||||||
} catch (error) {
|
|
||||||
console.error('NIP-04 test failed:', error)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
// State
|
|
||||||
isPublishing: readonly(isPublishing),
|
|
||||||
lastError: readonly(lastError),
|
|
||||||
publishedEvents: readonly(publishedEvents),
|
|
||||||
|
|
||||||
// Computed
|
|
||||||
isReady,
|
|
||||||
currentUserPubkey,
|
|
||||||
currentUserPrvkey,
|
|
||||||
|
|
||||||
// Methods
|
|
||||||
validateAuth,
|
|
||||||
createEventTemplate,
|
|
||||||
encryptOrderContent,
|
|
||||||
publishOrderEvent,
|
|
||||||
getPublishedEventId,
|
|
||||||
clearError,
|
|
||||||
reset,
|
|
||||||
testEncryption
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export singleton instance
|
|
||||||
export const nostrOrders = useNostrOrders()
|
|
||||||
|
|
@ -320,12 +320,11 @@ import {
|
||||||
BarChart3
|
BarChart3
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import type { OrderStatus } from '@/stores/market'
|
import type { OrderStatus } from '@/stores/market'
|
||||||
import { nostrOrders } from '@/composables/useNostrOrders'
|
import { nostrmarketService } from '../services/nostrmarketService'
|
||||||
import { auth } from '@/composables/useAuth'
|
import { auth } from '@/composables/useAuth'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const marketStore = useMarketStore()
|
const marketStore = useMarketStore()
|
||||||
const nostrOrdersComposable = nostrOrders
|
|
||||||
|
|
||||||
// Local state
|
// Local state
|
||||||
const isGeneratingInvoice = ref<string | null>(null)
|
const isGeneratingInvoice = ref<string | null>(null)
|
||||||
|
|
@ -502,7 +501,7 @@ const sendInvoiceToCustomer = async (order: any, invoice: any) => {
|
||||||
|
|
||||||
// Send the updated order to the customer via Nostr
|
// Send the updated order to the customer via Nostr
|
||||||
// This will include the invoice information
|
// This will include the invoice information
|
||||||
await nostrOrdersComposable.publishOrderEvent(updatedOrder, order.buyerPubkey)
|
await nostrmarketService.publishOrder(updatedOrder, order.buyerPubkey)
|
||||||
|
|
||||||
console.log('Updated order with invoice sent via Nostr to customer:', order.buyerPubkey)
|
console.log('Updated order with invoice sent via Nostr to customer:', order.buyerPubkey)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,18 @@ export class NostrmarketService {
|
||||||
return hub
|
return hub
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the service is ready for Nostr operations
|
||||||
|
*/
|
||||||
|
get isReady(): boolean {
|
||||||
|
try {
|
||||||
|
this.getAuth()
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert hex string to Uint8Array (browser-compatible)
|
* Convert hex string to Uint8Array (browser-compatible)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed, readonly, watch } from 'vue'
|
import { ref, computed, readonly, watch } from 'vue'
|
||||||
import { nostrOrders } from '@/composables/useNostrOrders'
|
|
||||||
import { invoiceService } from '@/lib/services/invoiceService'
|
import { invoiceService } from '@/lib/services/invoiceService'
|
||||||
import { paymentMonitor } from '@/lib/services/paymentMonitor'
|
import { paymentMonitor } from '@/lib/services/paymentMonitor'
|
||||||
import { nostrmarketService } from '../services/nostrmarketService'
|
import { nostrmarketService } from '../services/nostrmarketService'
|
||||||
|
|
@ -595,7 +594,7 @@ export const useMarketStore = defineStore('market', () => {
|
||||||
|
|
||||||
const sendPaymentConfirmation = async (order: Order) => {
|
const sendPaymentConfirmation = async (order: Order) => {
|
||||||
try {
|
try {
|
||||||
if (!nostrOrders.isReady.value) {
|
if (!nostrmarketService.isReady) {
|
||||||
console.warn('Nostr not ready for payment confirmation')
|
console.warn('Nostr not ready for payment confirmation')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -612,7 +611,7 @@ export const useMarketStore = defineStore('market', () => {
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// Send confirmation to customer
|
// Send confirmation to customer
|
||||||
await nostrOrders.publishOrderEvent(order, order.buyerPubkey)
|
await nostrmarketService.publishOrder(order, order.buyerPubkey)
|
||||||
|
|
||||||
console.log('Payment confirmation sent via Nostr')
|
console.log('Payment confirmation sent via Nostr')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -1,438 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="container mx-auto px-4 py-8">
|
|
||||||
<!-- Loading State -->
|
|
||||||
<div v-if="!isReady" 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-primary"></div>
|
|
||||||
<p class="text-muted-foreground">Loading checkout...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error State -->
|
|
||||||
<div v-else-if="error" class="text-center py-12">
|
|
||||||
<h2 class="text-2xl font-bold text-red-600 mb-4">Checkout Error</h2>
|
|
||||||
<p class="text-muted-foreground mb-4">{{ error }}</p>
|
|
||||||
<Button @click="$router.push('/cart')" variant="outline">
|
|
||||||
Back to Cart
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Checkout Content -->
|
|
||||||
<div v-else-if="checkoutCart && checkoutStall" class="max-w-4xl mx-auto">
|
|
||||||
<!-- Page Header -->
|
|
||||||
<div class="mb-8">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-bold text-foreground">Checkout</h1>
|
|
||||||
<p class="text-muted-foreground mt-2">
|
|
||||||
Complete your purchase from {{ checkoutStall.name }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button @click="$router.push('/cart')" variant="outline">
|
|
||||||
Back to Cart
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Checkout Grid -->
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
||||||
<!-- Main Checkout Form -->
|
|
||||||
<div class="lg:col-span-2 space-y-6">
|
|
||||||
<!-- Stall Information -->
|
|
||||||
<div class="bg-card border rounded-lg p-6">
|
|
||||||
<div class="flex items-center space-x-4 mb-4">
|
|
||||||
<div class="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center">
|
|
||||||
<Store class="w-6 h-6 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-semibold text-foreground">{{ checkoutStall.name }}</h3>
|
|
||||||
<p v-if="checkoutStall.description" class="text-muted-foreground">
|
|
||||||
{{ checkoutStall.description }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Contact Information -->
|
|
||||||
<div class="bg-card border rounded-lg p-6">
|
|
||||||
<h3 class="text-lg font-semibold text-foreground mb-4">Contact Information</h3>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-foreground mb-2">
|
|
||||||
Email (optional)
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
v-model="contactInfo.email"
|
|
||||||
type="email"
|
|
||||||
placeholder="your@email.com"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-foreground mb-2">
|
|
||||||
Message to Merchant (optional)
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
v-model="contactInfo.message"
|
|
||||||
rows="3"
|
|
||||||
placeholder="Any special requests or notes for the merchant..."
|
|
||||||
class="w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Shipping Information -->
|
|
||||||
<div class="bg-card border rounded-lg p-6">
|
|
||||||
<h3 class="text-lg font-semibold text-foreground mb-4">Shipping Information</h3>
|
|
||||||
|
|
||||||
<!-- Shipping Zone Selection -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<label class="block text-sm font-medium text-foreground mb-2">
|
|
||||||
Shipping Zone
|
|
||||||
</label>
|
|
||||||
<div v-if="availableShippingZones.length > 0" 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>
|
|
||||||
<p v-else class="text-sm text-muted-foreground">
|
|
||||||
No shipping zones available for this stall.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Shipping Address (only show for physical shipping) -->
|
|
||||||
<div v-if="selectedShippingZone && requiresPhysicalShipping" class="mt-4">
|
|
||||||
<label class="block text-sm font-medium text-foreground mb-2">
|
|
||||||
Shipping Address <span class="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
v-model="contactInfo.address"
|
|
||||||
rows="3"
|
|
||||||
placeholder="Enter your shipping address..."
|
|
||||||
class="w-full px-3 py-2 border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring bg-background text-foreground"
|
|
||||||
required
|
|
||||||
></textarea>
|
|
||||||
<p class="text-sm text-muted-foreground mt-1">
|
|
||||||
Required for physical product delivery
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Digital Delivery Note -->
|
|
||||||
<div v-if="selectedShippingZone && !requiresPhysicalShipping" class="mt-4 p-3 bg-muted/50 border border-border rounded-md">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<div class="w-5 h-5 text-muted-foreground">📧</div>
|
|
||||||
<div class="text-sm text-muted-foreground">
|
|
||||||
<p class="font-medium text-foreground">Digital Delivery</p>
|
|
||||||
<p>This product will be delivered digitally. No shipping address required.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Payment Method -->
|
|
||||||
<div class="bg-card border rounded-lg p-6">
|
|
||||||
<h3 class="text-lg font-semibold text-foreground mb-4">Payment Method</h3>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
id="lightning"
|
|
||||||
v-model="paymentMethod"
|
|
||||||
value="lightning"
|
|
||||||
class="text-primary focus:ring-primary"
|
|
||||||
/>
|
|
||||||
<label for="lightning" class="text-sm font-medium text-foreground">
|
|
||||||
⚡ Lightning Network (Recommended)
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
id="btc_onchain"
|
|
||||||
v-model="paymentMethod"
|
|
||||||
value="btc_onchain"
|
|
||||||
class="text-primary focus:ring-primary"
|
|
||||||
/>
|
|
||||||
<label for="btc_onchain" class="text-sm font-medium text-foreground">
|
|
||||||
₿ Bitcoin Onchain
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Place Order Button -->
|
|
||||||
<div class="bg-card border rounded-lg p-6">
|
|
||||||
<!-- Nostr Status Indicator -->
|
|
||||||
<div class="mb-4 p-3 rounded-lg border" :class="{
|
|
||||||
'bg-green-500/10 border-green-200': nostrOrders.isReady.value,
|
|
||||||
'bg-yellow-500/10 border-yellow-200': !nostrOrders.isReady.value
|
|
||||||
}">
|
|
||||||
<div class="flex items-center gap-2 text-sm">
|
|
||||||
<div v-if="nostrOrders.isReady.value" class="flex items-center gap-2 text-green-700">
|
|
||||||
<Wifi class="w-4 h-4" />
|
|
||||||
<span>Connected to Nostr network</span>
|
|
||||||
</div>
|
|
||||||
<div v-else class="flex items-center gap-2 text-yellow-700">
|
|
||||||
<WifiOff class="w-4 h-4" />
|
|
||||||
<span>Nostr network unavailable</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p v-if="!nostrOrders.isReady.value" class="text-xs text-yellow-600 mt-1">
|
|
||||||
Orders will be stored locally only. Please log in to send orders to merchants.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Test Encryption Button -->
|
|
||||||
<div v-if="nostrOrders.isReady.value" class="mt-3">
|
|
||||||
<Button
|
|
||||||
@click="testEncryption"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
:disabled="isTestingEncryption"
|
|
||||||
class="text-xs"
|
|
||||||
>
|
|
||||||
<div v-if="isTestingEncryption" class="flex items-center gap-2">
|
|
||||||
<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-primary"></div>
|
|
||||||
<span>Testing...</span>
|
|
||||||
</div>
|
|
||||||
<div v-else class="flex items-center gap-2">
|
|
||||||
<span>Test NIP-04 Encryption</span>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
<p v-if="encryptionTestResult" class="text-xs mt-1" :class="{
|
|
||||||
'text-green-600': encryptionTestResult === 'success',
|
|
||||||
'text-red-600': encryptionTestResult === 'error'
|
|
||||||
}">
|
|
||||||
{{ encryptionTestResult === 'success' ? '✓ Encryption test passed' : '✗ Encryption test failed' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
@click="handleCheckout"
|
|
||||||
:disabled="!canProceedToCheckout || isPlacingOrder"
|
|
||||||
variant="default"
|
|
||||||
class="w-full"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
<div v-if="isPlacingOrder" class="flex items-center space-x-2">
|
|
||||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
|
||||||
<span>Placing Order...</span>
|
|
||||||
</div>
|
|
||||||
<div v-else class="flex items-center space-x-2">
|
|
||||||
<Lock class="w-4 h-4" />
|
|
||||||
<span>Place Order</span>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<p v-if="error" class="text-sm text-red-600 mt-2 text-center">
|
|
||||||
{{ error }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Order Summary Sidebar -->
|
|
||||||
<div class="lg:col-span-1">
|
|
||||||
<div class="sticky top-8">
|
|
||||||
<CartSummary
|
|
||||||
:stall-id="checkoutCart.id"
|
|
||||||
:cart-items="checkoutCart.products"
|
|
||||||
:subtotal="checkoutCart.subtotal"
|
|
||||||
:currency="checkoutCart.currency"
|
|
||||||
:available-shipping-zones="availableShippingZones"
|
|
||||||
:selected-shipping-zone="selectedShippingZone || undefined"
|
|
||||||
@shipping-zone-selected="selectShippingZone"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted } from 'vue'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import { useMarketStore } from '@/stores/market'
|
|
||||||
import { nostrOrders } from '@/composables/useNostrOrders'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Store, Lock, Wifi, WifiOff } from 'lucide-vue-next'
|
|
||||||
import CartSummary from '@/components/market/CartSummary.vue'
|
|
||||||
import type { ShippingZone, ContactInfo } from '@/stores/market'
|
|
||||||
import { useAuth } from '@/composables/useAuth'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const marketStore = useMarketStore()
|
|
||||||
const auth = useAuth()
|
|
||||||
|
|
||||||
// Route parameters
|
|
||||||
const stallId = route.params.stallId as string
|
|
||||||
|
|
||||||
// Local state
|
|
||||||
const contactInfo = ref<ContactInfo>({
|
|
||||||
email: '',
|
|
||||||
message: '',
|
|
||||||
address: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const paymentMethod = ref<'lightning' | 'btc_onchain'>('lightning')
|
|
||||||
const selectedShippingZone = ref<ShippingZone | null>(null)
|
|
||||||
const error = ref<string | null>(null)
|
|
||||||
const isPlacingOrder = ref(false)
|
|
||||||
const isTestingEncryption = ref(false)
|
|
||||||
const encryptionTestResult = ref<'success' | 'error' | null>(null)
|
|
||||||
|
|
||||||
// Computed properties
|
|
||||||
const checkoutCart = computed(() => marketStore.checkoutCart)
|
|
||||||
const checkoutStall = computed(() => marketStore.checkoutStall)
|
|
||||||
|
|
||||||
const availableShippingZones = computed(() => {
|
|
||||||
if (!checkoutStall.value) return []
|
|
||||||
return checkoutStall.value.shipping || []
|
|
||||||
})
|
|
||||||
|
|
||||||
const isReady = computed(() => {
|
|
||||||
return checkoutCart.value && checkoutStall.value
|
|
||||||
})
|
|
||||||
|
|
||||||
const requiresPhysicalShipping = computed(() => {
|
|
||||||
return selectedShippingZone.value?.requiresPhysicalShipping || false
|
|
||||||
})
|
|
||||||
|
|
||||||
const canProceedToCheckout = computed(() => {
|
|
||||||
return selectedShippingZone.value && (requiresPhysicalShipping.value ? contactInfo.value.address : true)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Methods
|
|
||||||
const selectShippingZone = (shippingZone: ShippingZone) => {
|
|
||||||
selectedShippingZone.value = shippingZone
|
|
||||||
if (checkoutCart.value) {
|
|
||||||
marketStore.setShippingZone(checkoutCart.value.id, shippingZone)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCheckout = async () => {
|
|
||||||
// Validate required fields
|
|
||||||
if (!selectedShippingZone.value) {
|
|
||||||
error.value = 'Please select a shipping zone'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requiresPhysicalShipping.value && !contactInfo.value.address) {
|
|
||||||
error.value = 'Please provide a shipping address'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Clear any previous errors
|
|
||||||
error.value = null
|
|
||||||
isPlacingOrder.value = true
|
|
||||||
|
|
||||||
// Create the order
|
|
||||||
const order = await marketStore.createAndPlaceOrder({
|
|
||||||
cartId: checkoutCart.value!.id,
|
|
||||||
stallId: checkoutCart.value!.id,
|
|
||||||
buyerPubkey: auth.currentUser?.value?.pubkey || '', // Get from authenticated user
|
|
||||||
sellerPubkey: checkoutStall.value!.pubkey,
|
|
||||||
status: 'pending',
|
|
||||||
items: checkoutCart.value!.products.map(item => ({
|
|
||||||
productId: item.product.id,
|
|
||||||
productName: item.product.name,
|
|
||||||
quantity: item.quantity,
|
|
||||||
price: item.product.price,
|
|
||||||
currency: item.product.currency
|
|
||||||
})),
|
|
||||||
contactInfo: contactInfo.value,
|
|
||||||
shippingZone: selectedShippingZone.value,
|
|
||||||
paymentMethod: paymentMethod.value,
|
|
||||||
subtotal: checkoutCart.value!.subtotal,
|
|
||||||
shippingCost: selectedShippingZone.value.cost,
|
|
||||||
total: checkoutCart.value!.subtotal + selectedShippingZone.value.cost,
|
|
||||||
currency: checkoutCart.value!.currency
|
|
||||||
})
|
|
||||||
|
|
||||||
// Show success message
|
|
||||||
console.log('Order placed successfully:', order)
|
|
||||||
|
|
||||||
// TODO: Navigate to payment page or show payment modal
|
|
||||||
// For now, redirect to cart with success message
|
|
||||||
router.push({
|
|
||||||
path: '/cart',
|
|
||||||
query: { orderSuccess: 'true', orderId: order.id }
|
|
||||||
})
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to place order'
|
|
||||||
console.error('Order placement failed:', err)
|
|
||||||
} finally {
|
|
||||||
isPlacingOrder.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const testEncryption = async () => {
|
|
||||||
try {
|
|
||||||
isTestingEncryption.value = true
|
|
||||||
encryptionTestResult.value = null
|
|
||||||
|
|
||||||
const success = await nostrOrders.testEncryption()
|
|
||||||
encryptionTestResult.value = success ? 'success' : 'error'
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
console.log('NIP-04 encryption test passed!')
|
|
||||||
} else {
|
|
||||||
console.error('NIP-04 encryption test failed!')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Encryption test error:', error)
|
|
||||||
encryptionTestResult.value = 'error'
|
|
||||||
} finally {
|
|
||||||
isTestingEncryption.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize checkout
|
|
||||||
onMounted(() => {
|
|
||||||
if (!stallId) {
|
|
||||||
error.value = 'No stall ID provided'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the checkout cart for this stall
|
|
||||||
marketStore.setCheckoutCart(stallId)
|
|
||||||
|
|
||||||
// Auto-select shipping zone if only one available
|
|
||||||
if (availableShippingZones.value.length === 1) {
|
|
||||||
selectShippingZone(availableShippingZones.value[0])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
@ -31,15 +31,6 @@ const router = createRouter({
|
||||||
requiresAuth: true
|
requiresAuth: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/checkout/:stallId',
|
|
||||||
name: 'checkout',
|
|
||||||
component: () => import('@/pages/Checkout.vue'),
|
|
||||||
meta: {
|
|
||||||
title: 'Checkout',
|
|
||||||
requiresAuth: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/order-history',
|
path: '/order-history',
|
||||||
name: 'OrderHistory',
|
name: 'OrderHistory',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue