# Build prompt: a gift card shop on the plusmore RaaS API

Paste everything below this line into an AI coding agent working in an empty folder.

---

## Goal

Build an unbranded Next.js gift card shop that resells plusmore's live gift card catalogue, with four settings that
make it the owner's own brand (section 10). Buyers pay USDC on Base directly to the shop's own wallet; the shop
verifies the payment on chain, then orders the gift card through the plusmore RaaS API and shows the voucher to the
buyer. One API instead of a deal with every gift card brand. The margin a shop keeps is the gift card's partner rate:
across the live catalogue it averages 3.8% (median 3.2%), a third of listings are 5% or more, and rates vary by brand
and can be zero. The shop also runs without a key (DEMO mode, browsing only) and is fully testable on one machine
against a mock of the API and of the chain. Production access to the live API is by approval and the partner agreement is with plusmore;
this prompt never names plusmore's own upstream supplier, and neither should anything you build.

## 1. The plusmore RaaS API

Read https://plusmore.xyz/pool-n-earn/developers/docs if you can reach it. Everything you need is also here.

- Base URL: issued with production access, env `PLUSMORE_API_BASE`. Every route below is `${PLUSMORE_API_BASE}/v1/...`.
- Auth: `X-Supplier-Key: <PLUSMORE_API_KEY>` on every call. Also send `X-Supplier-Origin: <PLUSMORE_ORIGIN_TOKEN>`, but
  only when that env var is set. Missing or unknown key: `401 {"error":"unauthorised","code":"unauthorised"}`.
  Suspended shop: `403 consumer_suspended`.
- Every error is `{"error":"<message>","code":"<code>"}` plus the extra fields noted below.
- Money: `face_minor` and `wholesale_minor` are integer minor units, two decimals for every currency (2500 is 25.00).
  Rates are basis points (`net_rate_bps` 550 is 5.50%). The float is US dollars with six decimals (`*_usd6`, the USDC unit).

`GET /v1/ping` gives `{"data":{"consumer_id":"..."}}` (use it to verify a key).

`GET /v1/brands?currency=GBP&country=GB&q=adi&page=1&per_page=200` (all optional, per_page at most 500) gives
`{"data":[row],"page":1,"per_page":50,"total":222,"min_denomination":{"GBP":10}}` with rows like:

```json
{"slug":"adidas","name":"Adidas","brand_key":"adidas|GB","description":"<p>...</p>","currency":"GBP","countries":["GB"],
 "categories":["fashion"],"logo_url":"https://.../logo","gift_card_url":"","terms_url":null,"terms_copy":"","expiry":"3 years",
 "all_denominations":[10,25,50,100,250,500],"denominations":[10,25,50,100],"range":null,
 "prices":[{"face_minor":1000,"wholesale_minor":945},{"face_minor":2500,"wholesale_minor":2363}],
 "net_rate_bps":550,"cash_equivalent":false,
 "availability":{"state":"limited","float_light":"green","max_denom_today":100,"message":""},"source":"..."}
```

UNITS TRAP: in a row, `denominations`, `all_denominations`, `range` (`{"min","max","increment"}`) and
`availability.max_denom_today` are MAJOR units (25 means 25.00), while `prices` are minor units. Multiply the major values
by 100 once, where you map the row, and use minor units everywhere else. `denominations` are orderable now,
`all_denominations` is everything the brand carries. `availability.state` is `available`, `limited` or `unavailable`;
`float_light` is `green`, `amber`, `red` or `off`. `logo_url` and `gift_card_url` may be empty strings (no image).
`description` may contain HTML: render it as plain text. Ignore `source`.

`GET /v1/brands/{slug}/availability` gives `{"data": row}`, or `404 brand_not_available`. URL encode the slug (one real
slug contains "&").

`GET /v1/fx` gives `{"data":{"base":"USD","as_of":"...","rates":{"GBP":1.3252367947,"EUR":1.1403,"USD":1},"unavailable":["XYZ"]}}`.
`rates[C]` is US dollars per 1 unit of C, the same rate the API uses to debit the float. A currency with no rate, or
listed in `unavailable`, is not sellable. Cache it for a minute.

