@usequeek/theme-kit 0.1.23 → 0.1.24

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.
Files changed (100) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/LICENSE +1 -1
  3. package/README.md +164 -21
  4. package/api/collections.ts +14 -1
  5. package/api/orders.ts +15 -1
  6. package/apps/follow-card.tsx +5 -0
  7. package/apps/subscribe-form.tsx +2 -2
  8. package/components/auth/auth-flow.tsx +12 -0
  9. package/components/cart/cart-panel-controller.tsx +13 -0
  10. package/components/checkout/default-checkout-shell.tsx +5 -0
  11. package/components/checkout/policy-links.tsx +4 -0
  12. package/components/image.tsx +10 -0
  13. package/components/language-switcher.tsx +1 -0
  14. package/components/powered-by-queek.tsx +4 -3
  15. package/components/product-media-frame.tsx +5 -0
  16. package/components/product-metafields.tsx +9 -0
  17. package/components/variant-picker.css +4 -7
  18. package/edit-mode.tsx +4 -3
  19. package/hooks/use-address-autocomplete.ts +9 -0
  20. package/hooks/use-auth-flow.tsx +7 -12
  21. package/hooks/use-auth.ts +10 -0
  22. package/hooks/use-cart.ts +12 -0
  23. package/hooks/use-categories.ts +16 -2
  24. package/hooks/use-href.ts +9 -4
  25. package/hooks/use-posts.ts +16 -8
  26. package/hooks/use-product-detail.ts +25 -11
  27. package/hooks/use-product-questions.ts +16 -3
  28. package/hooks/use-products.ts +44 -22
  29. package/hooks/use-related-products.ts +17 -5
  30. package/hooks/use-resolve-coordinates.ts +9 -0
  31. package/hooks/use-reviews.ts +16 -2
  32. package/hooks/use-search.ts +10 -0
  33. package/hooks/use-shop-cart.ts +8 -0
  34. package/hooks/use-shop.ts +19 -6
  35. package/hooks/use-variant-selection.ts +23 -19
  36. package/index.ts +10 -11
  37. package/locales/en.default.json +4 -0
  38. package/navigation.tsx +2 -0
  39. package/package.json +25 -4
  40. package/page-renderer.tsx +11 -0
  41. package/provider.tsx +29 -3
  42. package/sdk/client.ts +183 -36
  43. package/sdk/error-handler.ts +17 -9
  44. package/sdk/queek-client.ts +11 -1
  45. package/sdk/server-store-client.ts +2 -2
  46. package/shared-blocks/embed.tsx +7 -3
  47. package/shared-blocks/manifest.ts +1 -1
  48. package/shared-blocks/product-qa.tsx +1 -1
  49. package/stores/address-modal-store.ts +1 -0
  50. package/stores/auth-modal-store.ts +1 -0
  51. package/stores/cart-panel-store.ts +1 -0
  52. package/stores/cart-store.ts +12 -7
  53. package/stores/checkout-store.ts +4 -4
  54. package/stores/location-store.ts +5 -0
  55. package/stores/orders-sheet-store.ts +1 -0
  56. package/stores/product-modal-store.ts +1 -0
  57. package/stores/search-modal-store.ts +1 -0
  58. package/stores/user-store.ts +1 -1
  59. package/strings/pseudo-locale.ts +1 -1
  60. package/theme-context.tsx +11 -1
  61. package/theme-mount.tsx +14 -0
  62. package/utils/format.ts +10 -1
  63. package/utils/menu-link.ts +8 -0
  64. package/utils/to-error.ts +4 -0
  65. package/utils/vendor-host.ts +1 -2
  66. package/scripts/verify-package.ts +0 -102
  67. package/tests/apps.test.tsx +0 -33
  68. package/tests/auth-email-errors.test.tsx +0 -55
  69. package/tests/currency-contract.test.ts +0 -85
  70. package/tests/default-metaobject-page.test.tsx +0 -56
  71. package/tests/display-format.test.ts +0 -403
  72. package/tests/fixtures/external-theme.tsx +0 -90
  73. package/tests/image-loading.test.tsx +0 -31
  74. package/tests/language-menu.test.tsx +0 -598
  75. package/tests/language-switcher.test.tsx +0 -350
  76. package/tests/list-pagination.test.tsx +0 -71
  77. package/tests/locale-switch.test.ts +0 -197
  78. package/tests/locale-transport.test.tsx +0 -321
  79. package/tests/manifest-parity.test.ts +0 -54
  80. package/tests/markdown-render.test.ts +0 -46
  81. package/tests/menu-placement.test.ts +0 -63
  82. package/tests/metaobjects-block.test.tsx +0 -70
  83. package/tests/navigation.test.tsx +0 -30
  84. package/tests/orders-walk.test.ts +0 -48
  85. package/tests/page-renderer-signature.test.tsx +0 -42
  86. package/tests/powered-by-queek.test.tsx +0 -65
  87. package/tests/powered-by-url.test.ts +0 -49
  88. package/tests/product-images.test.ts +0 -242
  89. package/tests/product-metafields.test.tsx +0 -154
  90. package/tests/public-surface.test.ts +0 -35
  91. package/tests/shop-query.test.ts +0 -64
  92. package/tests/shopper-chrome-strings.test.tsx +0 -271
  93. package/tests/storefront-client-scope.test.ts +0 -43
  94. package/tests/theme-hook-placement.test.tsx +0 -179
  95. package/tests/theme-strings-client.test.tsx +0 -310
  96. package/tests/theme-strings-pseudo.test.tsx +0 -171
  97. package/tests/theme-strings-typed.test.tsx +0 -97
  98. package/tests/theme-strings.test.tsx +0 -401
  99. package/tests/theme-typed-keys.fixture.ts +0 -76
  100. package/tests/use-shop-prefetch.test.tsx +0 -87
@@ -6,8 +6,15 @@ import { useUserStore } from '../stores/user-store';
6
6
  import { isReauthSuppressed } from './reauth-guard';
7
7
 
8
8
  /**
9
- * Returns true if the caught error is an unauthenticated (401) error
10
- * from the API or SDK token layer.
9
+ * True when the error is an unauthenticated (401) failure from the API or the
10
+ * SDK token layer: the customer session is missing, expired or revoked.
11
+ *
12
+ * @example
13
+ * try {
14
+ * await client.get('/client/orders');
15
+ * } catch (error) {
16
+ * if (isUnauthenticatedError(error)) triggerReauth('orders');
17
+ * }
11
18
  */
12
19
  export function isUnauthenticatedError(err: unknown): boolean {
13
20
  if (err instanceof QueekSdkError) {
@@ -18,19 +25,20 @@ export function isUnauthenticatedError(err: unknown): boolean {
18
25
  }
19
26
 
20
27
  /**
21
- * Reset local user state and open the auth modal. Safe to call
22
- * repeatedly (idempotent). Called by the SDK's onUnauthenticated
23
- * callback (configured in queek-client.ts).
28
+ * Clear the local customer session and open the sign-in modal. Safe to call
29
+ * repeatedly. The SDK's `onUnauthenticated` callback (configured in
30
+ * `queek-client.ts`) calls it, so most code never needs to.
31
+ *
32
+ * `reason` (usually the request path) is logged to help trace which call
33
+ * lost the session. It must not contain tokens.
24
34
  */
25
35
  export function triggerReauth(reason?: string): void {
26
36
  if (isReauthSuppressed()) {
27
-
28
- console.warn(`[queek-sdk] unauthenticated (${reason ?? 'unknown'}) — suppressed, an auth exchange is in flight`);
37
+ console.warn(`[theme-kit] a request lost its session (${reason ?? 'unknown request'}), but sign-in is in progress, so the customer was not signed out.`);
29
38
  return;
30
39
  }
31
40
  if (reason) {
32
-
33
- console.warn(`[queek-sdk] unauthenticated → reauth (${reason})`);
41
+ console.warn(`[theme-kit] session expired on ${reason}: signing the customer out and opening sign-in.`);
34
42
  }
35
43
  useUserStore.getState().logout();
36
44
  useAuthModalStore.getState().open('login');
@@ -75,6 +75,16 @@ export function getLastRequestId(): string | null {
75
75
  let cachedClient: QueekClientInstance | null = null;
76
76
  let cachedSlug: string | null = null;
77
77
 
78
+ /**
79
+ * The Queek customer SDK client for this store, created once and reused.
80
+ * Browser only: it keeps the customer's tokens in `localStorage` and sends
81
+ * every request to the same-origin `/api/v1`. A 401 signs the customer out and
82
+ * opens the sign-in modal (see `triggerReauth`).
83
+ *
84
+ * @example
85
+ * const client = getQueekClient(vendor.slug);
86
+ * const orders = await client.get('/client/store/orders');
87
+ */
78
88
  export function getQueekClient(vendorSlug?: string): QueekClientInstance {
79
89
  const slug = vendorSlug ?? null;
80
90
 
@@ -99,7 +109,7 @@ export function getQueekClient(vendorSlug?: string): QueekClientInstance {
99
109
  return cachedClient;
100
110
  }
101
111
 
102
- /** Read the current access token for one-off requests outside the SDK (e.g. inbox pairing-code mint). */
112
+ /** The customer's current access token, or null when signed out or on the server. For one-off requests outside the SDK; never log it. */
103
113
  export function getAccessToken(): string | null {
104
114
  return typeof window !== 'undefined' ? localStorage.getItem(ACCESS_KEY) : null;
105
115
  }
@@ -120,8 +120,8 @@ function resolveCachePolicy(vendorSlug: string, path: string): RequestCachePolic
120
120
 
121
121
  /**
122
122
  * True when this render is the merchant editor's preview iframe, which loads the
123
- * storefront with `?preview=true&id=<vendorId>`. Set by `proxy.ts`, which is the only
124
- * place that can see query params before the fetch layer runs.
123
+ * storefront with `?preview=true&id=<vendorId>`. Set by the host's request proxy, the
124
+ * only place that sees query params before the fetch layer runs.
125
125
  *
126
126
  * A preview render MUST bypass the cache outright rather than rely on the API's
127
127
  * invalidation call. Invalidation is a distributed handshake — it needs two env vars
@@ -2,12 +2,16 @@
2
2
 
3
3
  import type { JSX } from 'react';
4
4
  import type { EmbedBlockData } from '../types/block';
5
+ import { useThemeStrings } from '../provider';
5
6
 
6
7
  /**
7
8
  * Framework-owned embed block. Renders an iframe for any external URL
8
- * (Instagram, Spotify, Twitter, custom). Themes style via `.core-block-embed`.
9
+ * (a social post, an audio player, any page that allows embedding). Its words come
10
+ * from the theme strings (`embed.*`). Themes style via `.core-block-embed`.
9
11
  */
10
12
  export function CoreEmbedBlock({ url }: EmbedBlockData): JSX.Element {
13
+ const t = useThemeStrings();
14
+
11
15
  if (!url) {
12
16
  return (
13
17
  <section className="core-block core-block-embed">
@@ -17,7 +21,7 @@ export function CoreEmbedBlock({ url }: EmbedBlockData): JSX.Element {
17
21
  <polyline points="16 18 22 12 16 6" />
18
22
  <polyline points="8 6 2 12 8 18" />
19
23
  </svg>
20
- <span>Paste an Instagram, Spotify, or iframe URL</span>
24
+ <span>{t('embed.placeholder')}</span>
21
25
  </div>
22
26
  </div>
23
27
  </section>
@@ -30,7 +34,7 @@ export function CoreEmbedBlock({ url }: EmbedBlockData): JSX.Element {
30
34
  <iframe
31
35
  className="core-block-embed__frame"
32
36
  src={url}
33
- title="Embedded content"
37
+ title={t('embed.title')}
34
38
  allowFullScreen
35
39
  loading="lazy"
36
40
  referrerPolicy="no-referrer-when-downgrade"
@@ -20,7 +20,7 @@ export const FRAMEWORK_BLOCK_MANIFEST: Record<string, ThemeManifestVariant[]> =
20
20
  { id: 'default', label: 'Quote', default: true, purpose: 'Pull-quote with optional attribution' },
21
21
  ],
22
22
  embed: [
23
- { id: 'default', label: 'Embed', default: true, purpose: 'Instagram, Spotify, Twitter, or any iframe URL' },
23
+ { id: 'default', label: 'Embed', default: true, purpose: 'Any page that allows embedding, from its URL' },
24
24
  ],
25
25
  video: [
26
26
  { id: 'default', label: 'Video', default: true, purpose: 'Embed a YouTube, Vimeo, or self-hosted video' },
@@ -80,7 +80,7 @@ function AskQuestionPanel({ productSlug }: { productSlug: string }): JSX.Element
80
80
  setError(null);
81
81
  try {
82
82
  const client = getQueekClient(vendor.slug ?? undefined);
83
- await client.post(`/client/store/products/${productSlug}/questions`, { question: trimmed });
83
+ await client.post(`/client/store/products/${encodeURIComponent(productSlug)}/questions`, { question: trimmed });
84
84
  setOpen(false);
85
85
  setQuestion('');
86
86
  setSubmitted(true);
@@ -8,6 +8,7 @@ interface AddressModalState {
8
8
  close: () => void;
9
9
  }
10
10
 
11
+ /** Open and close state for the delivery address modal. Call `useAddressModalStore.getState().open()` from anywhere, or select from it in a component. */
11
12
  export const useAddressModalStore = create<AddressModalState>()((set) => ({
12
13
  isOpen: false,
13
14
  open: () => set({ isOpen: true }),
@@ -9,6 +9,7 @@ interface AuthModalState {
9
9
  close: () => void;
10
10
  }
11
11
 
12
+ /** Open and close state for the sign-in modal. `open('signup')` starts on the sign-up step. Most code uses `useAuth()` instead. */
12
13
  export const useAuthModalStore = create<AuthModalState>()((set) => ({
13
14
  isOpen: false,
14
15
  mode: 'login',
@@ -9,6 +9,7 @@ interface CartPanelState {
9
9
  toggle: () => void;
10
10
  }
11
11
 
12
+ /** Open and close state for the cart panel (drawer). Call `useCartPanelStore.getState().open()` from anywhere, or select from it in a component. */
12
13
  export const useCartPanelStore = create<CartPanelState>()((set) => ({
13
14
  isOpen: false,
14
15
  open: () => set({ isOpen: true }),
@@ -12,11 +12,11 @@ import { productRequiresVariant } from '../utils/match-variant';
12
12
 
13
13
  interface CartState {
14
14
  items: CartItem[];
15
- /** Set once per vendor mount (see vendor-shell.tsx) — the store has no
15
+ /** Set once per vendor mount by the storefront host. The store has no
16
16
  * React context, so this is how it learns the active vendor's currency
17
- * for GA4 event values. The initial value is the platform default as a
18
- * last resort only; vendor-shell overwrites it with the vendor's currency
19
- * on mount. List-level `meta.currency` is never a source. */
17
+ * for analytics event values. The initial value is the platform default as
18
+ * a last resort only; the host overwrites it with the vendor's currency on
19
+ * mount. List-level `meta.currency` is never a source. */
20
20
  currency: string;
21
21
  setCurrency: (currency: string) => void;
22
22
  addProduct: (product: Product, quantity?: number, variant?: ProductVariant | null) => void;
@@ -25,7 +25,7 @@ interface CartState {
25
25
  setQuantity: (id: string, shopId: string, quantity: number) => void;
26
26
  clearShopCart: (shopId: string) => void;
27
27
  /** Replaces a shop's items with the server-persisted cart — only called when
28
- * the local cart for that shop is empty (see use-cart-sync.ts), so this
28
+ * the local cart for that shop is empty (by the storefront host), so this
29
29
  * never clobbers what the customer is actively looking at. */
30
30
  adoptServerItems: (shopId: string, items: CartItem[]) => void;
31
31
  }
@@ -48,6 +48,11 @@ export function makeItemId(productId: string, addons: CartAddon[], variantId?: s
48
48
  return `${base}-${addons.map((addon) => addon.item_id).sort().join(',')}`;
49
49
  }
50
50
 
51
+ /**
52
+ * The cart store. Prefer the `useCart()` and `useShopCart()` hooks; read this
53
+ * directly only from non-React code (`useCartStore.getState()`). Lines persist
54
+ * in `localStorage` under a per-channel key.
55
+ */
51
56
  export const useCartStore = create<CartState>()(
52
57
  persist(
53
58
  (set, get) => ({
@@ -62,7 +67,7 @@ export const useCartStore = create<CartState>()(
62
67
  // save fails server-side, so persisting one only moves the failure to
63
68
  // checkout. Themes gate
64
69
  // their add button too, but a theme is presentation — correctness
65
- // cannot depend on all seven, or on the next one, getting it right.
70
+ // cannot depend on every theme getting it right.
66
71
  if (!variant && productRequiresVariant(product)) {
67
72
  toast({
68
73
  title: 'Select options',
@@ -75,7 +80,7 @@ export const useCartStore = create<CartState>()(
75
80
  const itemId = makeItemId(product.id, addons, variant?.id);
76
81
  const addonTotal = addons.reduce((sum, addon) => sum + addon.price, 0);
77
82
  // A variant's own price/stock override the base product's — the same
78
- // precedence quick-add-page.tsx's matchedVariant already uses.
83
+ // precedence the quick-add flow uses.
79
84
  const unitPrice = (variant?.pricing.sale_amount ?? product.pricing.sale_amount) + addonTotal;
80
85
  const itemImage = variant?.media.thumbnail ?? variant?.media.image ?? product.media.thumbnail ?? product.media.image ?? null;
81
86
 
@@ -15,7 +15,7 @@ export interface ScheduleSlot {
15
15
  export type CheckoutTab = 'summary' | 'checkout';
16
16
  export type DeliveryType = 'instant' | 'schedule';
17
17
 
18
- /** The Shipbubble courier the customer picked. Its price is re-derived from the
18
+ /** The courier option the customer picked for automated shipping. Its price is re-derived from the
19
19
  * server-cached quote (via request_token + service_code) at fee/order time —
20
20
  * never trusted from the client — so display == charge. */
21
21
  export interface ShipbubbleSelection {
@@ -42,7 +42,7 @@ interface CheckoutState {
42
42
  paymentMethodName: string | null;
43
43
  selectedBankAccount: SelectedBankAccount | null;
44
44
  /** instore_qr dine-in only — which of the vendor's tables the customer is
45
- * sitting at (App\Models\VendorTable id). Null for every other flow. */
45
+ * sitting at (the table's id). Null for every other flow. */
46
46
  selectedTableId: string | null;
47
47
  promoCode: string;
48
48
  vendorNote: string;
@@ -53,7 +53,7 @@ interface CheckoutState {
53
53
  /** Shipping zone UUID — required for a MANUAL shipping order */
54
54
  shippingZoneId: string | null;
55
55
  shippingZoneName: string | null;
56
- /** The picked Shipbubble courier for an AUTOMATED shipping order (transient) */
56
+ /** The courier option picked for an AUTOMATED shipping order (transient) */
57
57
  shipbubbleSelection: ShipbubbleSelection | null;
58
58
  /** Total order weight in kg — needed when selected zone has has_weight_pricing: true */
59
59
  weightKg: number | null;
@@ -64,7 +64,7 @@ interface CheckoutState {
64
64
  /** Transient. True while the customer is searching/changing their delivery
65
65
  * address — the resolved fee is stale then, so surfaces hide it. */
66
66
  isEditingAddress: boolean;
67
- /** The customer's saved address (CustomerAddress uuid) currently selected for
67
+ /** The customer's saved address (its uuid) currently selected for
68
68
  * delivery, when they picked one from their address book instead of
69
69
  * searching a fresh address. Lets the API reuse the cached courier
70
70
  * geocode. Null whenever the address came from a fresh Google search. */
@@ -16,6 +16,11 @@ interface LocationState {
16
16
  clearLocation: () => void;
17
17
  }
18
18
 
19
+ /**
20
+ * The shopper's chosen delivery location (coordinates and address), persisted
21
+ * per sales channel. Before one is chosen it holds the platform's default map
22
+ * centre and an empty address.
23
+ */
19
24
  export const useLocationStore = create<LocationState>()(
20
25
  persist(
21
26
  (set) => ({
@@ -11,6 +11,7 @@ interface OrdersSheetState {
11
11
  closeDetail: () => void;
12
12
  }
13
13
 
14
+ /** Open and close state for the customer's orders sheet. Call `useOrdersSheetStore.getState().open()` from anywhere, or select from it in a component. */
14
15
  export const useOrdersSheetStore = create<OrdersSheetState>()((set) => ({
15
16
  isOpen: false,
16
17
  activeOrderId: null,
@@ -10,6 +10,7 @@ interface ProductModalState {
10
10
  close: () => void;
11
11
  }
12
12
 
13
+ /** Open and close state for the quick-view product modal. Call `useProductModalStore.getState().open()` from anywhere, or select from it in a component. */
13
14
  export const useProductModalStore = create<ProductModalState>()((set) => ({
14
15
  isOpen: false,
15
16
  product: null,
@@ -9,6 +9,7 @@ interface SearchModalState {
9
9
  close: () => void;
10
10
  }
11
11
 
12
+ /** Open and close state for the search modal. Call `useSearchModalStore.getState().open()` from anywhere, or select from it in a component. */
12
13
  export const useSearchModalStore = create<SearchModalState>()((set) => ({
13
14
  isOpen: false,
14
15
  initialQuery: '',
@@ -32,7 +32,7 @@ export interface StorefrontUser {
32
32
  * the vendor already has the customer physically present, so a phone is not
33
33
  * needed to fulfil the order (matches POS, which never requires it either).
34
34
  *
35
- * `requiresEmail` — pass true for an automated-shipping vendor: Shipbubble's
35
+ * `requiresEmail` — pass true for an automated-shipping vendor: the
36
36
  * courier lookup needs a real email, which a phone-OTP-only signup never has.
37
37
  * Defaults false everywhere else so this stays a no-op for every other caller.
38
38
  */
@@ -2,7 +2,7 @@
2
2
  * Pseudo-locale QA helper.
3
3
  *
4
4
  * PURE AND SERVER-SAFE: this module imports nothing from `next/*` and nothing
5
- * from React, so server components, Node tooling (such as the theme check), tests and the
5
+ * from React, so server components, Node tooling (such as `npm run check` in a theme starter), tests and the
6
6
  * browser all share it (precedent: `utils/locale.ts`, `strings/theme-strings.ts`).
7
7
  * Keep it that way.
8
8
  *
package/theme-context.tsx CHANGED
@@ -6,6 +6,10 @@ import type { ThemeModule } from './types/theme';
6
6
 
7
7
  const ThemeContext = createContext<ThemeModule | null>(null);
8
8
 
9
+ /**
10
+ * Makes a theme module available to `useTheme()`. `ThemeMount` renders it for
11
+ * you; use it directly only when you mount a theme yourself.
12
+ */
9
13
  export function ThemeProvider({
10
14
  theme,
11
15
  children,
@@ -18,11 +22,17 @@ export function ThemeProvider({
18
22
  );
19
23
  }
20
24
 
25
+ /**
26
+ * The mounted theme module (manifest, pages, header and footer variants).
27
+ *
28
+ * @throws {Error} when called outside `ThemeMount` or `ThemeProvider`. Use
29
+ * `useOptionalTheme()` where a theme may be absent.
30
+ */
21
31
  export function useTheme(): ThemeModule {
22
32
  const theme = useContext(ThemeContext);
23
33
 
24
34
  if (!theme) {
25
- throw new Error('useTheme must be used within ThemeProvider');
35
+ throw new Error('useTheme() was called outside <ThemeProvider>. Render the component inside <ThemeMount> (or <ThemeProvider theme={...}>).');
26
36
  }
27
37
 
28
38
  return theme;
package/theme-mount.tsx CHANGED
@@ -49,6 +49,7 @@ class ThemeBoundary extends Component<
49
49
  }
50
50
  }
51
51
 
52
+ /** Props for {@link ThemeMount}. */
52
53
  export interface ThemeMountProps {
53
54
  theme: ThemeModule;
54
55
  headerProps: HeaderProps;
@@ -59,6 +60,19 @@ export interface ThemeMountProps {
59
60
  children: ReactNode;
60
61
  }
61
62
 
63
+ /**
64
+ * Render a theme: its layout, the header and footer chosen by variant id, and
65
+ * `children` (the page) between them. A header or footer that throws is
66
+ * replaced by a visible error box naming the part, so one broken component
67
+ * does not blank the screen.
68
+ *
69
+ * @example
70
+ * <StorefrontProvider vendor={vendor} config={config} menus={menus}>
71
+ * <ThemeMount theme={theme} headerProps={headerProps} footerProps={footerProps}>
72
+ * <HomePage blocks={blocks} />
73
+ * </ThemeMount>
74
+ * </StorefrontProvider>
75
+ */
62
76
  export function ThemeMount({
63
77
  theme,
64
78
  headerProps,
package/utils/format.ts CHANGED
@@ -125,7 +125,7 @@ export function formatRelativeTime(
125
125
  /**
126
126
  * Platform default — the LAST resort only. Item rows carry their own
127
127
  * `currency` code (see normalizeProduct) and store-wide display uses the
128
- * vendor's currency (cart-store, set from vendor-shell on mount). A missing
128
+ * vendor's currency (held in the cart store, which the storefront host sets on mount). A missing
129
129
  * or malformed code must resolve through this function, never a bare
130
130
  * `?? 'NGN'` at a call site, so the fallback stays in exactly one place.
131
131
  */
@@ -144,6 +144,14 @@ export function normalizeCurrencyCode(value: unknown, fallback: string = PLATFOR
144
144
  return /^[A-Z]{3}$/.test(cleanFallback) ? cleanFallback : PLATFORM_DEFAULT_CURRENCY;
145
145
  }
146
146
 
147
+ /**
148
+ * Format an amount in a currency (ISO 4217 code), in the shopper's locale.
149
+ * Decimals show only when the amount has them (`₦5,000`, `₦0.28`). An invalid
150
+ * currency code falls back to the platform default. Never throws.
151
+ *
152
+ * @example
153
+ * formatMoney(5000, 'NGN'); // "₦5,000"
154
+ */
147
155
  export function formatMoney(
148
156
  amount: number,
149
157
  currency = PLATFORM_DEFAULT_CURRENCY,
@@ -163,6 +171,7 @@ export function formatMoney(
163
171
  }
164
172
  }
165
173
 
174
+ /** A whole-number percentage string, e.g. `toPercent(12.4)` is `"12%"`. */
166
175
  export function toPercent(value: number): string {
167
176
  return `${Math.max(0, Math.round(value))}%`;
168
177
  }
@@ -11,6 +11,14 @@ export function menuItemKey(item: MenuItem, index: number): string {
11
11
  return `${item.type}:${item.ref ?? ''}:${item.meta?.anchor ?? ''}:${index}`;
12
12
  }
13
13
 
14
+ /**
15
+ * The link target for a menu item: pages, collections, the shop, the blog, posts
16
+ * and galleries resolve under `basePath`; a `url` item is used as is; anything
17
+ * unknown becomes `#`. Pass `useStorefront().basePath` (or use `useHref`).
18
+ *
19
+ * @example
20
+ * <Link href={menuItemToHref(item, basePath)}>{item.label}</Link>
21
+ */
14
22
  export function menuItemToHref(item: MenuItem, basePath: string = ''): string {
15
23
  switch (item.type) {
16
24
  case 'page': {
@@ -0,0 +1,4 @@
1
+ /** Normalises anything a `catch` can hand back into an `Error`, so hooks can expose one `error` type. */
2
+ export function toError(value: unknown): Error {
3
+ return value instanceof Error ? value : new Error(String(value));
4
+ }
@@ -84,8 +84,7 @@ export async function resolveCustomDomainVendorSlugRemote(hostname: string): Pro
84
84
  }
85
85
 
86
86
  // Middleware runs server-side — prefer the server-only absolute URL. The
87
- // public var can be a same-origin relative path (checkout-proxy rollout),
88
- // which `fetch()` can't resolve without a document origin here.
87
+ // public var can be a same-origin relative path, which `fetch()` can't resolve without a document origin here.
89
88
  const baseUrl = process.env.QUEEK_API_URL ?? process.env.NEXT_PUBLIC_QUEEK_API_URL;
90
89
  if (!baseUrl) {
91
90
  return null;
@@ -1,102 +0,0 @@
1
- /**
2
- * Consumes the kit the way an OUTSIDER does, from a packed tarball.
3
- *
4
- * WHY THIS EXISTS. @usequeek/theme-kit@0.1.0 was published and was broken for
5
- * every external consumer:
6
- *
7
- * node_modules/@usequeek/theme-kit/provider.tsx(58,5):
8
- * error TS2503: Cannot find namespace 'JSX'.
9
- *
10
- * 42 modules annotated returns as `JSX.Element`, relying on a GLOBAL JSX
11
- * namespace that came from a host app's `next-env.d.ts` — not from React.
12
- * Inside a host app the kit free-rode on that file, so tsc, the tests, the
13
- * build and the render smoke were ALL green. The workspace supplies exactly the
14
- * file whose absence is the bug, which is why no in-repo check could ever have
15
- * found it. (Every module now does `import type { JSX } from 'react'`, and this
16
- * script proves it.)
17
- *
18
- * It packs the CURRENT SOURCE rather than installing the published version, so
19
- * it fails before a broken version ships instead of after. Installing from the
20
- * registry would only ever test the last release.
21
- */
22
- import { execFileSync } from 'node:child_process';
23
- import { mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync, readdirSync } from 'node:fs';
24
- import { tmpdir } from 'node:os';
25
- import { dirname, join, resolve } from 'node:path';
26
- import { fileURLToPath } from 'node:url';
27
-
28
- // The kit package is `"type": "module"`, so `__dirname` does not exist here.
29
- const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
30
- const KIT = ROOT;
31
-
32
- function run(cmd: string, args: string[], cwd: string): string {
33
- return execFileSync(cmd, args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
34
- }
35
-
36
- function main(): void {
37
- const sandbox = mkdtempSync(join(tmpdir(), 'kit-consumer-'));
38
- let failed = false;
39
-
40
- try {
41
- const kitPkg = JSON.parse(readFileSync(join(KIT, 'package.json'), 'utf-8')) as {
42
- name: string; version: string; peerDependencies?: Record<string, string>;
43
- };
44
-
45
- console.log(`verify-package: packing ${kitPkg.name}@${kitPkg.version} and consuming it as an outsider`);
46
- run('npm', ['pack', '--pack-destination', sandbox], KIT);
47
- const tarball = readdirSync(sandbox).find((f) => f.endsWith('.tgz'));
48
- if (!tarball) throw new Error('npm pack produced no tarball');
49
-
50
- // Peers are declared by the kit; pin the ones the fixture needs to compile.
51
- const peers = kitPkg.peerDependencies ?? {};
52
- writeFileSync(join(sandbox, 'package.json'), JSON.stringify({
53
- name: 'kit-consumer', private: true, version: '1.0.0',
54
- dependencies: {
55
- [kitPkg.name]: `file:./${tarball}`,
56
- '@queekai/client-sdk': peers['@queekai/client-sdk'] ?? '*',
57
- next: '^16.0.0', react: '^19.0.0', 'react-dom': '^19.0.0',
58
- },
59
- devDependencies: { typescript: '^5', '@types/react': '^19', '@types/react-dom': '^19', '@types/node': '^22' },
60
- }, null, 2));
61
-
62
- // `bundler` resolution + no ambient Next types — exactly what a theme
63
- // developer's own project looks like. NOT this repo's tsconfig.
64
- writeFileSync(join(sandbox, 'tsconfig.json'), JSON.stringify({
65
- compilerOptions: {
66
- target: 'ES2017', lib: ['dom', 'dom.iterable', 'esnext'], jsx: 'react-jsx',
67
- module: 'esnext', moduleResolution: 'bundler', strict: true, noEmit: true,
68
- skipLibCheck: true, esModuleInterop: true, isolatedModules: true,
69
- },
70
- include: ['app/**/*.ts', 'app/**/*.tsx'],
71
- }, null, 2));
72
-
73
- mkdirSync(join(sandbox, 'app'), { recursive: true });
74
- writeFileSync(join(sandbox, 'app/theme.tsx'), readFileSync(join(ROOT, 'tests/fixtures/external-theme.tsx'), 'utf-8'));
75
-
76
- console.log('verify-package: installing from the tarball…');
77
- run('npm', ['install', '--no-audit', '--no-fund'], sandbox);
78
-
79
- console.log('verify-package: typechecking a real theme against it…');
80
- try {
81
- run(join(sandbox, 'node_modules/.bin/tsc'), ['--noEmit'], sandbox);
82
- console.log(`\n✓ ${kitPkg.name} is consumable from outside this repo.`);
83
- } catch (error) {
84
- const out = (error as { stdout?: string; stderr?: string });
85
- console.error('\n✗ the packed kit does NOT work for an external consumer:\n');
86
- console.error((out.stdout ?? '') + (out.stderr ?? ''));
87
- console.error('Errors inside node_modules/@usequeek/theme-kit are PACKAGE bugs — the kit is');
88
- console.error('relying on something only this repo provides. Errors in app/theme.tsx mean the');
89
- console.error('fixture drifted from the real API.');
90
- failed = true;
91
- }
92
- } catch (error) {
93
- console.error('✗ verify-package failed to run:', (error as Error).message);
94
- failed = true;
95
- } finally {
96
- rmSync(sandbox, { recursive: true, force: true });
97
- }
98
-
99
- process.exit(failed ? 1 : 0);
100
- }
101
-
102
- main();
@@ -1,33 +0,0 @@
1
- import React from 'react';
2
- import { renderToStaticMarkup } from 'react-dom/server';
3
- import { describe, expect, it } from 'vitest';
4
- import * as apps from '../apps';
5
- import { AppBlocks, AppBlocksProvider } from '../apps';
6
-
7
- describe('apps — the theme-facing app-block slot', () => {
8
- it('is exactly the provider, the slot, and the shared types', () => {
9
- expect(Object.keys(apps).sort()).toEqual(['AppBlocks', 'AppBlocksProvider']);
10
- });
11
-
12
- it('renders nothing without a host registration (SSR-safe)', () => {
13
- const html = renderToStaticMarkup(
14
- React.createElement(AppBlocks, { target: 'product', productId: 'p1' }),
15
- );
16
-
17
- expect(html).toBe('');
18
- });
19
-
20
- it('renders whatever the host registered, with the slot props', () => {
21
- function HostBlocks({ target, productId }: { target: 'product'; productId?: string }) {
22
- return <div data-host-blocks={`${target}:${productId ?? ''}`} />;
23
- }
24
-
25
- const html = renderToStaticMarkup(
26
- <AppBlocksProvider renderBlocks={HostBlocks}>
27
- <AppBlocks target="product" productId="p1" vendorSlug="glow" />
28
- </AppBlocksProvider>,
29
- );
30
-
31
- expect(html).toContain('data-host-blocks="product:p1"');
32
- });
33
- });
@@ -1,55 +0,0 @@
1
- /**
2
- * Auth error English pins.
3
- *
4
- * `mapEmailError` maps SDK failure codes to `t('auth.error.*')` strings, and
5
- * three more error keys are set directly in the provider. Every English value
6
- * below is resolved through the REAL default dictionary, so renaming a key or
7
- * changing any English value fails here.
8
- */
9
- import { describe, expect, it } from 'vitest';
10
- import { QueekSdkError } from '@queekai/client-sdk';
11
- import { t, defaultThemeStrings } from '../strings/theme-strings';
12
- import { mapEmailError } from '../hooks/use-auth-flow';
13
-
14
- const tt = (key: string): string => t(defaultThemeStrings, key);
15
-
16
- function sdkError(code: string, message: string): QueekSdkError {
17
- return new QueekSdkError(message, code, 400);
18
- }
19
-
20
- describe('mapEmailError code -> English pins', () => {
21
- it.each([
22
- ['invalid_credentials', 'Wrong email or password. Please try again.'],
23
- ['login_mode_mismatch', 'This account uses a different sign-in method (Google or phone).'],
24
- ['account_already_exists', 'Email already registered. Please sign in instead.'],
25
- ['phone_already_exists', 'Phone number already in use.'],
26
- ['otp_resend_too_soon', 'Please wait before requesting another code.'],
27
- ['invalid_otp', 'Incorrect code. Please try again.'],
28
- ] as Array<[string, string]>)('code %s maps to its exact English', (code, english) => {
29
- expect(mapEmailError(sdkError(code, 'server message'), tt)).toBe(english);
30
- });
31
-
32
- it('passes an unknown-code server message through untouched', () => {
33
- expect(mapEmailError(sdkError('something_else', 'Server says no.'), tt)).toBe('Server says no.');
34
- });
35
-
36
- it('falls back to generic English when the server message is empty', () => {
37
- expect(mapEmailError(sdkError('something_else', ''), tt)).toBe('Something went wrong. Please try again.');
38
- });
39
-
40
- it('falls back to generic English for non-SDK failures', () => {
41
- expect(mapEmailError(new Error('boom'), tt)).toBe('Something went wrong. Please try again.');
42
- expect(mapEmailError('boom', tt)).toBe('Something went wrong. Please try again.');
43
- });
44
- });
45
-
46
- describe('direct-set auth.error.* English pins', () => {
47
- it.each([
48
- ['auth.error.firstname', 'Please enter your first name.'],
49
- ['auth.error.register', 'Registration failed. Try again.'],
50
- ['auth.error.google', 'Could not connect to Google sign-in. Please try again.'],
51
- ['auth.error.generic', 'Something went wrong. Please try again.'],
52
- ] as Array<[string, string]>)('%s resolves to its exact English', (key, english) => {
53
- expect(tt(key)).toBe(english);
54
- });
55
- });