import { useEffect, useState, type ReactNode } from 'react';
import type { ColumnMeta, TableMeta } from '@wellformed/shared';
import { api } from './api/client';
import { usePublicConfig } from './api/hooks';
import { useI18n } from './i18n';
import { useSession } from './state/session';
import { useDismissSsr } from './state/ssr';
import { useWorkspace, WorkspaceProvider } from './state/workspace';
import { useToasts } from './state/toasts';
import { readRoute, type Route } from './state/route';
import { LoginScreen } from './components/LoginScreen';
import { SignupScreen } from './components/SignupScreen';
import { InviteScreen } from './components/InviteScreen';
import { ForgotScreen, ResetScreen, VerifyScreen } from './components/Recovery';
import { MarketingScreen } from './components/MarketingScreen';
import { EmptyWorkspace } from './components/EmptyWorkspace';
import { ImportDialog } from './components/ImportDialog';
import { TidyDialog } from './components/TidyDialog';
import { BillingDialog } from './components/BillingDialog';
import { PlanBanner } from './components/PlanBanner';
import { WorkspacePicker } from './components/WorkspacePicker';
import { TopBar } from './components/TopBar';
import { Sidebar } from './components/Sidebar';
import { ViewBar } from './components/ViewBar';
import { FormulaBar } from './components/FormulaBar';
import { Grid } from './components/Grid';
import { SqlBar } from './components/SqlBar';
import { AuditPanel } from './components/AuditPanel';
import { ColumnDialog } from './components/ColumnDialog';
import { TightenDialog } from './components/TightenDialog';
import { TableDialog } from './components/TableDialog';
import { PeopleDialog } from './components/PeopleDialog';
import { AgentKeysDialog } from './components/AgentKeysDialog';
import { AccountDialog } from './components/AccountDialog';
import { WorkspaceDialog } from './components/WorkspaceDialog';
import { ConfirmDialog } from './components/Modal';
export function App({ route = readRoute() }: { route?: Route }): ReactNode {
const session = useSession();
const { t } = useI18n();
if (session.loading) {
return
{t('app.starting')}
;
}
if (!session.reachable) {
return (
{t('app.unreachableBefore')}
npm run dev
{t('app.unreachableAfter')}
);
}
// Links that arrive by mail have to work for someone with no account, or no
// usable password, so they are answered before the sign-in gate rather than
// behind it. A reset link in particular is aimed at exactly the person who
// cannot get past that gate.
if (route.kind === 'invite') return ;
if (route.kind === 'reset') return ;
if (route.kind === 'verify') return ;
if (route.kind === 'pricing') return ;
if (!session.account) {
if (route.kind === 'signup') return ;
if (route.kind === 'forgot') return ;
if (route.kind === 'signin') return ;
// A named workspace with no session is either the published demo, which
// reads without one, or somewhere this browser has to sign in to reach.
if (route.kind === 'workspace') {
if (!session.workspace) return ;
return (
);
}
// Landing here with nothing in the URL is someone who has not decided yet.
return ;
}
// Signed in, but the URL names no workspace — or names one this account
// cannot open. Either way the honest answer is the list of the ones it can.
if (!session.workspace) {
return (
);
}
return (
);
}
/**
* What answers at the root, which is not the same page on every install.
*
* A hosted Wellformed opens with Wellformed selling itself: a headline, a
* screenshot, four selling points and a price list. None of that belongs on an
* install somebody bought and runs themselves. The prices are not theirs, the
* pitch is aimed at a decision their people already made, and "Start free" is
* an offer nobody on that server can accept — so the front door is the door.
*
* Held back until the answer is actually known. Guessing "hosted" and
* correcting a moment later would flash Wellformed's own sales page across the
* front of somebody else's business, which is the one thing this must not do;
* guessing the other way costs a hosted visitor a few hundred milliseconds of
* the same card the app already shows while it starts.
*/
function FrontDoor({ focus }: { focus?: 'pricing' }): ReactNode {
const install = usePublicConfig();
const { t } = useI18n();
if (install.isPending) return {t('app.starting')}
;
if (install.data && !install.data.metered) return ;
return ;
}
/** The centred card the app falls back to before it has a grid to show. */
export function Splash({
tagline,
children,
}: {
tagline: string;
children?: ReactNode;
}): ReactNode {
// Splash only renders as a terminal state (the API is unreachable), and a
// server-rendered page whose buttons all need that API should not stay up
// to disagree with it.
useDismissSsr();
return (
Wellformed
{tagline}
{children &&
{children}
}
);
}
type Dialog =
| { kind: 'addColumn' }
| { kind: 'editColumn'; column: ColumnMeta }
| { kind: 'tightenColumn'; column: ColumnMeta }
| { kind: 'deleteColumn'; column: ColumnMeta }
| { kind: 'addTable' }
| { kind: 'editTable'; table: TableMeta }
| { kind: 'deleteTable'; table: TableMeta }
| { kind: 'people' }
| { kind: 'agentKeys' }
| { kind: 'account' }
| { kind: 'workspace' }
| { kind: 'import'; table?: string }
| { kind: 'tidy'; table: string }
| { kind: 'billing' }
| null;
function Workspace(): ReactNode {
const ws = useWorkspace();
const toasts = useToasts();
const { t, locale } = useI18n();
const [dialog, setDialog] = useState