# Changelog

All notable changes to `@gamecore-api/sdk` are documented here. This project
follows [Semantic Versioning](https://semver.org/) with the 0.x pre-1.0
relaxation: minor bumps MAY contain breaking changes until 1.0.0.

## 0.71.0 — 2026-08-22

Type-only release for per-site catalog pages (MGD). No runtime code changed —
no method gained, lost or altered an argument.

A tenant may now publish a game under ITS OWN url
(`site_catalog_pages`, `intent: "main"`). The API answers on both urls, and
this release describes the two things a storefront must keep straight:

> **href = `siteSlug ?? slug`. echo/write = `slug`.**

Every `…SiteSlug` field below is an ADDRESS you may link to and 🔴 **never an
identity or a write value** — checkout, cart, analytics, coupons, reviews and
favourites all resolve games by the platform `slug`, and posting a per-site
slug back sends an identifier no service can resolve. (The public GET catalog
routes DO accept a per-site slug in the PATH — serving those urls is the whole
point of the feature. The rule is about what you send back, not about what you
can fetch.) Every one of them is **absent, never `null`**, both for tenants
with no per-site pages (i.e. all of them today) and whenever the per-site slug
would equal the platform one.

⚠ None of these are related to `sites.slug` (the tenant's own identifier). The
name collides; the meaning does not — these are slugs OF A GAME, chosen BY a
site.

### Added

- `GameDetail.page` — optional `{ slug, intent }` on `gc.catalog.getGame()`
  responses; identifies which per-site catalog page answered the request
  (`intent: "main"` for the game's own page, any other intent for a dedicated
  intent page). The key is **absent**, not `null`, for tenants that have no
  `site_catalog_pages` rows — i.e. every tenant today gets a byte-identical
  0.70 response body. Do not test with `'page' in data`; test the value.

- `Game.siteSlug` — on every listing row: `getGames()` (both server code
  paths), `getHomepageGames()`, `getRecommendations()` and `search().games`.

- `GameDetail.siteSlug` — on `getGame()`. Independent of `page`: `page` says
  WHICH per-site page answered this request (an intent page included), while
  `siteSlug` is the game's one per-site ADDRESS. On an intent-page request both
  are present and they differ on purpose.

- `GameGroup.primarySiteSlug` and `GameVariant.siteSlug` — the variant selector
  is the one place a response names OTHER games' urls, so each member carries
  its own. Link with `siteSlug ?? slug` or switching denomination walks the
  buyer out of the tenant's url space.

- `Product.gameSiteSlug` — on `getProduct(id)` and the SEO product view (the
  two responses that carry a canonical `gameSlug`). `gameId` / `gameSlug` stay
  the platform identity: `gameId` is what checkout stamps on an order.

- `getPromos()[].targetGameSiteSlug` — twin of `targetGameSlug`.

- `CmsArticleSummary.entitySiteSlug` / `CmsArticle.entitySiteSlug` — for the
  «к игре» link. Always absent for `entityKind: "superpass"`: superpass ids are
  a different id space and are deliberately untouched.

- All eight twins are declared `?: string` — optional, NOT `| null`. The server
  omits the key; a nullable declaration would force a guard for a value the
  wire never carries.

### Changed semantics (no new names — read before upgrading)

- 🔴 `GameDetail.canonicalSlug` may now be **ABSENT where 0.70 sent a value**,
  and may name a PER-SITE slug.

  Before: a request that hit a per-site page got that page's own slug echoed
  back here. `if (canonicalSlug) redirect('/games/' + canonicalSlug)` therefore
  redirected the buyer to the url he was already on — an infinite loop on
  exactly the urls per-site pages exist to serve. Now the field is emitted only
  when the target differs from the requested segment, and for a tenant that
  renamed the game it names the tenant's url (one hop, including from an alias
  or a case variant).

  MIGRATION: the loop is fixed server-side, so an un-guarded
  `if (canonicalSlug) redirect(...)` no longer hangs — but the BEHAVIOUR
  changed and two shapes need a look. (1) Code that compared
  `canonicalSlug !== requestedSegment` before redirecting keeps working
  unchanged. (2) Code that treated `canonicalSlug` as "the platform slug of
  this game" (for analytics keys, cache keys, or as a value to POST) must move
  to `GameDetail.slug`, which is still the platform identity. Route on
  `canonicalSlug`; identify on `slug`.

- 🔴 URL-shaped STRINGS now carry the per-site slug on tenants with
  `site_catalog_pages` rows. Same fields, same types, different content:

  - `Product.canonicalUrl` (both product-detail responses);
  - `getPromos()[].targetUrl` for `targetType: "game"`;
  - `SeoSitemapEntry.slug` from `gc.site.getSitemapData()`, plus that feed's
    hreflang hrefs and the whole of `/seo/sitemap.xml`;
  - the `url` and `productGroupID` of the game JSON-LD (`/seo/schema/game/:id`),
    which are derived from ONE value and stay equal to each other.

  MIGRATION: keep emitting them verbatim — that is what they are for. What
  breaks is REVERSING them: a storefront that parsed a slug out of
  `canonicalUrl` / `targetUrl` / the sitemap feed and used it as a game
  identifier (cart, analytics, a `getGame()` call it then POSTs from) must read
  the identity from the sibling field instead — `gameSlug`, `targetGameSlug`,
  or `Game.slug`. For a tenant without per-site pages nothing changes at all.

### Docs

- `gc.catalog.getSitemapRoutes()` — documented that a tenant with per-site
  catalog pages can make one game contribute several feed entries (main +
  intent pages), so a page of the feed can exceed `limit` entries while
  `total` keeps counting games.

- Non-main INTENT pages stay OUT of the SEO feeds (`/seo/sitemap.xml`,
  `getSitemapData()`) in this release: only `intent: "main"` pages are
  addresses. They remain in the routing feed (`getSitemapRoutes()`).

## 0.70.0 — 2026-08-21

Persian (`fa`) becomes a recognised locale, and the sitemap feed finally gets
the locale argument its server handler has been asking for. Both changes are
purely additive — no existing call changes shape or behaviour.

### Added

- `SdkLocale` widened from `"ru" | "en" | "es" | "pt-br"` to include `"fa"`
  (Persian, RTL). `CmsArticleLocale` widened the same way.

  🔴 **What `fa` does NOT get you yet.** This release only makes the API
  *recognise* the code — every locale resolver now returns `"fa"` instead of
  discarding it. No tenant lists `fa` in `sites.additional_locales` and no
  `catalog_translations` / `seo_content` / CMS rows exist in Persian, so a
  client created with `locale: "fa"` receives the API's untranslated
  fallbacks. Several platform label tables (payment-method captions, delivery
  estimates, the regional "Other" tab, order/refund notifications) currently
  answer `fa` with **English interim copy** pending a Persian copy pass. That
  is intentional: without an `fa` row those tables fall back to **Russian**,
  not English.

- `gc.catalog.getSitemapRoutes({ locale })` — new optional argument, forwarded
  as `?locale=`.

  🔴 **Opt-in only; it is NOT wired to the client's `defaultLocale`, on
  purpose.** This endpoint is a machine feed behind a proxy cache whose key
  omits `Accept-Language`, so the server ignores that header and pins `"ru"`
  when no `?locale=` is present. `"ru"` is the *legacy-effective* locale of
  every tenant's feed today — including the tenants whose `default_locale` is
  something else (ashop-games `en`, giftcardi `en`, kazakevich `uk`). Had the
  parameter defaulted to `defaultLocale`, upgrading the SDK would have
  silently re-languaged those three tenants' entire sitemap and product feeds.
  Passing nothing is byte-identical to 0.69.0.

### Known gap (pre-existing, unchanged here)

- `SdkLocale` still omits `"uk"`, even though the API treats Ukrainian as a
  first-class locale and the kazakevich tenant's `default_locale` is `uk`.
  A `uk` storefront needs a cast to set it. Widening would be type-only, but
  it is a separate decision about a live tenant and was left out of this
  change deliberately.

## 0.69.0 — 2026-08-20

Multilingual CMS guides. The type widening is additive, but it ships alongside
a server-side behaviour change on `getArticle()` that a storefront can feel —
read the second section.

### Added

- `CmsArticleLocale` widened from `"ru" | "en"` to
  `"ru" | "en" | "es" | "pt-br"`. Accepted by `gc.site.getArticles()` and
  `gc.site.getArticle()`; returned on `CmsArticleSummary.locale` /
  `CmsArticle.locale` as before. Unchanged for callers that only ever pass
  `"ru"`/`"en"`.

  An unrecognised `?locale=` (or none) still resolves to RU server-side rather
  than erroring — deliberately preserved, so a storefront passing a regional
  tag we don't stock keeps getting the Russian article instead of a 400.

### Changed (server behaviour — no SDK API change)

- 🔴 `gc.site.getArticle(type, slug, { locale })` no longer falls back to the
  RU article when the requested translation is missing: it throws
  `GameCoreError(404)` instead. Previously it returned the RU row with
  `article.locale === "ru"`, and callers were told to inspect that field to
  detect fallback.

  WHY: the fallback published one body under two canonical URLs
  (`/guides/foo` and `/en/guides/foo` served identical Russian text), which is
  duplicate content, and it made any hreflang cluster built from these URLs
  assert an EN alternate that does not exist. A missing translation now reads
  as missing so the storefront can omit it from hreflang and the sitemap.

  MIGRATION: a storefront that relied on "always renders something" must
  either request `"ru"` explicitly for its default-language surface, or catch
  the 404 and treat it as "not translated yet". `article.locale` is now always
  the locale you asked for — there is no fallback left to detect, so code
  branching on `article.locale !== requested` is dead and can go.

- Backend: the CMS entity-unique index now keys on locale too (migration
  0276), so one game can carry one guide PER LANGUAGE. No wire-format change.

## 0.68.0 — 2026-08-20

The checkout breakdown can now tell the buyer WHEN the bonus rubles he is about
to spend expire — the one moment he is most likely to act on it. Purely
additive: one nullable, display-only field on an existing response.

### Added

- `CheckoutPreview.bonusExpiringSoon` — `{ amount, expiresAt } | null`: the
  NEAREST expiry date within 7 days (the SAME window the coins wallet's
  `expiringSoon` uses) among the bonus rubles this cart spends, and how much of
  `bonusApplied` dies ON THAT DATE. Render the pair as one sentence: «из них N ₽
  сгорят {дата}».

  ⚠ `amount` is NOT a week's total. With 10 ₽ dying Friday and 15 ₽ Sunday this
  answers `{amount: 10, expiresAt: Friday}` — the 15 ₽ is a different deadline
  and is left out on purpose, because a number attached to a date must be true
  of that date.

  ⚠ `amount` is also a SLICE of `bonusApplied`, not an addition to it — the two
  describe the same rubles, so summing them double-counts the buyer's money. It
  is not the wallet's expiring balance either: lots this cart does not spend are
  excluded, because the line sits next to this cart's own number.

  `expiresAt` is an ISO-8601 string (not a `Date` — it crosses JSON). The field
  is typed OPTIONAL because an API deployed before 2026-08-20 omits the key;
  `undefined` and `null` mean the same thing: draw nothing. The seven money/echo
  fields stay required and byte-identical — no money math changed.

## 0.67.0 — 2026-08-17

The buyer can be told what their bonus rubles are actually worth on THIS cart
before they press Pay. Bonus rubles are spend-capped per order, so a wallet
showing 279.59 ₽ may only be able to pay 36.58 ₽ of a 289.59 ₽ order — and until
now that number reached the buyer only inside the 402 that refused their payment.
Purely additive; every existing call form is byte-identical on the wire.

### Added

- `checkout.preview(items, opts?)` → `POST /checkout/preview`, resolving
  `CheckoutPreview`: `total`, `spendable`, `bonusApplied`,
  `permanentApplied`, `shortfall`, `shortfallAmount`, `useBonus`. Authenticated
  buyers only — a guest gets 401, which THROWS `GameCoreError` (status 401, code
  `"UNAUTHORIZED"`; the SDK's 401 short-circuit never reads the body, so the
  server's `auth_required` never reaches you) and fires `onAuthError`.

  Display-only, and **fail-open is the caller's job**: on any error render
  today's UI (the raw wallet), leave Pay enabled and let the 402 refuse. The
  endpoint writes nothing and has its own server-side rate-limit bucket, so
  re-running it on every cart/checkbox change cannot spend a token the Pay button
  needs — but debounce it anyway: the ~30/min per-account budget is keyed by
  (site, buyer) with NO cart in the key, so re-asking the SAME cart costs a token
  exactly like a new one.

  ⚠ `shortfallAmount` is `0` on an affordable cart. Its same-named twin on the
  402 (`InsufficientBalanceDetails.shortfallAmount`) is never below 1. Same name,
  different floor, on purpose: a refusal always needs a real top-up, a preview
  must be able to say "nothing to top up". Do not merge the two shapes.

- `CheckoutItemInput` — the cart-line shape, extracted from the inline
  `CheckoutRequest.items` array so `preview` and `create` quote and charge the
  same lines. Structurally identical to what `items` always was; existing
  literals are unaffected.

- `CheckoutCreateOptions` on `checkout.create` as the second argument. The
  legacy `create(data, "key")` form still works and is exactly
  `create(data, { idempotencyKey: "key" })`.
  - `useBonus` — the «Использовать бонусы» checkbox. Sent in the body only when
    you set it (absent = `true` server-side). The server persists it ON the
    payment, so `completeWithBalance()` deducts under the same flag; do not send
    it there. Pass the `useBonus` a preview ECHOED, not the checkbox state you
    last rendered.
  - `idempotencyKey` — replaces the SDK's per-call random `X-Idempotency-Key`.
    ⚠ The server gives a client-sent key the FULL 24h replay window and only
    derives its own short-lived key when no header arrives; because this SDK has
    always sent a random UUID, server-side dedup has effectively been off for SDK
    callers. Automated re-submits (a top-up chain auto-completing an order) MUST
    pass a key derived from the thing that must happen once — `"chain:" +
    topupCode` — so the repeat replays the first payment instead of minting a
    second. An empty string counts as "not given".

- `guestAccount?: boolean` on `CheckoutStatus` and at the ROOT of the
  `orders.getByPayment` envelope (a sibling of `success`/`data`, not inside
  `data` — that endpoint returns the raw body). Landed on `main` without a
  version bump, so 0.67.0 is the first release carrying it. Tri-state, and the
  last two differ: `true` = an unclaimed auto-provisioned guest shell (the only
  state that may render the «мы создали аккаунт» notice), `false` = definitively
  not one (registered/claimed/legacy/no single buyer — the server looked and
  answered), key ABSENT = the server could not determine it and failed open.
  Gate rendering on `=== true`, never on `!== false` — the latter shows the
  notice to a registered buyer on any lookup blip.

- `TopupCreateOptions` as the third argument of `topup.create(amount, method,
  opts?)`. `idempotencyKey` sends `X-Idempotency-Key`, which the route has always
  honoured. Omit it and the server derives a short-TTL key from
  user+amount+method — which collapses a double-click, but also collapses a
  deliberate repeat of the same amount minutes later. So the chain passes a
  FRESH unique key per deliberate «Доплатить»: the exact opposite of checkout's
  stable key, for the exact opposite reason (never report old money as new).

## 0.66.0 — 2026-08-15

One-click reorder: a buyer whose item FAILED and whose money already came back
can buy the same product again in one tap — a new single-item order at today's
price, paid from balance. Purely additive; every existing field keeps its
shape. The surface is dark until the API deployment enables it, and the item
flags below are the gate: with the feature off they are all `false`/`null`, so
a storefront that renders on them shows nothing new until launch day.

### Added

- `orders.reorderItem(orderCode, itemId, opts?)` →
  `POST /orders/{code}/items/{itemId}/reorder`. `opts.deliveryData` is the
  buyer's CORRECTION (e.g. a fixed `login` when
  `cancelReasonCode === "wrong_field"`); omit it for the plain "try again"
  case — an absent value re-uses the source item's data verbatim, while an
  explicit `{}` is submitted and validated like any other input.

  Resolves with the `ReorderItemResult` union: 201 success (envelope
  intact — `success` is what tells it apart from the unenveloped refusals),
  409 `not_eligible` + a frozen `reason`, 422 `validation_error` + `field`,
  503 `reorder_unavailable` (retryable). Branch on the body in that order —
  `success` → `code` → `error` — never on an HTTP code you cannot see.

  ⚠ **402 insufficient balance THROWS** — read it with the
  `getInsufficientBalanceDetails(err)` you already call on checkout's 402.
  The refusal body is built by the one shared server-side builder for every
  balance rail, so this method deliberately does not offer a second way to
  read it. 404 (feature off / no such item — one deliberately uninformative
  answer), 400 (bad item id) and 429 (5/min anti-abuse) throw as well.

  Double-tap safe without an idempotency key: the server decides concurrent
  duplicates inside the insert, so the loser reads back 409
  `already_reordered` carrying the winner's `reorder_order_code` — never a
  second charge.

- `OrderItem.reorderEligible?: boolean` — may this failed item be bought again
  RIGHT NOW? Server-decided by the same eligibility core the endpoint runs, so
  the button and the answer cannot disagree; never re-derive it client-side.
  Render no button on `false`. Deliberately not a synonym for
  `cancelReasonCode`: that says the failure is of a fixable KIND, this says
  the buyer may act on it now (money back, product still sellable, not already
  retried).

- `OrderItem.reorderedAsOrderCode?: string | null` — the order that already
  reordered this item, or `null`. Present regardless of `reorderEligible` (an
  already-reordered item is ineligible BECAUSE of this value), so use it to
  replace the button with a link to the replacement order.

- `OrderItem.reorderCurrentPrice?: number | null` — the CURRENT per-unit price
  in RUB, i.e. what the reorder will actually charge. Not `price`, which is
  frozen from the original purchase and predates any rate move, markup edit or
  promo since. RAW, not ceiled: apply your own display rounding. `null` when
  the item is not eligible or the price is unavailable — never rendered as 0.

  All three are `undefined` on an API deployment that predates the feature;
  treat that exactly like the all-off default.

- `ReorderItemResult`, `ReorderCreated`, `ReorderIneligibleReason` — the
  response union, its 201 payload and the frozen 409 dictionary
  (`item_not_failed` / `not_fixable` / `refund_not_settled` /
  `already_reordered` / `product_unavailable`). Key your i18n on those exact
  strings; the server never re-words them.

  ⚠ `ReorderCreated` is `snake_case` (`order_code`, `order_id`,
  `charged_amount`, `balance_after`, `source`) — the wire verbatim, not a
  slip. This endpoint speaks snake_case where most of the API speaks camelCase
  and the SDK does not rename fields: a client reading `orderCode` off it gets
  `undefined` and nothing fails loudly. The keys INSIDE `balance_after`
  (`total` / `permanent` / `bonus`) are camelCase, also verbatim.

## 0.65.1 — 2026-08-14

Vendoria form-instruction images reach the storefront (ITQ-58). The supplier
embeds screenshots in its Markdown instructions as private storage keys; the
API now resolves and mirrors them at sync and serves public URLs. Purely
additive.

### Added

- `Product.instructionImages?: string[] | null` — public URLs of the images
  embedded in the supplier's raw purchase instruction, on `getProducts()`,
  `getProduct()` and every variant of `getProductsGrouped()`
  (`regions[].variants[].instructionImages`). Render alongside
  `instruction`: for ~half of the affected SKUs the picture is the WHOLE
  instruction, so `instruction` can be null while this field carries
  content. `null` = no images embedded; `undefined` = the API deployment
  predates this field. Both render nothing.

## 0.65.0 — 2026-08-14

- Regional fold: documented the synthetic `-1` category id / `regional-other`
  slug emitted for folded products when a site enables the fold
  (`Product.categoryId`, `Category.id` doc comments). Additive; no behavior
  change for existing consumers. (`Product.serverRegion` was already typed.)

## 0.64.0 — 2026-08-07

A public review now says WHAT was bought: every review row on the public
surfaces carries the purchased order lines, so the storefront can render
"Music Emote Pack 1 ×2" under the game name. Purely additive — every existing
field keeps its shape.

### Added

- `Review.items?: { name: string; amount: number | null }[]` — the purchased
  lines of the order behind the review, on `reviews.listPublic()` (including
  the per-game and product-scoped variants) and `reviews.getRandom()`. Only
  the product name and its amount are exposed — no prices, no supplier,
  nothing about the buyer. `[]` is a valid state meaning "items unknown" (a
  legacy order with no item rows, or a Telegram-imported review with no order
  at all); `undefined` means the API deployment predates this field. Both
  render nothing — no empty block, no placeholder. `reviews.getMine()` and
  the admin listing deliberately do not carry the field.

  ⚠ `amount: null` means "this amount is money, not a count — render the name
  alone". The column holds a USD sum (not a quantity) for wallet-top-up
  products, one prod row is a whole number, and no client-side integer
  heuristic can tell it from a real count — so the API classifies by the
  product's own `amount_type` and nulls the money case server-side. (This
  reshapes the pre-publication `amount: number` form of this same entry; the
  version never reached npm, so no consumer ever saw the old type.) For
  non-null values, show a `×N` multiplier only when the value is an integer
  greater than 1 (`Number.isInteger(amount) && amount > 1`); otherwise show
  the name alone — a rule a `null` passes through safely.

