@forgewp/woocommerce 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +28 -0
- package/src/components.tsx +1848 -0
- package/src/hooks.ts +825 -0
- package/src/index.ts +2 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,1848 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { WpPostContext } from "@forgewp/react";
|
|
3
|
+
import {
|
|
4
|
+
Heart,
|
|
5
|
+
Trash2,
|
|
6
|
+
Plus,
|
|
7
|
+
Minus,
|
|
8
|
+
Star,
|
|
9
|
+
Lock,
|
|
10
|
+
User,
|
|
11
|
+
Download,
|
|
12
|
+
SlidersHorizontal,
|
|
13
|
+
X,
|
|
14
|
+
ArrowRight
|
|
15
|
+
} from "lucide-react";
|
|
16
|
+
import {
|
|
17
|
+
WpCartContext,
|
|
18
|
+
CartItem,
|
|
19
|
+
CartState,
|
|
20
|
+
useWpCart,
|
|
21
|
+
useWpCheckout,
|
|
22
|
+
useWpCustomer,
|
|
23
|
+
useWpProductFilters,
|
|
24
|
+
useWpWishlist,
|
|
25
|
+
useWpProducts,
|
|
26
|
+
useWpProduct,
|
|
27
|
+
useWpProductCategories,
|
|
28
|
+
useWpProductTags,
|
|
29
|
+
fetchStoreApi,
|
|
30
|
+
mapStoreApiToCartState,
|
|
31
|
+
IS_DEV
|
|
32
|
+
} from "./hooks";
|
|
33
|
+
|
|
34
|
+
// ── 1. WpCartProvider ────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
export function WpCartProvider({ children }: { children: React.ReactNode }) {
|
|
37
|
+
const { products } = useWpProducts();
|
|
38
|
+
const [cart, setCart] = React.useState<CartState>({
|
|
39
|
+
items: [],
|
|
40
|
+
totals: { subtotal: "0.00", discount: "0.00", shipping: "0.00", tax: "0.00", total: "0.00" },
|
|
41
|
+
coupons: [],
|
|
42
|
+
shippingAddress: { country: "US", city: "", postcode: "" },
|
|
43
|
+
shippingMethod: "flat_rate"
|
|
44
|
+
});
|
|
45
|
+
const [isLoading, setIsLoading] = React.useState(false);
|
|
46
|
+
|
|
47
|
+
// Load from local storage or WooCommerce Store API
|
|
48
|
+
React.useEffect(() => {
|
|
49
|
+
if (typeof window !== "undefined") {
|
|
50
|
+
if (!IS_DEV) {
|
|
51
|
+
setIsLoading(true);
|
|
52
|
+
fetchStoreApi("cart")
|
|
53
|
+
.then((storeCart) => {
|
|
54
|
+
setCart(mapStoreApiToCartState(storeCart));
|
|
55
|
+
})
|
|
56
|
+
.catch((err) => {
|
|
57
|
+
console.error("Failed to fetch WooCommerce cart:", err);
|
|
58
|
+
})
|
|
59
|
+
.finally(() => {
|
|
60
|
+
setIsLoading(false);
|
|
61
|
+
});
|
|
62
|
+
} else {
|
|
63
|
+
const stored = localStorage.getItem("forgewp-cart");
|
|
64
|
+
if (stored) {
|
|
65
|
+
setCart(JSON.parse(stored));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
// Recalculate totals helper (used for dev mock fallback only)
|
|
72
|
+
const recalculateTotals = (items: CartItem[], coupons: string[], shippingMethod: string): CartState["totals"] => {
|
|
73
|
+
const subtotalNum = items.reduce((sum, item) => sum + parseFloat(item.line_subtotal), 0);
|
|
74
|
+
|
|
75
|
+
// Coupons calculation: "BRUTAL5" is 5% off, "STARK" is flat $10 off
|
|
76
|
+
let discountNum = 0;
|
|
77
|
+
coupons.forEach((code) => {
|
|
78
|
+
if (code.toUpperCase() === "BRUTAL5") {
|
|
79
|
+
discountNum += subtotalNum * 0.05;
|
|
80
|
+
} else if (code.toUpperCase() === "STARK") {
|
|
81
|
+
discountNum += 10.0;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
if (discountNum > subtotalNum) discountNum = subtotalNum;
|
|
85
|
+
|
|
86
|
+
// Shipping cost
|
|
87
|
+
let shippingNum = 10.0;
|
|
88
|
+
if (subtotalNum > 100.0 || shippingMethod === "free_shipping") {
|
|
89
|
+
shippingNum = 0.0;
|
|
90
|
+
} else if (shippingMethod === "local_pickup") {
|
|
91
|
+
shippingNum = 0.0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Tax (10% of subtotal after discounts)
|
|
95
|
+
const taxableBase = subtotalNum - discountNum;
|
|
96
|
+
const taxNum = taxableBase > 0 ? taxableBase * 0.1 : 0.0;
|
|
97
|
+
|
|
98
|
+
const totalNum = subtotalNum - discountNum + shippingNum + taxNum;
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
subtotal: subtotalNum.toFixed(2),
|
|
102
|
+
discount: discountNum.toFixed(2),
|
|
103
|
+
shipping: shippingNum.toFixed(2),
|
|
104
|
+
tax: taxNum.toFixed(2),
|
|
105
|
+
total: totalNum.toFixed(2)
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const updateCartState = (newItems: CartItem[], newCoupons: string[], newMethod: string, newAddress: any) => {
|
|
110
|
+
const newTotals = recalculateTotals(newItems, newCoupons, newMethod);
|
|
111
|
+
const updatedCart = {
|
|
112
|
+
items: newItems,
|
|
113
|
+
totals: newTotals,
|
|
114
|
+
coupons: newCoupons,
|
|
115
|
+
shippingMethod: newMethod,
|
|
116
|
+
shippingAddress: newAddress
|
|
117
|
+
};
|
|
118
|
+
setCart(updatedCart);
|
|
119
|
+
if (typeof window !== "undefined") {
|
|
120
|
+
localStorage.setItem("forgewp-cart", JSON.stringify(updatedCart));
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const addToCart = async (productId: number, quantity: number, variation?: Record<string, string>) => {
|
|
125
|
+
setIsLoading(true);
|
|
126
|
+
|
|
127
|
+
if (!IS_DEV) {
|
|
128
|
+
try {
|
|
129
|
+
const body: any = {
|
|
130
|
+
id: productId,
|
|
131
|
+
quantity
|
|
132
|
+
};
|
|
133
|
+
if (variation) {
|
|
134
|
+
body.variation = Object.entries(variation).map(([name, value]) => ({
|
|
135
|
+
attribute: name.startsWith("attribute_") ? name : `attribute_${name}`,
|
|
136
|
+
value
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const storeCart = await fetchStoreApi("cart/add-item", {
|
|
141
|
+
method: "POST",
|
|
142
|
+
body: JSON.stringify(body)
|
|
143
|
+
});
|
|
144
|
+
setCart(mapStoreApiToCartState(storeCart));
|
|
145
|
+
} catch (err) {
|
|
146
|
+
console.error("Failed to add item to WooCommerce cart:", err);
|
|
147
|
+
} finally {
|
|
148
|
+
setIsLoading(false);
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
154
|
+
|
|
155
|
+
const product = products.find((p) => p.id === productId);
|
|
156
|
+
if (!product) {
|
|
157
|
+
setIsLoading(false);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let itemPrice = parseFloat(product.price || "0");
|
|
162
|
+
let variationId = "";
|
|
163
|
+
let itemTitle = product.title;
|
|
164
|
+
|
|
165
|
+
if (product.type === "variable" && variation && product.variations) {
|
|
166
|
+
// Find matching variation
|
|
167
|
+
const match = product.variations.find((v) => {
|
|
168
|
+
return Object.entries(variation).every(([attr, val]) => v.attributes[attr] === val);
|
|
169
|
+
});
|
|
170
|
+
if (match) {
|
|
171
|
+
itemPrice = parseFloat(match.price);
|
|
172
|
+
variationId = String(match.id);
|
|
173
|
+
const desc = Object.entries(variation).map(([k, v]) => `${k}: ${v}`).join(", ");
|
|
174
|
+
itemTitle = `${product.title} (${desc})`;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const lineKey = variationId ? `${productId}_${variationId}` : `${productId}`;
|
|
179
|
+
const existingIndex = cart.items.findIndex((item) => item.key === lineKey);
|
|
180
|
+
|
|
181
|
+
const newItems = [...cart.items];
|
|
182
|
+
if (existingIndex > -1) {
|
|
183
|
+
const existing = newItems[existingIndex];
|
|
184
|
+
const newQty = existing.quantity + quantity;
|
|
185
|
+
newItems[existingIndex] = {
|
|
186
|
+
...existing,
|
|
187
|
+
quantity: newQty,
|
|
188
|
+
line_subtotal: (newQty * parseFloat(existing.price)).toFixed(2)
|
|
189
|
+
};
|
|
190
|
+
} else {
|
|
191
|
+
newItems.push({
|
|
192
|
+
key: lineKey,
|
|
193
|
+
id: productId,
|
|
194
|
+
quantity,
|
|
195
|
+
variation,
|
|
196
|
+
title: itemTitle,
|
|
197
|
+
price: itemPrice.toFixed(2),
|
|
198
|
+
featuredImage: product.featuredImage || "https://picsum.photos/seed/placeholder/300/300",
|
|
199
|
+
line_subtotal: (quantity * itemPrice).toFixed(2)
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
updateCartState(newItems, cart.coupons, cart.shippingMethod, cart.shippingAddress);
|
|
204
|
+
setIsLoading(false);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const addToCartBatch = async (batchItems: Array<{ productId: number; quantity: number; variation?: Record<string, string> }>) => {
|
|
208
|
+
setIsLoading(true);
|
|
209
|
+
|
|
210
|
+
if (!IS_DEV) {
|
|
211
|
+
try {
|
|
212
|
+
let lastCart = null;
|
|
213
|
+
for (const item of batchItems) {
|
|
214
|
+
const body: any = {
|
|
215
|
+
id: item.productId,
|
|
216
|
+
quantity: item.quantity
|
|
217
|
+
};
|
|
218
|
+
if (item.variation) {
|
|
219
|
+
body.variation = Object.entries(item.variation).map(([name, value]) => ({
|
|
220
|
+
attribute: name.startsWith("attribute_") ? name : `attribute_${name}`,
|
|
221
|
+
value
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
224
|
+
lastCart = await fetchStoreApi("cart/add-item", {
|
|
225
|
+
method: "POST",
|
|
226
|
+
body: JSON.stringify(body)
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
if (lastCart) {
|
|
230
|
+
setCart(mapStoreApiToCartState(lastCart));
|
|
231
|
+
}
|
|
232
|
+
} catch (err) {
|
|
233
|
+
console.error("Failed to add batch to WooCommerce cart:", err);
|
|
234
|
+
} finally {
|
|
235
|
+
setIsLoading(false);
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
241
|
+
|
|
242
|
+
const newItems = [...cart.items];
|
|
243
|
+
|
|
244
|
+
batchItems.forEach((b) => {
|
|
245
|
+
const product = products.find((p) => p.id === b.productId);
|
|
246
|
+
if (!product) return;
|
|
247
|
+
|
|
248
|
+
let itemPrice = parseFloat(product.price || "0");
|
|
249
|
+
let variationId = "";
|
|
250
|
+
let itemTitle = product.title;
|
|
251
|
+
|
|
252
|
+
if (product.type === "variable" && b.variation && product.variations) {
|
|
253
|
+
const match = product.variations.find((v) => {
|
|
254
|
+
return Object.entries(b.variation!).every(([attr, val]) => v.attributes[attr] === val);
|
|
255
|
+
});
|
|
256
|
+
if (match) {
|
|
257
|
+
itemPrice = parseFloat(match.price);
|
|
258
|
+
variationId = String(match.id);
|
|
259
|
+
const desc = Object.entries(b.variation).map(([k, v]) => `${k}: ${v}`).join(", ");
|
|
260
|
+
itemTitle = `${product.title} (${desc})`;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const lineKey = variationId ? `${b.productId}_${variationId}` : `${b.productId}`;
|
|
265
|
+
const existingIndex = newItems.findIndex((item) => item.key === lineKey);
|
|
266
|
+
|
|
267
|
+
if (existingIndex > -1) {
|
|
268
|
+
const existing = newItems[existingIndex];
|
|
269
|
+
const newQty = existing.quantity + b.quantity;
|
|
270
|
+
newItems[existingIndex] = {
|
|
271
|
+
...existing,
|
|
272
|
+
quantity: newQty,
|
|
273
|
+
line_subtotal: (newQty * parseFloat(existing.price)).toFixed(2)
|
|
274
|
+
};
|
|
275
|
+
} else {
|
|
276
|
+
newItems.push({
|
|
277
|
+
key: lineKey,
|
|
278
|
+
id: b.productId,
|
|
279
|
+
quantity: b.quantity,
|
|
280
|
+
variation: b.variation,
|
|
281
|
+
title: itemTitle,
|
|
282
|
+
price: itemPrice.toFixed(2),
|
|
283
|
+
featuredImage: product.featuredImage || "https://picsum.photos/seed/placeholder/300/300",
|
|
284
|
+
line_subtotal: (b.quantity * itemPrice).toFixed(2)
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
updateCartState(newItems, cart.coupons, cart.shippingMethod, cart.shippingAddress);
|
|
290
|
+
setIsLoading(false);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const updateQuantity = async (key: string, qty: number) => {
|
|
294
|
+
if (qty < 1) {
|
|
295
|
+
await removeItem(key);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
setIsLoading(true);
|
|
299
|
+
|
|
300
|
+
if (!IS_DEV) {
|
|
301
|
+
try {
|
|
302
|
+
const storeCart = await fetchStoreApi("cart/update-item", {
|
|
303
|
+
method: "POST",
|
|
304
|
+
body: JSON.stringify({
|
|
305
|
+
key,
|
|
306
|
+
quantity: qty
|
|
307
|
+
})
|
|
308
|
+
});
|
|
309
|
+
setCart(mapStoreApiToCartState(storeCart));
|
|
310
|
+
} catch (err) {
|
|
311
|
+
console.error("Failed to update item quantity in WooCommerce cart:", err);
|
|
312
|
+
} finally {
|
|
313
|
+
setIsLoading(false);
|
|
314
|
+
}
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
319
|
+
|
|
320
|
+
const newItems = cart.items.map((item) => {
|
|
321
|
+
if (item.key === key) {
|
|
322
|
+
return {
|
|
323
|
+
...item,
|
|
324
|
+
quantity: qty,
|
|
325
|
+
line_subtotal: (qty * parseFloat(item.price)).toFixed(2)
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
return item;
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
updateCartState(newItems, cart.coupons, cart.shippingMethod, cart.shippingAddress);
|
|
332
|
+
setIsLoading(false);
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const removeItem = async (key: string) => {
|
|
336
|
+
setIsLoading(true);
|
|
337
|
+
|
|
338
|
+
if (!IS_DEV) {
|
|
339
|
+
try {
|
|
340
|
+
const storeCart = await fetchStoreApi("cart/remove-item", {
|
|
341
|
+
method: "POST",
|
|
342
|
+
body: JSON.stringify({
|
|
343
|
+
key
|
|
344
|
+
})
|
|
345
|
+
});
|
|
346
|
+
setCart(mapStoreApiToCartState(storeCart));
|
|
347
|
+
} catch (err) {
|
|
348
|
+
console.error("Failed to remove item from WooCommerce cart:", err);
|
|
349
|
+
} finally {
|
|
350
|
+
setIsLoading(false);
|
|
351
|
+
}
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
356
|
+
const newItems = cart.items.filter((item) => item.key !== key);
|
|
357
|
+
updateCartState(newItems, cart.coupons, cart.shippingMethod, cart.shippingAddress);
|
|
358
|
+
setIsLoading(false);
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const applyCoupon = async (code: string) => {
|
|
362
|
+
setIsLoading(true);
|
|
363
|
+
|
|
364
|
+
if (!IS_DEV) {
|
|
365
|
+
try {
|
|
366
|
+
const storeCart = await fetchStoreApi("cart/apply-coupon", {
|
|
367
|
+
method: "POST",
|
|
368
|
+
body: JSON.stringify({
|
|
369
|
+
code
|
|
370
|
+
})
|
|
371
|
+
});
|
|
372
|
+
setCart(mapStoreApiToCartState(storeCart));
|
|
373
|
+
return true;
|
|
374
|
+
} catch (err) {
|
|
375
|
+
console.error("Failed to apply coupon to WooCommerce cart:", err);
|
|
376
|
+
return false;
|
|
377
|
+
} finally {
|
|
378
|
+
setIsLoading(false);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
383
|
+
const upperCode = code.toUpperCase();
|
|
384
|
+
if (upperCode === "BRUTAL5" || upperCode === "STARK") {
|
|
385
|
+
if (!cart.coupons.includes(upperCode)) {
|
|
386
|
+
updateCartState(cart.items, [...cart.coupons, upperCode], cart.shippingMethod, cart.shippingAddress);
|
|
387
|
+
setIsLoading(false);
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
setIsLoading(false);
|
|
392
|
+
return false;
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
const removeCoupon = async (code: string) => {
|
|
396
|
+
setIsLoading(true);
|
|
397
|
+
|
|
398
|
+
if (!IS_DEV) {
|
|
399
|
+
try {
|
|
400
|
+
const storeCart = await fetchStoreApi("cart/remove-coupon", {
|
|
401
|
+
method: "POST",
|
|
402
|
+
body: JSON.stringify({
|
|
403
|
+
code
|
|
404
|
+
})
|
|
405
|
+
});
|
|
406
|
+
setCart(mapStoreApiToCartState(storeCart));
|
|
407
|
+
} catch (err) {
|
|
408
|
+
console.error("Failed to remove coupon from WooCommerce cart:", err);
|
|
409
|
+
} finally {
|
|
410
|
+
setIsLoading(false);
|
|
411
|
+
}
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
416
|
+
const newCoupons = cart.coupons.filter((c) => c !== code);
|
|
417
|
+
updateCartState(cart.items, newCoupons, cart.shippingMethod, cart.shippingAddress);
|
|
418
|
+
setIsLoading(false);
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
const calculateShipping = async (address: { country: string; city: string; postcode: string }) => {
|
|
422
|
+
setIsLoading(true);
|
|
423
|
+
|
|
424
|
+
if (!IS_DEV) {
|
|
425
|
+
try {
|
|
426
|
+
const storeCart = await fetchStoreApi("cart/update-customer", {
|
|
427
|
+
method: "POST",
|
|
428
|
+
body: JSON.stringify({
|
|
429
|
+
shipping_address: address,
|
|
430
|
+
billing_address: address
|
|
431
|
+
})
|
|
432
|
+
});
|
|
433
|
+
setCart(mapStoreApiToCartState(storeCart));
|
|
434
|
+
} catch (err) {
|
|
435
|
+
console.error("Failed to update shipping calculation:", err);
|
|
436
|
+
} finally {
|
|
437
|
+
setIsLoading(false);
|
|
438
|
+
}
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
await new Promise((resolve) => setTimeout(resolve, 700));
|
|
443
|
+
updateCartState(cart.items, cart.coupons, cart.shippingMethod, address);
|
|
444
|
+
setIsLoading(false);
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
const setShippingMethod = async (methodId: string) => {
|
|
448
|
+
if (!IS_DEV) {
|
|
449
|
+
setIsLoading(true);
|
|
450
|
+
try {
|
|
451
|
+
const storeCart = await fetchStoreApi("cart/select-shipping-rate", {
|
|
452
|
+
method: "POST",
|
|
453
|
+
body: JSON.stringify({
|
|
454
|
+
rate_id: methodId
|
|
455
|
+
})
|
|
456
|
+
});
|
|
457
|
+
setCart(mapStoreApiToCartState(storeCart));
|
|
458
|
+
} catch (err) {
|
|
459
|
+
console.error("Failed to select shipping method:", err);
|
|
460
|
+
} finally {
|
|
461
|
+
setIsLoading(false);
|
|
462
|
+
}
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
updateCartState(cart.items, cart.coupons, methodId, cart.shippingAddress);
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
const clearCart = () => {
|
|
470
|
+
if (!IS_DEV) {
|
|
471
|
+
setCart({
|
|
472
|
+
items: [],
|
|
473
|
+
totals: { subtotal: "0.00", discount: "0.00", shipping: "0.00", tax: "0.00", total: "0.00" },
|
|
474
|
+
coupons: [],
|
|
475
|
+
shippingAddress: { country: "US", city: "", postcode: "" },
|
|
476
|
+
shippingMethod: "flat_rate"
|
|
477
|
+
});
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
updateCartState([], [], "flat_rate", { country: "US", city: "", postcode: "" });
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
const itemCount = cart.items.reduce((sum, item) => sum + item.quantity, 0);
|
|
485
|
+
const cartTotal = `$${cart.totals.total}`;
|
|
486
|
+
|
|
487
|
+
return (
|
|
488
|
+
<WpCartContext.Provider
|
|
489
|
+
value={{
|
|
490
|
+
cart,
|
|
491
|
+
itemCount,
|
|
492
|
+
cartTotal,
|
|
493
|
+
addToCart,
|
|
494
|
+
addToCartBatch,
|
|
495
|
+
updateQuantity,
|
|
496
|
+
removeItem,
|
|
497
|
+
applyCoupon,
|
|
498
|
+
removeCoupon,
|
|
499
|
+
calculateShipping,
|
|
500
|
+
setShippingMethod,
|
|
501
|
+
isLoading,
|
|
502
|
+
clearCart
|
|
503
|
+
}}
|
|
504
|
+
>
|
|
505
|
+
{children}
|
|
506
|
+
</WpCartContext.Provider>
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ── 2. Catalog & Product Detail Components ──────────────────────────────────
|
|
511
|
+
|
|
512
|
+
export function WpProductGallery({ productId }: { productId: number }) {
|
|
513
|
+
const { product, loading } = useWpProduct(productId);
|
|
514
|
+
const [activeImage, setActiveImage] = React.useState("");
|
|
515
|
+
|
|
516
|
+
React.useEffect(() => {
|
|
517
|
+
if (product) {
|
|
518
|
+
setActiveImage(product.featuredImage);
|
|
519
|
+
}
|
|
520
|
+
}, [product]);
|
|
521
|
+
|
|
522
|
+
// Listen to variation selection events
|
|
523
|
+
React.useEffect(() => {
|
|
524
|
+
const handleVariation = (e: Event) => {
|
|
525
|
+
const variationDetail = (e as CustomEvent).detail;
|
|
526
|
+
if (variationDetail && variationDetail.image && variationDetail.image.url) {
|
|
527
|
+
setActiveImage(variationDetail.image.url);
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
window.addEventListener(`forgewp-variation-selected-${productId}`, handleVariation);
|
|
531
|
+
return () => {
|
|
532
|
+
window.removeEventListener(`forgewp-variation-selected-${productId}`, handleVariation);
|
|
533
|
+
};
|
|
534
|
+
}, [productId]);
|
|
535
|
+
|
|
536
|
+
if (loading) return <div className="border-4 border-black p-4 font-mono text-center">LOADING GALLERY...</div>;
|
|
537
|
+
if (!product) return <div className="border-4 border-black p-4 font-mono font-bold text-center">PRODUCT NOT FOUND</div>;
|
|
538
|
+
|
|
539
|
+
const galleryImages = product.images || [];
|
|
540
|
+
|
|
541
|
+
return (
|
|
542
|
+
<div className="space-y-4">
|
|
543
|
+
{/* Main Image Frame */}
|
|
544
|
+
<div className="border-4 border-black bg-white p-2 shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] relative aspect-square overflow-hidden flex items-center justify-center">
|
|
545
|
+
{product.on_sale && (
|
|
546
|
+
<span className="absolute top-4 left-4 bg-yellow-400 border-2 border-black px-3 py-1 font-mono font-black text-xs uppercase z-10">
|
|
547
|
+
Sale
|
|
548
|
+
</span>
|
|
549
|
+
)}
|
|
550
|
+
<img src={activeImage} alt={product.title} className="max-w-full max-h-full object-cover" />
|
|
551
|
+
</div>
|
|
552
|
+
|
|
553
|
+
{/* Thumbnails */}
|
|
554
|
+
{galleryImages.length > 0 && (
|
|
555
|
+
<div className="grid grid-cols-4 gap-2">
|
|
556
|
+
<button
|
|
557
|
+
onClick={() => setActiveImage(product.featuredImage)}
|
|
558
|
+
className={`border-2 border-black p-1 bg-white aspect-square overflow-hidden flex items-center justify-center ${
|
|
559
|
+
activeImage === product.featuredImage ? "outline outline-4 outline-black" : ""
|
|
560
|
+
}`}
|
|
561
|
+
>
|
|
562
|
+
<img src={product.featuredImage} alt="Featured" className="max-w-full max-h-full object-cover" />
|
|
563
|
+
</button>
|
|
564
|
+
{galleryImages.map((img: any, idx: number) => (
|
|
565
|
+
<button
|
|
566
|
+
key={img.id || idx}
|
|
567
|
+
onClick={() => setActiveImage(img.url)}
|
|
568
|
+
className={`border-2 border-black p-1 bg-white aspect-square overflow-hidden flex items-center justify-center ${
|
|
569
|
+
activeImage === img.url ? "outline outline-4 outline-black" : ""
|
|
570
|
+
}`}
|
|
571
|
+
>
|
|
572
|
+
<img src={img.url} alt={`Gallery ${idx}`} className="max-w-full max-h-full object-cover" />
|
|
573
|
+
</button>
|
|
574
|
+
))}
|
|
575
|
+
</div>
|
|
576
|
+
)}
|
|
577
|
+
</div>
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export function WpProductPrice({ productId }: { productId: number }) {
|
|
582
|
+
const { product, loading } = useWpProduct(productId);
|
|
583
|
+
const [selectedPriceHtml, setSelectedPriceHtml] = React.useState<string | null>(null);
|
|
584
|
+
|
|
585
|
+
React.useEffect(() => {
|
|
586
|
+
const handleVariation = (e: Event) => {
|
|
587
|
+
const variationDetail = (e as CustomEvent).detail;
|
|
588
|
+
if (variationDetail && variationDetail.price) {
|
|
589
|
+
setSelectedPriceHtml(`$${parseFloat(variationDetail.price).toFixed(2)}`);
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
window.addEventListener(`forgewp-variation-selected-${productId}`, handleVariation);
|
|
593
|
+
return () => {
|
|
594
|
+
window.removeEventListener(`forgewp-variation-selected-${productId}`, handleVariation);
|
|
595
|
+
};
|
|
596
|
+
}, [productId]);
|
|
597
|
+
|
|
598
|
+
if (loading || !product) return null;
|
|
599
|
+
|
|
600
|
+
if (selectedPriceHtml) {
|
|
601
|
+
return <div className="font-mono text-2xl font-black">{selectedPriceHtml}</div>;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (product.type === "variable" && product.variations && product.variations.length > 0) {
|
|
605
|
+
const prices = product.variations.map((v) => parseFloat(v.price));
|
|
606
|
+
const minPrice = Math.min(...prices).toFixed(2);
|
|
607
|
+
const maxPrice = Math.max(...prices).toFixed(2);
|
|
608
|
+
return (
|
|
609
|
+
<div className="font-mono text-2xl font-black">
|
|
610
|
+
${minPrice} — ${maxPrice}
|
|
611
|
+
</div>
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const hasDiscount = product.on_sale && product.regular_price && product.regular_price !== product.price;
|
|
616
|
+
|
|
617
|
+
return (
|
|
618
|
+
<div className="flex items-center gap-3 font-mono text-2xl font-black">
|
|
619
|
+
{hasDiscount && (
|
|
620
|
+
<span className="text-zinc-400 line-through text-lg">${parseFloat(product.regular_price!).toFixed(2)}</span>
|
|
621
|
+
)}
|
|
622
|
+
<span>${parseFloat(product.price).toFixed(2)}</span>
|
|
623
|
+
</div>
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
export function WpProductVariationSelector({ productId }: { productId: number }) {
|
|
628
|
+
const { product, loading } = useWpProduct(productId);
|
|
629
|
+
const [selections, setSelections] = React.useState<Record<string, string>>({});
|
|
630
|
+
|
|
631
|
+
if (loading || !product || product.type !== "variable" || !product.attributes) return null;
|
|
632
|
+
|
|
633
|
+
const handleSelect = (attrName: string, optionVal: string) => {
|
|
634
|
+
const next = { ...selections, [attrName]: optionVal };
|
|
635
|
+
setSelections(next);
|
|
636
|
+
|
|
637
|
+
// Dispatch selection change
|
|
638
|
+
if (product.variations) {
|
|
639
|
+
const matchedVariation = product.variations.find((v) => {
|
|
640
|
+
return Object.entries(next).every(([attr, val]) => v.attributes[attr] === val);
|
|
641
|
+
});
|
|
642
|
+
if (matchedVariation) {
|
|
643
|
+
window.dispatchEvent(
|
|
644
|
+
new CustomEvent(`forgewp-variation-selected-${productId}`, { detail: matchedVariation })
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
return (
|
|
651
|
+
<div className="space-y-4 font-mono">
|
|
652
|
+
{product.attributes.map((attr) => (
|
|
653
|
+
<div key={attr.name} className="space-y-2">
|
|
654
|
+
<span className="font-bold text-xs uppercase text-zinc-500">{attr.name}</span>
|
|
655
|
+
<div className="flex flex-wrap gap-2">
|
|
656
|
+
{attr.options.map((opt) => {
|
|
657
|
+
const isSelected = selections[attr.name] === opt;
|
|
658
|
+
return (
|
|
659
|
+
<button
|
|
660
|
+
key={opt}
|
|
661
|
+
onClick={() => handleSelect(attr.name, opt)}
|
|
662
|
+
className={`border-2 border-black px-4 py-2 text-xs font-black uppercase transition-all shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] ${
|
|
663
|
+
isSelected ? "bg-black text-white translate-x-[1px] translate-y-[1px] shadow-none" : "bg-white text-black hover:bg-zinc-100"
|
|
664
|
+
}`}
|
|
665
|
+
>
|
|
666
|
+
{opt}
|
|
667
|
+
</button>
|
|
668
|
+
);
|
|
669
|
+
})}
|
|
670
|
+
</div>
|
|
671
|
+
</div>
|
|
672
|
+
))}
|
|
673
|
+
</div>
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
export function WpProductStockStatus({ productId }: { productId: number }) {
|
|
678
|
+
const { product, loading } = useWpProduct(productId);
|
|
679
|
+
const [status, setStatus] = React.useState("instock");
|
|
680
|
+
|
|
681
|
+
React.useEffect(() => {
|
|
682
|
+
if (product) {
|
|
683
|
+
setStatus(product.stock_status || "instock");
|
|
684
|
+
}
|
|
685
|
+
}, [product]);
|
|
686
|
+
|
|
687
|
+
React.useEffect(() => {
|
|
688
|
+
const handleVariation = (e: Event) => {
|
|
689
|
+
const variationDetail = (e as CustomEvent).detail;
|
|
690
|
+
if (variationDetail && variationDetail.stock_status) {
|
|
691
|
+
setStatus(variationDetail.stock_status);
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
window.addEventListener(`forgewp-variation-selected-${productId}`, handleVariation);
|
|
695
|
+
return () => {
|
|
696
|
+
window.removeEventListener(`forgewp-variation-selected-${productId}`, handleVariation);
|
|
697
|
+
};
|
|
698
|
+
}, [productId]);
|
|
699
|
+
|
|
700
|
+
if (loading || !product) return null;
|
|
701
|
+
|
|
702
|
+
const isInstock = status === "instock";
|
|
703
|
+
const label = isInstock ? "In Stock" : status === "outofstock" ? "Out of Stock" : "On Backorder";
|
|
704
|
+
|
|
705
|
+
return (
|
|
706
|
+
<div className="font-mono text-xs uppercase flex items-center gap-2">
|
|
707
|
+
<span className={`w-3 h-3 border-2 border-black ${isInstock ? "bg-emerald-400" : "bg-red-500"}`} />
|
|
708
|
+
<span className="font-bold">{label}</span>
|
|
709
|
+
</div>
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
export function WpRelatedProducts({ productId, limit = 4 }: { productId: number; limit?: number }) {
|
|
714
|
+
const { product: targetProduct, loading: loadingTarget } = useWpProduct(productId);
|
|
715
|
+
const { products, loading: loadingAll } = useWpProducts();
|
|
716
|
+
|
|
717
|
+
if (loadingTarget || loadingAll || !targetProduct) return null;
|
|
718
|
+
|
|
719
|
+
const targetCategory = targetProduct._terms?.product_cat?.[0]?.slug;
|
|
720
|
+
const related = products
|
|
721
|
+
.filter((p) => p.id !== productId && p._terms?.product_cat?.some((cat) => cat.slug === targetCategory))
|
|
722
|
+
.slice(0, limit);
|
|
723
|
+
|
|
724
|
+
if (related.length === 0) return null;
|
|
725
|
+
|
|
726
|
+
return (
|
|
727
|
+
<div className="space-y-6">
|
|
728
|
+
<h2 className="font-mono font-black text-3xl uppercase tracking-wider border-b-4 border-black pb-2">
|
|
729
|
+
Related Products
|
|
730
|
+
</h2>
|
|
731
|
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
732
|
+
{related.map((p) => (
|
|
733
|
+
<div key={p.id} className="border-4 border-black p-4 bg-white shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] flex flex-col justify-between">
|
|
734
|
+
<div className="aspect-square border-2 border-black overflow-hidden flex items-center justify-center bg-zinc-50 mb-4">
|
|
735
|
+
<img src={p.featuredImage} alt={p.title} className="max-w-full max-h-full object-cover" />
|
|
736
|
+
</div>
|
|
737
|
+
<div>
|
|
738
|
+
<span className="font-mono font-bold text-xs uppercase text-zinc-400">
|
|
739
|
+
{p._terms?.product_cat?.[0]?.name}
|
|
740
|
+
</span>
|
|
741
|
+
<h3 className="font-mono font-black text-lg uppercase my-1 truncate">{p.title}</h3>
|
|
742
|
+
<div className="font-mono font-black text-md text-accent">
|
|
743
|
+
${parseFloat(p.price || "0").toFixed(2)}
|
|
744
|
+
</div>
|
|
745
|
+
</div>
|
|
746
|
+
<a
|
|
747
|
+
href={`/product/${p.sku}`}
|
|
748
|
+
className="mt-4 border-4 border-black bg-black text-white text-center font-mono font-bold py-2 uppercase hover:bg-white hover:text-black transition-all block"
|
|
749
|
+
>
|
|
750
|
+
View Item
|
|
751
|
+
</a>
|
|
752
|
+
</div>
|
|
753
|
+
))}
|
|
754
|
+
</div>
|
|
755
|
+
</div>
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
export function WpProductReviews({ productId }: { productId: number }) {
|
|
760
|
+
const { product, loading } = useWpProduct(productId);
|
|
761
|
+
const [reviews, setReviews] = React.useState<any[]>([]);
|
|
762
|
+
const [rating, setRating] = React.useState(5);
|
|
763
|
+
|
|
764
|
+
React.useEffect(() => {
|
|
765
|
+
if (product && product.reviews) {
|
|
766
|
+
setReviews(product.reviews);
|
|
767
|
+
}
|
|
768
|
+
}, [product]);
|
|
769
|
+
|
|
770
|
+
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
771
|
+
e.preventDefault();
|
|
772
|
+
const data = new FormData(e.currentTarget);
|
|
773
|
+
const author = data.get("author") as string;
|
|
774
|
+
const content = data.get("content") as string;
|
|
775
|
+
|
|
776
|
+
if (!author || !content) return;
|
|
777
|
+
|
|
778
|
+
if (!IS_DEV) {
|
|
779
|
+
try {
|
|
780
|
+
const body = new URLSearchParams();
|
|
781
|
+
body.append("comment_post_ID", String(productId));
|
|
782
|
+
body.append("author", author);
|
|
783
|
+
body.append("email", `${author.toLowerCase().replace(/\s+/g, "")}@example.com`);
|
|
784
|
+
body.append("comment", content);
|
|
785
|
+
body.append("rating", String(rating));
|
|
786
|
+
|
|
787
|
+
const response = await fetch("/wp-comments-post.php", {
|
|
788
|
+
method: "POST",
|
|
789
|
+
headers: {
|
|
790
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
791
|
+
},
|
|
792
|
+
body: body.toString()
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
if (response.ok || response.redirected) {
|
|
796
|
+
const newRev = {
|
|
797
|
+
id: Date.now(),
|
|
798
|
+
author,
|
|
799
|
+
content,
|
|
800
|
+
rating,
|
|
801
|
+
date: new Date().toISOString().split("T")[0]
|
|
802
|
+
};
|
|
803
|
+
setReviews([newRev, ...reviews]);
|
|
804
|
+
e.currentTarget.reset();
|
|
805
|
+
} else {
|
|
806
|
+
console.error("Failed to submit WooCommerce review");
|
|
807
|
+
}
|
|
808
|
+
} catch (err) {
|
|
809
|
+
console.error("Error submitting review:", err);
|
|
810
|
+
}
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const newRev = {
|
|
815
|
+
id: Date.now(),
|
|
816
|
+
author,
|
|
817
|
+
content,
|
|
818
|
+
rating,
|
|
819
|
+
date: new Date().toISOString().split("T")[0]
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
const next = [newRev, ...reviews];
|
|
823
|
+
setReviews(next);
|
|
824
|
+
e.currentTarget.reset();
|
|
825
|
+
};
|
|
826
|
+
|
|
827
|
+
if (loading || !product) return null;
|
|
828
|
+
|
|
829
|
+
return (
|
|
830
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 font-mono border-t-4 border-black pt-8">
|
|
831
|
+
{/* Review Feed */}
|
|
832
|
+
<div className="space-y-6">
|
|
833
|
+
<h3 className="font-black text-2xl uppercase border-b-4 border-black pb-2">
|
|
834
|
+
Customer Feedbacks ({reviews.length})
|
|
835
|
+
</h3>
|
|
836
|
+
{reviews.length === 0 ? (
|
|
837
|
+
<p className="text-zinc-500 italic text-sm">No reviews yet. Be the first to leave one!</p>
|
|
838
|
+
) : (
|
|
839
|
+
<div className="space-y-4">
|
|
840
|
+
{reviews.map((r) => (
|
|
841
|
+
<div key={r.id} className="border-4 border-black p-4 bg-zinc-50 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)]">
|
|
842
|
+
<div className="flex justify-between items-center mb-2">
|
|
843
|
+
<span className="font-black text-sm uppercase">{r.author}</span>
|
|
844
|
+
<span className="text-xs text-zinc-500">{r.date}</span>
|
|
845
|
+
</div>
|
|
846
|
+
<div className="flex gap-1 mb-2 text-yellow-400">
|
|
847
|
+
{Array.from({ length: 5 }).map((_, i) => (
|
|
848
|
+
<Star key={i} size={14} className={i < r.rating ? "fill-current" : "text-zinc-300"} />
|
|
849
|
+
))}
|
|
850
|
+
</div>
|
|
851
|
+
<p className="text-xs font-semibold leading-relaxed text-zinc-700">{r.content}</p>
|
|
852
|
+
</div>
|
|
853
|
+
))}
|
|
854
|
+
</div>
|
|
855
|
+
)}
|
|
856
|
+
</div>
|
|
857
|
+
|
|
858
|
+
{/* Review Form Island */}
|
|
859
|
+
<div className="border-4 border-black p-6 bg-white shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] self-start">
|
|
860
|
+
<h3 className="font-black text-xl uppercase mb-4 border-b-2 border-black pb-2">Add a Review</h3>
|
|
861
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
862
|
+
<div className="space-y-1">
|
|
863
|
+
<label className="text-xs font-bold uppercase text-zinc-500">Rating</label>
|
|
864
|
+
<div className="flex gap-2 text-yellow-400">
|
|
865
|
+
{Array.from({ length: 5 }).map((_, i) => (
|
|
866
|
+
<button
|
|
867
|
+
key={i}
|
|
868
|
+
type="button"
|
|
869
|
+
onClick={() => setRating(i + 1)}
|
|
870
|
+
className="focus:outline-none"
|
|
871
|
+
>
|
|
872
|
+
<Star size={24} className={i < rating ? "fill-current" : "text-zinc-300"} />
|
|
873
|
+
</button>
|
|
874
|
+
))}
|
|
875
|
+
</div>
|
|
876
|
+
</div>
|
|
877
|
+
<div className="space-y-1">
|
|
878
|
+
<label className="text-xs font-bold uppercase text-zinc-500">Your Name</label>
|
|
879
|
+
<input
|
|
880
|
+
name="author"
|
|
881
|
+
required
|
|
882
|
+
className="w-full border-2 border-black p-2 text-xs focus:outline-none focus:ring-0 focus:border-black"
|
|
883
|
+
/>
|
|
884
|
+
</div>
|
|
885
|
+
<div className="space-y-1">
|
|
886
|
+
<label className="text-xs font-bold uppercase text-zinc-500">Your Feedback</label>
|
|
887
|
+
<textarea
|
|
888
|
+
name="content"
|
|
889
|
+
required
|
|
890
|
+
rows={4}
|
|
891
|
+
className="w-full border-2 border-black p-2 text-xs focus:outline-none focus:ring-0 focus:border-black"
|
|
892
|
+
/>
|
|
893
|
+
</div>
|
|
894
|
+
<button
|
|
895
|
+
type="submit"
|
|
896
|
+
className="w-full border-4 border-black bg-black text-white font-bold py-3 uppercase text-xs hover:bg-white hover:text-black transition-all"
|
|
897
|
+
>
|
|
898
|
+
Submit Feedback
|
|
899
|
+
</button>
|
|
900
|
+
</form>
|
|
901
|
+
</div>
|
|
902
|
+
</div>
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// ── 3. Shopping Cart Components ─────────────────────────────────────────────
|
|
907
|
+
|
|
908
|
+
export function WpCartLineItems() {
|
|
909
|
+
const { cart, updateQuantity, removeItem, isLoading } = useWpCart();
|
|
910
|
+
|
|
911
|
+
if (cart.items.length === 0) {
|
|
912
|
+
return (
|
|
913
|
+
<div className="border-4 border-black p-8 font-mono text-center font-bold bg-white uppercase">
|
|
914
|
+
Your Cart is empty.
|
|
915
|
+
</div>
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
return (
|
|
920
|
+
<div className="space-y-4 font-mono">
|
|
921
|
+
{cart.items.map((item) => (
|
|
922
|
+
<div
|
|
923
|
+
key={item.key}
|
|
924
|
+
className="border-4 border-black p-4 bg-white shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] flex flex-col sm:flex-row gap-4 items-center justify-between"
|
|
925
|
+
>
|
|
926
|
+
{/* Thumb and title */}
|
|
927
|
+
<div className="flex gap-4 items-center w-full sm:w-auto">
|
|
928
|
+
<div className="w-16 h-16 border-2 border-black overflow-hidden flex-shrink-0 flex items-center justify-center bg-zinc-50">
|
|
929
|
+
<img src={item.featuredImage} alt={item.title} className="object-cover w-full h-full" />
|
|
930
|
+
</div>
|
|
931
|
+
<div>
|
|
932
|
+
<h4 className="font-black text-sm uppercase leading-tight truncate max-w-[200px]">{item.title}</h4>
|
|
933
|
+
<span className="text-xs font-bold text-accent">${item.price}</span>
|
|
934
|
+
</div>
|
|
935
|
+
</div>
|
|
936
|
+
|
|
937
|
+
{/* Qty and price adjustments */}
|
|
938
|
+
<div className="flex gap-6 items-center w-full sm:w-auto justify-between sm:justify-end">
|
|
939
|
+
<div className="flex items-center border-2 border-black">
|
|
940
|
+
<button
|
|
941
|
+
disabled={isLoading}
|
|
942
|
+
onClick={() => updateQuantity(item.key, item.quantity - 1)}
|
|
943
|
+
className="p-2 hover:bg-zinc-100 border-r-2 border-black disabled:opacity-50"
|
|
944
|
+
>
|
|
945
|
+
<Minus size={12} />
|
|
946
|
+
</button>
|
|
947
|
+
<span className="px-4 text-xs font-bold">{item.quantity}</span>
|
|
948
|
+
<button
|
|
949
|
+
disabled={isLoading}
|
|
950
|
+
onClick={() => updateQuantity(item.key, item.quantity + 1)}
|
|
951
|
+
className="p-2 hover:bg-zinc-100 border-l-2 border-black disabled:opacity-50"
|
|
952
|
+
>
|
|
953
|
+
<Plus size={12} />
|
|
954
|
+
</button>
|
|
955
|
+
</div>
|
|
956
|
+
|
|
957
|
+
<div className="font-black text-md min-w-[70px] text-right">
|
|
958
|
+
${parseFloat(item.line_subtotal).toFixed(2)}
|
|
959
|
+
</div>
|
|
960
|
+
|
|
961
|
+
<button
|
|
962
|
+
disabled={isLoading}
|
|
963
|
+
onClick={() => removeItem(item.key)}
|
|
964
|
+
className="text-red-500 hover:text-black disabled:opacity-50"
|
|
965
|
+
>
|
|
966
|
+
<Trash2 size={18} />
|
|
967
|
+
</button>
|
|
968
|
+
</div>
|
|
969
|
+
</div>
|
|
970
|
+
))}
|
|
971
|
+
</div>
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
export function WpCartCouponForm() {
|
|
976
|
+
const { cart, applyCoupon, removeCoupon, isLoading } = useWpCart();
|
|
977
|
+
const [success, setSuccess] = React.useState<boolean | null>(null);
|
|
978
|
+
|
|
979
|
+
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
980
|
+
e.preventDefault();
|
|
981
|
+
const data = new FormData(e.currentTarget);
|
|
982
|
+
const code = data.get("coupon_code") as string;
|
|
983
|
+
if (!code) return;
|
|
984
|
+
|
|
985
|
+
const applied = await applyCoupon(code);
|
|
986
|
+
setSuccess(applied);
|
|
987
|
+
if (applied) {
|
|
988
|
+
e.currentTarget.reset();
|
|
989
|
+
}
|
|
990
|
+
};
|
|
991
|
+
|
|
992
|
+
return (
|
|
993
|
+
<div className="border-4 border-black p-4 bg-zinc-50 font-mono space-y-4">
|
|
994
|
+
<h4 className="font-black text-sm uppercase border-b-2 border-black pb-1">Promotional Coupons</h4>
|
|
995
|
+
<form onSubmit={handleSubmit} className="flex gap-2">
|
|
996
|
+
<input
|
|
997
|
+
name="coupon_code"
|
|
998
|
+
placeholder="COUPON CODE"
|
|
999
|
+
required
|
|
1000
|
+
className="border-2 border-black p-2 text-xs uppercase flex-1 focus:outline-none focus:ring-0"
|
|
1001
|
+
/>
|
|
1002
|
+
<button
|
|
1003
|
+
type="submit"
|
|
1004
|
+
disabled={isLoading}
|
|
1005
|
+
className="bg-black text-white border-2 border-black px-4 py-2 text-xs font-black uppercase hover:bg-white hover:text-black transition-all"
|
|
1006
|
+
>
|
|
1007
|
+
Apply
|
|
1008
|
+
</button>
|
|
1009
|
+
</form>
|
|
1010
|
+
|
|
1011
|
+
{success === false && (
|
|
1012
|
+
<p className="text-red-500 text-xs font-bold uppercase">Invalid Code. Try "BRUTAL5" or "STARK"</p>
|
|
1013
|
+
)}
|
|
1014
|
+
|
|
1015
|
+
{cart.coupons.length > 0 && (
|
|
1016
|
+
<div className="space-y-2 pt-2 border-t border-zinc-200">
|
|
1017
|
+
<span className="text-[10px] font-bold text-zinc-500 uppercase">Active Coupons:</span>
|
|
1018
|
+
<div className="flex flex-wrap gap-2">
|
|
1019
|
+
{cart.coupons.map((c) => (
|
|
1020
|
+
<span
|
|
1021
|
+
key={c}
|
|
1022
|
+
className="bg-yellow-300 border-2 border-black px-2 py-1 text-[10px] font-black uppercase flex items-center gap-2"
|
|
1023
|
+
>
|
|
1024
|
+
{c}
|
|
1025
|
+
<button type="button" onClick={() => removeCoupon(c)} className="hover:text-red-500">
|
|
1026
|
+
<X size={10} />
|
|
1027
|
+
</button>
|
|
1028
|
+
</span>
|
|
1029
|
+
))}
|
|
1030
|
+
</div>
|
|
1031
|
+
</div>
|
|
1032
|
+
)}
|
|
1033
|
+
</div>
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
export function WpCartShippingCalculator() {
|
|
1038
|
+
const { cart, calculateShipping, isLoading } = useWpCart();
|
|
1039
|
+
|
|
1040
|
+
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
|
1041
|
+
e.preventDefault();
|
|
1042
|
+
const data = new FormData(e.currentTarget);
|
|
1043
|
+
calculateShipping({
|
|
1044
|
+
country: data.get("country") as string,
|
|
1045
|
+
city: data.get("city") as string,
|
|
1046
|
+
postcode: data.get("postcode") as string
|
|
1047
|
+
});
|
|
1048
|
+
};
|
|
1049
|
+
|
|
1050
|
+
return (
|
|
1051
|
+
<div className="border-4 border-black p-4 bg-zinc-50 font-mono space-y-4">
|
|
1052
|
+
<h4 className="font-black text-sm uppercase border-b-2 border-black pb-1">Estimate Shipping</h4>
|
|
1053
|
+
<form onSubmit={handleSubmit} className="space-y-2">
|
|
1054
|
+
<input
|
|
1055
|
+
name="country"
|
|
1056
|
+
defaultValue={cart.shippingAddress.country}
|
|
1057
|
+
placeholder="Country (e.g. US)"
|
|
1058
|
+
required
|
|
1059
|
+
className="w-full border-2 border-black p-2 text-xs focus:outline-none"
|
|
1060
|
+
/>
|
|
1061
|
+
<input
|
|
1062
|
+
name="city"
|
|
1063
|
+
defaultValue={cart.shippingAddress.city}
|
|
1064
|
+
placeholder="City"
|
|
1065
|
+
className="w-full border-2 border-black p-2 text-xs focus:outline-none"
|
|
1066
|
+
/>
|
|
1067
|
+
<input
|
|
1068
|
+
name="postcode"
|
|
1069
|
+
defaultValue={cart.shippingAddress.postcode}
|
|
1070
|
+
placeholder="Postcode"
|
|
1071
|
+
required
|
|
1072
|
+
className="w-full border-2 border-black p-2 text-xs focus:outline-none"
|
|
1073
|
+
/>
|
|
1074
|
+
<button
|
|
1075
|
+
type="submit"
|
|
1076
|
+
disabled={isLoading}
|
|
1077
|
+
className="w-full bg-black text-white border-2 border-black py-2 text-xs font-black uppercase hover:bg-white hover:text-black transition-all"
|
|
1078
|
+
>
|
|
1079
|
+
Update Destination
|
|
1080
|
+
</button>
|
|
1081
|
+
</form>
|
|
1082
|
+
</div>
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// ── 4. Checkout Components ──────────────────────────────────────────────────
|
|
1087
|
+
|
|
1088
|
+
export const CheckoutFormContext = React.createContext<{
|
|
1089
|
+
billing: Record<string, string>;
|
|
1090
|
+
setBilling: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
|
1091
|
+
shipping: Record<string, string>;
|
|
1092
|
+
setShipping: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
|
1093
|
+
selectedGateway: string;
|
|
1094
|
+
setSelectedGateway: (id: string) => void;
|
|
1095
|
+
} | null>(null);
|
|
1096
|
+
|
|
1097
|
+
export function WpCheckoutForm({
|
|
1098
|
+
children,
|
|
1099
|
+
className
|
|
1100
|
+
}: {
|
|
1101
|
+
children: React.ReactNode;
|
|
1102
|
+
className?: string;
|
|
1103
|
+
}) {
|
|
1104
|
+
const [billing, setBilling] = React.useState<Record<string, string>>({});
|
|
1105
|
+
const [shipping, setShipping] = React.useState<Record<string, string>>({});
|
|
1106
|
+
const [selectedGateway, setSelectedGateway] = React.useState("stripe");
|
|
1107
|
+
|
|
1108
|
+
return (
|
|
1109
|
+
<CheckoutFormContext.Provider
|
|
1110
|
+
value={{
|
|
1111
|
+
billing,
|
|
1112
|
+
setBilling,
|
|
1113
|
+
shipping,
|
|
1114
|
+
setShipping,
|
|
1115
|
+
selectedGateway,
|
|
1116
|
+
setSelectedGateway
|
|
1117
|
+
}}
|
|
1118
|
+
>
|
|
1119
|
+
<div className={className}>{children}</div>
|
|
1120
|
+
</CheckoutFormContext.Provider>
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
WpCheckoutForm.BillingFields = function BillingFields({ className }: { className?: string }) {
|
|
1125
|
+
const context = React.useContext(CheckoutFormContext);
|
|
1126
|
+
if (!context) return null;
|
|
1127
|
+
|
|
1128
|
+
const handleChange = (name: string, val: string) => {
|
|
1129
|
+
context.setBilling((prev) => ({ ...prev, [name]: val }));
|
|
1130
|
+
};
|
|
1131
|
+
|
|
1132
|
+
return (
|
|
1133
|
+
<div className={`space-y-4 font-mono ${className}`}>
|
|
1134
|
+
<div className="grid grid-cols-2 gap-4">
|
|
1135
|
+
<div className="space-y-1">
|
|
1136
|
+
<label className="text-[10px] font-bold uppercase text-zinc-500">First Name</label>
|
|
1137
|
+
<input
|
|
1138
|
+
required
|
|
1139
|
+
onChange={(e) => handleChange("first_name", e.target.value)}
|
|
1140
|
+
className="w-full border-2 border-black p-2 text-xs focus:outline-none"
|
|
1141
|
+
/>
|
|
1142
|
+
</div>
|
|
1143
|
+
<div className="space-y-1">
|
|
1144
|
+
<label className="text-[10px] font-bold uppercase text-zinc-500">Last Name</label>
|
|
1145
|
+
<input
|
|
1146
|
+
required
|
|
1147
|
+
onChange={(e) => handleChange("last_name", e.target.value)}
|
|
1148
|
+
className="w-full border-2 border-black p-2 text-xs focus:outline-none"
|
|
1149
|
+
/>
|
|
1150
|
+
</div>
|
|
1151
|
+
</div>
|
|
1152
|
+
<div className="space-y-1">
|
|
1153
|
+
<label className="text-[10px] font-bold uppercase text-zinc-500">Email Address</label>
|
|
1154
|
+
<input
|
|
1155
|
+
type="email"
|
|
1156
|
+
required
|
|
1157
|
+
onChange={(e) => handleChange("email", e.target.value)}
|
|
1158
|
+
className="w-full border-2 border-black p-2 text-xs focus:outline-none"
|
|
1159
|
+
/>
|
|
1160
|
+
</div>
|
|
1161
|
+
<div className="space-y-1">
|
|
1162
|
+
<label className="text-[10px] font-bold uppercase text-zinc-500">Street Address</label>
|
|
1163
|
+
<input
|
|
1164
|
+
required
|
|
1165
|
+
placeholder="House number and street name"
|
|
1166
|
+
onChange={(e) => handleChange("address_1", e.target.value)}
|
|
1167
|
+
className="w-full border-2 border-black p-2 text-xs focus:outline-none"
|
|
1168
|
+
/>
|
|
1169
|
+
</div>
|
|
1170
|
+
<div className="grid grid-cols-2 gap-4">
|
|
1171
|
+
<div className="space-y-1">
|
|
1172
|
+
<label className="text-[10px] font-bold uppercase text-zinc-500">City</label>
|
|
1173
|
+
<input
|
|
1174
|
+
required
|
|
1175
|
+
onChange={(e) => handleChange("city", e.target.value)}
|
|
1176
|
+
className="w-full border-2 border-black p-2 text-xs focus:outline-none"
|
|
1177
|
+
/>
|
|
1178
|
+
</div>
|
|
1179
|
+
<div className="space-y-1">
|
|
1180
|
+
<label className="text-[10px] font-bold uppercase text-zinc-500">Postcode</label>
|
|
1181
|
+
<input
|
|
1182
|
+
required
|
|
1183
|
+
onChange={(e) => handleChange("postcode", e.target.value)}
|
|
1184
|
+
className="w-full border-2 border-black p-2 text-xs focus:outline-none"
|
|
1185
|
+
/>
|
|
1186
|
+
</div>
|
|
1187
|
+
</div>
|
|
1188
|
+
<div className="space-y-1">
|
|
1189
|
+
<label className="text-[10px] font-bold uppercase text-zinc-500">Country</label>
|
|
1190
|
+
<input
|
|
1191
|
+
required
|
|
1192
|
+
defaultValue="US"
|
|
1193
|
+
onChange={(e) => handleChange("country", e.target.value)}
|
|
1194
|
+
className="w-full border-2 border-black p-2 text-xs focus:outline-none"
|
|
1195
|
+
/>
|
|
1196
|
+
</div>
|
|
1197
|
+
</div>
|
|
1198
|
+
);
|
|
1199
|
+
};
|
|
1200
|
+
|
|
1201
|
+
WpCheckoutForm.PaymentGateways = function WpPaymentGateways({ className }: { className?: string }) {
|
|
1202
|
+
const context = React.useContext(CheckoutFormContext);
|
|
1203
|
+
const { paymentGateways } = useWpCheckout();
|
|
1204
|
+
if (!context) return null;
|
|
1205
|
+
|
|
1206
|
+
return (
|
|
1207
|
+
<div className={`space-y-4 font-mono ${className}`}>
|
|
1208
|
+
{paymentGateways.map((g) => {
|
|
1209
|
+
const isSelected = context.selectedGateway === g.id;
|
|
1210
|
+
return (
|
|
1211
|
+
<label
|
|
1212
|
+
key={g.id}
|
|
1213
|
+
onClick={() => context.setSelectedGateway(g.id)}
|
|
1214
|
+
className={`border-4 border-black p-4 bg-white shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] cursor-pointer flex gap-3 items-start select-none ${
|
|
1215
|
+
isSelected ? "outline outline-4 outline-black" : ""
|
|
1216
|
+
}`}
|
|
1217
|
+
>
|
|
1218
|
+
<div className={`w-4 h-4 border-2 border-black mt-1 flex-shrink-0 flex items-center justify-center`}>
|
|
1219
|
+
{isSelected && <div className="w-2 h-2 bg-black" />}
|
|
1220
|
+
</div>
|
|
1221
|
+
<div>
|
|
1222
|
+
<span className="font-black text-sm uppercase leading-tight">{g.title}</span>
|
|
1223
|
+
<p className="text-[10px] text-zinc-500 mt-1 font-semibold leading-relaxed">{g.description}</p>
|
|
1224
|
+
{g.id === "stripe" && isSelected && (
|
|
1225
|
+
<div className="mt-4 space-y-2 border-t-2 border-dashed border-zinc-200 pt-4 w-full">
|
|
1226
|
+
<span className="text-[10px] font-bold text-zinc-500 uppercase">Secure Card Fields</span>
|
|
1227
|
+
<input
|
|
1228
|
+
placeholder="CARD NUMBER"
|
|
1229
|
+
className="w-full border-2 border-black p-2 text-xs focus:outline-none"
|
|
1230
|
+
/>
|
|
1231
|
+
<div className="grid grid-cols-2 gap-2">
|
|
1232
|
+
<input
|
|
1233
|
+
placeholder="MM/YY"
|
|
1234
|
+
className="border-2 border-black p-2 text-xs focus:outline-none"
|
|
1235
|
+
/>
|
|
1236
|
+
<input
|
|
1237
|
+
placeholder="CVC"
|
|
1238
|
+
className="border-2 border-black p-2 text-xs focus:outline-none"
|
|
1239
|
+
/>
|
|
1240
|
+
</div>
|
|
1241
|
+
</div>
|
|
1242
|
+
)}
|
|
1243
|
+
</div>
|
|
1244
|
+
</label>
|
|
1245
|
+
);
|
|
1246
|
+
})}
|
|
1247
|
+
</div>
|
|
1248
|
+
);
|
|
1249
|
+
};
|
|
1250
|
+
|
|
1251
|
+
WpCheckoutForm.SubmitButton = function SubmitButton({ className }: { className?: string }) {
|
|
1252
|
+
const context = React.useContext(CheckoutFormContext);
|
|
1253
|
+
const { processOrder, isSubmitting, error } = useWpCheckout();
|
|
1254
|
+
|
|
1255
|
+
if (!context) return null;
|
|
1256
|
+
|
|
1257
|
+
const handleOrder = async () => {
|
|
1258
|
+
const res = await processOrder(context.billing, context.shipping, context.selectedGateway);
|
|
1259
|
+
if (res && res.success && res.redirectUrl) {
|
|
1260
|
+
if (typeof window !== "undefined") {
|
|
1261
|
+
window.location.href = res.redirectUrl + `?order_id=${res.orderId}`;
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
};
|
|
1265
|
+
|
|
1266
|
+
return (
|
|
1267
|
+
<div className="space-y-4">
|
|
1268
|
+
{error && (
|
|
1269
|
+
<div className="border-4 border-red-500 bg-red-50 text-red-700 p-4 font-mono font-bold text-xs uppercase">
|
|
1270
|
+
❌ {error}
|
|
1271
|
+
</div>
|
|
1272
|
+
)}
|
|
1273
|
+
<button
|
|
1274
|
+
onClick={handleOrder}
|
|
1275
|
+
disabled={isSubmitting}
|
|
1276
|
+
className={`w-full text-center font-mono font-black py-4 uppercase border-4 border-black bg-black text-white hover:bg-white hover:text-black transition-all shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] disabled:opacity-50 ${className}`}
|
|
1277
|
+
>
|
|
1278
|
+
{isSubmitting ? "Processing Order..." : "Place Order"}
|
|
1279
|
+
</button>
|
|
1280
|
+
</div>
|
|
1281
|
+
);
|
|
1282
|
+
};
|
|
1283
|
+
|
|
1284
|
+
export function WpShippingSelector() {
|
|
1285
|
+
const { shippingMethods } = useWpCheckout();
|
|
1286
|
+
const { cart, setShippingMethod } = useWpCart();
|
|
1287
|
+
|
|
1288
|
+
return (
|
|
1289
|
+
<div className="space-y-4 font-mono">
|
|
1290
|
+
{shippingMethods.map((m) => {
|
|
1291
|
+
const isSelected = cart.shippingMethod === m.id;
|
|
1292
|
+
return (
|
|
1293
|
+
<label
|
|
1294
|
+
key={m.id}
|
|
1295
|
+
onClick={() => setShippingMethod(m.id)}
|
|
1296
|
+
className={`border-4 border-black p-4 bg-white shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] cursor-pointer flex gap-3 items-start select-none ${
|
|
1297
|
+
isSelected ? "outline outline-4 outline-black" : ""
|
|
1298
|
+
}`}
|
|
1299
|
+
>
|
|
1300
|
+
<div className={`w-4 h-4 border-2 border-black mt-1 flex-shrink-0 flex items-center justify-center`}>
|
|
1301
|
+
{isSelected && <div className="w-2 h-2 bg-black" />}
|
|
1302
|
+
</div>
|
|
1303
|
+
<div className="flex-1 flex justify-between items-center">
|
|
1304
|
+
<div>
|
|
1305
|
+
<span className="font-black text-sm uppercase leading-tight">{m.title}</span>
|
|
1306
|
+
<p className="text-[10px] text-zinc-500 mt-1 font-semibold leading-relaxed">{m.description}</p>
|
|
1307
|
+
</div>
|
|
1308
|
+
<span className="font-black text-md">${m.cost.toFixed(2)}</span>
|
|
1309
|
+
</div>
|
|
1310
|
+
</label>
|
|
1311
|
+
);
|
|
1312
|
+
})}
|
|
1313
|
+
</div>
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// ── 5. Customer Accounts & Authentication ───────────────────────────────────
|
|
1318
|
+
|
|
1319
|
+
export function WpLoginForm() {
|
|
1320
|
+
const { login, loading } = useWpCustomer();
|
|
1321
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
1322
|
+
|
|
1323
|
+
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
1324
|
+
e.preventDefault();
|
|
1325
|
+
setError(null);
|
|
1326
|
+
const data = new FormData(e.currentTarget);
|
|
1327
|
+
try {
|
|
1328
|
+
await login(data.get("email") as string, data.get("password") as string);
|
|
1329
|
+
} catch (err: any) {
|
|
1330
|
+
setError(err.message || "Failed to sign in");
|
|
1331
|
+
}
|
|
1332
|
+
};
|
|
1333
|
+
|
|
1334
|
+
return (
|
|
1335
|
+
<div className="border-4 border-black p-6 bg-white shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] font-mono max-w-sm mx-auto">
|
|
1336
|
+
<h3 className="font-black text-2xl uppercase mb-6 border-b-4 border-black pb-2 flex items-center gap-2">
|
|
1337
|
+
<Lock size={20} /> Sign In
|
|
1338
|
+
</h3>
|
|
1339
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
1340
|
+
{error && (
|
|
1341
|
+
<div className="border-2 border-red-500 bg-red-50 text-red-600 p-2 text-xs uppercase font-bold">
|
|
1342
|
+
{error}
|
|
1343
|
+
</div>
|
|
1344
|
+
)}
|
|
1345
|
+
<div className="space-y-1">
|
|
1346
|
+
<label className="text-[10px] font-bold uppercase text-zinc-500">Email Address</label>
|
|
1347
|
+
<input
|
|
1348
|
+
type="email"
|
|
1349
|
+
name="email"
|
|
1350
|
+
required
|
|
1351
|
+
className="w-full border-2 border-black p-2 text-xs focus:outline-none"
|
|
1352
|
+
/>
|
|
1353
|
+
</div>
|
|
1354
|
+
<div className="space-y-1">
|
|
1355
|
+
<label className="text-[10px] font-bold uppercase text-zinc-500">Password</label>
|
|
1356
|
+
<input
|
|
1357
|
+
type="password"
|
|
1358
|
+
name="password"
|
|
1359
|
+
required
|
|
1360
|
+
className="w-full border-2 border-black p-2 text-xs focus:outline-none"
|
|
1361
|
+
/>
|
|
1362
|
+
</div>
|
|
1363
|
+
<button
|
|
1364
|
+
type="submit"
|
|
1365
|
+
disabled={loading}
|
|
1366
|
+
className="w-full bg-black text-white font-bold py-3 uppercase border-2 border-black hover:bg-white hover:text-black transition-all disabled:opacity-50"
|
|
1367
|
+
>
|
|
1368
|
+
{loading ? "Authenticating..." : "Sign In"}
|
|
1369
|
+
</button>
|
|
1370
|
+
</form>
|
|
1371
|
+
</div>
|
|
1372
|
+
);
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
export function WpRegisterForm() {
|
|
1376
|
+
const { register, loading } = useWpCustomer();
|
|
1377
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
1378
|
+
|
|
1379
|
+
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
1380
|
+
e.preventDefault();
|
|
1381
|
+
setError(null);
|
|
1382
|
+
const data = new FormData(e.currentTarget);
|
|
1383
|
+
try {
|
|
1384
|
+
await register(
|
|
1385
|
+
data.get("username") as string,
|
|
1386
|
+
data.get("email") as string,
|
|
1387
|
+
data.get("password") as string
|
|
1388
|
+
);
|
|
1389
|
+
} catch (err: any) {
|
|
1390
|
+
setError(err.message || "Failed to register");
|
|
1391
|
+
}
|
|
1392
|
+
};
|
|
1393
|
+
|
|
1394
|
+
return (
|
|
1395
|
+
<div className="border-4 border-black p-6 bg-white shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] font-mono max-w-sm mx-auto">
|
|
1396
|
+
<h3 className="font-black text-2xl uppercase mb-6 border-b-4 border-black pb-2 flex items-center gap-2">
|
|
1397
|
+
<User size={20} /> Register
|
|
1398
|
+
</h3>
|
|
1399
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
1400
|
+
{error && (
|
|
1401
|
+
<div className="border-2 border-red-500 bg-red-50 text-red-600 p-2 text-xs uppercase font-bold">
|
|
1402
|
+
{error}
|
|
1403
|
+
</div>
|
|
1404
|
+
)}
|
|
1405
|
+
<div className="space-y-1">
|
|
1406
|
+
<label className="text-[10px] font-bold uppercase text-zinc-500">Username</label>
|
|
1407
|
+
<input
|
|
1408
|
+
name="username"
|
|
1409
|
+
required
|
|
1410
|
+
className="w-full border-2 border-black p-2 text-xs focus:outline-none"
|
|
1411
|
+
/>
|
|
1412
|
+
</div>
|
|
1413
|
+
<div className="space-y-1">
|
|
1414
|
+
<label className="text-[10px] font-bold uppercase text-zinc-500">Email Address</label>
|
|
1415
|
+
<input
|
|
1416
|
+
type="email"
|
|
1417
|
+
name="email"
|
|
1418
|
+
required
|
|
1419
|
+
className="w-full border-2 border-black p-2 text-xs focus:outline-none"
|
|
1420
|
+
/>
|
|
1421
|
+
</div>
|
|
1422
|
+
<div className="space-y-1">
|
|
1423
|
+
<label className="text-[10px] font-bold uppercase text-zinc-500">Password</label>
|
|
1424
|
+
<input
|
|
1425
|
+
type="password"
|
|
1426
|
+
name="password"
|
|
1427
|
+
required
|
|
1428
|
+
className="w-full border-2 border-black p-2 text-xs focus:outline-none"
|
|
1429
|
+
/>
|
|
1430
|
+
</div>
|
|
1431
|
+
<button
|
|
1432
|
+
type="submit"
|
|
1433
|
+
disabled={loading}
|
|
1434
|
+
className="w-full bg-black text-white font-bold py-3 uppercase border-2 border-black hover:bg-white hover:text-black transition-all disabled:opacity-50"
|
|
1435
|
+
>
|
|
1436
|
+
{loading ? "Registering..." : "Create Account"}
|
|
1437
|
+
</button>
|
|
1438
|
+
</form>
|
|
1439
|
+
</div>
|
|
1440
|
+
);
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
export function WpOrderHistory() {
|
|
1444
|
+
const { orders } = useWpCustomer();
|
|
1445
|
+
|
|
1446
|
+
if (orders.length === 0) {
|
|
1447
|
+
return <div className="border-4 border-black p-4 font-mono text-center font-bold">NO ORDER HISTORY</div>;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
return (
|
|
1451
|
+
<div className="border-4 border-black overflow-x-auto font-mono bg-white shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]">
|
|
1452
|
+
<table className="w-full text-left border-collapse">
|
|
1453
|
+
<thead>
|
|
1454
|
+
<tr className="bg-black text-white text-xs uppercase">
|
|
1455
|
+
<th className="p-4 border-r-2 border-black">Order ID</th>
|
|
1456
|
+
<th className="p-4 border-r-2 border-black">Date</th>
|
|
1457
|
+
<th className="p-4 border-r-2 border-black">Status</th>
|
|
1458
|
+
<th className="p-4 border-r-2 border-black">Total</th>
|
|
1459
|
+
<th className="p-4">Downloads</th>
|
|
1460
|
+
</tr>
|
|
1461
|
+
</thead>
|
|
1462
|
+
<tbody className="text-xs font-bold divide-y divide-zinc-200">
|
|
1463
|
+
{orders.map((o) => (
|
|
1464
|
+
<tr key={o.id} className="hover:bg-zinc-50">
|
|
1465
|
+
<td className="p-4 border-r-2 border-black font-black">#{o.id}</td>
|
|
1466
|
+
<td className="p-4 border-r-2 border-black">{o.date}</td>
|
|
1467
|
+
<td className="p-4 border-r-2 border-black">
|
|
1468
|
+
<span className="bg-emerald-200 border-2 border-black px-2 py-1 text-[10px] font-black uppercase">
|
|
1469
|
+
{o.status}
|
|
1470
|
+
</span>
|
|
1471
|
+
</td>
|
|
1472
|
+
<td className="p-4 border-r-2 border-black font-black">{o.total}</td>
|
|
1473
|
+
<td className="p-4">
|
|
1474
|
+
{o.downloadableFiles && o.downloadableFiles.length > 0 ? (
|
|
1475
|
+
<div className="space-y-1">
|
|
1476
|
+
{o.downloadableFiles.map((file) => (
|
|
1477
|
+
<a
|
|
1478
|
+
key={file.name}
|
|
1479
|
+
href={file.url}
|
|
1480
|
+
className="inline-flex items-center gap-1 text-black underline hover:text-accent"
|
|
1481
|
+
>
|
|
1482
|
+
<Download size={12} /> {file.name}
|
|
1483
|
+
</a>
|
|
1484
|
+
))}
|
|
1485
|
+
</div>
|
|
1486
|
+
) : (
|
|
1487
|
+
<span className="text-zinc-400 italic">None</span>
|
|
1488
|
+
)}
|
|
1489
|
+
</td>
|
|
1490
|
+
</tr>
|
|
1491
|
+
))}
|
|
1492
|
+
</tbody>
|
|
1493
|
+
</table>
|
|
1494
|
+
</div>
|
|
1495
|
+
);
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// ── 6. Faceted Search & Filtering Components ────────────────────────────────
|
|
1499
|
+
|
|
1500
|
+
export function WpProductFilters() {
|
|
1501
|
+
const { activeFilters, setFilter, setPriceRange, setSortBy, resetFilters } = useWpProductFilters();
|
|
1502
|
+
const { products, loading } = useWpProducts();
|
|
1503
|
+
const categories = useWpProductCategories();
|
|
1504
|
+
const tags = useWpProductTags();
|
|
1505
|
+
|
|
1506
|
+
// Dynamically extract all attributes from variable products in mock database
|
|
1507
|
+
const attributes = React.useMemo(() => {
|
|
1508
|
+
const map: Record<string, Set<string>> = {};
|
|
1509
|
+
products.forEach((p) => {
|
|
1510
|
+
if (p.attributes) {
|
|
1511
|
+
p.attributes.forEach((attr: any) => {
|
|
1512
|
+
if (!map[attr.name]) map[attr.name] = new Set();
|
|
1513
|
+
attr.options.forEach((opt: string) => map[attr.name].add(opt));
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
});
|
|
1517
|
+
const result: Record<string, string[]> = {};
|
|
1518
|
+
Object.entries(map).forEach(([k, v]) => {
|
|
1519
|
+
result[k] = Array.from(v);
|
|
1520
|
+
});
|
|
1521
|
+
return result;
|
|
1522
|
+
}, [products]);
|
|
1523
|
+
|
|
1524
|
+
const handleCheckboxChange = (type: string, val: string, checked: boolean) => {
|
|
1525
|
+
setFilter(type, val, checked);
|
|
1526
|
+
};
|
|
1527
|
+
|
|
1528
|
+
const activeFiltersCount =
|
|
1529
|
+
activeFilters.categories.length +
|
|
1530
|
+
activeFilters.tags.length +
|
|
1531
|
+
Object.values(activeFilters.attributes).reduce((sum, list) => sum + list.length, 0) +
|
|
1532
|
+
(activeFilters.priceRange[0] > 0 || activeFilters.priceRange[1] < 100 ? 1 : 0);
|
|
1533
|
+
|
|
1534
|
+
if (loading) return <div className="border-4 border-black p-4 font-mono text-center">LOADING FILTERS...</div>;
|
|
1535
|
+
|
|
1536
|
+
return (
|
|
1537
|
+
<div className="border-4 border-black p-6 bg-white shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] font-mono space-y-6 self-start">
|
|
1538
|
+
{/* Header and Reset */}
|
|
1539
|
+
<div className="flex justify-between items-center border-b-4 border-black pb-2">
|
|
1540
|
+
<span className="font-black text-lg uppercase flex items-center gap-2">
|
|
1541
|
+
<SlidersHorizontal size={18} /> Filters
|
|
1542
|
+
</span>
|
|
1543
|
+
{activeFiltersCount > 0 && (
|
|
1544
|
+
<button onClick={resetFilters} className="text-[10px] font-black uppercase underline hover:text-red-500">
|
|
1545
|
+
Reset
|
|
1546
|
+
</button>
|
|
1547
|
+
)}
|
|
1548
|
+
</div>
|
|
1549
|
+
|
|
1550
|
+
{/* Sort Widget */}
|
|
1551
|
+
<div className="space-y-2">
|
|
1552
|
+
<span className="text-xs font-bold uppercase text-zinc-500">Sort By</span>
|
|
1553
|
+
<select
|
|
1554
|
+
value={activeFilters.sortBy}
|
|
1555
|
+
onChange={(e) => setSortBy(e.target.value)}
|
|
1556
|
+
className="w-full border-2 border-black p-2 text-xs font-black uppercase bg-white focus:outline-none"
|
|
1557
|
+
>
|
|
1558
|
+
<option value="date">Default Sorting</option>
|
|
1559
|
+
<option value="price-asc">Price: Low to High</option>
|
|
1560
|
+
<option value="price-desc">Price: High to Low</option>
|
|
1561
|
+
<option value="title">Alphabetical</option>
|
|
1562
|
+
</select>
|
|
1563
|
+
</div>
|
|
1564
|
+
|
|
1565
|
+
{/* Categories Widget */}
|
|
1566
|
+
{categories.length > 0 && (
|
|
1567
|
+
<div className="space-y-2">
|
|
1568
|
+
<span className="text-xs font-bold uppercase text-zinc-500">Categories</span>
|
|
1569
|
+
<div className="space-y-1">
|
|
1570
|
+
{categories.map((cat: any) => {
|
|
1571
|
+
const isChecked = activeFilters.categories.includes(cat.slug);
|
|
1572
|
+
return (
|
|
1573
|
+
<label key={cat.slug} className="flex items-center gap-2 text-xs font-bold uppercase cursor-pointer select-none">
|
|
1574
|
+
<input
|
|
1575
|
+
type="checkbox"
|
|
1576
|
+
checked={isChecked}
|
|
1577
|
+
onChange={(e) => handleCheckboxChange("category", cat.slug, e.target.checked)}
|
|
1578
|
+
className="w-4 h-4 border-2 border-black rounded-none bg-white text-black focus:ring-0 focus:ring-offset-0"
|
|
1579
|
+
/>
|
|
1580
|
+
{cat.name} ({cat.count || 0})
|
|
1581
|
+
</label>
|
|
1582
|
+
);
|
|
1583
|
+
})}
|
|
1584
|
+
</div>
|
|
1585
|
+
</div>
|
|
1586
|
+
)}
|
|
1587
|
+
|
|
1588
|
+
{/* Price Slider Range Widget */}
|
|
1589
|
+
<div className="space-y-2">
|
|
1590
|
+
<span className="text-xs font-bold uppercase text-zinc-500">Price Range</span>
|
|
1591
|
+
<div className="grid grid-cols-2 gap-2">
|
|
1592
|
+
<div className="flex items-center border-2 border-black p-1 text-xs font-bold uppercase">
|
|
1593
|
+
<span className="text-zinc-400 mr-1">$</span>
|
|
1594
|
+
<input
|
|
1595
|
+
type="number"
|
|
1596
|
+
value={activeFilters.priceRange[0]}
|
|
1597
|
+
onChange={(e) => setPriceRange(parseFloat(e.target.value) || 0, activeFilters.priceRange[1])}
|
|
1598
|
+
className="w-full border-0 p-0 text-xs font-black uppercase focus:ring-0"
|
|
1599
|
+
/>
|
|
1600
|
+
</div>
|
|
1601
|
+
<div className="flex items-center border-2 border-black p-1 text-xs font-bold uppercase">
|
|
1602
|
+
<span className="text-zinc-400 mr-1">$</span>
|
|
1603
|
+
<input
|
|
1604
|
+
type="number"
|
|
1605
|
+
value={activeFilters.priceRange[1]}
|
|
1606
|
+
onChange={(e) => setPriceRange(activeFilters.priceRange[0], parseFloat(e.target.value) || 100)}
|
|
1607
|
+
className="w-full border-0 p-0 text-xs font-black uppercase focus:ring-0"
|
|
1608
|
+
/>
|
|
1609
|
+
</div>
|
|
1610
|
+
</div>
|
|
1611
|
+
</div>
|
|
1612
|
+
|
|
1613
|
+
{/* Attribute Widgets */}
|
|
1614
|
+
{Object.entries(attributes).map(([name, options]) => (
|
|
1615
|
+
<div key={name} className="space-y-2 border-t-2 border-zinc-100 pt-4">
|
|
1616
|
+
<span className="text-xs font-bold uppercase text-zinc-500">{name}</span>
|
|
1617
|
+
<div className="space-y-1">
|
|
1618
|
+
{options.map((opt) => {
|
|
1619
|
+
const isChecked = (activeFilters.attributes[name] || []).includes(opt);
|
|
1620
|
+
return (
|
|
1621
|
+
<label key={opt} className="flex items-center gap-2 text-xs font-bold uppercase cursor-pointer select-none">
|
|
1622
|
+
<input
|
|
1623
|
+
type="checkbox"
|
|
1624
|
+
checked={isChecked}
|
|
1625
|
+
onChange={(e) => handleCheckboxChange(name, opt, e.target.checked)}
|
|
1626
|
+
className="w-4 h-4 border-2 border-black rounded-none bg-white text-black focus:ring-0"
|
|
1627
|
+
/>
|
|
1628
|
+
{opt}
|
|
1629
|
+
</label>
|
|
1630
|
+
);
|
|
1631
|
+
})}
|
|
1632
|
+
</div>
|
|
1633
|
+
</div>
|
|
1634
|
+
))}
|
|
1635
|
+
|
|
1636
|
+
{/* Tags Widget */}
|
|
1637
|
+
{tags.length > 0 && (
|
|
1638
|
+
<div className="space-y-2 border-t-2 border-zinc-100 pt-4">
|
|
1639
|
+
<span className="text-xs font-bold uppercase text-zinc-500">Popular Tags</span>
|
|
1640
|
+
<div className="flex flex-wrap gap-2">
|
|
1641
|
+
{tags.map((t: any) => {
|
|
1642
|
+
const isChecked = activeFilters.tags.includes(t.slug);
|
|
1643
|
+
return (
|
|
1644
|
+
<button
|
|
1645
|
+
key={t.slug}
|
|
1646
|
+
onClick={() => handleCheckboxChange("tag", t.slug, !isChecked)}
|
|
1647
|
+
className={`border-2 border-black px-2 py-1 text-[10px] font-black uppercase transition-all shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] ${
|
|
1648
|
+
isChecked ? "bg-black text-white translate-x-[1px] translate-y-[1px] shadow-none" : "bg-white text-black"
|
|
1649
|
+
}`}
|
|
1650
|
+
>
|
|
1651
|
+
{t.name}
|
|
1652
|
+
</button>
|
|
1653
|
+
);
|
|
1654
|
+
})}
|
|
1655
|
+
</div>
|
|
1656
|
+
</div>
|
|
1657
|
+
)}
|
|
1658
|
+
</div>
|
|
1659
|
+
);
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
// ── 7. Wishlist / Favorites Components ──────────────────────────────────────
|
|
1663
|
+
|
|
1664
|
+
export function WpWishlistButton({ productId }: { productId: number }) {
|
|
1665
|
+
const { isWishlisted, toggleWishlist } = useWpWishlist();
|
|
1666
|
+
const wish = isWishlisted(productId);
|
|
1667
|
+
|
|
1668
|
+
return (
|
|
1669
|
+
<button
|
|
1670
|
+
onClick={() => toggleWishlist(productId)}
|
|
1671
|
+
className={`border-4 border-black p-3 bg-white shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] hover:bg-zinc-100 transition-all ${
|
|
1672
|
+
wish ? "text-red-500 fill-current" : "text-black"
|
|
1673
|
+
}`}
|
|
1674
|
+
>
|
|
1675
|
+
<Heart size={18} className={wish ? "fill-current" : ""} />
|
|
1676
|
+
</button>
|
|
1677
|
+
);
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
export function WpWishlistList() {
|
|
1681
|
+
const { wishlist, toggleWishlist } = useWpWishlist();
|
|
1682
|
+
const { products, loading } = useWpProducts();
|
|
1683
|
+
const wishItems = products.filter((p) => wishlist.includes(p.id));
|
|
1684
|
+
|
|
1685
|
+
if (loading) return <div className="border-4 border-black p-4 font-mono text-center">LOADING WISHLIST...</div>;
|
|
1686
|
+
|
|
1687
|
+
if (wishItems.length === 0) {
|
|
1688
|
+
return (
|
|
1689
|
+
<div className="border-4 border-black p-8 font-mono text-center font-bold bg-white uppercase">
|
|
1690
|
+
No items in your wishlist.
|
|
1691
|
+
</div>
|
|
1692
|
+
);
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
return (
|
|
1696
|
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 font-mono">
|
|
1697
|
+
{wishItems.map((p) => (
|
|
1698
|
+
<div key={p.id} className="border-4 border-black p-4 bg-white shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] flex flex-col justify-between">
|
|
1699
|
+
<div className="aspect-square border-2 border-black overflow-hidden flex items-center justify-center bg-zinc-50 relative mb-4">
|
|
1700
|
+
<button
|
|
1701
|
+
onClick={() => toggleWishlist(p.id)}
|
|
1702
|
+
className="absolute top-2 right-2 border-2 border-black p-1 bg-white text-red-500 hover:bg-zinc-100"
|
|
1703
|
+
>
|
|
1704
|
+
<X size={14} />
|
|
1705
|
+
</button>
|
|
1706
|
+
<img src={p.featuredImage} alt={p.title} className="max-w-full max-h-full object-cover" />
|
|
1707
|
+
</div>
|
|
1708
|
+
<div>
|
|
1709
|
+
<h3 className="font-black text-lg uppercase truncate mb-1">{p.title}</h3>
|
|
1710
|
+
<span className="font-bold text-accent">${parseFloat(p.price || "0").toFixed(2)}</span>
|
|
1711
|
+
</div>
|
|
1712
|
+
<a
|
|
1713
|
+
href={`/product/${p.sku}`}
|
|
1714
|
+
className="mt-4 border-4 border-black bg-black text-white text-center font-bold py-2 uppercase hover:bg-white hover:text-black transition-all flex items-center justify-center gap-2"
|
|
1715
|
+
>
|
|
1716
|
+
View Details <ArrowRight size={14} />
|
|
1717
|
+
</a>
|
|
1718
|
+
</div>
|
|
1719
|
+
))}
|
|
1720
|
+
</div>
|
|
1721
|
+
);
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
// ── 8. Global Store Announcement Notices ────────────────────────────────────
|
|
1725
|
+
|
|
1726
|
+
export function WpStoreNotice() {
|
|
1727
|
+
const [visible, setVisible] = React.useState(true);
|
|
1728
|
+
|
|
1729
|
+
if (!visible) return null;
|
|
1730
|
+
|
|
1731
|
+
return (
|
|
1732
|
+
<div className="bg-yellow-300 border-b-4 border-black font-mono px-4 py-3 flex justify-between items-center gap-4 relative z-50">
|
|
1733
|
+
<div className="flex items-center gap-2 text-xs font-black uppercase">
|
|
1734
|
+
<span className="w-2.5 h-2.5 border-2 border-black bg-black animate-pulse" />
|
|
1735
|
+
This is a mock WooCommerce sandbox workspace. Orders will not be processed or charged.
|
|
1736
|
+
</div>
|
|
1737
|
+
<button onClick={() => setVisible(false)} className="border-2 border-black p-1 bg-white hover:bg-zinc-100">
|
|
1738
|
+
<X size={12} />
|
|
1739
|
+
</button>
|
|
1740
|
+
</div>
|
|
1741
|
+
);
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
|
|
1745
|
+
export interface WpProductLoopProps {
|
|
1746
|
+
category?: string;
|
|
1747
|
+
limit?: number;
|
|
1748
|
+
orderBy?: string;
|
|
1749
|
+
order?: "asc" | "desc";
|
|
1750
|
+
status?: string;
|
|
1751
|
+
children: React.ReactNode;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
export function WpProductLoop({
|
|
1755
|
+
category,
|
|
1756
|
+
limit = 10,
|
|
1757
|
+
orderBy = "date",
|
|
1758
|
+
order = "desc",
|
|
1759
|
+
status = "publish",
|
|
1760
|
+
children
|
|
1761
|
+
}: WpProductLoopProps) {
|
|
1762
|
+
if (!IS_DEV) {
|
|
1763
|
+
return (
|
|
1764
|
+
<>
|
|
1765
|
+
{/* @ts-ignore */}
|
|
1766
|
+
<forgewp-product-loop-start
|
|
1767
|
+
category={category}
|
|
1768
|
+
limit={limit}
|
|
1769
|
+
orderBy={orderBy}
|
|
1770
|
+
order={order}
|
|
1771
|
+
status={status}
|
|
1772
|
+
/>
|
|
1773
|
+
{children}
|
|
1774
|
+
{/* @ts-ignore */}
|
|
1775
|
+
<forgewp-product-loop-end />
|
|
1776
|
+
</>
|
|
1777
|
+
);
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
const { products, loading } = useWpProducts();
|
|
1781
|
+
|
|
1782
|
+
const filtered = React.useMemo(() => {
|
|
1783
|
+
let items = [...products];
|
|
1784
|
+
if (category) {
|
|
1785
|
+
items = items.filter((p) => {
|
|
1786
|
+
const cats = p._terms?.product_cat || [];
|
|
1787
|
+
return cats.some(
|
|
1788
|
+
(c) =>
|
|
1789
|
+
c.slug.toLowerCase() === category.toLowerCase() ||
|
|
1790
|
+
c.name.toLowerCase() === category.toLowerCase()
|
|
1791
|
+
);
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
items.sort((a, b) => {
|
|
1796
|
+
let valA: any = (a as any)[orderBy] ?? "";
|
|
1797
|
+
let valB: any = (b as any)[orderBy] ?? "";
|
|
1798
|
+
if (orderBy === "date") {
|
|
1799
|
+
valA = a.id;
|
|
1800
|
+
valB = b.id;
|
|
1801
|
+
}
|
|
1802
|
+
if (order.toLowerCase() === "desc") {
|
|
1803
|
+
return valA < valB ? 1 : valA > valB ? -1 : 0;
|
|
1804
|
+
}
|
|
1805
|
+
return valA > valB ? 1 : valA < valB ? -1 : 0;
|
|
1806
|
+
});
|
|
1807
|
+
|
|
1808
|
+
return items.slice(0, limit);
|
|
1809
|
+
}, [products, category, limit, orderBy, order]);
|
|
1810
|
+
|
|
1811
|
+
if (loading) {
|
|
1812
|
+
return (
|
|
1813
|
+
<div className="border-4 border-black p-4 font-mono text-center">
|
|
1814
|
+
LOADING PRODUCTS...
|
|
1815
|
+
</div>
|
|
1816
|
+
);
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
return (
|
|
1820
|
+
<>
|
|
1821
|
+
{filtered.map((prod) => {
|
|
1822
|
+
const postShape = {
|
|
1823
|
+
id: prod.id,
|
|
1824
|
+
title: prod.title,
|
|
1825
|
+
excerpt: prod.excerpt,
|
|
1826
|
+
content: prod.content,
|
|
1827
|
+
date: "",
|
|
1828
|
+
author: "Admin",
|
|
1829
|
+
featuredImage: prod.featuredImage,
|
|
1830
|
+
customFields: {
|
|
1831
|
+
price: prod.price,
|
|
1832
|
+
regular_price: prod.regular_price,
|
|
1833
|
+
on_sale: prod.on_sale,
|
|
1834
|
+
sku: prod.sku,
|
|
1835
|
+
stock_status: prod.stock_status,
|
|
1836
|
+
},
|
|
1837
|
+
__postType: "product",
|
|
1838
|
+
};
|
|
1839
|
+
return (
|
|
1840
|
+
<WpPostContext.Provider key={prod.id} value={postShape}>
|
|
1841
|
+
{children}
|
|
1842
|
+
</WpPostContext.Provider>
|
|
1843
|
+
);
|
|
1844
|
+
})}
|
|
1845
|
+
</>
|
|
1846
|
+
);
|
|
1847
|
+
}
|
|
1848
|
+
|