Files
Crussell/frontend/src/routes/+layout.svelte
T
popertots 73f0258b97 fix: mobile UI — touch targets, safe areas, text sizes
- Button: add min-h-11 (44px) for mobile touch targets
- NavBar: add safe-area-inset-top for notched phones
- NavBar mobile menu: increase link padding from py-2 to py-3
- Footer: increase text size from text-xs to text-sm (16px minimum)
- Footer: add px-4 for better mobile spacing
- DatePicker: increase calendar cell size on mobile to 44px
- Checkbox: add p-3 -m-3 on mobile for 44px touch target (desktop unchanged)
2026-08-22 00:34:51 +01:00

204 lines
6.6 KiB
Svelte

<script lang="ts">
import '../app.css';
import favicon from '$lib/assets/favicon.svg';
import NavBar from '$lib/components/layout/NavBar.svelte';
import { Toaster } from '$lib/components/ui/sonner/index.js';
import { toast } from 'svelte-sonner';
import { authStore } from '$lib/stores/auth.svelte';
import { resendEmailVerification, verifyEmailCode } from '$lib/square/square';
import { extractErrorMessage } from '$lib/utils/toast-safe';
import { resetZIndexStack } from '$lib/components/ui/dialog/zindex.js';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { resolve } from '$app/paths';
const { children } = $props();
let hideFooter = $derived(
$page.url.pathname === '/admin/schedule' ||
$page.url.pathname === '/account' ||
$page.url.searchParams.get('format') === 'pdf'
);
// Default to 'top-center' (mobile-first approach)
let toasterPosition = $state<'top-center' | 'bottom-center'>('top-center');
function updateToasterPosition() {
// Use 640px (Tailwind's 'sm' breakpoint) to differentiate desktop and mobile
if (typeof window !== 'undefined' && window.innerWidth >= 768) {
toasterPosition = 'bottom-center';
} else {
toasterPosition = 'top-center';
}
}
onMount(() => {
// Start the dialog z-index stack fresh per full page load so hot reloads
// / repeated runs don't let the counter climb forever (see zindex.ts).
resetZIndexStack();
updateToasterPosition();
window.addEventListener('resize', updateToasterPosition);
return () => {
window.removeEventListener('resize', updateToasterPosition);
};
});
// Re-send the email verification code via POST /api/verify/generate (the
// backend only exposes generate/check — there is no /api/verify-email).
async function verify_email() {
const loadingToast = toast.loading('Sending verification code...');
if (!authStore.currentUser?.email) {
toast.error('Unable to resend the verification code — no email on file', {
id: loadingToast
});
return;
}
try {
const response = await resendEmailVerification(authStore.currentUser.email);
if (response.ok) {
toast.success('If the email exists, a verification code has been sent', {
id: loadingToast
});
window.location.reload();
} else {
toast.error('Failed to send the verification code', { id: loadingToast });
}
} catch {
toast.error('Network error sending the verification code — please try again', {
id: loadingToast
});
}
}
// The banner has no submit surface on its own — the code lands out-of-band
// (email/SMS, or the dev [VERIFY] server log), so this inline input submits
// the received code to POST /api/verify/check. A success escalates the
// account to verified_email; the reload clears the banner.
let verificationCode = $state('');
let isVerifyingCode = $state(false);
async function submitVerificationCode() {
if (isVerifyingCode) return;
if (!authStore.currentUser?.email) {
toast.error('Unable to verify — no email on file');
return;
}
const code = verificationCode.trim();
if (!code) {
toast.error('Please enter the verification code');
return;
}
isVerifyingCode = true;
const loadingToast = toast.loading('Verifying code...');
try {
const response = await verifyEmailCode(authStore.currentUser.email, code);
if (response.ok) {
const data = (await response.json().catch(() => null)) as {
message?: unknown;
} | null;
toast.success(
typeof data?.message === 'string' ? data.message : 'Email verified successfully',
{ id: loadingToast }
);
window.location.reload();
} else {
const errData = await response.text().catch(() => '');
toast.error(
extractErrorMessage(errData) ||
'Verification failed — please check the code and try again',
{ id: loadingToast }
);
}
} catch {
toast.error('Network error verifying your code — please try again', { id: loadingToast });
} finally {
isVerifyingCode = false;
}
}
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<link rel="stylesheet" href="/fonts/playfair-display.css" />
</svelte:head>
<div class="flex min-h-screen flex-col supports-[height:100dvh]:min-h-dvh">
<NavBar />
<Toaster position={toasterPosition} />
<main class="flex-1 pt-16">
{#if authStore.currentUser?.role === 'unverified_email'}
<div class="w-full bg-red-600 py-2 pr-8 pl-8 text-center text-sm font-medium text-white">
<p>
Please verify your email address to continue. Didn't recieve the email? Check your spam
folder, or
<button
type="button"
onclick={verify_email}
class="cursor-pointer border-0 bg-transparent p-0 text-white underline"
>
click here
</button>
to resend it.
</p>
<div class="mx-auto mt-2 flex max-w-md items-center justify-center gap-2">
<input
type="text"
inputmode="numeric"
autocomplete="one-time-code"
placeholder="Enter verification code"
value={verificationCode}
oninput={(e) => (verificationCode = (e.target as HTMLInputElement).value)}
class="w-44 rounded-md border border-white/50 bg-white px-3 py-1.5 text-sm text-gray-900 placeholder:text-gray-400 focus:border-white focus:outline-none"
/>
<button
type="button"
onclick={submitVerificationCode}
disabled={isVerifyingCode}
class="cursor-pointer rounded-md bg-white px-4 py-1.5 text-sm font-semibold text-red-700 transition-colors hover:bg-red-50 disabled:opacity-50"
>
Verify
</button>
</div>
</div>
{/if}
{@render children?.()}
</main>
{#if !hideFooter}
<footer class="border-t py-4 text-center text-sm text-gray-500 md:py-6">
<div class="mx-auto flex max-w-3xl flex-wrap items-center justify-center gap-x-4 gap-y-2 px-4">
<a href={resolve('/privacy-policy')} class="hover:text-gray-700 hover:underline"
>Privacy Policy</a
>
<a href={resolve('/terms')} class="hover:text-gray-700 hover:underline"
>Terms &amp; Conditions</a
>
<a href={resolve('/cancellation-policy')} class="hover:text-gray-700 hover:underline"
>Booking, Deposit &amp; Cancellation Policy</a
>
<a href={resolve('/gift-card-terms')} class="hover:text-gray-700 hover:underline"
>Gift Card Terms</a
>
<a href={resolve('/gdpr')} class="hover:text-gray-700 hover:underline">Your Data</a>
</div>
<div class="mt-2">&copy; {new Date().getFullYear()} Crussell Nails. All rights reserved.</div>
</footer>
{/if}
</div>
<style>
@media print {
:global(nav) {
display: none !important;
}
:global(main > div.bg-red-600) {
display: none !important;
}
:global(main) {
padding-top: 0 !important;
}
}
</style>