add banner of home page

This commit is contained in:
2026-02-05 12:32:20 +03:30
parent 2dbaf6790e
commit cf51b14681
28 changed files with 762 additions and 9 deletions

View File

@@ -0,0 +1,79 @@
import { MarkerIcon } from "@/assets";
import { LatLng } from "@/stores/LocationStore";
import { Marker, MarkerDragEvent } from "@vis.gl/react-maplibre";
import { AnimatePresence, motion } from "framer-motion";
import { useState } from "react";
type Props = {
location: LatLng;
onDragEnd: (e: MarkerDragEvent) => void;
};
const dropInVariants = {
initial: { y: -50, opacity: 0, scale: 0.8 },
animate: {
y: 0,
opacity: 1,
scale: 1,
transition: {
type: "spring" as const,
stiffness: 600,
damping: 30,
},
},
};
export default function LocationMarker({ location, onDragEnd }: Props) {
const [isDragging, setIsDragging] = useState(false);
const handleDragStart = () => {
setIsDragging(true);
};
const handleDragEnd = (e: MarkerDragEvent) => {
setIsDragging(false);
onDragEnd(e);
};
return (
<Marker
latitude={location.lat}
longitude={location.lng}
draggable
anchor="bottom"
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<AnimatePresence mode="wait">
<motion.div
variants={dropInVariants}
initial="initial"
animate={{
y: isDragging ? -20 : 0,
opacity: 1,
transition: {
type: "spring" as const,
stiffness: 500,
damping: 25,
},
}}
exit="exit"
className="relative flex flex-col items-center"
>
{/* Drop pointer inside the marker (shown only when dragging) */}
{isDragging && (
<motion.div
layoutId="drop-pointer"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 0.5, y: 10 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
className="absolute -bottom-4.5 z-[-1] size-1.5 rounded-full bg-black shadow-md"
/>
)}
<MarkerIcon className="text-neo-aqua size-8" />
</motion.div>
</AnimatePresence>
</Marker>
);
}

View File

@@ -0,0 +1,16 @@
"use client"
import MainMap from "@/components/MainMap";
import LocationMarker from "./LocationMarker";
const LocationModal = () => {
return (
<div style={{ width: "100%", height: "100dvh", position: "relative" }}>
<MainMap>
<LocationMarker location={{lat : 36.541, lng : 54.56}} onDragEnd={()=> {
console.log("hello")}} />
</MainMap>
</div>
);
};
export default LocationModal;

View File

@@ -0,0 +1,36 @@
import SearchItem from "./SearchItem";
import { CalenderIcon, LocationIcon, SearchIcon } from "@/assets";
import { useModalStore } from "@/stores/useModalStore";
import LocationModal from "./LocationModal";
export default function SearchBar() {
const openModal = useModalStore((s) => s.openModal);
return (
<div className="bg-card flex max-w-2xl items-center justify-between gap-1 rounded-full p-4">
{/* آدرس */}
<SearchItem icon={<LocationIcon className="size-5" />}>
<button
type="button"
onClick={() => openModal(<LocationModal />)}
className="w-full text-right text-gray-500"
>
آدرس
</button>
</SearchItem>
{/* تاریخ */}
<SearchItem icon={<CalenderIcon className="size-5" />}>
<input type="date" className="w-full bg-transparent text-gray-500 outline-none" />
</SearchItem>
{/* دسته بندی */}
<SearchItem icon={<SearchIcon className="size-5" />}>
<select className="w-full bg-transparent text-gray-500 outline-none">
<option>دسته بندی</option>
<option>رستوران</option>
<option>کافیشاپ</option>
</select>
</SearchItem>
</div>
);
}

View File

@@ -0,0 +1,15 @@
interface SearchItemProps {
icon: React.ReactNode;
children: React.ReactNode;
}
function SearchItem({ icon, children }: SearchItemProps) {
return (
<div className="flex min-w-[180px] items-center gap-2 rounded-full bg-white px-4 py-3 shadow-sm">
<div className="text-gray-400">{icon}</div>
<div className="flex-1">{children}</div>
</div>
);
}
export default SearchItem;

View File

