@mohasinac/appkit 4.30.16 → 4.30.17
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/dist/_internal/shared/types/action-error-reporter.d.ts +40 -0
- package/dist/_internal/shared/types/action-error-reporter.js +27 -0
- package/dist/_internal/shared/types/action-result.d.ts +10 -0
- package/dist/_internal/shared/types/action-result.js +26 -0
- package/dist/client/api/surface-error.d.ts +6 -4
- package/dist/client/api/surface-error.js +19 -8
- package/dist/contracts/realtime-session.d.ts +27 -0
- package/dist/contracts/realtime-session.js +118 -0
- package/dist/core/unit-of-work.js +21 -13
- package/dist/errors/base-error.js +14 -1
- package/dist/errors/database-error.js +11 -1
- package/dist/errors/describe-cause.d.ts +5 -0
- package/dist/errors/describe-cause.js +76 -0
- package/dist/errors/error-display-map.d.ts +32 -1
- package/dist/errors/error-display-map.js +46 -0
- package/dist/errors/error-mapping.d.ts +47 -7
- package/dist/errors/error-mapping.js +46 -5
- package/dist/errors/index.d.ts +1 -0
- package/dist/errors/index.js +4 -0
- package/dist/features/about/components/PublicProfileView.js +4 -4
- package/dist/features/account/components/AddressBook.js +2 -2
- package/dist/features/account/components/AddressesIndexListing.js +2 -2
- package/dist/features/admin/components/AdminAnalyticsView.js +2 -2
- package/dist/features/admin/components/AdminCouponsView.js +2 -2
- package/dist/features/admin/components/AdminGuideHubView.js +2 -2
- package/dist/features/admin/components/AdminMediaView.js +2 -2
- package/dist/features/admin/components/AdminViewCards.js +2 -2
- package/dist/features/auctions/components/AuctionDetailPageView.js +1 -1
- package/dist/features/auctions/components/MarketplaceAuctionGrid.d.ts +1 -2
- package/dist/features/auctions/components/MarketplaceAuctionGrid.js +8 -7
- package/dist/features/auctions/components/PlaceBidFormClient.js +32 -15
- package/dist/features/before-after/components/BeforeAfterGallery.js +2 -2
- package/dist/features/blog/components/BlogIndexListing.js +3 -3
- package/dist/features/blog/components/BlogListView.js +3 -3
- package/dist/features/blog/components/BlogPostView.js +2 -2
- package/dist/features/categories/components/BundleCollage.js +2 -2
- package/dist/features/categories/components/CategoriesIndexListing.js +3 -3
- package/dist/features/categories/components/CategoryBundlesListing.js +2 -2
- package/dist/features/categories/components/CategoryGrid.js +2 -2
- package/dist/features/categories/components/CategoryProductsListing.js +2 -2
- package/dist/features/categories/components/CategoryStoresListing.js +2 -2
- package/dist/features/collections/components/CollectionCard.js +2 -2
- package/dist/features/events/components/EventRafflesSection.js +2 -2
- package/dist/features/events/components/EventsIndexListing.js +2 -2
- package/dist/features/events/components/EventsListView.js +3 -3
- package/dist/features/homepage/components/FeaturedBundlesSection.js +2 -2
- package/dist/features/homepage/components/GoogleReviewsSection.js +2 -2
- package/dist/features/homepage/components/HomepageSkeleton.js +1 -1
- package/dist/features/homepage/components/SecurityHighlightsSection.js +1 -1
- package/dist/features/homepage/components/SocialFeedSection.js +2 -2
- package/dist/features/homepage/components/StatsCounterSection.js +1 -1
- package/dist/features/homepage/components/TrustBadges.js +2 -2
- package/dist/features/homepage/components/TrustFeaturesSection.js +1 -1
- package/dist/features/messages/hooks/useConversation.js +39 -15
- package/dist/features/messages/hooks/useConversations.js +36 -14
- package/dist/features/messages/repository/conversations.repository.d.ts +23 -0
- package/dist/features/messages/repository/conversations.repository.js +53 -0
- package/dist/features/pre-orders/components/PreOrdersIndexListing.js +2 -3
- package/dist/features/products/components/AuctionsIndexListing.js +2 -3
- package/dist/features/products/components/PrizeDrawCollage.js +2 -2
- package/dist/features/products/components/PrizeDrawsIndexListing.js +2 -3
- package/dist/features/products/components/PrizeDrawsSection.js +2 -2
- package/dist/features/products/components/ProductGrid.js +2 -7
- package/dist/features/products/components/ProductsIndexListing.js +3 -3
- package/dist/features/products/components/RelatedProducts.js +2 -2
- package/dist/features/promotions/components/CouponsIndexListing.js +1 -1
- package/dist/features/reviews/components/ReviewDetailPageView.js +2 -2
- package/dist/features/reviews/components/ReviewsListingPanel.js +2 -2
- package/dist/features/seller/components/SellerCouponsView.js +2 -2
- package/dist/features/seller/components/SellerDashboardView.js +3 -3
- package/dist/features/server-errors/schemas/firestore.d.ts +9 -0
- package/dist/features/stores/components/StoreAuctionsListing.js +2 -3
- package/dist/features/stores/components/StoreClassifiedsListing.js +2 -3
- package/dist/features/stores/components/StoreDigitalCodesListing.js +2 -3
- package/dist/features/stores/components/StoreGuideHubView.js +2 -2
- package/dist/features/stores/components/StoreLiveItemsListing.js +2 -3
- package/dist/features/stores/components/StorePreOrdersListing.js +2 -3
- package/dist/features/stores/components/StoreProductsListing.js +2 -2
- package/dist/features/stores/components/StoresIndexListing.js +3 -3
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +54 -1
- package/dist/next/api/routeHandler.js +27 -7
- package/dist/providers/db-firebase/base.repository.js +34 -18
- package/dist/react/hooks/useRealtimeEvent.js +22 -4
- package/dist/server.d.ts +2 -0
- package/dist/server.js +5 -0
- package/dist/styles.css +84 -20
- package/dist/tailwind-utilities.css +1 -1
- package/dist/tokens/tokens.css +7 -2
- package/dist/ui/components/Layout.d.ts +31 -18
- package/dist/ui/components/Layout.js +30 -19
- package/dist/ui/components/Layout.style.css +76 -17
- package/dist/ui/components/ListingLayout.d.ts +2 -0
- package/dist/ui/components/ListingLayout.js +16 -2
- package/dist/ui/components/StickyToolbar.js +3 -48
- package/dist/ui/components/toolbar-collapse.d.ts +44 -0
- package/dist/ui/components/toolbar-collapse.js +93 -0
- 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, 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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
{
|
|
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(
|
|
97
|
-
if (status >= 500)
|
|
97
|
+
function shouldPersist(mapped) {
|
|
98
|
+
if (mapped.status >= 500)
|
|
98
99
|
return true;
|
|
99
|
-
|
|
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
|
-
|
|
287
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|