A complete online store for halal frozen foods with a second store for kitchen essentials, built for the Pakistani market with PKR pricing, JazzCash, Easypaisa and Cash on Delivery. It covers the entire shopping journey — discovery, product detail, cart, checkout, live order tracking, account and admin — and adds a fully modelled Refer & Earn programme whose rules are correct by construction: no double-crediting, no fraud loopholes, predictable cost.
Someone wanted an online shop that sells frozen food — nuggets, kebabs, ready meals — plus a second shop for kitchen tools like pans and knives. Frozen food is tricky to sell online, because customers worry it will arrive melted, or that it is not halal, and delivery speed depends on the city.
We built a complete store that answers those worries directly: it shows the live temperature of the delivery box, tells you exactly how fast your city gets orders, and lets you pay by cash, card or mobile wallet. It also has a "Refer & Earn" feature where customers get credit for bringing friends — designed so it can't be abused or paid out twice.
A business owner also gets an admin view showing sales, stock alerts and referral results. Everything works end to end as a live demo you can click through yourself.
| Metric | Value |
|---|---|
| Products | 35 across 12 categories, split over two stores (frozen foods and kitchen essentials) |
| Routes | Home, Shop, Product, Cart, Checkout, Order tracking, Track, Wishlist, Account, Referrals, Invite landing, Admin, 404, sitemap, robots |
| Source Size | ~5,750 lines of TypeScript/TSX |
| Largest Module | lib/referrals.ts — 589 lines of pure domain logic |
| Backend | None — state lives in the browser (localStorage), demo data |
| Payments | Cash on Delivery, Card (Luhn-validated), JazzCash, Easypaisa |
| Status | Live on Vercel — production build is lint and type-check clean |
Frostly is an online store for halal frozen foods — nuggets, kebabs, samosas, ready meals, seafood, desserts, fruit and veg — with a second store for kitchen essentials: cookware, appliances, knives and serveware. It targets the Pakistani market, with PKR pricing, JazzCash / Easypaisa / Cash on Delivery, local cities and the 03xx phone format.
The project is built as a client-presentation piece and is live at frostly-foods.vercel.app. It covers the entire shopping journey and adds a Refer & Earn programme, which is the most technically involved part of the codebase.
Selling frozen food online is harder than selling regular groceries, and the two-store model adds another layer:
Customers worry the product will arrive thawed, or that it is not halal. A normal storefront doesn't address that.
Delivery availability and speed depend on the city; a "-18°C cold chain" is the selling point, not a footnote.
A single frozen item rarely reaches a healthy order value, and delivery cost eats margin. People who buy air-fryer nuggets also need an air fryer, but the two catalogues usually live in different silos.
For a new brand, word-of-mouth is the cheapest channel — but a referral programme is easy to get wrong (fraud, double-crediting, uncontrolled cost).
Cash on Delivery and mobile wallets matter as much as cards.
A storefront that feels like a real product and can be shown to a client end to end, with cold-chain trust and delivery logic as first-class UX elements:
| Step | What the Customer Gets |
|---|---|
| Home | Hero, trust strip, two-store split, category grid, flash-deal countdown, bestseller tabs, "Friday Movie Night" bundle builder, delivery-city checker, reviews, FAQ, newsletter |
| Shop | Filters for store, category, tag, price ceiling, minimum rating and in-stock; six sort orders; removable filter chips; URL-synced state; mobile filter drawer; skeleton loading |
| Search | Header instant-search with keyboard navigation (/ to focus, ↑/↓, Enter, Esc) and a full results page fallback |
| Product | Zoom gallery, pack-size variants, low-stock urgency, nutrition facts, cooking guides (air fryer / shallow fry / oven / microwave), reviews, related products and cross-sell from the other store |
| Cart | Cart drawer and full cart page, quantity limits, free-delivery progress bar, promo codes, save-for-later / wishlist |
| Checkout | 3-step flow (details → delivery slot → payment), inline validation, city-aware slots, four payment methods, referral credit redemption |
| Order Tracking | Animated route map, live box temperature, five-stage timeline, ETA and rider info, printable invoice |
| Refer & Earn | Invite link, share tools, rank progression, friend table, wallet ledger, leaderboard, demo controls |
| Admin | KPIs, revenue chart, category revenue, referral analytics, top sellers, inventory alerts, searchable order table, CSV export |
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router) · React 19 |
| Language | TypeScript 5 |
| Styling | Tailwind CSS 4 with frost-blue / ember-orange design tokens, light and dark mode |
| State | Zustand 5 with persist middleware |
| Icons & Type | lucide-react · Fraunces + Plus Jakarta Sans via next/font |
Captured from the live deployment at frostly-foods.vercel.app:
Anything that depends on time is computed on every read. Delivery stores only createdAt and derives stage, progress, ETA and rider; referrals store only the referral list and credit spends and derive reward status, wallet, rank and ledger. Nothing is written by a timer, so state can't drift from the clock and double-crediting is structurally impossible.
Pages stay as Server Components (metadata, static params, notFound()) and only interactive islands are "use client". generateStaticParams pre-renders all 35 product pages.
A persisted store holds cart, wishlist, orders, promo, user and referrals in localStorage, versioned with a migrate step so existing users' saved data upgraded cleanly when the referral fields were added. A separate non-persisted store handles the cart drawer and toasts.
Persisted state only exists in the browser, which normally causes hydration mismatches. useHydrated() and useNow() are built on useSyncExternalStore and return stable server values. One setInterval per tick rate is shared through reference-counted subscription, so ten countdowns on a page run on a single timer.
CITIES drives the delivery checker, checkout slots and validation; PROMOS and computeTotals drive every price display; evaluateInvite drives all referral eligibility. The promise on the home page and the options at checkout can never disagree.
Demo orders, reviews, seeded referrals and analytics use seeded generators (a Lehmer LCG). Demos are reproducible and the server and client always render the same values.
Product JSON-LD with AggregateRating and AggregateOffer in PKR, per-page generateMetadata, sitemap.ts and robots.ts, a skip-to-content link, ARIA labels, keyboard-navigable search and full prefers-reduced-motion support. An inline pre-paint script prevents the flash of the wrong theme.
| Feature | How It Works |
|---|---|
| Flash-Deal Countdown | Driven by the shared clock, so several countdowns share a single timer |
| Bundle Builder | Four items (frozen + a wok); selecting 3 or more gives an automatic 12% discount (BUNDLE12, min. Rs. 2,500) through the promo system |
| Free-Shipping Bar | Delivery is Rs. 199 below Rs. 3,000; the bar shows the exact amount left to reach free delivery |
| Cross-Store Cross-Sell | crossSell() picks best sellers from the other store (frozen ⇄ kitchen), offset by product ID so suggestions vary |
| Delivery-City Checker | Same-day in Lahore, Karachi, Islamabad and Rawalpindi; next day for Faisalabad, Multan, Peshawar, Sialkot, Gujranwala; 2 days for Hyderabad, Quetta, Bahawalpur |
| Live Box Temperature | On the tracking page, fluctuating gently around −18°C |
| Promo Rules | WELCOME10, FROSTY500, BUNDLE12, FREESHIP — if a minimum isn't met the UI says exactly how much more to add |
Checkout validates the Pakistani mobile pattern (03xx-xxxxxxx), email format and deliverable city; cards are checked with the Luhn algorithm, brand detection, expiry and CVC length. Delivery slots are city-aware — the first available day is offset by the city's delivery speed, and past windows today are disabled.
The referral engine (lib/referrals.ts) is pure domain logic — no React, no store, no I/O — so every rule can be tested in isolation and the UI only renders what it returns.
| Rule | Value |
|---|---|
| Friend's Reward | Rs. 500 off a first order above Rs. 2,000 |
| Referrer's Base Reward | Rs. 700 per converted friend |
| Basket Bonus | 5% of the order total above Rs. 3,000, capped at Rs. 500 |
| Holding Period | 3 min in the demo (3 days in production) to cover returns |
| Invite / Credit Expiry | 30 days without a first order / 90 days after credit clears |
| Monthly Cap | 20 paid rewards per referrer per rolling 30 days |
| Redemption Limit | Max 50% of a basket, never against delivery fees |
| Rank | Unlocked Referrals | Multiplier | Perk |
|---|---|---|---|
| Starter | 0 | ×1 | Rs. 700 per friend |
| Insider | 3 | ×1.15 | +15% rewards, free delivery for a month |
| Ambassador | 8 | ×1.35 | +35% rewards, early access to drops |
| Legend | 15 | ×1.6 | +60% rewards, permanent free delivery |
summarize() walks conversions in chronological order and prices each reward at the rank the referrer held at that moment, so reaching Legend never re-prices rewards earned as a Starter. The same walk decides which conversions fall inside the rolling 30-day cap.
evaluateInvite() is the single source of eligibility and returns a typed reject reason: unknown code, self-referral, already referred, existing customer, or monthly cap. Re-visiting a used link is idempotent, fraud-flagged referrals never pay out, and credit is capped at half the basket.
codeFor(name, email) combines a name stem with a 3-character FNV-1a hash of the email, so the same account always produces the same code and an invite link keeps working even if local storage is wiped.
buildWallet() reconstructs every earn and spend row on each read, then computes balance, pending, lifetime earnings, spent, expired and "expiring within 14 days" — the only stored wallet data is the debit recorded when credit is spent.
programStats() simulates a 180-referrer cohort and reuses summarize() to price every reward, producing conversion rate, ROI, CAC, K-factor and fraud rate for the admin — from the same pricing function customers see.
useHydrated and useNow built on useSyncExternalStore return stable server values, so the first render always matches on server and client.
One shared interval per tick rate with reference-counted subscription; the wallet ticks every 30 s rather than every second because a balance only changes when a holding period ends.
A versioned persist store with a migrate step upgrades existing users' data cleanly.
Escaping < inside the structured-data script tag prevents </script> injection.
Slot availability is driven by city speed and the current time, with some slots shown as "Full" for realism.
Stated deliberately, because a project that hides its limitations is worth less than one that states them:
localStorage.Future roadmap: a backend and database with real authentication, payment-gateway integration with webhooks, real cold-chain IoT telemetry, unit and end-to-end tests, real inventory decrement and admin product management, transactional email/SMS/WhatsApp notifications, Urdu/RTL localisation and PWA support.
useSyncExternalStore is the right primitive for both persisted stores and shared clocks.Whether it's a niche storefront, local payment methods, cart and checkout logic, or a referral engine that can't be abused, we're always interested in building commerce that converts and scales.