@molecule/app-settings-panel-react 1.0.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/LICENSE +115 -0
- package/README.md +487 -0
- package/dist/AccountSection.d.ts +15 -0
- package/dist/AccountSection.d.ts.map +1 -0
- package/dist/AccountSection.js +83 -0
- package/dist/AppearanceSection.d.ts +10 -0
- package/dist/AppearanceSection.d.ts.map +1 -0
- package/dist/AppearanceSection.js +17 -0
- package/dist/AuthSection.d.ts +19 -0
- package/dist/AuthSection.d.ts.map +1 -0
- package/dist/AuthSection.js +141 -0
- package/dist/BillingSection.d.ts +19 -0
- package/dist/BillingSection.d.ts.map +1 -0
- package/dist/BillingSection.js +49 -0
- package/dist/DevicesSection.d.ts +30 -0
- package/dist/DevicesSection.d.ts.map +1 -0
- package/dist/DevicesSection.js +76 -0
- package/dist/LogOutDeleteSection.d.ts +10 -0
- package/dist/LogOutDeleteSection.d.ts.map +1 -0
- package/dist/LogOutDeleteSection.js +50 -0
- package/dist/NotificationsSection.d.ts +16 -0
- package/dist/NotificationsSection.d.ts.map +1 -0
- package/dist/NotificationsSection.js +93 -0
- package/dist/SettingsContainer.d.ts +12 -0
- package/dist/SettingsContainer.d.ts.map +1 -0
- package/dist/SettingsContainer.js +15 -0
- package/dist/ThisDeviceSection.d.ts +9 -0
- package/dist/ThisDeviceSection.d.ts.map +1 -0
- package/dist/ThisDeviceSection.js +16 -0
- package/dist/TiersUpgradeSection.d.ts +15 -0
- package/dist/TiersUpgradeSection.d.ts.map +1 -0
- package/dist/TiersUpgradeSection.js +120 -0
- package/dist/context.d.ts +18 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +15 -0
- package/dist/index.d.ts +85 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +84 -0
- package/dist/pushToggle.d.ts +111 -0
- package/dist/pushToggle.d.ts.map +1 -0
- package/dist/pushToggle.js +170 -0
- package/package.json +50 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { useHttpClient, usePush, useTranslation } from '@molecule/app-react';
|
|
4
|
+
import { getClassMap } from '@molecule/app-ui';
|
|
5
|
+
import { Flex, Switch } from '@molecule/app-ui-react';
|
|
6
|
+
import { disablePushOnCurrentDevice, enablePushOnCurrentDevice, readCurrentDevicePushEnabled, } from './pushToggle.js';
|
|
7
|
+
/**
|
|
8
|
+
* Push-notification toggle section — the full receive-side enable chain:
|
|
9
|
+
* browser permission → runtime VAPID public key
|
|
10
|
+
* (`GET /api/devices/push/public-key`) → `pushManager.subscribe` with
|
|
11
|
+
* `applicationServerKey` → PATCH the subscription onto the current device
|
|
12
|
+
* row (`/api/devices/:id { pushSubscription, hasPushSubscription }`), which
|
|
13
|
+
* is where the api-side push fan-outs look for it.
|
|
14
|
+
*
|
|
15
|
+
* Initial state reflects SERVER truth (the current device row's
|
|
16
|
+
* `hasPushSubscription`), and every failure surfaces as an honest inline
|
|
17
|
+
* message — a dev build without a service worker fails fast instead of
|
|
18
|
+
* hanging the switch.
|
|
19
|
+
*/
|
|
20
|
+
export function NotificationsSection() {
|
|
21
|
+
const cm = getClassMap();
|
|
22
|
+
const { t } = useTranslation();
|
|
23
|
+
const http = useHttpClient();
|
|
24
|
+
const { requestPermission, register, unregister } = usePush();
|
|
25
|
+
const [enabled, setEnabled] = useState(false);
|
|
26
|
+
const [busy, setBusy] = useState(false);
|
|
27
|
+
const [error, setError] = useState(null);
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
let cancelled = false;
|
|
30
|
+
// Server truth for THIS device; resolves false (never throws) when the
|
|
31
|
+
// devices endpoint is unavailable.
|
|
32
|
+
readCurrentDevicePushEnabled(http).then((serverEnabled) => {
|
|
33
|
+
if (!cancelled && serverEnabled)
|
|
34
|
+
setEnabled(true);
|
|
35
|
+
});
|
|
36
|
+
return () => {
|
|
37
|
+
cancelled = true;
|
|
38
|
+
};
|
|
39
|
+
}, [http]);
|
|
40
|
+
const failureMessage = (result) => {
|
|
41
|
+
switch (result.reason) {
|
|
42
|
+
case 'permission-denied':
|
|
43
|
+
return t('settings.pushPermissionDenied', undefined, {
|
|
44
|
+
defaultValue: 'Notification permission was denied.',
|
|
45
|
+
});
|
|
46
|
+
case 'server-unconfigured':
|
|
47
|
+
return t('settings.pushServerUnavailable', undefined, {
|
|
48
|
+
defaultValue: 'Push notifications are not configured on this server.',
|
|
49
|
+
});
|
|
50
|
+
case 'register-failed':
|
|
51
|
+
// The push provider throws already-localized messages (not supported /
|
|
52
|
+
// no service worker in a dev build / push-service rejection).
|
|
53
|
+
return (result.message ||
|
|
54
|
+
t('settings.pushUpdateFailed', undefined, {
|
|
55
|
+
defaultValue: 'Could not update push notification settings.',
|
|
56
|
+
}));
|
|
57
|
+
case 'persist-failed':
|
|
58
|
+
return t('settings.pushUpdateFailed', undefined, {
|
|
59
|
+
defaultValue: 'Could not update push notification settings.',
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const handleToggle = async (next) => {
|
|
64
|
+
if (busy)
|
|
65
|
+
return;
|
|
66
|
+
setBusy(true);
|
|
67
|
+
setError(null);
|
|
68
|
+
try {
|
|
69
|
+
if (next) {
|
|
70
|
+
const result = await enablePushOnCurrentDevice({ http, requestPermission, register });
|
|
71
|
+
if (result.ok) {
|
|
72
|
+
setEnabled(true);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
setError(failureMessage(result));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
const result = await disablePushOnCurrentDevice({ http, unregister });
|
|
80
|
+
// The switch goes off locally either way; a failed server clear is
|
|
81
|
+
// still reported so the user knows sends may continue.
|
|
82
|
+
setEnabled(false);
|
|
83
|
+
if (!result.ok) {
|
|
84
|
+
setError(failureMessage(result));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
setBusy(false);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
return (_jsxs("section", { children: [_jsx("h3", { className: cm.cn(cm.sectionHeading, cm.sp('mb', 3)), children: t('settings.notifications') }), _jsxs(Flex, { align: "center", justify: "between", children: [_jsx("span", { className: cm.textSize('sm'), children: t('settings.pushNotifications') }), _jsx(Switch, { checked: enabled, disabled: busy, onChange: (e) => handleToggle(e.target.checked), size: "sm", "data-mol-id": "settings-push-toggle" })] }), error ? (_jsx("p", { className: cm.cn(cm.textSize('xs'), cm.textError, cm.sp('mt', 1)), role: "alert", "data-mol-id": "settings-push-error", children: error })) : null] }));
|
|
93
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type ReactElement, type ReactNode } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Outer settings-panel layout: padded vertical stack that hosts the
|
|
4
|
+
* section sub-components. Publishes `onClose` to descendants via
|
|
5
|
+
* context so `<LogOutDeleteSection>` etc. can dismiss the panel
|
|
6
|
+
* after an action without explicit prop threading.
|
|
7
|
+
*/
|
|
8
|
+
export declare function SettingsContainer({ onClose, children, }: {
|
|
9
|
+
onClose: () => void;
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
}): ReactElement;
|
|
12
|
+
//# sourceMappingURL=SettingsContainer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SettingsContainer.d.ts","sourceRoot":"","sources":["../src/SettingsContainer.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,YAAY,EAAE,KAAK,SAAS,EAAW,MAAM,OAAO,CAAA;AAMlE;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,EAChC,OAAO,EACP,QAAQ,GACT,EAAE;IACD,OAAO,EAAE,MAAM,IAAI,CAAA;IACnB,QAAQ,EAAE,SAAS,CAAA;CACpB,GAAG,YAAY,CAQf"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo } from 'react';
|
|
3
|
+
import { getClassMap } from '@molecule/app-ui';
|
|
4
|
+
import { SettingsPanelContext } from './context.js';
|
|
5
|
+
/**
|
|
6
|
+
* Outer settings-panel layout: padded vertical stack that hosts the
|
|
7
|
+
* section sub-components. Publishes `onClose` to descendants via
|
|
8
|
+
* context so `<LogOutDeleteSection>` etc. can dismiss the panel
|
|
9
|
+
* after an action without explicit prop threading.
|
|
10
|
+
*/
|
|
11
|
+
export function SettingsContainer({ onClose, children, }) {
|
|
12
|
+
const cm = getClassMap();
|
|
13
|
+
const value = useMemo(() => ({ onClose }), [onClose]);
|
|
14
|
+
return (_jsx(SettingsPanelContext.Provider, { value: value, children: _jsx("div", { className: cm.cn(cm.sp('p', 6), cm.stack(6)), children: children }) }));
|
|
15
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { JSX } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* "This device" detail strip — OS, browser, online/offline.
|
|
4
|
+
*
|
|
5
|
+
* Reads from `@molecule/app-device` (useDevice hook) + browser
|
|
6
|
+
* `navigator.onLine`. No side effects, no app-specific state.
|
|
7
|
+
*/
|
|
8
|
+
export declare function ThisDeviceSection(): JSX.Element;
|
|
9
|
+
//# sourceMappingURL=ThisDeviceSection.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ThisDeviceSection.d.ts","sourceRoot":"","sources":["../src/ThisDeviceSection.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,OAAO,CAAA;AAMhC;;;;;GAKG;AACH,wBAAgB,iBAAiB,IAAI,GAAG,CAAC,OAAO,CAuB/C"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useDevice, useTranslation } from '@molecule/app-react';
|
|
3
|
+
import { getClassMap } from '@molecule/app-ui';
|
|
4
|
+
import { Flex } from '@molecule/app-ui-react';
|
|
5
|
+
/**
|
|
6
|
+
* "This device" detail strip — OS, browser, online/offline.
|
|
7
|
+
*
|
|
8
|
+
* Reads from `@molecule/app-device` (useDevice hook) + browser
|
|
9
|
+
* `navigator.onLine`. No side effects, no app-specific state.
|
|
10
|
+
*/
|
|
11
|
+
export function ThisDeviceSection() {
|
|
12
|
+
const cm = getClassMap();
|
|
13
|
+
const { t } = useTranslation();
|
|
14
|
+
const { deviceInfo } = useDevice();
|
|
15
|
+
return (_jsxs("section", { children: [_jsx("h3", { className: cm.cn(cm.sectionHeading, cm.sp('mb', 3)), children: t('settings.thisDevice') }), _jsxs("dl", { className: cm.cn(cm.stack(1), cm.textSize('sm')), children: [_jsxs(Flex, { justify: "between", children: [_jsx("dt", { className: cm.textMuted, children: t('settings.platform') }), _jsx("dd", { children: deviceInfo.os.name || t('settings.unknown') })] }), _jsxs(Flex, { justify: "between", children: [_jsx("dt", { className: cm.textMuted, children: t('settings.browser') }), _jsx("dd", { children: deviceInfo.browser.name || t('settings.unknown') })] }), _jsxs(Flex, { justify: "between", children: [_jsx("dt", { className: cm.textMuted, children: t('settings.network') }), _jsx("dd", { children: navigator.onLine ? t('settings.online') : t('settings.offline') })] })] })] }));
|
|
16
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type JSX } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Billing section with full multi-tier upgrade flow.
|
|
4
|
+
*
|
|
5
|
+
* Replaces the simpler `<BillingSection>` for apps backed by
|
|
6
|
+
* `@molecule/api-payments-stripe` + `@molecule/api-resource-payment`
|
|
7
|
+
* — loads the user's current plan from `/api/billing/status`,
|
|
8
|
+
* loads available tiers from `/api/billing/tiers`, and renders an
|
|
9
|
+
* Upgrade modal with a Subscribe button per tier price. Subscribe
|
|
10
|
+
* POSTs to `/api/billing/checkout` and redirects to the Stripe
|
|
11
|
+
* checkout URL; paid users see a Cancel button that POSTs to
|
|
12
|
+
* `/api/billing/cancel`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function TiersUpgradeSection(): JSX.Element;
|
|
15
|
+
//# sourceMappingURL=TiersUpgradeSection.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TiersUpgradeSection.d.ts","sourceRoot":"","sources":["../src/TiersUpgradeSection.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,GAAG,EAAuB,MAAM,OAAO,CAAA;AAkBrD;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,IAAI,GAAG,CAAC,OAAO,CA4LjD"}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { useHttpClient, useTranslation } from '@molecule/app-react';
|
|
4
|
+
import { getClassMap } from '@molecule/app-ui';
|
|
5
|
+
import { Alert, Button, Flex, Modal } from '@molecule/app-ui-react';
|
|
6
|
+
/**
|
|
7
|
+
* Billing section with full multi-tier upgrade flow.
|
|
8
|
+
*
|
|
9
|
+
* Replaces the simpler `<BillingSection>` for apps backed by
|
|
10
|
+
* `@molecule/api-payments-stripe` + `@molecule/api-resource-payment`
|
|
11
|
+
* — loads the user's current plan from `/api/billing/status`,
|
|
12
|
+
* loads available tiers from `/api/billing/tiers`, and renders an
|
|
13
|
+
* Upgrade modal with a Subscribe button per tier price. Subscribe
|
|
14
|
+
* POSTs to `/api/billing/checkout` and redirects to the Stripe
|
|
15
|
+
* checkout URL; paid users see a Cancel button that POSTs to
|
|
16
|
+
* `/api/billing/cancel`.
|
|
17
|
+
*/
|
|
18
|
+
export function TiersUpgradeSection() {
|
|
19
|
+
const cm = getClassMap();
|
|
20
|
+
const { t } = useTranslation();
|
|
21
|
+
const http = useHttpClient();
|
|
22
|
+
const [plan, setPlan] = useState('Free');
|
|
23
|
+
const [planKey, setPlanKey] = useState('free');
|
|
24
|
+
const [tiers, setTiers] = useState([]);
|
|
25
|
+
const [showUpgrade, setShowUpgrade] = useState(false);
|
|
26
|
+
const [upgradeLoading, setUpgradeLoading] = useState(null);
|
|
27
|
+
const [upgradeError, setUpgradeError] = useState('');
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
let cancelled = false;
|
|
30
|
+
Promise.all([
|
|
31
|
+
http.get('/api/billing/status').catch(() => null),
|
|
32
|
+
http
|
|
33
|
+
.get('/api/billing/tiers')
|
|
34
|
+
.catch(() => null),
|
|
35
|
+
])
|
|
36
|
+
.then(([statusRes, tiersRes]) => {
|
|
37
|
+
if (cancelled)
|
|
38
|
+
return;
|
|
39
|
+
const status = statusRes?.data ?? null;
|
|
40
|
+
if (status?.name)
|
|
41
|
+
setPlan(status.name);
|
|
42
|
+
if (status?.planKey)
|
|
43
|
+
setPlanKey(status.planKey);
|
|
44
|
+
const list = tiersRes?.data?.data ?? [];
|
|
45
|
+
if (Array.isArray(list)) {
|
|
46
|
+
setTiers(list.map((tier) => ({
|
|
47
|
+
planKey: tier.key,
|
|
48
|
+
name: tier.name,
|
|
49
|
+
prices: (tier.prices || []).map((p) => ({
|
|
50
|
+
stripePriceId: p.stripePriceId ?? undefined,
|
|
51
|
+
interval: p.period,
|
|
52
|
+
priceLabel: p.price,
|
|
53
|
+
})),
|
|
54
|
+
})));
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
.catch(() => { });
|
|
58
|
+
return () => {
|
|
59
|
+
cancelled = true;
|
|
60
|
+
};
|
|
61
|
+
}, [http]);
|
|
62
|
+
const handleStartCheckout = async (stripePriceId) => {
|
|
63
|
+
setUpgradeError('');
|
|
64
|
+
setUpgradeLoading(stripePriceId);
|
|
65
|
+
try {
|
|
66
|
+
const res = await http.post('/api/billing/checkout', { priceId: stripePriceId });
|
|
67
|
+
const data = res.data;
|
|
68
|
+
if (data?.checkoutUrl) {
|
|
69
|
+
window.location.href = data.checkoutUrl;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (data?.updated) {
|
|
73
|
+
setShowUpgrade(false);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
setUpgradeError(t('settings.billing.checkoutFailed', undefined, {
|
|
77
|
+
defaultValue: 'Could not start checkout.',
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
setUpgradeError(err instanceof Error
|
|
82
|
+
? err.message
|
|
83
|
+
: t('settings.billing.checkoutFailed', undefined, {
|
|
84
|
+
defaultValue: 'Could not start checkout.',
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
setUpgradeLoading(null);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
const handleCancelSubscription = async () => {
|
|
92
|
+
if (!window.confirm(t('settings.billing.cancelConfirm', undefined, {
|
|
93
|
+
defaultValue: 'Cancel your subscription?',
|
|
94
|
+
})))
|
|
95
|
+
return;
|
|
96
|
+
setUpgradeError('');
|
|
97
|
+
try {
|
|
98
|
+
await http.post('/api/billing/cancel', {});
|
|
99
|
+
setPlan('Free');
|
|
100
|
+
setPlanKey('free');
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
setUpgradeError(err instanceof Error
|
|
104
|
+
? err.message
|
|
105
|
+
: t('settings.billing.cancelFailed', undefined, {
|
|
106
|
+
defaultValue: 'Could not cancel.',
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
return (_jsxs(_Fragment, { children: [_jsxs("section", { children: [_jsx("h3", { className: cm.cn(cm.sectionHeading, cm.sp('mb', 3)), children: t('settings.billing', undefined, { defaultValue: 'Billing' }) }), _jsxs(Flex, { align: "center", justify: "between", children: [_jsxs("span", { className: cm.textSize('sm'), children: [t('settings.plan', undefined, { defaultValue: 'Plan' }), _jsx("strong", { children: plan })] }), planKey === 'free' ? (_jsx(Button, { variant: "outline", size: "sm", onClick: () => setShowUpgrade(true), children: t('settings.upgrade', undefined, { defaultValue: 'Upgrade' }) })) : (_jsx(Button, { variant: "outline", size: "sm", onClick: handleCancelSubscription, children: t('settings.billing.cancel', undefined, { defaultValue: 'Cancel' }) }))] })] }), _jsxs(Modal, { open: showUpgrade, onClose: () => setShowUpgrade(false), title: t('settings.billing.upgradeTitle', undefined, {
|
|
111
|
+
defaultValue: 'Upgrade your plan',
|
|
112
|
+
}), children: [upgradeError ? (_jsx(Alert, { status: "error", className: cm.sp('mb', 4), children: upgradeError })) : null, tiers.length === 0 ? (_jsx("p", { className: cm.cn(cm.textSize('sm'), cm.textMuted), children: t('settings.billing.noTiers', undefined, {
|
|
113
|
+
defaultValue: 'No paid plans configured.',
|
|
114
|
+
}) })) : (_jsx("ul", { className: cm.stack(3), children: tiers
|
|
115
|
+
.filter((tier) => tier.planKey !== 'free')
|
|
116
|
+
.flatMap((tier) => tier.prices.filter((p) => p.stripePriceId).map((price) => ({ tier, price })))
|
|
117
|
+
.map(({ tier, price }) => (_jsx("li", { className: cm.sp('p', 4), children: _jsxs(Flex, { align: "center", justify: "between", children: [_jsxs("div", { children: [_jsxs("p", { className: cm.fontWeight('semibold'), children: [tier.name, " (", price.interval, ")"] }), _jsx("p", { className: cm.cn(cm.textSize('xs'), cm.textMuted), children: price.priceLabel })] }), _jsx(Button, { size: "sm", loading: upgradeLoading === price.stripePriceId, onClick: () => price.stripePriceId && handleStartCheckout(price.stripePriceId), children: t('settings.billing.subscribe', undefined, {
|
|
118
|
+
defaultValue: 'Subscribe',
|
|
119
|
+
}) })] }) }, `${tier.planKey}-${price.stripePriceId}`))) }))] })] }));
|
|
120
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context published by `<SettingsContainer>` to its children.
|
|
3
|
+
*
|
|
4
|
+
* The container owns the dismiss-the-panel handler; sub-components
|
|
5
|
+
* that need to close after an action (logout, delete account, email
|
|
6
|
+
* error) read it from context rather than threading the prop down.
|
|
7
|
+
*/
|
|
8
|
+
export interface SettingsPanelContextValue {
|
|
9
|
+
onClose: () => void;
|
|
10
|
+
}
|
|
11
|
+
/** React context object for the settings panel; consume via `useSettingsPanelContext`. */
|
|
12
|
+
export declare const SettingsPanelContext: import("react").Context<SettingsPanelContextValue | null>;
|
|
13
|
+
/**
|
|
14
|
+
* Reads the `onClose` handler exposed by the parent `<SettingsContainer>`.
|
|
15
|
+
* Throws if used outside the container so component misuse is loud.
|
|
16
|
+
*/
|
|
17
|
+
export declare function useSettingsPanelContext(): SettingsPanelContextValue;
|
|
18
|
+
//# sourceMappingURL=context.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"context.d.ts","sourceRoot":"","sources":["../src/context.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AACH,MAAM,WAAW,yBAAyB;IACxC,OAAO,EAAE,MAAM,IAAI,CAAA;CACpB;AAID,0FAA0F;AAC1F,eAAO,MAAM,oBAAoB,2DAAM,CAAA;AAEvC;;;GAGG;AACH,wBAAgB,uBAAuB,IAAI,yBAAyB,CAQnE"}
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react';
|
|
2
|
+
const ctx = createContext(null);
|
|
3
|
+
/** React context object for the settings panel; consume via `useSettingsPanelContext`. */
|
|
4
|
+
export const SettingsPanelContext = ctx;
|
|
5
|
+
/**
|
|
6
|
+
* Reads the `onClose` handler exposed by the parent `<SettingsContainer>`.
|
|
7
|
+
* Throws if used outside the container so component misuse is loud.
|
|
8
|
+
*/
|
|
9
|
+
export function useSettingsPanelContext() {
|
|
10
|
+
const value = useContext(ctx);
|
|
11
|
+
if (!value) {
|
|
12
|
+
throw new Error('Settings panel section components must be rendered inside <SettingsContainer>.');
|
|
13
|
+
}
|
|
14
|
+
return value;
|
|
15
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@molecule/app-settings-panel-react` — composable, batteries-included
|
|
3
|
+
* settings panel.
|
|
4
|
+
*
|
|
5
|
+
* `<SettingsContainer onClose={…}>` owns the layout and publishes `onClose`
|
|
6
|
+
* via context; each section component is independent and loads its own data
|
|
7
|
+
* through hooks:
|
|
8
|
+
* - `<AccountSection>` — name/email edit (`PATCH /api/users/:id`).
|
|
9
|
+
* - `<AppearanceSection>` — dark-mode toggle (`useTheme()`).
|
|
10
|
+
* - `<AuthSection>` — password change + TOTP two-factor
|
|
11
|
+
* (`POST /api/users/:id/verify-two-factor`).
|
|
12
|
+
* - `<NotificationsSection>` — web-push toggle (see the exported
|
|
13
|
+
* `enablePushOnCurrentDevice` / `disablePushOnCurrentDevice` helpers and
|
|
14
|
+
* their documented device-row contract).
|
|
15
|
+
* - `<BillingSection>` — read-only plan display (`GET /api/billing/status`),
|
|
16
|
+
* or `<TiersUpgradeSection>` — full Stripe upgrade/cancel flow
|
|
17
|
+
* (`/api/billing/tiers|checkout|cancel`). Use one or the other, not both.
|
|
18
|
+
* - `<DevicesSection>` / `<ThisDeviceSection>` — device list + current
|
|
19
|
+
* device (`GET/DELETE /api/devices`).
|
|
20
|
+
* - `<LogOutDeleteSection>` — sign out + delete account
|
|
21
|
+
* (`DELETE /api/users/:id`).
|
|
22
|
+
* Apps compose via JSX children — pick sections, order them, and interleave
|
|
23
|
+
* custom sections.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```tsx
|
|
27
|
+
* import {
|
|
28
|
+
* AccountSection,
|
|
29
|
+
* AppearanceSection,
|
|
30
|
+
* AuthSection,
|
|
31
|
+
* BillingSection,
|
|
32
|
+
* DevicesSection,
|
|
33
|
+
* LogOutDeleteSection,
|
|
34
|
+
* NotificationsSection,
|
|
35
|
+
* SettingsContainer,
|
|
36
|
+
* ThisDeviceSection,
|
|
37
|
+
* } from '@molecule/app-settings-panel-react'
|
|
38
|
+
*
|
|
39
|
+
* function SettingsPanel({ onClose }: { onClose: () => void }) {
|
|
40
|
+
* return (
|
|
41
|
+
* <SettingsContainer onClose={onClose}>
|
|
42
|
+
* <AccountSection />
|
|
43
|
+
* <AppearanceSection />
|
|
44
|
+
* <AuthSection />
|
|
45
|
+
* <NotificationsSection />
|
|
46
|
+
* <BillingSection />
|
|
47
|
+
* <DevicesSection />
|
|
48
|
+
* <ThisDeviceSection />
|
|
49
|
+
* <LogOutDeleteSection />
|
|
50
|
+
* </SettingsContainer>
|
|
51
|
+
* )
|
|
52
|
+
* }
|
|
53
|
+
* ```
|
|
54
|
+
*
|
|
55
|
+
* @remarks
|
|
56
|
+
* - Wiring prereqs: sections need the standard `@molecule/app-react`
|
|
57
|
+
* provider stack — `<I18nProvider>`, `<HttpProvider>` (authenticated
|
|
58
|
+
* client), `<AuthProvider>`, `<ThemeProvider>` (AppearanceSection), push +
|
|
59
|
+
* device providers (NotificationsSection/ThisDeviceSection) — plus a
|
|
60
|
+
* bonded ClassMap. `<BillingSection>` and `<LogOutDeleteSection>` also
|
|
61
|
+
* call react-router's `useNavigate()` and throw outside a `<Router>`.
|
|
62
|
+
* - Section components throw if rendered outside `<SettingsContainer>`
|
|
63
|
+
* (they read its context for `onClose`).
|
|
64
|
+
* - Server contract: the molecule API surface from
|
|
65
|
+
* `@molecule/api-resource-user`, `@molecule/api-resource-device`,
|
|
66
|
+
* `@molecule/api-two-factor`, and the billing endpoints
|
|
67
|
+
* (`/api/billing/*`) wired by the payments stack. Missing read endpoints
|
|
68
|
+
* degrade gracefully (sections render empty); the mutating actions do not.
|
|
69
|
+
* - Translations: `@molecule/app-locales-settings-panel` companion bond.
|
|
70
|
+
*
|
|
71
|
+
* @module
|
|
72
|
+
*/
|
|
73
|
+
export * from './AccountSection.js';
|
|
74
|
+
export * from './AppearanceSection.js';
|
|
75
|
+
export * from './AuthSection.js';
|
|
76
|
+
export * from './BillingSection.js';
|
|
77
|
+
export * from './context.js';
|
|
78
|
+
export * from './DevicesSection.js';
|
|
79
|
+
export * from './LogOutDeleteSection.js';
|
|
80
|
+
export * from './NotificationsSection.js';
|
|
81
|
+
export * from './pushToggle.js';
|
|
82
|
+
export * from './SettingsContainer.js';
|
|
83
|
+
export * from './ThisDeviceSection.js';
|
|
84
|
+
export * from './TiersUpgradeSection.js';
|
|
85
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuEG;AAEH,cAAc,qBAAqB,CAAA;AACnC,cAAc,wBAAwB,CAAA;AACtC,cAAc,kBAAkB,CAAA;AAChC,cAAc,qBAAqB,CAAA;AACnC,cAAc,cAAc,CAAA;AAC5B,cAAc,qBAAqB,CAAA;AACnC,cAAc,0BAA0B,CAAA;AACxC,cAAc,2BAA2B,CAAA;AACzC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,wBAAwB,CAAA;AACtC,cAAc,wBAAwB,CAAA;AACtC,cAAc,0BAA0B,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@molecule/app-settings-panel-react` — composable, batteries-included
|
|
3
|
+
* settings panel.
|
|
4
|
+
*
|
|
5
|
+
* `<SettingsContainer onClose={…}>` owns the layout and publishes `onClose`
|
|
6
|
+
* via context; each section component is independent and loads its own data
|
|
7
|
+
* through hooks:
|
|
8
|
+
* - `<AccountSection>` — name/email edit (`PATCH /api/users/:id`).
|
|
9
|
+
* - `<AppearanceSection>` — dark-mode toggle (`useTheme()`).
|
|
10
|
+
* - `<AuthSection>` — password change + TOTP two-factor
|
|
11
|
+
* (`POST /api/users/:id/verify-two-factor`).
|
|
12
|
+
* - `<NotificationsSection>` — web-push toggle (see the exported
|
|
13
|
+
* `enablePushOnCurrentDevice` / `disablePushOnCurrentDevice` helpers and
|
|
14
|
+
* their documented device-row contract).
|
|
15
|
+
* - `<BillingSection>` — read-only plan display (`GET /api/billing/status`),
|
|
16
|
+
* or `<TiersUpgradeSection>` — full Stripe upgrade/cancel flow
|
|
17
|
+
* (`/api/billing/tiers|checkout|cancel`). Use one or the other, not both.
|
|
18
|
+
* - `<DevicesSection>` / `<ThisDeviceSection>` — device list + current
|
|
19
|
+
* device (`GET/DELETE /api/devices`).
|
|
20
|
+
* - `<LogOutDeleteSection>` — sign out + delete account
|
|
21
|
+
* (`DELETE /api/users/:id`).
|
|
22
|
+
* Apps compose via JSX children — pick sections, order them, and interleave
|
|
23
|
+
* custom sections.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```tsx
|
|
27
|
+
* import {
|
|
28
|
+
* AccountSection,
|
|
29
|
+
* AppearanceSection,
|
|
30
|
+
* AuthSection,
|
|
31
|
+
* BillingSection,
|
|
32
|
+
* DevicesSection,
|
|
33
|
+
* LogOutDeleteSection,
|
|
34
|
+
* NotificationsSection,
|
|
35
|
+
* SettingsContainer,
|
|
36
|
+
* ThisDeviceSection,
|
|
37
|
+
* } from '@molecule/app-settings-panel-react'
|
|
38
|
+
*
|
|
39
|
+
* function SettingsPanel({ onClose }: { onClose: () => void }) {
|
|
40
|
+
* return (
|
|
41
|
+
* <SettingsContainer onClose={onClose}>
|
|
42
|
+
* <AccountSection />
|
|
43
|
+
* <AppearanceSection />
|
|
44
|
+
* <AuthSection />
|
|
45
|
+
* <NotificationsSection />
|
|
46
|
+
* <BillingSection />
|
|
47
|
+
* <DevicesSection />
|
|
48
|
+
* <ThisDeviceSection />
|
|
49
|
+
* <LogOutDeleteSection />
|
|
50
|
+
* </SettingsContainer>
|
|
51
|
+
* )
|
|
52
|
+
* }
|
|
53
|
+
* ```
|
|
54
|
+
*
|
|
55
|
+
* @remarks
|
|
56
|
+
* - Wiring prereqs: sections need the standard `@molecule/app-react`
|
|
57
|
+
* provider stack — `<I18nProvider>`, `<HttpProvider>` (authenticated
|
|
58
|
+
* client), `<AuthProvider>`, `<ThemeProvider>` (AppearanceSection), push +
|
|
59
|
+
* device providers (NotificationsSection/ThisDeviceSection) — plus a
|
|
60
|
+
* bonded ClassMap. `<BillingSection>` and `<LogOutDeleteSection>` also
|
|
61
|
+
* call react-router's `useNavigate()` and throw outside a `<Router>`.
|
|
62
|
+
* - Section components throw if rendered outside `<SettingsContainer>`
|
|
63
|
+
* (they read its context for `onClose`).
|
|
64
|
+
* - Server contract: the molecule API surface from
|
|
65
|
+
* `@molecule/api-resource-user`, `@molecule/api-resource-device`,
|
|
66
|
+
* `@molecule/api-two-factor`, and the billing endpoints
|
|
67
|
+
* (`/api/billing/*`) wired by the payments stack. Missing read endpoints
|
|
68
|
+
* degrade gracefully (sections render empty); the mutating actions do not.
|
|
69
|
+
* - Translations: `@molecule/app-locales-settings-panel` companion bond.
|
|
70
|
+
*
|
|
71
|
+
* @module
|
|
72
|
+
*/
|
|
73
|
+
export * from './AccountSection.js';
|
|
74
|
+
export * from './AppearanceSection.js';
|
|
75
|
+
export * from './AuthSection.js';
|
|
76
|
+
export * from './BillingSection.js';
|
|
77
|
+
export * from './context.js';
|
|
78
|
+
export * from './DevicesSection.js';
|
|
79
|
+
export * from './LogOutDeleteSection.js';
|
|
80
|
+
export * from './NotificationsSection.js';
|
|
81
|
+
export * from './pushToggle.js';
|
|
82
|
+
export * from './SettingsContainer.js';
|
|
83
|
+
export * from './ThisDeviceSection.js';
|
|
84
|
+
export * from './TiersUpgradeSection.js';
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-free logic behind the push-notifications toggle
|
|
3
|
+
* ({@link NotificationsSection}). Extracted so the full enable/disable chain —
|
|
4
|
+
* permission → runtime VAPID key fetch → subscribe → persist on the device
|
|
5
|
+
* row — is unit-testable without a browser.
|
|
6
|
+
*
|
|
7
|
+
* The server contract (shared by every molecule api):
|
|
8
|
+
*
|
|
9
|
+
* - `GET /api/devices/push/public-key` → `{ publicKey }` — the VAPID public
|
|
10
|
+
* key the browser must pass as `applicationServerKey` (Chromium rejects
|
|
11
|
+
* keyless subscriptions). 404/503 when push is not configured server-side.
|
|
12
|
+
* - `GET /api/devices` → the caller's device rows; the session's own device
|
|
13
|
+
* is flagged `isCurrent: true`.
|
|
14
|
+
* - `PATCH /api/devices/:id { pushSubscription, hasPushSubscription }` —
|
|
15
|
+
* persists the browser subscription where the api-side push fan-outs read
|
|
16
|
+
* it. Without this PATCH the server never knows the subscription exists
|
|
17
|
+
* and no push can ever reach the user.
|
|
18
|
+
*
|
|
19
|
+
* @module
|
|
20
|
+
*/
|
|
21
|
+
/** Minimal structural slice of `@molecule/app-http`'s HttpClient used here. */
|
|
22
|
+
export interface PushToggleHttp {
|
|
23
|
+
get<T = unknown>(url: string): Promise<{
|
|
24
|
+
data: T;
|
|
25
|
+
}>;
|
|
26
|
+
patch<T = unknown>(url: string, data?: unknown): Promise<{
|
|
27
|
+
data: T;
|
|
28
|
+
}>;
|
|
29
|
+
}
|
|
30
|
+
/** Device row slice returned by `GET /api/devices`. */
|
|
31
|
+
export interface PushToggleDevice {
|
|
32
|
+
id: string;
|
|
33
|
+
isCurrent?: boolean;
|
|
34
|
+
hasPushSubscription?: boolean;
|
|
35
|
+
}
|
|
36
|
+
/** Push token slice returned by `@molecule/app-push` `register()`. */
|
|
37
|
+
export interface PushToggleToken {
|
|
38
|
+
value: string;
|
|
39
|
+
platform: 'web' | 'ios' | 'android';
|
|
40
|
+
}
|
|
41
|
+
/** Why an enable/disable attempt failed (mapped to i18n by the component). */
|
|
42
|
+
export type PushToggleFailureReason = 'permission-denied' | 'server-unconfigured' | 'register-failed' | 'persist-failed';
|
|
43
|
+
/** Result of an enable/disable attempt. */
|
|
44
|
+
export type PushToggleResult = {
|
|
45
|
+
ok: true;
|
|
46
|
+
} | {
|
|
47
|
+
ok: false;
|
|
48
|
+
reason: PushToggleFailureReason;
|
|
49
|
+
message?: string;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Picks the caller's own device row: the api flags the session device
|
|
53
|
+
* `isCurrent`; fall back to the first row for sessions predating the flag.
|
|
54
|
+
*
|
|
55
|
+
* @param devices - Rows from `GET /api/devices`.
|
|
56
|
+
* @returns The current device row, or `undefined` when the user has none.
|
|
57
|
+
*/
|
|
58
|
+
export declare const findCurrentDevice: (devices: PushToggleDevice[] | undefined) => PushToggleDevice | undefined;
|
|
59
|
+
/**
|
|
60
|
+
* Converts an `@molecule/app-push` token into the device resource's
|
|
61
|
+
* `pushSubscription` shape (web PushSubscription JSON, FCM registration for
|
|
62
|
+
* Android, APNs registration for iOS).
|
|
63
|
+
*
|
|
64
|
+
* @param token - The push token returned by `register()`.
|
|
65
|
+
* @returns The `pushSubscription` value to PATCH onto the device row.
|
|
66
|
+
*/
|
|
67
|
+
export declare const subscriptionFromToken: (token: PushToggleToken) => unknown;
|
|
68
|
+
/**
|
|
69
|
+
* Runs the full enable chain: browser permission → runtime VAPID public key
|
|
70
|
+
* (`GET /api/devices/push/public-key`) → `register({ vapidPublicKey })` →
|
|
71
|
+
* persist the subscription on the current device row.
|
|
72
|
+
*
|
|
73
|
+
* Every failure is returned as a typed reason (never thrown) so the UI can
|
|
74
|
+
* show an honest, specific message instead of hanging or silently reverting.
|
|
75
|
+
*
|
|
76
|
+
* @param deps - The http client + push actions from `usePush()`.
|
|
77
|
+
* @param deps.http - Authenticated http client (`useHttpClient()`).
|
|
78
|
+
* @param deps.requestPermission - `usePush().requestPermission`.
|
|
79
|
+
* @param deps.register - `usePush().register`.
|
|
80
|
+
* @returns `{ ok: true }` or a typed failure.
|
|
81
|
+
*/
|
|
82
|
+
export declare const enablePushOnCurrentDevice: (deps: {
|
|
83
|
+
http: PushToggleHttp;
|
|
84
|
+
requestPermission: () => Promise<string>;
|
|
85
|
+
register: (options?: {
|
|
86
|
+
vapidPublicKey?: string;
|
|
87
|
+
}) => Promise<PushToggleToken>;
|
|
88
|
+
}) => Promise<PushToggleResult>;
|
|
89
|
+
/**
|
|
90
|
+
* Disables push: unsubscribes the browser (best-effort — a dev build without
|
|
91
|
+
* a service worker has nothing to unsubscribe) and ALWAYS clears the server
|
|
92
|
+
* state so no further pushes target this device.
|
|
93
|
+
*
|
|
94
|
+
* @param deps - The http client + push action from `usePush()`.
|
|
95
|
+
* @param deps.http - Authenticated http client (`useHttpClient()`).
|
|
96
|
+
* @param deps.unregister - `usePush().unregister`.
|
|
97
|
+
* @returns `{ ok: true }` or a typed failure (server state not cleared).
|
|
98
|
+
*/
|
|
99
|
+
export declare const disablePushOnCurrentDevice: (deps: {
|
|
100
|
+
http: PushToggleHttp;
|
|
101
|
+
unregister: () => Promise<void>;
|
|
102
|
+
}) => Promise<PushToggleResult>;
|
|
103
|
+
/**
|
|
104
|
+
* Reads whether push is currently enabled for THIS device (server truth:
|
|
105
|
+
* the current device row's `hasPushSubscription`).
|
|
106
|
+
*
|
|
107
|
+
* @param http - Authenticated http client (`useHttpClient()`).
|
|
108
|
+
* @returns `true` when the current device has a stored subscription.
|
|
109
|
+
*/
|
|
110
|
+
export declare const readCurrentDevicePushEnabled: (http: PushToggleHttp) => Promise<boolean>;
|
|
111
|
+
//# sourceMappingURL=pushToggle.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pushToggle.d.ts","sourceRoot":"","sources":["../src/pushToggle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,+EAA+E;AAC/E,MAAM,WAAW,cAAc;IAC7B,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,CAAC,CAAA;KAAE,CAAC,CAAA;IACnD,KAAK,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,CAAC,CAAA;KAAE,CAAC,CAAA;CACtE;AAED,uDAAuD;AACvD,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAA;IACV,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,mBAAmB,CAAC,EAAE,OAAO,CAAA;CAC9B;AAED,sEAAsE;AACtE,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,KAAK,GAAG,KAAK,GAAG,SAAS,CAAA;CACpC;AAED,8EAA8E;AAC9E,MAAM,MAAM,uBAAuB,GACjC,mBAAmB,GAAG,qBAAqB,GAAG,iBAAiB,GAAG,gBAAgB,CAAA;AAEpF,2CAA2C;AAC3C,MAAM,MAAM,gBAAgB,GAC1B;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,uBAAuB,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,CAAA;AAEjF;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,GAC5B,SAAS,gBAAgB,EAAE,GAAG,SAAS,KACtC,gBAAgB,GAAG,SAGrB,CAAA;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,qBAAqB,GAAI,OAAO,eAAe,KAAG,OAS9D,CAAA;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,yBAAyB,GAAU,MAAM;IACpD,IAAI,EAAE,cAAc,CAAA;IACpB,iBAAiB,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAA;IACxC,QAAQ,EAAE,CAAC,OAAO,CAAC,EAAE;QAAE,cAAc,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,eAAe,CAAC,CAAA;CAC9E,KAAG,OAAO,CAAC,gBAAgB,CAmD3B,CAAA;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,0BAA0B,GAAU,MAAM;IACrD,IAAI,EAAE,cAAc,CAAA;IACpB,UAAU,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAChC,KAAG,OAAO,CAAC,gBAAgB,CA0B3B,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,GAAU,MAAM,cAAc,KAAG,OAAO,CAAC,OAAO,CASxF,CAAA"}
|