@shoppexio/storefront-react 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,7 +20,7 @@ import {
20
20
  type AccountSignInPrefill,
21
21
  } from './account-signin-link';
22
22
  import { callCustomerApi } from './customer-account';
23
- import { t } from './i18n';
23
+ import { getActiveLocale, isRightToLeft, t } from './i18n';
24
24
  import {
25
25
  AccountShellProvider,
26
26
  useAccountAvailability,
@@ -216,7 +216,13 @@ export function AccountPage({ frame: Frame, notFound }: AccountPageProps) {
216
216
 
217
217
  return (
218
218
  <Frame>
219
- <section className="sx-account sxa:space-y-8 sxa:py-6 sxa:md:py-8" data-sx-account-session={session.status}>
219
+ {/* The storefront sets only <html lang>; the account sets its own
220
+ direction so an Arabic account reads right to left. */}
221
+ <section
222
+ className="sx-account sxa:space-y-8 sxa:py-6 sxa:md:py-8"
223
+ dir={isRightToLeft(getActiveLocale()) ? 'rtl' : 'ltr'}
224
+ data-sx-account-session={session.status}
225
+ >
220
226
  <div className="sx-account-header sxa:flex sxa:flex-wrap sxa:items-end sxa:justify-between sxa:gap-4">
221
227
  <h1 className="sx-account-title sxa:font-heading sxa:text-h1">
222
228
  {t('account.title')}
@@ -112,6 +112,15 @@ export const WARRANTY_CLAIM_MESSAGE_MAX = 2000;
112
112
  * as omitting it: it stores a blank message the shop then reads as "the buyer
113
113
  * had nothing to say" rather than "the buyer did not write anything".
114
114
  */
115
+ /**
116
+ * Whether a claim is over the producer's limit, measured on what would be sent
117
+ * (trimmed). The form refuses it and names the limit; clipping it with
118
+ * `maxLength` would send a claim the buyer never wrote and report success.
119
+ */
120
+ export function isWarrantyClaimTooLong(input: string): boolean {
121
+ return input.trim().length > WARRANTY_CLAIM_MESSAGE_MAX;
122
+ }
123
+
115
124
  export function warrantyClaimMessage(input: string): string | undefined {
116
125
  const message = input.trim();
117
126
  return message.length === 0 ? undefined : message;
@@ -12,6 +12,7 @@
12
12
  * where the two differ, which is worse than not saying.
13
13
  */
14
14
 
15
+ import { notifyFavoritesChanged } from '@shoppexio/storefront-react';
15
16
  import { useEffect, useState } from 'react';
16
17
  import { Link } from 'react-router-dom';
17
18
  import {
@@ -96,6 +97,8 @@ export function AccountFavorites() {
96
97
  return;
97
98
  }
98
99
 
100
+ // Hearts elsewhere on the page follow the account.
101
+ notifyFavoritesChanged();
99
102
  // Dropped only once the server confirmed it. Removing the card first and
100
103
  // putting it back on failure makes the buyer watch their own action undo
101
104
  // itself, and leaves the two lists disagreeing whenever a retry is slow.
@@ -158,7 +158,8 @@
158
158
  "warrantyClaimSend": "إرسال الطلب",
159
159
  "warrantyClaimSending": "جارٍ الإرسال…",
160
160
  "warrantyClaimDone": "وصل طلبك إلى المتجر. سيردّون عبر البريد الإلكتروني.",
161
- "warrantyClaimFailed": "تعذّر إرسال طلبك."
161
+ "warrantyClaimFailed": "تعذّر إرسال طلبك.",
162
+ "warrantyClaimTooLong": "لا يمكن أن يتجاوز طلبك {max} حرف."
162
163
  },
163
164
  "downloads": {
164
165
  "loading": "جارٍ تحميل تنزيلاتك",
@@ -158,7 +158,8 @@
158
158
  "warrantyClaimSend": "Fall senden",
159
159
  "warrantyClaimSending": "Wird gesendet…",
160
160
  "warrantyClaimDone": "Der Shop hat deinen Fall erhalten. Die Antwort kommt per E-Mail.",
161
- "warrantyClaimFailed": "Dein Fall konnte nicht gesendet werden."
161
+ "warrantyClaimFailed": "Dein Fall konnte nicht gesendet werden.",
162
+ "warrantyClaimTooLong": "Dein Fall darf höchstens {max} Zeichen lang sein."
162
163
  },
163
164
  "downloads": {
164
165
  "loading": "Deine Downloads werden geladen",
@@ -158,7 +158,8 @@
158
158
  "warrantyClaimSend": "Send claim",
159
159
  "warrantyClaimSending": "Sending…",
160
160
  "warrantyClaimDone": "The store has your claim. They reply by email.",
161
- "warrantyClaimFailed": "Your claim could not be sent."
161
+ "warrantyClaimFailed": "Your claim could not be sent.",
162
+ "warrantyClaimTooLong": "Your claim can be at most {max} characters."
162
163
  },
163
164
  "downloads": {
164
165
  "loading": "Loading your downloads",
@@ -158,7 +158,8 @@
158
158
  "warrantyClaimSend": "Enviar reclamación",
159
159
  "warrantyClaimSending": "Enviando…",
160
160
  "warrantyClaimDone": "La tienda ha recibido tu reclamación. Te responderán por correo.",
161
- "warrantyClaimFailed": "No se ha podido enviar tu reclamación."
161
+ "warrantyClaimFailed": "No se ha podido enviar tu reclamación.",
162
+ "warrantyClaimTooLong": "Tu reclamación no puede superar los {max} caracteres."
162
163
  },
163
164
  "downloads": {
164
165
  "loading": "Cargando tus descargas",
@@ -158,7 +158,8 @@
158
158
  "warrantyClaimSend": "Lähetä vaatimus",
159
159
  "warrantyClaimSending": "Lähetetään…",
160
160
  "warrantyClaimDone": "Kauppa sai vaatimuksesi. He vastaavat sähköpostitse.",
161
- "warrantyClaimFailed": "Vaatimustasi ei voitu lähettää."
161
+ "warrantyClaimFailed": "Vaatimustasi ei voitu lähettää.",
162
+ "warrantyClaimTooLong": "Vaatimuksessa saa olla enintään {max} merkkiä."
162
163
  },
163
164
  "downloads": {
164
165
  "loading": "Ladataan latauksiasi",
@@ -158,7 +158,8 @@
158
158
  "warrantyClaimSend": "Envoyer la demande",
159
159
  "warrantyClaimSending": "Envoi en cours…",
160
160
  "warrantyClaimDone": "La boutique a bien reçu votre demande. Elle répond par e-mail.",
161
- "warrantyClaimFailed": "Votre demande n'a pas pu être envoyée."
161
+ "warrantyClaimFailed": "Votre demande n'a pas pu être envoyée.",
162
+ "warrantyClaimTooLong": "Votre demande ne peut pas dépasser {max} caractères."
162
163
  },
163
164
  "downloads": {
164
165
  "loading": "Chargement de vos téléchargements",
@@ -158,7 +158,8 @@
158
158
  "warrantyClaimSend": "दावा भेजें",
159
159
  "warrantyClaimSending": "भेजा जा रहा है…",
160
160
  "warrantyClaimDone": "स्टोर को आपका दावा मिल गया। जवाब ईमेल से आएगा।",
161
- "warrantyClaimFailed": "आपका दावा नहीं भेजा जा सका।"
161
+ "warrantyClaimFailed": "आपका दावा नहीं भेजा जा सका।",
162
+ "warrantyClaimTooLong": "आपका दावा {max} वर्णों से अधिक नहीं हो सकता।"
162
163
  },
163
164
  "downloads": {
164
165
  "loading": "आपके डाउनलोड लोड हो रहे हैं",
@@ -158,7 +158,8 @@
158
158
  "warrantyClaimSend": "Invia richiesta",
159
159
  "warrantyClaimSending": "Invio in corso…",
160
160
  "warrantyClaimDone": "Il negozio ha ricevuto la tua richiesta. Ti rispondono via e-mail.",
161
- "warrantyClaimFailed": "Non è stato possibile inviare la richiesta."
161
+ "warrantyClaimFailed": "Non è stato possibile inviare la richiesta.",
162
+ "warrantyClaimTooLong": "La richiesta non può superare i {max} caratteri."
162
163
  },
163
164
  "downloads": {
164
165
  "loading": "Caricamento dei tuoi download",
@@ -158,7 +158,8 @@
158
158
  "warrantyClaimSend": "Siųsti prašymą",
159
159
  "warrantyClaimSending": "Siunčiama…",
160
160
  "warrantyClaimDone": "Parduotuvė gavo jūsų prašymą. Atsakymas ateis el. paštu.",
161
- "warrantyClaimFailed": "Jūsų prašymo išsiųsti nepavyko."
161
+ "warrantyClaimFailed": "Jūsų prašymo išsiųsti nepavyko.",
162
+ "warrantyClaimTooLong": "Prašymas negali viršyti {max} simbolių."
162
163
  },
163
164
  "downloads": {
164
165
  "loading": "Įkeliami jūsų atsisiuntimai",
@@ -158,7 +158,8 @@
158
158
  "warrantyClaimSend": "Melding versturen",
159
159
  "warrantyClaimSending": "Versturen…",
160
160
  "warrantyClaimDone": "De winkel heeft je melding ontvangen. Ze antwoorden per e-mail.",
161
- "warrantyClaimFailed": "Je melding kon niet worden verstuurd."
161
+ "warrantyClaimFailed": "Je melding kon niet worden verstuurd.",
162
+ "warrantyClaimTooLong": "Je melding mag maximaal {max} tekens bevatten."
162
163
  },
163
164
  "downloads": {
164
165
  "loading": "Je downloads worden geladen",
@@ -158,7 +158,8 @@
158
158
  "warrantyClaimSend": "Wyślij zgłoszenie",
159
159
  "warrantyClaimSending": "Wysyłanie…",
160
160
  "warrantyClaimDone": "Sklep otrzymał Twoje zgłoszenie. Odpowiedź przyjdzie e-mailem.",
161
- "warrantyClaimFailed": "Nie udało się wysłać zgłoszenia."
161
+ "warrantyClaimFailed": "Nie udało się wysłać zgłoszenia.",
162
+ "warrantyClaimTooLong": "Zgłoszenie może mieć najwyżej {max} znaków."
162
163
  },
163
164
  "downloads": {
164
165
  "loading": "Wczytujemy Twoje pliki",
@@ -158,7 +158,8 @@
158
158
  "warrantyClaimSend": "Enviar pedido",
159
159
  "warrantyClaimSending": "A enviar…",
160
160
  "warrantyClaimDone": "A loja recebeu o teu pedido. Respondem por e-mail.",
161
- "warrantyClaimFailed": "Não foi possível enviar o teu pedido."
161
+ "warrantyClaimFailed": "Não foi possível enviar o teu pedido.",
162
+ "warrantyClaimTooLong": "O teu pedido não pode ter mais de {max} caracteres."
162
163
  },
163
164
  "downloads": {
164
165
  "loading": "A carregar as tuas transferências",
@@ -158,7 +158,8 @@
158
158
  "warrantyClaimSend": "Отправить обращение",
159
159
  "warrantyClaimSending": "Отправляем…",
160
160
  "warrantyClaimDone": "Магазин получил ваше обращение. Ответ придёт на почту.",
161
- "warrantyClaimFailed": "Не удалось отправить обращение."
161
+ "warrantyClaimFailed": "Не удалось отправить обращение.",
162
+ "warrantyClaimTooLong": "Обращение не должно превышать {max} символов."
162
163
  },
163
164
  "downloads": {
164
165
  "loading": "Загружаем ваши файлы",
@@ -158,7 +158,8 @@
158
158
  "warrantyClaimSend": "Talebi gönder",
159
159
  "warrantyClaimSending": "Gönderiliyor…",
160
160
  "warrantyClaimDone": "Mağaza talebinizi aldı. E-posta ile yanıt verirler.",
161
- "warrantyClaimFailed": "Talebiniz gönderilemedi."
161
+ "warrantyClaimFailed": "Talebiniz gönderilemedi.",
162
+ "warrantyClaimTooLong": "Talebiniz en fazla {max} karakter olabilir."
162
163
  },
163
164
  "downloads": {
164
165
  "loading": "İndirmeleriniz yükleniyor",
@@ -158,7 +158,8 @@
158
158
  "warrantyClaimSend": "Надіслати звернення",
159
159
  "warrantyClaimSending": "Надсилаємо…",
160
160
  "warrantyClaimDone": "Магазин отримав ваше звернення. Відповідь надійде на пошту.",
161
- "warrantyClaimFailed": "Не вдалося надіслати звернення."
161
+ "warrantyClaimFailed": "Не вдалося надіслати звернення.",
162
+ "warrantyClaimTooLong": "Звернення не має перевищувати {max} символів."
162
163
  },
163
164
  "downloads": {
164
165
  "loading": "Завантажуємо ваші файли",
@@ -158,7 +158,8 @@
158
158
  "warrantyClaimSend": "提交申请",
159
159
  "warrantyClaimSending": "提交中…",
160
160
  "warrantyClaimDone": "商店已收到你的申请,他们会通过邮件回复。",
161
- "warrantyClaimFailed": "申请提交失败。"
161
+ "warrantyClaimFailed": "申请提交失败。",
162
+ "warrantyClaimTooLong": "申请内容不能超过 {max} 个字符。"
162
163
  },
163
164
  "downloads": {
164
165
  "loading": "正在加载你的下载内容",
@@ -18,6 +18,7 @@ import { Link } from 'react-router-dom';
18
18
  import { paymentMethodLabel } from './account-payment-method';
19
19
  import { accountOrderHref, accountTabHref } from '@shoppexio/storefront-react';
20
20
  import {
21
+ isWarrantyClaimTooLong,
21
22
  WARRANTY_CLAIM_MESSAGE_MAX,
22
23
  canClaimWarranty,
23
24
  warrantiesForOrder,
@@ -525,6 +526,10 @@ function WarrantyRow({
525
526
  const fieldId = `warranty-claim-${warranty.uniqid}`;
526
527
 
527
528
  const send = async () => {
529
+ if (isWarrantyClaimTooLong(message)) {
530
+ setError(t('orders.warrantyClaimTooLong', { max: WARRANTY_CLAIM_MESSAGE_MAX }));
531
+ return;
532
+ }
528
533
  setSending(true);
529
534
  setError(null);
530
535
  const response = await callCustomerApi((sdk) => sdk.claimWarranty(
@@ -589,7 +594,7 @@ function WarrantyRow({
589
594
  rows={3}
590
595
  value={message}
591
596
  disabled={sending}
592
- maxLength={WARRANTY_CLAIM_MESSAGE_MAX}
597
+ aria-invalid={error !== null || undefined}
593
598
  onChange={(event) => {
594
599
  setMessage(event.target.value);
595
600
  setError(null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shoppexio/storefront-react",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "React hooks, models and a customer account for Shoppex code storefronts",
5
5
  "type": "module",
6
6
  "homepage": "https://docs.shoppex.io/storefront/code-storefront-development",
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/account-route.ts","../src/shoppex.ts"],"sourcesContent":["/**\n * Which account surface a URL names.\n *\n * The account lives under `/dashboard`, and the panel is a PATH SEGMENT:\n * `/dashboard/orders`, `/dashboard/orders/<uniqid>`, `/dashboard/settings`.\n * `/dashboard` itself is the overview. This is the platform's route shape and it\n * is identical on both storefront lanes (ADR-0064), so a theme that invents its\n * own account paths breaks links it does not own.\n *\n * The older query spelling — `?tab=<tab>` and `?order=<id>` — is still ACCEPTED,\n * because it sits in order emails already sent and in the platform's post-login\n * redirect. It is read as input and never written: a URL carrying it is\n * canonicalized to the path form, by replacement rather than a push, so the back\n * button does not bounce the buyer between the two spellings. The path always\n * wins when a URL carries both.\n *\n * `/customer-portal` is the oldest spelling of the same route and folds in with\n * its tail and query intact.\n *\n * A locale prefix (`/de/dashboard/orders`) is part of the served URL on a\n * localized storefront. The router's basename normally strips it before any of\n * this sees it, so the parser accepts a pathname with or without one and\n * canonicalization keeps whatever prefix it was given.\n *\n * Everything here is pure string work, so `tests/account-route.test.ts` can pin\n * the parts email links depend on.\n */\n\nexport const ACCOUNT_PATH = '/dashboard';\n\n/** The older spelling of the same route. Folded, never served separately. */\nexport const ACCOUNT_PATH_ALIAS = '/customer-portal';\n\nconst ACCOUNT_SEGMENT = 'dashboard';\nconst ACCOUNT_ALIAS_SEGMENT = 'customer-portal';\n\n/**\n * Every tab the Shoppex account surface defines, in navigation order.\n *\n * Listed in full so a link to a real platform tab can be told apart from a typo\n * and answered differently.\n */\nexport const ACCOUNT_TABS = [\n 'overview',\n 'orders',\n 'downloads',\n 'subscriptions',\n 'rewards',\n 'referrals',\n 'favorites',\n 'support',\n 'settings',\n 'reseller',\n] as const;\n\nexport type AccountTab = (typeof ACCOUNT_TABS)[number];\n\n/**\n * The catalog key a tab's label lives under.\n *\n * A key rather than a string: this file is imported by the router, the\n * navigation and the \"not part of this store\" notice, and each of them needs\n * the label in the locale the edge served — see `account/i18n.ts`.\n */\nexport function accountTabLabelKey(tab: AccountTab): string {\n return `account.tabs.${tab}`;\n}\n\n/**\n * The tabs the account renders.\n *\n * All ten platform tabs. Adding a panel means adding its tab here AND\n * rendering it, in the same change: a tab listed without a panel would navigate\n * to the \"not built\" notice, which is worse than not offering it.\n *\n * Reseller payloads are locally narrowed because shared contracts do not cover\n * them, but the worker now allowlists their SDK routes.\n */\nexport const IMPLEMENTED_ACCOUNT_TABS = [\n 'overview',\n 'orders',\n 'downloads',\n 'subscriptions',\n 'rewards',\n 'referrals',\n 'favorites',\n 'support',\n 'settings',\n 'reseller',\n] as const;\n\nexport type ImplementedAccountTab = (typeof IMPLEMENTED_ACCOUNT_TABS)[number];\n\nexport const DEFAULT_ACCOUNT_TAB: ImplementedAccountTab = 'overview';\n\n/**\n * What the account page should show.\n *\n * `not-built` and `unknown-tab` are separate on purpose. The first is a real\n * platform tab this storefront chose not to render, the second is a value that\n * names no tab at all. Collapsing them into \"show the overview instead\" would\n * hide a broken link behind a page that looks like it worked.\n */\nexport type AccountView =\n | { kind: 'panel'; tab: ImplementedAccountTab }\n | { kind: 'order'; orderId: string }\n | { kind: 'not-built'; tab: AccountTab }\n | { kind: 'unknown-tab' };\n\nexport function isAccountTab(value: string): value is AccountTab {\n return (ACCOUNT_TABS as readonly string[]).includes(value);\n}\n\nexport function isImplementedAccountTab(value: string): value is ImplementedAccountTab {\n return (IMPLEMENTED_ACCOUNT_TABS as readonly string[]).includes(value);\n}\n\n/** A two-letter locale segment, the only prefix the storefront edge serves. */\nconst LOCALE_SEGMENT = /^[a-z]{2}$/i;\n\nfunction isAccountSegment(segment: string | undefined): boolean {\n return segment === ACCOUNT_SEGMENT || segment === ACCOUNT_ALIAS_SEGMENT;\n}\n\nexport interface AccountRouteParts {\n /** What preceded `/dashboard`: '' or a locale prefix such as '/de'. */\n prefix: string;\n /** Whether the URL used the retired `/customer-portal` spelling. */\n alias: boolean;\n /** The segments after `/dashboard`, decoded. */\n tail: string[];\n}\n\n/**\n * Split an account URL into its prefix and the segments below `/dashboard`.\n *\n * Null when the path is not the account route at all. The account segment is\n * only recognised first or directly after a locale segment — searching for it\n * anywhere would make `/product/dashboard-pro` an account URL.\n */\nexport function parseAccountPath(pathname: string): AccountRouteParts | null {\n const segments = pathname.split('/').filter((segment) => segment.length > 0);\n\n let index = -1;\n if (isAccountSegment(segments[0])) index = 0;\n else if (segments[0] && LOCALE_SEGMENT.test(segments[0]) && isAccountSegment(segments[1])) index = 1;\n if (index === -1) return null;\n\n return {\n prefix: index === 0 ? '' : `/${segments[0]}`,\n alias: segments[index] === ACCOUNT_ALIAS_SEGMENT,\n tail: segments.slice(index + 1).map(decodeSegment),\n };\n}\n\nfunction decodeSegment(segment: string): string {\n try {\n return decodeURIComponent(segment);\n } catch {\n // A malformed escape is not a segment this route can name; keeping it raw\n // lets it fall through to `unknown-tab` instead of throwing on a link a\n // buyer merely mistyped.\n return segment;\n }\n}\n\n/**\n * What to render for a URL.\n *\n * The path is read first and the query only when the path names no panel, so a\n * canonical URL is never reinterpreted by a stale parameter riding along on it.\n */\nexport function resolveAccountView(pathname: string, search: URLSearchParams): AccountView {\n const parts = parseAccountPath(pathname);\n if (!parts) return { kind: 'unknown-tab' };\n\n if (parts.tail.length === 0) return resolveLegacyQueryView(search);\n\n const [first, second, ...rest] = parts.tail;\n if (rest.length > 0) return { kind: 'unknown-tab' };\n if (!first) return resolveLegacyQueryView(search);\n\n if (second !== undefined) {\n // The only two-segment surface: one order inside the orders panel.\n if (first !== 'orders' || second.length === 0) return { kind: 'unknown-tab' };\n return { kind: 'order', orderId: second };\n }\n\n return viewForTab(first);\n}\n\n/**\n * The retired spelling, read as input only.\n *\n * `?order=` belongs to the orders panel, so it is honoured when the query asks\n * for the orders tab or names no tab at all. An explicit different tab wins:\n * `?tab=settings&order=x` is a request for settings, and answering it with an\n * order would be the page deciding it knows better.\n */\nfunction resolveLegacyQueryView(search: URLSearchParams): AccountView {\n const rawTab = (search.get('tab') ?? '').trim();\n // `invoice` is the portal's spelling and it is already out in the world:\n // notification mails carry `?tab=orders&invoice=<uniqid>` and cannot be\n // edited after the fact. Reading only `order` would open the list and\n // canonicalize the invoice away.\n const orderId = (search.get('order') ?? search.get('invoice') ?? '').trim();\n\n if (rawTab && !isAccountTab(rawTab)) return { kind: 'unknown-tab' };\n if (orderId && (rawTab === '' || rawTab === 'orders')) return { kind: 'order', orderId };\n if (rawTab === '') return { kind: 'panel', tab: DEFAULT_ACCOUNT_TAB };\n\n return viewForTab(rawTab);\n}\n\nfunction viewForTab(value: string): AccountView {\n if (!isAccountTab(value)) return { kind: 'unknown-tab' };\n if (!isImplementedAccountTab(value)) return { kind: 'not-built', tab: value };\n return { kind: 'panel', tab: value };\n}\n\n/** `/dashboard/<tab>`. The overview is the bare path, not `/dashboard/overview`. */\nexport function accountTabHref(tab: AccountTab): string {\n if (tab === DEFAULT_ACCOUNT_TAB) return ACCOUNT_PATH;\n return `${ACCOUNT_PATH}/${tab}`;\n}\n\n/** `/dashboard/orders/<id>`, the one order-detail spelling. */\nexport function accountOrderHref(orderId: string): string {\n return `${ACCOUNT_PATH}/orders/${encodeURIComponent(orderId)}`;\n}\n\n/**\n * The canonical path for a URL that is not written the canonical way, or null\n * when it already is.\n *\n * Three URLs need rewriting: the `/customer-portal` alias, a `?tab=`/`?order=`\n * query, and the redundant `/dashboard/overview`. The caller replaces rather\n * than pushes — a push would put the retired spelling in the history and send\n * the back button straight back into it.\n *\n * A URL naming nothing real (`?tab=orderz`, `/dashboard/nope`) has no canonical\n * form and keeps the address it came with: rewriting it to the overview would\n * turn a broken link into one that looks like it worked.\n *\n * The prefix the URL came with is preserved, so a buyer on `/de/dashboard` is\n * not quietly moved out of their locale.\n */\nexport function canonicalAccountPath(pathname: string, search: URLSearchParams): string | null {\n const parts = parseAccountPath(pathname);\n if (!parts) return null;\n\n const view = resolveAccountView(pathname, search);\n const canonical = canonicalPathForView(view);\n if (canonical === null) return null;\n\n const current = `${parts.prefix}${ACCOUNT_PATH}${parts.tail.map((segment) => `/${encodeURIComponent(segment)}`).join('')}`;\n const target = `${parts.prefix}${canonical}`;\n\n // Already canonical AND carrying nothing to strip: leave the URL alone.\n if (!parts.alias && current === target && search.get('tab') === null && search.get('order') === null && search.get('invoice') === null) {\n return null;\n }\n\n return target;\n}\n\nfunction canonicalPathForView(view: AccountView): string | null {\n switch (view.kind) {\n case 'panel':\n return accountTabHref(view.tab);\n case 'order':\n return accountOrderHref(view.orderId);\n case 'not-built':\n return accountTabHref(view.tab);\n case 'unknown-tab':\n return null;\n }\n}\n\n\n/**\n * The storefront routes the account links to outside itself: the catalog\n * behind every \"browse the store\" action, and a product page per favorite.\n * React Router does not check a `to`, so a host that does not route one of\n * these answers the account's own buttons with its not-found page. A host\n * checks its router against this list (ForkMe: `tests/account-routes.test.ts`).\n */\nexport const ACCOUNT_STOREFRONT_ROUTES = ['/all-products', '/product/:slug'] as const;\n\n/**\n * The artifact capabilities the account relies on. A storefront that mounts\n * the account lists each of them in its `public/shoppex.capabilities.json`;\n * the storefront worker reads that file from the artifact and only then hands\n * the account what the capability promises. `account-reseller-invite-cookie`\n * is pinned to `RESELLER_INVITE_ARTIFACT_CAPABILITY` in\n * `workers/storefront/src/reseller-invite-link.ts`: without it the worker\n * leaves a reseller invitation in the URL for that build.\n */\nexport const ACCOUNT_ARTIFACT_CAPABILITIES = ['account-reseller-invite-cookie'] as const;\n","import type { ShoppexSdk } from './sdk';\n\n/**\n * Access to the Shoppex Storefront SDK global (loaded in index.html from\n * cdn.shoppex.io) plus its one-time initialization from the bootstrap globals\n * the Shoppex worker injects next to __SHOPPEX_INITIAL__:\n * shopSlug, shopId, apiBaseUrl, defaultCurrency (+ optional checkoutBaseUrl).\n *\n * Both the script and the globals are hard requirements — a storefront without\n * them cannot price, cart, or check out, so we fail fast instead of rendering\n * a broken shop.\n */\n\nlet initialized = false;\n\n/**\n * The sentence that names the CAUSE rather than the symptom.\n *\n * A missing bootstrap global and a missing `__SHOPPEX_INITIAL__` payload are\n * the same fault seen from two sides: the page is not being served by the\n * worker that injects both. `App.tsx`'s configuration screen shows this to the\n * merchant, so it lives here once instead of being written twice.\n */\nexport const SERVING_CONTEXT_HINT =\n 'This storefront must be served by the Shoppex storefront (or preview) worker.';\n\nfunction requiredGlobal(name: 'shopSlug' | 'shopId' | 'apiBaseUrl' | 'defaultCurrency'): string {\n const value = window[name];\n if (typeof value !== 'string' || !value.trim()) {\n throw new Error(`Shoppex bootstrap global \"${name}\" is missing. ${SERVING_CONTEXT_HINT}`);\n }\n return value.trim();\n}\n\nexport function getShoppex(): ShoppexSdk {\n const sdk = window.shoppex;\n if (!sdk) {\n throw new Error(\n 'The Shoppex SDK script did not load (window.shoppex is missing). Check the <script src=\"https://cdn.shoppex.io/sdk/...\"> tag in index.html.',\n );\n }\n if (!initialized) {\n const storeSlug = requiredGlobal('shopSlug');\n if (!sdk.isInitialized() || sdk.getConfig().storeSlug !== storeSlug) {\n const checkoutBaseUrl = typeof window.checkoutBaseUrl === 'string' && window.checkoutBaseUrl.trim()\n ? window.checkoutBaseUrl.trim()\n : undefined;\n sdk.init(storeSlug, {\n shopId: requiredGlobal('shopId'),\n apiBaseUrl: requiredGlobal('apiBaseUrl'),\n currency: requiredGlobal('defaultCurrency'),\n ...(checkoutBaseUrl ? { checkoutBaseUrl } : {}),\n });\n }\n // Last-click referral capture. checkout() reads this stored code by itself\n // whenever the cart does not carry an affiliate code of its own, so this\n // single call is what makes `?ref=<code>` links actually pay out. A code the\n // buyer TYPED and the quote classified as a referral takes precedence — see\n // `cart.tsx` → `checkout`.\n // A failed capture must never block the storefront from rendering.\n void sdk.captureAffiliateFromUrl().catch(() => undefined);\n initialized = true;\n }\n return sdk;\n}\n\n/**\n * The currency the prices on this page are in.\n *\n * The edge injected it alongside the catalog, from the same payload the prices\n * came from, so it is right whether the buyer asked for a currency, whether\n * their country selected one, or neither.\n *\n * Pass it to every quote and checkout. Left out, the SDK falls back to reading\n * `?currency=` off `window.location` — which survives the first page load and\n * nothing after it, because client-side navigation drops the query. The cart\n * then quoted in the shop's geo-resolved currency while the page went on\n * showing the one the buyer asked for.\n */\nexport function getBuyerCurrency(): string {\n return requiredGlobal('defaultCurrency');\n}\n"],"mappings":"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","names":[]}