## 0.63.0 — 2026-08-05

The buyer can now submit a code from the order page itself. The order GETs
already told a client that a code is being asked for (`codeAffordance:
"enter_code"`); they now also carry the two ids needed to answer it, so the
storefront no longer has to bounce the buyer into the conversation view to
find them. Purely additive — every existing field keeps its shape.

### Added

- `OrderItem.codeRequest?: { requestId, conversationId } | null` — the pair
  `profile.submitCode(conversationId, requestId, code)` takes, on the customer
  order GETs (`/orders`, `/orders/:code`, `/orders/payment/:code`,
  `/profile/orders`).

  **Populated ONLY when `codeAffordance === "enter_code"`**, i.e. exactly when
  the buyer is being shown the input field. Every other state sends `null`
  even when an open request row exists underneath — a request left open on a
  finished or cancelled order must not be submittable, so the server keys the
  ids off the COMPUTED affordance rather than off "a row is open". Do not
  reconstruct them from anywhere else, do not cache them across a refresh, and
  re-read them from the item whenever the affordance changes.

  `undefined` (key absent) means the API deployment predates this field —
  treat it exactly like `null` and fall back to the conversation view. The key
  is always present, and always `null` rather than omitted, on a deployment
  that has it, so the two cases are distinguishable.

## 0.62.0 — 2026-08-04

A review can now carry two extra, optional answers — how fast delivery was and
how support did — and a buyer with no account can leave one from an emailed
link. Purely additive: the positional `reviews.create(orderId, rating, text)`
builds the identical request body it always did, so nothing an existing client
does changes.

### Added

- `Review.deliveryRating` / `Review.supportRating` — optional 1–5 ratings on
  every review payload. Both are `number | null | undefined`, and all three
  states are real: `null` is "no value for this row" — either the buyer
  skipped the question (the column is nullable) or the row is a
  Telegram-imported review, which carries an overall rating only and is
  serialized with an EXPLICIT `null` for both dimensions rather than omitting
  them; `undefined` means the field is absent from the payload altogether,
  i.e. "this API predates 0.62.0". Render a dimension ONLY when the value is
  present; do not substitute 0, and do not feed the value into star arithmetic
  without a presence check, because `null <= 5` is `true`.

- `ReviewStats.deliveryAverage` / `deliveryCount` / `supportAverage` /
  `supportCount` — aggregates over PUBLISHED reviews that carry that
  dimension. The counts are ≤ `totalCount` and the averages are computed over
  a different population than `averageRating`, so never derive one from the
  other and never show a dimension average as "the shop's rating". The
  averages are declared nullable, but today's API coerces an empty average to
  0 — decide with `count === 0`, never with `average === null`. `count: 0`
  means "not rated yet" — render neither number.

