feat: add landing hero page and related routes in App, Sidebar, and API constants
This commit is contained in:
parent
d10b52924c
commit
025d9de1d7
|
|
@ -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 = () => {
|
|||
<Route path="categories/create" element={<CategoryFormPage />} />
|
||||
<Route path="categories/:id/edit" element={<CategoryFormPage />} />
|
||||
|
||||
{/* Landing Hero Route */}
|
||||
<Route path="landing-hero" element={<HeroSliderPage />} />
|
||||
|
||||
{/* Products Routes */}
|
||||
<Route path="products/create" element={<ProductFormPage />} />
|
||||
<Route path="products/:id" element={<ProductDetailPage />} />
|
||||
|
|
|
|||
|
|
@ -75,6 +75,11 @@ const menuItems: MenuItem[] = [
|
|||
path: '/permissions',
|
||||
permission: 22,
|
||||
},
|
||||
{
|
||||
title: 'اسلایدر لندینگ',
|
||||
icon: Sliders,
|
||||
path: '/landing-hero',
|
||||
},
|
||||
]
|
||||
}
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<LandingHeroData>({
|
||||
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 (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-6">مدیریت اسلایدر صفحه اصلی</h1>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
|
||||
{/* Main slide */}
|
||||
<div className="card p-6 space-y-6">
|
||||
<h2 className="text-lg font-semibold">اسلاید اصلی</h2>
|
||||
<Input
|
||||
label="ALT Text"
|
||||
{...control.register("main.alt_text" as const)}
|
||||
error={(errors as any).main?.alt_text?.message}
|
||||
/>
|
||||
|
||||
<FileUploader
|
||||
onUpload={handleMainFileUpload}
|
||||
onRemove={handleMainFileRemove}
|
||||
acceptedTypes={["image/*"]}
|
||||
maxFileSize={5 * 1024 * 1024}
|
||||
maxFiles={1}
|
||||
mode="single"
|
||||
label="تصویر اسلاید"
|
||||
description="تصویر را انتخاب کنید (حداکثر 5MB)"
|
||||
error={(errors as any).main?.url?.message}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Side slides */}
|
||||
<div className="card p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">اسلایدهای جانبی</h2>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
append({ alt_text: "", url: "", thumbnail: "" } as HeroImage)
|
||||
}
|
||||
>
|
||||
<PlusCircle className="h-4 w-4 ml-2" /> افزودن اسلاید
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="border p-4 rounded-lg space-y-4 relative">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-2 left-2 text-red-600"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
<Input
|
||||
label="ALT Text"
|
||||
{...control.register(`side.${index}.alt_text` as const)}
|
||||
error={
|
||||
(errors as any).side?.[index]?.alt_text?.message as string | undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<FileUploader
|
||||
onUpload={handleSideFileUpload(index)}
|
||||
onRemove={handleSideFileRemove(index)}
|
||||
acceptedTypes={["image/*"]}
|
||||
maxFileSize={5 * 1024 * 1024}
|
||||
maxFiles={1}
|
||||
mode="single"
|
||||
label="تصویر اسلاید"
|
||||
description="تصویر را انتخاب کنید (حداکثر 5MB)"
|
||||
error={(errors as any).side?.[index]?.url?.message as string | undefined}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end space-x-4 space-x-reverse">
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isSaving}
|
||||
disabled={!isDirty || !isValid}
|
||||
>
|
||||
<Save className="h-4 w-4 ml-2" /> ذخیره تغییرات
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroSliderPage;
|
||||
|
|
@ -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 || "خطا در بهروزرسانی اسلایدر");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<LandingHeroResponse>(
|
||||
APIUrlGenerator(API_ROUTES.GET_LANDING_HERO)
|
||||
);
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const updateLandingHero = async (data: LandingHeroData) => {
|
||||
const payload: UpdateLandingHeroRequest = { data };
|
||||
const response = await httpPostRequest<LandingHeroResponse>(
|
||||
APIUrlGenerator(API_ROUTES.UPDATE_LANDING_HERO),
|
||||
payload
|
||||
);
|
||||
return response.data.data;
|
||||
};
|
||||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue