@snowcone-app/ui 0.8.3 → 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 +11 -0
- package/dist/index.cjs +23 -13
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +25 -15
- package/dist/index.d.ts +25 -15
- package/dist/index.js +23 -13
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/composed/AddToCart.tsx +51 -31
- package/src/composed/__tests__/AddToCart.async.test.tsx +104 -0
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",
|
|
@@ -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",
|
|
@@ -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
|
}
|
|
@@ -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
|
+
});
|