@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.
- package/docs/migrations/2026/01/2026-01-14-external-link-detection.md +290 -0
- package/docs/migrations/2026/01/2026-01-14-use-route-hook.md +142 -0
- package/docs/migrations/2026/01/2026-01-15-input-disable-skeleton.md +82 -0
- package/docs/migrations/2026/02/2026-02-16-env-utils-from-clicktap-ui.md +94 -0
- package/docs/migrations/2026/02/2026-02-17-select-popover-portal-container-restored.md +84 -0
- package/docs/migrations/2026/03/2026-03-24-image-dimmed-hidden-onready.md +108 -0
- package/docs/migrations/2026/03/2026-03-24-modal-overlay-backdrop-dismiss.md +81 -0
- package/docs/migrations/2026/04/2026-04-17-select-address-input-onchange-key-or-item.md +182 -0
- package/docs/migrations/2026/04/2026-04-24-image-placeholder-frontend-fallback.md +85 -0
- package/docs/migrations/2026/04/2026-04-30-prune-invalid-pdp-selections.md +166 -0
- package/docs/migrations/2026/04/2026-04-30-top-down-pdp-option-dependency.md +286 -0
- package/docs/migrations/2026/05/2026-05-01-build-pdp-url-util.md +128 -0
- package/docs/migrations/2026/05/2026-05-03-auth-aware-graphql-client.md +278 -0
- package/docs/migrations/2026/05/2026-05-20-framer-motion-11-idioms.md +391 -0
- package/docs/migrations/2026/05/2026-05-21-button-loading-content.md +265 -0
- package/docs/migrations/2026/05/2026-05-21-password-check-hide-on-empty.md +50 -0
- package/docs/migrations/2026/05/2026-05-29-password-check-requirements-variant.md +63 -0
- package/docs/migrations/2026/06/2026-06-17-select-standardization.md +286 -0
- package/docs/migrations/2026/06/2026-06-18-select-hiddenselect-setvalue-crash.md +75 -0
- package/docs/migrations/2026/07/2026-07-31-ui-exports-bundler-resolution.md +50 -0
- package/docs/migrations/README.md +31 -0
- 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)
|