feat: add BusinessHours component and update contact page

This commit is contained in:
2026-08-22 00:34:48 +01:00
parent 117c37eaff
commit bea1582088
2 changed files with 286 additions and 4 deletions
@@ -0,0 +1,279 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { formatTime } from '$lib/utils/timeSlots';
type DefaultHours = {
weekday: number; // 0=Mon, 1=Tue, ..., 6=Sun
startTime: string;
endTime: string;
isOpen: boolean;
};
type DayWorkingHours = {
date: string;
weekday: number;
startTime: string;
endTime: string;
isOpen: boolean;
source: 'default' | 'exceptional';
};
type ExceptionalHours = {
id: number;
groupId: number;
weekday: number;
startTime: string;
endTime: string;
isOpen: boolean;
};
type ExceptionalGroup = {
id: number;
name: string;
description: string;
hours: ExceptionalHours[];
weekStarts: string[];
};
const DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
let defaultHours = $state<DefaultHours[]>([]);
let weekHours = $state<DayWorkingHours[]>([]);
let exceptionalGroups = $state<ExceptionalGroup[]>([]);
let loading = $state(true);
let error = $state(false);
function getLondonDate(): Date {
const londonDateStr = new Date().toLocaleDateString('en-CA', {
timeZone: 'Europe/London'
});
const [y, m, d] = londonDateStr.split('-').map(Number);
return new Date(y, m - 1, d);
}
function getCurrentMonday(): string {
const today = getLondonDate();
const dayOfWeek = today.getDay(); // 0=Sun
const offset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
const monday = new Date(today);
monday.setDate(today.getDate() + offset);
return fmtDate(monday);
}
function addDays(dateStr: string, days: number): string {
const [y, m, d] = dateStr.split('-').map(Number);
const dt = new Date(y, m - 1, d + days);
return fmtDate(dt);
}
function fmtDate(dt: Date): string {
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
}
function formatDayWithDate(dateStr: string, weekday: number): string {
const [y, m, d] = dateStr.split('-').map(Number);
const dt = new Date(y, m - 1, d);
const displayDate = dt.toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
timeZone: 'UTC'
});
return `${DAY_NAMES[weekday]} ${displayDate}`;
}
let timeInterval: ReturnType<typeof setInterval> | undefined;
onMount(() => {
updateLondonTime();
timeInterval = setInterval(updateLondonTime, 30000);
fetchData();
});
onDestroy(() => {
if (timeInterval) clearInterval(timeInterval);
});
async function fetchData() {
const currentMonday = getCurrentMonday();
const weekEnd = addDays(currentMonday, 6);
try {
const [defRes, weekRes, groupsRes] = await Promise.all([
fetch('/api/scheduling/default-hours'),
fetch(`/api/scheduling/working-hours?start=${currentMonday}&end=${weekEnd}`),
fetch('/api/scheduling/exceptional-groups')
]);
if (defRes.ok) defaultHours = await defRes.json();
if (weekRes.ok) weekHours = await weekRes.json();
if (groupsRes.ok) exceptionalGroups = await groupsRes.json();
if (!defRes.ok || !weekRes.ok || !groupsRes.ok) error = true;
} catch {
error = true;
} finally {
loading = false;
}
}
// --- Current week: exceptional or default ---
let hasExceptional = $derived(weekHours.some((d) => d.source === 'exceptional'));
let todayWeekday = $derived.by(() => {
const d = getLondonDate();
const jsDay = d.getDay();
return jsDay === 0 ? 6 : jsDay - 1; // 0=Mon, ..., 6=Sun
});
let todayDateStr = $derived(fmtDate(getLondonDate()));
let londonNowTime = $state('');
function updateLondonTime() {
londonNowTime = new Date().toLocaleTimeString('en-GB', {
timeZone: 'Europe/London',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
}
let isCurrentlyOpen = $derived.by(() => {
if (hasExceptional) {
const today = weekHours.find((d) => d.date === todayDateStr);
if (!today || !today.isOpen) return false;
return londonNowTime >= today.startTime && londonNowTime < today.endTime;
}
const today = defaultHours.find((d) => d.weekday === todayWeekday);
if (!today || !today.isOpen) return false;
return londonNowTime >= today.startTime && londonNowTime < today.endTime;
});
let displayData = $derived(
hasExceptional
? weekHours.map((d) => ({
label: formatDayWithDate(d.date, d.weekday),
isOpen: d.isOpen,
startTime: d.startTime,
endTime: d.endTime,
isToday: d.date === todayDateStr
}))
: defaultHours.map((d) => ({
label: DAY_NAMES[d.weekday],
isOpen: d.isOpen,
startTime: d.startTime,
endTime: d.endTime,
isToday: d.weekday === todayWeekday
}))
);
// --- Upcoming: first exceptional group in the next 3 weeks ---
let upcomingInfo = $derived.by(() => {
const currentMonday = getCurrentMonday();
const nextRangeStart = addDays(currentMonday, 7);
const nextRangeEnd = addDays(currentMonday, 28); // 4 Mondays out = 3 weeks ahead
// Find the earliest exceptional group week_start in the next 3 weeks
let best: { group: ExceptionalGroup; weekStart: string } | null = null;
for (const group of exceptionalGroups) {
for (const ws of group.weekStarts) {
if (ws >= nextRangeStart && ws < nextRangeEnd) {
if (!best || ws < best.weekStart) {
best = { group, weekStart: ws };
}
}
}
}
if (!best) return null;
// Only show days that differ from default hours
const differing = best.group.hours.filter((h) => {
const def = defaultHours.find((d) => d.weekday === h.weekday);
return !def || def.startTime !== h.startTime || def.endTime !== h.endTime || def.isOpen !== h.isOpen;
});
if (differing.length === 0) return null;
// Compute actual dates for this upcoming week
const weekStartDate = best.weekStart;
return {
name: best.group.name,
days: differing.map((h) => {
// Calculate the actual date for this weekday in the given week
const dateStr = addDays(weekStartDate, h.weekday);
return {
label: formatDayWithDate(dateStr, h.weekday),
isOpen: h.isOpen,
startTime: h.startTime,
endTime: h.endTime
};
})
};
});
</script>
<div class="mx-auto w-full max-w-sm lg:h-[420px] lg:max-w-none">
<div class="flex h-full flex-col rounded-lg border-2 border-gray-200 bg-white p-6">
<h3 class="mb-1 text-center text-lg font-semibold text-gray-900">Opening Hours</h3>
{#if hasExceptional}
<p class="mb-3 text-center text-xs font-medium text-amber-600">Holiday Hours</p>
{/if}
{#if loading}
<div class="animate-pulse space-y-3">
{#each { length: 7 } as _}
<div class="flex justify-between">
<div class="h-4 w-20 rounded bg-gray-200"></div>
<div class="h-4 w-24 rounded bg-gray-200"></div>
</div>
{/each}
</div>
{:else if error}
<p class="text-center text-sm text-gray-400">Unable to load opening hours.</p>
{:else}
<div class="flex-1 space-y-2 overflow-y-auto">
{#each displayData as { label, isOpen, startTime, endTime, isToday } (label)}
<div class="flex items-center justify-between text-sm">
<span class="flex items-center gap-2 font-medium text-gray-700">
{#if isToday}
<span
class="inline-block h-2 w-2 rounded-full"
class:bg-green-500={isCurrentlyOpen}
class:bg-red-500={!isCurrentlyOpen}
></span>
{/if}
{label}
</span>
<span class="text-gray-500">
{#if isOpen}
{formatTime(startTime)} {formatTime(endTime)}
{:else}
Closed
{/if}
</span>
</div>
{/each}
{#if upcomingInfo}
<hr class="my-2 border-gray-200" />
<p class="mb-2 text-center text-xs font-medium text-amber-600">Upcoming Holiday Hours</p>
{#each upcomingInfo.days as { label, isOpen, startTime, endTime } (label)}
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-gray-700">{label}</span>
<span class="text-gray-500">
{#if isOpen}
{formatTime(startTime)} {formatTime(endTime)}
{:else}
Closed
{/if}
</span>
</div>
{/each}
{/if}
</div>
{/if}
</div>
</div>
+7 -4
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import BusinessHours from '$lib/components/layout/BusinessHours.svelte';
import ContactCard from '$lib/components/layout/ContactCard.svelte'; import ContactCard from '$lib/components/layout/ContactCard.svelte';
import { Map, MapMarker, MapControls, MarkerContent, MarkerPopup } from '$lib/components/ui/map'; import { Map, MapMarker, MapControls, MarkerContent, MarkerPopup } from '$lib/components/ui/map';
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
@@ -33,11 +34,11 @@
<section class="py-12"> <section class="py-12">
<h1 class="mb-8 text-center font-['Playfair_Display'] text-4xl font-bold">Contact Me</h1> <h1 class="mb-8 text-center font-['Playfair_Display'] text-4xl font-bold">Contact Me</h1>
<div class="mx-auto grid max-w-[744px] gap-6 px-4 lg:grid-cols-2"> <div class="mx-auto grid max-w-[1024px] gap-6 px-4 lg:grid-cols-3">
<div class="h-[376px]"> <div class="lg:h-[420px]">
{#if loading} {#if loading}
<div <div
class="mx-auto max-w-sm animate-pulse rounded-lg border-2 border-gray-200 bg-white p-6" class="mx-auto h-full max-w-sm animate-pulse rounded-lg border-2 border-gray-200 bg-white p-6"
> >
<div class="mb-4 flex justify-center"> <div class="mb-4 flex justify-center">
<div class="h-24 w-24 rounded-full bg-gray-200"></div> <div class="h-24 w-24 rounded-full bg-gray-200"></div>
@@ -70,8 +71,10 @@
{/if} {/if}
</div> </div>
<BusinessHours />
<div <div
class="map-card mx-auto h-[376px] w-full max-w-sm overflow-hidden rounded-lg border-2 border-gray-200 bg-white lg:mx-0 lg:max-w-none" class="map-card mx-auto h-[300px] w-full max-w-sm overflow-hidden rounded-lg border-2 border-gray-200 bg-white lg:mx-0 lg:h-[420px] lg:max-w-none"
> >
<Map theme="light" center={[-3.476464162450991, 56.0781854944036]} zoom={15}> <Map theme="light" center={[-3.476464162450991, 56.0781854944036]} zoom={15}>
<MapMarker longitude={-3.476464162450991} latitude={56.0781854944036}> <MapMarker longitude={-3.476464162450991} latitude={56.0781854944036}>