@numueg/theme-sdk 0.2.2 → 0.2.3

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/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![npm](https://img.shields.io/npm/v/@numueg/theme-sdk.svg)](https://www.npmjs.com/package/@numueg/theme-sdk)
6
6
  [![license](https://img.shields.io/npm/l/@numueg/theme-sdk.svg)](./LICENSE)
7
7
 
8
- The SDK every NUMU theme consumes. 25+ hooks (`useCart`, `useProduct`, `useCheckout`, `useVariantSelection`, `useGiftCardBalance`, …), 15+ components (`AddToCartButton`, `ProductCard`, `Money`, `Section`, `NuMuProvider`, …), and helpers for variant resolution, asset URLs, and federation singletons.
8
+ The SDK every NUMU theme consumes. The `mountTheme()` runtime helper, 27+ hooks (`useCart`, `useProduct`, `useCheckout`, `useVariantSelection`, `useCurrentTemplate`, `useResolvedSettings`, …), 15+ components (`AddToCartButton`, `ProductCard`, `Money`, `Section`, `EditableText`/`EditableImage`, `NuMuProvider`, …), and helpers for variant resolution, focal image crops (`focalSrc`), global style tokens, asset URLs, and federation singletons.
9
9
 
10
10
  Themes import via the bare specifier `@numueg/theme-sdk` — at runtime the storefront's import map resolves it to the host-loaded singleton so every theme on the platform shares one React identity.
11
11
 
@@ -31,23 +31,23 @@ The `@numueg/theme-plugin` Vite plugin does this for you automatically.
31
31
 
32
32
  ## Usage
33
33
 
34
+ The host storefront calls your bundle's `mount(el, ctx)` where `ctx = { storeData, page, themeSettings, locale, … }`. Use `mountTheme()` — it wires catalog data, global style tokens, navigation, and live-edit updates for you and returns the `{ unmount, update }` contract the host expects:
35
+
34
36
  ```tsx
35
- import { createRoot } from "react-dom/client";
36
- import { NuMuProvider, useCart, AddToCartButton } from "@numueg/theme-sdk";
37
- import type { MountContext } from "@numueg/theme-sdk";
37
+ import { mountTheme, useCart } from "@numueg/theme-sdk";
38
38
 
39
39
  function App() {
40
40
  const { cart } = useCart();
41
41
  return <p>Items in cart: {cart.item_count}</p>;
42
42
  }
43
43
 
44
- export function mount(ctx: MountContext) {
45
- const root = createRoot(document.getElementById("numu-root")!);
46
- root.render(<NuMuProvider {...ctx}><App /></NuMuProvider>);
47
- return () => root.unmount();
44
+ export function mount(el: HTMLElement, ctx: unknown) {
45
+ return mountTheme(el, ctx, () => <App />);
48
46
  }
49
47
  ```
50
48
 
49
+ > The authoritative `ctx` shape is defined by the host's `ByotThemeBoundary` (numu-storefront), not by SDK types — accept it as opaque and let `mountTheme`/`NuMuProvider` normalize it.
50
+
51
51
  ## Docs
52
52
 
53
53
  - [SDK Overview](https://numueg.app/docs/sdk/overview)
@@ -138,6 +138,25 @@ interface Order {
138
138
  items: OrderItem[];
139
139
  created_at: string;
140
140
  shipping_address?: Address;
141
+ /**
142
+ * Discount code the customer applied at checkout, if any. Sourced from
143
+ * the backend order-detail (`GET /storefront/me/orders/{id}`). Themes
144
+ * render this on the order-confirmation / order-detail page.
145
+ */
146
+ coupon_code?: string | null;
147
+ /**
148
+ * Automatic offers (offers-v2 promotions) applied to the order. Each
149
+ * entry carries the localized title and the discount it contributed.
150
+ * `amount` is in integer cents — divide by 100 (or pass through
151
+ * `<Money>`) before display, consistent with the rest of the order's
152
+ * money fields.
153
+ */
154
+ applied_promotions?: {
155
+ id: string;
156
+ title: string;
157
+ title_ar?: string;
158
+ amount: number;
159
+ }[];
141
160
  }
142
161
  interface OrderItem {
143
162
  product_id: string;
@@ -138,6 +138,25 @@ interface Order {
138
138
  items: OrderItem[];
139
139
  created_at: string;
140
140
  shipping_address?: Address;
141
+ /**
142
+ * Discount code the customer applied at checkout, if any. Sourced from
143
+ * the backend order-detail (`GET /storefront/me/orders/{id}`). Themes
144
+ * render this on the order-confirmation / order-detail page.
145
+ */
146
+ coupon_code?: string | null;
147
+ /**
148
+ * Automatic offers (offers-v2 promotions) applied to the order. Each
149
+ * entry carries the localized title and the discount it contributed.
150
+ * `amount` is in integer cents — divide by 100 (or pass through
151
+ * `<Money>`) before display, consistent with the rest of the order's
152
+ * money fields.
153
+ */
154
+ applied_promotions?: {
155
+ id: string;
156
+ title: string;
157
+ title_ar?: string;
158
+ amount: number;
159
+ }[];
141
160
  }
142
161
  interface OrderItem {
143
162
  product_id: string;
package/dist/index.cjs CHANGED
@@ -1457,6 +1457,12 @@ function normalizeCartFromServer(cart) {
1457
1457
  items: Array.isArray(cart.items) ? cart.items.map((it) => ({ ...it, price: toMajor(it.price) })) : []
1458
1458
  };
1459
1459
  }
1460
+ function unwrapCart(json) {
1461
+ if (json && typeof json === "object" && "data" in json && json.data && typeof json.data === "object") {
1462
+ return json.data;
1463
+ }
1464
+ return json;
1465
+ }
1460
1466
  function readCsrfCookie() {
1461
1467
  if (typeof document === "undefined") return null;
1462
1468
  const match = document.cookie.match(/(?:^|;\s*)numu_csrf=([^;]+)/);
@@ -1476,8 +1482,8 @@ async function postCartMutation(endpoint, body, applyCart, reserveToken) {
1476
1482
  body: body === void 0 ? void 0 : JSON.stringify(body)
1477
1483
  });
1478
1484
  if (!res.ok) return;
1479
- const data = await res.json();
1480
- applyCart(data);
1485
+ const json = await res.json();
1486
+ applyCart(unwrapCart(json));
1481
1487
  }
1482
1488
  function NuMuProvider({
1483
1489
  store,
@@ -1657,7 +1663,8 @@ function NuMuProvider({
1657
1663
  cache: "no-store"
1658
1664
  });
1659
1665
  if (!res.ok || cancelled) return;
1660
- const data = await res.json();
1666
+ const json = await res.json();
1667
+ const data = unwrapCart(json);
1661
1668
  if (data && typeof data === "object") {
1662
1669
  setCart(normalizeCartFromServer(data));
1663
1670
  }
@@ -1704,11 +1711,32 @@ function NuMuProvider({
1704
1711
  );
1705
1712
  const addItem = react.useCallback(
1706
1713
  async (productId, variantId, quantity) => {
1714
+ const eventId = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}`;
1715
+ const qty = quantity || 1;
1707
1716
  await mutate("/api/cart/add", {
1708
1717
  product_id: productId,
1709
1718
  variant_id: variantId,
1710
- quantity: quantity || 1
1719
+ quantity: qty,
1720
+ _event_id: eventId
1711
1721
  });
1722
+ try {
1723
+ if (typeof window !== "undefined") {
1724
+ window.dispatchEvent(
1725
+ new CustomEvent("numu:analytics:event", {
1726
+ detail: {
1727
+ event: "add_to_cart",
1728
+ payload: {
1729
+ content_ids: [productId],
1730
+ content_type: "product",
1731
+ num_items: qty
1732
+ },
1733
+ event_id: eventId
1734
+ }
1735
+ })
1736
+ );
1737
+ }
1738
+ } catch {
1739
+ }
1712
1740
  },
1713
1741
  [mutate]
1714
1742
  );
@@ -2133,6 +2161,46 @@ function Money({
2133
2161
  children
2134
2162
  );
2135
2163
  }
2164
+
2165
+ // src/utils/imageTransform.ts
2166
+ var _clampT = (n, lo, hi) => Math.min(hi, Math.max(lo, Number.isFinite(n) ? n : lo));
2167
+ function asImageTransform(v) {
2168
+ if (v && typeof v === "object" && "transform" in v) {
2169
+ const t = v.transform;
2170
+ if (t && typeof t === "object") return t;
2171
+ }
2172
+ return void 0;
2173
+ }
2174
+ function applyImageTransform(t, fit = "cover") {
2175
+ if (!t) return { objectFit: fit };
2176
+ const fx = Math.round(_clampT(t.focal?.x ?? 0.5, 0, 1) * 1e4) / 100;
2177
+ const fy = Math.round(_clampT(t.focal?.y ?? 0.5, 0, 1) * 1e4) / 100;
2178
+ const zoom = _clampT(t.zoom ?? 1, 1, 4);
2179
+ const rot = ((t.rotation ?? 0) % 360 + 360) % 360;
2180
+ const effFit = t.fit ?? fit;
2181
+ const style = {
2182
+ transform: `scale(${zoom}) rotate(${rot}deg)`,
2183
+ transformOrigin: `${fx}% ${fy}%`,
2184
+ objectFit: effFit
2185
+ };
2186
+ if (effFit === "cover") style.objectPosition = `${fx}% ${fy}%`;
2187
+ return style;
2188
+ }
2189
+ var clamp01 = (n) => Math.min(1, Math.max(0, n));
2190
+ function focalSrc(url, options = {}) {
2191
+ if (!url) return "";
2192
+ if (url.startsWith("data:") || /[?&](fp-x|fp-y)=/.test(url)) return url;
2193
+ const p = new URLSearchParams();
2194
+ p.set("url", url);
2195
+ if (options.width) p.set("w", String(Math.round(options.width)));
2196
+ if (options.focal?.x != null) p.set("fp-x", String(clamp01(options.focal.x)));
2197
+ if (options.focal?.y != null) p.set("fp-y", String(clamp01(options.focal.y)));
2198
+ if (options.aspect) p.set("ar", options.aspect);
2199
+ if (options.fit) p.set("fit", options.fit);
2200
+ if (options.quality) p.set("q", String(Math.min(100, Math.max(1, Math.round(options.quality)))));
2201
+ if (options.format) p.set("f", options.format.toLowerCase());
2202
+ return `/api/image-transform?${p.toString()}`;
2203
+ }
2136
2204
  var DEFAULT_WIDTHS2 = [320, 480, 640, 768, 1024, 1280, 1600, 1920];
2137
2205
  function buildSrcSet(src, widths = DEFAULT_WIDTHS2) {
2138
2206
  if (/[?&]w=\d+/.test(src)) return "";
@@ -2145,27 +2213,48 @@ function Image({
2145
2213
  sizes = "(min-width: 1024px) 25vw, (min-width: 640px) 50vw, 100vw",
2146
2214
  responsive = true,
2147
2215
  loading = "lazy",
2216
+ aspectRatio,
2217
+ objectFit,
2218
+ objectPosition,
2219
+ transform,
2148
2220
  className,
2149
2221
  style,
2150
2222
  ...rest
2151
2223
  }) {
2224
+ const framed = Boolean(aspectRatio);
2152
2225
  if (!src) {
2153
- return /* @__PURE__ */ jsxRuntime.jsx(
2226
+ const placeholder = /* @__PURE__ */ jsxRuntime.jsx(
2154
2227
  "div",
2155
2228
  {
2156
- className,
2229
+ className: framed ? void 0 : className,
2157
2230
  role: "img",
2158
2231
  "aria-label": alt,
2159
2232
  style: {
2160
2233
  backgroundColor: "rgba(0,0,0,0.05)",
2161
2234
  display: "block",
2162
- ...style
2235
+ width: "100%",
2236
+ height: framed ? "100%" : void 0,
2237
+ ...framed ? {} : style
2163
2238
  }
2164
2239
  }
2165
2240
  );
2241
+ if (!framed) return placeholder;
2242
+ return /* @__PURE__ */ jsxRuntime.jsx(
2243
+ "span",
2244
+ {
2245
+ className,
2246
+ style: { display: "block", aspectRatio, overflow: "hidden", ...style },
2247
+ children: placeholder
2248
+ }
2249
+ );
2166
2250
  }
2167
2251
  const srcSet = responsive ? buildSrcSet(src) : void 0;
2168
- return /* @__PURE__ */ jsxRuntime.jsx(
2252
+ const effFit = objectFit ?? (framed ? "cover" : void 0);
2253
+ const fitStyle = transform ? applyImageTransform(transform, effFit === "contain" ? "contain" : "cover") : {
2254
+ ...effFit ? { objectFit: effFit } : {},
2255
+ ...objectPosition ? { objectPosition } : {}
2256
+ };
2257
+ const img = /* @__PURE__ */ jsxRuntime.jsx(
2169
2258
  "img",
2170
2259
  {
2171
2260
  src,
@@ -2174,11 +2263,27 @@ function Image({
2174
2263
  sizes: srcSet ? sizes : void 0,
2175
2264
  loading,
2176
2265
  decoding: "async",
2177
- className,
2178
- style,
2266
+ className: framed ? void 0 : className,
2267
+ style: framed ? { width: "100%", height: "100%", display: "block", ...fitStyle } : { ...fitStyle, ...style },
2179
2268
  ...rest
2180
2269
  }
2181
2270
  );
2271
+ if (!framed) return img;
2272
+ return /* @__PURE__ */ jsxRuntime.jsx(
2273
+ "span",
2274
+ {
2275
+ className,
2276
+ style: {
2277
+ display: "block",
2278
+ position: "relative",
2279
+ width: "100%",
2280
+ aspectRatio,
2281
+ overflow: "hidden",
2282
+ ...style
2283
+ },
2284
+ children: img
2285
+ }
2286
+ );
2182
2287
  }
2183
2288
  var ABSOLUTE_URL = /^[a-z]+:|^\/\//i;
2184
2289
  function Link({ to, children, ...rest }) {
@@ -3334,23 +3439,6 @@ function assetUrl(name) {
3334
3439
  return `${cleanBase}${filename}`;
3335
3440
  }
3336
3441
 
3337
- // src/utils/imageTransform.ts
3338
- var clamp01 = (n) => Math.min(1, Math.max(0, n));
3339
- function focalSrc(url, options = {}) {
3340
- if (!url) return "";
3341
- if (url.startsWith("data:") || /[?&](fp-x|fp-y)=/.test(url)) return url;
3342
- const p = new URLSearchParams();
3343
- p.set("url", url);
3344
- if (options.width) p.set("w", String(Math.round(options.width)));
3345
- if (options.focal?.x != null) p.set("fp-x", String(clamp01(options.focal.x)));
3346
- if (options.focal?.y != null) p.set("fp-y", String(clamp01(options.focal.y)));
3347
- if (options.aspect) p.set("ar", options.aspect);
3348
- if (options.fit) p.set("fit", options.fit);
3349
- if (options.quality) p.set("q", String(Math.min(100, Math.max(1, Math.round(options.quality)))));
3350
- if (options.format) p.set("f", options.format.toLowerCase());
3351
- return `/api/image-transform?${p.toString()}`;
3352
- }
3353
-
3354
3442
  // src/utils/locales.ts
3355
3443
  function flattenMessages(source, prefix = "") {
3356
3444
  const out = {};
@@ -3427,6 +3515,8 @@ exports.SectionContext = SectionContext;
3427
3515
  exports.ShopContext = ShopContext;
3428
3516
  exports.ThemeSettingsContext = ThemeSettingsContext;
3429
3517
  exports.applyGlobalStyleTokens = applyGlobalStyleTokens;
3518
+ exports.applyImageTransform = applyImageTransform;
3519
+ exports.asImageTransform = asImageTransform;
3430
3520
  exports.assetUrl = assetUrl;
3431
3521
  exports.availableValues = availableValues;
3432
3522
  exports.buildLocaleBundle = buildLocaleBundle;