- Connect homepage, blog, services, header, footer to Payload CMS via local API - Add HomePage global with 7 editorial sections (hero, painPoints, solution, whyAxil, audience, process, finalCta) - Add ServerHeader/ServerFooter async wrappers with unstable_cache + tag-based ISR revalidation - Rewrite blog/[slug] and services/[slug] pages to fetch from CMS with fallbacks - Add seed script (src/lib/seed.ts) to populate all collections and globals from hardcoded defaults - Restructure app into (site)/ route group to fix Payload admin hydration conflicts - Make root layout a passthrough; (site)/layout.tsx owns html/body/fonts - Restrict user creation/update/delete to admin role only - Fix migration imports: type MigrateUpArgs/Down from @payloadcms/db-postgres - Wrap revalidateTag dynamic imports in try/catch for seed/CLI compatibility - Skip migrations in dev mode (Payload handles schema push automatically) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
301 lines
10 KiB
TypeScript
301 lines
10 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useRef } from 'react';
|
|
import Image from 'next/image';
|
|
import Link from 'next/link';
|
|
import { usePathname } from 'next/navigation';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { MenuIcon, XIcon, ChevronDownIcon } from '@/components/ui/icons';
|
|
import { InteractiveMenu } from '@/components/ui/InteractiveMenu';
|
|
import { Home, Briefcase, BookOpen, Info, Mail, GraduationCap } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import type { Navigation } from '@/payload-types';
|
|
|
|
type NavChild = { label: string; href: string; desc: string };
|
|
type NavItem = { label: string; href: string; dropdown?: NavChild[] };
|
|
|
|
const NAV_ITEMS_FALLBACK: NavItem[] = [
|
|
{ label: 'Home', href: '/' },
|
|
{
|
|
label: 'Services',
|
|
href: '/services',
|
|
dropdown: [
|
|
{
|
|
label: 'Bookkeeping',
|
|
href: '/services/bookkeeping',
|
|
desc: 'Accurate records, zero stress',
|
|
},
|
|
{ label: 'Tax Returns', href: '/services/tax-returns', desc: 'Every allowance claimed' },
|
|
{ label: 'Payroll', href: '/services/payroll', desc: 'On time, every time' },
|
|
{ label: 'VAT Returns', href: '/services/vat-returns', desc: 'MTD-compliant filing' },
|
|
],
|
|
},
|
|
{ label: 'Courses', href: '/courses' },
|
|
{ label: 'About', href: '/about' },
|
|
{ label: 'Blog', href: '/blog' },
|
|
{ label: 'Contact', href: '/contact' },
|
|
];
|
|
|
|
function buildNavItems(navData: Navigation | null | undefined): NavItem[] {
|
|
if (!navData?.items?.length) return NAV_ITEMS_FALLBACK;
|
|
return navData.items.map((item) => ({
|
|
label: item.label,
|
|
href: item.href ?? '/',
|
|
dropdown:
|
|
item.isDropdown && item.children?.length
|
|
? item.children.map((c) => ({ label: c.label, href: c.href, desc: c.description ?? '' }))
|
|
: undefined,
|
|
}));
|
|
}
|
|
|
|
const DOCK_ITEMS = [
|
|
{ label: 'Home', icon: Home, href: '/' },
|
|
{ label: 'Services', icon: Briefcase, href: '/services' },
|
|
{ label: 'Courses', icon: GraduationCap, href: '/courses' },
|
|
{ label: 'Blog', icon: BookOpen, href: '/blog' },
|
|
{ label: 'About', icon: Info, href: '/about' },
|
|
{ label: 'Contact', icon: Mail, href: '/contact' },
|
|
];
|
|
|
|
function AxilLogo({ className }: { className?: string }) {
|
|
return (
|
|
<Image
|
|
src="/logo-axil.jpg"
|
|
alt="Axil Accounting"
|
|
width={140}
|
|
height={97}
|
|
className={cn('w-auto', className)}
|
|
priority
|
|
/>
|
|
);
|
|
}
|
|
|
|
/** Nav links shared between static header and floating pill */
|
|
function NavLinks({
|
|
items,
|
|
pathname,
|
|
dropdownOpen,
|
|
setDropdownOpen,
|
|
size = 'md',
|
|
}: {
|
|
items: NavItem[];
|
|
pathname: string;
|
|
dropdownOpen: boolean;
|
|
setDropdownOpen: (v: boolean) => void;
|
|
size?: 'sm' | 'md';
|
|
}) {
|
|
const textSize = size === 'sm' ? 'text-xs' : 'text-sm';
|
|
const px = size === 'sm' ? 'px-3' : 'px-4';
|
|
|
|
return (
|
|
<>
|
|
{items.map((item) =>
|
|
item.dropdown ? (
|
|
<div
|
|
key={item.label}
|
|
className="relative"
|
|
onMouseEnter={() => setDropdownOpen(true)}
|
|
onMouseLeave={() => setDropdownOpen(false)}
|
|
>
|
|
<Link
|
|
href={item.href}
|
|
className={cn(
|
|
'rounded-pill text-charcoal hover:bg-emerald-mist flex items-center gap-1 py-2 font-medium transition-colors',
|
|
textSize,
|
|
px,
|
|
pathname === item.href ? 'text-emerald font-semibold' : '',
|
|
)}
|
|
aria-expanded={dropdownOpen}
|
|
aria-haspopup="true"
|
|
>
|
|
{item.label}
|
|
<ChevronDownIcon
|
|
size={12}
|
|
className={`transition-transform duration-200 ${dropdownOpen ? 'rotate-180' : ''}`}
|
|
/>
|
|
</Link>
|
|
{dropdownOpen && (
|
|
<div className="rounded-hero border-charcoal/10 absolute top-full left-0 mt-2 w-60 border bg-white p-2 shadow-xl">
|
|
{item.dropdown.map((child) => (
|
|
<Link
|
|
key={child.label}
|
|
href={child.href}
|
|
className="rounded-card hover:bg-emerald-mist flex flex-col px-4 py-3 transition-colors"
|
|
onClick={() => setDropdownOpen(false)}
|
|
>
|
|
<span className="text-charcoal text-sm font-medium">{child.label}</span>
|
|
<span className="text-muted text-xs">{child.desc}</span>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<Link
|
|
key={item.label}
|
|
href={item.href}
|
|
className={cn(
|
|
'rounded-pill hover:bg-emerald-mist py-2 font-medium transition-colors',
|
|
textSize,
|
|
px,
|
|
pathname === item.href ? 'text-emerald font-semibold' : 'text-charcoal',
|
|
)}
|
|
>
|
|
{item.label}
|
|
</Link>
|
|
),
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function Header({ navData }: { navData?: Navigation | null }) {
|
|
const navItems = buildNavItems(navData);
|
|
const lastScrollY = useRef(0);
|
|
const [atTop, setAtTop] = useState(true);
|
|
const [floatVisible, setFloatVisible] = useState(false);
|
|
const [mobileOpen, setMobileOpen] = useState(false);
|
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
const [dropdownOpenF, setDropdownOpenF] = useState(false);
|
|
const pathname = usePathname();
|
|
|
|
useEffect(() => {
|
|
const onScroll = () => {
|
|
const y = window.scrollY;
|
|
const direction = y - lastScrollY.current;
|
|
lastScrollY.current = y;
|
|
|
|
if (y < 60) {
|
|
setAtTop(true);
|
|
setFloatVisible(false);
|
|
} else {
|
|
setAtTop(false);
|
|
setFloatVisible(direction < 0); // scroll up → show pill
|
|
}
|
|
};
|
|
window.addEventListener('scroll', onScroll, { passive: true });
|
|
return () => window.removeEventListener('scroll', onScroll);
|
|
}, []);
|
|
|
|
return (
|
|
<>
|
|
{/* ─── Static header (visible only at top of page) ─── */}
|
|
<AnimatePresence>
|
|
{atTop && (
|
|
<motion.header
|
|
key="static-header"
|
|
initial={{ opacity: 0, y: -16 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -16 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="border-charcoal/10 fixed inset-x-0 top-0 z-50 border-b bg-white/90 shadow-sm backdrop-blur-md"
|
|
>
|
|
<div className="mx-auto flex h-18 max-w-[1440px] items-center justify-between px-4 sm:px-6 lg:px-8 xl:px-16">
|
|
<Link href="/" aria-label="Axil Accountants — Home">
|
|
<AxilLogo className="h-13" />
|
|
</Link>
|
|
|
|
<nav className="hidden items-center gap-1 lg:flex">
|
|
<NavLinks
|
|
items={navItems}
|
|
pathname={pathname}
|
|
dropdownOpen={dropdownOpen}
|
|
setDropdownOpen={setDropdownOpen}
|
|
/>
|
|
</nav>
|
|
|
|
<div className="hidden items-center gap-3 lg:flex">
|
|
<Button size="sm" trailingArrow href="/contact">
|
|
Book Free Consultation
|
|
</Button>
|
|
</div>
|
|
|
|
<button
|
|
className="rounded-card text-charcoal hover:bg-emerald-mist flex size-10 items-center justify-center transition-colors lg:hidden"
|
|
onClick={() => setMobileOpen((v) => !v)}
|
|
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
|
|
>
|
|
{mobileOpen ? <XIcon size={20} /> : <MenuIcon size={20} />}
|
|
</button>
|
|
</div>
|
|
</motion.header>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* ─── Floating pill nav (appears on scroll-up) ─── */}
|
|
<AnimatePresence mode="wait">
|
|
{floatVisible && (
|
|
<motion.div
|
|
key="floating-nav"
|
|
initial={{ opacity: 0, y: -100 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -100 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="fixed inset-x-0 top-5 z-[5000] flex justify-center px-4"
|
|
>
|
|
<div className="border-charcoal/10 flex items-center gap-3 rounded-full border bg-white/95 py-2 pr-2 pl-3 shadow-lg backdrop-blur-md">
|
|
{/* Logo */}
|
|
<Link href="/" aria-label="Home" className="mr-1 flex-shrink-0">
|
|
<AxilLogo className="h-7" />
|
|
</Link>
|
|
|
|
{/* Divider */}
|
|
<div className="bg-charcoal/10 h-5 w-px" />
|
|
|
|
{/* Nav links */}
|
|
<nav className="hidden items-center lg:flex">
|
|
<NavLinks
|
|
items={navItems}
|
|
pathname={pathname}
|
|
dropdownOpen={dropdownOpenF}
|
|
setDropdownOpen={setDropdownOpenF}
|
|
size="sm"
|
|
/>
|
|
</nav>
|
|
|
|
{/* CTA */}
|
|
<Button size="sm" trailingArrow href="/contact" className="ml-1 flex-shrink-0">
|
|
Book Free Consultation
|
|
</Button>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* ─── Mobile slide-out menu ─── */}
|
|
{mobileOpen && (
|
|
<div className="fixed inset-0 z-40 flex flex-col bg-white pt-18 lg:hidden">
|
|
<nav className="flex flex-col gap-1 px-4 py-6">
|
|
{navItems.map((item) => (
|
|
<Link
|
|
key={item.label}
|
|
href={item.href}
|
|
className="rounded-card text-charcoal hover:bg-emerald-mist px-4 py-4 text-lg font-medium transition-colors"
|
|
onClick={() => setMobileOpen(false)}
|
|
>
|
|
{item.label}
|
|
</Link>
|
|
))}
|
|
</nav>
|
|
<div className="px-4">
|
|
<Button size="lg" className="w-full justify-center" trailingArrow href="/contact">
|
|
Book Free Consultation
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ─── Mobile floating dock ─── */}
|
|
<div className="fixed bottom-5 left-1/2 z-40 -translate-x-1/2 lg:hidden">
|
|
<InteractiveMenu
|
|
items={DOCK_ITEMS}
|
|
accentColor="var(--emerald)"
|
|
onSelect={(_, item) => {
|
|
if (item.href) window.location.href = item.href;
|
|
}}
|
|
/>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|