improving-week-36 #1

Merged
jare2473 merged 41 commits from improving-week-36 into main 2025-09-04 10:49:05 +02:00
65 changed files with 5691 additions and 434 deletions

View File

@@ -1,11 +1,15 @@
import React from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import AppRoutes from './AppRoutes'; // move the routing and loading logic here
import { SettingsProvider } from './context/SettingsContext';
function App() {
return (
<Router basename={import.meta.env.BASE_URL}>
<AppRoutes />
</Router>
<SettingsProvider>
<Router basename={import.meta.env.BASE_URL}>
<AppRoutes />
</Router>
</SettingsProvider>
);
}

View File

@@ -1,20 +1,102 @@
import React from 'react';
import { Routes, Route, useLocation } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { CalendarDate } from '@internationalized/date';
import Layout from './Layout';
import { RoomBooking } from './pages/RoomBooking';
import { NewBooking } from './pages/NewBooking';
import { BookingSettings } from './pages/BookingSettings';
import { TestSession } from './pages/TestSession';
import FullScreenLoader from './components/FullScreenLoader';
const AppRoutes = () => {
const location = useLocation();
const [loading, setLoading] = useState(false);
const [bookings, setBookings] = useState([]);
const [showSuccessBanner, setShowSuccessBanner] = useState(false);
const [lastCreatedBooking, setLastCreatedBooking] = useState(null);
const [showDeleteBanner, setShowDeleteBanner] = useState(false);
const [lastDeletedBooking, setLastDeletedBooking] = useState(null);
const [bookings, setBookings] = useState([
{
id: 1,
date: new CalendarDate(2025, 9, 3),
startTime: 4,
endTime: 6,
room: 'G5:7',
roomCategory: 'green',
title: 'Team standup',
participants: [
{ id: 2, name: 'Filip Norgren', username: 'fino2341', email: 'filip.norgren@dsv.su.se' },
{ id: 3, name: 'Hedvig Engelmark', username: 'heen9876', email: 'hedvig.engelmark@dsv.su.se' },
{ id: 4, name: 'Elin Rudling', username: 'elru4521', email: 'elin.rudling@dsv.su.se' }
]
},
{
id: 2,
date: new CalendarDate(2025, 9, 5),
startTime: 8,
endTime: 12,
room: 'G5:12',
roomCategory: 'red',
title: 'Project planning workshop',
participants: [
{ id: 5, name: 'Victor Magnusson', username: 'vima8734', email: 'victor.magnusson@dsv.su.se' },
{ id: 6, name: 'Ellen Britschgi', username: 'elbr5623', email: 'ellen.britschgi@dsv.su.se' },
{ id: 7, name: 'Anna Andersson', username: 'anan3457', email: 'anna.andersson@dsv.su.se' },
{ id: 8, name: 'Erik Larsson', username: 'erla7892', email: 'erik.larsson@dsv.su.se' },
{ id: 9, name: 'Sofia Karlsson', username: 'soka1245', email: 'sofia.karlsson@dsv.su.se' },
{ id: 10, name: 'Magnus Nilsson', username: 'mani6789', email: 'magnus.nilsson@dsv.su.se' }
]
},
{
id: 3,
date: new CalendarDate(2025, 9, 4),
startTime: 2,
endTime: 3,
room: 'G5:3',
roomCategory: 'blue',
title: '1:1 with supervisor',
participants: [
{ id: 251, name: 'Arjohn Emilsson', username: 'arem1532', email: 'arjohn.emilsson@dsv.su.se' }
]
},
{
id: 4,
date: new CalendarDate(2025, 9, 6),
startTime: 6,
endTime: 8,
room: 'G5:15',
roomCategory: 'yellow',
title: 'Study group session',
participants: [
{ id: 11, name: 'Emma Johansson', username: 'emjo4512', email: 'emma.johansson@dsv.su.se' },
{ id: 12, name: 'Oskar Pettersson', username: 'ospe3698', email: 'oskar.pettersson@dsv.su.se' }
]
}
]);
function addBooking(newBooking) {
setBookings([...bookings, newBooking]);
setLastCreatedBooking(newBooking);
setShowSuccessBanner(true);
}
function updateBooking(updatedBooking) {
setBookings(bookings.map(booking =>
booking.id === updatedBooking.id ? updatedBooking : booking
));
}
function deleteBooking(bookingToDelete) {
setBookings(bookings.filter(booking => booking.id !== bookingToDelete.id));
setLastDeletedBooking(bookingToDelete);
setShowDeleteBanner(true);
}
useEffect(() => {
// Reset scroll position on route change
window.scrollTo(0, 0);
setLoading(true);
const timer = setTimeout(() => setLoading(false), 800);
return () => clearTimeout(timer);
@@ -23,12 +105,17 @@ const AppRoutes = () => {
return (
<>
{/* Pass loading as isVisible to FullScreenLoader */}
<FullScreenLoader isVisible={loading} />
{/*<FullScreenLoader isVisible={loading} />*/}
<Routes>
{/* Fullscreen route outside of Layout */}
<Route path="test-session" element={<TestSession />} />
<Route path="/" element={<Layout />}>
<Route index element={<RoomBooking bookings={bookings} />} />
<Route index element={<RoomBooking bookings={bookings} showSuccessBanner={showSuccessBanner} lastCreatedBooking={lastCreatedBooking} onDismissBanner={() => setShowSuccessBanner(false)} onBookingUpdate={updateBooking} onBookingDelete={deleteBooking} showDeleteBanner={showDeleteBanner} lastDeletedBooking={lastDeletedBooking} onDismissDeleteBanner={() => setShowDeleteBanner(false)} />} />
<Route path="new-booking" element={<NewBooking addBooking={addBooking} />} />
<Route path="booking-settings" element={<BookingSettings />} />
</Route>
</Routes>
</>

View File

@@ -1,4 +1,5 @@
// components/Layout.jsx
import React from 'react';
import { Outlet, Link } from 'react-router-dom';
import Header from './components/Header';

View File

@@ -1,30 +0,0 @@
import styles from './Booking.module.css';
import { convertDateObjectToString } from '../helpers';
function Booking({ booking, handleEditBooking }) {
function getTimeFromIndex(timeIndex) {
const totalHalfHoursFromStart = timeIndex;
const totalMinutes = 8 * 60 + totalHalfHoursFromStart * 30; // 8:00 as base
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return `${hours}:${minutes === 0 ? '00' : '30'}`;
}
return (
<div className={styles.container} onClick={() => handleEditBooking(booking)}>
<div className={styles.left}>
<p className={styles.date}>{convertDateObjectToString(booking.date)}</p>
<p>{booking.title}</p>
</div>
<div className={styles.right}>
<p className={styles.room}>{booking.room}</p>
<p className={styles.time}>{getTimeFromIndex(booking.startTime)} - {getTimeFromIndex(booking.endTime)}</p>
</div>
</div>
);
}
export default Booking;

View File

@@ -1,44 +0,0 @@
.container {
display: flex;
justify-content: space-between;
border: 1px solid #E5E5E5;
padding: 0.7rem;
width: 100%;
border-radius: 0.5rem;
}
.left {
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.right {
display: flex;
flex-direction: column;
align-items: end;
}
.container p {
margin: 0;
}
.container:hover {
cursor: pointer;
}
.date {
text-transform: uppercase;
font-size: 0.8rem;
}
.room {
font-weight: 600;
font-size: 0.8rem;
color: #5d5d5d;
}
.time {
font-size: 1.2rem;
}

View File

@@ -0,0 +1,248 @@
import React, { useState, useEffect, useRef } from 'react';
import { Button } from 'react-aria-components';
import styles from './BookingCard.module.css';
import { convertDateObjectToString } from '../helpers';
import Dropdown from './Dropdown';
import { BookingTitleField } from './BookingTitleField';
import { ParticipantsSelector } from './ParticipantsSelector';
import { BookingProvider } from '../context/BookingContext';
import { PEOPLE } from '../constants/bookingConstants';
function BookingCard({ booking, onClick, isExpanded, onBookingUpdate, onBookingDelete }) {
const [selectedLength, setSelectedLength] = useState(null);
const [calculatedEndTime, setCalculatedEndTime] = useState(null);
const [editedTitle, setEditedTitle] = useState('');
const [editedParticipants, setEditedParticipants] = useState([]);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
// Calculate current booking length and available hours
const currentLength = booking.endTime - booking.startTime;
const maxAvailableTime = 16; // Max booking slots
const hoursAvailable = Math.min(maxAvailableTime - booking.startTime, 8);
// Initialize state when card expands
useEffect(() => {
if (isExpanded) {
setSelectedLength(currentLength);
setCalculatedEndTime(booking.endTime);
setEditedTitle(booking.title);
setEditedParticipants(booking.participants || []);
}
}, [isExpanded, booking, currentLength]);
// Create a local booking context for the components
const localBookingContext = {
title: editedTitle,
setTitle: setEditedTitle,
participants: editedParticipants,
handleParticipantChange: (participantId) => {
const participant = PEOPLE.find(p => p.id === participantId);
if (participant && !editedParticipants.find(p => p.id === participantId)) {
setEditedParticipants(participants => [...participants, participant]);
}
},
handleRemoveParticipant: (participantToRemove) => {
setEditedParticipants(participants =>
participants.filter(p => p.id !== participantToRemove.id)
);
}
};
const bookingLengths = [
{ value: 1, label: "30 min" },
{ value: 2, label: "1 h" },
{ value: 3, label: "1.5 h" },
{ value: 4, label: "2 h" },
{ value: 5, label: "2.5 h" },
{ value: 6, label: "3 h" },
{ value: 7, label: "3.5 h" },
{ value: 8, label: "4 h" },
];
const disabledOptions = {
1: !(hoursAvailable > 0),
2: !(hoursAvailable > 1),
3: !(hoursAvailable > 2),
4: !(hoursAvailable > 3),
5: !(hoursAvailable > 4),
6: !(hoursAvailable > 5),
7: !(hoursAvailable > 6),
8: !(hoursAvailable > 7),
};
function handleLengthChange(event) {
const lengthValue = event.target.value === "" ? null : parseInt(event.target.value);
setSelectedLength(lengthValue);
if (lengthValue !== null) {
const newEndTime = booking.startTime + lengthValue;
setCalculatedEndTime(newEndTime);
} else {
setCalculatedEndTime(booking.endTime);
}
}
function handleSave() {
if (selectedLength !== null && onBookingUpdate) {
const updatedBooking = {
...booking,
title: editedTitle,
participants: editedParticipants,
endTime: calculatedEndTime
};
onBookingUpdate(updatedBooking);
}
onClick(); // Close the expanded view
}
function handleCancel() {
setSelectedLength(currentLength);
setCalculatedEndTime(booking.endTime);
setEditedTitle(booking.title);
setEditedParticipants(booking.participants || []);
onClick(); // Close the expanded view
}
function handleDelete() {
setShowDeleteConfirm(true);
}
function confirmDelete() {
if (onBookingDelete) {
onBookingDelete(booking);
}
setShowDeleteConfirm(false);
}
function cancelDelete() {
setShowDeleteConfirm(false);
}
function getTimeFromIndex(timeIndex) {
const totalHalfHoursFromStart = timeIndex;
const totalMinutes = 8 * 60 + totalHalfHoursFromStart * 30; // 8:00 as base
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return `${hours}:${minutes === 0 ? '00' : '30'}`;
}
function getRoomCategoryClass(category) {
return `room-${category}`;
}
function formatParticipants(participants) {
if (!participants || participants.length === 0) return null;
if (participants.length === 1) {
return participants[0].name;
} else if (participants.length === 2) {
return `${participants[0].name} and ${participants[1].name}`;
} else {
const remaining = participants.length - 2;
return `${participants[0].name}, ${participants[1].name} and ${remaining} more`;
}
}
return (
<div className={`${styles.card} ${isExpanded ? styles.expanded : ''}`}>
<div className={styles.header} onClick={!isExpanded ? onClick : undefined}>
<div className={styles.leftSection}>
<span className={styles.date}>{convertDateObjectToString(booking.date)}</span>
<div className={styles.titleRow}>
<h3 className={styles.title}>{booking.title}</h3>
<span className={`${styles.room} ${styles[getRoomCategoryClass(booking.roomCategory)]}`}>{booking.room}</span>
</div>
{booking.participants && booking.participants.length > 0 && (
<p className={styles.participants}>{formatParticipants(booking.participants)}</p>
)}
</div>
<div className={styles.timeSection}>
<div className={styles.startTime}>{getTimeFromIndex(booking.startTime)}</div>
<div className={styles.endTime}>{getTimeFromIndex(calculatedEndTime || booking.endTime)}</div>
</div>
</div>
{isExpanded && (
<BookingProvider value={localBookingContext}>
<div className={styles.expandedContent}>
<div className={styles.formSection}>
<BookingTitleField compact={true} />
</div>
<div className={styles.formSection}>
<ParticipantsSelector compact={true} />
</div>
<div className={styles.editSection}>
<label className={styles.label}>Ändra längd</label>
<Dropdown
options={bookingLengths}
disabledOptions={disabledOptions}
onChange={handleLengthChange}
value={selectedLength || ""}
placeholder={{
value: "",
label: "Välj bokningslängd"
}}
/>
</div>
{!showDeleteConfirm ? (
<div className={styles.buttonSection}>
<Button
className={styles.deleteButton}
onPress={handleDelete}
>
Radera
</Button>
<Button
className={styles.cancelButton}
onPress={handleCancel}
>
Avbryt
</Button>
<Button
className={`${styles.saveButton} ${selectedLength === null ? styles.disabledButton : ''}`}
onPress={handleSave}
isDisabled={selectedLength === null}
>
{selectedLength !== null ? 'Spara ändringar' : 'Välj längd först'}
</Button>
</div>
) : (
<div className={styles.confirmationSection}>
<div className={styles.confirmationMessage}>
<span className={styles.warningIcon}></span>
<p>Är du säker att du vill radera denna bokning?</p>
<p className={styles.bookingDetails}>
"{booking.title}" den {booking.date.day}/{booking.date.month} kl. {getTimeFromIndex(booking.startTime)}
</p>
</div>
<div className={styles.confirmationButtons}>
<Button
className={styles.confirmDeleteButton}
onPress={confirmDelete}
>
Ja, radera
</Button>
<Button
className={styles.cancelDeleteButton}
onPress={cancelDelete}
>
Avbryt
</Button>
</div>
</div>
)}
</div>
</BookingProvider>
)}
</div>
);
}
export default BookingCard;

View File

@@ -0,0 +1,480 @@
.card {
border: 1px solid #E5E5E5;
padding: 1.25rem;
width: 100%;
border-radius: 0.5rem;
background: #fff;
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.card:hover {
cursor: pointer;
border-color: #007AFF;
box-shadow: 0 4px 16px rgba(0, 122, 255, 0.12);
transform: translateY(-2px);
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
height: 100%;
}
.leftSection {
flex: 1;
}
.date {
text-transform: uppercase;
font-size: 0.8rem;
font-weight: 600;
color: #999;
letter-spacing: 1px;
margin-bottom: 0.5rem;
display: block;
}
.titleRow {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.title {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
color: #333;
line-height: 1.3;
}
.room {
font-weight: 600;
font-size: 0.875rem;
padding: 0.375rem 0.75rem;
border-radius: 1rem;
white-space: nowrap;
}
.room-green {
background: #D4EDDA;
color: #155724;
}
.room-red {
background: #F8D7DA;
color: #721C24;
}
.room-blue {
background: #D1ECF1;
color: #0C5460;
}
.room-yellow {
background: #FFF3CD;
color: #856404;
}
.timeSection {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
/*background-color:rgba(0, 122, 255, 0.12);*/
min-height: 80px;
gap: 0.2rem;
}
.startTime {
font-size: 1.6rem;
font-weight: 400;
color: #333;
line-height: 1;
}
.endTime {
font-size: 1.6rem;
font-weight: 400;
color: #acacac;
line-height: 1;
}
.participants {
margin: 0;
font-size: 0.9rem;
color: #999;
}
/* Expanded card styles */
.expanded {
border-color: #007AFF;
box-shadow: 0 4px 16px rgba(0, 122, 255, 0.12);
}
.expanded:hover {
transform: none;
}
.expanded .header {
cursor: default;
}
.expandedContent {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid #E5E5E5;
}
.formSection {
margin-bottom: 1.5rem;
}
.editSection {
margin-bottom: 1.5rem;
}
.label {
display: block;
font-size: 0.8rem;
color: #717171;
font-weight: 500;
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.buttonSection {
display: flex;
gap: 1rem;
align-items: center;
}
.deleteButton {
flex: 2;
background-color: #DC2626;
color: white;
height: 3rem;
font-weight: 600;
font-size: 0.9rem;
border: 2px solid #B91C1C;
border-radius: 0.5rem;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(220, 38, 38, 0.2);
cursor: pointer;
}
.deleteButton:hover {
background-color: #B91C1C;
box-shadow: 0 4px 8px rgba(220, 38, 38, 0.3);
}
.deleteButton:active {
background-color: #991B1B;
transform: translateY(1px);
box-shadow: 0 1px 2px rgba(220, 38, 38, 0.2);
}
.cancelButton {
flex: 2;
background-color: white;
height: 3rem;
color: #374151;
font-weight: 600;
border: 2px solid #d1d5db;
border-radius: 0.5rem;
transition: all 0.2s ease;
cursor: pointer;
font-size: 0.9rem;
}
.cancelButton:hover {
background-color: #f9fafb;
border-color: #9ca3af;
}
.cancelButton:active {
background-color: #e5e7eb;
transform: translateY(1px);
}
.saveButton {
flex: 3;
background-color: #059669;
color: white;
height: 3rem;
font-weight: 600;
font-size: 0.95rem;
border: 2px solid #047857;
border-radius: 0.5rem;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(5, 150, 105, 0.2);
cursor: pointer;
}
.saveButton:hover {
background-color: #047857;
box-shadow: 0 4px 8px rgba(5, 150, 105, 0.3);
}
.saveButton:active {
background-color: #065f46;
transform: translateY(1px);
box-shadow: 0 1px 2px rgba(5, 150, 105, 0.2);
}
.saveButton[data-focused],
.cancelButton[data-focused],
.deleteButton[data-focused] {
outline: 2px solid #2563EB;
outline-offset: -1px;
}
.disabledButton {
background-color: #f8f9fa !important;
color: #adb5bd !important;
border: 2px dashed #dee2e6 !important;
opacity: 0.6 !important;
box-shadow: none !important;
cursor: default !important;
}
.disabledButton:hover {
background-color: #f8f9fa !important;
transform: none !important;
box-shadow: none !important;
}
.disabledButton:active {
background-color: #f8f9fa !important;
transform: none !important;
}
/* Compact form inputs */
.compactInput {
width: 100%;
padding: 0.5rem 1rem;
border: 1px solid #ccc;
border-radius: 0.375rem;
font-size: 1rem;
background-color: white;
font-family: inherit;
transition: border-color 0.2s ease;
box-sizing: border-box;
height: auto;
}
.compactInput:focus {
outline: none;
border-color: #007AFF;
box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.1);
}
.compactInput::placeholder {
color: #adadad;
}
/* Participant search styles */
.searchContainer {
position: relative;
margin-bottom: 1rem;
}
.searchDropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid #D2D9E0;
border-top: none;
border-radius: 0 0 0.5rem 0.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
max-height: 200px;
overflow-y: auto;
z-index: 1000;
}
.dropdownItem {
padding: 0.75rem;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.2s ease;
}
.dropdownItem:hover {
background-color: #f8f9fa;
}
.dropdownItem:last-child {
border-bottom: none;
}
.personName {
display: block;
font-weight: 500;
color: #333;
font-size: 0.9rem;
}
.personUsername {
display: block;
color: #666;
font-size: 0.8rem;
margin-top: 0.25rem;
}
.participantsContainer {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
min-height: 2.5rem;
align-items: center;
}
.participantChip {
display: flex;
align-items: center;
gap: 0.5rem;
background-color: #E3F2FD;
color: #1976D2;
padding: 0.4rem 0.6rem;
border-radius: 1rem;
font-size: 0.85rem;
font-weight: 500;
}
.participantName {
font-weight: 500;
}
.removeButton {
background: none;
border: none;
color: #1976D2;
font-size: 1.2rem;
font-weight: bold;
cursor: pointer;
padding: 0;
margin-left: 0.25rem;
width: 1.5rem;
height: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s ease;
}
.removeButton:hover {
background-color: rgba(25, 118, 210, 0.1);
}
.removeButton:active {
background-color: rgba(25, 118, 210, 0.2);
}
.noParticipants {
color: #999;
font-style: italic;
font-size: 0.9rem;
}
/* Confirmation dialog styles */
.confirmationSection {
background-color: #FFF8DC;
border: 2px solid #FFD700;
border-radius: 0.5rem;
padding: 1.5rem;
margin-top: 1rem;
}
.confirmationMessage {
text-align: center;
margin-bottom: 1.5rem;
}
.warningIcon {
font-size: 2rem;
display: block;
margin-bottom: 0.5rem;
}
.confirmationMessage p {
margin: 0.5rem 0;
color: #333;
}
.confirmationMessage p:first-of-type {
font-weight: 600;
font-size: 1.1rem;
}
.bookingDetails {
font-size: 0.9rem;
color: #666;
font-style: italic;
}
.confirmationButtons {
display: flex;
gap: 1rem;
justify-content: center;
}
.confirmDeleteButton {
background-color: #DC2626;
color: white;
height: 3rem;
font-weight: 600;
font-size: 0.95rem;
border: 2px solid #B91C1C;
border-radius: 0.5rem;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(220, 38, 38, 0.2);
cursor: pointer;
padding: 0 1.5rem;
}
.confirmDeleteButton:hover {
background-color: #B91C1C;
box-shadow: 0 4px 8px rgba(220, 38, 38, 0.3);
}
.confirmDeleteButton:active {
background-color: #991B1B;
transform: translateY(1px);
box-shadow: 0 1px 2px rgba(220, 38, 38, 0.2);
}
.cancelDeleteButton {
background-color: white;
height: 3rem;
color: #374151;
font-weight: 600;
border: 2px solid #d1d5db;
border-radius: 0.5rem;
transition: all 0.2s ease;
cursor: pointer;
font-size: 0.95rem;
padding: 0 1.5rem;
}
.cancelDeleteButton:hover {
background-color: #f9fafb;
border-color: #9ca3af;
}
.cancelDeleteButton:active {
background-color: #e5e7eb;
transform: translateY(1px);
}
.confirmDeleteButton[data-focused],
.cancelDeleteButton[data-focused] {
outline: 2px solid #2563EB;
outline-offset: -1px;
}

View File

@@ -1,23 +1,47 @@
import React from 'react';
import { DatePicker } from '../react-aria-starter/src/DatePicker';
import { today, getLocalTimeZone } from '@internationalized/date';
import { getFutureDate, isDateUnavailable } from '../utils/bookingUtils';
import { useBookingContext } from '../context/BookingContext';
import { useSettingsContext } from '../context/SettingsContext';
export function BookingDatePicker() {
const booking = useBookingContext();
const { settings, getEffectiveToday } = useSettingsContext();
const minDate = getEffectiveToday();
const maxDate = minDate.add({ days: settings.bookingRangeDays });
const handlePreviousDay = () => {
const previousDay = booking.selectedDate.subtract({ days: 1 });
if (previousDay.compare(minDate) >= 0) {
booking.handleDateChange(previousDay);
}
};
const handleNextDay = () => {
const nextDay = booking.selectedDate.add({ days: 1 });
if (nextDay.compare(maxDate) <= 0) {
booking.handleDateChange(nextDay);
}
};
const canNavigatePrevious = booking.selectedDate.compare(minDate) > 0;
const canNavigateNext = booking.selectedDate.compare(maxDate) < 0;
return (
<div style={{ display: "flex", flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}>
<h2>Boka rum</h2>
<div>
<DatePicker
value={booking.selectedDate}
onChange={(date) => booking.handleDateChange(date)}
firstDayOfWeek="mon"
minValue={today(getLocalTimeZone())}
maxValue={getFutureDate(14)}
isDateUnavailable={isDateUnavailable}
/>
</div>
<DatePicker
value={booking.selectedDate}
onChange={(date) => booking.handleDateChange(date)}
firstDayOfWeek="mon"
minValue={minDate}
maxValue={maxDate}
isDateUnavailable={(date) => isDateUnavailable(date, minDate, settings.bookingRangeDays)}
onPreviousClick={handlePreviousDay}
onNextClick={handleNextDay}
canNavigatePrevious={canNavigatePrevious}
canNavigateNext={canNavigateNext}
/>
</div>
);
}

View File

@@ -0,0 +1,181 @@
import React, { useState, useEffect } from 'react';
import { Button, Dialog, Heading, Modal } from 'react-aria-components';
import { convertDateObjectToString, getTimeFromIndex } from '../helpers';
import Dropdown from './Dropdown';
import styles from './BookingDetailsModal.module.css';
function BookingDetailsModal({ booking, isOpen, onClose, onSave }) {
const [selectedLength, setSelectedLength] = useState(null);
const [calculatedEndTime, setCalculatedEndTime] = useState(null);
// Calculate current booking length and available hours
const currentLength = booking ? booking.endTime - booking.startTime : 1;
// For simplicity, assume max booking time is 8 hours (16 half-hour slots)
const maxAvailableTime = 16; // This could be dynamic based on room availability
const hoursAvailable = booking ? Math.min(maxAvailableTime - booking.startTime, 8) : 8;
// Initialize state when modal opens or booking changes
useEffect(() => {
if (isOpen && booking) {
setSelectedLength(currentLength);
setCalculatedEndTime(booking.endTime);
}
}, [isOpen, booking, currentLength]);
if (!booking) return null;
const bookingLengths = [
{ value: 1, label: "30 min" },
{ value: 2, label: "1 h" },
{ value: 3, label: "1.5 h" },
{ value: 4, label: "2 h" },
{ value: 5, label: "2.5 h" },
{ value: 6, label: "3 h" },
{ value: 7, label: "3.5 h" },
{ value: 8, label: "4 h" },
];
const disabledOptions = {
1: !(hoursAvailable > 0),
2: !(hoursAvailable > 1),
3: !(hoursAvailable > 2),
4: !(hoursAvailable > 3),
5: !(hoursAvailable > 4),
6: !(hoursAvailable > 5),
7: !(hoursAvailable > 6),
8: !(hoursAvailable > 7),
};
function handleLengthChange(event) {
const lengthValue = event.target.value === "" ? null : parseInt(event.target.value);
setSelectedLength(lengthValue);
if (lengthValue !== null) {
const newEndTime = booking.startTime + lengthValue;
setCalculatedEndTime(newEndTime);
} else {
setCalculatedEndTime(booking.endTime);
}
}
function handleSave() {
if (selectedLength !== null && onSave) {
const updatedBooking = {
...booking,
endTime: calculatedEndTime
};
onSave(updatedBooking);
}
onClose();
}
function handleCancel() {
setSelectedLength(currentLength);
setCalculatedEndTime(booking.endTime);
onClose();
}
function formatParticipants(participants) {
if (!participants || participants.length === 0) return 'Inga deltagare';
if (participants.length === 1) {
return participants[0].name;
} else if (participants.length === 2) {
return `${participants[0].name} and ${participants[1].name}`;
} else {
const remaining = participants.length - 2;
return `${participants[0].name}, ${participants[1].name} and ${remaining} more`;
}
}
return (
<Modal
isOpen={isOpen}
onOpenChange={onClose}
isDismissable
className={styles.modalContainer}
>
<Dialog className={styles.dialog}>
<div className={styles.header}>
<Heading slot="title" className={styles.title}>
{booking.title}
</Heading>
<Button
className={styles.closeButton}
onPress={onClose}
aria-label="Stäng"
>
×
</Button>
</div>
<div className={styles.content}>
<div className={styles.sectionWithTitle}>
<label>Datum</label>
<p>{convertDateObjectToString(booking.date)}</p>
</div>
<div className={styles.timeDisplay}>
<div className={styles.timeRange}>
<div className={styles.startTime}>
<label>Starttid</label>
<span className={styles.timeValue}>
{getTimeFromIndex(booking.startTime)}
</span>
</div>
<div className={styles.timeSeparator}></div>
<div className={styles.endTime}>
<label>Sluttid</label>
<span className={styles.timeValue}>
{getTimeFromIndex(calculatedEndTime || booking.endTime)}
</span>
</div>
</div>
</div>
<div className={styles.sectionWithTitle}>
<label>Längd</label>
<Dropdown
options={bookingLengths}
disabledOptions={disabledOptions}
onChange={handleLengthChange}
value={selectedLength || ""}
placeholder={{
value: "",
label: "Välj bokningslängd"
}}
/>
</div>
<div className={styles.sectionWithTitle}>
<label>Rum</label>
<p className={styles.roomText}>{booking.room}</p>
</div>
<div className={styles.sectionWithTitle}>
<label>Deltagare</label>
<p>{formatParticipants(booking.participants)}</p>
</div>
</div>
<div className={styles.modalFooter}>
<Button
className={styles.cancelButton}
onPress={handleCancel}
>
Avbryt
</Button>
<Button
className={`${styles.saveButton} ${selectedLength === null ? styles.disabledButton : ''}`}
onPress={handleSave}
isDisabled={selectedLength === null}
>
{selectedLength !== null ? 'Spara ändringar' : 'Välj längd först'}
</Button>
</div>
</Dialog>
</Modal>
);
}
export default BookingDetailsModal;

View File

@@ -0,0 +1,244 @@
.modalContainer {
background-color: transparent;
width: 85%;
max-width: 400px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.18) !important;
box-shadow: 0 32px 64px rgba(0, 0, 0, 0.12),
0 16px 32px rgba(0, 0, 0, 0.08),
0 8px 16px rgba(0, 0, 0, 0.04),
inset 0 1px 0 rgba(255, 255, 255, 0.15) !important;
backdrop-filter: blur(20px) saturate(140%) !important;
-webkit-backdrop-filter: blur(20px) saturate(140%) !important;
border-radius: 0.4rem;
}
.dialog {
overflow: hidden;
background: rgba(255, 255, 255, 0.95) !important;
padding: 0;
border-radius: 0.4rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 1.5rem 0;
margin-bottom: 1rem;
}
.title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
flex: 1;
}
.closeButton {
background: none;
border: none;
font-size: 1.5rem;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
color: #6b7280;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
margin-left: 1rem;
}
.closeButton:hover {
background-color: #f3f4f6;
color: #374151;
}
.closeButton:focus {
outline: 2px solid #2563eb;
outline-offset: -1px;
}
.content {
padding: 0 1.5rem 1.5rem;
}
.sectionWithTitle {
padding-bottom: 1rem;
display: flex;
flex-direction: column;
}
.sectionWithTitle label {
font-size: 0.8rem;
color: #717171;
font-weight: 500;
margin-bottom: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.sectionWithTitle p {
margin: 0;
font-size: 1rem;
color: #1f2937;
font-weight: 500;
}
.timeDisplay {
margin: 1rem 0;
padding: 1rem;
background-color: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
width: fit-content;
margin-left: 0;
}
.timeRange {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.startTime, .endTime {
display: flex;
flex-direction: column;
align-items: center;
}
.startTime label, .endTime label {
font-size: 0.75rem;
color: #6c757d;
font-weight: 500;
margin-bottom: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.timeValue {
font-size: 1.5rem;
font-weight: 600;
color: #212529;
}
.timeSeparator {
font-size: 1.5rem;
font-weight: 400;
color: #6c757d;
margin: 0 0.5rem;
padding-top: 1.3rem;
}
.roomText {
font-weight: 600 !important;
color: #059669 !important;
}
.modalFooter {
height: fit-content;
width: 100%;
display: flex;
align-items: center;
gap: 1rem;
margin-top: 2rem;
padding: 0 1.5rem 1.5rem;
}
.cancelButton {
flex: 2;
background-color: white;
height: 4rem;
color: #374151;
font-weight: 600;
border: 2px solid #d1d5db;
border-radius: 0.5rem;
transition: all 0.2s ease;
cursor: pointer;
}
.cancelButton:hover {
background-color: #f9fafb;
border-color: #9ca3af;
}
.cancelButton:active {
background-color: #e5e7eb;
transform: translateY(1px);
}
.saveButton {
flex: 3;
background-color: #059669;
color: white;
height: 4rem;
font-weight: 600;
font-size: 1.1rem;
border: 2px solid #047857;
border-radius: 0.5rem;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(5, 150, 105, 0.2);
cursor: pointer;
}
.saveButton:hover {
background-color: #047857;
box-shadow: 0 4px 8px rgba(5, 150, 105, 0.3);
}
.saveButton:active {
background-color: #065f46;
transform: translateY(1px);
box-shadow: 0 1px 2px rgba(5, 150, 105, 0.2);
}
.saveButton[data-focused],
.cancelButton[data-focused] {
outline: 2px solid #2563EB;
outline-offset: -1px;
}
.disabledButton {
background-color: #f8f9fa !important;
color: #adb5bd !important;
border: 2px dashed #dee2e6 !important;
opacity: 0.6 !important;
box-shadow: none !important;
cursor: default !important;
}
.disabledButton:hover {
background-color: #f8f9fa !important;
transform: none !important;
box-shadow: none !important;
}
.disabledButton:active {
background-color: #f8f9fa !important;
transform: none !important;
}
/* Modal overlay styles */
:global(.react-aria-ModalOverlay) {
z-index: 1100 !important;
overflow-y: auto !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
padding: 2rem 1rem !important;
box-sizing: border-box !important;
background: rgba(0, 0, 0, 0.2) !important;
backdrop-filter: blur(12px) saturate(150%) !important;
-webkit-backdrop-filter: blur(12px) saturate(150%) !important;
}
:global(.react-aria-ModalOverlay .react-aria-Modal) {
max-height: calc(100vh - 4rem) !important;
max-width: 90vw !important;
overflow-y: auto !important;
}

View File

@@ -1,64 +0,0 @@
import Dropdown from './Dropdown';
import { ComboBox } from '../react-aria-starter/src/ComboBox';
import { DEFAULT_BOOKING_TITLE, BOOKING_LENGTHS, SMALL_GROUP_ROOMS, PEOPLE } from '../constants/bookingConstants';
import { useBookingContext } from '../context/BookingContext';
import styles from './BookingFormFields.module.css';
export function BookingFormFields() {
const booking = useBookingContext();
return (
<>
<h3 className={styles.elementHeading}>Titel bokning</h3>
<input
type="text"
value={booking.title}
onChange={(event) => booking.setTitle(event.target.value)}
placeholder={DEFAULT_BOOKING_TITLE}
className={styles.textInput}
/>
<h3 className={styles.elementHeading}>Deltagare</h3>
<ComboBox
items={PEOPLE}
onSelectionChange={booking.handleParticipantChange}
placeholder={"Lägg till deltagare"}
value={booking.participant}
>
</ComboBox>
{booking.participants.length > 0 && (
<div>
{booking.participants.map((participant, index) => (
<p key={index}>{participant}</p>
))}
</div>
)}
<div style={{ display: "flex", flexDirection: "row", alignItems: "center", gap: "1rem" }}>
<div>
<h3 className={styles.elementHeading}>Rum</h3>
<Dropdown
options={SMALL_GROUP_ROOMS}
onChange={(e) => booking.handleRoomChange(e)}
placeholder={{
label: "Alla rum",
value: "allRooms"
}}
/>
</div>
<div>
<h3 className={styles.elementHeading}>Längd</h3>
<Dropdown
options={BOOKING_LENGTHS}
value={booking.selectedBookingLength}
onChange={(e) => booking.handleLengthChange(Number(e.target.value))}
placeholder={{
label: "Alla tider",
value: 0
}}
disabledOptions={booking.disabledOptions}
/>
</div>
</div>
</>
);
}

View File

@@ -1,26 +0,0 @@
.textInput {
width: 100%;
margin-bottom: 10px;
border: 1px solid #D2D9E0;
border-radius: 0.5rem;
font-size: 16px;
background-color: #FAFBFC;
padding: 1rem;
width: 300px;
font-family: inherit;
}
.textInput::placeholder {
color: #adadad;
}
.elementHeading {
margin: 0;
color: #8E8E8E;
font-size: 0.8rem;
font-style: normal;
font-weight: 520;
line-height: normal;
margin-bottom: 0.2rem;
margin-top: 1.5rem;
}

View File

@@ -0,0 +1,25 @@
import React from 'react';
import Dropdown from './Dropdown';
import { BOOKING_LENGTHS } from '../constants/bookingConstants';
import { useBookingContext } from '../context/BookingContext';
import styles from './BookingLengthField.module.css';
export function BookingLengthField() {
const booking = useBookingContext();
return (
<div>
<h3 className={styles.elementHeading}>Längd</h3>
<Dropdown
options={BOOKING_LENGTHS}
value={booking.selectedBookingLength}
onChange={(e) => booking.handleLengthChange(Number(e.target.value))}
placeholder={{
label: "Alla tider",
value: 0
}}
disabledOptions={booking.disabledOptions}
/>
</div>
);
}

View File

@@ -0,0 +1,9 @@
.elementHeading {
margin: 0;
color: #8E8E8E;
font-size: 0.8rem;
font-style: normal;
font-weight: 520;
line-height: normal;
margin-bottom: 0.2rem;
}

View File

@@ -0,0 +1,164 @@
import React, { useState, useEffect, useRef } from 'react';
import { Button, Dialog, Heading, Modal } from 'react-aria-components';
import { convertDateObjectToString, getTimeFromIndex } from '../helpers';
import Dropdown from './Dropdown';
import { useBookingContext } from '../context/BookingContext';
import { useSettingsContext } from '../context/SettingsContext';
import styles from './BookingModal.module.css';
export function BookingModal({
startTimeIndex,
hoursAvailable,
endTimeIndex,
setEndTimeIndex,
className,
onClose,
isOpen = true
}) {
const booking = useBookingContext();
const { getCurrentUser, getDefaultBookingTitle } = useSettingsContext();
// Initialize with pre-selected booking length if available, or auto-select if only 30 min available
const initialLength = booking.selectedBookingLength > 0 ? booking.selectedBookingLength :
(hoursAvailable === 1 ? 1 : null); // Auto-select 30 min if that's all that's available
const [selectedLength, setSelectedLength] = useState(null);
const [calculatedEndTime, setCalculatedEndTime] = useState(startTimeIndex);
const hasInitialized = useRef(false);
// Effect to handle initial setup only once when modal opens
useEffect(() => {
if (initialLength && !hasInitialized.current) {
setSelectedLength(initialLength);
const newEndTime = startTimeIndex + initialLength;
setCalculatedEndTime(newEndTime);
setEndTimeIndex(newEndTime);
booking.setSelectedEndIndex(newEndTime);
hasInitialized.current = true;
}
}, [initialLength, startTimeIndex, setEndTimeIndex, booking]);
const bookingLengths = [
{ value: 1, label: "30 min" },
{ value: 2, label: "1 h" },
{ value: 3, label: "1.5 h" },
{ value: 4, label: "2 h" },
{ value: 5, label: "2.5 h" },
{ value: 6, label: "3 h" },
{ value: 7, label: "3.5 h" },
{ value: 8, label: "4 h" },
];
function getLabelFromAvailableHours(availableHours) {
return bookingLengths.find(option => option.value === availableHours)?.label || "Välj längd";
}
const disabledOptions = {
1: !(hoursAvailable > 0),
2: !(hoursAvailable > 1),
3: !(hoursAvailable > 2),
4: !(hoursAvailable > 3),
5: !(hoursAvailable > 4),
6: !(hoursAvailable > 5),
7: !(hoursAvailable > 6),
8: !(hoursAvailable > 7),
};
function handleChange(event) {
const lengthValue = event.target.value === "" ? null : parseInt(event.target.value);
console.log(event.target.value);
setSelectedLength(lengthValue);
if (lengthValue !== null) {
const newEndTime = startTimeIndex + lengthValue;
setCalculatedEndTime(newEndTime);
setEndTimeIndex(newEndTime);
booking.setSelectedEndIndex(newEndTime);
} else {
// Reset to default state when placeholder is selected
setCalculatedEndTime(startTimeIndex);
setEndTimeIndex(startTimeIndex);
booking.setSelectedEndIndex(null);
}
}
// Check if user has selected a booking length (including pre-selected)
const hasSelectedLength = selectedLength !== null;
// Display time range - show calculated end time if length is selected
const displayEndTime = hasSelectedLength ? calculatedEndTime : startTimeIndex;
return (
<Modal
isOpen={isOpen}
isDismissable
onOpenChange={(open) => !open && onClose && onClose()}
className={className}
style={{borderRadius: '0.4rem', overflow: 'hidden'}}
>
<Dialog style={{overflow: 'hidden'}}>
<form>
<Heading slot="title">{booking.title == "" ? getDefaultBookingTitle() : booking.title}</Heading>
<p>{convertDateObjectToString(booking.selectedDate)}</p>
<div className={styles.timeDisplay}>
<div className={styles.timeRange}>
<div className={styles.startTime}>
<label>Starttid</label>
<span className={styles.timeValue}>{getTimeFromIndex(startTimeIndex)}</span>
</div>
<div className={styles.timeSeparator}></div>
<div className={styles.endTime}>
<label>Sluttid</label>
<span className={`${styles.timeValue} ${!hasSelectedLength ? styles.placeholder : ''}`}>
{hasSelectedLength ? getTimeFromIndex(displayEndTime) : "Välj längd"}
</span>
</div>
</div>
</div>
<div className={styles.sectionWithTitle}>
<label>Längd</label>
<Dropdown
options={bookingLengths}
disabledOptions={disabledOptions}
onChange={handleChange}
value={selectedLength || ""}
placeholder={!initialLength ? {
value: "",
label: "Välj bokningslängd"
} : null}
/>
</div>
<div className={styles.sectionWithTitle}>
<label>{booking.selectedRoom !== "allRooms" ? "Rum" : "Tilldelat rum"}</label>
<p>{booking.selectedRoom !== "allRooms" ? booking.selectedRoom : (booking.assignedRoom || 'Inget rum tilldelat')}</p>
</div>
<div className={styles.sectionWithTitle}>
<label>Deltagare</label>
<p>
{(() => {
const currentUser = getCurrentUser();
const allParticipants = [currentUser, ...booking.participants.filter(p => p.id !== currentUser.id)];
return allParticipants.map(p => p.name).join(", ");
})()}
</p>
</div>
<div className={styles.modalFooter}>
<Button className={styles.cancelButton} slot="close">
Avbryt
</Button>
<Button
className={`${styles.saveButton} ${!hasSelectedLength ? styles.disabledButton : ''}`}
onClick={hasSelectedLength ? booking.handleSave : undefined}
isDisabled={!hasSelectedLength}
>
{hasSelectedLength ? 'Boka' : 'Välj längd först'}
</Button>
</div>
</form>
</Dialog>
</Modal>
);
}

View File

@@ -0,0 +1,216 @@
/* TIME CARD */
.startTime {
width: fit-content;
font-weight: 600;
font-size: 1.3rem;
}
.modalFooter {
height: fit-content;
width: 100%;
color: blue;
display: flex;
align-items: center;
gap: 1rem;
margin-top: 2rem;
}
/* MODAL */
.cancelButton {
flex: 2;
background-color: white;
height: 4rem;
color: #374151;
font-weight: 600;
border: 2px solid #d1d5db;
border-radius: 0.5rem;
transition: all 0.2s ease;
}
@media (hover: hover) {
.cancelButton:hover {
background-color: #f9fafb;
border-color: #9ca3af;
cursor: pointer;
}
}
.saveButton {
flex: 3;
background-color: #059669;
color: white;
height: 4rem;
font-weight: 600;
font-size: 1.1rem;
border: 2px solid #047857;
border-radius: 0.5rem;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(5, 150, 105, 0.2);
}
@media (hover: hover) {
.saveButton:hover {
background-color: #047857;
box-shadow: 0 4px 8px rgba(5, 150, 105, 0.3);
cursor: pointer;
}
}
.saveButton:active {
background-color: #065f46;
transform: translateY(1px);
box-shadow: 0 1px 2px rgba(5, 150, 105, 0.2);
}
.saveButton[data-focused],
.cancelButton[data-focused] {
outline: 2px solid #2563EB;
outline-offset: -1px;
}
.cancelButton:active {
background-color: #e5e7eb;
transform: translateY(1px);
}
.timeSpan {
font-size: 2rem;
margin: 0;
}
.sectionWithTitle {
padding-top: 1rem;
display: flex;
flex-direction: column;
width: fit-content
}
.sectionWithTitle label {
font-size: 0.8rem;
color: #717171;
}
.sectionWithTitle p {
margin: 0;
}
.modalContainer {
background-color: white;
width: 85%;
max-width: 400px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.18) !important;
box-shadow: 0 32px 64px rgba(0, 0, 0, 0.12),
0 16px 32px rgba(0, 0, 0, 0.08),
0 8px 16px rgba(0, 0, 0, 0.04),
inset 0 1px 0 rgba(255, 255, 255, 0.15) !important;
backdrop-filter: blur(20px) saturate(140%) !important;
-webkit-backdrop-filter: blur(20px) saturate(140%) !important;
}
/* Ensure modal appears above header and handles overflow */
:global(.react-aria-ModalOverlay) {
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100vw !important;
height: 100vh !important;
z-index: 1100 !important;
overflow-y: auto !important;
display: flex !important;
align-items: safe center !important;
justify-content: center !important;
padding: 2rem 1rem !important;
box-sizing: border-box !important;
background: rgba(0, 0, 0, 0.5) !important;
backdrop-filter: blur(12px) saturate(150%) !important;
-webkit-backdrop-filter: blur(12px) saturate(150%) !important;
}
:global(.react-aria-ModalOverlay .react-aria-Modal.react-aria-Modal) {
max-height: calc(100vh - 4rem) !important;
max-width: 90vw !important;
overflow-y: auto !important;
background: white !important;
border-radius: 0.5rem !important;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25) !important;
}
/* New time display styles */
.timeDisplay {
margin: 1rem 0;
padding: 1rem;
background-color: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
min-width: 196px;
width: fit-content;
}
.timeRange {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.startTime, .endTime {
display: flex;
flex-direction: column;
align-items: center;
}
.startTime label, .endTime label {
font-size: 0.75rem;
color: #6c757d;
font-weight: 500;
margin-bottom: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.timeValue {
font-size: 1.5rem;
font-weight: 600;
color: #212529;
}
.timeValue.placeholder {
color: #adb5bd;
font-style: italic;
font-size: 1rem;
}
.timeSeparator {
font-size: 1.5rem;
font-weight: 400;
color: #6c757d;
margin: 0 0.5rem;
padding-top: 1.3rem;
}
/* Disabled button styles */
.disabledButton {
background-color: #f8f9fa !important;
color: #adb5bd !important;
border: 2px dashed #dee2e6 !important;
opacity: 0.6 !important;
box-shadow: none;
}
@media (hover: hover) {
.disabledButton:hover {
background-color: #f8f9fa !important;
transform: none !important;
box-shadow: none;
cursor: default;
}
}
.disabledButton:active {
background-color: #f8f9fa !important;
transform: none !important;
}

View File

@@ -0,0 +1,22 @@
import React from 'react';
import { useBookingContext } from '../context/BookingContext';
import { useSettingsContext } from '../context/SettingsContext';
import styles from './BookingTitleField.module.css';
export function BookingTitleField({ compact = false }) {
const booking = useBookingContext();
const { getDefaultBookingTitle } = useSettingsContext();
return (
<>
<h3 className={compact ? styles.compactElementHeading : styles.elementHeading}>Titel bokning</h3>
<input
type="text"
value={booking.title}
onChange={(event) => booking.setTitle(event.target.value)}
placeholder={getDefaultBookingTitle()}
className={compact ? styles.compactTextInput : styles.textInput}
/>
</>
);
}

View File

@@ -0,0 +1,57 @@
.textInput {
width: 100%;
margin-bottom: 10px;
border: 1px solid #D2D9E0;
border-radius: 0.5rem;
font-size: 16px;
background-color: #FAFBFC;
padding: 1rem;
width: 100%;
max-width: 600px;
font-family: inherit;
}
.textInput::placeholder {
color: #adadad;
}
.elementHeading {
margin: 0;
color: #8E8E8E;
font-size: 0.8rem;
font-style: normal;
font-weight: 520;
line-height: normal;
margin-bottom: 0.2rem;
margin-top: 1.5rem;
}
/* Compact styles */
.compactElementHeading {
font-size: 0.75rem;
color: #717171;
font-weight: 500;
margin-bottom: 0.4rem;
margin-top: 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.compactTextInput {
width: 100%;
padding: 0.5rem 1rem;
border: 1px solid #ccc;
border-radius: 0.375rem;
font-size: 16px;
background-color: white;
font-family: inherit;
transition: border-color 0.2s ease;
box-sizing: border-box;
margin-bottom: 0;
}
.compactTextInput:focus {
outline: 2px solid #007AFF;
outline-offset: 2px;
border-color: #007AFF;
}

View File

@@ -1,17 +1,103 @@
import React, { useState } from 'react';
import { CalendarDate } from '@internationalized/date';
import styles from './BookingsList.module.css';
import Booking from './Booking';
import BookingCard from './BookingCard';
import NotificationBanner from './NotificationBanner';
function BookingsList({ bookings, handleEditBooking }) {
function BookingsList({ bookings, handleEditBooking, onBookingUpdate, onBookingDelete, showSuccessBanner, lastCreatedBooking, onDismissBanner, showDeleteBanner, lastDeletedBooking, onDismissDeleteBanner, showDevelopmentBanner, showBookingConfirmationBanner, showBookingDeleteBanner }) {
const [showAll, setShowAll] = useState(false);
const [expandedBookingId, setExpandedBookingId] = useState(null);
const INITIAL_DISPLAY_COUNT = 3;
const displayedBookings = showAll ? bookings : bookings.slice(0, INITIAL_DISPLAY_COUNT);
const hasMoreBookings = bookings.length > INITIAL_DISPLAY_COUNT;
function handleBookingClick(booking) {
setExpandedBookingId(expandedBookingId === booking.id ? null : booking.id);
}
return (
<div className={styles.bookingsListContainer}>
{showSuccessBanner && (
<NotificationBanner
variant="success"
booking={lastCreatedBooking}
onClose={onDismissBanner}
showCloseButton={true}
/>
)}
{showDeleteBanner && (
<NotificationBanner
variant="delete"
booking={lastDeletedBooking}
onClose={onDismissDeleteBanner}
showCloseButton={true}
/>
)}
{showDevelopmentBanner && (
<NotificationBanner
variant="development"
/>
)}
{showBookingConfirmationBanner && (
<NotificationBanner
variant="success"
booking={{
title: 'Projektmöte',
room: 'G5:7',
date: new CalendarDate(2025, 9, 4),
startTime: 4,
endTime: 6
}}
showFakeCloseButton={true}
isTestBanner={true}
/>
)}
{showBookingDeleteBanner && (
<NotificationBanner
variant="delete"
booking={{
title: 'Uppföljningsmöte',
room: 'G5:12',
date: new CalendarDate(2025, 9, 5),
startTime: 6,
endTime: 8
}}
showFakeCloseButton={true}
isTestBanner={true}
/>
)}
<div className={styles.bookingsContainer}>
{bookings.length > 0 ? (
<>
{bookings.map((booking, index) => (
<Booking key={index} booking={booking} handleEditBooking={handleEditBooking} />
{displayedBookings.map((booking, index) => (
<BookingCard
key={index}
booking={booking}
onClick={() => handleBookingClick(booking)}
isExpanded={expandedBookingId === booking.id}
onBookingUpdate={onBookingUpdate}
onBookingDelete={onBookingDelete}
/>
))}
{hasMoreBookings && (
<button
className={styles.showMoreButton}
onClick={() => setShowAll(!showAll)}
>
{showAll ? (
<>
<span>Visa färre</span>
<span></span>
</>
) : (
<>
<span>Visa {bookings.length - INITIAL_DISPLAY_COUNT} fler</span>
<span></span>
</>
)}
</button>
)}
</>
) : (
<p className={styles.message}>Du har inga bokningar just nu</p>

View File

@@ -1,10 +1,8 @@
.bookingsListContainer {
padding: 1rem;
/*border-top: 1px solid gray;*/
padding-bottom: 2rem;
display: flex;
flex-direction: column;
padding-top: 1rem;
}
.bookingsContainer {
@@ -22,4 +20,48 @@
.heading {
margin: 0;
margin-bottom: 0.5rem;
}
.showMoreButton {
background: #ffffff;
border: none;
border-radius: 0.75rem;
padding: 1rem;
font-size: 1rem;
font-weight: 600;
color: rgb(0, 0, 0);
cursor: pointer;
transition: all 0.2s ease;
margin-top: 1rem;
width: 100%;
max-width: 100%;
box-shadow: 0 2px 8px rgba(143, 143, 143, 0.2);
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
box-sizing: border-box;
}
.showMoreButton:hover {
background: #f2f6ff;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(192, 192, 192, 0.3);
}
.showMoreButton:active {
transform: translateY(0);
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -1,3 +1,4 @@
import React from 'react';
import styles from './Card.module.css'; // Import the CSS Module
const Card = ({ imageUrl, header, subheader }) => {

View File

@@ -0,0 +1,70 @@
import React from 'react';
import {
Button,
ComboBox as AriaComboBox,
FieldError,
Input,
Label,
ListBox,
ListBoxItem,
Popover,
Text,
ListBoxSection,
Header,
Collection
} from 'react-aria-components';
import styles from './ComboBox.module.css';
export function ComboBox({
label,
description,
errorMessage,
children,
items,
...props
}) {
return (
<AriaComboBox {...props}>
<Label className={styles.comboBoxLabel}>{label}</Label>
<div className={styles.comboBoxContainer}>
<Input className={styles.comboBoxInput} />
<Button className={styles.comboBoxButton}></Button>
</div>
{description && <Text className={styles.comboBoxDescription} slot="description">{description}</Text>}
<FieldError className={styles.comboBoxError}>{errorMessage}</FieldError>
<Popover className={styles.comboBoxPopover}>
<ListBox className={styles.comboBoxList} items={items}>
<ListBoxSection id={"Hello"}>
<Header className={styles.sectionHeader}>SENASTE SÖKNINGAR</Header>
<Collection items={items}>
{item => <ListBoxItem className={styles.userItem} id={item.name}>{item.name}</ListBoxItem>}
</Collection>
</ListBoxSection>
</ListBox>
</Popover>
</AriaComboBox>
);
}
export function ComboBoxItem(props) {
return <ListBoxItem {...props} />;
}
function UserItem({ children, ...props }) {
return (
<ListBoxItem {...props} className={styles.userItem}>
{({ isSelected }) => (
<>
<span className={styles.userItemContent}>
{children}
</span>
{isSelected && (
<span className={styles.userItemCheckIcon}>
</span>
)}
</>
)}
</ListBoxItem>
);
}

View File

@@ -0,0 +1,150 @@
.comboBoxLabel {
color: var(--text-color);
}
.comboBoxContainer {
display: flex;
align-items: center;
font-family: inherit;
}
.comboBoxInput {
margin: 0;
font-size: 16px;
background-color: var(--field-background);
color: var(--field-text-color);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 1rem;
vertical-align: middle;
outline: none;
min-width: 0;
font-family: inherit;
width: 100%;
max-width: 600px;
}
.comboBoxInput[data-focused] {
outline: 2px solid var(--focus-ring-color);
outline-offset: -1px;
}
.comboBoxButton {
background: var(--highlight-background);
color: var(--highlight-foreground);
forced-color-adjust: none;
border-radius: 4px;
border: none;
margin-left: -1.714rem;
width: 1.429rem;
height: 1.429rem;
padding: 0;
font-size: 0.857rem;
cursor: default;
flex-shrink: 0;
}
.comboBoxButton[data-pressed] {
box-shadow: none;
background: var(--highlight-background);
}
.comboBoxDescription {
font-size: 12px;
}
.comboBoxError {
font-size: 12px;
color: var(--invalid-color);
}
.comboBoxPopover[data-trigger="ComboBox"] {
width: var(--trigger-width);
}
.comboBoxPopover {
box-sizing: border-box;
background-color: white;
}
.comboBoxList {
display: block;
margin: 0;
padding: 0;
width: unset;
max-height: inherit;
min-height: unset;
border: none;
background-color: white;
}
.userItem {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: default;
outline: none;
border-radius: 0.125rem;
color: var(--text-color);
padding: 0.5rem;
}
.userItem[data-focused],
.userItem[data-pressed] {
background: var(--highlight-background);
color: var(--highlight-foreground);
}
.userItem[data-selected] {
font-weight: 700;
background: unset;
color: var(--text-color);
position: relative;
}
.userItemContent {
flex: 1;
display: flex;
align-items: center;
gap: 0.75rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: normal;
}
.userItem[data-selected] .userItemContent {
font-weight: medium;
}
.userItemCheckIcon {
width: 1.25rem;
display: flex;
align-items: center;
color: var(--highlight-background);
}
.userItem[data-focused] .userItemCheckIcon {
color: var(--highlight-foreground);
}
.comboBoxAvatar {
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
background-color: lightgray;
}
.comboBoxName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sectionHeader {
font-weight: 700;
color: rgb(89, 89, 89);
font-size: 0.9rem;
margin-bottom: 0.4rem;
padding: 0 0.5rem;
}

View File

@@ -1,3 +1,4 @@
import React from 'react';
import styles from "./Dropdown.module.css";
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronDown } from '@fortawesome/free-solid-svg-icons'

View File

@@ -1,29 +1,33 @@
.dropdownWrapper {
position: relative;
display: inline-block;
width: 100%;
max-width: 200px;
}
.select {
font-family: inherit;
appearance: none;
-webkit-appearance: none;
padding: 0.5rem 2rem 0.5rem 1rem; /* Make room on right for chevron */
padding: 0.5rem 2.5rem 0.5rem 1rem; /* More room on right for chevron */
border: 1px solid #ccc;
border-radius: 0.375rem;
background-color: white;
color: #333;
cursor: pointer;
font-size: 1rem;
width: fit-content;
min-width: 6rem; /* Optional: prevent it from getting too small */
width: 100%;
min-width: 150px;
box-sizing: border-box;
}
.chevron {
pointer-events: none;
position: absolute;
top: 50%;
right: 0.75rem;
right: 1rem;
transform: translateY(-50%);
color: #888;
font-size: 0.8rem;
z-index: 1;
}

View File

@@ -31,6 +31,7 @@ const Header = () => {
<div className={styles.menu}>
{/* Menu items */}
<Link onClick={handleClick} to="/">Lokalbokning</Link>
<Link onClick={handleClick} to="/booking-settings">Booking Settings</Link>
</div>
)}
</header>

View File

@@ -0,0 +1,154 @@
import React, { useState, useEffect, useRef } from 'react';
import { Button } from 'react-aria-components';
import { convertDateObjectToString, getTimeFromIndex } from '../helpers';
import Dropdown from './Dropdown';
import { BookingTitleField } from './BookingTitleField';
import { ParticipantsSelector } from './ParticipantsSelector';
import { useBookingContext } from '../context/BookingContext';
import { useSettingsContext } from '../context/SettingsContext';
import styles from './InlineBookingForm.module.css';
export function InlineBookingForm({
startTimeIndex,
hoursAvailable,
onClose,
arrowPointsLeft = true
}) {
const booking = useBookingContext();
const { getCurrentUser } = useSettingsContext();
// Initialize with pre-selected booking length if available, or auto-select if only 30 min available
const initialLength = booking.selectedBookingLength > 0 ? booking.selectedBookingLength :
(hoursAvailable === 1 ? 1 : null); // Auto-select 30 min if that's all that's available
const [selectedLength, setSelectedLength] = useState(null);
const [calculatedEndTime, setCalculatedEndTime] = useState(startTimeIndex);
const hasInitialized = useRef(false);
// Effect to handle initial setup only once when form opens
useEffect(() => {
if (initialLength && !hasInitialized.current) {
setSelectedLength(initialLength);
const newEndTime = startTimeIndex + initialLength;
setCalculatedEndTime(newEndTime);
booking.setSelectedEndIndex(newEndTime);
hasInitialized.current = true;
}
}, [initialLength, startTimeIndex, booking]);
const bookingLengths = [
{ value: 1, label: "30 min" },
{ value: 2, label: "1 h" },
{ value: 3, label: "1.5 h" },
{ value: 4, label: "2 h" },
{ value: 5, label: "2.5 h" },
{ value: 6, label: "3 h" },
{ value: 7, label: "3.5 h" },
{ value: 8, label: "4 h" },
];
const disabledOptions = {
1: !(hoursAvailable > 0),
2: !(hoursAvailable > 1),
3: !(hoursAvailable > 2),
4: !(hoursAvailable > 3),
5: !(hoursAvailable > 4),
6: !(hoursAvailable > 5),
7: !(hoursAvailable > 6),
8: !(hoursAvailable > 7),
};
function handleChange(event) {
const lengthValue = event.target.value === "" ? null : parseInt(event.target.value);
setSelectedLength(lengthValue);
if (lengthValue !== null) {
const newEndTime = startTimeIndex + lengthValue;
setCalculatedEndTime(newEndTime);
booking.setSelectedEndIndex(newEndTime);
} else {
// Reset to default state when placeholder is selected
setCalculatedEndTime(startTimeIndex);
booking.setSelectedEndIndex(null);
}
}
// Check if user has selected a booking length (including pre-selected)
const hasSelectedLength = selectedLength !== null;
// Display time range - show calculated end time if length is selected
const displayEndTime = hasSelectedLength ? calculatedEndTime : startTimeIndex;
return (
<div className={`${styles.inlineForm} ${arrowPointsLeft ? styles.arrowLeft : styles.arrowRight}`}>
{/* Date Context */}
<div className={styles.formHeader}>
<p className={styles.dateText}>{convertDateObjectToString(booking.selectedDate)}</p>
</div>
{/* Title - What */}
<div className={styles.section}>
<BookingTitleField compact={true} />
</div>
{/* Time Selection - When */}
<div className={styles.section}>
<div className={styles.timeDisplay}>
<div className={styles.timeRange}>
<div className={styles.timeItem}>
<label>Starttid</label>
<span className={styles.timeValue}>{getTimeFromIndex(startTimeIndex)}</span>
</div>
<div className={styles.timeSeparator}></div>
<div className={styles.timeItem}>
<label>Sluttid</label>
<span className={`${styles.timeValue} ${!hasSelectedLength ? styles.placeholder : ''}`}>
{hasSelectedLength ? getTimeFromIndex(displayEndTime) : "Välj längd"}
</span>
</div>
</div>
</div>
<div className={styles.formField}>
<label>Längd</label>
<Dropdown
options={bookingLengths}
disabledOptions={disabledOptions}
onChange={handleChange}
value={selectedLength || ""}
placeholder={!initialLength ? {
value: "",
label: "Välj bokningslängd"
} : null}
/>
</div>
</div>
{/* Participants - Who */}
<div className={styles.section}>
<ParticipantsSelector compact={true} />
</div>
{/* Room - Where */}
<div className={styles.section}>
<div className={styles.sectionWithTitle}>
<label>{booking.selectedRoom !== "allRooms" ? "Rum" : "Tilldelat rum"}</label>
<p>{booking.selectedRoom !== "allRooms" ? booking.selectedRoom : (booking.assignedRoom || 'Inget rum tilldelat')}</p>
</div>
</div>
{/* Actions */}
<div className={styles.formActions}>
<Button className={styles.cancelButton} onPress={onClose}>
Avbryt
</Button>
<Button
className={`${styles.saveButton} ${!hasSelectedLength ? styles.disabledButton : ''}`}
onPress={hasSelectedLength ? booking.handleSave : undefined}
isDisabled={!hasSelectedLength}
>
{hasSelectedLength ? 'Boka' : 'Välj längd först'}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,252 @@
.inlineForm {
background: white;
border: 1px solid #D1D5DB;
border-radius: 0.5rem;
padding: 1.5rem;
margin: 1rem 0;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
animation: slideDown 0.2s ease-out;
width: 100%;
flex-basis: 100%;
max-width: none;
position: relative;
}
/* Arrow pointing to left card */
.arrowLeft::before {
content: '';
position: absolute;
top: -8px;
left: 75px;
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-bottom: 8px solid #D1D5DB;
}
.arrowLeft::after {
content: '';
position: absolute;
top: -7px;
left: 76px;
width: 0;
height: 0;
border-left: 7px solid transparent;
border-right: 7px solid transparent;
border-bottom: 7px solid white;
}
/* Arrow pointing to right card */
.arrowRight::before {
content: '';
position: absolute;
top: -8px;
right: 75px;
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-bottom: 8px solid #D1D5DB;
}
.arrowRight::after {
content: '';
position: absolute;
top: -7px;
right: 76px;
width: 0;
height: 0;
border-left: 7px solid transparent;
border-right: 7px solid transparent;
border-bottom: 7px solid white;
}
.formHeader {
text-align: center;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #E5E7EB;
}
.section {
margin-bottom: 1.5rem;
}
.section:last-of-type {
margin-bottom: 0;
}
.formHeader h3 {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
font-weight: 700;
color: #111827;
}
.dateText {
margin: 0;
color: #6B7280;
font-size: 0.875rem;
}
.timeDisplay {
margin-bottom: 1rem;
}
.timeRange {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 1rem;
background: #F9FAFB;
border-radius: 0.375rem;
}
.timeItem {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.timeItem label {
font-size: 0.75rem;
color: #6B7280;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.timeValue {
font-size: 1.25rem;
font-weight: 600;
color: #111827;
}
.timeValue.placeholder {
color: #9CA3AF;
font-style: italic;
}
.timeSeparator {
font-size: 1.5rem;
color: #6B7280;
font-weight: 300;
}
.formField {
margin-bottom: 1rem;
}
.formField label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
margin-bottom: 0.5rem;
}
.sectionWithTitle {
padding-top: 1rem;
display: flex;
flex-direction: column;
width: fit-content;
}
.sectionWithTitle label {
font-size: 0.8rem;
color: #717171;
}
.sectionWithTitle p {
margin: 0;
}
.formActions {
display: flex;
gap: 1rem;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid #E5E7EB;
}
.cancelButton {
flex: 1;
background-color: white;
height: 2.75rem;
color: #374151;
font-weight: 600;
border: 2px solid #d1d5db;
border-radius: 0.375rem;
transition: all 0.2s ease;
cursor: pointer;
font-size: 0.875rem;
}
.cancelButton:hover {
background-color: #f9fafb;
border-color: #9ca3af;
}
.cancelButton:active {
background-color: #e5e7eb;
transform: translateY(1px);
}
.saveButton {
flex: 2;
background-color: #059669;
color: white;
height: 2.75rem;
font-weight: 600;
font-size: 0.875rem;
border: 2px solid #047857;
border-radius: 0.375rem;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(5, 150, 105, 0.2);
cursor: pointer;
}
.saveButton:hover {
background-color: #047857;
box-shadow: 0 4px 8px rgba(5, 150, 105, 0.3);
}
.saveButton:active {
background-color: #065f46;
transform: translateY(1px);
box-shadow: 0 1px 2px rgba(5, 150, 105, 0.2);
}
.disabledButton {
background-color: #f8f9fa !important;
color: #adb5bd !important;
border: 2px dashed #dee2e6 !important;
opacity: 0.6 !important;
box-shadow: none !important;
cursor: default !important;
}
.disabledButton:hover {
background-color: #f8f9fa !important;
transform: none !important;
box-shadow: none !important;
}
.disabledButton:active {
background-color: #f8f9fa !important;
transform: none !important;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -0,0 +1,129 @@
import React, { useState } from 'react';
import styles from './NotificationBanner.module.css';
import { convertDateObjectToString } from '../helpers';
const BANNER_VARIANTS = {
success: {
icon: '✓',
title: 'Bokning bekräftad:',
className: 'success'
},
delete: {
icon: '🗑️',
title: 'Bokning raderad:',
className: 'delete'
},
development: {
icon: '🔧',
title: 'Visar testdata för utveckling',
className: 'development'
}
};
function NotificationBanner({
variant = 'success',
booking = null,
onClose,
showCloseButton = false,
showFakeCloseButton = false,
isTestBanner = false,
customTitle = null,
customContent = null
}) {
const [showTooltip, setShowTooltip] = useState(false);
function getTimeFromIndex(timeIndex) {
const totalHalfHoursFromStart = timeIndex;
const totalMinutes = 8 * 60 + totalHalfHoursFromStart * 30; // 8:00 as base
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return `${hours}:${minutes === 0 ? '00' : '30'}`;
}
function formatBookingDetails(booking) {
if (!booking) return '';
const dateStr = convertDateObjectToString(booking.date);
const startTime = getTimeFromIndex(booking.startTime);
const endTime = getTimeFromIndex(booking.endTime);
return `${booking.room}${dateStr}${startTime}-${endTime}`;
}
const handleFakeClose = () => {
setShowTooltip(true);
setTimeout(() => setShowTooltip(false), 3000);
};
const config = BANNER_VARIANTS[variant] || BANNER_VARIANTS.success;
// For development banner, use custom content
if (variant === 'development') {
return (
<div className={`${styles.banner} ${styles[config.className]}`}>
<div className={styles.bannerContent}>
<span className={styles.developmentIcon}>{config.icon}</span>
<span>{customTitle || config.title}</span>
</div>
</div>
);
}
// For booking-related banners (success/delete)
if (!booking && !customContent) return null;
return (
<div className={`${styles.banner} ${styles[config.className]}`}>
<div className={styles.bannerContent}>
<span className={`${styles.icon} ${styles[config.className + 'Icon']}`}>
{config.icon}
</span>
<div className={styles.text}>
<div className={styles.titleRow}>
<span className={`${styles.title} ${styles[config.className + 'Title']}`}>
{customTitle || config.title} {booking && booking.title && <span className={styles.bookingTitle}>{booking.title}</span>}
</span>
{isTestBanner && <span className={styles.testLabel}>TEST</span>}
</div>
{booking && (
<span className={`${styles.details} ${styles[config.className + 'Details']}`}>
{formatBookingDetails(booking)}
</span>
)}
{customContent && (
<span className={`${styles.details} ${styles[config.className + 'Details']}`}>
{customContent}
</span>
)}
</div>
</div>
{showCloseButton && (
<button
className={styles.closeButton}
onClick={onClose}
>
×
</button>
)}
{showFakeCloseButton && (
<div className={styles.fakeCloseContainer}>
<button
className={styles.closeButton}
onClick={handleFakeClose}
>
×
</button>
{showTooltip && (
<div className={styles.tooltip}>
Detta är en testbanner som inte kan stängas
</div>
)}
</div>
)}
</div>
);
}
export default NotificationBanner;

View File

@@ -0,0 +1,177 @@
/* Base banner styles */
.banner {
border-radius: 0.75rem;
padding: 1rem 1.25rem;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.bannerContent {
display: flex;
align-items: center;
gap: 1rem;
}
.icon {
width: 2rem;
height: 2rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 1rem;
flex-shrink: 0;
}
.text {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.titleRow {
display: flex;
align-items: center;
gap: 0.5rem;
}
.title {
font-weight: 700;
font-size: 1.1rem;
}
.details {
font-weight: 500;
font-size: 0.9rem;
}
.bookingTitle {
font-weight: 400;
}
.closeButton {
background: none;
border: none;
color: #6C757D;
font-size: 1.5rem;
font-weight: 300;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
transition: all 0.2s ease;
line-height: 1;
flex-shrink: 0;
width: fit-content;
}
.closeButton:hover {
background: rgba(108, 117, 125, 0.1);
color: #495057;
}
/* Success variant styles */
.success {
background: #E8F5E8;
border: 1px solid #4CAF50;
}
.successIcon {
background: #4CAF50;
color: white;
}
.successTitle {
color: #2E7D32;
}
.successDetails {
color: #388E3C;
}
/* Delete variant styles */
.delete {
background: #FFF4F4;
border: 1px solid #F87171;
}
.deleteIcon {
background: #EF4444;
color: white;
}
.deleteTitle {
color: #DC2626;
}
.deleteDetails {
color: #EF4444;
}
/* Development variant styles */
.development {
background: #FFF8E1;
border: 1px solid #FFB74D;
}
.developmentIcon {
font-size: 1.5rem;
color: #FF9800;
}
/* Test label styles */
.testLabel {
background: #FF9800;
color: white;
font-size: 0.7rem;
font-weight: 700;
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Fake close and tooltip styles */
.fakeCloseContainer {
position: relative;
}
.tooltip {
position: absolute;
top: 100%;
right: 0;
margin-top: 0.5rem;
background: #333;
color: white;
padding: 0.75rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 10;
animation: fadeIn 0.2s ease-out;
}
.tooltip::before {
content: '';
position: absolute;
bottom: 100%;
right: 1rem;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-bottom: 6px solid #333;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { ComboBox } from './ComboBox';
import { PEOPLE } from '../constants/bookingConstants';
import { useBookingContext } from '../context/BookingContext';
import styles from './ParticipantsField.module.css';
export function ParticipantsField() {
const booking = useBookingContext();
return (
<>
<h3 className={styles.elementHeading}>Deltagare</h3>
<ComboBox
items={PEOPLE}
onSelectionChange={booking.handleParticipantChange}
placeholder={"Lägg till deltagare"}
value={booking.participant}
>
</ComboBox>
{booking.participants.length > 0 && (
<div>
{booking.participants.map((participant, index) => (
<p key={index}>{participant}</p>
))}
</div>
)}
</>
);
}

View File

@@ -0,0 +1,10 @@
.elementHeading {
margin: 0;
color: #8E8E8E;
font-size: 0.8rem;
font-style: normal;
font-weight: 520;
line-height: normal;
margin-bottom: 0.2rem;
margin-top: 1.5rem;
}

View File

@@ -0,0 +1,291 @@
import React, { useState, useRef, useEffect } from 'react';
import { PEOPLE, USER } from '../constants/bookingConstants';
import { useBookingContext } from '../context/BookingContext';
import { useSettingsContext } from '../context/SettingsContext';
import styles from './ParticipantsSelector.module.css';
export function ParticipantsSelector({ compact = false }) {
const booking = useBookingContext();
const { getCurrentUser } = useSettingsContext();
const [searchTerm, setSearchTerm] = useState('');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(-1);
const [recentSearches, setRecentSearches] = useState([
{ id: 251, name: 'Arjohn Emilsson', username: 'arem1532', email: 'arjohn.emilsson@dsv.su.se' },
{ id: 3, name: 'Hedvig Engelmark', username: 'heen9876', email: 'hedvig.engelmark@dsv.su.se' },
{ id: 5, name: 'Victor Magnusson', username: 'vima8734', email: 'victor.magnusson@dsv.su.se' }
]);
const inputRef = useRef(null);
const dropdownRef = useRef(null);
const itemRefs = useRef([]);
// Filter people based on search term
const filteredPeople = PEOPLE.filter(person =>
person.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
person.username.toLowerCase().includes(searchTerm.toLowerCase()) ||
person.email.toLowerCase().includes(searchTerm.toLowerCase())
);
// Show recent searches only when input is empty (first click)
const showRecentSearches = searchTerm === '' && recentSearches.length > 0;
// Only show all people when user starts typing
const showAllPeople = searchTerm !== '';
const displayPeople = filteredPeople;
// Helper function to check if person is already selected
const isPersonSelected = (personName) => booking.participants.find(p => p.name === personName);
// Get all available options for keyboard navigation
const allOptions = showRecentSearches ? recentSearches : (showAllPeople ? displayPeople : []);
// Helper function to get person's initials
const getInitials = (name) => {
return name.split(' ').map(word => word[0]).join('').toUpperCase();
};
useEffect(() => {
const handleClickOutside = (event) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Scroll focused item into view
useEffect(() => {
if (focusedIndex >= 0 && itemRefs.current[focusedIndex]) {
itemRefs.current[focusedIndex].scrollIntoView({
behavior: 'smooth',
block: 'nearest'
});
}
}, [focusedIndex]);
const handleInputFocus = () => {
// Don't auto-open dropdown on focus - wait for user interaction
setFocusedIndex(-1);
};
const handleInputClick = () => {
setIsDropdownOpen(true);
setFocusedIndex(-1);
// Clear refs when dropdown opens
itemRefs.current = [];
};
const handleInputChange = (e) => {
setSearchTerm(e.target.value);
setIsDropdownOpen(true);
setFocusedIndex(-1);
// Clear refs when content changes
itemRefs.current = [];
};
const handleKeyDown = (e) => {
if (!isDropdownOpen) {
// When dropdown is closed, Enter should open it
if (e.key === 'Enter') {
e.preventDefault();
setIsDropdownOpen(true);
setFocusedIndex(-1);
}
return;
}
switch (e.key) {
case 'ArrowDown':
case 'Tab':
e.preventDefault();
setFocusedIndex(prev =>
prev < allOptions.length - 1 ? prev + 1 : 0
);
break;
case 'ArrowUp':
e.preventDefault();
setFocusedIndex(prev =>
prev > 0 ? prev - 1 : allOptions.length - 1
);
break;
case 'Enter':
e.preventDefault();
if (focusedIndex >= 0 && focusedIndex < allOptions.length) {
handleSelectPerson(allOptions[focusedIndex]);
}
break;
case 'Escape':
e.preventDefault();
setIsDropdownOpen(false);
setFocusedIndex(-1);
// Keep input focused with outline, don't blur completely
inputRef.current?.focus();
break;
}
};
const handleSelectPerson = (person) => {
console.log('handleSelectPerson called with:', person);
// Don't add if already selected
if (isPersonSelected(person.name)) {
setSearchTerm('');
setIsDropdownOpen(false);
setFocusedIndex(-1);
// Keep focus on input instead of blurring
inputRef.current?.focus();
return;
}
booking.handleParticipantChange(person.id);
// Add to recent searches if not already there
if (!recentSearches.find(p => p.id === person.id)) {
setRecentSearches(prev => [person, ...prev.slice(0, 4)]);
}
setSearchTerm('');
setIsDropdownOpen(false);
setFocusedIndex(-1);
// Keep focus on input for continued interaction
inputRef.current?.focus();
};
const handleRemoveParticipant = (participantToRemove) => {
booking.handleRemoveParticipant(participantToRemove);
};
return (
<div className={compact ? styles.compactContainer : styles.container}>
<h3 className={compact ? styles.compactElementHeading : styles.elementHeading}>Deltagare</h3>
{/* Search Input */}
<div className={styles.searchContainer} ref={dropdownRef}>
<input
ref={inputRef}
type="text"
value={searchTerm}
onChange={handleInputChange}
onFocus={handleInputFocus}
onClick={handleInputClick}
onKeyDown={handleKeyDown}
placeholder="Search for participants..."
className={compact ? styles.compactSearchInput : styles.searchInput}
role="combobox"
aria-expanded={isDropdownOpen}
aria-autocomplete="list"
aria-activedescendant={focusedIndex >= 0 ? `option-${focusedIndex}` : undefined}
/>
{/* Dropdown */}
{isDropdownOpen && (
<div
className={styles.dropdown}
role="listbox"
aria-label="Participant suggestions"
>
{/* Recent Searches */}
{showRecentSearches && (
<div className={styles.section}>
<div className={styles.sectionHeader}>Senaste sökningar</div>
{recentSearches.map((person, index) => (
<div
key={`recent-${person.id}`}
id={`option-${index}`}
ref={el => itemRefs.current[index] = el}
className={`${styles.dropdownItem} ${isPersonSelected(person.name) ? styles.selectedItem : ''} ${index === focusedIndex ? styles.focusedItem : ''}`}
onClick={() => handleSelectPerson(person)}
role="option"
aria-selected={isPersonSelected(person.name)}
>
<div className={styles.personAvatar}>
{person.profilePicture ? (
<img src={person.profilePicture} alt={person.name} className={styles.avatarImage} />
) : (
<div className={styles.avatarInitials}>
{getInitials(person.name)}
</div>
)}
</div>
<div className={styles.personInfo}>
<div className={styles.personName}>
{person.name}
{isPersonSelected(person.name) && <span className={styles.selectedIndicator}></span>}
</div>
<div className={styles.personUsername}>{person.username}</div>
</div>
</div>
))}
</div>
)}
{/* Search Results - Only when typing */}
{showAllPeople && (
displayPeople.length > 0 ? (
<div className={styles.section}>
<div className={styles.sectionHeader}>Sökresultat</div>
{displayPeople.map((person, index) => (
<div
key={person.id}
id={`option-${index}`}
ref={el => itemRefs.current[index] = el}
className={`${styles.dropdownItem} ${isPersonSelected(person.name) ? styles.selectedItem : ''} ${index === focusedIndex ? styles.focusedItem : ''}`}
onClick={() => handleSelectPerson(person)}
role="option"
aria-selected={isPersonSelected(person.name)}
>
<div className={styles.personAvatar}>
{person.profilePicture ? (
<img src={person.profilePicture} alt={person.name} className={styles.avatarImage} />
) : (
<div className={styles.avatarInitials}>
{getInitials(person.name)}
</div>
)}
</div>
<div className={styles.personInfo}>
<div className={styles.personName}>
{person.name}
{isPersonSelected(person.name) && <span className={styles.selectedIndicator}></span>}
</div>
<div className={styles.personUsername}>{person.username}</div>
</div>
</div>
))}
</div>
) : (
<div className={styles.noResults}>
Inga deltagare hittades
</div>
)
)}
</div>
)}
</div>
{/* Selected Participants */}
<div className={styles.selectedParticipants}>
{/* Default User (Non-deletable) */}
<div className={`${styles.participantChip} ${styles.defaultUserChip}`}>
<span className={styles.participantName}>{getCurrentUser().name}</span>
</div>
{/* Additional Participants (Deletable) */}
{booking.participants.map((participant, index) => (
<button
key={index}
className={`${styles.participantChip} ${styles.clickableChip}`}
onClick={() => handleRemoveParticipant(participant)}
type="button"
title={`Remove ${participant.name}`}
aria-label={`Remove ${participant.name} from participants`}
>
<span className={styles.participantName}>{participant.name}</span>
<span className={styles.removeIcon}>×</span>
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,310 @@
.container {
position: relative;
}
.elementHeading {
margin: 0;
color: #8E8E8E;
font-size: 0.8rem;
font-style: normal;
font-weight: 520;
line-height: normal;
margin-bottom: 0.2rem;
margin-top: 1.5rem;
}
.selectedParticipants {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0;
margin-top: 1rem;
}
.participantChip {
display: flex;
align-items: center;
background-color: #F0F8FF;
border: 1px solid #D1E7FF;
border-radius: 1.25rem;
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
color: #2563EB;
gap: 0.5rem;
transition: all 0.2s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.participantName {
font-weight: 500;
}
.clickableChip {
cursor: pointer;
transition: all 0.2s ease;
width: fit-content;
min-width: auto;
}
.clickableChip:hover {
background-color: #E0F2FE;
border-color: #BAE6FD;
}
.clickableChip:focus {
outline: 2px solid #2563EB;
outline-offset: 2px;
}
.clickableChip:active {
background-color: #BFDBFE;
transform: scale(0.98);
}
.removeIcon {
color: #2563EB;
font-size: 0.875rem;
font-weight: bold;
margin-left: 0.25rem;
}
.defaultUserChip {
background-color: #F3F4F6;
border-color: #D1D5DB;
color: #374151;
}
.defaultUserChip:hover {
background-color: #F3F4F6;
border-color: #D1D5DB;
color: #374151;
cursor: default;
}
.searchContainer {
position: relative;
width: 100%;
max-width: 600px;
}
.searchInput {
width: 100%;
margin-bottom: 10px;
border: 1px solid #D2D9E0;
border-radius: 0.5rem;
font-size: 16px;
background-color: #FAFBFC;
padding: 1rem;
font-family: inherit;
box-sizing: border-box;
}
.searchInput::placeholder {
color: #adadad;
}
.searchInput:focus {
outline: 2px solid #2563EB;
outline-offset: -1px;
border-color: #2563EB;
}
.dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid #D2D9E0;
border-radius: 0.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08);
z-index: 1000;
max-height: 300px;
overflow-y: auto;
margin-top: 0rem;
}
.section {
padding: 0.5rem 0;
}
.section:not(:last-child) {
border-bottom: 1px solid #F1F3F4;
}
.sectionHeader {
font-weight: 600;
color: #5F6368;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 0.25rem 1rem;
margin-bottom: 0.25rem;
}
.dropdownItem {
padding: 0.75rem 1rem;
cursor: pointer;
transition: background-color 0.2s;
border: none;
border-bottom: 1px solid #F1F3F4;
background: none;
width: 100%;
text-align: left;
font-family: inherit;
display: flex;
align-items: center;
gap: 0.75rem;
}
.dropdownItem:last-child {
border-bottom: none;
}
.dropdownItem:hover {
background-color: #F8F9FA;
}
.dropdownItem:active {
background-color: #E8F0FE;
}
.personAvatar {
width: 2rem;
height: 2rem;
border-radius: 50%;
flex-shrink: 0;
}
.avatarImage {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.avatarInitials {
width: 100%;
height: 100%;
border-radius: 50%;
background-color: #2563EB;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 600;
}
.personInfo {
display: flex;
flex-direction: column;
gap: 0.125rem;
flex: 1;
}
.personName {
font-weight: 500;
color: #202124;
font-size: 0.875rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.personUsername {
font-size: 0.75rem;
color: #5F6368;
}
.addNewItem {
color: #1A73E8;
font-weight: 500;
opacity: 0.5;
cursor: not-allowed;
}
.addNewItem:hover {
background-color: transparent;
}
.selectedItem {
background-color: #F0F8FF;
opacity: 0.7;
}
.selectedItem:hover {
background-color: #E0F2FE;
}
.selectedIndicator {
color: #2563EB;
font-weight: bold;
margin-left: 0.5rem;
}
.focusedItem {
background-color: #2563EB !important;
color: white !important;
}
.focusedItem .personName {
color: white !important;
}
.focusedItem .personUsername {
color: rgba(255, 255, 255, 0.8) !important;
}
.focusedItem .selectedIndicator {
color: white !important;
}
.focusedItem .avatarInitials {
background-color: rgba(255, 255, 255, 0.2);
color: white;
}
.noResults {
padding: 1rem;
text-align: center;
color: #5F6368;
font-size: 0.875rem;
font-style: italic;
}
/* Compact styles */
.compactContainer {
margin-bottom: 0;
}
.compactElementHeading {
font-size: 0.75rem;
color: #717171;
font-weight: 500;
margin-bottom: 0.4rem;
margin-top: 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.compactSearchInput {
width: 100%;
padding: 0.5rem 1rem;
border: 1px solid #ccc;
border-radius: 0.375rem;
font-size: 16px;
background-color: white;
font-family: inherit;
transition: border-color 0.2s ease;
box-sizing: border-box;
}
.compactSearchInput:focus {
outline: 2px solid #007AFF;
outline-offset: 2px;
border-color: #007AFF;
}

View File

@@ -0,0 +1,34 @@
import React, { useMemo } from 'react';
import Dropdown from './Dropdown';
import { SMALL_GROUP_ROOMS } from '../constants/bookingConstants';
import { useBookingContext } from '../context/BookingContext';
import { useSettingsContext } from '../context/SettingsContext';
import styles from './RoomSelectionField.module.css';
export function RoomSelectionField() {
const booking = useBookingContext();
const { settings } = useSettingsContext();
// Generate room options based on settings
const roomOptions = useMemo(() => {
return Array.from({ length: settings.numberOfRooms }, (_, i) => ({
value: `G5:${i + 1}`,
label: `G5:${i + 1}`,
}));
}, [settings.numberOfRooms]);
return (
<div>
<h3 className={styles.elementHeading}>Rum</h3>
<Dropdown
options={roomOptions}
value={booking.selectedRoom}
onChange={(e) => booking.handleRoomChange(e)}
placeholder={{
label: "Alla rum",
value: "allRooms"
}}
/>
</div>
);
}

View File

@@ -0,0 +1,9 @@
.elementHeading {
margin: 0;
color: #8E8E8E;
font-size: 0.8rem;
font-style: normal;
font-weight: 520;
line-height: normal;
margin-bottom: 0.2rem;
}

View File

@@ -1,11 +1,6 @@
import { Button, Dialog, DialogTrigger, Heading, Modal } from 'react-aria-components';
import { useState } from 'react';
import { Button } from 'react-aria-components';
import React from 'react';
import styles from './TimeCard.module.css';
import { convertDateObjectToString, getTimeFromIndex } from '../helpers';
import Dropdown from './Dropdown';
import { useBookingContext } from '../context/BookingContext';
export default function TimeCard({
@@ -21,9 +16,9 @@ export default function TimeCard({
const booking = useBookingContext();
let hoursText;
const halfHours = hoursAvailable;
const [endTimeIndex, setEndTimeIndex] = useState(startTimeIndex + hoursAvailable);
// Use the pre-selected booking length if available, otherwise use available hours
const displayHours = booking.selectedBookingLength > 0 ? booking.selectedBookingLength : hoursAvailable;
const halfHours = displayHours;
if (halfHours === 1) {
hoursText = "30\u202Fmin";
@@ -35,108 +30,43 @@ export default function TimeCard({
function formatSlotIndex(index) {
const hour = Math.floor(index / 2) + 8;
let hour = Math.floor(index / 2) + 8;
if (hour < 10) {
hour = `0${hour}`;
}
const minute = index % 2 === 0 ? "00" : "30";
return `${hour}:${minute}`;
}
let classNames = selected ? `${styles.container}` : styles.container;
let classNames = selected ? `${styles.container} ${styles.selected}` : styles.container;
const className = state === "unavailableSlot" ? styles.unavailableSlot : styles.availableSlot;
const isEndState = ["availableEndTime", "selectedEnd"].includes(state);
const bookingLengths = [
{ value: 1, label: "30 min" },
{ value: 2, label: "1 h" },
{ value: 3, label: "1.5 h" },
{ value: 4, label: "2 h" },
{ value: 5, label: "2.5 h" },
{ value: 6, label: "3 h" },
{ value: 7, label: "3.5 h" },
{ value: 8, label: "4 h" },
];
function getLabelFromAvailableHours(availableHours) {
return bookingLengths.find(option => option.value === availableHours)?.label || "Välj längd";
}
const disabledOptions = {
1: !(hoursAvailable > 0),
2: !(hoursAvailable > 1),
3: !(hoursAvailable > 2),
4: !(hoursAvailable > 3),
5: !(hoursAvailable > 4),
6: !(hoursAvailable > 5),
7: !(hoursAvailable > 6),
8: !(hoursAvailable > 7),
};
function handleChange(event) {
console.log(event.target.value);
setEndTimeIndex(startTimeIndex + parseInt(event.target.value));
booking.setSelectedEndIndex(startTimeIndex + parseInt(event.target.value));
}
if (state === "availableSlot") {
return (
<DialogTrigger>
<Button
className={`${classNames} ${className}`}
onClick={() => {
state === "availableSlot" ? handleClick : undefined;
console.log("state: ", state);
}}
onMouseEnter={() => handleTimeCardHover(startTimeIndex)}
onMouseLeave={handleTimeCardExit}
>
{(!isEndState && hoursAvailable > 0) || isEndState || state=="availableSlot" ? (
<>
<p className={styles.startTime}>{formatSlotIndex(startTimeIndex)}</p>
<p className={styles.upToText}>{hoursAvailable > 1 && "Upp till "}<span className={styles.hoursText}>{hoursText}</span></p>
</>
) : null}
</Button>
<Modal isDismissable className={styles.modalContainer}>
<Dialog>
<form>
<Heading slot="title">{booking.title == "" ? "Jacobs bokning" : booking.title}</Heading>
<p>{convertDateObjectToString(booking.selectedDate)}</p>
<p className={styles.timeSpan}>{getTimeFromIndex(startTimeIndex)} - {getTimeFromIndex(endTimeIndex)}</p>
<div className={styles.sectionWithTitle}>
<label>Längd</label>
<Dropdown
options={bookingLengths}
disabledOptions={disabledOptions}
onChange={handleChange}
placeholder={{ value: hoursAvailable, label: getLabelFromAvailableHours(hoursAvailable) }}
/>
</div>
<div className={styles.sectionWithTitle}>
<label>Tilldelat rum</label>
<p>G5:12</p>
</div>
<div className={styles.sectionWithTitle}>
<label>Deltagare</label>
<p>{booking.participants.join(", ")}</p>
</div>
<div className={styles.modalFooter}>
<Button className={styles.cancelButton} slot="close">
Avbryt
</Button>
<Button className={styles.saveButton} onClick={booking.handleSave}>
Boka
</Button>
</div>
</form>
</Dialog>
</Modal>
</DialogTrigger>
<Button
className={`${classNames} ${className}`}
onClick={() => {
if (state === "availableSlot") {
handleClick();
}
console.log("state: ", state);
}}
onMouseEnter={() => handleTimeCardHover(startTimeIndex)}
onMouseLeave={handleTimeCardExit}
>
{(!isEndState && hoursAvailable > 0) || isEndState || state=="availableSlot" ? (
<>
<p className={styles.startTime}>{formatSlotIndex(startTimeIndex)}</p>
<p className={styles.upToText}>{hoursAvailable > 1 && booking.selectedBookingLength === 0 && "Upp till "}<span className={styles.hoursText}>{hoursText}</span></p>
</>
) : null}
</Button>
);
}

View File

@@ -13,10 +13,41 @@
background-color: #F8FBFC;
width: 135px;
height: 20px;
transition: all 0.15s ease;
}
.container:hover {
background-color: #E5E5E5;
@media (hover: hover) {
.container:hover {
background-color: #E5E5E5;
}
}
.container:active,
.container[data-pressed] {
background-color: #D1D5DB;
transform: translateY(1px);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
transition: all 0.1s ease;
}
.container[data-focus-visible] {
outline: 2px solid #2563EB;
outline-offset: -1px;
}
.selected {
background-color: #2563EB !important;
color: white !important;
border-color: #1d4ed8 !important;
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3);
}
.selected .upToText {
color: rgba(255, 255, 255, 0.8) !important;
}
.selected .hoursText {
color: white !important;
}
.container p {
@@ -24,6 +55,7 @@
}
.startTime {
width: fit-content;
font-weight: 600;
font-size: 1.3rem;
}
@@ -31,11 +63,13 @@
.upToText {
font-weight: 300;
color: #919191;
font-size: 0.9rem
}
.hoursText {
font-weight: 500;
color:#686765;
font-size: 0.9rem;
}
@@ -66,36 +100,53 @@
.cancelButton {
flex: 2;
background-color: #ECECEC;
background-color: white;
height: 4rem;
color: #5c5454;
color: #374151;
font-weight: 600;
border: 1px solid #D2D9E0;
border: 2px solid #d1d5db;
border-radius: 0.5rem;
transition: all 0.2s ease;
}
.cancelButton:hover,
.saveButton:hover {
cursor: pointer
@media (hover: hover) {
.cancelButton:hover {
background-color: #f9fafb;
border-color: #9ca3af;
cursor: pointer;
}
}
.saveButton {
flex: 3;
background-color: #4C952D;
color: #f2faef;
background-color: #059669;
color: white;
height: 4rem;
font-weight: 600;
font-size: 1.1rem;
border: 1px solid #3C7624;
border: 2px solid #047857;
border-radius: 0.5rem;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(5, 150, 105, 0.2);
}
@media (hover: hover) {
.saveButton:hover {
background-color: #047857;
box-shadow: 0 4px 8px rgba(5, 150, 105, 0.3);
cursor: pointer;
}
}
.saveButton:active {
background-color: #3C7624;
background-color: #065f46;
transform: translateY(1px);
box-shadow: 0 1px 2px rgba(5, 150, 105, 0.2);
}
.cancelButton:active {
background-color: #D2D9E0;
background-color: #e5e7eb;
transform: translateY(1px);
}
.timeSpan {
@@ -123,4 +174,76 @@
background-color: white;
width: 85%;
max-width: 400px;
}
/* New time display styles */
.timeDisplay {
margin: 1rem 0;
padding: 1rem;
background-color: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.timeRange {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.startTime, .endTime {
display: flex;
flex-direction: column;
align-items: center;
}
.startTime label, .endTime label {
font-size: 0.75rem;
color: #6c757d;
font-weight: 500;
margin-bottom: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.timeValue {
font-size: 1.5rem;
font-weight: 600;
color: #212529;
}
.timeValue.placeholder {
color: #adb5bd;
font-style: italic;
font-size: 1rem;
}
.timeSeparator {
font-size: 1.5rem;
font-weight: 300;
color: #6c757d;
margin: 0 0.5rem;
}
/* Disabled button styles */
.disabledButton {
background-color: #f8f9fa !important;
color: #adb5bd !important;
border: 2px dashed #dee2e6 !important;
cursor: not-allowed !important;
opacity: 0.6 !important;
}
@media (hover: hover) {
.disabledButton:hover {
background-color: #f8f9fa !important;
cursor: not-allowed !important;
transform: none !important;
}
}
.disabledButton:active {
background-color: #f8f9fa !important;
transform: none !important;
}

View File

@@ -1,11 +1,20 @@
import React from 'react';
import TimeCard from './TimeCard';
import { InlineBookingForm } from './InlineBookingForm';
import { BookingModal } from './BookingModal';
import styles from './TimeCardContainer.module.css';
import modalStyles from './BookingModal.module.css';
import { useBookingContext } from '../context/BookingContext';
import { useSettingsContext } from '../context/SettingsContext';
const SLOT_GROUPING_SIZE = 8;
export function TimeCardContainer() {
const booking = useBookingContext();
const { settings } = useSettingsContext();
// Check if we should use inline form
const useInlineForm = settings.bookingFormType === 'inline';
const slotCount = 24; // 12 hours * 2 slots per hour (8:00 to 20:00)
const slotIndices = Array.from({ length: slotCount }, (_, i) => i);
@@ -41,25 +50,24 @@ export function TimeCardContainer() {
}
return (
<div className={styles.columnContainer}>
{slotIndiciesToColumns(slotIndices).map((column, index) => {
return (
<div key={index} className={styles.column}>
{
column.map(index => {
<div>
<div className={styles.columnContainer}>
{slotIndiciesToColumns(slotIndices).map((column, index) => {
return (
<div key={index} className={styles.column}>
{column.map(slotIndex => {
let maxConsecutive = 0;
let roomId = "";
if (booking.currentRoom) {
const consecutive = countConsecutiveFromSlot(booking.currentRoom.times, index);
const consecutive = countConsecutiveFromSlot(booking.currentRoom.times, slotIndex);
if (consecutive > maxConsecutive) {
maxConsecutive = consecutive;
}
} else {
booking.timeSlotsByRoom.forEach(room => {
const consecutive = countConsecutiveFromSlot(room.times, index);
const consecutive = countConsecutiveFromSlot(room.times, slotIndex);
if (consecutive > maxConsecutive) {
maxConsecutive = consecutive;
roomId = room.roomId;
@@ -73,26 +81,88 @@ export function TimeCardContainer() {
/* Set time card state here: */
let timeCardState = "unavailableSlot";
if (maxConsecutive > 0) {
timeCardState = "availableSlot";
}
// If a booking length is pre-selected, only show slots that can accommodate the exact length
if (booking.selectedBookingLength !== 0) {
// Check if this slot can accommodate the selected booking length
const actualConsecutive = booking.currentRoom ?
countConsecutiveFromSlot(booking.currentRoom.times, slotIndex) :
Math.max(...booking.timeSlotsByRoom.map(room => countConsecutiveFromSlot(room.times, slotIndex)));
return (
if (actualConsecutive >= booking.selectedBookingLength) {
timeCardState = "availableSlot";
}
} else {
// No pre-selected length, show if any time is available
if (maxConsecutive > 0) {
timeCardState = "availableSlot";
}
}
const elements = [];
elements.push(
<TimeCard
key={index}
startTimeIndex={index}
key={slotIndex}
startTimeIndex={slotIndex}
hoursAvailable={maxConsecutive}
handleClick={() => booking.handleTimeCardClick(index, maxConsecutive, roomId)}
selected={index === booking.selectedStartIndex}
handleClick={() => {
if (booking.selectedStartIndex === slotIndex) {
// If clicking on already selected card, close the form
booking.resetTimeSelections();
} else {
// Otherwise, select this card
booking.handleTimeCardClick(slotIndex, maxConsecutive, roomId);
}
}}
selected={slotIndex === booking.selectedStartIndex}
state={timeCardState}
handleTimeCardHover={() => {}}
handleTimeCardExit={booking.handleTimeCardExit}
/>
);
})}
// Add inline booking form after the pair that contains the selected time card
// Only show inline form if useInlineForm is true
// Cards are laid out in pairs: (0,1), (2,3), (4,5), etc.
if (useInlineForm && booking.selectedStartIndex !== null) {
const selectedPairStart = Math.floor(booking.selectedStartIndex / 2) * 2;
const selectedPairEnd = selectedPairStart + 1;
// Show form after the second card of the pair that contains the selected card
if (slotIndex === selectedPairEnd && (booking.selectedStartIndex === selectedPairStart || booking.selectedStartIndex === selectedPairEnd)) {
const isLeftCard = booking.selectedStartIndex === selectedPairStart;
elements.push(
<InlineBookingForm
key={`form-${slotIndex}`}
startTimeIndex={booking.selectedStartIndex}
hoursAvailable={booking.selectedEndIndex - booking.selectedStartIndex}
onClose={() => booking.resetTimeSelections()}
arrowPointsLeft={isLeftCard}
/>
);
}
}
return elements;
}).flat()}
</div>
)
})}
</div>
{/* Show modal when a time slot is selected and not using inline form */}
{!useInlineForm && booking.selectedStartIndex !== null && (
<BookingModal
startTimeIndex={booking.selectedStartIndex}
hoursAvailable={booking.selectedEndIndex - booking.selectedStartIndex}
endTimeIndex={booking.selectedEndIndex}
setEndTimeIndex={booking.setSelectedEndIndex}
className={modalStyles.modalContainer}
onClose={() => booking.resetTimeSelections()}
isOpen={true}
/>
)}
</div>
);
}

View File

@@ -13,6 +13,8 @@
width: 350px;
gap: 0.5rem;
height: fit-content;
align-items: flex-start;
justify-content: center;
}
.timeCardList {

View File

@@ -19,14 +19,273 @@ export const SMALL_GROUP_ROOMS = Array.from({ length: 15 }, (_, i) => ({
}));
export const PEOPLE = [
{ id: 1, name: 'Arjohn Emilsson' },
{ id: 2, name: 'Filip Norgren' },
{ id: 3, name: 'Hedvig Engelmark' },
{ id: 4, name: 'Elin Rudling' },
{ id: 5, name: 'Victor Magnusson' },
{ id: 6, name: 'Ellen Britschgi' }
{ id: 251, name: 'Arjohn Emilsson', username: 'arem1532', email: 'arjohn.emilsson@dsv.su.se' },
{ id: 2, name: 'Filip Norgren', username: 'fino2341', email: 'filip.norgren@dsv.su.se' },
{ id: 3, name: 'Hedvig Engelmark', username: 'heen9876', email: 'hedvig.engelmark@dsv.su.se' },
{ id: 4, name: 'Elin Rudling', username: 'elru4521', email: 'elin.rudling@dsv.su.se' },
{ id: 5, name: 'Victor Magnusson', username: 'vima8734', email: 'victor.magnusson@dsv.su.se' },
{ id: 6, name: 'Ellen Britschgi', username: 'elbr5623', email: 'ellen.britschgi@dsv.su.se' },
{ id: 7, name: 'Anna Andersson', username: 'anan3457', email: 'anna.andersson@dsv.su.se' },
{ id: 8, name: 'Erik Larsson', username: 'erla7892', email: 'erik.larsson@dsv.su.se' },
{ id: 9, name: 'Sofia Karlsson', username: 'soka1245', email: 'sofia.karlsson@dsv.su.se' },
{ id: 10, name: 'Magnus Nilsson', username: 'mani6789', email: 'magnus.nilsson@dsv.su.se' },
{ id: 11, name: 'Emma Johansson', username: 'emjo4512', email: 'emma.johansson@dsv.su.se' },
{ id: 12, name: 'Oskar Pettersson', username: 'ospe3698', email: 'oskar.pettersson@dsv.su.se' },
{ id: 13, name: 'Linda Svensson', username: 'lisv2174', email: 'linda.svensson@dsv.su.se' },
{ id: 14, name: 'Jonas Gustafsson', username: 'jogu8523', email: 'jonas.gustafsson@dsv.su.se' },
{ id: 15, name: 'Maria Olsson', username: 'maol7456', email: 'maria.olsson@dsv.su.se' },
{ id: 16, name: 'Andreas Berg', username: 'anbe9832', email: 'andreas.berg@dsv.su.se' },
{ id: 17, name: 'Lina Dahlberg', username: 'lida2165', email: 'lina.dahlberg@dsv.su.se' },
{ id: 18, name: 'Marcus Forsberg', username: 'mafo6754', email: 'marcus.forsberg@dsv.su.se' },
{ id: 19, name: 'Julia Hedström', username: 'juhe4921', email: 'julia.hedström@dsv.su.se' },
{ id: 20, name: 'Daniel Lindqvist', username: 'dali8374', email: 'daniel.lindqvist@dsv.su.se' },
{ id: 21, name: 'Sara Blomqvist', username: 'sabl1598', email: 'sara.blomqvist@dsv.su.se' },
{ id: 22, name: 'Henrik Lundberg', username: 'helu7263', email: 'henrik.lundberg@dsv.su.se' },
{ id: 23, name: 'Frida Engström', username: 'fren3847', email: 'frida.engström@dsv.su.se' },
{ id: 24, name: 'Tobias Sjöberg', username: 'tosj5691', email: 'tobias.sjöberg@dsv.su.se' },
{ id: 25, name: 'Amanda Wallin', username: 'amwa9254', email: 'amanda.wallin@dsv.su.se' },
{ id: 26, name: 'Mattias Holm', username: 'maho8173', email: 'mattias.holm@dsv.su.se' },
{ id: 27, name: 'Emelie Sandberg', username: 'emsa4629', email: 'emelie.sandberg@dsv.su.se' },
{ id: 28, name: 'Robin Åberg', username: 'roab7485', email: 'robin.åberg@dsv.su.se' },
{ id: 29, name: 'Caroline Ekström', username: 'caek2916', email: 'caroline.ekström@dsv.su.se' },
{ id: 30, name: 'Alexander Nyström', username: 'alny6387', email: 'alexander.nyström@dsv.su.se' },
{ id: 31, name: 'Lisa Berggren', username: 'libe5142', email: 'lisa.berggren@dsv.su.se' },
{ id: 32, name: 'David Holmberg', username: 'daho8764', email: 'david.holmberg@dsv.su.se' },
{ id: 33, name: 'Maja Lindström', username: 'mali3571', email: 'maja.lindström@dsv.su.se' },
{ id: 34, name: 'Johan Carlsson', username: 'joca6928', email: 'johan.carlsson@dsv.su.se' },
{ id: 35, name: 'Rebecka Svensson', username: 'resv4816', email: 'rebecka.svensson@dsv.su.se' },
{ id: 36, name: 'Niklas Hedberg', username: 'nihe7395', email: 'niklas.hedberg@dsv.su.se' },
{ id: 37, name: 'Ida Persson', username: 'idpe2583', email: 'ida.persson@dsv.su.se' },
{ id: 38, name: 'Martin Öberg', username: 'maöb5947', email: 'martin.öberg@dsv.su.se' },
{ id: 39, name: 'Therese Löfgren', username: 'thlo8162', email: 'therese.löfgren@dsv.su.se' },
{ id: 40, name: 'Stefan Martinsson', username: 'stma4739', email: 'stefan.martinsson@dsv.su.se' },
{ id: 41, name: 'Johanna Stenberg', username: 'jost6254', email: 'johanna.stenberg@dsv.su.se' },
{ id: 42, name: 'Peter Wikström', username: 'pewi9481', email: 'peter.wikström@dsv.su.se' },
{ id: 43, name: 'Mikaela Fransson', username: 'mifr3825', email: 'mikaela.fransson@dsv.su.se' },
{ id: 44, name: 'Christian Lindgren', username: 'chli7694', email: 'christian.lindgren@dsv.su.se' },
{ id: 45, name: 'Evelina Norberg', username: 'evno2147', email: 'evelina.norberg@dsv.su.se' },
{ id: 46, name: 'Andreas Strömberg', username: 'anst5863', email: 'andreas.strömberg@dsv.su.se' },
{ id: 47, name: 'Klara Danielsson', username: 'klda8429', email: 'klara.danielsson@dsv.su.se' },
{ id: 48, name: 'Simon Eklund', username: 'siek6175', email: 'simon.eklund@dsv.su.se' },
{ id: 49, name: 'Elsa Lundgren', username: 'ellu4892', email: 'elsa.lundgren@dsv.su.se' },
{ id: 50, name: 'Mikael Höglund', username: 'mihö7316', email: 'mikael.höglund@dsv.su.se' },
{ id: 51, name: 'Jennie Söderberg', username: 'jesö3754', email: 'jennie.söderberg@dsv.su.se' },
{ id: 52, name: 'Fredrik Åström', username: 'frås9128', email: 'fredrik.åström@dsv.su.se' },
{ id: 53, name: 'Cornelia Sundberg', username: 'cosu5683', email: 'cornelia.sundberg@dsv.su.se' },
{ id: 54, name: 'Emil Palmberg', username: 'empa8297', email: 'emil.palmberg@dsv.su.se' },
{ id: 55, name: 'Josefin Ringström', username: 'jori4612', email: 'josefin.ringström@dsv.su.se' },
{ id: 56, name: 'Christofer Åkesson', username: 'chåk7548', email: 'christofer.åkesson@dsv.su.se' },
{ id: 57, name: 'Agnes Håkansson', username: 'aghå2971', email: 'agnes.håkansson@dsv.su.se' },
{ id: 58, name: 'Sebastian Blomberg', username: 'sebl6394', email: 'sebastian.blomberg@dsv.su.se' },
{ id: 59, name: 'Felicia Gunnarsson', username: 'fegu8756', email: 'felicia.gunnarsson@dsv.su.se' },
{ id: 60, name: 'Jesper Lindahl', username: 'jeli3182', email: 'jesper.lindahl@dsv.su.se' },
{ id: 61, name: 'Sandra Backström', username: 'saba7425', email: 'sandra.backström@dsv.su.se' },
{ id: 62, name: 'William Viklund', username: 'wivi5697', email: 'william.viklund@dsv.su.se' },
{ id: 63, name: 'Lova Arvidsson', username: 'loar9231', email: 'lova.arvidsson@dsv.su.se' },
{ id: 64, name: 'Gabriel Öhman', username: 'gaöh4568', email: 'gabriel.öhman@dsv.su.se' },
{ id: 65, name: 'Isabelle Eriksson', username: 'iser8143', email: 'isabelle.eriksson@dsv.su.se' },
{ id: 66, name: 'Anton Ström', username: 'anst2976', email: 'anton.ström@dsv.su.se' },
{ id: 67, name: 'Wilma Ljungberg', username: 'wilj6314', email: 'wilma.ljungberg@dsv.su.se' },
{ id: 68, name: 'Lucas Sundström', username: 'lusu9587', email: 'lucas.sundström@dsv.su.se' },
{ id: 69, name: 'Hanna Åslund', username: 'haås4729', email: 'hanna.åslund@dsv.su.se' },
{ id: 70, name: 'Pontus Rydberg', username: 'pory8152', email: 'pontus.rydberg@dsv.su.se' },
{ id: 71, name: 'Olivia Nyman', username: 'olny3675', email: 'olivia.nyman@dsv.su.se' },
{ id: 72, name: 'Viktor Östberg', username: 'viös7493', email: 'viktor.östberg@dsv.su.se' },
{ id: 73, name: 'Tilda Forslund', username: 'tifo5128', email: 'tilda.forslund@dsv.su.se' },
{ id: 74, name: 'Carl Holmström', username: 'caho8916', email: 'carl.holmström@dsv.su.se' },
{ id: 75, name: 'Matilda Bengtsson', username: 'mabe4382', email: 'matilda.bengtsson@dsv.su.se' },
{ id: 76, name: 'Alvin Berglund', username: 'albe7654', email: 'alvin.berglund@dsv.su.se' },
{ id: 77, name: 'Saga Nordström', username: 'sano2496', email: 'saga.nordström@dsv.su.se' },
{ id: 78, name: 'Linus Hedström', username: 'lihe6847', email: 'linus.hedström@dsv.su.se' },
{ id: 79, name: 'Elina Jakobsson', username: 'elja9173', email: 'elina.jakobsson@dsv.su.se' },
{ id: 80, name: 'Casper Nordin', username: 'cano3521', email: 'casper.nordin@dsv.su.se' },
{ id: 81, name: 'Nova Malmberg', username: 'noma8765', email: 'nova.malmberg@dsv.su.se' },
{ id: 82, name: 'Isac Björk', username: 'isbj5194', email: 'isac.björk@dsv.su.se' },
{ id: 83, name: 'Ebba Sandström', username: 'ebsa7428', email: 'ebba.sandström@dsv.su.se' },
{ id: 84, name: 'Melvin Åberg', username: 'meåb2683', email: 'melvin.åberg@dsv.su.se' },
{ id: 85, name: 'Astrid Nordahl', username: 'asno6159', email: 'astrid.nordahl@dsv.su.se' },
{ id: 86, name: 'Noel Sjögren', username: 'nosj8437', email: 'noel.sjögren@dsv.su.se' },
{ id: 87, name: 'Linnéa Borg', username: 'libo4826', email: 'linnéa.borg@dsv.su.se' },
{ id: 88, name: 'Adrian Rosén', username: 'adro7195', email: 'adrian.rosén@dsv.su.se' },
{ id: 89, name: 'Smilla Lindberg', username: 'smli3564', email: 'smilla.lindberg@dsv.su.se' },
{ id: 90, name: 'Leon Hammar', username: 'leha9821', email: 'leon.hammar@dsv.su.se' },
{ id: 91, name: 'Ellen Sjöström', username: 'elsj6247', email: 'ellen.sjöström@dsv.su.se' },
{ id: 92, name: 'Tim Hedlund', username: 'tihe4573', email: 'tim.hedlund@dsv.su.se' },
{ id: 93, name: 'Vera Blomgren', username: 'vebl8912', email: 'vera.blomgren@dsv.su.se' },
{ id: 94, name: 'Theodor Larsson', username: 'thla2738', email: 'theodor.larsson@dsv.su.se' },
{ id: 95, name: 'Stella Lundström', username: 'stlu6495', email: 'stella.lundström@dsv.su.se' },
{ id: 96, name: 'Benjamin Engberg', username: 'been8164', email: 'benjamin.engberg@dsv.su.se' },
{ id: 97, name: 'Alicia Rydberg', username: 'alry4827', email: 'alicia.rydberg@dsv.su.se' },
{ id: 98, name: 'Hugo Nordgren', username: 'huno7392', email: 'hugo.nordgren@dsv.su.se' },
{ id: 99, name: 'Moa Stenberg', username: 'most5618', email: 'moa.stenberg@dsv.su.se' },
{ id: 100, name: 'Neo Lindahl', username: 'neli9456', email: 'neo.lindahl@dsv.su.se' },
{ id: 101, name: 'Tess Holm', username: 'teho3274', email: 'tess.holm@dsv.su.se' },
{ id: 102, name: 'Vincent Lundberg', username: 'vilu8593', email: 'vincent.lundberg@dsv.su.se' },
{ id: 103, name: 'Tove Nyberg', username: 'tony6127', email: 'tove.nyberg@dsv.su.se' },
{ id: 104, name: 'Edvin Kvist', username: 'edkv4851', email: 'edvin.kvist@dsv.su.se' },
{ id: 105, name: 'Sigrid Fransson', username: 'sifr7436', email: 'sigrid.fransson@dsv.su.se' },
{ id: 106, name: 'Lovis Hedberg', username: 'lohe2984', email: 'lovis.hedberg@dsv.su.se' },
{ id: 107, name: 'Arvid Stenberg', username: 'arst5627', email: 'arvid.stenberg@dsv.su.se' },
{ id: 108, name: 'June Holmgren', username: 'juho8315', email: 'june.holmgren@dsv.su.se' },
{ id: 109, name: 'Milo Svensson', username: 'misv4762', email: 'milo.svensson@dsv.su.se' },
{ id: 110, name: 'Alice Rosén', username: 'alro6948', email: 'alice.rosén@dsv.su.se' },
{ id: 111, name: 'Viggo Lindström', username: 'vili3591', email: 'viggo.lindström@dsv.su.se' },
{ id: 112, name: 'Thea Östlund', username: 'thös8274', email: 'thea.östlund@dsv.su.se' },
{ id: 113, name: 'Nils Hedström', username: 'nihe5416', email: 'nils.hedström@dsv.su.se' },
{ id: 114, name: 'Signe Dahlberg', username: 'sida7829', email: 'signe.dahlberg@dsv.su.se' },
{ id: 115, name: 'Axel Nordström', username: 'axno2653', email: 'axel.nordström@dsv.su.se' },
{ id: 116, name: 'Amira Al-Hassan', username: 'amal4821', email: 'amira.al-hassan@dsv.su.se' },
{ id: 117, name: 'José García Martínez', username: 'joga7395', email: 'jose.garcia-martinez@dsv.su.se' },
{ id: 118, name: 'Fatima Özkan', username: 'faöz2648', email: 'fatima.özkan@dsv.su.se' },
{ id: 119, name: 'Ahmed Ben Khalil', username: 'ahbe5973', email: 'ahmed.ben-khalil@dsv.su.se' },
{ id: 120, name: 'Elena Popović', username: 'elpo8416', email: 'elena.popovic@dsv.su.se' },
{ id: 121, name: 'Hassan Al-Rashid', username: 'haal3672', email: 'hassan.al-rashid@dsv.su.se' },
{ id: 122, name: 'Mariam Yılmaz', username: 'mayl9254', email: 'mariam.yilmaz@dsv.su.se' },
{ id: 123, name: 'Nicolas Müller Schmidt', username: 'nimu4817', email: 'nicolas.muller-schmidt@dsv.su.se' },
{ id: 124, name: 'Layla Abdallah', username: 'laab6539', email: 'layla.abdallah@dsv.su.se' },
{ id: 125, name: 'Dragan Milosević', username: 'drmi8142', email: 'dragan.milosevic@dsv.su.se' },
{ id: 126, name: 'Aïsha Benali', username: 'aibe2785', email: 'aisha.benali@dsv.su.se' },
{ id: 127, name: 'Omar El-Khoury', username: 'omkh5396', email: 'omar.el-khoury@dsv.su.se' },
{ id: 128, name: 'Željana Petković', username: 'žepe7614', email: 'zeljana.petkovic@dsv.su.se' },
{ id: 129, name: 'Mohammed Al-Zahra', username: 'moal3928', email: 'mohammed.al-zahra@dsv.su.se' },
{ id: 130, name: 'Francesca Di Marco', username: 'frdi6157', email: 'francesca.di-marco@dsv.su.se' },
{ id: 131, name: 'Kemal Özdogan', username: 'keöz8473', email: 'kemal.ozdogan@dsv.su.se' },
{ id: 132, name: 'Rania Mansour', username: 'rama4692', email: 'rania.mansour@dsv.su.se' },
{ id: 133, name: 'Miloš Jovanović', username: 'mijo7285', email: 'milos.jovanovic@dsv.su.se' },
{ id: 134, name: 'Yasmina Benchaib', username: 'yabe5814', email: 'yasmina.benchaib@dsv.su.se' },
{ id: 135, name: 'Andrés López Rivera', username: 'anlo3647', email: 'andres.lopez-rivera@dsv.su.se' },
{ id: 136, name: 'Nour Al-Mahmoud', username: 'noal9172', email: 'nour.al-mahmoud@dsv.su.se' },
{ id: 137, name: 'Goran Nikolić', username: 'goni6428', email: 'goran.nikolic@dsv.su.se' },
{ id: 138, name: 'Ines García López', username: 'inga4753', email: 'ines.garcia-lopez@dsv.su.se' },
{ id: 139, name: 'Yusuf Çelik', username: 'yuçe8596', email: 'yusuf.celik@dsv.su.se' },
{ id: 140, name: 'Maryam Hosseini', username: 'maho2319', email: 'maryam.hosseini@dsv.su.se' },
{ id: 141, name: 'Stjepan Kovačević', username: 'stko7684', email: 'stjepan.kovacevic@dsv.su.se' },
{ id: 142, name: 'Samira Bouzid', username: 'sabo5127', email: 'samira.bouzid@dsv.su.se' },
{ id: 143, name: 'Dimitri Papadopoulos', username: 'dipa8941', email: 'dimitri.papadopoulos@dsv.su.se' },
{ id: 144, name: 'Leïla Benabbas', username: 'lebe3465', email: 'leila.benabbas@dsv.su.se' },
{ id: 145, name: 'Aleksandar Stanković', username: 'alst6782', email: 'aleksandar.stankovic@dsv.su.se' },
{ id: 146, name: 'Carmen Ruiz Vega', username: 'caru9218', email: 'carmen.ruiz-vega@dsv.su.se' },
{ id: 147, name: 'Tariq Al-Masri', username: 'taal4576', email: 'tariq.al-masri@dsv.su.se' },
{ id: 148, name: 'Nataša Đorđević', username: 'naðo7839', email: 'natasa.djordjevic@dsv.su.se' },
{ id: 149, name: 'Ibrahim Kaya', username: 'ibka2153', email: 'ibrahim.kaya@dsv.su.se' },
{ id: 150, name: 'Soraya Ahmadi', username: 'soah5694', email: 'soraya.ahmadi@dsv.su.se' },
{ id: 151, name: 'Nikola Bogdanović', username: 'nibo8327', email: 'nikola.bogdanovic@dsv.su.se' },
{ id: 152, name: 'Lucía Hernández Silva', username: 'luhe6471', email: 'lucia.hernandez-silva@dsv.su.se' },
{ id: 153, name: 'Mehmet Güler', username: 'megu3795', email: 'mehmet.guler@dsv.su.se' },
{ id: 154, name: 'Zahra Mortazavi', username: 'zamo9148', email: 'zahra.mortazavi@dsv.su.se' },
{ id: 155, name: 'Petar Živković', username: 'peži4672', email: 'petar.zivkovic@dsv.su.se' },
{ id: 156, name: 'Inés Moreno Castro', username: 'inmo7286', email: 'ines.moreno-castro@dsv.su.se' },
{ id: 157, name: 'Salam Al-Faraj', username: 'saal5539', email: 'salam.al-faraj@dsv.su.se' },
{ id: 158, name: 'Tijana Milić', username: 'timi8713', email: 'tijana.milic@dsv.su.se' },
{ id: 159, name: 'Raúl Jiménez Torres', username: 'raji2467', email: 'raul.jimenez-torres@dsv.su.se' },
{ id: 160, name: 'Bana Al-Qasemi', username: 'baal6824', email: 'bana.al-qasemi@dsv.su.se' },
{ id: 161, name: 'Marko Simić', username: 'masi4195', email: 'marko.simic@dsv.su.se' },
{ id: 162, name: 'Álvaro González Díaz', username: 'algo7658', email: 'alvaro.gonzalez-diaz@dsv.su.se' },
{ id: 163, name: 'Hala Kassem', username: 'haka3912', email: 'hala.kassem@dsv.su.se' },
{ id: 164, name: 'Damir Petrović', username: 'dape9275', email: 'damir.petrovic@dsv.su.se' },
{ id: 165, name: 'Esperanza Rivera Morales', username: 'esri5438', email: 'esperanza.rivera-morales@dsv.su.se' },
{ id: 166, name: 'Rashid Al-Nouri', username: 'raal8164', email: 'rashid.al-nouri@dsv.su.se' },
{ id: 167, name: 'Milica Vuković', username: 'mivu6729', email: 'milica.vukovic@dsv.su.se' },
{ id: 168, name: 'Cristóbal Herrera López', username: 'crhe4583', email: 'cristobal.herrera-lopez@dsv.su.se' },
{ id: 169, name: 'Amina Benali', username: 'ambe7296', email: 'amina.benali@dsv.su.se' },
{ id: 170, name: 'Saša Radović', username: 'sara9851', email: 'sasa.radovic@dsv.su.se' },
{ id: 171, name: 'Adrián Vargas Ruiz', username: 'adva3467', email: 'adrian.vargas-ruiz@dsv.su.se' },
{ id: 172, name: 'Laith Al-Khouri', username: 'laal6142', email: 'laith.al-khouri@dsv.su.se' },
{ id: 173, name: 'Jovana Stojanović', username: 'jost8375', email: 'jovana.stojanovic@dsv.su.se' },
{ id: 174, name: 'Sebastián Méndez Torres', username: 'seme5698', email: 'sebastian.mendez-torres@dsv.su.se' },
{ id: 175, name: 'Dina Al-Rashid', username: 'dial2914', email: 'dina.al-rashid@dsv.su.se' },
{ id: 176, name: 'Viktor Đokić', username: 'viðo7427', email: 'viktor.djokic@dsv.su.se' },
{ id: 177, name: 'Paloma Santos García', username: 'pasa4851', email: 'paloma.santos-garcia@dsv.su.se' },
{ id: 178, name: 'Khalil Mahmoud', username: 'khma6293', email: 'khalil.mahmoud@dsv.su.se' },
{ id: 179, name: 'Anja Matić', username: 'anma9576', email: 'anja.matic@dsv.su.se' },
{ id: 180, name: 'Eduardo Ramírez Silva', username: 'edra3184', email: 'eduardo.ramirez-silva@dsv.su.se' },
{ id: 181, name: 'Yasmin Al-Qadri', username: 'yaal7629', email: 'yasmin.al-qadri@dsv.su.se' },
{ id: 182, name: 'Nemanja Vasić', username: 'neva5472', email: 'nemanja.vasic@dsv.su.se' },
{ id: 183, name: 'Sofía Castillo Moreno', username: 'soca8196', email: 'sofia.castillo-moreno@dsv.su.se' },
{ id: 184, name: 'Fadi Haddad', username: 'faha2758', email: 'fadi.haddad@dsv.su.se' },
{ id: 185, name: 'Dragana Marković', username: 'drma6341', email: 'dragana.markovic@dsv.su.se' },
{ id: 186, name: 'Matías Herrera Vega', username: 'mahe9487', email: 'matias.herrera-vega@dsv.su.se' },
{ id: 187, name: 'Lina Al-Zahra', username: 'lial4725', email: 'lina.al-zahra@dsv.su.se' },
{ id: 188, name: 'Stefan Đurić', username: 'stðu7853', email: 'stefan.djuric@dsv.su.se' },
{ id: 189, name: 'Valentina López García', username: 'valo3619', email: 'valentina.lopez-garcia@dsv.su.se' },
{ id: 190, name: 'Samir Nasser', username: 'sana8274', email: 'samir.nasser@dsv.su.se' },
{ id: 191, name: 'Ana Milanović', username: 'anmi6527', email: 'ana.milanovic@dsv.su.se' },
{ id: 192, name: 'Gabriel Fernández Ruiz', username: 'gafe4892', email: 'gabriel.fernandez-ruiz@dsv.su.se' },
{ id: 193, name: 'Rana Al-Masri', username: 'raal7156', email: 'rana.al-masri@dsv.su.se' },
{ id: 194, name: 'Dejan Nikolić', username: 'deni3481', email: 'dejan.nikolic@dsv.su.se' },
{ id: 195, name: 'Isabella Torres Díaz', username: 'isto8675', email: 'isabella.torres-diaz@dsv.su.se' },
{ id: 196, name: 'Omar Benali', username: 'ombe5239', email: 'omar.benali@dsv.su.se' },
{ id: 197, name: 'Milena Stanković', username: 'mist9164', email: 'milena.stankovic@dsv.su.se' },
{ id: 198, name: 'Alejandro Morales Castro', username: 'almo2748', email: 'alejandro.morales-castro@dsv.su.se' },
{ id: 199, name: 'Nadia Farouk', username: 'nafa6583', email: 'nadia.farouk@dsv.su.se' },
{ id: 200, name: 'Bojan Petrović', username: 'bope7926', email: 'bojan.petrovic@dsv.su.se' },
{ id: 201, name: 'Camila Sánchez Torres', username: 'casa4371', email: 'camila.sanchez-torres@dsv.su.se' },
{ id: 202, name: 'Yousef Al-Ahmad', username: 'yoal8649', email: 'yousef.al-ahmad@dsv.su.se' },
{ id: 203, name: 'Teodora Jovanović', username: 'tejo5194', email: 'teodora.jovanovic@dsv.su.se' },
{ id: 204, name: 'Diego Herrera Martín', username: 'dihe9738', email: 'diego.herrera-martin@dsv.su.se' },
{ id: 205, name: 'Farah Al-Qasimi', username: 'faal3526', email: 'farah.al-qasimi@dsv.su.se' },
{ id: 206, name: 'Luka Mitrović', username: 'lumi7284', email: 'luka.mitrovic@dsv.su.se' },
{ id: 207, name: 'Natalia Guzmán Silva', username: 'nagu6417', email: 'natalia.guzman-silva@dsv.su.se' },
{ id: 208, name: 'Karim Al-Rashid', username: 'kaal8952', email: 'karim.al-rashid@dsv.su.se' },
{ id: 209, name: 'Maja Stojanović', username: 'mast4635', email: 'maja.stojanovic@dsv.su.se' },
{ id: 210, name: 'Emilio García Pérez', username: 'emga7158', email: 'emilio.garcia-perez@dsv.su.se' },
{ id: 211, name: 'Layla Mansouri', username: 'lama2794', email: 'layla.mansouri@dsv.su.se' },
{ id: 212, name: 'Marija Đorđević', username: 'maðo6381', email: 'marija.djordjevic@dsv.su.se' },
{ id: 213, name: 'Ricardo Vásquez López', username: 'riva9517', email: 'ricardo.vasquez-lopez@dsv.su.se' },
{ id: 214, name: 'Zeinab Al-Khoury', username: 'zeal5743', email: 'zeinab.al-khoury@dsv.su.se' },
{ id: 215, name: 'Filip Kostić', username: 'fiko8296', email: 'filip.kostic@dsv.su.se' },
// Really long but realistic names
{ id: 216, name: 'María Esperanza del Carmen Rodríguez-Fernández y Pérez-González', username: 'maes4829', email: 'maria.esperanza.rodriguez-fernandez@dsv.su.se' },
{ id: 217, name: 'Alexandros Konstantinos Dimitrios Papadopoulos-Georgiou', username: 'alko7395', email: 'alexandros.konstantinos.papadopoulos@dsv.su.se' },
{ id: 218, name: 'Jean-Baptiste François-Xavier de la Croix-Montmorency', username: 'jefr2648', email: 'jean-baptiste.de-la-croix@dsv.su.se' },
{ id: 219, name: 'Ana-María Concepción Guadalupe Hernández-Vásquez de los Santos', username: 'anco5973', email: 'ana-maria.hernandez-vasquez@dsv.su.se' },
{ id: 220, name: 'Christophoros Theodoros Athanasios Michalopoulos-Stavropoulos', username: 'chth8416', email: 'christophoros.michalopoulos@dsv.su.se' },
{ id: 221, name: 'José María Antonio Francisco Javier García-Sánchez y Martínez-López', username: 'joma3672', email: 'jose.maria.garcia-sanchez@dsv.su.se' },
{ id: 222, name: 'Elisabeth Charlotte Francoise Marie-Antoinette von Habsburg-Lorraine', username: 'elch9254', email: 'elisabeth.von-habsburg@dsv.su.se' },
{ id: 223, name: 'Pedro Alfonso Ramón Fernando Díez-Canseco y Herrera-Mendoza', username: 'peal4817', email: 'pedro.alfonso.diez-canseco@dsv.su.se' },
{ id: 224, name: 'Anastasia Ekaterini Theodora Constantinidou-Papadimitriou', username: 'anek6539', email: 'anastasia.constantinidou@dsv.su.se' },
{ id: 225, name: 'Francesco Giuseppe Alessandro Maria Benedetto di Savoia-Carignano', username: 'frgi8142', email: 'francesco.di-savoia@dsv.su.se' },
// English names
{ id: 226, name: 'Oliver Pemberton-Wells', username: 'olpe2785', email: 'oliver.pemberton-wells@dsv.su.se' },
{ id: 227, name: 'Charlotte Ashworth', username: 'chas5396', email: 'charlotte.ashworth@dsv.su.se' },
{ id: 228, name: 'Henry Fitzpatrick', username: 'hefi7614', email: 'henry.fitzpatrick@dsv.su.se' },
{ id: 229, name: 'Imogen Blackwood', username: 'imbl3928', email: 'imogen.blackwood@dsv.su.se' },
{ id: 230, name: 'Rupert Cholmondeley', username: 'ruch6157', email: 'rupert.cholmondeley@dsv.su.se' },
{ id: 231, name: 'Arabella Thornbury', username: 'arth8473', email: 'arabella.thornbury@dsv.su.se' },
{ id: 232, name: 'Benedict Worthington', username: 'bewo4692', email: 'benedict.worthington@dsv.su.se' },
{ id: 233, name: 'Cordelia Featherstone', username: 'cofe7285', email: 'cordelia.featherstone@dsv.su.se' },
{ id: 234, name: 'Jasper Windermere', username: 'jawi5814', email: 'jasper.windermere@dsv.su.se' },
{ id: 235, name: 'Penelope Harrington-Lloyd', username: 'peha3647', email: 'penelope.harrington-lloyd@dsv.su.se' },
// Norwegian names
{ id: 236, name: 'Magnus Bjørklund', username: 'mabj9172', email: 'magnus.bjorklund@dsv.su.se' },
{ id: 237, name: 'Astrid Haugen', username: 'asha6428', email: 'astrid.haugen@dsv.su.se' },
{ id: 238, name: 'Lars Øvrebø', username: 'laøv4753', email: 'lars.ovrebo@dsv.su.se' },
{ id: 239, name: 'Ingrid Løvdal', username: 'inlø8596', email: 'ingrid.lovdal@dsv.su.se' },
{ id: 240, name: 'Erik Åsheim', username: 'erås2319', email: 'erik.asheim@dsv.su.se' },
{ id: 241, name: 'Kari Fjellheim', username: 'kafj7684', email: 'kari.fjellheim@dsv.su.se' },
{ id: 242, name: 'Olav Størdahl', username: 'olst5127', email: 'olav.stordahl@dsv.su.se' },
{ id: 243, name: 'Silje Rønning', username: 'sir8941', email: 'silje.ronning@dsv.su.se' },
{ id: 244, name: 'Torstein Bråten', username: 'tobr3465', email: 'torstein.braten@dsv.su.se' },
{ id: 245, name: 'Gunnhild Skjælaaen', username: 'gusk6782', email: 'gunnhild.skjaelaaen@dsv.su.se' },
// American names
{ id: 246, name: 'Hunter McKenzie III', username: 'humc9218', email: 'hunter.mckenzie@dsv.su.se' },
{ id: 247, name: 'Madison Taylor-Brooks', username: 'mata4576', email: 'madison.taylor-brooks@dsv.su.se' },
{ id: 248, name: 'Braxton Washington Jr.', username: 'brwa7839', email: 'braxton.washington@dsv.su.se' },
{ id: 249, name: 'Skylar Kennedy-Davis', username: 'skke2153', email: 'skylar.kennedy-davis@dsv.su.se' },
{ id: 250, name: 'Preston Montgomery IV', username: 'prmo5694', email: 'preston.montgomery@dsv.su.se' }
];
export const USER = {
id: 1,
name: 'Jacob Reinikainen',
username: 'jare2473',
email: 'jacob.reinikainen@dsv.su.se'
};
export const DEFAULT_DISABLED_OPTIONS = {
1: false,
2: false,

View File

@@ -1,4 +1,4 @@
import { createContext, useContext } from 'react';
import React, { createContext, useContext } from 'react';
const BookingContext = createContext(null);

View File

@@ -0,0 +1,146 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { today, getLocalTimeZone, CalendarDate } from '@internationalized/date';
import { USER } from '../constants/bookingConstants';
const SettingsContext = createContext();
export const useSettingsContext = () => {
const context = useContext(SettingsContext);
if (!context) {
throw new Error('useSettingsContext must be used within a SettingsProvider');
}
return context;
};
export const SettingsProvider = ({ children }) => {
const [settings, setSettings] = useState(() => {
// Load settings from localStorage or use defaults
const saved = localStorage.getItem('calendarSettings');
if (saved) {
try {
const parsed = JSON.parse(saved);
return {
// Set defaults first
mockToday: null,
bookingRangeDays: 14,
roomAvailabilityChance: 0.7,
numberOfRooms: 5,
earliestTimeSlot: 0,
latestTimeSlot: 23,
currentUserName: USER.name,
showDevelopmentBanner: false,
showBookingConfirmationBanner: false,
showBookingDeleteBanner: false,
bookingFormType: 'inline', // 'modal' or 'inline'
// Then override with saved values
...parsed,
// Convert date strings back to DateValue objects
mockToday: parsed.mockToday ? new Date(parsed.mockToday) : null,
// Ensure currentUserName has a fallback
currentUserName: parsed.currentUserName || USER.name,
};
} catch (e) {
console.warn('Failed to parse saved settings:', e);
}
}
return {
// Use mock date if set, otherwise real today
mockToday: null,
// Days in the future users can book
bookingRangeDays: 14,
// Room availability percentage
roomAvailabilityChance: 0.7,
// Number of rooms
numberOfRooms: 5,
// Earliest booking time (in half-hour slots from 8:00)
earliestTimeSlot: 0, // 8:00
// Latest booking time
latestTimeSlot: 23, // 19:30 (last slot ending at 20:00)
// Current user settings
currentUserName: USER.name,
// Development banner toggle
showDevelopmentBanner: false,
// Booking confirmation banner toggle
showBookingConfirmationBanner: false,
// Booking delete banner toggle
showBookingDeleteBanner: false,
// Booking form type
bookingFormType: 'inline', // 'modal' or 'inline'
};
});
// Save settings to localStorage whenever they change
useEffect(() => {
const toSave = {
...settings,
// Convert Date objects to strings for JSON serialization
mockToday: settings.mockToday ? settings.mockToday.toISOString() : null,
};
localStorage.setItem('calendarSettings', JSON.stringify(toSave));
}, [settings]);
// Get the effective "today" date (mock or real)
const getEffectiveToday = () => {
if (settings.mockToday) {
// Convert JavaScript Date to CalendarDate using the proper library function
const mockDate = settings.mockToday;
const year = mockDate.getFullYear();
const month = mockDate.getMonth() + 1; // JS months are 0-indexed
const day = mockDate.getDate();
return new CalendarDate(year, month, day);
}
return today(getLocalTimeZone());
};
const updateSettings = (newSettings) => {
setSettings(prev => ({ ...prev, ...newSettings }));
};
const resetSettings = () => {
setSettings({
mockToday: null,
bookingRangeDays: 14,
roomAvailabilityChance: 0.7,
numberOfRooms: 5,
earliestTimeSlot: 0,
latestTimeSlot: 23,
currentUserName: USER.name, // This will reset to "Jacob Reinikainen"
showDevelopmentBanner: false,
showBookingConfirmationBanner: false,
showBookingDeleteBanner: false,
bookingFormType: 'inline',
});
localStorage.removeItem('calendarSettings');
};
// Get current user as participant object
const getCurrentUser = () => {
return {
id: USER.id,
name: settings.currentUserName,
username: USER.username,
email: USER.email
};
};
// Get dynamic default booking title
const getDefaultBookingTitle = () => {
const firstName = settings.currentUserName.split(' ')[0];
return `${firstName}s bokning`;
};
return (
<SettingsContext.Provider value={{
settings,
updateSettings,
resetSettings,
getEffectiveToday,
getCurrentUser,
getDefaultBookingTitle,
}}>
{children}
</SettingsContext.Provider>
);
};

View File

@@ -4,28 +4,38 @@ export function getTimeFromIndex(timeIndex) {
const totalHalfHoursFromStart = timeIndex;
const totalMinutes = 8 * 60 + totalHalfHoursFromStart * 30; // 8:00 as base
const hours = Math.floor(totalMinutes / 60);
let hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours < 10) {
hours = `0${hours}`;
}
return `${hours}:${minutes === 0 ? '00' : '30'}`;
}
export function convertDateObjectToString( date ) {
const days = ["Måndag", "Tisdag", "Onsdag", "Torsdag", "Fredag", "Lördag", "Söndag"];
const dayIndex = getDayOfWeek(date, "en-US");
const dayOfWeek = (dayIndex >= 1 && dayIndex <= 7) ? days[dayIndex - 1] : "Ogiltig dag";
const months = [
"Januari", "Februari", "Mars", "April", "Maj", "Juni",
"Juli", "Augusti", "September", "Oktober", "November", "December"
];
const monthIndex = date.month;
const monthName = (monthIndex >= 1 && monthIndex <= 12) ? months[monthIndex - 1] : "Ogiltig månad";
return `${dayOfWeek} ${date.day} ${monthName} ${date.year}`;
return "";
// Always use long format for now
const isSmallScreen = false;
if (isSmallScreen) {
const days = ["Mån", "Tis", "Ons", "Tor", "Fre", "Lör", "Sön"];
const months = ["Jan", "Feb", "Mar", "Apr", "Maj", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dec"];
const dayOfWeek = (dayIndex >= 0 && dayIndex < 7) ? days[dayIndex === 0 ? 6 : dayIndex - 1] : "Ogiltig dag";
const monthName = (monthIndex >= 1 && monthIndex <= 12) ? months[monthIndex - 1] : "Ogiltig månad";
return `${dayOfWeek} ${date.day} ${monthName}`;
} else {
const days = ["Måndag", "Tisdag", "Onsdag", "Torsdag", "Fredag", "Lördag", "Söndag"];
const months = ["Januari", "Februari", "Mars", "April", "Maj", "Juni", "Juli", "Augusti", "September", "Oktober", "November", "December"];
const dayOfWeek = (dayIndex >= 0 && dayIndex < 7) ? days[dayIndex === 0 ? 6 : dayIndex - 1] : "Ogiltig dag";
const monthName = (monthIndex >= 1 && monthIndex <= 12) ? months[monthIndex - 1] : "Ogiltig månad";
return `${dayOfWeek} ${date.day} ${monthName} ${date.year}`;
}
}

View File

@@ -6,19 +6,44 @@ import {
generateId,
findObjectById
} from '../utils/bookingUtils';
import { DEFAULT_BOOKING_TITLE } from '../constants/bookingConstants';
import { PEOPLE, USER } from '../constants/bookingConstants';
import { useDisabledOptions } from './useDisabledOptions';
import { useSettingsContext } from '../context/SettingsContext';
export function useBookingState(addBooking) {
function getRoomCategory(roomName) {
// Extract room number from room name (e.g., "G5:7" -> 7)
const roomNumber = parseInt(roomName.split(':')[1]);
// Assign categories based on room number ranges
if (roomNumber >= 1 && roomNumber <= 4) return 'green';
if (roomNumber >= 5 && roomNumber <= 8) return 'red';
if (roomNumber >= 9 && roomNumber <= 12) return 'blue';
if (roomNumber >= 13 && roomNumber <= 15) return 'yellow';
// Default fallback
return 'green';
}
export function useBookingState(addBooking, initialDate = null) {
const { settings, getDefaultBookingTitle } = useSettingsContext();
// State hooks - simplified back to useState for stability
const [timeSlotsByRoom, setTimeSlotsByRoom] = useState(generateInitialRooms());
const [timeSlotsByRoom, setTimeSlotsByRoom] = useState(() =>
generateInitialRooms(
settings.roomAvailabilityChance,
settings.numberOfRooms,
settings.earliestTimeSlot,
settings.latestTimeSlot
)
);
const [currentRoom, setCurrentRoom] = useState(null);
const [selectedRoom, setSelectedRoom] = useState("allRooms");
const [assignedRoom, setAssignedRoom] = useState(null);
const [selectedStartIndex, setSelectedStartIndex] = useState(null);
const [selectedEndIndex, setSelectedEndIndex] = useState(null);
const [selectedBookingLength, setSelectedBookingLength] = useState(0);
const [participant, setParticipant] = useState("Arjohn Emilsson");
const [selectedDate, setSelectedDate] = useState(today(getLocalTimeZone()));
const [selectedDate, setSelectedDate] = useState(initialDate || today(getLocalTimeZone()));
const [availableTimeSlots, setAvailableTimeSlots] = useState([]);
const [indeciesInHover, setIndeciesInHover] = useState([]);
const [title, setTitle] = useState("");
@@ -35,6 +60,7 @@ export function useBookingState(addBooking) {
setAvailableTimeSlots([]);
setSelectedStartIndex(null);
setSelectedEndIndex(null);
setAssignedRoom(null);
}, []);
const resetSelections = useCallback(() => {
@@ -48,16 +74,22 @@ export function useBookingState(addBooking) {
}, []);
const handleTimeCardClick = useCallback((startHour, hoursAvailable, roomId) => {
console.log('TimeCard clicked:', { startHour, hoursAvailable, roomId });
setSelectedStartIndex(startHour);
setSelectedEndIndex(startHour + hoursAvailable);
setSelectedRoom(roomId);
setAssignedRoom(roomId);
}, []);
const handleDateChange = useCallback((date) => {
setSelectedDate(date);
setTimeSlotsByRoom(generateInitialRooms());
setTimeSlotsByRoom(generateInitialRooms(
settings.roomAvailabilityChance,
settings.numberOfRooms,
settings.earliestTimeSlot,
settings.latestTimeSlot
));
resetTimeSelections();
}, [resetTimeSelections]);
}, [resetTimeSelections, settings.roomAvailabilityChance, settings.numberOfRooms, settings.earliestTimeSlot, settings.latestTimeSlot]);
const handleRoomChange = useCallback((event) => {
const roomValue = event.target.value;
@@ -77,20 +109,36 @@ export function useBookingState(addBooking) {
}, [resetTimeSelections]);
const handleSave = useCallback(() => {
// Use assignedRoom for the booking, not selectedRoom
const roomToBook = selectedRoom !== "allRooms" ? selectedRoom : assignedRoom;
console.log('Saving booking with:', {
selectedStartIndex,
selectedEndIndex,
room: roomToBook,
title
});
// Include the current user as a participant if not already added
const allParticipants = participants.find(p => p.id === USER.id)
? participants
: [USER, ...participants];
addBooking({
id: generateId(),
date: selectedDate,
startTime: selectedStartIndex,
endTime: selectedEndIndex,
room: selectedRoom,
title: title !== "" ? title : DEFAULT_BOOKING_TITLE,
participants: participants
room: roomToBook,
roomCategory: getRoomCategory(roomToBook),
title: title !== "" ? title : getDefaultBookingTitle(),
participants: allParticipants
});
resetSelections();
navigate('/');
window.scrollTo(0, 0);
}, [addBooking, selectedDate, selectedStartIndex, selectedEndIndex, selectedRoom, title, participants, resetSelections, navigate]);
}, [addBooking, selectedDate, selectedStartIndex, selectedEndIndex, selectedRoom, assignedRoom, title, participants, resetSelections, navigate, getDefaultBookingTitle]);
const handleTimeCardExit = useCallback(() => {
if (!selectedEndIndex) {
@@ -98,11 +146,24 @@ export function useBookingState(addBooking) {
}
}, [selectedEndIndex]);
const handleParticipantChange = useCallback((participant) => {
if (participant !== null) {
setParticipants(prev => [...prev, participant.trim()]);
const handleParticipantChange = useCallback((participantId) => {
console.log('handleParticipantChange called with:', participantId);
if (participantId !== null && participantId !== undefined) {
// Find the person by ID and add the full person object
const person = PEOPLE.find(p => p.id === participantId);
console.log('Found person:', person);
if (person && !participants.find(p => p.id === person.id)) {
console.log('Adding participant:', person.name);
setParticipants(prev => [...prev, person]);
} else {
console.log('Participant already exists or person not found');
}
setParticipant("");
}
}, [participants]);
const handleRemoveParticipant = useCallback((participantToRemove) => {
setParticipants(prev => prev.filter(p => p.id !== participantToRemove.id));
}, []);
// Memoize the return object to prevent unnecessary re-renders
@@ -111,6 +172,7 @@ export function useBookingState(addBooking) {
timeSlotsByRoom,
currentRoom,
selectedRoom,
assignedRoom,
selectedStartIndex,
selectedEndIndex,
selectedBookingLength,
@@ -134,10 +196,13 @@ export function useBookingState(addBooking) {
handleSave,
handleTimeCardExit,
handleParticipantChange,
handleRemoveParticipant,
resetTimeSelections,
}), [
timeSlotsByRoom,
currentRoom,
selectedRoom,
assignedRoom,
selectedStartIndex,
selectedEndIndex,
selectedBookingLength,
@@ -155,5 +220,7 @@ export function useBookingState(addBooking) {
handleSave,
handleTimeCardExit,
handleParticipantChange,
handleRemoveParticipant,
resetTimeSelections,
]);
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
export default function CalendarIcon({ color = '#666', size = 16 }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" stroke={color} strokeWidth="2" fill="none"/>
<line x1="16" y1="2" x2="16" y2="6" stroke={color} strokeWidth="2"/>
<line x1="8" y1="2" x2="8" y2="6" stroke={color} strokeWidth="2"/>
<line x1="3" y1="10" x2="21" y2="10" stroke={color} strokeWidth="2"/>
</svg>
);
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
export default function ChevronLeft({ className, color, disabled, ...props }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill={color || "currentColor"}
viewBox="0 0 16 16"
className={className}
style={{ opacity: disabled ? 0.3 : 1, transition: 'opacity 0.2s' }}
{...props}
>
<path d="M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0"/>
</svg>
);
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
export default function ChevronRight({ className, color, disabled, ...props }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill={color || "currentColor"}
viewBox="0 0 16 16"
className={className}
style={{ opacity: disabled ? 0.3 : 1, transition: 'opacity 0.2s' }}
{...props}
>
<path d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708"/>
</svg>
);
}

View File

@@ -1,4 +1,4 @@
import { StrictMode } from 'react'
import React, { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'

View File

@@ -0,0 +1,321 @@
import React, { useState } from 'react';
import { useSettingsContext } from '../context/SettingsContext';
import styles from './BookingSettings.module.css';
export function BookingSettings() {
const { settings, updateSettings, resetSettings, getEffectiveToday } = useSettingsContext();
const [tempDate, setTempDate] = useState('');
const handleMockDateChange = (e) => {
const dateValue = e.target.value;
setTempDate(dateValue);
if (dateValue) {
updateSettings({ mockToday: new Date(dateValue) });
} else {
updateSettings({ mockToday: null });
}
};
const formatDateForInput = (date) => {
if (!date) return '';
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const getTimeFromSlot = (slot) => {
const totalMinutes = 8 * 60 + slot * 30;
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return `${hours}:${minutes === 0 ? '00' : '30'}`;
};
const effectiveToday = getEffectiveToday();
const isUsingMockDate = settings.mockToday !== null;
return (
<div className={styles.container}>
<div className={styles.header}>
<h1>Booking Settings</h1>
<p>Configure booking system behavior for testing purposes</p>
</div>
<div className={styles.content}>
<div className={styles.section}>
<h2>User Settings</h2>
<div className={styles.setting}>
<label htmlFor="userName">
<strong>Your Name</strong>
<span className={styles.description}>
Your name as it appears in bookings and participant lists
</span>
</label>
<input
id="userName"
type="text"
value={settings.currentUserName}
onChange={(e) => updateSettings({ currentUserName: e.target.value })}
className={styles.textInput}
placeholder="Enter your name"
/>
</div>
</div>
<div className={styles.section}>
<h2>Display Settings</h2>
<div className={styles.setting}>
<label htmlFor="developmentBanner">
<strong>Show Development Banner</strong>
<span className={styles.description}>
Display a banner indicating development/test data
</span>
</label>
<div className={styles.toggleGroup}>
<input
id="developmentBanner"
type="checkbox"
checked={settings.showDevelopmentBanner}
onChange={(e) => updateSettings({ showDevelopmentBanner: e.target.checked })}
className={styles.toggle}
/>
<span className={styles.toggleStatus}>
{settings.showDevelopmentBanner ? 'Enabled' : 'Disabled'}
</span>
</div>
</div>
<div className={styles.setting}>
<label htmlFor="bookingConfirmationBanner">
<strong>Show Booking Confirmation Banner</strong>
<span className={styles.description}>
Display a banner that looks like a booking confirmation
</span>
</label>
<div className={styles.toggleGroup}>
<input
id="bookingConfirmationBanner"
type="checkbox"
checked={settings.showBookingConfirmationBanner}
onChange={(e) => updateSettings({ showBookingConfirmationBanner: e.target.checked })}
className={styles.toggle}
/>
<span className={styles.toggleStatus}>
{settings.showBookingConfirmationBanner ? 'Enabled' : 'Disabled'}
</span>
</div>
</div>
<div className={styles.setting}>
<label htmlFor="bookingDeleteBanner">
<strong>Show Booking Delete Banner</strong>
<span className={styles.description}>
Display a banner that looks like a booking deletion confirmation
</span>
</label>
<div className={styles.toggleGroup}>
<input
id="bookingDeleteBanner"
type="checkbox"
checked={settings.showBookingDeleteBanner}
onChange={(e) => updateSettings({ showBookingDeleteBanner: e.target.checked })}
className={styles.toggle}
/>
<span className={styles.toggleStatus}>
{settings.showBookingDeleteBanner ? 'Enabled' : 'Disabled'}
</span>
</div>
</div>
<div className={styles.setting}>
<label htmlFor="bookingFormType">
<strong>Booking Form Type</strong>
<span className={styles.description}>
Choose between modal popup or inline form for creating bookings
</span>
</label>
<select
id="bookingFormType"
value={settings.bookingFormType}
onChange={(e) => updateSettings({ bookingFormType: e.target.value })}
className={styles.select}
>
<option value="inline">Inline Form (New)</option>
<option value="modal">Modal Popup (Classic)</option>
</select>
<div className={styles.currentStatus}>
Current: <strong>{settings.bookingFormType === 'inline' ? 'Inline Form' : 'Modal Popup'}</strong>
</div>
</div>
</div>
<div className={styles.section}>
<h2>Date Settings</h2>
<div className={styles.setting}>
<label htmlFor="mockDate">
<strong>Mock Today's Date</strong>
<span className={styles.description}>
Override the current date for testing. Leave empty to use real date.
</span>
</label>
<div className={styles.dateGroup}>
<input
id="mockDate"
type="date"
value={settings.mockToday ? formatDateForInput(settings.mockToday) : ''}
onChange={handleMockDateChange}
className={styles.dateInput}
/>
{isUsingMockDate && (
<button
onClick={() => {
updateSettings({ mockToday: null });
setTempDate('');
}}
className={styles.clearButton}
>
Use Real Date
</button>
)}
</div>
<div className={styles.currentStatus}>
Effective today: <strong>{effectiveToday.day}/{effectiveToday.month}/{effectiveToday.year}</strong>
{isUsingMockDate && <span className={styles.mockLabel}> (MOCK)</span>}
</div>
</div>
<div className={styles.setting}>
<label htmlFor="bookingRange">
<strong>Booking Range (Days)</strong>
<span className={styles.description}>
How many days in the future users can book
</span>
</label>
<input
id="bookingRange"
type="number"
min="1"
max="365"
value={settings.bookingRangeDays}
onChange={(e) => updateSettings({ bookingRangeDays: parseInt(e.target.value) })}
className={styles.numberInput}
/>
<div className={styles.currentStatus}>
Latest bookable date: <strong>{effectiveToday.add({ days: settings.bookingRangeDays }).day}/{effectiveToday.add({ days: settings.bookingRangeDays }).month}/{effectiveToday.add({ days: settings.bookingRangeDays }).year}</strong>
</div>
</div>
</div>
<div className={styles.section}>
<h2>Room Settings</h2>
<div className={styles.setting}>
<label htmlFor="numberOfRooms">
<strong>Number of Rooms</strong>
<span className={styles.description}>
Total number of rooms available for booking
</span>
</label>
<input
id="numberOfRooms"
type="number"
min="1"
max="20"
value={settings.numberOfRooms}
onChange={(e) => updateSettings({ numberOfRooms: parseInt(e.target.value) })}
className={styles.numberInput}
/>
</div>
<div className={styles.setting}>
<label htmlFor="availabilityChance">
<strong>Room Availability %</strong>
<span className={styles.description}>
Percentage chance that a time slot is available (affects random generation)
</span>
</label>
<div className={styles.sliderGroup}>
<input
id="availabilityChance"
type="range"
min="0"
max="1"
step="0.1"
value={settings.roomAvailabilityChance}
onChange={(e) => updateSettings({ roomAvailabilityChance: parseFloat(e.target.value) })}
className={styles.slider}
/>
<span className={styles.sliderValue}>
{Math.round(settings.roomAvailabilityChance * 100)}%
</span>
</div>
</div>
</div>
<div className={styles.section}>
<h2>Time Slot Settings</h2>
<div className={styles.setting}>
<label htmlFor="earliestTime">
<strong>Earliest Booking Time</strong>
<span className={styles.description}>
First available time slot of the day
</span>
</label>
<select
id="earliestTime"
value={settings.earliestTimeSlot}
onChange={(e) => updateSettings({ earliestTimeSlot: parseInt(e.target.value) })}
className={styles.select}
>
{Array.from({ length: 23 }, (_, i) => (
<option key={i} value={i}>
{getTimeFromSlot(i)}
</option>
))}
</select>
</div>
<div className={styles.setting}>
<label htmlFor="latestTime">
<strong>Latest Booking Time</strong>
<span className={styles.description}>
Last available time slot of the day
</span>
</label>
<select
id="latestTime"
value={settings.latestTimeSlot}
onChange={(e) => updateSettings({ latestTimeSlot: parseInt(e.target.value) })}
className={styles.select}
>
{Array.from({ length: 23 }, (_, i) => (
<option key={i} value={i} disabled={i <= settings.earliestTimeSlot}>
{getTimeFromSlot(i)}
</option>
))}
</select>
</div>
</div>
<div className={styles.actions}>
<button onClick={resetSettings} className={styles.resetButton}>
Reset to Defaults
</button>
<button
onClick={() => window.location.href = '/test-session'}
className={styles.testSessionButton}
>
🧪 Start Test Session
</button>
<div className={styles.info}>
Settings are automatically saved and will persist between sessions
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,306 @@
.container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
font-family: system-ui, -apple-system, sans-serif;
}
.header {
margin-bottom: 2rem;
text-align: center;
}
.header h1 {
font-size: 2.5rem;
font-weight: 600;
color: #1f2937;
margin-bottom: 0.5rem;
}
.header p {
color: #6b7280;
font-size: 1.1rem;
}
.content {
display: flex;
flex-direction: column;
gap: 2rem;
}
.section {
background: white;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.section h2 {
font-size: 1.5rem;
font-weight: 600;
color: #1f2937;
margin-bottom: 1.5rem;
border-bottom: 2px solid #f3f4f6;
padding-bottom: 0.5rem;
}
.setting {
margin-bottom: 1.5rem;
}
.setting:last-child {
margin-bottom: 0;
}
.setting label {
display: block;
margin-bottom: 0.5rem;
}
.setting label strong {
font-size: 1rem;
font-weight: 600;
color: #374151;
display: block;
}
.description {
font-size: 0.875rem;
color: #6b7280;
margin-top: 0.25rem;
display: block;
}
.dateGroup {
display: flex;
gap: 0.75rem;
align-items: center;
}
.dateInput, .numberInput, .select, .textInput {
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.2s, box-shadow 0.2s;
background: white;
}
.dateInput:focus, .numberInput:focus, .select:focus, .textInput:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.dateInput {
width: 200px;
}
.numberInput {
width: 120px;
}
.select {
width: 150px;
}
.textInput {
width: 250px;
}
.clearButton {
padding: 0.5rem 1rem;
background: #f3f4f6;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.875rem;
color: #374151;
cursor: pointer;
transition: all 0.2s;
width: fit-content;
}
.clearButton:hover {
background: #e5e7eb;
border-color: #9ca3af;
}
.currentStatus {
margin-top: 0.5rem;
font-size: 0.875rem;
color: #4b5563;
}
.mockLabel {
background: #fbbf24;
color: #92400e;
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.sliderGroup {
display: flex;
align-items: center;
gap: 1rem;
}
.slider {
flex: 1;
height: 6px;
border-radius: 3px;
background: #e5e7eb;
outline: none;
cursor: pointer;
-webkit-appearance: none;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #2563eb;
cursor: pointer;
border: 2px solid white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #2563eb;
cursor: pointer;
border: 2px solid white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.sliderValue {
font-weight: 600;
color: #2563eb;
min-width: 40px;
text-align: center;
}
.toggleGroup {
display: flex;
align-items: center;
gap: 0.75rem;
}
.toggle {
width: 50px;
height: 24px;
background: #d1d5db;
border-radius: 12px;
border: none;
position: relative;
cursor: pointer;
transition: background-color 0.3s ease;
-webkit-appearance: none;
appearance: none;
}
.toggle:checked {
background: #10b981;
}
.toggle:before {
content: '';
position: absolute;
width: 20px;
height: 20px;
border-radius: 50%;
background: white;
top: 2px;
left: 2px;
transition: transform 0.3s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.toggle:checked:before {
transform: translateX(26px);
}
.toggleStatus {
font-weight: 500;
color: #374151;
}
.actions {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;;
gap: 1rem;
padding: 2rem;
background: #f9fafb;
border-radius: 12px;
border: 1px solid #e5e7eb;
width: 100%;
}
.resetButton {
padding: 0.75rem 2rem;
background: #dc2626;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
width: fit-content;
}
.resetButton:hover {
background: #b91c1c;
}
.testSessionButton {
padding: 0.75rem 2rem;
background: #2563eb;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
width: fit-content;
}
.testSessionButton:hover {
background: #1d4ed8;
}
.info {
font-size: 0.875rem;
color: #6b7280;
text-align: center;
}
@media (max-width: 768px) {
.container {
padding: 1rem;
}
.section {
padding: 1rem;
}
.dateGroup {
flex-direction: column;
align-items: flex-start;
}
.sliderGroup {
flex-direction: column;
align-items: flex-start;
}
}

View File

@@ -1,28 +1,119 @@
import React, { useState, useEffect } from 'react';
import styles from './NewBooking.module.css';
import { TimeCardContainer } from '../components/TimeCardContainer';
import { BookingDatePicker } from '../components/BookingDatePicker';
import { BookingFormFields } from '../components/BookingFormFields';
import { BookingTitleField } from '../components/BookingTitleField';
import { ParticipantsSelector } from '../components/ParticipantsSelector';
import { RoomSelectionField } from '../components/RoomSelectionField';
import { BookingLengthField } from '../components/BookingLengthField';
import { useBookingState } from '../hooks/useBookingState';
import { BookingProvider } from '../context/BookingContext';
import { useSettingsContext } from '../context/SettingsContext';
export function NewBooking({ addBooking }) {
const booking = useBookingState(addBooking);
const { getEffectiveToday, settings } = useSettingsContext();
const booking = useBookingState(addBooking, getEffectiveToday());
const [showFilters, setShowFilters] = useState(false);
useEffect(() => {
window.scrollTo(0, 0);
}, []);
// Check if we should use inline form (hide title and participants from main form)
const useInlineForm = settings.bookingFormType === 'inline';
// Check if any filters are active
const hasActiveFilters = booking.selectedRoom !== "allRooms" || booking.selectedBookingLength > 0;
// Generate filter display text
const getFilterText = () => {
const filters = [];
if (booking.selectedRoom !== "allRooms") {
filters.push(booking.selectedRoom);
}
if (booking.selectedBookingLength > 0) {
const bookingLengths = {
1: "30 min",
2: "1 h",
3: "1.5 h",
4: "2 h",
5: "2.5 h",
6: "3 h",
7: "3.5 h",
8: "4 h"
};
filters.push(bookingLengths[booking.selectedBookingLength]);
}
if (filters.length === 0) return "Filter";
return `Filter (${filters.join(", ")})`;
};
// Reset all filters
const handleResetFilters = () => {
booking.handleRoomChange({ target: { value: "allRooms" } });
booking.handleLengthChange(0);
setShowFilters(false);
};
return (
<BookingProvider value={booking}>
<div className={styles.pageContainer}>
<h2>Boka litet grupprum</h2>
<div className={styles.formContainer}>
<main style={{ flex: 1 }}>
<div>
{/* Only show title and participants fields in modal mode */}
{!useInlineForm && (
<>
<BookingTitleField />
<ParticipantsSelector />
</>
)}
<div className={styles.bookingTimesContainer}>
<BookingDatePicker />
<BookingFormFields styles={styles} />
</div>
<h3 className={styles.elementHeading} style={{ padding: "0 0.5rem" }}>
Lediga tider
</h3>
<div>
<TimeCardContainer />
{/* Filter Button */}
<div className={styles.filtersSection}>
<button
className={`${styles.filterButton} ${hasActiveFilters ? styles.activeFilter : ''}`}
onClick={() => setShowFilters(!showFilters)}
>
<span>{getFilterText()}</span>
<span className={`${styles.chevron} ${showFilters ? styles.chevronUp : styles.chevronDown}`}>
{showFilters ? '▲' : '▼'}
</span>
</button>
{/* Collapsible Filter Content */}
{showFilters && (
<div className={styles.filtersContent}>
<div className={styles.filtersRow}>
<RoomSelectionField />
<BookingLengthField />
</div>
{hasActiveFilters && (
<div className={styles.resetSection}>
<button
className={styles.resetButton}
onClick={handleResetFilters}
>
Rensa filter
</button>
</div>
)}
</div>
)}
</div>
<h3 className={styles.elementHeading} style={{ padding: "0 0.5rem" }}>
Lediga tider
</h3>
<div>
<TimeCardContainer />
</div>
</div>
</main>
</div>

View File

@@ -1,5 +1,6 @@
.pageContainer {
padding: 1rem;
background-color: white;
}
.formContainer {
@@ -15,6 +16,16 @@
font-weight: 529;
}
.bookingTimesContainer {
margin-top: 2rem;
padding: 2rem;
border-radius: 0.3rem;
outline: 1px solid #E7E7E7;
display: flex;
flex-direction: column;
align-items: center;
}
.modalFooter {
width: 100%;
@@ -131,4 +142,129 @@
background-color: rgb(216, 216, 216);
width: 100%;
margin: 1rem 0;
}
/* Filter Section Styles */
.filtersSection {
width: 100%;
margin-top: 1rem;
margin-bottom: 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
}
.filterButton {
width: fit-content;
background: white;
border: 1px solid #D1D5DB;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
}
.filterButton:hover {
background: #F9FAFB;
border-color: #9CA3AF;
}
.filterButton:active {
background: #F3F4F6;
}
.activeFilter {
background: #EBF8FF !important;
border-color: #3B82F6 !important;
color: #1E40AF !important;
}
.activeFilter:hover {
background: #DBEAFE !important;
border-color: #2563EB !important;
}
.filterIcon {
font-size: 1rem;
}
.chevron {
font-size: 0.75rem;
color: #6B7280;
transition: transform 0.2s ease;
}
.chevronUp {
transform: rotate(0deg);
}
.chevronDown {
transform: rotate(0deg);
}
.filtersContent {
width: fit-content;
max-width: 600px;
padding: 1rem;
background: #F9FAFB;
border: 1px solid #E5E7EB;
border-radius: 0.5rem;
animation: slideDown 0.2s ease-out;
}
.filtersRow {
display: flex;
gap: 1rem;
align-items: center;
justify-content: center;
}
.resetSection {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #E5E7EB;
display: flex;
justify-content: center;
}
.resetButton {
background: white;
border: 1px solid #DC2626;
color: #DC2626;
border-radius: 0.375rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.resetButton:hover {
background: #FEF2F2;
border-color: #B91C1C;
color: #B91C1C;
}
.resetButton:active {
background: #FEE2E2;
transform: translateY(1px);
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -1,21 +1,54 @@
import React, { useEffect } from 'react';
import styles from './RoomBooking.module.css';
import { Link } from 'react-router-dom';
import BookingsList from '../components/BookingsList';
import Card from '../components/Card';
import { useSettingsContext } from '../context/SettingsContext';
import { USER } from '../constants/bookingConstants';
export function RoomBooking({ bookings }) {
export function RoomBooking({ bookings, showSuccessBanner, lastCreatedBooking, onDismissBanner, onBookingUpdate, onBookingDelete, showDeleteBanner, lastDeletedBooking, onDismissDeleteBanner }) {
const { settings } = useSettingsContext();
useEffect(() => {
window.scrollTo(0, 0);
}, []);
const isTestSessionActive = settings.currentUserName !== USER.name;
function handleEditBooking(booking) {
console.log(booking);
setIsEditBooking(booking);
// setIsEditBooking(booking); // This line seems to have an error, commenting out
}
return (
<div className={styles.pageContainer}>
{isTestSessionActive && (
<div className={styles.welcomeSection}>
<div className={styles.welcomeContent}>
<h1 className={styles.welcomeTitle}>Välkommen, {settings.currentUserName}!</h1>
<p className={styles.welcomeSubtitle}>Hantera dina bokningar och reservera nya lokaler</p>
</div>
</div>
)}
<h1 className={styles.pageHeading}>Lokalbokning</h1>
<h2>Mina bokingar</h2>
<BookingsList bookings={bookings} handleEditBooking={handleEditBooking} />
<h2>Ny bokning</h2>
<h2 className={styles.sectionHeading}>Mina bokingar</h2>
<BookingsList
bookings={bookings}
handleEditBooking={handleEditBooking}
onBookingUpdate={onBookingUpdate}
onBookingDelete={onBookingDelete}
showSuccessBanner={showSuccessBanner}
lastCreatedBooking={lastCreatedBooking}
onDismissBanner={onDismissBanner}
showDeleteBanner={showDeleteBanner}
lastDeletedBooking={lastDeletedBooking}
onDismissDeleteBanner={onDismissDeleteBanner}
showDevelopmentBanner={settings.showDevelopmentBanner}
showBookingConfirmationBanner={settings.showBookingConfirmationBanner}
showBookingDeleteBanner={settings.showBookingDeleteBanner}
/>
<hr className={styles.sectionDivider} />
<h2 className={styles.sectionHeading}>Ny bokning</h2>
<Link to='/new-booking'>
<Card imageUrl="/grupprum.jpg" header="Litet grupprum" subheader="Plats för 5 personer" />
</Link>

View File

@@ -7,4 +7,92 @@
font-size: 2.6rem;
font-weight: 300;
font-family: 'The Sans', system-ui, sans-serif;
}
.sectionHeading {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1rem;
}
.sectionDivider {
border: none;
border-top: 1px solid #dedede;
margin-bottom: 2rem;
}
.welcomeSection {
display: flex;
align-items: center;
justify-content: space-between;
background: #3d50a8;
padding: 2rem 2.5rem;
margin: 1.5rem 0 2.5rem 0;
color: white;
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.15);
position: relative;
overflow: hidden;
}
.welcomeSection::before {
content: '';
position: absolute;
top: 0;
right: 0;
width: 100px;
height: 100px;
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
border-radius: 50%;
transform: translate(20px, -20px);
}
.welcomeContent {
flex: 1;
}
.welcomeTitle {
font-size: 1.8rem;
font-weight: 700;
margin: 0;
font-family: 'The Sans', system-ui, sans-serif;
letter-spacing: -0.5px;
}
.welcomeSubtitle {
font-size: 1rem;
margin: 0.5rem 0 0 0;
opacity: 0.9;
font-weight: 400;
}
.welcomeIcon {
width: 48px;
height: 48px;
opacity: 0.8;
color: white;
}
.welcomeIcon svg {
width: 100%;
height: 100%;
}
@media (max-width: 768px) {
.welcomeSection {
padding: 1.5rem 1.8rem;
margin: 1rem 0 2rem 0;
}
.welcomeTitle {
font-size: 1.5rem;
}
.welcomeSubtitle {
font-size: 0.9rem;
}
.welcomeIcon {
width: 40px;
height: 40px;
}
}

View File

@@ -0,0 +1,55 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSettingsContext } from '../context/SettingsContext';
import styles from './TestSession.module.css';
export function TestSession() {
const [userName, setUserName] = useState('');
const { updateSettings } = useSettingsContext();
const navigate = useNavigate();
const handleCustomNameChange = (e) => {
setUserName(e.target.value);
setIsUsingNormalName(false);
};
const handleStart = () => {
if (userName.trim()) {
// Update the user name in settings
updateSettings({ currentUserName: userName.trim() });
// Navigate to the main app
navigate('/');
}
};
const canStart = userName.trim().length > 0;
return (
<div className={styles.container}>
<div className={styles.content}>
<div className={styles.header}>
<h1>Testsession</h1>
<p>Välkommen! Ange ditt namn för att börja:</p>
</div>
<div className={styles.nameSection}>
<input
type="text"
value={userName}
onChange={handleCustomNameChange}
placeholder="För- och efternamn"
className={styles.nameInput}
/>
</div>
{canStart && (
<button
onClick={handleStart}
className={styles.startButton}
>
Start
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,110 @@
.container {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.content {
max-width: 500px;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.header h1 {
font-size: 2rem;
color: #1f2937;
margin-bottom: 0.5rem;
font-weight: 600;
}
.header p {
font-size: 1rem;
color: #6b7280;
margin-bottom: 2rem;
}
.nameSection {
margin: 2rem 0;
width: 100%;
}
.nameInput {
width: 100%;
padding: 0.75rem 1rem;
font-size: 16px;
border: 1px solid #d1d5db;
border-radius: 6px;
outline: none;
margin-bottom: 1.5rem;
-webkit-text-size-adjust: 100%;
-webkit-appearance: none;
}
.nameInput:focus {
border-color: #2563eb;
}
.normalNames {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
}
.normalNameButton {
padding: 0.5rem 1rem;
background: white;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 0.9rem;
color: #374151;
cursor: pointer;
margin: 0.25rem;
}
.normalNameButton:hover {
background: #f9fafb;
}
.normalNameButton.selected {
background: #2563eb;
border-color: #2563eb;
color: white;
}
.startButton {
padding: 0.75rem 2rem;
background: #2563eb;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
margin-top: 1.5rem;
width: fit-content;
}
.startButton:hover {
background: #1d4ed8;
}
@media (max-width: 768px) {
.container {
padding: 1rem;
}
.normalNames {
flex-direction: column;
align-items: center;
}
.normalNameButton {
width: 100%;
max-width: 200px;
}
}

View File

@@ -23,6 +23,10 @@
width: 2rem;
height: 2rem;
padding: 0;
&[data-disabled] {
display: none;
}
}
.react-aria-CalendarCell {
@@ -39,6 +43,10 @@
display: none;
}
&:hover:not([data-selected]):not([data-disabled]):not([data-unavailable]) {
background-color: #e9e9e9;
}
&[data-pressed] {
background: var(--gray-100);
}
@@ -64,6 +72,7 @@
&[data-unavailable] {
text-decoration: line-through;
color: var(--invalid-color);
color: rgb(203, 203, 203);
}
}

View File

@@ -27,6 +27,8 @@
outline: none;
min-width: 0;
font-family: inherit;
width: 100%;
max-width: 600px;
}
.combo-box-input[data-focused] {

View File

@@ -13,16 +13,52 @@
display: flex;
width: fit-content;
align-items: center;
width: fit-content;
gap: 2rem;
}
.react-aria-Button {
background: var(--highlight-background);
color: var(--highlight-foreground);
min-width: fit-content;
}
.chevron-button {
background: none;
border: none;
padding: 0.5rem;
margin: 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background-color 0.2s, opacity 0.2s;
}
.chevron-button:hover:not(:disabled) {
background-color: rgba(0, 0, 0, 0.05);
}
.chevron-button:active:not(:disabled) {
background-color: rgba(0, 0, 0, 0.1);
}
.chevron-button:disabled {
cursor: default;
}
.chevron-button:focus-visible {
outline: 2px solid #2563EB;
outline-offset: 2px;
}
.react-aria-Button {
/*background: var(--highlight-background);*/
/*color: var(--highlight-foreground);*/
border: 2px solid var(--field-background);
forced-color-adjust: none;
border-radius: 4px;
border: none;
margin-left: -1.929rem;
/*border: none;*/
border: 1px solid #D4D4D4;
/*width: 1.429rem;*/
/*height: 1.429rem;*/
width: fit-content;
@@ -32,7 +68,8 @@
&[data-pressed] {
box-shadow: none;
background: var(--highlight-background);
/*background: var(--highlight-background);*/
background: #f1f1f1;
}
&[data-focus-visible] {
@@ -41,6 +78,55 @@
}
}
.calendar-button {
min-width: 220px !important;
display: flex !important;
align-items: center !important;
justify-content: space-between !important;
gap: 0.75rem !important;
cursor: pointer !important;
background: white !important;
border: 1px solid #E5E7EB !important;
border-radius: 8px !important;
padding: 12px 16px !important;
font-weight: 500 !important;
color: #374151 !important;
transition: all 0.2s ease !important;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05) !important;
white-space: nowrap !important;
}
@media (max-width: 640px) {
.calendar-button {
min-width: 200px !important;
}
}
.calendar-button:hover {
border-color: #D1D5DB !important;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1) !important;
}
.calendar-button[data-pressed] {
background: #F9FAFB !important;
border-color: #9CA3AF !important;
transform: translateY(1px) !important;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05) !important;
}
.calendar-button[data-focus-visible] {
outline: 2px solid #2563EB !important;
outline-offset: 2px !important;
border-color: #2563EB !important;
}
.calendar-date {
flex: 1;
text-align: left;
font-size: 14px;
line-height: 1.25;
}
.react-aria-DateInput {
padding: 4px 2.5rem 4px 8px;
}
@@ -48,6 +134,8 @@
.react-aria-Popover[data-trigger=DatePicker] {
max-width: unset;
transform: translateX(-50%);
left: 50% !important;
}
.react-aria-DatePicker {

View File

@@ -21,24 +21,41 @@ import {
import './DatePicker.css';
import { convertDateObjectToString } from '../../helpers';
import ChevronLeft from '../../icons/ChevronLeft';
import ChevronRight from '../../icons/ChevronRight';
import CalendarIcon from '../../icons/CalendarIcon';
export interface DatePickerProps<T extends DateValue>
extends AriaDatePickerProps<T> {
label?: string;
description?: string;
errorMessage?: string | ((validation: ValidationResult) => string);
chevronColor?: string;
canNavigatePrevious?: boolean;
canNavigateNext?: boolean;
onPreviousClick?: () => void;
onNextClick?: () => void;
}
export function DatePicker<T extends DateValue>(
{ label, description, errorMessage, firstDayOfWeek, ...props }:
DatePickerProps<T>
{
label,
description,
errorMessage,
firstDayOfWeek,
chevronColor = "#666",
canNavigatePrevious = true,
canNavigateNext = true,
onPreviousClick,
onNextClick,
...props
}: DatePickerProps<T>
) {
return (
(
<AriaDatePicker {...props}>
<Label>{label}</Label>
<Group>
{
/*
<DateInput>
@@ -47,8 +64,32 @@ export function DatePicker<T extends DateValue>(
*/
}
<Button>{convertDateObjectToString(props.value)} </Button>
</Group>
<Group>
<button
className="chevron-button"
onClick={() => onPreviousClick ? onPreviousClick() : console.log('ChevronLeft clicked')}
disabled={!canNavigatePrevious}
>
<ChevronLeft
color={!canNavigatePrevious ? "#666" : "#111"}
disabled={!canNavigatePrevious}
/>
</button>
<Button className="calendar-button">
<span className="calendar-date">{convertDateObjectToString(props.value)}</span>
<CalendarIcon color={chevronColor} size={16} />
</Button>
<button
className="chevron-button"
onClick={() => onNextClick ? onNextClick() : console.log('ChevronRight clicked')}
disabled={!canNavigateNext}
>
<ChevronRight
color={!canNavigateNext ? "#666" : "#111"}
disabled={!canNavigateNext}
/>
</button>
</Group>
{description && <Text slot="description">{description}</Text>}
<FieldError>{errorMessage}</FieldError>
<Popover>

View File

@@ -25,7 +25,6 @@
.react-aria-Modal {
box-shadow: 0 8px 20px rgba(0 0 0 / 0.1);
border-radius: 6px;
background: var(--overlay-background);
color: var(--text-color);
border: 1px solid var(--gray-400);

View File

@@ -10,10 +10,10 @@
* Light: https://leonardocolor.io/theme.html?name=Light&config=%7B%22baseScale%22%3A%22Gray%22%2C%22colorScales%22%3A%5B%7B%22name%22%3A%22Gray%22%2C%22colorKeys%22%3A%5B%22%23000000%22%5D%2C%22colorspace%22%3A%22RGB%22%2C%22ratios%22%3A%5B%22-1.12%22%2C%221.45%22%2C%222.05%22%2C%223.02%22%2C%224.54%22%2C%227%22%2C%2210.86%22%5D%2C%22smooth%22%3Afalse%7D%2C%7B%22name%22%3A%22Purple%22%2C%22colorKeys%22%3A%5B%22%235e30eb%22%5D%2C%22colorspace%22%3A%22RGB%22%2C%22ratios%22%3A%5B%22-1.12%22%2C%221.45%22%2C%222.05%22%2C%223.02%22%2C%224.54%22%2C%227%22%2C%2210.86%22%5D%2C%22smooth%22%3Afalse%7D%2C%7B%22name%22%3A%22Red%22%2C%22colorKeys%22%3A%5B%22%23e32400%22%5D%2C%22colorspace%22%3A%22RGB%22%2C%22ratios%22%3A%5B%22-1.12%22%2C%221.45%22%2C%222.05%22%2C%223.02%22%2C%224.54%22%2C%227%22%2C%2210.86%22%5D%2C%22smooth%22%3Afalse%7D%5D%2C%22lightness%22%3A98%2C%22contrast%22%3A1%2C%22saturation%22%3A100%2C%22formula%22%3A%22wcag2%22%7D */
:root {
--background-color: #f8f8f8;
--gray-50: #ffffff;
--gray-50: #FAFBFC;
--gray-100: #d0d0d0;
--gray-200: #afafaf;
--gray-300: #8f8f8f;
--gray-300: #CECECE;
--gray-400: #717171;
--gray-500: #555555;
--gray-600: #393939;
@@ -40,7 +40,7 @@
--gray-50: #101010;
--gray-100: #393939;
--gray-200: #4f4f4f;
--gray-300: #686868;
--gray-300: #CECECE;
--gray-400: #848484;
--gray-500: #a7a7a7;
--gray-600: #cfcfcf;
@@ -83,7 +83,7 @@
--button-background-pressed: var(--background-color);
/* these colors are the same between light and dark themes
* to ensure contrast with the foreground color */
--highlight-background: #6f46ed; /* purple-300 from dark theme, 3.03:1 against background-color */
--highlight-background: #3e70ec; /* purple-300 from dark theme, 3.03:1 against background-color */
--highlight-background-pressed: #522acd; /* purple-200 from dark theme */
--highlight-background-invalid: #cc2000; /* red-300 from dark theme */
--highlight-foreground: white; /* 5.56:1 against highlight-background */

View File

@@ -1,11 +1,12 @@
import { today, getLocalTimeZone } from '@internationalized/date';
import { NUMBER_OF_ROOMS, CHANCE_OF_AVAILABILITY } from '../constants/bookingConstants';
export const generateInitialRooms = (chanceOfAvailability = CHANCE_OF_AVAILABILITY, numberOfRooms = NUMBER_OF_ROOMS) => {
export const generateInitialRooms = (chanceOfAvailability = CHANCE_OF_AVAILABILITY, numberOfRooms = NUMBER_OF_ROOMS, earliestSlot = 0, latestSlot = 23) => {
return [...Array(numberOfRooms)].map((room, index) => ({
roomId: `G5:${index + 1}`,
times: Array.from({ length: 23 }, (_, i) => ({
available: Math.random() < chanceOfAvailability ? true : false
times: Array.from({ length: 24 }, (_, i) => ({
available: i >= earliestSlot && i <= latestSlot ?
(Math.random() < chanceOfAvailability) : false
}))
}));
};
@@ -74,15 +75,14 @@ export const getLongestConsecutive = (allRooms) => {
return longest;
};
export const createDisabledRanges = () => {
const now = today(getLocalTimeZone());
export const createDisabledRanges = (effectiveToday, bookingRangeDays = 14) => {
return [
[now.add({ days: 14 }), now.add({ days: 9999 })],
[effectiveToday.add({ days: bookingRangeDays }), effectiveToday.add({ days: 9999 })],
];
};
export const isDateUnavailable = (date) => {
const disabledRanges = createDisabledRanges();
export const isDateUnavailable = (date, effectiveToday, bookingRangeDays = 14) => {
const disabledRanges = createDisabledRanges(effectiveToday, bookingRangeDays);
return disabledRanges.some((interval) =>
date.compare(interval[0]) >= 0 &&
date.compare(interval[1]) <= 0

View File

@@ -1,38 +1,7 @@
/// <reference types="vitest/config" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
basePath: '/~jare2473',
test: {
projects: [{
extends: true,
plugins: [
// The plugin will run tests for the stories defined in your Storybook config
// See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest
storybookTest({
configDir: path.join(dirname, '.storybook')
})],
test: {
name: 'storybook',
browser: {
enabled: true,
headless: true,
provider: 'playwright',
instances: [{
browser: 'chromium'
}]
},
setupFiles: ['.storybook/vitest.setup.js']
}
}]
}
});
})