clink-co

Keeps by HEIMSIGHT

Premium drinkware, glassware, barware, tableware and gifting — a Next.js e-commerce site for a refined, editorial retail brand, priced in South African Rand (ZAR).

“Made for moments worth raising a glass to.”

The homepage (src/app/page.tsx) is a complete, production-quality build: hero carousel, benefit strip, category showcase, editorial feature, a bestsellers carousel, new arrivals, curated collections, brand story, customer reviews, a social gallery, a newsletter section and a recently-viewed rail — all backed by the shared design system, layout (header/mega menus/mobile drawer/search/footer/cookie consent) and cart/ wishlist state built in earlier passes.

The shop and product catalogue are also a complete build: /shop, category and collection listing pages, and product detail pages, all with real filtering, sorting, search and pagination — see “Shop & product catalogue” below. Cart page / full checkout flow and account pages are the next phase; the data layer, types and client state here are already shaped to support them.


Tech stack

Concern Choice
Framework Next.js 16 (App Router, Turbopack)
Language TypeScript
Styling Tailwind CSS v4 (CSS-based design tokens)
Backend / Auth / DB Supabase (@supabase/ssr)
Client state Zustand (cart, wishlist, recently viewed, cookie consent, UI — persisted to localStorage where relevant)
Forms React Hook Form + Zod
Icons lucide-react + a few hand-drawn marks (see below)
Animation Framer Motion
Images next/image
Currency ZAR (South African Rand) — see src/config/site.ts

Homepage sections → source files

# Section Component Backing data
1 Hero (rotating carousel) src/components/sections/Hero.tsx (+ HeroWaypoint.tsx) src/data/hero-slides.ts
2 Customer benefit strip src/components/sections/FeatureStrip.tsx src/config/site.ts (delivery threshold, return window)
3 Shop by category src/components/sections/CategoryShowcase.tsx (+ product/CategoryCard.tsx) src/data/categories.ts
4 Editorial feature (“The Art of Hosting Well”) src/components/sections/LifestyleSplit.tsx (invoked from page.tsx) copy inline in page.tsx
5 Bestsellers carousel src/components/sections/Bestsellers.tsx (+ product/ProductCard.tsx) src/data/products.ts (getBestsellers())
6 New Arrivals grid src/components/sections/NewArrivals.tsx src/data/products.ts (getNewArrivals())
7 Curated collections src/components/sections/CuratedCollections.tsx src/data/collections.ts
8 Brand story src/components/sections/BrandStory.tsx copy inline
9 Customer reviews carousel src/components/sections/ReviewsCarousel.tsx src/data/reviews.ts
10 Social gallery src/components/sections/SocialGallery.tsx inline list + siteConfig.social
11 Newsletter src/components/sections/NewsletterSection.tsx src/lib/validations/newsletter.ts
12 Recently viewed (conditional) src/components/sections/RecentlyViewed.tsx src/store/recently-viewed-store.ts

Shared primitives these sections lean on: src/components/ui/Carousel.tsx (prev/next + pagination-dot carousel, used by Hero and Reviews), src/lib/hooks/use-horizontal-scroll.ts (the scroll-snap engine behind both Carousel and the Bestsellers/category rows), src/components/ui/Rating.tsx (star rating, used on ProductCard and reviews), and src/components/product/ProductCard.tsx’s detailed prop (adds rating, colour/style swatches and a discount badge — used by Bestsellers).


Shop & product catalogue

Routes

Route Page Notes
/shop src/app/shop/page.tsx Full catalogue, all 22 products
/shop/[category] src/app/shop/[category]/page.tsx One of the 6 categories, e.g. /shop/glassware; notFound() on an invalid slug; statically generated for all 6
/collections src/app/collections/page.tsx Index of the 4 curated collections
/collections/[collection] src/app/collections/[collection]/page.tsx One curated collection, e.g. /collections/home-bar-edit; notFound() on an invalid slug; statically generated for all 4
/products/[slug] src/app/products/[slug]/page.tsx Product detail page, e.g. /products/ribbed-champagne-coupe-set → /products/solstice-coupe-glasses in the seed data; notFound() on an invalid slug; statically generated for all 22 products

/shop/[category] and /collections/[collection] both render the same ShopExperience client component (src/components/catalogue/ShopExperience.tsx) as /shop, passed a pre-scoped product list and a “locked” facet (lockedCategory / lockedCollection) that hides the now-redundant filter section (you can’t un-filter “Glassware” from within /shop/glassware) — one implementation, three entry points, so filtering/sorting/search/pagination behave identically everywhere. All five routes are prerendered at build time via generateStaticParams; ShopExperience itself is a Client Component (it reads/writes useSearchParams()), so each route wraps it in <Suspense fallback={<ShopSkeleton />}> — that Suspense boundary is the page’s loading state, not a simulated one.

