@colixsystems/widget-sdk 0.278.0 → 0.281.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -73,7 +73,8 @@ The data layer lives in **four separate domain-client packages**, each instantia
73
73
  | **DIRECTORY** | `useInvites(query)` (optional: query) | `{ invites, loading, error, refetch, resend, revoke }` | `directory.invites.*` — `users.write:*` + the SystemAcl `users.write` capability (the whole invite surface, list included). `query` is `{ status?, q?, app_user_id?, limit?, offset? }` with `status` ∈ `pending \| accepted \| revoked \| expired \| not_sent \| open \| all` (endpoint default `all`; `open` = `pending`, `not_sent` or `expired`); `q` matches the invite's name or email, `app_user_id` returns the invites reserving that user id. `resend(inviteId)` sends the invitation — the first send of a `not_sent` one included. |
74
74
  | **DIRECTORY** | `useBankIdLink()` | `{ linked, available, status, qr, message, startLink, refresh, cancel, unlink, refetchStatus, … }` | `directory.bankid.*` — no scope (JWT-gated self-service) |
75
75
  | **FILESTORE** (`ctx.filestore`) | `usePdfExport({ spaceType, folderId })` (optional: spaceType, folderId) | `{ openPdf, opening, canOpenPdf, exportToPdf, exporting, error, lastExported }` | `ctx.filestore.files.renderPdf` + `ctx.device.pdfViewer`, `ctx.filestore.files.exportPdf` — `files.write:*`. `openPdf(html, { fileName? })` renders the HTML to a PDF server-side and OPENS it (web: PDF tab; native: print / share sheet) without saving. `exportToPdf(html, { fileName?, folderId? })` renders it and saves it as a file (`application/pdf`). Same server-side renderer on web + native. |
76
- | **PAYMENTS** (`ctx.payments`) | `usePayments()` | `{ requestPayment, getPayment }` | `ctx.payments.*` — `payments.charge:appUser`. Rejects with `PaymentError { code, message, retryable }`; when `retryable` is `false` show `message` and drop the retry. Charges are accepted ONLY in the currency the workspace sells in — omit `currency` and the platform applies it (a disagreeing literal is a publish-blocking `payment-currency` finding). |
76
+ | **PAYMENTS** (`ctx.payments`) | `useAccessPlans()` | `{ plans, loading, error, purchaseAvailable, purchase, cancel, refresh }` | `ctx.payments.{listAccessPlans,purchaseAccessPlan,cancelAccessPlan}` — `payments.charge:appUser`. Plans selling access to restricted pages; `purchase(planId)` names only the plan, the server sets price and group. `purchaseAvailable` is `false` in an iOS / Android build (digital goods need store billing): show a visible not-available state. |
77
+ | **PAYMENTS** (`ctx.payments`) | `usePayments()` | `{ requestPayment, getPayment }` | `ctx.payments.*` — `payments.charge:appUser`. `requestPayment` requires `kind` (`"physical"` \| `"service"` \| `"digital"`): what is sold. A `"digital"` charge is refused in an iOS or Android build (`DIGITAL_SALE_NEEDS_STORE_BILLING`). Rejects with `PaymentError { code, message, retryable }`; when `retryable` is `false` show `message` and drop the retry. Charges are accepted ONLY in the currency the workspace sells in — omit `currency` and the platform applies it (a disagreeing literal is a publish-blocking `payment-currency` finding). |
77
78
  | **NOTIFICATIONS** (`ctx.notifications`) | `useSendNotification()` | `{ send, sending, error }` | `ctx.notifications.send` — `notifications.send:appUser`. `send({ recipient_user_id, title, body, link?, payload? })` notifies one app user in the same workspace; call from an event handler (never render); rejects with `NotificationError`. |
78
79
  | **NOTIFICATIONS** (`ctx.notifications`) | `useRecordMutes(tableId)` | `{ isMuted, loading, error, refetch }` | `ctx.notifications.mutes` — no scope. Which records of one table the signed-in user has muted, in one request for a whole list; `isMuted(recordId)` turns false when a timed mute lapses, and a `useRecordMute` write shows at once. |
79
80
  | **NOTIFICATIONS** (`ctx.notifications`) | `useRecordMute(tableId, recordId)` | `{ muted, mutedUntil, loading, error, mute, unmute, refetch }` | `ctx.notifications.mutes` — no scope. Mutes one record's notifications for the signed-in user, `mute(until)` with an ISO time, a `Date`, or `null` for until unmuted. Needs read access to the record (`RECORD_NOT_FOUND` otherwise). |
@@ -87,7 +88,19 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
87
88
 
88
89
  ## Status
89
90
 
