@resira/ui 0.3.1 → 0.4.0

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/README.md CHANGED
@@ -1,11 +1,11 @@
1
- # @resira/ui v0.3.1
1
+ # @resira/ui v0.3.2
2
2
 
3
3
  React booking UI for Resira. It includes a ready-to-embed widget, modal flow, provider, hooks, and lower-level components for custom booking experiences.
4
4
 
5
5
  ## Installation
6
6
 
7
7
  ```bash
8
- npm install @resira/ui@0.3.1 @resira/sdk@0.3.0
8
+ npm install @resira/ui@0.3.2 @resira/sdk@0.3.1
9
9
  ```
10
10
 
11
11
  Peer dependencies:
@@ -103,7 +103,7 @@ import "@resira/ui/styles.css";
103
103
  | `groupServicesByCategory` | `boolean` | Group services by linked equipment type |
104
104
  | `renderServiceCard` | `(product, selected) => ReactNode` | Custom service card rendering |
105
105
  | `baseUrl` | `string` | Optional API origin override |
106
- | `baseUrls` | `Array<string \| WeightedBaseUrl>` | Optional explicit origin list |
106
+ | `baseUrls` | `Array<string \| WeightedBaseUrl>` | Optional ordered fallback origin list |
107
107
  | `stripePublishableKey` | `string` | Stripe publishable key override |
108
108
  | `termsText` | `string` | Terms text override |
109
109
  | `waiverText` | `string` | Waiver text override |
@@ -233,4 +233,6 @@ import { DishShowcase } from "@resira/ui";
233
233
 
234
234
  - The widget supports catalog mode when `resourceId` is omitted.
235
235
  - Service and watersport flows group services by linked equipment category by default.
236
+ - Loading and error states now announce themselves to assistive technologies on the main widget surfaces.
237
+ - Client-side API origin stickiness/load-balancing has been removed; routing is deterministic unless you pass explicit fallback origins.
236
238
  - The package is intended for browser usage and ships CSS separately via `@resira/ui/styles.css`.
package/dist/index.cjs CHANGED
@@ -224,6 +224,12 @@ function ResiraProvider({
224
224
  const visibleServiceCount = config?.visibleServiceCount ?? 4;
225
225
  const groupServicesByCategory = config?.groupServicesByCategory ?? true;
226
226
  const renderServiceCard = config?.renderServiceCard;
227
+ const showStepIndicator = config?.showStepIndicator ?? true;
228
+ const deeplink = config?.deeplink;
229
+ const deeplinkGuest = config?.deeplinkGuest;
230
+ const onStepChange = config?.onStepChange;
231
+ const onBookingComplete = config?.onBookingComplete;
232
+ const onError = config?.onError;
227
233
  const cssVars = react.useMemo(() => themeToCSS(theme), [theme]);
228
234
  const value = react.useMemo(
229
235
  () => ({
@@ -250,9 +256,15 @@ function ResiraProvider({
250
256
  serviceLayout,
251
257
  visibleServiceCount,
252
258
  groupServicesByCategory,
253
- renderServiceCard
259
+ renderServiceCard,
260
+ showStepIndicator,
261
+ deeplink,
262
+ deeplinkGuest,
263
+ onStepChange,
264
+ onBookingComplete,
265
+ onError
254
266
  }),
255
- [client, resourceId, activeResourceId, setActiveResourceId, catalogMode, allowMultiSelect, domain, theme, locale, domainConfig, stripePublishableKey, termsText, waiverText, showWaiver, showTerms, showRemainingSpots, depositPercent, refundPolicy, onClose, classNames, serviceLayout, visibleServiceCount, groupServicesByCategory, renderServiceCard]
267
+ [client, resourceId, activeResourceId, setActiveResourceId, catalogMode, allowMultiSelect, domain, theme, locale, domainConfig, stripePublishableKey, termsText, waiverText, showWaiver, showTerms, showRemainingSpots, depositPercent, refundPolicy, onClose, classNames, serviceLayout, visibleServiceCount, groupServicesByCategory, renderServiceCard, showStepIndicator, deeplink, deeplinkGuest, onStepChange, onBookingComplete, onError]
256
268
  );
257
269
  return /* @__PURE__ */ jsxRuntime.jsx(ResiraContext.Provider, { value, children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "resira-root", style: cssVars, children }) });
258
270
  }
@@ -547,12 +559,17 @@ function useDish(dishId) {
547
559
  }, [client, dishId]);
548
560
  return { dish, loading, error };
549
561
  }