- `gc.reviews.create(orderId, { rating, deliveryRating?, supportRating?, text? })`
  — an options form alongside the unchanged positional signature. It is the
  only way to send the dimensions. A skipped dimension is omitted from the
  body rather than sent as `null`: the endpoint validates them as optional
  integers 1–5, so an explicit `null` is a 422, not "no answer".

- `ReviewCreateResult.deliveryRating` / `.supportRating` — the echo of what was
  stored, on the authenticated create response. `null` for a question the buyer
  skipped. Optional because this endpoint, unlike its guest twin, predates
  0.62.0 and an older deployment omits both keys.

- `gc.reviews.createGuest(token, { rating, deliveryRating?, supportRating?, text?, authorName? })`
  → `ReviewGuestCreateResult` — submit a review with no account, using the
  signed `rt=` token from the review-request email. The token IS the
  authorization (public endpoint, tenant-bound, 30-day TTL, single purpose);
  read it off the order page URL. Re-using a token after the order already has
  a review is harmless and answers 400 — one review per order, whoever leaves
  it.

  No bonus is granted, because there is no balance to credit. The result type
  is deliberately NOT `ReviewCreateResult`: its `bonus` is optional and
  null-only, so a UI that shows "бонус зачислен" off a truthy `bonus` cannot
  be made to lie by this call.

- `ReviewCreateOptions`, `ReviewGuestCreateOptions`, `ReviewGuestCreateResult`
  — exported so callers can type their own form state.

Requires gamecore-api 2026-08-04 or later. Against an older deployment the new
fields are simply absent and `createGuest` answers 404 — as it also does when
the `reviews` module is disabled for the site, so a 404 alone does not tell you
which of the two you are looking at.

## 0.61.0 — 2026-08-03

The buyer can now tell the shop, per delivered code, that he activated it or
that it does not work — both halves: the list the storefront renders, and the
call it writes a mark with. Purely additive: `cdKeys` keeps its exact shape and
the deprecated `cdKey` is untouched, so nothing an existing client does
changes.

### Added

- `OrderItem.keys?: Array<{ code, keyRef, state }>` — the AUTHORITATIVE per-key
  list, on the customer order GETs (`/orders`, `/orders/:code`,
  `/orders/payment/:code`, `/profile/orders`). `state` is `"activated"`,
  `"not_working"`, or `null` when the buyer has not marked that key. An EMPTY
  array on items that delivered nothing by key; optional on the type only
  because an older deployment omits the field, so treat `undefined` as "this
  API cannot mark keys" and `[]` as "nothing was delivered by key".

  Render from this list, not by parsing `cdKeys` yourself. The API owns the
  parse so a client cannot disagree with it about how many keys an item has or
  what order they are in — and that disagreement would not fail loudly, it
  would put a mark on the WRONG code. Two ways your own parser will disagree in
  practice: storefronts render from `OrderItem & {…}` with SSE deltas folded
  on, and that extractor PREPENDS the singular `cdKey`, shifting every position
  by one; it also drops JSON-scalar rows this list keeps. Do not diff the two
  lists either — `code` here is TRIMMED while the raw column carries the
  untrimmed text.

  Write a mark back by `keyRef`, never by index and never by code:
  `PUT /orders/:code/items/:itemId/key-state` with `{ keyRef, state }`. `state`
  is required BUT nullable — an explicit `null` clears the mark, while omitting
  the field is a 422 rather than a clear, so a client that serialises
  `undefined` away cannot silently wipe what the buyer set.

  Requires gamecore-api 2026-08-03 or later.

- `gc.orders.setKeyState(code, itemId, keyRef, state)` → `OrderKeyStateResult`
  — the write half, shaped exactly like the other item-scoped order actions
  (`clientReady` / `requestRetry`). `keyRef` MUST be the one you rendered from
  `item.keys[i].keyRef`: it is a salted digest the server re-derives from the
  codes actually delivered on that item, so a constructed ref, a raw code or an
  index will not match.

  `state` is a required parameter and separately nullable — pass `"activated"`,
  `"not_working"`, or an explicit `null` to clear. It has no default on
  purpose: on the wire an absent `state` is a 422 rather than a clear, and a
  defaulted parameter would manufacture exactly the request the server refuses
  to guess at — one forgotten argument away from wiping a buyer's mark.

  Only `not_found` and `unknown_key` are classified outcomes; both arrive at
  HTTP 404 and resolve with the parsed body instead of throwing, so branch on
  `outcome` — the status cannot tell them apart. `not_found` is deliberately
  collapsed server-side ("does not exist", "not yours" and "wrong tenant" are
  one answer) and is not evidence about which, so do not word a message as if
  it were. `unknown_key` means the page is holding a stale key list: refetch
  and re-render. Unlike the code-readiness actions this method does NOT
  tolerate 400/422 — those are caller bugs, not buyer states, and must surface
  loudly. 401 throws and fires `onAuthError` as everywhere else.

  Idempotent: re-marking what is already marked answers `{ success: true }`
  without a write, so every tap can be sent. Staff are alerted only on a real
  transition into `not_working`, so a repeat tap needs no debounce. A mark is a
  SIGNAL — it never touches order status, refunds or cancellation.

- `OrderKeyState` (`"activated" | "not_working"`), `OrderKeyStateOutcome` and
  `OrderKeyStateResult`. `OrderKeyState` is the single alias behind BOTH
  directions — `OrderItem.keys[].state` and the `setKeyState` parameter — so
  the read and the write cannot drift into different ideas of what a mark is.

## 0.60.0 — 2026-08-01

Two fields whose DECLARED name disagreed with the wire, corrected in opposite
directions. Both were silent: TypeScript reads the declaration, not the
response, so a drifted field is simply `undefined` at runtime — forever, with
no exception, no log line and no failing test. Nothing here changes what a
correct client already does; it stops incorrect clients from looking correct.

### Fixed

- `Notification.isRead` and `.data` are now actually sent. The endpoint used to
  serialize the database row verbatim (`readAt`, `payload`) while this package
  declared `isRead`/`data` and nothing translated, so a client reading the
  typed fields saw `undefined`: bells rendered read notifications as unread and
  deep links fell back to the default route. Whether a shop visibly misrendered
  depended on whether its checkout carried a local tolerance shim — of the live
  shops checked on 01.08.2026, one did and was fine, one did not and was not.
  The fix is on the API side (gamecore-api 2026-08-01); the type is unchanged
  in its canonical half, so no client code needs editing.
- `OrderItem.cdKeys: string | null` — the delivered keys, the name the order
  REST endpoints have always used. This is the RAW `cd_keys` TEXT column: a
  JSON **string** like `[{"code":"XXXXX-YYYYY"}]`, several entries when qty >
  1, `null` before delivery. It is not parsed server-side, and legacy rows may
  hold a bare key string, so parse defensively. Here the TYPE was the wrong
  side: the singular `cdKey` this package declared is never populated on an
  order response, so a storefront reading it showed the buyer no key at all
  after a page reload.

- `Product.gameSlug?: string` — a game slug that `/catalog/products/:id` and
  the search endpoint have always sent while this package declared only
  `gameId`. Optional because the LIST endpoints (`getProducts` /
  `getProductsGrouped`) omit it.
  The sharper half is now documented on both fields: ONE interface serves
  THREE responses and they disagree about what a game slug is. `getProduct(id)`
  resolves through `game_mappings` and sends the CANONICAL slug in BOTH fields;
  the SEO product view resolves the same way but sends it in `gameSlug` only,
  with no `gameId`. The list endpoints put the SUPPLIER slug in `gameId` (it
  can carry a dedup `-2` suffix) and send no `gameSlug`. `search()` sends
  `gameSlug` only, taken from the SUPPLIER game row and remapped just through a
  hardcoded alias list while the catalog-dedup flag is on — so a search hit's
  `gameSlug` is not canonical in general.
  Nothing enforces any of this; know which call produced your object.

### Deprecated

- `OrderItem.cdKey` — never populated on `/orders`, `/orders/:code`,
  `/orders/payment/:code` or `/profile/orders`. Read `OrderItem.cdKeys`
  instead. The name stays valid on the SSE `OrderUpdateEvent` item delta, which
  really does carry the first key already extracted.
  Deprecated rather than removed on purpose: storefronts render from
  `OrderItem & { … }` with SSE deltas folded onto it, and on that merged object
  `cdKey` IS set while the page is open during fulfillment. Deleting it now
  would break their build for a value they legitimately hold. Removal is gated
  on those clients having a home for the merged shape — a dedicated type here
  or a local widening there — and is a breaking change to schedule, not a
  side effect of this one.
- `Notification.readAt` / `.payload` — the raw column names, now DECLARED
  because the API emits them alongside the canonical fields. Prefer
  `isRead`/`data`; a future release drops the aliases once the last consumer
  that reads them directly has moved.

## 0.59.0 — 2026-07-27

The GC Coins PURCHASE rail: buy coins with money, poll the purchase, read the
coin journal, and pay for a checkout with coins. Coins are denominated in the
site's `coinCurrency` (1:1) — a SEPARATE ledger from the platform-wide RUB
wallet. Never add or compare the two.

### Added

- `gc.coins.purchase({ amount, paymentMethod? })` → `CoinPurchaseResult` —
  mints an invoice and returns the gateway `paymentUrl`. `amount` is coins
  (integers, bounded by `CoinWallet.minPurchase`/`maxPurchase`);
  `paymentMethod` is a TOPUP-context option id, auto-picked when omitted.
  Invoice-first: the row exists before the call resolves, so the code is
  pollable immediately — but coins are credited by the payment webhook, never
  by this call. Do NOT pass an idempotency key (the route derives its own).
  Typed failures: 400 `amount_out_of_range` / `coin_currency_mismatch` /
  `method_unavailable`, 409 `purchase_in_progress`, 502 `gateway_error`, 503
  `no_gateway`. Available since gamecore-api 2026-07-27.
- `gc.coins.getPurchase(code)` → `CoinPurchaseStatus` — owner-scoped poll of a
  `C-…` purchase (`pending` / `processing` / `completed` / `credit_failed` /
  `failed`); a foreign or unknown code answers 404 `purchase_not_found`.
  Carries `coinName` so the gateway-return screen can name the coin with no
  wallet loaded. Available since gamecore-api 2026-07-27.
- `gc.coins.getTransactions({ limit?, offset? })` → `CoinTransaction[]` — the
  caller's coin journal, newest first, server-capped at 50 per page (mirrors
  `gc.profile.getTransactions`'s flat-array shape). SETTLED movements only:
  the `reserve`/`release` halves of a checkout hold are not returned, so the
  rows sum to `balance + reserved`, not to the spendable balance. `amount` is
  signed (credits +, debits −). Available since gamecore-api 2026-07-27.
- `CoinWallet.coinCurrency: string`, `.minPurchase: number`,
  `.maxPurchase: number` — the coin's denomination and the purchase bounds.
  REQUIRED, not optional: `/coins/me` merges platform defaults, so every site
  answers them. Available since gamecore-api 2026-07-27.
- `CompleteWithBalanceResult.coinsUsed?: number` and
  `.newCoinBalance?: { balance: number; reserved: number }` — the coin
  counterpart of `balanceUsed`/`newBalance`, on the coins rail only.
  `coinsUsed` is what the capture actually SETTLED (absent on an idempotent
  replay); `newCoinBalance` is omitted rather than zero-filled when the wallet
  read fails. Available since gamecore-api 2026-07-27.
- `CheckoutRequest.expectedCoinAmount?: number` — the coin price the
  storefront displayed. The server refuses a re-priced cart with 409
  `coin_price_changed` (`details.coinAmount`/`.coinCurrency` carry the current
  price) instead of silently charging a different number of coins. Ignored on
  every non-coins rail. Available since gamecore-api 2026-07-27.
- `CheckoutResponse.payment.coinAmount?: number` / `.coinCurrency?: string` —
  what the buyer pays in coins on the coins rail; `total` stays the RUB charge.
  Available since gamecore-api 2026-07-27.
