epicmerch-mcp 1.3.0 → 1.3.2

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.0",
3
+ "version": "1.3.2",
4
4
  "description": "MCP server for EpicMerch — integrates e-commerce into Claude and ChatGPT",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -33,27 +33,46 @@ 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, 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 }.
39
+ const [cart, setCart] = useState({ cart: [] });
37
40
 
38
41
  useEffect(() => { store.cart.get().then(setCart); }, []);
39
42
 
40
- const remove = async (productId) => {
41
- await store.cart.remove(productId);
42
- // Optimistic local update — no need to re-fetch the whole cart.
43
- setCart(c => ({ ...c, items: (c.items || []).filter(i => i.productId !== productId) }));
43
+ const remove = async (productId, variant) => {
44
+ await store.cart.remove(productId, variant);
45
+ // Optimistic local update — match on BOTH productId AND variant so we
46
+ // don't accidentally remove a different size of the same product.
47
+ setCart(c => ({
48
+ ...c,
49
+ cart: (c.cart || []).filter(
50
+ i => !(i.product._id === productId && i.variant === variant),
51
+ ),
52
+ }));
44
53
  };
45
54
 
46
- const total = cart.items?.reduce((s, i) => s + i.price * i.qty, 0) ?? 0;
55
+ const items = cart.cart || [];
56
+ const total = items.reduce((sum, i) => {
57
+ const price = i.product.salePrice ?? i.product.price;
58
+ return sum + price * i.qty;
59
+ }, 0);
47
60
 
48
61
  return (
49
62
  <div>
50
- {cart.items?.map(item => (
51
- <div key={item.productId}>
52
- <span>{item.name} x{item.qty}</span>
53
- <span>₹{item.price * item.qty}</span>
54
- <button onClick={() => remove(item.productId)}>Remove</button>
55
- </div>
56
- ))}
63
+ {items.map((item, idx) => {
64
+ const price = item.product.salePrice ?? item.product.price;
65
+ return (
66
+ <div key={`${item.product._id}-${item.variant || 'novariant'}-${idx}`}>
67
+ <span>
68
+ {item.product.name} x{item.qty}
69
+ {item.variant ? ` (${item.variant})` : ''}
70
+ </span>
71
+ <span>₹{price * item.qty}</span>
72
+ <button onClick={() => remove(item.product._id, item.variant)}>Remove</button>
73
+ </div>
74
+ );
75
+ })}
57
76
  <p>Total: ₹{total}</p>
58
77
  <button onClick={onCheckout}>Checkout</button>
59
78
  </div>
@@ -84,15 +103,33 @@ export default function Checkout({ cart, onSuccess }) {
84
103
 
85
104
  const placeOrder = async () => {
86
105
  await loadRazorpay();
87
- const total = cart.items.reduce((s, i) => s + i.price * i.qty, 0);
106
+
107
+ // 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
110
+ // - `name`, `image` — required, the server doesn't re-fetch them
111
+ // - `variant` — the size STRING, must match what was added to cart
112
+ // or the variant lookup returns undefined and you'll
113
+ // see "INSUFFICIENT_STOCK: undefined: need 1, have 0"
114
+ const items = cart.cart || [];
115
+ 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
+ }));
123
+ const total = orderItems.reduce((s, i) => s + i.price * i.qty, 0);
88
124
 
89
125
  // ONE call to /customer/orders is enough — the server creates the
90
126
  // EpicMerch order AND the Razorpay order in a single transaction and
91
127
  // returns both back. Do NOT separately call store.payment.createOrder
92
- // here; doing so creates an orphan second Razorpay order AND can
93
- // trigger an idempotency-key collision against /customer/orders.
128
+ // or store.payment.getConfig here; doing so creates an orphan second
129
+ // Razorpay order AND can trigger an idempotency-key collision against
130
+ // /customer/orders.
94
131
  const orderResult = await store.orders.create({
95
- orderItems: cart.items.map(i => ({ productId: i.productId, qty: i.qty, price: i.price })),
132
+ orderItems,
96
133
  shippingAddress: address,
97
134
  paymentMethod: 'Razorpay',
98
135
  totalPrice: total,
@@ -29,13 +29,46 @@ 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
+ // 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);
40
+
41
+ // Products on sale return both `price` (original) and `salePrice` (current).
42
+ // Always display the current price the customer will actually pay.
43
+ const price = product.salePrice ?? product.price;
44
+ const image = product.images?.[0] || product.image;
45
+ const needsSize = availableSizes.length > 0;
46
+
33
47
  return (
34
48
  <div className="product-card">
35
- <img src={product.images?.[0]} alt={product.name} />
49
+ <img src={image} alt={product.name} />
36
50
  <h3>{product.name}</h3>
37
- <p>₹{product.price}</p>
38
- <button onClick={() => onAddToCart(product._id)}>Add to Cart</button>
51
+ <p>₹{price}</p>
52
+ {needsSize && (
53
+ <div className="size-picker">
54
+ {availableSizes.map(v => (
55
+ <button
56
+ key={v.size}
57
+ type="button"
58
+ onClick={() => setSize(v.size)}
59
+ className={size === v.size ? 'selected' : ''}
60
+ >
61
+ {v.size}
62
+ </button>
63
+ ))}
64
+ </div>
65
+ )}
66
+ <button
67
+ onClick={() => onAddToCart(product._id, size)}
68
+ disabled={needsSize && !size}
69
+ >
70
+ Add to Cart
71
+ </button>
39
72
  </div>
40
73
  );
41
74
  }
@@ -66,7 +99,10 @@ export default function ProductList() {
66
99
  }
67
100
  }, [query, category]);
