@usequeek/theme-kit 0.1.22 → 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 (101) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/LICENSE +1 -1
  3. package/README.md +275 -26
  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-menu.css +349 -0
  14. package/components/language-menu.tsx +371 -0
  15. package/components/language-switcher.tsx +40 -8
  16. package/components/powered-by-queek.tsx +4 -3
  17. package/components/product-media-frame.tsx +5 -0
  18. package/components/product-metafields.tsx +9 -0
  19. package/components/variant-picker.css +4 -7
  20. package/edit-mode.tsx +4 -3
  21. package/hooks/use-address-autocomplete.ts +9 -0
  22. package/hooks/use-auth-flow.tsx +7 -12
  23. package/hooks/use-auth.ts +10 -0
  24. package/hooks/use-cart.ts +12 -0
  25. package/hooks/use-categories.ts +16 -2
  26. package/hooks/use-href.ts +9 -4
  27. package/hooks/use-posts.ts +16 -8
  28. package/hooks/use-product-detail.ts +25 -11
  29. package/hooks/use-product-questions.ts +16 -3
  30. package/hooks/use-products.ts +44 -22
  31. package/hooks/use-related-products.ts +17 -5
  32. package/hooks/use-resolve-coordinates.ts +9 -0
  33. package/hooks/use-reviews.ts +16 -2
  34. package/hooks/use-search.ts +10 -0
  35. package/hooks/use-shop-cart.ts +8 -0
  36. package/hooks/use-shop.ts +19 -6
  37. package/hooks/use-variant-selection.ts +23 -19
  38. package/index.ts +10 -11
  39. package/locales/en.default.json +4 -0
  40. package/navigation.tsx +2 -0
  41. package/package.json +25 -4
  42. package/page-renderer.tsx +11 -0
  43. package/provider.tsx +29 -3
  44. package/sdk/client.ts +183 -36
  45. package/sdk/error-handler.ts +17 -9
  46. package/sdk/queek-client.ts +11 -1
  47. package/sdk/server-store-client.ts +2 -2
  48. package/shared-blocks/embed.tsx +7 -3
  49. package/shared-blocks/manifest.ts +1 -1
  50. package/shared-blocks/product-qa.tsx +1 -1
  51. package/stores/address-modal-store.ts +1 -0
  52. package/stores/auth-modal-store.ts +1 -0
  53. package/stores/cart-panel-store.ts +1 -0
  54. package/stores/cart-store.ts +12 -7
  55. package/stores/checkout-store.ts +4 -4
  56. package/stores/location-store.ts +5 -0
  57. package/stores/orders-sheet-store.ts +1 -0
  58. package/stores/product-modal-store.ts +1 -0
  59. package/stores/search-modal-store.ts +1 -0
  60. package/stores/user-store.ts +1 -1
  61. package/strings/pseudo-locale.ts +1 -1
  62. package/theme-context.tsx +11 -1
  63. package/theme-mount.tsx +14 -0
  64. package/utils/format.ts +10 -1
  65. package/utils/menu-link.ts +8 -0
  66. package/utils/menu-placement.ts +91 -0
  67. package/utils/to-error.ts +4 -0
  68. package/utils/vendor-host.ts +1 -2
  69. package/scripts/verify-package.ts +0 -102
  70. package/tests/apps.test.tsx +0 -33
  71. package/tests/auth-email-errors.test.tsx +0 -55
  72. package/tests/currency-contract.test.ts +0 -85
  73. package/tests/default-metaobject-page.test.tsx +0 -56
  74. package/tests/display-format.test.ts +0 -403
  75. package/tests/fixtures/external-theme.tsx +0 -85
  76. package/tests/image-loading.test.tsx +0 -31
  77. package/tests/language-switcher.test.tsx +0 -350
  78. package/tests/list-pagination.test.tsx +0 -71
  79. package/tests/locale-switch.test.ts +0 -197
  80. package/tests/locale-transport.test.tsx +0 -321
  81. package/tests/manifest-parity.test.ts +0 -54
  82. package/tests/markdown-render.test.ts +0 -46
  83. package/tests/metaobjects-block.test.tsx +0 -70
  84. package/tests/navigation.test.tsx +0 -30
  85. package/tests/orders-walk.test.ts +0 -48
  86. package/tests/page-renderer-signature.test.tsx +0 -42
  87. package/tests/powered-by-queek.test.tsx +0 -65
  88. package/tests/powered-by-url.test.ts +0 -49
  89. package/tests/product-images.test.ts +0 -242
  90. package/tests/product-metafields.test.tsx +0 -154
  91. package/tests/public-surface.test.ts +0 -35
  92. package/tests/shop-query.test.ts +0 -64
  93. package/tests/shopper-chrome-strings.test.tsx +0 -271
  94. package/tests/storefront-client-scope.test.ts +0 -43
  95. package/tests/theme-hook-placement.test.tsx +0 -179
  96. package/tests/theme-strings-client.test.tsx +0 -310
  97. package/tests/theme-strings-pseudo.test.tsx +0 -171
  98. package/tests/theme-strings-typed.test.tsx +0 -97
  99. package/tests/theme-strings.test.tsx +0 -401
  100. package/tests/theme-typed-keys.fixture.ts +0 -76
  101. package/tests/use-shop-prefetch.test.tsx +0 -87
package/CHANGELOG.md ADDED
@@ -0,0 +1,37 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@usequeek/theme-kit`. See the README for the versioning
4
+ policy. Versions before this file started are in the git tags.
5
+
6
+ ## 0.1.24 - 2026-10-12
7
+
8
+ ### Added
9
+
10
+ - `ApiRequestError` and `ApiTimeoutError` carry a stable `code` (`unauthenticated`,
11
+ `forbidden`, `not_found`, `rate_limited`, `client_error`, `server_error`,
12
+ `timeout`) and a `retryable` flag. `ApiRequestError` also carries `requestId`,
13
+ `retryAfterSeconds` and `detail`. Their messages say what happened and what to do
14
+ next, and never include the query string.
15
+ - The fetching hooks (`useProducts`, `useProductBySlug`, `useShop`, `usePosts`,
16
+ `useReviews`, `useProductQuestions`, `useCategories`, `useRelatedProducts`,
17
+ `useProductDetail`) return an `error` field (`Error | null`).
18
+ - One warning, in production builds only, when `QUEEK_API_URL` is not set.
19
+ - JSDoc with examples on the hooks, providers, clients, stores and the main
20
+ components.
21
+
22
+ ### Fixed
23
+
24
+ - `useProducts` and `useShop` no longer leave an unhandled promise rejection when
25
+ the request fails.
26
+ - Slugs and ids are URL-encoded when they are placed in a request path
27
+ (products, collections, related products, questions, orders).
28
+ - Google sign-in no longer logs the sign-in URL or the response body.
29
+ - Hook errors raised outside a provider name the missing provider and the fix.
30
+
31
+ ### Packaging
32
+
33
+ - The published package no longer contains `tests/` or `scripts/`.
34
+ - Peer dependency `@queekai/client-sdk` is `^0.3.1 || ^0.4.0` (was `*`); `engines.node` is
35
+ `>=22`; `bugs`, `homepage`, `author` and `keywords` are set; releases publish
36
+ with npm provenance.
37
+ - The licence names the legal entity, Queekx Technology LTD.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Queek
3
+ Copyright (c) 2026 Queekx Technology LTD
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,9 +1,21 @@
1
1
  # @usequeek/theme-kit
2
2
 
3
- The framework a Queek storefront theme is built against: hooks, stores, headless
4
- flows (auth, cart, checkout), types and the block renderer. See the
5
- [theme documentation](https://docs.usequeek.com/docs/themes) for how themes
6
- work end to end.
3
+ The runtime a Queek storefront theme is built on: hooks for products, cart, auth
4
+ and search, state stores, the page and block renderer, navigation, shared blocks,
5
+ and locale strings. A theme is React components and CSS; this kit supplies
6
+ everything that talks to Queek. See the
7
+ [theme documentation](https://docs.usequeek.com/docs/themes) for how themes work
8
+ end to end.
9
+
10
+ ## Requirements
11
+
12
+ - **Next.js 15 or later (App Router)**, `react` and `react-dom` 19 or later. The
13
+ kit imports `next` itself (links, navigation, server request headers), so it
14
+ runs inside a Next.js app, not in a bare React app.
15
+ - `@queekai/client-sdk` `^0.3.1 || ^0.4.0`, the Queek customer SDK the auth and checkout
16
+ hooks call.
17
+ - Node.js 22 or later (the version CI tests on).
18
+ - TypeScript with `moduleResolution: bundler` and `jsx: react-jsx`.
7
19
 
8
20
  ## Install
9
21
 
@@ -11,9 +23,6 @@ work end to end.
11
23
  npm install @usequeek/theme-kit
12
24
  ```
13
25
 
14
- Peer dependencies: `next` (>=15), `react` and `react-dom` (>=19) and
15
- `@queekai/client-sdk`.
16
-
17
26
  The package ships TypeScript source, not a build. Most modules carry a
18
27
  `'use client'` directive, and that directive is the whole contract with the
19
28
  React Server Components boundary; bundlers routinely strip or hoist it when a
@@ -26,10 +35,57 @@ bundler that enforces them:
26
35
  export default { transpilePackages: ['@usequeek/theme-kit'] };
27
36
  ```
28
37
 
29
- Compiling a theme against it needs `moduleResolution: bundler` and
30
- `jsx: react-jsx`. A vitest setup that renders kit components needs the package
31
- inlined (`server.deps.inline: ['@usequeek/theme-kit']`) so the shipped `.tsx`
32
- source is transformed like first-party code.
38
+ A vitest setup that renders kit components needs the package inlined
39
+ (`server.deps.inline: ['@usequeek/theme-kit']`) so the shipped `.tsx` source is
40
+ transformed like first-party code.
41
+
42
+ ## Quickstart
43
+
44
+ A product list that loads, handles a failed request, links to each product and
45
+ adds to the cart:
46
+
47
+ ```tsx
48
+ 'use client';
49
+
50
+ import { useCart } from '@usequeek/theme-kit/hooks/use-cart';
51
+ import { useHref } from '@usequeek/theme-kit/hooks/use-href';
52
+ import { useProducts } from '@usequeek/theme-kit/hooks/use-products';
53
+ import { Link } from '@usequeek/theme-kit/navigation';
54
+ import { formatMoney } from '@usequeek/theme-kit/utils/format';
55
+
56
+ export function PopularProducts() {
57
+ const { products, isLoading, error } = useProducts({ limit: 4, sort: 'popular' });
58
+ const { addProduct } = useCart();
59
+ const href = useHref();
60
+
61
+ if (isLoading) return <p>Loading…</p>;
62
+ if (error) return <p>We could not load products. Please try again.</p>;
63
+
64
+ return (
65
+ <ul>
66
+ {products.map((product) => (
67
+ <li key={product.id}>
68
+ <Link href={href(`/products/${product.slug}`)}>{product.title}</Link>
69
+ <span>{formatMoney(product.pricing.sale_amount, product.currency)}</span>
70
+ <button type="button" onClick={() => addProduct(product)}>
71
+ Add to cart
72
+ </button>
73
+ </li>
74
+ ))}
75
+ </ul>
76
+ );
77
+ }
78
+ ```
79
+
80
+ The storefront mounts `StorefrontProvider` for every theme, so a theme never
81
+ renders it. Hooks called outside it throw an error that says so. In a test or a
82
+ script, wrap the component yourself:
83
+
84
+ ```tsx
85
+ <StorefrontProvider vendor={vendor} config={config} menus={[]}>
86
+ <PopularProducts />
87
+ </StorefrontProvider>
88
+ ```
33
89
 
34
90
  ## Use
35
91
 
@@ -53,6 +109,71 @@ Theme CSS ships with the package: `@usequeek/theme-kit/shared-blocks/core-blocks
53
109
  (framework-owned blocks), plus per-component CSS next to its component
54
110
  (`components/variant-picker.css`, `apps/apps.css`).
55
111
 
112
+ ### Hooks
113
+
114
+ The hooks that fetch (products, shop, categories, posts, reviews, questions,
115
+ related products, product detail) report failure through an `error` field (an
116
+ `Error`, or `null`). They never throw and never leave an unhandled rejection, and
117
+ they keep the data already on screen. They return `isLoading`, except
118
+ `useProductDetail`, which returns `loadingDetail`. In preview, hooks read
119
+ `previewData` from the provider and make no request.
120
+
121
+ | Hook | Module | Returns |
122
+ | --- | --- | --- |
123
+ | `useProducts(options?)` | `hooks/use-products` | A product list: `{ products, isLoading, error }` |
124
+ | `useProductBySlug(slug)` | `hooks/use-products` | One product by slug: `{ product, isLoading, error }` |
125
+ | `useProductDetail(listProduct, isOpen, vendorSlug?, locale?)` | `hooks/use-product-detail` | A list product completed with variants and add-ons |
126
+ | `useRelatedProducts(slug, limit?)` | `hooks/use-related-products` | Related products |
127
+ | `useShop(options?)` + `useShopParams()` | `hooks/use-shop`, `hooks/use-shop-params` | The shop catalogue page and its URL state |
128
+ | `useCollectionParams()` | `hooks/use-collection-params` | Search, sort and page state for a collection page |
129
+ | `useCategories(options?)` | `hooks/use-categories` | Categories, flattened |
130
+ | `usePosts(options?)` | `hooks/use-posts` | Blog posts |
131
+ | `useReviews(block)` | `hooks/use-reviews` | Reviews and the rating summary |
132
+ | `useProductQuestions(block)` | `hooks/use-product-questions` | Product questions and answers |
133
+ | `useCart()` / `useShopCart(shopId)` | `hooks/use-cart`, `hooks/use-shop-cart` | The cart and its actions; one shop's lines and totals |
134
+ | `useAuth()` | `hooks/use-auth` | The signed-in customer and sign-in modal controls |
135
+ | `useVariantSelection(product)` | `hooks/use-variant-selection` | Option selection state and the add-to-cart gate |
136
+ | `useSearch(query, products, collections)` | `hooks/use-search` | Instant search over data you hold |
137
+ | `useAddressAutocomplete(text)` / `useResolveCoordinates()` | `hooks/use-address-autocomplete`, `hooks/use-resolve-coordinates` | Delivery address lookup |
138
+ | `useHref()` | `hooks/use-href` | Builds a store-relative link |
139
+ | `useLocales()` | `hooks/use-locales` | The store's languages and the active one |
140
+ | `useStorefront()` / `useStorefrontLocale()` / `useThemeStrings()` | `provider` | The store, the request locale, the bound `t` |
141
+
142
+ ## Errors
143
+
144
+ Requests go through one place (`sdk/client.ts`). A non-2xx answer throws
145
+ `ApiRequestError`, a request that outlives its budget throws `ApiTimeoutError`.
146
+ Branch on `code`, never on the message:
147
+
148
+ | Field | Meaning |
149
+ | --- | --- |
150
+ | `code` | `unauthenticated` (401), `forbidden` (403), `not_found` (404), `rate_limited` (429), `client_error` (other 4xx), `server_error` (5xx), `timeout` |
151
+ | `status`, `url` | The HTTP status and the full request URL (`ApiRequestError` only) |
152
+ | `retryable` | `true` for 429, 5xx and timeouts. The kit never retries a write on its own: a timed-out POST may already have reached the API. |
153
+ | `requestId` | The API's `x-request-id`, for a support ticket |
154
+ | `retryAfterSeconds` | The API's `Retry-After`, when it sent one |
155
+ | `detail` | The API's own error message, when its body carried one |
156
+ | `cause` | The original `DOMException` (`ApiTimeoutError` only) |
157
+
158
+ Messages never include the query string, so search terms stay out of logs.
159
+ Server-side fetchers that must render "not found" for a missing resource use
160
+ `swallowNotFound`, which returns `null` for a 404 and rethrows everything else,
161
+ so an API outage never shows as a missing page.
162
+
163
+ ## Configuration (storefront host)
164
+
165
+ Themes set none of these; the storefront host does.
166
+
167
+ | Variable | Used for |
168
+ | --- | --- |
169
+ | `QUEEK_API_URL` (or `NEXT_PUBLIC_QUEEK_API_URL`) | Origin of the Queek API for server-side reads. Without it the kit falls back to `http://127.0.0.1:8000/api/v1` and warns once in production builds. |
170
+ | `NEXT_PUBLIC_QUEEK_AUTH_URL` | Origin of the sign-in service (default `https://auth.usequeek.com`). |
171
+ | `STOREFRONT_DOMAIN` | Domain whose subdomains are store slugs (default `queek.com.ng`). |
172
+ | `CUSTOM_DOMAIN_VENDOR_MAP` | JSON object mapping a custom domain to a store slug. |
173
+
174
+ Browser requests are always same-origin (`/api/v1/client/*`), so the host needs
175
+ a rewrite from that path to the API. The browser never learns the API origin.
176
+
56
177
  ## Theme strings