@@ -0,0 +1,169 @@
"use client";
import { ArrowTopLeft, AvatarIcon, BannerImage, BannerLine, BannerStar, LogoIcon, MyOrderIcon } from "@/assets";
import { useTranslations } from "next-intl";
import userStore from "@/stores/userStore";
import Image from "next/image";
import ThemeToggle from "@/components/ThemeToggle";
import { motion } from "framer-motion";
import GroupAvatar from "@/components/UI/GroupAvatar";
import SearchBar from "@/components/Home/Desktop/Banner/SearchBar";
export default function BannerComponent() {
const t = useTranslations("HomePage");
const isAuth = userStore((s) => s.isAuth);
return (
<motion.div
initial={{ opacity: 0, y: 60 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.9, ease: "easeOut" }}
className="bg-home-page-banner relative mx-16 mt-16 rounded-tr-[200px] rounded-bl-[200px] px-2 pt-2"
>
{/* ================= Header ================= */}
<header className="flex w-full items-center justify-center pt-8">
<div className="flex w-full items-center justify-center space-x-5">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 200 }}
>
<LogoIcon className="size-12" />
</motion.div>
<ThemeToggle />
{["home", "search", "salons", "about_us", "contact_us"].map((item, index) => (
<motion.p
key={item}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 * index }}
className="text-text-primary hover:text-primary-200 cursor-pointer transition"
>
{t(item)}
</motion.p>
))}
</div>
<div className="flex w-full items-center justify-center">
{isAuth ? (
<motion.div whileHover={{ scale: 1.1 }} className="cursor-pointer">
<AvatarIcon className="h-6 w-6" />
</motion.div>
) : (
<span className="flex w-full items-center justify-center gap-x-2">
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="bg-bg flex items-center justify-center gap-x-2 rounded-lg p-3"
>
<MyOrderIcon className="h-6 w-6" />
<p>{t("my_order")}</p>
</motion.button>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="bg-primary-200 flex items-center justify-center gap-x-2 rounded-lg p-3"
>
<AvatarIcon className="h-6 w-6" />
<p>{t("auth")}</p>
</motion.button>
</span>
)}
</div>
</header>
{/* ================= Main ================= */}
<motion.main initial="hidden" animate="visible" className="flex w-full items-center justify-center py-12">
{/* ===== Left Content ===== */}
<div className="flex w-full flex-col items-center justify-center space-y-7">
{/* Title */}
<motion.div className="text-4xl font-bold">
<motion.span
variants={{
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
}}
className="block"
>
{t("main_title_line1")}
</motion.span>
<motion.div
variants={{
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
}}
className="flex items-center justify-between"
>
{t("main_title_line2")}
<motion.div
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ delay: 0.6, duration: 0.4 }}
className="origin-right"
>
<BannerLine className="h-1 w-28" />
</motion.div>
<motion.div
animate={{ rotate: [0, 10, -10, 0] }}
transition={{ repeat: Infinity, duration: 3 }}
>
<BannerStar className="h-6 w-6" />
</motion.div>
</motion.div>
</motion.div>
{/* Description */}
<motion.p
variants={{
hidden: { opacity: 0 },
visible: { opacity: 1 },
}}
className="text-center text-xs"
>
{t("des")}
</motion.p>
{/* CTA Button */}
<motion.button
whileHover={{ scale: 1.07 }}
whileTap={{ scale: 0.95 }}
transition={{ type: "spring", stiffness: 300 }}
className="bg-primary-200 flex items-center justify-center gap-x-3 rounded-lg p-3"
>
<p className="text-white">{t("see_salons")}</p>
<motion.div whileHover={{ x: -4, y: -4 }}>
<ArrowTopLeft className="size-5 text-white" />
</motion.div>
</motion.button>
<GroupAvatar />
</div>
{/* ===== Right Image ===== */}
<motion.div
animate={{ y: [0, -12, 0] }}
transition={{ repeat: Infinity, duration: 4, ease: "easeInOut" }}
className="flex w-full items-center justify-center"
>
<Image
src={BannerImage}
alt="main_image"
className="object-cover"
width={400}
height={200}
priority
/>
</motion.div>
</motion.main>
<div className="absolute -bottom-10 left-24">
<SearchBar />
</div>
</motion.div>
);
}

View File

@@ -1,3 +1,9 @@
import BannerComponent from "./Banner";
export default function DesktopHome() {
return <></>;
return (
<div className="container mx-auto h-full w-full">
<BannerComponent />
</div>
);
}

View File

