webapp/src/modules/market/components/MerchantStore.vue

990 lines
35 KiB
Vue

<template>
<div class="space-y-6">
<!-- Loading State -->
<div v-if="isLoadingMerchant" class="flex justify-center items-center py-12">
<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">Loading your merchant profile...</p>
</div>
</div>
<!-- Error State -->
<div v-else-if="merchantCheckError" class="flex justify-center items-center py-12">
<div class="text-center">
<h2 class="text-2xl font-bold text-red-600 mb-4">Error Loading Merchant Status</h2>
<p class="text-gray-600 mb-4">{{ merchantCheckError }}</p>
<Button @click="checkMerchantProfile" variant="outline">
Try Again
</Button>
</div>
</div>
<!-- Content -->
<div v-else>
<!-- No Merchant Profile Empty State -->
<div v-if="!userHasMerchantProfile" class="flex flex-col items-center justify-center py-12">
<div class="w-24 h-24 bg-muted rounded-full flex items-center justify-center mb-6">
<User class="w-12 h-12 text-muted-foreground" />
</div>
<h2 class="text-2xl font-bold text-foreground mb-2">Create Your Merchant Profile</h2>
<p class="text-muted-foreground text-center mb-6 max-w-md">
Before you can create a store, you need to set up your merchant profile. This will create your merchant identity on the Nostr marketplace.
</p>
<Button
@click="createMerchantProfile"
variant="default"
size="lg"
:disabled="isCreatingMerchant"
>
<div v-if="isCreatingMerchant" class="flex items-center">
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
<span>Creating...</span>
</div>
<div v-else class="flex items-center">
<Plus class="w-5 h-5 mr-2" />
<span>Create Merchant Profile</span>
</div>
</Button>
</div>
<!-- Stores Grid (shown when merchant profile exists) -->
<div v-else>
<!-- Header Section -->
<div class="mb-8">
<div class="flex items-center justify-between mb-2">
<div>
<h2 class="text-2xl font-bold text-foreground">My Stores</h2>
<p class="text-muted-foreground mt-1">
Manage your stores and products
</p>
</div>
<Button @click="navigateToMarket" variant="outline">
<Store class="w-4 h-4 mr-2" />
Browse Market
</Button>
</div>
</div>
<!-- Loading State for Stalls -->
<div v-if="isLoadingStalls" class="flex justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<!-- Stores Cards Grid -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<!-- Existing Store Cards -->
<StoreCard
v-for="stall in userStalls"
:key="stall.id"
:stall="stall"
@manage="manageStall"
@view-products="viewStallProducts"
/>
<!-- Create New Store Card -->
<div class="bg-card rounded-lg border-2 border-dashed border-muted-foreground/25 hover:border-primary/50 transition-colors">
<button
@click="showCreateStoreDialog = true"
class="w-full h-full p-6 flex flex-col items-center justify-center min-h-[200px] hover:bg-muted/30 transition-colors"
>
<div class="w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mb-3">
<Plus class="w-6 h-6 text-primary" />
</div>
<h3 class="text-lg font-semibold text-foreground mb-1">Create New Store</h3>
<p class="text-sm text-muted-foreground text-center">
Add another store to expand your marketplace presence
</p>
</button>
</div>
</div>
<!-- Active Store Dashboard (shown when a store is selected) -->
<div v-if="activeStall">
<!-- Header -->
<div class="space-y-4 mb-6">
<!-- Top row with back button and currency -->
<div class="flex items-center gap-3">
<Button @click="activeStallId = null" variant="ghost" size="sm">
Back to Stores
</Button>
<div class="h-4 w-px bg-border"></div>
<Badge variant="secondary">{{ activeStall.currency }}</Badge>
</div>
<!-- Store info and actions -->
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div class="flex-1">
<h2 class="text-xl sm:text-2xl font-bold text-foreground">{{ activeStall.name }}</h2>
<p class="text-sm sm:text-base text-muted-foreground mt-1">{{ activeStall.config?.description || 'Manage incoming orders and your products' }}</p>
</div>
<div class="flex flex-col sm:flex-row gap-2 sm:gap-3">
<Button @click="navigateToMarket" variant="outline" class="w-full sm:w-auto">
<Store class="w-4 h-4 mr-2" />
<span class="sm:inline">Browse Market</span>
</Button>
<Button @click="showCreateProductDialog = true" variant="default" class="w-full sm:w-auto">
<Plus class="w-4 h-4 mr-2" />
<span>Add Product</span>
</Button>
</div>
</div>
</div>
<!-- Store Stats -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-6">
<!-- Incoming Orders -->
<div class="bg-card p-4 sm:p-6 rounded-lg border shadow-sm">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div class="flex-1">
<p class="text-xs sm:text-sm font-medium text-muted-foreground">Incoming Orders</p>
<p class="text-xl sm:text-2xl font-bold text-foreground">{{ storeStats.incomingOrders }}</p>
</div>
<div class="w-10 h-10 sm:w-12 sm:h-12 bg-primary/10 rounded-lg flex items-center justify-center flex-shrink-0">
<Package class="w-5 h-5 sm:w-6 sm:h-6 text-primary" />
</div>
</div>
<div class="mt-3 sm:mt-4">
<div class="flex flex-wrap items-center text-xs sm:text-sm text-muted-foreground gap-1">
<span>{{ storeStats.pendingOrders }} pending</span>
<span class="hidden sm:inline mx-1"></span>
<span>{{ storeStats.paidOrders }} paid</span>
</div>
</div>
</div>
<!-- Total Sales -->
<div class="bg-card p-4 sm:p-6 rounded-lg border shadow-sm">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div class="flex-1">
<p class="text-xs sm:text-sm font-medium text-muted-foreground">Total Sales</p>
<p class="text-xl sm:text-2xl font-bold text-foreground">{{ formatPrice(storeStats.totalSales, 'sat') }}</p>
</div>
<div class="w-10 h-10 sm:w-12 sm:h-12 bg-green-500/10 rounded-lg flex items-center justify-center flex-shrink-0">
<DollarSign class="w-5 h-5 sm:w-6 sm:h-6 text-green-500" />
</div>
</div>
<div class="mt-3 sm:mt-4">
<div class="flex items-center text-xs sm:text-sm text-muted-foreground">
<span>Last 30 days</span>
</div>
</div>
</div>
<!-- Products -->
<div class="bg-card p-4 sm:p-6 rounded-lg border shadow-sm">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div class="flex-1">
<p class="text-xs sm:text-sm font-medium text-muted-foreground">Products</p>
<p class="text-xl sm:text-2xl font-bold text-foreground">{{ stallProducts.length }}</p>
</div>
<div class="w-10 h-10 sm:w-12 sm:h-12 bg-purple-500/10 rounded-lg flex items-center justify-center flex-shrink-0">
<Store class="w-5 h-5 sm:w-6 sm:h-6 text-purple-500" />
</div>
</div>
<div class="mt-3 sm:mt-4">
<div class="flex items-center text-xs sm:text-sm text-muted-foreground">
<span>{{ stallProducts.filter(p => p.active).length }} active</span>
</div>
</div>
</div>
<!-- Customer Satisfaction -->
<div class="bg-card p-4 sm:p-6 rounded-lg border shadow-sm">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div class="flex-1">
<p class="text-xs sm:text-sm font-medium text-muted-foreground">Satisfaction</p>
<p class="text-xl sm:text-2xl font-bold text-foreground">{{ storeStats.satisfaction }}%</p>
</div>
<div class="w-10 h-10 sm:w-12 sm:h-12 bg-yellow-500/10 rounded-lg flex items-center justify-center flex-shrink-0">
<Star class="w-5 h-5 sm:w-6 sm:h-6 text-yellow-500" />
</div>
</div>
<div class="mt-3 sm:mt-4">
<div class="flex items-center text-xs sm:text-sm text-muted-foreground">
<span>{{ storeStats.totalReviews }} reviews</span>
</div>
</div>
</div>
</div>
<!-- Store Tabs -->
<div class="mt-8">
<Tabs v-model="activeTab" class="w-full">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="products">Products</TabsTrigger>
<TabsTrigger value="orders">Orders</TabsTrigger>
</TabsList>
<!-- Products Tab -->
<TabsContent value="products" class="mt-6">
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-semibold text-foreground">Products</h3>
<Button @click="showCreateProductDialog = true" variant="default" size="sm">
<Plus class="w-4 h-4 mr-2" />
Add Product
</Button>
</div>
<!-- Loading Products -->
<div v-if="isLoadingProducts" class="flex items-center justify-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<span class="ml-2 text-muted-foreground">Loading products...</span>
</div>
<!-- No Products -->
<div v-else-if="stallProducts.length === 0" class="text-center py-12">
<div class="w-16 h-16 mx-auto mb-4 bg-muted/50 rounded-full flex items-center justify-center">
<Package class="w-8 h-8 text-muted-foreground" />
</div>
<h4 class="text-lg font-medium text-foreground mb-2">No Products Yet</h4>
<p class="text-muted-foreground mb-6">Start selling by adding your first product</p>
<Button @click="showCreateProductDialog = true" variant="default">
<Plus class="w-4 h-4 mr-2" />
Add Your First Product
</Button>
</div>
<!-- Products Grid -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="product in stallProducts"
:key="product.id"
class="bg-card p-6 rounded-lg border shadow-sm hover:shadow-md transition-shadow"
>
<!-- Product Image -->
<div class="aspect-square mb-4 bg-muted rounded-lg flex items-center justify-center">
<img
v-if="product.images?.[0]"
:src="product.images[0]"
:alt="product.name"
class="w-full h-full object-cover rounded-lg"
/>
<Package v-else class="w-12 h-12 text-muted-foreground" />
</div>
<!-- Product Info -->
<div class="space-y-3">
<div>
<h4 class="font-semibold text-foreground">{{ product.name }}</h4>
<p v-if="product.config?.description" class="text-sm text-muted-foreground mt-1">
{{ product.config.description }}
</p>
</div>
<div class="flex items-center justify-between">
<div>
<span class="text-lg font-bold text-foreground">
{{ product.price }} {{ product.config?.currency || activeStall?.currency || 'sat' }}
</span>
<div class="text-sm text-muted-foreground">
Qty: {{ product.quantity }}
</div>
</div>
<div class="flex items-center gap-2">
<Badge :variant="product.active ? 'default' : 'secondary'">
{{ product.active ? 'Active' : 'Inactive' }}
</Badge>
<!-- Nostr Sync Status Indicator -->
<div class="flex items-center">
<template v-if="getProductSyncStatus(product.id) === 'confirmed'">
<CheckCircle class="w-4 h-4 text-green-600" title="Confirmed on Nostr" />
</template>
<template v-else-if="getProductSyncStatus(product.id) === 'pending'">
<Clock class="w-4 h-4 text-blue-600 animate-pulse" title="Awaiting Nostr confirmation" />
</template>
<template v-else>
<AlertCircle class="w-4 h-4 text-gray-400" title="Sync status unknown" />
</template>
</div>
</div>
</div>
<!-- Product Categories -->
<div v-if="product.categories?.length" class="flex flex-wrap gap-1">
<Badge
v-for="category in product.categories"
:key="category"
variant="outline"
class="text-xs"
>
{{ category }}
</Badge>
</div>
<!-- Product Actions -->
<div class="flex justify-between pt-2 border-t">
<div class="flex gap-2">
<Button
@click="deleteProduct(product)"
variant="ghost"
size="sm"
class="text-red-600 hover:text-red-700 hover:bg-red-50"
:disabled="isDeletingProduct"
>
<div class="flex items-center">
<Trash2 class="w-4 h-4 mr-1" />
{{ isDeletingProduct && deletingProductId === product.id ? 'Deleting...' : 'Delete' }}
</div>
</Button>
<Button
@click="resendProduct(product)"
variant="ghost"
size="sm"
class="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
:disabled="isResendingProduct"
>
<div class="flex items-center">
<Send class="w-4 h-4 mr-1" />
{{ isResendingProduct && resendingProductId === product.id ? 'Re-sending...' : 'Re-send' }}
</div>
</Button>
</div>
<Button
@click="editProduct(product)"
variant="ghost"
size="sm"
>
<div class="flex items-center">
<Edit class="w-4 h-4 mr-1" />
Edit
</div>
</Button>
</div>
</div>
</div>
</div>
</TabsContent>
<!-- Orders Tab -->
<TabsContent value="orders" class="mt-6">
<MerchantOrders :stall-id="activeStallId || undefined" />
</TabsContent>
</Tabs>
</div>
</div>
</div>
</div>
<!-- Create Store Dialog -->
<CreateStoreDialog
:is-open="showCreateStoreDialog"
@close="showCreateStoreDialog = false"
@created="onStoreCreated"
/>
<!-- Create Product Dialog -->
<CreateProductDialog
:is-open="showCreateProductDialog"
:stall="activeStall"
:product="editingProduct"
@close="closeProductDialog"
@created="onProductCreated"
@updated="onProductUpdated"
/>
<!-- Delete Confirm Dialog -->
<DeleteConfirmDialog
:is-open="showDeleteConfirmDialog"
:product-name="productToDelete?.name || ''"
:is-deleting="isDeletingProduct && deletingProductId === productToDelete?.id"
@confirm="confirmDeleteProduct"
@cancel="cancelDeleteProduct"
@update:is-open="showDeleteConfirmDialog = $event"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useMarketStore } from '@/modules/market/stores/market'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/ui/tabs'
import {
Package,
Store,
DollarSign,
Star,
Plus,
User,
Trash2,
Send,
Edit,
CheckCircle,
Clock,
AlertCircle
} from 'lucide-vue-next'
import type { NostrmarketAPI, Merchant, Stall, ProductApiResponse } from '../services/nostrmarketAPI'
import type { Product } from '../types/market'
import { mapApiResponseToProduct } from '../types/market'
import { auth } from '@/composables/useAuthService'
import { useToast } from '@/core/composables/useToast'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import CreateStoreDialog from './CreateStoreDialog.vue'
import CreateProductDialog from './CreateProductDialog.vue'
import DeleteConfirmDialog from './DeleteConfirmDialog.vue'
import StoreCard from './StoreCard.vue'
import MerchantOrders from './MerchantOrders.vue'
const router = useRouter()
const marketStore = useMarketStore()
const nostrmarketAPI = injectService(SERVICE_TOKENS.NOSTRMARKET_API) as NostrmarketAPI
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as any
const toast = useToast()
// Local state
const merchantProfile = ref<Merchant | null>(null)
const isLoadingMerchant = ref(false)
const merchantCheckError = ref<string | null>(null)
const isCreatingMerchant = ref(false)
// Multiple stalls state management
const userStalls = ref<Stall[]>([])
const activeStallId = ref<string | null>(null)
const isLoadingStalls = ref(false)
const activeStall = computed(() =>
userStalls.value.find(stall => stall.id === activeStallId.value)
)
// Product management state
const stallProducts = ref<Product[]>([])
const isLoadingProducts = ref(false)
// Product action state
const isDeletingProduct = ref(false)
const deletingProductId = ref<string | null>(null)
const isResendingProduct = ref(false)
const resendingProductId = ref<string | null>(null)
// Nostr sync tracking
const pendingNostrConfirmation = ref<Map<string, number>>(new Map()) // productId -> timestamp
const confirmedOnNostr = ref<Set<string>>(new Set())
// Tab management
const activeTab = ref<string>('products')
// Dialog state
const showCreateStoreDialog = ref(false)
const showCreateProductDialog = ref(false)
const showDeleteConfirmDialog = ref(false)
const editingProduct = ref<Product | null>(null)
const productToDelete = ref<Product | null>(null)
// Computed properties
const userHasMerchantProfile = computed(() => {
return merchantProfile.value !== null
})
const userHasStalls = computed(() => {
return userStalls.value.length > 0
})
// Helper to get sync status for a product
const getProductSyncStatus = (productId: string) => {
if (confirmedOnNostr.value.has(productId)) {
return 'confirmed'
}
if (pendingNostrConfirmation.value.has(productId)) {
return 'pending'
}
return 'unknown'
}
const storeStats = computed(() => {
const currentUserPubkey = auth.currentUser?.value?.pubkey
if (!currentUserPubkey) {
return {
incomingOrders: 0,
pendingOrders: 0,
paidOrders: 0,
totalSales: 0,
satisfaction: 0,
totalReviews: 0
}
}
// Filter orders to only count those where current user is the seller
const myOrders = Object.values(marketStore.orders).filter(o => o.sellerPubkey === currentUserPubkey)
const now = Date.now() / 1000
const thirtyDaysAgo = now - (30 * 24 * 60 * 60)
return {
incomingOrders: myOrders.filter(o => o.status === 'pending').length,
pendingOrders: myOrders.filter(o => o.status === 'pending').length,
paidOrders: myOrders.filter(o => o.status === 'paid').length,
totalSales: myOrders
.filter(o => o.status === 'paid' && o.createdAt > thirtyDaysAgo)
.reduce((sum, o) => sum + o.total, 0),
satisfaction: userHasStalls.value ? 95 : 0,
totalReviews: 0
}
})
// Methods
const formatPrice = (price: number, currency: string) => {
return marketStore.formatPrice(price, currency)
}
const createMerchantProfile = async () => {
const currentUser = auth.currentUser?.value
if (!currentUser) {
console.error('No authenticated user for merchant creation')
return
}
const userWallets = currentUser.wallets || []
if (userWallets.length === 0) {
console.error('No wallets available for merchant creation')
toast.error('No wallet available. Please ensure you have at least one wallet configured.')
return
}
const wallet = userWallets[0]
if (!wallet.adminkey) {
console.error('Wallet missing admin key for merchant creation')
toast.error('Wallet missing admin key. Admin key is required to create merchant profiles.')
return
}
isCreatingMerchant.value = true
try {
const merchantData = {
config: {}
}
const newMerchant = await nostrmarketAPI.createMerchant(wallet.adminkey, merchantData)
merchantProfile.value = newMerchant
toast.success('Merchant profile created successfully! You can now create your first store.')
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to create merchant profile'
console.error('Error creating merchant profile:', error)
toast.error(`Failed to create merchant profile: ${errorMessage}`)
} finally {
isCreatingMerchant.value = false
}
}
const loadStallsList = async () => {
const currentUser = auth.currentUser?.value
if (!currentUser?.wallets?.length) {
return
}
isLoadingStalls.value = true
const inkey = paymentService.getPreferredWalletInvoiceKey()
if (!inkey) {
console.error('No wallet invoice key available')
return
}
try {
const stalls = await nostrmarketAPI.getStalls(inkey)
userStalls.value = stalls || []
// If there are stalls but no active one selected, select the first
if (stalls?.length > 0 && !activeStallId.value) {
activeStallId.value = stalls[0].id!
}
} catch (error) {
console.error('Failed to load stalls:', error)
userStalls.value = []
} finally {
isLoadingStalls.value = false
}
}
const loadStallProducts = async () => {
if (!activeStall.value) return
const currentUser = auth.currentUser?.value
if (!currentUser?.wallets?.length) return
isLoadingProducts.value = true
const inkey = paymentService.getPreferredWalletInvoiceKey()
if (!inkey) {
console.error('No wallet invoice key available')
return
}
try {
const products = await nostrmarketAPI.getProducts(
inkey,
activeStall.value.id!
)
// Convert API responses to domain models using clean mapping function
const enrichedProducts = (products || []).map(product =>
mapApiResponseToProduct(
product,
activeStall.value?.name || 'Unknown Stall',
activeStall.value?.currency || 'sats'
)
)
stallProducts.value = enrichedProducts
// Only add active products to the market store so they appear in the main market
enrichedProducts
.filter(product => product.active)
.forEach(product => {
marketStore.addProduct(product)
})
// Initialize sync status for loaded products
initializeSyncStatus()
} catch (error) {
console.error('Failed to load products:', error)
stallProducts.value = []
} finally {
isLoadingProducts.value = false
}
}
const manageStall = (stallId: string) => {
activeStallId.value = stallId
}
const viewStallProducts = (stallId: string) => {
activeStallId.value = stallId
}
const navigateToMarket = () => router.push('/market')
const checkMerchantProfile = async () => {
const currentUser = auth.currentUser?.value
if (!currentUser) return
const userWallets = currentUser.wallets || []
if (userWallets.length === 0) {
console.warn('No wallets available for merchant check')
return
}
const wallet = userWallets[0]
if (!wallet.inkey) {
console.warn('Wallet missing invoice key for merchant check')
return
}
isLoadingMerchant.value = true
merchantCheckError.value = null
try {
const merchant = await nostrmarketAPI.getMerchant(wallet.inkey)
merchantProfile.value = merchant
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to check merchant profile'
console.error('Error checking merchant profile:', error)
merchantCheckError.value = errorMessage
merchantProfile.value = null
} finally {
isLoadingMerchant.value = false
}
}
// Event handlers
const onStoreCreated = async (_stall: Stall) => {
await loadStallsList()
toast.success('Store created successfully!')
}
const onProductCreated = async (_product: Product) => {
await loadStallProducts()
toast.success('Product created successfully!')
}
const onProductUpdated = async (_product: Product) => {
await loadStallProducts()
toast.success('Product updated successfully!')
}
const editProduct = (product: Product) => {
editingProduct.value = product
showCreateProductDialog.value = true
}
const deleteProduct = (product: Product) => {
productToDelete.value = product
showDeleteConfirmDialog.value = true
}
const confirmDeleteProduct = async () => {
if (!productToDelete.value) return
const product = productToDelete.value
try {
isDeletingProduct.value = true
deletingProductId.value = product.id
const adminKey = paymentService.getPreferredWalletAdminKey()
if (!adminKey) {
throw new Error('No wallet admin key available')
}
await nostrmarketAPI.deleteProduct(adminKey, product.id)
// Remove from local state
stallProducts.value = stallProducts.value.filter(p => p.id !== product.id)
showDeleteConfirmDialog.value = false
productToDelete.value = null
toast.success(`Product "${product.name}" deleted successfully!`)
} catch (error) {
console.error('Failed to delete product:', error)
toast.error('Failed to delete product. Please try again.')
} finally {
isDeletingProduct.value = false
deletingProductId.value = null
}
}
const cancelDeleteProduct = () => {
showDeleteConfirmDialog.value = false
productToDelete.value = null
}
const resendProduct = async (product: Product) => {
try {
isResendingProduct.value = true
resendingProductId.value = product.id
const adminKey = paymentService.getPreferredWalletAdminKey()
if (!adminKey) {
throw new Error('No wallet admin key available')
}
// Re-send by updating the product with its current data
// This will trigger LNbits to re-publish to Nostr
const productData: ProductApiResponse = {
id: product.id,
stall_id: product.stall_id,
name: product.name,
categories: product.categories || [],
images: product.images || [],
price: product.price,
quantity: product.quantity,
active: product.active ?? true,
pending: product.pending ?? false,
config: {
description: product.description || '',
currency: product.currency || 'sat',
use_autoreply: false,
autoreply_message: '',
shipping: []
},
event_id: product.nostrEventId,
event_created_at: product.createdAt
}
await nostrmarketAPI.updateProduct(adminKey, product.id, productData)
// Reset sync status - remove from confirmed and add to pending
confirmedOnNostr.value.delete(product.id)
pendingNostrConfirmation.value.set(product.id, Date.now())
console.log('🔄 Product re-sent - sync status reset to pending:', {
productId: product.id,
productName: product.name,
wasConfirmed: confirmedOnNostr.value.has(product.id),
nowPending: pendingNostrConfirmation.value.has(product.id)
})
toast.success(`Product "${product.name}" re-sent to LNbits for event publishing!`)
// TODO: Consider adding a timeout to remove from pending if not confirmed within reasonable time
// (e.g., 30 seconds) to avoid keeping products in pending state indefinitely
} catch (error) {
console.error('Failed to re-send product:', error)
toast.error('Failed to re-send product. Please try again.')
} finally {
isResendingProduct.value = false
resendingProductId.value = null
}
}
const closeProductDialog = () => {
showCreateProductDialog.value = false
editingProduct.value = null
}
// Watch for market store updates to detect confirmed products
watch(() => marketStore.products, (newProducts) => {
// Check if any pending products now appear in the market feed
for (const [productId, timestamp] of pendingNostrConfirmation.value.entries()) {
const foundProduct = newProducts.find(p => p.id === productId)
if (foundProduct) {
// Find the corresponding local product to compare content
const localProduct = stallProducts.value.find(p => p.id === productId)
if (localProduct) {
// Compare content to verify true sync
const localData = normalizeProductForComparison(localProduct)
const marketData = normalizeProductForComparison(foundProduct)
const localJson = JSON.stringify(localData)
const marketJson = JSON.stringify(marketData)
const isContentSynced = localJson === marketJson
if (isContentSynced) {
// Product content confirmed as synced on Nostr!
pendingNostrConfirmation.value.delete(productId)
confirmedOnNostr.value.add(productId)
// Show confirmation toast
toast.success(`Product "${foundProduct.name}" successfully published to Nostr!`)
console.log('🎉 Product confirmed on Nostr with matching content:', {
productId,
productName: foundProduct.name,
pendingTime: Date.now() - timestamp,
contentVerified: true
})
} else {
console.warn('⚠️ Product appeared in market but content differs:', {
productId,
productName: foundProduct.name,
localData,
marketData
})
// Remove from pending - content doesn't match, so it's not properly synced
pendingNostrConfirmation.value.delete(productId)
// Don't add to confirmedOnNostr - it should show as unsynced
}
} else {
// No local product found - just mark as confirmed
pendingNostrConfirmation.value.delete(productId)
confirmedOnNostr.value.add(productId)
toast.success(`Product "${foundProduct.name}" successfully published to Nostr!`)
}
}
}
// Update sync status for any new products that appear in market feed
initializeSyncStatus()
}, { deep: true })
// Cleanup pending confirmations after timeout (30 seconds)
const cleanupPendingConfirmations = () => {
const timeout = 30 * 1000 // 30 seconds
const now = Date.now()
for (const [productId, timestamp] of pendingNostrConfirmation.value.entries()) {
if (now - timestamp > timeout) {
pendingNostrConfirmation.value.delete(productId)
console.warn('⏰ Timeout: Product confirmation removed from pending after 30s:', productId)
}
}
}
// Run cleanup every 10 seconds
setInterval(cleanupPendingConfirmations, 10 * 1000)
// Helper function to normalize product data for comparison
const normalizeProductForComparison = (product: any) => {
return {
name: product.name,
description: product.description || '',
price: product.price,
quantity: product.quantity,
active: product.active ?? true,
categories: (product.categories ? [...product.categories] : []).sort(), // Sort for consistent comparison
images: (product.images ? [...product.images] : []).sort(), // Sort for consistent comparison
currency: product.currency || 'sat'
}
}
// Enhanced sync status detection with JSON content comparison
const initializeSyncStatus = () => {
// Cross-reference stallProducts with market feed to detect already-synced products
for (const product of stallProducts.value) {
if (product.id) {
const foundInMarket = marketStore.products.find(p => p.id === product.id)
if (foundInMarket) {
// Compare the actual product content, not just IDs
const localData = normalizeProductForComparison(product)
const marketData = normalizeProductForComparison(foundInMarket)
// Deep comparison of normalized data
const localJson = JSON.stringify(localData)
const marketJson = JSON.stringify(marketData)
const isContentSynced = localJson === marketJson
if (isContentSynced) {
// Product content is truly synced - mark as confirmed
confirmedOnNostr.value.add(product.id)
console.log('✅ Product content verified as synced to Nostr:', {
productId: product.id,
productName: product.name
})
} else {
// Product exists but content differs - needs re-sync
console.warn('⚠️ Product exists but content differs - needs re-sync:', {
productId: product.id,
productName: product.name,
localData,
marketData,
differences: {
local: localData,
market: marketData
}
})
// Remove from both confirmed and pending - it's out of sync
confirmedOnNostr.value.delete(product.id)
pendingNostrConfirmation.value.delete(product.id)
// User should see unsynced indicator (no badge)
}
} else {
console.log('📤 Product not found in market feed - not synced:', {
productId: product.id,
productName: product.name
})
}
}
}
}
// Lifecycle
onMounted(async () => {
console.log('Merchant Store component loaded')
await checkMerchantProfile()
// Load stalls if merchant profile exists
if (merchantProfile.value) {
console.log('Merchant profile exists, loading stalls...')
await loadStallsList()
} else {
console.log('No merchant profile found, skipping stalls loading')
}
})
// Watch for auth changes and re-check merchant profile
watch(() => auth.currentUser?.value?.pubkey, async (newPubkey, oldPubkey) => {
if (newPubkey !== oldPubkey) {
console.log('User changed, re-checking merchant profile')
await checkMerchantProfile()
}
})
// Watch for active stall changes and load products
watch(() => activeStallId.value, async (newStallId) => {
if (newStallId) {
await loadStallProducts()
} else {
stallProducts.value = []
}
})
</script>