90
- `v0.278.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**.
91
+ `v0.281.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**.
92
+
93
+ ### What's new in 0.281.0 (contract 1.230.0)
94
+
95
+ **Paid page access in the store builds.** A workspace can sell a `useAccessPlans()` plan in its iOS and Android apps by naming a RevenueCat entitlement for it. In those builds `purchase(planId)` then opens the store's own in-app purchase sheet, and access arrives when RevenueCat confirms the purchase — call `refresh()` a moment later. Each plan now carries `purchase_available`; `purchaseAvailable` is `false` only when no plan can be bought here, so check the plan's own flag before showing its button. A plan or paid-app access bought on the web also grants its RevenueCat entitlement, so `usePurchases().entitlements` in the app includes it. `usePurchases()` itself stays native-only. The payments client's `purchaseAccessPlan()` may answer with `store_product_id` (`@colixsystems/payments-client` 0.4.0).
96
+
97
+ ### What's new in 0.280.0 (contract 1.229.0)
98
+
99
+ **`useAccessPlans()` — paid access to restricted pages.** A workspace can sell plans (one-time or monthly) that add the buyer to a group the maker restricted pages to. `useAccessPlans()` returns `{ plans, loading, error, purchaseAvailable, purchase, cancel, refresh }`; each plan is `{ id, name, price_cents, currency, billing, owned, renews, access_ends_at }`. `cancel(planId)` stops a held monthly plan renewing; access continues until `access_ends_at`. `purchase(planId)` sends only the plan id: the server sets the price and the group, so a widget can never name an amount. The host opens hosted Checkout as it does for `usePayments()`, and access arrives when the payment is confirmed — call `refresh()` after the user returns. Page access is a digital good, so `purchaseAvailable` is `false` in an iOS or Android build; render a visible "not available in this app" state there. Needs the `payments.charge:appUser` scope. The injected payments client gains `listAccessPlans()`, `purchaseAccessPlan()` and `cancelAccessPlan()` (`@colixsystems/payments-client` 0.3.0).
100
+
101
+ ### What's new in 0.279.0 (contract 1.228.0)
102
+
103
+ **`requestPayment` says what it sells.** `usePayments().requestPayment` now takes a required `kind`: `"physical"` for goods shipped or handed over, `"service"` for a real-world service such as a booking or a ticket, and `"digital"` for content or features used inside the app. The server refuses a charge without one (`CHARGE_KIND_REQUIRED`) and a kind the workspace has not been opened for (`CHARGE_KIND_NOT_ENABLED`). App stores require their own billing for digital goods, so a `"digital"` charge from an iOS or Android build is refused with `DIGITAL_SALE_NEEDS_STORE_BILLING`. All three are non-retryable `PaymentError` codes that carry a message to show. This is a breaking change for a widget that charges without `kind`, so it ships as a minor version.
91
104
 
92
105
  ### What's new in 0.278.0 (contract 1.227.0)
93
106
 
package/dist/contract.cjs CHANGED
@@ -2330,13 +2330,37 @@ const HOOKS = [
2330
2330
  signature: "usePayments()",
2331
2331
  returnShape: {
2332
2332
  requestPayment:
2333
- "({ amount_cents, currency?, description, return_path? }) => Promise<{ id, status }> // host opens hosted Checkout; rejects with PaymentError { code, message, retryable } — render the message when retryable is false, never \"try again\"",
2333
+ "({ amount_cents, currency?, description, kind, return_path? }) => Promise<{ id, status }> // kind is REQUIRED: \"physical\" | \"service\" | \"digital\" — what is sold; a digital charge is refused in an iOS/Android build (DIGITAL_SALE_NEEDS_STORE_BILLING); host opens hosted Checkout; rejects with PaymentError { code, message, retryable } — render the message when retryable is false, never \"try again\"",
2334
2334
  getPayment:
2335
2335
  "(paymentId) => Promise<{ id, status, amount_cents, currency, description }>",
2336
2336
  },
2337
2337
  requiredContextSlice: ["payments.requestPayment"],
2338
2338
  scopes: ["payments.charge:appUser"],
2339
2339
  },
2340
+ // sc-15167 — paid access to restricted pages.
2341
+ {
2342
+ name: "useAccessPlans",
2343
+ signature: "useAccessPlans()",
2344
+ returnShape: {
2345
+ plans:
2346
+ "Array<{ id, name, price_cents, currency, billing: \"one_time\" | \"monthly\", owned, renews, access_ends_at, purchase_available }> // owned: the user holds it; renews: a held monthly plan will charge again; access_ends_at: end of held access without a renewal (null for one-time); purchase_available: this plan can be bought here — false in an iOS/Android build for a plan the workspace does not sell in-app",
2347
+ loading: "boolean",
2348
+ error: "PaymentError | null",
2349
+ purchaseAvailable:
2350
+ "boolean // false when no plan can be bought here (an iOS/Android build where the workspace sells no plan in-app) — render a visible not-available state, never a dead button or a blank; check each plan's purchase_available too",
2351
+ purchase:
2352
+ "(planId, { return_path? }?) => Promise<{ plan_id, status: \"ACTIVE\" | \"PENDING\" }> // pass ONLY the plan id; the server sets price and group; the host opens hosted Checkout on the web and the store's in-app purchase sheet in an iOS/Android build; access arrives with the payment confirmation, so refresh() a moment later",
2353
+ cancel:
2354
+ "(planId) => Promise<{ plan_id, renews: false, access_ends_at }> // stop a renewing monthly plan; access runs to access_ends_at — offer it beside every plan with renews: true",
2355
+ refresh: "() => Promise<void> // re-read after returning from Checkout",
2356
+ },
2357
+ requiredContextSlice: [
2358
+ "payments.listAccessPlans",
2359
+ "payments.purchaseAccessPlan",
2360
+ "payments.cancelAccessPlan",
2361
+ ],
2362
+ scopes: ["payments.charge:appUser"],
2363
+ },
2340
2364
  // sc-4686 — the workspace's charge currency + the only sanctioned way to
2341
2365
  // render money. A widget must NOT name a currency: the owner can change it
2342
2366
  // after the widget was authored, so a baked symbol goes stale over a charge