@@ -0,0 +1,113 @@
"use client"
import { Map, MapLayerMouseEvent } from "@vis.gl/react-maplibre";
import { ReactNode, useMemo } from "react";
import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import { useMapMove } from "@/utils/useMapMove";
import { useMapLayersStore } from "@/stores/useMapLayersStore";
import { useMapStore } from "@/stores/useMapStore";
// Read From Environment
const BRIGHT_MAP_TILE = process.env.NEXT_PUBLIC_MAP_BRIGHT!;
const DARK_MAP_TILE = process.env.NEXT_PUBLIC_MAP_DARK!;
const STORAGE_KEY = process.env.NEXT_PUBLIC_MAP_VIEW_LOCAL_STORAGE_KEY!;
const MIN_ZOOM = parseFloat(process.env.NEXT_PUBLIC_MAP_MIN_ZOOM!);
const MAX_ZOOM = parseFloat(process.env.NEXT_PUBLIC_MAP_MAX_ZOOM!);
type MainMapProps = {
id?: string;
mapLayer?: string;
children?: ReactNode;
initialBound?: [number, number];
initialZoom?: number;
ref?: React.Ref<any>;
interactiveLayerIds?: string[];
handleMapClick?: (e: maplibregl.MapLayerMouseEvent) => void;
handleContextMenu?: (e: maplibregl.MapMouseEvent & maplibregl.Event) => void;
handleMapInteraction?: () => void;
onMouseMove?: (e: MapLayerMouseEvent) => void;
onMouseLeave?: () => void;
cursor?: string | null;
};
type MapView = {
longitude: number;
latitude: number;
zoom: number;
};
export default function MainMap({
id,
ref,
onMouseLeave,
onMouseMove,
children,
initialBound = [34.5468992, 52.7300532],
initialZoom = 5.5,
handleMapClick,
interactiveLayerIds,
mapLayer,
handleContextMenu,
handleMapInteraction,
cursor,
}: MainMapProps) {
const { mapMove } = useMapMove();
const activeLayer = useMapLayersStore((s) => s.activeLayer);
const setIsMapMoving = useMapStore((s) => s.setIsMapMoving);
const parsed = useMemo(() => {
if (typeof window === "undefined") return null;
const saved = localStorage.getItem(STORAGE_KEY);
return saved ? (JSON.parse(saved) as MapView) : null;
}, []);
const MAP_STYLES = useMemo(
() => ({
dark: DARK_MAP_TILE,
bright: BRIGHT_MAP_TILE,
}),
[]
);
return (
<div className="relative h-full w-full">
<Map
ref={ref}
id={id ? id : "homePageMap"}
interactiveLayerIds={interactiveLayerIds}
initialViewState={{
longitude: parsed ? parsed.longitude : initialBound[1],
latitude: parsed ? parsed.latitude : initialBound[0],
zoom: parsed ? parsed.zoom : initialZoom,
}}
onError={(e) => {
console.error("Map error:", e);
}}
maxBounds={[
[35.0, 18.0], // west, south
[74.5, 44.0], // east, north
]}
onMoveStart={() => setIsMapMoving(true)}
RTLTextPlugin={"/mapbox-gl-rtl-text.js"}
onDragStart={handleMapInteraction}
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
onZoomStart={handleMapInteraction}
onMoveEnd={(e) => {
setIsMapMoving(false);
mapMove(e);
}}
onClick={handleMapClick}
onContextMenu={handleContextMenu}
cursor={cursor === "default" ? undefined : "auto"}
mapStyle={mapLayer ? mapLayer : MAP_STYLES[activeLayer]}
minZoom={MIN_ZOOM}
maxZoom={MAX_ZOOM}
attributionControl={false}
style={{ width: "100%", height: "100%" }}
>
{children}
</Map>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import { Avatar, AvatarFallback, AvatarImage } from "@/components/UI/avatar";
export default function GroupAvatar() {
return (
<div className="bg-card flex -space-x-[0.6rem] rounded-2xl rounded-bl-none p-3">
<Avatar className="ring-primary-200 ring-1">
<AvatarImage
alt="U1"
src="https://images.unsplash.com/photo-1543610892-0b1f7e6d8ac1?w=96&h=96&dpr=2&q=80"
/>
<AvatarFallback>U1</AvatarFallback>
</Avatar>
<Avatar className="ring-primary-200 ring-1">
<AvatarImage
alt="U2"
src="https://images.unsplash.com/photo-1628157588553-5eeea00af15c?w=96&h=96&dpr=2&q=80"
/>
<AvatarFallback>U2</AvatarFallback>
</Avatar>
<Avatar className="ring-primary-200 ring-1">
<AvatarImage
alt="U3"
src="https://images.unsplash.com/photo-1655874819398-c6dfbec68ac7?w=96&h=96&dpr=2&q=80"
/>
<AvatarFallback>U3</AvatarFallback>
</Avatar>
<Avatar className="ring-primary-200 ring-1">
<AvatarImage
alt="U3"
src="https://images.unsplash.com/photo-1655874819398-c6dfbec68ac7?w=96&h=96&dpr=2&q=80"
/>
<AvatarFallback>U3</AvatarFallback>
</Avatar>
</div>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
import { cn } from "@/lib/utils";
function Avatar({ className, ...props }: AvatarPrimitive.Root.Props) {
return (
<AvatarPrimitive.Root
className={cn(
"bg-background inline-flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full align-middle text-xs font-medium select-none",
className
)}
data-slot="avatar"
{...props}
/>
);
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
className={cn("size-full object-cover", className)}
data-slot="avatar-image"
{...props}
/>
);
}
function AvatarFallback({ className, ...props }: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
className={cn("bg-muted flex size-full items-center justify-center rounded-full", className)}
data-slot="avatar-fallback"
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback };