feat: [frontend] Create the register and onboarding flows
Some checks failed
/ test (push) Has been cancelled
Some checks failed
/ test (push) Has been cancelled
This commit is contained in:
parent
5532fb609b
commit
74c173b6ae
12 changed files with 1093 additions and 255 deletions
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
36
frontend/src/routes/app/+layout.server.ts
Normal file
36
frontend/src/routes/app/+layout.server.ts
Normal 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')
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
51
frontend/src/routes/app/onboarding/+page.server.ts
Normal file
51
frontend/src/routes/app/onboarding/+page.server.ts
Normal 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;
|
||||||
199
frontend/src/routes/app/onboarding/+page.svelte
Normal file
199
frontend/src/routes/app/onboarding/+page.svelte
Normal 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>
|
||||||
59
frontend/src/routes/app/profile/+page.server.ts
Normal file
59
frontend/src/routes/app/profile/+page.server.ts
Normal 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;
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}` : ''
|
||||||
|
|
|
||||||
28
frontend/src/routes/register/+page.server.ts
Normal file
28
frontend/src/routes/register/+page.server.ts
Normal 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;
|
||||||
181
frontend/src/routes/register/+page.svelte
Normal file
181
frontend/src/routes/register/+page.svelte
Normal 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>
|
||||||
24
frontend/src/routes/verify-email/+page.server.ts
Normal file
24
frontend/src/routes/verify-email/+page.server.ts
Normal 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.'
|
||||||
|
};
|
||||||
|
};
|
||||||
52
frontend/src/routes/verify-email/+page.svelte
Normal file
52
frontend/src/routes/verify-email/+page.svelte
Normal 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>
|
||||||
Loading…
Reference in a new issue