@micha.bigler/ui-core-micha 4.2.0 → 4.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.4.0 - UCM-BILL-1
4
+
5
+ Subscription and token surfaces in the main entry, against the `django-core-micha` billing API (DCM-BILL-1, `me/`,
6
+ `checkout/`, `portal/`, `team/seats/`; base path `apiBase`, default `/api/billing/`). `PlanPicker` (monthly/yearly
7
+ switch when both exist, team packages with seats and term, token packages with `kinds`) continues to Stripe Checkout:
8
+ the checkout and portal links are followed only when the server returns an `https://` URL, through an injectable
9
+ `redirect`. `SubscriptionSection` is the account section "Abo": the state comes only from the server (active
10
+ subscription with end date and customer portal, team payer with seat management, team member, free access, ended
11
+ access with `AccessEndedBanner` and renewal, token balance and "buy a package"). Also `TokenBalanceBadge`,
12
+ `TokenCostPreview` (the cost, alternatives and balance the app passes in), `useBilling`, `createBillingApi` /
13
+ `defaultBillingApi` and `billingTranslations` (de/en/fr/sw, part of `uiCoreTranslations`). The operator overview and the
14
+ "free access" switch are not part of this release (UCM-BILL-2, no API yet). The server does not send the plan name,
15
+ token expiry or seat-holder names, so none are shown.
16
+
17
+ ## 4.3.0 - UCM-CHAT-5
18
+
19
+ `ChatWorkspace` gets three optional props; without them it behaves as in 4.2.x. `getSendContext()` (synchronous, no
20
+ arguments) is called once per `send` frame, right before it is sent, for every route: the composer, `send(text)` from
21
+ the answer slot, `sendRequest` and the retry of a failed turn. A plain object is sent as `context` on the frame
22
+ (`{ type: 'send', message, context }`, read by `django-core-micha` 3.3.0, DCM-CHAT-11); `undefined` or `null` sends no
23
+ `context`; anything else, or a thrown error, sends nothing, keeps the draft and shows the generic action-error notice
24
+ (a failed retry also keeps the failed turn on screen). The surface does not interpret or size-check the context; the core
25
+ answers `invalid_frame` for a bad or oversized one. The context never appears in the displayed turn text.
26
+ `sendRequest` (`{ id, text }`) sends `text` as the next message of the shown chat, like a typed send including
27
+ `getSendContext`. Each new `id` is handled once; with no chat shown, an archived chat, no connection, a running turn,
28
+ blank text or `activeChatId` `null` it is not sent. A request that arrives together with an `activeChatId` switch waits
29
+ for that chat. `onSendRequestHandled({ id, sent })` is called once per request so a host can fall back to
30
+ `composerPrefill` when `sent` is `false`. Give every request a fresh `id` (a counter); an `id` that was answered earlier
31
+ is treated as the same request and ignored.
32
+
3
33
  ## 4.2.0 - UCM-CHAT-4
4
34
 
5
35
  `ChatWorkspace` takes two optional props so a host can control which chat is shown. `activeChatId` shows that chat
