@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
|
@@ -1,8 +1,23 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
currency: string;
|
|
1
|
+
import { type PlaceBidFormClientProps } from "./PlaceBidFormClient";
|
|
2
|
+
export interface AuctionBottomActionsProps extends PlaceBidFormClientProps {
|
|
4
3
|
bidCount: number;
|
|
5
|
-
isEnded: boolean;
|
|
6
|
-
auctionEndDate: Date | null;
|
|
7
4
|
}
|
|
8
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Mobile/after-scroll CTA bar for an auction.
|
|
7
|
+
*
|
|
8
|
+
* 🛑 It owns its own modal rather than pointing at one.
|
|
9
|
+
*
|
|
10
|
+
* Two defects lived here. (a) The single Place Bid action ran
|
|
11
|
+
* `getElementById("auction-bid-form")?.scrollIntoView(...)` — but that id is
|
|
12
|
+
* set inside `renderBidForm`, which `AuctionDetailView` wraps in
|
|
13
|
+
* `hidden lg:block`. On mobile the target is `display:none`, so the bar's only
|
|
14
|
+
* action did nothing on the only viewport the bar exists for. A DOM id is not
|
|
15
|
+
* a contract; the element it names can be hidden, moved or removed without any
|
|
16
|
+
* signal here. (b) There was no Buy Now action at all, even though
|
|
17
|
+
* `MOBILE_PRIMARY_ACTIONS.auction` has declared `[PLACE_BID, BUY_NOW_AUCTION]`
|
|
18
|
+
* for a long time — a declared CTA nothing rendered (Root Cause #8's shape).
|
|
19
|
+
*
|
|
20
|
+
* Both actions now open the real form, which is the only place the Buy Now
|
|
21
|
+
* button can report an error or route to `/checkout?lane=auction`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function AuctionBottomActions(props: AuctionBottomActionsProps): import("react").JSX.Element;
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useState } from "react";
|
|
2
4
|
import { useBottomActions } from "../../layout/hooks/useBottomActions";
|
|
3
5
|
import { formatCurrency } from "../../../utils/number.formatter";
|
|
4
6
|
import { ACTION_ID, ACTION_META } from "../../products/constants/action-defs";
|
|
5
7
|
import { useCountdown } from "../../../react";
|
|
8
|
+
import { Modal } from "../../../ui";
|
|
9
|
+
import { isBuyNowAvailable } from "../../../_internal/shared/features/auctions/config";
|
|
10
|
+
import { PlaceBidFormClient, } from "./PlaceBidFormClient";
|
|
6
11
|
function formatCountdownLabel(remaining) {
|
|
7
12
|
if (!remaining)
|
|
8
13
|
return undefined;
|
|
@@ -13,25 +18,51 @@ function formatCountdownLabel(remaining) {
|
|
|
13
18
|
return `Ends in ${hours}h ${minutes}m ${seconds}s`;
|
|
14
19
|
return `Ends in ${minutes}m ${seconds}s`;
|
|
15
20
|
}
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Mobile/after-scroll CTA bar for an auction.
|
|
23
|
+
*
|
|
24
|
+
* 🛑 It owns its own modal rather than pointing at one.
|
|
25
|
+
*
|
|
26
|
+
* Two defects lived here. (a) The single Place Bid action ran
|
|
27
|
+
* `getElementById("auction-bid-form")?.scrollIntoView(...)` — but that id is
|
|
28
|
+
* set inside `renderBidForm`, which `AuctionDetailView` wraps in
|
|
29
|
+
* `hidden lg:block`. On mobile the target is `display:none`, so the bar's only
|
|
30
|
+
* action did nothing on the only viewport the bar exists for. A DOM id is not
|
|
31
|
+
* a contract; the element it names can be hidden, moved or removed without any
|
|
32
|
+
* signal here. (b) There was no Buy Now action at all, even though
|
|
33
|
+
* `MOBILE_PRIMARY_ACTIONS.auction` has declared `[PLACE_BID, BUY_NOW_AUCTION]`
|
|
34
|
+
* for a long time — a declared CTA nothing rendered (Root Cause #8's shape).
|
|
35
|
+
*
|
|
36
|
+
* Both actions now open the real form, which is the only place the Buy Now
|
|
37
|
+
* button can report an error or route to `/checkout?lane=auction`.
|
|
38
|
+
*/
|
|
39
|
+
export function AuctionBottomActions(props) {
|
|
40
|
+
const { currentBid, currency, bidCount, isEnded, auctionEndDate, buyNowPrice } = props;
|
|
41
|
+
const [open, setOpen] = useState(false);
|
|
18
42
|
const remaining = useCountdown(auctionEndDate ?? undefined);
|
|
43
|
+
const placeBidMeta = ACTION_META[ACTION_ID.PLACE_BID];
|
|
44
|
+
const buyNowMeta = ACTION_META[ACTION_ID.BUY_NOW_AUCTION];
|
|
45
|
+
// Same single predicate the form and the card use. `!!props.onBuyNow` matters
|
|
46
|
+
// because the preview/demo mount passes no action.
|
|
47
|
+
const buyNowAvailable = isBuyNowAvailable({ buyNowPrice, currentBid, isEnded }) && !!props.onBuyNow;
|
|
19
48
|
useBottomActions(isEnded
|
|
20
49
|
? {}
|
|
21
50
|
: {
|
|
22
51
|
actions: [
|
|
23
|
-
{
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
52
|
+
{ ...placeBidMeta, onClick: () => setOpen(true) },
|
|
53
|
+
...(buyNowAvailable && buyNowPrice !== null
|
|
54
|
+
? [
|
|
55
|
+
{
|
|
56
|
+
...buyNowMeta,
|
|
57
|
+
label: `Buy Now — ${formatCurrency(buyNowPrice, currency)}`,
|
|
58
|
+
onClick: () => setOpen(true),
|
|
59
|
+
},
|
|
60
|
+
]
|
|
61
|
+
: []),
|
|
31
62
|
],
|
|
32
63
|
secondaryLabel: formatCountdownLabel(remaining),
|
|
33
64
|
infoLabel: `${formatCurrency(currentBid, currency)} · ${bidCount} bid${bidCount !== 1 ? "s" : ""}`,
|
|
34
65
|
desktop: "after-scroll",
|
|
35
66
|
});
|
|
36
|
-
return
|
|
67
|
+
return (_jsx(Modal, { isOpen: open, onClose: () => setOpen(false), size: "md", title: "Place your bid", children: _jsx(PlaceBidFormClient, { ...props }) }));
|
|
37
68
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ProductFeatureDocument } from "../../products/schemas/product-features";
|
|
2
|
-
import type { PlaceBidInput } from "./PlaceBidFormClient";
|
|
2
|
+
import type { PlaceBidInput, BidActionEnvelope, BuyNowSuccess } from "./PlaceBidFormClient";
|
|
3
3
|
export interface AuctionDetailPageViewProps {
|
|
4
4
|
id: string;
|
|
5
5
|
/**
|
|
@@ -9,8 +9,13 @@ export interface AuctionDetailPageViewProps {
|
|
|
9
9
|
* When absent, falls back to fetching by slug/id (backward compat).
|
|
10
10
|
*/
|
|
11
11
|
initialAuction?: import("../../products/schemas/firestore").ProductDocument | null;
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Typed against the single `ActionResult` envelope — see the note on
|
|
14
|
+
* `BidActionEnvelope`. `Promise<unknown>` here is what let a doubly-wrapped
|
|
15
|
+
* result flow through to a client that read the wrong `ok`.
|
|
16
|
+
*/
|
|
17
|
+
onPlaceBid?: (input: PlaceBidInput) => Promise<BidActionEnvelope<unknown>>;
|
|
18
|
+
onBuyNow?: () => Promise<BidActionEnvelope<BuyNowSuccess>>;
|
|
14
19
|
/** SSR-loaded productFeatures (platform + store-scope). See ProductDetailPageView for semantics. */
|
|
15
20
|
productFeatures?: ProductFeatureDocument[];
|
|
16
21
|
}
|
|
@@ -46,7 +46,7 @@ function toDescriptionHtml(raw) {
|
|
|
46
46
|
}
|
|
47
47
|
function renderAuctionInfoPanel(props) {
|
|
48
48
|
const { productId, title, currentBid, currency, bidCount, isEnded, endDate, buyNowPrice, buyNowAvailable, featured, freeShipping, condition, category, categoryName, brand, brandSlug, productFeatures, features, descriptionHtml, safeSeller, storeHref } = props;
|
|
49
|
-
return (_jsxs(Stack, { gap: "md", children: [_jsxs(Div, { children: [_jsxs(Row, { gap: "xs", wrap: true, className: "mb-2", children: [_jsx(Span, { size: "xs", weight: "semibold", className: CLS_LIVE_BADGE, children: "\uD83C\uDFF7\uFE0F Live Auction" }), isEnded ? (_jsx(Span, { color: "error", surface: "danger-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: "Ended" })) : (_jsx(Span, { color: "success", surface: "success-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: "Active" }))] }), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title })] }), _jsxs(Div, { children: [_jsx(Text, { className: "mb-0.5", color: "muted", size: "xs", children: "Current bid" }), _jsx(LiveBidPrice, { productId: productId, currentBid: currentBid, bidCount: bidCount, currency: currency, isEnded: isEnded, priceSize: "2xl", badgeSize: "sm" }), endDate && !isEnded && (_jsxs(Text, { className: "mt-1.5", color: "muted", size: "sm", children: ["Ends in ", _jsx(Span, { weight: "medium", color: "muted", children: _jsx(CountdownDisplay, { targetDate: endDate, format: "auto", expiredLabel: "Ended" }) })] })), endDate && _jsxs(Text, { className: "mt-0.5", color: "faint", size: "xs", children: [isEnded ? "Ended" : "Ends", " ", endDate.toLocaleString()] })] }), buyNowAvailable && buyNowPrice !== null && (_jsxs(Row, { align: "center", gap: "sm", className: "border border-[var(--appkit-color-primary-200)] dark:border-[var(--appkit-color-primary-800)] bg-primary-50 dark:bg-primary-900/20", padding: "inlineSm", rounded: "lg", children: [_jsx(Span, { size: "xs", color: "muted", children: "Buy Now:" }), _jsx(Span, { size: "base", weight: "bold", className: "text-primary-700 dark:text-primary-300", children: formatCurrency(buyNowPrice, currency) })] })), _jsx(ProductFeatureBadges, { featured: featured, freeShipping: freeShipping, condition: condition ?? undefined, labels: { featured: "Featured", fasterDelivery: "Faster Delivery", ratedSeller: "Rated Seller", condition: "Condition", conditionNew: "New", conditionUsed: "Used", conditionBroken: "For Parts", conditionRefurbished: "Refurbished", returnable: "Returnable", freeShipping: "Free Shipping", codAvailable: "Cash on Delivery", emiAvailable: "EMI Available", wishlistCount: (n) => `${n} wishlisted`, categoryProductCount: (n, cat) => `${n} in ${cat}` } }), (categoryName || category || brand) && (_jsxs(Row, { gap: "sm", wrap: true, children: [category && _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), className: "inline-flex items-center rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] px-[var(--appkit-space-2-5)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-medium text-[var(--appkit-color-text-muted)] transition-colors hover:border-primary-300 hover:bg-primary-
|
|
49
|
+
return (_jsxs(Stack, { gap: "md", children: [_jsxs(Div, { children: [_jsxs(Row, { gap: "xs", wrap: true, className: "mb-2", children: [_jsx(Span, { size: "xs", weight: "semibold", className: CLS_LIVE_BADGE, children: "\uD83C\uDFF7\uFE0F Live Auction" }), isEnded ? (_jsx(Span, { color: "error", surface: "danger-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: "Ended" })) : (_jsx(Span, { color: "success", surface: "success-surface", size: "xs", weight: "medium", className: "inline-block", padding: "pill-sm", rounded: "full", children: "Active" }))] }), _jsx(Heading, { level: 1, className: "leading-snug", smSize: "2xl", color: "primary", size: "xl", weight: "bold", children: title })] }), _jsxs(Div, { children: [_jsx(Text, { className: "mb-0.5", color: "muted", size: "xs", children: "Current bid" }), _jsx(LiveBidPrice, { productId: productId, currentBid: currentBid, bidCount: bidCount, currency: currency, isEnded: isEnded, priceSize: "2xl", badgeSize: "sm" }), endDate && !isEnded && (_jsxs(Text, { className: "mt-1.5", color: "muted", size: "sm", children: ["Ends in ", _jsx(Span, { weight: "medium", color: "muted", children: _jsx(CountdownDisplay, { targetDate: endDate, format: "auto", expiredLabel: "Ended" }) })] })), endDate && _jsxs(Text, { className: "mt-0.5", color: "faint", size: "xs", children: [isEnded ? "Ended" : "Ends", " ", endDate.toLocaleString()] })] }), buyNowAvailable && buyNowPrice !== null && (_jsxs(Row, { align: "center", gap: "sm", className: "border border-[var(--appkit-color-primary-200)] dark:border-[var(--appkit-color-primary-800)] bg-primary-50 dark:bg-primary-900/20", padding: "inlineSm", rounded: "lg", children: [_jsx(Span, { size: "xs", color: "muted", children: "Buy Now:" }), _jsx(Span, { size: "base", weight: "bold", className: "text-primary-700 dark:text-primary-300", children: formatCurrency(buyNowPrice, currency) }), _jsx(Span, { size: "xs", color: "faint", children: "\u2014 in \u201CPlace a bid\u201D" })] })), _jsx(ProductFeatureBadges, { featured: featured, freeShipping: freeShipping, condition: condition ?? undefined, labels: { featured: "Featured", fasterDelivery: "Faster Delivery", ratedSeller: "Rated Seller", condition: "Condition", conditionNew: "New", conditionUsed: "Used", conditionBroken: "For Parts", conditionRefurbished: "Refurbished", returnable: "Returnable", freeShipping: "Free Shipping", codAvailable: "Cash on Delivery", emiAvailable: "EMI Available", wishlistCount: (n) => `${n} wishlisted`, categoryProductCount: (n, cat) => `${n} in ${cat}` } }), (categoryName || category || brand) && (_jsxs(Row, { gap: "sm", wrap: true, children: [category && _jsx(Link, { href: String(ROUTES.PUBLIC.CATEGORY_DETAIL(category)), className: "inline-flex items-center rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] px-[var(--appkit-space-2-5)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-medium text-[var(--appkit-color-text-muted)] transition-colors hover:border-primary-300 hover:bg-primary-surface hover:text-primary-700 dark:hover:border-primary-700/60 dark:hover:text-primary-400", children: categoryName || category }), !category && categoryName && _jsx(Span, { layout: "inline-flex", size: "xs", weight: "medium", border: "subtle", rounded: "full", padding: "pill-sm-tall", surface: "muted", color: "muted", children: categoryName }), brand && brandSlug && _jsx(Link, { href: String(ROUTES.PUBLIC.BRAND_DETAIL(brandSlug)), className: "inline-flex items-center rounded-full border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] px-[var(--appkit-space-2-5)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-medium text-[var(--appkit-color-text-muted)] transition-colors hover:border-primary-300 hover:bg-primary-surface hover:text-primary-700 dark:hover:border-primary-700/60 dark:hover:text-primary-400", children: brand }), brand && !brandSlug && _jsx(Span, { layout: "inline-flex", size: "xs", weight: "medium", border: "subtle", rounded: "full", padding: "pill-sm-tall", surface: "muted", color: "muted", children: brand })] })), productFeatures && features.length > 0 && _jsx(FeatureBadgeList, { productFeatureIds: features, features: productFeatures }), !productFeatures && features.length > 0 && (_jsxs(Div, { border: "subtle", surface: "muted", padding: "inline", rounded: "xl", children: [_jsx(Text, { className: "mb-2 tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "About this item" }), _jsx(Ul, { spacing: "comfortable", size: "sm", color: "primary", children: features.map((f, i) => _jsxs(Li, { layout: "flex-start", gap: "2", children: [_jsx(Span, { className: "mt-0.5 flex-shrink-0 text-primary-500", children: "\u2022" }), f] }, i)) })] })), descriptionHtml && _jsx(RichText, { html: descriptionHtml, 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)] line-clamp-4" }), safeSeller && (_jsx(Div, { className: `${__P.p3}`, border: "subtle", rounded: "xl", surface: "muted", children: _jsxs(Row, { justify: "between", align: "center", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[10px] tracking-wide mb-0.5", color: "faint", transform: "uppercase", children: "Listed by" }), _jsx(Text, { size: "sm", weight: "semibold", color: "primary", children: safeSeller })] }), storeHref && _jsx(Link, { href: storeHref, className: "shrink-0 rounded-lg bg-primary/10 dark:bg-primary/20 px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-semibold text-primary-700 dark:text-primary-300 hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors", children: "Visit Store \u2192" })] }) }))] }));
|
|
50
50
|
}
|
|
51
51
|
/**
|
|
52
52
|
* Store reviews for the auction's seller.
|
|
@@ -204,5 +204,5 @@ export async function AuctionDetailPageView({ id, initialAuction, onPlaceBid, on
|
|
|
204
204
|
slug: typeof r.slug === "string" ? r.slug : undefined,
|
|
205
205
|
}));
|
|
206
206
|
return (_jsxs(Stack, { gap: "xl", children: [related.length > 0 && (_jsx(RelatedProducts, { labels: { title: "Similar Auctions" }, renderGrid: () => (_jsx(MarketplaceAuctionGrid, { auctions: related, gridClassName: "grid grid-cols-2 gap-[var(--appkit-space-4)] sm:grid-cols-3 lg:grid-cols-4" })) })), _jsx(GroupedListingsCarousel, { groups: groups }), _jsx(RelatedItemsSection, { relatedItems: relatedItems, relatedByBrand: relatedByBrand, relatedByTags: relatedByTags, relatedByStore: relatedByStore, categoryLabel: categoryName || category || undefined, brandLabel: brand || undefined, storeLabel: storeName || undefined })] }));
|
|
207
|
-
} }), renderAuctionStoreReviews(storeId), _jsx(AuctionBottomActions, { currentBid: currentBid,
|
|
207
|
+
} }), renderAuctionStoreReviews(storeId), onPlaceBid && (_jsx(AuctionBottomActions, { productId: String(product.id), currentBid: currentBid, startingBid: startingBid, tiers: bidIncrementTiers, minBidIncrementOverride: minBidIncrementOverride, currency: currency, isEnded: isEnded, auctionEndDate: endDate, buyNowPrice: buyNowPrice, bidCount: bidCount, tags: tags, onPlaceBid: onPlaceBid, onBuyNow: onBuyNow }))] })] }));
|
|
208
208
|
}
|
|
@@ -3,7 +3,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
3
3
|
import { normalizeError } from "../../../errors/normalize";
|
|
4
4
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
5
5
|
import { useRouter } from "next/navigation";
|
|
6
|
-
import { Clock, Gavel,
|
|
6
|
+
import { Clock, Gavel, Play, ShoppingBag, Star } from "lucide-react";
|
|
7
|
+
import { WishlistHeartButton } from "../../wishlist/components/WishlistHeartButton";
|
|
7
8
|
import { useLongPress } from "../../../react/hooks/useLongPress";
|
|
8
9
|
import { MediaImage } from "../../media";
|
|
9
10
|
import { useWishlistToggle } from "../../wishlist";
|
|
@@ -17,19 +18,17 @@ const CLS_STAR_ICON = "h-5 w-5 fill-warning text-warning";
|
|
|
17
18
|
const CLS_LIVE_BADGE = "rounded-full bg-error-solid px-[var(--appkit-space-2)] py-[var(--appkit-space-0-5)] text-error-on-solid";
|
|
18
19
|
const CLS_ENDING_BADGE = "rounded-full bg-warning-solid px-[var(--appkit-space-2)] py-[var(--appkit-space-0-5)] text-warning-on-solid";
|
|
19
20
|
const CLS_RESERVE_BADGE = "inline-flex items-center gap-[var(--appkit-space-1)] rounded-full bg-warning-solid px-[var(--appkit-space-2)] py-[var(--appkit-space-0-5)] text-warning-on-solid";
|
|
20
|
-
const CLS_HEART_ON = "fill-error text-error";
|
|
21
|
-
const CLS_HEART_OFF = "text-zinc-400";
|
|
22
21
|
function renderAuctionCardHero(props) {
|
|
23
22
|
const { product, auctionHref, currentSrc, hovered, images, imageIndex, hasVideo, isEnded, isEndingSoon, isSelected, selectable, mergedLabels, onSelect, setHovered, handleSelect } = props;
|
|
24
23
|
return (_jsxs(BaseListingCard.Hero, { aspect: "square", variant: "grid", onMouseEnter: () => setHovered(true), onMouseLeave: () => setHovered(false), children: [_jsx(TextLink, { href: auctionHref ?? "#", className: "absolute inset-0 block", children: _jsx(MediaImage, { src: currentSrc, alt: product.title, size: "card", fallback: "Auction image", className: `transition-transform duration-500 ${hovered && images.length > 1 ? "scale-105" : "scale-100"}` }) }), hasVideo && imageIndex === 0 ? (_jsx(Div, { className: "absolute right-2 top-2", children: _jsx(Span, { surface: "overlay-sm", layout: "flex-center", color: "inverse", className: "h-8 w-8", rounded: "full", children: _jsx(Play, { className: "h-4 w-4", "aria-label": mergedLabels.videoLabel }) }) })) : null, images.length > 1 ? (_jsx(Row, { gap: "xs", className: "pointer-events-none absolute bottom-2 left-1/2 -translate-x-1/2", children: images.map((_, index) => (_jsx(Span, { className: `transition-all duration-200 ${index === imageIndex ? "h-1.5 w-3 bg-[white]" : "h-1.5 w-1.5 bg-[white]/60"}`, rounded: "full" }, `${product.id}-image-${index}`))) })) : null, product.featured ? (_jsx(Div, { className: "pointer-events-none absolute left-2 top-2 z-10", children: _jsx(Star, { className: CLS_STAR_ICON }) })) : null, onSelect && (_jsx(BaseListingCard.Checkbox, { selected: isSelected, onSelect: handleSelect, label: isSelected ? mergedLabels.deselectItem : mergedLabels.selectItem, className: selectable || isSelected ? "opacity-100" : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity" })), _jsxs(Stack, { className: `absolute left-2 z-10 ${product.featured ? "top-8" : "top-2"}`, gap: "xs", children: [!isEnded ? _jsx(Span, { size: "xs", weight: "bold", className: CLS_LIVE_BADGE, children: mergedLabels.liveBadge }) : null, isEndingSoon ? _jsx(Span, { size: "xs", weight: "bold", className: CLS_ENDING_BADGE, children: mergedLabels.endingSoon }) : null, isEnded ? _jsx(Span, { color: "inverse", size: "xs", weight: "bold", surface: "overlay-lg", rounded: "full", padding: "pill-xs", children: mergedLabels.ended }) : null, product.status === "sold" ? _jsx(Span, { color: "inverse", size: "xs", weight: "bold", surface: "overlay-xl", rounded: "full", padding: "pill-xs", children: mergedLabels.sold }) : null] }), _jsx(Div, { className: "pointer-events-none absolute bottom-2 right-2 z-10", children: _jsxs(Span, { size: "xs", weight: "semibold", className: CLS_RESERVE_BADGE, children: [_jsx(Gavel, { className: "h-3 w-3" }), mergedLabels.typeBadge] }) })] }));
|
|
25
24
|
}
|
|
26
25
|
function renderAuctionCardInfoList(props) {
|
|
27
26
|
const { product, auctionHref, displayBid, bidCount, isEnded, remaining, inWishlist, wishlistLoading, wishlistActions, countdownClass, mergedLabels, handleWishlist, handleNavigate } = props;
|
|
28
|
-
return (_jsxs(_Fragment, { children: [_jsxs(Row, { className: "min-w-0", align: "start", justify: "between", gap: "sm", children: [_jsx(TextLink, { href: auctionHref ?? "#", className: `flex-1 leading-snug transition-colors hover:text-primary`, color: "primary", truncate: 2, size: "sm", weight: "medium", children: product.title }), wishlistActions ? (_jsx(
|
|
27
|
+
return (_jsxs(_Fragment, { children: [_jsxs(Row, { className: "min-w-0", align: "start", justify: "between", gap: "sm", children: [_jsx(TextLink, { href: auctionHref ?? "#", className: `flex-1 leading-snug transition-colors hover:text-primary`, color: "primary", truncate: 2, size: "sm", weight: "medium", children: product.title }), wishlistActions ? (_jsx(WishlistHeartButton, { size: "inline", inWishlist: inWishlist, isLoading: wishlistLoading, addLabel: mergedLabels.addToWishlist, removeLabel: mergedLabels.removeFromWishlist, onToggle: handleWishlist })) : null] }), _jsxs(Row, { align: "center", gap: "sm", wrap: true, children: [_jsx(Text, { className: "text-primary", size: "sm", weight: "bold", children: formatCurrency(displayBid, getDefaultCurrency()) }), _jsxs(Div, { layout: "inline-flex", gap: "1", textWeight: "semibold", className: `items-[center] py-[0.125rem] text-[11px] ${countdownClass}`, padding: "x-xs", rounded: "full", children: [_jsx(Clock, { className: "h-2.5 w-2.5" }), _jsx(Span, { children: formatCountdownLabel(remaining, mergedLabels.ended) })] }), bidCount > 0 && _jsx(Caption, { className: "text-[11px]", children: mergedLabels.totalBids(bidCount) })] }), !isEnded && (_jsxs(Button, { gap: "xs", type: "button", variant: "warning", size: "sm", textSize: "xs", className: "self-start px-[0.625rem] mt-0.5", onClick: handleNavigate, children: [_jsx(Gavel, { className: "h-3 w-3" }), _jsx(Span, { children: mergedLabels.placeBid })] }))] }));
|
|
29
28
|
}
|
|
30
29
|
function renderAuctionCardInfoGrid(props) {
|
|
31
30
|
const { product, auctionHref, displayBid, bidCount, hasCurrentBid, isEnded, remaining, resolvedBuyoutPrice, inWishlist, wishlistLoading, wishlistActions, countdownClass, mergedLabels, handleWishlist, handleNavigate } = props;
|
|
32
|
-
return (_jsxs(_Fragment, { children: [_jsxs(Row, { align: "start", gap: "sm", children: [_jsx(TextLink, { href: auctionHref ?? "#", className: "min-w-0 flex-1 leading-snug transition-colors hover:text-primary", color: "primary", size: "sm", weight: "medium", children: product.title }), wishlistActions ? (_jsx(
|
|
31
|
+
return (_jsxs(_Fragment, { children: [_jsxs(Row, { align: "start", gap: "sm", children: [_jsx(TextLink, { href: auctionHref ?? "#", className: "min-w-0 flex-1 leading-snug transition-colors hover:text-primary", color: "primary", size: "sm", weight: "medium", children: product.title }), wishlistActions ? (_jsx(WishlistHeartButton, { size: "inline", inWishlist: inWishlist, isLoading: wishlistLoading, addLabel: mergedLabels.addToWishlist, removeLabel: mergedLabels.removeFromWishlist, onToggle: handleWishlist })) : null] }), _jsxs(Div, { children: [_jsx(Caption, { children: isEnded && hasCurrentBid ? mergedLabels.winningBid : hasCurrentBid ? mergedLabels.currentBid : mergedLabels.startingBid }), _jsx(Text, { className: "leading-none text-primary", size: "base", weight: "bold", children: formatCurrency(displayBid, getDefaultCurrency()) }), isEnded && product.winnerDisplayName && (_jsxs(Caption, { className: "mt-0.5", color: "muted", children: [mergedLabels.wonBy, ": ", maskDisplayName(product.winnerDisplayName)] }))] }), _jsxs(Row, { wrap: true, justify: "between", gap: "sm", className: "gap-x-2 gap-y-1", children: [_jsxs(Div, { layout: "inline-flex", gap: "1", textWeight: "semibold", textSize: "xs", className: `items-[center] ${countdownClass}`, paddingY: "y-2xs", paddingX: "x-xs", rounded: "full", children: [_jsx(Clock, { className: "h-3 w-3" }), _jsx(Span, { children: formatCountdownLabel(remaining, mergedLabels.ended) })] }), _jsx(Caption, { children: mergedLabels.totalBids(bidCount) })] }), _jsx(Row, { wrap: true, gap: "xs", className: "mt-auto", children: isEnded ? (_jsx(Button, { gap: "xs", type: "button", variant: "ghost", size: "sm", textSize: "xs", paddingX: "xs", className: "flex-1 cursor-not-allowed opacity-60", disabled: true, children: _jsx(Span, { children: mergedLabels.ended }) })) : (_jsxs(_Fragment, { children: [_jsxs(Button, { gap: "xs", type: "button", variant: "warning", size: "sm", textSize: "xs", paddingX: "xs", className: "flex-1", onClick: handleNavigate, children: [_jsx(Gavel, { className: "h-3 w-3" }), _jsx(Span, { children: mergedLabels.placeBid })] }), resolvedBuyoutPrice ? (
|
|
33
32
|
/* `secondary`, not `danger` — this button only navigates to the
|
|
34
33
|
PDP, and `danger` is reserved for destructive actions (Rule #7
|
|
35
34
|
kinds). ACTIONS.AUCTION["buy-it-now"] is itself `secondary`. */
|
|
@@ -5,6 +5,31 @@ export interface PlaceBidInput {
|
|
|
5
5
|
bidAmount: number;
|
|
6
6
|
autoMaxBid?: number;
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* The `ActionResult` envelope every server action in this app returns.
|
|
10
|
+
*
|
|
11
|
+
* 🛑 Exactly ONE level. `onPlaceBid`/`onBuyNow` used to be typed
|
|
12
|
+
* `Promise<unknown>`, and the consumer's actions returned a *doubly*-wrapped
|
|
13
|
+
* `{ ok: true, data: { ok: false, error } }`. Reading the outer `ok` therefore
|
|
14
|
+
* saw `true` for every possible outcome — including every failure — so Buy Now
|
|
15
|
+
* placed a real bid and a real locked cart line and then silently
|
|
16
|
+
* `router.refresh()`ed a page that renders nothing from the cart. Typing these
|
|
17
|
+
* props is what makes a re-introduced second envelope a compile error rather
|
|
18
|
+
* than a silent no-op button.
|
|
19
|
+
*/
|
|
20
|
+
export type BidActionEnvelope<T> = {
|
|
21
|
+
ok: true;
|
|
22
|
+
data: T;
|
|
23
|
+
} | {
|
|
24
|
+
ok: false;
|
|
25
|
+
error: string;
|
|
26
|
+
code?: string;
|
|
27
|
+
issues?: unknown[];
|
|
28
|
+
};
|
|
29
|
+
/** The subset of `BuyNowAuctionResult` this component actually consumes. */
|
|
30
|
+
export interface BuyNowSuccess {
|
|
31
|
+
checkoutUrl?: string;
|
|
32
|
+
}
|
|
8
33
|
export interface PlaceBidFormClientProps {
|
|
9
34
|
productId: string;
|
|
10
35
|
currentBid: number;
|
|
@@ -17,8 +42,8 @@ export interface PlaceBidFormClientProps {
|
|
|
17
42
|
buyNowPrice: number | null;
|
|
18
43
|
bidCount: number;
|
|
19
44
|
tags?: string[];
|
|
20
|
-
onPlaceBid: (input: PlaceBidInput) => Promise<unknown
|
|
21
|
-
onBuyNow?: () => Promise<
|
|
45
|
+
onPlaceBid: (input: PlaceBidInput) => Promise<BidActionEnvelope<unknown>>;
|
|
46
|
+
onBuyNow?: () => Promise<BidActionEnvelope<BuyNowSuccess>>;
|
|
22
47
|
}
|
|
23
48
|
export declare function PlaceBidFormClient({ productId, currentBid: ssrCurrentBid, startingBid, tiers, minBidIncrementOverride, currency, isEnded, auctionEndDate, buyNowPrice, bidCount: ssrBidCount, tags, onPlaceBid, onBuyNow, }: PlaceBidFormClientProps): React.JSX.Element;
|
|
24
49
|
/**
|
|
@@ -48,6 +48,12 @@ export function PlaceBidFormClient({ productId, currentBid: ssrCurrentBid, start
|
|
|
48
48
|
const [isBuyNowPending, startBuyNowTransition] = useTransition();
|
|
49
49
|
const [success, setSuccess] = useState(false);
|
|
50
50
|
const [showLoginModal, setShowLoginModal] = useState(false);
|
|
51
|
+
/**
|
|
52
|
+
* Buy Now has no field to hang an error off — it is a standalone button, not
|
|
53
|
+
* a form input — so it needs its own slot. Without one, every domain failure
|
|
54
|
+
* (`AUCTION_ENDED`, `BUY_NOW_UNAVAILABLE`, rate-limit) was silent.
|
|
55
|
+
*/
|
|
56
|
+
const [buyNowError, setBuyNowError] = useState(null);
|
|
51
57
|
/** Last amount the form itself wrote — see `seedAmount` below. */
|
|
52
58
|
const lastSeeded = useRef(minBid);
|
|
53
59
|
/**
|
|
@@ -140,43 +146,59 @@ export function PlaceBidFormClient({ productId, currentBid: ssrCurrentBid, start
|
|
|
140
146
|
}
|
|
141
147
|
}
|
|
142
148
|
function bidErrorMessage(result) {
|
|
143
|
-
if (
|
|
144
|
-
return null;
|
|
145
|
-
const r = result;
|
|
146
|
-
if (r.ok !== false)
|
|
149
|
+
if (result.ok)
|
|
147
150
|
return null;
|
|
148
|
-
return (
|
|
151
|
+
return ((result.code && BID_ERROR_DISPLAY[result.code]) ??
|
|
152
|
+
result.error ??
|
|
153
|
+
"Failed to place bid. Please try again.");
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Show a Buy Now failure.
|
|
157
|
+
*
|
|
158
|
+
* Every failure used to be indistinguishable from success and was discarded.
|
|
159
|
+
* An auth failure never THROWS across a server-action boundary — it arrives
|
|
160
|
+
* as a code — so `isAuthError` in the catch below could never fire for it.
|
|
161
|
+
*
|
|
162
|
+
* ONLY `UNAUTHENTICATED` (401) means "sign in". `FORBIDDEN` (403) is "signed
|
|
163
|
+
* in, but not allowed" — bidding on your own auction, or an active
|
|
164
|
+
* `place_bids` soft ban. A login prompt for those tells the buyer to fix
|
|
165
|
+
* something that is not the problem.
|
|
166
|
+
*/
|
|
167
|
+
function reportBuyNowFailure(code, message) {
|
|
168
|
+
if (code === "UNAUTHENTICATED") {
|
|
169
|
+
setShowLoginModal(true);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
setBuyNowError((code && BID_ERROR_DISPLAY[code]) ??
|
|
173
|
+
message ??
|
|
174
|
+
"Buy Now failed. Please try again.");
|
|
149
175
|
}
|
|
150
176
|
function handleBuyNow() {
|
|
151
177
|
if (!onBuyNow)
|
|
152
178
|
return;
|
|
153
179
|
setSuccess(false);
|
|
180
|
+
setBuyNowError(null);
|
|
154
181
|
startBuyNowTransition(async () => {
|
|
155
182
|
try {
|
|
156
183
|
const result = await onBuyNow();
|
|
157
|
-
if (result
|
|
158
|
-
|
|
159
|
-
"ok" in result &&
|
|
160
|
-
result.ok === false) {
|
|
161
|
-
setSuccess(false);
|
|
184
|
+
if (!result.ok) {
|
|
185
|
+
reportBuyNowFailure(result.code, result.error);
|
|
162
186
|
return;
|
|
163
187
|
}
|
|
164
188
|
setSuccess(true);
|
|
165
189
|
// Buy-Now writes a locked cart line, so send the buyer straight to the
|
|
166
|
-
// auction checkout lane to pay for it.
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
router.push(checkoutUrl);
|
|
190
|
+
// auction checkout lane to pay for it. `buyNowAuction` deliberately
|
|
191
|
+
// never touches the product document, so falling through to
|
|
192
|
+
// `router.refresh()` re-renders a byte-identical page — which is
|
|
193
|
+
// exactly what "the button does nothing" looked like.
|
|
194
|
+
if (result.data.checkoutUrl)
|
|
195
|
+
router.push(result.data.checkoutUrl);
|
|
173
196
|
else
|
|
174
197
|
router.refresh();
|
|
175
198
|
}
|
|
176
199
|
catch (err) {
|
|
177
200
|
void normalizeError(err);
|
|
178
|
-
|
|
179
|
-
setShowLoginModal(true);
|
|
201
|
+
reportBuyNowFailure(isAuthError(err) ? "UNAUTHENTICATED" : undefined, err instanceof Error ? err.message : undefined);
|
|
180
202
|
}
|
|
181
203
|
});
|
|
182
204
|
}
|
|
@@ -212,7 +234,7 @@ export function PlaceBidFormClient({ productId, currentBid: ssrCurrentBid, start
|
|
|
212
234
|
return applyZodIssues(parsed.error.issues, setFieldError);
|
|
213
235
|
setSuccess(false);
|
|
214
236
|
startTransition(() => submitBid(parsed.data.bidAmount, setFieldError));
|
|
215
|
-
}, children: isEnded ? "Auction Ended" : "Place Bid" }), buyNowAvailable && (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", size: "md", className: "w-full", type: "button", disabled: isBuyNowPending || isPending, isLoading: isBuyNowPending, onClick: handleBuyNow, children: `Buy Now — ${formatCurrency(buyNowPrice, currency)}` }), _jsxs(Text, { align: "center", size: "xs", color: "muted", children: ["Skips the bidding. The auction stays live until you pay \u2014 complete checkout within ", AUCTION_BUYOUT_WINDOW_MINUTES, " minutes."] })] }))] })) }), tags.length > 0 && (_jsx(Div, { border: "default", className: "border-t", padding: "t-md", children: _jsx(Row, { wrap: true, gap: "xs", children: tags.map((tag) => (_jsx(Span, { rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", size: "xs", children: tag }, tag))) }) }))] }));
|
|
237
|
+
}, children: isEnded ? "Auction Ended" : "Place Bid" }), buyNowAvailable && (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", size: "md", className: "w-full", type: "button", disabled: isBuyNowPending || isPending, isLoading: isBuyNowPending, onClick: handleBuyNow, children: `Buy Now — ${formatCurrency(buyNowPrice, currency)}` }), buyNowError && (_jsx(Text, { align: "center", size: "xs", variant: "error", role: "alert", children: buyNowError })), _jsxs(Text, { align: "center", size: "xs", color: "muted", children: ["Skips the bidding. The auction stays live until you pay \u2014 complete checkout within ", AUCTION_BUYOUT_WINDOW_MINUTES, " minutes."] })] }))] })) }), tags.length > 0 && (_jsx(Div, { border: "default", className: "border-t", padding: "t-md", children: _jsx(Row, { wrap: true, gap: "xs", children: tags.map((tag) => (_jsx(Span, { rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", size: "xs", children: tag }, tag))) }) }))] }));
|
|
216
238
|
}
|
|
217
239
|
/**
|
|
218
240
|
* `PlaceBidModalButton` — opens a modal that hosts the same PlaceBidFormClient.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { Button, Div, Row, Span } from "../../../ui";
|
|
3
3
|
export function SocialAuthButtons({ onGoogle, disabled, dividerLabel, googleLabel, }) {
|
|
4
|
-
return (_jsxs(_Fragment, { children: [_jsxs(Div, { className: "relative", children: [_jsx(Row, { className: "absolute inset-0", align: "center", children: _jsx(Div, { className: "w-full border-t border-neutral-200" }) }), _jsx(Row, { textSize: "sm", className: "relative", justify: "center", children: _jsx(Span, { padding: "x-xs", surface: "default", color: "muted", children: dividerLabel }) })] }), _jsx(Div, { layout: "grid", gap: "3", className: "grid-cols-1", children: _jsxs(Button, { rounded: "md", type: "button", onClick: onGoogle, disabled: disabled, variant: "outline", paddingX: "md", paddingY: "sm", textSize: "sm", weight: "medium", className: "inline- justify-[center] border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] bg-[var(--appkit-color-surface)] text-[var(--appkit-color-text)] dark:text-[var(--appkit-color-text)] hover:bg-
|
|
4
|
+
return (_jsxs(_Fragment, { children: [_jsxs(Div, { className: "relative", children: [_jsx(Row, { className: "absolute inset-0", align: "center", children: _jsx(Div, { className: "w-full border-t border-neutral-200" }) }), _jsx(Row, { textSize: "sm", className: "relative", justify: "center", children: _jsx(Span, { padding: "x-xs", surface: "default", color: "muted", children: dividerLabel }) })] }), _jsx(Div, { layout: "grid", gap: "3", className: "grid-cols-1", children: _jsxs(Button, { rounded: "md", type: "button", onClick: onGoogle, disabled: disabled, variant: "outline", paddingX: "md", paddingY: "sm", textSize: "sm", weight: "medium", className: "inline- justify-[center] border border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)] bg-[var(--appkit-color-surface)] text-[var(--appkit-color-text)] dark:text-[var(--appkit-color-text)] hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-50", children: [_jsxs("svg", { className: "mr-2 h-5 w-5", viewBox: "0 0 24 24", "aria-hidden": "true", children: [_jsx("path", { fill: "currentColor", d: "M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" }), _jsx("path", { fill: "currentColor", d: "M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" }), _jsx("path", { fill: "currentColor", d: "M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" }), _jsx("path", { fill: "currentColor", d: "M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" })] }), googleLabel] }) })] }));
|
|
5
5
|
}
|
|
@@ -74,10 +74,32 @@ export declare function useVerifyEmail(options?: {
|
|
|
74
74
|
onSuccess?: (data: JsonValue) => void;
|
|
75
75
|
onError?: (error: Error) => void;
|
|
76
76
|
}): import("@tanstack/react-query").UseMutationResult<JsonValue, Error, VerifyEmailData, unknown>;
|
|
77
|
+
/**
|
|
78
|
+
* Re-send the verification email — Firebase-native, like the send that
|
|
79
|
+
* happens at registration.
|
|
80
|
+
*
|
|
81
|
+
* ## What this used to do, and why it could not work
|
|
82
|
+
*
|
|
83
|
+
* It POSTed `/api/auth/resend-verification`, a route **deleted** in the
|
|
84
|
+
* Firebase-native auth migration. Root Cause #54 is the reason it can never
|
|
85
|
+
* come back: the Admin SDK cannot send email at all — it only *generates*
|
|
86
|
+
* action links — so the only way to deliver a verification mail without
|
|
87
|
+
* routing it through Resend is the CLIENT SDK's own
|
|
88
|
+
* `sendEmailVerification()`, which uses Firebase's hosted template.
|
|
89
|
+
*
|
|
90
|
+
* So every call 404'd, and `useRegister` above says in as many words that
|
|
91
|
+
* "the user can re-request verification later" — a documented recovery path
|
|
92
|
+
* that did not exist. Found by `audit-client-verb-match`'s NO_ROUTE rule.
|
|
93
|
+
*
|
|
94
|
+
* `ResendVerificationData.email` is accepted and unused: the client SDK
|
|
95
|
+
* re-sends for the CURRENTLY signed-in user and takes no address, so honouring
|
|
96
|
+
* an arbitrary email here would be a lie about what happens. Kept in the
|
|
97
|
+
* signature so the two existing call shapes stay source-compatible.
|
|
98
|
+
*/
|
|
77
99
|
export declare function useResendVerification(options?: {
|
|
78
100
|
onSuccess?: (data: JsonValue) => void;
|
|
79
101
|
onError?: (error: Error) => void;
|
|
80
|
-
}): import("@tanstack/react-query").UseMutationResult<JsonValue, Error, ResendVerificationData, unknown>;
|
|
102
|
+
}): import("@tanstack/react-query").UseMutationResult<JsonValue, Error, void | ResendVerificationData, unknown>;
|
|
81
103
|
export declare function useForgotPassword(options?: {
|
|
82
104
|
onSuccess?: (data: JsonValue) => void;
|
|
83
105
|
onError?: (error: Error) => void;
|
|
@@ -337,9 +337,35 @@ export function useVerifyEmail(options) {
|
|
|
337
337
|
onError: options?.onError,
|
|
338
338
|
});
|
|
339
339
|
}
|
|
340
|
+
/**
|
|
341
|
+
* Re-send the verification email — Firebase-native, like the send that
|
|
342
|
+
* happens at registration.
|
|
343
|
+
*
|
|
344
|
+
* ## What this used to do, and why it could not work
|
|
345
|
+
*
|
|
346
|
+
* It POSTed `/api/auth/resend-verification`, a route **deleted** in the
|
|
347
|
+
* Firebase-native auth migration. Root Cause #54 is the reason it can never
|
|
348
|
+
* come back: the Admin SDK cannot send email at all — it only *generates*
|
|
349
|
+
* action links — so the only way to deliver a verification mail without
|
|
350
|
+
* routing it through Resend is the CLIENT SDK's own
|
|
351
|
+
* `sendEmailVerification()`, which uses Firebase's hosted template.
|
|
352
|
+
*
|
|
353
|
+
* So every call 404'd, and `useRegister` above says in as many words that
|
|
354
|
+
* "the user can re-request verification later" — a documented recovery path
|
|
355
|
+
* that did not exist. Found by `audit-client-verb-match`'s NO_ROUTE rule.
|
|
356
|
+
*
|
|
357
|
+
* `ResendVerificationData.email` is accepted and unused: the client SDK
|
|
358
|
+
* re-sends for the CURRENTLY signed-in user and takes no address, so honouring
|
|
359
|
+
* an arbitrary email here would be a lie about what happens. Kept in the
|
|
360
|
+
* signature so the two existing call shapes stay source-compatible.
|
|
361
|
+
*/
|
|
340
362
|
export function useResendVerification(options) {
|
|
341
363
|
return useMutation({
|
|
342
|
-
mutationFn: (
|
|
364
|
+
mutationFn: async () => {
|
|
365
|
+
const authProvider = getClientAuthProvider();
|
|
366
|
+
await authProvider.sendEmailVerification();
|
|
367
|
+
return { success: true };
|
|
368
|
+
},
|
|
343
369
|
onSuccess: options?.onSuccess,
|
|
344
370
|
onError: options?.onError,
|
|
345
371
|
});
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* Permission naming: admin:resource:action
|
|
12
12
|
* Ban action naming: plain verb phrase — write_reviews, place_bids, etc.
|
|
13
13
|
*/
|
|
14
|
-
export type Permission = "admin:dashboard:view" | "admin:users:read" | "admin:users:write" | "admin:users:delete" | "admin:user-bans:read" | "admin:user-bans:write" | "admin:products:read" | "admin:products:write" | "admin:products:delete" | "admin:orders:read" | "admin:orders:write" | "admin:returns:read" | "admin:returns:write" | "admin:stores:read" | "admin:stores:write" | "admin:store-addresses:read" | "admin:addresses:read" | "admin:addresses:write" | "admin:analytics:view" | "admin:payouts:read" | "admin:payouts:write" | "admin:audit-log:read" | "admin:categories:read" | "admin:categories:write" | "admin:categories:delete" | "admin:brands:read" | "admin:brands:write" | "admin:brands:delete" | "admin:coupons:read" | "admin:coupons:write" | "admin:coupons:delete" | "admin:deals:read" | "admin:deals:write" | "admin:featured:read" | "admin:featured:write" | "admin:reviews:read" | "admin:reviews:write" | "admin:reviews:delete" | "admin:blog:read" | "admin:blog:write" | "admin:blog:delete" | "admin:blog:publish" | "admin:bids:read" | "admin:bids:write" | "admin:offers:read" | "admin:offers:write" | "admin:media:read" | "admin:media:write" | "admin:media:delete" | "admin:site:read" | "admin:site:write" | "admin:settings:write" | "admin:navigation:read" | "admin:navigation:write" | "admin:sections:read" | "admin:sections:write" | "admin:carousel:read" | "admin:carousel:write" | "admin:carousel:delete" | "admin:ads:read" | "admin:ads:write" | "admin:ads:delete" | "admin:faqs:read" | "admin:faqs:write" | "admin:faqs:delete" | "admin:tester-checklist:read" | "admin:tester-checklist:write" | "admin:tester-checklist:delete" | "admin:tester-feedback:read" | "admin:tester-feedback:write" | "admin:newsletter:read" | "admin:newsletter:write" | "admin:contact:read" | "admin:events:read" | "admin:events:write" | "admin:events:delete" | "admin:event-entries:read" | "admin:event-entries:write" | "admin:support-tickets:read" | "admin:support-tickets:write" | "admin:shipments:read" | "admin:shipments:write" | "admin:catalogue:read" | "admin:catalogue:write" | "admin:support-tickets:assign" | "admin:support-tickets:close" | "admin:scammers:read" | "admin:scammers:write" | "admin:scammers:verify" | "admin:scammers:delete" | "admin:sessions:read" | "admin:sessions:delete" | "admin:notifications:read" | "admin:notifications:write" | "admin:carts:read" | "admin:wishlists:read" | "admin:feature-flags:read" | "admin:feature-flags:write" | "admin:copilot:view" | "admin:team:read" | "admin:team:write" | "admin:maintenance:view-server-errors" | "admin:maintenance:view-client-errors" | "admin:maintenance:view-function-errors" | "admin:maintenance:view-cloud-logs" | "admin:maintenance:view-payment-rollbacks" | "admin:maintenance:run-analysis" | "admin:maintenance:purge-errors";
|
|
14
|
+
export type Permission = "admin:dashboard:view" | "admin:users:read" | "admin:users:write" | "admin:users:delete" | "admin:user-bans:read" | "admin:user-bans:write" | "admin:products:read" | "admin:products:write" | "admin:products:delete" | "admin:orders:read" | "admin:orders:write" | "admin:returns:read" | "admin:returns:write" | "admin:stores:read" | "admin:stores:write" | "admin:store-addresses:read" | "admin:addresses:read" | "admin:addresses:write" | "admin:analytics:view" | "admin:payouts:read" | "admin:payouts:write" | "admin:audit-log:read" | "admin:categories:read" | "admin:categories:write" | "admin:categories:delete" | "admin:brands:read" | "admin:brands:write" | "admin:brands:delete" | "admin:coupons:read" | "admin:coupons:write" | "admin:coupons:delete" | "admin:deals:read" | "admin:deals:write" | "admin:featured:read" | "admin:featured:write" | "admin:reviews:read" | "admin:reviews:write" | "admin:reviews:delete" | "admin:blog:read" | "admin:blog:write" | "admin:blog:delete" | "admin:blog:publish" | "admin:bids:read" | "admin:bids:write" | "admin:offers:read" | "admin:offers:write" | "admin:media:read" | "admin:media:write" | "admin:media:delete" | "admin:site:read" | "admin:site:write" | "admin:settings:write" | "admin:navigation:read" | "admin:navigation:write" | "admin:sections:read" | "admin:sections:write" | "admin:carousel:read" | "admin:carousel:write" | "admin:carousel:delete" | "admin:ads:read" | "admin:ads:write" | "admin:ads:delete" | "admin:faqs:read" | "admin:faqs:write" | "admin:faqs:delete" | "admin:tester-checklist:read" | "admin:tester-checklist:write" | "admin:tester-checklist:delete" | "admin:tester-feedback:read" | "admin:tester-feedback:write" | "admin:newsletter:read" | "admin:newsletter:write" | "admin:contact:read" | "admin:events:read" | "admin:events:write" | "admin:events:delete" | "admin:event-entries:read" | "admin:event-entries:write" | "admin:support-tickets:read" | "admin:support-tickets:write" | "admin:shipments:read" | "admin:shipments:write" | "admin:catalogue:read" | "admin:catalogue:write" | "admin:grouped-listings:read" | "admin:grouped-listings:write" | "admin:support-tickets:assign" | "admin:support-tickets:close" | "admin:scammers:read" | "admin:scammers:write" | "admin:scammers:verify" | "admin:scammers:delete" | "admin:sessions:read" | "admin:sessions:delete" | "admin:notifications:read" | "admin:notifications:write" | "admin:carts:read" | "admin:wishlists:read" | "admin:feature-flags:read" | "admin:feature-flags:write" | "admin:copilot:view" | "admin:team:read" | "admin:team:write" | "admin:maintenance:view-server-errors" | "admin:maintenance:view-client-errors" | "admin:maintenance:view-function-errors" | "admin:maintenance:view-cloud-logs" | "admin:maintenance:view-payment-rollbacks" | "admin:maintenance:run-analysis" | "admin:maintenance:purge-errors";
|
|
15
15
|
/**
|
|
16
16
|
* Granular user actions that can be soft-banned individually.
|
|
17
17
|
* A user may have multiple simultaneous soft bans of different types.
|
|
@@ -126,6 +126,8 @@ export const PERMISSION_GROUPS = {
|
|
|
126
126
|
],
|
|
127
127
|
catalog_manager: [
|
|
128
128
|
"admin:dashboard:view",
|
|
129
|
+
"admin:grouped-listings:read",
|
|
130
|
+
"admin:grouped-listings:write",
|
|
129
131
|
"admin:categories:read",
|
|
130
132
|
"admin:categories:write",
|
|
131
133
|
"admin:categories:delete",
|
|
@@ -277,6 +279,7 @@ export const ROUTE_PERMISSION_MAP = {
|
|
|
277
279
|
"/admin/audit-log": "admin:audit-log:read",
|
|
278
280
|
"/admin/shipments": "admin:shipments:read",
|
|
279
281
|
"/admin/catalogue-approvals": "admin:catalogue:read",
|
|
282
|
+
"/admin/grouped-listings": "admin:grouped-listings:read",
|
|
280
283
|
"/admin/categories": "admin:categories:read",
|
|
281
284
|
"/admin/brands": "admin:brands:read",
|
|
282
285
|
"/admin/coupons": "admin:coupons:read",
|
|
@@ -1,9 +1,24 @@
|
|
|
1
|
-
import type { DocumentReference } from "firebase-admin/firestore";
|
|
1
|
+
import type { DocumentReference, DocumentSnapshot } from "firebase-admin/firestore";
|
|
2
2
|
import { BaseRepository, type FirebaseSieveFields, type FirebaseSieveResult, type SieveModel } from "../../../providers/db-firebase";
|
|
3
3
|
import { type SessionCreateInput, type SessionDocument, type SessionUpdateInput } from "../schemas";
|
|
4
4
|
export declare class SessionRepository extends BaseRepository<SessionDocument> {
|
|
5
5
|
static readonly SIEVE_FIELDS: FirebaseSieveFields;
|
|
6
|
+
/**
|
|
7
|
+
* Session PII, encrypted by BaseRepository's write hooks.
|
|
8
|
+
*
|
|
9
|
+
* This repository had no encryption at all — `createSession` calls
|
|
10
|
+
* `createWithId`, and before the hooks existed that method applied nothing —
|
|
11
|
+
* so device IPs, user agents and city were stored in cleartext.
|
|
12
|
+
*
|
|
13
|
+
* `deviceInfo.browser` / `deviceInfo.os` are deliberately NOT here: they are
|
|
14
|
+
* low-entropy strings the admin sessions view filters on (see SIEVE_FIELDS
|
|
15
|
+
* above), and encrypting them would break those queries while revealing
|
|
16
|
+
* nothing an IP does not.
|
|
17
|
+
*/
|
|
18
|
+
protected piiFields: readonly ["deviceInfo.ip", "deviceInfo.userAgent", "location.city"];
|
|
6
19
|
constructor();
|
|
20
|
+
/** Decrypt on read so callers see the same shape they wrote. */
|
|
21
|
+
protected mapDoc<D = SessionDocument>(snap: DocumentSnapshot): D;
|
|
7
22
|
list(model: SieveModel): Promise<FirebaseSieveResult<SessionDocument>>;
|
|
8
23
|
listForUser(userId: string, model: SieveModel): Promise<FirebaseSieveResult<SessionDocument>>;
|
|
9
24
|
createSession(userId: string, data: Omit<SessionCreateInput, "userId" | "expiresAt" | "isActive">): Promise<SessionDocument>;
|
|
@@ -3,10 +3,33 @@ import { DatabaseError } from "../../../errors";
|
|
|
3
3
|
import { serverLogger } from "../../../monitoring";
|
|
4
4
|
import { BaseRepository, parseSieveDateValue, } from "../../../providers/db-firebase";
|
|
5
5
|
import { generateSessionId } from "../token-helpers";
|
|
6
|
+
import { decryptPiiFields } from "../../../security/pii-encrypt";
|
|
6
7
|
import { SESSION_COLLECTION, SESSION_EXPIRATION_MS, SESSION_FIELDS, } from "../schemas";
|
|
7
8
|
export class SessionRepository extends BaseRepository {
|
|
8
9
|
constructor() {
|
|
9
10
|
super(SESSION_COLLECTION);
|
|
11
|
+
/**
|
|
12
|
+
* Session PII, encrypted by BaseRepository's write hooks.
|
|
13
|
+
*
|
|
14
|
+
* This repository had no encryption at all — `createSession` calls
|
|
15
|
+
* `createWithId`, and before the hooks existed that method applied nothing —
|
|
16
|
+
* so device IPs, user agents and city were stored in cleartext.
|
|
17
|
+
*
|
|
18
|
+
* `deviceInfo.browser` / `deviceInfo.os` are deliberately NOT here: they are
|
|
19
|
+
* low-entropy strings the admin sessions view filters on (see SIEVE_FIELDS
|
|
20
|
+
* above), and encrypting them would break those queries while revealing
|
|
21
|
+
* nothing an IP does not.
|
|
22
|
+
*/
|
|
23
|
+
this.piiFields = [
|
|
24
|
+
"deviceInfo.ip",
|
|
25
|
+
"deviceInfo.userAgent",
|
|
26
|
+
"location.city",
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
/** Decrypt on read so callers see the same shape they wrote. */
|
|
30
|
+
mapDoc(snap) {
|
|
31
|
+
const raw = super.mapDoc(snap);
|
|
32
|
+
return decryptPiiFields(raw, [...this.piiFields]);
|
|
10
33
|
}
|
|
11
34
|
async list(model) {
|
|
12
35
|
return this.sieveQuery(model, SessionRepository.SIEVE_FIELDS);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { normalizeError } from "../../../errors/normalize";
|
|
2
2
|
import { DatabaseError } from "../../../errors";
|
|
3
3
|
import { BaseRepository, getFirestoreCount, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
|
|
4
|
-
import { USER_PII_FIELDS, decryptPayoutDetails, decryptPiiFields, decryptShippingConfig, encryptPayoutDetails, encryptPiiFields, encryptShippingConfig, piiBlindIndex, } from "../../../security";
|
|
4
|
+
import { USER_PII_FIELDS, USER_PII_INDEX_MAP, piiIndicesFor, decryptPayoutDetails, decryptPiiFields, decryptShippingConfig, encryptPayoutDetails, encryptPiiFields, encryptShippingConfig, piiBlindIndex, } from "../../../security";
|
|
5
5
|
import { USER_COLLECTION, USER_FIELDS, createUserId, } from "../schemas";
|
|
6
6
|
export class UserRepository extends BaseRepository {
|
|
7
7
|
constructor() {
|
|
@@ -23,7 +23,18 @@ export class UserRepository extends BaseRepository {
|
|
|
23
23
|
// addPiiIndices is intentionally NOT called here — it spreads the original
|
|
24
24
|
// plaintext `data` back into the result, which would overwrite the encrypted
|
|
25
25
|
// ciphertext with the original plaintext values, defeating the encryption.
|
|
26
|
-
|
|
26
|
+
//
|
|
27
|
+
// The mapped indices below are not optional. `encryptPiiFields` derives its
|
|
28
|
+
// sibling name mechanically as `${field}Index`, so `phoneNumber` produces
|
|
29
|
+
// `phoneNumberIndex` — but `findByPhone` queries `USER_FIELDS.PHONE_INDEX`,
|
|
30
|
+
// which is `phoneIndex`. Only the seed paths ever wrote that name, so
|
|
31
|
+
// **phone lookup could never match a user created by the app.**
|
|
32
|
+
// `piiIndicesFor` applies USER_PII_INDEX_MAP's real names and, unlike
|
|
33
|
+
// `addPiiIndices`, carries no plaintext back over the ciphertext.
|
|
34
|
+
const encrypted = {
|
|
35
|
+
...encryptPiiFields(data, [...USER_PII_FIELDS]),
|
|
36
|
+
...piiIndicesFor(data, USER_PII_INDEX_MAP),
|
|
37
|
+
};
|
|
27
38
|
const access = encrypted;
|
|
28
39
|
if (access.payoutDetails) {
|
|
29
40
|
access.payoutDetails = encryptPayoutDetails(access.payoutDetails);
|