68
101
 
69
- const addToCart = (productId) => store.cart.add(productId, 1);
102
+ // `variant` is the size STRING (e.g. 'L'), per the SDK signature
103
+ // store.cart.add(productId, qty, variant). Products without variants
104
+ // pass undefined and the SDK accepts that.
105
+ const addToCart = (productId, variant) => store.cart.add(productId, 1, variant);
70
106
 
71
107
  return (
72
108
  <div>
@@ -118,13 +118,46 @@ For each of the two files in this step, check existence individually. If a file
118
118
  Write `src/components/ProductCard.jsx`:
119
119
 
120
120
  ```jsx
121
+ import { useState } from 'react';
122
+
121
123
  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);
129
+
130
+ // Products on sale return both `price` (original) and `salePrice` (current).
131
+ // Always display the current price the customer will actually pay.
132
+ const price = product.salePrice ?? product.price;
133
+ const image = product.images?.[0] || product.image;
134
+ const needsSize = availableSizes.length > 0;
135
+
122
136
  return (
123
137
  <div className="product-card">
124
- <img src={product.images?.[0]} alt={product.name} />
138
+ <img src={image} alt={product.name} />
125
139
  <h3>{product.name}</h3>
126
- <p>₹{product.price}</p>
127
- <button onClick={() => onAddToCart(product._id)}>Add to Cart</button>
140
+ <p>₹{price}</p>
141
+ {needsSize && (
142
+ <div className="size-picker">
143
+ {availableSizes.map(v => (
144
+ <button
145
+ key={v.size}
146
+ type="button"
147
+ onClick={() => setSize(v.size)}
148
+ className={size === v.size ? 'selected' : ''}
149
+ >
150
+ {v.size}
151
+ </button>
152
+ ))}
153
+ </div>
154
+ )}
155
+ <button
156
+ onClick={() => onAddToCart(product._id, size)}
157
+ disabled={needsSize && !size}
158
+ >
159
+ Add to Cart
160
+ </button>
128
161
  </div>
129
162
  );
130
163
  }
@@ -153,7 +186,10 @@ export default function ProductList() {
153
186
  }
154
187
  }, [query, category]);
155
188
 
156
- const addToCart = (productId) => store.cart.add(productId, 1);
189
+ // `variant` is the size STRING (e.g. 'L'), per the SDK signature
190
+ // store.cart.add(productId, qty, variant). Products without variants
191
+ // pass undefined and the SDK accepts that.
192
+ const addToCart = (productId, variant) => store.cart.add(productId, 1, variant);
157
193
 
158
194
  return (
159
195
  <div>
@@ -216,26 +252,46 @@ import { useEffect, useState } from 'react';
216
252
  import { store } from '../lib/epicmerch';
217
253
 
218
254
  export default function Cart({ onCheckout }) {
219
- const [cart, setCart] = useState({ items: [] });
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 }.
258
+ const [cart, setCart] = useState({ cart: [] });
220
259
 
221
260
  useEffect(() => { store.cart.get().then(setCart); }, []);
222
261
 
223
- const remove = async (productId) => {
224
- await store.cart.remove(productId);
225
- store.cart.get().then(setCart);
262
+ const remove = async (productId, variant) => {
263
+ await store.cart.remove(productId, variant);
264
+ // Optimistic local update — match on BOTH productId AND variant so we
265
+ // don't accidentally remove a different size of the same product.
266
+ setCart(c => ({
267
+ ...c,
268
+ cart: (c.cart || []).filter(
269
+ i => !(i.product._id === productId && i.variant === variant),
270
+ ),
271
+ }));
226
272
  };
227
273
 
228
- const total = cart.items?.reduce((s, i) => s + i.price * i.qty, 0) ?? 0;
274
+ const items = cart.cart || [];
275
+ const total = items.reduce((sum, i) => {
276
+ const price = i.product.salePrice ?? i.product.price;
277
+ return sum + price * i.qty;
278
+ }, 0);
229
279
 
230
280
  return (
231
281
  <div>
232
- {cart.items?.map(item => (
233
- <div key={item.productId}>
234
- <span>{item.name} x{item.qty}</span>
235
- <span>₹{item.price * item.qty}</span>
236
- <button onClick={() => remove(item.productId)}>Remove</button>
237
- </div>
238
- ))}
282
+ {items.map((item, idx) => {
283
+ const price = item.product.salePrice ?? item.product.price;
284
+ return (
285
+ <div key={`${item.product._id}-${item.variant || 'novariant'}-${idx}`}>
286
+ <span>
287
+ {item.product.name} x{item.qty}
288
+ {item.variant ? ` (${item.variant})` : ''}
289
+ </span>
290
+ <span>₹{price * item.qty}</span>
291
+ <button onClick={() => remove(item.product._id, item.variant)}>Remove</button>
292
+ </div>
293
+ );
294
+ })}
239
295
  <p>Total: ₹{total}</p>
240
296
  <button onClick={onCheckout}>Checkout</button>
241
297
  </div>
@@ -249,6 +305,8 @@ Write `src/components/Checkout.jsx`:
249
305
  import { useState } from 'react';
250
306
  import { store } from '../lib/epicmerch';
251
307
 
308
+ // Razorpay's checkout.js is heavy — load it lazily on the first Place Order
309
+ // click instead of blocking initial page render.
252
310
  const loadRazorpay = () =>
253
311
  new Promise((resolve) => {
254
312
  if (window.Razorpay) return resolve(true);
@@ -264,23 +322,49 @@ export default function Checkout({ cart, onSuccess }) {
264
322
 
265
323
  const placeOrder = async () => {
266
324
  await loadRazorpay();
267
- const total = cart.items.reduce((s, i) => s + i.price * i.qty, 0);
268
- const { orderId } = await store.orders.create({
269
- orderItems: cart.items.map(i => ({ productId: i.productId, qty: i.qty, price: i.price })),
325
+
326
+ // 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
329
+ // - `name`, `image` — required, the server doesn't re-fetch them
330
+ // - `variant` — the size STRING, must match what was added to cart
331
+ // or the variant lookup returns undefined and you'll
332
+ // see "INSUFFICIENT_STOCK: undefined: need 1, have 0"
333
+ const items = cart.cart || [];
334
+ 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,
341
+ }));
342
+ const total = orderItems.reduce((s, i) => s + i.price * i.qty, 0);
343
+
344
+ // ONE call to /customer/orders is enough — the server creates the
345
+ // EpicMerch order AND the Razorpay order in a single transaction and
346
+ // returns both back. Do NOT separately call store.payment.getConfig()
347
+ // or store.payment.createOrder() — orderResult already includes
348
+ // razorpayKeyId, razorpayOrderId, amount, currency, merchantName.
349
+ // Calling them again creates an orphan second Razorpay order AND can
350
+ // trigger an idempotency-key collision against /customer/orders.
351
+ const orderResult = await store.orders.create({
352
+ orderItems,
270
353
  shippingAddress: address,
271
354
  paymentMethod: 'Razorpay',
272
355
  totalPrice: total,
273
356
  });
274
- const config = await store.payment.getConfig();
275
- const { razorpayOrderId } = await store.payment.createOrder(total * 100, orderId);
357
+
276
358
  const rzp = new window.Razorpay({
277
- key: config.razorpayKeyId,
278
- order_id: razorpayOrderId,
279
- amount: total * 100,
359
+ key: orderResult.razorpayKeyId,
360
+ order_id: orderResult.razorpayOrderId,
361
+ amount: orderResult.amount,
362
+ currency: orderResult.currency,
363
+ name: orderResult.merchantName,
280
364
  handler: async (response) => {
281
- await store.payment.verify({ ...response, orderId });
365
+ await store.payment.verify({ ...response, orderId: orderResult.orderId });
282
366
  await store.cart.clear();
283
- onSuccess(orderId);
367
+ onSuccess(orderResult.orderId);
284
368
  },
285
369
  });
286
370
  rzp.open();
@@ -297,6 +381,8 @@ export default function Checkout({ cart, onSuccess }) {
297
381
  }
298
382
  ```
299
383
 
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)`.
385
+
300
386
  Do NOT add any Razorpay script tag to `index.html` — the script loads dynamically only when the user clicks Place Order.
301
387
 
302
388
  Write `src/components/OrderHistory.jsx`:
package/src/index.js CHANGED
@@ -36,7 +36,15 @@ async function main() {
36
36
  }
37
37
 
38
38
  if (cmd === 'setup') {
39
- const code = await setup({ storeName });
39
+ const flagSet = new Set(argv);
40
+ const code = await setup({
41
+ storeName,
42
+ // --force / --reset: re-login even if a valid token exists AND
43
+ // overwrite any existing MCP config + skill files. Use this for
44
+ // a clean from-scratch reinstall (e.g. when testing a fresh
45
+ // merchant onboarding flow).
46
+ force: flagSet.has('--force') || flagSet.has('--reset'),
47
+ });
40
48
  process.exit(code);
41
49
  return;
42
50
  }
package/src/install.js CHANGED
@@ -344,7 +344,10 @@ export async function install(opts = {}) {
344
344
  * (MCP config + slash commands). The merchant-friendly path; no
345
345
  * filesystem knowledge required.
346
346
  *
347
- * @param {{ storeName?: string, force?: boolean }} opts
347
+ * @param {{ storeName?: string, force?: boolean }} opts - `force` re-runs
348
+ * the browser login even if a valid token already exists, AND overwrites
349
+ * any existing MCP config + skill files. Useful for from-scratch
350
+ * reinstalls or testing the onboarding flow as a new merchant.
348
351
  * @returns {Promise<number>} exit code
349
352
  */
350
353
  export async function setup(opts = {}) {
@@ -352,20 +355,32 @@ export async function setup(opts = {}) {
352
355
  console.error('━━━ EpicMerch setup ━━━');
353
356
  console.error('');
354
357
 
355
- // --- Step 1: Login (skipped if a valid token already exists) ---
358
+ // --- Step 1: Login (skipped if a valid token already exists, UNLESS --force) ---
356
359
  // ESM dynamic import of an ESM-mocked token-store needs the same module specifier
357
360
  // as the test mocks; the helper above uses createRequire under the hood when needed.
358
- const skipLogin = await import('./token-store.js').then(async (m) => {
361
+ const tokenStore = await import('./token-store.js');
362
+ const hasValidToken = (() => {
359
363
  try {
360
- const data = m.readTokenStore();
364
+ const data = tokenStore.readTokenStore();
361
365
  const entry = data?.stores?.[data?.defaultStore];
362
366
  return entry && new Date(entry.refreshTokenExpiresAt) > new Date();
363
367
  } catch (_) { return false; }
364
- });
368
+ })();
369
+ const skipLogin = hasValidToken && !opts.force;
365
370
 
366
371
  if (skipLogin) {
367
372
  console.error('Step 1/2: ✓ Already authenticated (skipping browser login).');
373
+ console.error(' Pass --force to re-login.');
368
374
  } else {
375
+ if (hasValidToken && opts.force) {
376
+ // --force on an authenticated session: revoke + remove the stored
377
+ // token first so the next login is genuinely fresh (and the server
378
+ // sees a new device row, not a renewed one).
379
+ try {
380
+ const { logout } = await import('./logout.js');
381
+ await logout({ storeName });
382
+ } catch (_) { /* don't block setup if revoke fails */ }
383
+ }
369
384
  console.error('Step 1/2: Authenticating via browser…');
370
385
  console.error('');
371
386
  try {
@@ -1,285 +1,372 @@
1
- // src/tools/scaffold.js
2
- const TEMPLATES = {
3
- react: {
4
- auth: [
5
- {
6
- path: 'src/lib/epicmerch.js',
7
- content: `import EpicMerch from 'epicmerch';
8
-
9
- export const store = new EpicMerch({
10
- apiKey: import.meta.env.VITE_API_KEY,
11
- ...(import.meta.env.VITE_API_URL && { baseUrl: import.meta.env.VITE_API_URL }),
12
- });
13
- `,
14
- },
15
- {
16
- path: 'src/components/Login.jsx',
17
- content: `import { useState } from 'react';
18
- import { store } from '../lib/epicmerch';
19
-
20
- export default function Login({ onLogin }) {
21
- const [phone, setPhone] = useState('');
22
- const [otp, setOtp] = useState('');
23
- const [step, setStep] = useState('phone');
24
-
25
- const sendOtp = async () => {
26
- await store.auth.sendOtp(phone, 'phone');
27
- setStep('otp');
28
- };
29
-
30
- const verify = async () => {
31
- const result = await store.auth.verifyOtp(phone, otp, {});
32
- if (result.token) {
33
- store.setCustomerToken(result.token);
34
- localStorage.setItem('customerInfo', JSON.stringify(result));
35
- onLogin(result);
36
- }
37
- };
38
-
39
- return (
40
- <div>
41
- {step === 'phone' ? (
42
- <>
43
- <input value={phone} onChange={e => setPhone(e.target.value)} placeholder="+919876543210" />
44
- <button onClick={sendOtp}>Send OTP</button>
45
- </>
46
- ) : (
47
- <>
48
- <input value={otp} onChange={e => setOtp(e.target.value)} placeholder="Enter OTP" />
49
- <button onClick={verify}>Verify</button>
50
- </>
51
- )}
52
- </div>
53
- );
54
- }
55
- `,
56
- },
57
- ],
58
-
59
- products: [
60
- {
61
- path: 'src/components/ProductCard.jsx',
62
- content: `export default function ProductCard({ product, onAddToCart }) {
63
- return (
64
- <div className="product-card">
65
- <img src={product.images?.[0]} alt={product.name} />
66
- <h3>{product.name}</h3>
67
- <p>₹{product.price}</p>
68
- <button onClick={() => onAddToCart(product._id)}>Add to Cart</button>
69
- </div>
70
- );
71
- }
72
- `,
73
- },
74
- {
75
- path: 'src/components/ProductList.jsx',
76
- content: `import { useEffect, useState } from 'react';
77
- import { store } from '../lib/epicmerch';
78
- import ProductCard from './ProductCard';
79
-
80
- export default function ProductList() {
81
- const [products, setProducts] = useState([]);
82
- const [category, setCategory] = useState(undefined);
83
- const [categories, setCategories] = useState([]);
84
-
85
- useEffect(() => {
86
- store.categories.list().then(setCategories);
87
- }, []);
88
-
89
- useEffect(() => {
90
- store.products.list({ type: category, limit: 12 }).then(d => setProducts(d.products));
91
- }, [category]);
92
-
93
- const addToCart = async (productId) => {
94
- await store.cart.add(productId, 1);
95
- };
96
-
97
- return (
98
- <div>
99
- <div>
100
- {categories.map(c => (
101
- <button key={c} onClick={() => setCategory(c === 'All' ? undefined : c)}>{c}</button>
102
- ))}
103
- </div>
104
- <div className="product-grid">
105
- {products.map(p => <ProductCard key={p._id} product={p} onAddToCart={addToCart} />)}
106
- </div>
107
- </div>
108
- );
109
- }
110
- `,
111
- },
112
- ],
113
-
114
- orders: [
115
- {
116
- path: 'src/components/Cart.jsx',
117
- content: `import { useEffect, useState } from 'react';
118
- import { store } from '../lib/epicmerch';
119
-
120
- export default function Cart({ onCheckout }) {
121
- const [cart, setCart] = useState({ items: [] });
122
-
123
- useEffect(() => { store.cart.get().then(setCart); }, []);
124
-
125
- const remove = async (productId) => {
126
- await store.cart.remove(productId);
127
- // Optimistic local update no need to re-fetch the whole cart from the
128
- // server. We already know which item was removed.
129
- setCart(c => ({ ...c, items: (c.items || []).filter(i => i.productId !== productId) }));
130
- };
131
-
132
- const total = cart.items?.reduce((sum, i) => sum + i.price * i.qty, 0) ?? 0;
133
-
134
- return (
135
- <div>
136
- {cart.items?.map(item => (
137
- <div key={item.productId}>
138
- <span>{item.name} x{item.qty}</span>
139
- <span>₹{item.price * item.qty}</span>
140
- <button onClick={() => remove(item.productId)}>Remove</button>
141
- </div>
142
- ))}
143
- <p>Total: ₹{total}</p>
144
- <button onClick={onCheckout}>Checkout</button>
145
- </div>
146
- );
147
- }
148
- `,
149
- },
150
- {
151
- path: 'src/components/Checkout.jsx',
152
- content: `import { useState } from 'react';
153
- import { store } from '../lib/epicmerch';
154
-
155
- export default function Checkout({ cart, onSuccess }) {
156
- const [address, setAddress] = useState({ street: '', city: '', postalCode: '', country: 'India' });
157
-
158
- const placeOrder = async () => {
159
- const total = cart.items.reduce((s, i) => s + i.price * i.qty, 0);
160
-
161
- // ONE call to /customer/orders. The server creates the EpicMerch order
162
- // AND the Razorpay order in a single transaction and returns both in
163
- // the response. A separate payment.createOrder() call would create an
164
- // orphan second Razorpay order AND trigger an idempotency collision
165
- // against this same response cache.
166
- const orderResult = await store.orders.create({
167
- orderItems: cart.items.map(i => ({ productId: i.productId, qty: i.qty, price: i.price })),
168
- shippingAddress: address,
169
- paymentMethod: 'Razorpay',
170
- totalPrice: total,
171
- });
172
-
173
- const rzp = new window.Razorpay({
174
- key: orderResult.razorpayKeyId,
175
- order_id: orderResult.razorpayOrderId,
176
- amount: orderResult.amount,
177
- currency: orderResult.currency,
178
- name: orderResult.merchantName,
179
- handler: async (response) => {
180
- await store.payment.verify({ ...response, orderId: orderResult.orderId });
181
- await store.cart.clear();
182
- onSuccess(orderResult.orderId);
183
- },
184
- });
185
- rzp.open();
186
- };
187
-
188
- return (
189
- <div>
190
- <input placeholder="Street" onChange={e => setAddress(a => ({ ...a, street: e.target.value }))} />
191
- <input placeholder="City" onChange={e => setAddress(a => ({ ...a, city: e.target.value }))} />
192
- <input placeholder="Postal Code" onChange={e => setAddress(a => ({ ...a, postalCode: e.target.value }))} />
193
- <button onClick={placeOrder}>Place Order</button>
194
- </div>
195
- );
196
- }
197
- `,
198
- },
199
- {
200
- path: 'src/components/OrderHistory.jsx',
201
- content: `import { useEffect, useState } from 'react';
202
- import { store } from '../lib/epicmerch';
203
-
204
- export default function OrderHistory() {
205
- const [orders, setOrders] = useState([]);
206
-
207
- useEffect(() => { store.orders.list().then(setOrders); }, []);
208
-
209
- return (
210
- <div>
211
- {orders.map(order => (
212
- <div key={order._id}>
213
- <span>Order #{order._id.slice(-6)}</span>
214
- <span>{order.status}</span>
215
- <span>₹{order.totalPrice}</span>
216
- </div>
217
- ))}
218
- </div>
219
- );
220
- }
221
- `,
222
- },
223
- ],
224
-
225
- envExample: {
226
- path: '.env.example',
227
- content: `VITE_API_KEY=your_epicmerch_api_key_here\n# VITE_API_URL=http://localhost:5001/api\n`,
228
- },
229
- },
230
- };
231
-
232
- function getFrameworkTemplates(framework) {
233
- if (!TEMPLATES[framework]) {
234
- throw new Error(`Framework "${framework}" not supported. Supported: ${Object.keys(TEMPLATES).join(', ')}`);
235
- }
236
- return TEMPLATES[framework];
237
- }
238
-
239
- export function scaffoldTools() {
240
- return {
241
- async store_scaffold_auth({ framework = 'react' } = {}) {
242
- try {
243
- const t = getFrameworkTemplates(framework);
244
- return { content: [{ type: 'text', text: JSON.stringify(t.auth, null, 2) }] };
245
- } catch (err) {
246
- return { isError: true, content: [{ type: 'text', text: err.message }] };
247
- }
248
- },
249
-
250
- async store_scaffold_products({ framework = 'react' } = {}) {
251
- try {
252
- const t = getFrameworkTemplates(framework);
253
- return { content: [{ type: 'text', text: JSON.stringify(t.products, null, 2) }] };
254
- } catch (err) {
255
- return { isError: true, content: [{ type: 'text', text: err.message }] };
256
- }
257
- },
258
-
259
- async store_scaffold_orders({ framework = 'react' } = {}) {
260
- try {
261
- const t = getFrameworkTemplates(framework);
262
- return { content: [{ type: 'text', text: JSON.stringify(t.orders, null, 2) }] };
263
- } catch (err) {
264
- return { isError: true, content: [{ type: 'text', text: err.message }] };
265
- }
266
- },
267
-
268
- async store_scaffold_full({ framework = 'react' } = {}) {
269
- try {
270
- const t = getFrameworkTemplates(framework);
271
- const files = [...t.auth, ...t.products, ...t.orders, t.envExample];
272
- return { content: [{ type: 'text', text: JSON.stringify(files, null, 2) }] };
273
- } catch (err) {
274
- return { isError: true, content: [{ type: 'text', text: err.message }] };
275
- }
276
- },
277
- };
278
- }
279
-
280
- export const scaffoldToolDefs = [
281
- { name: 'store_scaffold_auth', description: 'Generate auth integration files (SDK init + Login component) for a framework. Returns [{path, content}] — write these files to the project.', schema: { framework: { type: 'string', description: 'react (default)' } } },
282
- { name: 'store_scaffold_products', description: 'Generate product catalog components (ProductList, ProductCard) for a framework.', schema: { framework: { type: 'string' } } },
283
- { name: 'store_scaffold_orders', description: 'Generate cart + order management components (Cart, Checkout, OrderHistory) for a framework.', schema: { framework: { type: 'string' } } },
284
- { name: 'store_scaffold_full', description: 'Generate a complete EpicMerch-integrated storefront scaffold (auth + products + orders + .env.example).', schema: { framework: { type: 'string' } } },
285
- ];
1
+ // src/tools/scaffold.js
2
+ const TEMPLATES = {
3
+ react: {
4
+ auth: [
5
+ {
6
+ path: 'src/lib/epicmerch.js',
7
+ content: `import EpicMerch from 'epicmerch';
8
+
9
+ export const store = new EpicMerch({
10
+ apiKey: import.meta.env.VITE_API_KEY,
11
+ ...(import.meta.env.VITE_API_URL && { baseUrl: import.meta.env.VITE_API_URL }),
12
+ });
13
+ `,
14
+ },
15
+ {
16
+ path: 'src/components/Login.jsx',
17
+ content: `import { useState } from 'react';
18
+ import { store } from '../lib/epicmerch';
19
+
20
+ export default function Login({ onLogin }) {
21
+ const [phone, setPhone] = useState('');
22
+ const [otp, setOtp] = useState('');
23
+ const [step, setStep] = useState('phone');
24
+
25
+ const sendOtp = async () => {
26
+ await store.auth.sendOtp(phone, 'phone');
27
+ setStep('otp');
28
+ };
29
+
30
+ const verify = async () => {
31
+ const result = await store.auth.verifyOtp(phone, otp, {});
32
+ if (result.token) {
33
+ store.setCustomerToken(result.token);
34
+ localStorage.setItem('customerInfo', JSON.stringify(result));
35
+ onLogin(result);
36
+ }
37
+ };
38
+
39
+ return (
40
+ <div>
41
+ {step === 'phone' ? (
42
+ <>
43
+ <input value={phone} onChange={e => setPhone(e.target.value)} placeholder="+919876543210" />
44
+ <button onClick={sendOtp}>Send OTP</button>
45
+ </>
46
+ ) : (
47
+ <>
48
+ <input value={otp} onChange={e => setOtp(e.target.value)} placeholder="Enter OTP" />
49
+ <button onClick={verify}>Verify</button>
50
+ </>
51
+ )}
52
+ </div>
53
+ );
54
+ }
55
+ `,
56
+ },
57
+ ],
58
+
59
+ products: [
60
+ {
61
+ path: 'src/components/ProductCard.jsx',
62
+ content: `import { useState } from 'react';
63
+
64
+ export default function ProductCard({ product, onAddToCart }) {
65
+ // Variants are stock-tracked per-size. Only show sizes that are in stock —
66
+ // the API will reject orders for out-of-stock variants with
67
+ // "INSUFFICIENT_STOCK: <size>: need N, have 0".
68
+ const availableSizes = (product.variants || []).filter(v => v.stock > 0);
69
+ const [size, setSize] = useState(availableSizes[0]?.size);
70
+
71
+ // Products on sale return both \`price\` (original) and \`salePrice\` (current).
72
+ // Always display the current price the customer will actually pay.
73
+ const price = product.salePrice ?? product.price;
74
+ const image = product.images?.[0] || product.image;
75
+ const needsSize = availableSizes.length > 0;
76
+
77
+ return (
78
+ <div className="product-card">
79
+ <img src={image} alt={product.name} />
80
+ <h3>{product.name}</h3>
81
+ <p>₹{price}</p>
82
+ {needsSize && (
83
+ <div className="size-picker">
84
+ {availableSizes.map(v => (
85
+ <button
86
+ key={v.size}
87
+ type="button"
88
+ onClick={() => setSize(v.size)}
89
+ className={size === v.size ? 'selected' : ''}
90
+ >
91
+ {v.size}
92
+ </button>
93
+ ))}
94
+ </div>
95
+ )}
96
+ <button
97
+ onClick={() => onAddToCart(product._id, size)}
98
+ disabled={needsSize && !size}
99
+ >
100
+ Add to Cart
101
+ </button>
102
+ </div>
103
+ );
104
+ }
105
+ `,
106
+ },
107
+ {
108
+ path: 'src/components/ProductList.jsx',
109
+ content: `import { useEffect, useState } from 'react';
110
+ import { store } from '../lib/epicmerch';
111
+ import ProductCard from './ProductCard';
112
+
113
+ export default function ProductList() {
114
+ const [products, setProducts] = useState([]);
115
+ const [category, setCategory] = useState(undefined);
116
+ const [categories, setCategories] = useState([]);
117
+
118
+ useEffect(() => {
119
+ store.categories.list().then(setCategories);
120
+ }, []);
121
+
122
+ useEffect(() => {
123
+ store.products.list({ type: category, limit: 12 }).then(d => setProducts(d.products ?? d));
124
+ }, [category]);
125
+
126
+ // \`variant\` is the size STRING (e.g. 'L'), per the SDK signature
127
+ // store.cart.add(productId, qty, variant). Products without variants
128
+ // pass undefined and the SDK accepts that.
129
+ const addToCart = async (productId, variant) => {
130
+ await store.cart.add(productId, 1, variant);
131
+ };
132
+
133
+ return (
134
+ <div>
135
+ <div>
136
+ {categories.map(c => (
137
+ <button key={c} onClick={() => setCategory(c === 'All' ? undefined : c)}>{c}</button>
138
+ ))}
139
+ </div>
140
+ <div className="product-grid">
141
+ {products.map(p => <ProductCard key={p._id} product={p} onAddToCart={addToCart} />)}
142
+ </div>
143
+ </div>
144
+ );
145
+ }
146
+ `,
147
+ },
148
+ ],
149
+
150
+ orders: [
151
+ {
152
+ path: 'src/components/Cart.jsx',
153
+ content: `import { useEffect, useState } from 'react';
154
+ import { store } from '../lib/epicmerch';
155
+
156
+ export default function Cart({ onCheckout }) {
157
+ // store.cart.get() returns { cart, cartCount, cartTotal } — the cart line
158
+ // items are on \`cart.cart\`, NOT \`cart.items\`. Each line item is shaped
159
+ // { product: { _id, name, price, salePrice, images, ... }, qty, variant }.
160
+ const [cart, setCart] = useState({ cart: [] });
161
+
162
+ useEffect(() => { store.cart.get().then(setCart); }, []);
163
+
164
+ const remove = async (productId, variant) => {
165
+ await store.cart.remove(productId, variant);
166
+ // Optimistic local update — match on BOTH productId and variant so we
167
+ // don't accidentally remove a different size of the same product.
168
+ setCart(c => ({
169
+ ...c,
170
+ cart: (c.cart || []).filter(
171
+ i => !(i.product._id === productId && i.variant === variant),
172
+ ),
173
+ }));
174
+ };
175
+
176
+ const items = cart.cart || [];
177
+ const total = items.reduce((sum, i) => {
178
+ const price = i.product.salePrice ?? i.product.price;
179
+ return sum + price * i.qty;
180
+ }, 0);
181
+
182
+ return (
183
+ <div>
184
+ {items.map((item, idx) => {
185
+ const price = item.product.salePrice ?? item.product.price;
186
+ return (
187
+ <div key={\`\${item.product._id}-\${item.variant || 'novariant'}-\${idx}\`}>
188
+ <span>
189
+ {item.product.name} x{item.qty}
190
+ {item.variant ? \` (\${item.variant})\` : ''}
191
+ </span>
192
+ <span>₹{price * item.qty}</span>
193
+ <button onClick={() => remove(item.product._id, item.variant)}>Remove</button>
194
+ </div>
195
+ );
196
+ })}
197
+ <p>Total: ₹{total}</p>
198
+ <button onClick={onCheckout}>Checkout</button>
199
+ </div>
200
+ );
201
+ }
202
+ `,
203
+ },
204
+ {
205
+ path: 'src/components/Checkout.jsx',
206
+ content: `import { useState } from 'react';
207
+ import { store } from '../lib/epicmerch';
208
+
209
+ // Razorpay's checkout.js is heavy — load it lazily on the first Place Order
210
+ // click instead of blocking initial page render. window.Razorpay sticks
211
+ // around after first load, so subsequent clicks skip the network round-trip.
212
+ const loadRazorpay = () =>
213
+ new Promise((resolve) => {
214
+ if (window.Razorpay) return resolve(true);
215
+ const script = document.createElement('script');
216
+ script.src = 'https://checkout.razorpay.com/v1/checkout.js';
217
+ script.onload = () => resolve(true);
218
+ script.onerror = () => resolve(false);
219
+ document.body.appendChild(script);
220
+ });
221
+
222
+ export default function Checkout({ cart, onSuccess }) {
223
+ const [address, setAddress] = useState({ street: '', city: '', postalCode: '', country: 'India' });
224
+
225
+ const placeOrder = async () => {
226
+ await loadRazorpay();
227
+
228
+ // Map cart line items to the orderItems shape the API expects.
229
+ // Critical fields per the SDK README:
230
+ // - \`product\` (NOT \`productId\`) — the product _id
231
+ // - \`name\`, \`image\` — required, the server doesn't re-fetch them
232
+ // - \`variant\` — the size string, must match what was added to cart
233
+ // or the variant lookup returns undefined and you'll
234
+ // see "INSUFFICIENT_STOCK: undefined: need 1, have 0"
235
+ const items = cart.cart || [];
236
+ const orderItems = items.map(i => ({
237
+ product: i.product._id,
238
+ name: i.product.name,
239
+ image: i.product.images?.[0] || i.product.image,
240
+ qty: i.qty,
241
+ price: i.product.salePrice ?? i.product.price,
242
+ variant: i.variant,
243
+ }));
244
+ const total = orderItems.reduce((s, i) => s + i.price * i.qty, 0);
245
+
246
+ // ONE call to /customer/orders. The server creates the EpicMerch order
247
+ // AND the Razorpay order in a single transaction and returns both in
248
+ // the response. Do NOT separately call payment.getConfig() or
249
+ // payment.createOrder() — orderResult already includes razorpayKeyId,
250
+ // razorpayOrderId, amount, currency, and merchantName. Calling them
251
+ // again creates an orphan second Razorpay order AND triggers an
252
+ // idempotency-key collision against this same response cache.
253
+ const orderResult = await store.orders.create({
254
+ orderItems,
255
+ shippingAddress: address,
256
+ paymentMethod: 'Razorpay',
257
+ totalPrice: total,
258
+ });
259
+
260
+ const rzp = new window.Razorpay({
261
+ key: orderResult.razorpayKeyId,
262
+ order_id: orderResult.razorpayOrderId,
263
+ amount: orderResult.amount,
264
+ currency: orderResult.currency,
265
+ name: orderResult.merchantName,
266
+ handler: async (response) => {
267
+ await store.payment.verify({ ...response, orderId: orderResult.orderId });
268
+ await store.cart.clear();
269
+ onSuccess(orderResult.orderId);
270
+ },
271
+ });
272
+ rzp.open();
273
+ };
274
+
275
+ return (
276
+ <div>
277
+ <input placeholder="Street" onChange={e => setAddress(a => ({ ...a, street: e.target.value }))} />
278
+ <input placeholder="City" onChange={e => setAddress(a => ({ ...a, city: e.target.value }))} />
279
+ <input placeholder="Postal Code" onChange={e => setAddress(a => ({ ...a, postalCode: e.target.value }))} />
280
+ <button onClick={placeOrder}>Place Order</button>
281
+ </div>
282
+ );
283
+ }
284
+ `,
285
+ },
286
+ {
287
+ path: 'src/components/OrderHistory.jsx',
288
+ content: `import { useEffect, useState } from 'react';
289
+ import { store } from '../lib/epicmerch';
290
+
291
+ export default function OrderHistory() {
292
+ const [orders, setOrders] = useState([]);
293
+
294
+ useEffect(() => { store.orders.list().then(setOrders); }, []);
295
+
296
+ return (
297
+ <div>
298
+ {orders.map(order => (
299
+ <div key={order._id}>
300
+ <span>Order #{order._id.slice(-6)}</span>
301
+ <span>{order.status}</span>
302
+ <span>₹{order.totalPrice}</span>
303
+ </div>
304
+ ))}
305
+ </div>
306
+ );
307
+ }
308
+ `,
309
+ },
310
+ ],
311
+
312
+ envExample: {
313
+ path: '.env.example',
314
+ content: `VITE_API_KEY=your_epicmerch_api_key_here\n# VITE_API_URL=http://localhost:5001/api\n`,
315
+ },
316
+ },
317
+ };
318
+
319
+ function getFrameworkTemplates(framework) {
320
+ if (!TEMPLATES[framework]) {
321
+ throw new Error(`Framework "${framework}" not supported. Supported: ${Object.keys(TEMPLATES).join(', ')}`);
322
+ }
323
+ return TEMPLATES[framework];
324
+ }
325
+
326
+ export function scaffoldTools() {
327
+ return {
328
+ async store_scaffold_auth({ framework = 'react' } = {}) {
329
+ try {
330
+ const t = getFrameworkTemplates(framework);
331
+ return { content: [{ type: 'text', text: JSON.stringify(t.auth, null, 2) }] };
332
+ } catch (err) {
333
+ return { isError: true, content: [{ type: 'text', text: err.message }] };
334
+ }
335
+ },
336
+
337
+ async store_scaffold_products({ framework = 'react' } = {}) {
338
+ try {
339
+ const t = getFrameworkTemplates(framework);
340
+ return { content: [{ type: 'text', text: JSON.stringify(t.products, null, 2) }] };
341
+ } catch (err) {
342
+ return { isError: true, content: [{ type: 'text', text: err.message }] };
343
+ }
344
+ },
345
+
346
+ async store_scaffold_orders({ framework = 'react' } = {}) {
347
+ try {
348
+ const t = getFrameworkTemplates(framework);
349
+ return { content: [{ type: 'text', text: JSON.stringify(t.orders, null, 2) }] };
350
+ } catch (err) {
351
+ return { isError: true, content: [{ type: 'text', text: err.message }] };
352
+ }
353
+ },
354
+
355
+ async store_scaffold_full({ framework = 'react' } = {}) {
356
+ try {
357
+ const t = getFrameworkTemplates(framework);
358
+ const files = [...t.auth, ...t.products, ...t.orders, t.envExample];
359
+ return { content: [{ type: 'text', text: JSON.stringify(files, null, 2) }] };
360
+ } catch (err) {
361
+ return { isError: true, content: [{ type: 'text', text: err.message }] };
362
+ }
363
+ },
364
+ };
365
+ }
366
+
367
+ export const scaffoldToolDefs = [
368
+ { name: 'store_scaffold_auth', description: 'Generate auth integration files (SDK init + Login component) for a framework. Returns [{path, content}] — write these files to the project.', schema: { framework: { type: 'string', description: 'react (default)' } } },
369
+ { name: 'store_scaffold_products', description: 'Generate product catalog components (ProductList, ProductCard with size picker) for a framework.', schema: { framework: { type: 'string' } } },
370
+ { name: 'store_scaffold_orders', description: 'Generate cart + order management components (Cart, Checkout, OrderHistory) for a framework. Cart reads the {cart, cartCount, cartTotal} shape returned by store.cart.get(); Checkout uses the orderItems shape {product, name, image, qty, price, variant} required by the API.', schema: { framework: { type: 'string' } } },
371
+ { name: 'store_scaffold_full', description: 'Generate a complete EpicMerch-integrated storefront scaffold (auth + products + orders + .env.example).', schema: { framework: { type: 'string' } } },
372
+ ];