@onexapis/cli 1.1.28 → 1.1.29

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.
@@ -22,18 +22,25 @@ import * as coreBlog from "@onexapis/core/blog";
22
22
  import * as coreFinance from "@onexapis/core/finance";
23
23
  import * as coreInternal from "@onexapis/core/internal";
24
24
  import * as coreTypes from "@onexapis/core/types";
25
- import { CartProvider } from "@onexapis/core/contexts";
25
+ import { CartProvider, FlyToCartProvider } from "@onexapis/core/contexts";
26
26
  import { LocaleProvider } from "@onexapis/core/contexts";
27
27
 
28
28
  // Compose @onexapis/core/hooks from published subpaths
29
- // (./hooks is not published in @onexapis/core yet, but themes import from it)
29
+ // Only include use* hook functions spreading entire modules includes
30
+ // Zustand stores and React providers that cause "selector is not a function" errors.
30
31
  const coreHooks = {
31
32
  ...coreCommerceHooks,
32
- ...(coreCart as any),
33
- ...(coreAuth as any),
34
- ...(coreOrders as any),
35
- ...(coreContexts as any),
36
33
  };
34
+ // Add context/state hooks individually (avoid spreading full modules)
35
+ for (const mod of [coreContexts, coreAuth, coreOrders, coreCart] as any[]) {
36
+ if (mod && typeof mod === "object") {
37
+ for (const [k, v] of Object.entries(mod)) {
38
+ if (typeof v === "function" && k.startsWith("use") && !(k in coreHooks)) {
39
+ (coreHooks as any)[k] = v;
40
+ }
41
+ }
42
+ }
43
+ }
37
44
 
38
45
  // Set React globals
39
46
  (globalThis as any).__ONEX_REACT__ = React;
