858 lines
26 KiB
TypeScript
858 lines
26 KiB
TypeScript
import {useEffect, useRef, useState} from 'react';
|
||
import {Form, Link, useLocation} from 'react-router';
|
||
import {
|
||
type CartViewPayload,
|
||
Image,
|
||
useAnalytics,
|
||
useOptimisticCart,
|
||
} from '@shopify/hydrogen';
|
||
import {
|
||
ChevronDown,
|
||
Heart,
|
||
LogOut,
|
||
Menu,
|
||
Package,
|
||
Search,
|
||
ShoppingBag,
|
||
User,
|
||
X,
|
||
} from 'lucide-react';
|
||
import type {HeaderQuery, CartApiQueryFragment} from 'storefrontapi.generated';
|
||
import type {CustomerFragment} from 'customer-accountapi.generated';
|
||
import {useAside} from '~/components/Aside';
|
||
import {getCustomerDisplayName, getCustomerInitials} from '~/lib/customerDisplay';
|
||
import {useAbiirWishlistCount} from '~/hooks/useAbiirWishlistCount';
|
||
import abiirLogoSrc from '~/assets/abiir-logo.png';
|
||
import {CUSTOMER_DETAILS_QUERY} from '~/graphql/customer-account/CustomerDetailsQuery';
|
||
import {useQuery} from '@sanity/react-loader';
|
||
import type {QueryResponseInitial} from '@sanity/react-loader';
|
||
|
||
/** GROQ: header fields on the Shopify template `settings` singleton. */
|
||
export const HEADER_SANITY_QUERY = `*[_type == "settings"][0].header{
|
||
announcementText,
|
||
announcementBackground,
|
||
announcementTextColor,
|
||
navAccent,
|
||
utilityAccent,
|
||
"navigationItems": navigationItems[]{
|
||
label,
|
||
"href": href
|
||
},
|
||
logo{
|
||
alt,
|
||
asset->{
|
||
_id,
|
||
url,
|
||
metadata{dimensions{width,height}}
|
||
}
|
||
},
|
||
"accountMenuItems": accountMenuItems[]{
|
||
label,
|
||
"href": href
|
||
}
|
||
}` as const;
|
||
|
||
export type AbiirHeaderNavItem = {
|
||
label: string;
|
||
href: string;
|
||
};
|
||
|
||
export type AbiirHeaderCms = {
|
||
announcementText: string;
|
||
announcementBackground: string;
|
||
announcementTextColor: string;
|
||
navAccent: string;
|
||
utilityAccent: string;
|
||
navigationItems: AbiirHeaderNavItem[];
|
||
logo: {
|
||
id?: string;
|
||
url: string;
|
||
alt: string;
|
||
width?: number;
|
||
height?: number;
|
||
} | null;
|
||
accountMenuItems: AbiirHeaderNavItem[];
|
||
};
|
||
|
||
/** Legacy nav used `/products` as a catalog index; this app lists all products at `/collections/all`. */
|
||
function normalizeCatalogListNavHref(href: string): string {
|
||
try {
|
||
const u = new URL(href, 'https://abiir.internal');
|
||
if (u.pathname === '/products' || u.pathname === '/products/') {
|
||
return `/collections/all${u.search}${u.hash}`;
|
||
}
|
||
} catch {
|
||
/* keep href */
|
||
}
|
||
return href;
|
||
}
|
||
|
||
type SanityHeaderPayload = {
|
||
announcementText?: string | null;
|
||
announcementBackground?: string | null;
|
||
announcementTextColor?: string | null;
|
||
navAccent?: string | null;
|
||
utilityAccent?: string | null;
|
||
navigationItems?: AbiirHeaderNavItem[] | null;
|
||
logo?: {
|
||
alt?: string | null;
|
||
asset?: {
|
||
_id?: string;
|
||
url?: string | null;
|
||
metadata?: {dimensions?: {width?: number | null; height?: number | null}} | null;
|
||
} | null;
|
||
} | null;
|
||
accountMenuItems?: AbiirHeaderNavItem[] | null;
|
||
} | null;
|
||
|
||
/** Visual / offline fallback — matches the legacy React Navbar defaults. */
|
||
export const MOCK_HEADER_CMS: AbiirHeaderCms = {
|
||
announcementText:
|
||
'FREE SHIPPING ON ORDERS OVER $75 · USE CODE: ABiiR15 FOR 15% OFF',
|
||
announcementBackground: '#5D0B24',
|
||
announcementTextColor: '#FAF8F3',
|
||
navAccent: '#b39763',
|
||
utilityAccent: '#5d0b24',
|
||
navigationItems: [
|
||
{label: 'Collection', href: '/collections/all'},
|
||
{label: 'Rituals', href: '/collections/ritual'},
|
||
{label: 'Our Story', href: '/about'},
|
||
// {label: 'Journal', href: '/journal'},
|
||
],
|
||
logo: null,
|
||
accountMenuItems: [
|
||
{label: 'My Profile', href: '/account/profile'},
|
||
{label: 'My Orders', href: '/account/orders'},
|
||
{label: 'Wishlist', href: '/wishlist'},
|
||
],
|
||
};
|
||
|
||
const DEFAULT_ACCOUNT_MENU: AbiirHeaderNavItem[] = [
|
||
{label: 'My Profile', href: '/account/profile'},
|
||
{label: 'My Orders', href: '/account/orders'},
|
||
{label: 'Wishlist', href: '/wishlist'},
|
||
];
|
||
|
||
export function normalizeHeaderSanityPayload(
|
||
raw: unknown,
|
||
): AbiirHeaderCms | null {
|
||
if (raw == null || typeof raw !== 'object') return null;
|
||
const h = raw as SanityHeaderPayload;
|
||
if (!h?.announcementText || !h.navigationItems?.length) return null;
|
||
|
||
const logoUrl = h.logo?.asset?.url;
|
||
const logo =
|
||
logoUrl != null
|
||
? {
|
||
id: h.logo?.asset?._id,
|
||
url: logoUrl,
|
||
alt: h.logo?.alt ?? 'ABiiR Skincare – Rooted in Ayurveda',
|
||
width: h.logo?.asset?.metadata?.dimensions?.width ?? undefined,
|
||
height: h.logo?.asset?.metadata?.dimensions?.height ?? undefined,
|
||
}
|
||
: null;
|
||
|
||
return {
|
||
announcementText: h.announcementText,
|
||
announcementBackground: h.announcementBackground ?? '#5D0B24',
|
||
announcementTextColor: h.announcementTextColor ?? '#FAF8F3',
|
||
navAccent: h.navAccent ?? '#b39763',
|
||
utilityAccent: h.utilityAccent ?? '#5d0b24',
|
||
navigationItems: h.navigationItems.map((i) => ({
|
||
label: i.label,
|
||
href: normalizeCatalogListNavHref(i.href ?? ''),
|
||
})),
|
||
logo,
|
||
accountMenuItems:
|
||
h.accountMenuItems?.filter((i) => i.label && i.href) ??
|
||
DEFAULT_ACCOUNT_MENU,
|
||
};
|
||
}
|
||
|
||
type ShopifyBrandImage = NonNullable<
|
||
NonNullable<HeaderQuery['shop']['brand']>['logo']
|
||
>['image'];
|
||
|
||
/** Shopify brand logo image from `HeaderQuery` (pass `header.shop.brand?.logo?.image`). */
|
||
export type AbiirShopifyBrandLogoImage = ShopifyBrandImage;
|
||
|
||
export type AbiirLogoImageData = NonNullable<
|
||
ReturnType<typeof toHydrogenImage>
|
||
>;
|
||
|
||
function toHydrogenImage(
|
||
input: {
|
||
id?: string | null;
|
||
url: string;
|
||
altText?: string | null;
|
||
width?: number | null;
|
||
height?: number | null;
|
||
} | null | undefined,
|
||
): {
|
||
id?: string | undefined;
|
||
url: string;
|
||
altText?: string | undefined;
|
||
width?: number | undefined;
|
||
height?: number | undefined;
|
||
} | null {
|
||
if (!input?.url) return null;
|
||
return {
|
||
id: input.id ?? undefined,
|
||
url: input.url,
|
||
altText: input.altText ?? '',
|
||
width: input.width ?? undefined,
|
||
height: input.height ?? undefined,
|
||
};
|
||
}
|
||
|
||
/** Same artwork as React `Navbar` (`figma:asset/11567d6fddae52ee62a5633b5f842f2e7b3e3b29.png` → `app/assets/abiir-logo.png`). */
|
||
const REACT_APP_BRAND_LOGO = toHydrogenImage({
|
||
id: 'abiir-logo-react',
|
||
url: abiirLogoSrc,
|
||
altText: 'ABiiR Skincare – Rooted in Ayurveda',
|
||
});
|
||
|
||
/** Sanity header logo → Shopify brand logo → bundled `abiir-logo.png` (header + footer). */
|
||
export function pickAbiirLogoData(
|
||
cms: AbiirHeaderCms,
|
||
shopifyImage: ShopifyBrandImage | null | undefined,
|
||
): AbiirLogoImageData {
|
||
if (cms.logo?.url) {
|
||
const fromCms = toHydrogenImage({
|
||
id: cms.logo.id,
|
||
url: cms.logo.url,
|
||
altText: cms.logo.alt,
|
||
width: cms.logo.width,
|
||
height: cms.logo.height,
|
||
});
|
||
if (fromCms) return fromCms;
|
||
}
|
||
const fromShopify = toHydrogenImage(shopifyImage);
|
||
if (fromShopify) return fromShopify;
|
||
return REACT_APP_BRAND_LOGO;
|
||
}
|
||
|
||
function isExternalHref(href: string) {
|
||
return /^https?:\/\//i.test(href);
|
||
}
|
||
|
||
function NavLinkOrA({
|
||
href,
|
||
className,
|
||
style,
|
||
onClick,
|
||
children,
|
||
}: {
|
||
href: string;
|
||
className?: string;
|
||
style?: React.CSSProperties;
|
||
onClick?: () => void;
|
||
children: React.ReactNode;
|
||
}) {
|
||
if (isExternalHref(href)) {
|
||
return (
|
||
<a
|
||
href={href}
|
||
className={className}
|
||
style={style}
|
||
onClick={onClick}
|
||
rel="noreferrer"
|
||
>
|
||
{children}
|
||
</a>
|
||
);
|
||
}
|
||
return (
|
||
<Link
|
||
to={href}
|
||
className={className}
|
||
style={style}
|
||
onClick={onClick}
|
||
prefetch="intent"
|
||
>
|
||
{children}
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
export interface HeaderProps {
|
||
header: HeaderQuery;
|
||
headerCms: AbiirHeaderCms;
|
||
/** Pass through from root `loadQuery` for `useQuery` hydration (optional). */
|
||
headerSanityInitial?: QueryResponseInitial<AbiirHeaderCms | null>;
|
||
cart: CartApiQueryFragment | null;
|
||
isLoggedIn: boolean;
|
||
accountCustomer: CustomerFragment | null;
|
||
publicStoreDomain: string;
|
||
}
|
||
|
||
/**
|
||
* Optional: use with a mounted Sanity query store / preview (e.g. `hydrogen-sanity` `Query`).
|
||
* The default `Header` uses CMS data from the root loader only.
|
||
*/
|
||
export function useAbiirHeaderCms(
|
||
initial: QueryResponseInitial<AbiirHeaderCms | null>,
|
||
) {
|
||
return useQuery<AbiirHeaderCms | null>(HEADER_SANITY_QUERY, undefined, {
|
||
initial,
|
||
});
|
||
}
|
||
|
||
export function Header({
|
||
header,
|
||
headerCms: headerCmsFromLoader,
|
||
headerSanityInitial: _headerSanityInitial,
|
||
isLoggedIn,
|
||
cart,
|
||
accountCustomer,
|
||
publicStoreDomain,
|
||
}: HeaderProps) {
|
||
void _headerSanityInitial;
|
||
const cms = headerCmsFromLoader ?? MOCK_HEADER_CMS;
|
||
|
||
const {shop} = header;
|
||
const primaryDomainUrl = shop.primaryDomain?.url ?? '';
|
||
const shopifyLogo = shop.brand?.logo?.image;
|
||
const logoData = pickAbiirLogoData(cms, shopifyLogo);
|
||
|
||
return (
|
||
<>
|
||
<div
|
||
className="w-full text-center py-[10px] px-4 font-['Raleway',sans-serif]"
|
||
style={{
|
||
background: cms.announcementBackground,
|
||
color: cms.announcementTextColor,
|
||
}}
|
||
>
|
||
<p className="m-0 text-[0.8125rem] font-normal tracking-[0.18em]">
|
||
{cms.announcementText}
|
||
</p>
|
||
</div>
|
||
<AbiirNavBar
|
||
cart={cart}
|
||
cms={cms}
|
||
isLoggedIn={isLoggedIn}
|
||
accountCustomer={accountCustomer}
|
||
logoData={logoData}
|
||
shopName={shop.name}
|
||
primaryDomainUrl={primaryDomainUrl}
|
||
publicStoreDomain={publicStoreDomain}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function AbiirNavBar({
|
||
cms,
|
||
cart,
|
||
isLoggedIn,
|
||
accountCustomer,
|
||
logoData,
|
||
shopName,
|
||
primaryDomainUrl,
|
||
publicStoreDomain,
|
||
}: {
|
||
cms: AbiirHeaderCms;
|
||
cart: CartApiQueryFragment | null;
|
||
isLoggedIn: boolean;
|
||
accountCustomer: CustomerFragment | null;
|
||
logoData: AbiirLogoImageData;
|
||
shopName: string;
|
||
primaryDomainUrl: string;
|
||
publicStoreDomain: string;
|
||
}) {
|
||
const wishlistCount = useAbiirWishlistCount();
|
||
const [scrolled, setScrolled] = useState(false);
|
||
const [menuOpen, setMenuOpen] = useState(false);
|
||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||
const location = useLocation();
|
||
const userMenuRef = useRef<HTMLDivElement>(null);
|
||
const navAccent = cms.navAccent;
|
||
const utilityBurgundy = cms.utilityAccent;
|
||
const accountLinks =
|
||
cms.accountMenuItems?.length > 0
|
||
? cms.accountMenuItems
|
||
: DEFAULT_ACCOUNT_MENU;
|
||
|
||
useEffect(() => {
|
||
const handleScroll = () => setScrolled(window.scrollY > 40);
|
||
window.addEventListener('scroll', handleScroll);
|
||
return () => window.removeEventListener('scroll', handleScroll);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
setMenuOpen(false);
|
||
setUserMenuOpen(false);
|
||
}, [location.pathname, location.search]);
|
||
|
||
useEffect(() => {
|
||
const handler = (e: MouseEvent) => {
|
||
if (
|
||
userMenuRef.current &&
|
||
!userMenuRef.current.contains(e.target as Node)
|
||
) {
|
||
setUserMenuOpen(false);
|
||
}
|
||
};
|
||
document.addEventListener('mousedown', handler);
|
||
return () => document.removeEventListener('mousedown', handler);
|
||
}, []);
|
||
|
||
const navLinks = cms.navigationItems;
|
||
|
||
return (
|
||
<nav
|
||
className="sticky top-0 z-50 w-full transition-[background,backdrop-filter] duration-300"
|
||
style={{
|
||
background: scrolled
|
||
? 'rgba(250, 248, 243, 0.97)'
|
||
: '#FAF8F3',
|
||
backdropFilter: scrolled ? 'blur(12px)' : 'none',
|
||
borderBottom: '1px solid rgba(0, 0, 0, 0.2)',
|
||
}}
|
||
>
|
||
<div className="max-w-[1600px] mx-auto px-6 sm:px-10 lg:px-16 xl:px-24 2xl:px-[206px]">
|
||
<div className="flex items-center justify-between h-[72px] lg:h-[96px] gap-4">
|
||
<div className="hidden lg:flex flex-1 items-center justify-between min-w-0 pr-6">
|
||
<Link
|
||
to="/"
|
||
prefetch="intent"
|
||
className="flex items-center select-none shrink-0 no-underline"
|
||
>
|
||
{logoData ? (
|
||
<Image
|
||
data={logoData}
|
||
alt={logoData.altText || 'ABiiR Skincare'}
|
||
width={148}
|
||
sizes="148px"
|
||
className="block w-[148px] h-auto max-w-[148px]"
|
||
/>
|
||
) : (
|
||
<span className="font-['Cormorant_Garamond',serif] text-[1.25rem] text-[#1A1A1A]">
|
||
{shopName}
|
||
</span>
|
||
)}
|
||
</Link>
|
||
<div className="flex items-center gap-10 shrink-0">
|
||
{navLinks.map((link) => {
|
||
const [pathname, query = ''] = link.href.split('?');
|
||
const isActive =
|
||
location.pathname === pathname &&
|
||
(!query || location.search.slice(1) === query);
|
||
return (
|
||
<NavLinkOrA
|
||
key={link.href}
|
||
href={normalizeStoreHref(
|
||
link.href,
|
||
primaryDomainUrl,
|
||
publicStoreDomain,
|
||
)}
|
||
className="flex flex-col gap-[3px] items-stretch transition-opacity duration-200 hover:opacity-60 no-underline uppercase font-medium text-[#1A1A1A] font-['Raleway',sans-serif] text-[13.813px] leading-[20.719px] tracking-[2.21px]"
|
||
>
|
||
<span>{link.label}</span>
|
||
<span
|
||
className="h-px w-full transition-transform duration-300 origin-left"
|
||
style={{
|
||
background: navAccent,
|
||
transform: isActive ? 'scaleX(1)' : 'scaleX(0)',
|
||
}}
|
||
/>
|
||
</NavLinkOrA>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<Link
|
||
to="/"
|
||
prefetch="intent"
|
||
className="flex lg:hidden items-center select-none shrink-0 no-underline"
|
||
>
|
||
{logoData ? (
|
||
<Image
|
||
data={logoData}
|
||
alt={logoData.altText || 'ABiiR Skincare'}
|
||
width={132}
|
||
className="block w-[132px] h-auto max-w-[132px]"
|
||
sizes="132px"
|
||
/>
|
||
) : (
|
||
<span className="font-['Cormorant_Garamond',serif] text-[1.1rem] text-[#1A1A1A]">
|
||
{shopName}
|
||
</span>
|
||
)}
|
||
</Link>
|
||
|
||
<div
|
||
className="flex items-center h-[30px] gap-6 lg:gap-[39px] shrink-0 pl-6 border-l border-[rgba(26,26,24,0.10)]"
|
||
>
|
||
<SearchToggle className="hover:opacity-50 transition-opacity hidden lg:flex lg:items-center text-[#1A1A1A]" />
|
||
|
||
<NavLinkOrA
|
||
href="/wishlist"
|
||
className="relative hover:opacity-50 transition-opacity hidden lg:flex lg:items-center text-[#1A1A1A]"
|
||
aria-label="Wishlist"
|
||
>
|
||
<Heart size={18} strokeWidth={1.4} />
|
||
{wishlistCount > 0 && (
|
||
<span
|
||
className="absolute flex items-center justify-center rounded-full min-w-[15px] h-[15px] left-[9px] top-[-6px] px-1 text-white font-['Raleway',sans-serif] font-semibold text-[8.5px] leading-[12.75px]"
|
||
style={{background: utilityBurgundy}}
|
||
>
|
||
{wishlistCount > 99 ? '99+' : wishlistCount}
|
||
</span>
|
||
)}
|
||
</NavLinkOrA>
|
||
|
||
<div
|
||
className="hidden lg:flex lg:items-center relative"
|
||
ref={userMenuRef}
|
||
>
|
||
{isLoggedIn ? (
|
||
<AccountDropdown
|
||
customer={accountCustomer}
|
||
userMenuOpen={userMenuOpen}
|
||
setUserMenuOpen={setUserMenuOpen}
|
||
utilityBurgundy={utilityBurgundy}
|
||
accountLinks={accountLinks}
|
||
/>
|
||
) : (
|
||
<Link
|
||
to="/account/login"
|
||
prefetch="intent"
|
||
className="hover:opacity-50 transition-opacity flex items-center text-[#1A1A1A] bg-transparent border-none cursor-pointer p-0"
|
||
aria-label="Sign in"
|
||
>
|
||
<User size={18} strokeWidth={1.4} />
|
||
</Link>
|
||
)}
|
||
</div>
|
||
|
||
<CartToggle cart={cart} utilityBurgundy={utilityBurgundy} />
|
||
|
||
<button
|
||
type="button"
|
||
className="lg:hidden hover:opacity-50 transition-opacity text-[#1A1A1A] bg-transparent border-none cursor-pointer p-0"
|
||
onClick={() => setMenuOpen(!menuOpen)}
|
||
aria-label="Menu"
|
||
>
|
||
{menuOpen ? (
|
||
<X size={21} strokeWidth={1.4} />
|
||
) : (
|
||
<Menu size={21} strokeWidth={1.4} />
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
className="lg:hidden overflow-hidden transition-all duration-500"
|
||
style={{
|
||
maxHeight: menuOpen ? '700px' : '0px',
|
||
background: '#FAF8F3',
|
||
borderTop: menuOpen ? '1px solid rgba(26, 26, 26, 0.08)' : 'none',
|
||
}}
|
||
>
|
||
<div className="px-8 py-8 flex flex-col gap-6">
|
||
{navLinks.map((link) => (
|
||
<NavLinkOrA
|
||
key={link.href}
|
||
href={normalizeStoreHref(
|
||
link.href,
|
||
primaryDomainUrl,
|
||
publicStoreDomain,
|
||
)}
|
||
onClick={() => setMenuOpen(false)}
|
||
className="no-underline font-['Cormorant_Garamond',serif] text-[1.75rem] font-light text-[#1A1A1A] tracking-[0.02em]"
|
||
>
|
||
{link.label}
|
||
</NavLinkOrA>
|
||
))}
|
||
<div
|
||
className="pt-6 flex flex-col gap-4 border-t border-[rgba(26,26,26,0.08)]"
|
||
>
|
||
<MobileSearchButton onOpen={() => setMenuOpen(false)} />
|
||
<NavLinkOrA
|
||
href="/wishlist"
|
||
onClick={() => setMenuOpen(false)}
|
||
className="font-['Raleway',sans-serif] text-[0.8125rem] tracking-[0.14em] text-[#5A5550] uppercase no-underline"
|
||
>
|
||
Wishlist {wishlistCount > 0 && `(${wishlistCount})`}
|
||
</NavLinkOrA>
|
||
{isLoggedIn ? (
|
||
<>
|
||
<NavLinkOrA
|
||
href="/account/profile"
|
||
onClick={() => setMenuOpen(false)}
|
||
className="font-['Raleway',sans-serif] text-[0.8125rem] tracking-[0.14em] text-[#5A5550] uppercase no-underline"
|
||
>
|
||
My Profile
|
||
</NavLinkOrA>
|
||
<Form method="post" action="/account/logout">
|
||
<button
|
||
type="submit"
|
||
className="bg-transparent border-none cursor-pointer font-['Raleway',sans-serif] text-[0.8125rem] tracking-[0.14em] text-[#c0392b] uppercase text-left p-0"
|
||
onClick={() => setMenuOpen(false)}
|
||
>
|
||
Sign Out
|
||
</button>
|
||
</Form>
|
||
</>
|
||
) : (
|
||
<Link
|
||
to="/account/login"
|
||
prefetch="intent"
|
||
onClick={() => setMenuOpen(false)}
|
||
className="bg-transparent border-none cursor-pointer font-['Raleway',sans-serif] text-[0.8125rem] tracking-[0.14em] text-[#7A2340] uppercase text-left p-0 no-underline"
|
||
>
|
||
Sign In / Register
|
||
</Link>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
);
|
||
}
|
||
|
||
function MobileSearchButton({onOpen}: {onOpen: () => void}) {
|
||
const {open} = useAside();
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
onOpen();
|
||
open('search');
|
||
}}
|
||
className="text-left bg-transparent border-none cursor-pointer p-0 font-['Raleway',sans-serif] text-[0.8125rem] tracking-[0.14em] text-[#5A5550] uppercase"
|
||
>
|
||
Search
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function normalizeStoreHref(
|
||
href: string,
|
||
primaryDomainUrl: string,
|
||
publicStoreDomain: string,
|
||
) {
|
||
if (isExternalHref(href)) return href;
|
||
if (
|
||
href.includes('myshopify.com') ||
|
||
(publicStoreDomain && href.includes(publicStoreDomain)) ||
|
||
(primaryDomainUrl && href.includes(primaryDomainUrl))
|
||
) {
|
||
try {
|
||
return new URL(href).pathname;
|
||
} catch {
|
||
return href;
|
||
}
|
||
}
|
||
return href;
|
||
}
|
||
|
||
function AccountDropdown({
|
||
customer,
|
||
userMenuOpen,
|
||
setUserMenuOpen,
|
||
utilityBurgundy,
|
||
accountLinks,
|
||
}: {
|
||
customer: CustomerFragment | null;
|
||
userMenuOpen: boolean;
|
||
setUserMenuOpen: (v: boolean | ((b: boolean) => boolean)) => void;
|
||
utilityBurgundy: string;
|
||
accountLinks: AbiirHeaderNavItem[];
|
||
}) {
|
||
const initials = getInitials(customer);
|
||
const displayName = getDisplayName(customer);
|
||
const email = customer?.emailAddress?.emailAddress ?? '';
|
||
|
||
return (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={() => setUserMenuOpen((o) => !o)}
|
||
className="flex items-center gap-[8.5px] hover:opacity-80 transition-opacity bg-transparent border-none cursor-pointer p-0 text-[#1A1A1A] h-[30px]"
|
||
aria-label="Account menu"
|
||
>
|
||
<div
|
||
className="w-[30px] h-[30px] rounded-[15px] flex items-center justify-center"
|
||
style={{background: utilityBurgundy}}
|
||
>
|
||
<span className="font-['Cormorant_Garamond',serif] text-[13.813px] font-medium text-[#FAF8F3] tracking-[1.63px] leading-[20.719px] uppercase">
|
||
{initials}
|
||
</span>
|
||
</div>
|
||
<ChevronDown
|
||
size={13}
|
||
strokeWidth={1.8}
|
||
className="text-[#1A1A1A] transition-transform duration-200"
|
||
style={{transform: userMenuOpen ? 'rotate(180deg)' : 'none'}}
|
||
/>
|
||
</button>
|
||
{userMenuOpen && (
|
||
<div className="absolute top-[calc(100%+14px)] right-0 min-w-[220px] z-[200] bg-[#FAF8F3] border border-[rgba(26,26,26,0.10)] shadow-[0_16px_48px_rgba(26,26,26,0.13)]">
|
||
<div className="px-5 py-4 border-b border-[rgba(26,26,26,0.07)]">
|
||
<p className="font-['Cormorant_Garamond',serif] text-[1.0625rem] font-normal text-[#1A1A1A] leading-[1.2] m-0">
|
||
{displayName}
|
||
</p>
|
||
{email ? (
|
||
<p className="font-['Raleway',sans-serif] text-[0.75rem] text-[#8A8480] font-light mt-[3px] m-0">
|
||
{email}
|
||
</p>
|
||
) : (
|
||
<p className="font-['Raleway',sans-serif] text-[0.75rem] text-[#8A8480] font-light mt-[3px] m-0 min-h-[14px]" />
|
||
)}
|
||
</div>
|
||
{accountLinks.map((item) => (
|
||
<NavLinkOrA
|
||
key={item.label}
|
||
href={item.href}
|
||
onClick={() => setUserMenuOpen(false)}
|
||
className="flex items-center gap-[10px] px-5 py-[13px] font-['Raleway',sans-serif] text-[0.8125rem] font-normal text-[#1A1A1A] no-underline border-b border-[rgba(26,26,26,0.05)] transition-colors duration-150 hover:bg-[#F5F1E8]"
|
||
>
|
||
<span className="text-[#7A2340]">
|
||
{item.label === 'My Profile' ? (
|
||
<User size={14} strokeWidth={1.5} />
|
||
) : item.label.toLowerCase().includes('order') ? (
|
||
<Package size={14} strokeWidth={1.5} />
|
||
) : (
|
||
<Heart size={14} strokeWidth={1.5} />
|
||
)}
|
||
</span>
|
||
{item.label}
|
||
</NavLinkOrA>
|
||
))}
|
||
<Form method="post" action="/account/logout" className="m-0">
|
||
<button
|
||
type="submit"
|
||
className="w-full flex items-center gap-[10px] px-5 py-[13px] bg-transparent border-none cursor-pointer font-['Raleway',sans-serif] text-[0.8125rem] font-normal text-[#c0392b] text-left normal-case tracking-normal hover:bg-[#fce4ec] transition-colors duration-150"
|
||
>
|
||
<LogOut size={14} strokeWidth={1.5} /> Sign Out
|
||
</button>
|
||
</Form>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function getInitials(customer: CustomerFragment | null) {
|
||
return getCustomerInitials(customer);
|
||
}
|
||
|
||
function getDisplayName(customer: CustomerFragment | null) {
|
||
if (!customer) return 'Customer';
|
||
return getCustomerDisplayName(customer) || 'Customer';
|
||
}
|
||
|
||
function SearchToggle({className}: {className?: string}) {
|
||
const {open} = useAside();
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={className}
|
||
onClick={() => open('search')}
|
||
aria-label="Search"
|
||
>
|
||
<Search size={18} strokeWidth={1.4} />
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function CartToggle({
|
||
cart,
|
||
utilityBurgundy,
|
||
}: {
|
||
cart: CartApiQueryFragment | null;
|
||
utilityBurgundy: string;
|
||
}) {
|
||
const cartState = useOptimisticCart(cart);
|
||
return (
|
||
<CartBadge
|
||
count={cartState?.totalQuantity ?? 0}
|
||
utilityBurgundy={utilityBurgundy}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function CartBadge({
|
||
count,
|
||
utilityBurgundy,
|
||
}: {
|
||
count: number;
|
||
utilityBurgundy: string;
|
||
}) {
|
||
const {open} = useAside();
|
||
const {publish, shop, cart, prevCart} = useAnalytics();
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
open('cart');
|
||
publish('cart_viewed', {
|
||
cart,
|
||
prevCart,
|
||
shop,
|
||
url: typeof window !== 'undefined' ? window.location.href : '',
|
||
} as CartViewPayload);
|
||
}}
|
||
className="relative hover:opacity-50 transition-opacity flex items-center text-[#1A1A1A] bg-transparent border-none cursor-pointer p-0"
|
||
aria-label="Cart"
|
||
>
|
||
<ShoppingBag size={18} strokeWidth={1.4} />
|
||
{count > 0 && (
|
||
<span
|
||
className="absolute flex items-center justify-center rounded-full min-w-[15px] h-[15px] left-[9px] top-[-6px] px-1 text-white font-['Raleway',sans-serif] font-semibold text-[8.5px] leading-[12.75px]"
|
||
style={{background: utilityBurgundy}}
|
||
>
|
||
{count > 99 ? '99+' : count}
|
||
</span>
|
||
)}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
export async function loadAbiirHeaderCms(args: {
|
||
sanity: import('hydrogen-sanity').SanityContext;
|
||
}): Promise<{
|
||
headerCms: AbiirHeaderCms;
|
||
headerSanityInitial: QueryResponseInitial<AbiirHeaderCms | null>;
|
||
}> {
|
||
const initial = await args.sanity
|
||
.loadQuery<SanityHeaderPayload>(HEADER_SANITY_QUERY)
|
||
.catch(
|
||
(): QueryResponseInitial<SanityHeaderPayload> => ({
|
||
data: null,
|
||
sourceMap: undefined,
|
||
}),
|
||
);
|
||
const normalized = normalizeHeaderSanityPayload(initial.data);
|
||
return {
|
||
headerCms: normalized ?? MOCK_HEADER_CMS,
|
||
headerSanityInitial: {
|
||
data: normalized,
|
||
sourceMap: undefined,
|
||
},
|
||
};
|
||
}
|
||
|
||
export async function loadAccountCustomer(context: {
|
||
customerAccount: import('@shopify/hydrogen').CustomerAccount;
|
||
}) {
|
||
const {customerAccount} = context;
|
||
const loggedIn = await customerAccount.isLoggedIn();
|
||
if (!loggedIn) return null;
|
||
await customerAccount.handleAuthStatus();
|
||
const {data, errors} = await customerAccount.query(CUSTOMER_DETAILS_QUERY, {
|
||
variables: {
|
||
language: customerAccount.i18n.language,
|
||
},
|
||
});
|
||
if (errors?.length || !data?.customer) return null;
|
||
return data.customer;
|
||
}
|