diff --git a/frontend/src/app.css b/frontend/src/app.css index 3aa20f5..38214a2 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -7,153 +7,153 @@ -------------------------------------------------------------------------- */ @font-face { - font-family: archivo; /* set name */ - src: url('/fonts/Archivo-Medium.woff2'); /* url of the font */ - font-weight: 500; + font-family: archivo; /* set name */ + src: url('/fonts/Archivo-Medium.woff2'); /* url of the font */ + font-weight: 500; } @font-face { - font-family: archivo; /* set name */ - src: url('/fonts/Archivo-Regular.woff2'); /* url of the font */ - font-weight: normal; + font-family: archivo; /* set name */ + src: url('/fonts/Archivo-Regular.woff2'); /* url of the font */ + font-weight: normal; } - /* -------------------------------------------------------------------------- Design Tokens -------------------------------------------------------------------------- */ :root { + /* --- Color: Primary --- */ + --color-primary: #516356; + --color-on-primary: #e9fded; + --color-primary-container: #c8d8c8; + --color-on-primary-container: #2f342e; - /* --- Color: Primary --- */ - --color-primary: #516356; - --color-on-primary: #e9fded; - --color-primary-container: #c8d8c8; - --color-on-primary-container: #2f342e; + /* --- Color: Secondary --- */ + --color-secondary-container: #dde4de; + --color-on-secondary-container: #2f342e; - /* --- Color: Secondary --- */ - --color-secondary-container: #dde4de; - --color-on-secondary-container: #2f342e; + /* --- Color: Surface Hierarchy (light → dark = elevated → recessed) --- */ + --color-surface-container-lowest: #ffffff; /* most elevated */ + --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-surface-container-lowest: #ffffff; /* most elevated */ - --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: On-Surface --- */ + --color-on-surface: #2f342e; /* replaces pure black */ + --color-on-surface-variant: #5c605b; - /* --- Color: On-Surface --- */ - --color-on-surface: #2f342e; /* replaces pure black */ - --color-on-surface-variant: #5c605b; + /* --- Color: Outline --- */ + --color-outline: #8c908b; + --color-outline-variant: #afb3ac; /* use at 20% opacity as "ghost border" */ - /* --- Color: Outline --- */ - --color-outline: #8c908b; - --color-outline-variant: #afb3ac; /* use at 20% opacity as "ghost border" */ + /* --- Typography: Font Families --- */ + --font-display: 'archivo', sans-serif; + --font-body: 'Newsreader', serif; + --font-label: 'Inter', sans-serif; - /* --- Typography: Font Families --- */ - --font-display: 'archivo', sans-serif; - --font-body: 'Newsreader', serif; - --font-label: 'Inter', sans-serif; + /* --- Typography: Scale --- */ + --text-display-lg: 3.5rem; /* article titles */ + --text-display-md: 2.75rem; + --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 --- */ - --text-display-lg: 3.5rem; /* article titles */ - --text-display-md: 2.75rem; - --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: Weights --- */ + --weight-light: 300; + --weight-regular: 400; + --weight-medium: 500; + --weight-semibold: 600; + --weight-bold: 700; - /* --- Typography: Weights --- */ - --weight-light: 300; - --weight-regular: 400; - --weight-medium: 500; - --weight-semibold: 600; - --weight-bold: 700; + /* --- Typography: Line Height --- */ + --leading-tight: 1.2; + --leading-snug: 1.375; + --leading-normal: 1.5; + --leading-relaxed: 1.6; /* "Digital Paper" body text minimum */ + --leading-loose: 1.8; - /* --- Typography: Line Height --- */ - --leading-tight: 1.2; - --leading-snug: 1.375; - --leading-normal: 1.5; - --leading-relaxed: 1.6; /* "Digital Paper" body text minimum */ - --leading-loose: 1.8; + /* --- Typography: Letter Spacing --- */ + --tracking-tight: -0.025em; + --tracking-normal: 0em; + --tracking-wide: 0.05rem; /* label-md metadata */ + --tracking-wider: 0.08rem; - /* --- Typography: Letter Spacing --- */ - --tracking-tight: -0.025em; - --tracking-normal: 0em; - --tracking-wide: 0.05rem; /* label-md metadata */ - --tracking-wider: 0.08rem; + /* --- Spacing Scale (base-4) --- */ + --space-1: 0.25rem; /* 4px */ + --space-2: 0.5rem; /* 8px */ + --space-3: 1rem; /* 16px — list item separation */ + --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) --- */ - --space-1: 0.25rem; /* 4px */ - --space-2: 0.5rem; /* 8px */ - --space-3: 1rem; /* 16px — list item separation */ - --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 */ + /* --- Border Radius --- */ + --radius-xs: 0.125rem; + --radius-sm: 0.25rem; + --radius-md: 0.375rem; /* primary button */ + --radius-lg: 0.75rem; + --radius-xl: 1.25rem; + --radius-full: 9999px; - /* --- Border Radius --- */ - --radius-xs: 0.125rem; - --radius-sm: 0.25rem; - --radius-md: 0.375rem; /* primary button */ - --radius-lg: 0.75rem; - --radius-xl: 1.25rem; - --radius-full: 9999px; + /* --- Elevation: Ambient "Tonal Shadow" --- */ + --shadow-tonal-sm: 0 4px 16px 0 color-mix(in srgb, var(--color-on-surface) 5%, transparent); + --shadow-tonal-md: 0 8px 32px 0 color-mix(in srgb, var(--color-on-surface) 5%, transparent); - /* --- Elevation: Ambient "Tonal Shadow" --- */ - --shadow-tonal-sm: 0 4px 16px 0 color-mix(in srgb, var(--color-on-surface) 5%, transparent); - --shadow-tonal-md: 0 8px 32px 0 color-mix(in srgb, var(--color-on-surface) 5%, transparent); + /* --- Motion --- */ + --duration-fast: 100ms; + --duration-normal: 200ms; + --duration-slow: 400ms; + --ease-standard: cubic-bezier(0.2, 0, 0, 1); - /* --- Motion --- */ - --duration-fast: 100ms; - --duration-normal: 200ms; - --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; + /* --- Glass / Frosted Effect --- */ + --glass-bg: color-mix(in srgb, var(--color-surface) 80%, transparent); + --glass-blur: 24px; } /* -------------------------------------------------------------------------- Reset & Base -------------------------------------------------------------------------- */ -*, *::before, *::after { - box-sizing: border-box; - margin: 0; - padding: 0; +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; } html { - font-size: 16px; - -webkit-text-size-adjust: 100%; - scroll-behavior: smooth; + font-size: 16px; + -webkit-text-size-adjust: 100%; + scroll-behavior: smooth; } body { - font-family: var(--font-body); - font-size: var(--text-body-lg); - font-weight: var(--weight-regular); - line-height: var(--leading-relaxed); - color: var(--color-on-surface); - background-color: var(--color-surface); - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; + font-family: var(--font-body); + font-size: var(--text-body-lg); + font-weight: var(--weight-regular); + line-height: var(--leading-relaxed); + color: var(--color-on-surface); + background-color: var(--color-surface); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } /* -------------------------------------------------------------------------- @@ -161,37 +161,37 @@ body { -------------------------------------------------------------------------- */ .display-lg { - 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); + 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); } .headline-md { - font-family: var(--font-display); - font-size: var(--text-headline-md); - font-weight: var(--weight-semibold); - line-height: var(--leading-snug); + font-family: var(--font-display); + font-size: var(--text-headline-md); + font-weight: var(--weight-semibold); + line-height: var(--leading-snug); } .label-md { - font-family: var(--font-label); - font-size: var(--text-label-md); - font-weight: var(--weight-medium); - letter-spacing: var(--tracking-wide); - text-transform: uppercase; + font-family: var(--font-label); + font-size: var(--text-label-md); + font-weight: var(--weight-medium); + letter-spacing: var(--tracking-wide); + text-transform: uppercase; } .link { - color: var(--color-primary); - text-decoration: none; - font-weight: var(--weight-medium); - transition: opacity var(--duration-fast) var(--ease-standard); + color: var(--color-primary); + text-decoration: none; + font-weight: var(--weight-medium); + transition: opacity var(--duration-fast) var(--ease-standard); } .link:hover { - opacity: 0.7; + opacity: 0.7; } /* -------------------------------------------------------------------------- @@ -199,41 +199,41 @@ body { -------------------------------------------------------------------------- */ .form { - display: flex; - flex-direction: column; - gap: var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-4); } .form-header { - margin-bottom: var(--space-6); + margin-bottom: var(--space-6); } .form-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-on-surface-variant); - margin-bottom: var(--space-1); + 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-on-surface-variant); + margin-bottom: var(--space-1); } .form-title { - 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); - } + 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); +} .form-footer { - display: flex; - align-items: center; - gap: var(--space-2); - margin-top: var(--space-6); - font-family: var(--font-label); - font-size: var(--text-body-sm); - color: var(--color-on-surface-variant); + display: flex; + align-items: center; + gap: var(--space-2); + margin-top: var(--space-6); + font-family: var(--font-label); + font-size: var(--text-body-sm); + color: var(--color-on-surface-variant); } /* -------------------------------------------------------------------------- @@ -241,42 +241,42 @@ body { -------------------------------------------------------------------------- */ .btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: var(--space-2); - padding: var(--space-2) var(--space-4); - border: none; - border-radius: var(--radius-md); - font-family: var(--font-display); - font-size: var(--text-label-lg); - font-weight: var(--weight-medium); - letter-spacing: var(--tracking-wide); - cursor: pointer; - text-decoration: none; - transition: - opacity var(--duration-normal) var(--ease-standard), - background-color var(--duration-normal) var(--ease-standard); + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + border: none; + border-radius: var(--radius-md); + font-family: var(--font-display); + font-size: var(--text-label-lg); + font-weight: var(--weight-medium); + letter-spacing: var(--tracking-wide); + cursor: pointer; + text-decoration: none; + transition: + opacity var(--duration-normal) var(--ease-standard), + background-color var(--duration-normal) var(--ease-standard); } .btn-primary { - background-color: var(--color-primary); - color: var(--color-on-primary); + background-color: var(--color-primary); + color: var(--color-on-primary); } .btn-primary:hover { - opacity: 0.88; + opacity: 0.88; } .btn-secondary { - background-color: var(--color-secondary-container); - color: var(--color-on-secondary-container); + background-color: var(--color-secondary-container); + color: var(--color-on-secondary-container); } .btn-ghost { - background: none; - color: var(--color-primary); - padding-inline: var(--space-2); + background: none; + color: var(--color-primary); + padding-inline: var(--space-2); } /* -------------------------------------------------------------------------- @@ -284,97 +284,107 @@ body { -------------------------------------------------------------------------- */ .field { - display: flex; - flex-direction: column; - gap: var(--space-1); + display: flex; + flex-direction: column; + gap: var(--space-1); } .field-label { - 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-on-surface-variant); + 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-on-surface-variant); } .field-input { - background-color: var(--color-surface-container-high); - color: var(--color-on-surface); - border: none; - border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent); - border-radius: var(--radius-sm) var(--radius-sm) 0 0; - padding: var(--space-2) var(--space-3); - font-family: var(--font-body); - font-size: var(--text-body-lg); - line-height: var(--leading-normal); - outline: none; - width: 100%; - transition: border-color var(--duration-fast) var(--ease-standard); + background-color: var(--color-surface-container-high); + color: var(--color-on-surface); + border: none; + border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent); + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + padding: var(--space-2) var(--space-3); + font-family: var(--font-body); + font-size: var(--text-body-lg); + line-height: var(--leading-normal); + outline: none; + width: 100%; + 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); + color: var(--color-outline-variant); } .field-input:focus { - border-bottom: 2px solid var(--color-primary); + border-bottom: 2px solid var(--color-primary); } .field-select { - background-color: var(--color-surface-container-high); - color: var(--color-on-surface); - border: none; - border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent); - border-radius: var(--radius-sm) var(--radius-sm) 0 0; - padding: var(--space-2) var(--space-8) var(--space-2) var(--space-3); - font-family: var(--font-body); - font-size: var(--text-body-lg); - line-height: var(--leading-normal); - outline: none; - width: 100%; - cursor: pointer; - 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-repeat: no-repeat; - background-position: right var(--space-3) center; - transition: border-color var(--duration-fast) var(--ease-standard); + background-color: var(--color-surface-container-high); + color: var(--color-on-surface); + border: none; + border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent); + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + padding: var(--space-2) var(--space-8) var(--space-2) var(--space-3); + font-family: var(--font-body); + font-size: var(--text-body-lg); + line-height: var(--leading-normal); + outline: none; + width: 100%; + cursor: pointer; + 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-repeat: no-repeat; + background-position: right var(--space-3) center; + transition: border-color var(--duration-fast) var(--ease-standard); } .field-select:focus { - border-bottom: 2px solid var(--color-primary); + border-bottom: 2px solid var(--color-primary); } .field-textarea { - background-color: var(--color-surface-container-high); - color: var(--color-on-surface); - border: none; - border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent); - border-radius: var(--radius-sm) var(--radius-sm) 0 0; - padding: var(--space-2) var(--space-3); - font-family: var(--font-body); - font-size: var(--text-body-lg); - line-height: var(--leading-relaxed); - outline: none; - width: 100%; - min-height: 14rem; - resize: vertical; - transition: border-color var(--duration-fast) var(--ease-standard); + background-color: var(--color-surface-container-high); + color: var(--color-on-surface); + border: none; + border-bottom: 1px solid color-mix(in srgb, var(--color-outline-variant) 20%, transparent); + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + padding: var(--space-2) var(--space-3); + font-family: var(--font-body); + font-size: var(--text-body-lg); + line-height: var(--leading-relaxed); + outline: none; + width: 100%; + min-height: 14rem; + resize: vertical; + transition: border-color var(--duration-fast) var(--ease-standard); } .field-textarea::placeholder { - color: var(--color-outline-variant); + color: var(--color-outline-variant); } .field-textarea:focus { - border-bottom: 2px solid var(--color-primary); + border-bottom: 2px solid var(--color-primary); } .field-hint { - font-family: var(--font-label); - font-size: var(--text-label-md); - color: var(--color-on-surface-variant); - margin-top: var(--space-1); + font-family: var(--font-label); + font-size: var(--text-label-md); + color: var(--color-on-surface-variant); + margin-top: var(--space-1); } /* -------------------------------------------------------------------------- @@ -382,14 +392,14 @@ body { -------------------------------------------------------------------------- */ .alert { - padding: var(--space-3); - border-radius: var(--radius-md); - font-family: var(--font-label); - font-size: var(--text-body-sm); + padding: var(--space-3); + border-radius: var(--radius-md); + font-family: var(--font-label); + font-size: var(--text-body-sm); } .alert-error { - background-color: color-mix(in srgb, #b3261e 10%, var(--color-surface)); - color: #b3261e; - border-left: 3px solid #b3261e; + background-color: color-mix(in srgb, #b3261e 10%, var(--color-surface)); + color: #b3261e; + border-left: 3px solid #b3261e; } diff --git a/frontend/src/routes/app/+layout.server.ts b/frontend/src/routes/app/+layout.server.ts new file mode 100644 index 0000000..b698d8e --- /dev/null +++ b/frontend/src/routes/app/+layout.server.ts @@ -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') + }; +}; diff --git a/frontend/src/routes/app/+layout.svelte b/frontend/src/routes/app/+layout.svelte index 34ec324..50cd343 100644 --- a/frontend/src/routes/app/+layout.svelte +++ b/frontend/src/routes/app/+layout.svelte @@ -1,9 +1,31 @@ +{#if data.emailUnverified} + +{/if} + {@render children()} + + diff --git a/frontend/src/routes/app/onboarding/+page.server.ts b/frontend/src/routes/app/onboarding/+page.server.ts new file mode 100644 index 0000000..b6a90e1 --- /dev/null +++ b/frontend/src/routes/app/onboarding/+page.server.ts @@ -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; diff --git a/frontend/src/routes/app/onboarding/+page.svelte b/frontend/src/routes/app/onboarding/+page.svelte new file mode 100644 index 0000000..55e9102 --- /dev/null +++ b/frontend/src/routes/app/onboarding/+page.svelte @@ -0,0 +1,199 @@ + + +
+
+
+

Getting started

+

Let's get you set up

+

Tell us a bit about yourself and what you'd like to learn.

+
+ +
+ +
+ + +
+ + +
+ + + {#if selectedPair} +

{selectedPair.description}

+ {/if} +
+ + +
+ My current level +

Select one or two levels that describe where you are now.

+
+ {#each data.proficiencies as level} + {@const isChecked = selectedProficiencies.includes(level.value)} + {@const isDisabled = selectedProficiencies.length >= 2 && !isChecked} + + {/each} +
+
+ + {#if form?.error} +
{form.error}
+ {/if} + + +
+
+
+ + diff --git a/frontend/src/routes/app/profile/+page.server.ts b/frontend/src/routes/app/profile/+page.server.ts new file mode 100644 index 0000000..2a431a5 --- /dev/null +++ b/frontend/src/routes/app/profile/+page.server.ts @@ -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; diff --git a/frontend/src/routes/app/profile/+page.svelte b/frontend/src/routes/app/profile/+page.svelte index 08d9a56..d72a60d 100644 --- a/frontend/src/routes/app/profile/+page.svelte +++ b/frontend/src/routes/app/profile/+page.svelte @@ -1,12 +1,113 @@ + +

Account

Profile

-

Profile settings coming soon.

+ {#if form?.success} +
Profile saved.
+ {/if} - Sign out +
+ +
+

Email

+

{data.account.email}

+
+ + +
+ + +
+ + +
+ + + {#if selectedPair} +

{selectedPair.description}

+ {/if} +
+ + +
+ My current level +

Select one or two levels that describe where you are now.

+
+ {#each data.proficiencies as level} + {@const isChecked = selectedProficiencies.includes(level.value)} + {@const isDisabled = selectedProficiencies.length >= 2 && !isChecked} + + {/each} +
+
+ + {#if form?.error} +
{form.error}
+ {/if} + +
+ + Sign out +
+
diff --git a/frontend/src/routes/login/+page.server.ts b/frontend/src/routes/login/+page.server.ts index 7b2b228..6609355 100644 --- a/frontend/src/routes/login/+page.server.ts +++ b/frontend/src/routes/login/+page.server.ts @@ -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}` : '' diff --git a/frontend/src/routes/register/+page.server.ts b/frontend/src/routes/register/+page.server.ts new file mode 100644 index 0000000..f179e8e --- /dev/null +++ b/frontend/src/routes/register/+page.server.ts @@ -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; diff --git a/frontend/src/routes/register/+page.svelte b/frontend/src/routes/register/+page.svelte new file mode 100644 index 0000000..6316cec --- /dev/null +++ b/frontend/src/routes/register/+page.svelte @@ -0,0 +1,181 @@ + + +
+ + +
+
+
+

Language Learning App

+

Create an account

+
+ + {#if form?.success} +
+

Account created. Check your inbox for a verification link.

+

In development, the link is printed to the API server logs.

+ Sign in +
+ {:else} +
+ {#if form?.error} +
{form.error}
+ {/if} + +
+ + +
+ +
+ + +

Minimum 8 characters

+
+ +
+ +
+
+ {/if} + +
+ Already have an account? + Sign in +
+
+
+
+ + diff --git a/frontend/src/routes/verify-email/+page.server.ts b/frontend/src/routes/verify-email/+page.server.ts new file mode 100644 index 0000000..8c56711 --- /dev/null +++ b/frontend/src/routes/verify-email/+page.server.ts @@ -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.' + }; +}; diff --git a/frontend/src/routes/verify-email/+page.svelte b/frontend/src/routes/verify-email/+page.svelte new file mode 100644 index 0000000..3db112e --- /dev/null +++ b/frontend/src/routes/verify-email/+page.svelte @@ -0,0 +1,52 @@ + + +
+
+ {#if data.success} +

Email verified.

+

You can now sign in.

+ Sign in → + {:else} +

Verification failed.

+
{data.error}
+ Back to registration + {/if} +
+
+ +