990 lines
35 KiB
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>
|