@gemboss/gem-framework 0.15.0 → 0.16.1

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
@@ -6,7 +6,7 @@ gem-framework của GemTheme đóng thành package, cho các repo **ngoài** mon
6
6
  | Cổng vào | Là gì | Nguồn |
7
7
  |---|---|---|
8
8
  | `@gemboss/gem-framework` | token, `createGemThemeCss`, runtime, manifest, bộ kiểm framework. Từ 0.3.4 manifest khai họ `gem-cart-line-item` (khối, `__media`, `__details`, `__title`, `__variant`, `__plan`, `__meta`, `__remove`, `__quantity`, `__price`): framework vốn style và adapter Shopify vốn render nó, nên section dùng markup dòng giỏ của framework không còn bị `unknown_gem_class` | `packages/gem-framework/src` |
9
- | `…/sections` | Các hàm `headless.tsx` được import: `elementOverrides`, `imageSrcSet`, `subscriptionPlans`, `defaultPlan`; từ 0.15.0 thêm bộ đọc nhấn trong câu `inlineMarkRuns`, `phraseMarkRuns`, `markClassName`, `inlineMarkText`, `safeMarkHref`, `MARK_KINDS` | `packages/gem-sections/src` |
9
+ | `…/sections` | Các hàm `headless.tsx` được import: `elementOverrides`, `imageSrcSet`, `subscriptionPlans`, `defaultPlan`; từ 0.15.0 thêm bộ đọc nhấn trong câu `inlineMarkRuns`, `phraseMarkRuns`, `markClassName`, `inlineMarkText`, `safeMarkHref`, `MARK_KINDS`; từ 0.16.0 thêm giá thẻ sản phẩm `cardPrice` (`card-price.ts`: nhiều giá thì "From" + giá thấp nhất, Sale chỉ khi biến thể mua được đang giảm, hết hàng, biến thể mà giá nói tới). `product-card-grid` quick-view-grid và featured từ section-library 1.39.6 import chúng: đồng bộ hai section đó khi đã lên 0.16.0 | `packages/gem-sections/src` |
10
10
  | `…/section-gate` | `validateSectionArtifacts`: ĐÚNG bộ kiểm section gemtheme dùng khi duyệt. Từ 0.3.0 thêm ba cổng của thư viện cho MỘT section: `layoutKnobCheck` (+ `LAYOUT_ATTRS`), `slotContractDrift` (+ `slotsReadBy`, `slotSourceFromElementTree`), `cssPairingOrphans` (+ `classesStyledBy`, truyền CSS từ `createGemThemeCss`). Từ 0.3.2 thêm hai cổng pháp lý: `fabricatedProofInSource` / `fabricatedProofInSchema` (điểm, số lượt, người, lời hứa bịa) và `inventedTestimonyIn` (hằng DEMO_*_QUOTE/REVIEW… làm giá trị khi thiếu dữ liệu). Từ 0.3.3 hai cổng này đọc được tiếng Việt ("4,8/5", "đã bán", "miễn phí vận chuyển", "199k", cả chữ NFD), tra giá trị của `DEMO.<key>` ở mọi chỗ đọc nó (`??`, `\|\|`, toán tử ba ngôi, dòng bị Prettier ngắt), và bỏ qua số lượng hiển thị (`reviews_to_show`…). Nâng từ 0.3.2 lên có thể làm đỏ section trước đó xanh: đó là lời khẳng định nằm trong DEMO. Từ 0.3.5 bắt thêm chữ ký người Việt (tên kèm nơi chốn như "Chị Lan, Hà Nội", hoặc họ + Thị/Văn như "Nguyễn Thị Hoa"; tiêu đề viết hoa từng chữ thì không), điểm sao ("4,9 ★"), `rating: 4.9` / `reviewCount: "1.284"` trong mảng sản phẩm mẫu, và hằng demo ở nhánh else của toán tử ba ngôi (`bound ? [] : DEMO_REVIEWS`). Mỗi thứ bịa chỉ báo MỘT lần: tại dòng DEMO nếu dòng đó tự lộ, không thì tại chỗ đọc. "đồng hồ" không còn bị đọc là giá. Từ 0.3.6 bắt lời hứa có thời hạn đứng sau từ khoá ("Dùng thử 30 ngày", "Bảo hành 12 tháng", "Nhận trong 2–4 ngày", "Giao 2 giờ") và "hoàn tiền" không kèm số; "Chính sách hoàn tiền" vẫn là nhãn. Từ 0.3.7 câu hỏi (kết thúc bằng "?") không bao giờ là lời hứa, "không hợp lệ"/số bước/nhịp giao không bị báo, và bắt thêm "trong vòng N ngày", "Giao hàng 2–4 ngày", "Bảo hành 1 đổi 1…", "Try it free for 30 days", "Ships in 24h". Từ 0.3.8 xuất thêm `claimIn` (câu hỏi "một chuỗi này có phải lời khẳng định, người bịa, hay lời hứa?"). Từ 0.3.9 bắt "cancel anytime", "guaranteed delivery", "huỷ bất cứ lúc nào" (chính sách huỷ gói và giao hàng). Từ 0.3.10 lời từ chối ("cannot be canceled", "Không thể hủy") không bị báo, "giao dịch/giao diện" không phải giao hàng, "Delivery not guaranteed" là lời miễn trừ, một chuỗi chỉ gồm con số viết tắt ("199k", "12k+") mang nhãn trung tính "hardcoded figure" (không phân biệt được giá với số đếm), và `stringLiteralsIn(line)` thay cho hằng `STRING_LITERAL` (đã gỡ: một regex `/g` dùng chung rò `lastIndex`; 0 chỗ dùng ở gemboss/gemfactory/gemsite). Từ 0.3.11 "Huỷ bỏ lọc…" và "Hủy đơn, gọi chúng tôi bất cứ lúc nào" không bị đọc là chính sách huỷ; "huỷ đăng ký" là bỏ nhận tin (như "Unsubscribe anytime"); `SOURCE.json` ghi `gemtheme_dirty`, và `npm pack` từ chối khi ĐẦU VÀO của gói (`packages/`, gói này trừ `package.json`, `scripts/lib/`) có thay đổi chưa commit hoặc git không trả lời được. Từ 0.3.12 bắt lời hứa hoàn tiền không có chữ "hoàn tiền" liền nhau ("nhận lại toàn bộ tiền", "Hoàn lại 100% tiền", "Trả lại tiền nếu không hài lòng"); lời từ chối ("Không được hoàn lại tiền") và tiền thừa/thối khi thanh toán không bị bắt Từ 0.4.0: `demoOutsidePreview(tsx)` (chữ mẫu DEMO chỉ hiện trong bản xem trước, tức khi `section.demo === true`; plan xuất bản không bao giờ mang cờ đó) và schema `SectionSpec` khai trường `demo` | `packages/gem-sections/src/section-artifacts.ts`, `section-contract.ts` |
