@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
|
@@ -32,6 +32,8 @@ export declare const ACTION_ID: {
|
|
|
32
32
|
readonly CANCEL_RESERVATION: "cancel-reservation";
|
|
33
33
|
readonly CHECKOUT: "checkout";
|
|
34
34
|
readonly CONTINUE_TO_VERIFY: "continue-to-verification";
|
|
35
|
+
readonly CONTINUE_TO_PAYMENT: "continue-to-payment";
|
|
36
|
+
readonly CHECKOUT_BACK: "checkout-back";
|
|
35
37
|
readonly VERIFY_OTP: "verify-otp";
|
|
36
38
|
readonly PAY_ONLINE: "pay-online";
|
|
37
39
|
readonly PAY_COD: "pay-cod";
|
|
@@ -74,10 +76,25 @@ export interface ActionMeta {
|
|
|
74
76
|
/** Message shown in LoginRequiredModal. Required when requiresAuth=true. */
|
|
75
77
|
authMessage?: string;
|
|
76
78
|
/**
|
|
77
|
-
* RBAC permission key required to use this action
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
79
|
+
* RBAC permission key required to use this action, checked by `useAuthGate`
|
|
80
|
+
* against `SessionUser.permissions`.
|
|
81
|
+
*
|
|
82
|
+
* 🛑 Two preconditions, and BOTH must hold or the action is a guaranteed
|
|
83
|
+
* denial that looks like a working permission check:
|
|
84
|
+
*
|
|
85
|
+
* 1. The value must be a **colon**-separated key that actually exists in
|
|
86
|
+
* `DEFAULT_ROLES` (`"admin:blog:read"`), not dot-separated. Three entries
|
|
87
|
+
* here carried dot-form keys (`"seller.payouts.request"`,
|
|
88
|
+
* `"seller.reviews.respond"`, `"user.account.delete"`) that match no role;
|
|
89
|
+
* they were removed 2026-08-27. Same family as the 182 guaranteed-403
|
|
90
|
+
* `permission:` lines deleted 2026-08-19.
|
|
91
|
+
* 2. `SessionUser.permissions` must be populated for the acting user — today
|
|
92
|
+
* that happens only on the employee path
|
|
93
|
+
* (`_internal/server/features/auth/permissions.ts`). Neither
|
|
94
|
+
* `buildSessionUser` nor `fetchUserProfileFromServer` sets it, so for an
|
|
95
|
+
* ordinary buyer/seller this field denies unconditionally.
|
|
96
|
+
*
|
|
97
|
+
* Leave it unset unless you have verified both.
|
|
81
98
|
*/
|
|
82
99
|
requiredPermission?: string;
|
|
83
100
|
/**
|
|
@@ -53,6 +53,8 @@ export const ACTION_ID = {
|
|
|
53
53
|
// ── Checkout / navigation CTAs ───────────────────────────────────────────
|
|
54
54
|
CHECKOUT: "checkout",
|
|
55
55
|
CONTINUE_TO_VERIFY: "continue-to-verification",
|
|
56
|
+
CONTINUE_TO_PAYMENT: "continue-to-payment",
|
|
57
|
+
CHECKOUT_BACK: "checkout-back",
|
|
56
58
|
VERIFY_OTP: "verify-otp",
|
|
57
59
|
PAY_ONLINE: "pay-online",
|
|
58
60
|
PAY_COD: "pay-cod",
|
|
@@ -98,6 +100,8 @@ export const ACTION_META = {
|
|
|
98
100
|
// ── Checkout / navigation CTAs ────────────────────────────────────────────
|
|
99
101
|
[ACTION_ID.CHECKOUT]: { id: ACTION_ID.CHECKOUT, label: regLabel("CART", "checkout", "Checkout"), variant: "primary", requiresAuth: true, authMessage: "You need to be signed in to checkout." },
|
|
100
102
|
[ACTION_ID.CONTINUE_TO_VERIFY]: { id: ACTION_ID.CONTINUE_TO_VERIFY, label: regLabel("CHECKOUT", "continue-to-verification", "Continue"), variant: "primary", requiresAuth: true, authMessage: "You need to be signed in to checkout." },
|
|
103
|
+
[ACTION_ID.CONTINUE_TO_PAYMENT]: { id: ACTION_ID.CONTINUE_TO_PAYMENT, label: regLabel("CHECKOUT", "continue-to-payment", "Continue to payment"), variant: "primary", requiresAuth: true, authMessage: "You need to be signed in to checkout." },
|
|
104
|
+
[ACTION_ID.CHECKOUT_BACK]: { id: ACTION_ID.CHECKOUT_BACK, label: regLabel("CHECKOUT", "back", "Back"), variant: "ghost" },
|
|
101
105
|
[ACTION_ID.VERIFY_OTP]: { id: ACTION_ID.VERIFY_OTP, label: regLabel("CHECKOUT", "verify-otp", "Verify & Continue"), variant: "primary", requiresAuth: true, authMessage: "You need to be signed in to checkout." },
|
|
102
106
|
[ACTION_ID.PAY_ONLINE]: { id: ACTION_ID.PAY_ONLINE, label: regLabel("CHECKOUT", "pay-online", "Pay Online"), variant: "primary", requiresAuth: true, authMessage: "You need to be signed in to checkout." },
|
|
103
107
|
[ACTION_ID.PAY_COD]: { id: ACTION_ID.PAY_COD, label: regLabel("CHECKOUT", "pay-cod", "Cash on Delivery"), variant: "secondary", requiresAuth: true, authMessage: "You need to be signed in to checkout." },
|
|
@@ -113,8 +117,8 @@ export const ACTION_META = {
|
|
|
113
117
|
[ACTION_ID.MESSAGE_SELLER]: { id: ACTION_ID.MESSAGE_SELLER, label: "Message Seller", variant: "outline", requiresAuth: true, authMessage: "You need to be signed in to message a seller." },
|
|
114
118
|
// ── Seller CTAs ───────────────────────────────────────────────────────────
|
|
115
119
|
[ACTION_ID.BECOME_SELLER]: { id: ACTION_ID.BECOME_SELLER, label: "Apply as Seller", variant: "primary", requiresAuth: true, authMessage: "You need to be signed in to apply as a seller." },
|
|
116
|
-
[ACTION_ID.REQUEST_PAYOUT]: { id: ACTION_ID.REQUEST_PAYOUT, label: regLabel("STORE", "request-payout", "Request Payout"), variant: "outline", requiresAuth: true, authMessage: "You need to be signed in to request a payout."
|
|
117
|
-
[ACTION_ID.RESPOND_TO_REVIEW]: { id: ACTION_ID.RESPOND_TO_REVIEW, label: regLabel("STORE", "reply-review", "Respond to Review"), variant: "outline", requiresAuth: true, authMessage: "You need to be signed in to respond to a review."
|
|
120
|
+
[ACTION_ID.REQUEST_PAYOUT]: { id: ACTION_ID.REQUEST_PAYOUT, label: regLabel("STORE", "request-payout", "Request Payout"), variant: "outline", requiresAuth: true, authMessage: "You need to be signed in to request a payout." },
|
|
121
|
+
[ACTION_ID.RESPOND_TO_REVIEW]: { id: ACTION_ID.RESPOND_TO_REVIEW, label: regLabel("STORE", "reply-review", "Respond to Review"), variant: "outline", requiresAuth: true, authMessage: "You need to be signed in to respond to a review." },
|
|
118
122
|
// ── User account actions ──────────────────────────────────────────────────
|
|
119
123
|
[ACTION_ID.CANCEL_ORDER]: { id: ACTION_ID.CANCEL_ORDER, label: regLabel("USER", "cancel-order", "Cancel Order"), variant: "danger", requiresAuth: true, authMessage: "You need to be signed in to cancel an order." },
|
|
120
124
|
[ACTION_ID.REQUEST_RETURN]: { id: ACTION_ID.REQUEST_RETURN, label: regLabel("USER", "request-return", "Request Return"), variant: "outline", requiresAuth: true, authMessage: "You need to be signed in to request a return." },
|
|
@@ -125,7 +129,7 @@ export const ACTION_META = {
|
|
|
125
129
|
[ACTION_ID.EDIT_ADDRESS]: { id: ACTION_ID.EDIT_ADDRESS, label: "Edit Address", variant: "ghost", requiresAuth: true, authMessage: "You need to be signed in to edit an address." },
|
|
126
130
|
[ACTION_ID.DELETE_ADDRESS]: { id: ACTION_ID.DELETE_ADDRESS, label: regLabel("USER", "delete-address", "Delete Address"), variant: "danger", requiresAuth: true, authMessage: "You need to be signed in to delete an address." },
|
|
127
131
|
[ACTION_ID.CHANGE_PASSWORD]: { id: ACTION_ID.CHANGE_PASSWORD, label: regLabel("USER", "update-password", "Change Password"), variant: "outline", requiresAuth: true, authMessage: "You need to be signed in to change your password." },
|
|
128
|
-
[ACTION_ID.DELETE_ACCOUNT]: { id: ACTION_ID.DELETE_ACCOUNT, label: "Delete Account", variant: "danger", requiresAuth: true, authMessage: "You need to be signed in to delete your account."
|
|
132
|
+
[ACTION_ID.DELETE_ACCOUNT]: { id: ACTION_ID.DELETE_ACCOUNT, label: "Delete Account", variant: "danger", requiresAuth: true, authMessage: "You need to be signed in to delete your account." },
|
|
129
133
|
};
|
|
130
134
|
// Detail page action groups — ordered top-to-bottom in the right-hand panel
|
|
131
135
|
export const DETAIL_ACTIONS = {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { sieveAnd, sieveFilter, SIEVE_OP
|
|
1
|
+
import { sieveAnd, sieveFilter, SIEVE_OP } from "../../../utils/sieve-builder";
|
|
2
|
+
import { sortBy } from "../../../constants/sort";
|
|
2
3
|
import { useQuery } from "@tanstack/react-query";
|
|
3
4
|
import { apiClient } from "../../../http";
|
|
4
5
|
import { PRODUCT_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
@@ -4,7 +4,8 @@ import { DatabaseError } from "../../../errors";
|
|
|
4
4
|
import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
|
|
5
5
|
import { cacheManager } from "../../../core";
|
|
6
6
|
import { serverLogger } from "../../../monitoring";
|
|
7
|
-
import { generateUniqueId, slugify,
|
|
7
|
+
import { generateUniqueId, slugify, generateBarcodeId } from "../../../utils";
|
|
8
|
+
import { buildSearchTxt, parseSearchTxtQuery } from "../../../utils/search-txt";
|
|
8
9
|
import { PRODUCT_COLLECTION, ProductStatusValues } from "../schemas";
|
|
9
10
|
import { PRODUCT_FIELDS } from "../../../constants/field-names";
|
|
10
11
|
/**
|
|
@@ -45,8 +46,21 @@ function buildListingKindClause(kind, inverted) {
|
|
|
45
46
|
const op = inverted ? "!=" : "==";
|
|
46
47
|
return `${PRODUCT_FIELDS.LISTING_TYPE}${op}${canonical}`;
|
|
47
48
|
}
|
|
48
|
-
function
|
|
49
|
-
return
|
|
49
|
+
function buildProductSearchTxt(p) {
|
|
50
|
+
return buildSearchTxt([
|
|
51
|
+
p.title,
|
|
52
|
+
p.description,
|
|
53
|
+
p.brand,
|
|
54
|
+
p.brandSlug,
|
|
55
|
+
p.categoryNames,
|
|
56
|
+
p.tags,
|
|
57
|
+
p.features,
|
|
58
|
+
p.condition,
|
|
59
|
+
p.card?.setName,
|
|
60
|
+
p.card?.cardNumber,
|
|
61
|
+
p.grading?.service,
|
|
62
|
+
p.specifications?.map((s) => `${s.name} ${s.value}`),
|
|
63
|
+
]);
|
|
50
64
|
}
|
|
51
65
|
export class ProductRepository extends BaseRepository {
|
|
52
66
|
constructor() {
|
|
@@ -97,7 +111,7 @@ export class ProductRepository extends BaseRepository {
|
|
|
97
111
|
const merged = { ...current, ...data };
|
|
98
112
|
const updated = await super.update(id, {
|
|
99
113
|
...data,
|
|
100
|
-
|
|
114
|
+
searchTxt: buildProductSearchTxt(merged),
|
|
101
115
|
});
|
|
102
116
|
this.cacheSet(updated);
|
|
103
117
|
return updated;
|
|
@@ -121,7 +135,7 @@ export class ProductRepository extends BaseRepository {
|
|
|
121
135
|
barcodeId,
|
|
122
136
|
slug: id,
|
|
123
137
|
availableQuantity: input.stockQuantity,
|
|
124
|
-
|
|
138
|
+
searchTxt: buildProductSearchTxt(input),
|
|
125
139
|
createdAt: new Date(),
|
|
126
140
|
updatedAt: new Date(),
|
|
127
141
|
};
|
|
@@ -359,17 +373,17 @@ export class ProductRepository extends BaseRepository {
|
|
|
359
373
|
if (opts?.storeId) {
|
|
360
374
|
baseQuery = baseQuery.where(PRODUCT_FIELDS.STORE_ID, "==", opts.storeId);
|
|
361
375
|
}
|
|
362
|
-
const
|
|
363
|
-
if (
|
|
376
|
+
const searchTxt = parseSearchTxtQuery(opts?.search ?? "");
|
|
377
|
+
if (searchTxt.length > 0 && opts?.categoriesIn && opts.categoriesIn.length > 0) {
|
|
364
378
|
// Firestore cannot combine two array operators in one query
|
|
365
379
|
}
|
|
366
|
-
else if (
|
|
367
|
-
baseQuery = baseQuery.where(PRODUCT_FIELDS.
|
|
380
|
+
else if (searchTxt.length === 1) {
|
|
381
|
+
baseQuery = baseQuery.where(PRODUCT_FIELDS.SEARCH_TXT, "array-contains", searchTxt[0]);
|
|
368
382
|
}
|
|
369
|
-
else if (
|
|
370
|
-
baseQuery = baseQuery.where(PRODUCT_FIELDS.
|
|
383
|
+
else if (searchTxt.length > 1) {
|
|
384
|
+
baseQuery = baseQuery.where(PRODUCT_FIELDS.SEARCH_TXT, "array-contains-any", searchTxt);
|
|
371
385
|
}
|
|
372
|
-
if (
|
|
386
|
+
if (searchTxt.length === 0 && opts?.categoriesIn && opts.categoriesIn.length > 0) {
|
|
373
387
|
let categoriesIn = opts.categoriesIn;
|
|
374
388
|
if (categoriesIn.length > ProductRepository.MAX_ARRAY_CONTAINS_ANY) {
|
|
375
389
|
// Products carry their FULL ancestor chain in `categorySlugs`, so a
|
|
@@ -559,7 +573,7 @@ ProductRepository.SIEVE_FIELDS = {
|
|
|
559
573
|
// field had no entry: `throwExceptions:false` means an unknown field is
|
|
560
574
|
// silently skipped. The toggle did nothing at all until 2026-08-21.
|
|
561
575
|
shippingPaidBy: { canFilter: true, canSort: false },
|
|
562
|
-
|
|
576
|
+
searchTxt: { canFilter: true, canSort: false },
|
|
563
577
|
"classified.meetupArea": { canFilter: true, canSort: false },
|
|
564
578
|
"classified.acceptsShipping": { canFilter: true, canSort: false },
|
|
565
579
|
"classified.negotiable": { canFilter: true, canSort: false },
|
|
@@ -349,7 +349,8 @@ export interface ProductDocument extends BaseDocument {
|
|
|
349
349
|
* Auto-generated as `LIR-{8-char-hex}` on product creation when not supplied.
|
|
350
350
|
* Override by scanning a pre-printed sticker during the create/edit flow. */
|
|
351
351
|
barcodeId?: string;
|
|
352
|
-
searchTokens
|
|
352
|
+
/** Normalized edge-n-gram search field. Replaced `searchTokens`. */
|
|
353
|
+
searchTxt?: string[];
|
|
353
354
|
}
|
|
354
355
|
/**
|
|
355
356
|
* Single prize within a prize-draw listing. A draw can have between 3 and 16
|
|
@@ -381,7 +382,7 @@ export declare const ProductStatusValues: {
|
|
|
381
382
|
readonly ARCHIVED: "archived";
|
|
382
383
|
};
|
|
383
384
|
export declare const PRODUCT_COLLECTION: "products";
|
|
384
|
-
export declare const PRODUCT_INDEXED_FIELDS: readonly ["storeId", "status", "category", "featured", "listingType", "isPromoted", "isOnSale", "isSold", "
|
|
385
|
+
export declare const PRODUCT_INDEXED_FIELDS: readonly ["storeId", "status", "category", "featured", "listingType", "isPromoted", "isOnSale", "isSold", "searchTxt", "createdAt", "barcodeId"];
|
|
385
386
|
export declare const DEFAULT_PRODUCT_DATA: Partial<ProductDocument>;
|
|
386
387
|
export declare const PRODUCT_PUBLIC_FIELDS: readonly ["allowOffers", "minOfferPercent", "id", "title", "description", "category", "subcategory", "brand", "price", "currency", "stockQuantity", "availableQuantity", "images", "status", "storeName", "featured", "tags", "specifications", "features", "shippingInfo", "returnPolicy", "listingType", "auctionEndDate", "startingBid", "currentBid", "bidCount", "reservePrice", "buyNowPrice", "minBidIncrement", "autoExtendable", "auctionExtensionMinutes", "auctionShippingPaidBy", "bidsHaveStarted", "grading", "card", "classified", "digitalCode", "liveItem", "catalogProductId", "preOrderDeliveryDate", "preOrderDepositPercent", "preOrderDepositAmount", "preOrderMaxQuantity", "preOrderCurrentCount", "preOrderProductionStatus", "preOrderCancellable", "condition", "insurance", "insuranceCost", "shippingPaidBy", "isPromoted", "isOnSale", "isSold", "slug", "seoTitle", "seoDescription", "seoKeywords", "viewCount", "customFields", "customSections", "sublistingCategoryId", "groupId", "isGroupParent", "groupParentSlug", "groupChildSlugs", "groupTitle", "createdAt"];
|
|
387
388
|
export declare const PRODUCT_UPDATABLE_FIELDS: readonly ["title", "description", "category", "subcategory", "brand", "price", "stockQuantity", "images", "status", "tags", "specifications", "features", "shippingInfo", "returnPolicy", "pickupAddressId", "condition", "insurance", "shippingPaidBy", "autoExtendable", "auctionExtensionMinutes", "auctionShippingPaidBy", "reservePrice", "buyNowPrice", "minBidIncrement", "bidsHaveStarted", "grading", "card", "classified", "digitalCode", "liveItem", "catalogProductId", "listingType", "preOrderDeliveryDate", "preOrderDepositPercent", "preOrderDepositAmount", "preOrderMaxQuantity", "preOrderProductionStatus", "preOrderCancellable", "isOnSale", "isSold", "allowShipBeforeEmiComplete", "printMeta", "seoTitle", "seoDescription", "seoKeywords", "customFields", "customSections", "sublistingCategoryId", "groupId", "isGroupParent", "groupParentSlug", "groupChildSlugs", "groupTitle", "barcodeId"];
|
|
@@ -27,7 +27,14 @@ export declare function updateReview(userId: string, reviewId: string, input: Up
|
|
|
27
27
|
export declare function deleteReview(userId: string, reviewId: string): Promise<void>;
|
|
28
28
|
export declare function adminUpdateReview(adminId: string, reviewId: string, input: UpdateReviewActionInput): Promise<ReviewDocument | null>;
|
|
29
29
|
export declare function adminDeleteReview(adminId: string, reviewId: string): Promise<void>;
|
|
30
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Record a helpful vote. `userId` is required — without it the endpoint has no
|
|
32
|
+
* idempotency key and a repeated POST inflates the count without limit.
|
|
33
|
+
*/
|
|
34
|
+
export declare function voteReviewHelpful(reviewId: string, helpful: boolean, userId: string): Promise<{
|
|
35
|
+
counted: boolean;
|
|
36
|
+
helpfulCount: number;
|
|
37
|
+
}>;
|
|
31
38
|
export declare function listReviewsByProduct(productId: string, page?: number, pageSize?: number): Promise<FirebaseSieveResult<ReviewDocument>>;
|
|
32
39
|
export declare function listAdminReviews(params?: {
|
|
33
40
|
filters?: string;
|
|
@@ -136,13 +136,24 @@ export async function adminDeleteReview(adminId, reviewId) {
|
|
|
136
136
|
});
|
|
137
137
|
await reviewRepository.delete(reviewId);
|
|
138
138
|
}
|
|
139
|
-
|
|
139
|
+
/**
|
|
140
|
+
* Record a helpful vote. `userId` is required — without it the endpoint has no
|
|
141
|
+
* idempotency key and a repeated POST inflates the count without limit.
|
|
142
|
+
*/
|
|
143
|
+
export async function voteReviewHelpful(reviewId, helpful, userId) {
|
|
140
144
|
if (!reviewId?.trim()) {
|
|
141
145
|
throw new ValidationError(ERR_REVIEW_ID_REQUIRED);
|
|
142
146
|
}
|
|
143
|
-
if (
|
|
144
|
-
|
|
147
|
+
if (!userId?.trim()) {
|
|
148
|
+
throw new ValidationError("A signed-in user is required to vote.");
|
|
149
|
+
}
|
|
150
|
+
if (!helpful) {
|
|
151
|
+
// Only "helpful" is recorded today; an un-vote would need its own path so
|
|
152
|
+
// it cannot be used to decrement a review it never incremented.
|
|
153
|
+
const review = await reviewRepository.findById(reviewId);
|
|
154
|
+
return { counted: false, helpfulCount: review?.helpfulCount ?? 0 };
|
|
145
155
|
}
|
|
156
|
+
return reviewRepository.voteHelpful(reviewId, userId);
|
|
146
157
|
}
|
|
147
158
|
export async function listReviewsByProduct(productId, page = 1, pageSize = 10) {
|
|
148
159
|
const result = await reviewRepository.listForProduct(productId, {
|
|
@@ -8,7 +8,7 @@ const __P = {
|
|
|
8
8
|
const __O = {
|
|
9
9
|
hidden: "overflow-hidden",
|
|
10
10
|
};
|
|
11
|
-
const CLS_RELATED_LINK = "group flex items-center gap-[var(--appkit-space-3)] rounded-xl border border-
|
|
11
|
+
const CLS_RELATED_LINK = "group flex items-center gap-[var(--appkit-space-3)] rounded-xl border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] p-[var(--appkit-space-4)] hover:border-primary hover:shadow-sm transition-all";
|
|
12
12
|
const CLS_RELATED_LABEL = "text-[length:var(--appkit-text-xs)] text-[var(--appkit-color-text-faint)] mb-0.5";
|
|
13
13
|
const CLS_RELATED_TITLE = "text-[length:var(--appkit-text-sm)] font-medium text-[var(--appkit-color-text)] dark:text-white truncate group-hover:text-primary transition-colors";
|
|
14
14
|
const CLS_RATING_PILL = "inline-flex items-center gap-[var(--appkit-space-1)] rounded-full bg-warning-surface px-[var(--appkit-space-3)] py-[var(--appkit-space-1)] text-warning dark:bg-warning-surface dark:text-warning";
|
|
@@ -70,8 +70,14 @@ export function ReviewDetailShell({ review, storeHref }) {
|
|
|
70
70
|
return;
|
|
71
71
|
setVoting(true);
|
|
72
72
|
try {
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
// The server is authoritative: it de-duplicates by uid, so a vote already
|
|
74
|
+
// cast returns counted:false with the unchanged total. Trusting the
|
|
75
|
+
// response instead of incrementing locally keeps the two in step when the
|
|
76
|
+
// localStorage flag is missing (new device, cleared storage).
|
|
77
|
+
const res = await apiClient.post(`${REVIEW_ENDPOINTS.LIST}/${review.id}/vote`, {});
|
|
78
|
+
if (typeof res?.helpfulCount === "number") {
|
|
79
|
+
setHelpfulCount(res.helpfulCount);
|
|
80
|
+
}
|
|
75
81
|
setVoted(true);
|
|
76
82
|
localStorage.setItem(storageKey, "1");
|
|
77
83
|
}
|
|
@@ -93,7 +99,7 @@ export function ReviewDetailShell({ review, storeHref }) {
|
|
|
93
99
|
? String(ROUTES.PUBLIC.PROFILE(reviewerProfileId))
|
|
94
100
|
: null;
|
|
95
101
|
const currentImage = lightboxIdx !== null ? images[lightboxIdx] : null;
|
|
96
|
-
return (_jsxs(_Fragment, { children: [_jsx(Div, { surface: "default", className: "border-b border-neutral-200 pt-[2.5rem]", padding: "b-xl", children: _jsxs(Div, { className: "mx-auto max-w-3xl", padding: "x-md", children: [_jsxs(Row, { gap: "sm", className: "mb-4", children: [_jsx(StarRating, { value: review.rating, size: "lg", readOnly: true }), _jsxs(Span, { color: "inverse", weight: "bold", className: "text-[var(--appkit-color-text)] dark:", size: "2xl", children: [review.rating, ".0"] }), review.verified && (_jsx(Span, { layout: "inline-flex", gap: "xs", color: "success", surface: "success-surface", size: "xs", weight: "semibold", rounded: "full", padding: "pill-md", children: "\u2713 Verified Purchase" })), review.featured && (_jsx(Span, { size: "xs", weight: "semibold", className: CLS_RATING_PILL, children: "\u2605 Featured" }))] }), review.title && (_jsx(Heading, { color: "inverse", level: 1, className: "text-[var(--appkit-color-text)] dark: mb-4 leading-snug", size: "2xl", weight: "bold", children: review.title })), _jsxs(Row, { gap: "sm", children: [review.userAvatar ? (_jsx(Div, { className: "relative h-11 w-11 flex-shrink-0 ring-2 ring-white ring-[var(--appkit-color-border-subtle)]", rounded: "full", overflow: "hidden", children: _jsx(MediaImage, { src: review.userAvatar, alt: displayName, size: "avatar" }) })) : (_jsx(Row, { textWeight: "bold", textSize: "base", centered: true, className: "h-11 w-11 flex-shrink-0 bg-primary/10 text-primary ring-2 ring-white ring-[var(--appkit-color-border-subtle)]", rounded: "full", children: initials })), _jsxs(Div, { className: "min-w-0", children: [reviewerHref ? (_jsx(Link, { href: reviewerHref, className: "text-[length:var(--appkit-text-sm)] font-semibold text-[var(--appkit-color-text)] dark:text-white hover:text-primary transition-colors", children: displayName })) : (_jsx(Span, { color: "inverse", size: "sm", weight: "semibold", className: "text-[var(--appkit-color-text)] dark:", children: review.isAnonymous ? "Anonymous" : displayName })), date && (_jsx(Text, { size: "xs", color: "muted", className: "mt-0.5", children: date }))] })] })] }) }), _jsxs(Stack, { gap: "xl", className: "mx-auto max-w-3xl", paddingY: "y-xl", paddingX: "x-md", children: [review.comment && (_jsx(Section, { children: _jsx(RichText, { html: normalizeRichTextHtml(review.comment), proseClass: "prose prose-neutral dark:prose-invert max-w-none prose-p:leading-relaxed prose-headings:font-semibold prose-img:rounded-lg prose-a:text-primary", className: "text-
|
|
102
|
+
return (_jsxs(_Fragment, { children: [_jsx(Div, { surface: "default", className: "border-b border-neutral-200 pt-[2.5rem]", padding: "b-xl", children: _jsxs(Div, { className: "mx-auto max-w-3xl", padding: "x-md", children: [_jsxs(Row, { gap: "sm", className: "mb-4", children: [_jsx(StarRating, { value: review.rating, size: "lg", readOnly: true }), _jsxs(Span, { color: "inverse", weight: "bold", className: "text-[var(--appkit-color-text)] dark:", size: "2xl", children: [review.rating, ".0"] }), review.verified && (_jsx(Span, { layout: "inline-flex", gap: "xs", color: "success", surface: "success-surface", size: "xs", weight: "semibold", rounded: "full", padding: "pill-md", children: "\u2713 Verified Purchase" })), review.featured && (_jsx(Span, { size: "xs", weight: "semibold", className: CLS_RATING_PILL, children: "\u2605 Featured" }))] }), review.title && (_jsx(Heading, { color: "inverse", level: 1, className: "text-[var(--appkit-color-text)] dark: mb-4 leading-snug", size: "2xl", weight: "bold", children: review.title })), _jsxs(Row, { gap: "sm", children: [review.userAvatar ? (_jsx(Div, { className: "relative h-11 w-11 flex-shrink-0 ring-2 ring-white ring-[var(--appkit-color-border-subtle)]", rounded: "full", overflow: "hidden", children: _jsx(MediaImage, { src: review.userAvatar, alt: displayName, size: "avatar" }) })) : (_jsx(Row, { textWeight: "bold", textSize: "base", centered: true, className: "h-11 w-11 flex-shrink-0 bg-primary/10 text-primary ring-2 ring-white ring-[var(--appkit-color-border-subtle)]", rounded: "full", children: initials })), _jsxs(Div, { className: "min-w-0", children: [reviewerHref ? (_jsx(Link, { href: reviewerHref, className: "text-[length:var(--appkit-text-sm)] font-semibold text-[var(--appkit-color-text)] dark:text-white hover:text-primary transition-colors", children: displayName })) : (_jsx(Span, { color: "inverse", size: "sm", weight: "semibold", className: "text-[var(--appkit-color-text)] dark:", children: review.isAnonymous ? "Anonymous" : displayName })), date && (_jsx(Text, { size: "xs", color: "muted", className: "mt-0.5", children: date }))] })] })] }) }), _jsxs(Stack, { gap: "xl", className: "mx-auto max-w-3xl", paddingY: "y-xl", paddingX: "x-md", children: [review.comment && (_jsx(Section, { children: _jsx(RichText, { html: normalizeRichTextHtml(review.comment), proseClass: "prose prose-neutral dark:prose-invert max-w-none prose-p:leading-relaxed prose-headings:font-semibold prose-img:rounded-lg prose-a:text-primary", className: "text-[var(--appkit-color-text-muted)]" }) })), images.length > 0 && (_jsxs(Section, { children: [_jsxs(Heading, { level: 2, className: "tracking-wide mb-3", color: "faint", size: "sm", weight: "semibold", transform: "uppercase", children: ["Photos (", images.length, ")"] }), _jsx(Grid, { gap: "xs", className: "grid-cols-3 sm:grid-cols-4", children: images.map((img, i) => (_jsxs(Button, { variant: "ghost", type: "button", onClick: () => setLightboxIdx(i), "aria-label": `View photo ${i + 1}`, rounded: "xl", paddingX: "none", paddingY: "none", className: "group relative aspect-square overflow-hidden border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface-elevated)] focus-visible:ring-2 focus-visible:ring-primary", children: [_jsx(MediaImage, { src: img, alt: `Review image ${i + 1}`, size: "card", className: "transition-transform duration-300 group-hover:scale-105" }), _jsx(Row, { centered: true, className: "absolute inset-0 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity bg-[rgba(0,0,0,0.3)]", children: _jsx(Span, { color: "inverse", size: "xl", children: "\uD83D\uDD0D" }) })] }, i))) })] })), review.video && (_jsxs(Section, { children: [_jsx(Heading, { level: 2, className: "tracking-wide mb-3", color: "faint", size: "sm", weight: "semibold", transform: "uppercase", children: "Video" }), _jsx(Div, { className: `${__O.hidden} border border-[var(--appkit-color-border)] bg-[rgb(0,0,0)] aspect-video`, rounded: "xl", children: _jsx("video", { src: review.video.url, poster: review.video.thumbnailUrl, controls: true, className: "h-full w-full", preload: "metadata" }) })] })), _jsxs(Section, { className: "flex items-[center] gap-[1rem] border-t border-neutral-100", padding: "y-md", children: [_jsx(Div, { textSize: "sm", className: "text-[var(--appkit-color-text-muted)]", children: helpfulCount > 0 && (_jsxs(Span, { children: [_jsx(Span, { color: "inverse", weight: "bold", className: "text-[var(--appkit-color-text)] dark:", children: helpfulCount }), " ", helpfulCount === 1 ? "person" : "people", " found this helpful"] })) }), _jsxs(Button, { variant: "outline", type: "button", onClick: handleVote, disabled: voted || voting, gap: "sm", rounded: "lg", paddingX: "md", paddingY: "sm", textSize: "sm", weight: "medium", className: `ml-auto transition-colors ${voted
|
|
97
103
|
? CLS_HELPFUL_ACTIVE
|
|
98
104
|
: "border-[var(--appkit-color-border)] text-[var(--appkit-color-text-muted)] hover:border-primary hover:text-primary dark:hover:border-primary dark:hover:text-primary disabled:opacity-50"}`, children: [_jsx(Span, { "aria-hidden": "true", children: voted ? "✓" : "👍" }), voted ? "Marked helpful" : voting ? "Saving…" : "Helpful?"] })] }), _jsxs(Section, { className: "grid gap-[0.75rem] sm:grid-cols-3", children: [productHref && (_jsxs(Link, { href: productHref, className: CLS_RELATED_LINK, children: [_jsx(Span, { className: CLS_ICON_ORANGE, children: "\uD83D\uDCE6" }), _jsxs(Div, { className: "min-w-0", children: [_jsx(Text, { className: CLS_RELATED_LABEL, children: "Product" }), _jsx(Text, { className: CLS_RELATED_TITLE, children: review.productTitle ?? "View Product" })] })] })), sellerHref && (_jsxs(Link, { href: sellerHref, className: CLS_RELATED_LINK, children: [_jsx(Span, { className: CLS_ICON_BLUE, children: "\uD83C\uDFEA" }), _jsxs(Div, { className: "min-w-0", children: [_jsx(Text, { className: CLS_RELATED_LABEL, children: "Seller" }), _jsx(Text, { className: CLS_RELATED_TITLE, children: "View Seller" })] })] })), reviewerHref ? (_jsxs(Link, { href: reviewerHref, className: CLS_RELATED_LINK, children: [_jsx(Span, { className: CLS_ICON_PURPLE, children: "\uD83D\uDC64" }), _jsxs(Div, { className: "min-w-0", children: [_jsx(Text, { className: CLS_RELATED_LABEL, children: "Reviewer" }), _jsx(Text, { className: CLS_RELATED_TITLE, children: displayName })] })] })) : (_jsxs(Row, { surface: "default", gap: "sm", className: `border border-neutral-200 dark:border-[var(--appkit-color-border)] ${__P.p4}`, rounded: "xl", children: [_jsx(Span, { size: "xl", className: CLS_ICON_PURPLE_BARE, children: "\uD83D\uDC64" }), _jsxs(Div, { className: "min-w-0", children: [_jsx(Span, { size: "xs", className: "block mb-0.5", color: "faint", children: "Reviewer" }), _jsx(Span, { color: "inverse", size: "sm", weight: "medium", className: "block text-[var(--appkit-color-text)] dark: truncate", children: "Anonymous" })] })] }))] })] }), lightboxIdx !== null && currentImage && (_jsxs(Row, { centered: true, className: "fixed inset-0 z-50 bg-[rgba(0,0,0,0.95)]", onClick: closeLightbox, role: "dialog", "aria-modal": "true", "aria-label": "Image lightbox", children: [_jsx(IconButton, { type: "button", onClick: closeLightbox, "aria-label": "Close lightbox", variant: "scrim", rounded: "full", className: "absolute top-4 right-4 z-10 h-10 w-10", children: _jsx(Span, { size: "xl", children: "\u00D7" }) }), _jsxs(Div, { textSize: "sm", className: "absolute top-4 left-1/2 -translate-x-1/2 text-white/70", children: [lightboxIdx + 1, " / ", images.length] }), images.length > 1 && (_jsx(IconButton, { type: "button", onClick: (e) => { e.stopPropagation(); prevImage(); }, "aria-label": "Previous image", variant: "scrim", rounded: "full", className: "absolute left-4 top-1/2 -translate-y-1/2 z-10 h-12 w-12", children: _jsx(Span, { size: "2xl", children: "\u2039" }) })), _jsx(Row, { centered: true, className: "relative h-[85vh] w-[85vw]", onClick: (e) => e.stopPropagation(), children: _jsx(MediaImage, { src: currentImage, alt: `Review photo ${lightboxIdx + 1}`, size: "hero", objectFit: "contain", rounded: "lg", shadow: "2xl" }) }), images.length > 1 && (_jsx(IconButton, { type: "button", onClick: (e) => { e.stopPropagation(); nextImage(); }, "aria-label": "Next image", variant: "scrim", rounded: "full", className: "absolute right-4 top-1/2 -translate-y-1/2 z-10 h-12 w-12", children: _jsx(Span, { size: "2xl", children: "\u203A" }) })), images.length > 1 && (_jsx(Row, { justify: "center", gap: "xs", className: "absolute bottom-4 left-0 right-0", padding: "x-md", children: images.map((img, i) => (_jsx(Button, { variant: "ghost", type: "button", onClick: (e) => { e.stopPropagation(); setLightboxIdx(i); }, "aria-label": `Go to image ${i + 1}`, rounded: "md", paddingX: "none", paddingY: "none", className: `h-12 w-12 flex-shrink-0 overflow-hidden border-2 transition-all ${i === lightboxIdx
|
|
99
105
|
? "border-white scale-110"
|
|
@@ -24,5 +24,5 @@ export function ViewReviewModal({ review, isOpen, onClose, }) {
|
|
|
24
24
|
day: "numeric",
|
|
25
25
|
})
|
|
26
26
|
: "";
|
|
27
|
-
return (_jsx(Modal, { isOpen: isOpen, onClose: onClose, title: "Review Details", children: _jsxs(Stack, { gap: "md", children: [_jsxs(Row, { gap: "3", children: [review.userAvatar ? (_jsx(Div, { className: "relative h-10 w-10 flex-shrink-0", rounded: "full", overflow: "hidden", children: _jsx(MediaImage, { src: review.userAvatar, alt: displayName, size: "avatar" }) })) : (_jsx(Row, { textWeight: "medium", textSize: "sm", className: "h-10 w-10 flex-shrink-0 bg-neutral-200 text-[var(--appkit-color-text-muted)]", align: "center", justify: "center", rounded: "full", children: displayName.charAt(0).toUpperCase() })), _jsxs(Div, { children: [_jsxs(Row, { gap: "sm", children: [_jsx(Text, { color: "inverse", className: "text-[var(--appkit-color-text)] dark:", weight: "medium", children: displayName }), review.verified && (_jsx(Text, { className: THEMED_TEXT_SUCCESS, size: "xs", children: "\u2713 Verified purchase" }))] }), _jsxs(Row, { className: "mt-0.5", gap: "sm", children: [_jsx(StarRating, { value: review.rating, size: "sm", readOnly: true }), date && (_jsx(Text, { size: "xs", color: "faint", children: date }))] })] })] }), review.title && (_jsx(Heading, { color: "inverse", level: 4, className: "text-[var(--appkit-color-text)] dark:", weight: "semibold", children: review.title })), review.comment && (_jsx(RichText, { html: normalizeRichTextHtml(review.comment), proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "text-[length:var(--appkit-text-sm)] leading-relaxed text-[var(--appkit-color-text-muted)] text-[var(--appkit-color-text-muted)]" })), review.images && review.images.length > 0 && (_jsx(Row, { wrap: true, gap: "sm", children: review.images.map((img, i) => (_jsx(Button, { variant: "ghost", type: "button", "aria-label": `View image ${i + 1}`, onClick: () => setLightboxIdx(i), rounded: "lg", paddingX: "none", paddingY: "none", className: `h-20 w-20 overflow-hidden transition hover:opacity-80 flex-shrink-0 ${lightboxIdx === i ? "ring-2 ring-primary-500" : "
|
|
27
|
+
return (_jsx(Modal, { isOpen: isOpen, onClose: onClose, title: "Review Details", children: _jsxs(Stack, { gap: "md", children: [_jsxs(Row, { gap: "3", children: [review.userAvatar ? (_jsx(Div, { className: "relative h-10 w-10 flex-shrink-0", rounded: "full", overflow: "hidden", children: _jsx(MediaImage, { src: review.userAvatar, alt: displayName, size: "avatar" }) })) : (_jsx(Row, { textWeight: "medium", textSize: "sm", className: "h-10 w-10 flex-shrink-0 bg-neutral-200 text-[var(--appkit-color-text-muted)]", align: "center", justify: "center", rounded: "full", children: displayName.charAt(0).toUpperCase() })), _jsxs(Div, { children: [_jsxs(Row, { gap: "sm", children: [_jsx(Text, { color: "inverse", className: "text-[var(--appkit-color-text)] dark:", weight: "medium", children: displayName }), review.verified && (_jsx(Text, { className: THEMED_TEXT_SUCCESS, size: "xs", children: "\u2713 Verified purchase" }))] }), _jsxs(Row, { className: "mt-0.5", gap: "sm", children: [_jsx(StarRating, { value: review.rating, size: "sm", readOnly: true }), date && (_jsx(Text, { size: "xs", color: "faint", children: date }))] })] })] }), review.title && (_jsx(Heading, { color: "inverse", level: 4, className: "text-[var(--appkit-color-text)] dark:", weight: "semibold", children: review.title })), review.comment && (_jsx(RichText, { html: normalizeRichTextHtml(review.comment), proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "text-[length:var(--appkit-text-sm)] leading-relaxed text-[var(--appkit-color-text-muted)] text-[var(--appkit-color-text-muted)]" })), review.images && review.images.length > 0 && (_jsx(Row, { wrap: true, gap: "sm", children: review.images.map((img, i) => (_jsx(Button, { variant: "ghost", type: "button", "aria-label": `View image ${i + 1}`, onClick: () => setLightboxIdx(i), rounded: "lg", paddingX: "none", paddingY: "none", className: `h-20 w-20 overflow-hidden transition hover:opacity-80 flex-shrink-0 ${lightboxIdx === i ? "ring-2 ring-primary-500" : " border-[var(--appkit-color-border)]"}`, children: _jsx(MediaImage, { src: img, alt: `Review thumbnail ${i + 1}`, size: "thumbnail" }) }, i))) })), (review.helpfulCount ?? 0) > 0 && (_jsxs(Text, { size: "xs", color: "faint", children: [review.helpfulCount, " people found this helpful"] })), _jsx(Row, { justify: "end", padding: "t-xs", children: _jsx(Button, { variant: "ghost", onClick: onClose, children: "Close" }) })] }) }));
|
|
28
28
|
}
|
|
@@ -9,6 +9,16 @@ export interface ReviewRatingAggregate {
|
|
|
9
9
|
declare class ReviewRepository extends BaseRepository<ReviewDocument> {
|
|
10
10
|
constructor();
|
|
11
11
|
private decryptSieveResult;
|
|
12
|
+
/**
|
|
13
|
+
* Encrypt PII, then attach blind indices derived from the plaintext.
|
|
14
|
+
*
|
|
15
|
+
* Previously this reassigned `encrypted = addPiiIndices(data, …)` — which
|
|
16
|
+
* re-reads the ORIGINAL plaintext and returns `{...data, ...indices}` — and
|
|
17
|
+
* then spread that over the ciphertext, so plaintext won and `userName` was
|
|
18
|
+
* written to Firestore in the clear beside a valid index. `piiIndicesFor`
|
|
19
|
+
* returns the index fields alone, so the ciphertext cannot be overwritten.
|
|
20
|
+
* The identical bug was fixed in UserRepository and never propagated here.
|
|
21
|
+
*/
|
|
12
22
|
private encryptReviewData;
|
|
13
23
|
protected mapDoc<D = ReviewDocument>(snap: import("../../../providers/db-firebase").DocumentSnapshot): D;
|
|
14
24
|
create(input: ReviewCreateInput): Promise<ReviewDocument>;
|
|
@@ -32,7 +42,21 @@ declare class ReviewRepository extends BaseRepository<ReviewDocument> {
|
|
|
32
42
|
findFeatured(limit?: number): Promise<ReviewDocument[]>;
|
|
33
43
|
approve(reviewId: string, moderatorId: string, moderatorNote?: string): Promise<ReviewDocument>;
|
|
34
44
|
reject(reviewId: string, moderatorId: string, rejectionReason: string, moderatorNote?: string): Promise<ReviewDocument>;
|
|
35
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Record one helpful vote, idempotently per user.
|
|
47
|
+
*
|
|
48
|
+
* Runs in a transaction because the read-modify-write on `helpfulCount` races
|
|
49
|
+
* itself otherwise — two concurrent votes both read N and both write N+1.
|
|
50
|
+
* `helpfulVoterIds` is what makes a repeat POST a no-op; the client's
|
|
51
|
+
* localStorage flag is advisory and cannot be trusted.
|
|
52
|
+
*
|
|
53
|
+
* @returns `counted` false when this user had already voted (not an error —
|
|
54
|
+
* the caller reports the current count either way).
|
|
55
|
+
*/
|
|
56
|
+
voteHelpful(reviewId: string, userId: string): Promise<{
|
|
57
|
+
counted: boolean;
|
|
58
|
+
helpfulCount: number;
|
|
59
|
+
}>;
|
|
36
60
|
incrementReportCount(reviewId: string): Promise<void>;
|
|
37
61
|
/**
|
|
38
62
|
* Approved-review count, average and per-star distribution for one product.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BaseRepository, getFirestoreCount, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
|
|
2
|
+
import { NotFoundError } from "../../../errors";
|
|
2
3
|
import { REVIEW_MAX_RATING, REVIEW_MIN_RATING } from "../../../_internal/shared/features/reviews/config";
|
|
3
|
-
import {
|
|
4
|
+
import { decryptPiiFields, encryptPiiFields, piiIndicesFor, REVIEW_PII_FIELDS, REVIEW_PII_INDEX_MAP, } from "../../../security";
|
|
4
5
|
import { createReviewId, REVIEW_COLLECTION, } from "../schemas";
|
|
5
6
|
const REVIEW_FIELDS = {
|
|
6
7
|
PRODUCT_ID: "productId",
|
|
@@ -25,18 +26,32 @@ class ReviewRepository extends BaseRepository {
|
|
|
25
26
|
])),
|
|
26
27
|
};
|
|
27
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Encrypt PII, then attach blind indices derived from the plaintext.
|
|
31
|
+
*
|
|
32
|
+
* Previously this reassigned `encrypted = addPiiIndices(data, …)` — which
|
|
33
|
+
* re-reads the ORIGINAL plaintext and returns `{...data, ...indices}` — and
|
|
34
|
+
* then spread that over the ciphertext, so plaintext won and `userName` was
|
|
35
|
+
* written to Firestore in the clear beside a valid index. `piiIndicesFor`
|
|
36
|
+
* returns the index fields alone, so the ciphertext cannot be overwritten.
|
|
37
|
+
* The identical bug was fixed in UserRepository and never propagated here.
|
|
38
|
+
*/
|
|
28
39
|
encryptReviewData(data) {
|
|
29
|
-
|
|
30
|
-
encrypted = addPiiIndices(data, REVIEW_PII_INDEX_MAP);
|
|
31
|
-
encrypted = {
|
|
40
|
+
return {
|
|
32
41
|
...encryptPiiFields(data, [...REVIEW_PII_FIELDS]),
|
|
33
|
-
...
|
|
42
|
+
...piiIndicesFor(data, REVIEW_PII_INDEX_MAP),
|
|
34
43
|
};
|
|
35
|
-
return encrypted;
|
|
36
44
|
}
|
|
37
45
|
mapDoc(snap) {
|
|
38
46
|
const raw = super.mapDoc(snap);
|
|
39
|
-
|
|
47
|
+
// `helpfulVoterIds` is an idempotency key, not review content — publishing it
|
|
48
|
+
// would tell everyone who voted on what. Stripped here rather than in each
|
|
49
|
+
// route because `/api/reviews` returns `result.items` verbatim (REVIEW_PUBLIC_FIELDS
|
|
50
|
+
// exists but nothing projects through it), so a route-level strip is one
|
|
51
|
+
// forgotten call site away from a leak. `voteHelpful` reads the raw snapshot
|
|
52
|
+
// inside its transaction and is unaffected.
|
|
53
|
+
const { helpfulVoterIds: _voters, ...rest } = raw;
|
|
54
|
+
return decryptPiiFields(rest, [
|
|
40
55
|
...REVIEW_PII_FIELDS,
|
|
41
56
|
]);
|
|
42
57
|
}
|
|
@@ -143,13 +158,38 @@ class ReviewRepository extends BaseRepository {
|
|
|
143
158
|
updatedAt: new Date(),
|
|
144
159
|
});
|
|
145
160
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
161
|
+
/**
|
|
162
|
+
* Record one helpful vote, idempotently per user.
|
|
163
|
+
*
|
|
164
|
+
* Runs in a transaction because the read-modify-write on `helpfulCount` races
|
|
165
|
+
* itself otherwise — two concurrent votes both read N and both write N+1.
|
|
166
|
+
* `helpfulVoterIds` is what makes a repeat POST a no-op; the client's
|
|
167
|
+
* localStorage flag is advisory and cannot be trusted.
|
|
168
|
+
*
|
|
169
|
+
* @returns `counted` false when this user had already voted (not an error —
|
|
170
|
+
* the caller reports the current count either way).
|
|
171
|
+
*/
|
|
172
|
+
async voteHelpful(reviewId, userId) {
|
|
173
|
+
const ref = this.getCollection().doc(reviewId);
|
|
174
|
+
return this.db.runTransaction(async (tx) => {
|
|
175
|
+
const snap = await tx.get(ref);
|
|
176
|
+
if (!snap.exists) {
|
|
177
|
+
throw new NotFoundError(`Review not found: ${reviewId}`);
|
|
178
|
+
}
|
|
179
|
+
const data = snap.data();
|
|
180
|
+
const voters = data.helpfulVoterIds ?? [];
|
|
181
|
+
const current = data.helpfulCount ?? 0;
|
|
182
|
+
if (voters.includes(userId)) {
|
|
183
|
+
return { counted: false, helpfulCount: current };
|
|
184
|
+
}
|
|
185
|
+
const next = current + 1;
|
|
186
|
+
tx.update(ref, {
|
|
187
|
+
helpfulCount: next,
|
|
188
|
+
helpfulVoterIds: [...voters, userId],
|
|
189
|
+
updatedAt: new Date(),
|
|
151
190
|
});
|
|
152
|
-
|
|
191
|
+
return { counted: true, helpfulCount: next };
|
|
192
|
+
});
|
|
153
193
|
}
|
|
154
194
|
async incrementReportCount(reviewId) {
|
|
155
195
|
const review = await this.findById(reviewId);
|
|
@@ -41,6 +41,12 @@ export interface ReviewDocument extends BaseDocument {
|
|
|
41
41
|
moderatorNote?: string;
|
|
42
42
|
rejectionReason?: string;
|
|
43
43
|
helpfulCount: number;
|
|
44
|
+
/**
|
|
45
|
+
* UIDs that have voted this review helpful. Server-side idempotency for
|
|
46
|
+
* POST /api/reviews/[id]/vote — the client's localStorage guard is advisory
|
|
47
|
+
* only and cannot stop a repeated POST. Never returned to the public.
|
|
48
|
+
*/
|
|
49
|
+
helpfulVoterIds?: string[];
|
|
44
50
|
reportCount: number;
|
|
45
51
|
verified: boolean;
|
|
46
52
|
featured?: boolean;
|
|
@@ -4,7 +4,7 @@ import Link from "next/link";
|
|
|
4
4
|
import { ROUTES } from "../../../next/routing/route-map";
|
|
5
5
|
import { formatCurrency } from "../../../utils/number.formatter";
|
|
6
6
|
const CLS_ICON_SM = "h-3 w-3 shrink-0";
|
|
7
|
-
const CLS_ROW_CHEVRON = "h-4 w-4 shrink-0
|
|
7
|
+
const CLS_ROW_CHEVRON = "h-4 w-4 shrink-0 transition group-hover:text-error text-[var(--appkit-color-text-faint)]";
|
|
8
8
|
const CLS_PILL_LINK = "inline-flex items-center gap-[var(--appkit-space-1)] rounded-full bg-error-surface dark:bg-error-surface px-[var(--appkit-space-2-5)] py-[var(--appkit-space-0-5)] text-[length:var(--appkit-text-xs)] font-medium text-error dark:text-error hover:bg-error-surface transition-colors";
|
|
9
9
|
import { Shield, Phone, Wallet, Mail, ChevronRight, Search } from "lucide-react";
|
|
10
10
|
import { Button, Container, Div, Form, Heading, Main, Section, Select, Text, Grid, Stack, Row, Card, Badge, Alert, EmptyState, Input, } from "../../../ui";
|
|
@@ -61,7 +61,7 @@ export async function ScamRegistryView({ searchParams = {} }) {
|
|
|
61
61
|
const qs = p.toString();
|
|
62
62
|
return `/scams${qs ? `?${qs}` : ""}`;
|
|
63
63
|
}
|
|
64
|
-
return (_jsxs(Main, { children: [_jsx(Div, { className: "border-b appkit-alert--warning", rounded: "none", children: _jsx(Container, { padding: "y-xs", size: "xl", className: "", children: _jsxs(Alert, { variant: "warning", compact: true, children: ["All profiles on this page have been verified by our moderation team. If you recognise a scammer not listed here,", " ", _jsx(Link, { href: reportHref, className: "font-medium underline hover:no-underline", children: "report them" }), "."] }) }) }), _jsx(Section, { padding: "y-2xl", children: _jsx(Container, { size: "xl", children: _jsxs(Stack, { gap: "xl", children: [_jsxs(Row, { justify: "between", gap: "md", align: "end", wrap: true, children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Heading, { level: 1, weight: "semibold", size: "3xl", children: "Scam Registry" }), _jsx(Text, { variant: "secondary", size: "sm", children: "Verified scammers active in India's collectibles community. Search by name, phone, or UPI." })] }), _jsxs(Link, { href: reportHref, className: "appkit-button appkit-button--danger appkit-button--md shrink-0", children: [_jsx(Shield, { className: "h-4 w-4" }), "Report a Scammer"] })] }), _jsx(Form, { method: "GET", children: _jsxs(Row, { wrap: true, gap: "sm", children: [_jsx(Div, { className: "flex-1 min-w-48", children: _jsx(Input, { type: "search", name: "q", defaultValue: query, placeholder: "Search name, phone, UPI ID, or email\u2026", icon: _jsx(Search, { className: "h-4 w-4" }) }) }), _jsx(Select, { name: "scamType", defaultValue: scamType, options: SCAM_TYPES.map((t) => ({ value: t.id, label: t.label })), placeholder: "All scam types" }), _jsx(Select, { name: "sort", defaultValue: sort, options: SORT_OPTIONS.map((o) => ({ value: o.value, label: o.label })) }), _jsx(Button, { type: "submit", variant: "primary", size: "sm", children: "Search" }), (query || scamType || sort !== "-createdAt") && (_jsx(Link, { href: ROUTES.PUBLIC.SCAMS, className: "rounded-lg border border-[var(--appkit-color-border)] px-[var(--appkit-space-4)] py-[var(--appkit-space-2)] text-[length:var(--appkit-text-sm)] text-[var(--appkit-color-text-muted)] hover:bg-
|
|
64
|
+
return (_jsxs(Main, { children: [_jsx(Div, { className: "border-b appkit-alert--warning", rounded: "none", children: _jsx(Container, { padding: "y-xs", size: "xl", className: "", children: _jsxs(Alert, { variant: "warning", compact: true, children: ["All profiles on this page have been verified by our moderation team. If you recognise a scammer not listed here,", " ", _jsx(Link, { href: reportHref, className: "font-medium underline hover:no-underline", children: "report them" }), "."] }) }) }), _jsx(Section, { padding: "y-2xl", children: _jsx(Container, { size: "xl", children: _jsxs(Stack, { gap: "xl", children: [_jsxs(Row, { justify: "between", gap: "md", align: "end", wrap: true, children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Heading, { level: 1, weight: "semibold", size: "3xl", children: "Scam Registry" }), _jsx(Text, { variant: "secondary", size: "sm", children: "Verified scammers active in India's collectibles community. Search by name, phone, or UPI." })] }), _jsxs(Link, { href: reportHref, className: "appkit-button appkit-button--danger appkit-button--md shrink-0", children: [_jsx(Shield, { className: "h-4 w-4" }), "Report a Scammer"] })] }), _jsx(Form, { method: "GET", children: _jsxs(Row, { wrap: true, gap: "sm", children: [_jsx(Div, { className: "flex-1 min-w-48", children: _jsx(Input, { type: "search", name: "q", defaultValue: query, placeholder: "Search name, phone, UPI ID, or email\u2026", icon: _jsx(Search, { className: "h-4 w-4" }) }) }), _jsx(Select, { name: "scamType", defaultValue: scamType, options: SCAM_TYPES.map((t) => ({ value: t.id, label: t.label })), placeholder: "All scam types" }), _jsx(Select, { name: "sort", defaultValue: sort, options: SORT_OPTIONS.map((o) => ({ value: o.value, label: o.label })) }), _jsx(Button, { type: "submit", variant: "primary", size: "sm", children: "Search" }), (query || scamType || sort !== "-createdAt") && (_jsx(Link, { href: ROUTES.PUBLIC.SCAMS, className: "rounded-lg border border-[var(--appkit-color-border)] px-[var(--appkit-space-4)] py-[var(--appkit-space-2)] text-[length:var(--appkit-text-sm)] text-[var(--appkit-color-text-muted)] hover:bg-surface-hover transition-colors", children: "Clear" }))] }) }), scamType && SCAM_TYPE_LABELS[scamType] && (_jsxs(Row, { gap: "sm", align: "center", children: [_jsx(Text, { variant: "secondary", size: "xs", children: "Filtering by:" }), _jsxs(Link, { href: buildHref({ scamType: "" }), className: CLS_PILL_LINK, children: [SCAM_TYPE_LABELS[scamType], _jsx(Span, { "aria-hidden": "true", children: "\u00D7" })] })] })), result.items.length === 0 ? (_jsx(EmptyState, { icon: _jsx(Shield, { className: "h-12 w-12" }), title: query || scamType
|
|
65
65
|
? "No verified scammers matched your filters"
|
|
66
66
|
: "No verified scammers in the registry yet", description: query || scamType
|
|
67
67
|
? "Try a different search or clear your filters."
|
|
@@ -5,7 +5,7 @@ const __O = {
|
|
|
5
5
|
hidden: "overflow-hidden",
|
|
6
6
|
};
|
|
7
7
|
const SVG_PATH_SEARCH = "M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z";
|
|
8
|
-
const CLS_HOVER_ROW = "hover:bg-
|
|
8
|
+
const CLS_HOVER_ROW = "hover:bg-surface-hover";
|
|
9
9
|
const CLS_ICON_ROW = "w-4 h-4 flex-shrink-0 text-[var(--appkit-color-text-muted)]";
|
|
10
10
|
const CLS_TRUNCATE = "font-medium truncate";
|
|
11
11
|
import { Button, Div, Input, Li, Row, Select, Span, Stack, Text, Ul } from "../../../ui";
|
|
@@ -216,7 +216,7 @@ export function Search({ isOpen, onClose, onSearch, onOpen, value, onChange, pla
|
|
|
216
216
|
if (inlineBlurRef.current)
|
|
217
217
|
clearTimeout(inlineBlurRef.current);
|
|
218
218
|
inlineBlurRef.current = setTimeout(() => setIsInlineOpen(false), 120);
|
|
219
|
-
}, placeholder: placeholder ?? labels.placeholder, className: "w-full rounded-lg border
|
|
219
|
+
}, placeholder: placeholder ?? labels.placeholder, className: "w-full rounded-lg border bg-[var(--appkit-color-surface)] pl-9 text-zinc-900 placeholder:text-zinc-400 focus:border-primary focus:ring-2 focus:ring-primary/20 border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] dark:text-white dark:placeholder:text-slate-500" }), query && (_jsx(Button, { rounded: "full", textColor: "faint", type: "button", variant: "ghost", onClick: handleClear, className: "absolute right-3 p-[0.125rem] hover:text-[var(--appkit-color-text-muted)] dark:hover:text-[var(--appkit-color-text)] transition-colors", "aria-label": labels.clearAriaLabel, children: _jsx("svg", { className: "w-4 h-4", "aria-hidden": "true", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M6 18L18 6M6 6l12 12" }) }) }))] }), resourceTypes && resourceTypes.length > 0 && (_jsx(Select, { options: resourceTypes, value: selectedType, onValueChange: handleTypeChange, "aria-label": labels.resourceTypeLabel ?? "Search in", wrapperClassName: "flex-shrink-0 max-w-[92px] sm:max-w-[150px]" })), deferred && (_jsx(Button, { rounded: "lg", paddingX: "sm", paddingY: "sm", type: "button", variant: "primary", onClick: handleDeferredSubmit, "aria-label": labels.ariaLabel, className: "flex-shrink-0", children: _jsx("svg", { className: "w-4 h-4", "aria-hidden": "true", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: SVG_PATH_SEARCH }) }) })), isInlineOpen && (filteredQuickLinks.length > 0 || query) && (_jsxs(Div, { rounded: "xl", border: "default", shadow: "lg", className: "absolute top-full left-0 right-0 mt-2 overflow-hidden bg-[var(--appkit-color-surface)] [z-index:var(--appkit-z-dropdown)]", onMouseDown: (event) => event.preventDefault(), children: [filteredQuickLinks.length > 0 && (_jsx(Ul, { children: filteredQuickLinks.map((link, index) => renderInlineQuickLink(link, index)) })), query && suggestionsLoading && (_jsx(Div, { padding: "inline", children: _jsx(Text, { variant: "secondary", size: "sm", children: labels.searching }) })), query &&
|
|
220
220
|
suggestions.slice(0, 5).map((suggestion, suggestionIndex) => {
|
|
221
221
|
const itemIndex = inlineQuickLinkItems.length + suggestionIndex;
|
|
222
222
|
const isActive = activeIndex === itemIndex;
|
|
@@ -292,7 +292,7 @@ export function Search({ isOpen, onClose, onSearch, onOpen, value, onChange, pla
|
|
|
292
292
|
};
|
|
293
293
|
if (!isOpen)
|
|
294
294
|
return null;
|
|
295
|
-
return (_jsx(Div, { border: "bottom-subtle", className: "animate-[fadeIn_150ms_ease-out]", surface: "default", shadow: "md", children: _jsxs(Div, { className: "container mx-auto md:py-[1rem] relative", padding: "inline", children: [_jsxs(Row, { gap: "sm", className: "md:gap-[0.75rem]", children: [_jsx(Div, { className: "flex-1 relative min-w-0", children: _jsx(Input, { ref: inputRef, bare: true, type: "search", placeholder: placeholder ?? labels.placeholder, value: query, onChange: (event) => setQuery(event.target.value), onKeyDown: handleOverlayKeyDown, className: "w-full rounded-lg border
|
|
295
|
+
return (_jsx(Div, { border: "bottom-subtle", className: "animate-[fadeIn_150ms_ease-out]", surface: "default", shadow: "md", children: _jsxs(Div, { className: "container mx-auto md:py-[1rem] relative", padding: "inline", children: [_jsxs(Row, { gap: "sm", className: "md:gap-[0.75rem]", children: [_jsx(Div, { className: "flex-1 relative min-w-0", children: _jsx(Input, { ref: inputRef, bare: true, type: "search", placeholder: placeholder ?? labels.placeholder, value: query, onChange: (event) => setQuery(event.target.value), onKeyDown: handleOverlayKeyDown, className: "w-full rounded-lg border bg-[var(--appkit-color-surface)] text-zinc-900 placeholder:text-zinc-400 focus:border-primary focus:ring-2 focus:ring-primary/20 border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] dark:text-white dark:placeholder:text-slate-500" }) }), resourceTypes && resourceTypes.length > 0 && (_jsx(Select, { options: resourceTypes, value: selectedType, onValueChange: handleTypeChange, "aria-label": labels.resourceTypeLabel ?? "Search in", wrapperClassName: "flex-shrink-0 max-w-[92px] sm:max-w-[150px]" })), _jsxs(Button, { gap: "md", onClick: handleOverlaySearch, variant: "primary", size: "md", className: "hidden sm:", children: [_jsx("svg", { className: "w-5 h-5", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: SVG_PATH_SEARCH }) }), labels.title] }), _jsx(Button, { rounded: "xl", variant: "ghost", onClick: () => onClose?.(), className: "p-[var(--appkit-space-2-5)] md:p-[var(--appkit-space-3)] transition-colors text-[var(--appkit-color-text-muted)] hover:text-zinc-700 text-[var(--appkit-color-text-muted)] hover:bg-surface-hover hover:text-[var(--appkit-color-text-muted)] flex-shrink-0", "aria-label": labels.closeAriaLabel, children: _jsx("svg", { className: "w-6 h-6 md:w-7 md:h-7", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M6 18L18 6M6 6l12 12" }) }) })] }), (filteredSiteLinks.length > 0 || query) && (_jsxs(Stack, { className: "absolute top-full left-0 right-0 z-50 pt-[0.5rem] pb-[1rem]", padding: "x-md", gap: "sm", children: [filteredSiteLinks.length > 0 && !suggestionsLoading && (_jsxs(Div, { className: `${__O.hidden}`, rounded: "xl", shadow: "lg", surface: "default", border: "default", children: [_jsx(Div, { border: "default", className: "border-b", paddingY: "y-xs", paddingX: "x-md", children: _jsx(Text, { variant: "secondary", size: "xs", className: "uppercase tracking-wider", weight: "semibold", children: labels.quickLinks }) }), _jsx(Ul, { children: filteredSiteLinks.map((link) => {
|
|
296
296
|
const Icon = link.icon;
|
|
297
297
|
const itemIndex = quickLinkItems.findIndex((item) => item.link.href === link.href);
|
|
298
298
|
const isActive = activeIndex === itemIndex;
|
|
@@ -31,5 +31,5 @@ export function SearchResultsSection({ products, total, totalPages, urlQ, urlSor
|
|
|
31
31
|
urlPage,
|
|
32
32
|
totalPages,
|
|
33
33
|
onPageChange,
|
|
34
|
-
})) : (_jsxs(Row, { align: "center", justify: "center", gap: "sm", padding: "t-md", children: [_jsx(Button, { rounded: "lg", type: "button", variant: "outline", size: "sm", onClick: () => onPageChange(urlPage - 1), disabled: urlPage <= 1, className: "px-[var(--appkit-space-4)] py-[var(--appkit-space-2)] border border-[var(--appkit-color-border)] text-[length:var(--appkit-text-sm)] text-[var(--appkit-color-text-muted)] disabled:opacity-40 hover:bg-
|
|
34
|
+
})) : (_jsxs(Row, { align: "center", justify: "center", gap: "sm", padding: "t-md", children: [_jsx(Button, { rounded: "lg", type: "button", variant: "outline", size: "sm", onClick: () => onPageChange(urlPage - 1), disabled: urlPage <= 1, className: "px-[var(--appkit-space-4)] py-[var(--appkit-space-2)] border border-[var(--appkit-color-border)] text-[length:var(--appkit-text-sm)] text-[var(--appkit-color-text-muted)] disabled:opacity-40 hover:bg-surface-hover transition-colors", children: L.prevPage }), _jsxs(Span, { size: "sm", className: "tabular-nums", color: "muted", children: [urlPage, " / ", totalPages] }), _jsx(Button, { rounded: "lg", type: "button", variant: "outline", size: "sm", onClick: () => onPageChange(urlPage + 1), disabled: urlPage >= totalPages, className: "px-[var(--appkit-space-4)] py-[var(--appkit-space-2)] border border-[var(--appkit-color-border)] text-[length:var(--appkit-text-sm)] text-[var(--appkit-color-text-muted)] disabled:opacity-40 hover:bg-surface-hover transition-colors", children: L.nextPage })] })))] }));
|
|
35
35
|
}
|