gloomberb 0.10.2 → 0.10.4
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 +3 -3
- package/README.zh-CN.md +2 -2
- package/package.json +2 -1
- package/src/api-client/auth.ts +8 -0
- package/src/api-client/index.ts +70 -0
- package/src/api-client/types.ts +49 -0
- package/src/app/runtime/desktop-deeplink.test.ts +8 -0
- package/src/app/runtime/desktop-deeplink.ts +14 -1
- package/src/components/chart/composite/composite-chart.test.tsx +340 -17
- package/src/components/chart/composite/composite-chart.tsx +859 -68
- package/src/components/chart/composite/interactions.test.ts +78 -22
- package/src/components/chart/composite/interactions.ts +45 -17
- package/src/components/chart/composite/rasterizer.ts +8 -2
- package/src/components/chart/composite/renderers.test.ts +61 -1
- package/src/components/chart/composite/scene.ts +14 -4
- package/src/components/chart/composite/tools.test.ts +371 -0
- package/src/components/chart/composite/tools.ts +508 -0
- package/src/components/chart/composite/types.ts +4 -0
- package/src/components/chart/core/pointer.ts +2 -0
- package/src/components/chart/native/raster/primitives.ts +18 -0
- package/src/components/chart/price-axis-labels.tsx +42 -2
- package/src/components/chart/static/chart/axis-overlays.test.tsx +2 -0
- package/src/components/chart/static/chart/axis-overlays.tsx +46 -0
- package/src/components/command-bar/action-runtime.ts +3 -0
- package/src/components/command-bar/assist/inventory.test.ts +93 -0
- package/src/components/command-bar/assist/inventory.ts +92 -0
- package/src/components/command-bar/assist/model.test.ts +104 -0
- package/src/components/command-bar/assist/model.ts +169 -0
- package/src/components/command-bar/assist/runtime.ts +185 -0
- package/src/components/command-bar/commands/direct/index.ts +1 -1
- package/src/components/command-bar/keyboard-shortcuts.ts +5 -0
- package/src/components/command-bar/list/model.ts +15 -2
- package/src/components/command-bar/list/navigation.ts +6 -0
- package/src/components/command-bar/list/view.tsx +16 -3
- package/src/components/command-bar/pane-templates/workflow.ts +1 -0
- package/src/components/command-bar/panel/body.tsx +2 -0
- package/src/components/command-bar/panel/index.tsx +3 -0
- package/src/components/command-bar/panel/runtime.ts +6 -0
- package/src/components/command-bar/panel/types.ts +1 -0
- package/src/components/command-bar/routes/root/results.ts +28 -2
- package/src/components/command-bar/routes/root/runtime.ts +66 -36
- package/src/components/command-bar/routes/root/shortcuts.ts +8 -4
- package/src/components/command-bar/routing/navigation-state.ts +17 -0
- package/src/components/command-bar/selection-runtime.ts +26 -0
- package/src/components/command-bar/surface/assist.test.tsx +276 -0
- package/src/components/command-bar/surface/index.tsx +93 -2
- package/src/components/command-bar/surface/portfolio.test.tsx +2 -1
- package/src/components/command-bar/view-model.ts +7 -1
- package/src/components/command-bar/workflow/broker.test.ts +22 -0
- package/src/components/command-bar/workflow/broker.ts +1 -0
- package/src/components/index.ts +1 -1
- package/src/components/layout/detached-pane-shell.tsx +40 -2
- package/src/components/layout/floating-pane.tsx +4 -1
- package/src/components/layout/pane/header.tsx +64 -6
- package/src/components/layout/pane/index.tsx +4 -1
- package/src/components/layout/shell/index.tsx +23 -0
- package/src/components/layout/shell/pane/layers.tsx +5 -0
- package/src/components/onboarding/account-step/chooser-panel.tsx +74 -0
- package/src/components/onboarding/account-step/form-panel.tsx +157 -0
- package/src/components/onboarding/account-step/index.tsx +58 -0
- package/src/components/onboarding/account-step/model.test.ts +67 -0
- package/src/components/onboarding/account-step/model.ts +126 -0
- package/src/components/onboarding/account-step/signed-in-panel.tsx +31 -0
- package/src/components/onboarding/onboarding-steps.tsx +91 -55
- package/src/components/onboarding/onboarding-wizard.test.tsx +73 -6
- package/src/components/onboarding/onboarding-wizard.tsx +93 -9
- package/src/components/onboarding/portfolio-step/broker-fields-panel.tsx +3 -3
- package/src/components/onboarding/wizard-account.ts +235 -0
- package/src/components/onboarding/wizard-keyboard.ts +67 -1
- package/src/components/onboarding/wizard-model.ts +2 -2
- package/src/components/ui/data-table/index.tsx +1 -0
- package/src/components/ui/data-table/opentui/index.tsx +44 -2
- package/src/components/ui/data-table/types.ts +10 -0
- package/src/components/ui/data-table/visible-range.test.ts +29 -0
- package/src/components/ui/data-table/visible-range.ts +21 -0
- package/src/components/ui/ui.test.tsx +23 -1
- package/src/i18n/es.ts +1093 -0
- package/src/i18n/index.test.ts +5 -2
- package/src/i18n/index.ts +3 -0
- package/src/i18n/ja.ts +66 -3
- package/src/i18n/ko.ts +66 -3
- package/src/i18n/languages.test.ts +4 -1
- package/src/i18n/languages.ts +12 -1
- package/src/i18n/zh-cn.ts +66 -3
- package/src/i18n/zh-tw.ts +66 -3
- package/src/market-data/coordinator/index.ts +1 -1
- package/src/market-data/coordinator/quotes.ts +64 -1
- package/src/market-data/coordinator/subscriptions.test.ts +72 -0
- package/src/market-data/quotes/cadence.ts +2 -0
- package/src/plugins/builtin/account-management/footer.ts +9 -3
- package/src/plugins/builtin/account-management/model.ts +53 -1
- package/src/plugins/builtin/account-management/pane.tsx +107 -24
- package/src/plugins/builtin/ai/index.tsx +7 -2
- package/src/plugins/builtin/ai/screener/market-runtime.ts +4 -2
- package/src/plugins/builtin/ai/screener/pane.tsx +3 -0
- package/src/plugins/builtin/chart-composer/editor.tsx +0 -1
- package/src/plugins/builtin/chart-composer/index.tsx +10 -2
- package/src/plugins/builtin/chart-composer/pane.tsx +111 -44
- package/src/plugins/builtin/chart-composer/quick-add.tsx +27 -3
- package/src/plugins/builtin/chart-composer/semantic.ts +21 -0
- package/src/plugins/builtin/chat/controller/persistence.ts +5 -0
- package/src/plugins/builtin/cloud/plugin.tsx +9 -1
- package/src/plugins/builtin/cloud/upgrade-command.ts +28 -0
- package/src/plugins/builtin/cloud/upgrade-status-widget.tsx +54 -0
- package/src/plugins/builtin/help/index.tsx +38 -0
- package/src/plugins/builtin/news/wire/news/footer.ts +18 -38
- package/src/plugins/builtin/options/footer.test.ts +20 -112
- package/src/plugins/builtin/options/footer.ts +35 -78
- package/src/plugins/builtin/options/index.tsx +6 -0
- package/src/plugins/builtin/options/live-quotes.test.ts +117 -14
- package/src/plugins/builtin/options/live-quotes.ts +93 -39
- package/src/plugins/builtin/options/view.test.tsx +5 -2
- package/src/plugins/builtin/options/view.tsx +49 -9
- package/src/plugins/builtin/portfolio-list/index.tsx +12 -4
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +22 -1
- package/src/plugins/builtin/portfolio-list/pane/streaming.ts +17 -13
- package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.test.tsx +76 -0
- package/src/plugins/builtin/portfolio-list/use-throttled-ticker-order.ts +78 -0
- package/src/plugins/builtin/shared/cloud-upgrade.ts +131 -0
- package/src/plugins/builtin/shared/live-streaming.ts +60 -0
- package/src/plugins/builtin/shared/plan-access.test.ts +69 -0
- package/src/plugins/builtin/shared/plan-access.ts +109 -0
- package/src/plugins/builtin/ticker-detail/chart-tab-switch.test.tsx +1 -1
- package/src/plugins/builtin/ticker-detail/index.tsx +11 -2
- package/src/plugins/builtin/ticker-detail/pane.tsx +21 -3
- package/src/plugins/builtin/ticker-detail/quote-monitor/index.tsx +4 -2
- package/src/plugins/registry/index.test.ts +51 -0
- package/src/plugins/registry/index.ts +12 -0
- package/src/plugins/registry/pane-settings.ts +29 -0
- package/src/renderers/electrobun/view/data-table/index.tsx +44 -3
- package/src/renderers/electrobun/view/host/box.test.tsx +97 -0
- package/src/renderers/electrobun/view/host/box.tsx +3 -0
- package/src/renderers/electrobun/view/host/chart-surface.tsx +85 -1
- package/src/renderers/electrobun/view/host/focus-scope.test.ts +37 -0
- package/src/renderers/electrobun/view/host/focus-scope.ts +56 -0
- package/src/renderers/electrobun/view/host/input.tsx +16 -1
- package/src/renderers/electrobun/view/host/mouse.ts +1 -0
- package/src/renderers/electrobun/view/host/style.ts +1 -1
- package/src/renderers/electrobun/view/host/tabs.tsx +4 -2
- package/src/renderers/electrobun/view/main.tsx +2 -0
- package/src/renderers/opentui/chart-surface.tsx +2 -0
- package/src/renderers/opentui/host.tsx +3 -0
- package/src/sources/gloomberb-cloud/index.test.ts +8 -0
- package/src/sources/gloomberb-cloud/index.ts +4 -4
- package/src/sources/provider-router/history.test.ts +3 -3
- package/src/sources/provider-router/history.ts +15 -4
- package/src/sources/yahoo-finance/history.test.ts +47 -0
- package/src/sources/yahoo-finance/history.ts +5 -1
- package/src/state/hooks/quote-streaming.test.tsx +181 -1
- package/src/state/hooks/quote-streaming.ts +93 -7
- package/src/theme/colors.ts +20 -1
- package/src/theme/themes.test.ts +3 -0
- package/src/{sources/gloomberb-cloud → time-series}/history-quality.ts +27 -3
- package/src/time-series/resolution.test.ts +51 -0
- package/src/time-series/resolution.ts +41 -0
- package/src/time-series/resolve.test.ts +207 -1
- package/src/time-series/resolve.ts +196 -29
- package/src/time-series/spec-studies.test.ts +4 -0
- package/src/time-series/spec.ts +15 -1
- package/src/time-series/types.ts +7 -0
- package/src/time-series/use-chart-resolution.test.tsx +37 -0
- package/src/time-series/use-chart-resolution.ts +17 -1
- package/src/types/plugin.ts +9 -0
- package/src/ui/host.tsx +17 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { Box, Text, TextAttributes } from "../../../ui";
|
|
3
|
+
import { colors } from "../../../theme/colors";
|
|
4
|
+
import { t } from "../../../i18n";
|
|
5
|
+
import { useAppLanguage } from "../../../i18n/react";
|
|
6
|
+
import { ListView, type ListViewItem } from "../../ui";
|
|
7
|
+
|
|
8
|
+
export function AccountChooserPanel({
|
|
9
|
+
choiceIdx,
|
|
10
|
+
onChoiceSelect,
|
|
11
|
+
onChoiceActivate,
|
|
12
|
+
height,
|
|
13
|
+
}: {
|
|
14
|
+
choiceIdx: number;
|
|
15
|
+
onChoiceSelect: (index: number) => void;
|
|
16
|
+
onChoiceActivate: (index: number) => void;
|
|
17
|
+
height: number;
|
|
18
|
+
}) {
|
|
19
|
+
const language = useAppLanguage();
|
|
20
|
+
const bullets = [
|
|
21
|
+
t("Try Pro free for 7 days — real-time quotes & news"),
|
|
22
|
+
t("Sync portfolios, watchlists & layouts"),
|
|
23
|
+
t("Chat + AI command bar"),
|
|
24
|
+
];
|
|
25
|
+
const choices = useMemo<ListViewItem[]>(() => [
|
|
26
|
+
{ id: "signup", label: t("Create free account"), description: t("Email and password — that's it") },
|
|
27
|
+
{ id: "login", label: t("Log In"), description: t("Already have a Gloom Cloud account") },
|
|
28
|
+
{ id: "skip", label: t("Skip for now"), description: t("Continue without an account") },
|
|
29
|
+
], [language]);
|
|
30
|
+
|
|
31
|
+
// Bullets are the first rows to go so the choices and footer survive a short terminal.
|
|
32
|
+
const showBullets = height >= 15;
|
|
33
|
+
const showHint = height >= 12;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Box flexDirection="column" paddingX={2}>
|
|
37
|
+
<Box height={1}>
|
|
38
|
+
<Text fg={colors.textBright} attributes={TextAttributes.BOLD}>
|
|
39
|
+
{t("Create your free Gloom Cloud account")}
|
|
40
|
+
</Text>
|
|
41
|
+
</Box>
|
|
42
|
+
<Box height={1} />
|
|
43
|
+
|
|
44
|
+
{showBullets && (
|
|
45
|
+
<>
|
|
46
|
+
{bullets.map((bullet) => (
|
|
47
|
+
<Box key={bullet} height={1} flexDirection="row">
|
|
48
|
+
<Text fg={colors.positive}>{"· "}</Text>
|
|
49
|
+
<Text fg={colors.textDim}>{bullet}</Text>
|
|
50
|
+
</Box>
|
|
51
|
+
))}
|
|
52
|
+
<Box height={1} />
|
|
53
|
+
</>
|
|
54
|
+
)}
|
|
55
|
+
|
|
56
|
+
<ListView
|
|
57
|
+
items={choices}
|
|
58
|
+
selectedIndex={choiceIdx}
|
|
59
|
+
onSelect={onChoiceSelect}
|
|
60
|
+
onActivate={(_item, index) => onChoiceActivate(index)}
|
|
61
|
+
showSelectedDescription
|
|
62
|
+
/>
|
|
63
|
+
|
|
64
|
+
{showHint && (
|
|
65
|
+
<>
|
|
66
|
+
<Box height={1} />
|
|
67
|
+
<Box height={1}>
|
|
68
|
+
<Text fg={colors.textMuted}>{t("Use ↑↓ to choose")}</Text>
|
|
69
|
+
</Box>
|
|
70
|
+
</>
|
|
71
|
+
)}
|
|
72
|
+
</Box>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { RefObject } from "react";
|
|
2
|
+
import { Box, Text, TextAttributes, type InputRenderable } from "../../../ui";
|
|
3
|
+
import { colors } from "../../../theme/colors";
|
|
4
|
+
import { t } from "../../../i18n";
|
|
5
|
+
import { TextField } from "../../ui";
|
|
6
|
+
import type { AccountMode, AccountSubmitError } from "./model";
|
|
7
|
+
|
|
8
|
+
function FieldRow({
|
|
9
|
+
label,
|
|
10
|
+
active,
|
|
11
|
+
value,
|
|
12
|
+
masked,
|
|
13
|
+
placeholder,
|
|
14
|
+
editing,
|
|
15
|
+
inputRef,
|
|
16
|
+
onChange,
|
|
17
|
+
}: {
|
|
18
|
+
label: string;
|
|
19
|
+
active: boolean;
|
|
20
|
+
value: string;
|
|
21
|
+
masked: boolean;
|
|
22
|
+
placeholder: string;
|
|
23
|
+
editing: boolean;
|
|
24
|
+
inputRef: RefObject<InputRenderable | null>;
|
|
25
|
+
onChange: (value: string) => void;
|
|
26
|
+
}) {
|
|
27
|
+
if (!active) {
|
|
28
|
+
return (
|
|
29
|
+
<Box height={1} flexDirection="row">
|
|
30
|
+
<Text fg={colors.positive}>{"✓ "}</Text>
|
|
31
|
+
<Text fg={colors.text}>{`${label}: ${masked ? "*".repeat(value.length) : value}`}</Text>
|
|
32
|
+
</Box>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Box flexDirection="column">
|
|
38
|
+
<Box height={1}>
|
|
39
|
+
<Text fg={colors.text} attributes={TextAttributes.BOLD}>{label}</Text>
|
|
40
|
+
</Box>
|
|
41
|
+
<Box height={1}>
|
|
42
|
+
{editing ? (
|
|
43
|
+
<TextField
|
|
44
|
+
inputRef={inputRef}
|
|
45
|
+
value={value}
|
|
46
|
+
type={masked ? "password" : "text"}
|
|
47
|
+
placeholder={placeholder}
|
|
48
|
+
focused
|
|
49
|
+
backgroundColor={colors.panel}
|
|
50
|
+
textColor={colors.text}
|
|
51
|
+
placeholderColor={colors.textDim}
|
|
52
|
+
onChange={onChange}
|
|
53
|
+
onSubmit={() => {}}
|
|
54
|
+
/>
|
|
55
|
+
) : (
|
|
56
|
+
<Text fg={value ? colors.text : colors.textMuted}>
|
|
57
|
+
{value ? (masked ? "*".repeat(value.length) : value) : t("Press enter to type...")}
|
|
58
|
+
</Text>
|
|
59
|
+
)}
|
|
60
|
+
</Box>
|
|
61
|
+
</Box>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function AccountFormPanel({
|
|
66
|
+
mode,
|
|
67
|
+
email,
|
|
68
|
+
password,
|
|
69
|
+
fieldIdx,
|
|
70
|
+
editing,
|
|
71
|
+
inputRef,
|
|
72
|
+
submitting,
|
|
73
|
+
submitError,
|
|
74
|
+
validationError,
|
|
75
|
+
onEmailChange,
|
|
76
|
+
onPasswordChange,
|
|
77
|
+
}: {
|
|
78
|
+
mode: AccountMode;
|
|
79
|
+
email: string;
|
|
80
|
+
password: string;
|
|
81
|
+
fieldIdx: number;
|
|
82
|
+
editing: boolean;
|
|
83
|
+
inputRef: RefObject<InputRenderable | null>;
|
|
84
|
+
submitting: boolean;
|
|
85
|
+
submitError: AccountSubmitError | null;
|
|
86
|
+
validationError: string | null;
|
|
87
|
+
onEmailChange: (value: string) => void;
|
|
88
|
+
onPasswordChange: (value: string) => void;
|
|
89
|
+
}) {
|
|
90
|
+
const onEmail = fieldIdx <= 0;
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<Box flexDirection="column" paddingX={2}>
|
|
94
|
+
<Box height={1}>
|
|
95
|
+
<Text fg={colors.textBright} attributes={TextAttributes.BOLD}>
|
|
96
|
+
{mode === "signup" ? t("Create your free account") : t("Log in to Gloom Cloud")}
|
|
97
|
+
</Text>
|
|
98
|
+
</Box>
|
|
99
|
+
<Box height={1} />
|
|
100
|
+
|
|
101
|
+
<FieldRow
|
|
102
|
+
label={t("Email")}
|
|
103
|
+
active={onEmail}
|
|
104
|
+
value={email}
|
|
105
|
+
masked={false}
|
|
106
|
+
placeholder={"email@example.com"}
|
|
107
|
+
editing={editing && !submitting}
|
|
108
|
+
inputRef={inputRef}
|
|
109
|
+
onChange={onEmailChange}
|
|
110
|
+
/>
|
|
111
|
+
|
|
112
|
+
{!onEmail && (
|
|
113
|
+
<>
|
|
114
|
+
<Box height={1} />
|
|
115
|
+
<FieldRow
|
|
116
|
+
label={t("Password")}
|
|
117
|
+
active
|
|
118
|
+
value={password}
|
|
119
|
+
masked
|
|
120
|
+
placeholder={mode === "signup" ? t("Min 8 characters") : t("Your password")}
|
|
121
|
+
editing={editing && !submitting}
|
|
122
|
+
inputRef={inputRef}
|
|
123
|
+
onChange={onPasswordChange}
|
|
124
|
+
/>
|
|
125
|
+
</>
|
|
126
|
+
)}
|
|
127
|
+
|
|
128
|
+
<Box height={1} />
|
|
129
|
+
{/* Two rows so a long server error wraps instead of being clipped. */}
|
|
130
|
+
<Box height={2}>
|
|
131
|
+
{submitting ? (
|
|
132
|
+
<Text fg={colors.text}>
|
|
133
|
+
{mode === "signup" ? t("Creating your account...") : t("Signing you in...")}
|
|
134
|
+
</Text>
|
|
135
|
+
) : validationError || submitError ? (
|
|
136
|
+
<Text fg={colors.negative}>{validationError ?? submitError?.message ?? ""}</Text>
|
|
137
|
+
) : (
|
|
138
|
+
<Text fg={colors.textDim}>
|
|
139
|
+
{mode === "signup"
|
|
140
|
+
? t("We email a link to verify your address.")
|
|
141
|
+
: t("Signs this terminal in to your account.")}
|
|
142
|
+
</Text>
|
|
143
|
+
)}
|
|
144
|
+
</Box>
|
|
145
|
+
<Box height={1}>
|
|
146
|
+
{!submitting && submitError?.kind === "switch-to-login" && (
|
|
147
|
+
<Text fg={colors.textDim}>{t("Press enter to log in with this email.")}</Text>
|
|
148
|
+
)}
|
|
149
|
+
</Box>
|
|
150
|
+
|
|
151
|
+
<Box height={1} />
|
|
152
|
+
<Box height={1}>
|
|
153
|
+
<Text fg={colors.textMuted}>{t("esc to go back — this step is optional")}</Text>
|
|
154
|
+
</Box>
|
|
155
|
+
</Box>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { RefObject } from "react";
|
|
2
|
+
import type { InputRenderable } from "../../../ui";
|
|
3
|
+
import { AccountChooserPanel } from "./chooser-panel";
|
|
4
|
+
import { AccountFormPanel } from "./form-panel";
|
|
5
|
+
import { AccountSignedInPanel } from "./signed-in-panel";
|
|
6
|
+
import type { AccountOutcome, AccountSub, AccountSubmitError } from "./model";
|
|
7
|
+
|
|
8
|
+
export interface AccountStepProps {
|
|
9
|
+
sub: AccountSub;
|
|
10
|
+
choiceIdx: number;
|
|
11
|
+
onChoiceSelect: (index: number) => void;
|
|
12
|
+
onChoiceActivate: (index: number) => void;
|
|
13
|
+
email: string;
|
|
14
|
+
password: string;
|
|
15
|
+
fieldIdx: number;
|
|
16
|
+
editing: boolean;
|
|
17
|
+
inputRef: RefObject<InputRenderable | null>;
|
|
18
|
+
submitting: boolean;
|
|
19
|
+
submitError: AccountSubmitError | null;
|
|
20
|
+
validationError: string | null;
|
|
21
|
+
outcome: AccountOutcome | null;
|
|
22
|
+
onEmailChange: (value: string) => void;
|
|
23
|
+
onPasswordChange: (value: string) => void;
|
|
24
|
+
height: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function AccountStep(props: AccountStepProps) {
|
|
28
|
+
if (props.sub === "signed-in") {
|
|
29
|
+
return <AccountSignedInPanel outcome={props.outcome} />;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (props.sub === "signup" || props.sub === "login") {
|
|
33
|
+
return (
|
|
34
|
+
<AccountFormPanel
|
|
35
|
+
mode={props.sub}
|
|
36
|
+
email={props.email}
|
|
37
|
+
password={props.password}
|
|
38
|
+
fieldIdx={props.fieldIdx}
|
|
39
|
+
editing={props.editing}
|
|
40
|
+
inputRef={props.inputRef}
|
|
41
|
+
submitting={props.submitting}
|
|
42
|
+
submitError={props.submitError}
|
|
43
|
+
validationError={props.validationError}
|
|
44
|
+
onEmailChange={props.onEmailChange}
|
|
45
|
+
onPasswordChange={props.onPasswordChange}
|
|
46
|
+
/>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<AccountChooserPanel
|
|
52
|
+
choiceIdx={props.choiceIdx}
|
|
53
|
+
onChoiceSelect={props.onChoiceSelect}
|
|
54
|
+
onChoiceActivate={props.onChoiceActivate}
|
|
55
|
+
height={props.height}
|
|
56
|
+
/>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
advanceAccountField,
|
|
4
|
+
classifyAccountError,
|
|
5
|
+
deriveUsernameFromEmail,
|
|
6
|
+
isUsernameConflictError,
|
|
7
|
+
} from "./model";
|
|
8
|
+
|
|
9
|
+
describe("deriveUsernameFromEmail", () => {
|
|
10
|
+
test("builds a server-legal username from the email local part", () => {
|
|
11
|
+
expect(deriveUsernameFromEmail("Vincent.Loewert@gmail.com")).toBe("vincent_loewert");
|
|
12
|
+
expect(deriveUsernameFromEmail("9lives@example.com")).toBe("u9lives");
|
|
13
|
+
expect(deriveUsernameFromEmail("a@example.com")).toBe("a00");
|
|
14
|
+
expect(deriveUsernameFromEmail("--weird--name--@example.com")).toBe("weird_name");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("stays within the 30 character limit, with and without a retry suffix", () => {
|
|
18
|
+
const long = `${"abcdefghij".repeat(4)}@example.com`;
|
|
19
|
+
expect(deriveUsernameFromEmail(long)).toHaveLength(30);
|
|
20
|
+
|
|
21
|
+
const retried = deriveUsernameFromEmail(long, 1);
|
|
22
|
+
expect(retried.length).toBeLessThanOrEqual(30);
|
|
23
|
+
expect(retried).not.toBe(deriveUsernameFromEmail(long));
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("isUsernameConflictError", () => {
|
|
28
|
+
test("only matches username collisions, so a duplicate email never re-posts a sign-up", () => {
|
|
29
|
+
expect(isUsernameConflictError(new Error("Username is already taken"))).toBe(true);
|
|
30
|
+
expect(isUsernameConflictError(new Error("User already exists USER_ALREADY_EXISTS"))).toBe(false);
|
|
31
|
+
expect(isUsernameConflictError(new Error("fetch failed"))).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("classifyAccountError", () => {
|
|
36
|
+
test("routes a duplicate email to the login form instead of a pointless retry", () => {
|
|
37
|
+
const result = classifyAccountError(new Error("User already exists USER_ALREADY_EXISTS"), "signup");
|
|
38
|
+
expect(result.kind).toBe("switch-to-login");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("keeps every other failure retryable and surfaces the server message", () => {
|
|
42
|
+
expect(classifyAccountError(new Error("fetch failed"), "signup")).toEqual({
|
|
43
|
+
message: "fetch failed",
|
|
44
|
+
kind: "retry",
|
|
45
|
+
});
|
|
46
|
+
expect(classifyAccountError(new Error("Invalid email or password"), "login").kind).toBe("retry");
|
|
47
|
+
expect(classifyAccountError({}, "login").message).toBe("Could not sign you in.");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("advanceAccountField", () => {
|
|
52
|
+
test("blocks the network call until both fields validate", () => {
|
|
53
|
+
expect(advanceAccountField({ mode: "signup", email: "nope", password: "", fieldIdx: 0 }).action)
|
|
54
|
+
.toBe("invalid");
|
|
55
|
+
expect(advanceAccountField({ mode: "signup", email: "a@b.co", password: "", fieldIdx: 0 }))
|
|
56
|
+
.toEqual({ action: "next-field", fieldIdx: 1 });
|
|
57
|
+
expect(advanceAccountField({ mode: "signup", email: "a@b.co", password: "short", fieldIdx: 1 }).action)
|
|
58
|
+
.toBe("invalid");
|
|
59
|
+
expect(advanceAccountField({ mode: "signup", email: "a@b.co", password: "longenough", fieldIdx: 1 }).action)
|
|
60
|
+
.toBe("submit");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("does not impose the sign-up length rule on existing passwords", () => {
|
|
64
|
+
expect(advanceAccountField({ mode: "login", email: "a@b.co", password: "short", fieldIdx: 1 }).action)
|
|
65
|
+
.toBe("submit");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logic for the onboarding account step, kept out of the React hook so the
|
|
3
|
+
* transitions that gate a network call (validation, retry classification,
|
|
4
|
+
* username derivation) can be exercised without rendering the wizard.
|
|
5
|
+
*/
|
|
6
|
+
import { t } from "../../../i18n";
|
|
7
|
+
|
|
8
|
+
export type AccountSub = "choose" | "signup" | "login" | "signed-in";
|
|
9
|
+
|
|
10
|
+
export type AccountMode = "signup" | "login";
|
|
11
|
+
|
|
12
|
+
export type AccountChoiceId = AccountMode | "skip";
|
|
13
|
+
|
|
14
|
+
/** Order matches the chooser list, so the selected index maps straight onto an id. */
|
|
15
|
+
export const ACCOUNT_CHOICE_IDS: AccountChoiceId[] = ["signup", "login", "skip"];
|
|
16
|
+
|
|
17
|
+
export interface AccountOutcome {
|
|
18
|
+
mode: AccountMode;
|
|
19
|
+
email: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type AccountErrorKind = "retry" | "switch-to-login";
|
|
23
|
+
|
|
24
|
+
export interface AccountSubmitError {
|
|
25
|
+
message: string;
|
|
26
|
+
kind: AccountErrorKind;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Server rule; keep in sync with the cloud sign-up endpoint. */
|
|
30
|
+
export const MIN_ACCOUNT_PASSWORD_LENGTH = 8;
|
|
31
|
+
|
|
32
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/;
|
|
33
|
+
const USERNAME_MIN_LENGTH = 3;
|
|
34
|
+
const USERNAME_MAX_LENGTH = 30;
|
|
35
|
+
|
|
36
|
+
export function validateAccountEmail(email: string): string | null {
|
|
37
|
+
const trimmed = email.trim();
|
|
38
|
+
if (!trimmed) return t("Enter your email address.");
|
|
39
|
+
if (!EMAIL_PATTERN.test(trimmed)) return t("That email address doesn't look right.");
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function validateAccountPassword(password: string, mode: AccountMode): string | null {
|
|
44
|
+
if (!password) return t("Enter your password.");
|
|
45
|
+
if (mode === "signup" && password.length < MIN_ACCOUNT_PASSWORD_LENGTH) {
|
|
46
|
+
return t("Use at least 8 characters.");
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Sign-up needs a username the caller never typed. Derive a stable one from the
|
|
53
|
+
* email local part; `attempt > 0` appends a suffix so a taken username can be
|
|
54
|
+
* retried without bouncing the user back into the form.
|
|
55
|
+
*/
|
|
56
|
+
export function deriveUsernameFromEmail(email: string, attempt = 0): string {
|
|
57
|
+
const localPart = email.trim().toLowerCase().split("@")[0] ?? "";
|
|
58
|
+
let base = localPart.replace(/[^a-z0-9_]+/g, "_").replace(/_{2,}/g, "_").replace(/^_+|_+$/g, "");
|
|
59
|
+
if (!/^[a-z]/.test(base)) {
|
|
60
|
+
base = `u${base}`;
|
|
61
|
+
}
|
|
62
|
+
const suffix = attempt > 0 ? String(attempt * 1000 + Math.floor(Math.random() * 1000)) : "";
|
|
63
|
+
base = base.slice(0, USERNAME_MAX_LENGTH - suffix.length);
|
|
64
|
+
const username = `${base}${suffix}`;
|
|
65
|
+
return username.length >= USERNAME_MIN_LENGTH
|
|
66
|
+
? username
|
|
67
|
+
: username.padEnd(USERNAME_MIN_LENGTH, "0");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function errorMessage(error: unknown): string {
|
|
71
|
+
if (error instanceof Error && error.message.trim()) return error.message.trim();
|
|
72
|
+
if (typeof error === "string" && error.trim()) return error.trim();
|
|
73
|
+
return "";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A taken username is our own derivation's fault, so it is worth one silent retry. */
|
|
77
|
+
export function isUsernameConflictError(error: unknown): boolean {
|
|
78
|
+
const message = errorMessage(error);
|
|
79
|
+
return /username/i.test(message) && /(taken|exists|use|unavailable|duplicate)/i.test(message);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Turns an auth failure into an inline message plus what enter should do next.
|
|
84
|
+
* A duplicate email is the one case where retrying the same request is useless,
|
|
85
|
+
* so it routes the user into the login form instead.
|
|
86
|
+
*/
|
|
87
|
+
export function classifyAccountError(error: unknown, mode: AccountMode): AccountSubmitError {
|
|
88
|
+
const message = errorMessage(error);
|
|
89
|
+
|
|
90
|
+
if (mode === "signup" && /already/i.test(message) && /(user|email|account)/i.test(message)) {
|
|
91
|
+
return { message: t("That email already has an account."), kind: "switch-to-login" };
|
|
92
|
+
}
|
|
93
|
+
if (mode === "login" && /(invalid|incorrect|wrong)/i.test(message)) {
|
|
94
|
+
return { message: t("Email or password is incorrect."), kind: "retry" };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
message: message || (mode === "signup" ? t("Could not create your account.") : t("Could not sign you in.")),
|
|
99
|
+
kind: "retry",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface AccountFormState {
|
|
104
|
+
mode: AccountMode;
|
|
105
|
+
email: string;
|
|
106
|
+
password: string;
|
|
107
|
+
fieldIdx: number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type AccountFieldAdvance =
|
|
111
|
+
| { action: "invalid"; message: string }
|
|
112
|
+
| { action: "next-field"; fieldIdx: number }
|
|
113
|
+
| { action: "submit" };
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Decides what pressing enter inside the form does. Validation runs before the
|
|
117
|
+
* field advances, so an invalid value can never reach the network call.
|
|
118
|
+
*/
|
|
119
|
+
export function advanceAccountField({ mode, email, password, fieldIdx }: AccountFormState): AccountFieldAdvance {
|
|
120
|
+
if (fieldIdx <= 0) {
|
|
121
|
+
const message = validateAccountEmail(email);
|
|
122
|
+
return message ? { action: "invalid", message } : { action: "next-field", fieldIdx: 1 };
|
|
123
|
+
}
|
|
124
|
+
const message = validateAccountPassword(password, mode);
|
|
125
|
+
return message ? { action: "invalid", message } : { action: "submit" };
|
|
126
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Box, Text, TextAttributes } from "../../../ui";
|
|
2
|
+
import { colors } from "../../../theme/colors";
|
|
3
|
+
import { t, tf } from "../../../i18n";
|
|
4
|
+
import type { AccountOutcome } from "./model";
|
|
5
|
+
|
|
6
|
+
export function AccountSignedInPanel({ outcome }: { outcome: AccountOutcome | null }) {
|
|
7
|
+
const email = outcome?.email ?? "";
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<Box flexDirection="column" paddingX={2}>
|
|
11
|
+
<Box height={1}>
|
|
12
|
+
<Text fg={colors.textBright} attributes={TextAttributes.BOLD}>{t("Gloom Cloud account")}</Text>
|
|
13
|
+
</Box>
|
|
14
|
+
<Box height={1} />
|
|
15
|
+
<Box height={1} flexDirection="row">
|
|
16
|
+
<Text fg={colors.positive} attributes={TextAttributes.BOLD}>{"✓ "}</Text>
|
|
17
|
+
<Text fg={colors.text}>
|
|
18
|
+
{email ? tf("Signed in as {email}", { email }) : t("Signed in to Gloom Cloud")}
|
|
19
|
+
</Text>
|
|
20
|
+
</Box>
|
|
21
|
+
<Box height={1} />
|
|
22
|
+
<Box height={1}>
|
|
23
|
+
<Text fg={colors.textDim}>
|
|
24
|
+
{outcome?.mode === "signup"
|
|
25
|
+
? t("We sent a verification link to your inbox.")
|
|
26
|
+
: t("Portfolios, watchlists and layouts sync to this account.")}
|
|
27
|
+
</Text>
|
|
28
|
+
</Box>
|
|
29
|
+
</Box>
|
|
30
|
+
);
|
|
31
|
+
}
|