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.
| 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 |
| # | 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).
| 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.
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.
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.
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.
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).
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.
/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:
ProductGallery.tsx: main image with hover-zoom, thumbnail
rail, prev/next, a full-screen lightbox (its own zoom toggle, keyboard
nav), an HTML5 <video> slide when Product.videoUrl is set, drag-to-swipe
(Framer Motion), and variant-aware images (ProductVariant.images).StockStatus.tsx (in-stock/low-stock/out-of-stock),
VariantSelectors.tsx (colour + set-size, the latter via the new
Product.setSizeOptions), QuantitySelector.tsx, PurchaseActions.tsx
(add to cart / buy now / wishlist / share), DeliveryEstimator.tsx
(src/lib/delivery.ts — an illustrative SA postal-code → zone/fee/ETA
estimator with a checkout-confirms-final-cost disclaimer).ProductAccordions.tsx (description, specs,
material, dimensions, capacity, care, delivery & returns, packaging),
KeyBenefits.tsx, ProductLifestyleSection.tsx (reuses the homepage’s
LifestyleSplit, falling back to the category image), PairsWellWith.tsx
and TrustBadges.tsx.ReviewsSection.tsx (rating histogram, filter by
star, sort, verified-purchase badge, photo thumbnails) and
WriteReviewForm.tsx (rating/title/body/photos, persisted per-device via
submitted-reviews-store.ts — no reviews API exists yet, see the
write-up); QandASection.tsx mirrors the same pattern for questions via
submitted-questions-store.ts.ProductDetailSkeleton.tsx (loading.tsx’s fallback),
error.tsx (a real Next.js error boundary, not simulated),
NotifyWhenAvailable.tsx (out of stock) and DiscontinuedNotice.tsx
(Product.discontinued — excluded from shop/search listings via
activeProducts, but its own PDP stays reachable and shows this instead
of purchase actions).StickyAddToCart.tsx appears once the main add-to-cart
button scrolls out of view, via IntersectionObserver.page.tsx injects Product JSON-LD (offers, availability,
aggregateRating, reviews).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:
src/lib/validations/checkout.ts).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).validateCartLines() in src/lib/cart-validation.ts) — price, stock,
discontinued status. A client can send stale prices/quantities; the server
never trusts them.validateCoupon()) against the same
scoping/date/usage-limit/minimum-spend rules the client already checked —
redundantly, but authoritatively.quoteDelivery()) for the chosen method/province/
postal code, including the coupon’s free-delivery flag if applicable.computeCartTotals()) and creates the order
(createOrder() in src/lib/orders/store.ts).initiate() and returns a
redirectUrl for the client to navigate to.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.
| 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).
src/lib/payments/providers/test.ts) is available
automatically outside production, or in production only if
ENABLE_TEST_PAYMENTS=true is explicitly set — so a staging deploy can
demo the full flow, but a real production deploy doesn’t accidentally ship
a fake “always succeeds” payment method. Its /checkout/pay/[orderNumber]
page (PaymentSimulatorView.tsx) lets you pick Paid / Failed / Cancelled,
which triggers a real signed webhook call from the server to
/api/webhooks/payments/test — exercising the exact same webhook code
path a live gateway would use, just without needing real merchant
credentials to test it.PAYFAST_SANDBOX,
OZOW_SANDBOX) that points initiate() at the gateway’s sandbox host
instead of production — flip it once real (non-sandbox) merchant
credentials are issued.PEACH_API_BASE_URL itself
(defaults to Peach’s test API host; set it to the production API host once
live).YOCO_SECRET_KEY.Two independent guards, matching the two ways a duplicate could happen:
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.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 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.
SignupForm.tsx posts to POST /api/auth/signup, which
calls supabase.auth.signUp() server-side (so the route can rate-limit
it and centralize error handling), creates the profile record
(ensureProfile()), and links any guest orders already placed under that
email (linkGuestOrdersToUser() — see below). If the Supabase project
has email confirmation on (the default), the account exists but has no
session until the link is clicked; the form shows a “check your inbox”
state rather than pretending sign-up is complete./auth/confirm?token_hash=&type=signup
/auth/confirm?token_hash=&type=recovery
GET /auth/confirm/route.ts calls supabase.auth.verifyOtp({ type,
token_hash }), which both verifies the link and establishes a real
session server-side, then redirects: recovery → /reset-password,
everything else → /account?welcome=1. Skipping this dashboard step
doesn’t break the app — Supabase’s default hosted redirect still works —
it just means the link doesn’t land on this app’s own confirm route.
LoginForm.tsx posts to POST /api/auth/login
(signInWithPassword), which always returns the same generic “Invalid
email or password” on any failure (wrong password, unknown email,
unconfirmed email) — never revealing which — and is rate-limited both
per-IP and per-email (see below). On success it also runs
linkGuestOrdersToUser(), so a customer who checked out as a guest
before ever creating an account still gets those orders attached the
moment they first log in.PasswordInput.tsx, a drop-in
Input variant with an eye/eye-off button, used everywhere a password is
typed (login, sign-up, reset, change-password).POST /api/auth/forgot-password always
responds with the same message (“If an account exists for that email,
we’ve sent a link”) whether or not the email is registered — the one
place in this flow where confirming an email is or isn’t registered
would itself be the security leak. The recovery link lands on
/auth/confirm?type=recovery, which establishes a session and redirects
to /reset-password; that page is a Server Component that checks for a
session before rendering the form at all, so a stale or already-used link
shows “This link has expired” rather than a broken form.
POST /api/auth/reset-password sets the new password via
updateUser(), then signs the session out — the customer logs in
fresh with the new password rather than staying signed in on whatever
device happened to open the email link.POST /api/auth/logout (supabase.auth.signOut()), wired
into LogoutButton.tsx (bottom of the account nav).SocialLoginButtons.tsx renders a “Continue
with Google” button that calls signInWithOAuth() (a real, working PKCE
flow via /auth/callback/route.ts), but only when
NEXT_PUBLIC_ENABLE_SOCIAL_LOGIN=true and the Google provider is
turned on in the Supabase dashboard (Authentication → Providers) — a step
this codebase can’t perform for you. Leave the flag unset and login/
sign-up stay email/password-only.CreateAccountPrompt.tsx: the
email is pre-filled from the order (read-only) and locked in, so the
customer only sets a password. Signing up this way runs the exact same
/api/auth/signup route, which means the order that was just placed —
and any other past guest order under that email — is linked immediately./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.
DashboardView.tsx — welcome message (first name from the profile),
a “we linked N previous orders” banner (fires POST
/api/account/claim-orders once per visit — safe to call repeatedly,
already-linked orders are just skipped), the 3 most recent orders, and
quick links to orders/addresses/profile/wishlist./account/orders lists every order with status
filter tabs (all/processing/fulfilled/cancelled); /account/orders/
[orderNumber] shows the full detail (items, delivery + billing address,
payment status and fulfilment status as two separate badges — see
src/lib/orders/status.ts — tracking, payment reference); /account/
orders/[orderNumber]/invoice is a print-styled tax invoice
(window.print() → “Save as PDF” is the download mechanism, no PDF
library needed). “Buy again” (src/lib/buy-again.ts) re-adds every line
at current price/stock via a live product lookup, skipping and naming
anything discontinued or out of stock rather than adding it at a stale
price. “Request a return” opens a reason + notes modal
(POST .../return-request, one open request per order, stored in
src/lib/account/returns-store.ts); “Contact support” is a mailto:
link pre-filled with the order number./account/addresses: add/edit (one shared modal,
AddressFormModal.tsx, keyed by address id so switching which address
it edits always mounts fresh default values instead of needing a
reset-on-prop-change effect), delete (two-click confirm), and one
“default delivery” + one “default billing” flag per customer — setting a
new default automatically clears the previous one
(src/lib/account/addresses-store.ts)./account/profile: first/last name, email (changing it
goes through Supabase’s own re-verification — the new address only takes
effect once its confirmation link is clicked, so the UI says so rather
than claiming an instant change), mobile number, date of birth
(optional), marketing consent./account/security: changing a password
re-authenticates with the current password first
(signInWithPassword) before calling updateUser() — an active session
alone (e.g. a device left logged in) isn’t enough to take over the
account’s credentials./account/preferences: the marketing-email toggle
(same field as Profile’s, shown here as its own settings page per the
brief) plus an always-on, non-toggleable “order & shipping updates” row —
transactional email can’t be switched off, and the UI says why./account/payment-methods: read-only, and
deliberately so. It lists the reference from each past order’s payment
(e.g. TEST-a1b2…, EFT-CC-260902-0001) with an explanation that card/
bank details themselves are never stored here — they live only with the
PCI-DSS-compliant gateway (PayFast/Peach/Yoco/Ozow) that processed them.
This is what “saved payment references where legally appropriate” means
in practice: a reference, never a card number.profiles.role ("customer" | "admin", default
"customer") and requireRole("admin") in src/lib/supabase/dal.ts are
real and ready, but nothing sets a profile to "admin" and no admin
surface calls requireRole() yet — there’s no admin area in this build.
Wired now so that surface doesn’t need to retrofit authorization later.getUser(), which round-trips to the Supabase Auth server rather
than trusting the session cookie’s contents — the same distinction
Supabase’s own docs draw between getSession() (fast, optimistic) and
getUser() (slow, authoritative). proxy.ts uses it for the fast
redirect; the DAL and every /api/account/** route use it again for the
real check.GET /api/orders/
[orderNumber] (used by the just-checked-out confirmation page) is
intentionally keyed by the order number alone, the standard pattern for
a guest-checkout redirect. GET /api/account/orders/[orderNumber] (used
by the account area) is the authenticated counterpart and is what
actually enforces ownership: it 404s — the same response whether the
order doesn’t exist or belongs to someone else — unless
order.userId === session.user.id. Knowing an order number is never
sufficient by itself inside the account area.src/lib/rate-limit.ts is an in-memory
fixed-window limiter, documented (like orders/store.ts) as a dev/demo
substitute for a shared store (Upstash Redis, or similar) a real
multi-instance deployment would need. Applied per-IP to sign-up and
per-IP-and-per-email to login, forgot-password and change-password, so
neither one attacker IP hammering many accounts nor a distributed attack
hammering one account gets unlimited guesses.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/** 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).
src/proxy.ts gates
/admin/** and /api/admin/** on having any signed-in session (same
mechanism as /account), and requireAdmin() in src/app/admin/layout.tsx
additionally requires the session’s profile to hold one of the six admin
roles below — a customer account is redirected away even though it’s
signed in. Every /api/admin/** route handler repeats the role/permission
check server-side via getAdminContext() + hasPermission(), so the UI
gating is a convenience, not the enforcement boundary.src/lib/admin/*-store.ts,
src/lib/orders/store.ts, src/lib/account/profiles-store.ts), the same
development/demo substitute for Postgres used elsewhere in this build (see
Seed data & placeholder imagery) — every
store function is written so swapping its body for a real Supabase query
later doesn’t change any call site. This is what makes admin edits appear
live: reads and writes share the same in-process module state, so there’s
no build step, cache, or redeploy between an admin saving a change and the
storefront rendering it.recordAuditLog()) with
the acting user, the action, the record affected, and before/after values
— reviewable at /admin/audit-log.POST /api/admin/media as base64 data URIs
(capped at 2MB) rather than a cloud storage bucket, since this environment
has no storage credentials configured — see
Environment variables.| 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.
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.
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.
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.
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:
npm run dev and open /dev/emails
(blocked outside development)..email-previews/*.html file — nothing fails,
nothing is lost, nothing real is ever sent by accident.EMAIL_PROVIDER to resend, sendgrid, or
postmark (see .env.local.example) — nothing else in the codebase
changes.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
All design tokens live in src/app/globals.css under @theme (Tailwind v4’s
CSS-native config) — there is no tailwind.config.ts.
Palette (exact brand values):
| Token | Hex | Usage |
|---|---|---|
porcelain |
#F7F5F0 |
primary page background |
warm-white |
#FCFBF8 |
card / header solid surfaces, text-on-dark |
soft-grey |
#E9E7E2 |
subtle tints, dividers |
sand |
#D8CBBB |
section fills, light badges, borders |
taupe |
#A99B8A |
muted borders/icons, scrollbar thumb |
stone |
#746C62 |
secondary/muted text |
charcoal |
#1C1C1A |
primary text, high-contrast sections |
pure-black |
#080808 |
reserved for the deepest-contrast surfaces |
green |
#697262 |
muted accent (availability, secondary emphasis) |
champagne |
#B69A68 |
gifting / editorial highlight accent |
success |
#287A4B |
|
error |
#B42318 |
Use them as plain Tailwind classes: bg-porcelain, text-stone,
border-sand, text-champagne, etc. — including arbitrary opacity, e.g.
bg-champagne/25.
rounded-2xl / rounded-3xl for cards and imagery,
rounded-full for the nav, buttons and pills.font-sans, default) for UI and body copy; Fraunces
(font-display) for editorial headlines only. Fluid headline sizes use
clamp()-based utility classes — text-display-md / -lg / -xl / -2xl
— defined once in globals.css and applied directly (no manual
sm:/lg: stacking needed for hero and section headings).<Reveal> wraps a section in a scroll-triggered fade-up
(Framer Motion whileInView); Header, MegaMenu, MobileDrawer, SearchModal,
CartDrawer, CookieBanner and the Hero/Reviews Carousel each use their own
enter/exit transitions.:focus-visible outline (charcoal, 2px) is
applied to every interactive element via globals.css, independent of
each component’s own styling; a “Skip to content” link is the first
focusable element in layout.tsx; prefers-reduced-motion is respected
site-wide.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.
Three different carousel needs, two implementations, sharing one hook:
Carousel (src/components/ui/Carousel.tsx) — one slide fills the
viewport at a time; prev/next arrows, dot pagination, optional autoplay
(pauses on hover/focus), ArrowLeft/ArrowRight keyboard support. Used
by the Hero and Reviews.useHorizontalScroll (src/lib/hooks/use-horizontal-scroll.ts) directly
with their own prev/next arrows and no dots, since “how many items are
visible” varies by breakpoint — a dot-per-page doesn’t generalise cleanly
there. Mobile relies on native swipe (overflow-x-auto + scroll-snap);
desktop gets the arrows.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.
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 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.
MegaMenu — “Shop” and “Collections” open a dropdown panel on hover
or keyboard. Keyboard support follows the WAI-ARIA disclosure pattern:
ArrowDown on the trigger opens the panel and moves focus into its
first link; Escape closes it and returns focus to the trigger; plain
Tab-focus on the trigger does not auto-open it (only hover/click/
ArrowDown do) — this is what avoids a focus/open feedback loop.MobileDrawer — slide-out panel below the xl breakpoint, with an
accordion for Shop/Collections, account/wishlist links, help links and
social icons.SearchModal — full-screen overlay with live product suggestions
matched via src/lib/catalogue.ts’s searchProducts() (name, SKU,
description, category, product type, collection and tags, with basic
typo tolerance — see “Shop & product catalogue” above), result-text
highlighting, recent searches (localStorage), popular-category
suggestions on no results, and full keyboard navigation
(↑/↓/Enter/Escape). Pressing Enter with no result selected routes
to /shop?q=<query>, so a search and the shop’s own filtering share one
matching implementation.nav-data.ts centralizes nav links, collections, footer link groups,
social links and contact info so Header/MegaMenu/MobileDrawer/Footer never
drift out of sync.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.
addItem/updateQuantity clamp against
product.stockQuantity; CartLineItem.tsx also does a live
getProductBySlug() lookup per line to show low-stock/out-of-stock/
exceeds-available-stock messaging, since the store’s own snapshot can go
stale between visits.cart-store.ts holds coupon/couponError; applyCoupon()/
removeCoupon() call into src/lib/promotions.ts (validateCoupon()),
which checks active/date-range/usage-limit/minimum-spend/product- or
collection-scoping against src/data/coupons.ts. Every add/remove/quantity
change re-validates the applied coupon (revalidateCoupon()) and silently
drops it with an explanatory couponError if it’s no longer eligible (e.g.
the only matching line was removed). This is client-side UX only — see
below for why the server never trusts it.toggle(product) (used by ProductCard’s heart button) and
a separate add(item) (used to move a denormalized cart line to the
wishlist without needing the full Product record) both live in
wishlist-store.ts. /wishlist (WishlistPageView.tsx) shows live stock
status per item and a “Share wishlist” button (Web Share API, clipboard
fallback) that builds a /wishlist/shared?items=slug1,slug2,... link,
resolved by SharedWishlistView.tsx for anyone who opens it (own account
not required).src/lib/merge.ts has pure, store-agnostic
mergeCartLines()/mergeWishlistItems() functions (guest quantities add
to matching account lines rather than overwrite them; wishlist items
de-duplicate by product id). src/lib/hooks/use-auth-cart-sync.ts wires
these to Supabase’s onAuthStateChange, mounted globally via
AuthCartSync.tsx in layout.tsx. SIGNED_IN now genuinely fires (email
login, sign-up, and — if enabled — OAuth all trigger it), so on every
sign-in the guest cart/wishlist held in localStorage merges into the
account’s copy in place. fetchAccountCart/fetchAccountWishlist inside
that hook are still stubbed to return [], though — there’s no real
cart_items/wishlist_items Supabase table yet for a second device to
restore an account’s saved cart/wishlist from, so today the merge is
effectively “the guest cart survives sign-in unchanged” rather than a true
cross-device merge. Wiring those two fetches is the one remaining step
once those tables exist; the merge algorithm itself doesn’t change.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 /> (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 |
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.
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:
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.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.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.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.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.
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.
# 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
next/image with explicit sizes
for responsive srcsets; the Hero’s first slide is priority-loaded, all
others lazy-load; placeholder SVGs are tiny (a gradient + one glyph) so
they cost almost nothing over the wire./shop, /shop/[category], /collections,
/collections/[collection], /journal, /journal/[slug],
/products/[slug]) prerender at build time via generateStaticParams
and revalidate hourly (export const revalidate = 3600), so an admin
edit to the in-memory content stores shows up within the hour without a
redeploy.SearchModal, QuickView and CookieBanner are
loaded via next/dynamic({ ssr: false }) so their (framer-motion-heavy)
bundles only download once a visitor actually opens them, not on every
page’s first paint; framer-motion is also opted into
experimental.optimizePackageImports in next.config.ts.loading.tsx for /account/**,
/admin/** and /checkout mirror their resolved layout’s shape (no
layout shift); /shop, /shop/[category] and /collections/[collection]
use an inline <Suspense> + ShopSkeleton for the same reason.<title>/<meta description>
and alternates.canonical; product, category, collection and journal
pages additionally set full OpenGraph/Twitter card metadata sourced from
their own SEO fields. /cart, /checkout, every /account/** and
/admin/** page carry robots: { index: false, follow: false }; a
dynamic src/app/robots.ts disallows those same sections plus /api,
/auth and /dev.src/app/sitemap.ts covers every static page plus
category/collection index routes; src/app/products/sitemap.ts and
src/app/journal/sitemap.ts are separate, per the task, and all three
are listed in robots.ts.Organization (with logo/contactPoint)
and WebSite (with a SearchAction pointing at /shop?q=) JSON-LD on
the homepage; Product JSON-LD (SKU, price, availability, brand, rating,
reviews) plus BreadcrumbList on every product page; BreadcrumbList on
every other page with a visible breadcrumb trail (via the shared
breadcrumbJsonLd()/<JsonLd> helpers in src/lib/seo/json-ld.tsx);
Article JSON-LD on journal posts; FAQPage JSON-LD on /faq.opengraph-image.tsx/twitter-image.tsx
routes (via next/og’s ImageResponse) generate a branded 1200×630 PNG
card for the homepage, every product, every collection and every journal
article — this project’s seed imagery is local SVG, which most social
platforms won’t render as a link preview at all, so these are real PNGs
rather than pointing at the SVG assets directly. Favicon/apple-touch-icon
and the two manifest icon sizes are generated the same way, from a shared
BrandMark component (src/lib/og/).src/lib/analytics/ prepares GA4, Meta Pixel, TikTok
Pixel and Microsoft Clarity, each gated on both its env var being set
(see Environment variables) and the matching
cookie-consent category; see
Cart, wishlist, recently viewed & cookie consent
below for exactly how that gating works and the full list of tracked events.Verified at phone (390px), tablet (834px) and desktop (1440px) widths:
xl breakpoint (1280px+); everything below that —
phones and tablets — gets the compact bar (logo, search, bag, menu)
and the slide-out MobileDrawer. This is a deliberate choice given the
expanded nav requirements, not an oversight.lg+, a 3-column grid at sm–lg, and a swipeable scroll-snap row below
sm (6 cards don’t fit one screen on a phone).xl, ~3 at lg, ~2 at sm,
and a swipeable single-card-plus-peek row on phones; its arrow controls
are lg+ only (mobile relies on touch swipe).sm
and stack fully below that.xl (mobile uses the drawer’s accordion instead).:focus-visible, 2px charcoal outline) on every
interactive element, not just the ones that opted in individually.aria-haspopup,
aria-expanded, ArrowDown to enter, Escape to exit and return focus)
rather than only working for mouse/hover users.Carousel primitive exposes role="region" aria-roledescription="carousel",
labels each slide (aria-hidden on off-screen slides), and its dot
pagination uses role="tablist"/role="tab" with aria-selected.aria-current="page" plus a persistent underline mark the active nav
item; aria-labels on all icon-only buttons (cart, wishlist, search,
social links, close buttons, carousel arrows).aria-invalid
and aria-describedby to their error text; the cookie preference Switch
uses role="switch"/aria-checked with htmlFor/aria-describedby on
each row.prefers-reduced-motion disables/shortens the site’s custom transitions
and scroll-behavior globally.charcoal-on-porcelain and
warm-white-on-charcoal are both high-contrast pairs for body text;
champagne (a light, warm accent) is used as text only on dark
(charcoal) backgrounds or as a background chip under dark text — never
as small text on a light surface, where its contrast would be too low.
A follow-up contrast audit (computed WCAG ratios, not eyeballed) found
champagne text/icons on light surfaces (rating stars, low-stock notices,
etc.) were in fact shipping at ~2.5:1, well under the 4.5:1 minimum for
text; --color-champagne-ink (src/app/globals.css) is a darkened
variant (~4.6–4.9:1 on porcelain/warm-white) now used everywhere
champagne renders as foreground content on a light background, leaving
the original champagne for backgrounds/borders and the dark-background
case, which already passed.Modal and MobileDrawer both use a shared
useFocusTrap() hook (src/lib/hooks/use-focus-trap.ts): opening either
moves focus into the panel, Tab/Shift+Tab cycle within it rather than
escaping to the page behind the overlay, Escape closes it, and closing
restores focus to whatever triggered it — the standard WAI-ARIA dialog
pattern.Input/Select/Textarea
primitives’ built-in error prop, which auto-generates a matching
aria-describedby-linked error message rather than a same-looking but
visually-only paragraph next to the field.SocialIcons.tsx and
PaymentIcons.tsx are hand-drawn, stroke-matched replacements — see
below for swapping in official logos before launch.public/images/** are generated gradient placeholders
(npm run generate:placeholders), not real photos.PaymentIcons.tsx currently ships simplified, non-trademarked
monochrome badges (labelled Visa/Mastercard/Amex/PayPal/Apple Pay/Google
Pay) rather than the official SVGs.siteConfig.social in
src/config/site.ts points at placeholder handles/numbers; swap in the
brand’s actual accounts (this single file feeds the header, footer and
social gallery, so it only needs updating in one place).siteConfig.contactEmail
(hello@keepsbyheimsight.com) is a placeholder pending the real domain.src/data/reviews.ts is realistic sample copy,
not real customer reviews; replace before launch or wire up a reviews
table..env.local.example with every required variable documentedformatPrice()next dev and a production next build/shop,
/shop/[category], /collections, /collections/[collection])
with full filtering, sorting, search and load-more pagination, all
reflected in shareable URL query parameters/products/[slug]) built out in full: zoomable/
swipeable/full-screen gallery with variant images and video support,
colour + set-size selectors, quantity, buy now, share, a South
African delivery estimator, an 8-section spec accordion, key
benefits, a lifestyle section, “Pairs well with”/”You may also
like”/Recently Viewed rails, reviews (filter/sort/write/photo
upload/verified badge) and Q&A, a mobile sticky add-to-cart bar,
loading/error/out-of-stock/discontinued states, and Product JSON-LDdiscontinued
to exercise that PDP state end-to-end)next dev and a production next buildsrc/data/*.ts up to Supabase (schema + query functions — see
“How catalogue data will connect to Supabase” above)