package/README.md CHANGED
@@ -44,3 +44,12 @@ The main entry needs only the required peers. Charts, the chat surface and `Mark
44
44
  extra peers are optional: `@micha.bigler/ui-core-micha/charts` (`@mui/x-charts`), `/chat` and `/markdown`
45
45
  (`react-markdown`, `remark-gfm`). Install those peers only in apps that import the sub-path. The 4.0.0 entry in
46
46
  `CHANGELOG.md` has the migration table.
47
+
48
+ ## Shared chat: host controls
49
+
50
+ `ChatWorkspace` (from `@micha.bigler/ui-core-micha/chat`) takes optional props for apps that put UI around the surface;
51
+ without them it behaves as before. Each is described in `CHANGELOG.md` under the release that added it.
52
+ Since 4.3.0: `getSendContext()` (a plain object attached as `context` to every `send` frame, read by
53
+ `django-core-micha` 3.3.0), `sendRequest` (`{ id, text }`, sends a message on the host's behalf) and
54
+ `onSendRequestHandled({ id, sent })`. Since 4.2.0: `activeChatId`, `onActiveChatChange`, `composerPrefill` and
55
+ `send(text)` / `canSend` in `renderAnswerExtra`.
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { Alert, Button, Stack } from '@mui/material';
4
+ import { useTranslation } from 'react-i18next';
5
+ import { formatDate } from './format';
6
+ export const ACCESS_ENDED_BANNER_ALERT_SX = { alignItems: 'center' };
7
+ export function AccessEndedBanner({ endedAt, deletionDate, onRenew }) {
8
+ const { t, i18n } = useTranslation();
9
+ return _jsx(Alert, { severity: "warning", role: "status", sx: ACCESS_ENDED_BANNER_ALERT_SX, action: _jsx(Button, { color: "inherit", onClick: onRenew, children: t('Billing.RENEW') }), children: _jsxs(Stack, { spacing: 0.5, children: [_jsx("span", { children: t('Billing.ENDED_TITLE') }), formatDate(endedAt, i18n.language) && _jsx("span", { children: t('Billing.ENDED_ON', { date: formatDate(endedAt, i18n.language) }) }), formatDate(deletionDate, i18n.language) && _jsx("span", { children: t('Billing.DELETION_ON', { date: formatDate(deletionDate, i18n.language) }) })] }) });
10
+ }
11
+ export default AccessEndedBanner;
@@ -0,0 +1,52 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { useMemo, useState } from 'react';
3
+ import { Alert, Button, Card, FormControlLabel, Radio, RadioGroup, Stack, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material';
4
+ import { useTranslation } from 'react-i18next';
5
+ import { formatPrice, formatTokens } from './format';
6
+ import { defaultBillingApi } from './api';
7
+ import { DEFAULT_PAYMENT_HOSTS, isTrustedPaymentUrl } from './safeUrl';
8
+ export const PLAN_PICKER_CARD_SX = { width: { xs: '100%', sm: 'calc(50% - 8px)' } };
9
+ export const PLAN_PICKER_SELECTED_CARD_SX = { outline: '2px solid', outlineColor: 'primary.main', outlineOffset: 1 };
10
+ export const PLAN_PICKER_ALERT_SX = { width: '100%' };
11
+ export const PLAN_PICKER_LABEL_SX = { m: 0, p: 2, width: '100%', height: '100%', alignItems: 'flex-start' };
12
+ export function PlanPicker({ plans = [], kinds = ['subscription', 'team_package'], api = defaultBillingApi, redirect = (url) => window.location.assign(url), allowedHosts = DEFAULT_PAYMENT_HOSTS, describePlan, onError }) {
13
+ const { t, i18n } = useTranslation();
14
+ const [selected, setSelected] = useState(null);
15
+ const [interval, setInterval] = useState('month');
16
+ const [busy, setBusy] = useState(false);
17
+ const [error, setError] = useState(false);
18
+ const subscriptionPlans = plans.filter((plan) => kinds.includes('subscription') && (plan === null || plan === void 0 ? void 0 : plan.kind) === 'subscription');
19
+ const intervals = [...new Set(subscriptionPlans.map((plan) => plan.interval).filter((value) => value === 'month' || value === 'year'))];
20
+ const filteredPlans = useMemo(() => plans.filter((plan) => kinds.includes(plan === null || plan === void 0 ? void 0 : plan.kind)
21
+ && (plan.kind !== 'subscription' || intervals.length < 2 || plan.interval === interval)), [plans, kinds, intervals.length, interval]);
22
+ // A plan hidden by the interval switch is no longer selected, so a hidden plan can never be checked out.
23
+ const effectiveSelected = filteredPlans.some((plan) => plan.key === selected) ? selected : null;
24
+ const continueToPayment = async () => {
25
+ if (busy || effectiveSelected === null)
26
+ return;
27
+ setBusy(true);
28
+ setError(false);
29
+ try {
30
+ const result = await api.startCheckout(effectiveSelected);
31
+ if (!isTrustedPaymentUrl(result === null || result === void 0 ? void 0 : result.url, allowedHosts))
32
+ throw new Error('invalid_checkout_url');
33
+ redirect(result.url);
34
+ }
35
+ catch (cause) {
36
+ setError(true);
37
+ onError === null || onError === void 0 ? void 0 : onError(cause);
38
+ }
39
+ finally {
40
+ setBusy(false);
41
+ }
42
+ };
43
+ return _jsxs(Stack, { spacing: 2, children: [intervals.length > 1 && _jsxs(ToggleButtonGroup, { exclusive: true, value: interval, onChange: (_, value) => { if (value) {
44
+ setInterval(value);
45
+ setSelected(null);
46
+ } }, "aria-label": t('Billing.PLAN_INTERVAL'), children: [_jsx(ToggleButton, { value: "month", children: t('Billing.MONTHLY') }), _jsx(ToggleButton, { value: "year", children: t('Billing.YEARLY') })] }), _jsx(RadioGroup, { row: true, value: effectiveSelected !== null && effectiveSelected !== void 0 ? effectiveSelected : '', onChange: (event) => setSelected(event.target.value), "aria-label": t('Billing.PLANS'), children: _jsx(Stack, { direction: "row", useFlexGap: true, flexWrap: "wrap", spacing: 2, children: filteredPlans.map((plan) => {
47
+ const active = effectiveSelected === plan.key;
48
+ const suffix = plan.kind === 'subscription' ? (plan.interval === 'year' ? t('Billing.PER_YEAR') : t('Billing.PER_MONTH')) : '';
49
+ return _jsx(Card, { variant: "outlined", sx: [PLAN_PICKER_CARD_SX, active && PLAN_PICKER_SELECTED_CARD_SX], children: _jsx(FormControlLabel, { value: plan.key, control: _jsx(Radio, {}), sx: PLAN_PICKER_LABEL_SX, label: _jsxs(Stack, { spacing: 1, children: [_jsx(Typography, { variant: "h6", children: plan.name }), _jsxs(Typography, { children: [formatPrice(plan.amount_cents, i18n.language), suffix] }), plan.kind === 'team_package' && _jsxs(Typography, { children: [t('Billing.SEATS', { count: plan.seats }), " \u00B7 ", t('Billing.TERM_MONTHS', { count: plan.term_months })] }), Number.isFinite(plan.token_allowance) && plan.token_allowance > 0 && _jsx(Typography, { children: t('Billing.TOKENS_INCLUDED', { tokens: formatTokens(plan.token_allowance, i18n.language) }) }), describePlan === null || describePlan === void 0 ? void 0 : describePlan(plan)] }) }) }, plan.key);
50
+ }) }) }), error && _jsx(Alert, { severity: "error", sx: PLAN_PICKER_ALERT_SX, children: t('Billing.ERROR_CHECKOUT') }), _jsx(Button, { variant: "contained", disabled: effectiveSelected === null || busy, onClick: continueToPayment, children: t('Billing.CONTINUE_TO_PAYMENT') })] });
51
+ }
52
+ export default PlanPicker;
@@ -0,0 +1,95 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { useEffect, useRef, useState } from 'react';
3
+ import { Alert, Button, CircularProgress, Stack, TextField, Typography } from '@mui/material';
4
+ import { useTranslation } from 'react-i18next';
5
+ import { defaultBillingApi } from './api';
6
+ import { DEFAULT_PAYMENT_HOSTS, isTrustedPaymentUrl } from './safeUrl';
7
+ import { useBilling } from './useBilling';
8
+ import { formatDate, formatPrice, formatTokens } from './format';
9
+ import { PlanPicker } from './PlanPicker';
10
+ import { TokenBalanceBadge } from './TokenBalanceBadge';
11
+ import { AccessEndedBanner } from './AccessEndedBanner';
12
+ export const SUBSCRIPTION_SECTION_ALERT_SX = { width: '100%' };
13
+ function isStatus(error, status) { var _a; return ((_a = error === null || error === void 0 ? void 0 : error.response) === null || _a === void 0 ? void 0 : _a.status) === status; }
14
+ function activeUntil(access, t, language) {
15
+ const date = formatDate(access === null || access === void 0 ? void 0 : access.ends_at, language);
16
+ return date ? t('Billing.ACTIVE_UNTIL', { date }) : null;
17
+ }
18
+ export function SubscriptionSection({ api = defaultBillingApi, redirect = (url) => window.location.assign(url), allowedHosts = DEFAULT_PAYMENT_HOSTS, onRenew, describePlan, sx }) {
19
+ const { t, i18n } = useTranslation();
20
+ const { state, me, reload } = useBilling(api);
21
+ const [seats, setSeats] = useState(null);
22
+ const [seatError, setSeatError] = useState(null);
23
+ const [seatInput, setSeatInput] = useState('');
24
+ const [busy, setBusy] = useState(false);
25
+ const [actionError, setActionError] = useState(null);
26
+ const renewalPickerRef = useRef(null);
27
+ useEffect(() => {
28
+ var _a;
29
+ let live = true;
30
+ setSeats(null);
31
+ setSeatError(null);
32
+ if (state === 'ready' && ((_a = me === null || me === void 0 ? void 0 : me.access) === null || _a === void 0 ? void 0 : _a.status) === 'active' && me.access.source === 'team') {
33
+ api.listSeats().then((result) => { if (live)
34
+ setSeats(Array.isArray(result === null || result === void 0 ? void 0 : result.seats) ? result.seats : []); }).catch((error) => {
35
+ if (!live)
36
+ return;
37
+ if (isStatus(error, 404))
38
+ setSeats(false);
39
+ else
40
+ setSeatError(error);
41
+ });
42
+ }
43
+ return () => { live = false; };
44
+ }, [api, state, me]);
45
+ const safeRedirect = async (operation, errorKey) => {
46
+ setBusy(true);
47
+ setActionError(null);
48
+ try {
49
+ const result = await operation();
50
+ if (!isTrustedPaymentUrl(result === null || result === void 0 ? void 0 : result.url, allowedHosts))
51
+ throw new Error('invalid_billing_url');
52
+ redirect(result.url);
53
+ }
54
+ catch (error) {
55
+ setActionError(errorKey);
56
+ }
57
+ finally {
58
+ setBusy(false);
59
+ }
60
+ };
61
+ const updateSeat = async (operation) => {
62
+ setBusy(true);
63
+ setSeatError(null);
64
+ try {
65
+ await operation();
66
+ const result = await api.listSeats();
67
+ setSeats(Array.isArray(result === null || result === void 0 ? void 0 : result.seats) ? result.seats : []);
68
+ setSeatInput('');
69
+ }
70
+ catch (error) {
71
+ setSeatError(isStatus(error, 409) ? 'Billing.ERROR_SEAT_LIMIT' : isStatus(error, 400) ? 'Billing.ERROR_SEAT_USER' : 'Billing.ERROR_LOAD');
72
+ }
73
+ finally {
74
+ setBusy(false);
75
+ }
76
+ };
77
+ if (state === 'loading')
78
+ return _jsxs(Stack, { sx: sx, role: "status", direction: "row", spacing: 1, alignItems: "center", children: [_jsx(CircularProgress, { size: 20 }), _jsx(Typography, { children: t('Billing.LOADING') })] });
79
+ if (state === 'error' || !(me === null || me === void 0 ? void 0 : me.access))
80
+ return _jsxs(Stack, { sx: sx, spacing: 1, children: [_jsx(Alert, { severity: "error", sx: SUBSCRIPTION_SECTION_ALERT_SX, children: t('Billing.ERROR_LOAD') }), _jsx(Button, { onClick: reload, children: t('Billing.RETRY') })] });
81
+ const { access, tokens } = me;
82
+ if (!['active', 'read_only'].includes(access.status) || !Number.isFinite(tokens === null || tokens === void 0 ? void 0 : tokens.balance))
83
+ return _jsx(Alert, { severity: "error", sx: SUBSCRIPTION_SECTION_ALERT_SX, children: t('Billing.ERROR_LOAD') });
84
+ const dateLine = activeUntil(access, t, i18n.language);
85
+ const packagePlans = (Array.isArray(me.plans) ? me.plans : []).filter((plan) => (plan === null || plan === void 0 ? void 0 : plan.kind) === 'token_package');
86
+ const sourceLine = access.source === 'subscription' ? t('Billing.SOURCE_SUBSCRIPTION') : access.source === 'team' ? t('Billing.SOURCE_TEAM') : access.source === 'free' ? t('Billing.SOURCE_FREE') : null;
87
+ if (!sourceLine && access.status === 'active')
88
+ return _jsx(Alert, { severity: "error", sx: SUBSCRIPTION_SECTION_ALERT_SX, children: t('Billing.ERROR_LOAD') });
89
+ return _jsxs(Stack, { sx: sx, spacing: 2, children: [access.status === 'read_only' ? _jsxs(_Fragment, { children: [_jsx(AccessEndedBanner, { endedAt: access.ends_at, onRenew: onRenew || (() => {
90
+ var _a, _b, _c, _d;
91
+ (_b = (_a = renewalPickerRef.current) === null || _a === void 0 ? void 0 : _a.scrollIntoView) === null || _b === void 0 ? void 0 : _b.call(_a, { behavior: 'smooth', block: 'start' });
92
+ (_d = (_c = renewalPickerRef.current) === null || _c === void 0 ? void 0 : _c.querySelector('input[type="radio"]')) === null || _d === void 0 ? void 0 : _d.focus();
93
+ }) }), _jsx("div", { ref: renewalPickerRef, children: _jsx(PlanPicker, { plans: me.plans || [], api: api, redirect: redirect, allowedHosts: allowedHosts, describePlan: describePlan }) })] }) : _jsxs(_Fragment, { children: [_jsx(Typography, { variant: "h5", children: t('Billing.HEADING') }), _jsx(Typography, { children: sourceLine }), access.source !== 'free' && dateLine && _jsx(Typography, { children: dateLine }), access.source === 'subscription' && _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(Button, { disabled: busy, onClick: () => safeRedirect(() => api.openPortal(), 'Billing.ERROR_PORTAL'), children: t('Billing.MANAGE') }), _jsx(Typography, { children: t('Billing.MANAGE_DETAILS') })] }), access.source === 'team' && seatError && !seats && _jsx(Alert, { severity: "error", sx: SUBSCRIPTION_SECTION_ALERT_SX, children: t('Billing.ERROR_LOAD') }), access.source === 'team' && seats && _jsxs(Stack, { spacing: 1, children: [_jsx(Typography, { children: t('Billing.TEAM_SEATS') }), seats.map((seat) => _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(Typography, { children: String(seat.user_id) }), _jsx(Button, { disabled: busy, onClick: () => updateSeat(() => api.removeSeat({ userId: seat.user_id })), children: t('Billing.SEAT_REMOVE') })] }, seat.user_id)), _jsxs(Stack, { direction: "row", spacing: 1, children: [_jsx(TextField, { size: "small", label: t('Billing.SEAT_USER'), value: seatInput, onChange: (event) => setSeatInput(event.target.value) }), _jsx(Button, { disabled: busy || !seatInput.trim(), onClick: () => updateSeat(() => seatInput.includes('@') ? api.addSeat({ email: seatInput.trim() }) : api.addSeat({ userId: seatInput.trim() })), children: t('Billing.SEATS_ADD') })] }), seatError && _jsx(Alert, { severity: "error", sx: SUBSCRIPTION_SECTION_ALERT_SX, children: typeof seatError === 'string' ? t(seatError) : t('Billing.ERROR_LOAD') })] }), actionError && _jsx(Alert, { severity: "error", sx: SUBSCRIPTION_SECTION_ALERT_SX, children: t(actionError) })] }), _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsx(Typography, { children: t('Billing.BALANCE_LABEL') }), _jsx(TokenBalanceBadge, { balance: tokens.balance })] }), _jsx(Typography, { children: t('Billing.BALANCE', { tokens: formatTokens(tokens.balance, i18n.language) }) }), packagePlans.length > 0 && _jsxs(Stack, { spacing: 1, children: [_jsx(Typography, { variant: "h6", children: t('Billing.BUY_PACKAGE') }), packagePlans.map((plan) => _jsxs(Stack, { direction: "row", spacing: 1, alignItems: "center", children: [_jsxs(Typography, { children: [formatPrice(plan.amount_cents, i18n.language), " \u00B7 ", t('Billing.PACKAGE_TOKENS', { tokens: formatTokens(plan.tokens, i18n.language) })] }), _jsx(Button, { disabled: busy, onClick: () => safeRedirect(() => api.startCheckout(plan.key), 'Billing.ERROR_CHECKOUT'), children: t('Billing.PACKAGE_BUY') })] }, plan.key))] })] });
94
+ }
95
+ export default SubscriptionSection;
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { Chip } from '@mui/material';
4
+ import { useTranslation } from 'react-i18next';
5
+ import { formatTokens } from './format';
6
+ export const TOKEN_BALANCE_BADGE_SX = { fontVariantNumeric: 'tabular-nums' };
7
+ export function TokenBalanceBadge({ balance, lowBelow, sx }) {
8
+ const { t, i18n } = useTranslation();
9
+ if (!Number.isInteger(balance) || balance < 0)
10
+ return null;
11
+ const label = t('Billing.BALANCE', { tokens: formatTokens(balance, i18n.language) });
12
+ return _jsx(Chip, { label: label, "aria-label": label, color: Number.isFinite(lowBelow) && balance < lowBelow ? 'warning' : 'default', sx: [TOKEN_BALANCE_BADGE_SX, ...(Array.isArray(sx) ? sx : sx ? [sx] : [])] });
13
+ }
14
+ export default TokenBalanceBadge;
@@ -0,0 +1,18 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React from 'react';
3
+ import { Stack, Typography } from '@mui/material';
4
+ import { useTranslation } from 'react-i18next';
5
+ import { formatTokens } from './format';
6
+ export function TokenCostPreview({ cost, alternatives = [], balance }) {
7
+ const { t, i18n } = useTranslation();
8
+ if (!Number.isInteger(cost) || cost < 0 || !Number.isInteger(balance) || balance < 0 || !Array.isArray(alternatives)
9
+ || alternatives.some((item) => !item || typeof item.label !== 'string' || !Number.isInteger(item.cost) || item.cost < 0))
10
+ return null;
11
+ const insufficient = balance < cost;
12
+ // "47 Token, mit Übersetzung 71 · Guthaben 1'153": the cost and its alternatives, then the balance.
13
+ const costs = [t('Billing.COST', { tokens: formatTokens(cost, i18n.language) })];
14
+ alternatives.forEach(({ label, cost: alternativeCost }) => costs.push(`${label} ${formatTokens(alternativeCost, i18n.language)}`));
15
+ const parts = [costs.join(', '), t('Billing.BALANCE', { tokens: formatTokens(balance, i18n.language) })];
16
+ return _jsxs(Stack, { component: "span", direction: "row", spacing: 0.5, flexWrap: "wrap", "aria-live": "polite", children: [_jsx(Typography, { component: "span", color: insufficient ? 'error' : 'text.primary', children: parts.join(' · ') }), insufficient && _jsx(Typography, { component: "span", color: "error", children: t('Billing.NOT_ENOUGH') })] });
17
+ }
18
+ export default TokenCostPreview;
@@ -0,0 +1,16 @@
1
+ import apiClient from '../auth/apiClient';
2
+ import { billingApiUrls } from './wire';
3
+ export function createBillingApi(overrides = {}, { apiBase = '/api/billing/' } = {}) {
4
+ const urls = billingApiUrls(apiBase);
5
+ const data = (request) => request.then((response) => response.data);
6
+ const defaults = {
7
+ getMe: () => data(apiClient.get(urls.me)),
8
+ startCheckout: (planKey) => data(apiClient.post(urls.checkout, { plan_key: planKey })),
9
+ openPortal: () => data(apiClient.post(urls.portal)),
10
+ listSeats: () => data(apiClient.get(urls.seats)),
11
+ addSeat: ({ userId, email } = {}) => data(apiClient.post(urls.seats, userId !== undefined ? { user_id: userId } : { email })),
12
+ removeSeat: ({ userId, email } = {}) => data(apiClient.delete(urls.seats, { data: userId !== undefined ? { user_id: userId } : { email } })),
13
+ };
14
+ return Object.assign(Object.assign({}, defaults), overrides);
15
+ }
16
+ export const defaultBillingApi = createBillingApi();
@@ -0,0 +1,18 @@
1
+ export function formatPrice(amountCents, language) {
2
+ if (!Number.isFinite(amountCents))
3
+ return '';
4
+ return new Intl.NumberFormat(language, { style: 'currency', currency: 'USD' }).format(amountCents / 100);
5
+ }
6
+ export function formatTokens(count, language) {
7
+ if (!Number.isFinite(count))
8
+ return '';
9
+ return new Intl.NumberFormat(language, { maximumFractionDigits: 0 }).format(count);
10
+ }
11
+ export function formatDate(iso, language) {
12
+ if (typeof iso !== 'string' || !iso)
13
+ return '';
14
+ const date = new Date(iso);
15
+ if (!Number.isFinite(date.getTime()))
16
+ return '';
17
+ return new Intl.DateTimeFormat(language, { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }).format(date);
18
+ }
@@ -0,0 +1,21 @@
1
+ // Hosts a checkout or portal link may point to. The server (Stripe) returns the link; the surface still checks it, so a
2
+ // malformed or tampered response can never send people to another site. An app can pass `allowedHosts` to add or replace.
3
+ export const DEFAULT_PAYMENT_HOSTS = ['*.stripe.com'];
4
+ const hostMatches = (hostname, pattern) => (pattern.startsWith('*.')
5
+ ? hostname.endsWith(pattern.slice(1)) && hostname.length > pattern.length - 1
6
+ : hostname === pattern);
7
+ /** True only for an `https://` URL without credentials whose host is one of `allowedHosts` (exact or `*.suffix`). */
8
+ export function isTrustedPaymentUrl(value, allowedHosts = DEFAULT_PAYMENT_HOSTS) {
9
+ if (typeof value !== 'string')
10
+ return false;
11
+ let url;
12
+ try {
13
+ url = new URL(value);
14
+ }
15
+ catch (_a) {
16
+ return false;
17
+ }
18
+ if (url.protocol !== 'https:' || url.username || url.password)
19
+ return false;
20
+ return allowedHosts.some((pattern) => hostMatches(url.hostname.toLowerCase(), String(pattern).toLowerCase()));
21
+ }
@@ -0,0 +1,37 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import { BILLING_SOURCES, BILLING_STATUSES } from './wire';
3
+ const validMe = (me) => {
4
+ var _a;
5
+ const access = me === null || me === void 0 ? void 0 : me.access;
6
+ return Boolean(access && typeof access === 'object' && !Array.isArray(access)
7
+ && [BILLING_STATUSES.ACTIVE, BILLING_STATUSES.READ_ONLY].includes(access.status)
8
+ && [BILLING_SOURCES.SUBSCRIPTION, BILLING_SOURCES.TEAM, BILLING_SOURCES.FREE, BILLING_SOURCES.NONE].includes(access.source)
9
+ && (access.ends_at === null || (typeof access.ends_at === 'string' && Number.isFinite(new Date(access.ends_at).getTime())))
10
+ && Number.isInteger((_a = me.tokens) === null || _a === void 0 ? void 0 : _a.balance) && me.tokens.balance >= 0);
11
+ };
12
+ export function useBilling(api) {
13
+ const [state, setState] = useState('loading');
14
+ const [me, setMe] = useState(null);
15
+ const [reloadCount, setReloadCount] = useState(0);
16
+ const reload = useCallback(() => { setState('loading'); setReloadCount((count) => count + 1); }, []);
17
+ useEffect(() => {
18
+ let live = true;
19
+ setState('loading');
20
+ Promise.resolve().then(() => api.getMe()).then((result) => {
21
+ if (!live)
22
+ return;
23
+ if (!validMe(result)) {
24
+ setMe(null);
25
+ setState('error');
26
+ return;
27
+ }
28
+ setMe(result);
29
+ setState('ready');
30
+ }).catch(() => { if (live) {
31
+ setMe(null);
32
+ setState('error');
33
+ } });
34
+ return () => { live = false; };
35
+ }, [api, reloadCount]);
36
+ return { state, me, reload };
37
+ }
@@ -0,0 +1,13 @@
1
+ export const BILLING_STATUSES = { ACTIVE: 'active', READ_ONLY: 'read_only' };
2
+ export const BILLING_SOURCES = { SUBSCRIPTION: 'subscription', TEAM: 'team', FREE: 'free', NONE: null };
3
+ export const BILLING_KINDS = { SUBSCRIPTION: 'subscription', TEAM_PACKAGE: 'team_package', TOKEN_PACKAGE: 'token_package' };
4
+ export const BILLING_ERROR_CODES = ['unknown_plan', 'checkout_unavailable', 'no_customer', 'portal_unavailable', 'no_active_team', 'invalid_user', 'seat_limit'];
5
+ export const billingApiUrls = (base = '/api/billing/') => {
6
+ const root = base.replace(/\/$/, '') + '/';
7
+ return {
8
+ me: root + 'me/',
9
+ checkout: root + 'checkout/',
10
+ portal: root + 'portal/',
11
+ seats: root + 'team/seats/',
12
+ };
13
+ };
@@ -9,6 +9,26 @@ import ChatThread from './ChatThread';
9
9
  import ChatComposer from './ChatComposer';
