@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
|
@@ -51,5 +51,5 @@ export function UserBidsView() {
|
|
|
51
51
|
},
|
|
52
52
|
};
|
|
53
53
|
const badge = detail ? bidStatusBadge(detail.status) : null;
|
|
54
|
-
return (_jsxs(_Fragment, { children: [_jsx(DataListingView, { config: config }), _jsx(RecordDetailModal, { isOpen: detail !== null, onClose: () => setDetail(null), title: detail?.productTitle || "Bid", badges: badge ? [{ label: badge.label }] : undefined, fields: detail ? buildBidDetailFields(detail, "buyer") : undefined, footer: detail ? (_jsx(TextLink, { href: String(ROUTES.PUBLIC.AUCTION_DETAIL(detail.productId)), children: "View auction \u2192" })) : undefined })] }));
|
|
54
|
+
return (_jsxs(_Fragment, { children: [_jsx(DataListingView, { config: config }), _jsx(RecordDetailModal, { isOpen: detail !== null, onClose: () => setDetail(null), title: detail?.productTitle || "Bid", badges: badge ? [{ label: badge.label }] : undefined, fields: detail ? buildBidDetailFields(detail, "buyer") : undefined, footer: detail ? (_jsxs(_Fragment, { children: [_jsx(TextLink, { href: String(ROUTES.USER.BID_DETAIL(detail.id)), children: "Open full page \u2192" }), _jsx(TextLink, { href: String(ROUTES.PUBLIC.AUCTION_DETAIL(detail.productId)), children: "View auction \u2192" })] })) : undefined })] }));
|
|
55
55
|
}
|
|
@@ -27,7 +27,7 @@ function NavLink({ item, isActive, onClick }) {
|
|
|
27
27
|
};
|
|
28
28
|
const linkClass = `flex items-center justify-end gap-[var(--appkit-space-2-5)] rounded-lg px-[var(--appkit-space-3)] py-[var(--appkit-space-2)] text-[0.8125rem] font-medium leading-tight transition-colors ${isActive
|
|
29
29
|
? "bg-primary-50 dark:bg-primary-900/25 text-primary-700 dark:text-primary-300"
|
|
30
|
-
: "text-[var(--appkit-color-text-muted)] hover:bg-
|
|
30
|
+
: "text-[var(--appkit-color-text-muted)] hover:bg-surface-hover hover:text-zinc-900 dark:hover:text-zinc-100"}`;
|
|
31
31
|
return (_jsxs(_Fragment, { children: [_jsxs(Link, { href: item.href, onClick: handleClick, className: linkClass, children: [item.icon && _jsx(Span, { size: "base", className: "shrink-0 opacity-70", children: item.icon }), _jsx(Span, { className: "truncate", children: item.label })] }), showConfirm && item.confirm && (_jsx(ConfirmDeleteModal, { isOpen: true, title: item.confirm.title ?? "Confirm", message: item.confirm.message, confirmText: "Continue", variant: "warning", onConfirm: () => {
|
|
32
32
|
setShowConfirm(false);
|
|
33
33
|
onClick?.();
|
|
@@ -7,6 +7,8 @@ import { Button, Div, FormActions, Row, Select, SideDrawer, Span, Stack, Text, U
|
|
|
7
7
|
import { FieldInput } from "../../../ui/forms/FieldInput";
|
|
8
8
|
import { FieldTextarea } from "../../../ui/forms/FieldTextarea";
|
|
9
9
|
import { apiClient } from "../../../http";
|
|
10
|
+
import { supportTicketCreateSchema } from "../../support/schemas/ticket-create-form";
|
|
11
|
+
import { ValidationError } from "../../../errors/validation-error";
|
|
10
12
|
import { SUPPORT_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
11
13
|
const __P = {
|
|
12
14
|
p3: "p-[var(--appkit-space-3)]",
|
|
@@ -33,7 +35,7 @@ const CATEGORY_OPTIONS = [
|
|
|
33
35
|
{ label: "Appeal a ban (unban request)", value: "unban_request" },
|
|
34
36
|
{ label: "General", value: "general" },
|
|
35
37
|
];
|
|
36
|
-
const CLS_MSG_USER = "
|
|
38
|
+
const CLS_MSG_USER = "border bg-[var(--appkit-color-surface)]/40 border-[var(--appkit-color-border)]";
|
|
37
39
|
const CLS_MSG_STAFF = "bg-info-surface border border-info dark:bg-info-surface dark:border-info";
|
|
38
40
|
const STATUS_BADGE = {
|
|
39
41
|
open: "bg-info-surface text-info",
|
|
@@ -68,12 +70,32 @@ export function UserSupportView(_props) {
|
|
|
68
70
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["user", "support-tickets"] });
|
|
69
71
|
const createMutation = useApiMutation({
|
|
70
72
|
mutationFn: async () => {
|
|
71
|
-
|
|
73
|
+
/*
|
|
74
|
+
* This drawer validated NOTHING. Its sibling page
|
|
75
|
+
* (`/user/support/new`) hand-rolled a `canSubmit` boolean that required
|
|
76
|
+
* an order id for an `order_issue` ticket — a real rule, since an order
|
|
77
|
+
* complaint with no order is unactionable — and the route did not
|
|
78
|
+
* enforce it either. So the same ticket was acceptable or not purely
|
|
79
|
+
* depending on which surface the user happened to open.
|
|
80
|
+
*
|
|
81
|
+
* One schema now, across both surfaces and the route.
|
|
82
|
+
*/
|
|
83
|
+
const parsed = supportTicketCreateSchema.safeParse({
|
|
72
84
|
subject: newSubject.trim(),
|
|
73
85
|
category: newCategory,
|
|
74
86
|
description: newDescription.trim(),
|
|
75
87
|
orderId: newOrderId.trim() || undefined,
|
|
76
88
|
});
|
|
89
|
+
if (!parsed.success) {
|
|
90
|
+
/*
|
|
91
|
+
* ValidationError, not a bare Error — it carries the issue LIST, so
|
|
92
|
+
* the failure keeps which field caused it. A plain Error flattens the
|
|
93
|
+
* whole thing to one string, which is what `audit-unvalidated-safeparse`
|
|
94
|
+
* exists to stop. It caught this in my own code.
|
|
95
|
+
*/
|
|
96
|
+
throw new ValidationError(parsed.error.issues[0]?.message ?? "Check the ticket details.", parsed.error.issues);
|
|
97
|
+
}
|
|
98
|
+
await apiClient.post(SUPPORT_ENDPOINTS.TICKETS, parsed.data);
|
|
77
99
|
},
|
|
78
100
|
onSuccess: () => {
|
|
79
101
|
showToast("Support ticket created.", "success");
|
|
@@ -9,6 +9,18 @@ export interface Address {
|
|
|
9
9
|
state: string;
|
|
10
10
|
postalCode: string;
|
|
11
11
|
country: string;
|
|
12
|
+
/**
|
|
13
|
+
* A nearby reference point ("opposite the Metro station").
|
|
14
|
+
*
|
|
15
|
+
* Store addresses have always accepted it — `POST /api/store/addresses`
|
|
16
|
+
* declares `landmark: z.string().max(100).optional()` and
|
|
17
|
+
* `SellerAddressesView`'s drawer collects it — while this shape did not.
|
|
18
|
+
* So an address created in that drawer WITH a landmark, then edited through
|
|
19
|
+
* any surface built on `AddressForm`, was saved back with `landmark`
|
|
20
|
+
* absent and lost it. Added here, on the form, and on the `Address` type so
|
|
21
|
+
* the round trip is lossless.
|
|
22
|
+
*/
|
|
23
|
+
landmark?: string;
|
|
12
24
|
isDefault?: boolean;
|
|
13
25
|
}
|
|
14
26
|
export interface AddressFormData {
|
|
@@ -21,6 +33,8 @@ export interface AddressFormData {
|
|
|
21
33
|
state: string;
|
|
22
34
|
postalCode: string;
|
|
23
35
|
country: string;
|
|
36
|
+
/** See Address.landmark — the form must carry it or an edit drops it. */
|
|
37
|
+
landmark?: string;
|
|
24
38
|
isDefault?: boolean;
|
|
25
39
|
}
|
|
26
40
|
interface SetDefaultAddressData {
|
|
@@ -46,8 +60,22 @@ export declare function useCreateAddress(options?: {
|
|
|
46
60
|
onSuccess?: (data: Address) => void;
|
|
47
61
|
onError?: (error: Error) => void;
|
|
48
62
|
}): import("@tanstack/react-query").UseMutationResult<Address, Error, AddressFormData, unknown>;
|
|
49
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Update options.
|
|
65
|
+
*
|
|
66
|
+
* `method` exists because the two portals disagree: `/api/user/addresses/[id]`
|
|
67
|
+
* exports PATCH and `/api/store/addresses/[id]` exports PUT, and
|
|
68
|
+
* `SellerAddressesView` hardcodes PUT against the latter. Changing the route's
|
|
69
|
+
* verb would break that working call site, so the hook bends instead.
|
|
70
|
+
*
|
|
71
|
+
* Caught by `audit-client-verb-match` before the store pages were written —
|
|
72
|
+
* this hook would have 405'd the moment they reused it.
|
|
73
|
+
*/
|
|
74
|
+
export interface UpdateAddressOptions {
|
|
50
75
|
byIdEndpoint?: (id: string) => string;
|
|
76
|
+
method?: "PUT" | "PATCH";
|
|
77
|
+
}
|
|
78
|
+
export declare function useUpdateAddress(id: string, options?: UpdateAddressOptions & {
|
|
51
79
|
onSuccess?: (data: Address) => void;
|
|
52
80
|
onError?: (error: Error) => void;
|
|
53
81
|
}): import("@tanstack/react-query").UseMutationResult<Address, Error, AddressFormData, unknown>;
|
|
@@ -48,8 +48,11 @@ export function useCreateAddress(options) {
|
|
|
48
48
|
export function useUpdateAddress(id, options) {
|
|
49
49
|
const queryClient = useQueryClient();
|
|
50
50
|
const byIdEndpoint = options?.byIdEndpoint ?? DEFAULT_ENDPOINTS.byId;
|
|
51
|
+
const method = options?.method ?? "PATCH";
|
|
51
52
|
return useMutation({
|
|
52
|
-
mutationFn: (data) =>
|
|
53
|
+
mutationFn: (data) => method === "PUT"
|
|
54
|
+
? apiClient.put(byIdEndpoint(id), data)
|
|
55
|
+
: apiClient.patch(byIdEndpoint(id), data),
|
|
53
56
|
onSuccess: (data) => {
|
|
54
57
|
queryClient.invalidateQueries({ queryKey: ["addresses"] });
|
|
55
58
|
options?.onSuccess?.(data);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useApiMutation } from "
|
|
1
|
+
import { useApiMutation } from "../../../client/api/useApiMutation";
|
|
2
2
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
3
|
import { apiClient } from "../../../http";
|
|
4
4
|
import { ACCOUNT_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
@@ -11,6 +11,19 @@ export declare class AddressesRepository extends BaseRepository<AddressDocument>
|
|
|
11
11
|
listByOwnerType(ownerType: AddressOwnerType, limit?: number): Promise<AddressDocument[]>;
|
|
12
12
|
countByOwner(ownerType: AddressOwnerType, ownerId: string): Promise<number>;
|
|
13
13
|
createForOwner(ownerType: AddressOwnerType, ownerId: string, input: AddressCreateInput): Promise<AddressDocument>;
|
|
14
|
+
/**
|
|
15
|
+
* Read one address, scoped to its owner.
|
|
16
|
+
*
|
|
17
|
+
* The owner check is `ownerType` AND `ownerId` — the same pair
|
|
18
|
+
* `updateForOwner` and `deleteForOwner` use, because `addresses` is one
|
|
19
|
+
* top-level collection holding both buyer and store addresses discriminated
|
|
20
|
+
* by `ownerType` (SB-UNI-A). Checking only `ownerId` would let a seller read
|
|
21
|
+
* their own personal address through the store endpoint and vice versa.
|
|
22
|
+
*
|
|
23
|
+
* Returns null rather than throwing: the caller turns it into a 404, and a
|
|
24
|
+
* missing address and a foreign one must be indistinguishable to the client.
|
|
25
|
+
*/
|
|
26
|
+
getForOwner(ownerType: AddressOwnerType, ownerId: string, addressId: string): Promise<AddressDocument | null>;
|
|
14
27
|
updateForOwner(ownerType: AddressOwnerType, ownerId: string, addressId: string, input: AddressUpdateInput): Promise<AddressDocument>;
|
|
15
28
|
deleteForOwner(ownerType: AddressOwnerType, ownerId: string, addressId: string): Promise<void>;
|
|
16
29
|
setDefault(ownerType: AddressOwnerType, ownerId: string, addressId: string): Promise<AddressDocument>;
|
|
@@ -115,6 +115,25 @@ export class AddressesRepository extends BaseRepository {
|
|
|
115
115
|
throw new DatabaseError(`Failed to create address for ${ownerType}:${ownerId}`, error);
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Read one address, scoped to its owner.
|
|
120
|
+
*
|
|
121
|
+
* The owner check is `ownerType` AND `ownerId` — the same pair
|
|
122
|
+
* `updateForOwner` and `deleteForOwner` use, because `addresses` is one
|
|
123
|
+
* top-level collection holding both buyer and store addresses discriminated
|
|
124
|
+
* by `ownerType` (SB-UNI-A). Checking only `ownerId` would let a seller read
|
|
125
|
+
* their own personal address through the store endpoint and vice versa.
|
|
126
|
+
*
|
|
127
|
+
* Returns null rather than throwing: the caller turns it into a 404, and a
|
|
128
|
+
* missing address and a foreign one must be indistinguishable to the client.
|
|
129
|
+
*/
|
|
130
|
+
async getForOwner(ownerType, ownerId, addressId) {
|
|
131
|
+
const existing = await this.findById(addressId);
|
|
132
|
+
if (!existing || existing.ownerType !== ownerType || existing.ownerId !== ownerId) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
return existing;
|
|
136
|
+
}
|
|
118
137
|
async updateForOwner(ownerType, ownerId, addressId, input) {
|
|
119
138
|
const existing = await this.findById(addressId);
|
|
120
139
|
if (!existing || existing.ownerType !== ownerType || existing.ownerId !== ownerId) {
|
|
@@ -14,6 +14,7 @@ import { getAdminRealtimeDb } from "../../../providers/db-firebase/index";
|
|
|
14
14
|
import { AuthorizationError, NotFoundError, ValidationError, } from "../../../errors/index";
|
|
15
15
|
import { ERROR_MESSAGES } from "../../../errors/messages";
|
|
16
16
|
import { serverLogger } from "../../../monitoring/index";
|
|
17
|
+
import { normalizeError } from "../../../errors/normalize";
|
|
17
18
|
export async function getChatRooms(userId, chatEnabled) {
|
|
18
19
|
if (!chatEnabled)
|
|
19
20
|
throw new AuthorizationError("Chat is temporarily unavailable.");
|
|
@@ -77,7 +78,10 @@ export async function sendChatMessage(userId, chatId, message) {
|
|
|
77
78
|
: (room.buyerName ?? "Member");
|
|
78
79
|
await msgRef.set({ userId, userName, message, timestamp: Date.now() });
|
|
79
80
|
chatRepository.updateLastMessage(chatId, message).catch((err) => {
|
|
80
|
-
serverLogger.warn("Failed to update chat lastMessage", {
|
|
81
|
+
serverLogger.warn("Failed to update chat lastMessage", {
|
|
82
|
+
chatId,
|
|
83
|
+
error: normalizeError(err).message,
|
|
84
|
+
});
|
|
81
85
|
});
|
|
82
86
|
serverLogger.debug("sendChatMessage", { userId, chatId });
|
|
83
87
|
return { messageId: id, timestamp: Date.now() };
|
|
@@ -116,7 +116,7 @@ export function AdminAdsView({ endpoint = ADMIN_ENDPOINTS.ADS, labels = {}, crea
|
|
|
116
116
|
render: (row) => {
|
|
117
117
|
const nextStatus = row.status === "active" ? "paused" : "active";
|
|
118
118
|
const cannotPublish = nextStatus === "active" && row.publishReady === false;
|
|
119
|
-
return (_jsxs(Row, { align: "center", gap: "sm", children: [renderEditLink ? renderEditLink(row) : (_jsx(TextLink, { variant: "bare", href: `/admin/ads/${row.id}/edit`, rounded: "md", paddingX: "sm", size: "xs", layout: "inline-flex", align: "center", className: "h-8 border border-
|
|
119
|
+
return (_jsxs(Row, { align: "center", gap: "sm", children: [renderEditLink ? renderEditLink(row) : (_jsx(TextLink, { variant: "bare", href: `/admin/ads/${row.id}/edit`, rounded: "md", paddingX: "sm", size: "xs", layout: "inline-flex", align: "center", className: "h-8 border border-[var(--appkit-color-border)]", children: "Edit" })), _jsx(Button, { size: "sm", variant: "outline", disabled: statusMutation.isPending || cannotPublish, onClick: () => statusMutation.mutate({ id: row.id, nextStatus }), children: row.status === "active" ? "Pause" : "Publish" })] }));
|
|
120
120
|
},
|
|
121
121
|
},
|
|
122
122
|
];
|
|
@@ -15,6 +15,8 @@ import { toRecordArray, toRelativeDate, toCurrency, toStringValue, } from "../ho
|
|
|
15
15
|
import { DataListingView } from "./DataListingView";
|
|
16
16
|
import { apiClient } from "../../../http";
|
|
17
17
|
import { RecordStatusTimeline } from "../../status-history/components/RecordStatusTimeline";
|
|
18
|
+
import { TextLink } from "../../../ui";
|
|
19
|
+
import { ROUTES } from "../../../constants/index";
|
|
18
20
|
export function AdminBidsView({ children, ...props }) {
|
|
19
21
|
const queryClient = useQueryClient();
|
|
20
22
|
const { showToast } = useToast();
|
|
@@ -99,7 +101,10 @@ export function AdminBidsView({ children, ...props }) {
|
|
|
99
101
|
const detailBadge = detail ? bidStatusBadge(detail.status) : null;
|
|
100
102
|
return (_jsxs(_Fragment, { children: [_jsx(DataListingView, { config: config }), _jsx(RecordDetailModal, { isOpen: detail !== null, onClose: () => setDetail(null), title: toStringValue(detail?.detail?.productTitle, "Bid"), badges: detailBadge ? [{ label: detailBadge.label }] : undefined, fields: detail
|
|
101
103
|
? buildBidDetailFields(detail.detail, "admin")
|
|
102
|
-
: undefined,
|
|
104
|
+
: undefined, footer: detail ? (
|
|
105
|
+
// The full page — a modal cannot be linked, bookmarked or reloaded
|
|
106
|
+
// into, which is the whole reason the page exists.
|
|
107
|
+
_jsx(TextLink, { href: String(ROUTES.ADMIN.BID_DETAIL(detail.id)), children: "Open full page \u2192" })) : undefined, extra:
|
|
103
108
|
// A bid moves through more states than any record a buyer owns, and
|
|
104
109
|
// every one of them happened inside a settlement batch with nothing
|
|
105
110
|
// recording it until W18. Absent history renders the empty label,
|
|
@@ -2,5 +2,13 @@ import React from "react";
|
|
|
2
2
|
import type { StackedViewShellProps } from "../../../ui";
|
|
3
3
|
export interface AdminCarouselGroupEditorViewProps extends Omit<StackedViewShellProps, "sections"> {
|
|
4
4
|
onCreated?: (id: string) => void;
|
|
5
|
+
/**
|
|
6
|
+
* Present → edit that carousel; absent → create a new one.
|
|
7
|
+
*
|
|
8
|
+
* This component was create-only, which is why `/admin/carousels/[id]/edit`
|
|
9
|
+
* did not exist. Same `featureId?`-style seam `AdminFeatureEditorView` uses.
|
|
10
|
+
*/
|
|
11
|
+
carouselId?: string;
|
|
12
|
+
onSaved?: (id: string) => void;
|
|
5
13
|
}
|
|
6
|
-
export declare function AdminCarouselGroupEditorView({ onCreated, ...rest }: AdminCarouselGroupEditorViewProps): React.JSX.Element;
|
|
14
|
+
export declare function AdminCarouselGroupEditorView({ onCreated, carouselId, onSaved, ...rest }: AdminCarouselGroupEditorViewProps): React.JSX.Element;
|
|
@@ -3,6 +3,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
3
3
|
import React from "react";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { useRouter } from "next/navigation";
|
|
6
|
+
import { useQuery } from "@tanstack/react-query";
|
|
6
7
|
import { Button, Form, Row, StackedViewShell, useToast } from "../../../ui";
|
|
7
8
|
import { FieldInput, FieldSelect, FormErrorSummary } from "../../../ui/forms";
|
|
8
9
|
import { useApiMutation } from "@mohasinac/appkit/client";
|
|
@@ -13,28 +14,59 @@ const carouselGroupFormSchema = z.object({
|
|
|
13
14
|
name: z.string().min(1, "Name is required").max(200),
|
|
14
15
|
status: z.enum(["active", "draft"]),
|
|
15
16
|
});
|
|
16
|
-
export function AdminCarouselGroupEditorView({ onCreated, ...rest }) {
|
|
17
|
+
export function AdminCarouselGroupEditorView({ onCreated, carouselId, onSaved, ...rest }) {
|
|
17
18
|
const { showToast } = useToast();
|
|
18
19
|
const router = useRouter();
|
|
20
|
+
const isEdit = Boolean(carouselId);
|
|
19
21
|
const [name, setName] = React.useState("");
|
|
20
22
|
const [status, setStatus] = React.useState("draft");
|
|
23
|
+
// Seed from the single-item GET, never from a cached list row — a list
|
|
24
|
+
// projection is narrower than the document, and saving would write the
|
|
25
|
+
// missing fields back as their defaults (Root Cause #38).
|
|
26
|
+
const existing = useQuery({
|
|
27
|
+
queryKey: ["admin", "carousel", carouselId],
|
|
28
|
+
queryFn: () => apiClient.get(ADMIN_ENDPOINTS.CAROUSELS_BY_ID(carouselId)),
|
|
29
|
+
enabled: isEdit,
|
|
30
|
+
});
|
|
31
|
+
React.useEffect(() => {
|
|
32
|
+
const doc = existing.data?.data;
|
|
33
|
+
if (!doc)
|
|
34
|
+
return;
|
|
35
|
+
setName(doc.name ?? "");
|
|
36
|
+
setStatus(doc.status === "active" ? "active" : "draft");
|
|
37
|
+
}, [existing.data]);
|
|
21
38
|
const createMutation = useApiMutation({
|
|
22
|
-
|
|
39
|
+
/*
|
|
40
|
+
* PATCH on the edit path, not PUT. `/api/admin/carousels/[id]` exports
|
|
41
|
+
* GET/PATCH/DELETE — copying `AdminFeatureEditorView`'s `apiClient.put`
|
|
42
|
+
* here would have been a 405, which is exactly what
|
|
43
|
+
* `audit-client-verb-match` exists to catch.
|
|
44
|
+
*/
|
|
45
|
+
mutationFn: () => isEdit
|
|
46
|
+
? apiClient.patch(ADMIN_ENDPOINTS.CAROUSELS_BY_ID(carouselId), { name, status })
|
|
47
|
+
: apiClient.post(ADMIN_ENDPOINTS.CAROUSELS, { name, status }),
|
|
23
48
|
onSuccess: (res) => {
|
|
24
|
-
const id = res?.data?.id;
|
|
25
|
-
showToast("Carousel created.", "success");
|
|
26
|
-
if (id)
|
|
27
|
-
|
|
28
|
-
|
|
49
|
+
const id = res?.data?.id ?? carouselId;
|
|
50
|
+
showToast(isEdit ? "Carousel updated." : "Carousel created.", "success");
|
|
51
|
+
if (!id)
|
|
52
|
+
return;
|
|
53
|
+
if (isEdit) {
|
|
54
|
+
if (onSaved)
|
|
55
|
+
onSaved(id);
|
|
29
56
|
else
|
|
30
57
|
router.push(String(ROUTES.ADMIN.CAROUSEL_DETAIL(id)));
|
|
58
|
+
return;
|
|
31
59
|
}
|
|
60
|
+
if (onCreated)
|
|
61
|
+
onCreated(id);
|
|
62
|
+
else
|
|
63
|
+
router.push(String(ROUTES.ADMIN.CAROUSEL_DETAIL(id)));
|
|
32
64
|
},
|
|
33
65
|
onError: (err) => {
|
|
34
|
-
showToast(err?.message ??
|
|
66
|
+
showToast(err?.message ?? `Failed to ${isEdit ? "update" : "create"} carousel.`, "error");
|
|
35
67
|
},
|
|
36
68
|
});
|
|
37
|
-
return (_jsx(StackedViewShell, { portal: "admin", ...rest, title: "New Named Carousel", sections: [
|
|
69
|
+
return (_jsx(StackedViewShell, { portal: "admin", ...rest, title: isEdit ? "Edit Carousel" : "New Named Carousel", sections: [
|
|
38
70
|
_jsx(Form, { schema: carouselGroupFormSchema, onSubmit: (e) => e.preventDefault(), spacing: "md", children: ({ setFieldError, clearErrors }) => (_jsxs(_Fragment, { children: [_jsx(FieldInput, { name: "name", label: "Carousel name", value: name, onChange: (v) => setName(v), required: true, placeholder: "e.g. Homepage Hero" }), _jsx(FieldSelect, { name: "status", label: "Status", value: status, onChange: (v) => setStatus(v), options: [
|
|
39
71
|
{ value: "draft", label: "Draft" },
|
|
40
72
|
{ value: "active", label: "Active" },
|
|
@@ -45,6 +77,6 @@ export function AdminCarouselGroupEditorView({ onCreated, ...rest }) {
|
|
|
45
77
|
return;
|
|
46
78
|
}
|
|
47
79
|
createMutation.mutate();
|
|
48
|
-
}, children: "Create carousel" }) })] })) }, "carousel-group-form"),
|
|
80
|
+
}, children: isEdit ? "Save changes" : "Create carousel" }) })] })) }, "carousel-group-form"),
|
|
49
81
|
] }));
|
|
50
82
|
}
|
|
@@ -145,5 +145,5 @@ export function AdminCarouselView({ children, onBulkDelete, ...props }) {
|
|
|
145
145
|
] })),
|
|
146
146
|
},
|
|
147
147
|
];
|
|
148
|
-
return (_jsxs(Div, { className: "min-h-screen", children: [_jsx(ListingToolbar, { filterCount: activeFilterCount, onFiltersClick: openFilters, searchValue: searchInput, searchPlaceholder: "Search slide titles", onSearchChange: setSearchInput, onSearchCommit: commitSearch, showTableView: true, view: view, onViewChange: (v) => setView(v), onResetAll: resetAll, hasActiveState: hasActiveState }), totalPages > 1 && (_jsx(StickyToolbar, { offset: "header+pagination", tone: "translucent", border: true, padding: "toolbar", children: _jsx(Row, { justify: "center", children: _jsx(Pagination, { currentPage: currentPage, totalPages: totalPages, onPageChange: (p) => table.setPage(p) }) }) })), selection.selectedCount > 0 && bulkActions.length > 0 && (_jsx(BulkActionBar, { selectedCount: selection.selectedCount, actions: bulkActions, onClearSelection: selection.clearSelection })), _jsxs(Div, { paddingX: "x-sm-md", padding: "y-md", children: [errorMessage && (_jsx(Div, { textSize: "sm", className: "mb-4 border border-error/20", color: "error", surface: "danger-surface", padding: "inline", rounded: "xl", children: errorMessage })), _jsx(DataTable, { rows: displayRows, columns: columns, isLoading: isLoading, emptyLabel: "No carousel slides \u2014 use 'New slide' to add one", rowHrefTemplate: "/admin/carousel/{id}/edit" })] }), _jsx(ListingFilterDrawer, { open: filterOpen, onClose: () => setFilterOpen(false), onApply: applyFilters, onClear: clearFilters, activeCount: activeFilterCount, children: _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { className: "tracking-widest", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Status" }), _jsx(Row, { wrap: true, gap: "sm", children: [{ label: "All", value: "" }, { label: "Active", value: "true" }, { label: "Inactive", value: "false" }].map((opt) => (_jsx(Button, { variant: "ghost", type: "button", onClick: () => setPendingFilters((p) => ({ ...p, active: opt.value })), className: `rounded-full px-[var(--appkit-space-3)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-medium border transition-colors min-h-0 h-auto ${(pendingFilters.active || "") === opt.value ? "bg-primary text-white border-primary" : "border-[var(--appkit-color-border)] text-[var(--appkit-color-text-muted)] hover:bg-
|
|
148
|
+
return (_jsxs(Div, { className: "min-h-screen", children: [_jsx(ListingToolbar, { filterCount: activeFilterCount, onFiltersClick: openFilters, searchValue: searchInput, searchPlaceholder: "Search slide titles", onSearchChange: setSearchInput, onSearchCommit: commitSearch, showTableView: true, view: view, onViewChange: (v) => setView(v), onResetAll: resetAll, hasActiveState: hasActiveState }), totalPages > 1 && (_jsx(StickyToolbar, { offset: "header+pagination", tone: "translucent", border: true, padding: "toolbar", children: _jsx(Row, { justify: "center", children: _jsx(Pagination, { currentPage: currentPage, totalPages: totalPages, onPageChange: (p) => table.setPage(p) }) }) })), selection.selectedCount > 0 && bulkActions.length > 0 && (_jsx(BulkActionBar, { selectedCount: selection.selectedCount, actions: bulkActions, onClearSelection: selection.clearSelection })), _jsxs(Div, { paddingX: "x-sm-md", padding: "y-md", children: [errorMessage && (_jsx(Div, { textSize: "sm", className: "mb-4 border border-error/20", color: "error", surface: "danger-surface", padding: "inline", rounded: "xl", children: errorMessage })), _jsx(DataTable, { rows: displayRows, columns: columns, isLoading: isLoading, emptyLabel: "No carousel slides \u2014 use 'New slide' to add one", rowHrefTemplate: "/admin/carousel/{id}/edit" })] }), _jsx(ListingFilterDrawer, { open: filterOpen, onClose: () => setFilterOpen(false), onApply: applyFilters, onClear: clearFilters, activeCount: activeFilterCount, children: _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { className: "tracking-widest", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Status" }), _jsx(Row, { wrap: true, gap: "sm", children: [{ label: "All", value: "" }, { label: "Active", value: "true" }, { label: "Inactive", value: "false" }].map((opt) => (_jsx(Button, { variant: "ghost", type: "button", onClick: () => setPendingFilters((p) => ({ ...p, active: opt.value })), className: `rounded-full px-[var(--appkit-space-3)] py-[var(--appkit-space-1)] text-[length:var(--appkit-text-xs)] font-medium border transition-colors min-h-0 h-auto ${(pendingFilters.active || "") === opt.value ? "bg-primary text-white border-primary" : "border-[var(--appkit-color-border)] text-[var(--appkit-color-text-muted)] hover:bg-surface-hover"}`, children: opt.label }, opt.label))) })] }) }), deleteTargetId && (_jsx(ConfirmDeleteModal, { isOpen: true, title: "Delete Carousel Slide", message: "Delete this carousel slide? This cannot be undone.", onConfirm: () => { deleteMutation.mutate(deleteTargetId); setDeleteTargetId(null); }, onClose: () => setDeleteTargetId(null), isDeleting: deleteMutation.isPending }))] }));
|
|
149
149
|
}
|
|
@@ -19,7 +19,7 @@ const __O = {
|
|
|
19
19
|
};
|
|
20
20
|
const PILL_BASE_CLASS = "inline-flex items-center gap-[var(--appkit-space-1-5)] rounded-full border px-[var(--appkit-space-3)] py-[var(--appkit-space-1-5)] text-[length:var(--appkit-text-xs)] font-medium cursor-pointer transition-colors";
|
|
21
21
|
const PILL_CHECKED_CLASS = "bg-primary text-white border-primary";
|
|
22
|
-
const PILL_UNCHECKED_CLASS = "border-[var(--appkit-color-border)] text-[var(--appkit-color-text-muted)] hover:bg-
|
|
22
|
+
const PILL_UNCHECKED_CLASS = "border-[var(--appkit-color-border)] text-[var(--appkit-color-text-muted)] hover:bg-surface-hover";
|
|
23
23
|
const FIELD_LABEL_CLASS = "text-[length:var(--appkit-text-sm)] font-medium text-[var(--appkit-color-text-muted)]";
|
|
24
24
|
const DELETE_CONFIRM_TEXT = "Delete this feature? It will fail if any product still references it.";
|
|
25
25
|
const TOAST = {
|
|
@@ -8,12 +8,13 @@ import { normalizeError } from "../../../errors/normalize";
|
|
|
8
8
|
import { sortBy } from "@mohasinac/appkit/client";
|
|
9
9
|
import React, { useState } from "react";
|
|
10
10
|
import { ListingLayout } from "../../../ui";
|
|
11
|
-
import { Button, Row, SideDrawer, Stack, Text, useToast, } from "../../../ui";
|
|
11
|
+
import { Button, Row, SideDrawer, Stack, Text, TextLink, useToast, } from "../../../ui";
|
|
12
12
|
import { toRecordArray, toRelativeDate, toStringValue, } from "../hooks/useAdminListingData";
|
|
13
13
|
import { DataListingView } from "./DataListingView";
|
|
14
14
|
import { ProductInlineSelect } from "../../seller/components/ProductInlineSelect";
|
|
15
15
|
import { apiClient } from "../../../http";
|
|
16
16
|
import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
17
|
+
import { ROUTES } from "../../../next/routing/route-map";
|
|
17
18
|
function ReassignProductsDrawer({ state, onClose, onSaved, }) {
|
|
18
19
|
const [productIds, setProductIds] = useState(state.currentProductIds);
|
|
19
20
|
const [saving, setSaving] = useState(false);
|
|
@@ -75,10 +76,21 @@ export function AdminGroupedListingsView({ children, ...props }) {
|
|
|
75
76
|
})),
|
|
76
77
|
getTotal: (response, mappedRows) => typeof response.total === "number" ? response.total : mappedRows.length,
|
|
77
78
|
buildFilters: () => "",
|
|
79
|
+
/*
|
|
80
|
+
* Admin had NO create path at all — there was no POST route either,
|
|
81
|
+
* so this could not have been wired before now.
|
|
82
|
+
*
|
|
83
|
+
* `toolbarExtra` rather than `primaryAction`: the latter takes an
|
|
84
|
+
* `onClick(panel)` for opening a create DRAWER, and this create is a
|
|
85
|
+
* page. Same shape `AdminAdsView` uses, and `audit-create-affordance`
|
|
86
|
+
* counts both — which is exactly why it has to, or four views with a
|
|
87
|
+
* perfectly good create link would read as having none.
|
|
88
|
+
*/
|
|
89
|
+
toolbarExtra: (_jsx(TextLink, { variant: "bare", href: String(ROUTES.ADMIN.GROUPED_LISTINGS_NEW), rounded: "md", paddingX: "sm", size: "sm", weight: "medium", layout: "inline-flex", align: "center", className: "h-9 bg-[var(--appkit-color-surface)] text-[var(--appkit-color-text)]", children: "New group" })),
|
|
78
90
|
// Mirrors the row action button's "Reassign products" drawer so the
|
|
79
91
|
// row itself is clickable, not just the button.
|
|
80
92
|
onRowClick: (row) => openReassign(row),
|
|
81
|
-
renderRowActions: (row) => (_jsx(Button, { size: "sm", variant: "outline", onClick: () => openReassign(row), children: "Reassign products" })),
|
|
93
|
+
renderRowActions: (row) => (_jsxs(Row, { gap: "xs", children: [_jsx(TextLink, { href: String(ROUTES.ADMIN.GROUPED_LISTINGS_EDIT(row.id)), children: "Edit" }), _jsx(Button, { size: "sm", variant: "outline", onClick: () => openReassign(row), children: "Reassign products" })] })),
|
|
82
94
|
}), [refreshKey, openReassign]);
|
|
83
95
|
if (React.Children.count(children) > 0) {
|
|
84
96
|
return (_jsx(ListingLayout, { portal: "admin", ...props, children: children }));
|
|
@@ -17,7 +17,7 @@ const __O = {
|
|
|
17
17
|
function NavLink({ item, isActive, onClick }) {
|
|
18
18
|
return (_jsxs(Link, { href: item.href, onClick: onClick, className: `flex items-center justify-end gap-[var(--appkit-space-2-5)] rounded-lg px-[var(--appkit-space-3)] py-[var(--appkit-space-2)] text-[0.8125rem] font-medium leading-tight transition-colors ${isActive
|
|
19
19
|
? "bg-[var(--appkit-color-surface)] bg-[var(--appkit-color-surface-elevated)] text-[var(--appkit-color-text)]"
|
|
20
|
-
: "text-[var(--appkit-color-text-muted)] hover:bg-
|
|
20
|
+
: "text-[var(--appkit-color-text-muted)] hover:bg-surface-hover hover:text-zinc-800 hover:text-[var(--appkit-color-text-muted)]"}`, children: [item.icon && _jsx(Span, { size: "base", className: "shrink-0 opacity-60", children: item.icon }), _jsx(Span, { className: "truncate", children: item.label })] }));
|
|
21
21
|
}
|
|
22
22
|
function GroupsContent({ groups, activePath, onItemClick, }) {
|
|
23
23
|
// Accordion — only one group open at a time. Initial pick: the group
|
|
@@ -52,7 +52,7 @@ function AdminCardItem({ row, view, selected, onToggleSelect, onRowClick, render
|
|
|
52
52
|
};
|
|
53
53
|
if (view === "list") {
|
|
54
54
|
return (_jsxs(Row, { gap: "sm", className: [
|
|
55
|
-
"px-[var(--appkit-space-4)] py-[var(--appkit-space-3)] cursor-pointer transition-colors hover:bg-
|
|
55
|
+
"px-[var(--appkit-space-4)] py-[var(--appkit-space-3)] cursor-pointer transition-colors hover:bg-surface-hover",
|
|
56
56
|
selected ? "bg-primary-50/40 dark:bg-primary-900/10" : "",
|
|
57
57
|
].filter(Boolean).join(" "), onClick: handleClick, role: onRowClick ? "button" : undefined, children: [onToggleSelect && (_jsx(Div, { "data-no-row-click": true, className: "shrink-0", onClick: (e) => { e.stopPropagation(); onToggleSelect(row.id); }, children: _jsx(Checkbox, { bare: true, checked: selected, onChange: () => onToggleSelect(row.id), className: "h-4 w-4 rounded border-zinc-300 text-primary accent-primary cursor-pointer", "aria-label": `Select ${row.primary}` }) })), _jsx(RowAvatar, { image: row.image, alt: row.primary, icon: resourceIcon }), _jsxs(Stack, { gap: "none", className: "flex-1 min-w-0", children: [_jsx(Text, { size: "sm", weight: "semibold", className: "truncate", color: "primary", children: row.primary }), _jsx(Text, { size: "xs", color: "muted", className: "truncate", children: row.secondary }), row.barcodeId && (_jsx(Text, { size: "xs", color: "faint", className: "truncate font-mono", children: row.barcodeId }))] }), flags.length > 0 && (_jsx(Row, { gap: "xs", className: "hidden sm:flex shrink-0", children: flags.map(({ key, label, tone }) => (_jsx(Span, { padding: "pill-2xs", weight: "medium", className: `inline-flex text-[10px] ${STATUS_TONE_CLASSES[tone]}`, rounded: "full", children: label }, key))) })), _jsx(StatusBadge, { status: row.status }), _jsx(Span, { size: "xs", color: "muted", className: "hidden sm:block shrink-0 w-24", align: "end", children: row.updatedAt }), renderRowActions && (_jsx(Div, { "data-no-row-click": true, className: "shrink-0", onClick: (e) => e.stopPropagation(), children: renderRowActions(row) }))] }));
|
|
58
58
|
}
|
|
@@ -60,7 +60,7 @@ function AdminCardItem({ row, view, selected, onToggleSelect, onRowClick, render
|
|
|
60
60
|
"border overflow-hidden cursor-pointer transition-all hover:shadow-md hover:-translate-y-0.5",
|
|
61
61
|
selected
|
|
62
62
|
? "border-primary ring-1 ring-primary/20 bg-primary-50/30 dark:bg-primary-900/10"
|
|
63
|
-
: "
|
|
63
|
+
: " border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)]",
|
|
64
64
|
].filter(Boolean).join(" "), onClick: handleClick, role: onRowClick ? "button" : undefined, children: [onToggleSelect && (_jsxs(Row, { gap: "xs", "data-no-row-click": true, paddingY: "t-sm", padding: "x-sm", onClick: (e) => { e.stopPropagation(); onToggleSelect(row.id); }, children: [_jsx(Checkbox, { bare: true, checked: selected, onChange: () => onToggleSelect(row.id), className: "h-4 w-4 rounded border-zinc-300 text-primary accent-primary cursor-pointer", "aria-label": `Select ${row.primary}` }), flags.length > 0 && (_jsx(Row, { gap: "xs", wrap: true, children: flags.map(({ key, label, tone }) => (_jsx(Span, { padding: "pill-2xs", weight: "medium", className: `inline-flex text-[10px] ${STATUS_TONE_CLASSES[tone]}`, rounded: "full", children: label }, key))) }))] })), _jsxs(Stack, { gap: "xs", className: `${__P.p3}.5`, children: [_jsxs(Row, { gap: "sm", align: "center", children: [_jsx(RowAvatar, { image: row.image, alt: row.primary, icon: resourceIcon, size: "12" }), _jsxs(Stack, { gap: "none", className: "min-w-0 flex-1", children: [_jsx(Text, { size: "sm", weight: "semibold", className: "line-clamp-2 leading-snug", color: "primary", children: row.primary }), _jsx(Text, { size: "xs", color: "muted", className: "truncate", children: row.secondary })] })] }), row.barcodeId && (_jsx(Text, { size: "xs", color: "faint", className: "truncate font-mono", children: row.barcodeId })), _jsxs(Row, { justify: "between", gap: "xs", children: [_jsx(StatusBadge, { status: row.status }), _jsx(Span, { color: "muted", className: "text-[11px] shrink-0", children: row.updatedAt })] }), renderRowActions && (_jsx(Row, { "data-no-row-click": true, justify: "end", gap: "xs", className: "border-t border-[var(--appkit-color-border)] -mx-[var(--appkit-space-3)] -mb-[var(--appkit-space-3)] mt-[var(--appkit-space-1)] px-[var(--appkit-space-3)] py-[var(--appkit-space-2)]", onClick: (e) => e.stopPropagation(), children: renderRowActions(row) }))] })] }));
|
|
65
65
|
}
|
|
66
66
|
export function AdminViewCards({ rows, view, isLoading, emptyLabel = "No items found", onRowClick, selectedIdSet, onToggleSelect, renderRowActions, resourceIcon, }) {
|
|
@@ -39,7 +39,7 @@ function SelectableRow({ row, columns, isSelected, onToggle, renderRowActions, o
|
|
|
39
39
|
: undefined;
|
|
40
40
|
const handleKeyDown = handleClick
|
|
41
41
|
? (event) => {
|
|
42
|
-
if (event.key === "Enter" || event.key === "
|
|
42
|
+
if (event.key === "Enter" || event.key === "")
|
|
43
43
|
handleClick();
|
|
44
44
|
}
|
|
45
45
|
: undefined;
|
|
@@ -54,5 +54,5 @@ export function DataTable({ columns: columnsProp, rows, isLoading, sortKey, sort
|
|
|
54
54
|
const allRowsSelected = selectionEnabled && rows.length > 0 && rows.every((r) => selectedIds?.has(r.id));
|
|
55
55
|
return (_jsxs(Div, { surface: "card", className: `${__O.hidden}`, children: [_jsx(Div, { className: `${__O.xAuto}`, children: _jsxs(Table, { "data-testid": "data-table", size: "sm", children: [_jsx(Thead, { children: _jsxs(Tr, { className: "bg-[var(--appkit-color-bg)] dark:bg-[var(--appkit-color-surface-elevated)]", border: "default", children: [selectionEnabled && (_jsx(Th, { padding: "xs-3", scope: "col", className: "w-10", children: onToggleSelectAll && (_jsx(Checkbox, { bare: true, "data-testid": "select-all-checkbox", "aria-label": allRowsSelected ? "Deselect all" : "Select all", checked: allRowsSelected, onChange: () => onToggleSelectAll(!allRowsSelected), className: "h-4 w-4 rounded border-[var(--appkit-color-border)] accent-zinc-900 dark:accent-zinc-100" })) })), columns.map((col) => (_jsxs(Th, { scope: "col", onClick: col.sortable && onSort ? () => onSort(col.key) : undefined, className: `text-left text-[var(--appkit-color-text)] ${col.sortable && onSort ? "cursor-pointer select-none hover:text-primary" : ""} ${COLUMN_PRIORITY_CLASS[resolveColumnPriority(col)]} ${col.className ?? ""}`, padding: "md", weight: "semibold", children: [col.header, col.sortable && sortKey === col.key && (_jsx(Span, { className: "ml-1", children: sortDir === "asc" ? "↑" : "↓" }))] }, col.key))), renderRowActions && _jsx(Th, { padding: "xs-3", scope: "col", className: "w-12" })] }) }), _jsx(Tbody, { children: isLoading ? (Array.from({ length: 5 }).map((_, i) => (_jsxs(Tr, { border: "default", children: [selectionEnabled && _jsx(Td, { padding: "xs-3", className: "w-10" }), columns.map((col) => (_jsx(Td, { padding: "md", className: COLUMN_PRIORITY_CLASS[resolveColumnPriority(col)], children: _jsx(Div, { className: "h-4 w-full animate-pulse bg-neutral-200", rounded: "default" }) }, col.key)))] }, i)))) : rows.length === 0 ? (_jsx(Tr, { children: _jsx(Td, { colSpan: columns.length + (selectionEnabled ? 1 : 0) + (renderRowActions ? 1 : 0), align: "center", color: "faint", paddingX: "md", paddingY: "md", children: emptyLabel }) })) : (rows.map((row) => (_jsx(SelectableRow, { row: row, columns: columns, isSelected: selectedIds?.has(row.id) ?? false, onToggle: onToggleSelect, renderRowActions: renderRowActions, onRowClick: onRowClick, rowHref: typeof rowHrefTemplate === "function"
|
|
56
56
|
? rowHrefTemplate(row)
|
|
57
|
-
: rowHrefTemplate?.replace("{id}", encodeURIComponent(row.id)), selectionEnabled: selectionEnabled }, row.id)))) })] }) }), totalPages > 1 && onPageChange && (_jsx(Row, { className: "border-t border-neutral-200", padding: "inline", align: "center", justify: "end", gap: "sm", children: Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (_jsx(Button, { onClick: () => onPageChange(p), variant: p === currentPage ? "primary" : "ghost", size: "sm", className: `h-8 w-8 rounded text-[length:var(--appkit-text-xs)] font-medium transition ${p === currentPage ? "bg-neutral-900 text-white" : "text-[var(--appkit-color-text-muted)] text-[var(--appkit-color-text-muted)] hover:bg-
|
|
57
|
+
: rowHrefTemplate?.replace("{id}", encodeURIComponent(row.id)), selectionEnabled: selectionEnabled }, row.id)))) })] }) }), totalPages > 1 && onPageChange && (_jsx(Row, { className: "border-t border-neutral-200", padding: "inline", align: "center", justify: "end", gap: "sm", children: Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (_jsx(Button, { onClick: () => onPageChange(p), variant: p === currentPage ? "primary" : "ghost", size: "sm", className: `h-8 w-8 rounded text-[length:var(--appkit-text-xs)] font-medium transition ${p === currentPage ? "bg-neutral-900 text-white" : "text-[var(--appkit-color-text-muted)] text-[var(--appkit-color-text-muted)] hover:bg-surface-hover"}`, children: p }, p))) }))] }));
|
|
58
58
|
}
|
|
@@ -254,11 +254,17 @@ export const DEFAULT_SITE_SETTINGS_DATA = {
|
|
|
254
254
|
codDepositPercent: 10,
|
|
255
255
|
codHandlingFeeMin: 200,
|
|
256
256
|
codHandlingFeePercent: 10,
|
|
257
|
-
|
|
257
|
+
// The three buyer add-ons ship ENABLED. They were `false`, which made
|
|
258
|
+
// StoreAddonsPicker return null on both the cart and checkout, so a fresh
|
|
259
|
+
// install had no add-ons at all and no indication why. `undefined` still
|
|
260
|
+
// means disabled everywhere (computeWhatsAppNotifyFee et al. read
|
|
261
|
+
// `if (!rates.XEnabled) return 0`) — only the DEFAULT changed, so an
|
|
262
|
+
// existing siteSettings document keeps whatever it already stored.
|
|
263
|
+
whatsappNotifyFeeEnabled: true,
|
|
258
264
|
whatsappNotifyFee: 10,
|
|
259
|
-
giftWrapFeeEnabled:
|
|
265
|
+
giftWrapFeeEnabled: true,
|
|
260
266
|
giftWrapFee: 49,
|
|
261
|
-
shipmentProtectionFeeEnabled:
|
|
267
|
+
shipmentProtectionFeeEnabled: true,
|
|
262
268
|
shipmentProtectionFeePercent: 2,
|
|
263
269
|
shipmentProtectionFeeMin: 30,
|
|
264
270
|
sellerShippingFixed: 0,
|
|
@@ -12,6 +12,7 @@ import { bidRepository } from "../repository/bid.repository";
|
|
|
12
12
|
import { productRepository } from "../../products/repository/products.repository";
|
|
13
13
|
import { isAuctionListing } from "../../products/utils/listing-type";
|
|
14
14
|
import { userRepository } from "../../auth/repository/user.repository";
|
|
15
|
+
import { isSoftBanned } from "../../auth/server/checkSoftBan";
|
|
15
16
|
import { unitOfWork } from "../../../core/unit-of-work";
|
|
16
17
|
import { storeRepository } from "../../stores/repository/store.repository";
|
|
17
18
|
import { siteSettingsRepository } from "../../../repositories";
|
|
@@ -28,6 +29,29 @@ import { cartRepository } from "../../cart/repository/cart.repository";
|
|
|
28
29
|
import { AUCTION_BUYOUT_WINDOW_MINUTES, AUCTION_BUYOUT_WINDOW_MS, AUCTION_CHECKOUT_URL, } from "../../../_internal/shared/checkout/lanes";
|
|
29
30
|
import { resolveDate } from "../../../utils";
|
|
30
31
|
// --- Domain Functions ----------------------------------------------------------
|
|
32
|
+
/**
|
|
33
|
+
* Load the bidder's profile and refuse if they carry an active `place_bids`
|
|
34
|
+
* soft ban. Returns the profile so the caller pays for exactly one read.
|
|
35
|
+
*
|
|
36
|
+
* 🛑 This belongs in the DOMAIN function, not in a route.
|
|
37
|
+
*
|
|
38
|
+
* Admins can issue a `place_bids` soft ban and `BID_ERROR_CODES.USER_BANNED`
|
|
39
|
+
* has always existed with a display-map entry and client copy — but nothing
|
|
40
|
+
* ever threw it. The only `isSoftBanned(user, "place_bids")` call in the
|
|
41
|
+
* codebase sat in `POST /api/auctions/[id]/buy-now`, a route with zero callers,
|
|
42
|
+
* while the live path (the `placeBidAction` / `buyNowAction` server actions)
|
|
43
|
+
* checked nothing. A banned user could bid and buy out freely. Enforcing it
|
|
44
|
+
* here means every caller — action, route, or job — is covered by
|
|
45
|
+
* construction.
|
|
46
|
+
*/
|
|
47
|
+
async function assertCanBid(userId) {
|
|
48
|
+
const profile = await userRepository.findById(userId);
|
|
49
|
+
if (profile && isSoftBanned(profile, "place_bids")) {
|
|
50
|
+
const ban = profile.softBans?.find((b) => b.action === "place_bids");
|
|
51
|
+
throw new AuthorizationError(`Your account is restricted from bidding. Reason: ${ban?.reason ?? "Policy violation"}.`, { code: BID_ERROR_CODES.USER_BANNED });
|
|
52
|
+
}
|
|
53
|
+
return profile ?? null;
|
|
54
|
+
}
|
|
31
55
|
export async function placeBid(userId, userEmail, input) {
|
|
32
56
|
const { productId, bidAmount, autoMaxBid } = input;
|
|
33
57
|
const product = await productRepository.findById(productId);
|
|
@@ -50,6 +74,9 @@ export async function placeBid(userId, userEmail, input) {
|
|
|
50
74
|
throw new AuthorizationError(ERROR_MESSAGES.BID.OWN_AUCTION);
|
|
51
75
|
}
|
|
52
76
|
}
|
|
77
|
+
// Before any write. The profile is reused below for the bid's denormalised
|
|
78
|
+
// userName/userEmail, so the ban check costs no extra read.
|
|
79
|
+
const profile = await assertCanBid(userId);
|
|
53
80
|
// An auction with no bids yet prices off its starting bid, and that starting
|
|
54
81
|
// bid is itself acceptable — see ResolveMinBidOptions.hasBids.
|
|
55
82
|
const hasBids = (product.currentBid ?? 0) > 0 || (product.bidCount ?? 0) > 0;
|
|
@@ -122,7 +149,6 @@ export async function placeBid(userId, userEmail, input) {
|
|
|
122
149
|
visibleBid = newCap;
|
|
123
150
|
bumpedPreviousVisible = Math.min(prevCap, target);
|
|
124
151
|
}
|
|
125
|
-
const profile = await userRepository.findById(userId);
|
|
126
152
|
const bid = await bidRepository.create({
|
|
127
153
|
productId,
|
|
128
154
|
productTitle: product.title,
|
|
@@ -229,12 +255,24 @@ export async function placeBid(userId, userEmail, input) {
|
|
|
229
255
|
return { bid };
|
|
230
256
|
}
|
|
231
257
|
export async function buyNowAuction(userId, userName, userEmail, input) {
|
|
232
|
-
const { productId } = input;
|
|
233
|
-
|
|
258
|
+
const { productId: productRef } = input;
|
|
259
|
+
// Auction URLs are slug-based, and this action is reachable from a route
|
|
260
|
+
// param, so accept either. Deliberately id-FIRST rather than
|
|
261
|
+
// `findByIdOrSlug`, which queries by slug first and would therefore cost two
|
|
262
|
+
// reads on the common id path (Rule #6) — here the fallback query only runs
|
|
263
|
+
// when the id lookup actually misses.
|
|
264
|
+
//
|
|
265
|
+
// This was masked because every seeded auction has `slug === id`. Any auction
|
|
266
|
+
// whose slug differs threw NotFoundError, which the caller then swallowed.
|
|
267
|
+
const product = (await productRepository.findById(productRef)) ??
|
|
268
|
+
(await productRepository.findBySlug(productRef));
|
|
234
269
|
if (!product)
|
|
235
270
|
throw new NotFoundError(ERROR_MESSAGES.BID.AUCTION_NOT_FOUND);
|
|
236
271
|
if (!isAuctionListing(product))
|
|
237
272
|
throw new ValidationError(ERROR_MESSAGES.BID.NOT_AN_AUCTION);
|
|
273
|
+
// 🛑 Every downstream write (the bid, the locked cart line, the notification)
|
|
274
|
+
// must key on the canonical document id, never on the ref the caller passed.
|
|
275
|
+
const productId = String(product.id);
|
|
238
276
|
if (product.isSold) {
|
|
239
277
|
throw new ValidationError(ERROR_MESSAGES.BID.AUCTION_ENDED, { code: BID_ERROR_CODES.AUCTION_ENDED });
|
|
240
278
|
}
|
|
@@ -250,6 +288,10 @@ export async function buyNowAuction(userId, userName, userEmail, input) {
|
|
|
250
288
|
throw new AuthorizationError(ERROR_MESSAGES.BID.OWN_AUCTION);
|
|
251
289
|
}
|
|
252
290
|
}
|
|
291
|
+
// Same guard as `placeBid` — a buyout IS a bid (it writes a real
|
|
292
|
+
// `BidDocument` with `isBuyout: true`), so a `place_bids` ban must block it.
|
|
293
|
+
// The profile is reused for the bid's denormalised name/email below.
|
|
294
|
+
const profile = await assertCanBid(userId);
|
|
253
295
|
const buyNowPrice = typeof product.buyNowPrice === "number" ? product.buyNowPrice : null;
|
|
254
296
|
if (buyNowPrice === null || buyNowPrice <= 0) {
|
|
255
297
|
throw new ValidationError(ERROR_MESSAGES.BID.BUY_NOW_NO_PRICE, { code: BID_ERROR_CODES.BUY_NOW_NO_PRICE });
|
|
@@ -286,7 +328,6 @@ export async function buyNowAuction(userId, userName, userEmail, input) {
|
|
|
286
328
|
// The product's own currency, not the site default — the cart line, the bid
|
|
287
329
|
// and the listing all have to agree, and settlement uses the bid's currency.
|
|
288
330
|
const currency = product.currency || getDefaultCurrency();
|
|
289
|
-
const profile = await userRepository.findById(userId);
|
|
290
331
|
// A real bid at the buy-now price — this is what "the bid placed is the
|
|
291
332
|
// buyout price by the buyer" means. It starts `active`/`isWinning: false`
|
|
292
333
|
// like any other bid (bidRepository.create forces both) and is promoted to
|