@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/sdk/error-handler.ts
CHANGED
|
@@ -6,8 +6,15 @@ import { useUserStore } from '../stores/user-store';
|
|
|
6
6
|
import { isReauthSuppressed } from './reauth-guard';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
22
|
-
* repeatedly
|
|
23
|
-
*
|
|
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');
|
package/sdk/queek-client.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
124
|
-
* place that
|
|
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
|
package/shared-blocks/embed.tsx
CHANGED
|
@@ -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
|
-
* (
|
|
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>
|
|
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=
|
|
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: '
|
|
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 }),
|
package/stores/cart-store.ts
CHANGED
|
@@ -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
|
|
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
|
|
18
|
-
* last resort only;
|
|
19
|
-
*
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
package/stores/checkout-store.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
|
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 (
|
|
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. */
|
package/stores/location-store.ts
CHANGED
|
@@ -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: '',
|
package/stores/user-store.ts
CHANGED
|
@@ -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:
|
|
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
|
*/
|
package/strings/pseudo-locale.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
}
|
package/utils/menu-link.ts
CHANGED
|
@@ -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,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Popover collision handling for the language menu — pure (no DOM reads, no
|
|
3
|
+
* React) so it is unit-testable with plain numbers.
|
|
4
|
+
*
|
|
5
|
+
* The menu is positioned by CSS (under or over its trigger, aligned to the
|
|
6
|
+
* trigger's inline-start or inline-end edge). This module only decides
|
|
7
|
+
* whether that preferred placement fits the viewport, and if not, which of
|
|
8
|
+
* the alternatives to use: flip to the other side, flip the alignment, then
|
|
9
|
+
* shift horizontally to keep a gutter.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type MenuSide = 'bottom' | 'top';
|
|
13
|
+
export type MenuAlign = 'start' | 'end';
|
|
14
|
+
|
|
15
|
+
export interface Box {
|
|
16
|
+
top: number;
|
|
17
|
+
right: number;
|
|
18
|
+
bottom: number;
|
|
19
|
+
left: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PlacementInput {
|
|
23
|
+
trigger: Box;
|
|
24
|
+
/** The popover's size — independent of where it is placed. */
|
|
25
|
+
popover: { width: number; height: number };
|
|
26
|
+
viewport: { width: number; height: number };
|
|
27
|
+
side: MenuSide;
|
|
28
|
+
align: MenuAlign;
|
|
29
|
+
/** Page direction: `'end'` is the right edge in `ltr` and the left edge in `rtl`. */
|
|
30
|
+
dir: 'ltr' | 'rtl';
|
|
31
|
+
/** Space kept between the popover and the viewport edge. */
|
|
32
|
+
gutter?: number;
|
|
33
|
+
/** Gap between the trigger and the popover. */
|
|
34
|
+
offset?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface Placement {
|
|
38
|
+
side: MenuSide;
|
|
39
|
+
align: MenuAlign;
|
|
40
|
+
/** Horizontal correction in px, in the physical direction (positive = right). */
|
|
41
|
+
shift: number;
|
|
42
|
+
/** Room on the chosen side in px: the popover's height cap. */
|
|
43
|
+
available: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DEFAULT_GUTTER = 16;
|
|
47
|
+
const DEFAULT_OFFSET = 8;
|
|
48
|
+
|
|
49
|
+
/** Left edge of the popover for an alignment. */
|
|
50
|
+
function leftFor(align: MenuAlign, dir: 'ltr' | 'rtl', trigger: Box, width: number): number {
|
|
51
|
+
const rightEdgeAligned = (align === 'end') === (dir === 'ltr');
|
|
52
|
+
return rightEdgeAligned ? trigger.right - width : trigger.left;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Overflow (px) of a left edge past the gutters, 0 when it fits. */
|
|
56
|
+
function overflow(left: number, width: number, viewportWidth: number, gutter: number): number {
|
|
57
|
+
if (left < gutter) return gutter - left;
|
|
58
|
+
if (left + width > viewportWidth - gutter) return viewportWidth - gutter - (left + width);
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function computePlacement(input: PlacementInput): Placement {
|
|
63
|
+
const { trigger, popover, viewport, dir } = input;
|
|
64
|
+
const gutter = input.gutter ?? DEFAULT_GUTTER;
|
|
65
|
+
const offset = input.offset ?? DEFAULT_OFFSET;
|
|
66
|
+
|
|
67
|
+
const room = {
|
|
68
|
+
bottom: viewport.height - trigger.bottom - offset - gutter / 2,
|
|
69
|
+
top: trigger.top - offset - gutter / 2,
|
|
70
|
+
};
|
|
71
|
+
const other: MenuSide = input.side === 'bottom' ? 'top' : 'bottom';
|
|
72
|
+
const side =
|
|
73
|
+
popover.height <= room[input.side] || room[input.side] >= room[other] ? input.side : other;
|
|
74
|
+
|
|
75
|
+
const otherAlign: MenuAlign = input.align === 'end' ? 'start' : 'end';
|
|
76
|
+
const preferred = overflow(leftFor(input.align, dir, trigger, popover.width), popover.width, viewport.width, gutter);
|
|
77
|
+
let align = input.align;
|
|
78
|
+
let shift = preferred;
|
|
79
|
+
if (preferred !== 0) {
|
|
80
|
+
const alternative = overflow(leftFor(otherAlign, dir, trigger, popover.width), popover.width, viewport.width, gutter);
|
|
81
|
+
if (alternative === 0) {
|
|
82
|
+
align = otherAlign;
|
|
83
|
+
shift = 0;
|
|
84
|
+
} else if (Math.abs(alternative) < Math.abs(preferred)) {
|
|
85
|
+
align = otherAlign;
|
|
86
|
+
shift = alternative;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { side, align, shift, available: Math.max(0, Math.floor(room[side])) };
|
|
91
|
+
}
|
package/utils/vendor-host.ts
CHANGED
|
@@ -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 (
|
|
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();
|