Compare commits

..

No commits in common. "74c173b6ae4e0aaefb847a2a1c4f9f1f0c3a37b7" and "3c23c49912ae776a55c6d25d540a6dfed41f0490" have entirely different histories.

14 changed files with 255 additions and 1100 deletions

View file

@ -1,7 +0,0 @@
# Frontend Architecture
This is a web application built using Svelte Kit v5, running on the NodeJS adapter.
Follow the svelte kit conventions where possible, e.g. in placing routes, authentication, code.
Where possible, this application will use Progressive Web App technologies, to increase its offline performance.

View file

@ -18,11 +18,13 @@
font-weight: normal; font-weight: normal;
} }
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
Design Tokens Design Tokens
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
:root { :root {
/* --- Color: Primary --- */ /* --- Color: Primary --- */
--color-primary: #516356; --color-primary: #516356;
--color-on-primary: #e9fded; --color-on-primary: #e9fded;
@ -39,7 +41,7 @@
--color-surface: #faf9f5; /* base */ --color-surface: #faf9f5; /* base */
--color-surface-container: #eeede9; --color-surface-container: #eeede9;
--color-surface-container-high: #e8e8e3; --color-surface-container-high: #e8e8e3;
--color-surface-container-highest: #e2e1dd; --color-surface-container-highest:#e2e1dd;
--color-surface-dim: #d6dcd2; /* recessed utility */ --color-surface-dim: #d6dcd2; /* recessed utility */
/* --- Color: On-Surface --- */ /* --- Color: On-Surface --- */
@ -131,9 +133,7 @@
Reset & Base Reset & Base
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
*, *, *::before, *::after {
*::before,
*::after {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
padding: 0; padding: 0;
@ -224,7 +224,7 @@ body {
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;
@ -313,16 +313,6 @@ body {
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);
} }

View file

@ -1,36 +0,0 @@
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,31 +1,9 @@
<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 { data, children }: LayoutProps = $props(); const { children } = $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

@ -1,51 +0,0 @@
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

@ -1,199 +0,0 @@
<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

@ -1,59 +0,0 @@
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,113 +1,12 @@
<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>
{#if form?.success} <p class="coming-soon">Profile settings coming soon.</p>
<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> <a href="/logout" class="btn btn-ghost">Sign out</a>
</div>
</form>
</div> </div>
<style> <style>
@ -117,87 +16,12 @@
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-6); gap: var(--space-5);
} }
/* ----------------------------------------------------------------------- .coming-soon {
Success alert font-family: var(--font-body);
----------------------------------------------------------------------- */ 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 { loginApiAuthLoginPost } from '../../client/sdk.gen.ts'; import { loginAuthLoginPost } 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 loginApiAuthLoginPost({ const { response, data } = await loginAuthLoginPost({
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: locals.authToken ? `Bearer ${locals.authToken}` : '' Authorization: locals.authToken ? `Bearer ${locals.authToken}` : ''

View file

@ -1,28 +0,0 @@
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

@ -1,181 +0,0 @@
<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

@ -1,24 +0,0 @@
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

@ -1,52 +0,0 @@
<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>