550
- function useDishes() {
562
+ function useDishes(enabled = true) {
551
563
  const { client } = useResira();
552
564
  const [dishes, setDishes] = react.useState([]);
553
- const [loading, setLoading] = react.useState(true);
565
+ const [loading, setLoading] = react.useState(enabled);
554
566
  const [error, setError] = react.useState(null);
555
567
  react.useEffect(() => {
568
+ if (!enabled) {
569
+ setLoading(false);
570
+ setError(null);
571
+ return;
572
+ }
556
573
  let cancelled = false;
557
574
  async function fetchDishes() {
558
575
  try {
@@ -576,7 +593,7 @@ function useDishes() {
576
593
  return () => {
577
594
  cancelled = true;
578
595
  };
579
- }, [client]);
596
+ }, [client, enabled]);
580
597
  return { dishes, loading, error };
581
598
  }
582
599
  var defaultSize = 20;
@@ -1542,13 +1559,13 @@ function ResourcePicker({
1542
1559
  error = null
1543
1560
  }) {
1544
1561
  if (loading) {
1545
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-loading", children: [
1546
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "resira-spinner" }),
1562
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-loading", role: "status", "aria-live": "polite", "aria-busy": "true", children: [
1563
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "resira-spinner", "aria-hidden": "true" }),
1547
1564
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "resira-loading-text", children: "Loading resources\u2026" })
1548
1565
  ] });
1549
1566
  }
1550
1567
  if (error) {
1551
- return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "resira-error", children: /* @__PURE__ */ jsxRuntime.jsx("p", { className: "resira-error-message", children: error }) });
1568
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "resira-error", role: "alert", "aria-live": "assertive", children: /* @__PURE__ */ jsxRuntime.jsx("p", { className: "resira-error-message", children: error }) });
1552
1569
  }
1553
1570
  if (resources.length === 0) {
1554
1571
  return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "resira-empty", children: /* @__PURE__ */ jsxRuntime.jsx("p", { children: "No resources available at the moment." }) });
@@ -1627,39 +1644,90 @@ function formatCategoryLabel(resourceType) {
1627
1644
  return resourceType.trim().split(/[_\-\s]+/).filter(Boolean).map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1).toLowerCase()).join(" ");
1628
1645
  }
