@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.
Files changed (271) hide show
  1. package/dist/_internal/client/features/lottery/LotteryAdminEditView.d.ts +16 -16
  2. package/dist/_internal/client/features/lottery/LotteryAdminEditView.js +29 -6
  3. package/dist/_internal/client/features/lottery/LotteryListView.js +13 -1
  4. package/dist/_internal/server/features/checkout/actions.js +6 -4
  5. package/dist/_internal/server/features/classified/metadata.js +3 -1
  6. package/dist/_internal/server/features/digital-code/metadata.js +3 -1
  7. package/dist/_internal/server/features/live/metadata.js +3 -1
  8. package/dist/_internal/server/features/refunds/actions.js +3 -2
  9. package/dist/_internal/server/features/reviews/actions.js +3 -2
  10. package/dist/_internal/server/features/seo/sitemap.js +189 -40
  11. package/dist/_internal/server/jobs/core/listingProcessor.d.ts +0 -1
  12. package/dist/_internal/server/jobs/core/listingProcessor.js +19 -1
  13. package/dist/_internal/shared/actions/action-registry.js +14 -0
  14. package/dist/_internal/shared/checkout/order-math.d.ts +25 -3
  15. package/dist/_internal/shared/features/checkout/config.d.ts +3 -3
  16. package/dist/_internal/shared/features/checkout/config.js +6 -3
  17. package/dist/client.d.ts +32 -2
  18. package/dist/client.js +37 -2
  19. package/dist/constants/api-endpoints.d.ts +21 -3
  20. package/dist/constants/api-endpoints.js +14 -1
  21. package/dist/constants/field-names.d.ts +2 -2
  22. package/dist/constants/field-names.js +2 -2
  23. package/dist/core/newsletter.repository.js +14 -1
  24. package/dist/features/about/components/FAQPageView.js +1 -1
  25. package/dist/features/about/components/FeesView.js +1 -1
  26. package/dist/features/account/components/AddressForm.d.ts +2 -0
  27. package/dist/features/account/components/AddressForm.js +5 -1
  28. package/dist/features/account/components/UserBidsView.js +1 -1
  29. package/dist/features/account/components/UserSidebar.js +1 -1
  30. package/dist/features/account/components/UserSupportView.js +24 -2
  31. package/dist/features/account/hooks/useAddresses.d.ts +29 -1
  32. package/dist/features/account/hooks/useAddresses.js +4 -1
  33. package/dist/features/account/hooks/useProfile.d.ts +1 -1
  34. package/dist/features/account/hooks/useProfile.js +1 -1
  35. package/dist/features/addresses/repository/addresses.repository.d.ts +13 -0
  36. package/dist/features/addresses/repository/addresses.repository.js +19 -0
  37. package/dist/features/admin/actions/chat-actions.js +5 -1
  38. package/dist/features/admin/components/AdminAdsView.js +1 -1
  39. package/dist/features/admin/components/AdminBidsView.js +6 -1
  40. package/dist/features/admin/components/AdminCarouselGroupEditorView.d.ts +9 -1
  41. package/dist/features/admin/components/AdminCarouselGroupEditorView.js +42 -10
  42. package/dist/features/admin/components/AdminCarouselView.js +1 -1
  43. package/dist/features/admin/components/AdminFeatureEditorView.js +1 -1
  44. package/dist/features/admin/components/AdminGroupedListingsView.js +14 -2
  45. package/dist/features/admin/components/AdminSidebar.js +1 -1
  46. package/dist/features/admin/components/AdminViewCards.js +2 -2
  47. package/dist/features/admin/components/DataTable.js +2 -2
  48. package/dist/features/admin/schemas/firestore.js +9 -3
  49. package/dist/features/auctions/actions/bid-actions.js +45 -4
  50. package/dist/features/auctions/components/AuctionBottomActions.d.ts +21 -6
  51. package/dist/features/auctions/components/AuctionBottomActions.js +42 -11
  52. package/dist/features/auctions/components/AuctionDetailPageView.d.ts +8 -3
  53. package/dist/features/auctions/components/AuctionDetailPageView.js +2 -2
  54. package/dist/features/auctions/components/MarketplaceAuctionCard.js +4 -5
  55. package/dist/features/auctions/components/PlaceBidFormClient.d.ts +27 -2
  56. package/dist/features/auctions/components/PlaceBidFormClient.js +42 -20
  57. package/dist/features/auth/components/SocialAuthButtons.js +1 -1
  58. package/dist/features/auth/hooks/useAuth.d.ts +23 -1
  59. package/dist/features/auth/hooks/useAuth.js +27 -1
  60. package/dist/features/auth/permissions/constants.d.ts +1 -1
  61. package/dist/features/auth/permissions/constants.js +3 -0
  62. package/dist/features/auth/repository/session.repository.d.ts +16 -1
  63. package/dist/features/auth/repository/session.repository.js +23 -0
  64. package/dist/features/auth/repository/user.repository.js +13 -2
  65. package/dist/features/blog/components/BlogListView.js +2 -2
  66. package/dist/features/cart/components/CartPriceBreakdown.d.ts +11 -1
  67. package/dist/features/cart/components/CartPriceBreakdown.js +3 -3
  68. package/dist/features/cart/components/StoreAddonsPicker.d.ts +9 -0
  69. package/dist/features/cart/components/StoreAddonsPicker.js +15 -5
  70. package/dist/features/cart/components/index.d.ts +1 -1
  71. package/dist/features/cart/components/index.js +1 -1
  72. package/dist/features/cart/hooks/useGuestCartMerge.d.ts +8 -1
  73. package/dist/features/cart/hooks/useGuestCartMerge.js +2 -1
  74. package/dist/features/cart/utils/line-total.d.ts +25 -0
  75. package/dist/features/cart/utils/line-total.js +26 -0
  76. package/dist/features/catalogue/repository/catalogue.repository.d.ts +12 -0
  77. package/dist/features/catalogue/repository/catalogue.repository.js +15 -0
  78. package/dist/features/categories/components/CategoryTree.js +2 -2
  79. package/dist/features/contact/components/ContactForm.js +1 -1
  80. package/dist/features/copilot/hooks/useCopilotFeedback.d.ts +1 -1
  81. package/dist/features/copilot/hooks/useCopilotFeedback.js +1 -1
  82. package/dist/features/events/components/AdminEventEditorView.js +16 -9
  83. package/dist/features/events/components/AdminEventEntriesView.js +7 -13
  84. package/dist/features/events/components/EventRafflesSection.js +14 -2
  85. package/dist/features/events/components/EventStatusBadge.js +1 -1
  86. package/dist/features/events/components/event-entry-detail-fields.d.ts +24 -0
  87. package/dist/features/events/components/event-entry-detail-fields.js +13 -0
  88. package/dist/features/faq/components/ContactCTA.js +1 -1
  89. package/dist/features/faq/components/FAQHelpfulButtons.js +2 -2
  90. package/dist/features/faq/repository/faqs.repository.d.ts +16 -1
  91. package/dist/features/faq/repository/faqs.repository.js +64 -54
  92. package/dist/features/faq/schemas/firestore.d.ts +3 -2
  93. package/dist/features/faq/schemas/firestore.js +2 -2
  94. package/dist/features/faq/schemas/index.d.ts +3 -3
  95. package/dist/features/faq/schemas/index.js +1 -1
  96. package/dist/features/filters/FilterFacetSection.js +2 -2
  97. package/dist/features/forms/Toggle.js +1 -1
  98. package/dist/features/grouped/components/GroupedListingEditorView.d.ts +18 -0
  99. package/dist/features/grouped/components/GroupedListingEditorView.js +192 -0
  100. package/dist/features/homepage/components/AdvertisementBanner.js +2 -2
  101. package/dist/features/homepage/components/BrandsSection.js +1 -1
  102. package/dist/features/homepage/components/CTABannerSection.js +1 -1
  103. package/dist/features/homepage/components/HomepageCustomerReviewsSection.js +1 -1
  104. package/dist/features/homepage/components/HomepageSectionBoundary.d.ts +39 -0
  105. package/dist/features/homepage/components/HomepageSectionBoundary.js +49 -0
  106. package/dist/features/homepage/components/ShopByCategorySection.js +1 -1
  107. package/dist/features/homepage/components/WelcomeSection.js +1 -1
  108. package/dist/features/homepage/lib/section-renderer.js +2 -1
  109. package/dist/features/layout/AppLayoutShell.js +5 -5
  110. package/dist/features/layout/BottomActions.js +5 -5
  111. package/dist/features/layout/BottomSheet.js +1 -1
  112. package/dist/features/layout/MainNavbar.js +1 -1
  113. package/dist/features/layout/NavbarLayout.js +1 -1
  114. package/dist/features/layout/TitleBarLayout.js +3 -3
  115. package/dist/features/layout/hooks/useFormBottomActions.d.ts +11 -1
  116. package/dist/features/layout/hooks/useFormBottomActions.js +16 -2
  117. package/dist/features/lottery/schemas/config-write.d.ts +141 -0
  118. package/dist/features/lottery/schemas/config-write.js +139 -0
  119. package/dist/features/media/MediaPickerModal.js +3 -1
  120. package/dist/features/media/MediaVideo.js +4 -2
  121. package/dist/features/media/components/MediaSlider.js +1 -1
  122. package/dist/features/orders/components/OrdersList.js +2 -2
  123. package/dist/features/payments/repository/payout.repository.d.ts +11 -1
  124. package/dist/features/payments/repository/payout.repository.js +14 -6
  125. package/dist/features/pre-orders/components/MarketplacePreorderCard.js +3 -2
  126. package/dist/features/pre-orders/components/PreOrderBadge.js +1 -1
  127. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +1 -1
  128. package/dist/features/products/columns/productTableColumns.js +3 -3
  129. package/dist/features/products/components/CompareOverlay.js +1 -1
  130. package/dist/features/products/components/PrizeDrawDetailPageView.js +1 -1
  131. package/dist/features/products/components/PrizeDrawEntryActions.js +2 -2
  132. package/dist/features/products/components/PrizeDrawsSection.js +5 -1
  133. package/dist/features/products/components/ProductDetailActions.js +11 -7
  134. package/dist/features/products/components/ProductDetailPageView.js +3 -3
  135. package/dist/features/products/components/ProductFeatureBadges.js +2 -2
  136. package/dist/features/products/components/ProductFeaturesSelector.js +1 -1
  137. package/dist/features/products/components/ProductFilters.js +15 -8
  138. package/dist/features/products/components/ProductGrid.js +8 -17
  139. package/dist/features/products/components/ProductTabs.js +7 -4
  140. package/dist/features/products/components/ProductTabsShell.js +2 -7
  141. package/dist/features/products/constants/action-defs.d.ts +21 -4
  142. package/dist/features/products/constants/action-defs.js +7 -3
  143. package/dist/features/products/hooks/useRelatedProducts.js +2 -1
  144. package/dist/features/products/repository/products.repository.d.ts +1 -1
  145. package/dist/features/products/repository/products.repository.js +27 -13
  146. package/dist/features/products/schemas/firestore.d.ts +3 -2
  147. package/dist/features/products/schemas/firestore.js +1 -1
  148. package/dist/features/reviews/actions/review-actions.d.ts +8 -1
  149. package/dist/features/reviews/actions/review-actions.js +14 -3
  150. package/dist/features/reviews/components/ReviewDetailShell.js +10 -4
  151. package/dist/features/reviews/components/ReviewModal.js +1 -1
  152. package/dist/features/reviews/repository/reviews.repository.d.ts +25 -1
  153. package/dist/features/reviews/repository/reviews.repository.js +53 -13
  154. package/dist/features/reviews/schemas/firestore.d.ts +6 -0
  155. package/dist/features/scams/components/ScamRegistryView.js +2 -2
  156. package/dist/features/search/components/Search.js +3 -3
  157. package/dist/features/search/components/SearchResultsSection.js +1 -1
  158. package/dist/features/seller/components/SellerAddressesView.js +3 -1
  159. package/dist/features/seller/components/SellerBidsView.js +6 -1
  160. package/dist/features/seller/components/SellerSidebar.js +1 -1
  161. package/dist/features/shell/SectionForm.d.ts +21 -1
  162. package/dist/features/shell/SectionForm.js +15 -3
  163. package/dist/features/shell/build-sections.js +10 -1
  164. package/dist/features/shell/field-ui-meta.d.ts +21 -0
  165. package/dist/features/shipments/components/AdminShipmentLotItemsView.js +23 -6
  166. package/dist/features/site-settings/components/ActionPermissionsManager.js +1 -1
  167. package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +21 -4
  168. package/dist/features/store-extensions/repository/store-extensions.repositories.js +24 -4
  169. package/dist/features/store-extensions/schemas/shipping-config-form.js +13 -0
  170. package/dist/features/store-extensions/schemas/store-category-form.js +1 -1
  171. package/dist/features/stores/actions/store-query-actions.js +25 -1
  172. package/dist/features/stores/components/InteractiveStoreCard.js +1 -1
  173. package/dist/features/stores/components/StoreHeader.js +1 -1
  174. package/dist/features/stores/components/StoreNavTabs.js +20 -8
  175. package/dist/features/support/repository/support.repository.d.ts +12 -0
  176. package/dist/features/support/repository/support.repository.js +15 -0
  177. package/dist/features/support/schemas/ticket-create-form.d.ts +33 -0
  178. package/dist/features/support/schemas/ticket-create-form.js +82 -0
  179. package/dist/features/tester/seed-data/products-tester-seed-data.js +2 -2
  180. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +138 -1
  181. package/dist/features/wishlist/components/WishlistHeartButton.d.ts +55 -0
  182. package/dist/features/wishlist/components/WishlistHeartButton.js +52 -0
  183. package/dist/features/wishlist/components/index.d.ts +2 -1
  184. package/dist/features/wishlist/components/index.js +1 -1
  185. package/dist/index.d.ts +10 -3
  186. package/dist/index.js +19 -4
  187. package/dist/monitoring/error-tracking.js +39 -5
  188. package/dist/monitoring/server-logger.js +70 -32
  189. package/dist/next/components/ErrorView.js +1 -1
  190. package/dist/next/routing/route-map.d.ts +39 -7
  191. package/dist/next/routing/route-map.js +34 -4
  192. package/dist/providers/db-firebase/base.repository.d.ts +30 -0
  193. package/dist/providers/db-firebase/base.repository.js +56 -9
  194. package/dist/react/ErrorBoundary.js +9 -1
  195. package/dist/react/contexts/SessionContext.js +6 -2
  196. package/dist/react/hooks/useAuthGate.d.ts +22 -0
  197. package/dist/react/hooks/useAuthGate.js +86 -7
  198. package/dist/schemas/registry.d.ts +3 -3
  199. package/dist/security/index.d.ts +1 -1
  200. package/dist/security/index.js +1 -1
  201. package/dist/security/pii-encrypt.d.ts +13 -5
  202. package/dist/security/pii-encrypt.js +132 -6
  203. package/dist/security/pii-schemas.d.ts +20 -4
  204. package/dist/security/pii-schemas.js +23 -5
  205. package/dist/seed/_helpers/product-search-txt.d.ts +18 -0
  206. package/dist/seed/_helpers/product-search-txt.js +33 -0
  207. package/dist/seed/faq-seed-data.d.ts +16 -0
  208. package/dist/seed/faq-seed-data.js +22 -1
  209. package/dist/seed/products-art-seed-data.js +49 -7
  210. package/dist/seed/products-auctions-seed-data.js +12 -4
  211. package/dist/seed/products-classifieds-seed-data.d.ts +5 -0
  212. package/dist/seed/products-classifieds-seed-data.js +65 -9
  213. package/dist/seed/products-digital-codes-seed-data.d.ts +5 -0
  214. package/dist/seed/products-digital-codes-seed-data.js +65 -9
  215. package/dist/seed/products-live-items-seed-data.d.ts +5 -0
  216. package/dist/seed/products-live-items-seed-data.js +33 -5
  217. package/dist/seed/products-preorders-seed-data.d.ts +1 -1
  218. package/dist/seed/products-preorders-seed-data.js +6 -2
  219. package/dist/seed/products-prize-draws-seed-data.d.ts +5 -0
  220. package/dist/seed/products-prize-draws-seed-data.js +57 -8
  221. package/dist/seed/products-standard-seed-data.js +6 -2
  222. package/dist/seed/products-stickers-seed-data.js +49 -7
  223. package/dist/seed/site-settings-seed-data.js +5 -3
  224. package/dist/seo/json-ld.d.ts +44 -2
  225. package/dist/seo/json-ld.js +57 -11
  226. package/dist/styles.css +368 -90
  227. package/dist/tailwind-utilities.css +1 -1
  228. package/dist/tokens/index.d.ts +1 -1
  229. package/dist/tokens/index.js +1 -1
  230. package/dist/tokens/themes/default-dark.js +6 -0
  231. package/dist/tokens/themes/default-light.js +5 -0
  232. package/dist/tokens/tokens.css +84 -0
  233. package/dist/ui/DataTable.js +1 -1
  234. package/dist/ui/DataTable.style.css +3 -3
  235. package/dist/ui/components/BulkActionBar.style.css +1 -3
  236. package/dist/ui/components/Button.js +15 -6
  237. package/dist/ui/components/Dropdown.style.css +1 -4
  238. package/dist/ui/components/FilterChipGroup.js +1 -1
  239. package/dist/ui/components/FilterDrawer.style.css +1 -1
  240. package/dist/ui/components/Icon.d.ts +53 -0
  241. package/dist/ui/components/Icon.js +41 -0
  242. package/dist/ui/components/IconButton.d.ts +13 -0
  243. package/dist/ui/components/IconButton.js +13 -0
  244. package/dist/ui/components/IconButton.style.css +18 -10
  245. package/dist/ui/components/ListingLayout.style.css +2 -10
  246. package/dist/ui/components/ListingToolbar.js +6 -6
  247. package/dist/ui/components/Modal.js +1 -1
  248. package/dist/ui/components/PaginatedSelect.style.css +3 -3
  249. package/dist/ui/components/Pagination.style.css +1 -6
  250. package/dist/ui/components/RichTextEditor.js +1 -1
  251. package/dist/ui/components/RowActionMenu.style.css +1 -1
  252. package/dist/ui/components/SideDrawer.style.css +1 -1
  253. package/dist/ui/components/Slider.js +1 -1
  254. package/dist/ui/components/TabBarShell.d.ts +78 -0
  255. package/dist/ui/components/TabBarShell.js +95 -0
  256. package/dist/ui/components/Tabs.js +12 -8
  257. package/dist/ui/components/Tabs.style.css +249 -41
  258. package/dist/ui/components/ViewToggle.style.css +1 -5
  259. package/dist/ui/components/tabs-overflow.d.ts +17 -0
  260. package/dist/ui/components/tabs-overflow.js +142 -0
  261. package/dist/ui/icons/icon-registry.d.ts +38 -0
  262. package/dist/ui/icons/icon-registry.js +37 -0
  263. package/dist/ui/index.d.ts +6 -0
  264. package/dist/ui/index.js +3 -0
  265. package/dist/utils/index.d.ts +1 -1
  266. package/dist/utils/index.js +1 -1
  267. package/dist/utils/media-url.d.ts +27 -4
  268. package/dist/utils/media-url.js +37 -6
  269. package/dist/utils/search-txt.d.ts +71 -0
  270. package/dist/utils/search-txt.js +118 -0
  271. 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-zinc-50 hover:bg-[var(--appkit-color-surface-elevated)] hover:text-zinc-900 dark:hover:text-zinc-100"}`;
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 = "bg-zinc-50 border border-zinc-200 bg-[var(--appkit-color-surface)]/40 border-[var(--appkit-color-border)]";
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
- await apiClient.post(SUPPORT_ENDPOINTS.TICKETS, {
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
- export declare function useUpdateAddress(id: string, options?: {
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) => apiClient.patch(byIdEndpoint(id), 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 { type JsonValue } from "@mohasinac/appkit/client";
1
+ import type { JsonValue } from "../../../schemas/types";
2
2
  export interface UserProfile {
3
3
  uid: string;
4
4
  email: string | null;
@@ -1,4 +1,4 @@
1
- import { useApiMutation } from "@mohasinac/appkit/client";
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", { chatId, err });
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-neutral-200 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" })] }));
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, extra:
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
- mutationFn: () => apiClient.post(ADMIN_ENDPOINTS.CAROUSELS, { name, status }),
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
- if (onCreated)
28
- onCreated(id);
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 ?? "Failed to create carousel.", "error");
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-zinc-50 hover:bg-[var(--appkit-color-surface-elevated)]"}`, 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 }))] }));
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-zinc-50 hover:bg-[var(--appkit-color-surface-elevated)]";
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-zinc-50 hover:bg-[var(--appkit-color-surface-elevated)]/60 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 })] }));
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-zinc-50 hover:bg-[var(--appkit-color-surface-elevated)]/50",
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
- : "border-zinc-100 border-[var(--appkit-color-border)] bg-[var(--appkit-color-surface)]",
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-[var(--appkit-color-surface)] hover:bg-[var(--appkit-color-surface-elevated)]"}`, children: p }, p))) }))] }));
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
- whatsappNotifyFeeEnabled: false,
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: false,
265
+ giftWrapFeeEnabled: true,
260
266
  giftWrapFee: 49,
261
- shipmentProtectionFeeEnabled: false,
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
- const product = await productRepository.findById(productId);
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