`GET /v1/summary` gives `{"data":{"brands":<number>,"gift_cards_listed":<number>,"countries":<number>,"currencies":<number>,"as_of":"..."}}`.
Use it for page copy; never hardcode these counts. A page states ONE catalogue count: `gift_cards_listed`, always labelled
"gift card brands" (for example "N gift card brands in M countries"). It is the total `GET /v1/brands` pages through;
never show `brands` beside it, and never state a second, different total on the same page.

`POST /v1/orders/check` with `{"brand_slug":"tesco","currency":"GBP","face_minor":2500}` books nothing and ALWAYS answers
HTTP 200: `{"data":{"ok":true,"code":"OK","retry_after_sec":null,"message":"","brand_slug":"tesco","currency":"GBP",
"face_minor":2500,"wholesale_minor":2363,"wholesale_usd6":31320000,"ledger_balance_usd6":100730000}}`. Codes: `OK`,
`LEDGER_INSUFFICIENT` (the float is too low), `SUPPLIER_RESTOCKING` (`retry_after_sec` 3600), `NOT_SOLD` (unknown brand or
amount), `CAP_EXCEEDED`. The `wholesale_*` and `ledger_*` values are the shop's private numbers.

`POST /v1/orders` with `{"client_ref":"tx-...","brand_slug":"tesco","currency":"GBP","face_minor":2500,"wallet_ref":"0x...",
"expected_wholesale_minor":2363}` answers `201` (or `200` with `"replay":true` when that client_ref was already issued):

```json
{"data":{"id":"...","client_ref":"tx-...","status":"issued","brand_slug":"tesco","currency":"GBP","face_minor":2500,
 "net_rate_bps":550,"wholesale_minor":2363,"wholesale_usd6":31320000,"fx_rate":1.3252367947,
 "voucher":{"code":null,"pin":null,"url":"https://...","expires_at":"2028-09-23T23:59:59+00:00","redemption_details":null},
 "ledger":{"balance_usd6":69410000,"enforced":true},"created_at":"...","replay":false}}
```

`client_ref` must match `[A-Za-z0-9_.:-]{1,128}` and is the idempotency key: the same client_ref always returns the same
order and voucher and is never charged twice. `wallet_ref` identifies the buyer (at least 8 characters). Errors:
`400 invalid_request`, `402 LEDGER_INSUFFICIENT`, `403 consumer_suspended`, `404` `unknown_brand`, `brand_excluded`,
`brand_not_available` or `NOT_SOLD`, `409 denomination_unavailable`, `409 price_changed` (extra `wholesale_minor`),
`409 order_in_progress` (retry in a few seconds with the same client_ref), `429 cap_exceeded`, `502 issue_failed` (the
debit was credited back; retrying with the same client_ref is allowed), `503 SUPPLIER_RESTOCKING` (`retry_after_sec`),
`503 fx_unavailable`, `500 internal`.

`GET /v1/orders/{client_ref}` gives `{"data": order}` (`voucher` null and `failure_code` set when it failed, `status`
`creating` while in progress), or `404 unknown_order`.

`GET /v1/float` gives `{"data":{"consumer_id":"...","balance_usd6":21014394,"totals":{"deposits_usd6":0,"debits_usd6":0,
"credits_usd6":0,"adjustments_usd6":0},"enforced":true,"low_balance":true,"as_of":"...","statement":{},"signature":null,"key_id":"v1"}}`.

Funding: the operator sends USDC on Base from the funding wallet they registered with plusmore to the deposit address
that comes with their production access. The shop never hardcodes it: env `PLUSMORE_DEPOSIT_ADDRESS`, shown on the admin page only.

## 2. The public catalogue (DEMO mode)

