@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.
- package/LICENSE +201 -0
- package/README.md +36 -0
- package/dist/admin/catalog-vertical-host.d.ts +45 -0
- package/dist/admin/catalog-vertical-host.d.ts.map +1 -0
- package/dist/admin/catalog-vertical-host.js +230 -0
- package/dist/admin/cruise-detail-host.d.ts +11 -0
- package/dist/admin/cruise-detail-host.d.ts.map +1 -0
- package/dist/admin/cruise-detail-host.js +33 -0
- package/dist/admin/dynamic-catalog-host.d.ts +13 -0
- package/dist/admin/dynamic-catalog-host.d.ts.map +1 -0
- package/dist/admin/dynamic-catalog-host.js +17 -0
- package/dist/admin/index.d.ts +133 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +144 -0
- package/dist/admin/open-in-new-tab.d.ts +7 -0
- package/dist/admin/open-in-new-tab.d.ts.map +1 -0
- package/dist/admin/open-in-new-tab.js +10 -0
- package/dist/admin/pages/catalog-accommodations-detail-page.d.ts +4 -0
- package/dist/admin/pages/catalog-accommodations-detail-page.d.ts.map +1 -0
- package/dist/admin/pages/catalog-accommodations-detail-page.js +7 -0
- package/dist/admin/pages/catalog-accommodations-index-page.d.ts +8 -0
- package/dist/admin/pages/catalog-accommodations-index-page.d.ts.map +1 -0
- package/dist/admin/pages/catalog-accommodations-index-page.js +17 -0
- package/dist/admin/pages/catalog-cruises-detail-page.d.ts +4 -0
- package/dist/admin/pages/catalog-cruises-detail-page.d.ts.map +1 -0
- package/dist/admin/pages/catalog-cruises-detail-page.js +7 -0
- package/dist/admin/pages/catalog-cruises-index-page.d.ts +8 -0
- package/dist/admin/pages/catalog-cruises-index-page.d.ts.map +1 -0
- package/dist/admin/pages/catalog-cruises-index-page.js +19 -0
- package/dist/admin/pages/catalog-excursions-detail-page.d.ts +4 -0
- package/dist/admin/pages/catalog-excursions-detail-page.d.ts.map +1 -0
- package/dist/admin/pages/catalog-excursions-detail-page.js +7 -0
- package/dist/admin/pages/catalog-excursions-index-page.d.ts +8 -0
- package/dist/admin/pages/catalog-excursions-index-page.d.ts.map +1 -0
- package/dist/admin/pages/catalog-excursions-index-page.js +12 -0
- package/dist/admin/pages/catalog-products-detail-page.d.ts +8 -0
- package/dist/admin/pages/catalog-products-detail-page.d.ts.map +1 -0
- package/dist/admin/pages/catalog-products-detail-page.js +12 -0
- package/dist/admin/pages/catalog-products-index-page.d.ts +8 -0
- package/dist/admin/pages/catalog-products-index-page.d.ts.map +1 -0
- package/dist/admin/pages/catalog-products-index-page.js +12 -0
- package/dist/admin/pages/catalog-tours-detail-page.d.ts +4 -0
- package/dist/admin/pages/catalog-tours-detail-page.d.ts.map +1 -0
- package/dist/admin/pages/catalog-tours-detail-page.js +7 -0
- package/dist/admin/pages/catalog-tours-index-page.d.ts +8 -0
- package/dist/admin/pages/catalog-tours-index-page.d.ts.map +1 -0
- package/dist/admin/pages/catalog-tours-index-page.js +12 -0
- package/dist/admin/product-detail-host.d.ts +18 -0
- package/dist/admin/product-detail-host.d.ts.map +1 -0
- package/dist/admin/product-detail-host.js +40 -0
- package/dist/admin/scheduled-catalog-host.d.ts +15 -0
- package/dist/admin/scheduled-catalog-host.d.ts.map +1 -0
- package/dist/admin/scheduled-catalog-host.js +19 -0
- package/dist/admin/vertical-detail-host.d.ts +13 -0
- package/dist/admin/vertical-detail-host.d.ts.map +1 -0
- package/dist/admin/vertical-detail-host.js +62 -0
- package/dist/booking-engine/index.d.ts +26 -0
- package/dist/booking-engine/index.d.ts.map +1 -0
- package/dist/booking-engine/index.js +25 -0
- package/dist/booking-engine/use-booking-commit.d.ts +61 -0
- package/dist/booking-engine/use-booking-commit.d.ts.map +1 -0
- package/dist/booking-engine/use-booking-commit.js +47 -0
- package/dist/booking-engine/use-booking-draft-shape.d.ts +20 -0
- package/dist/booking-engine/use-booking-draft-shape.d.ts.map +1 -0
- package/dist/booking-engine/use-booking-draft-shape.js +9 -0
- package/dist/booking-engine/use-booking-draft.d.ts +102 -0
- package/dist/booking-engine/use-booking-draft.d.ts.map +1 -0
- package/dist/booking-engine/use-booking-draft.js +93 -0
- package/dist/booking-engine/use-booking-hold.d.ts +30 -0
- package/dist/booking-engine/use-booking-hold.d.ts.map +1 -0
- package/dist/booking-engine/use-booking-hold.js +44 -0
- package/dist/booking-engine/use-booking-journey-api.d.ts +23 -0
- package/dist/booking-engine/use-booking-journey-api.d.ts.map +1 -0
- package/dist/booking-engine/use-booking-journey-api.js +24 -0
- package/dist/booking-engine/use-booking-quote.d.ts +711 -0
- package/dist/booking-engine/use-booking-quote.d.ts.map +1 -0
- package/dist/booking-engine/use-booking-quote.js +122 -0
- package/dist/catalog-enrichment-mappers.d.ts +162 -0
- package/dist/catalog-enrichment-mappers.d.ts.map +1 -0
- package/dist/catalog-enrichment-mappers.js +190 -0
- package/dist/catalog-enrichment.d.ts +203 -0
- package/dist/catalog-enrichment.d.ts.map +1 -0
- package/dist/catalog-enrichment.js +130 -0
- package/dist/catalog-offers-client.d.ts +58 -0
- package/dist/catalog-offers-client.d.ts.map +1 -0
- package/dist/catalog-offers-client.js +61 -0
- package/dist/catalog-search-params.d.ts +45 -0
- package/dist/catalog-search-params.d.ts.map +1 -0
- package/dist/catalog-search-params.js +30 -0
- package/dist/catalog-surfaces.d.ts +17 -0
- package/dist/catalog-surfaces.d.ts.map +1 -0
- package/dist/catalog-surfaces.js +26 -0
- package/dist/client.d.ts +20 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +65 -0
- package/dist/components/availability-calendar.d.ts +33 -0
- package/dist/components/availability-calendar.d.ts.map +1 -0
- package/dist/components/availability-calendar.js +65 -0
- package/dist/components/catalog-browse-page.d.ts +41 -0
- package/dist/components/catalog-browse-page.d.ts.map +1 -0
- package/dist/components/catalog-browse-page.js +47 -0
- package/dist/components/catalog-card.d.ts +68 -0
- package/dist/components/catalog-card.d.ts.map +1 -0
- package/dist/components/catalog-card.js +52 -0
- package/dist/components/catalog-detail-cruise-cards.d.ts +16 -0
- package/dist/components/catalog-detail-cruise-cards.d.ts.map +1 -0
- package/dist/components/catalog-detail-cruise-cards.js +54 -0
- package/dist/components/catalog-detail-departures.d.ts +25 -0
- package/dist/components/catalog-detail-departures.d.ts.map +1 -0
- package/dist/components/catalog-detail-departures.js +240 -0
- package/dist/components/catalog-detail-parts.d.ts +70 -0
- package/dist/components/catalog-detail-parts.d.ts.map +1 -0
- package/dist/components/catalog-detail-parts.js +282 -0
- package/dist/components/catalog-detail-sheet.d.ts +93 -0
- package/dist/components/catalog-detail-sheet.d.ts.map +1 -0
- package/dist/components/catalog-detail-sheet.js +68 -0
- package/dist/components/catalog-detail-view.d.ts +39 -0
- package/dist/components/catalog-detail-view.d.ts.map +1 -0
- package/dist/components/catalog-detail-view.js +157 -0
- package/dist/components/catalog-enrichment-fetchers.d.ts +8 -0
- package/dist/components/catalog-enrichment-fetchers.d.ts.map +1 -0
- package/dist/components/catalog-enrichment-fetchers.js +7 -0
- package/dist/components/catalog-faceted-filter.d.ts +30 -0
- package/dist/components/catalog-faceted-filter.d.ts.map +1 -0
- package/dist/components/catalog-faceted-filter.js +24 -0
- package/dist/components/catalog-filter-rail.d.ts +25 -0
- package/dist/components/catalog-filter-rail.d.ts.map +1 -0
- package/dist/components/catalog-filter-rail.js +88 -0
- package/dist/components/catalog-gallery.d.ts +27 -0
- package/dist/components/catalog-gallery.d.ts.map +1 -0
- package/dist/components/catalog-gallery.js +66 -0
- package/dist/components/catalog-hit.d.ts +27 -0
- package/dist/components/catalog-hit.d.ts.map +1 -0
- package/dist/components/catalog-hit.js +57 -0
- package/dist/components/catalog-page-cards.d.ts +21 -0
- package/dist/components/catalog-page-cards.d.ts.map +1 -0
- package/dist/components/catalog-page-cards.js +174 -0
- package/dist/components/catalog-page-config.d.ts +17 -0
- package/dist/components/catalog-page-config.d.ts.map +1 -0
- package/dist/components/catalog-page-config.js +369 -0
- package/dist/components/catalog-page.d.ts +88 -0
- package/dist/components/catalog-page.d.ts.map +1 -0
- package/dist/components/catalog-page.js +148 -0
- package/dist/components/catalog-range-filter.d.ts +34 -0
- package/dist/components/catalog-range-filter.d.ts.map +1 -0
- package/dist/components/catalog-range-filter.js +72 -0
- package/dist/components/catalog-search-page.d.ts +239 -0
- package/dist/components/catalog-search-page.d.ts.map +1 -0
- package/dist/components/catalog-search-page.js +63 -0
- package/dist/components/catalog-search-tab-panel.d.ts +42 -0
- package/dist/components/catalog-search-tab-panel.d.ts.map +1 -0
- package/dist/components/catalog-search-tab-panel.js +199 -0
- package/dist/components/catalog-vertical-detail-page.d.ts +33 -0
- package/dist/components/catalog-vertical-detail-page.d.ts.map +1 -0
- package/dist/components/catalog-vertical-detail-page.js +100 -0
- package/dist/components/cruise-detail-page-parts.d.ts +72 -0
- package/dist/components/cruise-detail-page-parts.d.ts.map +1 -0
- package/dist/components/cruise-detail-page-parts.js +146 -0
- package/dist/components/cruise-detail-page.d.ts +21 -0
- package/dist/components/cruise-detail-page.d.ts.map +1 -0
- package/dist/components/cruise-detail-page.js +201 -0
- package/dist/components/dynamic-catalog-page-parts.d.ts +40 -0
- package/dist/components/dynamic-catalog-page-parts.d.ts.map +1 -0
- package/dist/components/dynamic-catalog-page-parts.js +43 -0
- package/dist/components/dynamic-catalog-page.d.ts +23 -0
- package/dist/components/dynamic-catalog-page.d.ts.map +1 -0
- package/dist/components/dynamic-catalog-page.js +270 -0
- package/dist/components/media-gallery.d.ts +13 -0
- package/dist/components/media-gallery.d.ts.map +1 -0
- package/dist/components/media-gallery.js +42 -0
- package/dist/components/product-detail-page-parts.d.ts +106 -0
- package/dist/components/product-detail-page-parts.d.ts.map +1 -0
- package/dist/components/product-detail-page-parts.js +130 -0
- package/dist/components/product-detail-page.d.ts +57 -0
- package/dist/components/product-detail-page.d.ts.map +1 -0
- package/dist/components/product-detail-page.js +175 -0
- package/dist/components/scheduled-catalog-page.d.ts +34 -0
- package/dist/components/scheduled-catalog-page.d.ts.map +1 -0
- package/dist/components/scheduled-catalog-page.js +6 -0
- package/dist/hooks/index.d.ts +3 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +2 -0
- package/dist/hooks/use-catalog-offers.d.ts +186 -0
- package/dist/hooks/use-catalog-offers.d.ts.map +1 -0
- package/dist/hooks/use-catalog-offers.js +105 -0
- package/dist/hooks/use-catalog-search.d.ts +109 -0
- package/dist/hooks/use-catalog-search.d.ts.map +1 -0
- package/dist/hooks/use-catalog-search.js +52 -0
- package/dist/i18n/en.d.ts +397 -0
- package/dist/i18n/en.d.ts.map +1 -0
- package/dist/i18n/en.js +396 -0
- package/dist/i18n/index.d.ts +5 -0
- package/dist/i18n/index.d.ts.map +1 -0
- package/dist/i18n/index.js +3 -0
- package/dist/i18n/messages.d.ts +335 -0
- package/dist/i18n/messages.d.ts.map +1 -0
- package/dist/i18n/messages.js +1 -0
- package/dist/i18n/provider.d.ts +816 -0
- package/dist/i18n/provider.d.ts.map +1 -0
- package/dist/i18n/provider.js +44 -0
- package/dist/i18n/ro.d.ts +397 -0
- package/dist/i18n/ro.d.ts.map +1 -0
- package/dist/i18n/ro.js +396 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/provider.d.ts +2 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +1 -0
- package/dist/schemas-catalog-offers.d.ts +290 -0
- package/dist/schemas-catalog-offers.d.ts.map +1 -0
- package/dist/schemas-catalog-offers.js +155 -0
- package/dist/schemas.d.ts +143 -0
- package/dist/schemas.d.ts.map +1 -0
- package/dist/schemas.js +76 -0
- package/dist/ui.d.ts +19 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +18 -0
- package/package.json +150 -0
- package/src/styles.css +11 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { Badge } from "@voyant-travel/ui/components/badge";
|
|
4
|
+
import { Button } from "@voyant-travel/ui/components/button";
|
|
5
|
+
import { Input } from "@voyant-travel/ui/components/input";
|
|
6
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@voyant-travel/ui/components/select";
|
|
7
|
+
import { cn } from "@voyant-travel/ui/lib/utils";
|
|
8
|
+
import { ArrowDown, ArrowUp, ArrowUpDown, ChevronDown, ChevronRight, X } from "lucide-react";
|
|
9
|
+
import { Fragment, useEffect, useMemo, useState } from "react";
|
|
10
|
+
import { formatPriceCents } from "./catalog-detail-parts.js";
|
|
11
|
+
function monthKey(date) {
|
|
12
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
|
13
|
+
}
|
|
14
|
+
function collectMonthOptions(departures) {
|
|
15
|
+
const formatter = new Intl.DateTimeFormat(undefined, { month: "long", year: "numeric" });
|
|
16
|
+
const map = new Map();
|
|
17
|
+
for (const d of departures) {
|
|
18
|
+
const date = new Date(d.startsAt);
|
|
19
|
+
if (Number.isNaN(date.getTime()))
|
|
20
|
+
continue;
|
|
21
|
+
const key = monthKey(date);
|
|
22
|
+
if (!map.has(key))
|
|
23
|
+
map.set(key, { value: key, label: formatter.format(date) });
|
|
24
|
+
}
|
|
25
|
+
// i18n-literal-ok: numeric sort comparator return value
|
|
26
|
+
return Array.from(map.values()).sort((a, b) => (a.value < b.value ? -1 : 1));
|
|
27
|
+
}
|
|
28
|
+
function normaliseStatus(d) {
|
|
29
|
+
if (d.status)
|
|
30
|
+
return d.status;
|
|
31
|
+
return "open";
|
|
32
|
+
}
|
|
33
|
+
function collectStatusOptions(departures) {
|
|
34
|
+
const set = new Set();
|
|
35
|
+
for (const d of departures)
|
|
36
|
+
set.add(normaliseStatus(d));
|
|
37
|
+
return Array.from(set).sort();
|
|
38
|
+
}
|
|
39
|
+
function isDepartureBookable(d) {
|
|
40
|
+
if (d.status === "sold_out" || d.status === "closed" || d.status === "cancelled")
|
|
41
|
+
return false;
|
|
42
|
+
if (typeof d.remaining === "number" && d.remaining <= 0)
|
|
43
|
+
return false;
|
|
44
|
+
return new Date(d.startsAt).getTime() > Date.now();
|
|
45
|
+
}
|
|
46
|
+
const ALL_FILTER_VALUE = "__all__";
|
|
47
|
+
/**
|
|
48
|
+
* Flat departures table with sortable columns and filter controls
|
|
49
|
+
* (month/year, status, min-availability). Sold-out / closed / past
|
|
50
|
+
* rows render dimmed and are not clickable. Bookable rows expand to
|
|
51
|
+
* reveal a per-option row with its own remaining capacity and Book
|
|
52
|
+
* button.
|
|
53
|
+
*/
|
|
54
|
+
export function DeparturesTable({ hit, vertical, departures, options, productSellAmountCents, productSellCurrency, onBookDeparture, onBookOption, onLoadDeparturePricing, messages, }) {
|
|
55
|
+
const tableMessages = messages.departuresTable;
|
|
56
|
+
// Cruises call a scheduled departure a "sailing" — pick the cruise wording.
|
|
57
|
+
const isCruise = vertical === "cruises";
|
|
58
|
+
const noUpcomingLabel = isCruise ? messages.noUpcomingSailings : messages.noUpcomingDepartures;
|
|
59
|
+
const noResultsLabel = isCruise ? tableMessages.noResultsSailings : tableMessages.noResults;
|
|
60
|
+
const monthOptions = useMemo(() => collectMonthOptions(departures), [departures]);
|
|
61
|
+
const statusOptions = useMemo(() => collectStatusOptions(departures), [departures]);
|
|
62
|
+
const [monthFilter, setMonthFilter] = useState(ALL_FILTER_VALUE);
|
|
63
|
+
const [statusFilter, setStatusFilter] = useState(ALL_FILTER_VALUE);
|
|
64
|
+
const [minAvailability, setMinAvailability] = useState("");
|
|
65
|
+
const [sort, setSort] = useState({
|
|
66
|
+
column: "date",
|
|
67
|
+
direction: "asc",
|
|
68
|
+
});
|
|
69
|
+
const [expandedId, setExpandedId] = useState(null);
|
|
70
|
+
const dateTimeFormatter = useMemo(() => new Intl.DateTimeFormat(undefined, {
|
|
71
|
+
weekday: "short",
|
|
72
|
+
day: "numeric",
|
|
73
|
+
month: "short",
|
|
74
|
+
year: "numeric",
|
|
75
|
+
hour: "numeric",
|
|
76
|
+
minute: "2-digit",
|
|
77
|
+
}), []);
|
|
78
|
+
const filtersActive = monthFilter !== ALL_FILTER_VALUE || statusFilter !== ALL_FILTER_VALUE || minAvailability !== "";
|
|
79
|
+
const filtered = useMemo(() => {
|
|
80
|
+
const minAvail = minAvailability ? Number(minAvailability) : null;
|
|
81
|
+
return departures.filter((d) => {
|
|
82
|
+
const date = new Date(d.startsAt);
|
|
83
|
+
if (monthFilter !== ALL_FILTER_VALUE) {
|
|
84
|
+
if (Number.isNaN(date.getTime()))
|
|
85
|
+
return false;
|
|
86
|
+
if (monthKey(date) !== monthFilter)
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
if (statusFilter !== ALL_FILTER_VALUE && normaliseStatus(d) !== statusFilter) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
if (minAvail != null && Number.isFinite(minAvail)) {
|
|
93
|
+
const remaining = typeof d.remaining === "number" ? d.remaining : null;
|
|
94
|
+
if (remaining == null)
|
|
95
|
+
return false;
|
|
96
|
+
if (remaining < minAvail)
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
return true;
|
|
100
|
+
});
|
|
101
|
+
}, [departures, monthFilter, statusFilter, minAvailability]);
|
|
102
|
+
const sorted = useMemo(() => {
|
|
103
|
+
const list = [...filtered];
|
|
104
|
+
const dir = sort.direction === "asc" ? 1 : -1;
|
|
105
|
+
list.sort((a, b) => {
|
|
106
|
+
switch (sort.column) {
|
|
107
|
+
case "date":
|
|
108
|
+
return (new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime()) * dir;
|
|
109
|
+
case "status":
|
|
110
|
+
return normaliseStatus(a).localeCompare(normaliseStatus(b)) * dir;
|
|
111
|
+
case "availability": {
|
|
112
|
+
const av = typeof a.remaining === "number" ? a.remaining : -1;
|
|
113
|
+
const bv = typeof b.remaining === "number" ? b.remaining : -1;
|
|
114
|
+
return (av - bv) * dir;
|
|
115
|
+
}
|
|
116
|
+
case "priceFrom": {
|
|
117
|
+
const av = a.lowestPriceCents ?? productSellAmountCents ?? -1;
|
|
118
|
+
const bv = b.lowestPriceCents ?? productSellAmountCents ?? -1;
|
|
119
|
+
return (av - bv) * dir;
|
|
120
|
+
}
|
|
121
|
+
default:
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
return list;
|
|
126
|
+
}, [filtered, sort, productSellAmountCents]);
|
|
127
|
+
const toggleSort = (column) => {
|
|
128
|
+
setSort((current) => current.column === column
|
|
129
|
+
? { column, direction: current.direction === "asc" ? "desc" : "asc" }
|
|
130
|
+
: { column, direction: "asc" });
|
|
131
|
+
};
|
|
132
|
+
const clearFilters = () => {
|
|
133
|
+
setMonthFilter(ALL_FILTER_VALUE);
|
|
134
|
+
setStatusFilter(ALL_FILTER_VALUE);
|
|
135
|
+
setMinAvailability("");
|
|
136
|
+
};
|
|
137
|
+
return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex flex-wrap items-end gap-2", children: [_jsx("div", { className: "flex flex-col gap-1", children: _jsxs(Select, { value: monthFilter, onValueChange: (v) => setMonthFilter(v ?? ALL_FILTER_VALUE), children: [_jsx(SelectTrigger, { className: "h-9 w-[180px] text-sm", children: _jsx(SelectValue, { placeholder: tableMessages.anyMonth }) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: ALL_FILTER_VALUE, children: tableMessages.anyMonth }), monthOptions.map((opt) => (_jsx(SelectItem, { value: opt.value, children: opt.label }, opt.value)))] })] }) }), _jsx("div", { className: "flex flex-col gap-1", children: _jsxs(Select, { value: statusFilter, onValueChange: (v) => setStatusFilter(v ?? ALL_FILTER_VALUE), children: [_jsx(SelectTrigger, { className: "h-9 w-[160px] text-sm", children: _jsx(SelectValue, { placeholder: tableMessages.anyStatus }) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: ALL_FILTER_VALUE, children: tableMessages.anyStatus }), statusOptions.map((status) => (_jsx(SelectItem, { value: status, children: statusLabelFor(status, tableMessages) }, status)))] })] }) }), _jsx("div", { className: "flex flex-col gap-1", children: _jsx(Input, { type: "number", min: 0, value: minAvailability, onChange: (event) => setMinAvailability(event.target.value), placeholder: tableMessages.minAvailability, className: "h-9 w-[140px] text-sm" }) }), filtersActive && (_jsxs(Button, { variant: "ghost", size: "sm", onClick: clearFilters, className: "ml-auto", children: [_jsx(X, { className: "mr-1 h-3.5 w-3.5" }), tableMessages.clearFilters] }))] }), sorted.length === 0 ? (_jsx("div", { className: "rounded-md border bg-muted/10 px-3 py-6 text-center text-sm text-muted-foreground", children: filtersActive ? noResultsLabel : noUpcomingLabel })) : (_jsx("div", { className: "overflow-hidden rounded-md border", children: _jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { className: "border-b bg-muted/30 text-[11px] font-medium uppercase tracking-wider text-muted-foreground", children: _jsxs("tr", { children: [_jsx(SortableHeader, { className: "text-left", column: "date", sort: sort, onToggle: toggleSort, children: tableMessages.date }), _jsx(SortableHeader, { className: "text-left", column: "status", sort: sort, onToggle: toggleSort, children: tableMessages.status }), _jsx(SortableHeader, { className: "text-right", column: "availability", sort: sort, onToggle: toggleSort, children: tableMessages.availability }), _jsx(SortableHeader, { className: "text-right", column: "priceFrom", sort: sort, onToggle: toggleSort, children: tableMessages.priceFrom }), _jsx("th", { className: "w-[36px] px-3 py-2" })] }) }), _jsx("tbody", { children: sorted.map((d) => {
|
|
138
|
+
const bookable = isDepartureBookable(d);
|
|
139
|
+
const isExpanded = expandedId === d.id;
|
|
140
|
+
const date = new Date(d.startsAt);
|
|
141
|
+
const statusKey = normaliseStatus(d);
|
|
142
|
+
const remaining = typeof d.remaining === "number" ? d.remaining : null;
|
|
143
|
+
const priceCents = d.lowestPriceCents ?? productSellAmountCents;
|
|
144
|
+
const priceCurrency = d.currency ?? productSellCurrency ?? undefined;
|
|
145
|
+
return (_jsxs(Fragment, { children: [_jsxs("tr", { className: cn("border-b last:border-b-0 transition-colors", bookable ? "cursor-pointer hover:bg-muted/40" : "cursor-default opacity-50"), onClick: () => {
|
|
146
|
+
if (!bookable)
|
|
147
|
+
return;
|
|
148
|
+
setExpandedId((current) => (current === d.id ? null : d.id));
|
|
149
|
+
}, children: [_jsx("td", { className: "px-3 py-2 font-medium", children: dateTimeFormatter.format(date) }), _jsx("td", { className: "px-3 py-2", children: _jsx(Badge, { variant: bookable ? "outline" : "secondary", className: "w-fit font-normal capitalize", children: statusLabelFor(statusKey, tableMessages) }) }), _jsx("td", { className: "px-3 py-2 text-right tabular-nums", children: remaining != null ? remaining : "—" }), _jsx("td", { className: "px-3 py-2 text-right font-medium tabular-nums", children: priceCents != null ? formatPriceCents(priceCents, priceCurrency) : "—" }), _jsx("td", { className: "px-3 py-2 text-right", children: bookable ? (isExpanded ? (_jsx(ChevronDown, { className: "ml-auto h-4 w-4 text-muted-foreground" })) : (_jsx(ChevronRight, { className: "ml-auto h-4 w-4 text-muted-foreground" }))) : null })] }), isExpanded && (_jsx("tr", { className: "border-b last:border-b-0 bg-muted/20", children: _jsx("td", { colSpan: 5, className: "px-3 py-3", children: _jsx(DepartureDetailPanel, { hit: hit, departure: d, options: options, productSellAmountCents: productSellAmountCents, productSellCurrency: productSellCurrency, onBookDeparture: onBookDeparture, onBookOption: onBookOption, onLoadDeparturePricing: onLoadDeparturePricing, messages: messages }) }) }))] }, d.id));
|
|
150
|
+
}) })] }) }))] }));
|
|
151
|
+
}
|
|
152
|
+
function SortableHeader({ column, sort, onToggle, children, className, }) {
|
|
153
|
+
const active = sort.column === column;
|
|
154
|
+
const Icon = !active ? ArrowUpDown : sort.direction === "asc" ? ArrowUp : ArrowDown;
|
|
155
|
+
return (_jsx("th", { className: cn("px-3 py-2", className), children: _jsxs("button", { type: "button", onClick: () => onToggle(column), className: cn("inline-flex items-center gap-1 text-[11px] font-medium uppercase tracking-wider", active ? "text-foreground" : "text-muted-foreground hover:text-foreground",
|
|
156
|
+
// i18n-literal-ok: tailwind utilities, not user copy
|
|
157
|
+
className?.includes("text-right") ? "ml-auto flex-row-reverse" : null), children: [_jsx(Icon, { className: "h-3 w-3" }), children] }) }));
|
|
158
|
+
}
|
|
159
|
+
function statusLabelFor(status, tableMessages) {
|
|
160
|
+
switch (status) {
|
|
161
|
+
case "sold_out":
|
|
162
|
+
return tableMessages.soldOut;
|
|
163
|
+
case "closed":
|
|
164
|
+
return tableMessages.closed;
|
|
165
|
+
case "cancelled":
|
|
166
|
+
return tableMessages.cancelled;
|
|
167
|
+
case "open":
|
|
168
|
+
return tableMessages.open;
|
|
169
|
+
default:
|
|
170
|
+
return status.replace(/_/g, " ");
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Per-departure expansion panel. Lists the bookable options with their
|
|
175
|
+
* own remaining capacity, "from" price, and Book button. Today the
|
|
176
|
+
* seeded availability_slots track capacity at the product level (not
|
|
177
|
+
* per option), so per-option remaining mirrors the departure total —
|
|
178
|
+
* once `availability_slots.option_id` is populated, the per-option
|
|
179
|
+
* number will diverge automatically.
|
|
180
|
+
*/
|
|
181
|
+
function DepartureDetailPanel({ hit, departure, options, productSellAmountCents, productSellCurrency, onBookDeparture, onBookOption, onLoadDeparturePricing, messages, }) {
|
|
182
|
+
const tableMessages = messages.departuresTable;
|
|
183
|
+
const currency = departure.currency ?? productSellCurrency ?? undefined;
|
|
184
|
+
const departurePriceCents = departure.lowestPriceCents ?? productSellAmountCents;
|
|
185
|
+
const departureRemaining = typeof departure.remaining === "number" ? departure.remaining : null;
|
|
186
|
+
// Live per-cabin pricing (cruises). This panel only mounts when its departure
|
|
187
|
+
// row is expanded, so fetch lazily here — pricing is volatile-live, never
|
|
188
|
+
// baked into the cached content.
|
|
189
|
+
const [livePricing, setLivePricing] = useState(null);
|
|
190
|
+
const sailingRef = departure.sourceRef ?? null;
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
if (!hit || !onLoadDeparturePricing || !sailingRef)
|
|
193
|
+
return;
|
|
194
|
+
let cancelled = false;
|
|
195
|
+
onLoadDeparturePricing(hit, sailingRef).then((rows) => {
|
|
196
|
+
if (!cancelled)
|
|
197
|
+
setLivePricing(rows);
|
|
198
|
+
}, () => undefined);
|
|
199
|
+
return () => {
|
|
200
|
+
cancelled = true;
|
|
201
|
+
};
|
|
202
|
+
}, [hit, onLoadDeparturePricing, sailingRef]);
|
|
203
|
+
// Index the cheapest live price per cabin code. The upstream cabin ref is
|
|
204
|
+
// `<shipId>_<code>`, so the code is the trailing segment — matched against the
|
|
205
|
+
// option's `code`.
|
|
206
|
+
const livePriceByCode = useMemo(() => {
|
|
207
|
+
const byCode = new Map();
|
|
208
|
+
for (const row of livePricing ?? []) {
|
|
209
|
+
const code = row.cabinExternalId.slice(row.cabinExternalId.lastIndexOf("_") + 1);
|
|
210
|
+
const cents = Math.round(Number(row.pricePerPerson) * 100);
|
|
211
|
+
if (!Number.isFinite(cents))
|
|
212
|
+
continue;
|
|
213
|
+
const existing = byCode.get(code);
|
|
214
|
+
if (!existing || cents < existing.cents) {
|
|
215
|
+
byCode.set(code, { cents, currency: row.currency, availability: row.availability });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return byCode;
|
|
219
|
+
}, [livePricing]);
|
|
220
|
+
const handleBook = (option) => {
|
|
221
|
+
if (!hit)
|
|
222
|
+
return;
|
|
223
|
+
if (onBookOption) {
|
|
224
|
+
onBookOption(hit, departure, option);
|
|
225
|
+
}
|
|
226
|
+
else if (onBookDeparture) {
|
|
227
|
+
onBookDeparture(hit, departure);
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("div", { className: "text-[11px] font-medium uppercase tracking-wider text-muted-foreground", children: tableMessages.optionsHeading }), options.length === 0 ? (_jsx("div", { className: "text-xs text-muted-foreground", children: tableMessages.noOptions })) : (_jsx("ul", { className: "divide-y rounded-md border bg-background", children: options.map((option) => {
|
|
231
|
+
const canBook = isDepartureBookable(departure) &&
|
|
232
|
+
hit != null &&
|
|
233
|
+
(onBookOption != null || onBookDeparture != null);
|
|
234
|
+
const livePrice = option.code ? livePriceByCode.get(option.code) : undefined;
|
|
235
|
+
return (_jsxs("li", { className: "flex flex-wrap items-center justify-between gap-3 px-3 py-2 text-sm", children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col", children: [_jsx("span", { className: "font-medium", children: option.name }), option.description && (_jsx("span", { className: "text-xs text-muted-foreground", children: option.description }))] }), _jsxs("div", { className: "flex items-center gap-4", children: [livePrice ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "text-right text-xs capitalize text-muted-foreground", children: livePrice.availability.replace(/_/g, " ") }), _jsxs("div", { className: "text-right text-xs text-muted-foreground", children: [tableMessages.priceFrom, " ", _jsx("span", { className: "font-medium text-foreground tabular-nums", children: formatPriceCents(livePrice.cents, livePrice.currency) })] })] })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: "text-right text-xs text-muted-foreground", children: departureRemaining != null ? (_jsxs("span", { className: "tabular-nums", children: [_jsx("span", { className: "font-medium text-foreground", children: departureRemaining }), " ", tableMessages.remainingLabel] })) : ("—") }), departurePriceCents != null && (_jsxs("div", { className: "text-right text-xs text-muted-foreground", children: [tableMessages.priceFrom, " ", _jsx("span", { className: "font-medium text-foreground tabular-nums", children: formatPriceCents(departurePriceCents, currency) })] }))] })), canBook && (_jsx(Button, { size: "sm", variant: "outline", onClick: (event) => {
|
|
236
|
+
event.stopPropagation();
|
|
237
|
+
handleBook(option);
|
|
238
|
+
}, children: messages.book }))] })] }, option.id));
|
|
239
|
+
}) }))] }));
|
|
240
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { CatalogUiMessages } from "../i18n/messages.js";
|
|
3
|
+
import type { CatalogDetailEnrichment, CatalogSearchHit } from "../index.js";
|
|
4
|
+
import type { CatalogDetailSheetWidth } from "./catalog-detail-sheet.js";
|
|
5
|
+
/**
|
|
6
|
+
* Header-side "From {price}" indicator. Prefers per-departure
|
|
7
|
+
* `lowestPriceCents` minimums (only counting open/limited slots); falls
|
|
8
|
+
* back to the product's indexed `sellAmountCents` so always-on products
|
|
9
|
+
* still surface a price. Renders nothing when neither source has a
|
|
10
|
+
* value — the rest of the sheet still carries pricing detail elsewhere.
|
|
11
|
+
*/
|
|
12
|
+
export declare function ProductPriceFrom({ enrichment, fields, messages, }: {
|
|
13
|
+
enrichment: CatalogDetailEnrichment | null;
|
|
14
|
+
fields: Record<string, unknown>;
|
|
15
|
+
messages: CatalogUiMessages["catalogPage"]["detail"];
|
|
16
|
+
}): import("react/jsx-runtime").JSX.Element | null;
|
|
17
|
+
export declare function DefaultMediaGrid({ media, }: {
|
|
18
|
+
media: NonNullable<CatalogDetailEnrichment["media"]>;
|
|
19
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
20
|
+
export declare function sheetWidthClass(width: CatalogDetailSheetWidth): string;
|
|
21
|
+
export declare function Section({ title, children }: {
|
|
22
|
+
title?: string;
|
|
23
|
+
children: ReactNode;
|
|
24
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
25
|
+
export declare function AttributeList({ entries, formatters, messages, renderSupplierLink, }: {
|
|
26
|
+
entries: Array<[string, unknown]>;
|
|
27
|
+
formatters?: Record<string, (value: unknown) => ReactNode>;
|
|
28
|
+
messages: CatalogUiMessages["catalogPage"];
|
|
29
|
+
renderSupplierLink?: (supplierId: string, displayName: string) => ReactNode;
|
|
30
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
31
|
+
export declare function ArrayBadges({ value }: {
|
|
32
|
+
value: unknown;
|
|
33
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
34
|
+
export declare function toStringArray(value: unknown): string[];
|
|
35
|
+
/**
|
|
36
|
+
* Inline tag editor used by the catalog detail sheet. Holds a local
|
|
37
|
+
* working list so chip add/remove feels immediate; the indexed `value`
|
|
38
|
+
* re-syncs whenever the hit refetches after a successful mutation.
|
|
39
|
+
*/
|
|
40
|
+
export declare function InlineTagsEditor({ hit, value, onChange, placeholder, }: {
|
|
41
|
+
hit: CatalogSearchHit;
|
|
42
|
+
value: string[];
|
|
43
|
+
onChange: (hit: CatalogSearchHit, next: string[]) => Promise<void> | void;
|
|
44
|
+
placeholder: string;
|
|
45
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
46
|
+
export declare function defaultFormat(field: string, value: unknown, messages: CatalogUiMessages["catalogPage"]): ReactNode;
|
|
47
|
+
/**
|
|
48
|
+
* Turn camelCase / snake_case / dotted paths into a human-readable label.
|
|
49
|
+
* `defaultQuantity` → "Default quantity"
|
|
50
|
+
* `seller.operator_id` → "Seller · operator id"
|
|
51
|
+
* `text_embedding` → "Text embedding"
|
|
52
|
+
*/
|
|
53
|
+
export declare function humanize(key: string): string;
|
|
54
|
+
export declare function initialsOf(name: string): string;
|
|
55
|
+
export declare function stringOr<T>(value: unknown, fallback: T): string | T;
|
|
56
|
+
export declare function formatPriceCents(cents: number, currency?: string | null): string;
|
|
57
|
+
export declare function formatTemplate(template: string, values: Record<string, string | number>): string;
|
|
58
|
+
/**
|
|
59
|
+
* Compact, copyable id chip. Sourced cruise ids embed the full upstream
|
|
60
|
+
* SourceRef (`crus_sr_<base64url>`) so they're long; truncate the display,
|
|
61
|
+
* keep the full id on hover (`title`), and copy it on click.
|
|
62
|
+
*/
|
|
63
|
+
export declare function IdChip({ id }: {
|
|
64
|
+
id: string;
|
|
65
|
+
}): ReactNode;
|
|
66
|
+
/**
|
|
67
|
+
* One cabin in the cruise Cabins tab: photo gallery (carousel + lightbox),
|
|
68
|
+
* name + size/capacity, description, and amenity chips.
|
|
69
|
+
*/
|
|
70
|
+
//# sourceMappingURL=catalog-detail-parts.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"catalog-detail-parts.d.ts","sourceRoot":"","sources":["../../src/components/catalog-detail-parts.tsx"],"names":[],"mappings":"AAMA,OAAO,EAAsB,KAAK,SAAS,EAA+B,MAAM,OAAO,CAAA;AAGvF,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AAC5D,OAAO,KAAK,EAAE,uBAAuB,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA;AAC5E,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,2BAA2B,CAAA;AAExE;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,EAC/B,UAAU,EACV,MAAM,EACN,QAAQ,GACT,EAAE;IACD,UAAU,EAAE,uBAAuB,GAAG,IAAI,CAAA;IAC1C,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC/B,QAAQ,EAAE,iBAAiB,CAAC,aAAa,CAAC,CAAC,QAAQ,CAAC,CAAA;CACrD,kDAgCA;AAED,wBAAgB,gBAAgB,CAAC,EAC/B,KAAK,GACN,EAAE;IACD,KAAK,EAAE,WAAW,CAAC,uBAAuB,CAAC,OAAO,CAAC,CAAC,CAAA;CACrD,2CAmCA;AAaD,wBAAgB,eAAe,CAAC,KAAK,EAAE,uBAAuB,GAAG,MAAM,CAEtE;AAED,wBAAgB,OAAO,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,SAAS,CAAA;CAAE,2CAWnF;AAED,wBAAgB,aAAa,CAAC,EAC5B,OAAO,EACP,UAAU,EACV,QAAQ,EACR,kBAAkB,GACnB,EAAE;IACD,OAAO,EAAE,KAAK,CAAC,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;IACjC,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,SAAS,CAAC,CAAA;IAC1D,QAAQ,EAAE,iBAAiB,CAAC,aAAa,CAAC,CAAA;IAC1C,kBAAkB,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,KAAK,SAAS,CAAA;CAC5E,2CAaA;AAwDD,wBAAgB,WAAW,CAAC,EAAE,KAAK,EAAE,EAAE;IAAE,KAAK,EAAE,OAAO,CAAA;CAAE,2CAcxD;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,EAAE,CAGtD;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,EAC/B,GAAG,EACH,KAAK,EACL,QAAQ,EACR,WAAW,GACZ,EAAE;IACD,GAAG,EAAE,gBAAgB,CAAA;IACrB,KAAK,EAAE,MAAM,EAAE,CAAA;IACf,QAAQ,EAAE,CAAC,GAAG,EAAE,gBAAgB,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAA;IACzE,WAAW,EAAE,MAAM,CAAA;CACpB,2CAwGA;AAMD,wBAAgB,aAAa,CAC3B,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,OAAO,EACd,QAAQ,EAAE,iBAAiB,CAAC,aAAa,CAAC,GACzC,SAAS,CAkFX;AAED;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAQ5C;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAS/C;AAED,wBAAgB,QAAQ,CAAC,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,GAAG,MAAM,GAAG,CAAC,CAEnE;AAED,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAShF;AAED,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,GAAG,MAAM,CAKhG;AAED;;;;GAIG;AACH,wBAAgB,MAAM,CAAC,EAAE,EAAE,EAAE,EAAE;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAyBxD;AAED;;;GAGG"}
|
|
@@ -0,0 +1,282 @@
|
|
|
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 { Input } from "@voyant-travel/ui/components/input";
|
|
5
|
+
import { cn } from "@voyant-travel/ui/lib/utils";
|
|
6
|
+
import { Check, Copy, ExternalLink, Loader2, Minus, Plus, X } from "lucide-react";
|
|
7
|
+
import { useEffect, useRef, useState } from "react";
|
|
8
|
+
import { useCatalogUiMessagesOrDefault } from "../i18n/index.js";
|
|
9
|
+
/**
|
|
10
|
+
* Header-side "From {price}" indicator. Prefers per-departure
|
|
11
|
+
* `lowestPriceCents` minimums (only counting open/limited slots); falls
|
|
12
|
+
* back to the product's indexed `sellAmountCents` so always-on products
|
|
13
|
+
* still surface a price. Renders nothing when neither source has a
|
|
14
|
+
* value — the rest of the sheet still carries pricing detail elsewhere.
|
|
15
|
+
*/
|
|
16
|
+
export function ProductPriceFrom({ enrichment, fields, messages, }) {
|
|
17
|
+
const departureMin = (enrichment?.departures ?? []).reduce((acc, d) => {
|
|
18
|
+
if (typeof d.lowestPriceCents !== "number")
|
|
19
|
+
return acc;
|
|
20
|
+
if (d.status === "sold_out" || d.status === "closed" || d.status === "cancelled")
|
|
21
|
+
return acc;
|
|
22
|
+
if (acc && acc.amount <= d.lowestPriceCents)
|
|
23
|
+
return acc;
|
|
24
|
+
return { amount: d.lowestPriceCents, currency: d.currency ?? null };
|
|
25
|
+
}, null);
|
|
26
|
+
const fallbackAmount = typeof fields.sellAmountCents === "number"
|
|
27
|
+
? fields.sellAmountCents
|
|
28
|
+
: typeof fields.sellAmountCents === "string"
|
|
29
|
+
? Number(fields.sellAmountCents)
|
|
30
|
+
: null;
|
|
31
|
+
const fallbackCurrency = typeof fields.sellCurrency === "string" ? fields.sellCurrency : null;
|
|
32
|
+
const amount = departureMin?.amount ?? (Number.isFinite(fallbackAmount) ? fallbackAmount : null);
|
|
33
|
+
const currency = departureMin?.currency ?? fallbackCurrency;
|
|
34
|
+
if (amount == null)
|
|
35
|
+
return null;
|
|
36
|
+
return (_jsxs("div", { className: "flex flex-col items-end whitespace-nowrap", children: [_jsx("span", { className: "text-[10px] font-medium uppercase tracking-wider text-muted-foreground", children: messages.priceFromLabel }), _jsx("span", { className: "font-semibold text-base tabular-nums", children: formatPriceCents(amount, currency ?? undefined) })] }));
|
|
37
|
+
}
|
|
38
|
+
export function DefaultMediaGrid({ media, }) {
|
|
39
|
+
return (_jsx("div", { className: "grid grid-cols-3 gap-2", children: media.slice(0, 9).map((m, idx) => m.type === "image" || m.type == null ? (_jsx("a", { href: m.url, target: "_blank", rel: "noreferrer", className: "block aspect-square overflow-hidden rounded-md ring-1 ring-border hover:ring-primary", children: _jsx("img", { src: m.url, alt: m.caption ?? "", className: "h-full w-full object-cover", loading: "lazy" }) }, `${m.url}-${idx}`)) : (_jsx("a", { href: m.url, target: "_blank", rel: "noreferrer", className: "flex aspect-square items-center justify-center rounded-md bg-muted text-xs text-muted-foreground ring-1 ring-border hover:ring-primary", children: m.type }, `${m.url}-${idx}`))) }));
|
|
40
|
+
}
|
|
41
|
+
const SHEET_WIDTH_CLASSES = {
|
|
42
|
+
md: "max-w-md!",
|
|
43
|
+
lg: "max-w-lg!",
|
|
44
|
+
xl: "max-w-xl!",
|
|
45
|
+
"2xl": "max-w-2xl!",
|
|
46
|
+
"3xl": "max-w-3xl!",
|
|
47
|
+
"4xl": "max-w-4xl!",
|
|
48
|
+
"5xl": "max-w-5xl!",
|
|
49
|
+
"6xl": "max-w-6xl!",
|
|
50
|
+
};
|
|
51
|
+
export function sheetWidthClass(width) {
|
|
52
|
+
return SHEET_WIDTH_CLASSES[width] ?? width;
|
|
53
|
+
}
|
|
54
|
+
export function Section({ title, children }) {
|
|
55
|
+
return (_jsxs("section", { className: "flex flex-col gap-3", children: [title && (_jsx("h3", { className: "text-[11px] font-medium uppercase tracking-wider text-muted-foreground", children: title })), children] }));
|
|
56
|
+
}
|
|
57
|
+
export function AttributeList({ entries, formatters, messages, renderSupplierLink, }) {
|
|
58
|
+
return (_jsx("div", { className: "divide-y rounded-lg border", children: entries.map(([key, value]) => (_jsxs("div", { className: "grid grid-cols-[140px_1fr] items-baseline gap-4 px-3 py-2.5", children: [_jsx("span", { className: "text-xs text-muted-foreground", children: attributeLabel(key, messages) }), _jsx("span", { className: "text-sm break-words", children: renderAttributeValue(key, value, { formatters, messages, renderSupplierLink }) })] }, key))) }));
|
|
59
|
+
}
|
|
60
|
+
function attributeLabel(key, messages) {
|
|
61
|
+
const overrides = messages.detail.attributeLabels;
|
|
62
|
+
if (key === "sellAmount")
|
|
63
|
+
return overrides.sellAmount;
|
|
64
|
+
if (key === "supplierId")
|
|
65
|
+
return overrides.supplierId;
|
|
66
|
+
return humanize(key);
|
|
67
|
+
}
|
|
68
|
+
function renderAttributeValue(key, value, ctx) {
|
|
69
|
+
const { formatters, messages, renderSupplierLink } = ctx;
|
|
70
|
+
// Synthetic "Sell amount" — value is `{ amountCents, currency }`,
|
|
71
|
+
// emitted by the attribute-reshaping pass above.
|
|
72
|
+
if (key === "sellAmount" && value && typeof value === "object" && "amountCents" in value) {
|
|
73
|
+
const amountCents = value.amountCents;
|
|
74
|
+
const currency = value.currency;
|
|
75
|
+
const cents = typeof amountCents === "number" ? amountCents : Number(amountCents);
|
|
76
|
+
if (!Number.isFinite(cents))
|
|
77
|
+
return _jsx("span", { className: "text-muted-foreground", children: "\u2014" });
|
|
78
|
+
return (_jsx("span", { className: "font-medium tabular-nums", children: formatPriceCents(cents, typeof currency === "string" ? currency : undefined) }));
|
|
79
|
+
}
|
|
80
|
+
// Supplier — render an operator-supplied link to the supplier record,
|
|
81
|
+
// falling back to the plain formatter when no renderer is wired.
|
|
82
|
+
if (key === "supplierId" && renderSupplierLink && typeof value === "string" && value) {
|
|
83
|
+
const displayName = typeof formatters?.[key] === "function"
|
|
84
|
+
? String(formatters[key](value) ?? value)
|
|
85
|
+
: value;
|
|
86
|
+
return renderSupplierLink(value, displayName);
|
|
87
|
+
}
|
|
88
|
+
// Visibility — render as a badge so it reads at a glance.
|
|
89
|
+
if (key === "visibility" && typeof value === "string" && value) {
|
|
90
|
+
return (_jsx(Badge, { variant: value === "public" ? "default" : "secondary", className: "capitalize", children: value }));
|
|
91
|
+
}
|
|
92
|
+
return formatters?.[key] ? formatters[key](value) : defaultFormat(key, value, messages);
|
|
93
|
+
}
|
|
94
|
+
export function ArrayBadges({ value }) {
|
|
95
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
96
|
+
return _jsx("span", { className: "text-sm text-muted-foreground", children: "\u2014" });
|
|
97
|
+
}
|
|
98
|
+
return (_jsx("div", { className: "flex flex-wrap gap-1.5", children: value.map((v, i) => (_jsx(Badge, { variant: "secondary", className: "font-normal", children: String(v) }, `${String(v)}-${i}`))) }));
|
|
99
|
+
}
|
|
100
|
+
export function toStringArray(value) {
|
|
101
|
+
if (!Array.isArray(value))
|
|
102
|
+
return [];
|
|
103
|
+
return value.filter((v) => typeof v === "string" && v.length > 0);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Inline tag editor used by the catalog detail sheet. Holds a local
|
|
107
|
+
* working list so chip add/remove feels immediate; the indexed `value`
|
|
108
|
+
* re-syncs whenever the hit refetches after a successful mutation.
|
|
109
|
+
*/
|
|
110
|
+
export function InlineTagsEditor({ hit, value, onChange, placeholder, }) {
|
|
111
|
+
const messages = useCatalogUiMessagesOrDefault().catalogPage.detail;
|
|
112
|
+
// Seed the working set from the indexed value on each *hit change*
|
|
113
|
+
// only. The catalog search refetches after every mutation, but
|
|
114
|
+
// Typesense reindex is asynchronous — for a few seconds after the
|
|
115
|
+
// PATCH the indexed hit still carries the pre-mutation tags. If we
|
|
116
|
+
// re-synced from `value` on every render, those stale tags would
|
|
117
|
+
// clobber the chip the user just added. Pinning the seed to `hit.id`
|
|
118
|
+
// means a different product re-seeds, but our own optimistic state
|
|
119
|
+
// sticks while the index catches up.
|
|
120
|
+
const [tags, setTags] = useState(value);
|
|
121
|
+
const [draft, setDraft] = useState("");
|
|
122
|
+
const [pending, setPending] = useState(false);
|
|
123
|
+
const inputRef = useRef(null);
|
|
124
|
+
const seededIdRef = useRef(null);
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (seededIdRef.current === hit.id)
|
|
127
|
+
return;
|
|
128
|
+
seededIdRef.current = hit.id;
|
|
129
|
+
setTags(value);
|
|
130
|
+
setDraft("");
|
|
131
|
+
}, [hit.id, value]);
|
|
132
|
+
const commit = async (next) => {
|
|
133
|
+
const previous = tags;
|
|
134
|
+
setTags(next);
|
|
135
|
+
setPending(true);
|
|
136
|
+
try {
|
|
137
|
+
await onChange(hit, next);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
setTags(previous);
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
setPending(false);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const addTag = () => {
|
|
147
|
+
const trimmed = draft.trim().replace(/,+$/, "");
|
|
148
|
+
if (!trimmed)
|
|
149
|
+
return;
|
|
150
|
+
if (tags.includes(trimmed)) {
|
|
151
|
+
setDraft("");
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
void commit([...tags, trimmed]);
|
|
155
|
+
setDraft("");
|
|
156
|
+
};
|
|
157
|
+
const removeTag = (tag) => {
|
|
158
|
+
void commit(tags.filter((t) => t !== tag));
|
|
159
|
+
};
|
|
160
|
+
const handleKeyDown = (event) => {
|
|
161
|
+
if (event.key === "Enter" || event.key === ",") {
|
|
162
|
+
event.preventDefault();
|
|
163
|
+
addTag();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (event.key === "Backspace" && draft === "" && tags.length > 0) {
|
|
167
|
+
event.preventDefault();
|
|
168
|
+
removeTag(tags[tags.length - 1]);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-1.5", children: [tags.map((tag) => (_jsxs(Badge, { variant: "secondary", className: "gap-1 font-normal", children: [tag, _jsx("button", { type: "button", "aria-label": `Remove ${tag}`, className: "rounded-full hover:text-destructive", onClick: () => removeTag(tag), children: _jsx(X, { className: "h-3 w-3" }) })] }, tag))), _jsxs("button", { type: "button", className: "inline-flex items-center gap-1 rounded-full border border-dashed px-2 py-0.5 text-xs text-muted-foreground hover:border-foreground hover:text-foreground", onClick: () => inputRef.current?.focus(), children: [_jsx(Plus, { className: "h-3 w-3" }), messages.addTag] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Input, { ref: inputRef, value: draft, onChange: (event) => setDraft(event.target.value), onKeyDown: handleKeyDown, onBlur: () => {
|
|
172
|
+
if (draft.trim())
|
|
173
|
+
addTag();
|
|
174
|
+
}, placeholder: placeholder, className: "h-8 max-w-xs text-sm" }), pending && _jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin text-muted-foreground" })] })] }));
|
|
175
|
+
}
|
|
176
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
177
|
+
// Field formatting
|
|
178
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
179
|
+
export function defaultFormat(field, value, messages) {
|
|
180
|
+
if (value == null || value === "") {
|
|
181
|
+
return _jsx("span", { className: "text-muted-foreground", children: "\u2014" });
|
|
182
|
+
}
|
|
183
|
+
// Booleans (and string "true"/"false" — products schema stores some as strings)
|
|
184
|
+
if (typeof value === "boolean" || value === "true" || value === "false") {
|
|
185
|
+
const truthy = value === true || value === "true";
|
|
186
|
+
return (_jsxs("span", { className: cn("inline-flex items-center gap-1 text-sm", truthy ? "text-emerald-600" : "text-muted-foreground"), children: [truthy ? _jsx(Check, { className: "h-3.5 w-3.5" }) : _jsx(Minus, { className: "h-3.5 w-3.5" }), truthy ? messages.values.yes : messages.values.no] }));
|
|
187
|
+
}
|
|
188
|
+
// URLs (image, map, hero)
|
|
189
|
+
if (typeof value === "string" && /^https?:\/\//.test(value)) {
|
|
190
|
+
return (_jsxs("a", { href: value, target: "_blank", rel: "noreferrer", className: "inline-flex items-center gap-1 text-sm text-primary hover:underline", children: [messages.values.open, _jsx(ExternalLink, { className: "h-3 w-3" })] }));
|
|
191
|
+
}
|
|
192
|
+
// JSON-stringified ISO date — projection writes dates via JSON.stringify
|
|
193
|
+
// so they arrive wrapped in quotes ("\"2026-04-30T17:41:01Z\"")
|
|
194
|
+
if (typeof value === "string") {
|
|
195
|
+
const stripped = value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value;
|
|
196
|
+
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(stripped)) {
|
|
197
|
+
const d = new Date(stripped);
|
|
198
|
+
if (!Number.isNaN(d.getTime())) {
|
|
199
|
+
return (_jsx("time", { dateTime: stripped, className: "text-sm", children: new Intl.DateTimeFormat(undefined, {
|
|
200
|
+
year: "numeric",
|
|
201
|
+
month: "short",
|
|
202
|
+
day: "numeric",
|
|
203
|
+
}).format(d) }));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(stripped)) {
|
|
207
|
+
return _jsx("span", { className: "text-sm", children: stripped });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// Money fields stored as cents (integer string or number)
|
|
211
|
+
if (/Cents$/.test(field)) {
|
|
212
|
+
const num = typeof value === "number" ? value : Number(value);
|
|
213
|
+
if (Number.isFinite(num)) {
|
|
214
|
+
return (_jsx("span", { className: "font-medium text-sm", children: new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(num / 100) }));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// IDs — render in mono so they're visually distinct
|
|
218
|
+
if (/Id$|^id$/.test(field) && typeof value === "string") {
|
|
219
|
+
return _jsx("code", { className: "rounded bg-muted px-1.5 py-0.5 font-mono text-xs", children: value });
|
|
220
|
+
}
|
|
221
|
+
// Numeric strings (Typesense stores everything as string)
|
|
222
|
+
if (typeof value === "string" && /^-?\d+(\.\d+)?$/.test(value)) {
|
|
223
|
+
return _jsx("span", { className: "text-sm tabular-nums", children: value });
|
|
224
|
+
}
|
|
225
|
+
return _jsx("span", { className: "text-sm", children: String(value) });
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Turn camelCase / snake_case / dotted paths into a human-readable label.
|
|
229
|
+
* `defaultQuantity` → "Default quantity"
|
|
230
|
+
* `seller.operator_id` → "Seller · operator id"
|
|
231
|
+
* `text_embedding` → "Text embedding"
|
|
232
|
+
*/
|
|
233
|
+
export function humanize(key) {
|
|
234
|
+
const pretty = key
|
|
235
|
+
.replace(/\./g, " · ")
|
|
236
|
+
.replace(/_/g, " ")
|
|
237
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
238
|
+
.replace(/^(.)/, (m) => m.toUpperCase())
|
|
239
|
+
.toLowerCase();
|
|
240
|
+
return pretty.charAt(0).toUpperCase() + pretty.slice(1);
|
|
241
|
+
}
|
|
242
|
+
export function initialsOf(name) {
|
|
243
|
+
return (name
|
|
244
|
+
.split(/\s+/)
|
|
245
|
+
.filter(Boolean)
|
|
246
|
+
.slice(0, 2)
|
|
247
|
+
.map((w) => w[0]?.toUpperCase() ?? "")
|
|
248
|
+
.join("") || "?");
|
|
249
|
+
}
|
|
250
|
+
export function stringOr(value, fallback) {
|
|
251
|
+
return typeof value === "string" && value.length > 0 ? value : fallback;
|
|
252
|
+
}
|
|
253
|
+
export function formatPriceCents(cents, currency) {
|
|
254
|
+
if (!currency) {
|
|
255
|
+
return new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(cents / 100);
|
|
256
|
+
}
|
|
257
|
+
return new Intl.NumberFormat(undefined, {
|
|
258
|
+
style: "currency",
|
|
259
|
+
currency,
|
|
260
|
+
maximumFractionDigits: 0,
|
|
261
|
+
}).format(cents / 100);
|
|
262
|
+
}
|
|
263
|
+
export function formatTemplate(template, values) {
|
|
264
|
+
return template.replace(/\{(\w+)\}/g, (_, key) => {
|
|
265
|
+
const value = values[key];
|
|
266
|
+
return value === undefined ? "" : String(value);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Compact, copyable id chip. Sourced cruise ids embed the full upstream
|
|
271
|
+
* SourceRef (`crus_sr_<base64url>`) so they're long; truncate the display,
|
|
272
|
+
* keep the full id on hover (`title`), and copy it on click.
|
|
273
|
+
*/
|
|
274
|
+
export function IdChip({ id }) {
|
|
275
|
+
const [copied, setCopied] = useState(false);
|
|
276
|
+
return (_jsxs("button", { type: "button", title: id, onClick: () => {
|
|
277
|
+
void navigator.clipboard?.writeText(id).then(() => {
|
|
278
|
+
setCopied(true);
|
|
279
|
+
setTimeout(() => setCopied(false), 1200);
|
|
280
|
+
}, () => undefined);
|
|
281
|
+
}, className: "inline-flex max-w-[14rem] items-center gap-1 rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground transition-colors hover:text-foreground", children: [_jsx("span", { className: "truncate", children: id }), copied ? (_jsx(Check, { className: "h-3 w-3 shrink-0 text-emerald-500" })) : (_jsx(Copy, { className: "h-3 w-3 shrink-0 opacity-60" }))] }));
|
|
282
|
+
}
|