1629
1646
  function groupProductsByCategory(products, resources) {
1630
- const resourceTypeById = new Map(
1631
- resources.map((resource) => [resource.id, resource.resourceType])
1647
+ const resourceById = new Map(
1648
+ resources.map((resource) => [resource.id, resource])
1632
1649
  );
1633
1650
  const groups = /* @__PURE__ */ new Map();
1634
1651
  products.forEach((product) => {
1635
- const categoryType = product.equipmentIds.map((equipmentId) => resourceTypeById.get(equipmentId)?.trim()).find((resourceType) => Boolean(resourceType));
1652
+ let categoryResource;
1653
+ const categoryType = product.equipmentIds.map((equipmentId) => {
1654
+ const res = resourceById.get(equipmentId);
1655
+ if (res?.resourceType?.trim() && !categoryResource) {
1656
+ categoryResource = res;
1657
+ }
1658
+ return res?.resourceType?.trim();
1659
+ }).find((resourceType) => Boolean(resourceType));
1636
1660
  const groupId = categoryType?.toLowerCase() ?? UNCATEGORIZED_CATEGORY_KEY;
1637
1661
  const label = categoryType ? formatCategoryLabel(categoryType) : UNCATEGORIZED_CATEGORY_LABEL;
1638
1662
  if (!groups.has(groupId)) {
1639
1663
  groups.set(groupId, {
1640
1664
  id: groupId,
1641
1665
  label,
1666
+ imageUrl: categoryResource?.imageUrl ?? void 0,
1642
1667
  products: []
1643
1668
  });
1644
1669
  }
1645
- groups.get(groupId)?.products.push(product);
1670
+ const group = groups.get(groupId);
1671
+ group.products.push(product);
1672
+ if (!group.imageUrl && categoryResource?.imageUrl) {
1673
+ group.imageUrl = categoryResource.imageUrl;
1674
+ }
1646
1675
  });
1647
1676
  return Array.from(groups.values());
1648
1677
  }
1649
- function DefaultServiceCard({
1678
+ function BackArrow() {
1679
+ return /* @__PURE__ */ jsxRuntime.jsxs("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [
1680
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M19 12H5" }),
1681
+ /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M12 19l-7-7 7-7" })
1682
+ ] });
1683
+ }
1684
+ function CategoryTile({
1685
+ group,
1686
+ onClick
1687
+ }) {
1688
+ return /* @__PURE__ */ jsxRuntime.jsxs(
1689
+ "button",
1690
+ {
1691
+ type: "button",
1692
+ className: "resira-category-tile",
1693
+ onClick,
1694
+ children: [
1695
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-category-tile-image", children: [
1696
+ /* @__PURE__ */ jsxRuntime.jsx(
1697
+ "img",
1698
+ {
1699
+ src: group.imageUrl || PLACEHOLDER_IMG2,
1700
+ alt: group.label,
1701
+ loading: "lazy"
1702
+ }
1703
+ ),
1704
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "resira-category-tile-overlay" })
1705
+ ] }),
1706
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-category-tile-content", children: [
1707
+ /* @__PURE__ */ jsxRuntime.jsx("h3", { className: "resira-category-tile-name", children: group.label }),
1708
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "resira-category-tile-count", children: [
1709
+ group.products.length,
1710
+ " ",
1711
+ group.products.length === 1 ? "service" : "services"
1712
+ ] })
1713
+ ] })
1714
+ ]
1715
+ }
1716
+ );
1717
+ }
1718
+ function ServiceOverlayCard({
1650
1719
  product,
1651
1720
  isSelected,
1652
- layout,
1653
1721
  locale,
1654
1722
  cardClassName
1655
1723
  }) {
1656
1724
  const currency = product.currency ?? "EUR";
1657
1725
  const priceLabel = product.pricingModel === "per_rider" ? "per rider" : product.pricingModel === "per_person" ? locale.perPerson : locale.perSession;
1658
- let className = `resira-service-card resira-service-card--${layout}`;
1659
- if (isSelected) className += " resira-service-card--selected";
1726
+ let className = "resira-service-overlay-card";
1727
+ if (isSelected) className += " resira-service-overlay-card--selected";
1660
1728
  if (cardClassName) className += ` ${cardClassName}`;
1661
1729
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className, children: [
1662
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "resira-service-card-image", children: /* @__PURE__ */ jsxRuntime.jsx(
1730
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "resira-service-overlay-card-bg", children: /* @__PURE__ */ jsxRuntime.jsx(
1663
1731
  "img",
1664
1732
  {
1665
1733
  src: product.imageUrl ?? PLACEHOLDER_IMG2,
@@ -1667,30 +1735,27 @@ function DefaultServiceCard({
1667
1735
  loading: "lazy"
1668
1736
  }
1669
1737
  ) }),
1670
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-service-card-body", children: [
1671
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-service-card-top", children: [
1672
- /* @__PURE__ */ jsxRuntime.jsx("h3", { className: "resira-service-card-name", children: product.name }),
1673
- product.description && /* @__PURE__ */ jsxRuntime.jsx("p", { className: "resira-service-card-desc", children: product.description })
1738
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "resira-service-overlay-card-gradient" }),
1739
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-service-overlay-card-content", children: [
1740
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-service-overlay-card-top", children: [
1741
+ /* @__PURE__ */ jsxRuntime.jsx("h3", { className: "resira-service-overlay-card-name", children: product.name }),
1742
+ product.description && /* @__PURE__ */ jsxRuntime.jsx("p", { className: "resira-service-overlay-card-desc", children: product.description })
1674
1743
  ] }),
1675
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-service-card-bottom", children: [
1676
- /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "resira-service-card-price", children: [
1744
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-service-overlay-card-bottom", children: [
1745
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "resira-service-overlay-card-price", children: [
1677
1746
  formatPrice2(product.priceCents, currency),
1678
- /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "resira-service-card-price-unit", children: [
1747
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "resira-service-overlay-card-price-unit", children: [
1679
1748
  "/",
1680
1749
  priceLabel
1681
1750
  ] })
1682
1751
  ] }),
1683
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-service-card-pills", children: [
1684
- product.durationMinutes > 0 && /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "resira-service-card-pill", children: [
1685
- /* @__PURE__ */ jsxRuntime.jsx(ClockIcon, { size: 12 }),
1752
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-service-overlay-card-pills", children: [
1753
+ product.durationMinutes > 0 && /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "resira-service-overlay-card-pill", children: [
1754
+ /* @__PURE__ */ jsxRuntime.jsx(ClockIcon, { size: 11 }),
1686
1755
  formatDuration2(product.durationMinutes)
1687
1756
  ] }),
1688
- product.pricingModel === "per_person" && /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "resira-service-card-pill", children: [
1689
- /* @__PURE__ */ jsxRuntime.jsx(UsersIcon, { size: 12 }),
1690
- product.maxPartySize ? `1\u2013${product.maxPartySize}` : locale.perPerson
1691
- ] }),
1692
- product.maxPartySize && product.pricingModel !== "per_person" && /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "resira-service-card-pill", children: [
1693
- /* @__PURE__ */ jsxRuntime.jsx(UsersIcon, { size: 12 }),
1757
+ product.maxPartySize && /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "resira-service-overlay-card-pill", children: [
1758
+ /* @__PURE__ */ jsxRuntime.jsx(UsersIcon, { size: 11 }),
1694
1759
  "max ",
1695
1760
  product.maxPartySize
1696
1761
  ] })