@@ -3147,7 +3171,9 @@ const HOOKS = [
3147
3171
  "the buyer closed the sheet, so do not show it as a failure. A purchase needs a signed-in app user. NATIVE-ONLY: a " +
3148
3172
  "browser has no App Store or Google Play billing, so the web Player (and the Studio canvas) report supported:false " +
3149
3173
  "— ALWAYS render a visible 'available in the app' state there, never a blank or a dead button. The Expo export also " +
3150
- "reports supported:false until the workspace connects RevenueCat in Payments and rebuilds the app.",
3174
+ "reports supported:false until the workspace connects RevenueCat in Payments and rebuilds the app. On the web, sell " +
3175
+ "page access with useAccessPlans() instead: a web purchase of a plan (or of paid-app access) also grants its " +
3176
+ "RevenueCat entitlement, so `entitlements` in the app includes what the user bought on the web.",
3151
3177
  returnShape: {
3152
3178
  supported: "boolean // false on the web Player and in an app built without RevenueCat",
3153
3179
  loading: "boolean",
@@ -4380,9 +4406,15 @@ const WIDGET_CONTEXT_SHAPE = {
4380
4406
  },
4381
4407
  payments: {
4382
4408
  description:
4383
- "Injected @colixsystems/payments-client instance (REQ-BILL-07-WIDGETPAY). { requestPayment(body) -> Promise<{ id, status }>, getPayment(id) -> Promise<payment> }; wire is snake_case (amount_cents, return_path). Backs usePayments(); requires the payments.charge:appUser scope. The host opens hosted Checkout itself — same-tab on web, in-app browser on native (or auto-confirms under the mock provider); the charge settles to the workspace owner.",
4409
+ "Injected @colixsystems/payments-client instance (REQ-BILL-07-WIDGETPAY). { requestPayment(body) -> Promise<{ id, status }>, getPayment(id) -> Promise<payment>, listAccessPlans() -> Promise<{ data, meta }>, purchaseAccessPlan({ plan_id, return_path? }) -> Promise<{ plan_id, status }>, cancelAccessPlan(planId) -> Promise<{ plan_id, renews, access_ends_at }> }; wire is snake_case (amount_cents, return_path). Backs usePayments() and useAccessPlans(); requires the payments.charge:appUser scope. The host opens hosted Checkout itself — same-tab on web, in-app browser on native (or auto-confirms under the mock provider); the charge settles to the workspace owner.",
4384
4410
  required: true,
4385
- fields: { requestPayment: "function", getPayment: "function" },
4411
+ fields: {
4412
+ requestPayment: "function",
4413
+ getPayment: "function",
4414
+ listAccessPlans: "function",
4415
+ purchaseAccessPlan: "function",
4416
+ cancelAccessPlan: "function",
4417
+ },
4386
4418
  },
4387
4419
  // sc-890 — backs useSendNotification().
4388
4420
  notifications: {
@@ -6628,7 +6660,14 @@ const CONTRACT = deepFreeze({
6628
6660
  // 1.227.0: additive (sc-15163) — new native-only `usePurchases()` hook and
6629
6661
  // `PurchasesError`, a `device.purchases` slice, and the vetted
6630
6662
  // `react-native-purchases` import.
6631
- version: "1.227.0",
6663
+ // 1.228.0: stricter (sc-15161) — `usePayments().requestPayment` requires
6664
+ // `kind` (physical | service | digital); three new PaymentError codes.
6665
+ // 1.229.0: additive (sc-15167) — `useAccessPlans()`, and the payments slice
6666
+ // gains `listAccessPlans` / `purchaseAccessPlan` / `cancelAccessPlan`.
6667
+ // 1.230.0: additive (sc-15164) — each access plan carries
6668
+ // `purchase_available`; a store build buys a plan the workspace sells
6669
+ // in-app through the store, and web purchases grant RevenueCat entitlements.
6670
+ version: "1.230.0",
6632
6671
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6633
6672
  hooks: HOOKS,
6634
6673
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -2326,13 +2326,37 @@ const HOOKS = [
2326
2326
  signature: "usePayments()",
2327
2327
  returnShape: {
2328
2328
  requestPayment:
2329
- "({ amount_cents, currency?, description, return_path? }) => Promise<{ id, status }> // host opens hosted Checkout; rejects with PaymentError { code, message, retryable } — render the message when retryable is false, never \"try again\"",
2329
+ "({ amount_cents, currency?, description, kind, return_path? }) => Promise<{ id, status }> // kind is REQUIRED: \"physical\" | \"service\" | \"digital\" — what is sold; a digital charge is refused in an iOS/Android build (DIGITAL_SALE_NEEDS_STORE_BILLING); host opens hosted Checkout; rejects with PaymentError { code, message, retryable } — render the message when retryable is false, never \"try again\"",
2330
2330
  getPayment:
2331
2331
  "(paymentId) => Promise<{ id, status, amount_cents, currency, description }>",
2332
2332
  },
2333
2333
  requiredContextSlice: ["payments.requestPayment"],
2334
2334
  scopes: ["payments.charge:appUser"],
2335
2335
  },
2336
+ // sc-15167 — paid access to restricted pages.
2337
+ {
2338
+ name: "useAccessPlans",
2339
+ signature: "useAccessPlans()",
2340
+ returnShape: {
2341
+ plans:
2342
+ "Array<{ id, name, price_cents, currency, billing: \"one_time\" | \"monthly\", owned, renews, access_ends_at, purchase_available }> // owned: the user holds it; renews: a held monthly plan will charge again; access_ends_at: end of held access without a renewal (null for one-time); purchase_available: this plan can be bought here — false in an iOS/Android build for a plan the workspace does not sell in-app",
2343
+ loading: "boolean",
2344
+ error: "PaymentError | null",
2345
+ purchaseAvailable:
2346
+ "boolean // false when no plan can be bought here (an iOS/Android build where the workspace sells no plan in-app) — render a visible not-available state, never a dead button or a blank; check each plan's purchase_available too",
2347
+ purchase:
2348
+ "(planId, { return_path? }?) => Promise<{ plan_id, status: \"ACTIVE\" | \"PENDING\" }> // pass ONLY the plan id; the server sets price and group; the host opens hosted Checkout on the web and the store's in-app purchase sheet in an iOS/Android build; access arrives with the payment confirmation, so refresh() a moment later",
2349
+ cancel:
2350
+ "(planId) => Promise<{ plan_id, renews: false, access_ends_at }> // stop a renewing monthly plan; access runs to access_ends_at — offer it beside every plan with renews: true",
2351
+ refresh: "() => Promise<void> // re-read after returning from Checkout",
2352
+ },
2353
+ requiredContextSlice: [
2354
+ "payments.listAccessPlans",
2355
+ "payments.purchaseAccessPlan",
2356
+ "payments.cancelAccessPlan",
2357
+ ],
2358
+ scopes: ["payments.charge:appUser"],
2359
+ },
2336
2360
  // sc-4686 — the workspace's charge currency + the only sanctioned way to
2337
2361
  // render money. A widget must NOT name a currency: the owner can change it
2338
2362
  // after the widget was authored, so a baked symbol goes stale over a charge
@@ -3143,7 +3167,9 @@ const HOOKS = [
3143
3167
  "the buyer closed the sheet, so do not show it as a failure. A purchase needs a signed-in app user. NATIVE-ONLY: a " +
3144
3168
  "browser has no App Store or Google Play billing, so the web Player (and the Studio canvas) report supported:false " +
3145
3169
  "— ALWAYS render a visible 'available in the app' state there, never a blank or a dead button. The Expo export also " +
3146
- "reports supported:false until the workspace connects RevenueCat in Payments and rebuilds the app.",
3170
+ "reports supported:false until the workspace connects RevenueCat in Payments and rebuilds the app. On the web, sell " +
3171
+ "page access with useAccessPlans() instead: a web purchase of a plan (or of paid-app access) also grants its " +
3172
+ "RevenueCat entitlement, so `entitlements` in the app includes what the user bought on the web.",
3147
3173
  returnShape: {
3148
3174
  supported: "boolean // false on the web Player and in an app built without RevenueCat",
3149
3175
  loading: "boolean",
@@ -4376,9 +4402,15 @@ const WIDGET_CONTEXT_SHAPE = {
4376
4402
  },
4377
4403
  payments: {
4378
4404
  description:
4379
- "Injected @colixsystems/payments-client instance (REQ-BILL-07-WIDGETPAY). { requestPayment(body) -> Promise<{ id, status }>, getPayment(id) -> Promise<payment> }; wire is snake_case (amount_cents, return_path). Backs usePayments(); requires the payments.charge:appUser scope. The host opens hosted Checkout itself — same-tab on web, in-app browser on native (or auto-confirms under the mock provider); the charge settles to the workspace owner.",
4405
+ "Injected @colixsystems/payments-client instance (REQ-BILL-07-WIDGETPAY). { requestPayment(body) -> Promise<{ id, status }>, getPayment(id) -> Promise<payment>, listAccessPlans() -> Promise<{ data, meta }>, purchaseAccessPlan({ plan_id, return_path? }) -> Promise<{ plan_id, status }>, cancelAccessPlan(planId) -> Promise<{ plan_id, renews, access_ends_at }> }; wire is snake_case (amount_cents, return_path). Backs usePayments() and useAccessPlans(); requires the payments.charge:appUser scope. The host opens hosted Checkout itself — same-tab on web, in-app browser on native (or auto-confirms under the mock provider); the charge settles to the workspace owner.",
4380
4406
  required: true,
4381
- fields: { requestPayment: "function", getPayment: "function" },
4407
+ fields: {
4408
+ requestPayment: "function",
4409
+ getPayment: "function",
4410
+ listAccessPlans: "function",
4411
+ purchaseAccessPlan: "function",
4412
+ cancelAccessPlan: "function",
4413
+ },
4382
4414
  },
4383
4415
  // sc-890 — backs useSendNotification().
4384
4416
  notifications: {
@@ -6624,7 +6656,14 @@ const CONTRACT = deepFreeze({
6624
6656
  // 1.227.0: additive (sc-15163) — new native-only `usePurchases()` hook and
6625
6657
  // `PurchasesError`, a `device.purchases` slice, and the vetted
6626
6658
  // `react-native-purchases` import.
6627
- version: "1.227.0",
6659
+ // 1.228.0: stricter (sc-15161) — `usePayments().requestPayment` requires
6660
+ // `kind` (physical | service | digital); three new PaymentError codes.
6661
+ // 1.229.0: additive (sc-15167) — `useAccessPlans()`, and the payments slice
6662
+ // gains `listAccessPlans` / `purchaseAccessPlan` / `cancelAccessPlan`.
6663
+ // 1.230.0: additive (sc-15164) — each access plan carries
6664
+ // `purchase_available`; a store build buys a plan the workspace sells
6665
+ // in-app through the store, and web purchases grant RevenueCat entitlements.
6666
+ version: "1.230.0",
6628
6667
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6629
6668
  hooks: HOOKS,
6630
6669
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -6886,7 +6886,10 @@ const NON_RETRYABLE_PAYMENT_CODES = new Set([
6886
6886
  "AUTH_REQUIRED",
6887
6887
  "AMOUNT_TOO_LARGE",
6888
6888
  "BUSINESS_IDENTITY_REQUIRED",
6889
+ "CHARGE_KIND_NOT_ENABLED",
6890
+ "CHARGE_KIND_REQUIRED",
6889
6891
  "CONNECT_NOT_READY",
6892
+ "DIGITAL_SALE_NEEDS_STORE_BILLING",
6890
6893
  "FORBIDDEN",
6891
6894
  "INVALID_AMOUNT",
6892
6895
  "NOT_FOUND",
@@ -6912,6 +6915,11 @@ const NON_RETRYABLE_PAYMENT_CODES = new Set([
6912
6915
  * - "PAYMENTS_UNAVAILABLE" — no installed widget / no payments host
6913
6916
  * - "UNSUPPORTED_CURRENCY" — not the platform's charge currency
6914
6917
  * - "INVALID_AMOUNT" / "AMOUNT_TOO_LARGE" / "VALIDATION" — bad request
6918
+ * - "CHARGE_KIND_REQUIRED" — `kind` missing or not physical / service
6919
+ * / digital
6920
+ * - "CHARGE_KIND_NOT_ENABLED" — this workspace cannot sell that kind yet
6921
+ * - "DIGITAL_SALE_NEEDS_STORE_BILLING" — a digital sale from an iOS / Android
6922
+ * build: store policy requires store billing
6915
6923
  * - "CONNECT_NOT_READY" / "PAYMENTS_DISABLED" — provider not ready
6916
6924
  * - "DECLINED" — the charge was declined
6917
6925
  * - "INTERNAL" — anything else
@@ -6972,8 +6980,13 @@ function toPaymentError(err) {
6972
6980
  * `{ requestPayment, getPayment }`. The wire is snake_case (REQ-GEN-09) —
6973
6981
  * pass and read snake_case keys VERBATIM.
6974
6982
  *
6975
- * requestPayment({ amount_cents, currency?, description, return_path? })
6976
- * → Promise<{ id, status, ... }>. When a hosted-checkout provider
6983
+ * requestPayment({ amount_cents, currency?, description, kind, return_path? })
6984
+ * → Promise<{ id, status, ... }>. `kind` is REQUIRED and says what is
6985
+ * sold: "physical" (goods shipped or handed over), "service" (a
6986
+ * real-world service: a booking, a ticket, a delivery) or "digital"
6987
+ * (content or features used inside the app). A digital charge is refused
6988
+ * in an iOS / Android build with DIGITAL_SALE_NEEDS_STORE_BILLING — show
6989
+ * the error's message as a visible state, never a blank render. When a hosted-checkout provider
6977
6990
  * (Mollie) is active the HOST opens Checkout for you — a same-tab
6978
6991
  * redirect on web, the in-app browser on native — so you do NOT open
6979
6992
  * anything yourself (no `Linking.openURL`, no `checkout_url` to
@@ -7055,6 +7068,129 @@ export function usePayments() {
7055
7068
  return { requestPayment, getPayment };
7056
7069
  }
7057
7070
 
7071
+ /**
7072
+ * Paid access to restricted pages (sc-15167). A workspace sells plans; buying
7073
+ * one adds the signed-in app user to a group the maker restricted pages to, so
7074
+ * those pages open for them. Returns
7075
+ * `{ plans, loading, error, purchaseAvailable, purchase, cancel, refresh }`:
7076
+ *
7077
+ * plans → [{ id, name, price_cents, currency, billing, owned, renews,
7078
+ * access_ends_at, purchase_available }] — `billing` is "one_time" or
7079
+ * "monthly"; `owned` is true once the user holds the plan; `renews` while
7080
+ * a held monthly plan will charge again; `access_ends_at` is when held
7081
+ * access ends without a renewal (null for one-time access);
7082
+ * `purchase_available` is whether this plan can be bought here.
7083
+ * Render the price with `useWorkspaceCurrency().formatMoney(price_cents)`.
7084
+ * purchaseAvailable → false where no plan can be bought here: page access is
7085
+ * a digital good, and an iOS / Android build sells only the plans the
7086
+ * workspace sells in-app (through store billing). Render a visible "not
7087
+ * available in this app" state for a plan whose `purchase_available` is
7088
+ * false — never a dead button and never a blank render.
7089
+ * purchase(planId, { return_path? }?) → Promise<{ plan_id, status }>. Pass
7090
+ * ONLY the plan id: the server sets the price and the group, so a widget can
7091
+ * never name an amount. The HOST takes the payment: hosted Checkout on the
7092
+ * web, the store's in-app purchase sheet in an iOS / Android build. `status`
7093
+ * is "ACTIVE" when access was granted at once (the mock provider) and
7094
+ * "PENDING" while payment is confirmed; access arrives with the
7095
+ * confirmation, never from the client.
7096
+ * cancel(planId) → Promise<{ plan_id, renews: false, access_ends_at }> —
7097
+ * stop a held monthly plan renewing (`renews` true). Access continues
7098
+ * until `access_ends_at`; offer it beside every renewing plan.
7099
+ * refresh() → re-read the plans. Call it when the user returns from Checkout
7100
+ * (`useRouteParams().payment === "success"`) and poll a few times, since the
7101
+ * confirmation may land a moment after the redirect.
7102
+ *
7103
+ * `error` and `purchase` rejections are a `PaymentError` — show its `message`
7104
+ * when `retryable` is false. Requires the `payments.charge:appUser` scope.
7105
+ */
7106
+ export function useAccessPlans() {
7107
+ const ctx = useWidgetContextOrThrow("useAccessPlans");
7108
+ const paymentsRef = useRef(ctx.payments);
7109
+ paymentsRef.current = ctx.payments;
7110
+ const supported = Boolean(
7111
+ ctx.payments && typeof ctx.payments.listAccessPlans === "function",
7112
+ );
7113
+ const [state, setState] = useState(() => ({
7114
+ plans: [],
7115
+ loading: supported,
7116
+ error: supported
7117
+ ? null
7118
+ : new PaymentError(
7119
+ "PAYMENTS_UNAVAILABLE",
7120
+ "Plans are only available for an installed widget running in the app.",
7121
+ ),
7122
+ purchaseAvailable: false,
7123
+ }));
7124
+
7125
+ const refresh = useCallback(async () => {
7126
+ const payments = paymentsRef.current;
7127
+ if (!payments || typeof payments.listAccessPlans !== "function") return;
7128
+ setState((prev) => ({ ...prev, loading: true }));
7129
+ try {
7130
+ const res = await payments.listAccessPlans();
7131
+ setState({
7132
+ plans: Array.isArray(res && res.data) ? res.data : [],
7133
+ loading: false,
7134
+ error: null,
7135
+ purchaseAvailable: Boolean(res && res.meta && res.meta.purchase_available),
7136
+ });
7137
+ } catch (err) {
7138
+ setState((prev) => ({ ...prev, loading: false, error: toPaymentError(err) }));
7139
+ }
7140
+ }, []);
7141
+
7142
+ useEffect(() => {
7143
+ if (supported) refresh();
7144
+ }, [supported, refresh]);
7145
+
7146
+ const purchase = useCallback(
7147
+ async (planId, opts) => {
7148
+ const payments = paymentsRef.current;
7149
+ if (!payments || typeof payments.purchaseAccessPlan !== "function") {
7150
+ throw new PaymentError(
7151
+ "PAYMENTS_UNAVAILABLE",
7152
+ "Plans are only available for an installed widget running in the app.",
7153
+ );
7154
+ }
7155
+ let res;
7156
+ try {
7157
+ res = await payments.purchaseAccessPlan({
7158
+ plan_id: planId,
7159
+ return_path: opts && opts.return_path,
7160
+ });
7161
+ } catch (err) {
7162
+ throw toPaymentError(err);
7163
+ }
7164
+ if (res && res.status === "ACTIVE") await refresh();
7165
+ return res;
7166
+ },
7167
+ [refresh],
7168
+ );
7169
+
7170
+ const cancel = useCallback(
7171
+ async (planId) => {
7172
+ const payments = paymentsRef.current;
7173
+ if (!payments || typeof payments.cancelAccessPlan !== "function") {
7174
+ throw new PaymentError(
7175
+ "PAYMENTS_UNAVAILABLE",
7176
+ "Plans are only available for an installed widget running in the app.",
7177
+ );
7178
+ }
7179
+ let res;
7180
+ try {
7181
+ res = await payments.cancelAccessPlan(planId);
7182
+ } catch (err) {
7183
+ throw toPaymentError(err);
7184
+ }
7185
+ await refresh();
7186
+ return res;
7187
+ },
7188
+ [refresh],
7189
+ );
7190
+
7191
+ return { ...state, purchase, cancel, refresh };
7192
+ }
7193
+
7058
7194
  /* ============================================================================
7059
7195
  * NOTIFICATIONS CLIENT — ctx.notifications (@colixsystems/notifications-client)
7060
7196
  *
package/dist/index.d.ts CHANGED
@@ -697,6 +697,14 @@ export interface AssetsClient {
697
697
  export interface PaymentsClient {
698
698
  requestPayment(body: PaymentRequest): Promise<PaymentResult>;
699
699
  getPayment(paymentId: string): Promise<PaymentResult>;
700
+ listAccessPlans(): Promise<{
701
+ data: AccessPlan[];
702
+ meta: { total: number; limit: number; offset: number; purchase_available: boolean };
703
+ }>;
704
+ purchaseAccessPlan(body: { plan_id: string; return_path?: string }): Promise<AccessPlanPurchase>;
705
+ cancelAccessPlan(
706
+ planId: string,
707
+ ): Promise<{ plan_id: string; renews: false; access_ends_at: string | null }>;
700
708
  }
701
709
 
702
710
  /**
@@ -1465,6 +1473,14 @@ export interface PaymentRequest {
1465
1473
  amount_cents: number;
1466
1474
  currency?: string;
1467
1475
  description: string;
1476
+ /**
1477
+ * What the charge sells. Required. "physical": goods shipped or handed over;
1478
+ * "service": a real-world service such as a booking or a ticket; "digital":
1479
+ * content or features used inside the app. App stores require their own
1480
+ * billing for digital goods, so a "digital" charge from an iOS or Android
1481
+ * build is refused with DIGITAL_SALE_NEEDS_STORE_BILLING.
1482
+ */
1483
+ kind: "physical" | "service" | "digital";
1468
1484
  /**
1469
1485
  * Site-relative path to return to after Checkout (e.g. "/cart"). Defaults
1470
1486
  * to the current page, so the user lands back where they started.
@@ -1478,6 +1494,7 @@ export interface PaymentResult {
1478
1494
  amount_cents?: number;
1479
1495
  currency?: string;
1480
1496
  description?: string;
1497
+ kind?: "physical" | "service" | "digital" | null;
1481
1498
  }
1482
1499
 
1483
1500
  export interface PaymentsApi {
@@ -1496,6 +1513,53 @@ export interface PaymentsApi {
1496
1513
  */
1497
1514
  export function usePayments(): PaymentsApi;
1498
1515
 
1516
+ /** A plan selling access to restricted pages, as the app user sees it. */
1517
+ export interface AccessPlan {
1518
+ id: string;
1519
+ name: string;
1520
+ /** In the currency's minor unit. */
1521
+ price_cents: number;
1522
+ currency: string;
1523
+ billing: "one_time" | "monthly";
1524
+ /** True once the signed-in user holds the plan. */
1525
+ owned: boolean;
1526
+ /** A held monthly plan that will charge again; false once cancelled. */
1527
+ renews: boolean;
1528
+ /** ISO time held access ends without a renewal; null for one-time access. */
1529
+ access_ends_at: string | null;
1530
+ /** Whether this plan can be bought here (false in a store build that does not sell it in-app). */
1531
+ purchase_available: boolean;
1532
+ }
1533
+
1534
+ export interface AccessPlanPurchase {
1535
+ plan_id: string;
1536
+ /** ACTIVE: granted at once. PENDING: waiting for the payment to confirm. */
1537
+ status: "ACTIVE" | "PENDING";
1538
+ }
1539
+
1540
+ export interface AccessPlansApi {
1541
+ plans: AccessPlan[];
1542
+ loading: boolean;
1543
+ error: PaymentError | null;
1544
+ /**
1545
+ * False where no plan can be bought here (an iOS / Android build sells only
1546
+ * the plans the workspace sells in-app): render a visible not-available state.
1547
+ */
1548
+ purchaseAvailable: boolean;
1549
+ /** Pass only the plan id; the server sets price and group. */
1550
+ purchase(planId: string, opts?: { return_path?: string }): Promise<AccessPlanPurchase>;
1551
+ /** Stop a held monthly plan renewing; access runs to `access_ends_at`. */
1552
+ cancel(planId: string): Promise<{ plan_id: string; renews: false; access_ends_at: string | null }>;
1553
+ refresh(): Promise<void>;
1554
+ }
1555
+
1556
+ /**
1557
+ * Paid access to restricted pages: list the workspace's plans and buy one.
1558
+ * Buying adds the user to the group the maker restricted pages to, once the
1559
+ * payment is confirmed. Requires the `payments.charge:appUser` scope.
1560
+ */
1561
+ export function useAccessPlans(): AccessPlansApi;
1562
+
1499
1563
  /**
1500
1564
  * Rejection thrown by both `usePayments()` callbacks. `code` is the server's
1501
1565
  * own reason when it sent one; `message` is its user-safe sentence. Branch on
@@ -1514,6 +1578,9 @@ export class PaymentError extends Error {
1514
1578
  | "AMOUNT_TOO_LARGE"
1515
1579
  | "VALIDATION"
1516
1580
  | "CONNECT_NOT_READY"
1581
+ | "CHARGE_KIND_REQUIRED"
1582
+ | "CHARGE_KIND_NOT_ENABLED"
1583
+ | "DIGITAL_SALE_NEEDS_STORE_BILLING"
1517
1584
  | "PAYMENTS_DISABLED"
1518
1585
  | "DECLINED"
1519
1586
  | "FORBIDDEN"
package/dist/index.js CHANGED
@@ -67,6 +67,7 @@ export {
67
67
  useWidgetEvent,
68
68
  useWidgetInput,
69
69
  usePayments,
70
+ useAccessPlans,
70
71
  useWorkspaceCurrency,
71
72
  useSendNotification,
72
73
  useRecordMute,
@@ -67,6 +67,7 @@ export {
67
67
  useWidgetEvent,
68
68
  useWidgetInput,
69
69
  usePayments,
70
+ useAccessPlans,
70
71
  useWorkspaceCurrency,
71
72
  useSendNotification,
72
73
  useRecordMute,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.278.0",
3
+ "version": "0.281.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "homepage": "https://github.com/Colix-AB/AppStudio",
6
6
  "type": "module",
package/src/contract.js CHANGED
@@ -2326,13 +2326,37 @@ const HOOKS = [
2326
2326
  signature: "usePayments()",
2327
2327
  returnShape: {
2328
2328
  requestPayment:
2329
- "({ amount_cents, currency?, description, return_path? }) => Promise<{ id, status }> // host opens hosted Checkout; rejects with PaymentError { code, message, retryable } — render the message when retryable is false, never \"try again\"",
2329
+ "({ amount_cents, currency?, description, kind, return_path? }) => Promise<{ id, status }> // kind is REQUIRED: \"physical\" | \"service\" | \"digital\" — what is sold; a digital charge is refused in an iOS/Android build (DIGITAL_SALE_NEEDS_STORE_BILLING); host opens hosted Checkout; rejects with PaymentError { code, message, retryable } — render the message when retryable is false, never \"try again\"",
2330
2330
  getPayment:
2331
2331
  "(paymentId) => Promise<{ id, status, amount_cents, currency, description }>",
2332
2332
  },
2333
2333
  requiredContextSlice: ["payments.requestPayment"],
2334
2334
  scopes: ["payments.charge:appUser"],
2335
2335
  },
2336
+ // sc-15167 — paid access to restricted pages.
2337
+ {
2338
+ name: "useAccessPlans",
2339
+ signature: "useAccessPlans()",
2340
+ returnShape: {
2341
+ plans:
2342
+ "Array<{ id, name, price_cents, currency, billing: \"one_time\" | \"monthly\", owned, renews, access_ends_at, purchase_available }> // owned: the user holds it; renews: a held monthly plan will charge again; access_ends_at: end of held access without a renewal (null for one-time); purchase_available: this plan can be bought here — false in an iOS/Android build for a plan the workspace does not sell in-app",
2343
+ loading: "boolean",
2344
+ error: "PaymentError | null",
2345
+ purchaseAvailable:
2346
+ "boolean // false when no plan can be bought here (an iOS/Android build where the workspace sells no plan in-app) — render a visible not-available state, never a dead button or a blank; check each plan's purchase_available too",
2347
+ purchase:
2348
+ "(planId, { return_path? }?) => Promise<{ plan_id, status: \"ACTIVE\" | \"PENDING\" }> // pass ONLY the plan id; the server sets price and group; the host opens hosted Checkout on the web and the store's in-app purchase sheet in an iOS/Android build; access arrives with the payment confirmation, so refresh() a moment later",
2349
+ cancel:
2350
+ "(planId) => Promise<{ plan_id, renews: false, access_ends_at }> // stop a renewing monthly plan; access runs to access_ends_at — offer it beside every plan with renews: true",
2351
+ refresh: "() => Promise<void> // re-read after returning from Checkout",
2352
+ },
2353
+ requiredContextSlice: [
2354
+ "payments.listAccessPlans",
2355
+ "payments.purchaseAccessPlan",
2356
+ "payments.cancelAccessPlan",
2357
+ ],
2358
+ scopes: ["payments.charge:appUser"],
2359
+ },
2336
2360
  // sc-4686 — the workspace's charge currency + the only sanctioned way to
2337
2361
  // render money. A widget must NOT name a currency: the owner can change it
2338
2362
  // after the widget was authored, so a baked symbol goes stale over a charge
@@ -3143,7 +3167,9 @@ const HOOKS = [
3143
3167
  "the buyer closed the sheet, so do not show it as a failure. A purchase needs a signed-in app user. NATIVE-ONLY: a " +
3144
3168
  "browser has no App Store or Google Play billing, so the web Player (and the Studio canvas) report supported:false " +
3145
3169
  "— ALWAYS render a visible 'available in the app' state there, never a blank or a dead button. The Expo export also " +
3146
- "reports supported:false until the workspace connects RevenueCat in Payments and rebuilds the app.",
3170
+ "reports supported:false until the workspace connects RevenueCat in Payments and rebuilds the app. On the web, sell " +
3171
+ "page access with useAccessPlans() instead: a web purchase of a plan (or of paid-app access) also grants its " +
3172
+ "RevenueCat entitlement, so `entitlements` in the app includes what the user bought on the web.",
3147
3173
  returnShape: {
3148
3174
  supported: "boolean // false on the web Player and in an app built without RevenueCat",
3149
3175
  loading: "boolean",
@@ -4376,9 +4402,15 @@ const WIDGET_CONTEXT_SHAPE = {
4376
4402
  },
4377
4403
  payments: {
4378
4404
  description:
4379
- "Injected @colixsystems/payments-client instance (REQ-BILL-07-WIDGETPAY). { requestPayment(body) -> Promise<{ id, status }>, getPayment(id) -> Promise<payment> }; wire is snake_case (amount_cents, return_path). Backs usePayments(); requires the payments.charge:appUser scope. The host opens hosted Checkout itself — same-tab on web, in-app browser on native (or auto-confirms under the mock provider); the charge settles to the workspace owner.",
4405
+ "Injected @colixsystems/payments-client instance (REQ-BILL-07-WIDGETPAY). { requestPayment(body) -> Promise<{ id, status }>, getPayment(id) -> Promise<payment>, listAccessPlans() -> Promise<{ data, meta }>, purchaseAccessPlan({ plan_id, return_path? }) -> Promise<{ plan_id, status }>, cancelAccessPlan(planId) -> Promise<{ plan_id, renews, access_ends_at }> }; wire is snake_case (amount_cents, return_path). Backs usePayments() and useAccessPlans(); requires the payments.charge:appUser scope. The host opens hosted Checkout itself — same-tab on web, in-app browser on native (or auto-confirms under the mock provider); the charge settles to the workspace owner.",
4380
4406
  required: true,
4381
- fields: { requestPayment: "function", getPayment: "function" },
4407
+ fields: {
4408
+ requestPayment: "function",
4409
+ getPayment: "function",
4410
+ listAccessPlans: "function",
4411
+ purchaseAccessPlan: "function",
4412
+ cancelAccessPlan: "function",
4413
+ },
4382
4414
  },
4383
4415
  // sc-890 — backs useSendNotification().
4384
4416
  notifications: {
@@ -6624,7 +6656,14 @@ const CONTRACT = deepFreeze({
6624
6656
  // 1.227.0: additive (sc-15163) — new native-only `usePurchases()` hook and
6625
6657
  // `PurchasesError`, a `device.purchases` slice, and the vetted
6626
6658
  // `react-native-purchases` import.
6627
- version: "1.227.0",
6659
+ // 1.228.0: stricter (sc-15161) — `usePayments().requestPayment` requires
6660
+ // `kind` (physical | service | digital); three new PaymentError codes.
6661
+ // 1.229.0: additive (sc-15167) — `useAccessPlans()`, and the payments slice
6662
+ // gains `listAccessPlans` / `purchaseAccessPlan` / `cancelAccessPlan`.
6663
+ // 1.230.0: additive (sc-15164) — each access plan carries
6664
+ // `purchase_available`; a store build buys a plan the workspace sells
6665
+ // in-app through the store, and web purchases grant RevenueCat entitlements.
6666
+ version: "1.230.0",
6628
6667
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6629
6668
  hooks: HOOKS,
6630
6669
  primitives: PRIMITIVES,