57
178
 
58
179
  The kit's UI-string mechanism (`strings/theme-strings.ts`; pure, with no
@@ -292,7 +413,7 @@ export default function Footer() {
292
413
  - MUST: place `<PoweredByQueek />` on every footer variant (`footers/*.tsx`,
293
414
  or `footer.tsx` where a theme has no `footers/` dir). The placement and
294
415
  spacing are the theme's — the component is the content, the footer owns
295
- where it sits. The theme check rule `theme/footer-shows-powered-by`
416
+ where it sits. The `theme/footer-shows-powered-by` rule of `npm run check`
296
417
  ([checks reference](https://docs.usequeek.com/docs/themes/checks)) enforces
297
418
  this, so a theme cannot ship without it.
298
419
  - Style only: the link emits `core-powered-by` with a
@@ -346,8 +467,10 @@ keeps the page, the query string and the hash.
346
467
  ```tsx
347
468
  import { LanguageSwitcher } from '@usequeek/theme-kit/components/language-switcher';
348
469
 
349
- <LanguageSwitcher /> // native <select> (default)
350
- <LanguageSwitcher variant="inline-list" /> // every language as a link
470
+ <LanguageSwitcher variant="menu" /> // button + popover (sheet on phones) — recommended
471
+ <LanguageSwitcher variant="menu" side="top" /> // a footer: opens upward
472
+ <LanguageSwitcher variant="inline-list" /> // every language as a link (footer, up to four)
473
+ <LanguageSwitcher /> // native <select> — legacy
351
474
  ```
352
475
 
353
476
  Place it unconditionally, in the header or the footer: it renders nothing on a
@@ -357,10 +480,114 @@ store's languages.
357
480
  | Prop | Default | |
358
481
  | --- | --- | --- |
359
482
  | `className` | | Extra class on the root, to position or restyle it. |
360
- | `variant` | `'select'` | `'select'`: one native dropdown (best keyboard, touch and screen-reader behaviour). `'inline-list'`: a link per language that wraps on narrow screens; suits a footer. |
483
+ | `variant` | `'select'` | `'menu'`: a trigger button and a list of language links (below). `'inline-list'`: a link per language that wraps on narrow screens. `'select'`: one native dropdown, **legacy** — it works and is unchanged, but its open list is the operating system's menu and cannot be styled; prefer `menu`. |
484
+ | `side` | `'bottom'` | `menu` only. `'top'` opens above the trigger, for footers. Flips by itself when the preferred side does not fit. |
485
+ | `align` | `'end'` | `menu` only. Line the popover up with the trigger's inline `'end'` or `'start'` edge (mirrors in right-to-left). Flips and shifts to stay inside the viewport. |
361
486
  | `showNativeName` | `true` | Label languages in their own language (`Français`) instead of English (`French`). |
362
- | `showCode` | `false` | Append the code: `Français · FR`. |
363
- | `children` | | Render-prop `(state) => ReactNode`: replaces the default control entirely (never called on a single-language store). `state` is `useLocales()` plus `label`, `labelFor(locale)` and `displayName(locale)`. |
487
+ | `showCode` | `false` | Show the language code (`FR`) next to the name. |
488
+ | `children` | | Render-prop `(state) => ReactNode`: replaces the default control entirely (never called on a single-language store). `state` is `useLocales()` plus `label`, `labelFor(locale)`, `displayName(locale)`, `open` and `setOpen`. |
489
+
490
+ ### The `menu` variant
491
+
492
+ A real `<button>` opens a list of language **links** (`<a href>` built from the
493
+ current page, so open-in-new-tab and copy-link work). On screens wider than
494
+ 640px it is a popover under the trigger; below 640px it becomes a bottom sheet
495
+ with a scrim, safe-area padding, and page scroll locked while it is open. The
496
+ wide/narrow layout switch is CSS only (one media query), so the markup is the
497
+ same on both and server and client agree; JavaScript only consults the media
498
+ query while the menu opens, to lock scroll and to place the popover.
499
+ With more than four languages in a footer, use `menu` with `side="top"` rather
500
+ than `inline-list`. 2 to 20 languages are fine: the list scrolls past about
501
+ eight rows, and a long list can use two columns on wide screens (below).
502
+
503
+ It is the *disclosure* pattern, not a listbox, because choosing an item
504
+ navigates: the button has `aria-expanded` and `aria-controls` and the name
505
+ "Language: English"; the list is a `<ul>` labelled by its title; the current
506
+ language's link has `aria-current="true"`; there are no `option` roles.
507
+
508
+ | Key | |
509
+ | --- | --- |
510
+ | Enter, Space, ArrowDown on the button | Open, focus on the current language |
511
+ | ArrowUp on the button | Open, focus on the last language |
512
+ | ArrowDown / ArrowUp in the list | Next / previous link (wraps) |
513
+ | Home / End | First / last link |
514
+ | Typing letters | Jump to the language whose name or code starts with them (500 ms buffer) |
515
+ | Enter on a link | Follow it (native); Space does the same |
516
+ | Escape | Close and return focus to the button |
517
+ | Tab | Close the menu |
518
+ | Click outside, scrim tap | Close |
519
+
520
+ Only the focused link is in the tab order (roving tabindex), so Tab leaves the
521
+ list and closes it. Choosing a language closes the menu, returns focus to the
522
+ button and navigates (a modified click — new tab, copy link — is left to the
523
+ browser). Motion respects `prefers-reduced-motion` (an 80 ms fade only).
524
+
525
+ **Styling surface.** Every part has a class and a `data-part` attribute; the
526
+ root also carries state attributes:
527
+
528
+ | Part | Class | `data-part` |
529
+ | --- | --- | --- |
530
+ | Root | `qn-lang qn-lang--menu` | `data-qn-language-switcher`, `data-variant="menu"` |
531
+ | Trigger button | `qn-lang__trigger` | `trigger` |
532
+ | Trigger name / code / chevron | `qn-lang__trigger-label` / `__trigger-code` / `__chevron` | `trigger-label` / `trigger-code` / `chevron` |
533
+ | Scrim (sheet) | `qn-lang__scrim` | `scrim` |
534
+ | Popover / sheet | `qn-lang__popover` | `popover` |
535
+ | Sheet grabber | `qn-lang__handle` | `handle` |
536
+ | Group label | `qn-lang__title` | `title` |
537
+ | List / item | `qn-lang__options` / `__item` | `list` / `item` |
538
+ | Link (current: `aria-current="true"`, `data-current="true"`) | `qn-lang__option` | `option` |
539
+ | Name / code / current marker | `qn-lang__name` / `__code` / `__mark` | `name` / `code` / `mark` |
540
+
541
+ Root attributes: `data-state="open|closed"`, `data-side="bottom|top"`,
542
+ `data-align="start|end"`, `data-mode="popover|sheet"` (while open),
543
+ `data-count="<n>"`, `data-many="true"` (more than ten languages),
544
+ `data-show-code="true"`. The code spans are always rendered and hidden by
545
+ default, so a theme can show a code-only trigger with
546
+ `.my-header [data-part='trigger-label'] { display: none }` plus
547
+ `.my-header [data-part='trigger-code'] { display: inline }`.
548
+
549
+ | Variable | Default | Controls |
550
+ | --- | --- | --- |
551
+ | `--qn-lang-surface` | `--qn-surface` | popover / sheet background |
552
+ | `--qn-lang-ink` | `--qn-text` | text colour |
553
+ | `--qn-lang-muted` | `--qn-text-muted` | title and code colour |
554
+ | `--qn-lang-border` | `--qn-border` | hairlines |
555
+ | `--qn-lang-radius` | `--qn-radius-md` (8px) | trigger / popover corners |
556
+ | `--qn-lang-item-radius` | `--qn-radius-sm` (6px) | link corners |
557
+ | `--qn-lang-item-hover` / `--qn-lang-current-bg` | 6% ink / none | hover and current-link backgrounds |
558
+ | `--qn-lang-accent` | `--qn-accent` | current marker colour |
559
+ | `--qn-lang-focus` | `--qn-focus` | focus ring |
560
+ | `--qn-lang-shadow` | subtle | popover shadow |
561
+ | `--qn-lang-row-height` / `--qn-lang-sheet-row-height` | 44px / 52px | link height (popover / sheet) |
562
+ | `--qn-lang-gap` / `--qn-lang-pad` | 0 / 4px | space between links / inside the popover |
563
+ | `--qn-lang-offset` | 8px | gap between trigger and popover |
564
+ | `--qn-lang-popover-width` | 240px | popover width |
565
+ | `--qn-lang-many-width` / `--qn-lang-many-columns` | popover width / 1 | width and columns when `data-many="true"` (set `--qn-lang-many-columns: 2` and a wider width for two columns) |
566
+ | `--qn-lang-list-max` | 8.5 rows | height at which the list scrolls |
567
+ | `--qn-lang-sheet-radius` | 16px | sheet top corners |
568
+ | `--qn-lang-scrim` | 40% black | scrim colour |
569
+ | `--qn-lang-z` | 50 | z-index |
570
+ | `--qn-lang-duration` / `--qn-lang-ease` | 160ms / ease-out curve | open motion |
571
+
572
+ Each variable falls back to the matching `--qn-*` token, then a neutral
573
+ value, and is never set by the kit itself — set it on the root, on an
574
+ ancestor, or on `:root`. The sheet breakpoint (640px) is fixed.
575
+
576
+ Restyling recipe — no override of internals needed:
577
+
578
+ ```css
579
+ .site-header [data-qn-language-switcher] {
580
+ --qn-lang-surface: #f3f0e9;
581
+ --qn-lang-ink: #1b2029;
582
+ --qn-lang-radius: 0;
583
+ --qn-lang-item-radius: 0;
584
+ --qn-lang-popover-width: 280px;
585
+ --qn-lang-sheet-radius: 0;
586
+ --qn-lang-duration: 120ms;
587
+ }
588
+ .site-header [data-part='trigger'] { border: 0; letter-spacing: 0.12em; text-transform: uppercase; }
589
+ .site-header [data-part='option'][aria-current='true'] { font-style: italic; }
590
+ ```
364
591
 
365
592
  **Tell the storefront the theme places it.** Set `languageSwitcher: 'theme'` in
366
593
  the theme manifest. Without it the storefront keeps rendering its own floating
@@ -432,8 +659,8 @@ the kit is the one place that knows it, and a framework change is a kit
432
659
  release, not an update to every theme. `Link` is an `<a>` that moves inside the
433
660
  store without a full page load (`href`, `prefetch`, `replace`, plus any anchor
434
661
  attribute); `useRouter()` gives `push`, `replace`, `back` and `refresh`;
435
- `usePathname()` returns the current path. The theme check rejects a theme that
436
- imports `next`.
662
+ `usePathname()` returns the current path. `npm run check` in the theme starter rejects a
663
+ theme that imports `next`.
437
664
 
438
665
  ## Boundary
439
666
 
@@ -443,7 +670,7 @@ framework: the kit may import `next`, a theme may not.
443
670
  ## Develop
444
671
 
445
672
  ```bash
446
- npm install
673
+ npm ci
447
674
  npm run typecheck # tsc --noEmit
448
675
  npm test # public-text check, then vitest run
449
676
  npm run verify-package # pack current source, typecheck a real theme against it
@@ -454,19 +681,41 @@ it from a sandbox, so a break that only external consumers would see (for
454
681
  example a module relying on the global `JSX` namespace, which React 19 removed)
455
682
  fails here instead of after publishing.
456
683
 
684
+ ## Versioning
685
+
686
+ The kit is pre-1.0 (`0.x`). A change to the surface the barrels declare that can
687
+ break a theme ships in a minor version and is listed in
688
+ [CHANGELOG.md](./CHANGELOG.md); patch versions are fixes and additions. Pin a
689
+ minor version (`~0.1.23`) if a theme must not move on its own.
690
+
457
691
  ## Release
458
692
 
459
- Publishing runs on tags via `.github/workflows/publish.yml`, which needs the
460
- `NPM_TOKEN` repository secret:
693
+ Publishing runs on tags via `.github/workflows/publish.yml`, through npm
694
+ trusted publishing (no stored token), with provenance:
461
695
 
462
696
  ```bash
463
- # 1. Bump version in package.json and commit
464
- # 2. Tag and push the tag — the workflow typechecks, tests, runs verify-package,
465
- # then `npm publish --access public`:
697
+ # 1. Bump version in package.json, add the CHANGELOG entry, commit
698
+ # 2. Tag and push the tag. The workflow typechecks, tests, runs verify-package,
699
+ # then `npm publish --provenance --access public`:
466
700
  git tag vX.Y.Z
467
701
  git push origin main --tags
468
702
  ```
469
703
 
704
+ ## Contributing
705
+
706
+ Open an issue or a pull request at
707
+ [usequeek/theme-kit](https://github.com/usequeek/theme-kit). Run `npm run typecheck`
708
+ and `npm test` before you push; CI runs both plus `verify-package`. Keep a change
709
+ to the public surface small, documented with a JSDoc `@example`, and recorded in
710
+ the CHANGELOG.
711
+
712
+ ## Security
713
+
714
+ The kit holds no secrets and only calls the Queek API's `/client/*` routes (a
715
+ guard in `sdk/client.ts` refuses anything else). Customer tokens live in the
716
+ browser's `localStorage`. To report a vulnerability, use GitHub's private
717
+ vulnerability reporting on this repository rather than a public issue.
718
+
470
719
  ## License
471
720
 
472
- MIT
721
+ MIT, copyright Queekx Technology LTD. See [LICENSE](./LICENSE).
@@ -8,6 +8,7 @@ import { extractProducts, normalizeProduct } from '../utils/product-normalizer';
8
8
  * gets a 422 from the API. */
9
9
  export type CollectionSort = 'newest' | 'popular' | 'price_low' | 'price_high';
10
10
 
11
+ /** Options for `fetchCollectionProducts`. */
11
12
  export interface FetchCollectionProductsOptions {
12
13
  page?: number;
13
14
  perPage?: number;
@@ -20,6 +21,10 @@ export interface FetchCollectionProductsOptions {
20
21
  // SSR collection reads go through `/client/store/*`, which resolves the store and
21
22
  // forces X-Platform=storefront (base prices, no marketplace commission) — never
22
23
  // the legacy v1 routes shared with the marketplace.
24
+ /**
25
+ * The store's collections, for server components. Returns `[]` when the store
26
+ * has none. A 404 becomes `[]`; any other failure (timeout, 5xx) throws.
27
+ */
23
28
  export async function fetchCollections(vendorSlug: string, storefrontOnly = true, locale?: string): Promise<Collection[]> {
24
29
  const client = createServerStoreClient(vendorSlug, await getRequestOrigin());
25
30
 
@@ -36,12 +41,20 @@ export async function fetchCollections(vendorSlug: string, storefrontOnly = true
36
41
  }
37
42
  }
38
43
 
44
+ /** One collection by slug, or `null` when it is not in the store's list. */
39
45
  export async function fetchCollection(vendorSlug: string, slug: string, locale?: string): Promise<Collection | null> {
40
46
  const collections = await fetchCollections(vendorSlug, true, locale);
41
47
 
42
48
  return collections.find((collection) => collection.slug === slug) ?? null;
43
49
  }
44
50
 
51
+ /**
52
+ * A collection and one page of its products, for server components.
53
+ *
54
+ * A missing collection gives `{ collection: null, products: [] }`.
55
+ *
56
+ * @throws {ApiRequestError} on any other API failure (timeout, 5xx).
57
+ */
45
58
  export async function fetchCollectionProducts(
46
59
  vendorSlug: string,
47
60
  slug: string,
@@ -63,7 +76,7 @@ export async function fetchCollectionProducts(
63
76
  try {
64
77
  const [collection, response] = await Promise.all([
65
78
  fetchCollection(vendorSlug, slug, options?.locale),
66
- client.get<ApiResponse<unknown, Record<string, unknown>>>(`/collections/${slug}/products`, query, options?.locale),
79
+ client.get<ApiResponse<unknown, Record<string, unknown>>>(`/collections/${encodeURIComponent(slug)}/products`, query, options?.locale),
67
80
  ]);
68
81
 
69
82
  return {
package/api/orders.ts CHANGED
@@ -1,6 +1,15 @@
1
1
  import { createBrowserClient } from '../sdk/client';
2
2
  import type { OrderListResponse, OrderDetail } from '../types/order';
3
3
 
4
+ /**
5
+ * One page of the current customer's orders (`GET /client/store/orders`). Pass
6
+ * a `status` to filter; `'all'` or omitted means no filter.
7
+ *
8
+ * @throws {ApiRequestError} when the API refuses; `code: 'unauthenticated'` means the customer must sign in.
9
+ *
10
+ * @example
11
+ * const { data } = await fetchOrders(vendor.slug, 1, 'completed');
12
+ */
4
13
  export async function fetchOrders(
5
14
  vendorSlug: string,
6
15
  page = 1,
@@ -13,11 +22,16 @@ export async function fetchOrders(
13
22
  });
14
23
  }
15
24
 
25
+ /**
26
+ * One of the current customer's orders, by id.
27
+ *
28
+ * @throws {ApiRequestError} with `code: 'not_found'` when the order does not exist for this customer.
29
+ */
16
30
  export async function fetchOrderDetail(
17
31
  vendorSlug: string,
18
32
  id: string,
19
33
  ): Promise<OrderDetail> {
20
34
  const client = createBrowserClient(vendorSlug);
21
- const res = await client.get<{ data: OrderDetail }>(`/orders/${id}`);
35
+ const res = await client.get<{ data: OrderDetail }>(`/orders/${encodeURIComponent(id)}`);
22
36
  return res.data;
23
37
  }
@@ -41,6 +41,11 @@ const POSITION_CLASS: Record<string, string> = {
41
41
  'center-left': 'core-follow-card--center-left',
42
42
  };
43
43
 
44
+ /**
45
+ * The store's follow card, shown when the merchant has turned it on. It is
46
+ * floating by default; `display="inline"` places it in the page flow. Renders
47
+ * nothing when the app is off.
48
+ */
44
49
  export function FollowCard({ display }: { display?: 'floating' | 'inline' } = {}): JSX.Element | null {
45
50
  const { config, vendor } = useStorefront();
46
51
  const followConfig = config.apps?.follow_card;
@@ -22,8 +22,8 @@ type Status = 'idle' | 'saving' | 'done' | 'error';
22
22
  * The `subscribe` app's `inline` variant (the theme-declared default) — a
23
23
  * theme's own `getSubscribeForm` dispatch renders this directly, passing
24
24
  * heading/tagline/cta straight through with no gate of its own; gating on
25
- * `config.apps?.subscribe?.enabled` happens once, centrally, in whichever
26
- * variant dispatch mounts it (see vendor-shell.tsx / a theme's footer).
25
+ * `config.apps?.subscribe?.enabled` happens once, centrally, by whatever
26
+ * mounts it (the storefront host, or a theme's footer).
27
27
  */
28
28
  export function SubscribeForm({
29
29
  heading = 'Stay in the loop',
@@ -18,6 +18,18 @@ function AuthFlowInner(): JSX.Element | null {
18
18
  return null;
19
19
  }
20
20
 
21
+ /**
22
+ * The kit's customer sign-in and sign-up flow (email code, email and password,
23
+ * and Google), rendered step by step. A theme that wants its own sign-in
24
+ * layout provides `AuthRenderer` in its module and renders this inside it. Its
25
+ * markup carries `core-auth*` classes for your CSS, and every string goes
26
+ * through the theme strings (`auth.*`).
27
+ *
28
+ * @example
29
+ * export function AuthRenderer() {
30
+ * return <div className="my-auth-card"><AuthFlow /></div>;
31
+ * }
32
+ */
21
33
  export function AuthFlow(): JSX.Element {
22
34
  return (
23
35
  <AuthFlowProvider>
@@ -10,6 +10,7 @@ import { useShopCart } from '../../hooks/use-shop-cart';
10
10
  import { useCartPanelStore } from '../../stores/cart-panel-store';
11
11
  import { normalizeCurrencyCode } from '../../utils/format';
12
12
 
13
+ /** What {@link CartPanelController} hands to your render function: the panel state, this shop's lines, and the actions that change them. */
13
14
  export interface CartPanelControllerRenderProps {
14
15
  isOpen: boolean;
15
16
  items: CartItem[];
@@ -23,6 +24,18 @@ export interface CartPanelControllerRenderProps {
23
24
  onRemove: (id: string, shopId: string) => void;
24
25
  }
25
26
 
27
+ /**
28
+ * Headless controller for a cart panel (drawer). It owns open and close state,
29
+ * the Escape key, scroll locking and navigation to checkout; you own the markup.
30
+ * Open it from anywhere with `useCartPanelStore.getState().open()`.
31
+ *
32
+ * @example
33
+ * <CartPanelController>
34
+ * {({ isOpen, items, total, onClose, onCheckout }) =>
35
+ * isOpen ? <MyDrawer items={items} total={total} onClose={onClose} onCheckout={onCheckout} /> : <></>
36
+ * }
37
+ * </CartPanelController>
38
+ */
26
39
  export function CartPanelController({
27
40
  children,
28
41
  }: {
@@ -8,6 +8,11 @@ import { CheckoutContactSection } from './checkout-contact';
8
8
  import { useThemeStrings } from '../../provider';
9
9
  import { PolicyLinks } from './policy-links';
10
10
 
11
+ /**
12
+ * The kit's default checkout layout. The storefront fills its slots (address,
13
+ * payment, promo, fees, submit) and passes them in; a theme can replace the
14
+ * whole shell by providing its own checkout component.
15
+ */
11
16
  export function DefaultCheckoutShell({
12
17
  cartItems,
13
18
  deliveryModes,
@@ -23,6 +23,10 @@ function subscribeNoop(): () => void {
23
23
  return () => {};
24
24
  }
25
25
 
26
+ /**
27
+ * Links to the store's published policies (returns, shipping, privacy, terms).
28
+ * Each opens in the kit's policy modal. Renders nothing when `policies` is empty.
29
+ */
26
30
  export function PolicyLinks({ policies }: { policies: Page[] }): JSX.Element | null {
27
31
  const t = useThemeStrings();
28
32
  const activeSlug = usePolicyModalStore((s) => s.activeSlug);
@@ -23,6 +23,7 @@ const PLACEHOLDER_SVG = `data:image/svg+xml,${encodeURIComponent(
23
23
  </svg>`,
24
24
  )}`;
25
25
 
26
+ /** A labelled placeholder drawn in place of an image that is missing or failed to load. Size it to the space the image will fill. */
26
27
  export interface PlaceholderProp {
27
28
  label?: string;
28
29
  sublabel?: string;
@@ -59,6 +60,15 @@ type ImageProps = ImgHTMLAttributes<HTMLImageElement> & {
59
60
  */
60
61
  type LoadStage = 'primary' | 'original' | 'failed';
61
62
 
63
+ /**
64
+ * The kit's image element. Use it instead of a raw `<img>`: it picks a
65
+ * responsive `srcset` from the API's image variants, retries the original file
66
+ * when a resized one fails, and falls back to `fallback` or a branded
67
+ * placeholder. It accepts every `<img>` attribute.
68
+ *
69
+ * @example
70
+ * <Image src={product.media.image ?? ''} variants={product.media.image_variants} intent="card" alt={product.title} />
71
+ */
62
72
  export function Image({ fallback, placeholder, variants, intent = 'raw', onError, ...props }: ImageProps) {
63
73
  const [stage, setStage] = useState<LoadStage>('primary');
64
74
  // Read context directly — returns null outside a StorefrontProvider