feat(frontend): implement Respellion house-style design
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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> — {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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user