@mohasinac/appkit 4.30.16 → 4.30.18

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 (98) hide show
  1. package/dist/_internal/server/jobs/core/testerSandboxRefresh.js +27 -2
  2. package/dist/_internal/shared/types/action-error-reporter.d.ts +40 -0
  3. package/dist/_internal/shared/types/action-error-reporter.js +27 -0
  4. package/dist/_internal/shared/types/action-result.d.ts +10 -0
  5. package/dist/_internal/shared/types/action-result.js +26 -0
  6. package/dist/client/api/surface-error.d.ts +6 -4
  7. package/dist/client/api/surface-error.js +19 -8
  8. package/dist/contracts/realtime-session.d.ts +27 -0
  9. package/dist/contracts/realtime-session.js +118 -0
  10. package/dist/core/unit-of-work.js +21 -13
  11. package/dist/errors/base-error.js +14 -1
  12. package/dist/errors/database-error.js +11 -1
  13. package/dist/errors/describe-cause.d.ts +5 -0
  14. package/dist/errors/describe-cause.js +76 -0
  15. package/dist/errors/error-display-map.d.ts +32 -1
  16. package/dist/errors/error-display-map.js +46 -0
  17. package/dist/errors/error-mapping.d.ts +47 -7
  18. package/dist/errors/error-mapping.js +46 -5
  19. package/dist/errors/index.d.ts +1 -0
  20. package/dist/errors/index.js +4 -0
  21. package/dist/features/about/components/PublicProfileView.js +4 -4
  22. package/dist/features/account/components/AddressBook.js +2 -2
  23. package/dist/features/account/components/AddressesIndexListing.js +2 -2
  24. package/dist/features/admin/components/AdminAnalyticsView.js +2 -2
  25. package/dist/features/admin/components/AdminCouponsView.js +2 -2
  26. package/dist/features/admin/components/AdminGuideHubView.js +2 -2
  27. package/dist/features/admin/components/AdminMediaView.js +2 -2
  28. package/dist/features/admin/components/AdminViewCards.js +2 -2
  29. package/dist/features/auctions/components/AuctionDetailPageView.js +1 -1
  30. package/dist/features/auctions/components/MarketplaceAuctionGrid.d.ts +1 -2
  31. package/dist/features/auctions/components/MarketplaceAuctionGrid.js +8 -7
  32. package/dist/features/auctions/components/PlaceBidFormClient.js +32 -15
  33. package/dist/features/before-after/components/BeforeAfterGallery.js +2 -2
  34. package/dist/features/blog/components/BlogIndexListing.js +3 -3
  35. package/dist/features/blog/components/BlogListView.js +3 -3
  36. package/dist/features/blog/components/BlogPostView.js +2 -2
  37. package/dist/features/categories/components/BundleCollage.js +2 -2
  38. package/dist/features/categories/components/CategoriesIndexListing.js +3 -3
  39. package/dist/features/categories/components/CategoryBundlesListing.js +2 -2
  40. package/dist/features/categories/components/CategoryGrid.js +2 -2
  41. package/dist/features/categories/components/CategoryProductsListing.js +2 -2
  42. package/dist/features/categories/components/CategoryStoresListing.js +2 -2
  43. package/dist/features/collections/components/CollectionCard.js +2 -2
  44. package/dist/features/events/components/EventRafflesSection.js +2 -2
  45. package/dist/features/events/components/EventsIndexListing.js +2 -2
  46. package/dist/features/events/components/EventsListView.js +3 -3
  47. package/dist/features/homepage/components/FeaturedBundlesSection.js +2 -2
  48. package/dist/features/homepage/components/GoogleReviewsSection.js +2 -2
  49. package/dist/features/homepage/components/HomepageSkeleton.js +1 -1
  50. package/dist/features/homepage/components/SecurityHighlightsSection.js +1 -1
  51. package/dist/features/homepage/components/SocialFeedSection.js +2 -2
  52. package/dist/features/homepage/components/StatsCounterSection.js +1 -1
  53. package/dist/features/homepage/components/TrustBadges.js +2 -2
  54. package/dist/features/homepage/components/TrustFeaturesSection.js +1 -1
  55. package/dist/features/messages/hooks/useConversation.js +39 -15
  56. package/dist/features/messages/hooks/useConversations.js +36 -14
  57. package/dist/features/messages/repository/conversations.repository.d.ts +23 -0
  58. package/dist/features/messages/repository/conversations.repository.js +53 -0
  59. package/dist/features/pre-orders/components/PreOrdersIndexListing.js +2 -3
  60. package/dist/features/products/components/AuctionsIndexListing.js +2 -3
  61. package/dist/features/products/components/PrizeDrawCollage.js +2 -2
  62. package/dist/features/products/components/PrizeDrawsIndexListing.js +2 -3
  63. package/dist/features/products/components/PrizeDrawsSection.js +2 -2
  64. package/dist/features/products/components/ProductGrid.js +2 -7
  65. package/dist/features/products/components/ProductsIndexListing.js +3 -3
  66. package/dist/features/products/components/RelatedProducts.js +2 -2
  67. package/dist/features/promotions/components/CouponsIndexListing.js +1 -1
  68. package/dist/features/reviews/components/ReviewDetailPageView.js +2 -2
  69. package/dist/features/reviews/components/ReviewsListingPanel.js +2 -2
  70. package/dist/features/seller/components/SellerCouponsView.js +2 -2
  71. package/dist/features/seller/components/SellerDashboardView.js +3 -3
  72. package/dist/features/server-errors/schemas/firestore.d.ts +9 -0
  73. package/dist/features/stores/components/StoreAuctionsListing.js +2 -3
  74. package/dist/features/stores/components/StoreClassifiedsListing.js +2 -3
  75. package/dist/features/stores/components/StoreDigitalCodesListing.js +2 -3
  76. package/dist/features/stores/components/StoreGuideHubView.js +2 -2
  77. package/dist/features/stores/components/StoreLiveItemsListing.js +2 -3
  78. package/dist/features/stores/components/StorePreOrdersListing.js +2 -3
  79. package/dist/features/stores/components/StoreProductsListing.js +2 -2
  80. package/dist/features/stores/components/StoresIndexListing.js +3 -3
  81. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +54 -1
  82. package/dist/next/api/routeHandler.js +27 -7
  83. package/dist/providers/db-firebase/base.repository.js +34 -18
  84. package/dist/react/hooks/useRealtimeEvent.js +22 -4
  85. package/dist/server.d.ts +2 -0
  86. package/dist/server.js +5 -0
  87. package/dist/styles.css +84 -20
  88. package/dist/tailwind-utilities.css +1 -1
  89. package/dist/tokens/tokens.css +7 -2
  90. package/dist/ui/components/Layout.d.ts +31 -18
  91. package/dist/ui/components/Layout.js +30 -19
  92. package/dist/ui/components/Layout.style.css +76 -17
  93. package/dist/ui/components/ListingLayout.d.ts +2 -0
  94. package/dist/ui/components/ListingLayout.js +16 -2
  95. package/dist/ui/components/StickyToolbar.js +3 -48
  96. package/dist/ui/components/toolbar-collapse.d.ts +44 -0
  97. package/dist/ui/components/toolbar-collapse.js +93 -0
  98. package/package.json +1 -1
