Skip to content

Commit 8669cac

Browse files
authored
feat: hotel prices & booking (deep-link OTAs, optional live rates, exact-hotel links) (#35)
Adds hotel prices & booking for lodging places: a region-filtered OTA deep-link compare list + official-site row (Tier 1), optional config-gated LiteAPI live "from / night" rates (Tier 2), and exact-hotel deep links resolved for free via Wikidata external ids + Trip.com typeahead (Phase D). New `@openmapx/core` hotel hooks/types/store/utils, the `integrations/hotels` integration, and the place-panel UI.
1 parent 0db3298 commit 8669cac

49 files changed

Lines changed: 3430 additions & 3 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/hotel-prices-booking.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@openmapx/core": minor
3+
---
4+
5+
Add hotel prices & booking support. New region-filtered OTA deep-link compare list, optional LiteAPI live "from / night" rates (config-gated), and exact-hotel deep links resolved for free via Wikidata external ids + Trip.com typeahead. Exposes the hotel hooks (`useHotelProviders`, `useResolvedHotelProviders`, `useHotelConfig`, `useHotelOffers`, `useHotelSearchStore`), the `buildHotelOpenUrl` util, `isLodging`, and the hotel wire types/endpoints.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// apps/web/src/components/panels/place/HotelCompareList.tsx
2+
"use client";
3+
4+
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
5+
import VerifiedIcon from "@mui/icons-material/Verified";
6+
import Box from "@mui/material/Box";
7+
import Typography from "@mui/material/Typography";
8+
import {
9+
bareDomain,
10+
buildHotelOpenUrl,
11+
type HotelProviderInfo,
12+
type Place,
13+
useHotelSearchStore,
14+
useOfficialBookingUrl,
15+
} from "@openmapx/core";
16+
import { useTranslations } from "next-intl";
17+
import type { ReactNode } from "react";
18+
import { TEAL } from "@/lib/theme";
19+
import { BrandMark } from "../shared/BrandMark";
20+
21+
function Row({
22+
onClick,
23+
mark,
24+
primary,
25+
secondary,
26+
}: {
27+
onClick: () => void;
28+
mark: ReactNode;
29+
primary: ReactNode;
30+
secondary?: ReactNode;
31+
}) {
32+
return (
33+
<Box
34+
role="button"
35+
tabIndex={0}
36+
onClick={onClick}
37+
onKeyDown={(e) => {
38+
if (e.key === "Enter" || e.key === " ") {
39+
e.preventDefault();
40+
onClick();
41+
}
42+
}}
43+
sx={{
44+
display: "flex",
45+
alignItems: "center",
46+
gap: 1.5,
47+
py: 1.25,
48+
px: 1,
49+
mx: -1,
50+
borderRadius: 1.5,
51+
cursor: "pointer",
52+
"&:hover": { bgcolor: "action.hover" },
53+
}}
54+
>
55+
{mark}
56+
<Box sx={{ flex: 1, minWidth: 0 }}>
57+
<Typography variant="body2" sx={{ fontWeight: 500 }}>
58+
{primary}
59+
</Typography>
60+
{secondary && (
61+
<Typography variant="caption" sx={{ color: "text.secondary" }}>
62+
{secondary}
63+
</Typography>
64+
)}
65+
</Box>
66+
<OpenInNewIcon sx={{ fontSize: 16, color: "text.disabled", flexShrink: 0 }} />
67+
</Box>
68+
);
69+
}
70+
71+
/**
72+
* "All options" compare list for a lodging place. Renders an Official-site row
73+
* (place.website) when present, then the region-filtered OTAs. Each opens the
74+
* provider's pre-filled search via the backend hand-off. Mirrors the food
75+
* delivery provider list. Tier 1 shows no prices (deep-link only).
76+
*/
77+
export function HotelCompareList({
78+
place,
79+
providers,
80+
countryCode,
81+
}: {
82+
place: Place;
83+
providers: HotelProviderInfo[];
84+
countryCode?: string;
85+
}) {
86+
const t = useTranslations("place");
87+
const { checkIn, checkOut, adults, rooms } = useHotelSearchStore();
88+
89+
const { data: officialUrl } = useOfficialBookingUrl(
90+
{ name: place.name, website: place.website, checkIn, checkOut, adults, rooms },
91+
Boolean(place.website),
92+
);
93+
94+
const openProvider = (id: string) => {
95+
const [lng, lat] = place.coordinates;
96+
const url = buildHotelOpenUrl(id, {
97+
name: place.name,
98+
city: place.city,
99+
countryCode,
100+
lat,
101+
lng,
102+
address: place.address,
103+
checkIn,
104+
checkOut,
105+
adults,
106+
rooms,
107+
wikidata: place.osmTags?.wikidata,
108+
});
109+
window.open(url, "_blank", "noopener,noreferrer");
110+
};
111+
112+
const openOfficial = () => {
113+
const dest = officialUrl ?? place.website;
114+
if (dest) window.open(dest, "_blank", "noopener,noreferrer");
115+
};
116+
117+
return (
118+
<Box>
119+
{place.website && (
120+
<Row
121+
onClick={openOfficial}
122+
mark={<VerifiedIcon sx={{ fontSize: 28, color: TEAL, flexShrink: 0 }} />}
123+
primary={place.name}
124+
secondary={officialUrl ? t("officialSiteDated") : t("officialSite")}
125+
/>
126+
)}
127+
{providers.map((p) => (
128+
<Row
129+
key={p.id}
130+
onClick={() => openProvider(p.id)}
131+
mark={<BrandMark branding={{ name: p.name, color: p.color }} size={28} />}
132+
primary={p.name}
133+
secondary={p.domain || bareDomain(p.homepage)}
134+
/>
135+
))}
136+
</Box>
137+
);
138+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// apps/web/src/components/panels/place/HotelPriceBadge.tsx
2+
"use client";
3+
4+
import Box from "@mui/material/Box";
5+
import Typography from "@mui/material/Typography";
6+
import type { HotelOffer } from "@openmapx/core";
7+
import { useLocale, useTranslations } from "next-intl";
8+
9+
/** "from €75 / night" reference price from a live offer (Tier 2). */
10+
export function HotelPriceBadge({ offer }: { offer: HotelOffer }) {
11+
const t = useTranslations("place");
12+
const locale = useLocale();
13+
// offer.currency comes straight from the upstream rate; an invalid ISO-4217
14+
// code makes the Intl.NumberFormat constructor throw, which would crash this
15+
// (unguarded) render — degrade to a plain "amount CODE" instead.
16+
const formatPrice = (amount: number): string => {
17+
try {
18+
return new Intl.NumberFormat(locale, {
19+
style: "currency",
20+
currency: offer.currency,
21+
maximumFractionDigits: 0,
22+
}).format(amount);
23+
} catch {
24+
return `${Math.round(amount)} ${offer.currency}`;
25+
}
26+
};
27+
return (
28+
<Box sx={{ display: "flex", alignItems: "baseline", gap: 0.5 }}>
29+
<Typography variant="caption" sx={{ color: "text.secondary" }}>
30+
{t("priceFrom")}
31+
</Typography>
32+
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
33+
{formatPrice(offer.nightlyFrom)}
34+
</Typography>
35+
<Typography variant="caption" sx={{ color: "text.secondary" }}>
36+
{t("perNight")}
37+
</Typography>
38+
</Box>
39+
);
40+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// apps/web/src/components/panels/place/HotelRateOptions.tsx
2+
"use client";
3+
4+
import Box from "@mui/material/Box";
5+
import Typography from "@mui/material/Typography";
6+
import { useHotelSearchStore } from "@openmapx/core";
7+
import { useLocale, useTranslations } from "next-intl";
8+
import { type ChangeEvent, useMemo } from "react";
9+
import { TEAL } from "@/lib/theme";
10+
11+
/** Curated currency choices (extensible; the effective currency is prepended if missing). */
12+
const CURRENCIES = [
13+
"EUR",
14+
"USD",
15+
"GBP",
16+
"CHF",
17+
"SEK",
18+
"NOK",
19+
"DKK",
20+
"PLN",
21+
"CZK",
22+
"CAD",
23+
"AUD",
24+
"JPY",
25+
];
26+
/** Curated ISO-3166-1 alpha-2 nationality choices (the place's country is prepended if missing). */
27+
const NATIONALITIES = [
28+
"US",
29+
"GB",
30+
"DE",
31+
"FR",
32+
"ES",
33+
"IT",
34+
"NL",
35+
"BE",
36+
"AT",
37+
"CH",
38+
"SE",
39+
"NO",
40+
"DK",
41+
"PL",
42+
"CZ",
43+
"IE",
44+
"PT",
45+
"CA",
46+
"AU",
47+
"JP",
48+
"CN",
49+
"IN",
50+
"BR",
51+
"MX",
52+
];
53+
54+
const selectSx = {
55+
border: "1px solid",
56+
borderColor: "divider",
57+
borderRadius: 1.5,
58+
bgcolor: "background.paper",
59+
color: "text.primary",
60+
fontSize: 14,
61+
px: 1,
62+
py: 0.75,
63+
width: "100%",
64+
fontFamily: "inherit",
65+
"&:focus": { outline: "none", borderColor: TEAL },
66+
} as const;
67+
68+
export function HotelRateOptions({
69+
defaultCurrency,
70+
placeCountry,
71+
}: {
72+
defaultCurrency: string;
73+
placeCountry?: string;
74+
}) {
75+
const t = useTranslations("place");
76+
const locale = useLocale();
77+
const { currency, guestNationality, setCurrency, setGuestNationality } = useHotelSearchStore();
78+
79+
const effCurrency = currency || defaultCurrency;
80+
// placeCountry can be a non-ISO-alpha-2 value (e.g. an OSM `addr:country` tag
81+
// like "USA" or "Deutschland"); fall back to "US" so it never reaches the
82+
// option list / Intl below as an invalid region subtag.
83+
const effNationality =
84+
guestNationality ||
85+
(placeCountry && /^[A-Za-z]{2}$/.test(placeCountry) ? placeCountry.toUpperCase() : "US");
86+
const currencyChoices = CURRENCIES.includes(effCurrency)
87+
? CURRENCIES
88+
: [effCurrency, ...CURRENCIES];
89+
const nationChoices = NATIONALITIES.includes(effNationality)
90+
? NATIONALITIES
91+
: [effNationality, ...NATIONALITIES];
92+
const regionNames = useMemo(() => new Intl.DisplayNames(locale, { type: "region" }), [locale]);
93+
// Intl.DisplayNames.of THROWS (not returns undefined) on a structurally
94+
// invalid region subtag, so a bad code would crash render — fall back to the
95+
// raw code instead.
96+
const regionLabel = (cc: string): string => {
97+
try {
98+
return regionNames.of(cc) ?? cc;
99+
} catch {
100+
return cc;
101+
}
102+
};
103+
104+
return (
105+
<Box sx={{ display: "flex", gap: 1 }}>
106+
<Box sx={{ flex: 1, display: "flex", flexDirection: "column", gap: 0.5 }}>
107+
<Typography variant="caption" sx={{ color: "text.secondary" }}>
108+
{t("currency")}
109+
</Typography>
110+
<Box
111+
component="select"
112+
value={effCurrency}
113+
onChange={(e: ChangeEvent<HTMLSelectElement>) => setCurrency(e.target.value)}
114+
sx={selectSx}
115+
>
116+
{currencyChoices.map((c) => (
117+
<option key={c} value={c}>
118+
{c}
119+
</option>
120+
))}
121+
</Box>
122+
</Box>
123+
<Box sx={{ flex: 1, display: "flex", flexDirection: "column", gap: 0.5 }}>
124+
<Typography variant="caption" sx={{ color: "text.secondary" }}>
125+
{t("guestNationality")}
126+
</Typography>
127+
<Box
128+
component="select"
129+
value={effNationality}
130+
onChange={(e: ChangeEvent<HTMLSelectElement>) => setGuestNationality(e.target.value)}
131+
sx={selectSx}
132+
>
133+
{nationChoices.map((cc) => (
134+
<option key={cc} value={cc}>
135+
{regionLabel(cc)}
136+
</option>
137+
))}
138+
</Box>
139+
</Box>
140+
</Box>
141+
);
142+
}

0 commit comments

Comments
 (0)