The storefront customer portal

Reward Points

The storefront customer portal

Customers see their reward program at /account/rewards. The page is read-only; there is no in-page redemption — that lives on the checkout page.

What the customer sees

/account/rewards
├── Balance card (4 KPIs)
│   ├── Available     — what they can spend right now
│   ├── Total earned  — lifetime credits
│   ├── Total redeemed — lifetime debits at checkout
│   └── Total expired  — lifetime aged-out
└── Transaction history (paginated)
    ├── Earn         — green up-trend icon, "+N pts"
    ├── Redeem       — blue cart icon, "−N pts"
    ├── Bonus        — purple gift icon, "+N pts"
    ├── Adjust       — gray badge icon, ±N pts
    └── Expire       — orange clock icon, "−N pts"

Each row carries its description (Order #1234 completed, Order #1234 cancelled, Points expired after 365 days, or whatever the admin typed when adjusting), the date formatted for the shopper's locale, and a typed icon so glanceable scanning works.

The four storefront API routes

Mounted under /api/v1/storefront/rewards/*:

MethodPathScopePurpose
GET/balancecustomer:readOne-shot read of the four KPI numbers
GET/transactionscustomer:readPaginated history (?page=1&per_page=20, max 100)
GET/calculate?points=Ncustomer:readReturns {points: N, centavos: N × redemption_rate} — preview helper for the slider
GET/earnings?total=N(none)Returns how many points an order of N minor units would earn — used on the cart preview ("you'll earn 250 points on this order")
POST/redeemcustomer:writeUsed at checkout, NOT from the rewards page; commits a redeem row tied to an order_id

The /redeem route is rarely the right call from frontend code — the orders endpoint (POST /storefront/orders) accepts a redeem_points field that handles validation + commit atomically inside the order transaction. Use /redeem only for split-checkout flows that need to reserve a debit before the order row exists.

Module + auth gating

The useModuleEnabled("reward-points") hook returns false when the module is OFF; the page short-circuits to null and the "My Rewards" entry disappears from the account sidebar nav. When the customer hits the page direct-URL:

  • module disabled → blank page (no flash of content).
  • not authenticated → redirect to /login (the page reads useStorefrontAuthStore).
  • authenticated, module on → balance + transactions render.

Empty states

  • Balance row, no history: typical state for a brand-new customer post-signup before they've placed an order. The page renders the four KPI cards (all zero) plus an empty-state component pointing them at the catalog.
  • Module disabled mid-session: the page returns null on next render — the sidebar item also vanishes. Customers who had the page open see a blank state on next interaction.

What customers cannot do from this page

  • Cannot redeem. The redemption widget is on checkout. Hammering refresh on /account/rewards after redeeming at checkout shows the new debit row instantly (the API is read-after-write consistent).
  • Cannot transfer points. Reward Points are non-transferable. There is no API to gift points between users.
  • Cannot cash-out. Points have no monetary value outside the discount-at-checkout mechanic. They are strictly a discount accelerator.
  • Cannot see expiry warning. The page shows expired transactions after the fact, not impending expiry. If your team needs an expiry-warning email (e.g., 30 days before age-out), build it as a separate scheduled job — see Expiry + revocation.

Privacy note (GDPR and similar laws)

The transaction history contains no card data, no address data, and no PII beyond the customer's own user_id and the order_ids linked to their orders. Customer right-to-delete (account deletion) cascades through users and reward rows are deleted on the same ON DELETE CASCADE chain via user_id. No separate GDPR scrub is needed for rewards data.

Where the customer-side wallet messaging lives

The phrasing on /account/rewards is statically authored in the storefront page (frontend/src/app/(storefront)/account/rewards/page.tsx). Adjust the wording via the i18n t() calls — the translation keys live in frontend/src/lib/i18n/* under account.rewards.*. There is no admin-driven copy override for this page (yet).