slider
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: [] };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user