- Added support for AND/OR filtering modes in the `useCategoryFilter` composable, allowing users to filter products based on all or any selected categories. - Introduced reactive state management for filter mode and updated the filtering logic to accommodate the new functionality. - Enhanced the MarketPage UI with a toggle for selecting filter modes, improving user experience and accessibility. - Updated ARIA attributes for better screen reader support in the filter mode selection. These changes significantly enhance the category filtering capabilities, providing users with more control over product visibility. Refactor CreateProductDialog and MarketPage for improved category handling - Updated CreateProductDialog to utilize `model-value` and `@update:model-value` for the CategoryInput component, enhancing reactivity in category selection. - Enhanced MarketPage filtering logic to support AND/OR modes, allowing for more flexible product filtering based on selected categories. - Improved category normalization and matching logic to ensure accurate filtering results. These changes streamline the category management and filtering processes, providing users with a more intuitive experience when creating and finding products.
477 lines
No EOL
15 KiB
Vue
477 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
|
|
:key="`active-checkbox-${props.isOpen}`"
|
|
:model-value="value"
|
|
@update:model-value="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="{ value, handleChange }" name="categories">
|
|
<FormItem>
|
|
<FormLabel>Categories</FormLabel>
|
|
<FormDescription>Add categories to help customers find your product</FormDescription>
|
|
<FormControl>
|
|
<CategoryInput
|
|
:model-value="value || []"
|
|
@update:model-value="handleChange"
|
|
: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
|
|
:model-value="value"
|
|
@update:model-value="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> |