Filtering

Facets, all reflected in the URL as query parameters so any combination is a shareable link:

Facet Query param Example
Category category (CSV of slugs) ?category=glassware,barware
Product type type (CSV) ?type=Wine%20Glasses
Collection collection (CSV of collection ids) ?collection=home-bar-edit
Colour color (CSV) ?color=Ivory,Sage
Material material (CSV) ?material=Solid%20oak
Capacity capacity (CSV) ?capacity=300%20ml
Set size set (CSV) ?set=Set%20of%204
Price range price (min-max) ?price=500-2000
Rating rating (minimum, 3 or 4) ?rating=4
Availability availability=in-stock in-stock only
New arrivals new=1 badge-driven
Sale products sale=1 has a compareAtPrice
Search q also matches SKU/tags/collection — see below
Sort sort see table below
Load-more depth page how many pages of results have been loaded

Desktop gets an always-visible sidebar (FilterSidebar.tsx) of expandable Disclosure sections that apply each change immediately. Mobile gets a slide-up sheet (FilterDrawer.tsx) with its own draft state — nothing filters until Apply filters (N) is tapped, with a separate Clear all. Both render the exact same FilterPanel.tsx content so they can never drift apart. Active filters also surface as removable chips (ActiveFilterChips.tsx) above the grid.

Sorting

Featured (default) · Newest · Best Selling · Price: Low to High · Price: High to Low · Highest Rated · Biggest Discount — see SORT_OPTIONS in src/lib/catalogue.ts.

Product card & quick view

ProductCard.tsx shows: primary image, a secondary image swapped in on hover, category label, name, collection/short description, price with a struck-through original price and a -N% badge when on sale, New / Bestseller / Limited / Gift Edit badges, an out-of-stock state (dims the quick-add button, swaps its label to “Notify me”), star rating + review count (in detailed mode), a wishlist heart button, a quick-view eye button (only rendered where a handler is passed, e.g. the shop grid), and colour/variant swatch dots. Quick view (QuickView.tsx) opens a modal with the same core information plus a variant picker and “View full details” link to the full PDP, without leaving the grid.

src/lib/catalogue.ts’s searchProducts() matches name, SKU, short/full description, category name, product type, collection names and tags — every whitespace-separated token in the query must match somewhere, via an exact substring check first and a Levenshtein-distance-1 fallback per word for basic typo tolerance (so “decantr” still finds “Decanter”). The same function powers both SearchModal.tsx (the header’s full-screen search, which also keeps recent searches in localStorage and suggests popular categories on no results) and the q filter on the shop pages, so a search’s “View all results” link and the shop’s own search box behave identically. highlightMatch() wraps the matched substring in <mark> for visual emphasis in the search results list.

Empty / loading / error states

EmptyState.tsx (with a query-aware headline and a “Clear all filters” action when applicable), ProductGridSkeleton.tsx (the route-level Suspense fallback, and reused while “Load more” is in flight), and ErrorState.tsx (a defensive boundary around the filter/sort/search pipeline — genuinely reachable once catalogue reads become real, possibly failing network calls against Supabase).

Pagination

Grid results load 12 at a time (PRODUCTS_PER_PAGE in src/lib/catalogue.ts) via a “Load more” button (LoadMoreButton.tsx) with a short simulated delay standing in for what will be a real async fetch once Supabase is wired up; how many pages have been loaded is itself reflected in the URL’s page param, so a reloaded or shared URL restores exactly what was on screen.


Product detail page

/products/[slug] (src/app/products/[slug]/page.tsx) composes ~20 focused components under src/components/product/, orchestrated by ProductDetailView.tsx. A full breakdown of every state, component and data requirement is in the chat write-up for this phase; in short:


Checkout & payments

Architecture

Cart totals shown to the customer (src/lib/cart.ts, src/lib/promotions.ts, src/lib/delivery.ts) are advisory only. The single source of truth is POST /api/checkout (src/app/api/checkout/route.ts), which re-derives everything server-side before an order is ever created:

  1. Zod-validates the payload (src/lib/validations/checkout.ts).
  2. Looks up findOrderByIdempotencyKey() — if this exact checkout attempt already created an order, that order is returned as-is and nothing new is created (see “Duplicate orders” below).
  3. Re-validates every cart line against live product data (validateCartLines() in src/lib/cart-validation.ts) — price, stock, discontinued status. A client can send stale prices/quantities; the server never trusts them.
  4. Re-validates any coupon code (validateCoupon()) against the same scoping/date/usage-limit/minimum-spend rules the client already checked — redundantly, but authoritatively.
  5. Re-quotes delivery (quoteDelivery()) for the chosen method/province/ postal code, including the coupon’s free-delivery flag if applicable.
  6. Computes final totals (computeCartTotals()) and creates the order (createOrder() in src/lib/orders/store.ts).
  7. Initiates payment via the chosen provider’s initiate() and returns a redirectUrl for the client to navigate to.
  8. Fires an admin new-order email in the background (sendAdminOrderNotification()) for every order; for EFT specifically it also fires the customer confirmation email immediately, since EFT has no webhook to trigger it later.

