@eifi1/ui-kit 0.51.6 → 0.51.7

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.
Files changed (124) hide show
  1. package/README.md +5 -3
  2. package/dist/admin/admin-action-confirm.js +2 -4
  3. package/dist/admin/admin-action-confirm.js.map +1 -1
  4. package/dist/auth/complete-name-dialog.d.ts +3 -2
  5. package/dist/auth/complete-name-dialog.js +1 -4
  6. package/dist/auth/complete-name-dialog.js.map +1 -1
  7. package/dist/billing/cancel-request.d.ts +11 -2
  8. package/dist/billing/cancel-request.js +15 -3
  9. package/dist/billing/cancel-request.js.map +1 -1
  10. package/dist/billing/cancel-subscription.d.ts +2 -2
  11. package/dist/billing/cancel-subscription.js +2 -7
  12. package/dist/billing/cancel-subscription.js.map +1 -1
  13. package/dist/billing/plan-switch.d.ts +2 -2
  14. package/dist/billing/plan-switch.js +2 -4
  15. package/dist/billing/plan-switch.js.map +1 -1
  16. package/dist/components/amount-input.d.ts +9 -4
  17. package/dist/components/amount-input.js +2 -3
  18. package/dist/components/amount-input.js.map +1 -1
  19. package/dist/components/calculator.d.ts +21 -2
  20. package/dist/components/calculator.js +53 -14
  21. package/dist/components/calculator.js.map +1 -1
  22. package/dist/components/combobox-core.d.ts +8 -5
  23. package/dist/components/combobox-core.js +3 -4
  24. package/dist/components/combobox-core.js.map +1 -1
  25. package/dist/components/combobox.js +2 -2
  26. package/dist/components/combobox.js.map +1 -1
  27. package/dist/components/data-table-mobile.d.ts +24 -0
  28. package/dist/components/data-table-mobile.js +20 -1
  29. package/dist/components/data-table-mobile.js.map +1 -1
  30. package/dist/components/data-table.d.ts +13 -5
  31. package/dist/components/data-table.js +35 -9
  32. package/dist/components/data-table.js.map +1 -1
  33. package/dist/components/dialog-frame.d.ts +2 -1
  34. package/dist/components/dialog-frame.js.map +1 -1
  35. package/dist/components/entity-combobox.js +4 -2
  36. package/dist/components/entity-combobox.js.map +1 -1
  37. package/dist/components/inline-entity-combobox.js +4 -3
  38. package/dist/components/inline-entity-combobox.js.map +1 -1
  39. package/dist/components/list.js +1 -1
  40. package/dist/components/list.js.map +1 -1
  41. package/dist/components/modal.d.ts +24 -0
  42. package/dist/components/modal.js +9 -3
  43. package/dist/components/modal.js.map +1 -1
  44. package/dist/components/money-draft.js +7 -5
  45. package/dist/components/money-draft.js.map +1 -1
  46. package/dist/components/money-field.d.ts +2 -2
  47. package/dist/components/money-field.js.map +1 -1
  48. package/dist/components/multi-entity-combobox.js +2 -1
  49. package/dist/components/multi-entity-combobox.js.map +1 -1
  50. package/dist/components/number-field.d.ts +6 -4
  51. package/dist/components/number-field.js +3 -2
  52. package/dist/components/number-field.js.map +1 -1
  53. package/dist/components/number-input.d.ts +4 -1
  54. package/dist/components/number-input.js +3 -2
  55. package/dist/components/number-input.js.map +1 -1
  56. package/dist/components/numpad-sheet.js.map +1 -1
  57. package/dist/components/reauth-dialog.d.ts +4 -5
  58. package/dist/components/reauth-dialog.js +1 -4
  59. package/dist/components/reauth-dialog.js.map +1 -1
  60. package/dist/components/series-chart-axes.d.ts +7 -0
  61. package/dist/components/series-chart-axes.js +1 -0
  62. package/dist/components/series-chart-axes.js.map +1 -1
  63. package/dist/components/tabs.js +12 -10
  64. package/dist/components/tabs.js.map +1 -1
  65. package/dist/eslint/index.js +1 -1
  66. package/dist/hooks/use-dismiss.js.map +1 -1
  67. package/dist/hooks/use-overlay-history.d.ts +7 -1
  68. package/dist/hooks/use-overlay-history.js +16 -2
  69. package/dist/hooks/use-overlay-history.js.map +1 -1
  70. package/dist/lib/calc.d.ts +7 -1
  71. package/dist/lib/calc.js +3 -1
  72. package/dist/lib/calc.js.map +1 -1
  73. package/dist/lib/clipboard-minus.d.ts +2 -2
  74. package/dist/lib/clipboard-minus.js +1 -1
  75. package/dist/lib/clipboard-minus.js.map +1 -1
  76. package/dist/lib/decimal-marks.d.ts +7 -1
  77. package/dist/lib/decimal-marks.js +5 -2
  78. package/dist/lib/decimal-marks.js.map +1 -1
  79. package/dist/lib/format.d.ts +5 -3
  80. package/dist/lib/format.js +12 -0
  81. package/dist/lib/format.js.map +1 -1
  82. package/dist/lib/text-match.d.ts +9 -0
  83. package/dist/lib/text-match.js +4 -0
  84. package/dist/lib/text-match.js.map +1 -1
  85. package/dist/tour/tour.d.ts +3 -1
  86. package/dist/tour/tour.js +9 -3
  87. package/dist/tour/tour.js.map +1 -1
  88. package/dist/tour/use-tour-url-sync.js +2 -2
  89. package/dist/tour/use-tour-url-sync.js.map +1 -1
  90. package/package.json +3 -3
  91. package/src/admin/admin-action-confirm.tsx +4 -5
  92. package/src/auth/complete-name-dialog.tsx +5 -7
  93. package/src/billing/cancel-request.ts +28 -6
  94. package/src/billing/cancel-subscription.tsx +4 -11
  95. package/src/billing/plan-switch.tsx +5 -8
  96. package/src/components/amount-input.tsx +14 -7
  97. package/src/components/calculator.tsx +98 -17
  98. package/src/components/combobox-core.tsx +10 -7
  99. package/src/components/combobox.tsx +5 -6
  100. package/src/components/data-table-mobile.tsx +49 -1
  101. package/src/components/data-table.tsx +66 -11
  102. package/src/components/dialog-frame.tsx +2 -1
  103. package/src/components/entity-combobox.tsx +4 -1
  104. package/src/components/inline-entity-combobox.tsx +5 -3
  105. package/src/components/list.tsx +4 -1
  106. package/src/components/modal.tsx +39 -3
  107. package/src/components/money-draft.ts +16 -5
  108. package/src/components/money-field.tsx +2 -2
  109. package/src/components/multi-entity-combobox.tsx +1 -0
  110. package/src/components/number-field.tsx +14 -7
  111. package/src/components/number-input.tsx +9 -3
  112. package/src/components/numpad-sheet.tsx +4 -3
  113. package/src/components/reauth-dialog.tsx +6 -10
  114. package/src/components/series-chart-axes.ts +8 -0
  115. package/src/components/tabs.tsx +33 -17
  116. package/src/hooks/use-dismiss.ts +3 -2
  117. package/src/hooks/use-overlay-history.ts +38 -2
  118. package/src/lib/calc.ts +13 -2
  119. package/src/lib/clipboard-minus.ts +14 -4
  120. package/src/lib/decimal-marks.ts +12 -3
  121. package/src/lib/format.ts +30 -3
  122. package/src/lib/text-match.ts +12 -0
  123. package/src/tour/tour.tsx +21 -4
  124. package/src/tour/use-tour-url-sync.ts +7 -3
package/README.md CHANGED
@@ -68,7 +68,7 @@ when it chooses. To work on the design system and an app together, use
68
68
  | `react`, `react-dom` | peer, `>=19.2` | The kit is components and hooks; two copies of React break hooks and context. 19.2 for `useEffectEvent`. |
69
69
  | `react-router` | peer, `>=7` | Imported unconditionally by `DataTable`, `useTableState`, `AppShell` and `useWizard`. A Router has to be mounted even with `urlSync` off. |
70
70
  | `recharts` | peer, `>=3.8` | The chart kit imports it statically, and the main barrel re-exports the chart kit, so `import { Button } from "@eifi1/ui-kit"` needs it installed too (required since 0.5.0). `@eifi1/ui-kit/chart` is about chunk size, not about avoiding the peer. 3.8 for `useXAxisInverseScale` (the zoom). |
71
- | `lucide-react` | peer, `>=0.400.0 <2` | Icons, imported by name throughout the kit. **Yours to version.** |
71
+ | `lucide-react` | peer, `>=0.577.0 <2` | Icons, imported by name throughout the kit. **Yours to version.** |
72
72
  | `sonner` | peer, `>=2`, **optional** | Under the kit's `toast` and `<Toaster>` (and through them `FileDropzone` and the wizard's "fill in the required fields" toast), reached only through `await import()`. Pass `onValidationFailed` to route the wizard's elsewhere. |
73
73
  | `react-hook-form` | peer, `^7.55.0`, **optional** | Only `@eifi1/ui-kit/rhf` imports it; the main barrel does not (see "Forms / refs" below). |
74
74
  | `modern-screenshot` | peer, `^4.7.0`, **optional** | The feedback dialog's "Capture screenshot" (`captureAppScreenshot`), reached only through `await import()`. Without it the capture rejects and the dialog toasts `feedbackToast.captureFailed`. |
@@ -83,8 +83,10 @@ when it chooses. To work on the design system and an app together, use
83
83
  declared it themselves, so a hard dependency here bought each of them a second copy of the
84
84
  icon set — and `^0.511.0` on a 0.x package is a *patch* pin, which held them on 0.511.x
85
85
  while the line moved to 1.x. The range is wide on purpose: the kit imports named icon
86
- components and nothing else, and that has been stable across the whole span. *Migration:*
87
- none if you already depend on it; otherwise `npm install lucide-react`.
86
+ components and nothing else. Its floor, 0.577.0, is what the three apps run, and the
87
+ package check installs exactly that version, so the kit cannot use an icon that only 1.x
88
+ has without its own gate failing. *Migration:* none if you already depend on it;
89
+ otherwise `npm install lucide-react`.
88
90
 
89
91
  **`flag-icons` is a host contract, not a dependency.** `CurrencyFlag`, `CurrencySelect`