- `PaymentMethod.gatewayType` documented to include the INTERNAL `"coins"`
  rail (alongside `"balance"`): no gateway, no redirect, offered only when the
  site's Coin Rewards module is on. Available since gamecore-api 2026-07-27.
- `getInsufficientCoinsDetails(err)` → `InsufficientCoinsDetails | null` and
  `getCoinPriceChangedDetails(err)` → `CoinPriceChangedDetails | null` — read
  the machine fields off the two 409s the coins checkout rail can throw.
  EXTRACTORS rather than `is`-guards (unlike `isMethodAmountLimitError`)
  because these bodies nest their fields under `details` and carry the tag in
  `error`, not `code` — `err.code === "insufficient_coins"` never matches.
  `available` stays ABSENT (never 0) when the server omitted it.
  Available since gamecore-api 2026-07-27.

## 0.58.0 — 2026-07-22

New `gc.coins` namespace for Coin Rewards — a default-OFF per-site module
(wallet, daily claim, reward shop, redemption, public winners feed).

### Added

- `gc.coins.getMe()` → `CoinWallet` — balance, reserved, `expiringSoon`,
  the site's coin display name/icon, and the daily-claim `status` (reuses
  `DailyBonusStatus`).
- `gc.coins.claim()` → `{ claimedAmount, newStreak, nextAvailableAt,
  currency: "coins" }` — atomic daily coin claim; throws (409) while on
  cooldown.
- `gc.coins.getRewards()` → `CoinReward[]` — reward catalog; `available` /
  `unavailableReason` are populated only when authenticated.
- `gc.coins.redeem({ rewardId, idempotencyKey?, delivery? })` →
  `CoinRedeemResult` — spend coins on a reward; `delivery` carries prize
  delivery fields (e.g. `{ uid }` for PUBG UC).
- `gc.coins.getRedemptions()` → `CoinRedemption[]` — caller's own
  redemption history, latest 50.
- `gc.coins.getWinners()` → `CoinWinner[]` — public, no-auth,
  pseudonymized winners feed (may 404 when the site disables it). Carries
  no record id — only the pseudonymized `name` + reward fields.
- `DailyBonusStatus.currency?: "rub" | "coins"` and
  `DailyBonusStatus.coinName?: string` — additive fields populated when the
  Coin Rewards module is enabled on the site; both `undefined` in legacy
  RUB mode.

## 0.57.0 — 2026-07-20

Order-rescue program (phase 4 light): the «Исправить и повторить» (fix-and-retry)
signal on FAILED order items. Additive, type-only.

### Added

- `OrderItem.cancelReasonCode?: string | null` — machine code for the
  fix-and-retry CTA, emitted from a strict ALLOWLIST of buyer-FIXABLE codes
  only (`wrong_field`, `wrong_form_data`, `invalid_codes`, `game_not_linked`,
  `two_factor_required`, `wrong_platform`, `confirmation_not_received`,
  `ROBLOX_NOT_IN_GAME`). Match the exact string (Vendoria lowercase, Nexus
  UPPERCASE). `null` means "NOT buyer-fixable" — item not failed, or an
  internal/by-design-excluded reason; raw internal codes are NEVER serialized.
- `OrderItem.wrongField?: string | null` — the offending field name (e.g.
  `login`, `password`) when `cancelReasonCode === "wrong_field"`, parsed from
  the supplier's `wrong_field:<field>` code; `null` otherwise. Server-validated
  to stay store-neutral: it is ALWAYS one of the item's delivery-schema field
  ids (matching a `deliveryFields[].key` you can highlight), else `null`.
  Membership in the item's schema is REQUIRED — a raw/opaque supplier string, a
  field not in the schema, or a schema-less item (e.g. a custom pack / SuperPass
  with no buy-form) all drop to `null` while `cancelReasonCode` stays
  `"wrong_field"`, so the CTA still renders without per-field targeting.
- Both fields ride the customer order GETs (`/orders`, `/orders/:code`,
  `/orders/payment/:code`, `/profile/orders`) next to `cancelReason`.
- `FulfillmentMeta.requirements?: Array<{ code: string }>` (order-rescue phase 3)
  — auto-derived pre-purchase requirement codes on `Product.fulfillment`, for a
  checklist + blocking checkbox rendered BEFORE payment. Current codes:
  `confirm_readiness` (buyer must confirm readiness after payment) and
  `google_prompt` (Google login prompt). Vocabulary is OPEN-ENDED — render
  recognized codes and skip unknown ones gracefully. Omitted entirely (not an
  empty array) when the product has no requirements. Emitted identically by
  `getProducts()` and `getProduct()`; codes carry no supplier identity.
- `DeliveryHelp.requirements?: DeliveryHelpRequirement[]` + the new
  `DeliveryHelpRequirement` interface (`{ code: string; label?: string }`,
  order-rescue phase 3) — operator-authored pre-purchase requirement items on
  `gc.catalog.getDeliveryHelp()`, joining the auto-derived
  `FulfillmentMeta.requirements` in the pre-payment checklist. `code` is our
  own vocabulary (`[a-z0-9_-]`); standard codes (`confirm_readiness`,
  `google_prompt`, `wait_world_link`) get storefront i18n, unknown codes render
  the optional RU `label` (escape it — plain text) and are skipped when it's
  absent. Omitted entirely (not an empty array) when the operator set none. A
  per-site override REPLACES the whole default block, including these.
- MERGE RULE for the checklist: the storefront combines
  `FulfillmentMeta.requirements` + `DeliveryHelp.requirements` deduped by
  `code`, auto-derived wins — one checkbox per code, an operator row
  duplicating an auto code must not render twice.

## 0.56.0 — 2026-07-20

Order-rescue program (phase 2): item-scoped customer code actions for the
storefront «Подтвердите готовность» card.

### Added

- `gc.orders.clientReady(code, itemId)` — POST
  `/orders/:code/items/:itemId/client-ready`; the customer signals «я готов
  принять код» for ONE order item.
- `gc.orders.requestRetry(code, itemId)` — POST
  `/orders/:code/items/:itemId/request-retry`; the customer asks for a fresh
  code (prior one expired / didn't work).
- Both return `CodeReadinessResult`, a union discriminated on `success`:
  bare `{ success: true }`, or `{ success: false, outcome, resetIn? }` with
  `outcome: CodeReadinessOutcome` (`stale` 409 / `final` 400 / `not_found`
  404 / `cooldown` 429 / `error` 502). Every classified status RESOLVES with
  the parsed body (`okStatuses`) instead of throwing — branch on `outcome`,
  not HTTP code. `resetIn` = seconds until the cooldown clears (`cooldown`
  only) and is the ONLY countdown signal (raw cooldown timestamps are never
  serialized on items). 401 and unexpected statuses still throw.
- `OrderItem` affordance fields (customer order GETs: `/orders`,
  `/orders/:code`, `/orders/payment/:code`, `/profile/orders`):
  `codeAffordance?: CodeAffordance` (`"client_ready" | "enter_code" |
  "request_retry" | "none"` — which button to render), `affordanceActive?:
  boolean` (primary button pressable right now — false while its cooldown
  runs), `retryAvailable?: boolean` (the `enter_code` "code didn't work"
  escape). Optional only because older API versions omit them; the current
  API always serializes all three together.

### Changed

- `request()` 429 handling: a 429 listed in `okStatuses` now resolves with
  the parsed body instead of throwing — needed because the code-readiness
  endpoints classify their `cooldown` outcome onto 429. Opt-in per call:
  every existing endpoint keeps the differentiated rate-limit
  `GameCoreError` throw (a regression test pins it).

### Fixed

- `orders.clientReady`, `orders.requestRetry` and `orders.requestCancel` now
  percent-encode the `code` (and stringified `itemId`) path segments — a
  hostile code like `a/b?x=1` could previously rewrite the request path and
  leak the tail into the query string.

## 0.55.1 — 2026-07-19

### Added

- `Category.aliasSlugs?: string[]` — every slug a category historically
  answered to (recategorization program). Storefronts match stale category
  URL segments against these and 308 onto `slug`. Removes the local type
  widening the storefront train shipped with.

## 0.55.0 — 2026-07-18

### Added

- `gc.packRequests` namespace — Vendoria custom pack requests («Собери свой
  пак»): `listGames()`, `list()`, `get(id)`, `uploadImage(supplierGameId,
  file)` (multipart screenshot upload returning `{ key, grant }`),
  `create(data)`, `pay(id, data)` (real payment + order; returns the gateway
  URL, or `null` for instant balance payments), `cancel(id)`. No
  `idempotencyKey` arguments: the server derives its own keys (create is
  guarded by the durable active-request caps + image-key claim; pay by the
  derived `{siteId, packRequestId}` key), so a client-supplied key would be
  dead weight. Auth-only; requires the `vendoria_pack_requests`
  site module (disabled tenants answer a generic 404 on every endpoint).
- Types: `PackRequest`, `PackRequestStatus`, `PackRequestGame`,
  `PackRequestForm` / `PackRequestFormField` (vendor delivery forms,
  persisted verbatim), `PackRequestPayResponse` (reuses `CheckoutFee` —
  `total` is GROSS, never re-add `fee.amount`).
- 429 responses now carry their JSON body: `GameCoreError.code` comes from
  `body.code ?? body.error` (`upload_quota_exceeded`, `queue_full`, …) with
  `RATE_LIMITED` only as fallback; the parsed body is kept in `err.details`.

## 0.54.0 — 2026-07-17

SuperPass in-game handoff (API PR #71): assigned-employee surface on order
items.

### Added

- New exported type `SuperpassEmployee` — `{ username: string; profileUrl:
  string }`, the ONE shared shape for both surfaces below.
- `OrderItem.superpassEmployee` — `SuperpassEmployee | null`. Always
  present (object or `null`) on the customer order endpoints (`/orders`,
  `/orders/:code`, `/orders/payment/:code`, `/profile/orders`): an object
  when a SuperPass in-game employee is assigned to hand the item over,
  `null` otherwise. Public Roblox identity only; `profileUrl` is always a
  string (possibly `""`).
- `OrderUpdateEvent.items[].superpassEmployee?` — same `SuperpassEmployee`
  shape on the SSE order-tracking event, pushed when the fulfillment
  assigns an employee mid-tracking (absent until assigned, never `null`).

## 0.53.0 — 2026-07-17

### Changed

- CSP: `analytics.yandexMetrika: true` now emits the FULL official Yandex
  Metrika address list (yandex.ru/support/metrica/code/install-counter-csp,
  checked 2026-07-17): the `mc.yandex.<tld>` geo-mirror family +
  `mc.webvisor.*` + `yastatic.net` in script-src/connect-src, and the
  `wss://` counterparts in connect-src. Fixes the silently blocked
  `solid.ws` websocket transport (the tag degraded to https polling;
  confirmed via enforce-disposition CSP violations on paykod.net).
- CSP: `frame-src` gains `blob:` + the Metrika family (webvisor frames).
- CSP: **`frame-ancestors` changes from `'none'` to the official list of
  Metrika UI origins** when `yandexMetrika` is on — a deliberate, narrow
  relaxation: those 25 Yandex-owned origins may now frame the storefront
  (click/scroll maps and Webvisor render the live site inside the Metrika
  cabinet; `'none'` silently broke those reports). All other origins are
  still forbidden. Tenants without `yandexMetrika` keep `'none'`.

## 0.52.4 — 2026-07-15

### Fixed

- `GameDetail.ratingSource` union was missing `'reviews'` (0.52.3 added it
  only to `Game`): `getGame` already returns it on reviews-source sites, so
  exhaustive narrowing on the detail type was unsound.

## 0.52.3 — 2026-07-15

### Added

- `ratingSource` union gains `'reviews'` — per-site rating source backed by
  the shop's own real customer reviews (orders + published telegram imports,
  same aggregation as the game-page review stats, emitted from 3 reviews).

### Changed

- Rating field docs no longer claim a 10-review backend floor; thresholds
  are per-source (reviews: 3) and the source is a per-site setting.

## 0.50.0 — 2026-07-11

Marketing attribution (Marketing Cabinet, Task 224): visit beacon method +
optional marketing-visit refs on auth and checkout.

### Added

- `gc.marketing.trackVisit(body)` — fires the `POST /marketing/visit` beacon
  for a first-page landing and resolves `{ visitId? }`. Fire-and-forget
  contract: it NEVER throws (rate limits, outages, bot-filtered visits all
  resolve to `{}`), and an empty `landingPath` is normalized to `"/"`
  client-side (the API rejects empty with 422). Persist the returned ids:
  first-ever → `mvFirst`, latest → `mvLast`.
- New types: `MarketingTrackVisitRequest`, `MarketingTrackVisitResult`,
  `MarketingVisitRefs`; new util `sanitizeMvId` (positive-int4 clamp).
- Optional `mvFirst`/`mvLast` (marketing visit refs) threading — they ride
  exactly where `ref` rides:
  - `checkout.create` — new optional `CheckoutRequest.mvFirst`/`.mvLast`
    (pending order attribution, finalized on payment).
  - `auth.telegramOidc`, `auth.verifyMiniApp`, `auth.verifyTelegramWidget`,
    `auth.verifyVk` — new optional trailing `mv?: MarketingVisitRefs` param.
  - `auth.register` — new optional 5th param `mv?: MarketingVisitRefs`.
  - `auth.requestEmailLink` — `opts` gains `mvFirst`/`mvLast` (bound to the
    pending magic-link token server-side, consumed at verify).
  - `auth.getVkAuthUrl` — new optional `mv` param (bound to the VK oauth
    state server-side, consumed at the callback).
  - `renderTelegramWidget` / `loginViaTelegramBot` options gain
    `mvFirst`/`mvLast` (the bot-login flow appends them to the polling
    query, mirroring `ref`).
- All mv values are sanitized client-side (`sanitizeMvId`): garbage from a
  tampered localStorage is DROPPED, never sent — a bad value can never 422
  an auth call or a checkout. Server-side they are additionally validated
  (site match, 30-day window) and silently nulled when invalid.

## 0.48.0 — 2026-07-10

CMS `guide` article type + game-linkage fields (W5 SEO ContentPipe guides).

### Added

- `CmsArticleType` gains `"guide"` — AI-generated per-game guides, alongside
  the existing `news`/`promo`/`footer_block` types.
- `CmsArticleEntityKind` — new type alias, `"superpass" | "canonical"`.
- `CmsArticleSummary` (from `gc.site.getArticles()`) and `CmsArticle` (from
  `gc.site.getArticle()`) both gain `entityKind: CmsArticleEntityKind | null`,
  `entityId: number | null`, `entitySlug: string | null`. Populated on
  `type: "guide"` articles (linking back to the SuperPass or canonical game
  the guide is about); `null` on every other article type. The current API
  (post W5) always sends these three fields; storefronts on an older API
  deployment that predates this change won't receive them at all, even
  though the type declares them required — upgrade the API before relying
  on them client-side.

### Fixed — type accuracy on `CmsArticle`

- `CmsArticle` (the `gc.site.getArticle()` detail shape) wrongly declared
  `siteId`, `status`, `createdAt`, `updatedAt` as present. The public detail
  endpoint (`GET /site/cms/:type/:slug`) never actually returns those —
  they're admin-only fields. Removed from the type to match the real wire
  shape. This is a type-only correction: those fields were always
  `undefined` at runtime, so no behavior changes for existing callers unless
  they were (incorrectly) reading them.

## 0.47.1 — 2026-07-08

Docs: correct `superpasses` field presence semantics. The field is always
present on the current API (empty array when disabled); only pre-0.47 API
deployments have it absent. No code changes.

## 0.47.0 — 2026-07-08

SuperPass search + aliases + per-locale SEO. Additive only — all new fields
are optional; older API deployments that predate these changes are
unaffected (the fields simply come back `undefined`).

### Added — `catalog.search()` SuperPass hits

- `SearchResult.superpasses?: SuperpassSearchHit[]` — a separate Meilisearch
  index (`{ id, slug, name, nameEn, icon }`), merged into the global search
  response. On the current API, this field is always present — sites with
  SuperPass search disabled receive an empty array. The field is typed optional
  only for back-compat with pre-0.47 API deployments that lack the field
  entirely (treat absence as an empty list).

### Added — `superpasses.list()` aliases

- `SuperpassGameSummary.aliases?: string[]` — active search aliases per
  game (alternate spellings, romanizations), for client-side catalog
  filtering. The current API always sends an array; optional only for
  back-compat with pre-alias deployments.

### Added — `superpasses.get(slug, locale?)` SEO content

- New optional `locale` param — sends `?locale=` so the per-site SEO copy
  resolves for the page's display locale instead of base RU.
- `SuperpassGameResponse.seo?: SuperpassSeoContent | null` — per-site SEO
  copy for the game page (title, h1, meta, OG tags, intro/content,
  structured sections, FAQ, noindex). `null` means no copy has been written
  yet for that game/locale (the default state); the field is entirely
  absent only on API deployments that predate it.

## 0.46.0 — 2026-07-07

Referral funnel wiring (Phase A). Additive only — requires gamecore-api with
the `/referral/click` beacon deployed; older APIs 404 on `trackClick` and
ignore `CheckoutRequest.ref`.

### Added — `referrals.trackClick(ref)`

- `POST /referral/click { ref }` — counts a click on a referral link by
  **code or slug** (the API resolves both; case-mangled inputs included).
- Public: no user auth, only the site `X-Api-Key`. Call it server-side from
  the storefront's `/ref/[code]` route handler, fire-and-forget, then
  redirect.
- Unknown/invalid refs still resolve `{ success: true }` — there is
  deliberately **no link-existence oracle**, so never branch on the outcome.
- **True beacon semantics: the method never rejects.** Refs that cannot pass
  the server's validation (shorter than 2 chars after stripping to
  `[A-Za-z0-9_-]`, longer than 128 → truncated) are handled client-side, and
  transport/HTTP errors (429 during promo bursts, 5xx, network down) are
  swallowed — a lost click is strictly better than a broken landing.

