@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.
- package/README.md +12 -6
- package/dist/admin/admin-area-labels.d.ts +4 -0
- package/dist/admin/admin-area-labels.js +3 -1
- package/dist/admin/admin-area-labels.js.map +1 -1
- package/dist/admin/admin-overview.d.ts +25 -8
- package/dist/admin/admin-overview.js +26 -3
- package/dist/admin/admin-overview.js.map +1 -1
- package/dist/admin/admin-sections.d.ts +36 -5
- package/dist/admin/admin-sections.js +35 -18
- package/dist/admin/admin-sections.js.map +1 -1
- package/dist/billing/billing-banner.d.ts +8 -0
- package/dist/billing/billing-banner.js +25 -2
- package/dist/billing/billing-banner.js.map +1 -1
- package/dist/billing/billing-labels.d.ts +4 -0
- package/dist/billing/billing-labels.js +1 -0
- package/dist/billing/billing-labels.js.map +1 -1
- package/dist/billing/cancel-request.d.ts +53 -0
- package/dist/billing/cancel-request.js +21 -0
- package/dist/billing/cancel-request.js.map +1 -0
- package/dist/billing/provider-page.d.ts +40 -0
- package/dist/billing/provider-page.js +33 -0
- package/dist/billing/provider-page.js.map +1 -0
- package/dist/billing/subscription-actions.d.ts +32 -1
- package/dist/billing/subscription-actions.js +57 -4
- package/dist/billing/subscription-actions.js.map +1 -1
- package/dist/components/account-chips.d.ts +8 -1
- package/dist/components/account-chips.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/feedback/feedback-menu.d.ts +7 -4
- package/dist/feedback/feedback-menu.js +4 -2
- package/dist/feedback/feedback-menu.js.map +1 -1
- package/dist/hooks/use-search-param-state.js +4 -1
- package/dist/hooks/use-search-param-state.js.map +1 -1
- package/dist/i18n/defaults.js +2 -0
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.js +36 -2
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +3 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +51 -2
- package/dist/i18n/locales/en.js.map +3 -3
- package/dist/i18n/locales/es.js +36 -2
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +36 -2
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +36 -2
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +36 -2
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +36 -2
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +15 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/search/global-search.d.ts +3 -0
- package/dist/search/global-search.js +3 -1
- package/dist/search/global-search.js.map +1 -1
- package/dist/settings/settings-catalogue.d.ts +9 -1
- package/dist/settings/settings-catalogue.js +1 -1
- package/dist/settings/settings-catalogue.js.map +1 -1
- package/dist/shell/admin-menu.d.ts +26 -3
- package/dist/shell/admin-menu.js +8 -4
- package/dist/shell/admin-menu.js.map +1 -1
- package/dist/shell/dev-switchers.js +5 -0
- package/dist/shell/dev-switchers.js.map +1 -1
- package/dist/shell/top-bar-parts-labels.d.ts +6 -0
- package/dist/shell/top-bar-parts-labels.js +4 -1
- package/dist/shell/top-bar-parts-labels.js.map +1 -1
- package/dist/shell/tour-menu.d.ts +19 -10
- package/dist/shell/tour-menu.js +20 -6
- package/dist/shell/tour-menu.js.map +1 -1
- package/dist/shell.d.ts +2 -0
- package/dist/shell.js +2 -0
- package/dist/shell.js.map +1 -1
- package/dist/tour/tour-catalogue-labels.d.ts +44 -0
- package/dist/tour/tour-catalogue-labels.js +32 -0
- package/dist/tour/tour-catalogue-labels.js.map +1 -0
- package/dist/tour/tour-catalogue.d.ts +68 -0
- package/dist/tour/tour-catalogue.js +189 -0
- package/dist/tour/tour-catalogue.js.map +1 -0
- package/dist/tour/tour-info.d.ts +112 -0
- package/dist/tour/tour-info.js +36 -0
- package/dist/tour/tour-info.js.map +1 -0
- package/dist/tour/use-tour-progress.d.ts +61 -0
- package/dist/tour/use-tour-progress.js +116 -0
- package/dist/tour/use-tour-progress.js.map +1 -0
- package/dist/tour/use-tour-url-sync.d.ts +58 -0
- package/dist/tour/use-tour-url-sync.js +110 -0
- package/dist/tour/use-tour-url-sync.js.map +1 -0
- package/dist/tour.d.ts +8 -0
- package/dist/tour.js +18 -0
- package/dist/tour.js.map +1 -1
- package/package.json +1 -1
- package/src/admin/admin-area-labels.ts +7 -1
- package/src/admin/admin-overview.tsx +61 -10
- package/src/admin/admin-sections.tsx +109 -48
- package/src/billing/billing-banner.tsx +25 -2
- package/src/billing/billing-labels.ts +6 -0
- package/src/billing/cancel-request.ts +75 -0
- package/src/billing/provider-page.ts +75 -0
- package/src/billing/subscription-actions.tsx +97 -11
- package/src/components/account-chips.tsx +8 -1
- package/src/feedback/feedback-menu.tsx +8 -3
- package/src/hooks/use-search-param-state.ts +6 -1
- package/src/i18n/defaults.ts +2 -0
- package/src/i18n/german.ts +36 -1
- package/src/i18n/kit-labels.tsx +3 -0
- package/src/i18n/locales/en.ts +13 -0
- package/src/i18n/locales/es.ts +36 -1
- package/src/i18n/locales/fr.ts +36 -1
- package/src/i18n/locales/hu.ts +36 -1
- package/src/i18n/locales/it.ts +36 -1
- package/src/i18n/locales/zh.ts +36 -1
- package/src/i18n/review.ts +14 -0
- package/src/index.ts +8 -0
- package/src/search/global-search.tsx +5 -0
- package/src/settings/settings-catalogue.ts +10 -2
- package/src/shell/admin-menu.tsx +34 -5
- package/src/shell/dev-switchers.tsx +8 -0
- package/src/shell/top-bar-parts-labels.ts +9 -0
- package/src/shell/tour-menu.tsx +50 -15
- package/src/shell.ts +3 -0
- package/src/tour/tour-catalogue-labels.ts +75 -0
- package/src/tour/tour-catalogue.tsx +316 -0
- package/src/tour/tour-info.ts +144 -0
- package/src/tour/use-tour-progress.ts +198 -0
- package/src/tour/use-tour-url-sync.ts +221 -0
- 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: "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
121
|
-
* `
|
|
122
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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,
|
|
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:
|
|
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
|
-
|
|
115
|
-
|
|
116
|
+
out.push({ own: section });
|
|
117
|
+
placed.add(section.id);
|
|
116
118
|
placeAfter(section.id);
|
|
117
119
|
}
|
|
118
120
|
};
|
|
119
|
-
for (const
|
|
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
|
|
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
|
+
}
|