@mohasinac/appkit 4.27.0 → 4.29.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/features/stores/adapters.d.ts +1 -1
- package/dist/_internal/server/features/stores/adapters.js +5 -0
- 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/AdminContentGuideView.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/blog/repository/blog.repository.d.ts +4 -0
- package/dist/features/blog/repository/blog.repository.js +1 -0
- package/dist/features/blog/schemas/firestore.d.ts +3 -1
- package/dist/features/blog/schemas/firestore.js +1 -0
- 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/events/repository/events.repository.js +1 -0
- package/dist/features/events/schemas/firestore.d.ts +3 -1
- package/dist/features/events/schemas/firestore.js +1 -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/messages/repository/conversations.repository.js +15 -5
- 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 +38 -16
- 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 +29 -1
- package/dist/features/reviews/repository/reviews.repository.js +54 -13
- package/dist/features/reviews/schemas/firestore.d.ts +9 -1
- package/dist/features/reviews/schemas/firestore.js +1 -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/stores/repository/store.repository.js +1 -0
- package/dist/features/stores/schemas/firestore.d.ts +2 -0
- 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/repository/tester-checklist-item.repository.d.ts +0 -1
- package/dist/features/tester/repository/tester-checklist-item.repository.js +6 -15
- package/dist/features/tester/schemas/firestore.d.ts +5 -4
- package/dist/features/tester/schemas/firestore.js +3 -3
- 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 +32 -4
- package/dist/security/pii-schemas.js +38 -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/_helpers/search-txt-wrappers.d.ts +20 -0
- package/dist/seed/_helpers/search-txt-wrappers.js +73 -0
- package/dist/seed/blog-posts-seed-data.d.ts +4 -0
- package/dist/seed/blog-posts-seed-data.js +7 -1
- package/dist/seed/events-seed-data.d.ts +5 -1
- package/dist/seed/events-seed-data.js +7 -1
- 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/reviews-seed-data.js +2 -1
- package/dist/seed/site-settings-seed-data.js +5 -3
- package/dist/seed/stores-seed-data.d.ts +4 -0
- package/dist/seed/stores-seed-data.js +7 -1
- 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,24 @@
|
|
|
1
|
+
import type { EventEntryItem } from "../types";
|
|
2
|
+
/**
|
|
3
|
+
* The scalar rows for one event entry.
|
|
4
|
+
*
|
|
5
|
+
* Extracted 2026-08-26 (W22) so `AdminEventEntriesView`'s detail modal and
|
|
6
|
+
* `/admin/event-entries/[id]/view` render the same thing. The list had these
|
|
7
|
+
* inline; a page that re-listed them would be a second copy, and the first
|
|
8
|
+
* field added to one and not the other is the drift.
|
|
9
|
+
*
|
|
10
|
+
* Same shape as `buildBidDetailFields` in the auctions feature — that one also
|
|
11
|
+
* takes a viewer, because a bid's bidder identity is portal-dependent. An
|
|
12
|
+
* event entry has no such split: only admins and moderators reach this at all,
|
|
13
|
+
* so there is no viewer parameter to add and adding one "for symmetry" would
|
|
14
|
+
* imply a distinction that does not exist.
|
|
15
|
+
*
|
|
16
|
+
* `formResponses` is deliberately NOT a row. It is the survey/feedback
|
|
17
|
+
* submission being judged — open-ended and often long — and belongs in the
|
|
18
|
+
* `metadata` slot, not squeezed into a definition list.
|
|
19
|
+
*/
|
|
20
|
+
export interface EntryFieldRow {
|
|
21
|
+
label: string;
|
|
22
|
+
value: string;
|
|
23
|
+
}
|
|
24
|
+
export declare function buildEventEntryDetailFields(entry: EventEntryItem): EntryFieldRow[];
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function buildEventEntryDetailFields(entry) {
|
|
2
|
+
return [
|
|
3
|
+
{ label: "Entrant", value: entry.userDisplayName ?? entry.userId ?? "—" },
|
|
4
|
+
{ label: "Email", value: entry.userEmail ?? "—" },
|
|
5
|
+
{ label: "Review status", value: entry.reviewStatus },
|
|
6
|
+
{ label: "Points", value: entry.points != null ? String(entry.points) : "—" },
|
|
7
|
+
{ label: "Raffle eligible", value: entry.raffleEligible ? "Yes" : "No" },
|
|
8
|
+
{ label: "Poll votes", value: entry.pollVotes?.join(", ") || "—" },
|
|
9
|
+
{ label: "Reviewed by", value: entry.reviewedBy ?? "—" },
|
|
10
|
+
{ label: "Review note", value: entry.reviewNote ?? "—" },
|
|
11
|
+
{ label: "Entry ID", value: entry.id },
|
|
12
|
+
];
|
|
13
|
+
}
|
|
@@ -166,6 +166,7 @@ class EventRepository extends BaseRepository {
|
|
|
166
166
|
}
|
|
167
167
|
}
|
|
168
168
|
EventRepository.SIEVE_FIELDS = {
|
|
169
|
+
searchTxt: { canFilter: true, canSort: false },
|
|
169
170
|
type: { canFilter: true, canSort: false },
|
|
170
171
|
status: { canFilter: true, canSort: true },
|
|
171
172
|
title: { canFilter: true, canSort: true },
|
|
@@ -9,6 +9,8 @@ import type { LotteryConfig } from "../../lottery/types";
|
|
|
9
9
|
export declare const EVENTS_COLLECTION: "events";
|
|
10
10
|
export declare const EVENT_ENTRIES_COLLECTION: "eventEntries";
|
|
11
11
|
export interface EventDocument extends BaseDocument {
|
|
12
|
+
/** Normalized edge-n-gram search field — see appkit/src/utils/search-txt.ts. */
|
|
13
|
+
searchTxt?: string[];
|
|
12
14
|
slug?: string;
|
|
13
15
|
type: EventType;
|
|
14
16
|
title: string;
|
|
@@ -100,7 +102,7 @@ export type EventEntryDocument = {
|
|
|
100
102
|
guestIpHash?: string;
|
|
101
103
|
submittedAt: Date;
|
|
102
104
|
};
|
|
103
|
-
export declare const EVENT_INDEXED_FIELDS: readonly ["type", "status", "startsAt", "endsAt", "createdAt"];
|
|
105
|
+
export declare const EVENT_INDEXED_FIELDS: readonly ["searchTxt", "type", "status", "startsAt", "endsAt", "createdAt"];
|
|
104
106
|
export declare const EVENT_ENTRY_INDEXED_FIELDS: readonly ["eventId", "userId", "guestIpHash", "reviewStatus", "status", "submittedAt", "points", "spinWonAt"];
|
|
105
107
|
export declare const EVENT_FIELDS: {
|
|
106
108
|
readonly ID: "id";
|
|
@@ -3,7 +3,7 @@ import { Card, Div, Heading, Row, Span, Text, TextLink } from "../../../ui";
|
|
|
3
3
|
const __P = {
|
|
4
4
|
p8: "p-[var(--appkit-space-8)]",
|
|
5
5
|
};
|
|
6
|
-
const CLS_CONTACT_CARD = "flex items-center gap-[var(--appkit-space-3)] rounded-lg border border-
|
|
6
|
+
const CLS_CONTACT_CARD = "flex items-center gap-[var(--appkit-space-3)] rounded-lg border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface-elevated)] px-[var(--appkit-space-4)] py-[var(--appkit-space-3)] hover:border-primary/40 transition-colors";
|
|
7
7
|
const CLS_CONTACT_LABEL = "text-[length:var(--appkit-text-xs)] text-[var(--appkit-color-text-muted)]";
|
|
8
8
|
export function ContactCTA({ email, phone, contactHref, labels, }) {
|
|
9
9
|
return (_jsxs(Card, { variant: "outlined", padding: "lg", className: "text-center", children: [_jsx(Row, { className: "mb-6", justify: "center", children: _jsx(Div, { className: `bg-primary/10 ${__P.p8}`, rounded: "full", children: _jsx("svg", { className: "h-12 w-12 text-primary", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" }) }) }) }), _jsx(Heading, { level: 2, className: "mb-3", size: "2xl", weight: "semibold", children: labels?.title ?? "Need More Help?" }), _jsx(Text, { className: "mx-auto mb-6 max-w-2xl", color: "muted", children: labels?.description ?? "Our support team is here to help you." }), _jsxs(Div, { layout: "grid", gap: "4", className: "mb-8 grid-cols-1 md:grid-cols-3", children: [_jsxs(TextLink, { href: `mailto:${email}`, variant: "none", className: CLS_CONTACT_CARD, children: [_jsx(Text, { className: CLS_CONTACT_LABEL, children: labels?.emailUs ?? "Email Us" }), _jsx(Text, { size: "xs", color: "muted", children: email })] }), _jsxs(TextLink, { href: `tel:${phone}`, variant: "none", className: CLS_CONTACT_CARD, children: [_jsx(Text, { className: CLS_CONTACT_LABEL, children: labels?.callUs ?? "Call Us" }), _jsx(Text, { size: "xs", color: "muted", children: phone })] }), _jsxs(TextLink, { href: contactHref, className: CLS_CONTACT_CARD, children: [_jsx(Text, { className: CLS_CONTACT_LABEL, children: labels?.contactForm ?? "Contact Form" }), _jsx(Text, { size: "xs", color: "muted", children: labels?.submitRequest ?? "Submit a request" })] })] }), _jsxs(TextLink, { rounded: "xl", href: contactHref, layout: "inline-flex", align: "center", gap: "sm", className: "bg-primary p-[1.5rem] text-white transition-colors hover:bg-primary/90", weight: "medium", children: [_jsx(Span, { children: labels?.contactTeam ?? "Contact Team" }), _jsx("svg", { className: "h-5 w-5", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M9 5l7 7-7 7" }) })] })] }));
|
|
@@ -44,9 +44,9 @@ export function FAQHelpfulButtons({ faqId, initialHelpful, initialNotHelpful, on
|
|
|
44
44
|
? "bg-success-solid text-success-on-solid"
|
|
45
45
|
: userVote
|
|
46
46
|
? "cursor-not-allowed bg-zinc-200 text-[var(--appkit-color-text-muted)] opacity-50 dark:bg-slate-600 text-[var(--appkit-color-text-muted)]"
|
|
47
|
-
: "bg-[var(--appkit-color-surface)]
|
|
47
|
+
: "bg-[var(--appkit-color-surface)] hover:bg-success-surface bg-[var(--appkit-color-surface-elevated)] text-[var(--appkit-color-text)]"}`, children: [_jsx(Span, { size: "sm", children: labels?.yes ?? "Yes" }), _jsxs(Span, { size: "sm", color: "muted", children: ["(", helpful, ")"] })] }), _jsxs(Button, { variant: "ghost", onClick: () => handleVote(false), disabled: isPending || userVote !== null, className: `flex-1 gap-[var(--appkit-space-2)] rounded-lg p-[var(--appkit-space-4)] transition-all ${userVote === "not-helpful"
|
|
48
48
|
? "bg-error-solid text-error-on-solid"
|
|
49
49
|
: userVote
|
|
50
50
|
? "cursor-not-allowed bg-zinc-200 text-[var(--appkit-color-text-muted)] opacity-50 dark:bg-slate-600 text-[var(--appkit-color-text-muted)]"
|
|
51
|
-
: "bg-[var(--appkit-color-surface)]
|
|
51
|
+
: "bg-[var(--appkit-color-surface)] hover:bg-error-surface bg-[var(--appkit-color-surface-elevated)] text-[var(--appkit-color-text)]"}`, children: [_jsx(Span, { size: "sm", children: labels?.no ?? "No" }), _jsxs(Span, { size: "sm", color: "muted", children: ["(", notHelpful, ")"] })] })] })] }));
|
|
52
52
|
}
|
|
@@ -15,10 +15,25 @@ export declare class FAQsRepository {
|
|
|
15
15
|
update(id: string, data: Partial<FAQ>): Promise<FAQ>;
|
|
16
16
|
delete(id: string): Promise<void>;
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* FAQ `searchTxt`. Exported so the seed derives byte-identical tokens — the
|
|
20
|
+
* seed writes documents raw via batch.set() and never goes through this
|
|
21
|
+
* repository, which is exactly why all 63 seeded FAQs had no tokens at all.
|
|
22
|
+
*
|
|
23
|
+
* EVERY field is prefix-expanded, including the answer body. An earlier version
|
|
24
|
+
* whole-worded the body to protect the token budget, and a seed test caught what
|
|
25
|
+
* that costs: searching "ship" returned FEWER FAQs than "shipping", because an
|
|
26
|
+
* answer mentioning shipping had only the whole word indexed. Partial match that
|
|
27
|
+
* silently excludes the body is not partial match.
|
|
28
|
+
*
|
|
29
|
+
* Measured over the real seed corpus: max 449 tokens, average 200, nothing near
|
|
30
|
+
* the 600 cap — so nothing is truncated. Re-measure before reusing this shape on
|
|
31
|
+
* a long-form collection (blog posts), where bodies are far larger.
|
|
32
|
+
*/
|
|
33
|
+
export declare function buildFaqSearchTxt(input: Partial<FAQDocument>): string[];
|
|
18
34
|
export declare class FirebaseFAQsRepository extends BaseRepository<FAQDocument> {
|
|
19
35
|
static readonly SIEVE_FIELDS: FirebaseSieveFields;
|
|
20
36
|
constructor();
|
|
21
|
-
private buildSearchTokens;
|
|
22
37
|
create(data: Partial<FAQDocument>): Promise<FAQDocument>;
|
|
23
38
|
update(id: string, data: Partial<FAQDocument>): Promise<FAQDocument>;
|
|
24
39
|
list(model: SieveModel, opts?: {
|
|
@@ -2,6 +2,7 @@ import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../..
|
|
|
2
2
|
import { DatabaseError } from "../../../errors";
|
|
3
3
|
import { increment } from "../../../contracts/field-ops";
|
|
4
4
|
import { FAQ_FIELDS } from "../../../constants/field-names";
|
|
5
|
+
import { buildSearchTxt, matchesAllSearchTerms, parseSearchTxtQuery, } from "../../../utils/search-txt";
|
|
5
6
|
import { FAQS_COLLECTION, createFAQId, slugifyQuestion, } from "../schemas";
|
|
6
7
|
export class FAQsRepository {
|
|
7
8
|
constructor(repo) {
|
|
@@ -51,38 +52,39 @@ export class FAQsRepository {
|
|
|
51
52
|
return this.repo.delete(id);
|
|
52
53
|
}
|
|
53
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* FAQ `searchTxt`. Exported so the seed derives byte-identical tokens — the
|
|
57
|
+
* seed writes documents raw via batch.set() and never goes through this
|
|
58
|
+
* repository, which is exactly why all 63 seeded FAQs had no tokens at all.
|
|
59
|
+
*
|
|
60
|
+
* EVERY field is prefix-expanded, including the answer body. An earlier version
|
|
61
|
+
* whole-worded the body to protect the token budget, and a seed test caught what
|
|
62
|
+
* that costs: searching "ship" returned FEWER FAQs than "shipping", because an
|
|
63
|
+
* answer mentioning shipping had only the whole word indexed. Partial match that
|
|
64
|
+
* silently excludes the body is not partial match.
|
|
65
|
+
*
|
|
66
|
+
* Measured over the real seed corpus: max 449 tokens, average 200, nothing near
|
|
67
|
+
* the 600 cap — so nothing is truncated. Re-measure before reusing this shape on
|
|
68
|
+
* a long-form collection (blog posts), where bodies are far larger.
|
|
69
|
+
*/
|
|
70
|
+
export function buildFaqSearchTxt(input) {
|
|
71
|
+
const answer = typeof input.answer === "string" ? input.answer : (input.answer?.text ?? "");
|
|
72
|
+
return buildSearchTxt([
|
|
73
|
+
input.question,
|
|
74
|
+
input.category,
|
|
75
|
+
...(input.tags ?? []),
|
|
76
|
+
answer.replace(/<[^>]+>/g, " "),
|
|
77
|
+
]);
|
|
78
|
+
}
|
|
54
79
|
export class FirebaseFAQsRepository extends BaseRepository {
|
|
55
80
|
constructor() {
|
|
56
81
|
super(FAQS_COLLECTION);
|
|
57
82
|
}
|
|
58
|
-
buildSearchTokens(input) {
|
|
59
|
-
const rawText = [
|
|
60
|
-
input.question,
|
|
61
|
-
typeof input.answer === "string" ? input.answer : input.answer?.text,
|
|
62
|
-
input.category,
|
|
63
|
-
...(input.tags ?? []),
|
|
64
|
-
]
|
|
65
|
-
.filter(Boolean)
|
|
66
|
-
.join(" ")
|
|
67
|
-
.toLowerCase();
|
|
68
|
-
return Array.from(new Set(rawText
|
|
69
|
-
.split(/[^a-z0-9]+/i)
|
|
70
|
-
.map((token) => token.trim())
|
|
71
|
-
.filter((token) => token.length >= 2))).slice(0, 50);
|
|
72
|
-
}
|
|
73
83
|
async create(data) {
|
|
74
|
-
const
|
|
75
|
-
question: data.question ?? "",
|
|
76
|
-
answer: data.answer ?? {
|
|
77
|
-
text: "",
|
|
78
|
-
format: "plain",
|
|
79
|
-
},
|
|
80
|
-
tags: data.tags ?? [],
|
|
81
|
-
category: (data.category ?? "general"),
|
|
82
|
-
});
|
|
84
|
+
const searchTxt = buildFaqSearchTxt(data);
|
|
83
85
|
return super.create({
|
|
84
86
|
...data,
|
|
85
|
-
|
|
87
|
+
searchTxt,
|
|
86
88
|
});
|
|
87
89
|
}
|
|
88
90
|
async update(id, data) {
|
|
@@ -96,39 +98,52 @@ export class FirebaseFAQsRepository extends BaseRepository {
|
|
|
96
98
|
};
|
|
97
99
|
return super.update(id, {
|
|
98
100
|
...data,
|
|
99
|
-
|
|
100
|
-
question: merged.question,
|
|
101
|
-
answer: merged.answer,
|
|
102
|
-
tags: merged.tags ?? [],
|
|
103
|
-
category: merged.category,
|
|
104
|
-
}),
|
|
101
|
+
searchTxt: buildFaqSearchTxt(merged),
|
|
105
102
|
});
|
|
106
103
|
}
|
|
107
104
|
async list(model, opts) {
|
|
108
105
|
let baseQuery = this.getCollection();
|
|
109
106
|
const tags = opts?.tags?.filter(Boolean) ?? [];
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
107
|
+
// Longest term first — only ONE may become the array-contains clause
|
|
108
|
+
// (Firestore permits a single array operator per query), and the longest is
|
|
109
|
+
// the cheapest proxy for "narrows the most" without cardinality stats.
|
|
110
|
+
const terms = parseSearchTxtQuery(opts?.search ?? "");
|
|
111
|
+
const rawSearch = (opts?.search ?? "").trim();
|
|
112
|
+
// A query that produced no usable term must return NOTHING, not everything.
|
|
113
|
+
// The old guard dropped tokens under 2 characters, so a 1-character search
|
|
114
|
+
// silently fell through to the entire unfiltered list.
|
|
115
|
+
if (rawSearch && terms.length === 0) {
|
|
116
|
+
return { items: [], total: 0, page: 1, pageSize: 0, totalPages: 0, hasMore: false };
|
|
118
117
|
}
|
|
119
|
-
|
|
118
|
+
// Firestore allows one array operator per query, so tags and search cannot
|
|
119
|
+
// both be pushed down. Search takes the clause and tags refine in memory —
|
|
120
|
+
// this used to throw a DatabaseError and fail the request outright.
|
|
121
|
+
const pushTags = terms.length === 0;
|
|
122
|
+
if (pushTags && tags.length === 1) {
|
|
120
123
|
baseQuery = baseQuery.where(FAQ_FIELDS.TAGS, "array-contains", tags[0]);
|
|
121
124
|
}
|
|
122
|
-
else if (tags.length > 1) {
|
|
125
|
+
else if (pushTags && tags.length > 1) {
|
|
123
126
|
baseQuery = baseQuery.where(FAQ_FIELDS.TAGS, "array-contains-any", tags.slice(0, 10));
|
|
124
127
|
}
|
|
125
|
-
if (
|
|
126
|
-
baseQuery = baseQuery.where(FAQ_FIELDS.
|
|
128
|
+
if (terms.length > 0) {
|
|
129
|
+
baseQuery = baseQuery.where(FAQ_FIELDS.SEARCH_TXT, "array-contains", terms[0]);
|
|
127
130
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
131
|
+
const result = await this.sieveQuery(model, FirebaseFAQsRepository.SIEVE_FIELDS, { baseQuery });
|
|
132
|
+
// Refine the remaining terms (AND) and any tags that could not be pushed
|
|
133
|
+
// down. `array-contains-any` would have been OR — "shipping cost" returning
|
|
134
|
+
// everything matching either word is why search felt like it did nothing.
|
|
135
|
+
const extraTerms = terms.slice(1);
|
|
136
|
+
if (extraTerms.length === 0 && (pushTags || tags.length === 0))
|
|
137
|
+
return result;
|
|
138
|
+
const items = result.items.filter((faq) => matchesAllSearchTerms(faq.searchTxt, extraTerms) &&
|
|
139
|
+
(pushTags || tags.length === 0 || tags.some((t) => faq.tags?.includes(t))));
|
|
140
|
+
return {
|
|
141
|
+
...result,
|
|
142
|
+
items,
|
|
143
|
+
total: items.length,
|
|
144
|
+
totalPages: items.length === 0 ? 0 : 1,
|
|
145
|
+
hasMore: false,
|
|
146
|
+
};
|
|
132
147
|
}
|
|
133
148
|
async getFAQBySlug(slug) {
|
|
134
149
|
const snapshot = await this.db
|
|
@@ -248,12 +263,7 @@ export class FirebaseFAQsRepository extends BaseRepository {
|
|
|
248
263
|
...input.seo,
|
|
249
264
|
slug,
|
|
250
265
|
},
|
|
251
|
-
|
|
252
|
-
question: input.question,
|
|
253
|
-
answer: input.answer,
|
|
254
|
-
tags: input.tags ?? [],
|
|
255
|
-
category: input.category,
|
|
256
|
-
}),
|
|
266
|
+
searchTxt: buildFaqSearchTxt(input),
|
|
257
267
|
stats: {
|
|
258
268
|
views: 0,
|
|
259
269
|
helpful: 0,
|
|
@@ -293,7 +303,7 @@ FirebaseFAQsRepository.SIEVE_FIELDS = {
|
|
|
293
303
|
order: { canFilter: true, canSort: true },
|
|
294
304
|
priority: { canFilter: true, canSort: true },
|
|
295
305
|
tags: { canFilter: true, canSort: false },
|
|
296
|
-
|
|
306
|
+
searchTxt: { canFilter: true, canSort: false },
|
|
297
307
|
"stats.helpful": { canFilter: false, canSort: true },
|
|
298
308
|
createdAt: { canFilter: true, canSort: true, parseValue: parseSieveDateValue },
|
|
299
309
|
};
|
|
@@ -19,7 +19,8 @@ export interface FAQDocument extends BaseDocument {
|
|
|
19
19
|
order: number;
|
|
20
20
|
priority: number;
|
|
21
21
|
tags: string[];
|
|
22
|
-
searchTokens
|
|
22
|
+
/** Normalized edge-n-gram search field. Replaced `searchTokens`. */
|
|
23
|
+
searchTxt?: string[];
|
|
23
24
|
relatedFAQs: string[];
|
|
24
25
|
useSiteSettings: boolean;
|
|
25
26
|
variables?: Record<string, string>;
|
|
@@ -31,7 +32,7 @@ export interface FAQDocument extends BaseDocument {
|
|
|
31
32
|
createdBy: string;
|
|
32
33
|
}
|
|
33
34
|
export declare const FAQS_COLLECTION: "faqs";
|
|
34
|
-
export declare const FAQS_INDEXED_FIELDS: readonly ["slug", "seo.slug", "category", "order", "showOnHomepage", "showInFooter", "isPinned", "priority", "tags", "
|
|
35
|
+
export declare const FAQS_INDEXED_FIELDS: readonly ["slug", "seo.slug", "category", "order", "showOnHomepage", "showInFooter", "isPinned", "priority", "tags", "searchTxt", "isActive", "stats.helpful", "createdBy", "createdAt"];
|
|
35
36
|
export declare const FAQ_CATEGORY_LABELS: Record<FAQCategory, string>;
|
|
36
37
|
export declare const DEFAULT_FAQ_DATA: Partial<FAQDocument>;
|
|
37
38
|
export declare const FAQS_PUBLIC_FIELDS: readonly ["id", "question", "answer", "category", "showOnHomepage", "showInFooter", "isPinned", "order", "priority", "tags", "relatedFAQs", "stats.views", "stats.helpful", "stats.notHelpful", "seo.slug", "seo.metaTitle", "seo.metaDescription", "isActive", "createdAt"];
|
|
@@ -13,7 +13,7 @@ export const FAQS_INDEXED_FIELDS = [
|
|
|
13
13
|
"isPinned",
|
|
14
14
|
"priority",
|
|
15
15
|
"tags",
|
|
16
|
-
"
|
|
16
|
+
"searchTxt",
|
|
17
17
|
"isActive",
|
|
18
18
|
"stats.helpful",
|
|
19
19
|
"createdBy",
|
|
@@ -36,7 +36,7 @@ export const DEFAULT_FAQ_DATA = {
|
|
|
36
36
|
order: 0,
|
|
37
37
|
priority: 0,
|
|
38
38
|
tags: [],
|
|
39
|
-
|
|
39
|
+
searchTxt: [],
|
|
40
40
|
relatedFAQs: [],
|
|
41
41
|
useSiteSettings: true,
|
|
42
42
|
variables: {},
|
|
@@ -228,7 +228,7 @@ export declare const faqFirestoreSchema: z.ZodObject<{
|
|
|
228
228
|
order: z.ZodNumber;
|
|
229
229
|
priority: z.ZodNumber;
|
|
230
230
|
tags: z.ZodArray<z.ZodString, "many">;
|
|
231
|
-
|
|
231
|
+
searchTxt: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
232
232
|
relatedFAQs: z.ZodArray<z.ZodString, "many">;
|
|
233
233
|
useSiteSettings: z.ZodBoolean;
|
|
234
234
|
variables: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
@@ -310,7 +310,7 @@ export declare const faqFirestoreSchema: z.ZodObject<{
|
|
|
310
310
|
metaTitle?: string | undefined;
|
|
311
311
|
metaDescription?: string | undefined;
|
|
312
312
|
};
|
|
313
|
-
|
|
313
|
+
searchTxt?: string[] | undefined;
|
|
314
314
|
variables?: Record<string, string> | undefined;
|
|
315
315
|
}, {
|
|
316
316
|
id: string;
|
|
@@ -349,7 +349,7 @@ export declare const faqFirestoreSchema: z.ZodObject<{
|
|
|
349
349
|
metaTitle?: string | undefined;
|
|
350
350
|
metaDescription?: string | undefined;
|
|
351
351
|
};
|
|
352
|
-
|
|
352
|
+
searchTxt?: string[] | undefined;
|
|
353
353
|
variables?: Record<string, string> | undefined;
|
|
354
354
|
}>;
|
|
355
355
|
export type FAQFromSchema = z.infer<typeof faqFirestoreSchema>;
|
|
@@ -85,7 +85,7 @@ export const faqFirestoreSchema = z.object({
|
|
|
85
85
|
order: z.number().int(),
|
|
86
86
|
priority: z.number().int(),
|
|
87
87
|
tags: z.array(z.string()),
|
|
88
|
-
|
|
88
|
+
searchTxt: z.array(z.string()).optional(),
|
|
89
89
|
relatedFAQs: z.array(z.string()),
|
|
90
90
|
useSiteSettings: z.boolean(),
|
|
91
91
|
variables: z.record(z.string(), z.string()).optional(),
|
|
@@ -77,9 +77,9 @@ export function FilterFacetSection({ title, options, selected, onChange, selecti
|
|
|
77
77
|
if (displayAs === "dropdown") {
|
|
78
78
|
return (_jsxs(Div, { ref: dropdownRef, className: cn("relative inline-block", className), children: [_jsxs(Button, { type: "button", onClick: () => setDropdownOpen((o) => !o), variant: "ghost", className: cn("inline-flex items-center gap-[var(--appkit-space-1-5)] rounded-full border px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-sm)] font-medium transition-colors", hasValue
|
|
79
79
|
? CLS_OPTION_SELECTED
|
|
80
|
-
: "
|
|
80
|
+
: " bg-[var(--appkit-color-surface)] hover:border-zinc-400 border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] text-[var(--appkit-color-text-muted)] hover:border-[var(--appkit-color-border)]"), children: [title, hasValue && (_jsx(Span, { weight: "bold", className: CLS_OPTION_DOT, children: selected.length })), _jsx("svg", { className: cn("h-3.5 w-3.5 text-zinc-400 transition-transform", dropdownOpen ? "rotate-180" : ""), fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 2, "aria-hidden": "true", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M19 9l-7 7-7-7" }) })] }), dropdownOpen && (_jsxs(Div, { className: "absolute left-0 top-full z-[var(--appkit-z-dropdown,50)] mt-1 min-w-[180px] max-w-[260px]", surface: "default", rounded: "xl", shadow: "lg", border: "default", children: [searchable && (_jsx(Div, { border: "subtle", className: "border-b", padding: "xs", children: _jsx(Input, { type: "search", placeholder: "Search\u2026", value: search, onChange: (e) => handleSearch(e.target.value), className: "w-full rounded-md border px-[var(--appkit-space-2)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] focus:outline-none border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface-input)] text-[var(--appkit-color-text)]" }) })), _jsxs(Div, { className: `max-h-56 ${__O.yAuto}`, padding: "y-2xs", children: [filtered.map((option) => {
|
|
81
81
|
const isSelected = selected.includes(option.value);
|
|
82
|
-
return (_jsxs(Row, { gap: "xs", className: "cursor-pointer py-[0.375rem] hover:bg-[var(--appkit-color-bg)] mx-1", padding: "x-sm", rounded: "lg", onClick: () => toggle(option.value), children: [_jsx(Checkbox, { bare: true, type: selectionMode === "single" ? "radio" : "checkbox", checked: isSelected, onChange: () => toggle(option.value), "aria-label": option.label, className: "h-3.5 w-3.5 flex-shrink-0 cursor-pointer rounded
|
|
82
|
+
return (_jsxs(Row, { gap: "xs", className: "cursor-pointer py-[0.375rem] hover:bg-[var(--appkit-color-bg)] mx-1", padding: "x-sm", rounded: "lg", onClick: () => toggle(option.value), children: [_jsx(Checkbox, { bare: true, type: selectionMode === "single" ? "radio" : "checkbox", checked: isSelected, onChange: () => toggle(option.value), "aria-label": option.label, className: "h-3.5 w-3.5 flex-shrink-0 cursor-pointer rounded text-primary-600 border-[var(--appkit-color-border)] dark:text-secondary-500" }), _jsx(Span, { size: "xs", className: "flex-1 truncate", color: "muted", children: option.label }), option.count !== undefined && (_jsx(Span, { className: "flex-shrink-0 text-[10px] tabular-nums", color: "faint", children: option.count }))] }, option.value));
|
|
83
83
|
}), filtered.length === 0 && (_jsx(Text, { paddingY: "xs", paddingX: "x-sm", color: "faint", size: "xs", children: "No results" }))] }), hasValue && (_jsx(Div, { border: "subtle", className: "border-t p-[0.25rem]", children: _jsx(Button, { type: "button", variant: "ghost", size: "sm", className: CLS_CLEAR_BTN, onClick: () => { onChange([]); onClear?.(); setDropdownOpen(false); }, children: "Clear" }) }))] }))] }));
|
|
84
84
|
}
|
|
85
85
|
// ── Accordion variant (default) ─────────────────────────────────────────
|
|
@@ -45,7 +45,7 @@ export function Toggle({ checked: controlledChecked, defaultChecked = false, onC
|
|
|
45
45
|
const cfg = SIZE_CONFIG[size];
|
|
46
46
|
return (_jsxs(Row, { className: cn("gap-3", className), children: [_jsx(Button, { type: "button", role: "switch", id: toggleId, "aria-checked": checked, disabled: disabled, onClick: handleChange, className: cn("relative inline-flex items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-primary-500/30 dark:focus:ring-secondary-400/30 focus:ring-offset-2 dark:focus:ring-offset-slate-900", cfg.track, checked
|
|
47
47
|
? "bg-primary-600 dark:bg-secondary-500"
|
|
48
|
-
: "
|
|
48
|
+
: " bg-[var(--appkit-color-border)]", disabled && "opacity-50 cursor-not-allowed"), children: _jsx(Span, { className: cn("inline-block rounded-full bg-[var(--appkit-color-surface)] shadow-sm transition-transform duration-200", cfg.thumb, checked ? cfg.translateOn : cfg.translateOff) }) }), label && (_jsx("label", { htmlFor: toggleId, className: cn("text-sm font-medium cursor-pointer select-none", checked
|
|
49
49
|
? "text-[var(--appkit-color-text)]"
|
|
50
50
|
: "text-[var(--appkit-color-text-muted)]", disabled && "opacity-50 cursor-not-allowed"), children: label }))] }));
|
|
51
51
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
type Scope = "admin" | "store";
|
|
3
|
+
export interface GroupedListingEditorViewProps {
|
|
4
|
+
scope: Scope;
|
|
5
|
+
/** Present → edit that group; absent → create a new one. */
|
|
6
|
+
groupId?: string;
|
|
7
|
+
/**
|
|
8
|
+
* Store the group belongs to. Required for admin CREATE (an admin owns no
|
|
9
|
+
* store, so there is nothing to derive it from); ignored on the seller side,
|
|
10
|
+
* where the route reads it from the session and a body value would let a
|
|
11
|
+
* seller file a group under someone else's store.
|
|
12
|
+
*/
|
|
13
|
+
fixedStoreId?: string;
|
|
14
|
+
onSaved?: (id: string) => void;
|
|
15
|
+
onCancel?: () => void;
|
|
16
|
+
}
|
|
17
|
+
export declare function GroupedListingEditorView({ scope, groupId, fixedStoreId, onSaved, onCancel, }: GroupedListingEditorViewProps): React.JSX.Element;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
/*
|
|
4
|
+
* WHY: There were TWO hand-rolled grouped-listing forms (the seller's `new` and
|
|
5
|
+
* `[id]/edit` pages), between them covering 5 of the entity's 8 fields —
|
|
6
|
+
* `productIds` was hardcoded to `[]` on create, and `minActiveMembers` and
|
|
7
|
+
* `coverImage` could not be set anywhere at all. Admin had no form.
|
|
8
|
+
* WHAT: One editor for both portals, deriving its sections from
|
|
9
|
+
* `groupedListingFormSchema` so a field added to the schema appears here
|
|
10
|
+
* without a second edit.
|
|
11
|
+
*
|
|
12
|
+
* ## The admin API was the blocker, not this component
|
|
13
|
+
*
|
|
14
|
+
* Pointing an editor at the admin endpoints used to give: create → 405 (there
|
|
15
|
+
* was no admin POST), and every save of `title`/`groupTheme`/`isActive` → a
|
|
16
|
+
* 200 that wrote nothing, because the admin PATCH schema was
|
|
17
|
+
* `z.object({ productIds })` and `z.object()` strips unknown keys. That is
|
|
18
|
+
* Root Cause #40's exact shape — a success response is what the broken version
|
|
19
|
+
* returns, so only a reload reveals it. Both were fixed alongside this file;
|
|
20
|
+
* an `endpointOverride` on the seller form could never have worked.
|
|
21
|
+
*
|
|
22
|
+
* ## Two fields are overridden, the other six are derived
|
|
23
|
+
*
|
|
24
|
+
* `productIds` needs the real product search (`ProductInlineSelect`), and admin
|
|
25
|
+
* create needs a store picker — a grouped listing is store-owned and the seller
|
|
26
|
+
* route takes `storeId` from the session, which an admin has no equivalent of.
|
|
27
|
+
* Everything else comes from `buildSectionsFromSchema`.
|
|
28
|
+
*
|
|
29
|
+
* EXPORTS: GroupedListingEditorView, type GroupedListingEditorViewProps
|
|
30
|
+
*
|
|
31
|
+
* @tag domain:grouped
|
|
32
|
+
* @tag layer:feature
|
|
33
|
+
* @tag pattern:slot-shell
|
|
34
|
+
* @tag access:client
|
|
35
|
+
* @tag consumers:admin/grouped-listings pages,store/grouped-listings pages
|
|
36
|
+
* @tag sideEffects:network
|
|
37
|
+
*/
|
|
38
|
+
import React from "react";
|
|
39
|
+
import { useQuery } from "@tanstack/react-query";
|
|
40
|
+
import { useRouter } from "next/navigation";
|
|
41
|
+
import { Stack, Text, useToast } from "../../../ui";
|
|
42
|
+
import { FieldSelect, FormShellContext, FormErrorSummary, applyZodIssues, useFormShellState, } from "../../../ui/forms";
|
|
43
|
+
import { SectionForm, useSectionFormNav } from "../../shell";
|
|
44
|
+
import { buildSectionsFromSchema } from "../../shell/build-sections";
|
|
45
|
+
import { apiClient } from "../../../http";
|
|
46
|
+
import { ADMIN_ENDPOINTS, SELLER_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
47
|
+
import { ROUTES } from "../../../next/routing/route-map";
|
|
48
|
+
import { normalizeError } from "../../../errors/normalize";
|
|
49
|
+
import { ProductInlineSelect } from "../../seller/components/ProductInlineSelect";
|
|
50
|
+
import { groupedListingFormSchema, } from "../schemas/grouped-listing-form";
|
|
51
|
+
const EMPTY = {
|
|
52
|
+
title: "",
|
|
53
|
+
description: "",
|
|
54
|
+
groupTheme: "generic",
|
|
55
|
+
productIds: [],
|
|
56
|
+
minActiveMembers: 1,
|
|
57
|
+
coverImage: "",
|
|
58
|
+
isActive: true,
|
|
59
|
+
isFeatured: false,
|
|
60
|
+
};
|
|
61
|
+
export function GroupedListingEditorView({ scope, groupId, fixedStoreId, onSaved, onCancel, }) {
|
|
62
|
+
const router = useRouter();
|
|
63
|
+
const { showToast } = useToast();
|
|
64
|
+
const isEdit = Boolean(groupId);
|
|
65
|
+
const isAdmin = scope === "admin";
|
|
66
|
+
const listEndpoint = isAdmin ? ADMIN_ENDPOINTS.GROUPED_LISTINGS : SELLER_ENDPOINTS.GROUPED_LISTINGS;
|
|
67
|
+
const byIdEndpoint = isAdmin
|
|
68
|
+
? ADMIN_ENDPOINTS.GROUPED_LISTING_BY_ID
|
|
69
|
+
: SELLER_ENDPOINTS.GROUPED_LISTING_BY_ID;
|
|
70
|
+
const [values, setValues] = React.useState(EMPTY);
|
|
71
|
+
const [storeId, setStoreId] = React.useState(fixedStoreId ?? "");
|
|
72
|
+
const [saving, setSaving] = React.useState(false);
|
|
73
|
+
/*
|
|
74
|
+
* Seeded from the single-item GET, never from a cached list row — a list
|
|
75
|
+
* projection is narrower than the document, and an editor seeded from one
|
|
76
|
+
* re-sends the fields it never received as their defaults (Root Cause #38).
|
|
77
|
+
*/
|
|
78
|
+
const existing = useQuery({
|
|
79
|
+
queryKey: [scope, "grouped-listing", groupId],
|
|
80
|
+
queryFn: async () => {
|
|
81
|
+
const res = await apiClient.get(byIdEndpoint(groupId));
|
|
82
|
+
const body = res?.data ?? res;
|
|
83
|
+
// The admin GET wraps in `{ item }`; the seller GET returns the doc.
|
|
84
|
+
return (body?.item ?? body);
|
|
85
|
+
},
|
|
86
|
+
enabled: isEdit,
|
|
87
|
+
});
|
|
88
|
+
// Admin create only: an admin has no session store to derive the owner from.
|
|
89
|
+
const stores = useQuery({
|
|
90
|
+
queryKey: ["admin", "grouped-listing-editor", "stores"],
|
|
91
|
+
queryFn: async () => {
|
|
92
|
+
const res = await apiClient.get(`${ADMIN_ENDPOINTS.STORES}?pageSize=200&sorts=storeName`);
|
|
93
|
+
const body = res?.data ?? res;
|
|
94
|
+
return (body?.items ?? []);
|
|
95
|
+
},
|
|
96
|
+
enabled: isAdmin && !isEdit && !fixedStoreId,
|
|
97
|
+
});
|
|
98
|
+
React.useEffect(() => {
|
|
99
|
+
const doc = existing.data;
|
|
100
|
+
if (!doc)
|
|
101
|
+
return;
|
|
102
|
+
setValues({
|
|
103
|
+
title: String(doc.title ?? ""),
|
|
104
|
+
description: String(doc.description ?? ""),
|
|
105
|
+
groupTheme: doc.groupTheme ?? "generic",
|
|
106
|
+
productIds: Array.isArray(doc.productIds) ? doc.productIds.map(String) : [],
|
|
107
|
+
minActiveMembers: Number(doc.minActiveMembers ?? 1),
|
|
108
|
+
coverImage: String(doc.coverImage ?? ""),
|
|
109
|
+
isActive: doc.isActive !== false,
|
|
110
|
+
isFeatured: doc.isFeatured === true,
|
|
111
|
+
});
|
|
112
|
+
if (doc.storeId)
|
|
113
|
+
setStoreId(String(doc.storeId));
|
|
114
|
+
}, [existing.data]);
|
|
115
|
+
const update = React.useCallback((partial) => {
|
|
116
|
+
setValues((prev) => ({ ...prev, ...partial }));
|
|
117
|
+
}, []);
|
|
118
|
+
const needsStorePicker = isAdmin && !isEdit && !fixedStoreId;
|
|
119
|
+
const sections = React.useMemo(() => buildSectionsFromSchema(groupedListingFormSchema, {
|
|
120
|
+
renderers: {
|
|
121
|
+
productIds: ({ values: v, onChange }) => (_jsxs(Stack, { gap: "xs", children: [_jsx(ProductInlineSelect, { scope: scope, multiple: true, value: v.productIds ?? [], onChange: (ids) => onChange({ productIds: ids }), placeholder: "Search products to add\u2026" }), _jsxs(Text, { size: "xs", color: "muted", children: [(v.productIds ?? []).length, " product", (v.productIds ?? []).length === 1 ? "" : "s", " in this group."] })] })),
|
|
122
|
+
},
|
|
123
|
+
}), [scope]);
|
|
124
|
+
/*
|
|
125
|
+
* The nav seam — without it `FormErrorSummary`'s "jump to section" resolves
|
|
126
|
+
* to undefined, which is what it did for every form before W0.
|
|
127
|
+
*/
|
|
128
|
+
const { openIds, setOpenIds, goToSection, fieldToSectionIndex, sectionMeta } = useSectionFormNav(sections, values);
|
|
129
|
+
const { shellCtx, setFieldError, validate } = useFormShellState(groupedListingFormSchema, {
|
|
130
|
+
sections: sectionMeta,
|
|
131
|
+
onGoToSection: goToSection,
|
|
132
|
+
fieldToSectionIndex,
|
|
133
|
+
});
|
|
134
|
+
React.useEffect(() => {
|
|
135
|
+
validate(values);
|
|
136
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
137
|
+
}, [values, validate]);
|
|
138
|
+
const handleSubmit = async () => {
|
|
139
|
+
const parsed = groupedListingFormSchema.safeParse(values);
|
|
140
|
+
if (!parsed.success) {
|
|
141
|
+
applyZodIssues(parsed.error.issues, setFieldError);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (needsStorePicker && !storeId) {
|
|
145
|
+
showToast("Pick the store this group belongs to.", "error");
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
setSaving(true);
|
|
149
|
+
try {
|
|
150
|
+
if (isEdit) {
|
|
151
|
+
await apiClient.patch(byIdEndpoint(groupId), parsed.data);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
/*
|
|
155
|
+
* `storeId` is sent ONLY by admin. The seller route derives it from the
|
|
156
|
+
* session and rejects it in the body, which is what stops a seller
|
|
157
|
+
* filing a group under another store.
|
|
158
|
+
*/
|
|
159
|
+
const payload = isAdmin ? { ...parsed.data, storeId } : parsed.data;
|
|
160
|
+
const res = await apiClient.post(listEndpoint, payload);
|
|
161
|
+
const created = res?.data?.id;
|
|
162
|
+
if (created && onSaved) {
|
|
163
|
+
showToast("Group created.", "success");
|
|
164
|
+
onSaved(created);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
showToast(isEdit ? "Group updated." : "Group created.", "success");
|
|
169
|
+
if (onSaved)
|
|
170
|
+
onSaved(groupId ?? "");
|
|
171
|
+
else
|
|
172
|
+
router.push(String(isAdmin ? ROUTES.ADMIN.GROUPED_LISTINGS : ROUTES.STORE.GROUPED_LISTINGS));
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
const e = normalizeError(err);
|
|
176
|
+
showToast(e.message || "Failed to save the group.", "error");
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
setSaving(false);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
if (isEdit && existing.isLoading) {
|
|
183
|
+
return _jsx(Text, { color: "muted", children: "Loading\u2026" });
|
|
184
|
+
}
|
|
185
|
+
return (_jsxs(Stack, { gap: "md", children: [needsStorePicker ? (_jsx(FieldSelect, { name: "storeId", label: "Store", required: true, value: storeId, onChange: setStoreId, options: [
|
|
186
|
+
{ value: "", label: "Select a store…" },
|
|
187
|
+
...(stores.data ?? []).map((s) => ({
|
|
188
|
+
value: s.id,
|
|
189
|
+
label: s.storeName ?? s.id,
|
|
190
|
+
})),
|
|
191
|
+
] })) : null, _jsxs(FormShellContext.Provider, { value: shellCtx, children: [_jsx(FormErrorSummary, {}), _jsx(SectionForm, { sections: sections, values: values, onChange: update, onSubmit: handleSubmit, schema: groupedListingFormSchema, openIds: openIds, onOpenChange: setOpenIds, submitLabel: isEdit ? "Save changes" : "Create group", isLoading: saving, onCancel: onCancel })] })] }));
|
|
192
|
+
}
|