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 ProductFormPage = lazy(() => import('./pages/products/product-form/ProductFormPage'));
|
||||||
const ProductDetailPage = lazy(() => import('./pages/products/product-detail/ProductDetailPage'));
|
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 ProtectedRoute = ({ children }: { children: any }) => {
|
||||||
const { user, isLoading } = useAuth();
|
const { user, isLoading } = useAuth();
|
||||||
|
|
||||||
|
|
@ -106,6 +109,9 @@ const AppRoutes = () => {
|
||||||
<Route path="categories/create" element={<CategoryFormPage />} />
|
<Route path="categories/create" element={<CategoryFormPage />} />
|
||||||
<Route path="categories/:id/edit" element={<CategoryFormPage />} />
|
<Route path="categories/:id/edit" element={<CategoryFormPage />} />
|
||||||
|
|
||||||
|
{/* Landing Hero Route */}
|
||||||
|
<Route path="landing-hero" element={<HeroSliderPage />} />
|
||||||
|
|
||||||
{/* Products Routes */}
|
{/* Products Routes */}
|
||||||
<Route path="products/create" element={<ProductFormPage />} />
|
<Route path="products/create" element={<ProductFormPage />} />
|
||||||
<Route path="products/:id" element={<ProductDetailPage />} />
|
<Route path="products/:id" element={<ProductDetailPage />} />
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,11 @@ const menuItems: MenuItem[] = [
|
||||||
path: '/permissions',
|
path: '/permissions',
|
||||||
permission: 22,
|
permission: 22,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'اسلایدر لندینگ',
|
||||||
|
icon: Sliders,
|
||||||
|
path: '/landing-hero',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -76,4 +76,8 @@ export const API_ROUTES = {
|
||||||
CREATE_IMAGE: "api/v1/images",
|
CREATE_IMAGE: "api/v1/images",
|
||||||
UPDATE_IMAGE: (imageId: string) => `api/v1/products/images/${imageId}`,
|
UPDATE_IMAGE: (imageId: string) => `api/v1/products/images/${imageId}`,
|
||||||
DELETE_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",
|
CREATE_IMAGE: "create_image",
|
||||||
UPDATE_IMAGE: "update_image",
|
UPDATE_IMAGE: "update_image",
|
||||||
DELETE_IMAGE: "delete_image",
|
DELETE_IMAGE: "delete_image",
|
||||||
|
|
||||||
|
// Landing Hero
|
||||||
|
GET_LANDING_HERO: "get_landing_hero",
|
||||||
|
UPDATE_LANDING_HERO: "update_landing_hero",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue