@mohasinac/appkit 4.26.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 (277) 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/bundles/data.d.ts +19 -0
  5. package/dist/_internal/server/features/bundles/data.js +32 -0
  6. package/dist/_internal/server/features/bundles/index.d.ts +1 -1
  7. package/dist/_internal/server/features/bundles/index.js +1 -1
  8. package/dist/_internal/server/features/checkout/actions.js +6 -4
  9. package/dist/_internal/server/features/classified/metadata.js +3 -1
  10. package/dist/_internal/server/features/digital-code/metadata.js +3 -1
  11. package/dist/_internal/server/features/live/metadata.js +3 -1
  12. package/dist/_internal/server/features/refunds/actions.js +3 -2
  13. package/dist/_internal/server/features/reviews/actions.js +3 -2
  14. package/dist/_internal/server/features/seo/sitemap.js +189 -40
  15. package/dist/_internal/server/jobs/core/listingProcessor.d.ts +0 -1
  16. package/dist/_internal/server/jobs/core/listingProcessor.js +19 -1
  17. package/dist/_internal/shared/actions/action-registry.js +14 -0
  18. package/dist/_internal/shared/checkout/order-math.d.ts +25 -3
  19. package/dist/_internal/shared/features/checkout/config.d.ts +3 -3
  20. package/dist/_internal/shared/features/checkout/config.js +6 -3
  21. package/dist/client.d.ts +32 -2
  22. package/dist/client.js +37 -2
  23. package/dist/constants/api-endpoints.d.ts +21 -3
  24. package/dist/constants/api-endpoints.js +14 -1
  25. package/dist/constants/field-names.d.ts +2 -2
  26. package/dist/constants/field-names.js +2 -2
  27. package/dist/core/newsletter.repository.js +14 -1
  28. package/dist/features/about/components/FAQPageView.js +1 -1
  29. package/dist/features/about/components/FeesView.js +1 -1
  30. package/dist/features/account/components/AddressForm.d.ts +2 -0
  31. package/dist/features/account/components/AddressForm.js +5 -1
  32. package/dist/features/account/components/UserBidsView.js +1 -1
  33. package/dist/features/account/components/UserSidebar.js +1 -1
  34. package/dist/features/account/components/UserSupportView.js +24 -2
  35. package/dist/features/account/hooks/useAddresses.d.ts +29 -1
  36. package/dist/features/account/hooks/useAddresses.js +4 -1
  37. package/dist/features/account/hooks/useProfile.d.ts +1 -1
  38. package/dist/features/account/hooks/useProfile.js +1 -1
  39. package/dist/features/addresses/repository/addresses.repository.d.ts +13 -0
  40. package/dist/features/addresses/repository/addresses.repository.js +19 -0
  41. package/dist/features/admin/actions/chat-actions.js +5 -1
  42. package/dist/features/admin/components/AdminAdsView.js +1 -1
  43. package/dist/features/admin/components/AdminBidsView.js +6 -1
  44. package/dist/features/admin/components/AdminCarouselGroupEditorView.d.ts +9 -1
  45. package/dist/features/admin/components/AdminCarouselGroupEditorView.js +42 -10
  46. package/dist/features/admin/components/AdminCarouselView.js +1 -1
  47. package/dist/features/admin/components/AdminFeatureEditorView.js +1 -1
  48. package/dist/features/admin/components/AdminGroupedListingsView.js +14 -2
  49. package/dist/features/admin/components/AdminSidebar.js +1 -1
  50. package/dist/features/admin/components/AdminViewCards.js +2 -2
  51. package/dist/features/admin/components/DataTable.js +2 -2
  52. package/dist/features/admin/schemas/firestore.js +9 -3
  53. package/dist/features/auctions/actions/bid-actions.js +45 -4
  54. package/dist/features/auctions/components/AuctionBottomActions.d.ts +21 -6
  55. package/dist/features/auctions/components/AuctionBottomActions.js +42 -11
  56. package/dist/features/auctions/components/AuctionDetailPageView.d.ts +8 -3
  57. package/dist/features/auctions/components/AuctionDetailPageView.js +2 -2
  58. package/dist/features/auctions/components/MarketplaceAuctionCard.js +4 -5
  59. package/dist/features/auctions/components/PlaceBidFormClient.d.ts +27 -2
  60. package/dist/features/auctions/components/PlaceBidFormClient.js +42 -20
  61. package/dist/features/auth/components/SocialAuthButtons.js +1 -1
  62. package/dist/features/auth/hooks/useAuth.d.ts +23 -1
  63. package/dist/features/auth/hooks/useAuth.js +27 -1
  64. package/dist/features/auth/permissions/constants.d.ts +1 -1
  65. package/dist/features/auth/permissions/constants.js +3 -0
  66. package/dist/features/auth/repository/session.repository.d.ts +16 -1
  67. package/dist/features/auth/repository/session.repository.js +23 -0
  68. package/dist/features/auth/repository/user.repository.js +13 -2
  69. package/dist/features/blog/components/BlogListView.js +2 -2
  70. package/dist/features/cart/components/CartPriceBreakdown.d.ts +11 -1
  71. package/dist/features/cart/components/CartPriceBreakdown.js +3 -3
  72. package/dist/features/cart/components/StoreAddonsPicker.d.ts +9 -0
  73. package/dist/features/cart/components/StoreAddonsPicker.js +15 -5
  74. package/dist/features/cart/components/index.d.ts +1 -1
  75. package/dist/features/cart/components/index.js +1 -1
  76. package/dist/features/cart/hooks/useGuestCartMerge.d.ts +8 -1
  77. package/dist/features/cart/hooks/useGuestCartMerge.js +2 -1
  78. package/dist/features/cart/utils/line-total.d.ts +25 -0
  79. package/dist/features/cart/utils/line-total.js +26 -0
  80. package/dist/features/catalogue/repository/catalogue.repository.d.ts +12 -0
  81. package/dist/features/catalogue/repository/catalogue.repository.js +15 -0
  82. package/dist/features/categories/components/CategoryTree.js +2 -2
  83. package/dist/features/contact/components/ContactForm.js +1 -1
  84. package/dist/features/copilot/hooks/useCopilotFeedback.d.ts +1 -1
  85. package/dist/features/copilot/hooks/useCopilotFeedback.js +1 -1
  86. package/dist/features/events/components/AdminEventEditorView.js +16 -9
  87. package/dist/features/events/components/AdminEventEntriesView.js +7 -13
  88. package/dist/features/events/components/EventRafflesSection.js +14 -2
  89. package/dist/features/events/components/EventStatusBadge.js +1 -1
  90. package/dist/features/events/components/event-entry-detail-fields.d.ts +24 -0
  91. package/dist/features/events/components/event-entry-detail-fields.js +13 -0
  92. package/dist/features/faq/components/ContactCTA.js +1 -1
  93. package/dist/features/faq/components/FAQHelpfulButtons.js +2 -2
  94. package/dist/features/faq/repository/faqs.repository.d.ts +16 -1
  95. package/dist/features/faq/repository/faqs.repository.js +64 -54
  96. package/dist/features/faq/schemas/firestore.d.ts +3 -2
  97. package/dist/features/faq/schemas/firestore.js +2 -2
  98. package/dist/features/faq/schemas/index.d.ts +3 -3
  99. package/dist/features/faq/schemas/index.js +1 -1
  100. package/dist/features/filters/FilterFacetSection.js +2 -2
  101. package/dist/features/forms/Toggle.js +1 -1
  102. package/dist/features/grouped/components/GroupedListingEditorView.d.ts +18 -0
  103. package/dist/features/grouped/components/GroupedListingEditorView.js +192 -0
  104. package/dist/features/homepage/components/AdvertisementBanner.js +2 -2
  105. package/dist/features/homepage/components/BrandsSection.js +1 -1
  106. package/dist/features/homepage/components/CTABannerSection.js +1 -1
  107. package/dist/features/homepage/components/HomepageCustomerReviewsSection.js +1 -1
  108. package/dist/features/homepage/components/HomepageSectionBoundary.d.ts +39 -0
  109. package/dist/features/homepage/components/HomepageSectionBoundary.js +49 -0
  110. package/dist/features/homepage/components/ShopByCategorySection.js +1 -1
  111. package/dist/features/homepage/components/WelcomeSection.js +1 -1
  112. package/dist/features/homepage/lib/section-renderer.js +2 -1
  113. package/dist/features/layout/AppLayoutShell.js +5 -5
  114. package/dist/features/layout/BottomActions.js +5 -5
  115. package/dist/features/layout/BottomSheet.js +1 -1
  116. package/dist/features/layout/MainNavbar.js +1 -1
  117. package/dist/features/layout/NavbarLayout.js +1 -1
  118. package/dist/features/layout/TitleBarLayout.js +3 -3
  119. package/dist/features/layout/hooks/useFormBottomActions.d.ts +11 -1
  120. package/dist/features/layout/hooks/useFormBottomActions.js +16 -2
  121. package/dist/features/lottery/schemas/config-write.d.ts +141 -0
  122. package/dist/features/lottery/schemas/config-write.js +139 -0
  123. package/dist/features/media/MediaPickerModal.js +3 -1
  124. package/dist/features/media/MediaVideo.js +4 -2
  125. package/dist/features/media/components/MediaSlider.js +1 -1
  126. package/dist/features/orders/components/OrdersList.js +2 -2
  127. package/dist/features/payments/repository/payout.repository.d.ts +11 -1
  128. package/dist/features/payments/repository/payout.repository.js +14 -6
  129. package/dist/features/pre-orders/components/MarketplacePreorderCard.js +3 -2
  130. package/dist/features/pre-orders/components/PreOrderBadge.js +1 -1
  131. package/dist/features/pre-orders/components/PreOrderDetailPageView.js +1 -1
  132. package/dist/features/products/columns/productTableColumns.js +3 -3
  133. package/dist/features/products/components/CompareOverlay.js +1 -1
  134. package/dist/features/products/components/PrizeDrawDetailPageView.js +1 -1
  135. package/dist/features/products/components/PrizeDrawEntryActions.js +2 -2
  136. package/dist/features/products/components/PrizeDrawsSection.js +5 -1
  137. package/dist/features/products/components/ProductDetailActions.js +11 -7
  138. package/dist/features/products/components/ProductDetailPageView.js +3 -3
  139. package/dist/features/products/components/ProductFeatureBadges.js +2 -2
  140. package/dist/features/products/components/ProductFeaturesSelector.js +1 -1
  141. package/dist/features/products/components/ProductFilters.js +15 -8
  142. package/dist/features/products/components/ProductGrid.js +8 -17
  143. package/dist/features/products/components/ProductTabs.js +7 -4
  144. package/dist/features/products/components/ProductTabsShell.js +2 -7
  145. package/dist/features/products/constants/action-defs.d.ts +21 -4
  146. package/dist/features/products/constants/action-defs.js +7 -3
  147. package/dist/features/products/hooks/useRelatedProducts.js +2 -1
  148. package/dist/features/products/repository/products.repository.d.ts +1 -1
  149. package/dist/features/products/repository/products.repository.js +27 -13
  150. package/dist/features/products/schemas/firestore.d.ts +3 -2
  151. package/dist/features/products/schemas/firestore.js +1 -1
  152. package/dist/features/reviews/actions/review-actions.d.ts +8 -1
  153. package/dist/features/reviews/actions/review-actions.js +14 -3
  154. package/dist/features/reviews/components/ReviewDetailShell.js +10 -4
  155. package/dist/features/reviews/components/ReviewModal.js +1 -1
  156. package/dist/features/reviews/repository/reviews.repository.d.ts +25 -1
  157. package/dist/features/reviews/repository/reviews.repository.js +53 -13
  158. package/dist/features/reviews/schemas/firestore.d.ts +6 -0
  159. package/dist/features/scams/components/ScamRegistryView.js +2 -2
  160. package/dist/features/search/components/Search.js +3 -3
  161. package/dist/features/search/components/SearchResultsSection.js +1 -1
  162. package/dist/features/seller/components/SellerAddressesView.js +3 -1
  163. package/dist/features/seller/components/SellerBidsView.js +6 -1
  164. package/dist/features/seller/components/SellerSidebar.js +1 -1
  165. package/dist/features/shell/SectionForm.d.ts +21 -1
  166. package/dist/features/shell/SectionForm.js +15 -3
  167. package/dist/features/shell/build-sections.js +10 -1
  168. package/dist/features/shell/field-ui-meta.d.ts +21 -0
  169. package/dist/features/shipments/components/AdminShipmentLotItemsView.js +23 -6
  170. package/dist/features/site-settings/components/ActionPermissionsManager.js +1 -1
  171. package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +21 -4
  172. package/dist/features/store-extensions/repository/store-extensions.repositories.js +24 -4
  173. package/dist/features/store-extensions/schemas/shipping-config-form.js +13 -0
  174. package/dist/features/store-extensions/schemas/store-category-form.js +1 -1
  175. package/dist/features/stores/actions/store-query-actions.js +25 -1
  176. package/dist/features/stores/components/InteractiveStoreCard.js +1 -1
  177. package/dist/features/stores/components/StoreHeader.js +1 -1
  178. package/dist/features/stores/components/StoreNavTabs.js +20 -8
  179. package/dist/features/support/repository/support.repository.d.ts +12 -0
  180. package/dist/features/support/repository/support.repository.js +15 -0
  181. package/dist/features/support/schemas/ticket-create-form.d.ts +33 -0
  182. package/dist/features/support/schemas/ticket-create-form.js +82 -0
  183. package/dist/features/tester/seed-data/products-tester-seed-data.js +108 -2
  184. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +235 -13
  185. package/dist/features/wishlist/components/WishlistHeartButton.d.ts +55 -0
  186. package/dist/features/wishlist/components/WishlistHeartButton.js +52 -0
  187. package/dist/features/wishlist/components/index.d.ts +2 -1
  188. package/dist/features/wishlist/components/index.js +1 -1
  189. package/dist/index.d.ts +10 -3
  190. package/dist/index.js +19 -4
  191. package/dist/monitoring/error-tracking.js +39 -5
  192. package/dist/monitoring/server-logger.js +70 -32
  193. package/dist/next/components/ErrorView.js +1 -1
  194. package/dist/next/routing/route-map.d.ts +39 -7
  195. package/dist/next/routing/route-map.js +34 -4
  196. package/dist/providers/db-firebase/base.repository.d.ts +30 -0
  197. package/dist/providers/db-firebase/base.repository.js +56 -9
  198. package/dist/react/ErrorBoundary.js +9 -1
  199. package/dist/react/contexts/SessionContext.js +6 -2
  200. package/dist/react/hooks/useAuthGate.d.ts +22 -0
  201. package/dist/react/hooks/useAuthGate.js +86 -7
  202. package/dist/schemas/registry.d.ts +3 -3
  203. package/dist/security/index.d.ts +1 -1
  204. package/dist/security/index.js +1 -1
  205. package/dist/security/pii-encrypt.d.ts +13 -5
  206. package/dist/security/pii-encrypt.js +132 -6
  207. package/dist/security/pii-schemas.d.ts +20 -4
  208. package/dist/security/pii-schemas.js +23 -5
  209. package/dist/seed/_helpers/product-search-txt.d.ts +18 -0
  210. package/dist/seed/_helpers/product-search-txt.js +33 -0
  211. package/dist/seed/faq-seed-data.d.ts +16 -0
  212. package/dist/seed/faq-seed-data.js +22 -1
  213. package/dist/seed/products-art-seed-data.js +49 -7
  214. package/dist/seed/products-auctions-seed-data.js +12 -4
  215. package/dist/seed/products-classifieds-seed-data.d.ts +5 -0
  216. package/dist/seed/products-classifieds-seed-data.js +65 -9
  217. package/dist/seed/products-digital-codes-seed-data.d.ts +5 -0
  218. package/dist/seed/products-digital-codes-seed-data.js +65 -9
  219. package/dist/seed/products-live-items-seed-data.d.ts +5 -0
  220. package/dist/seed/products-live-items-seed-data.js +33 -5
  221. package/dist/seed/products-preorders-seed-data.d.ts +1 -1
  222. package/dist/seed/products-preorders-seed-data.js +6 -2
  223. package/dist/seed/products-prize-draws-seed-data.d.ts +5 -0
  224. package/dist/seed/products-prize-draws-seed-data.js +57 -8
  225. package/dist/seed/products-standard-seed-data.js +6 -2
  226. package/dist/seed/products-stickers-seed-data.js +49 -7
  227. package/dist/seed/site-settings-seed-data.js +5 -3
  228. package/dist/seo/json-ld.d.ts +44 -2
  229. package/dist/seo/json-ld.js +57 -11
  230. package/dist/server.d.ts +1 -1
  231. package/dist/server.js +1 -1
  232. package/dist/styles.css +368 -90
  233. package/dist/tailwind-utilities.css +1 -1
  234. package/dist/tokens/index.d.ts +1 -1
  235. package/dist/tokens/index.js +1 -1
  236. package/dist/tokens/themes/default-dark.js +6 -0
  237. package/dist/tokens/themes/default-light.js +5 -0
  238. package/dist/tokens/tokens.css +84 -0
  239. package/dist/ui/DataTable.js +1 -1
  240. package/dist/ui/DataTable.style.css +3 -3
  241. package/dist/ui/components/BulkActionBar.style.css +1 -3
  242. package/dist/ui/components/Button.js +15 -6
  243. package/dist/ui/components/Dropdown.style.css +1 -4
  244. package/dist/ui/components/FilterChipGroup.js +1 -1
  245. package/dist/ui/components/FilterDrawer.style.css +1 -1
  246. package/dist/ui/components/Icon.d.ts +53 -0
  247. package/dist/ui/components/Icon.js +41 -0
  248. package/dist/ui/components/IconButton.d.ts +13 -0
  249. package/dist/ui/components/IconButton.js +13 -0
  250. package/dist/ui/components/IconButton.style.css +18 -10
  251. package/dist/ui/components/ListingLayout.style.css +2 -10
  252. package/dist/ui/components/ListingToolbar.js +6 -6
  253. package/dist/ui/components/Modal.js +1 -1
  254. package/dist/ui/components/PaginatedSelect.style.css +3 -3
  255. package/dist/ui/components/Pagination.style.css +1 -6
  256. package/dist/ui/components/RichTextEditor.js +1 -1
  257. package/dist/ui/components/RowActionMenu.style.css +1 -1
  258. package/dist/ui/components/SideDrawer.style.css +1 -1
  259. package/dist/ui/components/Slider.js +1 -1
  260. package/dist/ui/components/TabBarShell.d.ts +78 -0
  261. package/dist/ui/components/TabBarShell.js +95 -0
  262. package/dist/ui/components/Tabs.js +12 -8
  263. package/dist/ui/components/Tabs.style.css +249 -41
  264. package/dist/ui/components/ViewToggle.style.css +1 -5
  265. package/dist/ui/components/tabs-overflow.d.ts +17 -0
  266. package/dist/ui/components/tabs-overflow.js +142 -0
  267. package/dist/ui/icons/icon-registry.d.ts +38 -0
  268. package/dist/ui/icons/icon-registry.js +37 -0
  269. package/dist/ui/index.d.ts +6 -0
  270. package/dist/ui/index.js +3 -0
  271. package/dist/utils/index.d.ts +1 -1
  272. package/dist/utils/index.js +1 -1
  273. package/dist/utils/media-url.d.ts +27 -4
  274. package/dist/utils/media-url.js +37 -6
  275. package/dist/utils/search-txt.d.ts +71 -0
  276. package/dist/utils/search-txt.js +118 -0
  277. package/package.json +1 -1
@@ -4,6 +4,8 @@ import React from "react";
4
4
  import { useApiMutation } from "@mohasinac/appkit/client";
5
5
  import { Alert, Badge, Button, Div, FieldCheckbox, FieldInput, Grid, Heading, Modal, Pagination, Skeleton, Stack, Table, Thead, Tbody, Tr, Th, Td, Text, Textarea, } from "../../../ui";
6
6
  import { apiClient } from "../../../http";
7
+ import { createShipmentItemSchema } from "../schemas/validation";
8
+ import { ValidationError } from "../../../errors/validation-error";
7
9
  import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
8
10
  import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
9
11
  import { useShipmentItems } from "../hooks/useShipments";
@@ -42,12 +44,27 @@ export function AdminShipmentLotItemsView({ shipmentId, lotId }) {
42
44
  const [linkTarget, setLinkTarget] = React.useState(null);
43
45
  const addItemMutation = useApiMutation({
44
46
  successMessage: "Item added",
45
- mutationFn: () => apiClient.post(ADMIN_ENDPOINTS.SHIPMENT_LOT_ITEMS(shipmentId, lotId), {
46
- title,
47
- quantity: Number(quantity),
48
- isForSelfUse,
49
- price: isForSelfUse ? undefined : Math.round(Number(priceRupees) * 100) / 100,
50
- }),
47
+ mutationFn: () => {
48
+ /*
49
+ * `createShipmentItemSchema` already validated this route and had no
50
+ * client consumer, so its rule — a projected sale price is required
51
+ * unless the item is for self use — only ever surfaced as a 400 that
52
+ * named no field. `Number(priceRupees)` also yields NaN on a typo,
53
+ * which the schema rejects and the old payload sent.
54
+ */
55
+ const parsed = createShipmentItemSchema.safeParse({
56
+ title,
57
+ quantity: Number(quantity),
58
+ isForSelfUse,
59
+ price: isForSelfUse ? undefined : Math.round(Number(priceRupees) * 100) / 100,
60
+ });
61
+ if (!parsed.success) {
62
+ // ValidationError carries the issue list; a bare Error flattens it to
63
+ // one string and loses which field failed.
64
+ throw new ValidationError(parsed.error.issues[0]?.message ?? "Check the item details.", parsed.error.issues);
65
+ }
66
+ return apiClient.post(ADMIN_ENDPOINTS.SHIPMENT_LOT_ITEMS(shipmentId, lotId), parsed.data);
67
+ },
51
68
  onSuccess: () => {
52
69
  setTitle("");
53
70
  setQuantity("1");
@@ -57,7 +57,7 @@ const CATEGORY_COLORS = {
57
57
  "Messaging": "bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-300",
58
58
  "Seller": "bg-warning-surface text-warning dark:bg-warning-surface dark:text-warning",
59
59
  "Orders": "bg-lime-100 text-lime-800 dark:bg-lime-900/30 dark:text-lime-300",
60
- "Account": "bg-[var(--appkit-color-surface)] text-zinc-700 bg-[var(--appkit-color-surface-elevated)] text-[var(--appkit-color-text-muted)]",
60
+ "Account": "bg-[var(--appkit-color-surface)] bg-[var(--appkit-color-surface-elevated)] text-[var(--appkit-color-text-muted)]",
61
61
  };
62
62
  const ALL_ACTION_IDS = Object.values(ACTION_ID);
63
63
  export function ActionPermissionsManager({ initialConfig, onUpdate }) {
@@ -1,15 +1,32 @@
1
1
  /**
2
2
  * S-STORE Extension Repositories
3
3
  *
4
- * 11 lightweight BaseRepository subclasses. None of these collections store
5
- * PII directly so no `createWithId` override is required (Pattern #9). Uses
6
- * the BaseRepository public API only: `findById`, `findBy`, `findAll`,
7
- * `create`, `update`, `delete`.
4
+ * 11 lightweight BaseRepository subclasses.
5
+ *
6
+ * This header used to claim "none of these collections store PII directly" —
7
+ * that was wrong: `PayoutMethodsRepository` holds `upiVpa`, `accountNumber` and
8
+ * `ifscCode`, which sat in cleartext because of it. It now declares `piiFields`
9
+ * and BaseRepository's write hooks encrypt them on every write path, so no
10
+ * per-method override is needed (which is the point of the hooks — Pattern #9
11
+ * described overrides that never existed).
12
+ *
13
+ * The other ten hold no PII and use the BaseRepository public API only.
8
14
  */
9
15
  import { BaseRepository } from "../../../providers/db-firebase";
16
+ import type { DocumentSnapshot } from "firebase-admin/firestore";
10
17
  import { type AnalyticsAlertDocument, type AnalyticsCardDocument, type ItemRequestDocument, type ListingTemplateDocument, type ModerationQueueDocument, type PayoutMethodDocument, type ReportDocument, type ShippingConfigDocument, type StoreCategoryDocument, type StoreGoogleConfigDocument, type StoreWhatsAppConfigDocument } from "../schemas/firestore";
11
18
  export declare class PayoutMethodsRepository extends BaseRepository<PayoutMethodDocument> {
19
+ /**
20
+ * Bank/UPI details, encrypted at rest by BaseRepository's write hooks.
21
+ *
22
+ * This repository had no encryption at all. Nothing queries these by equality
23
+ * (`listByStore` filters on `storeId`), so no read path depended on their
24
+ * plaintext.
25
+ */
26
+ protected piiFields: readonly ["upiVpa", "accountNumber", "ifscCode"];
12
27
  constructor();
28
+ /** Decrypt on read so callers see the shape they wrote. */
29
+ protected mapDoc<D = PayoutMethodDocument>(snap: DocumentSnapshot): D;
13
30
  listByStore(storeId: string): Promise<{
14
31
  items: PayoutMethodDocument[];
15
32
  }>;
@@ -1,16 +1,36 @@
1
1
  /**
2
2
  * S-STORE Extension Repositories
3
3
  *
4
- * 11 lightweight BaseRepository subclasses. None of these collections store
5
- * PII directly so no `createWithId` override is required (Pattern #9). Uses
6
- * the BaseRepository public API only: `findById`, `findBy`, `findAll`,
7
- * `create`, `update`, `delete`.
4
+ * 11 lightweight BaseRepository subclasses.
5
+ *
6
+ * This header used to claim "none of these collections store PII directly" —
7
+ * that was wrong: `PayoutMethodsRepository` holds `upiVpa`, `accountNumber` and
8
+ * `ifscCode`, which sat in cleartext because of it. It now declares `piiFields`
9
+ * and BaseRepository's write hooks encrypt them on every write path, so no
10
+ * per-method override is needed (which is the point of the hooks — Pattern #9
11
+ * described overrides that never existed).
12
+ *
13
+ * The other ten hold no PII and use the BaseRepository public API only.
8
14
  */
9
15
  import { BaseRepository } from "../../../providers/db-firebase";
16
+ import { decryptPiiFields } from "../../../security/pii-encrypt";
10
17
  import { ANALYTICS_ALERTS_COLLECTION, ANALYTICS_CARDS_COLLECTION, ITEM_REQUESTS_COLLECTION, LISTING_TEMPLATES_COLLECTION, MODERATION_QUEUE_COLLECTION, PAYOUT_METHODS_COLLECTION, REPORTS_COLLECTION, SHIPPING_CONFIGS_COLLECTION, STORE_CATEGORIES_COLLECTION, STORE_GOOGLE_CONFIG_COLLECTION, STORE_WHATSAPP_CONFIG_COLLECTION, } from "../schemas/firestore";
11
18
  export class PayoutMethodsRepository extends BaseRepository {
12
19
  constructor() {
13
20
  super(PAYOUT_METHODS_COLLECTION);
21
+ /**
22
+ * Bank/UPI details, encrypted at rest by BaseRepository's write hooks.
23
+ *
24
+ * This repository had no encryption at all. Nothing queries these by equality
25
+ * (`listByStore` filters on `storeId`), so no read path depended on their
26
+ * plaintext.
27
+ */
28
+ this.piiFields = ["upiVpa", "accountNumber", "ifscCode"];
29
+ }
30
+ /** Decrypt on read so callers see the shape they wrote. */
31
+ mapDoc(snap) {
32
+ const raw = super.mapDoc(snap);
33
+ return decryptPiiFields(raw, [...this.piiFields]);
14
34
  }
15
35
  async listByStore(storeId) {
16
36
  const items = await this.findBy("storeId", storeId);
@@ -66,17 +66,30 @@ export const shippingConfigFormSchema = z
66
66
  method: annotate(shippingMethodSchema, {
67
67
  section: "basics", quick: true, order: 2, row: "pair",
68
68
  }),
69
+ /*
70
+ * Each rate field shows only for the method it belongs to. A flat-rate
71
+ * rule has no price-per-kg, and showing one invites a seller to fill in a
72
+ * number the checkout will never read.
73
+ *
74
+ * These pair with the `superRefine` below, which requires the field under
75
+ * the SAME condition — hiding a required field would otherwise fail
76
+ * validation invisibly.
77
+ */
69
78
  flatRate: annotate(amount("Flat rate").optional(), {
70
79
  section: "rates", sectionLabel: "Rates", order: 1, row: "pair",
80
+ when: (v) => v.method === "flat",
71
81
  }),
72
82
  pricePerKg: annotate(amount("Price per kg").optional(), {
73
83
  section: "rates", order: 2, row: "pair",
84
+ when: (v) => v.method === "weight",
74
85
  }),
75
86
  freeAbove: annotate(amount("Free above").optional(), {
76
87
  section: "rates", order: 3, row: "pair",
88
+ when: (v) => v.method === "free",
77
89
  }),
78
90
  expressSurcharge: annotate(amount("Express surcharge").optional(), {
79
91
  section: "rates", order: 4, row: "pair",
92
+ when: (v) => v.method === "express",
80
93
  }),
81
94
  estimatedDays: annotate(z.coerce.number().int("Estimated days must be a whole number.").min(0).max(365).optional(), { section: "rates", order: 5, row: "pair" }),
82
95
  isDefault: annotate(z.boolean(), {
@@ -17,7 +17,7 @@
17
17
  * parses this: `storeId` is set from the session, never from the body, so a
18
18
  * caller cannot write a category into someone else's store by adding a field.
19
19
  *
20
- * `productIds` and `isActive` are deliberately absent: they are managed after
20
+ * `productIds` is deliberately absent: it is managed after
21
21
  * creation (assigning products, toggling visibility), not typed into the
22
22
  * create form, and the repository supplies their defaults.
23
23
  *
@@ -4,6 +4,7 @@
4
4
  * Public and seller-facing read functions for store listings, products,
5
5
  * auctions and reviews. No auth — callers decide whether auth is needed.
6
6
  */
7
+ import { cache } from "react";
7
8
  import { sieveFilter, SIEVE_OP } from "@mohasinac/appkit";
8
9
  import { NotFoundError } from "../../../errors";
9
10
  import { filterTestDataForViewer, filterSingleTestData } from "../../../_internal/server/features/tester/visibility";
@@ -26,8 +27,31 @@ export async function listStores(params = {}, viewer) {
26
27
  const result = await storeRepository.listStores(model);
27
28
  return { ...result, items: filterTestDataForViewer(result.items, viewer) };
28
29
  }
30
+ /**
31
+ * Request-scoped memo of the raw store fetch.
32
+ *
33
+ * CLAUDE.md § SSR Architecture requires `React.cache` on any data-fetch shared
34
+ * by a page and its `generateMetadata`. `stores/[storeSlug]/layout.tsx` awaits
35
+ * `getStoreBySlug` in BOTH — so every store page view was doing two identical
36
+ * Firestore reads.
37
+ *
38
+ * 🛑 The cache wraps the REPOSITORY call, not `getStoreBySlug`, and that is
39
+ * deliberate. `React.cache` memoises per argument list, and the two call sites
40
+ * pass different arguments — `getStoreBySlug(slug)` in `generateMetadata`,
41
+ * `getStoreBySlug(slug, viewer)` in the layout body. Wrapping the exported
42
+ * function would produce two cache entries and dedupe nothing.
43
+ *
44
+ * Making them match by reading the session in `generateMetadata` would be worse
45
+ * still: `getServerSessionUser()` is a dynamic API, and a dynamic API reached
46
+ * from metadata opts the route out of static rendering — the exact defect that
47
+ * made the whole site uncacheable (see `src/app/[locale]/layout.tsx`).
48
+ *
49
+ * So the read is cached on `storeSlug` alone and the viewer-specific
50
+ * `filterSingleTestData` is applied outside it, per caller.
51
+ */
52
+ const fetchStoreBySlug = cache((storeSlug) => storeRepository.findBySlug(storeSlug));
29
53
  export async function getStoreBySlug(storeSlug, viewer) {
30
- const store = await storeRepository.findBySlug(storeSlug);
54
+ const store = await fetchStoreBySlug(storeSlug);
31
55
  return filterSingleTestData(store, viewer);
32
56
  }
33
57
  export async function getStoreProducts(storeSlug, params = {}, viewer) {
@@ -25,5 +25,5 @@ export function InteractiveStoreCard({ store, href, selectable, isSelected = fal
25
25
  ].join(" "), onMouseDown: !isSelected ? longPress.onMouseDown : undefined, onMouseUp: !isSelected ? longPress.onMouseUp : undefined, onMouseLeave: !isSelected ? longPress.onMouseLeave : undefined, onTouchStart: !isSelected ? longPress.onTouchStart : undefined, onTouchEnd: !isSelected ? longPress.onTouchEnd : undefined, onTouchCancel: !isSelected ? longPress.onTouchCancel : undefined, children: [onSelect && (_jsx(BaseListingCard.Checkbox, { selected: isSelected, onSelect: (e) => {
26
26
  e.preventDefault();
27
27
  onSelect(store.id, !isSelected);
28
- }, label: isSelected ? "Deselect store" : "Select store", position: "top-2 left-2", className: selectable || isSelected ? "opacity-100" : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity" })), _jsxs(Link, { href: href, className: "flex flex-col flex-1 min-h-0", children: [_jsxs(Div, { surface: "muted", className: `relative aspect-video w-full ${__O.hidden} flex-shrink-0`, children: [hasBanner ? (_jsx(MediaImage, { src: store.storeBannerURL, alt: `${store.storeName} banner`, size: "banner", className: "h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" })) : (_jsx(Row, { className: "absolute inset-0", align: "center", justify: "center", children: _jsx(Span, { className: "opacity-20 select-none", size: "5xl", "aria-hidden": "true", children: "\uD83C\uDFEA" }) })), _jsx(Scrim, { direction: "bottom-up", intensity: "subtle", className: "absolute inset-x-0 bottom-0 h-12" }), store.averageRating != null && store.averageRating > 0 && (_jsxs(Div, { className: CLS_RATING_BADGE, children: ["\u2605 ", store.averageRating.toFixed(1)] }))] }), _jsxs(Stack, { paddingY: "b-md", className: "flex-1", padding: "x-md", children: [_jsx(Row, { className: "-mt-5 mb-2", align: "end", justify: "between", children: _jsx(Div, { className: "flex-shrink-0", children: hasLogo ? (_jsx(Div, { className: "relative h-10 w-10 border-2 border-white border-[var(--appkit-color-border-subtle)] bg-[var(--appkit-color-surface)]", rounded: "lg", shadow: "md", overflow: "hidden", children: _jsx(MediaImage, { src: store.storeLogoURL, alt: store.storeName, size: "thumbnail", onError: () => setLogoBroken(true) }) })) : (_jsx(Row, { textWeight: "bold", textSize: "base", className: "h-10 w-10 border-2 border-white bg-primary/10 dark:bg-primary/20 text-primary", align: "center", justify: "center", rounded: "lg", shadow: "md", children: initial })) }) }), _jsx(Heading, { level: 3, className: `group-hover:text-primary transition-colors`, truncate: true, size: "sm", weight: "bold", color: "primary", children: store.storeName }), store.storeDescription ? (_jsx(RichText, { html: normalizeRichTextHtml(store.storeDescription), proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "mt-1 line-clamp-2 text-[length:var(--appkit-text-xs)] text-[var(--appkit-color-text-muted)] flex-1" })) : (_jsx(Div, { className: "flex-1" })), _jsxs(Row, { color: "muted", textSize: "xs", gap: "sm", className: "mt-2.5", wrap: true, children: [store.totalProducts != null && store.totalProducts > 0 && (_jsxs(Span, { layout: "flex", gap: "2xs", children: [_jsx(Span, { "aria-hidden": "true", children: "\uD83D\uDCE6" }), " ", store.totalProducts, " ", labels.products ?? "products"] })), store.itemsSold != null && store.itemsSold > 0 && (_jsxs(Span, { layout: "flex", gap: "2xs", children: [_jsx(Span, { "aria-hidden": "true", children: "\uD83D\uDECD\uFE0F" }), " ", store.itemsSold, " ", labels.sold ?? "sold"] }))] }), _jsxs(Row, { border: "top-subtle", paddingY: "y-xs-tall", className: "mt-3", align: "center", justify: "between", children: [_jsxs(Span, { size: "xs", weight: "semibold", className: "text-primary group-hover:underline transition-colors", children: [labels.visitStore ?? "Visit store", " \u2192"] }), store.totalReviews != null && store.totalReviews > 0 && (_jsxs(Span, { size: "xs", color: "muted", children: [store.totalReviews, " ", labels.reviews ?? "reviews"] }))] })] })] })] }));
28
+ }, label: isSelected ? "Deselect store" : "Select store", position: "top-2 left-2", className: selectable || isSelected ? "opacity-100" : "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity" })), _jsxs(Link, { href: href, className: "flex flex-col flex-1 min-h-0", children: [_jsxs(Div, { surface: "muted", className: `relative aspect-video w-full ${__O.hidden} flex-shrink-0`, children: [hasBanner ? (_jsx(MediaImage, { src: store.storeBannerURL, alt: `${store.storeName} banner`, size: "banner", className: "h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" })) : (_jsx(Row, { className: "absolute inset-0", align: "center", justify: "center", children: _jsx(Span, { className: "opacity-20 select-none", size: "5xl", "aria-hidden": "true", children: "\uD83C\uDFEA" }) })), _jsx(Scrim, { direction: "bottom-up", intensity: "subtle", className: "absolute inset-x-0 bottom-0 h-12" }), store.averageRating != null && store.averageRating > 0 && (_jsxs(Div, { className: CLS_RATING_BADGE, children: ["\u2605 ", store.averageRating.toFixed(1)] }))] }), _jsxs(Stack, { paddingY: "b-md", className: "flex-1", padding: "x-md", children: [_jsx(Row, { className: "-mt-5 mb-2", align: "end", justify: "between", children: _jsx(Div, { className: "flex-shrink-0", children: hasLogo ? (_jsx(Div, { className: "relative h-10 w-10 border-2 border-[var(--appkit-color-border-subtle)] bg-[var(--appkit-color-surface)]", rounded: "lg", shadow: "md", overflow: "hidden", children: _jsx(MediaImage, { src: store.storeLogoURL, alt: store.storeName, size: "thumbnail", onError: () => setLogoBroken(true) }) })) : (_jsx(Row, { textWeight: "bold", textSize: "base", className: "h-10 w-10 border-2 border-white bg-primary/10 dark:bg-primary/20 text-primary", align: "center", justify: "center", rounded: "lg", shadow: "md", children: initial })) }) }), _jsx(Heading, { level: 3, className: `group-hover:text-primary transition-colors`, truncate: true, size: "sm", weight: "bold", color: "primary", children: store.storeName }), store.storeDescription ? (_jsx(RichText, { html: normalizeRichTextHtml(store.storeDescription), proseClass: "prose prose-sm max-w-none dark:prose-invert prose-p:my-0", className: "mt-1 line-clamp-2 text-[length:var(--appkit-text-xs)] text-[var(--appkit-color-text-muted)] flex-1" })) : (_jsx(Div, { className: "flex-1" })), _jsxs(Row, { color: "muted", textSize: "xs", gap: "sm", className: "mt-2.5", wrap: true, children: [store.totalProducts != null && store.totalProducts > 0 && (_jsxs(Span, { layout: "flex", gap: "2xs", children: [_jsx(Span, { "aria-hidden": "true", children: "\uD83D\uDCE6" }), " ", store.totalProducts, " ", labels.products ?? "products"] })), store.itemsSold != null && store.itemsSold > 0 && (_jsxs(Span, { layout: "flex", gap: "2xs", children: [_jsx(Span, { "aria-hidden": "true", children: "\uD83D\uDECD\uFE0F" }), " ", store.itemsSold, " ", labels.sold ?? "sold"] }))] }), _jsxs(Row, { border: "top-subtle", paddingY: "y-xs-tall", className: "mt-3", align: "center", justify: "between", children: [_jsxs(Span, { size: "xs", weight: "semibold", className: "text-primary group-hover:underline transition-colors", children: [labels.visitStore ?? "Visit store", " \u2192"] }), store.totalReviews != null && store.totalReviews > 0 && (_jsxs(Span, { size: "xs", color: "muted", children: [store.totalReviews, " ", labels.reviews ?? "reviews"] }))] })] })] })] }));
29
29
  }
@@ -8,7 +8,7 @@ import { SellerTrustBadge } from "../../scams/components/SellerTrustBadge";
8
8
  const __O = {
9
9
  hidden: "overflow-hidden",
10
10
  };
11
- const CLS_AVATAR = "-mt-8 h-16 w-16 rounded-xl border-2 border-white border-[var(--appkit-color-border-subtle)] bg-warning-surface dark:bg-warning-surface flex items-center justify-center text-warning dark:text-warning font-bold text-[length:var(--appkit-text-2xl)] shadow-sm";
11
+ const CLS_AVATAR = "-mt-8 h-16 w-16 rounded-xl border-2 border-[var(--appkit-color-border-subtle)] bg-warning-surface dark:bg-warning-surface flex items-center justify-center text-warning dark:text-warning font-bold text-[length:var(--appkit-text-2xl)] shadow-sm";
12
12
  const CLS_STARS = "inline-flex items-center gap-[var(--appkit-space-1)] text-warning";
13
13
  const CLS_FOLLOW_BTN = "rounded-lg border border-warning px-[var(--appkit-space-4)] py-[var(--appkit-space-2)] text-[length:var(--appkit-text-sm)] font-medium text-warning hover:bg-warning-surface transition-colors";
14
14
  const CLS_WARN_BANNER = "mt-3 rounded-lg bg-warning-surface dark:bg-warning-surface border border-warning dark:border-warning px-[var(--appkit-space-3)] py-[var(--appkit-space-2)] text-[length:var(--appkit-text-sm)] text-warning dark:text-warning";
@@ -1,8 +1,7 @@
1
1
  "use client";
2
- import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
3
3
  import { useRouter } from "next/navigation";
4
- import { Button, Div, Select, TextLink } from "../../../ui";
5
- const CLS_ACTIVE_TAB = "border-primary text-primary";
4
+ import { TabBarButton, TabBarShell, TabsNavSelect, TextLink } from "../../../ui";
6
5
  export function StoreNavTabs({ tabs, dropdownTabs = [], dropdownPlaceholder = "Browse listings", activeValue, onTabChange, renderTabBar, className = "", }) {
7
6
  const router = useRouter();
8
7
  const handleChange = (v) => onTabChange?.(v);
@@ -18,9 +17,22 @@ export function StoreNavTabs({ tabs, dropdownTabs = [], dropdownPlaceholder = "B
18
17
  router.push(tab.href);
19
18
  handleChange(tab.value);
20
19
  };
21
- return (_jsxs(Div, { layout: "flex", gap: "2", align: "center", role: "tablist", border: "bottom", className: `overflow-x-auto ${className}`, children: [dropdownTabs.length > 0 && (_jsx(Select, { bare: true, "aria-label": dropdownPlaceholder, options: dropdownTabs.map((tab) => ({ value: tab.value, label: tab.label })), value: activeDropdownTab?.value ?? "", placeholder: activeDropdownTab ? undefined : dropdownPlaceholder, onValueChange: handleDropdownChange, className: `max-w-[12rem] flex-shrink-0 whitespace-nowrap ${activeDropdownTab ? CLS_ACTIVE_TAB : ""}` })), tabs.map((tab) => tab.href ? (_jsx(TextLink, { variant: "bare", href: tab.href, role: "tab", "aria-selected": activeValue === tab.value, paddingX: "md", paddingY: "xs", size: "sm", weight: "medium", className: `whitespace-nowrap border-b-2 -mb-px transition-colors ${activeValue === tab.value
22
- ? CLS_ACTIVE_TAB
23
- : "border-transparent text-[var(--appkit-color-text-muted)] text-[var(--appkit-color-text-muted)] hover:text-neutral-800 hover:text-[var(--appkit-color-text-muted)]"}`, children: tab.label }, tab.value)) : (_jsx(Button, { variant: "ghost", type: "button", role: "tab", "aria-selected": activeValue === tab.value, onClick: () => handleChange(tab.value), paddingX: "md", paddingY: "sm", textSize: "sm", weight: "medium", rounded: "none", className: `whitespace-nowrap border-b-2 -mb-px transition-colors ${activeValue === tab.value
24
- ? CLS_ACTIVE_TAB
25
- : "border-transparent text-[var(--appkit-color-text-muted)] text-[var(--appkit-color-text-muted)] hover:text-neutral-800 hover:text-[var(--appkit-color-text-muted)]"}`, children: tab.label }, tab.value)))] }));
20
+ /*
21
+ * The listing-type picker goes in `leading` — i.e. INSIDE the shell but
22
+ * OUTSIDE the role="tablist" element. It used to be a direct child of the
23
+ * tablist, which is invalid ARIA: a <select> is not a role="tab". Its old
24
+ * `max-w-[12rem] flex-shrink-0` sizing also went onto the inner <select>,
25
+ * the wrong element for sizing utilities (Root Cause #29); those now live
26
+ * on `.appkit-tabs-dropdown` in Tabs.style.css.
27
+ */
28
+ const leading = dropdownTabs.length > 0 ? (_jsx(TabsNavSelect, { ariaLabel: dropdownPlaceholder, options: dropdownTabs.map((tab) => ({ value: tab.value, label: tab.label })), value: activeDropdownTab?.value ?? "", placeholder: activeDropdownTab ? undefined : dropdownPlaceholder, selected: Boolean(activeDropdownTab), onValueChange: handleDropdownChange })) : undefined;
29
+ /*
30
+ * Triggers carry `.appkit-tabs-trigger` and NOTHING else — no padding /
31
+ * size / weight props, no conditional colour classes. `important: true` is
32
+ * set in both tailwind configs, so any surviving utility would beat the
33
+ * component CSS unconditionally. The old inactive string also contained
34
+ * contradictory duplicates (`hover:text-neutral-800` immediately overridden
35
+ * by `hover:text-…text-muted`), so hover had been a no-op.
36
+ */
37
+ return (_jsx(TabBarShell, { className: className, ariaLabel: "Store sections", leading: leading, activeKey: activeValue, children: tabs.map((tab) => tab.href ? (_jsx(TextLink, { variant: "bare", href: tab.href, role: "tab", "aria-selected": activeValue === tab.value, className: "appkit-tabs-trigger", children: tab.label }, tab.value)) : (_jsx(TabBarButton, { selected: activeValue === tab.value, onClick: () => handleChange(tab.value), children: tab.label }, tab.value))) }));
26
38
  }
@@ -1,5 +1,6 @@
1
1
  import { BaseRepository } from "../../../providers/db-firebase";
2
2
  import type { FirebaseSieveFields, FirebaseSieveResult, SieveModel } from "../../../providers/db-firebase";
3
+ import type { DocumentSnapshot } from "firebase-admin/firestore";
3
4
  import { type SupportTicketDocument, type SupportTicketCreateInput, type SupportTicketUpdateInput, type TicketMessage, type TicketStatus } from "../schemas/firestore";
4
5
  import { type HistoryActor } from "../../../_internal/shared/history/index";
5
6
  /** Who/why for a write that lands in `statusHistory`. */
@@ -11,7 +12,18 @@ export interface TicketWriteContext {
11
12
  }
12
13
  export declare class SupportRepository extends BaseRepository<SupportTicketDocument> {
13
14
  static readonly SIEVE_FIELDS: FirebaseSieveFields;
15
+ /**
16
+ * Ticket PII, encrypted by BaseRepository's write hooks.
17
+ *
18
+ * `SUPPORT_TICKET_PII_FIELDS` existed but was only ever used to scrub status
19
+ * history — the documents themselves were never encrypted. Safe to enable:
20
+ * nothing queries these by equality (the admin list searches `subject`), so
21
+ * no read path depends on their plaintext.
22
+ */
23
+ protected piiFields: readonly ["userEmail", "userDisplayName", "assignedToName"];
14
24
  constructor();
25
+ /** Decrypt on read so callers see the shape they wrote. */
26
+ protected mapDoc<D = SupportTicketDocument>(snap: DocumentSnapshot): D;
15
27
  createTicket(input: SupportTicketCreateInput): Promise<SupportTicketDocument>;
16
28
  getUserTickets(userId: string, page?: number, pageSize?: number): Promise<FirebaseSieveResult<SupportTicketDocument>>;
17
29
  getTicketById(ticketId: string): Promise<SupportTicketDocument | null>;
@@ -3,11 +3,26 @@ import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../..
3
3
  import { normalizeError } from "../../../errors/normalize";
4
4
  import { DatabaseError } from "../../../errors";
5
5
  import { SUPPORT_TICKET_FIELDS } from "../../../constants/field-names";
6
+ import { decryptPiiFields } from "../../../security/pii-encrypt";
6
7
  import { SUPPORT_TICKET_COLLECTION, SUPPORT_TICKET_PII_FIELDS, SUPPORT_TICKET_TRACKED_FIELDS, TicketStatusValues, ACTIVE_TICKET_STATUSES, } from "../schemas/firestore";
7
8
  import { withHistory } from "../../../_internal/shared/history/index";
8
9
  export class SupportRepository extends BaseRepository {
9
10
  constructor() {
10
11
  super(SUPPORT_TICKET_COLLECTION);
12
+ /**
13
+ * Ticket PII, encrypted by BaseRepository's write hooks.
14
+ *
15
+ * `SUPPORT_TICKET_PII_FIELDS` existed but was only ever used to scrub status
16
+ * history — the documents themselves were never encrypted. Safe to enable:
17
+ * nothing queries these by equality (the admin list searches `subject`), so
18
+ * no read path depends on their plaintext.
19
+ */
20
+ this.piiFields = SUPPORT_TICKET_PII_FIELDS;
21
+ }
22
+ /** Decrypt on read so callers see the shape they wrote. */
23
+ mapDoc(snap) {
24
+ const raw = super.mapDoc(snap);
25
+ return decryptPiiFields(raw, [...SUPPORT_TICKET_PII_FIELDS]);
11
26
  }
12
27
  async createTicket(input) {
13
28
  try {
@@ -0,0 +1,33 @@
1
+ import { z } from "zod";
2
+ import { type TicketCategory } from "./firestore";
3
+ /**
4
+ * 🛑 `annotate()` must be the OUTERMOST call on each field — it keys a WeakMap
5
+ * by schema instance and every zod wrapper returns a new one.
6
+ */
7
+ export declare const supportTicketCreateSchema: z.ZodEffects<z.ZodObject<{
8
+ category: z.ZodEnum<[TicketCategory, ...TicketCategory[]]>;
9
+ subject: z.ZodString;
10
+ description: z.ZodString;
11
+ orderId: z.ZodOptional<z.ZodString>;
12
+ }, "strip", z.ZodTypeAny, {
13
+ category: TicketCategory;
14
+ description: string;
15
+ subject: string;
16
+ orderId?: string | undefined;
17
+ }, {
18
+ category: TicketCategory;
19
+ description: string;
20
+ subject: string;
21
+ orderId?: string | undefined;
22
+ }>, {
23
+ category: TicketCategory;
24
+ description: string;
25
+ subject: string;
26
+ orderId?: string | undefined;
27
+ }, {
28
+ category: TicketCategory;
29
+ description: string;
30
+ subject: string;
31
+ orderId?: string | undefined;
32
+ }>;
33
+ export type SupportTicketCreateValues = z.infer<typeof supportTicketCreateSchema>;
@@ -0,0 +1,82 @@
1
+ /*
2
+ * WHY: Support tickets had THREE validation rules in three places that did not
3
+ * agree. `/user/support/new` hand-rolled a `canSubmit` boolean;
4
+ * `UserSupportView`'s drawer checked nothing at all; and
5
+ * `POST /api/support/tickets` declared its own inline schema.
6
+ * WHAT: One schema for both surfaces AND the route.
7
+ *
8
+ * ## The page was STRICTER than the route, which is the interesting part
9
+ *
10
+ * `/user/support/new` required an `orderId` when the category is
11
+ * `order_issue` — a real rule, since an order complaint with no order is
12
+ * unactionable and support has to go back and ask. The route never enforced
13
+ * it, and the drawer never enforced anything, so the same ticket was
14
+ * acceptable or not depending on which surface the user happened to open.
15
+ *
16
+ * That rule is a `.superRefine` here, so it now holds on all three. Deleting
17
+ * it to "match the route" would have been the easy read of consolidation and
18
+ * the wrong one: the stricter rule was the correct one.
19
+ *
20
+ * EXPORTS: supportTicketCreateSchema, type SupportTicketCreateValues
21
+ *
22
+ * @tag domain:support
23
+ * @tag layer:schema
24
+ * @tag pattern:none
25
+ * @tag access:isomorphic
26
+ * @tag consumers:/user/support/new,UserSupportView,/api/support/tickets
27
+ * @tag sideEffects:none
28
+ */
29
+ import { z } from "zod";
30
+ import { annotate } from "../../shell/field-ui-meta";
31
+ import { TicketCategoryValues } from "./firestore";
32
+ /*
33
+ * `Object.values(...)` rather than `z.enum(TicketCategoryValues)`. The object
34
+ * form of `z.enum` is Zod 4 syntax, and appkit compiles against zod 3 while
35
+ * the app is on 4 — the two-zod split this repo carries. The tuple form works
36
+ * in both, and still DERIVES from the one map, so a twelfth category cannot be
37
+ * omitted here (Root Cause #61).
38
+ */
39
+ const CATEGORY_TUPLE = Object.values(TicketCategoryValues);
40
+ /**
41
+ * 🛑 `annotate()` must be the OUTERMOST call on each field — it keys a WeakMap
42
+ * by schema instance and every zod wrapper returns a new one.
43
+ */
44
+ export const supportTicketCreateSchema = z
45
+ .object({
46
+ category: annotate(z.enum(CATEGORY_TUPLE), {
47
+ section: "ticket",
48
+ sectionLabel: "What do you need help with?",
49
+ sectionRequired: true,
50
+ quick: true,
51
+ order: 1,
52
+ row: "pair",
53
+ }),
54
+ subject: annotate(z
55
+ .string()
56
+ .min(3, "Give the ticket a subject of at least 3 characters.")
57
+ .max(200, "Keep the subject under 200 characters."), { section: "ticket", quick: true, order: 2, row: "full" }),
58
+ description: annotate(z
59
+ .string()
60
+ .min(10, "Describe the problem in at least 10 characters.")
61
+ .max(5000, "Keep the description under 5000 characters."), { section: "ticket", quick: true, order: 3, row: "full", kind: "textarea" }),
62
+ orderId: annotate(z.string().max(120).optional(), {
63
+ section: "ticket",
64
+ order: 4,
65
+ row: "pair",
66
+ help: "Required when the category is an order issue.",
67
+ }),
68
+ })
69
+ .superRefine((v, ctx) => {
70
+ /*
71
+ * The rule only `/user/support/new` enforced. An order complaint with no
72
+ * order id cannot be actioned — support has to reply asking for it, which
73
+ * is a round trip the form can prevent.
74
+ */
75
+ if (v.category === TicketCategoryValues.ORDER_ISSUE && !v.orderId?.trim()) {
76
+ ctx.addIssue({
77
+ code: z.ZodIssueCode.custom,
78
+ path: ["orderId"],
79
+ message: "Which order is this about? An order id is required.",
80
+ });
81
+ }
82
+ });
@@ -23,7 +23,7 @@
23
23
  * @tag sideEffects:none
24
24
  */
25
25
  import { PRODUCT_FIELDS } from "../../../constants/field-names";
26
- import { buildSearchTokens } from "../../../utils/search-tokens";
26
+ import { buildSearchTxt } from "../../../utils/search-txt";
27
27
  import { seedExtMedia } from "../../../seed/_helpers/media";
28
28
  import { testDataExpiresAt } from "./tester-ttl";
29
29
  const TESTER_STORE_ID = "store-tester-sandbox";
@@ -42,7 +42,7 @@ function withTokens(p) {
42
42
  storeId: TESTER_STORE_ID,
43
43
  storeName: TESTER_STORE_NAME,
44
44
  ...p,
45
- searchTokens: buildSearchTokens(p.title, p.description, p.brand, p.brandSlug, p.categoryNames, p.tags),
45
+ searchTxt: buildSearchTxt([p.title, p.description, p.brand, p.brandSlug, p.categoryNames, p.tags]),
46
46
  };
47
47
  }
48
48
  // Staggered, incrementing-id auction fixtures so testers can watch a live
@@ -308,6 +308,111 @@ export const productsTesterSeedData = [
308
308
  createdAt: new Date(),
309
309
  updatedAt: new Date(),
310
310
  }),
311
+ // ── Grouped-cart-line fixtures ───────────────────────────────────────────
312
+ // Each of the three below exists to make ONE picker branch testable by hand.
313
+ // Without them the branch can only be reached by editing data live, which a
314
+ // reseed silently wipes out.
315
+ withTokens({
316
+ id: "product-tester-group-soldout",
317
+ slug: "product-tester-group-soldout",
318
+ title: "Test Part — Sold Out (Group Member)",
319
+ description: "A member of the tester product group that is deliberately out of stock. Exists so the group picker's blocked-member branch has real data: it must render a reason chip instead of a +/- stepper, and must not be selectable. Auto-expires in 7 days.",
320
+ categorySlugs: COLLECTIBLES_CATEGORY_SLUGS,
321
+ categoryNames: COLLECTIBLES_CATEGORY_NAMES,
322
+ brandSlug: "brand-tester-sandbox",
323
+ brand: "TestBrand",
324
+ price: 129,
325
+ currency: "INR",
326
+ stockQuantity: 0,
327
+ availableQuantity: 0,
328
+ isSold: true,
329
+ mainImage: seedExtMedia("https://picsum.photos/seed/product-image-tester-group-soldout-20260101/900/900"),
330
+ images: [seedExtMedia("https://picsum.photos/seed/product-image-tester-group-soldout-20260101/900/900")],
331
+ status: PRODUCT_FIELDS.STATUS_VALUES.PUBLISHED,
332
+ condition: PRODUCT_FIELDS.CONDITION_VALUES.NEW,
333
+ listingType: "standard",
334
+ customFields: [],
335
+ customSections: [],
336
+ isPromoted: false,
337
+ isOnSale: false,
338
+ gstRate: 18,
339
+ hsnCode: "95030090",
340
+ groupId: "group-tester-sandbox-bundle",
341
+ groupParentSlug: "group-tester-sandbox-bundle",
342
+ groupTitle: TESTER_GROUP_TITLE,
343
+ createdAt: new Date(),
344
+ updatedAt: new Date(),
345
+ }),
346
+ // A group whose members span TWO stores. `storeId` is the order-splitting key
347
+ // and the key per-store add-ons/coupons/shipping/payout hang off, so such a
348
+ // group can never become one cart line — the picker must stand down to a
349
+ // read-only table rather than offer a button that always fails.
350
+ // `withTokens` spreads `...p` last, so the storeId below overrides the
351
+ // sandbox default on purpose.
352
+ withTokens({
353
+ id: "product-tester-crossstore-a",
354
+ slug: "product-tester-crossstore-a",
355
+ title: "Test Cross-Store Set — Seller A",
356
+ description: "Parent of a deliberately CROSS-STORE tester group. Its sibling belongs to a different seller, so the group picker must render read-only with no quantity column and no \"Add selected\" button. Auto-expires in 7 days.",
357
+ categorySlugs: COLLECTIBLES_CATEGORY_SLUGS,
358
+ categoryNames: COLLECTIBLES_CATEGORY_NAMES,
359
+ brandSlug: "brand-tester-sandbox",
360
+ brand: "TestBrand",
361
+ price: 259,
362
+ currency: "INR",
363
+ stockQuantity: 6,
364
+ availableQuantity: 6,
365
+ isSold: false,
366
+ mainImage: seedExtMedia("https://picsum.photos/seed/product-image-tester-crossstore-a-20260101/900/900"),
367
+ images: [seedExtMedia("https://picsum.photos/seed/product-image-tester-crossstore-a-20260101/900/900")],
368
+ status: PRODUCT_FIELDS.STATUS_VALUES.PUBLISHED,
369
+ condition: PRODUCT_FIELDS.CONDITION_VALUES.NEW,
370
+ listingType: "standard",
371
+ customFields: [],
372
+ customSections: [],
373
+ isPromoted: false,
374
+ isOnSale: false,
375
+ gstRate: 18,
376
+ isGroupParent: true,
377
+ groupId: "group-tester-cross-store",
378
+ groupTitle: "Tester Cross-Store Set",
379
+ groupChildSlugs: ["product-tester-crossstore-b"],
380
+ createdAt: new Date(),
381
+ updatedAt: new Date(),
382
+ }),
383
+ withTokens({
384
+ id: "product-tester-crossstore-b",
385
+ slug: "product-tester-crossstore-b",
386
+ title: "Test Cross-Store Set — Seller B",
387
+ description: "Sibling of product-tester-crossstore-a, deliberately owned by a DIFFERENT store so the group spans two sellers. Still isTestData, so filterTestDataForViewer keeps it off that store's public listings for non-testers. Auto-expires in 7 days.",
388
+ categorySlugs: COLLECTIBLES_CATEGORY_SLUGS,
389
+ categoryNames: COLLECTIBLES_CATEGORY_NAMES,
390
+ brandSlug: "brand-tester-sandbox",
391
+ brand: "TestBrand",
392
+ price: 189,
393
+ currency: "INR",
394
+ stockQuantity: 6,
395
+ availableQuantity: 6,
396
+ isSold: false,
397
+ mainImage: seedExtMedia("https://picsum.photos/seed/product-image-tester-crossstore-b-20260101/900/900"),
398
+ images: [seedExtMedia("https://picsum.photos/seed/product-image-tester-crossstore-b-20260101/900/900")],
399
+ status: PRODUCT_FIELDS.STATUS_VALUES.PUBLISHED,
400
+ condition: PRODUCT_FIELDS.CONDITION_VALUES.NEW,
401
+ listingType: "standard",
402
+ customFields: [],
403
+ customSections: [],
404
+ isPromoted: false,
405
+ isOnSale: false,
406
+ gstRate: 12,
407
+ // The whole point of this fixture — a different seller from its sibling.
408
+ storeId: "store-beyblade-arena",
409
+ storeName: "Beyblade Arena",
410
+ groupId: "group-tester-cross-store",
411
+ groupParentSlug: "product-tester-crossstore-a",
412
+ groupTitle: "Tester Cross-Store Set",
413
+ createdAt: new Date(),
414
+ updatedAt: new Date(),
415
+ }),
311
416
  withTokens({
312
417
  id: "product-tester-standard-sold",
313
418
  slug: "product-tester-standard-sold",
@@ -687,6 +792,7 @@ export const productsTesterSeedData = [
687
792
  "product-tester-standard-1",
688
793
  "product-tester-standard-2",
689
794
  "product-tester-standard-3",
795
+ "product-tester-group-soldout",
690
796
  ],
691
797
  createdAt: new Date(),
692
798
  updatedAt: new Date(),