### Added — `CheckoutRequest.ref`

- Optional referral code or slug on `checkout.create()`. When the checkout
  provisions a **new** guest account (email checkout without login), the
  account is attributed to the referrer who owns the ref — commissions then
  accrue on the buyer's delivered orders.
- Ignored for authenticated buyers and existing accounts (attribution is
  creation-time only — this is deliberate, it prevents referrer-swap abuse).
- A broken/unknown ref never fails the checkout; the order proceeds
  unattributed. Storefronts should pass the persisted ref (cookie) here.
- Defense in depth against poisoned ref cookies: `checkout.create()` clamps
  the ref client-side (strip to `[A-Za-z0-9_-]`, cap at 128, drop when
  nothing valid remains) and the API clamps again server-side — a tampered
  cookie can never 422 a sale.

## 0.44.0 — 2026-07-05

Read-side fee snapshot (Block B / B7). Additive types only — the API emits the
snapshot for payments made from migration 0183 onward; legacy payments omit it.

### Added — `fee` on order/payment read responses

- `Order.payment` (`orders.get()`) gains an optional `fee?: CheckoutFee`. Note
  the list endpoint (`orders.list()`) returns only `paymentCode`, not a
  `payment` object — `fee` surfaces on the single-order and by-payment reads.
- `PaymentInfo` (`checkout.getByPayment()`) gains an optional `fee?: CheckoutFee`.
- The shape reuses `CheckoutFee` (`{ mode, amount, goodsTotal }`): under
  `mode: "surcharge"` the read `totalAmount` is GROSS and
  `totalAmount === fee.goodsTotal + fee.amount`; every other mode
  `totalAmount === fee.goodsTotal`.
- **Absent = legacy** (payment predates the snapshot). Treat a missing `fee` as
  "no surcharge, `totalAmount == goods`" — do not synthesize a fee line.

Storefronts render the order detail as a goods / payment-system fee / charged
breakdown that reconciles with the line items (the "Сумма" line was previously
GROSS with no way to explain the difference on surcharge orders).

## 0.43.0 — 2026-07-04

Bundles the Block B (3-mode payment fee) type surface, a client-side surcharge
preview, richer errors, and two runtime/type fixes. The fee fields are additive
— the API has emitted them for a while; the SDK simply types them now.

### Added — `FeeMode` + fee fields across the payment surface

- New `FeeMode` export (`"included" | "absorb" | "surcharge"`), mirroring the
  server. Only `"surcharge"` is added to the charge (customer pays goods + fee);
  `"absorb"`/`"included"` mean the customer pays the goods total.
- `PaymentMethod` (`checkout.getPaymentMethods()`) gains `feeFixed?: number`,
  `feeMode?: FeeMode`, and `group?` (collapsible-UI bucket, mirrors
  `TopupMethod.group`).
- `TopupMethod` (`topup.getPaymentMethods()`) gains `feeFixed?`/`feeMode?`.
  ⚠️ **Topups are always charged net** — never render a surcharge line on a
  topup even when `feeMode === "surcharge"` (surfaced for display parity only).

### Added — `CheckoutFee` + fee breakdown on `CheckoutResponse.payment`

New `CheckoutFee` (`{ mode: FeeMode; amount: number; goodsTotal: number }`).
`checkout.create()` responses now type `payment.fee?: CheckoutFee`,
`payment.couponId?: number | null`, and `payment.bundleDiscount?: { percent;
ruleName; saved } | null` — the unified shape both the gateway and balance
paths return. Storefronts can render a "Комиссия платёжной системы" line in
strict TS. ⚠️ `payment.total` is **GROSS** — under surcharge it equals
`fee.goodsTotal + fee.amount`; never re-add `fee.amount` to `total`. Balance
payments always carry `fee: { mode: "included", amount: 0 }`.

### Added — `estimateSurcharge()` client-side preview

`estimateSurcharge({ goods, feePercent?, feeFixed?, feeMode? }) → { applies,
fee, gross }` — a **bit-exact** mirror of the server fee math
(`site-payment-fees.ts` `computeFee`/`resolveCheckoutCharge`): `fee = max(0,
round2(goods * percent / 100 + fixed))`, `gross = round2(goods + fee)`,
`round2 = Math.round(x * 100) / 100`, evaluated in that exact order so the
preview matches the charge to the kopeck (a parity fuzz test in the API asserts
this over 20 000 random inputs). `applies` is true only for
`feeMode === "surcharge"`; absorb/included/unknown yield `applies:false, fee:0,
gross:goods`. Preview-only — the checkout `payment.fee` is the source of truth;
never use it for topup. Exported from the package root.

### Added — `GameCoreError.details` + `isMethodAmountLimitError()` guard

`GameCoreError` now carries `details?: Record<string, unknown>` — the full
parsed JSON error body, so storefronts read machine fields (min/max
`limit`/`label`/`methodId`, insufficient-funds `requiredAmount`) instead of
regex-scraping the Russian message. Adds an `isGameCoreError()` guard, a
`MethodAmountLimitDetails` type, and an `isMethodAmountLimitError()` guard for
the min/max contract. The `message`/`status`/`code` are unchanged and there are
no external `new GameCoreError` callers, so it is fully backward-compatible.
(Note: min/max are enforced against the **gross** charge under surcharge.)

### Fixed ⚠️ — `topup.getPaymentMethods()` now returns the array it always promised

`topup.getPaymentMethods()` is typed `Promise<TopupMethod[]>` but the endpoint
has returned `{ displayCurrency, methods: [...] }` since its first commit, and
the SDK's envelope-unwrap only fires on `{ success, data }`. So it handed back
an object typed as an array — any caller doing `methods.length` / `.map` /
`.find` got a silent no-op or `TypeError` (the giftcardi top-up picker rendered
empty). It now unwraps the envelope (`rawResponse: true` + `return res.methods
|| []`), mirroring `checkout.getPaymentMethods()`. This changes the **runtime**
return (object → array) to finally match the long-declared type — a correctness
fix, not a break; no known consumer depended on the object shape.

### Fixed — `CheckoutResponse.orders[]` shape matches the runtime