10
10
  import ModelPicker from './ModelPicker';
11
11
  const PENDING_ID = 'pending';
12
+ const buildSendFrame = (message, getContext) => {
13
+ if (typeof getContext !== 'function')
14
+ return { frame: { type: 'send', message } };
15
+ try {
16
+ const context = getContext();
17
+ if (context == null)
18
+ return { frame: { type: 'send', message } };
19
+ if (typeof context !== 'object' || Array.isArray(context))
20
+ return { error: true };
21
+ const prototype = Object.getPrototypeOf(context);
22
+ if (prototype !== Object.prototype && prototype !== null)
23
+ return { error: true };
24
+ if (typeof JSON.stringify(context) !== 'string')
25
+ return { error: true };
26
+ return { frame: { type: 'send', message, context } };
27
+ }
28
+ catch (_a) {
29
+ return { error: true };
30
+ }
31
+ };
12
32
  const bySeq = (a, b) => { var _a, _b; return ((_a = a.seq) !== null && _a !== void 0 ? _a : Number.MAX_SAFE_INTEGER) - ((_b = b.seq) !== null && _b !== void 0 ? _b : Number.MAX_SAFE_INTEGER); };
13
33
  const chronological = (turns) => [...(turns || [])].sort(bySeq);
14
34
  const isGenerating = (turn) => turn.state === 'generating';
