@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,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
|
+
}
|