@mohasinac/appkit 4.35.1 → 4.35.3
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/_internal/client/features/layout/DashboardLayoutClient.js +18 -3
- package/dist/_internal/client/features/layout/filterNavItems.d.ts +22 -1
- package/dist/_internal/client/features/layout/filterNavItems.js +26 -1
- package/dist/_internal/server/features/checkout/actions.js +28 -22
- package/dist/_internal/server/features/media/contextGuards.d.ts +1 -1
- package/dist/_internal/server/features/media/contextGuards.js +34 -1
- package/dist/_internal/server/features/orders/actions.js +58 -49
- package/dist/_internal/server/features/site-settings/adapters.d.ts +1 -1
- package/dist/_internal/server/features/site-settings/adapters.js +8 -0
- package/dist/_internal/server/jobs/core/auctionSettlement.js +13 -0
- package/dist/_internal/server/jobs/core/dailyStatusDigest.d.ts +25 -0
- package/dist/_internal/server/jobs/core/dailyStatusDigest.js +154 -11
- package/dist/_internal/server/jobs/core/hardBanReinstatement.js +21 -9
- package/dist/_internal/server/jobs/core/onOrderCreate.js +62 -25
- package/dist/_internal/server/jobs/core/onScamReportCreate.js +30 -19
- package/dist/_internal/server/jobs/core/onScamReportRejected.js +1 -1
- package/dist/_internal/server/jobs/core/onScamReportVerified.js +1 -1
- package/dist/_internal/server/jobs/core/onSupportTicketCreate.js +1 -1
- package/dist/_internal/server/jobs/core/onSupportTicketUpdate.js +1 -1
- package/dist/_internal/server/jobs/core/paymentReviewAutoApprove.js +21 -9
- package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +23 -10
- package/dist/_internal/server/jobs/core/pendingOrderTimeout.js +36 -10
- package/dist/_internal/server/jobs/core/whatsappNotify.js +43 -1
- package/dist/_internal/server/jobs/handlers/onCatalogueSubmittedForApproval.js +14 -1
- package/dist/_internal/server/notifications/send-guard.d.ts +42 -0
- package/dist/_internal/server/notifications/send-guard.js +142 -0
- package/dist/_internal/shared/features/messaging/config.d.ts +108 -0
- package/dist/_internal/shared/features/messaging/config.js +44 -0
- package/dist/_internal/shared/features/notifications/action-url.js +9 -0
- package/dist/_internal/shared/features/notifications/email-eligibility.d.ts +66 -0
- package/dist/_internal/shared/features/notifications/email-eligibility.js +129 -0
- package/dist/constants/api-endpoints.d.ts +30 -0
- package/dist/constants/api-endpoints.js +10 -0
- package/dist/constants/notification-types.d.ts +1 -1
- package/dist/features/admin/actions/notification-actions.d.ts +20 -0
- package/dist/features/admin/actions/notification-actions.js +43 -1
- package/dist/features/admin/components/AdminSiteSettingsView.js +34 -4
- package/dist/features/admin/components/AdminSupportTicketDetailView.js +11 -4
- package/dist/features/admin/schemas/firestore.d.ts +32 -2
- package/dist/features/admin/schemas/firestore.js +28 -0
- package/dist/features/auth/hooks/useAuth.d.ts +5 -0
- package/dist/features/auth/hooks/useAuth.js +42 -0
- package/dist/features/checkout/actions/checkout-value-otp-actions.js +32 -0
- package/dist/features/contact/email.d.ts +30 -26
- package/dist/features/contact/email.js +65 -105
- package/dist/features/email/notification-templates.js +11 -0
- package/dist/features/messaging/repository/message-budget.repository.d.ts +73 -0
- package/dist/features/messaging/repository/message-budget.repository.js +138 -0
- package/dist/features/messaging/schemas/firestore.d.ts +62 -0
- package/dist/features/messaging/schemas/firestore.js +36 -0
- package/dist/features/products/components/CardStoreLine.js +22 -3
- package/dist/features/seller/components/SellerProductShell.js +7 -0
- package/dist/features/tester/seed-data/authored/seo__page-metadata.js +6 -6
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +277 -9
- package/dist/seed/site-settings-seed-data.js +39 -10
- package/dist/server.d.ts +3 -5
- package/dist/server.js +27 -13
- package/package.json +1 -1
|
@@ -23,6 +23,16 @@ export declare const AUTH_ENDPOINTS: {
|
|
|
23
23
|
readonly SESSION_ACTIVITY: "/api/auth/session/activity";
|
|
24
24
|
readonly SESSION_VALIDATE: "/api/auth/session/validate";
|
|
25
25
|
readonly EVENT_INIT: "/api/auth/event/init";
|
|
26
|
+
/**
|
|
27
|
+
* Cooldown gate the client asks BEFORE calling Firebase's own
|
|
28
|
+
* `sendPasswordResetEmail` / `sendEmailVerification`.
|
|
29
|
+
*
|
|
30
|
+
* Those go browser → Firebase with no server hop, so this is the only
|
|
31
|
+
* throttle they have — nothing else on our side ever sees them. Advisory by
|
|
32
|
+
* design: a failure here resolves to "allowed", because a user locked out of
|
|
33
|
+
* their account must not also be locked out by our infrastructure.
|
|
34
|
+
*/
|
|
35
|
+
readonly MAIL_GATE: "/api/auth/mail-gate";
|
|
26
36
|
};
|
|
27
37
|
export declare const ACCOUNT_ENDPOINTS: {
|
|
28
38
|
/** @param userId — Firestore user document id */
|
|
@@ -480,6 +490,16 @@ export declare const API_ENDPOINTS: {
|
|
|
480
490
|
readonly SESSION_ACTIVITY: "/api/auth/session/activity";
|
|
481
491
|
readonly SESSION_VALIDATE: "/api/auth/session/validate";
|
|
482
492
|
readonly EVENT_INIT: "/api/auth/event/init";
|
|
493
|
+
/**
|
|
494
|
+
* Cooldown gate the client asks BEFORE calling Firebase's own
|
|
495
|
+
* `sendPasswordResetEmail` / `sendEmailVerification`.
|
|
496
|
+
*
|
|
497
|
+
* Those go browser → Firebase with no server hop, so this is the only
|
|
498
|
+
* throttle they have — nothing else on our side ever sees them. Advisory by
|
|
499
|
+
* design: a failure here resolves to "allowed", because a user locked out of
|
|
500
|
+
* their account must not also be locked out by our infrastructure.
|
|
501
|
+
*/
|
|
502
|
+
readonly MAIL_GATE: "/api/auth/mail-gate";
|
|
483
503
|
};
|
|
484
504
|
readonly ACCOUNT: {
|
|
485
505
|
/** @param userId — Firestore user document id */
|
|
@@ -939,6 +959,16 @@ export declare const API_ROUTES: {
|
|
|
939
959
|
readonly SESSION_ACTIVITY: "/api/auth/session/activity";
|
|
940
960
|
readonly SESSION_VALIDATE: "/api/auth/session/validate";
|
|
941
961
|
readonly EVENT_INIT: "/api/auth/event/init";
|
|
962
|
+
/**
|
|
963
|
+
* Cooldown gate the client asks BEFORE calling Firebase's own
|
|
964
|
+
* `sendPasswordResetEmail` / `sendEmailVerification`.
|
|
965
|
+
*
|
|
966
|
+
* Those go browser → Firebase with no server hop, so this is the only
|
|
967
|
+
* throttle they have — nothing else on our side ever sees them. Advisory by
|
|
968
|
+
* design: a failure here resolves to "allowed", because a user locked out of
|
|
969
|
+
* their account must not also be locked out by our infrastructure.
|
|
970
|
+
*/
|
|
971
|
+
readonly MAIL_GATE: "/api/auth/mail-gate";
|
|
942
972
|
};
|
|
943
973
|
readonly ACCOUNT: {
|
|
944
974
|
/** @param userId — Firestore user document id */
|
|
@@ -35,6 +35,16 @@ export const AUTH_ENDPOINTS = {
|
|
|
35
35
|
SESSION_ACTIVITY: "/api/auth/session/activity",
|
|
36
36
|
SESSION_VALIDATE: "/api/auth/session/validate",
|
|
37
37
|
EVENT_INIT: "/api/auth/event/init",
|
|
38
|
+
/**
|
|
39
|
+
* Cooldown gate the client asks BEFORE calling Firebase's own
|
|
40
|
+
* `sendPasswordResetEmail` / `sendEmailVerification`.
|
|
41
|
+
*
|
|
42
|
+
* Those go browser → Firebase with no server hop, so this is the only
|
|
43
|
+
* throttle they have — nothing else on our side ever sees them. Advisory by
|
|
44
|
+
* design: a failure here resolves to "allowed", because a user locked out of
|
|
45
|
+
* their account must not also be locked out by our infrastructure.
|
|
46
|
+
*/
|
|
47
|
+
MAIL_GATE: "/api/auth/mail-gate",
|
|
38
48
|
/*
|
|
39
49
|
* RESEND_VERIFICATION was here and is gone. The route was deleted in the
|
|
40
50
|
* Firebase-native auth migration (Root Cause #54: the Admin SDK cannot send
|
|
@@ -29,7 +29,7 @@ import type { NotificationType } from "../features/admin/schemas/firestore";
|
|
|
29
29
|
export { NOTIFICATION_TYPE_VALUES };
|
|
30
30
|
export type { NotificationType };
|
|
31
31
|
/** Alias kept for the two existing importers. */
|
|
32
|
-
export declare const NOTIFICATION_TYPES: 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", "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"];
|
|
32
|
+
export declare const NOTIFICATION_TYPES: 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"];
|
|
33
33
|
/**
|
|
34
34
|
* `order_shipped` → `Order shipped`.
|
|
35
35
|
*
|
|
@@ -28,6 +28,26 @@ export interface SendNotificationInput extends NotificationCreateInput {
|
|
|
28
28
|
* the seller, `offer_responded` back to the buyer.
|
|
29
29
|
*/
|
|
30
30
|
audience?: NotificationAudience;
|
|
31
|
+
/**
|
|
32
|
+
* Bypass `EMAIL_ELIGIBLE_TYPES` for THIS send, because a human explicitly
|
|
33
|
+
* asked for it.
|
|
34
|
+
*
|
|
35
|
+
* The only caller is the support-ticket reply route, where a staff member
|
|
36
|
+
* ticked "also email the user" on a reply. `support_ticket_update` is
|
|
37
|
+
* ineligible by default precisely so routine back-and-forth costs nothing —
|
|
38
|
+
* this is the escape hatch for the reply that genuinely has to reach an
|
|
39
|
+
* inbox.
|
|
40
|
+
*
|
|
41
|
+
* 🛑 It bypasses ELIGIBILITY ONLY. The kill switch, the daily ceiling, the
|
|
42
|
+
* channel config and the user's own opt-out all still apply — a staff tick
|
|
43
|
+
* is a request, not an override of the site's posture or of the recipient's
|
|
44
|
+
* stated preference.
|
|
45
|
+
*
|
|
46
|
+
* Deliberately a named literal rather than `boolean`: a `force: true` reads
|
|
47
|
+
* as "send harder" at the call site and invites reuse, while
|
|
48
|
+
* `"staff_requested"` states who is accountable for the send.
|
|
49
|
+
*/
|
|
50
|
+
eligibilityOverride?: "staff_requested";
|
|
31
51
|
}
|
|
32
52
|
export interface SendNotificationResult {
|
|
33
53
|
notification: NotificationDocument;
|
|
@@ -8,6 +8,7 @@ import { enqueueJob } from "../../jobs/actions/enqueue-job";
|
|
|
8
8
|
import { serverLogger } from "../../../monitoring";
|
|
9
9
|
import { decryptPii } from "../../../security/index";
|
|
10
10
|
import { resolveNotificationActionUrl, TYPE_AUDIENCE, } from "../../../_internal/shared/features/notifications/action-url";
|
|
11
|
+
import { isEmailEligible } from "../../../_internal/shared/features/notifications/email-eligibility";
|
|
11
12
|
import { renderNotificationEmail } from "../../email/notification-templates";
|
|
12
13
|
/**
|
|
13
14
|
* A notification's `actionUrl` is a site-relative path — correct for the
|
|
@@ -111,7 +112,7 @@ export async function getUnreadNotificationCount(userId) {
|
|
|
111
112
|
* 5. The required credentials are set in siteSettings.credentials
|
|
112
113
|
*/
|
|
113
114
|
export async function sendNotification(input) {
|
|
114
|
-
const { userEmail, userPhone, emailHtml, audience, ...notifInput } = input;
|
|
115
|
+
const { userEmail, userPhone, emailHtml, audience, eligibilityOverride, ...notifInput } = input;
|
|
115
116
|
/*
|
|
116
117
|
* Fill `actionUrl` when the caller did not.
|
|
117
118
|
*
|
|
@@ -131,6 +132,35 @@ export async function sendNotification(input) {
|
|
|
131
132
|
...notifInput,
|
|
132
133
|
...(resolvedActionUrl ? { actionUrl: resolvedActionUrl } : {}),
|
|
133
134
|
});
|
|
135
|
+
/*
|
|
136
|
+
* 🛑 The eligibility gate, and it is FIRST for a reason.
|
|
137
|
+
*
|
|
138
|
+
* `EMAIL_ELIGIBLE_TYPES` (see that file for the full argument) decides
|
|
139
|
+
* whether a type may leave the building at all. Checking it here — before
|
|
140
|
+
* the siteSettings read and before the user-prefs read — is what makes the
|
|
141
|
+
* expensive fan-outs free:
|
|
142
|
+
*
|
|
143
|
+
* `auctionSettlement` sends `bid_lost` to up to 50 losing bidders in
|
|
144
|
+
* parallel per auction. Each of those calls would otherwise pay TWO
|
|
145
|
+
* Firestore reads (settings + user doc) to arrive at "skipped". That is
|
|
146
|
+
* 100 reads per auction to send nothing, and every expired auction settles
|
|
147
|
+
* concurrently.
|
|
148
|
+
*
|
|
149
|
+
* Returning here costs zero reads, and it is also what lets the daily send
|
|
150
|
+
* counter stay a single unsharded document: the only remaining concurrent
|
|
151
|
+
* fan-out never reaches the counter.
|
|
152
|
+
*
|
|
153
|
+
* The bell is already filled by the write above, so nothing is lost — this
|
|
154
|
+
* suppresses the emailed/messaged COPY, not the notification.
|
|
155
|
+
*
|
|
156
|
+
* One map gates both channels deliberately. If a type is ever genuinely
|
|
157
|
+
* WhatsApp-worthy but not email-worthy, split the map then rather than
|
|
158
|
+
* loosening this early return.
|
|
159
|
+
*/
|
|
160
|
+
if (!isEmailEligible(notifInput.type) &&
|
|
161
|
+
eligibilityOverride !== "staff_requested") {
|
|
162
|
+
return { notification, email: "skipped", whatsapp: "skipped" };
|
|
163
|
+
}
|
|
134
164
|
// Load channel config + credentials (one Firestore read).
|
|
135
165
|
let settings;
|
|
136
166
|
try {
|
|
@@ -204,6 +234,18 @@ export async function sendNotification(input) {
|
|
|
204
234
|
// "Your catalogue photos are going stale" — housekeeping about the user's
|
|
205
235
|
// own account, which is what `system` covers.
|
|
206
236
|
catalogue_images_stale: "system",
|
|
237
|
+
/*
|
|
238
|
+
* Both land under `system` rather than getting new preference keys.
|
|
239
|
+
*
|
|
240
|
+
* Same reasoning as the EMI note above: an eighth and ninth key would have
|
|
241
|
+
* no stored value on any existing user, so it reads as on or off purely by
|
|
242
|
+
* accident of the default — a silent migration nobody asked for. And
|
|
243
|
+
* neither is something a user would plausibly want to silence
|
|
244
|
+
* independently: a ticket update is a reply to a conversation THEY started,
|
|
245
|
+
* and a scam-report outcome is the answer to a report THEY filed.
|
|
246
|
+
*/
|
|
247
|
+
support_ticket_update: "system",
|
|
248
|
+
scam_report_update: "system",
|
|
207
249
|
};
|
|
208
250
|
const typeKey = typeToPrefsKey[type];
|
|
209
251
|
// If the user has explicitly disabled this notification type, skip all external channels.
|
|
@@ -306,7 +306,17 @@ export function AdminSiteSettingsView({ labels = {}, ...rest }) {
|
|
|
306
306
|
// Daily ops digest — recipients are edited as one-per-line text, split on save.
|
|
307
307
|
const [digestEnabled, setDigestEnabled] = React.useState(false);
|
|
308
308
|
const [digestRecipients, setDigestRecipients] = React.useState("");
|
|
309
|
-
|
|
309
|
+
/*
|
|
310
|
+
* Outbound messaging: the kill switch and the daily ceiling.
|
|
311
|
+
*
|
|
312
|
+
* `emailEnabled` suppresses USER-facing mail only. Staff mail — the daily
|
|
313
|
+
* digest, the payout summary — keeps flowing regardless, because switching
|
|
314
|
+
* off customer email must never also blind the operator.
|
|
315
|
+
*/
|
|
316
|
+
const [msgEmailEnabled, setMsgEmailEnabled] = React.useState(false);
|
|
317
|
+
const [msgWhatsappEnabled, setMsgWhatsappEnabled] = React.useState(false);
|
|
318
|
+
const [msgCeilingEmail, setMsgCeilingEmail] = React.useState(80);
|
|
319
|
+
const [msgCeilingWhatsapp, setMsgCeilingWhatsapp] = React.useState(200);
|
|
310
320
|
// Snapshot of the masked placeholder strings the server returned for every
|
|
311
321
|
// credentials.* field, captured once per load. A combined single-save has
|
|
312
322
|
// to send the FULL credentials object on every save (not just the group the
|
|
@@ -490,7 +500,13 @@ export function AdminSiteSettingsView({ labels = {}, ...rest }) {
|
|
|
490
500
|
setNotifFromName(s.emailSettings?.fromName ?? "");
|
|
491
501
|
setDigestEnabled(s.emailSettings?.dailyDigest?.enabled ?? false);
|
|
492
502
|
setDigestRecipients((s.emailSettings?.dailyDigest?.recipients ?? []).join("\n"));
|
|
493
|
-
|
|
503
|
+
// Defaults mirror MESSAGE_BUDGET_DEFAULTS — both channels OFF, so a
|
|
504
|
+
// settings document written before this feature existed reads as quiet
|
|
505
|
+
// rather than as "on and unmetered".
|
|
506
|
+
setMsgEmailEnabled(s.messaging?.emailEnabled ?? false);
|
|
507
|
+
setMsgWhatsappEnabled(s.messaging?.whatsappEnabled ?? false);
|
|
508
|
+
setMsgCeilingEmail(s.messaging?.dailyCeiling?.email ?? 80);
|
|
509
|
+
setMsgCeilingWhatsapp(s.messaging?.dailyCeiling?.whatsapp ?? 200);
|
|
494
510
|
originalMaskedRef.current = {
|
|
495
511
|
razorpayKeyId: s.credentialsMasked?.razorpayKeyId ?? "",
|
|
496
512
|
razorpayKeySecret: s.credentialsMasked?.razorpayKeySecret ?? "",
|
|
@@ -630,7 +646,21 @@ export function AdminSiteSettingsView({ labels = {}, ...rest }) {
|
|
|
630
646
|
dailyDigest: {
|
|
631
647
|
enabled: digestEnabled,
|
|
632
648
|
recipients: splitEmailList(digestRecipients),
|
|
633
|
-
|
|
649
|
+
// Always empty — the CC field is gone (see the Notifications tab).
|
|
650
|
+
// Sent explicitly rather than omitted so a save clears any CC list a
|
|
651
|
+
// pre-existing document still carries.
|
|
652
|
+
ccRecipients: [],
|
|
653
|
+
},
|
|
654
|
+
},
|
|
655
|
+
messaging: {
|
|
656
|
+
emailEnabled: msgEmailEnabled,
|
|
657
|
+
whatsappEnabled: msgWhatsappEnabled,
|
|
658
|
+
dailyCeiling: {
|
|
659
|
+
email: msgCeilingEmail,
|
|
660
|
+
whatsapp: msgCeilingWhatsapp,
|
|
661
|
+
// No SMS sender exists; the ceiling is carried so one cannot ship
|
|
662
|
+
// uncapped if somebody adds a provider later.
|
|
663
|
+
sms: 50,
|
|
634
664
|
},
|
|
635
665
|
},
|
|
636
666
|
integrations: { googleAnalyticsId: gaMeasurementId, facebookPixelId: fbPixelId, gtmContainerId },
|
|
@@ -744,7 +774,7 @@ export function AdminSiteSettingsView({ labels = {}, ...rest }) {
|
|
|
744
774
|
...bidIncrementTiers.slice(0, -1),
|
|
745
775
|
{ upTo: 100, increment: 10 },
|
|
746
776
|
bidIncrementTiers[bidIncrementTiers.length - 1] ?? { upTo: null, increment: 1000 },
|
|
747
|
-
]), children: "+ Add tier" })] }), _jsx(Input, { label: "Auto-extend window (minutes before end)", value: String(autoExtendWindow), onChange: (e) => setAutoExtendWindow(parseInt(e.target.value) || 0), type: "number", min: 0, helperText: "Extend auction end time if a bid arrives within this window." }), _jsx(Input, { label: "Settlement grace period (hours)", value: String(settlementGrace), onChange: (e) => setSettlementGrace(parseInt(e.target.value) || 0), type: "number", min: 1, helperText: "Time winner has to pay before the auction is re-listed." })] }) }), _jsx(TabsContent, { value: "limits", children: _jsx(SettingsTabForm, { id: "limits", label: "Limits", children: _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "Max products per store", value: String(maxProductsPerStore), onChange: (e) => setMaxProductsPerStore(parseInt(e.target.value) || 0), type: "number", min: 1 }), _jsx(Input, { label: "Max custom fields per product", value: String(maxCustomFields), onChange: (e) => setMaxCustomFields(parseInt(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "Max custom sections per product", value: String(maxCustomSections), onChange: (e) => setMaxCustomSections(parseInt(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "Order cancellation window (hours)", value: String(orderCancelWindow), onChange: (e) => setOrderCancelWindow(parseInt(e.target.value) || 0), type: "number", min: 0 })] }) }) }), _jsx(TabsContent, { value: "whatsapp", children: _jsxs(SettingsTabForm, { id: "whatsapp", label: "Whatsapp", children: [_jsx(Text, { size: "xs", color: "muted", children: "Platform-level WhatsApp Business Cloud API credentials. Used for automated purchase announcements to admin numbers when orders are placed. Store owners configure their own credentials in Store \u2192 WhatsApp." }), _jsx(Input, { label: "Phone Number ID", value: waPhoneNumberId, onChange: (e) => setWaPhoneNumberId(e.target.value), placeholder: "987654321098765", helperText: "From Meta for Developers \u2192 App \u2192 WhatsApp \u2192 API Setup" }), _jsx(MaskedInput, { label: "Cloud API System User Token", value: waCloudApiToken, onChange: setWaCloudApiToken, placeholder: "EAAxxxxxxxx\u2026", helperText: "Long-lived system user access token with WhatsApp Business permissions" }), _jsx(Input, { label: "Admin Notify Numbers", value: waAdminNotifyNumbers, onChange: (e) => setWaAdminNotifyNumbers(e.target.value), placeholder: "919876543210,918765432109", helperText: "Comma-separated, digits-only, include country code. These receive a WhatsApp message when any order is placed." }), _jsx(Text, { size: "xs", color: "muted", children: "Approved Meta message templates for the buyer-facing WhatsApp order-updates addon (Fees tab). Meta rejects business-initiated free-text WhatsApp messages outside a 24h customer-service window \u2014 a real template name here is required for that notification type to actually deliver. Leave blank to skip WhatsApp for that type (in-app + email still fire)." }), _jsx(Input, { label: "Template language code", value: waTemplateLanguage, onChange: (e) => setWaTemplateLanguage(e.target.value), placeholder: "en", helperText: "BCP-47 code the templates below were approved in, e.g. en or en_US." }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "Order placed template name", value: waTemplateOrderPlaced, onChange: (e) => setWaTemplateOrderPlaced(e.target.value), placeholder: "order_placed_update" }), _jsx(Input, { label: "Order confirmed template name", value: waTemplateOrderConfirmed, onChange: (e) => setWaTemplateOrderConfirmed(e.target.value), placeholder: "order_confirmed_update" }), _jsx(Input, { label: "Order shipped template name", value: waTemplateOrderShipped, onChange: (e) => setWaTemplateOrderShipped(e.target.value), placeholder: "order_shipped_update" }), _jsx(Input, { label: "Order delivered template name", value: waTemplateOrderDelivered, onChange: (e) => setWaTemplateOrderDelivered(e.target.value), placeholder: "order_delivered_update" }), _jsx(Input, { label: "Order cancelled template name", value: waTemplateOrderCancelled, onChange: (e) => setWaTemplateOrderCancelled(e.target.value), placeholder: "order_cancelled_update" }), _jsx(Input, { label: "Refund initiated template name", value: waTemplateRefundInitiated, onChange: (e) => setWaTemplateRefundInitiated(e.target.value), placeholder: "refund_initiated_update" })] })] }) }), _jsx(TabsContent, { value: "notifications", children: _jsxs(SettingsTabForm, { id: "notifications", label: "Notifications", children: [_jsx(Text, { size: "xs", color: "muted", children: "In-app notifications are always on. Enable external channels below to let the platform fan out to email, WhatsApp, or SMS. Users can further restrict which types they receive." }), _jsxs(Stack, { gap: "xs", surface: "muted", rounded: "lg", border: "default", padding: "md", children: [_jsxs(Row, { justify: "between", gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "In-app (notification bell)" }), _jsx(Span, { color: "success", surface: "success-surface", size: "xs", weight: "semibold", rounded: "full", padding: "pill-xs", children: "Always on" })] }), _jsx(Text, { size: "xs", color: "muted", children: "Displayed in the notification bell and inbox. Cannot be disabled." })] }), _jsxs(Stack, { gap: "md", rounded: "lg", border: "default", padding: "md", children: [_jsx(Div, { id: "setting-email-notifications", children: _jsx(Toggle, { label: "Email notifications", checked: notifEmailEnabled, onChange: setNotifEmailEnabled }) }), notifEmailEnabled && (_jsxs(Stack, { gap: "md", className: NOTIF_CHANNEL_INDENT, children: [_jsx(Select, { label: "Minimum priority to send email", options: PRIORITY_OPTIONS, value: notifEmailMinPriority, onValueChange: setNotifEmailMinPriority }), _jsx(Text, { size: "xs", weight: "medium", color: "muted", className: "pt-[0.25rem]", children: "Resend API (for transactional email)" }), _jsx(MaskedInput, { label: "Resend API Key", value: resendApiKey, onChange: setResendApiKey, placeholder: "re_live_\u2026", helperText: "Get your key at resend.com \u2014 used for all transactional notifications." }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "From email", value: notifFromEmail, onChange: (e) => setNotifFromEmail(e.target.value), placeholder: "noreply@letitrip.in", type: "email" }), _jsx(Input, { label: "From name", value: notifFromName, onChange: (e) => setNotifFromName(e.target.value), placeholder: "LetItRip" })] }), _jsx(Text, { size: "xs", weight: "medium", color: "muted", className: "pt-[0.25rem]", children: "Notification types (leave empty to send all types)" }), _jsx(PaginatedSelect, { multiple: true, options: NOTIF_TYPE_OPTIONS, value: notifEmailTypes, onChange: (values) => setNotifEmailTypes(values), placeholder: "All notification types" })] }))] }), _jsxs(Stack, { gap: "md", rounded: "lg", border: "default", padding: "md", children: [_jsx(Div, { id: "setting-daily-digest", children: _jsx(Toggle, { label: "Daily status digest", checked: digestEnabled, onChange: setDigestEnabled }) }), digestEnabled && (_jsxs(Stack, { gap: "md", className: NOTIF_CHANNEL_INDENT, children: [_jsx(Text, { size: "xs", color: "muted", children: "Sent every morning at 10:00 IST with the previous 24 hours of order activity. Its arrival doubles as the platform health check \u2014 if it stops landing, something needs a look." }), _jsx(Textarea, { label: "Recipients (To)", value: digestRecipients, onChange: (e) => setDigestRecipients(e.target.value), placeholder: "support@letitrip.in\nmohasin@letitrip.in", rows: 4, helperText: "One address per line." }), _jsx(Textarea, { label: "CC recipients", value: digestCcRecipients, onChange: (e) => setDigestCcRecipients(e.target.value), placeholder: "One address per line", rows: 3, helperText: "Optional \u2014 anyone else who should receive a copy." })] }))] }), _jsxs(Stack, { gap: "md", rounded: "lg", border: "default", padding: "md", children: [_jsx(Div, { id: "setting-whatsapp-notifications", children: _jsx(Toggle, { label: "WhatsApp notifications", checked: notifWhatsappEnabled, onChange: setNotifWhatsappEnabled }) }), notifWhatsappEnabled && (_jsxs(Stack, { gap: "md", className: NOTIF_CHANNEL_INDENT, children: [_jsx(Select, { label: "Minimum priority to send WhatsApp", options: PRIORITY_OPTIONS, value: notifWhatsappMinPriority, onValueChange: setNotifWhatsappMinPriority }), _jsx(Toggle, { label: "Enable WhatsApp OTP (for login and verification)", checked: notifWhatsappOtpEnabled, onChange: setNotifWhatsappOtpEnabled }), _jsx(Text, { size: "xs", color: "muted", children: "WhatsApp credentials are configured in the WhatsApp tab (\u246C). OTP messages use the same phone number." }), _jsx(Text, { size: "xs", weight: "medium", color: "muted", className: "pt-[0.25rem]", children: "Notification types (leave empty to send all types)" }), _jsx(PaginatedSelect, { multiple: true, options: NOTIF_TYPE_OPTIONS, value: notifWhatsappTypes, onChange: (values) => setNotifWhatsappTypes(values), placeholder: "All notification types" })] }))] }), _jsxs(Stack, { gap: "md", rounded: "lg", border: "default", padding: "md", children: [_jsx(Toggle, { label: "SMS notifications", checked: notifSmsEnabled, onChange: setNotifSmsEnabled }), notifSmsEnabled && (_jsxs(Stack, { gap: "md", className: NOTIF_CHANNEL_INDENT, children: [_jsx(Select, { label: "Minimum priority to send SMS", options: PRIORITY_OPTIONS, value: notifSmsMinPriority, onValueChange: setNotifSmsMinPriority }), _jsx(Text, { size: "xs", color: "muted", children: "SMS gateway credentials (e.g. Twilio, MSG91) can be configured in the Integrations tab once an SMS provider is connected." })] }))] })] }) }), _jsx(TabsContent, { value: "legal", children: _jsx(SettingsTabForm, { id: "legal", label: "Legal", children: [
|
|
777
|
+
]), children: "+ Add tier" })] }), _jsx(Input, { label: "Auto-extend window (minutes before end)", value: String(autoExtendWindow), onChange: (e) => setAutoExtendWindow(parseInt(e.target.value) || 0), type: "number", min: 0, helperText: "Extend auction end time if a bid arrives within this window." }), _jsx(Input, { label: "Settlement grace period (hours)", value: String(settlementGrace), onChange: (e) => setSettlementGrace(parseInt(e.target.value) || 0), type: "number", min: 1, helperText: "Time winner has to pay before the auction is re-listed." })] }) }), _jsx(TabsContent, { value: "limits", children: _jsx(SettingsTabForm, { id: "limits", label: "Limits", children: _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "Max products per store", value: String(maxProductsPerStore), onChange: (e) => setMaxProductsPerStore(parseInt(e.target.value) || 0), type: "number", min: 1 }), _jsx(Input, { label: "Max custom fields per product", value: String(maxCustomFields), onChange: (e) => setMaxCustomFields(parseInt(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "Max custom sections per product", value: String(maxCustomSections), onChange: (e) => setMaxCustomSections(parseInt(e.target.value) || 0), type: "number", min: 0 }), _jsx(Input, { label: "Order cancellation window (hours)", value: String(orderCancelWindow), onChange: (e) => setOrderCancelWindow(parseInt(e.target.value) || 0), type: "number", min: 0 })] }) }) }), _jsx(TabsContent, { value: "whatsapp", children: _jsxs(SettingsTabForm, { id: "whatsapp", label: "Whatsapp", children: [_jsx(Text, { size: "xs", color: "muted", children: "Platform-level WhatsApp Business Cloud API credentials. Used for automated purchase announcements to admin numbers when orders are placed. Store owners configure their own credentials in Store \u2192 WhatsApp." }), _jsx(Input, { label: "Phone Number ID", value: waPhoneNumberId, onChange: (e) => setWaPhoneNumberId(e.target.value), placeholder: "987654321098765", helperText: "From Meta for Developers \u2192 App \u2192 WhatsApp \u2192 API Setup" }), _jsx(MaskedInput, { label: "Cloud API System User Token", value: waCloudApiToken, onChange: setWaCloudApiToken, placeholder: "EAAxxxxxxxx\u2026", helperText: "Long-lived system user access token with WhatsApp Business permissions" }), _jsx(Input, { label: "Admin Notify Numbers", value: waAdminNotifyNumbers, onChange: (e) => setWaAdminNotifyNumbers(e.target.value), placeholder: "919876543210,918765432109", helperText: "Comma-separated, digits-only, include country code. These receive a WhatsApp message when any order is placed." }), _jsx(Text, { size: "xs", color: "muted", children: "Approved Meta message templates for the buyer-facing WhatsApp order-updates addon (Fees tab). Meta rejects business-initiated free-text WhatsApp messages outside a 24h customer-service window \u2014 a real template name here is required for that notification type to actually deliver. Leave blank to skip WhatsApp for that type (in-app + email still fire)." }), _jsx(Input, { label: "Template language code", value: waTemplateLanguage, onChange: (e) => setWaTemplateLanguage(e.target.value), placeholder: "en", helperText: "BCP-47 code the templates below were approved in, e.g. en or en_US." }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "Order placed template name", value: waTemplateOrderPlaced, onChange: (e) => setWaTemplateOrderPlaced(e.target.value), placeholder: "order_placed_update" }), _jsx(Input, { label: "Order confirmed template name", value: waTemplateOrderConfirmed, onChange: (e) => setWaTemplateOrderConfirmed(e.target.value), placeholder: "order_confirmed_update" }), _jsx(Input, { label: "Order shipped template name", value: waTemplateOrderShipped, onChange: (e) => setWaTemplateOrderShipped(e.target.value), placeholder: "order_shipped_update" }), _jsx(Input, { label: "Order delivered template name", value: waTemplateOrderDelivered, onChange: (e) => setWaTemplateOrderDelivered(e.target.value), placeholder: "order_delivered_update" }), _jsx(Input, { label: "Order cancelled template name", value: waTemplateOrderCancelled, onChange: (e) => setWaTemplateOrderCancelled(e.target.value), placeholder: "order_cancelled_update" }), _jsx(Input, { label: "Refund initiated template name", value: waTemplateRefundInitiated, onChange: (e) => setWaTemplateRefundInitiated(e.target.value), placeholder: "refund_initiated_update" })] })] }) }), _jsx(TabsContent, { value: "notifications", children: _jsxs(SettingsTabForm, { id: "notifications", label: "Notifications", children: [_jsx(Text, { size: "xs", color: "muted", children: "In-app notifications are always on. Enable external channels below to let the platform fan out to email, WhatsApp, or SMS. Users can further restrict which types they receive." }), _jsxs(Stack, { gap: "md", rounded: "lg", border: "default", padding: "md", children: [_jsx(Text, { size: "sm", weight: "semibold", children: "Outbound messaging budget" }), _jsxs(Text, { size: "xs", color: "muted", children: ["The master switches. These suppress ", _jsx(Span, { weight: "semibold", children: "user-facing" }), " messages only \u2014 the daily status digest always reaches staff, so turning customer email off never leaves the site unwatched. Transactional messages (checkout verification codes, order receipts) also keep sending, because suppressing those breaks checkout rather than quietening it."] }), _jsx(Toggle, { label: "Send user-facing email", checked: msgEmailEnabled, onChange: setMsgEmailEnabled }), _jsx(Toggle, { label: "Send user-facing WhatsApp", checked: msgWhatsappEnabled, onChange: setMsgWhatsappEnabled }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "Daily email ceiling", type: "number", value: String(msgCeilingEmail), onChange: (e) => setMsgCeilingEmail(Number(e.target.value) || 0), helperText: "Resend's free tier is 100/day. 80 leaves headroom for password-reset mail and in-flight sends." }), _jsx(Input, { label: "Daily WhatsApp ceiling", type: "number", value: String(msgCeilingWhatsapp), onChange: (e) => setMsgCeilingWhatsapp(Number(e.target.value) || 0), helperText: "A sanity bound. Meta bills per conversation, not per message." })] }), _jsx(Text, { size: "xs", color: "muted", children: "Some notification types never email regardless of these settings \u2014 losing an auction, being outbid, marketing, review activity and ticket updates are shown in the notification bell only. That is deliberate: those were the highest-volume sends and the ones most likely to exhaust the day's allowance. A support reply can still be emailed one-off from the ticket screen." })] }), _jsxs(Stack, { gap: "xs", surface: "muted", rounded: "lg", border: "default", padding: "md", children: [_jsxs(Row, { justify: "between", gap: "sm", children: [_jsx(Text, { size: "sm", weight: "medium", color: "muted", children: "In-app (notification bell)" }), _jsx(Span, { color: "success", surface: "success-surface", size: "xs", weight: "semibold", rounded: "full", padding: "pill-xs", children: "Always on" })] }), _jsx(Text, { size: "xs", color: "muted", children: "Displayed in the notification bell and inbox. Cannot be disabled." })] }), _jsxs(Stack, { gap: "md", rounded: "lg", border: "default", padding: "md", children: [_jsx(Div, { id: "setting-email-notifications", children: _jsx(Toggle, { label: "Email notifications", checked: notifEmailEnabled, onChange: setNotifEmailEnabled }) }), notifEmailEnabled && (_jsxs(Stack, { gap: "md", className: NOTIF_CHANNEL_INDENT, children: [_jsx(Select, { label: "Minimum priority to send email", options: PRIORITY_OPTIONS, value: notifEmailMinPriority, onValueChange: setNotifEmailMinPriority }), _jsx(Text, { size: "xs", weight: "medium", color: "muted", className: "pt-[0.25rem]", children: "Resend API (for transactional email)" }), _jsx(MaskedInput, { label: "Resend API Key", value: resendApiKey, onChange: setResendApiKey, placeholder: "re_live_\u2026", helperText: "Get your key at resend.com \u2014 used for all transactional notifications." }), _jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(Input, { label: "From email", value: notifFromEmail, onChange: (e) => setNotifFromEmail(e.target.value), placeholder: "noreply@letitrip.in", type: "email" }), _jsx(Input, { label: "From name", value: notifFromName, onChange: (e) => setNotifFromName(e.target.value), placeholder: "LetItRip" })] }), _jsx(Text, { size: "xs", weight: "medium", color: "muted", className: "pt-[0.25rem]", children: "Notification types (leave empty to send all types)" }), _jsx(PaginatedSelect, { multiple: true, options: NOTIF_TYPE_OPTIONS, value: notifEmailTypes, onChange: (values) => setNotifEmailTypes(values), placeholder: "All notification types" })] }))] }), _jsxs(Stack, { gap: "md", rounded: "lg", border: "default", padding: "md", children: [_jsx(Div, { id: "setting-daily-digest", children: _jsx(Toggle, { label: "Daily status digest", checked: digestEnabled, onChange: setDigestEnabled }) }), digestEnabled && (_jsxs(Stack, { gap: "md", className: NOTIF_CHANNEL_INDENT, children: [_jsx(Text, { size: "xs", color: "muted", children: "Sent every morning at 10:00 IST with the previous 24 hours of order activity. Its arrival doubles as the platform health check \u2014 if it stops landing, something needs a look." }), _jsx(Textarea, { label: "Recipients (To)", value: digestRecipients, onChange: (e) => setDigestRecipients(e.target.value), placeholder: "mohasin@letitrip.in", rows: 3, helperText: "One address per line. Each address is billed as a separate send against the daily allowance \u2014 keep this list short." })] }))] }), _jsxs(Stack, { gap: "md", rounded: "lg", border: "default", padding: "md", children: [_jsx(Div, { id: "setting-whatsapp-notifications", children: _jsx(Toggle, { label: "WhatsApp notifications", checked: notifWhatsappEnabled, onChange: setNotifWhatsappEnabled }) }), notifWhatsappEnabled && (_jsxs(Stack, { gap: "md", className: NOTIF_CHANNEL_INDENT, children: [_jsx(Select, { label: "Minimum priority to send WhatsApp", options: PRIORITY_OPTIONS, value: notifWhatsappMinPriority, onValueChange: setNotifWhatsappMinPriority }), _jsx(Toggle, { label: "Enable WhatsApp OTP (for login and verification)", checked: notifWhatsappOtpEnabled, onChange: setNotifWhatsappOtpEnabled }), _jsx(Text, { size: "xs", color: "muted", children: "WhatsApp credentials are configured in the WhatsApp tab (\u246C). OTP messages use the same phone number." }), _jsx(Text, { size: "xs", weight: "medium", color: "muted", className: "pt-[0.25rem]", children: "Notification types (leave empty to send all types)" }), _jsx(PaginatedSelect, { multiple: true, options: NOTIF_TYPE_OPTIONS, value: notifWhatsappTypes, onChange: (values) => setNotifWhatsappTypes(values), placeholder: "All notification types" })] }))] }), _jsxs(Stack, { gap: "md", rounded: "lg", border: "default", padding: "md", children: [_jsx(Toggle, { label: "SMS notifications", checked: notifSmsEnabled, onChange: setNotifSmsEnabled }), notifSmsEnabled && (_jsxs(Stack, { gap: "md", className: NOTIF_CHANNEL_INDENT, children: [_jsx(Select, { label: "Minimum priority to send SMS", options: PRIORITY_OPTIONS, value: notifSmsMinPriority, onValueChange: setNotifSmsMinPriority }), _jsx(Text, { size: "xs", color: "muted", children: "SMS gateway credentials (e.g. Twilio, MSG91) can be configured in the Integrations tab once an SMS provider is connected." })] }))] })] }) }), _jsx(TabsContent, { value: "legal", children: _jsx(SettingsTabForm, { id: "legal", label: "Legal", children: [
|
|
748
778
|
["Terms of Service", termsHtml, setTermsHtml],
|
|
749
779
|
["Privacy Policy", privacyHtml, setPrivacyHtml],
|
|
750
780
|
["Refund Policy", refundHtml, setRefundHtml],
|
|
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
3
3
|
import { Code, useApiMutation } from "@mohasinac/appkit/client";
|
|
4
4
|
import React from "react";
|
|
5
5
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
6
|
-
import { Button, Div, HorizontalRule, Input, Label, Row, Select, SideDrawer, Span, Stack, Text, Textarea, Toggle, useToast } from "../../../ui";
|
|
6
|
+
import { Button, Checkbox, Div, HorizontalRule, Input, Label, Row, Select, SideDrawer, Span, Stack, Text, Textarea, Toggle, useToast } from "../../../ui";
|
|
7
7
|
import { apiClient } from "../../../http";
|
|
8
8
|
import { ADMIN_ENDPOINTS, SUPPORT_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
9
9
|
import { supportTicketUpdateSchema, toRelatedPartiesPayload, } from "../schemas/admin-ops-forms";
|
|
@@ -65,6 +65,12 @@ export function AdminSupportTicketDetailView({ open, onClose, ticketId, subject,
|
|
|
65
65
|
const queryClient = useQueryClient();
|
|
66
66
|
const { showToast } = useToast();
|
|
67
67
|
const [replyBody, setReplyBody] = React.useState("");
|
|
68
|
+
/*
|
|
69
|
+
* Opt-in per reply, and it RESETS to false after every send (see
|
|
70
|
+
* `onSuccess`). A sticky "also email" would quietly turn the exception back
|
|
71
|
+
* into the default — which is the behaviour this whole change removed.
|
|
72
|
+
*/
|
|
73
|
+
const [alsoEmail, setAlsoEmail] = React.useState(false);
|
|
68
74
|
const [draft, setDraft] = React.useState(() => seedTicketDraft(currentStatus, currentPriority, internalNotes, relatedParties));
|
|
69
75
|
React.useEffect(() => {
|
|
70
76
|
if (open) {
|
|
@@ -102,11 +108,12 @@ export function AdminSupportTicketDetailView({ open, onClose, ticketId, subject,
|
|
|
102
108
|
const replyMutation = useApiMutation({
|
|
103
109
|
errorMessage: "Failed to send reply.",
|
|
104
110
|
mutationFn: async () => {
|
|
105
|
-
await apiClient.post(SUPPORT_ENDPOINTS.TICKET_MESSAGES(ticketId), { body: replyBody, newStatus: draft.status });
|
|
111
|
+
await apiClient.post(SUPPORT_ENDPOINTS.TICKET_MESSAGES(ticketId), { body: replyBody, newStatus: draft.status, sendEmail: alsoEmail });
|
|
106
112
|
},
|
|
107
113
|
onSuccess: () => {
|
|
108
|
-
showToast("Reply sent.", "success");
|
|
114
|
+
showToast(alsoEmail ? "Reply sent and emailed." : "Reply sent.", "success");
|
|
109
115
|
setReplyBody("");
|
|
116
|
+
setAlsoEmail(false);
|
|
110
117
|
invalidate();
|
|
111
118
|
onClose();
|
|
112
119
|
},
|
|
@@ -257,7 +264,7 @@ export function AdminSupportTicketDetailView({ open, onClose, ticketId, subject,
|
|
|
257
264
|
setOrderItemsOpen(false);
|
|
258
265
|
},
|
|
259
266
|
});
|
|
260
|
-
return (_jsx(SideDrawer, { isOpen: open, onClose: onClose, title: subject ?? "Support Ticket", children: _jsxs(Stack, { className: `${__P.p4}`, gap: "md", children: [_jsxs(Row, { align: "center", gap: "sm", wrap: true, children: [_jsx(Span, { size: "xs", weight: "medium", className: `inline-flex ${STATUS_COLOR[currentStatus ?? "open"] ?? STATUS_COLOR.open}`, rounded: "full", padding: "pill-sm-tall", children: (currentStatus ?? "open").replace(/_/g, " ") }), _jsx(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: category }), orderId && (_jsxs(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: ["Order: ", orderId] }))] }), _jsxs(Div, { textSize: "sm", color: "muted", children: ["From: ", _jsx(Span, { weight: "medium", color: "primary", children: userDisplayName })] }), description && (_jsxs(Div, { className: `${__P.p3}`, rounded: "lg", surface: "muted", border: "default", children: [_jsx(Text, { className: "mb-1 tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Description" }), _jsx(Text, { className: "whitespace-pre-wrap", color: "primary", size: "sm", children: description })] })), messages.length > 0 && (_jsxs(Stack, { gap: "sm", children: [_jsxs(Text, { className: "tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: ["Messages (", messages.length, ")"] }), _jsx(Stack, { className: `max-h-64 ${__O.yAuto}`, gap: "sm", children: messages.map((msg, i) => (_jsxs(Div, { textSize: "sm", surface: msg.authorRole === "user" ? "default" : "info-surface", className: msg.authorRole === "user" ? "border border-[var(--appkit-color-border)]" : "border border-[var(--appkit-color-info)]", rounded: "lg", padding: "sm", children: [_jsxs(Row, { color: "muted", textSize: "xs", className: "mb-1", align: "center", gap: "sm", children: [_jsx(Span, { weight: "medium", color: "muted", children: ROLE_LABEL[msg.authorRole ?? "user"] ?? msg.authorRole }), msg.createdAt && (_jsx(Span, { children: new Date(msg.createdAt).toLocaleString() }))] }), _jsx(Text, { className: "whitespace-pre-wrap", color: "primary", children: msg.body })] }, msg.id ?? i))) })] })), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { className: "uppercase tracking-wide", color: "muted", size: "xs", weight: "semibold", children: "Reply to user" }), _jsx(Textarea, { value: replyBody, onChange: (e) => setReplyBody(e.target.value), rows: 3, placeholder: "Type a reply\u2026" }), _jsx(Button, { type: "button", variant: "primary", size: "sm", isLoading: replyMutation.isPending, disabled: !replyBody.trim() || !ticketId || replyMutation.isPending, onClick: () => replyMutation.mutate(), children: "Send reply" })] }), _jsx(HorizontalRule, { tone: "subtle", spacing: "comfortable" }), _jsx(RecordStatusTimeline, { entries: statusHistory, truncatedCount: statusHistoryTruncated }), _jsx(HorizontalRule, { tone: "subtle", spacing: "comfortable" }), _jsxs(FormShellContext.Provider, { value: form.shellCtx, children: [_jsx(FormErrorSummary, {}), _jsx(SectionForm, { sections: sections, values: draft, onChange: (partial) => setDraft((d) => ({ ...d, ...partial })), onSubmit: () => {
|
|
267
|
+
return (_jsx(SideDrawer, { isOpen: open, onClose: onClose, title: subject ?? "Support Ticket", children: _jsxs(Stack, { className: `${__P.p4}`, gap: "md", children: [_jsxs(Row, { align: "center", gap: "sm", wrap: true, children: [_jsx(Span, { size: "xs", weight: "medium", className: `inline-flex ${STATUS_COLOR[currentStatus ?? "open"] ?? STATUS_COLOR.open}`, rounded: "full", padding: "pill-sm-tall", children: (currentStatus ?? "open").replace(/_/g, " ") }), _jsx(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: category }), orderId && (_jsxs(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: ["Order: ", orderId] }))] }), _jsxs(Div, { textSize: "sm", color: "muted", children: ["From: ", _jsx(Span, { weight: "medium", color: "primary", children: userDisplayName })] }), description && (_jsxs(Div, { className: `${__P.p3}`, rounded: "lg", surface: "muted", border: "default", children: [_jsx(Text, { className: "mb-1 tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Description" }), _jsx(Text, { className: "whitespace-pre-wrap", color: "primary", size: "sm", children: description })] })), messages.length > 0 && (_jsxs(Stack, { gap: "sm", children: [_jsxs(Text, { className: "tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: ["Messages (", messages.length, ")"] }), _jsx(Stack, { className: `max-h-64 ${__O.yAuto}`, gap: "sm", children: messages.map((msg, i) => (_jsxs(Div, { textSize: "sm", surface: msg.authorRole === "user" ? "default" : "info-surface", className: msg.authorRole === "user" ? "border border-[var(--appkit-color-border)]" : "border border-[var(--appkit-color-info)]", rounded: "lg", padding: "sm", children: [_jsxs(Row, { color: "muted", textSize: "xs", className: "mb-1", align: "center", gap: "sm", children: [_jsx(Span, { weight: "medium", color: "muted", children: ROLE_LABEL[msg.authorRole ?? "user"] ?? msg.authorRole }), msg.createdAt && (_jsx(Span, { children: new Date(msg.createdAt).toLocaleString() }))] }), _jsx(Text, { className: "whitespace-pre-wrap", color: "primary", children: msg.body })] }, msg.id ?? i))) })] })), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { className: "uppercase tracking-wide", color: "muted", size: "xs", weight: "semibold", children: "Reply to user" }), _jsx(Textarea, { value: replyBody, onChange: (e) => setReplyBody(e.target.value), rows: 3, placeholder: "Type a reply\u2026" }), _jsx(Checkbox, { checked: alsoEmail, onChange: (e) => setAlsoEmail(e.target.checked), label: "Also email the user about this reply" }), _jsx(Button, { type: "button", variant: "primary", size: "sm", isLoading: replyMutation.isPending, disabled: !replyBody.trim() || !ticketId || replyMutation.isPending, onClick: () => replyMutation.mutate(), children: "Send reply" })] }), _jsx(HorizontalRule, { tone: "subtle", spacing: "comfortable" }), _jsx(RecordStatusTimeline, { entries: statusHistory, truncatedCount: statusHistoryTruncated }), _jsx(HorizontalRule, { tone: "subtle", spacing: "comfortable" }), _jsxs(FormShellContext.Provider, { value: form.shellCtx, children: [_jsx(FormErrorSummary, {}), _jsx(SectionForm, { sections: sections, values: draft, onChange: (partial) => setDraft((d) => ({ ...d, ...partial })), onSubmit: () => {
|
|
261
268
|
// SectionForm scrolls to the first erroring section and then
|
|
262
269
|
// submits regardless, so the guard lives here.
|
|
263
270
|
form.clearErrors();
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { AboutContentDocument } from "../../about/schemas/firestore";
|
|
6
6
|
export declare const ADMIN_CHECKOUT_BYPASS_FLAG_KEY: "adminCheckoutBypass";
|
|
7
|
-
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", "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"];
|
|
7
|
+
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"];
|
|
8
8
|
export type NotificationType = (typeof NOTIFICATION_TYPE_VALUES)[number];
|
|
9
9
|
/**
|
|
10
10
|
* What a notification points AT. Drives the shared `actionUrl` resolver, so a
|
|
@@ -77,7 +77,7 @@ export type NotificationUpdateInput = Partial<Pick<NotificationDocument, "isRead
|
|
|
77
77
|
export declare const notificationQueryHelpers: {
|
|
78
78
|
readonly byUser: (userId: string) => readonly ["userId", "==", string];
|
|
79
79
|
readonly unread: (userId: string) => readonly [readonly ["userId", "==", string], readonly ["isRead", "==", false]];
|
|
80
|
-
readonly byType: (type: NotificationType) => readonly ["type", "==", "system" | "welcome" | "promotion" | "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" | "account_action" | "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"];
|
|
80
|
+
readonly byType: (type: NotificationType) => readonly ["type", "==", "system" | "welcome" | "promotion" | "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" | "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"];
|
|
81
81
|
};
|
|
82
82
|
export interface TrustBarItem {
|
|
83
83
|
icon: string;
|
|
@@ -447,6 +447,36 @@ export interface SiteSettingsDocument extends BaseDocument {
|
|
|
447
447
|
instagram?: string;
|
|
448
448
|
linkedin?: string;
|
|
449
449
|
};
|
|
450
|
+
/**
|
|
451
|
+
* The outbound-messaging kill switch and spend ceiling.
|
|
452
|
+
*
|
|
453
|
+
* Distinct from `notificationChannels`, which decides WHICH notification
|
|
454
|
+
* types a channel carries. This decides whether the channel runs at all and
|
|
455
|
+
* how much of the day's allowance it may spend — a site-wide posture rather
|
|
456
|
+
* than a per-type routing rule.
|
|
457
|
+
*
|
|
458
|
+
* 🛑 Every field here is PRIVATE. It is triaged into
|
|
459
|
+
* `PRIVATE_SITE_SETTINGS_FIELDS` in the public projection, and must stay
|
|
460
|
+
* there: the ceiling and the remaining headroom are operational detail, and
|
|
461
|
+
* publishing "email is currently off" tells an attacker that password-reset
|
|
462
|
+
* mail is not being watched.
|
|
463
|
+
*/
|
|
464
|
+
messaging?: {
|
|
465
|
+
/** User-facing email. Staff mail (daily digest, payout summary) ignores this. */
|
|
466
|
+
emailEnabled: boolean;
|
|
467
|
+
/** User-facing WhatsApp. Same exemption for staff. */
|
|
468
|
+
whatsappEnabled: boolean;
|
|
469
|
+
/**
|
|
470
|
+
* Reserved sends allowed per channel per day. Applies to staff and
|
|
471
|
+
* transactional mail too — the ceiling exists to stay inside the
|
|
472
|
+
* provider's own limit, and the provider does not exempt anyone.
|
|
473
|
+
*/
|
|
474
|
+
dailyCeiling: {
|
|
475
|
+
email: number;
|
|
476
|
+
whatsapp: number;
|
|
477
|
+
sms: number;
|
|
478
|
+
};
|
|
479
|
+
};
|
|
450
480
|
emailSettings: {
|
|
451
481
|
fromName: string;
|
|
452
482
|
fromEmail: string;
|
|
@@ -49,7 +49,27 @@ export const NOTIFICATION_TYPE_VALUES = [
|
|
|
49
49
|
"promotion",
|
|
50
50
|
"system",
|
|
51
51
|
"welcome",
|
|
52
|
+
/*
|
|
53
|
+
* 🛑 `account_action` means A BAN OR AN UNBAN, and nothing else.
|
|
54
|
+
*
|
|
55
|
+
* It used to also carry support-ticket activity AND scam-report outcomes —
|
|
56
|
+
* three unrelated concerns on one value, which made them impossible to tell
|
|
57
|
+
* apart anywhere downstream. That mattered the moment email became
|
|
58
|
+
* type-gated (see `EMAIL_ELIGIBLE_TYPES`): there was no way to stop emailing
|
|
59
|
+
* "your ticket got a reply" without also silencing "you have been banned",
|
|
60
|
+
* and those two have opposite answers. A banned user has to be told outside
|
|
61
|
+
* the app, because the app is what they just lost.
|
|
62
|
+
*
|
|
63
|
+
* Split 2026-09. If you are tempted to route a fourth concern through this
|
|
64
|
+
* value because it is vaguely account-shaped, add a type instead — the
|
|
65
|
+
* `Record<NotificationType, …>` maps will tell you everywhere that needs an
|
|
66
|
+
* answer.
|
|
67
|
+
*/
|
|
52
68
|
"account_action",
|
|
69
|
+
/** Support ticket created, replied to, or moved status. Bell-only by default — see `EMAIL_ELIGIBLE_TYPES`; staff can opt a single reply into email. */
|
|
70
|
+
"support_ticket_update",
|
|
71
|
+
/** Scam report received, verified, or rejected. Bell-only; the admin side rides the daily digest. */
|
|
72
|
+
"scam_report_update",
|
|
53
73
|
"offer_received",
|
|
54
74
|
"offer_responded",
|
|
55
75
|
"offer_expired",
|
|
@@ -199,6 +219,14 @@ export const DEFAULT_SITE_SETTINGS_DATA = {
|
|
|
199
219
|
smsVerification: false,
|
|
200
220
|
adminCheckoutBypass: false,
|
|
201
221
|
},
|
|
222
|
+
// Mirrors MESSAGE_BUDGET_DEFAULTS — both channels start OFF. A fresh install
|
|
223
|
+
// that has made no decision should be quiet, not mailing real people from a
|
|
224
|
+
// half-configured site.
|
|
225
|
+
messaging: {
|
|
226
|
+
emailEnabled: false,
|
|
227
|
+
whatsappEnabled: false,
|
|
228
|
+
dailyCeiling: { email: 80, whatsapp: 200, sms: 50 },
|
|
229
|
+
},
|
|
202
230
|
emi: {
|
|
203
231
|
enabled: false,
|
|
204
232
|
minOrderValue: 10000,
|
|
@@ -100,6 +100,11 @@ export declare function useResendVerification(options?: {
|
|
|
100
100
|
onSuccess?: (data: JsonValue) => void;
|
|
101
101
|
onError?: (error: Error) => void;
|
|
102
102
|
}): import("@tanstack/react-query").UseMutationResult<JsonValue, Error, void | ResendVerificationData, unknown>;
|
|
103
|
+
/** Thrown when the cooldown refuses — carries the wait so the UI can say it. */
|
|
104
|
+
export declare class AuthMailCooldownError extends Error {
|
|
105
|
+
readonly retryAfterSeconds: number;
|
|
106
|
+
constructor(retryAfterSeconds: number);
|
|
107
|
+
}
|
|
103
108
|
export declare function useForgotPassword(options?: {
|
|
104
109
|
onSuccess?: (data: JsonValue) => void;
|
|
105
110
|
onError?: (error: Error) => void;
|
|
@@ -373,9 +373,51 @@ export function useResendVerification(options) {
|
|
|
373
373
|
onError: options?.onError,
|
|
374
374
|
});
|
|
375
375
|
}
|
|
376
|
+
/**
|
|
377
|
+
* Ask the server whether this address may be sent an auth mail right now.
|
|
378
|
+
*
|
|
379
|
+
* Firebase's own auth mail goes browser → Firebase with no server hop, so
|
|
380
|
+
* nothing on our side can see it — including `applyRateLimit`. This gate is the
|
|
381
|
+
* only throttle these flows have, and it is deliberately advisory: a `false`
|
|
382
|
+
* from the network, a thrown request, or a missing route all resolve to
|
|
383
|
+
* "allowed", because a user locked out of their account must never be blocked
|
|
384
|
+
* from requesting a reset by our own infrastructure failing.
|
|
385
|
+
*
|
|
386
|
+
* See `src/app/api/auth/mail-gate/route.ts` for why this is an abuse guard
|
|
387
|
+
* rather than a security boundary.
|
|
388
|
+
*/
|
|
389
|
+
async function requestAuthMailPermission(email, purpose) {
|
|
390
|
+
try {
|
|
391
|
+
const res = await apiClient.post(AUTH_ENDPOINTS.MAIL_GATE, { email, purpose });
|
|
392
|
+
const data = (res?.data ?? res);
|
|
393
|
+
return {
|
|
394
|
+
allowed: data?.allowed !== false,
|
|
395
|
+
retryAfterSeconds: data?.retryAfterSeconds ?? 0,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
// Normalised then deliberately discarded: an unreachable gate is not an
|
|
400
|
+
// incident worth reporting, it is the documented fail-open path. The user
|
|
401
|
+
// still gets their reset mail; only the cooldown is skipped.
|
|
402
|
+
void normalizeError(err);
|
|
403
|
+
return { allowed: true, retryAfterSeconds: 0 };
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
/** Thrown when the cooldown refuses — carries the wait so the UI can say it. */
|
|
407
|
+
export class AuthMailCooldownError extends Error {
|
|
408
|
+
constructor(retryAfterSeconds) {
|
|
409
|
+
const mins = Math.max(1, Math.ceil(retryAfterSeconds / 60));
|
|
410
|
+
super(`We've already sent that recently — please check your inbox, including spam. You can request another in ${mins} minute${mins === 1 ? "" : "s"}.`);
|
|
411
|
+
this.name = "AuthMailCooldownError";
|
|
412
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
376
415
|
export function useForgotPassword(options) {
|
|
377
416
|
return useMutation({
|
|
378
417
|
mutationFn: async (data) => {
|
|
418
|
+
const gate = await requestAuthMailPermission(data.email, "password_reset");
|
|
419
|
+
if (!gate.allowed)
|
|
420
|
+
throw new AuthMailCooldownError(gate.retryAfterSeconds);
|
|
379
421
|
await getClientAuthProvider().sendPasswordResetEmail(data.email);
|
|
380
422
|
return { success: true };
|
|
381
423
|
},
|
|
@@ -18,6 +18,7 @@ import { addressesRepository } from "../../addresses/repository/addresses.reposi
|
|
|
18
18
|
import { siteSettingsRepository } from "../../admin/repository/site-settings.repository";
|
|
19
19
|
import { sendWhatsAppBusinessMessage } from "../../whatsapp-bot/helpers/whatsapp";
|
|
20
20
|
import { isChannelHealthy, withChannelRetry } from "../../../_internal/server/notifications/channel-health";
|
|
21
|
+
import { guardSend, logSuppressedSend } from "../../../_internal/server/notifications/send-guard";
|
|
21
22
|
import { CHECKOUT_VALUE_OTP_EXPIRY_MS, CHECKOUT_VALUE_OTP_MAX_ATTEMPTS, hashOtp, generateOtpCode, checkoutValueOtpRef, enforceCheckoutValueOtpRateLimit, buildCheckoutValueOtpMessage, } from "../../auth/checkout-value-otp";
|
|
22
23
|
function maskEmail(userEmail) {
|
|
23
24
|
const [local, domain] = userEmail.split("@");
|
|
@@ -82,6 +83,20 @@ export async function sendCheckoutValueOtp(userId, userEmail, channel = "email",
|
|
|
82
83
|
createdAt: new Date(),
|
|
83
84
|
});
|
|
84
85
|
const message = buildCheckoutValueOtpMessage(code, siteName);
|
|
86
|
+
// Same guard as the email branch below, same reasoning: transactional, so
|
|
87
|
+
// the kill switch does not apply (the buyer cannot check out without the
|
|
88
|
+
// code), but the daily ceiling does.
|
|
89
|
+
const waGuard = {
|
|
90
|
+
channel: "whatsapp",
|
|
91
|
+
feature: "checkout_value_otp",
|
|
92
|
+
audience: "user",
|
|
93
|
+
transactional: true,
|
|
94
|
+
};
|
|
95
|
+
const waDecision = await guardSend(waGuard);
|
|
96
|
+
if (!waDecision.allow) {
|
|
97
|
+
logSuppressedSend(waGuard, waDecision);
|
|
98
|
+
throw new ValidationError("We couldn't send the verification code via WhatsApp — please try again, or send it by email instead.");
|
|
99
|
+
}
|
|
85
100
|
try {
|
|
86
101
|
const sent = await withChannelRetry("whatsapp", async () => {
|
|
87
102
|
const ok = await sendWhatsAppBusinessMessage({ toPhone: phone, message, phoneNumberId, accessToken });
|
|
@@ -126,6 +141,23 @@ export async function sendCheckoutValueOtp(userId, userEmail, channel = "email",
|
|
|
126
141
|
<p style="color:#888;font-size:12px">This code expires in 10 minutes. If you did not request this, please ignore this email.</p>
|
|
127
142
|
</div>
|
|
128
143
|
`,
|
|
144
|
+
}, {
|
|
145
|
+
channel: "email",
|
|
146
|
+
feature: "checkout_value_otp",
|
|
147
|
+
audience: "user",
|
|
148
|
+
/*
|
|
149
|
+
* The strongest case for `transactional` in the codebase: the buyer
|
|
150
|
+
* is mid-checkout above the high-value threshold and CANNOT complete
|
|
151
|
+
* the order without this code. Suppressing it does not quieten the
|
|
152
|
+
* site, it breaks checkout — so it ignores the kill switch.
|
|
153
|
+
*
|
|
154
|
+
* It still counts against the daily ceiling, which is the honest
|
|
155
|
+
* behaviour: if the provider is going to start rejecting, pretending
|
|
156
|
+
* otherwise here just moves the failure somewhere less legible. The
|
|
157
|
+
* 15-minute per-user cooldown in `checkout-value-otp.ts` already
|
|
158
|
+
* bounds how much of the allowance one buyer can consume.
|
|
159
|
+
*/
|
|
160
|
+
transactional: true,
|
|
129
161
|
});
|
|
130
162
|
if (error)
|
|
131
163
|
throw new Error(String(error));
|