@mohasinac/appkit 4.63.4 → 4.63.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.build-stamp.json +3 -3
- package/dist/_internal/server/features/checkout/actions.d.ts +2 -0
- package/dist/_internal/server/features/checkout/actions.js +22 -5
- package/dist/_internal/server/features/orders/adapters.js +3 -1
- package/dist/_internal/server/features/site-settings/adapters.js +20 -2
- package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
- package/dist/_internal/server/features/stores/adapters.js +1 -0
- package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.js +5 -2
- package/dist/_internal/server/jobs/core/jobRunners.js +5 -0
- package/dist/_internal/server/jobs/core/onOrderCreate.js +104 -10
- 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/onScamReportCreate.js +1 -0
- package/dist/_internal/server/jobs/core/onScamReportRejected.js +1 -0
- package/dist/_internal/server/jobs/core/onScamReportVerified.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/whatsappNotify.d.ts +35 -0
- package/dist/_internal/server/jobs/core/whatsappNotify.js +140 -0
- package/dist/_internal/server/notifications/channel-health.d.ts +8 -6
- package/dist/_internal/server/notifications/channel-health.js +2 -2
- package/dist/_internal/server/notifications/send-guard.d.ts +1 -1
- package/dist/_internal/server/notifications/send-guard.js +4 -2
- package/dist/_internal/shared/actions/action-registry.js +28 -0
- package/dist/_internal/shared/checkout/order-math.d.ts +1 -1
- package/dist/_internal/shared/checkout/order-math.js +1 -1
- package/dist/_internal/shared/features/checkout/config.d.ts +2 -0
- package/dist/_internal/shared/features/checkout/config.js +2 -0
- package/dist/_internal/shared/features/messaging/config.d.ts +5 -7
- package/dist/_internal/shared/features/messaging/config.js +4 -1
- package/dist/_internal/shared/features/site-settings/fees.d.ts +2 -0
- package/dist/_internal/shared/features/site-settings/fees.js +2 -0
- package/dist/_internal/shared/features/site-settings/types.d.ts +27 -2
- package/dist/_internal/shared/fees/calculator.d.ts +8 -0
- package/dist/_internal/shared/fees/calculator.js +7 -0
- package/dist/cli/index.js +2 -0
- package/dist/client.d.ts +2 -2
- package/dist/client.js +1 -1
- package/dist/constants/api-endpoints.d.ts +15 -0
- package/dist/constants/api-endpoints.js +8 -0
- package/dist/constants/notification-types.d.ts +1 -1
- package/dist/constants/notification-types.js +1 -1
- package/dist/core/integration-keys.d.ts +5 -0
- package/dist/core/integration-keys.js +10 -0
- package/dist/features/about/components/FeesView.js +6 -0
- package/dist/features/account/components/BuyerShoppingGuideView.js +1 -1
- package/dist/features/account/components/NotificationPreferencesPanel.js +4 -3
- package/dist/features/account/types/index.d.ts +2 -0
- package/dist/features/admin/actions/notification-actions.d.ts +17 -2
- package/dist/features/admin/actions/notification-actions.js +154 -9
- package/dist/features/admin/components/AdminOrderEditorView.d.ts +1 -1
- package/dist/features/admin/components/AdminOrdersView.js +2 -0
- package/dist/features/admin/components/AdminSiteSettingsView.js +66 -6
- package/dist/features/admin/components/AdminStoreEditorView.js +1 -0
- package/dist/features/admin/constants/site-settings-tabs.d.ts +12 -7
- package/dist/features/admin/constants/site-settings-tabs.js +12 -6
- package/dist/features/admin/repository/site-settings.repository.js +1 -1
- package/dist/features/admin/schemas/admin-user-form.d.ts +61 -0
- package/dist/features/admin/schemas/admin-user-form.js +44 -3
- package/dist/features/admin/schemas/firestore.d.ts +73 -0
- package/dist/features/admin/schemas/firestore.js +8 -3
- package/dist/features/auth/checkout-value-otp.d.ts +1 -1
- package/dist/features/auth/checkout-value-otp.js +1 -1
- package/dist/features/auth/permissions/constants.d.ts +2 -2
- package/dist/features/auth/permissions/constants.js +2 -1
- package/dist/features/cart/components/CartPriceBreakdown.d.ts +2 -0
- package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
- package/dist/features/cart/components/StoreAddonsPicker.d.ts +5 -2
- package/dist/features/cart/components/StoreAddonsPicker.js +5 -4
- package/dist/features/cart/repository/cart.repository.js +2 -1
- package/dist/features/cart/schemas/firestore.d.ts +2 -1
- package/dist/features/checkout/actions/checkout-value-otp-actions.d.ts +5 -10
- package/dist/features/checkout/actions/checkout-value-otp-actions.js +88 -2
- package/dist/features/homepage/lib/section-renderer.js +1 -1
- package/dist/features/orders/actions/refund-actions.js +1 -0
- package/dist/features/orders/components/OrderAddonBadges.d.ts +3 -1
- package/dist/features/orders/components/OrderAddonBadges.js +4 -3
- package/dist/features/orders/repository/orders.repository.d.ts +8 -0
- package/dist/features/orders/repository/orders.repository.js +2 -0
- package/dist/features/orders/schemas/firestore.d.ts +4 -0
- package/dist/features/orders/types/index.d.ts +5 -2
- package/dist/features/search/action-index/settings-entries.js +9 -0
- package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +6 -1
- package/dist/features/store-extensions/repository/store-extensions.repositories.js +10 -1
- package/dist/features/store-extensions/schemas/firestore.d.ts +17 -1
- package/dist/features/store-extensions/schemas/firestore.js +10 -0
- package/dist/features/stores/components/StoreCapabilitiesGuideView.js +1 -0
- package/dist/features/stores/components/StoreGuideHubView.js +1 -1
- package/dist/features/stores/components/StoreSettingsGuideView.js +6 -2
- package/dist/features/stores/repository/store.repository.d.ts +23 -1
- package/dist/features/stores/repository/store.repository.js +29 -1
- package/dist/features/stores/schemas/firestore.d.ts +16 -6
- package/dist/features/stores/schemas/firestore.js +3 -6
- package/dist/features/stores/schemas/index.d.ts +86 -0
- package/dist/features/stores/schemas/index.js +14 -0
- package/dist/features/whatsapp-bot/api/send-status/route.d.ts +12 -0
- package/dist/features/whatsapp-bot/api/send-status/route.js +38 -0
- package/dist/features/whatsapp-bot/api/webhook/route.d.ts +12 -0
- package/dist/features/whatsapp-bot/api/webhook/route.js +43 -0
- package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.d.ts +57 -0
- package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +76 -0
- package/dist/features/whatsapp-bot/components/WhatsAppChatButton.d.ts +9 -0
- package/dist/features/whatsapp-bot/components/WhatsAppChatButton.js +7 -0
- package/dist/features/whatsapp-bot/components/index.d.ts +3 -0
- package/dist/features/whatsapp-bot/components/index.js +2 -0
- package/dist/features/whatsapp-bot/helpers/whatsapp.d.ts +52 -0
- package/dist/features/whatsapp-bot/helpers/whatsapp.js +288 -0
- package/dist/features/whatsapp-bot/index.d.ts +3 -0
- package/dist/features/whatsapp-bot/index.js +3 -0
- package/dist/features/whatsapp-bot/manifest.d.ts +2 -0
- package/dist/features/whatsapp-bot/manifest.js +13 -0
- package/dist/features/whatsapp-bot/messages/en.json +7 -0
- package/dist/features/whatsapp-bot/server.d.ts +7 -0
- package/dist/features/whatsapp-bot/server.js +7 -0
- package/dist/features/whatsapp-bot/types/index.d.ts +126 -0
- package/dist/features/whatsapp-bot/types/index.js +1 -0
- package/dist/index.d.ts +23 -6
- package/dist/index.js +38 -12
- package/dist/next/routing/route-map.d.ts +3 -0
- package/dist/next/routing/route-map.js +1 -0
- package/dist/providers/db-firebase/base.repository.d.ts +1 -1
- package/dist/providers/db-firebase/base.repository.js +1 -1
- package/dist/repositories/index.d.ts +1 -1
- package/dist/repositories/index.js +1 -1
- package/dist/schemas/registry.d.ts +105 -0
- package/dist/schemas/registry.js +2 -1
- package/dist/security/pii-encrypt.js +1 -1
- package/dist/server.d.ts +10 -2
- package/dist/server.js +22 -2
- package/dist/styles.css +1 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/NavigationLoader.d.ts +14 -1
- package/dist/ui/components/NavigationLoader.js +79 -30
- package/package.json +1 -1
- package/dist/features/orders/utils/order-announcement-message.d.ts +0 -18
- package/dist/features/orders/utils/order-announcement-message.js +0 -16
|
@@ -32,6 +32,10 @@ export interface NotificationDocument extends BaseDocument {
|
|
|
32
32
|
readAt?: Date;
|
|
33
33
|
relatedId?: string;
|
|
34
34
|
relatedType?: NotificationRelatedType;
|
|
35
|
+
/** Outcome of the async whatsappNotify job for this notification's WhatsApp send, if one was enqueued. */
|
|
36
|
+
whatsappStatus?: "queued" | "sent" | "failed" | "skipped";
|
|
37
|
+
/** jobs/{id} doc that ran (or is running) the WhatsApp send for this notification. */
|
|
38
|
+
whatsappJobId?: string;
|
|
35
39
|
}
|
|
36
40
|
export declare const NOTIFICATIONS_COLLECTION: "notifications";
|
|
37
41
|
export declare const NOTIFICATIONS_INDEXED_FIELDS: readonly ["userId", "isRead", "createdAt", "type"];
|
|
@@ -110,6 +114,7 @@ export interface SiteSettingsCredentials {
|
|
|
110
114
|
/** Webhook signing secret from Razorpay's dashboard → Webhooks. Without it `verifyWebhook()` fails closed. */
|
|
111
115
|
razorpayWebhookSecret?: string;
|
|
112
116
|
resendApiKey?: string;
|
|
117
|
+
whatsappApiKey?: string;
|
|
113
118
|
metaAppId?: string;
|
|
114
119
|
metaAppSecret?: string;
|
|
115
120
|
metaPageAccessToken?: string;
|
|
@@ -124,6 +129,28 @@ export interface SiteSettingsCredentials {
|
|
|
124
129
|
/** Google Places API for Google Business Reviews (HS4) */
|
|
125
130
|
googleMapsApiKey?: string;
|
|
126
131
|
googlePlaceId?: string;
|
|
132
|
+
/** WhatsApp Business Cloud API — platform level (for admin purchase announcements) */
|
|
133
|
+
whatsappPhoneNumberId?: string;
|
|
134
|
+
whatsappCloudApiToken?: string;
|
|
135
|
+
/** Comma-separated digits-only phone numbers that receive order announcements */
|
|
136
|
+
whatsappAdminNotifyNumbers?: string;
|
|
137
|
+
/**
|
|
138
|
+
* Approved Meta message-template names, one flat string field per
|
|
139
|
+
* order-lifecycle notification type — every `SiteSettingsCredentials`
|
|
140
|
+
* field is stored as an individually-encrypted string (see
|
|
141
|
+
* `getDecryptedCredentials`/`getCredentialsMasked`, which iterate + mask
|
|
142
|
+
* every field as a string), so this can't be a `Record<string,string>`
|
|
143
|
+
* map. Business-initiated WhatsApp sends outside the 24h customer-service
|
|
144
|
+
* window require a pre-approved template.
|
|
145
|
+
*/
|
|
146
|
+
whatsappTemplateOrderPlaced?: string;
|
|
147
|
+
whatsappTemplateOrderConfirmed?: string;
|
|
148
|
+
whatsappTemplateOrderShipped?: string;
|
|
149
|
+
whatsappTemplateOrderDelivered?: string;
|
|
150
|
+
whatsappTemplateOrderCancelled?: string;
|
|
151
|
+
whatsappTemplateRefundInitiated?: string;
|
|
152
|
+
/** BCP-47 language code the templates above were approved in, e.g. "en" or "en_US". */
|
|
153
|
+
whatsappTemplateLanguage?: string;
|
|
127
154
|
}
|
|
128
155
|
export interface SiteSettingsCredentialsMasked {
|
|
129
156
|
phonepeClientId?: string;
|
|
@@ -136,6 +163,7 @@ export interface SiteSettingsCredentialsMasked {
|
|
|
136
163
|
razorpayKeySecret?: string;
|
|
137
164
|
razorpayWebhookSecret?: string;
|
|
138
165
|
resendApiKey?: string;
|
|
166
|
+
whatsappApiKey?: string;
|
|
139
167
|
metaAppId?: string;
|
|
140
168
|
metaAppSecret?: string;
|
|
141
169
|
metaPageAccessToken?: string;
|
|
@@ -147,6 +175,16 @@ export interface SiteSettingsCredentialsMasked {
|
|
|
147
175
|
deviantartClientSecret?: string;
|
|
148
176
|
googleMapsApiKey?: string;
|
|
149
177
|
googlePlaceId?: string;
|
|
178
|
+
whatsappPhoneNumberId?: string;
|
|
179
|
+
whatsappCloudApiToken?: string;
|
|
180
|
+
whatsappAdminNotifyNumbers?: string;
|
|
181
|
+
whatsappTemplateOrderPlaced?: string;
|
|
182
|
+
whatsappTemplateOrderConfirmed?: string;
|
|
183
|
+
whatsappTemplateOrderShipped?: string;
|
|
184
|
+
whatsappTemplateOrderDelivered?: string;
|
|
185
|
+
whatsappTemplateOrderCancelled?: string;
|
|
186
|
+
whatsappTemplateRefundInitiated?: string;
|
|
187
|
+
whatsappTemplateLanguage?: string;
|
|
150
188
|
}
|
|
151
189
|
export interface NotificationChannelConfig {
|
|
152
190
|
inApp: {
|
|
@@ -162,6 +200,15 @@ export interface NotificationChannelConfig {
|
|
|
162
200
|
/** Subset of NotificationTypes to email — absent means all types. */
|
|
163
201
|
types?: NotificationType[];
|
|
164
202
|
};
|
|
203
|
+
whatsapp: {
|
|
204
|
+
enabled: boolean;
|
|
205
|
+
/** Minimum priority that triggers a WhatsApp message (default: "high"). */
|
|
206
|
+
minPriority?: NotificationPriority;
|
|
207
|
+
/** Subset of NotificationTypes to WhatsApp — absent means all types. */
|
|
208
|
+
types?: NotificationType[];
|
|
209
|
+
/** When true, WhatsApp OTP is offered as an alternative to email OTP at checkout. */
|
|
210
|
+
otpEnabled?: boolean;
|
|
211
|
+
};
|
|
165
212
|
sms: {
|
|
166
213
|
enabled: boolean;
|
|
167
214
|
/** Minimum priority that triggers an SMS (default: "high"). */
|
|
@@ -452,6 +499,29 @@ export interface SiteSettingsDocument extends BaseDocument {
|
|
|
452
499
|
* ₹2,000 of "handling" on a ₹20,000 order — see `computeCodHandlingFee`.
|
|
453
500
|
*/ codHandlingFeeCap: number;
|
|
454
501
|
codHandlingFeePercent: number;
|
|
502
|
+
/**
|
|
503
|
+
* Admin master toggle for the per-order, BUYER-paid WhatsApp addon.
|
|
504
|
+
*
|
|
505
|
+
* 🛑 Defaults to FALSE. The cost of WhatsApp moved to a standing monthly
|
|
506
|
+
* charge on the SELLER (`whatsappNotifyMonthlyFee` below) — the platform
|
|
507
|
+
* owns one number, so the expense is standing rather than per order, and a
|
|
508
|
+
* buyer should not have to purchase their own delivery updates. The
|
|
509
|
+
* checkout path that charges it is left intact rather than cut out again:
|
|
510
|
+
* it is woven through the order-total arithmetic that carries D42's
|
|
511
|
+
* round-once fix, and disabling it makes the fee resolve to 0 without
|
|
512
|
+
* touching that money code.
|
|
513
|
+
*/
|
|
514
|
+
whatsappNotifyFeeEnabled: boolean;
|
|
515
|
+
/** Flat rupee fee charged to the buyer when they opt into the per-order addon. Unreachable while `whatsappNotifyFeeEnabled` is false. */
|
|
516
|
+
whatsappNotifyFee: number;
|
|
517
|
+
/**
|
|
518
|
+
* What a seller pays per month to receive WhatsApp order notifications —
|
|
519
|
+
* the admin-set price of `StoreDocument.whatsappNotifySubscribed`.
|
|
520
|
+
*
|
|
521
|
+
* Rupees per month, not per message: Meta bills us per template, but a
|
|
522
|
+
* seller cannot predict their own order count, and a per-message price
|
|
523
|
+
* would make their bill a function of how well they sold.
|
|
524
|
+
*/ whatsappNotifyMonthlyFee: number;
|
|
455
525
|
/** Admin master toggle — whether the gift-wrap addon is offered at checkout at all. */
|
|
456
526
|
giftWrapFeeEnabled: boolean;
|
|
457
527
|
/** Flat rupee fee charged to the buyer when they opt into gift wrap. */
|
|
@@ -537,6 +607,8 @@ export interface SiteSettingsDocument extends BaseDocument {
|
|
|
537
607
|
messaging?: {
|
|
538
608
|
/** User-facing email. Staff mail (daily digest, payout summary) ignores this. */
|
|
539
609
|
emailEnabled: boolean;
|
|
610
|
+
/** User-facing WhatsApp. Same exemption for staff. */
|
|
611
|
+
whatsappEnabled: boolean;
|
|
540
612
|
/**
|
|
541
613
|
* Reserved sends allowed per channel per day. Applies to staff and
|
|
542
614
|
* transactional mail too — the ceiling exists to stay inside the
|
|
@@ -544,6 +616,7 @@ export interface SiteSettingsDocument extends BaseDocument {
|
|
|
544
616
|
*/
|
|
545
617
|
dailyCeiling: {
|
|
546
618
|
email: number;
|
|
619
|
+
whatsapp: number;
|
|
547
620
|
sms: number;
|
|
548
621
|
};
|
|
549
622
|
};
|
|
@@ -15,7 +15,7 @@ export const ADMIN_CHECKOUT_BYPASS_FLAG_KEY = "adminCheckoutBypass";
|
|
|
15
15
|
* · `constants/notification-types.ts` (9) — fed the admin filter chips and
|
|
16
16
|
* the per-channel allow-list, so the admin list could not filter 18 of
|
|
17
17
|
* the 27 real types and an admin literally could not allow-list
|
|
18
|
-
* `offer_received` or `payment_review` for email
|
|
18
|
+
* `offer_received` or `payment_review` for email or WhatsApp
|
|
19
19
|
* · `features/account/types/index.ts` (5: order|offer|promo|system|message)
|
|
20
20
|
* — a different vocabulary entirely, on a `UserNotification` type with
|
|
21
21
|
* zero real consumers
|
|
@@ -195,6 +195,7 @@ export const notificationQueryHelpers = {
|
|
|
195
195
|
export const DEFAULT_NOTIFICATION_CHANNELS = {
|
|
196
196
|
inApp: { enabled: true, readOnly: true },
|
|
197
197
|
email: { enabled: false, minPriority: "normal" },
|
|
198
|
+
whatsapp: { enabled: false, minPriority: "high", otpEnabled: false },
|
|
198
199
|
sms: { enabled: false, minPriority: "high" },
|
|
199
200
|
};
|
|
200
201
|
const PRIORITY_ORDER = {
|
|
@@ -254,7 +255,8 @@ export const DEFAULT_SITE_SETTINGS_DATA = {
|
|
|
254
255
|
// half-configured site.
|
|
255
256
|
messaging: {
|
|
256
257
|
emailEnabled: false,
|
|
257
|
-
|
|
258
|
+
whatsappEnabled: false,
|
|
259
|
+
dailyCeiling: { email: 80, whatsapp: 200, sms: 50 },
|
|
258
260
|
},
|
|
259
261
|
emi: {
|
|
260
262
|
enabled: false,
|
|
@@ -277,9 +279,12 @@ export const DEFAULT_SITE_SETTINGS_DATA = {
|
|
|
277
279
|
// The buyer add-ons ship ENABLED. They were `false`, which made
|
|
278
280
|
// StoreAddonsPicker return null on both the cart and checkout, so a fresh
|
|
279
281
|
// install had no add-ons at all and no indication why. `undefined` still
|
|
280
|
-
// means disabled everywhere (
|
|
282
|
+
// means disabled everywhere (computeWhatsAppNotifyFee et al. read
|
|
281
283
|
// `if (!rates.XEnabled) return 0`) — only the DEFAULT changed, so an
|
|
282
284
|
// existing siteSettings document keeps whatever it already stored.
|
|
285
|
+
whatsappNotifyFeeEnabled: false,
|
|
286
|
+
whatsappNotifyFee: 10,
|
|
287
|
+
whatsappNotifyMonthlyFee: 199,
|
|
283
288
|
giftWrapFeeEnabled: true,
|
|
284
289
|
giftWrapFee: 49,
|
|
285
290
|
shipmentProtectionFeeEnabled: true,
|
|
@@ -44,6 +44,6 @@ export interface CheckoutValueOtpDoc {
|
|
|
44
44
|
* making the whole feature look broken.
|
|
45
45
|
*/
|
|
46
46
|
export declare function enforceCheckoutValueOtpRateLimit(db: Db, uid: string): Promise<void>;
|
|
47
|
-
/** Build the checkout-value OTP message body. */
|
|
47
|
+
/** Build the checkout-value OTP WhatsApp/SMS message body. */
|
|
48
48
|
export declare function buildCheckoutValueOtpMessage(code: string, siteName: string): string;
|
|
49
49
|
export {};
|
|
@@ -75,7 +75,7 @@ export async function enforceCheckoutValueOtpRateLimit(db, uid) {
|
|
|
75
75
|
tx.set(metaRef, { lastSentAt: new Date() }, { merge: true });
|
|
76
76
|
});
|
|
77
77
|
}
|
|
78
|
-
/** Build the checkout-value OTP message body. */
|
|
78
|
+
/** Build the checkout-value OTP WhatsApp/SMS message body. */
|
|
79
79
|
export function buildCheckoutValueOtpMessage(code, siteName) {
|
|
80
80
|
return (`*${siteName} — Order Verification*\n\n` +
|
|
81
81
|
`Your verification code for this high-value order is: *${code}*\n\n` +
|
|
@@ -83,7 +83,7 @@ export declare const ROUTE_PERMISSION_MAP: Record<string, Permission>;
|
|
|
83
83
|
*
|
|
84
84
|
* Defaults on store creation: ["suggest_brands", "create_coupons"]
|
|
85
85
|
*/
|
|
86
|
-
export type StoreCapability = "advanced_listing_types" | "host_auctions" | "host_preorders" | "create_categories" | "suggest_brands" | "create_coupons" | "bulk_listing_import" | "extended_return_window" | "verified_seller" | "featured_placement" | "promotional_banner" | "priority_support" | "multiple_stores" | "custom_store_slug" | "api_access" | "lower_commission_rate" | "early_access_features" | "advanced_analytics";
|
|
86
|
+
export type StoreCapability = "advanced_listing_types" | "host_auctions" | "host_preorders" | "create_categories" | "suggest_brands" | "create_coupons" | "bulk_listing_import" | "extended_return_window" | "verified_seller" | "featured_placement" | "promotional_banner" | "priority_support" | "multiple_stores" | "custom_store_slug" | "api_access" | "lower_commission_rate" | "early_access_features" | "advanced_analytics" | "whatsapp_catalog_sync";
|
|
87
87
|
export declare const ALL_STORE_CAPABILITIES: StoreCapability[];
|
|
88
88
|
/** Narrow an untrusted string to a real capability. */
|
|
89
89
|
export declare function isStoreCapability(value: unknown): value is StoreCapability;
|
|
@@ -118,7 +118,7 @@ export declare function hasCapability(capabilities: StoreCapability[], required:
|
|
|
118
118
|
*
|
|
119
119
|
* 🛑 The PURE half of `getStoreCapabilities`, extracted so a caller that
|
|
120
120
|
* already holds the store document does not pay a second Firestore read to ask
|
|
121
|
-
* what it is entitled to (Rule #6).
|
|
121
|
+
* what it is entitled to (Rule #6). Three WhatsApp routes were reading
|
|
122
122
|
* `store.capabilities ?? []` inline for exactly that reason, which made them a
|
|
123
123
|
* second, divergent reader of the field — they skipped the default merge, so
|
|
124
124
|
* they and `getStoreCapabilities` could disagree about the same store.
|
|
@@ -390,6 +390,7 @@ const STORE_CAPABILITY_MAP = {
|
|
|
390
390
|
lower_commission_rate: true,
|
|
391
391
|
early_access_features: true,
|
|
392
392
|
advanced_analytics: true,
|
|
393
|
+
whatsapp_catalog_sync: true,
|
|
393
394
|
};
|
|
394
395
|
export const ALL_STORE_CAPABILITIES = Object.keys(STORE_CAPABILITY_MAP);
|
|
395
396
|
/** Narrow an untrusted string to a real capability. */
|
|
@@ -441,7 +442,7 @@ export function hasCapability(capabilities, required) {
|
|
|
441
442
|
*
|
|
442
443
|
* 🛑 The PURE half of `getStoreCapabilities`, extracted so a caller that
|
|
443
444
|
* already holds the store document does not pay a second Firestore read to ask
|
|
444
|
-
* what it is entitled to (Rule #6).
|
|
445
|
+
* what it is entitled to (Rule #6). Three WhatsApp routes were reading
|
|
445
446
|
* `store.capabilities ?? []` inline for exactly that reason, which made them a
|
|
446
447
|
* second, divergent reader of the field — they skipped the default merge, so
|
|
447
448
|
* they and `getStoreCapabilities` could disagree about the same store.
|
|
@@ -22,6 +22,7 @@ export interface CartPriceBreakdownStore {
|
|
|
22
22
|
subtotal: number;
|
|
23
23
|
shippingFee: number;
|
|
24
24
|
codHandlingFee: number;
|
|
25
|
+
whatsappNotifyFee: number;
|
|
25
26
|
giftWrapFee: number;
|
|
26
27
|
shipmentProtectionFee: number;
|
|
27
28
|
saleProtectionFee: number;
|
|
@@ -44,6 +45,7 @@ export interface CartPriceBreakdownData {
|
|
|
44
45
|
subtotal: number;
|
|
45
46
|
shippingFee: number;
|
|
46
47
|
codHandlingFee: number;
|
|
48
|
+
whatsappNotifyFee: number;
|
|
47
49
|
giftWrapFee: number;
|
|
48
50
|
shipmentProtectionFee: number;
|
|
49
51
|
saleProtectionFee: number;
|
|
@@ -40,7 +40,7 @@ export function CartPriceBreakdown({ preview, itemCount, currency = "INR", unava
|
|
|
40
40
|
const subtotalLabel = itemCount !== undefined
|
|
41
41
|
? `Subtotal (${itemCount} item${itemCount !== 1 ? "s" : ""}${storeCount > 1 ? `, ${storeCount} stores` : ""})`
|
|
42
42
|
: "Subtotal";
|
|
43
|
-
return (_jsxs(Stack, { gap: "xs", className: className, children: [_jsx(Line, { label: subtotalLabel, amount: money(preview.subtotal) }), preview.shippingFee > 0 && (_jsx(Line, { label: withStoreCount("Shipping", storeCount), amount: money(preview.shippingFee) })), preview.codHandlingFee > 0 && (_jsx(Line, { label: "COD handling fee", amount: money(preview.codHandlingFee), derivation: derivations?.codHandlingFee })), preview.giftWrapFee > 0 && (_jsx(Line, { label: withStoreCount("Gift wrap", countCharged((s) => s.giftWrapFee)), amount: money(preview.giftWrapFee) })), preview.shipmentProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Shipment protection", countCharged((s) => s.shipmentProtectionFee)), amount: money(preview.shipmentProtectionFee) })), preview.saleProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Sale protection", countCharged((s) => s.saleProtectionFee)), amount: money(preview.saleProtectionFee) })), preview.emiSurcharge > 0 && (_jsx(Line, { label: "EMI surcharge", amount: money(preview.emiSurcharge), derivation: derivations?.emiSurcharge })), preview.sharedPurchaseFee > 0 && (_jsx(Line, { label: withStoreCount("Split delivery", countCharged((s) => s.sharedPurchaseFee)), amount: money(preview.sharedPurchaseFee) })), preview.platformFee > 0 && (_jsx(Line, { label: "Platform fee", amount: money(preview.platformFee), derivation: derivations?.platformFee })), preview.gstOnFee > 0 && (_jsx(Line, { label: "GST on platform fee", amount: money(preview.gstOnFee), derivation: derivations?.gstOnFee })), preview.couponDiscount > 0 && (_jsx(Line, { label: "Coupon discount", amount: `−${money(preview.couponDiscount)}`, tone: "success" })), _jsx(Div, { border: "top-subtle", paddingY: "t-sm", children: _jsx(Line, { label: "Total", amount: money(preview.total), tone: "primary", strong: preview.pointsApplied <= 0 }) }), preview.pointsApplied > 0 && (_jsxs(_Fragment, { children: [_jsx(Line, { label: `Points applied (${preview.pointsApplied.toLocaleString("en-IN")} pts)`, amount: `−${money(preview.pointsApplied)}`, tone: "success" }), _jsx(Div, { border: "top-subtle", paddingY: "t-sm", children: _jsx(Line, { label: "Amount due", amount: money(preview.amountDue), tone: "primary", strong: true }) })] })), preview.pointsApplied > 0 && preview.pointsBalance > preview.pointsApplied && (_jsxs(Text, { size: "xs", color: "muted", children: [(preview.pointsBalance - preview.pointsApplied).toLocaleString("en-IN"), " points stay in your balance \u2014 up to half of an order can be paid with points."] })), preview.pointsApplied === 0 && preview.pointsBalance > 0 && (_jsx(Text, { size: "xs", color: "muted", children: preview.stores.some((s) => s.acceptsPoints)
|
|
43
|
+
return (_jsxs(Stack, { gap: "xs", className: className, children: [_jsx(Line, { label: subtotalLabel, amount: money(preview.subtotal) }), preview.shippingFee > 0 && (_jsx(Line, { label: withStoreCount("Shipping", storeCount), amount: money(preview.shippingFee) })), preview.codHandlingFee > 0 && (_jsx(Line, { label: "COD handling fee", amount: money(preview.codHandlingFee), derivation: derivations?.codHandlingFee })), preview.whatsappNotifyFee > 0 && (_jsx(Line, { label: withStoreCount("WhatsApp updates", countCharged((s) => s.whatsappNotifyFee)), amount: money(preview.whatsappNotifyFee) })), preview.giftWrapFee > 0 && (_jsx(Line, { label: withStoreCount("Gift wrap", countCharged((s) => s.giftWrapFee)), amount: money(preview.giftWrapFee) })), preview.shipmentProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Shipment protection", countCharged((s) => s.shipmentProtectionFee)), amount: money(preview.shipmentProtectionFee) })), preview.saleProtectionFee > 0 && (_jsx(Line, { label: withStoreCount("Sale protection", countCharged((s) => s.saleProtectionFee)), amount: money(preview.saleProtectionFee) })), preview.emiSurcharge > 0 && (_jsx(Line, { label: "EMI surcharge", amount: money(preview.emiSurcharge), derivation: derivations?.emiSurcharge })), preview.sharedPurchaseFee > 0 && (_jsx(Line, { label: withStoreCount("Split delivery", countCharged((s) => s.sharedPurchaseFee)), amount: money(preview.sharedPurchaseFee) })), preview.platformFee > 0 && (_jsx(Line, { label: "Platform fee", amount: money(preview.platformFee), derivation: derivations?.platformFee })), preview.gstOnFee > 0 && (_jsx(Line, { label: "GST on platform fee", amount: money(preview.gstOnFee), derivation: derivations?.gstOnFee })), preview.couponDiscount > 0 && (_jsx(Line, { label: "Coupon discount", amount: `−${money(preview.couponDiscount)}`, tone: "success" })), _jsx(Div, { border: "top-subtle", paddingY: "t-sm", children: _jsx(Line, { label: "Total", amount: money(preview.total), tone: "primary", strong: preview.pointsApplied <= 0 }) }), preview.pointsApplied > 0 && (_jsxs(_Fragment, { children: [_jsx(Line, { label: `Points applied (${preview.pointsApplied.toLocaleString("en-IN")} pts)`, amount: `−${money(preview.pointsApplied)}`, tone: "success" }), _jsx(Div, { border: "top-subtle", paddingY: "t-sm", children: _jsx(Line, { label: "Amount due", amount: money(preview.amountDue), tone: "primary", strong: true }) })] })), preview.pointsApplied > 0 && preview.pointsBalance > preview.pointsApplied && (_jsxs(Text, { size: "xs", color: "muted", children: [(preview.pointsBalance - preview.pointsApplied).toLocaleString("en-IN"), " points stay in your balance \u2014 up to half of an order can be paid with points."] })), preview.pointsApplied === 0 && preview.pointsBalance > 0 && (_jsx(Text, { size: "xs", color: "muted", children: preview.stores.some((s) => s.acceptsPoints)
|
|
44
44
|
? "Your points could not be applied to this order."
|
|
45
45
|
: storeCount > 1
|
|
46
46
|
? "None of these sellers accepts points."
|
|
@@ -3,12 +3,13 @@ import React from "react";
|
|
|
3
3
|
* Paid add-on checkboxes for ONE store.
|
|
4
4
|
*
|
|
5
5
|
* Add-on fees are billed per order group (= per store), so they are chosen per
|
|
6
|
-
* store: ticking "
|
|
6
|
+
* store: ticking "WhatsApp updates" for one seller must not bill it for every
|
|
7
7
|
* seller in the cart. Rendered inside that store's card in the cart, and inside
|
|
8
8
|
* that store's row of the checkout summary — Buy Now skips the cart entirely,
|
|
9
9
|
* so both surfaces have to be able to edit them.
|
|
10
10
|
*/
|
|
11
11
|
export interface StoreAddonsValue {
|
|
12
|
+
whatsappNotifyAddon?: boolean;
|
|
12
13
|
giftWrapAddon?: boolean;
|
|
13
14
|
giftWrapMessage?: string;
|
|
14
15
|
shipmentProtectionAddon?: boolean;
|
|
@@ -16,6 +17,8 @@ export interface StoreAddonsValue {
|
|
|
16
17
|
}
|
|
17
18
|
/** The subset of `siteSettings.commissions` that gates and prices the add-ons. */
|
|
18
19
|
export interface StoreAddonsRates {
|
|
20
|
+
whatsappNotifyFeeEnabled?: boolean;
|
|
21
|
+
whatsappNotifyFee?: number;
|
|
19
22
|
giftWrapFeeEnabled?: boolean;
|
|
20
23
|
giftWrapFee?: number;
|
|
21
24
|
shipmentProtectionFeeEnabled?: boolean;
|
|
@@ -56,7 +59,7 @@ export interface StoreProtectionContext {
|
|
|
56
59
|
* showing an explanatory line instead.
|
|
57
60
|
*
|
|
58
61
|
* 🛑 Deliberately asks only about TIER 1. A seller who supports neither
|
|
59
|
-
* protection still has gift wrap, and a store whose only
|
|
62
|
+
* protection still has WhatsApp updates and gift wrap, and a store whose only
|
|
60
63
|
* enabled add-ons are protections it declined must still render the picker —
|
|
61
64
|
* because that is where the risk notice lives, and a hidden notice is the
|
|
62
65
|
* tri-state failure in § `StoreProtectionContext`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { Gift, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react";
|
|
3
|
+
import { Gift, MessageCircle, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react";
|
|
4
4
|
import { Div, Row, Stack, Text } from "../../../ui/index.js";
|
|
5
5
|
import { Disclaimer } from "../../../ui/components/Disclaimer.js";
|
|
6
6
|
import { IconBox } from "../../../ui/components/IconBox.js";
|
|
@@ -16,14 +16,15 @@ import { formatCurrency } from "../../../utils/number.formatter.js";
|
|
|
16
16
|
* showing an explanatory line instead.
|
|
17
17
|
*
|
|
18
18
|
* 🛑 Deliberately asks only about TIER 1. A seller who supports neither
|
|
19
|
-
* protection still has gift wrap, and a store whose only
|
|
19
|
+
* protection still has WhatsApp updates and gift wrap, and a store whose only
|
|
20
20
|
* enabled add-ons are protections it declined must still render the picker —
|
|
21
21
|
* because that is where the risk notice lives, and a hidden notice is the
|
|
22
22
|
* tri-state failure in § `StoreProtectionContext`.
|
|
23
23
|
*/
|
|
24
24
|
export function hasAnyStoreAddon(rates) {
|
|
25
25
|
return (!!rates &&
|
|
26
|
-
!!(rates.
|
|
26
|
+
!!(rates.whatsappNotifyFeeEnabled ||
|
|
27
|
+
rates.giftWrapFeeEnabled ||
|
|
27
28
|
rates.shipmentProtectionFeeEnabled ||
|
|
28
29
|
rates.saleProtectionFeeEnabled));
|
|
29
30
|
}
|
|
@@ -51,7 +52,7 @@ export function StoreAddonsPicker({ storeId, storeSubtotal, value, onChange, rat
|
|
|
51
52
|
const shippingDeclined = !!rates.shipmentProtectionFeeEnabled && !protection?.supportsShippingProtection;
|
|
52
53
|
const saleDeclined = !!rates.saleProtectionFeeEnabled && !protection?.supportsSaleProtection;
|
|
53
54
|
const saleBlockedByFinalSale = !!rates.saleProtectionFeeEnabled && !!protection?.supportsSaleProtection && !!protection?.hasFinalSaleItem;
|
|
54
|
-
return (_jsxs(Stack, { gap: "xs", className: `min-w-0 ${className}`, children: [disabled && disabledReason && (_jsx(Text, { size: "xs", color: "muted", children: disabledReason })), rates.giftWrapFeeEnabled && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "brand", children: _jsx(Gift, { size: 14 }) }), _jsxs(Stack, { gap: "xs", className: "flex-1 min-w-0", children: [_jsx(FieldCheckbox, { name: `giftWrapAddon-${storeId}`, label: `Gift wrap (+${money(rates.giftWrapFee ?? 49)})`, checked: !!value.giftWrapAddon, onChange: (checked) => set({ giftWrapAddon: checked }), disabled: disabled }), showGiftMessage && value.giftWrapAddon && (_jsx(FieldTextarea, { name: `giftWrapMessage-${storeId}`, label: "Gift message (optional)", value: value.giftWrapMessage ?? "", onChange: (next) => set({ giftWrapMessage: next.slice(0, 500) }), rows: 2, maxLength: 500, showCharCount: true, placeholder: "Add a note for the recipient\u2026", disabled: disabled }))] })] })), shippingOffered && storeSubtotal > 0 && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "brand", children: _jsx(ShieldCheck, { size: 14 }) }), _jsx(Div, { className: "flex-1 min-w-0", children: _jsx(FieldCheckbox, { name: `shipmentProtectionAddon-${storeId}`, label: `Shipment protection — loss or damage in transit (+${money(protectionFee)})`, checked: !!value.shipmentProtectionAddon, onChange: (checked) => set({ shipmentProtectionAddon: checked }), disabled: disabled }) })] })), saleOffered && storeSubtotal > 0 && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "brand", children: _jsx(ShieldAlert, { size: 14 }) }), _jsx(Div, { className: "flex-1 min-w-0", children: _jsx(FieldCheckbox, { name: `saleProtectionAddon-${storeId}`, label: `Sale protection — item not as described (+${money(saleFee)})`, checked: !!value.saleProtectionAddon, onChange: (checked) => set({ saleProtectionAddon: checked }), disabled: disabled }) })] })), (shippingDeclined || saleDeclined) && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "muted", children: _jsx(ShieldOff, { size: 14 }) }), _jsx(Text, { size: "xs", color: "muted", className: "flex-1 min-w-0", children: shippingDeclined && saleDeclined
|
|
55
|
+
return (_jsxs(Stack, { gap: "xs", className: `min-w-0 ${className}`, children: [disabled && disabledReason && (_jsx(Text, { size: "xs", color: "muted", children: disabledReason })), rates.whatsappNotifyFeeEnabled && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "brand", children: _jsx(MessageCircle, { size: 14 }) }), _jsx(Div, { className: "flex-1 min-w-0", children: _jsx(FieldCheckbox, { name: `whatsappNotifyAddon-${storeId}`, label: `WhatsApp order updates (+${money(rates.whatsappNotifyFee ?? 10)})`, checked: !!value.whatsappNotifyAddon, onChange: (checked) => set({ whatsappNotifyAddon: checked }), disabled: disabled }) })] })), rates.giftWrapFeeEnabled && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "brand", children: _jsx(Gift, { size: 14 }) }), _jsxs(Stack, { gap: "xs", className: "flex-1 min-w-0", children: [_jsx(FieldCheckbox, { name: `giftWrapAddon-${storeId}`, label: `Gift wrap (+${money(rates.giftWrapFee ?? 49)})`, checked: !!value.giftWrapAddon, onChange: (checked) => set({ giftWrapAddon: checked }), disabled: disabled }), showGiftMessage && value.giftWrapAddon && (_jsx(FieldTextarea, { name: `giftWrapMessage-${storeId}`, label: "Gift message (optional)", value: value.giftWrapMessage ?? "", onChange: (next) => set({ giftWrapMessage: next.slice(0, 500) }), rows: 2, maxLength: 500, showCharCount: true, placeholder: "Add a note for the recipient\u2026", disabled: disabled }))] })] })), shippingOffered && storeSubtotal > 0 && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "brand", children: _jsx(ShieldCheck, { size: 14 }) }), _jsx(Div, { className: "flex-1 min-w-0", children: _jsx(FieldCheckbox, { name: `shipmentProtectionAddon-${storeId}`, label: `Shipment protection — loss or damage in transit (+${money(protectionFee)})`, checked: !!value.shipmentProtectionAddon, onChange: (checked) => set({ shipmentProtectionAddon: checked }), disabled: disabled }) })] })), saleOffered && storeSubtotal > 0 && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "brand", children: _jsx(ShieldAlert, { size: 14 }) }), _jsx(Div, { className: "flex-1 min-w-0", children: _jsx(FieldCheckbox, { name: `saleProtectionAddon-${storeId}`, label: `Sale protection — item not as described (+${money(saleFee)})`, checked: !!value.saleProtectionAddon, onChange: (checked) => set({ saleProtectionAddon: checked }), disabled: disabled }) })] })), (shippingDeclined || saleDeclined) && (_jsxs(Row, { gap: "sm", align: "start", className: "min-w-0", children: [_jsx(IconBox, { size: "sm", tone: "muted", children: _jsx(ShieldOff, { size: 14 }) }), _jsx(Text, { size: "xs", color: "muted", className: "flex-1 min-w-0", children: shippingDeclined && saleDeclined
|
|
55
56
|
? "This seller does not offer shipment or sale protection."
|
|
56
57
|
: shippingDeclined
|
|
57
58
|
? "This seller does not offer shipment protection."
|
|
@@ -577,7 +577,8 @@ export class CartRepository extends BaseRepository {
|
|
|
577
577
|
*/
|
|
578
578
|
async setStoreAddons(userId, storeId, addons) {
|
|
579
579
|
const isEmpty = !addons ||
|
|
580
|
-
(!addons.
|
|
580
|
+
(!addons.whatsappNotifyAddon &&
|
|
581
|
+
!addons.giftWrapAddon &&
|
|
581
582
|
!addons.shipmentProtectionAddon &&
|
|
582
583
|
!addons.saleProtectionAddon);
|
|
583
584
|
await this.db
|
|
@@ -272,11 +272,12 @@ export interface CartAppliedCoupon {
|
|
|
272
272
|
*
|
|
273
273
|
* Add-on fees have always been billed per order group (= per store) by
|
|
274
274
|
* `createOrderForGroup` / `previewCheckoutPricing`, but the selection used to be
|
|
275
|
-
* a single cart-wide boolean — so one tick on "
|
|
275
|
+
* a single cart-wide boolean — so one tick on "WhatsApp updates" quietly billed
|
|
276
276
|
* ₹10 × every store in the cart, with no way to opt in for just one seller.
|
|
277
277
|
* Keying the selection by storeId makes the choice match the billing.
|
|
278
278
|
*/
|
|
279
279
|
export interface CartStoreAddons {
|
|
280
|
+
whatsappNotifyAddon?: boolean;
|
|
280
281
|
giftWrapAddon?: boolean;
|
|
281
282
|
giftWrapMessage?: string;
|
|
282
283
|
/** Loss or damage IN TRANSIT. */
|
|
@@ -6,17 +6,12 @@
|
|
|
6
6
|
* purpose from `checkout-actions.ts`'s third-party shipping consent OTP —
|
|
7
7
|
* same crypto primitives, separate Firestore namespace and copy.
|
|
8
8
|
*/
|
|
9
|
+
export type CheckoutValueOtpChannel = "email" | "whatsapp";
|
|
9
10
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* than deleted so the parameter stays self-documenting and a second channel
|
|
15
|
-
* has an obvious seam.
|
|
16
|
-
*/
|
|
17
|
-
export type CheckoutValueOtpChannel = "email";
|
|
18
|
-
/**
|
|
19
|
-
* Sends a checkout-value OTP by email.
|
|
11
|
+
* Sends a checkout-value OTP either by email (default) or WhatsApp
|
|
12
|
+
* (opt-in — the buyer must explicitly choose it in the UI). WhatsApp phone
|
|
13
|
+
* is always resolved server-side from the selected shipping address —
|
|
14
|
+
* never trust a client-supplied phone number.
|
|
20
15
|
*
|
|
21
16
|
* If the chosen channel (and, for email, its skip-worthy fallback) is
|
|
22
17
|
* currently circuit-broken or unconfigured, the OTP gate is treated as
|
|
@@ -9,12 +9,17 @@
|
|
|
9
9
|
import { timingSafeEqual } from "crypto";
|
|
10
10
|
import { ValidationError } from "../../../errors/index.js";
|
|
11
11
|
import { normalizeError } from "../../../errors/normalize.js";
|
|
12
|
+
import { safeRead } from "../../../errors/safe-read.js";
|
|
12
13
|
import { serverLogger } from "../../../monitoring/index.js";
|
|
13
14
|
import { sendEmail } from "../../contact/email.js";
|
|
14
15
|
import { getAdminDb } from "../../../providers/db-firebase/index.js";
|
|
15
16
|
import { resolveDate } from "../../../utils/index.js";
|
|
17
|
+
import { addressesRepository } from "../../addresses/repository/addresses.repository.js";
|
|
18
|
+
import { siteSettingsRepository } from "../../admin/repository/site-settings.repository.js";
|
|
19
|
+
import { sendWhatsAppBusinessMessage } from "../../whatsapp-bot/helpers/whatsapp.js";
|
|
16
20
|
import { isChannelHealthy, withChannelRetry } from "../../../_internal/server/notifications/channel-health.js";
|
|
17
|
-
import {
|
|
21
|
+
import { guardSend, logSuppressedSend } from "../../../_internal/server/notifications/send-guard.js";
|
|
22
|
+
import { CHECKOUT_VALUE_OTP_EXPIRY_MS, CHECKOUT_VALUE_OTP_MAX_ATTEMPTS, hashOtp, generateOtpCode, checkoutValueOtpRef, enforceCheckoutValueOtpRateLimit, buildCheckoutValueOtpMessage, } from "../../auth/checkout-value-otp.js";
|
|
18
23
|
function maskEmail(userEmail) {
|
|
19
24
|
const [local, domain] = userEmail.split("@");
|
|
20
25
|
return domain
|
|
@@ -22,7 +27,10 @@ function maskEmail(userEmail) {
|
|
|
22
27
|
: "***";
|
|
23
28
|
}
|
|
24
29
|
/**
|
|
25
|
-
* Sends a checkout-value OTP by email
|
|
30
|
+
* Sends a checkout-value OTP either by email (default) or WhatsApp
|
|
31
|
+
* (opt-in — the buyer must explicitly choose it in the UI). WhatsApp phone
|
|
32
|
+
* is always resolved server-side from the selected shipping address —
|
|
33
|
+
* never trust a client-supplied phone number.
|
|
26
34
|
*
|
|
27
35
|
* If the chosen channel (and, for email, its skip-worthy fallback) is
|
|
28
36
|
* currently circuit-broken or unconfigured, the OTP gate is treated as
|
|
@@ -37,6 +45,84 @@ export async function sendCheckoutValueOtp(userId, userEmail, channel = "email",
|
|
|
37
45
|
const codeHash = hashOtp(code);
|
|
38
46
|
const expiresAt = new Date(Date.now() + CHECKOUT_VALUE_OTP_EXPIRY_MS);
|
|
39
47
|
const siteName = process.env.NEXT_PUBLIC_SITE_NAME || "LetItRip";
|
|
48
|
+
if (channel === "whatsapp") {
|
|
49
|
+
if (!addressId) {
|
|
50
|
+
throw new ValidationError("Select a delivery address before sending the code via WhatsApp.");
|
|
51
|
+
}
|
|
52
|
+
// A failed settings read makes the WhatsApp channel look deliberately
|
|
53
|
+
// switched off ("send the code by email instead") — a defensible fallback,
|
|
54
|
+
// but one an operator has to be able to see, since it is indistinguishable
|
|
55
|
+
// from an admin having disabled it.
|
|
56
|
+
const settings = await safeRead(() => siteSettingsRepository.getSingleton(), {
|
|
57
|
+
route: "checkout/value-otp",
|
|
58
|
+
key: "checkoutValueOtp.siteSettings",
|
|
59
|
+
fallback: null,
|
|
60
|
+
});
|
|
61
|
+
const creds = settings?.credentials ?? {};
|
|
62
|
+
const whatsappOtpEnabled = settings?.notificationChannels?.whatsapp?.otpEnabled === true;
|
|
63
|
+
const phoneNumberId = creds.whatsappPhoneNumberId?.trim() ?? "";
|
|
64
|
+
const accessToken = creds.whatsappCloudApiToken?.trim() ?? "";
|
|
65
|
+
const credsReady = phoneNumberId && accessToken && !phoneNumberId.includes("PLACEHOLDER") && !accessToken.includes("PLACEHOLDER");
|
|
66
|
+
if (!whatsappOtpEnabled || !credsReady) {
|
|
67
|
+
throw new ValidationError("WhatsApp verification isn't available right now — send the code by email instead.");
|
|
68
|
+
}
|
|
69
|
+
const address = await addressesRepository.findById(addressId);
|
|
70
|
+
/*
|
|
71
|
+
* REVEALED: this phone receives the high-value checkout OTP, so it has to
|
|
72
|
+
* be the real number. `addressesRepository` is ciphertext-by-default, and
|
|
73
|
+
* `.trim()` on the stored value is what the nominal brand caught — the
|
|
74
|
+
* old intersection form let `Encrypted<string>` call string methods.
|
|
75
|
+
*/
|
|
76
|
+
const addressPlain = address ? addressesRepository.revealAddress(address) : null;
|
|
77
|
+
const phone = addressPlain?.phone?.trim();
|
|
78
|
+
if (!phone) {
|
|
79
|
+
throw new ValidationError("The selected address has no phone number on file — send the code by email instead.");
|
|
80
|
+
}
|
|
81
|
+
if (!(await isChannelHealthy("whatsapp"))) {
|
|
82
|
+
serverLogger.warn("sendCheckoutValueOtp: WhatsApp channel unhealthy — skipping OTP gate", { uid: userId, channel });
|
|
83
|
+
return { skipped: true };
|
|
84
|
+
}
|
|
85
|
+
await checkoutValueOtpRef(db, userId).set({
|
|
86
|
+
codeHash,
|
|
87
|
+
expiresAt,
|
|
88
|
+
attempts: 0,
|
|
89
|
+
verified: false,
|
|
90
|
+
createdAt: new Date(),
|
|
91
|
+
});
|
|
92
|
+
const message = buildCheckoutValueOtpMessage(code, siteName);
|
|
93
|
+
// Same guard as the email branch below, same reasoning: transactional, so
|
|
94
|
+
// the kill switch does not apply (the buyer cannot check out without the
|
|
95
|
+
// code), but the daily ceiling does.
|
|
96
|
+
const waGuard = {
|
|
97
|
+
channel: "whatsapp",
|
|
98
|
+
feature: "checkout_value_otp",
|
|
99
|
+
audience: "user",
|
|
100
|
+
transactional: true,
|
|
101
|
+
};
|
|
102
|
+
const waDecision = await guardSend(waGuard);
|
|
103
|
+
if (!waDecision.allow) {
|
|
104
|
+
logSuppressedSend(waGuard, waDecision);
|
|
105
|
+
throw new ValidationError("We couldn't send the verification code via WhatsApp — please try again, or send it by email instead.");
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const sent = await withChannelRetry("whatsapp", async () => {
|
|
109
|
+
const ok = await sendWhatsAppBusinessMessage({ toPhone: phone, message, phoneNumberId, accessToken });
|
|
110
|
+
if (!ok)
|
|
111
|
+
throw new Error("WhatsApp Cloud API returned a non-OK response");
|
|
112
|
+
return ok;
|
|
113
|
+
});
|
|
114
|
+
if (!sent)
|
|
115
|
+
throw new Error("WhatsApp send failed");
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
void normalizeError(err);
|
|
119
|
+
serverLogger.error("Failed to send checkout value OTP via WhatsApp", { uid: userId, err });
|
|
120
|
+
throw new ValidationError("We couldn't send the verification code via WhatsApp — please try again, or send it by email instead.");
|
|
121
|
+
}
|
|
122
|
+
const maskedPhone = phone.length > 4 ? `${"*".repeat(phone.length - 4)}${phone.slice(-4)}` : "***";
|
|
123
|
+
serverLogger.info(`Checkout value OTP sent via WhatsApp: uid=${userId}`);
|
|
124
|
+
return { maskedPhone };
|
|
125
|
+
}
|
|
40
126
|
if (!(await isChannelHealthy("email"))) {
|
|
41
127
|
serverLogger.warn("sendCheckoutValueOtp: email channel unhealthy — skipping OTP gate", { uid: userId, channel });
|
|
42
128
|
return { skipped: true };
|
|
@@ -259,7 +259,7 @@ const SECTION_REGISTRY = {
|
|
|
259
259
|
return (_jsx(WhatsAppCommunitySection, { title: cleanTitle(cfg?.title) || SECTION_TITLE.whatsappCommunity, descriptionHtml: cfg?.description || SECTION_COPY.whatsappDescription,
|
|
260
260
|
// No `|| 5000` fallback: inventing a member count the site cannot
|
|
261
261
|
// verify is a fabricated social-proof number.
|
|
262
|
-
memberCount: cfg?.memberCount, groupLink: cfg?.groupLink ||
|
|
262
|
+
memberCount: cfg?.memberCount, groupLink: cfg?.groupLink || "https://chat.whatsapp.com/", benefits: cfg?.benefits ?? [], testimonial: cfg?.testimonial, buttonText: cfg?.buttonText }));
|
|
263
263
|
},
|
|
264
264
|
},
|
|
265
265
|
faq: {
|
|
@@ -79,6 +79,7 @@ export async function issuePartialRefund(adminUid, orderId, deductFees, refundNo
|
|
|
79
79
|
: `Your full refund of ₹${grossRefund} is being processed.`,
|
|
80
80
|
relatedId: orderId,
|
|
81
81
|
relatedType: "order",
|
|
82
|
+
storeId: order.storeId,
|
|
82
83
|
});
|
|
83
84
|
serverLogger.info("Admin partial refund initiated", {
|
|
84
85
|
adminUid,
|
|
@@ -2,7 +2,7 @@ import React from "react";
|
|
|
2
2
|
/**
|
|
3
3
|
* Icon badges for an order's paid add-ons and applied coupon.
|
|
4
4
|
*
|
|
5
|
-
* These are fulfilment instructions, not decoration:
|
|
5
|
+
* These are fulfilment instructions, not decoration: WhatsApp tells the
|
|
6
6
|
* status-change notifier to message this buyer, gift wrap tells the packer to
|
|
7
7
|
* wrap the parcel and enclose the message, and either protection changes how a
|
|
8
8
|
* claim is handled. They are recorded on the order (and filterable in the
|
|
@@ -14,6 +14,8 @@ import React from "react";
|
|
|
14
14
|
* at once instead of being wired per screen.
|
|
15
15
|
*/
|
|
16
16
|
export interface OrderAddonBadgesOrder {
|
|
17
|
+
whatsappNotifyAddon?: boolean;
|
|
18
|
+
whatsappNotifyFee?: number;
|
|
17
19
|
giftWrapAddon?: boolean;
|
|
18
20
|
giftWrapFee?: number;
|
|
19
21
|
giftWrapMessage?: string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { Gift, ShieldAlert, ShieldCheck, Tag } from "lucide-react";
|
|
3
|
+
import { Gift, MessageCircle, ShieldAlert, ShieldCheck, Tag } from "lucide-react";
|
|
4
4
|
import { Row, Span, Stack, Text } from "../../../ui/index.js";
|
|
5
5
|
import { formatCurrency } from "../../../utils/number.formatter.js";
|
|
6
6
|
const CHIP_BASE = "inline-flex items-center gap-[var(--appkit-space-1)] rounded-full px-[var(--appkit-space-2)] py-[var(--appkit-space-0-5)] text-[length:var(--appkit-text-xs)] font-medium";
|
|
@@ -15,14 +15,15 @@ function Chip({ icon, label, tone, title, }) {
|
|
|
15
15
|
export function OrderAddonBadges({ order, variant = "compact", showCoupon = true, currency = "INR", className = "", }) {
|
|
16
16
|
const money = (v) => (typeof v === "number" && v > 0 ? formatCurrency(v, currency) : undefined);
|
|
17
17
|
const hasCoupon = showCoupon && !!order.couponCode;
|
|
18
|
-
const hasAny = order.
|
|
18
|
+
const hasAny = order.whatsappNotifyAddon ||
|
|
19
|
+
order.giftWrapAddon ||
|
|
19
20
|
order.shipmentProtectionAddon ||
|
|
20
21
|
order.saleProtectionAddon ||
|
|
21
22
|
hasCoupon;
|
|
22
23
|
if (!hasAny)
|
|
23
24
|
return null;
|
|
24
25
|
const isDetail = variant === "detail";
|
|
25
|
-
return (_jsxs(Stack, { gap: "xs", className: `min-w-0 ${className}`, children: [_jsxs(Row, { gap: "xs", align: "center", wrap: true, className: "min-w-0", children: [order.giftWrapAddon && (_jsx(Chip, { icon: _jsx(Gift, { size: 12 }), label: isDetail && money(order.giftWrapFee) ? `Gift wrap · ${money(order.giftWrapFee)}` : "Gift wrap", tone: "brand", title: "This parcel must be gift-wrapped before dispatch." })), order.shipmentProtectionAddon && (_jsx(Chip, { icon: _jsx(ShieldCheck, { size: 12 }), label: isDetail && money(order.shipmentProtectionFee) ? `Protected · ${money(order.shipmentProtectionFee)}` : "Protected", tone: "info", title: "Shipment protection purchased \u2014 loss or damage in transit is covered, claimed against the held funds." })), order.saleProtectionAddon && (_jsx(Chip, { icon: _jsx(ShieldAlert, { size: 12 }), label: isDetail && money(order.saleProtectionFee) ? `Sale protected · ${money(order.saleProtectionFee)}` : "Sale protected", tone: "info", title: "Sale protection purchased \u2014 if the item is not as described the buyer is refunded, no questions asked." })), hasCoupon && (_jsx(Chip, { icon: _jsx(Tag, { size: 12 }), label: isDetail && money(order.couponDiscount)
|
|
26
|
+
return (_jsxs(Stack, { gap: "xs", className: `min-w-0 ${className}`, children: [_jsxs(Row, { gap: "xs", align: "center", wrap: true, className: "min-w-0", children: [order.whatsappNotifyAddon && (_jsx(Chip, { icon: _jsx(MessageCircle, { size: 12 }), label: isDetail && money(order.whatsappNotifyFee) ? `WhatsApp updates · ${money(order.whatsappNotifyFee)}` : "WhatsApp updates", tone: "success", title: "Buyer opted into WhatsApp order-status updates \u2014 status changes on this order should notify them on WhatsApp." })), order.giftWrapAddon && (_jsx(Chip, { icon: _jsx(Gift, { size: 12 }), label: isDetail && money(order.giftWrapFee) ? `Gift wrap · ${money(order.giftWrapFee)}` : "Gift wrap", tone: "brand", title: "This parcel must be gift-wrapped before dispatch." })), order.shipmentProtectionAddon && (_jsx(Chip, { icon: _jsx(ShieldCheck, { size: 12 }), label: isDetail && money(order.shipmentProtectionFee) ? `Protected · ${money(order.shipmentProtectionFee)}` : "Protected", tone: "info", title: "Shipment protection purchased \u2014 loss or damage in transit is covered, claimed against the held funds." })), order.saleProtectionAddon && (_jsx(Chip, { icon: _jsx(ShieldAlert, { size: 12 }), label: isDetail && money(order.saleProtectionFee) ? `Sale protected · ${money(order.saleProtectionFee)}` : "Sale protected", tone: "info", title: "Sale protection purchased \u2014 if the item is not as described the buyer is refunded, no questions asked." })), hasCoupon && (_jsx(Chip, { icon: _jsx(Tag, { size: 12 }), label: isDetail && money(order.couponDiscount)
|
|
26
27
|
? `${order.couponCode} · −${money(order.couponDiscount)}`
|
|
27
28
|
: order.couponCode, tone: "success", title: `Coupon ${order.couponCode} applied to this order.` }))] }), isDetail && order.giftWrapAddon && order.giftWrapMessage && (_jsxs(Stack, { gap: "xs", className: "min-w-0", children: [_jsx(Text, { size: "xs", color: "muted", weight: "semibold", transform: "uppercase", className: "tracking-wide", children: "Gift message" }), _jsx(Text, { size: "sm", color: "primary", className: "min-w-0 whitespace-pre-wrap break-words", children: order.giftWrapMessage })] }))] }));
|
|
28
29
|
}
|
|
@@ -215,6 +215,10 @@ declare class OrderRepository extends BaseRepository<OrderDocument> {
|
|
|
215
215
|
canFilter: boolean;
|
|
216
216
|
canSort: boolean;
|
|
217
217
|
};
|
|
218
|
+
whatsappNotifyAddon: {
|
|
219
|
+
canFilter: boolean;
|
|
220
|
+
canSort: boolean;
|
|
221
|
+
};
|
|
218
222
|
giftWrapAddon: {
|
|
219
223
|
canFilter: boolean;
|
|
220
224
|
canSort: boolean;
|
|
@@ -335,6 +339,10 @@ declare class OrderRepository extends BaseRepository<OrderDocument> {
|
|
|
335
339
|
canFilter: boolean;
|
|
336
340
|
canSort: boolean;
|
|
337
341
|
};
|
|
342
|
+
whatsappNotifyAddon: {
|
|
343
|
+
canFilter: boolean;
|
|
344
|
+
canSort: boolean;
|
|
345
|
+
};
|
|
338
346
|
giftWrapAddon: {
|
|
339
347
|
canFilter: boolean;
|
|
340
348
|
canSort: boolean;
|
|
@@ -1364,6 +1364,7 @@ OrderRepository.SELLER_SIEVE_FIELDS = {
|
|
|
1364
1364
|
// Fulfilment flags. Filterable so "which of my orders need gift wrap?" is a
|
|
1365
1365
|
// query against the orders themselves — the whole point of recording the
|
|
1366
1366
|
// add-on on the order rather than keeping a side list of who asked for what.
|
|
1367
|
+
whatsappNotifyAddon: { canFilter: true, canSort: false },
|
|
1367
1368
|
giftWrapAddon: { canFilter: true, canSort: false },
|
|
1368
1369
|
shipmentProtectionAddon: { canFilter: true, canSort: false },
|
|
1369
1370
|
saleProtectionAddon: { canFilter: true, canSort: false },
|
|
@@ -1396,6 +1397,7 @@ OrderRepository.ADMIN_SIEVE_FIELDS = {
|
|
|
1396
1397
|
// Same fulfilment flags as SELLER_SIEVE_FIELDS — admin needs to answer
|
|
1397
1398
|
// "who opted into WhatsApp updates?" when a status change goes out, without
|
|
1398
1399
|
// maintaining a parallel tracker.
|
|
1400
|
+
whatsappNotifyAddon: { canFilter: true, canSort: false },
|
|
1399
1401
|
giftWrapAddon: { canFilter: true, canSort: false },
|
|
1400
1402
|
shipmentProtectionAddon: { canFilter: true, canSort: false },
|
|
1401
1403
|
saleProtectionAddon: { canFilter: true, canSort: false },
|