@@ -1718,7 +1783,8 @@ function ProductSelector({
1718
1783
  error = null,
1719
1784
  layout: layoutProp,
1720
1785
  visibleCount: visibleCountProp,
1721
- groupByCategory: groupByCategoryProp
1786
+ groupByCategory: groupByCategoryProp,
1787
+ initialCategory
1722
1788
  }) {
1723
1789
  const {
1724
1790
  locale,
@@ -1732,81 +1798,107 @@ function ProductSelector({
1732
1798
  const visibleCount = visibleCountProp ?? providerCount;
1733
1799
  const groupByCategory = groupByCategoryProp ?? providerGroupByCategory;
1734
1800
  const containerRef = react.useRef(null);
1801
+ const [activeCategory, setActiveCategory] = react.useState(initialCategory ?? null);
1735
1802
  const productGroups = react.useMemo(
1736
- () => groupByCategory ? groupProductsByCategory(products, resources) : [{ id: "all", label: "", products }],
1803
+ () => groupByCategory ? groupProductsByCategory(products, resources) : [{ id: "all", label: "", imageUrl: void 0, products }],
1737
1804
  [groupByCategory, products, resources]
1738
1805
  );
1739
- const containerStyle = react.useMemo(() => {
1740
- if (layout === "horizontal") {
1741
- return {};
1806
+ const skipCategoryView = productGroups.length <= 1;
1807
+ react.useEffect(() => {
1808
+ if (initialCategory && productGroups.some((g) => g.id === initialCategory)) {
1809
+ setActiveCategory(initialCategory);
1810
+ }
1811
+ }, [initialCategory, productGroups]);
1812
+ react.useEffect(() => {
1813
+ if (activeCategory && !productGroups.some((g) => g.id === activeCategory)) {
1814
+ setActiveCategory(null);
1742
1815
  }
1743
- const cardHeight = 88;
1744
- const gap = 10;
1745
- const sectionHeaderHeight = groupByCategory ? productGroups.length * 30 : 0;
1746
- const sectionGap = groupByCategory ? Math.max(productGroups.length - 1, 0) * 12 : 0;
1747
- const maxHeight = visibleCount * cardHeight + (visibleCount - 1) * gap + sectionHeaderHeight + sectionGap;
1816
+ }, [activeCategory, productGroups]);
1817
+ const activeCategoryGroup = react.useMemo(
1818
+ () => productGroups.find((g) => g.id === activeCategory) ?? null,
1819
+ [productGroups, activeCategory]
1820
+ );
1821
+ const displayProducts = react.useMemo(() => {
1822
+ if (skipCategoryView) return products;
1823
+ return activeCategoryGroup?.products ?? [];
1824
+ }, [skipCategoryView, products, activeCategoryGroup]);
1825
+ const containerStyle = react.useMemo(() => {
1826
+ if (layout === "horizontal") return {};
1827
+ const cardHeight = 180;
1828
+ const gap = 12;
1829
+ const maxHeight = visibleCount * cardHeight + (visibleCount - 1) * gap;
1748
1830
  return { maxHeight, overflowY: "auto" };
1749
- }, [groupByCategory, layout, productGroups.length, visibleCount]);
1831
+ }, [layout, visibleCount]);
1750
1832
  if (loading) {
1751
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-loading", children: [
1752
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "resira-spinner" }),
1833
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-loading", role: "status", "aria-live": "polite", "aria-busy": "true", children: [
1834
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "resira-spinner", "aria-hidden": "true" }),
1753
1835
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "resira-loading-text", children: locale.loading })
1754
1836
  ] });
1755
1837
  }
1756
1838
  if (error) {
1757
- return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "resira-error", children: /* @__PURE__ */ jsxRuntime.jsx("p", { className: "resira-error-message", children: error }) });
1839
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "resira-error", role: "alert", "aria-live": "assertive", children: /* @__PURE__ */ jsxRuntime.jsx("p", { className: "resira-error-message", children: error }) });
1758
1840
  }
1759
1841
  if (products.length === 0) {
1760
1842
  return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "resira-empty", children: /* @__PURE__ */ jsxRuntime.jsx("p", { children: "No services available at the moment." }) });
1761
1843
  }
