@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,109 @@
1
+ import {
2
+ useState } from "react";
3
+ import {
4
+ usePage,
5
+ useProducts,
6
+ type Product,
7
+ } from "@numueg/theme-sdk";
8
+ import { EditableText } from "../lib/EditableText";
9
+ import type { EmpSectionProps } from "../lib/section";
10
+ import { useT } from "../lib/i18n";
11
+ import { ProductCard } from "../lib/ProductCard";
12
+
13
+ interface FeaturedSettings {
14
+ title?: string;
15
+ view_all_link?: string;
16
+ view_all_label?: string;
17
+ max_items?: number;
18
+ tab_trending_label?: string;
19
+ tab_bestseller_label?: string;
20
+ tab_new_label?: string;
21
+ }
22
+
23
+ const TABS: Array<{
24
+ key: string;
25
+ settingId: keyof FeaturedSettings;
26
+ en: string;
27
+ ar: string;
28
+ tags: string[];
29
+ }> = [
30
+ { key: "trending", settingId: "tab_trending_label", en: "Trending", ar: "الأكثر رواجاً", tags: ["رائج", "trending", "مميز", "featured"] },
31
+ { key: "bestseller", settingId: "tab_bestseller_label", en: "Bestsellers", ar: "الأكثر مبيعاً", tags: ["مبيع", "bestseller", "best", "الأكثر مبيعاً"] },
32
+ { key: "new", settingId: "tab_new_label", en: "New", ar: "وصل حديثاً", tags: ["جديد", "new", "حديث"] },
33
+ ];
34
+
35
+ /** Filter products for a tab by their `tags` (case-insensitive substring),
36
+ * falling back to the full list so a tab never renders empty. */
37
+ function filterByTab(products: Product[], key: string): Product[] {
38
+ const tab = TABS.find((t) => t.key === key);
39
+ if (!tab || key === "trending") return products;
40
+ const matched = products.filter((p) =>
41
+ (p.tags ?? []).some((t) =>
42
+ tab.tags.some((needle) => t.toLowerCase().includes(needle.toLowerCase())),
43
+ ),
44
+ );
45
+ return matched.length > 0 ? matched : products;
46
+ }
47
+
48
+ /**
49
+ * Featured rail — large uppercase title with pill filter tabs on one row, then
50
+ * a horizontally-scrollable snap rail of product cards (Empire's signature
51
+ * layout). Reads the SSR-forwarded catalog from `page.data.products`, falling
52
+ * back to a client fetch when the route didn't pre-fetch.
53
+ */
54
+ export default function FeaturedCollection({ id, settings }: EmpSectionProps) {
55
+ const s = settings as FeaturedSettings;
56
+ const tr = useT();
57
+ const title = s.title ?? tr("Shop", "المتجر");
58
+ const viewAll = s.view_all_link || "/products";
59
+ const max = Math.max(4, Math.min(12, s.max_items ?? 8));
60
+
61
+ const page = usePage();
62
+ const ssrProducts = (page?.data?.products as Product[] | undefined) ?? [];
63
+ const fallback = useProducts({ fetchIfMissing: ssrProducts.length === 0 });
64
+ const products = ssrProducts.length > 0 ? ssrProducts : fallback.products;
65
+
66
+ const [tab, setTab] = useState("trending");
67
+
68
+ const filtered = filterByTab(products, tab).slice(0, max);
69
+
70
+ if (products.length === 0) return null;
71
+
72
+ return (
73
+ <section className="nt-section nt-bg-white">
74
+ <div className="nt-container">
75
+ <div className="nt-shop__head">
76
+ <EditableText
77
+ as="h2"
78
+ className="nt-display-sm"
79
+ sectionId={id}
80
+ settingId="title"
81
+ value={title}
82
+ />
83
+ <div className="nt-tabs">
84
+ {TABS.map((item) => (
85
+ <button
86
+ key={item.key}
87
+ type="button"
88
+ className="nt-chip"
89
+ aria-pressed={tab === item.key}
90
+ onClick={() => setTab(item.key)}
91
+ >
92
+ {(s[item.settingId] as string) || tr(item.en, item.ar)}
93
+ </button>
94
+ ))}
95
+ <a className="nt-chip" href={viewAll}>
96
+ {s.view_all_label || tr("View all", "عرض الكل")}
97
+ </a>
98
+ </div>
99
+ </div>
100
+
101
+ <div className="nt-rail">
102
+ {filtered.map((p) => (
103
+ <ProductCard key={p.id} product={p} />
104
+ ))}
105
+ </div>
106
+ </div>
107
+ </section>
108
+ );
109
+ }
@@ -0,0 +1,187 @@
1
+ import {
2
+ useMemo,
3
+ useState } from "react";
4
+ import {
5
+ useProductOptional,
6
+ useRelatedProducts,
7
+ useProducts,
8
+ usePage,
9
+ useCart,
10
+ useLocalization,
11
+ useShop,
12
+ type Product,
13
+ } from "@numueg/theme-sdk";
14
+ import { EditableText } from "../lib/EditableText";
15
+ import type { EmpSectionProps } from "../lib/section";
16
+ import { openCart } from "../lib/cartUI";
17
+
18
+ interface FbtSettings {
19
+ title?: string;
20
+ max_items?: number;
21
+ /** Merchant on/off switch (belt-and-suspenders alongside the section
22
+ * visibility toggle, which the host honours via `instance.disabled`). */
23
+ enabled?: boolean;
24
+ /** When true, render only the card (no full-width section wrapper) so it can
25
+ * sit inside the PDP buy column. */
26
+ embedded?: boolean;
27
+ }
28
+
29
+ /**
30
+ * Frequently bought together — seeds the bundle with the current product and
31
+ * appends related items (`useRelatedProducts`, falling back to the page list).
32
+ * Each row is a togglable checkbox; "أضف الكل" adds every checked product to the
33
+ * live cart in one pass and pops the drawer. Hidden when fewer than 2 items.
34
+ */
35
+ export default function FrequentlyBought({ id, settings }: EmpSectionProps) {
36
+ const s = settings as FbtSettings;
37
+ const max = Math.max(2, Math.min(4, s.max_items ?? 3));
38
+
39
+ const product = useProductOptional();
40
+ const related = useRelatedProducts(product?.id, { limit: max });
41
+ const page = usePage();
42
+ const { addItem } = useCart();
43
+ const { formatMoney } = useLocalization();
44
+ const shop = useShop();
45
+
46
+ const pageProducts = (page?.data?.products as Product[] | undefined) ?? [];
47
+ // Last-resort fallback so the bundle still renders on PDP routes that pass
48
+ // only the single product (no related endpoint data, no product list).
49
+ const catalog = useProducts({ fetchIfMissing: true });
50
+
51
+ const bundle = useMemo(() => {
52
+ const pool =
53
+ related.items.length > 0
54
+ ? related.items
55
+ : pageProducts.length > 0
56
+ ? pageProducts
57
+ : catalog.products;
58
+ const seen = new Set<string>();
59
+ const list: Product[] = [];
60
+ if (product) {
61
+ list.push(product);
62
+ seen.add(product.id);
63
+ }
64
+ for (const p of pool) {
65
+ if (list.length >= max) break;
66
+ if (seen.has(p.id)) continue;
67
+ seen.add(p.id);
68
+ list.push(p);
69
+ }
70
+ return list;
71
+ }, [product, related.items, pageProducts, catalog.products, max]);
72
+
73
+ const [selected, setSelected] = useState<Record<string, boolean>>({});
74
+ const isOn = (pid: string) => selected[pid] !== false; // default on
75
+ const [pending, setPending] = useState(false);
76
+
77
+ const currency = product?.currency || shop?.currency;
78
+ // Coerce to Number — related/catalog endpoints can return `price` as a string,
79
+ // which would make `sum + p.price` concatenate ("0"+"12"+"110" → 12110)
80
+ // instead of adding.
81
+ const total = bundle
82
+ .filter((p) => isOn(p.id))
83
+ .reduce((sum, p) => sum + (Number(p.price) || 0), 0);
84
+
85
+ if (s.enabled === false) return null;
86
+ // On a product page the PDP buy-box embeds its own FBT (`embedded`), so a
87
+ // standalone section here would be a duplicate. Step aside when a product is
88
+ // in context and we're not the embedded instance. (Off-PDP, `product` is
89
+ // null and the standalone "you might also like" use still renders.)
90
+ if (!s.embedded && product) return null;
91
+ if (bundle.length < 2) return null;
92
+
93
+ async function addBundle() {
94
+ if (pending) return;
95
+ setPending(true);
96
+ try {
97
+ for (const p of bundle) {
98
+ if (!isOn(p.id)) continue;
99
+ await addItem(p.id, p.variants?.[0]?.id, 1);
100
+ }
101
+ openCart();
102
+ } finally {
103
+ setPending(false);
104
+ }
105
+ }
106
+
107
+ const card = (
108
+ <div className="nt-fbt-card">
109
+ <EditableText
110
+ as="h2"
111
+ className="nt-fbt-card__title"
112
+ sectionId={id}
113
+ settingId="title"
114
+ value={s.title ?? "يُشترى عادةً معاً"}
115
+ />
116
+
117
+ <div className="nt-fbt">
118
+ {bundle.map((p, i) => {
119
+ // Tolerate images as ProductImage objects OR raw url strings.
120
+ const raw = p.images?.[0] as unknown;
121
+ const imgUrl =
122
+ typeof raw === "string" ? raw : (raw as { url?: string } | undefined)?.url;
123
+ return (
124
+ <div className="nt-fbt__item" key={p.id}>
125
+ <label className="nt-fbt__card">
126
+ <input
127
+ type="checkbox"
128
+ checked={isOn(p.id)}
129
+ onChange={(e) =>
130
+ setSelected((prev) => ({ ...prev, [p.id]: e.target.checked }))
131
+ }
132
+ />
133
+ <span className="nt-fbt__thumb">
134
+ {imgUrl ? (
135
+ <img
136
+ src={imgUrl}
137
+ alt={p.name}
138
+ loading="lazy"
139
+ onError={(e) => {
140
+ const el = e.target as HTMLImageElement;
141
+ el.style.visibility = "hidden";
142
+ }}
143
+ />
144
+ ) : (
145
+ <span className="nt-card__placeholder" aria-hidden="true" />
146
+ )}
147
+ </span>
148
+ <span className="nt-fbt__info">
149
+ <span className="nt-fbt__name">{p.name}</span>
150
+ <span className="nt-fbt__price">
151
+ {formatMoney(Number(p.price) || 0, currency)}
152
+ </span>
153
+ </span>
154
+ </label>
155
+ {i < bundle.length - 1 ? (
156
+ <span className="nt-fbt__plus" aria-hidden="true">
157
+ +
158
+ </span>
159
+ ) : null}
160
+ </div>
161
+ );
162
+ })}
163
+ </div>
164
+
165
+ <div className="nt-fbt__foot">
166
+ <p className="nt-fbt__total">
167
+ الإجمالي: <strong>{formatMoney(total, currency)}</strong>
168
+ </p>
169
+ <button
170
+ className="nt-btn"
171
+ type="button"
172
+ disabled={pending || total <= 0}
173
+ onClick={addBundle}
174
+ >
175
+ {pending ? "..." : "أضف الكل للسلة"}
176
+ </button>
177
+ </div>
178
+ </div>
179
+ );
180
+
181
+ if (s.embedded) return card;
182
+ return (
183
+ <section className="nt-container" style={{ paddingBlock: "2rem" }}>
184
+ {card}
185
+ </section>
186
+ );
187
+ }
@@ -0,0 +1,133 @@
1
+ import {
2
+ useEffect,
3
+ useState } from "react";
4
+ import { EditableText } from "../lib/EditableText";
5
+ import type { EmpSectionProps } from "../lib/section";
6
+ import { useT } from "../lib/i18n";
7
+
8
+ interface HeroSettings {
9
+ headline?: string;
10
+ subtitle?: string;
11
+ cta_text?: string;
12
+ cta_link?: string;
13
+ image_1?: string;
14
+ image_2?: string;
15
+ image_3?: string;
16
+ autoplay?: boolean;
17
+ }
18
+
19
+ /**
20
+ * Full-bleed hero slideshow — black canvas, cross-fading cover images with a
21
+ * dark bottom gradient, centered headline + pill CTA at the bottom, dots and
22
+ * prev/next arrows. Slides auto-advance on a timer (client-only effect, so the
23
+ * SSR render is deterministic — slide 0 paints first).
24
+ */
25
+ export default function Hero({ id, settings }: EmpSectionProps) {
26
+ const s = settings as HeroSettings;
27
+ const t = useT();
28
+ const headline = s.headline ?? t("Discover the new collection", "اكتشف التشكيلة الجديدة");
29
+ const subtitle = s.subtitle ?? t("Shop now", "تسوق الآن");
30
+ const ctaText = s.cta_text ?? t("Shop", "تسوق");
31
+ const ctaLink = s.cta_link ?? "/products";
32
+
33
+ const slides = [s.image_1, s.image_2, s.image_3].filter(
34
+ (x): x is string => Boolean(x),
35
+ );
36
+ if (slides.length === 0) {
37
+ slides.push(
38
+ "https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=1600",
39
+ );
40
+ }
41
+
42
+ const [current, setCurrent] = useState(0);
43
+ const count = slides.length;
44
+
45
+ useEffect(() => {
46
+ if (s.autoplay === false || count <= 1) return;
47
+ const t = setInterval(() => setCurrent((p) => (p + 1) % count), 6000);
48
+ return () => clearInterval(t);
49
+ }, [count, s.autoplay]);
50
+
51
+ const go = (i: number) => setCurrent(((i % count) + count) % count);
52
+
53
+ return (
54
+ <section className="nt-hero">
55
+ {slides.map((src, i) => (
56
+ <div
57
+ key={i}
58
+ className={`nt-hero__slide${i === current ? " is-active" : ""}`}
59
+ aria-hidden={i !== current}
60
+ >
61
+ <img src={src} alt="" />
62
+ <div className="nt-hero__overlay" />
63
+ </div>
64
+ ))}
65
+
66
+ <div className="nt-hero__content">
67
+ <EditableText
68
+ as="h1"
69
+ className="nt-hero__title"
70
+ sectionId={id}
71
+ settingId="headline"
72
+ value={headline}
73
+ />
74
+ <EditableText
75
+ as="p"
76
+ className="nt-hero__sub"
77
+ sectionId={id}
78
+ settingId="subtitle"
79
+ value={subtitle}
80
+ />
81
+ <a className="nt-hero__cta" href={ctaLink}>
82
+ <EditableText
83
+ as="span"
84
+ sectionId={id}
85
+ settingId="cta_text"
86
+ value={ctaText}
87
+ />
88
+ </a>
89
+
90
+ {count > 1 ? (
91
+ <div className="nt-hero__dots">
92
+ {slides.map((_, i) => (
93
+ <button
94
+ key={i}
95
+ className={`nt-hero__dot${i === current ? " is-active" : ""}`}
96
+ type="button"
97
+ aria-label={`شريحة ${i + 1}`}
98
+ onClick={() => go(i)}
99
+ />
100
+ ))}
101
+ </div>
102
+ ) : null}
103
+ </div>
104
+
105
+ {count > 1 ? (
106
+ <>
107
+ <button
108
+ className="nt-hero__arrow nt-hero__arrow--prev"
109
+ type="button"
110
+ aria-label="السابق"
111
+ onClick={() => go(current - 1)}
112
+ >
113
+ <Chevron dir="start" />
114
+ </button>
115
+ <button
116
+ className="nt-hero__arrow nt-hero__arrow--next"
117
+ type="button"
118
+ aria-label="التالي"
119
+ onClick={() => go(current + 1)}
120
+ >
121
+ <Chevron dir="end" />
122
+ </button>
123
+ </>
124
+ ) : null}
125
+ </section>
126
+ );
127
+ }
128
+
129
+ const Chevron = ({ dir }: { dir: "start" | "end" }) => (
130
+ <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
131
+ {dir === "start" ? <path d="m15 18-6-6 6-6" /> : <path d="m9 18 6-6-6-6" />}
132
+ </svg>
133
+ );
@@ -0,0 +1,105 @@
1
+ import { EditableImage } from "@numueg/theme-sdk";
2
+ import { EditableText } from "../lib/EditableText";
3
+ import type { EmpSectionProps } from "../lib/section";
4
+
5
+ interface IwtSettings {
6
+ eyebrow?: string;
7
+ title?: string;
8
+ subtitle?: string;
9
+ cta_text?: string;
10
+ cta_link?: string;
11
+ image?: string;
12
+ image_position?: "start" | "end" | "background";
13
+ overlay?: boolean;
14
+ }
15
+
16
+ /**
17
+ * Image-with-text editorial block. Two modes:
18
+ * - "background": full-bleed image with a dark overlay and centered copy
19
+ * (used as a page hero, e.g. About / Lookbook).
20
+ * - "start" / "end": side-by-side image + copy column.
21
+ * Title/subtitle/CTA + the image are inline-editable.
22
+ */
23
+ export default function ImageWithText({ id, settings }: EmpSectionProps) {
24
+ const s = settings as IwtSettings;
25
+ const pos = s.image_position ?? "start";
26
+ const image =
27
+ s.image ||
28
+ "https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=1600";
29
+ const hasCta = Boolean(s.cta_text);
30
+
31
+ const Copy = (
32
+ <div className="nt-iwt__copy">
33
+ {s.eyebrow ? (
34
+ <EditableText
35
+ as="p"
36
+ className="nt-label"
37
+ sectionId={id}
38
+ settingId="eyebrow"
39
+ value={s.eyebrow}
40
+ />
41
+ ) : null}
42
+ <EditableText
43
+ as="h2"
44
+ className="nt-heading"
45
+ sectionId={id}
46
+ settingId="title"
47
+ value={s.title ?? "قصة علامتنا"}
48
+ />
49
+ {s.subtitle ? (
50
+ <EditableText
51
+ as="p"
52
+ className="nt-iwt__sub"
53
+ sectionId={id}
54
+ settingId="subtitle"
55
+ value={s.subtitle}
56
+ />
57
+ ) : null}
58
+ {hasCta ? (
59
+ <a
60
+ className={pos === "background" ? "nt-btn-light" : "nt-btn"}
61
+ href={s.cta_link || "/products"}
62
+ >
63
+ <EditableText
64
+ as="span"
65
+ sectionId={id}
66
+ settingId="cta_text"
67
+ value={s.cta_text as string}
68
+ />
69
+ </a>
70
+ ) : null}
71
+ </div>
72
+ );
73
+
74
+ if (pos === "background") {
75
+ return (
76
+ <section className="nt-iwt nt-iwt--bg">
77
+ <EditableImage
78
+ className="nt-iwt__bgimg"
79
+ sectionId={id}
80
+ settingId="image"
81
+ src={image}
82
+ alt=""
83
+ />
84
+ {s.overlay !== false ? <div className="nt-iwt__overlay" /> : null}
85
+ <div className="nt-container nt-iwt__bgcontent">{Copy}</div>
86
+ </section>
87
+ );
88
+ }
89
+
90
+ return (
91
+ <section className="nt-section nt-bg-white">
92
+ <div className={`nt-container nt-iwt nt-iwt--${pos}`}>
93
+ <div className="nt-iwt__media">
94
+ <EditableImage
95
+ sectionId={id}
96
+ settingId="image"
97
+ src={image}
98
+ alt={(s.title as string) || ""}
99
+ />
100
+ </div>
101
+ {Copy}
102
+ </div>
103
+ </section>
104
+ );
105
+ }
@@ -0,0 +1,45 @@
1
+ import { useShop } from "@numueg/theme-sdk";
2
+ import { EditableText } from "../lib/EditableText";
3
+ import type { EmpSectionProps } from "../lib/section";
4
+ import { useT } from "../lib/i18n";
5
+
6
+ interface MarqueeSettings {
7
+ text?: string;
8
+ repeat?: number;
9
+ }
10
+
11
+ /** Black scrolling ticker — bold uppercase text alternating with the store
12
+ * name, separated by dots. Pure CSS animation (respects reduced-motion). */
13
+ export default function Marquee({ id, settings }: EmpSectionProps) {
14
+ const s = settings as MarqueeSettings;
15
+ const t = useT();
16
+ const shop = useShop();
17
+ const text = s.text ?? t("100% Independent", "100% مستقل");
18
+ const storeName = shop?.name || "STORE";
19
+ const repeat = Math.max(4, Math.min(20, (s.repeat as number) || 10));
20
+
21
+ const items = Array.from({ length: repeat }, (_, i) => (
22
+ <span className="nt-marquee__item" key={i}>
23
+ <span className="nt-marquee__text">{text}</span>
24
+ <span className="nt-marquee__dot">●</span>
25
+ <span className="nt-marquee__sub">{storeName}</span>
26
+ <span className="nt-marquee__dot">●</span>
27
+ </span>
28
+ ));
29
+
30
+ return (
31
+ <div className="nt-marquee">
32
+ <EditableText
33
+ as="span"
34
+ sectionId={id}
35
+ settingId="text"
36
+ value={text}
37
+ style={{ display: "none" }}
38
+ />
39
+ <div className="nt-marquee__track">
40
+ {items}
41
+ {items}
42
+ </div>
43
+ </div>
44
+ );
45
+ }
@@ -0,0 +1,79 @@
1
+ import {
2
+ useState } from "react";
3
+ import { EditableText } from "../lib/EditableText";
4
+ import type { EmpSectionProps } from "../lib/section";
5
+ import { useT } from "../lib/i18n";
6
+
7
+ interface NewsletterSettings {
8
+ title?: string;
9
+ subtitle?: string;
10
+ button_text?: string;
11
+ placeholder?: string;
12
+ }
13
+
14
+ /** Black newsletter block — centered uppercase headline, muted subtitle and a
15
+ * pill email field + light submit button. Submission is handled client-side
16
+ * (swap in the host's marketing endpoint when wiring a real list). */
17
+ export default function Newsletter({ id, settings }: EmpSectionProps) {
18
+ const s = settings as NewsletterSettings;
19
+ const t = useT();
20
+ const title = s.title ?? t("Join our newsletter", "اشترك في نشرتنا");
21
+ const subtitle = s.subtitle ?? t("Be first to hear about offers and new arrivals", "اعرف أول واحد عن العروض والمنتجات الجديدة");
22
+ const buttonText = s.button_text ?? t("Subscribe", "اشترك");
23
+ const placeholder = s.placeholder ?? t("Email address", "البريد الإلكتروني");
24
+
25
+ const [email, setEmail] = useState("");
26
+ const [submitted, setSubmitted] = useState(false);
27
+
28
+ const onSubmit = (e: React.FormEvent) => {
29
+ e.preventDefault();
30
+ if (email) setSubmitted(true);
31
+ };
32
+
33
+ return (
34
+ <section className="nt-news">
35
+ <div className="nt-container">
36
+ <div className="nt-news__inner">
37
+ <EditableText
38
+ as="h2"
39
+ className="nt-news__title"
40
+ sectionId={id}
41
+ settingId="title"
42
+ value={title}
43
+ />
44
+ <EditableText
45
+ as="p"
46
+ className="nt-news__sub"
47
+ sectionId={id}
48
+ settingId="subtitle"
49
+ value={subtitle}
50
+ />
51
+
52
+ {submitted ? (
53
+ <p style={{ fontWeight: 600 }}>شكراً لاشتراكك! 🎉</p>
54
+ ) : (
55
+ <form className="nt-news__form" onSubmit={onSubmit}>
56
+ <input
57
+ className="nt-news__input"
58
+ type="email"
59
+ dir="ltr"
60
+ required
61
+ value={email}
62
+ placeholder={placeholder}
63
+ onChange={(e) => setEmail(e.target.value)}
64
+ />
65
+ <button className="nt-btn-light" type="submit">
66
+ <EditableText
67
+ as="span"
68
+ sectionId={id}
69
+ settingId="button_text"
70
+ value={buttonText}
71
+ />
72
+ </button>
73
+ </form>
74
+ )}
75
+ </div>
76
+ </div>
77
+ </section>
78
+ );
79
+ }