Fix isFormComplete derived always returning true for login mode. Now requires email and password to be non-empty before enabling Sign In button. Add early-return guard in handleSubmit to prevent sending empty credentials.
602 lines
18 KiB
Svelte
602 lines
18 KiB
Svelte
<script lang="ts">
|
|
import { Button } from '$lib/components/ui/button/index.js';
|
|
import { Input } from '$lib/components/ui/input/index.js';
|
|
import { EmailInput } from '$lib/components/ui/email-input/index.js';
|
|
import { PhoneInput } from '$lib/components/ui/phone-input/index.js';
|
|
import { Label } from '$lib/components/ui/label/index.js';
|
|
import { Separator } from '$lib/components/ui/separator/index.js';
|
|
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
|
import RequiredLabel from '$lib/components/layout/RequiredLabel.svelte';
|
|
import { isValidUKPhone, toE164UK } from '$lib/utils/phone';
|
|
import { SvelteDate } from 'svelte/reactivity';
|
|
|
|
// zxcvbn-ts imports
|
|
import { ZxcvbnFactory } from '@zxcvbn-ts/core';
|
|
import * as languageCommon from '@zxcvbn-ts/language-common';
|
|
import * as languageEn from '@zxcvbn-ts/language-en';
|
|
import { toast } from 'svelte-sonner';
|
|
import { extractErrorMessage, sanitizeText } from '$lib/utils/toast-safe';
|
|
|
|
// set up options so that feedback, dictionary etc. are included
|
|
const zxcvbn = new ZxcvbnFactory({
|
|
translations: languageEn.translations,
|
|
graphs: languageCommon.adjacencyGraphs,
|
|
dictionary: {
|
|
...languageCommon.dictionary,
|
|
...languageEn.dictionary
|
|
}
|
|
});
|
|
|
|
let isLogin = $state(true);
|
|
let agreedToPolicy = $state(false);
|
|
|
|
let formData = $state({
|
|
email: '',
|
|
password: '',
|
|
firstName: '',
|
|
lastName: '',
|
|
confirmPassword: '',
|
|
phone: '',
|
|
dateOfBirth: '',
|
|
referralCode: ''
|
|
});
|
|
|
|
// Validation state
|
|
let validationErrors = $state({
|
|
email: '',
|
|
phone: '',
|
|
dateOfBirth: ''
|
|
});
|
|
|
|
function toggleMode() {
|
|
isLogin = !isLogin;
|
|
agreedToPolicy = false;
|
|
formData = {
|
|
email: '',
|
|
password: '',
|
|
firstName: '',
|
|
lastName: '',
|
|
confirmPassword: '',
|
|
phone: '',
|
|
dateOfBirth: '',
|
|
referralCode: ''
|
|
};
|
|
validationErrors = {
|
|
email: '',
|
|
phone: '',
|
|
dateOfBirth: ''
|
|
};
|
|
}
|
|
|
|
function validatePhone(phone: string): boolean {
|
|
const valid = isValidUKPhone(phone);
|
|
validationErrors.phone = valid ? '' : 'Please enter a valid UK phone number';
|
|
return valid;
|
|
}
|
|
|
|
// Age validation (must be 16+)
|
|
function validateAge(dateStr: string): boolean {
|
|
if (!dateStr) {
|
|
validationErrors.dateOfBirth = '';
|
|
return true;
|
|
}
|
|
|
|
const dob = new SvelteDate(dateStr);
|
|
const today = new SvelteDate();
|
|
const sixteenYearsAgo = new SvelteDate(
|
|
today.getFullYear() - 16,
|
|
today.getMonth(),
|
|
today.getDate()
|
|
);
|
|
|
|
const isValid = dob <= sixteenYearsAgo;
|
|
validationErrors.dateOfBirth = isValid
|
|
? ''
|
|
: "You must be at least 16 years old to register, but you can call for an appointment by clicking 'Contact' at the top of the page.";
|
|
return isValid;
|
|
}
|
|
|
|
/**
|
|
* Formats referral code input into 3 blocks of 4 characters (xxxx-xxxx-xxxx).
|
|
* Handles paste, backspace, and partial inputs gracefully.
|
|
*/
|
|
function handleReferralInput(e: Event) {
|
|
const target = e.target as HTMLInputElement;
|
|
|
|
// 1. Clean the input: keep only alphanumeric characters
|
|
let raw = target.value.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
|
|
|
|
// 2. Limit to 12 characters
|
|
if (raw.length > 12) {
|
|
raw = raw.slice(0, 12);
|
|
}
|
|
|
|
// 3. Reconstruct with dashes
|
|
let formatted = '';
|
|
if (raw.length > 0) {
|
|
formatted += raw.slice(0, 4);
|
|
}
|
|
if (raw.length > 4) {
|
|
formatted += '-' + raw.slice(4, 8);
|
|
}
|
|
if (raw.length > 8) {
|
|
formatted += '-' + raw.slice(8, 12);
|
|
}
|
|
|
|
// 4. Update state
|
|
formData.referralCode = formatted;
|
|
}
|
|
|
|
// Normalize data before sending
|
|
function normalizeFormData() {
|
|
return {
|
|
firstName: formData.firstName.trim(),
|
|
lastName: formData.lastName.trim(),
|
|
email: formData.email.trim().toLowerCase(),
|
|
password: formData.password,
|
|
phone: toE164UK(formData.phone) ?? formData.phone,
|
|
dateOfBirth: formData.dateOfBirth.trim(),
|
|
// Strip dashes to send raw 12 characters
|
|
referralCode: formData.referralCode.replace(/-/g, '').trim() || undefined,
|
|
agreedToPolicy: agreedToPolicy
|
|
};
|
|
}
|
|
|
|
async function handleSubmit() {
|
|
if (isLogin) {
|
|
const email = formData.email.trim().toLowerCase();
|
|
if (!email || !formData.password) {
|
|
toast.error('Please enter your email and password.');
|
|
return;
|
|
}
|
|
|
|
// Login flow with loading toast
|
|
const loadingToast = toast.loading('Signing in...');
|
|
|
|
try {
|
|
const response = await fetch('/api/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
email,
|
|
password: formData.password
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
localStorage.setItem('authToken', data.token);
|
|
|
|
// Decode token to check role for redirect
|
|
let redirectTo = '/';
|
|
try {
|
|
const payload = JSON.parse(atob(data.token.split('.')[1]));
|
|
if (payload.role === 'admin') {
|
|
redirectTo = '/today';
|
|
}
|
|
} catch {
|
|
// Token decode failure is non-fatal; default redirectTo = '/' is used
|
|
}
|
|
toast.success('Successfully logged in!', { id: loadingToast });
|
|
window.location.href = redirectTo;
|
|
} else if (response.status === 409) {
|
|
toast.error('Login already in progress. Please wait.', { id: loadingToast });
|
|
} else if (response.status === 401) {
|
|
toast.error('Invalid email or password.', { id: loadingToast });
|
|
} else {
|
|
const text = await response.text();
|
|
toast.error('Error: ' + sanitizeText(extractErrorMessage(text)), { id: loadingToast });
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
toast.error('Network error, please try again later.', { id: loadingToast });
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Validate before submitting
|
|
const isEmailValid = !validationErrors.email && formData.email.trim().length > 0;
|
|
const isPhoneValid = validatePhone(formData.phone);
|
|
const isAgeValid = validateAge(formData.dateOfBirth);
|
|
|
|
if (!isEmailValid || !isPhoneValid || !isAgeValid) {
|
|
toast.error('Please fix the validation errors before submitting.');
|
|
return;
|
|
}
|
|
|
|
// Registration flow with loading toast
|
|
const loadingToast = toast.loading('Creating your account...');
|
|
|
|
try {
|
|
const response = await fetch('/api/register', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(normalizeFormData())
|
|
});
|
|
|
|
if (response.ok) {
|
|
toast.success('Account created successfully!', { id: loadingToast });
|
|
toggleMode();
|
|
} else {
|
|
const text = await response.text();
|
|
toast.error('Error: ' + sanitizeText(extractErrorMessage(text)), { id: loadingToast });
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
toast.error('Network error, please try again later.', { id: loadingToast });
|
|
}
|
|
}
|
|
|
|
function handleSocialLogin(provider: string) {
|
|
toast.info(`${provider} login coming soon`);
|
|
}
|
|
|
|
// when password changes, re-compute strength
|
|
const passwordStrength = $derived(
|
|
formData.password
|
|
? (() => {
|
|
const result = zxcvbn.check(formData.password);
|
|
// Add minimum length check (6 chars) to the score feedback
|
|
if (formData.password.length < 6) {
|
|
return {
|
|
...result,
|
|
score: Math.min(result.score, 0), // Force weak for too short
|
|
feedback: {
|
|
warning: 'Password must be at least 6 characters',
|
|
suggestions: ['Add more characters to meet the minimum length requirement']
|
|
}
|
|
};
|
|
}
|
|
return result;
|
|
})()
|
|
: null
|
|
);
|
|
|
|
// form completion check
|
|
const isFormComplete = $derived(
|
|
isLogin
|
|
? formData.email.trim().length > 0 && formData.password.length > 0
|
|
: (formData.firstName.trim() &&
|
|
formData.lastName.trim() &&
|
|
formData.phone &&
|
|
formData.dateOfBirth &&
|
|
formData.email &&
|
|
formData.password &&
|
|
formData.confirmPassword &&
|
|
formData.password === formData.confirmPassword &&
|
|
passwordStrength &&
|
|
formData.password.length >= 6 &&
|
|
passwordStrength.score >= 2 &&
|
|
agreedToPolicy &&
|
|
!validationErrors.email &&
|
|
!validationErrors.phone &&
|
|
!validationErrors.dateOfBirth)
|
|
);
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<script>
|
|
(function () {
|
|
// Pre-hydration auth guard: reads localStorage directly because the Svelte
|
|
// authStore hasn't initialized yet at this point (async+$state). This runs
|
|
// synchronously in <svelte:head> before any rendering, preventing a flash
|
|
// of protected content. The authStore handles post-hydration auth.
|
|
try {
|
|
var token = localStorage.getItem('authToken');
|
|
if (token) {
|
|
var payload = JSON.parse(atob(token.split('.')[1]));
|
|
if (payload.exp * 1000 > Date.now()) {
|
|
var path = payload.role === 'admin' ? '/today' : '/';
|
|
window.location.replace(path);
|
|
}
|
|
}
|
|
} catch (e) {}
|
|
})();
|
|
</script>
|
|
</svelte:head>
|
|
|
|
<div class="mx-auto flex w-full justify-center p-4 pt-2">
|
|
<div class="w-full max-w-md space-y-6">
|
|
<!-- Header -->
|
|
<div class="space-y-2 text-center">
|
|
<h1 class="font-['Playfair_Display'] text-4xl font-bold">
|
|
{isLogin ? 'Welcome back' : 'Create account'}
|
|
</h1>
|
|
<p class="text-muted-foreground">
|
|
{isLogin
|
|
? 'Sign in to your account to continue'
|
|
: 'Choose an option below to create your account'}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Social Login Buttons -->
|
|
<div class="space-y-3">
|
|
<Button variant="outline" onclick={() => handleSocialLogin('Google')} class="w-full">
|
|
<svg class="mr-2" style="width: 1rem; height: 1rem;" viewBox="0 0 24 24">
|
|
<path
|
|
fill="currentColor"
|
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
|
/>
|
|
<path
|
|
fill="currentColor"
|
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
|
/>
|
|
<path
|
|
fill="currentColor"
|
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
|
/>
|
|
<path
|
|
fill="currentColor"
|
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
|
/>
|
|
</svg>
|
|
Continue with Google
|
|
</Button>
|
|
|
|
<Button variant="outline" onclick={() => handleSocialLogin('Microsoft')} class="w-full">
|
|
<svg
|
|
class="mr-2"
|
|
style="width: 1rem; height: 1rem;"
|
|
fill="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
d="M11.4 24H0V12.6h11.4V24zM24 24H12.6V12.6H24V24zM11.4 11.4H0V0h11.4v11.4zM24 11.4H12.6V0H24v11.4z"
|
|
/>
|
|
</svg>
|
|
Continue with Microsoft
|
|
</Button>
|
|
|
|
<Button variant="outline" onclick={() => handleSocialLogin('Facebook')} class="w-full">
|
|
<svg
|
|
class="mr-2"
|
|
style="width: 1rem; height: 1rem;"
|
|
fill="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"
|
|
/>
|
|
</svg>
|
|
Continue with Facebook
|
|
</Button>
|
|
</div>
|
|
|
|
<div class="relative">
|
|
<div class="absolute inset-0 flex items-center">
|
|
<Separator class="w-full" />
|
|
</div>
|
|
<div class="relative flex justify-center text-xs uppercase">
|
|
<span class="bg-background px-2 text-muted-foreground">or continue with email</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Email/Password Form -->
|
|
<form
|
|
onsubmit={(e) => {
|
|
e.preventDefault();
|
|
handleSubmit();
|
|
}}
|
|
class="space-y-6"
|
|
>
|
|
{#if !isLogin}
|
|
<!-- SECTION 1: LOGIN DETAILS -->
|
|
<div class="space-y-4">
|
|
<h3 class="text-lg font-semibold">What we need to log you in</h3>
|
|
|
|
<div class="space-y-2">
|
|
<RequiredLabel forId="email" text="Email" />
|
|
<EmailInput
|
|
id="email"
|
|
bind:value={formData.email}
|
|
bind:error={validationErrors.email}
|
|
placeholder="john@example.com"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<RequiredLabel forId="password" text="Password" />
|
|
<Input
|
|
id="password"
|
|
type="password"
|
|
placeholder="Enter your password"
|
|
maxlength={72}
|
|
bind:value={formData.password}
|
|
required
|
|
/>
|
|
|
|
{#if passwordStrength}
|
|
<!-- Strength meter bar -->
|
|
<div class="mt-1">
|
|
<div class="h-2 overflow-hidden rounded bg-gray-200">
|
|
<div
|
|
class="h-2 transition-all"
|
|
style="
|
|
width: {(passwordStrength.score + 1) * 20}%;
|
|
background-color: {passwordStrength.score < 2
|
|
? 'var(--chart-1)'
|
|
: passwordStrength.score === 2
|
|
? 'orange'
|
|
: passwordStrength.score === 3
|
|
? 'var(--chart-2)'
|
|
: 'var(--chart-2)'};
|
|
"
|
|
></div>
|
|
</div>
|
|
<p class="mt-1 text-xs">
|
|
{passwordStrength.feedback.warning
|
|
? passwordStrength.feedback.warning
|
|
: `Strength: ${['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'][passwordStrength.score]}`}
|
|
</p>
|
|
{#if passwordStrength.feedback.suggestions.length > 0}
|
|
<ul class="mt-1 ml-4 list-disc text-xs text-muted-foreground">
|
|
{#each passwordStrength.feedback.suggestions as suggestion (suggestion)}
|
|
<li>{suggestion}</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<RequiredLabel forId="confirmPassword" text="Confirm Password" />
|
|
<Input
|
|
id="confirmPassword"
|
|
type="password"
|
|
placeholder="Confirm your password"
|
|
bind:value={formData.confirmPassword}
|
|
required
|
|
/>
|
|
{#if formData.confirmPassword && formData.password !== formData.confirmPassword}
|
|
<p class="mt-1 text-sm text-red-500">Passwords do not match</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<Separator class="w-full" />
|
|
|
|
<!-- SECTION 2: CONTACT DETAILS -->
|
|
<div class="space-y-4">
|
|
<h3 class="text-lg font-semibold">What we need to know about you</h3>
|
|
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div class="space-y-2">
|
|
<RequiredLabel forId="firstName" text="First Name" />
|
|
<Input
|
|
id="firstName"
|
|
placeholder="John"
|
|
maxlength={50}
|
|
bind:value={formData.firstName}
|
|
onblur={() => (formData.firstName = formData.firstName.trim())}
|
|
required
|
|
/>
|
|
</div>
|
|
<div class="space-y-2">
|
|
<RequiredLabel forId="lastName" text="Last Name" />
|
|
<Input
|
|
id="lastName"
|
|
placeholder="Doe"
|
|
maxlength={50}
|
|
bind:value={formData.lastName}
|
|
onblur={() => (formData.lastName = formData.lastName.trim())}
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<RequiredLabel forId="phone" text="Phone Number" />
|
|
<PhoneInput
|
|
id="phone"
|
|
bind:value={formData.phone}
|
|
bind:error={validationErrors.phone}
|
|
placeholder="07123 456789 or +44 7123 456789"
|
|
/>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<RequiredLabel forId="dateOfBirth" text="Date of Birth" />
|
|
<Input
|
|
id="dateOfBirth"
|
|
type="date"
|
|
bind:value={formData.dateOfBirth}
|
|
onblur={() => validateAge(formData.dateOfBirth)}
|
|
max={new SvelteDate(
|
|
new SvelteDate().setFullYear(new SvelteDate().getFullYear() - 16)
|
|
).toLocaleDateString('en-CA', { timeZone: 'Europe/London' })}
|
|
required
|
|
/>
|
|
{#if validationErrors.dateOfBirth}
|
|
<p class="text-sm text-red-500">{validationErrors.dateOfBirth}</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<Separator class="w-full" />
|
|
|
|
<!-- SECTION 3: REFERRAL -->
|
|
<div class="space-y-4">
|
|
<!-- <h3 class="text-lg font-semibold">Referral</h3> -->
|
|
|
|
<div class="space-y-2">
|
|
<Label for="referralCode">Referral Code (optional)</Label>
|
|
<Input
|
|
id="referralCode"
|
|
placeholder="a2c4-e6g8-i0k2"
|
|
maxlength={14}
|
|
value={formData.referralCode}
|
|
oninput={handleReferralInput}
|
|
/>
|
|
<p class="text-xs text-muted-foreground">
|
|
Enter a 12-character referral code if you were referred by an existing customer
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<!-- Login Fields (Simple inline layout) -->
|
|
<div class="space-y-4">
|
|
<div class="space-y-2">
|
|
<RequiredLabel forId="email" text="Email" />
|
|
<EmailInput
|
|
id="email"
|
|
bind:value={formData.email}
|
|
bind:error={validationErrors.email}
|
|
placeholder="john@example.com"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<RequiredLabel forId="password" text="Password" />
|
|
<Input
|
|
id="password"
|
|
type="password"
|
|
placeholder="Enter your password"
|
|
maxlength={72}
|
|
bind:value={formData.password}
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if !isLogin}
|
|
<div class="flex items-center space-x-2">
|
|
<Checkbox id="privacy" bind:checked={agreedToPolicy} class="mt-1" />
|
|
<div class="text-sm leading-snug">
|
|
<Label for="privacy">
|
|
I agree to the
|
|
<a
|
|
href="/terms"
|
|
class="font-semibold text-primary hover:underline"
|
|
target="_blank"
|
|
rel="noopener noreferrer external">Terms & Conditions</a
|
|
>
|
|
and
|
|
<a
|
|
href="/privacy"
|
|
class="font-semibold text-primary hover:underline"
|
|
target="_blank"
|
|
rel="noopener noreferrer external">Privacy Policy</a
|
|
>
|
|
</Label>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<Button type="submit" class="w-full" disabled={!isFormComplete}>
|
|
{isLogin ? 'Sign In' : 'Create Account'}
|
|
</Button>
|
|
</form>
|
|
|
|
<div class="text-center text-sm text-muted-foreground">
|
|
{isLogin ? "Don't have an account?" : 'Already have an account?'}
|
|
<Button variant="link" onclick={toggleMode} class="px-1 font-semibold text-primary">
|
|
{isLogin ? 'Sign up' : 'Sign in'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|