`PUBLIC_CATALOGUE_URL` needs no key. Its GET answers EVERY gift card at once (about 3 MB):
`{"ok":true,"data":{"cards":[card],"count":<number>,"summary":{"brands":<number>,"gift_cards_listed":<number>,"countries":<number>,"currencies":<number>}}}`.
The summary is the same as `GET /v1/summary`, so DEMO mode states the same one count, "N gift card brands". The entries in
`cards` are grouped by brand (regions that share a slug become one brand), so the number you page through locally is NOT that
count: never print it as a total next to it.
Fetch it once, keep it in memory for 5 minutes, then search, filter and page locally. If an answer carries top level
`page`, `per_page` and `total`, follow the remaining pages. Amounts here are already minor units. One gift card:

```json
{"brand_key":"1800baskets|US","name":"1-800-Baskets.com US","art":{"logo_url":"/api/v1/giftpay/art/1800baskets%7CUS/logo","card_url":"/api/v1/giftpay/art/1800baskets%7CUS/card"},"cash_equivalent":false,"top_rate_bps":700,"regions":[{"country":"US","currency":"USD","slug":"gt-1-800-baskets-com-us","denominations":[2500,5000,10000],"all_denominations":[2500,5000,10000,25000,50000],"range":{"min":2500,"max":10800,"increment":100},"availability":{"state":"limited","float_light":"amber","max_denom_today":10800},"base_rate_bps":100,"rate_bps":700,"usd_per_unit":1,"sellable":true}],"categories":["Groceries"]}
```

Each region is one country. Regions of a gift card that share a `slug` are the same orderable product sold in several
countries: map them to ONE brand with all their countries. `rate_bps` is the partner rate, `usd_per_unit` the FX rate.
Art paths are relative: the absolute URL is everything in PUBLIC_CATALOGUE_URL before `/api/v1/`, plus the path
(`https://plusmore.xyz/pool-n-earn/api/v1/giftpay/catalogue` gives `https://plusmore.xyz/pool-n-earn/api/v1/giftpay/art/...`).
When the catalogue cannot be read (network error, not HTTP 200, not JSON), show a small built in sample with a notice
that says so; never a server error. Save the gift card above and the two below as `lib/sample-catalogue.json`, wrapped as
`{"ok":true,"data":{"cards":[...]}}`.

```json
[{"brand_key":"1083|FR","name":"1083 France","art":{"logo_url":"/api/v1/giftpay/art/1083%7CFR/logo","card_url":"/api/v1/giftpay/art/1083%7CFR/card"},"cash_equivalent":false,"top_rate_bps":300,"regions":[{"country":"FR","currency":"EUR","slug":"1083-fr","denominations":[3000],"all_denominations":[3000,5000,7500,9900,10000,11900,13900,15000],"range":null,"availability":{"state":"limited","float_light":"green","max_denom_today":4520},"base_rate_bps":100,"rate_bps":300,"usd_per_unit":1.1225,"sellable":true}],"categories":["Fashion"]},
 {"brand_key":"1847|AE","name":"1847 UAE","art":{"logo_url":"/api/v1/giftpay/art/1847%7CAE/logo","card_url":"/api/v1/giftpay/art/1847%7CAE/card"},"cash_equivalent":false,"top_rate_bps":550,"regions":[{"country":"AE","currency":"AED","slug":"1847-uae","denominations":[],"all_denominations":[25000,50000],"range":null,"availability":{"state":"unavailable","float_light":"green","max_denom_today":1370},"base_rate_bps":100,"rate_bps":550,"usd_per_unit":0.2716576489,"sellable":true}],"categories":["Health and beauty"]}]
```

## 3. Stack, configuration and modes

Next.js 14 App Router, React 18, TypeScript (strict), plain CSS, Vitest. Pin these versions: `next` 14.2.x, `react` and
`react-dom` 18.3.x, `typescript` ~5.9 (not 7: Next.js 14 type checks with the TypeScript 5 API), `vitest` ^3.2 (Vitest 4
and later need a newer Node.js), `@types/node` 20, `@types/react` and `@types/react-dom` 18, `viem` ^2.21 (the ONE
dependency beyond Next and React: it is what verifies a buyer's claim signature against a smart contract wallet, see
section 6; everything else is plain fetch, hand written validation, hand written log decoding). A standalone folder
with its own package.json and lockfile; `npm install && npm run build && npm test` must work with nothing else.