@@ -42,7 +62,7 @@ const appendDelta = (turns, text) => {
42
62
  * Props: `apiBase` and `wsUrl` (where the app mounted the chat routes), `api` (partial overrides of the REST
43
63
  * calls; every function not given keeps its default), `scope` (passed when creating a chat), `sx`.
44
64
  */
45
- export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, unavailableReasonText, sidePanel, sidePanelLabel, renderAnswerExtra, renderComposerExtra, onAppFrame, activeChatId, onActiveChatChange, composerPrefill, layout = 'viewport', compactBelow = 720 }) {
65
+ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, unavailableReasonText, sidePanel, sidePanelLabel, renderAnswerExtra, renderComposerExtra, onAppFrame, activeChatId, onActiveChatChange, composerPrefill, getSendContext, sendRequest, onSendRequestHandled, layout = 'viewport', compactBelow = 720 }) {
46
66
  const { t } = useTranslation();
47
67
  const theme = useTheme();
48
68
  const viewportNarrow = useMediaQuery(theme.breakpoints.down('md'));
@@ -78,6 +98,13 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
78
98
  const [draft, setDraft] = useState('');
79
99
  const composerInputRef = useRef(null);
80
100
  const lastPrefillId = useRef(undefined);
101
+ const lastSendRequestId = useRef(undefined);
102
+ const deferredSendRequest = useRef(null); // `{ id, wanted }`: a request that had to wait for the chat named by `activeChatId`
103
+ const connectedChatId = useRef(null);
104
+ const getSendContextRef = useRef(getSendContext);
105
+ getSendContextRef.current = getSendContext;
106
+ const sendRequestHandledRef = useRef(onSendRequestHandled);
107
+ sendRequestHandledRef.current = onSendRequestHandled;
81
108
  const [focusRequest, setFocusRequest] = useState(0);
82
109
  const focusPending = useRef(false);
83
110
  const [problem, setProblem] = useState(null);
@@ -186,6 +213,7 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
186
213
  }, [activeChatId, status, listsLoaded]);
