@mohasinac/appkit 4.62.1 → 4.63.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 (261) 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 +120 -0
  112. package/dist/features/ads/schemas/booking-write.js +75 -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.d.ts +27 -1
  232. package/dist/server.js +144 -0
  233. package/dist/styles.css +349 -1
  234. package/dist/tailwind-utilities.css +1 -1
  235. package/dist/ui/components/Button.d.ts +10 -1
  236. package/dist/ui/components/Button.js +25 -3
  237. package/dist/ui/components/Chart.d.ts +63 -0
  238. package/dist/ui/components/Chart.js +169 -0
  239. package/dist/ui/components/Chart.style.css +214 -0
  240. package/dist/ui/components/Disclaimer.js +10 -1
  241. package/dist/ui/components/OverflowRow.js +54 -2
  242. package/dist/ui/components/OverflowRow.style.css +38 -0
  243. package/dist/ui/components/RichTextEditor.js +99 -8
  244. package/dist/ui/components/chart-spec.d.ts +68 -0
  245. package/dist/ui/components/chart-spec.js +140 -0
  246. package/dist/ui/components/index.style.css +1 -0
  247. package/dist/ui/components/surface-tokens.d.ts +1 -1
  248. package/dist/ui/components/surface-tokens.js +9 -0
  249. package/dist/ui/components/toolbar-collapse.d.ts +13 -0
  250. package/dist/ui/components/toolbar-collapse.js +10 -1
  251. package/dist/ui/index.d.ts +6 -0
  252. package/dist/ui/index.js +3 -0
  253. package/dist/ui/rich-text/RichText.d.ts +10 -1
  254. package/dist/ui/rich-text/RichText.js +33 -2
  255. package/dist/ui/rich-text/RichText.style.css +35 -0
  256. package/dist/ui/rich-text/RichTextRenderer.d.ts +30 -1
  257. package/dist/ui/rich-text/RichTextRenderer.js +34 -2
  258. package/dist/ui/rich-text/chart-blocks.d.ts +62 -0
  259. package/dist/ui/rich-text/chart-blocks.js +87 -0
  260. package/package.json +1 -1
  261. package/scripts/seed-cli.mjs +3 -0
@@ -46,7 +46,7 @@ const CARRIER_OPTIONS = [
46
46
  { label: "Other", value: "Other" },
47
47
  ];
48
48
  // --- Component ---------------------------------------------------------------
