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:
parent
d52d7f4d7f
commit
0c931cf457
5 changed files with 3021 additions and 1529 deletions
367
src/modules/market/components/CreateProductDialog.vue
Normal file
367
src/modules/market/components/CreateProductDialog.vue
Normal file
|
|
@ -0,0 +1,367 @@
|
||||||
|
<template>
|
||||||
|
<Dialog :open="isOpen" @update:open="(open) => !open && $emit('close')">
|
||||||
|
<DialogContent class="sm:max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add New Product to {{ stall?.name }}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form @submit="onSubmit" class="space-y-6 py-4" autocomplete="off">
|
||||||
|
<!-- Basic Product Info -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- Product Name -->
|
||||||
|
<FormField v-slot="{ componentField }" name="name">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Product Name *</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter product name"
|
||||||
|
:disabled="isCreating"
|
||||||
|
v-bind="componentField"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Choose a clear, descriptive name</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<!-- Price -->
|
||||||
|
<FormField v-slot="{ componentField }" name="price">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Price * ({{ stall?.currency || 'sat' }})</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0.01"
|
||||||
|
placeholder="0.00"
|
||||||
|
:disabled="isCreating"
|
||||||
|
v-bind="componentField"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Price in {{ stall?.currency || 'sat' }}</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<FormField v-slot="{ componentField }" name="description">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Describe your product, its features, and benefits"
|
||||||
|
:disabled="isCreating"
|
||||||
|
v-bind="componentField"
|
||||||
|
rows="4"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Detailed description to help customers understand your product</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<!-- Quantity and Active Status -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- Quantity -->
|
||||||
|
<FormField v-slot="{ componentField }" name="quantity">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Quantity *</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
placeholder="1"
|
||||||
|
:disabled="isCreating"
|
||||||
|
v-bind="componentField"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Available quantity (0 = unlimited)</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<!-- Active Status -->
|
||||||
|
<FormField v-slot="{ value, handleChange }" name="active">
|
||||||
|
<FormItem>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<FormLabel>Product Status</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
:checked="value"
|
||||||
|
@update:checked="handleChange"
|
||||||
|
:disabled="isCreating"
|
||||||
|
/>
|
||||||
|
<Label>Product is active and visible</Label>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Inactive products won't be shown to customers</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Categories -->
|
||||||
|
<FormField name="categories">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Categories</FormLabel>
|
||||||
|
<FormDescription>Add categories to help customers find your product</FormDescription>
|
||||||
|
<div class="text-center py-8 border-2 border-dashed rounded-lg">
|
||||||
|
<Package class="w-8 h-8 mx-auto mb-2 text-muted-foreground" />
|
||||||
|
<p class="text-sm text-muted-foreground">Category management coming soon</p>
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<!-- Images -->
|
||||||
|
<FormField name="images">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Product Images</FormLabel>
|
||||||
|
<FormDescription>Add images to showcase your product</FormDescription>
|
||||||
|
<div class="text-center py-8 border-2 border-dashed rounded-lg">
|
||||||
|
<Package class="w-8 h-8 mx-auto mb-2 text-muted-foreground" />
|
||||||
|
<p class="text-sm text-muted-foreground">Image upload coming soon</p>
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<!-- Auto-reply Settings -->
|
||||||
|
<div class="border-t pt-6">
|
||||||
|
<FormField v-slot="{ value, handleChange }" name="use_autoreply">
|
||||||
|
<FormItem>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
:checked="value"
|
||||||
|
@update:checked="handleChange"
|
||||||
|
:disabled="isCreating"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel>Enable auto-reply to customer messages</FormLabel>
|
||||||
|
</div>
|
||||||
|
<FormDescription>Automatically respond to customer inquiries about this product</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField v-slot="{ componentField }" name="autoreply_message">
|
||||||
|
<FormItem class="mt-4">
|
||||||
|
<FormLabel>Auto-reply Message</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Thank you for your interest! I'll get back to you soon..."
|
||||||
|
:disabled="isCreating"
|
||||||
|
v-bind="componentField"
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>Message sent automatically when customers inquire about this product</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
</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 Product</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, 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 { 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 { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form'
|
||||||
|
import { Package } from 'lucide-vue-next'
|
||||||
|
import type { NostrmarketAPI, Stall, CreateProductRequest } 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
|
||||||
|
stall?: Stall | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
created: [product: 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)
|
||||||
|
|
||||||
|
// Product form schema
|
||||||
|
const productFormSchema = toTypedSchema(z.object({
|
||||||
|
name: z.string().min(1, "Product name is required").max(100, "Product name must be less than 100 characters"),
|
||||||
|
description: z.string().max(1000, "Description must be less than 1000 characters"),
|
||||||
|
price: z.number().min(0.01, "Price must be greater than 0"),
|
||||||
|
quantity: z.number().int().min(0, "Quantity must be 0 or greater"),
|
||||||
|
categories: z.array(z.string()).max(10, "Maximum 10 categories allowed"),
|
||||||
|
images: z.array(z.string().url("Invalid image URL")).max(5, "Maximum 5 images allowed"),
|
||||||
|
active: z.boolean(),
|
||||||
|
use_autoreply: z.boolean(),
|
||||||
|
autoreply_message: z.string().max(500, "Auto-reply message must be less than 500 characters")
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Product form setup with vee-validate
|
||||||
|
const form = useForm({
|
||||||
|
validationSchema: productFormSchema,
|
||||||
|
initialValues: {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
price: 0,
|
||||||
|
quantity: 1,
|
||||||
|
categories: [] as string[],
|
||||||
|
images: [] as string[],
|
||||||
|
active: true,
|
||||||
|
use_autoreply: false,
|
||||||
|
autoreply_message: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Destructure product form methods
|
||||||
|
const { resetForm, values, meta } = form
|
||||||
|
|
||||||
|
// Product form validation computed
|
||||||
|
const isFormValid = computed(() => meta.value.valid)
|
||||||
|
|
||||||
|
// Product form submit handler
|
||||||
|
const onSubmit = form.handleSubmit(async (values) => {
|
||||||
|
await createProduct(values)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const createProduct = async (formData: any) => {
|
||||||
|
const currentUser = auth.currentUser?.value
|
||||||
|
if (!currentUser?.wallets?.length || !props.stall) {
|
||||||
|
toast.error('No active store or wallets available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
price,
|
||||||
|
quantity,
|
||||||
|
categories,
|
||||||
|
images,
|
||||||
|
active,
|
||||||
|
use_autoreply,
|
||||||
|
autoreply_message
|
||||||
|
} = formData
|
||||||
|
|
||||||
|
isCreating.value = true
|
||||||
|
createError.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const productData: CreateProductRequest = {
|
||||||
|
stall_id: props.stall.id!,
|
||||||
|
name,
|
||||||
|
categories: categories || [],
|
||||||
|
images: images || [],
|
||||||
|
price: Number(price),
|
||||||
|
quantity: Number(quantity),
|
||||||
|
active,
|
||||||
|
config: {
|
||||||
|
description: description || '',
|
||||||
|
currency: props.stall.currency,
|
||||||
|
use_autoreply,
|
||||||
|
autoreply_message: use_autoreply ? autoreply_message || '' : '',
|
||||||
|
shipping: [] // Will be populated from shipping zones if needed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newProduct = await nostrmarketAPI.createProduct(
|
||||||
|
currentUser.wallets[0].adminkey,
|
||||||
|
productData
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reset form and close dialog
|
||||||
|
resetForm()
|
||||||
|
emit('created', newProduct)
|
||||||
|
emit('close')
|
||||||
|
|
||||||
|
toast.success(`Product "${name}" created successfully!`)
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Failed to create product'
|
||||||
|
console.error('Error creating product:', error)
|
||||||
|
createError.value = errorMessage
|
||||||
|
toast.error(`Failed to create product: ${errorMessage}`)
|
||||||
|
} finally {
|
||||||
|
isCreating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize data when dialog opens
|
||||||
|
watch(() => props.isOpen, async (isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
// Reset form to initial state
|
||||||
|
resetForm({
|
||||||
|
values: {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
price: 0,
|
||||||
|
quantity: 1,
|
||||||
|
categories: [],
|
||||||
|
images: [],
|
||||||
|
active: true,
|
||||||
|
use_autoreply: false,
|
||||||
|
autoreply_message: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wait for reactivity
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// Clear any previous errors
|
||||||
|
createError.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
471
src/modules/market/components/CreateStoreDialog.vue
Normal file
471
src/modules/market/components/CreateStoreDialog.vue
Normal 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>
|
||||||
File diff suppressed because it is too large
Load diff
1864
src/modules/market/components/MerchantStore.vue.bak
Normal file
1864
src/modules/market/components/MerchantStore.vue.bak
Normal file
File diff suppressed because it is too large
Load diff
69
src/modules/market/components/StoreCard.vue
Normal file
69
src/modules/market/components/StoreCard.vue
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
<template>
|
||||||
|
<div 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="$emit('manage', stall.id)"
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
<Settings class="w-4 h-4 mr-1" />
|
||||||
|
Manage
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
@click="$emit('view-products', stall.id)"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
<Package class="w-4 h-4 mr-1" />
|
||||||
|
Products
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Settings, Package } from 'lucide-vue-next'
|
||||||
|
import type { Stall } from '../services/nostrmarketAPI'
|
||||||
|
|
||||||
|
// Props
|
||||||
|
interface Props {
|
||||||
|
stall: Stall
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
|
||||||
|
// Emits
|
||||||
|
defineEmits<{
|
||||||
|
manage: [stallId: string]
|
||||||
|
'view-products': [stallId: string]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue