@voyant-travel/catalog-react 0.117.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +36 -0
  3. package/dist/admin/catalog-vertical-host.d.ts +45 -0
  4. package/dist/admin/catalog-vertical-host.d.ts.map +1 -0
  5. package/dist/admin/catalog-vertical-host.js +230 -0
  6. package/dist/admin/cruise-detail-host.d.ts +11 -0
  7. package/dist/admin/cruise-detail-host.d.ts.map +1 -0
  8. package/dist/admin/cruise-detail-host.js +33 -0
  9. package/dist/admin/dynamic-catalog-host.d.ts +13 -0
  10. package/dist/admin/dynamic-catalog-host.d.ts.map +1 -0
  11. package/dist/admin/dynamic-catalog-host.js +17 -0
  12. package/dist/admin/index.d.ts +133 -0
  13. package/dist/admin/index.d.ts.map +1 -0
  14. package/dist/admin/index.js +144 -0
  15. package/dist/admin/open-in-new-tab.d.ts +7 -0
  16. package/dist/admin/open-in-new-tab.d.ts.map +1 -0
  17. package/dist/admin/open-in-new-tab.js +10 -0
  18. package/dist/admin/pages/catalog-accommodations-detail-page.d.ts +4 -0
  19. package/dist/admin/pages/catalog-accommodations-detail-page.d.ts.map +1 -0
  20. package/dist/admin/pages/catalog-accommodations-detail-page.js +7 -0
  21. package/dist/admin/pages/catalog-accommodations-index-page.d.ts +8 -0
  22. package/dist/admin/pages/catalog-accommodations-index-page.d.ts.map +1 -0
  23. package/dist/admin/pages/catalog-accommodations-index-page.js +17 -0
  24. package/dist/admin/pages/catalog-cruises-detail-page.d.ts +4 -0
  25. package/dist/admin/pages/catalog-cruises-detail-page.d.ts.map +1 -0
  26. package/dist/admin/pages/catalog-cruises-detail-page.js +7 -0
  27. package/dist/admin/pages/catalog-cruises-index-page.d.ts +8 -0
  28. package/dist/admin/pages/catalog-cruises-index-page.d.ts.map +1 -0
  29. package/dist/admin/pages/catalog-cruises-index-page.js +19 -0
  30. package/dist/admin/pages/catalog-excursions-detail-page.d.ts +4 -0
  31. package/dist/admin/pages/catalog-excursions-detail-page.d.ts.map +1 -0
  32. package/dist/admin/pages/catalog-excursions-detail-page.js +7 -0
  33. package/dist/admin/pages/catalog-excursions-index-page.d.ts +8 -0
  34. package/dist/admin/pages/catalog-excursions-index-page.d.ts.map +1 -0
  35. package/dist/admin/pages/catalog-excursions-index-page.js +12 -0
  36. package/dist/admin/pages/catalog-products-detail-page.d.ts +8 -0
  37. package/dist/admin/pages/catalog-products-detail-page.d.ts.map +1 -0
  38. package/dist/admin/pages/catalog-products-detail-page.js +12 -0
  39. package/dist/admin/pages/catalog-products-index-page.d.ts +8 -0
  40. package/dist/admin/pages/catalog-products-index-page.d.ts.map +1 -0
  41. package/dist/admin/pages/catalog-products-index-page.js +12 -0
  42. package/dist/admin/pages/catalog-tours-detail-page.d.ts +4 -0
  43. package/dist/admin/pages/catalog-tours-detail-page.d.ts.map +1 -0
  44. package/dist/admin/pages/catalog-tours-detail-page.js +7 -0
  45. package/dist/admin/pages/catalog-tours-index-page.d.ts +8 -0
  46. package/dist/admin/pages/catalog-tours-index-page.d.ts.map +1 -0
  47. package/dist/admin/pages/catalog-tours-index-page.js +12 -0
  48. package/dist/admin/product-detail-host.d.ts +18 -0
  49. package/dist/admin/product-detail-host.d.ts.map +1 -0
  50. package/dist/admin/product-detail-host.js +40 -0
  51. package/dist/admin/scheduled-catalog-host.d.ts +15 -0
  52. package/dist/admin/scheduled-catalog-host.d.ts.map +1 -0
  53. package/dist/admin/scheduled-catalog-host.js +19 -0
  54. package/dist/admin/vertical-detail-host.d.ts +13 -0
  55. package/dist/admin/vertical-detail-host.d.ts.map +1 -0
  56. package/dist/admin/vertical-detail-host.js +62 -0
  57. package/dist/booking-engine/index.d.ts +26 -0
  58. package/dist/booking-engine/index.d.ts.map +1 -0
  59. package/dist/booking-engine/index.js +25 -0
  60. package/dist/booking-engine/use-booking-commit.d.ts +61 -0
  61. package/dist/booking-engine/use-booking-commit.d.ts.map +1 -0
  62. package/dist/booking-engine/use-booking-commit.js +47 -0
  63. package/dist/booking-engine/use-booking-draft-shape.d.ts +20 -0
  64. package/dist/booking-engine/use-booking-draft-shape.d.ts.map +1 -0
  65. package/dist/booking-engine/use-booking-draft-shape.js +9 -0
  66. package/dist/booking-engine/use-booking-draft.d.ts +102 -0
  67. package/dist/booking-engine/use-booking-draft.d.ts.map +1 -0
  68. package/dist/booking-engine/use-booking-draft.js +93 -0
  69. package/dist/booking-engine/use-booking-hold.d.ts +30 -0
  70. package/dist/booking-engine/use-booking-hold.d.ts.map +1 -0
  71. package/dist/booking-engine/use-booking-hold.js +44 -0
  72. package/dist/booking-engine/use-booking-journey-api.d.ts +23 -0
  73. package/dist/booking-engine/use-booking-journey-api.d.ts.map +1 -0
  74. package/dist/booking-engine/use-booking-journey-api.js +24 -0
  75. package/dist/booking-engine/use-booking-quote.d.ts +711 -0
  76. package/dist/booking-engine/use-booking-quote.d.ts.map +1 -0
  77. package/dist/booking-engine/use-booking-quote.js +122 -0
  78. package/dist/catalog-enrichment-mappers.d.ts +162 -0
  79. package/dist/catalog-enrichment-mappers.d.ts.map +1 -0
  80. package/dist/catalog-enrichment-mappers.js +190 -0
  81. package/dist/catalog-enrichment.d.ts +203 -0
  82. package/dist/catalog-enrichment.d.ts.map +1 -0
  83. package/dist/catalog-enrichment.js +130 -0
  84. package/dist/catalog-offers-client.d.ts +58 -0
  85. package/dist/catalog-offers-client.d.ts.map +1 -0
  86. package/dist/catalog-offers-client.js +61 -0
  87. package/dist/catalog-search-params.d.ts +45 -0
  88. package/dist/catalog-search-params.d.ts.map +1 -0
  89. package/dist/catalog-search-params.js +30 -0
  90. package/dist/catalog-surfaces.d.ts +17 -0
  91. package/dist/catalog-surfaces.d.ts.map +1 -0
  92. package/dist/catalog-surfaces.js +26 -0
  93. package/dist/client.d.ts +20 -0
  94. package/dist/client.d.ts.map +1 -0
  95. package/dist/client.js +65 -0
  96. package/dist/components/availability-calendar.d.ts +33 -0
  97. package/dist/components/availability-calendar.d.ts.map +1 -0
  98. package/dist/components/availability-calendar.js +65 -0
  99. package/dist/components/catalog-browse-page.d.ts +41 -0
  100. package/dist/components/catalog-browse-page.d.ts.map +1 -0
  101. package/dist/components/catalog-browse-page.js +47 -0
  102. package/dist/components/catalog-card.d.ts +68 -0
  103. package/dist/components/catalog-card.d.ts.map +1 -0
  104. package/dist/components/catalog-card.js +52 -0
  105. package/dist/components/catalog-detail-cruise-cards.d.ts +16 -0
  106. package/dist/components/catalog-detail-cruise-cards.d.ts.map +1 -0
  107. package/dist/components/catalog-detail-cruise-cards.js +54 -0
  108. package/dist/components/catalog-detail-departures.d.ts +25 -0
  109. package/dist/components/catalog-detail-departures.d.ts.map +1 -0
  110. package/dist/components/catalog-detail-departures.js +240 -0
  111. package/dist/components/catalog-detail-parts.d.ts +70 -0
  112. package/dist/components/catalog-detail-parts.d.ts.map +1 -0
  113. package/dist/components/catalog-detail-parts.js +282 -0
  114. package/dist/components/catalog-detail-sheet.d.ts +93 -0
  115. package/dist/components/catalog-detail-sheet.d.ts.map +1 -0
  116. package/dist/components/catalog-detail-sheet.js +68 -0
  117. package/dist/components/catalog-detail-view.d.ts +39 -0
  118. package/dist/components/catalog-detail-view.d.ts.map +1 -0
  119. package/dist/components/catalog-detail-view.js +157 -0
  120. package/dist/components/catalog-enrichment-fetchers.d.ts +8 -0
  121. package/dist/components/catalog-enrichment-fetchers.d.ts.map +1 -0
  122. package/dist/components/catalog-enrichment-fetchers.js +7 -0
  123. package/dist/components/catalog-faceted-filter.d.ts +30 -0
  124. package/dist/components/catalog-faceted-filter.d.ts.map +1 -0
  125. package/dist/components/catalog-faceted-filter.js +24 -0
  126. package/dist/components/catalog-filter-rail.d.ts +25 -0
  127. package/dist/components/catalog-filter-rail.d.ts.map +1 -0
  128. package/dist/components/catalog-filter-rail.js +88 -0
  129. package/dist/components/catalog-gallery.d.ts +27 -0
  130. package/dist/components/catalog-gallery.d.ts.map +1 -0
  131. package/dist/components/catalog-gallery.js +66 -0
  132. package/dist/components/catalog-hit.d.ts +27 -0
  133. package/dist/components/catalog-hit.d.ts.map +1 -0
  134. package/dist/components/catalog-hit.js +57 -0
  135. package/dist/components/catalog-page-cards.d.ts +21 -0
  136. package/dist/components/catalog-page-cards.d.ts.map +1 -0
  137. package/dist/components/catalog-page-cards.js +174 -0
  138. package/dist/components/catalog-page-config.d.ts +17 -0
  139. package/dist/components/catalog-page-config.d.ts.map +1 -0
  140. package/dist/components/catalog-page-config.js +369 -0
  141. package/dist/components/catalog-page.d.ts +88 -0
  142. package/dist/components/catalog-page.d.ts.map +1 -0
  143. package/dist/components/catalog-page.js +148 -0
  144. package/dist/components/catalog-range-filter.d.ts +34 -0
  145. package/dist/components/catalog-range-filter.d.ts.map +1 -0
  146. package/dist/components/catalog-range-filter.js +72 -0
  147. package/dist/components/catalog-search-page.d.ts +239 -0
  148. package/dist/components/catalog-search-page.d.ts.map +1 -0
  149. package/dist/components/catalog-search-page.js +63 -0
  150. package/dist/components/catalog-search-tab-panel.d.ts +42 -0
  151. package/dist/components/catalog-search-tab-panel.d.ts.map +1 -0
  152. package/dist/components/catalog-search-tab-panel.js +199 -0
  153. package/dist/components/catalog-vertical-detail-page.d.ts +33 -0
  154. package/dist/components/catalog-vertical-detail-page.d.ts.map +1 -0
  155. package/dist/components/catalog-vertical-detail-page.js +100 -0
  156. package/dist/components/cruise-detail-page-parts.d.ts +72 -0
  157. package/dist/components/cruise-detail-page-parts.d.ts.map +1 -0
  158. package/dist/components/cruise-detail-page-parts.js +146 -0
  159. package/dist/components/cruise-detail-page.d.ts +21 -0
  160. package/dist/components/cruise-detail-page.d.ts.map +1 -0
  161. package/dist/components/cruise-detail-page.js +201 -0
  162. package/dist/components/dynamic-catalog-page-parts.d.ts +40 -0
  163. package/dist/components/dynamic-catalog-page-parts.d.ts.map +1 -0
  164. package/dist/components/dynamic-catalog-page-parts.js +43 -0
  165. package/dist/components/dynamic-catalog-page.d.ts +23 -0
  166. package/dist/components/dynamic-catalog-page.d.ts.map +1 -0
  167. package/dist/components/dynamic-catalog-page.js +270 -0
  168. package/dist/components/media-gallery.d.ts +13 -0
  169. package/dist/components/media-gallery.d.ts.map +1 -0
  170. package/dist/components/media-gallery.js +42 -0
  171. package/dist/components/product-detail-page-parts.d.ts +106 -0
  172. package/dist/components/product-detail-page-parts.d.ts.map +1 -0
  173. package/dist/components/product-detail-page-parts.js +130 -0
  174. package/dist/components/product-detail-page.d.ts +57 -0
  175. package/dist/components/product-detail-page.d.ts.map +1 -0
  176. package/dist/components/product-detail-page.js +175 -0
  177. package/dist/components/scheduled-catalog-page.d.ts +34 -0
  178. package/dist/components/scheduled-catalog-page.d.ts.map +1 -0
  179. package/dist/components/scheduled-catalog-page.js +6 -0
  180. package/dist/hooks/index.d.ts +3 -0
  181. package/dist/hooks/index.d.ts.map +1 -0
  182. package/dist/hooks/index.js +2 -0
  183. package/dist/hooks/use-catalog-offers.d.ts +186 -0
  184. package/dist/hooks/use-catalog-offers.d.ts.map +1 -0
  185. package/dist/hooks/use-catalog-offers.js +105 -0
  186. package/dist/hooks/use-catalog-search.d.ts +109 -0
  187. package/dist/hooks/use-catalog-search.d.ts.map +1 -0
  188. package/dist/hooks/use-catalog-search.js +52 -0
  189. package/dist/i18n/en.d.ts +397 -0
  190. package/dist/i18n/en.d.ts.map +1 -0
  191. package/dist/i18n/en.js +396 -0
  192. package/dist/i18n/index.d.ts +5 -0
  193. package/dist/i18n/index.d.ts.map +1 -0
  194. package/dist/i18n/index.js +3 -0
  195. package/dist/i18n/messages.d.ts +335 -0
  196. package/dist/i18n/messages.d.ts.map +1 -0
  197. package/dist/i18n/messages.js +1 -0
  198. package/dist/i18n/provider.d.ts +816 -0
  199. package/dist/i18n/provider.d.ts.map +1 -0
  200. package/dist/i18n/provider.js +44 -0
  201. package/dist/i18n/ro.d.ts +397 -0
  202. package/dist/i18n/ro.d.ts.map +1 -0
  203. package/dist/i18n/ro.js +396 -0
  204. package/dist/index.d.ts +9 -0
  205. package/dist/index.d.ts.map +1 -0
  206. package/dist/index.js +17 -0
  207. package/dist/provider.d.ts +2 -0
  208. package/dist/provider.d.ts.map +1 -0
  209. package/dist/provider.js +1 -0
  210. package/dist/schemas-catalog-offers.d.ts +290 -0
  211. package/dist/schemas-catalog-offers.d.ts.map +1 -0
  212. package/dist/schemas-catalog-offers.js +155 -0
  213. package/dist/schemas.d.ts +143 -0
  214. package/dist/schemas.d.ts.map +1 -0
  215. package/dist/schemas.js +76 -0
  216. package/dist/ui.d.ts +19 -0
  217. package/dist/ui.d.ts.map +1 -0
  218. package/dist/ui.js +18 -0
  219. package/package.json +150 -0
  220. package/src/styles.css +11 -0
@@ -0,0 +1,130 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Badge } from "@voyant-travel/ui/components/badge";
4
+ import { Button } from "@voyant-travel/ui/components/button";
5
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@voyant-travel/ui/components/select";
6
+ import { Accessibility, Baby, Building2, Dumbbell, Info, Plane, Umbrella, Users, UtensilsCrossed, Waves, } from "lucide-react";
7
+ import { useEffect, useMemo, useState } from "react";
8
+ // Per-section icon by supplier `kind`, so the facilities read as scannable
9
+ // cards rather than a wall of text. Falls back to a neutral info glyph.
10
+ const SECTION_ICONS = {
11
+ BEACH: Umbrella,
12
+ FOR_KIDS: Baby,
13
+ FOOD: UtensilsCrossed,
14
+ HOTEL: Building2,
15
+ POOL: Waves,
16
+ SPORT_AND_WELLNESS: Dumbbell,
17
+ DISABILITY: Accessibility,
18
+ };
19
+ export function sectionIconFor(kind) {
20
+ return (kind ? SECTION_ICONS[kind] : undefined) ?? Info;
21
+ }
22
+ // Aggregate guest-review card (rating + per-category subratings). Connect only
23
+ // exposes the aggregate — individual review texts aren't in the feed.
24
+ export function ReviewsCard({ reviews, t, }) {
25
+ return (_jsxs("div", { className: "rounded-lg border p-4", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: "flex h-12 w-12 shrink-0 items-center justify-center rounded-md bg-secondary font-semibold text-lg text-secondary-foreground", children: reviews.rating?.toFixed(1) }), _jsxs("div", { children: [_jsx("div", { className: "font-medium text-sm", children: t.guestReviews }), _jsxs("div", { className: "text-muted-foreground text-xs", children: [reviews.reviewsCount ?? 0, " ", t.reviewsWord, reviews.source ? ` · ${reviews.source}` : ""] })] })] }), reviews.subratings.length > 0 && (_jsx("div", { className: "mt-3 grid gap-x-6 gap-y-1 sm:grid-cols-2", children: reviews.subratings.map((sr) => (_jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx("span", { className: "text-muted-foreground capitalize", children: sr.name }), _jsx("span", { className: "font-medium tabular-nums", children: sr.value?.toFixed(1) })] }, sr.name))) }))] }));
26
+ }
27
+ // One room type for the selected day: the room's details/amenities + a single
28
+ // rate row whose **meal (board) is selectable** — TUI's "Servicii de masă"
29
+ // dropdown — where the room is offered on more than one board. Switching the
30
+ // meal updates the price, flight and Book target. The cheapest meal is the
31
+ // default selection (the room's headline price).
32
+ export function RoomRateCard({ group, showImage, onBook, t, locale, }) {
33
+ const { room, code, offers } = group;
34
+ const title = room?.name ?? code ?? t.room;
35
+ const hasImage = showImage && Boolean(room?.image);
36
+ const details = [room?.area != null ? `${room.area} m²` : null, room?.view ?? null].filter((v) => Boolean(v));
37
+ // One entry per meal (board), keeping the cheapest offer for that meal;
38
+ // cheapest meal first so the default selection is the headline price.
39
+ const mealOptions = useMemo(() => {
40
+ const byBoard = new Map();
41
+ for (const o of offers) {
42
+ const key = boardKeyOf(o);
43
+ const cur = byBoard.get(key);
44
+ if (!cur || (o.total?.amountMinor ?? 0) < (cur.total?.amountMinor ?? 0))
45
+ byBoard.set(key, o);
46
+ }
47
+ return [...byBoard.values()].sort((a, b) => (a.total?.amountMinor ?? 0) - (b.total?.amountMinor ?? 0));
48
+ }, [offers]);
49
+ const [meal, setMeal] = useState(() => boardKeyOf(mealOptions[0] ?? offers[0]));
50
+ // Keep the selection valid if the underlying offers change (date switch).
51
+ useEffect(() => {
52
+ if (!mealOptions.some((o) => boardKeyOf(o) === meal)) {
53
+ setMeal(boardKeyOf(mealOptions[0] ?? offers[0]));
54
+ }
55
+ }, [mealOptions, offers, meal]);
56
+ const selected = mealOptions.find((o) => boardKeyOf(o) === meal) ?? mealOptions[0] ?? offers[0];
57
+ if (!selected)
58
+ return null;
59
+ const canChangeMeal = mealOptions.length > 1;
60
+ const flight = selected.flights[0];
61
+ return (_jsxs("div", { className: "overflow-hidden rounded-lg border", children: [_jsxs("div", { className: "flex gap-3 p-3", children: [hasImage && room?.image && (_jsx("img", { src: room.image, alt: title, className: "h-20 w-28 shrink-0 rounded-md object-cover", loading: "lazy" })), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("div", { className: "flex items-start justify-between gap-2", children: [_jsx("h4", { className: "font-medium text-sm leading-tight", children: title }), code && (_jsx(Badge, { variant: "outline", className: "shrink-0 font-normal text-xs", children: code }))] }), _jsxs("div", { className: "mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-muted-foreground text-xs", children: [details.map((d) => (_jsx("span", { children: d }, d))), room?.maxGuests != null && (_jsxs("span", { className: "flex items-center gap-1", children: [_jsx(Users, { className: "h-3 w-3" }), " ", t.max, " ", room.maxGuests] }))] }), room?.specifications && room.specifications.length > 0 && (_jsx("p", { className: "mt-1 line-clamp-2 text-muted-foreground text-xs", children: room.specifications.join(" · ") }))] })] }), _jsxs("div", { className: "flex items-center justify-between gap-3 border-t px-3 py-2.5", children: [_jsxs("div", { className: "flex min-w-0 flex-wrap items-center gap-2", children: [canChangeMeal ? (_jsxs(Select, { value: meal, onValueChange: (v) => setMeal(v), children: [_jsx(SelectTrigger, { className: "h-8 w-[180px] rounded-md data-[size=default]:h-8", "aria-label": t.mealPlan, children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: mealOptions.map((o) => (_jsx(SelectItem, { value: boardKeyOf(o), children: boardLabel(o.board, t) }, boardKeyOf(o)))) })] })) : (selected.board && (_jsx(Badge, { variant: "secondary", className: "font-normal", children: boardLabel(selected.board, t) }))), flight && (_jsxs("span", { className: "flex items-center gap-1 text-muted-foreground text-xs", children: [_jsx(Plane, { className: "h-3.5 w-3.5" }), [flight.carrier, `${flight.origin ?? ""}→${flight.destination ?? ""}`]
62
+ .filter((v) => v && v !== "→")
63
+ .join(" ")] })), selected.freeCancellationUntil && (_jsx("span", { className: "text-emerald-600 text-xs dark:text-emerald-400", children: t.freeCancellation }))] }), _jsxs("div", { className: "flex shrink-0 items-center gap-3", children: [_jsxs("div", { className: "text-right", children: [selected.total && (_jsx("div", { className: "font-semibold text-sm tabular-nums", children: formatMoney(selected.total, locale) })), selected.perPerson && (_jsxs("div", { className: "text-muted-foreground text-xs tabular-nums", children: [formatMoney(selected.perPerson, locale), " ", t.perPerson] }))] }), _jsx(Button, { size: "sm", onClick: () => onBook(selected), children: t.book })] })] })] }));
64
+ }
65
+ // Stable key for a meal/board (case-folded; offers without a board share one).
66
+ export function boardKeyOf(o) {
67
+ return (o?.board ?? "").toUpperCase() || "__none__";
68
+ }
69
+ // Readable, localized meal label (AI/HB/BB/…) → "All-inclusive"/"Half board"/…
70
+ export function boardLabel(board, t) {
71
+ if (!board)
72
+ return t.boards.standard;
73
+ return t.boards[board.toUpperCase()] ?? board;
74
+ }
75
+ // Live offer room ids are namespaced by accommodation (`LCA20072:DZL1`); the
76
+ // content room code is the bare suffix. Take the part after the last colon.
77
+ export function roomCodeOf(roomTypeId) {
78
+ if (!roomTypeId)
79
+ return null;
80
+ const idx = roomTypeId.lastIndexOf(":");
81
+ return idx >= 0 ? roomTypeId.slice(idx + 1) || roomTypeId : roomTypeId;
82
+ }
83
+ // Turn supplier codes like `free_wifi` into "Free wifi"; leave human labels as-is.
84
+ export function humanizeFeature(value) {
85
+ if (!/^[a-z0-9]+(_[a-z0-9]+)+$/.test(value))
86
+ return value;
87
+ const spaced = value.replace(/_/g, " ");
88
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
89
+ }
90
+ export function formatMoney(m, locale) {
91
+ return new Intl.NumberFormat(locale, {
92
+ style: "currency",
93
+ currency: m.currency,
94
+ maximumFractionDigits: 0,
95
+ }).format(m.amountMinor / 100);
96
+ }
97
+ export function formatStars(value) {
98
+ if (value == null || !Number.isFinite(value) || value <= 0)
99
+ return null;
100
+ return `${Number.isInteger(value) ? value : value.toFixed(1)}★`;
101
+ }
102
+ export function formatCountry(code, locale) {
103
+ if (!code || !/^[A-Za-z]{2}$/.test(code))
104
+ return code ?? null;
105
+ try {
106
+ return new Intl.DisplayNames(locale, { type: "region" }).of(code.toUpperCase()) ?? code;
107
+ }
108
+ catch {
109
+ return code;
110
+ }
111
+ }
112
+ export function formatDay(iso, locale) {
113
+ const d = new Date(iso);
114
+ if (Number.isNaN(d.getTime()))
115
+ return iso;
116
+ return new Intl.DateTimeFormat(locale, {
117
+ weekday: "short",
118
+ day: "numeric",
119
+ month: "short",
120
+ year: "numeric",
121
+ }).format(d);
122
+ }
123
+ export function addDays(date, days) {
124
+ const next = new Date(date);
125
+ next.setDate(next.getDate() + days);
126
+ return next;
127
+ }
128
+ export function isoDate(date) {
129
+ return date.toISOString().slice(0, 10);
130
+ }
@@ -0,0 +1,57 @@
1
+ import type { CatalogSurface } from "../index.js";
2
+ /**
3
+ * Individual product page (Dynamic surface) — full-page, URL-addressable at
4
+ * `/catalog/products/$productId` (opened in a new tab from the catalog).
5
+ * Fetches the full record from the SOURCE (Connect `package-detail` →
6
+ * accommodation detail + rich content: gallery, descriptions, rooms, reviews)
7
+ * plus the live dated offers — NOT from the search index. Renders a gallery,
8
+ * overview, inclusions, an availability calendar with a per-room rate table,
9
+ * rooms, and reviews.
10
+ *
11
+ * Presentational: navigation (`onBook`) and breadcrumbs (`onBreadcrumbs`) are
12
+ * injected by the host; the base URL + fetcher come from `VoyantCatalogProvider`.
13
+ */
14
+ /** The offer the user clicked Book on — enough for the journey to pre-fill the
15
+ * date and render a "what you're booking" preview. */
16
+ export interface ProductBookSelection {
17
+ /** Check-in / departure date (ISO), seeds the journey's departure. */
18
+ checkIn: string | null;
19
+ /** Product name for the journey side-panel preview. */
20
+ name: string | null;
21
+ /** Hero image for the preview, if the content has one. */
22
+ heroImageUrl: string | null;
23
+ /** Rate pin — the exact room + rate plan the operator clicked Book on, so the
24
+ * connect adapter re-resolves THAT offer (not just the first for the date).
25
+ * Pinned by stable keys; the per-search offer id can't be replayed. #1579. */
26
+ roomTypeId: string | null;
27
+ ratePlanId: string | null;
28
+ board: string | null;
29
+ }
30
+ export interface ProductDetailPageProps {
31
+ productId: string;
32
+ adults?: number;
33
+ nights?: number;
34
+ locale?: string;
35
+ /** `/v1/admin/...` (default) vs `/v1/public/...`. */
36
+ surface?: CatalogSurface;
37
+ /** Localized "Packages" label — breadcrumb root + header fallback. */
38
+ productsLabel: string;
39
+ /** Href of the packages browse page, e.g. `/catalog/products`. */
40
+ productsHref: string;
41
+ /**
42
+ * Route to the booking journey, pinned to the resolved Connect source.
43
+ * `selection` carries the offer the user clicked Book on, so the journey can
44
+ * pre-fill the date and show a preview instead of starting blank.
45
+ */
46
+ onBook: (productId: string, source: {
47
+ connectionId?: string;
48
+ ref?: string | null;
49
+ }, selection?: ProductBookSelection) => void;
50
+ /** Publish breadcrumbs as the resolved name changes. */
51
+ onBreadcrumbs?: (crumbs: Array<{
52
+ label: string;
53
+ href?: string;
54
+ }>) => void;
55
+ }
56
+ export declare function ProductDetailPage({ productId, adults, nights, locale, surface, productsLabel, productsHref, onBook, onBreadcrumbs, }: ProductDetailPageProps): import("react/jsx-runtime").JSX.Element;
57
+ //# sourceMappingURL=product-detail-page.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"product-detail-page.d.ts","sourceRoot":"","sources":["../../src/components/product-detail-page.tsx"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AA6BjD;;;;;;;;;;;GAWG;AAEH;uDACuD;AACvD,MAAM,WAAW,oBAAoB;IACnC,sEAAsE;IACtE,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;IACtB,uDAAuD;IACvD,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IACnB,0DAA0D;IAC1D,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B;;mFAE+E;IAC/E,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CACrB;AAED,MAAM,WAAW,sBAAsB;IACrC,SAAS,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,qDAAqD;IACrD,OAAO,CAAC,EAAE,cAAc,CAAA;IACxB,sEAAsE;IACtE,aAAa,EAAE,MAAM,CAAA;IACrB,kEAAkE;IAClE,YAAY,EAAE,MAAM,CAAA;IACpB;;;;OAIG;IACH,MAAM,EAAE,CACN,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE;QAAE,YAAY,CAAC,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,EACtD,SAAS,CAAC,EAAE,oBAAoB,KAC7B,IAAI,CAAA;IACT,wDAAwD;IACxD,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,KAAK,IAAI,CAAA;CAC1E;AAED,wBAAgB,iBAAiB,CAAC,EAChC,SAAS,EACT,MAAU,EACV,MAAU,EACV,MAAa,EACb,OAAiB,EACjB,aAAa,EACb,YAAY,EACZ,MAAM,EACN,aAAa,GACd,EAAE,sBAAsB,2CAybxB"}
@@ -0,0 +1,175 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Badge } from "@voyant-travel/ui/components/badge";
4
+ import { Check, Image as ImageIcon, MapPin, Star, Users } from "lucide-react";
5
+ import { useEffect, useMemo, useState } from "react";
6
+ import { useCatalogUiI18nOrDefault, useCatalogUiMessagesOrDefault } from "../i18n/index.js";
7
+ import { fetchPackageDetail, useVoyantCatalogContext } from "../index.js";
8
+ import { AvailabilityCalendar, compareMonth, monthOfIso, shiftMonth, } from "./availability-calendar.js";
9
+ import { Gallery, GalleryLightbox } from "./catalog-gallery.js";
10
+ import { addDays, formatCountry, formatDay, formatMoney, formatStars, humanizeFeature, isoDate, ReviewsCard, RoomRateCard, roomCodeOf, sectionIconFor, } from "./product-detail-page-parts.js";
11
+ export function ProductDetailPage({ productId, adults = 2, nights = 7, locale = "ro", surface = "admin", productsLabel, productsHref, onBook, onBreadcrumbs, }) {
12
+ const { baseUrl, fetcher } = useVoyantCatalogContext();
13
+ const t = useCatalogUiMessagesOrDefault().catalogBrowser.detail;
14
+ const { locale: resolvedLocale } = useCatalogUiI18nOrDefault();
15
+ const [state, setState] = useState({ status: "loading", product: null, offers: [], retryable: false, source: null });
16
+ const [monthCursor, setMonthCursor] = useState(null);
17
+ const [selected, setSelected] = useState(null);
18
+ // null = lightbox closed; a number = open at that gallery index.
19
+ const [lightbox, setLightbox] = useState(null);
20
+ useEffect(() => {
21
+ let cancelled = false;
22
+ setState((p) => ({ ...p, status: "loading" }));
23
+ void (async () => {
24
+ try {
25
+ const json = await fetchPackageDetail({ baseUrl, fetcher, surface }, {
26
+ productId,
27
+ departureDateFrom: isoDate(addDays(new Date(), 7)),
28
+ departureDateTo: isoDate(addDays(new Date(), 230)),
29
+ adults,
30
+ nights: { min: nights, max: nights },
31
+ locale,
32
+ });
33
+ if (cancelled)
34
+ return;
35
+ const offers = json.offers ?? [];
36
+ setState({
37
+ status: "ready",
38
+ product: json.product ?? null,
39
+ offers,
40
+ retryable: Boolean(json.retryable),
41
+ source: json.source ?? null,
42
+ });
43
+ const first = offers
44
+ .map((o) => o.checkIn)
45
+ .filter((d) => Boolean(d))
46
+ .sort()[0];
47
+ if (first) {
48
+ setMonthCursor(monthOfIso(first.slice(0, 10)));
49
+ setSelected(first.slice(0, 10));
50
+ }
51
+ }
52
+ catch {
53
+ if (!cancelled)
54
+ setState({ status: "error", product: null, offers: [], retryable: true, source: null });
55
+ }
56
+ })();
57
+ return () => {
58
+ cancelled = true;
59
+ };
60
+ }, [productId, adults, nights, locale, baseUrl, fetcher, surface]);
61
+ // Per-day availability: how many offers depart each day + the cheapest.
62
+ const byDate = useMemo(() => {
63
+ const acc = new Map();
64
+ for (const o of state.offers) {
65
+ if (!o.checkIn || !o.total)
66
+ continue;
67
+ const day = o.checkIn.slice(0, 10);
68
+ const cur = acc.get(day) ?? { count: 0, fromMinor: Number.POSITIVE_INFINITY };
69
+ cur.count += 1;
70
+ cur.fromMinor = Math.min(cur.fromMinor, o.total.amountMinor);
71
+ acc.set(day, cur);
72
+ }
73
+ const out = new Map();
74
+ for (const [day, v] of acc)
75
+ out.set(day, v);
76
+ return out;
77
+ }, [state.offers]);
78
+ const availableMonths = useMemo(() => {
79
+ const months = [...byDate.keys()].map(monthOfIso).filter((m) => m != null);
80
+ months.sort(compareMonth);
81
+ return months.filter((m, i) => i === 0 || compareMonth(m, months[i - 1]) !== 0);
82
+ }, [byDate]);
83
+ const currency = state.offers.find((o) => o.total)?.total?.currency ?? "EUR";
84
+ const fromPrice = useMemo(() => {
85
+ const vals = [...byDate.values()].map((v) => v.fromMinor);
86
+ return vals.length ? Math.min(...vals) : null;
87
+ }, [byDate]);
88
+ const selectedOffers = useMemo(() => state.offers
89
+ .filter((o) => o.checkIn?.slice(0, 10) === selected)
90
+ .sort((a, b) => (a.total?.amountMinor ?? 0) - (b.total?.amountMinor ?? 0)), [state.offers, selected]);
91
+ const product = state.product;
92
+ // Join live offers to the accommodation's content rooms by code, so the
93
+ // bookable rates can be grouped under each room with its details/amenities.
94
+ const roomByCode = useMemo(() => {
95
+ const m = new Map();
96
+ for (const r of product?.rooms ?? []) {
97
+ if (r.code)
98
+ m.set(r.code.toUpperCase(), r);
99
+ }
100
+ return m;
101
+ }, [product]);
102
+ // The selected day's offers grouped by room type (TUI "configure room"
103
+ // layout): one card per room, its board options listed beneath, cheapest
104
+ // room first.
105
+ const roomGroups = useMemo(() => {
106
+ const groups = new Map();
107
+ for (const o of selectedOffers) {
108
+ // Offer room ids come prefixed with the accommodation code
109
+ // (e.g. `LCA20072:DZL1`); the content room code is the bare suffix
110
+ // (`DZL1`). Strip the prefix so the join + the badge stay clean.
111
+ const code = roomCodeOf(o.roomTypeId);
112
+ const key = (code ?? "__room__").toUpperCase();
113
+ const g = groups.get(key) ?? { code, offers: [] };
114
+ g.offers.push(o);
115
+ groups.set(key, g);
116
+ }
117
+ return [...groups.values()]
118
+ .map((g) => ({
119
+ code: g.code,
120
+ room: g.code ? (roomByCode.get(g.code.toUpperCase()) ?? null) : null,
121
+ offers: g.offers
122
+ .slice()
123
+ .sort((a, b) => (a.total?.amountMinor ?? 0) - (b.total?.amountMinor ?? 0)),
124
+ }))
125
+ .sort((a, b) => (a.offers[0]?.total?.amountMinor ?? 0) - (b.offers[0]?.total?.amountMinor ?? 0));
126
+ }, [selectedOffers, roomByCode]);
127
+ const bookOffer = (offer) => onBook(productId, { connectionId: state.source?.connectionId, ref: state.source?.ref }, {
128
+ checkIn: offer.checkIn,
129
+ name: state.product?.name ?? null,
130
+ heroImageUrl: state.product?.media.find((m) => m.src)?.src ?? null,
131
+ roomTypeId: offer.roomTypeId,
132
+ ratePlanId: offer.ratePlanId ?? null,
133
+ board: offer.board,
134
+ });
135
+ const stars = formatStars(product?.stars);
136
+ const location = [
137
+ product?.city,
138
+ product?.region,
139
+ formatCountry(product?.countryCode, resolvedLocale),
140
+ ]
141
+ .filter((v) => Boolean(v))
142
+ .join(" · ");
143
+ const gallery = product?.media ?? [];
144
+ // Room photos are an upstream gap for some hotels — only show the image area
145
+ // when at least one room has a photo, else render clean text-only cards.
146
+ const roomsHaveImages = (product?.rooms ?? []).some((r) => Boolean(r.image));
147
+ // Split the content sections so the overview can lead with the marketing
148
+ // highlights ("Top reasons") + a clean location panel; the rest stay as the
149
+ // facilities list further down.
150
+ const sections = product?.sections ?? [];
151
+ const highlights = sections.find((s) => s.kind === "TOP_REASONS" || s.type === "HIGHLIGHT") ?? null;
152
+ const locationSection = sections.find((s) => s.kind === "LOCALIZATION") ?? null;
153
+ const aboutSections = sections.filter((s) => s !== highlights && s !== locationSection);
154
+ const monthIndex = monthCursor
155
+ ? availableMonths.findIndex((m) => compareMonth(m, monthCursor) === 0)
156
+ : -1;
157
+ // Header breadcrumbs (Packages › this product) — the product segment appears
158
+ // once its name has loaded.
159
+ useEffect(() => {
160
+ if (!onBreadcrumbs)
161
+ return;
162
+ onBreadcrumbs(product?.name
163
+ ? [{ label: productsLabel, href: productsHref }, { label: product.name }]
164
+ : [{ label: productsLabel, href: productsHref }]);
165
+ }, [product?.name, productsLabel, productsHref, onBreadcrumbs]);
166
+ if (state.status === "loading") {
167
+ return (_jsxs("div", { className: "mx-auto w-full max-w-screen-2xl px-6 py-6 lg:px-8", children: [_jsxs("div", { className: "grid h-[340px] grid-cols-4 grid-rows-2 gap-2 sm:h-[440px]", children: [_jsx("div", { className: "col-span-2 row-span-2 animate-pulse rounded-xl bg-muted/40" }), Array.from({ length: 4 }).map((_, i) => (_jsx("div", { className: "animate-pulse rounded-lg bg-muted/30" }, i)))] }), _jsx("div", { className: "mt-5 h-7 w-1/3 animate-pulse rounded bg-muted/40" }), _jsx("div", { className: "mt-2 h-4 w-1/4 animate-pulse rounded bg-muted/20" }), _jsx("div", { className: "mt-6 h-64 w-full animate-pulse rounded-lg bg-muted/30" })] }));
168
+ }
169
+ return (_jsxs("div", { className: "mx-auto w-full max-w-screen-2xl px-6 py-6 lg:px-8", children: [_jsx(Gallery, { images: gallery, photosLabel: t.photos, onOpen: (i) => setLightbox(i) }), lightbox != null && gallery.length > 0 && (_jsx(GalleryLightbox, { images: gallery, index: lightbox, onIndex: setLightbox, onClose: () => setLightbox(null), labels: { close: t.close, prev: t.prevPhoto, next: t.nextPhoto } })), _jsxs("div", { className: "mt-5 flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between", children: [_jsxs("div", { className: "min-w-0", children: [_jsx("h1", { className: "font-semibold text-2xl", children: product?.name ?? t.room }), _jsxs("div", { className: "mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground text-sm", children: [stars && _jsx("span", { className: "text-amber-500", children: stars }), location && _jsx("span", { children: location }), product?.category && _jsxs("span", { className: "capitalize", children: ["\u00B7 ", product.category] }), product?.reviews?.rating != null && (_jsxs("span", { className: "flex items-center gap-1 rounded bg-secondary px-1.5 py-0.5 font-medium text-secondary-foreground text-xs", children: [_jsx(Star, { className: "h-3 w-3 fill-current" }), product.reviews.rating.toFixed(1), product.reviews.reviewsCount != null && (_jsxs("span", { className: "font-normal text-muted-foreground", children: ["(", product.reviews.reviewsCount, ")"] }))] }))] })] }), fromPrice != null && (_jsxs("div", { className: "shrink-0 text-right", children: [_jsxs("span", { className: "text-muted-foreground text-xs", children: [t.from, " "] }), _jsx("span", { className: "font-semibold text-2xl", children: formatMoney({ amountMinor: fromPrice, currency }, resolvedLocale) }), _jsx("div", { className: "text-muted-foreground text-xs", children: t.nightsFlightIncluded.replace("{nights}", String(nights)) })] }))] }), product?.features && product.features.length > 0 && (_jsx("div", { className: "mt-4 flex flex-wrap gap-1.5", children: product.features.map((f) => (_jsxs(Badge, { variant: "outline", className: "gap-1 font-normal", children: [_jsx(Check, { className: "h-3 w-3 text-emerald-600 dark:text-emerald-400" }), humanizeFeature(f.label ?? f.code ?? "")] }, f.code ?? f.label))) })), (highlights || locationSection || product?.reviews?.rating != null) && (_jsxs("div", { className: "mt-6 space-y-4", children: [highlights && highlights.lines.length > 0 && (_jsxs("div", { className: "rounded-lg border bg-muted/30 p-4", children: [_jsx("h2", { className: "mb-2 font-medium text-sm", children: t.highlights }), _jsx("div", { className: "flex flex-wrap gap-x-6 gap-y-2", children: highlights.lines.map((line) => (_jsxs("span", { className: "flex items-center gap-2 text-sm", children: [_jsx(Check, { className: "h-4 w-4 shrink-0 text-emerald-600 dark:text-emerald-400" }), line] }, line))) })] })), (locationSection || product?.reviews?.rating != null) && (_jsxs("div", { className: "grid gap-4 md:grid-cols-2", children: [locationSection && locationSection.lines.length > 0 && (_jsxs("div", { className: "rounded-lg border p-4", children: [_jsxs("h2", { className: "mb-2 flex items-center gap-1.5 font-medium text-sm", children: [_jsx(MapPin, { className: "h-4 w-4 text-muted-foreground" }), locationSection.title ?? t.location] }), _jsx("ul", { className: "space-y-1.5 text-sm", children: locationSection.lines.map((line) => (_jsxs("li", { className: "flex items-start gap-2", children: [_jsx(Check, { className: "mt-0.5 h-4 w-4 shrink-0 text-emerald-600 dark:text-emerald-400" }), _jsx("span", { children: line })] }, line))) })] })), product?.reviews?.rating != null && _jsx(ReviewsCard, { reviews: product.reviews, t: t })] }))] })), _jsx("h2", { className: "mt-8 mb-2 font-medium text-lg", children: t.datesAndPrices }), state.status === "error" && (_jsx("div", { className: "rounded-md border border-destructive/40 bg-destructive/5 p-6 text-center text-destructive text-sm", children: t.datesError })), state.status === "ready" && byDate.size === 0 && (_jsx("div", { className: "rounded-md border border-dashed p-6 text-center text-muted-foreground text-sm", children: state.retryable
170
+ ? t.availabilityUnavailable
171
+ : t.noDepartures.replace("{nights}", String(nights)) })), monthCursor && byDate.size > 0 && (_jsxs("div", { className: "grid gap-6 lg:grid-cols-[minmax(0,360px)_1fr]", children: [_jsx(AvailabilityCalendar, { cursor: monthCursor, byDate: byDate, currency: currency, selected: selected, onSelect: setSelected, onPrev: () => setMonthCursor((c) => (c ? shiftMonth(c, -1) : c)), onNext: () => setMonthCursor((c) => (c ? shiftMonth(c, 1) : c)), canPrev: monthIndex > 0, canNext: monthIndex >= 0 && monthIndex < availableMonths.length - 1 }), _jsx("div", { children: selected && roomGroups.length > 0 ? (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("h3", { className: "font-medium", children: [formatDay(selected, resolvedLocale), " \u00B7", " ", _jsxs("span", { className: "text-muted-foreground", children: [roomGroups.length, " ", roomGroups.length === 1 ? t.roomType : t.roomTypes] })] }), roomGroups.map((group) => (_jsx(RoomRateCard, { group: group, showImage: roomsHaveImages, onBook: bookOffer, t: t, locale: resolvedLocale }, group.code ?? group.offers[0]?.id)))] })) : (_jsx("div", { className: "flex h-full items-center justify-center rounded-md border border-dashed p-6 text-center text-muted-foreground text-sm", children: t.selectDate })) })] })), aboutSections.length > 0 && (_jsxs("div", { className: "mt-10", children: [_jsx("h2", { className: "mb-3 font-medium text-lg", children: t.about }), _jsx("div", { className: "columns-1 gap-4 sm:columns-2 lg:columns-3 [&>*]:mb-4 [&>*]:break-inside-avoid", children: aboutSections.map((s) => {
172
+ const Icon = sectionIconFor(s.kind);
173
+ return (_jsxs("div", { className: "rounded-lg border p-4", children: [_jsxs("h3", { className: "mb-2 flex items-center gap-2 font-medium text-sm", children: [_jsx(Icon, { className: "h-4 w-4 shrink-0 text-muted-foreground" }), s.title ?? s.kind] }), _jsx("ul", { className: "space-y-1.5 text-muted-foreground text-sm", children: s.lines.map((line) => (_jsxs("li", { className: "flex gap-2", children: [_jsx("span", { className: "mt-2 h-1 w-1 shrink-0 rounded-full bg-muted-foreground/40" }), _jsx("span", { children: line })] }, line))) })] }, s.title ?? s.kind));
174
+ }) })] })), product?.rooms && product.rooms.length > 0 && (_jsxs("div", { className: "mt-10", children: [_jsx("h2", { className: "mb-3 font-medium text-lg", children: t.roomsTitle }), _jsx("div", { className: "grid gap-4 sm:grid-cols-2 xl:grid-cols-3", children: product.rooms.map((room) => (_jsxs("div", { className: "flex flex-col overflow-hidden rounded-lg border", children: [roomsHaveImages && (_jsx("div", { className: "relative aspect-[4/3] w-full bg-muted", children: room.image ? (_jsx("img", { src: room.image, alt: room.name ?? "", className: "h-full w-full object-cover", loading: "lazy" })) : (_jsx("div", { className: "flex h-full w-full items-center justify-center text-muted-foreground/60", children: _jsx(ImageIcon, { className: "h-7 w-7", "aria-hidden": "true" }) })) })), _jsxs("div", { className: "flex flex-1 flex-col gap-1.5 p-3", children: [_jsx("div", { className: "font-medium text-sm leading-tight", children: room.name ?? t.room }), _jsxs("div", { className: "flex flex-wrap gap-x-3 gap-y-0.5 text-muted-foreground text-xs", children: [room.area != null && _jsxs("span", { children: [room.area, " m\u00B2"] }), room.maxGuests != null && (_jsxs("span", { className: "flex items-center gap-1", children: [_jsx(Users, { className: "h-3 w-3" }), " ", t.max, " ", room.maxGuests] })), room.view && _jsx("span", { children: room.view })] }), room.specifications.length > 0 && (_jsx("ul", { className: "mt-0.5 space-y-0.5 text-muted-foreground text-xs", children: room.specifications.slice(0, 4).map((spec) => (_jsxs("li", { children: ["\u00B7 ", spec] }, spec))) }))] })] }, room.name ?? room.specifications.join("|")))) })] }))] }));
175
+ }
@@ -0,0 +1,34 @@
1
+ import type { ReactNode } from "react";
2
+ /**
3
+ * Scheduled (fixed-departure) catalog surface — products whose departures are
4
+ * known and finite, so it's a **departures-first browse** (the grid + each
5
+ * product's dated departures and remaining seats/allotment in the detail
6
+ * sheet). Pinned to `supplyModel: scheduled` so it never mixes with
7
+ * dynamically-composed packages, and split by duration:
8
+ * - `excursions` — single-day trips (`durationDays ≤ 1`)
9
+ * - `tours` — multi-day circuits (`durationDays ≥ 2`)
10
+ *
11
+ * Presentational: the localized `title`/`subtitle` and the browse grid itself
12
+ * (`renderBrowseGrid`, the host's `CatalogBrowsePage` wired to its data) are
13
+ * injected. This surface only owns the header layout + the supply-model /
14
+ * duration locks that define the scope.
15
+ */
16
+ export type ScheduledScope = "excursions" | "tours";
17
+ export interface ScheduledCatalogLocks {
18
+ lockedFacets: Record<string, Array<string | number>>;
19
+ lockedRanges: Record<string, {
20
+ gte?: number;
21
+ lte?: number;
22
+ }>;
23
+ }
24
+ export interface ScheduledCatalogPageProps {
25
+ scope: ScheduledScope;
26
+ /** Localized surface title (e.g. "Excursions" / "Tours"). */
27
+ title: string;
28
+ /** Localized surface tagline. */
29
+ subtitle: string;
30
+ /** Render the embedded browse grid with the surface's locked filters applied. */
31
+ renderBrowseGrid: (locks: ScheduledCatalogLocks) => ReactNode;
32
+ }
33
+ export declare function ScheduledCatalogPage({ scope, title, subtitle, renderBrowseGrid, }: ScheduledCatalogPageProps): import("react/jsx-runtime").JSX.Element;
34
+ //# sourceMappingURL=scheduled-catalog-page.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scheduled-catalog-page.d.ts","sourceRoot":"","sources":["../../src/components/scheduled-catalog-page.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAEtC;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,cAAc,GAAG,YAAY,GAAG,OAAO,CAAA;AAEnD,MAAM,WAAW,qBAAqB;IACpC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC,CAAA;IACpD,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE;QAAE,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;CAC7D;AAED,MAAM,WAAW,yBAAyB;IACxC,KAAK,EAAE,cAAc,CAAA;IACrB,6DAA6D;IAC7D,KAAK,EAAE,MAAM,CAAA;IACb,iCAAiC;IACjC,QAAQ,EAAE,MAAM,CAAA;IAChB,iFAAiF;IACjF,gBAAgB,EAAE,CAAC,KAAK,EAAE,qBAAqB,KAAK,SAAS,CAAA;CAC9D;AAED,wBAAgB,oBAAoB,CAAC,EACnC,KAAK,EACL,KAAK,EACL,QAAQ,EACR,gBAAgB,GACjB,EAAE,yBAAyB,2CAc3B"}
@@ -0,0 +1,6 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ export function ScheduledCatalogPage({ scope, title, subtitle, renderBrowseGrid, }) {
4
+ const lockedRanges = scope === "excursions" ? { durationDays: { lte: 1 } } : { durationDays: { gte: 2 } };
5
+ return (_jsxs("div", { className: "mx-auto w-full max-w-screen-2xl px-6 py-6 lg:px-8", children: [_jsxs("div", { className: "mb-4", children: [_jsx("h1", { className: "font-semibold text-2xl", children: title }), _jsx("p", { className: "text-muted-foreground text-sm", children: subtitle })] }), renderBrowseGrid({ lockedFacets: { supplyModel: ["scheduled"] }, lockedRanges })] }));
6
+ }
@@ -0,0 +1,3 @@
1
+ export { useCatalogSlots, useCruiseContent, useCruisePrice, useCruiseSailingPricing, useDepartureAirports, usePackageDetail, usePackageSearch, } from "./use-catalog-offers.js";
2
+ export { type CatalogSearchFilter, type CatalogSearchMode, type UseCatalogSearchOptions, useCatalogSearch, } from "./use-catalog-search.js";
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/hooks/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,eAAe,EACf,gBAAgB,EAChB,cAAc,EACd,uBAAuB,EACvB,oBAAoB,EACpB,gBAAgB,EAChB,gBAAgB,GACjB,MAAM,yBAAyB,CAAA;AAChC,OAAO,EACL,KAAK,mBAAmB,EACxB,KAAK,iBAAiB,EACtB,KAAK,uBAAuB,EAC5B,gBAAgB,GACjB,MAAM,yBAAyB,CAAA"}
@@ -0,0 +1,2 @@
1
+ export { useCatalogSlots, useCruiseContent, useCruisePrice, useCruiseSailingPricing, useDepartureAirports, usePackageDetail, usePackageSearch, } from "./use-catalog-offers.js";
2
+ export { useCatalogSearch, } from "./use-catalog-search.js";
@@ -0,0 +1,186 @@
1
+ import { type CatalogSurface, type NightsRange } from "../catalog-offers-client.js";
2
+ interface BaseOfferHookOptions {
3
+ /** `/v1/admin/...` (default) vs `/v1/public/...`. */
4
+ surface?: CatalogSurface;
5
+ /** Disable the query (e.g. while inputs are empty). */
6
+ enabled?: boolean;
7
+ /** TanStack Query stale time, milliseconds. */
8
+ staleTime?: number;
9
+ }
10
+ /**
11
+ * Departure airports for a destination — probed before a full availability
12
+ * search so the operator can pick where they fly from.
13
+ */
14
+ export declare function useDepartureAirports(options: {
15
+ countryCode: string;
16
+ } & BaseOfferHookOptions): import("@tanstack/react-query").UseQueryResult<{
17
+ departureAirports?: {
18
+ code: string;
19
+ label: string;
20
+ }[] | undefined;
21
+ }, Error>;
22
+ /** Dynamic package search by destination/dates/occupancy → live offers. */
23
+ export declare function usePackageSearch(options: {
24
+ countryCode: string;
25
+ departureDateFrom: string;
26
+ departureDateTo: string;
27
+ adults: number;
28
+ nights: NightsRange;
29
+ } & BaseOfferHookOptions): import("@tanstack/react-query").UseQueryResult<{
30
+ offers?: {
31
+ productId: string;
32
+ name: string | null;
33
+ image: string | null;
34
+ stars: string | number | null;
35
+ destination: string | null;
36
+ country: string | null;
37
+ board: string | null;
38
+ checkIn: string | null;
39
+ checkOut: string | null;
40
+ nights: number | null;
41
+ departureAirport: string | null;
42
+ arrivalAirport: string | null;
43
+ carrier: string | null;
44
+ perPerson: {
45
+ amountMinor: number;
46
+ currency: string;
47
+ } | null;
48
+ total: {
49
+ amountMinor: number;
50
+ currency: string;
51
+ } | null;
52
+ }[] | undefined;
53
+ departureAirports?: {
54
+ code: string;
55
+ label: string;
56
+ }[] | undefined;
57
+ currency?: string | undefined;
58
+ retryable?: boolean | undefined;
59
+ }, Error>;
60
+ /** Full product detail (source content + live dated offers). */
61
+ export declare function usePackageDetail(options: {
62
+ productId: string;
63
+ departureDateFrom: string;
64
+ departureDateTo: string;
65
+ adults: number;
66
+ nights: NightsRange;
67
+ locale?: string;
68
+ } & BaseOfferHookOptions): import("@tanstack/react-query").UseQueryResult<{
69
+ product?: {
70
+ name: string | null;
71
+ stars: number | null;
72
+ city: string | null;
73
+ region: string | null;
74
+ countryCode: string | null;
75
+ category: string | null;
76
+ media: {
77
+ src: string;
78
+ rel: string | null;
79
+ caption: string | null;
80
+ }[];
81
+ sections: {
82
+ title: string | null;
83
+ kind: string | null;
84
+ type: string | null;
85
+ lines: string[];
86
+ }[];
87
+ features: {
88
+ code: string | null;
89
+ label: string | null;
90
+ type: string | null;
91
+ }[];
92
+ rooms: {
93
+ code: string | null;
94
+ name: string | null;
95
+ area: number | null;
96
+ maxGuests: number | null;
97
+ view: string | null;
98
+ specifications: string[];
99
+ image: string | null;
100
+ }[];
101
+ reviews: {
102
+ source: string | null;
103
+ rating: number | null;
104
+ reviewsCount: number | null;
105
+ subratings: {
106
+ name: string | null;
107
+ value: number | null;
108
+ }[];
109
+ } | null;
110
+ } | null | undefined;
111
+ offers?: {
112
+ id: string;
113
+ title: string | null;
114
+ checkIn: string | null;
115
+ checkOut: string | null;
116
+ nights: number | null;
117
+ board: string | null;
118
+ roomTypeId: string | null;
119
+ perPerson: {
120
+ amountMinor: number;
121
+ currency: string;
122
+ } | null;
123
+ total: {
124
+ amountMinor: number;
125
+ currency: string;
126
+ } | null;
127
+ flights: {
128
+ origin: string | null;
129
+ destination: string | null;
130
+ departureAt: string | null;
131
+ carrier: string | null;
132
+ flightNumber: string | null;
133
+ }[];
134
+ freeCancellationUntil: string | null;
135
+ ratePlanId?: string | null | undefined;
136
+ }[] | undefined;
137
+ retryable?: boolean | undefined;
138
+ source?: {
139
+ connectionId: string;
140
+ ref: string | null;
141
+ } | null | undefined;
142
+ }, Error>;
143
+ /** Cruise-level "from" price (Connect). */
144
+ export declare function useCruisePrice(options: {
145
+ cruiseId: string;
146
+ } & BaseOfferHookOptions): import("@tanstack/react-query").UseQueryResult<{
147
+ fromAmountMinor?: number | null | undefined;
148
+ currency?: string | null | undefined;
149
+ }, Error>;
150
+ /** Live per-cabin pricing for one sailing (lazy — enable on row expand). */
151
+ export declare function useCruiseSailingPricing(options: {
152
+ cruiseId: string;
153
+ sailingRef: string;
154
+ } & BaseOfferHookOptions): import("@tanstack/react-query").UseQueryResult<{
155
+ cabins?: {
156
+ code: string;
157
+ fromAmountMinor: number;
158
+ available: boolean;
159
+ }[] | undefined;
160
+ currency?: string | null | undefined;
161
+ }, Error>;
162
+ /** Rich cruise content (gallery/sailings/cabins/itinerary) from the source. */
163
+ export declare function useCruiseContent(options: {
164
+ cruiseId: string;
165
+ locale?: string;
166
+ } & BaseOfferHookOptions): import("@tanstack/react-query").UseQueryResult<{
167
+ data?: {
168
+ content?: unknown;
169
+ } | undefined;
170
+ }, Error>;
171
+ /** Per-departure availability slots for an entity. */
172
+ export declare function useCatalogSlots(options: {
173
+ entityModule: string;
174
+ entityId: string;
175
+ } & BaseOfferHookOptions): import("@tanstack/react-query").UseQueryResult<{
176
+ rows: {
177
+ id: string;
178
+ startsAt?: string | undefined;
179
+ status?: string | null | undefined;
180
+ unlimited?: boolean | null | undefined;
181
+ remainingPax?: number | null | undefined;
182
+ initialPax?: number | null | undefined;
183
+ }[];
184
+ }, Error>;
185
+ export {};
186
+ //# sourceMappingURL=use-catalog-offers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-catalog-offers.d.ts","sourceRoot":"","sources":["../../src/hooks/use-catalog-offers.ts"],"names":[],"mappings":"AAIA,OAAO,EACL,KAAK,cAAc,EAQnB,KAAK,WAAW,EACjB,MAAM,6BAA6B,CAAA;AAGpC,UAAU,oBAAoB;IAC5B,qDAAqD;IACrD,OAAO,CAAC,EAAE,cAAc,CAAA;IACxB,uDAAuD;IACvD,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,+CAA+C;IAC/C,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE;IAAE,WAAW,EAAE,MAAM,CAAA;CAAE,GAAG,oBAAoB;;;;;UAU3F;AAED,2EAA2E;AAC3E,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE;IACP,WAAW,EAAE,MAAM,CAAA;IACnB,iBAAiB,EAAE,MAAM,CAAA;IACzB,eAAe,EAAE,MAAM,CAAA;IACvB,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,WAAW,CAAA;CACpB,GAAG,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;UAkCzB;AAED,gEAAgE;AAChE,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE;IACP,SAAS,EAAE,MAAM,CAAA;IACjB,iBAAiB,EAAE,MAAM,CAAA;IACzB,eAAe,EAAE,MAAM,CAAA;IACvB,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,WAAW,CAAA;IACnB,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB,GAAG,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;UAmCzB;AAED,2CAA2C;AAC3C,wBAAgB,cAAc,CAAC,OAAO,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG,oBAAoB;;;UAUlF;AAED,4EAA4E;AAC5E,wBAAgB,uBAAuB,CACrC,OAAO,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GAAG,oBAAoB;;;;;;;UAWzE;AAED,+EAA+E;AAC/E,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,oBAAoB;;;;UAWtE;AAED,sDAAsD;AACtD,wBAAgB,eAAe,CAC7B,OAAO,EAAE;IAAE,YAAY,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG,oBAAoB;;;;;;;;;UAW3E"}