eriks-booking-variant #6

Merged
jare2473 merged 13 commits from eriks-booking-variant into main 2025-09-22 11:16:13 +02:00
14 changed files with 699 additions and 7 deletions
Showing only changes of commit d89e25633f - Show all commits

View File

@@ -6,6 +6,7 @@ import Layout from './Layout';
import { RoomBooking } from './pages/RoomBooking';
import { NewBooking } from './pages/NewBooking';
import { BookingDetails } from './pages/BookingDetails';
import { BookingConfirmation } from './pages/BookingConfirmation';
import { BookingSettings } from './pages/BookingSettings';
import { CourseSchedule } from './pages/CourseSchedule';
import { CourseScheduleView } from './pages/CourseScheduleView';
@@ -121,6 +122,7 @@ const AppRoutes = () => {
<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-details" element={<BookingDetails addBooking={addBooking} />} />
<Route path="booking-confirmation" element={<BookingConfirmation addBooking={addBooking} />} />
<Route path="course-schedule" element={<CourseSchedule />} />
<Route path="course-schedule/:courseId" element={<CourseScheduleView />} />
<Route path="booking-settings" element={<BookingSettings />} />

View File

@@ -10,6 +10,7 @@
flex-basis: 100%;
max-width: none;
position: relative;
z-index: 1;
}
/* Arrow pointing to left card */

View File

@@ -0,0 +1,159 @@
import React, { useState, useEffect, useRef } from 'react';
import { Button } from 'react-aria-components';
import { useNavigate } from 'react-router-dom';
import { convertDateObjectToString, getTimeFromIndex } from '../../helpers';
import Dropdown from '../ui/Dropdown';
import { BookingTitleField } from '../forms/BookingTitleField';
import { ParticipantsSelector } from '../forms/ParticipantsSelector';
import { useBookingContext } from '../../context/BookingContext';
import { useSettingsContext } from '../../context/SettingsContext';
import styles from './InlineModalBookingForm.module.css';
import extendedStyles from './InlineModalExtendedBookingForm.module.css';
export function InlineModalExtendedBookingForm({
startTimeIndex,
hoursAvailable,
endTimeIndex,
setEndTimeIndex,
onClose,
onNavigateToDetails,
arrowPointsLeft = true
}) {
const navigate = useNavigate();
const booking = useBookingContext();
const { getCurrentUser, getDefaultBookingTitle } = useSettingsContext();
// Initialize with pre-selected end time if available, or auto-select if only 30 min available
const initialEndTimeIndex = booking.selectedBookingLength > 0 ? startTimeIndex + booking.selectedBookingLength :
(hoursAvailable === 1 ? startTimeIndex + 1 : null); // Auto-select 30 min if that's all that's available
const [selectedEndTimeIndex, setSelectedEndTimeIndex] = useState(null);
const hasInitialized = useRef(false);
// Store the original hours available to prevent it from changing when selections are made
const originalHoursAvailable = useRef(hoursAvailable);
if (originalHoursAvailable.current < hoursAvailable) {
originalHoursAvailable.current = hoursAvailable;
}
// Effect to handle initial setup only once when form opens
useEffect(() => {
if (initialEndTimeIndex && !hasInitialized.current) {
setSelectedEndTimeIndex(initialEndTimeIndex);
setEndTimeIndex(initialEndTimeIndex);
booking.setSelectedEndIndex(initialEndTimeIndex);
hasInitialized.current = true;
}
}, [initialEndTimeIndex, setEndTimeIndex, booking]);
// Generate end time options based on available hours
const endTimeOptions = [];
const disabledOptions = {};
// Always show all possible options up to the original available hours, not limited by current selection
const maxOptions = Math.min(originalHoursAvailable.current, 8);
for (let i = 1; i <= maxOptions; i++) {
const endTimeIndex = startTimeIndex + i;
const endTime = getTimeFromIndex(endTimeIndex);
const durationLabel = i === 1 ? "30 min" :
i === 2 ? "1 h" :
i === 3 ? "1.5 h" :
i === 4 ? "2 h" :
i === 5 ? "2.5 h" :
i === 6 ? "3 h" :
i === 7 ? "3.5 h" :
i === 8 ? "4 h" : `${i * 0.5} h`;
endTimeOptions.push({
value: endTimeIndex,
label: `${endTime} · ${durationLabel}`
});
disabledOptions[endTimeIndex] = false; // All available options are enabled
}
function handleChange(event) {
const endTimeValue = event.target.value === "" ? null : parseInt(event.target.value);
setSelectedEndTimeIndex(endTimeValue);
if (endTimeValue !== null) {
setEndTimeIndex(endTimeValue);
booking.setSelectedEndIndex(endTimeValue);
// Update the selected booking length in context so it doesn't interfere
const newLength = endTimeValue - startTimeIndex;
booking.setSelectedBookingLength && booking.setSelectedBookingLength(newLength);
} else {
// Reset to default state when placeholder is selected
setEndTimeIndex(startTimeIndex);
booking.setSelectedEndIndex(null);
booking.setSelectedBookingLength && booking.setSelectedBookingLength(0);
}
}
// Check if user has selected an end time (including pre-selected)
const hasSelectedEndTime = selectedEndTimeIndex !== null;
const handleNavigateToConfirmation = () => {
if (hasSelectedEndTime) {
// Navigate directly to booking confirmation instead of details
navigate('/booking-confirmation', {
state: {
selectedDate: booking.selectedDate,
selectedStartIndex: booking.selectedStartIndex,
selectedEndIndex: booking.selectedEndIndex,
assignedRoom: booking.assignedRoom,
title: booking.title,
participants: booking.participants
}
});
}
};
return (
<div className={`${styles.inlineForm} ${arrowPointsLeft ? styles.arrowLeft : styles.arrowRight}`}>
{/* Header */}
<div className={styles.formHeader}>
{/* Time Selection */}
<div className={extendedStyles.section}>
<div className={styles.formField}>
<Dropdown
options={endTimeOptions}
disabledOptions={disabledOptions}
onChange={handleChange}
value={selectedEndTimeIndex || ""}
placeholder={!initialEndTimeIndex ? {
value: "",
label: "Välj sluttid"
} : null}
className={styles.endTimeDropdown}
/>
</div>
</div>
{/* Title Field - Compact */}
<div className={extendedStyles.section}>
<BookingTitleField compact={true} />
</div>
{/* Participants Field - Compact */}
<div className={extendedStyles.section}>
<ParticipantsSelector compact={true} />
</div>
</div>
{/* Actions */}
<div className={styles.formActions}>
<Button className={styles.cancelButton} onPress={onClose}>
Avbryt
</Button>
<Button
className={`${styles.saveButton} ${!hasSelectedEndTime ? styles.disabledButton : ''}`}
onPress={hasSelectedEndTime ? handleNavigateToConfirmation : undefined}
isDisabled={!hasSelectedEndTime}
>
{hasSelectedEndTime ? 'Boka' : 'Välj sluttid först'}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
/* Import base styles from the regular inline modal form */
@import './InlineModalBookingForm.module.css';
/* Extended form specific styles to ensure height alignment */
.section {
margin-bottom: var(--spacing-lg); /* Reduce spacing between sections */
}
/* Ensure all form inputs have consistent height */
.compactInput {
height: 2.5rem; /* Match dropdown height */
display: flex;
align-items: center;
box-sizing: border-box;
}
/* Override compact styles to match dropdown height exactly */
:global(.compactTextInput),
:global(.compactSearchInput) {
height: 2.5rem !important;
display: flex !important;
align-items: center !important;
padding: 0.5rem 1rem !important;
box-sizing: border-box !important;
}
/* Ensure headings are aligned */
.compactHeading {
margin-bottom: 0.4rem;
margin-top: 0;
}

View File

@@ -182,7 +182,7 @@ export function ParticipantsSelector({ compact = false }) {
onClick={handleInputClick}
onBlur={handleInputBlur}
onKeyDown={handleKeyDown}
placeholder="Search for participants..."
placeholder="Sök deltagare..."
className={compact ? styles.compactSearchInput : styles.searchInput}
role="combobox"
aria-expanded={isDropdownOpen}

View File

@@ -15,6 +15,7 @@
}
.selectedParticipants {
margin-top: 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;

View File

@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import TimeCard from './TimeCard';
import { InlineBookingForm } from '../booking/InlineBookingForm';
import { InlineModalBookingForm } from '../booking/InlineModalBookingForm';
import { InlineModalExtendedBookingForm } from '../booking/InlineModalExtendedBookingForm';
import { BookingModal } from '../booking/BookingModal';
import styles from './TimeCardContainer.module.css';
import modalStyles from '../booking/BookingModal.module.css';
@@ -19,6 +20,7 @@ export function TimeCardContainer() {
// Check booking form type
const useInlineForm = settings.bookingFormType === 'inline';
const useInlineModal = settings.bookingFormType === 'inline-modal';
const useInlineModalExtended = settings.bookingFormType === 'inline-modal-extended';
const useModal = settings.bookingFormType === 'modal';
const handleNavigateToDetails = () => {
@@ -143,7 +145,7 @@ export function TimeCardContainer() {
// Add inline booking form after the pair that contains the selected time card
// Cards are laid out in pairs: (0,1), (2,3), (4,5), etc.
if ((useInlineForm || useInlineModal) && booking.selectedStartIndex !== null) {
if ((useInlineForm || useInlineModal || useInlineModalExtended) && booking.selectedStartIndex !== null) {
const selectedPairStart = Math.floor(booking.selectedStartIndex / 2) * 2;
const selectedPairEnd = selectedPairStart + 1;
@@ -174,6 +176,19 @@ export function TimeCardContainer() {
arrowPointsLeft={isLeftCard}
/>
);
} else if (useInlineModalExtended) {
elements.push(
<InlineModalExtendedBookingForm
key={`form-${slotIndex}`}
startTimeIndex={booking.selectedStartIndex}
hoursAvailable={booking.selectedEndIndex - booking.selectedStartIndex}
endTimeIndex={booking.selectedEndIndex}
setEndTimeIndex={booking.setSelectedEndIndex}
onClose={() => booking.resetTimeSelections()}
onNavigateToDetails={handleNavigateToDetails}
arrowPointsLeft={isLeftCard}
/>
);
}
}
}

View File

@@ -0,0 +1,122 @@
import React, { useEffect, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import styles from './BookingConfirmation.module.css';
import { useBookingContext } from '../context/BookingContext';
import { BookingProvider } from '../context/BookingContext';
import { useSettingsContext } from '../context/SettingsContext';
import { useBookingState } from '../hooks/useBookingState';
import { convertDateObjectToString, formatBookingDate, getTimeFromIndex } from '../helpers';
export function BookingConfirmation({ addBooking }) {
const navigate = useNavigate();
const location = useLocation();
const { getEffectiveToday, getCurrentUser } = useSettingsContext();
const booking = useBookingState(addBooking, getEffectiveToday());
useEffect(() => {
window.scrollTo(0, 0);
// Automatically save the booking when the page loads
if (location.state && booking) {
setTimeout(() => {
booking.handleSave();
}, 100); // Small delay to ensure state is populated
}
}, [location.state]);
// Populate booking state from navigation state if available
useEffect(() => {
const navigationState = location.state;
if (navigationState) {
// Update booking state with navigation data
if (navigationState.selectedDate) booking.setSelectedDate(navigationState.selectedDate);
if (navigationState.selectedStartIndex !== undefined) booking.setSelectedStartIndex(navigationState.selectedStartIndex);
if (navigationState.selectedEndIndex !== undefined) booking.setSelectedEndIndex(navigationState.selectedEndIndex);
if (navigationState.assignedRoom) booking.setAssignedRoom(navigationState.assignedRoom);
if (navigationState.title) booking.setTitle(navigationState.title);
if (navigationState.participants) booking.setParticipants(navigationState.participants);
} else if (!booking.selectedDate || !booking.selectedStartIndex || !booking.selectedEndIndex) {
// Redirect back if no booking data from navigation or state
navigate('/new-booking');
}
}, [location.state, navigate]);
const handleBackToBookings = () => {
navigate('/');
};
const handleNewBooking = () => {
navigate('/new-booking');
};
// Get current user and all participants
const currentUser = getCurrentUser();
const allParticipants = [currentUser, ...(booking.participants || [])];
return (
<BookingProvider value={booking}>
<div className={styles.container}>
{/* Combined Confirmation and Room Section */}
<div className={styles.combinedSection}>
{/* Booking Confirmation */}
<div className={styles.bookingConfirmed}>
<div className={styles.bannerContent}>
<div className={styles.successIcon}></div>
<div className={styles.bannerText}>
<h1 className={styles.successTitle}>
Booking confirmed: <span className={styles.bookingTitle}>"{booking.title}"</span>
</h1>
<div className={styles.bookingDetails}>
{booking.assignedRoom || 'G5:12'} {booking.selectedDate ? formatBookingDate(booking.selectedDate) : 'Välj datum'} {getTimeFromIndex(booking.selectedStartIndex)}-{getTimeFromIndex(booking.selectedEndIndex)} {allParticipants.map(p => p.name).join(', ')}
</div>
</div>
</div>
</div>
{/* Room Visualization */}
<div className={styles.roomVisualization}>
<div className={styles.roomArea}></div>
<div className={styles.roomInfo}>
<div className={styles.roomHeader}>
<span>Lokal: {booking.assignedRoom || 'G5:12'}</span>
</div>
<div className={styles.roomDetails}>
<div className={styles.roomDetailItem}>
<span className={styles.roomDetailLabel}>5 platser</span>
</div>
<div className={styles.roomDetailItem}>
<span className={styles.roomDetailLabel}>Plats:</span>
<span className={styles.roomDetailValue}>Röda avdelningen</span>
</div>
<div className={styles.roomDetailItem}>
<span className={styles.roomDetailLabel}>Utrustning:</span>
<span className={styles.roomDetailValue}>Whiteboard, TV, Tangentbord, Mus</span>
</div>
<div className={styles.roomDetailItem}>
<span className={styles.roomDetailLabel}> Status:</span>
<span className={styles.roomDetailValue}>HDMI-kabel glappar</span>
</div>
</div>
</div>
</div>
</div>
{/* Action Buttons */}
<div className={styles.actions}>
<button
className={styles.secondaryButton}
onClick={handleNewBooking}
>
Boka igen
</button>
<button
className={styles.primaryButton}
onClick={handleBackToBookings}
>
Visa mina bokningar
</button>
</div>
</div>
</BookingProvider>
);
}

View File

@@ -0,0 +1,314 @@
.container {
margin: 0 auto;
padding: 2rem 1rem;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
background-color: var(--bg-primary);
width: 100%;
box-sizing: border-box;
}
/* Combined Section Container */
.combinedSection {
margin: 2rem 0;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
width: 100%;
}
/* Combined Booking Confirmed Section - Banner-like */
.bookingConfirmed {
padding: var(--spacing-lg) var(--spacing-xl);
background: var(--notification-success-bg);
border: var(--border-width-thin) solid var(--notification-success-border);
border-bottom: none;
position: relative;
box-shadow: var(--shadow-lg);
display: flex;
align-items: center;
justify-content: center;
}
@keyframes shimmer {
0%, 100% { opacity: 0.8; }
50% { opacity: 1; }
}
.successIcon {
width: 2rem;
height: 2rem;
background: var(--notification-success-icon-bg);
color: var(--notification-success-icon-text);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
flex-shrink: 0;
margin-right: var(--spacing-lg);
}
@keyframes successPulse {
0% {
transform: scale(0.8);
opacity: 0;
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
opacity: 1;
}
}
.bannerContent {
display: flex;
align-items: center;
gap: var(--spacing-lg);
width: 100%;
}
.bannerText {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
flex: 1;
min-width: 0;
}
.successTitle {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--notification-success-title);
margin: 0;
}
.bookingDetails {
font-size: var(--font-size-md);
font-weight: var(--font-weight-medium);
color: var(--notification-success-details);
margin: 0;
line-height: 1.4;
}
.date {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--notification-success-title);
}
.time {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-medium);
color: var(--notification-success-details);
}
.room {
font-size: 1.5rem;
font-weight: 600;
color: var(--color-primary);
background: var(--bg-primary);
padding: 0.75rem 1.5rem;
border-radius: 1rem;
border: 2px solid var(--color-primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.bookingTitle {
font-weight: var(--font-weight-normal);
font-style: italic;
}
.participants {
font-size: var(--font-size-md);
color: var(--notification-success-details);
font-weight: var(--font-weight-medium);
line-height: 1.6;
max-width: 80%;
}
/* Action Buttons */
.actions {
display: flex;
gap: 1rem;
width: 100%;
max-width: 400px;
box-sizing: border-box;
}
.primaryButton {
flex: 2;
background: linear-gradient(135deg, var(--color-primary), var(--color-primary-hover));
color: white;
border: none;
border-radius: 0.75rem;
padding: 1rem 2rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
box-sizing: border-box;
}
.primaryButton:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
}
.primaryButton:active {
transform: translateY(0);
}
.secondaryButton {
flex: 1;
background: var(--bg-secondary);
color: var(--text-primary);
border: 2px solid var(--border-light);
border-radius: 0.75rem;
padding: 1rem 2rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
box-sizing: border-box;
}
.secondaryButton:hover {
background: var(--bg-tertiary);
border-color: var(--border-medium);
}
.secondaryButton:active {
transform: translateY(1px);
}
/* Room Visualization - Connected to booking confirmation */
.combinedSection .roomVisualization {
margin-bottom: 0;
border-radius: 0;
overflow: hidden;
box-shadow: none;
width: 100%;
}
.roomArea {
height: 200px;
background-image: url('./grupprum.jpg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
position: relative;
}
.roomInfo {
border-top: 2px solid var(--su-sky);
background: var(--su-blue);
color: white;
padding: 1.5rem;
font-weight: 500;
}
.roomHeader {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 1.25rem;
}
.roomDetails {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.roomDetailItem {
display: flex;
gap: 0.75rem;
align-items: flex-start;
font-size: 0.95rem;
line-height: 1.4;
}
.roomDetailItem:first-child {
font-size: 1.05rem;
font-weight: 600;
margin-bottom: 0.25rem;
}
.roomDetailItem:first-child .roomDetailLabel {
color: white;
}
.roomDetailLabel {
color: rgba(255, 255, 255, 0.85);
font-weight: 500;
min-width: fit-content;
font-size: inherit;
}
.roomDetailValue {
color: white;
font-weight: 400;
font-size: inherit;
}
.spacer {
height: 1rem;
}
/* Responsive */
@media (max-width: 640px) {
.container {
padding: 1rem 0.5rem;
}
.combinedSection {
margin: 1.5rem 0;
}
.bookingConfirmed {
padding: var(--spacing-md) var(--spacing-lg);
}
.bannerContent {
flex-direction: column;
text-align: center;
gap: var(--spacing-md);
}
.successIcon {
margin-right: 0;
margin-bottom: var(--spacing-sm);
}
.successTitle {
font-size: var(--font-size-lg);
}
.bookingDetails {
font-size: var(--font-size-sm);
}
.actions {
flex-direction: column;
}
.actions button {
flex: none;
}
}

View File

@@ -47,6 +47,44 @@
text-transform: uppercase;
}
/* Booking confirmation summary styles */
.summaryHeading {
margin: 0;
color: var(--text-tertiary);
font-size: 0.8rem;
font-style: normal;
font-weight: 520;
line-height: normal;
margin-bottom: 0.2rem;
margin-top: 1.5rem;
}
.summaryValue {
background-color: var(--input-bg);
border: 1px solid var(--input-border);
border-radius: 0.5rem;
padding: 1rem;
color: var(--input-text);
font-size: 16px;
}
.participantsList {
background-color: var(--input-bg);
border: 1px solid var(--input-border);
border-radius: 0.5rem;
padding: 1rem;
}
.participantItem {
padding: 0.5rem 0;
border-bottom: 1px solid var(--border-light);
color: var(--input-text);
}
.participantItem:last-child {
border-bottom: none;
}
.bookingInfo {
margin-bottom: 2rem;
}

View File

@@ -150,18 +150,21 @@ export function BookingSettings() {
<option value="inline">Inline Form (Complete)</option>
<option value="modal">Modal Popup (Classic)</option>
<option value="inline-modal">Inline Modal (Hybrid)</option>
<option value="inline-modal-extended">Inline Modal Extended (Hybrid+)</option>
</select>
<div className={styles.currentStatus}>
Current: <strong>
{settings.bookingFormType === 'inline' ? 'Inline Form' :
settings.bookingFormType === 'modal' ? 'Modal Popup' :
'Inline Modal'}
settings.bookingFormType === 'inline-modal' ? 'Inline Modal' :
'Inline Modal Extended'}
</strong>
</div>
<div className={styles.description} style={{marginTop: '0.5rem', fontSize: '0.85rem'}}>
<strong>Inline Form:</strong> All fields in one form<br/>
<strong>Modal Popup:</strong> Time selection in popup, then details page<br/>
<strong>Inline Modal:</strong> Time selection inline, then details page
<strong>Inline Modal:</strong> Time selection inline, then details page<br/>
<strong>Inline Modal Extended:</strong> Like hybrid, plus title and participants
</div>
</div>
</div>

View File

@@ -20,7 +20,6 @@
.bookingTimesContainer {
margin-top: 2rem;
padding: 2rem;
border-radius: 0.3rem;
outline: 1px solid var(--border-light);
display: flex;

View File

@@ -12,9 +12,15 @@
padding: var(--spacing-md);
border: 1px solid var(--border-light);
position: sticky;
width: 100%;
top: 1rem;
z-index: 10;
display: flex;
flex-direction: row;
justify-content: center;
.react-aria-Group {
height: fit-content;
display: flex;
width: fit-content;
align-items: center;
@@ -22,6 +28,7 @@
flex-direction: row;
justify-content: space-between;
gap: 2rem;
}
.react-aria-Button {
@@ -165,6 +172,6 @@
@media (max-width: 1024px) {
.react-aria-DatePicker {
top: 5rem;
top: 4rem;
}
}

View File

@@ -432,7 +432,7 @@
/* Background Colors */
/*--bg-primary: #1a1a1a;*/
--bg-primary: #0F0F0F;
--bg-primary: #1a1919;
--bg-secondary: #21211F;
--bg-tertiary: #333;
--bg-muted: #3a3a3a;