@clicktap/ui 0.40.8 → 0.40.10
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/components/Accordion/Accordion.d.ts +1 -1
- package/components/Accordion/Accordion.js +1 -1
- package/components/Accordion/index.d.ts +2 -2
- package/components/Accordion/index.js +1 -1
- package/components/Badge/Badge.js +1 -1
- package/components/Badge/Badge.types.d.ts +1 -1
- package/components/Badge/index.js +1 -1
- package/components/Collection/CollectionFilters/CollectionFilters.js +1 -1
- package/components/Collection/CollectionFilters/index.js +1 -1
- package/components/Collection/index.js +1 -1
- package/components/Pagination/Pagination.d.ts +1 -1
- package/components/Pagination/Pagination.js +1 -1
- package/components/Pagination/index.js +1 -1
- package/components/Tooltip/Tooltip.d.ts +1 -1
- package/components/Tooltip/Tooltip.js +1 -1
- package/components/Tooltip/Tooltip.types.d.ts +1 -1
- package/components/Tooltip/index.js +1 -1
- 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 +141 -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/2026/08/2026-08-02-nextui-to-heroui.md +65 -0
- package/docs/migrations/README.md +31 -0
- package/package.json +7 -5
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# `PLACEHOLDER_IMAGE` Export from `@clicktap/ui/utils/placeholder`
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-04-24
|
|
4
|
+
**Affects:** `@clicktap/ui` consumers that render product/category images
|
|
5
|
+
**Classification:** IMPROVEMENT
|
|
6
|
+
**Breaking:** No
|
|
7
|
+
|
|
8
|
+
## Dependencies
|
|
9
|
+
|
|
10
|
+
- See [frontend counterpart](2026-04-24-image-placeholder-frontend-fallback.md) ↔ for the consuming-app changes.
|
|
11
|
+
|
|
12
|
+
## Summary
|
|
13
|
+
|
|
14
|
+
Adds `PLACEHOLDER_IMAGE` — a self-contained data URL — to `@clicktap/ui/utils/placeholder`. Frontend apps render this whenever they have a null image to display, replacing the old "backend returns a placeholder URL" pattern.
|
|
15
|
+
|
|
16
|
+
The value is an inline `data:image/svg+xml;utf8,...` string containing a neutral grayscale "no image available" SVG (camera icon with diagonal slash). Works in every bundler (Next.js, Storybook, Jest, vanilla node) without consumer-side asset pipeline configuration — no `public/` copy, no rewrite, no build script.
|
|
17
|
+
|
|
18
|
+
## Changes
|
|
19
|
+
|
|
20
|
+
### New export
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
// libs/ui/src/utils/placeholder.ts
|
|
24
|
+
export const PLACEHOLDER_IMAGE: string;
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Import path:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { PLACEHOLDER_IMAGE } from '@clicktap/ui/utils/placeholder';
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### What it looks like
|
|
34
|
+
|
|
35
|
+
The SVG is 200×200, viewBox `0 0 200 200`, with:
|
|
36
|
+
- Light gray background (`#f3f4f6`)
|
|
37
|
+
- Camera icon (rect + viewfinder bump + concentric lens circles), stroke `#9ca3af` weight 4
|
|
38
|
+
- Diagonal slash from upper-right to lower-left at 45° through the camera
|
|
39
|
+
|
|
40
|
+
The body is centered on the canvas (lens at canvas center 100,100); slash passes through canvas center. Symmetric.
|
|
41
|
+
|
|
42
|
+
## Pattern for Consumers
|
|
43
|
+
|
|
44
|
+
Use directly as the `src` for any image renderer:
|
|
45
|
+
|
|
46
|
+
```tsx
|
|
47
|
+
import { Image } from '@clicktap/ui/components/Image';
|
|
48
|
+
import { PLACEHOLDER_IMAGE } from '@clicktap/ui/utils/placeholder';
|
|
49
|
+
|
|
50
|
+
<Image
|
|
51
|
+
src={product.image ?? PLACEHOLDER_IMAGE}
|
|
52
|
+
alt={product.name}
|
|
53
|
+
// ...
|
|
54
|
+
/>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Works equally well with raw `<img>`:
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
<img src={product.image ?? PLACEHOLDER_IMAGE} alt="" />
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
For the PDP/PLP migration, see the [frontend counterpart guide](2026-04-24-image-placeholder-frontend-fallback.md) — it covers the full cascade decision (variant → placeholder, no parent fallback) and the dead `no_selection` checks to remove.
|
|
64
|
+
|
|
65
|
+
## Files Changed
|
|
66
|
+
|
|
67
|
+
| File | What changed |
|
|
68
|
+
|---|---|
|
|
69
|
+
| `libs/ui/src/utils/placeholder.ts` | New file. Inlines the SVG and exports a data-URL constant. |
|
|
70
|
+
| `libs/ui/src/assets/clicktap-placeholder.svg` | New file. Source of truth for the SVG; the data URL above is a byte-for-byte copy. Keep in sync if the icon changes. |
|
|
71
|
+
| `libs/ui/package.json` | Adds `./assets/*` to the `exports` map (already in place from earlier work; mentioned here for completeness). |
|
|
72
|
+
|
|
73
|
+
## Migration Checklist
|
|
74
|
+
|
|
75
|
+
- [ ] `@clicktap/ui >= 0.27.0` installed in consumers.
|
|
76
|
+
- [ ] No need to copy any SVG into the consuming app's `public/` folder.
|
|
77
|
+
- [ ] Apply the [frontend counterpart guide](2026-04-24-image-placeholder-frontend-fallback.md) for the consuming-app changes.
|
|
78
|
+
|
|
79
|
+
## Why a data URL and not a bundled file?
|
|
80
|
+
|
|
81
|
+
Three reasons:
|
|
82
|
+
|
|
83
|
+
1. **Zero consumer setup.** A bundled file requires consumers to either copy it into `public/` or configure a static-file plugin. A data URL works on import.
|
|
84
|
+
2. **Bundler-agnostic.** The `@clicktap/ui` package ships to apps using Next.js, Storybook, and Jest test runners. SVG-as-static-asset semantics differ across them; a string constant is identical everywhere.
|
|
85
|
+
3. **Tiny payload.** The SVG is ~700 bytes; URL-encoded it's ~1KB. Network overhead vs an HTTP-fetched SVG is negligible and we avoid a second request.
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# PDP Option Selection — Conservative Prune Helper (`pruneInvalidSelections`)
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-04-30
|
|
4
|
+
**Affects:** `@clicktap/ui/utils/variantOptions` — adds `pruneInvalidSelections`
|
|
5
|
+
**Classification:** IMPROVEMENT
|
|
6
|
+
**Breaking:** No (additive)
|
|
7
|
+
|
|
8
|
+
## Dependencies
|
|
9
|
+
|
|
10
|
+
- Builds on [`2026-04-30-top-down-pdp-option-dependency.md`](./2026-04-30-top-down-pdp-option-dependency.md). That migration introduced top-down option filtering with a blanket "clear every downstream selection on any upstream change". This migration replaces the recommended consumer pattern with a more conservative prune that preserves still-valid downstream picks.
|
|
11
|
+
- Frontend counterpart: [`apps/frontend/.../2026-04-30-prune-invalid-pdp-selections.md`](2026-04-30-prune-invalid-pdp-selections.md) ↔.
|
|
12
|
+
|
|
13
|
+
## Summary
|
|
14
|
+
|
|
15
|
+
Adds `pruneInvalidSelections(variants, selections, axes)` to `@clicktap/ui/utils/variantOptions`. Walks the selection map top-to-bottom and drops only entries whose value is no longer reachable under the (possibly already-pruned) upstream context. Preserves user intent: a downstream pick is dropped only when actually invalid, never just because an upstream sibling changed.
|
|
16
|
+
|
|
17
|
+
`getDownstreamAxisCodes` is kept exported for convenience but the recommended `setUserSelection` pattern now uses `pruneInvalidSelections` instead.
|
|
18
|
+
|
|
19
|
+
## Why
|
|
20
|
+
|
|
21
|
+
The previous `setUserSelection` pattern (from the earlier 2026-04-30 migration) blanket-cleared every axis downstream of the changed one. That's correct, but it discards user intent in a common case:
|
|
22
|
+
|
|
23
|
+
- User has `Color=Red, Size=Large, Material=Cotton`.
|
|
24
|
+
- User changes `Color` to `Blue`.
|
|
25
|
+
- If `Blue+Large+Cotton` is a real variant, blanket-clear still drops both `Size` and `Material` and forces the user to repick.
|
|
26
|
+
|
|
27
|
+
`pruneInvalidSelections` keeps still-valid downstream picks. Three behaviors:
|
|
28
|
+
|
|
29
|
+
| Scenario | Blanket clear (old) | Prune (new) |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| `Blue+Large+Cotton` is a real variant | Drops Size and Material | Keeps both |
|
|
32
|
+
| `Blue+Large` exists but `Blue+Large+Cotton` doesn't | Drops Size and Material | Keeps Size, drops Material |
|
|
33
|
+
| `Blue+Large` doesn't exist | Drops Size and Material | Drops Size and Material |
|
|
34
|
+
|
|
35
|
+
## Public API
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import {
|
|
39
|
+
pruneInvalidSelections,
|
|
40
|
+
filterAvailableOptions,
|
|
41
|
+
findVariant,
|
|
42
|
+
getDownstreamAxisCodes,
|
|
43
|
+
} from '@clicktap/ui/utils/variantOptions';
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
/**
|
|
48
|
+
* Walks `selections` top-to-bottom along `axes` and drops any whose value
|
|
49
|
+
* is no longer reachable under the (possibly already-pruned) upstream
|
|
50
|
+
* context.
|
|
51
|
+
*
|
|
52
|
+
* Validates the entire selection map, not just downstream of one axis —
|
|
53
|
+
* works for both selection mutation and URL/state rehydration.
|
|
54
|
+
*
|
|
55
|
+
* Returns the same object reference when nothing was dropped, so callers
|
|
56
|
+
* can use `===` to skip downstream state updates.
|
|
57
|
+
*/
|
|
58
|
+
function pruneInvalidSelections<V extends VariantLike>(
|
|
59
|
+
variants: V[],
|
|
60
|
+
selections: Record<string, string>,
|
|
61
|
+
axes: string[],
|
|
62
|
+
): Record<string, string>;
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Implementation properties worth knowing:
|
|
66
|
+
|
|
67
|
+
- **Top-to-bottom iteration**: each axis evaluates against axes earlier in `axes` that have already been validated this pass. Cascades through naturally — if an upstream pick gets dropped, downstream is re-checked against the now-empty upstream slot.
|
|
68
|
+
- **Object identity preserved**: returns `selections` unchanged when nothing is dropped. Lets `useState` setters skip downstream re-renders via referential equality.
|
|
69
|
+
- **Conservative**: a downstream value is dropped only when its reachable set (under current upstream) doesn't contain it. Independence between sibling axes is preserved. E.g. switching `Color: Red→Blue` keeps `Material: Cotton` even when `Size` was dropped, as long as `Blue+(any size)+Cotton` exists.
|
|
70
|
+
|
|
71
|
+
## Pattern for Consumers
|
|
72
|
+
|
|
73
|
+
Replace `getDownstreamAxisCodes` + manual delete in your selection handler with `pruneInvalidSelections` over the full candidate map. **Read the previous selections from the closure and run all side effects in event-handler scope — do NOT use the functional-updater form of setState here**:
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
import { pruneInvalidSelections } from '@clicktap/ui/utils/variantOptions';
|
|
77
|
+
|
|
78
|
+
const setSelection = useCallback(
|
|
79
|
+
(axisCode: string, optionCode: string) => {
|
|
80
|
+
if (selections[axisCode] === optionCode) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const candidate = { ...selections, [axisCode]: optionCode };
|
|
84
|
+
// Prune cascades: top-to-bottom, each axis reads pruned upstream.
|
|
85
|
+
const next = pruneInvalidSelections(variants, candidate, axisCodes);
|
|
86
|
+
|
|
87
|
+
setSelections(next);
|
|
88
|
+
writeSelectionsToUrl(next); // or any other side effect
|
|
89
|
+
},
|
|
90
|
+
[axisCodes, selections, variants],
|
|
91
|
+
);
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
> ⚠ **Don't put side effects inside a `setSelections((prev) => ...)` updater.**
|
|
95
|
+
> React requires functional updaters to be pure — no calls that schedule
|
|
96
|
+
> updates on other components, no mutations. Calling `router.replace()` (or
|
|
97
|
+
> any side effect that dispatches into another component's render cycle)
|
|
98
|
+
> from inside an updater triggers `"Cannot update a component while
|
|
99
|
+
> rendering a different component"` and a recoverable crash on
|
|
100
|
+
> first-select / deselect interactions. React 19 enforces this harder.
|
|
101
|
+
> Read the previous state from the closure and put side effects after the
|
|
102
|
+
> setter, in the event-handler scope. Trade-off: closure-read loses
|
|
103
|
+
> stale-closure protection if multiple selections fire in the same
|
|
104
|
+
> event-loop tick — fine for swatch clicks paced by humans, but if you
|
|
105
|
+
> wire a programmatic batch-set-multiple-axes flow on top, defer the side
|
|
106
|
+
> effects to a `useEffect` keyed on `selections` and bring back the
|
|
107
|
+
> functional updater for the state write.
|
|
108
|
+
|
|
109
|
+
If your component tracks per-axis manual-clear flags (so auto-collapse doesn't re-fill an axis the user explicitly cleared), only unflag the axes that the prune actually dropped:
|
|
110
|
+
|
|
111
|
+
```tsx
|
|
112
|
+
const candidate = { ...selections, [axisCode]: optionCode };
|
|
113
|
+
const next = pruneInvalidSelections(variants, candidate, axisCodes);
|
|
114
|
+
|
|
115
|
+
for (const code of axisCodes) {
|
|
116
|
+
if (candidate[code] !== undefined && next[code] === undefined) {
|
|
117
|
+
manuallyClearedAxes.current.delete(code);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
setSelections(next);
|
|
122
|
+
writeSelectionsToUrl(next);
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Otherwise, a kept (still-valid) selection could later get auto-collapsed back to a value the user previously cleared.
|
|
126
|
+
|
|
127
|
+
## Migration Steps
|
|
128
|
+
|
|
129
|
+
This is purely additive — existing code keeps compiling. To adopt:
|
|
130
|
+
|
|
131
|
+
### 1. Bump `@clicktap/ui`
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
pnpm up @clicktap/ui
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Minimum version: **next minor after the release that ships this migration**.
|
|
138
|
+
|
|
139
|
+
### 2. Replace blanket clear with prune in selection handlers
|
|
140
|
+
|
|
141
|
+
Find every call site that uses `getDownstreamAxisCodes` to clear downstream selections:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
grep -rn 'getDownstreamAxisCodes' apps/ libs/ --include='*.ts' --include='*.tsx'
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Replace the manual delete loop with a single `pruneInvalidSelections` call (see "Pattern for Consumers" above). `getDownstreamAxisCodes` itself stays exported for any caller that genuinely needs the codes for non-prune purposes.
|
|
148
|
+
|
|
149
|
+
### Verification
|
|
150
|
+
|
|
151
|
+
- **Behavior**: change an upstream axis on a configurable PDP. Confirm a downstream selection that's still reachable (i.e. the new combination is a real variant) stays selected. Confirm a downstream selection that's now unreachable gets dropped.
|
|
152
|
+
- **Lib tests**: `nx test ui --testPathPattern variantOptions` → 26 tests pass (was 15).
|
|
153
|
+
- **Typecheck**: `nx run frontend:typecheck` (or your app equivalent) → no errors.
|
|
154
|
+
|
|
155
|
+
## Files Changed
|
|
156
|
+
|
|
157
|
+
| File | Change |
|
|
158
|
+
|---|---|
|
|
159
|
+
| `libs/ui/src/utils/variantOptions/pruneInvalidSelections.ts` | New — top-to-bottom prune helper |
|
|
160
|
+
| `libs/ui/src/utils/variantOptions/pruneInvalidSelections.test.ts` | New — Jest coverage (11 tests) |
|
|
161
|
+
| `libs/ui/src/utils/variantOptions/index.ts` | Export `pruneInvalidSelections` |
|
|
162
|
+
|
|
163
|
+
## References
|
|
164
|
+
|
|
165
|
+
- Frontend counterpart migration: [`apps/frontend/.../2026-04-30-prune-invalid-pdp-selections.md`](2026-04-30-prune-invalid-pdp-selections.md) ↔
|
|
166
|
+
- Previous top-down migration this builds on: [`2026-04-30-top-down-pdp-option-dependency.md`](./2026-04-30-top-down-pdp-option-dependency.md)
|
|
@@ -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,141 @@
|
|
|
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
|
+
**Status:** ⚠️ **Superseded — never shipped. Do not adopt.** (CTAP-1077)
|
|
8
|
+
**Superseded by:** the backend line-item `productUrl` fields — `QuoteItem.productUrl` / `OrderItem.productUrl` (see `2026-06-08-quote-item-product-url.md` and `2026-06-08-order-item-product-url.md` in the `quote-graphql` / `order-graphql` modules).
|
|
9
|
+
|
|
10
|
+
> ⚠️ **This export was never published.** As of `@clicktap/ui` **0.40.9** there is no
|
|
11
|
+
> `@clicktap/ui/utils/buildPdpUrl` — do not try to import it (there is no "shipped in X" version to check
|
|
12
|
+
> against). The problem it describes — a cart/order row linking to a PDP without the variant
|
|
13
|
+
> pre-selected — is solved better on the **backend**: `QuoteItem.productUrl` and `OrderItem.productUrl`
|
|
14
|
+
> return the product-page URL **with the variant selection already applied**. Prefer them: there's no
|
|
15
|
+
> client-side helper to keep in sync and no drift risk against `ProductTypeResolver.writeSelectionsToUrl`
|
|
16
|
+
> (the exact drift the design below tries to avoid by mirroring it). Select `productUrl` on your
|
|
17
|
+
> quote/order line-item fragment and link to it directly.
|
|
18
|
+
>
|
|
19
|
+
> The original design is retained below for history only.
|
|
20
|
+
|
|
21
|
+
## Dependencies
|
|
22
|
+
|
|
23
|
+
*None.*
|
|
24
|
+
|
|
25
|
+
## Summary
|
|
26
|
+
|
|
27
|
+
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.
|
|
28
|
+
|
|
29
|
+
## Why
|
|
30
|
+
|
|
31
|
+
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.
|
|
32
|
+
|
|
33
|
+
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).
|
|
34
|
+
|
|
35
|
+
## Public API
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { buildPdpUrl } from '@clicktap/ui/utils/buildPdpUrl';
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
/**
|
|
43
|
+
* Append configurable-product variant selections to a PDP canonical URL as
|
|
44
|
+
* `?attributeCode=optionCode` pairs (e.g. `?color=black`).
|
|
45
|
+
*
|
|
46
|
+
* Accepts the structural shape that any cart-item / order-item / quote-item
|
|
47
|
+
* `typeOptions` produces on the configurable case.
|
|
48
|
+
*
|
|
49
|
+
* Returns the URL unchanged when:
|
|
50
|
+
* - the URL is null/empty,
|
|
51
|
+
* - the item isn't configurable (no attributes), or
|
|
52
|
+
* - the existing URL already carries a query string (we don't merge —
|
|
53
|
+
* the item's own variant context is authoritative, and bailing keeps
|
|
54
|
+
* caller intent clear).
|
|
55
|
+
*/
|
|
56
|
+
function buildPdpUrl(
|
|
57
|
+
canonicalUrl: string | null | undefined,
|
|
58
|
+
typeOptions: unknown,
|
|
59
|
+
): string | null;
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
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.
|
|
63
|
+
|
|
64
|
+
## Behavior summary
|
|
65
|
+
|
|
66
|
+
| Input | Output |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `(null, ...)` | `null` |
|
|
69
|
+
| `('', ...)` | `null` |
|
|
70
|
+
| `('/foo', null)` | `'/foo'` |
|
|
71
|
+
| `('/foo', { attributes: [] })` | `'/foo'` |
|
|
72
|
+
| `('/foo', { attributes: [{ attribute: { code: 'color' }, option: { code: 'red' } }] })` | `'/foo?color=red'` |
|
|
73
|
+
| `('/foo', { attributes: [{ color }, { size }] })` | `'/foo?color=red&size=m'` |
|
|
74
|
+
| `('/foo?bar=1', { attributes: [...] })` | `'/foo?bar=1'` (URL has a query — bail rather than merge) |
|
|
75
|
+
|
|
76
|
+
## Pattern for Consumers
|
|
77
|
+
|
|
78
|
+
Use it wherever a cart/order item row links to its PDP and you want the variant pre-selected:
|
|
79
|
+
|
|
80
|
+
```tsx
|
|
81
|
+
import { Link } from '@clicktap/ui/components/Link';
|
|
82
|
+
import { buildPdpUrl } from '@clicktap/ui/utils/buildPdpUrl';
|
|
83
|
+
|
|
84
|
+
function CartItemRow({ item }: { item: QuoteItem }) {
|
|
85
|
+
const pdpUrl = buildPdpUrl(item.product.canonicalUrl, item.typeOptions) ?? '';
|
|
86
|
+
return (
|
|
87
|
+
<Link href={pdpUrl}>
|
|
88
|
+
{item.name}
|
|
89
|
+
</Link>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The function is structural, so `item.typeOptions` doesn't need to be cast — pass it directly.
|
|
95
|
+
|
|
96
|
+
## Migration Steps
|
|
97
|
+
|
|
98
|
+
This is purely additive — existing code keeps compiling. To adopt:
|
|
99
|
+
|
|
100
|
+
### 1. Bump `@clicktap/ui`
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
pnpm up @clicktap/ui
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Minimum version: **next minor after the release that ships this migration**.
|
|
107
|
+
|
|
108
|
+
### 2. Replace any inlined copies with the shared util
|
|
109
|
+
|
|
110
|
+
Search consumer projects for inlined copies that predate this migration:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
grep -rn 'buildPdpUrl' apps/ libs/ --include='*.ts' --include='*.tsx'
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Switch each call site to:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import { buildPdpUrl } from '@clicktap/ui/utils/buildPdpUrl';
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
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.
|
|
123
|
+
|
|
124
|
+
### 3. (Optional) Wire it into cart/order link components
|
|
125
|
+
|
|
126
|
+
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.
|
|
127
|
+
|
|
128
|
+
### Verification
|
|
129
|
+
|
|
130
|
+
- **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.
|
|
131
|
+
- **Typecheck**: `nx run <app>:typecheck` → no errors.
|
|
132
|
+
|
|
133
|
+
## Files Changed
|
|
134
|
+
|
|
135
|
+
| File | Change |
|
|
136
|
+
|---|---|
|
|
137
|
+
| `libs/ui/src/utils/buildPdpUrl.ts` | New — structural helper |
|
|
138
|
+
|
|
139
|
+
## References
|
|
140
|
+
|
|
141
|
+
- PDP encoding source of truth: `apps/frontend/components/Product/ProductType/.../ProductTypeResolver.writeSelectionsToUrl`
|