@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.
- package/dist/_internal/server/features/checkout/actions.d.ts +15 -0
- package/dist/_internal/server/features/checkout/actions.js +245 -182
- package/dist/_internal/server/functions/scheduled.d.ts +2 -1
- package/dist/_internal/server/functions/scheduled.js +9 -1
- package/dist/_internal/server/jobs/core/jobRunners.js +10 -0
- package/dist/_internal/server/jobs/core/newsletterExport.d.ts +15 -0
- package/dist/_internal/server/jobs/core/newsletterExport.js +43 -0
- package/dist/_internal/server/jobs/core/onOrderStatusChange.d.ts +2 -0
- package/dist/_internal/server/jobs/core/onOrderStatusChange.js +1 -0
- package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +1 -0
- package/dist/_internal/server/jobs/core/pendingOrderTimeout.js +1 -0
- package/dist/_internal/server/jobs/core/revenueRollup.d.ts +13 -0
- package/dist/_internal/server/jobs/core/revenueRollup.js +22 -0
- package/dist/_internal/server/jobs/core/whatsappNotify.d.ts +35 -0
- package/dist/_internal/server/jobs/core/whatsappNotify.js +82 -0
- package/dist/_internal/server/jobs/handlers/index.d.ts +1 -0
- package/dist/_internal/server/jobs/handlers/index.js +1 -0
- package/dist/_internal/server/jobs/handlers/revenueRollup.d.ts +2 -0
- package/dist/_internal/server/jobs/handlers/revenueRollup.js +2 -0
- package/dist/_internal/shared/features/checkout/config.d.ts +7 -0
- package/dist/_internal/shared/features/checkout/config.js +7 -0
- package/dist/_internal/shared/fees/calculator.d.ts +26 -0
- package/dist/_internal/shared/fees/calculator.js +25 -0
- package/dist/features/about/components/FeesView.js +18 -0
- package/dist/features/admin/actions/notification-actions.d.ts +4 -1
- package/dist/features/admin/actions/notification-actions.js +56 -10
- package/dist/features/admin/components/AdminAdsView.js +1 -1
- package/dist/features/admin/components/AdminNewsletterView.js +42 -11
- package/dist/features/admin/components/AdminSiteSettingsView.js +83 -6
- package/dist/features/admin/repository/analytics-rollup.repository.d.ts +25 -0
- package/dist/features/admin/repository/analytics-rollup.repository.js +31 -0
- package/dist/features/admin/repository/site-settings.repository.js +8 -1
- package/dist/features/admin/schemas/firestore.d.ts +41 -0
- package/dist/features/admin/schemas/firestore.js +62 -24
- package/dist/features/layout/BackToTop.d.ts +9 -5
- package/dist/features/layout/BackToTop.js +17 -21
- package/dist/features/orders/actions/refund-actions.js +1 -0
- package/dist/features/orders/schemas/firestore.d.ts +14 -0
- package/dist/features/products/constants/action-defs.d.ts +28 -3
- package/dist/features/products/constants/action-defs.js +76 -1
- package/dist/features/seller/components/SellerOrdersView.js +1 -1
- package/dist/features/whatsapp-bot/helpers/whatsapp.d.ts +10 -1
- package/dist/features/whatsapp-bot/helpers/whatsapp.js +47 -0
- package/dist/features/whatsapp-bot/types/index.d.ts +19 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -2
- package/dist/next/routing/route-map.d.ts +2 -0
- package/dist/next/routing/route-map.js +1 -0
- package/dist/repositories/index.d.ts +2 -0
- package/dist/repositories/index.js +1 -0
- package/dist/seed/site-settings-seed-data.js +7 -0
- package/dist/server.d.ts +2 -2
- package/dist/server.js +2 -2
- 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";
|
|
@@ -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
|
-
|
|
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 {
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
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-
|
|
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
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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 () => {
|