@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.
- package/CHANGELOG.md +22 -0
- package/dist/index.js +817 -405
- package/package.json +2 -1
- package/templates/scaffold/index.html +13 -0
- package/templates/scaffold/package.json +27 -0
- package/templates/scaffold/schemas/sections/about_section.json +23 -0
- package/templates/scaffold/schemas/sections/account.json +8 -0
- package/templates/scaffold/schemas/sections/cart_summary.json +12 -0
- package/templates/scaffold/schemas/sections/categories.json +9 -0
- package/templates/scaffold/schemas/sections/featured_collection.json +14 -0
- package/templates/scaffold/schemas/sections/footer.json +14 -0
- package/templates/scaffold/schemas/sections/frequently_bought.json +10 -0
- package/templates/scaffold/schemas/sections/header.json +14 -0
- package/templates/scaffold/schemas/sections/hero.json +15 -0
- package/templates/scaffold/schemas/sections/image_with_text.json +19 -0
- package/templates/scaffold/schemas/sections/marquee.json +9 -0
- package/templates/scaffold/schemas/sections/newsletter.json +11 -0
- package/templates/scaffold/schemas/sections/not_found.json +12 -0
- package/templates/scaffold/schemas/sections/order_confirmation.json +9 -0
- package/templates/scaffold/schemas/sections/product_details.json +12 -0
- package/templates/scaffold/schemas/sections/product_grid.json +12 -0
- package/templates/scaffold/schemas/sections/promo_banner.json +13 -0
- package/templates/scaffold/schemas/sections/rich_text.json +17 -0
- package/templates/scaffold/schemas/sections/search_results.json +11 -0
- package/templates/scaffold/schemas/sections/size_chart.json +9 -0
- package/templates/scaffold/schemas/sections/testimonials.json +22 -0
- package/templates/scaffold/settings_schema.json +35 -0
- package/templates/scaffold/src/dev-entry.tsx +244 -0
- package/templates/scaffold/src/lib/CouponForm.tsx +90 -0
- package/templates/scaffold/src/lib/EditableText.tsx +178 -0
- package/templates/scaffold/src/lib/ProductCard.tsx +99 -0
- package/templates/scaffold/src/lib/cartUI.ts +43 -0
- package/templates/scaffold/src/lib/i18n.ts +17 -0
- package/templates/scaffold/src/lib/section.ts +12 -0
- package/templates/scaffold/src/main.tsx +230 -0
- package/templates/scaffold/src/sections/Footer.tsx +161 -0
- package/templates/scaffold/src/sections/Header.tsx +453 -0
- package/templates/scaffold/src/sections/about_section.tsx +104 -0
- package/templates/scaffold/src/sections/account.tsx +422 -0
- package/templates/scaffold/src/sections/cart_summary.tsx +169 -0
- package/templates/scaffold/src/sections/categories.tsx +57 -0
- package/templates/scaffold/src/sections/featured_collection.tsx +109 -0
- package/templates/scaffold/src/sections/frequently_bought.tsx +187 -0
- package/templates/scaffold/src/sections/hero.tsx +133 -0
- package/templates/scaffold/src/sections/image_with_text.tsx +105 -0
- package/templates/scaffold/src/sections/marquee.tsx +45 -0
- package/templates/scaffold/src/sections/newsletter.tsx +79 -0
- package/templates/scaffold/src/sections/not_found.tsx +56 -0
- package/templates/scaffold/src/sections/order_confirmation.tsx +127 -0
- package/templates/scaffold/src/sections/product_details.tsx +517 -0
- package/templates/scaffold/src/sections/product_grid.tsx +147 -0
- package/templates/scaffold/src/sections/promo_banner.tsx +80 -0
- package/templates/scaffold/src/sections/rich_text.tsx +51 -0
- package/templates/scaffold/src/sections/search_results.tsx +93 -0
- package/templates/scaffold/src/sections/size_chart.tsx +109 -0
- package/templates/scaffold/src/sections/testimonials.tsx +112 -0
- package/templates/scaffold/styles.css +2404 -0
- package/templates/scaffold/templates/error.html +13 -0
- package/templates/scaffold/templates/loading.html +11 -0
- package/templates/scaffold/theme.json +224 -0
- package/templates/scaffold/tsconfig.json +22 -0
- 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
|
+
}
|