1844
+ if (groupByCategory && !skipCategoryView && !activeCategory) {
1845
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: "resira-service-picker", children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "resira-category-grid", children: productGroups.map((group) => /* @__PURE__ */ jsxRuntime.jsx(
1846
+ CategoryTile,
1847
+ {
1848
+ group,
1849
+ onClick: () => setActiveCategory(group.id)
1850
+ },
1851
+ group.id
1852
+ )) }) });
1853
+ }
1762
1854
  const listClassName = [
1763
- "resira-service-list",
1764
- `resira-service-list--${layout}`,
1855
+ "resira-service-overlay-list",
1765
1856
  classNames.serviceList
1766
1857
  ].filter(Boolean).join(" ");
1767
- const groupedListClassName = [
1768
- listClassName,
1769
- groupByCategory ? "resira-service-list--grouped" : ""
1770
- ].filter(Boolean).join(" ");
1771
1858
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-service-picker", children: [
1859
+ groupByCategory && !skipCategoryView && activeCategory && /* @__PURE__ */ jsxRuntime.jsxs(
1860
+ "button",
1861
+ {
1862
+ type: "button",
1863
+ className: "resira-category-back",
1864
+ onClick: () => setActiveCategory(null),
1865
+ children: [
1866
+ /* @__PURE__ */ jsxRuntime.jsx(BackArrow, {}),
1867
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: activeCategoryGroup?.label ?? "All categories" })
1868
+ ]
1869
+ }
1870
+ ),
1772
1871
  /* @__PURE__ */ jsxRuntime.jsx(
1773
1872
  "div",
1774
1873
  {
1775
1874
  ref: containerRef,
1776
- className: groupByCategory ? "resira-service-group-list" : listClassName,
1875
+ className: listClassName,
1777
1876
  style: containerStyle,
1778
- children: productGroups.map((group) => /* @__PURE__ */ jsxRuntime.jsxs("section", { className: "resira-service-group", "aria-label": group.label || locale.chooseService, children: [
1779
- groupByCategory && group.label && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-service-group-header", children: [
1780
- /* @__PURE__ */ jsxRuntime.jsx("h3", { className: "resira-service-group-title", children: group.label }),
1781
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "resira-service-group-count", children: group.products.length })
1782
- ] }),
1783
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: groupByCategory ? groupedListClassName : listClassName, children: group.products.map((product) => {
1784
- const isSelected = selectedId === product.id;
1785
- return /* @__PURE__ */ jsxRuntime.jsx(
1786
- "button",
1787
- {
1788
- type: "button",
1789
- className: "resira-service-card-btn",
1790
- onClick: () => onSelect(product),
1791
- "aria-pressed": isSelected,
1792
- children: renderServiceCard ? renderServiceCard(product, isSelected) : /* @__PURE__ */ jsxRuntime.jsx(
1793
- DefaultServiceCard,
1794
- {
1795
- product,
1796
- isSelected,
1797
- layout,
1798
- locale,
1799
- cardClassName: isSelected ? classNames.serviceCardSelected : classNames.serviceCard
1800
- }
1801
- )
1802
- },
1803
- product.id
1804
- );
1805
- }) })
1806
- ] }, group.id))
1877
+ children: displayProducts.map((product) => {
1878
+ const isSelected = selectedId === product.id;
1879
+ return /* @__PURE__ */ jsxRuntime.jsx(
1880
+ "button",
1881
+ {
1882
+ type: "button",
1883
+ className: "resira-service-card-btn",
1884
+ onClick: () => onSelect(product),
1885
+ "aria-pressed": isSelected,
1886
+ children: renderServiceCard ? renderServiceCard(product, isSelected) : /* @__PURE__ */ jsxRuntime.jsx(
1887
+ ServiceOverlayCard,
1888
+ {
1889
+ product,
1890
+ isSelected,
1891
+ locale,
1892
+ cardClassName: isSelected ? classNames.serviceCardSelected : classNames.serviceCard
1893
+ }
1894
+ )
1895
+ },
1896
+ product.id
1897
+ );
1898
+ })
1807
1899
  }
1808
1900
  ),
1809
- layout === "vertical" && products.length > visibleCount && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-service-scroll-hint", children: [
1901
+ displayProducts.length > visibleCount && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-service-scroll-hint", children: [
1810
1902
  /* @__PURE__ */ jsxRuntime.jsx("span", { children: "Scroll for more" }),
1811
1903
  /* @__PURE__ */ jsxRuntime.jsx("svg", { width: "12", height: "12", viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M4 6l4 4 4-4" }) })
1812
1904
  ] })
@@ -2330,8 +2422,8 @@ function PaymentForm({
2330
2422
  ] })
2331
2423
  ] }) }),
2332
2424
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-payment-element-container", children: [
2333
- !ready && !error && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-loading", style: { padding: "24px 0" }, children: [
2334
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "resira-spinner" }),
2425
+ !ready && !error && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-loading", style: { padding: "24px 0" }, role: "status", "aria-live": "polite", "aria-busy": "true", children: [
2426
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "resira-spinner", "aria-hidden": "true" }),
2335
2427
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "resira-loading-text", children: locale.loading })
2336
2428
  ] }),
2337
2429
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -2343,7 +2435,7 @@ function PaymentForm({
2343
2435
  }
2344
2436
  )
2345
2437
  ] }),
2346
- error && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-payment-error", children: [
2438
+ error && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-payment-error", role: "alert", "aria-live": "assertive", children: [
2347
2439
  /* @__PURE__ */ jsxRuntime.jsx(AlertCircleIcon, { size: 16 }),
2348
2440
  /* @__PURE__ */ jsxRuntime.jsx("span", { children: error })
2349
2441
  ] }),
@@ -2601,7 +2693,13 @@ function ResiraBookingWidget() {
2601
2693
  showWaiver,
2602
2694
  showRemainingSpots,
2603
2695
  depositPercent,
2604
- onClose
2696
+ onClose,
2697
+ showStepIndicator,
2698
+ deeplink,
2699
+ deeplinkGuest,
2700
+ onStepChange,
2701
+ onBookingComplete,
2702
+ onError
2605
2703
  } = useResira();
2606
2704
  const isDateBased = domain === "rental";
2607
2705
  const isTimeBased = domain === "restaurant" || domain === "watersport" || domain === "service";
@@ -2959,8 +3057,9 @@ function ResiraBookingWidget() {
2959
3057
  setStep("confirmation");
2960
3058
  }
2961
3059
  }, [createPayment, paymentPayload, confirmPayment]);