The `orders` element was mistyped `{ code; status }`; the API never sent
`status` there. Retyped to the real `{ code; gameId; gameName; total;
itemCount }`. Type-level breaking change for anyone reading `order.status` off a
`checkout.create()` result (always `undefined` at runtime, so no real impact).
The unrelated `CheckoutStatus.orders` (`checkout.getStatus()`) is unchanged.

## 0.42.0 — 2026-06-29

### Added — percent-of-profit quest rewards on `Quest`

`Quest` gains three optional fields for `profile.getQuests()` /
`profile.completeQuest()` (additive): `rewardType?: "flat" | "percent_profit"`
(missing ⇒ treat as `"flat"`), `rewardPercent?: number | null` (percent of the
order's profit paid when `rewardType` is `"percent_profit"`), and
`rewardCap?: number | null` (optional RUB ceiling). For `"percent_profit"` the
flat `rewardAmount` is `0` — the payout is derived server-side from the order
profit at claim time and never exceeds the shop's margin.

## 0.41.0 — 2026-06-27

### Added — `itemCode` on `OrderItem`

`OrderItem` now exposes an optional `itemCode?: string` field — the per-item
order number in the format `{orderCode}-{seq}` (e.g. `ash-A7X9K2-2`). It is
both the customer-facing item id and the per-item supplier reference. The field
is optional so the SDK stays backward-compatible with older API responses that
do not include it.

## 0.40.0 — 2026-06-19

### Added — per-call `locale` on catalog + announcement methods

Several catalog and announcement methods now accept an optional `locale`
argument that forwards a `?locale=` query param, so product / SKU names come
back localized (es / pt-br / en) instead of base RU. The backend localizes off
the query param — not the `Accept-Language` header — so these endpoints were
silently resolving to RU without it. Mirrors the existing `getGame()` pattern;
additive and backward-compatible.

## 0.39.0 — 2026-06-15

### Added — per-call display currency on `getGame` + BRL formatting

`catalog.getGame(slug, locale?, currency?)` accepts an optional third argument
that forwards a `?currency=` query param, overriding the client-level default
for that one call. This lets a shared server-side client (configured for RUB)
fetch a `pt-br` page's prices in `BRL` (or an `es` page's in `USD`) without
spinning up a per-request client — mirroring how `locale` is already threaded
from the route segment. Fully backward-compatible: omit `currency` to keep the
existing `X-Currency`/RUB behaviour.

`formatPrice(amount, "BRL")` now renders the Brazilian convention
`R$ 1.234,56` (period thousands, comma decimal) instead of the generic
`1234.56 BRL` fallthrough. RUB/USD/EUR output is unchanged.

## 0.38.0 — 2026-06-14

### Added — payment-method lists are now locale/region aware

`checkout.getPaymentMethods()` and `topup.getPaymentMethods()` now forward the
client locale as a `?locale=` query param. The API uses it to region-gate the
returned methods — e.g. a client created with `locale: "es"` or `"pt-br"`
(Latin America) is no longer offered Russia-only rails (СБП, Картой РФ), while
crypto and account balance stay available everywhere.

Backward compatible: when no `locale` is set on the client the param is
omitted and the API returns the full enabled set, exactly as before. Set the
locale via the `locale` constructor option or `client.setLocale(...)`.

## 0.37.0 — 2026-06-12

### Added — `verifyWebhookSignature` now verifies B2B webhooks too

`verifyWebhookSignature(payload, signature, secret, maxAgeSeconds?, timestamp?)`
gained an optional 5th `timestamp` argument so one verifier covers **both**
GameCore webhook signing schemes (additive, fully backward-compatible):

- **Storefront events** (order/payment) — unchanged: signature is
  `sha256=hex(HMAC(secret, body))`, freshness from the in-body `timestamp`.
  Call without `timestamp`.
- **B2B events** — signature is `sha256=hex(HMAC(secret, "<ts>.<body>"))` with
  the timestamp in the `X-Webhook-Timestamp` header. Pass that header value as
  `timestamp`; freshness is taken from it (mirrors the server's own verifier:
  rejects non-finite or out-of-±window timestamps).

Previously the SDK verifier only understood the storefront scheme, so B2B
webhooks could not be verified with it. Existing 3–4 argument calls behave
exactly as before.

The two schemes occupy disjoint input spaces and are domain-separated so a
signature valid under one can't be replayed under the other: the storefront body
must be a JSON object, and a B2B timestamp must be a finite number. Pass the
`X-Webhook-Timestamp` header when present and a downgraded/tampered request still
fails. Note: the freshness window is the only built-in replay defense — dedupe on
the `X-Idempotency-Key` header (or the body event id) for full idempotency, and
`maxAgeSeconds = 0` disables that window for both schemes. The storefront
producer's wire format is unchanged. See `examples/04-webhook-verify.ts`.

## 0.36.0 — 2026-06-08

### Added — `Game.noindex`

`noindex?: boolean` on catalog game entries: thin / zero-demand pages the
storefront should render with `<meta name="robots" content="noindex,follow">`
and drop from the sitemap. (Additive, non-breaking.)

## 0.35.0 — 2026-06-08

### Changed — supplier-aware order cancellation

Order cancellation surfaces supplier-driven cancellation context (reason /
metadata) so storefronts can show an accurate buyer-facing message. (Additive
fields on the existing cancel path.)

## 0.34.0 — 2026-06-07

### Added — game variant grouping (`Game.group` / `Game.groupVariants`)

One-game-one-page grouping: a game that belongs to a variant group (region /
language / edition / denomination variants) now carries `group` (the `GameGroup`
metadata: shared slug, role, primary slug, variant count) and `groupVariants`
(the sibling variants, primary-first). Both omitted for ungrouped games.
(Additive, non-breaking.)

## 0.33.0 — 2026-06-02

### Added — passwordless email auth (`auth.requestEmailLink` / `auth.verifyEmail`)

`auth.requestEmailLink(...)` sends a magic-link / 6-digit code email, and
`auth.verifyEmail({ token } | { email, code })` completes login — backing the
storefront passwordless flow.

## 0.32.0 — 2026-06-01

### Added — promo banner link target (`getPromos`)

`catalog.getPromos()` now describes where each banner links via a structured
target (additive, non-breaking):

- `targetType?: "game" | "url" | "none"` — `"game"` links to a catalog game,
  `"url"` to an operator-supplied page, `"none"` is display-only. Legacy
  campaigns (no stored type) are reported as `"url"` when a `targetUrl` is set,
  else `"none"`. (Omitted only by API versions predating this field.)
- `targetGameSlug?: string | null` — the linked game's slug for `"game"`
  targets; build your own locale-prefixed link from it.
- `targetUrl` (already present) — for `"game"` targets it's the server-resolved
  `/catalog/<slug>` path (re-derived each request, so it survives slug renames);
  for `"url"` it's the operator's destination; otherwise `null`.

No client-method or breaking changes — the new fields flow through the existing
`catalog.getPromos()`.

## 0.31.0 — 2026-06-01

### Added — account identity-linking, labelled order delivery fields, review avatars

Three additive, non-breaking surface additions for the storefront:

- **`auth.linkTelegramOidc({ idToken })`** — link a Telegram account to the
  currently authenticated (e.g. email-registered) user using the same
  `id_token` the Telegram OIDC login widget returns. Verifies the token,
  rejects collisions on the site, and is audit-logged server-side.
- **`auth.getIdentities()` reshaped** — now returns a typed `identities`
  object (`identities.email{linked,address,hasPassword}`,
  `identities.telegram{linked,username}`, `identities.vk{linked}`) read fresh
  from the DB, alongside the legacy `providers[]` array (kept for existing
  callers; new code should use `identities`). `displayName` is now
  `string | null`.
- **`OrderItem.deliveryFields?`** — labelled, ordered delivery fields ready to
  render directly (`{ key, label, value, sensitive? }`). Labels resolve from
  the supplier's `deliveryDataSchema` first, then a canonical dictionary.
  Sensitive fields (passwords, gift-card codes, PINs) carry `sensitive: true`
  so the storefront can mask them behind a reveal toggle. The raw
  `deliveryData` map is unchanged for legacy clients.
- **`Review.authorAvatarUrl?`** — public author avatar URL, always HTTPS or
  `null` (the backend normalizes anything else to `null`), safe to drop into
  an `<img src>` without re-validating. `null` for Telegram-imported reviews.

No breaking changes — every addition is optional. `auth.getIdentities()`
keeps the old `providers[]` field.

## 0.30.0 — 2026-05-31

### Added — `SiteConfig.site` legal-entity + external-ratings fields

A backend-vs-SDK audit across the last 100 commits found the `/site/config`
response had grown three `site` fields that the SDK type never surfaced. They
are now typed (additive, non-breaking):

- `SiteConfig.site.legalEntityPublished?: boolean` — whether the tenant
  published a legal-entity block.
