Contact flow:

- Added contact page for bookings over API limit.
- Added ContactUs dialog to redirect customers to contact page.
This commit is contained in:
2026-01-07 22:25:27 +01:00
parent d4094d09c6
commit 94312de6e2
15 changed files with 584 additions and 93 deletions
@@ -0,0 +1,65 @@
import { useNavigate } from "react-router-dom";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "../ui/alert-dialog";
import { Button } from "../ui/button";
import { ConstsNav, ConstsTexts } from "@/utils/Consts";
type OnCancelClickFunction = () => void;
export function ContactUsDialog({
onCancelClick,
isOpen,
cancelButtonText = "Cancel",
}: {
cancelButtonText: string;
onCancelClick: OnCancelClickFunction;
isOpen: boolean;
}) {
const navigate = useNavigate();
const navToContactPage = (text: string) => {
navigate(ConstsNav.page_Contact, {
state: text,
});
};
return (
<AlertDialog open={isOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{ConstsTexts.contactUsAlertDialog_title}
</AlertDialogTitle>
<AlertDialogDescription>
{ConstsTexts.contactUsAlertDialog_text}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button variant={"secondary"} onClick={onCancelClick}>
{cancelButtonText}
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
variant={"link"}
onClick={() =>
navToContactPage("Contact us for a personalized quote!")
}
>
Go to contact page
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
+74 -27
View File
@@ -9,6 +9,11 @@ import { useState } from "react";
import { SearchFilter } from "@/model/InternalTypes"; import { SearchFilter } from "@/model/InternalTypes";
import { DateRange } from "react-day-picker"; import { DateRange } from "react-day-picker";
import { useToast } from "../ui/use-toast"; import { useToast } from "../ui/use-toast";
import { isValidSearchFilter } from "@/utils/utils";
import { useNavigate } from "react-router-dom";
import { ConstsNav, ConstsTexts } from "@/utils/Consts";
import { ContactUsDialog } from "../Dialogs/ContactUsDialog";
import { useCheckForNightCount } from "@/utils/hooks";
type funOnSearchClick = (newFilter: SearchFilter) => void; type funOnSearchClick = (newFilter: SearchFilter) => void;
@@ -23,21 +28,22 @@ export function SearchFilterCard({
const [searchFilter, setSearchFilter] = useState<SearchFilter | null>( const [searchFilter, setSearchFilter] = useState<SearchFilter | null>(
initialsearchFilter ?? null, initialsearchFilter ?? null,
); );
const [isContactDialogOpen, setIsContactDialogOpen] =
useState<boolean>(false);
const {
isLoading: isNightCountLoading,
isOverApiMax: nightCountCheckIsOverApi,
shouldShowContactDialog: nightCountShowDialog,
} = useCheckForNightCount(searchFilter);
const { toast } = useToast(); const { toast } = useToast();
const navigate = useNavigate();
// Validate and call callback on btSearch click. // Validate and call callback on btSearch click.
//TODOME: Add data validations and show messages as form instead of toasts. //TODOME: Show validation messages as form instead of toasts.
const btSearchClick = () => { const btSearchClick = () => {
if (!searchFilter || !searchFilter.dateRange) { if (!isValidSearchFilter(searchFilter)) {
toast({
variant: "destructive",
title: "Uh oh! Invalid Search.",
});
return;
}
if (!searchFilter.dateRange.from || !searchFilter.dateRange.to) {
toast({ toast({
variant: "destructive", variant: "destructive",
title: "Uh oh! Invalid Search.", title: "Uh oh! Invalid Search.",
@@ -46,12 +52,29 @@ export function SearchFilterCard({
return; return;
} }
//Check for max night count.
//API Config vars values must be loaded before this! Unexpected behaviours may happen otherwise.
if (nightCountCheckIsOverApi) {
//Navigate to contact page and show info.
navigate(ConstsNav.page_Contact, {
state: ConstsTexts.navToContact_maxAllowedNightCount,
});
return;
}
if (nightCountShowDialog) {
//Show Alert dialog.
setIsContactDialogOpen(true);
//Page onSearch action will be executed based on the dialog result.
return;
}
//Execute page onSearch action.
if (onSearchClick) { if (onSearchClick) {
onSearchClick(searchFilter); onSearchClick(searchFilter);
} }
}; };
// Handle Element updates. // Handle internal Searchfilter updates.
const updateDateRange = (newRange: DateRange) => { const updateDateRange = (newRange: DateRange) => {
setSearchFilter({ setSearchFilter({
...searchFilter, ...searchFilter,
@@ -59,6 +82,19 @@ export function SearchFilterCard({
}); });
}; };
const onContactDialogClose = () => {
setIsContactDialogOpen(false);
if (!isValidSearchFilter(searchFilter)) {
return;
}
if (onSearchClick) {
onSearchClick(searchFilter);
}
};
const isSearchButtonDisabled = isNightCountLoading;
const datePickerElement = const datePickerElement =
searchFilter == null ? ( searchFilter == null ? (
<DatePickerWithRange <DatePickerWithRange
@@ -75,22 +111,33 @@ export function SearchFilterCard({
); );
return ( return (
<Card> <>
<CardHeader className="m-0 mb-5 p-0"> <ContactUsDialog
<Typography variant={"h2"} as="h1" className="mx-5 py-2"> isOpen={isContactDialogOpen}
Reserva ahora onCancelClick={onContactDialogClose}
</Typography> cancelButtonText="Continue Search"
</CardHeader> />
<CardContent> <Card>
{datePickerElement} <CardHeader className="m-0 mb-5 p-0">
<Separator className="my-4" /> <Typography variant={"h2"} as="h1" className="mx-5 py-2">
<Button size={"lg"} onClick={btSearchClick}> Reserva ahora
<SearchIcon className="me-2 text-primary-foreground" />
<Typography variant={"h3"} className="text-primary-foreground">
Buscar
</Typography> </Typography>
</Button> </CardHeader>
</CardContent> <CardContent>
</Card> {datePickerElement}
<Separator className="my-4" />
<Button
size={"lg"}
onClick={btSearchClick}
disabled={isSearchButtonDisabled}
>
<SearchIcon className="me-2 text-primary-foreground" />
<Typography variant={"h3"} className="text-primary-foreground">
Buscar
</Typography>
</Button>
</CardContent>
</Card>
</>
); );
} }
+82 -31
View File
@@ -7,6 +7,11 @@ import { Button } from "../ui/button";
import { useAPIRoomCategoryList } from "@/api/api-roomCategories"; import { useAPIRoomCategoryList } from "@/api/api-roomCategories";
import { IconWithTooltip } from "../ui/icon-number-tooltip"; import { IconWithTooltip } from "../ui/icon-number-tooltip";
import { UserIcon } from "lucide-react"; import { UserIcon } from "lucide-react";
import { useCheckForGuestNumber } from "@/utils/hooks";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { ConstsNav, ConstsTexts } from "@/utils/Consts";
import { ContactUsDialog } from "../Dialogs/ContactUsDialog";
type OnBookingClickFunction = () => void; type OnBookingClickFunction = () => void;
type BookingDetailsModes = "Book" | "View"; type BookingDetailsModes = "Book" | "View";
@@ -18,10 +23,42 @@ export function BookingDetails({
onBookingConfirm?: OnBookingClickFunction; onBookingConfirm?: OnBookingClickFunction;
mode: BookingDetailsModes; mode: BookingDetailsModes;
}) { }) {
const [isContactDialogOpen, setIsContactDialogOpen] =
useState<boolean>(false);
const bookingData = useAppStore((state) => state.currentBooking); const bookingData = useAppStore((state) => state.currentBooking);
const { roomCategoryList } = useAPIRoomCategoryList(); const { roomCategoryList } = useAPIRoomCategoryList();
const navigate = useNavigate();
const {
isLoading: isGuestNumberCheckLoading,
isOverApiMax: guestNumberCheckIsOverApi,
shouldShowContactDialog: guestNumberShowDialog,
} = useCheckForGuestNumber(bookingData);
const onBtBookingClick = () => { const onBtBookingClick = () => {
//Check for Guest Number limits.
//API Config vars values must be loaded before this! Unexpected behaviours may happen otherwise.
if (guestNumberCheckIsOverApi) {
//Navigate to contact page and show info.
navigate(ConstsNav.page_Contact, {
state: ConstsTexts.navToContact_maxAllowedGuestNumber,
});
return;
}
if (guestNumberShowDialog) {
//Show Alert dialog.
setIsContactDialogOpen(true);
//Page onSearch action will be executed based on the dialog result.
return;
}
if (onBookingConfirm) {
onBookingConfirm();
}
};
const onContactDialogClose = () => {
if (onBookingConfirm) { if (onBookingConfirm) {
onBookingConfirm(); onBookingConfirm();
} }
@@ -87,37 +124,51 @@ export function BookingDetails({
); );
return ( return (
<Card> <>
<CardHeader> <ContactUsDialog
<Typography variant={"h2"} as="h2"> isOpen={isContactDialogOpen}
Your Booking cancelButtonText="Continue booking"
</Typography> onCancelClick={onContactDialogClose}
</CardHeader> />
<CardContent> <Card>
<Typography variant={"h3"}>Your Dates</Typography> <CardHeader>
<Typography variant={"largeText"}> <Typography variant={"h2"} as="h2">
Arrival: {bookingData.arrivalDate.toLocaleDateString()} - Departure:{" "} Your Booking
{bookingData.departureDate.toLocaleDateString()} <br></br> </Typography>
Number of nights:{" "} </CardHeader>
{differenceInDays(bookingData.departureDate, bookingData.arrivalDate)} <CardContent>
</Typography> <Typography variant={"h3"}>Your Dates</Typography>
<Separator className="my-2"></Separator> <Typography variant={"largeText"}>
<Typography variant={"h3"}>Your Rooms</Typography> Arrival: {bookingData.arrivalDate.toLocaleDateString()} - Departure:{" "}
<div className="">{roomList}</div> {bookingData.departureDate.toLocaleDateString()} <br></br>
<Separator className="my-2"></Separator> Number of nights:{" "}
<Typography variant={"h3"}>Total Price</Typography> {differenceInDays(
<Typography variant={"h3"}>{totalPrice / 100} €</Typography> bookingData.departureDate,
{mode == "Book" && ( bookingData.arrivalDate,
<Button className="my-2" onClick={onBtBookingClick}> )}
<Typography </Typography>
variant={"largeText"} <Separator className="my-2"></Separator>
className="text-primary-foreground" <Typography variant={"h3"}>Your Rooms</Typography>
<div className="">{roomList}</div>
<Separator className="my-2"></Separator>
<Typography variant={"h3"}>Total Price</Typography>
<Typography variant={"h3"}>{totalPrice / 100} €</Typography>
{mode == "Book" && (
<Button
className="my-2"
onClick={onBtBookingClick}
disabled={isGuestNumberCheckLoading}
> >
Book it! <Typography
</Typography> variant={"largeText"}
</Button> className="text-primary-foreground"
)} >
</CardContent> Book it!
</Card> </Typography>
</Button>
)}
</CardContent>
</Card>
</>
); );
} }
+155
View File
@@ -0,0 +1,155 @@
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
+5
View File
@@ -8,6 +8,7 @@ import Homepage from "./routes/home-page";
import SearchPage from "./routes/search-page"; import SearchPage from "./routes/search-page";
import BookPage from "./routes/book-page"; import BookPage from "./routes/book-page";
import SuccessPage from "./routes/success-page"; import SuccessPage from "./routes/success-page";
import ContactPage from "./routes/contact-page";
const router = createBrowserRouter([ const router = createBrowserRouter([
{ {
@@ -35,6 +36,10 @@ const router = createBrowserRouter([
path: "/success", path: "/success",
element: <SuccessPage />, element: <SuccessPage />,
}, },
{
path: "/contact",
element: <ContactPage />,
},
], ],
}, },
]); ]);
+4 -2
View File
@@ -6,15 +6,17 @@ export type ConfigVar = {
export enum eConfigVarKeys { export enum eConfigVarKeys {
varCompanyName = "CompanyName", varCompanyName = "CompanyName",
varCompanyWebsite = "CompanyWebsite", varCompanyWebsite = "CompanyWebsite",
varCompanyContactFormUrl = "CompanyContactFormUrl",
varCompanyLogo = "CompanyLogo", varCompanyLogo = "CompanyLogo",
varCompanyLogoSize = "CompanyLogoSize", varCompanyLogoSize = "CompanyLogoSize",
varCompanyEmail = "CompanyEmail", varCompanyEmail = "CompanyEmail",
varCompanyPhone = "CompanyPhone", varCompanyPhone = "CompanyPhone",
varCompanyAddress = "CompanyAddress", varCompanyAddress = "CompanyAddress",
varNightAmountForContactDialog = "NightAmountForContactDialog", varNightCountForContactDialog = "NightCountForContactDialog",
varGuestNumberForContactDialog = "GuestNumberForContactDialog", varGuestNumberForContactDialog = "GuestNumberForContactDialog",
varContactDialogShowBehaviour = "ContactDialogShowBehaviour",
varShowNonAvailableRooms = "ShowNonAvailableRooms", varShowNonAvailableRooms = "ShowNonAvailableRooms",
varSomniaAttributionNotice = "SomniaAttributionNotice", varSomniaAttributionNotice = "SomniaAttributionNotice",
varPhotoAspectRatio = "PhotoAspectRatio", varPhotoAspectRatio = "PhotoAspectRatio",
varAPIMaxAllowedNightAmount = "MaxAllowedNightAmount",
varAPIMaxAllowedGuestNumber = "MaxAllowedGuestNumber",
} }
+3 -2
View File
@@ -21,6 +21,7 @@ import { z } from "zod/v4";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { eBookingState } from "@/utils/AppStateManager"; import { eBookingState } from "@/utils/AppStateManager";
import { useBookingStateDispacher } from "@/utils/hooks"; import { useBookingStateDispacher } from "@/utils/hooks";
import { ConstsNav } from "@/utils/Consts";
enum PageState { enum PageState {
showForm, showForm,
@@ -72,7 +73,7 @@ function BookPage() {
setPageState(PageState.error); setPageState(PageState.error);
} else { } else {
setBookingStateBooked(); setBookingStateBooked();
navigate("/success", { state: rs.bookingResponse }); navigate(ConstsNav.page_Success, { state: rs.bookingResponse });
} }
}; };
@@ -82,7 +83,7 @@ function BookPage() {
appBookingState.state != eBookingState.Booking || appBookingState.state != eBookingState.Booking ||
!bookingData !bookingData
) { ) {
navigate("/home"); navigate(ConstsNav.page_Home);
} }
const errorMsg = ( const errorMsg = (
+63
View File
@@ -0,0 +1,63 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Typography } from "@/components/ui/typography";
import { eConfigVarKeys } from "@/model/ConfigVar";
import { useConfigVariable } from "@/utils/apiHooks";
import { Mail, Phone } from "lucide-react";
import { useLocation } from "react-router-dom";
function ContactPage() {
const location = useLocation();
const { value: varCompanyPhone, isLoading: isPhoneLoading } =
useConfigVariable(eConfigVarKeys.varCompanyPhone, "");
const { value: varCompanyMail, isLoading: isMailLoading } = useConfigVariable(
eConfigVarKeys.varCompanyEmail,
"",
);
const { value: varCompanyContactPage, isLoading: isContactPageLoading } =
useConfigVariable(eConfigVarKeys.varCompanyContactFormUrl, "");
const isDataLoading = isPhoneLoading || isMailLoading || isContactPageLoading;
const mainText = location.state ?? "Contact us for a personalized quote!";
const LoadingContent = <Skeleton className="h-[100px] w-full" />;
const MainContent = (
<div className="mx-auto">
<div className="mx-auto">
<Typography variant={"largeText"}>{mainText}</Typography>
<div className="my-4 flex justify-center gap-4">
<Card className="flex flex-col items-center gap-2 px-5 hover:bg-secondary">
<Phone size={"1.2em"} />
<Typography variant={"p_inline"}>{varCompanyPhone}</Typography>
</Card>
<Card className="flex flex-col items-center gap-2 px-5 hover:bg-secondary">
<Mail size={"1.2em"} />
<Typography variant={"p_inline"}>{varCompanyMail}</Typography>
</Card>
</div>
</div>
{varCompanyContactPage !== "" && (
<Button className="my-4" variant={"link"}>
<a href={varCompanyContactPage}>
<Typography variant={"largeText"}>Go to contact form</Typography>
</a>
</Button>
)}
</div>
);
return (
<Card>
<CardHeader>
<Typography variant={"h2"}>Contact us!</Typography>
</CardHeader>
<CardContent>{isDataLoading ? LoadingContent : MainContent}</CardContent>
</Card>
);
}
export default ContactPage;
+2 -6
View File
@@ -11,10 +11,10 @@ import { useNavigate } from "react-router-dom";
import { LoaderIcon } from "lucide-react"; import { LoaderIcon } from "lucide-react";
import "./home-page.css"; import "./home-page.css";
import { useAppStore } from "@/store"; import { useAppStore } from "@/store";
import { isValidSearchFilter } from "@/utils/utils";
import { useBookingStateDispacher } from "@/utils/hooks"; import { useBookingStateDispacher } from "@/utils/hooks";
import { eConfigVarKeys } from "@/model/ConfigVar"; import { eConfigVarKeys } from "@/model/ConfigVar";
import { useConfigVariable } from "@/utils/apiHooks"; import { useConfigVariable } from "@/utils/apiHooks";
import { ConstsNav } from "@/utils/Consts";
function Homepage() { function Homepage() {
const appSearchFilter = useAppStore((state) => state.searchFilter); const appSearchFilter = useAppStore((state) => state.searchFilter);
@@ -32,13 +32,9 @@ function Homepage() {
} = useAPIRoomCategoryList(); } = useAPIRoomCategoryList();
const onBtSearchClick = (filter: SearchFilter) => { const onBtSearchClick = (filter: SearchFilter) => {
if (!isValidSearchFilter(filter)) {
return;
}
setBookingStateSearched(filter); setBookingStateSearched(filter);
navigate("/search", { state: filter }); navigate(ConstsNav.page_Search, { state: filter });
}; };
const roomList = roomCategoryList?.map((element: RoomCategory) => { const roomList = roomCategoryList?.map((element: RoomCategory) => {
+3 -9
View File
@@ -9,12 +9,10 @@ import { Typography } from "@/components/ui/typography";
import { SearchFilter } from "@/model/InternalTypes"; import { SearchFilter } from "@/model/InternalTypes";
import { eBookingState } from "@/utils/AppStateManager"; import { eBookingState } from "@/utils/AppStateManager";
import { useAppStore } from "@/store"; import { useAppStore } from "@/store";
import { import { makeSearchPeriodRequestFromSearchFilter } from "@/utils/utils";
isValidSearchFilter,
makeSearchPeriodRequestFromSearchFilter,
} from "@/utils/utils";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useBookingStateDispacher } from "@/utils/hooks"; import { useBookingStateDispacher } from "@/utils/hooks";
import { ConstsNav } from "@/utils/Consts";
//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: 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ó. //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ó.
@@ -40,16 +38,12 @@ function SearchPage() {
roomAvailabilityList?.length && roomAvailabilityList?.length > 0; roomAvailabilityList?.length && roomAvailabilityList?.length > 0;
const onBtSearchClick = (newFilter: SearchFilter) => { const onBtSearchClick = (newFilter: SearchFilter) => {
if (!isValidSearchFilter(newFilter)) {
return;
}
setBookingStateSearched(newFilter); setBookingStateSearched(newFilter);
}; };
const onBookingContinue = () => { const onBookingContinue = () => {
setBookingStateBooking(); setBookingStateBooking();
navigate("/book"); navigate(ConstsNav.page_Book);
}; };
const roomList = const roomList =
+3 -2
View File
@@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Typography } from "@/components/ui/typography"; import { Typography } from "@/components/ui/typography";
import { NewBookingResponse } from "@/model/Bookings"; import { NewBookingResponse } from "@/model/Bookings";
import { useAppStore } from "@/store"; import { useAppStore } from "@/store";
import { ConstsNav } from "@/utils/Consts";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
@@ -15,14 +16,14 @@ function SuccessPage() {
useEffect(() => { useEffect(() => {
if (location.state == null) { if (location.state == null) {
navigate("/home"); navigate(ConstsNav.page_Home);
} }
setPageState(location.state); setPageState(location.state);
}, [location, navigate]); }, [location, navigate]);
const handleBtBackClick = () => { const handleBtBackClick = () => {
resetBookingState(); resetBookingState();
navigate("/home"); navigate(ConstsNav.page_Home);
}; };
return ( return (
+21 -2
View File
@@ -1,3 +1,22 @@
export const c_somniaRooms_notice_text =
export const c_somniaRooms_notice_text = "Powered by SomniaRooms, an Open-Source hospitality booking platform!"; "Powered by SomniaRooms, an Open-Source hospitality booking platform!";
export const c_sominaRooms_webpage = "https://somnia.dev"; export const c_sominaRooms_webpage = "https://somnia.dev";
export const ConstsNav = {
page_Home: "/home",
page_Contact: "/contact",
page_Search: "/search",
page_Book: "/book",
page_Success: "/success",
};
//TMP - The following texts should be replaced for a proper intl system.
export const ConstsTexts = {
navToContact_maxAllowedNightCount:
"We are sorry, but the selected date range is too large. Search on a smaller range or contact us for a personalized quote.",
navToContact_maxAllowedGuestNumber:
"We are sorry, group bookings are not allowed. Contact us for a personalized quote.",
contactUsAlertDialog_title: "Contact us!",
contactUsAlertDialog_text:
"This booking seems like a big deal... Contact us for a personalized quote!",
};
+2 -10
View File
@@ -35,20 +35,12 @@ export function useConfigVariableAsNumber(
isLoading: boolean; isLoading: boolean;
} { } {
const rs = useConfigVariable(key, defaultValue.toString()); const rs = useConfigVariable(key, defaultValue.toString());
if (rs.isLoading) {
return {
value: defaultValue,
isLoading: true,
};
}
return { return {
value: parseFloat(rs.value), value: rs.isLoading ? defaultValue : parseFloat(rs.value),
isLoading: false, isLoading: rs.isLoading,
}; };
} }
export function useRoomCategory(roomCategoryId: number): { export function useRoomCategory(roomCategoryId: number): {
value: RoomCategory | null; value: RoomCategory | null;
isLoading: boolean; isLoading: boolean;
+91 -1
View File
@@ -1,8 +1,11 @@
import { SearchFilter } from "@/model/InternalTypes"; import { SearchFilter } from "@/model/InternalTypes";
import { useAppStore } from "@/store"; import { useAppStore } from "@/store";
import { makeDayUTCDate } from "./utils"; import { getNightCountFromSearchFilter, makeDayUTCDate } from "./utils";
import { useCallback } from "react"; import { useCallback } from "react";
import { eBookingState } from "./AppStateManager"; import { eBookingState } from "./AppStateManager";
import { useConfigVariableAsNumber } from "./apiHooks";
import { eConfigVarKeys } from "@/model/ConfigVar";
import { NewBooking } from "@/model/Bookings";
export function useBookingStateDispacher(): { export function useBookingStateDispacher(): {
setBookingStateSearched: (sf: SearchFilter) => void; setBookingStateSearched: (sf: SearchFilter) => void;
@@ -39,3 +42,90 @@ export function useBookingStateDispacher(): {
setBookingStateBooked: fnBooked, setBookingStateBooked: fnBooked,
}; };
} }
//This checks may be moved into "useBookingStateDispacher".
// That way, the logic may be more general and separated from the components and buttons
// and the dialog may use global state.
export function useCheckForNightCount(sf: SearchFilter | null): {
isOverApiMax: boolean;
shouldShowContactDialog: boolean;
isLoading: boolean;
} {
const {
value: varApiMaxAllowedNights,
isLoading: isLoadingVarMaxAllowedNights,
} = useConfigVariableAsNumber(eConfigVarKeys.varAPIMaxAllowedNightAmount, -1);
const {
value: varNightCountForContactDialog,
isLoading: isLoadingVarNightCountForContact,
} = useConfigVariableAsNumber(
eConfigVarKeys.varNightCountForContactDialog,
-1,
);
const areVariablesLoading =
isLoadingVarMaxAllowedNights || isLoadingVarNightCountForContact;
const filterNightCount = getNightCountFromSearchFilter(sf);
if (filterNightCount == null || areVariablesLoading) {
return {
isOverApiMax: false,
shouldShowContactDialog: false,
isLoading: areVariablesLoading,
};
}
const doCheckForMaxAllowedNights = varApiMaxAllowedNights > -1;
const doCheckForContactAlert = varNightCountForContactDialog > -1;
return {
isOverApiMax:
doCheckForMaxAllowedNights && filterNightCount > varApiMaxAllowedNights,
shouldShowContactDialog:
doCheckForContactAlert &&
filterNightCount >= varNightCountForContactDialog,
isLoading: areVariablesLoading,
};
}
export function useCheckForGuestNumber(bookingData: NewBooking | null): {
isOverApiMax: boolean;
shouldShowContactDialog: boolean;
isLoading: boolean;
} {
const {
value: varApiMaxAllowedGuests,
isLoading: isLoadingVarMaxAllowedGuests,
} = useConfigVariableAsNumber(eConfigVarKeys.varAPIMaxAllowedGuestNumber, -1);
const {
value: varGuestNumberForContactDialog,
isLoading: isLoadingVarGuestNumberForContact,
} = useConfigVariableAsNumber(
eConfigVarKeys.varGuestNumberForContactDialog,
-1,
);
const areVariablesLoading =
isLoadingVarMaxAllowedGuests || isLoadingVarGuestNumberForContact;
if (bookingData == null || areVariablesLoading) {
return {
isOverApiMax: false,
shouldShowContactDialog: false,
isLoading: areVariablesLoading,
};
}
//Calculate guest number in current booking data.
const totalGuestNumber = bookingData.rooms.reduce((acc, r) => {
return acc + r.guestNumber * r.amount;
}, 0);
const doCheckForMaxAllowedGuests = varApiMaxAllowedGuests > -1;
const doCheckForContactAlert = varGuestNumberForContactDialog > -1;
return {
isOverApiMax:
doCheckForMaxAllowedGuests && totalGuestNumber > varApiMaxAllowedGuests,
shouldShowContactDialog:
doCheckForContactAlert &&
totalGuestNumber >= varGuestNumberForContactDialog,
isLoading: areVariablesLoading,
};
}
+11 -1
View File
@@ -1,6 +1,7 @@
import { SearchPeriodRequest } from "@/model/RoomSearch"; import { SearchPeriodRequest } from "@/model/RoomSearch";
import { SearchFilter } from "@/model/InternalTypes"; import { SearchFilter } from "@/model/InternalTypes";
import { ConfigVar } from "@/model/ConfigVar"; import { ConfigVar } from "@/model/ConfigVar";
import { differenceInDays } from "date-fns";
export function makeDayUTCDate(date: Date | undefined): Date { export function makeDayUTCDate(date: Date | undefined): Date {
if (!date) { if (!date) {
@@ -18,13 +19,22 @@ export function makeDayUTCDate(date: Date | undefined): Date {
); );
} }
export function isValidSearchFilter(sf: SearchFilter): boolean { export function isValidSearchFilter(
sf: SearchFilter | null,
): sf is SearchFilter {
if (sf?.dateRange?.from && sf?.dateRange?.to) { if (sf?.dateRange?.from && sf?.dateRange?.to) {
return true; return true;
} }
return false; return false;
} }
export function getNightCountFromSearchFilter(sf: SearchFilter | null): number | null {
if (!isValidSearchFilter(sf)) {
return null;
}
return differenceInDays(sf.dateRange.to!, sf.dateRange.from!);
}
export function makeSearchPeriodRequestFromSearchFilter( export function makeSearchPeriodRequestFromSearchFilter(
sf: SearchFilter, sf: SearchFilter,
): SearchPeriodRequest | null { ): SearchPeriodRequest | null {