# Shop Multi-Currency Public Selector TODO

Date: 2026-06-18

Status: Phase 5 planning completed. FX-rate implementation is deferred until business pricing policy is explicitly chosen.

Scope: define the safe execution path for adding public currency selection to the storefront while preserving the Payload ecommerce plugin, IDR-first shop behavior, historical USD data, cart correctness, SEO safety, and existing shop filter/sort behavior.

Related documents:

- `docs/shop-idr-currency-migration-todo.md`
- `docs/shop-commerce-data-readiness-todo.md`
- `docs/shop-fx-rate-automation-debt-todo.md`
- `docs/shop-sidebar-builder-todo.md`

External references:

- Payload Ecommerce Overview: https://payloadcms.com/docs/ecommerce/overview
- Payload Ecommerce Plugin: https://payloadcms.com/docs/ecommerce/plugin
- Google faceted navigation crawling guidance: https://developers.google.com/crawling/docs/faceted-navigation
- Google canonical guidance: https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls

## Current State

- Payload ecommerce plugin version is `@payloadcms/plugin-ecommerce@3.84.1`.
- Current currency config is multi-currency: `IDR`, `USD`, `EUR`, `SGD`, `MYR`.
- Current default storefront currency is `IDR`.
- Product and Variant records have generated price fields for all supported currencies.
- Product and Variant prices have been backfilled for all supported currencies from the legacy USD source using documented bootstrap rates.
- Historical carts, orders, and transactions were not rewritten.
- `EcommerceProvider` receives `ecommerceCurrenciesConfig`.
- `EcommerceProvider` uses `syncLocalStorage={{ key: 'cart-idr' }}` to avoid reusing old browser carts.
- `Price` formats with active ecommerce provider currency and uses `id-ID` locale for IDR.
- `ProductPrice` exists and selects product or variant amount based on the active ecommerce provider currency.
- Product cards, product detail display, carousel labels, and three-item grid labels now use currency-aware display.
- `/shop` query, price filter, and price sort use the parsed URL currency, defaulting to `IDR`.
- Public currency selector is mounted in the header and supports `IDR`, `USD`, `EUR`, `SGD`, and `MYR`.
- On `/shop`, the selector updates the URL `currency` param so server query behavior matches visible currency.
- On product detail pages, the selector updates display currency without changing the product URL.
- Currency preference is intentionally not persisted through cookie or standalone localStorage preference.
- `/shop` remains server-deterministic from URL state alone; missing currency means IDR.
- Shop query coverage is locked for all public selector currencies: `IDR`, `USD`, `EUR`, `SGD`, and `MYR`.
- Product JSON-LD still uses IDR by design.

## Core Constraint

Do not add a public currency selector as a visual-only feature.

Reason:

- If the selector changes display currency but `/shop` price range and price sort still use IDR, user-facing behavior becomes misleading.
- If currency is added to faceted URLs without SEO rules, crawl space expands.
- If cart currency changes while an existing cart exists, line item prices and totals can drift from the selected storefront currency.
- If product structured data changes by client-side selected currency, search engines may see unstable metadata.

## No-Touch Zones

- Do not patch `@payloadcms/plugin-ecommerce`.
- Do not remove USD from supported currencies.
- Do not rewrite historical orders, transactions, or carts in this task.
- Do not enable checkout/payment while payment methods remain intentionally disabled.
- Do not change product taxonomy, sidebar grouping, or Stitch visual polish in this task.
- Do not make faceted/filter URLs indexable.
- Do not mix currency selector implementation with FX-rate admin tooling.
- Do not use `noindex` and `rel=canonical` together as a casual workaround for the same URL.

## SEO Contract

Default indexable shop URL:

- `/shop`

Non-indexable shop URLs:

- Any URL with search, filter, sort, pagination, or currency parameters.
- Examples: `/shop?q=...`, `/shop?segment=...`, `/shop?minPrice=...`, `/shop?currency=USD`.

Canonical rules:

- `/shop` may self-canonical to `/shop`.
- Non-indexable faceted or currency URLs should not assert canonical to `/shop` unless explicitly reviewed again.
- Product detail canonical remains `/products/[slug]`.
- Product JSON-LD remains IDR-first unless a future server-side localized product URL strategy is designed.

Rationale:

- Google warns that faceted navigation query parameters can generate large crawl spaces.
- Google canonical guidance warns against conflicting canonical signals.
- Currency selector should improve user experience without creating unstable or duplicate SEO surfaces.

## Phase 0 - Historical Currency Display Hardening

Goal: fix existing historical currency display gaps before adding any selector.

Known gaps:

- `src/app/(app)/(account)/orders/[id]/page.tsx` renders `order.amount` without `currencyCode={order.currency}`.
- `src/app/(app)/(account)/orders/[id]/page.tsx` renders `ProductItem` without passing `order.currency`.
- Existing historical USD orders can therefore be displayed using the active provider currency instead of their stored order currency.

Candidate files:

- `src/app/(app)/(account)/orders/[id]/page.tsx`
- `src/components/ProductItem/index.tsx` only if props need tightening.
- `src/components/OrderItem/index.tsx` for audit only; it already passes `order.currency`.

Implementation rules:

- Pass `currencyCode={order.currency ?? undefined}` to order total `Price`.
- Pass `currencyCode={order.currency ?? undefined}` to `ProductItem`.
- Keep order amount semantics unchanged.
- Do not alter order records or transaction records.

Acceptance criteria:

- Historical USD order total renders as USD.
- Historical USD order item subtotal renders as USD.
- IDR orders render as IDR.
- TypeScript passes.
- Existing order access behavior is unchanged.

Verification:

- `pnpm exec tsc --noEmit --pretty false`
- HTTP smoke for one existing order if accessible.
- If no accessible order smoke is practical, inspect rendered HTML through a targeted local script or add a small component/unit test later.

Phase 0 execution notes:

- 2026-06-18: Updated order detail rendering to pass `order.currency` into the order total `Price` component and each order item `ProductItem`.
- 2026-06-18: `pnpm exec tsc --noEmit --pretty false` passed.
- 2026-06-18: HTTP smoke passed for an existing guest-accessible historical USD order. Visible order total rendered as USD, visible item subtotal rendered as USD, and the checked order price snippets did not contain IDR/Rupiah output.

## Phase 1 - Formalize Currency URL Contract

Goal: define how selected currency is represented in URLs before UI is added.

Recommended decision:

- Use `currency` query param for shop state, e.g. `/shop?currency=USD`.
- Keep missing or invalid `currency` as `IDR`.
- Keep `currency=IDR` optional; canonical clean URL remains `/shop`.
- Any non-default currency URL is `noindex, follow`.

Candidate files:

- `src/utilities/shopParams.ts`
- `src/lib/shop/queryProducts.ts`
- `src/app/(app)/shop/page.tsx`
- `src/components/shop/ShopProductResults.tsx`
- `tests/int/queryShopProducts.int.spec.ts`

Implementation rules:

- Add `currency?: StoreCurrencyCode` to parsed shop params.
- Normalize invalid currency to default IDR.
- Decide whether `buildShopSearchParams` omits `currency=IDR`.
- Keep the existing noindex policy for any URL with `currency`.
- Do not add selector UI yet.

Acceptance criteria:

- `/shop?currency=USD` parses currency as USD.
- `/shop?currency=INVALID` falls back safely to IDR.
- `/shop?currency=IDR` can normalize away when building params if we choose clean default URLs.
- `/shop?currency=USD` is non-indexable.
- `/shop` remains indexable.

Verification:

- `pnpm exec tsc --noEmit --pretty false`
- `pnpm exec vitest run tests/int/queryShopProducts.int.spec.ts --config ./vitest.config.mts --pool=threads`
- HTTP smoke for `/shop` and `/shop?currency=USD`.

Phase 1 execution notes:

