webapp/src/modules/market/components/CreateProductDialog.vue
padreug a75982f8ef Add CategoryInput component for category management in CreateProductDialog
- Introduced a new CategoryInput component to facilitate category selection with suggestions and popular categories.
- Updated CreateProductDialog to integrate the CategoryInput, enhancing the user experience for adding product categories.
- Improved accessibility and usability by allowing users to add categories via keyboard shortcuts and providing visual feedback for selected categories.

These changes enhance the product creation process by streamlining category management.
2025-09-26 00:40:40 +02:00

475 lines
No EOL
15 KiB
Vue

<template>
<Dialog :open="isOpen" @update:open="(open) => !open && $emit('close')">
<DialogContent class="sm:max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{{ product ? 'Edit' : 'Add New' }} Product{{ stall?.name ? ` ${product ? 'in' : '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 v-slot="{ componentField }" name="categories">
<FormItem>
<FormLabel>Categories</FormLabel>
<FormDescription>Add categories to help customers find your product</FormDescription>
<FormControl>
<CategoryInput
v-bind="componentField"
:disabled="isCreating"
placeholder="Enter category (e.g., electronics, clothing, books...)"
:max-categories="10"
:show-popular-categories="true"
/>
</FormControl>
<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"
>
{{ submitButtonText }}
</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 CategoryInput from './CategoryInput.vue'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Package } from 'lucide-vue-next'
import type { NostrmarketAPI, Stall, Product, 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
product?: Product | null // For editing existing products
}
const props = defineProps<Props>()
const emit = defineEmits<{
close: []
created: [product: any]
updated: [product: any]
}>()
// Services
const nostrmarketAPI = injectService(SERVICE_TOKENS.NOSTRMARKET_API) as NostrmarketAPI
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as any
const toast = useToast()
// Local state
const isCreating = ref(false)
const createError = ref<string | null>(null)
// Computed properties
const isEditMode = computed(() => !!props.product?.id)
const submitButtonText = computed(() => isCreating.value ?
(isEditMode.value ? 'Updating...' : 'Creating...') :
(isEditMode.value ? 'Update Product' : 'Create Product')
)
// 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, meta } = form
// Product form validation computed
const isFormValid = computed(() => meta.value.valid)
// Product form submit handler
const onSubmit = form.handleSubmit(async (values) => {
await createOrUpdateProduct(values)
})
// Methods
const createOrUpdateProduct = async (formData: any) => {
if (isEditMode.value) {
await updateProduct(formData)
} else {
await createProduct(formData)
}
}
const updateProduct = async (formData: any) => {
const currentUser = auth.currentUser?.value
if (!currentUser?.wallets?.length || !props.product?.id) {
toast.error('No active store or product ID available')
return
}
const {
name,
description,
price,
quantity,
categories,
images,
active,
use_autoreply,
autoreply_message
} = formData
isCreating.value = true
createError.value = null
try {
const productData: Product = {
id: props.product.id,
stall_id: props.product.stall_id,
name,
categories: categories || [],
images: images || [],
price: Number(price),
quantity: Number(quantity),
active,
pending: false,
config: {
description: description || '',
currency: props.stall?.currency || props.product.config.currency,
use_autoreply,
autoreply_message: use_autoreply ? autoreply_message || '' : '',
shipping: props.product.config.shipping || []
}
}
const adminKey = paymentService.getPreferredWalletAdminKey()
if (!adminKey) {
throw new Error('No wallet admin key available')
}
const updatedProduct = await nostrmarketAPI.updateProduct(
adminKey,
props.product.id,
productData
)
// Reset form and close dialog
resetForm()
emit('updated', updatedProduct)
emit('close')
toast.success(`Product "${name}" updated successfully!`)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to update product'
console.error('Error updating product:', error)
createError.value = errorMessage
toast.error(`Failed to update product: ${errorMessage}`)
} finally {
isCreating.value = false
}
}
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 adminKey = paymentService.getPreferredWalletAdminKey()
if (!adminKey) {
throw new Error('No wallet admin key available')
}
const newProduct = await nostrmarketAPI.createProduct(
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) {
// If editing, pre-populate with existing product data
const initialValues = props.product ? {
name: props.product.name || '',
description: props.product.config?.description || '',
price: props.product.price || 0,
quantity: props.product.quantity || 1,
categories: props.product.categories || [],
images: props.product.images || [],
active: props.product.active ?? true,
use_autoreply: props.product.config?.use_autoreply || false,
autoreply_message: props.product.config?.autoreply_message || ''
} : {
name: '',
description: '',
price: 0,
quantity: 1,
categories: [],
images: [],
active: true,
use_autoreply: false,
autoreply_message: ''
}
// Reset form with appropriate initial values
resetForm({ values: initialValues })
// Wait for reactivity
await nextTick()
// Clear any previous errors
createError.value = null
}
})
</script>