- `SiteConfig.site.legalInfoImageUrl?: string | null` — path to the
  anti-indexable legal-entity PNG (mount as `<img src>`, `?lang=ru|en`).
  ОГРН/ИНН/address are served as an image, never JSON, to keep them out of
  search indexes (Task #222).
- `SiteConfig.site.externalRatings?: ExternalRating[]` — footer trust ratings
  (T-Bank / Я.Карты / Я.Маркет / Google Maps / Trustpilot / Otzovik), Task #23.

New exported type `ExternalRating`. No client method changes — the new fields
flow through the existing `site.getConfig()`.

The same audit confirmed the rest of the public surface is already in sync
(quests `profile.getQuests()` / `profile.completeQuest()`, daily-bonus,
gift-cards, announcements, display-currency, locale). The public-but-keyless
`POST /api/v1/leads` (marketing lead capture) and the `GET /legal-info.png`
image endpoint are intentionally out of SDK scope.

## 0.29.0 — 2026-05-22

### Changed — catalog contract sync

- Locale-aware platform labels and previously-missing console platform slugs in
  the catalog responses; SDK types resynced to the catalog contract.

## 0.28.0 — 2026-05-16

### Added — public endpoints, `support` + `superpasses` namespaces, multipart upload

After a backend-vs-SDK audit across all commits since 2026-05-12 the
following endpoints — which shipped to the API but had no client
surface — are now bound:

**Catalog & SEO:**
- `catalog.getLetterCounts({ type?, q?, inStockOnly?, platform?, category? })`
  — first-letter histogram for the alphabetical catalog sidebar.
  Honours the same filters as `getGames()` so the dimmed letters
  match the listing about to render. Special bucket `"0-9"` collects
  names starting with a digit.
- `catalog.getSitemapRoutes({ page?, limit? })` — bulk sitemap feed
  bundling each game's categories + products in one paginated call
  (default 200 / page, hard cap 500). Replaces the N+1 loop Next.js
  `app/sitemap.ts` used to do; ashop QA reported 935/2000 fetches
  failing under load before this endpoint existed.

**Checkout:**
- `checkout.beginGuestSession({ preferredChannel, email?, phone?, cartItems?, deliveryData? })`
  — task #24, 54-ФЗ ст.1.2. Persists contact channel + cart snapshot
  BEFORE the user clicks "pay" so fiscal receipts and post-payment
  notifications survive the user closing the tab. 7-day TTL.

**Site:**
- `site.getRecentPurchases({ limit? })` — public recent-purchases
  feed for the homepage social-proof ticker. Privacy-masked
  (first name, masked username, or "Покупатель"). Distinct from
  `site.getSocialProof()` — that one is a `{gameName, timeAgo}[]`
  projection for a different widget.

**New namespaces:**
- `gc.support` — in-app chat (`getThread`, `getMessages`,
  `sendMessage`, `uploadImage`, `callAdmin`) + public guest form
  (`submitPublic`). Image attachment goes through `uploadImage()`
  which uses `multipart/form-data` (the SDK now auto-detects
  `FormData` bodies and skips `JSON.stringify`).
- `gc.superpasses` — public Roblox SuperPass catalog (`list()`,
  `get(slug)`, `verifyUser(name)`). Verification is rate-limited
  10 req/min and rejects non-alphanumeric names.

**Profile:**
- `profile.uploadReviewProof({ file, platform, reviewUrl?, userNote? })`
  — task 174 multipart variant of `submitReviewProof()`. Accepts a
  `File` / `Blob`, server-stored under `/uploads/review-proofs/`,
  then the same validation pipeline as the URL path. JPEG / PNG /
  WebP up to 5MB; 413 oversize, 422 mime / extension rejection.

### Deprecated

- `CheckoutRequest.couponCode` — silently dropped by the API since
  the coupon model moved to `user_active_coupons` (apply via
  `gc.coupons.apply()` BEFORE `checkout.create()`). Field stays in
  the type for one release with a `@deprecated` marker; removal in
  0.29.0.

### Removed — references to the dead `/catalog/v2/*` API

The 0.26.2 changelog mentioned `/catalog/v2/games` and
`/catalog/v2/games/:slug/products` honouring `Accept-Language`. Those
routes were silently mounted at `/catalog/catalog/v2/*` (double-prefix
bug from commit `b59ea45`) and were never reachable from any
storefront. Their SELECTs also lacked per-site scoping
(`getSiteCatalogOverrides`, `canonical_visibility`,
`site_product_visibility`) — a latent cross-tenant leak waiting to be
discovered. The handlers were removed from the API; the SDK never
shipped a binding. **Migration:** if you previously called
`catalog.v2.getGames()` in a draft, switch to `catalog.getGames()` —
it already honours `Accept-Language` (since 0.25.0) and is the only
tenant-safe canonical listing.

### Internal

- `request()` now detects `FormData` bodies and skips the
  `Content-Type: application/json` header so `fetch` can pick the
  multipart boundary itself. No public API change.

## 0.27.0 — 2026-05-11

### Added — display currency switching (RUB / USD / EUR / KZT / UAH / TRY …)

International storefronts can now quote catalog prices in any
supported ISO-4217 currency. Backed by the existing
`currency-service` (live rates from open.er-api.com, cached 30 min)
plus a new server-side `parseCurrency` resolver and a per-currency
rounding pass.

```ts
import { GameCoreClient } from "@gamecore-api/sdk";

const gc = new GameCoreClient({
  apiKey: "gc_live_...",
  baseUrl: "https://api.gamecore-api.tech",
  currency: "USD", // X-Currency header on every catalog request
});

gc.setCurrency("KZT");           // runtime swap
gc.getCurrency();                // "KZT"

const products = await gc.catalog.getProducts("free-fire");
products[0].price;               // 480
products[0].currency;            // "KZT"

// Per-call override beats the client default:
await gc.catalog.getProducts("free-fire", { currency: "EUR" });
```

Resolution order on the server (highest priority first):
1. `?currency=XXX` query param
2. `X-Currency` request header (auto-injected by the SDK)
3. RUB default

Supported codes: `RUB`, `USD`, `EUR`, `GBP`, `KZT`, `UAH`, `TRY`,
`BRL`, `ARS`, `INR`, `PLN`, `CZK`.

Backend endpoints updated (every place that emits a `price` field):
`/catalog/games/:slug/products`, `/catalog/games/:slug/products/grouped`,
`/catalog/products/:id`, `/catalog/games/:slug/categories/:categorySlug`,
`/catalog/games/:slug/categories/:categorySlug/products/:productId`.

Every product response now carries `currency` (ISO code). Storefronts
should render this field rather than tracking the requested code
locally — it's the source of truth when the FX rate for the target
currency is temporarily missing (the server falls back to RUB and
flags it in the response).

**Checkout is unaffected**: payments still settle through gateways in
RUB or USD via `paymentMethod`. Display currency is a catalog-side
feature today.

New file: `examples/05-currency-switching.ts`. AGENTS.md and README
both document the new flow.

### Added — `SdkCurrency` export

```ts
export type SdkCurrency =
  | "RUB" | "USD" | "EUR" | "GBP" | "KZT" | "UAH"
  | "TRY" | "BRL" | "ARS" | "INR" | "PLN" | "CZK";
```

Also re-exports `SdkLocale` from the package root — those weren't
explicitly re-exported before, even though README referenced them.

## 0.26.3 — 2026-05-11

### Fixed — quickstart checkout passes full body schema

0.26.2 added `gameId` to the example but `gameName`, `productName`,
and `deliveryData` were still missing — the API's TypeBox body
schema rejects checkouts without them (all three are typed
required server-side; only `amount` is optional). Backend rejection
happened before order processing, so the smoke flow in the example
couldn't have reached the payment step.

The example now passes all five required item fields. Comment block
above the call lists exactly which fields the SDK type marks
optional but the runtime rejects — same drift as `gameId` flagged
in 0.26.2.

Known follow-up still open: align SDK `CheckoutRequest.items` to
mark the four required fields as required, in a future minor.

## 0.26.2 — 2026-05-11

### Fixed — quickstart checkout item

`examples/01-quickstart.ts` sent a checkout item without `gameId`,
which crashes the API: the backend reads `item.gameId` directly to
route Steam top-ups, SuperPass bundles, and Robux-via-pass through
their special validation paths. The SDK type currently marks the
field optional (`gameId?: string`) but the runtime enforces it on
every checkout call.

The example now passes `gameId: game.slug` and explains the
type-vs-runtime drift inline so anyone copying the snippet ships
working code.

Known follow-up: align `CheckoutRequest.items[].gameId` to be
required in the next minor (matches runtime), or relax the runtime
to tolerate missing `gameId` for plain gift-card items.

## 0.26.1 — 2026-05-11

### Fixed — examples API references

The four files added in 0.26.0 used draft method signatures that
didn't match the shipped SDK. Codex review caught it before any
agent could copy/paste the wrong patterns. Now corrected against
the real types:

- Imports use the actual export name `GameCoreClient` (not `GameCore`).
- `gc.catalog.getGame()` returns metadata; products come from a
  separate `gc.catalog.getProducts(slug)` call.
- Checkout items use `amount` (not `qty`).
- `gc.checkout.create()` returns `{ payment?: { code, paymentUrl } }`;
  examples now access via `checkout.payment?.code`.
- Webhook example uses `WebhookPayload.event` (a string union) and
  casts inside each switch branch, since `data` is
  `Record<string, unknown>`.

Also adds a `typecheck:examples` script + `tsconfig.examples.json`
so future drift between `examples/` and `src/` is caught locally.

### Server-side companion fix

`/seo/:pageType/:entityId` now honors `Accept-Language` in addition
to `?locale=`. Previously the SDK's auto-injected `Accept-Language`
header was ignored by SEO routes — a storefront on EN locale could
get an EN catalog page rendered with RU SEO copy. Migrated to the
shared `parseLocale()` helper used everywhere else.

## 0.26.0 — 2026-05-11

### Added — AI-agent-friendly docs

- `AGENTS.md` at the package root: short orientation for AI coding
  assistants. Covers setup, namespaces, locale, pitfalls, and pointers.
- `examples/` folder with four runnable `.ts` files:
  - `01-quickstart.ts` — catalog → checkout → status polling
  - `02-locale-switching.ts` — RU/EN: constructor, runtime, per-call
  - `03-error-handling.ts` — `GameCoreError`, retry helper, status/code patterns
  - `04-webhook-verify.ts` — HMAC verification via `/server` entry point
- README links into AGENTS.md and the examples folder up top.

Both `AGENTS.md` and `examples/` are now included in the published
tarball so they're discoverable from `node_modules/@gamecore-api/sdk/`.

### Fixed — locale gaps on catalog endpoints

Server-side fix backed by SDK 0.25.0's `Accept-Language` injection.
Before this release the SDK sent `Accept-Language: en` but six
catalog endpoints still returned RU names regardless. They now honor
the header:

- `GET /catalog/homepage-games`
- `GET /catalog/games/full`
- `GET /catalog/recent-games`
- `GET /catalog/games/:slug/recommendations`
- `GET /catalog/v2/games`
- `GET /catalog/v2/games/:slug/products`

If you upgraded to 0.25.0 and saw mixed-language UI, that's resolved
once the API is on `aa33f17` or later.

## 0.25.0 — 2026-05-11

### Added — built-in locale switching (RU / EN)

`GameCoreClient` now accepts a `locale: SdkLocale` option (`SdkLocale =
"ru" | "en"`). When set, every request sends an `Accept-Language`
header so the API serves localized name / short_description /
description fields from the unified `catalog_translations` store
(19,770 EN + 5,322 RU translations backfilled across the entire
published catalog).

Runtime switching for storefront language toggles:

```ts
const gc = new GameCoreClient({ apiKey, baseUrl, locale: "en" });
gc.setLocale("ru");
gc.getLocale(); // "ru"
```

Per-call `locale` arguments on `getGame` / `getGames` / `cmsArticles.*`
etc. still take precedence over the client default — useful when a
single page needs a different language than the rest of the session.

Backwards-compatible: clients that don't set `locale` keep the
previous behaviour (API falls back to "ru").

## 0.20.0 — 2026-05-04

### Changed — `availabilityStatus` union widened

Adds `"out_of_stock"` to `Game.availabilityStatus` and the matching
field on `GameDetail`. The backend now derives this state on every
catalog response (entity, listing, search) when a game has 0 visible
products under the requesting site's filters (gateway allow-list,
per-site overrides, allowed regions). Editorial overrides
(`coming_soon`, `maintenance`, `discontinued`) on canonical_games
still win and are passed through unchanged.

Storefronts should branch on this field — render an "out of stock"
banner instead of an empty cart, and emit Schema.org
`availability: "https://schema.org/OutOfStock"` so Google does not
flag the page as soft-404. See task #158 in gamecore-api repo for
the full rationale on why we keep the page live (200 OK) instead of
hard-deleting empty entities — 237 SEO-indexed URLs on ashop-games
depend on it.

### Changed — `/catalog/platforms` and `/catalog/categories` count in-stock games only

`PlatformInfo.gameCount` and `CategoryInfo.gameCount` now exclude
games with 0 visible products. Before this fix, `Steam: 15 460` was
reported next to `352 games available` because the count included
all KeyHub-empty entities. The field name is unchanged, only the
number is now coherent with the catalog total. See
`docs/backend-requests/platform-counter-anomaly.md` (ashop repo).

### Changed — search response includes `availabilityStatus`

`gc.catalog.search()` now populates `availabilityStatus` on each
game result, matching listing and entity routes. Out-of-stock games
that surface through search (Meilisearch + DB fallback) now carry
the same signal.

## 0.18.0 — 2026-05-01

Wave 5 backend release. Nine ashop-requested features land at once;
nothing existing breaks, all new fields are optional/nullable so
storefronts on pre-0.18 keep working.

### Added — new SDK methods

