feat(frontend): implement Respellion house-style design

This commit is contained in:
2026-04-30 16:35:06 +02:00
parent 4468e7b891
commit 74a05253e7
11 changed files with 1357 additions and 417 deletions

View File

@@ -4,7 +4,10 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<title>randall</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Rubik+Mono+One&family=Inter:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>

View File

@@ -40,7 +40,7 @@ export default function App() {
<Route path="/" element={<PlannerPage auth={auth} onLogout={handleLogout} />} />
<Route
path="/admin"
element={auth.isAdmin ? <AdminPage /> : <Navigate to="/" replace />}
element={auth.isAdmin ? <AdminPage auth={auth} onLogout={handleLogout} /> : <Navigate to="/" replace />}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@@ -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 (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<div
style={{
position: 'fixed', inset: 0,
background: 'rgba(42,31,107,0.35)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 50,
}}
onClick={onClose}
>
<div
className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-md mx-4"
role="dialog"
aria-modal="true"
style={{
background: PAPER, borderRadius: 18, padding: 32,
width: '100%', maxWidth: 380, margin: '0 16px',
border: '1px solid rgba(91,79,199,0.12)',
boxShadow: '0 20px 60px -12px rgba(42,31,107,0.25)',
}}
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-xl font-semibold text-slate-800 mb-1">Cancel reservation</h2>
<p className="text-sm text-slate-500 mb-6">
Cancel your booking for <span className="font-medium text-slate-700">{deskName}</span> on{' '}
<span className="font-medium text-slate-700">{date}</span>?
</p>
<div style={{
fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase',
color: PURPLE, opacity: 0.7, marginBottom: 8, fontWeight: 500,
}}>
Cancel reservation
</div>
<div style={{
fontFamily: "'Rubik Mono One', monospace",
fontSize: 36, letterSpacing: '-0.03em', color: PURPLE,
lineHeight: 0.95, marginBottom: 6,
}}>
{deskName}
</div>
<div style={{ fontSize: 13, color: PURPLE, opacity: 0.65, marginBottom: 24 }}>
{date}
</div>
{error && <p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg mb-4">{error}</p>}
{error && (
<div style={{
fontSize: 13, color: '#c0392b',
background: 'rgba(192,57,43,0.08)',
borderRadius: 8, padding: '10px 14px',
marginBottom: 18,
}}>
{error}
</div>
)}
<div className="flex gap-3">
<div style={{ display: 'flex', gap: 10 }}>
<button
onClick={onClose}
className="flex-1 px-4 py-2 text-sm font-medium text-slate-600 bg-slate-100 rounded-lg hover:bg-slate-200 transition-colors"
style={{
flex: 1, padding: '11px 16px', borderRadius: 99,
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
color: PURPLE_DEEP, fontSize: 13, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
Keep it
</button>
<button
onClick={handleConfirm}
disabled={loading}
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-red-500 rounded-lg hover:bg-red-600 disabled:opacity-50 transition-colors"
style={{
flex: 1, padding: '11px 16px', borderRadius: 99,
background: PURPLE_DEEP, border: `1.5px solid rgba(42,31,107,0.8)`,
color: '#fff', fontSize: 13, fontWeight: 500,
cursor: loading ? 'default' : 'pointer',
fontFamily: 'inherit',
opacity: loading ? 0.6 : 1,
}}
>
{loading ? 'Cancelling…' : 'Cancel reservation'}
</button>

View File

@@ -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 (
<button
className={`flex flex-col items-center justify-center w-20 rounded-xl border-2 font-semibold transition-all select-none py-2 px-1 gap-0.5 ${bgColor} ${borderColor} ${textColor} ${cursor}`}
onClick={available || reserved ? onClick : undefined}
title={title}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
onClick={isTaken ? undefined : onClick}
title={
isMine
? `${name} — your reservation (click to cancel)`
: isFree
? `Reserve ${name}`
: reservedBy
? `Reserved by ${reservedBy}`
: `${name} is taken`
}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: 74,
height: 80,
padding: '4px',
gap: 1,
borderRadius: 12,
background: isMine ? SAGE : isFree ? PAPER : 'transparent',
border: isMine
? `1.5px solid ${SAGE_DEEP}`
: isFree
? '1px solid rgba(91,79,199,0.18)'
: '1px dashed rgba(91,79,199,0.18)',
color: isMine ? PURPLE_DEEP : isFree ? PURPLE : 'rgba(91,79,199,0.45)',
cursor: isTaken ? 'default' : 'pointer',
boxShadow: isMine
? '0 6px 16px -8px rgba(91,79,199,0.25)'
: hover && isFree
? '0 8px 20px -10px rgba(91,79,199,0.20)'
: 'none',
transform: hover && !isTaken ? 'translateY(-2px)' : 'translateY(0)',
transition: 'transform 220ms cubic-bezier(0.34,1.56,0.64,1), box-shadow 200ms, border-color 160ms',
fontFamily: 'inherit',
outline: 'none',
}}
>
{/* Top-down desk: rectangular surface + screen stripe */}
<svg viewBox="0 0 48 48" className={`w-10 h-10 ${rotate === 'cw' ? 'rotate-90' : rotate === 'ccw' ? '-rotate-90' : ''}`} fill="none" aria-hidden="true">
{/* Desk surface (wide rectangle, ~1.6:1 ratio) */}
<rect x="2" y="10" width="44" height="28" rx="2.5" fill={deskColor} />
{/* Screen stripe — centered, ~30% of desk width, small margin from back edge */}
<rect x="17" y="13" width="14" height="5" rx="1.5" fill="#1e293b" />
{/* Chair — small square in front of desk */}
<rect x="18" y="41" width="12" height="8" rx="2" fill={deskColor} />
<svg
viewBox="0 0 48 48"
width={28}
height={28}
fill="none"
aria-hidden="true"
style={{
transform: rotate === 'cw' ? 'rotate(90deg)' : 'rotate(-90deg)',
transition: 'transform 200ms',
flexShrink: 0,
}}
>
<rect x="2" y="10" width="44" height="28" rx="2.5" fill={svgFill} />
<rect x="17" y="13" width="14" height="5" rx="1.5" fill={PURPLE_DEEP} opacity="0.7" />
<rect x="18" y="41" width="12" height="8" rx="2" fill={svgFill} />
</svg>
<span className="text-xs font-semibold leading-none">{name}</span>
{reserved && <span className="text-[10px] opacity-75 leading-none">Mine</span>}
{available && <span className="text-[10px] opacity-75 leading-none">Free</span>}
{!reserved && !available && (
<span className="text-[10px] opacity-75 leading-tight text-center px-0.5">
{displayName ?? 'Taken'}
</span>
)}
<span style={{ fontSize: 11, fontWeight: 600, letterSpacing: '-0.01em', lineHeight: 1 }}>
{name}
</span>
<span style={{ fontSize: 9, opacity: 0.7, fontWeight: 500, lineHeight: 1.1, textAlign: 'center' }}>
{displayLabel}
</span>
</button>
);
}

View File

@@ -9,48 +9,64 @@ interface ScheduleItem {
}
interface DeskPodProps {
label: string;
desks: ScheduleItem[];
myReservedIds: Set<string>;
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 (
<div className="flex flex-col items-center gap-2">
<div className="bg-white border-2 border-slate-200 rounded-2xl p-5 shadow-sm">
<div className="flex gap-6">
<div className="flex flex-col gap-3">
{left.map((desk) => (
<Desk
key={desk.id}
name={desk.name}
available={desk.isAvailable}
reserved={myReservedIds.has(desk.id)}
reservedBy={desk.reservedBy ?? undefined}
rotate="cw"
onClick={() => onDeskClick(desk)}
/>
))}
</div>
<div className="w-px bg-slate-100" />
<div className="flex flex-col gap-3">
{right.map((desk) => (
<Desk
key={desk.id}
name={desk.name}
available={desk.isAvailable}
reserved={myReservedIds.has(desk.id)}
reservedBy={desk.reservedBy ?? undefined}
rotate="ccw"
onClick={() => onDeskClick(desk)}
/>
))}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, flex: '0 0 auto' }}>
<div style={{
fontSize: 10,
letterSpacing: '0.18em',
textTransform: 'uppercase',
color: 'var(--purple)',
opacity: 0.6,
fontWeight: 500,
}}>
{label}
</div>
<div style={{
display: 'inline-flex',
gap: 10,
padding: '10px',
background: 'rgba(255,255,255,0.4)',
borderRadius: 14,
border: '1px solid rgba(91,79,199,0.08)',
width: 'fit-content',
alignSelf: 'flex-start',
}}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{left.map((desk) => (
<Desk
key={desk.id}
name={desk.name}
available={desk.isAvailable}
reserved={myReservedIds.has(desk.id)}
reservedBy={desk.reservedBy ?? undefined}
rotate="cw"
onClick={() => onDeskClick(desk)}
/>
))}
</div>
<div style={{ width: 1, background: 'rgba(91,79,199,0.10)', alignSelf: 'stretch' }} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{right.map((desk) => (
<Desk
key={desk.id}
name={desk.name}
available={desk.isAvailable}
reserved={myReservedIds.has(desk.id)}
reservedBy={desk.reservedBy ?? undefined}
rotate="ccw"
onClick={() => onDeskClick(desk)}
/>
))}
</div>
</div>
</div>

View File

@@ -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 (
<p className="text-sm text-slate-400 text-center py-4">No upcoming reservations.</p>
<p style={{ fontSize: 13, color: PURPLE, opacity: 0.4, textAlign: 'center', padding: '8px 0', margin: 0 }}>
No upcoming reservations.
</p>
);
}
return (
<ul className="flex flex-col gap-2">
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
{upcoming.map((r) => (
<li
key={r.id}
className="flex items-center justify-between bg-white border border-slate-200 rounded-xl px-4 py-3"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 0',
borderBottom: '1px solid rgba(91,79,199,0.08)',
}}
>
<div>
<span className="font-medium text-slate-800">{r.workplaceName}</span>
<div className="text-xs text-slate-400 mt-0.5">{r.date}</div>
<div style={{ fontSize: 13, fontWeight: 600, color: PURPLE, lineHeight: 1 }}>
{r.workplaceName}
</div>
<div style={{ fontSize: 11, color: PURPLE, opacity: 0.55, marginTop: 3 }}>
{r.date}
</div>
</div>
<button
onClick={() => onCancel(r)}
className="text-xs text-red-500 hover:text-red-700 font-medium px-3 py-1 rounded-lg hover:bg-red-50 transition-colors"
style={{
padding: '5px 12px',
borderRadius: 99,
background: SAGE,
border: `1px solid ${SAGE_DEEP}`,
color: PURPLE_DEEP,
fontSize: 11,
fontWeight: 500,
cursor: 'pointer',
fontFamily: 'inherit',
}}
>
Cancel
</button>

View File

@@ -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 (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<div
style={{
position: 'fixed', inset: 0,
background: 'rgba(42,31,107,0.35)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 50,
}}
onClick={onClose}
>
<div
className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-sm mx-4"
role="dialog"
aria-modal="true"
style={{
background: PAPER, borderRadius: 18, padding: 32,
width: '100%', maxWidth: 360, margin: '0 16px',
border: '1px solid rgba(91,79,199,0.12)',
boxShadow: '0 20px 60px -12px rgba(42,31,107,0.25)',
}}
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-xl font-semibold text-slate-800 mb-1">Reserve desk</h2>
<p className="text-sm text-slate-500 mb-6">
<span className="font-medium text-slate-700">{deskName}</span> &mdash; {date}
</p>
<div style={{
fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase',
color: PURPLE, opacity: 0.7, marginBottom: 8, fontWeight: 500,
}}>
Reserve desk
</div>
<div style={{
fontFamily: "'Rubik Mono One', monospace",
fontSize: 36, letterSpacing: '-0.03em', color: PURPLE,
lineHeight: 0.95, marginBottom: 6,
}}>
{deskName}
</div>
<div style={{ fontSize: 13, color: PURPLE, opacity: 0.65, marginBottom: 24 }}>
{date}
</div>
{error && <p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg mb-4">{error}</p>}
{error && (
<div style={{
fontSize: 13, color: '#c0392b',
background: 'rgba(192,57,43,0.08)',
borderRadius: 8, padding: '10px 14px',
marginBottom: 18,
}}>
{error}
</div>
)}
<div className="flex gap-3">
<div style={{ display: 'flex', gap: 10 }}>
<button
onClick={onClose}
className="flex-1 px-4 py-2 text-sm font-medium text-slate-600 bg-slate-100 rounded-lg hover:bg-slate-200 transition-colors"
style={{
flex: 1, padding: '11px 16px', borderRadius: 99,
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
color: PURPLE_DEEP, fontSize: 13, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={loading}
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-emerald-500 rounded-lg hover:bg-emerald-600 disabled:opacity-50 transition-colors"
style={{
flex: 1, padding: '11px 16px', borderRadius: 99,
background: PURPLE, border: `1.5px solid ${PURPLE_DEEP}`,
color: '#fff', fontSize: 13, fontWeight: 500,
cursor: loading ? 'default' : 'pointer',
fontFamily: 'inherit',
opacity: loading ? 0.6 : 1,
}}
>
{loading ? 'Reserving…' : 'Confirm'}
</button>

View File

@@ -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;
}

View File

@@ -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<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [search, setSearch] = useState('');
const [approvingId, setApprovingId] = useState<string | null>(null);
const [makingAdminId, setMakingAdminId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(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 (
<li className="flex items-center justify-between bg-white border border-slate-200 rounded-xl px-5 py-4 shadow-sm">
<div>
<div className="flex items-center gap-2">
<p className="font-medium text-slate-800">{user.name}</p>
{user.isAdmin && (
<span className="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-amber-100 text-amber-700">
Admin
</span>
)}
</div>
<p className="text-sm text-slate-500">{user.email}</p>
</div>
<div className="flex gap-2 items-center">
{!user.isApproved && (
<button
onClick={() => handleApprove(user.id)}
disabled={busy || confirmAdminId === user.id || confirmDeleteId === user.id}
className="text-sm font-medium px-4 py-1.5 rounded-lg bg-emerald-500 text-white hover:bg-emerald-600 disabled:opacity-50 transition-colors"
>
{approvingId === user.id ? '…' : 'Approve'}
</button>
)}
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 ? (
<>
<span className="text-sm text-slate-500">Make admin?</span>
<button
onClick={() => handleMakeAdmin(user.id)}
disabled={makingAdminId === user.id}
className="text-sm font-medium px-4 py-1.5 rounded-lg bg-amber-500 text-white hover:bg-amber-600 disabled:opacity-50 transition-colors"
>
{makingAdminId === user.id ? '…' : 'Yes'}
</button>
<button
onClick={() => setConfirmAdminId(null)}
className="text-sm text-slate-400 hover:text-slate-600 transition-colors"
>
Cancel
</button>
</>
) : (
!user.isAdmin && (
<button
onClick={() => setConfirmAdminId(user.id)}
disabled={busy || confirmDeleteId === user.id}
className="text-sm font-medium px-4 py-1.5 rounded-lg bg-amber-500 text-white hover:bg-amber-600 disabled:opacity-50 transition-colors"
>
Make admin
</button>
)
)}
{confirmDeleteId === user.id ? (
<>
<span className="text-sm text-slate-500">Sure?</span>
<button
onClick={() => handleDelete(user.id)}
disabled={deletingId === user.id}
className="text-sm font-medium px-4 py-1.5 rounded-lg bg-red-500 text-white hover:bg-red-600 disabled:opacity-50 transition-colors"
>
{deletingId === user.id ? '…' : 'Yes, delete'}
</button>
<button
onClick={() => setConfirmDeleteId(null)}
className="text-sm text-slate-400 hover:text-slate-600 transition-colors"
>
Cancel
</button>
</>
) : (
<button
onClick={() => setConfirmDeleteId(user.id)}
disabled={busy || confirmAdminId === user.id}
className="text-sm font-medium px-4 py-1.5 rounded-lg bg-red-500 text-white hover:bg-red-600 disabled:opacity-50 transition-colors"
>
Delete
</button>
)}
</div>
</li>
);
}
const TABLE_COLS = '2fr 2fr 1fr 1fr auto';
return (
<div className="min-h-screen bg-slate-50">
<header className="bg-white border-b border-slate-200 px-6 py-4 shadow-sm">
<div className="max-w-3xl mx-auto flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-slate-800">Admin Portal</h1>
<p className="text-xs text-slate-400 mt-0.5">Manage user accounts</p>
<div style={{ background: 'var(--bg)', minHeight: '100vh' }}>
<div style={{
maxWidth: 1100,
margin: '0 auto',
height: '100vh',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
}}>
{/* Header */}
<header style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '22px 36px', flexShrink: 0,
}}>
{/* Wordmark + Admin badge */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div
onClick={() => navigate('/')}
style={{
display: 'flex', alignItems: 'center', gap: 1,
color: PURPLE,
fontFamily: "'Rubik Mono One', monospace",
fontSize: 18, letterSpacing: '-0.02em', cursor: 'pointer',
}}
>
<span style={{ opacity: 0.6 }}>{'{'}</span>
<span style={{ padding: '0 4px' }}>randall</span>
<span style={{ opacity: 0.6 }}>{'}'}</span>
</div>
<span style={{
fontSize: 11, letterSpacing: '0.16em', textTransform: 'uppercase',
color: PURPLE, padding: '3px 9px', borderRadius: 99,
border: '1px solid rgba(91,79,199,0.25)', fontWeight: 500,
}}>
Admin
</span>
</div>
{/* Nav */}
<div style={{ display: 'flex', alignItems: 'center', gap: 28, fontSize: 13, color: PURPLE }}>
<span style={{ fontWeight: 500 }}>Users</span>
<button
onClick={onLogout}
style={{
padding: '7px 18px', borderRadius: 99,
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
color: PURPLE_DEEP, fontSize: 12, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
Sign out
</button>
</div>
</header>
{/* Body */}
<div style={{
padding: '8px 36px 32px',
display: 'flex', flexDirection: 'column', gap: 18,
minHeight: 0, flex: 1, overflow: 'hidden',
}}>
{/* Hero */}
<div>
<div style={{
fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase',
color: PURPLE, opacity: 0.7, marginBottom: 12, fontWeight: 500,
}}>
Users · The Hague HQ
</div>
<h1 style={{
margin: 0,
fontFamily: "'Rubik Mono One', monospace",
fontSize: 42, fontWeight: 400, letterSpacing: '-0.02em',
lineHeight: 0.95, color: PURPLE,
}}>
Who can <span style={{ color: SAGE_DEEP }}>book?</span>
</h1>
</div>
{/* KPI strip */}
{!loading && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 12 }}>
{kpis.map((k) => (
<div key={k.label} style={{
background: PAPER, borderRadius: 14, padding: '14px 16px',
border: '1px solid rgba(91,79,199,0.10)',
}}>
<div style={{
fontSize: 10, letterSpacing: '0.16em', textTransform: 'uppercase',
color: PURPLE, opacity: 0.7, fontWeight: 500,
}}>
{k.label}
</div>
<div style={{
fontFamily: "'Rubik Mono One', monospace",
fontSize: 30, letterSpacing: '-0.02em', color: PURPLE,
marginTop: 4, lineHeight: 1,
}}>
{k.value}
</div>
</div>
))}
</div>
)}
{/* Users table card */}
<div style={{
background: PAPER, borderRadius: 18,
border: '1px solid rgba(91,79,199,0.10)',
flex: 1, display: 'flex', flexDirection: 'column',
minHeight: 0, overflow: 'hidden',
}}>
{/* Toolbar */}
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '14px 18px',
borderBottom: '1px solid rgba(91,79,199,0.10)',
flexShrink: 0,
}}>
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '7px 12px', borderRadius: 99,
border: '1px solid rgba(91,79,199,0.18)',
background: 'rgba(255,255,255,0.4)',
fontSize: 13, color: PURPLE, minWidth: 260,
}}>
<span style={{ opacity: 0.6 }}></span>
<input
type="text"
placeholder="Search by name or email"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{
background: 'none', border: 'none', outline: 'none',
fontSize: 13, color: PURPLE, fontFamily: 'inherit',
flex: 1, opacity: search ? 1 : 0.6,
}}
/>
</div>
<button style={{
padding: '8px 16px', borderRadius: 99,
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
color: PURPLE_DEEP, fontSize: 12, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}>
+ Add user
</button>
</div>
{/* Table head */}
<div style={{
display: 'grid', gridTemplateColumns: TABLE_COLS,
padding: '10px 18px',
fontSize: 10, letterSpacing: '0.16em', textTransform: 'uppercase',
color: PURPLE, opacity: 0.7, fontWeight: 500,
borderBottom: '1px solid rgba(91,79,199,0.08)',
flexShrink: 0,
}}>
<span>Name</span>
<span>Email</span>
<span>Role</span>
<span>Status</span>
<span>Actions</span>
</div>
{/* Table rows */}
<div style={{ overflow: 'auto', flex: 1 }}>
{loading && (
<p style={{ textAlign: 'center', color: PURPLE, opacity: 0.5, fontSize: 13, margin: '32px 0' }}>
Loading
</p>
)}
{error && (
<p style={{ textAlign: 'center', color: '#c0392b', fontSize: 13, margin: '24px 0' }}>
{error}
</p>
)}
{!loading && filtered.map((u, i) => {
const busy = approvingId === u.id || makingAdminId === u.id || deletingId === u.id;
const isLastRow = i === filtered.length - 1;
return (
<div key={u.id} data-testid="user-row" style={{
display: 'grid', gridTemplateColumns: TABLE_COLS,
alignItems: 'center',
padding: '12px 18px',
fontSize: 13, color: PURPLE_DEEP,
borderBottom: isLastRow ? 'none' : '1px solid rgba(91,79,199,0.06)',
}}>
{/* Name */}
<span style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{
width: 26, height: 26, borderRadius: '50%',
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 10, fontWeight: 600, color: PURPLE_DEEP,
flexShrink: 0,
}}>
{monogram(u.name)}
</span>
<span style={{ fontWeight: 500 }}>{u.name}</span>
</span>
{/* Email */}
<span style={{ color: PURPLE, opacity: 0.85, fontSize: 12 }}>{u.email}</span>
{/* Role badge */}
<span>
<span data-testid="role-badge" style={{
fontSize: 10, letterSpacing: '0.14em', textTransform: 'uppercase',
padding: '2px 8px', borderRadius: 99, fontWeight: 500,
background: u.isAdmin ? SAGE : 'transparent',
border: `1px solid ${u.isAdmin ? SAGE_DEEP : 'rgba(91,79,199,0.20)'}`,
color: u.isAdmin ? PURPLE_DEEP : PURPLE,
}}>
{u.isAdmin ? 'admin' : 'employee'}
</span>
</span>
{/* Status */}
<span style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: PURPLE }}>
<span style={{
width: 7, height: 7, borderRadius: '50%', flexShrink: 0,
background: u.isApproved ? SAGE_DEEP : 'rgba(91,79,199,0.30)',
}} />
{u.isApproved ? 'Active' : 'Pending'}
</span>
{/* Actions */}
<span style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{!u.isApproved && (
confirmAdminId !== u.id && confirmDeleteId !== u.id && (
<button
onClick={() => handleApprove(u.id)}
disabled={busy}
style={{
padding: '3px 10px', borderRadius: 99, fontSize: 11, fontWeight: 500,
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
color: PURPLE_DEEP, cursor: busy ? 'default' : 'pointer',
fontFamily: 'inherit', opacity: busy ? 0.5 : 1,
}}
>
{approvingId === u.id ? '…' : 'Approve'}
</button>
)
)}
{confirmAdminId === u.id ? (
<>
<span style={{ fontSize: 12, color: PURPLE, opacity: 0.7 }}>Make admin?</span>
<button
onClick={() => handleMakeAdmin(u.id)}
disabled={!!makingAdminId}
style={{
padding: '3px 10px', borderRadius: 99, fontSize: 11, fontWeight: 500,
background: PURPLE, border: `1px solid ${PURPLE_DEEP}`,
color: '#fff', cursor: 'pointer', fontFamily: 'inherit',
}}
>
{makingAdminId === u.id ? '…' : 'Yes'}
</button>
<button
onClick={() => setConfirmAdminId(null)}
style={{
background: 'none', border: 'none', fontSize: 12,
color: PURPLE, opacity: 0.6, cursor: 'pointer', fontFamily: 'inherit',
}}
>
No
</button>
</>
) : confirmDeleteId === u.id ? (
<>
<span style={{ fontSize: 12, color: PURPLE, opacity: 0.7 }}>Delete?</span>
<button
onClick={() => handleDelete(u.id)}
disabled={!!deletingId}
style={{
padding: '3px 10px', borderRadius: 99, fontSize: 11, fontWeight: 500,
background: PURPLE_DEEP, border: `1px solid ${PURPLE_DEEP}`,
color: '#fff', cursor: 'pointer', fontFamily: 'inherit',
}}
>
{deletingId === u.id ? '…' : 'Yes'}
</button>
<button
onClick={() => setConfirmDeleteId(null)}
style={{
background: 'none', border: 'none', fontSize: 12,
color: PURPLE, opacity: 0.6, cursor: 'pointer', fontFamily: 'inherit',
}}
>
No
</button>
</>
) : (
<>
{!u.isAdmin && (
<button
onClick={() => setConfirmAdminId(u.id)}
disabled={busy}
style={{
background: 'none', border: 'none', padding: 0,
fontSize: 12, color: PURPLE, opacity: busy ? 0.4 : 0.65,
cursor: busy ? 'default' : 'pointer', fontFamily: 'inherit',
}}
>
Admin
</button>
)}
<button
onClick={() => setConfirmDeleteId(u.id)}
disabled={busy}
style={{
background: 'none', border: 'none', padding: 0,
fontSize: 12, color: PURPLE, opacity: busy ? 0.4 : 0.65,
cursor: busy ? 'default' : 'pointer', fontFamily: 'inherit',
}}
>
Delete
</button>
</>
)}
</span>
</div>
);
})}
{!loading && filtered.length === 0 && !error && (
<p style={{ textAlign: 'center', color: PURPLE, opacity: 0.4, fontSize: 13, margin: '32px 0' }}>
{search ? 'No users match your search.' : 'No users found.'}
</p>
)}
</div>
</div>
<button
onClick={() => navigate('/')}
className="text-sm text-slate-400 hover:text-slate-600 transition-colors"
>
Back to planner
</button>
</div>
</header>
<main className="max-w-3xl mx-auto px-6 py-8 flex flex-col gap-8">
{loading && <p className="text-sm text-slate-400 text-center py-12">Loading</p>}
{error && <p className="text-sm text-red-500 text-center py-4">{error}</p>}
{!loading && (
<>
<section>
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-widest mb-3">
Pending approval
</h2>
{pending.length === 0 ? (
<p className="text-sm text-slate-400">No pending accounts.</p>
) : (
<ul className="flex flex-col gap-3">
{pending.map((u) => <UserRow key={u.id} user={u} />)}
</ul>
)}
</section>
<section>
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-widest mb-3">
Approved accounts
</h2>
{approved.length === 0 ? (
<p className="text-sm text-slate-400">No approved accounts yet.</p>
) : (
<ul className="flex flex-col gap-3">
{approved.map((u) => <UserRow key={u.id} user={u} />)}
</ul>
)}
</section>
</>
)}
</main>
</div>
</div>
);
}

