@numueg/theme-cli 0.5.0 → 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,453 @@
1
+ import {
2
+ useEffect,
3
+ useRef,
4
+ useState } from "react";
5
+ import {
6
+ useCart,
7
+ useLocalization,
8
+ useNavigation,
9
+ useCollections,
10
+ useCurrency,
11
+ useShop,
12
+ } from "@numueg/theme-sdk";
13
+ import { EditableText } from "../lib/EditableText";
14
+ import type { EmpSectionProps } from "../lib/section";
15
+ import { useCartOpen, openCart, closeCart } from "../lib/cartUI";
16
+ import { CouponForm } from "../lib/CouponForm";
17
+ import { useT } from "../lib/i18n";
18
+
19
+ interface HeaderSettings {
20
+ brand_name?: string;
21
+ logo?: string;
22
+ announcement_text?: string;
23
+ menu_handle?: string;
24
+ show_search?: boolean;
25
+ show_account?: boolean;
26
+ show_cart?: boolean;
27
+ }
28
+
29
+ export default function Header({
30
+ id,
31
+ settings,
32
+ solidHeader,
33
+ }: EmpSectionProps & { solidHeader?: boolean }) {
34
+ const s = settings as HeaderSettings;
35
+ const t = useT();
36
+ const shop = useShop();
37
+ // The merchant rarely overrides the brand, and the theme's placeholder
38
+ // ("STORE") gets baked into the store's saved customization on activation.
39
+ // Treat that placeholder as "unset" so the real store name always wins.
40
+ const brand =
41
+ s.brand_name && s.brand_name !== "STORE"
42
+ ? s.brand_name
43
+ : shop?.name || s.brand_name || "STORE";
44
+ const DEFAULT_LINKS = [
45
+ { label: t("Home", "الرئيسية"), url: "/" },
46
+ { label: t("Shop", "المتجر"), url: "/products" },
47
+ { label: t("Contact", "تواصل"), url: "/pages/contact" },
48
+ ];
49
+
50
+ const { cart, updateQuantity, removeItem, loading } = useCart();
51
+ const { formatMoney, locale, setLocale, availableLocales } =
52
+ useLocalization();
53
+ const nav = useNavigation(s.menu_handle || "main-menu");
54
+ const { collections } = useCollections({ limit: 6 });
55
+ const currency = useCurrency();
56
+ const drawerOpen = useCartOpen();
57
+
58
+ const [scrolled, setScrolled] = useState(false);
59
+ const [menuOpen, setMenuOpen] = useState(false);
60
+ const [megaOpen, setMegaOpen] = useState(false);
61
+ const megaTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
62
+
63
+ useEffect(() => {
64
+ const onScroll = () => setScrolled(window.scrollY > 50);
65
+ onScroll();
66
+ window.addEventListener("scroll", onScroll, { passive: true });
67
+ return () => window.removeEventListener("scroll", onScroll);
68
+ }, []);
69
+
70
+ const items = cart?.items ?? [];
71
+ const count = items.reduce((n, it) => n + it.quantity, 0);
72
+ const cartCurrency = cart?.currency;
73
+
74
+ const links =
75
+ nav.items.length > 0
76
+ ? nav.items.map((it) => ({ label: it.title, url: it.url }))
77
+ : DEFAULT_LINKS;
78
+
79
+ const showCurrency = currency.presentment.length > 1;
80
+
81
+ // Language toggle. The store advertises locales via `availableLocales`; when
82
+ // it doesn't, fall back to the Arabic/English pair this store ships with.
83
+ const isAr = typeof locale === "string" && locale.toLowerCase().startsWith("ar");
84
+ const localePair =
85
+ availableLocales.length >= 2 ? availableLocales : ["ar", "en"];
86
+ const nextLocale =
87
+ localePair.find((l) =>
88
+ isAr ? !l.toLowerCase().startsWith("ar") : l.toLowerCase().startsWith("ar"),
89
+ ) || (isAr ? "en" : "ar");
90
+ // Label shows the language you'll switch TO, in its own script.
91
+ const switchLabel = isAr ? "EN" : "ع";
92
+
93
+ const enterMega = () => {
94
+ clearTimeout(megaTimer.current);
95
+ setMegaOpen(true);
96
+ };
97
+ const leaveMega = () => {
98
+ megaTimer.current = setTimeout(() => setMegaOpen(false), 180);
99
+ };
100
+
101
+ return (
102
+ <>
103
+ <header
104
+ className={`nt-header${solidHeader || scrolled ? " is-scrolled" : ""}`}
105
+ >
106
+ {s.announcement_text ? (
107
+ <EditableText
108
+ as="div"
109
+ className="nt-announce"
110
+ sectionId={id}
111
+ settingId="announcement_text"
112
+ value={s.announcement_text}
113
+ />
114
+ ) : null}
115
+
116
+ <div className="nt-container">
117
+ <div className="nt-header__bar">
118
+ {/* Left: desktop nav + mobile burger */}
119
+ <nav className="nt-header__nav" aria-label="Primary">
120
+ {links.map((l, i) => {
121
+ const isShop = l.url === "/products";
122
+ if (!isShop) {
123
+ return (
124
+ <a key={i} href={l.url}>
125
+ {l.label}
126
+ </a>
127
+ );
128
+ }
129
+ return (
130
+ <div
131
+ key={i}
132
+ style={{ position: "relative" }}
133
+ onMouseEnter={enterMega}
134
+ onMouseLeave={leaveMega}
135
+ >
136
+ <a href={l.url}>{l.label}</a>
137
+ {megaOpen && collections.length > 0 ? (
138
+ <div
139
+ className="nt-mega"
140
+ onMouseEnter={enterMega}
141
+ onMouseLeave={leaveMega}
142
+ >
143
+ <div className="nt-mega__col">
144
+ <p className="nt-mega__label">{l.label}</p>
145
+ <a
146
+ className="nt-mega__link nt-mega__link--strong"
147
+ href="/products"
148
+ >
149
+ {t("All products", "كل المنتجات")}
150
+ </a>
151
+ {collections.slice(0, 5).map((c) => (
152
+ <a
153
+ key={c.id}
154
+ className="nt-mega__link"
155
+ href={`/collections/${c.slug}`}
156
+ >
157
+ {c.name}
158
+ </a>
159
+ ))}
160
+ </div>
161
+ <div className="nt-mega__cards">
162
+ {collections.slice(0, 3).map((c) => (
163
+ <a
164
+ key={c.id}
165
+ className="nt-mega__card"
166
+ href={`/collections/${c.slug}`}
167
+ >
168
+ <span className="nt-mega__cardmedia">
169
+ {c.image_url ? (
170
+ <img src={c.image_url} alt={c.name} />
171
+ ) : (
172
+ <span className="nt-cat__placeholder">
173
+ {c.name?.[0] ?? "?"}
174
+ </span>
175
+ )}
176
+ </span>
177
+ <span className="nt-mega__cardname">
178
+ {c.name}
179
+ </span>
180
+ </a>
181
+ ))}
182
+ </div>
183
+ </div>
184
+ ) : null}
185
+ </div>
186
+ );
187
+ })}
188
+ </nav>
189
+
190
+ <button
191
+ className="nt-burger"
192
+ type="button"
193
+ aria-label="Menu"
194
+ aria-expanded={menuOpen}
195
+ onClick={() => setMenuOpen((v) => !v)}
196
+ >
197
+ {menuOpen ? <IconX /> : <IconMenu />}
198
+ </button>
199
+
200
+ {/* Center: logo */}
201
+ <a className="nt-header__logo" href="/" aria-label={brand}>
202
+ {s.logo ? (
203
+ <img src={s.logo} alt={brand} />
204
+ ) : (
205
+ <EditableText
206
+ as="span"
207
+ sectionId={id}
208
+ settingId="brand_name"
209
+ value={brand}
210
+ />
211
+ )}
212
+ </a>
213
+
214
+ {/* Right: actions */}
215
+ <div className="nt-header__actions">
216
+ <button
217
+ className="nt-langswitch"
218
+ type="button"
219
+ onClick={() => setLocale(nextLocale)}
220
+ aria-label={isAr ? "Switch to English" : "التبديل إلى العربية"}
221
+ title={isAr ? "English" : "العربية"}
222
+ >
223
+ {switchLabel}
224
+ </button>
225
+
226
+ {showCurrency ? (
227
+ <select
228
+ className="nt-header__currency"
229
+ aria-label="Currency"
230
+ value={currency.selected}
231
+ onChange={(e) => currency.setSelected(e.target.value)}
232
+ >
233
+ {currency.presentment.map((c) => (
234
+ <option key={c} value={c}>
235
+ {c}
236
+ </option>
237
+ ))}
238
+ </select>
239
+ ) : null}
240
+
241
+ {s.show_search !== false ? (
242
+ <a className="nt-iconbtn" href="/search" aria-label="بحث">
243
+ <IconSearch />
244
+ </a>
245
+ ) : null}
246
+
247
+ {s.show_account !== false ? (
248
+ <a className="nt-iconbtn" href="/account" aria-label="الحساب">
249
+ <IconUser />
250
+ </a>
251
+ ) : null}
252
+
253
+ {s.show_cart !== false ? (
254
+ <button
255
+ className="nt-iconbtn"
256
+ type="button"
257
+ onClick={openCart}
258
+ aria-label={`السلة، ${count} عناصر`}
259
+ >
260
+ <IconBag />
261
+ <span>{count}</span>
262
+ </button>
263
+ ) : null}
264
+ </div>
265
+ </div>
266
+ </div>
267
+
268
+ {menuOpen ? (
269
+ <div className="nt-mobilemenu">
270
+ {links.map((l, i) => (
271
+ <a key={i} href={l.url} onClick={() => setMenuOpen(false)}>
272
+ {l.label}
273
+ </a>
274
+ ))}
275
+ {collections.slice(0, 5).map((c) => (
276
+ <a
277
+ key={c.id}
278
+ href={`/collections/${c.slug}`}
279
+ onClick={() => setMenuOpen(false)}
280
+ style={{ paddingInlineStart: "1rem", fontWeight: 400 }}
281
+ >
282
+ {c.name}
283
+ </a>
284
+ ))}
285
+ </div>
286
+ ) : null}
287
+ </header>
288
+
289
+ {/* Cart drawer */}
290
+ <div
291
+ className={`nt-drawer${drawerOpen ? " is-open" : ""}`}
292
+ role="dialog"
293
+ aria-label="السلة"
294
+ aria-hidden={!drawerOpen}
295
+ >
296
+ <div className="nt-drawer__overlay" onClick={closeCart} />
297
+ <div className="nt-drawer__panel">
298
+ <div className="nt-drawer__head">
299
+ <span className="nt-drawer__title">
300
+ {count > 0
301
+ ? `${count} ${t("in cart", "عنصر في السلة")}`
302
+ : t("Cart", "السلة")}
303
+ </span>
304
+ <button
305
+ className="nt-drawer__close"
306
+ type="button"
307
+ onClick={closeCart}
308
+ aria-label="إغلاق"
309
+ >
310
+ <IconX />
311
+ </button>
312
+ </div>
313
+
314
+ <div className="nt-drawer__body">
315
+ {items.length === 0 ? (
316
+ <div className="nt-drawer__empty">
317
+ <p>{t("Your cart is empty", "السلة فاضية")}</p>
318
+ </div>
319
+ ) : (
320
+ items.map((line) => (
321
+ <div className="nt-line" key={line.id}>
322
+ <div className="nt-line__img">
323
+ {line.image_url ? (
324
+ <img src={line.image_url} alt={line.name} />
325
+ ) : null}
326
+ </div>
327
+ <div className="nt-line__body">
328
+ <div className="nt-line__top">
329
+ <div>
330
+ <p className="nt-line__name">{line.name}</p>
331
+ {line.variant_name ? (
332
+ <p className="nt-line__variant">
333
+ {line.variant_name}
334
+ </p>
335
+ ) : null}
336
+ </div>
337
+ <p>{formatMoney(line.price * line.quantity, cartCurrency)}</p>
338
+ </div>
339
+ <div className="nt-line__controls">
340
+ <div className="nt-qty">
341
+ <button
342
+ type="button"
343
+ aria-label="تقليل"
344
+ disabled={loading}
345
+ onClick={() =>
346
+ updateQuantity(line.id, line.quantity - 1)
347
+ }
348
+ >
349
+
350
+ </button>
351
+ <span>{line.quantity}</span>
352
+ <button
353
+ type="button"
354
+ aria-label="زيادة"
355
+ disabled={loading}
356
+ onClick={() =>
357
+ updateQuantity(line.id, line.quantity + 1)
358
+ }
359
+ >
360
+ +
361
+ </button>
362
+ </div>
363
+ <button
364
+ className="nt-line__remove"
365
+ type="button"
366
+ disabled={loading}
367
+ onClick={() => removeItem(line.id)}
368
+ aria-label="حذف"
369
+ >
370
+ <IconX />
371
+ </button>
372
+ </div>
373
+ </div>
374
+ </div>
375
+ ))
376
+ )}
377
+ </div>
378
+
379
+ {items.length > 0 ? (
380
+ <div className="nt-drawer__foot">
381
+ <CouponForm compact />
382
+ <div className="nt-subtotal">
383
+ <span>{t("Subtotal", "المجموع الفرعي")}</span>
384
+ <span>{formatMoney(cart?.subtotal ?? 0, cartCurrency)}</span>
385
+ </div>
386
+ {cart?.discount_amount && cart.discount_amount > 0 ? (
387
+ <div className="nt-subtotal nt-discount">
388
+ <span>
389
+ {t("Discount", "الخصم")}
390
+ {cart.discount_code ? ` (${cart.discount_code})` : ""}
391
+ </span>
392
+ <span>−{formatMoney(cart.discount_amount, cartCurrency)}</span>
393
+ </div>
394
+ ) : null}
395
+ <div className="nt-subtotal nt-total">
396
+ <span>{t("Total", "الإجمالي")}</span>
397
+ <span>
398
+ {formatMoney(
399
+ cart?.total ??
400
+ (cart?.subtotal ?? 0) - (cart?.discount_amount ?? 0),
401
+ cartCurrency,
402
+ )}
403
+ </span>
404
+ </div>
405
+ <a className="nt-btn nt-btn--block" href="/checkout">
406
+ {t("Checkout", "إتمام الطلب")}
407
+ </a>
408
+ <button
409
+ className="nt-iconbtn"
410
+ type="button"
411
+ style={{ justifyContent: "center" }}
412
+ onClick={closeCart}
413
+ >
414
+ {t("Continue shopping", "متابعة التسوق")}
415
+ </button>
416
+ </div>
417
+ ) : null}
418
+ </div>
419
+ </div>
420
+ </>
421
+ );
422
+ }
423
+
424
+ /* ── Inline icons (no runtime icon dep) ── */
425
+ const IconSearch = () => (
426
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
427
+ <circle cx="11" cy="11" r="7" />
428
+ <path d="m21 21-4.35-4.35" />
429
+ </svg>
430
+ );
431
+ const IconUser = () => (
432
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
433
+ <circle cx="12" cy="8" r="4" />
434
+ <path d="M4 21a8 8 0 0 1 16 0" />
435
+ </svg>
436
+ );
437
+ const IconBag = () => (
438
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
439
+ <path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z" />
440
+ <path d="M3 6h18" />
441
+ <path d="M16 10a4 4 0 0 1-8 0" />
442
+ </svg>
443
+ );
444
+ const IconMenu = () => (
445
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
446
+ <path d="M3 12h18M3 6h18M3 18h18" />
447
+ </svg>
448
+ );
449
+ const IconX = () => (
450
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
451
+ <path d="M18 6 6 18M6 6l12 12" />
452
+ </svg>
453
+ );
@@ -0,0 +1,104 @@
1
+ import {
2
+ EditableImage,
3
+ type BlockInstance,
4
+ } from "@numueg/theme-sdk";
5
+ import { EditableText } from "../lib/EditableText";
6
+ import type { EmpSectionProps } from "../lib/section";
7
+
8
+ interface AboutSettings {
9
+ eyebrow?: string;
10
+ title?: string;
11
+ body?: string;
12
+ image?: string;
13
+ }
14
+
15
+ /**
16
+ * About / brand-story block — eyebrow + display heading + body paragraph beside
17
+ * an image, with an optional row of stat highlights (`stat` blocks: value +
18
+ * label). All copy + the image are inline-editable.
19
+ */
20
+ export default function AboutSection({
21
+ id,
22
+ settings,
23
+ blocks,
24
+ blockOrder,
25
+ }: EmpSectionProps) {
26
+ const s = settings as AboutSettings;
27
+
28
+ const stats = (blockOrder ?? [])
29
+ .map((bid) => ({ bid, block: blocks?.[bid] }))
30
+ .filter(
31
+ (x): x is { bid: string; block: BlockInstance } =>
32
+ !!x.block && !x.block.disabled && x.block.type === "stat",
33
+ );
34
+
35
+ return (
36
+ <section className="nt-section nt-bg-white">
37
+ <div className="nt-container nt-about">
38
+ <div className="nt-about__copy">
39
+ {s.eyebrow ? (
40
+ <EditableText
41
+ as="p"
42
+ className="nt-label"
43
+ sectionId={id}
44
+ settingId="eyebrow"
45
+ value={s.eyebrow}
46
+ />
47
+ ) : null}
48
+ <EditableText
49
+ as="h2"
50
+ className="nt-display-sm"
51
+ sectionId={id}
52
+ settingId="title"
53
+ value={s.title ?? "صُنع بشغف، يدوم مدى الحياة"}
54
+ />
55
+ <EditableText
56
+ as="p"
57
+ className="nt-about__body"
58
+ sectionId={id}
59
+ settingId="body"
60
+ value={
61
+ s.body ??
62
+ "بدأنا كاستوديو صغير مستقل، وكبرنا بفضل عملائنا. كل قطعة تُختار بعناية لتجمع بين التصميم النظيف والجودة التي تدوم."
63
+ }
64
+ />
65
+ {stats.length > 0 ? (
66
+ <div className="nt-about__stats">
67
+ {stats.map(({ bid, block }) => (
68
+ <div className="nt-about__stat" key={bid}>
69
+ <EditableText
70
+ as="span"
71
+ className="nt-about__statvalue"
72
+ sectionId={id}
73
+ blockId={bid}
74
+ settingId="value"
75
+ value={(block.settings.value as string) || "—"}
76
+ />
77
+ <EditableText
78
+ as="span"
79
+ className="nt-about__statlabel"
80
+ sectionId={id}
81
+ blockId={bid}
82
+ settingId="label"
83
+ value={(block.settings.label as string) || ""}
84
+ />
85
+ </div>
86
+ ))}
87
+ </div>
88
+ ) : null}
89
+ </div>
90
+ <div className="nt-about__media">
91
+ <EditableImage
92
+ sectionId={id}
93
+ settingId="image"
94
+ src={
95
+ s.image ||
96
+ "https://images.unsplash.com/photo-1441984904996-e0b6ba687e04?w=1200"
97
+ }
98
+ alt={(s.title as string) || ""}
99
+ />
100
+ </div>
101
+ </div>
102
+ </section>
103
+ );
104
+ }