@eifi1/ui-kit 0.32.2 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (363) hide show
  1. package/README.md +16 -8
  2. package/dist/account/delete-account-setting.d.ts +10 -10
  3. package/dist/account/delete-account-setting.js +2 -1
  4. package/dist/account/delete-account-setting.js.map +1 -1
  5. package/dist/account/email-change-setting.d.ts +5 -0
  6. package/dist/account/email-change-setting.js +4 -3
  7. package/dist/account/email-change-setting.js.map +1 -1
  8. package/dist/account/sessions-setting.d.ts +4 -0
  9. package/dist/account/sessions-setting.js +3 -2
  10. package/dist/account/sessions-setting.js.map +1 -1
  11. package/dist/admin/admin-action-confirm.d.ts +10 -10
  12. package/dist/admin/admin-action-confirm.js.map +1 -1
  13. package/dist/admin/invitations-panel.d.ts +10 -10
  14. package/dist/admin/invitations-panel.js +4 -3
  15. package/dist/admin/invitations-panel.js.map +1 -1
  16. package/dist/admin/plan-change.d.ts +100 -0
  17. package/dist/admin/plan-change.js +208 -0
  18. package/dist/admin/plan-change.js.map +1 -0
  19. package/dist/admin/reviewer-scope-editor.d.ts +10 -10
  20. package/dist/admin/reviewer-scope-editor.js.map +1 -1
  21. package/dist/admin/role-select.d.ts +7 -7
  22. package/dist/admin/role-select.js.map +1 -1
  23. package/dist/admin/transfer-ownership-dialog.d.ts +10 -10
  24. package/dist/admin/user-roster.d.ts +7 -7
  25. package/dist/admin/user-roster.js +2 -2
  26. package/dist/admin/user-roster.js.map +1 -1
  27. package/dist/auth/auth-errors.d.ts +13 -2
  28. package/dist/auth/auth-errors.js +5 -1
  29. package/dist/auth/auth-errors.js.map +1 -1
  30. package/dist/auth/complete-name-dialog.d.ts +4 -1
  31. package/dist/auth/complete-name-dialog.js +2 -1
  32. package/dist/auth/complete-name-dialog.js.map +1 -1
  33. package/dist/auth/register-form.d.ts +10 -10
  34. package/dist/billing/billing-banner.d.ts +13 -11
  35. package/dist/billing/billing-banner.js.map +1 -1
  36. package/dist/billing/billing-labels.d.ts +10 -10
  37. package/dist/billing/billing-labels.js +10 -1
  38. package/dist/billing/billing-labels.js.map +1 -1
  39. package/dist/billing/billing-lock.d.ts +39 -12
  40. package/dist/billing/billing-lock.js +9 -3
  41. package/dist/billing/billing-lock.js.map +1 -1
  42. package/dist/billing/billing-overview.d.ts +154 -0
  43. package/dist/billing/billing-overview.js +2 -0
  44. package/dist/billing/billing-overview.js.map +1 -0
  45. package/dist/billing/billing-standing.d.ts +10 -10
  46. package/dist/billing/checkout-processing.d.ts +217 -0
  47. package/dist/billing/checkout-processing.js +198 -0
  48. package/dist/billing/checkout-processing.js.map +1 -0
  49. package/dist/billing/checkout-return.d.ts +51 -0
  50. package/dist/billing/checkout-return.js +43 -0
  51. package/dist/billing/checkout-return.js.map +1 -0
  52. package/dist/billing/pay-page.d.ts +210 -0
  53. package/dist/billing/pay-page.js +113 -0
  54. package/dist/billing/pay-page.js.map +1 -0
  55. package/dist/billing/plan-card.d.ts +10 -10
  56. package/dist/billing/plan-limit-notice.d.ts +10 -10
  57. package/dist/billing/plan-limit-toast.d.ts +143 -0
  58. package/dist/billing/plan-limit-toast.js +40 -0
  59. package/dist/billing/plan-limit-toast.js.map +1 -0
  60. package/dist/billing/subscription-actions.d.ts +38 -15
  61. package/dist/billing/subscription-actions.js +9 -2
  62. package/dist/billing/subscription-actions.js.map +1 -1
  63. package/dist/billing/subscription-status-chip.d.ts +9 -9
  64. package/dist/bin/eifi1-pay-page.mjs +166 -0
  65. package/dist/chart.d.ts +10 -10
  66. package/dist/components/account-chips.d.ts +6 -1
  67. package/dist/components/account-settings.d.ts +4 -1
  68. package/dist/components/account-settings.js +2 -1
  69. package/dist/components/account-settings.js.map +1 -1
  70. package/dist/components/amount-input.d.ts +10 -10
  71. package/dist/components/autocomplete.d.ts +2 -1
  72. package/dist/components/autocomplete.js +3 -1
  73. package/dist/components/autocomplete.js.map +1 -1
  74. package/dist/components/button-group.d.ts +10 -10
  75. package/dist/components/button-group.js +1 -1
  76. package/dist/components/button-group.js.map +1 -1
  77. package/dist/components/calculator.d.ts +10 -10
  78. package/dist/components/calculator.js +3 -3
  79. package/dist/components/calculator.js.map +1 -1
  80. package/dist/components/checkbox-group.d.ts +2 -1
  81. package/dist/components/checkbox-group.js.map +1 -1
  82. package/dist/components/checkbox.d.ts +2 -1
  83. package/dist/components/checkbox.js.map +1 -1
  84. package/dist/components/chip.d.ts +2 -1
  85. package/dist/components/chip.js +19 -17
  86. package/dist/components/chip.js.map +1 -1
  87. package/dist/components/choice-card.d.ts +3 -1
  88. package/dist/components/choice-card.js +1 -1
  89. package/dist/components/choice-card.js.map +1 -1
  90. package/dist/components/combobox.d.ts +4 -3
  91. package/dist/components/combobox.js.map +1 -1
  92. package/dist/components/confirm-dialog.d.ts +10 -10
  93. package/dist/components/confirm-dialog.js +6 -3
  94. package/dist/components/confirm-dialog.js.map +1 -1
  95. package/dist/components/copy-button.d.ts +11 -11
  96. package/dist/components/country-select.d.ts +2 -1
  97. package/dist/components/country-select.js.map +1 -1
  98. package/dist/components/danger-confirm.d.ts +10 -10
  99. package/dist/components/danger-confirm.js.map +1 -1
  100. package/dist/components/data-table-cells.d.ts +12 -1
  101. package/dist/components/data-table-filter-popover.d.ts +12 -1
  102. package/dist/components/data-table-filters.d.ts +12 -1
  103. package/dist/components/data-table-labels.d.ts +12 -1
  104. package/dist/components/data-table-pagination.d.ts +12 -1
  105. package/dist/components/data-table.d.ts +12 -1
  106. package/dist/components/data-table.js +3 -3
  107. package/dist/components/data-table.js.map +1 -1
  108. package/dist/components/date-picker.js +6 -4
  109. package/dist/components/date-picker.js.map +1 -1
  110. package/dist/components/entity-combobox.d.ts +3 -2
  111. package/dist/components/entity-combobox.js.map +1 -1
  112. package/dist/components/facing-pair.d.ts +10 -10
  113. package/dist/components/field-parts.d.ts +32 -4
  114. package/dist/components/field-parts.js +54 -19
  115. package/dist/components/field-parts.js.map +1 -1
  116. package/dist/components/file-button.d.ts +10 -10
  117. package/dist/components/file-button.js +3 -2
  118. package/dist/components/file-button.js.map +1 -1
  119. package/dist/components/file-dropzone.d.ts +11 -11
  120. package/dist/components/file-dropzone.js.map +1 -1
  121. package/dist/components/floating-panel.js +5 -5
  122. package/dist/components/floating-panel.js.map +1 -1
  123. package/dist/components/form-actions.d.ts +9 -9
  124. package/dist/components/form-actions.js +3 -3
  125. package/dist/components/form-actions.js.map +1 -1
  126. package/dist/components/iban-input.d.ts +10 -10
  127. package/dist/components/inline-edit-field.d.ts +2 -1
  128. package/dist/components/inline-edit-field.js +6 -6
  129. package/dist/components/inline-edit-field.js.map +1 -1
  130. package/dist/components/language-select.d.ts +10 -10
  131. package/dist/components/legal-page.d.ts +10 -10
  132. package/dist/components/legal.d.ts +10 -10
  133. package/dist/components/list.d.ts +2 -1
  134. package/dist/components/list.js +4 -5
  135. package/dist/components/list.js.map +1 -1
  136. package/dist/components/money-field.d.ts +10 -10
  137. package/dist/components/month-picker.js +54 -44
  138. package/dist/components/month-picker.js.map +1 -1
  139. package/dist/components/multi-entity-combobox.d.ts +3 -2
  140. package/dist/components/multi-entity-combobox.js.map +1 -1
  141. package/dist/components/number-field.d.ts +10 -10
  142. package/dist/components/number-input.d.ts +10 -10
  143. package/dist/components/numpad-sheet.d.ts +10 -10
  144. package/dist/components/numpad-sheet.js +2 -2
  145. package/dist/components/numpad-sheet.js.map +1 -1
  146. package/dist/components/phone-input.d.ts +10 -10
  147. package/dist/components/row-actions.d.ts +5 -5
  148. package/dist/components/row-actions.js +130 -32
  149. package/dist/components/row-actions.js.map +1 -1
  150. package/dist/components/series-chart.d.ts +10 -10
  151. package/dist/components/settings-fields.d.ts +10 -10
  152. package/dist/components/share-card.d.ts +7 -7
  153. package/dist/components/share-card.js +26 -10
  154. package/dist/components/share-card.js.map +1 -1
  155. package/dist/components/status-dot.d.ts +1 -0
  156. package/dist/components/status-dot.js +2 -2
  157. package/dist/components/status-dot.js.map +1 -1
  158. package/dist/components/swatch-picker.d.ts +2 -1
  159. package/dist/components/swatch-picker.js.map +1 -1
  160. package/dist/components/swipeable-row.d.ts +88 -17
  161. package/dist/components/swipeable-row.js +46 -9
  162. package/dist/components/swipeable-row.js.map +1 -1
  163. package/dist/components/switch.d.ts +2 -1
  164. package/dist/components/switch.js.map +1 -1
  165. package/dist/components/table.d.ts +2 -1
  166. package/dist/components/table.js +6 -0
  167. package/dist/components/table.js.map +1 -1
  168. package/dist/components/text-link.d.ts +9 -9
  169. package/dist/components/time-input.d.ts +10 -10
  170. package/dist/components/toggle-group.d.ts +2 -1
  171. package/dist/components/toggle-group.js +1 -1
  172. package/dist/components/toggle-group.js.map +1 -1
  173. package/dist/components/translation-review-editor.d.ts +4 -0
  174. package/dist/components/translation-review-editor.js +4 -3
  175. package/dist/components/translation-review-editor.js.map +1 -1
  176. package/dist/components/translation-review-parts.d.ts +1 -0
  177. package/dist/components/translation-review.d.ts +1 -0
  178. package/dist/components/translation-review.js +15 -18
  179. package/dist/components/translation-review.js.map +1 -1
  180. package/dist/components/ui.d.ts +9 -9
  181. package/dist/components/ui.js +47 -27
  182. package/dist/components/ui.js.map +1 -1
  183. package/dist/components/use-table-state.d.ts +12 -1
  184. package/dist/components/user-avatar.d.ts +1 -0
  185. package/dist/components/write-lock.d.ts +101 -13
  186. package/dist/components/write-lock.js +37 -13
  187. package/dist/components/write-lock.js.map +1 -1
  188. package/dist/{data-table-labels-kpAAVi6g.d.ts → data-table-labels-BJ5JE_X2.d.ts} +17 -0
  189. package/dist/data-table.d.ts +12 -1
  190. package/dist/{delete-account-setting-Dy4kJgUm.d.ts → delete-account-setting-DiPX4Vue.d.ts} +355 -41
  191. package/dist/feedback/feedback-attachment.d.ts +10 -10
  192. package/dist/feedback/feedback-attachment.js +7 -7
  193. package/dist/feedback/feedback-attachment.js.map +1 -1
  194. package/dist/feedback/feedback-context.d.ts +10 -10
  195. package/dist/feedback/feedback-crash.d.ts +10 -10
  196. package/dist/feedback/feedback-dialog.d.ts +10 -10
  197. package/dist/feedback/feedback-inbox.d.ts +9 -9
  198. package/dist/feedback/feedback-inbox.js +7 -3
  199. package/dist/feedback/feedback-inbox.js.map +1 -1
  200. package/dist/feedback/feedback-labels.d.ts +10 -10
  201. package/dist/feedback/feedback-menu.d.ts +3 -3
  202. package/dist/feedback/feedback-record.d.ts +10 -10
  203. package/dist/feedback/feedback-row-detail.d.ts +10 -10
  204. package/dist/feedback/feedback-row-detail.js.map +1 -1
  205. package/dist/feedback/feedback-status-undo.d.ts +10 -10
  206. package/dist/feedback/feedback-submit.d.ts +10 -10
  207. package/dist/feedback/feedback-swipe.d.ts +10 -10
  208. package/dist/feedback/feedback-swipe.js +3 -6
  209. package/dist/feedback/feedback-swipe.js.map +1 -1
  210. package/dist/feedback/feedback-table.d.ts +10 -10
  211. package/dist/feedback/feedback-table.js.map +1 -1
  212. package/dist/feedback/feedback-thread.d.ts +10 -10
  213. package/dist/feedback.d.ts +10 -10
  214. package/dist/hooks/use-file-drop.d.ts +10 -10
  215. package/dist/i18n/defaults.d.ts +10 -10
  216. package/dist/i18n/defaults.js +2 -0
  217. package/dist/i18n/defaults.js.map +1 -1
  218. package/dist/i18n/german.d.ts +10 -10
  219. package/dist/i18n/german.js +34 -1
  220. package/dist/i18n/german.js.map +1 -1
  221. package/dist/i18n/kit-labels.d.ts +4 -4
  222. package/dist/i18n/kit-labels.js.map +1 -1
  223. package/dist/i18n/languages.d.ts +10 -10
  224. package/dist/i18n/locales/de-CH.d.ts +10 -10
  225. package/dist/i18n/locales/en.d.ts +10 -10
  226. package/dist/i18n/locales/es.d.ts +10 -10
  227. package/dist/i18n/locales/es.js +34 -1
  228. package/dist/i18n/locales/es.js.map +1 -1
  229. package/dist/i18n/locales/fr.d.ts +10 -10
  230. package/dist/i18n/locales/fr.js +35 -1
  231. package/dist/i18n/locales/fr.js.map +1 -1
  232. package/dist/i18n/locales/hu.d.ts +10 -10
  233. package/dist/i18n/locales/hu.js +36 -1
  234. package/dist/i18n/locales/hu.js.map +1 -1
  235. package/dist/i18n/locales/it.d.ts +10 -10
  236. package/dist/i18n/locales/it.js +34 -1
  237. package/dist/i18n/locales/it.js.map +1 -1
  238. package/dist/i18n/locales/zh.d.ts +10 -10
  239. package/dist/i18n/locales/zh.js +34 -1
  240. package/dist/i18n/locales/zh.js.map +1 -1
  241. package/dist/i18n/review.d.ts +10 -10
  242. package/dist/i18n/review.js +8 -1
  243. package/dist/i18n/review.js.map +1 -1
  244. package/dist/index.d.ts +10 -5
  245. package/dist/index.js +40 -2
  246. package/dist/index.js.map +1 -1
  247. package/dist/landing/landing-actions.d.ts +10 -10
  248. package/dist/landing/landing-sections.d.ts +10 -10
  249. package/dist/landing/public-header.d.ts +10 -10
  250. package/dist/landing/routing.d.ts +10 -10
  251. package/dist/pay/.well-known/apple-developer-merchantid-domain-association +1 -0
  252. package/dist/pay/index.html +30 -0
  253. package/dist/pay/pay.css +126 -0
  254. package/dist/pay/pay.js +198 -0
  255. package/dist/rhf/fields.d.ts +10 -10
  256. package/dist/rhf/form.d.ts +10 -10
  257. package/dist/rhf.d.ts +10 -10
  258. package/dist/settings/settings-section.d.ts +10 -10
  259. package/dist/shell/app-shell.d.ts +9 -9
  260. package/dist/shell/top-bar-brand.d.ts +10 -10
  261. package/dist/shell/topbar-action-menu.d.ts +1 -0
  262. package/dist/shell.d.ts +10 -10
  263. package/dist/testing/css-variables.d.ts +74 -0
  264. package/dist/testing/css-variables.generated.d.ts +7 -0
  265. package/dist/testing/css-variables.generated.js +8 -0
  266. package/dist/testing/css-variables.generated.js.map +1 -0
  267. package/dist/testing/css-variables.js +93 -0
  268. package/dist/testing/css-variables.js.map +1 -0
  269. package/dist/testing/index.d.ts +1 -0
  270. package/dist/testing/index.js +12 -0
  271. package/dist/testing/index.js.map +1 -0
  272. package/dist/theme/contrast-tokens.d.ts +44 -1
  273. package/dist/theme/contrast-tokens.js +21 -0
  274. package/dist/theme/contrast-tokens.js.map +1 -1
  275. package/dist/theme/contrast.d.ts +2 -2
  276. package/dist/theme/palette-derive.js +60 -11
  277. package/dist/theme/palette-derive.js.map +1 -1
  278. package/dist/theme/palette-presets.js +45 -43
  279. package/dist/theme/palette-presets.js.map +1 -1
  280. package/dist/wizard/stepper-nav.d.ts +11 -11
  281. package/dist/wizard/stepper-nav.js.map +1 -1
  282. package/dist/wizard.d.ts +10 -10
  283. package/package.json +10 -2
  284. package/src/account/delete-account-setting.tsx +6 -1
  285. package/src/account/email-change-setting.tsx +9 -3
  286. package/src/account/sessions-setting.tsx +7 -2
  287. package/src/admin/admin-action-confirm.tsx +2 -1
  288. package/src/admin/invitations-panel.tsx +9 -3
  289. package/src/admin/plan-change.tsx +459 -0
  290. package/src/admin/reviewer-scope-editor.tsx +2 -1
  291. package/src/admin/role-select.tsx +2 -1
  292. package/src/admin/user-roster.tsx +5 -3
  293. package/src/auth/auth-errors.ts +25 -2
  294. package/src/auth/complete-name-dialog.tsx +6 -2
  295. package/src/billing/billing-banner.tsx +3 -1
  296. package/src/billing/billing-labels.ts +45 -0
  297. package/src/billing/billing-lock.ts +36 -3
  298. package/src/billing/billing-overview.ts +58 -0
  299. package/src/billing/checkout-processing.ts +391 -0
  300. package/src/billing/checkout-return.ts +90 -0
  301. package/src/billing/pay-page.ts +242 -0
  302. package/src/billing/plan-limit-toast.tsx +91 -0
  303. package/src/billing/subscription-actions.tsx +41 -8
  304. package/src/components/account-settings.tsx +6 -2
  305. package/src/components/autocomplete.tsx +5 -2
  306. package/src/components/button-group.tsx +4 -1
  307. package/src/components/calculator.tsx +10 -6
  308. package/src/components/checkbox-group.tsx +2 -2
  309. package/src/components/checkbox.tsx +2 -1
  310. package/src/components/chip.tsx +37 -32
  311. package/src/components/choice-card.tsx +4 -2
  312. package/src/components/combobox.tsx +4 -4
  313. package/src/components/confirm-dialog.tsx +13 -6
  314. package/src/components/country-select.tsx +2 -2
  315. package/src/components/danger-confirm.tsx +2 -2
  316. package/src/components/data-table.tsx +20 -9
  317. package/src/components/date-picker.tsx +10 -7
  318. package/src/components/entity-combobox.tsx +3 -3
  319. package/src/components/field-parts.tsx +61 -14
  320. package/src/components/file-button.tsx +5 -4
  321. package/src/components/file-dropzone.tsx +2 -2
  322. package/src/components/floating-panel.tsx +7 -5
  323. package/src/components/form-actions.tsx +12 -5
  324. package/src/components/inline-edit-field.tsx +13 -13
  325. package/src/components/list.tsx +10 -9
  326. package/src/components/month-picker.tsx +8 -2
  327. package/src/components/multi-entity-combobox.tsx +3 -3
  328. package/src/components/numpad-sheet.tsx +8 -4
  329. package/src/components/row-actions.tsx +208 -58
  330. package/src/components/share-card.tsx +67 -13
  331. package/src/components/status-dot.tsx +7 -7
  332. package/src/components/swatch-picker.tsx +2 -2
  333. package/src/components/swipeable-row.tsx +138 -27
  334. package/src/components/switch.tsx +2 -1
  335. package/src/components/table.tsx +8 -1
  336. package/src/components/toggle-group.tsx +5 -3
  337. package/src/components/translation-review-editor.tsx +8 -3
  338. package/src/components/translation-review.tsx +18 -22
  339. package/src/components/ui.tsx +92 -43
  340. package/src/components/write-lock.tsx +150 -24
  341. package/src/feedback/feedback-attachment.tsx +13 -9
  342. package/src/feedback/feedback-inbox.tsx +10 -6
  343. package/src/feedback/feedback-row-detail.tsx +3 -2
  344. package/src/feedback/feedback-swipe.tsx +6 -11
  345. package/src/feedback/feedback-table.tsx +2 -1
  346. package/src/i18n/defaults.ts +2 -0
  347. package/src/i18n/german.ts +38 -0
  348. package/src/i18n/kit-labels.tsx +4 -0
  349. package/src/i18n/locales/es.ts +34 -0
  350. package/src/i18n/locales/fr.ts +39 -0
  351. package/src/i18n/locales/hu.ts +37 -0
  352. package/src/i18n/locales/it.ts +35 -0
  353. package/src/i18n/locales/zh.ts +33 -0
  354. package/src/i18n/review.ts +7 -0
  355. package/src/index.ts +48 -4
  356. package/src/testing/css-variables.generated.ts +112 -0
  357. package/src/testing/css-variables.ts +191 -0
  358. package/src/testing/index.ts +13 -0
  359. package/src/theme/contrast-tokens.ts +53 -2
  360. package/src/theme/palette-derive.ts +87 -10
  361. package/src/theme/palette-presets.ts +45 -43
  362. package/src/wizard/stepper-nav.tsx +2 -1
  363. package/tokens.css +306 -46
