@numueg/theme-cli 0.4.1 → 0.6.0

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/index.js +817 -405
  3. package/package.json +2 -1
  4. package/templates/scaffold/index.html +13 -0
  5. package/templates/scaffold/package.json +27 -0
  6. package/templates/scaffold/schemas/sections/about_section.json +23 -0
  7. package/templates/scaffold/schemas/sections/account.json +8 -0
  8. package/templates/scaffold/schemas/sections/cart_summary.json +12 -0
  9. package/templates/scaffold/schemas/sections/categories.json +9 -0
  10. package/templates/scaffold/schemas/sections/featured_collection.json +14 -0
  11. package/templates/scaffold/schemas/sections/footer.json +14 -0
  12. package/templates/scaffold/schemas/sections/frequently_bought.json +10 -0
  13. package/templates/scaffold/schemas/sections/header.json +14 -0
  14. package/templates/scaffold/schemas/sections/hero.json +15 -0
  15. package/templates/scaffold/schemas/sections/image_with_text.json +19 -0
  16. package/templates/scaffold/schemas/sections/marquee.json +9 -0
  17. package/templates/scaffold/schemas/sections/newsletter.json +11 -0
  18. package/templates/scaffold/schemas/sections/not_found.json +12 -0
  19. package/templates/scaffold/schemas/sections/order_confirmation.json +9 -0
  20. package/templates/scaffold/schemas/sections/product_details.json +12 -0
  21. package/templates/scaffold/schemas/sections/product_grid.json +12 -0
  22. package/templates/scaffold/schemas/sections/promo_banner.json +13 -0
  23. package/templates/scaffold/schemas/sections/rich_text.json +17 -0
  24. package/templates/scaffold/schemas/sections/search_results.json +11 -0
  25. package/templates/scaffold/schemas/sections/size_chart.json +9 -0
  26. package/templates/scaffold/schemas/sections/testimonials.json +22 -0
  27. package/templates/scaffold/settings_schema.json +35 -0
  28. package/templates/scaffold/src/dev-entry.tsx +244 -0
  29. package/templates/scaffold/src/lib/CouponForm.tsx +90 -0
  30. package/templates/scaffold/src/lib/EditableText.tsx +178 -0
  31. package/templates/scaffold/src/lib/ProductCard.tsx +99 -0
  32. package/templates/scaffold/src/lib/cartUI.ts +43 -0
  33. package/templates/scaffold/src/lib/i18n.ts +17 -0
  34. package/templates/scaffold/src/lib/section.ts +12 -0
  35. package/templates/scaffold/src/main.tsx +230 -0
  36. package/templates/scaffold/src/sections/Footer.tsx +161 -0
  37. package/templates/scaffold/src/sections/Header.tsx +453 -0
  38. package/templates/scaffold/src/sections/about_section.tsx +104 -0
  39. package/templates/scaffold/src/sections/account.tsx +422 -0
  40. package/templates/scaffold/src/sections/cart_summary.tsx +169 -0
  41. package/templates/scaffold/src/sections/categories.tsx +57 -0
  42. package/templates/scaffold/src/sections/featured_collection.tsx +109 -0
  43. package/templates/scaffold/src/sections/frequently_bought.tsx +187 -0
  44. package/templates/scaffold/src/sections/hero.tsx +133 -0
  45. package/templates/scaffold/src/sections/image_with_text.tsx +105 -0
  46. package/templates/scaffold/src/sections/marquee.tsx +45 -0
  47. package/templates/scaffold/src/sections/newsletter.tsx +79 -0
  48. package/templates/scaffold/src/sections/not_found.tsx +56 -0
  49. package/templates/scaffold/src/sections/order_confirmation.tsx +127 -0
  50. package/templates/scaffold/src/sections/product_details.tsx +517 -0
  51. package/templates/scaffold/src/sections/product_grid.tsx +147 -0
  52. package/templates/scaffold/src/sections/promo_banner.tsx +80 -0
  53. package/templates/scaffold/src/sections/rich_text.tsx +51 -0
  54. package/templates/scaffold/src/sections/search_results.tsx +93 -0
  55. package/templates/scaffold/src/sections/size_chart.tsx +109 -0
  56. package/templates/scaffold/src/sections/testimonials.tsx +112 -0
  57. package/templates/scaffold/styles.css +2404 -0
  58. package/templates/scaffold/templates/error.html +13 -0
  59. package/templates/scaffold/templates/loading.html +11 -0
  60. package/templates/scaffold/theme.json +224 -0
  61. package/templates/scaffold/tsconfig.json +22 -0
  62. package/templates/scaffold/vite.config.ts +16 -0
@@ -0,0 +1,517 @@
1
+ import { useState } from "react";
2
+ import {
3
+ useProductOptional,
4
+ useVariantSelection,
5
+ useRelatedProducts,
6
+ useProductSizeChart,
7
+ useCart,
8
+ useLocalization,
9
+ useShop,
10
+ defaultVariant,
11
+ } from "@numueg/theme-sdk";
12
+ import { EditableText } from "../lib/EditableText";
13
+ import type { EmpSectionProps } from "../lib/section";
14
+ import { ProductCard } from "../lib/ProductCard";
15
+ import { openCart } from "../lib/cartUI";
16
+ import { useT } from "../lib/i18n";
17
+ import FrequentlyBought from "./frequently_bought";
18
+
19
+ interface PdpSettings {
20
+ add_to_cart_label?: string;
21
+ show_compare_price?: boolean;
22
+ show_related?: boolean;
23
+ show_whatsapp?: boolean;
24
+ show_fbt?: boolean;
25
+ }
26
+
27
+ /**
28
+ * Premium product detail page — gallery + buy box. Variant picker (option axes
29
+ * or flat variant list), inline size guide, quantity, full-width add-to-cart +
30
+ * WhatsApp, share row, a trust strip, and the "frequently bought" bundle
31
+ * embedded directly in the buy column.
32
+ */
33
+ export default function ProductDetails({ id, settings }: EmpSectionProps) {
34
+ const s = settings as PdpSettings;
35
+ const t = useT();
36
+ const product = useProductOptional();
37
+ const { cart, addItem, updateNote } = useCart();
38
+ const { formatMoney } = useLocalization();
39
+ const shop = useShop();
40
+ const chart = useProductSizeChart();
41
+ const [pending, setPending] = useState(false);
42
+ const [activeImg, setActiveImg] = useState(0);
43
+ const [qty, setQty] = useState(1);
44
+ const [pickedId, setPickedId] = useState<string | undefined>(undefined);
45
+ const [pickedSize, setPickedSize] = useState<string | undefined>(undefined);
46
+ const [sizeOpen, setSizeOpen] = useState(false);
47
+ const [copied, setCopied] = useState(false);
48
+
49
+ const variantSel = useVariantSelection(
50
+ product ?? { options: [], variants: [] },
51
+ { autoSelect: true },
52
+ );
53
+ const related = useRelatedProducts(product?.id, { limit: 4 });
54
+
55
+ if (!product) {
56
+ return (
57
+ <section className="nt-page nt-container">
58
+ <p className="nt-placeholder">
59
+ {t("Product not found.", "لم يتم العثور على المنتج.")}
60
+ </p>
61
+ </section>
62
+ );
63
+ }
64
+
65
+ const opts = product.options ?? [];
66
+ const variants = product.variants ?? [];
67
+ const hasOptions = opts.length > 0;
68
+ const hasVariantList = !hasOptions && variants.length > 1;
69
+
70
+ // Sizes from the size chart — when a product has no real option axes (the
71
+ // common case here: one SKU, sizes only described in the chart) we still let
72
+ // the buyer pick a size, mirroring how the bazaar storefront renders sizes.
73
+ // The choice is recorded on the cart note (the SDK's add-to-cart carries no
74
+ // per-line properties and there's a single variant to attach it to).
75
+ const chartSizes = (chart?.rows ?? [])
76
+ .map((r) => r.size)
77
+ .filter((sz): sz is string => Boolean(sz));
78
+ // A chart can be "enabled" but empty (merchant toggled it on, never filled
79
+ // it) — only treat it as showable when it actually has rows or an image.
80
+ const chartHasContent =
81
+ !!chart && (chartSizes.length > 0 || Boolean(chart.image_url));
82
+ const needsSize = !hasOptions && !hasVariantList && chartSizes.length > 0;
83
+
84
+ const { selection, variant, select, availability, isComplete } = variantSel;
85
+ const fallbackVariant = hasOptions
86
+ ? null
87
+ : (variants.find((v) => v.id === pickedId) ??
88
+ defaultVariant(product) ??
89
+ variants[0] ??
90
+ null);
91
+ const activeVariant = hasOptions ? variant : fallbackVariant;
92
+
93
+ const currency = product.currency || shop?.currency;
94
+ const activePrice = Number(activeVariant?.price ?? product.price) || 0;
95
+ const compareRaw = Number(activeVariant?.compare_at_price ?? product.compare_at_price) || 0;
96
+ const compareAt =
97
+ s.show_compare_price !== false && compareRaw > activePrice ? compareRaw : null;
98
+
99
+ const images = product.images ?? [];
100
+ const mainImage = images[activeImg] ?? images[0];
101
+ const purchasable =
102
+ product.in_stock &&
103
+ (activeVariant?.is_in_stock ?? true) &&
104
+ (hasOptions ? isComplete : true) &&
105
+ (needsSize ? Boolean(pickedSize) : true);
106
+ const productId = product.id;
107
+ const productName = product.name;
108
+ const selectedVariantId = activeVariant?.id ?? pickedId;
109
+
110
+ const href = `/products/${product.slug}`;
111
+ const shareUrl = shop?.formatUrl ? shop.formatUrl(href) : href;
112
+ const enc = encodeURIComponent(shareUrl);
113
+ const waNumber = ((shop?.social_links?.whatsapp as string) || "").replace(/\D/g, "");
114
+ const waHref = waNumber
115
+ ? `https://wa.me/${waNumber}?text=${encodeURIComponent(`${product.name} — ${shareUrl}`)}`
116
+ : `https://wa.me/?text=${encodeURIComponent(`${product.name} — ${shareUrl}`)}`;
117
+
118
+ async function handleAdd() {
119
+ if (pending || !purchasable) return;
120
+ setPending(true);
121
+ try {
122
+ // Record the chosen size on the cart note so the merchant fulfils the
123
+ // right size. Replace any prior line for this product (re-adds, qty
124
+ // changes) so the note stays one line per product.
125
+ if (needsSize && pickedSize) {
126
+ const prefix = `• ${productName} — `;
127
+ const tag = `${prefix}${t("Size", "المقاس")}: `;
128
+ const prev = cart?.note ?? "";
129
+ const kept = prev
130
+ .split("\n")
131
+ .filter((line) => line && !line.startsWith(prefix))
132
+ .join("\n");
133
+ const next = `${kept ? `${kept}\n` : ""}${tag}${pickedSize}`;
134
+ await updateNote(next);
135
+ }
136
+ await addItem(productId, selectedVariantId, qty);
137
+ openCart();
138
+ } finally {
139
+ setPending(false);
140
+ }
141
+ }
142
+
143
+ function copyLink() {
144
+ try {
145
+ navigator.clipboard?.writeText(shareUrl);
146
+ setCopied(true);
147
+ setTimeout(() => setCopied(false), 1500);
148
+ } catch {
149
+ /* ignore */
150
+ }
151
+ }
152
+
153
+ const variantLabel = (v: (typeof variants)[number], i: number) =>
154
+ v.name ||
155
+ Object.values(v.option_values ?? {}).join(" / ") ||
156
+ `${t("Option", "الخيار")} ${i + 1}`;
157
+
158
+ const shares = [
159
+ { name: "telegram", href: `https://t.me/share/url?url=${enc}`, icon: <IconTelegram /> },
160
+ { name: "x", href: `https://twitter.com/intent/tweet?url=${enc}`, icon: <IconX /> },
161
+ { name: "facebook", href: `https://www.facebook.com/sharer/sharer.php?u=${enc}`, icon: <IconFacebook /> },
162
+ { name: "whatsapp", href: `https://wa.me/?text=${enc}`, icon: <IconWhatsApp /> },
163
+ ];
164
+
165
+ return (
166
+ <section className="nt-container" style={{ paddingBlock: "2.5rem" }}>
167
+ <nav className="nt-breadcrumb nt-label">
168
+ <a href="/">{t("Home", "الرئيسية")}</a>
169
+ <span>/</span>
170
+ <a href="/products">{t("Shop", "المتجر")}</a>
171
+ <span>/</span>
172
+ <span style={{ color: "var(--nt-fg)" }}>{product.name}</span>
173
+ </nav>
174
+
175
+ <div className="nt-pdp">
176
+ {/* Gallery */}
177
+ <div className="nt-pdp__gallery">
178
+ <div className="nt-pdp__main-img">
179
+ {compareAt ? (
180
+ <span className="nt-badge nt-badge--blue" style={{ insetInlineStart: "auto" }}>
181
+ -{Math.round((1 - activePrice / compareAt) * 100)}%
182
+ </span>
183
+ ) : null}
184
+ {mainImage ? (
185
+ <img src={mainImage.url} alt={mainImage.alt || product.name} />
186
+ ) : (
187
+ <div className="nt-card__placeholder" />
188
+ )}
189
+ </div>
190
+ {images.length > 1 ? (
191
+ <div className="nt-pdp__thumbs">
192
+ {images.map((img, i) => (
193
+ <button
194
+ key={img.id ?? i}
195
+ type="button"
196
+ className={`nt-pdp__thumb${i === activeImg ? " is-active" : ""}`}
197
+ onClick={() => setActiveImg(i)}
198
+ aria-label={`${t("Image", "صورة")} ${i + 1}`}
199
+ >
200
+ <img src={img.url} alt={img.alt || ""} />
201
+ </button>
202
+ ))}
203
+ </div>
204
+ ) : null}
205
+ </div>
206
+
207
+ {/* Buy box */}
208
+ <div className="nt-pdp__info">
209
+ {product.category ? (
210
+ <p className="nt-label" style={{ marginBottom: "0.5rem" }}>
211
+ {product.category}
212
+ </p>
213
+ ) : null}
214
+ <h1 className="nt-pdp__title">{product.name}</h1>
215
+
216
+ <div className="nt-pdp__pricerow">
217
+ <p className="nt-pdp__price">
218
+ {compareAt ? (
219
+ <span className="nt-card__compare">{formatMoney(compareAt, currency)}</span>
220
+ ) : null}
221
+ {formatMoney(activePrice, currency)}
222
+ </p>
223
+ <span className={`nt-pdp__stock${product.in_stock ? " is-in" : " is-out"}`}>
224
+ {product.in_stock ? t("In stock", "متوفر") : t("Out of stock", "نفذ المخزون")}
225
+ <span className="nt-pdp__dot" aria-hidden />
226
+ </span>
227
+ </div>
228
+
229
+ {product.description ? (
230
+ <p className="nt-pdp__desc">{product.description}</p>
231
+ ) : null}
232
+
233
+ <div className="nt-pdp__divider" />
234
+
235
+ {/* Option-axis picker */}
236
+ {opts.map((opt) => (
237
+ <div key={opt.name} className="nt-pdp__optgroup">
238
+ <div className="nt-pdp__opt-head">
239
+ {chartHasContent ? (
240
+ <button type="button" className="nt-pdp__sizelink" onClick={() => setSizeOpen(true)}>
241
+ <SizeIcon /> {t("Size guide", "دليل المقاسات")}
242
+ </button>
243
+ ) : <span />}
244
+ <span className="nt-pdp__opt-label">{opt.name}</span>
245
+ </div>
246
+ <div className="nt-pdp__sizes">
247
+ {opt.values.map((value) => {
248
+ const selected = selection[opt.name] === value;
249
+ const reachable = availability[opt.name]?.has(value) ?? true;
250
+ return (
251
+ <button
252
+ key={value}
253
+ type="button"
254
+ className={`nt-sizebox${selected ? " is-active" : ""}`}
255
+ aria-pressed={selected}
256
+ disabled={!reachable && !selected}
257
+ onClick={() => select(opt.name, value)}
258
+ >
259
+ {value}
260
+ </button>
261
+ );
262
+ })}
263
+ </div>
264
+ </div>
265
+ ))}
266
+
267
+ {/* Flat variant picker */}
268
+ {hasVariantList ? (
269
+ <div className="nt-pdp__optgroup">
270
+ <div className="nt-pdp__opt-head">
271
+ {chartHasContent ? (
272
+ <button type="button" className="nt-pdp__sizelink" onClick={() => setSizeOpen(true)}>
273
+ <SizeIcon /> {t("Size guide", "دليل المقاسات")}
274
+ </button>
275
+ ) : <span />}
276
+ <span className="nt-pdp__opt-label">{t("Option", "الخيار")}</span>
277
+ </div>
278
+ <div className="nt-pdp__sizes">
279
+ {variants.map((v, i) => {
280
+ const isSel = (activeVariant?.id ?? null) === v.id;
281
+ const inStock = v.is_in_stock ?? v.in_stock ?? true;
282
+ return (
283
+ <button
284
+ key={v.id}
285
+ type="button"
286
+ className={`nt-sizebox${isSel ? " is-active" : ""}`}
287
+ aria-pressed={isSel}
288
+ disabled={!inStock}
289
+ onClick={() => setPickedId(v.id)}
290
+ >
291
+ {variantLabel(v, i)}
292
+ </button>
293
+ );
294
+ })}
295
+ </div>
296
+ </div>
297
+ ) : null}
298
+
299
+ {/* Size picker derived from the size chart (no real option axes) */}
300
+ {needsSize ? (
301
+ <div className="nt-pdp__optgroup">
302
+ <div className="nt-pdp__opt-head">
303
+ <button type="button" className="nt-pdp__sizelink" onClick={() => setSizeOpen(true)}>
304
+ <SizeIcon /> {t("Size guide", "دليل المقاسات")}
305
+ </button>
306
+ <span className="nt-pdp__opt-label">{t("Size", "المقاس")}</span>
307
+ </div>
308
+ <div className="nt-pdp__sizes">
309
+ {chartSizes.map((sz) => {
310
+ const selected = pickedSize === sz;
311
+ return (
312
+ <button
313
+ key={sz}
314
+ type="button"
315
+ className={`nt-sizebox${selected ? " is-active" : ""}`}
316
+ aria-pressed={selected}
317
+ onClick={() => setPickedSize(sz)}
318
+ >
319
+ {sz}
320
+ </button>
321
+ );
322
+ })}
323
+ </div>
324
+ {!pickedSize ? (
325
+ <p className="nt-pdp__hint">
326
+ {t("Please select a size", "اختر المقاس من فضلك")}
327
+ </p>
328
+ ) : null}
329
+ </div>
330
+ ) : chartHasContent && !hasOptions && !hasVariantList ? (
331
+ <button
332
+ type="button"
333
+ className="nt-pdp__sizelink nt-pdp__sizelink--standalone"
334
+ onClick={() => setSizeOpen(true)}
335
+ >
336
+ <SizeIcon /> {t("Size guide", "دليل المقاسات")}
337
+ </button>
338
+ ) : null}
339
+
340
+ {/* Quantity */}
341
+ <div className="nt-pdp__qtyblock">
342
+ <span className="nt-pdp__opt-label">{t("Quantity", "الكمية")}</span>
343
+ <div className="nt-qty nt-pdp__qty" aria-label={t("Quantity", "الكمية")}>
344
+ <button type="button" aria-label={t("Decrease", "تقليل")} onClick={() => setQty((q) => Math.max(1, q - 1))}>
345
+
346
+ </button>
347
+ <span>{qty}</span>
348
+ <button type="button" aria-label={t("Increase", "زيادة")} onClick={() => setQty((q) => q + 1)}>
349
+ +
350
+ </button>
351
+ </div>
352
+ </div>
353
+
354
+ {/* CTAs */}
355
+ <button
356
+ className="nt-btn nt-btn--block"
357
+ type="button"
358
+ style={{ marginTop: "1.25rem" }}
359
+ disabled={!purchasable || pending}
360
+ onClick={handleAdd}
361
+ >
362
+ {pending ? (
363
+ "..."
364
+ ) : !product.in_stock ? (
365
+ t("Sold out", "نفذ المخزون")
366
+ ) : needsSize && !pickedSize ? (
367
+ t("Select a size", "اختر المقاس")
368
+ ) : (
369
+ <EditableText
370
+ as="span"
371
+ sectionId={id}
372
+ settingId="add_to_cart_label"
373
+ value={s.add_to_cart_label || t("Add to cart", "أضف إلى السلة")}
374
+ />
375
+ )}
376
+ </button>
377
+ {s.show_whatsapp !== false ? (
378
+ <a className="nt-btn-outline nt-btn--block" href={waHref} target="_blank" rel="noopener noreferrer" style={{ marginTop: "0.625rem" }}>
379
+ {t("Ask via WhatsApp", "اسأل عبر واتساب")} <IconWhatsApp />
380
+ </a>
381
+ ) : null}
382
+
383
+ {/* Share */}
384
+ <div className="nt-pdp__share">
385
+ <button type="button" className="nt-pdp__sharebtn" onClick={copyLink} aria-label={t("Copy link", "نسخ الرابط")}>
386
+ {copied ? <IconCheck /> : <IconLink />}
387
+ </button>
388
+ {shares.map((sh) => (
389
+ <a key={sh.name} className="nt-pdp__sharebtn" href={sh.href} target="_blank" rel="noopener noreferrer" aria-label={sh.name}>
390
+ {sh.icon}
391
+ </a>
392
+ ))}
393
+ </div>
394
+
395
+ {/* Trust row */}
396
+ <ul className="nt-pdp__trust">
397
+ <li>
398
+ <TruckIcon />
399
+ <span className="nt-pdp__trust-main">{t("Fast shipping", "شحن سريع")}</span>
400
+ <span className="nt-pdp__trust-sub">{t("All Egypt", "كل مصر")}</span>
401
+ </li>
402
+ <li>
403
+ <ReturnIcon />
404
+ <span className="nt-pdp__trust-main">{t("Returns", "إرجاع")}</span>
405
+ <span className="nt-pdp__trust-sub">{t("14 days", "خلال ١٤ يوم")}</span>
406
+ </li>
407
+ <li>
408
+ <LockIcon />
409
+ <span className="nt-pdp__trust-main">{t("Authentic", "أصلي")}</span>
410
+ <span className="nt-pdp__trust-sub">{t("100% guaranteed", "مضمون 100%")}</span>
411
+ </li>
412
+ </ul>
413
+
414
+ {/* Frequently bought — embedded in the buy column */}
415
+ {s.show_fbt !== false ? (
416
+ <div className="nt-pdp__fbt">
417
+ <FrequentlyBought
418
+ id="fbt-pdp"
419
+ settings={{ enabled: true, embedded: true, max_items: 3, title: t("Frequently bought together", "يُشترى عادةً معاً") }}
420
+ />
421
+ </div>
422
+ ) : null}
423
+ </div>
424
+ </div>
425
+
426
+ {s.show_related !== false && related.items.length > 0 ? (
427
+ <div style={{ marginTop: "4rem" }}>
428
+ <h2 className="nt-heading" style={{ marginBottom: "1.5rem" }}>
429
+ {t("You may also like", "منتجات مشابهة")}
430
+ </h2>
431
+ <div className="nt-grid" style={{ ["--cols" as string]: 4 }}>
432
+ {related.items.map((p) => (
433
+ <ProductCard key={p.id} product={p} />
434
+ ))}
435
+ </div>
436
+ </div>
437
+ ) : null}
438
+
439
+ {/* Size guide modal */}
440
+ {sizeOpen && chartHasContent && chart ? (
441
+ <div className="nt-modal" role="dialog" aria-modal="true" aria-label={t("Size guide", "دليل المقاسات")}>
442
+ <div className="nt-modal__overlay" onClick={() => setSizeOpen(false)} />
443
+ <div className="nt-modal__panel">
444
+ <div className="nt-modal__head">
445
+ <h2 className="nt-modal__title">
446
+ {t("Size guide", "دليل المقاسات")}{chart.unit ? ` (${chart.unit})` : ""}
447
+ </h2>
448
+ <button className="nt-drawer__close" type="button" onClick={() => setSizeOpen(false)} aria-label={t("Close", "إغلاق")}>
449
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
450
+ <path d="M18 6 6 18M6 6l12 12" />
451
+ </svg>
452
+ </button>
453
+ </div>
454
+ <div className="nt-modal__body">
455
+ {chart.image_url ? <img className="nt-sizeguide__img" src={chart.image_url} alt="" /> : null}
456
+ <table className="nt-sizetable">
457
+ <thead>
458
+ <tr>
459
+ <th>{t("Size", "المقاس")}</th>
460
+ {chart.column_headers.map((h, i) => (
461
+ <th key={i}>{h}</th>
462
+ ))}
463
+ </tr>
464
+ </thead>
465
+ <tbody>
466
+ {chart.rows.map((row, ri) => (
467
+ <tr key={ri}>
468
+ <th scope="row">{row.size}</th>
469
+ {chart.column_headers.map((_, ci) => (
470
+ <td key={ci}>{row.values[ci] ?? "—"}</td>
471
+ ))}
472
+ </tr>
473
+ ))}
474
+ </tbody>
475
+ </table>
476
+ {chart.notes ? (
477
+ <p className="nt-muted" style={{ fontSize: "0.8125rem", marginTop: "1rem" }}>{chart.notes}</p>
478
+ ) : null}
479
+ </div>
480
+ </div>
481
+ </div>
482
+ ) : null}
483
+ </section>
484
+ );
485
+ }
486
+
487
+ /* ── Icons ── */
488
+ const SizeIcon = () => (
489
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden><path d="M3 7h18v10H3z" /><path d="M7 7v3M11 7v5M15 7v3M19 7v5" /></svg>
490
+ );
491
+ const TruckIcon = () => (
492
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden><path d="M14 18V6H2v12h2" /><path d="M14 9h4l4 4v5h-3" /><circle cx="7" cy="18" r="2" /><circle cx="17" cy="18" r="2" /></svg>
493
+ );
494
+ const ReturnIcon = () => (
495
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden><path d="M3 7v6h6" /><path d="M3.5 13a9 9 0 1 0 2.3-9.3L3 7" /></svg>
496
+ );
497
+ const LockIcon = () => (
498
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" /><path d="m9 12 2 2 4-4" /></svg>
499
+ );
500
+ const IconLink = () => (
501
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></svg>
502
+ );
503
+ const IconCheck = () => (
504
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden><path d="M20 6 9 17l-5-5" /></svg>
505
+ );
506
+ const IconTelegram = () => (
507
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden><path d="M21.94 4.3 18.6 20.04c-.25 1.11-.91 1.39-1.85.86l-5.1-3.76-2.46 2.37c-.27.27-.5.5-1.03.5l.37-5.2L18.99 6.1c.41-.37-.09-.57-.64-.2L6.04 13.8l-5.03-1.57c-1.09-.34-1.11-1.09.23-1.61l19.65-7.57c.91-.34 1.7.2 1.41 1.45z" /></svg>
508
+ );
509
+ const IconX = () => (
510
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" /></svg>
511
+ );
512
+ const IconFacebook = () => (
513
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" /></svg>
514
+ );
515
+ const IconWhatsApp = () => (
516
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden><path d="M.057 24l1.687-6.163a11.867 11.867 0 0 1-1.587-5.946C.16 5.335 5.495 0 12.05 0a11.817 11.817 0 0 1 8.413 3.488 11.824 11.824 0 0 1 3.48 8.414c-.003 6.557-5.338 11.892-11.893 11.892a11.9 11.9 0 0 1-5.688-1.448L.057 24zm6.597-3.807c1.676.995 3.276 1.591 5.392 1.592 5.448 0 9.886-4.434 9.889-9.885.002-5.462-4.415-9.89-9.881-9.892-5.452 0-9.887 4.434-9.889 9.884a9.86 9.86 0 0 0 1.51 5.26l-.999 3.648 3.978-1.04zm11.387-5.464c-.074-.124-.272-.198-.57-.347-.297-.149-1.758-.868-2.031-.967-.272-.099-.47-.149-.669.149-.198.297-.768.967-.941 1.165-.173.198-.347.223-.644.074-.297-.149-1.255-.462-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.297-.347.446-.521.151-.172.2-.296.3-.495.099-.198.05-.372-.025-.521-.075-.148-.669-1.611-.916-2.206-.242-.579-.487-.501-.669-.51l-.57-.01c-.198 0-.52.074-.792.372s-1.04 1.017-1.04 2.479 1.065 2.876 1.213 3.074c.149.198 2.095 3.2 5.076 4.487.71.306 1.263.489 1.694.626.712.226 1.36.194 1.872.118.571-.085 1.758-.719 2.006-1.413.248-.695.248-1.29.173-1.414z" /></svg>
517
+ );
@@ -0,0 +1,147 @@
1
+ import {
2
+ useMemo,
3
+ useState } from "react";
4
+ import {
5
+ usePage,
6
+ useProducts,
7
+ type Product,
8
+ } from "@numueg/theme-sdk";
9
+ import { EditableText } from "../lib/EditableText";
10
+ import type { EmpSectionProps } from "../lib/section";
11
+ import { ProductCard } from "../lib/ProductCard";
12
+
13
+ interface GridSettings {
14
+ title?: string;
15
+ columns_desktop?: number;
16
+ show_search?: boolean;
17
+ show_sort?: boolean;
18
+ show_categories?: boolean;
19
+ }
20
+
21
+ type SortKey = "featured" | "price_asc" | "price_desc" | "name";
22
+
23
+ /**
24
+ * Products listing — page title, optional search box, category chip bar
25
+ * (derived from the catalog), a count + sort toolbar and a responsive grid.
26
+ * All filtering/sorting is client-side over the SSR-forwarded catalog so the
27
+ * page stays fast and works without extra round-trips.
28
+ */
29
+ export default function ProductGrid({ id, settings }: EmpSectionProps) {
30
+ const s = settings as GridSettings;
31
+ const cols = Math.max(2, Math.min(5, s.columns_desktop ?? 4));
32
+ const title = s.title ?? "كل المنتجات";
33
+
34
+ const page = usePage();
35
+ const ssr = (page?.data?.products as Product[] | undefined) ?? [];
36
+ const fallback = useProducts({ fetchIfMissing: ssr.length === 0 });
37
+ const products = ssr.length > 0 ? ssr : fallback.products;
38
+
39
+ const [query, setQuery] = useState("");
40
+ const [cat, setCat] = useState<string>("all");
41
+ const [sort, setSort] = useState<SortKey>("featured");
42
+
43
+ const categories = useMemo(() => {
44
+ const set = new Set<string>();
45
+ for (const p of products) if (p.category) set.add(p.category);
46
+ return Array.from(set);
47
+ }, [products]);
48
+
49
+ const visible = useMemo(() => {
50
+ let list = products;
51
+ if (cat !== "all") list = list.filter((p) => p.category === cat);
52
+ if (query.trim()) {
53
+ const q = query.trim().toLowerCase();
54
+ list = list.filter((p) => p.name.toLowerCase().includes(q));
55
+ }
56
+ const sorted = [...list];
57
+ switch (sort) {
58
+ case "price_asc":
59
+ sorted.sort((a, b) => a.price - b.price);
60
+ break;
61
+ case "price_desc":
62
+ sorted.sort((a, b) => b.price - a.price);
63
+ break;
64
+ case "name":
65
+ sorted.sort((a, b) => a.name.localeCompare(b.name, "ar"));
66
+ break;
67
+ }
68
+ return sorted;
69
+ }, [products, cat, query, sort]);
70
+
71
+ return (
72
+ <section className="nt-container" style={{ paddingBlock: "2.5rem" }}>
73
+ <EditableText
74
+ as="h1"
75
+ className="nt-display-sm"
76
+ sectionId={id}
77
+ settingId="title"
78
+ value={title}
79
+ style={{ marginBottom: "1.5rem" }}
80
+ />
81
+
82
+ {s.show_search !== false ? (
83
+ <input
84
+ className="nt-input"
85
+ type="search"
86
+ value={query}
87
+ placeholder="ابحث عن منتج..."
88
+ onChange={(e) => setQuery(e.target.value)}
89
+ style={{ marginBottom: "1.25rem" }}
90
+ />
91
+ ) : null}
92
+
93
+ {s.show_categories !== false && categories.length > 0 ? (
94
+ <div className="nt-catbar">
95
+ <button
96
+ type="button"
97
+ className="nt-chip"
98
+ aria-pressed={cat === "all"}
99
+ onClick={() => setCat("all")}
100
+ >
101
+ الكل
102
+ </button>
103
+ {categories.map((c) => (
104
+ <button
105
+ key={c}
106
+ type="button"
107
+ className="nt-chip"
108
+ aria-pressed={cat === c}
109
+ onClick={() => setCat(c)}
110
+ >
111
+ {c}
112
+ </button>
113
+ ))}
114
+ </div>
115
+ ) : null}
116
+
117
+ <div className="nt-toolbar">
118
+ <span className="nt-label">{visible.length} منتج</span>
119
+ {s.show_sort !== false ? (
120
+ <select
121
+ className="nt-select"
122
+ value={sort}
123
+ onChange={(e) => setSort(e.target.value as SortKey)}
124
+ aria-label="ترتيب"
125
+ >
126
+ <option value="featured">مميز</option>
127
+ <option value="price_asc">السعر: من الأقل</option>
128
+ <option value="price_desc">السعر: من الأعلى</option>
129
+ <option value="name">الاسم</option>
130
+ </select>
131
+ ) : null}
132
+ </div>
133
+
134
+ {visible.length === 0 ? (
135
+ <p className="nt-placeholder" style={{ paddingBlock: "3rem" }}>
136
+ لا توجد منتجات مطابقة.
137
+ </p>
138
+ ) : (
139
+ <div className="nt-grid" style={{ ["--cols" as string]: cols }}>
140
+ {visible.map((p) => (
141
+ <ProductCard key={p.id} product={p} />
142
+ ))}
143
+ </div>
144
+ )}
145
+ </section>
146
+ );
147
+ }