@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
package/dist/index.js
CHANGED
|
@@ -878,359 +878,80 @@ function detectAuthor() {
|
|
|
878
878
|
if (name) return name;
|
|
879
879
|
return "Theme Author";
|
|
880
880
|
}
|
|
881
|
-
|
|
882
|
-
const
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
881
|
+
function scaffoldRoot() {
|
|
882
|
+
const candidates = [
|
|
883
|
+
path.join(__dirname, "..", "templates", "scaffold"),
|
|
884
|
+
path.join(__dirname, "..", "..", "templates", "scaffold")
|
|
885
|
+
];
|
|
886
|
+
for (const c of candidates) {
|
|
887
|
+
if (fs.existsSync(c)) return c;
|
|
886
888
|
}
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
"src/sections",
|
|
890
|
-
"src/blocks",
|
|
891
|
-
"schemas/sections",
|
|
892
|
-
"schemas/blocks",
|
|
893
|
-
"assets"
|
|
894
|
-
]) {
|
|
895
|
-
fs.mkdirSync(path.join(dir, d), { recursive: true });
|
|
896
|
-
}
|
|
897
|
-
fs.writeFileSync(
|
|
898
|
-
path.join(dir, "theme.json"),
|
|
899
|
-
JSON.stringify(
|
|
900
|
-
{
|
|
901
|
-
id: name.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
|
|
902
|
-
name,
|
|
903
|
-
author: detectAuthor(),
|
|
904
|
-
version: "0.1.0",
|
|
905
|
-
layout: "single-column",
|
|
906
|
-
description: `${name} NUMU theme`,
|
|
907
|
-
// Phase 7.3 — static BYOT templates for chrome that renders
|
|
908
|
-
// outside the React tree (the streaming loading skeleton +
|
|
909
|
-
// the client error boundary). Themes that want to fully
|
|
910
|
-
// own those moments edit these HTML files; absent or 404
|
|
911
|
-
// → the platform's hardcoded fallback renders.
|
|
912
|
-
error_template: "templates/error.html",
|
|
913
|
-
loading_template: "templates/loading.html",
|
|
914
|
-
presets: {
|
|
915
|
-
templates: {
|
|
916
|
-
home: {
|
|
917
|
-
name: "Home",
|
|
918
|
-
sections: [
|
|
919
|
-
{
|
|
920
|
-
type: "hero",
|
|
921
|
-
settings: { headline: "Welcome to our store" }
|
|
922
|
-
}
|
|
923
|
-
]
|
|
924
|
-
}
|
|
925
|
-
}
|
|
926
|
-
}
|
|
927
|
-
},
|
|
928
|
-
null,
|
|
929
|
-
2
|
|
930
|
-
)
|
|
931
|
-
);
|
|
932
|
-
fs.mkdirSync(path.join(dir, "templates"), { recursive: true });
|
|
933
|
-
fs.writeFileSync(
|
|
934
|
-
path.join(dir, "templates/error.html"),
|
|
935
|
-
`<!--
|
|
936
|
-
Static BYOT error template \u2014 rendered by the storefront when a
|
|
937
|
-
page throws. No JS available (the bundle might be the thing that
|
|
938
|
-
failed). Use <button data-numu-reset> to expose a retry button \u2014
|
|
939
|
-
the storefront wires the click for you.
|
|
940
|
-
-->
|
|
941
|
-
<main role="alert" style="min-height:100vh;display:flex;align-items:center;justify-content:center;padding:1rem;font-family:system-ui">
|
|
942
|
-
<div style="text-align:center;max-width:28rem">
|
|
943
|
-
<h1 style="font-size:1.5rem;font-weight:700;color:#b91c1c">Something went wrong</h1>
|
|
944
|
-
<p style="color:#374151;margin-top:.5rem">Please try again in a moment.</p>
|
|
945
|
-
<button data-numu-reset type="button" style="margin-top:1.5rem;padding:.5rem 1rem;background:#1d4ed8;color:white;border-radius:.375rem;border:0;cursor:pointer">Try again</button>
|
|
946
|
-
</div>
|
|
947
|
-
</main>
|
|
948
|
-
`
|
|
949
|
-
);
|
|
950
|
-
fs.writeFileSync(
|
|
951
|
-
path.join(dir, "templates/loading.html"),
|
|
952
|
-
`<!-- Static BYOT loading skeleton. -->
|
|
953
|
-
<div role="status" aria-live="polite" aria-label="Loading" style="min-height:100vh;display:flex;align-items:center;justify-content:center;padding:1rem;font-family:system-ui">
|
|
954
|
-
<div style="display:flex;align-items:center;gap:.75rem;color:#4b5563">
|
|
955
|
-
<svg style="width:1.25rem;height:1.25rem;animation:spin 1s linear infinite" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
956
|
-
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-opacity=".25" stroke-width="3"></circle>
|
|
957
|
-
<path d="M22 12a10 10 0 0 1-10 10" stroke="currentColor" stroke-width="3" stroke-linecap="round"></path>
|
|
958
|
-
</svg>
|
|
959
|
-
<span style="font-size:.875rem;font-weight:500">Loading\u2026</span>
|
|
960
|
-
</div>
|
|
961
|
-
<style>@keyframes spin { to { transform: rotate(360deg) } }</style>
|
|
962
|
-
</div>
|
|
963
|
-
`
|
|
964
|
-
);
|
|
965
|
-
fs.writeFileSync(
|
|
966
|
-
path.join(dir, "settings_schema.json"),
|
|
967
|
-
JSON.stringify(
|
|
968
|
-
[
|
|
969
|
-
{
|
|
970
|
-
type: "color",
|
|
971
|
-
id: "primary_color",
|
|
972
|
-
label: "Primary Color",
|
|
973
|
-
default: "#3B82F6"
|
|
974
|
-
},
|
|
975
|
-
{
|
|
976
|
-
type: "color",
|
|
977
|
-
id: "secondary_color",
|
|
978
|
-
label: "Secondary Color",
|
|
979
|
-
default: "#1E40AF"
|
|
980
|
-
},
|
|
981
|
-
{
|
|
982
|
-
type: "select",
|
|
983
|
-
id: "font_family",
|
|
984
|
-
label: "Font Family",
|
|
985
|
-
default: "Inter",
|
|
986
|
-
options: [
|
|
987
|
-
{ value: "Inter", label: "Inter" },
|
|
988
|
-
{ value: "Roboto", label: "Roboto" },
|
|
989
|
-
{ value: "Cairo", label: "Cairo" }
|
|
990
|
-
]
|
|
991
|
-
}
|
|
992
|
-
],
|
|
993
|
-
null,
|
|
994
|
-
2
|
|
995
|
-
)
|
|
996
|
-
);
|
|
997
|
-
fs.writeFileSync(
|
|
998
|
-
path.join(dir, "styles.css"),
|
|
999
|
-
":root { --numu-primary: #3B82F6; }\n"
|
|
1000
|
-
);
|
|
1001
|
-
fs.writeFileSync(
|
|
1002
|
-
path.join(dir, "src/sections/Hero.tsx"),
|
|
1003
|
-
`import type { SectionProps } from "@numueg/theme-sdk";
|
|
1004
|
-
|
|
1005
|
-
export default function Hero({ settings }: SectionProps) {
|
|
1006
|
-
return (
|
|
1007
|
-
<section className="min-h-[50vh] flex items-center justify-center bg-gray-900 text-white">
|
|
1008
|
-
<div className="text-center">
|
|
1009
|
-
<h1 className="text-4xl font-bold">{settings.headline as string || "Welcome"}</h1>
|
|
1010
|
-
{settings.subtitle ? (
|
|
1011
|
-
<p className="mt-4 text-xl">{settings.subtitle as string}</p>
|
|
1012
|
-
) : null}
|
|
1013
|
-
</div>
|
|
1014
|
-
</section>
|
|
889
|
+
throw new Error(
|
|
890
|
+
"Could not locate the scaffold templates. Reinstall @numueg/theme-cli."
|
|
1015
891
|
);
|
|
1016
892
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
{
|
|
1028
|
-
type: "text",
|
|
1029
|
-
id: "headline",
|
|
1030
|
-
label: "Headline",
|
|
1031
|
-
locales: { ar: { label: "\u0627\u0644\u0639\u0646\u0648\u0627\u0646" } },
|
|
1032
|
-
default: "Welcome"
|
|
1033
|
-
},
|
|
1034
|
-
{
|
|
1035
|
-
type: "text",
|
|
1036
|
-
id: "subtitle",
|
|
1037
|
-
label: "Subtitle",
|
|
1038
|
-
locales: { ar: { label: "\u0627\u0644\u0639\u0646\u0648\u0627\u0646 \u0627\u0644\u0641\u0631\u0639\u064A" } }
|
|
1039
|
-
},
|
|
1040
|
-
{
|
|
1041
|
-
type: "image_picker",
|
|
1042
|
-
id: "background_image",
|
|
1043
|
-
label: "Background Image",
|
|
1044
|
-
locales: { ar: { label: "\u0635\u0648\u0631\u0629 \u0627\u0644\u062E\u0644\u0641\u064A\u0629" } }
|
|
1045
|
-
}
|
|
1046
|
-
]
|
|
1047
|
-
},
|
|
1048
|
-
null,
|
|
1049
|
-
2
|
|
1050
|
-
)
|
|
893
|
+
var SUBSTITUTED = /* @__PURE__ */ new Set([
|
|
894
|
+
"theme.json",
|
|
895
|
+
"package.json",
|
|
896
|
+
"index.html",
|
|
897
|
+
path.join("src", "dev-entry.tsx")
|
|
898
|
+
]);
|
|
899
|
+
function applyTokens(content, tokens) {
|
|
900
|
+
return content.replace(
|
|
901
|
+
/__([A-Z_]+)__/g,
|
|
902
|
+
(whole, key) => key in tokens ? tokens[key] : whole
|
|
1051
903
|
);
|
|
1052
|
-
fs.writeFileSync(
|
|
1053
|
-
path.join(dir, "src/main.tsx"),
|
|
1054
|
-
`import type { ComponentType } from "react";
|
|
1055
|
-
import { defineThemeEntry } from "@numueg/theme-sdk";
|
|
1056
|
-
import type { ThemeSettingsV3 } from "@numueg/theme-sdk";
|
|
1057
|
-
import Hero from "./sections/Hero";
|
|
1058
|
-
|
|
1059
|
-
const SECTION_REGISTRY: Record<string, ComponentType<any>> = {
|
|
1060
|
-
hero: Hero,
|
|
1061
|
-
};
|
|
1062
|
-
|
|
1063
|
-
interface ThemeProps {
|
|
1064
|
-
themeSettings: ThemeSettingsV3;
|
|
1065
|
-
currentTemplate: string;
|
|
1066
904
|
}
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
const
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
settings={section.settings}
|
|
1083
|
-
blocks={section.blocks}
|
|
1084
|
-
blockOrder={section.block_order}
|
|
1085
|
-
/>
|
|
1086
|
-
);
|
|
1087
|
-
})}
|
|
1088
|
-
</main>
|
|
1089
|
-
);
|
|
905
|
+
function copyTree(from, to, rel, tokens) {
|
|
906
|
+
const entries = fs.readdirSync(from, { withFileTypes: true });
|
|
907
|
+
for (const entry of entries) {
|
|
908
|
+
const srcPath = path.join(from, entry.name);
|
|
909
|
+
const dstPath = path.join(to, entry.name);
|
|
910
|
+
const relPath = rel ? path.join(rel, entry.name) : entry.name;
|
|
911
|
+
if (entry.isDirectory()) {
|
|
912
|
+
fs.mkdirSync(dstPath, { recursive: true });
|
|
913
|
+
copyTree(srcPath, dstPath, relPath, tokens);
|
|
914
|
+
} else {
|
|
915
|
+
let content = fs.readFileSync(srcPath, "utf-8");
|
|
916
|
+
if (SUBSTITUTED.has(relPath)) content = applyTokens(content, tokens);
|
|
917
|
+
fs.writeFileSync(dstPath, content);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
1090
920
|
}
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
));
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
<script type="module" src="/src/dev-entry.tsx"></script>
|
|
1116
|
-
</body>
|
|
1117
|
-
</html>
|
|
1118
|
-
`
|
|
1119
|
-
);
|
|
1120
|
-
fs.writeFileSync(
|
|
1121
|
-
path.join(dir, "src/dev-entry.tsx"),
|
|
1122
|
-
`import { createRoot } from "react-dom/client";
|
|
1123
|
-
import Theme from "./main";
|
|
1124
|
-
|
|
1125
|
-
const placeholder = {
|
|
1126
|
-
schema_version: 3 as const,
|
|
1127
|
-
theme_id: "${name}",
|
|
1128
|
-
global_settings: {},
|
|
1129
|
-
templates: {
|
|
1130
|
-
home: {
|
|
1131
|
-
name: "Home",
|
|
1132
|
-
sections: { hero_1: { type: "hero", settings: { headline: "Hello, theme!" } } },
|
|
1133
|
-
order: ["hero_1"],
|
|
1134
|
-
},
|
|
1135
|
-
},
|
|
1136
|
-
section_groups: {},
|
|
1137
|
-
};
|
|
1138
|
-
|
|
1139
|
-
const root = document.getElementById("root");
|
|
1140
|
-
if (root)
|
|
1141
|
-
createRoot(root).render(
|
|
1142
|
-
<Theme themeSettings={placeholder as any} currentTemplate="home" />,
|
|
1143
|
-
);
|
|
1144
|
-
`
|
|
1145
|
-
);
|
|
1146
|
-
fs.writeFileSync(
|
|
1147
|
-
path.join(dir, "vite.config.ts"),
|
|
1148
|
-
`import { defineConfig } from "vite";
|
|
1149
|
-
import react from "@vitejs/plugin-react";
|
|
1150
|
-
import { numuTheme } from "@numueg/theme-plugin";
|
|
1151
|
-
|
|
1152
|
-
// The @numueg/theme-plugin handles all the NUMU-specific glue:
|
|
1153
|
-
// - validates theme.json + settings_schema.json + entry point
|
|
1154
|
-
// - externalizes React + @numueg/theme-sdk (host-provided)
|
|
1155
|
-
// - emits dist/manifest.json + dist/import-map.json after build
|
|
1156
|
-
//
|
|
1157
|
-
// You don't need to repeat \`build.lib\` / \`build.rollupOptions.external\`
|
|
1158
|
-
// \u2014 the plugin sets sensible defaults when they're omitted.
|
|
1159
|
-
|
|
1160
|
-
export default defineConfig({
|
|
1161
|
-
plugins: [react(), numuTheme()],
|
|
1162
|
-
server: { port: 5173 },
|
|
1163
|
-
});
|
|
1164
|
-
`
|
|
1165
|
-
);
|
|
1166
|
-
fs.writeFileSync(
|
|
1167
|
-
path.join(dir, "tsconfig.json"),
|
|
1168
|
-
JSON.stringify(
|
|
1169
|
-
{
|
|
1170
|
-
compilerOptions: {
|
|
1171
|
-
target: "ES2022",
|
|
1172
|
-
lib: ["ES2022", "DOM", "DOM.Iterable"],
|
|
1173
|
-
module: "ESNext",
|
|
1174
|
-
moduleResolution: "bundler",
|
|
1175
|
-
jsx: "react-jsx",
|
|
1176
|
-
strict: true,
|
|
1177
|
-
noEmit: true,
|
|
1178
|
-
skipLibCheck: true,
|
|
1179
|
-
isolatedModules: true,
|
|
1180
|
-
esModuleInterop: true,
|
|
1181
|
-
resolveJsonModule: true
|
|
1182
|
-
},
|
|
1183
|
-
include: ["src"]
|
|
1184
|
-
},
|
|
1185
|
-
null,
|
|
1186
|
-
2
|
|
1187
|
-
)
|
|
1188
|
-
);
|
|
1189
|
-
fs.writeFileSync(
|
|
1190
|
-
path.join(dir, "package.json"),
|
|
1191
|
-
JSON.stringify(
|
|
1192
|
-
{
|
|
1193
|
-
name: `numu-theme-${name}`,
|
|
1194
|
-
version: "0.1.0",
|
|
1195
|
-
private: true,
|
|
1196
|
-
type: "module",
|
|
1197
|
-
scripts: {
|
|
1198
|
-
dev: "numu-theme dev",
|
|
1199
|
-
build: "numu-theme build",
|
|
1200
|
-
check: "numu-theme check",
|
|
1201
|
-
// Render-verify every template against fixtures (Phase 2).
|
|
1202
|
-
verify: "numu-theme verify"
|
|
1203
|
-
},
|
|
1204
|
-
dependencies: { "@numueg/theme-sdk": "^0.4.0" },
|
|
1205
|
-
devDependencies: {
|
|
1206
|
-
"@numueg/theme-cli": "^0.4.0",
|
|
1207
|
-
"@numueg/theme-plugin": "^0.4.0",
|
|
1208
|
-
"@vitejs/plugin-react": "^4.3.0",
|
|
1209
|
-
vite: "^6.0.0",
|
|
1210
|
-
typescript: "^5.8.0",
|
|
1211
|
-
"@types/react": "^19.0.0",
|
|
1212
|
-
"@types/react-dom": "^19.0.0",
|
|
1213
|
-
react: "^19.0.0",
|
|
1214
|
-
"react-dom": "^19.0.0"
|
|
1215
|
-
}
|
|
1216
|
-
},
|
|
1217
|
-
null,
|
|
1218
|
-
2
|
|
1219
|
-
)
|
|
1220
|
-
);
|
|
1221
|
-
fs.writeFileSync(
|
|
1222
|
-
path.join(dir, ".gitignore"),
|
|
1223
|
-
"node_modules\ndist\n.env\n.env.*\n.next\n"
|
|
1224
|
-
);
|
|
921
|
+
var initCommand = new import_commander.Command("init").description("Scaffold a new NUMU theme project from the standard starter").argument("<name>", "Theme name").option("--template <template>", "Starter template", "basic").action(async (name, _options) => {
|
|
922
|
+
const dir = path.resolve(process.cwd(), name);
|
|
923
|
+
if (fs.existsSync(dir)) {
|
|
924
|
+
console.error(`Directory "${name}" already exists`);
|
|
925
|
+
process.exit(1);
|
|
926
|
+
}
|
|
927
|
+
const themeId = name.toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
928
|
+
const tokens = {
|
|
929
|
+
THEME_NAME: name,
|
|
930
|
+
THEME_ID: themeId,
|
|
931
|
+
PKG_NAME: themeId,
|
|
932
|
+
AUTHOR: detectAuthor(),
|
|
933
|
+
VERSION: "0.1.0"
|
|
934
|
+
};
|
|
935
|
+
console.log(`Creating NUMU theme: ${name}...`);
|
|
936
|
+
let root;
|
|
937
|
+
try {
|
|
938
|
+
root = scaffoldRoot();
|
|
939
|
+
} catch (err) {
|
|
940
|
+
console.error(err.message);
|
|
941
|
+
process.exit(1);
|
|
942
|
+
}
|
|
943
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
944
|
+
copyTree(root, dir, "", tokens);
|
|
1225
945
|
console.log(
|
|
1226
946
|
`
|
|
1227
|
-
Theme "${name}" created.
|
|
947
|
+
Theme "${name}" created from the NUMU Starter.
|
|
1228
948
|
|
|
1229
949
|
Next steps:
|
|
1230
950
|
cd ${name}
|
|
1231
951
|
npm install
|
|
1232
|
-
npx numu-theme dev
|
|
1233
|
-
npx numu-theme
|
|
952
|
+
npx numu-theme dev # local preview
|
|
953
|
+
npx numu-theme check # validate
|
|
954
|
+
npx numu-theme build # production build
|
|
1234
955
|
`
|
|
1235
956
|
);
|
|
1236
957
|
});
|
|
@@ -3001,21 +2722,29 @@ var featuredProducts = {
|
|
|
3001
2722
|
name: "Featured Products",
|
|
3002
2723
|
description: "Grid of hand-picked products with title + price + CTA",
|
|
3003
2724
|
component: `import type { SectionProps } from "@numueg/theme-sdk";
|
|
3004
|
-
import { ProductCard, useProducts } from "@numueg/theme-sdk";
|
|
2725
|
+
import { ProductCard, useProducts, useLocale } from "@numueg/theme-sdk";
|
|
2726
|
+
|
|
2727
|
+
// Bilingual AR/EN text without a shared import (keeps the snippet forkable).
|
|
2728
|
+
function useT() {
|
|
2729
|
+
const locale = useLocale();
|
|
2730
|
+
const isAr =
|
|
2731
|
+
typeof locale === "string" && locale.toLowerCase().startsWith("ar");
|
|
2732
|
+
return (en: string, ar: string) => (isAr ? ar : en);
|
|
2733
|
+
}
|
|
3005
2734
|
|
|
3006
2735
|
export default function FeaturedProducts({ settings }: SectionProps) {
|
|
2736
|
+
const t = useT();
|
|
3007
2737
|
const ids = (settings.product_ids as string[]) || [];
|
|
3008
2738
|
const limit = (settings.limit as number) || 8;
|
|
3009
|
-
const title = (settings.title as string) || "Featured products";
|
|
2739
|
+
const title = (settings.title as string) || t("Featured products", "\u0645\u0646\u062A\u062C\u0627\u062A \u0645\u0645\u064A\u0632\u0629");
|
|
3010
2740
|
|
|
3011
|
-
//
|
|
3012
|
-
//
|
|
3013
|
-
const { products, loading } = useProducts({
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
});
|
|
2741
|
+
// Pull the catalog (fetching if the route didn't pre-load it), then narrow
|
|
2742
|
+
// to the merchant's hand-picked ids when provided, else take the top-N.
|
|
2743
|
+
const { products: all, loading } = useProducts({ fetchIfMissing: true, limit });
|
|
2744
|
+
const products =
|
|
2745
|
+
ids.length > 0 ? all.filter((p) => ids.includes(p.id)) : all;
|
|
3017
2746
|
|
|
3018
|
-
if (loading) return <section className="py-16 text-center text-gray-500">Loading\u2026</section>;
|
|
2747
|
+
if (loading) return <section className="py-16 text-center text-gray-500">{t("Loading\u2026", "\u062C\u0627\u0631\u064D \u0627\u0644\u062A\u062D\u0645\u064A\u0644\u2026")}</section>;
|
|
3019
2748
|
if (!products.length) return null;
|
|
3020
2749
|
|
|
3021
2750
|
return (
|
|
@@ -3067,9 +2796,18 @@ var imageWithText = {
|
|
|
3067
2796
|
name: "Image with Text",
|
|
3068
2797
|
description: "Two-column section: image on one side, headline + body + CTA on the other",
|
|
3069
2798
|
component: `import type { SectionProps } from "@numueg/theme-sdk";
|
|
3070
|
-
import { useDirection } from "@numueg/theme-sdk";
|
|
2799
|
+
import { useDirection, useLocale } from "@numueg/theme-sdk";
|
|
2800
|
+
|
|
2801
|
+
// Bilingual AR/EN text without a shared import (keeps the snippet forkable).
|
|
2802
|
+
function useT() {
|
|
2803
|
+
const locale = useLocale();
|
|
2804
|
+
const isAr =
|
|
2805
|
+
typeof locale === "string" && locale.toLowerCase().startsWith("ar");
|
|
2806
|
+
return (en: string, ar: string) => (isAr ? ar : en);
|
|
2807
|
+
}
|
|
3071
2808
|
|
|
3072
2809
|
export default function ImageWithText({ settings }: SectionProps) {
|
|
2810
|
+
const t = useT();
|
|
3073
2811
|
const dir = useDirection();
|
|
3074
2812
|
const image = settings.image as string | undefined;
|
|
3075
2813
|
const layout = (settings.image_position as string) || "left";
|
|
@@ -3164,13 +2902,22 @@ var newsletterSignup = {
|
|
|
3164
2902
|
name: "Newsletter Signup",
|
|
3165
2903
|
description: "Email capture form with merchant-supplied heading + consent copy",
|
|
3166
2904
|
component: `import type { SectionProps } from "@numueg/theme-sdk";
|
|
3167
|
-
import { Form } from "@numueg/theme-sdk";
|
|
2905
|
+
import { Form, useLocale } from "@numueg/theme-sdk";
|
|
2906
|
+
|
|
2907
|
+
// Bilingual AR/EN text without a shared import (keeps the snippet forkable).
|
|
2908
|
+
function useT() {
|
|
2909
|
+
const locale = useLocale();
|
|
2910
|
+
const isAr =
|
|
2911
|
+
typeof locale === "string" && locale.toLowerCase().startsWith("ar");
|
|
2912
|
+
return (en: string, ar: string) => (isAr ? ar : en);
|
|
2913
|
+
}
|
|
3168
2914
|
|
|
3169
2915
|
export default function NewsletterSignup({ settings }: SectionProps) {
|
|
3170
|
-
const
|
|
2916
|
+
const t = useT();
|
|
2917
|
+
const heading = (settings.heading as string) || t("Stay in touch", "\u0627\u0628\u0642\u064E \u0639\u0644\u0649 \u062A\u0648\u0627\u0635\u0644");
|
|
3171
2918
|
const subheading = (settings.subheading as string) || "";
|
|
3172
|
-
const placeholder = (settings.placeholder as string) || "Email address";
|
|
3173
|
-
const buttonLabel = (settings.button_label as string) || "Subscribe";
|
|
2919
|
+
const placeholder = (settings.placeholder as string) || t("Email address", "\u0627\u0644\u0628\u0631\u064A\u062F \u0627\u0644\u0625\u0644\u0643\u062A\u0631\u0648\u0646\u064A");
|
|
2920
|
+
const buttonLabel = (settings.button_label as string) || t("Subscribe", "\u0627\u0634\u062A\u0631\u0627\u0643");
|
|
3174
2921
|
const consent = (settings.consent_text as string) || "";
|
|
3175
2922
|
|
|
3176
2923
|
return (
|
|
@@ -3251,10 +2998,19 @@ var multiColumn = {
|
|
|
3251
2998
|
slug: "multi-column",
|
|
3252
2999
|
name: "Multi-column",
|
|
3253
3000
|
description: "2 / 3 / 4-column features grid with icon + heading + text per column",
|
|
3254
|
-
component: `import type { SectionProps
|
|
3255
|
-
import { Block } from "@numueg/theme-sdk";
|
|
3001
|
+
component: `import type { SectionProps } from "@numueg/theme-sdk";
|
|
3002
|
+
import { Block, useLocale } from "@numueg/theme-sdk";
|
|
3003
|
+
|
|
3004
|
+
// Bilingual AR/EN text without a shared import (keeps the snippet forkable).
|
|
3005
|
+
function useT() {
|
|
3006
|
+
const locale = useLocale();
|
|
3007
|
+
const isAr =
|
|
3008
|
+
typeof locale === "string" && locale.toLowerCase().startsWith("ar");
|
|
3009
|
+
return (en: string, ar: string) => (isAr ? ar : en);
|
|
3010
|
+
}
|
|
3256
3011
|
|
|
3257
|
-
export default function MultiColumn({ settings, blocks }: SectionProps
|
|
3012
|
+
export default function MultiColumn({ settings, blocks, blockOrder }: SectionProps) {
|
|
3013
|
+
const t = useT();
|
|
3258
3014
|
const heading = (settings.heading as string) || "";
|
|
3259
3015
|
const cols = (settings.columns as number) || 3;
|
|
3260
3016
|
const gridCols =
|
|
@@ -3262,31 +3018,38 @@ export default function MultiColumn({ settings, blocks }: SectionProps & { block
|
|
|
3262
3018
|
cols === 4 ? "md:grid-cols-2 lg:grid-cols-4" :
|
|
3263
3019
|
"md:grid-cols-3";
|
|
3264
3020
|
|
|
3021
|
+
// Blocks arrive as a {id: instance} map + an order array \u2014 mirror the
|
|
3022
|
+
// section\u2192block render: walk the order and look each id up.
|
|
3023
|
+
const map = blocks || {};
|
|
3024
|
+
const order = blockOrder || Object.keys(map);
|
|
3025
|
+
|
|
3265
3026
|
return (
|
|
3266
|
-
<section className="py-16 px-6 max-w-7xl mx-auto">
|
|
3027
|
+
<section className="py-16 px-6 max-w-7xl mx-auto" aria-label={t("Features", "\u0627\u0644\u0645\u0645\u064A\u0632\u0627\u062A")}>
|
|
3267
3028
|
{heading && (
|
|
3268
3029
|
<h2 className="text-2xl md:text-3xl font-semibold text-center mb-12">{heading}</h2>
|
|
3269
3030
|
)}
|
|
3270
3031
|
<div className={\`grid grid-cols-1 \${gridCols} gap-10\`}>
|
|
3271
|
-
{
|
|
3272
|
-
|
|
3273
|
-
|
|
3032
|
+
{order.map((id) => {
|
|
3033
|
+
const b = map[id];
|
|
3034
|
+
if (!b || b.type !== "column") return null;
|
|
3035
|
+
return (
|
|
3036
|
+
<Block key={id} id={id} type={b.type}>
|
|
3274
3037
|
<div className="text-center">
|
|
3275
|
-
{
|
|
3038
|
+
{b.settings.icon ? (
|
|
3276
3039
|
<img
|
|
3277
|
-
src={
|
|
3040
|
+
src={b.settings.icon as string}
|
|
3278
3041
|
alt=""
|
|
3279
3042
|
className="w-12 h-12 mx-auto mb-4"
|
|
3280
3043
|
/>
|
|
3281
3044
|
) : null}
|
|
3282
3045
|
<h3 className="text-lg font-semibold mb-2">
|
|
3283
|
-
{(
|
|
3046
|
+
{(b.settings.title as string) || ""}
|
|
3284
3047
|
</h3>
|
|
3285
|
-
<p className="text-gray-600">{(
|
|
3048
|
+
<p className="text-gray-600">{(b.settings.text as string) || ""}</p>
|
|
3286
3049
|
</div>
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
)
|
|
3050
|
+
</Block>
|
|
3051
|
+
);
|
|
3052
|
+
})}
|
|
3290
3053
|
</div>
|
|
3291
3054
|
</section>
|
|
3292
3055
|
);
|
|
@@ -3350,9 +3113,19 @@ var testimonials = {
|
|
|
3350
3113
|
name: "Testimonials",
|
|
3351
3114
|
description: "Customer reviews carousel with quote, author, role, optional photo",
|
|
3352
3115
|
component: `import type { SectionProps, BlockProps } from "@numueg/theme-sdk";
|
|
3116
|
+
import { useLocale } from "@numueg/theme-sdk";
|
|
3117
|
+
|
|
3118
|
+
// Bilingual AR/EN text without a shared import (keeps the snippet forkable).
|
|
3119
|
+
function useT() {
|
|
3120
|
+
const locale = useLocale();
|
|
3121
|
+
const isAr =
|
|
3122
|
+
typeof locale === "string" && locale.toLowerCase().startsWith("ar");
|
|
3123
|
+
return (en: string, ar: string) => (isAr ? ar : en);
|
|
3124
|
+
}
|
|
3353
3125
|
|
|
3354
3126
|
export default function Testimonials({ settings, blocks }: SectionProps & { blocks?: BlockProps[] }) {
|
|
3355
|
-
const
|
|
3127
|
+
const t = useT();
|
|
3128
|
+
const heading = (settings.heading as string) || t("What our customers say", "\u0622\u0631\u0627\u0621 \u0639\u0645\u0644\u0627\u0626\u0646\u0627");
|
|
3356
3129
|
return (
|
|
3357
3130
|
<section className="py-16 px-6 bg-gray-50">
|
|
3358
3131
|
<div className="max-w-5xl mx-auto">
|
|
@@ -3405,9 +3178,19 @@ var faqAccordion = {
|
|
|
3405
3178
|
name: "FAQ Accordion",
|
|
3406
3179
|
description: "Collapsible question/answer pairs using native <details>/<summary>",
|
|
3407
3180
|
component: `import type { SectionProps, BlockProps } from "@numueg/theme-sdk";
|
|
3181
|
+
import { useLocale } from "@numueg/theme-sdk";
|
|
3182
|
+
|
|
3183
|
+
// Bilingual AR/EN text without a shared import (keeps the snippet forkable).
|
|
3184
|
+
function useT() {
|
|
3185
|
+
const locale = useLocale();
|
|
3186
|
+
const isAr =
|
|
3187
|
+
typeof locale === "string" && locale.toLowerCase().startsWith("ar");
|
|
3188
|
+
return (en: string, ar: string) => (isAr ? ar : en);
|
|
3189
|
+
}
|
|
3408
3190
|
|
|
3409
3191
|
export default function FaqAccordion({ settings, blocks }: SectionProps & { blocks?: BlockProps[] }) {
|
|
3410
|
-
const
|
|
3192
|
+
const t = useT();
|
|
3193
|
+
const heading = (settings.heading as string) || t("Frequently asked questions", "\u0627\u0644\u0623\u0633\u0626\u0644\u0629 \u0627\u0644\u0634\u0627\u0626\u0639\u0629");
|
|
3411
3194
|
return (
|
|
3412
3195
|
<section className="py-16 px-6 max-w-3xl mx-auto">
|
|
3413
3196
|
<h2 className="text-2xl md:text-3xl font-semibold text-center mb-10">{heading}</h2>
|
|
@@ -3453,8 +3236,18 @@ var logoCloud = {
|
|
|
3453
3236
|
name: "Logo Cloud",
|
|
3454
3237
|
description: "Row of partner / press / payment logos in grayscale with hover restore",
|
|
3455
3238
|
component: `import type { SectionProps, BlockProps } from "@numueg/theme-sdk";
|
|
3239
|
+
import { useLocale } from "@numueg/theme-sdk";
|
|
3240
|
+
|
|
3241
|
+
// Bilingual AR/EN text without a shared import (keeps the snippet forkable).
|
|
3242
|
+
function useT() {
|
|
3243
|
+
const locale = useLocale();
|
|
3244
|
+
const isAr =
|
|
3245
|
+
typeof locale === "string" && locale.toLowerCase().startsWith("ar");
|
|
3246
|
+
return (en: string, ar: string) => (isAr ? ar : en);
|
|
3247
|
+
}
|
|
3456
3248
|
|
|
3457
3249
|
export default function LogoCloud({ settings, blocks }: SectionProps & { blocks?: BlockProps[] }) {
|
|
3250
|
+
const t = useT();
|
|
3458
3251
|
const heading = (settings.heading as string) || "";
|
|
3459
3252
|
return (
|
|
3460
3253
|
<section className="py-12 px-6 bg-white">
|
|
@@ -3501,17 +3294,25 @@ var collectionList = {
|
|
|
3501
3294
|
name: "Collection List",
|
|
3502
3295
|
description: "Grid of collection cards (image + name) linking to each collection's PLP",
|
|
3503
3296
|
component: `import type { SectionProps } from "@numueg/theme-sdk";
|
|
3504
|
-
import { CollectionCard, useCollections } from "@numueg/theme-sdk";
|
|
3297
|
+
import { CollectionCard, useCollections, useLocale } from "@numueg/theme-sdk";
|
|
3298
|
+
|
|
3299
|
+
// Bilingual AR/EN text without a shared import (keeps the snippet forkable).
|
|
3300
|
+
function useT() {
|
|
3301
|
+
const locale = useLocale();
|
|
3302
|
+
const isAr =
|
|
3303
|
+
typeof locale === "string" && locale.toLowerCase().startsWith("ar");
|
|
3304
|
+
return (en: string, ar: string) => (isAr ? ar : en);
|
|
3305
|
+
}
|
|
3505
3306
|
|
|
3506
3307
|
export default function CollectionListSection({ settings }: SectionProps) {
|
|
3308
|
+
const t = useT();
|
|
3507
3309
|
const ids = (settings.collection_ids as string[]) || [];
|
|
3508
3310
|
const limit = (settings.limit as number) || 6;
|
|
3509
|
-
const heading = (settings.heading as string) || "Shop by collection";
|
|
3510
|
-
const { collections, loading } = useCollections({
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
}
|
|
3514
|
-
if (loading) return <section className="py-16 text-center text-gray-500">Loading\u2026</section>;
|
|
3311
|
+
const heading = (settings.heading as string) || t("Shop by collection", "\u062A\u0633\u0648\u0651\u0642 \u062D\u0633\u0628 \u0627\u0644\u0645\u062C\u0645\u0648\u0639\u0629");
|
|
3312
|
+
const { collections: all, loading } = useCollections({ fetchIfMissing: true, limit });
|
|
3313
|
+
const collections =
|
|
3314
|
+
ids.length > 0 ? all.filter((c) => ids.includes(c.id)) : all;
|
|
3315
|
+
if (loading) return <section className="py-16 text-center text-gray-500">{t("Loading\u2026", "\u062C\u0627\u0631\u064D \u0627\u0644\u062A\u062D\u0645\u064A\u0644\u2026")}</section>;
|
|
3515
3316
|
if (!collections.length) return null;
|
|
3516
3317
|
return (
|
|
3517
3318
|
<section className="py-16 px-6 max-w-7xl mx-auto">
|
|
@@ -3541,8 +3342,18 @@ var videoEmbed = {
|
|
|
3541
3342
|
name: "Video Embed",
|
|
3542
3343
|
description: "Responsive 16:9 video (YouTube / Vimeo / self-hosted MP4)",
|
|
3543
3344
|
component: `import type { SectionProps } from "@numueg/theme-sdk";
|
|
3345
|
+
import { useLocale } from "@numueg/theme-sdk";
|
|
3346
|
+
|
|
3347
|
+
// Bilingual AR/EN text without a shared import (keeps the snippet forkable).
|
|
3348
|
+
function useT() {
|
|
3349
|
+
const locale = useLocale();
|
|
3350
|
+
const isAr =
|
|
3351
|
+
typeof locale === "string" && locale.toLowerCase().startsWith("ar");
|
|
3352
|
+
return (en: string, ar: string) => (isAr ? ar : en);
|
|
3353
|
+
}
|
|
3544
3354
|
|
|
3545
3355
|
export default function VideoEmbed({ settings }: SectionProps) {
|
|
3356
|
+
const t = useT();
|
|
3546
3357
|
const heading = (settings.heading as string) || "";
|
|
3547
3358
|
const url = (settings.video_url as string) || "";
|
|
3548
3359
|
const poster = (settings.poster as string) || undefined;
|
|
@@ -3558,14 +3369,14 @@ export default function VideoEmbed({ settings }: SectionProps) {
|
|
|
3558
3369
|
) : (
|
|
3559
3370
|
<iframe
|
|
3560
3371
|
src={url}
|
|
3561
|
-
title={heading || "Video"}
|
|
3372
|
+
title={heading || t("Video", "\u0641\u064A\u062F\u064A\u0648")}
|
|
3562
3373
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
|
3563
3374
|
allowFullScreen
|
|
3564
3375
|
className="w-full h-full border-0"
|
|
3565
3376
|
/>
|
|
3566
3377
|
)
|
|
3567
3378
|
) : (
|
|
3568
|
-
<div className="flex items-center justify-center h-full text-white/60">Set a video URL</div>
|
|
3379
|
+
<div className="flex items-center justify-center h-full text-white/60">{t("Set a video URL", "\u0623\u062F\u062E\u0644 \u0631\u0627\u0628\u0637 \u0627\u0644\u0641\u064A\u062F\u064A\u0648")}</div>
|
|
3569
3380
|
)}
|
|
3570
3381
|
</div>
|
|
3571
3382
|
</section>
|
|
@@ -3590,9 +3401,18 @@ var richText = {
|
|
|
3590
3401
|
name: "Rich Text",
|
|
3591
3402
|
description: "Merchant-edited rich text block \u2014 title + paragraph(s) + optional CTA",
|
|
3592
3403
|
component: `import type { SectionProps } from "@numueg/theme-sdk";
|
|
3593
|
-
import { RichText } from "@numueg/theme-sdk";
|
|
3404
|
+
import { RichText, useLocale } from "@numueg/theme-sdk";
|
|
3405
|
+
|
|
3406
|
+
// Bilingual AR/EN text without a shared import (keeps the snippet forkable).
|
|
3407
|
+
function useT() {
|
|
3408
|
+
const locale = useLocale();
|
|
3409
|
+
const isAr =
|
|
3410
|
+
typeof locale === "string" && locale.toLowerCase().startsWith("ar");
|
|
3411
|
+
return (en: string, ar: string) => (isAr ? ar : en);
|
|
3412
|
+
}
|
|
3594
3413
|
|
|
3595
3414
|
export default function RichTextSection({ settings }: SectionProps) {
|
|
3415
|
+
const t = useT();
|
|
3596
3416
|
const heading = (settings.heading as string) || "";
|
|
3597
3417
|
const body = (settings.body as string) || "";
|
|
3598
3418
|
const align = (settings.alignment as string) || "left";
|
|
@@ -3639,11 +3459,21 @@ var announcementBar = {
|
|
|
3639
3459
|
name: "Announcement Bar",
|
|
3640
3460
|
description: "Thin top-of-page bar for promos / shipping notices, optionally dismissible",
|
|
3641
3461
|
component: `import type { SectionProps } from "@numueg/theme-sdk";
|
|
3462
|
+
import { useLocale } from "@numueg/theme-sdk";
|
|
3642
3463
|
import { useEffect, useState } from "react";
|
|
3643
3464
|
|
|
3644
3465
|
const DISMISS_KEY = "numu_announcement_dismissed";
|
|
3645
3466
|
|
|
3467
|
+
// Bilingual AR/EN text without a shared import (keeps the snippet forkable).
|
|
3468
|
+
function useT() {
|
|
3469
|
+
const locale = useLocale();
|
|
3470
|
+
const isAr =
|
|
3471
|
+
typeof locale === "string" && locale.toLowerCase().startsWith("ar");
|
|
3472
|
+
return (en: string, ar: string) => (isAr ? ar : en);
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3646
3475
|
export default function AnnouncementBar({ settings }: SectionProps) {
|
|
3476
|
+
const t = useT();
|
|
3647
3477
|
const message = (settings.message as string) || "";
|
|
3648
3478
|
const linkLabel = settings.link_label as string | undefined;
|
|
3649
3479
|
const linkHref = (settings.link_href as string) || "/";
|
|
@@ -3658,7 +3488,7 @@ export default function AnnouncementBar({ settings }: SectionProps) {
|
|
|
3658
3488
|
if (!message || dismissed) return null;
|
|
3659
3489
|
|
|
3660
3490
|
return (
|
|
3661
|
-
<div role="region" aria-label="Announcement" className="bg-gray-900 text-white text-sm">
|
|
3491
|
+
<div role="region" aria-label={t("Announcement", "\u0625\u0639\u0644\u0627\u0646")} className="bg-gray-900 text-white text-sm">
|
|
3662
3492
|
<div className="max-w-7xl mx-auto px-4 py-2 flex items-center justify-center gap-3 relative">
|
|
3663
3493
|
<p>
|
|
3664
3494
|
{message}
|
|
@@ -3672,7 +3502,7 @@ export default function AnnouncementBar({ settings }: SectionProps) {
|
|
|
3672
3502
|
{dismissible && (
|
|
3673
3503
|
<button
|
|
3674
3504
|
type="button"
|
|
3675
|
-
aria-label="Dismiss announcement"
|
|
3505
|
+
aria-label={t("Dismiss announcement", "\u0625\u063A\u0644\u0627\u0642 \u0627\u0644\u0625\u0639\u0644\u0627\u0646")}
|
|
3676
3506
|
onClick={() => {
|
|
3677
3507
|
setDismissed(true);
|
|
3678
3508
|
try { window.sessionStorage.setItem(DISMISS_KEY, "1"); } catch {}
|
|
@@ -3706,8 +3536,17 @@ var countdownTimer = {
|
|
|
3706
3536
|
name: "Countdown Timer",
|
|
3707
3537
|
description: "Sale-end countdown \u2014 DD:HH:MM:SS ticking to a merchant-set target date",
|
|
3708
3538
|
component: `import type { SectionProps } from "@numueg/theme-sdk";
|
|
3539
|
+
import { useLocale } from "@numueg/theme-sdk";
|
|
3709
3540
|
import { useEffect, useState } from "react";
|
|
3710
3541
|
|
|
3542
|
+
// Bilingual AR/EN text without a shared import (keeps the snippet forkable).
|
|
3543
|
+
function useT() {
|
|
3544
|
+
const locale = useLocale();
|
|
3545
|
+
const isAr =
|
|
3546
|
+
typeof locale === "string" && locale.toLowerCase().startsWith("ar");
|
|
3547
|
+
return (en: string, ar: string) => (isAr ? ar : en);
|
|
3548
|
+
}
|
|
3549
|
+
|
|
3711
3550
|
function diff(target: number) {
|
|
3712
3551
|
const ms = Math.max(0, target - Date.now());
|
|
3713
3552
|
const d = Math.floor(ms / 86_400_000);
|
|
@@ -3718,14 +3557,15 @@ function diff(target: number) {
|
|
|
3718
3557
|
}
|
|
3719
3558
|
|
|
3720
3559
|
export default function CountdownTimer({ settings }: SectionProps) {
|
|
3721
|
-
const
|
|
3560
|
+
const t = useT();
|
|
3561
|
+
const heading = (settings.heading as string) || t("Sale ends in", "\u064A\u0646\u062A\u0647\u064A \u0627\u0644\u0639\u0631\u0636 \u062E\u0644\u0627\u0644");
|
|
3722
3562
|
const targetIso = settings.end_at as string | undefined;
|
|
3723
3563
|
const target = targetIso ? Date.parse(targetIso) : 0;
|
|
3724
|
-
const [
|
|
3564
|
+
const [time, setTime] = useState(() => diff(target));
|
|
3725
3565
|
|
|
3726
3566
|
useEffect(() => {
|
|
3727
3567
|
if (!target) return;
|
|
3728
|
-
const id = window.setInterval(() =>
|
|
3568
|
+
const id = window.setInterval(() => setTime(diff(target)), 1000);
|
|
3729
3569
|
return () => window.clearInterval(id);
|
|
3730
3570
|
}, [target]);
|
|
3731
3571
|
|
|
@@ -3734,17 +3574,17 @@ export default function CountdownTimer({ settings }: SectionProps) {
|
|
|
3734
3574
|
return (
|
|
3735
3575
|
<section className="py-12 px-6 bg-red-50 text-center">
|
|
3736
3576
|
<h2 className="text-xl md:text-2xl font-semibold mb-4">{heading}</h2>
|
|
3737
|
-
{
|
|
3738
|
-
<p className="text-lg text-red-700">The sale has ended
|
|
3577
|
+
{time.done ? (
|
|
3578
|
+
<p className="text-lg text-red-700">{t("The sale has ended.", "\u0627\u0646\u062A\u0647\u0649 \u0627\u0644\u0639\u0631\u0636.")}</p>
|
|
3739
3579
|
) : (
|
|
3740
3580
|
<div className="flex justify-center gap-4 md:gap-6 font-mono">
|
|
3741
3581
|
{[
|
|
3742
|
-
{ label: "days", value:
|
|
3743
|
-
{ label: "hrs", value:
|
|
3744
|
-
{ label: "min", value:
|
|
3745
|
-
{ label: "sec", value:
|
|
3582
|
+
{ key: "days", label: t("days", "\u0623\u064A\u0627\u0645"), value: time.d },
|
|
3583
|
+
{ key: "hrs", label: t("hrs", "\u0633\u0627\u0639\u0627\u062A"), value: time.h },
|
|
3584
|
+
{ key: "min", label: t("min", "\u062F\u0642\u0627\u0626\u0642"), value: time.m },
|
|
3585
|
+
{ key: "sec", label: t("sec", "\u062B\u0648\u0627\u0646\u064D"), value: time.s },
|
|
3746
3586
|
].map((u) => (
|
|
3747
|
-
<div key={u.
|
|
3587
|
+
<div key={u.key} className="bg-white rounded-lg p-3 min-w-[64px] shadow-sm">
|
|
3748
3588
|
<div className="text-2xl md:text-3xl font-bold">{String(u.value).padStart(2, "0")}</div>
|
|
3749
3589
|
<div className="text-xs text-gray-500 uppercase">{u.label}</div>
|
|
3750
3590
|
</div>
|
|
@@ -3772,6 +3612,7 @@ var featuredBlogPosts = {
|
|
|
3772
3612
|
name: "Featured Blog Posts",
|
|
3773
3613
|
description: "3-up grid of recent blog articles with cover image + title + excerpt",
|
|
3774
3614
|
component: `import type { SectionProps } from "@numueg/theme-sdk";
|
|
3615
|
+
import { useLocale } from "@numueg/theme-sdk";
|
|
3775
3616
|
|
|
3776
3617
|
interface Article {
|
|
3777
3618
|
handle: string;
|
|
@@ -3781,8 +3622,17 @@ interface Article {
|
|
|
3781
3622
|
cover_image?: string | null;
|
|
3782
3623
|
}
|
|
3783
3624
|
|
|
3625
|
+
// Bilingual AR/EN text without a shared import (keeps the snippet forkable).
|
|
3626
|
+
function useT() {
|
|
3627
|
+
const locale = useLocale();
|
|
3628
|
+
const isAr =
|
|
3629
|
+
typeof locale === "string" && locale.toLowerCase().startsWith("ar");
|
|
3630
|
+
return (en: string, ar: string) => (isAr ? ar : en);
|
|
3631
|
+
}
|
|
3632
|
+
|
|
3784
3633
|
export default function FeaturedBlogPosts({ settings }: SectionProps) {
|
|
3785
|
-
const
|
|
3634
|
+
const t = useT();
|
|
3635
|
+
const heading = (settings.heading as string) || t("From the blog", "\u0645\u0646 \u0627\u0644\u0645\u062F\u0648\u0646\u0629");
|
|
3786
3636
|
// Themes can pass articles in via page.data (the blogs route ships
|
|
3787
3637
|
// them on /[domain]/blogs). On other pages, this section needs a
|
|
3788
3638
|
// merchant-supplied list of links in the schema.
|
|
@@ -3826,9 +3676,19 @@ var contactMap = {
|
|
|
3826
3676
|
name: "Contact + Map",
|
|
3827
3677
|
description: "Two-column contact info (address / phone / hours) + embedded map iframe",
|
|
3828
3678
|
component: `import type { SectionProps } from "@numueg/theme-sdk";
|
|
3679
|
+
import { useLocale } from "@numueg/theme-sdk";
|
|
3680
|
+
|
|
3681
|
+
// Bilingual AR/EN text without a shared import (keeps the snippet forkable).
|
|
3682
|
+
function useT() {
|
|
3683
|
+
const locale = useLocale();
|
|
3684
|
+
const isAr =
|
|
3685
|
+
typeof locale === "string" && locale.toLowerCase().startsWith("ar");
|
|
3686
|
+
return (en: string, ar: string) => (isAr ? ar : en);
|
|
3687
|
+
}
|
|
3829
3688
|
|
|
3830
3689
|
export default function ContactMap({ settings }: SectionProps) {
|
|
3831
|
-
const
|
|
3690
|
+
const t = useT();
|
|
3691
|
+
const heading = (settings.heading as string) || t("Visit us", "\u0632\u0648\u0631\u0648\u0646\u0627");
|
|
3832
3692
|
const address = (settings.address as string) || "";
|
|
3833
3693
|
const phone = (settings.phone as string) || "";
|
|
3834
3694
|
const hours = (settings.hours as string) || "";
|
|
@@ -3841,19 +3701,19 @@ export default function ContactMap({ settings }: SectionProps) {
|
|
|
3841
3701
|
<div className="space-y-6">
|
|
3842
3702
|
{address && (
|
|
3843
3703
|
<div>
|
|
3844
|
-
<h3 className="font-semibold mb-1">Address</h3>
|
|
3704
|
+
<h3 className="font-semibold mb-1">{t("Address", "\u0627\u0644\u0639\u0646\u0648\u0627\u0646")}</h3>
|
|
3845
3705
|
<p className="text-gray-700 whitespace-pre-line">{address}</p>
|
|
3846
3706
|
</div>
|
|
3847
3707
|
)}
|
|
3848
3708
|
{phone && (
|
|
3849
3709
|
<div>
|
|
3850
|
-
<h3 className="font-semibold mb-1">Phone</h3>
|
|
3710
|
+
<h3 className="font-semibold mb-1">{t("Phone", "\u0627\u0644\u0647\u0627\u062A\u0641")}</h3>
|
|
3851
3711
|
<a href={\`tel:\${phone}\`} className="text-blue-700 hover:underline">{phone}</a>
|
|
3852
3712
|
</div>
|
|
3853
3713
|
)}
|
|
3854
3714
|
{hours && (
|
|
3855
3715
|
<div>
|
|
3856
|
-
<h3 className="font-semibold mb-1">Hours</h3>
|
|
3716
|
+
<h3 className="font-semibold mb-1">{t("Hours", "\u0633\u0627\u0639\u0627\u062A \u0627\u0644\u0639\u0645\u0644")}</h3>
|
|
3857
3717
|
<p className="text-gray-700 whitespace-pre-line">{hours}</p>
|
|
3858
3718
|
</div>
|
|
3859
3719
|
)}
|
|
@@ -3862,14 +3722,14 @@ export default function ContactMap({ settings }: SectionProps) {
|
|
|
3862
3722
|
{mapUrl ? (
|
|
3863
3723
|
<iframe
|
|
3864
3724
|
src={mapUrl}
|
|
3865
|
-
title="Map"
|
|
3725
|
+
title={t("Map", "\u062E\u0631\u064A\u0637\u0629")}
|
|
3866
3726
|
loading="lazy"
|
|
3867
3727
|
className="w-full h-full border-0"
|
|
3868
3728
|
referrerPolicy="no-referrer-when-downgrade"
|
|
3869
3729
|
allowFullScreen
|
|
3870
3730
|
/>
|
|
3871
3731
|
) : (
|
|
3872
|
-
<div className="flex items-center justify-center h-full text-gray-400">Set a map embed URL</div>
|
|
3732
|
+
<div className="flex items-center justify-center h-full text-gray-400">{t("Set a map embed URL", "\u0623\u0636\u0641 \u0631\u0627\u0628\u0637 \u062A\u0636\u0645\u064A\u0646 \u0627\u0644\u062E\u0631\u064A\u0637\u0629")}</div>
|
|
3873
3733
|
)}
|
|
3874
3734
|
</div>
|
|
3875
3735
|
</div>
|
|
@@ -3891,6 +3751,547 @@ export default function ContactMap({ settings }: SectionProps) {
|
|
|
3891
3751
|
}
|
|
3892
3752
|
};
|
|
3893
3753
|
|
|
3754
|
+
// src/section-library/entries/product-details.ts
|
|
3755
|
+
var productDetails = {
|
|
3756
|
+
slug: "product-details",
|
|
3757
|
+
name: "Product Details",
|
|
3758
|
+
description: "PDP buy box: gallery, variant/size pickers (size-chart aware) that gate add-to-cart",
|
|
3759
|
+
component: `import { useState } from "react";
|
|
3760
|
+
import type { SectionProps } from "@numueg/theme-sdk";
|
|
3761
|
+
import {
|
|
3762
|
+
useProductOptional,
|
|
3763
|
+
useVariantSelection,
|
|
3764
|
+
useProductSizeChart,
|
|
3765
|
+
useCart,
|
|
3766
|
+
useLocalization,
|
|
3767
|
+
useShop,
|
|
3768
|
+
useLocale,
|
|
3769
|
+
defaultVariant,
|
|
3770
|
+
} from "@numueg/theme-sdk";
|
|
3771
|
+
|
|
3772
|
+
// Bilingual AR/EN text without a shared import (keeps the snippet forkable).
|
|
3773
|
+
function useT() {
|
|
3774
|
+
const locale = useLocale();
|
|
3775
|
+
const isAr =
|
|
3776
|
+
typeof locale === "string" && locale.toLowerCase().startsWith("ar");
|
|
3777
|
+
return (en: string, ar: string) => (isAr ? ar : en);
|
|
3778
|
+
}
|
|
3779
|
+
// Coerce API money (often string-decimals) before any math.
|
|
3780
|
+
const toNum = (v: unknown) => Number(v) || 0;
|
|
3781
|
+
|
|
3782
|
+
/**
|
|
3783
|
+
* Product detail buy box. Renders the variant picker from real option axes
|
|
3784
|
+
* when present; otherwise derives required size buttons from the product's
|
|
3785
|
+
* size chart (common single-SKU case) and gates add-to-cart on a size pick \u2014
|
|
3786
|
+
* recording the chosen size on the cart note so the merchant fulfils it.
|
|
3787
|
+
*/
|
|
3788
|
+
export default function ProductDetails({ settings }: SectionProps) {
|
|
3789
|
+
const t = useT();
|
|
3790
|
+
const product = useProductOptional();
|
|
3791
|
+
const { cart, addItem, updateNote } = useCart();
|
|
3792
|
+
const { formatMoney } = useLocalization();
|
|
3793
|
+
const shop = useShop();
|
|
3794
|
+
const chart = useProductSizeChart();
|
|
3795
|
+
const [qty, setQty] = useState(1);
|
|
3796
|
+
const [pickedSize, setPickedSize] = useState<string | undefined>(undefined);
|
|
3797
|
+
const [pending, setPending] = useState(false);
|
|
3798
|
+
|
|
3799
|
+
const variantSel = useVariantSelection(
|
|
3800
|
+
product ?? { options: [], variants: [] },
|
|
3801
|
+
{ autoSelect: true },
|
|
3802
|
+
);
|
|
3803
|
+
|
|
3804
|
+
if (!product) {
|
|
3805
|
+
return (
|
|
3806
|
+
<section className="py-16 px-6 text-center text-gray-500">
|
|
3807
|
+
{t("Product not found.", "\u0644\u0645 \u064A\u062A\u0645 \u0627\u0644\u0639\u062B\u0648\u0631 \u0639\u0644\u0649 \u0627\u0644\u0645\u0646\u062A\u062C.")}
|
|
3808
|
+
</section>
|
|
3809
|
+
);
|
|
3810
|
+
}
|
|
3811
|
+
|
|
3812
|
+
const opts = product.options ?? [];
|
|
3813
|
+
const variants = product.variants ?? [];
|
|
3814
|
+
const hasOptions = opts.length > 0;
|
|
3815
|
+
const { selection, variant, select, availability, isComplete } = variantSel;
|
|
3816
|
+
|
|
3817
|
+
const chartSizes = (chart?.rows ?? [])
|
|
3818
|
+
.map((r) => r.size)
|
|
3819
|
+
.filter((sz): sz is string => Boolean(sz));
|
|
3820
|
+
const needsSize = !hasOptions && variants.length <= 1 && chartSizes.length > 0;
|
|
3821
|
+
|
|
3822
|
+
const activeVariant = hasOptions
|
|
3823
|
+
? variant
|
|
3824
|
+
: (defaultVariant(product) ?? variants[0] ?? null);
|
|
3825
|
+
const currency = product.currency || shop?.currency;
|
|
3826
|
+
const price = toNum(activeVariant?.price ?? product.price);
|
|
3827
|
+
const images = product.images ?? [];
|
|
3828
|
+
const productName = product.name;
|
|
3829
|
+
const productId = product.id;
|
|
3830
|
+
|
|
3831
|
+
const purchasable =
|
|
3832
|
+
product.in_stock &&
|
|
3833
|
+
(activeVariant?.is_in_stock ?? true) &&
|
|
3834
|
+
(hasOptions ? isComplete : true) &&
|
|
3835
|
+
(needsSize ? Boolean(pickedSize) : true);
|
|
3836
|
+
|
|
3837
|
+
async function handleAdd() {
|
|
3838
|
+
if (pending || !purchasable) return;
|
|
3839
|
+
setPending(true);
|
|
3840
|
+
try {
|
|
3841
|
+
if (needsSize && pickedSize) {
|
|
3842
|
+
const prefix = "\u2022 " + productName + " \u2014 ";
|
|
3843
|
+
const tag = prefix + t("Size", "\u0627\u0644\u0645\u0642\u0627\u0633") + ": ";
|
|
3844
|
+
const prev = cart?.note ?? "";
|
|
3845
|
+
const kept = prev
|
|
3846
|
+
.split("\\n")
|
|
3847
|
+
.filter((line) => line && !line.startsWith(prefix))
|
|
3848
|
+
.join("\\n");
|
|
3849
|
+
await updateNote((kept ? kept + "\\n" : "") + tag + pickedSize);
|
|
3850
|
+
}
|
|
3851
|
+
await addItem(productId, activeVariant?.id, qty);
|
|
3852
|
+
} finally {
|
|
3853
|
+
setPending(false);
|
|
3854
|
+
}
|
|
3855
|
+
}
|
|
3856
|
+
|
|
3857
|
+
const box =
|
|
3858
|
+
"min-w-[3rem] h-12 px-3 inline-flex items-center justify-center border rounded-md text-sm font-medium";
|
|
3859
|
+
const boxActive = "bg-black text-white border-black";
|
|
3860
|
+
|
|
3861
|
+
return (
|
|
3862
|
+
<section className="py-10 px-6 max-w-6xl mx-auto grid grid-cols-1 md:grid-cols-2 gap-10">
|
|
3863
|
+
<div>
|
|
3864
|
+
{images[0] ? (
|
|
3865
|
+
<img
|
|
3866
|
+
src={images[0].url}
|
|
3867
|
+
alt={images[0].alt || productName}
|
|
3868
|
+
className="w-full aspect-square object-cover rounded-lg bg-gray-100"
|
|
3869
|
+
/>
|
|
3870
|
+
) : (
|
|
3871
|
+
<div className="w-full aspect-square rounded-lg bg-gray-100" />
|
|
3872
|
+
)}
|
|
3873
|
+
</div>
|
|
3874
|
+
|
|
3875
|
+
<div>
|
|
3876
|
+
<h1 className="text-2xl font-bold mb-2">{productName}</h1>
|
|
3877
|
+
<p className="text-xl font-semibold mb-6">
|
|
3878
|
+
{formatMoney(price, currency)}
|
|
3879
|
+
</p>
|
|
3880
|
+
|
|
3881
|
+
{opts.map((opt) => (
|
|
3882
|
+
<div key={opt.name} className="mb-5">
|
|
3883
|
+
<span className="block text-sm font-semibold mb-2">{opt.name}</span>
|
|
3884
|
+
<div className="flex flex-wrap gap-2">
|
|
3885
|
+
{opt.values.map((value) => {
|
|
3886
|
+
const sel = selection[opt.name] === value;
|
|
3887
|
+
const reachable = availability[opt.name]?.has(value) ?? true;
|
|
3888
|
+
return (
|
|
3889
|
+
<button
|
|
3890
|
+
key={value}
|
|
3891
|
+
type="button"
|
|
3892
|
+
disabled={!reachable && !sel}
|
|
3893
|
+
onClick={() => select(opt.name, value)}
|
|
3894
|
+
className={box + (sel ? " " + boxActive : "") + (!reachable && !sel ? " opacity-40 line-through" : "")}
|
|
3895
|
+
>
|
|
3896
|
+
{value}
|
|
3897
|
+
</button>
|
|
3898
|
+
);
|
|
3899
|
+
})}
|
|
3900
|
+
</div>
|
|
3901
|
+
</div>
|
|
3902
|
+
))}
|
|
3903
|
+
|
|
3904
|
+
{needsSize ? (
|
|
3905
|
+
<div className="mb-5">
|
|
3906
|
+
<span className="block text-sm font-semibold mb-2">
|
|
3907
|
+
{t("Size", "\u0627\u0644\u0645\u0642\u0627\u0633")}
|
|
3908
|
+
</span>
|
|
3909
|
+
<div className="flex flex-wrap gap-2">
|
|
3910
|
+
{chartSizes.map((sz) => (
|
|
3911
|
+
<button
|
|
3912
|
+
key={sz}
|
|
3913
|
+
type="button"
|
|
3914
|
+
onClick={() => setPickedSize(sz)}
|
|
3915
|
+
className={box + (pickedSize === sz ? " " + boxActive : "")}
|
|
3916
|
+
>
|
|
3917
|
+
{sz}
|
|
3918
|
+
</button>
|
|
3919
|
+
))}
|
|
3920
|
+
</div>
|
|
3921
|
+
{!pickedSize ? (
|
|
3922
|
+
<p className="text-xs text-red-600 mt-2">
|
|
3923
|
+
{t("Please select a size", "\u0627\u062E\u062A\u0631 \u0627\u0644\u0645\u0642\u0627\u0633 \u0645\u0646 \u0641\u0636\u0644\u0643")}
|
|
3924
|
+
</p>
|
|
3925
|
+
) : null}
|
|
3926
|
+
</div>
|
|
3927
|
+
) : null}
|
|
3928
|
+
|
|
3929
|
+
<div className="flex items-center gap-3 mb-5">
|
|
3930
|
+
<span className="text-sm font-semibold">{t("Quantity", "\u0627\u0644\u0643\u0645\u064A\u0629")}</span>
|
|
3931
|
+
<div className="inline-flex items-center border rounded-md">
|
|
3932
|
+
<button type="button" className="px-3 py-2" onClick={() => setQty((q) => Math.max(1, q - 1))}>
|
|
3933
|
+
\u2212
|
|
3934
|
+
</button>
|
|
3935
|
+
<span className="px-3">{qty}</span>
|
|
3936
|
+
<button type="button" className="px-3 py-2" onClick={() => setQty((q) => q + 1)}>
|
|
3937
|
+
+
|
|
3938
|
+
</button>
|
|
3939
|
+
</div>
|
|
3940
|
+
</div>
|
|
3941
|
+
|
|
3942
|
+
<button
|
|
3943
|
+
type="button"
|
|
3944
|
+
disabled={!purchasable || pending}
|
|
3945
|
+
onClick={handleAdd}
|
|
3946
|
+
className="w-full py-3 rounded-full bg-black text-white font-semibold disabled:opacity-50"
|
|
3947
|
+
>
|
|
3948
|
+
{pending
|
|
3949
|
+
? "\u2026"
|
|
3950
|
+
: !product.in_stock
|
|
3951
|
+
? t("Sold out", "\u0646\u0641\u0630 \u0627\u0644\u0645\u062E\u0632\u0648\u0646")
|
|
3952
|
+
: needsSize && !pickedSize
|
|
3953
|
+
? t("Select a size", "\u0627\u062E\u062A\u0631 \u0627\u0644\u0645\u0642\u0627\u0633")
|
|
3954
|
+
: (settings.add_to_cart_label as string) ||
|
|
3955
|
+
t("Add to cart", "\u0623\u0636\u0641 \u0625\u0644\u0649 \u0627\u0644\u0633\u0644\u0629")}
|
|
3956
|
+
</button>
|
|
3957
|
+
</div>
|
|
3958
|
+
</section>
|
|
3959
|
+
);
|
|
3960
|
+
}
|
|
3961
|
+
`,
|
|
3962
|
+
schema: {
|
|
3963
|
+
type: "product-details",
|
|
3964
|
+
name: "Product Details",
|
|
3965
|
+
locales: { ar: { name: "\u062A\u0641\u0627\u0635\u064A\u0644 \u0627\u0644\u0645\u0646\u062A\u062C" } },
|
|
3966
|
+
settings: [
|
|
3967
|
+
{
|
|
3968
|
+
type: "text",
|
|
3969
|
+
id: "add_to_cart_label",
|
|
3970
|
+
label: "Add-to-cart label",
|
|
3971
|
+
locales: { ar: { label: "\u0646\u0635 \u0632\u0631 \u0627\u0644\u0625\u0636\u0627\u0641\u0629" } },
|
|
3972
|
+
default: "Add to cart"
|
|
3973
|
+
}
|
|
3974
|
+
]
|
|
3975
|
+
}
|
|
3976
|
+
};
|
|
3977
|
+
|
|
3978
|
+
// src/section-library/entries/frequently-bought.ts
|
|
3979
|
+
var frequentlyBought = {
|
|
3980
|
+
slug: "frequently-bought",
|
|
3981
|
+
name: "Frequently Bought Together",
|
|
3982
|
+
description: "Bundle the current product with related items; checkboxes + one-tap add-all",
|
|
3983
|
+
component: `import { useMemo, useState } from "react";
|
|
3984
|
+
import type { SectionProps } from "@numueg/theme-sdk";
|
|
3985
|
+
import {
|
|
3986
|
+
useProductOptional,
|
|
3987
|
+
useRelatedProducts,
|
|
3988
|
+
useProducts,
|
|
3989
|
+
useCart,
|
|
3990
|
+
useLocalization,
|
|
3991
|
+
useShop,
|
|
3992
|
+
useLocale,
|
|
3993
|
+
type Product,
|
|
3994
|
+
} from "@numueg/theme-sdk";
|
|
3995
|
+
|
|
3996
|
+
// --- Standards (self-contained so the snippet stays forkable) -------------
|
|
3997
|
+
// Bilingual AR/EN text without a shared import.
|
|
3998
|
+
function useT() {
|
|
3999
|
+
const locale = useLocale();
|
|
4000
|
+
const isAr =
|
|
4001
|
+
typeof locale === "string" && locale.toLowerCase().startsWith("ar");
|
|
4002
|
+
return (en: string, ar: string) => (isAr ? ar : en);
|
|
4003
|
+
}
|
|
4004
|
+
// Coerce API money (often string-decimals) so totals add instead of concat.
|
|
4005
|
+
const toNum = (v: unknown) => Number(v) || 0;
|
|
4006
|
+
// Tolerate images as {url} objects OR raw url strings.
|
|
4007
|
+
const imgUrl = (p?: Product) => {
|
|
4008
|
+
const raw = p?.images?.[0] as unknown;
|
|
4009
|
+
return typeof raw === "string"
|
|
4010
|
+
? raw
|
|
4011
|
+
: (raw as { url?: string } | undefined)?.url;
|
|
4012
|
+
};
|
|
4013
|
+
|
|
4014
|
+
interface FbtSettings {
|
|
4015
|
+
title?: string;
|
|
4016
|
+
max_items?: number;
|
|
4017
|
+
}
|
|
4018
|
+
|
|
4019
|
+
/**
|
|
4020
|
+
* Frequently bought together \u2014 seeds the bundle with the current product and
|
|
4021
|
+
* appends related items, falling back to the catalog. Each row is a toggle;
|
|
4022
|
+
* "add all" adds every checked product in one pass. Hidden when < 2 items.
|
|
4023
|
+
*/
|
|
4024
|
+
export default function FrequentlyBought({ settings }: SectionProps) {
|
|
4025
|
+
const s = settings as FbtSettings;
|
|
4026
|
+
const t = useT();
|
|
4027
|
+
const max = Math.max(2, Math.min(4, s.max_items ?? 3));
|
|
4028
|
+
|
|
4029
|
+
const product = useProductOptional();
|
|
4030
|
+
const related = useRelatedProducts(product?.id, { limit: max });
|
|
4031
|
+
const catalog = useProducts({ fetchIfMissing: true });
|
|
4032
|
+
const { addItem } = useCart();
|
|
4033
|
+
const { formatMoney } = useLocalization();
|
|
4034
|
+
const shop = useShop();
|
|
4035
|
+
|
|
4036
|
+
const bundle = useMemo(() => {
|
|
4037
|
+
const pool = related.items.length > 0 ? related.items : catalog.products;
|
|
4038
|
+
const seen = new Set<string>();
|
|
4039
|
+
const list: Product[] = [];
|
|
4040
|
+
if (product) {
|
|
4041
|
+
list.push(product);
|
|
4042
|
+
seen.add(product.id);
|
|
4043
|
+
}
|
|
4044
|
+
for (const p of pool) {
|
|
4045
|
+
if (list.length >= max) break;
|
|
4046
|
+
if (seen.has(p.id)) continue;
|
|
4047
|
+
seen.add(p.id);
|
|
4048
|
+
list.push(p);
|
|
4049
|
+
}
|
|
4050
|
+
return list;
|
|
4051
|
+
}, [product, related.items, catalog.products, max]);
|
|
4052
|
+
|
|
4053
|
+
const [selected, setSelected] = useState<Record<string, boolean>>({});
|
|
4054
|
+
const isOn = (id: string) => selected[id] !== false; // default on
|
|
4055
|
+
const [pending, setPending] = useState(false);
|
|
4056
|
+
|
|
4057
|
+
const currency = product?.currency || shop?.currency;
|
|
4058
|
+
const total = bundle
|
|
4059
|
+
.filter((p) => isOn(p.id))
|
|
4060
|
+
.reduce((sum, p) => sum + toNum(p.price), 0);
|
|
4061
|
+
|
|
4062
|
+
if (bundle.length < 2) return null;
|
|
4063
|
+
|
|
4064
|
+
async function addAll() {
|
|
4065
|
+
if (pending) return;
|
|
4066
|
+
setPending(true);
|
|
4067
|
+
try {
|
|
4068
|
+
for (const p of bundle) {
|
|
4069
|
+
if (!isOn(p.id)) continue;
|
|
4070
|
+
await addItem(p.id, p.variants?.[0]?.id, 1);
|
|
4071
|
+
}
|
|
4072
|
+
} finally {
|
|
4073
|
+
setPending(false);
|
|
4074
|
+
}
|
|
4075
|
+
}
|
|
4076
|
+
|
|
4077
|
+
return (
|
|
4078
|
+
<section className="py-12 px-6 max-w-3xl mx-auto">
|
|
4079
|
+
<h2 className="text-lg font-semibold mb-4">
|
|
4080
|
+
{s.title || t("Frequently bought together", "\u064A\u064F\u0634\u062A\u0631\u0649 \u0639\u0627\u062F\u0629\u064B \u0645\u0639\u0627\u064B")}
|
|
4081
|
+
</h2>
|
|
4082
|
+
<div className="flex flex-wrap items-stretch gap-3">
|
|
4083
|
+
{bundle.map((p, i) => (
|
|
4084
|
+
<div key={p.id} className="flex items-center gap-3">
|
|
4085
|
+
<label className="flex items-center gap-3 border rounded-lg p-3 cursor-pointer">
|
|
4086
|
+
<input
|
|
4087
|
+
type="checkbox"
|
|
4088
|
+
checked={isOn(p.id)}
|
|
4089
|
+
onChange={(e) =>
|
|
4090
|
+
setSelected((prev) => ({ ...prev, [p.id]: e.target.checked }))
|
|
4091
|
+
}
|
|
4092
|
+
/>
|
|
4093
|
+
{imgUrl(p) ? (
|
|
4094
|
+
<img
|
|
4095
|
+
src={imgUrl(p)}
|
|
4096
|
+
alt={p.name}
|
|
4097
|
+
loading="lazy"
|
|
4098
|
+
className="w-12 h-12 object-cover rounded"
|
|
4099
|
+
/>
|
|
4100
|
+
) : (
|
|
4101
|
+
<span className="w-12 h-12 rounded bg-gray-100" />
|
|
4102
|
+
)}
|
|
4103
|
+
<span className="text-sm">
|
|
4104
|
+
<span className="block font-medium">{p.name}</span>
|
|
4105
|
+
<span className="block text-gray-500">
|
|
4106
|
+
{formatMoney(toNum(p.price), currency)}
|
|
4107
|
+
</span>
|
|
4108
|
+
</span>
|
|
4109
|
+
</label>
|
|
4110
|
+
{i < bundle.length - 1 ? (
|
|
4111
|
+
<span className="text-gray-400" aria-hidden="true">
|
|
4112
|
+
+
|
|
4113
|
+
</span>
|
|
4114
|
+
) : null}
|
|
4115
|
+
</div>
|
|
4116
|
+
))}
|
|
4117
|
+
</div>
|
|
4118
|
+
<div className="flex items-center justify-between mt-5">
|
|
4119
|
+
<p className="text-sm">
|
|
4120
|
+
{t("Total", "\u0627\u0644\u0625\u062C\u0645\u0627\u0644\u064A")}:{" "}
|
|
4121
|
+
<strong>{formatMoney(total, currency)}</strong>
|
|
4122
|
+
</p>
|
|
4123
|
+
<button
|
|
4124
|
+
type="button"
|
|
4125
|
+
disabled={pending || total <= 0}
|
|
4126
|
+
onClick={addAll}
|
|
4127
|
+
className="px-5 py-2.5 rounded-full bg-black text-white text-sm font-semibold disabled:opacity-50"
|
|
4128
|
+
>
|
|
4129
|
+
{pending ? "\u2026" : t("Add all to cart", "\u0623\u0636\u0641 \u0627\u0644\u0643\u0644 \u0644\u0644\u0633\u0644\u0629")}
|
|
4130
|
+
</button>
|
|
4131
|
+
</div>
|
|
4132
|
+
</section>
|
|
4133
|
+
);
|
|
4134
|
+
}
|
|
4135
|
+
`,
|
|
4136
|
+
schema: {
|
|
4137
|
+
type: "frequently-bought",
|
|
4138
|
+
name: "Frequently Bought Together",
|
|
4139
|
+
locales: { ar: { name: "\u064A\u064F\u0634\u062A\u0631\u0649 \u0645\u0639\u0627\u064B" } },
|
|
4140
|
+
settings: [
|
|
4141
|
+
{
|
|
4142
|
+
type: "text",
|
|
4143
|
+
id: "title",
|
|
4144
|
+
label: "Section title",
|
|
4145
|
+
locales: { ar: { label: "\u0639\u0646\u0648\u0627\u0646 \u0627\u0644\u0642\u0633\u0645" } },
|
|
4146
|
+
default: "Frequently bought together"
|
|
4147
|
+
},
|
|
4148
|
+
{
|
|
4149
|
+
type: "range",
|
|
4150
|
+
id: "max_items",
|
|
4151
|
+
label: "Max items in bundle",
|
|
4152
|
+
locales: { ar: { label: "\u0623\u0642\u0635\u0649 \u0639\u062F\u062F \u0644\u0644\u0645\u0646\u062A\u062C\u0627\u062A" } },
|
|
4153
|
+
min: 2,
|
|
4154
|
+
max: 4,
|
|
4155
|
+
default: 3
|
|
4156
|
+
}
|
|
4157
|
+
]
|
|
4158
|
+
}
|
|
4159
|
+
};
|
|
4160
|
+
|
|
4161
|
+
// src/section-library/entries/size-guide.ts
|
|
4162
|
+
var sizeGuide = {
|
|
4163
|
+
slug: "size-guide",
|
|
4164
|
+
name: "Size Guide",
|
|
4165
|
+
description: "Size-chart trigger + modal table, resolved from the product/store size chart",
|
|
4166
|
+
component: `import { useState } from "react";
|
|
4167
|
+
import type { SectionProps } from "@numueg/theme-sdk";
|
|
4168
|
+
import { useProductSizeChart, useLocale } from "@numueg/theme-sdk";
|
|
4169
|
+
|
|
4170
|
+
// Bilingual AR/EN text without a shared import (keeps the snippet forkable).
|
|
4171
|
+
function useT() {
|
|
4172
|
+
const locale = useLocale();
|
|
4173
|
+
const isAr =
|
|
4174
|
+
typeof locale === "string" && locale.toLowerCase().startsWith("ar");
|
|
4175
|
+
return (en: string, ar: string) => (isAr ? ar : en);
|
|
4176
|
+
}
|
|
4177
|
+
|
|
4178
|
+
/**
|
|
4179
|
+
* Size guide \u2014 renders a trigger button and a modal table. The chart is
|
|
4180
|
+
* resolved by the SDK from the product chart (\`product.attributes.size_chart\`)
|
|
4181
|
+
* with a fallback to the store-wide default. Renders nothing when there's no
|
|
4182
|
+
* chart to show, or when an enabled chart is still empty.
|
|
4183
|
+
*/
|
|
4184
|
+
export default function SizeGuide({ settings }: SectionProps) {
|
|
4185
|
+
const t = useT();
|
|
4186
|
+
const [open, setOpen] = useState(false);
|
|
4187
|
+
const chart = useProductSizeChart();
|
|
4188
|
+
|
|
4189
|
+
const hasContent =
|
|
4190
|
+
!!chart &&
|
|
4191
|
+
((chart.rows?.length ?? 0) > 0 || Boolean(chart.image_url));
|
|
4192
|
+
if (!hasContent || !chart) return null;
|
|
4193
|
+
|
|
4194
|
+
const label = (settings.label as string) || t("Size guide", "\u062F\u0644\u064A\u0644 \u0627\u0644\u0645\u0642\u0627\u0633\u0627\u062A");
|
|
4195
|
+
|
|
4196
|
+
return (
|
|
4197
|
+
<div className="py-4 px-6 max-w-3xl mx-auto">
|
|
4198
|
+
<button
|
|
4199
|
+
type="button"
|
|
4200
|
+
onClick={() => setOpen(true)}
|
|
4201
|
+
className="inline-flex items-center gap-2 text-sm font-medium underline underline-offset-4"
|
|
4202
|
+
>
|
|
4203
|
+
{label}
|
|
4204
|
+
</button>
|
|
4205
|
+
|
|
4206
|
+
{open ? (
|
|
4207
|
+
<div
|
|
4208
|
+
role="dialog"
|
|
4209
|
+
aria-modal="true"
|
|
4210
|
+
aria-label={label}
|
|
4211
|
+
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
4212
|
+
>
|
|
4213
|
+
<div
|
|
4214
|
+
className="absolute inset-0 bg-black/50"
|
|
4215
|
+
onClick={() => setOpen(false)}
|
|
4216
|
+
/>
|
|
4217
|
+
<div className="relative bg-white rounded-xl max-w-lg w-full max-h-[85vh] overflow-auto p-6">
|
|
4218
|
+
<div className="flex items-center justify-between mb-4">
|
|
4219
|
+
<h2 className="text-lg font-semibold">
|
|
4220
|
+
{label}
|
|
4221
|
+
{chart.unit ? " (" + chart.unit + ")" : ""}
|
|
4222
|
+
</h2>
|
|
4223
|
+
<button
|
|
4224
|
+
type="button"
|
|
4225
|
+
onClick={() => setOpen(false)}
|
|
4226
|
+
aria-label={t("Close", "\u0625\u063A\u0644\u0627\u0642")}
|
|
4227
|
+
className="p-1"
|
|
4228
|
+
>
|
|
4229
|
+
\u2715
|
|
4230
|
+
</button>
|
|
4231
|
+
</div>
|
|
4232
|
+
{chart.image_url ? (
|
|
4233
|
+
<img
|
|
4234
|
+
src={chart.image_url}
|
|
4235
|
+
alt=""
|
|
4236
|
+
className="w-full rounded mb-4"
|
|
4237
|
+
/>
|
|
4238
|
+
) : null}
|
|
4239
|
+
{chart.rows?.length ? (
|
|
4240
|
+
<table className="w-full text-sm border-collapse">
|
|
4241
|
+
<thead>
|
|
4242
|
+
<tr>
|
|
4243
|
+
<th className="text-start p-2 border-b">
|
|
4244
|
+
{t("Size", "\u0627\u0644\u0645\u0642\u0627\u0633")}
|
|
4245
|
+
</th>
|
|
4246
|
+
{chart.column_headers.map((h, i) => (
|
|
4247
|
+
<th key={i} className="text-start p-2 border-b">
|
|
4248
|
+
{h}
|
|
4249
|
+
</th>
|
|
4250
|
+
))}
|
|
4251
|
+
</tr>
|
|
4252
|
+
</thead>
|
|
4253
|
+
<tbody>
|
|
4254
|
+
{chart.rows.map((row, ri) => (
|
|
4255
|
+
<tr key={ri}>
|
|
4256
|
+
<th scope="row" className="text-start p-2 border-b font-medium">
|
|
4257
|
+
{row.size}
|
|
4258
|
+
</th>
|
|
4259
|
+
{chart.column_headers.map((_, ci) => (
|
|
4260
|
+
<td key={ci} className="p-2 border-b">
|
|
4261
|
+
{row.values[ci] ?? "\u2014"}
|
|
4262
|
+
</td>
|
|
4263
|
+
))}
|
|
4264
|
+
</tr>
|
|
4265
|
+
))}
|
|
4266
|
+
</tbody>
|
|
4267
|
+
</table>
|
|
4268
|
+
) : null}
|
|
4269
|
+
{chart.notes ? (
|
|
4270
|
+
<p className="text-xs text-gray-500 mt-4">{chart.notes}</p>
|
|
4271
|
+
) : null}
|
|
4272
|
+
</div>
|
|
4273
|
+
</div>
|
|
4274
|
+
) : null}
|
|
4275
|
+
</div>
|
|
4276
|
+
);
|
|
4277
|
+
}
|
|
4278
|
+
`,
|
|
4279
|
+
schema: {
|
|
4280
|
+
type: "size-guide",
|
|
4281
|
+
name: "Size Guide",
|
|
4282
|
+
locales: { ar: { name: "\u062F\u0644\u064A\u0644 \u0627\u0644\u0645\u0642\u0627\u0633\u0627\u062A" } },
|
|
4283
|
+
settings: [
|
|
4284
|
+
{
|
|
4285
|
+
type: "text",
|
|
4286
|
+
id: "label",
|
|
4287
|
+
label: "Trigger label",
|
|
4288
|
+
locales: { ar: { label: "\u0646\u0635 \u0627\u0644\u0632\u0631" } },
|
|
4289
|
+
default: "Size guide"
|
|
4290
|
+
}
|
|
4291
|
+
]
|
|
4292
|
+
}
|
|
4293
|
+
};
|
|
4294
|
+
|
|
3894
4295
|
// src/section-library/index.ts
|
|
3895
4296
|
var LIBRARY = [
|
|
3896
4297
|
heroWithCta,
|
|
@@ -3907,7 +4308,10 @@ var LIBRARY = [
|
|
|
3907
4308
|
announcementBar,
|
|
3908
4309
|
countdownTimer,
|
|
3909
4310
|
featuredBlogPosts,
|
|
3910
|
-
contactMap
|
|
4311
|
+
contactMap,
|
|
4312
|
+
productDetails,
|
|
4313
|
+
frequentlyBought,
|
|
4314
|
+
sizeGuide
|
|
3911
4315
|
];
|
|
3912
4316
|
function findEntry(slug) {
|
|
3913
4317
|
return LIBRARY.find((e) => e.slug === slug);
|
|
@@ -3972,7 +4376,7 @@ var addSectionCommand = new import_commander13.Command("add-section").descriptio
|
|
|
3972
4376
|
]
|
|
3973
4377
|
};
|
|
3974
4378
|
}
|
|
3975
|
-
const componentPath = path12.join(themeDir, "src/sections", `${
|
|
4379
|
+
const componentPath = path12.join(themeDir, "src/sections", `${slug}.tsx`);
|
|
3976
4380
|
ensureDirOf(componentPath);
|
|
3977
4381
|
if (fs12.existsSync(componentPath)) {
|
|
3978
4382
|
console.error(`Already exists: ${componentPath}`);
|
|
@@ -3985,7 +4389,7 @@ var addSectionCommand = new import_commander13.Command("add-section").descriptio
|
|
|
3985
4389
|
tryWireMain(themeDir, slug, pascal);
|
|
3986
4390
|
tryAddToHomePreset(themeDir, slug);
|
|
3987
4391
|
console.log(`\u2714 Created section '${slug}'.`);
|
|
3988
|
-
console.log(` \u2022 src/sections/${
|
|
4392
|
+
console.log(` \u2022 src/sections/${slug}.tsx`);
|
|
3989
4393
|
console.log(` \u2022 schemas/sections/${slug}.json`);
|
|
3990
4394
|
console.log(
|
|
3991
4395
|
options.fromLibrary ? `
|
|
@@ -4024,8 +4428,8 @@ function tryWireMain(themeDir, slug, pascal) {
|
|
|
4024
4428
|
const mainPath = path12.join(themeDir, "src/main.tsx");
|
|
4025
4429
|
if (!fs12.existsSync(mainPath)) return;
|
|
4026
4430
|
const src = fs12.readFileSync(mainPath, "utf-8");
|
|
4027
|
-
if (src.includes(`./sections/${
|
|
4028
|
-
const importLine = `import ${pascal} from "./sections/${
|
|
4431
|
+
if (src.includes(`./sections/${slug}"`)) return;
|
|
4432
|
+
const importLine = `import ${pascal} from "./sections/${slug}";
|
|
4029
4433
|
`;
|
|
4030
4434
|
const lines = src.split("\n");
|
|
4031
4435
|
let lastImportIdx = -1;
|
|
@@ -4037,6 +4441,14 @@ function tryWireMain(themeDir, slug, pascal) {
|
|
|
4037
4441
|
} else {
|
|
4038
4442
|
lines.unshift(importLine.trimEnd());
|
|
4039
4443
|
}
|
|
4444
|
+
for (let i = 0; i < lines.length; i++) {
|
|
4445
|
+
if (/SECTION_REGISTRY[^=]*=\s*\{/.test(lines[i])) {
|
|
4446
|
+
const indent = (lines[i].match(/^\s*/)?.[0] ?? "") + " ";
|
|
4447
|
+
lines.splice(i + 1, 0, `${indent}"${slug}": ${pascal},`);
|
|
4448
|
+
fs12.writeFileSync(mainPath, lines.join("\n"));
|
|
4449
|
+
return;
|
|
4450
|
+
}
|
|
4451
|
+
}
|
|
4040
4452
|
let inserted = false;
|
|
4041
4453
|
for (let i = 0; i < lines.length; i++) {
|
|
4042
4454
|
if (/section\.type\s*===\s*['"]/.test(lines[i])) {
|