Loyalty
Stamp campaigns, cycles, shake-to-win, and the points and tiers removal.
Files: convex/gamificationLoyalty/*, convex/mobileLoyalty/*.
The loyalty program is stamps only. A paid order earns stamps on every active campaign whose minimum spend it meets, and special stamp positions pay out shake-to-win vouchers. Points and tiers were removed on 2026-10-16 (see Points and tiers removal).
Stamp campaigns
stampCampaigns define totalStamps (1–24), stampsPerPurchase, minPurchaseAmountCents, and specialPositionsConfig (per-position shake reward tiers). evaluateStampEligibility in gamificationLoyalty/stampOutcome.ts is the only minimum-spend check. It compares minPurchaseAmountCents with the order subtotal in cents, after discounts and before shipping and tax, and returns the shortfall when the order falls short. The mobile bag runs a copy of the same function, and a test keeps the two in step. A normalized campaignSpecialPositions table is written alongside, but the reward engine reads only campaign.specialPositionsConfig.
Cycle math (computeStampPlan, pure)
- The cycle-completing stamp lands on position
totalStamps(never 0). currentStampsstores only the remainder.completedCyclesis a monotonic lifetime counter (analytics read it).- Redemption consumes
redeemedCycles; redeemable =completedCycles − redeemedCycles.
awardStampsForPurchase inserts one stampHistory row per stamp, evaluates a special reward at every crossed position, maintains the campaignStamps aggregate per row, patches progress once at the end, and dedups by (orderId, campaignId). Multi-stamp awards that cross a special position still grant it.
Shake-to-win
Rewards are instant at stamp collection. calculateShakeReward draws from probability tiers (tierOrder, summing to 100%, validated on write) using cryptoRandomFloat(). Reward tier types are VOUCHER and MISS. A POINTS tier saved before the removal grants nothing, and calculateShakeReward logs a warning when it draws one. Voucher wins insert a vouchers row immediately (source shake_reward), enqueue Shopify code creation, and bump the counter. The mobile "shake" animation is purely visual.
shakeOpportunities (pending/claimed/expired) is vestigial — the instant-reward design replaced it; no production code inserts rows.
Safety rails
collectStamprate limit: 1/min per(userId, campaignId).collectStampreturns a uniform failure result instead of throwing, so one campaign's failure can't sink the workflow.mobileCollectStampisinternalMutationon purpose — it used to be public and let clients mint stamps with a self-supplied amount.resetUserProgressmust zeroredeemedCyclestoo, or the negative redeemable balance locks the user out.- NaN guard before stamp math (
Number.isFinite) — NaN passes<= 0checks and poisons progress.
Points and tiers removal
Points and loyalty tiers came out on 2026-10-16. Nobody had ever spent a point (prod showed zero redemptions), so the whole surface went. Orders earn no points, checkout reserves none, admins cannot adjust a balance, and the dashboard lost its tier badges, tier filters and carousel tier targeting. Shake-to-win reward tiers are a different thing and stay.
- Dashboard queries return no points or tier keys. The dashboard on
mainbefore this release still reads them, so its backend deploys only after Vercel has rebuilt the dashboard (step 6 below). orders.pointsEarnedis the legacy done marker. A non-null value, including 0, still means the order's rewards run finished.isOrderRewardsSettledhonours it next torewardsProcessedAt.campaign.rewardTypeisvoucherorproduct.- The schema no longer defines the five tables
loyaltyPoints,pointsTransactions,loyaltyTiers,userTiersandloyaltySettings, theuserProfiles.loyaltyPointsBalanceanduserProfiles.loyaltyTierfields, or the checkout-session reservation fields. Convex refuses a schema that drops a field documents still hold, so a deployment that still has points data needs two deploys.
Dev was done on 2026-10-16. For staging and prod, from packages/backend:
- Deploy commit
f3c378a1. It runs the stamps-only code on the old schema, still shipsmigrations/removePoints.ts, and still serves the old dashboard. bash scripts/archive-points-tables.sh <staging|prod>writes every row of the five tables to JSONL outside the repo. It exits 2 when a table hits the export limit, and thenbunx convex exportis the fallback. The archive is customer data, so never commit it.bunx convex run migrations/removePoints:removePointsData '{"archived": true}'deletes the rows and unsets the leftover profile, checkout and campaign fields. Add--prod, or run it through the staging env file, for those deployments. It refuses to run withoutarchived: true, and itsprobe.okmust be true.- Deploy commit
feca3de3, the schema drop. Deploying it before step 3 fails safely, because Convex rejects the schema and changes nothing. - Run
migrations/orderStamps:fillOrderStampFields '{}'. - Rebuild the dashboard from this release. For prod, merge the
developtomainPR so Vercel rebuilds. For staging, pushstaging. - Deploy
developHEAD, which drops the dashboard compatibility keys.