From 74a05253e760c89970bdff6bb45ad4551bcf02e3 Mon Sep 17 00:00:00 2001 From: Robert van Diest Date: Thu, 30 Apr 2026 16:35:06 +0200 Subject: [PATCH] feat(frontend): implement Respellion house-style design --- src/frontend/index.html | 5 +- src/frontend/src/App.tsx | 2 +- src/frontend/src/components/CancelModal.tsx | 75 ++- src/frontend/src/components/Desk.tsx | 140 +++-- src/frontend/src/components/DeskPod.tsx | 82 +-- .../src/components/MyReservations.tsx | 39 +- .../src/components/ReservationModal.tsx | 74 ++- src/frontend/src/index.css | 21 +- src/frontend/src/pages/AdminPage.tsx | 506 ++++++++++++----- src/frontend/src/pages/AuthPage.tsx | 323 ++++++++--- src/frontend/src/pages/PlannerPage.tsx | 507 +++++++++++++++--- 11 files changed, 1357 insertions(+), 417 deletions(-) diff --git a/src/frontend/index.html b/src/frontend/index.html index 0fca6f0..766b360 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -4,7 +4,10 @@ - frontend + randall + + +
diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index f57043b..1cbe74b 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -40,7 +40,7 @@ export default function App() { } /> : } + element={auth.isAdmin ? : } /> } /> diff --git a/src/frontend/src/components/CancelModal.tsx b/src/frontend/src/components/CancelModal.tsx index c7f6267..479a866 100644 --- a/src/frontend/src/components/CancelModal.tsx +++ b/src/frontend/src/components/CancelModal.tsx @@ -7,6 +7,12 @@ interface CancelModalProps { onClose: () => void; } +const PURPLE = '#5b4fc7'; +const PURPLE_DEEP = '#3f33a8'; +const SAGE = '#c7d4b8'; +const SAGE_DEEP = '#a9bb96'; +const PAPER = '#f4f3ee'; + export function CancelModal({ deskName, date, onConfirm, onClose }: CancelModalProps) { const [loading, setLoading] = useState(false); const [error, setError] = useState(''); @@ -24,30 +30,77 @@ export function CancelModal({ deskName, date, onConfirm, onClose }: CancelModalP } return ( -
+
e.stopPropagation()} > -

Cancel reservation

-

- Cancel your booking for {deskName} on{' '} - {date}? -

+
+ Cancel reservation +
+
+ {deskName} +
+
+ {date} +
- {error &&

{error}

} + {error && ( +
+ {error} +
+ )} -
+
diff --git a/src/frontend/src/components/Desk.tsx b/src/frontend/src/components/Desk.tsx index 6482dbe..792e2c0 100644 --- a/src/frontend/src/components/Desk.tsx +++ b/src/frontend/src/components/Desk.tsx @@ -1,72 +1,104 @@ +import { useState } from 'react'; + interface DeskProps { name: string; available: boolean; - reserved: boolean; // reserved by the current user - reservedBy?: string; // name of whoever reserved it (when taken by someone else) + reserved: boolean; + reservedBy?: string; rotate?: 'cw' | 'ccw'; onClick: () => void; } +const PURPLE = '#5b4fc7'; +const PURPLE_DEEP = '#3f33a8'; +const SAGE = '#c7d4b8'; +const SAGE_DEEP = '#a9bb96'; +const PAPER = '#f4f3ee'; + export function Desk({ name, available, reserved, reservedBy, rotate, onClick }: DeskProps) { - let bgColor: string; - let borderColor: string; - let textColor: string; - let deskColor: string; - let cursor: string; - let title: string; + const [hover, setHover] = useState(false); - if (reserved) { - bgColor = 'bg-blue-50'; - borderColor = 'border-blue-400'; - textColor = 'text-blue-700'; - deskColor = '#93c5fd'; - cursor = 'cursor-pointer hover:bg-blue-100'; - title = 'Your reservation — click to cancel'; - } else if (available) { - bgColor = 'bg-emerald-50'; - borderColor = 'border-emerald-400'; - textColor = 'text-emerald-700'; - deskColor = '#6ee7b7'; - cursor = 'cursor-pointer hover:bg-emerald-100 hover:scale-105'; - title = `Reserve ${name}`; - } else { - bgColor = 'bg-slate-50'; - borderColor = 'border-slate-300'; - textColor = 'text-slate-500'; - deskColor = '#cbd5e1'; - cursor = 'cursor-default'; - title = reservedBy ? `Reserved by ${reservedBy}` : `${name} is taken`; - } + const isMine = reserved; + const isFree = !reserved && available; + const isTaken = !reserved && !available; - // Truncate long names to fit the tile - const displayName = reservedBy && reservedBy.length > 7 - ? reservedBy.slice(0, 6) + '…' - : reservedBy; + const svgFill = isMine ? PURPLE_DEEP : isFree ? PURPLE : 'rgba(91,79,199,0.25)'; + + const displayLabel = isFree + ? 'free' + : isMine + ? 'yours' + : reservedBy + ? reservedBy.length > 7 + ? reservedBy.slice(0, 6) + '…' + : reservedBy + : 'taken'; return ( ); } diff --git a/src/frontend/src/components/DeskPod.tsx b/src/frontend/src/components/DeskPod.tsx index f6c7bc8..67aaa02 100644 --- a/src/frontend/src/components/DeskPod.tsx +++ b/src/frontend/src/components/DeskPod.tsx @@ -9,48 +9,64 @@ interface ScheduleItem { } interface DeskPodProps { + label: string; desks: ScheduleItem[]; myReservedIds: Set; onDeskClick: (desk: ScheduleItem) => void; } -export function DeskPod({ desks, myReservedIds, onDeskClick }: DeskPodProps) { +export function DeskPod({ label, desks, myReservedIds, onDeskClick }: DeskPodProps) { const left = [...desks.slice(0, 4)].reverse(); const right = [...desks.slice(4, 8)].reverse(); return ( -
-
-
-
- {left.map((desk) => ( - onDeskClick(desk)} - /> - ))} -
- -
- -
- {right.map((desk) => ( - onDeskClick(desk)} - /> - ))} -
+
+
+ {label} +
+
+
+ {left.map((desk) => ( + onDeskClick(desk)} + /> + ))} +
+
+
+ {right.map((desk) => ( + onDeskClick(desk)} + /> + ))}
diff --git a/src/frontend/src/components/MyReservations.tsx b/src/frontend/src/components/MyReservations.tsx index 5661add..fd19d21 100644 --- a/src/frontend/src/components/MyReservations.tsx +++ b/src/frontend/src/components/MyReservations.tsx @@ -5,6 +5,11 @@ interface MyReservationsProps { onCancel: (reservation: Reservation) => void; } +const PURPLE = '#5b4fc7'; +const PURPLE_DEEP = '#3f33a8'; +const SAGE = '#c7d4b8'; +const SAGE_DEEP = '#a9bb96'; + export function MyReservations({ reservations, onCancel }: MyReservationsProps) { const upcoming = reservations .filter((r) => r.status === 'Active') @@ -12,24 +17,46 @@ export function MyReservations({ reservations, onCancel }: MyReservationsProps) if (upcoming.length === 0) { return ( -

No upcoming reservations.

+

+ No upcoming reservations. +

); } return ( -
    +
      {upcoming.map((r) => (
    • - {r.workplaceName} -
      {r.date}
      +
      + {r.workplaceName} +
      +
      + {r.date} +
      diff --git a/src/frontend/src/components/ReservationModal.tsx b/src/frontend/src/components/ReservationModal.tsx index ef1dfe8..c9703b2 100644 --- a/src/frontend/src/components/ReservationModal.tsx +++ b/src/frontend/src/components/ReservationModal.tsx @@ -7,6 +7,12 @@ interface ReservationModalProps { onClose: () => void; } +const PURPLE = '#5b4fc7'; +const PURPLE_DEEP = '#3f33a8'; +const SAGE = '#c7d4b8'; +const SAGE_DEEP = '#a9bb96'; +const PAPER = '#f4f3ee'; + export function ReservationModal({ deskName, date, onConfirm, onClose }: ReservationModalProps) { const [error, setError] = useState(''); const [loading, setLoading] = useState(false); @@ -24,29 +30,77 @@ export function ReservationModal({ deskName, date, onConfirm, onClose }: Reserva } return ( -
      +
      e.stopPropagation()} > -

      Reserve desk

      -

      - {deskName} — {date} -

      +
      + Reserve desk +
      +
      + {deskName} +
      +
      + {date} +
      - {error &&

      {error}

      } + {error && ( +
      + {error} +
      + )} -
      +
      diff --git a/src/frontend/src/index.css b/src/frontend/src/index.css index 55a54fa..a75f3fd 100644 --- a/src/frontend/src/index.css +++ b/src/frontend/src/index.css @@ -1,12 +1,27 @@ @import "tailwindcss"; +:root { + --bg: #e6e7e0; + --paper: #f4f3ee; + --ink: #2a1f6b; + --purple: #5b4fc7; + --purple-deep: #3f33a8; + --sage: #c7d4b8; + --sage-deep: #a9bb96; +} + *, *::before, *::after { box-sizing: border-box; } body { margin: 0; - font-family: system-ui, 'Segoe UI', Roboto, sans-serif; - background-color: #f8fafc; - color: #0f172a; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + background-color: var(--bg); + color: var(--ink); +} + +button:focus-visible { + outline: 2px solid var(--purple); + outline-offset: 2px; } diff --git a/src/frontend/src/pages/AdminPage.tsx b/src/frontend/src/pages/AdminPage.tsx index 4e14c8c..ae3fce1 100644 --- a/src/frontend/src/pages/AdminPage.tsx +++ b/src/frontend/src/pages/AdminPage.tsx @@ -1,13 +1,29 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { api } from '../api/client'; -import type { AdminUser } from '../api/types'; +import type { AdminUser, AuthResponse } from '../api/types'; -export function AdminPage() { +const PURPLE = '#5b4fc7'; +const PURPLE_DEEP = '#3f33a8'; +const SAGE = '#c7d4b8'; +const SAGE_DEEP = '#a9bb96'; +const PAPER = '#f4f3ee'; + +interface AdminPageProps { + auth: AuthResponse; + onLogout: () => void; +} + +function monogram(name: string): string { + return name.split(' ').map((n) => n[0]).join('').slice(0, 2).toUpperCase(); +} + +export function AdminPage({ auth, onLogout }: AdminPageProps) { const navigate = useNavigate(); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); + const [search, setSearch] = useState(''); const [approvingId, setApprovingId] = useState(null); const [makingAdminId, setMakingAdminId] = useState(null); const [deletingId, setDeletingId] = useState(null); @@ -44,7 +60,7 @@ export function AdminPage() { setMakingAdminId(id); try { await api.makeAdmin(id); - setUsers((prev) => prev.filter((u) => u.id !== id)); + setUsers((prev) => prev.map((u) => u.id === id ? { ...u, isAdmin: true } : u)); setConfirmAdminId(null); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to make user admin'); @@ -66,147 +82,363 @@ export function AdminPage() { } } - const pending = users.filter((u) => !u.isApproved); - const approved = users.filter((u) => u.isApproved); + const filtered = users.filter((u) => { + const q = search.toLowerCase(); + return !q || u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q); + }); - function UserRow({ user }: { user: AdminUser }) { - const busy = approvingId === user.id || makingAdminId === user.id || deletingId === user.id; + const totalUsers = users.length; + const approvedCount = users.filter((u) => u.isApproved).length; + const adminCount = users.filter((u) => u.isAdmin).length; + const pendingCount = users.filter((u) => !u.isApproved).length; - return ( -
    • -
      -
      -

      {user.name}

      - {user.isAdmin && ( - - Admin - - )} -
      -

      {user.email}

      -
      -
      - {!user.isApproved && ( - - )} + const kpis = [ + { label: 'Total users', value: String(totalUsers).padStart(2, '0') }, + { label: 'Approved', value: String(approvedCount).padStart(2, '0') }, + { label: 'Admins', value: String(adminCount).padStart(2, '0') }, + { label: 'Pending', value: String(pendingCount).padStart(2, '0') }, + ]; - {!user.isAdmin && confirmAdminId === user.id ? ( - <> - Make admin? - - - - ) : ( - !user.isAdmin && ( - - ) - )} - - {confirmDeleteId === user.id ? ( - <> - Sure? - - - - ) : ( - - )} -
      -
    • - ); - } + const TABLE_COLS = '2fr 2fr 1fr 1fr auto'; return ( -
      -
      -
      -
      -

      Admin Portal

      -

      Manage user accounts

      +
      +
      + {/* Header */} +
      + {/* Wordmark + Admin badge */} +
      +
      navigate('/')} + style={{ + display: 'flex', alignItems: 'center', gap: 1, + color: PURPLE, + fontFamily: "'Rubik Mono One', monospace", + fontSize: 18, letterSpacing: '-0.02em', cursor: 'pointer', + }} + > + {'{'} + randall + {'}'} +
      + + Admin + +
      + + {/* Nav */} +
      + Users + +
      +
      + + {/* Body */} +
      + {/* Hero */} +
      +
      + Users · The Hague HQ +
      +

      + Who can book? +

      +
      + + {/* KPI strip */} + {!loading && ( +
      + {kpis.map((k) => ( +
      +
      + {k.label} +
      +
      + {k.value} +
      +
      + ))} +
      + )} + + {/* Users table card */} +
      + {/* Toolbar */} +
      +
      + + setSearch(e.target.value)} + style={{ + background: 'none', border: 'none', outline: 'none', + fontSize: 13, color: PURPLE, fontFamily: 'inherit', + flex: 1, opacity: search ? 1 : 0.6, + }} + /> +
      + +
      + + {/* Table head */} +
      + Name + Email + Role + Status + Actions +
      + + {/* Table rows */} +
      + {loading && ( +

      + Loading… +

      + )} + {error && ( +

      + {error} +

      + )} + {!loading && filtered.map((u, i) => { + const busy = approvingId === u.id || makingAdminId === u.id || deletingId === u.id; + const isLastRow = i === filtered.length - 1; + + return ( +
      + {/* Name */} + + + {monogram(u.name)} + + {u.name} + + + {/* Email */} + {u.email} + + {/* Role badge */} + + + {u.isAdmin ? 'admin' : 'employee'} + + + + {/* Status */} + + + {u.isApproved ? 'Active' : 'Pending'} + + + {/* Actions */} + + {!u.isApproved && ( + confirmAdminId !== u.id && confirmDeleteId !== u.id && ( + + ) + )} + + {confirmAdminId === u.id ? ( + <> + Make admin? + + + + ) : confirmDeleteId === u.id ? ( + <> + Delete? + + + + ) : ( + <> + {!u.isAdmin && ( + + )} + + + )} + +
      + ); + })} + {!loading && filtered.length === 0 && !error && ( +

      + {search ? 'No users match your search.' : 'No users found.'} +

      + )} +
      -
      -
      - -
      - {loading &&

      Loading…

      } - {error &&

      {error}

      } - - {!loading && ( - <> -
      -

      - Pending approval -

      - {pending.length === 0 ? ( -

      No pending accounts.

      - ) : ( -
        - {pending.map((u) => )} -
      - )} -
      - -
      -

      - Approved accounts -

      - {approved.length === 0 ? ( -

      No approved accounts yet.

      - ) : ( -
        - {approved.map((u) => )} -
      - )} -
      - - )} -
      +
); } diff --git a/src/frontend/src/pages/AuthPage.tsx b/src/frontend/src/pages/AuthPage.tsx index 493422b..3359240 100644 --- a/src/frontend/src/pages/AuthPage.tsx +++ b/src/frontend/src/pages/AuthPage.tsx @@ -6,6 +6,34 @@ interface AuthPageProps { onAuth: (auth: AuthResponse) => void; } +const PURPLE = '#5b4fc7'; +const PURPLE_DEEP = '#3f33a8'; +const SAGE = '#c7d4b8'; +const SAGE_DEEP = '#a9bb96'; +const PAPER = '#f4f3ee'; + +const inputStyle: React.CSSProperties = { + padding: '12px 14px', + borderRadius: 12, + border: '1px solid rgba(91,79,199,0.18)', + background: PAPER, + color: PURPLE_DEEP, + fontSize: 14, + fontFamily: 'inherit', + outline: 'none', + width: '100%', + boxSizing: 'border-box', +}; + +const fieldLabelStyle: React.CSSProperties = { + fontSize: 11, + letterSpacing: '0.14em', + textTransform: 'uppercase', + color: PURPLE, + opacity: 0.7, + fontWeight: 500, +}; + export function AuthPage({ onAuth }: AuthPageProps) { const [mode, setMode] = useState<'login' | 'register' | 'pending'>('login'); const [email, setEmail] = useState(''); @@ -14,7 +42,7 @@ export function AuthPage({ onAuth }: AuthPageProps) { const [error, setError] = useState(''); const [loading, setLoading] = useState(false); - async function handleSubmit(e: { preventDefault: () => void }) { + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(''); setLoading(true); @@ -34,101 +62,220 @@ export function AuthPage({ onAuth }: AuthPageProps) { } return ( -
-
-
-

Office Planner

-

Reserve your workspace

+
+
+ {/* Wordmark */} +
+ {'{'} + randall + {'}'}
- {mode === 'pending' && ( -
-
-

Account pending approval

-

- Your account has been created. An administrator will review and approve it shortly. -

- -
- )} - - {mode !== 'pending' &&
-
- - + {/* Left editorial column */} +
+
+ The Hague HQ · Sign in
-
- {mode === 'register' && ( -
- - setName(e.target.value)} - placeholder="Jane Smith" - className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-400" - /> +

+ Find your desk. +

+ +

+ Reserve a desk for today or up to two weeks ahead. Sign in with your Randall email. +

+
+ + {/* Right card column */} +
+ {mode === 'pending' ? ( +
+
+ Account pending
- )} - -
- - setEmail(e.target.value)} - placeholder="jane@company.com" - className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-400" - /> +
+ Almost there. +
+

+ Your account has been created. An administrator will review and approve it shortly. +

+
+ ) : ( +
+ {/* Tab switcher */} +
+ {(['login', 'register'] as const).map((m) => ( + + ))} +
-
- - setPassword(e.target.value)} - placeholder="••••••••" - className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-400" - /> + + + + + + {mode === 'register' && ( + + )} + + {error && ( +
+ {error} +
+ )} + + + + +
+ {mode === 'login' ? ( + <>No account?{' '} + { setMode('register'); setError(''); }} + style={{ color: PURPLE, cursor: 'pointer', textDecoration: 'underline' }} + > + Register + + + ) : ( + <>Have one?{' '} + { setMode('login'); setError(''); }} + style={{ color: PURPLE, cursor: 'pointer', textDecoration: 'underline' }} + > + Sign in + + + )} +
- - {error && ( -

{error}

- )} - - - -
} + )} +
); diff --git a/src/frontend/src/pages/PlannerPage.tsx b/src/frontend/src/pages/PlannerPage.tsx index b7f65ef..036f77b 100644 --- a/src/frontend/src/pages/PlannerPage.tsx +++ b/src/frontend/src/pages/PlannerPage.tsx @@ -7,6 +7,12 @@ import { ReservationModal } from '../components/ReservationModal'; import { CancelModal } from '../components/CancelModal'; import { MyReservations } from '../components/MyReservations'; +const PURPLE = '#5b4fc7'; +const PURPLE_DEEP = '#3f33a8'; +const SAGE = '#c7d4b8'; +const SAGE_DEEP = '#a9bb96'; +const PAPER = '#f4f3ee'; + function toIsoDate(date: Date): string { const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); @@ -25,6 +31,49 @@ function formatDisplayDate(isoDate: string): string { }); } +function formatKickerDate(isoDate: string): string { + const [y, m, d] = isoDate.split('-').map(Number); + const date = new Date(y, m - 1, d); + const weekday = date.toLocaleDateString('en-GB', { weekday: 'short' }).toUpperCase(); + const month = date.toLocaleDateString('en-GB', { month: 'short' }).toUpperCase(); + return `${weekday}, ${d} ${month} · THE HAGUE HQ`; +} + +function formatRelativeTime(isoString: string): string { + const created = new Date(isoString); + const now = new Date(); + const mins = Math.floor((now.getTime() - created.getTime()) / 60000); + if (mins < 1) return 'Just now'; + if (mins < 60) return `${mins} min ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + return 'Earlier today'; +} + +function getDayOffset(isoDate: string, isoToday: string): number { + const [y1, m1, d1] = isoDate.split('-').map(Number); + const [y2, m2, d2] = isoToday.split('-').map(Number); + const a = new Date(y1, m1 - 1, d1).getTime(); + const b = new Date(y2, m2 - 1, d2).getTime(); + return Math.round((a - b) / (1000 * 60 * 60 * 24)); +} + +function getTrailingWord(dayOffset: number): string { + if (dayOffset === 0) return 'today?'; + if (dayOffset === 1) return 'tomorrow?'; + return 'then?'; +} + +function formatDayCell(isoDate: string): { weekday: string; day: number; monthTag: string | null } { + const [y, m, d] = isoDate.split('-').map(Number); + const date = new Date(y, m - 1, d); + const weekday = date.toLocaleDateString('en-GB', { weekday: 'short' }).toUpperCase().slice(0, 3); + const monthTag = d === 1 + ? date.toLocaleDateString('en-GB', { month: 'short' }).toUpperCase() + : null; + return { weekday, day: d, monthTag }; +} + interface PlannerPageProps { auth: AuthResponse; onLogout: () => void; @@ -34,7 +83,8 @@ export function PlannerPage({ auth, onLogout }: PlannerPageProps) { const navigate = useNavigate(); const today = toIsoDate(new Date()); - const maxDate = toIsoDate(new Date(Date.now() + 14 * 24 * 60 * 60 * 1000)); + const MAX_OFFSET = 13; + const maxDate = offsetDate(today, MAX_OFFSET); const [selectedDate, setSelectedDate] = useState(today); const [schedule, setSchedule] = useState([]); @@ -65,6 +115,30 @@ export function PlannerPage({ auth, onLogout }: PlannerPageProps) { .map((r) => r.workplaceId), ); + const myDeskOnDate = myReservations.find( + (r) => r.status === 'Active' && r.date === selectedDate, + ); + const myDeskSchedule = myDeskOnDate + ? schedule.find((w) => w.id === myDeskOnDate.workplaceId) + : null; + const neighbours = myDeskSchedule + ? schedule + .filter( + (w) => + w.location === myDeskSchedule.location && + !w.isAvailable && + w.id !== myDeskSchedule.id && + w.reservedBy !== null, + ) + .map((w) => w.reservedBy!.split(' ')[0]) + .join(', ') + : ''; + + const freeCount = schedule.filter((w) => w.isAvailable).length; + const dayOffset = getDayOffset(selectedDate, today); + const trailingWord = getTrailingWord(dayOffset); + const dayStrip = Array.from({ length: 14 }, (_, i) => offsetDate(today, i)); + function handleDeskClick(desk: WorkplaceScheduleItem) { if (myReservedIdsOnDate.has(desk.id)) { const res = myReservations.find( @@ -93,93 +167,380 @@ export function PlannerPage({ auth, onLogout }: PlannerPageProps) { const podA = schedule.filter((w) => w.location === 'Pod A'); const podB = schedule.filter((w) => w.location === 'Pod B'); + const arrowBtnStyle = (disabled: boolean): React.CSSProperties => ({ + background: 'none', + border: 'none', + cursor: disabled ? 'default' : 'pointer', + color: PURPLE, + opacity: disabled ? 0.35 : 0.65, + fontSize: 16, + padding: '0 4px', + fontFamily: 'inherit', + flexShrink: 0, + lineHeight: 1, + }); + return ( -
-
-
-
-

Office Planner

-

Reserve your workspace up to 2 weeks ahead

+ /* Outer bg fills the full viewport */ +
+ {/* Centred 1100 px frame */} +
+ {/* Header */} +
+ {/* Wordmark */} +
+ {'{'} + randall + {'}'}
-
- {auth.name} + + {/* Nav */} +
+ Today {auth.isAdmin && ( )} + NL ▾ +
+
+ + {/* Body */} +
+ {/* Left column */} +
+ {/* Editorial hero */} +
+ {/* Kicker */} +
+ {formatKickerDate(selectedDate)} +
+ + {/* Headline */} +

+ Where to sit{' '} + {trailingWord} +

+ + {/* Body copy */} +

+ {loadingFloor + ? 'Loading floor plan…' + : floorError + ? floorError + : myDeskOnDate + ? `${freeCount} ${freeCount === 1 ? 'desk is' : 'desks are'} free, you're holding ${myDeskOnDate.workplaceName}. Pick another or swap with a teammate.` + : `${freeCount} ${freeCount === 1 ? 'desk is' : 'desks are'} free. Pick one to hold your spot.`} +

+ + {/* 14-day date strip */} +
+ +
+ {dayStrip.map((dateIso) => { + const isActive = dateIso === selectedDate; + const { weekday, day, monthTag } = formatDayCell(dateIso); + return ( + + ); + })} +
+ +
+
+ + {/* Floor card */} +
+ {/* Legend */} +
+ + + free + + + + yours + + + + taken + +
+ + {/* Pods */} + {!loadingFloor && !floorError && ( +
+ + +
+ )} + {loadingFloor && ( +

+ Loading… +

+ )} + {floorError && ( +

+ {floorError} +

+ )} +
+
+ + {/* Right rail */} +
+ {/* Your desk card */} +
+
+ Your desk +
+ +
+ {myDeskOnDate?.workplaceName ?? '—'} +
+ +
+ {myDeskOnDate + ? `${myDeskOnDate.workplaceLocation} · Held ${formatRelativeTime(myDeskOnDate.createdAt)}` + : 'No desk held for this day'} +
+ +
+
+ Date + {formatDisplayDate(selectedDate)} +
+ {myDeskOnDate && ( +
+ Neighbours + + {neighbours || '—'} + +
+ )} +
+ Streak + +
+
+ +
+ + {/* My reservations card */} +
+
+ My reservations +
+ +
-
- -
-
- - - setSelectedDate(e.target.value)} - className="border border-slate-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-400 bg-white" - /> - - {formatDisplayDate(selectedDate)} -
- -
- - - Available — click to reserve - - - - Your reservation — click to cancel - - - - Taken — hover to see who - -
- -
- {loadingFloor &&

Loading floor plan…

} - {floorError &&

{floorError}

} - {!loadingFloor && !floorError && ( -
- - -
- )} -
- -
-

My reservations

- -
-
+
{reserveTarget && (