2962
- const handlePaymentError = react.useCallback((_msg) => {
2963
- }, []);
3060
+ const handlePaymentError = react.useCallback((msg) => {
3061
+ onError?.("payment_error", msg);
3062
+ }, [onError]);
2964
3063
  const handleSubmitNoPayment = react.useCallback(async () => {
2965
3064
  if (showTerms && !termsAccepted) {
2966
3065
  setTermsError(locale.termsRequired);
@@ -2990,9 +3089,63 @@ function ResiraBookingWidget() {
2990
3089
  }, [guest, selection, locale, submit, termsAccepted, waiverAccepted, showTerms, showWaiver, activeResourceId, selectedProduct]);
2991
3090
  const footerBusy = submitting || creatingPayment || confirmingPayment || paymentFormSubmitting;
2992
3091
  const paymentActionDisabled = !paymentIntent || !paymentFormReady || paymentFormSubmitting || confirmingPayment;
3092
+ const [deeplinkApplied, setDeeplinkApplied] = react.useState(false);
3093
+ react.useEffect(() => {
3094
+ if (deeplinkApplied || !deeplink) return;
3095
+ if (deeplink.productId && products.length > 0 && !selectedProduct) {
3096
+ const target = products.find((p) => p.id === deeplink.productId);
3097
+ if (target) {
3098
+ handleProductSelect(target);
3099
+ }
3100
+ }
3101
+ if (deeplink.partySize || deeplink.duration || deeplink.date) {
3102
+ setSelection((prev) => ({
3103
+ ...prev,
3104
+ ...deeplink.partySize ? { partySize: deeplink.partySize } : {},
3105
+ ...deeplink.duration ? { duration: deeplink.duration } : {},
3106
+ ...deeplink.date ? { startDate: deeplink.date } : {}
3107
+ }));
3108
+ if (deeplink.date) setSlotDate(deeplink.date);
3109
+ }
3110
+ if (deeplinkGuest) {
3111
+ setGuest((prev) => ({
3112
+ guestName: deeplinkGuest.name ?? prev.guestName,
3113
+ guestEmail: deeplinkGuest.email ?? prev.guestEmail,
3114
+ guestPhone: deeplinkGuest.phone ?? prev.guestPhone,
3115
+ notes: deeplinkGuest.notes ?? prev.notes
3116
+ }));
3117
+ }
3118
+ if (deeplink.productId && step === "resource" && isServiceBased) {
3119
+ const target = products.find((p) => p.id === deeplink.productId);
3120
+ if (target) {
3121
+ setStep(STEPS[stepIndex("resource", STEPS) + 1]);
3122
+ }
3123
+ }
3124
+ setDeeplinkApplied(true);
3125
+ }, [deeplink, deeplinkGuest, products, selectedProduct, step, isServiceBased, STEPS, handleProductSelect]);
3126
+ react.useEffect(() => {
3127
+ onStepChange?.(step);
3128
+ }, [step, onStepChange]);
3129
+ react.useEffect(() => {
3130
+ if (step === "confirmation") {
3131
+ const bookingId = reservation?.id ?? paymentIntent?.reservationId;
3132
+ onBookingComplete?.({
3133
+ reservationId: bookingId,
3134
+ product: selectedProduct ?? void 0,
3135
+ selection,
3136
+ guest
3137
+ });
3138
+ }
3139
+ }, [step]);
3140
+ react.useEffect(() => {
3141
+ if (submitError) onError?.("submit_error", submitError);
3142
+ }, [submitError, onError]);
3143
+ react.useEffect(() => {
3144
+ if (paymentError) onError?.("payment_error", paymentError);
3145
+ }, [paymentError, onError]);
2993
3146
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-widget", children: [
2994
3147
  step !== "confirmation" && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-widget-topbar", children: [
2995
- /* @__PURE__ */ jsxRuntime.jsx("nav", { className: "resira-steps", "aria-label": "Booking steps", children: STEPS.filter((s) => s !== "confirmation").map((s, i) => {
3148
+ showStepIndicator && /* @__PURE__ */ jsxRuntime.jsx("nav", { className: "resira-steps", "aria-label": "Booking steps", children: STEPS.filter((s) => s !== "confirmation").map((s, i) => {
2996
3149
  const isCompleted = stepIndex(step, STEPS) > i;
2997
3150
  const isActive = s === step;
2998
3151
  return /* @__PURE__ */ jsxRuntime.jsxs(
@@ -3014,7 +3167,7 @@ function ResiraBookingWidget() {
3014
3167
  stepSubtitle && /* @__PURE__ */ jsxRuntime.jsx("p", { className: "resira-widget-subtitle", children: stepSubtitle })
3015
3168
  ] })
3016
3169
  ] }),
3017
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-widget-body", children: [
3170
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-widget-body", "aria-busy": loading || calendarLoading || resourcesLoading || productsLoading || footerBusy, children: [
3018
3171
  step === "resource" && isServiceBased && /* @__PURE__ */ jsxRuntime.jsx(
3019
3172
  ProductSelector,
3020
3173
  {
@@ -3037,10 +3190,10 @@ function ResiraBookingWidget() {
3037
3190
  error: resourcesError
3038
3191
  }
3039
3192
  ),
3040
- step === "availability" && /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: (loading || calendarLoading) && !availability && !calendarData ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-loading", children: [
3041
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "resira-spinner" }),
3193
+ step === "availability" && /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: (loading || calendarLoading) && !availability && !calendarData ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-loading", role: "status", "aria-live": "polite", "aria-busy": "true", children: [
3194
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "resira-spinner", "aria-hidden": "true" }),
3042
3195
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "resira-loading-text", children: locale.loading })
3043
- ] }) : error ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-error", children: [
3196
+ ] }) : error ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-error", role: "alert", "aria-live": "assertive", children: [
3044
3197
  /* @__PURE__ */ jsxRuntime.jsx(AlertCircleIcon, { size: 24 }),
3045
3198
  /* @__PURE__ */ jsxRuntime.jsx("p", { className: "resira-error-message", children: error }),
3046
3199
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -3511,12 +3664,17 @@ function supportsArQuickLook() {
3511
3664
  const a = document.createElement("a");
3512
3665
  return a.relList?.supports?.("ar") ?? false;
3513
3666
  }
3667
+ function supportsModelViewer() {
3668
+ if (typeof window === "undefined") return false;
3669
+ return typeof customElements !== "undefined" && customElements.get("model-viewer") !== void 0;
3670
+ }
3514
3671
  function ModelViewer3D({
3515
3672
  dish,
3516
3673
  onClose
3517
3674
  }) {
3518
3675
  const model = dish.model;
3519
3676
  const arSupported = supportsArQuickLook();
3677
+ const modelViewerSupported = supportsModelViewer();
3520
3678
  const arLinkRef = react.useRef(null);
3521
3679
  const handleArClick = react.useCallback(() => {
3522
3680
  arLinkRef.current?.click();
@@ -3538,7 +3696,7 @@ function ModelViewer3D({
3538
3696
  ),
3539
3697
  /* @__PURE__ */ jsxRuntime.jsx("h3", { className: "resira-dish-viewer-title", children: dish.name })
3540
3698
  ] }),
3541
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "resira-dish-viewer-canvas", children: model?.glbUrl ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
3699
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "resira-dish-viewer-canvas", children: model?.glbUrl && modelViewerSupported ? /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
3542
3700
  /* @__PURE__ */ jsxRuntime.jsx(
3543
3701
  "model-viewer",
3544
3702
  {
@@ -3580,7 +3738,7 @@ function ModelViewer3D({
3580
3738
  className: "resira-dish-viewer-fallback-img"
3581
3739
  }
3582
3740
  ),
3583
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "resira-dish-viewer-no3d", children: "No 3D model available" })
3741
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "resira-dish-viewer-no3d", children: model?.glbUrl ? "3D preview is unavailable here. Showing image instead." : "No 3D model available" })
3584
3742
  ] }) : /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-dish-viewer-fallback", children: [
