@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,244 @@
1
+ /**
2
+ * Local dev harness for `numu-theme dev` (plain Vite + index.html).
3
+ *
4
+ * The marketplace bundle's real entry is `main.tsx` (mount/createApp). Here we
5
+ * call that same `mount(el, ctx)` with a mock Arabic store, catalog and cart so
6
+ * the full provider tree (cart, shop, page, localization, direction) is live —
7
+ * every section renders exactly as it will inside the storefront, including the
8
+ * live "Add to cart" → drawer flow. This file is NOT part of the built theme.js.
9
+ *
10
+ * `themeSettings` is sent with EMPTY templates/section_groups on purpose so the
11
+ * bundle exercises its own theme.json presets (the marketplace-preview path).
12
+ */
13
+ import { mount } from "./main";
14
+ import type { Product, Store, Cart, Collection } from "@numueg/theme-sdk";
15
+
16
+ const img = (seed: string) => ({
17
+ id: seed,
18
+ url: `https://picsum.photos/seed/${seed}/1000/1000`,
19
+ alt: seed,
20
+ position: 0,
21
+ });
22
+
23
+ const products: Product[] = [
24
+ {
25
+ id: "p1",
26
+ name: "تيشيرت قطن ثقيل",
27
+ slug: "heavy-tee",
28
+ description: "تيشيرت قطن 280 جرام، قصة واسعة ولمسة ناعمة.",
29
+ price: 450,
30
+ compare_at_price: 600,
31
+ currency: "EGP",
32
+ images: [img("tee"), img("tee2")],
33
+ category: "ملابس",
34
+ tags: ["جديد"],
35
+ options: [{ name: "المقاس", position: 0, values: ["S", "M", "L", "XL"] }],
36
+ variants: [
37
+ { id: "p1-s", position: 0, option_values: { المقاس: "S" }, price: 450, inventory_quantity: 5, is_in_stock: true },
38
+ { id: "p1-m", position: 1, option_values: { المقاس: "M" }, price: 450, inventory_quantity: 5, is_in_stock: true },
39
+ { id: "p1-l", position: 2, option_values: { المقاس: "L" }, price: 450, inventory_quantity: 0, is_in_stock: false },
40
+ { id: "p1-xl", position: 3, option_values: { المقاس: "XL" }, price: 450, inventory_quantity: 3, is_in_stock: true },
41
+ ],
42
+ in_stock: true,
43
+ attributes: {
44
+ size_chart: {
45
+ mode: "custom",
46
+ unit: "cm",
47
+ column_headers: ["الصدر", "الطول", "الكتف"],
48
+ rows: [
49
+ { size: "S", values: ["96", "70", "42"] },
50
+ { size: "M", values: ["102", "72", "44"] },
51
+ { size: "L", values: ["108", "74", "46"] },
52
+ { size: "XL", values: ["114", "76", "48"] },
53
+ ],
54
+ notes: "القياسات بالسنتيمتر، بهامش ±1 سم.",
55
+ },
56
+ },
57
+ } as Product,
58
+ {
59
+ id: "p2",
60
+ name: "حقيبة جلد طبيعي",
61
+ slug: "leather-bag",
62
+ description: "حقيبة جلد طبيعي بخامة فاخرة تدوم لسنوات.",
63
+ price: 1250,
64
+ currency: "EGP",
65
+ images: [img("bag")],
66
+ category: "إكسسوارات",
67
+ options: [],
68
+ variants: [
69
+ { id: "p2-o", position: 0, option_values: {}, price: 1250, inventory_quantity: 8, is_in_stock: true },
70
+ ],
71
+ in_stock: true,
72
+ },
73
+ {
74
+ id: "p3",
75
+ name: "حذاء رياضي أبيض",
76
+ slug: "white-sneakers",
77
+ description: "حذاء رياضي كلاسيكي بتصميم نظيف يناسب كل الإطلالات.",
78
+ price: 980,
79
+ compare_at_price: 1200,
80
+ currency: "EGP",
81
+ images: [img("shoe")],
82
+ category: "أحذية",
83
+ tags: ["الأكثر مبيعاً"],
84
+ options: [{ name: "المقاس", position: 0, values: ["40", "41", "42", "43"] }],
85
+ variants: [
86
+ { id: "p3-40", position: 0, option_values: { المقاس: "40" }, price: 980, inventory_quantity: 4, is_in_stock: true },
87
+ { id: "p3-41", position: 1, option_values: { المقاس: "41" }, price: 980, inventory_quantity: 4, is_in_stock: true },
88
+ { id: "p3-42", position: 2, option_values: { المقاس: "42" }, price: 980, inventory_quantity: 2, is_in_stock: true },
89
+ { id: "p3-43", position: 3, option_values: { المقاس: "43" }, price: 980, inventory_quantity: 0, is_in_stock: false },
90
+ ],
91
+ in_stock: true,
92
+ },
93
+ {
94
+ id: "p4",
95
+ name: "نظارة شمسية",
96
+ slug: "sunglasses",
97
+ description: "نظارة شمسية بإطار معدني خفيف وعدسات مستقطبة.",
98
+ price: 520,
99
+ currency: "EGP",
100
+ images: [img("glasses")],
101
+ category: "إكسسوارات",
102
+ options: [],
103
+ variants: [
104
+ { id: "p4-o", position: 0, option_values: {}, price: 520, inventory_quantity: 12, is_in_stock: true },
105
+ ],
106
+ in_stock: true,
107
+ },
108
+ {
109
+ id: "p5",
110
+ name: "ساعة يد كلاسيكية",
111
+ slug: "classic-watch",
112
+ description: "ساعة يد بتصميم أنيق وحزام جلد.",
113
+ price: 1800,
114
+ currency: "EGP",
115
+ images: [img("watch")],
116
+ category: "إكسسوارات",
117
+ options: [],
118
+ variants: [
119
+ { id: "p5-o", position: 0, option_values: {}, price: 1800, inventory_quantity: 6, is_in_stock: true },
120
+ ],
121
+ in_stock: true,
122
+ },
123
+ ];
124
+
125
+ const collections: Collection[] = [
126
+ { id: "c1", name: "ملابس", slug: "clothing", product_count: 1, image_url: img("clothing").url, products },
127
+ { id: "c2", name: "إكسسوارات", slug: "accessories", product_count: 3, image_url: img("acc").url, products },
128
+ { id: "c3", name: "أحذية", slug: "shoes", product_count: 1, image_url: img("shoesc").url, products },
129
+ { id: "c4", name: "وصل حديثاً", slug: "new", product_count: 2, image_url: img("new").url, products },
130
+ { id: "c5", name: "تخفيضات", slug: "sale", product_count: 2, image_url: img("sale").url, products },
131
+ ];
132
+
133
+ const store: Store = {
134
+ id: "dev-store",
135
+ name: "إمباير",
136
+ slug: "nt",
137
+ domain: "localhost",
138
+ currency: "EGP",
139
+ default_language: "ar",
140
+ use_nextjs_storefront: true,
141
+ };
142
+
143
+ const initialCart: Cart = {
144
+ id: "dev-cart",
145
+ items: [],
146
+ subtotal: 0,
147
+ total: 0,
148
+ currency: "EGP",
149
+ };
150
+
151
+ const themeSettings = {
152
+ schema_version: 3,
153
+ theme_id: "__THEME_ID__",
154
+ global_settings: {},
155
+ templates: {},
156
+ section_groups: {},
157
+ } as any;
158
+
159
+ if (typeof window !== "undefined") {
160
+ window.requestAnimationFrame(() => {
161
+ const path = window.location.pathname;
162
+ const params = new URLSearchParams(window.location.search);
163
+
164
+ let template = params.get("template") || "home";
165
+ let slug: string | null = params.get("slug");
166
+ if (path.startsWith("/products/")) {
167
+ template = "product";
168
+ slug = decodeURIComponent(
169
+ path.slice("/products/".length).replace(/\/$/, ""),
170
+ );
171
+ } else if (path === "/products") {
172
+ template = "products";
173
+ } else if (path.startsWith("/collections/")) {
174
+ template = "collection";
175
+ } else if (path.startsWith("/cart")) {
176
+ template = "cart";
177
+ } else if (path.startsWith("/order")) {
178
+ template = "order-confirmation";
179
+ }
180
+
181
+ const activeProduct = products.find((p) => p.slug === slug) ?? products[0];
182
+
183
+ const pageByTemplate: Record<string, any> = {
184
+ home: { type: "home", title: "الرئيسية", data: { products, collections } },
185
+ product: {
186
+ type: "product",
187
+ title: activeProduct.name,
188
+ handle: activeProduct.slug,
189
+ data: { product: activeProduct, products },
190
+ },
191
+ products: {
192
+ type: "products",
193
+ title: "كل المنتجات",
194
+ data: { products, collections },
195
+ },
196
+ collection: {
197
+ type: "collection",
198
+ title: "المجموعة",
199
+ data: { collection: collections[0], products },
200
+ },
201
+ cart: { type: "cart", title: "السلة", data: {} },
202
+ "order-confirmation": {
203
+ type: "order-confirmation",
204
+ title: "تأكيد الطلب",
205
+ data: {},
206
+ },
207
+ };
208
+
209
+ // Dev-only template switcher, mounted OUTSIDE the theme root.
210
+ const bar = document.createElement("div");
211
+ bar.style.cssText =
212
+ "position:fixed;bottom:12px;left:50%;transform:translateX(-50%);z-index:9999;display:flex;gap:4px;padding:6px;background:#000;border-radius:999px;font:500 12px sans-serif;box-shadow:0 8px 24px rgba(0,0,0,.3)";
213
+ const navItems: Array<[string, string, string]> = [
214
+ ["الرئيسية", "/", "home"],
215
+ ["منتج", `/products/${products[0].slug}`, "product"],
216
+ ["المنتجات", "/products", "products"],
217
+ ["مجموعة", "/collections/clothing", "collection"],
218
+ ["السلة", "/cart", "cart"],
219
+ ];
220
+ for (const [label, href, tpl] of navItems) {
221
+ const a = document.createElement("a");
222
+ a.href = href;
223
+ a.textContent = label;
224
+ const active = tpl === template;
225
+ a.style.cssText = `padding:5px 12px;border-radius:999px;text-decoration:none;color:${active ? "#000" : "#e7e2d8"};background:${active ? "#e7e2d8" : "transparent"}`;
226
+ bar.appendChild(a);
227
+ }
228
+ document.body.appendChild(bar);
229
+
230
+ const root = document.getElementById("root");
231
+ if (root) {
232
+ mount(root, {
233
+ store,
234
+ themeSettings,
235
+ currentTemplate: template,
236
+ initialCart,
237
+ initialProducts: products,
238
+ initialCollections: collections,
239
+ locale: "ar",
240
+ page: pageByTemplate[template] ?? pageByTemplate.home,
241
+ } as any);
242
+ }
243
+ });
244
+ }
@@ -0,0 +1,90 @@
1
+ import { useState } from "react";
2
+ import { useCart } from "@numueg/theme-sdk";
3
+
4
+ /**
5
+ * Coupon / discount-code input wired to the live cart. Applies via the SDK's
6
+ * `applyDiscount` (which re-fetches the cart with the dashboard-computed
7
+ * discount); once a code is on the cart it shows an "applied" chip with a
8
+ * remove action. Shared by the cart drawer and the full cart page.
9
+ */
10
+ export function CouponForm({ compact = false }: { compact?: boolean }) {
11
+ const { cart, applyDiscount, removeDiscount, loading } = useCart();
12
+ const [code, setCode] = useState("");
13
+ const [error, setError] = useState<string | null>(null);
14
+ const [busy, setBusy] = useState(false);
15
+
16
+ const applied = cart?.discount_code;
17
+
18
+ async function submit(e: React.FormEvent) {
19
+ e.preventDefault();
20
+ const value = code.trim();
21
+ if (!value || busy || loading) return;
22
+ setError(null);
23
+ setBusy(true);
24
+ try {
25
+ await applyDiscount(value);
26
+ setCode("");
27
+ } catch {
28
+ setError("تعذّر تطبيق الكود. تأكد من صحته وحاول مرة أخرى.");
29
+ } finally {
30
+ setBusy(false);
31
+ }
32
+ }
33
+
34
+ async function remove() {
35
+ if (busy || loading) return;
36
+ setBusy(true);
37
+ try {
38
+ await removeDiscount();
39
+ } finally {
40
+ setBusy(false);
41
+ }
42
+ }
43
+
44
+ if (applied) {
45
+ return (
46
+ <div className={`nt-coupon nt-coupon--applied${compact ? " is-compact" : ""}`}>
47
+ <span className="nt-coupon__tag">
48
+ كود الخصم: <strong>{applied}</strong>
49
+ </span>
50
+ <button
51
+ type="button"
52
+ className="nt-coupon__remove"
53
+ onClick={remove}
54
+ disabled={busy || loading}
55
+ >
56
+ إزالة
57
+ </button>
58
+ </div>
59
+ );
60
+ }
61
+
62
+ return (
63
+ <form
64
+ className={`nt-coupon${compact ? " is-compact" : ""}`}
65
+ onSubmit={submit}
66
+ >
67
+ <div className="nt-coupon__row">
68
+ <input
69
+ className="nt-input"
70
+ type="text"
71
+ value={code}
72
+ placeholder="كود الخصم"
73
+ aria-label="كود الخصم"
74
+ onChange={(e) => {
75
+ setCode(e.target.value);
76
+ if (error) setError(null);
77
+ }}
78
+ />
79
+ <button
80
+ className="nt-btn-outline"
81
+ type="submit"
82
+ disabled={busy || loading || !code.trim()}
83
+ >
84
+ {busy ? "..." : "تطبيق"}
85
+ </button>
86
+ </div>
87
+ {error ? <p className="nt-coupon__err">{error}</p> : null}
88
+ </form>
89
+ );
90
+ }
@@ -0,0 +1,178 @@
1
+ "use client";
2
+
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useRef,
7
+ useState,
8
+ type CSSProperties,
9
+ type ElementType,
10
+ type ReactNode,
11
+ } from "react";
12
+
13
+ /**
14
+ * Empire's in-place inline text editor (Canva-style), drop-in compatible with
15
+ * the SDK's <EditableText> API so sections keep `as` / `sectionId` /
16
+ * `settingId` / `value`.
17
+ *
18
+ * Why local instead of the SDK component: the host PreviewBridge only does
19
+ * section click-to-select; the SDK <EditableText> just posts
20
+ * `numu:editor:select-field` (focuses the side-panel field) — no in-place edit.
21
+ * This component restores the proven Empire behavior:
22
+ * - Inside the customizer iframe (or `?editor=` flag) the text shows a gold
23
+ * dashed frame on hover; click → contenteditable + select-all.
24
+ * - Blur / Enter (single-line) posts `numu:editor:inline-edit`
25
+ * {sectionId, blockId?, key, value}; the hub patches the draft and the
26
+ * iframe re-renders with the persisted value. Escape cancels.
27
+ * - On the public storefront (top-level window) it's inert — plain text.
28
+ */
29
+ export interface EditableTextProps {
30
+ sectionId: string;
31
+ blockId?: string | null;
32
+ groupId?: string;
33
+ settingId: string;
34
+ value: string | undefined | null;
35
+ as?: ElementType;
36
+ /** Force multi-line (Enter inserts newline instead of committing). Defaults
37
+ * to true for block tags (p/div), false for inline/heading tags. */
38
+ multiline?: boolean;
39
+ className?: string;
40
+ style?: CSSProperties;
41
+ placeholder?: ReactNode;
42
+ /** Accepted for SDK API parity; inline editing always edits plain text. */
43
+ html?: boolean;
44
+ }
45
+
46
+ function isInsideEditor(): boolean {
47
+ if (typeof window === "undefined") return false;
48
+ try {
49
+ if (new URLSearchParams(window.location.search).get("editor")) return true;
50
+ } catch {
51
+ /* ignore */
52
+ }
53
+ return typeof window !== "undefined" && window.parent !== window;
54
+ }
55
+
56
+ const BLOCK_TAGS = new Set(["p", "div", "blockquote", "li"]);
57
+
58
+ export function EditableText({
59
+ sectionId,
60
+ blockId,
61
+ groupId,
62
+ settingId,
63
+ value,
64
+ as,
65
+ multiline,
66
+ className,
67
+ style,
68
+ placeholder,
69
+ }: EditableTextProps) {
70
+ const Tag = (as ?? "span") as ElementType;
71
+ const ref = useRef<HTMLElement | null>(null);
72
+ const [editing, setEditing] = useState(false);
73
+ const [inEditor, setInEditor] = useState(false);
74
+ const isMultiline =
75
+ multiline ?? (typeof Tag === "string" && BLOCK_TAGS.has(Tag));
76
+
77
+ useEffect(() => {
78
+ setInEditor(isInsideEditor());
79
+ }, []);
80
+
81
+ const commit = useCallback(
82
+ (next: string) => {
83
+ setEditing(false);
84
+ if (ref.current) ref.current.removeAttribute("contenteditable");
85
+ const trimmed = isMultiline ? next : next.replace(/\s+/g, " ").trim();
86
+ if (trimmed === (value ?? "")) return;
87
+ if (typeof window === "undefined") return;
88
+ try {
89
+ window.parent.postMessage(
90
+ {
91
+ type: "numu:editor:inline-edit",
92
+ payload: { sectionId, blockId, groupId, key: settingId, value: trimmed },
93
+ },
94
+ "*",
95
+ );
96
+ } catch {
97
+ /* ignore */
98
+ }
99
+ },
100
+ [sectionId, blockId, groupId, settingId, value, isMultiline],
101
+ );
102
+
103
+ const cancel = useCallback(() => {
104
+ setEditing(false);
105
+ if (ref.current) {
106
+ ref.current.removeAttribute("contenteditable");
107
+ ref.current.textContent = value ?? "";
108
+ }
109
+ }, [value]);
110
+
111
+ const startEditing = useCallback(
112
+ (e: React.MouseEvent) => {
113
+ if (!inEditor) return;
114
+ e.preventDefault();
115
+ e.stopPropagation();
116
+ const el = ref.current;
117
+ if (!el || typeof window === "undefined" || typeof document === "undefined")
118
+ return;
119
+ el.setAttribute("contenteditable", "true");
120
+ el.focus();
121
+ const sel = window.getSelection();
122
+ if (sel) {
123
+ const range = document.createRange();
124
+ range.selectNodeContents(el);
125
+ sel.removeAllRanges();
126
+ sel.addRange(range);
127
+ }
128
+ setEditing(true);
129
+ },
130
+ [inEditor],
131
+ );
132
+
133
+ const onKeyDown = useCallback(
134
+ (e: React.KeyboardEvent<HTMLElement>) => {
135
+ if (!editing) return;
136
+ if (e.key === "Escape") {
137
+ e.preventDefault();
138
+ cancel();
139
+ ref.current?.blur();
140
+ } else if (e.key === "Enter" && !isMultiline && !e.shiftKey) {
141
+ e.preventDefault();
142
+ commit(ref.current?.textContent ?? "");
143
+ ref.current?.blur();
144
+ }
145
+ },
146
+ [editing, isMultiline, commit, cancel],
147
+ );
148
+
149
+ const onBlur = useCallback(() => {
150
+ if (!editing) return;
151
+ commit(ref.current?.textContent ?? "");
152
+ }, [editing, commit]);
153
+
154
+ const composed = [
155
+ className,
156
+ inEditor ? "nt-inline-edit nt-inline-edit--armed" : null,
157
+ editing ? "nt-inline-edit--editing" : null,
158
+ ]
159
+ .filter(Boolean)
160
+ .join(" ");
161
+
162
+ return (
163
+ <Tag
164
+ ref={ref as React.Ref<HTMLElement & HTMLDivElement>}
165
+ className={composed || undefined}
166
+ style={style}
167
+ data-numu-inline-section={sectionId}
168
+ data-numu-inline-key={settingId}
169
+ data-numu-inline-block={blockId || undefined}
170
+ onClick={inEditor ? startEditing : undefined}
171
+ onKeyDown={onKeyDown}
172
+ onBlur={onBlur}
173
+ suppressContentEditableWarning
174
+ >
175
+ {value || placeholder || null}
176
+ </Tag>
177
+ );
178
+ }
@@ -0,0 +1,99 @@
1
+ import { useState } from "react";
2
+ import {
3
+ useCart,
4
+ useLocalization,
5
+ useShop,
6
+ type Product,
7
+ } from "@numueg/theme-sdk";
8
+ import { openCart } from "./cartUI";
9
+
10
+ /**
11
+ * Empire product card — monochrome, square media with a hover "quick add"
12
+ * pill, a category badge (top-start) and a discount badge (accent blue,
13
+ * top-end). Shared by the featured rail and the products grid so the card
14
+ * looks identical everywhere. Writes to the live SDK cart and pops the drawer.
15
+ */
16
+ export function ProductCard({ product }: { product: Product }) {
17
+ const { addItem } = useCart();
18
+ const { formatMoney } = useLocalization();
19
+ const shop = useShop();
20
+ const [pending, setPending] = useState(false);
21
+
22
+ const image = product.images?.[0];
23
+ const currency = product.currency || shop?.currency;
24
+ // Coerce — some endpoints return price/compare_at_price as strings, which
25
+ // breaks numeric comparison (lexicographic) and arithmetic.
26
+ const priceNum = Number(product.price) || 0;
27
+ const compareNum = Number(product.compare_at_price) || 0;
28
+ const price = formatMoney(priceNum, currency);
29
+ const hasCompare = compareNum > priceNum;
30
+ const compareAt = hasCompare ? formatMoney(compareNum, currency) : null;
31
+ const discountPct = hasCompare
32
+ ? Math.round((1 - priceNum / compareNum) * 100)
33
+ : 0;
34
+ const categoryBadge = product.tags?.[0] || product.category;
35
+ const variantId = product.variants?.[0]?.id;
36
+ const href = `/products/${product.slug}`;
37
+
38
+ async function handleAdd(e: React.MouseEvent) {
39
+ e.preventDefault();
40
+ if (pending || !product.in_stock) return;
41
+ setPending(true);
42
+ try {
43
+ await addItem(product.id, variantId, 1);
44
+ openCart();
45
+ } finally {
46
+ setPending(false);
47
+ }
48
+ }
49
+
50
+ return (
51
+ <article className="nt-card">
52
+ <div className="nt-card__media">
53
+ <a href={href} aria-label={product.name}>
54
+ {image?.url ? (
55
+ <img
56
+ src={image.url}
57
+ alt={image.alt || product.name}
58
+ loading="lazy"
59
+ onError={(e) => {
60
+ (e.target as HTMLImageElement).style.display = "none";
61
+ }}
62
+ />
63
+ ) : (
64
+ <div className="nt-card__placeholder" aria-hidden="true" />
65
+ )}
66
+ </a>
67
+ {categoryBadge ? (
68
+ <span className="nt-badge">{categoryBadge}</span>
69
+ ) : null}
70
+ {discountPct > 0 ? (
71
+ <span className="nt-badge nt-badge--blue">-{discountPct}%</span>
72
+ ) : null}
73
+ {product.in_stock ? (
74
+ <button
75
+ className="nt-card__add"
76
+ type="button"
77
+ disabled={pending}
78
+ onClick={handleAdd}
79
+ >
80
+ {pending ? "..." : "أضف للسلة"}
81
+ </button>
82
+ ) : (
83
+ <span className="nt-card__add" style={{ opacity: 1 }}>
84
+ نفذ المخزون
85
+ </span>
86
+ )}
87
+ </div>
88
+ <a href={href} className="nt-card__body">
89
+ <h3 className="nt-card__name">{product.name}</h3>
90
+ <p className="nt-card__price">
91
+ {price}
92
+ {compareAt ? (
93
+ <span className="nt-card__compare">{compareAt}</span>
94
+ ) : null}
95
+ </p>
96
+ </a>
97
+ </article>
98
+ );
99
+ }
@@ -0,0 +1,43 @@
1
+ import { useSyncExternalStore } from "react";
2
+
3
+ /**
4
+ * Tiny cross-section UI store for the slide-over cart drawer.
5
+ *
6
+ * Sections render as independent components inside the host's React tree, so
7
+ * the Header (which owns the drawer) and any product card's "Add" button can't
8
+ * share React state directly. They DO share this module, so a module-level
9
+ * external store is the clean bridge — no window globals, no context plumbing,
10
+ * SSR-safe via a constant server snapshot.
11
+ */
12
+ let open = false;
13
+ const listeners = new Set<() => void>();
14
+
15
+ function emit() {
16
+ for (const l of listeners) l();
17
+ }
18
+
19
+ export function openCart() {
20
+ if (open) return;
21
+ open = true;
22
+ emit();
23
+ }
24
+
25
+ export function closeCart() {
26
+ if (!open) return;
27
+ open = false;
28
+ emit();
29
+ }
30
+
31
+ function subscribe(cb: () => void) {
32
+ listeners.add(cb);
33
+ return () => listeners.delete(cb);
34
+ }
35
+
36
+ /** React hook: is the drawer open? Re-renders subscribers on toggle. */
37
+ export function useCartOpen(): boolean {
38
+ return useSyncExternalStore(
39
+ subscribe,
40
+ () => open,
41
+ () => false, // server snapshot — drawer is always closed on first paint
42
+ );
43
+ }
@@ -0,0 +1,17 @@
1
+ import { useLocale } from "@numueg/theme-sdk";
2
+
3
+ /**
4
+ * Locale-aware string picker for the theme's own chrome (nav, buttons, empty
5
+ * states, etc.) so the store reads correctly in both English (LTR) and Arabic
6
+ * (RTL). Merchant-authored content comes from settings; this only covers the
7
+ * fixed UI strings the theme ships.
8
+ *
9
+ * const t = useT();
10
+ * t("Add to cart", "أضف إلى السلة")
11
+ */
12
+ export function useT(): (en: string, ar: string) => string {
13
+ const locale = useLocale();
14
+ const isAr =
15
+ typeof locale === "string" && locale.toLowerCase().startsWith("ar");
16
+ return (en, ar) => (isAr ? ar : en);
17
+ }
@@ -0,0 +1,12 @@
1
+ import type { SectionProps } from "@numueg/theme-sdk";
2
+
3
+ /**
4
+ * `main.tsx` passes each section component its instance `id` and `type` in
5
+ * addition to the SDK's `SectionProps` (settings/blocks/blockOrder). The SDK
6
+ * type doesn't declare those, so we widen it here — the `id` is what
7
+ * `<EditableText sectionId=…>` / `<EditableImage>` need for inline editing.
8
+ */
9
+ export interface EmpSectionProps extends SectionProps {
10
+ id: string;
11
+ type?: string;
12
+ }