feat: add BusinessHours component and update contact page
This commit is contained in:
@@ -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>
|
||||||
@@ -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}>
|
||||||
|
|||||||
Reference in New Issue
Block a user