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 +1 -1
- package/skills/epicmerch-auth.md +38 -4
- package/skills/epicmerch-debug.md +217 -0
- package/skills/epicmerch-orders.md +60 -16
- package/skills/epicmerch-products.md +42 -4
- package/skills/epicmerch-storefront.md +170 -29
- package/skills/epicmerch-verify.md +221 -0
- package/skills/epicmerch.md +257 -85
- package/src/index.js +28 -2
- package/src/install.js +121 -6
- package/src/prompts/index.js +30 -0
- package/src/resources/examples/orders.md +90 -27
- package/src/resources/examples/payments.md +64 -23
- package/src/resources/sdk-guide.md +69 -34
- package/src/tools/merchant.js +21 -11
- package/src/tools/scaffold.js +425 -285
package/package.json
CHANGED
package/skills/epicmerch-auth.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
{
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
<
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
|
92
|
-
// trigger an idempotency-key collision against
|
|
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
|
|
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={
|
|
51
|
+
<img src={image} alt={product.name} />
|
|
36
52
|
<h3>{product.name}</h3>
|
|
37
|
-
<p>₹{
|
|
38
|
-
|
|
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
|
-
|
|
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>
|