feat: [frontend] Create the register and onboarding flows
Some checks failed
/ test (push) Has been cancelled

This commit is contained in:
wilson 2026-04-11 11:46:50 +01:00
parent 5532fb609b
commit 74c173b6ae
12 changed files with 1093 additions and 255 deletions

View file

@ -7,153 +7,153 @@
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
@font-face { @font-face {
font-family: archivo; /* set name */ font-family: archivo; /* set name */
src: url('/fonts/Archivo-Medium.woff2'); /* url of the font */ src: url('/fonts/Archivo-Medium.woff2'); /* url of the font */
font-weight: 500; font-weight: 500;
} }
@font-face { @font-face {
font-family: archivo; /* set name */ font-family: archivo; /* set name */
src: url('/fonts/Archivo-Regular.woff2'); /* url of the font */ src: url('/fonts/Archivo-Regular.woff2'); /* url of the font */
font-weight: normal; font-weight: normal;
} }
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
Design Tokens Design Tokens
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
:root { :root {
/* --- Color: Primary --- */
--color-primary: #516356;
--color-on-primary: #e9fded;
--color-primary-container: #c8d8c8;
--color-on-primary-container: #2f342e;
/* --- Color: Primary --- */ /* --- Color: Secondary --- */
--color-primary: #516356; --color-secondary-container: #dde4de;
--color-on-primary: #e9fded; --color-on-secondary-container: #2f342e;
--color-primary-container: #c8d8c8;
--color-on-primary-container: #2f342e;
/* --- Color: Secondary --- */ /* --- Color: Surface Hierarchy (light → dark = elevated → recessed) --- */
--color-secondary-container: #dde4de; --color-surface-container-lowest: #ffffff; /* most elevated */
--color-on-secondary-container: #2f342e; --color-surface-container-low: #f4f4ef;
--color-surface: #faf9f5; /* base */
--color-surface-container: #eeede9;
--color-surface-container-high: #e8e8e3;
--color-surface-container-highest: #e2e1dd;
--color-surface-dim: #d6dcd2; /* recessed utility */
/* --- Color: Surface Hierarchy (light → dark = elevated → recessed) --- */ /* --- Color: On-Surface --- */
--color-surface-container-lowest: #ffffff; /* most elevated */ --color-on-surface: #2f342e; /* replaces pure black */
--color-surface-container-low: #f4f4ef; --color-on-surface-variant: #5c605b;
--color-surface: #faf9f5; /* base */
--color-surface-container: #eeede9;
--color-surface-container-high: #e8e8e3;
--color-surface-container-highest:#e2e1dd;
--color-surface-dim: #d6dcd2; /* recessed utility */
/* --- Color: On-Surface --- */ /* --- Color: Outline --- */
--color-on-surface: #2f342e; /* replaces pure black */ --color-outline: #8c908b;
--color-on-surface-variant: #5c605b; --color-outline-variant: #afb3ac; /* use at 20% opacity as "ghost border" */
/* --- Color: Outline --- */ /* --- Typography: Font Families --- */
--color-outline: #8c908b; --font-display: 'archivo', sans-serif;
--color-outline-variant: #afb3ac; /* use at 20% opacity as "ghost border" */ --font-body: 'Newsreader', serif;
--font-label: 'Inter', sans-serif;
/* --- Typography: Font Families --- */ /* --- Typography: Scale --- */
--font-display: 'archivo', sans-serif; --text-display-lg: 3.5rem; /* article titles */
--font-body: 'Newsreader', serif; --text-display-md: 2.75rem;
--font-label: 'Inter', sans-serif; --text-display-sm: 2.25rem;
--text-headline-lg: 1.875rem;
--text-headline-md: 1.5rem;
--text-headline-sm: 1.25rem;
--text-title-lg: 1.125rem;
--text-title-md: 1rem;
--text-body-xl: 1.25rem; /* long-form reading standard */
--text-body-lg: 1rem;
--text-body-md: 0.9375rem;
--text-body-sm: 0.875rem;
--text-label-lg: 0.875rem;
--text-label-md: 0.75rem; /* metadata, all-caps */
--text-label-sm: 0.6875rem;
/* --- Typography: Scale --- */ /* --- Typography: Weights --- */
--text-display-lg: 3.5rem; /* article titles */ --weight-light: 300;
--text-display-md: 2.75rem; --weight-regular: 400;
--text-display-sm: 2.25rem; --weight-medium: 500;
--text-headline-lg: 1.875rem; --weight-semibold: 600;
--text-headline-md: 1.5rem; --weight-bold: 700;
--text-headline-sm: 1.25rem;
--text-title-lg: 1.125rem;
--text-title-md: 1rem;
--text-body-xl: 1.25rem; /* long-form reading standard */
--text-body-lg: 1rem;
--text-body-md: 0.9375rem;
--text-body-sm: 0.875rem;
--text-label-lg: 0.875rem;
--text-label-md: 0.75rem; /* metadata, all-caps */
--text-label-sm: 0.6875rem;
/* --- Typography: Weights --- */ /* --- Typography: Line Height --- */
--weight-light: 300; --leading-tight: 1.2;
--weight-regular: 400; --leading-snug: 1.375;
--weight-medium: 500; --leading-normal: 1.5;
--weight-semibold: 600; --leading-relaxed: 1.6; /* "Digital Paper" body text minimum */
--weight-bold: 700; --leading-loose: 1.8;
/* --- Typography: Line Height --- */ /* --- Typography: Letter Spacing --- */
--leading-tight: 1.2; --tracking-tight: -0.025em;
--leading-snug: 1.375; --tracking-normal: 0em;
--leading-normal: 1.5; --tracking-wide: 0.05rem; /* label-md metadata */
--leading-relaxed: 1.6; /* "Digital Paper" body text minimum */ --tracking-wider: 0.08rem;
--leading-loose: 1.8;
/* --- Typography: Letter Spacing --- */ /* --- Spacing Scale (base-4) --- */
--tracking-tight: -0.025em; --space-1: 0.25rem; /* 4px */
--tracking-normal: 0em; --space-2: 0.5rem; /* 8px */
--tracking-wide: 0.05rem; /* label-md metadata */ --space-3: 1rem; /* 16px — list item separation */
--tracking-wider: 0.08rem; --space-4: 1.4rem; /* ~22px — list item group separation */
--space-5: 1.75rem; /* 28px */
--space-6: 2rem; /* 32px */
--space-8: 3rem; /* 48px */
--space-10: 4rem; /* 64px */
--space-12: 4.5rem; /* 72px */
--space-16: 5.5rem; /* 88px — top-of-page breathability */
/* --- Spacing Scale (base-4) --- */ /* --- Border Radius --- */
--space-1: 0.25rem; /* 4px */ --radius-xs: 0.125rem;
--space-2: 0.5rem; /* 8px */ --radius-sm: 0.25rem;
--space-3: 1rem; /* 16px — list item separation */ --radius-md: 0.375rem; /* primary button */
--space-4: 1.4rem; /* ~22px — list item group separation */ --radius-lg: 0.75rem;
--space-5: 1.75rem; /* 28px */ --radius-xl: 1.25rem;
--space-6: 2rem; /* 32px */ --radius-full: 9999px;
--space-8: 3rem; /* 48px */
--space-10: 4rem; /* 64px */
--space-12: 4.5rem; /* 72px */
--space-16: 5.5rem; /* 88px — top-of-page breathability */
/* --- Border Radius --- */ /* --- Elevation: Ambient "Tonal Shadow" --- */
--radius-xs: 0.125rem; --shadow-tonal-sm: 0 4px 16px 0 color-mix(in srgb, var(--color-on-surface) 5%, transparent);
--radius-sm: 0.25rem; --shadow-tonal-md: 0 8px 32px 0 color-mix(in srgb, var(--color-on-surface) 5%, transparent);
--radius-md: 0.375rem; /* primary button */
--radius-lg: 0.75rem;
--radius-xl: 1.25rem;
--radius-full: 9999px;
/* --- Elevation: Ambient "Tonal Shadow" --- */ /* --- Motion --- */
--shadow-tonal-sm: 0 4px 16px 0 color-mix(in srgb, var(--color-on-surface) 5%, transparent); --duration-fast: 100ms;
--shadow-tonal-md: 0 8px 32px 0 color-mix(in srgb, var(--color-on-surface) 5%, transparent); --duration-normal: 200ms;
--duration-slow: 400ms;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
/* --- Motion --- */ /* --- Glass / Frosted Effect --- */
--duration-fast: 100ms; --glass-bg: color-mix(in srgb, var(--color-surface) 80%, transparent);
--duration-normal: 200ms; --glass-blur: 24px;
--duration-slow: 400ms;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
/* --- Glass / Frosted Effect --- */
--glass-bg: color-mix(in srgb, var(--color-surface) 80%, transparent);
--glass-blur: 24px;
} }
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
Reset & Base Reset & Base
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
*, *::before, *::after { *,
box-sizing: border-box; *::before,
margin: 0; *::after {
padding: 0; box-sizing: border-box;
margin: 0;
padding: 0;
} }
html { html {
font-size: 16px; font-size: 16px;
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
scroll-behavior: smooth; scroll-behavior: smooth;
} }
body { body {
font-family: var(--font-body); font-family: var(--font-body);
font-size: var(--text-body-lg); font-size: var(--text-body-lg);
font-weight: var(--weight-regular); font-weight: var(--weight-regular);
line-height: var(--leading-relaxed); line-height: var(--leading-relaxed);
color: var(--color-on-surface); color: var(--color-on-surface);
background-color: var(--color-surface); background-color: var(--color-surface);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
@ -161,37 +161,37 @@ body {
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
.display-lg { .display-lg {
font-family: var(--font-display); font-family: var(--font-display);
font-size: var(--text-display-lg); font-size: var(--text-display-lg);
font-weight: var(--weight-bold); font-weight: var(--weight-bold);
line-height: var(--leading-tight); line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight); letter-spacing: var(--tracking-tight);
} }
.headline-md { .headline-md {
font-family: var(--font-display); font-family: var(--font-display);
font-size: var(--text-headline-md); font-size: var(--text-headline-md);
font-weight: var(--weight-semibold); font-weight: var(--weight-semibold);
line-height: var(--leading-snug); line-height: var(--leading-snug);
} }
.label-md { .label-md {
font-family: var(--font-label); font-family: var(--font-label);
font-size: var(--text-label-md); font-size: var(--text-label-md);
font-weight: var(--weight-medium); font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
text-transform: uppercase; text-transform: uppercase;
} }
.link { .link {
color: var(--color-primary); color: var(--color-primary);
text-decoration: none; text-decoration: none;
font-weight: var(--weight-medium); font-weight: var(--weight-medium);
transition: opacity var(--duration-fast) var(--ease-standard); transition: opacity var(--duration-fast) var(--ease-standard);
} }
.link:hover { .link:hover {
opacity: 0.7; opacity: 0.7;
} }
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
@ -199,41 +199,41 @@ body {
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
.form { .form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-4); gap: var(--space-4);
} }
.form-header { .form-header {
margin-bottom: var(--space-6); margin-bottom: var(--space-6);
} }
.form-eyebrow { .form-eyebrow {
font-family: var(--font-label); font-family: var(--font-label);
font-size: var(--text-label-md); font-size: var(--text-label-md);
font-weight: var(--weight-medium); font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
text-transform: uppercase; text-transform: uppercase;
color: var(--color-on-surface-variant); color: var(--color-on-surface-variant);
margin-bottom: var(--space-1); margin-bottom: var(--space-1);
} }
.form-title { .form-title {
font-family: var(--font-display); font-family: var(--font-display);
font-size: var(--text-headline-lg); font-size: var(--text-headline-lg);
font-weight: var(--weight-semibold); font-weight: var(--weight-semibold);
line-height: var(--leading-snug); line-height: var(--leading-snug);
color: var(--color-on-surface); color: var(--color-on-surface);
} }
.form-footer { .form-footer {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
margin-top: var(--space-6); margin-top: var(--space-6);
font-family: var(--font-label); font-family: var(--font-label);
font-size: var(--text-body-sm); font-size: var(--text-body-sm);
color: var(--color-on-surface-variant); color: var(--color-on-surface-variant);
} }
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
@ -241,42 +241,42 @@ body {
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
.btn { .btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: var(--space-2); gap: var(--space-2);
padding: var(--space-2) var(--space-4); padding: var(--space-2) var(--space-4);
border: none; border: none;
border-radius: var(--radius-md); border-radius: var(--radius-md);
font-family: var(--font-display); font-family: var(--font-display);
font-size: var(--text-label-lg); font-size: var(--text-label-lg);
font-weight: var(--weight-medium); font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
transition: transition:
opacity var(--duration-normal) var(--ease-standard), opacity var(--duration-normal) var(--ease-standard),
background-color var(--duration-normal) var(--ease-standard); background-color var(--duration-normal) var(--ease-standard);
} }
.btn-primary { .btn-primary {
background-color: var(--color-primary); background-color: var(--color-primary);
color: var(--color-on-primary); color: var(--color-on-primary);
} }
.btn-primary:hover { .btn-primary:hover {
opacity: 0.88; opacity: 0.88;
} }
.btn-secondary { .btn-secondary {
background-color: var(--color-secondary-container); background-color: var(--color-secondary-container);
color: var(--color-on-secondary-container); color: var(--color-on-secondary-container);
} }
.btn-ghost { .btn-ghost {
background: none; background: none;
color: var(--color-primary); color: var(--color-primary);
padding-inline: var(--space-2); padding-inline: var(--space-2);
} }
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
@ -284,97 +284,107 @@ body {
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
.field { .field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-1); gap: var(--space-1);
} }
.field-label { .field-label {
font-family: var(--font-label); font-family: var(--font-label);
font-size: var(--text-label-md); font-size: var(--text-label-md);
font-weight: var(--weight-medium); font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
text-transform: uppercase; text-transform: uppercase;
color: var(--color-on-surface-variant); color: var(--color-on-surface-variant);
} }
.field-input { .field-input {
background-color: var(--color-surface-container-high); background-color: var(--color-surface-container-high);
color: var(--color-on-surface); color: var(--color-on-surface);
border: none; border: none;
border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent); border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent);
border-radius: var(--radius-sm) var(--radius-sm) 0 0; border-radius: var(--radius-sm) var(--radius-sm) 0 0;
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
font-family: var(--font-body); font-family: var(--font-body);
font-size: var(--text-body-lg); font-size: var(--text-body-lg);
line-height: var(--leading-normal); line-height: var(--leading-normal);
outline: none; outline: none;
width: 100%; width: 100%;
transition: border-color var(--duration-fast) var(--ease-standard); transition: border-color var(--duration-fast) var(--ease-standard);
}
.field-disabled {
font-family: var(--font-body);
font-size: var(--text-body-lg);
color: var(--color-on-surface-variant);
padding: var(--space-2) var(--space-3);
background-color: var(--color-surface-container);
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent);
} }
.field-input::placeholder { .field-input::placeholder {
color: var(--color-outline-variant); color: var(--color-outline-variant);
} }
.field-input:focus { .field-input:focus {
border-bottom: 2px solid var(--color-primary); border-bottom: 2px solid var(--color-primary);
} }
.field-select { .field-select {
background-color: var(--color-surface-container-high); background-color: var(--color-surface-container-high);
color: var(--color-on-surface); color: var(--color-on-surface);
border: none; border: none;
border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent); border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent);
border-radius: var(--radius-sm) var(--radius-sm) 0 0; border-radius: var(--radius-sm) var(--radius-sm) 0 0;
padding: var(--space-2) var(--space-8) var(--space-2) var(--space-3); padding: var(--space-2) var(--space-8) var(--space-2) var(--space-3);
font-family: var(--font-body); font-family: var(--font-body);
font-size: var(--text-body-lg); font-size: var(--text-body-lg);
line-height: var(--leading-normal); line-height: var(--leading-normal);
outline: none; outline: none;
width: 100%; width: 100%;
cursor: pointer; cursor: pointer;
appearance: none; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%235c605b' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%235c605b' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: right var(--space-3) center; background-position: right var(--space-3) center;
transition: border-color var(--duration-fast) var(--ease-standard); transition: border-color var(--duration-fast) var(--ease-standard);
} }
.field-select:focus { .field-select:focus {
border-bottom: 2px solid var(--color-primary); border-bottom: 2px solid var(--color-primary);
} }
.field-textarea { .field-textarea {
background-color: var(--color-surface-container-high); background-color: var(--color-surface-container-high);
color: var(--color-on-surface); color: var(--color-on-surface);
border: none; border: none;
border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent); border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent);
border-radius: var(--radius-sm) var(--radius-sm) 0 0; border-radius: var(--radius-sm) var(--radius-sm) 0 0;
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
font-family: var(--font-body); font-family: var(--font-body);
font-size: var(--text-body-lg); font-size: var(--text-body-lg);
line-height: var(--leading-relaxed); line-height: var(--leading-relaxed);
outline: none; outline: none;
width: 100%; width: 100%;
min-height: 14rem; min-height: 14rem;
resize: vertical; resize: vertical;
transition: border-color var(--duration-fast) var(--ease-standard); transition: border-color var(--duration-fast) var(--ease-standard);
} }
.field-textarea::placeholder { .field-textarea::placeholder {
color: var(--color-outline-variant); color: var(--color-outline-variant);
} }
.field-textarea:focus { .field-textarea:focus {
border-bottom: 2px solid var(--color-primary); border-bottom: 2px solid var(--color-primary);
} }
.field-hint { .field-hint {
font-family: var(--font-label); font-family: var(--font-label);
font-size: var(--text-label-md); font-size: var(--text-label-md);
color: var(--color-on-surface-variant); color: var(--color-on-surface-variant);
margin-top: var(--space-1); margin-top: var(--space-1);
} }
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
@ -382,14 +392,14 @@ body {
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
.alert { .alert {
padding: var(--space-3); padding: var(--space-3);
border-radius: var(--radius-md); border-radius: var(--radius-md);
font-family: var(--font-label); font-family: var(--font-label);
font-size: var(--text-body-sm); font-size: var(--text-body-sm);
} }
.alert-error { .alert-error {
background-color: color-mix(in srgb, #b3261e 10%, var(--color-surface)); background-color: color-mix(in srgb, #b3261e 10%, var(--color-surface));
color: #b3261e; color: #b3261e;
border-left: 3px solid #b3261e; border-left: 3px solid #b3261e;
} }

View file

@ -0,0 +1,36 @@
import { redirect, type ServerLoad } from '@sveltejs/kit';
import { getAccountStatusApiAccountStatusGet } from '../../client/sdk.gen.ts';
import type { AccountStatusResponse } from '../../client/types.gen.ts';
const PROBLEM_FLAGS_COOKIE_NAME = 'account_problem_flags';
export const load: ServerLoad = async ({ locals, url, cookies }) => {
const cachedFlags = cookies.get(PROBLEM_FLAGS_COOKIE_NAME);
let problemFlags: string[] = [];
if (cachedFlags === undefined) {
const { data } = await getAccountStatusApiAccountStatusGet({
headers: { Authorization: `Bearer ${locals.authToken}` }
});
const flags = (data as AccountStatusResponse)?.problem_flags ?? [];
// 15-minute cookie-based cache
cookies.set(PROBLEM_FLAGS_COOKIE_NAME, JSON.stringify(flags), {
path: '/',
maxAge: 60 * 15
});
problemFlags = flags;
}
// Redirect to onboarding if not yet completed — guard against redirect loop
if (problemFlags.includes('no_onboarding') && !url.pathname.startsWith('/app/onboarding')) {
redirect(307, '/app/onboarding');
}
// Don't hard-block on unverified email yet (login gate is not active),
// but surface the flag so the layout can show a warning banner.
return {
emailUnverified: problemFlags.includes('unvalidated_email')
};
};

View file

@ -1,9 +1,31 @@
<script lang="ts"> <script lang="ts">
import TopNav from '$lib/components/TopNav.svelte'; import TopNav from '$lib/components/TopNav.svelte';
import type { LayoutProps } from './$types';
const { children } = $props(); const { data, children }: LayoutProps = $props();
</script> </script>
<TopNav /> <TopNav />
{#if data.emailUnverified}
<div class="email-warning" role="alert">
Your email address is not yet verified. Check your inbox for a verification link.
</div>
{/if}
{@render children()} {@render children()}
<style>
.email-warning {
background-color: var(--color-surface-container-high);
color: var(--color-primary);
font-family: var(--font-label);
font-size: var(--text-label-md);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
text-align: center;
padding: var(--space-2) var(--space-5);
width: 100%;
}
</style>

View file

@ -0,0 +1,51 @@
import { redirect, type Actions, type ServerLoad } from '@sveltejs/kit';
import {
getOnboardingBffAccountOnboardingGet,
completeOnboardingApiAccountOnboardingPost
} from '../../../client/sdk.gen.ts';
import type { OnboardingResponse } from '../../../client/types.gen.ts';
export const load: ServerLoad = async ({ locals }) => {
const { data } = await getOnboardingBffAccountOnboardingGet({
headers: { Authorization: `Bearer ${locals.authToken}` }
});
const onboarding = data as OnboardingResponse;
return {
languagePairs: onboarding.language_pairs,
proficiencies: onboarding.proficiencies
};
};
export const actions = {
default: async ({ request, locals }) => {
const data = await request.formData();
const humanName = data.get('human_name') as string;
const languagePair = data.get('language_pair') as string;
const proficiencies = data.getAll('proficiency') as string[];
if (!humanName?.trim()) return { error: 'Please enter your name.' };
if (!languagePair) return { error: 'Please select a language to learn.' };
if (!proficiencies.length) return { error: 'Please select at least one proficiency level.' };
if (proficiencies.length > 2) return { error: 'Select a maximum of two proficiency levels.' };
const { response } = await completeOnboardingApiAccountOnboardingPost({
headers: {
Authorization: `Bearer ${locals.authToken}`,
'Content-Type': 'application/json'
},
body: {
human_name: humanName.trim(),
language_pairs: [languagePair],
proficiencies: [proficiencies]
}
});
if (response.status === 200) {
redirect(303, '/app');
}
return { error: 'Something went wrong. Please try again.' };
}
} satisfies Actions;

View file

@ -0,0 +1,199 @@
<script lang="ts">
import type { PageProps } from './$types';
import type { LanguagePairOption } from '../../../client/types.gen.ts';
const { data, form }: PageProps = $props();
let selectedPairValue = $state('');
let selectedProficiencies = $state<string[]>([]);
const selectedPair = $derived(
data.languagePairs.find((p: LanguagePairOption) => p.value === selectedPairValue) ?? null
);
</script>
<div class="page">
<div class="container">
<header class="header">
<p class="eyebrow">Getting started</p>
<h1 class="heading">Let's get you set up</h1>
<p class="subheading">Tell us a bit about yourself and what you'd like to learn.</p>
</header>
<form method="POST" class="form">
<!-- Name -->
<div class="field">
<label for="human_name" class="field-label">Your name</label>
<input
id="human_name"
name="human_name"
class="field-input"
type="text"
placeholder="Alice"
required
/>
</div>
<!-- Language pair -->
<div class="field">
<label for="language_pair" class="field-label">I want to learn</label>
<select
id="language_pair"
name="language_pair"
class="field-select"
required
bind:value={selectedPairValue}
>
<option value="" disabled selected>Select a language…</option>
{#each data.languagePairs as pair}
<option value={pair.value}>{pair.label}</option>
{/each}
</select>
{#if selectedPair}
<p class="field-hint">{selectedPair.description}</p>
{/if}
</div>
<!-- Proficiency -->
<fieldset class="field">
<legend class="field-label">My current level</legend>
<p class="field-hint">Select one or two levels that describe where you are now.</p>
<div class="proficiency-list">
{#each data.proficiencies as level}
{@const isChecked = selectedProficiencies.includes(level.value)}
{@const isDisabled = selectedProficiencies.length >= 2 && !isChecked}
<label class="proficiency-option" class:disabled={isDisabled}>
<input
type="checkbox"
name="proficiency"
value={level.value}
disabled={isDisabled}
onchange={(e) => {
if (e.currentTarget.checked) {
selectedProficiencies = [...selectedProficiencies, level.value];
} else {
selectedProficiencies = selectedProficiencies.filter((p) => p !== level.value);
}
}}
/>
<span class="proficiency-label">{level.label}</span>
<span class="proficiency-description">{level.description}</span>
</label>
{/each}
</div>
</fieldset>
{#if form?.error}
<div class="alert alert-error">{form.error}</div>
{/if}
<button type="submit" class="btn btn-primary">Start learning →</button>
</form>
</div>
</div>
<style>
.page {
display: flex;
justify-content: center;
padding: var(--space-10) var(--space-5);
min-height: 100svh;
background-color: var(--color-surface);
}
.container {
width: 100%;
max-width: 32rem;
}
/* -----------------------------------------------------------------------
Header
----------------------------------------------------------------------- */
.header {
margin-bottom: var(--space-8);
}
.eyebrow {
font-family: var(--font-label);
font-size: var(--text-label-md);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--color-primary);
margin-bottom: var(--space-2);
}
.heading {
font-family: var(--font-display);
font-size: var(--text-display-md);
font-weight: var(--weight-bold);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
color: var(--color-on-surface);
margin-bottom: var(--space-3);
}
.subheading {
font-family: var(--font-body);
font-size: var(--text-body-lg);
color: var(--color-on-surface-variant);
line-height: var(--leading-relaxed);
}
/* -----------------------------------------------------------------------
Proficiency checkboxes
----------------------------------------------------------------------- */
.proficiency-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-top: var(--space-3);
}
.proficiency-option {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
column-gap: var(--space-3);
row-gap: var(--space-1);
align-items: start;
padding: var(--space-3);
background-color: var(--color-surface-container-low);
border-radius: var(--radius-md);
cursor: pointer;
transition: background-color var(--duration-fast) var(--ease-standard);
}
.proficiency-option:hover:not(.disabled) {
background-color: var(--color-surface-container);
}
.proficiency-option.disabled {
opacity: 0.45;
cursor: not-allowed;
}
.proficiency-option input[type='checkbox'] {
grid-row: 1 / 3;
margin-top: 0.15rem;
accent-color: var(--color-primary);
width: 1rem;
height: 1rem;
cursor: inherit;
}
.proficiency-label {
font-family: var(--font-label);
font-size: var(--text-body-sm);
font-weight: var(--weight-semibold);
color: var(--color-on-surface);
}
.proficiency-description {
font-family: var(--font-label);
font-size: var(--text-label-md);
color: var(--color-on-surface-variant);
}
</style>

View file

@ -0,0 +1,59 @@
import { type Actions, type ServerLoad } from '@sveltejs/kit';
import {
getAccountBffAccountGet,
getOnboardingBffAccountOnboardingGet,
completeOnboardingApiAccountOnboardingPost
} from '../../../client/sdk.gen.ts';
import type { AccountResponse, OnboardingResponse } from '../../../client/types.gen.ts';
export const load: ServerLoad = async ({ locals }) => {
const auth = { headers: { Authorization: `Bearer ${locals.authToken}` } };
const [accountResult, onboardingResult] = await Promise.all([
getAccountBffAccountGet(auth),
getOnboardingBffAccountOnboardingGet(auth)
]);
const account = accountResult.data as AccountResponse;
const onboarding = onboardingResult.data as OnboardingResponse;
return {
account,
languagePairs: onboarding.language_pairs,
proficiencies: onboarding.proficiencies
};
};
export const actions = {
default: async ({ request, locals }) => {
const formData = await request.formData();
const humanName = formData.get('human_name') as string;
const languagePair = formData.get('language_pair') as string;
const proficiencies = formData.getAll('proficiency') as string[];
if (!humanName?.trim()) return { success: false, error: 'Please enter your name.' };
if (!languagePair) return { success: false, error: 'Please select a language to learn.' };
if (!proficiencies.length)
return { success: false, error: 'Please select at least one proficiency level.' };
if (proficiencies.length > 2)
return { success: false, error: 'Select a maximum of two proficiency levels.' };
const { response } = await completeOnboardingApiAccountOnboardingPost({
headers: {
Authorization: `Bearer ${locals.authToken}`,
'Content-Type': 'application/json'
},
body: {
human_name: humanName.trim(),
language_pairs: [languagePair],
proficiencies: [proficiencies]
}
});
if (response.status === 200) {
return { success: true };
}
return { success: false, error: 'Something went wrong. Please try again.' };
}
} satisfies Actions;

View file

@ -1,12 +1,113 @@
<script lang="ts">
import type { PageProps } from './$types';
import type { LanguagePairOption } from '../../../client/types.gen.ts';
const { data, form }: PageProps = $props();
// Pre-populate from the first language pair on the account (if any)
const currentPair = data.account.language_pairs[0];
const currentPairValue = currentPair
? `${currentPair.source_language},${currentPair.target_language}`
: '';
let selectedPairValue = $state(currentPairValue);
let selectedProficiencies = $state<string[]>(currentPair?.proficiencies ?? []);
const selectedPair = $derived(
data.languagePairs.find((p: LanguagePairOption) => p.value === selectedPairValue) ?? null
);
</script>
<div class="page"> <div class="page">
<header> <header>
<p class="form-eyebrow">Account</p> <p class="form-eyebrow">Account</p>
<h1 class="form-title">Profile</h1> <h1 class="form-title">Profile</h1>
</header> </header>
<p class="coming-soon">Profile settings coming soon.</p> {#if form?.success}
<div class="alert alert-success">Profile saved.</div>
{/if}
<a href="/logout" class="btn btn-ghost">Sign out</a> <form method="POST" class="form">
<!-- Email — read-only -->
<div class="field">
<p class="field-label">Email</p>
<p class="field-disabled">{data.account.email}</p>
</div>
<!-- Name -->
<div class="field">
<label for="human_name" class="field-label">Name</label>
<input
id="human_name"
name="human_name"
class="field-input"
type="text"
placeholder="Alice"
value={data.account.human_name ?? ''}
required
/>
</div>
<!-- Language pair -->
<div class="field">
<label for="language_pair" class="field-label">I am learning</label>
<select
id="language_pair"
name="language_pair"
class="field-select"
required
bind:value={selectedPairValue}
>
<option value="" disabled>Select a language…</option>
{#each data.languagePairs as pair}
<option value={pair.value}>{pair.label}</option>
{/each}
</select>
{#if selectedPair}
<p class="field-hint">{selectedPair.description}</p>
{/if}
</div>
<!-- Proficiency -->
<fieldset class="field">
<legend class="field-label">My current level</legend>
<p class="field-hint">Select one or two levels that describe where you are now.</p>
<div class="proficiency-list">
{#each data.proficiencies as level}
{@const isChecked = selectedProficiencies.includes(level.value)}
{@const isDisabled = selectedProficiencies.length >= 2 && !isChecked}
<label class="proficiency-option" class:disabled={isDisabled}>
<input
type="checkbox"
name="proficiency"
value={level.value}
checked={isChecked}
disabled={isDisabled}
onchange={(e) => {
if (e.currentTarget.checked) {
selectedProficiencies = [...selectedProficiencies, level.value];
} else {
selectedProficiencies = selectedProficiencies.filter((p) => p !== level.value);
}
}}
/>
<span class="proficiency-label">{level.label}</span>
<span class="proficiency-description">{level.description}</span>
</label>
{/each}
</div>
</fieldset>
{#if form?.error}
<div class="alert alert-error">{form.error}</div>
{/if}
<div class="actions">
<button type="submit" class="btn btn-primary">Save changes</button>
<a href="/logout" class="btn btn-ghost">Sign out</a>
</div>
</form>
</div> </div>
<style> <style>
@ -16,12 +117,87 @@
padding: var(--space-8) var(--space-6); padding: var(--space-8) var(--space-6);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-5); gap: var(--space-6);
} }
.coming-soon { /* -----------------------------------------------------------------------
font-family: var(--font-body); Success alert
font-size: var(--text-body-md); ----------------------------------------------------------------------- */
.alert-success {
padding: var(--space-3);
border-radius: var(--radius-md);
font-family: var(--font-label);
font-size: var(--text-body-sm);
background-color: color-mix(in srgb, var(--color-primary) 10%, var(--color-surface));
color: var(--color-primary);
border-left: 3px solid var(--color-primary);
}
/* -----------------------------------------------------------------------
Proficiency checkboxes
----------------------------------------------------------------------- */
.proficiency-list {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-top: var(--space-3);
}
.proficiency-option {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
column-gap: var(--space-3);
row-gap: var(--space-1);
align-items: start;
padding: var(--space-3);
background-color: var(--color-surface-container-low);
border-radius: var(--radius-md);
cursor: pointer;
transition: background-color var(--duration-fast) var(--ease-standard);
}
.proficiency-option:hover:not(.disabled) {
background-color: var(--color-surface-container);
}
.proficiency-option.disabled {
opacity: 0.45;
cursor: not-allowed;
}
.proficiency-option input[type='checkbox'] {
grid-row: 1 / 3;
margin-top: 0.15rem;
accent-color: var(--color-primary);
width: 1rem;
height: 1rem;
cursor: inherit;
}
.proficiency-label {
font-family: var(--font-label);
font-size: var(--text-body-sm);
font-weight: var(--weight-semibold);
color: var(--color-on-surface);
}
.proficiency-description {
font-family: var(--font-label);
font-size: var(--text-label-md);
color: var(--color-on-surface-variant); color: var(--color-on-surface-variant);
} }
/* -----------------------------------------------------------------------
Action row
----------------------------------------------------------------------- */
.actions {
display: flex;
align-items: center;
gap: var(--space-4);
padding-top: var(--space-2);
}
</style> </style>

View file

@ -1,4 +1,4 @@
import { loginAuthLoginPost } from '../../client/sdk.gen.ts'; import { loginApiAuthLoginPost } from '../../client/sdk.gen.ts';
import { redirect, type Actions, type ServerLoad } from '@sveltejs/kit'; import { redirect, type Actions, type ServerLoad } from '@sveltejs/kit';
export const load: ServerLoad = async ({ locals }) => { export const load: ServerLoad = async ({ locals }) => {
@ -17,7 +17,7 @@ export const actions = {
const email = formData.get('email') as string; const email = formData.get('email') as string;
const password = formData.get('password') as string; const password = formData.get('password') as string;
const { response, data } = await loginAuthLoginPost({ const { response, data } = await loginApiAuthLoginPost({
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: locals.authToken ? `Bearer ${locals.authToken}` : '' Authorization: locals.authToken ? `Bearer ${locals.authToken}` : ''

View file

@ -0,0 +1,28 @@
import { redirect, type Actions, type ServerLoad } from '@sveltejs/kit';
import { registerApiAuthRegisterPost } from '../../client/sdk.gen.ts';
export const load: ServerLoad = async ({ locals }) => {
if (locals.authToken) redirect(307, '/app');
return {};
};
export const actions = {
default: async ({ request }) => {
const data = await request.formData();
const email = data.get('email') as string;
const password = data.get('password') as string;
const { response, data: body } = await registerApiAuthRegisterPost({
body: { email, password }
});
if (response.status === 201 && body?.success) {
return { success: true };
}
return {
success: false,
error: body?.error_message ?? 'Registration failed. Please try again.'
};
}
} satisfies Actions;

View file

@ -0,0 +1,181 @@
<script lang="ts">
import type { PageProps } from './$types';
const { form }: PageProps = $props();
</script>
<div class="page">
<aside class="brand-panel" aria-hidden="true">
<div class="brand-content">
<p class="brand-eyebrow">Language Learning App</p>
<h1 class="brand-headline">Read.<br />Listen.<br />Remember.</h1>
</div>
</aside>
<main class="form-panel">
<div class="form-inner">
<header class="form-header">
<p class="form-eyebrow">Language Learning App</p>
<h2 class="form-title">Create an account</h2>
</header>
{#if form?.success}
<div class="success-state">
<p class="success-message">Account created. Check your inbox for a verification link.</p>
<p class="success-hint">In development, the link is printed to the API server logs.</p>
<a href="/login" class="btn btn-primary">Sign in</a>
</div>
{:else}
<form method="POST" class="form">
{#if form?.error}
<div class="alert alert-error">{form.error}</div>
{/if}
<div class="field">
<label for="email" class="field-label">Email</label>
<input
id="email"
name="email"
class="field-input"
type="email"
placeholder="you@example.com"
autocomplete="email"
required
/>
</div>
<div class="field">
<label for="password" class="field-label">Password</label>
<input
id="password"
name="password"
class="field-input"
type="password"
placeholder="••••••••"
autocomplete="new-password"
required
/>
<p class="field-hint">Minimum 8 characters</p>
</div>
<div class="field">
<button type="submit" class="btn btn-primary">Create account</button>
</div>
</form>
{/if}
<footer class="form-footer">
<span>Already have an account?</span>
<a href="/login" class="link">Sign in</a>
</footer>
</div>
</main>
</div>
<style>
/* -----------------------------------------------------------------------
Page layout: two-panel split
----------------------------------------------------------------------- */
.page {
display: grid;
grid-template-columns: 1fr 1fr;
min-height: 100svh;
}
/* -----------------------------------------------------------------------
Left: brand panel
----------------------------------------------------------------------- */
.brand-panel {
display: flex;
align-items: flex-end;
padding: var(--space-8);
background: radial-gradient(
ellipse at 30% 60%,
var(--color-surface-container-low) 0%,
var(--color-surface) 70%
);
border-right: none;
}
.brand-content {
max-width: 28rem;
}
.brand-eyebrow {
font-family: var(--font-label);
font-size: var(--text-label-md);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--color-primary);
margin-bottom: var(--space-3);
}
.brand-headline {
font-family: var(--font-display);
font-size: var(--text-display-lg);
font-weight: var(--weight-bold);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
color: var(--color-on-surface);
}
/* -----------------------------------------------------------------------
Right: form panel
----------------------------------------------------------------------- */
.form-panel {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-8) var(--space-6);
background-color: var(--color-surface-container-lowest);
}
.form-inner {
width: 100%;
max-width: 22rem;
}
/* -----------------------------------------------------------------------
Success state
----------------------------------------------------------------------- */
.success-state {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.success-message {
font-family: var(--font-body);
font-size: var(--text-body-lg);
color: var(--color-on-surface);
line-height: var(--leading-normal);
}
.success-hint {
font-family: var(--font-label);
font-size: var(--text-label-md);
color: var(--color-on-surface-variant);
}
/* -----------------------------------------------------------------------
Responsive: collapse to single column on mobile
----------------------------------------------------------------------- */
@media (max-width: 640px) {
.page {
grid-template-columns: 1fr;
}
.brand-panel {
display: none;
}
.form-panel {
padding: var(--space-10) var(--space-5);
}
}
</style>

View file

@ -0,0 +1,24 @@
import type { ServerLoad } from '@sveltejs/kit';
import { verifyEmailApiAuthVerifyEmailGet } from '../../client/sdk.gen.ts';
export const load: ServerLoad = async ({ url }) => {
const token = url.searchParams.get('token');
if (!token) {
return { success: false, error: 'Missing verification token.' };
}
const { response } = await verifyEmailApiAuthVerifyEmailGet({
query: { token }
});
if (response.status === 200) {
return { success: true };
}
// 400 from API means invalid, expired, or already used
return {
success: false,
error: 'This verification link is invalid or has already been used.'
};
};

View file

@ -0,0 +1,52 @@
<script lang="ts">
import type { PageProps } from './$types';
const { data }: PageProps = $props();
</script>
<div class="page">
<div class="card">
{#if data.success}
<h1 class="heading">Email verified.</h1>
<p class="body">You can now sign in.</p>
<a href="/login" class="btn btn-primary">Sign in →</a>
{:else}
<h1 class="heading">Verification failed.</h1>
<div class="alert alert-error">{data.error}</div>
<a href="/register" class="btn btn-ghost">Back to registration</a>
{/if}
</div>
</div>
<style>
.page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100svh;
padding: var(--space-8) var(--space-5);
background-color: var(--color-surface);
}
.card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-4);
width: 100%;
max-width: 24rem;
}
.heading {
font-family: var(--font-display);
font-size: var(--text-headline-lg);
font-weight: var(--weight-semibold);
line-height: var(--leading-snug);
color: var(--color-on-surface);
}
.body {
font-family: var(--font-body);
font-size: var(--text-body-lg);
color: var(--color-on-surface-variant);
}
</style>