@eifi1/ui-kit 0.42.0 → 0.43.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 (130) hide show
  1. package/README.md +12 -6
  2. package/dist/admin/admin-area-labels.d.ts +4 -0
  3. package/dist/admin/admin-area-labels.js +3 -1
  4. package/dist/admin/admin-area-labels.js.map +1 -1
  5. package/dist/admin/admin-overview.d.ts +25 -8
  6. package/dist/admin/admin-overview.js +26 -3
  7. package/dist/admin/admin-overview.js.map +1 -1
  8. package/dist/admin/admin-sections.d.ts +36 -5
  9. package/dist/admin/admin-sections.js +35 -18
  10. package/dist/admin/admin-sections.js.map +1 -1
  11. package/dist/billing/billing-banner.d.ts +8 -0
  12. package/dist/billing/billing-banner.js +25 -2
  13. package/dist/billing/billing-banner.js.map +1 -1
  14. package/dist/billing/billing-labels.d.ts +4 -0
  15. package/dist/billing/billing-labels.js +1 -0
  16. package/dist/billing/billing-labels.js.map +1 -1
  17. package/dist/billing/cancel-request.d.ts +53 -0
  18. package/dist/billing/cancel-request.js +21 -0
  19. package/dist/billing/cancel-request.js.map +1 -0
  20. package/dist/billing/provider-page.d.ts +40 -0
  21. package/dist/billing/provider-page.js +33 -0
  22. package/dist/billing/provider-page.js.map +1 -0
  23. package/dist/billing/subscription-actions.d.ts +32 -1
  24. package/dist/billing/subscription-actions.js +57 -4
  25. package/dist/billing/subscription-actions.js.map +1 -1
  26. package/dist/components/account-chips.d.ts +8 -1
  27. package/dist/components/account-chips.js.map +1 -1
  28. package/dist/eslint/index.js +1 -1
  29. package/dist/feedback/feedback-menu.d.ts +7 -4
  30. package/dist/feedback/feedback-menu.js +4 -2
  31. package/dist/feedback/feedback-menu.js.map +1 -1
  32. package/dist/hooks/use-search-param-state.js +4 -1
  33. package/dist/hooks/use-search-param-state.js.map +1 -1
  34. package/dist/i18n/defaults.js +2 -0
  35. package/dist/i18n/defaults.js.map +1 -1
  36. package/dist/i18n/german.js +36 -2
  37. package/dist/i18n/german.js.map +1 -1
  38. package/dist/i18n/kit-labels.d.ts +3 -0
  39. package/dist/i18n/kit-labels.js.map +1 -1
  40. package/dist/i18n/locales/en.js +51 -2
  41. package/dist/i18n/locales/en.js.map +3 -3
  42. package/dist/i18n/locales/es.js +36 -2
  43. package/dist/i18n/locales/es.js.map +1 -1
  44. package/dist/i18n/locales/fr.js +36 -2
  45. package/dist/i18n/locales/fr.js.map +1 -1
  46. package/dist/i18n/locales/hu.js +36 -2
  47. package/dist/i18n/locales/hu.js.map +1 -1
  48. package/dist/i18n/locales/it.js +36 -2
  49. package/dist/i18n/locales/it.js.map +1 -1
  50. package/dist/i18n/locales/zh.js +36 -2
  51. package/dist/i18n/locales/zh.js.map +1 -1
  52. package/dist/i18n/review.js +15 -1
  53. package/dist/i18n/review.js.map +1 -1
  54. package/dist/index.d.ts +6 -0
  55. package/dist/index.js +6 -0
  56. package/dist/index.js.map +1 -1
  57. package/dist/search/global-search.d.ts +3 -0
  58. package/dist/search/global-search.js +3 -1
  59. package/dist/search/global-search.js.map +1 -1
  60. package/dist/settings/settings-catalogue.d.ts +9 -1
  61. package/dist/settings/settings-catalogue.js +1 -1
  62. package/dist/settings/settings-catalogue.js.map +1 -1
  63. package/dist/shell/admin-menu.d.ts +26 -3
  64. package/dist/shell/admin-menu.js +8 -4
  65. package/dist/shell/admin-menu.js.map +1 -1
  66. package/dist/shell/dev-switchers.js +5 -0
  67. package/dist/shell/dev-switchers.js.map +1 -1
  68. package/dist/shell/top-bar-parts-labels.d.ts +6 -0
  69. package/dist/shell/top-bar-parts-labels.js +4 -1
  70. package/dist/shell/top-bar-parts-labels.js.map +1 -1
  71. package/dist/shell/tour-menu.d.ts +19 -10
  72. package/dist/shell/tour-menu.js +20 -6
  73. package/dist/shell/tour-menu.js.map +1 -1
  74. package/dist/shell.d.ts +2 -0
  75. package/dist/shell.js +2 -0
  76. package/dist/shell.js.map +1 -1
  77. package/dist/tour/tour-catalogue-labels.d.ts +44 -0
  78. package/dist/tour/tour-catalogue-labels.js +32 -0
  79. package/dist/tour/tour-catalogue-labels.js.map +1 -0
  80. package/dist/tour/tour-catalogue.d.ts +68 -0
  81. package/dist/tour/tour-catalogue.js +189 -0
  82. package/dist/tour/tour-catalogue.js.map +1 -0
  83. package/dist/tour/tour-info.d.ts +112 -0
  84. package/dist/tour/tour-info.js +36 -0
  85. package/dist/tour/tour-info.js.map +1 -0
  86. package/dist/tour/use-tour-progress.d.ts +61 -0
  87. package/dist/tour/use-tour-progress.js +116 -0
  88. package/dist/tour/use-tour-progress.js.map +1 -0
  89. package/dist/tour/use-tour-url-sync.d.ts +58 -0
  90. package/dist/tour/use-tour-url-sync.js +110 -0
  91. package/dist/tour/use-tour-url-sync.js.map +1 -0
  92. package/dist/tour.d.ts +8 -0
  93. package/dist/tour.js +18 -0
  94. package/dist/tour.js.map +1 -1
  95. package/package.json +1 -1
  96. package/src/admin/admin-area-labels.ts +7 -1
  97. package/src/admin/admin-overview.tsx +61 -10
  98. package/src/admin/admin-sections.tsx +109 -48
  99. package/src/billing/billing-banner.tsx +25 -2
  100. package/src/billing/billing-labels.ts +6 -0
  101. package/src/billing/cancel-request.ts +75 -0
  102. package/src/billing/provider-page.ts +75 -0
  103. package/src/billing/subscription-actions.tsx +97 -11
  104. package/src/components/account-chips.tsx +8 -1
  105. package/src/feedback/feedback-menu.tsx +8 -3
  106. package/src/hooks/use-search-param-state.ts +6 -1
  107. package/src/i18n/defaults.ts +2 -0
  108. package/src/i18n/german.ts +36 -1
  109. package/src/i18n/kit-labels.tsx +3 -0
  110. package/src/i18n/locales/en.ts +13 -0
  111. package/src/i18n/locales/es.ts +36 -1
  112. package/src/i18n/locales/fr.ts +36 -1
  113. package/src/i18n/locales/hu.ts +36 -1
  114. package/src/i18n/locales/it.ts +36 -1
  115. package/src/i18n/locales/zh.ts +36 -1
  116. package/src/i18n/review.ts +14 -0
  117. package/src/index.ts +8 -0
  118. package/src/search/global-search.tsx +5 -0
  119. package/src/settings/settings-catalogue.ts +10 -2
  120. package/src/shell/admin-menu.tsx +34 -5
  121. package/src/shell/dev-switchers.tsx +8 -0
  122. package/src/shell/top-bar-parts-labels.ts +9 -0
  123. package/src/shell/tour-menu.tsx +50 -15
  124. package/src/shell.ts +3 -0
  125. package/src/tour/tour-catalogue-labels.ts +75 -0
  126. package/src/tour/tour-catalogue.tsx +316 -0
  127. package/src/tour/tour-info.ts +144 -0
  128. package/src/tour/use-tour-progress.ts +198 -0
  129. package/src/tour/use-tour-url-sync.ts +221 -0
  130. package/src/tour.ts +11 -0
