diff --git a/src/app/[name]/(Main)/components/HomeSliderCarousel.tsx b/src/app/[name]/(Main)/components/HomeSliderCarousel.tsx new file mode 100644 index 0000000..c6f4d11 --- /dev/null +++ b/src/app/[name]/(Main)/components/HomeSliderCarousel.tsx @@ -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 + {slider.title { + event.currentTarget.src = "/assets/images/food-image.png"; + }} + /> + ); + + const slideContent = ( +
+ {image} + {slider.title ? ( +
+

+ {slider.title} +

+
+ ) : null} +
+ ); + + if (!slider.link) { + return slideContent; + } + + const isExternal = /^https?:\/\//i.test(slider.link); + + if (isExternal) { + return ( + + {slideContent} + + ); + } + + return ( + + {slideContent} + + ); +} + +export default function HomeSliderCarousel({ sliders }: Props) { + const visibleSliders = useMemo(() => getVisibleSliders(sliders), [sliders]); + const scrollRef = useRef(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 ( +
+
+ {visibleSliders.map((slider, index) => ( +
{ + slideRefs.current[index] = element; + }} + className="w-full shrink-0 snap-start" + > + +
+ ))} +
+ + {visibleSliders.length > 1 ? ( +
+ {visibleSliders.map((slider, index) => ( +
+ ) : null} +
+ ); +} diff --git a/src/app/[name]/(Main)/components/MenuIndex.tsx b/src/app/[name]/(Main)/components/MenuIndex.tsx index 632cb7c..7c62b16 100644 --- a/src/app/[name]/(Main)/components/MenuIndex.tsx +++ b/src/app/[name]/(Main)/components/MenuIndex.tsx @@ -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(() => foodsData?.data || [], [foodsData?.data]); const categories = useMemo(() => 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 ; + return ( +
+ + +
+ ); } return (
+
diff --git a/src/app/[name]/(Main)/components/MenuSkeleton.tsx b/src/app/[name]/(Main)/components/MenuSkeleton.tsx index 7323da7..13a3c74 100644 --- a/src/app/[name]/(Main)/components/MenuSkeleton.tsx +++ b/src/app/[name]/(Main)/components/MenuSkeleton.tsx @@ -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 ( -
+
diff --git a/src/app/[name]/(Main)/hooks/useMenuData.ts b/src/app/[name]/(Main)/hooks/useMenuData.ts index 9014294..6858d65 100644 --- a/src/app/[name]/(Main)/hooks/useMenuData.ts +++ b/src/app/[name]/(Main)/hooks/useMenuData.ts @@ -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", + }); +}; diff --git a/src/app/[name]/(Main)/service/MenuService.ts b/src/app/[name]/(Main)/service/MenuService.ts index 356d758..67e8820 100644 --- a/src/app/[name]/(Main)/service/MenuService.ts +++ b/src/app/[name]/(Main)/service/MenuService.ts @@ -3,6 +3,7 @@ import { CategoriesResponse, FoodsResponse, NotificationsCountResponse, + SlidersResponse, } from "../types/Types"; export const getFoods = async (slug: string): Promise => { @@ -29,3 +30,14 @@ export const getNotificationsCount = async (): Promise< ); return response.data; }; + +export const getSliders = async (slug: string): Promise => { + try { + const response = await api.get( + `/public/sliders/restaurant/${slug}`, + ); + return response.data; + } catch { + return { success: true, data: [] }; + } +}; diff --git a/src/app/[name]/(Main)/types/Types.ts b/src/app/[name]/(Main)/types/Types.ts index 242b5af..ed58424 100644 --- a/src/app/[name]/(Main)/types/Types.ts +++ b/src/app/[name]/(Main)/types/Types.ts @@ -104,3 +104,17 @@ export interface NotificationsCountData { } export type NotificationsCountResponse = BaseResponse; + +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; diff --git a/src/app/[name]/lib/prefetchMenuPage.ts b/src/app/[name]/lib/prefetchMenuPage.ts index 48eb405..15b0a1a 100644 --- a/src/app/[name]/lib/prefetchMenuPage.ts +++ b/src/app/[name]/lib/prefetchMenuPage.ts @@ -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), + }), ]); } diff --git a/src/app/[name]/lib/serverMenuFetch.ts b/src/app/[name]/lib/serverMenuFetch.ts index 942c2d1..259f9b5 100644 --- a/src/app/[name]/lib/serverMenuFetch.ts +++ b/src/app/[name]/lib/serverMenuFetch.ts @@ -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 { + try { + const client = createServerAxios(slug); + const { data } = await client.get( + `/public/sliders/restaurant/${slug}`, + ); + return data; + } catch { + return { success: true, data: [] }; + } +} diff --git a/src/config/const.ts b/src/config/const.ts index c7007b2..be8101a 100644 --- a/src/config/const.ts +++ b/src/config/const.ts @@ -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";