Loyalty Card (Store Staff Side)
Overview
This page is the counter tool for store staff, opened from their own LINE account. It does two things:
- Issues a QR for customers to scan and earn, specifying either a stamp count or the amount the customer spent.
- Scans customer coupons to redeem rewards.
Its most important design decision is a fail-closed gate: the issuing panel appears only when the staff status is active. Every other status — including new statuses an older app build does not recognize — routes to the access request screen. This prevents a stale build sitting on someone's phone from accidentally granting access.
Business Flow
Access and Onboarding (Invite-Only)
-
Staff open
/{hash}/loyalty-staff, sometimes with an invite parameter attached. -
The app authenticates, then queries the API for this account's staff status.
-
The screen shown depends on that status:
Status Screen shown Active The issuing panel, ready to use Pending A message that approval from an administrator is pending Revoked A message that access was revoked, with no re-request button — they must contact the shop owner Not found or any other status A message that an invite link is required -
If an invite parameter is present and no access exists yet, the app claims it automatically and immediately — no extra confirmation tap. Distinct messages cover the cases where the account is already staff and where the link is no longer valid.
-
The invite token survives the round trip through LINE sign-in because the page stores the full URL, query string included, as its return destination.
Issuing a QR for Customers
- Select a branch — this control appears only when the shop has more than one.
- Enter a value according to the program's mode:
- Stamp mode — use the plus/minus buttons to set the number of stamps (1 to 20).
- Point mode — use the numeric keypad to enter the amount the customer spent (up to 1,000,000). The app previews how many points that yields, rounding down to match the server's calculation. If no conversion rate has been configured yet, staff are prompted to notify the shop owner.
- Tap to generate the QR. The app requests a token from the API, sending the branch plus either the stamp count or the amount spent. In point mode the client never sends a point total itself — the server converts the amount, because a client-side figure could be tampered with.
- The QR is displayed encoding a LIFF link to the card page with the token, whenever the LIFF ID is known. The advantage is that an ordinary phone camera can open it, so new customers need not add the OA as a friend first. If the LIFF ID is unknown, the QR encodes the bare token, which can only be scanned from inside the card app, and the caption below the QR changes accordingly.
- The countdown is derived from the expiry timestamp the server returns, not the device clock, so a phone that slept mid-transaction never shows time remaining on a code that has already died.
Redeeming a Customer's Reward
Staff tap to scan the customer's coupon; the app opens the scanner, uppercases the code, and submits it for redemption. The common cases have distinct messages: the reward has already been claimed, the reward has expired, and the reward could not be found.
Key Screens & Components
The page lives at /{hash}/loyalty-staff, with components under
src/app/[hash]/loyalty-staff/:
- Page container (
staff.container.tsx) — checks staff status, chooses which screen to render, handles the automatic invite claim, and provides a LIFF window-close helper. - Issuing panel (
components/IssuePanel.tsx) — branch selection, stamp count or amount entry, QR generation, the countdown, and the coupon scan button, along with the ceiling values for stamps and amount. - Access request screen (
components/RequestAccess.tsx) — used for every status that does not pass the gate. - Components shared with the customer card page — the sign-in-again screen and the scanner helper.
API calls are consolidated in src/service/loyalty.service.ts, the same module the
customer card page uses.
Endpoints Used
| Method | Path |
|---|---|
| GET | /loyalty/{hash}/staff/me |
| POST | /loyalty/{hash}/staff/claim |
| POST | /loyalty/{hash}/staff/tokens |
| POST | /loyalty/{hash}/staff/redeem |
| GET | /loyalty/{hash}/staff/activity |
The activity history endpoint already has service support, but no screen calls it yet.
Dependencies
- Ant Design — uses the
QRCodecomponent to render the QR at 220 pixels. - The LIFF scanner — accessed through the same helper as the customer card page, so error handling is identical.
- The bulletin board's authentication hook — used to build the API request headers (see bulletin-board).
- The customer card page is the matching half: tokens issued here are consumed there, and coupons created there are burned here (see loyalty-card).
- A dedicated test suite verifies the status-based access gate.
Backend Details (Client API)
What "being staff" means to the backend
All five staff endpoints use the same LIFF app as customers and authenticate in exactly
the same way. Being staff is not a different kind of token — it is having a row in
loyalty.staff bound to that LINE user, and each handler checks its own allow-list
individually. There is no central middleware for it.
The per-organization app toggle middleware also covers the staff side: if the platform owner disables the loyalty app for an organization, that organization's staff tool stops working immediately too.
GET /staff/me — the endpoint that decides which screen appears
- Verify the LIFF token against the OA's channel, then look up the staff row by LINE user.
- Not found → return status
unknown, the display name taken from the token, and an empty branch list. - Found → return the status, the assigned branch, and the display name.
- The branch list, program mode, unit label, and conversion rate are sent only when that staff member can actually issue points. A branch list discloses the size of the business to someone without permission, and the mode and rate exist for the issuing screen, which only issuing-capable staff ever see. This is why the web app can pick a screen from the status alone — there is nothing else to see.
POST /staff/claim — accepting an invite (rate limited 5/60s, the strictest here)
This is the only endpoint reachable by someone who is not yet staff, which makes it the place tokens get guessed. It replaced the previous system's open "request staff access" endpoint: today the shop owner identifies the person first and sends them a single-use link.
- Empty token → 400
LOYALTY_INVITE_INVALID. - Already staff → 409
LOYALTY_ALREADY_STAFF, without burning the invite — what they need to do is nothing at all. - An unknown, expired, or already-claimed token collapses into one code,
LOYALTY_INVITE_INVALID, because to someone holding a link all three are the same situation, and distinguishing them would confirm which tokens exist (helpful only to someone guessing). - On success it returns the status of the newly created staff row.
POST /staff/tokens — issuing a QR (rate limited 30/60s)
Checks run in order:
- Program not live → 400
LOYALTY_PROGRAM_INACTIVE; staff not active → 403LOYALTY_STAFF_NOT_ACTIVE. - Point mode — the amount spent is required (missing → 400
LOYALTY_AMOUNT_REQUIRED), and the server converts it to points itself, never accepting a point figure from the staff device: a client sending points directly can send any number it likes, and the conversion rate is a shop setting rather than a phone setting. No rate configured, or an amount of zero or less → 400LOYALTY_RATE_NOT_SET. A genuine purchase that works out to less than one point → 400LOYALTY_SPEND_TOO_SMALL, which has its own code because "spend a little more" is a completely different message from "the system is broken". - Stamp mode — the amount is ignored and the stamp count the staff member typed is used (a cup of tea is one stamp whatever it costs). If no count is sent and this is not point mode, it defaults to 1.
- The upper bounds differ because "unit" means different things — stamp mode uses the
card-size ceiling (20, a grid that still fits a phone screen; a typo of 500 would hand out
50 cards in a single scan), while point mode uses a 10,000 ceiling (at 20 baht per point,
a 2,000-baht bill is 100 points, an ordinary basket). Out of range → 400
LOYALTY_TOKEN_INVALID. - Staff without permission for that branch → 403
LOYALTY_BRANCH_INVALID; a branch that does not exist → 400 with the same code. - The token is stored with a 60-second lifetime — short enough that a screenshot of the QR is worthless — and the response carries the token, unit count, amount, and expiry timestamp (the value the web page counts down from instead of the device clock).
Tokens issued here are consumed by the customer-side earn endpoint, which is what settles races via a conditional UPDATE (see loyalty-card).
POST /staff/redeem — scanning a coupon to burn it (rate limited 30/60s)
- The staff member must be able to issue, otherwise 403
LOYALTY_STAFF_NOT_ACTIVE. - The code is uppercased and trimmed; empty → 400
LOYALTY_REWARD_INVALID. - Burning is a conditional UPDATE inside a transaction, not a read followed by a write — success means the reward comes back.
- When nothing matches, the service explains why but only for rewards belonging to this
OA, so a code from another tenant answers "unknown" rather than confirming it exists
somewhere: missing → 404
LOYALTY_REWARD_INVALID; already used → 409LOYALTY_REWARD_CLAIMED; anything else → 409LOYALTY_REWARD_EXPIRED.
GET /staff/activity — today's activity
Requires issuing capability, otherwise 403 LOYALTY_STAFF_NOT_ACTIVE, and returns only
that staff member's own record, not the branch's — it is an accountability tool for the
individual. The endpoint is ready even though no web screen calls it yet.