feat: initial commit — storefront, convex backend, and shared packages

Completes the first milestone of The Pet Loft ecommerce platform:
- apps/storefront: full customer-facing Next.js app with HeroUI (cart,
  checkout, orders, wishlist, product detail, shop, search, auth)
- convex/: serverless backend with schema, queries, mutations, actions,
  HTTP routes, Stripe/Shippo integrations, and co-located tests
- packages/types, packages/utils, packages/convex: shared workspace packages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 09:31:18 +03:00
commit cc15338ad9
361 changed files with 45005 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
"use client";
import { DesktopHeader } from "./desktop/DesktopHeader";
import { MobileHeader } from "./mobile/MobileHeader";
export function Header() {
return (
<>
{/* Desktop Header - hidden on mobile/tablet */}
<div className="hidden lg:block">
<DesktopHeader />
</div>
{/* Mobile Header - shown on mobile and tablet */}
<div className="block w-full max-w-full min-w-0 overflow-x-clip lg:hidden">
<MobileHeader />
</div>
</>
);
}

View File

@@ -0,0 +1,212 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
useProductSearch,
useClickOutside,
SEARCH_CATEGORIES,
MIN_SEARCH_LENGTH,
} from "@/lib/search";
import type { SearchCategory } from "@/lib/search";
import { SearchResultsPanel } from "@/components/search/SearchResultsPanel";
import { getProductUrl } from "@/lib/shop/productMapper";
interface HeaderSearchBarProps {
variant: "desktop" | "mobile";
}
export function HeaderSearchBar({ variant }: HeaderSearchBarProps) {
const router = useRouter();
const search = useProductSearch();
const searchBarRef = useRef<HTMLDivElement>(null);
// Desktop-only: custom category dropdown state
const [selectedCategory, setSelectedCategory] = useState<SearchCategory>(
SEARCH_CATEGORIES[0],
);
const [dropdownOpen, setDropdownOpen] = useState(false);
useClickOutside(
[searchBarRef, search.panelRef],
search.close,
search.isOpen,
);
function handleCategorySelect(cat: SearchCategory) {
setSelectedCategory(cat);
search.setCategorySlug(cat.slug);
setDropdownOpen(false);
}
function handleSearchButtonClick() {
if (search.query.length >= MIN_SEARCH_LENGTH) {
router.push(`/shop?search=${encodeURIComponent(search.query)}`);
search.close();
} else {
search.open();
}
}
const isDesktop = variant === "desktop";
return (
<div
ref={searchBarRef}
className={
isDesktop
? "relative flex w-full max-w-2xl items-center rounded-full border border-[#d9e8e7] bg-[#f9fcfb] shadow-sm transition-shadow focus-within:border-[#38a99f] focus-within:shadow-md"
: "relative flex items-center rounded-full border border-[#d9e8e7] bg-[#f9fcfb] p-1.5 shadow-sm transition-shadow focus-within:border-[#38a99f] focus-within:shadow-md"
}
>
{/* Category picker */}
{isDesktop ? (
/* Desktop: custom dropdown */
<div className="relative">
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
onBlur={() => setTimeout(() => setDropdownOpen(false), 150)}
className="flex items-center gap-1.5 rounded-l-full py-3 pl-5 pr-3 text-sm text-[#3d5554] transition-colors hover:text-[#1a2e2d]"
>
<span className="max-w-[110px] truncate">{selectedCategory.label}</span>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className={`transition-transform ${dropdownOpen ? "rotate-180" : ""}`}
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{dropdownOpen && (
<div className="absolute left-0 top-full z-50 mt-1 min-w-[180px] overflow-hidden rounded-xl border border-[#e8f7f6] bg-white py-1 shadow-lg">
{SEARCH_CATEGORIES.map((cat) => (
<button
key={cat.label}
onClick={() => handleCategorySelect(cat)}
className={`block w-full px-4 py-2 text-left text-sm transition-colors hover:bg-[#e8f7f6] ${
selectedCategory.label === cat.label
? "font-medium text-[#236f6b]"
: "text-[#3d5554]"
}`}
>
{cat.label}
</button>
))}
</div>
)}
</div>
) : (
/* Mobile: native select leverages OS picker */
<div className="flex items-center gap-1 border-r border-[#d9e8e7] pl-3 pr-2">
<select
value={search.categorySlug ?? ""}
onChange={(e) => search.setCategorySlug(e.target.value || undefined)}
className="max-w-[80px] appearance-none truncate bg-transparent text-xs font-medium text-[#3d5554] outline-none"
aria-label="Search category"
>
{SEARCH_CATEGORIES.map((cat) => (
<option key={cat.label} value={cat.slug ?? ""}>
{cat.label}
</option>
))}
</select>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0 text-[#8aa9a8]"
aria-hidden="true"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
)}
{/* Divider — desktop only, between category picker and input */}
{isDesktop && <div className="h-6 w-px bg-[#d9e8e7]" />}
{/* Input */}
<input
ref={search.inputRef}
type="text"
value={search.query}
onChange={(e) => search.setQuery(e.target.value)}
onFocus={() => search.open()}
onKeyDown={search.handleKeyDown}
onBlur={search.handleBlur}
placeholder={
isDesktop
? "Search for products, brands, and more..."
: "Search for food, toys, etc."
}
className={
isDesktop
? "flex-1 bg-transparent py-3 pl-4 pr-3 text-sm text-[#1a2e2d] outline-none placeholder:text-[#8aa9a8]"
: "flex-1 border-none bg-transparent pl-3 text-sm text-[#1a2e2d] outline-none placeholder:text-[#8aa9a8] focus:ring-0"
}
role="combobox"
aria-expanded={search.isOpen && search.showResults}
aria-controls="search-results-panel"
aria-activedescendant={
search.selectedIndex >= 0
? `search-result-${search.selectedIndex}`
: undefined
}
aria-autocomplete="list"
autoComplete="off"
/>
{/* Search Button */}
<button
onClick={handleSearchButtonClick}
aria-label="Search"
className={`${isDesktop ? "mr-1.5 " : ""}flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#236f6b] text-white transition-colors hover:bg-[#38a99f]`}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</button>
{/* Results Panel */}
{(search.showResults || search.showMinCharsHint) && (
<SearchResultsPanel
results={search.results}
isLoading={search.isLoading}
isStalled={search.isStalled}
isEmpty={search.isEmpty}
query={search.query}
selectedIndex={search.selectedIndex}
onItemSelect={(item) => {
router.push(getProductUrl(item));
search.close();
}}
onItemHover={search.setSelectedIndex}
showMinCharsHint={search.showMinCharsHint}
panelRef={search.panelRef}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,242 @@
<!DOCTYPE html>
<html class="light" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>Pet Store - Modern Home</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,600;0,9..144,700;1,9..144,400&amp;family=DM+Sans:ital,wght@0,300;0,400;0,500;0,700;1,400&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<style type="text/tailwindcss">
:root {
--deep-teal: #236f6b;
--accent: #38a99f;
--soft-teal: #8dd5d1;
--default: #e8f7f6;
--surface-secondary: #e8f7f6;
--sunny-amber: #f4a13a;
--amber-cream: #fde8c8;
--playful-coral: #f2705a;
--coral-blush: #fce0da;
--foreground: #1a2e2d;
--muted: #3d5554;
--border: #8aa9a8;
--field-placeholder: #8aa9a8;
--background: #f0f8f7;
--surface: #ffffff;
--font-fraunces: 'Fraunces', serif;
--radius: 0.75rem;
}
body {
font-family: 'DM Sans', sans-serif;
background-color: var(--background);
color: var(--foreground);
}
.font-display {
font-family: var(--font-fraunces);
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.product-card-shadow {
box-shadow: 0 4px 6px -1px rgba(56, 169, 159, 0.1), 0 2px 4px -1px rgba(56, 169, 159, 0.06);
}
.product-card-shadow:hover {
box-shadow: 0 10px 15px -3px rgba(56, 169, 159, 0.2);
transform: translateY(-4px);
}
</style>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"primary": "#38a99f",
},
fontFamily: {
"sans": ["DM Sans", "sans-serif"],
"serif": ["Fraunces", "serif"]
},
borderRadius: {
"DEFAULT": "0.75rem",
"lg": "1rem",
"xl": "1.5rem",
"full": "9999px"
},
},
},
}
</script>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="min-h-screen flex flex-col bg-[#f0f8f7]">
<div class="flex items-center justify-between px-4 py-2 bg-white border-b border-[#8aa9a8]/20">
<span class="text-[10px] font-medium tracking-widest text-[#3d5554] uppercase font-sans">petstore.com</span>
<div class="flex items-center gap-3">
<span class="bg-[#fce0da] text-[#f2705a] text-[10px] font-bold px-2 py-0.5 rounded-sm font-sans uppercase">10% OFF</span>
<span class="material-symbols-outlined text-[#3d5554] text-lg cursor-pointer">call</span>
</div>
</div>
<header class="bg-[#236f6b] px-4 pt-6 pb-2 sticky top-0 z-50">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-2">
<div class="w-9 h-9 bg-white/10 rounded-lg flex items-center justify-center">
<span class="material-symbols-outlined text-[#38a99f] text-xl font-bold">pets</span>
</div>
<h1 class="text-xl font-bold tracking-tight text-white font-serif">
Pet<span class="text-[#f4a13a]">Store</span>
</h1>
</div>
<div class="flex items-center gap-4">
<button class="relative p-1">
<span class="material-symbols-outlined text-white/75 hover:text-white transition-colors">favorite</span>
<span class="absolute top-1 right-1 w-2 h-2 bg-[#f2705a] rounded-full"></span>
</button>
<button class="p-1">
<span class="material-symbols-outlined text-white/75 hover:text-white transition-colors">shopping_bag</span>
</button>
<button class="w-8 h-8 rounded-full bg-white/10 overflow-hidden border border-white/20">
<img alt="Profile" class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCnapMxzVZKxuHUBEXawVka0boJ9Y2sdWkYbTpigfEw5NtyPo2JtMNF9ZHQ-A8sgRebAnjVWXT1NyccaPWF0obFuiOd3VzwV1G29SsDZccSf-Wigc__vfnooY2SW0grPY9c0oXCwdDXuaSOIFEQ06STsebuvIcHT3w6tTrLTuEoKYEdM_8lrm7K8vecZZVULNcUxnSD32eeSyUBEVEuwo15MpIlji74Voi0wmnB1V46KPwR_S3zCiP7B4jiJA9JHrslgwXE7Az54hxW"/>
</button>
</div>
</div>
<div class="relative flex items-center bg-white rounded-[0.75rem] p-1.5 shadow-sm border border-transparent mb-4">
<div class="flex items-center gap-1 pl-3 pr-2 border-r border-[#8aa9a8]/30">
<span class="text-xs font-medium text-[#3d5554] truncate max-w-[80px] font-sans">All Categories</span>
<span class="material-symbols-outlined text-sm text-[#8aa9a8]">expand_more</span>
</div>
<input class="flex-1 bg-transparent border-none focus:ring-0 text-sm text-[#1a2e2d] placeholder:text-[#8aa9a8] font-sans" placeholder="Search for food, toys, etc." type="text"/>
<button class="w-10 h-10 bg-[#38a99f] hover:brightness-110 transition-colors rounded-[0.75rem] flex items-center justify-center text-white">
<span class="material-symbols-outlined">search</span>
</button>
</div>
</header>
<main class="flex-1 overflow-y-auto pb-24">
<div class="bg-white border-b border-[#8aa9a8]/10">
<div class="flex items-center gap-2 overflow-x-auto px-4 py-3 hide-scrollbar">
<button class="flex items-center gap-1.5 shrink-0 px-4 py-2 bg-[#e8f7f6] text-[#1a2e2d] rounded-full font-medium text-xs border border-[#8dd5d1]/30 font-sans">
<span class="material-symbols-outlined text-sm text-[#38a99f]">pets</span>
Dogs
<span class="material-symbols-outlined text-sm">keyboard_arrow_down</span>
</button>
<button class="flex items-center gap-1.5 shrink-0 px-4 py-2 text-[#3d5554] rounded-full font-medium text-xs hover:bg-[#e8f7f6] font-sans transition-colors">
<span class="material-symbols-outlined text-sm">pets</span>
Cats
<span class="material-symbols-outlined text-sm">keyboard_arrow_down</span>
</button>
<button class="flex items-center gap-1.5 shrink-0 px-4 py-2 text-[#3d5554] rounded-full font-medium text-xs hover:bg-[#e8f7f6] font-sans transition-colors">
<span class="material-symbols-outlined text-sm text-[#8dd5d1]">medical_services</span>
Pharmacy
<span class="material-symbols-outlined text-sm">keyboard_arrow_down</span>
</button>
<button class="flex items-center gap-1.5 shrink-0 px-4 py-2 text-[#3d5554] rounded-full font-medium text-xs hover:bg-[#e8f7f6] font-sans transition-colors">
<span class="material-symbols-outlined text-sm">verified</span>
Brands
<span class="material-symbols-outlined text-sm">keyboard_arrow_down</span>
</button>
</div>
</div>
<section class="bg-[#e8f7f6]">
<div class="grid grid-cols-2 gap-3 px-4 py-6">
<a class="flex flex-col items-start gap-2 p-4 bg-white rounded-[0.75rem] border border-[#8aa9a8]/10 transition-all product-card-shadow" href="#">
<div class="w-10 h-10 bg-[#fce0da] rounded-full flex items-center justify-center text-[#f2705a]">
<span class="material-symbols-outlined">sell</span>
</div>
<div>
<p class="font-serif font-semibold text-[#1a2e2d] leading-tight">Promotions</p>
<p class="text-[10px] text-[#f2705a] font-bold uppercase tracking-wider font-sans">Flash Sales</p>
</div>
</a>
<a class="flex flex-col items-start gap-2 p-4 bg-white rounded-[0.75rem] border border-[#8aa9a8]/10 transition-all product-card-shadow" href="#">
<div class="w-10 h-10 bg-[#fde8c8] rounded-full flex items-center justify-center text-[#f4a13a]">
<span class="material-symbols-outlined">tips_and_updates</span>
</div>
<div>
<p class="font-serif font-semibold text-[#1a2e2d] leading-tight">Tips &amp; Tricks</p>
<p class="text-[10px] text-[#f4a13a] font-bold uppercase tracking-wider font-sans">Expert Advice</p>
</div>
</a>
</div>
</section>
<div class="px-4 py-6 bg-white">
<div class="relative h-56 w-full rounded-xl overflow-hidden shadow-xl group">
<img alt="Happy Dog" class="w-full h-full object-cover" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBEuRo5WgBTT4osxjXazPDRQuBOEsKqmqbBo_ghuBKWOCISBAAcwZGC56tc-QIakBAGhc77KDs3ISzKgBz-Cuuuh3SlGmPT6Y_dFs5WzPLhMbIqeriwQoMG1MqSsRI8zom4nlFAjlPbeQT5jfVA-6UtfDwt6cybeEo5NI8t4nTB3FWUstGEKcKlqEW3INs3nZVQev8W82DTdwZ7ubWhJg7AITqLuwrWIuZqmTdn-J32trGHQV4CmzeacPm3-K4Z7R28CVi-btxx-nWn"/>
<div class="absolute inset-0 bg-gradient-to-r from-[#236f6b] via-[#38a99f]/60 to-transparent flex flex-col justify-center px-8">
<span class="bg-[#fce0da] text-[#f2705a] text-[10px] font-bold px-2 py-0.5 rounded w-fit mb-2 font-sans uppercase">LIMITED TIME</span>
<h2 class="text-white text-2xl font-serif font-bold mb-1">Premium Kibble</h2>
<p class="text-white/82 text-sm font-light mb-5 font-sans">Up to 30% off on Royal Canin</p>
<button class="bg-[#f4a13a] text-[#1a2e2d] font-bold px-6 py-2.5 rounded-full text-xs w-fit hover:brightness-110 transition-all font-sans">Shop Now</button>
</div>
</div>
</div>
<section class="bg-[#e8f7f6] px-4 py-8">
<div class="flex items-center justify-between mb-6">
<h3 class="font-serif font-bold text-lg text-[#1a2e2d]">Popular Categories</h3>
<span class="text-sm font-semibold text-[#38a99f] font-sans">View All</span>
</div>
<div class="grid grid-cols-4 gap-3">
<div class="flex flex-col items-center gap-2">
<div class="w-14 h-14 bg-white rounded-full border border-[#8aa9a8]/20 flex items-center justify-center shadow-sm">
<span class="material-symbols-outlined text-[#38a99f]">restaurant</span>
</div>
<span class="text-[10px] font-bold text-[#3d5554] uppercase tracking-tight font-sans">Food</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="w-14 h-14 bg-white rounded-full border border-[#8aa9a8]/20 flex items-center justify-center shadow-sm">
<span class="material-symbols-outlined text-[#38a99f]">toys</span>
</div>
<span class="text-[10px] font-bold text-[#3d5554] uppercase tracking-tight font-sans">Toys</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="w-14 h-14 bg-white rounded-full border border-[#8aa9a8]/20 flex items-center justify-center shadow-sm">
<span class="material-symbols-outlined text-[#38a99f]">bed</span>
</div>
<span class="text-[10px] font-bold text-[#3d5554] uppercase tracking-tight font-sans">Beds</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="w-14 h-14 bg-white rounded-full border border-[#8aa9a8]/20 flex items-center justify-center shadow-sm">
<span class="material-symbols-outlined text-[#38a99f]">stroller</span>
</div>
<span class="text-[10px] font-bold text-[#3d5554] uppercase tracking-tight font-sans">Travel</span>
</div>
</div>
</section>
</main>
<nav class="fixed bottom-0 left-0 right-0 bg-[#236f6b] border-t border-white/10 px-4 pb-6 pt-3 z-50">
<div class="flex items-center justify-around max-w-md mx-auto">
<a class="flex flex-col items-center gap-1 group" href="#">
<div class="text-[#f4a13a] p-1">
<span class="material-symbols-outlined text-[26px] font-variation-fill">home</span>
</div>
<span class="text-[10px] font-medium text-white tracking-tight font-sans">Home</span>
</a>
<a class="flex flex-col items-center gap-1 group" href="#">
<div class="text-white/75 group-hover:text-white transition-colors p-1">
<span class="material-symbols-outlined text-[26px]">storefront</span>
</div>
<span class="text-[10px] font-medium text-white/75 group-hover:text-white tracking-tight transition-colors font-sans">Shop</span>
</a>
<a class="flex flex-col items-center gap-1 group" href="#">
<div class="text-white/75 group-hover:text-white transition-colors p-1">
<span class="material-symbols-outlined text-[26px]">receipt_long</span>
</div>
<span class="text-[10px] font-medium text-white/75 group-hover:text-white tracking-tight transition-colors font-sans">Orders</span>
</a>
<a class="flex flex-col items-center gap-1 group" href="#">
<div class="text-white/75 group-hover:text-white transition-colors p-1">
<span class="material-symbols-outlined text-[26px]">settings</span>
</div>
<span class="text-[10px] font-medium text-white/75 group-hover:text-white tracking-tight transition-colors font-sans">Settings</span>
</a>
</div>
</nav>
</body></html>

View File

@@ -0,0 +1,191 @@
"use client";
import { useState } from "react";
import { Link } from "@heroui/react";
import {
navCategories,
navCategoryOrder,
navCategoryLabels,
type NavSubcategory,
} from "@/lib/shop/navCategories";
const primaryLinks = navCategoryOrder.map((key) => ({
key,
label: navCategoryLabels[key],
href: `/shop/${navCategories[key].slug}`,
hasDropdown: true,
}));
const highlightedLinks = [
{ label: "Special Offers", href: "/shop/sale" },
{ label: "Tips & Tricks", href: "/tips" },
];
function ChevronDown({ open }: { open?: boolean }) {
return (
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className={`transition-transform duration-200 ${open ? "rotate-180" : ""}`}
>
<polyline points="6 9 12 15 18 9" />
</svg>
);
}
function NavDropdown({
label,
href,
categorySlug,
subcategories,
isOpen,
onOpen,
onClose,
}: {
label: string;
href: string;
categorySlug: string;
subcategories: NavSubcategory[];
isOpen: boolean;
onOpen: () => void;
onClose: () => void;
}) {
const colCount =
subcategories.length <= 6 ? 2 : subcategories.length <= 12 ? 3 : 4;
return (
<div className="relative" onMouseEnter={onOpen} onMouseLeave={onClose}>
<button
className={`flex items-center gap-1.5 rounded-lg px-4 py-3 text-sm font-medium transition-colors ${
isOpen
? "bg-[#e8f7f6] text-[#236f6b]"
: "text-[#3d5554] hover:bg-[#e8f7f6] hover:text-[#236f6b]"
}`}
>
<span>{label}</span>
<ChevronDown open={isOpen} />
</button>
<div
className={`absolute left-0 top-full z-50 pt-1 transition-all duration-150 ${
isOpen
? "pointer-events-auto visible translate-y-0 opacity-100"
: "pointer-events-none invisible -translate-y-1 opacity-0"
}`}
>
<div className="w-max rounded-xl border border-[#e8f7f6] bg-white px-5 pb-4 pt-5 shadow-xl">
<div
className="grid gap-x-5"
style={{
gridTemplateColumns: `repeat(${colCount}, max-content)`,
}}
>
{subcategories.map((sub) => (
<Link
key={sub.slug}
href={`/shop/${categorySlug}/${sub.slug}`}
className="whitespace-nowrap rounded-md px-2.5 py-1.5 text-sm text-[#3d5554] no-underline transition-colors hover:bg-[#e8f7f6] hover:text-[#236f6b]"
>
{sub.name}
</Link>
))}
</div>
<div className="mt-3.5 border-t border-[#e8f7f6] pt-3">
<Link
href={href}
className="inline-flex items-center gap-1.5 text-sm font-medium text-[#236f6b] no-underline transition-colors hover:text-[#38a99f]"
>
Browse all {label}
<Link.Icon />
</Link>
</div>
</div>
</div>
</div>
);
}
export function BottomNav() {
const [openMenu, setOpenMenu] = useState<string | null>(null);
return (
<nav className="relative w-full border-b border-[#e8f7f6] bg-white">
<div className="mx-auto flex max-w-[1400px] items-center justify-between px-6">
<ul className="flex items-center gap-1">
{primaryLinks.map((link) => {
const category = navCategories[link.key];
const subcategories = category.subcategories;
return (
<li key={link.key}>
<NavDropdown
label={link.label}
href={link.href}
categorySlug={category.slug}
subcategories={subcategories}
isOpen={openMenu === link.key}
onOpen={() => setOpenMenu(link.key)}
onClose={() => setOpenMenu(null)}
/>
</li>
);
})}
</ul>
<ul className="flex items-center gap-1">
{highlightedLinks.map((link) => (
<li key={link.label}>
<a
href={link.href}
className="flex items-center gap-1.5 rounded-lg px-4 py-3 text-sm font-medium text-[#f2705a] transition-colors hover:bg-[#fce0da]/50"
>
{link.label === "Special Offers" && (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 12 20 22 4 22 4 12" />
<rect x="2" y="7" width="20" height="5" />
<line x1="12" y1="22" x2="12" y2="7" />
<path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z" />
<path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z" />
</svg>
)}
{link.label === "Tips & Tricks" && (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9 18h6" />
<path d="M10 22h4" />
<path d="M12 2a7 7 0 0 0-4 12.7V17h8v-2.3A7 7 0 0 0 12 2z" />
</svg>
)}
<span>{link.label}</span>
</a>
</li>
))}
</ul>
</div>
</nav>
);
}

View File

@@ -0,0 +1,107 @@
"use client";
import { useRef } from "react";
import { useCartUI } from "@/components/cart/CartUIContext";
import { useCart } from "@/lib/cart/useCart";
import { useCartSessionId } from "@/lib/cart/useCartSessionId";
import { useWishlistCount } from "@/lib/wishlist/useWishlistCount";
import Link from "next/link";
import { HeaderUserAction } from "./HeaderUserAction";
import { BrandLogo } from "@/components/layout/BrandLogo";
import { HeaderSearchBar } from "@/components/layout/header/HeaderSearchBar";
export function CoreBrandBar() {
const { isOpen: isCartOpen, openCart, closeCart } = useCartUI();
const cartButtonRef = useRef<HTMLButtonElement>(null);
const sessionId = useCartSessionId();
const { items } = useCart(sessionId);
const cartItemCount = items.reduce((sum, i) => sum + i.quantity, 0);
const wishlistCount = useWishlistCount();
return (
<div className="w-full bg-white">
<div className="mx-auto flex max-w-[1400px] items-center justify-between gap-8 px-6 py-4">
{/* Logo */}
<BrandLogo size={32} />
{/* Search Bar */}
<HeaderSearchBar variant="desktop" />
{/* User Actions */}
<div className="flex shrink-0 items-center gap-6">
<HeaderUserAction />
{/* Wishlist */}
<Link
href="/wishlist"
className="group relative flex flex-col items-center gap-1"
aria-label={`Wishlist${wishlistCount > 0 ? `, ${wishlistCount} items` : ""}`}
>
<div className="relative">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-[#3d5554] transition-colors group-hover:text-[#f2705a]"
>
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
{wishlistCount > 0 && (
<span className="absolute -right-2 -top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-[#f2705a] text-[9px] font-bold text-white">
{wishlistCount > 99 ? "99+" : wishlistCount}
</span>
)}
</div>
<span className="text-[10px] font-medium tracking-wide text-[#3d5554] transition-colors group-hover:text-[#f2705a]">
Wishlist
</span>
</Link>
{/* Cart */}
<button
ref={cartButtonRef}
type="button"
onClick={() =>
isCartOpen
? closeCart()
: openCart(cartButtonRef.current ?? undefined)
}
className="group relative flex flex-col items-center gap-1"
aria-label={`Cart${cartItemCount > 0 ? `, ${cartItemCount} items` : ""}`}
>
<div className="relative">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-[#3d5554] transition-colors group-hover:text-[#236f6b]"
>
<circle cx="9" cy="21" r="1" />
<circle cx="20" cy="21" r="1" />
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />
</svg>
{cartItemCount > 0 && (
<span className="absolute -right-2 -top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-[#f2705a] text-[9px] font-bold text-white">
{cartItemCount > 99 ? "99+" : cartItemCount}
</span>
)}
</div>
<span className="text-[10px] font-medium tracking-wide text-[#3d5554] transition-colors group-hover:text-[#236f6b]">
Cart
</span>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { TopUtilityBar } from "./TopUtilityBar";
import { CoreBrandBar } from "./CoreBrandBar";
import { BottomNav } from "./BottomNav";
export function DesktopHeader() {
return (
<header className="sticky top-0 z-50 w-full shadow-sm">
<TopUtilityBar />
<CoreBrandBar />
<BottomNav />
</header>
);
}

View File

@@ -0,0 +1,78 @@
"use client";
import { useConvexAuth } from "convex/react";
import { UserButton } from "@clerk/nextjs";
function OrdersIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z" />
<line x1="3" y1="6" x2="21" y2="6" />
<path d="M16 10a4 4 0 0 1-8 0" />
</svg>
);
}
export function HeaderUserAction() {
const { isLoading, isAuthenticated } = useConvexAuth();
if (isLoading || !isAuthenticated) {
return (
<a
href="/sign-in"
className="group flex flex-col items-center gap-1"
>
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-[#3d5554] transition-colors group-hover:text-[#236f6b]"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<span className="text-[10px] font-medium tracking-wide text-[#3d5554] transition-colors group-hover:text-[#236f6b]">
Sign In
</span>
</a>
);
}
return (
<div className="flex flex-col items-center gap-1">
<UserButton
afterSignOutUrl="/"
appearance={{
elements: {
avatarBox: "w-[22px] h-[22px]",
},
}}
>
<UserButton.MenuItems>
<UserButton.Link
label="My Orders"
labelIcon={<OrdersIcon />}
href="/account/orders"
/>
</UserButton.MenuItems>
</UserButton>
<span className="text-[10px] font-medium tracking-wide text-[#3d5554]">
Account
</span>
</div>
);
}

View File

@@ -0,0 +1,74 @@
export function TopUtilityBar() {
return (
<div className="w-full bg-[#f5f5f5] border-b border-[#e8e8e8]">
<div className="mx-auto flex max-w-[1400px] items-center justify-between px-6 py-1.5 text-xs">
{/* Domain */}
<span className="tracking-wide text-[#3d5554]">www.thepetloft.com</span>
{/* Promo */}
<div className="flex items-center gap-1.5 font-medium text-[#f2705a]">
<span><strong className="text-[13px]"> 10% </strong>
off your first order</span>
<span></span>
<span><strong className="text-[13px]"> 5% </strong>
off on all Re-orders over <strong>£30</strong></span>
<span></span>
<span>Free shipping on orders over <strong>£40</strong></span>
</div>
{/* Utility links */}
<div className="flex items-center gap-5 text-[#3d5554]">
<button className="flex items-center gap-1.5 transition-colors hover:text-[#1a2e2d]">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
<path d="M14.05 2a9 9 0 0 1 8 7.94" />
<path d="M14.05 6A5 5 0 0 1 18 10" />
</svg>
<span>Contact</span>
</button>
<div className="h-3 w-px bg-[#ccc]" />
<button className="flex items-center gap-1.5 transition-colors hover:text-[#1a2e2d]">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
<span>EN</span>
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,97 @@
"use client";
import { useRef } from "react";
import { useCartUI } from "@/components/cart/CartUIContext";
import { useCart } from "@/lib/cart/useCart";
import { useCartSessionId } from "@/lib/cart/useCartSessionId";
import { useWishlistCount } from "@/lib/wishlist/useWishlistCount";
import Link from "next/link";
import { MobileHeaderUserAction } from "./MobileHeaderUserAction";
import { BrandLogo } from "@/components/layout/BrandLogo";
import { HeaderSearchBar } from "@/components/layout/header/HeaderSearchBar";
export function MobileCoreBrandBar() {
const { isOpen: isCartOpen, openCart, closeCart } = useCartUI();
const cartButtonRef = useRef<HTMLButtonElement>(null);
const sessionId = useCartSessionId();
const { items } = useCart(sessionId);
const cartItemCount = items.reduce((sum, i) => sum + i.quantity, 0);
const wishlistCount = useWishlistCount();
return (
<div className="bg-white px-4 py-4">
{/* Logo and Actions Row */}
<div className="mb-4 flex items-center justify-between">
{/* Logo */}
<BrandLogo
size={26}
textClassName="font-[family-name:var(--font-fraunces)] text-xl font-bold tracking-tight text-[#236f6b]"
/>
{/* Actions */}
<div className="flex items-center gap-4">
<Link
href="/wishlist"
className="relative p-1 text-[#3d5554] transition-colors hover:text-[#f2705a]"
aria-label={`Wishlist${wishlistCount > 0 ? `, ${wishlistCount} items` : ""}`}
>
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
{wishlistCount > 0 && (
<span className="absolute -right-1 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-[#f2705a] text-[9px] font-bold text-white">
{wishlistCount > 99 ? "99+" : wishlistCount}
</span>
)}
</Link>
<button
ref={cartButtonRef}
type="button"
onClick={() =>
isCartOpen
? closeCart()
: openCart(cartButtonRef.current ?? undefined)
}
className="relative p-1 text-[#3d5554] transition-colors hover:text-[#236f6b]"
aria-label={`Cart${cartItemCount > 0 ? `, ${cartItemCount} items` : ""}`}
>
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="9" cy="21" r="1" />
<circle cx="20" cy="21" r="1" />
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />
</svg>
{cartItemCount > 0 && (
<span className="absolute -right-1 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-[#f2705a] text-[9px] font-bold text-white">
{cartItemCount > 99 ? "99+" : cartItemCount}
</span>
)}
</button>
<MobileHeaderUserAction />
</div>
</div>
{/* Search Bar */}
<HeaderSearchBar variant="mobile" />
</div>
);
}

View File

@@ -0,0 +1,99 @@
"use client";
import { useRef } from "react";
import { MobileUtilityBar } from "./MobileUtilityBar";
import { MobileNavButtons } from "./MobileNavButtons";
import { MobileHeaderUserAction } from "./MobileHeaderUserAction";
import Link from "next/link";
import { useCartUI } from "@/components/cart/CartUIContext";
import { useCart } from "@/lib/cart/useCart";
import { useCartSessionId } from "@/lib/cart/useCartSessionId";
import { useWishlistCount } from "@/lib/wishlist/useWishlistCount";
import { BrandLogo } from "@/components/layout/BrandLogo";
import { HeaderSearchBar } from "@/components/layout/header/HeaderSearchBar";
export function MobileHeader() {
const { openCart } = useCartUI();
const cartButtonRef = useRef<HTMLButtonElement>(null);
const sessionId = useCartSessionId();
const { items } = useCart(sessionId);
const cartItemCount = items.reduce((sum, i) => sum + i.quantity, 0);
const wishlistCount = useWishlistCount();
return (
<>
{/* In-flow: utility bar + logo row scroll away with the page */}
<div className="w-full max-w-full min-w-0 overflow-x-hidden bg-white">
<MobileUtilityBar />
<div className="flex min-w-0 items-center justify-between gap-2 border-b border-[#e8f7f6] px-4 py-3">
<BrandLogo
size={26}
textClassName="font-[family-name:var(--font-fraunces)] text-xl font-bold tracking-tight text-[#236f6b]"
/>
<div className="flex shrink-0 items-center gap-3">
<Link
href="/wishlist"
className="relative p-1 text-[#3d5554] transition-colors hover:text-[#f2705a]"
aria-label={`Wishlist${wishlistCount > 0 ? `, ${wishlistCount} items` : ""}`}
>
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
</svg>
{wishlistCount > 0 && (
<span className="absolute -right-1 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-[#f2705a] text-[9px] font-bold text-white">
{wishlistCount > 99 ? "99+" : wishlistCount}
</span>
)}
</Link>
<button
ref={cartButtonRef}
type="button"
onClick={() => openCart(cartButtonRef.current)}
className="relative flex min-h-[44px] min-w-[44px] items-center justify-center p-1 text-[#3d5554] transition-colors hover:text-[#236f6b]"
aria-label={cartItemCount > 0 ? `Cart, ${cartItemCount} items` : "Cart"}
>
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="9" cy="21" r="1" />
<circle cx="20" cy="21" r="1" />
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />
</svg>
{cartItemCount > 0 && (
<span className="absolute right-0 top-0 flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-[#f2705a] px-1 text-[10px] font-bold text-white">
{cartItemCount > 99 ? "99+" : cartItemCount}
</span>
)}
</button>
<MobileHeaderUserAction />
</div>
</div>
</div>
{/* Sticky: search bar + nav stay at top once they reach the viewport */}
{/* overflow-x-clip (not hidden) so the absolute results panel can overflow below */}
<header className="sticky top-0 z-50 w-full max-w-full min-w-0 overflow-x-clip bg-white shadow-sm">
<div className="min-w-0 px-4 py-2">
<HeaderSearchBar variant="mobile" />
</div>
<MobileNavButtons />
</header>
</>
);
}

View File

@@ -0,0 +1,70 @@
"use client";
import { useConvexAuth } from "convex/react";
import { UserButton } from "@clerk/nextjs";
function OrdersIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z" />
<line x1="3" y1="6" x2="21" y2="6" />
<path d="M16 10a4 4 0 0 1-8 0" />
</svg>
);
}
export function MobileHeaderUserAction() {
const { isLoading, isAuthenticated } = useConvexAuth();
if (isLoading || !isAuthenticated) {
return (
<a
href="/sign-in"
className="flex h-8 w-8 items-center justify-center rounded-full border border-[#d9e8e7] bg-[#f9fcfb]"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="text-[#3d5554]"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</a>
);
}
return (
<UserButton
afterSignOutUrl="/"
appearance={{
elements: {
avatarBox: "w-8 h-8",
},
}}
>
<UserButton.MenuItems>
<UserButton.Link
label="My Orders"
labelIcon={<OrdersIcon />}
href="/account/orders"
/>
</UserButton.MenuItems>
</UserButton>
);
}

View File

@@ -0,0 +1,139 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
import Link from "next/link";
import {
navCategories,
navCategoryOrder,
navCategoryLabels,
} from "@/lib/shop/navCategories";
/** Nav items from single source: navCategories.ts */
const navItems = navCategoryOrder.map((key) => ({
key,
label: navCategoryLabels[key],
href: `/shop/${navCategories[key].slug}`,
subcategories: navCategories[key].subcategories,
}));
function ChevronDown({ open }: { open?: boolean }) {
return (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className={`shrink-0 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
aria-hidden
>
<polyline points="6 9 12 15 18 9" />
</svg>
);
}
export function MobileNavButtons() {
const [openLabel, setOpenLabel] = useState<string | null>(null);
const [dropdownTop, setDropdownTop] = useState<number | null>(null);
const triggerRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const containerRef = useRef<HTMLDivElement>(null);
const openItem = openLabel ? navItems.find((i) => i.label === openLabel) : null;
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
const target = e.target as HTMLElement;
if (!target.closest("[role='menu']")) setOpenLabel(null);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
useEffect(() => {
if (!openLabel) {
setDropdownTop(null);
return;
}
const el = triggerRefs.current[openLabel];
if (el) {
const r = el.getBoundingClientRect();
setDropdownTop(r.bottom + 4);
}
}, [openLabel]);
return (
<div ref={containerRef} className="border-b border-[#e8f7f6] bg-white">
<div className="scrollbar-hide flex items-center gap-2 overflow-x-auto px-4 py-3">
{navItems.map((item) => {
const isOpen = openLabel === item.label;
return (
<button
key={item.label}
ref={(el) => {
triggerRefs.current[item.label] = el;
}}
type="button"
onClick={() => setOpenLabel(isOpen ? null : item.label)}
className={`shrink-0 flex items-center gap-1.5 rounded-full border px-4 py-2 font-sans text-xs font-medium transition-colors ${
isOpen
? "border-[#38a99f]/30 bg-[#e8f7f6] text-[#236f6b]"
: "border-transparent text-[#3d5554] hover:bg-[#e8f7f6] hover:text-[#236f6b]"
}`}
aria-expanded={isOpen}
aria-haspopup="true"
aria-controls={isOpen ? `nav-dropdown-${item.label}` : undefined}
id={`nav-trigger-${item.label}`}
>
{item.label}
<ChevronDown open={isOpen} />
</button>
);
})}
</div>
{openItem &&
dropdownTop != null &&
typeof document !== "undefined" &&
createPortal(
<div
id={`nav-dropdown-${openItem.label}`}
role="menu"
className="fixed z-[100] max-h-[60vh] overflow-y-auto rounded-xl border border-[#e8f7f6] bg-white py-2 shadow-lg"
style={{
top: dropdownTop,
left: 16,
right: 16,
}}
>
{openItem.subcategories.map((sub) => (
<Link
key={sub.slug}
href={`/shop/${navCategories[openItem.key].slug}/${sub.slug}`}
role="menuitem"
className="block px-4 py-2.5 font-sans text-sm text-[#3d5554] no-underline transition-colors hover:bg-[#e8f7f6] hover:text-[#236f6b]"
onClick={() => setOpenLabel(null)}
>
{sub.name}
</Link>
))}
<div className="mt-2 border-t border-[#e8f7f6] pt-2">
<Link
href={openItem.href}
className="block px-4 py-2 font-sans text-sm font-medium text-[#236f6b] no-underline transition-colors hover:bg-[#e8f7f6]"
onClick={() => setOpenLabel(null)}
>
Browse all {openItem.label}
</Link>
</div>
</div>,
document.body
)}
</div>
);
}

View File

@@ -0,0 +1,21 @@
"use client";
const PROMO_TEXT =
"☞ 10% off your first order ★ ☞ 5% off on all Re-orders over £30 ★ Free shipping on orders over £40 ★ ";
export function MobileUtilityBar() {
return (
<div className="relative flex min-h-[2.5rem] w-full max-w-full items-center overflow-hidden border-b border-[#e8e8e8] bg-[#f5f5f5]">
<div className="absolute inset-0 flex items-center overflow-hidden" aria-hidden>
<div className="flex animate-marquee whitespace-nowrap">
<span className="inline-block pr-8 font-sans text-[10px] font-medium text-[#f2705a]">
{PROMO_TEXT}
</span>
<span className="inline-block pr-8 font-sans text-[10px] font-medium text-[#f2705a]">
{PROMO_TEXT}
</span>
</div>
</div>
</div>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB