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">
|
||||
import BusinessHours from '$lib/components/layout/BusinessHours.svelte';
|
||||
import ContactCard from '$lib/components/layout/ContactCard.svelte';
|
||||
import { Map, MapMarker, MapControls, MarkerContent, MarkerPopup } from '$lib/components/ui/map';
|
||||
import { onMount, tick } from 'svelte';
|
||||
@@ -33,11 +34,11 @@
|
||||
|
||||
<section class="py-12">
|
||||
<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="h-[376px]">
|
||||
<div class="mx-auto grid max-w-[1024px] gap-6 px-4 lg:grid-cols-3">
|
||||
<div class="lg:h-[420px]">
|
||||
{#if loading}
|
||||
<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="h-24 w-24 rounded-full bg-gray-200"></div>
|
||||
@@ -70,8 +71,10 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<BusinessHours />
|
||||
|
||||
<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}>
|
||||
<MapMarker longitude={-3.476464162450991} latitude={56.0781854944036}>
|
||||
|
||||
Reference in New Issue
Block a user