web-app/src/modules/market/components/MerchantStore.vue
padreug e68a7a9ed5 refactor: simplify LoadingErrorState and enhance MarketSearchBar functionality
- Removed unnecessary imports and streamlined the LoadingErrorState component by eliminating redundant props.
- Improved keyboard handling in MarketSearchBar to support basic Escape key functionality and enhanced keyboard shortcuts.
- Updated MerchantStore and MarketPage components to utilize the revised LoadingErrorState and MarketSearchBar, ensuring consistent loading/error handling and search capabilities.
- Enhanced StallView to provide better category filtering and product search experience.

These changes improve code clarity and maintainability while enhancing user interaction across the market module.
2025-09-27 09:51:00 +02:00

643 lines
23 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>
<!-- Products Section -->
<div class="mt-8">
<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>
</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-end pt-2 border-t">
<Button
@click="editProduct(product)"
variant="ghost"
size="sm"
>
Edit
</Button>
</div>
</div>
</div>
</div>
</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"
/>
</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 {
Package,
Store,
DollarSign,
Star,
Plus,
User
} from 'lucide-vue-next'
import type { NostrmarketAPI, Merchant, Stall } 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 StoreCard from './StoreCard.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)
// Dialog state
const showCreateStoreDialog = ref(false)
const showCreateProductDialog = ref(false)
const editingProduct = ref<Product | null>(null)
// Computed properties
const userHasMerchantProfile = computed(() => {
return merchantProfile.value !== null
})
const userHasStalls = computed(() => {
return userStalls.value.length > 0
})
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)
})
} 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 closeProductDialog = () => {
showCreateProductDialog.value = false
editingProduct.value = null
}
// 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>