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 { 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,6 +111,12 @@ export function SearchFilterCard({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<ContactUsDialog
|
||||||
|
isOpen={isContactDialogOpen}
|
||||||
|
onCancelClick={onContactDialogClose}
|
||||||
|
cancelButtonText="Continue Search"
|
||||||
|
/>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="m-0 mb-5 p-0">
|
<CardHeader className="m-0 mb-5 p-0">
|
||||||
<Typography variant={"h2"} as="h1" className="mx-5 py-2">
|
<Typography variant={"h2"} as="h1" className="mx-5 py-2">
|
||||||
@@ -84,7 +126,11 @@ export function SearchFilterCard({
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
{datePickerElement}
|
{datePickerElement}
|
||||||
<Separator className="my-4" />
|
<Separator className="my-4" />
|
||||||
<Button size={"lg"} onClick={btSearchClick}>
|
<Button
|
||||||
|
size={"lg"}
|
||||||
|
onClick={btSearchClick}
|
||||||
|
disabled={isSearchButtonDisabled}
|
||||||
|
>
|
||||||
<SearchIcon className="me-2 text-primary-foreground" />
|
<SearchIcon className="me-2 text-primary-foreground" />
|
||||||
<Typography variant={"h3"} className="text-primary-foreground">
|
<Typography variant={"h3"} className="text-primary-foreground">
|
||||||
Buscar
|
Buscar
|
||||||
@@ -92,5 +138,6 @@ export function SearchFilterCard({
|
|||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +124,12 @@ export function BookingDetails({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<ContactUsDialog
|
||||||
|
isOpen={isContactDialogOpen}
|
||||||
|
cancelButtonText="Continue booking"
|
||||||
|
onCancelClick={onContactDialogClose}
|
||||||
|
/>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Typography variant={"h2"} as="h2">
|
<Typography variant={"h2"} as="h2">
|
||||||
@@ -99,7 +142,10 @@ export function BookingDetails({
|
|||||||
Arrival: {bookingData.arrivalDate.toLocaleDateString()} - Departure:{" "}
|
Arrival: {bookingData.arrivalDate.toLocaleDateString()} - Departure:{" "}
|
||||||
{bookingData.departureDate.toLocaleDateString()} <br></br>
|
{bookingData.departureDate.toLocaleDateString()} <br></br>
|
||||||
Number of nights:{" "}
|
Number of nights:{" "}
|
||||||
{differenceInDays(bookingData.departureDate, bookingData.arrivalDate)}
|
{differenceInDays(
|
||||||
|
bookingData.departureDate,
|
||||||
|
bookingData.arrivalDate,
|
||||||
|
)}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Separator className="my-2"></Separator>
|
<Separator className="my-2"></Separator>
|
||||||
<Typography variant={"h3"}>Your Rooms</Typography>
|
<Typography variant={"h3"}>Your Rooms</Typography>
|
||||||
@@ -108,7 +154,11 @@ export function BookingDetails({
|
|||||||
<Typography variant={"h3"}>Total Price</Typography>
|
<Typography variant={"h3"}>Total Price</Typography>
|
||||||
<Typography variant={"h3"}>{totalPrice / 100} €</Typography>
|
<Typography variant={"h3"}>{totalPrice / 100} €</Typography>
|
||||||
{mode == "Book" && (
|
{mode == "Book" && (
|
||||||
<Button className="my-2" onClick={onBtBookingClick}>
|
<Button
|
||||||
|
className="my-2"
|
||||||
|
onClick={onBtBookingClick}
|
||||||
|
disabled={isGuestNumberCheckLoading}
|
||||||
|
>
|
||||||
<Typography
|
<Typography
|
||||||
variant={"largeText"}
|
variant={"largeText"}
|
||||||
className="text-primary-foreground"
|
className="text-primary-foreground"
|
||||||
@@ -119,5 +169,6 @@ export function BookingDetails({
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 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 />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -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 { 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) => {
|
||||||
|
|||||||
@@ -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,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
@@ -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
@@ -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 {
|
return {
|
||||||
value: defaultValue,
|
value: rs.isLoading ? defaultValue : parseFloat(rs.value),
|
||||||
isLoading: true,
|
isLoading: rs.isLoading,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
|
||||||
value: parseFloat(rs.value),
|
|
||||||
isLoading: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function useRoomCategory(roomCategoryId: number): {
|
export function useRoomCategory(roomCategoryId: number): {
|
||||||
value: RoomCategory | null;
|
value: RoomCategory | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
|||||||
+91
-1
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user