@@ -4,7 +4,7 @@ import { useState, useCallback } from "react";
4
4
  import { useUrlTable } from "../../../react/hooks/useUrlTable";
5
5
  import { usePendingTable } from "../../../react/hooks/usePendingTable";
6
6
  import { useProducts } from "../../products/hooks/useProducts";
7
- import { Div, FilterDrawer, ListingToolbar, Pagination, Row, Text, StickyToolbar } from "../../../ui";
7
+ import { Div, FilterDrawer, Grid, ListingToolbar, Pagination, Row, Text, StickyToolbar } from "../../../ui";
8
8
  import { LiveItemFilters } from "../../live/components/LiveItemFilters";
9
9
  import { InteractiveProductCard } from "../../products/components/InteractiveProductCard";
10
10
  import { PRODUCT_FIELDS } from "../../../constants/field-names";
@@ -56,8 +56,7 @@ export function StoreLiveItemsListing({ storeId, initialData }) {
56
56
  availability,
57
57
  };
58
58
  const { products, totalPages, page, isLoading } = useProducts(params, { initialData });
59
- const gridClass = "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-[var(--appkit-space-4)]";
60
- return (_jsxs(Div, { className: "min-h-[200px]", children: [_jsx(ListingToolbar, { filterCount: filterActiveCount, onFiltersClick: openFilters, searchValue: searchInput, searchPlaceholder: "Search live animals & plants\u2026", onSearchChange: setSearchInput, onSearchCommit: () => table.set("q", searchInput.trim()), sortValue: table.get("sort") || DEFAULT_SORT, sortOptions: SORT_OPTIONS, onSortChange: (v) => table.set("sort", v), onResetAll: resetAll, hasActiveState: hasActiveState }), _jsx(Div, { padding: "y-sm", children: _jsx(AvailabilityTabs, { types: STORE_LIVE_TYPES }) }), totalPages > 1 && (_jsx(StickyToolbar, { offset: "header+pagination", tone: "translucent", border: true, padding: "toolbar", children: _jsx(Row, { justify: "center", children: _jsx(Pagination, { currentPage: page, totalPages: totalPages, onPageChange: (p) => table.setPage(p) }) }) })), _jsx(Div, { padding: "y-lg", children: _jsx(Div, { className: gridClass, children: isLoading
59
+ return (_jsxs(Div, { className: "min-h-[200px]", children: [_jsx(ListingToolbar, { filterCount: filterActiveCount, onFiltersClick: openFilters, searchValue: searchInput, searchPlaceholder: "Search live animals & plants\u2026", onSearchChange: setSearchInput, onSearchCommit: () => table.set("q", searchInput.trim()), sortValue: table.get("sort") || DEFAULT_SORT, sortOptions: SORT_OPTIONS, onSortChange: (v) => table.set("sort", v), onResetAll: resetAll, hasActiveState: hasActiveState }), _jsx(Div, { padding: "y-sm", children: _jsx(AvailabilityTabs, { types: STORE_LIVE_TYPES }) }), totalPages > 1 && (_jsx(StickyToolbar, { offset: "header+pagination", tone: "translucent", border: true, padding: "toolbar", children: _jsx(Row, { justify: "center", children: _jsx(Pagination, { currentPage: page, totalPages: totalPages, onPageChange: (p) => table.setPage(p) }) }) })), _jsx(Div, { padding: "y-lg", children: _jsx(Grid, { cols: "cards", gap: "md", children: isLoading
61
60
  ? Array.from({ length: 8 }).map((_, i) => _jsx(Div, { surface: "muted", rounded: "xl", className: "aspect-square animate-pulse" }, i))
62
61
  : products.length === 0
63
62
  ? _jsx(Div, { paddingY: "y-4xl", className: "col-span-full text-left", children: _jsx(Text, { color: "muted", children: "No live items in this store yet." }) })
@@ -4,7 +4,7 @@ import { useState, useCallback } from "react";
4
4
  import { useUrlTable } from "../../../react/hooks/useUrlTable";
5
5
  import { usePendingTable } from "../../../react/hooks/usePendingTable";
6
6
  import { useProducts } from "../../products/hooks/useProducts";
7
- import { Div, FilterDrawer, ListingToolbar, Pagination, Row, Stack, Text, StickyToolbar } from "../../../ui";
7
+ import { Div, FilterDrawer, Grid, ListingToolbar, Pagination, Row, Stack, Text, StickyToolbar } from "../../../ui";
8
8
  import { MarketplacePreorderCard } from "../../pre-orders/components/MarketplacePreorderCard";
9
9
  import { ProductFilters } from "../../products/components/ProductFilters";
10
10
  import { ROUTES } from "../../../next";
@@ -64,7 +64,6 @@ export function StorePreOrdersListing({ storeId, initialData }) {
64
64
  // table.set("q", v) already resets page to 1 — see root-cause #13.
65
65
  table.set("q", searchInput.trim());
66
66
  }, [searchInput, table]);
67
- const gridClass = "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-[var(--appkit-space-4)]";
68
67
  return (_jsxs(Div, { className: "min-h-[200px]", children: [_jsx(ListingToolbar, { filterCount: filterActiveCount, onFiltersClick: openFilters, searchValue: searchInput, searchPlaceholder: "Search store pre-orders...", onSearchChange: setSearchInput, onSearchCommit: commitSearch, sortValue: table.get("sort") || DEFAULT_SORT, sortOptions: PREORDER_PUBLIC_SORT_OPTIONS, onSortChange: (v) => {
69
68
  table.set("sort", v);
70
69
  }, view: view, onViewChange: (v) => {
@@ -72,5 +71,5 @@ export function StorePreOrdersListing({ storeId, initialData }) {
72
71
  return;
73
72
  setView(v);
74
73
  table.set("view", v);
75
- }, onResetAll: resetAll, hasActiveState: hasActiveState }), _jsx(Div, { padding: "y-sm", children: _jsx(AvailabilityTabs, { types: STORE_PRE_ORDER_TYPES }) }), totalPages > 1 && (_jsx(StickyToolbar, { offset: "header+pagination", tone: "translucent", border: true, padding: "toolbar", children: _jsx(Row, { justify: "center", children: _jsx(Pagination, { currentPage: page, totalPages: totalPages, onPageChange: (p) => table.setPage(p) }) }) })), _jsx(Div, { padding: "y-lg", children: isLoading ? (_jsx(Div, { className: gridClass, children: Array.from({ length: 8 }).map((_, i) => (_jsxs(Div, { className: "overflow-hidden animate-pulse", border: "subtle", rounded: "xl", children: [_jsx(Div, { className: "aspect-square", surface: "subtle" }), _jsxs(Stack, { className: `${__P.p3}`, gap: "sm", children: [_jsx(Div, { className: "h-3 w-3/4", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-1/2", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-8", surface: "subtle", rounded: "default" })] })] }, i))) })) : preOrders.length === 0 ? (_jsx(Text, { paddingY: "3xl", color: "muted", size: "sm", align: "start", children: "This store has no pre-orders yet." })) : view === "list" ? (_jsx(Stack, { className: "divide-y divide-zinc-100 divide-[var(--appkit-color-border-subtle)]", border: "subtle", rounded: "xl", children: preOrders.map((product) => (_jsx(MarketplacePreorderCard, { product: product, variant: "list", hrefBuilder: (p) => String(ROUTES.PUBLIC.PRE_ORDER_DETAIL(p.id)) }, product.id))) })) : (_jsx(Div, { className: gridClass, children: preOrders.map((product) => (_jsx(MarketplacePreorderCard, { product: product, variant: "grid", hrefBuilder: (p) => String(ROUTES.PUBLIC.PRE_ORDER_DETAIL(p.id)) }, product.id))) })) }), _jsx(FilterDrawer, { open: filterOpen, onOpen: openFilters, onClose: () => setFilterOpen(false), onApply: applyFilters, onReset: onFilterClear, activeCount: filterActiveCount, hideTrigger: true, children: _jsx(ProductFilters, { table: pendingTable, currencyPrefix: "\u20B9" }) })] }));
74
+ }, onResetAll: resetAll, hasActiveState: hasActiveState }), _jsx(Div, { padding: "y-sm", children: _jsx(AvailabilityTabs, { types: STORE_PRE_ORDER_TYPES }) }), totalPages > 1 && (_jsx(StickyToolbar, { offset: "header+pagination", tone: "translucent", border: true, padding: "toolbar", children: _jsx(Row, { justify: "center", children: _jsx(Pagination, { currentPage: page, totalPages: totalPages, onPageChange: (p) => table.setPage(p) }) }) })), _jsx(Div, { padding: "y-lg", children: isLoading ? (_jsx(Grid, { cols: "cards", gap: "md", children: Array.from({ length: 8 }).map((_, i) => (_jsxs(Div, { className: "overflow-hidden animate-pulse", border: "subtle", rounded: "xl", children: [_jsx(Div, { className: "aspect-square", surface: "subtle" }), _jsxs(Stack, { className: `${__P.p3}`, gap: "sm", children: [_jsx(Div, { className: "h-3 w-3/4", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-1/2", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-8", surface: "subtle", rounded: "default" })] })] }, i))) })) : preOrders.length === 0 ? (_jsx(Text, { paddingY: "3xl", color: "muted", size: "sm", align: "start", children: "This store has no pre-orders yet." })) : view === "list" ? (_jsx(Stack, { className: "divide-y divide-zinc-100 divide-[var(--appkit-color-border-subtle)]", border: "subtle", rounded: "xl", children: preOrders.map((product) => (_jsx(MarketplacePreorderCard, { product: product, variant: "list", hrefBuilder: (p) => String(ROUTES.PUBLIC.PRE_ORDER_DETAIL(p.id)) }, product.id))) })) : (_jsx(Grid, { cols: "cards", gap: "md", children: preOrders.map((product) => (_jsx(MarketplacePreorderCard, { product: product, variant: "grid", hrefBuilder: (p) => String(ROUTES.PUBLIC.PRE_ORDER_DETAIL(p.id)) }, product.id))) })) }), _jsx(FilterDrawer, { open: filterOpen, onOpen: openFilters, onClose: () => setFilterOpen(false), onApply: applyFilters, onReset: onFilterClear, activeCount: filterActiveCount, hideTrigger: true, children: _jsx(ProductFilters, { table: pendingTable, currencyPrefix: "\u20B9" }) })] }));
76
75
  }
@@ -4,7 +4,7 @@ import { useState, useCallback } from "react";
4
4
  import { useUrlTable } from "../../../react/hooks/useUrlTable";
5
5
  import { usePendingTable } from "../../../react/hooks/usePendingTable";
6
6
  import { useProducts } from "../../products/hooks/useProducts";
7
- import { Div, FilterDrawer, ListingToolbar, LoginRequiredModal, Pagination, Row, Stack, useToast, StickyToolbar } from "../../../ui";
7
+ import { Div, Grid, FilterDrawer, ListingToolbar, LoginRequiredModal, Pagination, Row, Stack, useToast, StickyToolbar } from "../../../ui";
8
8
  import { useAuthGate } from "../../../react/hooks/useAuthGate";
9
9
  import { ACTION_ID } from "../../products/constants/action-defs";
10
10
  import { ROUTES } from "../../../next";
@@ -104,5 +104,5 @@ export function StoreProductsListing({ storeId, listingTypes = ["standard"], ini
104
104
  }, [localCart, showToast]);
105
105
  return (_jsxs(Div, { className: "min-h-[200px]", children: [_jsx(ListingToolbar, { filterCount: filterActiveCount, onFiltersClick: openFilters, searchValue: searchInput, searchPlaceholder: "Search store products...", onSearchChange: setSearchInput, onSearchCommit: commitSearch, sortValue: table.get("sort") || "-createdAt", sortOptions: PRODUCT_PUBLIC_SORT_OPTIONS, onSortChange: (v) => {
106
106
  table.set("sort", v);
107
- }, view: view === "card" ? "grid" : "list", onViewChange: (v) => handleViewToggle(v === "grid" ? "card" : "list"), onResetAll: resetAll, hasActiveState: hasActiveState }), _jsx(Div, { padding: "y-sm", children: _jsx(AvailabilityTabs, { types: listingTypes }) }), totalPages > 1 && (_jsx(StickyToolbar, { offset: "header+pagination", tone: "translucent", border: true, padding: "toolbar", children: _jsx(Row, { justify: "center", children: _jsx(Pagination, { currentPage: page, totalPages: totalPages, onPageChange: (p) => table.setPage(p) }) }) })), _jsx(Div, { padding: "y-lg", children: isLoading ? (_jsx(Div, { layout: "grid", gap: "6", className: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-4", children: Array.from({ length: 8 }).map((_, i) => (_jsxs(Div, { className: "overflow-hidden animate-pulse", border: "subtle", rounded: "xl", children: [_jsx(Div, { className: "aspect-square", surface: "subtle" }), _jsxs(Stack, { className: `${__P.p3}`, gap: "sm", children: [_jsx(Div, { className: "h-3 w-3/4", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-1/2", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-4 w-1/3", surface: "subtle", rounded: "default" })] })] }, i))) })) : (_jsx(ProductGrid, { products: products, getProductHref: (p) => String(ROUTES.PUBLIC.PRODUCT_DETAIL(p.slug || p.id)), view: view, emptyLabel: "This store has no products yet.", onWishlistToggle: handleWishlistToggle, wishlistedIds: wishlistedIds, onAddToCart: handleAddToCart })) }), _jsx(FilterDrawer, { open: filterOpen, onOpen: openFilters, onClose: () => setFilterOpen(false), onApply: applyFilters, onReset: onFilterClear, activeCount: filterActiveCount, hideTrigger: true, children: _jsx(ProductFilters, { table: pendingTable, currencyPrefix: "\u20B9" }) }), _jsx(LoginRequiredModal, { isOpen: modalOpen, onClose: closeModal, message: modalMessage })] }));
107
+ }, view: view === "card" ? "grid" : "list", onViewChange: (v) => handleViewToggle(v === "grid" ? "card" : "list"), onResetAll: resetAll, hasActiveState: hasActiveState }), _jsx(Div, { padding: "y-sm", children: _jsx(AvailabilityTabs, { types: listingTypes }) }), totalPages > 1 && (_jsx(StickyToolbar, { offset: "header+pagination", tone: "translucent", border: true, padding: "toolbar", children: _jsx(Row, { justify: "center", children: _jsx(Pagination, { currentPage: page, totalPages: totalPages, onPageChange: (p) => table.setPage(p) }) }) })), _jsx(Div, { padding: "y-lg", children: isLoading ? (_jsx(Grid, { cols: "cards", gap: "lg", children: Array.from({ length: 8 }).map((_, i) => (_jsxs(Div, { className: "overflow-hidden animate-pulse", border: "subtle", rounded: "xl", children: [_jsx(Div, { className: "aspect-square", surface: "subtle" }), _jsxs(Stack, { className: `${__P.p3}`, gap: "sm", children: [_jsx(Div, { className: "h-3 w-3/4", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-1/2", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-4 w-1/3", surface: "subtle", rounded: "default" })] })] }, i))) })) : (_jsx(ProductGrid, { products: products, getProductHref: (p) => String(ROUTES.PUBLIC.PRODUCT_DETAIL(p.slug || p.id)), view: view, emptyLabel: "This store has no products yet.", onWishlistToggle: handleWishlistToggle, wishlistedIds: wishlistedIds, onAddToCart: handleAddToCart })) }), _jsx(FilterDrawer, { open: filterOpen, onOpen: openFilters, onClose: () => setFilterOpen(false), onApply: applyFilters, onReset: onFilterClear, activeCount: filterActiveCount, hideTrigger: true, children: _jsx(ProductFilters, { table: pendingTable, currencyPrefix: "\u20B9" }) }), _jsx(LoginRequiredModal, { isOpen: modalOpen, onClose: closeModal, message: modalMessage })] }));
108
108
  }
@@ -4,7 +4,7 @@ import { Row, SIEVE_OP, Stack, sieveFilter } from "@mohasinac/appkit/client";
4
4
  import { useState, useCallback, useMemo } from "react";
5
5
  import { useUrlTable } from "../../../react/hooks/useUrlTable";
6
6
  import { useStores } from "../hooks/useStores";
7
- import { BulkActionBar, Div, ListingFilterDrawer, ListingToolbar, Pagination, Text, StickyToolbar } from "../../../ui";
7
+ import { BulkActionBar, Div, Grid, ListingFilterDrawer, ListingToolbar, Pagination, Text, StickyToolbar } from "../../../ui";
8
8
  import { ROUTES } from "../../../next";
9
9
  import { InteractiveStoreCard } from "./InteractiveStoreCard";
10
10
  import { StoreFilters } from "./StoreFilters";
@@ -161,10 +161,10 @@ export function StoresIndexListing({ initialData }) {
161
161
  disabled: selection.selectedCount !== 1,
162
162
  onClick: () => { selection.clearSelection(); },
163
163
  },
164
- ] }), totalPages > 1 && (_jsx(StickyToolbar, { offset: "header+pagination", tone: "translucent", border: true, padding: "toolbar", children: _jsx(Row, { justify: "center", children: _jsx(Pagination, { currentPage: table.getNumber("page", 1), totalPages: totalPages, onPageChange: (p) => table.setPage(p) }) }) })), _jsx(Div, { padding: "y-lg", children: isLoading ? (_jsx(Div, { layout: "grid", gap: "6", className: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3", children: Array.from({ length: 6 }).map((_, i) => (_jsxs(Div, { className: `${__O.hidden} animate-pulse`, border: "subtle", rounded: "xl", children: [_jsx(Div, { className: "aspect-video", surface: "subtle" }), _jsxs(Div, { className: `${__P.p4} space-y-[0.625rem]`, children: [_jsx(Row, { align: "center", gap: "sm", children: _jsx(Div, { className: "h-10 w-10", surface: "subtle", rounded: "lg" }) }), _jsx(Div, { className: "h-4 w-2/3", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-full", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-1/2", surface: "subtle", rounded: "default" })] })] }, i))) })) : stores.length === 0 ? (_jsx(Text, { paddingY: "4xl", color: "muted", size: "sm", align: "start", children: "No stores found." })) : view === "list" ? (_jsx(Stack, { className: "divide-y divide-zinc-100 divide-[var(--appkit-color-border-subtle)]", border: "subtle", rounded: "xl", children: stores.map((store) => {
164
+ ] }), totalPages > 1 && (_jsx(StickyToolbar, { offset: "header+pagination", tone: "translucent", border: true, padding: "toolbar", children: _jsx(Row, { justify: "center", children: _jsx(Pagination, { currentPage: table.getNumber("page", 1), totalPages: totalPages, onPageChange: (p) => table.setPage(p) }) }) })), _jsx(Div, { padding: "y-lg", children: isLoading ? (_jsx(Grid, { cols: "cards", gap: "lg", children: Array.from({ length: 6 }).map((_, i) => (_jsxs(Div, { className: `${__O.hidden} animate-pulse`, border: "subtle", rounded: "xl", children: [_jsx(Div, { className: "aspect-video", surface: "subtle" }), _jsxs(Div, { className: `${__P.p4} space-y-[0.625rem]`, children: [_jsx(Row, { align: "center", gap: "sm", children: _jsx(Div, { className: "h-10 w-10", surface: "subtle", rounded: "lg" }) }), _jsx(Div, { className: "h-4 w-2/3", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-full", surface: "subtle", rounded: "default" }), _jsx(Div, { className: "h-3 w-1/2", surface: "subtle", rounded: "default" })] })] }, i))) })) : stores.length === 0 ? (_jsx(Text, { paddingY: "4xl", color: "muted", size: "sm", align: "start", children: "No stores found." })) : view === "list" ? (_jsx(Stack, { className: "divide-y divide-zinc-100 divide-[var(--appkit-color-border-subtle)]", border: "subtle", rounded: "xl", children: stores.map((store) => {
165
165
  const storeKey = store.storeSlug ?? store.id;
166
166
  return (_jsx(InteractiveStoreCard, { store: store, href: String(ROUTES.PUBLIC.STORE_DETAIL(storeKey)), selectable: selection.isSelecting, isSelected: selection.isSelected(store.id ?? store.storeSlug), onSelect: (id, sel) => { void sel; selection.toggle(id); } }, storeKey));
167
- }) })) : (_jsx(Div, { layout: "grid", gap: "6", className: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3", children: stores.map((store) => {
167
+ }) })) : (_jsx(Grid, { cols: "cards", gap: "lg", children: stores.map((store) => {
168
168
  const storeKey = store.storeSlug ?? store.id;
169
169
  return (_jsx(InteractiveStoreCard, { store: store, href: String(ROUTES.PUBLIC.STORE_DETAIL(storeKey)), selectable: selection.isSelecting, isSelected: selection.isSelected(store.id ?? store.storeSlug), onSelect: (id, sel) => { void sel; selection.toggle(id); } }, storeKey));
170
170
  }) })) }), _jsx(ListingFilterDrawer, { open: filterOpen, onClose: () => setFilterOpen(false), onApply: applyFilters, onClear: clearFilters, activeCount: pendingFilterCount, children: _jsx(StoreFilters, { table: pendingTable }) })] }));
@@ -174,6 +174,24 @@ const rawTesterChecklistItems = [
174
174
  { key: "browse-categories", label: "Browsing categories shows relevant products", href: "/categories" },
175
175
  { key: "search", label: "Search returns relevant results", href: "/search" },
176
176
  { key: "filters", label: "Search/listing filters (price, brand, condition) work correctly" },
177
+ {
178
+ key: "grid-one-card-per-row-on-phone",
179
+ label: "On a phone, a product listing shows exactly ONE card per row — not two narrow ones",
180
+ description: "BEFORE (until 2026-08-28): every listing put 2 cards per row at 375px, so each card was ~170px wide and its title/price squeezed. AFTER: 1 card per row on a phone, 2 on a tablet (~768px), 3–4 on a desktop. Check /products, /stores, /blog and an admin listing in card view. Resize slowly through 375 / 768 / 1024 / 1440 — the count should go 1, 2, 3, 4 and the cards should always fill the full width between the page margins, never leave a gap on the right.",
181
+ href: "/products",
182
+ },
183
+ {
184
+ key: "grid-short-last-row-not-stretched",
185
+ label: "A listing whose last row is short does NOT stretch that card across the page",
186
+ description: "Find a listing showing e.g. 4 items while 3 fit per row (add filters until the count is not a multiple of the row size). The single card left over on the last row must be exactly as wide as the cards above it, sitting on the left with empty space to its right. If it stretches to full width, the grid was switched from `auto-fill` to `auto-fit` — report it, this is the specific bug the layout was rebuilt to fix.",
187
+ href: "/products",
188
+ },
189
+ {
190
+ key: "grid-follows-sidebar-not-viewport",
191
+ label: "Opening the desktop filter sidebar drops ONE column and keeps the cards the same size",
192
+ description: "On a wide desktop (~1440px) open /products and toggle the filter sidebar. The card count per row should drop by one while each card stays roughly the same width — the grid measures its own container, not the window. If the cards instead shrink to squeeze the same number in, report it.",
193
+ href: "/products",
194
+ },
177
195
  {
178
196
  key: "homepage-prize-draws-section",
179
197
  label: "The homepage shows a \"Prize Draws\" strip with real cards — NOT an empty gap and NOT a \"Something went wrong\" block",
@@ -192,6 +210,18 @@ const rawTesterChecklistItems = [
192
210
  description: "Press F12 → Console tab, then load the page. A missing translation key logs 'MISSING_MESSAGE: <key>' and renders the raw key text instead of a readable label. Report the exact key name if you see one.",
193
211
  href: "/products",
194
212
  },
213
+ {
214
+ key: "listing-toolbar-collapsed-on-mobile",
215
+ label: "On a phone, a listing opens with the search/filter toolbar COLLAPSED to a single \"Show Toolbar\" strip",
216
+ description: "BEFORE (until 2026-08-28): every listing opened with two stacked toolbar rows — search + Filter, then a scrolling sort/view chip row — roughly 100px of chrome above the first card. AFTER: a phone gets one thin \"Show Toolbar\" strip instead. Tap it: search, Filter and sort appear. Navigate to another listing and back — it should stay expanded, because you chose that. On a desktop (~1440px) the toolbar must still be open by default, exactly as before. Also confirm the toolbar is never simply GONE — there must always be a visible strip to tap.",
217
+ href: "/products",
218
+ },
219
+ {
220
+ key: "listing-toolbar-forced-open-on-selection",
221
+ label: "Selecting rows on a phone force-opens the toolbar so the bulk actions are reachable",
222
+ description: "In an admin or seller listing on a phone, collapse the toolbar, then select a row (long-press or the checkbox). The toolbar must open by itself, because the bulk-action bar lives inside it — if it stays collapsed you can select rows but have no way to act on them. Report if the bulk bar is invisible or cut off.",
223
+ href: "/products",
224
+ },
195
225
  {
196
226
  key: "product-filter-status-labels",
197
227
  label: "In an admin/seller listing's filter drawer, every Status option reads as words (Published / Draft / In Review / Archived) — never a raw key like \"filters.statusInReview\"",
@@ -687,6 +717,18 @@ const rawTesterChecklistItems = [
687
717
  label: "A seller's per-listing \"Minimum Bid Increment\" can require MORE than the admin tier, but can never let a bid undercut the tier",
688
718
  description: "Set a listing's \"Minimum Bid Increment\" below the current tier's value (e.g. 1) and confirm the effective minimum bid still enforces the tier, not the smaller override. Then set it above the tier (e.g. 500 when the tier is 100) and confirm the higher override is now enforced instead.",
689
719
  },
720
+ {
721
+ key: "bid-succeeds-and-outbids-previous-winner",
722
+ label: "Placing a valid bid actually records it — the current bid rises and the previous high bid flips to \"outbid\"",
723
+ description: "Fixed 2026-08-28. BEFORE: every bid failed and the modal showed the raw text \"Batch write failed: Cannot find module '../providers/db-firebase' Require stack: - /var/task/.next/server/chunks/ssr/...\" in the amount field. AFTER: the bid is accepted, \"Current bid\" rises by at least the minimum increment, and the previous winner's row in Bid History reads \"outbid\". Note the failure returned a normally-rendered modal and an HTTP 200, so \"the page loaded\" and \"no error in the network tab\" both prove nothing — read the actual numbers. Root cause: a relative-path runtime require inside appkit that only breaks once bundled into a Lambda chunk.",
724
+ href: "/auctions/auction-tester-sandbox-cycle-1",
725
+ },
726
+ {
727
+ key: "server-error-copy-is-never-raw",
728
+ label: "A server failure shows plain-English copy — never a file path, stack trace or \"Cannot find module\"",
729
+ description: "Fixed 2026-08-28. BEFORE: any 500 printed its own internal message to the user, including absolute server paths under /var/task/. AFTER: the user sees \"A database error occurred. Please try again\" (or \"Something went wrong. Please try again.\"), while the FULL original text plus its cause chain appears in Admin → Maintenance → Server errors. Check both halves: generic in the browser, specific in the admin list. Seeing the raw text in either the bid modal or any toast is a regression.",
730
+ href: "/auctions/auction-tester-sandbox-cycle-1",
731
+ },
690
732
  {
691
733
  key: "bid-increment-live-tier-change",
692
734
  label: "The displayed \"min increment\" on an open auction updates live (without a page refresh) if another bidder's bid pushes the current bid across a tier boundary",
@@ -1738,7 +1780,18 @@ const rawTesterChecklistItems = [
1738
1780
  cases: [
1739
1781
  { key: "start-conversation", label: "Starting a conversation with a seller works" },
1740
1782
  { key: "receive-reply", label: "Receiving and reading a seller's reply works" },
1741
- { key: "message-realtime-update", label: "A new message appears in the recipient's thread without a manual page refresh (realtime ping-channel)", href: "/user/messages" },
1783
+ {
1784
+ key: "message-realtime-update",
1785
+ label: "A new message appears in the RECIPIENT's thread without a manual page refresh (realtime ping-channel)",
1786
+ description: "Fixed 2026-08-28. BEFORE: only the SENDER saw their own message, because the send handler calls refetch() directly — the recipient saw nothing until they reloaded. Two stacked faults: the hooks subscribed without ever signing in to the realtime database, AND the `conversationIds` claim the security rule requires had no issuer anywhere (the token route only sent `chatIds`, from an unrelated collection). Both errors were swallowed, one into a literal `// ignore`. AFTER: open the same conversation as buyer in one browser and seller in another, send from one, and the other updates on its own. Test it from BOTH directions — testing only the sender's own window is what let this pass before.",
1787
+ href: "/user/messages",
1788
+ },
1789
+ {
1790
+ key: "message-live-updates-survive-bulk-job",
1791
+ label: "Running a bulk admin action while a message thread is open does not kill that thread's live updates",
1792
+ description: "Fixed 2026-08-28. BEFORE: the realtime app is a single shared connection, and finishing a bulk job signed it out globally — so an open chat or message thread silently stopped updating, and vice versa (opening a chat killed an in-flight bulk job's progress). AFTER: open a message thread, run any bulk action from an admin listing until it completes, then send a message from the other account — the thread must still update live without a reload.",
1793
+ href: "/user/messages",
1794
+ },
1742
1795
  { key: "messages-list-thread", label: "The messages list and individual thread view both load correctly", href: "/user/messages" },
1743
1796
  ],
1744
1797
  },
@@ -32,6 +32,7 @@ import { NextResponse } from "next/server.js";
32
32
  import { getProviders } from "../../contracts";
33
33
  import { isEffectiveAdminUser } from "../../features/auth/role-predicates";
34
34
  import { mapToHttpError, HTTP_ERROR_CODES } from "../../errors/error-mapping";
35
+ import { describeCauseChain } from "../../errors/describe-cause";
35
36
  import { buildErrorEnvelope, toApiIssues } from "../../errors/error-envelope";
36
37
  import { serverErrorsRepository } from "../../features/server-errors/repository/server-errors.repository";
37
38
  import { userRepository } from "../../repositories";
@@ -93,10 +94,17 @@ const LOG_AS_INCIDENT_CODES = new Set([
93
94
  HTTP_ERROR_CODES.PAYMENT_ROLLBACK_ATTEMPTED,
94
95
  HTTP_ERROR_CODES.PAYMENT_ROLLBACK_FAILED,
95
96
  ]);
96
- function shouldPersist(status, code) {
97
- if (status >= 500)
97
+ function shouldPersist(mapped) {
98
+ if (mapped.status >= 500)
98
99
  return true;
99
- return LOG_AS_INCIDENT_CODES.has(code);
100
+ // Firestore itself refused. `permission-denied` (expired service-account
101
+ // key) and `resource-exhausted` (quota blown) map to 403/429, which sit
102
+ // below the 5xx threshold — so these were previously recorded nowhere.
103
+ // Keyed on provenance rather than code, because our own RBAC guard and rate
104
+ // limiter emit those same codes in normal operation.
105
+ if (mapped.infrastructure)
106
+ return true;
107
+ return LOG_AS_INCIDENT_CODES.has(mapped.code);
100
108
  }
101
109
  /**
102
110
  * Resolve the body schema for a createRouteHandler call. Accepts either a
@@ -282,16 +290,28 @@ export function createRouteHandler(options) {
282
290
  const mapped = mapToHttpError(err, {
283
291
  isProduction: process.env.NODE_ENV === "production",
284
292
  });
285
- // -- Persisted log (5xx + selected 4xx) --------------------------------
286
- if (shouldPersist(mapped.status, mapped.code)) {
287
- void serverErrorsRepository().record({
293
+ // -- Persisted log (5xx + infrastructure + selected 4xx) ---------------
294
+ // AWAITED, not fire-and-forget. `void record(...)` raced the response:
295
+ // the lambda can be frozen or reclaimed the moment we return, and
296
+ // `record()` swallows its own failures, so a lost write left no trace
297
+ // anywhere — indistinguishable from "the error never happened".
298
+ // `record()` is documented never to throw, so awaiting it cannot turn a
299
+ // logging failure into a request failure; the cost is one Firestore
300
+ // round-trip on an already-failed request.
301
+ if (shouldPersist(mapped)) {
302
+ await serverErrorsRepository().record({
288
303
  source: "vercel",
289
304
  route: new URL(request.url).pathname,
290
305
  method: request.method,
291
306
  ...(user?.uid ? { userId: user.uid } : {}),
292
307
  code: mapped.code,
293
- message: mapped.message,
308
+ // internalMessage ?? message — `mapped.message` is scrubbed to a
309
+ // generic string for 5xx in production so it is safe to send to a
310
+ // browser. The store must get the real one, or the scrub would have
311
+ // traded a UI leak for blindness.
312
+ message: mapped.internalMessage ?? mapped.message,
294
313
  stack: err instanceof Error ? err.stack : undefined,
314
+ cause: describeCauseChain(err),
295
315
  requestId,
296
316
  userAgent: request.headers.get("user-agent") ?? undefined,
297
317
  });
@@ -5,6 +5,17 @@ import { applySieveToFirestore, countSieveMatches, } from "./sieve";
5
5
  import { deserializeTimestamps, prepareForFirestore } from "./helpers";
6
6
  import { getAdminDb } from "./admin";
7
7
  import { encryptPiiFields, piiIndicesFor } from "../../security/pii-encrypt";
8
+ /**
9
+ * The underlying failure's message, for interpolation into a DatabaseError.
10
+ *
11
+ * Every wrap below used to read `"Failed to update document: abc123"` and
12
+ * nothing else, so the persisted serverErrors row — whose `message` is what the
13
+ * admin list renders — said only which method failed, never why. `cause` now
14
+ * carries the stack, but a human scanning the list needs the reason on the row.
15
+ */
16
+ function detailOf(error) {
17
+ return error instanceof Error ? error.message : String(error);
18
+ }
8
19
  export class BaseRepository {
9
20
  constructor(collectionName) {
10
21
  /**
@@ -60,7 +71,7 @@ export class BaseRepository {
60
71
  }
61
72
  catch (error) {
62
73
  void normalizeError(error);
63
- throw new DatabaseError(`Failed to find document by ID: ${id}`, error);
74
+ throw new DatabaseError(`Failed to find document by ID: ${id}: ${detailOf(error)}`, error);
64
75
  }
65
76
  }
66
77
  async findByIdOrFail(id) {
@@ -79,7 +90,7 @@ export class BaseRepository {
79
90
  }
80
91
  catch (error) {
81
92
  void normalizeError(error);
82
- throw new DatabaseError(`Failed to find documents by ${field}`, error);
93
+ throw new DatabaseError(`Failed to find documents by ${field}: ${detailOf(error)}`, error);
83
94
  }
84
95
  }
85
96
  async findOneBy(field, value) {
@@ -95,7 +106,7 @@ export class BaseRepository {
95
106
  }
96
107
  catch (error) {
97
108
  void normalizeError(error);
98
- throw new DatabaseError(`Failed to find document by ${field}`, error);
109
+ throw new DatabaseError(`Failed to find document by ${field}: ${detailOf(error)}`, error);
99
110
  }
100
111
  }
101
112
  async findAll(limit) {
@@ -109,7 +120,7 @@ export class BaseRepository {
109
120
  }
110
121
  catch (error) {
111
122
  void normalizeError(error);
112
- throw new DatabaseError("Failed to fetch all documents", error);
123
+ throw new DatabaseError(`Failed to fetch all documents: ${detailOf(error)}`, error);
113
124
  }
114
125
  }
115
126
  /**
@@ -158,7 +169,7 @@ export class BaseRepository {
158
169
  }
159
170
  catch (error) {
160
171
  void normalizeError(error);
161
- throw new DatabaseError("Failed to create document", error);
172
+ throw new DatabaseError(`Failed to create document: ${detailOf(error)}`, error);
162
173
  }
163
174
  }
164
175
  async createWithId(id, data) {
@@ -169,20 +180,20 @@ export class BaseRepository {
169
180
  createdAt: now,
170
181
  updatedAt: now,
171
182
  });
172
- serverLogger.debug(`Creating document with ID: ${id} in collection: ${this.collection}`);
183
+ // No success-path logging. This emitted one debug + one info line for
184
+ // EVERY document written, against Rule #6's ~4 KB/s log budget, and the
185
+ // failure path below already carries everything the success path did.
173
186
  await this.getCollection().doc(id).set(cleanData);
174
- serverLogger.info(`Document created successfully: ${id}`);
175
187
  const doc = await this.getCollection().doc(id).get();
176
188
  return this.mapDoc(doc);
177
189
  }
178
190
  catch (error) {
179
191
  void normalizeError(error);
180
- serverLogger.error(`Failed to create document with ID: ${id}`, {
181
- collection: this.collection,
182
- error: error.message,
183
- code: error.code,
184
- });
185
- throw new DatabaseError(`Failed to create document with ID: ${id}`, error);
192
+ // No serverLogger.error here either — this method was the only one of the
193
+ // ten wraps that logged AND rethrew, so its failures were recorded twice
194
+ // (once here, once by createRouteHandler), the first copy strictly less
195
+ // informative. The DatabaseError below now carries the same detail.
196
+ throw new DatabaseError(`Failed to create document with ID: ${id}: ${detailOf(error)}`, error);
186
197
  }
187
198
  }
188
199
  async update(id, data) {
@@ -196,7 +207,7 @@ export class BaseRepository {
196
207
  }
197
208
  catch (error) {
198
209
  void normalizeError(error);
199
- throw new DatabaseError(`Failed to update document: ${id}`, error);
210
+ throw new DatabaseError(`Failed to update document: ${id}: ${detailOf(error)}`, error);
200
211
  }
201
212
  }
202
213
  async delete(id) {
@@ -205,7 +216,7 @@ export class BaseRepository {
205
216
  }
206
217
  catch (error) {
207
218
  void normalizeError(error);
208
- throw new DatabaseError(`Failed to delete document: ${id}`, error);
219
+ throw new DatabaseError(`Failed to delete document: ${id}: ${detailOf(error)}`, error);
209
220
  }
210
221
  }
211
222
  async exists(id) {
@@ -215,7 +226,7 @@ export class BaseRepository {
215
226
  }
216
227
  catch (error) {
217
228
  void normalizeError(error);
218
- throw new DatabaseError(`Failed to check document existence: ${id}`, error);
229
+ throw new DatabaseError(`Failed to check document existence: ${id}: ${detailOf(error)}`, error);
219
230
  }
220
231
  }
221
232
  /**
@@ -237,7 +248,7 @@ export class BaseRepository {
237
248
  }
238
249
  catch (error) {
239
250
  void normalizeError(error);
240
- throw new DatabaseError("Failed to count documents", error);
251
+ throw new DatabaseError(`Failed to count documents: ${detailOf(error)}`, error);
241
252
  }
242
253
  }
243
254
  async sieveQuery(model, fields, options) {
@@ -298,7 +309,12 @@ export class BaseRepository {
298
309
  }
299
310
  catch (error) {
300
311
  void normalizeError(error);
301
- serverLogger.warn(`facetCounts: count failed for ${this.collection}.${field}==${value}; facet will stay visible`);
312
+ // ERROR, not warn, and WITH the error object. A count failing here is
313
+ // almost always a missing composite index — a permanent, silent
314
+ // degradation of every facet on the listing page, not a transient
315
+ // blip. Logging it at warn with no error attached meant the actual
316
+ // FAILED_PRECONDITION (which names the index to create) was discarded.
317
+ serverLogger.error(`facetCounts: count failed for ${this.collection}.${field}==${value}; facet will stay visible`, { collection: this.collection, field, value, error });
302
318
  return [value, undefined];
303
319
  }
304
320
  }));
@@ -2,6 +2,7 @@
2
2
  import { normalizeError } from "../../errors/normalize";
3
3
  import { useCallback, useEffect, useRef, useState } from "react";
4
4
  import { getClientRealtimeProvider, } from "../../contracts/client-realtime";
5
+ import { acquireExternalRealtimeLease } from "../../contracts/realtime-session";
5
6
  import { RealtimeEventType, RealtimeEventStatus, RTDBPayloadStatus, } from "./realtime-event-constants";
6
7
  // Re-exported for existing client import paths — the canonical definitions
7
8
  // now live in realtime-event-constants.ts (a plain, non-"use client" module)
@@ -31,6 +32,8 @@ export function useRealtimeEvent(config) {
31
32
  const unsubscribeRef = useRef(null);
32
33
  const timeoutRef = useRef(null);
33
34
  const eventIdRef = useRef(null);
35
+ /** Lease on the shared realtime session — see cleanup() for why. */
36
+ const releaseSessionRef = useRef(null);
34
37
  const cleanup = useCallback(() => {
35
38
  if (unsubscribeRef.current) {
36
39
  unsubscribeRef.current();
@@ -40,10 +43,20 @@ export function useRealtimeEvent(config) {
40
43
  clearTimeout(timeoutRef.current);
41
44
  timeoutRef.current = null;
42
45
  }
43
- const provider = configRef.current.realtimeProvider ?? getClientRealtimeProvider();
44
- provider.signOut().catch(() => {
45
- // no-op
46
- });
46
+ // 🛑 Deliberately NO provider.signOut() here.
47
+ //
48
+ // `FirebaseClientRealtimeProvider` is a SINGLETON on the shared
49
+ // "letitrip-realtime" app, so signing out is a global act. When a bulk job
50
+ // finished, this line tore the session out from under any admin chat or
51
+ // conversation subscription that was still open — and in reverse, those
52
+ // channels' tokens overwrote an in-flight bulk token. Whoever finished
53
+ // last broke whoever was still working.
54
+ //
55
+ // Sessions are reference-counted now (contracts/realtime-session.ts); the
56
+ // release returned by `acquireRealtimeSession` is the only thing that may
57
+ // end one, and only once the last consumer has let go.
58
+ releaseSessionRef.current?.();
59
+ releaseSessionRef.current = null;
47
60
  }, []);
48
61
  const subscribe = useCallback((eventId, customToken) => {
49
62
  const { type, rtdbPath, realtimeProvider, timeoutMs = DEFAULT_TIMEOUT_MS, extractData, messages, onLogError, } = configRef.current;
@@ -60,6 +73,11 @@ export function useRealtimeEvent(config) {
60
73
  (async () => {
61
74
  try {
62
75
  await provider.signInWithToken(customToken);
76
+ // Hold a lease for as long as this subscription lives. This hook owns
77
+ // its own sign-in (the token carries a job-scoped `bulkJobId` claim),
78
+ // but it must still participate in the ref-count so its cleanup
79
+ // cannot end a session another channel is relying on.
80
+ releaseSessionRef.current = acquireExternalRealtimeLease();
63
81
  }
64
82
  catch (authErr) {
65
83
  void normalizeError(authErr);
package/dist/server.d.ts CHANGED
@@ -6,6 +6,8 @@ export { SERVER_ERRORS_COLLECTION, SERVER_ERROR_FIELDS, SERVER_ERROR_STACK_MAX_B
6
6
  export type { ServerErrorDocument, ServerErrorSource, } from "./features/server-errors/schemas/firestore";
7
7
  export { wrapAction, isOk, unwrap, } from "./_internal/shared/types/action-result";
8
8
  export type { ActionResult } from "./_internal/shared/types/action-result";
9
+ export { setActionErrorReporter, hasActionErrorReporter, } from "./_internal/shared/types/action-error-reporter";
10
+ export type { ActionErrorReport, ActionErrorReporter, } from "./_internal/shared/types/action-error-reporter";
9
11
  export { attachPaymentProofAction, adminVerifyPaymentAction, adminRequestProofReuploadAction, adminRejectPaymentAsFraudAction, raiseOrderDisputeAction, } from "./_internal/server/features/orders/actions";
10
12
  export { wrapJobHandler, wrapScheduleHandler, wrapTriggerHandler, wrapCallableHandler, } from "./_internal/server/jobs/core/wrapJobHandler";
11
13
  export { analyzeLogs } from "./_internal/server/features/maintenance/analyze";
package/dist/server.js CHANGED
@@ -11,6 +11,11 @@ export { SERVER_ERRORS_COLLECTION, SERVER_ERROR_FIELDS, SERVER_ERROR_STACK_MAX_B
11
11
  // [SERVER-ONLY] ActionResult envelope + wrapAction helper for "use server" functions.
12
12
  // Mirrors the routeHandler envelope so client wrappers consume both surfaces uniformly.
13
13
  export { wrapAction, isOk, unwrap, } from "./_internal/shared/types/action-result";
14
+ // [SERVER-ONLY] Registration seam for recording server-action failures.
15
+ // Must be called once from src/instrumentation.ts — see the module docstring
16
+ // for why it is a hook rather than a direct import, and why it is registered
17
+ // server-side rather than from a client bootstrap.
18
+ export { setActionErrorReporter, hasActionErrorReporter, } from "./_internal/shared/types/action-error-reporter";
14
19
  // [SERVER-ONLY] P-1 manual payment proof server actions.
15
20
  export { attachPaymentProofAction, adminVerifyPaymentAction, adminRequestProofReuploadAction, adminRejectPaymentAsFraudAction, raiseOrderDisputeAction, } from "./_internal/server/features/orders/actions";
16
21
  // [SERVER-ONLY] Cloud Function handler wrappers — persist exceptions to