90
92
  (and `AmountInput`'s currency picker, which uses it) and `LanguageMenu` render flags as
@@ -169,6 +169,7 @@ function AdminActionConfirm({
169
169
  title: heading,
170
170
  description: identity,
171
171
  onClose: () => onClose(confirmed.current),
172
+ busy,
172
173
  actions: (close) => /* @__PURE__ */ jsx(CloseOnDone, { done, children: /* @__PURE__ */ jsx(Button, { type: "button", variant: "secondary", disabled: busy, onClick: close, children: labels.close }) }),
173
174
  children: refusalBanner
174
175
  }
@@ -194,10 +195,7 @@ function AdminActionConfirm({
194
195
  title: heading,
195
196
  description: identity,
196
197
  onClose: () => onClose(confirmed.current),
197
- onKeyDown: (event) => {
198
- if (busy && event.key === "Escape") event.preventDefault();
199
- },
200
- "aria-busy": busy || void 0,
198
+ busy,
201
199
  actions: (close) => /* @__PURE__ */ jsxs(CloseOnDone, { done, children: [
202
200
  /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", disabled: busy, onClick: close, children: labels.cancel }),
203
201
  /* @__PURE__ */ jsx(
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/admin/admin-action-confirm.tsx"],"sourcesContent":["import { Children, useContext, useEffect, useId, useRef, useState } from \"react\";\nimport type { KeyboardEvent, ReactNode } from \"react\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { AlertBanner } from \"../components/alert-banner\";\nimport { Checkbox } from \"../components/checkbox\";\nimport { DEFAULT_DANGER_CONFIRM_LABELS, TypedConfirmField, typedMatches } from \"../components/danger-confirm\";\nimport type { DangerConsequence } from \"../components/danger-confirm\";\nimport { DialogFrame } from \"../components/dialog-frame\";\nimport { ModalCloseContext } from \"../components/modal\";\nimport { Button } from \"../components/button\";\nimport { authErrorCode } from \"../auth/auth-errors\";\nimport type { KitErrorCode } from \"../auth/auth-errors\";\nimport { usePersonLabel } from \"./admin-parts\";\nimport { hasContent } from \"../lib/presence\";\nimport type { AdminPerson, MaybePromise } from \"./admin-parts\";\nimport { isGroupTarget } from \"./admin-action-log\";\nimport type { AdminGroupTarget } from \"./admin-action-log\";\nimport type { CommitScope } from \"../components/write-lock\";\nimport { DEFAULT_ADMIN_ACTION_LABELS } from \"./admin-action-confirm-labels\";\n\n/**\n * The confirmation in front of an admin action, at the level the SERVER states\n * (docs/user-admin-harmonization.md §4.2):\n *\n * | level | what it asks | actions |\n * |---|---|---|\n * | `none` | nothing: it confirms at once and renders nothing | resend a mail, reactivate, the reviewer scope… |\n * | `acknowledge` | a tick under the consequence | force a password change, change a role, delete a group |\n * | `type_email` | the target's address, typed | deactivate, erase now, transfer |\n *\n * A GROUP (round 0.34, §11.8) is a target at `none` and `acknowledge` only, said by its\n * name: it has no address to type, and no level table gives a group's action\n * `type_email` (server-kit's `require_confirmation` raises for one). The props' types\n * say so. Deleting one (`group_delete`) is `acknowledge` — a tick under the app's own\n * sentence of what goes with it, Kurvenschmiede's \"3 members, 2 grants and 1 invitation\n * go with it\" (decision 5): the cascade is what nobody can see afterwards.\n *\n * keksdose decides the level per action and per account on the server\n * (`admin_user_actions_service`) and Kurvenschmiede asks for the typed address on its\n * two hard-to-undo actions; both drew the dialog by hand beside the kit's `DangerConfirm`\n * and `useConfirm`. The level is a PROP, never derived here: the server enforces it and\n * a hand-written request never sees this dialog, so a page that guessed its own level\n * could only disagree with the server. The comparison (caseless), the field and the tick\n * are `DangerConfirm`'s (`TypedConfirmField`, the `dangerConfirm` words), so the three\n * confirmations in the kit cannot disagree about what counts as the address.\n *\n * WHY TYPE THE TARGET'S ADDRESS and not the admin's password: the admin proved who they\n * are at the admin gate. What is unproven is which ROW they are on (keksdose's\n * `TYPE_EMAIL` rule, Kurvenschmiede's `TypedConfirm`).\n *\n * THE REFUSALS (§4.1, §9.6) are read by code — `authErrorCode`, which knows the account\n * codes beside the sign-in ones — and stay in the dialog, which stays open so the admin\n * can read them: `last_admin`, `self_action`, `confirmation_mismatch`, and kastlan's\n * `other_companies` — with \"Remove from company\" offered in its place when the app passes\n * `onRemoveFromCompany`. Anything else is `describeError`'s words, else \"That didn't work\".\n * At level `none` nothing is drawn while the action runs; a refusal opens a dialog of its\n * own to say it.\n *\n * It never sends a request: `onConfirm` does, with what the guards were answered with.\n */\n\n/** How much confirmation an action needs — the server's `confirmation_level(action)`. */\nexport type AdminConfirmLevel = \"none\" | \"acknowledge\" | \"type_email\";\n\n/** What {@link AdminActionConfirmProps.onConfirm} receives — the guards' answers, for\n * the request body (the server re-checks both). */\nexport interface AdminActionConfirmValues {\n /** `type_email`: the address as typed — trimmed, its case the admin's; the body's\n * `confirm_email`, which the server compares with the account's (server-kit's\n * `confirm_email_matches`). */\n confirmEmail?: string;\n /** `acknowledge` and `type_email`: the action was confirmed past a guard — the body's\n * `acknowledged`, which every admin action carries (server-kit 0.4). */\n acknowledged?: true;\n}\n\n/** The account the action is about. A group is an {@link AdminGroupTarget}. */\nexport type AdminActionTarget = AdminPerson & { email: string };\n\n/** The `adminAction` namespace. The tick, the typed field and their held-button reasons\n * are the `dangerConfirm` namespace's, shared with `DangerConfirm`. */\nexport interface AdminActionLabels {\n /** The confirm button when the action names none. */\n confirm: string;\n cancel: string;\n /** The refusal dialog's only button (level `none`). */\n close: string;\n /** `409 last_admin`. */\n lastAdmin: string;\n /** `409 self_action`: the admin's own account. */\n self: string;\n /** `confirmation_mismatch`: the typed address is not this account's — the page and\n * the server disagree about which row this is. */\n confirmationMismatch: string;\n /** kastlan's `409 other_companies` (§9.6). */\n otherCompanies: string;\n /** The alternative offered with it. */\n removeFromCompany: string;\n /** Any other failure, unless `describeError` says better. */\n failed: string;\n}\n\n// Defined in ./admin-action-confirm-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_ADMIN_ACTION_LABELS };\n\n/** What the action is about, and the level the server states for it. A union, so a group\n * can't be given `type_email` (§11.8). */\ntype AdminActionConfirmTarget =\n | {\n /** The level the server states for this action on this account (§4.2). */\n level: AdminConfirmLevel;\n /** Whom it is about: the address (typed at `type_email`) and the name parts. */\n target: AdminActionTarget;\n }\n | {\n /** A group's action: `none`, or `acknowledge` for `group_delete` (decision 5). */\n level: Exclude<AdminConfirmLevel, \"type_email\">;\n /** The group it is about, said by its name (0.34, §11.8). */\n target: AdminGroupTarget;\n };\n\n/** The props whatever the target. */\ninterface AdminActionConfirmCommonProps {\n /** The confirm button — \"Deactivate\". Also the title when no `title` is given. */\n confirmLabel?: ReactNode;\n /** The dialog's heading — \"Deactivate Ada Example?\". Default `confirmLabel`. */\n title?: ReactNode;\n /**\n * What the action does to THIS account, said before it happens: a sentence, or lines\n * (`DangerConsequence`, a `severe` one in the danger colour). Derive them from the\n * row's facts rather than reciting the worst case (keksdose dev#488).\n */\n consequence?: ReactNode | readonly (string | DangerConsequence)[];\n /** The tick's words at `acknowledge`. Default `dangerConfirm.acknowledge` — \"I have\n * read what this does and want to continue.\" */\n acknowledgeLabel?: ReactNode;\n /** `danger` (default at `type_email`): a red confirm. `warning` (default at\n * `acknowledge`) and `neutral`: the primary confirm. */\n tone?: \"danger\" | \"warning\" | \"neutral\";\n /**\n * Runs the action. Return a promise and the dialog manages itself: busy until it\n * settles, closed (`onClose(true)`) when it resolves, open with the refusal when it\n * rejects. Return nothing and the app owns the rest — `busy`, `error`, closing it.\n */\n onConfirm: (values: AdminActionConfirmValues) => MaybePromise;\n /** The dialog has closed: `true` after the action (or the alternative) went through,\n * `false` for Cancel, Escape, the backdrop or Back. */\n onClose: (done: boolean) => void;\n /** kastlan: offered with an `other_companies` refusal — `DELETE …/membership`,\n * logged as `membership_remove` (§4.1). Promise handling as `onConfirm`. */\n onRemoveFromCompany?: () => MaybePromise;\n /** The app's words for a failure the kit has no code for. Asked first. */\n describeError?: (error: unknown) => ReactNode | undefined;\n /** A refusal the app caught itself, when `onConfirm` returns nothing. */\n error?: unknown;\n /** The action is running, for an app that tracks it itself. */\n busy?: boolean;\n /**\n * Fields of the action's own, above the guard — the transfer's recipient, kastlan's\n * roles (`RolesEditor`). Their values are the app's; hold the confirm with\n * `confirmDisabledReason` until they are complete.\n */\n children?: ReactNode;\n /** Why the confirm is held for a reason of the app's (\"Choose who receives it\").\n * Named before the guard's own, since the fields are above it. */\n confirmDisabledReason?: ReactNode;\n /** Kept-mounted mode, as on `DialogFrame`; left out, open while mounted. */\n open?: boolean;\n /** The confirm COMMITS: under a locked `WriteLockProvider` it is held with the lock's\n * reason. Off by default — an admin action is not a write to the open record. */\n commit?: CommitScope;\n labels?: LabelOverride<AdminActionLabels>;\n}\n\n/** A type since 0.34 (an interface before): a person at any level, or a group at `none`\n * and `acknowledge`. */\nexport type AdminActionConfirmProps = AdminActionConfirmCommonProps & AdminActionConfirmTarget;\n\n/** Whether a callback answered a promise. */\nfunction isThenable(value: unknown): value is PromiseLike<unknown> {\n return typeof value === \"object\" && value !== null && typeof (value as PromiseLike<unknown>).then === \"function\";\n}\n\n/** The refusal's words and code. */\nfunction refusalOf(\n error: unknown,\n present: boolean,\n describeError: AdminActionConfirmProps[\"describeError\"],\n labels: AdminActionLabels,\n): { text: ReactNode; code: KitErrorCode | undefined } | null {\n if (!present) return null;\n const code = authErrorCode(error);\n const own = describeError?.(error);\n if (hasContent(own)) return { text: own, code };\n const text =\n code === \"last_admin\"\n ? labels.lastAdmin\n : code === \"self_action\"\n ? labels.self\n : code === \"confirmation_mismatch\"\n ? labels.confirmationMismatch\n : code === \"other_companies\"\n ? labels.otherCompanies\n : labels.failed;\n return { text, code };\n}\n\n/** The selector for the first field among the caller's `children` — what opening focuses. */\nconst FIELD =\n 'input:not([type=\"hidden\"]):not(:disabled), select:not(:disabled), textarea:not(:disabled), [role=\"combobox\"]';\n\nexport function AdminActionConfirm({\n level,\n target,\n confirmLabel,\n title,\n consequence,\n acknowledgeLabel,\n tone: toneProp,\n onConfirm,\n onClose,\n onRemoveFromCompany,\n describeError,\n error: errorProp,\n busy: busyProp,\n children,\n confirmDisabledReason,\n open,\n commit,\n labels: labelsProp,\n}: AdminActionConfirmProps) {\n const labels = useKitLabels(\"adminAction\", DEFAULT_ADMIN_ACTION_LABELS, labelsProp);\n const guardLabels = useKitLabels(\"dangerConfirm\", DEFAULT_DANGER_CONFIRM_LABELS);\n const nameOf = usePersonLabel();\n const isOpen = open ?? true;\n const [typed, setTyped] = useState(\"\");\n const [ticked, setTicked] = useState(false);\n const [pending, setPending] = useState(false);\n // The last rejection, boxed so a rejection with `undefined` still counts as one.\n const [failure, setFailure] = useState<{ error: unknown } | null>(null);\n // Resolved: the actions row lowers the panel, and `onClose` answers true.\n const [done, setDone] = useState(false);\n const confirmed = useRef(false);\n const mounted = useRef(true);\n const started = useRef(false);\n const formId = useId();\n const slotRef = useRef<HTMLDivElement>(null);\n const guardRef = useRef<HTMLInputElement>(null);\n\n useEffect(() => {\n mounted.current = true;\n return () => {\n mounted.current = false;\n };\n }, []);\n\n // A close (kept-mounted) wipes the guards and the refusal: a reopened dialog starts\n // over. During render, like ReauthDialog's, so no frame shows the old answers.\n const [wasOpen, setWasOpen] = useState(isOpen);\n if (wasOpen !== isOpen) {\n setWasOpen(isOpen);\n if (!isOpen) {\n setTyped(\"\");\n setTicked(false);\n setFailure(null);\n setDone(false);\n }\n }\n\n const busy = Boolean(busyProp) || pending;\n\n /** Runs `call`, managing busy, the refusal and the close — or not, when it answers\n * no promise (the app owns it then). */\n const perform = (call: () => MaybePromise) => {\n let result: MaybePromise;\n try {\n result = call();\n } catch (caught) {\n setFailure({ error: caught });\n return;\n }\n if (!isThenable(result)) return;\n setFailure(null);\n setPending(true);\n result.then(\n () => {\n if (!mounted.current) return;\n setPending(false);\n confirmed.current = true;\n setDone(true);\n },\n (caught: unknown) => {\n if (!mounted.current) return;\n setPending(false);\n setFailure({ error: caught });\n },\n );\n };\n\n // Level `none`: confirmed the moment it opens, once per opening. Nothing is drawn\n // while it runs; the promise's answer comes back asynchronously.\n useEffect(() => {\n if (level !== \"none\") return;\n if (!isOpen) {\n started.current = false;\n return;\n }\n if (started.current) return;\n started.current = true;\n confirmed.current = false;\n let result: unknown;\n try {\n result = onConfirm({});\n } catch (caught) {\n result = Promise.reject(caught);\n }\n // No promise: the app owns the rest (it closes the part, or passes `error`).\n if (!isThenable(result)) return;\n result.then(\n () => {\n if (mounted.current) onClose(true);\n },\n (caught: unknown) => {\n if (mounted.current) setFailure({ error: caught });\n },\n );\n }, [level, isOpen, onConfirm, onClose]);\n\n // Focus the first thing the dialog waits for, after Modal's own effect has recorded\n // the trigger to return to (a parent's effects run after its children's).\n useEffect(() => {\n if (!isOpen || level === \"none\") return;\n const field = slotRef.current?.querySelector<HTMLElement>(FIELD) ?? guardRef.current;\n field?.focus();\n }, [isOpen, level]);\n\n const hasRefusal = failure !== null || errorProp !== undefined;\n const refusal = refusalOf(failure ? failure.error : errorProp, hasRefusal, describeError, labels);\n const offerRemoval = refusal?.code === \"other_companies\" && onRemoveFromCompany !== undefined;\n\n // A group has no address: it is said by its name, and nothing is typed for it — not\n // even when a caller outside the types passes `type_email` (the server refuses that).\n const group = isGroupTarget(target);\n const email = group ? \"\" : target.email;\n const name = group ? (target.name ?? \"\").trim() : nameOf(target);\n const said = !name || name === email ? email : email ? `${name} · ${email}` : name;\n const identity = <span data-private>{said}</span>;\n const heading = title ?? confirmLabel ?? labels.confirm;\n\n const refusalBanner = refusal && (\n <AlertBanner\n tone=\"danger\"\n size=\"sm\"\n action={\n offerRemoval ? (\n <Button\n type=\"button\"\n size=\"sm\"\n variant=\"secondary\"\n disabled={busy}\n onClick={() => perform(() => onRemoveFromCompany!())}\n >\n {labels.removeFromCompany}\n </Button>\n ) : undefined\n }\n >\n {refusal.text}\n </AlertBanner>\n );\n\n if (level === \"none\") {\n // Nothing to ask: only a refusal is worth a dialog.\n if (!isOpen || !refusal) return null;\n return (\n <DialogFrame\n role=\"alertdialog\"\n title={heading}\n description={identity}\n onClose={() => onClose(confirmed.current)}\n actions={(close) => (\n <CloseOnDone done={done}>\n <Button type=\"button\" variant=\"secondary\" disabled={busy} onClick={close}>\n {labels.close}\n </Button>\n </CloseOnDone>\n )}\n >\n {refusalBanner}\n </DialogFrame>\n );\n }\n\n const tone = toneProp ?? (level === \"type_email\" ? \"danger\" : \"warning\");\n const askTyped = level === \"type_email\" && !group;\n const typedOk = !askTyped || typedMatches(typed, email, \"caseless\");\n const tickOk = askTyped || ticked;\n const guardReason: ReactNode = hasContent(confirmDisabledReason)\n ? confirmDisabledReason\n : !tickOk\n ? guardLabels.needsAcknowledge\n : !typedOk\n ? typeof guardLabels.needsPhrase === \"function\"\n ? guardLabels.needsPhrase(email)\n : guardLabels.needsPhrase\n : undefined;\n\n const submit = () => {\n if (busy || guardReason !== undefined) return;\n perform(() => onConfirm(askTyped ? { confirmEmail: typed.trim(), acknowledged: true } : { acknowledged: true }));\n };\n\n const lines = Array.isArray(consequence)\n ? (consequence as readonly (string | DangerConsequence)[]).map((item, index) =>\n typeof item === \"string\"\n ? { key: item, text: item as ReactNode, severe: false }\n : { key: item.key ?? String(index), text: item.text, severe: Boolean(item.severe) },\n )\n : null;\n\n return (\n <DialogFrame\n open={open}\n role=\"alertdialog\"\n title={heading}\n description={identity}\n onClose={() => onClose(confirmed.current)}\n // A request that has left cannot be called back by closing the dialog.\n onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {\n if (busy && event.key === \"Escape\") event.preventDefault();\n }}\n aria-busy={busy || undefined}\n actions={(close) => (\n <CloseOnDone done={done}>\n <Button type=\"button\" variant=\"ghost\" disabled={busy} onClick={close}>\n {labels.cancel}\n </Button>\n <Button\n type=\"submit\"\n form={formId}\n variant={tone === \"danger\" ? \"danger\" : \"default\"}\n pending={busy}\n commit={commit}\n disabledReason={busy ? undefined : guardReason}\n >\n {confirmLabel ?? labels.confirm}\n </Button>\n </CloseOnDone>\n )}\n >\n <form\n id={formId}\n noValidate\n className=\"space-y-3\"\n onSubmit={(event) => {\n event.preventDefault();\n submit();\n }}\n >\n {lines ? (\n <ul className=\"list-disc space-y-1 ps-5 text-sm text-secondary\">\n {lines.map((line) => (\n <li key={line.key} className={line.severe ? \"text-danger\" : undefined}>\n {line.text}\n </li>\n ))}\n </ul>\n ) : (\n hasContent(consequence as ReactNode) && <p className=\"text-sm text-secondary\">{consequence as ReactNode}</p>\n )}\n {Children.toArray(children).some(hasContent) && (\n <div ref={slotRef} className=\"space-y-3\">\n {children}\n </div>\n )}\n {askTyped ? (\n <TypedConfirmField\n ref={guardRef}\n target={email}\n match=\"caseless\"\n value={typed}\n onValueChange={setTyped}\n busy={busy}\n data-private\n />\n ) : (\n <Checkbox\n ref={guardRef}\n label={acknowledgeLabel ?? guardLabels.acknowledge}\n checked={ticked}\n // Not `disabled` while busy: the box may hold the focus.\n onCheckedChange={(next) => {\n if (!busy) setTicked(next);\n }}\n />\n )}\n {refusalBanner}\n </form>\n </DialogFrame>\n );\n}\n\n/**\n * The actions row's wrapper: it renders INSIDE the Modal, where the panel's animated\n * close is in context, so a resolved action lowers the panel the way Cancel does and\n * Modal then calls `onClose` (ReauthDialog's pattern).\n */\nfunction CloseOnDone({ done, children }: { done: boolean; children: ReactNode }) {\n const close = useContext(ModalCloseContext);\n useEffect(() => {\n if (done) close?.();\n }, [done, close]);\n return <>{children}</>;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/admin/admin-action-confirm.tsx"],"sourcesContent":["import { Children, useContext, useEffect, useId, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { AlertBanner } from \"../components/alert-banner\";\nimport { Checkbox } from \"../components/checkbox\";\nimport { DEFAULT_DANGER_CONFIRM_LABELS, TypedConfirmField, typedMatches } from \"../components/danger-confirm\";\nimport type { DangerConsequence } from \"../components/danger-confirm\";\nimport { DialogFrame } from \"../components/dialog-frame\";\nimport { ModalCloseContext } from \"../components/modal\";\nimport { Button } from \"../components/button\";\nimport { authErrorCode } from \"../auth/auth-errors\";\nimport type { KitErrorCode } from \"../auth/auth-errors\";\nimport { usePersonLabel } from \"./admin-parts\";\nimport { hasContent } from \"../lib/presence\";\nimport type { AdminPerson, MaybePromise } from \"./admin-parts\";\nimport { isGroupTarget } from \"./admin-action-log\";\nimport type { AdminGroupTarget } from \"./admin-action-log\";\nimport type { CommitScope } from \"../components/write-lock\";\nimport { DEFAULT_ADMIN_ACTION_LABELS } from \"./admin-action-confirm-labels\";\n\n/**\n * The confirmation in front of an admin action, at the level the SERVER states\n * (docs/user-admin-harmonization.md §4.2):\n *\n * | level | what it asks | actions |\n * |---|---|---|\n * | `none` | nothing: it confirms at once and renders nothing | resend a mail, reactivate, the reviewer scope… |\n * | `acknowledge` | a tick under the consequence | force a password change, change a role, delete a group |\n * | `type_email` | the target's address, typed | deactivate, erase now, transfer |\n *\n * A GROUP (round 0.34, §11.8) is a target at `none` and `acknowledge` only, said by its\n * name: it has no address to type, and no level table gives a group's action\n * `type_email` (server-kit's `require_confirmation` raises for one). The props' types\n * say so. Deleting one (`group_delete`) is `acknowledge` — a tick under the app's own\n * sentence of what goes with it, Kurvenschmiede's \"3 members, 2 grants and 1 invitation\n * go with it\" (decision 5): the cascade is what nobody can see afterwards.\n *\n * keksdose decides the level per action and per account on the server\n * (`admin_user_actions_service`) and Kurvenschmiede asks for the typed address on its\n * two hard-to-undo actions; both drew the dialog by hand beside the kit's `DangerConfirm`\n * and `useConfirm`. The level is a PROP, never derived here: the server enforces it and\n * a hand-written request never sees this dialog, so a page that guessed its own level\n * could only disagree with the server. The comparison (caseless), the field and the tick\n * are `DangerConfirm`'s (`TypedConfirmField`, the `dangerConfirm` words), so the three\n * confirmations in the kit cannot disagree about what counts as the address.\n *\n * WHY TYPE THE TARGET'S ADDRESS and not the admin's password: the admin proved who they\n * are at the admin gate. What is unproven is which ROW they are on (keksdose's\n * `TYPE_EMAIL` rule, Kurvenschmiede's `TypedConfirm`).\n *\n * THE REFUSALS (§4.1, §9.6) are read by code — `authErrorCode`, which knows the account\n * codes beside the sign-in ones — and stay in the dialog, which stays open so the admin\n * can read them: `last_admin`, `self_action`, `confirmation_mismatch`, and kastlan's\n * `other_companies` — with \"Remove from company\" offered in its place when the app passes\n * `onRemoveFromCompany`. Anything else is `describeError`'s words, else \"That didn't work\".\n * At level `none` nothing is drawn while the action runs; a refusal opens a dialog of its\n * own to say it.\n *\n * It never sends a request: `onConfirm` does, with what the guards were answered with.\n */\n\n/** How much confirmation an action needs — the server's `confirmation_level(action)`. */\nexport type AdminConfirmLevel = \"none\" | \"acknowledge\" | \"type_email\";\n\n/** What {@link AdminActionConfirmProps.onConfirm} receives — the guards' answers, for\n * the request body (the server re-checks both). */\nexport interface AdminActionConfirmValues {\n /** `type_email`: the address as typed — trimmed, its case the admin's; the body's\n * `confirm_email`, which the server compares with the account's (server-kit's\n * `confirm_email_matches`). */\n confirmEmail?: string;\n /** `acknowledge` and `type_email`: the action was confirmed past a guard — the body's\n * `acknowledged`, which every admin action carries (server-kit 0.4). */\n acknowledged?: true;\n}\n\n/** The account the action is about. A group is an {@link AdminGroupTarget}. */\nexport type AdminActionTarget = AdminPerson & { email: string };\n\n/** The `adminAction` namespace. The tick, the typed field and their held-button reasons\n * are the `dangerConfirm` namespace's, shared with `DangerConfirm`. */\nexport interface AdminActionLabels {\n /** The confirm button when the action names none. */\n confirm: string;\n cancel: string;\n /** The refusal dialog's only button (level `none`). */\n close: string;\n /** `409 last_admin`. */\n lastAdmin: string;\n /** `409 self_action`: the admin's own account. */\n self: string;\n /** `confirmation_mismatch`: the typed address is not this account's — the page and\n * the server disagree about which row this is. */\n confirmationMismatch: string;\n /** kastlan's `409 other_companies` (§9.6). */\n otherCompanies: string;\n /** The alternative offered with it. */\n removeFromCompany: string;\n /** Any other failure, unless `describeError` says better. */\n failed: string;\n}\n\n// Defined in ./admin-action-confirm-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_ADMIN_ACTION_LABELS };\n\n/** What the action is about, and the level the server states for it. A union, so a group\n * can't be given `type_email` (§11.8). */\ntype AdminActionConfirmTarget =\n | {\n /** The level the server states for this action on this account (§4.2). */\n level: AdminConfirmLevel;\n /** Whom it is about: the address (typed at `type_email`) and the name parts. */\n target: AdminActionTarget;\n }\n | {\n /** A group's action: `none`, or `acknowledge` for `group_delete` (decision 5). */\n level: Exclude<AdminConfirmLevel, \"type_email\">;\n /** The group it is about, said by its name (0.34, §11.8). */\n target: AdminGroupTarget;\n };\n\n/** The props whatever the target. */\ninterface AdminActionConfirmCommonProps {\n /** The confirm button — \"Deactivate\". Also the title when no `title` is given. */\n confirmLabel?: ReactNode;\n /** The dialog's heading — \"Deactivate Ada Example?\". Default `confirmLabel`. */\n title?: ReactNode;\n /**\n * What the action does to THIS account, said before it happens: a sentence, or lines\n * (`DangerConsequence`, a `severe` one in the danger colour). Derive them from the\n * row's facts rather than reciting the worst case (keksdose dev#488).\n */\n consequence?: ReactNode | readonly (string | DangerConsequence)[];\n /** The tick's words at `acknowledge`. Default `dangerConfirm.acknowledge` — \"I have\n * read what this does and want to continue.\" */\n acknowledgeLabel?: ReactNode;\n /** `danger` (default at `type_email`): a red confirm. `warning` (default at\n * `acknowledge`) and `neutral`: the primary confirm. */\n tone?: \"danger\" | \"warning\" | \"neutral\";\n /**\n * Runs the action. Return a promise and the dialog manages itself: busy until it\n * settles, closed (`onClose(true)`) when it resolves, open with the refusal when it\n * rejects. Return nothing and the app owns the rest — `busy`, `error`, closing it.\n */\n onConfirm: (values: AdminActionConfirmValues) => MaybePromise;\n /** The dialog has closed: `true` after the action (or the alternative) went through,\n * `false` for Cancel, Escape, the backdrop or Back. */\n onClose: (done: boolean) => void;\n /** kastlan: offered with an `other_companies` refusal — `DELETE …/membership`,\n * logged as `membership_remove` (§4.1). Promise handling as `onConfirm`. */\n onRemoveFromCompany?: () => MaybePromise;\n /** The app's words for a failure the kit has no code for. Asked first. */\n describeError?: (error: unknown) => ReactNode | undefined;\n /** A refusal the app caught itself, when `onConfirm` returns nothing. */\n error?: unknown;\n /** The action is running, for an app that tracks it itself. */\n busy?: boolean;\n /**\n * Fields of the action's own, above the guard — the transfer's recipient, kastlan's\n * roles (`RolesEditor`). Their values are the app's; hold the confirm with\n * `confirmDisabledReason` until they are complete.\n */\n children?: ReactNode;\n /** Why the confirm is held for a reason of the app's (\"Choose who receives it\").\n * Named before the guard's own, since the fields are above it. */\n confirmDisabledReason?: ReactNode;\n /** Kept-mounted mode, as on `DialogFrame`; left out, open while mounted. */\n open?: boolean;\n /** The confirm COMMITS: under a locked `WriteLockProvider` it is held with the lock's\n * reason. Off by default — an admin action is not a write to the open record. */\n commit?: CommitScope;\n labels?: LabelOverride<AdminActionLabels>;\n}\n\n/** A type since 0.34 (an interface before): a person at any level, or a group at `none`\n * and `acknowledge`. */\nexport type AdminActionConfirmProps = AdminActionConfirmCommonProps & AdminActionConfirmTarget;\n\n/** Whether a callback answered a promise. */\nfunction isThenable(value: unknown): value is PromiseLike<unknown> {\n return typeof value === \"object\" && value !== null && typeof (value as PromiseLike<unknown>).then === \"function\";\n}\n\n/** The refusal's words and code. */\nfunction refusalOf(\n error: unknown,\n present: boolean,\n describeError: AdminActionConfirmProps[\"describeError\"],\n labels: AdminActionLabels,\n): { text: ReactNode; code: KitErrorCode | undefined } | null {\n if (!present) return null;\n const code = authErrorCode(error);\n const own = describeError?.(error);\n if (hasContent(own)) return { text: own, code };\n const text =\n code === \"last_admin\"\n ? labels.lastAdmin\n : code === \"self_action\"\n ? labels.self\n : code === \"confirmation_mismatch\"\n ? labels.confirmationMismatch\n : code === \"other_companies\"\n ? labels.otherCompanies\n : labels.failed;\n return { text, code };\n}\n\n/** The selector for the first field among the caller's `children` — what opening focuses. */\nconst FIELD =\n 'input:not([type=\"hidden\"]):not(:disabled), select:not(:disabled), textarea:not(:disabled), [role=\"combobox\"]';\n\nexport function AdminActionConfirm({\n level,\n target,\n confirmLabel,\n title,\n consequence,\n acknowledgeLabel,\n tone: toneProp,\n onConfirm,\n onClose,\n onRemoveFromCompany,\n describeError,\n error: errorProp,\n busy: busyProp,\n children,\n confirmDisabledReason,\n open,\n commit,\n labels: labelsProp,\n}: AdminActionConfirmProps) {\n const labels = useKitLabels(\"adminAction\", DEFAULT_ADMIN_ACTION_LABELS, labelsProp);\n const guardLabels = useKitLabels(\"dangerConfirm\", DEFAULT_DANGER_CONFIRM_LABELS);\n const nameOf = usePersonLabel();\n const isOpen = open ?? true;\n const [typed, setTyped] = useState(\"\");\n const [ticked, setTicked] = useState(false);\n const [pending, setPending] = useState(false);\n // The last rejection, boxed so a rejection with `undefined` still counts as one.\n const [failure, setFailure] = useState<{ error: unknown } | null>(null);\n // Resolved: the actions row lowers the panel, and `onClose` answers true.\n const [done, setDone] = useState(false);\n const confirmed = useRef(false);\n const mounted = useRef(true);\n const started = useRef(false);\n const formId = useId();\n const slotRef = useRef<HTMLDivElement>(null);\n const guardRef = useRef<HTMLInputElement>(null);\n\n useEffect(() => {\n mounted.current = true;\n return () => {\n mounted.current = false;\n };\n }, []);\n\n // A close (kept-mounted) wipes the guards and the refusal: a reopened dialog starts\n // over. During render, like ReauthDialog's, so no frame shows the old answers.\n const [wasOpen, setWasOpen] = useState(isOpen);\n if (wasOpen !== isOpen) {\n setWasOpen(isOpen);\n if (!isOpen) {\n setTyped(\"\");\n setTicked(false);\n setFailure(null);\n setDone(false);\n }\n }\n\n const busy = Boolean(busyProp) || pending;\n\n /** Runs `call`, managing busy, the refusal and the close — or not, when it answers\n * no promise (the app owns it then). */\n const perform = (call: () => MaybePromise) => {\n let result: MaybePromise;\n try {\n result = call();\n } catch (caught) {\n setFailure({ error: caught });\n return;\n }\n if (!isThenable(result)) return;\n setFailure(null);\n setPending(true);\n result.then(\n () => {\n if (!mounted.current) return;\n setPending(false);\n confirmed.current = true;\n setDone(true);\n },\n (caught: unknown) => {\n if (!mounted.current) return;\n setPending(false);\n setFailure({ error: caught });\n },\n );\n };\n\n // Level `none`: confirmed the moment it opens, once per opening. Nothing is drawn\n // while it runs; the promise's answer comes back asynchronously.\n useEffect(() => {\n if (level !== \"none\") return;\n if (!isOpen) {\n started.current = false;\n return;\n }\n if (started.current) return;\n started.current = true;\n confirmed.current = false;\n let result: unknown;\n try {\n result = onConfirm({});\n } catch (caught) {\n result = Promise.reject(caught);\n }\n // No promise: the app owns the rest (it closes the part, or passes `error`).\n if (!isThenable(result)) return;\n result.then(\n () => {\n if (mounted.current) onClose(true);\n },\n (caught: unknown) => {\n if (mounted.current) setFailure({ error: caught });\n },\n );\n }, [level, isOpen, onConfirm, onClose]);\n\n // Focus the first thing the dialog waits for, after Modal's own effect has recorded\n // the trigger to return to (a parent's effects run after its children's).\n useEffect(() => {\n if (!isOpen || level === \"none\") return;\n const field = slotRef.current?.querySelector<HTMLElement>(FIELD) ?? guardRef.current;\n field?.focus();\n }, [isOpen, level]);\n\n const hasRefusal = failure !== null || errorProp !== undefined;\n const refusal = refusalOf(failure ? failure.error : errorProp, hasRefusal, describeError, labels);\n const offerRemoval = refusal?.code === \"other_companies\" && onRemoveFromCompany !== undefined;\n\n // A group has no address: it is said by its name, and nothing is typed for it — not\n // even when a caller outside the types passes `type_email` (the server refuses that).\n const group = isGroupTarget(target);\n const email = group ? \"\" : target.email;\n const name = group ? (target.name ?? \"\").trim() : nameOf(target);\n const said = !name || name === email ? email : email ? `${name} · ${email}` : name;\n const identity = <span data-private>{said}</span>;\n const heading = title ?? confirmLabel ?? labels.confirm;\n\n const refusalBanner = refusal && (\n <AlertBanner\n tone=\"danger\"\n size=\"sm\"\n action={\n offerRemoval ? (\n <Button\n type=\"button\"\n size=\"sm\"\n variant=\"secondary\"\n disabled={busy}\n onClick={() => perform(() => onRemoveFromCompany!())}\n >\n {labels.removeFromCompany}\n </Button>\n ) : undefined\n }\n >\n {refusal.text}\n </AlertBanner>\n );\n\n if (level === \"none\") {\n // Nothing to ask: only a refusal is worth a dialog.\n if (!isOpen || !refusal) return null;\n return (\n <DialogFrame\n role=\"alertdialog\"\n title={heading}\n description={identity}\n onClose={() => onClose(confirmed.current)}\n // The removal offered with the refusal is a request too.\n busy={busy}\n actions={(close) => (\n <CloseOnDone done={done}>\n <Button type=\"button\" variant=\"secondary\" disabled={busy} onClick={close}>\n {labels.close}\n </Button>\n </CloseOnDone>\n )}\n >\n {refusalBanner}\n </DialogFrame>\n );\n }\n\n const tone = toneProp ?? (level === \"type_email\" ? \"danger\" : \"warning\");\n const askTyped = level === \"type_email\" && !group;\n const typedOk = !askTyped || typedMatches(typed, email, \"caseless\");\n const tickOk = askTyped || ticked;\n const guardReason: ReactNode = hasContent(confirmDisabledReason)\n ? confirmDisabledReason\n : !tickOk\n ? guardLabels.needsAcknowledge\n : !typedOk\n ? typeof guardLabels.needsPhrase === \"function\"\n ? guardLabels.needsPhrase(email)\n : guardLabels.needsPhrase\n : undefined;\n\n const submit = () => {\n if (busy || guardReason !== undefined) return;\n perform(() => onConfirm(askTyped ? { confirmEmail: typed.trim(), acknowledged: true } : { acknowledged: true }));\n };\n\n const lines = Array.isArray(consequence)\n ? (consequence as readonly (string | DangerConsequence)[]).map((item, index) =>\n typeof item === \"string\"\n ? { key: item, text: item as ReactNode, severe: false }\n : { key: item.key ?? String(index), text: item.text, severe: Boolean(item.severe) },\n )\n : null;\n\n return (\n <DialogFrame\n open={open}\n role=\"alertdialog\"\n title={heading}\n description={identity}\n onClose={() => onClose(confirmed.current)}\n // A request that has left cannot be called back by closing the dialog.\n busy={busy}\n actions={(close) => (\n <CloseOnDone done={done}>\n <Button type=\"button\" variant=\"ghost\" disabled={busy} onClick={close}>\n {labels.cancel}\n </Button>\n <Button\n type=\"submit\"\n form={formId}\n variant={tone === \"danger\" ? \"danger\" : \"default\"}\n pending={busy}\n commit={commit}\n disabledReason={busy ? undefined : guardReason}\n >\n {confirmLabel ?? labels.confirm}\n </Button>\n </CloseOnDone>\n )}\n >\n <form\n id={formId}\n noValidate\n className=\"space-y-3\"\n onSubmit={(event) => {\n event.preventDefault();\n submit();\n }}\n >\n {lines ? (\n <ul className=\"list-disc space-y-1 ps-5 text-sm text-secondary\">\n {lines.map((line) => (\n <li key={line.key} className={line.severe ? \"text-danger\" : undefined}>\n {line.text}\n </li>\n ))}\n </ul>\n ) : (\n hasContent(consequence as ReactNode) && <p className=\"text-sm text-secondary\">{consequence as ReactNode}</p>\n )}\n {Children.toArray(children).some(hasContent) && (\n <div ref={slotRef} className=\"space-y-3\">\n {children}\n </div>\n )}\n {askTyped ? (\n <TypedConfirmField\n ref={guardRef}\n target={email}\n match=\"caseless\"\n value={typed}\n onValueChange={setTyped}\n busy={busy}\n data-private\n />\n ) : (\n <Checkbox\n ref={guardRef}\n label={acknowledgeLabel ?? guardLabels.acknowledge}\n checked={ticked}\n // Not `disabled` while busy: the box may hold the focus.\n onCheckedChange={(next) => {\n if (!busy) setTicked(next);\n }}\n />\n )}\n {refusalBanner}\n </form>\n </DialogFrame>\n );\n}\n\n/**\n * The actions row's wrapper: it renders INSIDE the Modal, where the panel's animated\n * close is in context, so a resolved action lowers the panel the way Cancel does and\n * Modal then calls `onClose` (ReauthDialog's pattern).\n */\nfunction CloseOnDone({ done, children }: { done: boolean; children: ReactNode }) {\n const close = useContext(ModalCloseContext);\n useEffect(() => {\n if (done) close?.();\n }, [done, close]);\n return <>{children}</>;\n}\n"],"mappings":"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","names":[]}
@@ -59,8 +59,9 @@ export interface CompleteNameDialogProps {
59
59
  * complete name, no page is blocked, and the app asks again at the next sign-in.
60
60
  *
61
61
  * The promise handling is `ReauthDialog`'s: busy while `onSave` runs (the fields
62
- * read-only, "Later" disabled, Escape held — a request that has left cannot be called
63
- * back by closing the dialog), closed on resolve, kept open on reject. Save is a
62
+ * read-only, "Later" disabled, Escape, the backdrop and Back held — a request that has
63
+ * left cannot be called back by closing the dialog), closed on resolve, kept open on
64
+ * reject. Save is a
64
65
  * `commit` control, so a `WriteLockProvider` above it locks it with its reason — every
65
66
  * lock but a lapsed plan's (`COMMIT_EXCEPT_BILLING`, 0.33): one's own name is the
66
67
  * account's own settings, which billing never locks (docs/billing-harmonization.md §3.3,
@@ -75,10 +75,7 @@ function CompleteNameDialog({
75
75
  title: labels.title,
76
76
  description: labels.description,
77
77
  onClose: () => onClose(saved.current),
78
- onKeyDown: (e) => {
79
- if (pending && e.key === "Escape") e.preventDefault();
80
- },
81
- "aria-busy": pending || void 0,
78
+ busy: pending,
82
79
  actions: /* @__PURE__ */ jsx(
83
80
  CompleteNameActions,
84
81
  {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/auth/complete-name-dialog.tsx"],"sourcesContent":["import { useContext, useEffect, useId, useRef, useState } from \"react\";\nimport type { KeyboardEvent, ReactNode } from \"react\";\n\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { usePromisePending } from \"../components/danger-confirm\";\nimport { DialogFrame } from \"../components/dialog-frame\";\nimport { ModalCloseContext } from \"../components/modal\";\nimport { Button } from \"../components/button\";\nimport { Input } from \"../components/input\";\nimport { personNameOk, PERSON_NAME_MAX_LENGTH } from \"./form-rules\";\nimport { COMMIT_EXCEPT_BILLING } from \"../components/write-lock\";\nimport { DEFAULT_COMPLETE_NAME_LABELS } from \"./complete-name-dialog-labels\";\n\n/** What `CompleteNameDialog` hands `onSave`: both names, trimmed, 1–120 characters —\n * `PATCH /auth/me {first_name, last_name}` (§6.1). */\nexport interface CompleteNameValues {\n firstName: string;\n lastName: string;\n}\n\n/**\n * Every string the dialog renders — the `completeName` namespace of `<UiKitProvider\n * labels>`, overridable per instance through `labels`.\n */\nexport interface CompleteNameLabels {\n title: string;\n description: string;\n firstName: string;\n lastName: string;\n save: string;\n /** Dismisses the dialog until the next sign-in. */\n later: string;\n /** Under the fields when `onSave` rejected and the app passed no `error`. */\n failed: string;\n}\n\n// Defined in ./complete-name-dialog-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_COMPLETE_NAME_LABELS };\n\nexport interface CompleteNameDialogProps {\n /** What the account holds now — after the migration, the old display name (§3.3). */\n firstName?: string;\n /** Usually empty: that is why the dialog is up. */\n lastName?: string;\n /**\n * Save the names (`PATCH /auth/me`). Resolve and the dialog closes (`onClose(true)`);\n * reject and it stays open with the names kept and `error` — or \"Your name could not\n * be saved\" — under them.\n */\n onSave: (values: CompleteNameValues) => Promise<unknown>;\n /**\n * The dialog has closed: `true` after a resolved `onSave`, `false` for \"Later\", Escape,\n * the backdrop and Back. `false` only DEFERS it: the app shows it again after the\n * next sign-in while `/auth/me` still answers `name_incomplete: true`.\n */\n onClose: (saved: boolean) => void;\n /** The app's words for the last failed save, instead of `labels.failed`. Hidden again\n * once a name is edited. */\n error?: ReactNode;\n /** Kept-mounted mode, as on `Modal`: left out, the dialog is open while mounted. */\n open?: boolean;\n labels?: LabelOverride<CompleteNameLabels>;\n}\n\n/**\n * \"Complete your name\" — asked once after sign-in of a user whose name was migrated\n * (docs/auth-harmonization.md §3.3). keksdose's and Kurvenschmiede's accounts had one\n * `display_name`; it moved whole into `first_name`, `last_name` stayed empty, and\n * nothing was split by guesswork (§2.4). The app shows this while `/auth/me` answers\n * `name_incomplete: true` — never for a demo user.\n *\n * Both names prefilled with what is there; \"Save\" waits until both have 1–120\n * characters after trimming. **\"Later\" only defers it**: nothing else waits on a\n * complete name, no page is blocked, and the app asks again at the next sign-in.\n *\n * The promise handling is `ReauthDialog`'s: busy while `onSave` runs (the fields\n * read-only, \"Later\" disabled, Escape held — a request that has left cannot be called\n * back by closing the dialog), closed on resolve, kept open on reject. Save is a\n * `commit` control, so a `WriteLockProvider` above it locks it with its reason — every\n * lock but a lapsed plan's (`COMMIT_EXCEPT_BILLING`, 0.33): one's own name is the\n * account's own settings, which billing never locks (docs/billing-harmonization.md §3.3,\n * §12.36).\n *\n * Focus starts in the first EMPTY field — the last name, as a rule — because filling it\n * in is the whole of what the dialog is for.\n */\nexport function CompleteNameDialog({\n firstName: initialFirst = \"\",\n lastName: initialLast = \"\",\n onSave,\n onClose,\n error,\n open,\n labels: labelsProp,\n}: CompleteNameDialogProps) {\n const labels = useKitLabels(\"completeName\", DEFAULT_COMPLETE_NAME_LABELS, labelsProp);\n const [first, setFirst] = useState(initialFirst);\n const [last, setLast] = useState(initialLast);\n // A failed save describes the names that were sent; an edit makes it stale.\n const [failed, setFailed] = useState(false);\n const { pending, run } = usePromisePending();\n const [done, setDone] = useState(false);\n const saved = useRef(false);\n const formId = useId();\n const firstRef = useRef<HTMLInputElement>(null);\n const lastRef = useRef<HTMLInputElement>(null);\n const isOpen = open ?? true;\n\n // Reopened (kept-mounted): start again from what the account holds. During render,\n // as ReauthDialog wipes its password, so no frame shows the old draft.\n const [wasOpen, setWasOpen] = useState(isOpen);\n if (wasOpen !== isOpen) {\n setWasOpen(isOpen);\n if (isOpen) {\n setFirst(initialFirst);\n setLast(initialLast);\n setFailed(false);\n setDone(false);\n }\n }\n\n // After Modal's own effect, so its trap has recorded the trigger before focus moves.\n useEffect(() => {\n if (!isOpen) return;\n saved.current = false;\n const target = firstRef.current?.value.trim() ? lastRef.current : firstRef.current;\n target?.focus();\n }, [isOpen]);\n\n const canSave = personNameOk(first) && personNameOk(last);\n\n const submit = () => {\n if (pending || !canSave) return;\n setFailed(false);\n const values = { firstName: first.trim(), lastName: last.trim() };\n let result: Promise<unknown>;\n try {\n result = Promise.resolve(onSave(values));\n } catch (err) {\n result = Promise.reject(err);\n }\n // A rejection must not go unhandled; usePromisePending clears `pending` on it.\n result.catch(() => setFailed(true));\n run(result, () => {\n saved.current = true;\n setDone(true);\n });\n };\n\n const edit = (set: (value: string) => void) => (value: string) => {\n set(value);\n setFailed(false);\n };\n\n const message = failed ? (error ?? labels.failed) : undefined;\n\n return (\n <DialogFrame\n open={open}\n title={labels.title}\n description={labels.description}\n onClose={() => onClose(saved.current)}\n onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {\n if (pending && e.key === \"Escape\") e.preventDefault();\n }}\n aria-busy={pending || undefined}\n actions={\n <CompleteNameActions\n formId={formId}\n done={done}\n busy={pending}\n canSave={canSave}\n laterLabel={labels.later}\n saveLabel={labels.save}\n />\n }\n >\n <form\n id={formId}\n noValidate\n className=\"grid gap-3 @container\"\n onSubmit={(e) => {\n e.preventDefault();\n submit();\n }}\n >\n <div className=\"grid gap-3 @xs:grid-cols-2\">\n <Input\n ref={firstRef}\n name=\"given-name\"\n autoComplete=\"given-name\"\n label={labels.firstName}\n value={first}\n onChange={(e) => edit(setFirst)(e.target.value)}\n maxLength={PERSON_NAME_MAX_LENGTH}\n readOnly={pending}\n required\n />\n <Input\n ref={lastRef}\n name=\"family-name\"\n autoComplete=\"family-name\"\n label={labels.lastName}\n value={last}\n onChange={(e) => edit(setLast)(e.target.value)}\n maxLength={PERSON_NAME_MAX_LENGTH}\n readOnly={pending}\n required\n />\n </div>\n {message !== undefined && (\n <p role=\"alert\" className=\"text-xs text-danger\">\n {message}\n </p>\n )}\n </form>\n </DialogFrame>\n );\n}\n\n/** The actions row, inside the `Modal`, where its animated close is in context: a\n * resolved save lowers the panel through it, as \"Later\" and Escape do. */\nfunction CompleteNameActions({\n formId,\n done,\n busy,\n canSave,\n laterLabel,\n saveLabel,\n}: {\n formId: string;\n done: boolean;\n busy: boolean;\n canSave: boolean;\n laterLabel: string;\n saveLabel: string;\n}) {\n const close = useContext(ModalCloseContext);\n useEffect(() => {\n if (done) close?.();\n }, [done, close]);\n return (\n <>\n <Button type=\"button\" variant=\"ghost\" disabled={busy} onClick={() => close?.()}>\n {laterLabel}\n </Button>\n <Button type=\"submit\" form={formId} commit={COMMIT_EXCEPT_BILLING} disabled={!busy && !canSave} pending={busy}>\n {saveLabel}\n </Button>\n </>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/auth/complete-name-dialog.tsx"],"sourcesContent":["import { useContext, useEffect, useId, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\n\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { usePromisePending } from \"../components/danger-confirm\";\nimport { DialogFrame } from \"../components/dialog-frame\";\nimport { ModalCloseContext } from \"../components/modal\";\nimport { Button } from \"../components/button\";\nimport { Input } from \"../components/input\";\nimport { personNameOk, PERSON_NAME_MAX_LENGTH } from \"./form-rules\";\nimport { COMMIT_EXCEPT_BILLING } from \"../components/write-lock\";\nimport { DEFAULT_COMPLETE_NAME_LABELS } from \"./complete-name-dialog-labels\";\n\n/** What `CompleteNameDialog` hands `onSave`: both names, trimmed, 1–120 characters —\n * `PATCH /auth/me {first_name, last_name}` (§6.1). */\nexport interface CompleteNameValues {\n firstName: string;\n lastName: string;\n}\n\n/**\n * Every string the dialog renders — the `completeName` namespace of `<UiKitProvider\n * labels>`, overridable per instance through `labels`.\n */\nexport interface CompleteNameLabels {\n title: string;\n description: string;\n firstName: string;\n lastName: string;\n save: string;\n /** Dismisses the dialog until the next sign-in. */\n later: string;\n /** Under the fields when `onSave` rejected and the app passed no `error`. */\n failed: string;\n}\n\n// Defined in ./complete-name-dialog-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_COMPLETE_NAME_LABELS };\n\nexport interface CompleteNameDialogProps {\n /** What the account holds now — after the migration, the old display name (§3.3). */\n firstName?: string;\n /** Usually empty: that is why the dialog is up. */\n lastName?: string;\n /**\n * Save the names (`PATCH /auth/me`). Resolve and the dialog closes (`onClose(true)`);\n * reject and it stays open with the names kept and `error` — or \"Your name could not\n * be saved\" — under them.\n */\n onSave: (values: CompleteNameValues) => Promise<unknown>;\n /**\n * The dialog has closed: `true` after a resolved `onSave`, `false` for \"Later\", Escape,\n * the backdrop and Back. `false` only DEFERS it: the app shows it again after the\n * next sign-in while `/auth/me` still answers `name_incomplete: true`.\n */\n onClose: (saved: boolean) => void;\n /** The app's words for the last failed save, instead of `labels.failed`. Hidden again\n * once a name is edited. */\n error?: ReactNode;\n /** Kept-mounted mode, as on `Modal`: left out, the dialog is open while mounted. */\n open?: boolean;\n labels?: LabelOverride<CompleteNameLabels>;\n}\n\n/**\n * \"Complete your name\" — asked once after sign-in of a user whose name was migrated\n * (docs/auth-harmonization.md §3.3). keksdose's and Kurvenschmiede's accounts had one\n * `display_name`; it moved whole into `first_name`, `last_name` stayed empty, and\n * nothing was split by guesswork (§2.4). The app shows this while `/auth/me` answers\n * `name_incomplete: true` — never for a demo user.\n *\n * Both names prefilled with what is there; \"Save\" waits until both have 1–120\n * characters after trimming. **\"Later\" only defers it**: nothing else waits on a\n * complete name, no page is blocked, and the app asks again at the next sign-in.\n *\n * The promise handling is `ReauthDialog`'s: busy while `onSave` runs (the fields\n * read-only, \"Later\" disabled, Escape, the backdrop and Back held — a request that has\n * left cannot be called back by closing the dialog), closed on resolve, kept open on\n * reject. Save is a\n * `commit` control, so a `WriteLockProvider` above it locks it with its reason — every\n * lock but a lapsed plan's (`COMMIT_EXCEPT_BILLING`, 0.33): one's own name is the\n * account's own settings, which billing never locks (docs/billing-harmonization.md §3.3,\n * §12.36).\n *\n * Focus starts in the first EMPTY field — the last name, as a rule — because filling it\n * in is the whole of what the dialog is for.\n */\nexport function CompleteNameDialog({\n firstName: initialFirst = \"\",\n lastName: initialLast = \"\",\n onSave,\n onClose,\n error,\n open,\n labels: labelsProp,\n}: CompleteNameDialogProps) {\n const labels = useKitLabels(\"completeName\", DEFAULT_COMPLETE_NAME_LABELS, labelsProp);\n const [first, setFirst] = useState(initialFirst);\n const [last, setLast] = useState(initialLast);\n // A failed save describes the names that were sent; an edit makes it stale.\n const [failed, setFailed] = useState(false);\n const { pending, run } = usePromisePending();\n const [done, setDone] = useState(false);\n const saved = useRef(false);\n const formId = useId();\n const firstRef = useRef<HTMLInputElement>(null);\n const lastRef = useRef<HTMLInputElement>(null);\n const isOpen = open ?? true;\n\n // Reopened (kept-mounted): start again from what the account holds. During render,\n // as ReauthDialog wipes its password, so no frame shows the old draft.\n const [wasOpen, setWasOpen] = useState(isOpen);\n if (wasOpen !== isOpen) {\n setWasOpen(isOpen);\n if (isOpen) {\n setFirst(initialFirst);\n setLast(initialLast);\n setFailed(false);\n setDone(false);\n }\n }\n\n // After Modal's own effect, so its trap has recorded the trigger before focus moves.\n useEffect(() => {\n if (!isOpen) return;\n saved.current = false;\n const target = firstRef.current?.value.trim() ? lastRef.current : firstRef.current;\n target?.focus();\n }, [isOpen]);\n\n const canSave = personNameOk(first) && personNameOk(last);\n\n const submit = () => {\n if (pending || !canSave) return;\n setFailed(false);\n const values = { firstName: first.trim(), lastName: last.trim() };\n let result: Promise<unknown>;\n try {\n result = Promise.resolve(onSave(values));\n } catch (err) {\n result = Promise.reject(err);\n }\n // A rejection must not go unhandled; usePromisePending clears `pending` on it.\n result.catch(() => setFailed(true));\n run(result, () => {\n saved.current = true;\n setDone(true);\n });\n };\n\n const edit = (set: (value: string) => void) => (value: string) => {\n set(value);\n setFailed(false);\n };\n\n const message = failed ? (error ?? labels.failed) : undefined;\n\n return (\n <DialogFrame\n open={open}\n title={labels.title}\n description={labels.description}\n onClose={() => onClose(saved.current)}\n busy={pending}\n actions={\n <CompleteNameActions\n formId={formId}\n done={done}\n busy={pending}\n canSave={canSave}\n laterLabel={labels.later}\n saveLabel={labels.save}\n />\n }\n >\n <form\n id={formId}\n noValidate\n className=\"grid gap-3 @container\"\n onSubmit={(e) => {\n e.preventDefault();\n submit();\n }}\n >\n <div className=\"grid gap-3 @xs:grid-cols-2\">\n <Input\n ref={firstRef}\n name=\"given-name\"\n autoComplete=\"given-name\"\n label={labels.firstName}\n value={first}\n onChange={(e) => edit(setFirst)(e.target.value)}\n maxLength={PERSON_NAME_MAX_LENGTH}\n readOnly={pending}\n required\n />\n <Input\n ref={lastRef}\n name=\"family-name\"\n autoComplete=\"family-name\"\n label={labels.lastName}\n value={last}\n onChange={(e) => edit(setLast)(e.target.value)}\n maxLength={PERSON_NAME_MAX_LENGTH}\n readOnly={pending}\n required\n />\n </div>\n {message !== undefined && (\n <p role=\"alert\" className=\"text-xs text-danger\">\n {message}\n </p>\n )}\n </form>\n </DialogFrame>\n );\n}\n\n/** The actions row, inside the `Modal`, where its animated close is in context: a\n * resolved save lowers the panel through it, as \"Later\" and Escape do. */\nfunction CompleteNameActions({\n formId,\n done,\n busy,\n canSave,\n laterLabel,\n saveLabel,\n}: {\n formId: string;\n done: boolean;\n busy: boolean;\n canSave: boolean;\n laterLabel: string;\n saveLabel: string;\n}) {\n const close = useContext(ModalCloseContext);\n useEffect(() => {\n if (done) close?.();\n }, [done, close]);\n return (\n <>\n <Button type=\"button\" variant=\"ghost\" disabled={busy} onClick={() => close?.()}>\n {laterLabel}\n </Button>\n <Button type=\"submit\" form={formId} commit={COMMIT_EXCEPT_BILLING} disabled={!busy && !canSave} pending={busy}>\n {saveLabel}\n </Button>\n </>\n );\n}\n"],"mappings":"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","names":[]}
@@ -19,7 +19,8 @@ export interface CancelRequestView {
19
19
  export interface CancelRequest {
20
20
  /** Read the overview again every few seconds while this is true — the overview query's
21
21
  * `waiting` (kastlan's `useBillingOverview({ waiting })`, a `refetchInterval` inside) —
22
- * so the page follows the provider. Needs no overview data: pass it to the query. */
22
+ * so the page follows the provider. Needs no overview data: pass it to the query.
23
+ * False again after `timeoutMs` with no move of the row. */
23
24
  waiting: boolean;
24
25
  /**
25
26
  * The row as read now (the overview's `cancel_at_period_end`; `undefined` while it
@@ -49,5 +50,13 @@ export interface CancelRequest {
49
50
  * onResume={() => resume.mutate(undefined, { onSuccess: view.noteAsked })}
50
51
  * …
51
52
  * />
53
+ *
54
+ * **Bounded,** as `useCheckoutProcessing` is (FLW-6): after `timeoutMs` (10 minutes)
55
+ * with no move of the row the request is dropped — the polling stops and the page shows
56
+ * the row as it is. Unbounded, a webhook that never landed kept kastlan re-reading the
57
+ * overview every 3 s for as long as the page was open, which also kept a Cloud Run
58
+ * instance awake, and the plan line said "Cancels on …" for a row that never moved.
52
59
  */
53
- export declare function useCancelRequest(): CancelRequest;
60
+ export declare function useCancelRequest({ timeoutMs }?: {
61
+ timeoutMs?: number;
62
+ }): CancelRequest;
@@ -1,7 +1,16 @@
1
1
  "use client";
2
- import { useState } from "react";
3
- function useCancelRequest() {
2
+ import { useEffect, useState } from "react";
3
+ const WAIT_FOR = 10 * 6e4;
4
+ function useCancelRequest({ timeoutMs = WAIT_FOR } = {}) {
4
5
  const [asked, setAsked] = useState(null);
6
+ useEffect(() => {
7
+ if (asked === null) return;
8
+ const timer = setTimeout(
9
+ () => setAsked((current) => current === asked ? null : current),
10
+ Math.max(0, asked.at + timeoutMs - Date.now())
11
+ );
12
+ return () => clearTimeout(timer);
13
+ }, [asked, timeoutMs]);
5
14
  const view = (row) => {
6
15
  if (row === void 0 || row === null) return { requested: null, cancelScheduled: false, noteAsked: () => {
7
16
  } };
@@ -10,7 +19,10 @@ function useCancelRequest() {
10
19
  return {
11
20
  requested: open ? row ? "resume" : "cancel" : null,
12
21
  cancelScheduled: open ? !row : row,
13
- noteAsked: () => setAsked((a) => a !== null && a.scheduled === row ? null : { scheduled: row })
22
+ noteAsked: () => {
23
+ const at = Date.now();
24
+ setAsked((a) => a !== null && a.scheduled === row ? null : { scheduled: row, at });
25
+ }
14
26
  };
15
27
  };
16
28
  return { waiting: asked !== null, view };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/billing/cancel-request.ts"],"sourcesContent":["import { useState } from \"react\";\n\n/**\n * What the payer asked for in the app (0.43, kastlan `18bf4c0`): `\"cancel\"` after\n * `POST /billing/cancel` answered 204, `\"resume\"` after `POST /billing/resume` — while the\n * overview still shows the old `cancel_at_period_end`, until the provider's\n * `subscription.updated` has landed (§16.3). `null`: nothing asked, or the row has moved.\n */\nexport type CancelRequested = \"cancel\" | \"resume\" | null;\n\n/** What the page shows for the row as read now — `CancelRequest.view(rowScheduled)`. */\nexport interface CancelRequestView {\n /** For `SubscriptionActions`' `cancelRequested`. */\n requested: CancelRequested;\n /** What the plan line says — the asked-for state until the row moves, then the row's:\n * \"Cancels on …\" at once after a cancel, \"Renews on …\" after \"Keep subscription\". */\n cancelScheduled: boolean;\n /** Call on the 204 of a cancel or of a resume. A cancel and its undo both asked before\n * the row moved cancel out. */\n noteAsked: () => void;\n}\n\nexport interface CancelRequest {\n /** Read the overview again every few seconds while this is true — the overview query's\n * `waiting` (kastlan's `useBillingOverview({ waiting })`, a `refetchInterval` inside) —\n * so the page follows the provider. Needs no overview data: pass it to the query. */\n waiting: boolean;\n /**\n * The row as read now (the overview's `cancel_at_period_end`; `undefined` while it\n * loads) → what the page shows. Call it during render, after the query: when the row\n * has moved past what was asked, it drops the request (React's \"adjust state while\n * rendering\" pattern), so `waiting` turns false and the query stops polling.\n */\n view: (rowScheduled: boolean | null | undefined) => CancelRequestView;\n}\n\n/**\n * The in-app cancel's follow-up (0.43, kastlan's derivation in `18bf4c0`): after \"Cancel\n * subscription now\" or \"Keep subscription\" the server answers 204 at once, but the row's\n * `cancel_at_period_end` only changes when the provider's webhook lands. Until then the\n * page shows what was asked for, and a second press would ask the provider again.\n *\n * The state lives in the hook and needs no overview at construction, so it goes BEFORE\n * the overview query, which reads `waiting`, and the view comes after it — the overview\n * read once (build review 0.43, kastlan B2; kastlan `subscription-section.tsx:74-92`):\n *\n * const request = useCancelRequest();\n * const { data } = useBillingOverview({ waiting: request.waiting });\n * const view = request.view(data?.cancel_at_period_end);\n * <p>{standingLine({ ...data, cancel_at_period_end: view.cancelScheduled })}</p>\n * <SubscriptionActions\n * cancelAtPeriodEnd={data.cancel_at_period_end}\n * cancelRequested={view.requested}\n * onResume={() => resume.mutate(undefined, { onSuccess: view.noteAsked })}\n * …\n * />\n */\nexport function useCancelRequest(): CancelRequest {\n // What was asked, kept with the row's state it was asked against.\n const [asked, setAsked] = useState<{ scheduled: boolean } | null>(null);\n const view = (row: boolean | null | undefined): CancelRequestView => {\n // Not read yet: nothing to show against, and nothing to drop.\n if (row === undefined || row === null) return { requested: null, cancelScheduled: false, noteAsked: () => {} };\n // The row has moved: it says the rest. Adjusted during render, React's pattern for\n // state that follows a prop.\n if (asked !== null && asked.scheduled !== row) setAsked(null);\n const open = asked !== null && asked.scheduled === row;\n return {\n requested: open ? (row ? \"resume\" : \"cancel\") : null,\n cancelScheduled: open ? !row : row,\n noteAsked: () => setAsked((a) => (a !== null && a.scheduled === row ? null : { scheduled: row })),\n };\n };\n return { waiting: asked !== null, view };\n}\n"],"mappings":";AAAA,SAAS,gBAAgB;AAyDlB,SAAS,mBAAkC;AAEhD,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAwC,IAAI;AACtE,QAAM,OAAO,CAAC,QAAuD;AAEnE,QAAI,QAAQ,UAAa,QAAQ,KAAM,QAAO,EAAE,WAAW,MAAM,iBAAiB,OAAO,WAAW,MAAM;AAAA,IAAC,EAAE;AAG7G,QAAI,UAAU,QAAQ,MAAM,cAAc,IAAK,UAAS,IAAI;AAC5D,UAAM,OAAO,UAAU,QAAQ,MAAM,cAAc;AACnD,WAAO;AAAA,MACL,WAAW,OAAQ,MAAM,WAAW,WAAY;AAAA,MAChD,iBAAiB,OAAO,CAAC,MAAM;AAAA,MAC/B,WAAW,MAAM,SAAS,CAAC,MAAO,MAAM,QAAQ,EAAE,cAAc,MAAM,OAAO,EAAE,WAAW,IAAI,CAAE;AAAA,IAClG;AAAA,EACF;AACA,SAAO,EAAE,SAAS,UAAU,MAAM,KAAK;AACzC;","names":[]}
1
+ {"version":3,"sources":["../../src/billing/cancel-request.ts"],"sourcesContent":["import { useEffect, useState } from \"react\";\n\n/**\n * What the payer asked for in the app (0.43, kastlan `18bf4c0`): `\"cancel\"` after\n * `POST /billing/cancel` answered 204, `\"resume\"` after `POST /billing/resume` — while the\n * overview still shows the old `cancel_at_period_end`, until the provider's\n * `subscription.updated` has landed (§16.3). `null`: nothing asked, or the row has moved.\n */\nexport type CancelRequested = \"cancel\" | \"resume\" | null;\n\n/** How long a request waits for the provider's webhook: `useCheckoutProcessing`'s bound. */\nconst WAIT_FOR = 10 * 60_000;\n\n/** What the page shows for the row as read now — `CancelRequest.view(rowScheduled)`. */\nexport interface CancelRequestView {\n /** For `SubscriptionActions`' `cancelRequested`. */\n requested: CancelRequested;\n /** What the plan line says — the asked-for state until the row moves, then the row's:\n * \"Cancels on …\" at once after a cancel, \"Renews on …\" after \"Keep subscription\". */\n cancelScheduled: boolean;\n /** Call on the 204 of a cancel or of a resume. A cancel and its undo both asked before\n * the row moved cancel out. */\n noteAsked: () => void;\n}\n\nexport interface CancelRequest {\n /** Read the overview again every few seconds while this is true — the overview query's\n * `waiting` (kastlan's `useBillingOverview({ waiting })`, a `refetchInterval` inside) —\n * so the page follows the provider. Needs no overview data: pass it to the query.\n * False again after `timeoutMs` with no move of the row. */\n waiting: boolean;\n /**\n * The row as read now (the overview's `cancel_at_period_end`; `undefined` while it\n * loads) → what the page shows. Call it during render, after the query: when the row\n * has moved past what was asked, it drops the request (React's \"adjust state while\n * rendering\" pattern), so `waiting` turns false and the query stops polling.\n */\n view: (rowScheduled: boolean | null | undefined) => CancelRequestView;\n}\n\n/**\n * The in-app cancel's follow-up (0.43, kastlan's derivation in `18bf4c0`): after \"Cancel\n * subscription now\" or \"Keep subscription\" the server answers 204 at once, but the row's\n * `cancel_at_period_end` only changes when the provider's webhook lands. Until then the\n * page shows what was asked for, and a second press would ask the provider again.\n *\n * The state lives in the hook and needs no overview at construction, so it goes BEFORE\n * the overview query, which reads `waiting`, and the view comes after it — the overview\n * read once (build review 0.43, kastlan B2; kastlan `subscription-section.tsx:74-92`):\n *\n * const request = useCancelRequest();\n * const { data } = useBillingOverview({ waiting: request.waiting });\n * const view = request.view(data?.cancel_at_period_end);\n * <p>{standingLine({ ...data, cancel_at_period_end: view.cancelScheduled })}</p>\n * <SubscriptionActions\n * cancelAtPeriodEnd={data.cancel_at_period_end}\n * cancelRequested={view.requested}\n * onResume={() => resume.mutate(undefined, { onSuccess: view.noteAsked })}\n * …\n * />\n *\n * **Bounded,** as `useCheckoutProcessing` is (FLW-6): after `timeoutMs` (10 minutes)\n * with no move of the row the request is dropped — the polling stops and the page shows\n * the row as it is. Unbounded, a webhook that never landed kept kastlan re-reading the\n * overview every 3 s for as long as the page was open, which also kept a Cloud Run\n * instance awake, and the plan line said \"Cancels on …\" for a row that never moved.\n */\nexport function useCancelRequest({ timeoutMs = WAIT_FOR }: { timeoutMs?: number } = {}): CancelRequest {\n // What was asked, kept with the row's state it was asked against and when.\n const [asked, setAsked] = useState<{ scheduled: boolean; at: number } | null>(null);\n useEffect(() => {\n if (asked === null) return;\n // Only THIS ask: one that a later ask or the row has replaced is not dropped twice.\n const timer = setTimeout(\n () => setAsked((current) => (current === asked ? null : current)),\n Math.max(0, asked.at + timeoutMs - Date.now()),\n );\n return () => clearTimeout(timer);\n }, [asked, timeoutMs]);\n const view = (row: boolean | null | undefined): CancelRequestView => {\n // Not read yet: nothing to show against, and nothing to drop.\n if (row === undefined || row === null) return { requested: null, cancelScheduled: false, noteAsked: () => {} };\n // The row has moved: it says the rest. Adjusted during render, React's pattern for\n // state that follows a prop.\n if (asked !== null && asked.scheduled !== row) setAsked(null);\n const open = asked !== null && asked.scheduled === row;\n return {\n requested: open ? (row ? \"resume\" : \"cancel\") : null,\n cancelScheduled: open ? !row : row,\n noteAsked: () => {\n const at = Date.now();\n setAsked((a) => (a !== null && a.scheduled === row ? null : { scheduled: row, at }));\n },\n };\n };\n return { waiting: asked !== null, view };\n}\n"],"mappings":";AAAA,SAAS,WAAW,gBAAgB;AAWpC,MAAM,WAAW,KAAK;AAwDf,SAAS,iBAAiB,EAAE,YAAY,SAAS,IAA4B,CAAC,GAAkB;AAErG,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAoD,IAAI;AAClF,YAAU,MAAM;AACd,QAAI,UAAU,KAAM;AAEpB,UAAM,QAAQ;AAAA,MACZ,MAAM,SAAS,CAAC,YAAa,YAAY,QAAQ,OAAO,OAAQ;AAAA,MAChE,KAAK,IAAI,GAAG,MAAM,KAAK,YAAY,KAAK,IAAI,CAAC;AAAA,IAC/C;AACA,WAAO,MAAM,aAAa,KAAK;AAAA,EACjC,GAAG,CAAC,OAAO,SAAS,CAAC;AACrB,QAAM,OAAO,CAAC,QAAuD;AAEnE,QAAI,QAAQ,UAAa,QAAQ,KAAM,QAAO,EAAE,WAAW,MAAM,iBAAiB,OAAO,WAAW,MAAM;AAAA,IAAC,EAAE;AAG7G,QAAI,UAAU,QAAQ,MAAM,cAAc,IAAK,UAAS,IAAI;AAC5D,UAAM,OAAO,UAAU,QAAQ,MAAM,cAAc;AACnD,WAAO;AAAA,MACL,WAAW,OAAQ,MAAM,WAAW,WAAY;AAAA,MAChD,iBAAiB,OAAO,CAAC,MAAM;AAAA,MAC/B,WAAW,MAAM;AACf,cAAM,KAAK,KAAK,IAAI;AACpB,iBAAS,CAAC,MAAO,MAAM,QAAQ,EAAE,cAAc,MAAM,OAAO,EAAE,WAAW,KAAK,GAAG,CAAE;AAAA,MACrF;AAAA,IACF;AAAA,EACF;AACA,SAAO,EAAE,SAAS,UAAU,MAAM,KAAK;AACzC;","names":[]}
@@ -11,8 +11,8 @@ export interface CancelSubscriptionConfirmProps {
11
11
  periodEnd: DateInput;
12
12
  /** Confirm: the app's `POST /billing/cancel` (server-kit 0.14, §16.3). */
13
13
  onConfirm: () => void;
14
- /** The cancel is under way: Confirm's spinner, no second press, and Escape and Back
15
- * don't close. */
14
+ /** The cancel is under way: Confirm's spinner, no second press, and neither Escape,
15
+ * the backdrop nor Back closes it. */
16
16
  pending?: boolean;
17
17
  /** The date's locale. Default: the provider's. */
18
18
  locale?: string;
@@ -25,13 +25,8 @@ function CancelSubscriptionConfirm({
25
25
  open,
26
26
  title: labels.cancelConfirmTitle,
27
27
  description: labels.cancelConfirmBody(formatDate(periodEnd, "medium", { locale })),
28
- onClose: () => {
29
- if (!pending) onOpenChange(false);
30
- },
31
- onKeyDown: (e) => {
32
- if (pending && e.key === "Escape") e.preventDefault();
33
- },
34
- "aria-busy": pending || void 0,
28
+ onClose: () => onOpenChange(false),
29
+ busy: pending,
35
30
  "data-cancel-subscription-confirm": "",
36
31
  actions: (close) => /* @__PURE__ */ jsxs(Fragment, { children: [
37
32
  /* @__PURE__ */ jsx(Button, { variant: "secondary", onClick: close, disabled: pending, children: common.back }),
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/billing/cancel-subscription.tsx"],"sourcesContent":["import type { KeyboardEvent } from \"react\";\n\nimport { useKitLabels, useKitLocale } from \"../i18n/kit-labels\";\nimport type { LabelOverride } from \"../i18n/kit-labels\";\nimport { DEFAULT_COMMON_LABELS } from \"../i18n/shared-labels\";\nimport { formatDate } from \"../lib/format\";\nimport type { DateInput } from \"../lib/format\";\nimport { Button } from \"../components/button\";\nimport { DialogFrame } from \"../components/dialog-frame\";\nimport { COMMIT_EXCEPT_BILLING } from \"../components/write-lock\";\nimport { useBillingLabels } from \"./billing-labels\";\nimport type { BillingLabels } from \"./billing-labels\";\n\nexport interface CancelSubscriptionConfirmProps {\n open: boolean;\n /** `false` when the admin dismisses it — Back, Escape, the backdrop. Confirm leaves it\n * open: close it once `POST /billing/cancel` answers. */\n onOpenChange: (open: boolean) => void;\n /** The overview's `current_period_end`: the day the subscription ends. Formatted with\n * the kit's date formatter (`formatDate`, `\"medium\"`). */\n periodEnd: DateInput;\n /** Confirm: the app's `POST /billing/cancel` (server-kit 0.14, §16.3). */\n onConfirm: () => void;\n /** The cancel is under way: Confirm's spinner, no second press, and Escape and Back\n * don't close. */\n pending?: boolean;\n /** The date's locale. Default: the provider's. */\n locale?: string;\n /** Prop > `<UiKitProvider labels={{ billing }}>` > English. */\n labels?: LabelOverride<BillingLabels>;\n}\n\n/**\n * The in-app cancel's confirm (docs/billing-harmonization.md §16.2 decision 2, §16.4,\n * ui-kit 0.42): \"Cancel subscription\" for an admin the provider's portal doesn't open for\n * (`SubscriptionActions`' `portal=\"buyer_only\"` → `onCancelInApp`). It names the day the\n * subscription ends — \"Your subscription ends on 9 Nov 2026. Until then everything works\n * as before.\" — and its button reads \"Cancel subscription now\" (German \"Jetzt kündigen\",\n * the confirmation button of §312k BGB).\n *\n * <CancelSubscriptionConfirm\n * open={asking}\n * onOpenChange={setAsking}\n * periodEnd={overview.current_period_end}\n * pending={cancel.isPending}\n * onConfirm={() => cancel.mutate()}\n * />\n *\n * The cancel is at the period's end (Paddle's `cancel`, not immediately); the row follows\n * by webhook (`cancel_at_period_end`), as after a cancel in the portal, and\n * `SubscriptionActions` then offers \"Keep subscription\" (`onResume`).\n *\n * It takes no billing lock (`COMMIT_EXCEPT_BILLING` built in, §3.3): a read-only payer\n * may always cancel. A demo lock still holds it, as the server's demo guard does. The kit\n * sends no request.\n */\nexport function CancelSubscriptionConfirm({\n open,\n onOpenChange,\n periodEnd,\n onConfirm,\n pending = false,\n locale: localeProp,\n labels: labelsProp,\n}: CancelSubscriptionConfirmProps) {\n const labels = useBillingLabels(labelsProp);\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const locale = useKitLocale(localeProp);\n\n return (\n <DialogFrame\n open={open}\n title={labels.cancelConfirmTitle}\n description={labels.cancelConfirmBody(formatDate(periodEnd, \"medium\", { locale }))}\n onClose={() => {\n if (!pending) onOpenChange(false);\n }}\n onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {\n if (pending && e.key === \"Escape\") e.preventDefault();\n }}\n aria-busy={pending || undefined}\n data-cancel-subscription-confirm=\"\"\n actions={(close) => (\n <>\n <Button variant=\"secondary\" onClick={close} disabled={pending}>\n {common.back}\n </Button>\n <Button\n variant=\"danger\"\n commit={COMMIT_EXCEPT_BILLING}\n pending={pending}\n onClick={() => {\n if (!pending) onConfirm();\n }}\n >\n {labels.cancelConfirmAction}\n </Button>\n </>\n )}\n />\n );\n}\n"],"mappings":";AAmFQ,mBACE,KADF;AAjFR,SAAS,cAAc,oBAAoB;AAE3C,SAAS,6BAA6B;AACtC,SAAS,kBAAkB;AAE3B,SAAS,cAAc;AACvB,SAAS,mBAAmB;AAC5B,SAAS,6BAA6B;AACtC,SAAS,wBAAwB;AA8C1B,SAAS,0BAA0B;AAAA,EACxC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,UAAU;AAAA,EACV,QAAQ;AAAA,EACR,QAAQ;AACV,GAAmC;AACjC,QAAM,SAAS,iBAAiB,UAAU;AAC1C,QAAM,SAAS,aAAa,UAAU,qBAAqB;AAC3D,QAAM,SAAS,aAAa,UAAU;AAEtC,SACE;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA,OAAO,OAAO;AAAA,MACd,aAAa,OAAO,kBAAkB,WAAW,WAAW,UAAU,EAAE,OAAO,CAAC,CAAC;AAAA,MACjF,SAAS,MAAM;AACb,YAAI,CAAC,QAAS,cAAa,KAAK;AAAA,MAClC;AAAA,MACA,WAAW,CAAC,MAAqC;AAC/C,YAAI,WAAW,EAAE,QAAQ,SAAU,GAAE,eAAe;AAAA,MACtD;AAAA,MACA,aAAW,WAAW;AAAA,MACtB,oCAAiC;AAAA,MACjC,SAAS,CAAC,UACR,iCACE;AAAA,4BAAC,UAAO,SAAQ,aAAY,SAAS,OAAO,UAAU,SACnD,iBAAO,MACV;AAAA,QACA;AAAA,UAAC;AAAA;AAAA,YACC,SAAQ;AAAA,YACR,QAAQ;AAAA,YACR;AAAA,YACA,SAAS,MAAM;AACb,kBAAI,CAAC,QAAS,WAAU;AAAA,YAC1B;AAAA,YAEC,iBAAO;AAAA;AAAA,QACV;AAAA,SACF;AAAA;AAAA,EAEJ;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../src/billing/cancel-subscription.tsx"],"sourcesContent":["import { useKitLabels, useKitLocale } from \"../i18n/kit-labels\";\nimport type { LabelOverride } from \"../i18n/kit-labels\";\nimport { DEFAULT_COMMON_LABELS } from \"../i18n/shared-labels\";\nimport { formatDate } from \"../lib/format\";\nimport type { DateInput } from \"../lib/format\";\nimport { Button } from \"../components/button\";\nimport { DialogFrame } from \"../components/dialog-frame\";\nimport { COMMIT_EXCEPT_BILLING } from \"../components/write-lock\";\nimport { useBillingLabels } from \"./billing-labels\";\nimport type { BillingLabels } from \"./billing-labels\";\n\nexport interface CancelSubscriptionConfirmProps {\n open: boolean;\n /** `false` when the admin dismisses it — Back, Escape, the backdrop. Confirm leaves it\n * open: close it once `POST /billing/cancel` answers. */\n onOpenChange: (open: boolean) => void;\n /** The overview's `current_period_end`: the day the subscription ends. Formatted with\n * the kit's date formatter (`formatDate`, `\"medium\"`). */\n periodEnd: DateInput;\n /** Confirm: the app's `POST /billing/cancel` (server-kit 0.14, §16.3). */\n onConfirm: () => void;\n /** The cancel is under way: Confirm's spinner, no second press, and neither Escape,\n * the backdrop nor Back closes it. */\n pending?: boolean;\n /** The date's locale. Default: the provider's. */\n locale?: string;\n /** Prop > `<UiKitProvider labels={{ billing }}>` > English. */\n labels?: LabelOverride<BillingLabels>;\n}\n\n/**\n * The in-app cancel's confirm (docs/billing-harmonization.md §16.2 decision 2, §16.4,\n * ui-kit 0.42): \"Cancel subscription\" for an admin the provider's portal doesn't open for\n * (`SubscriptionActions`' `portal=\"buyer_only\"` → `onCancelInApp`). It names the day the\n * subscription ends — \"Your subscription ends on 9 Nov 2026. Until then everything works\n * as before.\" — and its button reads \"Cancel subscription now\" (German \"Jetzt kündigen\",\n * the confirmation button of §312k BGB).\n *\n * <CancelSubscriptionConfirm\n * open={asking}\n * onOpenChange={setAsking}\n * periodEnd={overview.current_period_end}\n * pending={cancel.isPending}\n * onConfirm={() => cancel.mutate()}\n * />\n *\n * The cancel is at the period's end (Paddle's `cancel`, not immediately); the row follows\n * by webhook (`cancel_at_period_end`), as after a cancel in the portal, and\n * `SubscriptionActions` then offers \"Keep subscription\" (`onResume`).\n *\n * It takes no billing lock (`COMMIT_EXCEPT_BILLING` built in, §3.3): a read-only payer\n * may always cancel. A demo lock still holds it, as the server's demo guard does. The kit\n * sends no request.\n */\nexport function CancelSubscriptionConfirm({\n open,\n onOpenChange,\n periodEnd,\n onConfirm,\n pending = false,\n locale: localeProp,\n labels: labelsProp,\n}: CancelSubscriptionConfirmProps) {\n const labels = useBillingLabels(labelsProp);\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const locale = useKitLocale(localeProp);\n\n return (\n <DialogFrame\n open={open}\n title={labels.cancelConfirmTitle}\n description={labels.cancelConfirmBody(formatDate(periodEnd, \"medium\", { locale }))}\n onClose={() => onOpenChange(false)}\n busy={pending}\n data-cancel-subscription-confirm=\"\"\n actions={(close) => (\n <>\n <Button variant=\"secondary\" onClick={close} disabled={pending}>\n {common.back}\n </Button>\n <Button\n variant=\"danger\"\n commit={COMMIT_EXCEPT_BILLING}\n pending={pending}\n onClick={() => {\n if (!pending) onConfirm();\n }}\n >\n {labels.cancelConfirmAction}\n </Button>\n </>\n )}\n />\n );\n}\n"],"mappings":";AA4EQ,mBACE,KADF;AA5ER,SAAS,cAAc,oBAAoB;AAE3C,SAAS,6BAA6B;AACtC,SAAS,kBAAkB;AAE3B,SAAS,cAAc;AACvB,SAAS,mBAAmB;AAC5B,SAAS,6BAA6B;AACtC,SAAS,wBAAwB;AA8C1B,SAAS,0BAA0B;AAAA,EACxC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,UAAU;AAAA,EACV,QAAQ;AAAA,EACR,QAAQ;AACV,GAAmC;AACjC,QAAM,SAAS,iBAAiB,UAAU;AAC1C,QAAM,SAAS,aAAa,UAAU,qBAAqB;AAC3D,QAAM,SAAS,aAAa,UAAU;AAEtC,SACE;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA,OAAO,OAAO;AAAA,MACd,aAAa,OAAO,kBAAkB,WAAW,WAAW,UAAU,EAAE,OAAO,CAAC,CAAC;AAAA,MACjF,SAAS,MAAM,aAAa,KAAK;AAAA,MACjC,MAAM;AAAA,MACN,oCAAiC;AAAA,MACjC,SAAS,CAAC,UACR,iCACE;AAAA,4BAAC,UAAO,SAAQ,aAAY,SAAS,OAAO,UAAU,SACnD,iBAAO,MACV;AAAA,QACA;AAAA,UAAC;AAAA;AAAA,YACC,SAAQ;AAAA,YACR,QAAQ;AAAA,YACR;AAAA,YACA,SAAS,MAAM;AACb,kBAAI,CAAC,QAAS,WAAU;AAAA,YAC1B;AAAA,YAEC,iBAAO;AAAA;AAAA,QACV;AAAA,SACF;AAAA;AAAA,EAEJ;AAEJ;","names":[]}
@@ -33,8 +33,8 @@ export interface PlanSwitchConfirmProps {
33
33
  /** `POST /billing/plan/preview`'s answer. Undefined while it loads: a spinner, and
34
34
  * Confirm is held. */
35
35
  preview?: PlanSwitchPreview;
36
- /** `POST /billing/plan` is under way: Confirm's spinner, no second press, and Escape
37
- * doesn't close. */
36
+ /** `POST /billing/plan` is under way: Confirm's spinner, no second press, and neither
37
+ * Escape, the backdrop nor Back closes it. */
38
38
  pending?: boolean;
39
39
  /** The plan's display name, the app's (§3.1). Default: the code. */
40
40
  planName?: (code: string) => string;
@@ -53,10 +53,8 @@ function PlanSwitchConfirm({
53
53
  title: labels.switchTitle,
54
54
  description: labels.switchFromTo(side(from), side(to)),
55
55
  onClose: () => onOpenChange(false),
56
- onKeyDown: (e) => {
57
- if (pending && e.key === "Escape") e.preventDefault();
58
- },
59
- "aria-busy": preview === void 0 || pending || void 0,
56
+ busy: pending,
57
+ "aria-busy": preview === void 0 || void 0,
60
58
  actions: (close) => /* @__PURE__ */ jsxs(Fragment, { children: [
61
59
  /* @__PURE__ */ jsx(Button, { variant: "secondary", onClick: close, disabled: pending, children: common.cancel }),
62
60
  /* @__PURE__ */ jsx(
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/billing/plan-switch.tsx"],"sourcesContent":["import type { KeyboardEvent } from \"react\";\n\nimport { useKitLabels, useKitLocale } from \"../i18n/kit-labels\";\nimport type { LabelOverride } from \"../i18n/kit-labels\";\nimport { DEFAULT_COMMON_LABELS } from \"../i18n/shared-labels\";\nimport { pluralForm } from \"../i18n/plurals\";\nimport type { PluralForms } from \"../i18n/plurals\";\nimport { formatDate, formatMoney, formatNumber } from \"../lib/format\";\nimport { AlertBanner } from \"../components/alert-banner\";\nimport { Button } from \"../components/button\";\nimport { DialogFrame } from \"../components/dialog-frame\";\nimport { Spinner } from \"../components/spinner\";\nimport { COMMIT_EXCEPT_BILLING } from \"../components/write-lock\";\nimport { DEFAULT_PLAN_CHANGE_LABELS } from \"../admin/plan-change-labels\";\nimport { useBillingLabels } from \"./billing-labels\";\nimport type { BillingLabels } from \"./billing-labels\";\nimport type { PlanSwitchPreview } from \"./billing-overview\";\nimport { minorToMajor } from \"./plan-price\";\nimport type { BillingInterval } from \"./plan-price\";\n\n/**\n * The payer's plan switch (docs/billing-harmonization.md §15.2.10, ui-kit 0.39): built\n * once in the kits and used by all three apps, instead of Paddle's portal. While a\n * provider subscription runs, the app sends a `PlanPicker` pick to\n * `POST /billing/plan/preview` and opens this dialog with the answer; Confirm is the\n * app's `POST /billing/plan`. Otherwise the pick goes to the checkout, as before.\n *\n * The switch is immediate and prorated: an upgrade charges the difference now, a\n * downgrade credits the unused part to the customer's balance, which Paddle takes off\n * the next charges. So the payer agrees to a figure — Paddle's preview — and sees the\n * dimensions over the new plan's limit before agreeing (§3.4: a limit gates creation\n * only, so the switch is allowed).\n */\n\n/** One side of a switch: a plan code and its billing period. */\nexport interface PlanSwitchSide {\n plan: string;\n interval: BillingInterval;\n}\n\nexport interface PlanSwitchConfirmProps {\n open: boolean;\n /** `false` when the payer dismisses it — Cancel, Escape, the backdrop. Confirm leaves\n * it open: close it once the switch answers. */\n onOpenChange: (open: boolean) => void;\n /** What the payer pays now: the overview's `paid`. */\n from: PlanSwitchSide;\n /** The pick. */\n to: PlanSwitchSide;\n /** `POST /billing/plan/preview`'s answer. Undefined while it loads: a spinner, and\n * Confirm is held. */\n preview?: PlanSwitchPreview;\n /** `POST /billing/plan` is under way: Confirm's spinner, no second press, and Escape\n * doesn't close. */\n pending?: boolean;\n /** The plan's display name, the app's (§3.1). Default: the code. */\n planName?: (code: string) => string;\n /**\n * The app's word per dimension, for the over-limit lines. A heading's word (\"Budgets\")\n * reads \"Budgets: 7 of 5\" (`billing.limitUsageLine`); its plural forms\n * (`{ one: \"budget\", other: \"budgets\" }`) read \"7 of 5 budgets\" (`planChange.usageOf`,\n * the form the limit takes). A dimension without one is named by its key.\n */\n dimensionLabels?: Readonly<Record<string, string | PluralForms>>;\n /** How a usage or limit figure is written. Default: a number in the locale. */\n formatLimit?: (dimension: string, value: number) => string;\n /** Confirm: the app's `POST /billing/plan` with `{plan, interval}` of `to`. */\n onConfirm: () => void;\n /** The figures' and the date's locale. Default: the provider's. */\n locale?: string;\n /**\n * 0.42 (§16.2 decision 5, §16.8 B): the payer's free grant runs without an end —\n * `isPermanentGrant(overview)`. The switch changes the PAID plan, not the applied one\n * (§12.11), so the dialog says so above its figures (`grantWarningTitle`,\n * `grantWarningBody`) before the payer agrees. Confirm stays Confirm: the warning is\n * this dialog's own, and `PlanPicker` — told `switching` — shows none of its own on\n * the way here, so the payer reads it once.\n */\n permanentGrant?: boolean;\n /** Prop > `<UiKitProvider labels={{ billing }}>` > English. */\n labels?: LabelOverride<BillingLabels>;\n}\n\n/**\n * The confirm for a plan switch (§15.2.10): from → to, each with its billing period;\n * Paddle's preview — \"Charged now\" (an upgrade) or \"Credited towards your next charges\"\n * (a downgrade), and the next charge with its date — in the subscription's currency;\n * the dimensions over the new plan's limit; and Confirm.\n *\n * const preview = useMutation({ mutationFn: api.previewPlanSwitch });\n * <PlanSwitchConfirm\n * open={pick !== null}\n * onOpenChange={(open) => !open && setPick(null)}\n * from={overview.paid}\n * to={pick}\n * preview={preview.data}\n * pending={switching.isPending}\n * planName={planName}\n * onConfirm={() => switching.mutate(pick)}\n * />\n *\n * It takes no billing lock (`COMMIT_EXCEPT_BILLING` built in): a read-only payer may\n * change plan, as they may pay. A demo lock still holds it, as the server's demo guard\n * does. The kit sends no request.\n */\nexport function PlanSwitchConfirm({\n open,\n onOpenChange,\n from,\n to,\n preview,\n pending = false,\n planName = (code) => code,\n dimensionLabels,\n formatLimit,\n onConfirm,\n locale: localeProp,\n permanentGrant = false,\n labels: labelsProp,\n}: PlanSwitchConfirmProps) {\n const labels = useBillingLabels(labelsProp);\n const planChange = useKitLabels(\"planChange\", DEFAULT_PLAN_CHANGE_LABELS);\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const locale = useKitLocale(localeProp);\n\n const period = (interval: BillingInterval) => (interval === \"year\" ? labels.yearly : labels.monthly);\n const side = (s: PlanSwitchSide) => `${planName(s.plan.toLowerCase())} (${period(s.interval)})`;\n const money = (minor: number, currency: string) => formatMoney(minorToMajor(minor, currency), currency, { locale });\n const figure = (dimension: string, value: number) =>\n formatLimit ? formatLimit(dimension, value) : formatNumber(value, { locale });\n\n /** One line per dimension over the new limit, in the word's own sentence. */\n const overLimit = (p: PlanSwitchPreview): string[] =>\n (p.over_limit ?? []).flatMap((dimension) => {\n const limit = p.limits?.[dimension];\n const used = p.usage?.[dimension];\n if (limit === null || limit === undefined || used === undefined) return [];\n const word = dimensionLabels?.[dimension] ?? dimension;\n return [\n typeof word === \"string\"\n ? labels.limitUsageLine(word, figure(dimension, used), figure(dimension, limit))\n : planChange.usageOf(figure(dimension, used), figure(dimension, limit), pluralForm(limit, word, locale)),\n ];\n });\n\n const credit = preview !== undefined && preview.now_minor < 0;\n const lines = preview ? overLimit(preview) : [];\n\n return (\n <DialogFrame\n open={open}\n title={labels.switchTitle}\n description={labels.switchFromTo(side(from), side(to))}\n onClose={() => onOpenChange(false)}\n onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {\n if (pending && e.key === \"Escape\") e.preventDefault();\n }}\n aria-busy={preview === undefined || pending || undefined}\n actions={(close) => (\n <>\n <Button variant=\"secondary\" onClick={close} disabled={pending}>\n {common.cancel}\n </Button>\n <Button\n commit={COMMIT_EXCEPT_BILLING}\n pending={pending}\n disabled={preview === undefined}\n onClick={() => {\n if (preview !== undefined && !pending) onConfirm();\n }}\n >\n {labels.switchConfirm}\n </Button>\n </>\n )}\n >\n {permanentGrant && (\n <AlertBanner tone=\"warning\" size=\"sm\" data-grant-warning=\"\">\n <strong className=\"block font-medium\">{labels.grantWarningTitle}</strong> {labels.grantWarningBody}\n </AlertBanner>\n )}\n {preview === undefined ? (\n <div className=\"flex justify-center py-4\">\n <Spinner showLabel />\n </div>\n ) : (\n <>\n <dl className=\"grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-1 text-sm\" data-plan-switch-preview>\n <dt className=\"text-secondary\">{credit ? labels.switchCredited : labels.switchChargedNow}</dt>\n <dd className=\"text-right font-medium tabular-nums\" data-now={credit ? \"credit\" : \"charge\"}>\n {money(Math.abs(preview.now_minor), preview.currency)}\n </dd>\n {preview.next_billed_at && preview.next_minor != null && (\n <>\n <dt className=\"text-secondary\">\n {labels.switchNextCharge(formatDate(preview.next_billed_at, \"medium\", { locale }))}\n </dt>\n <dd className=\"text-right tabular-nums\">{money(preview.next_minor, preview.currency)}</dd>\n </>\n )}\n </dl>\n {lines.length > 0 && (\n <div className=\"space-y-1 text-sm\" data-plan-switch-over-limit>\n <p>{planChange.overLimit}</p>\n <ul className=\"list-disc space-y-0.5 ps-5 text-secondary\">\n {lines.map((line) => (\n <li key={line}>{line}</li>\n ))}\n </ul>\n </div>\n )}\n </>\n )}\n </DialogFrame>\n );\n}\n"],"mappings":";AA+JQ,mBACE,KADF;AA7JR,SAAS,cAAc,oBAAoB;AAE3C,SAAS,6BAA6B;AACtC,SAAS,kBAAkB;AAE3B,SAAS,YAAY,aAAa,oBAAoB;AACtD,SAAS,mBAAmB;AAC5B,SAAS,cAAc;AACvB,SAAS,mBAAmB;AAC5B,SAAS,eAAe;AACxB,SAAS,6BAA6B;AACtC,SAAS,kCAAkC;AAC3C,SAAS,wBAAwB;AAGjC,SAAS,oBAAoB;AAwFtB,SAAS,kBAAkB;AAAA,EAChC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,UAAU;AAAA,EACV,WAAW,CAAC,SAAS;AAAA,EACrB;AAAA,EACA;AAAA,EACA;AAAA,EACA,QAAQ;AAAA,EACR,iBAAiB;AAAA,EACjB,QAAQ;AACV,GAA2B;AACzB,QAAM,SAAS,iBAAiB,UAAU;AAC1C,QAAM,aAAa,aAAa,cAAc,0BAA0B;AACxE,QAAM,SAAS,aAAa,UAAU,qBAAqB;AAC3D,QAAM,SAAS,aAAa,UAAU;AAEtC,QAAM,SAAS,CAAC,aAA+B,aAAa,SAAS,OAAO,SAAS,OAAO;AAC5F,QAAM,OAAO,CAAC,MAAsB,GAAG,SAAS,EAAE,KAAK,YAAY,CAAC,CAAC,KAAK,OAAO,EAAE,QAAQ,CAAC;AAC5F,QAAM,QAAQ,CAAC,OAAe,aAAqB,YAAY,aAAa,OAAO,QAAQ,GAAG,UAAU,EAAE,OAAO,CAAC;AAClH,QAAM,SAAS,CAAC,WAAmB,UACjC,cAAc,YAAY,WAAW,KAAK,IAAI,aAAa,OAAO,EAAE,OAAO,CAAC;AAG9E,QAAM,YAAY,CAAC,OAChB,EAAE,cAAc,CAAC,GAAG,QAAQ,CAAC,cAAc;AAC1C,UAAM,QAAQ,EAAE,SAAS,SAAS;AAClC,UAAM,OAAO,EAAE,QAAQ,SAAS;AAChC,QAAI,UAAU,QAAQ,UAAU,UAAa,SAAS,OAAW,QAAO,CAAC;AACzE,UAAM,OAAO,kBAAkB,SAAS,KAAK;AAC7C,WAAO;AAAA,MACL,OAAO,SAAS,WACZ,OAAO,eAAe,MAAM,OAAO,WAAW,IAAI,GAAG,OAAO,WAAW,KAAK,CAAC,IAC7E,WAAW,QAAQ,OAAO,WAAW,IAAI,GAAG,OAAO,WAAW,KAAK,GAAG,WAAW,OAAO,MAAM,MAAM,CAAC;AAAA,IAC3G;AAAA,EACF,CAAC;AAEH,QAAM,SAAS,YAAY,UAAa,QAAQ,YAAY;AAC5D,QAAM,QAAQ,UAAU,UAAU,OAAO,IAAI,CAAC;AAE9C,SACE;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA,OAAO,OAAO;AAAA,MACd,aAAa,OAAO,aAAa,KAAK,IAAI,GAAG,KAAK,EAAE,CAAC;AAAA,MACrD,SAAS,MAAM,aAAa,KAAK;AAAA,MACjC,WAAW,CAAC,MAAqC;AAC/C,YAAI,WAAW,EAAE,QAAQ,SAAU,GAAE,eAAe;AAAA,MACtD;AAAA,MACA,aAAW,YAAY,UAAa,WAAW;AAAA,MAC/C,SAAS,CAAC,UACR,iCACE;AAAA,4BAAC,UAAO,SAAQ,aAAY,SAAS,OAAO,UAAU,SACnD,iBAAO,QACV;AAAA,QACA;AAAA,UAAC;AAAA;AAAA,YACC,QAAQ;AAAA,YACR;AAAA,YACA,UAAU,YAAY;AAAA,YACtB,SAAS,MAAM;AACb,kBAAI,YAAY,UAAa,CAAC,QAAS,WAAU;AAAA,YACnD;AAAA,YAEC,iBAAO;AAAA;AAAA,QACV;AAAA,SACF;AAAA,MAGD;AAAA,0BACC,qBAAC,eAAY,MAAK,WAAU,MAAK,MAAK,sBAAmB,IACvD;AAAA,8BAAC,YAAO,WAAU,qBAAqB,iBAAO,mBAAkB;AAAA,UAAS;AAAA,UAAE,OAAO;AAAA,WACpF;AAAA,QAED,YAAY,SACX,oBAAC,SAAI,WAAU,4BACb,8BAAC,WAAQ,WAAS,MAAC,GACrB,IAEA,iCACE;AAAA,+BAAC,QAAG,WAAU,+DAA8D,4BAAwB,MAClG;AAAA,gCAAC,QAAG,WAAU,kBAAkB,mBAAS,OAAO,iBAAiB,OAAO,kBAAiB;AAAA,YACzF,oBAAC,QAAG,WAAU,uCAAsC,YAAU,SAAS,WAAW,UAC/E,gBAAM,KAAK,IAAI,QAAQ,SAAS,GAAG,QAAQ,QAAQ,GACtD;AAAA,YACC,QAAQ,kBAAkB,QAAQ,cAAc,QAC/C,iCACE;AAAA,kCAAC,QAAG,WAAU,kBACX,iBAAO,iBAAiB,WAAW,QAAQ,gBAAgB,UAAU,EAAE,OAAO,CAAC,CAAC,GACnF;AAAA,cACA,oBAAC,QAAG,WAAU,2BAA2B,gBAAM,QAAQ,YAAY,QAAQ,QAAQ,GAAE;AAAA,eACvF;AAAA,aAEJ;AAAA,UACC,MAAM,SAAS,KACd,qBAAC,SAAI,WAAU,qBAAoB,+BAA2B,MAC5D;AAAA,gCAAC,OAAG,qBAAW,WAAU;AAAA,YACzB,oBAAC,QAAG,WAAU,6CACX,gBAAM,IAAI,CAAC,SACV,oBAAC,QAAe,kBAAP,IAAY,CACtB,GACH;AAAA,aACF;AAAA,WAEJ;AAAA;AAAA;AAAA,EAEJ;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../src/billing/plan-switch.tsx"],"sourcesContent":["import { useKitLabels, useKitLocale } from \"../i18n/kit-labels\";\nimport type { LabelOverride } from \"../i18n/kit-labels\";\nimport { DEFAULT_COMMON_LABELS } from \"../i18n/shared-labels\";\nimport { pluralForm } from \"../i18n/plurals\";\nimport type { PluralForms } from \"../i18n/plurals\";\nimport { formatDate, formatMoney, formatNumber } from \"../lib/format\";\nimport { AlertBanner } from \"../components/alert-banner\";\nimport { Button } from \"../components/button\";\nimport { DialogFrame } from \"../components/dialog-frame\";\nimport { Spinner } from \"../components/spinner\";\nimport { COMMIT_EXCEPT_BILLING } from \"../components/write-lock\";\nimport { DEFAULT_PLAN_CHANGE_LABELS } from \"../admin/plan-change-labels\";\nimport { useBillingLabels } from \"./billing-labels\";\nimport type { BillingLabels } from \"./billing-labels\";\nimport type { PlanSwitchPreview } from \"./billing-overview\";\nimport { minorToMajor } from \"./plan-price\";\nimport type { BillingInterval } from \"./plan-price\";\n\n/**\n * The payer's plan switch (docs/billing-harmonization.md §15.2.10, ui-kit 0.39): built\n * once in the kits and used by all three apps, instead of Paddle's portal. While a\n * provider subscription runs, the app sends a `PlanPicker` pick to\n * `POST /billing/plan/preview` and opens this dialog with the answer; Confirm is the\n * app's `POST /billing/plan`. Otherwise the pick goes to the checkout, as before.\n *\n * The switch is immediate and prorated: an upgrade charges the difference now, a\n * downgrade credits the unused part to the customer's balance, which Paddle takes off\n * the next charges. So the payer agrees to a figure — Paddle's preview — and sees the\n * dimensions over the new plan's limit before agreeing (§3.4: a limit gates creation\n * only, so the switch is allowed).\n */\n\n/** One side of a switch: a plan code and its billing period. */\nexport interface PlanSwitchSide {\n plan: string;\n interval: BillingInterval;\n}\n\nexport interface PlanSwitchConfirmProps {\n open: boolean;\n /** `false` when the payer dismisses it — Cancel, Escape, the backdrop. Confirm leaves\n * it open: close it once the switch answers. */\n onOpenChange: (open: boolean) => void;\n /** What the payer pays now: the overview's `paid`. */\n from: PlanSwitchSide;\n /** The pick. */\n to: PlanSwitchSide;\n /** `POST /billing/plan/preview`'s answer. Undefined while it loads: a spinner, and\n * Confirm is held. */\n preview?: PlanSwitchPreview;\n /** `POST /billing/plan` is under way: Confirm's spinner, no second press, and neither\n * Escape, the backdrop nor Back closes it. */\n pending?: boolean;\n /** The plan's display name, the app's (§3.1). Default: the code. */\n planName?: (code: string) => string;\n /**\n * The app's word per dimension, for the over-limit lines. A heading's word (\"Budgets\")\n * reads \"Budgets: 7 of 5\" (`billing.limitUsageLine`); its plural forms\n * (`{ one: \"budget\", other: \"budgets\" }`) read \"7 of 5 budgets\" (`planChange.usageOf`,\n * the form the limit takes). A dimension without one is named by its key.\n */\n dimensionLabels?: Readonly<Record<string, string | PluralForms>>;\n /** How a usage or limit figure is written. Default: a number in the locale. */\n formatLimit?: (dimension: string, value: number) => string;\n /** Confirm: the app's `POST /billing/plan` with `{plan, interval}` of `to`. */\n onConfirm: () => void;\n /** The figures' and the date's locale. Default: the provider's. */\n locale?: string;\n /**\n * 0.42 (§16.2 decision 5, §16.8 B): the payer's free grant runs without an end —\n * `isPermanentGrant(overview)`. The switch changes the PAID plan, not the applied one\n * (§12.11), so the dialog says so above its figures (`grantWarningTitle`,\n * `grantWarningBody`) before the payer agrees. Confirm stays Confirm: the warning is\n * this dialog's own, and `PlanPicker` — told `switching` — shows none of its own on\n * the way here, so the payer reads it once.\n */\n permanentGrant?: boolean;\n /** Prop > `<UiKitProvider labels={{ billing }}>` > English. */\n labels?: LabelOverride<BillingLabels>;\n}\n\n/**\n * The confirm for a plan switch (§15.2.10): from → to, each with its billing period;\n * Paddle's preview — \"Charged now\" (an upgrade) or \"Credited towards your next charges\"\n * (a downgrade), and the next charge with its date — in the subscription's currency;\n * the dimensions over the new plan's limit; and Confirm.\n *\n * const preview = useMutation({ mutationFn: api.previewPlanSwitch });\n * <PlanSwitchConfirm\n * open={pick !== null}\n * onOpenChange={(open) => !open && setPick(null)}\n * from={overview.paid}\n * to={pick}\n * preview={preview.data}\n * pending={switching.isPending}\n * planName={planName}\n * onConfirm={() => switching.mutate(pick)}\n * />\n *\n * It takes no billing lock (`COMMIT_EXCEPT_BILLING` built in): a read-only payer may\n * change plan, as they may pay. A demo lock still holds it, as the server's demo guard\n * does. The kit sends no request.\n */\nexport function PlanSwitchConfirm({\n open,\n onOpenChange,\n from,\n to,\n preview,\n pending = false,\n planName = (code) => code,\n dimensionLabels,\n formatLimit,\n onConfirm,\n locale: localeProp,\n permanentGrant = false,\n labels: labelsProp,\n}: PlanSwitchConfirmProps) {\n const labels = useBillingLabels(labelsProp);\n const planChange = useKitLabels(\"planChange\", DEFAULT_PLAN_CHANGE_LABELS);\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const locale = useKitLocale(localeProp);\n\n const period = (interval: BillingInterval) => (interval === \"year\" ? labels.yearly : labels.monthly);\n const side = (s: PlanSwitchSide) => `${planName(s.plan.toLowerCase())} (${period(s.interval)})`;\n const money = (minor: number, currency: string) => formatMoney(minorToMajor(minor, currency), currency, { locale });\n const figure = (dimension: string, value: number) =>\n formatLimit ? formatLimit(dimension, value) : formatNumber(value, { locale });\n\n /** One line per dimension over the new limit, in the word's own sentence. */\n const overLimit = (p: PlanSwitchPreview): string[] =>\n (p.over_limit ?? []).flatMap((dimension) => {\n const limit = p.limits?.[dimension];\n const used = p.usage?.[dimension];\n if (limit === null || limit === undefined || used === undefined) return [];\n const word = dimensionLabels?.[dimension] ?? dimension;\n return [\n typeof word === \"string\"\n ? labels.limitUsageLine(word, figure(dimension, used), figure(dimension, limit))\n : planChange.usageOf(figure(dimension, used), figure(dimension, limit), pluralForm(limit, word, locale)),\n ];\n });\n\n const credit = preview !== undefined && preview.now_minor < 0;\n const lines = preview ? overLimit(preview) : [];\n\n return (\n <DialogFrame\n open={open}\n title={labels.switchTitle}\n description={labels.switchFromTo(side(from), side(to))}\n onClose={() => onOpenChange(false)}\n busy={pending}\n // A preview that loads is busy too, but holds nothing: Cancel stays a way out.\n aria-busy={preview === undefined || undefined}\n actions={(close) => (\n <>\n <Button variant=\"secondary\" onClick={close} disabled={pending}>\n {common.cancel}\n </Button>\n <Button\n commit={COMMIT_EXCEPT_BILLING}\n pending={pending}\n disabled={preview === undefined}\n onClick={() => {\n if (preview !== undefined && !pending) onConfirm();\n }}\n >\n {labels.switchConfirm}\n </Button>\n </>\n )}\n >\n {permanentGrant && (\n <AlertBanner tone=\"warning\" size=\"sm\" data-grant-warning=\"\">\n <strong className=\"block font-medium\">{labels.grantWarningTitle}</strong> {labels.grantWarningBody}\n </AlertBanner>\n )}\n {preview === undefined ? (\n <div className=\"flex justify-center py-4\">\n <Spinner showLabel />\n </div>\n ) : (\n <>\n <dl className=\"grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-1 text-sm\" data-plan-switch-preview>\n <dt className=\"text-secondary\">{credit ? labels.switchCredited : labels.switchChargedNow}</dt>\n <dd className=\"text-right font-medium tabular-nums\" data-now={credit ? \"credit\" : \"charge\"}>\n {money(Math.abs(preview.now_minor), preview.currency)}\n </dd>\n {preview.next_billed_at && preview.next_minor != null && (\n <>\n <dt className=\"text-secondary\">\n {labels.switchNextCharge(formatDate(preview.next_billed_at, \"medium\", { locale }))}\n </dt>\n <dd className=\"text-right tabular-nums\">{money(preview.next_minor, preview.currency)}</dd>\n </>\n )}\n </dl>\n {lines.length > 0 && (\n <div className=\"space-y-1 text-sm\" data-plan-switch-over-limit>\n <p>{planChange.overLimit}</p>\n <ul className=\"list-disc space-y-0.5 ps-5 text-secondary\">\n {lines.map((line) => (\n <li key={line}>{line}</li>\n ))}\n </ul>\n </div>\n )}\n </>\n )}\n </DialogFrame>\n );\n}\n"],"mappings":"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","names":[]}
@@ -234,10 +234,15 @@ export interface AmountInputProps extends FieldHelpProps, DataAttributes {
234
234
  max?: number;
235
235
  /**
236
236
  * The SETTLED figure, dot-decimal, sign included — fired on blur, on Enter, on the
237
- * numpad's "=" and on every result the calculator writes back, never on a keystroke
238
- * ("1200+" is not a figure yet). `onChange` still reports every keystroke; this is
239
- * for a host that stores a number and must not see drafts (kastlan 52; see
240
- * {@link MoneyField}, which is built on it).
237
+ * numpad's "=" and once per calculator session (its "=" or Enter in its display, or
238
+ * the pad closing on a result not committed yet), never on a keystroke ("1200+" is
239
+ * not a figure yet). `onChange` still reports every keystroke and every result the
240
+ * calculator writes back; this is for a host that stores a number and must not see
241
+ * drafts (kastlan 52; see {@link MoneyField}, which is built on it).
242
+ *
243
+ * Refactor audit 2026-10-30, INP-1: every calculator keystroke that resolved used to
244
+ * commit, so "100+50" committed 105 on its way to 150 — keksdose saved both, and its
245
+ * threshold field, `disabled` while it saves, lost the calculator to the first.
241
246
  */
242
247
  onCommit?: (value: string) => void;
243
248
  /**
@@ -227,10 +227,9 @@ function AmountInput(props) {
227
227
  value: derived,
228
228
  onChange: (result, expression) => {
229
229
  setDraft(null);
230
- const settled = settle(result);
231
- handleText(settled, expression);
232
- onCommit?.(settled);
230
+ handleText(settle(result), expression);
233
231
  },
232
+ onCommit: (result) => onCommit?.(settle(result)),
234
233
  className: "px-1.5",
235
234
  "aria-label": labels?.calculatorTrigger,
236
235
  labels: labels?.calculator