49
- export function AdminOrderEditorView({ open, onClose, orderId, orderLabel, currentStatus, items, sourceContext, currency, paymentProofUrl, paymentTransactionId, paymentMethod, paymentStatus, displayedUpiId, buyerReportedUpiId, paymentUpiMismatch, buyerMarkedPaid, buyerFraudAgreementAccepted, paymentReviewOutcome, addons, }) {
49
+ export function AdminOrderEditorView({ open, onClose, orderId, orderLabel, currentStatus, items, sourceContext, currency, paymentProofUrl, paymentTransactionId, paymentMethod, paymentStatus, displayedUpiId, buyerReportedUpiId, paymentUpiMismatch, buyerMarkedPaid, buyerFraudAgreementAccepted, paymentReviewOutcome, addons, balance, }) {
50
50
  const queryClient = useQueryClient();
51
51
  const { showToast } = useToast();
52
52
  const [draft, setDraft] = React.useState({
@@ -83,6 +83,19 @@ export function AdminOrderEditorView({ open, onClose, orderId, orderLabel, curre
83
83
  const [reviewNoteError, setReviewNoteError] = React.useState(null);
84
84
  const [isRequestingReupload, setIsRequestingReupload] = useState(false);
85
85
  const [isRejectingFraud, setIsRejectingFraud] = useState(false);
86
+ /*
87
+ * D55 — the balance review's OWN note and its own pending flags.
88
+ *
89
+ * 🛑 Not shared with `reviewNote` above, which belongs to the deposit's
90
+ * three actions. One note box feeding two reviews is the bug where an admin
91
+ * types "blurry screenshot" meaning the balance and sends it as a
92
+ * fraud-rejection reason on the deposit — and the deposit's rejection bans
93
+ * the account for 7 days.
94
+ */
95
+ const [balanceNote, setBalanceNote] = React.useState("");
96
+ const [balanceNoteError, setBalanceNoteError] = React.useState(null);
97
+ const [isAcceptingBalance, setIsAcceptingBalance] = useState(false);
98
+ const [isRejectingBalance, setIsRejectingBalance] = useState(false);
86
99
  // This used to inline `paymentMethod === "cash" || === "upi_manual"`, which
87
100
  // omitted `emi` — so the whole proof panel never rendered for an EMI order and
88
101
  // an admin had no way to verify one, even though the list row said "Awaiting
@@ -105,6 +118,10 @@ export function AdminOrderEditorView({ open, onClose, orderId, orderLabel, curre
105
118
  notes: "",
106
119
  });
107
120
  setReviewNote("");
121
+ // Reset with its sibling — a note typed for one order must not reappear
122
+ // under the next one the drawer opens.
123
+ setBalanceNote("");
124
+ setBalanceNoteError(null);
108
125
  }
109
126
  }, [open, currentStatus]);
110
127
  const saveMutation = useApiMutation({
@@ -216,7 +233,49 @@ export function AdminOrderEditorView({ open, onClose, orderId, orderLabel, curre
216
233
  setIsRejectingFraud(false);
217
234
  }
218
235
  };
219
- return (_jsx(SideDrawer, { isOpen: open, onClose: onClose, title: orderLabel ? `Order: ${orderLabel}` : "Update Order", children: _jsx(FormShellContext.Provider, { value: form.shellCtx, children: _jsxs(Stack, { gap: "md", padding: "md", children: [_jsx(FormErrorSummary, {}), addons && _jsx(OrderAddonBadges, { order: addons, variant: "detail" }), _jsx(OrderProvenanceCard, { sourceContext: sourceContext, currency: currency }), items && items.length > 0 && (_jsxs(Stack, { gap: "xs", children: [_jsxs(Label, { size: "sm", weight: "medium", color: "primary", children: ["Items (", items.length, ")"] }), _jsx(Div, { className: "divide-y divide-[var(--appkit-color-border)] border border-[var(--appkit-color-border)]", rounded: "lg", children: groupOrderItemsByLine(items).map((group, gi) => group.kind === "single" ? (adminOrderItemRow(group.item, `s-${group.index}`)) : (_jsxs(Div, { children: [_jsxs(Div, { layout: "flex", align: "center", gap: "2", padding: "x-sm", surface: "muted", children: [_jsx(Text, { size: "xs", weight: "semibold", children: group.groupTitle ?? "Grouped purchase" }), _jsx(Text, { size: "xs", color: "muted", children: `${group.memberCount} ${group.memberCount === 1 ? "item" : "items"} · one line` })] }), _jsx(Div, { className: "pl-4", children: group.items.map(({ item, index }) => adminOrderItemRow(item, `m-${index}`)) })] }, `g-${gi}`))) })] })), _jsx(SectionForm, { sections: sections, values: draft, onChange: (partial) => setDraft((d) => ({ ...d, ...partial })), onSubmit: () => {
236
+ /**
237
+ * D55 — accept or reject the pre-order BALANCE proof.
238
+ *
239
+ * 🛑 One handler, because the two verdicts differ by a boolean and a note
240
+ * and nothing else. The deposit has three handlers because its three
241
+ * outcomes are genuinely different operations (verify / re-ask / cancel +
242
+ * ban); mirroring that shape here would be three copies of one request.
243
+ *
244
+ * The note requirement is checked in BOTH places on purpose: here so the
245
+ * admin is told before the round trip, and in `reviewBalanceProof` because
246
+ * the route is reachable without this component.
247
+ */
248
+ const handleBalanceReview = async (approve) => {
249
+ if (!orderId)
250
+ return;
251
+ if (!approve && !balanceNote.trim()) {
252
+ setBalanceNoteError("Say what was wrong — without it the buyer will send the same proof again.");
253
+ return;
254
+ }
255
+ setBalanceNoteError(null);
256
+ const setPending = approve ? setIsAcceptingBalance : setIsRejectingBalance;
257
+ setPending(true);
258
+ try {
259
+ await apiClient.post(ADMIN_ENDPOINTS.ORDER_BALANCE_REVIEW(orderId), {
260
+ approve,
261
+ ...(balanceNote.trim() ? { note: balanceNote.trim() } : {}),
262
+ });
263
+ showToast(approve
264
+ ? "Balance accepted. The order is clear to ship."
265
+ : "Proof returned to the buyer with your note.", "success");
266
+ queryClient.invalidateQueries({ queryKey: ["admin", "orders"] });
267
+ onClose();
268
+ }
269
+ catch (err) {
270
+ void normalizeError(err);
271
+ // Authored copy — `err.message` is server text (Root Cause #86).
272
+ showToast("Could not record that decision.", "error");
273
+ }
274
+ finally {
275
+ setPending(false);
276
+ }
277
+ };
278
+ return (_jsx(SideDrawer, { isOpen: open, onClose: onClose, title: orderLabel ? `Order: ${orderLabel}` : "Update Order", children: _jsx(FormShellContext.Provider, { value: form.shellCtx, children: _jsxs(Stack, { gap: "md", padding: "md", children: [_jsx(FormErrorSummary, {}), addons && _jsx(OrderAddonBadges, { order: addons, variant: "detail" }), _jsx(OrderProvenanceCard, { sourceContext: sourceContext, currency: currency }), items && items.length > 0 && (_jsxs(Stack, { gap: "xs", children: [_jsxs(Label, { size: "sm", weight: "medium", color: "primary", children: ["Items (", items.length, ")"] }), _jsx(Div, { className: "divide-y divide-[var(--appkit-color-border)] border border-[var(--appkit-color-border)]", rounded: "lg", children: groupOrderItemsByLine(items).map((group, gi) => group.kind === "single" ? (adminOrderItemRow(group.item, `s-${group.index}`)) : (_jsxs(Div, { children: [_jsxs(Div, { layout: "flex", align: "center", gap: "2", padding: "x-sm", surface: "muted", children: [_jsx(Text, { size: "xs", weight: "semibold", children: group.groupTitle ?? "Grouped purchase" }), _jsx(Text, { size: "xs", color: "muted", children: `${group.memberCount} ${group.memberCount === 1 ? "item" : "items"} · one line` })] }), _jsx(Div, { paddingX: "l-md", children: group.items.map(({ item, index }) => adminOrderItemRow(item, `m-${index}`)) })] }, `g-${gi}`))) })] })), _jsx(SectionForm, { sections: sections, values: draft, onChange: (partial) => setDraft((d) => ({ ...d, ...partial })), onSubmit: () => {
220
279
  // SectionForm surfaces errors and then submits regardless, so the
221
280
  // guard is here.
222
281
  form.clearErrors();
@@ -226,5 +285,15 @@ export function AdminOrderEditorView({ open, onClose, orderId, orderLabel, curre
226
285
  return;
227
286
  }
228
287
  saveMutation.mutate();
229
- }, onValidationChange: () => form.validate(draft), schema: adminOrderUpdateSchema, openIds: nav.openIds, onOpenChange: nav.setOpenIds, submitLabel: "Save changes", cancelLabel: "Cancel", onCancel: onClose, isLoading: saveMutation.isPending }), isManualPayment && (_jsxs(Stack, { gap: "xs", children: [_jsx(Label, { size: "sm", weight: "medium", color: "primary", children: "Payment Proof" }), isRejected && (_jsx(Text, { size: "xs", className: "text-error", weight: "medium", children: "Payment rejected as fraud \u2014 no further action available here." })), isReuploadRequested && !isVerified && (_jsx(Text, { size: "xs", className: "text-warning", weight: "medium", children: "Re-upload requested \u2014 waiting for the buyer to submit a new proof." })), paymentProofUrl ? (_jsxs(Stack, { gap: "xs", children: [_jsx(Div, { border: "default", rounded: "lg", overflow: "hidden", children: _jsx(MediaImage, { src: paymentProofUrl, alt: "Payment screenshot", size: "card" }) }), paymentTransactionId && (_jsxs(Text, { size: "xs", color: "muted", children: ["UTR: ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: paymentTransactionId })] })), needsVerification && (_jsxs(Stack, { gap: "xs", children: [(displayedUpiId || buyerReportedUpiId) && (_jsxs(Stack, { gap: "xs", children: [_jsxs(Text, { size: "xs", color: "muted", children: ["Expected UPI: ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: displayedUpiId || "—" }), " · ", "Buyer reported: ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: buyerReportedUpiId || "—" })] }), paymentUpiMismatch && (_jsx(Div, { rounded: "lg", padding: "inlineSm", className: "border border-error/20", surface: "danger-surface", children: _jsx(Text, { size: "xs", className: "text-error", weight: "semibold", children: "UPI mismatch \u2014 buyer-reported ID doesn't match the one shown for this order." }) }))] })), _jsxs(Text, { size: "xs", color: "muted", children: ["Buyer marked as paid: ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: buyerMarkedPaid ? "Yes" : "No" }), " · ", "Fraud agreement accepted: ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: buyerFraudAgreementAccepted ? "Yes" : "No" })] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { size: "sm", weight: "medium", color: "primary", children: "Review note" }), _jsxs(Text, { size: "xs", color: "muted", children: ["Sent with ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: "Request re-upload" }), " or", " ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: "Reject as fraud" }), " below. Not saved by \u201CSave changes\u201D."] }), _jsx(Textarea, { value: reviewNote, onChange: (e) => handleReviewNoteChange(e.target.value), rows: 2, "aria-invalid": reviewNoteError ? true : undefined, placeholder: "e.g. Screenshot is blurry \u2014 amount not readable / UPI ID doesn't match order" }), reviewNoteError && (_jsx(Text, { size: "xs", color: "error", role: "alert", children: reviewNoteError }))] }), _jsx(Button, { type: "button", action: ACTIONS.ADMIN["verify-payment"], onClick: handleVerifyPayment, isLoading: isVerifyingPayment, disabled: isVerifyingPayment, variant: "primary", className: "w-full" }), _jsx(Button, { type: "button", action: ACTIONS.ADMIN["request-payment-reupload"], onClick: handleRequestReupload, isLoading: isRequestingReupload, disabled: isRequestingReupload, variant: "secondary", className: "w-full" }), _jsx(Button, { type: "button", action: ACTIONS.ADMIN["reject-payment-fraud"], onClick: handleRejectFraud, isLoading: isRejectingFraud, disabled: isRejectingFraud, variant: "danger", className: "w-full" })] })), isVerified && (_jsx(Div, { rounded: "lg", padding: "inlineSm", className: "border border-success/20", surface: "success-surface", children: _jsx(Text, { size: "xs", className: "text-success", weight: "medium", children: "Payment verified" }) }))] })) : (_jsx(Text, { size: "xs", color: "faint", children: "No proof uploaded yet." }))] }))] }) }) }));
288
+ }, onValidationChange: () => form.validate(draft), schema: adminOrderUpdateSchema, openIds: nav.openIds, onOpenChange: nav.setOpenIds, submitLabel: "Save changes", cancelLabel: "Cancel", onCancel: onClose, isLoading: saveMutation.isPending }), isManualPayment && (_jsxs(Stack, { gap: "xs", children: [_jsx(Label, { size: "sm", weight: "medium", color: "primary", children: "Payment Proof" }), isRejected && (_jsx(Text, { size: "xs", className: "text-error", weight: "medium", children: "Payment rejected as fraud \u2014 no further action available here." })), isReuploadRequested && !isVerified && (_jsx(Text, { size: "xs", className: "text-warning", weight: "medium", children: "Re-upload requested \u2014 waiting for the buyer to submit a new proof." })), paymentProofUrl ? (_jsxs(Stack, { gap: "xs", children: [_jsx(Div, { border: "default", rounded: "lg", overflow: "hidden", children: _jsx(MediaImage, { src: paymentProofUrl, alt: "Payment screenshot", size: "card" }) }), paymentTransactionId && (_jsxs(Text, { size: "xs", color: "muted", children: ["UTR: ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: paymentTransactionId })] })), needsVerification && (_jsxs(Stack, { gap: "xs", children: [(displayedUpiId || buyerReportedUpiId) && (_jsxs(Stack, { gap: "xs", children: [_jsxs(Text, { size: "xs", color: "muted", children: ["Expected UPI: ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: displayedUpiId || "—" }), " · ", "Buyer reported: ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: buyerReportedUpiId || "—" })] }), paymentUpiMismatch && (_jsx(Div, { rounded: "lg", padding: "inlineSm", className: "border border-error/20", surface: "danger-surface", children: _jsx(Text, { size: "xs", className: "text-error", weight: "semibold", children: "UPI mismatch \u2014 buyer-reported ID doesn't match the one shown for this order." }) }))] })), _jsxs(Text, { size: "xs", color: "muted", children: ["Buyer marked as paid: ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: buyerMarkedPaid ? "Yes" : "No" }), " · ", "Fraud agreement accepted: ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: buyerFraudAgreementAccepted ? "Yes" : "No" })] }), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { size: "sm", weight: "medium", color: "primary", children: "Review note" }), _jsxs(Text, { size: "xs", color: "muted", children: ["Sent with ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: "Request re-upload" }), " or", " ", _jsx(Text, { as: "span", size: "xs", weight: "medium", children: "Reject as fraud" }), " below. Not saved by \u201CSave changes\u201D."] }), _jsx(Textarea, { value: reviewNote, onChange: (e) => handleReviewNoteChange(e.target.value), rows: 2, "aria-invalid": reviewNoteError ? true : undefined, placeholder: "e.g. Screenshot is blurry \u2014 amount not readable / UPI ID doesn't match order" }), reviewNoteError && (_jsx(Text, { size: "xs", color: "error", role: "alert", children: reviewNoteError }))] }), _jsx(Button, { type: "button", action: ACTIONS.ADMIN["verify-payment"], onClick: handleVerifyPayment, isLoading: isVerifyingPayment, disabled: isVerifyingPayment, variant: "primary", className: "w-full" }), _jsx(Button, { type: "button", action: ACTIONS.ADMIN["request-payment-reupload"], onClick: handleRequestReupload, isLoading: isRequestingReupload, disabled: isRequestingReupload, variant: "secondary", className: "w-full" }), _jsx(Button, { type: "button", action: ACTIONS.ADMIN["reject-payment-fraud"], onClick: handleRejectFraud, isLoading: isRejectingFraud, disabled: isRejectingFraud, variant: "danger", className: "w-full" })] })), isVerified && (_jsx(Div, { rounded: "lg", padding: "inlineSm", className: "border border-success/20", surface: "success-surface", children: _jsx(Text, { size: "xs", className: "text-success", weight: "medium", children: "Payment verified" }) }))] })) : (_jsx(Text, { size: "xs", color: "faint", children: "No proof uploaded yet." }))] })), balance && (_jsxs(Stack, { gap: "xs", children: [_jsx(Label, { size: "sm", weight: "medium", color: "primary", children: "Pre-order balance" }), _jsxs(Text, { size: "xs", color: "muted", children: [toCurrency(balance.amount), " \u00B7", " ", balance.status === "paid"
289
+ ? "accepted"
290
+ : balance.status === "submitted"
291
+ ? "proof submitted, awaiting your decision"
292
+ : balance.status === "rejected"
293
+ ? "returned to the buyer"
294
+ : "open — the buyer has not paid yet", balance.transactionId ? ` · Ref ${balance.transactionId}` : ""] }), balance.status === "rejected" && balance.reviewNote && (_jsxs(Text, { size: "xs", color: "muted", children: ["Last note: ", balance.reviewNote] })), balance.status === "submitted" && (_jsxs(Stack, { gap: "xs", children: [balance.proofUrl && (_jsx(Div, { border: "default", rounded: "lg", overflow: "hidden", children: _jsx(MediaImage, { src: balance.proofUrl, alt: "Balance payment screenshot", size: "card" }) })), _jsx(Text, { size: "xs", color: "muted", children: "Check the reference against the bank before accepting. Accepting does NOT post a ledger entry \u2014 the sale was recognised in full when the order was created, so a second entry would double it." }), _jsx(Textarea, { value: balanceNote, onChange: (e) => {
295
+ setBalanceNote(e.target.value);
296
+ if (balanceNoteError)
297
+ setBalanceNoteError(null);
298
+ }, rows: 2, "aria-invalid": balanceNoteError ? true : undefined, placeholder: "Required when rejecting \u2014 e.g. reference does not match any credit we received" }), balanceNoteError && (_jsx(Text, { size: "xs", color: "error", role: "alert", children: balanceNoteError })), _jsx(Button, { type: "button", action: ACTIONS.ADMIN["verify-balance"], onClick: () => void handleBalanceReview(true), isLoading: isAcceptingBalance, disabled: isAcceptingBalance || isRejectingBalance, variant: "primary", className: "w-full" }), _jsx(Button, { type: "button", action: ACTIONS.ADMIN["reject-balance"], onClick: () => void handleBalanceReview(false), isLoading: isRejectingBalance, disabled: isAcceptingBalance || isRejectingBalance, variant: "secondary", className: "w-full" })] }))] }))] }) }) }));
230
299
  }
@@ -267,6 +267,25 @@ export function AdminOrdersView({ children, ...props }) {
267
267
  // which coerces every field by hand, this one needs a tag check
268
268
  // before it can be trusted. See `toSourceContext`.
269
269
  sourceContext: toSourceContext(item.sourceContext),
270
+ /*
271
+ * D55. Coerced field by field like `addons` above, deliberately
272
+ * NOT `as AdminOrderEditorViewProps["balance"]` — a cast at a
273
+ * raw-document boundary is where Root Cause #98 lives, and main
274
+ * had just removed the same cast from `sourceContext` one line up.
275
+ *
276
+ * `proofUrl` is omitted ON PURPOSE: this is the LIST payload, and
277
+ * the screenshot is admin-detail only. A list row never needs it.
278
+ */
279
+ balance: item.balance
280
+ ? {
281
+ amount: Number(item.balance.amount ?? 0),
282
+ status: toStringValue(item.balance.status, "awaiting"),
283
+ transactionId: toStringValue(item.balance.transactionId) ||
284
+ undefined,
285
+ reviewNote: toStringValue(item.balance.reviewNote) ||
286
+ undefined,
287
+ }
288
+ : undefined,
270
289
  currency: toStringValue(item.currency, "") || undefined,
271
290
  };
272
291
  }),
@@ -351,5 +370,5 @@ export function AdminOrdersView({ children, ...props }) {
351
370
  ] })),
352
371
  renderFilterPanel: ({ pendingFilters, setPendingFilters }) => (_jsxs(_Fragment, { children: [_jsx(FilterChipGroup, { label: "Status", tabs: ADMIN_ORDER_STATUS_TABS, value: pendingFilters.status ?? "", onChange: (id) => setPendingFilters((p) => ({ ...p, status: id, paymentReview: "" })) }), _jsx(FilterChipGroup, { label: "Manual payment", tabs: ADMIN_ORDER_PAYMENT_REVIEW_TABS, value: pendingFilters.paymentReview ?? "", onChange: (id) => setPendingFilters((p) => ({ ...p, paymentReview: id, status: "" })) })] })),
353
372
  };
354
- return (_jsxs(_Fragment, { children: [_jsx(DataListingView, { config: config }), _jsx(AdminOrderEditorView, { open: drawerOpen, onClose: () => setDrawerOpen(false), orderId: selectedRow?.id, orderLabel: selectedRow?.primary, currentStatus: selectedRow?.status, items: selectedRow?.items, paymentProofUrl: selectedRow?.paymentProofUrl, paymentTransactionId: selectedRow?.paymentTransactionId, paymentMethod: selectedRow?.paymentMethod, paymentStatus: selectedRow?.paymentStatus, displayedUpiId: selectedRow?.displayedUpiId, buyerReportedUpiId: selectedRow?.buyerReportedUpiId, paymentUpiMismatch: selectedRow?.paymentUpiMismatch, buyerMarkedPaid: selectedRow?.buyerMarkedPaid, buyerFraudAgreementAccepted: selectedRow?.buyerFraudAgreementAccepted, paymentReviewOutcome: selectedRow?.paymentReviewOutcome, addons: selectedRow?.addons, sourceContext: selectedRow?.sourceContext, currency: selectedRow?.currency })] }));
373
+ return (_jsxs(_Fragment, { children: [_jsx(DataListingView, { config: config }), _jsx(AdminOrderEditorView, { open: drawerOpen, onClose: () => setDrawerOpen(false), orderId: selectedRow?.id, orderLabel: selectedRow?.primary, currentStatus: selectedRow?.status, items: selectedRow?.items, paymentProofUrl: selectedRow?.paymentProofUrl, paymentTransactionId: selectedRow?.paymentTransactionId, paymentMethod: selectedRow?.paymentMethod, paymentStatus: selectedRow?.paymentStatus, displayedUpiId: selectedRow?.displayedUpiId, buyerReportedUpiId: selectedRow?.buyerReportedUpiId, paymentUpiMismatch: selectedRow?.paymentUpiMismatch, buyerMarkedPaid: selectedRow?.buyerMarkedPaid, buyerFraudAgreementAccepted: selectedRow?.buyerFraudAgreementAccepted, paymentReviewOutcome: selectedRow?.paymentReviewOutcome, addons: selectedRow?.addons, sourceContext: selectedRow?.sourceContext, balance: selectedRow?.balance, currency: selectedRow?.currency })] }));
355
374
  }
@@ -3,6 +3,7 @@
3
3
  * Covers: notifications, chat rooms, site settings
4
4
  */
5
5
  import type { AboutContentDocument } from "../../about/schemas/firestore";
6
+ import type { RateChange } from "../../../_internal/shared/features/pricing/rate-registry";
6
7
  export declare const ADMIN_CHECKOUT_BYPASS_FLAG_KEY: "adminCheckoutBypass";
7
8
  export declare const NOTIFICATION_TYPE_VALUES: readonly ["order_placed", "order_confirmed", "order_shipped", "order_delivered", "order_cancelled", "bid_placed", "bid_outbid", "bid_won", "bid_lost", "auction_ended", "review_approved", "review_replied", "product_available", "promotion", "system", "welcome", "account_action", "support_ticket_update", "scam_report_update", "offer_received", "offer_responded", "offer_expired", "offer_counter_accepted", "refund_initiated", "prize_won", "prize_reveal_expired", "emi_installment_due_soon", "emi_installment_overdue", "payment_review", "catalogue_images_stale", "message_received"];
8
9
  export type NotificationType = (typeof NOTIFICATION_TYPE_VALUES)[number];
@@ -233,6 +234,18 @@ export type SiteSettingsAdPlacement = {
233
234
  label: string;
234
235
  enabled: boolean;
235
236
  reservedHeight: number;
237
+ /**
238
+ * D56 — the flat monthly rate for a self-serve booking of this slot, in
239
+ * rupees. Absent means NOT SOLD: `bookAdSlot` refuses rather than
240
+ * defaulting to zero, because a free booking that looks successful takes
241
+ * the slot, bills nothing, and says nothing about the missing price.
242
+ *
243
+ * 🛑 Editing this does NOT reprice a sold slot. `AdBookingDocument.priceRs`
244
+ * is snapshotted at booking, for the same reason `sourceContext` is
245
+ * written once: the rate is a price somebody agreed to, and re-deriving it
246
+ * later silently rewrites a completed sale.
247
+ */
248
+ priceRsPerPeriod?: number;
236
249
  };
237
250
  /** Embedded in siteSettings.adSettings — a type alias, not a collection root. */
238
251
  export type SiteSettingsAdInventoryItem = {
@@ -838,6 +851,25 @@ export interface SiteSettingsDocument extends BaseDocument {
838
851
  * opt-in and require the corresponding credentials to be set.
839
852
  */
840
853
  notificationChannels?: NotificationChannelConfig;
854
+ /**
855
+ * The rate-change log behind `/admin/pricing`. Append-only, newest FIRST,
856
+ * capped at `RATE_CHANGE_LOG_MAX`.
857
+ *
858
+ * 🛑 **An array on the singleton rather than a collection, deliberately.**
859
+ * The page's only query is "the recent history of these 16 rates", which is
860
+ * one document read — and the rates themselves already live here, so a
861
+ * separate collection would mean two reads to render one table and two
862
+ * writes to record one change. It is capped for the same reason
863
+ * `statusHistory` is: an uncapped array on a hot singleton eventually makes
864
+ * every settings read expensive.
865
+ *
866
+ * 🛑 **Not an audit trail, and not a substitute for one.** A `pricing_update`
867
+ * row also goes to `adminAuditLog`, which is queryable and answers "what
868
+ * have admins done lately". This answers the narrower question the pricing
869
+ * page asks — "when did THIS rate last move, and to what" — and it is the
870
+ * one a truncation can safely lose.
871
+ */
872
+ rateChanges?: RateChange[];
841
873
  }
842
874
  export declare const SITE_SETTINGS_COLLECTION: "siteSettings";
843
875
  export declare const SITE_SETTINGS_INDEXED_FIELDS: readonly [];
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The write contract for a self-serve ad booking.
3
+ *
4
+ * ## 🛑 The shape cannot express moderation, payment or price
5
+ *
6
+ * This is Root Cause #76's rule applied to a surface where the submitter is
7
+ * a member of the public rather than an admin, which makes it sharper: an
8
+ * advertiser who can send `moderation: { state: "approved" }` has not
9
+ * bypassed a check, they have *become* the check. A schema that merely
10
+ * VALIDATED the field would still let `"approved"` through — the field has
11
+ * to be unrepresentable, so `.strict()` turns it into a 400 instead of
12
+ * something the server has to remember to overwrite.
13
+ *
14
+ * Same for `priceRs` (the server reads the rate card; a client-supplied
15
+ * price is a ₹1 banner) and `paymentState` (a booking that declares itself
16
+ * paid is a free slot).
17
+ *
18
+ * `placementId` and `period` ARE accepted: they are what is being bought,
19
+ * and they are also the booking's identity, so the conflict they can cause
20
+ * is a document collision rather than a validation question.
21
+ */
22
+ import { z } from "zod";
23
+ /**
24
+ * 🛑 Creative fields are bounded, and `ctaHref` is http(s)-only.
25
+ *
26
+ * An unbounded `body` is a storage cost per booking and a layout break on
27
+ * every page carrying the slot. A `javascript:` or `data:` CTA is script
28
+ * execution on every page the creative renders on, submitted by a member of
29
+ * the public — this is the one field on this schema where a miss is a
30
+ * security defect rather than a tidiness one.
31
+ */
32
+ export declare const httpUrl: z.ZodEffects<z.ZodString, string, string>;
33
+ export declare const adBookingCreativeSchema: z.ZodObject<{
34
+ title: z.ZodString;
35
+ body: z.ZodOptional<z.ZodString>;
36
+ imageUrl: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
37
+ ctaLabel: z.ZodOptional<z.ZodString>;
38
+ ctaHref: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
39
+ }, "strict", z.ZodTypeAny, {
40
+ title: string;
41
+ body?: string | undefined;
42
+ imageUrl?: string | undefined;
43
+ ctaLabel?: string | undefined;
44
+ ctaHref?: string | undefined;
45
+ }, {
46
+ title: string;
47
+ body?: string | undefined;
48
+ imageUrl?: string | undefined;
49
+ ctaLabel?: string | undefined;
50
+ ctaHref?: string | undefined;
51
+ }>;
52
+ export declare const adBookingWriteSchema: z.ZodObject<{
53
+ placementId: z.ZodString;
54
+ period: z.ZodEffects<z.ZodString, string, string>;
55
+ creative: z.ZodObject<{
56
+ title: z.ZodString;
57
+ body: z.ZodOptional<z.ZodString>;
58
+ imageUrl: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
59
+ ctaLabel: z.ZodOptional<z.ZodString>;
60
+ ctaHref: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
61
+ }, "strict", z.ZodTypeAny, {
62
+ title: string;
63
+ body?: string | undefined;
64
+ imageUrl?: string | undefined;
65
+ ctaLabel?: string | undefined;
66
+ ctaHref?: string | undefined;
67
+ }, {
68
+ title: string;
69
+ body?: string | undefined;
70
+ imageUrl?: string | undefined;
71
+ ctaLabel?: string | undefined;
72
+ ctaHref?: string | undefined;
73
+ }>;
74
+ }, "strict", z.ZodTypeAny, {
75
+ placementId: string;
76
+ period: string;
77
+ creative: {
78
+ title: string;
79
+ body?: string | undefined;
80
+ imageUrl?: string | undefined;
81
+ ctaLabel?: string | undefined;
82
+ ctaHref?: string | undefined;
83
+ };
84
+ }, {
85
+ placementId: string;
86
+ period: string;
87
+ creative: {
88
+ title: string;
89
+ body?: string | undefined;
90
+ imageUrl?: string | undefined;
91
+ ctaLabel?: string | undefined;
92
+ ctaHref?: string | undefined;
93
+ };
94
+ }>;
95
+ export type AdBookingWriteInput = z.infer<typeof adBookingWriteSchema>;
96
+ /**
97
+ * A moderator's verdict.
98
+ *
99
+ * 🛑 A rejection REQUIRES a note, for the same reason an admin offer-cancel
100
+ * does: without one the advertiser's next submission is the same creative
101
+ * again, and the moderator reviews it twice. An approval takes no note
102
+ * because there is nothing to say.
103
+ */
104
+ export declare const adModerationSchema: z.ZodEffects<z.ZodObject<{
105
+ approve: z.ZodBoolean;
106
+ note: z.ZodOptional<z.ZodString>;
107
+ }, "strict", z.ZodTypeAny, {
108
+ approve: boolean;
109
+ note?: string | undefined;
110
+ }, {
111
+ approve: boolean;
112
+ note?: string | undefined;
113
+ }>, {
114
+ approve: boolean;
115
+ note?: string | undefined;
116
+ }, {
117
+ approve: boolean;
118
+ note?: string | undefined;
119
+ }>;
120
+ export type AdModerationInput = z.infer<typeof adModerationSchema>;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The write contract for a self-serve ad booking.
3
+ *
4
+ * ## 🛑 The shape cannot express moderation, payment or price
5
+ *
6
+ * This is Root Cause #76's rule applied to a surface where the submitter is
7
+ * a member of the public rather than an admin, which makes it sharper: an
8
+ * advertiser who can send `moderation: { state: "approved" }` has not
9
+ * bypassed a check, they have *become* the check. A schema that merely
10
+ * VALIDATED the field would still let `"approved"` through — the field has
11
+ * to be unrepresentable, so `.strict()` turns it into a 400 instead of
12
+ * something the server has to remember to overwrite.
13
+ *
14
+ * Same for `priceRs` (the server reads the rate card; a client-supplied
15
+ * price is a ₹1 banner) and `paymentState` (a booking that declares itself
16
+ * paid is a free slot).
17
+ *
18
+ * `placementId` and `period` ARE accepted: they are what is being bought,
19
+ * and they are also the booking's identity, so the conflict they can cause
20
+ * is a document collision rather than a validation question.
21
+ */
22
+ import { z } from "zod";
23
+ import { isAdPeriod } from "./firestore.js";
24
+ /**
25
+ * 🛑 Creative fields are bounded, and `ctaHref` is http(s)-only.
26
+ *
27
+ * An unbounded `body` is a storage cost per booking and a layout break on
28
+ * every page carrying the slot. A `javascript:` or `data:` CTA is script
29
+ * execution on every page the creative renders on, submitted by a member of
30
+ * the public — this is the one field on this schema where a miss is a
31
+ * security defect rather than a tidiness one.
32
+ */
33
+ /*
34
+ * Exported so `creative-form.ts` reuses the REFINEMENT rather than restating
35
+ * it — the form and the wire must agree on what a link is, and a second copy
36
+ * of a security check is a second thing to forget.
37
+ */
38
+ export const httpUrl = z
39
+ .string()
40
+ .url()
41
+ .refine((u) => /^https?:\/\//i.test(u), "Links must start with http:// or https://");
42
+ export const adBookingCreativeSchema = z
43
+ .object({
44
+ title: z.string().min(2).max(80),
45
+ body: z.string().max(200).optional(),
46
+ imageUrl: httpUrl.optional(),
47
+ ctaLabel: z.string().max(40).optional(),
48
+ ctaHref: httpUrl.optional(),
49
+ })
50
+ .strict();
51
+ export const adBookingWriteSchema = z
52
+ .object({
53
+ placementId: z.string().min(1).max(80),
54
+ period: z.string().refine(isAdPeriod, "Pick a month, as YYYY-MM."),
55
+ creative: adBookingCreativeSchema,
56
+ })
57
+ .strict();
58
+ /**
59
+ * A moderator's verdict.
60
+ *
61
+ * 🛑 A rejection REQUIRES a note, for the same reason an admin offer-cancel
62
+ * does: without one the advertiser's next submission is the same creative
63
+ * again, and the moderator reviews it twice. An approval takes no note
64
+ * because there is nothing to say.
65
+ */
66
+ export const adModerationSchema = z
67
+ .object({
68
+ approve: z.boolean(),
69
+ note: z.string().max(300).optional(),
70
+ })
71
+ .strict()
72
+ .refine((v) => v.approve || (v.note?.trim().length ?? 0) >= 10, {
73
+ message: "Say why it was rejected — the advertiser sees this and will resubmit without it.",
74
+ path: ["note"],
75
+ });
@@ -0,0 +1,18 @@
1
+ import { z } from "zod";
2
+ export declare const adCreativeFormSchema: z.ZodObject<{
3
+ title: z.ZodString;
4
+ body: z.ZodOptional<z.ZodString>;
5
+ ctaLabel: z.ZodOptional<z.ZodString>;
6
+ ctaHref: z.ZodOptional<z.ZodUnion<[z.ZodLiteral<"">, z.ZodEffects<z.ZodString, string, string>]>>;
7
+ }, "strip", z.ZodTypeAny, {
8
+ title: string;
9
+ body?: string | undefined;
10
+ ctaLabel?: string | undefined;
11
+ ctaHref?: string | undefined;
12
+ }, {
13
+ title: string;
14
+ body?: string | undefined;
15
+ ctaLabel?: string | undefined;
16
+ ctaHref?: string | undefined;
17
+ }>;
18
+ export type AdCreativeFormValues = z.infer<typeof adCreativeFormSchema>;
@@ -0,0 +1,51 @@
1
+ /*
2
+ * WHY: `/advertise` rendered four hand-wired `<FieldInput>`s inside a bare
3
+ * `<Form>`, so the advertiser's creative had no client-side bounds at
4
+ * all — the only limits were the ones `adBookingCreateSchema` enforces
5
+ * server-side, which the visitor meets as a 400 after submitting. A
6
+ * headline is the single most visible string this platform renders to
7
+ * strangers; it should not be the one field with no feedback until POST.
8
+ *
9
+ * WHAT: The FLAT form shape for the booking creative, annotated so
10
+ * `buildSectionsFromSchema` produces the fields. The wire shape nests
11
+ * these under `creative` (see `booking-write.ts`); the page lifts them
12
+ * at submit.
13
+ *
14
+ * 🛑 It deliberately does NOT carry `placementId` or `period`. Those are
15
+ * chosen by clicking a month in the rate card, not typed — and more to the
16
+ * point, a slot whose availability the server has already decided must not
17
+ * become a text input the advertiser can edit. The booking id IS the lock
18
+ * (`adBookingId`), so a typed period is a way to aim at somebody else's slot.
19
+ *
20
+ * 🛑 Bounds mirror `adCreativeSchema` exactly. Two numbers that merely happen
21
+ * to agree today would drift into a form that accepts what the route refuses,
22
+ * which is the worse of the two failures: it looks like a server fault.
23
+ *
24
+ * EXPORTS: adCreativeFormSchema, type AdCreativeFormValues
25
+ *
26
+ * @tag domain:ads
27
+ * @tag layer:schema
28
+ * @tag pattern:none
29
+ * @tag access:isomorphic
30
+ * @tag consumers:/advertise
31
+ * @tag sideEffects:none
32
+ */
33
+ import { z } from "zod";
34
+ import { annotate } from "../../shell/field-ui-meta.js";
35
+ import { httpUrl } from "./booking-write.js";
36
+ export const adCreativeFormSchema = z.object({
37
+ title: annotate(z.string().trim().min(2, "A headline is required.").max(80, "Keep the headline under 80 characters."), { section: "creative", sectionLabel: "Your creative", sectionRequired: true, quick: true, order: 1, row: "full" }),
38
+ body: annotate(z.string().trim().max(200, "Keep the body under 200 characters.").optional(), { section: "creative", order: 2, row: "full", kind: "textarea" }),
39
+ ctaLabel: annotate(z.string().trim().max(40, "Keep the button text under 40 characters.").optional(), { section: "creative", order: 3, row: "pair" }),
40
+ /*
41
+ * 🛑 `httpUrl`, the same refinement the wire schema uses — a `javascript:`
42
+ * or `data:` CTA on a page we render to strangers is stored XSS with our
43
+ * own domain's credibility behind it. An empty string is allowed through so
44
+ * clearing the field is not an error; the page drops it before POST.
45
+ */
46
+ ctaHref: annotate(z.union([z.literal(""), httpUrl]).optional(), {
47
+ section: "creative",
48
+ order: 4,
49
+ row: "pair",
50
+ }),
51
+ });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * D56 — a SELF-SERVE ad booking: one advertiser, one placement, one period.
3
+ *
4
+ * ## 🛑 Flat fee per slot per period, and explicitly NOT CPM
5
+ *
6
+ * Impression counting is a write per page view, which is the exact cost
7
+ * pattern that suspended this project (Root Cause #104: 3.1M ISR writes
8
+ * against a 200K allowance, 52.52 GB of Fast Origin Transfer against 10 GB,
9
+ * for eleven monthly active users). A flat fee is one write per BOOKING.
10
+ * Nothing here counts anything per render, and nothing should be added that
11
+ * does.
12
+ *
13
+ * ## Why a PERIOD rather than an arbitrary date range
14
+ *
15
+ * The plan's requirement is slot-availability locking — two advertisers must
16
+ * not book the same slot for overlapping dates. Firestore has no range
17
+ * constraint, so an arbitrary `startAt`/`endAt` pair can only be checked by
18
+ * QUERYING for overlaps and then writing, and two concurrent bookings both
19
+ * read "no overlap" and both write. That race is invisible in testing and
20
+ * sells the same slot twice.
21
+ *
22
+ * Discretising to a period makes the conflict a DOCUMENT COLLISION instead:
23
+ * the booking id is derived from `(placementId, period)`, so the second
24
+ * writer fails on an existing document inside its own transaction and
25
+ * Firestore does the locking. There is no query to race against.
26
+ *
27
+ * It also matches what is being sold. "The homepage banner for November" is
28
+ * the product; "the homepage banner from the 9th to the 23rd" is a pricing
29
+ * model nobody asked for and an availability calendar nobody can render.
30
+ */
31
+ import type { BaseDocument } from "../../../_internal/shared/types/base-document";
32
+ export declare const AD_BOOKING_COLLECTION = "adBookings";
33
+ /**
34
+ * A calendar month, `YYYY-MM`.
35
+ *
36
+ * 🛑 A STRING, not a Date. It is an identity — half of the booking's primary
37
+ * key — and a Date would make "the same month" a comparison rather than an
38
+ * equality, which is precisely what the document-collision lock depends on
39
+ * not being.
40
+ */
41
+ export type AdPeriod = string;
42
+ export declare function isAdPeriod(value: string): value is AdPeriod;
43
+ /** The period a date falls in, in UTC. */
44
+ export declare function adPeriodOf(date: Date): AdPeriod;
45
+ /** First instant of a period, UTC. */
46
+ export declare function adPeriodStart(period: AdPeriod): Date;
47
+ /** First instant of the NEXT period — the exclusive end. */
48
+ export declare function adPeriodEnd(period: AdPeriod): Date;
49
+ /**
50
+ * 🛑 The booking id IS the lock. Derived, never generated.
51
+ *
52
+ * Two advertisers booking `banner-home` for `2026-11` compute the same id and
53
+ * the second `tx.create` throws `ALREADY_EXISTS` — which is the whole
54
+ * availability guarantee, enforced by Firestore rather than by a check
55
+ * somebody could forget to run.
56
+ */
57
+ export declare function adBookingId(placementId: string, period: AdPeriod): string;
58
+ /**
59
+ * Payment state. Deliberately coarse: this records whether the slot is PAID
60
+ * FOR, not how. The invoice and the ledger posting are Part 4's job and carry
61
+ * the detail; duplicating any of it here would be two records of one fact.
62
+ */
63
+ export declare const AD_BOOKING_PAYMENT_STATES: readonly ["unpaid", "paid", "refunded"];
64
+ export type AdBookingPaymentState = (typeof AD_BOOKING_PAYMENT_STATES)[number];
65
+ /**
66
+ * Creative moderation. **Nothing renders on the site until `approved`.**
67
+ *
68
+ * 🛑 `pending` is the initial value and there is no way to write `approved`
69
+ * through the advertiser's own schema — see `adBookingWriteSchema`. A
70
+ * self-serve flow where the submitter can set their own moderation state is
71
+ * not moderated; it is a form with an extra field (Root Cause #76 — a field
72
+ * the form can send is a field the form can destroy, here inverted into a
73
+ * field the form can grant itself).
74
+ */
75
+ export declare const AD_MODERATION_STATES: readonly ["pending", "approved", "rejected"];
76
+ export type AdModerationState = (typeof AD_MODERATION_STATES)[number];
77
+ export interface AdBookingCreative {
78
+ title: string;
79
+ body?: string;
80
+ imageUrl?: string;
81
+ ctaLabel?: string;
82
+ ctaHref?: string;
83
+ }
84
+ export interface AdBookingDocument extends BaseDocument {
85
+ /** Matches a `SiteSettingsAdPlacement.id`. */
86
+ placementId: string;
87
+ period: AdPeriod;
88
+ /** Auth uid of the advertiser. */
89
+ advertiserUid: string;
90
+ /** Denormalised for the admin queue — a moderator should not fan out to `users`. */
91
+ advertiserName?: string;
92
+ /**
93
+ * Rupees, SNAPSHOTTED at booking.
94
+ *
95
+ * 🛑 Never recomputed from the live rate card. The rate is a price the
96
+ * advertiser agreed to; re-deriving it at render or invoice time would
97
+ * silently reprice a sold slot the first time an admin edits the card —
98
+ * the same rule `sourceContext` follows for order provenance.
99
+ */
100
+ priceRs: number;
101
+ paymentState: AdBookingPaymentState;
102
+ /** Set by the Part 4 invoicing path once the booking is billed. */
103
+ invoiceId?: string;
104
+ creative: AdBookingCreative;
105
+ moderation: {
106
+ state: AdModerationState;
107
+ /** Required on a rejection — see `moderateAdCreative`. */
108
+ note?: string;
109
+ reviewedAt?: Date;
110
+ /**
111
+ * Staff uid. 🛑 Never returned to the advertiser: who rejected you is
112
+ * not information a self-serve submitter needs, and naming an individual
113
+ * moderator invites them being contacted directly.
114
+ */
115
+ reviewedBy?: string;
116
+ };
117
+ /** Set when an admin or the advertiser cancels. A cancelled booking frees nothing — see below. */
118
+ cancelledAt?: Date;
119
+ cancelledReason?: string;
120
+ }
121
+ /**
122
+ * Does this booking put a creative on the page right now?
123
+ *
124
+ * 🛑 All four conditions, and the order is not an accident — moderation is
125
+ * checked before the calendar so an approved-but-future booking and an
126
+ * unreviewed current one are distinguishable in a debugger.
127
+ */
128
+ export declare function isAdBookingLive(booking: Pick<AdBookingDocument, "moderation" | "paymentState" | "period" | "cancelledAt">, now?: Date): boolean;
129
+ /**
130
+ * 🛑 A cancelled booking KEEPS its document, and therefore keeps the slot
131
+ * locked for its own period.
132
+ *
133
+ * Deleting it would free the slot — which sounds like the point and is the
134
+ * wrong behaviour twice over: the advertiser has paid for a period that has
135
+ * possibly already started, and re-selling a month mid-flight means two
136
+ * creatives were live in one slot at different times with one invoice
137
+ * between them. Freeing a period is an ADMIN act (`releaseAdSlot`), done
138
+ * knowingly, not a side effect of a cancel button.
139
+ */
140
+ export declare const AD_BOOKING_CANCELLED_KEEPS_SLOT = true;