4 Commits
Author SHA1 Message Date
Daniel-Garmig 70e483bc45 API - Prices - Updated endpoint for prices "by RoomCategory".
CI / Docker Lint (push) Successful in 31s
2025-10-13 14:58:27 +02:00
Daniel-Garmig 99653371a8 Added config var usage: Use SomniaRooms config vars.
Improvements to app footer and header.
2025-10-13 13:51:27 +02:00
Daniel-Garmig 3e77426d1d Config Vars - Get config vars from API 2025-09-22 20:52:23 +02:00
Daniel-Garmig 550beffba8 Booking page state improvements. 2025-09-08 19:45:46 +02:00
15 changed files with 368 additions and 77 deletions
+16 -8
View File
@@ -9,13 +9,21 @@ export async function APISendCreateBooking(bookingData: NewBooking | null) {
};
}
const rs = await fetcherWithFilter<NewBookingResponse>(
"/api/booking/create",
bookingData,
);
try {
const rs = await fetcherWithFilter<NewBookingResponse>(
"/api/booking/create",
bookingData,
);
return {
bookingResponse: rs,
isError: false,
};
return {
bookingResponse: rs,
isError: false,
};
} catch (error) {
return {
bookingResponse: null,
isError: true,
errorMessage: error,
};
}
}
+28
View File
@@ -0,0 +1,28 @@
import { ConfigVar } from "@/model/ConfigVar";
import { fetcher, fetcherUnwraper } from "./backend";
import useSWR from "swr";
export function useAPIConfigVarList() {
const { data, error, isLoading } = useSWR<ConfigVar[]>(
"/api/config",
fetcherUnwraper,
);
return {
configVarList: data,
isLoading,
isError: error,
};
}
export function useAPIConfigVar(key: string) {
const url = `/api/config/var/${key}`;
const { data, error, isLoading } = useSWR<ConfigVar>(url, fetcher);
return {
configVar: data,
isLoading,
isError: error,
};
}
+1 -1
View File
@@ -40,7 +40,7 @@ export function useAPIRoomPrices(filter: FareFilter | null) {
: null;
const { data, isLoading, error } = useSWR(
request ? ["/api/search/prices", request] : null,
request ? ["/api/search/pricesCategory", request] : null,
([url, request]) => fetcherUnwraperWithFilter<RoomPrice[]>(url, request),
);
+20 -8
View File
@@ -2,11 +2,13 @@ import { Separator } from "../ui/separator";
import { Card, CardContent } from "../ui/card";
import { Typography } from "../ui/typography";
import { UserIcon } from "lucide-react";
import { RoomCategory, RoomCategoryPhoto } from "@/model/RoomCategory";
import { RoomCategory } from "@/model/RoomCategory";
import { IconWithTooltip } from "../ui/icon-number-tooltip";
import roomImg from "@/res/SomniaRoomImage.svg";
import defaultRoomImg from "@/res/SomniaRoomImage.svg";
import { cn } from "@/lib/utils";
import { Icon } from "@iconify-icon/react";
import { useConfigVariable } from "@/utils/hooks";
import { eConfigVarKeys } from "@/model/ConfigVar";
interface RoomItemCardProps {
room: RoomCategory;
@@ -27,11 +29,18 @@ export function RoomItemCard({
room = defaultRoom,
className,
}: RoomItemCardProps) {
const { value: varPhotoAspect } = useConfigVariable(
eConfigVarKeys.varPhotoAspectRatio,
"4/3",
);
//FIXME: Use PhotoOrder.
const mainPhoto: RoomCategoryPhoto | undefined =
room.roomCategoryPhotos.length > 0
? room.roomCategoryPhotos.at(0)
: undefined;
const getRoomMainPhoto = () => {
if (room.roomCategoryPhotos.length < 0) {
return defaultRoomImg;
}
return room.roomCategoryPhotos.at(0)?.path ?? defaultRoomImg;
};
const bedTypeIconList = room.roomCategoryBedTypes.map((e) => {
return (
@@ -45,6 +54,7 @@ export function RoomItemCard({
);
});
const photoAspectRationClassName = "aspect-" + varPhotoAspect;
return (
<Card key={room.id} className={cn(className, "relative", "py-0")}>
{/* <CardHeader className="p-0 m-0 mb-5">
@@ -52,8 +62,10 @@ export function RoomItemCard({
<CardContent className="flex h-full w-full flex-col p-0">
<div className="">
<img
src={mainPhoto?.path ?? roomImg}
className="aspect-4/3 w-full rounded-t-md object-cover"
src={getRoomMainPhoto()}
className={
photoAspectRationClassName + " w-full rounded-t-md object-cover"
}
></img>
<Separator className="my-0 border-2" />
</div>
+122 -9
View File
@@ -1,16 +1,129 @@
import { useConfigVariable } from "@/utils/hooks";
import { Separator } from "./ui/separator";
import { Typography } from "./ui/typography";
import { eConfigVarKeys } from "@/model/ConfigVar";
import {
c_sominaRooms_webpage,
c_somniaRooms_notice_text,
} from "@/utils/Consts";
import { Skeleton } from "./ui/skeleton";
import { Mail, Phone } from "lucide-react";
import { Link } from "react-router-dom";
function AppFooter() {
const { value: varCompanyLogo, isLoading: isLogoLoading } = useConfigVariable(
eConfigVarKeys.varCompanyLogo,
"public/SR2.svg",
);
const { value: varCompanyLogoSize, isLoading: isLogoSizeLoading } =
useConfigVariable(eConfigVarKeys.varCompanyLogoSize, "64px");
const { value: varCompanyName, isLoading: isNameLoading } = useConfigVariable(
eConfigVarKeys.varCompanyName,
"Somnia Rooms",
);
const { value: varCompanyAddress, isLoading: isAddressLoading } =
useConfigVariable(eConfigVarKeys.varCompanyAddress, "");
const { value: varCompanyPhone, isLoading: isPhoneLoading } =
useConfigVariable(eConfigVarKeys.varCompanyPhone, "");
const { value: varCompanyMail, isLoading: isMailLoading } = useConfigVariable(
eConfigVarKeys.varCompanyEmail,
"",
);
const logoSizeClass = "w-[" + varCompanyLogoSize + "]";
const isLoading =
isLogoLoading ||
isLogoSizeLoading ||
isNameLoading ||
isAddressLoading ||
isPhoneLoading ||
isMailLoading;
const getCompanyElement = () => {
if (isLoading) {
return <Skeleton className="h-[64px]" />;
}
return (
<>
<img
src={varCompanyLogo}
alt="company-logo"
className={logoSizeClass}
/>
<div>
<Typography variant={"p_inline"} className="text-nowrap">
{varCompanyName} - &copy; {new Date().getUTCFullYear()}
</Typography>
<Typography variant={"p_inline"}>{varCompanyAddress}</Typography>
</div>
</>
);
};
const getContactElement = () => {
if (isLoading) {
return <Skeleton className="w-[64px]" />;
}
return (
<>
<li className="flex items-center gap-2">
<Phone size={"1.2em"} />
<Typography variant={"p_inline"}>{varCompanyPhone}</Typography>
</li>
<li className="flex items-center gap-2">
<Mail size={"1.2em"} />
<Typography variant={"p_inline"}>{varCompanyMail}</Typography>
</li>
</>
);
};
return (
<div className="hidden flex-col bg-secondary">
<div className="">Created with Love by me :)</div>
<div className="">
<p>Más información</p>
<ul className="list-outside">
<li>Info</li>
<li>Info2</li>
<li>Info3</li>
<footer className="w-full p-8 pt-0">
<Separator className="my-4" />
<div className="flex flex-row flex-wrap items-center justify-center gap-x-12 gap-y-6 text-center lg:justify-between">
<div className="flex flex-row items-center gap-2">
{getCompanyElement()}
</div>
<ul className="flex flex-wrap items-start gap-x-10 gap-y-2 px-4 py-4">
<li>
<ul className="text-start">
<li>
<Typography variant={"largeText"} className="text-start">
Legal
</Typography>
</li>
<li>
<Typography asChild className="hover:text-blue-500">
<Link to={"terms"}>Terms and conditions</Link>
</Typography>
</li>
<li>
<Typography asChild className="hover:text-blue-500">
<Link to={"privacy"}>Privacy policy</Link>
</Typography>
</li>
</ul>
</li>
<li>
<ul>
<li>
<Typography variant={"largeText"} className="text-start">
Contact Us
</Typography>
</li>
{getContactElement()}
</ul>
</li>
</ul>
</div>
</div>
<Separator className="my-4" />
<Typography className="text-center" variant={"mutedText"}>
<a href={c_sominaRooms_webpage}>{c_somniaRooms_notice_text}</a>
</Typography>
</footer>
);
}
+68 -27
View File
@@ -3,37 +3,78 @@ import { Button } from "./ui/button";
import { Card, CardContent } from "./ui/card";
import { Typography } from "./ui/typography";
import { Link } from "react-router-dom";
import { BookAIcon, HomeIcon } from "lucide-react";
import { Bed, BookAIcon, HomeIcon } from "lucide-react";
import { eConfigVarKeys } from "@/model/ConfigVar";
import { Skeleton } from "./ui/skeleton";
import { useConfigVariable } from "@/utils/hooks";
function AppHeader() {
const { value: varCompanyLogo, isLoading: isLogoLoading } = useConfigVariable(
eConfigVarKeys.varCompanyLogo,
"public/SR2.svg",
);
const { value: varCompanyLogoSize, isLoading: isLogoSizeLoading } =
useConfigVariable(eConfigVarKeys.varCompanyLogoSize, "64px");
const { value: varCompanyName, isLoading: isNameLoading } = useConfigVariable(
eConfigVarKeys.varCompanyName,
"Somnia Rooms",
);
const { value: varCompanyWeb, isLoading: isWebLoading } = useConfigVariable(
eConfigVarKeys.varCompanyWebsite,
"#",
);
const isLoading =
isLogoLoading || isNameLoading || isWebLoading || isLogoSizeLoading;
const getElementLogo = () => {
if (isLoading) {
return <Skeleton className="w-[64px]" />;
}
const logoSizeClass = "w-[" + varCompanyLogoSize + "]";
return (
<div className="relative flex flex-row items-center gap-2">
<img src={varCompanyLogo} className={logoSizeClass}></img>
<Typography variant={"largeText"} className="text-nowrap">
{varCompanyName}
</Typography>
<a href={varCompanyWeb} className="absolute h-full w-full"></a>
</div>
);
};
return (
<Card className="mx-10 my-4 flex items-center py-0">
<CardContent className="w-full py-2">
<div className="flex items-center justify-around">
<div className="relative flex flex-row items-center gap-2">
<img src="public/SR2.svg" className="w-[64px]"></img>
<Typography variant={"largeText"}>Somnia Rooms</Typography>
<a
href="https://duck.com"
className="absolute h-full w-full"
></a>
<header>
<Card className="mx-10 my-4 items-center py-0">
<CardContent className="w-full py-2">
<div className="flex flex-col items-center justify-around gap-y-3 md:flex-row">
{getElementLogo()}
<div className="flex flex-row items-center">
<Link to={`home`}>
<Button variant="ghost" className="h-15 hover:cursor-pointer">
<HomeIcon className="mx-auto" />
Home
</Button>
</Link>
<Link to={`search`}>
<Button variant="ghost" className="h-15 hover:cursor-pointer">
<BookAIcon className="mx-auto" />
Reservar
</Button>
</Link>
<Link to={`home`}>
<Button variant="ghost" className="h-15 hover:cursor-pointer">
<Bed className="mx-auto" />
Mi reserva
</Button>
</Link>
</div>
<ModeToggle />
</div>
<Button variant="ghost" className="h-15">
<Link to={`home`}>
<HomeIcon className="mx-auto" />
Home
</Link>
</Button>
<Button variant="ghost" className="h-15">
<Link to={`search`}>
<BookAIcon className="mx-auto" />
Reservar
</Link>
</Button>
<ModeToggle />
</div>
</CardContent>
</Card>
</CardContent>
</Card>
</header>
);
}
+19 -5
View File
@@ -2,22 +2,36 @@ import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
import { cva, VariantProps } from "class-variance-authority";
const separatorVariants = cva(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
{
variants: {
variant: {
default: "my-10",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Separator({
className,
variant,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
}: React.ComponentProps<typeof SeparatorPrimitive.Root> &
VariantProps<typeof separatorVariants>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
className={cn(separatorVariants({ variant, className }))}
{...props}
/>
);
+20
View File
@@ -0,0 +1,20 @@
export type ConfigVar = {
key: string;
value: string;
};
export enum eConfigVarKeys {
varCompanyName = "CompanyName",
varCompanyWebsite = "CompanyWebsite",
varCompanyLogo = "CompanyLogo",
varCompanyLogoSize = "CompanyLogoSize",
varCompanyEmail = "CompanyEmail",
varCompanyPhone = "CompanyPhone",
varCompanyAddress = "CompanyAddress",
varNightAmountForContactDialog = "NightAmountForContactDialog",
varGuestNumberForContactDialog = "GuestNumberForContactDialog",
varContactDialogShowBehaviour = "ContactDialogShowBehaviour",
varShowNonAvailableRooms = "ShowNonAvailableRooms",
varSomniaAttributionNotice = "SomniaAttributionNotice",
varPhotoAspectRatio = "PhotoAspectRatio",
}
+1 -1
View File
@@ -10,7 +10,7 @@ function Root() {
<>
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<AppHeader />
<div className="mx-auto w-full bg-background p-8 text-center text-foreground sm:w-[90%] md:w-[80%]">
<div className="mx-auto w-full min-h-[100vh] bg-background p-8 text-center text-foreground sm:w-[90%] md:w-[80%]">
<Outlet />
</div>
<Toaster />
+23 -7
View File
@@ -19,19 +19,22 @@ import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { z } from "zod/v4";
import { zodResolver } from "@hookform/resolvers/zod";
import { eBookingState } from "@/utils/AppStateManager";
enum PageState {
noData,
showForm,
sending,
created,
error,
}
function BookPage() {
const [pageState, setPageState] = useState<PageState>(PageState.showForm);
const bookingData = useAppStore((state) => state.currentBooking);
const setCustomerData = useAppStore((state) => state.currentBookingSetCustomer);
const appBookingState = useAppStore((state) => state.bookingState);
const setAppBookingState = useAppStore((state) => state.setBookingState);
const setCustomerData = useAppStore(
(state) => state.currentBookingSetCustomer,
);
const navigate = useNavigate();
const bookingForm = useForm<z.infer<typeof BookingFormSchema>>({
@@ -67,16 +70,28 @@ function BookPage() {
if (rs.isError) {
setPageState(PageState.error);
} else {
setAppBookingState(eBookingState.Booked);
navigate("/success", { state: rs.bookingResponse });
}
};
if (!bookingData || !bookingData.customerDetails) {
return (
<Typography variant={"h2"}>You must select some rooms...</Typography>
);
//Redirect to home if no booking is active.
if (
!appBookingState ||
appBookingState.state != eBookingState.Booking ||
!bookingData
) {
navigate("/home");
}
const errorMsg = (
<div>
<Typography variant={"inlineCode"}>
Server error. Try again later!
</Typography>
</div>
);
return (
<>
<Typography variant={"h1"}>Booking Details</Typography>
@@ -150,6 +165,7 @@ function BookPage() {
</Button>
</form>
</Form>
{pageState == PageState.error ? errorMsg : null}
</div>
<Separator className="my-10 lg:hidden" />
<BookingDetails mode="View"></BookingDetails>
+11 -7
View File
@@ -13,14 +13,18 @@ import { LoaderIcon } from "lucide-react";
import "./home-page.css";
import { useAppStore } from "@/store";
import { isValidSearchFilter, makeDayUTCDate } from "@/utils/utils";
import { useConfigVariable } from "@/utils/hooks";
import { eConfigVarKeys } from "@/model/ConfigVar";
function Homepage() {
const appSearchFilter = useAppStore((state) => state.searchFilter);
const setAppSearchFilter = useAppStore((state) => state.setSearchFilter);
const setAppBookingState = useAppStore((state) => state.setBookingState);
const initAppBooking = useAppStore((state) => state.initCurrentBooking);
const { roomCategoryList, isLoading, isError } = useAPIRoomCategoryList();
const { value: varCompanyName } = useConfigVariable(eConfigVarKeys.varCompanyName, "");
const { roomCategoryList, isLoading: isLoadingCategories, isError } = useAPIRoomCategoryList();
const navigate = useNavigate();
const onBtSearchClick = (filter: SearchFilter) => {
@@ -44,15 +48,15 @@ function Homepage() {
});
return (
<>
<Typography variant={"h1"}>Somnia Rooms</Typography>
<Separator className="my-10" />
<Typography variant={"h1"}>{varCompanyName}</Typography>
<Separator />
<SearchFilterCard
onSearchClick={onBtSearchClick}
initialsearchFilter={appSearchFilter}
/>
<Separator className="my-10" />
<Separator />
<div className="homePageRoomGrid grid gap-4">{roomList}</div>
{isLoading && (
{isLoadingCategories && (
<>
<Skeleton className="mx-auto flex h-[200px] w-full flex-col items-center justify-center">
<LoaderIcon />
@@ -62,8 +66,8 @@ function Homepage() {
</Skeleton>
</>
)}
<Separator className="my-10" />
<BackendConnectionStatus isLoading={isLoading} isError={isError} />
<Separator />
<BackendConnectionStatus isLoading={isLoadingCategories} isError={isError} />
</>
);
}
+7 -4
View File
@@ -55,6 +55,7 @@ function SearchPage() {
};
const onBookingContinue = () => {
setAppBookingState(eBookingState.Booking);
navigate("/book");
};
@@ -93,7 +94,7 @@ function SearchPage() {
const pageContentLoaded = (
<div className="2xl:grid 2xl:grid-cols-60/40 2xl:gap-6">
<div className="flex flex-col gap-5">{roomList}</div>
<Separator className="my-10 2xl:hidden" />
<Separator className="2xl:hidden" />
<BookingDetails
onBookingConfirm={onBookingContinue}
mode={"Book"}
@@ -102,7 +103,9 @@ function SearchPage() {
);
const getPageContent = () => {
const isSearched = appBookingState.state == eBookingState.Searched;
const isSearched =
appBookingState.state == eBookingState.Searched ||
appBookingState.state == eBookingState.Booking;
if (!isSearched) {
return pageContentSearch;
}
@@ -121,9 +124,9 @@ function SearchPage() {
onSearchClick={onBtSearchClick}
initialsearchFilter={appSearchFilter}
/>
<Separator className="my-10" />
<Separator />
{getPageContent()}
<Separator className="my-10" />
<Separator />
<BackendConnectionStatus isLoading={isLoading} isError={isError} />
</div>
);
+3
View File
@@ -0,0 +1,3 @@
export const c_somniaRooms_notice_text = "Powered by SomniaRooms, an Open-Source hospitality booking platform!";
export const c_sominaRooms_webpage = "https://somnia.dev";
+17
View File
@@ -0,0 +1,17 @@
import { useAPIConfigVarList } from "@/api/api-configVars";
import { getConfigVarFromList } from "./utils";
import { eConfigVarKeys } from "@/model/ConfigVar";
export function useConfigVariable(key: eConfigVarKeys, defaultValue: string) {
const { configVarList, isLoading } = useAPIConfigVarList();
if (isLoading) {
return {
value: defaultValue,
isLoading: true,
};
}
const configVar = getConfigVarFromList(configVarList, key);
return { value: configVar?.value ?? defaultValue, isLoading: isLoading };
}
+12
View File
@@ -1,5 +1,6 @@
import { AvailabilityFilter, FareFilter } from "@/model/RoomSearch";
import { SearchFilter } from "@/model/InternalTypes";
import { ConfigVar } from "@/model/ConfigVar";
export function makeDayUTCDate(date: Date | undefined): Date {
if (!date) {
@@ -50,3 +51,14 @@ export function makeFareFilterFromSearchFilter(
roomCategory: roomCategoryId,
};
}
export function getConfigVarFromList(
varList: ConfigVar[] | undefined,
key: string,
): ConfigVar | undefined {
if (!varList) {
return undefined;
}
return varList.find((v) => v.key === key);
}