Contact flow:
- Added contact page for bookings over API limit. - Added ContactUs dialog to redirect customers to contact page.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,11 @@ import { useState } from "react";
|
||||
import { SearchFilter } from "@/model/InternalTypes";
|
||||
import { DateRange } from "react-day-picker";
|
||||
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;
|
||||
|
||||
@@ -23,21 +28,22 @@ export function SearchFilterCard({
|
||||
const [searchFilter, setSearchFilter] = useState<SearchFilter | null>(
|
||||
initialsearchFilter ?? null,
|
||||
);
|
||||
const [isContactDialogOpen, setIsContactDialogOpen] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const {
|
||||
isLoading: isNightCountLoading,
|
||||
isOverApiMax: nightCountCheckIsOverApi,
|
||||
shouldShowContactDialog: nightCountShowDialog,
|
||||
} = useCheckForNightCount(searchFilter);
|
||||
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 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 = () => {
|
||||
if (!searchFilter || !searchFilter.dateRange) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Uh oh! Invalid Search.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!searchFilter.dateRange.from || !searchFilter.dateRange.to) {
|
||||
if (!isValidSearchFilter(searchFilter)) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Uh oh! Invalid Search.",
|
||||
@@ -46,12 +52,29 @@ export function SearchFilterCard({
|
||||
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) {
|
||||
onSearchClick(searchFilter);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Element updates.
|
||||
// Handle internal Searchfilter updates.
|
||||
const updateDateRange = (newRange: DateRange) => {
|
||||
setSearchFilter({
|
||||
...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 =
|
||||
searchFilter == null ? (
|
||||
<DatePickerWithRange
|
||||
@@ -75,22 +111,33 @@ export function SearchFilterCard({
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="m-0 mb-5 p-0">
|
||||
<Typography variant={"h2"} as="h1" className="mx-5 py-2">
|
||||
Reserva ahora
|
||||
</Typography>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{datePickerElement}
|
||||
<Separator className="my-4" />
|
||||
<Button size={"lg"} onClick={btSearchClick}>
|
||||
<SearchIcon className="me-2 text-primary-foreground" />
|
||||
<Typography variant={"h3"} className="text-primary-foreground">
|
||||
Buscar
|
||||
<>
|
||||
<ContactUsDialog
|
||||
isOpen={isContactDialogOpen}
|
||||
onCancelClick={onContactDialogClose}
|
||||
cancelButtonText="Continue Search"
|
||||
/>
|
||||
<Card>
|
||||
<CardHeader className="m-0 mb-5 p-0">
|
||||
<Typography variant={"h2"} as="h1" className="mx-5 py-2">
|
||||
Reserva ahora
|
||||
</Typography>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@ import { Button } from "../ui/button";
|
||||
import { useAPIRoomCategoryList } from "@/api/api-roomCategories";
|
||||
import { IconWithTooltip } from "../ui/icon-number-tooltip";
|
||||
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 BookingDetailsModes = "Book" | "View";
|
||||
@@ -18,10 +23,42 @@ export function BookingDetails({
|
||||
onBookingConfirm?: OnBookingClickFunction;
|
||||
mode: BookingDetailsModes;
|
||||
}) {
|
||||
const [isContactDialogOpen, setIsContactDialogOpen] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const bookingData = useAppStore((state) => state.currentBooking);
|
||||
const { roomCategoryList } = useAPIRoomCategoryList();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
isLoading: isGuestNumberCheckLoading,
|
||||
isOverApiMax: guestNumberCheckIsOverApi,
|
||||
shouldShowContactDialog: guestNumberShowDialog,
|
||||
} = useCheckForGuestNumber(bookingData);
|
||||
|
||||
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) {
|
||||
onBookingConfirm();
|
||||
}
|
||||
@@ -87,37 +124,51 @@ export function BookingDetails({
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Typography variant={"h2"} as="h2">
|
||||
Your Booking
|
||||
</Typography>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Typography variant={"h3"}>Your Dates</Typography>
|
||||
<Typography variant={"largeText"}>
|
||||
Arrival: {bookingData.arrivalDate.toLocaleDateString()} - Departure:{" "}
|
||||
{bookingData.departureDate.toLocaleDateString()} <br></br>
|
||||
Number of nights:{" "}
|
||||
{differenceInDays(bookingData.departureDate, bookingData.arrivalDate)}
|
||||
</Typography>
|
||||
<Separator className="my-2"></Separator>
|
||||
<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}>
|
||||
<Typography
|
||||
variant={"largeText"}
|
||||
className="text-primary-foreground"
|
||||
<>
|
||||
<ContactUsDialog
|
||||
isOpen={isContactDialogOpen}
|
||||
cancelButtonText="Continue booking"
|
||||
onCancelClick={onContactDialogClose}
|
||||
/>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Typography variant={"h2"} as="h2">
|
||||
Your Booking
|
||||
</Typography>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Typography variant={"h3"}>Your Dates</Typography>
|
||||
<Typography variant={"largeText"}>
|
||||
Arrival: {bookingData.arrivalDate.toLocaleDateString()} - Departure:{" "}
|
||||
{bookingData.departureDate.toLocaleDateString()} <br></br>
|
||||
Number of nights:{" "}
|
||||
{differenceInDays(
|
||||
bookingData.departureDate,
|
||||
bookingData.arrivalDate,
|
||||
)}
|
||||
</Typography>
|
||||
<Separator className="my-2"></Separator>
|
||||
<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>
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Typography
|
||||
variant={"largeText"}
|
||||
className="text-primary-foreground"
|
||||
>
|
||||
Book it!
|
||||
</Typography>
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import Homepage from "./routes/home-page";
|
||||
import SearchPage from "./routes/search-page";
|
||||
import BookPage from "./routes/book-page";
|
||||
import SuccessPage from "./routes/success-page";
|
||||
import ContactPage from "./routes/contact-page";
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@@ -35,6 +36,10 @@ const router = createBrowserRouter([
|
||||
path: "/success",
|
||||
element: <SuccessPage />,
|
||||
},
|
||||
{
|
||||
path: "/contact",
|
||||
element: <ContactPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -6,15 +6,17 @@ export type ConfigVar = {
|
||||
export enum eConfigVarKeys {
|
||||
varCompanyName = "CompanyName",
|
||||
varCompanyWebsite = "CompanyWebsite",
|
||||
varCompanyContactFormUrl = "CompanyContactFormUrl",
|
||||
varCompanyLogo = "CompanyLogo",
|
||||
varCompanyLogoSize = "CompanyLogoSize",
|
||||
varCompanyEmail = "CompanyEmail",
|
||||
varCompanyPhone = "CompanyPhone",
|
||||
varCompanyAddress = "CompanyAddress",
|
||||
varNightAmountForContactDialog = "NightAmountForContactDialog",
|
||||
varNightCountForContactDialog = "NightCountForContactDialog",
|
||||
varGuestNumberForContactDialog = "GuestNumberForContactDialog",
|
||||
varContactDialogShowBehaviour = "ContactDialogShowBehaviour",
|
||||
varShowNonAvailableRooms = "ShowNonAvailableRooms",
|
||||
varSomniaAttributionNotice = "SomniaAttributionNotice",
|
||||
varPhotoAspectRatio = "PhotoAspectRatio",
|
||||
varAPIMaxAllowedNightAmount = "MaxAllowedNightAmount",
|
||||
varAPIMaxAllowedGuestNumber = "MaxAllowedGuestNumber",
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { z } from "zod/v4";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { eBookingState } from "@/utils/AppStateManager";
|
||||
import { useBookingStateDispacher } from "@/utils/hooks";
|
||||
import { ConstsNav } from "@/utils/Consts";
|
||||
|
||||
enum PageState {
|
||||
showForm,
|
||||
@@ -72,7 +73,7 @@ function BookPage() {
|
||||
setPageState(PageState.error);
|
||||
} else {
|
||||
setBookingStateBooked();
|
||||
navigate("/success", { state: rs.bookingResponse });
|
||||
navigate(ConstsNav.page_Success, { state: rs.bookingResponse });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -82,7 +83,7 @@ function BookPage() {
|
||||
appBookingState.state != eBookingState.Booking ||
|
||||
!bookingData
|
||||
) {
|
||||
navigate("/home");
|
||||
navigate(ConstsNav.page_Home);
|
||||
}
|
||||
|
||||
const errorMsg = (
|
||||
|
||||
@@ -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;
|
||||
@@ -11,10 +11,10 @@ import { useNavigate } from "react-router-dom";
|
||||
import { LoaderIcon } from "lucide-react";
|
||||
import "./home-page.css";
|
||||
import { useAppStore } from "@/store";
|
||||
import { isValidSearchFilter } from "@/utils/utils";
|
||||
import { useBookingStateDispacher } from "@/utils/hooks";
|
||||
import { eConfigVarKeys } from "@/model/ConfigVar";
|
||||
import { useConfigVariable } from "@/utils/apiHooks";
|
||||
import { ConstsNav } from "@/utils/Consts";
|
||||
|
||||
function Homepage() {
|
||||
const appSearchFilter = useAppStore((state) => state.searchFilter);
|
||||
@@ -32,13 +32,9 @@ function Homepage() {
|
||||
} = useAPIRoomCategoryList();
|
||||
|
||||
const onBtSearchClick = (filter: SearchFilter) => {
|
||||
if (!isValidSearchFilter(filter)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBookingStateSearched(filter);
|
||||
|
||||
navigate("/search", { state: filter });
|
||||
navigate(ConstsNav.page_Search, { state: filter });
|
||||
};
|
||||
|
||||
const roomList = roomCategoryList?.map((element: RoomCategory) => {
|
||||
|
||||
@@ -9,12 +9,10 @@ import { Typography } from "@/components/ui/typography";
|
||||
import { SearchFilter } from "@/model/InternalTypes";
|
||||
import { eBookingState } from "@/utils/AppStateManager";
|
||||
import { useAppStore } from "@/store";
|
||||
import {
|
||||
isValidSearchFilter,
|
||||
makeSearchPeriodRequestFromSearchFilter,
|
||||
} from "@/utils/utils";
|
||||
import { makeSearchPeriodRequestFromSearchFilter } from "@/utils/utils";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
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: 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;
|
||||
|
||||
const onBtSearchClick = (newFilter: SearchFilter) => {
|
||||
if (!isValidSearchFilter(newFilter)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBookingStateSearched(newFilter);
|
||||
};
|
||||
|
||||
const onBookingContinue = () => {
|
||||
setBookingStateBooking();
|
||||
navigate("/book");
|
||||
navigate(ConstsNav.page_Book);
|
||||
};
|
||||
|
||||
const roomList =
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Typography } from "@/components/ui/typography";
|
||||
import { NewBookingResponse } from "@/model/Bookings";
|
||||
import { useAppStore } from "@/store";
|
||||
import { ConstsNav } from "@/utils/Consts";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
@@ -15,14 +16,14 @@ function SuccessPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (location.state == null) {
|
||||
navigate("/home");
|
||||
navigate(ConstsNav.page_Home);
|
||||
}
|
||||
setPageState(location.state);
|
||||
}, [location, navigate]);
|
||||
|
||||
const handleBtBackClick = () => {
|
||||
resetBookingState();
|
||||
navigate("/home");
|
||||
navigate(ConstsNav.page_Home);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
+21
-2
@@ -1,3 +1,22 @@
|
||||
|
||||
export const c_somniaRooms_notice_text = "Powered by SomniaRooms, an Open-Source hospitality booking platform!";
|
||||
export const c_somniaRooms_notice_text =
|
||||
"Powered by SomniaRooms, an Open-Source hospitality booking platform!";
|
||||
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
@@ -35,20 +35,12 @@ export function useConfigVariableAsNumber(
|
||||
isLoading: boolean;
|
||||
} {
|
||||
const rs = useConfigVariable(key, defaultValue.toString());
|
||||
if (rs.isLoading) {
|
||||
return {
|
||||
value: defaultValue,
|
||||
isLoading: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: parseFloat(rs.value),
|
||||
isLoading: false,
|
||||
value: rs.isLoading ? defaultValue : parseFloat(rs.value),
|
||||
isLoading: rs.isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export function useRoomCategory(roomCategoryId: number): {
|
||||
value: RoomCategory | null;
|
||||
isLoading: boolean;
|
||||
|
||||
+91
-1
@@ -1,8 +1,11 @@
|
||||
import { SearchFilter } from "@/model/InternalTypes";
|
||||
import { useAppStore } from "@/store";
|
||||
import { makeDayUTCDate } from "./utils";
|
||||
import { getNightCountFromSearchFilter, makeDayUTCDate } from "./utils";
|
||||
import { useCallback } from "react";
|
||||
import { eBookingState } from "./AppStateManager";
|
||||
import { useConfigVariableAsNumber } from "./apiHooks";
|
||||
import { eConfigVarKeys } from "@/model/ConfigVar";
|
||||
import { NewBooking } from "@/model/Bookings";
|
||||
|
||||
export function useBookingStateDispacher(): {
|
||||
setBookingStateSearched: (sf: SearchFilter) => void;
|
||||
@@ -39,3 +42,90 @@ export function useBookingStateDispacher(): {
|
||||
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
@@ -1,6 +1,7 @@
|
||||
import { SearchPeriodRequest } from "@/model/RoomSearch";
|
||||
import { SearchFilter } from "@/model/InternalTypes";
|
||||
import { ConfigVar } from "@/model/ConfigVar";
|
||||
import { differenceInDays } from "date-fns";
|
||||
|
||||
export function makeDayUTCDate(date: Date | undefined): 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) {
|
||||
return true;
|
||||
}
|
||||
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(
|
||||
sf: SearchFilter,
|
||||
): SearchPeriodRequest | null {
|
||||
|
||||
Reference in New Issue
Block a user