W0 web foundation: Vite + React SPA under /app/, sync-pull ledger, auth, tokens, shared components
Build Kvitt images / build (push) Failing after 1h14m5s
Build Kvitt images / build (push) Failing after 1h14m5s
web/ is the browser client's scaffold: Vite 8, React 19, TypeScript 5.9 strict, React Router 7 (basename /app, every fixed route with a lazy placeholder page), TanStack Query 5 (refetch on focus and every 60 s), openapi-typescript types generated from contracts/openapi.yaml with openapi-fetch. Reads go through GET /sync (no REST reads exist for the ledger, SPEC §7): useLedger() pulls from cursor 0 and then incrementally, restarting on a new epoch or cursor_ahead. Login pairs the browser as "Web · <browser>" with an invite code (E-3); every call sends the SPEC §14.3.1 headers, a 401 signs out. kvitt-domain's split, balances, allocation and settlement rules are ported to web/src/lib and pass every case of contracts/test-vectors. Design tokens from design-spec §2 in tokens.css (light-dark()), Material Symbols Rounded (Apache-2.0) as generated inline SVG, sv/en catalogs generated from docs/ux/strings.csv plus web-strings.csv. Gate: cd web && npm run check. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
import { PlaceholderPage } from '../../app/PlaceholderPage';
|
||||
import { paths } from '../../app/routes';
|
||||
import { useI18n } from '../../i18n/useI18n';
|
||||
|
||||
/** S-5 Account detail, /app/accounts/:id. */
|
||||
export default function AccountDetailPage() {
|
||||
const { t } = useI18n();
|
||||
return <PlaceholderPage title={t('web_account_detail_title')} back={paths.home} />;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useParams } from 'react-router';
|
||||
import { PlaceholderPage } from '../../app/PlaceholderPage';
|
||||
import { paths } from '../../app/routes';
|
||||
import { useI18n } from '../../i18n/useI18n';
|
||||
|
||||
/** S-6 Account editor: /app/accounts/new and /app/accounts/:id/edit. */
|
||||
export default function AccountEditorPage() {
|
||||
const { t } = useI18n();
|
||||
const { id } = useParams();
|
||||
return id === undefined ? (
|
||||
<PlaceholderPage title={t('account_editor_new')} back={paths.home} />
|
||||
) : (
|
||||
<PlaceholderPage title={t('account_editor_edit')} back={paths.account(id)} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { RouteObject } from 'react-router';
|
||||
import { lazyPage } from '../../app/lazyPage';
|
||||
|
||||
/** Routes of W1's accounts, relative to /app (inside the shell). */
|
||||
export const accountsRoutes: RouteObject[] = [
|
||||
{ path: 'accounts/new', ...lazyPage(() => import('./AccountEditorPage')) },
|
||||
{ path: 'accounts/:id', ...lazyPage(() => import('./AccountDetailPage')) },
|
||||
{ path: 'accounts/:id/edit', ...lazyPage(() => import('./AccountEditorPage')) },
|
||||
];
|
||||
@@ -0,0 +1,8 @@
|
||||
import { PlaceholderPage } from '../../app/PlaceholderPage';
|
||||
import { useI18n } from '../../i18n/useI18n';
|
||||
|
||||
/** S-11 Analytics, /app/analytics. */
|
||||
export default function AnalyticsPage() {
|
||||
const { t } = useI18n();
|
||||
return <PlaceholderPage title={t('analytics_title')} />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { PlaceholderPage } from '../../app/PlaceholderPage';
|
||||
import { paths } from '../../app/routes';
|
||||
import { useI18n } from '../../i18n/useI18n';
|
||||
|
||||
/** S-12 Analytics drill-down, /app/analytics/:groupKind/:groupId. */
|
||||
export default function DrillDownPage() {
|
||||
const { t } = useI18n();
|
||||
return <PlaceholderPage title={t('analytics_title')} back={paths.analytics} />;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { PlaceholderPage } from '../../app/PlaceholderPage';
|
||||
import { useI18n } from '../../i18n/useI18n';
|
||||
|
||||
/** Insights, /app/insights (web only). */
|
||||
export default function InsightsPage() {
|
||||
const { t } = useI18n();
|
||||
return <PlaceholderPage title={t('web_insights_title')} />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { RouteObject } from 'react-router';
|
||||
import { lazyPage } from '../../app/lazyPage';
|
||||
|
||||
/** Routes of W4's analytics and insights, relative to /app (inside the shell). */
|
||||
export const analyticsRoutes: RouteObject[] = [
|
||||
{ path: 'analytics', ...lazyPage(() => import('./AnalyticsPage')) },
|
||||
{ path: 'analytics/:groupKind/:groupId', ...lazyPage(() => import('./DrillDownPage')) },
|
||||
{ path: 'insights', ...lazyPage(() => import('./InsightsPage')) },
|
||||
];
|
||||
@@ -0,0 +1,82 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--s6) var(--gutter);
|
||||
}
|
||||
.column {
|
||||
width: min(440px, 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s6);
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
text-align: center;
|
||||
}
|
||||
.mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-container);
|
||||
color: var(--on-primary-container);
|
||||
}
|
||||
.wordmark {
|
||||
font: var(--type-display-small);
|
||||
}
|
||||
.tagline {
|
||||
font: var(--type-body-large);
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
.title {
|
||||
font: var(--type-headline-small);
|
||||
}
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s4);
|
||||
}
|
||||
.server {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s1);
|
||||
}
|
||||
.serverLabel {
|
||||
font: var(--type-label-medium);
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
.serverValue {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
font: var(--type-body-large);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.code {
|
||||
font: var(--type-title-large);
|
||||
letter-spacing: 0.08em;
|
||||
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
}
|
||||
.hint {
|
||||
font: var(--type-body-medium);
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
.notice {
|
||||
padding: var(--s3) var(--s4);
|
||||
border-radius: var(--radius-medium);
|
||||
background: var(--warning-container);
|
||||
color: var(--on-warning-container);
|
||||
font: var(--type-body-medium);
|
||||
}
|
||||
.noticeTitle {
|
||||
font: var(--type-title-small);
|
||||
}
|
||||
.error {
|
||||
font: var(--type-body-medium);
|
||||
color: var(--error);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { act, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { FakeServer, errorResponse, json } from '../../test/fakeServer';
|
||||
import { ANNA, DEVICE, JACOB, page } from '../../test/fixtures';
|
||||
import { renderApp } from '../../test/render';
|
||||
import { formatInviteCode, inviterFromLink, isValidInviteCode, normaliseInviteCode } from './inviteCode';
|
||||
|
||||
const TOKEN = 'kvt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
|
||||
|
||||
function server() {
|
||||
return new FakeServer()
|
||||
.on('POST', '/invites/accept', () =>
|
||||
json({ token: TOKEN, invite_kind: 'device', user: JACOB, device: DEVICE, household: [JACOB, ANNA] }),
|
||||
)
|
||||
.on('GET', '/sync', () => json(page(0, { users: [JACOB, ANNA] })))
|
||||
.on('GET', '/me', () =>
|
||||
json({ user: JACOB, device: DEVICE, household: [JACOB, ANNA], public_url: 'https://kvitt.test' }),
|
||||
);
|
||||
}
|
||||
|
||||
describe('invite codes (SPEC §8.3)', () => {
|
||||
it('normalises, validates and groups', () => {
|
||||
expect(normaliseInviteCode('7k3m-qx9d-2hpa')).toBe('7K3MQX9D2HPA');
|
||||
expect(normaliseInviteCode('O1IL 0000 0000')).toBe('0111' + '00000000');
|
||||
expect(normaliseInviteCode('https://kvitt.test/i/4N8RVT2KW6ZC?from=Jacob')).toBe('4N8RVT2KW6ZC');
|
||||
expect(inviterFromLink('https://kvitt.test/i/4N8RVT2KW6ZC?from=Jacob%20H')).toBe('Jacob H');
|
||||
expect(isValidInviteCode('7K3MQX9D2HPA')).toBe(true);
|
||||
expect(isValidInviteCode('7K3MQX9D2HPU')).toBe(false);
|
||||
expect(isValidInviteCode('7K3M')).toBe(false);
|
||||
expect(formatInviteCode('7K3MQX9D2HPA')).toBe('7K3M-QX9D-2HPA');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sign-in flow', () => {
|
||||
it('sends a signed-out visitor to the login page', async () => {
|
||||
renderApp('/', { session: null });
|
||||
expect(await screen.findByRole('heading', { name: 'Logga in i Kvitt' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pairs with an invite code, stores the token and sends it with the protocol headers', async () => {
|
||||
const fake = server();
|
||||
const { session } = renderApp('/settings', { session: null, server: fake });
|
||||
const user = userEvent.setup();
|
||||
await user.type(await screen.findByLabelText('Inbjudningskod'), '7k3mqx9d2hpa');
|
||||
expect(screen.getByLabelText('Inbjudningskod')).toHaveValue('7K3M-QX9D-2HPA');
|
||||
await user.click(screen.getByRole('button', { name: 'Para ihop' }));
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Inställningar' })).toBeInTheDocument();
|
||||
const accept = fake.calls('POST', '/invites/accept')[0];
|
||||
expect(accept?.body).toEqual({ code: '7K3MQX9D2HPA', device_name: expect.stringMatching(/^Web/) as string });
|
||||
expect(accept?.headers.get('Authorization')).toBeNull();
|
||||
expect(session.get()?.token).toBe(TOKEN);
|
||||
expect(window.localStorage.getItem('kvitt.session.test')).toContain(TOKEN);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fake.calls('GET', '/sync').length).toBeGreaterThan(0);
|
||||
});
|
||||
const pull = fake.calls('GET', '/sync')[0];
|
||||
expect(pull?.headers.get('Authorization')).toBe(`Bearer ${TOKEN}`);
|
||||
expect(pull?.headers.get('X-Kvitt-Protocol')).toBe('1');
|
||||
expect(pull?.headers.get('X-Kvitt-App-Version')).toBe('1');
|
||||
expect(pull?.headers.get('X-Kvitt-App-Version-Name')).toBe('0.1.0');
|
||||
expect(pull?.headers.get('Accept-Language')).toBe('sv');
|
||||
});
|
||||
|
||||
it('asks for a name when the code needs one, and shows invite errors', async () => {
|
||||
let attempts = 0;
|
||||
const fake = server().on('POST', '/invites/accept', (request) => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) return errorResponse('invite_invalid', 400);
|
||||
const body = request.body as { display_name?: string };
|
||||
if (body.display_name === undefined) return errorResponse('invalid_request', 400, 'display_name');
|
||||
return json({ token: TOKEN, invite_kind: 'partner', user: ANNA, device: DEVICE, household: [JACOB, ANNA] });
|
||||
});
|
||||
renderApp('/login', { session: null, server: fake, language: 'en' });
|
||||
const user = userEvent.setup();
|
||||
await user.type(await screen.findByLabelText('Invite code'), '7K3MQX9D2HPA');
|
||||
await user.click(screen.getByRole('button', { name: 'Pair' }));
|
||||
expect(await screen.findByText(/This code does not work/)).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Pair' }));
|
||||
const name = await screen.findByLabelText('Your name');
|
||||
await user.click(screen.getByRole('button', { name: 'Pair' }));
|
||||
expect(await screen.findByText('Enter your name')).toBeInTheDocument();
|
||||
await user.type(name, 'Anna');
|
||||
await user.click(screen.getByRole('button', { name: 'Pair' }));
|
||||
// A partner invite opens the profile first (AC-2.10).
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Profile' })).toBeInTheDocument();
|
||||
expect(fake.calls('POST', '/invites/accept').at(-1)?.body).toMatchObject({ display_name: 'Anna' });
|
||||
});
|
||||
|
||||
it('signs out on 401 and says so on the login page', async () => {
|
||||
const fake = server().on('GET', '/sync', () => errorResponse('unauthorized', 401));
|
||||
const { session } = renderApp('/', { server: fake });
|
||||
expect(await screen.findByText('Du loggades ut')).toBeInTheDocument();
|
||||
expect(session.get()).toBeNull();
|
||||
expect(session.lastSignOut()).toBe('unauthorized');
|
||||
});
|
||||
|
||||
it("shows S-18's text when the device was revoked (D-130)", async () => {
|
||||
const fake = server().on('GET', '/sync', () => errorResponse('device_revoked', 401));
|
||||
const { session } = renderApp('/', { server: fake });
|
||||
expect(await screen.findByText('Den här telefonen har tagits bort')).toBeInTheDocument();
|
||||
expect(screen.getByText('Vill du använda Kvitt igen behöver du en ny inbjudningskod.')).toBeInTheDocument();
|
||||
expect(session.lastSignOut()).toBe('device_revoked');
|
||||
});
|
||||
|
||||
it('signs out from Settings after confirming', async () => {
|
||||
const { session } = renderApp('/settings', { server: server() });
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByRole('button', { name: 'Logga ut' }));
|
||||
const dialog = await screen.findByRole('alertdialog', { name: 'Logga ut' });
|
||||
await act(async () => {
|
||||
await user.click(dialog.querySelectorAll('button')[1] as HTMLElement);
|
||||
});
|
||||
expect(await screen.findByRole('heading', { name: 'Logga in i Kvitt' })).toBeInTheDocument();
|
||||
expect(session.get()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router';
|
||||
import { ApiError, unwrap } from '../../api/client';
|
||||
import { useApi, useSessionStore } from '../../api/useApi';
|
||||
import { webDeviceName } from '../../app/buildInfo';
|
||||
import { paths } from '../../app/routes';
|
||||
import { useDocumentTitle } from '../../app/useDocumentTitle';
|
||||
import { useSession } from '../../app/useSession';
|
||||
import { Button } from '../../components/Button';
|
||||
import { Card } from '../../components/Card';
|
||||
import { TextField } from '../../components/TextField';
|
||||
import { Icon } from '../../design/Icon';
|
||||
import type { StringKey } from '../../i18n/catalog';
|
||||
import { useI18n } from '../../i18n/useI18n';
|
||||
import { formatInviteCode, inviterFromLink, isValidInviteCode, normaliseInviteCode } from './inviteCode';
|
||||
import styles from './LoginPage.module.css';
|
||||
|
||||
/** SPEC §9.6: a display name is 1 to 40 code points after trimming. */
|
||||
const DISPLAY_NAME_MAX = 40;
|
||||
|
||||
function errorKey(error: unknown): StringKey {
|
||||
if (!(error instanceof ApiError)) return 'error_server';
|
||||
switch (error.code) {
|
||||
case 'network':
|
||||
return 'onboarding_error_network';
|
||||
case 'invite_invalid':
|
||||
return 'onboarding_error_invite_invalid';
|
||||
case 'rate_limited':
|
||||
return 'onboarding_error_rate_limited';
|
||||
case 'household_full':
|
||||
return 'error_household_full';
|
||||
case 'validation_failed':
|
||||
case 'invalid_request':
|
||||
return 'error_validation';
|
||||
default:
|
||||
return 'error_server';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign-in like a phone's pairing (S-1): the server is this origin, the user types an invite code (12 characters in
|
||||
* groups of 4) and the browser becomes a device ("Web · <browser>") through E-3. A display name is asked for only
|
||||
* when the code turns out to need one (`admin_setup` or `partner` invites: `invalid_request` on `display_name`).
|
||||
* After a 401 it says why the session ended: S-18's text for `device_revoked` (D-130), otherwise "Du loggades ut".
|
||||
*/
|
||||
export default function LoginPage() {
|
||||
const { t, tp } = useI18n();
|
||||
const api = useApi();
|
||||
const sessionStore = useSessionStore();
|
||||
const session = useSession();
|
||||
const queryClient = useQueryClient();
|
||||
const location = useLocation();
|
||||
const [code, setCode] = useState('');
|
||||
const [inviter, setInviter] = useState<string | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [needsName, setNeedsName] = useState(false);
|
||||
const [codeError, setCodeError] = useState<string | null>(null);
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [landing, setLanding] = useState<string | null>(null);
|
||||
useDocumentTitle(t('web_login_title'));
|
||||
|
||||
const from = (location.state as { from?: string } | null)?.from ?? paths.home;
|
||||
if (session !== null) return <Navigate to={landing ?? from} replace />;
|
||||
const signedOut = sessionStore.lastSignOut();
|
||||
|
||||
const onCodeChange = (text: string) => {
|
||||
const name = inviterFromLink(text);
|
||||
if (name !== null) setInviter(name);
|
||||
setCode(formatInviteCode(normaliseInviteCode(text).slice(0, 12)));
|
||||
setCodeError(null);
|
||||
setFormError(null);
|
||||
};
|
||||
|
||||
const submit = async (event: { preventDefault: () => void }) => {
|
||||
event.preventDefault();
|
||||
const normalised = normaliseInviteCode(code);
|
||||
if (!isValidInviteCode(normalised)) {
|
||||
setCodeError(t('onboarding_error_code_length'));
|
||||
return;
|
||||
}
|
||||
const displayName = name.trim();
|
||||
if (needsName) {
|
||||
if (displayName.length === 0) {
|
||||
setNameError(t('onboarding_error_name_required'));
|
||||
return;
|
||||
}
|
||||
if (Array.from(displayName).length > DISPLAY_NAME_MAX) {
|
||||
setNameError(tp('error_too_long', DISPLAY_NAME_MAX));
|
||||
return;
|
||||
}
|
||||
}
|
||||
setBusy(true);
|
||||
setFormError(null);
|
||||
try {
|
||||
const accepted = await unwrap(
|
||||
api.POST('/invites/accept', {
|
||||
body: {
|
||||
code: normalised,
|
||||
device_name: webDeviceName(navigator.userAgent),
|
||||
...(needsName ? { display_name: displayName } : {}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
queryClient.clear();
|
||||
// After a partner invite the first page is the profile, like the app's S-2 (AC-2.10, D-086).
|
||||
setLanding(accepted.invite_kind === 'partner' ? paths.settingsProfile : from);
|
||||
sessionStore.signIn({
|
||||
token: accepted.token,
|
||||
userId: accepted.user.id,
|
||||
deviceId: accepted.device.id,
|
||||
inviteKind: accepted.invite_kind,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.code === 'invalid_request' && error.field === 'display_name') {
|
||||
setNeedsName(true);
|
||||
} else if (error instanceof ApiError && error.code === 'validation_failed' && error.field === 'display_name') {
|
||||
setNeedsName(true);
|
||||
setNameError(tp('error_too_long', DISPLAY_NAME_MAX));
|
||||
} else if (error instanceof ApiError && error.code === 'invite_invalid') {
|
||||
setCodeError(t('onboarding_error_invite_invalid'));
|
||||
} else {
|
||||
setFormError(t(errorKey(error)));
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const paste = async () => {
|
||||
try {
|
||||
onCodeChange(await navigator.clipboard.readText());
|
||||
} catch (error) {
|
||||
// Clipboard access refused or unavailable: the user types or pastes into the field instead.
|
||||
if (!(error instanceof DOMException) && !(error instanceof TypeError)) throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<div className={styles.column}>
|
||||
<div className={styles.brand}>
|
||||
<span className={styles.mark} aria-hidden="true">
|
||||
<Icon name="receipt_long" filled size={32} />
|
||||
</span>
|
||||
<p className={styles.wordmark}>{t('common_wordmark')}</p>
|
||||
<p className={styles.tagline}>{t('common_tagline')}</p>
|
||||
</div>
|
||||
<Card>
|
||||
<h1 className={styles.title}>{t('web_login_title')}</h1>
|
||||
{signedOut === 'device_revoked' ? (
|
||||
<div className={styles.notice} role="status">
|
||||
<p className={styles.noticeTitle}>{t('onboarding_removed_title')}</p>
|
||||
<p>{t('onboarding_removed_new_code')}</p>
|
||||
</div>
|
||||
) : signedOut === 'unauthorized' ? (
|
||||
<p className={styles.notice} role="status">
|
||||
{t('web_signed_out')}
|
||||
</p>
|
||||
) : null}
|
||||
<form className={styles.form} onSubmit={(event) => void submit(event)} noValidate>
|
||||
<div className={styles.server}>
|
||||
<span className={styles.serverLabel}>{t('web_login_server')}</span>
|
||||
<span className={styles.serverValue}>
|
||||
<Icon name="dns" size={18} />
|
||||
{window.location.host}
|
||||
</span>
|
||||
</div>
|
||||
<TextField
|
||||
label={t('common_invite_code')}
|
||||
value={code}
|
||||
onChange={onCodeChange}
|
||||
error={codeError}
|
||||
supporting={inviter === null ? t('web_login_body') : t('onboarding_code_help', inviter)}
|
||||
placeholder={t('onboarding_code_placeholder')}
|
||||
autoComplete="one-time-code"
|
||||
autoCapitalize="characters"
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
inputClassName={styles.code}
|
||||
suffix={
|
||||
<Button variant="text" onClick={() => void paste()} icon={<Icon name="content_paste" size={20} />}>
|
||||
{t('onboarding_code_paste')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{needsName ? (
|
||||
<>
|
||||
<p className={styles.hint}>{t('web_login_name_needed')}</p>
|
||||
<TextField
|
||||
label={t('onboarding_name_label')}
|
||||
value={name}
|
||||
onChange={(value) => {
|
||||
setName(value);
|
||||
setNameError(null);
|
||||
}}
|
||||
error={nameError}
|
||||
autoComplete="given-name"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{formError === null ? null : (
|
||||
<p className={styles.error} role="alert">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" size="medium" disabled={busy}>
|
||||
{busy ? t('onboarding_pairing') : t('onboarding_pair')}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { RouteObject } from 'react-router';
|
||||
import { lazyPage } from '../../app/lazyPage';
|
||||
|
||||
/** W-1 sign-in, outside the shell and the auth gate (W0). */
|
||||
export const authRoutes: RouteObject[] = [{ path: 'login', ...lazyPage(() => import('./LoginPage')) }];
|
||||
@@ -0,0 +1,34 @@
|
||||
/** Invite codes (SPEC §8.3): 12 Crockford base32 characters, shown in three groups of four. */
|
||||
export const INVITE_CODE_LENGTH = 12;
|
||||
const ALPHABET = /^[0-9ABCDEFGHJKMNPQRSTVWXYZ]*$/;
|
||||
|
||||
/**
|
||||
* Normalisation before sending: uppercase; remove `-` and white space; `O` -> `0`, `I` and `L` -> `1`. A pasted invite
|
||||
* link (`…/i/<code>?from=…`) gives its code.
|
||||
*/
|
||||
export function normaliseInviteCode(text: string): string {
|
||||
const fromLink = /\/i\/([^/?#\s]+)/.exec(text)?.[1] ?? text;
|
||||
return fromLink.toUpperCase().replace(/[-\s]/g, '').replace(/O/g, '0').replace(/[IL]/g, '1');
|
||||
}
|
||||
|
||||
export function isValidInviteCode(normalised: string): boolean {
|
||||
return normalised.length === INVITE_CODE_LENGTH && ALPHABET.test(normalised);
|
||||
}
|
||||
|
||||
/** Groups of four for display while typing: `7K3M-QX9D-2HPA`. */
|
||||
export function formatInviteCode(normalised: string): string {
|
||||
return (normalised.match(/.{1,4}/g) ?? []).join('-');
|
||||
}
|
||||
|
||||
/** The admin's display name of a pasted link's `from` parameter, if any (D-087), at most 40 code points. */
|
||||
export function inviterFromLink(text: string): string | null {
|
||||
const match = /[?&]from=([^&#\s]*)/.exec(text);
|
||||
if (match?.[1] === undefined) return null;
|
||||
try {
|
||||
const name = decodeURIComponent(match[1].replace(/\+/g, ' ')).trim();
|
||||
return name.length > 0 && Array.from(name).length <= 40 ? name : null;
|
||||
} catch (error) {
|
||||
if (error instanceof URIError) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { PlaceholderPage } from '../../app/PlaceholderPage';
|
||||
import { paths } from '../../app/routes';
|
||||
import { useI18n } from '../../i18n/useI18n';
|
||||
|
||||
/** S-8 Expense detail, /app/expenses/:id. */
|
||||
export default function ExpenseDetailPage() {
|
||||
const { t } = useI18n();
|
||||
return <PlaceholderPage title={t('web_expense_detail_title')} back={paths.home} />;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useParams } from 'react-router';
|
||||
import { PlaceholderPage } from '../../app/PlaceholderPage';
|
||||
import { paths } from '../../app/routes';
|
||||
import { useI18n } from '../../i18n/useI18n';
|
||||
|
||||
/** S-7 Expense editor: /app/expenses/new and /app/expenses/:id/edit. */
|
||||
export default function ExpenseEditorPage() {
|
||||
const { t } = useI18n();
|
||||
const { id } = useParams();
|
||||
return id === undefined ? (
|
||||
<PlaceholderPage title={t('expense_editor_new')} back={paths.home} />
|
||||
) : (
|
||||
<PlaceholderPage title={t('expense_editor_edit')} back={paths.expense(id)} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { RouteObject } from 'react-router';
|
||||
import { lazyPage } from '../../app/lazyPage';
|
||||
|
||||
/** Routes of W2's expenses, relative to /app (inside the shell). */
|
||||
export const expensesRoutes: RouteObject[] = [
|
||||
{ path: 'expenses/new', ...lazyPage(() => import('./ExpenseEditorPage')) },
|
||||
{ path: 'expenses/:id', ...lazyPage(() => import('./ExpenseDetailPage')) },
|
||||
{ path: 'expenses/:id/edit', ...lazyPage(() => import('./ExpenseEditorPage')) },
|
||||
];
|
||||
@@ -0,0 +1,8 @@
|
||||
import { PlaceholderPage } from '../../app/PlaceholderPage';
|
||||
import { useI18n } from '../../i18n/useI18n';
|
||||
|
||||
/** S-4 Home (W1). */
|
||||
export default function HomePage() {
|
||||
const { t } = useI18n();
|
||||
return <PlaceholderPage title={t('nav_home')} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { RouteObject } from 'react-router';
|
||||
import { lazyPage } from '../../app/lazyPage';
|
||||
|
||||
/** Routes of W1's Home, relative to /app (inside the shell). */
|
||||
export const homeRoutes: RouteObject[] = [{ index: true, ...lazyPage(() => import('./HomePage')) }];
|
||||
@@ -0,0 +1,8 @@
|
||||
import { PlaceholderPage } from '../../app/PlaceholderPage';
|
||||
import { useI18n } from '../../i18n/useI18n';
|
||||
|
||||
/** Search, /app/search (web only; the / shortcut). */
|
||||
export default function SearchPage() {
|
||||
const { t } = useI18n();
|
||||
return <PlaceholderPage title={t('web_search_title')} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { RouteObject } from 'react-router';
|
||||
import { lazyPage } from '../../app/lazyPage';
|
||||
|
||||
/** Routes of W2's search, relative to /app (inside the shell). */
|
||||
export const searchRoutes: RouteObject[] = [{ path: 'search', ...lazyPage(() => import('./SearchPage')) }];
|
||||
@@ -0,0 +1,9 @@
|
||||
import { PlaceholderPage } from '../../app/PlaceholderPage';
|
||||
import { paths } from '../../app/routes';
|
||||
import { useI18n } from '../../i18n/useI18n';
|
||||
|
||||
/** S-22 About, /app/settings/about. */
|
||||
export default function AboutPage() {
|
||||
const { t } = useI18n();
|
||||
return <PlaceholderPage title={t('settings_about_title')} back={paths.settings} />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { PlaceholderPage } from '../../app/PlaceholderPage';
|
||||
import { paths } from '../../app/routes';
|
||||
import { useI18n } from '../../i18n/useI18n';
|
||||
|
||||
/** S-15 Categories, /app/settings/categories. */
|
||||
export default function CategoriesPage() {
|
||||
const { t } = useI18n();
|
||||
return <PlaceholderPage title={t('settings_categories_title')} back={paths.settings} />;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { PlaceholderPage } from '../../app/PlaceholderPage';
|
||||
import { paths } from '../../app/routes';
|
||||
import { SignOutButton } from '../../app/SignOutButton';
|
||||
import { useI18n } from '../../i18n/useI18n';
|
||||
|
||||
/** S-20 Devices, /app/settings/devices. */
|
||||
export default function DevicesPage() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<PlaceholderPage title={t('settings_devices_title')} back={paths.settings}>
|
||||
<div>
|
||||
<SignOutButton />
|
||||
</div>
|
||||
</PlaceholderPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { PlaceholderPage } from '../../app/PlaceholderPage';
|
||||
import { paths } from '../../app/routes';
|
||||
import { useI18n } from '../../i18n/useI18n';
|
||||
|
||||
/** S-21 Invites, /app/settings/invite. */
|
||||
export default function InvitePage() {
|
||||
const { t } = useI18n();
|
||||
return <PlaceholderPage title={t('settings_invites_title_device')} back={paths.settings} />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { PlaceholderPage } from '../../app/PlaceholderPage';
|
||||
import { paths } from '../../app/routes';
|
||||
import { useI18n } from '../../i18n/useI18n';
|
||||
|
||||
/** S-14 Profile, /app/settings/profile. */
|
||||
export default function ProfilePage() {
|
||||
const { t } = useI18n();
|
||||
return <PlaceholderPage title={t('settings_profile')} back={paths.settings} />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { PlaceholderPage } from '../../app/PlaceholderPage';
|
||||
import { paths } from '../../app/routes';
|
||||
import { useI18n } from '../../i18n/useI18n';
|
||||
|
||||
/** S-19 Recurring expenses, /app/settings/recurring. */
|
||||
export default function RecurringPage() {
|
||||
const { t } = useI18n();
|
||||
return <PlaceholderPage title={t('settings_recurring_title')} back={paths.settings} />;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { PlaceholderPage } from '../../app/PlaceholderPage';
|
||||
import { PreferencesCard } from '../../app/PreferencesCard';
|
||||
import { SignOutButton } from '../../app/SignOutButton';
|
||||
import { useI18n } from '../../i18n/useI18n';
|
||||
|
||||
/** S-13 Settings hub, /app/settings. Keeps the web's preferences and sign-out when the hub is built. */
|
||||
export default function SettingsPage() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<PlaceholderPage title={t('settings_title')}>
|
||||
<PreferencesCard />
|
||||
<div>
|
||||
<SignOutButton />
|
||||
</div>
|
||||
</PlaceholderPage>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { RouteObject } from 'react-router';
|
||||
import { lazyPage } from '../../app/lazyPage';
|
||||
|
||||
/** Routes of W5's settings, relative to /app (inside the shell). */
|
||||
export const settingsRoutes: RouteObject[] = [
|
||||
{ path: 'settings', ...lazyPage(() => import('./SettingsPage')) },
|
||||
{ path: 'settings/categories', ...lazyPage(() => import('./CategoriesPage')) },
|
||||
{ path: 'settings/recurring', ...lazyPage(() => import('./RecurringPage')) },
|
||||
{ path: 'settings/devices', ...lazyPage(() => import('./DevicesPage')) },
|
||||
{ path: 'settings/profile', ...lazyPage(() => import('./ProfilePage')) },
|
||||
{ path: 'settings/invite', ...lazyPage(() => import('./InvitePage')) },
|
||||
{ path: 'settings/about', ...lazyPage(() => import('./AboutPage')) },
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
import { PlaceholderPage } from '../../app/PlaceholderPage';
|
||||
import { paths } from '../../app/routes';
|
||||
import { useI18n } from '../../i18n/useI18n';
|
||||
|
||||
/** S-9 Settle up, /app/settle. */
|
||||
export default function SettlePage() {
|
||||
const { t } = useI18n();
|
||||
return <PlaceholderPage title={t('settle_title')} back={paths.home} />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { PlaceholderPage } from '../../app/PlaceholderPage';
|
||||
import { paths } from '../../app/routes';
|
||||
import { useI18n } from '../../i18n/useI18n';
|
||||
|
||||
/** S-10 Settlement detail, /app/settlements/:id. */
|
||||
export default function SettlementDetailPage() {
|
||||
const { t } = useI18n();
|
||||
return <PlaceholderPage title={t('settle_detail_title')} back={paths.home} />;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { RouteObject } from 'react-router';
|
||||
import { lazyPage } from '../../app/lazyPage';
|
||||
|
||||
/** Routes of W3's settle up, relative to /app (inside the shell). */
|
||||
export const settleRoutes: RouteObject[] = [
|
||||
{ path: 'settle', ...lazyPage(() => import('./SettlePage')) },
|
||||
{ path: 'settlements/:id', ...lazyPage(() => import('./SettlementDetailPage')) },
|
||||
];
|
||||
Reference in New Issue
Block a user