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/*:
| Method | Path | Scope | Purpose |
|---|---|---|---|
| GET | /balance | customer:read | One-shot read of the four KPI numbers |
| GET | /transactions | customer:read | Paginated history (?page=1&per_page=20, max 100) |
| GET | /calculate?points=N | customer:read | Returns {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 | /redeem | customer:write | Used 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 readsuseStorefrontAuthStore). - 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
nullon 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/rewardsafter 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).
