@clicktap/ui 0.40.8 → 0.40.9

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 (22) hide show
  1. package/docs/migrations/2026/01/2026-01-14-external-link-detection.md +290 -0
  2. package/docs/migrations/2026/01/2026-01-14-use-route-hook.md +142 -0
  3. package/docs/migrations/2026/01/2026-01-15-input-disable-skeleton.md +82 -0
  4. package/docs/migrations/2026/02/2026-02-16-env-utils-from-clicktap-ui.md +94 -0
  5. package/docs/migrations/2026/02/2026-02-17-select-popover-portal-container-restored.md +84 -0
  6. package/docs/migrations/2026/03/2026-03-24-image-dimmed-hidden-onready.md +108 -0
  7. package/docs/migrations/2026/03/2026-03-24-modal-overlay-backdrop-dismiss.md +81 -0
  8. package/docs/migrations/2026/04/2026-04-17-select-address-input-onchange-key-or-item.md +182 -0
  9. package/docs/migrations/2026/04/2026-04-24-image-placeholder-frontend-fallback.md +85 -0
  10. package/docs/migrations/2026/04/2026-04-30-prune-invalid-pdp-selections.md +166 -0
  11. package/docs/migrations/2026/04/2026-04-30-top-down-pdp-option-dependency.md +286 -0
  12. package/docs/migrations/2026/05/2026-05-01-build-pdp-url-util.md +128 -0
  13. package/docs/migrations/2026/05/2026-05-03-auth-aware-graphql-client.md +278 -0
  14. package/docs/migrations/2026/05/2026-05-20-framer-motion-11-idioms.md +391 -0
  15. package/docs/migrations/2026/05/2026-05-21-button-loading-content.md +265 -0
  16. package/docs/migrations/2026/05/2026-05-21-password-check-hide-on-empty.md +50 -0
  17. package/docs/migrations/2026/05/2026-05-29-password-check-requirements-variant.md +63 -0
  18. package/docs/migrations/2026/06/2026-06-17-select-standardization.md +286 -0
  19. package/docs/migrations/2026/06/2026-06-18-select-hiddenselect-setvalue-crash.md +75 -0
  20. package/docs/migrations/2026/07/2026-07-31-ui-exports-bundler-resolution.md +50 -0
  21. package/docs/migrations/README.md +31 -0
  22. package/package.json +1 -1
