epicmerch-mcp 1.3.2 → 1.3.6

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": "epicmerch-mcp",
3
- "version": "1.3.2",
3
+ "version": "1.3.6",
4
4
  "description": "MCP server for EpicMerch — integrates e-commerce into Claude and ChatGPT",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -22,9 +22,23 @@ export const store = new EpicMerch({
22
22
  apiKey: import.meta.env.VITE_API_KEY,
23
23
  ...(import.meta.env.VITE_API_URL && { baseUrl: import.meta.env.VITE_API_URL }),
24
24
  });
25
+
26
+ // Restore the customer session on page load — CRITICAL for OTP login to
27
+ // actually stick. Without this, the merchant logs in successfully, the
28
+ // token gets saved to localStorage, but the SDK in memory stays
29
+ // unauthenticated. The very next render shows them as logged-out again.
30
+ if (typeof window !== 'undefined') {
31
+ try {
32
+ const saved = localStorage.getItem('customerInfo');
33
+ if (saved) {
34
+ const parsed = JSON.parse(saved);
35
+ if (parsed?.token) store.setCustomerToken(parsed.token);
36
+ }
37
+ } catch (_) { /* localStorage unavailable — silent fallback */ }
38
+ }
25
39
  ```
26
40
 
27
- (For Next.js projects use `process.env.NEXT_PUBLIC_API_KEY` / `NEXT_PUBLIC_API_URL` instead.)
41
+ (For Next.js projects use `process.env.NEXT_PUBLIC_API_KEY` / `NEXT_PUBLIC_API_URL` instead. The session-restore block stays the same — the `typeof window !== 'undefined'` guard prevents SSR crashes.)
28
42
 
29
43
  ## Step 3: Login component
30
44
 
@@ -47,6 +61,8 @@ export default function Login({ onLogin }) {
47
61
  const verify = async () => {
48
62
  const result = await store.auth.verifyOtp(phone, otp, {});
49
63
  if (result.token) {
64
+ // SDK already sets the token internally during verifyOtp; mirror to
65
+ // localStorage so src/lib/epicmerch.js can restore on next page load.
50
66
  store.setCustomerToken(result.token);
51
67
  localStorage.setItem('customerInfo', JSON.stringify(result));
52
68
  onLogin(result);
@@ -57,13 +73,31 @@ export default function Login({ onLogin }) {
57
73
  <div>
58
74
  {step === 'phone' ? (
59
75
  <>
60
- <input value={phone} onChange={e => setPhone(e.target.value)} placeholder="+919876543210" />
76
+ <input
77
+ type="tel"
78
+ value={phone}
79
+ onChange={e => setPhone(e.target.value)}
80
+ placeholder="+919876543210"
81
+ autoComplete="tel"
82
+ />
61
83
  <button onClick={sendOtp}>Send OTP</button>
62
84
  </>
63
85
  ) : (
64
86
  <>
65
- <input value={otp} onChange={e => setOtp(e.target.value)} placeholder="Enter OTP" />
66
- <button onClick={verify}>Verify</button>
87
+ {/* EpicMerch OTPs are 4 digits. inputMode='numeric' pops the
88
+ numeric keypad on mobile; autoComplete='one-time-code' lets
89
+ iOS auto-fill from SMS without leaving the page. */}
90
+ <input
91
+ type="tel"
92
+ inputMode="numeric"
93
+ maxLength={4}
94
+ autoComplete="one-time-code"
95
+ value={otp}
96
+ onChange={e => setOtp(e.target.value.replace(/\D/g, '').slice(0, 4))}
97
+ placeholder="4-digit OTP"
98
+ autoFocus
99
+ />
100
+ <button onClick={verify} disabled={otp.length !== 4}>Verify</button>
67
101
  </>
68
102
  )}
69
103
  </div>
@@ -0,0 +1,217 @@
1
+ ---
2
+ name: epicmerch-debug
3
+ description: Diagnose and fix a broken EpicMerch storefront. Walks through the four most common failure modes — OTP login that "works" but doesn't stick across reloads, products not loading on the site, cart returns 401, INSUFFICIENT_STOCK on checkout — runs targeted checks, and routes to a specific fix.
4
+ ---
5
+
6
+ # EpicMerch Debug
7
+
8
+ You are debugging a storefront that isn't behaving. Don't ask the merchant
9
+ to describe what's wrong in detail — most issues map to a known failure
10
+ mode. Walk through the four checks below in order. The first one that
11
+ hits is almost always the root cause.
12
+
13
+ ## Pre-flight: gather context
14
+
15
+ Before checking anything, get the basics in one parallel batch:
16
+
17
+ 1. Read the project's `.env` (or `.env.local`) and grab `VITE_API_KEY` /
18
+ `NEXT_PUBLIC_API_KEY`. If neither exists, that's the first problem —
19
+ route to `/epicmerch` step 1 to generate one.
20
+ 2. Read `src/lib/epicmerch.js` (or wherever the SDK is initialised). Note
21
+ whether it has the `setCustomerToken` restoration block from
22
+ localStorage (see Check 1 below — if it's missing, that's the cause).
23
+ 3. Call `merchant_get_settings` to confirm the merchant's session is still
24
+ live and the API key actually belongs to this merchant.
25
+ 4. Call `merchant_get_checkout_settings` to confirm a payment processor
26
+ is configured.
27
+
28
+ Print a 4-line summary, then dive into the relevant check.
29
+
30
+ ---
31
+
32
+ ## Check 1 — "I logged in but nothing happened" / "OTP works but doesn't persist"
33
+
34
+ **Symptom.** Merchant enters phone, gets OTP, types OTP, the success
35
+ state briefly flashes — then on next page render OR after a page reload,
36
+ the storefront thinks they're logged out again. Cart returns 401. Order
37
+ button is disabled. They re-login, same thing.
38
+
39
+ **Cause.** The EpicMerch SDK holds the customer token in memory only.
40
+ `store.auth.verifyOtp()` calls `store.setCustomerToken()` internally,
41
+ which is why the first session works — but the token isn't restored from
42
+ localStorage on subsequent SDK initialisations. Login.jsx writes
43
+ `customerInfo` to localStorage; the SDK never reads it back.
44
+
45
+ **Diagnosis.** Read `src/lib/epicmerch.js`. If you do NOT see a block
46
+ like this somewhere after the `new EpicMerch(...)` call:
47
+
48
+ ```js
49
+ if (typeof window !== 'undefined') {
50
+ try {
51
+ const saved = localStorage.getItem('customerInfo');
52
+ if (saved) {
53
+ const parsed = JSON.parse(saved);
54
+ if (parsed?.token) store.setCustomerToken(parsed.token);
55
+ }
56
+ } catch (_) {}
57
+ }
58
+ ```
59
+
60
+ …that's the bug. Confirm by asking the merchant to open DevTools →
61
+ Application → Local Storage, expand their site origin, and check that
62
+ `customerInfo` IS there (it should be — Login.jsx writes it). If the
63
+ key exists but the SDK doesn't know about it, it's this exact bug.
64
+
65
+ **Fix.** Patch `src/lib/epicmerch.js` to add the restoration block
66
+ (verbatim from above, right after `export const store = ...`). Tell the
67
+ merchant to refresh — they should still be logged in from their previous
68
+ session.
69
+
70
+ If they want to log out properly, the matching cleanup is:
71
+
72
+ ```js
73
+ store.clearCustomerToken();
74
+ localStorage.removeItem('customerInfo');
75
+ ```
76
+
77
+ ---
78
+
79
+ ## Check 2 — Products don't load on the site
80
+
81
+ **Symptom.** Storefront renders, but the product grid is empty.
82
+ DevTools → Network shows the call to `/api/products` either 401-ing,
83
+ 403-ing, or returning an empty array.
84
+
85
+ **Cause.** One of three:
86
+ - **401**: invalid or revoked API key — `VITE_API_KEY` in `.env` doesn't
87
+ match any active key on the server.
88
+ - **403**: domain isn't in `merchant.allowedDomains` — the CORS / origin
89
+ check is rejecting the request.
90
+ - **200 with empty `products` array**: API key is fine, but the
91
+ merchant's catalog is genuinely empty.
92
+
93
+ **Diagnosis.**
94
+
95
+ 1. Open DevTools → Network, filter for `/products`, click the request.
96
+ Note the response status.
97
+ 2. Compare the `x-api-key` header value to the merchant's actual key:
98
+ `merchant_list_api_keys()` returns the canonical list. If it's masked,
99
+ generate a fresh one with `merchant_generate_api_key({ name: "Debug" })`
100
+ and use that value in `.env`.
101
+ 3. Note `window.location.origin`. Call `merchant_get_settings()` and
102
+ compare against `merchant.allowedDomains`. If the storefront's origin
103
+ isn't listed, that's the 403.
104
+ 4. Call `merchant_list_products({ limit: 1 })` to confirm the catalog
105
+ actually has products.
106
+
107
+ **Fix.**
108
+ - **Bad API key**: regenerate via `merchant_generate_api_key`, paste the
109
+ new value into `.env`, restart the dev server (`npm run dev`) so Vite
110
+ picks up the new env var.
111
+ - **Domain not allowed**: `merchant_add_allowed_domain({ domain: '<origin>' })`,
112
+ then refresh.
113
+ - **Empty catalog**: route to `/epicmerch` step 3 to add products.
114
+
115
+ ---
116
+
117
+ ## Check 3 — "INSUFFICIENT_STOCK: undefined: need 1, have 0" on checkout
118
+
119
+ **Symptom.** Adding to cart works. Cart shows items. Place Order returns
120
+ HTTP 400 with `{ code: 'INSUFFICIENT_STOCK', message: 'undefined: need 1, have 0' }`.
121
+
122
+ **Cause.** Almost always one of these mismatches between what the
123
+ storefront sends and what the server expects in `orderItems[]`:
124
+
125
+ - The line item uses `productId` field but it resolves to `undefined`
126
+ (variant lookup fails because the size string in `orderItems[].variant`
127
+ doesn't match any row in `product_variants`).
128
+ - The product genuinely has 0 stock for that variant.
129
+ - An old scaffold (pre-1.3.3) sent `orderItems[].productId` as
130
+ `undefined` because it read `v.size` on a variant object where the
131
+ field is actually named `v.variant`.
132
+
133
+ **Diagnosis.**
134
+
135
+ 1. Read `src/components/ProductCard.jsx`. Look for either:
136
+ - `availableSizes.map(v => v.size)` — old, broken (returns undefined)
137
+ - `availableVariants.map(v => v.variant)` — correct
138
+ 2. Read `src/components/Checkout.jsx`. Look at the `orderItems` map. The
139
+ product field should be `productId: i.product._id` (canonical) and
140
+ the variant field should be `variant: i.variant` (a string).
141
+ 3. From the merchant's perspective, open Cart, note the variant
142
+ labels visible. Open DevTools → Application → Local Storage →
143
+ `customerInfo`. Try `store.cart.get()` in the console and inspect
144
+ the result — every line item should have a `variant` string that
145
+ matches an entry in the product's `product_variants` table.
146
+
147
+ **Fix.**
148
+ - If ProductCard reads `v.size` → re-run `/epicmerch-storefront` to
149
+ refresh the scaffold from the 1.3.3+ contract.
150
+ - If Checkout sends `product:` instead of `productId:` in orderItems
151
+ → same, re-scaffold.
152
+ - If the variant truly isn't in the database → the product has stock=0
153
+ for that size, or the variant was never created. Have the merchant
154
+ call `merchant_get_product({ id })` and inspect `.variants`. If
155
+ needed, `merchant_update_product({ id, variants: [...] })` to fix.
156
+
157
+ ---
158
+
159
+ ## Check 4 — Add to cart returns 401 / 403
160
+
161
+ **Symptom.** Customer is "logged in" (UI shows their phone/name), but
162
+ clicking Add to Cart fails. Network shows 401 or 403 on POST `/customer/cart`.
163
+
164
+ **Cause.** The Authorization header isn't being sent. Either:
165
+ - Token was never restored on this page load (Check 1).
166
+ - Token expired (30-minute lifetime by default; SDK can refresh, but the
167
+ merchant might have been idle).
168
+ - Login.jsx didn't actually save the token (look for typo).
169
+
170
+ **Diagnosis.**
171
+
172
+ 1. In the console: `localStorage.getItem('customerInfo')` — is it
173
+ present? Is the `token` field a non-empty string?
174
+ 2. In the console: `store.getCustomerToken?.()` (if exposed) or check
175
+ that subsequent API calls include the Authorization header.
176
+ 3. Call `store.auth.getSession()` — if it returns null, the token has
177
+ expired or wasn't restored. `store.auth.refreshToken()` can extend it.
178
+
179
+ **Fix.**
180
+ - If localStorage is empty: merchant needs to log in again.
181
+ - If the token is there but the SDK doesn't have it: apply Check 1's
182
+ restoration block.
183
+ - If `getSession()` returns null: `store.auth.refreshToken()` — if
184
+ that fails, log out (clear customerInfo) and log in again.
185
+
186
+ ---
187
+
188
+ ## Wrap-up
189
+
190
+ After running through the four checks, print a one-line summary:
191
+
192
+ ```
193
+ ✓ Check 1 — Session restore localStorage block present in src/lib/epicmerch.js
194
+ ✗ Check 2 — Products load API key in .env didn't match any active key — regenerated
195
+ - Check 3 — Order shape skipped (cart works)
196
+ - Check 4 — Cart auth skipped (cart works)
197
+
198
+ Fix applied: rewrote .env with the new API key. Restart `npm run dev`
199
+ to pick up the change.
200
+ ```
201
+
202
+ If everything checks out and the merchant still has an issue, ask them
203
+ to paste the EXACT error message + the response body from DevTools →
204
+ Network. That usually pins it. Don't speculate — get the literal error.
205
+
206
+ ## When to escalate
207
+
208
+ If none of the 4 checks fit the symptom, fall through to
209
+ `merchant_diagnose` for a server-side health snapshot, then route based
210
+ on what it reports:
211
+
212
+ - "Razorpay not configured" → `/epicmerch-razorpay`
213
+ - "No products" → `/epicmerch` step 3 or `/epicmerch-migrate`
214
+ - "No API key" → `/epicmerch` step 1
215
+ - "Domain not allowed" → `merchant_add_allowed_domain`
216
+ - Anything else → surface the diagnostic output to the merchant
217
+ verbatim and let them decide.
@@ -33,9 +33,12 @@ import { useEffect, useState } from 'react';
33
33
  import { store } from '../lib/epicmerch';
34
34
 
35
35
  export default function Cart({ onCheckout }) {
36
- // store.cart.get() returns { cart, cartCount, cartTotal } — the cart line
37
- // items are on `cart.cart`, NOT `cart.items`. Each line item is shaped
38
- // { product: { _id, name, price, salePrice, images, ... }, qty, variant }.
36
+ // store.cart.get() returns { cart: [...] } — JUST one field. The SDK
37
+ // README mentions cartCount/cartTotal but those aren't actually sent by
38
+ // the server; compute them locally. Each line item is shaped
39
+ // { product: { _id, name, price, originalPrice, salePrice, image, type },
40
+ // qty, variant }. Note `product.price` on cart items is ALREADY the
41
+ // effective price (sale if on sale, else regular).
39
42
  const [cart, setCart] = useState({ cart: [] });
40
43
 
41
44
  useEffect(() => { store.cart.get().then(setCart); }, []);
@@ -105,20 +108,23 @@ export default function Checkout({ cart, onSuccess }) {
105
108
  await loadRazorpay();
106
109
 
107
110
  // Map cart line items to the orderItems shape the API expects.
108
- // Critical fields per the SDK README:
109
- // - `product` (NOT `productId`) — the product _id
111
+ // Critical fields per the SDK contract:
112
+ // - `productId` — the product _id (same field name as cart.add,
113
+ // magic-checkout-init, analytics.track; consistent
114
+ // across every endpoint). Server still accepts the
115
+ // legacy `product` field but `productId` is canonical.
110
116
  // - `name`, `image` — required, the server doesn't re-fetch them
111
117
  // - `variant` — the size STRING, must match what was added to cart
112
118
  // or the variant lookup returns undefined and you'll
113
119
  // see "INSUFFICIENT_STOCK: undefined: need 1, have 0"
114
120
  const items = cart.cart || [];
115
121
  const orderItems = items.map(i => ({
116
- product: i.product._id,
117
- name: i.product.name,
118
- image: i.product.images?.[0] || i.product.image,
119
- qty: i.qty,
120
- price: i.product.salePrice ?? i.product.price,
121
- variant: i.variant,
122
+ productId: i.product._id,
123
+ name: i.product.name,
124
+ image: i.product.images?.[0] || i.product.image,
125
+ qty: i.qty,
126
+ price: i.product.salePrice ?? i.product.price,
127
+ variant: i.variant,
122
128
  }));
123
129
  const total = orderItems.reduce((s, i) => s + i.price * i.qty, 0);
124
130
 
@@ -32,40 +32,42 @@ If `src/components/ProductCard.jsx` already exists, skip. Otherwise write:
32
32
  import { useState } from 'react';
33
33
 
34
34
  export default function ProductCard({ product, onAddToCart }) {
35
- // Variants are stock-tracked per-size. Only show sizes that are in stock
36
- // the API will reject orders for out-of-stock variants with
37
- // "INSUFFICIENT_STOCK: <size>: need N, have 0".
38
- const availableSizes = (product.variants || []).filter(v => v.stock > 0);
39
- const [size, setSize] = useState(availableSizes[0]?.size);
35
+ // ProductVariant rows are { variant: String, stock: Int } note the field
36
+ // is named `variant`, NOT `size`. The "variant" string is whatever the
37
+ // merchant configured (typically a size like 'S'/'M'/'L', but could be
38
+ // a color or material). Only show in-stock options — out-of-stock orders
39
+ // are rejected with "INSUFFICIENT_STOCK: <variant>: need N, have 0".
40
+ const availableVariants = (product.variants || []).filter(v => v.stock > 0);
41
+ const [variant, setVariant] = useState(availableVariants[0]?.variant);
40
42
 
41
43
  // Products on sale return both `price` (original) and `salePrice` (current).
42
44
  // Always display the current price the customer will actually pay.
43
45
  const price = product.salePrice ?? product.price;
44
46
  const image = product.images?.[0] || product.image;
45
- const needsSize = availableSizes.length > 0;
47
+ const hasVariants = availableVariants.length > 0;
46
48
 
47
49
  return (
48
50
  <div className="product-card">
49
51
  <img src={image} alt={product.name} />
50
52
  <h3>{product.name}</h3>
51
53
  <p>₹{price}</p>
52
- {needsSize && (
53
- <div className="size-picker">
54
- {availableSizes.map(v => (
54
+ {hasVariants && (
55
+ <div className="variant-picker">
56
+ {availableVariants.map(v => (
55
57
  <button
56
- key={v.size}
58
+ key={v.variant}
57
59
  type="button"
58
- onClick={() => setSize(v.size)}
59
- className={size === v.size ? 'selected' : ''}
60
+ onClick={() => setVariant(v.variant)}
61
+ className={variant === v.variant ? 'selected' : ''}
60
62
  >
61
- {v.size}
63
+ {v.variant}
62
64
  </button>
63
65
  ))}
64
66
  </div>
65
67
  )}
66
68
  <button
67
- onClick={() => onAddToCart(product._id, size)}
68
- disabled={needsSize && !size}
69
+ onClick={() => onAddToCart(product._id, variant)}
70
+ disabled={hasVariants && !variant}
69
71
  >
70
72
  Add to Cart
71
73
  </button>
@@ -54,6 +54,20 @@ export const store = new EpicMerch({
54
54
  apiKey: import.meta.env.VITE_API_KEY,
55
55
  ...(import.meta.env.VITE_API_URL && { baseUrl: import.meta.env.VITE_API_URL }),
56
56
  });
57
+
58
+ // Restore the customer session on page load. Without this, OTP login
59
+ // appears to "work" (token gets saved to localStorage) but the SDK
60
+ // stays unauthenticated until something calls setCustomerToken — so
61
+ // the merchant logs in, the page reloads, and they're logged out again.
62
+ if (typeof window !== 'undefined') {
63
+ try {
64
+ const saved = localStorage.getItem('customerInfo');
65
+ if (saved) {
66
+ const parsed = JSON.parse(saved);
67
+ if (parsed?.token) store.setCustomerToken(parsed.token);
68
+ }
69
+ } catch (_) { /* localStorage unavailable — silent fallback */ }
70
+ }
57
71
  ```
