@snowcone-app/ui 0.8.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/dist/index.cjs +101 -32
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +70 -17
- package/dist/index.d.ts +70 -17
- package/dist/index.js +126 -58
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/composed/AddToCart.tsx +51 -31
- package/src/composed/PlacementTabs.tsx +117 -19
- package/src/composed/__tests__/AddToCart.async.test.tsx +104 -0
- package/src/composed/__tests__/PlacementTabs.linked.test.tsx +144 -0
- package/src/patterns/RealtimeProvider.tsx +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@snowcone-app/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "React components for merchandise visualization and customization",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react",
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
"react-instantsearch": "^7.15.5",
|
|
104
104
|
"react-zoom-pan-pinch": "^3.6.4",
|
|
105
105
|
"tailwind-merge": "^3.0.0",
|
|
106
|
-
"@snowcone-app/sdk": "0.24.
|
|
106
|
+
"@snowcone-app/sdk": "0.24.2"
|
|
107
107
|
},
|
|
108
108
|
"devDependencies": {
|
|
109
109
|
"@chromatic-com/storybook": "^4.1.2",
|
|
@@ -121,7 +121,7 @@
|
|
|
121
121
|
"@vitejs/plugin-react": "^4.7.0",
|
|
122
122
|
"@vueless/storybook-dark-mode": "^10.0.3",
|
|
123
123
|
"jsdom": "^24.1.0",
|
|
124
|
-
"next": "
|
|
124
|
+
"next": "16.4.3",
|
|
125
125
|
"react": "^18.3.1",
|
|
126
126
|
"react-dom": "^18.3.1",
|
|
127
127
|
"rimraf": "^6.1.2",
|
|
@@ -130,7 +130,7 @@
|
|
|
130
130
|
"tsup": "^8.0.1",
|
|
131
131
|
"typescript": "^5.4.5",
|
|
132
132
|
"vite": "^5.4.21",
|
|
133
|
-
"vitest": "^2.
|
|
133
|
+
"vitest": "^2.1.9",
|
|
134
134
|
"@snowcone-app/brand": "0.1.0"
|
|
135
135
|
},
|
|
136
136
|
"scripts": {
|
|
@@ -14,14 +14,23 @@ export interface AddToCartProps {
|
|
|
14
14
|
text?: string;
|
|
15
15
|
loadingText?: string;
|
|
16
16
|
successText?: string;
|
|
17
|
+
/** Button text after a failed add; the error's message is announced in a
|
|
18
|
+
* `role="alert"` below the button and the button stays enabled to retry. */
|
|
19
|
+
errorText?: string;
|
|
17
20
|
disabled?: boolean;
|
|
18
21
|
className?: string;
|
|
19
22
|
quantity?: number;
|
|
20
|
-
|
|
23
|
+
/** Performs the add. Return a Promise and the button waits for it: "Added!"
|
|
24
|
+
* only after it resolves; a rejection shows the error state. */
|
|
25
|
+
onClick?: (cartDetail: any) => void | Promise<unknown>;
|
|
21
26
|
onError?: (error: Error) => void;
|
|
27
|
+
/** Fake delay used only when there is no `onClick` (demos). */
|
|
22
28
|
simulateDelay?: number;
|
|
23
29
|
}
|
|
24
30
|
|
|
31
|
+
/** `data-add-to-cart` on the button — assert on this, not the label text. */
|
|
32
|
+
export type AddToCartStatus = "idle" | "adding" | "added" | "error";
|
|
33
|
+
|
|
25
34
|
/**
|
|
26
35
|
* AddToCart - Add to cart button with validation and loading states
|
|
27
36
|
*
|
|
@@ -31,8 +40,11 @@ export interface AddToCartProps {
|
|
|
31
40
|
*
|
|
32
41
|
* Features:
|
|
33
42
|
* - Automatic selection validation before allowing add
|
|
34
|
-
* - Loading state with customizable text
|
|
35
|
-
* - Success
|
|
43
|
+
* - Loading state with customizable text; waits for an async `onClick`
|
|
44
|
+
* - Success only after `onClick` resolves; a rejection shows `errorText`
|
|
45
|
+
* plus the error message in a `role="alert"`, and the button stays
|
|
46
|
+
* enabled to retry (never a silent "Added!")
|
|
47
|
+
* - `data-add-to-cart="idle|adding|added|error"` for tests and agents
|
|
36
48
|
* - Automatic cart detail generation with variant info
|
|
37
49
|
* - Simulated delay for cart operations
|
|
38
50
|
* - Screen reader announcements for state changes
|
|
@@ -57,9 +69,10 @@ export interface AddToCartProps {
|
|
|
57
69
|
* <Product productId="shirt-123">
|
|
58
70
|
* <ProductOptions />
|
|
59
71
|
* <AddToCart
|
|
60
|
-
* onClick={(cartDetail) => {
|
|
61
|
-
*
|
|
62
|
-
*
|
|
72
|
+
* onClick={async (cartDetail) => {
|
|
73
|
+
* // Throw (or reject) if the add fails — the button shows the error.
|
|
74
|
+
* const res = await fetch('/api/cart', { method: 'POST', body: JSON.stringify(cartDetail) });
|
|
75
|
+
* if (!res.ok) throw new Error('Could not add to cart');
|
|
63
76
|
* }}
|
|
64
77
|
* />
|
|
65
78
|
* </Product>
|
|
@@ -89,27 +102,26 @@ export interface AddToCartProps {
|
|
|
89
102
|
*
|
|
90
103
|
* @example
|
|
91
104
|
* ```tsx
|
|
92
|
-
* //
|
|
93
|
-
* <AddToCart
|
|
94
|
-
* simulateDelay={500}
|
|
95
|
-
* onClick={(detail) => console.log(detail)}
|
|
96
|
-
* />
|
|
105
|
+
* // Demo without a real cart: a simulated delay, then "Added!"
|
|
106
|
+
* <AddToCart simulateDelay={500} />
|
|
97
107
|
* ```
|
|
98
108
|
*
|
|
99
109
|
* @param text - Button text in default state (default: "Add to Bag")
|
|
100
110
|
* @param loadingText - Button text while adding (default: "Adding...")
|
|
101
111
|
* @param successText - Button text after successful add (default: "Added!")
|
|
112
|
+
* @param errorText - Button text after a failed add (default: "Couldn't add — try again")
|
|
102
113
|
* @param disabled - Force button to disabled state
|
|
103
114
|
* @param className - Additional CSS classes for button
|
|
104
115
|
* @param quantity - Number of items to add (default: 1)
|
|
105
|
-
* @param onClick -
|
|
116
|
+
* @param onClick - Performs the add with the cart detail; may return a Promise, which the button awaits
|
|
106
117
|
* @param onError - Callback when add fails with error
|
|
107
|
-
* @param simulateDelay - Delay in ms to simulate cart operation (default: 1000)
|
|
118
|
+
* @param simulateDelay - Delay in ms to simulate a cart operation when there is no `onClick` (default: 1000)
|
|
108
119
|
*/
|
|
109
120
|
export function AddToCart({
|
|
110
121
|
text = "Add to Bag",
|
|
111
122
|
loadingText = "Adding...",
|
|
112
123
|
successText = "Added!",
|
|
124
|
+
errorText = "Couldn't add — try again",
|
|
113
125
|
disabled = false,
|
|
114
126
|
className = "",
|
|
115
127
|
quantity = 1,
|
|
@@ -119,8 +131,10 @@ export function AddToCart({
|
|
|
119
131
|
}: AddToCartProps) {
|
|
120
132
|
const context = useProduct();
|
|
121
133
|
const shopContext = useShopOptional();
|
|
122
|
-
const [
|
|
123
|
-
const [
|
|
134
|
+
const [status, setStatus] = useState<AddToCartStatus>("idle");
|
|
135
|
+
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
136
|
+
const loading = status === "adding";
|
|
137
|
+
const success = status === "added";
|
|
124
138
|
|
|
125
139
|
// Dev-mode warning for missing providers
|
|
126
140
|
useEffect(() => {
|
|
@@ -151,7 +165,8 @@ export function AddToCart({
|
|
|
151
165
|
if (isDisabled || !product) return;
|
|
152
166
|
|
|
153
167
|
try {
|
|
154
|
-
|
|
168
|
+
setStatus("adding");
|
|
169
|
+
setErrorMessage(null);
|
|
155
170
|
|
|
156
171
|
// Create cart detail using ui-core
|
|
157
172
|
const cartDetail = createCartDetail({
|
|
@@ -166,28 +181,26 @@ export function AddToCart({
|
|
|
166
181
|
throw new Error("Invalid selection - unable to create cart detail");
|
|
167
182
|
}
|
|
168
183
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
// Show success state
|
|
176
|
-
setSuccess(true);
|
|
184
|
+
if (onClick) {
|
|
185
|
+
// The host performs the add; success means it resolved.
|
|
186
|
+
await onClick(cartDetail);
|
|
187
|
+
} else {
|
|
188
|
+
await simulateCartOperation(simulateDelay);
|
|
189
|
+
}
|
|
177
190
|
|
|
178
|
-
|
|
191
|
+
setStatus("added");
|
|
179
192
|
setTimeout(() => {
|
|
180
|
-
|
|
193
|
+
setStatus((s) => (s === "added" ? "idle" : s));
|
|
181
194
|
}, 2000);
|
|
182
195
|
} catch (error) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
196
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
197
|
+
setStatus("error");
|
|
198
|
+
setErrorMessage(err.message);
|
|
199
|
+
onError?.(err);
|
|
187
200
|
}
|
|
188
201
|
};
|
|
189
202
|
|
|
190
|
-
const buttonText = loading ? loadingText : success ? successText : text;
|
|
203
|
+
const buttonText = loading ? loadingText : success ? successText : status === "error" ? errorText : text;
|
|
191
204
|
|
|
192
205
|
// Default styles that work out of the box - LLM-friendly
|
|
193
206
|
// These are always applied, then className can override/extend them
|
|
@@ -204,6 +217,7 @@ export function AddToCart({
|
|
|
204
217
|
const getAriaLabel = () => {
|
|
205
218
|
if (loading) return loadingText;
|
|
206
219
|
if (success) return successText;
|
|
220
|
+
if (status === "error") return `${errorText} - ${product?.name || "product"}`;
|
|
207
221
|
if (!product) return text;
|
|
208
222
|
return `${text} - ${product.name || 'product'}`;
|
|
209
223
|
};
|
|
@@ -221,9 +235,15 @@ export function AddToCart({
|
|
|
221
235
|
onClick={handleClick}
|
|
222
236
|
aria-label={getAriaLabel()}
|
|
223
237
|
aria-busy={loading}
|
|
238
|
+
data-add-to-cart={status}
|
|
224
239
|
>
|
|
225
240
|
{buttonText}
|
|
226
241
|
</button>
|
|
242
|
+
{status === "error" && errorMessage && (
|
|
243
|
+
<p role="alert" className="mt-2 text-sm text-destructive">
|
|
244
|
+
{errorMessage}
|
|
245
|
+
</p>
|
|
246
|
+
)}
|
|
227
247
|
</>
|
|
228
248
|
);
|
|
229
249
|
}
|
|
@@ -43,8 +43,41 @@ export interface PlacementTabsProps {
|
|
|
43
43
|
toolbarSize?: number;
|
|
44
44
|
/** Additional className for the container */
|
|
45
45
|
className?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Linked placements (ADR-0101): one design on every print area.
|
|
48
|
+
*
|
|
49
|
+
* - `true` — LINKED. Renders one non-interactive pill naming the set
|
|
50
|
+
* ("Both sides" for two placements, "All N areas" otherwise; the labels
|
|
51
|
+
* in its `title`) plus a single **Customize separately** action
|
|
52
|
+
* (`onSplitAll`). No per-placement tabs: there is one design to edit.
|
|
53
|
+
* - `false` — SPLIT. Renders the per-placement tabs plus one
|
|
54
|
+
* **Use one design for all** action (`onLinkAll`).
|
|
55
|
+
* - `undefined` (default) — no link model: plain tabs, exactly as before.
|
|
56
|
+
*/
|
|
57
|
+
linked?: boolean;
|
|
58
|
+
/** Linked → split: every placement gets its own copy of the design. */
|
|
59
|
+
onSplitAll?: () => void;
|
|
60
|
+
/** Split → linked: drop the per-placement copies, back to one design. */
|
|
61
|
+
onLinkAll?: () => void;
|
|
62
|
+
/** Override the linked pill's copy (localisation). */
|
|
63
|
+
linkedLabel?: string;
|
|
64
|
+
/** Override the "Customize separately" action copy. */
|
|
65
|
+
splitAllLabel?: string;
|
|
66
|
+
/** Override the "Use one design for all" action copy. */
|
|
67
|
+
linkAllLabel?: string;
|
|
46
68
|
}
|
|
47
69
|
|
|
70
|
+
/** Default pill copy for a linked set of `count` placements. */
|
|
71
|
+
export function linkedPlacementsLabel(count: number): string {
|
|
72
|
+
return count === 2 ? "Both sides" : `All ${count} areas`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const ACTION_CLASS =
|
|
76
|
+
"transition-colors duration-200 cursor-pointer text-muted-foreground hover:text-foreground underline underline-offset-4 decoration-muted-foreground/40 hover:decoration-foreground";
|
|
77
|
+
|
|
78
|
+
const PILL_CLASS =
|
|
79
|
+
"inline-flex items-center rounded-full bg-foreground/[0.08] px-3 py-1 font-medium text-foreground";
|
|
80
|
+
|
|
48
81
|
/**
|
|
49
82
|
* PlacementTabs - Renders placement tabs with optional floating toolbar placeholder.
|
|
50
83
|
*
|
|
@@ -52,6 +85,11 @@ export interface PlacementTabsProps {
|
|
|
52
85
|
* to flow around the toolbar placeholder. On desktop, renders simple tabs
|
|
53
86
|
* without the toolbar placeholder (toolbar is typically in header).
|
|
54
87
|
*
|
|
88
|
+
* The container carries `data-placement-tabs` and, when the link model is in
|
|
89
|
+
* play, `data-linked="true" | "false"`; the pill is `data-placement-pill`
|
|
90
|
+
* and the actions are `data-action="split-all" | "link-all"` so hosts and
|
|
91
|
+
* agents can assert the state instead of reading pixels.
|
|
92
|
+
*
|
|
55
93
|
* @example
|
|
56
94
|
* ```tsx
|
|
57
95
|
* // Basic usage
|
|
@@ -71,6 +109,16 @@ export interface PlacementTabsProps {
|
|
|
71
109
|
* onPlacementChange={setSelected}
|
|
72
110
|
* isDesktop
|
|
73
111
|
* />
|
|
112
|
+
*
|
|
113
|
+
* // Linked by default (ADR-0101): one design on every area
|
|
114
|
+
* <PlacementTabs
|
|
115
|
+
* placements={placements}
|
|
116
|
+
* selectedPlacement={selected}
|
|
117
|
+
* onPlacementChange={setSelected}
|
|
118
|
+
* linked={linked}
|
|
119
|
+
* onSplitAll={splitAll}
|
|
120
|
+
* onLinkAll={linkAll}
|
|
121
|
+
* />
|
|
74
122
|
* ```
|
|
75
123
|
*/
|
|
76
124
|
export function PlacementTabs({
|
|
@@ -81,6 +129,12 @@ export function PlacementTabs({
|
|
|
81
129
|
toolbarPlaceholderId = "toolbar-placeholder",
|
|
82
130
|
toolbarSize = 120,
|
|
83
131
|
className = "",
|
|
132
|
+
linked,
|
|
133
|
+
onSplitAll,
|
|
134
|
+
onLinkAll,
|
|
135
|
+
linkedLabel,
|
|
136
|
+
splitAllLabel = "Customize separately",
|
|
137
|
+
linkAllLabel = "Use one design for all",
|
|
84
138
|
}: PlacementTabsProps) {
|
|
85
139
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
86
140
|
const [lineHeight, setLineHeight] = useState(24); // Default estimate
|
|
@@ -103,7 +157,7 @@ export function PlacementTabs({
|
|
|
103
157
|
return null;
|
|
104
158
|
}
|
|
105
159
|
return (
|
|
106
|
-
<div className={`flex justify-end w-full ${className}`}>
|
|
160
|
+
<div className={`flex justify-end w-full ${className}`} data-placement-tabs>
|
|
107
161
|
<span
|
|
108
162
|
id={toolbarPlaceholderId}
|
|
109
163
|
className="block"
|
|
@@ -113,15 +167,46 @@ export function PlacementTabs({
|
|
|
113
167
|
);
|
|
114
168
|
}
|
|
115
169
|
|
|
116
|
-
|
|
117
|
-
|
|
170
|
+
const dataLinked = linked === undefined ? undefined : linked ? "true" : "false";
|
|
171
|
+
const labels = placements.map((p) => p.label);
|
|
172
|
+
|
|
173
|
+
// The row content is shared by the desktop and mobile layouts; only the
|
|
174
|
+
// toolbar-placeholder float around it differs.
|
|
175
|
+
const renderItems = (tabClass: string) => {
|
|
176
|
+
if (linked === true) {
|
|
177
|
+
return (
|
|
178
|
+
<>
|
|
179
|
+
<span
|
|
180
|
+
className={`${tabClass} ${PILL_CLASS}`}
|
|
181
|
+
data-placement-pill
|
|
182
|
+
title={labels.join(" · ")}
|
|
183
|
+
aria-label={`One design on ${labels.join(", ")}`}
|
|
184
|
+
>
|
|
185
|
+
{linkedLabel ?? linkedPlacementsLabel(placements.length)}
|
|
186
|
+
</span>
|
|
187
|
+
{onSplitAll && (
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
onClick={onSplitAll}
|
|
191
|
+
className={`${tabClass} ${ACTION_CLASS}`}
|
|
192
|
+
data-action="split-all"
|
|
193
|
+
>
|
|
194
|
+
{splitAllLabel}
|
|
195
|
+
</button>
|
|
196
|
+
)}
|
|
197
|
+
</>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
118
200
|
return (
|
|
119
|
-
|
|
201
|
+
<>
|
|
120
202
|
{placements.map((p) => (
|
|
121
203
|
<button
|
|
122
204
|
key={p.label}
|
|
205
|
+
type="button"
|
|
123
206
|
onClick={() => onPlacementChange(p.label)}
|
|
124
|
-
|
|
207
|
+
data-placement-tab={p.label}
|
|
208
|
+
aria-pressed={selectedPlacement === p.label}
|
|
209
|
+
className={`${tabClass} transition-all duration-200 cursor-pointer border-b-2 ${
|
|
125
210
|
selectedPlacement === p.label
|
|
126
211
|
? "text-foreground border-primary"
|
|
127
212
|
: "text-foreground/70 hover:text-foreground border-muted-foreground/20 hover:border-muted-foreground/40"
|
|
@@ -130,6 +215,29 @@ export function PlacementTabs({
|
|
|
130
215
|
{p.label}
|
|
131
216
|
</button>
|
|
132
217
|
))}
|
|
218
|
+
{linked === false && onLinkAll && (
|
|
219
|
+
<button
|
|
220
|
+
type="button"
|
|
221
|
+
onClick={onLinkAll}
|
|
222
|
+
className={`${tabClass} ${ACTION_CLASS}`}
|
|
223
|
+
data-action="link-all"
|
|
224
|
+
>
|
|
225
|
+
{linkAllLabel}
|
|
226
|
+
</button>
|
|
227
|
+
)}
|
|
228
|
+
</>
|
|
229
|
+
);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// Desktop: just render placement tabs without toolbar placeholder
|
|
233
|
+
if (isDesktop) {
|
|
234
|
+
return (
|
|
235
|
+
<div
|
|
236
|
+
className={`flex flex-wrap items-center gap-x-3 gap-y-1 text-sm leading-relaxed w-full ${className}`}
|
|
237
|
+
data-placement-tabs
|
|
238
|
+
data-linked={dataLinked}
|
|
239
|
+
>
|
|
240
|
+
{renderItems("")}
|
|
133
241
|
</div>
|
|
134
242
|
);
|
|
135
243
|
}
|
|
@@ -144,6 +252,8 @@ export function PlacementTabs({
|
|
|
144
252
|
<div
|
|
145
253
|
ref={containerRef}
|
|
146
254
|
className={`flex overflow-hidden text-sm leading-relaxed w-full ${className}`}
|
|
255
|
+
data-placement-tabs
|
|
256
|
+
data-linked={dataLinked}
|
|
147
257
|
>
|
|
148
258
|
<p className="m-0 w-full">
|
|
149
259
|
<span
|
|
@@ -159,20 +269,8 @@ export function PlacementTabs({
|
|
|
159
269
|
/>
|
|
160
270
|
</span>
|
|
161
271
|
|
|
162
|
-
{/* Placement tabs */}
|
|
163
|
-
{
|
|
164
|
-
<button
|
|
165
|
-
key={p.label}
|
|
166
|
-
onClick={() => onPlacementChange(p.label)}
|
|
167
|
-
className={`inline transition-all duration-200 cursor-pointer mr-3 border-b-2 ${
|
|
168
|
-
selectedPlacement === p.label
|
|
169
|
-
? "text-foreground border-primary"
|
|
170
|
-
: "text-foreground/70 hover:text-foreground border-muted-foreground/20 hover:border-muted-foreground/40"
|
|
171
|
-
}`}
|
|
172
|
-
>
|
|
173
|
-
{p.label}
|
|
174
|
-
</button>
|
|
175
|
-
))}
|
|
272
|
+
{/* Placement tabs (or the linked pill + action) */}
|
|
273
|
+
{renderItems("inline mr-3")}
|
|
176
274
|
</p>
|
|
177
275
|
</div>
|
|
178
276
|
);
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AddToCart waits for the host's add and never reports a failure as success.
|
|
3
|
+
*
|
|
4
|
+
* - An async `onClick` is awaited: "Added!" only after it resolves.
|
|
5
|
+
* - A rejection shows `errorText`, puts the error message in a role=alert,
|
|
6
|
+
* calls `onError`, and leaves the button enabled to retry.
|
|
7
|
+
* - `data-add-to-cart` exposes idle | adding | added | error.
|
|
8
|
+
* - With no `onClick`, the demo delay still ends in "Added!".
|
|
9
|
+
*/
|
|
10
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
11
|
+
import React from "react";
|
|
12
|
+
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
13
|
+
|
|
14
|
+
vi.mock("../../patterns/Product", () => {
|
|
15
|
+
const ctx = {
|
|
16
|
+
product: { id: "P1", name: "Framed Canvas" },
|
|
17
|
+
selection: {},
|
|
18
|
+
combinations: [],
|
|
19
|
+
optionAttributes: {},
|
|
20
|
+
};
|
|
21
|
+
return { useProduct: () => ctx, useProductOptional: () => ctx };
|
|
22
|
+
});
|
|
23
|
+
vi.mock("../../patterns/ShopProvider", () => ({ useShopOptional: () => null }));
|
|
24
|
+
vi.mock("../../utils/devWarnings", () => ({ checkProviders: () => {} }));
|
|
25
|
+
vi.mock("@snowcone-app/sdk", () => ({
|
|
26
|
+
validateProductSelection: () => true,
|
|
27
|
+
createCartDetail: (d: { productId: string; quantity: number }) => ({ productId: d.productId, quantity: d.quantity }),
|
|
28
|
+
simulateCartOperation: () => Promise.resolve(),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
import { AddToCart } from "../AddToCart";
|
|
32
|
+
|
|
33
|
+
afterEach(cleanup);
|
|
34
|
+
|
|
35
|
+
function deferred() {
|
|
36
|
+
let resolve!: () => void;
|
|
37
|
+
let reject!: (e: Error) => void;
|
|
38
|
+
const promise = new Promise<void>((res, rej) => {
|
|
39
|
+
resolve = res;
|
|
40
|
+
reject = rej;
|
|
41
|
+
});
|
|
42
|
+
return { promise, resolve, reject };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const button = () => screen.getByRole("button");
|
|
46
|
+
|
|
47
|
+
describe("AddToCart with an async onClick", () => {
|
|
48
|
+
it("stays 'adding' until the add resolves, then shows success", async () => {
|
|
49
|
+
const add = deferred();
|
|
50
|
+
render(<AddToCart onClick={() => add.promise} />);
|
|
51
|
+
|
|
52
|
+
fireEvent.click(button());
|
|
53
|
+
await act(async () => {});
|
|
54
|
+
expect(button().getAttribute("data-add-to-cart")).toBe("adding");
|
|
55
|
+
expect(button().textContent).toBe("Adding...");
|
|
56
|
+
|
|
57
|
+
await act(async () => add.resolve());
|
|
58
|
+
expect(button().getAttribute("data-add-to-cart")).toBe("added");
|
|
59
|
+
expect(button().textContent).toBe("Added!");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("shows the error, alerts its message, calls onError, and allows a retry", async () => {
|
|
63
|
+
const onError = vi.fn();
|
|
64
|
+
const onClick = vi.fn(
|
|
65
|
+
(): Promise<void> => Promise.reject(new Error("Couldn't add Framed Canvas to your bag (error 500).")),
|
|
66
|
+
);
|
|
67
|
+
render(<AddToCart onClick={onClick} onError={onError} errorText="Try again" />);
|
|
68
|
+
|
|
69
|
+
await act(async () => fireEvent.click(button()));
|
|
70
|
+
expect(button().getAttribute("data-add-to-cart")).toBe("error");
|
|
71
|
+
expect(button().textContent).toBe("Try again");
|
|
72
|
+
expect(screen.getByRole("alert").textContent).toBe("Couldn't add Framed Canvas to your bag (error 500).");
|
|
73
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
74
|
+
expect((button() as HTMLButtonElement).disabled).toBe(false);
|
|
75
|
+
|
|
76
|
+
onClick.mockImplementationOnce(() => Promise.resolve());
|
|
77
|
+
await act(async () => fireEvent.click(button()));
|
|
78
|
+
expect(button().getAttribute("data-add-to-cart")).toBe("added");
|
|
79
|
+
expect(screen.queryByRole("alert")).toBeNull();
|
|
80
|
+
expect(onClick).toHaveBeenCalledTimes(2);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("treats a synchronous throw like a rejection", async () => {
|
|
84
|
+
render(
|
|
85
|
+
<AddToCart
|
|
86
|
+
onClick={() => {
|
|
87
|
+
throw new Error("boom");
|
|
88
|
+
}}
|
|
89
|
+
/>,
|
|
90
|
+
);
|
|
91
|
+
await act(async () => fireEvent.click(button()));
|
|
92
|
+
expect(button().getAttribute("data-add-to-cart")).toBe("error");
|
|
93
|
+
expect(screen.getByRole("alert").textContent).toBe("boom");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("AddToCart without onClick (demo)", () => {
|
|
98
|
+
it("ends in success after the simulated delay", async () => {
|
|
99
|
+
render(<AddToCart />);
|
|
100
|
+
expect(button().getAttribute("data-add-to-cart")).toBe("idle");
|
|
101
|
+
await act(async () => fireEvent.click(button()));
|
|
102
|
+
expect(button().getAttribute("data-add-to-cart")).toBe("added");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PlacementTabs linked mode (ADR-0101 §4/§6).
|
|
3
|
+
*
|
|
4
|
+
* - `linked` undefined → today's tabs, no link actions (back-compat).
|
|
5
|
+
* - `linked: true` → one non-interactive pill ("Both sides" / "All N areas")
|
|
6
|
+
* plus "Customize separately"; no per-placement tabs.
|
|
7
|
+
* - `linked: false` → the tabs plus "Use one design for all".
|
|
8
|
+
* - The container exposes `data-linked` so the state is assertable.
|
|
9
|
+
*/
|
|
10
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
11
|
+
import React from "react";
|
|
12
|
+
import { cleanup, fireEvent, render } from "@testing-library/react";
|
|
13
|
+
import { PlacementTabs, linkedPlacementsLabel, type PlacementConfig } from "../PlacementTabs";
|
|
14
|
+
|
|
15
|
+
afterEach(cleanup);
|
|
16
|
+
|
|
17
|
+
const two: PlacementConfig[] = [
|
|
18
|
+
{ label: "Left", type: "image", width: 1200, height: 1800 },
|
|
19
|
+
{ label: "Right", type: "image", width: 1200, height: 1800 },
|
|
20
|
+
];
|
|
21
|
+
const four: PlacementConfig[] = [
|
|
22
|
+
...two,
|
|
23
|
+
{ label: "Front", type: "image", width: 1200, height: 1800 },
|
|
24
|
+
{ label: "Back", type: "image", width: 1200, height: 1800 },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
describe("linkedPlacementsLabel", () => {
|
|
28
|
+
it("names the set", () => {
|
|
29
|
+
expect(linkedPlacementsLabel(2)).toBe("Both sides");
|
|
30
|
+
expect(linkedPlacementsLabel(4)).toBe("All 4 areas");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe.each([{ isDesktop: true }, { isDesktop: false }])(
|
|
35
|
+
"PlacementTabs (isDesktop=$isDesktop)",
|
|
36
|
+
({ isDesktop }) => {
|
|
37
|
+
it("renders plain tabs with no link actions when `linked` is undefined", () => {
|
|
38
|
+
const { container, queryByText, getByText } = render(
|
|
39
|
+
<PlacementTabs
|
|
40
|
+
placements={two}
|
|
41
|
+
selectedPlacement="Left"
|
|
42
|
+
onPlacementChange={() => {}}
|
|
43
|
+
isDesktop={isDesktop}
|
|
44
|
+
onSplitAll={() => {}}
|
|
45
|
+
onLinkAll={() => {}}
|
|
46
|
+
/>,
|
|
47
|
+
);
|
|
48
|
+
const root = container.querySelector("[data-placement-tabs]");
|
|
49
|
+
expect(root).not.toBeNull();
|
|
50
|
+
expect(root!.getAttribute("data-linked")).toBeNull();
|
|
51
|
+
expect(getByText("Left")).toBeTruthy();
|
|
52
|
+
expect(getByText("Right")).toBeTruthy();
|
|
53
|
+
expect(queryByText("Customize separately")).toBeNull();
|
|
54
|
+
expect(queryByText("Use one design for all")).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("linked: shows one pill and the split action, no tabs", () => {
|
|
58
|
+
const onSplitAll = vi.fn();
|
|
59
|
+
const onPlacementChange = vi.fn();
|
|
60
|
+
const { container, queryByText, getByText } = render(
|
|
61
|
+
<PlacementTabs
|
|
62
|
+
placements={two}
|
|
63
|
+
selectedPlacement="Left"
|
|
64
|
+
onPlacementChange={onPlacementChange}
|
|
65
|
+
isDesktop={isDesktop}
|
|
66
|
+
linked
|
|
67
|
+
onSplitAll={onSplitAll}
|
|
68
|
+
onLinkAll={() => {}}
|
|
69
|
+
/>,
|
|
70
|
+
);
|
|
71
|
+
const root = container.querySelector("[data-placement-tabs]")!;
|
|
72
|
+
expect(root.getAttribute("data-linked")).toBe("true");
|
|
73
|
+
const pill = container.querySelector("[data-placement-pill]")!;
|
|
74
|
+
expect(pill.textContent).toBe("Both sides");
|
|
75
|
+
expect(pill.getAttribute("title")).toBe("Left · Right");
|
|
76
|
+
expect(pill.tagName).not.toBe("BUTTON");
|
|
77
|
+
// No per-placement tabs while linked — there is one design to edit.
|
|
78
|
+
expect(container.querySelector("[data-placement-tab]")).toBeNull();
|
|
79
|
+
expect(queryByText("Use one design for all")).toBeNull();
|
|
80
|
+
|
|
81
|
+
fireEvent.click(getByText("Customize separately"));
|
|
82
|
+
expect(onSplitAll).toHaveBeenCalledTimes(1);
|
|
83
|
+
expect(onPlacementChange).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("linked with 4 placements says 'All 4 areas'", () => {
|
|
87
|
+
const { container } = render(
|
|
88
|
+
<PlacementTabs
|
|
89
|
+
placements={four}
|
|
90
|
+
selectedPlacement="Left"
|
|
91
|
+
onPlacementChange={() => {}}
|
|
92
|
+
isDesktop={isDesktop}
|
|
93
|
+
linked
|
|
94
|
+
onSplitAll={() => {}}
|
|
95
|
+
/>,
|
|
96
|
+
);
|
|
97
|
+
expect(container.querySelector("[data-placement-pill]")!.textContent).toBe(
|
|
98
|
+
"All 4 areas",
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("split: shows the tabs plus the link action", () => {
|
|
103
|
+
const onLinkAll = vi.fn();
|
|
104
|
+
const onPlacementChange = vi.fn();
|
|
105
|
+
const { container, getByText, queryByText } = render(
|
|
106
|
+
<PlacementTabs
|
|
107
|
+
placements={two}
|
|
108
|
+
selectedPlacement="Left"
|
|
109
|
+
onPlacementChange={onPlacementChange}
|
|
110
|
+
isDesktop={isDesktop}
|
|
111
|
+
linked={false}
|
|
112
|
+
onSplitAll={() => {}}
|
|
113
|
+
onLinkAll={onLinkAll}
|
|
114
|
+
/>,
|
|
115
|
+
);
|
|
116
|
+
const root = container.querySelector("[data-placement-tabs]")!;
|
|
117
|
+
expect(root.getAttribute("data-linked")).toBe("false");
|
|
118
|
+
expect(container.querySelectorAll("[data-placement-tab]")).toHaveLength(2);
|
|
119
|
+
expect(queryByText("Customize separately")).toBeNull();
|
|
120
|
+
expect(container.querySelector("[data-placement-pill]")).toBeNull();
|
|
121
|
+
|
|
122
|
+
fireEvent.click(getByText("Right"));
|
|
123
|
+
expect(onPlacementChange).toHaveBeenCalledWith("Right");
|
|
124
|
+
|
|
125
|
+
fireEvent.click(getByText("Use one design for all"));
|
|
126
|
+
expect(onLinkAll).toHaveBeenCalledTimes(1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("single placement renders no link model at all", () => {
|
|
130
|
+
const { container, queryByText } = render(
|
|
131
|
+
<PlacementTabs
|
|
132
|
+
placements={[two[0]]}
|
|
133
|
+
selectedPlacement="Left"
|
|
134
|
+
onPlacementChange={() => {}}
|
|
135
|
+
isDesktop={isDesktop}
|
|
136
|
+
linked
|
|
137
|
+
onSplitAll={() => {}}
|
|
138
|
+
/>,
|
|
139
|
+
);
|
|
140
|
+
expect(container.querySelector("[data-placement-pill]")).toBeNull();
|
|
141
|
+
expect(queryByText("Customize separately")).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
);
|