@mohasinac/appkit 4.62.1 → 4.63.1

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 (263) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/client/features/layout/DashboardLayoutClient.js +8 -29
  3. package/dist/_internal/client/features/layout/filterNavItems.d.ts +28 -0
  4. package/dist/_internal/client/features/layout/filterNavItems.js +35 -0
  5. package/dist/_internal/server/features/ads/booking.d.ts +118 -0
  6. package/dist/_internal/server/features/ads/booking.js +326 -0
  7. package/dist/_internal/server/features/checkout/actions.d.ts +51 -11
  8. package/dist/_internal/server/features/checkout/actions.js +335 -80
  9. package/dist/_internal/server/features/checkout/expand-order-items.d.ts +1 -2
  10. package/dist/_internal/server/features/checkout/expand-order-items.js +32 -0
  11. package/dist/_internal/server/features/checkout/freeze-parts.d.ts +50 -0
  12. package/dist/_internal/server/features/checkout/freeze-parts.js +94 -0
  13. package/dist/_internal/server/features/checkout/value-otp-gate.d.ts +55 -0
  14. package/dist/_internal/server/features/checkout/value-otp-gate.js +60 -0
  15. package/dist/_internal/server/features/dashboard/attention.d.ts +128 -0
  16. package/dist/_internal/server/features/dashboard/attention.js +183 -0
  17. package/dist/_internal/server/features/dashboard/data.d.ts +47 -0
  18. package/dist/_internal/server/features/dashboard/data.js +82 -0
  19. package/dist/_internal/server/features/inventory/reservations.d.ts +99 -0
  20. package/dist/_internal/server/features/inventory/reservations.js +273 -0
  21. package/dist/_internal/server/features/offers/destinations.d.ts +40 -0
  22. package/dist/_internal/server/features/offers/destinations.js +65 -0
  23. package/dist/_internal/server/features/orders/adapters.js +21 -0
  24. package/dist/_internal/server/features/orders/amend-delivery.d.ts +56 -0
  25. package/dist/_internal/server/features/orders/amend-delivery.js +124 -0
  26. package/dist/_internal/server/features/orders/balance.d.ts +89 -0
  27. package/dist/_internal/server/features/orders/balance.js +256 -0
  28. package/dist/_internal/server/features/pricing/actions.d.ts +58 -0
  29. package/dist/_internal/server/features/pricing/actions.js +144 -0
  30. package/dist/_internal/server/features/shipping/book-split.d.ts +86 -0
  31. package/dist/_internal/server/features/shipping/book-split.js +130 -0
  32. package/dist/_internal/server/features/shipping/book.d.ts +61 -0
  33. package/dist/_internal/server/features/shipping/book.js +176 -0
  34. package/dist/_internal/server/features/shipping/courier-block.js +26 -1
  35. package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
  36. package/dist/_internal/server/features/site-settings/adapters.js +14 -0
  37. package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
  38. package/dist/_internal/server/features/stores/adapters.js +14 -0
  39. package/dist/_internal/server/functions/scheduled.d.ts +2 -1
  40. package/dist/_internal/server/functions/scheduled.js +57 -1
  41. package/dist/_internal/server/jobs/core/autoPayoutEligibility.js +2 -0
  42. package/dist/_internal/server/jobs/core/jobRunners.js +45 -0
  43. package/dist/_internal/server/jobs/core/onOrderStatusChange.js +4 -1
  44. package/dist/_internal/server/jobs/core/reservationExpiry.d.ts +17 -0
  45. package/dist/_internal/server/jobs/core/reservationExpiry.js +40 -0
  46. package/dist/_internal/server/jobs/core/weeklyPayoutEligibility.js +15 -0
  47. package/dist/_internal/server/jobs/handlers/index.d.ts +1 -0
  48. package/dist/_internal/server/jobs/handlers/index.js +1 -0
  49. package/dist/_internal/server/jobs/handlers/reservationExpiry.d.ts +2 -0
  50. package/dist/_internal/server/jobs/handlers/reservationExpiry.js +2 -0
  51. package/dist/_internal/shared/actions/action-registry.d.ts +1 -1
  52. package/dist/_internal/shared/actions/action-registry.js +268 -0
  53. package/dist/_internal/shared/checkout/order-math.d.ts +31 -2
  54. package/dist/_internal/shared/checkout/order-math.js +22 -36
  55. package/dist/_internal/shared/checkout/rules/index.d.ts +1 -1
  56. package/dist/_internal/shared/checkout/rules/index.js +1 -1
  57. package/dist/_internal/shared/checkout/rules/payment-gate.d.ts +18 -0
  58. package/dist/_internal/shared/checkout/rules/payment-gate.js +25 -0
  59. package/dist/_internal/shared/checkout/split.d.ts +0 -12
  60. package/dist/_internal/shared/checkout/split.js +22 -2
  61. package/dist/_internal/shared/content/disclaimers.js +7 -7
  62. package/dist/_internal/shared/content/published-values.d.ts +6 -1
  63. package/dist/_internal/shared/content/published-values.js +8 -0
  64. package/dist/_internal/shared/features/admin/integrations.d.ts +32 -2
  65. package/dist/_internal/shared/features/admin/integrations.js +19 -9
  66. package/dist/_internal/shared/features/dashboard/hub-sections.d.ts +72 -0
  67. package/dist/_internal/shared/features/dashboard/hub-sections.js +75 -0
  68. package/dist/_internal/shared/features/emi/choice.d.ts +98 -0
  69. package/dist/_internal/shared/features/emi/choice.js +69 -0
  70. package/dist/_internal/shared/features/emi/group.d.ts +84 -0
  71. package/dist/_internal/shared/features/emi/group.js +126 -0
  72. package/dist/_internal/shared/features/ledger/posting.d.ts +107 -0
  73. package/dist/_internal/shared/features/ledger/posting.js +165 -0
  74. package/dist/_internal/shared/features/ledger/reports.d.ts +76 -0
  75. package/dist/_internal/shared/features/ledger/reports.js +127 -0
  76. package/dist/_internal/shared/features/ledger/rules.d.ts +170 -0
  77. package/dist/_internal/shared/features/ledger/rules.js +221 -0
  78. package/dist/_internal/shared/features/offers/coordination.d.ts +122 -0
  79. package/dist/_internal/shared/features/offers/coordination.js +80 -0
  80. package/dist/_internal/shared/features/orders/delivery-snapshot.d.ts +114 -0
  81. package/dist/_internal/shared/features/orders/delivery-snapshot.js +116 -0
  82. package/dist/_internal/shared/features/payouts/pay-period.d.ts +91 -0
  83. package/dist/_internal/shared/features/payouts/pay-period.js +104 -0
  84. package/dist/_internal/shared/features/points/accepts.d.ts +1 -8
  85. package/dist/_internal/shared/features/points/accepts.js +12 -6
  86. package/dist/_internal/shared/features/pricing/rate-registry.d.ts +150 -0
  87. package/dist/_internal/shared/features/pricing/rate-registry.js +246 -0
  88. package/dist/_internal/shared/features/shipping/parcel.d.ts +123 -0
  89. package/dist/_internal/shared/features/shipping/parcel.js +139 -0
  90. package/dist/_internal/shared/features/site-settings/fees.d.ts +16 -8
  91. package/dist/_internal/shared/features/site-settings/fees.js +12 -8
  92. package/dist/_internal/shared/fees/explain.d.ts +81 -0
  93. package/dist/_internal/shared/fees/explain.js +101 -0
  94. package/dist/_internal/shared/history/index.d.ts +1 -1
  95. package/dist/_internal/shared/history/index.js +1 -1
  96. package/dist/_internal/shared/history/types.d.ts +7 -1
  97. package/dist/_internal/shared/history/types.js +25 -0
  98. package/dist/client.d.ts +11 -1
  99. package/dist/client.js +13 -0
  100. package/dist/constants/api-endpoints.d.ts +72 -0
  101. package/dist/constants/api-endpoints.js +41 -0
  102. package/dist/constants/field-names.d.ts +41 -0
  103. package/dist/constants/field-names.js +47 -0
  104. package/dist/features/account/components/UserOrdersView.js +28 -2
  105. package/dist/features/admin/components/AdminIntegrationsView.d.ts +55 -0
  106. package/dist/features/admin/components/AdminIntegrationsView.js +74 -0
  107. package/dist/features/admin/components/AdminOrderEditorView.d.ts +16 -1
  108. package/dist/features/admin/components/AdminOrderEditorView.js +72 -3
  109. package/dist/features/admin/components/AdminOrdersView.js +20 -1
  110. package/dist/features/admin/schemas/firestore.d.ts +32 -0
  111. package/dist/features/ads/schemas/booking-write.d.ts +157 -0
  112. package/dist/features/ads/schemas/booking-write.js +88 -0
  113. package/dist/features/ads/schemas/creative-form.d.ts +18 -0
  114. package/dist/features/ads/schemas/creative-form.js +51 -0
  115. package/dist/features/ads/schemas/firestore.d.ts +140 -0
  116. package/dist/features/ads/schemas/firestore.js +109 -0
  117. package/dist/features/auctions/repository/bid.repository.js +3 -1
  118. package/dist/features/audit-log/schemas/firestore.d.ts +110 -0
  119. package/dist/features/audit-log/schemas/firestore.js +74 -0
  120. package/dist/features/auth/actions/profile-actions.d.ts +69 -1
  121. package/dist/features/auth/actions/profile-actions.js +62 -1
  122. package/dist/features/auth/schemas/firestore.d.ts +16 -2
  123. package/dist/features/auth/schemas/firestore.js +2 -1
  124. package/dist/features/blog/actions/blog-actions.d.ts +6 -0
  125. package/dist/features/blog/actions/blog-actions.js +17 -2
  126. package/dist/features/blog/api/route.js +4 -1
  127. package/dist/features/blog/columns/index.js +5 -0
  128. package/dist/features/blog/components/BlogIndexPageView.js +2 -1
  129. package/dist/features/blog/repository/blog.repository.d.ts +4 -0
  130. package/dist/features/blog/repository/blog.repository.js +5 -0
  131. package/dist/features/blog/schemas/blog-form.d.ts +3 -0
  132. package/dist/features/blog/schemas/blog-form.js +1 -0
  133. package/dist/features/blog/schemas/firestore.d.ts +38 -1
  134. package/dist/features/blog/schemas/firestore.js +23 -0
  135. package/dist/features/blog/schemas/index.d.ts +6 -0
  136. package/dist/features/blog/schemas/index.js +2 -0
  137. package/dist/features/blog/types/index.d.ts +2 -0
  138. package/dist/features/cart/components/CartPriceBreakdown.d.ts +12 -1
  139. package/dist/features/cart/components/CartPriceBreakdown.js +24 -5
  140. package/dist/features/cart/repository/cart.repository.d.ts +14 -0
  141. package/dist/features/cart/repository/cart.repository.js +20 -0
  142. package/dist/features/cart/schemas/firestore.d.ts +24 -0
  143. package/dist/features/dashboard/components/DashboardHubView.d.ts +83 -0
  144. package/dist/features/dashboard/components/DashboardHubView.js +56 -0
  145. package/dist/features/dashboard/components/DashboardSectionsClient.d.ts +23 -0
  146. package/dist/features/dashboard/components/DashboardSectionsClient.js +66 -0
  147. package/dist/features/dispute-reasons/components/AdminDisputeReasonsView.d.ts +55 -0
  148. package/dist/features/dispute-reasons/components/AdminDisputeReasonsView.js +175 -0
  149. package/dist/features/dispute-reasons/repository/dispute-reasons.repository.d.ts +24 -0
  150. package/dist/features/dispute-reasons/repository/dispute-reasons.repository.js +28 -0
  151. package/dist/features/dispute-reasons/schemas/firestore.d.ts +3 -5
  152. package/dist/features/events/components/EventSaleBanner.js +1 -1
  153. package/dist/features/faq/components/FAQPageContent.js +2 -2
  154. package/dist/features/gateway-events/schemas/index.d.ts +2 -2
  155. package/dist/features/homepage/schemas/index.d.ts +8 -8
  156. package/dist/features/inventory/schemas/stock-reservation.d.ts +114 -0
  157. package/dist/features/inventory/schemas/stock-reservation.js +103 -0
  158. package/dist/features/layout/AppLayoutShell.d.ts +9 -1
  159. package/dist/features/layout/AppLayoutShell.js +12 -4
  160. package/dist/features/layout/BottomActions.js +2 -1
  161. package/dist/features/layout/BottomNavbar.d.ts +10 -2
  162. package/dist/features/layout/SideActionDock.js +52 -2
  163. package/dist/features/layout/TitleBarLayout.js +13 -4
  164. package/dist/features/layout/TitleBarLayout.style.css +59 -0
  165. package/dist/features/layout/useCtaReveal.d.ts +16 -1
  166. package/dist/features/layout/useCtaReveal.js +17 -26
  167. package/dist/features/ledger/repository/journal.repository.d.ts +110 -0
  168. package/dist/features/ledger/repository/journal.repository.js +312 -0
  169. package/dist/features/ledger/schemas/expenses.d.ts +87 -0
  170. package/dist/features/ledger/schemas/expenses.js +27 -0
  171. package/dist/features/ledger/schemas/firestore.d.ts +183 -0
  172. package/dist/features/ledger/schemas/firestore.js +69 -0
  173. package/dist/features/orders/components/BalancePaymentPanel.d.ts +12 -0
  174. package/dist/features/orders/components/BalancePaymentPanel.js +94 -0
  175. package/dist/features/orders/components/RaiseDisputeForm.js +19 -11
  176. package/dist/features/orders/repository/orders.repository.d.ts +28 -1
  177. package/dist/features/orders/repository/orders.repository.js +60 -6
  178. package/dist/features/orders/schemas/balance.d.ts +40 -0
  179. package/dist/features/orders/schemas/balance.js +47 -0
  180. package/dist/features/orders/schemas/firestore.d.ts +202 -0
  181. package/dist/features/orders/schemas/firestore.js +43 -0
  182. package/dist/features/orders/schemas/index.d.ts +2 -2
  183. package/dist/features/orders/types/index.d.ts +21 -0
  184. package/dist/features/payments/repository/payout.repository.js +3 -2
  185. package/dist/features/payments/schemas/firestore.d.ts +92 -1
  186. package/dist/features/payments/schemas/firestore.js +23 -0
  187. package/dist/features/pricing/components/AdminPricingView.d.ts +13 -0
  188. package/dist/features/pricing/components/AdminPricingView.js +157 -0
  189. package/dist/features/products/schemas/firestore.d.ts +26 -1
  190. package/dist/features/products/schemas/firestore.js +2 -0
  191. package/dist/features/products/schemas/index.d.ts +2 -2
  192. package/dist/features/reviews/repository/reviews.repository.d.ts +14 -0
  193. package/dist/features/reviews/repository/reviews.repository.js +16 -0
  194. package/dist/features/scams/repository/scammer.repository.js +2 -1
  195. package/dist/features/scams/schemas/firestore.d.ts +2 -1
  196. package/dist/features/scams/schemas/firestore.js +2 -1
  197. package/dist/features/seller/actions/offer-actions.js +56 -0
  198. package/dist/features/seller/actions/seller-actions.d.ts +1 -1
  199. package/dist/features/seller/actions/seller-actions.js +4 -0
  200. package/dist/features/seller/components/DispatchForm.d.ts +11 -0
  201. package/dist/features/seller/components/DispatchForm.js +190 -0
  202. package/dist/features/seller/components/SellerOrdersView.js +94 -2
  203. package/dist/features/seller/components/index.d.ts +1 -0
  204. package/dist/features/seller/components/index.js +1 -0
  205. package/dist/features/seller/repository/offer.repository.js +4 -3
  206. package/dist/features/seller/schemas/firestore.d.ts +23 -2
  207. package/dist/features/seller/schemas/firestore.js +3 -2
  208. package/dist/features/seller/schemas/index.d.ts +4 -4
  209. package/dist/features/seller/schemas/order-forms.d.ts +61 -4
  210. package/dist/features/seller/schemas/order-forms.js +41 -8
  211. package/dist/features/shipments/schemas/firestore.d.ts +25 -0
  212. package/dist/features/shipping-boxes/repository/shipping-boxes.repository.d.ts +39 -0
  213. package/dist/features/shipping-boxes/repository/shipping-boxes.repository.js +55 -0
  214. package/dist/features/shipping-boxes/schemas/firestore.d.ts +2 -4
  215. package/dist/features/stores/repository/store.repository.d.ts +18 -0
  216. package/dist/features/stores/repository/store.repository.js +27 -1
  217. package/dist/features/stores/schemas/firestore.d.ts +41 -0
  218. package/dist/features/stores/schemas/firestore.js +20 -0
  219. package/dist/features/stores/schemas/index.d.ts +6 -6
  220. package/dist/features/support/repository/support.repository.js +72 -6
  221. package/dist/features/support/schemas/firestore.d.ts +17 -0
  222. package/dist/features/support/schemas/index.d.ts +2 -2
  223. package/dist/index.d.ts +5 -0
  224. package/dist/index.js +19 -0
  225. package/dist/next/routing/route-map.d.ts +92 -0
  226. package/dist/next/routing/route-map.js +53 -0
  227. package/dist/react/contexts/SessionContext.d.ts +17 -1
  228. package/dist/schemas/registry.d.ts +22 -16
  229. package/dist/security/pii-schemas.d.ts +1 -1
  230. package/dist/security/pii-schemas.js +45 -0
  231. package/dist/server-entry.d.ts +1 -1
  232. package/dist/server-entry.js +10 -1
  233. package/dist/server.d.ts +29 -1
  234. package/dist/server.js +158 -0
  235. package/dist/styles.css +349 -1
  236. package/dist/tailwind-utilities.css +1 -1
  237. package/dist/ui/components/Button.d.ts +10 -1
  238. package/dist/ui/components/Button.js +25 -3
  239. package/dist/ui/components/Chart.d.ts +63 -0
  240. package/dist/ui/components/Chart.js +169 -0
  241. package/dist/ui/components/Chart.style.css +214 -0
  242. package/dist/ui/components/Disclaimer.js +10 -1
  243. package/dist/ui/components/OverflowRow.js +54 -2
  244. package/dist/ui/components/OverflowRow.style.css +38 -0
  245. package/dist/ui/components/RichTextEditor.js +99 -8
  246. package/dist/ui/components/chart-spec.d.ts +68 -0
  247. package/dist/ui/components/chart-spec.js +140 -0
  248. package/dist/ui/components/index.style.css +1 -0
  249. package/dist/ui/components/surface-tokens.d.ts +1 -1
  250. package/dist/ui/components/surface-tokens.js +9 -0
  251. package/dist/ui/components/toolbar-collapse.d.ts +13 -0
  252. package/dist/ui/components/toolbar-collapse.js +10 -1
  253. package/dist/ui/index.d.ts +6 -0
  254. package/dist/ui/index.js +3 -0
  255. package/dist/ui/rich-text/RichText.d.ts +10 -1
  256. package/dist/ui/rich-text/RichText.js +33 -2
  257. package/dist/ui/rich-text/RichText.style.css +35 -0
  258. package/dist/ui/rich-text/RichTextRenderer.d.ts +30 -1
  259. package/dist/ui/rich-text/RichTextRenderer.js +34 -2
  260. package/dist/ui/rich-text/chart-blocks.d.ts +62 -0
  261. package/dist/ui/rich-text/chart-blocks.js +87 -0
  262. package/package.json +1 -1
  263. package/scripts/seed-cli.mjs +3 -0
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "stampVersion": 1,
3
- "version": "4.62.1",
4
- "head": "83adcbe6a2561f3581bbf3bb11091bbe945bf1e6",
3
+ "version": "4.63.1",
4
+ "head": "4851d3d585790224397f1dcb19e6dee274fe9d60",
5
5
  "branch": "main",