187
214
  // Clear the previous chat's content before the browser paints, so a host-driven switch never flashes it under the new title.
188
215
  useLayoutEffect(() => {
216
+ connectedChatId.current = null; // the socket of the new chat has not opened yet
189
217
  setTurns([]);
190
218
  setOlderCursor(null);
191
219
  setTool(null);
@@ -239,8 +267,8 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
239
267
  setUnavailableReason(frame.reason || unavailableReason);
240
268
  }
241
269
  }, [chat === null || chat === void 0 ? void 0 : chat.id]);
242
- const onOpen = useCallback(() => { setOpenedOnce(true); setProblem((current) => (current === 'connection_lost' ? null : current)); }, []);
243
- const onClose = useCallback(({ reason, retrying }) => { if (!retrying)
270
+ const onOpen = useCallback((chatId) => { connectedChatId.current = String(chatId); setOpenedOnce(true); setProblem((current) => (current === 'connection_lost' ? null : current)); }, []);
271
+ const onClose = useCallback(({ reason, retrying }) => { connectedChatId.current = null; if (!retrying)
244
272
  setProblem(reason); }, []);
245
273
  const socket = useChatSocket(chat === null || chat === void 0 ? void 0 : chat.id, { wsUrl, onFrame, onOpen, onClose });
246
274
  useEffect(() => {
@@ -262,6 +290,53 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
262
290
  focusPending.current = true;
263
291
  setFocusRequest((count) => count + 1);
264
292
  }, [composerPrefill, chat, isArchived, activeChatId]);
293
+ // Host send request, handled once per id. A request that arrives before the chat named by `activeChatId` is shown waits
294
+ // for that chat AND for its socket to open (sending needs a connected socket of that chat); a chat that cannot be found
295
+ // ends the wait. Every other request is decided at once. Decided = sent if the surface can send right now, otherwise
296
+ // reported as not sent.
297
+ useEffect(() => {
298
+ var _a;
299
+ const answerFor = (id, sent) => {
300
+ var _a, _b;
301
+ lastSendRequestId.current = id;
302
+ if (((_a = deferredSendRequest.current) === null || _a === void 0 ? void 0 : _a.id) === id)
303
+ deferredSendRequest.current = null;
304
+ (_b = sendRequestHandledRef.current) === null || _b === void 0 ? void 0 : _b.call(sendRequestHandledRef, { id, sent });
305
+ };
306
+ // A waiting request that the host replaced or withdrew is answered as not sent, exactly once.
307
+ const waiting = deferredSendRequest.current;
308
+ if (waiting && waiting.id !== (sendRequest === null || sendRequest === void 0 ? void 0 : sendRequest.id))
309
+ answerFor(waiting.id, false);
310
+ if (sendRequest == null || sendRequest.id === lastSendRequestId.current)
311
+ return;
312
+ const answer = (sent) => answerFor(sendRequest.id, sent);
313
+ const blank = typeof sendRequest.text !== 'string' || !sendRequest.text.trim();
314
+ if (activeChatId === null || blank) {
315
+ answer(false);
316
+ return;
317
+ }
318
+ const wanted = activeChatId === undefined ? null : String(activeChatId);
319
+ const deferred = ((_a = deferredSendRequest.current) === null || _a === void 0 ? void 0 : _a.id) === sendRequest.id ? deferredSendRequest.current : null;
320
+ // The host named another chat while the request was waiting: it belonged to the earlier one, so it is not sent.
321
+ if (deferred && deferred.wanted !== wanted) {
322
+ answer(false);
323
+ return;
324
+ }
325
+ if (wanted !== null && !unloadableIds.current.has(wanted)) {
326
+ if (String(chat === null || chat === void 0 ? void 0 : chat.id) !== wanted) {
327
+ deferredSendRequest.current = { id: sendRequest.id, wanted };
328
+ return;
329
+ }
330
+ // Only a request that could be sent once connected is worth waiting for.
331
+ if (deferred && connectedChatId.current !== wanted && !isArchived && !running)
332
+ return;
333
+ }
334
+ const text = sendRequest.text;
335
+ const sent = Boolean(canSendRef.current && typeof text === 'string' && text.trim() && startTurn(text));
336
+ if (sent)
337
+ canSendRef.current = false;
338
+ answer(sent);
339
+ }, [sendRequest, chat, activeChatId, socket.connected, running]);
265
340
  // Runs after the render that carries the prefilled draft; a counter, so the same text requested twice focuses twice.
266
341
  // A disabled input (connection down) cannot take focus, so the request stays pending until it is enabled again.
267
342
  useEffect(() => {
@@ -319,7 +394,12 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
319
394
  }
320
395
  });
321
396
  const startTurn = (message) => {
322
- if (!socket.send({ type: 'send', message }))
397
+ const built = buildSendFrame(message, getSendContextRef.current);
398
+ if (built.error) {
399
+ setProblem('action_error');
400
+ return false;
401
+ }
402
+ if (!socket.send(built.frame))
323
403
  return false;
324
404
  setProblem(null);
325
405
  setTurns((current) => [...current, {
@@ -349,8 +429,8 @@ export function ChatWorkspace({ apiBase, wsUrl, api: apiOverrides, scope, sx, un
349
429
  const retry = (turn) => {
350
430
  if (!socket.connected)
351
431
  return;
352
- setTurns((current) => current.filter((item) => item.id !== turn.id));
353
- startTurn(turn.user_text);
432
+ if (startTurn(turn.user_text))
433
+ setTurns((current) => current.filter((item) => item.id !== turn.id));
354
434
  };
355
435
  const noticeFor = () => {
356
436
  const texts = {
@@ -0,0 +1,43 @@
1
+ export const billingTranslations = {
2
+ 'Billing.BALANCE': { de: 'Guthaben {{tokens}} Token', en: 'Balance {{tokens}} tokens', fr: 'Solde {{tokens}} jetons', sw: 'Salio {{tokens}} tokeni' },
3
+ 'Billing.COST': { de: '{{tokens}} Token', en: '{{tokens}} tokens', fr: '{{tokens}} jetons', sw: 'Tokeni {{tokens}}' },
4
+ 'Billing.NOT_ENOUGH': { de: 'Guthaben reicht nicht aus', en: 'Insufficient balance', fr: 'Solde insuffisant', sw: 'Salio halitoshi' },
5
+ 'Billing.ENDED_TITLE': { de: 'Abo beendet – Projekte sind schreibgeschützt. Export bleibt möglich.', en: 'Subscription ended — projects are read-only. You can still export.', fr: 'Abonnement terminé — les projets sont en lecture seule. L’export reste possible.', sw: 'Usajili umeisha — miradi inaweza kusomwa tu. Bado unaweza kuhamisha data.' },
6
+ 'Billing.ENDED_ON': { de: 'Beendet am {{date}}', en: 'Ended on {{date}}', fr: 'Terminé le {{date}}', sw: 'Uliisha tarehe {{date}}' },
7
+ 'Billing.DELETION_ON': { de: 'Löschung am {{date}}', en: 'Deletion on {{date}}', fr: 'Suppression le {{date}}', sw: 'Kufutwa tarehe {{date}}' },
8
+ 'Billing.RENEW': { de: 'Abo erneuern', en: 'Renew subscription', fr: 'Renouveler l’abonnement', sw: 'Fanya upya usajili' },
9
+ 'Billing.MONTHLY': { de: 'Monatlich', en: 'Monthly', fr: 'Mensuel', sw: 'Kila mwezi' },
10
+ 'Billing.YEARLY': { de: 'Jährlich', en: 'Yearly', fr: 'Annuel', sw: 'Kila mwaka' },
11
+ 'Billing.PER_MONTH': { de: '/ Monat', en: '/ month', fr: '/ mois', sw: '/ mwezi' },
12
+ 'Billing.PER_YEAR': { de: '/ Jahr', en: '/ year', fr: '/ an', sw: '/ mwaka' },
13
+ 'Billing.TOKENS_INCLUDED': { de: '{{tokens}} Token inklusive', en: '{{tokens}} tokens included', fr: '{{tokens}} jetons inclus', sw: 'Tokeni {{tokens}} zimejumuishwa' },
14
+ 'Billing.CONTINUE_TO_PAYMENT': { de: 'Weiter zur Bezahlung', en: 'Continue to payment', fr: 'Continuer vers le paiement', sw: 'Endelea kulipa' },
15
+ 'Billing.ERROR_CHECKOUT': { de: 'Die Bezahlung konnte nicht gestartet werden. Bitte versuchen Sie es erneut.', en: 'Checkout could not be started. Please try again.', fr: 'Le paiement n’a pas pu démarrer. Veuillez réessayer.', sw: 'Malipo hayakuweza kuanzishwa. Tafadhali jaribu tena.' },
16
+ 'Billing.ERROR_LOAD': { de: 'Abodaten konnten nicht geladen werden.', en: 'Subscription details could not be loaded.', fr: 'Impossible de charger les informations de l’abonnement.', sw: 'Maelezo ya usajili hayakuweza kupakiwa.' },
17
+ 'Billing.RETRY': { de: 'Erneut versuchen', en: 'Try again', fr: 'Réessayer', sw: 'Jaribu tena' },
18
+ 'Billing.SOURCE_SUBSCRIPTION': { de: 'Persönliches Abo', en: 'Personal subscription', fr: 'Abonnement personnel', sw: 'Usajili binafsi' },
19
+ 'Billing.SOURCE_TEAM': { de: 'Platz in einem Team-Paket', en: 'Seat in a team package', fr: 'Place dans un forfait d’équipe', sw: 'Nafasi katika kifurushi cha timu' },
20
+ 'Billing.SOURCE_FREE': { de: 'Ohne Abo freigeschaltet', en: 'Access enabled without a subscription', fr: 'Accès activé sans abonnement', sw: 'Ufikiaji umewezeshwa bila usajili' },
21
+ 'Billing.ACTIVE_UNTIL': { de: 'Aktiv bis {{date}}', en: 'Active until {{date}}', fr: 'Actif jusqu’au {{date}}', sw: 'Inatumika hadi {{date}}' },
22
+ 'Billing.MANAGE': { de: 'Abo verwalten', en: 'Manage subscription', fr: 'Gérer l’abonnement', sw: 'Dhibiti usajili' },
23
+ 'Billing.MANAGE_DETAILS': { de: 'Kündigung, Zahlungsmethode und Belege', en: 'Cancellation, payment method, and receipts', fr: 'Résiliation, moyen de paiement et reçus', sw: 'Kughairi, njia ya malipo na risiti' },
24
+ 'Billing.ERROR_PORTAL': { de: 'Das Abo-Portal ist derzeit nicht verfügbar.', en: 'The subscription portal is unavailable right now.', fr: 'Le portail d’abonnement est indisponible pour le moment.', sw: 'Tovuti ya usajili haipatikani kwa sasa.' },
25
+ 'Billing.BALANCE_LABEL': { de: 'Token-Guthaben', en: 'Token balance', fr: 'Solde de jetons', sw: 'Salio la tokeni' },
26
+ 'Billing.BUY_PACKAGE': { de: 'Token-Paket kaufen', en: 'Buy a token package', fr: 'Acheter un forfait de jetons', sw: 'Nunua kifurushi cha tokeni' },
27
+ 'Billing.PACKAGE_BUY': { de: 'Paket kaufen', en: 'Buy package', fr: 'Acheter le forfait', sw: 'Nunua kifurushi' },
28
+ 'Billing.SEATS_one': { de: '{{count}} Sitzplatz', en: '{{count}} seat', fr: '{{count}} place', sw: 'Nafasi {{count}}' },
29
+ 'Billing.SEATS_other': { de: '{{count}} Sitzplätze', en: '{{count}} seats', fr: '{{count}} places', sw: 'Nafasi {{count}}' },
30
+ 'Billing.TERM_MONTHS_one': { de: '{{count}} Monat', en: '{{count}} month', fr: '{{count}} mois', sw: 'Mwezi {{count}}' },
31
+ 'Billing.TERM_MONTHS_other': { de: '{{count}} Monate', en: '{{count}} months', fr: '{{count}} mois', sw: 'Miezi {{count}}' },
32
+ 'Billing.SEATS_ADD': { de: 'Sitzplatz hinzufügen', en: 'Add seat', fr: 'Ajouter une place', sw: 'Ongeza nafasi' },
33
+ 'Billing.SEAT_USER': { de: 'E-Mail-Adresse oder Benutzer-ID', en: 'Email address or user ID', fr: 'Adresse e-mail ou identifiant utilisateur', sw: 'Barua pepe au kitambulisho cha mtumiaji' },
34
+ 'Billing.SEAT_REMOVE': { de: 'Entfernen', en: 'Remove', fr: 'Retirer', sw: 'Ondoa' },
35
+ 'Billing.ERROR_SEAT_LIMIT': { de: 'Alle Sitzplätze sind belegt.', en: 'All seats are occupied.', fr: 'Toutes les places sont occupées.', sw: 'Nafasi zote zimejazwa.' },
36
+ 'Billing.ERROR_SEAT_USER': { de: 'Diese Person kann nicht hinzugefügt werden.', en: 'This person could not be added.', fr: 'Cette personne ne peut pas être ajoutée.', sw: 'Mtu huyu hakuweza kuongezwa.' },
37
+ 'Billing.PLAN_INTERVAL': { de: 'Abrechnungszeitraum', en: 'Billing interval', fr: 'Période de facturation', sw: 'Kipindi cha malipo' },
38
+ 'Billing.LOADING': { de: 'Abodaten werden geladen …', en: 'Loading subscription details …', fr: 'Chargement de l’abonnement …', sw: 'Inapakia maelezo ya usajili …' },
39
+ 'Billing.HEADING': { de: 'Abo', en: 'Subscription', fr: 'Abonnement', sw: 'Usajili' },
40
+ 'Billing.TEAM_SEATS': { de: 'Sitzplätze im Team-Paket', en: 'Seats in the team package', fr: 'Places dans le forfait d’équipe', sw: 'Nafasi katika kifurushi cha timu' },
41
+ 'Billing.PACKAGE_TOKENS': { de: '{{tokens}} Token', en: '{{tokens}} tokens', fr: '{{tokens}} jetons', sw: 'Tokeni {{tokens}}' },
42
+ 'Billing.PLANS': { de: 'Pläne', en: 'Plans', fr: 'Offres', sw: 'Mipango' },
43
+ };
@@ -10,6 +10,7 @@ var __rest = (this && this.__rest) || function (s, e) {
10
10
  return t;
11
11
  };
12
12
  import { authTranslations } from './authTranslations';
13
+ import { billingTranslations } from './billingTranslations';
13
14
  import { chartsTranslations } from './chartsTranslations';
14
15
  import { chatTranslations } from './chatTranslations';
15
16
  import { messagingTranslations } from './messagingTranslations';
@@ -17,7 +18,7 @@ import { notificationsTranslations } from './notificationsTranslations';
17
18
  import { onboardingTranslations } from './onboardingTranslations';
18
19
  import { sectionNavTranslations } from './sectionNavTranslations';
19
20
  import { userMenuTranslations } from './userMenuTranslations';
20
- export const uiCoreTranslations = Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, authTranslations), chartsTranslations), chatTranslations), messagingTranslations), notificationsTranslations), onboardingTranslations), sectionNavTranslations), userMenuTranslations);
21
+ export const uiCoreTranslations = Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, authTranslations), billingTranslations), chartsTranslations), chatTranslations), messagingTranslations), notificationsTranslations), onboardingTranslations), sectionNavTranslations), userMenuTranslations);
21
22
  export function createUiCoreTranslations({ germanVariant = 'formal' } = {}) {
22
23
  return Object.fromEntries(Object.entries(uiCoreTranslations).map(([key, value]) => {
23
24
  const { de_informal } = value, rest = __rest(value, ["de_informal"]);
package/dist/index.js CHANGED
@@ -42,6 +42,14 @@ export { MobileBottomNav } from './components/MobileBottomNav';
42
42
  export { default as StatTile } from './components/StatTile';
43
43
  export { default as SoftChip } from './components/SoftChip';
44
44
  export { default as EmptyState } from './components/EmptyState';
45
+ // --- 5.1 Billing ---
46
+ export { TokenBalanceBadge } from './billing/TokenBalanceBadge';
47
+ export { TokenCostPreview } from './billing/TokenCostPreview';
48
+ export { AccessEndedBanner } from './billing/AccessEndedBanner';
49
+ export { PlanPicker } from './billing/PlanPicker';
50
+ export { SubscriptionSection } from './billing/SubscriptionSection';
51
+ export { useBilling } from './billing/useBilling';
52
+ export { createBillingApi, defaultBillingApi } from './billing/api';
45
53
  // --- 6. Charts ---
46
54
  export { getNeutralChartPalette, useNeutralChartPalette } from './components/charts/palette';
47
55
  export { formatShortTime, formatShortDate, formatShortMonth, formatShortYear, } from './components/charts/chartLabels';
@@ -88,4 +96,5 @@ export { onboardingTranslations } from './i18n/onboardingTranslations';
88
96
  export { chartsTranslations } from './i18n/chartsTranslations';
89
97
  export { messagingTranslations } from './i18n/messagingTranslations';
90
98
  export { chatTranslations } from './i18n/chatTranslations';
99
+ export { billingTranslations } from './i18n/billingTranslations';
91
100
  export { createUiCoreTranslations, uiCoreTranslations } from './i18n/uiCoreTranslations';
@@ -33,8 +33,13 @@ import { SIGN_UP_PAGE_ALERT_SX } from '../pages/SignUpPage';
33
33
  import { SIGNUP_CONFIRM_PAGE_ALERT_SX } from '../pages/SignupConfirmPage';
34
34
  import { REAUTH_MODAL_ALERT_SX, REAUTH_MODAL_TEXT_FIELD_SX } from '../auth/ReauthModal';
35
35
  import { createKitSxEntry } from './kitSxEntry';
36
+ import { ACCESS_ENDED_BANNER_ALERT_SX } from '../billing/AccessEndedBanner';
37
+ import { TOKEN_BALANCE_BADGE_SX } from '../billing/TokenBalanceBadge';
38
+ import { PLAN_PICKER_ALERT_SX, PLAN_PICKER_CARD_SX, PLAN_PICKER_LABEL_SX, PLAN_PICKER_SELECTED_CARD_SX } from '../billing/PlanPicker';
39
+ import { SUBSCRIPTION_SECTION_ALERT_SX } from '../billing/SubscriptionSection';
36
40
  const SX_EXPORTS = {
37
41
  ACCESS_CODE_ACTION_SX,
42
+ ACCESS_ENDED_BANNER_ALERT_SX,
38
43
  ACCESS_CODE_ALERT_SX,
39
44
  ACCESS_CODE_PRIMARY_ACTION_SX,
40
45
  ACCESS_CODE_SINGLE_USE_ALERT_SX,
@@ -101,6 +106,12 @@ const SX_EXPORTS = {
101
106
  SUPPORT_RECOVERY_AGENT_PAPER_SX,
102
107
  SUPPORT_RECOVERY_ALERT_SX,
103
108
  THREAD_DAY_SEPARATOR_SX,
109
+ TOKEN_BALANCE_BADGE_SX,
110
+ PLAN_PICKER_ALERT_SX,
111
+ PLAN_PICKER_CARD_SX,
112
+ PLAN_PICKER_LABEL_SX,
113
+ PLAN_PICKER_SELECTED_CARD_SX,
114
+ SUBSCRIPTION_SECTION_ALERT_SX,
104
115
  USER_INVITE_ACTION_SX,
105
116
  USER_INVITE_ALERT_SX,
106
117
  USER_LIST_ACTION_SX,
@@ -199,4 +210,11 @@ export const KIT_COMPONENT_SX_REGISTRY = [
199
210
  entry('SignupConfirmPage.alert', 'MuiAlert', SIGNUP_CONFIRM_PAGE_ALERT_SX),
200
211
  entry('ReauthModal.alert', 'MuiAlert', REAUTH_MODAL_ALERT_SX),
201
212
  entry('ReauthModal.field', 'MuiTextField', REAUTH_MODAL_TEXT_FIELD_SX),
213
+ entry('AccessEndedBanner.alert', 'MuiAlert', ACCESS_ENDED_BANNER_ALERT_SX),
214
+ entry('TokenBalanceBadge.root', 'MuiChip', TOKEN_BALANCE_BADGE_SX),
215
+ entry('PlanPicker.card', 'MuiCard', PLAN_PICKER_CARD_SX),
216
+ entry('PlanPicker.selectedCard', 'MuiCard', PLAN_PICKER_SELECTED_CARD_SX),
217
+ entry('PlanPicker.label', 'MuiFormControlLabel', PLAN_PICKER_LABEL_SX),
218
+ entry('PlanPicker.alert', 'MuiAlert', PLAN_PICKER_ALERT_SX),
219
+ entry('SubscriptionSection.alert', 'MuiAlert', SUBSCRIPTION_SECTION_ALERT_SX),
202
220
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "4.2.0",
3
+ "version": "4.4.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "exports": {