web-app/src/modules/market/components/MerchantStore.vue
padreug 6f68c2320e Enhance MerchantStore component with improved stall management and loading states
- Refactor the MerchantStore component to display a grid of user stalls, including loading indicators and action buttons for managing stalls and viewing products.
- Introduce a new card for creating additional stores, enhancing the user experience for merchants.
- Update the NostrmarketAPI to correct the endpoint for fetching stalls, ensuring accurate data retrieval.
- Implement state management for user stalls and active stall selection, improving the overall functionality and responsiveness of the component.

These changes streamline the stall management process for merchants, providing a more intuitive interface and better feedback during interactions.
2025-09-08 17:46:54 +02:00

1358 lines
47 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="space-y-6">
<!-- Loading State -->
<div v-if="isLoadingMerchant" class="flex flex-col items-center justify-center py-12">
<div class="w-16 h-16 mx-auto mb-4 bg-muted/50 rounded-full flex items-center justify-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<h3 class="text-lg font-medium text-foreground mb-2">Checking Merchant Status</h3>
<p class="text-muted-foreground">Loading your merchant profile...</p>
</div>
<!-- Error State -->
<div v-else-if="merchantCheckError" class="flex flex-col items-center justify-center py-12">
<div class="w-16 h-16 mx-auto mb-4 bg-red-500/10 rounded-full flex items-center justify-center">
<AlertCircle class="w-8 h-8 text-red-500" />
</div>
<h3 class="text-lg font-medium text-foreground mb-2">Error Loading Merchant Status</h3>
<p class="text-muted-foreground mb-4">{{ merchantCheckError }}</p>
<Button @click="checkMerchantProfile" variant="outline">
Try Again
</Button>
</div>
<!-- No Merchant Profile Empty State -->
<div v-else-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 -->
<div v-for="stall in userStalls" :key="stall.id"
class="bg-card rounded-lg border shadow-sm hover:shadow-md transition-shadow">
<div class="p-6">
<div class="flex items-start justify-between mb-4">
<div class="flex-1">
<h3 class="text-lg font-semibold text-foreground">{{ stall.name }}</h3>
<p class="text-sm text-muted-foreground mt-1">
{{ stall.config?.description || 'No description' }}
</p>
</div>
<Badge variant="secondary">{{ stall.currency }}</Badge>
</div>
<div class="space-y-3">
<!-- Store Metrics -->
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">Products</span>
<span class="font-medium">{{ stall.products?.length || 0 }}</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">Shipping Zones</span>
<span class="font-medium">{{ stall.shipping_zones?.length || 0 }}</span>
</div>
<!-- Action Buttons -->
<div class="flex gap-2 pt-3">
<Button
@click="manageStall(stall.id)"
variant="default"
size="sm"
class="flex-1"
>
<Settings class="w-4 h-4 mr-1" />
Manage
</Button>
<Button
@click="viewStallProducts(stall.id)"
variant="outline"
size="sm"
class="flex-1"
>
<Package class="w-4 h-4 mr-1" />
Products
</Button>
</div>
</div>
</div>
</div>
<!-- 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="initializeStallCreation"
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="flex items-center justify-between mb-6">
<div>
<div class="flex items-center gap-3 mb-2">
<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>
<h2 class="text-2xl font-bold text-foreground">{{ activeStall.name }}</h2>
<p class="text-muted-foreground mt-1">{{ activeStall.config?.description || 'Manage incoming orders and your products' }}</p>
</div>
<div class="flex items-center gap-3">
<Button @click="navigateToMarket" variant="outline">
<Store class="w-4 h-4 mr-2" />
Browse Market
</Button>
<Button @click="addProduct" variant="default">
<Plus class="w-4 h-4 mr-2" />
Add Product
</Button>
</div>
</div>
<!-- Store Stats -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
<!-- Incoming Orders -->
<div class="bg-card p-6 rounded-lg border shadow-sm">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-muted-foreground">Incoming Orders</p>
<p class="text-2xl font-bold text-foreground">{{ storeStats.incomingOrders }}</p>
</div>
<div class="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center">
<Package class="w-6 h-6 text-primary" />
</div>
</div>
<div class="mt-4">
<div class="flex items-center text-sm text-muted-foreground">
<span>{{ storeStats.pendingOrders }} pending</span>
<span class="mx-2">•</span>
<span>{{ storeStats.paidOrders }} paid</span>
</div>
</div>
</div>
<!-- Total Sales -->
<div class="bg-card p-6 rounded-lg border shadow-sm">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-muted-foreground">Total Sales</p>
<p class="text-2xl font-bold text-foreground">{{ formatPrice(storeStats.totalSales, 'sat') }}</p>
</div>
<div class="w-12 h-12 bg-green-500/10 rounded-lg flex items-center justify-center">
<DollarSign class="w-6 h-6 text-green-500" />
</div>
</div>
<div class="mt-4">
<div class="flex items-center text-sm text-muted-foreground">
<span>Last 30 days</span>
</div>
</div>
</div>
<!-- Products -->
<div class="bg-card p-6 rounded-lg border shadow-sm">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-muted-foreground">Products</p>
<p class="text-2xl font-bold text-foreground">{{ storeStats.totalProducts }}</p>
</div>
<div class="w-12 h-12 bg-purple-500/10 rounded-lg flex items-center justify-center">
<Store class="w-6 h-6 text-purple-500" />
</div>
</div>
<div class="mt-4">
<div class="flex items-center text-sm text-muted-foreground">
<span>{{ storeStats.activeProducts }} active</span>
</div>
</div>
</div>
<!-- Customer Satisfaction -->
<div class="bg-card p-6 rounded-lg border shadow-sm">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-muted-foreground">Satisfaction</p>
<p class="text-2xl font-bold text-foreground">{{ storeStats.satisfaction }}%</p>
</div>
<div class="w-12 h-12 bg-yellow-500/10 rounded-lg flex items-center justify-center">
<Star class="w-6 h-6 text-yellow-500" />
</div>
</div>
<div class="mt-4">
<div class="flex items-center text-sm text-muted-foreground">
<span>{{ storeStats.totalReviews }} reviews</span>
</div>
</div>
</div>
</div>
<!-- Incoming Orders Section -->
<div class="bg-card rounded-lg border shadow-sm">
<div class="p-6 border-b border-border">
<h3 class="text-lg font-semibold text-foreground">Incoming Orders</h3>
<p class="text-sm text-muted-foreground mt-1">Orders waiting for your attention</p>
</div>
<div v-if="incomingOrders.length > 0" class="divide-y divide-border">
<div
v-for="order in incomingOrders"
:key="order.id"
class="p-6 hover:bg-muted/50 transition-colors"
>
<!-- Order Header -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-primary/10 rounded-full flex items-center justify-center">
<Package class="w-5 h-5 text-primary" />
</div>
<div>
<h4 class="font-semibold text-foreground">Order #{{ order.id.slice(-8) }}</h4>
<p class="text-sm text-muted-foreground">
{{ formatDate(order.createdAt) }} • {{ formatPrice(order.total, order.currency) }}
</p>
<div class="flex items-center gap-2 mt-1">
<Badge :variant="getStatusVariant(order.status)">
{{ formatStatus(order.status) }}
</Badge>
<Badge v-if="order.paymentStatus === 'pending'" variant="secondary">
Payment Pending
</Badge>
</div>
</div>
</div>
<div class="flex items-center gap-2">
<!-- Wallet Indicator -->
<div v-if="order.status === 'pending' && !order.lightningInvoice" class="text-xs text-muted-foreground mr-2">
<span>Wallet: {{ getFirstWalletName() }}</span>
</div>
<Button
v-if="order.status === 'pending' && !order.lightningInvoice"
@click="generateInvoice(order.id)"
:disabled="isGeneratingInvoice === order.id"
size="sm"
variant="default"
>
<div v-if="isGeneratingInvoice === order.id" class="flex items-center space-x-2">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<span>Generating...</span>
</div>
<div v-else class="flex items-center space-x-2">
<Zap class="w-4 h-4" />
<span>Generate Invoice</span>
</div>
</Button>
<Button
v-if="order.lightningInvoice"
@click="viewOrderDetails(order.id)"
size="sm"
variant="outline"
>
<Eye class="w-4 h-4 mr-2" />
View Details
</Button>
<Button
@click="processOrder(order.id)"
size="sm"
variant="outline"
>
<Check class="w-4 h-4 mr-2" />
Process
</Button>
</div>
</div>
<!-- Order Items -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<h5 class="font-medium text-foreground mb-2">Items</h5>
<div class="space-y-1">
<div
v-for="item in order.items"
:key="item.productId"
class="text-sm text-muted-foreground"
>
{{ item.productName }} × {{ item.quantity }}
</div>
</div>
</div>
<div>
<h5 class="font-medium text-foreground mb-2">Customer Info</h5>
<div class="space-y-1 text-sm text-muted-foreground">
<p v-if="order.contactInfo.email">
<span class="font-medium">Email:</span> {{ order.contactInfo.email }}
</p>
<p v-if="order.contactInfo.message">
<span class="font-medium">Message:</span> {{ order.contactInfo.message }}
</p>
<p v-if="order.contactInfo.address">
<span class="font-medium">Address:</span> {{ order.contactInfo.address }}
</p>
</div>
</div>
</div>
<!-- Payment Status -->
<div v-if="order.lightningInvoice" class="p-4 bg-green-500/10 border border-green-200 rounded-lg">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<CheckCircle class="w-5 h-5 text-green-600" />
<span class="text-sm font-medium text-green-900">Lightning Invoice Generated</span>
</div>
<div class="text-sm text-green-700">
Amount: {{ formatPrice(order.total, order.currency) }}
</div>
</div>
</div>
<div v-else class="p-4 bg-yellow-500/10 border border-yellow-200 rounded-lg">
<div class="flex items-center gap-2">
<AlertCircle class="w-5 h-5 text-yellow-600" />
<span class="text-sm font-medium text-yellow-900">Invoice Required</span>
</div>
</div>
</div>
</div>
<div v-else class="p-6 text-center text-muted-foreground">
<Package class="w-12 h-12 mx-auto mb-3 text-muted-foreground/50" />
<p>No incoming orders</p>
<p class="text-sm">Orders from customers will appear here</p>
</div>
</div>
<!-- Quick Actions -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Order Management -->
<div class="bg-card p-6 rounded-lg border shadow-sm">
<h3 class="text-lg font-semibold text-foreground mb-4">Order Management</h3>
<div class="space-y-3">
<Button
@click="viewAllOrders"
variant="default"
class="w-full justify-start"
>
<Package class="w-4 h-4 mr-2" />
View All Orders
</Button>
<Button
@click="generateBulkInvoices"
variant="outline"
class="w-full justify-start"
>
<Zap class="w-4 h-4 mr-2" />
Generate Bulk Invoices
</Button>
<Button
@click="exportOrders"
variant="outline"
class="w-full justify-start"
>
<Download class="w-4 h-4 mr-2" />
Export Orders
</Button>
</div>
</div>
<!-- Store Management -->
<div class="bg-card p-6 rounded-lg border shadow-sm">
<h3 class="text-lg font-semibold text-foreground mb-4">Store Management</h3>
<div class="space-y-3">
<Button
@click="manageProducts"
variant="default"
class="w-full justify-start"
>
<Store class="w-4 h-4 mr-2" />
Manage Products
</Button>
<Button
@click="storeSettings"
variant="outline"
class="w-full justify-start"
>
<Settings class="w-4 h-4 mr-2" />
Store Settings
</Button>
<Button
@click="analytics"
variant="outline"
class="w-full justify-start"
>
<BarChart3 class="w-4 h-4 mr-2" />
View Analytics
</Button>
</div>
</div>
</div>
</div> <!-- End of Active Store Dashboard -->
</div> <!-- End of Stores Grid section -->
</div> <!-- End of main container -->
<!-- Create Stall Dialog -->
<Dialog v-model:open="showStallDialog">
<DialogContent class="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Create New Store</DialogTitle>
</DialogHeader>
<form @submit="onSubmit" class="space-y-6 py-4">
<!-- Basic Store Info -->
<div class="space-y-4">
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel>Store Name *</FormLabel>
<FormControl>
<Input
placeholder="Enter your store name"
:disabled="isCreatingStall"
v-bind="componentField"
/>
</FormControl>
<FormDescription>
Choose a unique name for your store
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="description">
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Describe your store and products"
:disabled="isCreatingStall"
v-bind="componentField"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="currency">
<FormItem>
<FormLabel>Currency *</FormLabel>
<Select :disabled="isCreatingStall" v-bind="componentField">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select currency" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem v-for="currency in availableCurrencies" :key="currency" :value="currency">
{{ currency }}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
</FormField>
</div>
<!-- Shipping Zones Section -->
<FormField name="selectedZones">
<FormItem>
<div class="mb-4">
<FormLabel class="text-base">Shipping Zones *</FormLabel>
<FormDescription>
Select existing zones or create new ones for your store
</FormDescription>
</div>
<!-- Existing Zones -->
<div v-if="availableZones.length > 0" class="space-y-3">
<FormLabel class="text-sm font-medium">Available Zones:</FormLabel>
<div class="space-y-2 max-h-32 overflow-y-auto">
<FormField
v-for="zone in availableZones"
:key="zone.id"
v-slot="{ value, handleChange }"
type="checkbox"
:value="zone.id"
:unchecked-value="false"
name="selectedZones"
>
<FormItem class="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
:model-value="value.includes(zone.id)"
@update:model-value="handleChange"
:disabled="isCreatingStall"
/>
</FormControl>
<FormLabel class="text-sm cursor-pointer font-normal">
{{ zone.name }} - {{ zone.cost }} {{ zone.currency }}
<span class="text-muted-foreground ml-1">({{ zone.countries.slice(0, 2).join(', ') }}{{ zone.countries.length > 2 ? '...' : '' }})</span>
</FormLabel>
</FormItem>
</FormField>
</div>
</div>
<FormMessage />
</FormItem>
</FormField>
<!-- Create New Zone -->
<div class="border-t pt-4">
<Label class="text-sm font-medium">Create New Zone:</Label>
<div class="space-y-3 mt-2">
<FormField v-slot="{ componentField }" name="newZone.name">
<FormItem>
<FormLabel>Zone Name</FormLabel>
<FormControl>
<Input
placeholder="e.g., Europe, Worldwide"
:disabled="isCreatingStall"
v-bind="componentField"
/>
</FormControl>
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="newZone.cost">
<FormItem>
<FormLabel>Shipping Cost</FormLabel>
<FormControl>
<Input
type="number"
min="0"
:placeholder="`Cost in ${getFieldValue('currency') || 'sat'}`"
:disabled="isCreatingStall"
v-bind="componentField"
/>
</FormControl>
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="newZone.selectedCountries">
<FormItem>
<FormLabel>Countries/Regions</FormLabel>
<FormControl>
<Select multiple :disabled="isCreatingStall" v-bind="componentField">
<SelectTrigger>
<SelectValue placeholder="Select countries/regions" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="country in countries" :key="country" :value="country">
{{ country }}
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<div v-if="getFieldValue('newZone')?.selectedCountries?.length > 0" class="mt-2">
<div class="flex flex-wrap gap-1">
<Badge
v-for="country in getFieldValue('newZone').selectedCountries"
:key="country"
variant="secondary"
class="text-xs"
>
{{ country }}
</Badge>
</div>
</div>
</FormItem>
</FormField>
<Button
@click="addNewZone"
type="button"
variant="outline"
size="sm"
:disabled="isCreatingStall || !getFieldValue('newZone')?.name || !getFieldValue('newZone')?.selectedCountries?.length"
>
<Plus class="w-4 h-4 mr-2" />
Add Zone
</Button>
</div>
</div>
<!-- Error Display -->
<div v-if="stallCreateError" class="text-sm text-destructive">
{{ stallCreateError }}
</div>
<div class="flex justify-end space-x-2 pt-4">
<Button
@click="showStallDialog = false"
variant="outline"
:disabled="isCreatingStall"
>
Cancel
</Button>
<Button
type="submit"
:disabled="isCreatingStall || !isFormValid"
>
<span v-if="isCreatingStall">Creating...</span>
<span v-else>Create Store</span>
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import { useMarketStore } from '@/modules/market/stores/market'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
Package,
Store,
DollarSign,
Star,
Plus,
Zap,
Eye,
Check,
AlertCircle,
CheckCircle,
Download,
Settings,
BarChart3,
User
} from 'lucide-vue-next'
import type { OrderStatus } from '@/modules/market/stores/market'
import type { NostrmarketService } from '../services/nostrmarketService'
import type { NostrmarketAPI, Merchant, Zone, Stall, CreateStallRequest } from '../services/nostrmarketAPI'
import { auth } from '@/composables/useAuthService'
import { useToast } from '@/core/composables/useToast'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
const router = useRouter()
const marketStore = useMarketStore()
const nostrmarketService = injectService(SERVICE_TOKENS.NOSTRMARKET_SERVICE) as NostrmarketService
const nostrmarketAPI = injectService(SERVICE_TOKENS.NOSTRMARKET_API) as NostrmarketAPI
const toast = useToast()
// Local state
const isGeneratingInvoice = ref<string | null>(null)
const merchantProfile = ref<Merchant | null>(null)
const isLoadingMerchant = ref(false)
const merchantCheckError = ref<string | null>(null)
const isCreatingMerchant = ref(false)
const merchantCreateError = ref<string | null>(null)
// 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)
)
// Stall creation state
const isCreatingStall = ref(false)
const stallCreateError = ref<string | null>(null)
const showStallDialog = ref(false)
const availableCurrencies = ref<string[]>(['sat'])
const availableZones = ref<Zone[]>([])
const countries = ref([
'Free (digital)', 'Worldwide', 'Europe', 'Australia', 'Austria', 'Belgium', 'Brazil', 'Canada',
'Denmark', 'Finland', 'France', 'Germany', 'Greece', 'Hong Kong', 'Hungary',
'Ireland', 'Indonesia', 'Israel', 'Italy', 'Japan', 'Kazakhstan', 'Korea',
'Luxembourg', 'Malaysia', 'Mexico', 'Netherlands', 'New Zealand', 'Norway',
'Poland', 'Portugal', 'Romania', 'Russia', 'Saudi Arabia', 'Singapore',
'Spain', 'Sweden', 'Switzerland', 'Thailand', 'Turkey', 'Ukraine',
'United Kingdom', 'United States', 'Vietnam', 'China'
])
// Stall form schema
const stallFormSchema = toTypedSchema(z.object({
name: z.string().min(1, "Store name is required").max(100, "Store name must be less than 100 characters"),
description: z.string().max(500, "Description must be less than 500 characters").optional(),
currency: z.string().min(1, "Currency is required"),
wallet: z.string().optional(),
selectedZones: z.array(z.string()).min(1, "Select at least one shipping zone"),
newZone: z.object({
name: z.string().optional(),
cost: z.number().min(0, "Cost must be 0 or greater").optional(),
selectedCountries: z.array(z.string()).optional()
}).optional()
}))
// Form setup with vee-validate
const form = useForm({
validationSchema: stallFormSchema,
initialValues: {
name: '',
description: '',
currency: 'sat',
wallet: '',
selectedZones: [] as string[],
newZone: {
name: '',
cost: 0,
selectedCountries: [] as string[]
}
}
})
// Destructure form methods for easier access
const { setFieldValue, resetForm, values, meta } = form
// Helper function to get field values safely
const getFieldValue = (fieldName: string) => {
return fieldName.split('.').reduce((obj, key) => obj?.[key], values)
}
// Form validation computed
const isFormValid = computed(() => meta.value.valid)
// Form submit handler
const onSubmit = form.handleSubmit(async (values) => {
console.log('Form submitted with values:', values)
await createStall(values)
})
// Computed properties
const userHasMerchantProfile = computed(() => {
// Use the actual API response to determine if user has merchant profile
return merchantProfile.value !== null
})
const userHasStalls = computed(() => {
return userStalls.value.length > 0
})
const incomingOrders = computed(() => {
// Filter orders to only show those where the current user is the seller
const currentUserPubkey = auth.currentUser?.value?.pubkey
if (!currentUserPubkey) return []
return Object.values(marketStore.orders)
.filter(order => order.sellerPubkey === currentUserPubkey && order.status === 'pending')
.sort((a, b) => b.createdAt - a.createdAt)
})
const storeStats = computed(() => {
const currentUserPubkey = auth.currentUser?.value?.pubkey
if (!currentUserPubkey) {
return {
incomingOrders: 0,
pendingOrders: 0,
paidOrders: 0,
totalSales: 0,
totalProducts: 0,
activeProducts: 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),
totalProducts: 0, // TODO: Implement product management
activeProducts: 0, // TODO: Implement product management
satisfaction: userHasStalls.value ? 95 : 0, // TODO: Implement review system
totalReviews: 0 // TODO: Implement review system
}
})
// Methods
const formatDate = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const formatStatus = (status: OrderStatus) => {
const statusMap: Record<OrderStatus, string> = {
pending: 'Pending',
paid: 'Paid',
processing: 'Processing',
shipped: 'Shipped',
delivered: 'Delivered',
cancelled: 'Cancelled'
}
return statusMap[status] || status
}
const getStatusVariant = (status: OrderStatus) => {
const variantMap: Record<OrderStatus, 'default' | 'secondary' | 'outline' | 'destructive'> = {
pending: 'outline',
paid: 'secondary',
processing: 'secondary',
shipped: 'default',
delivered: 'default',
cancelled: 'destructive'
}
return variantMap[status] || 'outline'
}
const formatPrice = (price: number, currency: string) => {
return marketStore.formatPrice(price, currency)
}
const generateInvoice = async (orderId: string) => {
console.log('Generating invoice for order:', orderId)
isGeneratingInvoice.value = orderId
try {
// Get the order from the store
const order = marketStore.orders[orderId]
if (!order) {
throw new Error('Order not found')
}
// Temporary fix: If buyerPubkey is missing, try to get it from auth
if (!order.buyerPubkey && auth.currentUser?.value?.pubkey) {
console.log('Fixing missing buyerPubkey for existing order')
marketStore.updateOrder(order.id, { buyerPubkey: auth.currentUser.value.pubkey })
}
// Temporary fix: If sellerPubkey is missing, use current user's pubkey
if (!order.sellerPubkey && auth.currentUser?.value?.pubkey) {
console.log('Fixing missing sellerPubkey for existing order')
marketStore.updateOrder(order.id, { sellerPubkey: auth.currentUser.value.pubkey })
}
// Get the updated order
const updatedOrder = marketStore.orders[orderId]
console.log('Order details for invoice generation:', {
orderId: updatedOrder.id,
orderFields: Object.keys(updatedOrder),
buyerPubkey: updatedOrder.buyerPubkey,
sellerPubkey: updatedOrder.sellerPubkey,
status: updatedOrder.status,
total: updatedOrder.total
})
// Get the user's wallet list
const userWallets = auth.currentUser?.value?.wallets || []
console.log('Available wallets:', userWallets)
if (userWallets.length === 0) {
throw new Error('No wallet available to generate invoice. Please ensure you have at least one wallet configured.')
}
// Use the first available wallet for invoice generation
const walletId = userWallets[0].id
const walletName = userWallets[0].name
const adminKey = userWallets[0].adminkey
console.log('Using wallet for invoice generation:', { walletId, walletName, balance: userWallets[0].balance_msat })
const invoice = await marketStore.createLightningInvoice(orderId, adminKey)
if (invoice) {
console.log('Lightning invoice created:', invoice)
// Send the invoice to the customer via Nostr
await sendInvoiceToCustomer(updatedOrder, invoice)
console.log('Invoice sent to customer successfully')
// Show success message (you could add a toast notification here)
alert(`Invoice generated successfully using wallet: ${walletName}`)
} else {
throw new Error('Failed to create Lightning invoice')
}
} catch (error) {
console.error('Failed to generate invoice:', error)
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'
// Show error message to user
alert(`Failed to generate invoice: ${errorMessage}`)
} finally {
isGeneratingInvoice.value = null
}
}
const sendInvoiceToCustomer = async (order: any, invoice: any) => {
try {
console.log('Sending invoice to customer for order:', {
orderId: order.id,
buyerPubkey: order.buyerPubkey,
sellerPubkey: order.sellerPubkey,
invoiceFields: Object.keys(invoice)
})
// Check if we have the buyer's public key
if (!order.buyerPubkey) {
console.error('Missing buyerPubkey in order:', order)
throw new Error('Cannot send invoice: buyer public key not found')
}
// Update the order with the invoice details
const updatedOrder = {
...order,
lightningInvoice: invoice,
paymentHash: invoice.payment_hash,
paymentStatus: 'pending',
paymentRequest: invoice.bolt11, // Use bolt11 field from LNBits response
updatedAt: Math.floor(Date.now() / 1000)
}
// Update the order in the store
marketStore.updateOrder(order.id, updatedOrder)
// Send the updated order to the customer via Nostr
// This will include the invoice information
await nostrmarketService.publishOrder(updatedOrder, order.buyerPubkey)
console.log('Updated order with invoice sent via Nostr to customer:', order.buyerPubkey)
} catch (error) {
console.error('Failed to send invoice to customer:', error)
throw error
}
}
const viewOrderDetails = (orderId: string) => {
// TODO: Navigate to detailed order view
console.log('Viewing order details:', orderId)
}
const processOrder = (orderId: string) => {
// TODO: Implement order processing
console.log('Processing order:', orderId)
}
const addProduct = () => {
// TODO: Navigate to add product form
console.log('Adding new product')
}
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] // Use first wallet
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
merchantCreateError.value = null
try {
console.log('Creating merchant profile...', {
walletId: wallet.id,
adminKeyLength: wallet.adminkey.length,
adminKeyPreview: wallet.adminkey.substring(0, 8) + '...'
})
// Create merchant with empty config, exactly like the nostrmarket extension
const merchantData = {
config: {}
}
const newMerchant = await nostrmarketAPI.createMerchant(wallet.adminkey, merchantData)
console.log('Merchant profile created successfully:', {
merchantId: newMerchant.id,
publicKey: newMerchant.public_key
})
// Update local state
merchantProfile.value = newMerchant
// Show success message
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)
merchantCreateError.value = errorMessage
// Show error to user
toast.error(`Failed to create merchant profile: ${errorMessage}`)
} finally {
isCreatingMerchant.value = false
}
}
const initializeStallCreation = async () => {
const currentUser = auth.currentUser?.value
if (!currentUser?.wallets?.length) {
toast.error('No wallets available')
return
}
// Set default wallet
setFieldValue('wallet', currentUser.wallets[0].id)
// Load currencies and zones
await Promise.all([
loadAvailableCurrencies(),
loadAvailableZones()
])
showStallDialog.value = true
}
const loadAvailableCurrencies = async () => {
try {
const currencies = await nostrmarketAPI.getCurrencies()
availableCurrencies.value = currencies
} catch (error) {
console.error('Failed to load currencies:', error)
// Keep default 'sat'
}
}
const loadAvailableZones = async () => {
const currentUser = auth.currentUser?.value
if (!currentUser?.wallets?.length) return
try {
const zones = await nostrmarketAPI.getZones(currentUser.wallets[0].inkey)
availableZones.value = zones
} catch (error) {
console.error('Failed to load zones:', error)
}
}
const loadStallsList = async () => {
const currentUser = auth.currentUser?.value
console.log('Loading stalls list - currentUser:', !!currentUser, 'wallets:', currentUser?.wallets?.length)
if (!currentUser?.wallets?.length) {
console.log('No user or wallets available, skipping stalls loading')
return
}
isLoadingStalls.value = true
try {
console.log('Calling getStalls with inkey:', currentUser.wallets[0].inkey.substring(0, 8) + '...')
const stalls = await nostrmarketAPI.getStalls(currentUser.wallets[0].inkey)
userStalls.value = stalls || []
console.log('Loaded user stalls:', {
stallsCount: stalls?.length || 0,
stalls: stalls?.map(s => ({ id: s.id, name: s.name }))
})
// If there are stalls but no active one selected, select the first
if (stalls?.length > 0 && !activeStallId.value) {
activeStallId.value = stalls[0].id
console.log('Set active stall ID to:', stalls[0].id)
}
} catch (error) {
console.error('Failed to load stalls:', error)
userStalls.value = []
} finally {
isLoadingStalls.value = false
}
}
const addNewZone = async () => {
const currentUser = auth.currentUser?.value
if (!currentUser?.wallets?.length) {
toast.error('No wallets available')
return
}
const newZone = getFieldValue('newZone')
if (!newZone?.name || !newZone.selectedCountries?.length || (newZone.cost ?? -1) < 0) {
toast.error('Please fill in all zone details')
return
}
try {
const createdZone = await nostrmarketAPI.createZone(
currentUser.wallets[0].adminkey,
{
name: newZone.name,
currency: getFieldValue('currency'),
cost: newZone.cost,
countries: newZone.selectedCountries
}
)
// Add to available zones and select it
availableZones.value.push(createdZone)
const currentSelectedZones = getFieldValue('selectedZones') || []
setFieldValue('selectedZones', [...currentSelectedZones, createdZone.id])
// Reset the new zone form
setFieldValue('newZone', {
name: '',
cost: 0,
selectedCountries: []
})
toast.success(`Zone "${newZone.name}" created successfully`)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to create zone'
toast.error(`Failed to create zone: ${errorMessage}`)
}
}
const createStall = async (formData: any) => {
const currentUser = auth.currentUser?.value
if (!currentUser?.wallets?.length) {
toast.error('No wallets available')
return
}
const { name, description, currency, selectedZones } = formData
isCreatingStall.value = true
stallCreateError.value = null
try {
// Get the selected zones data
const selectedZoneData = availableZones.value.filter(zone =>
selectedZones.includes(zone.id)
)
const stallData: CreateStallRequest = {
name,
wallet: currentUser.wallets[0].id,
currency,
shipping_zones: selectedZoneData,
config: {
description: description || undefined
}
}
console.log('Creating stall:', {
name,
currency,
zonesCount: selectedZoneData.length
})
const newStall = await nostrmarketAPI.createStall(
currentUser.wallets[0].adminkey,
stallData
)
console.log('Stall created successfully:', {
stallId: newStall.id,
stallName: newStall.name
})
// Update stalls list
await loadStallsList()
// Reset form and close dialog
resetForm({
values: {
name: '',
description: '',
currency: 'sat',
wallet: currentUser.wallets[0].id,
selectedZones: [],
newZone: {
name: '',
cost: 0,
selectedCountries: []
}
}
})
showStallDialog.value = false
toast.success('Store created successfully! You can now add products.')
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to create store'
console.error('Error creating stall:', error)
stallCreateError.value = errorMessage
toast.error(`Failed to create store: ${errorMessage}`)
} finally {
isCreatingStall.value = false
}
}
// Missing functions for stall management
const manageStall = (stallId: string) => {
console.log('Managing stall:', stallId)
// Set as active stall for the dashboard view
activeStallId.value = stallId
}
const viewStallProducts = (stallId: string) => {
console.log('Viewing products for stall:', stallId)
// TODO: Navigate to products view for specific stall
// For now, set as active stall and scroll to products section
activeStallId.value = stallId
}
const navigateToMarket = () => router.push('/market')
const viewAllOrders = () => router.push('/market-dashboard?tab=orders')
const generateBulkInvoices = () => console.log('Generate bulk invoices')
const exportOrders = () => console.log('Export orders')
const manageProducts = () => console.log('Manage products')
const storeSettings = () => router.push('/market-dashboard?tab=settings')
const analytics = () => console.log('View analytics')
const getFirstWalletName = () => {
const userWallets = auth.currentUser?.value?.wallets || []
if (userWallets.length > 0) {
return userWallets[0].name
}
return 'N/A'
}
// Methods
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] // Use first wallet
if (!wallet.inkey) {
console.warn('Wallet missing invoice key for merchant check')
return
}
isLoadingMerchant.value = true
merchantCheckError.value = null
try {
console.log('Checking for merchant profile...')
const merchant = await nostrmarketAPI.getMerchant(wallet.inkey)
merchantProfile.value = merchant
console.log('Merchant profile check result:', {
hasMerchant: !!merchant,
merchantId: merchant?.id,
active: merchant?.config?.active
})
} 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
}
}
// 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()
}
})
</script>