diff --git a/src/App.tsx b/src/App.tsx index 4f5678e..4ee1c81 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -48,6 +48,9 @@ const ProductsListPage = lazy(() => import('./pages/products/products-list/Produ const ProductFormPage = lazy(() => import('./pages/products/product-form/ProductFormPage')); const ProductDetailPage = lazy(() => import('./pages/products/product-detail/ProductDetailPage')); +// Landing Hero Page +const HeroSliderPage = lazy(() => import('./pages/landing-hero/HeroSliderPage')); + const ProtectedRoute = ({ children }: { children: any }) => { const { user, isLoading } = useAuth(); @@ -106,6 +109,9 @@ const AppRoutes = () => { } /> } /> + {/* Landing Hero Route */} + } /> + {/* Products Routes */} } /> } /> diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index f0779d7..248d6dd 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -75,6 +75,11 @@ const menuItems: MenuItem[] = [ path: '/permissions', permission: 22, }, + { + title: 'اسلایدر لندینگ', + icon: Sliders, + path: '/landing-hero', + }, ] } ]; diff --git a/src/constant/routes.ts b/src/constant/routes.ts index e956083..7ad3f25 100644 --- a/src/constant/routes.ts +++ b/src/constant/routes.ts @@ -76,4 +76,8 @@ export const API_ROUTES = { CREATE_IMAGE: "api/v1/images", UPDATE_IMAGE: (imageId: string) => `api/v1/products/images/${imageId}`, DELETE_IMAGE: (imageId: string) => `api/v1/products/images/${imageId}`, + + // Landing Hero APIs + GET_LANDING_HERO: "api/v1/settings/landing/hero", + UPDATE_LANDING_HERO: "api/v1/admin/settings/landing/hero", }; diff --git a/src/pages/landing-hero/HeroSliderPage.tsx b/src/pages/landing-hero/HeroSliderPage.tsx new file mode 100644 index 0000000..d071f13 --- /dev/null +++ b/src/pages/landing-hero/HeroSliderPage.tsx @@ -0,0 +1,186 @@ +import { useEffect } from "react"; +import { useForm, useFieldArray } from "react-hook-form"; +import { yupResolver } from "@hookform/resolvers/yup"; +import * as yup from "yup"; +import { Input } from "@/components/ui/Input"; +import { FileUploader } from "@/components/ui/FileUploader"; +import { Button } from "@/components/ui/Button"; +import { useLandingHero, useUpdateLandingHero } from "./core/_hooks"; +import { LandingHeroData, HeroImage } from "./core/_models"; +import { LoadingSpinner } from "@/components/ui/LoadingSpinner"; +import { PlusCircle, Trash2, Save } from "lucide-react"; +import { useFileUpload, useFileDelete } from "@/hooks/useFileUpload"; + +const heroImageSchema = yup.object({ + alt_text: yup.string().required("متن ALT الزامی است"), + url: yup.string() + .transform((v) => (v === "" ? null : v)) + .required("آدرس تصویر الزامی است") + .url("آدرس معتبر نیست"), + thumbnail: yup.string().url().nullable().notRequired(), +}); + +const landingHeroSchema = yup.object({ + main: heroImageSchema, + side: yup.array().of(heroImageSchema).min(0), +}); + +export const HeroSliderPage = () => { + const { data, isLoading } = useLandingHero(); + const { mutate: updateHero, isPending: isSaving } = useUpdateLandingHero(); + + const { + control, + handleSubmit, + formState: { errors, isDirty, isValid }, + reset, + setValue, + } = useForm({ + resolver: yupResolver(landingHeroSchema) as any, + mode: "onChange", + defaultValues: { + main: { alt_text: "", url: "", thumbnail: "" }, + side: [], + }, + }); + + const { fields, append, remove } = useFieldArray({ + control, + name: "side", + }); + + // File upload hooks + const { mutateAsync: uploadFile } = useFileUpload(); + const { mutate: deleteFile } = useFileDelete(); + + // Handlers for main slide image + const handleMainFileUpload = async (file: File) => { + const result = await uploadFile(file); + setValue("main.url", result.url, { shouldValidate: true, shouldDirty: true }); + return result; + }; + + const handleMainFileRemove = (fileId: string) => { + deleteFile(fileId); + setValue("main.url", "", { shouldValidate: true, shouldDirty: true }); + }; + + // Handlers for side slide images (returns functions bound to index) + const handleSideFileUpload = (index: number) => async (file: File) => { + const result = await uploadFile(file); + setValue(`side.${index}.url`, result.url, { shouldValidate: true, shouldDirty: true }); + return result; + }; + + const handleSideFileRemove = (index: number) => (fileId: string) => { + deleteFile(fileId); + setValue(`side.${index}.url`, "", { shouldValidate: true, shouldDirty: true }); + }; + + useEffect(() => { + if (data) { + reset(data); + } + }, [data, reset]); + + const onSubmit = (formData: LandingHeroData) => { + updateHero(formData); + }; + + if (isLoading) { + return ( + + + + ); + } + + return ( + + مدیریت اسلایدر صفحه اصلی + + + {/* Main slide */} + + اسلاید اصلی + + + + + + {/* Side slides */} + + + اسلایدهای جانبی + + append({ alt_text: "", url: "", thumbnail: "" } as HeroImage) + } + > + افزودن اسلاید + + + + {fields.map((field, index) => ( + + remove(index)} + > + + + + + + + ))} + + + + + ذخیره تغییرات + + + + + ); +}; + +export default HeroSliderPage; diff --git a/src/pages/landing-hero/core/_hooks.ts b/src/pages/landing-hero/core/_hooks.ts new file mode 100644 index 0000000..1485cd2 --- /dev/null +++ b/src/pages/landing-hero/core/_hooks.ts @@ -0,0 +1,30 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { QUERY_KEYS } from "@/utils/query-key"; +import { getLandingHero, updateLandingHero } from "./_requests"; +import { LandingHeroData } from "./_models"; +import toast from "react-hot-toast"; + +export const useLandingHero = () => { + return useQuery({ + queryKey: [QUERY_KEYS.GET_LANDING_HERO], + queryFn: getLandingHero, + }); +}; + +export const useUpdateLandingHero = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationKey: [QUERY_KEYS.UPDATE_LANDING_HERO], + mutationFn: (data: LandingHeroData) => updateLandingHero(data), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [QUERY_KEYS.GET_LANDING_HERO], + }); + toast.success("اسلایدر با موفقیت بهروزرسانی شد"); + }, + onError: (error: any) => { + console.error("Update landing hero error", error); + toast.error(error?.message || "خطا در بهروزرسانی اسلایدر"); + }, + }); +}; diff --git a/src/pages/landing-hero/core/_models.ts b/src/pages/landing-hero/core/_models.ts new file mode 100644 index 0000000..68ec0a1 --- /dev/null +++ b/src/pages/landing-hero/core/_models.ts @@ -0,0 +1,20 @@ +export interface HeroImage { + alt_text: string; + url: string; + thumbnail: string; +} + +export interface LandingHeroData { + main: HeroImage; + side: HeroImage[]; +} + +// Request payloads +export interface UpdateLandingHeroRequest { + data: LandingHeroData; +} + +// Response types +export interface LandingHeroResponse { + data: LandingHeroData; +} diff --git a/src/pages/landing-hero/core/_requests.ts b/src/pages/landing-hero/core/_requests.ts new file mode 100644 index 0000000..da0328b --- /dev/null +++ b/src/pages/landing-hero/core/_requests.ts @@ -0,0 +1,27 @@ +import { + httpGetRequest, + httpPostRequest, + APIUrlGenerator, +} from "@/utils/baseHttpService"; +import { API_ROUTES } from "@/constant/routes"; +import { + LandingHeroResponse, + UpdateLandingHeroRequest, + LandingHeroData, +} from "./_models"; + +export const getLandingHero = async () => { + const response = await httpGetRequest( + APIUrlGenerator(API_ROUTES.GET_LANDING_HERO) + ); + return response.data.data; +}; + +export const updateLandingHero = async (data: LandingHeroData) => { + const payload: UpdateLandingHeroRequest = { data }; + const response = await httpPostRequest( + APIUrlGenerator(API_ROUTES.UPDATE_LANDING_HERO), + payload + ); + return response.data.data; +}; diff --git a/src/utils/query-key.ts b/src/utils/query-key.ts index 0cd271d..3cbd9a1 100644 --- a/src/utils/query-key.ts +++ b/src/utils/query-key.ts @@ -67,4 +67,8 @@ export const QUERY_KEYS = { CREATE_IMAGE: "create_image", UPDATE_IMAGE: "update_image", DELETE_IMAGE: "delete_image", + + // Landing Hero + GET_LANDING_HERO: "get_landing_hero", + UPDATE_LANDING_HERO: "update_landing_hero", };