8 Commits
Author SHA1 Message Date
Daniel-Garmig 899d27f468 Updated Prettier formatting
CI / Docker Lint (push) Successful in 29s
2026-01-13 15:00:22 +01:00
Daniel-Garmig 90fa82234d Removed Toast component 2026-01-13 14:14:22 +01:00
Daniel-Garmig b8ec6f7bdd General improvements:
- Search control - Improved error messages.
- Fixed lint errors.
2026-01-13 14:12:58 +01:00
Daniel-Garmig 94312de6e2 Contact flow:
- Added contact page for bookings over API limit.
- Added ContactUs dialog to redirect customers to contact page.
2026-01-07 22:25:27 +01:00
Daniel-Garmig d4094d09c6 Updated shadcn-ui components. 2026-01-05 22:01:53 +01:00
Daniel-Garmig 42437881c4 Updated deps (minor) 2026-01-05 20:35:39 +01:00
Daniel-Garmig 5d2eb2e635 General improvements in booking state management. 2026-01-05 20:11:42 +01:00
Daniel-Garmig 2b3d1eafd3 API - Use the new Prices API. 2026-01-05 15:08:05 +01:00
51 changed files with 3149 additions and 1724 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"endOfLine": "lf", "endOfLine": "auto",
"plugins": ["prettier-plugin-tailwindcss"], "plugins": ["prettier-plugin-tailwindcss"],
"tailwindStylesheet": "./src/index.css" "tailwindStylesheet": "./src/index.css"
} }
+10 -10
View File
@@ -2,20 +2,20 @@
User app for SomniaRooms. User app for SomniaRooms.
## Docker Usage: ## Podman Usage:
### Dev ### Dev
**Build Image:** **Build Image:**
``` ```
docker build -t somniarooms-app:dev --target final-dev . podman build -t somniarooms-app:dev --target final-dev .
``` ```
**Docker Run Image:** **Podman Run Image:**
``` ```
docker run -d -it --rm -v ${PWD}:/app -v /app/node_modules -p 3000:3000 --name somniarooms-app somniarooms-app:dev podman run -d -it --rm -v ${PWD}:/app -v /app/node_modules -p 3000:3000 --name somniarooms-app somniarooms-app:dev
``` ```
Notas: Notas:
@@ -28,24 +28,24 @@ Notas:
**Build Image** **Build Image**
``` ```
docker build -t somniarooms-app:prod --target final-prod . podman build -t somniarooms-app:prod --target final-prod .
``` ```
**Docker Run** **Podman Run**
``` ```
docker run -d -it -p 8080:80 --name somniarooms-app somniarooms-app:prod podman run -d -it -p 8080:80 --name somniarooms-app somniarooms-app:prod
``` ```
**Docker run with env vars:** **Podman run with env vars:**
``` ```
docker run -d -it -p 8080:80 --name somniarooms-app -e SOMNIAROOMS_BACKEND_HOST=https://somniaapi.example.org/ -e SOMNIAROOMS_BACKEND_PORT=443 somniarooms-app:prod podman run -d -it -p 8080:80 --name somniarooms-app -e SOMNIAROOMS_BACKEND_HOST=https://somniaapi.example.org/ -e SOMNIAROOMS_BACKEND_PORT=443 somniarooms-app:prod
``` ```
## Env Vars. ## Env Vars.
As this is distributed as a docker image ready to use, you must set some env variables to configure the app: As this is distributed as a image ready to use, you must set some env variables to configure the app:
| Var | Default Value | Description | | Var | Default Value | Description |
| :------------------------: | :----------------------------: | :------------------------------------------------------------------------------------------: | | :------------------------: | :----------------------------: | :------------------------------------------------------------------------------------------: |
+1 -1
View File
@@ -18,4 +18,4 @@
"hooks": "@/hooks" "hooks": "@/hooks"
}, },
"iconLibrary": "lucide" "iconLibrary": "lucide"
} }
+2230 -1020
View File
File diff suppressed because it is too large Load Diff
+31 -38
View File
@@ -11,55 +11,48 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.1.1", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-checkbox": "^1.3.2", "@tailwindcss/vite": "^4.1.18",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-toast": "^1.2.14",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.8",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"lucide-react": "^0.513.0", "lucide-react": "^0.562.0",
"radix-ui": "^1.4.3",
"react": "^18.2.0", "react": "^18.2.0",
"react-day-picker": "^9.7.0", "react-day-picker": "^9.13.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.57.0", "react-hook-form": "^7.70.0",
"react-router-dom": "^6.23.0", "react-router-dom": "^6.30.2",
"swr": "^2.3.3", "swr": "^2.3.8",
"tailwind-merge": "^3.3.0", "tailwind-merge": "^3.4.0",
"zod": "^3.25.57", "zod": "^3.25.76",
"zustand": "^5.0.5" "zustand": "^5.0.9"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.2.9", "@eslint/compat": "^2.0.0",
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.3",
"@eslint/js": "^9.28.0", "@eslint/js": "^9.39.2",
"@iconify-icon/react": "^3.0.0", "@iconify-icon/react": "^3.0.3",
"@tailwindcss/postcss": "^4.1.8", "@tailwindcss/postcss": "^4.1.18",
"@types/node": "^22.15.30", "@types/node": "^22.15.30",
"@types/react": "^18.2.66", "@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22", "@types/react-dom": "^18.2.22",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@typescript-eslint/eslint-plugin": "^8.34.0", "@typescript-eslint/eslint-plugin": "^8.52.0",
"@typescript-eslint/parser": "^8.34.0", "@typescript-eslint/parser": "^8.52.0",
"@vitejs/plugin-react-swc": "^3.10.1", "@vitejs/plugin-react-swc": "^4.2.2",
"eslint": "^9.28.0", "eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.5", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.26",
"globals": "^16.2.0", "globals": "^17.0.0",
"postcss": "^8.5.4", "postcss": "^8.5.6",
"prettier": "^3.5.3", "prettier": "^3.7.4",
"prettier-plugin-tailwindcss": "^0.6.12", "prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^4.1.8", "tailwindcss": "^4.1.18",
"tw-animate-css": "^1.3.4", "tw-animate-css": "^1.4.0",
"typescript": "^5.8.3", "typescript": "^5.9.3",
"vite": "^6.3.5" "vite": "^6.4.1"
} }
} }
+1 -1
View File
@@ -1,5 +1,5 @@
export default { export default {
plugins: { plugins: {
'@tailwindcss/postcss': {}, "@tailwindcss/postcss": {},
}, },
}; };
+8 -25
View File
@@ -1,24 +1,14 @@
import { fetcherUnwraperWithFilter } from "./backend"; import { fetcherUnwraperWithFilter } from "./backend";
import useSWR from "swr"; import useSWR from "swr";
import { import {
AvailabilityFilter, SearchPeriodRequest,
AvailabilityRequest,
FareFilter,
FareRequest,
RoomAvailabity, RoomAvailabity,
RoomPrice, RoomPriceSearchResponse,
} from "@/model/RoomSearch"; } from "@/model/RoomSearch";
export function useAPIRoomAvailability(filter: AvailabilityFilter | null) { export function useAPIRoomAvailability(filter: SearchPeriodRequest | null) {
const request: AvailabilityRequest | null = filter
? {
startDate: filter.startDate,
endDate: filter.endDate,
}
: null;
const { data, isLoading, error } = useSWR( const { data, isLoading, error } = useSWR(
request ? ["/api/search/availability", request] : null, filter ? ["/api/search/availability", filter] : null,
([url, request]) => ([url, request]) =>
fetcherUnwraperWithFilter<RoomAvailabity[]>(url, request), fetcherUnwraperWithFilter<RoomAvailabity[]>(url, request),
); );
@@ -30,18 +20,11 @@ export function useAPIRoomAvailability(filter: AvailabilityFilter | null) {
}; };
} }
export function useAPIRoomPrices(filter: FareFilter | null) { export function useAPIRoomPrices(filter: SearchPeriodRequest | null) {
const request: FareRequest | null = filter
? {
roomCategory: filter.roomCategory,
startDate: filter.startDate,
endDate: filter.endDate,
}
: null;
const { data, isLoading, error } = useSWR( const { data, isLoading, error } = useSWR(
request ? ["/api/search/pricesCategory", request] : null, filter ? ["/api/search/prices", filter] : null,
([url, request]) => fetcherUnwraperWithFilter<RoomPrice[]>(url, request), ([url, request]) =>
fetcherUnwraperWithFilter<RoomPriceSearchResponse[]>(url, request),
); );
return { return {
@@ -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 -17
View File
@@ -1,4 +1,3 @@
import { useAPIRoomCategory } from "@/api/api-roomCategories";
import { Card, CardContent } from "../ui/card"; import { Card, CardContent } from "../ui/card";
import { Typography } from "../ui/typography"; import { Typography } from "../ui/typography";
import { import {
@@ -11,14 +10,14 @@ import {
} from "../ui/table"; } from "../ui/table";
import { Skeleton } from "../ui/skeleton"; import { Skeleton } from "../ui/skeleton";
import { useAppStore } from "@/store"; import { useAppStore } from "@/store";
import { useAPIRoomPrices } from "@/api/api-roomSearch";
import { NumberSelector } from "../ui/number-selector"; import { NumberSelector } from "../ui/number-selector";
import { NewBooking, NewBookingRoom } from "@/model/Bookings"; import { NewBooking, NewBookingRoom } from "@/model/Bookings";
import { RoomItemCard } from "./room-item-card"; import { RoomItemCard } from "./room-item-card";
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 { makeFareFilterFromSearchFilter } from "@/utils/utils"; import { makeSearchPeriodRequestFromSearchFilter } from "@/utils/utils";
import { SearchFilter } from "@/model/InternalTypes"; import { SearchFilter } from "@/model/InternalTypes";
import { useRoomCategory, useRoomPrice } from "@/utils/apiHooks";
function RoomBooking({ function RoomBooking({
roomCategoryId, roomCategoryId,
@@ -77,24 +76,17 @@ function RoomBooking({
const canAddMoreRooms: boolean = availableAmount > totalSelectAmount; const canAddMoreRooms: boolean = availableAmount > totalSelectAmount;
//This seems a little bit overkill, but IDK. //This seems a little bit overkill, but IDK.
const fareFilter = makeFareFilterFromSearchFilter( const fareFilter = makeSearchPeriodRequestFromSearchFilter(appSearchFilter);
appSearchFilter,
const { value: roomCategory, isLoading: isLoadingCategory } =
useRoomCategory(roomCategoryId);
const { value: roomPriceList, isLoading: isLoadingPrices } = useRoomPrice(
fareFilter,
roomCategoryId, roomCategoryId,
); );
const {
roomCategory,
isLoading: isLoadingCategory,
isError: isErrorCategory,
} = useAPIRoomCategory(roomCategoryId);
const {
roomPriceList,
isLoading: isLoadingPrices,
isError: isErrorPrices,
} = useAPIRoomPrices(fareFilter);
const isLoading = isLoadingCategory || isLoadingPrices; const isLoading = isLoadingCategory || isLoadingPrices;
const isError = isErrorCategory || isErrorPrices; const isError = roomCategory == null;
if (isLoading) { if (isLoading) {
return <Skeleton className="mx-auto h-[150px] w-[600px]" />; return <Skeleton className="mx-auto h-[150px] w-[600px]" />;
+1 -1
View File
@@ -7,7 +7,7 @@ import { IconWithTooltip } from "../ui/icon-number-tooltip";
import defaultRoomImg from "@/res/SomniaRoomImage.svg"; import defaultRoomImg from "@/res/SomniaRoomImage.svg";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Icon } from "@iconify-icon/react"; import { Icon } from "@iconify-icon/react";
import { useConfigVariable } from "@/utils/hooks"; import { useConfigVariable } from "@/utils/apiHooks";
import { eConfigVarKeys } from "@/model/ConfigVar"; import { eConfigVarKeys } from "@/model/ConfigVar";
interface RoomItemCardProps { interface RoomItemCardProps {
+88 -32
View File
@@ -8,7 +8,11 @@ import { SearchIcon } from "lucide-react";
import { useState } from "react"; 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 { 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,42 +27,74 @@ export function SearchFilterCard({
const [searchFilter, setSearchFilter] = useState<SearchFilter | null>( const [searchFilter, setSearchFilter] = useState<SearchFilter | null>(
initialsearchFilter ?? null, initialsearchFilter ?? null,
); );
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [isContactDialogOpen, setIsContactDialogOpen] =
useState<boolean>(false);
const { toast } = useToast(); const {
isLoading: isNightCountLoading,
isOverApiMax: nightCountCheckIsOverApi,
shouldShowContactDialog: nightCountShowDialog,
} = useCheckForNightCount(searchFilter);
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.
const btSearchClick = () => { const btSearchClick = () => {
if (!searchFilter || !searchFilter.dateRange) { if (!isValidSearchFilter(searchFilter)) {
toast({ setErrorMsg("Select a valid 'from' and 'to' dates!");
variant: "destructive",
title: "Uh oh! Invalid Search.",
});
return; return;
} }
if (!searchFilter.dateRange.from || !searchFilter.dateRange.to) { //Check for max night count.
toast({ //API Config vars values must be loaded before this! Unexpected behaviours may happen otherwise.
variant: "destructive", if (nightCountCheckIsOverApi) {
title: "Uh oh! Invalid Search.", //Navigate to contact page and show info.
description: "You should select a 'from' and 'to' dates", navigate(ConstsNav.page_Contact, {
state: ConstsTexts.navToContact_maxAllowedNightCount,
}); });
return; 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({ const newSearchFilter = {
...searchFilter, ...searchFilter,
dateRange: newRange, dateRange: newRange,
}); };
setSearchFilter(newSearchFilter);
if (isValidSearchFilter(newSearchFilter)) {
setErrorMsg(null);
}
}; };
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
@@ -74,23 +110,43 @@ export function SearchFilterCard({
></DatePickerWithRange> ></DatePickerWithRange>
); );
const errorMsgLabel = (
<Typography variant={"errorText"} className="mt-2 text-lg">
{errorMsg}
</Typography>
);
const searchHasErrors = errorMsg != null;
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}
{searchHasErrors && errorMsgLabel}
<Separator className="my-4" />
<Button
size={"lg"}
onClick={btSearchClick}
disabled={isSearchButtonDisabled}
variant={searchHasErrors ? "destructive" : "default"}
>
<SearchIcon className="me-2 text-primary-foreground" />
<Typography variant={"h3"} className="text-primary-foreground">
Buscar
</Typography>
</Button>
</CardContent>
</Card>
</>
); );
} }
+3 -3
View File
@@ -1,4 +1,3 @@
import { useConfigVariable } from "@/utils/hooks";
import { Separator } from "./ui/separator"; import { Separator } from "./ui/separator";
import { Typography } from "./ui/typography"; import { Typography } from "./ui/typography";
import { eConfigVarKeys } from "@/model/ConfigVar"; import { eConfigVarKeys } from "@/model/ConfigVar";
@@ -9,6 +8,7 @@ import {
import { Skeleton } from "./ui/skeleton"; import { Skeleton } from "./ui/skeleton";
import { Mail, Phone } from "lucide-react"; import { Mail, Phone } from "lucide-react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useConfigVariable } from "@/utils/apiHooks";
function AppFooter() { function AppFooter() {
const { value: varCompanyLogo, isLoading: isLogoLoading } = useConfigVariable( const { value: varCompanyLogo, isLoading: isLogoLoading } = useConfigVariable(
@@ -41,7 +41,7 @@ function AppFooter() {
isMailLoading; isMailLoading;
const getCompanyElement = () => { const getCompanyElement = () => {
if (isLoading) { if (isLoading) {
return <Skeleton className="h-[64px]" />; return <Skeleton className="h-16" />;
} }
return ( return (
@@ -63,7 +63,7 @@ function AppFooter() {
const getContactElement = () => { const getContactElement = () => {
if (isLoading) { if (isLoading) {
return <Skeleton className="w-[64px]" />; return <Skeleton className="w-16" />;
} }
return ( return (
+1 -1
View File
@@ -6,7 +6,7 @@ import { Link } from "react-router-dom";
import { Bed, BookAIcon, HomeIcon } from "lucide-react"; import { Bed, BookAIcon, HomeIcon } from "lucide-react";
import { eConfigVarKeys } from "@/model/ConfigVar"; import { eConfigVarKeys } from "@/model/ConfigVar";
import { Skeleton } from "./ui/skeleton"; import { Skeleton } from "./ui/skeleton";
import { useConfigVariable } from "@/utils/hooks"; import { useConfigVariable } from "@/utils/apiHooks";
function AppHeader() { function AppHeader() {
const { value: varCompanyLogo, isLoading: isLogoLoading } = useConfigVariable( const { value: varCompanyLogo, isLoading: isLogoLoading } = useConfigVariable(
+84 -38
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();
} }
@@ -43,9 +80,7 @@ export function BookingDetails({
} }
const roomList = bookingData.rooms.map((room) => { const roomList = bookingData.rooms.map((room) => {
const category = roomCategoryList.roomCategoryList const category = roomCategoryList?.find((c) => c.id == room.roomCategory);
?.filter((c) => c.id == room.roomCategory)
.at(0);
const photo = category?.roomCategoryPhotos.at(0); const photo = category?.roomCategoryPhotos.at(0);
return ( return (
@@ -89,40 +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( <CardContent>
bookingData.departureDate, <Typography variant={"h3"}>Your Dates</Typography>
bookingData.arrivalDate, <Typography variant={"largeText"}>
) + 1} Arrival: {bookingData.arrivalDate.toLocaleDateString()} - Departure:{" "}
</Typography> {bookingData.departureDate.toLocaleDateString()} <br></br>
<Separator className="my-2"></Separator> Number of nights:{" "}
<Typography variant={"h3"}>Your Rooms</Typography> {differenceInDays(
<div className="">{roomList}</div> bookingData.departureDate,
<Separator className="my-2"></Separator> bookingData.arrivalDate,
<Typography variant={"h3"}>Total Price</Typography> )}
<Typography variant={"h3"}>{totalPrice / 100} €</Typography> </Typography>
{mode == "Book" && ( <Separator className="my-2"></Separator>
<Button className="my-2" onClick={onBtBookingClick}> <Typography variant={"h3"}>Your Rooms</Typography>
<Typography <div className="">{roomList}</div>
variant={"largeText"} <Separator className="my-2"></Separator>
className="text-primary-foreground" <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>
</>
); );
} }
+2 -2
View File
@@ -16,8 +16,8 @@ export function ModeToggle() {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger>
<Button variant="outline" size="icon"> <Button variant="outline" size="icon">
<SunIcon className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" /> <SunIcon className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<MoonIcon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> <MoonIcon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Toggle theme</span> <span className="sr-only">Toggle theme</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
+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(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"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 bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 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-sm text-muted-foreground", 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,
};
-47
View File
@@ -1,47 +0,0 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span";
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export { Badge, badgeVariants };
+11 -8
View File
@@ -1,5 +1,5 @@
import * as React from "react"; import * as React from "react";
import { Slot } from "@radix-ui/react-slot"; import { Slot as SlotPrimitive } from "radix-ui";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -9,14 +9,13 @@ const buttonVariants = cva(
{ {
variants: { variants: {
variant: { variant: {
default: default: "bg-primary text-primary-foreground hover:bg-primary/90",
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive: destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline: outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary: secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
@@ -26,6 +25,8 @@ const buttonVariants = cva(
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4", lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9", icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
}, },
}, },
defaultVariants: { defaultVariants: {
@@ -37,19 +38,21 @@ const buttonVariants = cva(
function Button({ function Button({
className, className,
variant, variant = "default",
size, size = "default",
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"button"> & }: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean; asChild?: boolean;
}) { }) {
const Comp = asChild ? Slot : "button"; const Comp = asChild ? SlotPrimitive.Slot : "button";
return ( return (
<Comp <Comp
data-slot="button" data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
/> />
+13 -3
View File
@@ -4,7 +4,11 @@ import {
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
} from "lucide-react"; } from "lucide-react";
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"; import {
DayPicker,
getDefaultClassNames,
type DayButton,
} from "react-day-picker";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button"; import { Button, buttonVariants } from "@/components/ui/button";
@@ -71,7 +75,10 @@ function Calendar({
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md", "relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root, defaultClassNames.dropdown_root,
), ),
dropdown: cn("absolute inset-0 opacity-0", defaultClassNames.dropdown), dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown,
),
caption_label: cn( caption_label: cn(
"select-none font-medium", "select-none font-medium",
captionLayout === "label" captionLayout === "label"
@@ -95,7 +102,10 @@ function Calendar({
defaultClassNames.week_number, defaultClassNames.week_number,
), ),
day: cn( day: cn(
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none", "relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
defaultClassNames.day, defaultClassNames.day,
), ),
range_start: cn( range_start: cn(
+1 -1
View File
@@ -20,7 +20,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="card-header" data-slot="card-header"
className={cn( className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className, className,
)} )}
{...props} {...props}
+2 -2
View File
@@ -1,5 +1,5 @@
import * as React from "react"; import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; import { Checkbox as CheckboxPrimitive } from "radix-ui";
import { CheckIcon } from "lucide-react"; import { CheckIcon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -19,7 +19,7 @@ function Checkbox({
> >
<CheckboxPrimitive.Indicator <CheckboxPrimitive.Indicator
data-slot="checkbox-indicator" data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none" className="grid place-content-center text-current transition-none"
> >
<CheckIcon className="size-3.5" /> <CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
+3 -3
View File
@@ -90,7 +90,7 @@ export function DatePickerWithRange({
id="dateFrom" id="dateFrom"
variant="outline" variant="outline"
data-empty={!dateRange} data-empty={!dateRange}
className="w-[100%] justify-start text-left font-normal data-[empty=true]:text-muted-foreground" className="w-full justify-start text-left font-normal data-[empty=true]:text-muted-foreground"
> >
<CalendarIcon /> <CalendarIcon />
{dateRange?.from ? ( {dateRange?.from ? (
@@ -117,11 +117,11 @@ export function DatePickerWithRange({
id="dateTo" id="dateTo"
variant={"outline"} variant={"outline"}
data-empty={!dateRange} data-empty={!dateRange}
className="w-[100%] justify-start text-left font-normal data-[empty=true]:text-muted-foreground" className="w-full justify-start text-left font-normal data-[empty=true]:text-muted-foreground"
> >
<CalendarIcon className="mr-2 h-4 w-4" /> <CalendarIcon className="mr-2 h-4 w-4" />
{dateRange?.to ? ( {dateRange?.to ? (
format(dateRange.to, "LLL dd, y") format(dateRange.to, "PPP")
) : ( ) : (
<span>Pick a date</span> <span>Pick a date</span>
)} )}
+4 -4
View File
@@ -1,5 +1,5 @@
import * as React from "react"; import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -40,7 +40,7 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md", "z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className, className,
)} )}
{...props} {...props}
@@ -209,7 +209,7 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger" data-slot="dropdown-menu-sub-trigger"
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", "flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className, className,
)} )}
{...props} {...props}
@@ -228,7 +228,7 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
className={cn( className={cn(
"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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg", "z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className, className,
)} )}
{...props} {...props}
+6 -4
View File
@@ -1,6 +1,6 @@
import * as React from "react"; import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label"; import { Label as LabelPrimitive, Slot as SlotPrimitive } from "radix-ui";
import { Slot } from "@radix-ui/react-slot";
import { import {
Controller, Controller,
FormProvider, FormProvider,
@@ -102,12 +102,14 @@ function FormLabel({
); );
} }
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) { function FormControl({
...props
}: React.ComponentProps<typeof SlotPrimitive.Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = const { error, formItemId, formDescriptionId, formMessageId } =
useFormField(); useFormField();
return ( return (
<Slot <SlotPrimitive.Slot
data-slot="form-control" data-slot="form-control"
id={formItemId} id={formItemId}
aria-describedby={ aria-describedby={
+1 -1
View File
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
"flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30", "h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50", "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40", "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className, className,
+1 -1
View File
@@ -1,5 +1,5 @@
import * as React from "react"; import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label"; import { Label as LabelPrimitive } from "radix-ui";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
+10 -10
View File
@@ -1,18 +1,18 @@
import * as React from "react" import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover" import { Popover as PopoverPrimitive } from "radix-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Popover({ function Popover({
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) { }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} /> return <PopoverPrimitive.Root data-slot="popover" {...props} />;
} }
function PopoverTrigger({ function PopoverTrigger({
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) { }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} /> return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
} }
function PopoverContent({ function PopoverContent({
@@ -28,19 +28,19 @@ function PopoverContent({
align={align} align={align}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden", "z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className className,
)} )}
{...props} {...props}
/> />
</PopoverPrimitive.Portal> </PopoverPrimitive.Portal>
) );
} }
function PopoverAnchor({ function PopoverAnchor({
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) { }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} /> return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
} }
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
+1 -1
View File
@@ -1,5 +1,5 @@
import * as React from "react"; import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator"; import { Separator as SeparatorPrimitive } from "radix-ui";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { cva, VariantProps } from "class-variance-authority"; import { cva, VariantProps } from "class-variance-authority";
-125
View File
@@ -1,125 +0,0 @@
import * as React from "react";
import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { XIcon } from "lucide-react";
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-100 flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className,
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-(--radix-toast-swipe-end-x) data-[swipe=move]:translate-x-(--radix-toast-swipe-move-x) data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full sm:data-[state=open]:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: { variant: "default" },
},
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-hidden focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 hover:group-[.destructive]:border-destructive/30 hover:group-[.destructive]:bg-destructive hover:group-[.destructive]:text-destructive-foreground focus:group-[.destructive]:ring-destructive",
className,
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-hidden focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 hover:group-[.destructive]:text-red-50 focus:group-[.destructive]:ring-red-400 focus:group-[.destructive]:ring-offset-red-600",
className,
)}
toast-close=""
{...props}
>
<XIcon className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};
-33
View File
@@ -1,33 +0,0 @@
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast";
import { useToast } from "@/components/ui/use-toast";
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}
+3 -3
View File
@@ -1,5 +1,5 @@
import * as React from "react"; import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip"; import { Tooltip as TooltipPrimitive } from "radix-ui";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -44,13 +44,13 @@ function TooltipContent({
data-slot="tooltip-content" data-slot="tooltip-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md bg-primary px-3 py-1.5 text-xs text-balance text-primary-foreground", "z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className, className,
)} )}
{...props} {...props}
> >
{children} {children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-primary fill-primary" /> <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content> </TooltipPrimitive.Content>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
); );
+6 -3
View File
@@ -3,7 +3,7 @@
import * as React from "react"; import * as React from "react";
import { VariantProps, cva } from "class-variance-authority"; import { VariantProps, cva } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Slot } from "@radix-ui/react-slot"; import { Slot as SlotPrimitive } from "radix-ui";
const typographyVariants = cva("text-foreground", { const typographyVariants = cva("text-foreground", {
variants: { variants: {
@@ -24,6 +24,7 @@ const typographyVariants = cva("text-foreground", {
largeText: "text-lg font-semibold", largeText: "text-lg font-semibold",
smallText: "text-sm leading-none font-medium", smallText: "text-sm leading-none font-medium",
mutedText: "text-muted-foreground text-sm", mutedText: "text-muted-foreground text-sm",
errorText: "text-destructive text-sm font-normal",
}, },
}, },
defaultVariants: { defaultVariants: {
@@ -51,11 +52,13 @@ const variantElementMap: Record<
smallText: "small", smallText: "small",
lead: "p", lead: "p",
mutedText: "p", mutedText: "p",
errorText: "p",
ul: "ul", ul: "ul",
}; };
export interface TypographyProps export interface TypographyProps
extends React.HTMLAttributes<HTMLElement>, extends
React.HTMLAttributes<HTMLElement>,
VariantProps<typeof typographyVariants> { VariantProps<typeof typographyVariants> {
asChild?: boolean; asChild?: boolean;
as?: string; as?: string;
@@ -64,7 +67,7 @@ export interface TypographyProps
const Typography = React.forwardRef<HTMLElement, TypographyProps>( const Typography = React.forwardRef<HTMLElement, TypographyProps>(
({ className, variant, as, asChild, ...props }, ref) => { ({ className, variant, as, asChild, ...props }, ref) => {
const Comp = asChild const Comp = asChild
? Slot ? SlotPrimitive.Slot
: (as ?? (variant ? variantElementMap[variant] : undefined) ?? "div"); : (as ?? (variant ? variantElementMap[variant] : undefined) ?? "div");
return ( return (
<Comp <Comp
-158
View File
@@ -1,158 +0,0 @@
import * as React from "react";
import type { ToastActionElement, ToastProps } from "src/components/ui/toast";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| { type: ActionType["ADD_TOAST"]; toast: ToasterToast }
| { type: ActionType["UPDATE_TOAST"]; toast: Partial<ToasterToast> }
| { type: ActionType["DISMISS_TOAST"]; toastId?: ToasterToast["id"] }
| { type: ActionType["REMOVE_TOAST"]; toastId?: ToasterToast["id"] };
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({ type: "REMOVE_TOAST", toastId: toastId });
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
};
case "DISMISS_TOAST": {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined ? { ...t, open: false } : t,
),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return { ...state, toasts: [] };
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({ type: "UPDATE_TOAST", toast: { ...props, id } });
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return { id: id, dismiss, update };
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { useToast, toast };
+3 -7
View File
@@ -1,11 +1,9 @@
@import 'tailwindcss'; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme inline { @theme inline {
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
@@ -78,7 +76,6 @@
} }
:root { :root {
--radius: 0.625rem; --radius: 0.625rem;
--background: oklch(1 0 0); --background: oklch(1 0 0);
@@ -145,7 +142,6 @@
} }
.dark { .dark {
--background: oklch(0.147 0.004 49.25); --background: oklch(0.147 0.004 49.25);
--foreground: oklch(0.985 0.001 106.423); --foreground: oklch(0.985 0.001 106.423);
@@ -221,8 +217,8 @@
/* SomniaRooms Extended CSS */ /* SomniaRooms Extended CSS */
@utility grid-cols-40/60 { @utility grid-cols-40/60 {
grid-template-columns: 40% 60%; grid-template-columns: 40% 60%;
} }
@utility grid-cols-60/40 { @utility grid-cols-60/40 {
grid-template-columns: 60% 40%; grid-template-columns: 60% 40%;
} }
+3 -3
View File
@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
} }
+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",
} }
+1 -1
View File
@@ -18,4 +18,4 @@ export type RoomCategoryBedType = {
amount: number; amount: number;
bedTypeName: string; bedTypeName: string;
bedTypeIcon: string; bedTypeIcon: string;
} };
+4 -16
View File
@@ -8,24 +8,12 @@ export type RoomPrice = {
price: number; price: number;
}; };
export type AvailabilityRequest = { export type SearchPeriodRequest = {
startDate: string; startDate: string;
endDate: string; endDate: string;
}; };
export type AvailabilityFilter = { export type RoomPriceSearchResponse = {
startDate: string; roomCategoryId: number;
endDate: string; prices: RoomPrice[];
};
export type FareRequest = {
roomCategory: number;
startDate: string;
endDate: string;
};
export type FareFilter = {
roomCategory: number;
startDate: string;
endDate: string;
}; };
+1 -3
View File
@@ -3,17 +3,15 @@ import "./Root.css";
import AppHeader from "../components/app-header"; import AppHeader from "../components/app-header";
import AppFooter from "../components/app-footer"; import AppFooter from "../components/app-footer";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/toaster";
function Root() { function Root() {
return ( return (
<> <>
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme"> <ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<AppHeader /> <AppHeader />
<div className="mx-auto w-full min-h-[100vh] bg-background p-8 text-center text-foreground sm:w-[90%] md:w-[80%]"> <div className="mx-auto min-h-screen w-full bg-background p-8 text-center text-foreground sm:w-[90%] md:w-[80%]">
<Outlet /> <Outlet />
</div> </div>
<Toaster />
<AppFooter /> <AppFooter />
</ThemeProvider> </ThemeProvider>
</> </>
+17 -12
View File
@@ -20,6 +20,9 @@ import { useNavigate } from "react-router-dom";
import { z } from "zod/v4"; 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 { ConstsNav } from "@/utils/Consts";
import { NewBookingCustomerDetails } from "@/model/Bookings";
enum PageState { enum PageState {
showForm, showForm,
@@ -31,11 +34,11 @@ function BookPage() {
const [pageState, setPageState] = useState<PageState>(PageState.showForm); const [pageState, setPageState] = useState<PageState>(PageState.showForm);
const bookingData = useAppStore((state) => state.currentBooking); const bookingData = useAppStore((state) => state.currentBooking);
const appBookingState = useAppStore((state) => state.bookingState); const appBookingState = useAppStore((state) => state.bookingState);
const setAppBookingState = useAppStore((state) => state.setBookingState);
const setCustomerData = useAppStore( const setCustomerData = useAppStore(
(state) => state.currentBookingSetCustomer, (state) => state.currentBookingSetCustomer,
); );
const navigate = useNavigate(); const navigate = useNavigate();
const { setBookingStateBooked } = useBookingStateDispacher();
const bookingForm = useForm<z.infer<typeof BookingFormSchema>>({ const bookingForm = useForm<z.infer<typeof BookingFormSchema>>({
resolver: zodResolver(BookingFormSchema), resolver: zodResolver(BookingFormSchema),
@@ -51,12 +54,13 @@ function BookPage() {
if (bookingData == null) { if (bookingData == null) {
return; return;
} }
const newCustomerData = bookingData.customerDetails; const newCustomerData: NewBookingCustomerDetails = {
newCustomerData.name = values.name; ...bookingData.customerDetails,
newCustomerData.surname = values.surname; name: values.name,
newCustomerData.email = values.email; surname: values.surname,
newCustomerData.phoneNumber = values.phone; email: values.email,
phoneNumber: values.phone,
};
setCustomerData(newCustomerData); setCustomerData(newCustomerData);
setPageState(PageState.sending); setPageState(PageState.sending);
@@ -70,18 +74,19 @@ function BookPage() {
if (rs.isError) { if (rs.isError) {
setPageState(PageState.error); setPageState(PageState.error);
} else { } else {
setAppBookingState(eBookingState.Booked); setBookingStateBooked();
navigate("/success", { state: rs.bookingResponse }); navigate(ConstsNav.page_Success, { state: rs.bookingResponse });
} }
}; };
//Redirect to home if no booking is active. //Redirect to home if no booking is active.
if ( if (
!appBookingState || !appBookingState ||
appBookingState.state != eBookingState.Booking || !bookingData ||
!bookingData (appBookingState.state != eBookingState.Booking &&
appBookingState.state != eBookingState.Booked)
) { ) {
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;
+20 -23
View File
@@ -7,40 +7,34 @@ import { Skeleton } from "@/components/ui/skeleton";
import { Typography } from "@/components/ui/typography"; import { Typography } from "@/components/ui/typography";
import { RoomCategory } from "@/model/RoomCategory"; import { RoomCategory } from "@/model/RoomCategory";
import { SearchFilter } from "@/model/InternalTypes"; import { SearchFilter } from "@/model/InternalTypes";
import { eBookingState } from "@/utils/AppStateManager";
import { useNavigate } from "react-router-dom"; 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, makeDayUTCDate } from "@/utils/utils"; import { useBookingStateDispacher } from "@/utils/hooks";
import { useConfigVariable } from "@/utils/hooks";
import { eConfigVarKeys } from "@/model/ConfigVar"; import { eConfigVarKeys } from "@/model/ConfigVar";
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);
const setAppSearchFilter = useAppStore((state) => state.setSearchFilter);
const setAppBookingState = useAppStore((state) => state.setBookingState);
const initAppBooking = useAppStore((state) => state.initCurrentBooking);
const { value: varCompanyName } = useConfigVariable(eConfigVarKeys.varCompanyName, "");
const { roomCategoryList, isLoading: isLoadingCategories, isError } = useAPIRoomCategoryList();
const navigate = useNavigate(); const navigate = useNavigate();
const { setBookingStateSearched } = useBookingStateDispacher();
const { value: varCompanyName } = useConfigVariable(
eConfigVarKeys.varCompanyName,
"",
);
const {
roomCategoryList,
isLoading: isLoadingCategories,
isError,
} = useAPIRoomCategoryList();
const onBtSearchClick = (filter: SearchFilter) => { const onBtSearchClick = (filter: SearchFilter) => {
if (!isValidSearchFilter(filter)) { setBookingStateSearched(filter);
return;
}
setAppSearchFilter(filter); navigate(ConstsNav.page_Search, { state: filter });
setAppBookingState(eBookingState.Searched);
initAppBooking(
makeDayUTCDate(filter.dateRange.from),
makeDayUTCDate(filter.dateRange.to),
);
navigate("/search", { state: filter });
}; };
const roomList = roomCategoryList?.map((element: RoomCategory) => { const roomList = roomCategoryList?.map((element: RoomCategory) => {
@@ -67,7 +61,10 @@ function Homepage() {
</> </>
)} )}
<Separator /> <Separator />
<BackendConnectionStatus isLoading={isLoadingCategories} isError={isError} /> <BackendConnectionStatus
isLoading={isLoadingCategories}
isError={isError}
/>
</> </>
); );
} }
+10 -23
View File
@@ -9,29 +9,27 @@ 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,
makeAvailabilityFilterFromSearchFilter,
makeDayUTCDate,
} from "@/utils/utils";
import { useNavigate } from "react-router-dom"; 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: 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ó.
function SearchPage() { function SearchPage() {
const appBookingState = useAppStore((state) => state.bookingState); const appBookingState = useAppStore((state) => state.bookingState);
const setAppBookingState = useAppStore((state) => state.setBookingState);
const appSearchFilter = useAppStore((state) => state.searchFilter); const appSearchFilter = useAppStore((state) => state.searchFilter);
const setAppSearchFilter = useAppStore((state) => state.setSearchFilter);
const initAppBooking = useAppStore((state) => state.initCurrentBooking);
const navigate = useNavigate(); const navigate = useNavigate();
const { setBookingStateSearched, setBookingStateBooking } =
useBookingStateDispacher();
const { const {
roomAvailabilityList, roomAvailabilityList,
isLoading: isLoadingAvailability, isLoading: isLoadingAvailability,
isError: isErrorAvailability, isError: isErrorAvailability,
} = useAPIRoomAvailability( } = useAPIRoomAvailability(
makeAvailabilityFilterFromSearchFilter(appSearchFilter) ?? null, makeSearchPeriodRequestFromSearchFilter(appSearchFilter) ?? null,
); );
const isLoading = isLoadingAvailability; const isLoading = isLoadingAvailability;
const isError = isErrorAvailability; const isError = isErrorAvailability;
@@ -39,24 +37,13 @@ function SearchPage() {
const hasRooms = const hasRooms =
roomAvailabilityList?.length && roomAvailabilityList?.length > 0; roomAvailabilityList?.length && roomAvailabilityList?.length > 0;
//TODOME: Hay que ver para mover esto a un método común.
const onBtSearchClick = (newFilter: SearchFilter) => { const onBtSearchClick = (newFilter: SearchFilter) => {
if (!isValidSearchFilter(newFilter)) { setBookingStateSearched(newFilter);
return;
}
setAppSearchFilter(newFilter);
setAppBookingState(eBookingState.Searched);
initAppBooking(
makeDayUTCDate(newFilter.dateRange.from),
makeDayUTCDate(newFilter.dateRange.to),
);
}; };
const onBookingContinue = () => { const onBookingContinue = () => {
setAppBookingState(eBookingState.Booking); setBookingStateBooking();
navigate("/book"); navigate(ConstsNav.page_Book);
}; };
const roomList = const roomList =
+7 -3
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";
@@ -13,16 +14,19 @@ function SuccessPage() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
if (pageState == null && location.state != null) {
setPageState(location.state);
}
useEffect(() => { useEffect(() => {
if (location.state == null) { if (location.state == null) {
navigate("/home"); navigate(ConstsNav.page_Home);
} }
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 for 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!",
};
+97
View File
@@ -0,0 +1,97 @@
import { useAPIConfigVarList } from "@/api/api-configVars";
import { getConfigVarFromList } from "./utils";
import { eConfigVarKeys } from "@/model/ConfigVar";
import { useAPIRoomCategoryList } from "@/api/api-roomCategories";
import { useAPIRoomPrices } from "@/api/api-roomSearch";
import { RoomCategory } from "@/model/RoomCategory";
import { RoomPrice, SearchPeriodRequest } from "@/model/RoomSearch";
export function useConfigVariable(
key: eConfigVarKeys,
defaultValue: string,
): {
value: string;
isLoading: boolean;
} {
const { configVarList, isLoading } = useAPIConfigVarList();
if (isLoading) {
return {
value: defaultValue,
isLoading: true,
};
}
const configVar = getConfigVarFromList(configVarList, key);
return { value: configVar?.value ?? defaultValue, isLoading: isLoading };
}
export function useConfigVariableAsNumber(
key: eConfigVarKeys,
defaultValue: number,
): {
value: number;
isLoading: boolean;
} {
const rs = useConfigVariable(key, defaultValue.toString());
return {
value: rs.isLoading ? defaultValue : parseFloat(rs.value),
isLoading: rs.isLoading,
};
}
export function useRoomCategory(roomCategoryId: number): {
value: RoomCategory | null;
isLoading: boolean;
} {
const { roomCategoryList, isLoading, isError } = useAPIRoomCategoryList();
if (isLoading) {
return {
value: null,
isLoading: true,
};
}
if (isError) {
return {
value: null,
isLoading: false,
};
}
const roomCategoryItem = roomCategoryList?.find(
(i) => i.id == roomCategoryId,
);
return { value: roomCategoryItem ?? null, isLoading: isLoading };
}
export function useRoomPrice(
filter: SearchPeriodRequest | null,
roomCategoryId: number,
): {
value: RoomPrice[];
isLoading: boolean;
} {
const { roomPriceList, isLoading, isError } = useAPIRoomPrices(filter);
if (isLoading) {
return {
value: [],
isLoading: true,
};
}
if (isError) {
return {
value: [],
isLoading: false,
};
}
const roomPrice = roomPriceList?.find(
(i) => i.roomCategoryId == roomCategoryId,
);
return { value: roomPrice?.prices ?? [], isLoading: isLoading };
}
+123 -9
View File
@@ -1,17 +1,131 @@
import { useAPIConfigVarList } from "@/api/api-configVars"; import { SearchFilter } from "@/model/InternalTypes";
import { getConfigVarFromList } from "./utils"; import { useAppStore } from "@/store";
import { getNightCountFromSearchFilter, makeDayUTCDate } from "./utils";
import { useCallback } from "react";
import { eBookingState } from "./AppStateManager";
import { useConfigVariableAsNumber } from "./apiHooks";
import { eConfigVarKeys } from "@/model/ConfigVar"; import { eConfigVarKeys } from "@/model/ConfigVar";
import { NewBooking } from "@/model/Bookings";
export function useConfigVariable(key: eConfigVarKeys, defaultValue: string) { export function useBookingStateDispacher(): {
const { configVarList, isLoading } = useAPIConfigVarList(); setBookingStateSearched: (sf: SearchFilter) => void;
setBookingStateBooking: () => void;
setBookingStateBooked: () => void;
} {
const setAppBookingState = useAppStore((state) => state.setBookingState);
const setAppSearchFilter = useAppStore((state) => state.setSearchFilter);
const initAppBooking = useAppStore((state) => state.initCurrentBooking);
if (isLoading) { const fnSearched = useCallback(
(sf: SearchFilter) => {
setAppSearchFilter(sf);
setAppBookingState(eBookingState.Searched);
initAppBooking(
makeDayUTCDate(sf.dateRange.from),
makeDayUTCDate(sf.dateRange.to),
);
},
[setAppBookingState, setAppSearchFilter, initAppBooking],
);
const fnBooking = useCallback(() => {
setAppBookingState(eBookingState.Booking);
}, [setAppBookingState]);
const fnBooked = useCallback(() => {
setAppBookingState(eBookingState.Booked);
}, [setAppBookingState]);
return {
setBookingStateSearched: fnSearched,
setBookingStateBooking: fnBooking,
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 { return {
value: defaultValue, isOverApiMax: false,
isLoading: true, shouldShowContactDialog: false,
isLoading: areVariablesLoading,
}; };
} }
const configVar = getConfigVarFromList(configVarList, key);
return { value: configVar?.value ?? defaultValue, isLoading: isLoading }; 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,
};
} }
+16 -19
View File
@@ -1,6 +1,7 @@
import { AvailabilityFilter, FareFilter } 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,16 +19,27 @@ 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 makeAvailabilityFilterFromSearchFilter( 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, sf: SearchFilter,
): AvailabilityFilter | null { ): SearchPeriodRequest | null {
if (!isValidSearchFilter(sf)) { if (!isValidSearchFilter(sf)) {
return null; return null;
} }
@@ -37,21 +49,6 @@ export function makeAvailabilityFilterFromSearchFilter(
}; };
} }
export function makeFareFilterFromSearchFilter(
sf: SearchFilter,
roomCategoryId: number,
): FareFilter | null {
if (!isValidSearchFilter(sf) || roomCategoryId < 0) {
return null;
}
return {
startDate: sf.dateRange.from!.toISOString(),
endDate: sf.dateRange.to!.toISOString(),
roomCategory: roomCategoryId,
};
}
export function getConfigVarFromList( export function getConfigVarFromList(
varList: ConfigVar[] | undefined, varList: ConfigVar[] | undefined,
key: string, key: string,
+1 -3
View File
@@ -3,9 +3,7 @@
// ... // ...
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": [ "@/*": ["./src/*"]
"./src/*"
]
} }
// ... // ...
} }
+1 -1
View File
@@ -1,7 +1,7 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc"; import react from "@vitejs/plugin-react-swc";
import path from "path"; import path from "path";
import tailwindcss from "@tailwindcss/vite" import tailwindcss from "@tailwindcss/vite";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({