@@ -129,6 +129,13 @@ export interface BillingLabels {
129
129
  usage: (used: string, limit: string) => string;
130
130
  /** The subject of the `contact` mail; `dimension` is the app's name for it ("Budgets"). */
131
131
  contactSubject: (dimension: string) => string;
132
+ /**
133
+ * The figure line of the limit's toast (`usePlanLimitToast`, §14.11) and of an
134
+ * operator's plan change over the new limit (§14.12): "Budgets: 3 of 3". A key of its
135
+ * own rather than `${dimension}: ${usage}`, because French puts a space before the
136
+ * colon. Both figures are formatted already.
137
+ */
138
+ limitUsageLine: (dimension: string, used: string, limit: string) => string;
132
139
 
133
140
  /* ── The provider's portal (§7, §12.26) ── */
134
141
  /** The portal: payment method and invoices (§2.8, the provider hosts both). */
@@ -139,6 +146,24 @@ export interface BillingLabels {
139
146
  * as unambiguously — a translator should use exactly that.
140
147
  */
141
148
  cancel: string;
149
+ /**
150
+ * In place of both while the payer hasn't reached the provider (the overview's
151
+ * `at_provider` is false, §14.5): there is no customer to open a portal for yet. Also
152
+ * the words for `409 billing_not_at_provider`, a stale page that asked anyway.
153
+ */
154
+ notAtProvider: string;
155
+
156
+ /* ── The pay page (§14.4) ── */
157
+ /* Built into the static page `dist/pay/` at the kit's build: it has no React and no
158
+ provider, so an app's override never reaches it. */
159
+ /** While the checkout opens. */
160
+ payOpening: string;
161
+ /** A link without a transaction, or billing switched off: nothing to pay. */
162
+ payNothing: string;
163
+ /** Paddle's script didn't load, or the page's configuration is wrong. */
164
+ payFailed: string;
165
+ /** The buyer closed the checkout without paying. */
166
+ payBack: string;
142
167
 
143
168
  /* ── Offline (§12.5) ── */
144
169
  /** Changes refused as `billing_read_only` inside a sync reply, kept queued and sent
@@ -159,6 +184,16 @@ export interface BillingLabels {
159
184
  /** `billing_disabled`: billing is switched off on this server (§2.9), for a page or a
160
185
  * link that reached it anyway. */
161
186
  disabled: string;
187
+ /* 0.33.0 — server-kit 0.7's refusals (§14.2). `billing_not_at_provider` is
188
+ {@link notAtProvider}'s: a stale page that asked for the portal anyway. */
189
+ /** `billing_provider_unavailable` (502): the provider didn't answer. Try again. */
190
+ providerUnavailable: string;
191
+ /** `billing_already_subscribed` (409): a second checkout while a subscription runs
192
+ * (§14.6) — plan changes go through the portal. */
193
+ alreadySubscribed: string;
194
+ /** `billing_plan_not_sold` (422): the catalogue doesn't sell this plan with that
195
+ * period or currency. */
196
+ planNotSold: string;
162
197
  }
163
198
 
164
199
  /** "today", "tomorrow", "in 5 days". */
@@ -221,9 +256,16 @@ export const DEFAULT_BILLING_LABELS: BillingLabels = {
221
256
  askForMore: "Ask for more",
222
257
  usage: (used, limit) => `${used} of ${limit}`,
223
258
  contactSubject: (dimension) => `Plan limit: ${dimension}`,
259
+ limitUsageLine: (dimension, used, limit) => `${dimension}: ${used} of ${limit}`,
224
260
 
225
261
  manage: "Payment and invoices",
226
262
  cancel: "Cancel subscription",
263
+ notAtProvider: "Nothing to manage yet. Payments and invoices appear here once you have a paid plan.",
264
+
265
+ payOpening: "Opening the secure checkout…",
266
+ payNothing: "There’s no payment to make here. To choose a plan, go back to the app.",
267
+ payFailed: "The checkout couldn’t be opened. Please go back and try again in a moment.",
268
+ payBack: "The checkout was closed. You can go back, or reload this page to open it again.",
227
269
 
228
270
  waitingChanges: (count) =>
229
271
  count === 1 ? "1 change waiting for a plan" : `${count} changes waiting for a plan`,
@@ -231,6 +273,9 @@ export const DEFAULT_BILLING_LABELS: BillingLabels = {
231
273
 
232
274
  notConfigured: "Payments aren't set up yet. Please try again later.",
233
275
  disabled: "Subscriptions aren't available here.",
276
+ providerUnavailable: "The payment provider can’t be reached right now. Please try again in a moment.",
277
+ alreadySubscribed: "You already have a subscription. To change your plan, go to “Payment and invoices”.",
278
+ planNotSold: "This plan isn’t offered for that billing period or currency. Please choose another.",
234
279
  };
235
280
 
236
281
  /** The `billing` namespace, resolved: English, then the provider, then `labels` — which
@@ -1,14 +1,21 @@
1
1
  import type { ReactNode } from "react";
2
2
 
3
3
  import type { LabelOverride } from "../i18n/kit-labels";
4
+ import type { WriteLockHold, WriteLockKind } from "../components/write-lock";
4
5
  import { useBillingLabels } from "./billing-labels";
5
6
  import type { BillingLabels } from "./billing-labels";
6
7
 
7
- /** One reason an app may lock writing: whether it locks now, and its one sentence. */
8
+ /** One reason an app may lock writing: whether it locks now, its one sentence, and what
9
+ * kind of lock it is. */
8
10
  export interface WriteLockSource {
9
11
  locked: boolean;
10
12
  /** The sentence a locked commit shows ("Not possible in the demo."). */
11
13
  reason?: ReactNode;
14
+ /**
15
+ * `"demo"`, `"billing"`, or the app's own (§12.36): a control's `commit` may be exempt
16
+ * from a kind — `COMMIT_EXCEPT_BILLING`. Left out, no control can be exempt from it.
17
+ */
18
+ kind?: WriteLockKind;
12
19
  }
13
20
 
14
21
  /** What {@link combineWriteLocks} answers: the props of one `WriteLockProvider`. */
@@ -16,6 +23,9 @@ export interface CombinedWriteLock {
16
23
  locked: boolean;
17
24
  /** The first locked source's reason; `undefined` while nothing locks. */
18
25
  reason: ReactNode | undefined;
26
+ /** EVERY locked source, kind and reason, in the order given (§12.36) — so a control
27
+ * exempt from billing under the demo's lock and billing's still finds the demo's. */
28
+ holds: WriteLockHold[];
19
29
  }
20
30
 
21
31
  /**
@@ -41,14 +51,21 @@ export interface CombinedWriteLock {
41
51
  * not two joined: a tooltip answers "why can't I?" once, and in practice one source locks
42
52
  * at a time — demo users have no subscription row and are never gated (§12.7). `null`,
43
53
  * `undefined` and `false` entries are skipped, for `cond && { … }`.
54
+ *
55
+ * 0.33 (§12.36): name each source's `kind` — `{ locked, reason, kind: "demo" }`,
56
+ * {@link useBillingWriteLock} for billing — and `holds` carries every locked one, in
57
+ * order, so the kit's parts that a lapsed plan still allows (removing access, the
58
+ * account's own settings, an operator's plan change) reopen under billing by themselves
59
+ * while the demo's lock still holds them.
44
60
  */
45
61
  export function combineWriteLocks(
46
62
  sources: ReadonlyArray<WriteLockSource | null | undefined | false>,
47
63
  ): CombinedWriteLock {
64
+ const holds: WriteLockHold[] = [];
48
65
  for (const source of sources) {
49
- if (source && source.locked) return { locked: true, reason: source.reason };
66
+ if (source && source.locked) holds.push({ kind: source.kind, reason: source.reason });
50
67
  }
51
- return { locked: false, reason: undefined };
68
+ return { locked: holds.length > 0, reason: holds[0]?.reason, holds };
52
69
  }
53
70
 
54
71
  export interface BillingLockReasonOptions {
@@ -73,3 +90,19 @@ export function useBillingLockReason(options: BillingLockReasonOptions = {}): st
73
90
  const labels = useBillingLabels(options.labels);
74
91
  return options.guest ? labels.guestReadOnly(options.item) : labels.lockReason;
75
92
  }
93
+
94
+ /**
95
+ * Billing's lock as one source for {@link combineWriteLocks}: `{ locked, reason, kind:
96
+ * "billing" }`, the reason the payer's or a guest's as {@link useBillingLockReason} says
97
+ * it (§12.36). The kind is what lets the kit's parts that §3.3 and §12.13 allow stay live
98
+ * under it.
99
+ *
100
+ * <WriteLockProvider {...combineWriteLocks([
101
+ * { locked: demoReadOnly, reason: demo.writeLocked, kind: "demo" },
102
+ * useBillingWriteLock(budget.locked === "billing", { guest: !isOwner, item: budget.name }),
103
+ * ])}>
104
+ */
105
+ export function useBillingWriteLock(locked: boolean, options: BillingLockReasonOptions = {}): WriteLockSource {
106
+ const reason = useBillingLockReason(options);
107
+ return { locked, reason, kind: "billing" };
108
+ }
@@ -0,0 +1,58 @@
1
+ import type { DateInput } from "../lib/format";
2
+ import type { SubscriptionStatus } from "./billing-labels";
3
+ import type { BillingCurrency } from "./plan-price";
4
+
5
+ /**
6
+ * The billing API's shapes the kit's parts read (docs/billing-harmonization.md §4,
7
+ * §14.5) — server-kit 0.7's schemas, field for field under their wire names, for an app
8
+ * whose client is hand-written (kastlan's `features/billing/types.ts`, Kurvenschmiede's
9
+ * `shared/types/billing.ts`, which no mirror test covers).
10
+ */
11
+
12
+ /** Where a row's current state came from (server-kit's `SubscriptionSource`, §3.2). */
13
+ export type SubscriptionSource = "trial" | "provider" | "manual" | "beta";
14
+
15
+ /** `GET /billing/overview` (§4): where the payer's subscription stands, what the plan
16
+ * allows and what is used. Only ever the payer's own (§12.6). */
17
+ export interface BillingOverview {
18
+ /** The plan's code, lowercase (§3.1). */
19
+ plan: string;
20
+ status: SubscriptionStatus;
21
+ source: SubscriptionSource;
22
+ /** False: read-only (§3.3). */
23
+ in_good_standing: boolean;
24
+ trial_ends_at: DateInput;
25
+ /** When the free grant ends; null: no end. */
26
+ comped_until: DateInput;
27
+ current_period_end: DateInput;
28
+ cancel_at_period_end: boolean;
29
+ /** Dimension → limit; null for unlimited. */
30
+ limits: Record<string, number | null>;
31
+ /** What the payer has, per dimension (§12.15). */
32
+ usage: Record<string, number>;
33
+ currency: BillingCurrency;
34
+ /**
35
+ * 0.7 (§14.5): the provider has a customer for this payer, whatever the status or the
36
+ * source — `SubscriptionActions`' `atProvider`. False: "nothing to manage yet", and a
37
+ * portal request would answer `409 billing_not_at_provider`.
38
+ */
39
+ at_provider: boolean;
40
+ }
41
+
42
+ /**
43
+ * Where in the provider's portal a request lands (server-kit's `PortalTarget`, §14.5):
44
+ *
45
+ * - `overview` — "Payment and invoices";
46
+ * - `cancel` — "Cancel subscription": the subscription's cancel link (§12.26);
47
+ * - `payment_method` — the payment-failed banner's "Update payment method".
48
+ *
49
+ * The server answers the overview where Paddle gives no such link (a paused or cancelled
50
+ * subscription).
51
+ */
52
+ export type PortalTarget = "overview" | "cancel" | "payment_method";
53
+
54
+ /** `POST /billing/portal`'s optional body (server-kit's `PortalRequest`, §14.5); left
55
+ * out, the overview. The answer is `{url}`, a fresh link that is never stored. */
56
+ export interface PortalRequest {
57
+ target?: PortalTarget;
58
+ }
@@ -0,0 +1,391 @@
1
+ import { useCallback, useEffect, useEffectEvent, useLayoutEffect, useRef, useSyncExternalStore } from "react";
2
+
3
+ import { toDate } from "../lib/format";
4
+ import type { DateInput } from "../lib/format";
5
+ import { readStored, writeStored } from "../lib/safe-storage";
6
+ import type { BillingStanding } from "./billing-standing";
7
+
8
+ /**
9
+ * "Payment processing" after the way back from a checkout (docs/billing-harmonization.md
10
+ * §12.21, §14.4; keksdose's `features/billing/checkout-return.ts`, made router-agnostic).
11
+ *
12
+ * The provider tells the app about a payment by WEBHOOK, and the webhook may land after
13
+ * the buyer is back: Cloud Run scales to zero, and a cold start can outlast the
14
+ * provider's redirect. Without this, someone who has just paid comes back to a page that
15
+ * still says their trial has ended. So the return is recognised (`?checkout=done`,
16
+ * `isCheckoutReturn`), the page says "your payment is being processed"
17
+ * (`BillingBanner kind="processing"`), and the overview is asked again every few seconds
18
+ * until it has moved.
19
+ *
20
+ * **"Landed" is judged against the overview at departure** ({@link checkoutFingerprint},
21
+ * noted by {@link noteCheckoutStarted}), not by "is it `active` yet": a trial and a beta
22
+ * grant can both buy, and a running free grant keeps its status whatever the provider
23
+ * says (§12.11) — only its period end moves — so "active" alone would wait for ever on
24
+ * one of them. A return with no departure on record (another tab, another device) waits
25
+ * for `active`. kastlan's 60-second poll for `source === "provider"` is the case this
26
+ * replaces: a payer who bought under a grant never matched it.
27
+ *
28
+ * **Bounded:** after `timeoutMs` (10 minutes) the device stops asking, and the page says
29
+ * what the overview says, whatever that is — a checkout abandoned at the provider must
30
+ * not leave "processing" up for good.
31
+ *
32
+ * **Remembered across the whole-page return, per payer.** The departure goes into the
33
+ * browser's storage through the kit's safe storage (a throwing storage — Safari's
34
+ * private mode — keeps it in memory for this page instead), under `storageKey`; a mark
35
+ * names its payer, so it never speaks for another account on a shared device. It is a
36
+ * browser entry on the app's origin: an app whose privacy text lists its entries names
37
+ * it (Kurvenschmiede passes its own key).
38
+ */
39
+
40
+ /** The overview fields a completed checkout changes (wire names, `GET /billing/overview`).
41
+ * Pass the overview as it came; the other fields are ignored. */
42
+ export type CheckoutOverview = Pick<BillingStanding, "status" | "in_good_standing"> & {
43
+ plan: string;
44
+ source: string;
45
+ current_period_end?: DateInput;
46
+ };
47
+
48
+ /** How long a return keeps the page in "processing" before it trusts the overview. */
49
+ const PROCESSING_FOR = 10 * 60_000;
50
+ /** How often the overview is asked meanwhile. */
51
+ const POLL_EVERY = 4_000;
52
+ /** The browser entry, unless the app names its own. */
53
+ const STORAGE_KEY = "eifi1-billing-checkout";
54
+
55
+ /**
56
+ * What of the overview a completed checkout changes, as one string: the plan (read
57
+ * without case, §12.16), the status, the source, the standing and the period's end (read
58
+ * as an instant, so `…Z` and `…+00:00` agree).
59
+ */
60
+ export function checkoutFingerprint(o: CheckoutOverview): string {
61
+ const end = toDate(o.current_period_end ?? null);
62
+ return [
63
+ String(o.plan ?? "").toLowerCase(),
64
+ o.status,
65
+ o.source,
66
+ String(o.in_good_standing),
67
+ end ? end.toISOString() : "",
68
+ ].join("|");
69
+ }
70
+
71
+ /**
72
+ * Whether the overview `now` shows the checkout: it differs from the fingerprint taken
73
+ * at departure (`before`), or — with no departure on record — it is `active`.
74
+ */
75
+ export function checkoutLanded(before: string | null, now: CheckoutOverview): boolean {
76
+ return before === null ? now.status === "active" : checkoutFingerprint(now) !== before;
77
+ }
78
+
79
+ /* ── The store: one per storage key, shared by every mounted instance ─────── */
80
+
81
+ interface PendingCheckout {
82
+ /** Whose checkout: a user's or a company's id, as a string. */
83
+ payer: string;
84
+ /** The overview's fingerprint at departure, or null when it was not known. */
85
+ before: string | null;
86
+ /** When the buyer came back; null while they are (or were) at the provider. */
87
+ returnedAt: number | null;
88
+ }
89
+
90
+ /** One mounted hook: what the shared timers call, kept current on every render. */
91
+ interface Instance {
92
+ payer: string | null;
93
+ refetch: () => unknown;
94
+ onLanded: (() => void) | undefined;
95
+ timeoutMs: number;
96
+ pollMs: number;
97
+ }
98
+
99
+ interface CheckoutStore {
100
+ key: string;
101
+ pending: PendingCheckout | null;
102
+ listeners: Set<() => void>;
103
+ /** The mounted instances, in mount order: the last is the latest. */
104
+ instances: Instance[];
105
+ poll: ReturnType<typeof setInterval> | null;
106
+ pollEvery: number;
107
+ expiry: ReturnType<typeof setTimeout> | null;
108
+ expiryAt: number | null;
109
+ /** An instance consumed the marker in this commit; the others leave it be. */
110
+ consuming: boolean;
111
+ }
112
+
113
+ /** Module-level, so the banner's instance and the subscription page's share one state,
114
+ * one poll and one landing (keksdose mounts the hook twice, §14.4). */
115
+ const stores = new Map<string, CheckoutStore>();
116
+
117
+ function readPending(key: string): PendingCheckout | null {
118
+ const raw = readStored(key);
119
+ if (raw === null) return null;
120
+ try {
121
+ const value = JSON.parse(raw) as Partial<PendingCheckout> | null;
122
+ if (
123
+ value &&
124
+ typeof value.payer === "string" &&
125
+ (value.before === null || typeof value.before === "string") &&
126
+ (value.returnedAt === null || typeof value.returnedAt === "number")
127
+ ) {
128
+ return { payer: value.payer, before: value.before, returnedAt: value.returnedAt };
129
+ }
130
+ } catch {
131
+ // Not ours, or damaged: as if nothing was noted.
132
+ }
133
+ return null;
134
+ }
135
+
136
+ /** Drop the browser entry. lib/safe-storage reads and writes; removing needs the same
137
+ * guard (a blocked storage throws on access). */
138
+ function forget(key: string): void {
139
+ if (typeof window === "undefined") return;
140
+ try {
141
+ window.localStorage.removeItem(key);
142
+ } catch {
143
+ // ignore (private mode, partitioned storage)
144
+ }
145
+ }
146
+
147
+ function storeOf(key: string): CheckoutStore {
148
+ let store = stores.get(key);
149
+ if (!store) {
150
+ store = {
151
+ key,
152
+ pending: readPending(key),
153
+ listeners: new Set(),
154
+ instances: [],
155
+ poll: null,
156
+ pollEvery: 0,
157
+ expiry: null,
158
+ expiryAt: null,
159
+ consuming: false,
160
+ };
161
+ stores.set(key, store);
162
+ }
163
+ return store;
164
+ }
165
+
166
+ function setPending(store: CheckoutStore, next: PendingCheckout | null): void {
167
+ store.pending = next;
168
+ if (next) writeStored(store.key, JSON.stringify(next));
169
+ else forget(store.key);
170
+ for (const listener of store.listeners) listener();
171
+ sync(store);
172
+ }
173
+
174
+ /** The latest mounted instance's refetch, failures swallowed: a poll that fails is
175
+ * simply asked again at the next tick. */
176
+ function refetchLatest(store: CheckoutStore): void {
177
+ const latest = store.instances.at(-1);
178
+ if (!latest) return;
179
+ try {
180
+ const result = latest.refetch() as PromiseLike<unknown> | undefined;
181
+ if (result && typeof result.then === "function") result.then(undefined, () => {});
182
+ } catch {
183
+ // as above
184
+ }
185
+ }
186
+
187
+ /** Arms or stops the store's two timers — the bound and the poll — for its state. */
188
+ function sync(store: CheckoutStore): void {
189
+ const pending = store.pending;
190
+ const latest = store.instances.at(-1);
191
+ const returnedAt = pending?.returnedAt ?? null;
192
+
193
+ if (returnedAt === null) {
194
+ if (store.expiry) clearTimeout(store.expiry);
195
+ store.expiry = null;
196
+ store.expiryAt = null;
197
+ } else {
198
+ const at = returnedAt + (latest?.timeoutMs ?? PROCESSING_FOR);
199
+ if (at <= Date.now()) {
200
+ setPending(store, null);
201
+ return;
202
+ }
203
+ if (store.expiryAt !== at) {
204
+ if (store.expiry) clearTimeout(store.expiry);
205
+ store.expiryAt = at;
206
+ store.expiry = setTimeout(() => {
207
+ store.expiry = null;
208
+ store.expiryAt = null;
209
+ if (store.pending === pending) setPending(store, null);
210
+ }, at - Date.now());
211
+ }
212
+ }
213
+
214
+ // One poll for every instance, while a mounted one is the mark's payer.
215
+ const watching = returnedAt !== null && store.instances.some((i) => i.payer === pending!.payer);
216
+ const every = latest?.pollMs ?? POLL_EVERY;
217
+ if (!watching) {
218
+ if (store.poll) clearInterval(store.poll);
219
+ store.poll = null;
220
+ } else if (!store.poll || store.pollEvery !== every) {
221
+ if (store.poll) clearInterval(store.poll);
222
+ store.pollEvery = every;
223
+ store.poll = setInterval(() => refetchLatest(store), every);
224
+ }
225
+ }
226
+
227
+ /** A mounted instance joins the store's timers; the latest is the one they call. */
228
+ function register(store: CheckoutStore, instance: Instance): () => void {
229
+ store.instances.push(instance);
230
+ return () => {
231
+ store.instances = store.instances.filter((i) => i !== instance);
232
+ sync(store);
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Whether this instance is the one to consume a return marker: the first in a commit is.
238
+ * Another instance in the same commit sees the same marker and leaves it be; the claim
239
+ * lapses once the commit's effects have run.
240
+ */
241
+ function claimReturn(store: CheckoutStore): boolean {
242
+ if (store.consuming) return false;
243
+ store.consuming = true;
244
+ void Promise.resolve().then(() => {
245
+ store.consuming = false;
246
+ });
247
+ return true;
248
+ }
249
+
250
+ /**
251
+ * Note the departure for a checkout — just before the page leaves for the provider (or
252
+ * for the pay page, `payPageUrl(url, locale)`):
253
+ *
254
+ * noteCheckoutStarted(user.id, overview.data);
255
+ * window.location.assign(payPageUrl(url, i18n.language));
256
+ *
257
+ * `payer` is the user's or the company's id: a mark never speaks for another payer.
258
+ * `overview` is what the page shows now; left out, the return waits for `active`. Pass
259
+ * the same `storageKey` as the hook's.
260
+ */
261
+ export function noteCheckoutStarted(
262
+ payer: string | number,
263
+ overview?: CheckoutOverview,
264
+ opts: { storageKey?: string } = {},
265
+ ): void {
266
+ const store = storeOf(opts.storageKey ?? STORAGE_KEY);
267
+ setPending(store, {
268
+ payer: String(payer),
269
+ before: overview ? checkoutFingerprint(overview) : null,
270
+ returnedAt: null,
271
+ });
272
+ }
273
+
274
+ export interface CheckoutProcessingOptions {
275
+ /** The signed-in payer — the user's or the company's id; null while unknown (the
276
+ * marker then waits in the URL). */
277
+ payer: string | number | null;
278
+ /** The page was reached as the way back: `isCheckoutReturn(location.search)`. */
279
+ returned: boolean;
280
+ /** Drop the marker, with replace — `setSearchParams((p) => withoutCheckoutReturn(p),
281
+ * { replace: true })`. Called once per return, by the first instance that sees it. */
282
+ onConsumed: () => void;
283
+ /** The overview query's data, judged here against the departure. */
284
+ overview: CheckoutOverview | undefined;
285
+ /** The overview query's refetch: the hook polls with it — one timer for every
286
+ * instance, with the latest mounted one's — and "Check again" calls it. */
287
+ refetch: () => unknown;
288
+ /** Once per landing: refresh what a new standing changes — the plans, the items whose
289
+ * `locked` may have lifted. The overview itself is already fresh. */
290
+ onLanded?: () => void;
291
+ /** How long a return keeps "processing". Default 600 000 (10 minutes). */
292
+ timeoutMs?: number;
293
+ /** How often the overview is asked meanwhile. Default 4 000. */
294
+ pollMs?: number;
295
+ /** The browser entry. Default `"eifi1-billing-checkout"`. */
296
+ storageKey?: string;
297
+ }
298
+
299
+ /**
300
+ * Whether this payer is back from a checkout whose payment the overview has not shown
301
+ * yet — and the "Check again" for the banner that says so:
302
+ *
303
+ * const [params, setParams] = useSearchParams();
304
+ * const overview = useQuery(billingOverviewQuery);
305
+ * const { processing, checkAgain } = useCheckoutProcessing({
306
+ * payer: user?.id ?? null,
307
+ * returned: isCheckoutReturn(params),
308
+ * onConsumed: () => setParams((p) => withoutCheckoutReturn(p), { replace: true }),
309
+ * overview: overview.data,
310
+ * refetch: overview.refetch,
311
+ * onLanded: () => void qc.invalidateQueries({ queryKey: ["budgets"] }),
312
+ * });
313
+ * processing && <BillingBanner kind="processing" onAction={checkAgain} />
314
+ *
315
+ * The kit has no router: the app says whether the URL returned and how to drop the
316
+ * marker. Mount it wherever the return lands and the banner shows — every instance with
317
+ * the same `storageKey` shares one state: the first that sees the marker consumes it,
318
+ * one timer polls, and `onLanded` runs once. The app's `refetchOnWindowFocus` doesn't
319
+ * matter.
320
+ */
321
+ export function useCheckoutProcessing(options: CheckoutProcessingOptions): {
322
+ processing: boolean;
323
+ checkAgain: () => void;
324
+ } {
325
+ const {
326
+ payer,
327
+ returned,
328
+ onConsumed,
329
+ overview,
330
+ refetch,
331
+ onLanded,
332
+ timeoutMs = PROCESSING_FOR,
333
+ pollMs = POLL_EVERY,
334
+ storageKey = STORAGE_KEY,
335
+ } = options;
336
+ const payerKey = payer === null || payer === undefined ? null : String(payer);
337
+ const store = storeOf(storageKey);
338
+ const subscribe = useCallback(
339
+ (notify: () => void) => {
340
+ store.listeners.add(notify);
341
+ return () => {
342
+ store.listeners.delete(notify);
343
+ };
344
+ },
345
+ [store],
346
+ );
347
+ const snapshot = () => store.pending;
348
+ const pending = useSyncExternalStore(subscribe, snapshot, snapshot);
349
+ const processing = payerKey !== null && pending?.payer === payerKey && pending.returnedAt !== null;
350
+
351
+ // This instance, as the shared timers see it. Registered before paint, so a mark that
352
+ // ran out while the page was closed is dropped before "processing" can flash.
353
+ const instanceRef = useRef<Instance>({ payer: payerKey, refetch, onLanded, timeoutMs, pollMs });
354
+ useLayoutEffect(() => register(store, instanceRef.current), [store]);
355
+ useLayoutEffect(() => {
356
+ Object.assign(instanceRef.current, { payer: payerKey, refetch, onLanded, timeoutMs, pollMs });
357
+ sync(store);
358
+ });
359
+
360
+ // The return: note it, then drop the marker so a reload does not count it twice.
361
+ const consumed = useEffectEvent(() => onConsumed());
362
+ useEffect(() => {
363
+ if (!returned || payerKey === null || !claimReturn(store)) return;
364
+ const before = store.pending?.payer === payerKey ? store.pending.before : null;
365
+ setPending(store, { payer: payerKey, before, returnedAt: Date.now() });
366
+ consumed();
367
+ }, [returned, payerKey, store]);
368
+
369
+ // Caught up: the overview has moved since the departure. Judged against the store as
370
+ // it is NOW, so a second instance in the same commit finds the mark gone.
371
+ useEffect(() => {
372
+ if (!processing || !overview) return;
373
+ const live = store.pending;
374
+ if (!live || live.payer !== payerKey || live.returnedAt === null) return;
375
+ if (!checkoutLanded(live.before, overview)) return;
376
+ setPending(store, null);
377
+ const latest = [...store.instances].reverse().find((i) => i.onLanded);
378
+ latest?.onLanded?.();
379
+ }, [processing, overview, payerKey, store]);
380
+
381
+ const checkAgain = useCallback(() => {
382
+ try {
383
+ const result = instanceRef.current.refetch() as PromiseLike<unknown> | undefined;
384
+ if (result && typeof result.then === "function") result.then(undefined, () => {});
385
+ } catch {
386
+ // The banner stays; the next poll asks again.
387
+ }
388
+ }, []);
389
+
390
+ return { processing, checkAgain };
391
+ }