@mohasinac/appkit 4.27.0 → 4.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_internal/client/features/lottery/LotteryAdminEditView.d.ts +16 -16
- package/dist/_internal/client/features/lottery/LotteryAdminEditView.js +29 -6
- package/dist/_internal/client/features/lottery/LotteryListView.js +13 -1
- package/dist/_internal/server/features/checkout/actions.js +6 -4
- package/dist/_internal/server/features/classified/metadata.js +3 -1
- package/dist/_internal/server/features/digital-code/metadata.js +3 -1
- package/dist/_internal/server/features/live/metadata.js +3 -1
- package/dist/_internal/server/features/refunds/actions.js +3 -2
- package/dist/_internal/server/features/reviews/actions.js +3 -2
- package/dist/_internal/server/features/seo/sitemap.js +189 -40
- package/dist/_internal/server/jobs/core/listingProcessor.d.ts +0 -1
- package/dist/_internal/server/jobs/core/listingProcessor.js +19 -1
- package/dist/_internal/shared/actions/action-registry.js +14 -0
- package/dist/_internal/shared/checkout/order-math.d.ts +25 -3
- package/dist/_internal/shared/features/checkout/config.d.ts +3 -3
- package/dist/_internal/shared/features/checkout/config.js +6 -3
- package/dist/client.d.ts +32 -2
- package/dist/client.js +37 -2
- package/dist/constants/api-endpoints.d.ts +21 -3
- package/dist/constants/api-endpoints.js +14 -1
- package/dist/constants/field-names.d.ts +2 -2
- package/dist/constants/field-names.js +2 -2
- package/dist/core/newsletter.repository.js +14 -1
- package/dist/features/about/components/FAQPageView.js +1 -1
- package/dist/features/about/components/FeesView.js +1 -1
- package/dist/features/account/components/AddressForm.d.ts +2 -0
- package/dist/features/account/components/AddressForm.js +5 -1
- package/dist/features/account/components/UserBidsView.js +1 -1
- package/dist/features/account/components/UserSidebar.js +1 -1
- package/dist/features/account/components/UserSupportView.js +24 -2
- package/dist/features/account/hooks/useAddresses.d.ts +29 -1
- package/dist/features/account/hooks/useAddresses.js +4 -1
- package/dist/features/account/hooks/useProfile.d.ts +1 -1
- package/dist/features/account/hooks/useProfile.js +1 -1
- package/dist/features/addresses/repository/addresses.repository.d.ts +13 -0
- package/dist/features/addresses/repository/addresses.repository.js +19 -0
- package/dist/features/admin/actions/chat-actions.js +5 -1
- package/dist/features/admin/components/AdminAdsView.js +1 -1
- package/dist/features/admin/components/AdminBidsView.js +6 -1
- package/dist/features/admin/components/AdminCarouselGroupEditorView.d.ts +9 -1
- package/dist/features/admin/components/AdminCarouselGroupEditorView.js +42 -10
- package/dist/features/admin/components/AdminCarouselView.js +1 -1
- package/dist/features/admin/components/AdminFeatureEditorView.js +1 -1
- package/dist/features/admin/components/AdminGroupedListingsView.js +14 -2
- package/dist/features/admin/components/AdminSidebar.js +1 -1
- package/dist/features/admin/components/AdminViewCards.js +2 -2
- package/dist/features/admin/components/DataTable.js +2 -2
- package/dist/features/admin/schemas/firestore.js +9 -3
- package/dist/features/auctions/actions/bid-actions.js +45 -4
- package/dist/features/auctions/components/AuctionBottomActions.d.ts +21 -6
- package/dist/features/auctions/components/AuctionBottomActions.js +42 -11
- package/dist/features/auctions/components/AuctionDetailPageView.d.ts +8 -3
- package/dist/features/auctions/components/AuctionDetailPageView.js +2 -2
- package/dist/features/auctions/components/MarketplaceAuctionCard.js +4 -5
- package/dist/features/auctions/components/PlaceBidFormClient.d.ts +27 -2
- package/dist/features/auctions/components/PlaceBidFormClient.js +42 -20
- package/dist/features/auth/components/SocialAuthButtons.js +1 -1
- package/dist/features/auth/hooks/useAuth.d.ts +23 -1
- package/dist/features/auth/hooks/useAuth.js +27 -1
- package/dist/features/auth/permissions/constants.d.ts +1 -1
- package/dist/features/auth/permissions/constants.js +3 -0
- package/dist/features/auth/repository/session.repository.d.ts +16 -1
- package/dist/features/auth/repository/session.repository.js +23 -0
- package/dist/features/auth/repository/user.repository.js +13 -2
- package/dist/features/blog/components/BlogListView.js +2 -2
- package/dist/features/cart/components/CartPriceBreakdown.d.ts +11 -1
- package/dist/features/cart/components/CartPriceBreakdown.js +3 -3
- package/dist/features/cart/components/StoreAddonsPicker.d.ts +9 -0
- package/dist/features/cart/components/StoreAddonsPicker.js +15 -5
- package/dist/features/cart/components/index.d.ts +1 -1
- package/dist/features/cart/components/index.js +1 -1
- package/dist/features/cart/hooks/useGuestCartMerge.d.ts +8 -1
- package/dist/features/cart/hooks/useGuestCartMerge.js +2 -1
- package/dist/features/cart/utils/line-total.d.ts +25 -0
- package/dist/features/cart/utils/line-total.js +26 -0
- package/dist/features/catalogue/repository/catalogue.repository.d.ts +12 -0
- package/dist/features/catalogue/repository/catalogue.repository.js +15 -0
- package/dist/features/categories/components/CategoryTree.js +2 -2
- package/dist/features/contact/components/ContactForm.js +1 -1
- package/dist/features/copilot/hooks/useCopilotFeedback.d.ts +1 -1
- package/dist/features/copilot/hooks/useCopilotFeedback.js +1 -1
- package/dist/features/events/components/AdminEventEditorView.js +16 -9
- package/dist/features/events/components/AdminEventEntriesView.js +7 -13
- package/dist/features/events/components/EventRafflesSection.js +14 -2
- package/dist/features/events/components/EventStatusBadge.js +1 -1
- package/dist/features/events/components/event-entry-detail-fields.d.ts +24 -0
- package/dist/features/events/components/event-entry-detail-fields.js +13 -0
- package/dist/features/faq/components/ContactCTA.js +1 -1
- package/dist/features/faq/components/FAQHelpfulButtons.js +2 -2
- package/dist/features/faq/repository/faqs.repository.d.ts +16 -1
- package/dist/features/faq/repository/faqs.repository.js +64 -54
- package/dist/features/faq/schemas/firestore.d.ts +3 -2
- package/dist/features/faq/schemas/firestore.js +2 -2
- package/dist/features/faq/schemas/index.d.ts +3 -3
- package/dist/features/faq/schemas/index.js +1 -1
- package/dist/features/filters/FilterFacetSection.js +2 -2
- package/dist/features/forms/Toggle.js +1 -1
- package/dist/features/grouped/components/GroupedListingEditorView.d.ts +18 -0
- package/dist/features/grouped/components/GroupedListingEditorView.js +192 -0
- package/dist/features/homepage/components/AdvertisementBanner.js +2 -2
- package/dist/features/homepage/components/BrandsSection.js +1 -1
- package/dist/features/homepage/components/CTABannerSection.js +1 -1
- package/dist/features/homepage/components/HomepageCustomerReviewsSection.js +1 -1
- package/dist/features/homepage/components/HomepageSectionBoundary.d.ts +39 -0
- package/dist/features/homepage/components/HomepageSectionBoundary.js +49 -0
- package/dist/features/homepage/components/ShopByCategorySection.js +1 -1
- package/dist/features/homepage/components/WelcomeSection.js +1 -1
- package/dist/features/homepage/lib/section-renderer.js +2 -1
- package/dist/features/layout/AppLayoutShell.js +5 -5
- package/dist/features/layout/BottomActions.js +5 -5
- package/dist/features/layout/BottomSheet.js +1 -1
- package/dist/features/layout/MainNavbar.js +1 -1
- package/dist/features/layout/NavbarLayout.js +1 -1
- package/dist/features/layout/TitleBarLayout.js +3 -3
- package/dist/features/layout/hooks/useFormBottomActions.d.ts +11 -1
- package/dist/features/layout/hooks/useFormBottomActions.js +16 -2
- package/dist/features/lottery/schemas/config-write.d.ts +141 -0
- package/dist/features/lottery/schemas/config-write.js +139 -0
- package/dist/features/media/MediaPickerModal.js +3 -1
- package/dist/features/media/MediaVideo.js +4 -2
- package/dist/features/media/components/MediaSlider.js +1 -1
- package/dist/features/orders/components/OrdersList.js +2 -2
- package/dist/features/payments/repository/payout.repository.d.ts +11 -1
- package/dist/features/payments/repository/payout.repository.js +14 -6
- package/dist/features/pre-orders/components/MarketplacePreorderCard.js +3 -2
- package/dist/features/pre-orders/components/PreOrderBadge.js +1 -1
- package/dist/features/pre-orders/components/PreOrderDetailPageView.js +1 -1
- package/dist/features/products/columns/productTableColumns.js +3 -3
- package/dist/features/products/components/CompareOverlay.js +1 -1
- package/dist/features/products/components/PrizeDrawDetailPageView.js +1 -1
- package/dist/features/products/components/PrizeDrawEntryActions.js +2 -2
- package/dist/features/products/components/PrizeDrawsSection.js +5 -1
- package/dist/features/products/components/ProductDetailActions.js +11 -7
- package/dist/features/products/components/ProductDetailPageView.js +3 -3
- package/dist/features/products/components/ProductFeatureBadges.js +2 -2
- package/dist/features/products/components/ProductFeaturesSelector.js +1 -1
- package/dist/features/products/components/ProductFilters.js +15 -8
- package/dist/features/products/components/ProductGrid.js +8 -17
- package/dist/features/products/components/ProductTabs.js +7 -4
- package/dist/features/products/components/ProductTabsShell.js +2 -7
- package/dist/features/products/constants/action-defs.d.ts +21 -4
- package/dist/features/products/constants/action-defs.js +7 -3
- package/dist/features/products/hooks/useRelatedProducts.js +2 -1
- package/dist/features/products/repository/products.repository.d.ts +1 -1
- package/dist/features/products/repository/products.repository.js +27 -13
- package/dist/features/products/schemas/firestore.d.ts +3 -2
- package/dist/features/products/schemas/firestore.js +1 -1
- package/dist/features/reviews/actions/review-actions.d.ts +8 -1
- package/dist/features/reviews/actions/review-actions.js +14 -3
- package/dist/features/reviews/components/ReviewDetailShell.js +10 -4
- package/dist/features/reviews/components/ReviewModal.js +1 -1
- package/dist/features/reviews/repository/reviews.repository.d.ts +25 -1
- package/dist/features/reviews/repository/reviews.repository.js +53 -13
- package/dist/features/reviews/schemas/firestore.d.ts +6 -0
- package/dist/features/scams/components/ScamRegistryView.js +2 -2
- package/dist/features/search/components/Search.js +3 -3
- package/dist/features/search/components/SearchResultsSection.js +1 -1
- package/dist/features/seller/components/SellerAddressesView.js +3 -1
- package/dist/features/seller/components/SellerBidsView.js +6 -1
- package/dist/features/seller/components/SellerSidebar.js +1 -1
- package/dist/features/shell/SectionForm.d.ts +21 -1
- package/dist/features/shell/SectionForm.js +15 -3
- package/dist/features/shell/build-sections.js +10 -1
- package/dist/features/shell/field-ui-meta.d.ts +21 -0
- package/dist/features/shipments/components/AdminShipmentLotItemsView.js +23 -6
- package/dist/features/site-settings/components/ActionPermissionsManager.js +1 -1
- package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +21 -4
- package/dist/features/store-extensions/repository/store-extensions.repositories.js +24 -4
- package/dist/features/store-extensions/schemas/shipping-config-form.js +13 -0
- package/dist/features/store-extensions/schemas/store-category-form.js +1 -1
- package/dist/features/stores/actions/store-query-actions.js +25 -1
- package/dist/features/stores/components/InteractiveStoreCard.js +1 -1
- package/dist/features/stores/components/StoreHeader.js +1 -1
- package/dist/features/stores/components/StoreNavTabs.js +20 -8
- package/dist/features/support/repository/support.repository.d.ts +12 -0
- package/dist/features/support/repository/support.repository.js +15 -0
- package/dist/features/support/schemas/ticket-create-form.d.ts +33 -0
- package/dist/features/support/schemas/ticket-create-form.js +82 -0
- package/dist/features/tester/seed-data/products-tester-seed-data.js +2 -2
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +138 -1
- package/dist/features/wishlist/components/WishlistHeartButton.d.ts +55 -0
- package/dist/features/wishlist/components/WishlistHeartButton.js +52 -0
- package/dist/features/wishlist/components/index.d.ts +2 -1
- package/dist/features/wishlist/components/index.js +1 -1
- package/dist/index.d.ts +10 -3
- package/dist/index.js +19 -4
- package/dist/monitoring/error-tracking.js +39 -5
- package/dist/monitoring/server-logger.js +70 -32
- package/dist/next/components/ErrorView.js +1 -1
- package/dist/next/routing/route-map.d.ts +39 -7
- package/dist/next/routing/route-map.js +34 -4
- package/dist/providers/db-firebase/base.repository.d.ts +30 -0
- package/dist/providers/db-firebase/base.repository.js +56 -9
- package/dist/react/ErrorBoundary.js +9 -1
- package/dist/react/contexts/SessionContext.js +6 -2
- package/dist/react/hooks/useAuthGate.d.ts +22 -0
- package/dist/react/hooks/useAuthGate.js +86 -7
- package/dist/schemas/registry.d.ts +3 -3
- package/dist/security/index.d.ts +1 -1
- package/dist/security/index.js +1 -1
- package/dist/security/pii-encrypt.d.ts +13 -5
- package/dist/security/pii-encrypt.js +132 -6
- package/dist/security/pii-schemas.d.ts +20 -4
- package/dist/security/pii-schemas.js +23 -5
- package/dist/seed/_helpers/product-search-txt.d.ts +18 -0
- package/dist/seed/_helpers/product-search-txt.js +33 -0
- package/dist/seed/faq-seed-data.d.ts +16 -0
- package/dist/seed/faq-seed-data.js +22 -1
- package/dist/seed/products-art-seed-data.js +49 -7
- package/dist/seed/products-auctions-seed-data.js +12 -4
- package/dist/seed/products-classifieds-seed-data.d.ts +5 -0
- package/dist/seed/products-classifieds-seed-data.js +65 -9
- package/dist/seed/products-digital-codes-seed-data.d.ts +5 -0
- package/dist/seed/products-digital-codes-seed-data.js +65 -9
- package/dist/seed/products-live-items-seed-data.d.ts +5 -0
- package/dist/seed/products-live-items-seed-data.js +33 -5
- package/dist/seed/products-preorders-seed-data.d.ts +1 -1
- package/dist/seed/products-preorders-seed-data.js +6 -2
- package/dist/seed/products-prize-draws-seed-data.d.ts +5 -0
- package/dist/seed/products-prize-draws-seed-data.js +57 -8
- package/dist/seed/products-standard-seed-data.js +6 -2
- package/dist/seed/products-stickers-seed-data.js +49 -7
- package/dist/seed/site-settings-seed-data.js +5 -3
- package/dist/seo/json-ld.d.ts +44 -2
- package/dist/seo/json-ld.js +57 -11
- package/dist/styles.css +368 -90
- package/dist/tailwind-utilities.css +1 -1
- package/dist/tokens/index.d.ts +1 -1
- package/dist/tokens/index.js +1 -1
- package/dist/tokens/themes/default-dark.js +6 -0
- package/dist/tokens/themes/default-light.js +5 -0
- package/dist/tokens/tokens.css +84 -0
- package/dist/ui/DataTable.js +1 -1
- package/dist/ui/DataTable.style.css +3 -3
- package/dist/ui/components/BulkActionBar.style.css +1 -3
- package/dist/ui/components/Button.js +15 -6
- package/dist/ui/components/Dropdown.style.css +1 -4
- package/dist/ui/components/FilterChipGroup.js +1 -1
- package/dist/ui/components/FilterDrawer.style.css +1 -1
- package/dist/ui/components/Icon.d.ts +53 -0
- package/dist/ui/components/Icon.js +41 -0
- package/dist/ui/components/IconButton.d.ts +13 -0
- package/dist/ui/components/IconButton.js +13 -0
- package/dist/ui/components/IconButton.style.css +18 -10
- package/dist/ui/components/ListingLayout.style.css +2 -10
- package/dist/ui/components/ListingToolbar.js +6 -6
- package/dist/ui/components/Modal.js +1 -1
- package/dist/ui/components/PaginatedSelect.style.css +3 -3
- package/dist/ui/components/Pagination.style.css +1 -6
- package/dist/ui/components/RichTextEditor.js +1 -1
- package/dist/ui/components/RowActionMenu.style.css +1 -1
- package/dist/ui/components/SideDrawer.style.css +1 -1
- package/dist/ui/components/Slider.js +1 -1
- package/dist/ui/components/TabBarShell.d.ts +78 -0
- package/dist/ui/components/TabBarShell.js +95 -0
- package/dist/ui/components/Tabs.js +12 -8
- package/dist/ui/components/Tabs.style.css +249 -41
- package/dist/ui/components/ViewToggle.style.css +1 -5
- package/dist/ui/components/tabs-overflow.d.ts +17 -0
- package/dist/ui/components/tabs-overflow.js +142 -0
- package/dist/ui/icons/icon-registry.d.ts +38 -0
- package/dist/ui/icons/icon-registry.js +37 -0
- package/dist/ui/index.d.ts +6 -0
- package/dist/ui/index.js +3 -0
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/utils/media-url.d.ts +27 -4
- package/dist/utils/media-url.js +37 -6
- package/dist/utils/search-txt.d.ts +71 -0
- package/dist/utils/search-txt.js +118 -0
- package/package.json +1 -1
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
/*
|
|
3
|
+
* WHY: A tab strip that overflows its container needs a visible affordance,
|
|
4
|
+
* and a thin scrollbar is not one on a touch device or a trackpad. The
|
|
5
|
+
* arrows this hook drives are in-flow flex siblings of the tablist, which
|
|
6
|
+
* is what makes the hysteresis below mandatory rather than a nicety.
|
|
7
|
+
*
|
|
8
|
+
* WHAT: Measures whether the tab list overflows, whether it is scrolled to
|
|
9
|
+
* either end, and exposes a paging scroll. Consumed only by
|
|
10
|
+
* <TabBarShell>.
|
|
11
|
+
*
|
|
12
|
+
* EXPORTS: useTabsOverflow, TabsOverflowState
|
|
13
|
+
*
|
|
14
|
+
* @tag domain:ui
|
|
15
|
+
* @tag layer:ui
|
|
16
|
+
* @tag pattern:hook
|
|
17
|
+
* @tag access:client
|
|
18
|
+
* @tag consumers:TabBarShell
|
|
19
|
+
* @tag sideEffects:dom-measurement
|
|
20
|
+
*/
|
|
21
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
22
|
+
/**
|
|
23
|
+
* Slack used by the hysteresis rule below, in px. Matches
|
|
24
|
+
* 2 x --appkit-tabs-arrow-size (2rem) — the width the two in-flow arrows
|
|
25
|
+
* reclaim from the list when they unmount.
|
|
26
|
+
*/
|
|
27
|
+
const ARROW_SLACK_PX = 64;
|
|
28
|
+
/** Sub-pixel tolerance: scrollWidth/clientWidth can differ by <1px at some zooms. */
|
|
29
|
+
const EPSILON = 1;
|
|
30
|
+
const INITIAL = { hasArrows: false, atStart: true, atEnd: true };
|
|
31
|
+
function sameState(a, b) {
|
|
32
|
+
return a.hasArrows === b.hasArrows && a.atStart === b.atStart && a.atEnd === b.atEnd;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Drives the scroll-arrow affordances on a horizontally scrollable tab list.
|
|
36
|
+
*
|
|
37
|
+
* `activeKey` identifies the currently-selected tab. It exists solely to key
|
|
38
|
+
* the scroll-into-view effect, so the strip repositions when the selection
|
|
39
|
+
* changes and stays put while the user scrolls.
|
|
40
|
+
*/
|
|
41
|
+
export function useTabsOverflow(listRef, activeKey) {
|
|
42
|
+
// Starts false and can only become true inside an effect, so the server HTML
|
|
43
|
+
// never contains arrows and always matches the first client render.
|
|
44
|
+
const [state, setState] = useState(INITIAL);
|
|
45
|
+
const stateRef = useRef(state);
|
|
46
|
+
stateRef.current = state;
|
|
47
|
+
const measure = useCallback(() => {
|
|
48
|
+
const el = listRef.current;
|
|
49
|
+
if (!el)
|
|
50
|
+
return;
|
|
51
|
+
const overflow = el.scrollWidth - el.clientWidth;
|
|
52
|
+
const previous = stateRef.current.hasArrows;
|
|
53
|
+
/*
|
|
54
|
+
* 🛑 HYSTERESIS — do not collapse this to a single threshold.
|
|
55
|
+
*
|
|
56
|
+
* The arrows are IN-FLOW, so mounting them shrinks the list's clientWidth
|
|
57
|
+
* by ~ARROW_SLACK_PX, which INCREASES the measured overflow. With one
|
|
58
|
+
* threshold a strip 10px wider than its container oscillates forever:
|
|
59
|
+
* hidden -> overflow 10 -> show -> overflow 74 -> still overflowing
|
|
60
|
+
* -> ... -> hide -> overflow -54 -> hide -> widen -> overflow 10 -> show
|
|
61
|
+
*
|
|
62
|
+
* Once the arrows are up they may only come down if the content would
|
|
63
|
+
* STILL fit after reclaiming their width, which by construction leaves
|
|
64
|
+
* overflow <= 0 afterwards. That makes the rule provably stable.
|
|
65
|
+
*/
|
|
66
|
+
const hasArrows = previous ? overflow > ARROW_SLACK_PX : overflow > EPSILON;
|
|
67
|
+
const max = el.scrollWidth - el.clientWidth;
|
|
68
|
+
// Math.abs: scrollLeft is negative (or inverted) in RTL depending on engine.
|
|
69
|
+
const position = Math.abs(el.scrollLeft);
|
|
70
|
+
const next = {
|
|
71
|
+
hasArrows,
|
|
72
|
+
atStart: position <= EPSILON,
|
|
73
|
+
atEnd: max <= EPSILON || position >= max - EPSILON,
|
|
74
|
+
};
|
|
75
|
+
setState((current) => (sameState(current, next) ? current : next));
|
|
76
|
+
}, [listRef]);
|
|
77
|
+
/*
|
|
78
|
+
* Content width — a badge appearing, a locale swapping every label, a tab
|
|
79
|
+
* being added. Deliberately dependency-FREE so it runs after every render:
|
|
80
|
+
* none of those changes is expressible as a dep, and a ResizeObserver on a
|
|
81
|
+
* scroll container fires only for its own box, never for its content. The
|
|
82
|
+
* `sameState` guard in `measure` is what makes this safe — it only calls
|
|
83
|
+
* setState when a value actually changed, so there is no render loop. The
|
|
84
|
+
* cost is two layout READS per render (no writes, so no forced reflow).
|
|
85
|
+
*/
|
|
86
|
+
useLayoutEffect(() => {
|
|
87
|
+
measure();
|
|
88
|
+
});
|
|
89
|
+
// Container width: viewport resize, sidebar collapse, drawer open.
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
const el = listRef.current;
|
|
92
|
+
// jsdom does not implement ResizeObserver; appkit/tests/setup.ts stubs it,
|
|
93
|
+
// but guard anyway so the hook is safe in any non-DOM runtime.
|
|
94
|
+
if (!el || typeof ResizeObserver === "undefined")
|
|
95
|
+
return;
|
|
96
|
+
const observer = new ResizeObserver(() => measure());
|
|
97
|
+
observer.observe(el);
|
|
98
|
+
return () => observer.disconnect();
|
|
99
|
+
}, [listRef, measure]);
|
|
100
|
+
// atStart / atEnd. Passive: this listener never calls preventDefault.
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
const el = listRef.current;
|
|
103
|
+
if (!el)
|
|
104
|
+
return;
|
|
105
|
+
const onScroll = () => measure();
|
|
106
|
+
el.addEventListener("scroll", onScroll, { passive: true });
|
|
107
|
+
return () => el.removeEventListener("scroll", onScroll);
|
|
108
|
+
}, [listRef, measure]);
|
|
109
|
+
// A web-font swap changes every label's width after first paint; without
|
|
110
|
+
// this the initial measurement is taken against the fallback font.
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
let cancelled = false;
|
|
113
|
+
const fonts = document.fonts;
|
|
114
|
+
fonts?.ready?.then(() => {
|
|
115
|
+
if (!cancelled)
|
|
116
|
+
measure();
|
|
117
|
+
});
|
|
118
|
+
return () => {
|
|
119
|
+
cancelled = true;
|
|
120
|
+
};
|
|
121
|
+
}, [measure]);
|
|
122
|
+
/*
|
|
123
|
+
* Keep the selected tab reachable when it starts off-screen in the strip.
|
|
124
|
+
* Keyed on `activeKey` and NOT run every render — an
|
|
125
|
+
* unconditional scrollIntoView would yank the strip back while the user is
|
|
126
|
+
* mid-scroll, fighting their own input.
|
|
127
|
+
*/
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
const active = listRef.current?.querySelector('[aria-selected="true"]');
|
|
130
|
+
// `block: "nearest"` is essential — the default "start" scrolls the whole
|
|
131
|
+
// PAGE vertically whenever a tab bar sits below the fold.
|
|
132
|
+
active?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
|
|
133
|
+
}, [listRef, activeKey]);
|
|
134
|
+
const scrollByPage = useCallback((direction) => {
|
|
135
|
+
const el = listRef.current;
|
|
136
|
+
// Smoothness comes from `scroll-behavior: smooth` in Tabs.style.css,
|
|
137
|
+
// which already has a prefers-reduced-motion override — no JS branch.
|
|
138
|
+
// `?.` because jsdom implements neither scrollBy nor scrollIntoView.
|
|
139
|
+
el?.scrollBy?.({ left: direction * el.clientWidth * 0.8 });
|
|
140
|
+
}, [listRef]);
|
|
141
|
+
return { ...state, scrollByPage };
|
|
142
|
+
}
|
|
@@ -53,6 +53,44 @@ export declare const ICONS: {
|
|
|
53
53
|
};
|
|
54
54
|
/** Every valid `iconKey`. A typo is a compile error, not a silent blank. */
|
|
55
55
|
export type IconKey = keyof typeof ICONS;
|
|
56
|
+
/**
|
|
57
|
+
* The ONLY sanctioned glyph sizes.
|
|
58
|
+
*
|
|
59
|
+
* WHY: a sweep of `appkit/src` + `src` found ~217 icon sites each hand-picking
|
|
60
|
+
* a size, in two spellings with no canonical order (`h-4 w-4` ×104,
|
|
61
|
+
* `w-5 h-5` ×64, `h-3 w-3` ×23, `h-3.5 w-3.5` ×26) across 105 lucide-importing
|
|
62
|
+
* files and 63 with an inline `<svg>`. Nothing related a glyph's size to the
|
|
63
|
+
* control containing it, so a 14px heart could sit in a 44px button and read as
|
|
64
|
+
* broken — which is exactly the "the wishlist icon is very small" report.
|
|
65
|
+
*
|
|
66
|
+
* Pick by ROLE, not by eye:
|
|
67
|
+
*
|
|
68
|
+
* | key | px | use for |
|
|
69
|
+
* |------|----|-----------------------------------------------------|
|
|
70
|
+
* | `xs` | 12 | dense meta, inside a badge or a caption row |
|
|
71
|
+
* | `sm` | 14 | chrome inside a `size="sm"` control |
|
|
72
|
+
* | `md` | 16 | inline with body text; a `size="md"` control |
|
|
73
|
+
* | `lg` | 20 | a standalone icon-only control's glyph |
|
|
74
|
+
* | `xl` | 24 | hero, empty-state, feature tile |
|
|
75
|
+
* | `2xl`| 28 | chrome floating on an overlay (lightbox, carousel) |
|
|
76
|
+
*
|
|
77
|
+
* `2xl` exists because ten lightbox/carousel controls had independently and
|
|
78
|
+
* unanimously picked 28px — that agreement is a real tier, not ten mistakes.
|
|
79
|
+
* Overlay chrome sits over arbitrary imagery and needs more presence than
|
|
80
|
+
* inline UI.
|
|
81
|
+
*
|
|
82
|
+
* `<Button>` and `<IconButton>` derive their own icon size from these — prefer
|
|
83
|
+
* letting the control choose over passing a size by hand.
|
|
84
|
+
*/
|
|
85
|
+
export declare const ICON_SIZE: {
|
|
86
|
+
readonly xs: "h-3 w-3";
|
|
87
|
+
readonly sm: "h-3.5 w-3.5";
|
|
88
|
+
readonly md: "h-4 w-4";
|
|
89
|
+
readonly lg: "h-5 w-5";
|
|
90
|
+
readonly xl: "h-6 w-6";
|
|
91
|
+
readonly "2xl": "h-7 w-7";
|
|
92
|
+
};
|
|
93
|
+
export type IconSizeKey = keyof typeof ICON_SIZE;
|
|
56
94
|
/** Narrow an unknown string — for data that crosses a boundary the type cannot. */
|
|
57
95
|
export declare function isIconKey(value: unknown): value is IconKey;
|
|
58
96
|
/**
|
|
@@ -99,6 +99,43 @@ export const ICONS = {
|
|
|
99
99
|
expand: ChevronDown,
|
|
100
100
|
user: User,
|
|
101
101
|
};
|
|
102
|
+
/**
|
|
103
|
+
* The ONLY sanctioned glyph sizes.
|
|
104
|
+
*
|
|
105
|
+
* WHY: a sweep of `appkit/src` + `src` found ~217 icon sites each hand-picking
|
|
106
|
+
* a size, in two spellings with no canonical order (`h-4 w-4` ×104,
|
|
107
|
+
* `w-5 h-5` ×64, `h-3 w-3` ×23, `h-3.5 w-3.5` ×26) across 105 lucide-importing
|
|
108
|
+
* files and 63 with an inline `<svg>`. Nothing related a glyph's size to the
|
|
109
|
+
* control containing it, so a 14px heart could sit in a 44px button and read as
|
|
110
|
+
* broken — which is exactly the "the wishlist icon is very small" report.
|
|
111
|
+
*
|
|
112
|
+
* Pick by ROLE, not by eye:
|
|
113
|
+
*
|
|
114
|
+
* | key | px | use for |
|
|
115
|
+
* |------|----|-----------------------------------------------------|
|
|
116
|
+
* | `xs` | 12 | dense meta, inside a badge or a caption row |
|
|
117
|
+
* | `sm` | 14 | chrome inside a `size="sm"` control |
|
|
118
|
+
* | `md` | 16 | inline with body text; a `size="md"` control |
|
|
119
|
+
* | `lg` | 20 | a standalone icon-only control's glyph |
|
|
120
|
+
* | `xl` | 24 | hero, empty-state, feature tile |
|
|
121
|
+
* | `2xl`| 28 | chrome floating on an overlay (lightbox, carousel) |
|
|
122
|
+
*
|
|
123
|
+
* `2xl` exists because ten lightbox/carousel controls had independently and
|
|
124
|
+
* unanimously picked 28px — that agreement is a real tier, not ten mistakes.
|
|
125
|
+
* Overlay chrome sits over arbitrary imagery and needs more presence than
|
|
126
|
+
* inline UI.
|
|
127
|
+
*
|
|
128
|
+
* `<Button>` and `<IconButton>` derive their own icon size from these — prefer
|
|
129
|
+
* letting the control choose over passing a size by hand.
|
|
130
|
+
*/
|
|
131
|
+
export const ICON_SIZE = {
|
|
132
|
+
xs: "h-3 w-3",
|
|
133
|
+
sm: "h-3.5 w-3.5",
|
|
134
|
+
md: "h-4 w-4",
|
|
135
|
+
lg: "h-5 w-5",
|
|
136
|
+
xl: "h-6 w-6",
|
|
137
|
+
"2xl": "h-7 w-7",
|
|
138
|
+
};
|
|
102
139
|
/** Narrow an unknown string — for data that crosses a boundary the type cannot. */
|
|
103
140
|
export function isIconKey(value) {
|
|
104
141
|
return typeof value === "string" && value in ICONS;
|
package/dist/ui/index.d.ts
CHANGED
|
@@ -35,6 +35,10 @@ export type { ButtonProps } from "./components/Button";
|
|
|
35
35
|
export { Button } from "./components/Button";
|
|
36
36
|
export type { IconButtonProps } from "./components/IconButton";
|
|
37
37
|
export { IconButton } from "./components/IconButton";
|
|
38
|
+
export type { IconProps, IconTone } from "./components/Icon";
|
|
39
|
+
export { Icon } from "./components/Icon";
|
|
40
|
+
export type { IconKey, IconSizeKey } from "./icons/icon-registry";
|
|
41
|
+
export { ICONS, ICON_SIZE, isIconKey, resolveIcon, } from "./icons/icon-registry";
|
|
38
42
|
export type { TextLinkProps } from "./components/TextLink";
|
|
39
43
|
export { TextLink } from "./components/TextLink";
|
|
40
44
|
export type { TooltipProps } from "./components/Tooltip";
|
|
@@ -153,6 +157,8 @@ export type { NavigationLoaderProps } from "./components/NavigationLoader";
|
|
|
153
157
|
export { NavigationLoader } from "./components/NavigationLoader";
|
|
154
158
|
export type { TabsProps, TabsListProps, TabsTriggerProps, TabsContentProps, } from "./components/Tabs";
|
|
155
159
|
export { Tabs, TabsList, TabsTrigger, TabsContent } from "./components/Tabs";
|
|
160
|
+
export type { TabBarShellProps, TabBarButtonProps, TabsNavSelectProps, } from "./components/TabBarShell";
|
|
161
|
+
export { TabBarShell, TabBarButton, TabsNavSelect } from "./components/TabBarShell";
|
|
156
162
|
export type { FilterDrawerProps } from "./components/FilterDrawer";
|
|
157
163
|
export { FilterDrawer } from "./components/FilterDrawer";
|
|
158
164
|
export type { RowAction, RowActionMenuProps } from "./components/RowActionMenu";
|
package/dist/ui/index.js
CHANGED
|
@@ -20,6 +20,8 @@ export { SiteMark } from "./components/SiteMark";
|
|
|
20
20
|
export { Skeleton } from "./components/Skeleton";
|
|
21
21
|
export { Button } from "./components/Button";
|
|
22
22
|
export { IconButton } from "./components/IconButton";
|
|
23
|
+
export { Icon } from "./components/Icon";
|
|
24
|
+
export { ICONS, ICON_SIZE, isIconKey, resolveIcon, } from "./icons/icon-registry";
|
|
23
25
|
export { TextLink } from "./components/TextLink";
|
|
24
26
|
export { Tooltip } from "./components/Tooltip";
|
|
25
27
|
export { Accordion, AccordionItem } from "./components/Accordion";
|
|
@@ -79,6 +81,7 @@ export { RoleBadge } from "./components/RoleBadge";
|
|
|
79
81
|
export { SkipToMain } from "./components/SkipToMain";
|
|
80
82
|
export { NavigationLoader } from "./components/NavigationLoader";
|
|
81
83
|
export { Tabs, TabsList, TabsTrigger, TabsContent } from "./components/Tabs";
|
|
84
|
+
export { TabBarShell, TabBarButton, TabsNavSelect } from "./components/TabBarShell";
|
|
82
85
|
export { FilterDrawer } from "./components/FilterDrawer";
|
|
83
86
|
export { RowActionMenu } from "./components/RowActionMenu";
|
|
84
87
|
export { PasswordStrengthIndicator } from "./components/PasswordStrengthIndicator";
|
package/dist/utils/index.d.ts
CHANGED
|
@@ -17,5 +17,5 @@ export * from "./business-day";
|
|
|
17
17
|
export * from "./schema-ui";
|
|
18
18
|
export * from "./listing-params";
|
|
19
19
|
export * from "./action-response";
|
|
20
|
-
export * from "./search-
|
|
20
|
+
export * from "./search-txt";
|
|
21
21
|
export { splitCartIntoOrderGroups, type OrderType, type OrderGroup, } from "../features/orders/utils/order-splitter";
|
package/dist/utils/index.js
CHANGED
|
@@ -17,6 +17,6 @@ export * from "./business-day";
|
|
|
17
17
|
export * from "./schema-ui";
|
|
18
18
|
export * from "./listing-params";
|
|
19
19
|
export * from "./action-response";
|
|
20
|
-
export * from "./search-
|
|
20
|
+
export * from "./search-txt";
|
|
21
21
|
// Re-export order utilities for checkout flows
|
|
22
22
|
export { splitCartIntoOrderGroups, } from "../features/orders/utils/order-splitter";
|
|
@@ -7,17 +7,40 @@
|
|
|
7
7
|
export declare const FIREBASE_STORAGE_HOST = "firebasestorage.googleapis.com";
|
|
8
8
|
/** Google Cloud Storage host (used by public-asset URLs). */
|
|
9
9
|
export declare const GCS_HOST = "storage.googleapis.com";
|
|
10
|
+
export interface ResolveMediaUrlOptions {
|
|
11
|
+
/**
|
|
12
|
+
* When false, a URL we do not own is returned UNCHANGED instead of being sent
|
|
13
|
+
* to `/api/media/ext`. That proxy is image-only (it 400s on `video/mp4`), so
|
|
14
|
+
* every non-image caller must pass false — see `resolveVideoUrl`.
|
|
15
|
+
* @default true
|
|
16
|
+
*/
|
|
17
|
+
externalProxy?: boolean;
|
|
18
|
+
}
|
|
10
19
|
/**
|
|
11
20
|
* Normalise any image URL so it goes through the watermark proxy:
|
|
12
|
-
* - /media
|
|
21
|
+
* - /media<path> → return as-is (already proxied via Firebase Storage)
|
|
13
22
|
* - blob: / data: URI → return as-is (local-only, never fetchable server-side)
|
|
14
23
|
* - Firebase Storage URL → extract /o/ path → /media/<path>
|
|
15
|
-
* - GCS
|
|
16
|
-
* - Any other absolute URL → /api/media/ext?url=<encoded> (ext watermark proxy)
|
|
24
|
+
* - GCS URL for OUR bucket → drop the bucket segment → /media/<path>
|
|
25
|
+
* - Any other absolute URL → /api/media/ext?url=<encoded> (ext watermark proxy),
|
|
26
|
+
* or unchanged when `externalProxy: false`
|
|
17
27
|
* - Relative URI → return as-is
|
|
18
28
|
* - Falsy → undefined
|
|
19
29
|
*/
|
|
20
|
-
export declare function resolveMediaUrl(url: string | null | undefined): string | undefined;
|
|
30
|
+
export declare function resolveMediaUrl(url: string | null | undefined, opts?: ResolveMediaUrlOptions): string | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* Resolve a VIDEO src.
|
|
33
|
+
*
|
|
34
|
+
* Same rules as `resolveMediaUrl` for media we own — a Firebase Storage or
|
|
35
|
+
* own-bucket GCS URL still becomes `/media/<path>`, and the `[...slug]` proxy
|
|
36
|
+
* raw-pipes non-images — but a third-party URL is returned untouched rather
|
|
37
|
+
* than routed through `/api/media/ext`, which is image-only and 400s on
|
|
38
|
+
* `video/mp4` (Root Cause #27).
|
|
39
|
+
*
|
|
40
|
+
* Use this for anything that ends up as a `<video src>`. Posters and thumbnails
|
|
41
|
+
* are images and stay on `resolveMediaUrl`.
|
|
42
|
+
*/
|
|
43
|
+
export declare function resolveVideoUrl(url: string | null | undefined): string | undefined;
|
|
21
44
|
/**
|
|
22
45
|
* Extracts the 11-char YouTube video ID from a watch/share/embed URL, or
|
|
23
46
|
* `null` when `url` isn't a recognized YouTube URL. A video field can be
|
package/dist/utils/media-url.js
CHANGED
|
@@ -10,17 +10,25 @@ export const FIREBASE_STORAGE_HOST = "firebasestorage.googleapis.com";
|
|
|
10
10
|
/** Google Cloud Storage host (used by public-asset URLs). */
|
|
11
11
|
export const GCS_HOST = "storage.googleapis.com";
|
|
12
12
|
const PROXY_PREFIX = "/media/";
|
|
13
|
+
/**
|
|
14
|
+
* Our own Storage bucket, as it appears as the first path segment of a
|
|
15
|
+
* `storage.googleapis.com` URL. Only URLs pointing at THIS bucket may have that
|
|
16
|
+
* segment stripped and be rewritten to `/media/<path>` — see the GCS branch
|
|
17
|
+
* below for why assuming every GCS URL is ours is a live 404.
|
|
18
|
+
*/
|
|
19
|
+
const OWN_STORAGE_BUCKET = process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET ?? "";
|
|
13
20
|
/**
|
|
14
21
|
* Normalise any image URL so it goes through the watermark proxy:
|
|
15
|
-
* - /media
|
|
22
|
+
* - /media<path> → return as-is (already proxied via Firebase Storage)
|
|
16
23
|
* - blob: / data: URI → return as-is (local-only, never fetchable server-side)
|
|
17
24
|
* - Firebase Storage URL → extract /o/ path → /media/<path>
|
|
18
|
-
* - GCS
|
|
19
|
-
* - Any other absolute URL → /api/media/ext?url=<encoded> (ext watermark proxy)
|
|
25
|
+
* - GCS URL for OUR bucket → drop the bucket segment → /media/<path>
|
|
26
|
+
* - Any other absolute URL → /api/media/ext?url=<encoded> (ext watermark proxy),
|
|
27
|
+
* or unchanged when `externalProxy: false`
|
|
20
28
|
* - Relative URI → return as-is
|
|
21
29
|
* - Falsy → undefined
|
|
22
30
|
*/
|
|
23
|
-
export function resolveMediaUrl(url) {
|
|
31
|
+
export function resolveMediaUrl(url, opts) {
|
|
24
32
|
if (!url)
|
|
25
33
|
return undefined;
|
|
26
34
|
if (url.startsWith(PROXY_PREFIX))
|
|
@@ -43,19 +51,42 @@ export function resolveMediaUrl(url) {
|
|
|
43
51
|
// Path shape: /<bucket>/<object-path...> — drop the bucket segment,
|
|
44
52
|
// proxy the rest like a Firebase Storage URL (consistent watermarking
|
|
45
53
|
// + caching instead of falling through to the external-URL proxy).
|
|
54
|
+
//
|
|
55
|
+
// ONLY for our own bucket. This branch used to strip the first segment of
|
|
56
|
+
// ANY storage.googleapis.com URL, so Google's public sample bucket
|
|
57
|
+
// https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4
|
|
58
|
+
// became /media/sample/BigBuckBunny.mp4 — an object that has never existed
|
|
59
|
+
// in our bucket — and 404'd on the homepage hero carousel.
|
|
46
60
|
const parts = parsed.pathname.replace(/^\//, "").split("/");
|
|
47
|
-
if (parts.length > 1) {
|
|
61
|
+
if (parts.length > 1 && OWN_STORAGE_BUCKET && parts[0] === OWN_STORAGE_BUCKET) {
|
|
48
62
|
const objectPath = parts.slice(1).join("/");
|
|
49
63
|
return `${PROXY_PREFIX}${decodeURIComponent(objectPath)}`;
|
|
50
64
|
}
|
|
51
65
|
}
|
|
52
|
-
|
|
66
|
+
// Not ours. The ext proxy watermarks IMAGES only and 400s on anything else,
|
|
67
|
+
// so a non-image caller opts out and gets the original URL back.
|
|
68
|
+
return opts?.externalProxy === false ? url : MEDIA_ENDPOINTS.EXT_URL(url);
|
|
53
69
|
}
|
|
54
70
|
catch (_err) {
|
|
55
71
|
void normalizeError(_err); // URL constructor throws for non-URL strings (e.g., relative paths) — return original
|
|
56
72
|
return url;
|
|
57
73
|
}
|
|
58
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Resolve a VIDEO src.
|
|
77
|
+
*
|
|
78
|
+
* Same rules as `resolveMediaUrl` for media we own — a Firebase Storage or
|
|
79
|
+
* own-bucket GCS URL still becomes `/media/<path>`, and the `[...slug]` proxy
|
|
80
|
+
* raw-pipes non-images — but a third-party URL is returned untouched rather
|
|
81
|
+
* than routed through `/api/media/ext`, which is image-only and 400s on
|
|
82
|
+
* `video/mp4` (Root Cause #27).
|
|
83
|
+
*
|
|
84
|
+
* Use this for anything that ends up as a `<video src>`. Posters and thumbnails
|
|
85
|
+
* are images and stay on `resolveMediaUrl`.
|
|
86
|
+
*/
|
|
87
|
+
export function resolveVideoUrl(url) {
|
|
88
|
+
return resolveMediaUrl(url, { externalProxy: false });
|
|
89
|
+
}
|
|
59
90
|
const YOUTUBE_HOSTS = new Set([
|
|
60
91
|
"youtube.com",
|
|
61
92
|
"m.youtube.com",
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `searchTxt` — one normalized, partial-match search field per entity.
|
|
3
|
+
*
|
|
4
|
+
* ## Why an array and not a string
|
|
5
|
+
*
|
|
6
|
+
* Firestore has no LIKE/substring operator. A string field supports `==` and a
|
|
7
|
+
* range, and a range on a string is a **prefix anchored at the start of the whole
|
|
8
|
+
* value** — so `"pokemon charizard psa 9"` would match a search for `"pok"` and
|
|
9
|
+
* never for `"charizard"`. A single text column cannot do partial match here.
|
|
10
|
+
*
|
|
11
|
+
* An array of normalized *edge n-grams* can, in one `array-contains` clause:
|
|
12
|
+
*
|
|
13
|
+
* "Pokémon Charizard PSA 9"
|
|
14
|
+
* → normalize → "pokemon charizard psa 9"
|
|
15
|
+
* → ["po","pok",…,"pokemon","ch","cha","char","chari",…,"charizard","ps","psa"]
|
|
16
|
+
*
|
|
17
|
+
* A query for "chari" is then a single equality-shaped clause against one index.
|
|
18
|
+
* No OR-group, no query-time case folding, no per-sort composite index.
|
|
19
|
+
*
|
|
20
|
+
* ## Deliberate limits
|
|
21
|
+
*
|
|
22
|
+
* - **Word-prefix, not mid-word.** "ariza" will not match "charizard". True
|
|
23
|
+
* mid-word needs trigrams, which multiply index entries several-fold for a
|
|
24
|
+
* case users rarely type.
|
|
25
|
+
* - **One `array-contains` per Firestore query**, so a multi-word search uses the
|
|
26
|
+
* most selective term as the clause and AND-refines the rest server-side.
|
|
27
|
+
* - **Never put PII in here.** These are readable fragments of the source text —
|
|
28
|
+
* a `searchTxt` for a person's name IS that name, re-encoded. Encrypted fields
|
|
29
|
+
* stay searchable only through their HMAC blind index (exact match).
|
|
30
|
+
*/
|
|
31
|
+
export interface SearchTxtOptions {
|
|
32
|
+
/**
|
|
33
|
+
* Emit prefixes for these sources. Use for short, high-signal text (title,
|
|
34
|
+
* tags, category). Long body text should stay whole-word or it dominates the
|
|
35
|
+
* token budget.
|
|
36
|
+
*/
|
|
37
|
+
prefix?: boolean;
|
|
38
|
+
maxPrefixLength?: number;
|
|
39
|
+
maxTokens?: number;
|
|
40
|
+
}
|
|
41
|
+
type Source = string | string[] | undefined | null;
|
|
42
|
+
/**
|
|
43
|
+
* Lowercase, strip accents, collapse everything non-alphanumeric to spaces.
|
|
44
|
+
*
|
|
45
|
+
* Accent folding is what makes "Pokemon" find "Pokémon" — a plain lowercase
|
|
46
|
+
* would leave those as different tokens.
|
|
47
|
+
*/
|
|
48
|
+
export declare function normalizeSearchText(input: string): string;
|
|
49
|
+
/**
|
|
50
|
+
* Build the stored token array.
|
|
51
|
+
*
|
|
52
|
+
* @param sources any mix of strings and string arrays; nullish entries ignored
|
|
53
|
+
*/
|
|
54
|
+
export declare function buildSearchTxt(sources: Source[], options?: SearchTxtOptions): string[];
|
|
55
|
+
/**
|
|
56
|
+
* Tokenize a user's query into candidate clause values, most selective first.
|
|
57
|
+
*
|
|
58
|
+
* Longest term first because only one may become the `array-contains` clause —
|
|
59
|
+
* the longest is the cheapest proxy for "narrows the result set most" without
|
|
60
|
+
* keeping per-term cardinality statistics.
|
|
61
|
+
*
|
|
62
|
+
* Uses the SAME normalizer as the write side; if the two ever diverge, a query
|
|
63
|
+
* silently stops matching what was indexed.
|
|
64
|
+
*/
|
|
65
|
+
export declare function parseSearchTxtQuery(query: string): string[];
|
|
66
|
+
/**
|
|
67
|
+
* Does a document's stored tokens satisfy every term? Used for the AND-refine
|
|
68
|
+
* pass after the single `array-contains` clause has narrowed the candidates.
|
|
69
|
+
*/
|
|
70
|
+
export declare function matchesAllSearchTerms(searchTxt: readonly string[] | undefined, terms: readonly string[]): boolean;
|
|
71
|
+
export {};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `searchTxt` — one normalized, partial-match search field per entity.
|
|
3
|
+
*
|
|
4
|
+
* ## Why an array and not a string
|
|
5
|
+
*
|
|
6
|
+
* Firestore has no LIKE/substring operator. A string field supports `==` and a
|
|
7
|
+
* range, and a range on a string is a **prefix anchored at the start of the whole
|
|
8
|
+
* value** — so `"pokemon charizard psa 9"` would match a search for `"pok"` and
|
|
9
|
+
* never for `"charizard"`. A single text column cannot do partial match here.
|
|
10
|
+
*
|
|
11
|
+
* An array of normalized *edge n-grams* can, in one `array-contains` clause:
|
|
12
|
+
*
|
|
13
|
+
* "Pokémon Charizard PSA 9"
|
|
14
|
+
* → normalize → "pokemon charizard psa 9"
|
|
15
|
+
* → ["po","pok",…,"pokemon","ch","cha","char","chari",…,"charizard","ps","psa"]
|
|
16
|
+
*
|
|
17
|
+
* A query for "chari" is then a single equality-shaped clause against one index.
|
|
18
|
+
* No OR-group, no query-time case folding, no per-sort composite index.
|
|
19
|
+
*
|
|
20
|
+
* ## Deliberate limits
|
|
21
|
+
*
|
|
22
|
+
* - **Word-prefix, not mid-word.** "ariza" will not match "charizard". True
|
|
23
|
+
* mid-word needs trigrams, which multiply index entries several-fold for a
|
|
24
|
+
* case users rarely type.
|
|
25
|
+
* - **One `array-contains` per Firestore query**, so a multi-word search uses the
|
|
26
|
+
* most selective term as the clause and AND-refines the rest server-side.
|
|
27
|
+
* - **Never put PII in here.** These are readable fragments of the source text —
|
|
28
|
+
* a `searchTxt` for a person's name IS that name, re-encoded. Encrypted fields
|
|
29
|
+
* stay searchable only through their HMAC blind index (exact match).
|
|
30
|
+
*/
|
|
31
|
+
/** Longest prefix emitted. Nobody types more than this before results narrow. */
|
|
32
|
+
const MAX_PREFIX_LENGTH = 12;
|
|
33
|
+
/** Shortest prefix emitted. 1 keeps single-character searches working. */
|
|
34
|
+
const MIN_PREFIX_LENGTH = 1;
|
|
35
|
+
/**
|
|
36
|
+
* Ceiling on tokens per document. Firestore allows 40k index entries per doc and
|
|
37
|
+
* each array element costs one per index containing the field, so a long body
|
|
38
|
+
* left unbounded is a real limit to hit.
|
|
39
|
+
*/
|
|
40
|
+
const MAX_TOKENS = 600;
|
|
41
|
+
/** Words longer than this contribute whole-word only — no prefix expansion. */
|
|
42
|
+
const LONG_WORD_LIMIT = 24;
|
|
43
|
+
/**
|
|
44
|
+
* Lowercase, strip accents, collapse everything non-alphanumeric to spaces.
|
|
45
|
+
*
|
|
46
|
+
* Accent folding is what makes "Pokemon" find "Pokémon" — a plain lowercase
|
|
47
|
+
* would leave those as different tokens.
|
|
48
|
+
*/
|
|
49
|
+
export function normalizeSearchText(input) {
|
|
50
|
+
return input
|
|
51
|
+
.normalize("NFD")
|
|
52
|
+
.replace(/[̀-ͯ]/g, "")
|
|
53
|
+
.toLowerCase()
|
|
54
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
55
|
+
.trim();
|
|
56
|
+
}
|
|
57
|
+
function words(sources) {
|
|
58
|
+
const text = sources
|
|
59
|
+
.flat()
|
|
60
|
+
.filter((v) => typeof v === "string" && v.length > 0)
|
|
61
|
+
.join(" ");
|
|
62
|
+
const normalized = normalizeSearchText(text);
|
|
63
|
+
return normalized ? normalized.split(" ").filter(Boolean) : [];
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Build the stored token array.
|
|
67
|
+
*
|
|
68
|
+
* @param sources any mix of strings and string arrays; nullish entries ignored
|
|
69
|
+
*/
|
|
70
|
+
export function buildSearchTxt(sources, options = {}) {
|
|
71
|
+
const { prefix = true, maxPrefixLength = MAX_PREFIX_LENGTH, maxTokens = MAX_TOKENS, } = options;
|
|
72
|
+
const tokens = new Set();
|
|
73
|
+
for (const word of words(sources)) {
|
|
74
|
+
tokens.add(word);
|
|
75
|
+
if (!prefix || word.length > LONG_WORD_LIMIT)
|
|
76
|
+
continue;
|
|
77
|
+
// Inclusive: maxPrefixLength=5 must emit the 5-character prefix. When the
|
|
78
|
+
// bound equals the word length the last slice is the whole word, which the
|
|
79
|
+
// Set already holds.
|
|
80
|
+
const upper = Math.min(word.length, maxPrefixLength);
|
|
81
|
+
for (let n = MIN_PREFIX_LENGTH; n <= upper; n++) {
|
|
82
|
+
tokens.add(word.slice(0, n));
|
|
83
|
+
}
|
|
84
|
+
if (tokens.size >= maxTokens)
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
return Array.from(tokens).slice(0, maxTokens);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Tokenize a user's query into candidate clause values, most selective first.
|
|
91
|
+
*
|
|
92
|
+
* Longest term first because only one may become the `array-contains` clause —
|
|
93
|
+
* the longest is the cheapest proxy for "narrows the result set most" without
|
|
94
|
+
* keeping per-term cardinality statistics.
|
|
95
|
+
*
|
|
96
|
+
* Uses the SAME normalizer as the write side; if the two ever diverge, a query
|
|
97
|
+
* silently stops matching what was indexed.
|
|
98
|
+
*/
|
|
99
|
+
export function parseSearchTxtQuery(query) {
|
|
100
|
+
const normalized = normalizeSearchText(query ?? "");
|
|
101
|
+
if (!normalized)
|
|
102
|
+
return [];
|
|
103
|
+
return Array.from(new Set(normalized.split(" ").filter(Boolean)))
|
|
104
|
+
.sort((a, b) => b.length - a.length)
|
|
105
|
+
.slice(0, 10);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Does a document's stored tokens satisfy every term? Used for the AND-refine
|
|
109
|
+
* pass after the single `array-contains` clause has narrowed the candidates.
|
|
110
|
+
*/
|
|
111
|
+
export function matchesAllSearchTerms(searchTxt, terms) {
|
|
112
|
+
if (terms.length === 0)
|
|
113
|
+
return true;
|
|
114
|
+
if (!searchTxt || searchTxt.length === 0)
|
|
115
|
+
return false;
|
|
116
|
+
const set = new Set(searchTxt);
|
|
117
|
+
return terms.every((t) => set.has(t));
|
|
118
|
+
}
|