This commit is contained in:
hamid zarghami
2026-07-04 10:15:30 +03:30
parent 5921ac0bc7
commit 136acf1f4c
9 changed files with 279 additions and 7 deletions
@@ -0,0 +1,194 @@
"use client";
import type { Slider } from "@/app/[name]/(Main)/types/Types";
import clsx from "clsx";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
type Props = {
sliders: Slider[];
};
const AUTOPLAY_MS = 8000;
function getVisibleSliders(sliders: Slider[]) {
return sliders
.filter((slider) => slider.isActive !== false && slider.imageUrl)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
function SliderSlide({
slider,
priority,
}: {
slider: Slider;
priority?: boolean;
}) {
const image = (
// eslint-disable-next-line @next/next/no-img-element
<img
src={slider.imageUrl}
alt={slider.title || "promo banner"}
loading={priority ? "eager" : "lazy"}
draggable={false}
className="h-full w-full object-cover"
onError={(event) => {
event.currentTarget.src = "/assets/images/food-image.png";
}}
/>
);
const slideContent = (
<div className="relative aspect-[2.4/1] w-full overflow-hidden rounded-2xl">
{image}
{slider.title ? (
<div className="pointer-events-none absolute bottom-6 right-3 max-w-[75%]">
<p className="truncate text-right text-sm font-medium text-white drop-shadow-[0_2px_4px_rgba(0,0,0,0.7)]">
{slider.title}
</p>
</div>
) : null}
</div>
);
if (!slider.link) {
return slideContent;
}
const isExternal = /^https?:\/\//i.test(slider.link);
if (isExternal) {
return (
<a
href={slider.link}
target="_blank"
rel="noopener noreferrer"
className="block w-full shrink-0 snap-center"
aria-label={slider.title || "promo banner"}
>
{slideContent}
</a>
);
}
return (
<Link
href={slider.link}
className="block w-full shrink-0 snap-center"
aria-label={slider.title || "promo banner"}
>
{slideContent}
</Link>
);
}
export default function HomeSliderCarousel({ sliders }: Props) {
const visibleSliders = useMemo(() => getVisibleSliders(sliders), [sliders]);
const scrollRef = useRef<HTMLDivElement>(null);
const slideRefs = useRef<(HTMLDivElement | null)[]>([]);
const autoplayPausedRef = useRef(false);
const [activeIndex, setActiveIndex] = useState(0);
const scrollToIndex = useCallback((index: number) => {
slideRefs.current[index]?.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "start",
});
}, []);
useEffect(() => {
const container = scrollRef.current;
if (!container || visibleSliders.length <= 1) return;
const handleScroll = () => {
const slideWidth = container.clientWidth;
if (!slideWidth) return;
const nextIndex = Math.round(container.scrollLeft / slideWidth);
setActiveIndex(Math.min(nextIndex, visibleSliders.length - 1));
};
container.addEventListener("scroll", handleScroll, { passive: true });
handleScroll();
return () => {
container.removeEventListener("scroll", handleScroll);
};
}, [visibleSliders.length]);
useEffect(() => {
if (visibleSliders.length <= 1) return;
const intervalId = window.setInterval(() => {
if (autoplayPausedRef.current) return;
setActiveIndex((currentIndex) => {
const nextIndex = (currentIndex + 1) % visibleSliders.length;
scrollToIndex(nextIndex);
return nextIndex;
});
}, AUTOPLAY_MS);
return () => {
window.clearInterval(intervalId);
};
}, [scrollToIndex, visibleSliders.length]);
const pauseAutoplay = useCallback(() => {
autoplayPausedRef.current = true;
window.setTimeout(() => {
autoplayPausedRef.current = false;
}, AUTOPLAY_MS * 2);
}, []);
if (visibleSliders.length === 0) {
return null;
}
return (
<section className="mb-1 w-full" aria-label="promotional banners">
<div
ref={scrollRef}
dir="ltr"
className="flex w-full snap-x snap-mandatory overflow-x-auto noscrollbar touch-pan-x"
onTouchStart={pauseAutoplay}
onMouseDown={pauseAutoplay}
>
{visibleSliders.map((slider, index) => (
<div
key={slider.id}
ref={(element) => {
slideRefs.current[index] = element;
}}
className="w-full shrink-0 snap-start"
>
<SliderSlide slider={slider} priority={index === 0} />
</div>
))}
</div>
{visibleSliders.length > 1 ? (
<div className="mt-2 flex items-center justify-center gap-1.5">
{visibleSliders.map((slider, index) => (
<button
key={slider.id}
type="button"
aria-label={`Go to slide ${index + 1}`}
aria-current={index === activeIndex}
onClick={() => {
pauseAutoplay();
setActiveIndex(index);
scrollToIndex(index);
}}
className={clsx(
"h-1.5 rounded-full transition-all duration-200",
index === activeIndex ? "w-5 bg-primary" : "w-1.5 bg-foreground/25",
)}
/>
))}
</div>
) : null}
</section>
);
}
+11 -2
View File
@@ -1,5 +1,6 @@
"use client";
import CategoryScroll from "@/app/[name]/(Main)/components/CategoryScroll";
import HomeSliderCarousel from "@/app/[name]/(Main)/components/HomeSliderCarousel";
import MenuFilterDrawer from "@/app/[name]/(Main)/components/MenuFilterDrawer";
import MenuSkeleton from "@/app/[name]/(Main)/components/MenuSkeleton";
import MenuSortingDrawer from "@/app/[name]/(Main)/components/MenuSortingDrawer";
@@ -16,7 +17,7 @@ import { Candle2 } from "iconsax-react";
import { useQueryState } from "next-usequerystate";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useGetCategories, useGetFoods } from "../hooks/useMenuData";
import { useGetCategories, useGetFoods, useGetSliders } from "../hooks/useMenuData";
import type { Category, Food } from "../types/Types";
const sortings = ["PopularityDescending", "RateDescending", "PriceAscending", "PriceDescending"] as const;
@@ -24,8 +25,10 @@ const sortings = ["PopularityDescending", "RateDescending", "PriceAscending", "P
const MenuIndex = () => {
const { data: foodsData, isPending: foodsPending } = useGetFoods();
const { data: categoriesData, isPending: categoriesPending } = useGetCategories();
const { data: slidersData } = useGetSliders();
const foods = useMemo<Food[]>(() => foodsData?.data || [], [foodsData?.data]);
const categories = useMemo<Category[]>(() => categoriesData?.data || [], [categoriesData?.data]);
const sliders = useMemo(() => slidersData?.data ?? [], [slidersData?.data]);
const isLoading = foodsPending || categoriesPending;
const { t: tCommon } = useTranslation("common");
@@ -162,11 +165,17 @@ const MenuIndex = () => {
}, [selectedCategory, search, selectedIngredients, selectedDeliveryId, foods, sorting]);
if (isLoading) {
return <MenuSkeleton />;
return (
<div className="flex flex-col gap-4 items-center pt-5 mb-8">
<HomeSliderCarousel sliders={sliders} />
<MenuSkeleton embedded />
</div>
);
}
return (
<div className="flex flex-col gap-4 items-center pt-5 mb-8" ref={wrapperRef}>
<HomeSliderCarousel sliders={sliders} />
<div className="w-full">
<SearchBox value={search} placeholder={tCommon("SearchPlaceholder")} onChange={updateSearch} />
<CategoryScroll categories={categories} selectedCategory={selectedCategory} onSelect={updateCategory} />
@@ -3,10 +3,15 @@
import MenuItemRenderer from "@/components/listview/MenuItemRenderer";
import VerticalScrollView from "@/components/listview/VerticalScrollView";
import { Skeleton } from "@/components/ui/skeleton";
import clsx from "clsx";
const MenuSkeleton = () => {
type Props = {
embedded?: boolean;
};
const MenuSkeleton = ({ embedded = false }: Props) => {
return (
<div className="flex flex-col gap-4 items-center pt-5 mb-8">
<div className={clsx("flex flex-col gap-4 items-center w-full", !embedded && "pt-5 mb-8")}>
<div className="w-full">
<Skeleton className="h-12 w-full rounded-xl mb-4" />
<div className="flex gap-3 overflow-x-hidden py-4">
@@ -67,3 +67,14 @@ export const useGetNotificationsCount = () => {
enabled: hasAuthToken(),
});
};
export const useGetSliders = () => {
const { name } = useParams<{ name: string }>();
return useQuery({
queryKey: ["sliders", name],
queryFn: () => api.getSliders(name),
enabled: !!name,
staleTime: 5 * 60_000,
refetchOnMount: "always",
});
};
@@ -3,6 +3,7 @@ import {
CategoriesResponse,
FoodsResponse,
NotificationsCountResponse,
SlidersResponse,
} from "../types/Types";
export const getFoods = async (slug: string): Promise<FoodsResponse> => {
@@ -29,3 +30,14 @@ export const getNotificationsCount = async (): Promise<
);
return response.data;
};
export const getSliders = async (slug: string): Promise<SlidersResponse> => {
try {
const response = await api.get<SlidersResponse>(
`/public/sliders/restaurant/${slug}`,
);
return response.data;
} catch {
return { success: true, data: [] };
}
};
+14
View File
@@ -104,3 +104,17 @@ export interface NotificationsCountData {
}
export type NotificationsCountResponse = BaseResponse<NotificationsCountData>;
export interface Slider {
id: string;
createdAt?: string;
updatedAt?: string;
deletedAt?: string | null;
imageUrl: string;
title?: string | null;
link?: string | null;
order?: number;
isActive?: boolean;
}
export type SlidersResponse = BaseResponse<Slider[]>;
+5
View File
@@ -3,6 +3,7 @@ import {
fetchAboutResponse,
fetchCategoriesResponse,
fetchFoodsResponse,
fetchSlidersResponse,
} from "./serverMenuFetch";
export async function prefetchMenuPageData(
@@ -22,5 +23,9 @@ export async function prefetchMenuPageData(
queryKey: ["categories", name],
queryFn: () => fetchCategoriesResponse(name),
}),
queryClient.prefetchQuery({
queryKey: ["sliders", name],
queryFn: () => fetchSlidersResponse(name),
}),
]);
}
+19 -1
View File
@@ -1,7 +1,11 @@
import axios from "axios";
import { API_BASE_URL } from "@/config/const";
import type { AboutResponse } from "../(Main)/about/types/Types";
import type { CategoriesResponse, FoodsResponse } from "../(Main)/types/Types";
import type {
CategoriesResponse,
FoodsResponse,
SlidersResponse,
} from "../(Main)/types/Types";
import { getRestaurant, RESTAURANT_FETCH_TIMEOUT_MS } from "./getRestaurant";
function createServerAxios(slug: string) {
@@ -37,3 +41,17 @@ export async function fetchCategoriesResponse(
);
return data;
}
export async function fetchSlidersResponse(
slug: string,
): Promise<SlidersResponse> {
try {
const client = createServerAxios(slug);
const { data } = await client.get<SlidersResponse>(
`/public/sliders/restaurant/${slug}`,
);
return data;
} catch {
return { success: true, data: [] };
}
}
+6 -2
View File
@@ -1,5 +1,9 @@
// export const API_BASE_URL = "https://dmenuplus-api-production.dev.danakcorp.com";
export const API_BASE_URL = "https://dmenu-api.danakcorp.com";
// export const API_BASE_URL = "http://192.168.99.131:2000";
// export const API_BASE_URL = "https://dmenu-api.danakcorp.com";
export const API_BASE_URL =
process.env.NEXT_PUBLIC_API_URL ??
(process.env.NODE_ENV === "development"
? "http://192.168.99.131:2000"
: "https://dmenu-api.danakcorp.com");
export const TOKEN_NAME = "dmenu-t";
export const REFRESH_TOKEN_NAME = "dmenu-rt";