Add CreateProductDialog and CreateStoreDialog components

- Introduce CreateProductDialog for adding new products with comprehensive form fields including name, price, description, quantity, and active status.
- Implement validation using Zod and integrate auto-reply settings.
- Add CreateStoreDialog for creating new stores, allowing input for store name, description, currency, and shipping zones.
- Enhance user experience with loading states and error handling in both dialogs.
- Refactor MerchantStore component to utilize StoreCard for displaying user stalls, improving code organization and readability.

These changes enhance the merchant experience by streamlining product and store creation processes, ensuring better validation and user feedback.
This commit is contained in:
padreug 2025-09-08 22:13:17 +02:00
parent d52d7f4d7f
commit 0c931cf457
5 changed files with 3021 additions and 1529 deletions

View file

@ -0,0 +1,471 @@
<template>
<Dialog :open="isOpen" @update:open="(open) => !open && $emit('close')">
<DialogContent class="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Create New Store</DialogTitle>
</DialogHeader>
<form @submit="onSubmit" class="space-y-6 py-4" autocomplete="off">
<!-- 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="isCreating"
v-bind="componentField"
autocomplete="off"
spellcheck="false"
/>
</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="isCreating"
v-bind="componentField"
autocomplete="off"
spellcheck="false"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="currency">
<FormItem>
<FormLabel>Currency *</FormLabel>
<Select :disabled="isCreating" 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="Array.isArray(value) && value.includes(zone.id)"
@update:model-value="handleChange"
:disabled="isCreating"
/>
</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="isCreating"
v-bind="componentField"
autocomplete="off"
spellcheck="false"
/>
</FormControl>
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="newZone.cost">
<FormItem>
<FormLabel>Shipping Cost</FormLabel>
<FormControl>
<Input
type="number"
min="0"
:placeholder="`Cost in ${values.currency || 'sat'}`"
:disabled="isCreating"
v-bind="componentField"
/>
</FormControl>
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="newZone.selectedCountries">
<FormItem>
<FormLabel>Countries/Regions</FormLabel>
<FormControl>
<Select multiple :disabled="isCreating" 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="values.newZone?.selectedCountries?.length > 0" class="mt-2">
<div class="flex flex-wrap gap-1">
<Badge
v-for="country in values.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="isCreating || !values.newZone?.name || !values.newZone?.selectedCountries?.length"
>
<Plus class="w-4 h-4 mr-2" />
Add Zone
</Button>
</div>
</div>
<!-- Error Display -->
<div v-if="createError" class="text-sm text-destructive">
{{ createError }}
</div>
<div class="flex justify-end space-x-2 pt-4">
<Button
type="button"
@click="$emit('close')"
variant="outline"
:disabled="isCreating"
>
Cancel
</Button>
<Button
type="submit"
:disabled="isCreating || !isFormValid"
>
<span v-if="isCreating">Creating...</span>
<span v-else>Create Store</span>
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
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 { Plus } from 'lucide-vue-next'
import type { NostrmarketAPI, Zone, CreateStallRequest } from '../services/nostrmarketAPI'
import { auth } from '@/composables/useAuthService'
import { useToast } from '@/core/composables/useToast'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
// Props and emits
interface Props {
isOpen: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
close: []
created: [stall: any]
}>()
// Services
const nostrmarketAPI = injectService(SERVICE_TOKENS.NOSTRMARKET_API) as NostrmarketAPI
const toast = useToast()
// Local state
const isCreating = ref(false)
const createError = ref<string | null>(null)
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'
])
// Form schema
const formSchema = 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"),
currency: z.string().min(1, "Currency is required"),
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: formSchema,
initialValues: {
name: '',
description: '',
currency: 'sat',
selectedZones: [] as string[],
newZone: {
name: '',
cost: 0,
selectedCountries: [] as string[]
}
}
})
// Destructure form methods
const { setFieldValue, resetForm, values, meta } = form
// Form validation computed
const isFormValid = computed(() => meta.value.valid)
// Form submit handler
const onSubmit = form.handleSubmit(async (values) => {
await createStore(values)
})
// Methods
const loadAvailableCurrencies = async () => {
try {
const currencies = await nostrmarketAPI.getCurrencies()
availableCurrencies.value = currencies
} catch (error) {
console.error('Failed to load currencies:', error)
}
}
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
// Auto-select the first available zone to make form valid
const currentSelectedZones = values.selectedZones || []
if (zones.length > 0 && Array.isArray(currentSelectedZones) && currentSelectedZones.length === 0) {
setFieldValue('selectedZones', [zones[0].id])
}
} catch (error) {
console.error('Failed to load zones:', error)
}
}
const addNewZone = async () => {
const currentUser = auth.currentUser?.value
if (!currentUser?.wallets?.length) {
toast.error('No wallets available')
return
}
const newZone = values.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: values.currency,
cost: newZone.cost,
countries: newZone.selectedCountries
}
)
// Add to available zones and select it
availableZones.value.push(createdZone)
const currentSelectedZones = values.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 createStore = async (formData: any) => {
const currentUser = auth.currentUser?.value
if (!currentUser?.wallets?.length) {
toast.error('No wallets available')
return
}
const { name, description, currency, selectedZones } = formData
isCreating.value = true
createError.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 || ''
}
}
const newStall = await nostrmarketAPI.createStall(
currentUser.wallets[0].adminkey,
stallData
)
// Reset form
resetForm()
// Close dialog and emit created event
emit('created', newStall)
emit('close')
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)
createError.value = errorMessage
toast.error(`Failed to create store: ${errorMessage}`)
} finally {
isCreating.value = false
}
}
// Initialize data when dialog opens
watch(() => props.isOpen, async (isOpen) => {
if (isOpen) {
const currentUser = auth.currentUser?.value
if (!currentUser?.wallets?.length) {
toast.error('No wallets available')
return
}
// Reset form to initial state
resetForm({
values: {
name: '',
description: '',
currency: 'sat',
selectedZones: [],
newZone: {
name: '',
cost: 0,
selectedCountries: []
}
}
})
// Wait for reactivity
await nextTick()
// Clear any previous errors
createError.value = null
// Load currencies and zones
await Promise.all([
loadAvailableCurrencies(),
loadAvailableZones()
])
}
})
</script>