feat(frontend): implement Respellion house-style design
This commit is contained in:
@@ -4,7 +4,10 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function App() {
|
|||||||
<Route path="/" element={<PlannerPage auth={auth} onLogout={handleLogout} />} />
|
<Route path="/" element={<PlannerPage auth={auth} onLogout={handleLogout} />} />
|
||||||
<Route
|
<Route
|
||||||
path="/admin"
|
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 />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ interface CancelModalProps {
|
|||||||
onClose: () => void;
|
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) {
|
export function CancelModal({ deskName, date, onConfirm, onClose }: CancelModalProps) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -24,30 +30,77 @@ export function CancelModal({ deskName, date, onConfirm, onClose }: CancelModalP
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
|
||||||
<div
|
<div
|
||||||
className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-md mx-4"
|
style={{
|
||||||
|
position: 'fixed', inset: 0,
|
||||||
|
background: 'rgba(42,31,107,0.35)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
zIndex: 50,
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<h2 className="text-xl font-semibold text-slate-800 mb-1">Cancel reservation</h2>
|
<div style={{
|
||||||
<p className="text-sm text-slate-500 mb-6">
|
fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase',
|
||||||
Cancel your booking for <span className="font-medium text-slate-700">{deskName}</span> on{' '}
|
color: PURPLE, opacity: 0.7, marginBottom: 8, fontWeight: 500,
|
||||||
<span className="font-medium text-slate-700">{date}</span>?
|
}}>
|
||||||
</p>
|
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
|
<button
|
||||||
onClick={onClose}
|
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
|
Keep it
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
disabled={loading}
|
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'}
|
{loading ? 'Cancelling…' : 'Cancel reservation'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,72 +1,104 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
interface DeskProps {
|
interface DeskProps {
|
||||||
name: string;
|
name: string;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
reserved: boolean; // reserved by the current user
|
reserved: boolean;
|
||||||
reservedBy?: string; // name of whoever reserved it (when taken by someone else)
|
reservedBy?: string;
|
||||||
rotate?: 'cw' | 'ccw';
|
rotate?: 'cw' | 'ccw';
|
||||||
onClick: () => void;
|
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) {
|
export function Desk({ name, available, reserved, reservedBy, rotate, onClick }: DeskProps) {
|
||||||
let bgColor: string;
|
const [hover, setHover] = useState(false);
|
||||||
let borderColor: string;
|
|
||||||
let textColor: string;
|
|
||||||
let deskColor: string;
|
|
||||||
let cursor: string;
|
|
||||||
let title: string;
|
|
||||||
|
|
||||||
if (reserved) {
|
const isMine = reserved;
|
||||||
bgColor = 'bg-blue-50';
|
const isFree = !reserved && available;
|
||||||
borderColor = 'border-blue-400';
|
const isTaken = !reserved && !available;
|
||||||
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`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Truncate long names to fit the tile
|
const svgFill = isMine ? PURPLE_DEEP : isFree ? PURPLE : 'rgba(91,79,199,0.25)';
|
||||||
const displayName = reservedBy && reservedBy.length > 7
|
|
||||||
|
const displayLabel = isFree
|
||||||
|
? 'free'
|
||||||
|
: isMine
|
||||||
|
? 'yours'
|
||||||
|
: reservedBy
|
||||||
|
? reservedBy.length > 7
|
||||||
? reservedBy.slice(0, 6) + '…'
|
? reservedBy.slice(0, 6) + '…'
|
||||||
: reservedBy;
|
: reservedBy
|
||||||
|
: 'taken';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<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}`}
|
onMouseEnter={() => setHover(true)}
|
||||||
onClick={available || reserved ? onClick : undefined}
|
onMouseLeave={() => setHover(false)}
|
||||||
title={title}
|
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
|
||||||
<svg viewBox="0 0 48 48" className={`w-10 h-10 ${rotate === 'cw' ? 'rotate-90' : rotate === 'ccw' ? '-rotate-90' : ''}`} fill="none" aria-hidden="true">
|
viewBox="0 0 48 48"
|
||||||
{/* Desk surface (wide rectangle, ~1.6:1 ratio) */}
|
width={28}
|
||||||
<rect x="2" y="10" width="44" height="28" rx="2.5" fill={deskColor} />
|
height={28}
|
||||||
{/* Screen stripe — centered, ~30% of desk width, small margin from back edge */}
|
fill="none"
|
||||||
<rect x="17" y="13" width="14" height="5" rx="1.5" fill="#1e293b" />
|
aria-hidden="true"
|
||||||
{/* Chair — small square in front of desk */}
|
style={{
|
||||||
<rect x="18" y="41" width="12" height="8" rx="2" fill={deskColor} />
|
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>
|
</svg>
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, letterSpacing: '-0.01em', lineHeight: 1 }}>
|
||||||
<span className="text-xs font-semibold leading-none">{name}</span>
|
{name}
|
||||||
{reserved && <span className="text-[10px] opacity-75 leading-none">Mine</span>}
|
</span>
|
||||||
{available && <span className="text-[10px] opacity-75 leading-none">Free</span>}
|
<span style={{ fontSize: 9, opacity: 0.7, fontWeight: 500, lineHeight: 1.1, textAlign: 'center' }}>
|
||||||
{!reserved && !available && (
|
{displayLabel}
|
||||||
<span className="text-[10px] opacity-75 leading-tight text-center px-0.5">
|
|
||||||
{displayName ?? 'Taken'}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,20 +9,39 @@ interface ScheduleItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface DeskPodProps {
|
interface DeskPodProps {
|
||||||
|
label: string;
|
||||||
desks: ScheduleItem[];
|
desks: ScheduleItem[];
|
||||||
myReservedIds: Set<string>;
|
myReservedIds: Set<string>;
|
||||||
onDeskClick: (desk: ScheduleItem) => void;
|
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 left = [...desks.slice(0, 4)].reverse();
|
||||||
const right = [...desks.slice(4, 8)].reverse();
|
const right = [...desks.slice(4, 8)].reverse();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, flex: '0 0 auto' }}>
|
||||||
<div className="bg-white border-2 border-slate-200 rounded-2xl p-5 shadow-sm">
|
<div style={{
|
||||||
<div className="flex gap-6">
|
fontSize: 10,
|
||||||
<div className="flex flex-col gap-3">
|
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) => (
|
{left.map((desk) => (
|
||||||
<Desk
|
<Desk
|
||||||
key={desk.id}
|
key={desk.id}
|
||||||
@@ -35,10 +54,8 @@ export function DeskPod({ desks, myReservedIds, onDeskClick }: DeskPodProps) {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ width: 1, background: 'rgba(91,79,199,0.10)', alignSelf: 'stretch' }} />
|
||||||
<div className="w-px bg-slate-100" />
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{right.map((desk) => (
|
{right.map((desk) => (
|
||||||
<Desk
|
<Desk
|
||||||
key={desk.id}
|
key={desk.id}
|
||||||
@@ -53,6 +70,5 @@ export function DeskPod({ desks, myReservedIds, onDeskClick }: DeskPodProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ interface MyReservationsProps {
|
|||||||
onCancel: (reservation: Reservation) => void;
|
onCancel: (reservation: Reservation) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PURPLE = '#5b4fc7';
|
||||||
|
const PURPLE_DEEP = '#3f33a8';
|
||||||
|
const SAGE = '#c7d4b8';
|
||||||
|
const SAGE_DEEP = '#a9bb96';
|
||||||
|
|
||||||
export function MyReservations({ reservations, onCancel }: MyReservationsProps) {
|
export function MyReservations({ reservations, onCancel }: MyReservationsProps) {
|
||||||
const upcoming = reservations
|
const upcoming = reservations
|
||||||
.filter((r) => r.status === 'Active')
|
.filter((r) => r.status === 'Active')
|
||||||
@@ -12,24 +17,46 @@ export function MyReservations({ reservations, onCancel }: MyReservationsProps)
|
|||||||
|
|
||||||
if (upcoming.length === 0) {
|
if (upcoming.length === 0) {
|
||||||
return (
|
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 (
|
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) => (
|
{upcoming.map((r) => (
|
||||||
<li
|
<li
|
||||||
key={r.id}
|
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>
|
<div>
|
||||||
<span className="font-medium text-slate-800">{r.workplaceName}</span>
|
<div style={{ fontSize: 13, fontWeight: 600, color: PURPLE, lineHeight: 1 }}>
|
||||||
<div className="text-xs text-slate-400 mt-0.5">{r.date}</div>
|
{r.workplaceName}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: PURPLE, opacity: 0.55, marginTop: 3 }}>
|
||||||
|
{r.date}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => onCancel(r)}
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ interface ReservationModalProps {
|
|||||||
onClose: () => void;
|
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) {
|
export function ReservationModal({ deskName, date, onConfirm, onClose }: ReservationModalProps) {
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -24,29 +30,77 @@ export function ReservationModal({ deskName, date, onConfirm, onClose }: Reserva
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
|
|
||||||
<div
|
<div
|
||||||
className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-sm mx-4"
|
style={{
|
||||||
|
position: 'fixed', inset: 0,
|
||||||
|
background: 'rgba(42,31,107,0.35)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
zIndex: 50,
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<h2 className="text-xl font-semibold text-slate-800 mb-1">Reserve desk</h2>
|
<div style={{
|
||||||
<p className="text-sm text-slate-500 mb-6">
|
fontSize: 11, letterSpacing: '0.18em', textTransform: 'uppercase',
|
||||||
<span className="font-medium text-slate-700">{deskName}</span> — {date}
|
color: PURPLE, opacity: 0.7, marginBottom: 8, fontWeight: 500,
|
||||||
</p>
|
}}>
|
||||||
|
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
|
<button
|
||||||
onClick={onClose}
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
disabled={loading}
|
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'}
|
{loading ? 'Reserving…' : 'Confirm'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,12 +1,27 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #e6e7e0;
|
||||||
|
--paper: #f4f3ee;
|
||||||
|
--ink: #2a1f6b;
|
||||||
|
--purple: #5b4fc7;
|
||||||
|
--purple-deep: #3f33a8;
|
||||||
|
--sage: #c7d4b8;
|
||||||
|
--sage-deep: #a9bb96;
|
||||||
|
}
|
||||||
|
|
||||||
*, *::before, *::after {
|
*, *::before, *::after {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: system-ui, 'Segoe UI', Roboto, sans-serif;
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
background-color: #f8fafc;
|
background-color: var(--bg);
|
||||||
color: #0f172a;
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 2px solid var(--purple);
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,29 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { api } from '../api/client';
|
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 navigate = useNavigate();
|
||||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
const [approvingId, setApprovingId] = useState<string | null>(null);
|
const [approvingId, setApprovingId] = useState<string | null>(null);
|
||||||
const [makingAdminId, setMakingAdminId] = useState<string | null>(null);
|
const [makingAdminId, setMakingAdminId] = useState<string | null>(null);
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
@@ -44,7 +60,7 @@ export function AdminPage() {
|
|||||||
setMakingAdminId(id);
|
setMakingAdminId(id);
|
||||||
try {
|
try {
|
||||||
await api.makeAdmin(id);
|
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);
|
setConfirmAdminId(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to make user admin');
|
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 filtered = users.filter((u) => {
|
||||||
const approved = users.filter((u) => u.isApproved);
|
const q = search.toLowerCase();
|
||||||
|
return !q || u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
|
||||||
function UserRow({ user }: { user: AdminUser }) {
|
const totalUsers = users.length;
|
||||||
const busy = approvingId === user.id || makingAdminId === user.id || deletingId === user.id;
|
const approvedCount = users.filter((u) => u.isApproved).length;
|
||||||
|
const adminCount = users.filter((u) => u.isAdmin).length;
|
||||||
|
const pendingCount = users.filter((u) => !u.isApproved).length;
|
||||||
|
|
||||||
|
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') },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TABLE_COLS = '2fr 2fr 1fr 1fr auto';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="flex items-center justify-between bg-white border border-slate-200 rounded-xl px-5 py-4 shadow-sm">
|
<div style={{ background: 'var(--bg)', minHeight: '100vh' }}>
|
||||||
<div>
|
<div style={{
|
||||||
<div className="flex items-center gap-2">
|
maxWidth: 1100,
|
||||||
<p className="font-medium text-slate-800">{user.name}</p>
|
margin: '0 auto',
|
||||||
{user.isAdmin && (
|
height: '100vh',
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-amber-100 text-amber-700">
|
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
|
Admin
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!user.isAdmin && confirmAdminId === user.id ? (
|
{/* Nav */}
|
||||||
<>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 28, fontSize: 13, color: PURPLE }}>
|
||||||
<span className="text-sm text-slate-500">Make admin?</span>
|
<span style={{ fontWeight: 500 }}>Users</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleMakeAdmin(user.id)}
|
onClick={onLogout}
|
||||||
disabled={makingAdminId === user.id}
|
style={{
|
||||||
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"
|
padding: '7px 18px', borderRadius: 99,
|
||||||
|
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
|
||||||
|
color: PURPLE_DEEP, fontSize: 12, fontWeight: 500,
|
||||||
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{makingAdminId === user.id ? '…' : 'Yes'}
|
Sign out
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
<button
|
|
||||||
onClick={() => navigate('/')}
|
|
||||||
className="text-sm text-slate-400 hover:text-slate-600 transition-colors"
|
|
||||||
>
|
|
||||||
← Back to planner
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="max-w-3xl mx-auto px-6 py-8 flex flex-col gap-8">
|
{/* Body */}
|
||||||
{loading && <p className="text-sm text-slate-400 text-center py-12">Loading…</p>}
|
<div style={{
|
||||||
{error && <p className="text-sm text-red-500 text-center py-4">{error}</p>}
|
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 && (
|
{!loading && (
|
||||||
<>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: 12 }}>
|
||||||
<section>
|
{kpis.map((k) => (
|
||||||
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-widest mb-3">
|
<div key={k.label} style={{
|
||||||
Pending approval
|
background: PAPER, borderRadius: 14, padding: '14px 16px',
|
||||||
</h2>
|
border: '1px solid rgba(91,79,199,0.10)',
|
||||||
{pending.length === 0 ? (
|
}}>
|
||||||
<p className="text-sm text-slate-400">No pending accounts.</p>
|
<div style={{
|
||||||
) : (
|
fontSize: 10, letterSpacing: '0.16em', textTransform: 'uppercase',
|
||||||
<ul className="flex flex-col gap-3">
|
color: PURPLE, opacity: 0.7, fontWeight: 500,
|
||||||
{pending.map((u) => <UserRow key={u.id} user={u} />)}
|
}}>
|
||||||
</ul>
|
{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>
|
||||||
)}
|
)}
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
{/* Users table card */}
|
||||||
<h2 className="text-sm font-semibold text-slate-500 uppercase tracking-widest mb-3">
|
<div style={{
|
||||||
Approved accounts
|
background: PAPER, borderRadius: 18,
|
||||||
</h2>
|
border: '1px solid rgba(91,79,199,0.10)',
|
||||||
{approved.length === 0 ? (
|
flex: 1, display: 'flex', flexDirection: 'column',
|
||||||
<p className="text-sm text-slate-400">No approved accounts yet.</p>
|
minHeight: 0, overflow: 'hidden',
|
||||||
) : (
|
}}>
|
||||||
<ul className="flex flex-col gap-3">
|
{/* Toolbar */}
|
||||||
{approved.map((u) => <UserRow key={u.id} user={u} />)}
|
<div style={{
|
||||||
</ul>
|
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>
|
||||||
)}
|
)}
|
||||||
</section>
|
{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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,34 @@ interface AuthPageProps {
|
|||||||
onAuth: (auth: AuthResponse) => void;
|
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) {
|
export function AuthPage({ onAuth }: AuthPageProps) {
|
||||||
const [mode, setMode] = useState<'login' | 'register' | 'pending'>('login');
|
const [mode, setMode] = useState<'login' | 'register' | 'pending'>('login');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
@@ -14,7 +42,7 @@ export function AuthPage({ onAuth }: AuthPageProps) {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
async function handleSubmit(e: { preventDefault: () => void }) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -34,101 +62,220 @@ export function AuthPage({ onAuth }: AuthPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 flex flex-col items-center justify-center px-4">
|
<div style={{ background: 'var(--bg)', minHeight: '100vh', display: 'flex' }}>
|
||||||
<div className="w-full max-w-sm">
|
<div style={{
|
||||||
<div className="text-center mb-8">
|
maxWidth: 1100,
|
||||||
<h1 className="text-2xl font-semibold text-slate-800">Office Planner</h1>
|
width: '100%',
|
||||||
<p className="text-sm text-slate-400 mt-1">Reserve your workspace</p>
|
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>
|
</div>
|
||||||
|
|
||||||
{mode === 'pending' && (
|
{/* Left editorial column */}
|
||||||
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8 text-center">
|
<div style={{
|
||||||
<div className="text-3xl mb-4">⏳</div>
|
flex: 1,
|
||||||
<h2 className="text-base font-semibold text-slate-800 mb-2">Account pending approval</h2>
|
display: 'flex',
|
||||||
<p className="text-sm text-slate-500 mb-6">
|
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>
|
||||||
|
|
||||||
|
<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 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.
|
Your account has been created. An administrator will review and approve it shortly.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setMode('login'); setError(''); }}
|
onClick={() => { setMode('login'); setError(''); }}
|
||||||
className="text-sm text-emerald-600 hover:text-emerald-700 font-medium"
|
style={{
|
||||||
|
background: 'none', border: 'none', padding: 0,
|
||||||
|
fontSize: 13, color: PURPLE, opacity: 0.7, cursor: 'pointer',
|
||||||
|
fontFamily: 'inherit', textDecoration: 'underline',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Back to sign in
|
Back to sign in
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (
|
||||||
|
<div style={{
|
||||||
{mode !== 'pending' && <div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
|
width: '100%', background: PAPER, borderRadius: 18, padding: 28,
|
||||||
<div className="flex rounded-lg bg-slate-100 p-1 mb-6">
|
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
|
<button
|
||||||
onClick={() => { setMode('login'); setError(''); }}
|
key={m}
|
||||||
className={`flex-1 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
onClick={() => { setMode(m); setError(''); }}
|
||||||
mode === 'login' ? 'bg-white text-slate-800 shadow-sm' : 'text-slate-500 hover:text-slate-700'
|
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',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Sign in
|
{m === 'login' ? 'Sign in' : 'Register'}
|
||||||
</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>
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
{mode === 'register' && (
|
<label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
<div>
|
<span style={fieldLabelStyle}>Email</span>
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">Work email</label>
|
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
required
|
required
|
||||||
|
placeholder="you@randall.local"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="jane@company.com"
|
style={inputStyle}
|
||||||
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>
|
</label>
|
||||||
|
|
||||||
<div>
|
<label style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">Password</label>
|
<span style={fieldLabelStyle}>Password</span>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
|
placeholder="••••••••"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="••••••••"
|
style={inputStyle}
|
||||||
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>
|
</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 && (
|
{error && (
|
||||||
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
|
<div style={{
|
||||||
|
fontSize: 13, color: '#c0392b',
|
||||||
|
background: 'rgba(192,57,43,0.08)',
|
||||||
|
borderRadius: 8, padding: '10px 14px',
|
||||||
|
}}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
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"
|
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'}
|
{loading ? '…' : mode === 'login' ? 'Sign in →' : 'Create account →'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>}
|
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import { ReservationModal } from '../components/ReservationModal';
|
|||||||
import { CancelModal } from '../components/CancelModal';
|
import { CancelModal } from '../components/CancelModal';
|
||||||
import { MyReservations } from '../components/MyReservations';
|
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 {
|
function toIsoDate(date: Date): string {
|
||||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
const d = String(date.getDate()).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 {
|
interface PlannerPageProps {
|
||||||
auth: AuthResponse;
|
auth: AuthResponse;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
@@ -34,7 +83,8 @@ export function PlannerPage({ auth, onLogout }: PlannerPageProps) {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const today = toIsoDate(new Date());
|
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 [selectedDate, setSelectedDate] = useState(today);
|
||||||
const [schedule, setSchedule] = useState<WorkplaceScheduleItem[]>([]);
|
const [schedule, setSchedule] = useState<WorkplaceScheduleItem[]>([]);
|
||||||
@@ -65,6 +115,30 @@ export function PlannerPage({ auth, onLogout }: PlannerPageProps) {
|
|||||||
.map((r) => r.workplaceId),
|
.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) {
|
function handleDeskClick(desk: WorkplaceScheduleItem) {
|
||||||
if (myReservedIdsOnDate.has(desk.id)) {
|
if (myReservedIdsOnDate.has(desk.id)) {
|
||||||
const res = myReservations.find(
|
const res = myReservations.find(
|
||||||
@@ -93,93 +167,380 @@ export function PlannerPage({ auth, onLogout }: PlannerPageProps) {
|
|||||||
const podA = schedule.filter((w) => w.location === 'Pod A');
|
const podA = schedule.filter((w) => w.location === 'Pod A');
|
||||||
const podB = schedule.filter((w) => w.location === 'Pod B');
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50">
|
/* Outer bg fills the full viewport */
|
||||||
<header className="bg-white border-b border-slate-200 px-6 py-4 shadow-sm">
|
<div style={{ background: 'var(--bg)', minHeight: '100vh' }}>
|
||||||
<div className="max-w-5xl mx-auto flex items-center justify-between">
|
{/* Centred 1100 px frame */}
|
||||||
<div>
|
<div style={{
|
||||||
<h1 className="text-xl font-semibold text-slate-800">Office Planner</h1>
|
maxWidth: 1100,
|
||||||
<p className="text-xs text-slate-400 mt-0.5">Reserve your workspace up to 2 weeks ahead</p>
|
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>
|
||||||
<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 && (
|
{auth.isAdmin && (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/admin')}
|
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>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={onLogout}
|
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
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<span style={{ fontSize: 12, color: PURPLE, opacity: 0.6, cursor: 'default' }}>NL ▾</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="max-w-5xl mx-auto px-6 py-8 flex flex-col gap-8">
|
{/* Body */}
|
||||||
<section className="flex items-center gap-3 flex-wrap">
|
<div style={{
|
||||||
<label className="text-sm font-medium text-slate-600 whitespace-nowrap">Date</label>
|
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
|
<button
|
||||||
onClick={() => setSelectedDate(offsetDate(selectedDate, -1))}
|
onClick={() => setSelectedDate(offsetDate(selectedDate, -1))}
|
||||||
disabled={selectedDate <= today}
|
disabled={dayOffset === 0}
|
||||||
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"
|
style={arrowBtnStyle(dayOffset === 0)}
|
||||||
>
|
>
|
||||||
←
|
←
|
||||||
</button>
|
</button>
|
||||||
<input
|
<div style={{ display: 'flex', gap: 5, flex: 1 }}>
|
||||||
type="date"
|
{dayStrip.map((dateIso) => {
|
||||||
value={selectedDate}
|
const isActive = dateIso === selectedDate;
|
||||||
min={today}
|
const { weekday, day, monthTag } = formatDayCell(dateIso);
|
||||||
max={maxDate}
|
return (
|
||||||
onChange={(e) => setSelectedDate(e.target.value)}
|
<button
|
||||||
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"
|
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
|
<button
|
||||||
onClick={() => setSelectedDate(offsetDate(selectedDate, 1))}
|
onClick={() => setSelectedDate(offsetDate(selectedDate, 1))}
|
||||||
disabled={selectedDate >= maxDate}
|
disabled={dayOffset >= MAX_OFFSET}
|
||||||
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"
|
style={arrowBtnStyle(dayOffset >= MAX_OFFSET)}
|
||||||
>
|
>
|
||||||
→
|
→
|
||||||
</button>
|
</button>
|
||||||
<span className="text-sm text-slate-500 font-medium">{formatDisplayDate(selectedDate)}</span>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-6 text-xs text-slate-500">
|
{/* Floor card */}
|
||||||
<span className="flex items-center gap-1.5">
|
<div style={{
|
||||||
<span className="w-3 h-3 rounded bg-emerald-400 inline-block" />
|
background: PAPER,
|
||||||
Available — click to reserve
|
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>
|
||||||
<span className="flex items-center gap-1.5">
|
<span style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||||
<span className="w-3 h-3 rounded bg-blue-400 inline-block" />
|
<span style={{
|
||||||
Your reservation — click to cancel
|
width: 8, height: 8, borderRadius: 2,
|
||||||
|
background: SAGE, border: `1px solid ${SAGE_DEEP}`,
|
||||||
|
display: 'inline-block', flexShrink: 0,
|
||||||
|
}} />
|
||||||
|
yours
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1.5">
|
<span style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||||
<span className="w-3 h-3 rounded bg-slate-300 inline-block" />
|
<span style={{
|
||||||
Taken — hover to see who
|
width: 8, height: 8, borderRadius: 2,
|
||||||
|
border: '1px dashed rgba(91,79,199,0.4)',
|
||||||
|
display: 'inline-block', flexShrink: 0,
|
||||||
|
}} />
|
||||||
|
taken
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section>
|
{/* Pods */}
|
||||||
{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 && (
|
{!loadingFloor && !floorError && (
|
||||||
<div className="flex flex-wrap gap-12 justify-center">
|
<div style={{
|
||||||
<DeskPod desks={podA} myReservedIds={myReservedIdsOnDate} onDeskClick={handleDeskClick} />
|
display: 'flex',
|
||||||
<DeskPod desks={podB} myReservedIds={myReservedIdsOnDate} onDeskClick={handleDeskClick} />
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
{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>
|
||||||
|
|
||||||
<section className="bg-white border border-slate-200 rounded-2xl p-6 shadow-sm">
|
{/* Right rail */}
|
||||||
<h2 className="text-base font-semibold text-slate-700 mb-4">My reservations</h2>
|
<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} />
|
<MyReservations reservations={myReservations} onCancel={setCancelTarget} />
|
||||||
</section>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{reserveTarget && (
|
{reserveTarget && (
|
||||||
<ReservationModal
|
<ReservationModal
|
||||||
|
|||||||
Reference in New Issue
Block a user