From b17c8bb408d99df4e67f2a763fb4e4d9adf1f535 Mon Sep 17 00:00:00 2001 From: hossein taromi Date: Sun, 27 Jul 2025 14:45:31 +0330 Subject: [PATCH] feat(product-options): implement product options management system - Add product options models, requests and hooks - Implement product options list page with search and CRUD operations - Add product option form page with TagInput for values management - Include real-time preview and help sections for better UX - Support for color, size, material and other product variations --- src/pages/product-options/core/_hooks.ts | 90 +++++ src/pages/product-options/core/_models.ts | 49 +++ src/pages/product-options/core/_requests.ts | 79 +++++ .../ProductOptionFormPage.tsx | 198 +++++++++++ .../ProductOptionsListPage.tsx | 317 ++++++++++++++++++ 5 files changed, 733 insertions(+) create mode 100644 src/pages/product-options/core/_hooks.ts create mode 100644 src/pages/product-options/core/_models.ts create mode 100644 src/pages/product-options/core/_requests.ts create mode 100644 src/pages/product-options/product-option-form/ProductOptionFormPage.tsx create mode 100644 src/pages/product-options/product-options-list/ProductOptionsListPage.tsx diff --git a/src/pages/product-options/core/_hooks.ts b/src/pages/product-options/core/_hooks.ts new file mode 100644 index 0000000..fb35a26 --- /dev/null +++ b/src/pages/product-options/core/_hooks.ts @@ -0,0 +1,90 @@ +import { QUERY_KEYS } from "@/utils/query-key"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + getProductOptions, + getProductOption, + createProductOption, + updateProductOption, + deleteProductOption, +} from "./_requests"; +import { + CreateProductOptionRequest, + UpdateProductOptionRequest, + ProductOptionFilters, +} from "./_models"; +import toast from "react-hot-toast"; + +export const useProductOptions = (filters?: ProductOptionFilters) => { + return useQuery({ + queryKey: [QUERY_KEYS.GET_PRODUCT_OPTIONS, filters], + queryFn: () => getProductOptions(filters), + }); +}; + +export const useProductOption = (id: string, enabled: boolean = true) => { + return useQuery({ + queryKey: [QUERY_KEYS.GET_PRODUCT_OPTION, id], + queryFn: () => getProductOption(id), + enabled: enabled && !!id, + }); +}; + +export const useCreateProductOption = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: [QUERY_KEYS.CREATE_PRODUCT_OPTION], + mutationFn: (data: CreateProductOptionRequest) => createProductOption(data), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [QUERY_KEYS.GET_PRODUCT_OPTIONS], + }); + toast.success("گزینه محصول با موفقیت ایجاد شد"); + }, + onError: (error: any) => { + console.error("Create product option error:", error); + toast.error(error?.message || "خطا در ایجاد گزینه محصول"); + }, + }); +}; + +export const useUpdateProductOption = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: [QUERY_KEYS.UPDATE_PRODUCT_OPTION], + mutationFn: (data: UpdateProductOptionRequest) => updateProductOption(data), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: [QUERY_KEYS.GET_PRODUCT_OPTIONS], + }); + queryClient.invalidateQueries({ + queryKey: [QUERY_KEYS.GET_PRODUCT_OPTION, variables.id.toString()], + }); + toast.success("گزینه محصول با موفقیت ویرایش شد"); + }, + onError: (error: any) => { + console.error("Update product option error:", error); + toast.error(error?.message || "خطا در ویرایش گزینه محصول"); + }, + }); +}; + +export const useDeleteProductOption = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: [QUERY_KEYS.DELETE_PRODUCT_OPTION], + mutationFn: (id: string) => deleteProductOption(id), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [QUERY_KEYS.GET_PRODUCT_OPTIONS], + }); + toast.success("گزینه محصول با موفقیت حذف شد"); + }, + onError: (error: any) => { + console.error("Delete product option error:", error); + toast.error(error?.message || "خطا در حذف گزینه محصول"); + }, + }); +}; diff --git a/src/pages/product-options/core/_models.ts b/src/pages/product-options/core/_models.ts new file mode 100644 index 0000000..00a6e8a --- /dev/null +++ b/src/pages/product-options/core/_models.ts @@ -0,0 +1,49 @@ +export interface ProductOption { + id: number; + name: string; + values: string[]; + created_at: string; + updated_at: string; +} + +export interface ProductOptionFormData { + name: string; + values: string[]; +} + +export interface ProductOptionFilters { + search?: string; + page?: number; + limit?: number; +} + +export interface CreateProductOptionRequest { + name: string; + values: string[]; +} + +export interface UpdateProductOptionRequest { + id: number; + name: string; + values: string[]; +} + +export interface ProductOptionsResponse { + product_options: ProductOption[] | null; +} + +export interface ProductOptionResponse { + product_option: ProductOption; +} + +export interface CreateProductOptionResponse { + product_option: ProductOption; +} + +export interface UpdateProductOptionResponse { + product_option: ProductOption; +} + +export interface DeleteProductOptionResponse { + message: string; +} diff --git a/src/pages/product-options/core/_requests.ts b/src/pages/product-options/core/_requests.ts new file mode 100644 index 0000000..8e313f4 --- /dev/null +++ b/src/pages/product-options/core/_requests.ts @@ -0,0 +1,79 @@ +import { + httpGetRequest, + httpPostRequest, + httpPutRequest, + httpDeleteRequest, + APIUrlGenerator, +} from "@/utils/baseHttpService"; +import { API_ROUTES } from "@/constant/routes"; +import { + ProductOption, + CreateProductOptionRequest, + UpdateProductOptionRequest, + ProductOptionsResponse, + ProductOptionResponse, + CreateProductOptionResponse, + UpdateProductOptionResponse, + DeleteProductOptionResponse, + ProductOptionFilters, +} from "./_models"; + +export const getProductOptions = async (filters?: ProductOptionFilters) => { + try { + const queryParams: Record = {}; + + if (filters?.search) queryParams.search = filters.search; + if (filters?.page) queryParams.page = filters.page; + if (filters?.limit) queryParams.limit = filters.limit; + + const response = await httpGetRequest( + APIUrlGenerator(API_ROUTES.GET_PRODUCT_OPTIONS, queryParams) + ); + + console.log("Product Options API Response:", response); + + if ( + response.data && + response.data.product_options && + Array.isArray(response.data.product_options) + ) { + return response.data.product_options; + } + + console.warn("Product options is null or not an array:", response.data); + return []; + } catch (error) { + console.error("Error fetching product options:", error); + return []; + } +}; + +export const getProductOption = async (id: string) => { + const response = await httpGetRequest( + APIUrlGenerator(API_ROUTES.GET_PRODUCT_OPTION(id)) + ); + return response.data.product_option; +}; + +export const createProductOption = async (data: CreateProductOptionRequest) => { + const response = await httpPostRequest( + APIUrlGenerator(API_ROUTES.CREATE_PRODUCT_OPTION), + data + ); + return response.data.product_option; +}; + +export const updateProductOption = async (data: UpdateProductOptionRequest) => { + const response = await httpPutRequest( + APIUrlGenerator(API_ROUTES.UPDATE_PRODUCT_OPTION(data.id.toString())), + data + ); + return response.data.product_option; +}; + +export const deleteProductOption = async (id: string) => { + const response = await httpDeleteRequest( + APIUrlGenerator(API_ROUTES.DELETE_PRODUCT_OPTION(id)) + ); + return response.data; +}; diff --git a/src/pages/product-options/product-option-form/ProductOptionFormPage.tsx b/src/pages/product-options/product-option-form/ProductOptionFormPage.tsx new file mode 100644 index 0000000..ee0bf0b --- /dev/null +++ b/src/pages/product-options/product-option-form/ProductOptionFormPage.tsx @@ -0,0 +1,198 @@ +import React, { useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; +import { useProductOption, useCreateProductOption, useUpdateProductOption } from '../core/_hooks'; +import { ProductOptionFormData } from '../core/_models'; +import { Button } from "@/components/ui/Button"; +import { Input } from "@/components/ui/Input"; +import { LoadingSpinner } from "@/components/ui/LoadingSpinner"; +import { TagInput } from "@/components/ui/TagInput"; +import { ArrowRight, Settings } from "lucide-react"; + +const productOptionSchema = yup.object({ + name: yup.string().required('نام گزینه الزامی است').min(2, 'نام گزینه باید حداقل 2 کاراکتر باشد'), + values: yup.array().of(yup.string()).min(1, 'حداقل یک مقدار باید وارد شود').required('مقادیر الزامی است'), +}); + +const ProductOptionFormPage = () => { + const navigate = useNavigate(); + const { id } = useParams<{ id: string }>(); + const isEdit = !!id; + + const { data: productOption, isLoading: isLoadingOption } = useProductOption(id || '', isEdit); + const { mutate: createOption, isPending: isCreating } = useCreateProductOption(); + const { mutate: updateOption, isPending: isUpdating } = useUpdateProductOption(); + + const isLoading = isCreating || isUpdating; + + const { + register, + handleSubmit, + formState: { errors, isValid, isDirty }, + setValue, + watch, + control + } = useForm({ + resolver: yupResolver(productOptionSchema) as any, + mode: 'onChange', + defaultValues: { + name: '', + values: [] + } + }); + + const formValues = watch(); + + useEffect(() => { + if (isEdit && productOption) { + setValue('name', productOption.name, { shouldValidate: true }); + setValue('values', productOption.values, { shouldValidate: true }); + } + }, [isEdit, productOption, setValue]); + + const onSubmit = (data: ProductOptionFormData) => { + if (isEdit && id) { + updateOption({ + id: parseInt(id), + name: data.name, + values: data.values + }, { + onSuccess: () => { + navigate('/product-options'); + } + }); + } else { + createOption({ + name: data.name, + values: data.values + }, { + onSuccess: () => { + navigate('/product-options'); + } + }); + } + }; + + const handleBack = () => { + navigate('/product-options'); + }; + + const handleValuesChange = (values: string[]) => { + setValue('values', values, { shouldValidate: true, shouldDirty: true }); + }; + + if (isEdit && isLoadingOption) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+ +
+

+ + {isEdit ? 'ویرایش گزینه محصول' : 'ایجاد گزینه محصول جدید'} +

+

+ {isEdit ? 'ویرایش اطلاعات گزینه محصول' : 'اطلاعات گزینه محصول جدید را وارد کنید'} +

+
+
+ + {/* Form */} +
+
+ + + + + {/* Preview */} + {formValues.values && formValues.values.length > 0 && ( +
+

+ پیش‌نمایش گزینه +

+
+
+ نام: {formValues.name || 'نام گزینه'} +
+
+ مقادیر: +
+ {formValues.values.map((value, index) => ( + + {value} + + ))} +
+
+
+
+ )} + +
+ + +
+ +
+ + {/* Help Section */} +
+

+ راهنما +

+
    +
  • • گزینه‌های محصول برای تعریف ویژگی‌هایی مثل رنگ، سایز، جنس استفاده می‌شوند
  • +
  • • هر گزینه می‌تواند چندین مقدار داشته باشد (مثل قرمز، آبی، سبز برای رنگ)
  • +
  • • این گزینه‌ها بعداً در ایجاد محصولات قابل استفاده خواهند بود
  • +
  • • برای اضافه کردن مقدار جدید، آن را تایپ کنید و Enter بزنید
  • +
+
+
+ ); +}; + +export default ProductOptionFormPage; \ No newline at end of file diff --git a/src/pages/product-options/product-options-list/ProductOptionsListPage.tsx b/src/pages/product-options/product-options-list/ProductOptionsListPage.tsx new file mode 100644 index 0000000..23d9941 --- /dev/null +++ b/src/pages/product-options/product-options-list/ProductOptionsListPage.tsx @@ -0,0 +1,317 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useProductOptions, useDeleteProductOption } from '../core/_hooks'; +import { ProductOption } from '../core/_models'; +import { Button } from "@/components/ui/Button"; +import { LoadingSpinner } from "@/components/ui/LoadingSpinner"; +import { Trash2, Edit3, Plus, Settings, Tag } from "lucide-react"; +import { Modal } from "@/components/ui/Modal"; + +const ProductOptionsTableSkeleton = () => ( +
+
+
+ + + + + + + + + + + {[...Array(5)].map((_, i) => ( + + + + + + + ))} + +
+ نام گزینه + + مقادیر + + تاریخ ایجاد + + عملیات +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+); + +const ProductOptionsListPage = () => { + const navigate = useNavigate(); + const [deleteOptionId, setDeleteOptionId] = useState(null); + const [filters, setFilters] = useState({ + search: '' + }); + + const { data: productOptions, isLoading, error } = useProductOptions(filters); + const { mutate: deleteOption, isPending: isDeleting } = useDeleteProductOption(); + + const handleCreate = () => { + navigate('/product-options/create'); + }; + + const handleEdit = (optionId: number) => { + navigate(`/product-options/${optionId}/edit`); + }; + + const handleDeleteConfirm = () => { + if (deleteOptionId) { + deleteOption(deleteOptionId, { + onSuccess: () => { + setDeleteOptionId(null); + } + }); + } + }; + + const handleSearchChange = (e: React.ChangeEvent) => { + setFilters(prev => ({ ...prev, search: e.target.value })); + }; + + if (error) { + return ( +
+
+

خطا در بارگذاری گزینه‌های محصول

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

+ + مدیریت گزینه‌های محصول +

+

+ مدیریت گزینه‌هایی مثل رنگ، سایز، جنس و غیره +

+
+ +
+ + {/* Filters */} +
+
+
+ + +
+
+
+ + {/* Product Options Table */} + {isLoading ? ( + + ) : ( +
+ {/* Desktop Table */} +
+
+ + + + + + + + + + + {(productOptions || []).map((option: ProductOption) => ( + + + + + + + ))} + +
+ نام گزینه + + مقادیر + + تاریخ ایجاد + + عملیات +
+ {option.name} + +
+ {option.values.slice(0, 3).map((value, index) => ( + + + {value} + + ))} + {option.values.length > 3 && ( + + +{option.values.length - 3} بیشتر + + )} +
+
+ {new Date(option.created_at).toLocaleDateString('fa-IR')} + +
+ + +
+
+
+
+ + {/* Mobile Cards */} +
+ {(productOptions || []).map((option: ProductOption) => ( +
+
+
+

+ {option.name} +

+
+ {option.values.slice(0, 3).map((value, index) => ( + + + {value} + + ))} + {option.values.length > 3 && ( + + +{option.values.length - 3} بیشتر + + )} +
+
+
+
+ تاریخ ایجاد: {new Date(option.created_at).toLocaleDateString('fa-IR')} +
+
+ + +
+
+ ))} +
+ + {/* Empty State */} + {(!productOptions || productOptions.length === 0) && !isLoading && ( +
+ +

+ گزینه‌ای موجود نیست +

+

+ برای شروع، اولین گزینه محصول خود را ایجاد کنید. +

+
+ +
+
+ )} +
+ )} + + {/* Delete Confirmation Modal */} + setDeleteOptionId(null)} + title="حذف گزینه محصول" + > +
+

+ آیا از حذف این گزینه محصول اطمینان دارید؟ این عمل قابل بازگشت نیست و ممکن است بر محصولاتی که از این گزینه استفاده می‌کنند تأثیر بگذارد. +

+
+ + +
+
+
+
+ ); +}; + +export default ProductOptionsListPage; \ No newline at end of file