6
6
  "dirty": false,
7
- "builtAt": "2026-10-23T04:34:28.254Z"
7
+ "builtAt": "2026-10-23T08:10:33.829Z"
8
8
  }
@@ -30,7 +30,7 @@ import { AdminSidebar } from "../../../../features/admin/components/AdminSidebar
30
30
  import { StoreSidebar } from "../../../../features/seller/components/SellerSidebar.js";
31
31
  import { UserSidebar } from "../../../../features/account/components/UserSidebar.js";
32
32
  import { DASHBOARD_DESKTOP_MEDIA_QUERY } from "../../../shared/features/layout/config.js";
33
- import { filterNavItems } from "./filterNavItems.js";
33
+ import { filterNavGroups } from "./filterNavItems.js";
34
34
  import { useSiteSettings } from "../../../../core/hooks/useSiteSettings.js";
35
35
  import { useTheme } from "../../theme/index.js";
36
36
  import { useHandMode } from "../../hand-mode/index.js";
@@ -119,33 +119,12 @@ function useResponsiveDrawer(storageKey) {
119
119
  }, [registerNav, unregisterNav, open, close, toggle]);
120
120
  return { desktopOpen, mobileOpen, close, closeMobile, toggle };
121
121
  }
122
- /** Filter nav groups by navConfig (enabled toggle) + requiredPermission. */
123
- function filterGroups(groups, navConfig, permissions) {
124
- /*
125
- * `permissions === null` MEANS ADMIN, and that must hold whether or not a
126
- * navConfig exists.
127
- *
128
- * The early return below encoded the convention correctly and then applied it
129
- * only when `!navConfig`. The moment site settings carried ANY nav config,
130
- * an admin fell through to `filterNavItems(..., undefined)` — and
131
- * `undefined?.includes(x)` is falsy, so every item carrying a
132
- * `requiredPermission` was dropped. The Admin Panel sidebar rendered empty
133
- * while every admin page stayed reachable by URL.
134
- *
135
- * Passing `isAdmin` keeps the admin's navConfig toggles working (disabling an
136
- * entry is a configuration decision, not a permission) while granting the
137
- * permissions an admin implicitly holds.
138
- */
139
- const isAdmin = permissions === null;
140
- if (isAdmin && !navConfig)
141
- return groups;
142
- return groups
143
- .map((group) => ({
144
- ...group,
145
- items: filterNavItems(group.items, navConfig, permissions ?? undefined, isAdmin),
146
- }))
147
- .filter((group) => group.items.length > 0);
148
- }
122
+ /*
123
+ * The group filter MOVED to `filterNavItems.ts` as `filterNavGroups` when the
124
+ * dashboard hubs needed the same answer. It is imported, never re-derived —
125
+ * a hub whose cards disagreed with this sidebar would promise a page that then
126
+ * 403s. See that function's own header for the `permissions === null` lesson.
127
+ */
149
128
  /*
150
129
  * ── Content clearance for the desktop sidebar ───────────────────────────────
151
130
  *
@@ -238,7 +217,7 @@ responsive: _responsive, crossNav, className, contentPadding, contentSurface, co
238
217
  const resolvedLightBackground = lightBackground ?? settings?.background?.light;
239
218
  const resolvedDarkBackground = darkBackground ?? settings?.background?.dark;
240
219
  const hasBackground = Boolean(resolvedLightBackground?.value || resolvedDarkBackground?.value);
241
- const filteredGroups = filterGroups(groups, navConfig, permissions);
220
+ const filteredGroups = filterNavGroups(groups, navConfig, permissions);
242
221
  const adminGroups = variant === "admin"
243
222
  ? filteredGroups
244
223
  : groups;
@@ -1,3 +1,4 @@
1
+ import type { SidebarNavGroup } from "../../../shared/features/layout/types";
1
2
  type FilterableItem = {
2
3
  id?: string;
3
4
  requiredPermission?: string;
@@ -33,4 +34,31 @@ type FilterableItem = {
33
34
  export declare function filterNavItems<T extends FilterableItem>(items: T[], navConfig: Record<string, {
34
35
  enabled: boolean;
35
36
  }> | undefined, userPermissions: string[] | undefined, isAdmin?: boolean): T[];
37
+ /**
38
+ * The GROUP-level filter — the sidebar's and the dashboard hubs' single answer
39
+ * to "which sections and links may this viewer see".
40
+ *
41
+ * ## 🛑 `permissions === null` MEANS ADMIN, and that is why this is shared
42
+ *
43
+ * This lived as a module-private `filterGroups` inside
44
+ * `DashboardLayoutClient.tsx` until the dashboard hubs needed the same answer.
45
+ * Copying it would have been Root Cause #75's shape exactly — a rule whose own
46
+ * comment says it is the convention, reachable only by copy-paste, where the
47
+ * copy made today never learns about tomorrow's branch. And the branch it would
48
+ * have lost is the expensive one: the `isAdmin && !navConfig` early return was
49
+ * ONCE applied only when no navConfig existed, and the moment site settings
50
+ * carried any nav config an admin's whole sidebar filtered to empty.
51
+ *
52
+ * A hub that disagreed with the sidebar would be worse than that, not better:
53
+ * the sidebar is a list of links, while a hub card is a promise that the page
54
+ * behind it will open. A card for a page that then 403s is a dead end the
55
+ * viewer cannot distinguish from a broken permission.
56
+ *
57
+ * An empty group is dropped rather than rendered as a heading with nothing
58
+ * under it — on a hub that is a section card claiming "0 screens", which reads
59
+ * as a fault rather than as "not for you".
60
+ */
61
+ export declare function filterNavGroups<T extends SidebarNavGroup>(groups: T[], navConfig: Record<string, {
62
+ enabled: boolean;
63
+ }> | undefined, permissions: string[] | null | undefined): T[];
36
64
  export {};
