feat: add landing hero page and related routes in App, Sidebar, and API constants

This commit is contained in:
hosseintaromi 2025-08-01 20:06:31 +03:30
parent d10b52924c
commit 025d9de1d7
8 changed files with 282 additions and 0 deletions

View File

@ -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 />} />

View File

@ -75,6 +75,11 @@ const menuItems: MenuItem[] = [
path: '/permissions',
permission: 22,
},
{
title: 'اسلایدر لندینگ',
icon: Sliders,
path: '/landing-hero',
},
]
}
];

View File

@ -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",
};

View File

@ -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;

View File

@ -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 || "خطا در به‌روزرسانی اسلایدر");
},
});
};

View File

@ -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;
}

View File

@ -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;
};

View File

@ -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",
};