Files
ar.badieiandCursor c2376b3b9e
Build & Push Docker Image / build (push) Has been cancelled
feat: redesign landing page with brand system and section blocks
Refresh the homepage around new hero/services/roadmap sections, shared design tokens, and updated brand assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 17:00:55 +03:30

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>
);
}