- 2026-06-18: Added normalized `currency` parsing to shop search params. Missing, default, or invalid currency resolves to IDR; non-default supported currencies serialize into the URL.
- 2026-06-18: Kept `currency=IDR` out of generated shop URLs so the clean default URL remains `/shop`.
- 2026-06-18: Preserved non-default currency when clearing active shop filters from product results.
- 2026-06-18: `pnpm exec tsc --noEmit --pretty false` passed.
- 2026-06-18: `pnpm exec vitest run tests/int/queryShopProducts.int.spec.ts --config ./vitest.config.mts --pool=threads` passed with 12 tests.
- 2026-06-18: HTTP smoke passed: `/shop` returned `index, follow` with canonical `/shop`, while `/shop?currency=USD` returned `noindex, follow` with no canonical tag.

## Phase 2 - Currency-Aware Shop Query

Goal: make `/shop` price range and price sort use the selected shop currency when currency is present in URL.

Candidate files:

- `src/lib/shop/queryProducts.ts`
- `src/utilities/pricing.ts`
- `tests/int/queryShopProducts.int.spec.ts`

Implementation rules:

- Use `filters.currency` or normalized default currency in `getVariantPrices`.
- Use the same currency in `getEffectivePrice`.
- Price range filtering must compare amounts in selected currency.
- Price ascending and descending sort must sort by selected currency.
- Product display already uses active provider currency, but server query must become consistent with URL currency.
- Do not make client-only provider currency influence server query.

Acceptance criteria:

- `/shop?currency=USD&sort=price_asc` sorts by USD amounts.
- `/shop?currency=IDR&sort=price_asc` sorts by IDR amounts.
- `/shop?currency=USD&minPrice=...&maxPrice=...` filters by USD amounts.
- Invalid or disabled price fields exclude products safely.
- Existing IDR tests still pass.

Verification:

- Add or update integration tests for USD and IDR price range/sort.
- `pnpm exec tsc --noEmit --pretty false`
- Targeted shop integration tests.

Phase 2 execution notes:

- 2026-06-18: Updated shop effective price lookup so variant prices, product fallback prices, price range filtering, and price sorting use `filters.currency`.
- 2026-06-18: Added selected-currency enabled flags to product and variant price selects so disabled currency prices are excluded safely from price-range results.
- 2026-06-18: Added targeted tests for selected-currency sort, USD price range filtering, and disabled selected-currency prices.
- 2026-06-18: `pnpm exec vitest run tests/int/queryShopProducts.int.spec.ts --config ./vitest.config.mts --pool=threads` passed with 15 tests.
- 2026-06-18: `pnpm exec tsc --noEmit --pretty false` passed.

## Phase 3 - Currency Selector UI

Goal: add public selector after URL and query contracts are safe.

Recommended placement:

- Header utility area, near cart/account controls.
- Optional duplicate in shop sidebar later, but not in first slice.

Candidate files:

- `src/components/Header/index.client.tsx`
- `src/components/CurrencySelector.tsx` or `src/components/shop/CurrencySelector.tsx`
- `src/utilities/shopParams.ts`
- Potentially `src/app/(app)/layout.tsx` if a provider wrapper is needed.

Implementation rules:

- Selector must call plugin `setCurrency`.
- On `/shop`, selector must also update URL `currency` param so server query matches visible currency.
- On product detail pages, selector can be display-only in first slice because product detail price display uses `ProductPrice`.
- If switching currency while cart has items in another currency, do not silently mix currencies.
- Styling should be minimal; no Stitch polish in this phase.

Cart behavior decision required before implementation:

- Option A: Block currency switch when cart has items and show a message.
- Option B: Clear cart on currency switch after explicit confirmation.
- Option C: Maintain separate cart localStorage keys per currency.

Recommended first implementation:

- Use Option A first. It is safest and avoids destructive behavior.

Acceptance criteria:

- Selector shows IDR, USD, EUR, SGD, MYR.
- Selecting USD on `/shop` updates URL to `?currency=USD`.
- Selecting IDR on `/shop` removes or normalizes currency param if default URL policy says so.
- Product card prices reflect selected currency.
- Price range and sort reflect selected currency because Phase 2 is complete.
- If cart has items, unsafe currency switch is blocked or clearly handled.

