@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.
- package/CHANGELOG.md +37 -0
- package/LICENSE +1 -1
- package/README.md +275 -26
- package/api/collections.ts +14 -1
- package/api/orders.ts +15 -1
- package/apps/follow-card.tsx +5 -0
- package/apps/subscribe-form.tsx +2 -2
- package/components/auth/auth-flow.tsx +12 -0
- package/components/cart/cart-panel-controller.tsx +13 -0
- package/components/checkout/default-checkout-shell.tsx +5 -0
- package/components/checkout/policy-links.tsx +4 -0
- package/components/image.tsx +10 -0
- package/components/language-menu.css +349 -0
- package/components/language-menu.tsx +371 -0
- package/components/language-switcher.tsx +40 -8
- package/components/powered-by-queek.tsx +4 -3
- package/components/product-media-frame.tsx +5 -0
- package/components/product-metafields.tsx +9 -0
- package/components/variant-picker.css +4 -7
- package/edit-mode.tsx +4 -3
- package/hooks/use-address-autocomplete.ts +9 -0
- package/hooks/use-auth-flow.tsx +7 -12
- package/hooks/use-auth.ts +10 -0
- package/hooks/use-cart.ts +12 -0
- package/hooks/use-categories.ts +16 -2
- package/hooks/use-href.ts +9 -4
- package/hooks/use-posts.ts +16 -8
- package/hooks/use-product-detail.ts +25 -11
- package/hooks/use-product-questions.ts +16 -3
- package/hooks/use-products.ts +44 -22
- package/hooks/use-related-products.ts +17 -5
- package/hooks/use-resolve-coordinates.ts +9 -0
- package/hooks/use-reviews.ts +16 -2
- package/hooks/use-search.ts +10 -0
- package/hooks/use-shop-cart.ts +8 -0
- package/hooks/use-shop.ts +19 -6
- package/hooks/use-variant-selection.ts +23 -19
- package/index.ts +10 -11
- package/locales/en.default.json +4 -0
- package/navigation.tsx +2 -0
- package/package.json +25 -4
- package/page-renderer.tsx +11 -0
- package/provider.tsx +29 -3
- package/sdk/client.ts +183 -36
- package/sdk/error-handler.ts +17 -9
- package/sdk/queek-client.ts +11 -1
- package/sdk/server-store-client.ts +2 -2
- package/shared-blocks/embed.tsx +7 -3
- package/shared-blocks/manifest.ts +1 -1
- package/shared-blocks/product-qa.tsx +1 -1
- package/stores/address-modal-store.ts +1 -0
- package/stores/auth-modal-store.ts +1 -0
- package/stores/cart-panel-store.ts +1 -0
- package/stores/cart-store.ts +12 -7
- package/stores/checkout-store.ts +4 -4
- package/stores/location-store.ts +5 -0
- package/stores/orders-sheet-store.ts +1 -0
- package/stores/product-modal-store.ts +1 -0
- package/stores/search-modal-store.ts +1 -0
- package/stores/user-store.ts +1 -1
- package/strings/pseudo-locale.ts +1 -1
- package/theme-context.tsx +11 -1
- package/theme-mount.tsx +14 -0
- package/utils/format.ts +10 -1
- package/utils/menu-link.ts +8 -0
- package/utils/menu-placement.ts +91 -0
- package/utils/to-error.ts +4 -0
- package/utils/vendor-host.ts +1 -2
- package/scripts/verify-package.ts +0 -102
- package/tests/apps.test.tsx +0 -33
- package/tests/auth-email-errors.test.tsx +0 -55
- package/tests/currency-contract.test.ts +0 -85
- package/tests/default-metaobject-page.test.tsx +0 -56
- package/tests/display-format.test.ts +0 -403
- package/tests/fixtures/external-theme.tsx +0 -85
- package/tests/image-loading.test.tsx +0 -31
- package/tests/language-switcher.test.tsx +0 -350
- package/tests/list-pagination.test.tsx +0 -71
- package/tests/locale-switch.test.ts +0 -197
- package/tests/locale-transport.test.tsx +0 -321
- package/tests/manifest-parity.test.ts +0 -54
- package/tests/markdown-render.test.ts +0 -46
- package/tests/metaobjects-block.test.tsx +0 -70
- package/tests/navigation.test.tsx +0 -30
- package/tests/orders-walk.test.ts +0 -48
- package/tests/page-renderer-signature.test.tsx +0 -42
- package/tests/powered-by-queek.test.tsx +0 -65
- package/tests/powered-by-url.test.ts +0 -49
- package/tests/product-images.test.ts +0 -242
- package/tests/product-metafields.test.tsx +0 -154
- package/tests/public-surface.test.ts +0 -35
- package/tests/shop-query.test.ts +0 -64
- package/tests/shopper-chrome-strings.test.tsx +0 -271
- package/tests/storefront-client-scope.test.ts +0 -43
- package/tests/theme-hook-placement.test.tsx +0 -179
- package/tests/theme-strings-client.test.tsx +0 -310
- package/tests/theme-strings-pseudo.test.tsx +0 -171
- package/tests/theme-strings-typed.test.tsx +0 -97
- package/tests/theme-strings.test.tsx +0 -401
- package/tests/theme-typed-keys.fixture.ts +0 -76
- 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
package/README.md
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
# @usequeek/theme-kit
|
|
2
2
|
|
|
3
|
-
The
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
30
|
-
`
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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 />
|
|
350
|
-
<LanguageSwitcher variant="
|
|
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'` | `'
|
|
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` |
|
|
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)
|
|
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.
|
|
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
|
|
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`,
|
|
460
|
-
|
|
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
|
|
464
|
-
# 2. Tag and push the tag
|
|
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).
|
package/api/collections.ts
CHANGED
|
@@ -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
|
}
|
package/apps/follow-card.tsx
CHANGED
|
@@ -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;
|
package/apps/subscribe-form.tsx
CHANGED
|
@@ -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,
|
|
26
|
-
*
|
|
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);
|
package/components/image.tsx
CHANGED
|
@@ -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
|