Build & Push Docker Image / build (push) Has been cancelled
Refresh the homepage around new hero/services/roadmap sections, shared design tokens, and updated brand assets. Co-authored-by: Cursor <cursoragent@cursor.com>
414 lines
12 KiB
TypeScript
414 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import {
|
|
ChevronDown,
|
|
Headphones,
|
|
LayoutTemplate,
|
|
Megaphone,
|
|
Palette,
|
|
Search,
|
|
type LucideIcon,
|
|
} from "lucide-react";
|
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
|
import * as React from "react";
|
|
import { createPortal } from "react-dom";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
import { type } from "@/lib/typography";
|
|
|
|
export type MegaMenuItem = {
|
|
name: string;
|
|
href: string;
|
|
description: string;
|
|
icon: LucideIcon;
|
|
};
|
|
|
|
/** Experimental: service pages not built yet — hrefs stay inert. */
|
|
export const serviceMegaItems: MegaMenuItem[] = [
|
|
{
|
|
name: "طراحی سایت در اصفهان",
|
|
href: "#",
|
|
description: "وبسایت حرفهای با تجربه کاربری مدرن",
|
|
icon: LayoutTemplate,
|
|
},
|
|
{
|
|
name: "سئو در اصفهان",
|
|
href: "#",
|
|
description: "رتبه بهتر در گوگل و جذب مشتری هدفمند",
|
|
icon: Search,
|
|
},
|
|
{
|
|
name: "پشتیبانی سایت",
|
|
href: "#",
|
|
description: "نگهداری، امنیت و بهروزرسانی مداوم",
|
|
icon: Headphones,
|
|
},
|
|
{
|
|
name: "طراحی گرافیک",
|
|
href: "#",
|
|
description: "هویت بصری، لوگو و محتوای گرافیکی",
|
|
icon: Palette,
|
|
},
|
|
{
|
|
name: "ارسال انبوه",
|
|
href: "#",
|
|
description: "ارسال انبوه پیامک و کمپینهای دیجیتال",
|
|
icon: Megaphone,
|
|
},
|
|
];
|
|
|
|
const CLOSE_DELAY_MS = 140;
|
|
const PANEL_ANIMATION_MS = 220;
|
|
|
|
function isLinkActive(pathname: string, href: string): boolean {
|
|
if (href === "/") return pathname === "/";
|
|
if (href.startsWith("/#")) return pathname === "/";
|
|
return pathname === href || pathname.startsWith(`${href}/`);
|
|
}
|
|
|
|
function MegaMenuCard({
|
|
item,
|
|
active,
|
|
onNavigate,
|
|
}: {
|
|
item: MegaMenuItem;
|
|
active: boolean;
|
|
onNavigate?: () => void;
|
|
}) {
|
|
const Icon = item.icon;
|
|
|
|
return (
|
|
<Link
|
|
href={item.href}
|
|
onClick={onNavigate}
|
|
className={cn(
|
|
"group/card flex min-h-[5.5rem] gap-3 rounded-2xl border border-transparent p-3",
|
|
"transition-[transform,background-color,border-color,box-shadow,color] duration-200 ease-out",
|
|
"hover:-translate-y-[2px] hover:border-border/50 hover:bg-background/45 hover:shadow-surface-sm",
|
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
active && "border-primary/25 bg-background/40"
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"flex size-11 shrink-0 items-center justify-center rounded-2xl",
|
|
"border border-border/40 bg-background/40 text-muted-foreground transition-colors duration-200",
|
|
"group-hover/card:border-primary/30 group-hover/card:bg-primary/10 group-hover/card:text-primary",
|
|
active && "border-primary/30 bg-primary/10 text-primary"
|
|
)}
|
|
>
|
|
<Icon className="size-5" aria-hidden />
|
|
</span>
|
|
<span className="flex min-w-0 flex-1 flex-col justify-center gap-1 text-start">
|
|
<span className={cn(type.nav, "font-semibold text-foreground")}>
|
|
{item.name}
|
|
</span>
|
|
<span className={cn(type.caption, "line-clamp-2 leading-snug")}>
|
|
{item.description}
|
|
</span>
|
|
</span>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
type ServicesMegaMenuProps = {
|
|
label?: string;
|
|
pathname: string;
|
|
triggerClassName?: string;
|
|
active?: boolean;
|
|
};
|
|
|
|
export function ServicesMegaMenu({
|
|
label = "خدمات",
|
|
pathname,
|
|
triggerClassName,
|
|
active = false,
|
|
}: ServicesMegaMenuProps) {
|
|
const reduceMotion = useReducedMotion();
|
|
const [open, setOpen] = React.useState(false);
|
|
const [mounted, setMounted] = React.useState(false);
|
|
const [panelStyle, setPanelStyle] = React.useState<React.CSSProperties>({});
|
|
const rootRef = React.useRef<HTMLDivElement>(null);
|
|
const triggerRef = React.useRef<HTMLButtonElement>(null);
|
|
const panelRef = React.useRef<HTMLDivElement>(null);
|
|
const closeTimerRef = React.useRef<number | null>(null);
|
|
|
|
const groupActive =
|
|
active ||
|
|
serviceMegaItems.some((item) => isLinkActive(pathname, item.href));
|
|
|
|
React.useEffect(() => {
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
const clearCloseTimer = React.useCallback(() => {
|
|
if (closeTimerRef.current !== null) {
|
|
window.clearTimeout(closeTimerRef.current);
|
|
closeTimerRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const openMenu = React.useCallback(() => {
|
|
clearCloseTimer();
|
|
setOpen(true);
|
|
}, [clearCloseTimer]);
|
|
|
|
const closeMenu = React.useCallback(() => {
|
|
clearCloseTimer();
|
|
setOpen(false);
|
|
}, [clearCloseTimer]);
|
|
|
|
const scheduleClose = React.useCallback(() => {
|
|
clearCloseTimer();
|
|
closeTimerRef.current = window.setTimeout(() => {
|
|
setOpen(false);
|
|
}, CLOSE_DELAY_MS);
|
|
}, [clearCloseTimer]);
|
|
|
|
const updatePanelPosition = React.useCallback(() => {
|
|
const trigger = triggerRef.current;
|
|
if (!trigger) return;
|
|
|
|
const rect = trigger.getBoundingClientRect();
|
|
const viewportPadding = 16;
|
|
const panelWidth = Math.min(352, window.innerWidth - viewportPadding * 2);
|
|
const isTablet = window.innerWidth < 1280;
|
|
|
|
if (isTablet) {
|
|
setPanelStyle({
|
|
top: rect.bottom + 8,
|
|
left: viewportPadding,
|
|
right: viewportPadding,
|
|
width: "auto",
|
|
});
|
|
return;
|
|
}
|
|
|
|
const inlineStart = rect.right - panelWidth;
|
|
const clampedStart = Math.max(
|
|
viewportPadding,
|
|
Math.min(inlineStart, window.innerWidth - panelWidth - viewportPadding)
|
|
);
|
|
|
|
setPanelStyle({
|
|
top: rect.bottom + 8,
|
|
left: clampedStart,
|
|
width: panelWidth,
|
|
});
|
|
}, []);
|
|
|
|
React.useLayoutEffect(() => {
|
|
if (!open) return;
|
|
updatePanelPosition();
|
|
|
|
const handleLayout = () => updatePanelPosition();
|
|
window.addEventListener("resize", handleLayout);
|
|
window.addEventListener("scroll", handleLayout, { passive: true });
|
|
|
|
return () => {
|
|
window.removeEventListener("resize", handleLayout);
|
|
window.removeEventListener("scroll", handleLayout);
|
|
};
|
|
}, [open, updatePanelPosition]);
|
|
|
|
React.useEffect(() => {
|
|
if (!open) return;
|
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
closeMenu();
|
|
triggerRef.current?.focus();
|
|
}
|
|
};
|
|
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
}, [closeMenu, open]);
|
|
|
|
React.useEffect(() => {
|
|
return () => clearCloseTimer();
|
|
}, [clearCloseTimer]);
|
|
|
|
const handleTriggerKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault();
|
|
openMenu();
|
|
requestAnimationFrame(() => {
|
|
panelRef.current
|
|
?.querySelector<HTMLAnchorElement>("a[href]")
|
|
?.focus();
|
|
});
|
|
}
|
|
};
|
|
|
|
const panelMotion = reduceMotion
|
|
? {
|
|
initial: { opacity: 0 },
|
|
animate: { opacity: 1 },
|
|
exit: { opacity: 0 },
|
|
transition: { duration: 0.15, ease: "easeOut" as const },
|
|
}
|
|
: {
|
|
initial: { opacity: 0, y: -10, scale: 0.98, filter: "blur(10px)" },
|
|
animate: { opacity: 1, y: 0, scale: 1, filter: "blur(0px)" },
|
|
exit: { opacity: 0, y: -6, scale: 0.99, filter: "blur(4px)" },
|
|
transition: { duration: PANEL_ANIMATION_MS / 1000, ease: "easeOut" as const },
|
|
};
|
|
|
|
const overlayMotion = reduceMotion
|
|
? {
|
|
initial: { opacity: 0 },
|
|
animate: { opacity: 1 },
|
|
exit: { opacity: 0 },
|
|
transition: { duration: 0.15 },
|
|
}
|
|
: {
|
|
initial: { opacity: 0 },
|
|
animate: { opacity: 1 },
|
|
exit: { opacity: 0 },
|
|
transition: { duration: PANEL_ANIMATION_MS / 1000, ease: "easeOut" as const },
|
|
};
|
|
|
|
const portal =
|
|
mounted && typeof document !== "undefined"
|
|
? createPortal(
|
|
<AnimatePresence>
|
|
{open ? (
|
|
<>
|
|
<motion.button
|
|
type="button"
|
|
aria-hidden
|
|
tabIndex={-1}
|
|
className="fixed inset-0 z-40 bg-transparent"
|
|
{...overlayMotion}
|
|
onClick={closeMenu}
|
|
/>
|
|
<motion.div
|
|
ref={panelRef}
|
|
id="services-mega-menu"
|
|
data-mega-menu-panel
|
|
role="region"
|
|
aria-label="منوی خدمات"
|
|
className={cn(
|
|
"fixed z-50 overflow-hidden rounded-2xl border border-border/50",
|
|
"bg-background/75 shadow-sm",
|
|
"backdrop-blur-xl backdrop-saturate-150",
|
|
"supports-[backdrop-filter]:bg-background/65",
|
|
"xl:max-w-none"
|
|
)}
|
|
style={panelStyle}
|
|
{...panelMotion}
|
|
onPointerEnter={openMenu}
|
|
onPointerLeave={scheduleClose}
|
|
>
|
|
<ul className="flex flex-col gap-1 p-3">
|
|
{serviceMegaItems.map((item) => (
|
|
<li key={item.href}>
|
|
<MegaMenuCard
|
|
item={item}
|
|
active={isLinkActive(pathname, item.href)}
|
|
onNavigate={closeMenu}
|
|
/>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</motion.div>
|
|
</>
|
|
) : null}
|
|
</AnimatePresence>,
|
|
document.body
|
|
)
|
|
: null;
|
|
|
|
return (
|
|
<motion.div
|
|
ref={rootRef}
|
|
className="relative"
|
|
onPointerEnter={openMenu}
|
|
onPointerLeave={scheduleClose}
|
|
>
|
|
<button
|
|
ref={triggerRef}
|
|
type="button"
|
|
aria-expanded={open}
|
|
aria-haspopup="true"
|
|
aria-controls="services-mega-menu"
|
|
onClick={() => (open ? closeMenu() : openMenu())}
|
|
onKeyDown={handleTriggerKeyDown}
|
|
className={triggerClassName}
|
|
>
|
|
<span>{label}</span>
|
|
<ChevronDown
|
|
aria-hidden
|
|
className={cn(
|
|
"ms-1 size-3.5 opacity-70 transition-transform duration-200",
|
|
open && "rotate-180"
|
|
)}
|
|
/>
|
|
<span
|
|
aria-hidden
|
|
className={cn(
|
|
"pointer-events-none absolute inset-x-2 -bottom-0.5 h-px origin-center scale-x-0 bg-foreground/40",
|
|
"transition-transform duration-300 ease-out",
|
|
groupActive && "scale-x-100 bg-primary/70"
|
|
)}
|
|
/>
|
|
</button>
|
|
|
|
{/* Hover bridge — eliminates gap between trigger and floating panel */}
|
|
<span
|
|
aria-hidden
|
|
className={cn(
|
|
"absolute start-0 end-0 top-full h-3",
|
|
open ? "pointer-events-auto" : "pointer-events-none"
|
|
)}
|
|
onPointerEnter={openMenu}
|
|
/>
|
|
|
|
{portal}
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
/** Compact mobile links — title only, no description cards. */
|
|
export function MegaMenuMobileCards({
|
|
items,
|
|
pathname,
|
|
onNavigate,
|
|
}: {
|
|
items: MegaMenuItem[];
|
|
pathname: string;
|
|
onNavigate?: () => void;
|
|
}) {
|
|
return (
|
|
<ul className="flex flex-col gap-0.5 ps-1">
|
|
{items.map((item) => {
|
|
const active = isLinkActive(pathname, item.href);
|
|
const Icon = item.icon;
|
|
return (
|
|
<li key={item.href}>
|
|
<Link
|
|
href={item.href}
|
|
onClick={onNavigate}
|
|
className={cn(
|
|
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5",
|
|
"text-muted-foreground/90 transition-[color,background-color] duration-200 ease-out",
|
|
"hover:bg-muted/60 hover:text-foreground",
|
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 focus-visible:ring-offset-2",
|
|
active && "bg-muted/50 text-foreground"
|
|
)}
|
|
>
|
|
<Icon className="size-4 shrink-0 opacity-70" aria-hidden />
|
|
<span className={cn(type["nav-mobile"], "text-sm leading-tight")}>
|
|
{item.name}
|
|
</span>
|
|
</Link>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
);
|
|
}
|