@@ -48,6 +48,10 @@ export interface AdminAreaLabels {
48
48
  sections: Record<AdminSharedSection, SettingsGroupLabels>;
49
49
  /** The overview's tiles. */
50
50
  tiles: AdminTileLabels;
51
+ /** 0.43: `AdminOverview`'s `error` — the counts could not be loaded. */
52
+ overviewError: string;
53
+ /** 0.43: its button, which calls `onRetry`. */
54
+ retry: string;
51
55
  /** The Tools list's heading and name (§2.5 decision 16). */
52
56
  tools: string;
53
57
  }
@@ -62,7 +66,7 @@ export const DEFAULT_ADMIN_AREA_LABELS: AdminAreaLabels = {
62
66
  sections: {
63
67
  overview: { title: "Overview", help: "What is waiting, at a glance." },
64
68
  users: { title: "Users", help: "Who has an account, and what each may do." },
65
- invitations: { title: "Invitations", help: "The links that let a new person in." },
69
+ invitations: { title: "Invitations", help: "Invite people by e-mail in their language; resend or revoke what’s still open." },
66
70
  billing: { title: "Billing", help: "The plan, the payment and the invoices." },
67
71
  activity: { title: "Activity", help: "What the admins did, newest first." },
68
72
  },
@@ -73,6 +77,8 @@ export const DEFAULT_ADMIN_AREA_LABELS: AdminAreaLabels = {
73
77
  users: "Users",
74
78
  usersNew: (count) => `${count} new in 7 days`,
75
79
  },
80
+ overviewError: "The overview couldn’t be loaded.",
81
+ retry: "Try again",
76
82
  tools: "Tools",
77
83
  };
78
84
 
@@ -1,7 +1,10 @@
1
1
  import type { ReactNode } from "react";
2
+ import { AlertBanner } from "../components/alert-banner";
3
+ import { Button } from "../components/button";
2
4
  import { StatTile, StatTileGrid } from "../components/stat-tile";
3
5
  import type { StatTileProps } from "../components/stat-tile";
4
6
  import type { LabelOverride } from "../i18n/kit-labels";