`.env.example`, every line commented in plain English:

```
PLUSMORE_API_BASE=
PLUSMORE_API_KEY=
PLUSMORE_ORIGIN_TOKEN=
PUBLIC_CATALOGUE_URL=https://plusmore.xyz/pool-n-earn/api/v1/giftpay/catalogue
SHOP_WALLET=
NEXT_PUBLIC_SHOP_WALLET=
BASE_RPC_URL=https://mainnet.base.org
NEXT_PUBLIC_SHOP_DOMAIN=
NEXT_PUBLIC_SHOP_NAME=
NEXT_PUBLIC_SHOP_LOGO_URL=
PLUSMORE_DEPOSIT_ADDRESS=
ADMIN_PASSWORD=
```

`NEXT_PUBLIC_SHOP_DOMAIN` is optional: it names this shop in the claim message buyers sign (section 6). Empty means use
the incoming request's own host, which is right for almost every deployment.

`NEXT_PUBLIC_SHOP_NAME` and `NEXT_PUBLIC_SHOP_LOGO_URL` are optional branding (section 10). The name (empty means
"Gift card shop", trimmed to 60 characters) is the header text and every page title (`generateMetadata` in the root
layout: the name as the default title and `%s | name` as the template). The logo is an https address or a path to a
file in `public/` such as `/logo.svg`; anything else (http, `//host`, `javascript:`, `data:`) is ignored with a
configuration problem, and with no logo the header shows the name.

LIVE mode when `PLUSMORE_API_BASE` and `PLUSMORE_API_KEY` are both set, else DEMO. Read every variable in one module
(`lib/env.ts`) at request time, never at build time (read names through a variable, `process.env[name]`, so Next.js does
not inline `NEXT_PUBLIC_` values at build time), and put no host defaults in code (the defaults live only in
`.env.example`). Mark every page and route `export const dynamic = "force-dynamic"` so `next build` fetches nothing.
Checkout is enabled only in LIVE mode with a valid `SHOP_WALLET` and `BASE_RPC_URL`, and only when
`NEXT_PUBLIC_SHOP_WALLET` (the address shown to buyers) is empty or equal to `SHOP_WALLET`.

Files: `app/` (pages and route handlers), `lib/env.ts`, `lib/types.ts`, `lib/catalogue.ts`, `lib/partner.ts`,
`lib/pricing.ts`, `lib/idempotency.ts`, `lib/rpc.ts`, `lib/claim.ts`, `mock/server.mjs`, `mock/rpc.mjs`, `tests/`,
`README.md`, `.env.example`.

## 4. Money safety rules (non negotiable)

1. The API key is read on the server only. It never reaches a browser, a response body or a log line.
2. The pre payment check (`POST /v1/orders/check`) runs before the buyer is shown how to pay. If it says no, the buyer is
   never asked to pay.
3. `client_ref` is derived from the payment's transaction hash: `clientRefFromTxHash(txHash)` trims, lowercases, strips a
   leading `0x` and prefixes `tx-` (it throws unless 64 hex characters remain). So one payment is one order forever, and a
   retry MUST reuse the same client_ref. Never mint a new client_ref for a retry.
4. A transaction hash alone is NEVER proof of who paid: hashes are public the instant they are broadcast, so anyone
   watching `SHOP_WALLET` could otherwise submit a hash before the real buyer does and walk off with their gift card.
   Every claim (first time or a replay) must also carry a signature proving the caller controls the wallet that actually
   sent the USDC, verified BEFORE looking up or returning any order. See section 6: this is not optional.
5. Before ordering, `GET /v1/orders/{client_ref}`. If it is issued, return that order as a replay and stop, but only
   after rule 4's signature check passes: an already issued voucher is never handed to a caller who cannot sign as the
   payer either. Never order twice.