58
72
 
59
73
  - For `NEXT_PUBLIC_` prefix (Next.js):
@@ -64,6 +78,19 @@ export const store = new EpicMerch({
64
78
  apiKey: process.env.NEXT_PUBLIC_API_KEY,
65
79
  ...(process.env.NEXT_PUBLIC_API_URL && { baseUrl: process.env.NEXT_PUBLIC_API_URL }),
66
80
  });
81
+
82
+ // Restore customer session on page load (same as Vite — without this,
83
+ // OTP login looks like it works but doesn't survive a page reload).
84
+ // Guard the localStorage call so server-rendered pages don't crash.
85
+ if (typeof window !== 'undefined') {
86
+ try {
87
+ const saved = localStorage.getItem('customerInfo');
88
+ if (saved) {
89
+ const parsed = JSON.parse(saved);
90
+ if (parsed?.token) store.setCustomerToken(parsed.token);
91
+ }
92
+ } catch (_) { /* localStorage unavailable */ }
93
+ }
67
94
  ```
68
95
 
69
96
  - [ ] **Step 4: Scaffold auth**
@@ -87,6 +114,8 @@ export default function Login({ onLogin }) {
87
114
  const verify = async () => {
88
115
  const result = await store.auth.verifyOtp(phone, otp, {});
89
116
  if (result.token) {
117
+ // SDK already sets the token internally during verifyOtp; mirror to
118
+ // localStorage so src/lib/epicmerch.js can restore on next page load.
90
119
  store.setCustomerToken(result.token);
91
120
  localStorage.setItem('customerInfo', JSON.stringify(result));
92
121
  onLogin(result);
@@ -97,13 +126,31 @@ export default function Login({ onLogin }) {
97
126
  <div>
98
127
  {step === 'phone' ? (
99
128
  <>
100
- <input value={phone} onChange={e => setPhone(e.target.value)} placeholder="+919876543210" />
129
+ <input
130
+ type="tel"
131
+ value={phone}
132
+ onChange={e => setPhone(e.target.value)}
133
+ placeholder="+919876543210"
134
+ autoComplete="tel"
135
+ />
101
136
  <button onClick={sendOtp}>Send OTP</button>
102
137
  </>
103
138
  ) : (
104
139
  <>
105
- <input value={otp} onChange={e => setOtp(e.target.value)} placeholder="Enter OTP" />
106
- <button onClick={verify}>Verify</button>
140
+ {/* EpicMerch OTPs are 4 digits. inputMode='numeric' pops the
141
+ numeric keypad on mobile; autoComplete='one-time-code' lets
142
+ iOS auto-fill from SMS without leaving the page. */}
143
+ <input
144
+ type="tel"
145
+ inputMode="numeric"
146
+ maxLength={4}
147
+ autoComplete="one-time-code"
148
+ value={otp}
149
+ onChange={e => setOtp(e.target.value.replace(/\D/g, '').slice(0, 4))}
150
+ placeholder="4-digit OTP"
151
+ autoFocus
152
+ />
153
+ <button onClick={verify} disabled={otp.length !== 4}>Verify</button>
107
154
  </>
108
155
  )}
109
156
  </div>
@@ -121,40 +168,42 @@ Write `src/components/ProductCard.jsx`:
121
168
  import { useState } from 'react';
122
169
 
123
170
  export default function ProductCard({ product, onAddToCart }) {
124
- // Variants are stock-tracked per-size. Only show sizes that are in stock
125
- // the API will reject orders for out-of-stock variants with
126
- // "INSUFFICIENT_STOCK: <size>: need N, have 0".
127
- const availableSizes = (product.variants || []).filter(v => v.stock > 0);
128
- const [size, setSize] = useState(availableSizes[0]?.size);
171
+ // ProductVariant rows are { variant: String, stock: Int } note the field
172
+ // is named `variant`, NOT `size`. The "variant" string is whatever the
173
+ // merchant configured (typically a size like 'S'/'M'/'L', but could be
174
+ // a color or material). Only show in-stock options — out-of-stock orders
175
+ // are rejected with "INSUFFICIENT_STOCK: <variant>: need N, have 0".
176
+ const availableVariants = (product.variants || []).filter(v => v.stock > 0);
177
+ const [variant, setVariant] = useState(availableVariants[0]?.variant);
129
178
 
130
179
  // Products on sale return both `price` (original) and `salePrice` (current).
131
180
  // Always display the current price the customer will actually pay.
132
181
  const price = product.salePrice ?? product.price;
133
182
  const image = product.images?.[0] || product.image;
134
- const needsSize = availableSizes.length > 0;
183
+ const hasVariants = availableVariants.length > 0;
135
184
 
136
185
  return (
137
186
  <div className="product-card">
138
187
  <img src={image} alt={product.name} />
139
188
  <h3>{product.name}</h3>
140
189
  <p>₹{price}</p>
141
- {needsSize && (
142
- <div className="size-picker">
143
- {availableSizes.map(v => (
190
+ {hasVariants && (
191
+ <div className="variant-picker">
192
+ {availableVariants.map(v => (
144
193
  <button
145
- key={v.size}
194
+ key={v.variant}
146
195
  type="button"
147
- onClick={() => setSize(v.size)}
148
- className={size === v.size ? 'selected' : ''}
196
+ onClick={() => setVariant(v.variant)}
197
+ className={variant === v.variant ? 'selected' : ''}
149
198
  >
150
- {v.size}
199
+ {v.variant}
151
200
  </button>
152
201
  ))}
153
202
  </div>
154
203
  )}
155
204
  <button
156
- onClick={() => onAddToCart(product._id, size)}
157
- disabled={needsSize && !size}
205
+ onClick={() => onAddToCart(product._id, variant)}
206
+ disabled={hasVariants && !variant}
158
207
  >
159
208
  Add to Cart
160
209
  </button>
@@ -252,9 +301,12 @@ import { useEffect, useState } from 'react';
252
301
  import { store } from '../lib/epicmerch';
253
302
 
254
303
  export default function Cart({ onCheckout }) {
255
- // store.cart.get() returns { cart, cartCount, cartTotal } — the cart line
256
- // items are on `cart.cart`, NOT `cart.items`. Each line item is shaped
257
- // { product: { _id, name, price, salePrice, images, ... }, qty, variant }.
304
+ // store.cart.get() returns { cart: [...] } — JUST one field. The SDK
305
+ // README mentions cartCount/cartTotal but those aren't actually sent by
306
+ // the server; compute them locally. Each line item is shaped
307
+ // { product: { _id, name, price, originalPrice, salePrice, image, type },
308
+ // qty, variant }. Note `product.price` on cart items is ALREADY the
309
+ // effective price (sale if on sale, else regular).
258
310
  const [cart, setCart] = useState({ cart: [] });
259
311
 
260
312
  useEffect(() => { store.cart.get().then(setCart); }, []);
@@ -324,20 +376,23 @@ export default function Checkout({ cart, onSuccess }) {
324
376
  await loadRazorpay();
325
377
 
326
378
  // Map cart line items to the orderItems shape the API expects.
327
- // Critical fields per the SDK README:
328
- // - `product` (NOT `productId`) — the product _id
379
+ // Critical fields per the SDK contract:
380
+ // - `productId` — the product _id (same field name as cart.add,
381
+ // magic-checkout-init, analytics.track; consistent
382
+ // across every endpoint). Server still accepts the
383
+ // legacy `product` field but `productId` is canonical.
329
384
  // - `name`, `image` — required, the server doesn't re-fetch them
330
385
  // - `variant` — the size STRING, must match what was added to cart
331
386
  // or the variant lookup returns undefined and you'll
332
387
  // see "INSUFFICIENT_STOCK: undefined: need 1, have 0"
333
388
  const items = cart.cart || [];
334
389
  const orderItems = items.map(i => ({
335
- product: i.product._id,
336
- name: i.product.name,
337
- image: i.product.images?.[0] || i.product.image,
338
- qty: i.qty,
339
- price: i.product.salePrice ?? i.product.price,
340
- variant: i.variant,
390
+ productId: i.product._id,
391
+ name: i.product.name,
392
+ image: i.product.images?.[0] || i.product.image,
393
+ qty: i.qty,
394
+ price: i.product.salePrice ?? i.product.price,
395
+ variant: i.variant,
341
396
  }));
342
397
  const total = orderItems.reduce((s, i) => s + i.price * i.qty, 0);
343
398
 
@@ -381,7 +436,7 @@ export default function Checkout({ cart, onSuccess }) {
381
436
  }
382
437
  ```
383
438
 
384
- **If the merchant has a top-level `App.jsx` (or similar) that maintains a `refreshCartCount` or `handleAddToCart`, also update it:** `cart.get()` returns `{ cart, cartCount, cartTotal }`, so `refreshCartCount` should read `c?.cart?.length` (or just `c?.cartCount`), NOT `c?.items?.length`; and `handleAddToCart` should accept a `variant` arg and forward it: `store.cart.add(productId, 1, variant)`.
439
+ **If the merchant has a top-level `App.jsx` (or similar) that maintains a `refreshCartCount` or `handleAddToCart`, also update it:** `cart.get()` returns `{ cart: [...] }`, so `refreshCartCount` should read `c?.cart?.length` (NOT `c?.items?.length` and NOT `c?.cartCount` — that field doesn't come back from the server, despite what the SDK README says); and `handleAddToCart` should accept a `variant` arg and forward it: `store.cart.add(productId, 1, variant)`. Bonus: `store.cart.add` itself returns `{ message, cartCount }`, so you can update the badge from THAT response without a separate `cart.get()` round-trip.
385
440
 
386
441
  Do NOT add any Razorpay script tag to `index.html` — the script loads dynamically only when the user clicks Place Order.
387
442