3585
3743
  /* @__PURE__ */ jsxRuntime.jsx(CubeIcon, { size: 48 }),
3586
3744
  /* @__PURE__ */ jsxRuntime.jsx("p", { className: "resira-dish-viewer-no3d", children: "No preview available" })
@@ -3594,7 +3752,7 @@ function ModelViewer3D({
3594
3752
  dish.allergens && dish.allergens.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "resira-dish-viewer-allergens", children: dish.allergens.map((a) => /* @__PURE__ */ jsxRuntime.jsx("span", { className: "resira-dish-viewer-allergen", children: a }, a)) })
3595
3753
  ] }),
3596
3754
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-dish-viewer-actions", children: [
3597
- model?.glbUrl && /* @__PURE__ */ jsxRuntime.jsxs(
3755
+ model?.glbUrl && modelViewerSupported && /* @__PURE__ */ jsxRuntime.jsxs(
3598
3756
  "button",
3599
3757
  {
3600
3758
  type: "button",
@@ -3673,7 +3831,8 @@ function DishShowcase({
3673
3831
  const { dish: singleDish, loading: singleLoading, error: singleError } = useDish(
3674
3832
  open && dishId ? dishId : void 0
3675
3833
  );
3676
- const { dishes: allDishes, loading: allLoading, error: allError } = useDishes();
3834
+ const shouldLoadAllDishes = open && (!dishId || showAll);
3835
+ const { dishes: allDishes, loading: allLoading, error: allError } = useDishes(shouldLoadAllDishes);
3677
3836
  const isSingleMode = !!dishId;
3678
3837
  const loading = isSingleMode ? singleLoading : allLoading;
3679
3838
  const error = isSingleMode ? singleError : allError;
@@ -3760,6 +3919,8 @@ function DishShowcase({
3760
3919
  className: className || "resira-dish-showcase-btn",
3761
3920
  style,
3762
3921
  onClick: handleOpen,
3922
+ "aria-haspopup": "dialog",
3923
+ "aria-expanded": open,
3763
3924
  children
3764
3925
  }
3765
3926
  ),
@@ -3777,7 +3938,7 @@ function DishShowcase({
3777
3938
  onClick: (e) => e.stopPropagation(),
3778
3939
  role: "dialog",
3779
3940
  "aria-modal": "true",
3780
- "aria-label": "Dish showcase",
3941
+ "aria-label": selectedDish ? `${selectedDish.name} dish preview` : "Dish showcase",
3781
3942
  children: [
3782
3943
  /* @__PURE__ */ jsxRuntime.jsx(
3783
3944
  "button",
@@ -3789,11 +3950,11 @@ function DishShowcase({
3789
3950
  children: /* @__PURE__ */ jsxRuntime.jsx(XIcon, { size: 18 })
3790
3951
  }
3791
3952
  ),
3792
- loading && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-dish-loading", children: [
3793
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "resira-dish-spinner" }),
3953
+ loading && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "resira-dish-loading", role: "status", "aria-live": "polite", "aria-busy": "true", children: [
3954
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "resira-dish-spinner", "aria-hidden": "true" }),
3794
3955
  /* @__PURE__ */ jsxRuntime.jsx("span", { children: "Loading dishes\u2026" })
3795
3956
  ] }),
3796
- error && !loading && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "resira-dish-error", children: /* @__PURE__ */ jsxRuntime.jsx("p", { children: error }) }),
3957
+ error && !loading && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "resira-dish-error", role: "alert", "aria-live": "assertive", children: /* @__PURE__ */ jsxRuntime.jsx("p", { children: error }) }),
3797
3958
  !loading && !error && /* @__PURE__ */ jsxRuntime.jsx(jsxRuntime.Fragment, { children: selectedDish ? /* @__PURE__ */ jsxRuntime.jsx(
3798
3959
  ModelViewer3D,
3799
3960
  {