11
11
  | `…/settings-schema` | `validateSettingsSchema` | `scripts/lib/settings-schema-contract.mjs` |
12
12
  | `…/gem-framework.css`, `…/gem-runtime.js` | CSS token mặc định + runtime mọi plugin, dựng sẵn. `gem-runtime.js` tự chạy khi tải: đăng ký và lên lịch đủ 23 plugin | build |
@@ -93,6 +93,26 @@ Từ 0.8.2 mỗi module có `.d.ts` cạnh nó (`mount(root?: ParentNode | null,
93
93
 
94
94
  ## Nhật ký thay đổi
95
95
 
96
+ - **0.16.1** (12/10/2026): SỬA cổng slot-contract (`slotsReadBy`, `…/section-gate`), gap #42 của GemFactory + review #758. Tên túi
97
+ slot chỉ tính khi đứng ĐẦU một biểu thức: không sau chữ, `$`, `-`, `.`, `/` hay dấu nháy. Nên id `…reward-slots.v1`,
98
+ `delivery-picker.slots.v1`, `gift.timeslots.v1`, chuỗi `"./slots.ts"` và `item.content.title` (tên alias làm thuộc tính của thứ
99
+ khác) không còn sinh slot giả. `content_slots` là tên duy nhất được đứng sau `.` (`section.content_slots.heading` vẫn là lượt
100
+ đọc). Thêm: `slots?.x`, `slots?.["x"]`, alias có khai kiểu (`const c: T = …`) và alias qua `section?.content_slots` /
101
+ `props.section.content_slots`. Đánh đổi có chủ ý: phép trừ không cách `total -slots.count` không còn tính. Script
102
+ `audit-slot-key-drift.mjs` dùng chung `slotsReadBy` (chạy bằng `npx tsx`). Thư viện giữ baseline (57/91 sạch). Đối chứng âm:
103
+ bỏ phần lookbehind mới thì 2 ca đỏ. Chuỗi merge: GemFactory nâng lên 0.16.1 thì gỡ (hoặc hạ) cảnh báo `variant_name_slots_trap`,
104
+ vì cổng không còn đọc nhầm.
105
+ - **0.16.0** (12/10/2026): CHỈ THÊM. **Giá thẻ sản phẩm**: `…/sections` (và `@gemtheme/gem-sections`) thêm `cardPrice(product,
106
+ fromLabel?)`, MỘT luật giá cho mọi section thẻ sản phẩm gọi nó. `product-card-grid` quick-view-grid và featured giữ hai bản riêng, sửa
107
+ một bản thì bản kia giữ lỗi, nên cùng một sản phẩm ra hai giá ở hai lưới (gemtheme #754). Trả về:
108
+ - `text` (dòng giá) và `from`: nhiều giá thì "From" + giá THẤP NHẤT (min của priceRange, không thì biến thể rẻ nhất), như Dawn;
109
+ - `compareAt` (có ⇔ có Sale): biến thể MUA ĐƯỢC ở giá đang hiện đang giảm, hoặc giá gạch của chính người bán khi so được là lớn hơn;
110
+ - `soldOut`: cờ curated, `availableForSale` của sản phẩm, hoặc mọi biến thể hết;
111
+ - `variant`: biến thể thêm giỏ được (mua được, có id, đúng giá đang hiện);
112
+ - `amount`: số cho `data-gem-product-price`.
113
+
114
+ Section-library 1.39.6 import nó: GemFactory đồng bộ hai section `product-card-grid` đó khi đã lên 0.16.0, không thì
115
+ `cardPrice is not a function`.
96
116
  - **0.15.0** (11/10/2026): CHỈ THÊM, store đang chạy không đổi pixel nào (chưa trang nào mang các lớp mới). Gap #39: **nhấn
97
117
  trong câu**. Chữ advertorial sống nhờ cách nhấn (kho GemFactory `research/advertorial-blocks-2026-10-10`: souleora 84 cụm đậm,
98
118
  5 nghiêng, 4 đổi màu, 1 gạch chân trong 152 đoạn; 28pilates chốt thẻ "đừng mua" bằng câu đỏ, tô màu nhấn cụm kết quả trong
@@ -116,8 +136,18 @@ Từ 0.8.2 mỗi module có `.d.ts` cạnh nó (`mount(root?: ParentNode | null,
116
136
  trong manifest (nhóm content), nên section vẽ chúng không bị `unknown_gem_class`.
117
137
  - Người dùng: gemboss phải thêm các tên mới vào requireShim của bản xem trước section; gemsite chép `inline-marks.ts`
118
138
  byte-identical (cặp kiểm của `check-elements-copied-from-gemtheme`) rồi vẽ dấu cho element `text` / `heading`.
119
- - **0.14.0** (11/10/2026): CHỈ THÊM, store đang chạy không đổi pixel nào (không section nào của gemtheme, gemsite, gemboss
120
- đọc các tên dưới đây; 30 mục kho GemFactory tự khai thang dùng tên riêng theo mục). Hai gap của bộ interaction "Gọn"
139
+ - **0.14.0** (11/10/2026): thêm thang và plugin (không đổi gì sẵn có), trừ hai chỗ dưới. Không section nào của gemtheme hay
140
+ gemboss đọc các tên mới (đo `git grep -nE -e '--gem-motion-(fast|base|slow|distance|distance-lg|stagger|blur)[^a-z-]' -e
141
+ '--gem-ease-(enter|exit)[^a-z-]'`; ĐỪNG dùng `\b`: git của Apple 2.39.5 không hiểu nó và ra 0 im lặng). Kho GemFactory: 209
142
+ mục tự khai thang bằng tên riêng theo mục, đếm bằng cổng `motion_scale_self_declared` ngày 11/10/2026 trên 259 section; 0 mục
143
+ khai lại biến của gói, 0 mục đọc thiếu số dự phòng. *Sửa 11/10/2026, bản đầu ghi "store đang chạy không đổi pixel nào":*
144
+ (a) gemsite `app/globals.css` có 3 luật họ hero-split (`.gem-hero-split__option`, `__option-arrow`, `__playpause`) đọc
145
+ `var(--gem-motion-fast, 0.2s)`. Từ gemsite #854 (thang vào `:root` của store) transition của chúng (hover, nền, viền, và nhấn
146
+ `:active` của nút play) chạy 100ms thay 200ms, và 1ms khi người xem giảm chuyển động hoặc preset `motion_level: "none"`.
147
+ Component phát ba lớp đó (24 hero của gemsite #150) đã xoá ở gemsite #688, nên ba luật là CSS chết: đề xuất xoá.
148
+ (b) Bí danh motion tra theo slug: plan viết `easing_soft` / `ambient_slow` / `Enter` trước đây ra biến không ai đọc, nay ra
149
+ `--gem-ease-soft` / `--gem-ambient-slow` / `--gem-ease-enter` mà luật `[data-gem-ambient]` đọc, tức là có tác dụng. Chưa plan
150
+ nào trong các repo viết kiểu đó. Hai gap của bộ interaction "Gọn"
121
151
  (Lily 08/10/2026) mà kho đang chép tay vào từng mục:
122
152
  (1) **Thang chuyển động (#32).** `createGemTokenCss` luôn phát `--gem-motion-fast` 100ms, `-base` 180ms, `-slow` 260ms,
123
153
  `--gem-ease-enter` / `--gem-ease-exit`, `--gem-motion-distance` 8px, `-distance-lg` 16px, `-stagger` 30ms, `-blur` 8px
package/dist/SOURCE.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "package": "@gemboss/gem-framework",
3
- "version": "0.15.0",
4
- "gemtheme_sha": "c69c7c6e8d2c2dcf1543170990f6a644805c4dd8",
3
+ "version": "0.16.1",
4
+ "gemtheme_sha": "d9398bcf426b33f3c3b8b0cd4865d4f051b2c358",
5
5
  "gemtheme_dirty": false,
6
- "framework_version": "0.15.0"
6
+ "framework_version": "0.16.1"
7
7
  }
package/dist/index.js CHANGED
@@ -280,7 +280,7 @@ function gemButtonGradientEndCss(accent = "var(--gem-color-accent)", accentText
280
280
  }
281
281
 
282
282
  // ../../packages/gem-framework/src/tokens.ts
283
- var gemFrameworkVersion = "0.15.0";
283
+ var gemFrameworkVersion = "0.16.1";
284
284
  var GEM_FOCUS_RING_CSS_VAR = "--gem-focus-ring: 2px solid var(--gem-color-accent);";
285
285
  var GEM_TAP_MIN_CSS_VAR = "--gem-tap-min: 44px;";
286
286
  var GEM_FOCUS_OFFSET_CSS_VAR = "--gem-focus-offset: 3px;";
@@ -4813,14 +4813,15 @@ function layoutKnobCheck(tsx, knobExempt = /* @__PURE__ */ new Set()) {
4813
4813
  }
4814
4814
  function slotsReadBy(raw) {
4815
4815
  const source = raw.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/(^|[^:])\/\/.*$/gm, "$1 ");
4816
- const names = /* @__PURE__ */ new Set(["slots"]);
4817
- for (const m of source.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:section\.)?content_slots\b/g)) names.add(m[1]);
4818
- const alt = [...names].map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
4816
+ const aliases = /* @__PURE__ */ new Set(["slots"]);
4817
+ for (const m of source.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=;]+)?=\s*(?:[A-Za-z_$][\w$]*\??\.)*content_slots\b/g)) aliases.add(m[1]);
4818
+ const esc = (n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4819
+ const bag = `(?:(?<![\\w$.\\-/"'\`])(?:${[...aliases].map(esc).join("|")})|(?<![\\w$\\-/"'\`])content_slots)`;
4819
4820
  const keys = /* @__PURE__ */ new Set();
4820
- for (const m of source.matchAll(new RegExp(`(?:${alt})\\.([A-Za-z_][A-Za-z0-9_]*)`, "g"))) keys.add(m[1]);
4821
- for (const m of source.matchAll(new RegExp(`(?:${alt})\\[["']([^"']+)["']\\]`, "g"))) keys.add(m[1]);
4821
+ for (const m of source.matchAll(new RegExp(`${bag}\\??\\.([A-Za-z_][A-Za-z0-9_]*)`, "g"))) keys.add(m[1]);
4822
+ for (const m of source.matchAll(new RegExp(`${bag}\\??\\.?\\[["']([^"']+)["']\\]`, "g"))) keys.add(m[1]);
4822
4823
  const prefixes = [];
4823
- for (const m of source.matchAll(new RegExp("(?:" + alt + ")\\[`([^`]*)`\\]", "g"))) {
4824
+ for (const m of source.matchAll(new RegExp(`${bag}\\??\\.?\\[\`([^\`]*)\`\\]`, "g"))) {
4824
4825
  const literal = m[1].split("${")[0];
4825
4826
  if (literal.length >= 3) prefixes.push(literal);
4826
4827
  }
package/dist/sections.js CHANGED
@@ -82,8 +82,8 @@ function subscriptionPlans(allocations, basePrice) {
82
82
  if (!id || seen.has(id)) continue;
83
83
  const adjustments = Array.isArray(row?.priceAdjustments) ? row.priceAdjustments.filter(Boolean) : [];
84
84
  const first = adjustments[0];
85
- const money = first?.perDeliveryPrice ?? first?.price ?? null;
86
- const amount = adjustments.length ? num(money?.amount) : base > 0 ? base : null;
85
+ const money2 = first?.perDeliveryPrice ?? first?.price ?? null;
86
+ const amount = adjustments.length ? num(money2?.amount) : base > 0 ? base : null;
87
87
  if (amount === null) continue;
88
88
  seen.add(id);
89
89
  const cheaper = base > 0 && amount < base;
@@ -92,7 +92,7 @@ function subscriptionPlans(allocations, basePrice) {
92
92
  id,
93
93
  name: typeof row?.sellingPlan?.name === "string" && row.sellingPlan.name.trim() || "Subscription",
94
94
  amount,
95
- currencyCode: typeof money?.currencyCode === "string" && money.currencyCode || void 0,
95
+ currencyCode: typeof money2?.currencyCode === "string" && money2.currencyCode || void 0,
96
96
  savingPct: Math.round(discountPct),
97
97
  discountPct,
98
98
  introOnly: adjustments.length > 1
@@ -153,10 +153,10 @@ function merge(runs) {
153
153
  }
154
154
  return out.length ? out : [{ text: "", marks: [] }];
155
155
  }
156
- function place(text, phrases) {
156
+ function place(text2, phrases) {
157
157
  const taken = [];
158
158
  phrases.forEach((phrase, index) => {
159
- for (let at = text.indexOf(phrase); at >= 0; at = text.indexOf(phrase, at + 1)) {
159
+ for (let at = text2.indexOf(phrase); at >= 0; at = text2.indexOf(phrase, at + 1)) {
160
160
  const end = at + phrase.length;
161
161
  if (!taken.some(([s, e]) => at < e && s < end)) {
162
162
  taken.push([at, end, index]);
@@ -166,8 +166,8 @@ function place(text, phrases) {
166
166
  });
167
167
  return taken;
168
168
  }
169
- function phraseMarkRuns(text, lists = {}) {
170
- const copy = String(text ?? "");
169
+ function phraseMarkRuns(text2, lists = {}) {
170
+ const copy = String(text2 ?? "");
171
171
  const n = copy.length;
172
172
  const marks = Array.from({ length: n });
173
173
  const hrefs = Array.from({ length: n });
@@ -207,10 +207,10 @@ var OPEN = [["***", ["bold", "italic"]], ["**", ["bold"]], ["==", ["highlight"]]
207
207
  var ESCAPABLE = "*_=!^[]()\\";
208
208
  var STAND_IN = 57344;
209
209
  var escapeMarkers = (src) => src.replace(/\\([*_=!^[\]()\\])/g, (_, c) => String.fromCharCode(STAND_IN + ESCAPABLE.indexOf(c)));
210
- var unescapeMarkers = (text) => text.replace(/[-]/g, (c) => ESCAPABLE[c.charCodeAt(0) - STAND_IN]);
210
+ var unescapeMarkers = (text2) => text2.replace(/[-]/g, (c) => ESCAPABLE[c.charCodeAt(0) - STAND_IN]);
211
211
  function read(src, inherited, href, depth) {
212
212
  const out = [];
213
- const at = (text, marks, h = href) => ({ text, marks: ordered(marks), ...h ? { href: h } : {} });
213
+ const at = (text2, marks, h = href) => ({ text: text2, marks: ordered(marks), ...h ? { href: h } : {} });
214
214
  let last = 0;
215
215
  for (const m of src.matchAll(INLINE)) {
216
216
  const i = m.index ?? 0;
@@ -235,10 +235,10 @@ function read(src, inherited, href, depth) {
235
235
  return out;
236
236
  }
237
237
  function inlineMarkRuns(src) {
238
- const text = String(src ?? "");
239
- const escapes = !/[\ue000-\ue009]/.test(text);
238
+ const text2 = String(src ?? "");
239
+ const escapes = !/[\ue000-\ue009]/.test(text2);
240
240
  const runs = [];
241
- text.split("\n").forEach((line, i) => {
241
+ text2.split("\n").forEach((line, i) => {
242
242
  if (i) runs.push({ text: "\n", marks: [] });
243
243
  if (line.length > MAX_INLINE) return void runs.push({ text: line, marks: [] });
244
244
  const read1 = read(escapes ? escapeMarkers(line) : line, [], void 0, 0);
@@ -253,8 +253,118 @@ function markClassName(marks) {
253
253
  function inlineMarkText(src) {
254
254
  return inlineMarkRuns(src).map((r) => r.text).join("");
255
255
  }
256
+
257
+ // ../../packages/gem-sections/src/card-price.ts
258
+ function priceAmount(m) {
259
+ const a = m?.amount;
260
+ if (a == null || typeof a === "string" && !a.trim()) return NaN;
261
+ return typeof a === "number" || typeof a === "string" ? Number(a) : NaN;
262
+ }
263
+ var text = (v) => typeof v === "string" && v.trim() ? v : void 0;
264
+ var finite = (n) => typeof n === "number" && Number.isFinite(n) ? n : void 0;
265
+ var MONEY = /* @__PURE__ */ new Map();
266
+ function money(n, currency) {
267
+ if (!Number.isFinite(n) || !currency) return void 0;
268
+ let format = MONEY.get(currency);
269
+ if (format === void 0) {
270
+ try {
271
+ format = new Intl.NumberFormat(void 0, { style: "currency", currency });
272
+ } catch {
273
+ format = null;
274
+ }
275
+ if (MONEY.size < 64) MONEY.set(currency, format);
276
+ }
277
+ return format ? format.format(n) : `${n.toFixed(2)} ${currency}`;
278
+ }
279
+ var WHOLE;
280
+ var CENTS;
281
+ function plain(n, cents = !Number.isInteger(n)) {
282
+ if (!Number.isFinite(n)) return void 0;
283
+ if (!cents) {
284
+ if (!WHOLE) WHOLE = new Intl.NumberFormat(void 0, { maximumFractionDigits: 0 });
285
+ return WHOLE.format(n);
286
+ }
287
+ if (!CENTS) CENTS = new Intl.NumberFormat(void 0, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
288
+ return CENTS.format(n);
289
+ }
290
+ var SEP = "[,'\u2019 \\u00a0\\u2009\\u202f]";
291
+ var SEPS = new RegExp(SEP, "g");
292
+ var ONE_NUMBER = new RegExp(`^(\\D*)(\\d{1,3}(?:(?:${SEP}\\d{2})*(?:${SEP}\\d{3})+)?|\\d+)(\\.\\d{1,2})?(\\D*)$`);
293
+ var MULTIPLIER = /^\s*(?:k|tr|triệu|nghìn|ngàn|m|mn|mio|b|bn)(?!\p{L})/iu;
294
+ function priceTextAmount(value) {
295
+ const m = value ? ONE_NUMBER.exec(value.trim()) : null;
296
+ if (!m || /[.,]$/.test(m[1]) || new Set(m[2].replace(/\d/g, "")).size > 1 || MULTIPLIER.test(m[4])) return void 0;
297
+ return Number(m[2].replace(SEPS, "") + (m[3] ?? ""));
298
+ }
299
+ function startsWithFrom(value, label) {
300
+ if (typeof value !== "string") return false;
301
+ const head = value.trim().normalize("NFC").toLowerCase();
302
+ return [label, "from", "t\u1EEB"].some((w) => {
303
+ const t = String(w ?? "").trim().normalize("NFC").toLowerCase();
304
+ return Boolean(t) && head.startsWith(t) && !/^\p{L}/u.test(head.slice(t.length));
305
+ });
306
+ }
307
+ var NUMBER_RUN = /\d(?:[\d.,'’    ]*\d)?/g;
308
+ function readAlike(value) {
309
+ const runs = value.trim().match(NUMBER_RUN);
310
+ if (!runs || runs.length !== 1) return void 0;
311
+ const run = runs[0];
312
+ const tail = /^(.*)[.,](\d{1,2})$/.exec(run);
313
+ const n = Number(tail ? `${tail[1].replace(/\D/g, "")}.${tail[2]}` : run.replace(/\D/g, ""));
314
+ return Number.isFinite(n) ? { n, frame: value.trim().replace(run, "#") } : void 0;
315
+ }
316
+ function struckAbove(price, struck) {
317
+ const a = readAlike(price), b = readAlike(struck);
318
+ return a != null && b != null && a.frame === b.frame && b.n > a.n;
319
+ }
320
+ function cardPrice(product, fromLabel = "From") {
321
+ const p = product ?? {};
322
+ const seller = text(p.price);
323
+ const nodes = (Array.isArray(p.variants?.nodes) ? p.variants.nodes : []).filter((v) => v != null && typeof v === "object");
324
+ const sel = p.selectedVariant != null && typeof p.selectedVariant === "object" ? p.selectedVariant : void 0;
325
+ const at = sel ? nodes.findIndex((v) => sel.id != null && v.id === sel.id || nodes.length === 1 && (v.id == null || sel.id == null)) : -1;
326
+ const variants = at >= 0 ? nodes.map((v, i) => i === at ? { ...sel, ...v } : v) : sel ? [sel, ...nodes] : nodes;
327
+ const priced = variants.filter((v) => Number.isFinite(priceAmount(v.price)));
328
+ const amounts = priced.map((v) => priceAmount(v.price));
329
+ const min = p.priceRange?.minVariantPrice;
330
+ const minAmount = priceAmount(min);
331
+ const maxAmount = priceAmount(p.priceRange?.maxVariantPrice);
332
+ const lowest = Number.isFinite(minAmount) ? minAmount : amounts.length ? Math.min(...amounts) : finite(p.price) ?? NaN;
333
+ const code = (v) => text(v)?.trim();
334
+ const currency = code(min?.currencyCode) ?? priced.map((v) => code(v.price?.currencyCode)).find(Boolean);
335
+ const varies = p.priceVaries === true || Number.isFinite(lowest) && (amounts.some((a) => a !== lowest) || Number.isFinite(maxAmount) && maxAmount !== lowest);
336
+ const amount = finite(lowest) ?? priceTextAmount(seller);
337
+ const soldOut = p.soldOut === true || p.availableForSale === false || p.availableForSale !== true && variants.length > 0 && variants.every((v) => v.availableForSale === false);
338
+ const buyable = soldOut ? [] : variants.filter((v) => v.availableForSale !== false);
339
+ const atLowest = buyable.filter((v) => priceAmount(v.price) === lowest);
340
+ const discounted = atLowest.find((v) => priceAmount(v.compareAtPrice) > lowest);
341
+ const withId = (list) => list.find((v) => v.id != null);
342
+ const anyBuyable = priced.length === 0 ? withId(buyable) : void 0;
343
+ const base = { varies, amount, soldOut };
344
+ const formatted = money(lowest, currency);
345
+ if (formatted || Number.isFinite(lowest) && !seller) {
346
+ const cmp = discounted ? priceAmount(discounted.compareAtPrice) : NaN;
347
+ const cents = !Number.isInteger(lowest) || Number.isFinite(cmp) && !Number.isInteger(cmp);
348
+ const show = (n) => money(n, currency) ?? plain(n, cents);
349
+ const variant = discounted ? discounted.id != null ? discounted : void 0 : withId(atLowest) ?? anyBuyable;
350
+ return { text: show(lowest), from: varies, compareAt: discounted ? show(cmp) : void 0, variant, ...base };
351
+ }
352
+ if (seller) {
353
+ const named = priceTextAmount(seller) === lowest ? withId(atLowest) : void 0;
354
+ const struck = text(p.compareAtPrice);
355
+ return {
356
+ text: seller,
357
+ from: p.priceVaries === true && !startsWithFrom(seller, fromLabel),
358
+ compareAt: !soldOut && struck && struckAbove(seller, struck) ? struck : void 0,
359
+ variant: named ?? anyBuyable,
360
+ ...base
361
+ };
362
+ }
363
+ return { from: false, variant: anyBuyable, ...base };
364
+ }
256
365
  export {
257
366
  MARK_KINDS,
367
+ cardPrice,
258
368
  defaultPlan,
259
369
  elementOverrides,
260
370
  imageSrcSet,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gemboss/gem-framework",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "description": "gem-framework của GemTheme (CSS, runtime, token, bộ kiểm) cộng đúng bộ kiểm section gemtheme dùng khi duyệt. Build từ gemtheme, không sửa ở đây.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",