@@ -0,0 +1,286 @@
1
+ # Top-Down PDP Option Dependency — `@clicktap/ui/utils/variantOptions`
2
+
3
+ **Date:** 2026-04-30
4
+ **Affects:** New utility module `@clicktap/ui/utils/variantOptions` (ships generic `filterAvailableOptions`, `findVariant`, `getDownstreamAxisCodes`)
5
+ **Classification:** IMPROVEMENT
6
+ **Breaking:** No (additive)
7
+
8
+ ## Dependencies
9
+
10
+ - Builds on the per-axis selection model used in [`apps/frontend/.../2026-04-24-pdp-configurable-selector-ux.md`](../../../../../../apps/frontend/docs/migrations/2026/04/2026-04-24-pdp-configurable-selector-ux.md). The previous symmetric filter lived in the frontend app; that logic now lives here in generic, reusable form. See [`apps/frontend/.../2026-04-30-top-down-pdp-option-dependency.md`](2026-04-30-top-down-pdp-option-dependency.md) ↔ for the frontend-side migration steps that adopt the new utilities and switch to top-down semantics.
11
+
12
+ ## Summary
13
+
14
+ Adds a small reusable module — `@clicktap/ui/utils/variantOptions` — for any consumer that builds a configurable-PDP variant picker. Three pure functions:
15
+
16
+ 1. **`filterAvailableOptions(variants, selections, axes, axisCode)`** — top-down: returns option codes selectable for `axisCode` given only the *upstream* selections (axes earlier in `axes`). Downstream selections never constrain.
17
+ 2. **`findVariant(variants, selections)`** — first variant whose attributes match every non-empty selection; matches by option `code` or `id`.
18
+ 3. **`getDownstreamAxisCodes(axes, axisCode)`** — axis codes after `axisCode` in `axes`. Useful for clearing dependent selections when an upstream axis changes.
19
+
20
+ All three are generic (`<V extends VariantLike>`) and operate on a minimal structural shape so consumers don't have to flatten their domain types. Pure, no React dependency, no side effects.
21
+
22
+ ## Why top-down
23
+
24
+ Symmetric filtering — selecting any axis filters every other axis equally — is what most early implementations do. It feels coherent until the user wants to change a higher-priority pick: every dependent dropdown is now scoped by their previous lower picks, and they can't re-explore the upstream space without manually clearing each lower selection first.
25
+
26
+ Top-down inverts the relationship: the *first* axis is always fully open, the *second* is filtered by the first, and so on. Changing the first axis is free. The cost is that the consumer must clear lower selections when an upstream changes (their value may not exist in the new scope) — `getDownstreamAxisCodes` makes that mechanical.
27
+
28
+ This module ships the filter logic only. The selection-state mechanics (clearing downstream when upstream changes, URL sync, manual-clear guards) stay in the consumer app since those are wired into framework-specific state and routing.
29
+
30
+ ## Changes
31
+
32
+ ### New module
33
+
34
+ **Source:** `libs/ui/src/utils/variantOptions/`
35
+
36
+ ```
37
+ libs/ui/src/utils/variantOptions/
38
+ ├── filterAvailableOptions.ts
39
+ ├── filterAvailableOptions.test.ts
40
+ ├── findVariant.ts
41
+ ├── findVariant.test.ts
42
+ ├── getDownstreamAxisCodes.ts
43
+ ├── getDownstreamAxisCodes.test.ts
44
+ ├── index.ts
45
+ └── types.ts
46
+ ```
47
+
48
+ ### Public API
49
+
50
+ ```ts
51
+ import {
52
+ filterAvailableOptions,
53
+ findVariant,
54
+ getDownstreamAxisCodes,
55
+ type VariantLike,
56
+ type VariantAttribute,
57
+ } from '@clicktap/ui/utils/variantOptions';
58
+ ```
59
+
60
+ ### Type contract
61
+
62
+ The minimum structural shape required by every function:
63
+
64
+ ```ts
65
+ // libs/ui/src/utils/variantOptions/types.ts
66
+ export interface VariantAttribute {
67
+ attribute: { code: string };
68
+ option: { code: string; id?: string | null };
69
+ }
70
+
71
+ export interface VariantLike {
72
+ attributes: VariantAttribute[];
73
+ }
74
+ ```
75
+
76
+ Real-world variants typically carry far more (price, image, sku, …). Pass them in as-is — the generic constraint only requires `attributes`.
77
+
78
+ ### `filterAvailableOptions`
79
+
80
+ ```ts
81
+ export function filterAvailableOptions<V extends VariantLike>(
82
+ variants: V[],
83
+ selections: Record<string, string>,
84
+ axes: string[],
85
+ axisCode: string,
86
+ ): Set<string>
87
+ ```
88
+
89
+ - `axes` is the contract: top-down order, upstream → downstream. The caller decides ordering (typically via the attribute's `sortPriority` from the backend).
90
+ - For `axisCode` at index `i` in `axes`, only `selections[axes[0]]…selections[axes[i-1]]` constrain. Selections on `axes[i+1]…` are ignored.
91
+ - `axisCode === axes[0]` (or any axis not in `axes`) returns the full reachable set across `variants` for that code.
92
+ - Empty-string and `undefined` selections are treated as unconstrained.
93
+
94
+ ### `findVariant`
95
+
96
+ ```ts
97
+ export function findVariant<V extends VariantLike>(
98
+ variants: V[],
99
+ selections: Record<string, string>,
100
+ ): V | undefined
101
+ ```
102
+
103
+ - Empty/missing selections per axis are unconstrained.
104
+ - A selection matches if it equals either the option's `code` or `id` — supports callers that key by either.
105
+
106
+ ### `getDownstreamAxisCodes`
107
+
108
+ ```ts
109
+ export function getDownstreamAxisCodes(
110
+ axes: string[],
111
+ axisCode: string,
112
+ ): string[]
113
+ ```
114
+
115
+ - Returns `axes.slice(idx + 1)` where `idx = axes.indexOf(axisCode)`.
116
+ - Returns `[]` if `axisCode` isn't in `axes` (defensive — caller may pass a non-axis code).
117
+
118
+ ## Pattern for Consumers
119
+
120
+ A minimal PDP variant picker built on these utilities looks like this:
121
+
122
+ ```tsx
123
+ import { useMemo, useCallback, useState } from 'react';
124
+ import {
125
+ filterAvailableOptions,
126
+ findVariant,
127
+ getDownstreamAxisCodes,
128
+ } from '@clicktap/ui/utils/variantOptions';
129
+
130
+ type Variant = {
131
+ id: string;
132
+ attributes: Array<{
133
+ attribute: { code: string };
134
+ option: { code: string; id?: string };
135
+ }>;
136
+ // ...your domain fields
137
+ };
138
+
139
+ type Props = {
140
+ // Top-down ordered axes — upstream first.
141
+ axes: { code: string; label: string; options: { code: string; label: string }[] }[];
142
+ variants: Variant[];
143
+ };
144
+
145
+ export function VariantPicker({ axes, variants }: Props) {
146
+ const axisCodes = useMemo(() => axes.map((a) => a.code), [axes]);
147
+ const [selections, setSelections] = useState<Record<string, string>>({});
148
+
149
+ const setSelection = useCallback(
150
+ (axisCode: string, optionCode: string) => {
151
+ setSelections((prev) => {
152
+ // Top-down: clear any selection downstream of this axis. Their value
153
+ // may not exist in the new upstream scope.
154
+ const downstream = getDownstreamAxisCodes(axisCodes, axisCode);
155
+ const next: Record<string, string> = { ...prev, [axisCode]: optionCode };
156
+ for (const code of downstream) {
157
+ delete next[code];
158
+ }
159
+ return next;
160
+ });
161
+ },
162
+ [axisCodes],
163
+ );
164
+
165
+ const matchedVariant = useMemo(
166
+ () => findVariant(variants, selections),
167
+ [variants, selections],
168
+ );
169
+
170
+ return (
171
+ <div>
172
+ {axes.map((axis) => {
173
+ const reachable = filterAvailableOptions(
174
+ variants,
175
+ selections,
176
+ axisCodes,
177
+ axis.code,
178
+ );
179
+ return (
180
+ <div key={axis.code}>
181
+ <label>{axis.label}</label>
182
+ {axis.options
183
+ .filter((o) => reachable.has(o.code))
184
+ .map((o) => (
185
+ <button
186
+ key={o.code}
187
+ aria-pressed={selections[axis.code] === o.code}
188
+ onClick={() => setSelection(axis.code, o.code)}
189
+ >
190
+ {o.label}
191
+ </button>
192
+ ))}
193
+ </div>
194
+ );
195
+ })}
196
+ {matchedVariant && <p>Matched: {matchedVariant.id}</p>}
197
+ </div>
198
+ );
199
+ }
200
+ ```
201
+
202
+ Annotations:
203
+ - The `setSelections` callback is the entire top-down rule — clear downstream on any upstream change. Skip this and the picker behaves as a buggy version of symmetric filtering.
204
+ - `filterAvailableOptions` is called once per axis on each render. Cheap — O(variants × attributes).
205
+ - The pattern intentionally doesn't cover URL sync, manual-clear guards, or auto-collapse. Those are framework- and routing-specific; layer them on top.
206
+
207
+ ## Migration Steps
208
+
209
+ This module is purely additive — there's no migration *required* of existing `@clicktap/ui` consumers. The steps below are for consumers who want to adopt it.
210
+
211
+ ### 1. Bump `@clicktap/ui`
212
+
213
+ Once a release including this module is published, update the dependency:
214
+
215
+ ```bash
216
+ pnpm up @clicktap/ui
217
+ ```
218
+
219
+ Minimum version: **next minor after 0.27.2** (set the explicit floor once released).
220
+
221
+ ### 2. Import where needed
222
+
223
+ ```ts
224
+ import {
225
+ filterAvailableOptions,
226
+ findVariant,
227
+ getDownstreamAxisCodes,
228
+ } from '@clicktap/ui/utils/variantOptions';
229
+ ```
230
+
231
+ ### 3. Pass top-down axis order
232
+
233
+ Wherever you call `filterAvailableOptions`, pass the axes array in upstream-to-downstream order. The natural source is your attribute list sorted by priority — for backends following the Clicktap shape, that's `sortPriority DESC`:
234
+
235
+ ```ts
236
+ const axisCodes = product.attributes.map((a) => a.code);
237
+ // product.attributes is already sorted by sortPriority DESC server-side.
238
+
239
+ const reachable = filterAvailableOptions(
240
+ product.variants,
241
+ userSelections,
242
+ axisCodes,
243
+ attribute.code,
244
+ );
245
+ ```
246
+
247
+ ### 4. Clear downstream on upstream change
248
+
249
+ When the user picks a value for any axis, drop any downstream selections in the same state update:
250
+
251
+ ```ts
252
+ const setUserSelection = (axisCode: string, optionCode: string) => {
253
+ setSelections((prev) => {
254
+ const downstream = getDownstreamAxisCodes(axisCodes, axisCode);
255
+ const next = { ...prev, [axisCode]: optionCode };
256
+ for (const code of downstream) {
257
+ delete next[code];
258
+ }
259
+ return next;
260
+ });
261
+ };
262
+ ```
263
+
264
+ Skip this and you'll occasionally see the matched variant query against an impossible (upstream-changed but downstream-stale) selection set.
265
+
266
+ ### Verification
267
+
268
+ - New axis renders all reachable options regardless of downstream state.
269
+ - Picking the upper axis clears the lower selections.
270
+ - Picking the lowest axis doesn't change which upper options are reachable.
271
+
272
+ ## Files Changed
273
+
274
+ | File | Change |
275
+ |---|---|
276
+ | `libs/ui/src/utils/variantOptions/types.ts` | New — `VariantLike`, `VariantAttribute` |
277
+ | `libs/ui/src/utils/variantOptions/filterAvailableOptions.ts` | New — top-down filter |
278
+ | `libs/ui/src/utils/variantOptions/findVariant.ts` | New — generic match |
279
+ | `libs/ui/src/utils/variantOptions/getDownstreamAxisCodes.ts` | New — slice helper |
280
+ | `libs/ui/src/utils/variantOptions/index.ts` | New — barrel export |
281
+ | `libs/ui/src/utils/variantOptions/*.test.ts` | New — Jest coverage (15 tests) |
282
+
283
+ ## References
284
+
285
+ - Frontend counterpart migration: [`apps/frontend/.../2026-04-30-top-down-pdp-option-dependency.md`](2026-04-30-top-down-pdp-option-dependency.md) ↔
286
+ - Prior symmetric-filter implementation it replaces: [`apps/frontend/.../2026-04-24-pdp-configurable-selector-ux.md`](../../../../../../apps/frontend/docs/migrations/2026/04/2026-04-24-pdp-configurable-selector-ux.md)
@@ -0,0 +1,128 @@
1
+ # `buildPdpUrl` Utility — `@clicktap/ui/utils/buildPdpUrl`
2
+
3
+ **Date:** 2026-05-01
4
+ **Affects:** `@clicktap/ui/utils/buildPdpUrl` — new export
5
+ **Classification:** IMPROVEMENT
6
+ **Breaking:** No (additive)
7
+
8
+ ## Dependencies
9
+
10
+ *None.*
11
+
12
+ ## Summary
13
+
14
+ Adds `buildPdpUrl(canonicalUrl, typeOptions)` to `@clicktap/ui/utils/buildPdpUrl`. Encodes a configurable item's variant selections onto its PDP canonical URL as `?attributeCode=optionCode` pairs, matching `ProductTypeResolver.writeSelectionsToUrl` so deep-linking from a cart/order item lands on the exact variant.
15
+
16
+ ## Why
17
+
18
+ Cart and order item rows hold both a canonical URL (e.g. `/everyday-backpack`) and a list of selected attributes (e.g. `color: orange`, `size: m`). Linking with just the canonical URL drops the variant context — the customer lands on the PDP with no swatch pre-selected and has to re-pick. Encoding the selections onto the link mirrors what the PDP itself writes back to the URL on swatch change, so navigating into a PDP from a cart/order row produces the same URL as if the user had configured the PDP themselves.
19
+
20
+ The helper was originally inlined under `apps/frontend/components/Checkout/Success/Items/buildPdpUrl.ts`, used only by the order-success page. Promoting it to `@clicktap/ui` makes it reusable from the cart drawer (and any future consumer that pairs a canonical URL with a `typeOptions`-shaped attribute list).
21
+
22
+ ## Public API
23
+
24
+ ```ts
25
+ import { buildPdpUrl } from '@clicktap/ui/utils/buildPdpUrl';
26
+ ```
27
+
28
+ ```ts
29
+ /**
30
+ * Append configurable-product variant selections to a PDP canonical URL as
31
+ * `?attributeCode=optionCode` pairs (e.g. `?color=black`).
32
+ *
33
+ * Accepts the structural shape that any cart-item / order-item / quote-item
34
+ * `typeOptions` produces on the configurable case.
35
+ *
36
+ * Returns the URL unchanged when:
37
+ * - the URL is null/empty,
38
+ * - the item isn't configurable (no attributes), or
39
+ * - the existing URL already carries a query string (we don't merge —
40
+ * the item's own variant context is authoritative, and bailing keeps
41
+ * caller intent clear).
42
+ */
43
+ function buildPdpUrl(
44
+ canonicalUrl: string | null | undefined,
45
+ typeOptions: unknown,
46
+ ): string | null;
47
+ ```
48
+
49
+ The `typeOptions` parameter is intentionally typed `unknown` and read structurally as `{ attributes?: Array<{ attribute?: { code? }, option?: { code? } }> }`. This lets the same helper accept both `OrderItemTypeOptions` and `QuoteItemTypeOptions` (and any other GraphQL union with the same shape) without forcing the lib to import the consumer's generated types.
50
+
51
+ ## Behavior summary
52
+
53
+ | Input | Output |
54
+ |---|---|
55
+ | `(null, ...)` | `null` |
56
+ | `('', ...)` | `null` |
57
+ | `('/foo', null)` | `'/foo'` |
58
+ | `('/foo', { attributes: [] })` | `'/foo'` |
59
+ | `('/foo', { attributes: [{ attribute: { code: 'color' }, option: { code: 'red' } }] })` | `'/foo?color=red'` |
60
+ | `('/foo', { attributes: [{ color }, { size }] })` | `'/foo?color=red&size=m'` |
61
+ | `('/foo?bar=1', { attributes: [...] })` | `'/foo?bar=1'` (URL has a query — bail rather than merge) |
62
+
63
+ ## Pattern for Consumers
64
+
65
+ Use it wherever a cart/order item row links to its PDP and you want the variant pre-selected:
66
+
67
+ ```tsx
68
+ import { Link } from '@clicktap/ui/components/Link';
69
+ import { buildPdpUrl } from '@clicktap/ui/utils/buildPdpUrl';
70
+
71
+ function CartItemRow({ item }: { item: QuoteItem }) {
72
+ const pdpUrl = buildPdpUrl(item.product.canonicalUrl, item.typeOptions) ?? '';
73
+ return (
74
+ <Link href={pdpUrl}>
75
+ {item.name}
76
+ </Link>
77
+ );
78
+ }
79
+ ```
80
+
81
+ The function is structural, so `item.typeOptions` doesn't need to be cast — pass it directly.
82
+
83
+ ## Migration Steps
84
+
85
+ This is purely additive — existing code keeps compiling. To adopt:
86
+
87
+ ### 1. Bump `@clicktap/ui`
88
+
89
+ ```bash
90
+ pnpm up @clicktap/ui
91
+ ```
92
+
93
+ Minimum version: **next minor after the release that ships this migration**.
94
+
95
+ ### 2. Replace any inlined copies with the shared util
96
+
97
+ Search consumer projects for inlined copies that predate this migration:
98
+
99
+ ```bash
100
+ grep -rn 'buildPdpUrl' apps/ libs/ --include='*.ts' --include='*.tsx'
101
+ ```
102
+
103
+ Switch each call site to:
104
+
105
+ ```ts
106
+ import { buildPdpUrl } from '@clicktap/ui/utils/buildPdpUrl';
107
+ ```
108
+
109
+ If the consumer project had its own typed-to-`OrderItemTypeOptions` (or any other concrete) signature, the structural `unknown` parameter accepts it without any cast change at the call site — the existing arguments compile as-is.
110
+
111
+ ### 3. (Optional) Wire it into cart/order link components
112
+
113
+ Anywhere a cart/order row currently links via `product.canonicalUrl` directly, swap to `buildPdpUrl(product.canonicalUrl, typeOptions)`. Make sure your GraphQL selection actually fetches `canonicalUrl` and the `typeOptions { ... attributes { attribute { code }, option { code } } }` block — without those, the helper returns the URL unchanged and the variant won't pre-select.
114
+
115
+ ### Verification
116
+
117
+ - **Behavior**: open a cart drawer (or order success page) for a configurable product with attributes set, click the item link. The PDP should load with the swatches matching the variant pre-selected.
118
+ - **Typecheck**: `nx run <app>:typecheck` → no errors.
119
+
120
+ ## Files Changed
121
+
122
+ | File | Change |
123
+ |---|---|
124
+ | `libs/ui/src/utils/buildPdpUrl.ts` | New — structural helper |
125
+
126
+ ## References
127
+
128
+ - PDP encoding source of truth: `apps/frontend/components/Product/ProductType/.../ProductTypeResolver.writeSelectionsToUrl`
@@ -0,0 +1,278 @@
1
+ # Auth-aware GraphQL client — `@clicktap/ui/utils/createAuthAwareGraphqlClient`
2
+
3
+ **Date:** 2026-05-03
4
+ **Affects:** `@clicktap/ui/utils/createAuthAwareGraphqlClient` — new export
5
+ **Classification:** IMPROVEMENT
6
+ **Breaking:** No (additive)
7
+
8
+ ## Dependencies
9
+
10
+ *None.*
11
+
12
+ ## Summary
13
+
14
+ Adds two factories and an event bus for handling `graphql-request` responses uniformly across consumer apps:
15
+
16
+ - `createAuthAwareGraphqlClient()` — returns a `request(url, doc, vars, headers)` callable plus a `.withOptions({ … signal })` overload. Drop-in for any caller that previously used bare `request()` from `graphql-request`.
17
+ - `createAuthAwareFetch()` — returns a `fetch` function suitable for `new GraphQLClient(url, { fetch: … })` when the caller manages their own client config (cors, credentials, custom headers).
18
+ - `authAwareEvents` — a small, app-runtime-agnostic event bus. Both factories dispatch `unauthenticated` (HTTP 401), `permissionDenied` (403), and `serverError` (5xx) events. Consumer apps subscribe from a Provider component (where xstate actors and routers are in scope) to wire logout / toast / redirect.
19
+
20
+ The `request()` form additionally returns the partial `data` from a HTTP-200-with-errors response — single-field GraphQL errors don't tear down the whole UI. (Same behavior the prior project-level `apps/frontend/utils/graphqlRequest.ts` had — moved here so every consumer gets it for free.)
21
+
22
+ ## Why
23
+
24
+ Every consumer app was reinventing the same four pieces in its own actors / mutation handlers:
25
+
26
+ 1. 401 → log out + toast + redirect to login
27
+ 2. 403 → permission-denied toast
28
+ 3. 5xx → service-unavailable toast
29
+ 4. Partial responses → return `data`, don't throw
30
+
31
+ The 401 path in particular was load-bearing for the upcoming "no-guest storefront" pattern (see [`2026-05-03-guest-checkout-disabled-storefront.md`](#) cross-app guides), but its scope is broader: any project that wants its session-expired UX coherent benefits.
32
+
33
+ The factory pattern decouples the lib from app runtime context. The lib emits typed events; the app subscribes from a Provider where it has the runtime concerns (auth machine, toast actor, router). Lib stays pure; consumers stay flexible.
34
+
35
+ ## Public API
36
+
37
+ ```ts
38
+ import {
39
+ createAuthAwareGraphqlClient,
40
+ createAuthAwareFetch,
41
+ authAwareEvents,
42
+ ClientError, // re-exported — see "Cross-package error handling" below
43
+ GraphQLClient, // re-exported
44
+ type AuthAwareEventName,
45
+ type AuthAwareEventDetail,
46
+ } from '@clicktap/ui/utils/createAuthAwareGraphqlClient';
47
+
48
+ import { getRequestErrorStatus } from '@clicktap/ui/utils/error';
49
+ ```
50
+
51
+ ```ts
52
+ /**
53
+ * Returns a callable: `(url, document, variables?, headers?) => Promise<TData>`.
54
+ * Plus a `.withOptions({ url, document, variables?, headers?, signal? })` form
55
+ * for actors needing AbortSignal support.
56
+ *
57
+ * Internally creates a fresh GraphQLClient per call. On error: dispatches
58
+ * the appropriate event (401/403/5xx) and either rethrows or returns the
59
+ * partial `data` from a HTTP-200-with-errors response.
60
+ */
61
+ function createAuthAwareGraphqlClient(): AuthAwareGraphqlClient;
62
+
63
+ /**
64
+ * Returns a `fetch` function. Pass to:
65
+ * new GraphQLClient(url, { fetch: createAuthAwareFetch(), credentials, mode, … })
66
+ *
67
+ * Wraps the global `fetch`. On 401/403/5xx response, dispatches the
68
+ * matching event with `{ status, response }`. The original Response is
69
+ * returned untouched — graphql-request still parses, throws, etc. exactly
70
+ * as it would have. The event dispatch is a side channel.
71
+ */
72
+ function createAuthAwareFetch(): typeof fetch;
73
+
74
+ /**
75
+ * Module-level event bus. Subscribe with a callback; returns an unsubscribe
76
+ * function.
77
+ *
78
+ * authAwareEvents.subscribe('unauthenticated', ({ status, error, response }) => { … });
79
+ */
80
+ export const authAwareEvents: {
81
+ subscribe(name: AuthAwareEventName, listener: (detail: AuthAwareEventDetail) => void): () => void;
82
+ dispatch(name: AuthAwareEventName, detail: AuthAwareEventDetail): void;
83
+ };
84
+
85
+ type AuthAwareEventName = 'unauthenticated' | 'permissionDenied' | 'serverError';
86
+ interface AuthAwareEventDetail {
87
+ status: number;
88
+ /** Set when triggered from a `graphql-request` ClientError. */
89
+ error?: ClientError;
90
+ /** Set when triggered from the fetch wrapper. */
91
+ response?: Response;
92
+ }
93
+ ```
94
+
95
+ ## Pattern for consumers
96
+
97
+ Inside a Provider component (where actors + router + toast are in scope):
98
+
99
+ ```tsx
100
+ import { authAwareEvents } from '@clicktap/ui/utils/createAuthAwareGraphqlClient';
101
+ import { useAuth } from '@/state/auth/AuthProvider';
102
+ import { useToast } from '@/state/toast/ToastProvider';
103
+
104
+ function AuthAwareEventBridge({ children }: { children: ReactNode }) {
105
+ const { authActor } = useAuth();
106
+ const { toastActor } = useToast();
107
+
108
+ useEffect(() => {
109
+ const unsubUnauth = authAwareEvents.subscribe('unauthenticated', () => {
110
+ authActor.send({ type: 'LOGOUT' });
111
+ toastActor.send({
112
+ type: 'ADD_ITEM',
113
+ item: <Alert type="error" title="Session expired" message="Please log in again." />,
114
+ duration: 5000,
115
+ });
116
+ });
117
+ const unsubPerm = authAwareEvents.subscribe('permissionDenied', () => {
118
+ toastActor.send({
119
+ type: 'ADD_ITEM',
120
+ item: <Alert type="error" title="Permission denied" />,
121
+ duration: 5000,
122
+ });
123
+ });
124
+ const unsub5xx = authAwareEvents.subscribe('serverError', () => {
125
+ toastActor.send({
126
+ type: 'ADD_ITEM',
127
+ item: <Alert type="error" title="Service unavailable" />,
128
+ duration: 5000,
129
+ });
130
+ });
131
+ return () => { unsubUnauth(); unsubPerm(); unsub5xx(); };
132
+ }, [authActor, toastActor]);
133
+
134
+ return <>{children}</>;
135
+ }
136
+ ```
137
+
138
+ For raw callers, two flavors:
139
+
140
+ **Caller that previously used `request()` from `graphql-request`:**
141
+
142
+ ```ts
143
+ // Before
144
+ import { request } from 'graphql-request';
145
+ const data = await request(url, document, vars, headers);
146
+
147
+ // After
148
+ import { graphqlRequest } from '@/utils/graphqlRequest';
149
+ // where graphqlRequest is `createAuthAwareGraphqlClient()` instantiated once at module scope
150
+ const data = await graphqlRequest(url, document, vars, headers);
151
+ ```
152
+
153
+ **Caller that constructs its own `GraphQLClient`:**
154
+
155
+ ```ts
156
+ // Before
157
+ const client = new GraphQLClient(url, { credentials: 'include', mode: 'cors' });
158
+
159
+ // After
160
+ import { createAuthAwareFetch } from '@clicktap/ui/utils/createAuthAwareGraphqlClient';
161
+ const client = new GraphQLClient(url, {
162
+ credentials: 'include',
163
+ mode: 'cors',
164
+ fetch: createAuthAwareFetch(),
165
+ });
166
+ ```
167
+
168
+ ## Cross-package error handling — `ClientError` re-export + `getRequestErrorStatus`
169
+
170
+ **Added 2026-06-15.**
171
+
172
+ `graphql-request` is bundled into every consuming package, so its `ClientError`
173
+ class is a *different object* in each bundle. A consumer that catches an error
174
+ thrown by this client and checks `error instanceof ClientError` using its **own**
175
+ `import { ClientError } from 'graphql-request'` always gets `false` — the class
176
+ identities don't match across the package boundary. In practice this silently
177
+ broke the 401→login redirect on SSR route fetches: the `instanceof` guard
178
+ failed, the 401 fell through to a rethrow, and the page **500'd instead of
179
+ redirecting**.
180
+
181
+ Two fixes, both additive:
182
+
183
+ 1. **`ClientError` (and `GraphQLClient`) are re-exported from the client
184
+ module.** Import them from `@clicktap/ui` so the class identity matches the
185
+ one this client throws:
186
+
187
+ ```ts
188
+ import { ClientError } from '@clicktap/ui/utils/createAuthAwareGraphqlClient';
189
+
190
+ try {
191
+ return await fetchRoute(path);
192
+ } catch (error) {
193
+ if (error instanceof ClientError && error.response?.status === 401) {
194
+ redirect('/account/authenticate?…');
195
+ }
196
+ throw error;
197
+ }
198
+ ```
199
+
200
+ 2. **`getRequestErrorStatus(error)` — identity-agnostic status extraction.**
201
+ Prefer this at cross-package boundaries: it duck-types `response.status`, so
202
+ it works regardless of which bundle threw, with no `instanceof` at all.
203
+ Returns `undefined` when no numeric status is present.
204
+
205
+ ```ts
206
+ import { getRequestErrorStatus } from '@clicktap/ui/utils/error';
207
+
208
+ try {
209
+ return await fetchRoute(path);
210
+ } catch (error) {
211
+ if (getRequestErrorStatus(error) === 401) {
212
+ redirect(`/account/authenticate?redirect=${path}&reason=authentication_required`);
213
+ }
214
+ throw error;
215
+ }
216
+ ```
217
+
218
+ ### SSR route fetches must forward the auth token
219
+
220
+ Now that catalog/entity routes can be auth-gated on the backend (the router's
221
+ `EntityAuthenticationGate` 401s a guest request whose route resolves to a gated
222
+ entity type), an SSR route/data fetch that omits the caller's bearer token makes
223
+ a **logged-in user look unauthenticated to the gate** — they get bounced like a
224
+ guest. SSR fetches for gated routes must forward `Authorization: Bearer <token>`
225
+ from the request's session:
226
+
227
+ ```ts
228
+ const token = await getAccessToken(); // request-memoized; no extra round-trip
229
+ const data = await graphqlRequest(url, GetRoute, { path }, {
230
+ ...scopeHeaders,
231
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
232
+ });
233
+ ```
234
+
235
+ The storefront-auth-gating guide documents where route protection is configured.
236
+
237
+ ## Migration Steps
238
+
239
+ This is purely additive — existing code keeps compiling. To adopt:
240
+
241
+ ### 1. Bump `@clicktap/ui`
242
+
243
+ ```bash
244
+ pnpm up @clicktap/ui
245
+ ```
246
+
247
+ Minimum version: **next minor after the release that ships this migration**.
248
+
249
+ ### 2. Build a Provider-level event bridge
250
+
251
+ Mount it once high in the tree (alongside `AuthProvider` / `ToastProvider`). Subscribe to the three events, dispatch into your project's runtime. See "Pattern for consumers" above.
252
+
253
+ ### 3. (Incremental) Migrate actors that hit gated GraphQL operations
254
+
255
+ For any actor that performs a mutation the backend has gated behind auth (e.g. cart mutations on a no-guest storefront), switch its GraphQL call to one of the two forms above. The wrapping is what fires the events.
256
+
257
+ Actors that hit non-gated operations don't need migration unless you want their 5xx / 403 to surface a toast too — adopt incrementally.
258
+
259
+ ## Verification
260
+
261
+ - **Typecheck** clean.
262
+ - **Browser smoke test**:
263
+ 1. Mount a page that performs a wrapped GraphQL request.
264
+ 2. Force the response to 401 (devtools → request blocking → respond with 401, or expire the JWT).
265
+ 3. Confirm: subscriber callback fires; auth/toast/router behave as wired.
266
+ 4. Repeat for 403 and 503.
267
+
268
+ ## Files Changed
269
+
270
+ | File | Change |
271
+ |---|---|
272
+ | `libs/ui/src/utils/createAuthAwareGraphqlClient.ts` | NEW — factories + event bus; re-exports `ClientError`/`GraphQLClient` (2026-06-15) |
273
+ | `libs/ui/src/utils/error.ts` | `getRequestErrorStatus()` — identity-agnostic HTTP-status extraction (2026-06-15) |
274
+
275
+ ## References
276
+
277
+ - Frontend wiring (consumes this): [`apps/frontend/.../2026-05-03-guest-checkout-disabled-storefront.md`](../../../../../apps/frontend/docs/migrations/2026/05/2026-05-03-guest-checkout-disabled-storefront.md)
278
+ - Backend that 401s the gated operations: [`apps/middleware/.../2026-05-03-guest-checkout-disabled-storefront.md`](../../../../../apps/middleware/docs/migrations/2026/05/2026-05-03-guest-checkout-disabled-storefront.md)