6. A voucher (code, PIN, redemption URL) travels only in the answer to a payment claim (`POST /api/verify-payment`), and
   only once the claim signature verifies. A repeat of the same payment, signed by the same wallet, returns the same
   voucher. The order page shows it once, right after payment, and keeps no copy; status routes never return it; and it
   is NEVER written to a server log. Log only allowlisted fields (client_ref, status, error code).
7. Only buyer safe fields leave the server: never the wholesale price, the float balance or the buyer reference. (A
   gift card's partner rate is public, the public catalogue publishes it, so catalogue routes may return it.)

## 5. Verifying the payment on Base (`lib/rpc.ts`)

`getUsdcTransferToShop(txHash: string, minUsdc: number): Promise<{ok:true, amountUsd:number, confirmed:boolean, from:string|null} | {ok:false, reason:string}>`,
using two plain JSON RPC POSTs to `BASE_RPC_URL`:

- `eth_chainId` must answer `"0x2105"` (Base mainnet), else `wrong_chain`.
- `eth_getTransactionReceipt` with `[hash]`: `null` means `not_found_yet` (the buyer retries in a few seconds); a `status`
  other than `"0x1"` means `reverted`.
- Add up every log whose `address` (lower case) is `0x833589fcd6edb6e08f4c7c32d4f71b54bda02913` (USDC on Base), whose
  `topics[0]` is `0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef` (ERC20 Transfer) and whose `topics[2]`
  (the recipient: the last 40 hex characters of the 32 byte topic) equals `SHOP_WALLET` (compare lower case). Each amount is
  `BigInt(log.data)` with six decimals; `topics[1]` (the sender, same decoding) is `from`: the ONLY wallet allowed to
  claim this payment (section 6).
- Nothing to the shop gives `no_usdc_to_shop`; a total below `BigInt(Math.round(minUsdc * 1e6))` gives `amount_too_small`.
  Compare integers, never floats.
- Do not wait for extra confirmations (a receipt with success status counts as paid). Say so in a comment: a production
  shop should wait for more.

## 6. Proving who paid: the claim signature (`lib/claim.ts`)

This is the step that stops front running. Anyone can see a transaction hash and the amount it paid on a public chain;
only the wallet that actually signed and sent that transaction can produce a valid signature as it. A claim is accepted
only when both are true: the on chain payment itself (section 5), AND a signature over a fixed message by exactly the
wallet that sent it (`from` above). Build this exactly:

- `buildClaimMessage({domain, txHash, brandSlug, currency, faceMinor})` returns one deterministic, human readable
  string naming all four (normalise `txHash` with the same function as `client_ref`, uppercase `currency`, the amount
  as `(faceMinor/100).toFixed(2)`), for example:
  ```
  plusmore gift card shop: claim a gift card

  Shop: my-shop.example.com
  Payment transaction: 0xabc...
  Gift card: adidas
  Amount: 25.00 GBP

  Signing proves you control the wallet that sent this payment. It does not send any funds and costs no gas.
  ```
  Plain text only, never an EIP-712 typed message or anything that could be confused with a transaction: signing it
  must never be able to cost the signer anything. `domain` is `NEXT_PUBLIC_SHOP_DOMAIN` when set, else the incoming
  request's own host (`new URL(request.url).host`); build it identically wherever you need it, from the SAME request,
  so the route that hands out the message and the route that verifies a signature of it can never disagree.
- `POST /api/claim-message` `{slug, currency, faceMinor, txHash}` (LIVE only) returns `{message}` from the function
  above. No chain call, no partner API call: pure string building, so it reveals nothing about whether an order exists.
- `verifyClaimSignature({address, message, signature, rpcUrl}): Promise<boolean>` uses viem:
  `createPublicClient({transport: http(rpcUrl)}).verifyMessage({address, message, signature})`. This one call covers a
  plain EOA (pure ECDSA recovery, no RPC round trip needed), a deployed smart contract wallet (ERC-1271
  `isValidSignature`) and a counterfactual, not yet deployed one (ERC-6492), because that is what viem's
  `verifyMessage` already does; do not hand roll any of those three paths yourself. False (never throw) on a malformed
  signature or an RPC failure: a payment is never claimed on an unproven signature.
- `POST /api/verify-payment` now takes `{slug, currency, faceMinor, txHash, signature, buyerRef?}`. Order of checks,
  every one of them before any order lookup or creation (rule 4 above): price now, the on chain payment
  (section 5, gives `from`), THEN `verifyClaimSignature({address: from, message: buildClaimMessage(...the same four
  fields from this request...), signature, rpcUrl: BASE_RPC_URL})`. Not verified: `401 {"error":"...","code":
  "signature_mismatch","paid":true,"retryable":true}` (the USDC did arrive, so say so; the wallet just has not proven
  it yet) and stop, before even checking whether an order already exists. Only once it verifies does section 4 rule 5
  (the replay lookup) run.
- A signature is at least a 65 byte ECDSA signature (130 hex characters after `0x`); an ERC-6492 wrapped one is longer.
  Validate the shape (`/^0x[0-9a-fA-F]{130,8192}$/`) before ever calling `verifyMessage` with it.
- Checkout UI: once the buyer has pasted a transaction hash, fetch the claim message, then try an injected wallet
  (`window.ethereum`, EIP-1193, no library needed on the client: `eth_requestAccounts` then `personal_sign` with
  `[message, account]`). If there is no injected wallet, or it fails, show the message (with a copy button) and a field
  to paste a signature produced another way. The submit button is disabled until a signature is attached. Never submit
  `verify-payment` without one.

## 7. The purchase flow

- Price: `usdFaceValue(faceMinor, currency, rates)` is `faceMinor / 100 * rates[currency]` rounded half up to the cent
  (USD is always 1). That is the USDC amount the buyer sends, shown as a plain decimal such as `33.13`.
- Brand page `/brand/[slug]`: every value of `all_denominations` as a button (orderable ones link to
  `/checkout/{slug}?amount={faceMinor}&currency={CUR}`, the rest disabled), plus a free amount input for range brands
  (min to max on the increment, capped by `max_denom_today`). In DEMO mode every buy button is disabled with the text
  "Selling is switched off in DEMO mode. Production access to the plusmore RaaS API is by approval."
- `POST /api/check` `{slug, currency, faceMinor}` answers `{ok, code, message, retryAfterSec, priceUsd}` (`priceUsd` such
  as `"33.13"`), never the wholesale or float values.
- Checkout `/checkout/[slug]`: the page calls `POST /api/check`. If ok, it shows "send exactly {priceUsd} USDC on Base",
  the shop wallet with a copy button, a warning that the wrong amount, token or network is not refunded automatically, a
  form for the transaction hash, and the sign step from section 6 once the hash looks valid.
- `POST /api/verify-payment` as specified in section 6: validate; client_ref from the hash; rule 5 of section 4 (a
  `creating` order answers `409 order_in_progress`, only reached after the signature verifies); the payment must cover
  the whole current price (no tolerance, or anyone could pay a little less on purpose; rates move about once a day, and
  a buyer caught by an update is refused with both amounts shown); run the check again (if it refuses now, tell the
  buyer the payment is safe and to retry later with the same hash and a fresh signature); `POST /v1/orders` with
  `wallet_ref` = buyerRef or the payer address and `expected_wholesale_minor` from the check (on `price_changed`, retry
  once with the new `wholesale_minor`). Answer `201 {ok:true, replay:false, order}`, or `200` with `replay:true`, where
  `order` is `{clientRef, status, issued, brandSlug, currency, faceMinor, createdAt, failureCode, voucher}` and
  `voucher` is the API's `{code, pin, url, expires_at, redemption_details}`. Errors are
  `{error, code, paid, retryable, clientRef}` with buyer friendly messages.
- Order page `/order/[clientRef]`: shows the voucher handed over from checkout (sessionStorage), then deletes it from
  storage. Otherwise it shows the status from `GET /api/orders/[clientRef]` (`{order}` as above but never the voucher; an
  admin session also sees the cost, the partner rate and the buyer reference when the API returns one) and, after a failed retryable attempt, a
  "Try again" button that resubmits the SAME transaction hash (and signs the message again: a stored signature is never reused past that one submission).
- Admin `/admin`: `ADMIN_PASSWORD` (empty switches the page off), compared in constant time; the session is an HttpOnly,
  SameSite=Strict, HMAC signed cookie that expires. Shows the mode, configuration problems, the float from `GET /v1/float`,
  the deposit address (or "it comes with your production access" when the env is empty) and an order lookup by client_ref
  (status only).
- Routes: `GET /api/brands?q=&country=&currency=&page=` (`{mode, brands, page, perPage, total, notice}`),
  `GET /api/brands/[slug]` (`{mode, brand}`), `POST /api/check`, `POST /api/claim-message`, `POST /api/verify-payment`,
  `GET /api/orders/[clientRef]`, `POST /api/admin/login` (`{password}`, sets the cookie), `POST /api/admin/logout`,
  `GET /api/admin/float` (`{float}`, the `/v1/float` data; 401 without the session). In DEMO mode `check`, `claim-message`,
  `verify-payment` and `orders` answer `400 {"code":"DEMO_MODE","error":"...production access to the plusmore RaaS API is by approval..."}`.
- The home page shows a DEMO or LIVE banner in plain English, search, a country filter (a select built from the cached
  catalogue in DEMO mode; a two letter text input in LIVE mode, where the API has no country list), a currency filter
  (from `/v1/fx` in LIVE mode) and an availability badge per gift card.
- Counts: the banner states the one count from section 1, "N gift card brands in M countries", read from the summary
  (none at all when the built in sample is shown). The line above the grid says "Showing 1 to 48 of N gift card brands"
  only when the list pages through exactly that count (LIVE mode); otherwise "Showing 1 to 48". A search or a filter
  says "Showing 1 to 12 of 12 matches".
- In DEMO mode only, a small "Make it your brand" panel under the banner lists the five steps of section 10. It is
  never shown in LIVE mode.

## 8. One Brand type for both modes (`lib/types.ts`)

```ts
type Availability = { state: "available" | "limited" | "unavailable"; floatLight: "green" | "amber" | "red" | "off"; maxDenomToday: number | null; message?: string };
type Brand = {
  slug: string; brandKey: string; name: string; description?: string; currency: string; countries: string[];
  categories: string[]; logoUrl: string | null; cardUrl: string | null;
  denominations: number[]; allDenominations: number[]; range: { min: number; max: number; increment: number } | null; // minor units
  netRateBps: number; cashEquivalent: boolean; availability: Availability; usdPerUnit: number | null; source: "demo" | "live";
};
```

## 9. Mock and tests

- `mock/server.mjs`: a dependency free `node:http` fake of the `/v1` routes above, port 4100 (env `PORT` overrides), run
  with `npm run mock`: 8 to 12 fictional brands in
  a few currencies (some with a range, one unavailable, one cash equivalent), the same units and error envelope as the
  real API, idempotent orders by client_ref, an in memory float, and `X-Supplier-Key` checked against `MOCK_KEY` (default
  `mock-key`). On the same server: `POST /rpc` (a Base JSON RPC stub, `mock/rpc.mjs`, answering `eth_chainId`,
  `eth_getTransactionReceipt` from seeded receipts, and `eth_call`/`eth_getCode`, a stand in for a real chain's
  ERC-1271/ERC-6492 contract call so a test can prove a smart contract wallet signature verifies, toggled with
  `setEthCallAnswer(true|false)`), `POST /mock/seed-receipt {txHash, toAddress, amountUsdc, status, fromAddress}`
  (builds a receipt with a real looking USDC Transfer log, sender included) and `GET /mock/state`
  (`{orders, issued, balance_usd6}`). Pre seed a few demo payments to the shop wallet
  `0x1111111111111111111111111111111111111111` and document them in `mock/README.md`, each one also naming the fake
  payer wallet so a tester can actually sign the claim message for it.
- Vitest: unit tests for the pricing helpers, client_ref, both Brand mappings (same field names and types), the payment
  check against the RPC stub, AND the claim signature: `buildClaimMessage` is deterministic and changes with any of its
  four fields; a right signer's signature verifies and a different wallet's does not, for a plain local test account
  (no RPC call needed, pure ECDSA); a smart contract wallet's signature verifies against the stubbed `eth_call`
  (`setEthCallAnswer(true)`) and is refused when the stub says no (`setEthCallAnswer(false)`). End to end tests that
  start the app (`next dev` in its own build folder, every env var set explicitly) in DEMO mode against a recorded
  catalogue and in LIVE mode against the mock, walking every item of the checklist below (claiming a payment always
  fetches the message and signs it with the correct test wallet first), including: a wallet that never sent the
  payment cannot claim it, whether tried before or after the real payer does, gets no order created and no voucher
  back either time; and capturing the server output and asserting that no voucher value appears in it.
  Tip: when the tests give `next dev` its own `distDir`, list `<distDir>/types/**/*.ts` in the tsconfig `include` so
  Next.js does not rewrite `tsconfig.json` during the test run.
- `README.md`: DEMO quick start, LIVE mode against the mock, going live, and these known limitations: every USDC
  transfer into `SHOP_WALLET` can be claimed once, by the wallet that sent it (so use a wallet that receives only buyer
  payments), payments are not held for extra confirmations, and refunds are manual.

## 10. Copy and style

Call the API "the plusmore RaaS API". Production access is by approval and the partner agreement is with plusmore: never name plusmore's own
upstream supplier, and do not claim the shop buys from the brands themselves or that nothing sits in between, it buys
through the plusmore RaaS API. No em dashes or en dashes and no emoji in any visible text. Unbranded by default, plain
neutral styling: the shop's own identity comes only from the branding settings below (gift card art from the catalogue
is fine). Plain English everywhere, including error messages.