src/lib/payments/ implements a single PaymentProvider interface (isConfigured(), initiate(), parseWebhook()) per gateway (providers/{test,eft,payfast,peach,yoco,ozow}.ts), registered in src/lib/payments/index.ts. getAvailablePaymentMethods() filters to only providers whose isConfigured() returns true — this is what “if enabled by the administrator” means in practice: set a provider’s env vars and it appears at checkout, leave them unset and it doesn’t. GET /api/payments/methods exposes only {id, label, description} to the client — never credentials.

Once the customer completes payment on the gateway’s hosted page, the gateway calls back to POST /api/webhooks/payments/[provider]/route.ts server-to-server. That route calls the provider’s parseWebhook() (which verifies the request’s signature and returns null — a 400 — if it doesn’t check out), maps the gateway’s status to an internal OrderStatus, updates the order, and sends the customer confirmation email on a fresh transition to “paid” (not on every retried webhook delivery — see “Duplicate orders”). GET /api/orders/[orderNumber] then serves the finished order to the confirmation page.

Checkout works for both guests and signed-in customers. POST /api/checkout reads the current session (getUser() from src/lib/supabase/dal.ts) and sets Order.userId/isGuest accordingly — a signed-in customer’s order is attached to their account the moment it’s created, no separate claim step needed. A guest order stays unattached until either the customer signs in later with the same (verified) email — every login/sign-up auto-links any guest orders on that email, see linkGuestOrdersToUser() in src/lib/orders/store.ts — or they use the “Create an account” prompt shown on the confirmation page for a guest order (CreateAccountPrompt.tsx), which signs them up with that same email and gets the same auto-link for free. See Authentication & account below for the full picture.

Required payment credentials

Provider Env vars Where to get them
Test (mock gateway) ENABLE_TEST_PAYMENTS, TEST_PAYMENT_WEBHOOK_SECRET None — built in, for demos/dev/staging
EFT / bank transfer EFT_ENABLED None — manually reconciled, no gateway account
PayFast PAYFAST_MERCHANT_ID, PAYFAST_MERCHANT_KEY, PAYFAST_PASSPHRASE, PAYFAST_SANDBOX PayFast merchant dashboard → Settings → Integration
Peach Payments PEACH_ENTITY_ID, PEACH_ACCESS_TOKEN, PEACH_API_BASE_URL, PEACH_WEBHOOK_SECRET Peach dashboard → Checkout API credentials
Yoco YOCO_SECRET_KEY, YOCO_WEBHOOK_SECRET Yoco portal → Online → API Keys
Ozow OZOW_SITE_CODE, OZOW_PRIVATE_KEY, OZOW_SANDBOX Ozow business portal → Settings → API