View File

@@ -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 (
<div className="min-h-screen bg-slate-50 flex flex-col items-center justify-center px-4">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<h1 className="text-2xl font-semibold text-slate-800">Office Planner</h1>
<p className="text-sm text-slate-400 mt-1">Reserve your workspace</p>
<div style={{ background: 'var(--bg)', minHeight: '100vh', display: 'flex' }}>
<div style={{
maxWidth: 1100,
width: '100%',
margin: '0 auto',
height: '100vh',
display: 'flex',
position: 'relative',
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
}}>
{/* Wordmark */}
<div style={{
position: 'absolute', top: 22, left: 36,
display: 'flex', alignItems: 'center', gap: 1,
color: PURPLE,
fontFamily: "'Rubik Mono One', monospace",
fontSize: 18, letterSpacing: '-0.02em',
}}>
<span style={{ opacity: 0.6 }}>{'{'}</span>
<span style={{ padding: '0 4px' }}>randall</span>
<span style={{ opacity: 0.6 }}>{'}'}</span>
</div>
{mode === 'pending' && (
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8 text-center">
<div className="text-3xl mb-4"></div>
<h2 className="text-base font-semibold text-slate-800 mb-2">Account pending approval</h2>
<p className="text-sm text-slate-500 mb-6">
Your account has been created. An administrator will review and approve it shortly.
</p>
<button
onClick={() => { setMode('login'); setError(''); }}
className="text-sm text-emerald-600 hover:text-emerald-700 font-medium"
>
Back to sign in
</button>
</div>
)}
{mode !== 'pending' && <div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
<div className="flex rounded-lg bg-slate-100 p-1 mb-6">
<button
onClick={() => { setMode('login'); setError(''); }}
className={`flex-1 py-1.5 text-sm font-medium rounded-md transition-colors ${
mode === 'login' ? 'bg-white text-slate-800 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
Sign in
</button>
<button
onClick={() => { setMode('register'); setError(''); }}
className={`flex-1 py-1.5 text-sm font-medium rounded-md transition-colors ${
mode === 'register' ? 'bg-white text-slate-800 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
Create account
</button>
{/* Left editorial column */}
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: '0 80px',
}}>
<div style={{
fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase',
color: PURPLE, opacity: 0.7, marginBottom: 14, fontWeight: 500,
}}>
The Hague HQ · Sign in
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{mode === 'register' && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Full name</label>
<input
type="text"
required
value={name}
onChange={(e) => 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"
/>
<h1 style={{
margin: 0,
fontFamily: "'Rubik Mono One', monospace",
fontSize: 54, fontWeight: 400,
letterSpacing: '-0.02em', lineHeight: 0.95,
color: PURPLE, maxWidth: 520,
}}>
Find your <span style={{ color: SAGE_DEEP }}>desk.</span>
</h1>
<p style={{
margin: '14px 0 0', maxWidth: 480,
fontSize: 14, lineHeight: 1.55,
color: PURPLE, opacity: 0.85,
}}>
Reserve a desk for today or up to two weeks ahead. Sign in with your Randall email.
</p>
</div>
{/* Right card column */}
<div style={{
width: 430,
display: 'flex',
alignItems: 'center',
padding: '0 60px 0 0',
}}>
{mode === 'pending' ? (
<div style={{
width: '100%', background: PAPER, borderRadius: 18, padding: 28,
border: '1px solid rgba(91,79,199,0.10)',
}}>
<div style={{
fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase',
color: PURPLE, opacity: 0.7, marginBottom: 16, fontWeight: 500,
}}>
Account pending
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Work email</label>
<input
type="email"
required
value={email}
onChange={(e) => 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"
/>
<div style={{
fontFamily: "'Rubik Mono One', monospace",
fontSize: 32, color: PURPLE, lineHeight: 0.95,
letterSpacing: '-0.02em', marginBottom: 12,
}}>
Almost there.
</div>
<p style={{ fontSize: 13, color: PURPLE, opacity: 0.75, lineHeight: 1.5, margin: '0 0 22px' }}>
Your account has been created. An administrator will review and approve it shortly.
</p>
<button
onClick={() => { setMode('login'); setError(''); }}
style={{
background: 'none', border: 'none', padding: 0,
fontSize: 13, color: PURPLE, opacity: 0.7, cursor: 'pointer',
fontFamily: 'inherit', textDecoration: 'underline',
}}
>
Back to sign in
</button>
</div>
) : (
<div style={{
width: '100%', background: PAPER, borderRadius: 18, padding: 28,
border: '1px solid rgba(91,79,199,0.10)',
}}>
{/* Tab switcher */}
<div style={{
display: 'flex', gap: 6, marginBottom: 22,
background: 'rgba(91,79,199,0.06)', padding: 4, borderRadius: 99,
}}>
{(['login', 'register'] as const).map((m) => (
<button
key={m}
onClick={() => { setMode(m); setError(''); }}
style={{
flex: 1, padding: '8px 10px', borderRadius: 99, border: 'none',
background: mode === m ? PAPER : 'transparent',
color: mode === m ? PURPLE_DEEP : PURPLE,
fontWeight: 500, fontSize: 12, cursor: 'pointer', fontFamily: 'inherit',
boxShadow: mode === m ? '0 2px 6px -2px rgba(91,79,199,0.18)' : 'none',
}}
>
{m === 'login' ? 'Sign in' : 'Register'}
</button>
))}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Password</label>
<input
type="password"
required
value={password}
onChange={(e) => 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"
/>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<span style={fieldLabelStyle}>Email</span>
<input
type="email"
required
placeholder="you@randall.local"
value={email}
onChange={(e) => setEmail(e.target.value)}
style={inputStyle}
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<span style={fieldLabelStyle}>Password</span>
<input
type="password"
required
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
style={inputStyle}
/>
</label>
{mode === 'register' && (
<label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<span style={fieldLabelStyle}>Full name</span>
<input
type="text"
required
placeholder="Jane Smith"
value={name}
onChange={(e) => setName(e.target.value)}
style={inputStyle}
/>
</label>
)}
{error && (
<div style={{
fontSize: 13, color: '#c0392b',
background: 'rgba(192,57,43,0.08)',
borderRadius: 8, padding: '10px 14px',
}}>
{error}
</div>
)}
<button
type="submit"
disabled={loading}
style={{
padding: '13px 16px', borderRadius: 99, marginTop: 6,
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
color: PURPLE_DEEP, fontSize: 13, fontWeight: 500,
cursor: loading ? 'default' : 'pointer', fontFamily: 'inherit',
opacity: loading ? 0.6 : 1,
}}
>
{loading ? '…' : mode === 'login' ? 'Sign in →' : 'Create account →'}
</button>
</form>
<div style={{
textAlign: 'center', fontSize: 12, color: PURPLE,
opacity: 0.7, marginTop: 16,
}}>
{mode === 'login' ? (
<>No account?{' '}
<span
onClick={() => { setMode('register'); setError(''); }}
style={{ color: PURPLE, cursor: 'pointer', textDecoration: 'underline' }}
>
Register
</span>
</>
) : (
<>Have one?{' '}
<span
onClick={() => { setMode('login'); setError(''); }}
style={{ color: PURPLE, cursor: 'pointer', textDecoration: 'underline' }}
>
Sign in
</span>
</>
)}
</div>
</div>
{error && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full py-2 text-sm font-medium text-white bg-emerald-500 rounded-lg hover:bg-emerald-600 disabled:opacity-50 transition-colors mt-1"
>
{loading ? '…' : mode === 'login' ? 'Sign in' : 'Create account'}
</button>
</form>
</div>}
)}
</div>
</div>
</div>
);

View File

@@ -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<WorkplaceScheduleItem[]>([]);
@@ -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 (
<div className="min-h-screen bg-slate-50">
<header className="bg-white border-b border-slate-200 px-6 py-4 shadow-sm">
<div className="max-w-5xl mx-auto flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-slate-800">Office Planner</h1>
<p className="text-xs text-slate-400 mt-0.5">Reserve your workspace up to 2 weeks ahead</p>
/* Outer bg fills the full viewport */
<div style={{ background: 'var(--bg)', minHeight: '100vh' }}>
{/* Centred 1100 px frame */}
<div style={{
maxWidth: 1100,
margin: '0 auto',
height: '100vh',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}>
{/* Header */}
<header style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '22px 36px',
flexShrink: 0,
}}>
{/* Wordmark */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: 1,
color: PURPLE,
fontFamily: "'Rubik Mono One', 'Major Mono Display', monospace",
fontSize: 18,
letterSpacing: '-0.02em',
}}>
<span style={{ fontWeight: 400, opacity: 0.6 }}>{'{'}</span>
<span style={{ padding: '0 4px' }}>randall</span>
<span style={{ fontWeight: 400, opacity: 0.6 }}>{'}'}</span>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-slate-600">{auth.name}</span>
{/* Nav */}
<div style={{ display: 'flex', alignItems: 'center', gap: 28, fontSize: 13, color: PURPLE }}>
<span style={{ fontWeight: 500 }}>Today</span>
{auth.isAdmin && (
<button
onClick={() => navigate('/admin')}
className="text-sm text-slate-400 hover:text-slate-600 transition-colors"
style={{
background: 'none', border: 'none', fontFamily: 'inherit',
fontSize: 13, color: PURPLE, opacity: 0.6, cursor: 'pointer', padding: 0,
}}
>
Admin portal
Admin
</button>
)}
<button
onClick={onLogout}
className="text-sm text-slate-400 hover:text-slate-600 transition-colors"
style={{
padding: '7px 18px',
borderRadius: 99,
background: SAGE,
border: `1px solid ${SAGE_DEEP}`,
color: PURPLE_DEEP,
fontSize: 12,
fontWeight: 500,
cursor: 'pointer',
fontFamily: 'inherit',
}}
>
Sign out
</button>
<span style={{ fontSize: 12, color: PURPLE, opacity: 0.6, cursor: 'default' }}>NL </span>
</div>
</header>
{/* Body */}
<div style={{
display: 'flex',
flex: 1,
gap: 32,
padding: '8px 36px 32px',
minHeight: 0,
overflow: 'hidden',
}}>
{/* Left column */}
<div style={{
flex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
gap: 22,
overflow: 'auto',
}}>
{/* Editorial hero */}
<div>
{/* Kicker */}
<div style={{
fontSize: 11,
letterSpacing: '0.18em',
textTransform: 'uppercase',
color: PURPLE,
opacity: 0.7,
fontWeight: 500,
marginBottom: 14,
}}>
{formatKickerDate(selectedDate)}
</div>
{/* Headline */}
<h1 style={{
margin: 0,
fontFamily: "'Rubik Mono One', 'Major Mono Display', monospace",
fontSize: 46,
fontWeight: 400,
letterSpacing: '-0.02em',
lineHeight: 0.95,
color: PURPLE,
}}>
Where to sit{' '}
<span style={{ color: SAGE_DEEP }}>{trailingWord}</span>
</h1>
{/* Body copy */}
<p style={{
margin: '10px 0 0',
maxWidth: 480,
fontSize: 13,
lineHeight: 1.5,
color: PURPLE,
opacity: 0.85,
}}>
{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.`}
</p>
{/* 14-day date strip */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 18 }}>
<button
onClick={() => setSelectedDate(offsetDate(selectedDate, -1))}
disabled={dayOffset === 0}
style={arrowBtnStyle(dayOffset === 0)}
>
</button>
<div style={{ display: 'flex', gap: 5, flex: 1 }}>
{dayStrip.map((dateIso) => {
const isActive = dateIso === selectedDate;
const { weekday, day, monthTag } = formatDayCell(dateIso);
return (
<button
key={dateIso}
data-date={dateIso}
onClick={() => setSelectedDate(dateIso)}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
flex: '1 0 0',
padding: '6px 2px',
borderRadius: 10,
background: isActive ? SAGE : 'transparent',
border: isActive
? `1.5px solid ${SAGE_DEEP}`
: '1px solid rgba(91,79,199,0.18)',
cursor: 'pointer',
fontFamily: 'inherit',
gap: 2,
transition: 'background 150ms, border-color 150ms',
}}
>
<span style={{
fontSize: 10,
fontWeight: 500,
letterSpacing: '0.16em',
textTransform: 'uppercase',
color: PURPLE,
opacity: isActive ? 0.85 : 0.55,
lineHeight: 1,
}}>
{weekday}
</span>
<span style={{
fontFamily: "'Rubik Mono One', monospace",
fontSize: 18,
color: isActive ? PURPLE_DEEP : PURPLE,
lineHeight: 1,
}}>
{day}
</span>
{monthTag && (
<span style={{
fontSize: 8,
fontWeight: 500,
letterSpacing: '0.12em',
textTransform: 'uppercase',
color: PURPLE,
opacity: 0.5,
lineHeight: 1,
}}>
{monthTag}
</span>
)}
</button>
);
})}
</div>
<button
onClick={() => setSelectedDate(offsetDate(selectedDate, 1))}
disabled={dayOffset >= MAX_OFFSET}
style={arrowBtnStyle(dayOffset >= MAX_OFFSET)}
>
</button>
</div>
</div>
{/* Floor card */}
<div style={{
background: PAPER,
borderRadius: 18,
padding: 22,
border: '1px solid rgba(91,79,199,0.10)',
display: 'flex',
flexDirection: 'column',
}}>
{/* Legend */}
<div style={{
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
marginBottom: 16,
gap: 14,
fontSize: 11,
color: PURPLE,
opacity: 0.75,
}}>
<span style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<span style={{
width: 8, height: 8, borderRadius: 2,
background: PAPER, border: '1px solid rgba(91,79,199,0.4)',
display: 'inline-block', flexShrink: 0,
}} />
free
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<span style={{
width: 8, height: 8, borderRadius: 2,
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
display: 'inline-block', flexShrink: 0,
}} />
yours
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<span style={{
width: 8, height: 8, borderRadius: 2,
border: '1px dashed rgba(91,79,199,0.4)',
display: 'inline-block', flexShrink: 0,
}} />
taken
</span>
</div>
{/* Pods */}
{!loadingFloor && !floorError && (
<div style={{
display: 'flex',
gap: 36,
justifyContent: 'center',
alignItems: 'flex-start',
}}>
<DeskPod label="Pod A" desks={podA} myReservedIds={myReservedIdsOnDate} onDeskClick={handleDeskClick} />
<DeskPod label="Pod B" desks={podB} myReservedIds={myReservedIdsOnDate} onDeskClick={handleDeskClick} />
</div>
)}
{loadingFloor && (
<p style={{ textAlign: 'center', color: PURPLE, opacity: 0.5, fontSize: 13, margin: '32px 0' }}>
Loading
</p>
)}
{floorError && (
<p style={{ textAlign: 'center', color: '#c0392b', fontSize: 13, margin: '32px 0' }}>
{floorError}
</p>
)}
</div>
</div>
{/* Right rail */}
<div style={{ width: 300, display: 'flex', flexDirection: 'column', gap: 14, overflow: 'auto', flexShrink: 0 }}>
{/* Your desk card */}
<div style={{
background: PAPER,
borderRadius: 18,
padding: 22,
border: '1px solid rgba(91,79,199,0.10)',
flexShrink: 0,
}}>
<div style={{
fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase',
color: PURPLE, opacity: 0.7, marginBottom: 8, fontWeight: 500,
}}>
Your desk
</div>
<div style={{
fontFamily: "'Rubik Mono One', monospace",
fontSize: 46, letterSpacing: '-0.03em', color: PURPLE,
lineHeight: 0.95, marginBottom: 6,
}}>
{myDeskOnDate?.workplaceName ?? '—'}
</div>
<div style={{ fontSize: 13, color: PURPLE, opacity: 0.75, marginBottom: 18 }}>
{myDeskOnDate
? `${myDeskOnDate.workplaceLocation} · Held ${formatRelativeTime(myDeskOnDate.createdAt)}`
: 'No desk held for this day'}
</div>
<div style={{
display: 'flex', flexDirection: 'column', gap: 7,
fontSize: 13, color: PURPLE, marginBottom: 18,
borderTop: '1px solid rgba(91,79,199,0.10)', paddingTop: 14,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ opacity: 0.65 }}>Date</span>
<span>{formatDisplayDate(selectedDate)}</span>
</div>
{myDeskOnDate && (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ opacity: 0.65 }}>Neighbours</span>
<span style={{ textAlign: 'right', maxWidth: 140 }}>
{neighbours || '—'}
</span>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ opacity: 0.65 }}>Streak</span>
<span></span>
</div>
</div>
</div>
{/* My reservations card */}
<div style={{
background: PAPER,
borderRadius: 18,
padding: 22,
border: '1px solid rgba(91,79,199,0.10)',
}}>
<div style={{
fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase',
color: PURPLE, opacity: 0.7, marginBottom: 14, fontWeight: 500,
}}>
My reservations
</div>
<MyReservations reservations={myReservations} onCancel={setCancelTarget} />
</div>
</div>
</div>
</header>
<main className="max-w-5xl mx-auto px-6 py-8 flex flex-col gap-8">
<section className="flex items-center gap-3 flex-wrap">
<label className="text-sm font-medium text-slate-600 whitespace-nowrap">Date</label>
<button
onClick={() => setSelectedDate(offsetDate(selectedDate, -1))}
disabled={selectedDate <= today}
className="px-2.5 py-1.5 text-sm rounded-lg border border-slate-300 bg-white hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
</button>
<input
type="date"
value={selectedDate}
min={today}
max={maxDate}
onChange={(e) => 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"
/>
<button
onClick={() => setSelectedDate(offsetDate(selectedDate, 1))}
disabled={selectedDate >= maxDate}
className="px-2.5 py-1.5 text-sm rounded-lg border border-slate-300 bg-white hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
</button>
<span className="text-sm text-slate-500 font-medium">{formatDisplayDate(selectedDate)}</span>
</section>
<div className="flex gap-6 text-xs text-slate-500">
<span className="flex items-center gap-1.5">
<span className="w-3 h-3 rounded bg-emerald-400 inline-block" />
Available click to reserve
</span>
<span className="flex items-center gap-1.5">
<span className="w-3 h-3 rounded bg-blue-400 inline-block" />
Your reservation click to cancel
</span>
<span className="flex items-center gap-1.5">
<span className="w-3 h-3 rounded bg-slate-300 inline-block" />
Taken hover to see who
</span>
</div>
<section>
{loadingFloor && <p className="text-sm text-slate-400 text-center py-12">Loading floor plan</p>}
{floorError && <p className="text-sm text-red-500 text-center py-12">{floorError}</p>}
{!loadingFloor && !floorError && (
<div className="flex flex-wrap gap-12 justify-center">
<DeskPod desks={podA} myReservedIds={myReservedIdsOnDate} onDeskClick={handleDeskClick} />
<DeskPod desks={podB} myReservedIds={myReservedIdsOnDate} onDeskClick={handleDeskClick} />
</div>
)}
</section>
<section className="bg-white border border-slate-200 rounded-2xl p-6 shadow-sm">
<h2 className="text-base font-semibold text-slate-700 mb-4">My reservations</h2>
<MyReservations reservations={myReservations} onCancel={setCancelTarget} />
</section>
</main>
</div>
{reserveTarget && (
<ReservationModal