epicmerch-mcp 1.3.1 → 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.1",
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,26 +33,49 @@ import { useEffect, useState } from 'react';
33
33
  import { store } from '../lib/epicmerch';
34
34
 
35
35
  export default function Cart({ onCheckout }) {
36
- const [cart, setCart] = useState({ items: [] });
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).
42
+ const [cart, setCart] = useState({ cart: [] });
37
43
 
38
44
  useEffect(() => { store.cart.get().then(setCart); }, []);
39
45
 
40
- const remove = async (productId) => {
41
- await store.cart.remove(productId);
42
- store.cart.get().then(setCart);
46
+ const remove = async (productId, variant) => {
47
+ await store.cart.remove(productId, variant);
48
+ // Optimistic local update — match on BOTH productId AND variant so we
49
+ // don't accidentally remove a different size of the same product.
50
+ setCart(c => ({
51
+ ...c,
52
+ cart: (c.cart || []).filter(
53
+ i => !(i.product._id === productId && i.variant === variant),
54
+ ),
55
+ }));
43
56
  };
44
57
 
45
- const total = cart.items?.reduce((s, i) => s + i.price * i.qty, 0) ?? 0;
58
+ const items = cart.cart || [];
59
+ const total = items.reduce((sum, i) => {
60
+ const price = i.product.salePrice ?? i.product.price;
61
+ return sum + price * i.qty;
62
+ }, 0);
46
63
 
47
64
  return (
48
65
  <div>
49
- {cart.items?.map(item => (
50
- <div key={item.productId}>
51
- <span>{item.name} x{item.qty}</span>
52
- <span>₹{item.price * item.qty}</span>
53
- <button onClick={() => remove(item.productId)}>Remove</button>
54
- </div>
55
- ))}
66
+ {items.map((item, idx) => {
67
+ const price = item.product.salePrice ?? item.product.price;
68
+ return (
69
+ <div key={`${item.product._id}-${item.variant || 'novariant'}-${idx}`}>
70
+ <span>
71
+ {item.product.name} x{item.qty}
72
+ {item.variant ? ` (${item.variant})` : ''}
73
+ </span>
74
+ <span>₹{price * item.qty}</span>
75
+ <button onClick={() => remove(item.product._id, item.variant)}>Remove</button>
76
+ </div>
77
+ );
78
+ })}
56
79
  <p>Total: ₹{total}</p>
57
80
  <button onClick={onCheckout}>Checkout</button>
58
81
  </div>
@@ -83,15 +106,36 @@ export default function Checkout({ cart, onSuccess }) {
83
106
 
84
107
  const placeOrder = async () => {
85
108
  await loadRazorpay();
86
- const total = cart.items.reduce((s, i) => s + i.price * i.qty, 0);
109
+
110
+ // Map cart line items to the orderItems shape the API expects.
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.
116
+ // - `name`, `image` — required, the server doesn't re-fetch them
117
+ // - `variant` — the size STRING, must match what was added to cart
118
+ // or the variant lookup returns undefined and you'll
119
+ // see "INSUFFICIENT_STOCK: undefined: need 1, have 0"
120
+ const items = cart.cart || [];
121
+ const orderItems = items.map(i => ({
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,
128
+ }));
129
+ const total = orderItems.reduce((s, i) => s + i.price * i.qty, 0);
87
130
 
88
131
  // ONE call to /customer/orders is enough — the server creates the
89
132
  // EpicMerch order AND the Razorpay order in a single transaction and
90
133
  // returns both back. Do NOT separately call store.payment.createOrder
91
- // here; doing so creates an orphan second Razorpay order AND can
92
- // trigger an idempotency-key collision against /customer/orders.
134
+ // or store.payment.getConfig here; doing so creates an orphan second
135
+ // Razorpay order AND can trigger an idempotency-key collision against
136
+ // /customer/orders.
93
137
  const orderResult = await store.orders.create({
94
- orderItems: cart.items.map(i => ({ productId: i.productId, qty: i.qty, price: i.price })),
138
+ orderItems,
95
139
  shippingAddress: address,
96
140
  paymentMethod: 'Razorpay',
97
141
  totalPrice: total,
@@ -29,13 +29,48 @@ export const store = new EpicMerch({
29
29
  If `src/components/ProductCard.jsx` already exists, skip. Otherwise write:
30
30
 
31
31
  ```jsx
32
+ import { useState } from 'react';
33
+
32
34
  export default function ProductCard({ product, onAddToCart }) {
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);
42
+
43
+ // Products on sale return both `price` (original) and `salePrice` (current).
44
+ // Always display the current price the customer will actually pay.
45
+ const price = product.salePrice ?? product.price;
46
+ const image = product.images?.[0] || product.image;
47
+ const hasVariants = availableVariants.length > 0;
48
+
33
49
  return (
34
50
  <div className="product-card">
35
- <img src={product.images?.[0]} alt={product.name} />
51
+ <img src={image} alt={product.name} />
36
52
  <h3>{product.name}</h3>
37
- <p>₹{product.price}</p>
38
- <button onClick={() => onAddToCart(product._id)}>Add to Cart</button>
53
+ <p>₹{price}</p>
54
+ {hasVariants && (
55
+ <div className="variant-picker">
56
+ {availableVariants.map(v => (
57
+ <button
58
+ key={v.variant}
59
+ type="button"
60
+ onClick={() => setVariant(v.variant)}
61
+ className={variant === v.variant ? 'selected' : ''}
62
+ >
63
+ {v.variant}
64
+ </button>
65
+ ))}
66
+ </div>
67
+ )}
68
+ <button
69
+ onClick={() => onAddToCart(product._id, variant)}
70
+ disabled={hasVariants && !variant}
71
+ >
72
+ Add to Cart
73
+ </button>
39
74
  </div>
40
75
  );
41
76
  }
@@ -66,7 +101,10 @@ export default function ProductList() {
66
101
  }
67
102
  }, [query, category]);
68
103
 
69
- const addToCart = (productId) => store.cart.add(productId, 1);
104
+ // `variant` is the size STRING (e.g. 'L'), per the SDK signature
105
+ // store.cart.add(productId, qty, variant). Products without variants
106
+ // pass undefined and the SDK accepts that.
107
+ const addToCart = (productId, variant) => store.cart.add(productId, 1, variant);
70
108
 
71
109
  return (
72
110
  <div>