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";
|
"use client";
|
||||||
import CategoryScroll from "@/app/[name]/(Main)/components/CategoryScroll";
|
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 MenuFilterDrawer from "@/app/[name]/(Main)/components/MenuFilterDrawer";
|
||||||
import MenuSkeleton from "@/app/[name]/(Main)/components/MenuSkeleton";
|
import MenuSkeleton from "@/app/[name]/(Main)/components/MenuSkeleton";
|
||||||
import MenuSortingDrawer from "@/app/[name]/(Main)/components/MenuSortingDrawer";
|
import MenuSortingDrawer from "@/app/[name]/(Main)/components/MenuSortingDrawer";
|
||||||
@@ -16,7 +17,7 @@ import { Candle2 } from "iconsax-react";
|
|||||||
import { useQueryState } from "next-usequerystate";
|
import { useQueryState } from "next-usequerystate";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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";
|
import type { Category, Food } from "../types/Types";
|
||||||
|
|
||||||
const sortings = ["PopularityDescending", "RateDescending", "PriceAscending", "PriceDescending"] as const;
|
const sortings = ["PopularityDescending", "RateDescending", "PriceAscending", "PriceDescending"] as const;
|
||||||
@@ -24,8 +25,10 @@ const sortings = ["PopularityDescending", "RateDescending", "PriceAscending", "P
|
|||||||
const MenuIndex = () => {
|
const MenuIndex = () => {
|
||||||
const { data: foodsData, isPending: foodsPending } = useGetFoods();
|
const { data: foodsData, isPending: foodsPending } = useGetFoods();
|
||||||
const { data: categoriesData, isPending: categoriesPending } = useGetCategories();
|
const { data: categoriesData, isPending: categoriesPending } = useGetCategories();
|
||||||
|
const { data: slidersData } = useGetSliders();
|
||||||
const foods = useMemo<Food[]>(() => foodsData?.data || [], [foodsData?.data]);
|
const foods = useMemo<Food[]>(() => foodsData?.data || [], [foodsData?.data]);
|
||||||
const categories = useMemo<Category[]>(() => categoriesData?.data || [], [categoriesData?.data]);
|
const categories = useMemo<Category[]>(() => categoriesData?.data || [], [categoriesData?.data]);
|
||||||
|
const sliders = useMemo(() => slidersData?.data ?? [], [slidersData?.data]);
|
||||||
|
|
||||||
const isLoading = foodsPending || categoriesPending;
|
const isLoading = foodsPending || categoriesPending;
|
||||||
const { t: tCommon } = useTranslation("common");
|
const { t: tCommon } = useTranslation("common");
|
||||||
@@ -162,11 +165,17 @@ const MenuIndex = () => {
|
|||||||
}, [selectedCategory, search, selectedIngredients, selectedDeliveryId, foods, sorting]);
|
}, [selectedCategory, search, selectedIngredients, selectedDeliveryId, foods, sorting]);
|
||||||
|
|
||||||
if (isLoading) {
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-4 items-center pt-5 mb-8" ref={wrapperRef}>
|
<div className="flex flex-col gap-4 items-center pt-5 mb-8" ref={wrapperRef}>
|
||||||
|
<HomeSliderCarousel sliders={sliders} />
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<SearchBox value={search} placeholder={tCommon("SearchPlaceholder")} onChange={updateSearch} />
|
<SearchBox value={search} placeholder={tCommon("SearchPlaceholder")} onChange={updateSearch} />
|
||||||
<CategoryScroll categories={categories} selectedCategory={selectedCategory} onSelect={updateCategory} />
|
<CategoryScroll categories={categories} selectedCategory={selectedCategory} onSelect={updateCategory} />
|
||||||
|
|||||||
@@ -3,10 +3,15 @@
|
|||||||
import MenuItemRenderer from "@/components/listview/MenuItemRenderer";
|
import MenuItemRenderer from "@/components/listview/MenuItemRenderer";
|
||||||
import VerticalScrollView from "@/components/listview/VerticalScrollView";
|
import VerticalScrollView from "@/components/listview/VerticalScrollView";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
const MenuSkeleton = () => {
|
type Props = {
|
||||||
|
embedded?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MenuSkeleton = ({ embedded = false }: Props) => {
|
||||||
return (
|
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">
|
<div className="w-full">
|
||||||
<Skeleton className="h-12 w-full rounded-xl mb-4" />
|
<Skeleton className="h-12 w-full rounded-xl mb-4" />
|
||||||
<div className="flex gap-3 overflow-x-hidden py-4">
|
<div className="flex gap-3 overflow-x-hidden py-4">
|
||||||
|
|||||||
@@ -67,3 +67,14 @@ export const useGetNotificationsCount = () => {
|
|||||||
enabled: hasAuthToken(),
|
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,
|
CategoriesResponse,
|
||||||
FoodsResponse,
|
FoodsResponse,
|
||||||
NotificationsCountResponse,
|
NotificationsCountResponse,
|
||||||
|
SlidersResponse,
|
||||||
} from "../types/Types";
|
} from "../types/Types";
|
||||||
|
|
||||||
export const getFoods = async (slug: string): Promise<FoodsResponse> => {
|
export const getFoods = async (slug: string): Promise<FoodsResponse> => {
|
||||||
@@ -29,3 +30,14 @@ export const getNotificationsCount = async (): Promise<
|
|||||||
);
|
);
|
||||||
return response.data;
|
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 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,
|
fetchAboutResponse,
|
||||||
fetchCategoriesResponse,
|
fetchCategoriesResponse,
|
||||||
fetchFoodsResponse,
|
fetchFoodsResponse,
|
||||||
|
fetchSlidersResponse,
|
||||||
} from "./serverMenuFetch";
|
} from "./serverMenuFetch";
|
||||||
|
|
||||||
export async function prefetchMenuPageData(
|
export async function prefetchMenuPageData(
|
||||||
@@ -22,5 +23,9 @@ export async function prefetchMenuPageData(
|
|||||||
queryKey: ["categories", name],
|
queryKey: ["categories", name],
|
||||||
queryFn: () => fetchCategoriesResponse(name),
|
queryFn: () => fetchCategoriesResponse(name),
|
||||||
}),
|
}),
|
||||||
|
queryClient.prefetchQuery({
|
||||||
|
queryKey: ["sliders", name],
|
||||||
|
queryFn: () => fetchSlidersResponse(name),
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { API_BASE_URL } from "@/config/const";
|
import { API_BASE_URL } from "@/config/const";
|
||||||
import type { AboutResponse } from "../(Main)/about/types/Types";
|
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";
|
import { getRestaurant, RESTAURANT_FETCH_TIMEOUT_MS } from "./getRestaurant";
|
||||||
|
|
||||||
function createServerAxios(slug: string) {
|
function createServerAxios(slug: string) {
|
||||||
@@ -37,3 +41,17 @@ export async function fetchCategoriesResponse(
|
|||||||
);
|
);
|
||||||
return data;
|
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://dmenuplus-api-production.dev.danakcorp.com";
|
||||||
export const API_BASE_URL = "https://dmenu-api.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 =
|
||||||
|
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 TOKEN_NAME = "dmenu-t";
|
||||||
export const REFRESH_TOKEN_NAME = "dmenu-rt";
|
export const REFRESH_TOKEN_NAME = "dmenu-rt";
|
||||||
|
|||||||
Reference in New Issue
Block a user