improving-week-36 #1

Merged
jare2473 merged 41 commits from improving-week-36 into main 2025-09-04 10:49:05 +02:00
6 changed files with 479 additions and 57 deletions
Showing only changes of commit 2633238209 - Show all commits

View File

@@ -0,0 +1,146 @@
import React, { useState, useEffect, useRef } from 'react';
import { Button } 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 './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}`}>
<div className={styles.formHeader}>
<h3>{booking.title == "" ? "Jacobs bokning" : booking.title}</h3>
<p className={styles.dateText}>{convertDateObjectToString(booking.selectedDate)}</p>
</div>
<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 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.formActions}>
<Button className={styles.cancelButton} onPress={onClose}>
Avbryt
</Button>
<Button
className={`${styles.saveButton} ${!hasSelectedLength ? styles.disabledButton : ''}`}
onPress={hasSelectedLength ? booking.handleSave : undefined}
isDisabled={!hasSelectedLength}
>
{hasSelectedLength ? 'Boka' : 'Välj längd först'}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,243 @@
.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%;
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: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid #E5E7EB;
}
.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: 1.5rem;
}
.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: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #E5E7EB;
}
.cancelButton {
flex: 1;
background-color: white;
height: 2.75rem;
color: #374151;
font-weight: 600;
border: 2px solid #d1d5db;
border-radius: 0.375rem;
transition: all 0.2s ease;
cursor: pointer;
font-size: 0.875rem;
}
.cancelButton:hover {
background-color: #f9fafb;
border-color: #9ca3af;
}
.cancelButton:active {
background-color: #e5e7eb;
transform: translateY(1px);
}
.saveButton {
flex: 2;
background-color: #059669;
color: white;
height: 2.75rem;
font-weight: 600;
font-size: 0.875rem;
border: 2px solid #047857;
border-radius: 0.375rem;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(5, 150, 105, 0.2);
cursor: pointer;
}
.saveButton:hover {
background-color: #047857;
box-shadow: 0 4px 8px rgba(5, 150, 105, 0.3);
}
.saveButton:active {
background-color: #065f46;
transform: translateY(1px);
box-shadow: 0 1px 2px rgba(5, 150, 105, 0.2);
}
.disabledButton {
background-color: #f8f9fa !important;
color: #adb5bd !important;
border: 2px dashed #dee2e6 !important;
opacity: 0.6 !important;
box-shadow: none !important;
cursor: default !important;
}
.disabledButton:hover {
background-color: #f8f9fa !important;
transform: none !important;
box-shadow: none !important;
}
.disabledButton:active {
background-color: #f8f9fa !important;
transform: none !important;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -1,8 +1,7 @@
import { Button, DialogTrigger } from 'react-aria-components';
import React, { useState } from 'react';
import { Button } from 'react-aria-components';
import React from 'react';
import styles from './TimeCard.module.css';
import { useBookingContext } from '../context/BookingContext';
import { BookingModal } from './BookingModal';
export default function TimeCard({
startTimeIndex,
@@ -20,8 +19,6 @@ export default function TimeCard({
// Use the pre-selected booking length if available, otherwise use available hours
const displayHours = booking.selectedBookingLength > 0 ? booking.selectedBookingLength : hoursAvailable;
const halfHours = displayHours;
const [endTimeIndex, setEndTimeIndex] = useState(startTimeIndex + hoursAvailable);
if (halfHours === 1) {
hoursText = "30\u202Fmin";
@@ -41,7 +38,7 @@ export default function TimeCard({
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;
@@ -52,37 +49,24 @@ export default function TimeCard({
if (state === "availableSlot") {
return (
<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>
<BookingModal
startTimeIndex={startTimeIndex}
hoursAvailable={hoursAvailable}
endTimeIndex={endTimeIndex}
setEndTimeIndex={setEndTimeIndex}
className={styles.modalContainer}
onClose={() => {
// Reset time selections when modal is closed without booking
booking.resetTimeSelections();
}}
/>
</DialogTrigger>
<Button
className={`${classNames} ${className}`}
onClick={() => {
if (state === "availableSlot") {
handleClick();
}
console.log("state: ", state);
}}
onMouseEnter={() => handleTimeCardHover(startTimeIndex)}
onMouseLeave={handleTimeCardExit}
>
{(!isEndState && hoursAvailable > 0) || isEndState || state=="availableSlot" ? (
<>
<p className={styles.startTime}>{formatSlotIndex(startTimeIndex)}</p>
<p className={styles.upToText}>{hoursAvailable > 1 && booking.selectedBookingLength === 0 && "Upp till "}<span className={styles.hoursText}>{hoursText}</span></p>
</>
) : null}
</Button>
);
}

View File

@@ -35,6 +35,21 @@
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 {
margin: 0;
}

View File

@@ -1,5 +1,6 @@
import React from 'react';
import TimeCard from './TimeCard';
import { InlineBookingForm } from './InlineBookingForm';
import styles from './TimeCardContainer.module.css';
import { useBookingContext } from '../context/BookingContext';
@@ -42,25 +43,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;
@@ -79,8 +79,8 @@ export function TimeCardContainer() {
if (booking.selectedBookingLength !== 0) {
// Check if this slot can accommodate the selected booking length
const actualConsecutive = booking.currentRoom ?
countConsecutiveFromSlot(booking.currentRoom.times, index) :
Math.max(...booking.timeSlotsByRoom.map(room => countConsecutiveFromSlot(room.times, index)));
countConsecutiveFromSlot(booking.currentRoom.times, slotIndex) :
Math.max(...booking.timeSlotsByRoom.map(room => countConsecutiveFromSlot(room.times, slotIndex)));
if (actualConsecutive >= booking.selectedBookingLength) {
timeCardState = "availableSlot";
@@ -91,23 +91,57 @@ export function TimeCardContainer() {
timeCardState = "availableSlot";
}
}
return (
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
// Cards are laid out in pairs: (0,1), (2,3), (4,5), etc.
if (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>
</div>
);
}

View File

@@ -13,7 +13,7 @@
width: 350px;
gap: 0.5rem;
height: fit-content;
align-items: center;
align-items: flex-start;
justify-content: center;
}