add banner of home page
This commit is contained in:
79
src/components/Home/Desktop/Banner/LocationMarker.tsx
Normal file
79
src/components/Home/Desktop/Banner/LocationMarker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/components/Home/Desktop/Banner/LocationModal.tsx
Normal file
16
src/components/Home/Desktop/Banner/LocationModal.tsx
Normal 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;
|
||||
36
src/components/Home/Desktop/Banner/SearchBar.tsx
Normal file
36
src/components/Home/Desktop/Banner/SearchBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
src/components/Home/Desktop/Banner/SearchItem.tsx
Normal file
15
src/components/Home/Desktop/Banner/SearchItem.tsx
Normal 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;
|
||||
169
src/components/Home/Desktop/Banner/index.tsx
Normal file
169
src/components/Home/Desktop/Banner/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
113
src/components/MainMap/index.tsx
Normal file
113
src/components/MainMap/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/components/UI/GroupAvatar.tsx
Normal file
36
src/components/UI/GroupAvatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/components/UI/avatar.tsx
Normal file
40
src/components/UI/avatar.tsx
Normal 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 };
|
||||
Reference in New Issue
Block a user