@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.
- package/dist/.build-stamp.json +3 -3
- package/dist/_internal/client/features/layout/DashboardLayoutClient.js +8 -29
- package/dist/_internal/client/features/layout/filterNavItems.d.ts +28 -0
- package/dist/_internal/client/features/layout/filterNavItems.js +35 -0
- package/dist/_internal/server/features/ads/booking.d.ts +118 -0
- package/dist/_internal/server/features/ads/booking.js +326 -0
- package/dist/_internal/server/features/checkout/actions.d.ts +51 -11
- package/dist/_internal/server/features/checkout/actions.js +335 -80
- package/dist/_internal/server/features/checkout/expand-order-items.d.ts +1 -2
- package/dist/_internal/server/features/checkout/expand-order-items.js +32 -0
- package/dist/_internal/server/features/checkout/freeze-parts.d.ts +50 -0
- package/dist/_internal/server/features/checkout/freeze-parts.js +94 -0
- package/dist/_internal/server/features/checkout/value-otp-gate.d.ts +55 -0
- package/dist/_internal/server/features/checkout/value-otp-gate.js +60 -0
- package/dist/_internal/server/features/dashboard/attention.d.ts +128 -0
- package/dist/_internal/server/features/dashboard/attention.js +183 -0
- package/dist/_internal/server/features/dashboard/data.d.ts +47 -0
- package/dist/_internal/server/features/dashboard/data.js +82 -0
- package/dist/_internal/server/features/inventory/reservations.d.ts +99 -0
- package/dist/_internal/server/features/inventory/reservations.js +273 -0
- package/dist/_internal/server/features/offers/destinations.d.ts +40 -0
- package/dist/_internal/server/features/offers/destinations.js +65 -0
- package/dist/_internal/server/features/orders/adapters.js +21 -0
- package/dist/_internal/server/features/orders/amend-delivery.d.ts +56 -0
- package/dist/_internal/server/features/orders/amend-delivery.js +124 -0
- package/dist/_internal/server/features/orders/balance.d.ts +89 -0
- package/dist/_internal/server/features/orders/balance.js +256 -0
- package/dist/_internal/server/features/pricing/actions.d.ts +58 -0
- package/dist/_internal/server/features/pricing/actions.js +144 -0
- package/dist/_internal/server/features/shipping/book-split.d.ts +86 -0
- package/dist/_internal/server/features/shipping/book-split.js +130 -0
- package/dist/_internal/server/features/shipping/book.d.ts +61 -0
- package/dist/_internal/server/features/shipping/book.js +176 -0
- package/dist/_internal/server/features/shipping/courier-block.js +26 -1
- package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
- package/dist/_internal/server/features/site-settings/adapters.js +14 -0
- package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
- package/dist/_internal/server/features/stores/adapters.js +14 -0
- package/dist/_internal/server/functions/scheduled.d.ts +2 -1
- package/dist/_internal/server/functions/scheduled.js +57 -1
- package/dist/_internal/server/jobs/core/autoPayoutEligibility.js +2 -0
- package/dist/_internal/server/jobs/core/jobRunners.js +45 -0
- package/dist/_internal/server/jobs/core/onOrderStatusChange.js +4 -1
- package/dist/_internal/server/jobs/core/reservationExpiry.d.ts +17 -0
- package/dist/_internal/server/jobs/core/reservationExpiry.js +40 -0
- package/dist/_internal/server/jobs/core/weeklyPayoutEligibility.js +15 -0
- package/dist/_internal/server/jobs/handlers/index.d.ts +1 -0
- package/dist/_internal/server/jobs/handlers/index.js +1 -0
- package/dist/_internal/server/jobs/handlers/reservationExpiry.d.ts +2 -0
- package/dist/_internal/server/jobs/handlers/reservationExpiry.js +2 -0
- package/dist/_internal/shared/actions/action-registry.d.ts +1 -1
- package/dist/_internal/shared/actions/action-registry.js +268 -0
- package/dist/_internal/shared/checkout/order-math.d.ts +31 -2
- package/dist/_internal/shared/checkout/order-math.js +22 -36
- package/dist/_internal/shared/checkout/rules/index.d.ts +1 -1
- package/dist/_internal/shared/checkout/rules/index.js +1 -1
- package/dist/_internal/shared/checkout/rules/payment-gate.d.ts +18 -0
- package/dist/_internal/shared/checkout/rules/payment-gate.js +25 -0
- package/dist/_internal/shared/checkout/split.d.ts +0 -12
- package/dist/_internal/shared/checkout/split.js +22 -2
- package/dist/_internal/shared/content/disclaimers.js +7 -7
- package/dist/_internal/shared/content/published-values.d.ts +6 -1
- package/dist/_internal/shared/content/published-values.js +8 -0
- package/dist/_internal/shared/features/admin/integrations.d.ts +32 -2
- package/dist/_internal/shared/features/admin/integrations.js +19 -9
- package/dist/_internal/shared/features/dashboard/hub-sections.d.ts +72 -0
- package/dist/_internal/shared/features/dashboard/hub-sections.js +75 -0
- package/dist/_internal/shared/features/emi/choice.d.ts +98 -0
- package/dist/_internal/shared/features/emi/choice.js +69 -0
- package/dist/_internal/shared/features/emi/group.d.ts +84 -0
- package/dist/_internal/shared/features/emi/group.js +126 -0
- package/dist/_internal/shared/features/ledger/posting.d.ts +107 -0
- package/dist/_internal/shared/features/ledger/posting.js +165 -0
- package/dist/_internal/shared/features/ledger/reports.d.ts +76 -0
- package/dist/_internal/shared/features/ledger/reports.js +127 -0
- package/dist/_internal/shared/features/ledger/rules.d.ts +170 -0
- package/dist/_internal/shared/features/ledger/rules.js +221 -0
- package/dist/_internal/shared/features/offers/coordination.d.ts +122 -0
- package/dist/_internal/shared/features/offers/coordination.js +80 -0
- package/dist/_internal/shared/features/orders/delivery-snapshot.d.ts +114 -0
- package/dist/_internal/shared/features/orders/delivery-snapshot.js +116 -0
- package/dist/_internal/shared/features/payouts/pay-period.d.ts +91 -0
- package/dist/_internal/shared/features/payouts/pay-period.js +104 -0
- package/dist/_internal/shared/features/points/accepts.d.ts +1 -8
- package/dist/_internal/shared/features/points/accepts.js +12 -6
- package/dist/_internal/shared/features/pricing/rate-registry.d.ts +150 -0
- package/dist/_internal/shared/features/pricing/rate-registry.js +246 -0
- package/dist/_internal/shared/features/shipping/parcel.d.ts +123 -0
- package/dist/_internal/shared/features/shipping/parcel.js +139 -0
- package/dist/_internal/shared/features/site-settings/fees.d.ts +16 -8
- package/dist/_internal/shared/features/site-settings/fees.js +12 -8
- package/dist/_internal/shared/fees/explain.d.ts +81 -0
- package/dist/_internal/shared/fees/explain.js +101 -0
- package/dist/_internal/shared/history/index.d.ts +1 -1
- package/dist/_internal/shared/history/index.js +1 -1
- package/dist/_internal/shared/history/types.d.ts +7 -1
- package/dist/_internal/shared/history/types.js +25 -0
- package/dist/client.d.ts +11 -1
- package/dist/client.js +13 -0
- package/dist/constants/api-endpoints.d.ts +72 -0
- package/dist/constants/api-endpoints.js +41 -0
- package/dist/constants/field-names.d.ts +41 -0
- package/dist/constants/field-names.js +47 -0
- package/dist/features/account/components/UserOrdersView.js +28 -2
- package/dist/features/admin/components/AdminIntegrationsView.d.ts +55 -0
- package/dist/features/admin/components/AdminIntegrationsView.js +74 -0
- package/dist/features/admin/components/AdminOrderEditorView.d.ts +16 -1
- package/dist/features/admin/components/AdminOrderEditorView.js +72 -3
- package/dist/features/admin/components/AdminOrdersView.js +20 -1
- package/dist/features/admin/schemas/firestore.d.ts +32 -0
- package/dist/features/ads/schemas/booking-write.d.ts +120 -0
- package/dist/features/ads/schemas/booking-write.js +75 -0
- package/dist/features/ads/schemas/creative-form.d.ts +18 -0
- package/dist/features/ads/schemas/creative-form.js +51 -0
- package/dist/features/ads/schemas/firestore.d.ts +140 -0
- package/dist/features/ads/schemas/firestore.js +109 -0
- package/dist/features/auctions/repository/bid.repository.js +3 -1
- package/dist/features/audit-log/schemas/firestore.d.ts +110 -0
- package/dist/features/audit-log/schemas/firestore.js +74 -0
- package/dist/features/auth/actions/profile-actions.d.ts +69 -1
- package/dist/features/auth/actions/profile-actions.js +62 -1
- package/dist/features/auth/schemas/firestore.d.ts +16 -2
- package/dist/features/auth/schemas/firestore.js +2 -1
- package/dist/features/blog/actions/blog-actions.d.ts +6 -0
- package/dist/features/blog/actions/blog-actions.js +17 -2
- package/dist/features/blog/api/route.js +4 -1
- package/dist/features/blog/columns/index.js +5 -0
- package/dist/features/blog/components/BlogIndexPageView.js +2 -1
- package/dist/features/blog/repository/blog.repository.d.ts +4 -0
- package/dist/features/blog/repository/blog.repository.js +5 -0
- package/dist/features/blog/schemas/blog-form.d.ts +3 -0
- package/dist/features/blog/schemas/blog-form.js +1 -0
- package/dist/features/blog/schemas/firestore.d.ts +38 -1
- package/dist/features/blog/schemas/firestore.js +23 -0
- package/dist/features/blog/schemas/index.d.ts +6 -0
- package/dist/features/blog/schemas/index.js +2 -0
- package/dist/features/blog/types/index.d.ts +2 -0
- package/dist/features/cart/components/CartPriceBreakdown.d.ts +12 -1
- package/dist/features/cart/components/CartPriceBreakdown.js +24 -5
- package/dist/features/cart/repository/cart.repository.d.ts +14 -0
- package/dist/features/cart/repository/cart.repository.js +20 -0
- package/dist/features/cart/schemas/firestore.d.ts +24 -0
- package/dist/features/dashboard/components/DashboardHubView.d.ts +83 -0
- package/dist/features/dashboard/components/DashboardHubView.js +56 -0
- package/dist/features/dashboard/components/DashboardSectionsClient.d.ts +23 -0
- package/dist/features/dashboard/components/DashboardSectionsClient.js +66 -0
- package/dist/features/dispute-reasons/components/AdminDisputeReasonsView.d.ts +55 -0
- package/dist/features/dispute-reasons/components/AdminDisputeReasonsView.js +175 -0
- package/dist/features/dispute-reasons/repository/dispute-reasons.repository.d.ts +24 -0
- package/dist/features/dispute-reasons/repository/dispute-reasons.repository.js +28 -0
- package/dist/features/dispute-reasons/schemas/firestore.d.ts +3 -5
- package/dist/features/events/components/EventSaleBanner.js +1 -1
- package/dist/features/faq/components/FAQPageContent.js +2 -2
- package/dist/features/gateway-events/schemas/index.d.ts +2 -2
- package/dist/features/homepage/schemas/index.d.ts +8 -8
- package/dist/features/inventory/schemas/stock-reservation.d.ts +114 -0
- package/dist/features/inventory/schemas/stock-reservation.js +103 -0
- package/dist/features/layout/AppLayoutShell.d.ts +9 -1
- package/dist/features/layout/AppLayoutShell.js +12 -4
- package/dist/features/layout/BottomActions.js +2 -1
- package/dist/features/layout/BottomNavbar.d.ts +10 -2
- package/dist/features/layout/SideActionDock.js +52 -2
- package/dist/features/layout/TitleBarLayout.js +13 -4
- package/dist/features/layout/TitleBarLayout.style.css +59 -0
- package/dist/features/layout/useCtaReveal.d.ts +16 -1
- package/dist/features/layout/useCtaReveal.js +17 -26
- package/dist/features/ledger/repository/journal.repository.d.ts +110 -0
- package/dist/features/ledger/repository/journal.repository.js +312 -0
- package/dist/features/ledger/schemas/expenses.d.ts +87 -0
- package/dist/features/ledger/schemas/expenses.js +27 -0
- package/dist/features/ledger/schemas/firestore.d.ts +183 -0
- package/dist/features/ledger/schemas/firestore.js +69 -0
- package/dist/features/orders/components/BalancePaymentPanel.d.ts +12 -0
- package/dist/features/orders/components/BalancePaymentPanel.js +94 -0
- package/dist/features/orders/components/RaiseDisputeForm.js +19 -11
- package/dist/features/orders/repository/orders.repository.d.ts +28 -1
- package/dist/features/orders/repository/orders.repository.js +60 -6
- package/dist/features/orders/schemas/balance.d.ts +40 -0
- package/dist/features/orders/schemas/balance.js +47 -0
- package/dist/features/orders/schemas/firestore.d.ts +202 -0
- package/dist/features/orders/schemas/firestore.js +43 -0
- package/dist/features/orders/schemas/index.d.ts +2 -2
- package/dist/features/orders/types/index.d.ts +21 -0
- package/dist/features/payments/repository/payout.repository.js +3 -2
- package/dist/features/payments/schemas/firestore.d.ts +92 -1
- package/dist/features/payments/schemas/firestore.js +23 -0
- package/dist/features/pricing/components/AdminPricingView.d.ts +13 -0
- package/dist/features/pricing/components/AdminPricingView.js +157 -0
- package/dist/features/products/schemas/firestore.d.ts +26 -1
- package/dist/features/products/schemas/firestore.js +2 -0
- package/dist/features/products/schemas/index.d.ts +2 -2
- package/dist/features/reviews/repository/reviews.repository.d.ts +14 -0
- package/dist/features/reviews/repository/reviews.repository.js +16 -0
- package/dist/features/scams/repository/scammer.repository.js +2 -1
- package/dist/features/scams/schemas/firestore.d.ts +2 -1
- package/dist/features/scams/schemas/firestore.js +2 -1
- package/dist/features/seller/actions/offer-actions.js +56 -0
- package/dist/features/seller/actions/seller-actions.d.ts +1 -1
- package/dist/features/seller/actions/seller-actions.js +4 -0
- package/dist/features/seller/components/DispatchForm.d.ts +11 -0
- package/dist/features/seller/components/DispatchForm.js +190 -0
- package/dist/features/seller/components/SellerOrdersView.js +94 -2
- package/dist/features/seller/components/index.d.ts +1 -0
- package/dist/features/seller/components/index.js +1 -0
- package/dist/features/seller/repository/offer.repository.js +4 -3
- package/dist/features/seller/schemas/firestore.d.ts +23 -2
- package/dist/features/seller/schemas/firestore.js +3 -2
- package/dist/features/seller/schemas/index.d.ts +4 -4
- package/dist/features/seller/schemas/order-forms.d.ts +61 -4
- package/dist/features/seller/schemas/order-forms.js +41 -8
- package/dist/features/shipments/schemas/firestore.d.ts +25 -0
- package/dist/features/shipping-boxes/repository/shipping-boxes.repository.d.ts +39 -0
- package/dist/features/shipping-boxes/repository/shipping-boxes.repository.js +55 -0
- package/dist/features/shipping-boxes/schemas/firestore.d.ts +2 -4
- package/dist/features/stores/repository/store.repository.d.ts +18 -0
- package/dist/features/stores/repository/store.repository.js +27 -1
- package/dist/features/stores/schemas/firestore.d.ts +41 -0
- package/dist/features/stores/schemas/firestore.js +20 -0
- package/dist/features/stores/schemas/index.d.ts +6 -6
- package/dist/features/support/repository/support.repository.js +72 -6
- package/dist/features/support/schemas/firestore.d.ts +17 -0
- package/dist/features/support/schemas/index.d.ts +2 -2
- package/dist/index.d.ts +5 -0
- package/dist/index.js +19 -0
- package/dist/next/routing/route-map.d.ts +92 -0
- package/dist/next/routing/route-map.js +53 -0
- package/dist/react/contexts/SessionContext.d.ts +17 -1
- package/dist/schemas/registry.d.ts +22 -16
- package/dist/security/pii-schemas.d.ts +1 -1
- package/dist/security/pii-schemas.js +45 -0
- package/dist/server.d.ts +27 -1
- package/dist/server.js +144 -0
- package/dist/styles.css +349 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/Button.d.ts +10 -1
- package/dist/ui/components/Button.js +25 -3
- package/dist/ui/components/Chart.d.ts +63 -0
- package/dist/ui/components/Chart.js +169 -0
- package/dist/ui/components/Chart.style.css +214 -0
- package/dist/ui/components/Disclaimer.js +10 -1
- package/dist/ui/components/OverflowRow.js +54 -2
- package/dist/ui/components/OverflowRow.style.css +38 -0
- package/dist/ui/components/RichTextEditor.js +99 -8
- package/dist/ui/components/chart-spec.d.ts +68 -0
- package/dist/ui/components/chart-spec.js +140 -0
- package/dist/ui/components/index.style.css +1 -0
- package/dist/ui/components/surface-tokens.d.ts +1 -1
- package/dist/ui/components/surface-tokens.js +9 -0
- package/dist/ui/components/toolbar-collapse.d.ts +13 -0
- package/dist/ui/components/toolbar-collapse.js +10 -1
- package/dist/ui/index.d.ts +6 -0
- package/dist/ui/index.js +3 -0
- package/dist/ui/rich-text/RichText.d.ts +10 -1
- package/dist/ui/rich-text/RichText.js +33 -2
- package/dist/ui/rich-text/RichText.style.css +35 -0
- package/dist/ui/rich-text/RichTextRenderer.d.ts +30 -1
- package/dist/ui/rich-text/RichTextRenderer.js +34 -2
- package/dist/ui/rich-text/chart-blocks.d.ts +62 -0
- package/dist/ui/rich-text/chart-blocks.js +87 -0
- package/package.json +1 -1
- 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
|
-
|
|
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;
|