(wip): add onboarding
(fix): login page reloading
This commit is contained in:
parent
b6dfb4d019
commit
bf8f449f03
19 changed files with 461 additions and 439 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import { Button } from "@tensamin/ui";
|
||||
import { Button, withTooltip } from "@tensamin/ui";
|
||||
import { toggleDeaf, useCall } from "../../store";
|
||||
import { HeadphoneOff, Headphones } from "lucide-react";
|
||||
import { type CallButtonProps, iconScale, withTooltip } from "./tooltipButton";
|
||||
import { type CallButtonProps, iconScale } from "./tooltipButton";
|
||||
|
||||
export default function DeafButton({
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { LeaveIcon } from "@livekit/components-react";
|
||||
import { Button } from "@tensamin/ui";
|
||||
import { Button, withTooltip } from "@tensamin/ui";
|
||||
import { disconnect, useCall } from "../../store";
|
||||
import { type CallButtonProps, iconScale, withTooltip } from "./tooltipButton";
|
||||
import { type CallButtonProps, iconScale } from "./tooltipButton";
|
||||
|
||||
export default function LeaveButton({
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Button } from "@tensamin/ui";
|
||||
import { Button, withTooltip } from "@tensamin/ui";
|
||||
import { toggleMute, useCall } from "../../store";
|
||||
import { Mic, MicOff } from "lucide-react";
|
||||
import { type CallButtonProps, iconScale, withTooltip } from "./tooltipButton";
|
||||
import { type CallButtonProps, iconScale } from "./tooltipButton";
|
||||
|
||||
export default function MuteButton({
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
import { type ReactElement } from "react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@tensamin/ui";
|
||||
|
||||
export type CallButtonProps = {
|
||||
className?: string;
|
||||
iconSize?: number;
|
||||
|
|
@ -11,22 +8,3 @@ export type CallButtonProps = {
|
|||
export function iconScale(iconSize?: number) {
|
||||
return { scale: (iconSize ? iconSize + 100 : 100) + "%" };
|
||||
}
|
||||
|
||||
export function withTooltip(
|
||||
button: ReactElement,
|
||||
tooltip?: string,
|
||||
portalContainer?: HTMLElement,
|
||||
) {
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={button} />
|
||||
<TooltipContent portalProps={{ container: portalContainer }}>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
23
packages/onboarding/package.json
Normal file
23
packages/onboarding/package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "@tensamin/onboarding",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.tsx"
|
||||
},
|
||||
"scripts": {
|
||||
"format": "pnpm exec prettier --write .",
|
||||
"lint": "eslint src",
|
||||
"build": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/storage": "workspace:*",
|
||||
"@tensamin/ui": "*",
|
||||
"lucide-react": "^1.14.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
178
packages/onboarding/src/index.tsx
Normal file
178
packages/onboarding/src/index.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import {
|
||||
CreateScreen,
|
||||
ErrorScreen,
|
||||
Link,
|
||||
OnboardingFlow,
|
||||
Spinner,
|
||||
type OnboardingStep,
|
||||
} from "@tensamin/ui";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { legalDocsSchema } from "@tensamin/shared/features/legal/schema";
|
||||
import { log } from "@tensamin/shared/log";
|
||||
import type { z } from "zod";
|
||||
|
||||
import LegalPage from "./pages/legal";
|
||||
import { onboardingSteps } from "./steps";
|
||||
|
||||
export {
|
||||
useOnboardingStep,
|
||||
type OnboardingStep,
|
||||
type OnboardingStepControls,
|
||||
} from "@tensamin/ui";
|
||||
|
||||
type LegalDocs = z.infer<typeof legalDocsSchema>;
|
||||
|
||||
interface GateState {
|
||||
docs: LegalDocs;
|
||||
acceptedPP: boolean;
|
||||
acceptedTOS: boolean;
|
||||
includeLegal: boolean;
|
||||
includeOnboarding: boolean;
|
||||
}
|
||||
|
||||
export default function OnboardingGate({ children }: { children: ReactNode }) {
|
||||
const { load, save } = useStorage();
|
||||
const [state, setState] = useState<GateState>();
|
||||
const [complete, setComplete] = useState(false);
|
||||
const [showLoading, setShowLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [errorDescription, setErrorDescription] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const loadingTimer = setTimeout(() => {
|
||||
if (active) setShowLoading(true);
|
||||
}, 200);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch("https://legal.tensamin.net/api/current");
|
||||
if (!response.ok) {
|
||||
throw new Error(`Legal documents request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const current: unknown = await response.json();
|
||||
if (!active) return;
|
||||
|
||||
const parsed = legalDocsSchema.safeParse(current);
|
||||
if (!parsed.success) {
|
||||
setError("Failed to load legal documents");
|
||||
setErrorDescription(
|
||||
"The legal documents data received from the server is invalid. Please try again later.",
|
||||
);
|
||||
log(0, "Legal", "red", "Invalid legal documents data", parsed.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const [localDocs, acceptedPP, acceptedTOS, onboardingDone] =
|
||||
await Promise.all([
|
||||
load("legal_docs"),
|
||||
load("accepted_privacy_policy"),
|
||||
load("accepted_terms_of_service"),
|
||||
load("onboarding_done"),
|
||||
]);
|
||||
|
||||
if (!active) return;
|
||||
|
||||
const currentAcceptedPP =
|
||||
acceptedPP && localDocs.pp.hash === parsed.data.pp.hash;
|
||||
const currentAcceptedTOS =
|
||||
acceptedTOS && localDocs.tos.hash === parsed.data.tos.hash;
|
||||
const existingUser = acceptedPP && acceptedTOS;
|
||||
const includeOnboarding = !onboardingDone && !existingUser;
|
||||
|
||||
if (!onboardingDone && existingUser) {
|
||||
await save("onboarding_done", true);
|
||||
if (!active) return;
|
||||
}
|
||||
|
||||
setState({
|
||||
docs: parsed.data,
|
||||
acceptedPP: currentAcceptedPP,
|
||||
acceptedTOS: currentAcceptedTOS,
|
||||
includeLegal: !currentAcceptedPP || !currentAcceptedTOS,
|
||||
includeOnboarding,
|
||||
});
|
||||
} catch (caught) {
|
||||
if (!active) return;
|
||||
setError("Failed to load legal documents");
|
||||
setErrorDescription(
|
||||
"An error occurred while fetching the legal documents from the server. Please try again later.",
|
||||
);
|
||||
log(0, "Legal", "red", "Failed to fetch legal documents", caught);
|
||||
} finally {
|
||||
clearTimeout(loadingTimer);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
clearTimeout(loadingTimer);
|
||||
};
|
||||
}, [load, save]);
|
||||
|
||||
const acceptLegal = useCallback(async () => {
|
||||
if (!state) return;
|
||||
await Promise.all([
|
||||
save("accepted_privacy_policy", true),
|
||||
save("accepted_terms_of_service", true),
|
||||
save("legal_docs", state.docs),
|
||||
]);
|
||||
setState((current) =>
|
||||
current ? { ...current, acceptedPP: true, acceptedTOS: true } : current,
|
||||
);
|
||||
}, [save, state]);
|
||||
|
||||
const finish = useCallback(async () => {
|
||||
if (state?.includeOnboarding) {
|
||||
await save("onboarding_done", true);
|
||||
}
|
||||
setComplete(true);
|
||||
}, [save, state?.includeOnboarding]);
|
||||
|
||||
if (error && errorDescription) {
|
||||
return <ErrorScreen error={error} description={errorDescription} />;
|
||||
}
|
||||
|
||||
if (!state) {
|
||||
if (!showLoading) return null;
|
||||
return (
|
||||
<CreateScreen>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Spinner className="size-8" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Fetching legal documents...
|
||||
</p>
|
||||
<Link
|
||||
label="status.methanium.net"
|
||||
link="https://status.methanium.net"
|
||||
/>
|
||||
</div>
|
||||
</CreateScreen>
|
||||
);
|
||||
}
|
||||
|
||||
const steps: OnboardingStep[] = [];
|
||||
if (state.includeLegal) {
|
||||
steps.push({
|
||||
id: "legal",
|
||||
defaultCanContinue: false,
|
||||
content: (
|
||||
<LegalPage
|
||||
docs={state.docs}
|
||||
initiallyAcceptedPP={state.acceptedPP}
|
||||
initiallyAcceptedTOS={state.acceptedTOS}
|
||||
onAccept={acceptLegal}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (state.includeOnboarding) {
|
||||
steps.push(...onboardingSteps);
|
||||
}
|
||||
|
||||
if (complete || steps.length === 0) return <>{children}</>;
|
||||
|
||||
return <OnboardingFlow steps={steps} onFinish={finish} />;
|
||||
}
|
||||
98
packages/onboarding/src/pages/legal.tsx
Normal file
98
packages/onboarding/src/pages/legal.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { Checkbox, Label, Link } from "@tensamin/ui";
|
||||
import { legalDocsSchema } from "@tensamin/shared/features/legal/schema";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { useOnboardingStep } from "@tensamin/ui";
|
||||
|
||||
type LegalDocs = z.infer<typeof legalDocsSchema>;
|
||||
|
||||
export default function LegalPage({
|
||||
docs,
|
||||
initiallyAcceptedPP,
|
||||
initiallyAcceptedTOS,
|
||||
onAccept,
|
||||
}: {
|
||||
docs: LegalDocs;
|
||||
initiallyAcceptedPP: boolean;
|
||||
initiallyAcceptedTOS: boolean;
|
||||
onAccept: () => Promise<void>;
|
||||
}) {
|
||||
const [acceptedPP, setAcceptedPP] = useState(initiallyAcceptedPP);
|
||||
const [acceptedTOS, setAcceptedTOS] = useState(initiallyAcceptedTOS);
|
||||
|
||||
const handleContinue = useCallback(async () => {
|
||||
if (!acceptedPP || !acceptedTOS) return false;
|
||||
await onAccept();
|
||||
}, [acceptedPP, acceptedTOS, onAccept]);
|
||||
|
||||
useOnboardingStep({
|
||||
canContinue: acceptedPP && acceptedTOS,
|
||||
onContinue: handleContinue,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full w-full max-w-5xl flex-col gap-10 p-10 py-20 md:p-24">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold md:text-4xl" tabIndex={-1}>
|
||||
Privacy Policy & ToS
|
||||
</h1>
|
||||
<p className="pt-3 text-xl text-muted-foreground">
|
||||
{docs.pp.version} / {docs.tos.version}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<BigCheckbox
|
||||
id="acceptPP"
|
||||
checked={acceptedPP}
|
||||
onChange={setAcceptedPP}
|
||||
label="I agree to the Privacy Policy"
|
||||
/>
|
||||
<BigCheckbox
|
||||
id="acceptTOS"
|
||||
checked={acceptedTOS}
|
||||
onChange={setAcceptedTOS}
|
||||
label="I agree to the Terms of Service"
|
||||
/>
|
||||
<div className="w-full border-t-2" />
|
||||
<Link
|
||||
label="Privacy Policy"
|
||||
link={`https://legal.tensamin.net/pp/${docs.pp.version}`}
|
||||
/>
|
||||
<Link
|
||||
label="Terms of Service"
|
||||
link={`https://legal.tensamin.net/tos/${docs.tos.version}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BigCheckbox({
|
||||
id,
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={onChange}
|
||||
className="flex size-5.5 items-center justify-center rounded-md"
|
||||
/>
|
||||
<Label htmlFor={id} className="text-lg">
|
||||
{label}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
packages/onboarding/src/pages/onboarding.tsx
Normal file
9
packages/onboarding/src/pages/onboarding.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export default function OnboardingPage() {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-8">
|
||||
<h1 className="text-3xl font-bold" tabIndex={-1}>
|
||||
Onboarding
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
packages/onboarding/src/steps.tsx
Normal file
10
packages/onboarding/src/steps.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import type { OnboardingStep } from "@tensamin/ui";
|
||||
import OnboardingPage from "./pages/onboarding";
|
||||
|
||||
// Add future onboarding pages here. Completed users skip this entire manifest.
|
||||
export const onboardingSteps: OnboardingStep[] = [
|
||||
{
|
||||
id: "onboarding",
|
||||
content: <OnboardingPage />,
|
||||
},
|
||||
];
|
||||
12
packages/onboarding/tsconfig.json
Normal file
12
packages/onboarding/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
|
@ -1,14 +1,4 @@
|
|||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
Input,
|
||||
Label,
|
||||
Switch as UISwitch,
|
||||
} from "@tensamin/ui";
|
||||
import { EditableList, LabeledSwitch } from "@tensamin/ui";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { storageDefaults, type Storage } from "@tensamin/shared/data";
|
||||
|
|
@ -24,9 +14,12 @@ type ListStorageKey = {
|
|||
}[keyof Storage];
|
||||
|
||||
type ListStorageItem<K extends ListStorageKey> =
|
||||
Storage[K] extends Array<infer Item> ? Item : never;
|
||||
Storage[K] extends Array<infer Item> ? Item & (string | number) : never;
|
||||
|
||||
export function Switch({ label, id }: {
|
||||
export function Switch({
|
||||
label,
|
||||
id,
|
||||
}: {
|
||||
label: React.ReactNode;
|
||||
id: keyof typeof settingsStorageDefaults & BooleanStorageKey;
|
||||
}) {
|
||||
|
|
@ -38,91 +31,55 @@ export function Switch({ label, id }: {
|
|||
}, [id, load]);
|
||||
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<UISwitch id={id} checked={value} onCheckedChange={(nextValue) => {
|
||||
<LabeledSwitch
|
||||
id={id}
|
||||
label={label}
|
||||
checked={value}
|
||||
onCheckedChange={(nextValue) => {
|
||||
setValue(nextValue);
|
||||
save(id, nextValue);
|
||||
}} />
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
</div>
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function List<K extends ListStorageKey>({ label, id }: {
|
||||
export function List<K extends ListStorageKey>({
|
||||
label,
|
||||
id,
|
||||
}: {
|
||||
label: React.ReactNode;
|
||||
id: K;
|
||||
}) {
|
||||
const { save, load } = useStorage();
|
||||
const [items, setItems] = useState<Storage[K]>(storageDefaults[id]);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
load(id).then((value) => setItems(value));
|
||||
}, [id, load]);
|
||||
|
||||
const persistItems = (nextItems: Storage[K]) => {
|
||||
setItems(nextItems);
|
||||
save(id, nextItems);
|
||||
const persistItems = (nextItems: ListStorageItem<K>[]) => {
|
||||
setItems(nextItems as Storage[K]);
|
||||
save(id, nextItems as Storage[K]);
|
||||
};
|
||||
|
||||
const toStorageItem = (value: string): ListStorageItem<K> => {
|
||||
const referenceItem = items[0] ?? storageDefaults[id][0];
|
||||
return (typeof referenceItem === "number" ? Number(value) : value) as ListStorageItem<K>;
|
||||
};
|
||||
|
||||
const addItem = () => {
|
||||
const trimmedValue = inputValue.trim();
|
||||
if (!trimmedValue) return;
|
||||
const nextItem = toStorageItem(trimmedValue);
|
||||
if (typeof nextItem === "number" && Number.isNaN(nextItem)) return;
|
||||
persistItems([...items, nextItem] as Storage[K]);
|
||||
setInputValue("");
|
||||
};
|
||||
|
||||
const deleteItems = (indexes: Set<number>) => {
|
||||
const nextItems = items.filter((_, index) => !indexes.has(index)) as Storage[K];
|
||||
setItems(nextItems);
|
||||
setSelectedItems(new Set());
|
||||
save(id, nextItems);
|
||||
return (
|
||||
typeof referenceItem === "number" ? Number(value) : value
|
||||
) as ListStorageItem<K>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 pt-4">
|
||||
<Label>{label}</Label>
|
||||
<div className="flex flex-col gap-0 overflow-hidden p-1 border-2 rounded-xl">
|
||||
<div className="flex gap-1">
|
||||
<Input value={inputValue} onChange={(event) => setInputValue(event.target.value)} onKeyDown={(event) => {
|
||||
if (event.key === "Enter") addItem();
|
||||
}} />
|
||||
<Button onClick={addItem}>Add item</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0">
|
||||
{items.map((item, index) => {
|
||||
const labelId = `${String(id)}-${index}`;
|
||||
const selected = selectedItems.has(index);
|
||||
const deletingSelectedItems = selectedItems.size > 1;
|
||||
return (
|
||||
<ContextMenu key={`${String(item)}-${index}`}>
|
||||
<ContextMenuTrigger render={<div className="grid grid-cols-[auto_auto_1fr] items-center gap-2 border-b px-1 py-2 last:border-b-0">
|
||||
<Checkbox id={labelId} checked={selected} onCheckedChange={(checked) => setSelectedItems((previous) => {
|
||||
const nextSelected = new Set(previous);
|
||||
if (checked) nextSelected.add(index);
|
||||
else nextSelected.delete(index);
|
||||
return nextSelected;
|
||||
})} />
|
||||
<Label htmlFor={labelId}>{String(item)}</Label><div />
|
||||
</div>} />
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem variant="destructive" onClick={() => deleteItems(deletingSelectedItems ? selectedItems : new Set([index]))}>
|
||||
{deletingSelectedItems ? "Delete Selected" : "Delete"}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<EditableList<ListStorageItem<K>>
|
||||
label={label}
|
||||
items={items as ListStorageItem<K>[]}
|
||||
parseItem={(value) => {
|
||||
const item = toStorageItem(value);
|
||||
return typeof item === "number" && Number.isNaN(item)
|
||||
? undefined
|
||||
: item;
|
||||
}}
|
||||
onItemsChange={persistItems}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -421,6 +421,7 @@ export interface Storage extends SettingsStorageDefaults {
|
|||
session_id: number;
|
||||
user_id: number;
|
||||
mtp_keyring: string;
|
||||
onboarding_done: boolean;
|
||||
ppandtos_done: boolean;
|
||||
accepted_terms_of_service: boolean;
|
||||
accepted_privacy_policy: boolean;
|
||||
|
|
@ -458,6 +459,7 @@ export const storageDefaults: Storage = {
|
|||
session_id: 0,
|
||||
user_id: 0,
|
||||
mtp_keyring: "",
|
||||
onboarding_done: false,
|
||||
ppandtos_done: false,
|
||||
accepted_terms_of_service: false,
|
||||
accepted_privacy_policy: false,
|
||||
|
|
|
|||
Loading…
Reference in a new issue