Make it your brand. Put the colours in CSS variables at the top of `app/globals.css`: `--accent` (the brand colour on
buttons and links) and `--accent-text` (the text on it), set once in `:root` for light mode and once
in a `prefers-color-scheme: dark` block for dark mode. The README carries these steps, and so does the DEMO panel:

1. Name: set `NEXT_PUBLIC_SHOP_NAME`. It shows in the header and in every page title.
2. Logo: set `NEXT_PUBLIC_SHOP_LOGO_URL` to an https image, or to a file in `public/` such as `/logo.svg`. Without one,
   the header shows the name.
3. Colours: change `--accent` and `--accent-text` in `app/globals.css`, in `:root` for light mode and in the dark mode
   block.
4. Domain: point your domain at the shop and set `NEXT_PUBLIC_SHOP_DOMAIN` to it.
5. Restart or redeploy: the shop reads these settings from its environment when it serves a page.

## 11. Acceptance checklist

1. With no `PLUSMORE_API_KEY` set, the home page loads and lists real or fixture gift card brands with no server error (DEMO mode).
2. In DEMO mode, a brand page shows denominations but the buy action is disabled and says selling is switched off in DEMO mode.
3. With `PLUSMORE_API_BASE`/`PLUSMORE_API_KEY` pointed at the mock, the home page lists the mock's brands (LIVE mode).
4. `POST /api/check` for a sellable mock brand/amount returns ok.
5. Paying (a tx hash the mock RPC confirms as a sufficient USDC transfer to `SHOP_WALLET`) and signing the claim message with that SAME wallet creates exactly one order and reveals a voucher.
6. Submitting the SAME tx hash and a fresh signature from the SAME wallet again never creates a second order (the same voucher comes back); a signature from a DIFFERENT wallet for that tx hash is refused, before the order exists and after it already does.
7. A tx hash that does not pay enough, pays the wrong address, or carries a signature from a wallet that did not send the payment, is refused with no order created.
8. The admin page refuses without the right password and shows the float balance with the right password.
9. No server log line ever contains a voucher code, pin, or redemption URL.
10. `npm run build` and `npm test` both succeed from a clean `npm install` with no other setup.

Build until every item on this checklist is true; do not consider this done otherwise.