@@ -41,3 +41,38 @@ export function filterNavItems(items, navConfig, userPermissions, isAdmin = fals
41
41
  return true;
42
42
  });
43
43
  }
44
+ /**
45
+ * The GROUP-level filter — the sidebar's and the dashboard hubs' single answer
46
+ * to "which sections and links may this viewer see".
47
+ *
48
+ * ## 🛑 `permissions === null` MEANS ADMIN, and that is why this is shared
49
+ *
50
+ * This lived as a module-private `filterGroups` inside
51
+ * `DashboardLayoutClient.tsx` until the dashboard hubs needed the same answer.
52
+ * Copying it would have been Root Cause #75's shape exactly — a rule whose own
53
+ * comment says it is the convention, reachable only by copy-paste, where the
54
+ * copy made today never learns about tomorrow's branch. And the branch it would
55
+ * have lost is the expensive one: the `isAdmin && !navConfig` early return was
56
+ * ONCE applied only when no navConfig existed, and the moment site settings
57
+ * carried any nav config an admin's whole sidebar filtered to empty.
58
+ *
59
+ * A hub that disagreed with the sidebar would be worse than that, not better:
60
+ * the sidebar is a list of links, while a hub card is a promise that the page
61
+ * behind it will open. A card for a page that then 403s is a dead end the
62
+ * viewer cannot distinguish from a broken permission.
63
+ *
64
+ * An empty group is dropped rather than rendered as a heading with nothing
65
+ * under it — on a hub that is a section card claiming "0 screens", which reads
66
+ * as a fault rather than as "not for you".
67
+ */
68
+ export function filterNavGroups(groups, navConfig, permissions) {
69
+ const isAdmin = permissions === null;
70
+ if (isAdmin && !navConfig)
71
+ return groups;
72
+ return groups
73
+ .map((group) => ({
74
+ ...group,
75
+ items: filterNavItems(group.items, navConfig, permissions ?? undefined, isAdmin),
76
+ }))
77
+ .filter((group) => group.items.length > 0);
78
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * D56 — self-serve ad bookings: take a slot, moderate the creative, bill it.
3
+ *
4
+ * ## 🛑 Availability is a DOCUMENT COLLISION, not a query
5
+ *
6
+ * The obvious implementation of "two advertisers must not book overlapping
7
+ * dates" is to query for an overlap and write when none is found. That is a
8
+ * read-then-write race: two concurrent bookings both read "free" and both
9
+ * write, and the slot is sold twice with nothing erroring. It is invisible
10
+ * in any test that does not run two requests at the same instant.
11
+ *
12
+ * So the booking id is `${placementId}__${period}` and creation uses
13
+ * `tx.create`, which throws `ALREADY_EXISTS` on a document that is already
14
+ * there. Firestore does the locking; there is no window between the check
15
+ * and the write because they are the same operation.
16
+ *
17
+ * ## 🛑 Nothing renders until a human approves the creative
18
+ *
19
+ * `isAdBookingLive` requires `approved` AND `paid` AND in-period AND
20
+ * not-cancelled, and the advertiser's write schema cannot express the first
21
+ * two. A self-serve surface where the submitter sets their own moderation
22
+ * state is a form with an extra field, not a moderated system.
23
+ */
24
+ import { type AdBookingCreative, type AdBookingDocument, type AdPeriod } from "../../../../features/ads/schemas/firestore";
25
+ export interface BookAdSlotInput {
26
+ placementId: string;
27
+ period: AdPeriod;
28
+ advertiserUid: string;
29
+ advertiserName?: string;
30
+ creative: AdBookingCreative;
31
+ /**
32
+ * Admin override — book on an advertiser's behalf. The plan requires it:
33
+ * *"a self-serve flow with no manual escape hatch has no way to fix a
34
+ * mistake."* It changes who may call, never what is written.
35
+ */
36
+ byAdmin?: boolean;
37
+ }
38
+ /**
39
+ * Take a slot. Returns the booking id.
40
+ *
41
+ * Throws **409** when the slot is taken (a well-formed request against a
42
+ * world that moved — the same distinction the lottery config write makes for
43
+ * a pulled slot) and **400** for a past period or an unknown placement.
44
+ */
45
+ export declare function bookAdSlot(input: BookAdSlotInput): Promise<string>;
46
+ /**
47
+ * A moderator's verdict. **This is the only writer of `moderation.state`.**
48
+ */
49
+ export declare function moderateAdCreative(bookingId: string, input: {
50
+ approve: boolean;
51
+ note?: string;
52
+ reviewerUid: string;
53
+ }): Promise<void>;
54
+ /**
55
+ * Re-submit a rejected creative.
56
+ *
57
+ * 🛑 Resets moderation to `pending` in the SAME write. A creative edited
58
+ * while `approved` would otherwise go live unreviewed — change the image,
59
+ * keep the approval. That is the whole attack, and it needs no cleverness:
60
+ * submit something bland, get approved, edit.
61
+ */
62
+ export declare function resubmitAdCreative(bookingId: string, advertiserUid: string, creative: AdBookingCreative): Promise<void>;
63
+ /**
64
+ * Cancel a booking. **The slot stays locked for its period** — see
65
+ * `AD_BOOKING_CANCELLED_KEEPS_SLOT`. Releasing it is a separate, deliberate
66
+ * admin act.
67
+ */
68
+ export declare function cancelAdBooking(bookingId: string, opts: {
69
+ actorUid: string;
70
+ reason?: string;
71
+ byAdmin?: boolean;
72
+ }): Promise<void>;
73
+ /**
74
+ * 🛑 ADMIN ONLY, and it DELETES — this is the one path that frees a period.
75
+ *
76
+ * Cancelling keeps the lock because the advertiser paid for a month that may
77
+ * already be running. Releasing says "that month is for sale again", which
78
+ * is a commercial decision with a refund behind it, so it is separate,
79
+ * explicit, and logged rather than a side effect of a cancel button.
80
+ */
81
+ export declare function releaseAdSlot(placementId: string, period: AdPeriod, actorUid: string): Promise<void>;
82
+ /**
83
+ * What should actually render in a placement right now.
84
+ *
85
+ * 🛑 ONE document read, by derived id — no query, no index, no scan. The
86
+ * slot's occupant for this month is computable from the placement and the
87
+ * date, which is the second reason the period model is worth its
88
+ * coarseness: the render path of an ad costs the same as reading one row.
89
+ */
90
+ export declare function liveBookingForPlacement(placementId: string, now?: Date): Promise<AdBookingDocument | null>;
91
+ /** The moderation queue. */
92
+ export declare function pendingAdBookings(limit?: number): Promise<AdBookingDocument[]>;
93
+ /** An advertiser's own bookings. */
94
+ export declare function adBookingsForAdvertiser(advertiserUid: string, limit?: number): Promise<AdBookingDocument[]>;
95
+ export interface AdSlotOffer {
96
+ placementId: string;
97
+ label: string;
98
+ priceRsPerPeriod: number;
99
+ /** `YYYY-MM` → is it still free? */
100
+ availability: {
101
+ period: AdPeriod;
102
+ available: boolean;
103
+ }[];
104
+ }
105
+ /**
106
+ * The rate card plus availability — what an `/advertise` page renders.
107
+ *
108
+ * 🛑 One read per OFFERED SLOT, and the slot count is bounded by
109
+ * `placements × months`, both small and both known. The id is derived, so
110
+ * this is a `getAll` of specific documents rather than a query — there is no
111
+ * scan, no index, and no page of results to cap.
112
+ *
113
+ * Unpriced and disabled placements are OMITTED rather than shown greyed out:
114
+ * a card for something that cannot be bought is a dead facet, and
115
+ * `brand-rows.ts` already records why that is worse than absent — "it
116
+ * renders as a card in the grid that opens an empty page".
117
+ */
118
+ export declare function adSlotOffers(months?: number, now?: Date): Promise<AdSlotOffer[]>;
@@ -0,0 +1,326 @@
1
+ /**
2
+ * D56 — self-serve ad bookings: take a slot, moderate the creative, bill it.
3
+ *
4
+ * ## 🛑 Availability is a DOCUMENT COLLISION, not a query
5
+ *
6
+ * The obvious implementation of "two advertisers must not book overlapping
7
+ * dates" is to query for an overlap and write when none is found. That is a
8
+ * read-then-write race: two concurrent bookings both read "free" and both
9
+ * write, and the slot is sold twice with nothing erroring. It is invisible
10
+ * in any test that does not run two requests at the same instant.
11
+ *
12
+ * So the booking id is `${placementId}__${period}` and creation uses
13
+ * `tx.create`, which throws `ALREADY_EXISTS` on a document that is already
14
+ * there. Firestore does the locking; there is no window between the check
15
+ * and the write because they are the same operation.
16
+ *
17
+ * ## 🛑 Nothing renders until a human approves the creative
18
+ *
19
+ * `isAdBookingLive` requires `approved` AND `paid` AND in-period AND
20
+ * not-cancelled, and the advertiser's write schema cannot express the first
21
+ * two. A self-serve surface where the submitter sets their own moderation
22
+ * state is a form with an extra field, not a moderated system.
23
+ */
24
+ import { getAdminDb } from "../../../../providers/db-firebase/index.js";
25
+ import { ValidationError } from "../../../../errors/validation-error.js";
26
+ import { NotFoundError } from "../../../../errors/not-found-error.js";
27
+ import { ConflictError } from "../../../../errors/conflict-error.js";
28
+ import { normalizeError } from "../../../../errors/normalize.js";
29
+ import { serverLogger } from "../../../../monitoring/server-logger.js";
30
+ import { siteSettingsRepository } from "../../../../features/admin/repository/site-settings.repository.js";
31
+ import { AD_BOOKING_FIELDS } from "../../../../constants/field-names.js";
32
+ /*
33
+ * 🛑 ONE message for both "no such booking" and "not yours", and that is the
34
+ * whole point of naming it.
35
+ *
36
+ * A booking the caller does not own answers 404, never 403 — a 403 confirms
37
+ * the id exists, which turns a guessable id into an enumeration oracle for
38
+ * who is advertising where. The two branches must therefore be
39
+ * indistinguishable, and two string literals that merely happen to match
40
+ * today are one careless edit away from telling them apart.
41
+ */
42
+ const BOOKING_NOT_FOUND = "Booking not found.";
43
+ import { AD_BOOKING_COLLECTION, adBookingId, adPeriodOf, adPeriodStart, isAdBookingLive, isAdPeriod, } from "../../../../features/ads/schemas/firestore.js";
44
+ /**
45
+ * Take a slot. Returns the booking id.
46
+ *
47
+ * Throws **409** when the slot is taken (a well-formed request against a
48
+ * world that moved — the same distinction the lottery config write makes for
49
+ * a pulled slot) and **400** for a past period or an unknown placement.
50
+ */
51
+ export async function bookAdSlot(input) {
52
+ if (!isAdPeriod(input.period)) {
53
+ throw new ValidationError("Pick a month, as YYYY-MM.");
54
+ }
55
+ /*
56
+ * 🛑 The CURRENT period is bookable, a past one is not.
57
+ *
58
+ * Comparing period STARTS rather than "is the period over" is deliberate:
59
+ * booking the running month is a legitimate late sale, and the slot simply
60
+ * runs for whatever is left of it. Refusing it would mean the only
61
+ * bookable month is next month, which is not a rule anybody asked for.
62
+ */
63
+ if (adPeriodStart(input.period) < adPeriodStart(adPeriodOf(new Date()))) {
64
+ throw new ValidationError("That month has already passed.");
65
+ }
66
+ const settings = await siteSettingsRepository.getSingleton();
67
+ const placement = settings?.adSettings?.placements?.find((p) => p.id === input.placementId);
68
+ if (!placement)
69
+ throw new NotFoundError("No such ad placement.");
70
+ /*
71
+ * A disabled placement renders nothing, so selling it would take money for
72
+ * a slot that cannot appear. An admin override does NOT bypass this —
73
+ * the escape hatch exists to fix a booking, not to sell a dead slot.
74
+ */
75
+ if (!placement.enabled) {
76
+ throw new ValidationError("That placement is not currently sold.");
77
+ }
78
+ const priceRs = resolveSlotPrice(placement);
79
+ const db = getAdminDb();
80
+ const id = adBookingId(input.placementId, input.period);
81
+ const ref = db.collection(AD_BOOKING_COLLECTION).doc(id);
82
+ const now = new Date();
83
+ const doc = {
84
+ placementId: input.placementId,
85
+ period: input.period,
86
+ advertiserUid: input.advertiserUid,
87
+ ...(input.advertiserName ? { advertiserName: input.advertiserName } : {}),
88
+ priceRs,
89
+ paymentState: "unpaid",
90
+ creative: input.creative,
91
+ /*
92
+ * 🛑 Always `pending`, including on the admin path. An admin booking on
93
+ * an advertiser's behalf is still an unreviewed creative — letting the
94
+ * override imply approval would make the escape hatch the way around
95
+ * moderation, which is how every escape hatch eventually gets used.
96
+ */
97
+ moderation: { state: "pending" },
98
+ createdAt: now,
99
+ updatedAt: now,
100
+ };
101
+ try {
102
+ await db.runTransaction(async (tx) => {
103
+ /*
104
+ * `tx.create`, NOT `tx.set`. `set` overwrites, which would silently
105
+ * replace another advertiser's paid booking with this one — the exact
106
+ * failure the lock exists to prevent, spelled one method name apart.
107
+ */
108
+ tx.create(ref, doc);
109
+ });
110
+ }
111
+ catch (error) {
112
+ const normalized = normalizeError(error);
113
+ if (/already exists/i.test(normalized.message)) {
114
+ throw new ConflictError("That slot is already booked for this month.");
115
+ }
116
+ throw error;
117
+ }
118
+ return id;
119
+ }
120
+ /**
121
+ * A moderator's verdict. **This is the only writer of `moderation.state`.**
122
+ */
123
+ export async function moderateAdCreative(bookingId, input) {
124
+ const note = input.note?.trim();
125
+ if (!input.approve && (note?.length ?? 0) < 10) {
126
+ /*
127
+ * Enforced here AND in the Zod schema. The schema protects the route;
128
+ * this protects every other caller, and a rejection with no reason
129
+ * guarantees the advertiser resubmits the same creative (Root Cause
130
+ * #74's shape — a prediction with no mechanism is unenforced).
131
+ */
132
+ throw new ValidationError("Say why it was rejected.");
133
+ }
134
+ const db = getAdminDb();
135
+ const ref = db.collection(AD_BOOKING_COLLECTION).doc(bookingId);
136
+ await db.runTransaction(async (tx) => {
137
+ const snap = await tx.get(ref);
138
+ if (!snap.exists)
139
+ throw new NotFoundError(BOOKING_NOT_FOUND);
140
+ const booking = snap.data();
141
+ if (booking.cancelledAt) {
142
+ throw new ConflictError("That booking was cancelled.");
143
+ }
144
+ tx.update(ref, {
145
+ "moderation.state": input.approve ? "approved" : "rejected",
146
+ "moderation.reviewedAt": new Date(),
147
+ "moderation.reviewedBy": input.reviewerUid,
148
+ ...(note ? { "moderation.note": note } : {}),
149
+ updatedAt: new Date(),
150
+ });
151
+ });
152
+ }
153
+ /**
154
+ * Re-submit a rejected creative.
155
+ *
156
+ * 🛑 Resets moderation to `pending` in the SAME write. A creative edited
157
+ * while `approved` would otherwise go live unreviewed — change the image,
158
+ * keep the approval. That is the whole attack, and it needs no cleverness:
159
+ * submit something bland, get approved, edit.
160
+ */
161
+ export async function resubmitAdCreative(bookingId, advertiserUid, creative) {
162
+ const db = getAdminDb();
163
+ const ref = db.collection(AD_BOOKING_COLLECTION).doc(bookingId);
164
+ await db.runTransaction(async (tx) => {
165
+ const snap = await tx.get(ref);
166
+ if (!snap.exists)
167
+ throw new NotFoundError(BOOKING_NOT_FOUND);
168
+ const booking = snap.data();
169
+ if (booking.advertiserUid !== advertiserUid) {
170
+ throw new NotFoundError(BOOKING_NOT_FOUND);
171
+ }
172
+ if (booking.cancelledAt)
173
+ throw new ConflictError("That booking was cancelled.");
174
+ tx.update(ref, {
175
+ creative,
176
+ "moderation.state": "pending",
177
+ "moderation.note": "",
178
+ updatedAt: new Date(),
179
+ });
180
+ });
181
+ }
182
+ /**
183
+ * Cancel a booking. **The slot stays locked for its period** — see
184
+ * `AD_BOOKING_CANCELLED_KEEPS_SLOT`. Releasing it is a separate, deliberate
185
+ * admin act.
186
+ */
187
+ export async function cancelAdBooking(bookingId, opts) {
188
+ const db = getAdminDb();
189
+ const ref = db.collection(AD_BOOKING_COLLECTION).doc(bookingId);
190
+ await db.runTransaction(async (tx) => {
191
+ const snap = await tx.get(ref);
192
+ if (!snap.exists)
193
+ throw new NotFoundError(BOOKING_NOT_FOUND);
194
+ const booking = snap.data();
195
+ if (!opts.byAdmin && booking.advertiserUid !== opts.actorUid) {
196
+ throw new NotFoundError(BOOKING_NOT_FOUND);
197
+ }
198
+ if (booking.cancelledAt)
199
+ return; // idempotent
200
+ tx.update(ref, {
201
+ cancelledAt: new Date(),
202
+ ...(opts.reason ? { cancelledReason: opts.reason } : {}),
203
+ updatedAt: new Date(),
204
+ });
205
+ });
206
+ }
207
+ /**
208
+ * 🛑 ADMIN ONLY, and it DELETES — this is the one path that frees a period.
209
+ *
210
+ * Cancelling keeps the lock because the advertiser paid for a month that may
211
+ * already be running. Releasing says "that month is for sale again", which
212
+ * is a commercial decision with a refund behind it, so it is separate,
213
+ * explicit, and logged rather than a side effect of a cancel button.
214
+ */
215
+ export async function releaseAdSlot(placementId, period, actorUid) {
216
+ const db = getAdminDb();
217
+ const id = adBookingId(placementId, period);
218
+ const ref = db.collection(AD_BOOKING_COLLECTION).doc(id);
219
+ const snap = await ref.get();
220
+ if (!snap.exists)
221
+ throw new NotFoundError("No booking on that slot.");
222
+ const booking = snap.data();
223
+ if (booking.paymentState === "paid") {
224
+ throw new ConflictError("That booking is paid — refund it before releasing the slot.");
225
+ }
226
+ await ref.delete();
227
+ serverLogger.info("Ad slot released", { placementId, period, actorUid });
228
+ }
229
+ /**
230
+ * What should actually render in a placement right now.
231
+ *
232
+ * 🛑 ONE document read, by derived id — no query, no index, no scan. The
233
+ * slot's occupant for this month is computable from the placement and the
234
+ * date, which is the second reason the period model is worth its
235
+ * coarseness: the render path of an ad costs the same as reading one row.
236
+ */
237
+ export async function liveBookingForPlacement(placementId, now = new Date()) {
238
+ const db = getAdminDb();
239
+ const id = adBookingId(placementId, adPeriodOf(now));
240
+ const snap = await db.collection(AD_BOOKING_COLLECTION).doc(id).get();
241
+ if (!snap.exists)
242
+ return null;
243
+ const booking = { id: snap.id, ...snap.data() };
244
+ return isAdBookingLive(booking, now) ? booking : null;
245
+ }
246
+ /** The moderation queue. */
247
+ export async function pendingAdBookings(limit = 50) {
248
+ const db = getAdminDb();
249
+ const snap = await db
250
+ .collection(AD_BOOKING_COLLECTION)
251
+ .where("moderation.state", "==", "pending")
252
+ .orderBy(AD_BOOKING_FIELDS.CREATED_AT, "asc")
253
+ .limit(limit)
254
+ .get();
255
+ return snap.docs.map((d) => ({ id: d.id, ...d.data() }));
256
+ }
257
+ /** An advertiser's own bookings. */
258
+ export async function adBookingsForAdvertiser(advertiserUid, limit = 50) {
259
+ const db = getAdminDb();
260
+ const snap = await db
261
+ .collection(AD_BOOKING_COLLECTION)
262
+ .where(AD_BOOKING_FIELDS.ADVERTISER_UID, "==", advertiserUid)
263
+ .orderBy(AD_BOOKING_FIELDS.CREATED_AT, "desc")
264
+ .limit(limit)
265
+ .get();
266
+ return snap.docs.map((d) => ({ id: d.id, ...d.data() }));
267
+ }
268
+ /**
269
+ * The slot's monthly rate, from the placement itself.
270
+ *
271
+ * 🛑 Throws rather than defaulting to zero. A free booking that looks
272
+ * successful is worse than a refused one: the slot is taken, the invoice
273
+ * is ₹0, and nothing anywhere says a price was missing. An unpriced
274
+ * placement is one an admin has not put up for sale yet, and saying so is
275
+ * the honest answer.
276
+ */
277
+ function resolveSlotPrice(placement) {
278
+ const rate = placement.priceRsPerPeriod;
279
+ if (typeof rate !== "number" || !Number.isFinite(rate) || rate <= 0) {
280
+ throw new ValidationError("That placement has no price set — an admin must add one before it can be sold.");
281
+ }
282
+ return rate;
283
+ }
284
+ /**
285
+ * The rate card plus availability — what an `/advertise` page renders.
286
+ *
287
+ * 🛑 One read per OFFERED SLOT, and the slot count is bounded by
288
+ * `placements × months`, both small and both known. The id is derived, so
289
+ * this is a `getAll` of specific documents rather than a query — there is no
290
+ * scan, no index, and no page of results to cap.
291
+ *
292
+ * Unpriced and disabled placements are OMITTED rather than shown greyed out:
293
+ * a card for something that cannot be bought is a dead facet, and
294
+ * `brand-rows.ts` already records why that is worse than absent — "it
295
+ * renders as a card in the grid that opens an empty page".
296
+ */
297
+ export async function adSlotOffers(months = 6, now = new Date()) {
298
+ const settings = await siteSettingsRepository.getSingleton();
299
+ const placements = (settings?.adSettings?.placements ?? []).filter((p) => p.enabled && typeof p.priceRsPerPeriod === "number" && p.priceRsPerPeriod > 0);
300
+ if (placements.length === 0)
301
+ return [];
302
+ const periods = [];
303
+ for (let i = 0; i < Math.max(1, Math.min(months, 12)); i += 1) {
304
+ periods.push(adPeriodOf(new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + i, 1))));
305
+ }
306
+ const db = getAdminDb();
307
+ const refs = placements.flatMap((p) => periods.map((period) => db.collection(AD_BOOKING_COLLECTION).doc(adBookingId(p.id, period))));
308
+ const snaps = await db.getAll(...refs);
309
+ const taken = new Set(snaps.filter((s) => s.exists).map((s) => s.id));
310
+ return placements.map((p) => ({
311
+ placementId: p.id,
312
+ label: p.label,
313
+ priceRsPerPeriod: p.priceRsPerPeriod,
314
+ availability: periods.map((period) => ({
315
+ period,
316
+ /*
317
+ * A CANCELLED booking still occupies its month — the document is the
318
+ * lock and cancelling deliberately keeps it (see
319
+ * `AD_BOOKING_CANCELLED_KEEPS_SLOT`). Existence is therefore the right
320
+ * test, and reading the status to "free up" a cancelled month here
321
+ * would quietly undo that decision in the one place a buyer sees it.
322
+ */
323
+ available: !taken.has(adBookingId(p.id, period)),
324
+ })),
325
+ }));
326
+ }