@colixsystems/widget-sdk 0.84.0 → 0.85.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 +10 -1
- package/dist/contract.cjs +150 -3
- package/dist/contract.js +150 -3
- package/dist/hooks.js +33 -0
- package/dist/index.d.ts +17 -6
- package/dist/index.js +1 -0
- package/dist/index.native.js +1 -0
- package/dist/linter.cjs +59 -14
- package/dist/linter.js +70 -27
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -18,12 +18,13 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
18
18
|
| Group | Hook (signature) | Returns | Reads / scope |
|
|
19
19
|
| ----- | ---------------- | ------- | ------------- |
|
|
20
20
|
| **CORE** | `useTheme()` | `{ colors, elevation, spacing, radii, typography, components }` | `ctx.workspace.theme` — no scope. `elevation` is the shared depth scale (`none / sm / md / lg / xl`) you spread into a style; `colors` includes the accent's quiet tiers (`primarySoft` / `onPrimarySoft` / `primaryStrong`). `components` is HOST-OWNED (the theme's per-component style tokens); the host has already folded it into your `props.style`, so read `useWidgetStyle()` and ignore this slice. |
|
|
21
|
+
| **CORE** | `useWorkspaceCurrency()` | `{ currency, formatMoney }` | `ctx.workspace.currency` — no scope. The currency this workspace charges its app users in, resolved at RENDER time. Render every price as `formatMoney(minorUnits)` and never write a currency symbol or code into a widget: the owner can change it after the widget ships, and a baked label then contradicts the charge. |
|
|
21
22
|
| **CORE** | `useWidgetStyle()` | `{ [styleField]: value }` | `ctx.props.style` — no scope. The author-set per-widget style values declared in `manifest.styleSchema`; apply each onto whatever element you choose. |
|
|
22
23
|
| **CORE** | `useUser()` | `{ id, email, displayName, roles, groupIds }` | `ctx.user` (host-built context, **camelCase** — not a wire payload; `id` null when anonymous) — no scope |
|
|
23
24
|
| **CORE** | `useNavigation()` | `{ goTo, goBack, push, replace, back, currentRoute }` | `ctx.navigation` — no scope (external URLs use the `Linking` primitive) |
|
|
24
25
|
| **CORE** | `useRouteParams()` | `{ [paramKey]: value }` | `ctx.navigation.currentRoute.params` — no scope. The nav params the previous page passed via `goTo(pageId, params)`; the flat accessor for master→detail (read `recordId` on a detail page). Empty object when none. |
|
|
25
26
|
| **CORE** | `usePageContext()` | `{ params, records }` | `ctx.pageContext` — no scope. The page's DECLARED parameters, resolved once by the host: `params` are coerced to their declared types, `records` holds the row already fetched for each `record` param (read it instead of fetching again). Both empty when the page declares none. |
|
|
26
|
-
| **CORE** | `useWidgetEvent(name)` | `(payload?) => void` | `ctx.events.emit` — no scope |
|
|
27
|
+
| **CORE** | `useWidgetEvent(name)` | `(payload?) => void` | `ctx.events.emit` — no scope. The hook IS the emitter: `const emitSlot = useWidgetEvent("slotChosen")`, then `emitSlot(payload)`. Never destructure the result — there is no `emit` member. |
|
|
27
28
|
| **CORE** | `useWidgetInput(inputName)` | the published payload, or `undefined` | `ctx.inputs` — no scope. Reads a value ANOTHER widget on the same page published with `useWidgetEvent`. Declare the input in `manifest.inputs`; the page author wires it to one sibling's declared event. The channel retains the last payload, so a widget that mounts later still reads it. `undefined` while unwired or before the first publish — always render a sensible default. Page-scoped and ephemeral: use `useRouteParams()` for state that must survive navigation, the datastore for state that must persist. |
|
|
28
29
|
| **CORE** | `useChildRenderer()` | `{ renderNode(node) }` | `ctx.renderer` — no scope (prefer the `WidgetTree` component) |
|
|
29
30
|
| **CORE** | `useFill()` | `boolean` | `ctx.fill` — no scope. `true` when the host sized this widget to fill its page-grid tile's reserved height (containers + media fill by default; the author can override per tile). Media-style widgets switch to a `flex: 1` / `height: "100%"` layout; others ignore it. Defaults `false`. |
|
|
@@ -62,6 +63,14 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
62
63
|
|
|
63
64
|
`v0.77.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
|
|
64
65
|
|
|
66
|
+
### What's new in 0.85.1 (contract 1.60.1)
|
|
67
|
+
|
|
68
|
+
**`useWidgetEvent(name)` returns the emitter FUNCTION — the declared contract said otherwise (sc-4753).** `CONTRACT.hooks`'s entry for the hook declared `returnShape: { emit }`, so every surface derived from it — chiefly the Widget Builder Agent's hooks table — told authors the hook resolves to an object. It never did: `useWidgetEvent("slotChosen")` hands back the callable you invoke directly (`emitSlot({ courtId })`), exactly as the typings and the Developer guide have always documented. A widget written against the declared shape destructured a function, got `undefined`, and threw the moment a user interacted — a cross-widget wire that rendered perfectly and only failed on click. The declaration is now a bare callable and the publish-time render harness models the same shape, so a wrong destructure is caught instead of waved through. `CONTRACT.version` → `1.60.1`. Documentation-only correction: no export, signature, or runtime behaviour changed — a widget already calling the result is unaffected.
|
|
69
|
+
|
|
70
|
+
### What's new in 0.85.0 (contract 1.60.0)
|
|
71
|
+
|
|
72
|
+
**A widget must never name a currency — `useWorkspaceCurrency()` resolves it at render time (sc-4686).** A workspace picks the currency it charges its app users in, and the owner usually sets that *after* the app is built (the normal order is prompt first, billing later). So anything a widget wrote down — a `"kr"` in JSX, a `€` in a `manifest.translations` string, a `currency` argument on `requestPayment` — kept displaying the old currency over a charge that had correctly followed the change: one price shown, another taken. Currency is workspace configuration that changes after authoring, exactly like `theme` and `locale`, so it now joins them on the host-resolved `ctx.workspace` slice. `useWorkspaceCurrency()` returns `{ currency, formatMoney }`; `formatMoney(45000)` renders `"450,00 kr"` or `"450,00 €"` from `CONTRACT.currencyFormats` — an explicit table, not `Intl.NumberFormat`, which does not agree between the exported Expo app and react-native-web. Omit `currency` on `requestPayment` and the platform applies the workspace's own, so it can never be wrong. Enforcement tightened to match: `payment-currency` now rejects **any** currency literal (one that matches today still lies tomorrow) and so needs no per-workspace option — it fires in a bare `appstudio-widget lint`, and `lintSource`'s `paymentCurrency` option is removed; a new `no-hardcoded-currency-label` warning catches a symbol or code beside a price in a charging widget. `CONTRACT.version` → `1.60.0`. Additive for a widget that already omits `currency`.
|
|
73
|
+
|
|
65
74
|
### What's new in 0.84.0 (contract 1.59.0)
|
|
66
75
|
|
|
67
76
|
**A charge is denominated in the WORKSPACE's currency, and a widget that hardcodes a different one no longer publishes (sc-4649).** Every workspace picks the currency it charges its app users in, and `POST /payments/widget-charge` refuses any other code with `UNSUPPORTED_CURRENCY` — but nothing told a widget author which one that was. A widget priced in EUR for a workspace selling in SEK compiled, rendered, and looked finished, then failed every single checkout; the buyer read that as a generic "payment failed" and retried forever. Two changes: `currency` on `requestPayment` is best **omitted** (the platform applies the workspace's own, so it can never be wrong), and a literal that disagrees is now a publish-blocking `payment-currency` finding. Because the expected code is **per-workspace**, the SDK cannot know it: the rule fires only when the caller supplies `lintSource(source, { paymentCurrency })`, which the platform's publish gate does and a local `appstudio-widget lint` does not — it stays silent rather than guessing and flagging correct code. The rule is scoped to the argument of a `requestPayment(...)` call, so a `currency` field elsewhere (a datastore column, an `Intl.NumberFormat` option) is untouched. `CONTRACT.version` → `1.59.0`. Additive; a widget that omits `currency` or already matches its workspace is unaffected.
|
package/dist/contract.cjs
CHANGED
|
@@ -782,7 +782,16 @@ const HOOKS = [
|
|
|
782
782
|
{
|
|
783
783
|
name: "useWidgetEvent",
|
|
784
784
|
signature: "useWidgetEvent(eventName)",
|
|
785
|
-
|
|
785
|
+
// sc-4753 — the hook IS the emitter, so its return is declared as a bare
|
|
786
|
+
// callable. Naming an `emit` member here made every derived surface tell
|
|
787
|
+
// authors to destructure a function, which yields undefined at run time.
|
|
788
|
+
returnShape: {
|
|
789
|
+
"(returns)":
|
|
790
|
+
"(payload?) => void // the hook returns the emitter FUNCTION itself, " +
|
|
791
|
+
"not a wrapper object. Call it directly: " +
|
|
792
|
+
"const emitSlot = useWidgetEvent(\"slotChosen\"); " +
|
|
793
|
+
"emitSlot({ courtId }). Never destructure the result.",
|
|
794
|
+
},
|
|
786
795
|
requiredContextSlice: ["events.emit"],
|
|
787
796
|
scopes: null,
|
|
788
797
|
},
|
|
@@ -810,6 +819,22 @@ const HOOKS = [
|
|
|
810
819
|
requiredContextSlice: ["payments.requestPayment"],
|
|
811
820
|
scopes: ["payments.charge:appUser"],
|
|
812
821
|
},
|
|
822
|
+
// sc-4686 — the workspace's charge currency + the only sanctioned way to
|
|
823
|
+
// render money. A widget must NOT name a currency: the owner can change it
|
|
824
|
+
// after the widget was authored, so a baked symbol goes stale over a charge
|
|
825
|
+
// that followed the change.
|
|
826
|
+
{
|
|
827
|
+
name: "useWorkspaceCurrency",
|
|
828
|
+
signature: "useWorkspaceCurrency()",
|
|
829
|
+
returnShape: {
|
|
830
|
+
currency:
|
|
831
|
+
"string // the ISO code this workspace charges its app users in, e.g. \"SEK\"",
|
|
832
|
+
formatMoney:
|
|
833
|
+
"(minorUnits) => string // 45000 -> \"450,00 kr\"; renders identically on web and native. NEVER write a currency symbol or code yourself",
|
|
834
|
+
},
|
|
835
|
+
requiredContextSlice: ["workspace.currency"],
|
|
836
|
+
scopes: null,
|
|
837
|
+
},
|
|
813
838
|
// sc-890 — send an in-app notification to one app user in the tenant.
|
|
814
839
|
// IMPERATIVE: send() never fires on mount; the widget calls it from an
|
|
815
840
|
// event handler. Reads ctx.notifications.send (the injected
|
|
@@ -1457,13 +1482,15 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
1457
1482
|
},
|
|
1458
1483
|
workspace: {
|
|
1459
1484
|
description:
|
|
1460
|
-
"Workspace identity + resolved theme ({ id, slug, theme, locale }).",
|
|
1485
|
+
"Workspace identity + resolved theme + charge currency ({ id, slug, theme, locale, currency }).",
|
|
1461
1486
|
required: true,
|
|
1462
1487
|
fields: {
|
|
1463
1488
|
id: "string",
|
|
1464
1489
|
slug: "string",
|
|
1465
1490
|
theme: "ThemeTokens",
|
|
1466
1491
|
locale: "string",
|
|
1492
|
+
// sc-4686 — the ISO code this workspace charges its app users in.
|
|
1493
|
+
currency: "string",
|
|
1467
1494
|
},
|
|
1468
1495
|
},
|
|
1469
1496
|
navigation: {
|
|
@@ -2010,6 +2037,115 @@ const TRANSLATION_API_HOSTS = [
|
|
|
2010
2037
|
"lingvanex.com",
|
|
2011
2038
|
];
|
|
2012
2039
|
|
|
2040
|
+
// sc-4686 — the currency a price renders in when the HOST does not supply the
|
|
2041
|
+
// workspace's own (a host predating the workspace.currency slice, or a caller
|
|
2042
|
+
// that forgot to wire it). A rendering fallback so a price is never blank or
|
|
2043
|
+
// "undefined" — NOT a statement about which currencies the server accepts,
|
|
2044
|
+
// which is per-workspace and operator-configured (sc-4649).
|
|
2045
|
+
const CHARGE_CURRENCY = "SEK";
|
|
2046
|
+
|
|
2047
|
+
// sc-4686 — how each accepted currency is written, so a widget never has to.
|
|
2048
|
+
//
|
|
2049
|
+
// A widget must not name a currency: it is workspace configuration the owner
|
|
2050
|
+
// can change AFTER the widget was authored (see `chargeCurrency` on the
|
|
2051
|
+
// workspace context slice), so a baked "kr" or "€" goes stale the moment they
|
|
2052
|
+
// switch. Widgets render money through `useWorkspaceCurrency().formatMoney`,
|
|
2053
|
+
// which reads this table.
|
|
2054
|
+
//
|
|
2055
|
+
// Deliberately NOT `Intl.NumberFormat`: the exported Expo app and
|
|
2056
|
+
// react-native-web do not agree on its output (the same reason widgets format
|
|
2057
|
+
// dates with `date-fns` rather than `toLocaleDateString`), and this string ends
|
|
2058
|
+
// up on a price the buyer compares against the amount they are charged. An
|
|
2059
|
+
// explicit table renders byte-identically on both hosts.
|
|
2060
|
+
//
|
|
2061
|
+
// `symbol` is written `before` or `after` the amount; `space` inserts a
|
|
2062
|
+
// non-breaking gap between them. `decimals` is the minor-unit exponent, so it
|
|
2063
|
+
// is also how many minor units make one major unit.
|
|
2064
|
+
const CURRENCY_FORMATS = {
|
|
2065
|
+
SEK: { symbol: "kr", position: "after", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: " " },
|
|
2066
|
+
NOK: { symbol: "kr", position: "after", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: " " },
|
|
2067
|
+
DKK: { symbol: "kr.", position: "after", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: "." },
|
|
2068
|
+
ISK: { symbol: "kr", position: "after", space: true, decimals: 0, decimalSeparator: ",", groupSeparator: "." },
|
|
2069
|
+
EUR: { symbol: "€", position: "after", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: " " },
|
|
2070
|
+
GBP: { symbol: "£", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
|
|
2071
|
+
USD: { symbol: "$", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
|
|
2072
|
+
CAD: { symbol: "$", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
|
|
2073
|
+
AUD: { symbol: "$", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
|
|
2074
|
+
NZD: { symbol: "$", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
|
|
2075
|
+
CHF: { symbol: "CHF", position: "before", space: true, decimals: 2, decimalSeparator: ".", groupSeparator: "'" },
|
|
2076
|
+
PLN: { symbol: "zł", position: "after", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: " " },
|
|
2077
|
+
CZK: { symbol: "Kč", position: "after", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: " " },
|
|
2078
|
+
HUF: { symbol: "Ft", position: "after", space: true, decimals: 0, decimalSeparator: ",", groupSeparator: " " },
|
|
2079
|
+
RON: { symbol: "lei", position: "after", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: "." },
|
|
2080
|
+
BGN: { symbol: "лв", position: "after", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: " " },
|
|
2081
|
+
JPY: { symbol: "¥", position: "before", space: false, decimals: 0, decimalSeparator: ".", groupSeparator: "," },
|
|
2082
|
+
INR: { symbol: "₹", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
|
|
2083
|
+
ILS: { symbol: "₪", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
|
|
2084
|
+
ZAR: { symbol: "R", position: "before", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: " " },
|
|
2085
|
+
MXN: { symbol: "$", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
|
|
2086
|
+
BRL: { symbol: "R$", position: "before", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: "." },
|
|
2087
|
+
SGD: { symbol: "$", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
|
|
2088
|
+
HKD: { symbol: "$", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
|
|
2089
|
+
AED: { symbol: "AED", position: "before", space: true, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
|
|
2090
|
+
TRY: { symbol: "₺", position: "before", space: false, decimals: 2, decimalSeparator: ",", groupSeparator: "." },
|
|
2091
|
+
THB: { symbol: "฿", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
|
|
2092
|
+
KRW: { symbol: "₩", position: "before", space: false, decimals: 0, decimalSeparator: ".", groupSeparator: "," },
|
|
2093
|
+
CNY: { symbol: "¥", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
|
|
2094
|
+
};
|
|
2095
|
+
|
|
2096
|
+
// An unlisted currency still has to render something a buyer can read, so the
|
|
2097
|
+
// ISO code stands in for a symbol rather than the amount appearing bare.
|
|
2098
|
+
const CURRENCY_FORMAT_FALLBACK = Object.freeze({
|
|
2099
|
+
position: "after",
|
|
2100
|
+
space: true,
|
|
2101
|
+
decimals: 2,
|
|
2102
|
+
decimalSeparator: ".",
|
|
2103
|
+
groupSeparator: ",",
|
|
2104
|
+
});
|
|
2105
|
+
|
|
2106
|
+
// sc-4686 — the ISO code a workspace charges in, normalised. An empty value
|
|
2107
|
+
// means a host that predates the `workspace.currency` slice; falling back to the
|
|
2108
|
+
// platform default keeps a price rendering rather than printing "undefined" next
|
|
2109
|
+
// to an amount, which is the one place a blank is unacceptable.
|
|
2110
|
+
function normaliseCurrencyCode(value) {
|
|
2111
|
+
const code = typeof value === "string" ? value.trim().toUpperCase() : "";
|
|
2112
|
+
return code || CHARGE_CURRENCY;
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
/**
|
|
2116
|
+
* `formatMoneyIn("SEK", 45000)` → `"450,00 kr"`.
|
|
2117
|
+
*
|
|
2118
|
+
* Takes MINOR units — the same unit `requestPayment` charges in — so there is no
|
|
2119
|
+
* conversion step for a widget to get wrong. Renders from `CURRENCY_FORMATS`
|
|
2120
|
+
* rather than `Intl.NumberFormat`, which does not produce identical output in
|
|
2121
|
+
* the exported Expo app and react-native-web; this string sits beside the amount
|
|
2122
|
+
* the buyer is actually charged, so the two hosts must agree exactly.
|
|
2123
|
+
*
|
|
2124
|
+
* A non-finite amount renders as zero rather than "NaN": a price whose data has
|
|
2125
|
+
* not loaded should read as an amount, never as a broken string in a checkout.
|
|
2126
|
+
*/
|
|
2127
|
+
function formatMoneyIn(currency, minorUnits) {
|
|
2128
|
+
const code = normaliseCurrencyCode(currency);
|
|
2129
|
+
const fmt = CURRENCY_FORMATS[code] || {
|
|
2130
|
+
...CURRENCY_FORMAT_FALLBACK,
|
|
2131
|
+
symbol: code,
|
|
2132
|
+
};
|
|
2133
|
+
const amount = Number(minorUnits);
|
|
2134
|
+
const safe = Number.isFinite(amount) ? Math.round(amount) : 0;
|
|
2135
|
+
const negative = safe < 0;
|
|
2136
|
+
const digits = String(Math.abs(safe)).padStart(fmt.decimals + 1, "0");
|
|
2137
|
+
const whole = fmt.decimals > 0 ? digits.slice(0, -fmt.decimals) : digits;
|
|
2138
|
+
const cents = fmt.decimals > 0 ? digits.slice(-fmt.decimals) : "";
|
|
2139
|
+
const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, fmt.groupSeparator);
|
|
2140
|
+
const body = cents ? `${grouped}${fmt.decimalSeparator}${cents}` : grouped;
|
|
2141
|
+
const gap = fmt.space ? " " : "";
|
|
2142
|
+
const withSymbol =
|
|
2143
|
+
fmt.position === "before"
|
|
2144
|
+
? `${fmt.symbol}${gap}${body}`
|
|
2145
|
+
: `${body}${gap}${fmt.symbol}`;
|
|
2146
|
+
return negative ? `-${withSymbol}` : withSymbol;
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2013
2149
|
function deepFreeze(value) {
|
|
2014
2150
|
if (value === null || typeof value !== "object") return value;
|
|
2015
2151
|
if (Object.isFrozen(value)) return value;
|
|
@@ -2524,7 +2660,13 @@ const CONTRACT = deepFreeze({
|
|
|
2524
2660
|
// `recipient_expr` shape and no recipient column can name them. The member
|
|
2525
2661
|
// list stays host-side — a script receives a count, never the ids — and
|
|
2526
2662
|
// `exclude_user_id` keeps an author off their own message.
|
|
2527
|
-
|
|
2663
|
+
// 1.60.1: fix (sc-4753) — `useWidgetEvent`'s declared `returnShape` claimed
|
|
2664
|
+
// the hook resolves to an object with an `emit` member. It returns the
|
|
2665
|
+
// emitter FUNCTION itself, exactly as hooks.js, index.d.ts and the
|
|
2666
|
+
// Developer guide have always said, so a widget written against the
|
|
2667
|
+
// declared shape destructured a function and threw on first interaction.
|
|
2668
|
+
// Declared as a bare callable now; no runtime behaviour changed.
|
|
2669
|
+
version: "1.60.1",
|
|
2528
2670
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
2529
2671
|
hooks: HOOKS,
|
|
2530
2672
|
primitives: PRIMITIVES,
|
|
@@ -2546,6 +2688,9 @@ const CONTRACT = deepFreeze({
|
|
|
2546
2688
|
allowedBareImports: ALLOWED_BARE_IMPORTS,
|
|
2547
2689
|
hostApiUrlPatterns: HOST_API_URL_PATTERNS,
|
|
2548
2690
|
payloadValueTypes: PAYLOAD_VALUE_TYPES,
|
|
2691
|
+
chargeCurrency: CHARGE_CURRENCY,
|
|
2692
|
+
currencyFormats: CURRENCY_FORMATS,
|
|
2693
|
+
currencyFormatFallback: CURRENCY_FORMAT_FALLBACK,
|
|
2549
2694
|
translationApiHosts: TRANSLATION_API_HOSTS,
|
|
2550
2695
|
});
|
|
2551
2696
|
|
|
@@ -2721,6 +2866,8 @@ module.exports = {
|
|
|
2721
2866
|
deriveAccentTints,
|
|
2722
2867
|
gradientAngleToVector,
|
|
2723
2868
|
normaliseComponentGradient,
|
|
2869
|
+
formatMoneyIn,
|
|
2870
|
+
normaliseCurrencyCode,
|
|
2724
2871
|
widgetTranslationPrefix,
|
|
2725
2872
|
widgetTranslationKey,
|
|
2726
2873
|
sharedTranslationPrefix,
|
package/dist/contract.js
CHANGED
|
@@ -782,7 +782,16 @@ const HOOKS = [
|
|
|
782
782
|
{
|
|
783
783
|
name: "useWidgetEvent",
|
|
784
784
|
signature: "useWidgetEvent(eventName)",
|
|
785
|
-
|
|
785
|
+
// sc-4753 — the hook IS the emitter, so its return is declared as a bare
|
|
786
|
+
// callable. Naming an `emit` member here made every derived surface tell
|
|
787
|
+
// authors to destructure a function, which yields undefined at run time.
|
|
788
|
+
returnShape: {
|
|
789
|
+
"(returns)":
|
|
790
|
+
"(payload?) => void // the hook returns the emitter FUNCTION itself, " +
|
|
791
|
+
"not a wrapper object. Call it directly: " +
|
|
792
|
+
"const emitSlot = useWidgetEvent(\"slotChosen\"); " +
|
|
793
|
+
"emitSlot({ courtId }). Never destructure the result.",
|
|
794
|
+
},
|
|
786
795
|
requiredContextSlice: ["events.emit"],
|
|
787
796
|
scopes: null,
|
|
788
797
|
},
|
|
@@ -810,6 +819,22 @@ const HOOKS = [
|
|
|
810
819
|
requiredContextSlice: ["payments.requestPayment"],
|
|
811
820
|
scopes: ["payments.charge:appUser"],
|
|
812
821
|
},
|
|
822
|
+
// sc-4686 — the workspace's charge currency + the only sanctioned way to
|
|
823
|
+
// render money. A widget must NOT name a currency: the owner can change it
|
|
824
|
+
// after the widget was authored, so a baked symbol goes stale over a charge
|
|
825
|
+
// that followed the change.
|
|
826
|
+
{
|
|
827
|
+
name: "useWorkspaceCurrency",
|
|
828
|
+
signature: "useWorkspaceCurrency()",
|
|
829
|
+
returnShape: {
|
|
830
|
+
currency:
|
|
831
|
+
"string // the ISO code this workspace charges its app users in, e.g. \"SEK\"",
|
|
832
|
+
formatMoney:
|
|
833
|
+
"(minorUnits) => string // 45000 -> \"450,00 kr\"; renders identically on web and native. NEVER write a currency symbol or code yourself",
|
|
834
|
+
},
|
|
835
|
+
requiredContextSlice: ["workspace.currency"],
|
|
836
|
+
scopes: null,
|
|
837
|
+
},
|
|
813
838
|
// sc-890 — send an in-app notification to one app user in the tenant.
|
|
814
839
|
// IMPERATIVE: send() never fires on mount; the widget calls it from an
|
|
815
840
|
// event handler. Reads ctx.notifications.send (the injected
|
|
@@ -1457,13 +1482,15 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
1457
1482
|
},
|
|
1458
1483
|
workspace: {
|
|
1459
1484
|
description:
|
|
1460
|
-
"Workspace identity + resolved theme ({ id, slug, theme, locale }).",
|
|
1485
|
+
"Workspace identity + resolved theme + charge currency ({ id, slug, theme, locale, currency }).",
|
|
1461
1486
|
required: true,
|
|
1462
1487
|
fields: {
|
|
1463
1488
|
id: "string",
|
|
1464
1489
|
slug: "string",
|
|
1465
1490
|
theme: "ThemeTokens",
|
|
1466
1491
|
locale: "string",
|
|
1492
|
+
// sc-4686 — the ISO code this workspace charges its app users in.
|
|
1493
|
+
currency: "string",
|
|
1467
1494
|
},
|
|
1468
1495
|
},
|
|
1469
1496
|
navigation: {
|
|
@@ -2010,6 +2037,115 @@ const TRANSLATION_API_HOSTS = [
|
|
|
2010
2037
|
"lingvanex.com",
|
|
2011
2038
|
];
|
|
2012
2039
|
|
|
2040
|
+
// sc-4686 — the currency a price renders in when the HOST does not supply the
|
|
2041
|
+
// workspace's own (a host predating the workspace.currency slice, or a caller
|
|
2042
|
+
// that forgot to wire it). A rendering fallback so a price is never blank or
|
|
2043
|
+
// "undefined" — NOT a statement about which currencies the server accepts,
|
|
2044
|
+
// which is per-workspace and operator-configured (sc-4649).
|
|
2045
|
+
const CHARGE_CURRENCY = "SEK";
|
|
2046
|
+
|
|
2047
|
+
// sc-4686 — how each accepted currency is written, so a widget never has to.
|
|
2048
|
+
//
|
|
2049
|
+
// A widget must not name a currency: it is workspace configuration the owner
|
|
2050
|
+
// can change AFTER the widget was authored (see `chargeCurrency` on the
|
|
2051
|
+
// workspace context slice), so a baked "kr" or "€" goes stale the moment they
|
|
2052
|
+
// switch. Widgets render money through `useWorkspaceCurrency().formatMoney`,
|
|
2053
|
+
// which reads this table.
|
|
2054
|
+
//
|
|
2055
|
+
// Deliberately NOT `Intl.NumberFormat`: the exported Expo app and
|
|
2056
|
+
// react-native-web do not agree on its output (the same reason widgets format
|
|
2057
|
+
// dates with `date-fns` rather than `toLocaleDateString`), and this string ends
|
|
2058
|
+
// up on a price the buyer compares against the amount they are charged. An
|
|
2059
|
+
// explicit table renders byte-identically on both hosts.
|
|
2060
|
+
//
|
|
2061
|
+
// `symbol` is written `before` or `after` the amount; `space` inserts a
|
|
2062
|
+
// non-breaking gap between them. `decimals` is the minor-unit exponent, so it
|
|
2063
|
+
// is also how many minor units make one major unit.
|
|
2064
|
+
const CURRENCY_FORMATS = {
|
|
2065
|
+
SEK: { symbol: "kr", position: "after", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: " " },
|
|
2066
|
+
NOK: { symbol: "kr", position: "after", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: " " },
|
|
2067
|
+
DKK: { symbol: "kr.", position: "after", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: "." },
|
|
2068
|
+
ISK: { symbol: "kr", position: "after", space: true, decimals: 0, decimalSeparator: ",", groupSeparator: "." },
|
|
2069
|
+
EUR: { symbol: "€", position: "after", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: " " },
|
|
2070
|
+
GBP: { symbol: "£", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
|
|
2071
|
+
USD: { symbol: "$", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
|
|
2072
|
+
CAD: { symbol: "$", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
|
|
2073
|
+
AUD: { symbol: "$", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
|
|
2074
|
+
NZD: { symbol: "$", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
|
|
2075
|
+
CHF: { symbol: "CHF", position: "before", space: true, decimals: 2, decimalSeparator: ".", groupSeparator: "'" },
|
|
2076
|
+
PLN: { symbol: "zł", position: "after", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: " " },
|
|
2077
|
+
CZK: { symbol: "Kč", position: "after", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: " " },
|
|
2078
|
+
HUF: { symbol: "Ft", position: "after", space: true, decimals: 0, decimalSeparator: ",", groupSeparator: " " },
|
|
2079
|
+
RON: { symbol: "lei", position: "after", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: "." },
|
|
2080
|
+
BGN: { symbol: "лв", position: "after", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: " " },
|
|
2081
|
+
JPY: { symbol: "¥", position: "before", space: false, decimals: 0, decimalSeparator: ".", groupSeparator: "," },
|
|
2082
|
+
INR: { symbol: "₹", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
|
|
2083
|
+
ILS: { symbol: "₪", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
|
|
2084
|
+
ZAR: { symbol: "R", position: "before", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: " " },
|
|
2085
|
+
MXN: { symbol: "$", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
|
|
2086
|
+
BRL: { symbol: "R$", position: "before", space: true, decimals: 2, decimalSeparator: ",", groupSeparator: "." },
|
|
2087
|
+
SGD: { symbol: "$", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
|
|
2088
|
+
HKD: { symbol: "$", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
|
|
2089
|
+
AED: { symbol: "AED", position: "before", space: true, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
|
|
2090
|
+
TRY: { symbol: "₺", position: "before", space: false, decimals: 2, decimalSeparator: ",", groupSeparator: "." },
|
|
2091
|
+
THB: { symbol: "฿", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
|
|
2092
|
+
KRW: { symbol: "₩", position: "before", space: false, decimals: 0, decimalSeparator: ".", groupSeparator: "," },
|
|
2093
|
+
CNY: { symbol: "¥", position: "before", space: false, decimals: 2, decimalSeparator: ".", groupSeparator: "," },
|
|
2094
|
+
};
|
|
2095
|
+
|
|
2096
|
+
// An unlisted currency still has to render something a buyer can read, so the
|
|
2097
|
+
// ISO code stands in for a symbol rather than the amount appearing bare.
|
|
2098
|
+
const CURRENCY_FORMAT_FALLBACK = Object.freeze({
|
|
2099
|
+
position: "after",
|
|
2100
|
+
space: true,
|
|
2101
|
+
decimals: 2,
|
|
2102
|
+
decimalSeparator: ".",
|
|
2103
|
+
groupSeparator: ",",
|
|
2104
|
+
});
|
|
2105
|
+
|
|
2106
|
+
// sc-4686 — the ISO code a workspace charges in, normalised. An empty value
|
|
2107
|
+
// means a host that predates the `workspace.currency` slice; falling back to the
|
|
2108
|
+
// platform default keeps a price rendering rather than printing "undefined" next
|
|
2109
|
+
// to an amount, which is the one place a blank is unacceptable.
|
|
2110
|
+
function normaliseCurrencyCode(value) {
|
|
2111
|
+
const code = typeof value === "string" ? value.trim().toUpperCase() : "";
|
|
2112
|
+
return code || CHARGE_CURRENCY;
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
/**
|
|
2116
|
+
* `formatMoneyIn("SEK", 45000)` → `"450,00 kr"`.
|
|
2117
|
+
*
|
|
2118
|
+
* Takes MINOR units — the same unit `requestPayment` charges in — so there is no
|
|
2119
|
+
* conversion step for a widget to get wrong. Renders from `CURRENCY_FORMATS`
|
|
2120
|
+
* rather than `Intl.NumberFormat`, which does not produce identical output in
|
|
2121
|
+
* the exported Expo app and react-native-web; this string sits beside the amount
|
|
2122
|
+
* the buyer is actually charged, so the two hosts must agree exactly.
|
|
2123
|
+
*
|
|
2124
|
+
* A non-finite amount renders as zero rather than "NaN": a price whose data has
|
|
2125
|
+
* not loaded should read as an amount, never as a broken string in a checkout.
|
|
2126
|
+
*/
|
|
2127
|
+
function formatMoneyIn(currency, minorUnits) {
|
|
2128
|
+
const code = normaliseCurrencyCode(currency);
|
|
2129
|
+
const fmt = CURRENCY_FORMATS[code] || {
|
|
2130
|
+
...CURRENCY_FORMAT_FALLBACK,
|
|
2131
|
+
symbol: code,
|
|
2132
|
+
};
|
|
2133
|
+
const amount = Number(minorUnits);
|
|
2134
|
+
const safe = Number.isFinite(amount) ? Math.round(amount) : 0;
|
|
2135
|
+
const negative = safe < 0;
|
|
2136
|
+
const digits = String(Math.abs(safe)).padStart(fmt.decimals + 1, "0");
|
|
2137
|
+
const whole = fmt.decimals > 0 ? digits.slice(0, -fmt.decimals) : digits;
|
|
2138
|
+
const cents = fmt.decimals > 0 ? digits.slice(-fmt.decimals) : "";
|
|
2139
|
+
const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, fmt.groupSeparator);
|
|
2140
|
+
const body = cents ? `${grouped}${fmt.decimalSeparator}${cents}` : grouped;
|
|
2141
|
+
const gap = fmt.space ? " " : "";
|
|
2142
|
+
const withSymbol =
|
|
2143
|
+
fmt.position === "before"
|
|
2144
|
+
? `${fmt.symbol}${gap}${body}`
|
|
2145
|
+
: `${body}${gap}${fmt.symbol}`;
|
|
2146
|
+
return negative ? `-${withSymbol}` : withSymbol;
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2013
2149
|
function deepFreeze(value) {
|
|
2014
2150
|
if (value === null || typeof value !== "object") return value;
|
|
2015
2151
|
if (Object.isFrozen(value)) return value;
|
|
@@ -2524,7 +2660,13 @@ const CONTRACT = deepFreeze({
|
|
|
2524
2660
|
// `recipient_expr` shape and no recipient column can name them. The member
|
|
2525
2661
|
// list stays host-side — a script receives a count, never the ids — and
|
|
2526
2662
|
// `exclude_user_id` keeps an author off their own message.
|
|
2527
|
-
|
|
2663
|
+
// 1.60.1: fix (sc-4753) — `useWidgetEvent`'s declared `returnShape` claimed
|
|
2664
|
+
// the hook resolves to an object with an `emit` member. It returns the
|
|
2665
|
+
// emitter FUNCTION itself, exactly as hooks.js, index.d.ts and the
|
|
2666
|
+
// Developer guide have always said, so a widget written against the
|
|
2667
|
+
// declared shape destructured a function and threw on first interaction.
|
|
2668
|
+
// Declared as a bare callable now; no runtime behaviour changed.
|
|
2669
|
+
version: "1.60.1",
|
|
2528
2670
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
2529
2671
|
hooks: HOOKS,
|
|
2530
2672
|
primitives: PRIMITIVES,
|
|
@@ -2546,6 +2688,9 @@ const CONTRACT = deepFreeze({
|
|
|
2546
2688
|
allowedBareImports: ALLOWED_BARE_IMPORTS,
|
|
2547
2689
|
hostApiUrlPatterns: HOST_API_URL_PATTERNS,
|
|
2548
2690
|
payloadValueTypes: PAYLOAD_VALUE_TYPES,
|
|
2691
|
+
chargeCurrency: CHARGE_CURRENCY,
|
|
2692
|
+
currencyFormats: CURRENCY_FORMATS,
|
|
2693
|
+
currencyFormatFallback: CURRENCY_FORMAT_FALLBACK,
|
|
2549
2694
|
translationApiHosts: TRANSLATION_API_HOSTS,
|
|
2550
2695
|
});
|
|
2551
2696
|
|
|
@@ -2721,6 +2866,8 @@ export {
|
|
|
2721
2866
|
deriveAccentTints,
|
|
2722
2867
|
gradientAngleToVector,
|
|
2723
2868
|
normaliseComponentGradient,
|
|
2869
|
+
formatMoneyIn,
|
|
2870
|
+
normaliseCurrencyCode,
|
|
2724
2871
|
widgetTranslationPrefix,
|
|
2725
2872
|
widgetTranslationKey,
|
|
2726
2873
|
sharedTranslationPrefix,
|
package/dist/hooks.js
CHANGED
|
@@ -33,10 +33,15 @@ import React, {
|
|
|
33
33
|
// `sharedTranslationKey` builds the tenant-wide predefined key
|
|
34
34
|
// (`shared.<key>`); `isSharedTranslationKey` tells the hook whether a bare key
|
|
35
35
|
// is one of the predefined shared keys and so should try the shared namespace.
|
|
36
|
+
// sc-4686 — `formatMoneyIn` lives beside the currency table it reads, for the
|
|
37
|
+
// same single-source reason: the host, the compiler-parity tests and this hook
|
|
38
|
+
// must render a price identically.
|
|
36
39
|
import {
|
|
37
40
|
widgetTranslationKey,
|
|
38
41
|
sharedTranslationKey,
|
|
39
42
|
isSharedTranslationKey,
|
|
43
|
+
formatMoneyIn,
|
|
44
|
+
normaliseCurrencyCode,
|
|
40
45
|
} from "./contract.js";
|
|
41
46
|
|
|
42
47
|
/** @internal — host-injected context value of shape WidgetContext (see index.d.ts). */
|
|
@@ -129,6 +134,34 @@ function readInput(inputs, inputName) {
|
|
|
129
134
|
: undefined;
|
|
130
135
|
}
|
|
131
136
|
|
|
137
|
+
/**
|
|
138
|
+
* sc-4686 — the workspace's charge currency, and the only sanctioned way for a
|
|
139
|
+
* widget to render money.
|
|
140
|
+
*
|
|
141
|
+
* A widget must never name a currency. The owner picks what the workspace
|
|
142
|
+
* charges its app users in and can change it long AFTER the widget was authored,
|
|
143
|
+
* so a hardcoded "kr" or "€" starts lying about a charge that correctly followed
|
|
144
|
+
* the change. Reading it from the host means every existing widget survives a
|
|
145
|
+
* switch with no regeneration — the same reason `useTheme()` and `useI18n()`
|
|
146
|
+
* resolve the other two pieces of workspace configuration at render time.
|
|
147
|
+
*
|
|
148
|
+
* `formatMoney` takes MINOR units, the same unit `requestPayment` charges in.
|
|
149
|
+
*
|
|
150
|
+
* @returns {{ currency: string, formatMoney: (minorUnits: number) => string }}
|
|
151
|
+
*/
|
|
152
|
+
export function useWorkspaceCurrency() {
|
|
153
|
+
const ctx = useWidgetContextOrThrow("useWorkspaceCurrency");
|
|
154
|
+
const currency = ctx.workspace && ctx.workspace.currency;
|
|
155
|
+
// React.useMemo rather than a bare import — see the import block above.
|
|
156
|
+
return React.useMemo(
|
|
157
|
+
() => ({
|
|
158
|
+
currency: normaliseCurrencyCode(currency),
|
|
159
|
+
formatMoney: (minorUnits) => formatMoneyIn(currency, minorUnits),
|
|
160
|
+
}),
|
|
161
|
+
[currency],
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
132
165
|
/**
|
|
133
166
|
* Returns the host-provided theme tokens. The host guarantees every field
|
|
134
167
|
* documented in CONTRACT.themeTokens is present (defaults merged with
|
package/dist/index.d.ts
CHANGED
|
@@ -846,6 +846,19 @@ export interface SendNotificationApi {
|
|
|
846
846
|
*/
|
|
847
847
|
export function useSendNotification(): SendNotificationApi;
|
|
848
848
|
|
|
849
|
+
export interface WorkspaceCurrency {
|
|
850
|
+
/** The ISO code this workspace charges its app users in. */
|
|
851
|
+
currency: string;
|
|
852
|
+
/** MINOR units in, a display string out: 45000 -> "450,00 kr". */
|
|
853
|
+
formatMoney: (minorUnits: number) => string;
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* sc-4686 — the workspace charge currency + the only sanctioned way to render
|
|
857
|
+
* money. Never write a currency symbol or code into a widget: the owner can
|
|
858
|
+
* change it after the widget ships.
|
|
859
|
+
*/
|
|
860
|
+
export function useWorkspaceCurrency(): WorkspaceCurrency;
|
|
861
|
+
|
|
849
862
|
export function useTheme(): ThemeTokens;
|
|
850
863
|
|
|
851
864
|
/**
|
|
@@ -1768,12 +1781,6 @@ export interface LintFinding {
|
|
|
1768
1781
|
}
|
|
1769
1782
|
export interface LintOptions {
|
|
1770
1783
|
manifest?: { requestedScopes?: string[]; supportedPlatforms?: string[] };
|
|
1771
|
-
/**
|
|
1772
|
-
* sc-4649 — the currency the target workspace charges its app users in. A
|
|
1773
|
-
* per-workspace value the SDK cannot know, so the `payment-currency` rule
|
|
1774
|
-
* stays silent unless the caller (the backend publish gate) supplies it.
|
|
1775
|
-
*/
|
|
1776
|
-
paymentCurrency?: string;
|
|
1777
1784
|
}
|
|
1778
1785
|
export function lintSource(
|
|
1779
1786
|
source: string,
|
|
@@ -1849,6 +1856,10 @@ export interface AiWidgetContract {
|
|
|
1849
1856
|
readonly bundleExportContract: ReadonlyArray<ContractBundleShape>;
|
|
1850
1857
|
readonly bannedApis: ReadonlyArray<ContractBannedApi>;
|
|
1851
1858
|
readonly allowedBareImports: ReadonlyArray<string>;
|
|
1859
|
+
/** sc-4686 — the render-time fallback currency when the host supplies none. */
|
|
1860
|
+
readonly chargeCurrency: string;
|
|
1861
|
+
/** sc-4686 — how each currency is written; read by `formatMoney`, never Intl. */
|
|
1862
|
+
readonly currencyFormats: Readonly<Record<string, Readonly<{ symbol: string; position: "before" | "after"; space: boolean; decimals: number; decimalSeparator: string; groupSeparator: string }>>>;
|
|
1852
1863
|
}
|
|
1853
1864
|
|
|
1854
1865
|
export const CONTRACT: AiWidgetContract;
|
package/dist/index.js
CHANGED
package/dist/index.native.js
CHANGED
package/dist/linter.cjs
CHANGED
|
@@ -728,17 +728,15 @@ function _jsxOpenTagEnd(source, from) {
|
|
|
728
728
|
return source.length;
|
|
729
729
|
}
|
|
730
730
|
|
|
731
|
-
// sc-4649 — see linter.js for the rationale
|
|
732
|
-
// in lockstep (
|
|
731
|
+
// sc-4649 / sc-4686 — see linter.js for the rationale comments. The two
|
|
732
|
+
// files must stay in lockstep (a test asserts behaviour-equivalence).
|
|
733
733
|
const REQUEST_PAYMENT_CALL = "requestPayment(";
|
|
734
734
|
// A generous window: the options object is usually inline, occasionally spread
|
|
735
735
|
// over a few lines. Bounded so an unbalanced source can't scan to EOF.
|
|
736
736
|
const REQUEST_PAYMENT_WINDOW = 400;
|
|
737
737
|
const CURRENCY_LITERAL_RE = /currency\s*:\s*(['"`])\s*([A-Za-z]{2,8})\s*\1/;
|
|
738
738
|
|
|
739
|
-
function _paymentCurrencyRules(source
|
|
740
|
-
const expected = String(paymentCurrency || "").trim().toUpperCase();
|
|
741
|
-
if (!expected) return [];
|
|
739
|
+
function _paymentCurrencyRules(source) {
|
|
742
740
|
const findings = [];
|
|
743
741
|
// Comments blanked, strings kept: the currency code IS a string literal.
|
|
744
742
|
const code = _stripNonCode(source, { keepStrings: true });
|
|
@@ -752,18 +750,16 @@ function _paymentCurrencyRules(source, paymentCurrency) {
|
|
|
752
750
|
code.slice(at, at + REQUEST_PAYMENT_WINDOW),
|
|
753
751
|
);
|
|
754
752
|
if (!match) continue;
|
|
755
|
-
const found = match[2].toUpperCase();
|
|
756
|
-
if (found === expected) continue;
|
|
757
753
|
// Line of the currency literal itself, not of the call.
|
|
758
754
|
const line = code.slice(0, at + match.index).split(/\r?\n/).length;
|
|
759
755
|
findings.push({
|
|
760
756
|
rule: "payment-currency",
|
|
761
757
|
severity: "error",
|
|
762
758
|
label:
|
|
763
|
-
`requestPayment
|
|
764
|
-
|
|
765
|
-
`
|
|
766
|
-
|
|
759
|
+
`requestPayment hardcodes currency "${match[2].toUpperCase()}". Each ` +
|
|
760
|
+
`workspace charges in its OWN currency and can change it after this ` +
|
|
761
|
+
`widget ships, so OMIT currency — the platform applies the ` +
|
|
762
|
+
`workspace's — and show prices with useWorkspaceCurrency().formatMoney.`,
|
|
767
763
|
line,
|
|
768
764
|
snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
|
|
769
765
|
});
|
|
@@ -771,6 +767,56 @@ function _paymentCurrencyRules(source, paymentCurrency) {
|
|
|
771
767
|
return findings;
|
|
772
768
|
}
|
|
773
769
|
|
|
770
|
+
// sc-4686 — no-hardcoded-currency-label.
|
|
771
|
+
//
|
|
772
|
+
// The charge is safe once `currency` is omitted, but the DISPLAY is not: a "kr"
|
|
773
|
+
// in JSX or a "€" in a translation string keeps showing the old currency after
|
|
774
|
+
// the owner switches, so the widget tells the buyer one currency and charges
|
|
775
|
+
// another. `formatMoney` renders the symbol from the workspace's currency.
|
|
776
|
+
//
|
|
777
|
+
// A `warning`, not an error: this is a text heuristic over string data, and a
|
|
778
|
+
// legitimate non-price use exists (a currency-converter widget's own labels).
|
|
779
|
+
// It drives an AI repair turn without hard-failing a human submission.
|
|
780
|
+
//
|
|
781
|
+
// Only in a widget that CHARGES — a widget with no `requestPayment` is not
|
|
782
|
+
// displaying a price the platform is about to take money for. And only where the
|
|
783
|
+
// symbol sits on a digit or a template hole, so prose can't trip it.
|
|
784
|
+
const CURRENCY_LABEL_RES = [
|
|
785
|
+
// `${` is a template hole, `{` a JSX expression — a price is written both ways.
|
|
786
|
+
/[€£¥₹₺₪]\s*(?:\d|\$?\{)/,
|
|
787
|
+
/(?:\d|\})\s*(?:kr|kr\.|zł|Kč|Ft|lei|лв)\b/,
|
|
788
|
+
/\b(?:SEK|NOK|DKK|EUR|GBP|USD|CHF|PLN|CZK|HUF|JPY|INR)\b\s*(?:\d|\$\{)/,
|
|
789
|
+
/(?:\d|\})\s*\b(?:SEK|NOK|DKK|EUR|GBP|USD|CHF|PLN|CZK|HUF|JPY|INR)\b/,
|
|
790
|
+
];
|
|
791
|
+
|
|
792
|
+
function _hardcodedCurrencyLabelRules(source) {
|
|
793
|
+
const code = _stripNonCode(source, { keepStrings: true });
|
|
794
|
+
if (!code.includes(REQUEST_PAYMENT_CALL)) return [];
|
|
795
|
+
const findings = [];
|
|
796
|
+
const lines = code.split(/\r?\n/);
|
|
797
|
+
const sourceLines = source.split(/\r?\n/);
|
|
798
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
799
|
+
// The requestPayment call's own `currency:` literal is the other rule's
|
|
800
|
+
// finding — don't report the same line twice.
|
|
801
|
+
if (CURRENCY_LITERAL_RE.test(lines[i])) continue;
|
|
802
|
+
if (!CURRENCY_LABEL_RES.some((re) => re.test(lines[i]))) continue;
|
|
803
|
+
findings.push({
|
|
804
|
+
rule: "no-hardcoded-currency-label",
|
|
805
|
+
severity: "warning",
|
|
806
|
+
label:
|
|
807
|
+
`this charging widget writes a currency next to a price. The owner can ` +
|
|
808
|
+
`change the workspace's currency after it ships, leaving the label ` +
|
|
809
|
+
`wrong over a correct charge — render money with ` +
|
|
810
|
+
`useWorkspaceCurrency().formatMoney(amountInMinorUnits) instead.`,
|
|
811
|
+
line: i + 1,
|
|
812
|
+
snippet: (sourceLines[i] || "").trim().slice(0, 200),
|
|
813
|
+
});
|
|
814
|
+
// One per line is enough; a price line often matches twice.
|
|
815
|
+
}
|
|
816
|
+
return findings;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
|
|
774
820
|
// sc-4650 — soft warning: a widget that charges must tell a failure worth
|
|
775
821
|
// retrying from a refusal only the workspace owner can lift. Collapsing every
|
|
776
822
|
// rejection into one "please try again" is what sent payers round an
|
|
@@ -894,9 +940,8 @@ function lintSource(source, options) {
|
|
|
894
940
|
findings.push(..._reactInScopeRules(source));
|
|
895
941
|
findings.push(..._imagePercentHeightRules(source));
|
|
896
942
|
// sc-4650 — soft warning: every payment refusal reported as "try again".
|
|
897
|
-
findings.push(
|
|
898
|
-
|
|
899
|
-
);
|
|
943
|
+
findings.push(..._paymentCurrencyRules(source));
|
|
944
|
+
findings.push(..._hardcodedCurrencyLabelRules(source));
|
|
900
945
|
findings.push(..._paymentErrorHandlingRules(source));
|
|
901
946
|
findings.push(
|
|
902
947
|
..._scopeRules(source, options && options.manifest).map((f) => ({
|
package/dist/linter.js
CHANGED
|
@@ -846,33 +846,29 @@ function _jsxOpenTagEnd(source, from) {
|
|
|
846
846
|
return source.length;
|
|
847
847
|
}
|
|
848
848
|
|
|
849
|
-
// sc-4649 — payment-currency.
|
|
849
|
+
// sc-4649 / sc-4686 — payment-currency.
|
|
850
850
|
//
|
|
851
|
-
//
|
|
852
|
-
//
|
|
853
|
-
//
|
|
854
|
-
//
|
|
855
|
-
//
|
|
856
|
-
// a literal mismatch is unambiguous and there is no correct code for an opt-out
|
|
857
|
-
// directive to rescue.
|
|
851
|
+
// A widget must not name a currency AT ALL. Each workspace charges its app users
|
|
852
|
+
// in one currency of its own choosing, the owner can change it long after the
|
|
853
|
+
// widget was authored, and the server refuses a charge in anything else — so
|
|
854
|
+
// omitting `currency` is the ONLY always-correct call: the platform applies the
|
|
855
|
+
// workspace's own, and an existing widget follows a switch with no rebuild.
|
|
858
856
|
//
|
|
859
|
-
//
|
|
860
|
-
//
|
|
861
|
-
//
|
|
862
|
-
// deliberately no default: guessing one would flag a correct widget.
|
|
857
|
+
// That makes this rule workspace-INDEPENDENT (it needs no expected value passed
|
|
858
|
+
// in, unlike its first sc-4649 form) and therefore it also fires in a bare
|
|
859
|
+
// `appstudio-widget lint`, before a third-party developer ever uploads.
|
|
863
860
|
//
|
|
864
|
-
//
|
|
865
|
-
//
|
|
866
|
-
//
|
|
861
|
+
// `error`, not a warning like `payment-error-not-branched`: a literal here either
|
|
862
|
+
// disagrees with the workspace and kills every checkout, or agrees today and
|
|
863
|
+
// silently starts lying the moment the owner switches. Neither is code worth an
|
|
864
|
+
// opt-out directive.
|
|
867
865
|
const REQUEST_PAYMENT_CALL = "requestPayment(";
|
|
868
866
|
// A generous window: the options object is usually inline, occasionally spread
|
|
869
867
|
// over a few lines. Bounded so an unbalanced source can't scan to EOF.
|
|
870
868
|
const REQUEST_PAYMENT_WINDOW = 400;
|
|
871
869
|
const CURRENCY_LITERAL_RE = /currency\s*:\s*(['"`])\s*([A-Za-z]{2,8})\s*\1/;
|
|
872
870
|
|
|
873
|
-
function _paymentCurrencyRules(source
|
|
874
|
-
const expected = String(paymentCurrency || "").trim().toUpperCase();
|
|
875
|
-
if (!expected) return [];
|
|
871
|
+
function _paymentCurrencyRules(source) {
|
|
876
872
|
const findings = [];
|
|
877
873
|
// Comments blanked, strings kept: the currency code IS a string literal.
|
|
878
874
|
const code = _stripNonCode(source, { keepStrings: true });
|
|
@@ -886,18 +882,16 @@ function _paymentCurrencyRules(source, paymentCurrency) {
|
|
|
886
882
|
code.slice(at, at + REQUEST_PAYMENT_WINDOW),
|
|
887
883
|
);
|
|
888
884
|
if (!match) continue;
|
|
889
|
-
const found = match[2].toUpperCase();
|
|
890
|
-
if (found === expected) continue;
|
|
891
885
|
// Line of the currency literal itself, not of the call.
|
|
892
886
|
const line = code.slice(0, at + match.index).split(/\r?\n/).length;
|
|
893
887
|
findings.push({
|
|
894
888
|
rule: "payment-currency",
|
|
895
889
|
severity: "error",
|
|
896
890
|
label:
|
|
897
|
-
`requestPayment
|
|
898
|
-
|
|
899
|
-
`
|
|
900
|
-
|
|
891
|
+
`requestPayment hardcodes currency "${match[2].toUpperCase()}". Each ` +
|
|
892
|
+
`workspace charges in its OWN currency and can change it after this ` +
|
|
893
|
+
`widget ships, so OMIT currency — the platform applies the ` +
|
|
894
|
+
`workspace's — and show prices with useWorkspaceCurrency().formatMoney.`,
|
|
901
895
|
line,
|
|
902
896
|
snippet: (sourceLines[line - 1] || "").trim().slice(0, 200),
|
|
903
897
|
});
|
|
@@ -905,6 +899,56 @@ function _paymentCurrencyRules(source, paymentCurrency) {
|
|
|
905
899
|
return findings;
|
|
906
900
|
}
|
|
907
901
|
|
|
902
|
+
// sc-4686 — no-hardcoded-currency-label.
|
|
903
|
+
//
|
|
904
|
+
// The charge is safe once `currency` is omitted, but the DISPLAY is not: a "kr"
|
|
905
|
+
// in JSX or a "€" in a translation string keeps showing the old currency after
|
|
906
|
+
// the owner switches, so the widget tells the buyer one currency and charges
|
|
907
|
+
// another. `formatMoney` renders the symbol from the workspace's currency.
|
|
908
|
+
//
|
|
909
|
+
// A `warning`, not an error: this is a text heuristic over string data, and a
|
|
910
|
+
// legitimate non-price use exists (a currency-converter widget's own labels).
|
|
911
|
+
// It drives an AI repair turn without hard-failing a human submission.
|
|
912
|
+
//
|
|
913
|
+
// Only in a widget that CHARGES — a widget with no `requestPayment` is not
|
|
914
|
+
// displaying a price the platform is about to take money for. And only where the
|
|
915
|
+
// symbol sits on a digit or a template hole, so prose can't trip it.
|
|
916
|
+
const CURRENCY_LABEL_RES = [
|
|
917
|
+
// `${` is a template hole, `{` a JSX expression — a price is written both ways.
|
|
918
|
+
/[€£¥₹₺₪]\s*(?:\d|\$?\{)/,
|
|
919
|
+
/(?:\d|\})\s*(?:kr|kr\.|zł|Kč|Ft|lei|лв)\b/,
|
|
920
|
+
/\b(?:SEK|NOK|DKK|EUR|GBP|USD|CHF|PLN|CZK|HUF|JPY|INR)\b\s*(?:\d|\$\{)/,
|
|
921
|
+
/(?:\d|\})\s*\b(?:SEK|NOK|DKK|EUR|GBP|USD|CHF|PLN|CZK|HUF|JPY|INR)\b/,
|
|
922
|
+
];
|
|
923
|
+
|
|
924
|
+
function _hardcodedCurrencyLabelRules(source) {
|
|
925
|
+
const code = _stripNonCode(source, { keepStrings: true });
|
|
926
|
+
if (!code.includes(REQUEST_PAYMENT_CALL)) return [];
|
|
927
|
+
const findings = [];
|
|
928
|
+
const lines = code.split(/\r?\n/);
|
|
929
|
+
const sourceLines = source.split(/\r?\n/);
|
|
930
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
931
|
+
// The requestPayment call's own `currency:` literal is the other rule's
|
|
932
|
+
// finding — don't report the same line twice.
|
|
933
|
+
if (CURRENCY_LITERAL_RE.test(lines[i])) continue;
|
|
934
|
+
if (!CURRENCY_LABEL_RES.some((re) => re.test(lines[i]))) continue;
|
|
935
|
+
findings.push({
|
|
936
|
+
rule: "no-hardcoded-currency-label",
|
|
937
|
+
severity: "warning",
|
|
938
|
+
label:
|
|
939
|
+
`this charging widget writes a currency next to a price. The owner can ` +
|
|
940
|
+
`change the workspace's currency after it ships, leaving the label ` +
|
|
941
|
+
`wrong over a correct charge — render money with ` +
|
|
942
|
+
`useWorkspaceCurrency().formatMoney(amountInMinorUnits) instead.`,
|
|
943
|
+
line: i + 1,
|
|
944
|
+
snippet: (sourceLines[i] || "").trim().slice(0, 200),
|
|
945
|
+
});
|
|
946
|
+
// One per line is enough; a price line often matches twice.
|
|
947
|
+
}
|
|
948
|
+
return findings;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
|
|
908
952
|
// sc-4650 — soft warning: a widget that charges must tell a failure worth
|
|
909
953
|
// retrying from a refusal only the workspace owner can lift. Collapsing every
|
|
910
954
|
// rejection into one "please try again" is what sent payers round an
|
|
@@ -1040,9 +1084,8 @@ export function lintSource(source, options) {
|
|
|
1040
1084
|
// sc-3493 — soft warning: percentage height on an <Image> collapses to 0.
|
|
1041
1085
|
findings.push(..._imagePercentHeightRules(source));
|
|
1042
1086
|
// sc-4650 — soft warning: every payment refusal reported as "try again".
|
|
1043
|
-
findings.push(
|
|
1044
|
-
|
|
1045
|
-
);
|
|
1087
|
+
findings.push(..._paymentCurrencyRules(source));
|
|
1088
|
+
findings.push(..._hardcodedCurrencyLabelRules(source));
|
|
1046
1089
|
findings.push(..._paymentErrorHandlingRules(source));
|
|
1047
1090
|
// REQ-USERMGMT / REQ-ACL-SYS M3 — scope-aware rules. Run after the
|
|
1048
1091
|
// line-by-line scan so banned-identifier findings stay first in the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.85.1",
|
|
4
4
|
"description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
],
|
|
49
49
|
"scripts": {
|
|
50
50
|
"build": "node scripts/build.js",
|
|
51
|
-
"test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-payment-error.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/theme-depth-tokens.test.js"
|
|
51
|
+
"test": "node --test src/__tests__/contract.test.js src/__tests__/hooks-users.test.js src/__tests__/hooks-groups.test.js src/__tests__/hooks-schema.test.js src/__tests__/hooks-assets-by-tag.test.js src/__tests__/hooks-filestore-upload.test.js src/__tests__/hooks-filestore-file.test.js src/__tests__/hooks-mutation.test.js src/__tests__/hooks-payments.test.js src/__tests__/hooks-record-permissions.test.js src/__tests__/hooks-geolocation.test.js src/__tests__/hooks-section-empty.test.js src/__tests__/hooks-widget-event.test.js src/__tests__/hooks-widget-input.test.js src/__tests__/hooks-identification.test.js src/__tests__/hooks-subscription.test.js src/__tests__/hooks-volatile-query-key.test.js src/__tests__/linter-users-scope.test.js src/__tests__/linter-comments.test.js src/__tests__/linter-translation-api.test.js src/__tests__/linter-image-height.test.js src/__tests__/linter-payment-error.test.js src/__tests__/linter-platform.test.js src/__tests__/linter-react-import.test.js src/__tests__/lucide-icon-names.test.js src/__tests__/lucideIconName.test.js src/__tests__/manifest-actions.test.js src/__tests__/widget-translations.test.js src/__tests__/hooks-translate.test.js src/__tests__/devserver.test.js src/__tests__/host-externals.test.js src/__tests__/datetimepicker.test.js src/__tests__/property-schema-resolve.test.js src/__tests__/theme-components-parity.test.js src/__tests__/theme-depth-tokens.test.js"
|
|
52
52
|
},
|
|
53
53
|
"engines": {
|
|
54
54
|
"node": ">=18"
|