@snowcone-app/ui 0.8.3 → 0.9.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@snowcone-app/ui",
3
- "version": "0.8.3",
3
+ "version": "0.9.1",
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.2"
106
+ "@snowcone-app/sdk": "0.24.3"
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": "15.5.24",
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
- onClick?: (cartDetail: any) => void;
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 state with visual feedback (green background)
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
- * console.log('Added to cart:', cartDetail);
62
- * // Add to your cart state/API
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
- * // Faster simulation for testing
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 - Callback with cart detail after successful add
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 [loading, setLoading] = useState(false);
123
- const [success, setSuccess] = useState(false);
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
- setLoading(true);
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
- // Simulate cart operation delay
170
- await simulateCartOperation(simulateDelay);
171
-
172
- // Call onClick handler with cart detail
173
- onClick?.(cartDetail);
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
- // Reset success after 2 seconds
191
+ setStatus("added");
179
192
  setTimeout(() => {
180
- setSuccess(false);
193
+ setStatus((s) => (s === "added" ? "idle" : s));
181
194
  }, 2000);
182
195
  } catch (error) {
183
- onError?.(error as Error);
184
- console.error("Add to cart failed:", error);
185
- } finally {
186
- setLoading(false);
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,7 +43,7 @@ const QUICK_COLORS = [
43
43
  * ```tsx
44
44
  * import { Product, ColorPicker } from '@snowcone-app/ui';
45
45
  *
46
- * <Product productId="AR2P3G">
46
+ * <Product productId="CAXHQC">
47
47
  * <ColorPicker placement="Crown" defaultColor="#ff0000" />
48
48
  * </Product>
49
49
  * ```
@@ -24,6 +24,7 @@ import {
24
24
  getMockupUrl,
25
25
  type Design,
26
26
  resolveVariantId,
27
+ resolvePreviewVariantId,
27
28
  resolveMockupId,
28
29
  } from "@snowcone-app/sdk";
29
30
 
@@ -232,13 +233,18 @@ export const HeroProductImage = memo(function HeroProductImage({
232
233
  const actualProductId = product?.id || productId || context?.product?.id;
233
234
 
234
235
  // Resolve variant and mockup IDs - MOVED UP for initial URL computation
235
- const actualGvid = useMemo(() => {
236
+ const selectedGvid = useMemo(() => {
236
237
  return resolveVariantId(context, gvid, variantId);
237
238
  }, [gvid, variantId, context?.selection, context?.combinations]);
238
239
 
239
240
  const actualMockupId = useMemo(() => {
240
- return resolveMockupId(context, actualGvid, mockupId);
241
- }, [mockupId, actualGvid, context]);
241
+ return resolveMockupId(context, selectedGvid, mockupId);
242
+ }, [mockupId, selectedGvid, context]);
243
+
244
+ // What this image renders: a selected size with no scene previews a listed one. A variant the caller named is theirs.
245
+ const actualGvid = useMemo(() => {
246
+ return gvid || variantId ? selectedGvid : resolvePreviewVariantId(context, selectedGvid, actualMockupId);
247
+ }, [gvid, variantId, selectedGvid, actualMockupId, context]);
242
248
 
243
249
  // The server refused THIS mockup for the session (its scene failed render
244
250
  // parity — E_MOCKUP_NOT_BLESSED). Shown as unavailable on top of whatever
@@ -23,7 +23,7 @@ export interface ProductGalleryProps extends UseProductGalleryOptions {
23
23
  * 2. List all products: Pass empty array or omit productIds
24
24
  *
25
25
  * @example Specific products
26
- * <ProductGallery productIds={["BEEB77", "AR2P3G", "KMYKUK"]}>
26
+ * <ProductGallery productIds={["BEEB77", "CAXHQC", "KMYKUK"]}>
27
27
  * {(products, loading, error) => (
28
28
  * <div className="grid grid-cols-3 gap-4">
29
29
  * {products.map(product => (
@@ -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
+ });
@@ -49,7 +49,7 @@ export interface UseProductGalleryReturn {
49
49
  * ```tsx
50
50
  * // Fetch specific products
51
51
  * const { products, loading, error, refetch } = useProductGallery(
52
- * ['BEEB77', 'AR2P3G'],
52
+ * ['BEEB77', 'CAXHQC'],
53
53
  * { endpoint: 'http://localhost:3000', mode: 'mock' }
54
54
  * );
55
55
  *
@@ -37,7 +37,7 @@ import React, {
37
37
  useMemo,
38
38
  } from "react";
39
39
  import { useRealtimeMockup } from "@snowcone-app/sdk/react";
40
- import { resolveVariantId as resolveVariantIdUtil } from "@snowcone-app/sdk";
40
+ import { resolvePreviewVariantId, resolveVariantId as resolveVariantIdUtil } from "@snowcone-app/sdk";
41
41
  import type {
42
42
  RealtimeRenderError,
43
43
  MockupResult as SdkMockupResult,
@@ -987,10 +987,9 @@ export function RealtimeProvider({
987
987
  const placements = product.placements || [];
988
988
 
989
989
  // Resolve variant ID based on current selection
990
- const variantId = resolveVariantIdUtil(
990
+ const variantId = resolvePreviewVariantId(
991
991
  productContext,
992
- undefined,
993
- undefined
992
+ resolveVariantIdUtil(productContext, undefined, undefined)
994
993
  );
995
994
 
996
995
  // Check if we need to send config:
@@ -64,6 +64,7 @@ vi.mock("@snowcone-app/sdk/react", () => ({
64
64
 
65
65
  vi.mock("@snowcone-app/sdk", () => ({
66
66
  resolveVariantId: () => "gv1",
67
+ resolvePreviewVariantId: (_context: unknown, variantId: string) => variantId,
67
68
  }));
68
69
 
69
70
  vi.mock("../Product", () => {