State Management - Improve state management for searchs.
CI / Docker Lint (push) Successful in 29s

This commit is contained in:
2025-09-08 14:36:50 +02:00
parent 4c21dc2764
commit 590d7f6fa1
15 changed files with 308 additions and 184 deletions
+32 -24
View File
@@ -1,6 +1,5 @@
import { useAPIRoomCategory } from "@/api/api-roomCategories";
import { Card, CardContent } from "../ui/card";
import { useMemo } from "react";
import { Typography } from "../ui/typography";
import {
Table,
@@ -12,14 +11,14 @@ import {
} from "../ui/table";
import { Skeleton } from "../ui/skeleton";
import { useAppStore } from "@/store";
import { DateRange } from "react-day-picker";
import { FareFilter } from "@/model/RoomSearch";
import { useAPIRoomPrices } from "@/api/api-roomSearch";
import { NumberSelector } from "../ui/number-selector";
import { NewBooking, NewBookingRoom } from "@/model/Bookings";
import { RoomItemCard } from "./room-item-card";
import { IconWithTooltip } from "../ui/icon-number-tooltip";
import { UserIcon } from "lucide-react";
import { makeFareFilterFromSearchFilter } from "@/utils/utils";
import { SearchFilter } from "@/model/InternalTypes";
function RoomBooking({
roomCategoryId,
@@ -28,12 +27,12 @@ function RoomBooking({
roomCategoryId: number;
availableAmount: number;
}) {
const currentDates: DateRange = useAppStore(
(state) => state.searchFilter.dateRange,
const appSearchFilter: SearchFilter = useAppStore(
(state) => state.searchFilter,
);
const addNewRoom = useAppStore((state) => state.addNewBookingRoom);
const addNewRoom = useAppStore((state) => state.currentBookingAddRoom);
const bookingRooms: NewBooking | null = useAppStore(
(state) => state.newBookingData,
(state) => state.currentBooking,
);
function onSelectedAmountUpdate(
@@ -59,29 +58,29 @@ function RoomBooking({
addNewRoom(newRoom);
}
const totalSelected = () => {
const getSelectedRoomAmount = (guestNumber: number | null) => {
let sum = 0;
if (bookingRooms) {
//Rooms with same category and, if guestNumber is set, rooms with same guestNumber.
bookingRooms.rooms
.filter((v) => v.roomCategory == roomCategoryId)
.filter((v) => {
const isSameCat = v.roomCategory == roomCategoryId;
const isSameGuestNum =
guestNumber != null ? v.guestNumber == guestNumber : true;
return isSameCat && isSameGuestNum;
})
.forEach((v) => (sum += v.amount));
}
return sum;
};
const canAddMore: boolean = availableAmount > totalSelected();
const totalSelectAmount = getSelectedRoomAmount(null);
const canAddMoreRooms: boolean = availableAmount > totalSelectAmount;
//This seems a little bit overkill, but IDK.
const fareFilter: FareFilter | null = useMemo(() => {
if (currentDates && currentDates.from && currentDates.to) {
const filter: FareFilter = {
startDate: currentDates.from?.toDateString(),
endDate: currentDates.to?.toDateString(),
roomCategory: roomCategoryId,
};
return filter;
}
return null;
}, [currentDates, roomCategoryId]);
const fareFilter = makeFareFilterFromSearchFilter(
appSearchFilter,
roomCategoryId,
);
const {
roomCategory,
@@ -112,9 +111,15 @@ function RoomBooking({
const hasPrices = roomPriceList != null && roomPriceList?.length > 0;
let priceTable = null;
if (hasPrices) {
const tableRows = roomPriceList?.map((el) => {
//A row for each room guestNumber.
let tableRows = null;
if (!isLoadingPrices) {
tableRows = roomPriceList?.map((el) => {
const selectedRoomAmount =
totalSelectAmount > 0 ? getSelectedRoomAmount(el.guestNumber) : 0;
return (
<TableRow key={el.guestNumber}>
<TableRow key={roomCategoryId + "-" + el.guestNumber}>
<TableCell>
<IconWithTooltip
label={el.guestNumber?.toString()}
@@ -130,13 +135,16 @@ function RoomBooking({
updateCount={(count) =>
onSelectedAmountUpdate(el.guestNumber, count, el.price)
}
addEnabled={canAddMore}
addEnabled={canAddMoreRooms}
initialValue={selectedRoomAmount}
></NumberSelector>
</TableCell>
</TableRow>
);
});
}
//Element for
priceTable = (
<Table className="overflow-hidden">
<TableHeader>
-2
View File
@@ -35,7 +35,6 @@ export function RoomItemCard({
const bedTypeIconList = room.roomCategoryBedTypes.map((e) => {
return (
<div>
<IconWithTooltip
key={room.id.toString() + e.bedTypeIcon}
tooltipText={e.bedTypeName}
@@ -43,7 +42,6 @@ export function RoomItemCard({
>
<Icon icon={e.bedTypeIcon} height="auto" />
</IconWithTooltip>
</div>
);
});
+7 -18
View File
@@ -6,37 +6,28 @@ import { DatePickerWithRange } from "../ui/dateRange-picker";
import { Button } from "../ui/button";
import { SearchIcon } from "lucide-react";
import { useState } from "react";
import { SearchFilter } from "@/model/SearchFilter";
import { SearchFilter } from "@/model/InternalTypes";
import { DateRange } from "react-day-picker";
import { useToast } from "../ui/use-toast";
import { useAppStore } from "@/store";
type funOnSearchClick = (newFilter: SearchFilter) => void;
export function SearchFilterCard({
onSearchClick,
initialsearchFilter,
}: {
onSearchClick?: funOnSearchClick;
initialsearchFilter?: SearchFilter;
}) {
//App filter. Updated on btSearchClick.
const appSearchFilter = useAppStore((state) => state.searchFilter);
const setAppSearchFilter = useAppStore((state) => state.setSearchFilter);
//Local Filter state.
const [searchFilter, setSearchFilter] =
useState<SearchFilter>(appSearchFilter);
//On location change, try to load searchFilter from state.
//FIXME: Si llegamos sin state, hay que borrar el state global...
// const location = useLocation();
// useEffect(() => {
// setSearchFilter(location.state);
// }, [location]);
const [searchFilter, setSearchFilter] = useState<SearchFilter | null>(
initialsearchFilter ?? null,
);
const { toast } = useToast();
// Validate and call callback on btSearch click.
//TODOME: Add data validations and show messages.
//TODOME: Add data validations and show messages as form instead of toasts.
const btSearchClick = () => {
if (!searchFilter || !searchFilter.dateRange) {
toast({
@@ -55,8 +46,6 @@ export function SearchFilterCard({
return;
}
setAppSearchFilter(searchFilter);
if (onSearchClick) {
onSearchClick(searchFilter);
}
+1 -1
View File
@@ -18,7 +18,7 @@ export function BookingDetails({
onBookingConfirm?: OnBookingClickFunction;
mode: BookingDetailsModes;
}) {
const bookingData = useAppStore((state) => state.newBookingData);
const bookingData = useAppStore((state) => state.currentBooking);
const roomCategoryList = useAPIRoomCategoryList();
const onBtBookingClick = () => {
+3 -1
View File
@@ -8,12 +8,14 @@ export function NumberSelector({
updateCount,
addEnabled = true,
allowNegative = false,
initialValue = 0,
}: {
updateCount: UpdateSelectedNumberFunction;
addEnabled?: boolean;
allowNegative?: boolean;
initialValue?: number;
}) {
const [count, setCount] = useState<number>(0);
const [count, setCount] = useState<number>(initialValue);
const counterUpdated = (newAmount: number) => {
setCount(newAmount);
+1 -1
View File
@@ -3,7 +3,7 @@ import ReactDOM from "react-dom/client";
import "./index.css";
import { RouterProvider, createBrowserRouter } from "react-router-dom";
import Root from "./routes/Root";
import ErrorPage from "./error-page";
import ErrorPage from "./routes/error-page";
import Homepage from "./routes/home-page";
import SearchPage from "./routes/search-page";
import BookPage from "./routes/book-page";
+2 -2
View File
@@ -30,8 +30,8 @@ enum PageState {
function BookPage() {
const [pageState, setPageState] = useState<PageState>(PageState.showForm);
const bookingData = useAppStore((state) => state.newBookingData);
const setCustomerData = useAppStore((state) => state.setBookingCustomer);
const bookingData = useAppStore((state) => state.currentBooking);
const setCustomerData = useAppStore((state) => state.currentBookingSetCustomer);
const navigate = useNavigate();
const bookingForm = useForm<z.infer<typeof BookingFormSchema>>({
+25 -2
View File
@@ -6,16 +6,36 @@ import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Typography } from "@/components/ui/typography";
import { RoomCategory } from "@/model/RoomCategory";
import { SearchFilter } from "@/model/SearchFilter";
import { SearchFilter } from "@/model/InternalTypes";
import { eBookingState } from "@/utils/AppStateManager";
import { useNavigate } from "react-router-dom";
import { LoaderIcon } from "lucide-react";
import "./home-page.css";
import { useAppStore } from "@/store";
import { isValidSearchFilter, makeDayUTCDate } from "@/utils/utils";
function Homepage() {
const appSearchFilter = useAppStore((state) => state.searchFilter);
const setAppSearchFilter = useAppStore((state) => state.setSearchFilter);
const setAppBookingState = useAppStore((state) => state.setBookingState);
const initAppBooking = useAppStore((state) => state.initCurrentBooking);
const { roomCategoryList, isLoading, isError } = useAPIRoomCategoryList();
const navigate = useNavigate();
const onBtSearchClick = (filter: SearchFilter) => {
if (!isValidSearchFilter(filter)) {
return;
}
setAppSearchFilter(filter);
setAppBookingState(eBookingState.Searched);
initAppBooking(
makeDayUTCDate(filter.dateRange.from),
makeDayUTCDate(filter.dateRange.to),
);
navigate("/search", { state: filter });
};
@@ -26,7 +46,10 @@ function Homepage() {
<>
<Typography variant={"h1"}>Somnia Rooms</Typography>
<Separator className="my-10" />
<SearchFilterCard onSearchClick={onBtSearchClick} />
<SearchFilterCard
onSearchClick={onBtSearchClick}
initialsearchFilter={appSearchFilter}
/>
<Separator className="my-10" />
<div className="homePageRoomGrid grid gap-4">{roomList}</div>
{isLoading && (
+63 -62
View File
@@ -6,79 +6,68 @@ import { BookingDetails } from "@/components/booking/booking-details";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Typography } from "@/components/ui/typography";
import { AvailabilityFilter } from "@/model/RoomSearch";
import { SearchFilter } from "@/model/SearchFilter";
import { SearchFilter } from "@/model/InternalTypes";
import { eBookingState } from "@/utils/AppStateManager";
import { useAppStore } from "@/store";
import { useMemo, useState } from "react";
import {
isValidSearchFilter,
makeAvailabilityFilterFromSearchFilter,
makeDayUTCDate,
} from "@/utils/utils";
import { useNavigate } from "react-router-dom";
//TODOME: Igual podemos crear un StateMachine conjunta para los siguiente estados de la página: Sin Buscar, Sin disponibilidad, error, cargando.
//TODOME: Al pulsar el botón "buscar", si ya existen datos en la reserva (se han añadido habitaciones) mostrar un cuadro de aviso de que si se busca se borrará la selección actual.
//TODOME: Mediante los estados globales de la reserva, si ya está en estado "booking" añadiendo los datos, mostrar un mensaje y permitir redirigir directamente para que continue donde lo dejó.
function SearchPage() {
//const [availabilityFilter, setAvailabilityFilter] = useState<AvailabilityFilter>();
const searchFilter = useAppStore((state) => state.searchFilter);
const resetBooking = useAppStore((state) => state.resetBookingState);
const appBookingState = useAppStore((state) => state.bookingState);
const setAppBookingState = useAppStore((state) => state.setBookingState);
const appSearchFilter = useAppStore((state) => state.searchFilter);
const setAppSearchFilter = useAppStore((state) => state.setSearchFilter);
const initAppBooking = useAppStore((state) => state.initCurrentBooking);
const navigate = useNavigate();
const [isSearched, setIsSearched] = useState<boolean>(false);
//This seems a little bit overkill, but IDK.
const availabilityFilter: AvailabilityFilter | null = useMemo(() => {
if (
searchFilter &&
searchFilter.dateRange &&
searchFilter.dateRange.from &&
searchFilter.dateRange.to
) {
const newAvailabilityFilter: AvailabilityFilter = {
startDate: searchFilter.dateRange.from.toDateString(),
endDate: searchFilter.dateRange.to.toDateString(),
};
return newAvailabilityFilter;
}
return null;
}, [searchFilter]);
const {
roomAvailabilityList,
isLoading: isLoadingAvailability,
isError: isErrorAvailability,
} = useAPIRoomAvailability(availabilityFilter ?? null);
} = useAPIRoomAvailability(
makeAvailabilityFilterFromSearchFilter(appSearchFilter) ?? null,
);
const isLoading = isLoadingAvailability;
const isError = isErrorAvailability;
const hasRooms =
roomAvailabilityList?.length && roomAvailabilityList?.length > 0;
//TODOME: Hay que ver para mover esto a un método común.
const onBtSearchClick = (newFilter: SearchFilter) => {
if (
newFilter.dateRange &&
newFilter.dateRange.from &&
newFilter.dateRange.to
) {
setIsSearched(true);
resetBooking(makeDayUTCDate(newFilter.dateRange.from), makeDayUTCDate(newFilter.dateRange.to));
if (!isValidSearchFilter(newFilter)) {
return;
}
setAppSearchFilter(newFilter);
setAppBookingState(eBookingState.Searched);
initAppBooking(
makeDayUTCDate(newFilter.dateRange.from),
makeDayUTCDate(newFilter.dateRange.to),
);
};
// TODOME: Hay que dar una vuelta a esta solución
// - Mirar cómo trabajar con tiempos UTC desde el principio.
// - En cualquier caso, mover esta función a otro lugar.
const makeDayUTCDate = (date : Date) : Date => {
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999);
}
const onBookingContinue = () => {
navigate("/book");
};
const roomList = roomAvailabilityList?.map((element) => {
const roomList =
roomAvailabilityList?.map((element) => {
return (
<RoomBooking
key={element.roomCategory}
key={element.roomCategory + "-" + appBookingState.lastSearchDate}
availableAmount={element.availability}
roomCategoryId={element.roomCategory}
/>
);
});
}) ?? null;
const noRoomsAvailable = (
<>
<Typography variant={"h3"} as="h3">
@@ -87,22 +76,23 @@ function SearchPage() {
</Typography>
</>
);
const hasRooms =
roomAvailabilityList?.length && roomAvailabilityList?.length > 0;
console.log(hasRooms);
const pageContent = isLoading ? (
const pageContentSearch = (
<Typography>
Realiza una búsqueda para ver la disponibilidad y los precios.
</Typography>
);
const pageContentLoading = (
<>
<Typography variant={"h3"} as="h2">
Loading...
</Typography>
<Skeleton className="mx-auto h-[200px] w-full" />
</>
) : (
);
const pageContentLoaded = (
<div className="2xl:grid 2xl:grid-cols-60/40 2xl:gap-6">
<div className="flex flex-col gap-5">
{hasRooms ? roomList : isSearched && noRoomsAvailable}
</div>
<div className="flex flex-col gap-5">{roomList}</div>
<Separator className="my-10 2xl:hidden" />
<BookingDetails
onBookingConfirm={onBookingContinue}
@@ -111,17 +101,28 @@ function SearchPage() {
</div>
);
const getPageContent = () => {
const isSearched = appBookingState.state == eBookingState.Searched;
if (!isSearched) {
return pageContentSearch;
}
if (isLoading) {
return pageContentLoading;
}
if (!hasRooms) {
return noRoomsAvailable;
}
return pageContentLoaded;
};
return (
<div className="bg-background text-foreground">
<SearchFilterCard onSearchClick={onBtSearchClick} />
<SearchFilterCard
onSearchClick={onBtSearchClick}
initialsearchFilter={appSearchFilter}
/>
<Separator className="my-10" />
{hasRooms ? (
pageContent
) : (
<Typography>
Realiza una búsqueda para ver la disponibilidad y los precios.
</Typography>
)}
{getPageContent()}
<Separator className="my-10" />
<BackendConnectionStatus isLoading={isLoading} isError={isError} />
</div>
+2 -3
View File
@@ -3,13 +3,12 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Typography } from "@/components/ui/typography";
import { NewBookingResponse } from "@/model/Bookings";
import { useAppStore } from "@/store";
import { addDays } from "date-fns";
import { useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
function SuccessPage() {
const [pageState, setPageState] = useState<NewBookingResponse | null>(null);
const resetBookingState = useAppStore((state) => state.resetBookingState);
const resetBookingState = useAppStore((state) => state.resetCurrentBooking);
const location = useLocation();
const navigate = useNavigate();
@@ -22,7 +21,7 @@ function SuccessPage() {
}, [location, navigate]);
const handleBtBackClick = () => {
resetBookingState(new Date(), addDays(new Date(), 2));
resetBookingState();
navigate("/home");
};
+59 -34
View File
@@ -1,19 +1,31 @@
import { create } from "zustand";
import { SearchFilter } from "./model/SearchFilter";
import { SearchFilter } from "@/model/InternalTypes";
import {
BookingState,
eBookingState,
SetBookingState,
} from "./utils/AppStateManager";
import {
NewBooking,
NewBookingCustomerDetails,
NewBookingRoom,
} from "./model/Bookings";
interface AppState {
interface State {
searchFilter: SearchFilter;
currentBooking: NewBooking | null;
bookingState: BookingState;
}
interface Actions {
setSearchFilter: (newFilter: SearchFilter) => void;
newBookingData: NewBooking | null;
setNewBooking: (newBooking: NewBooking) => void;
resetBookingState: (arrival: Date, departure: Date) => void;
addNewBookingRoom: (newRoom: NewBookingRoom) => void;
setBookingCustomer: (newCustomer: NewBookingCustomerDetails) => void;
resetSearchFilter: () => void;
setCurrentBooking: (newBooking: NewBooking) => void;
resetCurrentBooking: () => void;
initCurrentBooking: (arrival: Date, departure: Date) => void;
currentBookingAddRoom: (newRoom: NewBookingRoom) => void;
currentBookingSetCustomer: (newCustomer: NewBookingCustomerDetails) => void;
setBookingState: (newState: eBookingState) => void;
}
const defaultSearchFilter: SearchFilter = {
@@ -32,7 +44,12 @@ const defaultNewBooking: NewBooking = {
rooms: [],
};
function getDefaultBookingData(arrivalDate: Date, departureDate: Date) {
const defaultBookingState: BookingState = {
state: eBookingState.None,
lastSearchDate: new Date(),
};
function getDefaultBooking(arrivalDate: Date, departureDate: Date) {
return {
...defaultNewBooking,
arrivalDate: arrivalDate,
@@ -40,19 +57,19 @@ function getDefaultBookingData(arrivalDate: Date, departureDate: Date) {
};
}
function addNewBookingRoomToCurrentState(
function addNewBookingRoom(
newRoom: NewBookingRoom,
currentState: NewBooking | null,
currentBookingState: NewBooking | null,
): NewBooking | null {
if (currentState == null) {
return currentState;
if (currentBookingState == null) {
return currentBookingState;
}
//Remove rooms with "same key".
const filteredRooms = currentState.rooms.filter((room) => {
const diffRoom = room.roomCategory != newRoom.roomCategory;
const diffAmount = room.guestNumber != newRoom.guestNumber;
if (diffRoom || (!diffRoom && diffAmount)) {
const filteredRooms = currentBookingState.rooms.filter((room) => {
const isDiffRoom = room.roomCategory != newRoom.roomCategory;
const isDiffAmount = room.guestNumber != newRoom.guestNumber;
if (isDiffRoom || (!isDiffRoom && isDiffAmount)) {
return true;
}
return false;
@@ -64,11 +81,11 @@ function addNewBookingRoomToCurrentState(
}
const newState: NewBooking = {
...currentState,
...currentBookingState,
rooms: filteredRooms ?? [],
};
console.log("AddRoomNewState:");
console.log("Updated 'currentBooking' state:");
console.log(newState);
return newState;
@@ -76,35 +93,43 @@ function addNewBookingRoomToCurrentState(
function setBookingCustomer(
newCustomer: NewBookingCustomerDetails,
currentState: NewBooking | null,
currentBookingState: NewBooking | null,
) {
if (currentState == null) {
return currentState;
if (currentBookingState == null) {
return currentBookingState;
}
const newState: NewBooking = {
...currentState,
...currentBookingState,
customerDetails: newCustomer,
};
return newState;
}
export const useAppStore = create<AppState>()((set) => ({
export const useAppStore = create<State & Actions>()((set) => ({
searchFilter: defaultSearchFilter,
currentBooking: null,
bookingState: defaultBookingState,
setSearchFilter: (newFilter) => set(() => ({ searchFilter: newFilter })),
newBookingData: null,
setNewBooking: (newBooking) => set(() => ({ newBookingData: newBooking })),
resetBookingState: (arrival, departure) =>
set(() => ({ newBookingData: getDefaultBookingData(arrival, departure) })),
addNewBookingRoom: (newRoom: NewBookingRoom) =>
resetSearchFilter: () => set(() => ({ searchFilter: defaultSearchFilter })),
setCurrentBooking: (newBooking) =>
set(() => ({ currentBooking: newBooking })),
resetCurrentBooking: () => set(() => ({ currentBooking: null })),
initCurrentBooking: (arrival, departure) =>
set(() => ({ currentBooking: getDefaultBooking(arrival, departure) })),
currentBookingAddRoom: (newRoom) =>
set((state) => ({
newBookingData: addNewBookingRoomToCurrentState(
newRoom,
state.newBookingData,
),
currentBooking: addNewBookingRoom(newRoom, state.currentBooking),
})),
setBookingCustomer: (newCustomer: NewBookingCustomerDetails) =>
currentBookingSetCustomer: (newCustomer) =>
set((state) => ({
newBookingData: setBookingCustomer(newCustomer, state.newBookingData),
currentBooking: setBookingCustomer(newCustomer, state.currentBooking),
})),
setBookingState: (newState) =>
set((state) => ({
bookingState: SetBookingState(newState, state.bookingState),
})),
}));
+27
View File
@@ -0,0 +1,27 @@
export type BookingState = {
state: eBookingState;
lastSearchDate: Date;
};
export enum eBookingState {
None = "NONE",
Searched = "SEARCHED",
Booking = "BOOKING",
Booked = "BOOKED",
}
export function SetBookingState(
newState: eBookingState,
currentAppBookingState: BookingState,
) {
const newAppState: BookingState = {
...currentAppBookingState,
state: newState,
};
if (newState == eBookingState.Searched) {
newAppState.lastSearchDate = new Date();
}
return newAppState;
}
+52
View File
@@ -0,0 +1,52 @@
import { AvailabilityFilter, FareFilter } from "@/model/RoomSearch";
import { SearchFilter } from "@/model/InternalTypes";
export function makeDayUTCDate(date: Date | undefined): Date {
if (!date) {
return new Date();
}
return new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
23,
59,
59,
999,
);
}
export function isValidSearchFilter(sf: SearchFilter): boolean {
if (sf?.dateRange?.from && sf?.dateRange?.to) {
return true;
}
return false;
}
export function makeAvailabilityFilterFromSearchFilter(
sf: SearchFilter,
): AvailabilityFilter | null {
if (!isValidSearchFilter(sf)) {
return null;
}
return {
startDate: sf.dateRange.from!.toISOString(),
endDate: sf.dateRange.to!.toISOString(),
};
}
export function makeFareFilterFromSearchFilter(
sf: SearchFilter,
roomCategoryId: number,
): FareFilter | null {
if (!isValidSearchFilter(sf) || roomCategoryId < 0) {
return null;
}
return {
startDate: sf.dateRange.from!.toISOString(),
endDate: sf.dateRange.to!.toISOString(),
roomCategory: roomCategoryId,
};
}