feat(register): redesign register page to match spec
Replaces the minimal login-style form with the full spec design: hero section (eyebrow, headline, subtext), three labelled form sections, 2-column name grid, confirm-password field with client-side match hints, password strength indicator, notification checkbox card, loading state on submit, and "already have an account?" footer link. Backend: adds notifyOnMention to RegisterRequest and wires both notifyOnMention and notifyOnReply via updateNotificationPreferences on invite redemption. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@ import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
|
||||
<header class="bg-header">
|
||||
<div class="h-1 bg-accent"></div>
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-12 items-center justify-between">
|
||||
<div class="flex h-16 items-center justify-between">
|
||||
<a href="/" class="flex items-center" aria-label="Familienarchiv">
|
||||
<span class="font-sans text-sm font-bold tracking-widest text-white uppercase"
|
||||
>Familienarchiv</span
|
||||
|
||||
@@ -35,12 +35,13 @@ export const actions = {
|
||||
const password = formData.get('password') as string;
|
||||
const firstName = formData.get('firstName') as string;
|
||||
const lastName = formData.get('lastName') as string;
|
||||
const notifyOnMention = formData.get('notifyOnMention') === 'on';
|
||||
|
||||
const apiUrl = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||
const res = await fetch(`${apiUrl}/api/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code, email, password, firstName, lastName })
|
||||
body: JSON.stringify({ code, email, password, firstName, lastName, notifyOnMention })
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { getErrorMessage } from '$lib/errors';
|
||||
import { enhance } from '$app/forms';
|
||||
import AuthHeader from '../AuthHeader.svelte';
|
||||
|
||||
let {
|
||||
@@ -16,6 +17,22 @@ let {
|
||||
} = $props();
|
||||
|
||||
let showPassword = $state(false);
|
||||
let showPasswordConfirm = $state(false);
|
||||
let password = $state('');
|
||||
let passwordConfirm = $state('');
|
||||
let notifyOnMention = $state(true);
|
||||
let submitAttempted = $state(false);
|
||||
let submitting = $state(false);
|
||||
|
||||
const pwValid = $derived(password.length >= 8);
|
||||
const pwMatch = $derived(passwordConfirm.length > 0 && password === passwordConfirm);
|
||||
const pwMismatch = $derived(passwordConfirm.length > 0 && password !== passwordConfirm);
|
||||
const showPwHint = $derived(submitAttempted || password.length > 0);
|
||||
const showMatchHint = $derived(submitAttempted || passwordConfirm.length > 0);
|
||||
|
||||
$effect(() => {
|
||||
if (form?.error) submitting = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -25,16 +42,8 @@ let showPassword = $state(false);
|
||||
<div class="flex min-h-screen flex-col bg-canvas">
|
||||
<AuthHeader />
|
||||
|
||||
<main class="flex flex-1 items-center justify-center px-4 py-8">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="mb-10 text-center">
|
||||
<a href="/" class="inline-flex items-center" aria-label="Familienarchiv">
|
||||
<span class="font-sans text-2xl font-bold tracking-widest text-ink uppercase"
|
||||
>Familienarchiv</span
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<main class="flex flex-1 items-center justify-center px-4 py-12">
|
||||
<div class="w-full max-w-2xl">
|
||||
{#if data.codeError}
|
||||
<div class="rounded-sm border border-line bg-surface p-8 text-center shadow-sm">
|
||||
<svg
|
||||
@@ -62,118 +71,217 @@ let showPassword = $state(false);
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-sm border border-line bg-surface p-8 shadow-sm">
|
||||
<h1 class="mb-1 font-sans text-sm font-bold tracking-widest text-ink uppercase">
|
||||
{m.register_heading()}
|
||||
<!-- Hero -->
|
||||
<div class="mb-8 text-center">
|
||||
<p
|
||||
class="mb-3 inline-block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase"
|
||||
>
|
||||
{m.register_eyebrow()}
|
||||
</p>
|
||||
<h1 class="mb-3 font-serif text-4xl font-normal text-ink sm:text-[46px] sm:leading-tight">
|
||||
{m.register_hero_headline()}
|
||||
</h1>
|
||||
{#if data.code}
|
||||
<p class="mb-6 font-serif text-xs text-ink-2">{m.register_subtext()}</p>
|
||||
{/if}
|
||||
<p class="mx-auto max-w-lg font-serif text-lg text-ink-2">
|
||||
{m.register_hero_subtext()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" class="space-y-5">
|
||||
<!-- Form card -->
|
||||
<div class="rounded-sm border border-line bg-surface p-8 shadow-sm sm:p-10">
|
||||
<form
|
||||
method="POST"
|
||||
class="space-y-8"
|
||||
use:enhance={() => {
|
||||
if (!pwValid || pwMismatch) {
|
||||
submitAttempted = true;
|
||||
return async () => {};
|
||||
}
|
||||
submitAttempted = true;
|
||||
submitting = true;
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="code" value={data.code ?? ''} />
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="firstName"
|
||||
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||
>{m.register_label_first_name()}</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="firstName"
|
||||
id="firstName"
|
||||
autocomplete="given-name"
|
||||
value={data.prefill?.firstName ?? ''}
|
||||
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
<!-- Section: Über dich -->
|
||||
<section class="space-y-5">
|
||||
<p class="font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.register_section_about()}
|
||||
</p>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label
|
||||
for="firstName"
|
||||
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||
>{m.register_label_first_name()}</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="firstName"
|
||||
id="firstName"
|
||||
autocomplete="given-name"
|
||||
value={data.prefill?.firstName ?? ''}
|
||||
class="block w-full border border-line px-3 py-3 font-serif text-[17px] text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="lastName"
|
||||
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||
>{m.register_label_last_name()}</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="lastName"
|
||||
id="lastName"
|
||||
autocomplete="family-name"
|
||||
value={data.prefill?.lastName ?? ''}
|
||||
class="block w-full border border-line px-3 py-3 font-serif text-[17px] text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="lastName"
|
||||
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||
>{m.register_label_last_name()}</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="lastName"
|
||||
id="lastName"
|
||||
autocomplete="family-name"
|
||||
value={data.prefill?.lastName ?? ''}
|
||||
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
<!-- Section: Konto -->
|
||||
<section class="space-y-5">
|
||||
<p class="font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.register_section_account()}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="email"
|
||||
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||
>{m.register_label_email()}</label
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
value={data.prefill?.email ?? ''}
|
||||
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
{#if data.prefill?.email}
|
||||
<p class="mt-1 font-sans text-xs text-ink-3">{m.register_prefill_hint()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="password"
|
||||
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||
>{m.register_label_password()}</label
|
||||
>
|
||||
<div class="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
name="password"
|
||||
id="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="block w-full border border-line px-3 py-2.5 pr-10 font-serif text-sm text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showPassword = !showPassword)}
|
||||
class="absolute inset-y-0 right-0 flex items-center px-3 text-ink-3 hover:text-ink"
|
||||
aria-label={showPassword ? m.register_password_hide() : m.register_password_show()}
|
||||
<div>
|
||||
<label
|
||||
for="email"
|
||||
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||
>{m.register_label_email()}</label
|
||||
>
|
||||
{#if showPassword}
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
value={data.prefill?.email ?? ''}
|
||||
class="block w-full border border-line px-3 py-3 font-serif text-[17px] text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
{#if data.prefill?.email}
|
||||
<p class="mt-1.5 font-sans text-xs text-ink-3">{m.register_prefill_hint()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="password"
|
||||
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||
>{m.register_label_password()}</label
|
||||
>
|
||||
<div class="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
name="password"
|
||||
id="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
bind:value={password}
|
||||
class="block w-full border border-line px-3 py-3 pr-20 font-serif text-[17px] text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showPassword = !showPassword)}
|
||||
class="absolute inset-y-0 right-0 flex items-center px-3 font-sans text-xs font-bold text-ink-3 hover:text-ink"
|
||||
aria-label={showPassword ? m.register_password_hide() : m.register_password_show()}
|
||||
>
|
||||
{showPassword ? m.register_password_hide() : m.register_password_show()}
|
||||
</button>
|
||||
</div>
|
||||
{#if showPwHint}
|
||||
<p class="mt-1.5 font-sans text-xs {pwValid ? 'text-green-700' : 'text-ink-3'}">
|
||||
{pwValid ? m.register_pw_ok() : m.register_pw_hint()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="passwordConfirm"
|
||||
class="mb-1.5 block font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||
>{m.register_label_password_confirm()}</label
|
||||
>
|
||||
<div class="relative">
|
||||
<input
|
||||
type={showPasswordConfirm ? 'text' : 'password'}
|
||||
id="passwordConfirm"
|
||||
autocomplete="new-password"
|
||||
bind:value={passwordConfirm}
|
||||
aria-invalid={submitAttempted && pwMismatch}
|
||||
class="block w-full border px-3 py-3 pr-20 font-serif text-[17px] text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring
|
||||
{submitAttempted && pwMismatch ? 'border-red-400' : 'border-line'}"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showPasswordConfirm = !showPasswordConfirm)}
|
||||
class="absolute inset-y-0 right-0 flex items-center px-3 font-sans text-xs font-bold text-ink-3 hover:text-ink"
|
||||
aria-label={showPasswordConfirm ? m.register_password_hide() : m.register_password_show()}
|
||||
>
|
||||
{showPasswordConfirm ? m.register_password_hide() : m.register_password_show()}
|
||||
</button>
|
||||
</div>
|
||||
{#if showMatchHint}
|
||||
{#if pwMatch}
|
||||
<p class="mt-1.5 font-sans text-xs text-green-700">
|
||||
{m.register_pw_match_ok()}
|
||||
</p>
|
||||
{:else if pwMismatch}
|
||||
<p class="mt-1.5 font-sans text-xs text-red-600">{m.register_pw_match_no()}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section: Benachrichtigungen -->
|
||||
<section>
|
||||
<p class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.register_section_notifications()}
|
||||
</p>
|
||||
<label
|
||||
class="flex cursor-pointer items-start gap-3 rounded-sm border p-4 transition-colors
|
||||
{notifyOnMention ? 'border-brand-navy bg-brand-mint/10' : 'border-line bg-white'}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="notifyOnMention"
|
||||
bind:checked={notifyOnMention}
|
||||
class="sr-only"
|
||||
/>
|
||||
<!-- Visual checkbox -->
|
||||
<span
|
||||
class="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-sm border transition-colors
|
||||
{notifyOnMention ? 'border-brand-navy bg-brand-navy' : 'border-line bg-white'}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if notifyOnMention}
|
||||
<svg class="h-3 w-3 text-white" viewBox="0 0 12 12" fill="none">
|
||||
<path
|
||||
d="M2 6l3 3 5-5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
<span>
|
||||
<span class="block font-sans text-sm font-bold text-ink"
|
||||
>{m.register_notify_title()}</span
|
||||
>
|
||||
<span class="mt-0.5 block font-serif text-sm text-ink-2"
|
||||
>{m.register_notify_desc()}</span
|
||||
>
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="font-sans text-xs font-medium text-red-600">
|
||||
@@ -181,12 +289,51 @@ let showPassword = $state(false);
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="mt-2 w-full bg-primary py-2.5 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90"
|
||||
>
|
||||
{m.register_btn_submit()}
|
||||
</button>
|
||||
<div class="space-y-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
class="flex w-full items-center justify-center gap-2 bg-primary py-3 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-colors hover:bg-primary/90 disabled:cursor-wait disabled:opacity-70"
|
||||
>
|
||||
{#if submitting}
|
||||
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
/>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
{m.register_btn_loading()}
|
||||
{:else}
|
||||
{m.register_btn_submit()}
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 8l4 4m0 0l-4 4m4-4H3"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<p class="text-center font-sans text-xs text-ink-3">
|
||||
{m.register_already_have_account()}
|
||||
<a
|
||||
href="/login"
|
||||
class="font-bold tracking-widest text-brand-navy uppercase transition-colors hover:text-brand-navy/70"
|
||||
>{m.register_sign_in()}</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user