**Catalog (#50, Steam metadata proxy):**
- `gc.catalog.getGameSystemRequirements(slug)` →
  `SystemRequirementsResponse`. Steam-sourced minimum/recommended PC
  requirements. `hasData: false` for non-Steam games. Server-side
  cache 7 days, lazy revalidation.
- `gc.catalog.getGameScreenshots(slug)` → `ScreenshotsResponse`.
  Screenshot gallery + trailers from the same Steam payload.

**Cart (#51, cashback engine):**
- `gc.cart.getCashbackPreview()` → `CashbackPreview`. Server-side
  preview of the credit the **authenticated** user would earn after
  paying for the current cart. Lives behind the same auth gate as
  the rest of `/cart/*` — calling it without a session returns 401.
  Cashback rate cascades from `loyalty_levels.cashback_percent`
  (per-site, per-level, opt-in, defaults to 0). For tenants without
  configured cashback the response is `totalAmount: 0` with a UX
  hint in `rateExplanation` instead of an error.

**Coupons (#54, public landing listing):**
- `gc.coupons.getActiveForGame(slug)` → `PublicCoupon[]`. Public
  unauthenticated listing of active coupons applicable to a game.
  Designed for `/promocode/<slug>` programmatic-SEO landings. `code`
  is `null` when the operator chose to advertise the offer without
  exposing the code (`isPublic: false`).

**Profile — daily bonus + quests (#49):**
- `gc.profile.getDailyBonus()` → `DailyBonusStatus`. Claim status
  preview (`available`, `currentStreak`, projected reward,
  `nextAvailableAt` cooldown).
- `gc.profile.claimDailyBonus()` → `DailyBonusClaimResult`. Atomic
  claim with CAS so concurrent calls don't double-credit.
- `gc.profile.getQuests(filter?)` → `Quest[]`. Catalog folded with
  the caller's progress. Three verification modes:
    * `auto` — server-checkable (`first_purchase`, `weekly_purchase`,
      `daily_login` derived from `customer_orders` /
      `user_daily_streaks`).
    * `trust` — storefront sends a click signal (TG/VK subscribe).
    * `manual` — admin moderates (e.g. tied to review-proof #56).
- `gc.profile.completeQuest(code)` → `QuestCompleteResult`.
  Idempotent — second claim returns `alreadyClaimed: true` with
  reward=0.

**Profile — review proofs (#56):**
- `gc.profile.submitReviewProof(input)` → `ReviewProof`. Customer
  submits a screenshot of a third-party review (T-Bank, Otzyvru,
  MyWot, Google Play, App Store, Trustpilot, Yandex). Capped at
  5 simultaneously-pending rows per user. Lands in moderator queue.
- `gc.profile.getReviewProofs()` → `ReviewProof[]`. List own
  submissions newest-first.

**Site (#58, embed iframe allowlist):**
- `gc.site.getEmbedAllowlist()` → `string[]`. Active partner origins
  permitted to iframe storefront pages. Storefront feeds the list
  into its own `Content-Security-Policy: frame-ancestors`.

### Added — new SDK types

`CashbackPreview`, `PublicCoupon`, `SystemRequirementsBlock`,
`SystemRequirementsResponse`, `SteamScreenshot`, `SteamMovie`,
`ScreenshotsResponse`, `DailyBonusStatus`, `DailyBonusClaimResult`,
`Quest`, `QuestCompleteResult`, `ReviewProof`.

### Changed — existing SDK types

- `Game.deliveryTypes: string[]` (#59) — was declared in the type
  but only populated on `/catalog/games/full`. Now always present
  on every list endpoint (`getGames`, `getHomepageGames`,
  `getRecentGames`, `getRecommendations`, `search`). Empty array
  when the game has no products visible to the calling site. Same
  per-site visibility filter as `productCount` (gateway allow-list +
  product overrides + `allowed_regions`).
- `Product` (#52) gains four nullable variant fields:
  `riskTier`, `warningMessage`, `variantGroup`, `variantLabel`. All
  optional/nullable so products without operator-configured
  grouping render the same as before.

### Backend changes (gamecore-api 2026-05-01, migrations 0072–0079)

Server-side work that this SDK fronts. All changes are additive; no
existing endpoint shape regressed.

- `loyalty_levels.cashback_percent` column (#51, migration 0072).
- `coupons.is_public/description/seo_priority` (#54, migration 0073).
- `site_embed_origins` table (#58, migration 0074).
- `sites.b2b_volume_tiers` (#57, migration 0075). B2B partners get a
  volume-based discount; unrelated to ashop (storefront-type sites).
- `game_external_metadata` table (#50, migration 0076). Steam
  appdetails cache.
- `supplier_products.risk_tier/warning_message/variant_group/
  variant_label/estimated_delivery_override` (#52, migration 0077).
- `review_proofs` table (#56, migration 0078).
- `user_daily_streaks`, `quest_definitions`, `user_quest_progress`
  (#49, migration 0079).
- Order completion (`payment-fulfillment`) credits per-order
  cashback. Idempotent per `(userId, orderId)` so retries / dual
  webhooks cannot double-credit.
- `/catalog/games/:slug/recommendations` is now per-site (Wave 4
  #47, no SDK shape change): pool is the tenant's canonical catalog
  + visibility overrides, weighted random by per-site sales
  (Efraimidis–Spirakis), stock-filter matches `/products` (gateway
  allow-list + product overrides + `allowed_regions`).
- Wave 4 follow-ups (no SDK change): `productCount` in
  `/recommendations` (#46); double-slash icon URLs cleaned up via
  source-fix + 321-row backfill (#43).

### Tenant configuration required for some features

Behaviour without per-tenant configuration:
- **Cashback** (#51): until `loyalty_levels.cashback_percent` is set
  per level, `CashbackPreview.totalAmount` is `0` and
  `rateExplanation` says "Кэшбэк начнёт начисляться с более
  высокого уровня".
- **Public coupons** (#54): `getActiveForGame()` returns every
  currently-active coupon applicable to the game regardless of
  `is_public`. The flag controls **code visibility, not row
  visibility** — `is_public=false` rows return `code: null` so the
  storefront can still advertise the offer card without leaking the
  redemption code. Set `is_public=true` + `description` +
  `seo_priority` on rows you want to expose codes for.
- **Quest catalog** (#49): seed `quest_definitions` rows. Daily
  bonus works without any quests configured.
- **Embed allowlist** (#58): empty by default; storefront's
  `frame-ancestors` directive becomes `'none'` until partners are
  added.

## 0.17.1 — 2026-04-30

### Documentation

- `Game.platforms` JSDoc clarifies the runtime contract: starting
  from gamecore-api deployed 2026-04-30, every list endpoint
  (`getGames`, `getHomepageGames`, `getGamesFull`, `getRecentGames`,
  `getRecommendations`, `search`) and `getGame` returns the field
  as a (possibly empty) array. The TypeScript type stays optional
  for backward-compat with pre-0.17 backends; modern storefront code
  can treat it as `string[]`.
- `getGames({type})` parameter is now marked `@deprecated`. Prefer
  `platform: "mobile_game"` (instead of `type: "topup"`) and
  `platform: "steam"` (instead of `type: "cdkey"`). Canonical
  platforms surface facet counts via `getPlatforms()` and survive
  supplier reshuffles. The `type` filter remains accepted by the
  backend but will be removed in a future major release.

### No runtime changes

This is a documentation-only release — no behaviour, no signatures,
no new methods. Safe to upgrade from 0.17.0 with zero code edits.

## 0.17.0 — 2026-04-23

### Added

- `catalog.getPlatforms()` — returns `PlatformInfo[]` (slug, label,
  group, gameCount) for every distribution platform the site carries
  at least one canonical game on. Counts respect per-site catalog
  overrides, so empty platforms don't render in the filter UI.
  Introduced by task 118 Phase 3.
- `catalog.getCatalogCategories()` — returns `CategoryInfo[]` (slug,
  labelRu, labelEn, group, gameCount) for canonical category buckets
  present in the site catalog (`currency`, `battle_pass`, `bundle`,
  etc). Not to be confused with `getCategories(gameSlug)` which
  returns per-game categories. Introduced by task 119 Phase 3.
- `catalog.getGames({ platform, category })` — server-side filter on
  the denormalized platform/category slug arrays. Pair with
  `getPlatforms()` / `getCatalogCategories()` to render filter chips
  that produce correctly-filtered result sets.
- `Category.canonicalSlug` (optional) — canonical category slug on
  per-game `Category` objects, stable across suppliers. Use this for
  storefront dedup/routing instead of `name`, which depends on whichever
  supplier happened to be synced first.

### Storefront impact

Category tabs on canonical games that are mapped across multiple suppliers
now dedup by canonical slug instead of by name. If your storefront shows
a canonical game, "Алмазы" (Nexus) and "Diamonds" (Vendoria) collapse into
a single tab (first-seen name wins). No SDK API change is required for
this behaviour — it's a server-side fix shipped in task 119 Phase 2.

## 0.16.0 — 2026-04-22

### Added

- `auth.renderTelegramWidget(opts)` — high-level helper that mounts the
  official Telegram Login Widget into a supplied `HTMLElement`, wires
  the `data-onauth` callback to `verifyTelegramWidget`, and hands the
  verified user back through `onAuth`. Bot username is pulled from
  `site.getConfig().authConfig.telegram.botUsername` by default so
  storefronts never hardcode it. Returns a cleanup function that
  removes the widget from the DOM.
- `auth.loginViaTelegramBot(opts)` — orchestrates the init → poll
  flow as a single promise. Resolves with the authenticated `User`
  when the user presses Start in the bot, rejects with `TIMEOUT` after
  `timeoutMs` (default 120s). Works in every Telegram client
  including third-party ones (Nicegram, Plus Messenger, etc)
  because it only needs a deep-link + a bot /start, not the widget's
  popup flow which requires an official Telegram Web session.
- `SiteConfig.authConfig.telegram.botUsername` — structured auth
  config companion to the flat `auth` array. When the Telegram auth
  module is enabled for a site, this field carries the bot username
  the Login Widget needs at render time. `null` when the site has no
  Telegram credentials configured.
- `TelegramWidgetRenderOptions`, `TelegramBotLoginOptions`,
  `TelegramAuthUser` — new public types backing the helpers above.

### Notes

- No breaking changes. Existing `verifyTelegramWidget`, `initTelegram`,
  and `pollTelegramStatus` still work; the new helpers are additive
  sugar on top.
- BotFather `/setdomain` is still a hard prerequisite for the Login
  Widget — `renderTelegramWidget` can't bypass it because the check
  is enforced by telegram.org itself.

## 0.15.0 — 2026-04-21

### Added

- `GameDetail` now exposes rich upstream metadata: `shortDescription`,
  `developers`, `publishers`, `genres`, `releaseDate`, `steamAppid`. All
  optional — null / empty for suppliers that don't send this data
  (Nexus/Vendoria). For KeyHub-backed games the catalog API now returns
  the full set, letting storefronts render genre pills, a release-year
  badge, a link to Steam, etc.

### Notes

- No breaking changes. Existing clients reading `GameDetail` see the same
  shape; new fields are additive and all optional.
- Requires GameCore API to have migration `0067_canonical_games_metadata`
  applied (adds the backing columns on `canonical_games`).

## 0.14.0 — 2026-04-20

### Breaking

- `giftCards.purchase(amountRub, message?)` — first arg is now amount in RUB
  (was USD in 0.13.0). Pass `amountUsd * rubRate` if you were passing USD.
- `GiftCard` shape: `amount_usd` renamed to `amount_rub`. Added `currency`,
  `remainingBalance`, `expiresAt`. `denomination` is now optional (kept for
  backwards-compatible reads of pre-migration records).

### Added

- `cart.merge(items)` — merge a guest cart into the authed session. Typical
  flow: capture guest items in `localStorage`, call on login success.
- `cart.remove(id)` — explicit delete-by-cart-row (was previously grouped
  under `cart.clear()` only).
- `CartItem` response: new `quantity`, `addedAt`, `gameIcon` fields.
- `auth.linkEmail(email, password)` — add an email+password identity to an
  existing Telegram-only or VK-only account.
- `profile.getConversations()`, `profile.getConversationMessages(id)`,
  `profile.submitCode(conversationId, requestId, code)`,
  `profile.submitScreenshot(conversationId, requestId, file)` — in-profile
  support chat + conversation request fulfilment.
- `profile.getPushPublicKey()`, `profile.subscribePush(subscription)`,
  `profile.unsubscribePush(endpoint)` — web push subscriptions.
- `referrals.getPopularProducts(limit?)` — most-referred products by the
  current user.
- `referrals.getPerformance({ from, to })` — affiliate stats over a date
  range.
- `site.requestGame({ gameName, contact })` — public "request a game"
  lead-capture endpoint.
- `site.getSitemapData()` — canonical data source for `sitemap.xml`.
- `checkout.completeWithBalance` response now includes `newBalance` so the UI
  can reconcile balance without a second fetch.

### Changed

- `package.json`: removed stale `module` entry (we ship CJS+ESM as `.js`, not
  `.mjs`).

## 0.13.0 — 2026-04-15

- Canonical slug in catalog responses.
- Admin reply on support conversations.
- Typed payment method responses.
- Review result payload.

## 0.12.0 — 2026-04-08

- Email + password auth for storefront users.
- Catalog pagination.

## 0.9.0 and earlier

See `git log packages/sdk/` for historical changes.
