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:
20
apps/storefront/src/components/layout/header/Header.tsx
Normal file
20
apps/storefront/src/components/layout/header/Header.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
212
apps/storefront/src/components/layout/header/HeaderSearchBar.tsx
Normal file
212
apps/storefront/src/components/layout/header/HeaderSearchBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
242
apps/storefront/src/components/layout/header/code.html
Normal file
242
apps/storefront/src/components/layout/header/code.html
Normal 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&family=DM+Sans:ital,wght@0,300;0,400;0,500;0,700;1,400&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&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 & 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>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
BIN
apps/storefront/src/components/layout/header/screen.png
Normal file
BIN
apps/storefront/src/components/layout/header/screen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 316 KiB |
Reference in New Issue
Block a user