@mohasinac/appkit 4.1.3 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/_internal/server/features/checkout/actions.d.ts +15 -0
  2. package/dist/_internal/server/features/checkout/actions.js +245 -182
  3. package/dist/_internal/server/functions/scheduled.d.ts +2 -1
  4. package/dist/_internal/server/functions/scheduled.js +9 -1
  5. package/dist/_internal/server/jobs/core/jobRunners.js +10 -0
  6. package/dist/_internal/server/jobs/core/newsletterExport.d.ts +15 -0
  7. package/dist/_internal/server/jobs/core/newsletterExport.js +43 -0
  8. package/dist/_internal/server/jobs/core/onOrderStatusChange.d.ts +2 -0
  9. package/dist/_internal/server/jobs/core/onOrderStatusChange.js +1 -0
  10. package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +1 -0
  11. package/dist/_internal/server/jobs/core/pendingOrderTimeout.js +1 -0
  12. package/dist/_internal/server/jobs/core/revenueRollup.d.ts +13 -0
  13. package/dist/_internal/server/jobs/core/revenueRollup.js +22 -0
  14. package/dist/_internal/server/jobs/core/whatsappNotify.d.ts +35 -0
  15. package/dist/_internal/server/jobs/core/whatsappNotify.js +82 -0
  16. package/dist/_internal/server/jobs/handlers/index.d.ts +1 -0
  17. package/dist/_internal/server/jobs/handlers/index.js +1 -0
  18. package/dist/_internal/server/jobs/handlers/revenueRollup.d.ts +2 -0
  19. package/dist/_internal/server/jobs/handlers/revenueRollup.js +2 -0
  20. package/dist/_internal/shared/features/checkout/config.d.ts +7 -0
  21. package/dist/_internal/shared/features/checkout/config.js +7 -0
  22. package/dist/_internal/shared/fees/calculator.d.ts +26 -0
  23. package/dist/_internal/shared/fees/calculator.js +25 -0
  24. package/dist/features/about/components/FeesView.js +18 -0
  25. package/dist/features/admin/actions/notification-actions.d.ts +4 -1
  26. package/dist/features/admin/actions/notification-actions.js +56 -10
  27. package/dist/features/admin/components/AdminAdsView.js +1 -1
  28. package/dist/features/admin/components/AdminNewsletterView.js +42 -11
  29. package/dist/features/admin/components/AdminSiteSettingsView.js +83 -6
  30. package/dist/features/admin/repository/analytics-rollup.repository.d.ts +25 -0
  31. package/dist/features/admin/repository/analytics-rollup.repository.js +31 -0
  32. package/dist/features/admin/repository/site-settings.repository.js +8 -1
  33. package/dist/features/admin/schemas/firestore.d.ts +41 -0
  34. package/dist/features/admin/schemas/firestore.js +62 -24
  35. package/dist/features/layout/BackToTop.d.ts +9 -5
  36. package/dist/features/layout/BackToTop.js +17 -21
  37. package/dist/features/orders/actions/refund-actions.js +1 -0
  38. package/dist/features/orders/schemas/firestore.d.ts +14 -0
  39. package/dist/features/products/constants/action-defs.d.ts +28 -3
  40. package/dist/features/products/constants/action-defs.js +76 -1
  41. package/dist/features/seller/components/SellerOrdersView.js +1 -1
  42. package/dist/features/whatsapp-bot/helpers/whatsapp.d.ts +10 -1
  43. package/dist/features/whatsapp-bot/helpers/whatsapp.js +47 -0
  44. package/dist/features/whatsapp-bot/types/index.d.ts +19 -0
  45. package/dist/index.d.ts +4 -2
  46. package/dist/index.js +4 -2
  47. package/dist/next/routing/route-map.d.ts +2 -0
  48. package/dist/next/routing/route-map.js +1 -0
  49. package/dist/repositories/index.d.ts +2 -0
  50. package/dist/repositories/index.js +1 -0
  51. package/dist/seed/site-settings-seed-data.js +7 -0
  52. package/dist/server.d.ts +2 -2
  53. package/dist/server.js +2 -2
  54. package/package.json +1 -1
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Core: async newsletter-subscriber CSV export (`newsletterExport` job).
3
+ *
4
+ * `GET /api/admin/newsletter/export` used to build the full CSV in-process,
5
+ * synchronously, for up to 10,000 subscribers — a real 10s-timeout risk on
6
+ * Vercel Hobby as the list grows (CLAUDE.md Rule #6). This runner does the
7
+ * same work inside a Firebase Function instead and writes the CSV text onto
8
+ * the job's `result.data.csv` field — small enough for a Firestore doc at
9
+ * realistic subscriber counts, and avoids routing through Storage (which
10
+ * would need a `/media/...` proxy URL per the Media Architecture rules
11
+ * rather than a raw signed URL).
12
+ */
13
+ import { newsletterRepository } from "../../../../repositories";
14
+ import { sortBy } from "../../../../constants/sort";
15
+ import { COMMON_FIELDS } from "../../../../constants/field-names";
16
+ function escape(value) {
17
+ const s = String(value ?? "");
18
+ return s.includes(",") || s.includes('"') || s.includes("\n")
19
+ ? `"${s.replace(/"/g, '""')}"`
20
+ : s;
21
+ }
22
+ function csvRow(cols) {
23
+ return cols.map(escape).join(",");
24
+ }
25
+ export async function runNewsletterExport(ctx) {
26
+ const result = await newsletterRepository.list({
27
+ sorts: sortBy(COMMON_FIELDS.CREATED_AT),
28
+ page: "1",
29
+ pageSize: "10000",
30
+ });
31
+ const rows = result.data;
32
+ const header = csvRow(["id", "email", "status", "source", "subscribedAt", "createdAt"]);
33
+ const dataRows = rows.map((s) => csvRow([s["id"], s["email"] ?? "", s["status"] ?? "", s["source"] ?? "", s["subscribedAt"] ?? "", s["createdAt"] ?? ""]));
34
+ const csv = [header, ...dataRows].join("\r\n");
35
+ ctx.logger.info("newsletterExport: complete", { subscriberCount: rows.length });
36
+ return {
37
+ summary: { total: rows.length, succeeded: rows.length, skipped: 0, failed: 0 },
38
+ succeeded: [],
39
+ skipped: [],
40
+ failed: [],
41
+ data: { csv, subscriberCount: rows.length },
42
+ };
43
+ }
@@ -6,6 +6,8 @@ export type OrderAfter = {
6
6
  userEmail: string;
7
7
  productTitle: string;
8
8
  trackingNumber?: string;
9
+ /** Buyer opted into the ₹10 WhatsApp order-updates addon at checkout. */
10
+ whatsappNotifyAddon?: boolean;
9
11
  };
10
12
  export type OrderBefore = {
11
13
  status: OrderStatus;
@@ -56,6 +56,7 @@ export async function handleOrderStatusChange(input, ctx) {
56
56
  relatedId: orderId,
57
57
  relatedType: "order",
58
58
  userEmail,
59
+ orderWhatsappAddonPaid: after.whatsappNotifyAddon === true,
59
60
  });
60
61
  try {
61
62
  await getAdminRealtimeDb().ref(`notifications/${after.userId}`).push({
@@ -51,6 +51,7 @@ export async function runPaymentWindowTimeout(ctx) {
51
51
  message: ORDER_MESSAGES.CANCELLED_PAYMENT_WINDOW_MESSAGE(entry.data.productTitle),
52
52
  relatedId: entry.id,
53
53
  relatedType: "order",
54
+ orderWhatsappAddonPaid: entry.data.whatsappNotifyAddon === true,
54
55
  })));
55
56
  ctx.logger.info("Payment window timeout sweep complete", {
56
57
  scanned: expired.length,
@@ -49,6 +49,7 @@ export async function runPendingOrderTimeout(ctx) {
49
49
  message: ORDER_MESSAGES.CANCELLED_TIMEOUT_MESSAGE(entry.data.productTitle, timeoutHours),
50
50
  relatedId: entry.id,
51
51
  relatedType: "order",
52
+ orderWhatsappAddonPaid: entry.data.whatsappNotifyAddon === true,
52
53
  })));