7
+ import { cn } from "../lib/cn";
5
8
  import { useAdminAreaLabels } from "./admin-area-labels";
6
9
  import type { AdminAreaLabels } from "./admin-area-labels";
7
10
 
@@ -68,14 +71,16 @@ export function adminWaitingCount(
68
71
  export interface AttentionTileProps extends Omit<StatTileProps, "href" | "onClick" | "value"> {
69
72
  /** The count. `null` shows the tile's dash; {@link AdminOverview} leaves such a tile out. */
70
73
  count: number | null | undefined;
71
- /** Where the work is — the tile is a link to it. */
72
- href: string;
74
+ /** Where the work is — the tile is a link to it. Since 0.43 it may be left out: a
75
+ * quiet count, no link (Kurvenschmiede). */
76
+ href?: string;
73
77
  }
74
78
 
75
79
  /**
76
80
  * One tile of the overview: a {@link StatTile} that is a LINK to where the work is, its
77
81
  * count the headline. The whole tile is the link, named by its label; route it through
78
- * `renderLink` or the provider's link component, as any StatTile.
82
+ * `renderLink` or the provider's link component, as any StatTile. Without `href` (0.43) it
83
+ * is a quiet count, no link.
79
84
  */
80
85
  export function AttentionTile({ count, href, ...tile }: AttentionTileProps) {
81
86
  return <StatTile data-slot="attention-tile" {...tile} value={count ?? null} href={href} />;
@@ -88,7 +93,9 @@ export interface AdminExtraTile {
88
93
  label: ReactNode;
89
94
  /** `null` or `undefined` leaves the tile out, as for a shared one. */
90
95
  count: number | null | undefined;
91
- href: string;
96
+ /** Where the work is. Left out (0.43, Kurvenschmiede): a quiet count with no link —
97
+ * a number worth seeing that has no page of its own. */
98
+ href?: string;
92
99
  /** A line under the count. */
93
100
  description?: ReactNode;
94
101
  }
@@ -106,6 +113,18 @@ export interface AdminOverviewProps {
106
113
  extraTiles?: readonly AdminExtraTile[];
107
114
  /** Passed to every tile — `renderLink` for the router, `size`, `variant`. */
108
115
  tileProps?: Pick<StatTileProps, "renderLink" | "size" | "variant" | "locale">;
116
+ /**
117
+ * 0.43 (keksdose): the answer failed — the query's `error`, or `true`. Anything truthy
118
+ * shows `adminArea.overviewError` in a danger box, with "Try again" when
119
+ * {@link onRetry} is given. A failed poll keeps the last good tiles: with `attention`
120
+ * still there (a query keeps its last data), the box shows ALONGSIDE them, above, not
121
+ * instead (build review 0.43, keksdose N7) — pass `attention.data` as it is. Without
122
+ * an answer yet, the box alone. Without `error`: `undefined` is the skeletons and
123
+ * `null` nothing, as before.
124
+ */
125
+ error?: unknown;
126
+ /** "Try again" in the error box — the query's `refetch`. */
127
+ onRetry?: () => void;
109
128
  /** Prop > `<UiKitProvider labels={{ adminArea }}>` > English. */
110
129
  labels?: LabelOverride<AdminAreaLabels>;
111
130
  className?: string;
@@ -117,19 +136,43 @@ export interface AdminOverviewProps {
117
136
  * the total) — then the app's `extraTiles`. A tile whose count is `null` is left out,
118
137
  * so each app shows only what it has; a `0` is a quiet tile, "nothing waiting".
119
138
  *
120
- * Put it in the `overview` section's `SettingsSection` (titled by the sidebar, so
121
- * `titleVisible={false}`). keksdose links its metrics from here with at most two tiles
122
- * (§6.5).
139
+ * The overview is the admin area's landing page, standalone: it is NOT wrapped in a
140
+ * `SettingsSection` (`adminSections` gives it no entry; render it as the page's body, as
141
+ * Kurvenschmiede's `OverviewPanel` does). keksdose links its metrics from here with at
142
+ * most two tiles (§6.5).
123
143
  */
124
144
  export function AdminOverview({
125
145
  attention,
126
146
  hrefs,
127
147
  extraTiles = [],
128
148
  tileProps,
149
+ error,
150
+ onRetry,
129
151
  labels: labelsProp,
130
152
  className,
131
153
  }: AdminOverviewProps) {
132
154
  const labels = useAdminAreaLabels(labelsProp);
155
+ /** The error box: on its own (`alone`) it is the overview's root. */
156
+ const errorBox = (alone: boolean) => (
157
+ <AlertBanner
158
+ data-slot={alone ? "admin-overview" : undefined}
159
+ data-state={alone ? "error" : undefined}
160
+ variant="box"
161
+ tone="danger"
162
+ className={alone ? className : undefined}
163
+ action={
164
+ onRetry ? (
165
+ <Button type="button" variant="secondary" size="sm" onClick={() => onRetry()}>
166
+ {labels.retry}
167
+ </Button>
168
+ ) : undefined
169
+ }
170
+ >
171
+ {labels.overviewError}
172
+ </AlertBanner>
173
+ );
174
+ // Nothing good to keep: the box alone.
175
+ if (error && !attention) return errorBox(true);
133
176
  const href = { ...DEFAULT_ADMIN_ATTENTION_HREFS, ...hrefs };
134
177
  const loading = attention === undefined;
135
178
  const shared: { key: AdminAttentionTile; label: string; description?: ReactNode }[] = [
@@ -147,9 +190,9 @@ export function AdminOverview({
147
190
  ...shared.map((t) => ({ ...t, count: attention ? attention[t.key] : undefined, href: href[t.key] })),
148
191
  ...extraTiles,
149
192
  ].filter((t) => loading || (t.count !== null && t.count !== undefined));
150
- if (!loading && tiles.length === 0) return null;
151
- return (
152
- <StatTileGrid data-slot="admin-overview" loading={loading} className={className}>
193
+ if (!loading && tiles.length === 0) return error ? errorBox(true) : null;
194
+ const grid = (
195
+ <StatTileGrid data-slot={error ? undefined : "admin-overview"} loading={loading} className={error ? undefined : className}>
153
196
  {tiles.map((t) => (
154
197
  <AttentionTile
155
198
  key={t.key}
@@ -163,4 +206,12 @@ export function AdminOverview({
163
206
  ))}
164
207
  </StatTileGrid>
165
208
  );
209
+ if (!error) return grid;
210
+ // A failed poll: the error above the last good tiles.
211
+ return (
212
+ <div data-slot="admin-overview" data-state="error" className={cn("space-y-4", className)}>
213
+ {errorBox(false)}
214
+ {grid}
215
+ </div>
216
+ );
166
217
  }
@@ -41,12 +41,27 @@ export interface AdminOwnSection<G extends string = string> extends SettingsGrou
41
41
  after?: AdminSharedSection | G;
42
42
  }
43
43
 
44
- export interface AdminSectionsOptions {
44
+ /** Where one section goes, without its words — what {@link adminSectionOrder} takes. An
45
+ * {@link AdminOwnSection} is one. */
46
+ export interface AdminSectionPlace<G extends string = string> {
47
+ id: G;
48
+ after?: AdminSharedSection | G;
49
+ }
50
+
51
+ /** The order's options: the shared sections an app does not have. */
52
+ export interface AdminSectionOrderOptions<L extends AdminSharedSection = AdminSharedSection> {
45
53
  /**
46
54
  * Shared sections this app does not have — keksdose and Kurvenschmiede have no billing
47
- * view (`["billing"]`). Left out, every shared section is in the catalogue.
55
+ * view (`["billing"]`). Left out, every shared section is in the catalogue. Since 0.43
56
+ * the result's type leaves them out too, so no cast is needed to drop `"billing"` —
57
+ * when TypeScript infers the type arguments. With them written out, name the left-out
58
+ * ones as the second (`adminSections<Own, "billing">(…)`); `adminSections<Own>(…)`
59
+ * keeps every shared id in the type, as before.
48
60
  */
49
- leaveOut?: readonly AdminSharedSection[];
61
+ leaveOut?: readonly L[];
62
+ }
63
+
64
+ export interface AdminSectionsOptions<L extends AdminSharedSection = AdminSharedSection> extends AdminSectionOrderOptions<L> {
50
65
  /**
51
66
  * A shared section that is in the catalogue but hidden for now, as
52
67
  * {@link SettingsGroup.visible}: kastlan's `{ billing: billingEnabled }` (settings §5),
@@ -55,37 +70,23 @@ export interface AdminSectionsOptions {
55
70
  visible?: Partial<Record<AdminSharedSection, boolean>>;
56
71
  }
57
72
 
73
+ /** The shared sections an app keeps when it leaves out `L`. A `leaveOut` typed as the
74
+ * whole union (a variable of `AdminSharedSection[]`) says nothing about which, so it
75
+ * narrows nothing. */
76
+ export type AdminKeptSection<L extends AdminSharedSection = AdminSharedSection> = [AdminSharedSection] extends [L]
77
+ ? AdminSharedSection
78
+ : Exclude<AdminSharedSection, L>;
79
+
58
80
  /** The shared section after which the app's sections go when they name no place. */
59
81
  const OWN_DEFAULT_AFTER: AdminSharedSection = "invitations";
60
82
 
61
- /**
62
- * An app's admin catalogue groups, in the template's order (§2.3 decision 10, §6.8): the
63
- * shared sections with the kit's icons and words, and the app's own at the places they
64
- * name. The result is `SettingsLayout`'s `groups`; the entries stay the app's (a group
65
- * with no visible entry is hidden, as on any settings page).
66
- *
67
- * const groups = adminSections(useAdminAreaLabels(), [
68
- * { id: "metrics", icon: <BarChart3 />, title: t("…"), help: t("…"), after: "overview" },
69
- * { id: "messaging", icon: <MessageSquare />, title: t("…"), help: t("…") },
70
- * { id: "health", icon: <HeartPulse />, title: t("…"), help: t("…"), after: "billing" },
71
- * ], { leaveOut: ["billing"] });
72
- *
73
- * gives keksdose's overview, metrics, users, invitations, messaging, health, activity.
74
- * An `after` that names a left-out section takes that section's place. Kurvenschmiede's
75
- * Teams needs no `after`: between Invitations and Activity is the default.
76
- *
77
- * `labels` is the `adminArea` namespace, resolved — {@link useAdminAreaLabels} inside a
78
- * component, or the app's catalogue for the language; `undefined` is English. Throws for
79
- * an own section whose id is a shared one, an id given twice, or an `after` that names no
80
- * section: the catalogue is built once, and a section quietly missing from it is worse.
81
- */
82
- export function adminSections<G extends string = never>(
83
- labels: AdminAreaLabels | undefined,
84
- own: readonly AdminOwnSection<G>[] = [],
85
- options: AdminSectionsOptions = {},
86
- ): SettingsGroup<AdminSharedSection | G>[] {
87
- const words = labels ?? DEFAULT_ADMIN_AREA_LABELS;
88
- const leaveOut = new Set<string>(options.leaveOut ?? []);
83
+ /** The shared and own sections in their order — a shared one as its definition, an own
84
+ * one as given. Throws as {@link adminSections} documents. */
85
+ function orderSections<S extends AdminSectionPlace<string>>(
86
+ own: readonly S[],
87
+ leaveOutIds: readonly AdminSharedSection[] | undefined,
88
+ ): ({ shared: AdminSectionDefinition } | { own: S })[] {
89
+ const leaveOut = new Set<string>(leaveOutIds ?? []);
89
90
  const shared = new Set<string>(ADMIN_SECTIONS.map((s) => s.id));
90
91
  const ownIds = new Set<string>();
91
92
  for (const section of own) {
@@ -102,36 +103,96 @@ export function adminSections<G extends string = never>(
102
103
 
103
104
  // Each section's followers, in the order given: the shared ones, then the own ones
104
105
  // under their `after`, recursively, so an own section may follow another own one.
105
- const followers = new Map<string, AdminOwnSection<G>[]>();
106
+ const followers = new Map<string, S[]>();
106
107
  for (const section of own) {
107
108
  const after = section.after ?? OWN_DEFAULT_AFTER;
108
109
  followers.set(after, [...(followers.get(after) ?? []), section]);
109
110
  }
110
111
 
111
- const out: SettingsGroup<AdminSharedSection | G>[] = [];
112
+ const out: ({ shared: AdminSectionDefinition } | { own: S })[] = [];
113
+ const placed = new Set<string>();
112
114
  const placeAfter = (id: string) => {
113
115
  for (const section of followers.get(id) ?? []) {
114
- const { after: _after, ...group } = section;
115
- out.push(group);
116
+ out.push({ own: section });
117
+ placed.add(section.id);
116
118
  placeAfter(section.id);
117
119
  }
118
120
  };
119
- for (const { id, icon } of ADMIN_SECTIONS) {
120
- if (!leaveOut.has(id)) {
121
- const visible = options.visible?.[id];
122
- out.push({
123
- id,
124
- icon: createElement(icon, { "aria-hidden": true }) as ReactNode,
125
- title: words.sections[id].title,
126
- help: words.sections[id].help,
127
- ...(visible === undefined ? null : { visible }),
128
- });
129
- }
121
+ for (const definition of ADMIN_SECTIONS) {
122
+ if (!leaveOut.has(definition.id)) out.push({ shared: definition });
130
123
  // A left-out section's followers keep its place.
131
- placeAfter(id);
124
+ placeAfter(definition.id);
132
125
  }
133
- const placed = new Set<string>(out.map((g) => g.id));
134
126
  const stuck = own.filter((s) => !placed.has(s.id)).map((s) => s.id);
135
127
  if (stuck.length) throw new Error(`adminSections: ${stuck.join(", ")} follow each other in a circle`);
136
128
  return out;
137
129
  }
130
+
131
+ /**
132
+ * The admin area's section ids in the template's order (0.43, keksdose) — what
133
+ * {@link adminSections} builds its groups in, without the words: for an app that needs the
134
+ * ORDER only (a router's list of paths, a test), outside a component, with no labels.
135
+ *
136
+ * adminSectionOrder([{ id: "metrics", after: "overview" }], { leaveOut: ["billing"] })
137
+ * // ["overview", "metrics", "users", "invitations", "activity"]
138
+ *
139
+ * Throws as {@link adminSections} does.
140
+ */
141
+ export function adminSectionOrder<G extends string = never, L extends AdminSharedSection = AdminSharedSection>(
142
+ own: readonly AdminSectionPlace<G>[] = [],
143
+ options: AdminSectionOrderOptions<L> = {},
144
+ ): (AdminKeptSection<L> | G)[] {
145
+ return orderSections(own, options.leaveOut).map((s) =>
146
+ "shared" in s ? (s.shared.id as AdminKeptSection<L>) : s.own.id,
147
+ );
148
+ }
149
+
150
+ /**
151
+ * An app's admin catalogue groups, in the template's order (§2.3 decision 10, §6.8): the
152
+ * shared sections with the kit's icons and words, and the app's own at the places they
153
+ * name. The result is `SettingsLayout`'s `groups`; the entries stay the app's (a group
154
+ * with no visible entry is hidden, as on any settings page — except, since 0.43, the
155
+ * Overview, a page of tiles that needs no entry: it is `standalone`). Since 0.43 the
156
+ * result's type leaves out the `leaveOut` sections, and {@link adminSectionOrder} gives
157
+ * the same order as ids, without words.
158
+ *
159
+ * const groups = adminSections(useAdminAreaLabels(), [
160
+ * { id: "metrics", icon: <BarChart3 />, title: t("…"), help: t("…"), after: "overview" },
161
+ * { id: "messaging", icon: <MessageSquare />, title: t("…"), help: t("…") },
162
+ * { id: "health", icon: <HeartPulse />, title: t("…"), help: t("…"), after: "billing" },
163
+ * ], { leaveOut: ["billing"] });
164
+ *
165
+ * gives keksdose's overview, metrics, users, invitations, messaging, health, activity.
166
+ * An `after` that names a left-out section takes that section's place. Kurvenschmiede's
167
+ * Teams needs no `after`: between Invitations and Activity is the default.
168
+ *
169
+ * `labels` is the `adminArea` namespace, resolved — {@link useAdminAreaLabels} inside a
170
+ * component, or the app's catalogue for the language; `undefined` is English. Throws for
171
+ * an own section whose id is a shared one, an id given twice, or an `after` that names no
172
+ * section: the catalogue is built once, and a section quietly missing from it is worse.
173
+ */
174
+ export function adminSections<G extends string = never, L extends AdminSharedSection = AdminSharedSection>(
175
+ labels: AdminAreaLabels | undefined,
176
+ own: readonly AdminOwnSection<G>[] = [],
177
+ options: AdminSectionsOptions<L> = {},
178
+ ): SettingsGroup<AdminKeptSection<L> | G>[] {
179
+ const words = labels ?? DEFAULT_ADMIN_AREA_LABELS;
180
+ return orderSections(own, options.leaveOut).map((s): SettingsGroup<AdminKeptSection<L> | G> => {
181
+ if ("own" in s) {
182
+ const { after: _after, ...group } = s.own;
183
+ return group;
184
+ }
185
+ const { id, icon } = s.shared;
186
+ const visible = options.visible?.[id];
187
+ return {
188
+ id: id as AdminKeptSection<L>,
189
+ icon: createElement(icon, { "aria-hidden": true }) as ReactNode,
190
+ title: words.sections[id].title,
191
+ help: words.sections[id].help,
192
+ // 0.43 (kastlan): the overview is a page of tiles, not of setting cards — it shows
193
+ // with no entry of its own.
194
+ ...(id === "overview" ? { standalone: true } : null),
195
+ ...(visible === undefined ? null : { visible }),
196
+ };
197
+ });
198
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ComponentPropsWithRef, ReactNode } from "react";
2
2
  import { CreditCard, Hourglass, PencilOff } from "lucide-react";
3
3
 
4
+ import { DEFAULT_COMMON_LABELS, useKitLabels } from "../i18n/kit-labels";
4
5
  import type { LabelOverride } from "../i18n/kit-labels";
5
6
  import { AlertBanner } from "../components/alert-banner";
6
7
  import type { AlertSize, AlertTone } from "../components/alert-banner";
@@ -9,6 +10,7 @@ import { Spinner } from "../components/spinner";
9
10
  import { useBillingLabels } from "./billing-labels";
10
11
  import type { BillingLabels } from "./billing-labels";
11
12
  import type { BillingPortalAccess } from "./billing-overview";
13
+ import { ExternalMark } from "./subscription-actions";
12
14
 
13
15
  /**
14
16
  * The billing banners (docs/billing-harmonization.md §3.3, §7, §12.6, §12.21):
@@ -66,6 +68,14 @@ interface BillingBannerBaseProps extends Omit<ComponentPropsWithRef<"div">, "chi
66
68
  actionLabel?: string;
67
69
  /** The action is under way: the button's spinner, no second press. */
68
70
  actionPending?: boolean;
71
+ /**
72
+ * 0.43 (§17): the action opens the provider's portal in a NEW TAB (`openProviderPage`
73
+ * in {@link onAction}): the external-link mark, "(opens in a new tab)" for a reader,
74
+ * and `target="_blank"` on an {@link actionHref}. Default: true for `payment-failed`
75
+ * with `onAction` (the portal's card page), false otherwise — an `actionHref` is
76
+ * usually the app's own subscription page.
77
+ */
78
+ actionNewTab?: boolean;
69
79
  /** An × that calls this; the app removes the banner and remembers that it did. */
70
80
  onDismiss?: () => void;
71
81
  dismissLabel?: string;
@@ -161,6 +171,7 @@ export function BillingBanner(props: BillingBannerProps) {
161
171
  onAction,
162
172
  actionLabel,
163
173
  actionPending,
174
+ actionNewTab,
164
175
  onDismiss,
165
176
  dismissLabel,
166
177
  labels: labelsProp,
@@ -168,6 +179,7 @@ export function BillingBanner(props: BillingBannerProps) {
168
179
  ...rest
169
180
  } = props;
170
181
  const labels = useBillingLabels(labelsProp);
182
+ const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
171
183
  const days = Math.max(0, Math.floor(daysLeft ?? 0));
172
184
 
173
185
  let text: string;
@@ -209,14 +221,25 @@ export function BillingBanner(props: BillingBannerProps) {
209
221
  }
210
222
 
211
223
  const words = actionLabel ?? ownAction;
224
+ const newTab = actionNewTab ?? (kind === "payment-failed" && actionHref === undefined && onAction !== undefined);
212
225
  const action =
213
226
  askBuyer || words === undefined || (actionHref === undefined && onAction === undefined) ? undefined : actionHref !== undefined ? (
214
- <Button href={actionHref} variant="secondary" size="sm">
227
+ <Button href={actionHref} variant="secondary" size="sm" external={newTab || undefined}>
215
228
  {words}
229
+ {newTab && <ExternalMark />}
216
230
  </Button>
217
231
  ) : (
218
- <Button type="button" variant="secondary" size="sm" onClick={onAction} pending={actionPending}>
232
+ <Button
233
+ type="button"
234
+ variant="secondary"
235
+ size="sm"
236
+ onClick={onAction}
237
+ pending={actionPending}
238
+ className={newTab ? "relative" : undefined}
239
+ data-portal-action={newTab ? "payment_method" : undefined}
240
+ >
219
241
  {words}
242
+ {newTab && <ExternalMark reader={common.opensInNewTab} />}
220
243
  </Button>
221
244
  );
222
245
 
@@ -179,6 +179,10 @@ export interface BillingLabels {
179
179
  /** In place of "Cancel subscription" while a cancel is scheduled (§16.7 C, `POST
180
180
  * /billing/resume`): "Keep subscription". */
181
181
  keepSubscription: string;
182
+ /** 0.43 (§17.3): the muted note under the actions while the portal opens in a new tab —
183
+ * `provider` the provider's name ("Paddle"), `app` the app's; without the app, "this
184
+ * page". "Payment and invoices open at Paddle in a new tab; Kastlan stays open here." */
185
+ portalOpensNewTab: (provider: string, app?: string) => string;
182
186
 
183
187
  /* ── The pay page (§14.4) ── */
184
188
  /* Built into the static page `dist/pay/` at the kit's build: it has no React and no
@@ -334,6 +338,8 @@ export const DEFAULT_BILLING_LABELS: BillingLabels = {
334
338
  cancelConfirmBody: (date) => `Your subscription ends on ${date}. Until then everything works as before.`,
335
339
  cancelConfirmAction: "Cancel subscription now",
336
340
  keepSubscription: "Keep subscription",
341
+ portalOpensNewTab: (provider, app) =>
342
+ `Payment and invoices open at ${provider} in a new tab; ${app ? `${app} stays open here` : "this page stays open"}.`,
337
343
 
338
344
  payOpening: "Opening the secure checkout…",
339
345
  payNothing: "There’s no payment to make here. To choose a plan, go back to the app.",
@@ -0,0 +1,75 @@
1
+ import { useState } from "react";
2
+
3
+ /**
4
+ * What the payer asked for in the app (0.43, kastlan `18bf4c0`): `"cancel"` after
5
+ * `POST /billing/cancel` answered 204, `"resume"` after `POST /billing/resume` — while the
6
+ * overview still shows the old `cancel_at_period_end`, until the provider's
7
+ * `subscription.updated` has landed (§16.3). `null`: nothing asked, or the row has moved.
8
+ */
9
+ export type CancelRequested = "cancel" | "resume" | null;
10
+
11
+ /** What the page shows for the row as read now — `CancelRequest.view(rowScheduled)`. */
12
+ export interface CancelRequestView {
13
+ /** For `SubscriptionActions`' `cancelRequested`. */
14
+ requested: CancelRequested;
15
+ /** What the plan line says — the asked-for state until the row moves, then the row's:
16
+ * "Cancels on …" at once after a cancel, "Renews on …" after "Keep subscription". */
17
+ cancelScheduled: boolean;
18
+ /** Call on the 204 of a cancel or of a resume. A cancel and its undo both asked before
19
+ * the row moved cancel out. */
20
+ noteAsked: () => void;
21
+ }
22
+
23
+ export interface CancelRequest {
24
+ /** Read the overview again every few seconds while this is true — the overview query's
25
+ * `waiting` (kastlan's `useBillingOverview({ waiting })`, a `refetchInterval` inside) —
26
+ * so the page follows the provider. Needs no overview data: pass it to the query. */
27
+ waiting: boolean;
28
+ /**
29
+ * The row as read now (the overview's `cancel_at_period_end`; `undefined` while it
30
+ * loads) → what the page shows. Call it during render, after the query: when the row
31
+ * has moved past what was asked, it drops the request (React's "adjust state while
32
+ * rendering" pattern), so `waiting` turns false and the query stops polling.
33
+ */
34
+ view: (rowScheduled: boolean | null | undefined) => CancelRequestView;
35
+ }
36
+
37
+ /**
38
+ * The in-app cancel's follow-up (0.43, kastlan's derivation in `18bf4c0`): after "Cancel
39
+ * subscription now" or "Keep subscription" the server answers 204 at once, but the row's
40
+ * `cancel_at_period_end` only changes when the provider's webhook lands. Until then the
41
+ * page shows what was asked for, and a second press would ask the provider again.
42
+ *
43
+ * The state lives in the hook and needs no overview at construction, so it goes BEFORE
44
+ * the overview query, which reads `waiting`, and the view comes after it — the overview
45
+ * read once (build review 0.43, kastlan B2; kastlan `subscription-section.tsx:74-92`):
46
+ *
47
+ * const request = useCancelRequest();
48
+ * const { data } = useBillingOverview({ waiting: request.waiting });
49
+ * const view = request.view(data?.cancel_at_period_end);
50
+ * <p>{standingLine({ ...data, cancel_at_period_end: view.cancelScheduled })}</p>
51
+ * <SubscriptionActions
52
+ * cancelAtPeriodEnd={data.cancel_at_period_end}
53
+ * cancelRequested={view.requested}
54
+ * onResume={() => resume.mutate(undefined, { onSuccess: view.noteAsked })}
55
+ * …
56
+ * />
57
+ */
58
+ export function useCancelRequest(): CancelRequest {
59
+ // What was asked, kept with the row's state it was asked against.
60
+ const [asked, setAsked] = useState<{ scheduled: boolean } | null>(null);
61
+ const view = (row: boolean | null | undefined): CancelRequestView => {
62
+ // Not read yet: nothing to show against, and nothing to drop.
63
+ if (row === undefined || row === null) return { requested: null, cancelScheduled: false, noteAsked: () => {} };
64
+ // The row has moved: it says the rest. Adjusted during render, React's pattern for
65
+ // state that follows a prop.
66
+ if (asked !== null && asked.scheduled !== row) setAsked(null);
67
+ const open = asked !== null && asked.scheduled === row;
68
+ return {
69
+ requested: open ? (row ? "resume" : "cancel") : null,
70
+ cancelScheduled: open ? !row : row,
71
+ noteAsked: () => setAsked((a) => (a !== null && a.scheduled === row ? null : { scheduled: row })),
72
+ };
73
+ };
74
+ return { waiting: asked !== null, view };
75
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The provider's hosted portal in a NEW TAB (0.43, docs/billing-harmonization.md §17):
3
+ * "Payment and invoices", the portal's "Cancel subscription" and the payment-failed
4
+ * banner's "Update payment method" open at the provider while the app stays open where
5
+ * it was. kastlan's `usePortal` (feat/kit-0.42, `72c3177`) is the reference.
6
+ *
7
+ * The checkout is NOT one of them (§17.4): it stays in the same tab, since the page waits
8
+ * for the payer's return (`useCheckoutProcessing`).
9
+ */
10
+
11
+ /** How the page went: in a tab of its own, or — the browser refused one — in this one. */
12
+ export type ProviderPageWay = "new-tab" | "same-tab";
13
+
14
+ export interface OpenProviderPageOptions {
15
+ /** The window to open from and to fall back to. Default the global `window` (a test's
16
+ * or an iframe's own). */
17
+ window?: Window;
18
+ }
19
+
20
+ /**
21
+ * Opens the provider's page in a new tab, from INSIDE the click: call it in the click
22
+ * handler itself, before any `await` — a `window.open` after one is blocked as a pop-up.
23
+ *
24
+ * 1. opens `about:blank` synchronously and sets its `opener` to `null`, so the provider's
25
+ * page cannot reach back into the app;
26
+ * 2. awaits `fetchUrl` — the app's `POST /billing/portal` with its target, answering the
27
+ * `url` (the kit sends no request);
28
+ * 3. points the tab at the URL;
29
+ * 4. on a refusal closes the tab again and rethrows, so the app shows its error;
30
+ * 5. where the browser refused the tab (a pop-up blocker), or it was closed while the
31
+ * URL was on its way, the app's own page goes there instead.
32
+ *
33
+ * <SubscriptionActions
34
+ * onPortal={(target) => {
35
+ * openProviderPage(() => api.billingPortal({ target }).then((r) => r.url)).catch(showError);
36
+ * }}
37
+ * providerName="Paddle"
38
+ * appName="Kastlan"
39
+ * />
40
+ *
41
+ * Resolves with the way it went.
42
+ */
43
+ export async function openProviderPage(
44
+ fetchUrl: () => Promise<string>,
45
+ options: OpenProviderPageOptions = {},
46
+ ): Promise<ProviderPageWay> {
47
+ const win = options.window ?? window;
48
+ let tab: Window | null;
49
+ try {
50
+ tab = win.open("about:blank", "_blank");
51
+ } catch {
52
+ tab = null;
53
+ }
54
+ if (tab) {
55
+ try {
56
+ // The provider's page must not reach back into the app.
57
+ tab.opener = null;
58
+ } catch {
59
+ /* a browser that guards `opener`: the tab is still ours to point */
60
+ }
61
+ }
62
+ let url: string;
63
+ try {
64
+ url = await fetchUrl();
65
+ } catch (error) {
66
+ tab?.close();
67
+ throw error;
68
+ }
69
+ if (tab && !tab.closed) {
70
+ tab.location.href = url;
71
+ return "new-tab";
72
+ }
73
+ win.location.assign(url);
74
+ return "same-tab";
75
+ }