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

@ -18,13 +18,11 @@
font-weight: normal;
}
/* --------------------------------------------------------------------------
Design Tokens
-------------------------------------------------------------------------- */
:root {
/* --- Color: Primary --- */
--color-primary: #516356;
--color-on-primary: #e9fded;
@ -133,7 +131,9 @@
Reset & Base
-------------------------------------------------------------------------- */
*, *::before, *::after {
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
@ -313,6 +313,16 @@ body {
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 {
color: var(--color-outline-variant);
}

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">
import TopNav from '$lib/components/TopNav.svelte';
import type { LayoutProps } from './$types';
const { children } = $props();
const { data, children }: LayoutProps = $props();
</script>
<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()}
<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,13 +1,114 @@
<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">
<header>
<p class="form-eyebrow">Account</p>
<h1 class="form-title">Profile</h1>
</header>
<p class="coming-soon">Profile settings coming soon.</p>
{#if form?.success}
<div class="alert alert-success">Profile saved.</div>
{/if}
<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>
<style>
.page {
@ -16,12 +117,87 @@
padding: var(--space-8) var(--space-6);
display: flex;
flex-direction: column;
gap: var(--space-5);
gap: var(--space-6);
}
.coming-soon {
font-family: var(--font-body);
font-size: var(--text-body-md);
/* -----------------------------------------------------------------------
Success alert
----------------------------------------------------------------------- */
.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);
}
/* -----------------------------------------------------------------------
Action row
----------------------------------------------------------------------- */
.actions {
display: flex;
align-items: center;
gap: var(--space-4);
padding-top: var(--space-2);
}
</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';
export const load: ServerLoad = async ({ locals }) => {
@ -17,7 +17,7 @@ export const actions = {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const { response, data } = await loginAuthLoginPost({
const { response, data } = await loginApiAuthLoginPost({
headers: {
'Content-Type': 'application/json',
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>