@numueg/theme-cli 0.5.0 → 0.6.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/index.js +825 -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 +541 -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,541 @@
1
+ import { useEffect, 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
+ // Stock to surface in the badge/button: the selected variant's when one is
102
+ // resolved (so "In stock" never sits next to a dead button), else product.
103
+ const displayInStock = activeVariant
104
+ ? (activeVariant.is_in_stock ?? activeVariant.in_stock ?? product.in_stock)
105
+ : product.in_stock;
106
+ const purchasable =
107
+ product.in_stock &&
108
+ (activeVariant?.is_in_stock ?? true) &&
109
+ (hasOptions ? isComplete : true) &&
110
+ (needsSize ? Boolean(pickedSize) : true);
111
+ const productId = product.id;
112
+ const productName = product.name;
113
+ const selectedVariantId = activeVariant?.id ?? pickedId;
114
+
115
+ // Cap the quantity stepper to the selected variant's available stock so the
116
+ // buyer can't choose more than exists (the backend enforces this too; this
117
+ // keeps the UI honest). No per-variant stock ⇒ unbounded.
118
+ const stockCap =
119
+ activeVariant && typeof activeVariant.inventory_quantity === "number"
120
+ ? Math.max(0, activeVariant.inventory_quantity)
121
+ : Infinity;
122
+ const maxQty = stockCap === Infinity ? Infinity : Math.max(1, stockCap);
123
+ useEffect(() => {
124
+ setQty((q) => Math.min(q, maxQty));
125
+ }, [maxQty]);
126
+ const lowStockLeft =
127
+ stockCap !== Infinity && stockCap > 0 && stockCap <= 5 ? stockCap : null;
128
+
129
+ const href = `/products/${product.slug}`;
130
+ const shareUrl = shop?.formatUrl ? shop.formatUrl(href) : href;
131
+ const enc = encodeURIComponent(shareUrl);
132
+ const waNumber = ((shop?.social_links?.whatsapp as string) || "").replace(/\D/g, "");
133
+ const waHref = waNumber
134
+ ? `https://wa.me/${waNumber}?text=${encodeURIComponent(`${product.name} — ${shareUrl}`)}`
135
+ : `https://wa.me/?text=${encodeURIComponent(`${product.name} — ${shareUrl}`)}`;
136
+
137
+ async function handleAdd() {
138
+ if (pending || !purchasable) return;
139
+ setPending(true);
140
+ try {
141
+ // Record the chosen size on the cart note so the merchant fulfils the
142
+ // right size. Replace any prior line for this product (re-adds, qty
143
+ // changes) so the note stays one line per product.
144
+ if (needsSize && pickedSize) {
145
+ const prefix = `• ${productName} — `;
146
+ const tag = `${prefix}${t("Size", "المقاس")}: `;
147
+ const prev = cart?.note ?? "";
148
+ const kept = prev
149
+ .split("\n")
150
+ .filter((line) => line && !line.startsWith(prefix))
151
+ .join("\n");
152
+ const next = `${kept ? `${kept}\n` : ""}${tag}${pickedSize}`;
153
+ await updateNote(next);
154
+ }
155
+ await addItem(productId, selectedVariantId, Math.min(qty, maxQty));
156
+ openCart();
157
+ } finally {
158
+ setPending(false);
159
+ }
160
+ }
161
+
162
+ function copyLink() {
163
+ try {
164
+ navigator.clipboard?.writeText(shareUrl);
165
+ setCopied(true);
166
+ setTimeout(() => setCopied(false), 1500);
167
+ } catch {
168
+ /* ignore */
169
+ }
170
+ }
171
+
172
+ const variantLabel = (v: (typeof variants)[number], i: number) =>
173
+ v.name ||
174
+ Object.values(v.option_values ?? {}).join(" / ") ||
175
+ `${t("Option", "الخيار")} ${i + 1}`;
176
+
177
+ const shares = [
178
+ { name: "telegram", href: `https://t.me/share/url?url=${enc}`, icon: <IconTelegram /> },
179
+ { name: "x", href: `https://twitter.com/intent/tweet?url=${enc}`, icon: <IconX /> },
180
+ { name: "facebook", href: `https://www.facebook.com/sharer/sharer.php?u=${enc}`, icon: <IconFacebook /> },
181
+ { name: "whatsapp", href: `https://wa.me/?text=${enc}`, icon: <IconWhatsApp /> },
182
+ ];
183
+
184
+ return (
185
+ <section className="nt-container" style={{ paddingBlock: "2.5rem" }}>
186
+ <nav className="nt-breadcrumb nt-label">
187
+ <a href="/">{t("Home", "الرئيسية")}</a>
188
+ <span>/</span>
189
+ <a href="/products">{t("Shop", "المتجر")}</a>
190
+ <span>/</span>
191
+ <span style={{ color: "var(--nt-fg)" }}>{product.name}</span>
192
+ </nav>
193
+
194
+ <div className="nt-pdp">
195
+ {/* Gallery */}
196
+ <div className="nt-pdp__gallery">
197
+ <div className="nt-pdp__main-img">
198
+ {compareAt ? (
199
+ <span className="nt-badge nt-badge--blue" style={{ insetInlineStart: "auto" }}>
200
+ -{Math.round((1 - activePrice / compareAt) * 100)}%
201
+ </span>
202
+ ) : null}
203
+ {mainImage ? (
204
+ <img src={mainImage.url} alt={mainImage.alt || product.name} />
205
+ ) : (
206
+ <div className="nt-card__placeholder" />
207
+ )}
208
+ </div>
209
+ {images.length > 1 ? (
210
+ <div className="nt-pdp__thumbs">
211
+ {images.map((img, i) => (
212
+ <button
213
+ key={img.id ?? i}
214
+ type="button"
215
+ className={`nt-pdp__thumb${i === activeImg ? " is-active" : ""}`}
216
+ onClick={() => setActiveImg(i)}
217
+ aria-label={`${t("Image", "صورة")} ${i + 1}`}
218
+ >
219
+ <img src={img.url} alt={img.alt || ""} />
220
+ </button>
221
+ ))}
222
+ </div>
223
+ ) : null}
224
+ </div>
225
+
226
+ {/* Buy box */}
227
+ <div className="nt-pdp__info">
228
+ {product.category ? (
229
+ <p className="nt-label" style={{ marginBottom: "0.5rem" }}>
230
+ {product.category}
231
+ </p>
232
+ ) : null}
233
+ <h1 className="nt-pdp__title">{product.name}</h1>
234
+
235
+ <div className="nt-pdp__pricerow">
236
+ <p className="nt-pdp__price">
237
+ {compareAt ? (
238
+ <span className="nt-card__compare">{formatMoney(compareAt, currency)}</span>
239
+ ) : null}
240
+ {formatMoney(activePrice, currency)}
241
+ </p>
242
+ <span className={`nt-pdp__stock${displayInStock ? " is-in" : " is-out"}`}>
243
+ {displayInStock ? t("In stock", "متوفر") : t("Out of stock", "نفذ المخزون")}
244
+ <span className="nt-pdp__dot" aria-hidden />
245
+ </span>
246
+ </div>
247
+
248
+ {product.description ? (
249
+ <p className="nt-pdp__desc">{product.description}</p>
250
+ ) : null}
251
+
252
+ <div className="nt-pdp__divider" />
253
+
254
+ {/* Option-axis picker */}
255
+ {opts.map((opt) => (
256
+ <div key={opt.name} className="nt-pdp__optgroup">
257
+ <div className="nt-pdp__opt-head">
258
+ {chartHasContent ? (
259
+ <button type="button" className="nt-pdp__sizelink" onClick={() => setSizeOpen(true)}>
260
+ <SizeIcon /> {t("Size guide", "دليل المقاسات")}
261
+ </button>
262
+ ) : <span />}
263
+ <span className="nt-pdp__opt-label">{opt.name}</span>
264
+ </div>
265
+ <div className="nt-pdp__sizes">
266
+ {opt.values.map((value) => {
267
+ const selected = selection[opt.name] === value;
268
+ const reachable = availability[opt.name]?.has(value) ?? true;
269
+ return (
270
+ <button
271
+ key={value}
272
+ type="button"
273
+ className={`nt-sizebox${selected ? " is-active" : ""}`}
274
+ aria-pressed={selected}
275
+ disabled={!reachable && !selected}
276
+ onClick={() => select(opt.name, value)}
277
+ >
278
+ {value}
279
+ </button>
280
+ );
281
+ })}
282
+ </div>
283
+ </div>
284
+ ))}
285
+
286
+ {/* Flat variant picker */}
287
+ {hasVariantList ? (
288
+ <div className="nt-pdp__optgroup">
289
+ <div className="nt-pdp__opt-head">
290
+ {chartHasContent ? (
291
+ <button type="button" className="nt-pdp__sizelink" onClick={() => setSizeOpen(true)}>
292
+ <SizeIcon /> {t("Size guide", "دليل المقاسات")}
293
+ </button>
294
+ ) : <span />}
295
+ <span className="nt-pdp__opt-label">{t("Option", "الخيار")}</span>
296
+ </div>
297
+ <div className="nt-pdp__sizes">
298
+ {variants.map((v, i) => {
299
+ const isSel = (activeVariant?.id ?? null) === v.id;
300
+ const inStock = v.is_in_stock ?? v.in_stock ?? true;
301
+ return (
302
+ <button
303
+ key={v.id}
304
+ type="button"
305
+ className={`nt-sizebox${isSel ? " is-active" : ""}`}
306
+ aria-pressed={isSel}
307
+ disabled={!inStock}
308
+ onClick={() => setPickedId(v.id)}
309
+ >
310
+ {variantLabel(v, i)}
311
+ </button>
312
+ );
313
+ })}
314
+ </div>
315
+ </div>
316
+ ) : null}
317
+
318
+ {/* Size picker derived from the size chart (no real option axes) */}
319
+ {needsSize ? (
320
+ <div className="nt-pdp__optgroup">
321
+ <div className="nt-pdp__opt-head">
322
+ <button type="button" className="nt-pdp__sizelink" onClick={() => setSizeOpen(true)}>
323
+ <SizeIcon /> {t("Size guide", "دليل المقاسات")}
324
+ </button>
325
+ <span className="nt-pdp__opt-label">{t("Size", "المقاس")}</span>
326
+ </div>
327
+ <div className="nt-pdp__sizes">
328
+ {chartSizes.map((sz) => {
329
+ const selected = pickedSize === sz;
330
+ return (
331
+ <button
332
+ key={sz}
333
+ type="button"
334
+ className={`nt-sizebox${selected ? " is-active" : ""}`}
335
+ aria-pressed={selected}
336
+ onClick={() => setPickedSize(sz)}
337
+ >
338
+ {sz}
339
+ </button>
340
+ );
341
+ })}
342
+ </div>
343
+ {!pickedSize ? (
344
+ <p className="nt-pdp__hint">
345
+ {t("Please select a size", "اختر المقاس من فضلك")}
346
+ </p>
347
+ ) : null}
348
+ </div>
349
+ ) : chartHasContent && !hasOptions && !hasVariantList ? (
350
+ <button
351
+ type="button"
352
+ className="nt-pdp__sizelink nt-pdp__sizelink--standalone"
353
+ onClick={() => setSizeOpen(true)}
354
+ >
355
+ <SizeIcon /> {t("Size guide", "دليل المقاسات")}
356
+ </button>
357
+ ) : null}
358
+
359
+ {/* Quantity */}
360
+ <div className="nt-pdp__qtyblock">
361
+ <span className="nt-pdp__opt-label">{t("Quantity", "الكمية")}</span>
362
+ <div className="nt-qty nt-pdp__qty" aria-label={t("Quantity", "الكمية")}>
363
+ <button type="button" aria-label={t("Decrease", "تقليل")} disabled={qty <= 1} onClick={() => setQty((q) => Math.max(1, q - 1))}>
364
+
365
+ </button>
366
+ <span>{Math.min(qty, maxQty)}</span>
367
+ <button type="button" aria-label={t("Increase", "زيادة")} disabled={qty >= maxQty} onClick={() => setQty((q) => Math.min(maxQty, q + 1))}>
368
+ +
369
+ </button>
370
+ </div>
371
+ {lowStockLeft != null ? (
372
+ <span className="nt-pdp__hint">
373
+ {t(`Only ${lowStockLeft} left`, `باقي ${lowStockLeft} فقط`)}
374
+ </span>
375
+ ) : null}
376
+ </div>
377
+
378
+ {/* CTAs */}
379
+ <button
380
+ className="nt-btn nt-btn--block"
381
+ type="button"
382
+ style={{ marginTop: "1.25rem" }}
383
+ disabled={!purchasable || pending}
384
+ onClick={handleAdd}
385
+ >
386
+ {pending ? (
387
+ "..."
388
+ ) : !displayInStock ? (
389
+ t("Sold out", "نفذ المخزون")
390
+ ) : needsSize && !pickedSize ? (
391
+ t("Select a size", "اختر المقاس")
392
+ ) : (
393
+ <EditableText
394
+ as="span"
395
+ sectionId={id}
396
+ settingId="add_to_cart_label"
397
+ value={s.add_to_cart_label || t("Add to cart", "أضف إلى السلة")}
398
+ />
399
+ )}
400
+ </button>
401
+ {s.show_whatsapp !== false ? (
402
+ <a className="nt-btn-outline nt-btn--block" href={waHref} target="_blank" rel="noopener noreferrer" style={{ marginTop: "0.625rem" }}>
403
+ {t("Ask via WhatsApp", "اسأل عبر واتساب")} <IconWhatsApp />
404
+ </a>
405
+ ) : null}
406
+
407
+ {/* Share */}
408
+ <div className="nt-pdp__share">
409
+ <button type="button" className="nt-pdp__sharebtn" onClick={copyLink} aria-label={t("Copy link", "نسخ الرابط")}>
410
+ {copied ? <IconCheck /> : <IconLink />}
411
+ </button>
412
+ {shares.map((sh) => (
413
+ <a key={sh.name} className="nt-pdp__sharebtn" href={sh.href} target="_blank" rel="noopener noreferrer" aria-label={sh.name}>
414
+ {sh.icon}
415
+ </a>
416
+ ))}
417
+ </div>
418
+
419
+ {/* Trust row */}
420
+ <ul className="nt-pdp__trust">
421
+ <li>
422
+ <TruckIcon />
423
+ <span className="nt-pdp__trust-main">{t("Fast shipping", "شحن سريع")}</span>
424
+ <span className="nt-pdp__trust-sub">{t("All Egypt", "كل مصر")}</span>
425
+ </li>
426
+ <li>
427
+ <ReturnIcon />
428
+ <span className="nt-pdp__trust-main">{t("Returns", "إرجاع")}</span>
429
+ <span className="nt-pdp__trust-sub">{t("14 days", "خلال ١٤ يوم")}</span>
430
+ </li>
431
+ <li>
432
+ <LockIcon />
433
+ <span className="nt-pdp__trust-main">{t("Authentic", "أصلي")}</span>
434
+ <span className="nt-pdp__trust-sub">{t("100% guaranteed", "مضمون 100%")}</span>
435
+ </li>
436
+ </ul>
437
+
438
+ {/* Frequently bought — embedded in the buy column */}
439
+ {s.show_fbt !== false ? (
440
+ <div className="nt-pdp__fbt">
441
+ <FrequentlyBought
442
+ id="fbt-pdp"
443
+ settings={{ enabled: true, embedded: true, max_items: 3, title: t("Frequently bought together", "يُشترى عادةً معاً") }}
444
+ />
445
+ </div>
446
+ ) : null}
447
+ </div>
448
+ </div>
449
+
450
+ {s.show_related !== false && related.items.length > 0 ? (
451
+ <div style={{ marginTop: "4rem" }}>
452
+ <h2 className="nt-heading" style={{ marginBottom: "1.5rem" }}>
453
+ {t("You may also like", "منتجات مشابهة")}
454
+ </h2>
455
+ <div className="nt-grid" style={{ ["--cols" as string]: 4 }}>
456
+ {related.items.map((p) => (
457
+ <ProductCard key={p.id} product={p} />
458
+ ))}
459
+ </div>
460
+ </div>
461
+ ) : null}
462
+
463
+ {/* Size guide modal */}
464
+ {sizeOpen && chartHasContent && chart ? (
465
+ <div className="nt-modal" role="dialog" aria-modal="true" aria-label={t("Size guide", "دليل المقاسات")}>
466
+ <div className="nt-modal__overlay" onClick={() => setSizeOpen(false)} />
467
+ <div className="nt-modal__panel">
468
+ <div className="nt-modal__head">
469
+ <h2 className="nt-modal__title">
470
+ {t("Size guide", "دليل المقاسات")}{chart.unit ? ` (${chart.unit})` : ""}
471
+ </h2>
472
+ <button className="nt-drawer__close" type="button" onClick={() => setSizeOpen(false)} aria-label={t("Close", "إغلاق")}>
473
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
474
+ <path d="M18 6 6 18M6 6l12 12" />
475
+ </svg>
476
+ </button>
477
+ </div>
478
+ <div className="nt-modal__body">
479
+ {chart.image_url ? <img className="nt-sizeguide__img" src={chart.image_url} alt="" /> : null}
480
+ <table className="nt-sizetable">
481
+ <thead>
482
+ <tr>
483
+ <th>{t("Size", "المقاس")}</th>
484
+ {chart.column_headers.map((h, i) => (
485
+ <th key={i}>{h}</th>
486
+ ))}
487
+ </tr>
488
+ </thead>
489
+ <tbody>
490
+ {chart.rows.map((row, ri) => (
491
+ <tr key={ri}>
492
+ <th scope="row">{row.size}</th>
493
+ {chart.column_headers.map((_, ci) => (
494
+ <td key={ci}>{row.values[ci] ?? "—"}</td>
495
+ ))}
496
+ </tr>
497
+ ))}
498
+ </tbody>
499
+ </table>
500
+ {chart.notes ? (
501
+ <p className="nt-muted" style={{ fontSize: "0.8125rem", marginTop: "1rem" }}>{chart.notes}</p>
502
+ ) : null}
503
+ </div>
504
+ </div>
505
+ </div>
506
+ ) : null}
507
+ </section>
508
+ );
509
+ }
510
+
511
+ /* ── Icons ── */
512
+ const SizeIcon = () => (
513
+ <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>
514
+ );
515
+ const TruckIcon = () => (
516
+ <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>
517
+ );
518
+ const ReturnIcon = () => (
519
+ <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>
520
+ );
521
+ const LockIcon = () => (
522
+ <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>
523
+ );
524
+ const IconLink = () => (
525
+ <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>
526
+ );
527
+ const IconCheck = () => (
528
+ <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>
529
+ );
530
+ const IconTelegram = () => (
531
+ <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>
532
+ );
533
+ const IconX = () => (
534
+ <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>
535
+ );
536
+ const IconFacebook = () => (
537
+ <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>
538
+ );
539
+ const IconWhatsApp = () => (
540
+ <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>
541
+ );