Verification:

- TypeScript.
- Targeted integration tests if query state changes.
- Playwright smoke for selector on `/shop`.
- Smoke product detail price display after selector.
- Smoke cart with existing item if cart flow is stable enough.

Phase 3 execution notes:

- 2026-06-18: Added `CurrencySelector` using Payload ecommerce `useCurrency` and mounted it in the header action area behind a small Suspense boundary.
- 2026-06-18: On `/shop`, currency changes now update the `currency` URL param, remove `currency=IDR` for the default clean URL, and clear `minPrice`, `maxPrice`, and `page` to avoid carrying price-range state across currencies.
- 2026-06-18: Added a cart safety guard that blocks switching currency when the current cart has items in another currency.
- 2026-06-18: `pnpm exec tsc --noEmit --pretty false` passed.
- 2026-06-18: Playwright smoke passed in an isolated browser context: `/shop` defaulted to IDR, selecting USD produced `/shop?currency=USD`, and selecting IDR returned to `/shop`.
- 2026-06-18: Product detail smoke passed on `/products/no-inventory-product`: selecting USD changed the selector state without changing the product detail URL.
- 2026-06-18: Added a server-query regression test for non-USD selector currencies so EUR, SGD, and MYR price sort behavior is covered.

## Phase 4 - Persistence Strategy

Goal: decide whether currency preference persists across sessions.

Options:

- URL-only for `/shop`, provider state for current session.
- LocalStorage preference for product/detail and non-shop pages.
- Cookie-backed preference for server-aware rendering.

Recommended first implementation:

- URL-only for `/shop`.
- No cookie in first selector slice.
- Optional localStorage for non-shop display after selector is stable.

Reason:

- Cookie-backed currency can cause the same URL to render different server results, which is risky for cache and SEO.
- URL state is explicit and testable for shop filtering and sorting.

Acceptance criteria:

- `/shop?currency=USD` is reproducible from URL alone.
- `/shop` remains IDR by default.
- No hidden server-side currency variation for `/shop` without URL state.

Phase 4 execution notes:

- 2026-06-18: Locked persistence strategy to URL-only for `/shop` and provider session state for non-shop pages.
- 2026-06-18: Confirmed no new currency preference cookie or standalone localStorage key is introduced.
- 2026-06-18: Kept the existing `cart-idr` ecommerce cart storage key only for cart isolation, not as a storefront currency preference.
- 2026-06-18: No code change was needed for this phase because Phase 3 already satisfies the accepted persistence contract.

## Phase 5 - FX Rates And Admin Tooling

Goal: decide whether admins need a tool to manage exchange rates or bulk update currency prices.

Important distinction:

- The current plugin model stores explicit price fields per currency.
- Runtime conversion is not needed for normal storefront display.
- A future FX model should be an admin operational tool, not a request-time price conversion layer.

Local plugin facts:

- Payload ecommerce `currencies` config exposes `defaultCurrency` and `supportedCurrencies`.
- Product and Variant price fields are generated as `priceIn${currency.code}` with matching enabled flags.
- The client ecommerce provider stores selected currency in client state and queries matching price fields.
- Cart and transaction behavior reads stored currency and explicit currency price fields.

Business decision gate:

- Fixed market prices: admins maintain `priceInIDR`, `priceInUSD`, `priceInEUR`, `priceInSGD`, and `priceInMYR` directly. This is the least surprising commerce model.
- API-assisted conversion: current planning direction is for admins to maintain IDR as source of truth, then use a future exchange-rate API to generate USD, EUR, SGD, and MYR price field values after a provider and approval workflow are chosen.
- Automatic runtime conversion: not recommended for this project because it bypasses explicit plugin price fields, can make cart totals drift, and can make storefront output unstable.

Possible future scope:

- `currencyRates` global or collection.
- Manual effective date.
- Admin-only access.
- Batch job to suggest or update product/variant price fields.
- Audit log for rate and price changes.

Recommended future implementation if approved:

- Create an admin-only `currencyRates` collection or global with base currency `IDR`, target currency, rate, effective date, status, and internal notes.
- Add a dry-run price suggestion script or admin endpoint that reads current product/variant IDR prices and calculates proposed target-currency prices.
- Require explicit admin confirmation before writing any `priceIn*` fields.
- Write product and variant price fields in a batch with audit output, never through request-time conversion.
- Preserve historical orders, carts, and transactions exactly as stored.
- Keep product JSON-LD IDR-first unless a future localized product URL strategy is designed.

Acceptance criteria for any future implementation:

- Database backup exists before bulk price updates.
- Dry-run output lists all affected products, variants, old prices, proposed prices, and skipped disabled prices.
- Admin-only access is enforced.
- Product and variant explicit price fields remain the storefront source of truth.
- Cart/order historical currency display remains unchanged.
- `/shop` URL currency contract and SEO policy remain unchanged.

Do not start until:

- Public selector is stable.
- Business confirms whether foreign prices should be fixed market prices or auto-derived from IDR.

Phase 5 planning notes:

- 2026-06-18: Confirmed from local Payload ecommerce plugin source that price fields are generated per configured currency and the storefront provider selects explicit currency fields.
- 2026-06-18: Chose not to implement FX-rate collection, global, endpoint, or batch job in this slice.
- 2026-06-18: Future FX tooling must be admin-operational and explicit-field based; runtime conversion is rejected for stability.
- 2026-06-18: Logged API-assisted FX conversion as deferred technical debt in `docs/shop-fx-rate-automation-debt-todo.md`.
- 2026-06-18: `pnpm exec vitest run tests/int/queryShopProducts.int.spec.ts --config ./vitest.config.mts --pool=threads` passed with 16 tests.
- 2026-06-18: `pnpm exec tsc --noEmit --pretty false` passed.

## Test Matrix

Static:

- `pnpm run generate:types` only when schema changes.
- `pnpm exec tsc --noEmit --pretty false`

Integration:

- `pnpm exec vitest run tests/int/queryShopProducts.int.spec.ts --config ./vitest.config.mts --pool=threads`
- Add tests for currency parsing, selected-currency price range, and selected-currency sort.

Frontend smoke:

- `/shop`
- `/shop?currency=USD`
- `/shop?currency=EUR&sort=price_asc`
- `/shop?currency=SGD&minPrice=...&maxPrice=...`
- `/products/[slug]`
- Existing order detail page with historical USD order if accessible.

SEO smoke:

- `/shop` should be `index, follow`.
- `/shop?currency=USD` should be `noindex, follow`.
- Faceted shop URLs should remain `noindex, follow`.
- Product JSON-LD should remain IDR unless a future server-side localized product URL strategy is approved.

Cart smoke:

- New IDR cart creates `currency: 'IDR'`.
- Existing USD historical order remains readable.
- Currency switch behavior with cart items follows the selected cart policy.

## Execution Rules

- Re-read this document before each phase.
- Re-check `git status --short` before each phase.
- Touch only files listed in the current phase unless a new dependency is discovered and documented first.
- Update this document after each completed phase with verification notes.
- Do not start the next phase until the previous phase acceptance criteria pass.
- Prefer targeted tests for each phase before wider E2E.
- If a phase reveals a conflict with Payload plugin behavior, stop and re-plan instead of patching around it.

## Next Recommended Step

Stop this selector task here. Continue FX-rate automation only through `docs/shop-fx-rate-automation-debt-todo.md`.

Reason:

- Phase 0 through Phase 4 are complete.
- Public selector behavior is stable without hidden persistence.
- Phase 5 planning is complete and intentionally does not create new schema or admin tooling yet.
- Do not start FX-rate admin tooling or checkout/payment behavior until provider choice, rounding policy, fallback behavior, and admin approval workflow are explicitly chosen.
