improving-week-36 #1
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// components/Layout.jsx
|
||||
import React from 'react';
|
||||
import { Outlet, Link } from 'react-router-dom';
|
||||
import Header from './components/Header';
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
248
my-app/src/components/BookingCard.jsx
Normal file
248
my-app/src/components/BookingCard.jsx
Normal 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 på 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;
|
||||
480
my-app/src/components/BookingCard.module.css
Normal file
480
my-app/src/components/BookingCard.module.css
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
181
my-app/src/components/BookingDetailsModal.jsx
Normal file
181
my-app/src/components/BookingDetailsModal.jsx
Normal 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;
|
||||
244
my-app/src/components/BookingDetailsModal.module.css
Normal file
244
my-app/src/components/BookingDetailsModal.module.css
Normal 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;
|
||||
}
|
||||
@@ -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 på 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
25
my-app/src/components/BookingLengthField.jsx
Normal file
25
my-app/src/components/BookingLengthField.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
my-app/src/components/BookingLengthField.module.css
Normal file
9
my-app/src/components/BookingLengthField.module.css
Normal 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;
|
||||
}
|
||||
164
my-app/src/components/BookingModal.jsx
Normal file
164
my-app/src/components/BookingModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
216
my-app/src/components/BookingModal.module.css
Normal file
216
my-app/src/components/BookingModal.module.css
Normal 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;
|
||||
}
|
||||
22
my-app/src/components/BookingTitleField.jsx
Normal file
22
my-app/src/components/BookingTitleField.jsx
Normal 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 på bokning</h3>
|
||||
<input
|
||||
type="text"
|
||||
value={booking.title}
|
||||
onChange={(event) => booking.setTitle(event.target.value)}
|
||||
placeholder={getDefaultBookingTitle()}
|
||||
className={compact ? styles.compactTextInput : styles.textInput}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
57
my-app/src/components/BookingTitleField.module.css
Normal file
57
my-app/src/components/BookingTitleField.module.css
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from 'react';
|
||||
import styles from './Card.module.css'; // Import the CSS Module
|
||||
|
||||
const Card = ({ imageUrl, header, subheader }) => {
|
||||
|
||||
70
my-app/src/components/ComboBox.jsx
Normal file
70
my-app/src/components/ComboBox.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
my-app/src/components/ComboBox.module.css
Normal file
150
my-app/src/components/ComboBox.module.css
Normal 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;
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
154
my-app/src/components/InlineBookingForm.jsx
Normal file
154
my-app/src/components/InlineBookingForm.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
252
my-app/src/components/InlineBookingForm.module.css
Normal file
252
my-app/src/components/InlineBookingForm.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
129
my-app/src/components/NotificationBanner.jsx
Normal file
129
my-app/src/components/NotificationBanner.jsx
Normal 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;
|
||||
177
my-app/src/components/NotificationBanner.module.css
Normal file
177
my-app/src/components/NotificationBanner.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
29
my-app/src/components/ParticipantsField.jsx
Normal file
29
my-app/src/components/ParticipantsField.jsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
my-app/src/components/ParticipantsField.module.css
Normal file
10
my-app/src/components/ParticipantsField.module.css
Normal 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;
|
||||
}
|
||||
291
my-app/src/components/ParticipantsSelector.jsx
Normal file
291
my-app/src/components/ParticipantsSelector.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
310
my-app/src/components/ParticipantsSelector.module.css
Normal file
310
my-app/src/components/ParticipantsSelector.module.css
Normal 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;
|
||||
}
|
||||
34
my-app/src/components/RoomSelectionField.jsx
Normal file
34
my-app/src/components/RoomSelectionField.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
my-app/src/components/RoomSelectionField.module.css
Normal file
9
my-app/src/components/RoomSelectionField.module.css
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,8 @@
|
||||
width: 350px;
|
||||
gap: 0.5rem;
|
||||
height: fit-content;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.timeCardList {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import React, { createContext, useContext } from 'react';
|
||||
|
||||
const BookingContext = createContext(null);
|
||||
|
||||
|
||||
146
my-app/src/context/SettingsContext.jsx
Normal file
146
my-app/src/context/SettingsContext.jsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
18
my-app/src/icons/CalendarIcon.jsx
Normal file
18
my-app/src/icons/CalendarIcon.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
my-app/src/icons/ChevronLeft.jsx
Normal file
18
my-app/src/icons/ChevronLeft.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
my-app/src/icons/ChevronRight.jsx
Normal file
18
my-app/src/icons/ChevronRight.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
321
my-app/src/pages/BookingSettings.jsx
Normal file
321
my-app/src/pages/BookingSettings.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
306
my-app/src/pages/BookingSettings.module.css
Normal file
306
my-app/src/pages/BookingSettings.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
55
my-app/src/pages/TestSession.jsx
Normal file
55
my-app/src/pages/TestSession.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
my-app/src/pages/TestSession.module.css
Normal file
110
my-app/src/pages/TestSession.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
outline: none;
|
||||
min-width: 0;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.combo-box-input[data-focused] {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
}]
|
||||
}
|
||||
});
|
||||
})
|
||||
Reference in New Issue
Block a user