@@ -346,24 +353,91 @@ function discoverPageConfigs(
346
353
 
347
354
  // ===== 8. PREVIEW APP COMPONENT =====
348
355
 
356
+ /**
357
+ * Match a URL path against a route pattern with [param] segments.
358
+ * Returns extracted params if matched, null otherwise.
359
+ * E.g. matchDynamicPath("/products/[slug]", "/products/my-product") → { slug: "my-product" }
360
+ */
361
+ function matchDynamicPath(
362
+ pattern: string,
363
+ pathname: string
364
+ ): Record<string, string> | null {
365
+ const patternParts = pattern.replace(/^\/+|\/+$/g, "").split("/");
366
+ const pathParts = pathname.replace(/^\/+|\/+$/g, "").split("/");
367
+ if (patternParts.length !== pathParts.length) return null;
368
+ const params: Record<string, string> = {};
369
+ for (let i = 0; i < patternParts.length; i++) {
370
+ const segMatch = patternParts[i].match(/^\[(\w+)\]$/);
371
+ if (segMatch) {
372
+ params[segMatch[1]] = pathParts[i];
373
+ } else if (patternParts[i].toLowerCase() !== pathParts[i].toLowerCase()) {
374
+ return null;
375
+ }
376
+ }
377
+ return params;
378
+ }
379
+
380
+ const PREVIEW_SUPPORTED_LOCALES = ["vi", "en"];
381
+
382
+ /**
383
+ * Extract locale prefix from URL pathname.
384
+ * "/vi/san-pham/slug" → { locale: "vi", rest: "/san-pham/slug" }
385
+ * "/products/slug" → { locale: "en", rest: "/products/slug" }
386
+ */
387
+ function extractLocaleFromUrl(pathname: string): { locale: string; rest: string } {
388
+ const parts = pathname.replace(/^\/+/, "").split("/");
389
+ if (parts[0] && PREVIEW_SUPPORTED_LOCALES.includes(parts[0])) {
390
+ return { locale: parts[0], rest: "/" + parts.slice(1).join("/") };
391
+ }
392
+ return { locale: "en", rest: pathname };
393
+ }
394
+
395
+ /**
396
+ * Get the effective path for a page in a given locale.
397
+ * Uses localizedPaths[locale] if available, falls back to path.
398
+ */
399
+ function getPagePathForLocale(config: any, locale: string): string | undefined {
400
+ return config?.localizedPaths?.[locale] || config?.path;
401
+ }
402
+
349
403
  /**
350
404
  * Match URL pathname to a page index.
351
- * "/" or "/home" home page, "/showcase" showcase page, etc.
405
+ * Supports: static pages, dynamic routes, locale-prefixed URLs, and localizedPaths.
352
406
  */
353
407
  function getInitialPageFromURL(
354
- pages: Array<{ key: string; label: string; config: any }>
408
+ pages: Array<{ key: string; label: string; config: any }>,
409
+ locale: string,
410
+ restPath: string
355
411
  ): number {
356
- const pathname = window.location.pathname
357
- .replace(/^\/+|\/+$/g, "")
358
- .toLowerCase();
359
- if (!pathname || pathname === "home") return 0; // home is always first after sorting
412
+ const pathname = restPath.replace(/^\/+|\/+$/g, "").toLowerCase();
413
+ if (!pathname || pathname === "home") return 0;
360
414
 
361
- const idx = pages.findIndex((p) => {
415
+ // 1. Try exact static match (by label or key)
416
+ let idx = pages.findIndex((p) => {
362
417
  const pageSlug = p.label.toLowerCase().replace(/\s+/g, "-");
363
418
  const pageKey = p.key.replace("PageConfig", "").toLowerCase();
364
419
  return pageSlug === pathname || pageKey === pathname;
365
420
  });
366
- return idx >= 0 ? idx : -2; // -2 = page not found
421
+ if (idx >= 0) return idx;
422
+
423
+ // 2. Try matching against localizedPaths[locale] or fallback path
424
+ for (let i = 0; i < pages.length; i++) {
425
+ const config = pages[i].config;
426
+ const effectivePath = getPagePathForLocale(config, locale);
427
+ if (!effectivePath) continue;
428
+
429
+ // Static locale path match (e.g., /gioi-thieu matches localizedPaths.vi: "/gioi-thieu")
430
+ const normalizedEffective = effectivePath.replace(/^\/+|\/+$/g, "").toLowerCase();
431
+ if (normalizedEffective === pathname) return i;
432
+
433
+ // Dynamic locale path match (e.g., /san-pham/my-slug matches localizedPaths.vi: "/san-pham/[slug]")
434
+ if (config?.isDynamic) {
435
+ const params = matchDynamicPath(effectivePath, `/${pathname}`);
436
+ if (params) return i;
437
+ }
438
+ }
439
+
440
+ return -2; // page not found
367
441
  }
368
442
 
369
443
  function PreviewApp() {
@@ -468,9 +542,16 @@ function PreviewApp() {
468
542
  const pages = discoverPageConfigs(themeExports);
469
543
  const sectionRegistry = buildSectionRegistry(themeExports);
470
544
 
545
+ // Extract locale from URL (e.g., /vi/san-pham/slug → locale="vi", rest="/san-pham/slug")
546
+ const { locale: urlLocale, rest: urlRestPath } = extractLocaleFromUrl(
547
+ window.location.pathname
548
+ );
549
+
471
550
  // Resolve initial page from URL path (once pages are discovered)
472
551
  const resolvedPage =
473
- selectedPage === -1 ? getInitialPageFromURL(pages) : selectedPage;
552
+ selectedPage === -1
553
+ ? getInitialPageFromURL(pages, urlLocale, urlRestPath)
554
+ : selectedPage;
474
555
 
475
556
  // Page not found (-2)
476
557
  if (resolvedPage === -2) {
@@ -547,13 +628,25 @@ function PreviewApp() {
547
628
  );
548
629
  }
549
630
 
550
- // Build section data (Gap 4)
631
+ // Extract route params for dynamic pages using locale-aware path
632
+ let routeParams: Record<string, string> = {};
633
+ if (currentPage.config?.isDynamic) {
634
+ const effectivePath = getPagePathForLocale(currentPage.config, urlLocale);
635
+ if (effectivePath) {
636
+ const params = matchDynamicPath(effectivePath, urlRestPath);
637
+ if (params) routeParams = params;
638
+ }
639
+ }
640
+
641
+ // Build section data
551
642
  const sectionData = {
552
643
  theme: themeExports.themeConfig || {},
553
644
  page: currentPage.config,
554
645
  products: [],
555
646
  blogs: [],
556
647
  settings: {},
648
+ routeParams,
649
+ locale: urlLocale,
557
650
  };
558
651
 
559
652
  // Resolve layout sections (Gap 1)
@@ -639,12 +732,17 @@ const queryClient = new QueryClient({
639
732
  },
640
733
  });
641
734
 
735
+ // Detect locale from URL for the root provider
736
+ const _rootLocale = extractLocaleFromUrl(window.location.pathname).locale;
737
+
642
738
  const root = createRoot(document.getElementById("onex-preview-root")!);
643
739
  root.render(
644
740
  <QueryClientProvider client={queryClient}>
645
- <LocaleProvider locale="en">
741
+ <LocaleProvider locale={_rootLocale as any}>
646
742
  <CartProvider>
647
- <PreviewApp />
743
+ <FlyToCartProvider>
744
+ <PreviewApp />
745
+ </FlyToCartProvider>
648
746
  </CartProvider>
649
747
  </LocaleProvider>
650
748
  </QueryClientProvider>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onexapis/cli",
3
- "version": "1.1.28",
3
+ "version": "1.1.29",
4
4
  "description": "CLI tool for OneX theme development - scaffolds themes using @onexapis/core",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -50,7 +50,7 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@aws-sdk/client-s3": "^3.470.0",
53
- "@onexapis/core": "^1.0.2",
53
+ "@onexapis/core": "workspace:*",
54
54
  "@tanstack/react-query": "^5.90.16",
55
55
  "adm-zip": "^0.5.16",
56
56
  "archiver": "^7.0.1",
@@ -6,7 +6,12 @@
6
6
  },
7
7
  "figma": {
8
8
  "command": "npx",
9
- "args": ["-y", "figma-developer-mcp", "--figma-api-key=__FIGMA_API_KEY__", "--stdio"]
9
+ "args": [
10
+ "-y",
11
+ "figma-developer-mcp",
12
+ "--figma-api-key=__FIGMA_API_KEY__",
13
+ "--stdio"
14
+ ]
10
15
  }
11
16
  }
12
17
  }
@@ -595,6 +595,98 @@ When `dataRequirements.products = true`:
595
595
  - Use `useCommerceData(data)` to access it
596
596
  - Falls back to client-side `useProducts()` if not pre-fetched
597
597
 
598
+ ## Dynamic Pages (Product Detail, Blog Detail)
599
+
600
+ Dynamic pages use URL parameters like `/products/[slug]` to render detail views.
601
+
602
+ ### Define a dynamic page in `pages/`
603
+
604
+ ```typescript
605
+ // pages/product-detail.ts
606
+ import type { PageConfig } from "@onexapis/core/types";
607
+
608
+ export const productDetailPageConfig: Omit<PageConfig, "id" | "createdAt" | "updatedAt"> = {
609
+ title: "Product Detail",
610
+ handle: "product-detail",
611
+ path: "/products/[slug]", // Dynamic segment in brackets
612
+ isDynamic: true, // Flag as dynamic
613
+ dynamicSegments: ["slug"], // Parameter names
614
+ type: "product",
615
+ renderMode: "sections",
616
+ themeId: "my-theme",
617
+ editable: true,
618
+ published: true,
619
+ sections: [
620
+ {
621
+ id: "product-detail-1",
622
+ type: "my-product-detail",
623
+ template: "default",
624
+ order: 0,
625
+ enabled: true,
626
+ settings: {},
627
+ components: [],
628
+ blocks: [],
629
+ },
630
+ ],
631
+ };
632
+ export default productDetailPageConfig;
633
+ ```
634
+
635
+ Export in `bundle-entry.ts`:
636
+ ```typescript
637
+ export { default as productDetailPageConfig } from "./pages/product-detail";
638
+ ```
639
+
640
+ ### Access route params in sections
641
+
642
+ Sections receive `data.routeParams` with the extracted URL parameters:
643
+
644
+ ```tsx
645
+ export function ProductDetail({ section, schema, isEditing, data }: SectionComponentProps) {
646
+ const routeParams = (data?.routeParams || {}) as Record<string, string>;
647
+ const slug = routeParams.slug || ""; // "blue-shirt" from /products/blue-shirt
648
+
649
+ const { data: product, isLoading } = useProductBySlug(slug, !!slug);
650
+ // ...
651
+ }
652
+ ```
653
+
654
+ ### Locale-aware paths
655
+
656
+ Use `localizedPaths` to define different URL slugs per locale:
657
+
658
+ ```typescript
659
+ export const productDetailPageConfig = {
660
+ handle: "product-detail",
661
+ path: "/products/[slug]", // Default/fallback
662
+ isDynamic: true,
663
+ dynamicSegments: ["slug"],
664
+ localizedPaths: { // Locale-specific paths (typed by Locale enum)
665
+ vi: "/san-pham/[slug]", // /vi/san-pham/blue-shirt
666
+ en: "/products/[slug]", // /en/products/blue-shirt
667
+ },
668
+ type: "product",
669
+ // ...
670
+ };
671
+ ```
672
+
673
+ The `Locale` type (`"vi" | "en"`) is from `@onexapis/core/types` — gives autocomplete.
674
+
675
+ Sections also receive `data.locale` to know the current locale:
676
+ ```tsx
677
+ const locale = data?.locale as string; // "vi" or "en"
678
+ ```
679
+
680
+ ### Key conventions
681
+
682
+ - `path` uses bracket syntax: `/products/[slug]`, `/blog/[slug]`
683
+ - `localizedPaths` maps `Locale` → path for locale-specific URLs
684
+ - `dynamicSegments` lists parameter names extracted from the path
685
+ - `type: "product"` or `"blog"` indicates what kind of detail page
686
+ - Sections access params via `data.routeParams.slug` (or whatever parameter name)
687
+ - Sections access locale via `data.locale` or `useLocale()` hook
688
+ - Use `useProductBySlug()` / `useBlogBySlug()` hooks to fetch detail data
689
+
598
690
  ## Product & Blog Types
599
691
 
600
692
  ### Product
@@ -909,6 +1001,123 @@ export function ProductGrid({
909
1001
  export default ProductGrid;
910
1002
  ```
911
1003
 
1004
+ ## Cart & Fly-to-Cart Animation
1005
+
1006
+ The cart system uses `useCart()` for state and a flexible fly-to-cart animation triggered by `data-fly-to-cart-target`.
1007
+
1008
+ ### Cart State
1009
+
1010
+ ```tsx
1011
+ import { useCart } from "@onexapis/core/hooks";
1012
+
1013
+ const { items, addItem, removeItem, updateQuantity, clearCart, itemCount, subtotal } = useCart();
1014
+
1015
+ // Add to cart
1016
+ addItem({
1017
+ productId: product.id,
1018
+ name: product.title,
1019
+ image: product.image,
1020
+ price: product.salePrice,
1021
+ slug: product.slug,
1022
+ });
1023
+ ```
1024
+
1025
+ ### Fly-to-Cart Animation
1026
+
1027
+ When a user clicks "Add to Cart", the product thumbnail flies from the button to the cart icon in the header.
1028
+
1029
+ **Step 1: Mark any element as the cart target** (in header section):
1030
+
1031
+ ```tsx
1032
+ // Option A: Use any element — just add the data attribute
1033
+ <div data-fly-to-cart-target className="my-cart-icon">
1034
+ <ShoppingCartIcon />
1035
+ <span>{itemCount}</span>
1036
+ </div>
1037
+
1038
+ // Option B: Use CartIcon convenience component (auto-adds data-fly-to-cart-target)
1039
+ import { CartIcon } from "@onexapis/core/components";
1040
+ <CartIcon count={itemCount} onClick={() => openCartDrawer()} />
1041
+ ```
1042
+
1043
+ **Step 2: ProductCard triggers animation automatically** — just pass `onAddToCart`:
1044
+
1045
+ ```tsx
1046
+ <ProductCard
1047
+ product={product}
1048
+ onAddToCart={(p) => addItem({ productId: p.id, name: p.title, image: p.image, price: p.salePrice })}
1049
+ />
1050
+ ```
1051
+
1052
+ **Step 3: Custom trigger** (for buttons that aren't ProductCard):
1053
+
1054
+ ```tsx
1055
+ import { useFlyToCart } from "@onexapis/core/hooks";
1056
+
1057
+ const { flyToCart } = useFlyToCart();
1058
+
1059
+ <button onClick={(e) => {
1060
+ flyToCart(e.currentTarget, product.image); // animation
1061
+ addItem({ ... }); // cart logic
1062
+ }}>
1063
+ Buy Now
1064
+ </button>
1065
+ ```
1066
+
1067
+ ### How it works
1068
+
1069
+ - `data-fly-to-cart-target` on any element → animation lands there (detected via `querySelector`)
1070
+ - `FlyToCartProvider` renders a portal with the flying thumbnail (CSS transition)
1071
+ - No refs or context wiring needed — just add the data attribute
1072
+ - Without `data-fly-to-cart-target` → cart still works, no animation
1073
+
1074
+ ## Server-Side APIs
1075
+
1076
+ Server-side data fetching is available from `@onexapis/core/server`. Use in Next.js server components, server actions, or API routes.
1077
+
1078
+ ```tsx
1079
+ import {
1080
+ fetchProducts,
1081
+ fetchBlogs,
1082
+ fetchSettings,
1083
+ fetchCompany,
1084
+ fetchTheme,
1085
+ fetchPage,
1086
+ prefetchSectionData,
1087
+ } from "@onexapis/core/server";
1088
+
1089
+ // Fetch products (server-side with ISR caching)
1090
+ const { data: products, pagination } = await fetchProducts(companyId, { limit: 12 });
1091
+
1092
+ // Fetch website settings
1093
+ const settings = await fetchSettings(companyId);
1094
+
1095
+ // Smart prefetch — scans sections for dataRequirements, fetches in parallel
1096
+ const data = await prefetchSectionData({
1097
+ companyId,
1098
+ sections: allSections,
1099
+ dataRequirements: manifest.dataRequirements,
1100
+ });
1101
+ // data.products, data.blogs, data.settings
1102
+ ```
1103
+
1104
+ ### Advanced: Custom server client
1105
+
1106
+ ```tsx
1107
+ import { CommerceServerClient, noopCacheAdapter } from "@onexapis/core/server";
1108
+
1109
+ const client = new CommerceServerClient({
1110
+ apiUrl: "https://api.example.com",
1111
+ companyId: "xxx",
1112
+ cacheAdapter: noopCacheAdapter, // For non-Next.js environments
1113
+ });
1114
+
1115
+ const products = await client.getProducts({ limit: 8 });
1116
+ const blog = await client.getBlogBySlug("my-post");
1117
+ ```
1118
+
1119
+ Environment variables auto-detected: `NEXT_PUBLIC_COMMERCE_API_URL`, `NEXT_PUBLIC_API_URL`.
1120
+
912
1121
  ## MCP Servers
913
1122
 
914
1123
  This project has THREE MCP servers. Do NOT confuse them:
@@ -924,11 +1133,13 @@ Registered in `.mcp.json` in this project. Provides theme-specific tools:
924
1133
  - `onexthm_from_figma` — **Convert Figma design to OneX section** (see Figma Integration below)
925
1134
 
926
1135
  Resources (auto-loaded context):
1136
+
927
1137
  - `onexthm://rules` — Theme development rules (DOs/DON'Ts)
928
1138
  - `onexthm://field-types` — All available field types and categories
929
1139
  - `onexthm://hooks` — Hooks reference with examples
930
1140
 
931
1141
  Prompts (guided workflows):
1142
+
932
1143
  - `create_section` — Guided section creation workflow
933
1144
  - `review_theme` — Review theme for issues
934
1145
  - `figma_to_section` — Full Figma-to-OneX conversion pipeline
@@ -944,6 +1155,7 @@ Registered in `.mcp.json`. Reads Figma designs for design-to-code conversion:
944
1155
  - `search_design_system` — Search components, variables, styles from libraries
945
1156
 
946
1157
  **Setup**: Requires Figma API key in `.mcp.json`:
1158
+
947
1159
  ```json
948
1160
  {
949
1161
  "figma": {
@@ -952,6 +1164,7 @@ Registered in `.mcp.json`. Reads Figma designs for design-to-code conversion:
952
1164
  }
953
1165
  }
954
1166
  ```
1167
+
955
1168
  Get your key: Figma → Settings → Account → Personal access tokens.
956
1169
 
957
1170
  ### `onex-platform` MCP (Backend Services) — DO NOT USE FOR THEMES
@@ -964,17 +1177,17 @@ This is for managing microservices on the OneXEOS platform. Its tools are:
964
1177
 
965
1178
  ### When to use which
966
1179
 
967
- | Task | MCP to use |
968
- | --------------------------------- | ----------------------------------------- |
969
- | Create a new section | `onexthm_create_section` |
970
- | Convert Figma design to section | `onexthm_from_figma` + `figma` tools |
971
- | Validate theme structure | `onexthm_validate` |
972
- | Look up available hooks | `onexthm_list_hooks` |
973
- | Generate schema from description | `onexthm_generate_schema` |
974
- | Read Figma design layers | `figma:get_metadata` |
975
- | Get Figma design tokens | `figma:get_variable_defs` |
976
- | Deploy a backend service | `onex_deploy` (platform MCP) |
977
- | Check service health | `onex_status` (platform MCP) |
1180
+ | Task | MCP to use |
1181
+ | -------------------------------- | ------------------------------------ |
1182
+ | Create a new section | `onexthm_create_section` |
1183
+ | Convert Figma design to section | `onexthm_from_figma` + `figma` tools |
1184
+ | Validate theme structure | `onexthm_validate` |
1185
+ | Look up available hooks | `onexthm_list_hooks` |
1186
+ | Generate schema from description | `onexthm_generate_schema` |
1187
+ | Read Figma design layers | `figma:get_metadata` |
1188
+ | Get Figma design tokens | `figma:get_variable_defs` |
1189
+ | Deploy a backend service | `onex_deploy` (platform MCP) |
1190
+ | Check service health | `onex_status` (platform MCP) |
978
1191
 
979
1192
  ## Figma → OneX Conversion
980
1193
 
@@ -983,6 +1196,7 @@ Convert Figma designs directly to OneX theme sections using the combined Figma +
983
1196
  ### Quick Start
984
1197
 
985
1198
  Use the `figma_to_section` prompt for a guided workflow:
1199
+
986
1200
  ```
987
1201
  "Convert the selected Figma frame to a section called 'hero'"
988
1202
  ```