All of the above are read only in server-side modules (src/lib/payments/providers/*.ts, imported only from route handlers) — never in a "use client" component, and never sent to the browser. src/config/payments.ts is a deliberately separate, client-safe module that holds only the public EFT bank-details display data, so PaymentMethodStep.tsx can render it without pulling in the server-only provider code (which uses Node’s crypto module and cannot be bundled for the browser).

Test vs. production settings

How duplicate orders are prevented

Two independent guards, matching the two ways a duplicate could happen:

  1. Duplicate order creation (double-click “Place order”, a network retry, browser back/forward after payment) — CheckoutView.tsx generates one idempotencyKey (crypto.randomUUID()) per checkout attempt and persists it in sessionStorage for the duration of that attempt, sending it on every POST /api/checkout call. The server checks findOrderByIdempotencyKey() before creating anything — if an order already exists for that key, it’s returned unchanged rather than a second order being created. The key is only cleared from sessionStorage once the request actually succeeds.
  2. Duplicate webhook processing (every real gateway retries webhook delivery until it gets a 200 — PayFast/Ozow/Yoco/Peach will all resend the same event) — src/lib/orders/store.ts tracks processed webhook events by a normalized eventId (hasProcessedWebhookEvent() / markWebhookEventProcessed()). The webhook route checks this before touching the order or sending any email, so a retried delivery for an already-processed event returns 200 immediately without reprocessing — which also means the confirmation email only ever fires once, on the first transition into paid.

src/lib/orders/store.ts is an in-memory store standing in for a real orders table (its functions mirror what real Supabase queries would look like: findOrderByIdempotencyKey ≈ SELECT ... WHERE idempotency_key = $1, etc.) — moving to Supabase means giving idempotency_key a UNIQUE constraint and doing the “does it already exist” check as an upsert, but the call sites in checkout/route.ts and the webhook route don’t need to change.


Authentication & account

Authentication itself is real Supabase Auth — not a mock. Sign-up, login, logout, email verification, and password reset all call the genuine Supabase Auth API and produce genuine, cookie-based sessions via @supabase/ssr. What’s not backed by a live database (for the same reason orders isn’t — this environment has no access to the Supabase project’s SQL editor/migrations) is the account data layered on top: profiles, the address book, and return requests live in in-memory stores (src/lib/account/*.ts) that mirror the exact shape of the real profiles/addresses tables documented in src/lib/supabase/types.ts, the same documented trade-off src/lib/orders/store.ts already makes. Swapping either store’s function bodies for real Supabase queries later is a drop-in change — no call site changes.

Sign-up, verification, login

Account dashboard

/account and everything under it is guarded twice: optimistically by src/proxy.ts (redirects to /login?redirect=<path> on the cookie-level check) and, the check that actually matters, by requireUser() (src/lib/supabase/dal.ts) in src/app/account/layout.tsx, which re-verifies the session against the Supabase Auth server (getUser(), not a cookie read) before rendering anything. Every /api/account/** route independently calls the same getUser() again — the layout guard is a nice UX shortcut, not the security boundary.

Security

Protected routes

Every route under /account/** (/account, /account/orders, /account/orders/[orderNumber], /account/orders/[orderNumber]/invoice, /account/addresses, /account/profile, /account/security, /account/preferences, /account/payment-methods) and every route under /api/account/** require a signed-in session — enforced twice, as described above. /login and /signup redirect away to /account if you’re already signed in. /reset-password requires the session established by a recovery link (or an existing session) rather than a separate token. Everything else — /, /shop, /products/**, /cart, /checkout, /wishlist, /forgot-password — stays open to guests, by design.


Admin dashboard

/admin/** is a secure internal tool the business uses to run the storefront day-to-day — products, orders, customers, promotions, content, media, store settings and team access — without touching code or redeploying. It has its own full-page shell (sidebar nav + topbar, AdminShell.tsx) instead of the storefront’s Header/Footer (SiteChrome.tsx skips storefront chrome for any /admin path).

Architecture

Admin routes

Route Purpose
/admin Dashboard — sales/orders/AOV, low & out-of-stock, recent orders, bestsellers, sales trend, order-status distribution
/admin/products, /admin/products/new, /admin/products/[id] Product list, create, edit — images, variants, pricing/scheduling, stock, SEO, publish status
/admin/categories Category CRUD, images, slugs, SEO, reorder
/admin/collections Collection CRUD, product assignment
/admin/orders, /admin/orders/[orderNumber] Order list/search/filter, detail, status/tracking, refund, cancel, notes, CSV export
/admin/orders/[orderNumber]/invoice, /admin/orders/[orderNumber]/packing-slip Printable invoice and packing slip
/admin/customers, /admin/customers/[id] Customer search, profile, order history, spend, notes, marketing consent, disable account
/admin/promotions Discount codes, automatic discounts, free-delivery rules, restrictions, usage limits
/admin/content Hero slides, banners, editorial section, about page, FAQs, journal, policy pages, newsletter copy, homepage section order
/admin/media Media library — upload, search, folders/labels, alt text, replace, delete
/admin/settings Business details, currency/tax, delivery/payment methods, email sender, order number format, return window, maintenance mode
/admin/team Grant/change admin roles (view: all admins; edit: team:write only, i.e. super_admin/store_admin)
/admin/audit-log Full history of admin actions — who, what, when, before/after

Every page above has a matching /api/admin/** route family the UI calls (e.g. /admin/products is backed by GET/POST /api/admin/products and GET/PATCH/DELETE /api/admin/products/[id], plus /archive, /restore, /duplicate); they enforce the same permissions server-side and aren’t meant to be called directly.

Roles & permissions

Every account is a customer by default; the six roles below are additive — granting one turns on /admin access scoped to that role’s permissions only, without changing the account’s ability to shop as a customer too. Roles are granted at /admin/team (never through an env var, except for bootstrapping the very first admin — see Environment variables).

Role Can access
Super Administrator Everything, including changing anyone’s role
Store Administrator Everything except granting/revoking admin roles
Product Manager Dashboard, products, categories, collections, media
Order Fulfilment Dashboard, orders (view/fulfil/export), customers (view), products (view)
Content Editor Dashboard, content, media, categories (view)
Customer Support Dashboard, customers (view/edit), orders (view)

The full matrix (one row per resource × view/write) lives in src/lib/admin/roles.ts (ROLE_PERMISSIONS) — the source of truth both the UI and every API route check against.

How content changes reach the storefront — and why no redeploy is needed

An admin edit (say, publishing a product, changing a hero slide, or turning on maintenance mode) writes directly into the same in-memory store the storefront reads from on every request — there’s no separate “draft” copy that needs publishing, no cache to invalidate, and no static site to rebuild. The very next request to any storefront page — the homepage, a product page, the footer newsletter copy — calls the same store function and gets the change immediately. This is verified end-to-end for promotional banners: /admin/content → Banners writes to content-store.ts, and PromoBannerBar.tsx (rendered on every non-admin page via SiteChrome.tsx) reads getActiveBanners() from that same store on every render.

The one exception is draft products and draft journal articles: a draft resolves directly at its real URL (so an admin can preview it) but is excluded from listings/search/sitemaps until its publish status changes — by design, not because anything needs redeploying.

In production, this same architecture holds once the in-memory stores are swapped for real Supabase tables (each store function’s body changes, no call site does) — an admin save is a database write, and the next page request is a database read, still with no redeploy in between.


Database (Supabase)

The complete target database — 35 tables, full Row Level Security, storage buckets, seed data, generated TypeScript types, and a typed data-access layer (src/lib/db/**) — lives under supabase/ and is ready to run against a real Supabase project today; it’s what the “swapped for real Supabase tables” line above is describing. See supabase/README.md for the full schema (table relationships, every RLS policy, the role permission matrix), how to run the migrations, how to seed it, the storage bucket setup, and the security decisions behind it.


Transactional emails

26 branded, mobile-responsive email templates (19 customer-facing, 7 staff-facing), a provider-neutral send service with retry and event logging, a configurable abandoned-cart workflow, and a dormant WhatsApp notification scaffold — see src/lib/email/README.md for the full template list, the provider abstraction, and how to preview and test every template safely without sending anything real.

Quick reference:


Folder structure

clink-co/
├─ public/
│  └─ images/                     Local placeholder imagery (see below)
├─ scripts/
│  └─ generate-placeholders.mjs   Regenerates the placeholder SVGs
├─ src/
│  ├─ app/
│  │  ├─ layout.tsx                Root layout: fonts, metadata, Header/Footer/CookieBanner
│  │  ├─ page.tsx                  Homepage (assembles all 12 sections + JSON-LD)
│  │  ├─ globals.css               Design tokens (@theme) + base styles
│  │  ├─ shop/
│  │  │  ├─ page.tsx                /shop — full catalogue
│  │  │  └─ [category]/page.tsx     /shop/[category] — locked-category catalogue
│  │  ├─ collections/
│  │  │  ├─ page.tsx                /collections — index of curated collections
│  │  │  └─ [collection]/page.tsx   /collections/[collection] — locked-collection catalogue
│  │  └─ products/
│  │     └─ [slug]/page.tsx         /products/[slug] — product detail page
│  ├─ components/
│  │  ├─ ui/                       Button, Badge, Card, Input, Textarea, Label, Modal, Switch,
│  │  │                            Checkbox, Carousel, Rating, Disclosure (accordion section)
│  │  ├─ layout/                   Header, MegaMenu, MobileDrawer, Footer, Logo, NewsletterForm,
│  │  │                            CookieBanner, CookieSettingsLink, nav-data.ts (shared nav/footer data)
│  │  ├─ search/                   SearchModal (full-screen search overlay)
│  │  ├─ catalogue/                ShopExperience, FilterSidebar/FilterDrawer/FilterPanel,
│  │  │                            ActiveFilterChips, SortSelect, Breadcrumbs, ProductGrid,
│  │  │                            ProductGridSkeleton, ShopSkeleton, EmptyState, ErrorState,
│  │  │                            LoadMoreButton — see "Shop & product catalogue" above
│  │  ├─ product/                  ProductCard, CategoryCard, QuickView, ProductDetailView
│  │  ├─ sections/                 All 12 homepage sections — see table above
│  │  ├─ cart/                     CartDrawer
│  │  ├─ motion/                   Reveal (scroll-triggered fade-up)
│  │  └─ icons/                    SocialIcons, PaymentIcons — lucide-react dropped brand/payment marks
│  ├─ data/                        Seed data: products.ts (22 products), categories.ts, hero-slides.ts,
│  │                                collections.ts, reviews.ts
│  ├─ types/                       Product, Category domain types
│  ├─ config/
│  │  └─ site.ts                   Currency, locale, free-delivery threshold, social links, contact info
│  ├─ lib/
│  │  ├─ supabase/                 client.ts (browser), server.ts (RSC/actions), types.ts (DB schema)
│  │  ├─ validations/              Zod schemas: auth.ts, newsletter.ts (+ newsletterSectionSchema)
│  │  ├─ hooks/                    use-mounted.ts, use-horizontal-scroll.ts
│  │  ├─ catalogue.ts              Filter/sort/search + URL (de)serialization — see above
│  │  └─ utils.ts                  cn(), formatPrice(), slugify()
│  ├─ store/
│  │  ├─ cart-store.ts             Zustand cart (add/remove/update, persisted)
│  │  ├─ wishlist-store.ts         Zustand wishlist (toggle/has, persisted)
│  │  ├─ recently-viewed-store.ts  Tracks viewed products, persisted, capped at 8
│  │  ├─ consent-store.ts          Cookie consent decision (persisted)
│  │  └─ ui-store.ts               Cross-component UI signals (header-over-hero tracking)
│  └─ proxy.ts                     Supabase session refresh (Next.js "proxy"/middleware)
├─ .env.local.example
├─ next.config.ts
└─ tsconfig.json

Design system

All design tokens live in src/app/globals.css under @theme (Tailwind v4’s CSS-native config) — there is no tailwind.config.ts.

UI primitives (src/components/ui)

Button (variants: primary / secondary / inverse / ghost / link, sizes sm/md/lg/icon), Badge (variants: dark/light/sale/ outline/champagne/green/success/warning/error/neutral — the last three added for order-status badges, see Authentication & account), Card (+ Header/Title/Description/ Content/Footer), Input, PasswordInput (an Input variant with a show/hide toggle), Textarea, Label, Modal, Switch (accessible toggle, role="switch"), Checkbox (accessible, role="checkbox"), Carousel (prev/next + pagination dots, autoplay, keyboard, one slide per view — see below), Rating (star display with an inverse variant for dark sections). All are built with class-variance-authority and tailwind-merge (via the cn() helper), so they compose cleanly with extra classNames from call sites.

Input, PasswordInput and Textarea all render their error message as visible text (an id-linked <p>, wired to the input via aria-describedby) whenever a truthy error prop is passed, on top of the red border/aria-invalid styling — every call site across every phase already passes error={errors.field?.message} from react-hook-form, so this was a from-the-source fix rather than a per-form one.

Carousels

Three different carousel needs, two implementations, sharing one hook:

Both are built on native CSS scroll-snap rather than a carousel library — no extra dependency, and swipe support comes for free from the browser.

Currency & site configuration

src/config/site.ts centralises the things an admin dashboard would eventually expose as editable settings: currency/locale, the free-delivery threshold, the return window, social links and the contact email/WhatsApp number. formatPrice() (src/lib/utils.ts) reads siteConfig.currency (ZAR) by default.

Note on formatPrice: it’s deliberately not built on Intl.NumberFormat(locale, { style: "currency" }). Node’s default (small-icu) build only ships full ICU data for English locales, so en-ZA grouping can render differently server-side (Node, at build/request time) than client-side (the browser, which always ships full ICU) — a real hydration mismatch ("R 1,450" server vs. "R 1 450" client), caught while QA-ing the Bestsellers carousel. The fix groups digits via the always-available en-US locale and swaps the separator manually, so output is byte-identical on both sides regardless of the runtime’s ICU data.

Header, navigation & the “over hero” effect

Header is position: fixed, so it visually floats over whatever renders beneath it — including a page’s hero. <main> in layout.tsx carries pt-24 to clear the header’s height on ordinary pages; Hero.tsx cancels that with -mt-24 so it renders full-bleed from the very top, behind the header.

Whether the header currently shows its transparent/tinted “over hero” style or its solid scrolled style is not a scroll-position heuristic — it’s driven by HeroWaypoint, a 1px sentinel mounted at the bottom of a page’s hero, observed via IntersectionObserver and written to useUIStore().overHero. Any future page that wants the same effect just renders <HeroWaypoint /> at the end of its hero section; pages that don’t mount it get the header’s safe default (solid) from the start.

src/store/cart-store.ts and wishlist-store.ts are Zustand stores persisted to localStorage — this is the guest cart/wishlist storage the brief asks for, and it’s what every visitor uses right up until they sign in (see Authentication & account for what happens then). Header reads their counts for the bag/heart badges; ProductCard’s “Quick add” and heart button call them directly; CartDrawer (mounted once in Header) renders the slide-in mini-cart, and /cart (CartPageView.tsx) is the full page. No page-level integration is needed — useCartStore() / useCartCount() / useWishlistStore() work from any client component.

src/store/recently-viewed-store.ts records a lightweight product snapshot whenever a ProductCard link is clicked (capped at 8, most-recent-first). RecentlyViewed.tsx renders nothing until it’s non-empty and the component has mounted client-side (avoids a hydration mismatch against the localStorage-backed state) — so on a first visit the section simply isn’t there, per the “only show if the customer has recently viewed products” requirement.

src/store/consent-store.ts backs CookieBanner (Accept all / Reject non-essential / Manage preferences, the last opening a Modal with per-category Switch toggles). The decision persists to localStorage; CookieSettingsLink in the footer calls reset() to reopen the banner.

Analytics & tracked events

<Analytics /> (mounted once in layout.tsx) renders nothing until the visitor has made a cookie-consent decision, then conditionally injects each provider’s script via next/script: GA4 and Microsoft Clarity require analytics consent, the Meta and TikTok pixels require marketing consent — independently of that, each provider also only renders if its env var is set (see Environment variables), so a clone of this repo with no keys configured loads nothing at all. Withdrawing consent (via the “Manage preferences” modal) stops new events immediately; already-loaded scripts aren’t force-unloaded mid-session, but src/lib/analytics/track.ts’s track() re-checks consent on every call and no-ops if it’s been withdrawn.

track() is a plain, framework-agnostic function (not a hook) so it can be called from Zustand store actions as well as components. It fans a single internal event shape out to each configured/consented provider, mapping to GA4’s recommended event vocabulary, Meta’s standard events, and TikTok’s standard events where each has an equivalent (falling back to a custom event otherwise). The 13 events called for are wired at these call sites:

Event Fired from
Product viewed ProductDetailView.tsx, on mount
Product searched SearchModal.tsx, on committing a search
Filter used ShopExperience.tsx, one event per facet that actually changed
Add to cart cart-store.ts’s addItem()
Remove from cart cart-store.ts’s removeLine()
Add to wishlist wishlist-store.ts’s toggle(), add branch only
Begin checkout CheckoutView.tsx, once per checkout session
Add delivery information CheckoutView.tsx, on completing the delivery-method step
Add payment information CheckoutView.tsx, on completing the payment-method step
Purchase completed ConfirmationView.tsx, once payment has actually succeeded
Coupon applied cart-store.ts’s applyCoupon(), on success
Newsletter signup NewsletterSection.tsx, on successful subscribe
Contact form submitted ContactForm.tsx, on successful submit

Seed data & placeholder imagery

How catalogue data is currently stored

src/data/products.ts is a plain, in-memory TypeScript array of 22 Product objects (src/types/product.ts) — no database, no fetch, no loading state at the data layer. Each product carries sku, short/full description, price/compareAtPrice (ZAR), images, categorySlug (one of 6 top-level categories), productType (a finer facet, e.g. “Wine Glasses”), collectionSlugs (0+ curated collections), material, colors/variants (swatches with optional per-option pricing), capacity, setSize, stockQuantity/inStock, featured, badges, rating/reviewCount, tags, careInstructions, dimensions and weightGrams. Helper functions in the same file (getProductBySlug, getProductsByCategory, getBestsellers, getRelatedProducts, etc.) are the only way the rest of the app reads product data — components never .filter()/.find() the array directly. src/data/categories.ts and collections.ts follow the same pattern, and deliberately derive their itemCount / product counts from the live products array (products .filter(...).length) at module load rather than hardcoding a number that could drift out of sync as products are added. All of this — plus src/data/hero-slides.ts and reviews.ts for the Hero and Reviews sections — is synchronous, so every page in this build (including /shop, /products/[slug], etc.) can be statically generated at build time with generateStaticParams.

How catalogue data will connect to Supabase

src/lib/supabase/types.ts already defines the target database schema — the Product/Category shapes in src/types/ were designed to match it field-for-field, so the migration is a data-source swap, not a rewrite:

  1. Schema — create products, categories and collections tables (plus a product_collections join table for the many-to-many relationship collectionSlugs represents) in Supabase using src/lib/supabase/types.ts as the DDL reference.
  2. Reads — replace the src/data/*.ts arrays with query functions of the same name and signature (getProductBySlug(slug), getProductsByCategory(slug), etc.), backed by src/lib/supabase/server.ts’s server client, so every call site (page.tsx files, ShopExperience, SearchModal) keeps working unchanged — only the implementation behind each helper changes from an Array.prototype call to a supabase.from("products").select(...) call.
  3. Async boundary — those helpers become async, which the route page.tsx files already anticipate (they’re Server Components awaiting params/generateMetadata), so the change is additive there; the client-side pieces (ShopExperience, SearchModal) receive their product list as a prop from the server rather than importing the array directly, so their filter/sort/search logic in src/lib/catalogue.ts — which is already pure and framework-agnostic — needs no change at all.
  4. Filtering & search at scale — src/lib/catalogue.ts’s filterProducts/sortProducts/searchProducts currently run in memory over the full catalogue; once product count outgrows what’s reasonable to ship to the client, the same filter/sort inputs (CatalogueFilters, SortKey) translate directly into Supabase .eq()/.in()/.gte()/.lte()/.order() calls (and .textSearch() or pg_trgm for fuzzy search) run server-side, with ErrorState/ProductGridSkeleton becoming genuinely reachable states instead of the defensive/simulated ones they are today.
  5. Images — swap the local placeholder SVG paths in images: [] for Supabase Storage URLs (the remotePatterns example is already commented into next.config.ts) once real photography exists.

src/data/categories.ts (Glassware, Barware, Tableware, Serveware, Gift Sets, Accessories) and collections.ts (4 curated collections) contain realistic, Keeps–specific copy typed against src/types/product.ts / category.ts. src/data/hero-slides.ts and reviews.ts back the Hero and Reviews sections respectively.

Product, category, hero and editorial imagery is not final — public/images/ holds generated placeholder SVGs (soft brand-palette gradients with a minimal vessel motif, no text baked into the image itself — all copy is real HTML rendered by the page) so the UI can be built and reviewed without external image hosting. Regenerate or extend them with:

npm run generate:placeholders

Before launch, replace these with real photography — most simply via Supabase Storage (uncomment the remotePatterns example in next.config.ts and update the image/images fields in src/data/). See Placeholder assets still required below for the full list.


Environment variables

Copy the example file and fill in real values:

cp .env.local.example .env.local
Variable Required for
NEXT_PUBLIC_SUPABASE_URL Auth, database, storage
NEXT_PUBLIC_SUPABASE_ANON_KEY Auth, database, storage — this alone is enough for full sign-up/login/logout/reset
SUPABASE_SERVICE_ROLE_KEY The Supabase data-access layer’s server-only writes (order/payment creation, discount redemption, audit log) — see src/lib/supabase/service.ts and supabase/README.md. Not used by the in-memory stores the app runs on today.
NEXT_PUBLIC_SITE_URL Metadata / OpenGraph canonical URLs / JSON-LD / auth email redirect links
NEXT_PUBLIC_ENABLE_SOCIAL_LOGIN Shows the “Continue with Google” button (also requires enabling Google in the Supabase dashboard)
ADMIN_BOOTSTRAP_EMAILS Auto-grants Super Administrator to these emails on first sign-up/login — the only way to create your first admin. See Admin dashboard below.
ENABLE_TEST_PAYMENTS Test payment provider in production (always on outside production)
TEST_PAYMENT_WEBHOOK_SECRET Test payment provider webhook signing
EFT_ENABLED EFT / bank transfer payment method (defaults on)
PAYFAST_MERCHANT_ID / _MERCHANT_KEY / _PASSPHRASE / _SANDBOX PayFast payment method
PEACH_ENTITY_ID / _ACCESS_TOKEN / _API_BASE_URL / _WEBHOOK_SECRET Peach Payments payment method
YOCO_SECRET_KEY / YOCO_WEBHOOK_SECRET Yoco payment method
OZOW_SITE_CODE / _PRIVATE_KEY / _SANDBOX Ozow payment method
RESEND_API_KEY Transactional email / newsletter / order confirmations
NEXT_PUBLIC_GA_MEASUREMENT_ID Google Analytics 4 — only loads once the visitor has given “analytics” cookie consent
NEXT_PUBLIC_GSC_VERIFICATION Google Search Console ownership verification meta tag
NEXT_PUBLIC_FB_PIXEL_ID Meta Pixel — only loads once the visitor has given “marketing” cookie consent
NEXT_PUBLIC_TIKTOK_PIXEL_ID TikTok Pixel — only loads once the visitor has given “marketing” cookie consent
NEXT_PUBLIC_CLARITY_ID Microsoft Clarity — only loads once the visitor has given “analytics” cookie consent

The app runs without any of these set — Supabase calls are structured to no-op gracefully in src/proxy.ts, both newsletter forms currently simulate their network call (marked with a TODO pointing at a subscribers table/edge function) so their success/error states are real without a backend wired up yet, and checkout always has at least one working payment method (Test and EFT are on by default outside production) even with none of the payments vars set. See Checkout & payments below for how each gateway’s credentials are used and how to enable/disable a method.


Local setup

# 1. Install dependencies
npm install

# 2. Configure environment variables
cp .env.local.example .env.local
# then fill in your Supabase project URL/keys (optional for now — see above)

# 3. Run the dev server
npm run dev

Open http://localhost:3000.

Other scripts:

npm run build                 # production build
npm run start                 # serve the production build
npm run lint                  # ESLint
npm run generate:placeholders # regenerate placeholder SVG imagery

Performance, SEO & conversion notes

Responsive behavior

Verified at phone (390px), tablet (834px) and desktop (1440px) widths:

Accessibility considerations


Placeholder assets still required

Foundation checklist