W0 web foundation: Vite + React SPA under /app/, sync-pull ledger, auth, tokens, shared components
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:
2026-10-21 21:00:03 +02:00
parent 3fbd393082
commit 2ae65a5a9f
154 changed files with 15651 additions and 0 deletions
@@ -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)} />
);
}
+9
View File
@@ -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')} />;
}
+9
View File
@@ -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);
}
+120
View File
@@ -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();
});
});
+217
View File
@@ -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>
);
}
+5
View File
@@ -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')) }];
+34
View File
@@ -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)} />
);
}
+9
View File
@@ -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')) },
];
+8
View File
@@ -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')} />;
}
+5
View File
@@ -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')) }];
+8
View File
@@ -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')} />;
}
+5
View File
@@ -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')) }];
+9
View File
@@ -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} />;
}
+16
View File
@@ -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>
);
}
+9
View File
@@ -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>
);
}
+13
View File
@@ -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')) },
];
+9
View File
@@ -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} />;
}
+8
View File
@@ -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')) },
];