53
54
  ctx.logger.info("Pending order timeout complete", { cancelled: timedOut.length, restored });
54
55
  }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Core: daily revenue rollup.
3
+ *
4
+ * `GET /api/admin/dashboard` used to sum `totalPrice` over the FULL
5
+ * `delivered`-status orders list on every request (`orderRepository.findByStatus`
6
+ * has no bound) — a real, growing Firestore-read cost and a 10s-timeout risk
7
+ * on Vercel Hobby (CLAUDE.md Rule #6) as order volume increases. This job
8
+ * pre-computes the same aggregate once a day and writes it to the
9
+ * `analytics/dashboardRollup` singleton doc; the API route now reads that
10
+ * single doc instead of scanning the collection.
11
+ */
12
+ import type { JobContext } from "../runtime/types";
13
+ export declare function runRevenueRollup(ctx: JobContext): Promise<void>;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Core: daily revenue rollup.
3
+ *
4
+ * `GET /api/admin/dashboard` used to sum `totalPrice` over the FULL
5
+ * `delivered`-status orders list on every request (`orderRepository.findByStatus`
6
+ * has no bound) — a real, growing Firestore-read cost and a 10s-timeout risk
7
+ * on Vercel Hobby (CLAUDE.md Rule #6) as order volume increases. This job
8
+ * pre-computes the same aggregate once a day and writes it to the
9
+ * `analytics/dashboardRollup` singleton doc; the API route now reads that
10
+ * single doc instead of scanning the collection.
11
+ */
12
+ import { orderRepository } from "../../../../repositories";
13
+ import { analyticsRollupRepository } from "../../../../repositories";
14
+ export async function runRevenueRollup(ctx) {
15
+ const deliveredOrders = await orderRepository.findByStatus("delivered").catch(() => []);
16
+ const totalRevenue = deliveredOrders.reduce((sum, order) => sum + (Number(order.totalPrice ?? 0) || 0), 0);
17
+ await analyticsRollupRepository.setDashboardRollup({
18
+ totalRevenue,
19
+ deliveredOrderCount: deliveredOrders.length,
20
+ });
21
+ ctx.logger.info("revenueRollup: complete", { totalRevenue, deliveredOrderCount: deliveredOrders.length });
22
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Core: async WhatsApp notification dispatch (`whatsappNotify` job).
3
+ *
4
+ * `sendNotification()` (appkit/src/features/admin/actions/notification-actions.ts)
5
+ * no longer calls the Meta Cloud API synchronously in-request — it enqueues
6
+ * this job instead (CLAUDE.md Rule #6 async job primitive) and returns
7
+ * immediately. This runner does the actual send, with a bounded retry, and
8
+ * writes the outcome back onto `notifications/{notificationId}` so admins
9
+ * have visibility beyond the `jobs/{jobId}` doc itself.
10
+ *
11
+ * Retry note: `sendWhatsAppTemplateMessage`/`sendWhatsAppBusinessMessage`
12
+ * return a plain boolean (not a status code), so this runner cannot cleanly
13
+ * distinguish a transient 5xx from a terminal 4xx (bad token, unknown
14
+ * template) the way a status-code-aware client could. It retries any
15
+ * failure up to MAX_ATTEMPTS with a short backoff — an acceptable
16
+ * simplification since this runs off the critical path, well inside the
17
+ * Firebase Function's 300s budget, and a terminal error just burns two
18
+ * extra fast failures before giving up.
19
+ */
20
+ import type { JobContext } from "../runtime/types";
21
+ import type { JobRunResult } from "./jobRunners";
22
+ export interface WhatsAppNotifyPayload {
23
+ toPhone: string;
24
+ title: string;
25
+ message: string;
26
+ type: string;
27
+ /** Approved Meta template name for this notification type, if configured. */
28
+ templateName?: string;
29
+ /** BCP-47 language code the template was approved in. Defaults to "en". */
30
+ templateLanguage?: string;
31
+ notificationId: string;
32
+ phoneNumberId: string;
33
+ accessToken: string;
34
+ }
35
+ export declare function runWhatsAppNotify(payload: WhatsAppNotifyPayload, ctx: JobContext): Promise<JobRunResult>;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Core: async WhatsApp notification dispatch (`whatsappNotify` job).
3
+ *
4
+ * `sendNotification()` (appkit/src/features/admin/actions/notification-actions.ts)
5
+ * no longer calls the Meta Cloud API synchronously in-request — it enqueues
6
+ * this job instead (CLAUDE.md Rule #6 async job primitive) and returns
7
+ * immediately. This runner does the actual send, with a bounded retry, and
8
+ * writes the outcome back onto `notifications/{notificationId}` so admins
9
+ * have visibility beyond the `jobs/{jobId}` doc itself.
10
+ *
11
+ * Retry note: `sendWhatsAppTemplateMessage`/`sendWhatsAppBusinessMessage`
12
+ * return a plain boolean (not a status code), so this runner cannot cleanly
13
+ * distinguish a transient 5xx from a terminal 4xx (bad token, unknown
14
+ * template) the way a status-code-aware client could. It retries any
15
+ * failure up to MAX_ATTEMPTS with a short backoff — an acceptable
16
+ * simplification since this runs off the critical path, well inside the
17
+ * Firebase Function's 300s budget, and a terminal error just burns two
18
+ * extra fast failures before giving up.
19
+ */
20
+ import { normalizeError } from "../../../../errors/normalize";
21
+ import { notificationRepository } from "../../../../features/admin/repository/notification.repository";
22
+ import { sendWhatsAppTemplateMessage, sendWhatsAppBusinessMessage, } from "../../../../features/whatsapp-bot/helpers/whatsapp";
23
+ const MAX_ATTEMPTS = 3;
24
+ const RETRY_DELAY_MS = 1500;
25
+ function delay(ms) {
26
+ return new Promise((resolve) => setTimeout(resolve, ms));
27
+ }
28
+ export async function runWhatsAppNotify(payload, ctx) {
29
+ const { toPhone, title, message, templateName, templateLanguage, notificationId, phoneNumberId, accessToken } = payload;
30
+ let sent = false;
31
+ let lastError;
32
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS && !sent; attempt++) {
33
+ try {
34
+ sent = templateName
35
+ ? await sendWhatsAppTemplateMessage({
36
+ toPhone,
37
+ phoneNumberId,
38
+ accessToken,
39
+ templateName,
40
+ languageCode: templateLanguage ?? "en",
41
+ bodyParams: [title, message],
42
+ })
43
+ : await sendWhatsAppBusinessMessage({
44
+ toPhone,
45
+ phoneNumberId,
46
+ accessToken,
47
+ message: `*${title}*\n${message}`,
48
+ });
49
+ if (!sent)
50
+ lastError = "Meta WhatsApp API returned a non-OK response";
51
+ }
52
+ catch (err) {
53
+ void normalizeError(err);
54
+ lastError = err instanceof Error ? err.message : String(err);
55
+ }
56
+ if (!sent && attempt < MAX_ATTEMPTS) {
57
+ ctx.logger.warn("whatsappNotify: send attempt failed, retrying", { notificationId, attempt, error: lastError ?? "" });
58
+ await delay(RETRY_DELAY_MS * attempt);
59
+ }
60
+ }
61
+ if (!templateName) {
62
+ ctx.logger.warn("whatsappNotify: no approved template configured for this notification type — sent as free-form text, which Meta rejects outside the 24h customer-service window", { notificationId });
63
+ }
64
+ await notificationRepository
65
+ .update(notificationId, { whatsappStatus: sent ? "sent" : "failed" })
66
+ .catch((err) => {
67
+ void normalizeError(err);
68
+ ctx.logger.warn("whatsappNotify: failed to write back whatsappStatus onto notification doc", {
69
+ notificationId,
70
+ error: err instanceof Error ? err.message : String(err),
71
+ });
72
+ });
73
+ if (!sent) {
74
+ throw new Error(lastError ?? "WhatsApp send failed after retries");
75
+ }
76
+ return {
77
+ summary: { total: 1, succeeded: 1, skipped: 0, failed: 0 },
78
+ succeeded: [notificationId],
79
+ skipped: [],
80
+ failed: [],
81
+ };
82
+ }
@@ -28,6 +28,7 @@ export { paymentWindowTimeoutHandler } from "./paymentWindowTimeout";
28
28
  export { hardBanReinstatementHandler } from "./hardBanReinstatement";
29
29
  export { paymentReviewAutoApproveHandler } from "./paymentReviewAutoApprove";
30
30
  export { productStatsSyncHandler } from "./productStatsSync";
31
+ export { revenueRollupHandler } from "./revenueRollup";
31
32
  export { positionsReconcileHandler } from "./positionsReconcile";
32
33
  export { payoutBatchHandler } from "./payoutBatch";
33
34
  export { weeklyPayoutEligibilityHandler } from "./weeklyPayoutEligibility";
@@ -28,6 +28,7 @@ export { paymentWindowTimeoutHandler } from "./paymentWindowTimeout";
28
28
  export { hardBanReinstatementHandler } from "./hardBanReinstatement";
29
29
  export { paymentReviewAutoApproveHandler } from "./paymentReviewAutoApprove";
30
30
  export { productStatsSyncHandler } from "./productStatsSync";
31
+ export { revenueRollupHandler } from "./revenueRollup";
31
32
  export { positionsReconcileHandler } from "./positionsReconcile";
32
33
  export { payoutBatchHandler } from "./payoutBatch";
33
34
  export { weeklyPayoutEligibilityHandler } from "./weeklyPayoutEligibility";
@@ -0,0 +1,2 @@
1
+ import type { ScheduleHandler } from "../runtime/types";
2
+ export declare const revenueRollupHandler: ScheduleHandler;
@@ -0,0 +1,2 @@
1
+ import { runRevenueRollup } from "../core/revenueRollup";
2
+ export const revenueRollupHandler = runRevenueRollup;
@@ -2,6 +2,13 @@ export declare const CHECKOUT_DEFAULT_COMMISSIONS: {
2
2
  readonly codDepositPercent: 10;
3
3
  readonly codHandlingFeeMin: 200;
4
4
  readonly codHandlingFeePercent: 10;
5
+ readonly whatsappNotifyFeeEnabled: false;
6
+ readonly whatsappNotifyFee: 10;
7
+ readonly giftWrapFeeEnabled: false;
8
+ readonly giftWrapFee: 49;
9
+ readonly shipmentProtectionFeeEnabled: false;
10
+ readonly shipmentProtectionFeePercent: 2;
11
+ readonly shipmentProtectionFeeMin: 30;
5
12
  readonly sellerShippingFixed: 50;
6
13
  readonly platformShippingPercent: 10;
7
14
  readonly platformShippingFixedMin: 50;
@@ -2,6 +2,13 @@ export const CHECKOUT_DEFAULT_COMMISSIONS = {
2
2
  codDepositPercent: 10,
3
3
  codHandlingFeeMin: 200,
4
4
  codHandlingFeePercent: 10,
5
+ whatsappNotifyFeeEnabled: false,
6
+ whatsappNotifyFee: 10,
7
+ giftWrapFeeEnabled: false,
8
+ giftWrapFee: 49,
9
+ shipmentProtectionFeeEnabled: false,
10
+ shipmentProtectionFeePercent: 2,
11
+ shipmentProtectionFeeMin: 30,
5
12
  sellerShippingFixed: 50,
6
13
  platformShippingPercent: 10,
7
14
  platformShippingFixedMin: 50,
@@ -50,6 +50,32 @@ export interface CodHandlingFeeRates {
50
50
  }
51
51
  /** COD handling fee charged to the buyer: max(fixed floor, subtotal × percent). */
52
52
  export declare function computeCodHandlingFee(subtotal: number, rates: CodHandlingFeeRates): number;
53
+ export interface WhatsAppNotifyFeeRates {
54
+ /** Admin master toggle — the addon isn't charged (even if the buyer selected it) unless this is true. */
55
+ whatsappNotifyFeeEnabled?: boolean;
56
+ /** Flat rupee fee charged when the buyer opts in. Falls back to ₹10. */
57
+ whatsappNotifyFee?: number;
58
+ }
59
+ /** Flat WhatsApp order-updates addon fee — charged only when the buyer opted in AND the admin has the addon enabled. */
60
+ export declare function computeWhatsAppNotifyFee(addonSelected: boolean, rates: WhatsAppNotifyFeeRates): number;
61
+ export interface GiftWrapFeeRates {
62
+ /** Admin master toggle — the addon isn't charged (even if the buyer selected it) unless this is true. */
63
+ giftWrapFeeEnabled?: boolean;
64
+ /** Flat rupee fee charged when the buyer opts in. Falls back to ₹49. */
65
+ giftWrapFee?: number;
66
+ }
67
+ /** Flat gift-wrap addon fee — charged only when the buyer opted in AND the admin has the addon enabled. */
68
+ export declare function computeGiftWrapFee(addonSelected: boolean, rates: GiftWrapFeeRates): number;
69
+ export interface ShipmentProtectionFeeRates {
70
+ /** Admin master toggle — the addon isn't charged (even if the buyer selected it) unless this is true. */
71
+ shipmentProtectionFeeEnabled?: boolean;
72
+ /** Percent of subtotal. Falls back to 2%. */
73
+ shipmentProtectionFeePercent?: number;
74
+ /** Rupee floor. Falls back to ₹30. */
75
+ shipmentProtectionFeeMin?: number;
76
+ }
77
+ /** Shipment-protection addon fee: max(fixed floor, subtotal × percent) — charged only when the buyer opted in AND the admin has the addon enabled. */
78
+ export declare function computeShipmentProtectionFee(subtotal: number, addonSelected: boolean, rates: ShipmentProtectionFeeRates): number;
53
79
  /**
54
80
  * P-8 GST — buyer-facing product tax, distinct from the platform-commission
55
81
  * GST above. Intra-state orders split the rate evenly between CGST + SGST;
@@ -38,6 +38,31 @@ export function computeCodHandlingFee(subtotal, rates) {
38
38
  const percentFee = roundRupees(subtotal * (percent / 100));
39
39
  return Math.max(min, percentFee);
40
40
  }
41
+ const DEFAULT_WHATSAPP_NOTIFY_FEE = 10;
42
+ /** Flat WhatsApp order-updates addon fee — charged only when the buyer opted in AND the admin has the addon enabled. */
43
+ export function computeWhatsAppNotifyFee(addonSelected, rates) {
44
+ if (!addonSelected || !rates.whatsappNotifyFeeEnabled)
45
+ return 0;
46
+ return rates.whatsappNotifyFee ?? DEFAULT_WHATSAPP_NOTIFY_FEE;
47
+ }
48
+ const DEFAULT_GIFT_WRAP_FEE = 49;
49
+ /** Flat gift-wrap addon fee — charged only when the buyer opted in AND the admin has the addon enabled. */
50
+ export function computeGiftWrapFee(addonSelected, rates) {
51
+ if (!addonSelected || !rates.giftWrapFeeEnabled)
52
+ return 0;
53
+ return rates.giftWrapFee ?? DEFAULT_GIFT_WRAP_FEE;
54
+ }
55
+ const DEFAULT_SHIPMENT_PROTECTION_FEE_PERCENT = 2;
56
+ const DEFAULT_SHIPMENT_PROTECTION_FEE_MIN = 30;
57
+ /** Shipment-protection addon fee: max(fixed floor, subtotal × percent) — charged only when the buyer opted in AND the admin has the addon enabled. */
58
+ export function computeShipmentProtectionFee(subtotal, addonSelected, rates) {
59
+ if (!addonSelected || !rates.shipmentProtectionFeeEnabled)
60
+ return 0;
61
+ const percent = rates.shipmentProtectionFeePercent ?? DEFAULT_SHIPMENT_PROTECTION_FEE_PERCENT;
62
+ const min = rates.shipmentProtectionFeeMin ?? DEFAULT_SHIPMENT_PROTECTION_FEE_MIN;
63
+ const percentFee = roundRupees(subtotal * (percent / 100));
64
+ return Math.max(min, percentFee);
65
+ }
41
66
  export function calculateGst(rate, intraState, taxableAmount) {
42
67
  const gstAmount = roundRupees(taxableAmount * (rate / 100));
43
68
  if (intraState) {
@@ -65,6 +65,24 @@ export async function FeesView({} = {}) {
65
65
  who: t("paidByBuyer"),
66
66
  note: t("codHandlingFeeNote"),
67
67
  },
68
+ {
69
+ category: t("whatsappNotifyFeeTitle"),
70
+ rate: t("whatsappNotifyFeeRate"),
71
+ who: t("paidByBuyer"),
72
+ note: t("whatsappNotifyFeeNote"),
73
+ },
74
+ {
75
+ category: t("giftWrapFeeTitle"),
76
+ rate: t("giftWrapFeeRate"),
77
+ who: t("paidByBuyer"),
78
+ note: t("giftWrapFeeNote"),
79
+ },
80
+ {
81
+ category: t("shipmentProtectionFeeTitle"),
82
+ rate: t("shipmentProtectionFeeRate"),
83
+ who: t("paidByBuyer"),
84
+ note: t("shipmentProtectionFeeNote"),
85
+ },
68
86
  ];
69
87
  const OFFER_PAYOUT_ROWS = [
70
88
  { label: t("grossSale"), example: "₹1,000" },
@@ -14,11 +14,14 @@ export interface SendNotificationInput extends NotificationCreateInput {
14
14
  userPhone?: string;
15
15
  /** Pre-rendered email HTML — falls back to a plain-text <p> when absent. */
16
16
  emailHtml?: string;
17
+ /** Buyer paid the ₹10 WhatsApp order-updates addon — enables WhatsApp channel for this notification even when the user has no standing WhatsApp subscription. */
18
+ orderWhatsappAddonPaid?: boolean;
17
19
  }
18
20
  export interface SendNotificationResult {
19
21
  notification: NotificationDocument;
20
22
  email: "sent" | "skipped" | "failed";
21
- whatsapp: "sent" | "skipped" | "failed";
23
+ /** WhatsApp dispatch is now async (enqueued as a `whatsappNotify` job) — "queued" replaces "sent" as the immediate outcome; the real delivery result lands on `notification.whatsappStatus`. */
24
+ whatsapp: "queued" | "skipped" | "failed";
22
25
  }
23
26
  /**
24
27
  * Create an in-app notification and fan out to enabled external channels.
@@ -3,10 +3,39 @@ import { notificationRepository } from "../repository/notification.repository";
3
3
  import { siteSettingsRepository } from "../repository/site-settings.repository";
4
4
  import { userRepository } from "../../auth/repository/user.repository";
5
5
  import { createResendProvider } from "../../../providers/email-resend/provider";
6
- import { sendWhatsAppBusinessMessage } from "../../whatsapp-bot/helpers/whatsapp";
6
+ import { enqueueJob } from "../../jobs/actions/enqueue-job";
7
7
  import { serverLogger } from "../../../monitoring";
8
8
  import { decryptPii } from "../../../security/index";
9
9
  import { DEFAULT_NOTIFICATION_CHANNELS, meetsMinPriority, } from "../schemas/firestore";
10
+ /**
11
+ * Notification types whose WhatsApp send is gated behind the buyer's
12
+ * per-order ₹10 WhatsApp-updates addon (`OrderDocument.whatsappNotifyAddon`)
13
+ * — see `orderWhatsappAddonPaid` on `SendNotificationInput`. Every other
14
+ * type (bid_won, offers, scam reports, support tickets, admin alerts, …)
15
+ * keeps the original two-gate behavior (admin channel enabled + user hasn't
16
+ * opted out) unchanged.
17
+ */
18
+ const ORDER_GATED_TYPES = new Set([
19
+ "order_placed",
20
+ "order_confirmed",
21
+ "order_shipped",
22
+ "order_delivered",
23
+ "order_cancelled",
24
+ "refund_initiated",
25
+ ]);
26
+ /** Notification type → the flat `SiteSettingsCredentials` field holding its approved Meta template name. */
27
+ const WHATSAPP_TEMPLATE_FIELD = {
28
+ order_placed: "whatsappTemplateOrderPlaced",
29
+ order_confirmed: "whatsappTemplateOrderConfirmed",
30
+ order_shipped: "whatsappTemplateOrderShipped",
31
+ order_delivered: "whatsappTemplateOrderDelivered",
32
+ order_cancelled: "whatsappTemplateOrderCancelled",
33
+ refund_initiated: "whatsappTemplateRefundInitiated",
34
+ };
35
+ function resolveWhatsAppTemplateName(type, creds) {
36
+ const field = WHATSAPP_TEMPLATE_FIELD[type];
37
+ return (field ? creds[field] : undefined)?.trim() ?? "";
38
+ }
10
39
  export async function markNotificationRead(id) {
11
40
  await notificationRepository.markAsRead(id);
12
41
  }
@@ -121,28 +150,45 @@ export async function sendNotification(input) {
121
150
  }
122
151
  }
123
152
  }
124
- // WhatsApp channel — admin enabled AND user hasn't opted out
153
+ // WhatsApp channel — admin enabled AND user hasn't opted out AND (for
154
+ // order-lifecycle types) the buyer paid the per-order WhatsApp addon.
125
155
  let whatsappStatus = "skipped";
126
156
  if (channels.whatsapp.enabled &&
127
157
  userChannelPrefs.whatsapp !== false &&
128
158
  resolvedPhone &&
129
159
  meetsMinPriority(priority, channels.whatsapp.minPriority) &&
130
- (!channels.whatsapp.types?.length || channels.whatsapp.types.includes(type))) {
160
+ (!channels.whatsapp.types?.length || channels.whatsapp.types.includes(type)) &&
161
+ (!ORDER_GATED_TYPES.has(type) || input.orderWhatsappAddonPaid === true)) {
131
162
  const phoneNumberId = creds.whatsappPhoneNumberId?.trim() ?? "";
132
163
  const accessToken = creds.whatsappCloudApiToken?.trim() ?? "";
133
164
  if (phoneNumberId && accessToken) {
134
165
  try {
135
- const ok = await sendWhatsAppBusinessMessage({
136
- toPhone: resolvedPhone,
137
- message: `*${title}*\n${message}`,
138
- phoneNumberId,
139
- accessToken,
166
+ const { jobId } = await enqueueJob({
167
+ jobType: "whatsappNotify",
168
+ payload: {
169
+ toPhone: resolvedPhone,
170
+ title,
171
+ message,
172
+ type,
173
+ templateName: resolveWhatsAppTemplateName(type, creds),
174
+ templateLanguage: creds.whatsappTemplateLanguage ?? "en",
175
+ notificationId: notification.id,
176
+ phoneNumberId,
177
+ accessToken,
178
+ },
179
+ requestedBy: input.userId,
180
+ });
181
+ whatsappStatus = "queued";
182
+ await notificationRepository
183
+ .update(notification.id, { whatsappStatus: "queued", whatsappJobId: jobId })
184
+ .catch((err) => {
185
+ void normalizeError(err);
186
+ serverLogger.warn("sendNotification: failed to write whatsappJobId onto notification doc", { notificationId: notification.id });
140
187
  });
141
- whatsappStatus = ok ? "sent" : "failed";
142
188
  }
143
189
  catch (err) {
144
190
  void normalizeError(err);
145
- serverLogger.error("sendNotification: WhatsApp dispatch failed", { userId: input.userId, err });
191
+ serverLogger.error("sendNotification: WhatsApp job enqueue failed", { userId: input.userId, err });
146
192
  whatsappStatus = "failed";
147
193
  }
148
194
  }
@@ -156,7 +156,7 @@ export function AdminAdsView({ endpoint = ADMIN_ENDPOINTS.ADS, labels = {}, crea
156
156
  settingsMutation.mutate(payload);
157
157
  };
158
158
  return (_jsx(StackedViewShell, { portal: "admin", ...rest, title: labels.title ?? "Ad Inventory", sections: [
159
- _jsxs(Row, { align: "center", justify: "between", gap: "3", children: [_jsx(Text, { variant: "secondary", children: "Manage ad inventory, placement mapping, and publishing state." }), _jsx(TextLink, { variant: "bare", href: createHref, rounded: "md", paddingX: "sm", size: "sm", weight: "medium", layout: "inline-flex", align: "center", className: "h-9 bg-neutral-900 text-white bg-[var(--appkit-color-surface)] dark:text-[var(--appkit-color-text)]", children: "New ad" })] }),
159
+ _jsxs(Row, { align: "center", justify: "between", gap: "3", children: [_jsx(Text, { variant: "secondary", children: "Manage ad inventory, placement mapping, and publishing state." }), _jsx(TextLink, { variant: "bare", href: createHref, rounded: "md", paddingX: "sm", size: "sm", weight: "medium", layout: "inline-flex", align: "center", className: "h-9 bg-[var(--appkit-color-surface)] text-[var(--appkit-color-text)]", children: "New ad" })] }),
160
160
  adsQuery.error ? (_jsx(Alert, { variant: "error", title: "Could not load ads", children: adsQuery.error instanceof Error ? adsQuery.error.message : "Unknown error" })) : null,
161
161
  _jsx(AdsSettingsPanel, { adsenseClientId: adsenseClientId, setAdsenseClientId: setAdsenseClientId, thirdPartyScriptUrl: thirdPartyScriptUrl, setThirdPartyScriptUrl: setThirdPartyScriptUrl, consentRequired: consentRequired, setConsentRequired: setConsentRequired, serverCredentialIssues: serverCredentialIssues, localCredentialIssues: localCredentialIssues, credentialStatus: credentialStatus, providerCredentialsMasked: adsQuery.data?.providerCredentialsMasked, settingsMutation: settingsMutation, hasPendingCredentialInput: hasPendingCredentialInput, currentConsentRequired: Boolean(adsQuery.data?.consentRequired), settingsMessage: settingsMessage, onSave: saveSettings }),
162
162
  _jsx(AdsFilterRow, { q: q, setQ: setQ, status: status, setStatus: setStatus, provider: provider, setProvider: setProvider, placement: placement, setPlacement: setPlacement, placements: placements, onPageReset: () => setPage(1) }),
@@ -1,10 +1,10 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { normalizeError } from "../../../errors/normalize";
4
- import { useApiMutation } from "@mohasinac/appkit/client";
4
+ import { useApiMutation, useBulkEvent, RTDB_PATHS } from "@mohasinac/appkit/client";
5
5
  import { sieveFilter, SIEVE_OP } from "@mohasinac/appkit";
6
6
  import { sortBy } from "@mohasinac/appkit";
7
- import React, { useCallback, useState } from "react";
7
+ import React, { useCallback, useEffect, useState } from "react";
8
8
  import { useQueryClient } from "@tanstack/react-query";
9
9
  import { Button, ConfirmDeleteModal, FilterChipGroup, ListingLayout, RowActionMenu, useToast } from "../../../ui";
10
10
  import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
@@ -33,24 +33,55 @@ export function AdminNewsletterView({ children, onBulkUnsubscribe, ...props }) {
33
33
  showToast(err?.message ?? "Failed to unsubscribe.", "error");
34
34
  },
35
35
  });
36
+ // Export runs as an async `newsletterExport` job (Async Job Primitive) —
37
+ // the route only enqueues; this subscribes to the same RTDB bulk-event
38
+ // channel every other async job in the admin panel uses.
39
+ const [exporting, setExporting] = useState(false);
40
+ const exportEvent = useBulkEvent({ rtdbPath: RTDB_PATHS.BULK_EVENTS });
36
41
  const handleExportCsv = useCallback(async () => {
42
+ setExporting(true);
37
43
  try {
38
44
  const response = await fetch(ADMIN_ENDPOINTS.NEWSLETTER_EXPORT);
39
45
  if (!response.ok)
40
46
  throw new Error("Export failed");
41
- const blob = await response.blob();
42
- const url = URL.createObjectURL(blob);
43
- const a = document.createElement("a");
44
- a.href = url;
45
- a.download = "newsletter-subscribers.csv";
46
- a.click();
47
- URL.revokeObjectURL(url);
47
+ const body = (await response.json());
48
+ const { jobId, customToken } = body.data ?? {};
49
+ if (jobId && customToken) {
50
+ exportEvent.subscribe(jobId, customToken);
51
+ }
52
+ else {
53
+ throw new Error("Export failed to start");
54
+ }
48
55
  }
49
56
  catch (_err) {
50
57
  void normalizeError(_err);
58
+ setExporting(false);
51
59
  showToast("Failed to export CSV.", "error");
52
60
  }
53
- }, [showToast]);
61
+ }, [showToast, exportEvent]);
62
+ useEffect(() => {
63
+ if (exportEvent.status === "success") {
64
+ setExporting(false);
65
+ const csv = exportEvent.result?.data?.csv;
66
+ if (csv) {
67
+ const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
68
+ const url = URL.createObjectURL(blob);
69
+ const a = document.createElement("a");
70
+ a.href = url;
71
+ a.download = `newsletter-subscribers-${new Date().toISOString().slice(0, 10)}.csv`;
72
+ a.click();
73
+ URL.revokeObjectURL(url);
74
+ }
75
+ else {
76
+ showToast("Export finished with no data.", "error");
77
+ }
78
+ }
79
+ else if (exportEvent.status === "failed" || exportEvent.status === "timeout") {
80
+ setExporting(false);
81
+ showToast(exportEvent.error ?? "Failed to export CSV.", "error");
82
+ }
83
+ // eslint-disable-next-line react-hooks/exhaustive-deps
84
+ }, [exportEvent.status]);
54
85
  if (React.Children.count(children) > 0) {
55
86
  return (_jsx(ListingLayout, { portal: "admin", ...props, children: children }));
56
87
  }
@@ -85,7 +116,7 @@ export function AdminNewsletterView({ children, onBulkUnsubscribe, ...props }) {
85
116
  return mappedRows.length;
86
117
  },
87
118
  buildFilters: (state) => state.status && state.status !== "All" ? sieveFilter("status", SIEVE_OP.EQ, state.status) : undefined,
88
- toolbarExtra: (_jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: handleExportCsv, children: ACTIONS.ADMIN["export-csv"].label })),
119
+ toolbarExtra: (_jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: handleExportCsv, isLoading: exporting, disabled: exporting, children: ACTIONS.ADMIN["export-csv"].label })),
89
120
  buildBulkActions: onBulkUnsubscribe
90
121
  ? (selection) => [
91
122
  buildBulkAction(ACTIONS.ADMIN["unsubscribe-newsletter"], async () => {