@mohasinac/appkit 4.63.3 → 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/promotions/adapters.d.ts +25 -0
- package/dist/_internal/server/features/promotions/adapters.js +43 -0
- package/dist/_internal/server/features/promotions/data.d.ts +26 -0
- package/dist/_internal/server/features/promotions/data.js +45 -0
- package/dist/_internal/server/features/promotions/index.d.ts +2 -1
- package/dist/_internal/server/features/promotions/index.js +2 -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/triggerEventRaffle.js +45 -14
- 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/field-names.d.ts +4 -0
- package/dist/constants/field-names.js +4 -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/components/UserSidebar.js +1 -1
- 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/AdminBlogEditorView.js +10 -2
- 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/blog/schemas/blog-form.d.ts +3 -0
- package/dist/features/blog/schemas/blog-form.js +14 -0
- package/dist/features/blog/schemas/firestore.d.ts +17 -0
- package/dist/features/blog/schemas/firestore.js +1 -0
- package/dist/features/blog/schemas/index.d.ts +6 -0
- package/dist/features/blog/schemas/index.js +3 -0
- package/dist/features/blog/types/index.d.ts +2 -0
- 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/events/components/AdminEventEditorView.js +64 -9
- package/dist/features/events/components/EventOfferCard.js +3 -18
- package/dist/features/events/components/EventRaffleWinnerView.d.ts +6 -0
- package/dist/features/events/components/EventRaffleWinnerView.js +11 -1
- package/dist/features/events/schemas/event-form.d.ts +10 -0
- package/dist/features/events/schemas/event-form.js +19 -0
- package/dist/features/events/schemas/firestore.d.ts +68 -0
- package/dist/features/events/schemas/firestore.js +25 -0
- package/dist/features/events/schemas/index.d.ts +7 -0
- package/dist/features/events/schemas/index.js +5 -0
- package/dist/features/events/types/index.d.ts +4 -0
- 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/promotions/components/AttachedCouponsSection.d.ts +30 -0
- package/dist/features/promotions/components/AttachedCouponsSection.js +99 -0
- package/dist/features/promotions/components/CouponCodeCopyRow.d.ts +19 -0
- package/dist/features/promotions/components/CouponCodeCopyRow.js +39 -0
- package/dist/features/promotions/components/index.d.ts +4 -0
- package/dist/features/promotions/components/index.js +2 -0
- package/dist/features/promotions/constants/attached-coupons.d.ts +55 -0
- package/dist/features/promotions/constants/attached-coupons.js +24 -0
- package/dist/features/promotions/repository/coupons.repository.d.ts +20 -0
- package/dist/features/promotions/repository/coupons.repository.js +39 -0
- package/dist/features/promotions/schemas/attached-coupons.d.ts +39 -0
- package/dist/features/promotions/schemas/attached-coupons.js +66 -0
- 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 +121 -0
- package/dist/schemas/registry.js +2 -1
- package/dist/security/pii-encrypt.js +1 -1
- package/dist/server-entry.d.ts +1 -0
- package/dist/server-entry.js +2 -0
- package/dist/server.d.ts +11 -2
- package/dist/server.js +26 -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
package/dist/client.d.ts
CHANGED
|
@@ -545,7 +545,7 @@ export type { FeeDerivation, FeeDerivations } from "./_internal/shared/fees/expl
|
|
|
545
545
|
export type { BuyerFacingFees, BuyerEmiSettings, } from "./_internal/shared/features/site-settings/fees";
|
|
546
546
|
export type { PublicSiteSettings } from "./_internal/shared/features/site-settings/types";
|
|
547
547
|
export { computeCodHandlingFee } from "./_internal/shared/fees/calculator";
|
|
548
|
-
export type { CodHandlingFeeRates, GiftWrapFeeRates, ShipmentProtectionFeeRates, } from "./_internal/shared/fees/calculator";
|
|
548
|
+
export type { CodHandlingFeeRates, WhatsAppNotifyFeeRates, GiftWrapFeeRates, ShipmentProtectionFeeRates, } from "./_internal/shared/fees/calculator";
|
|
549
549
|
export { FAQPageContent } from "./features/faq/index";
|
|
550
550
|
export type { FAQCategory, FAQCategoryItem, FAQSortOption } from "./features/faq/index";
|
|
551
551
|
export { CategoryInlineSelect } from "./features/seller/components/CategoryInlineSelect";
|
|
@@ -571,7 +571,7 @@ export type { AdCreativeFormValues } from "./features/ads/schemas/creative-form"
|
|
|
571
571
|
export { adminStoreUpdateSchema, announcementBarSchema, } from "./features/admin/schemas/admin-editor-forms";
|
|
572
572
|
export { adminOrderUpdateSchema, employeeInviteSchema, hardBanReasonSchema, featureFlagsUpdateSchema, adminAdFormSchema, } from "./features/admin/schemas/admin-ops-forms";
|
|
573
573
|
export { changeEmailSchema } from "./features/account/schemas/index";
|
|
574
|
-
export { adminUserUpdateSchema, lotteryPullSchema, } from "./features/admin/schemas/admin-user-form";
|
|
574
|
+
export { adminUserUpdateSchema, whatsappSettingsSchema, lotteryPullSchema, } from "./features/admin/schemas/admin-user-form";
|
|
575
575
|
export { siteSettingsFormSchema } from "./features/admin/schemas/site-settings-form";
|
|
576
576
|
export { refineAddressPostal, addressFormSchema, addressUpdateSchema, adminAddressCreateSchema, adminAddressUpdateSchema, addressUnbanRequestSchema, } from "./features/addresses/schemas/address-form";
|
|
577
577
|
export type { AddressFormValues, AddressUpdateValues, AdminAddressCreateValues, } from "./features/addresses/schemas/address-form";
|
package/dist/client.js
CHANGED
|
@@ -619,7 +619,7 @@ export { adCreativeFormSchema } from "./features/ads/schemas/creative-form.js";
|
|
|
619
619
|
export { adminStoreUpdateSchema, announcementBarSchema, } from "./features/admin/schemas/admin-editor-forms.js";
|
|
620
620
|
export { adminOrderUpdateSchema, employeeInviteSchema, hardBanReasonSchema, featureFlagsUpdateSchema, adminAdFormSchema, } from "./features/admin/schemas/admin-ops-forms.js";
|
|
621
621
|
export { changeEmailSchema } from "./features/account/schemas/index.js";
|
|
622
|
-
export { adminUserUpdateSchema, lotteryPullSchema, } from "./features/admin/schemas/admin-user-form.js";
|
|
622
|
+
export { adminUserUpdateSchema, whatsappSettingsSchema, lotteryPullSchema, } from "./features/admin/schemas/admin-user-form.js";
|
|
623
623
|
export { siteSettingsFormSchema } from "./features/admin/schemas/site-settings-form.js";
|
|
624
624
|
// The ONE address shape and the ONE postal rule (W5 / D19). Eleven field
|
|
625
625
|
// shapes and fifteen disagreeing postal rules collapsed to these.
|
|
@@ -626,6 +626,11 @@ export declare const SCAMMER_ENDPOINTS: {
|
|
|
626
626
|
export declare const BEFORE_AFTER_ENDPOINTS: {
|
|
627
627
|
readonly LIST: "/api/before-after";
|
|
628
628
|
};
|
|
629
|
+
export declare const WHATSAPP_SELLER_ENDPOINTS: {
|
|
630
|
+
readonly SETTINGS: "/api/store/whatsapp-settings";
|
|
631
|
+
readonly CATALOG_SYNC: "/api/store/whatsapp-settings/catalog-sync";
|
|
632
|
+
readonly CATALOG_IMPORT: "/api/store/whatsapp-settings/catalog-import";
|
|
633
|
+
};
|
|
629
634
|
export declare const API_ENDPOINTS: {
|
|
630
635
|
readonly CONFIG: {};
|
|
631
636
|
readonly LOGS: {
|
|
@@ -1178,6 +1183,11 @@ export declare const API_ENDPOINTS: {
|
|
|
1178
1183
|
readonly PRODUCTS: (sellerId: string) => string;
|
|
1179
1184
|
readonly REVIEWS: (userId: string) => string;
|
|
1180
1185
|
};
|
|
1186
|
+
readonly WHATSAPP_SELLER: {
|
|
1187
|
+
readonly SETTINGS: "/api/store/whatsapp-settings";
|
|
1188
|
+
readonly CATALOG_SYNC: "/api/store/whatsapp-settings/catalog-sync";
|
|
1189
|
+
readonly CATALOG_IMPORT: "/api/store/whatsapp-settings/catalog-import";
|
|
1190
|
+
};
|
|
1181
1191
|
readonly SUPPORT: {
|
|
1182
1192
|
readonly TICKETS: "/api/support/tickets";
|
|
1183
1193
|
readonly TICKET_BY_ID: (id: string) => string;
|
|
@@ -1764,6 +1774,11 @@ export declare const API_ROUTES: {
|
|
|
1764
1774
|
readonly PRODUCTS: (sellerId: string) => string;
|
|
1765
1775
|
readonly REVIEWS: (userId: string) => string;
|
|
1766
1776
|
};
|
|
1777
|
+
readonly WHATSAPP_SELLER: {
|
|
1778
|
+
readonly SETTINGS: "/api/store/whatsapp-settings";
|
|
1779
|
+
readonly CATALOG_SYNC: "/api/store/whatsapp-settings/catalog-sync";
|
|
1780
|
+
readonly CATALOG_IMPORT: "/api/store/whatsapp-settings/catalog-import";
|
|
1781
|
+
};
|
|
1767
1782
|
readonly SUPPORT: {
|
|
1768
1783
|
readonly TICKETS: "/api/support/tickets";
|
|
1769
1784
|
readonly TICKET_BY_ID: (id: string) => string;
|
|
@@ -829,6 +829,13 @@ export const BEFORE_AFTER_ENDPOINTS = {
|
|
|
829
829
|
LIST: "/api/before-after",
|
|
830
830
|
};
|
|
831
831
|
// ---------------------------------------------------------------------------
|
|
832
|
+
// WhatsApp Business (store-level settings + catalog sync)
|
|
833
|
+
// ---------------------------------------------------------------------------
|
|
834
|
+
export const WHATSAPP_SELLER_ENDPOINTS = {
|
|
835
|
+
SETTINGS: "/api/store/whatsapp-settings",
|
|
836
|
+
CATALOG_SYNC: "/api/store/whatsapp-settings/catalog-sync",
|
|
837
|
+
CATALOG_IMPORT: "/api/store/whatsapp-settings/catalog-import",
|
|
838
|
+
};
|
|
832
839
|
// ---------------------------------------------------------------------------
|
|
833
840
|
// Aggregate namespace export
|
|
834
841
|
// ---------------------------------------------------------------------------
|
|
@@ -873,6 +880,7 @@ export const API_ENDPOINTS = {
|
|
|
873
880
|
SUBLISTING_CATEGORIES: SUBLISTING_CATEGORY_ENDPOINTS,
|
|
874
881
|
WISHLIST: WISHLIST_ENDPOINTS,
|
|
875
882
|
PROFILE_STATS: PROFILE_STATS_ENDPOINTS,
|
|
883
|
+
WHATSAPP_SELLER: WHATSAPP_SELLER_ENDPOINTS,
|
|
876
884
|
SUPPORT: SUPPORT_ENDPOINTS,
|
|
877
885
|
SCAMMERS: SCAMMER_ENDPOINTS,
|
|
878
886
|
REPORTS: REPORT_ENDPOINTS,
|
|
@@ -382,11 +382,15 @@ export declare const EVENT_FIELDS: {
|
|
|
382
382
|
readonly ENDS_AT: "endsAt";
|
|
383
383
|
readonly CREATED_BY: "createdBy";
|
|
384
384
|
readonly STATS: "stats";
|
|
385
|
+
/** Promotional coupons shown on the event page — not prizes. */
|
|
386
|
+
readonly COUPON_IDS: "couponIds";
|
|
385
387
|
readonly HAS_RAFFLE: "hasRaffle";
|
|
386
388
|
readonly RAFFLE_TYPE: "raffleType";
|
|
387
389
|
readonly RAFFLE_TOP_N: "raffleTopN";
|
|
388
390
|
readonly RAFFLE_PRIZE: "rafflePrize";
|
|
391
|
+
/** @deprecated legacy scalar — resolve via `rafflePrizeCouponIdsOf`. */
|
|
389
392
|
readonly RAFFLE_PRIZE_COUPON_ID: "rafflePrizeCouponId";
|
|
393
|
+
readonly RAFFLE_PRIZE_COUPON_IDS: "rafflePrizeCouponIds";
|
|
390
394
|
readonly RAFFLE_GITHUB_FUNCTION_URL: "raffleGithubFunctionUrl";
|
|
391
395
|
readonly RAFFLE_WINNER_USER_ID: "raffleWinnerUserId";
|
|
392
396
|
readonly RAFFLE_WINNER_DISPLAY_NAME: "raffleWinnerDisplayName";
|
|
@@ -436,12 +436,16 @@ export const EVENT_FIELDS = {
|
|
|
436
436
|
ENDS_AT: "endsAt",
|
|
437
437
|
CREATED_BY: "createdBy",
|
|
438
438
|
STATS: "stats",
|
|
439
|
+
/** Promotional coupons shown on the event page — not prizes. */
|
|
440
|
+
COUPON_IDS: "couponIds",
|
|
439
441
|
// Raffle fields (SB9)
|
|
440
442
|
HAS_RAFFLE: "hasRaffle",
|
|
441
443
|
RAFFLE_TYPE: "raffleType",
|
|
442
444
|
RAFFLE_TOP_N: "raffleTopN",
|
|
443
445
|
RAFFLE_PRIZE: "rafflePrize",
|
|
446
|
+
/** @deprecated legacy scalar — resolve via `rafflePrizeCouponIdsOf`. */
|
|
444
447
|
RAFFLE_PRIZE_COUPON_ID: "rafflePrizeCouponId",
|
|
448
|
+
RAFFLE_PRIZE_COUPON_IDS: "rafflePrizeCouponIds",
|
|
445
449
|
RAFFLE_GITHUB_FUNCTION_URL: "raffleGithubFunctionUrl",
|
|
446
450
|
RAFFLE_WINNER_USER_ID: "raffleWinnerUserId",
|
|
447
451
|
RAFFLE_WINNER_DISPLAY_NAME: "raffleWinnerDisplayName",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* **18 of the 27 real types** and was offered two that never appear.
|
|
13
13
|
* · `AdminSiteSettingsView`'s per-channel allow-list, so an admin literally
|
|
14
14
|
* **could not allow-list `offer_received` or `payment_review`** for email
|
|
15
|
-
* — the toggle did not exist.
|
|
15
|
+
* or WhatsApp — the toggle did not exist.
|
|
16
16
|
*
|
|
17
17
|
* Everything here now derives from `NOTIFICATION_TYPE_VALUES`, so a new
|
|
18
18
|
* notification type is one edit and cannot be missing from either surface.
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* **18 of the 27 real types** and was offered two that never appear.
|
|
13
13
|
* · `AdminSiteSettingsView`'s per-channel allow-list, so an admin literally
|
|
14
14
|
* **could not allow-list `offer_received` or `payment_review`** for email
|
|
15
|
-
* — the toggle did not exist.
|
|
15
|
+
* or WhatsApp — the toggle did not exist.
|
|
16
16
|
*
|
|
17
17
|
* Everything here now derives from `NOTIFICATION_TYPE_VALUES`, so a new
|
|
18
18
|
* notification type is one edit and cannot be missing from either surface.
|
|
@@ -12,6 +12,11 @@ export interface ResolvedKeys {
|
|
|
12
12
|
/** Webhook signing secret. Optional in practice: without it `verifyWebhook()` fails closed and the buyer-present /verify path still works. */
|
|
13
13
|
razorpayWebhookSecret: string;
|
|
14
14
|
resendApiKey: string;
|
|
15
|
+
whatsappApiKey: string;
|
|
16
|
+
whatsappPhoneNumberId: string;
|
|
17
|
+
whatsappCloudApiToken: string;
|
|
18
|
+
/** Comma-separated digits-only numbers, e.g. "919876543210,918765432109" */
|
|
19
|
+
whatsappAdminNotifyNumbers: string;
|
|
15
20
|
}
|
|
16
21
|
/** Drops the in-process cache. Call this after admin saves new credentials. */
|
|
17
22
|
export declare function invalidateIntegrationKeysCache(): void;
|
|
@@ -32,6 +32,10 @@ const EMPTY_KEYS = {
|
|
|
32
32
|
razorpayKeySecret: "",
|
|
33
33
|
razorpayWebhookSecret: "",
|
|
34
34
|
resendApiKey: "",
|
|
35
|
+
whatsappApiKey: "",
|
|
36
|
+
whatsappPhoneNumberId: "",
|
|
37
|
+
whatsappCloudApiToken: "",
|
|
38
|
+
whatsappAdminNotifyNumbers: "",
|
|
35
39
|
};
|
|
36
40
|
let _cache = null;
|
|
37
41
|
const CACHE_TTL_MS = 60000; // 1 minute
|
|
@@ -79,6 +83,12 @@ export async function resolveKeys() {
|
|
|
79
83
|
phonepeWebhookUsername: db.phonepeWebhookUsername || process.env.PHONEPE_WEBHOOK_USERNAME || "",
|
|
80
84
|
phonepeWebhookPassword: db.phonepeWebhookPassword || process.env.PHONEPE_WEBHOOK_PASSWORD || "",
|
|
81
85
|
resendApiKey: db.resendApiKey || process.env.RESEND_API_KEY || "",
|
|
86
|
+
whatsappApiKey: db.whatsappApiKey || process.env.WHATSAPP_API_KEY || "",
|
|
87
|
+
whatsappPhoneNumberId: db.whatsappPhoneNumberId || process.env.WHATSAPP_PHONE_NUMBER_ID || "",
|
|
88
|
+
whatsappCloudApiToken: db.whatsappCloudApiToken || process.env.WHATSAPP_CLOUD_API_TOKEN || "",
|
|
89
|
+
whatsappAdminNotifyNumbers: db.whatsappAdminNotifyNumbers ||
|
|
90
|
+
process.env.WHATSAPP_ADMIN_NOTIFY_NUMBERS ||
|
|
91
|
+
"",
|
|
82
92
|
};
|
|
83
93
|
_cache = { value, expiresAt: Date.now() + CACHE_TTL_MS };
|
|
84
94
|
return value;
|
|
@@ -66,6 +66,12 @@ export async function FeesView({} = {}) {
|
|
|
66
66
|
who: t("paidByBuyer"),
|
|
67
67
|
note: t("codHandlingFeeNote"),
|
|
68
68
|
},
|
|
69
|
+
{
|
|
70
|
+
category: t("whatsappNotifyFeeTitle"),
|
|
71
|
+
rate: t("whatsappNotifyFeeRate"),
|
|
72
|
+
who: t("paidByBuyer"),
|
|
73
|
+
note: t("whatsappNotifyFeeNote"),
|
|
74
|
+
},
|
|
69
75
|
{
|
|
70
76
|
category: t("giftWrapFeeTitle"),
|
|
71
77
|
rate: t("giftWrapFeeRate"),
|
|
@@ -25,7 +25,7 @@ const SECTIONS = [
|
|
|
25
25
|
},
|
|
26
26
|
{
|
|
27
27
|
Icon: CheckCircle, title: "Order Confirmation",
|
|
28
|
-
content: (_jsxs(_Fragment, { children: [_jsx(Text, { className: GC.textMuted, children: "After a successful payment you'll see an on-screen confirmation with your order ID. You'll also receive:" }), _jsxs(Ul, { className: `${GC.listDiscMuted} mt-3`, children: [_jsx(Li, { children: "An email confirmation to your registered address" }), _jsx(Li, { children: "
|
|
28
|
+
content: (_jsxs(_Fragment, { children: [_jsx(Text, { className: GC.textMuted, children: "After a successful payment you'll see an on-screen confirmation with your order ID. You'll also receive:" }), _jsxs(Ul, { className: `${GC.listDiscMuted} mt-3`, children: [_jsx(Li, { children: "An email confirmation to your registered address" }), _jsx(Li, { children: "A WhatsApp message if you've added and verified your phone number" })] }), _jsxs(Text, { className: `${GC.textMuted} mt-3`, children: ["View all your orders at any time under ", _jsx(Span, { weight: "bold", children: "My Account \u2192 My Orders" }), "."] })] })),
|
|
29
29
|
},
|
|
30
30
|
{
|
|
31
31
|
Icon: UserX, title: "Guest Limitations",
|
|
@@ -30,7 +30,7 @@ export function NotificationPreferencesPanel({ fetchUrl = ACCOUNT_ENDPOINTS.NOTI
|
|
|
30
30
|
const { showToast } = useToast();
|
|
31
31
|
const { data: settings } = useSiteSettings();
|
|
32
32
|
const adminChannels = settings?.notificationChannels;
|
|
33
|
-
const [channels, setChannels] = useState({ email: true, sms: true });
|
|
33
|
+
const [channels, setChannels] = useState({ email: true, whatsapp: true, sms: true });
|
|
34
34
|
const [types, setTypes] = useState({
|
|
35
35
|
orderUpdates: true, bids: true, promotions: true,
|
|
36
36
|
system: true, reviews: true, messages: true, offers: true,
|
|
@@ -92,7 +92,8 @@ export function NotificationPreferencesPanel({ fetchUrl = ACCOUNT_ENDPOINTS.NOTI
|
|
|
92
92
|
}
|
|
93
93
|
// Which channels are admin-enabled (only show those toggles to the user)
|
|
94
94
|
const emailAdminEnabled = adminChannels?.email?.enabled ?? false;
|
|
95
|
+
const whatsappAdminEnabled = adminChannels?.whatsapp?.enabled ?? false;
|
|
95
96
|
const smsAdminEnabled = adminChannels?.sms?.enabled ?? false;
|
|
96
|
-
const anyChannelEnabled = emailAdminEnabled || smsAdminEnabled;
|
|
97
|
-
return (_jsxs(Stack, { gap: "lg", children: [_jsx(SectionCard, { title: "In-app notifications", children: _jsxs(Row, { align: "center", justify: "between", gap: "md", padding: "y-sm", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[var(--appkit-color-text)]", size: "sm", weight: "medium", children: "In-app notifications" }), _jsx(Text, { variant: "secondary", className: "mt-0.5", size: "xs", children: "Always on \u2014 displayed in the notification bell and inbox" })] }), _jsx(Row, { className: "h-5 w-9 bg-[var(--appkit-color-primary)] px-[0.125rem]", align: "center", justify: "end", rounded: "full", "aria-label": "Always on", children: _jsx(Span, { className: "h-4 w-4", rounded: "full", surface: "default", shadow: "default" }) })] }) }), anyChannelEnabled && (_jsxs(SectionCard, { title: "Notification channels", children: [emailAdminEnabled && (_jsx(ToggleRow, { label: "Email", description: "Receive notifications by email", checked: channels.email !== false, onChange: (v) => setChannel("email", v) })), smsAdminEnabled && (_jsx(ToggleRow, { label: "SMS", description: "Receive SMS text notifications", checked: channels.sms !== false, onChange: (v) => setChannel("sms", v) }))] })), _jsx(SectionCard, { title: "Notification types", children: TYPE_ROWS.map((row) => (_jsx(ToggleRow, { label: row.label, description: row.description, checked: types[row.key] !== false, onChange: (v) => setType(row.key, v) }, row.key))) }), _jsxs(Row, { justify: "end", gap: "sm", children: [saved && (_jsx(Text, { className: "text-[var(--appkit-color-success)] self-center", size: "sm", children: "Preferences saved \u2713" })), _jsx(Button, { onClick: handleSave, isLoading: saving, size: "sm", children: "Save preferences" })] })] }));
|
|
97
|
+
const anyChannelEnabled = emailAdminEnabled || whatsappAdminEnabled || smsAdminEnabled;
|
|
98
|
+
return (_jsxs(Stack, { gap: "lg", children: [_jsx(SectionCard, { title: "In-app notifications", children: _jsxs(Row, { align: "center", justify: "between", gap: "md", padding: "y-sm", children: [_jsxs(Div, { children: [_jsx(Text, { className: "text-[var(--appkit-color-text)]", size: "sm", weight: "medium", children: "In-app notifications" }), _jsx(Text, { variant: "secondary", className: "mt-0.5", size: "xs", children: "Always on \u2014 displayed in the notification bell and inbox" })] }), _jsx(Row, { className: "h-5 w-9 bg-[var(--appkit-color-primary)] px-[0.125rem]", align: "center", justify: "end", rounded: "full", "aria-label": "Always on", children: _jsx(Span, { className: "h-4 w-4", rounded: "full", surface: "default", shadow: "default" }) })] }) }), anyChannelEnabled && (_jsxs(SectionCard, { title: "Notification channels", children: [emailAdminEnabled && (_jsx(ToggleRow, { label: "Email", description: "Receive notifications by email", checked: channels.email !== false, onChange: (v) => setChannel("email", v) })), whatsappAdminEnabled && (_jsx(ToggleRow, { label: "WhatsApp", description: "Receive notifications on WhatsApp", checked: channels.whatsapp !== false, onChange: (v) => setChannel("whatsapp", v) })), smsAdminEnabled && (_jsx(ToggleRow, { label: "SMS", description: "Receive SMS text notifications", checked: channels.sms !== false, onChange: (v) => setChannel("sms", v) }))] })), _jsx(SectionCard, { title: "Notification types", children: TYPE_ROWS.map((row) => (_jsx(ToggleRow, { label: row.label, description: row.description, checked: types[row.key] !== false, onChange: (v) => setType(row.key, v) }, row.key))) }), _jsxs(Row, { justify: "end", gap: "sm", children: [saved && (_jsx(Text, { className: "text-[var(--appkit-color-success)] self-center", size: "sm", children: "Preferences saved \u2713" })), _jsx(Button, { onClick: handleSave, isLoading: saving, size: "sm", children: "Save preferences" })] })] }));
|
|
98
99
|
}
|
|
@@ -97,7 +97,7 @@ export function UserSidebar({ items, groups, mobileOpen = false, onCloseMobile,
|
|
|
97
97
|
// ── Persistent sidebar variant (desktop left slide-over, mobile bottom-sheet) ──────
|
|
98
98
|
if (variant === "sidebar") {
|
|
99
99
|
const handleToggle = onToggle ?? close;
|
|
100
|
-
return (_jsxs(_Fragment, { children: [_jsxs(Div, { className: `hidden lg:flex fixed ${rail.edgeClass} z-40 transition-transform duration-300 top-[var(--header-height,3.5rem)] h-[calc(100vh-var(--header-height,3.5rem))] w-[
|
|
100
|
+
return (_jsxs(_Fragment, { children: [_jsxs(Div, { className: `hidden lg:flex fixed ${rail.edgeClass} z-40 transition-transform duration-300 top-[var(--header-height,3.5rem)] h-[calc(100vh-var(--header-height,3.5rem))] w-[18rem] ${desktopOpen ? "translate-x-0" : rail.collapsedTranslateClass}`, children: [_jsxs(Stack, { border: "default", surface: "default", className: `flex-1 ${rail.borderClass} dark:border-[var(--appkit-color-border)] ${__O.hidden}`, shadow: "xl", children: [_jsx(Div, { border: "bottom-subtle", paddingY: "y-sm-tall", className: "shrink-0", padding: "x-md", children: _jsx(Span, { size: "xs", weight: "semibold", transform: "uppercase", color: "muted", children: "My Account" }) }), _jsx(Div, { className: `flex-1 ${__O.yAuto}`, children: _jsx(DrawerContent, { groups: groups, items: items, activeHref: pathname }) }), renderFooter && _jsx(Div, { border: "top", padding: "inline", children: renderFooter() })] }), _jsx(SidebarCollapseToggle, { expanded: desktopOpen, onToggle: handleToggle, edge: hand })] }), _jsx(Div, { className: "lg:hidden", children: _jsx(BottomSheet, { open: mobileOpen, onClose: close, title: "My Account", children: _jsx(DrawerContent, { groups: groups, items: items, activeHref: pathname, onItemClick: close }) }) })] }));
|
|
101
101
|
}
|
|
102
102
|
// ── Overlay variant (default) — portal on desktop, bottom-sheet on mobile ─
|
|
103
103
|
const content = (_jsx(DrawerContent, { groups: groups, items: items, activeHref: pathname, onItemClick: close }));
|
|
@@ -75,6 +75,8 @@ export interface UserAddress {
|
|
|
75
75
|
export interface NotificationChannelPrefs {
|
|
76
76
|
/** Disable all email notifications for this user (default: true = enabled). */
|
|
77
77
|
email?: boolean;
|
|
78
|
+
/** Disable all WhatsApp notifications for this user. */
|
|
79
|
+
whatsapp?: boolean;
|
|
78
80
|
/** Disable all SMS notifications for this user. */
|
|
79
81
|
sms?: boolean;
|
|
80
82
|
}
|
|
@@ -11,8 +11,21 @@ export declare function getUnreadNotificationCount(userId: string): Promise<numb
|
|
|
11
11
|
export interface SendNotificationInput extends NotificationCreateInput {
|
|
12
12
|
/** User's email — used by the email channel when enabled. */
|
|
13
13
|
userEmail?: string;
|
|
14
|
+
/** User's phone with country code, digits only — used by WhatsApp channel. */
|
|
15
|
+
userPhone?: string;
|
|
14
16
|
/** Pre-rendered email HTML — falls back to a plain-text <p> when absent. */
|
|
15
17
|
emailHtml?: string;
|
|
18
|
+
/**
|
|
19
|
+
* The store this notification is about, for the order-lifecycle types in
|
|
20
|
+
* `ORDER_GATED_TYPES`. Its WhatsApp subscription decides whether the
|
|
21
|
+
* WhatsApp arm runs; absent, those types fall back to in-app + email.
|
|
22
|
+
*
|
|
23
|
+
* Resolved HERE rather than by each caller: six call sites would otherwise
|
|
24
|
+
* each do their own store read and each be a place the rule could drift
|
|
25
|
+
* (Root Cause #75). The read only happens when the type is gated AND
|
|
26
|
+
* WhatsApp is otherwise eligible, so the common path costs nothing.
|
|
27
|
+
*/
|
|
28
|
+
storeId?: string;
|
|
16
29
|
/**
|
|
17
30
|
* Which portal the recipient reads this in — decides where `actionUrl`
|
|
18
31
|
* points when the caller does not supply one.
|
|
@@ -48,15 +61,17 @@ export interface SendNotificationInput extends NotificationCreateInput {
|
|
|
48
61
|
export interface SendNotificationResult {
|
|
49
62
|
notification: NotificationDocument;
|
|
50
63
|
email: "sent" | "skipped" | "failed";
|
|
64
|
+
/** 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`. */
|
|
65
|
+
whatsapp: "queued" | "skipped" | "failed";
|
|
51
66
|
}
|
|
52
67
|
/**
|
|
53
68
|
* Create an in-app notification and fan out to enabled external channels.
|
|
54
69
|
*
|
|
55
|
-
* In-app is always written.
|
|
70
|
+
* In-app is always written. Email / WhatsApp are sent only when:
|
|
56
71
|
* 1. The channel is enabled in siteSettings.notificationChannels
|
|
57
72
|
* 2. Notification priority meets the channel's minPriority threshold
|
|
58
73
|
* 3. The optional `types` filter includes this notification type (or is absent)
|
|
59
|
-
* 4. The contact field (userEmail) is supplied by the caller
|
|
74
|
+
* 4. The contact field (userEmail / userPhone) is supplied by the caller
|
|
60
75
|
* 5. The required credentials are set in siteSettings.credentials
|
|
61
76
|
*/
|
|
62
77
|
export declare function sendNotification(input: SendNotificationInput): Promise<SendNotificationResult>;
|
|
@@ -2,13 +2,15 @@ import { normalizeError } from "../../../errors/normalize.js";
|
|
|
2
2
|
import { notificationRepository } from "../repository/notification.repository.js";
|
|
3
3
|
import { siteSettingsRepository } from "../repository/site-settings.repository.js";
|
|
4
4
|
import { userRepository } from "../../auth/repository/user.repository.js";
|
|
5
|
+
import { storeRepository } from "../../stores/repository/store.repository.js";
|
|
6
|
+
import { safeRead } from "../../../errors/safe-read.js";
|
|
5
7
|
import { createResendProvider } from "../../../providers/email-resend/provider.js";
|
|
6
8
|
import { isChannelHealthy, withChannelRetry } from "../../../_internal/server/notifications/channel-health.js";
|
|
9
|
+
import { enqueueJob } from "../../jobs/actions/enqueue-job.js";
|
|
7
10
|
import { serverLogger } from "../../../monitoring/index.js";
|
|
8
11
|
import { resolveNotificationActionUrl, TYPE_AUDIENCE, } from "../../../_internal/shared/features/notifications/action-url.js";
|
|
9
12
|
import { isEmailEligible } from "../../../_internal/shared/features/notifications/email-eligibility.js";
|
|
10
13
|
import { renderNotificationEmail } from "../../email/notification-templates.js";
|
|
11
|
-
import { DEFAULT_NOTIFICATION_CHANNELS, meetsMinPriority, } from "../schemas/firestore.js";
|
|
12
14
|
/**
|
|
13
15
|
* A notification's `actionUrl` is a site-relative path — correct for the
|
|
14
16
|
* in-app bell, useless in an email, where a bare `/user/orders/x` is not a
|
|
@@ -31,6 +33,85 @@ function absoluteActionUrl(path) {
|
|
|
31
33
|
const base = process.env.NEXT_PUBLIC_APP_URL?.replace(/\/+$/, "");
|
|
32
34
|
return base ? `${base}${path}` : path;
|
|
33
35
|
}
|
|
36
|
+
import { DEFAULT_NOTIFICATION_CHANNELS, meetsMinPriority, } from "../schemas/firestore.js";
|
|
37
|
+
/**
|
|
38
|
+
* Notification types whose WhatsApp send is gated behind the SELLER's
|
|
39
|
+
* standing WhatsApp-notifications subscription — see `storeId` on
|
|
40
|
+
* `SendNotificationInput`.
|
|
41
|
+
*
|
|
42
|
+
* 🛑 This replaced a per-ORDER ₹10 addon the buyer paid at checkout
|
|
43
|
+
* (`OrderDocument.whatsappNotifyAddon`). The axis moved deliberately: the
|
|
44
|
+
* platform owns one WhatsApp number, so the cost is a standing monthly one
|
|
45
|
+
* borne by the seller who benefits from it, not a line item a buyer opts into
|
|
46
|
+
* per order. A buyer should not have to purchase their own delivery updates.
|
|
47
|
+
*
|
|
48
|
+
* Every other type (bid_won, offers, scam reports, support tickets, admin
|
|
49
|
+
* alerts, …) keeps the original two gates — admin channel enabled, user has
|
|
50
|
+
* not opted out — unchanged.
|
|
51
|
+
*/
|
|
52
|
+
/**
|
|
53
|
+
* Does this store subscribe to WhatsApp order notifications?
|
|
54
|
+
*
|
|
55
|
+
* The subscription is a monthly add-on priced by the admin
|
|
56
|
+
* (`commissions.whatsappNotifyMonthlyFee`); this reads only whether it is
|
|
57
|
+
* currently active. Billing lives with the other recurring charges, not here.
|
|
58
|
+
*
|
|
59
|
+
* 🛑 Fails CLOSED. A store we cannot read, or one that has not subscribed,
|
|
60
|
+
* gets no WhatsApp send — the buyer still receives the in-app notification and
|
|
61
|
+
* the email, which are ungated. Failing open would message customers on behalf
|
|
62
|
+
* of a seller who is not paying for it, and bill the platform for the template.
|
|
63
|
+
*/
|
|
64
|
+
async function storeWhatsAppSubscribed(storeId) {
|
|
65
|
+
if (!storeId)
|
|
66
|
+
return false;
|
|
67
|
+
const store = await safeRead(() => storeRepository.findBySlug(storeId), {
|
|
68
|
+
route: "sendNotification",
|
|
69
|
+
key: `whatsapp-subscription:${storeId}`,
|
|
70
|
+
fallback: null,
|
|
71
|
+
});
|
|
72
|
+
return store?.whatsappNotifySubscribed === true;
|
|
73
|
+
}
|
|
74
|
+
const ORDER_GATED_TYPES = new Set([
|
|
75
|
+
"order_placed",
|
|
76
|
+
"order_confirmed",
|
|
77
|
+
"order_shipped",
|
|
78
|
+
"order_delivered",
|
|
79
|
+
"order_cancelled",
|
|
80
|
+
"refund_initiated",
|
|
81
|
+
]);
|
|
82
|
+
/**
|
|
83
|
+
* Notification type → the flat `SiteSettingsCredentials` field holding its
|
|
84
|
+
* approved Meta template name.
|
|
85
|
+
*
|
|
86
|
+
* 🛑 **`Partial` here is CORRECT and is not the `typeToPrefsKey` mistake.**
|
|
87
|
+
*
|
|
88
|
+
* The distinction is what a missing key MEANS. A missing `typeToPrefsKey`
|
|
89
|
+
* entry silently ignored a user's opt-out — a real setting that read as
|
|
90
|
+
* honoured and was not. A missing entry here means "no Meta-approved template
|
|
91
|
+
* exists for this type", which is a fact about the Meta account, not a bug:
|
|
92
|
+
* six of the 28 types have one, and the rest legitimately fall back to
|
|
93
|
+
* free-form (which Meta accepts inside the 24-hour customer-service window
|
|
94
|
+
* and rejects outside it — the runner already logs that rejection).
|
|
95
|
+
*
|
|
96
|
+
* Getting six approved templates is a Meta Business review process, not a
|
|
97
|
+
* code change, so listing all 28 here with empty values would be a
|
|
98
|
+
* `Record<…>` that lies about coverage.
|
|
99
|
+
*
|
|
100
|
+
* The six that ARE approved are the order lifecycle, which is also the set
|
|
101
|
+
* gated behind the buyer's ₹10 addon above — the two lists agree on purpose.
|
|
102
|
+
*/
|
|
103
|
+
const WHATSAPP_TEMPLATE_FIELD = {
|
|
104
|
+
order_placed: "whatsappTemplateOrderPlaced",
|
|
105
|
+
order_confirmed: "whatsappTemplateOrderConfirmed",
|
|
106
|
+
order_shipped: "whatsappTemplateOrderShipped",
|
|
107
|
+
order_delivered: "whatsappTemplateOrderDelivered",
|
|
108
|
+
order_cancelled: "whatsappTemplateOrderCancelled",
|
|
109
|
+
refund_initiated: "whatsappTemplateRefundInitiated",
|
|
110
|
+
};
|
|
111
|
+
function resolveWhatsAppTemplateName(type, creds) {
|
|
112
|
+
const field = WHATSAPP_TEMPLATE_FIELD[type];
|
|
113
|
+
return (field ? creds[field] : undefined)?.trim() ?? "";
|
|
114
|
+
}
|
|
34
115
|
export async function markNotificationRead(id) {
|
|
35
116
|
await notificationRepository.markAsRead(id);
|
|
36
117
|
}
|
|
@@ -53,15 +134,15 @@ export async function getUnreadNotificationCount(userId) {
|
|
|
53
134
|
/**
|
|
54
135
|
* Create an in-app notification and fan out to enabled external channels.
|
|
55
136
|
*
|
|
56
|
-
* In-app is always written.
|
|
137
|
+
* In-app is always written. Email / WhatsApp are sent only when:
|
|
57
138
|
* 1. The channel is enabled in siteSettings.notificationChannels
|
|
58
139
|
* 2. Notification priority meets the channel's minPriority threshold
|
|
59
140
|
* 3. The optional `types` filter includes this notification type (or is absent)
|
|
60
|
-
* 4. The contact field (userEmail) is supplied by the caller
|
|
141
|
+
* 4. The contact field (userEmail / userPhone) is supplied by the caller
|
|
61
142
|
* 5. The required credentials are set in siteSettings.credentials
|
|
62
143
|
*/
|
|
63
144
|
export async function sendNotification(input) {
|
|
64
|
-
const { userEmail, emailHtml, audience, eligibilityOverride, ...notifInput } = input;
|
|
145
|
+
const { userEmail, userPhone, emailHtml, audience, eligibilityOverride, ...notifInput } = input;
|
|
65
146
|
/*
|
|
66
147
|
* Fill `actionUrl` when the caller did not.
|
|
67
148
|
*
|
|
@@ -103,12 +184,12 @@ export async function sendNotification(input) {
|
|
|
103
184
|
* suppresses the emailed/messaged COPY, not the notification.
|
|
104
185
|
*
|
|
105
186
|
* One map gates both channels deliberately. If a type is ever genuinely
|
|
106
|
-
*
|
|
187
|
+
* WhatsApp-worthy but not email-worthy, split the map then rather than
|
|
107
188
|
* loosening this early return.
|
|
108
189
|
*/
|
|
109
190
|
if (!isEmailEligible(notifInput.type) &&
|
|
110
191
|
eligibilityOverride !== "staff_requested") {
|
|
111
|
-
return { notification, email: "skipped" };
|
|
192
|
+
return { notification, email: "skipped", whatsapp: "skipped" };
|
|
112
193
|
}
|
|
113
194
|
// Load channel config + credentials (one Firestore read).
|
|
114
195
|
let settings;
|
|
@@ -118,7 +199,7 @@ export async function sendNotification(input) {
|
|
|
118
199
|
catch (err) {
|
|
119
200
|
void normalizeError(err);
|
|
120
201
|
serverLogger.warn("sendNotification: could not load siteSettings — skipping external channels", { err });
|
|
121
|
-
return { notification, email: "skipped" };
|
|
202
|
+
return { notification, email: "skipped", whatsapp: "skipped" };
|
|
122
203
|
}
|
|
123
204
|
const channels = settings.notificationChannels ?? DEFAULT_NOTIFICATION_CHANNELS;
|
|
124
205
|
const creds = settings.credentials ?? {};
|
|
@@ -150,6 +231,7 @@ export async function sendNotification(input) {
|
|
|
150
231
|
? userRepository.revealUser(fetchedUserDoc)
|
|
151
232
|
: null;
|
|
152
233
|
const resolvedEmail = userEmail ?? fetchedUserPlain?.email ?? undefined;
|
|
234
|
+
const resolvedPhone = userPhone ?? fetchedUserPlain?.phoneNumber ?? undefined;
|
|
153
235
|
// Map NotificationType to a NotificationTypePrefs key.
|
|
154
236
|
/*
|
|
155
237
|
* 🛑 COMPLETE and compile-checked: `Record<NotificationType, …>`, not
|
|
@@ -212,7 +294,7 @@ export async function sendNotification(input) {
|
|
|
212
294
|
const typeKey = typeToPrefsKey[type];
|
|
213
295
|
// If the user has explicitly disabled this notification type, skip all external channels.
|
|
214
296
|
if (typeKey && userTypePrefs[typeKey] === false) {
|
|
215
|
-
return { notification, email: "skipped" };
|
|
297
|
+
return { notification, email: "skipped", whatsapp: "skipped" };
|
|
216
298
|
}
|
|
217
299
|
// Email channel — admin enabled AND user hasn't opted out
|
|
218
300
|
let emailStatus = "skipped";
|
|
@@ -259,10 +341,73 @@ export async function sendNotification(input) {
|
|
|
259
341
|
}
|
|
260
342
|
}
|
|
261
343
|
}
|
|
344
|
+
// WhatsApp channel — admin enabled AND user hasn't opted out AND (for
|
|
345
|
+
// order-lifecycle types) the buyer paid the per-order WhatsApp addon.
|
|
346
|
+
let whatsappStatus = "skipped";
|
|
347
|
+
if (channels.whatsapp.enabled &&
|
|
348
|
+
userChannelPrefs.whatsapp !== false &&
|
|
349
|
+
resolvedPhone &&
|
|
350
|
+
meetsMinPriority(priority, channels.whatsapp.minPriority) &&
|
|
351
|
+
(!channels.whatsapp.types?.length || channels.whatsapp.types.includes(type)) &&
|
|
352
|
+
(!ORDER_GATED_TYPES.has(type) || (await storeWhatsAppSubscribed(input.storeId)))) {
|
|
353
|
+
/*
|
|
354
|
+
* ENV FIRST, site settings second.
|
|
355
|
+
*
|
|
356
|
+
* The platform owns one WhatsApp number, so its credentials are
|
|
357
|
+
* deployment configuration rather than admin-editable content — they
|
|
358
|
+
* belong beside the other secrets in the env, not in a Firestore document
|
|
359
|
+
* an admin screen can rewrite. Rotating a leaked Meta token should be a
|
|
360
|
+
* redeploy, not a form submission.
|
|
361
|
+
*
|
|
362
|
+
* 🛑 `WHATSAPP_PHONE_NUMBER_ID` is the Cloud API's numeric id, NOT the
|
|
363
|
+
* dialable number: sends go to `/{phone-number-id}/messages`, and a `+1…`
|
|
364
|
+
* string there is a 404 that looks like a routing bug.
|
|
365
|
+
*
|
|
366
|
+
* The site-settings fallback is kept so an install that configured these
|
|
367
|
+
* through the admin screen keeps working — this changes precedence, not
|
|
368
|
+
* behaviour, and nothing has to be migrated.
|
|
369
|
+
*/
|
|
370
|
+
const phoneNumberId = process.env.WHATSAPP_PHONE_NUMBER_ID?.trim() ||
|
|
371
|
+
(creds.whatsappPhoneNumberId?.trim() ?? "");
|
|
372
|
+
const accessToken = process.env.WHATSAPP_MESSAGES_TOKEN?.trim() ||
|
|
373
|
+
(creds.whatsappCloudApiToken?.trim() ?? "");
|
|
374
|
+
if (phoneNumberId && accessToken) {
|
|
375
|
+
try {
|
|
376
|
+
const { jobId } = await enqueueJob({
|
|
377
|
+
jobType: "whatsappNotify",
|
|
378
|
+
payload: {
|
|
379
|
+
toPhone: resolvedPhone,
|
|
380
|
+
title,
|
|
381
|
+
message,
|
|
382
|
+
type,
|
|
383
|
+
templateName: resolveWhatsAppTemplateName(type, creds),
|
|
384
|
+
templateLanguage: creds.whatsappTemplateLanguage ?? "en",
|
|
385
|
+
notificationId: notification.id,
|
|
386
|
+
phoneNumberId,
|
|
387
|
+
accessToken,
|
|
388
|
+
},
|
|
389
|
+
requestedBy: input.userId,
|
|
390
|
+
});
|
|
391
|
+
whatsappStatus = "queued";
|
|
392
|
+
await notificationRepository
|
|
393
|
+
.update(notification.id, { whatsappStatus: "queued", whatsappJobId: jobId })
|
|
394
|
+
.catch((err) => {
|
|
395
|
+
void normalizeError(err);
|
|
396
|
+
serverLogger.warn("sendNotification: failed to write whatsappJobId onto notification doc", { notificationId: notification.id });
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
catch (err) {
|
|
400
|
+
void normalizeError(err);
|
|
401
|
+
serverLogger.error("sendNotification: WhatsApp job enqueue failed", { userId: input.userId, err });
|
|
402
|
+
whatsappStatus = "failed";
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
262
406
|
serverLogger.info("sendNotification: dispatched", {
|
|
263
407
|
userId: input.userId,
|
|
264
408
|
type,
|
|
265
409
|
email: emailStatus,
|
|
410
|
+
whatsapp: whatsappStatus,
|
|
266
411
|
});
|
|
267
|
-
return { notification, email: emailStatus };
|
|
412
|
+
return { notification, email: emailStatus, whatsapp: whatsappStatus };
|
|
268
413
|
}
|
|
@@ -10,6 +10,8 @@ import { ImageUpload, MediaImage, useMediaUpload } from "../../media/index.js";
|
|
|
10
10
|
import { apiClient } from "../../../http/index.js";
|
|
11
11
|
import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints.js";
|
|
12
12
|
import { blogDraftSchema } from "../../blog/schemas/blog-form.js";
|
|
13
|
+
import { CouponInlineSelect } from "../../seller/components/CouponInlineSelect.js";
|
|
14
|
+
import { ATTACHED_COUPON_MAX } from "../../promotions/constants/attached-coupons.js";
|
|
13
15
|
import { SectionForm, useSectionFormNav } from "../../shell/index.js";
|
|
14
16
|
const __P = {
|
|
15
17
|
p4: "p-[var(--appkit-space-4)]",
|
|
@@ -39,6 +41,7 @@ const DEFAULT_DRAFT = {
|
|
|
39
41
|
youtubeId: "",
|
|
40
42
|
category: "news",
|
|
41
43
|
tags: [],
|
|
44
|
+
couponIds: [],
|
|
42
45
|
status: "published",
|
|
43
46
|
isFeatured: true,
|
|
44
47
|
authorName: "Admin",
|
|
@@ -103,6 +106,7 @@ export function AdminBlogEditorView({ postId, onSaved, onDeleted, embedded, ...r
|
|
|
103
106
|
youtubeId: p.youtubeId ?? "",
|
|
104
107
|
category: p.category ?? "news",
|
|
105
108
|
tags: Array.isArray(p.tags) ? p.tags : [],
|
|
109
|
+
couponIds: Array.isArray(p.couponIds) ? p.couponIds : [],
|
|
106
110
|
status: p.status ?? "published",
|
|
107
111
|
isFeatured: p.isFeatured ?? true,
|
|
108
112
|
authorName: p.authorName ?? "Admin",
|
|
@@ -125,6 +129,10 @@ export function AdminBlogEditorView({ postId, onSaved, onDeleted, embedded, ...r
|
|
|
125
129
|
youtubeId: draft.youtubeId || undefined,
|
|
126
130
|
category: draft.category,
|
|
127
131
|
tags: draft.tags,
|
|
132
|
+
// Unconditional, `[]` included — see the matching note in the event
|
|
133
|
+
// editor: a key omitted when empty cannot clear the stored array,
|
|
134
|
+
// because the route merges.
|
|
135
|
+
couponIds: draft.couponIds,
|
|
128
136
|
status: draft.status,
|
|
129
137
|
isFeatured: draft.isFeatured,
|
|
130
138
|
authorName: draft.authorName,
|
|
@@ -236,8 +244,8 @@ export function AdminBlogEditorView({ postId, onSaved, onDeleted, embedded, ...r
|
|
|
236
244
|
id: "publish",
|
|
237
245
|
label: "Publish",
|
|
238
246
|
quick: true,
|
|
239
|
-
fields: ["status", "publishedAt", "authorName", "isFeatured"],
|
|
240
|
-
render: ({ values, onChange, errors }) => (_jsxs(Stack, { gap: "5", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Publish Settings" }), _jsxs(Div, { layout: "grid", gap: "4", className: "grid-cols-2", children: [_jsx(Select, { label: "Status", error: errors.status, options: STATUS_OPTIONS, value: values.status, onValueChange: (v) => onChange({ status: v }) }), _jsx(Input, { label: "Publish date (optional)", error: errors.publishedAt, value: values.publishedAt, onChange: (e) => onChange({ publishedAt: e.target.value }), type: "date", helperText: "Auto-set to now when publishing." })] }), _jsx(Input, { label: "Author Name", error: errors.authorName, value: values.authorName, onChange: (e) => onChange({ authorName: e.target.value }), placeholder: "Author display name" }), _jsx(Toggle, { label: "Featured post", checked: values.isFeatured, onChange: (checked) => onChange({ isFeatured: checked }) }), isEdit && (_jsx(Div, { className: "border-t border-[var(--appkit-color-border)]", padding: "t-md", children: _jsx(Button, { type: "button", variant: "danger", size: "sm", onClick: () => setShowDeleteConfirm(true), isLoading: deleteMutation.isPending, children: "Delete post" }) }))] })),
|
|
247
|
+
fields: ["status", "publishedAt", "authorName", "isFeatured", "couponIds"],
|
|
248
|
+
render: ({ values, onChange, errors }) => (_jsxs(Stack, { gap: "5", children: [_jsx(Heading, { level: 3, className: "mb-2", children: "Publish Settings" }), _jsxs(Div, { layout: "grid", gap: "4", className: "grid-cols-2", children: [_jsx(Select, { label: "Status", error: errors.status, options: STATUS_OPTIONS, value: values.status, onValueChange: (v) => onChange({ status: v }) }), _jsx(Input, { label: "Publish date (optional)", error: errors.publishedAt, value: values.publishedAt, onChange: (e) => onChange({ publishedAt: e.target.value }), type: "date", helperText: "Auto-set to now when publishing." })] }), _jsx(Input, { label: "Author Name", error: errors.authorName, value: values.authorName, onChange: (e) => onChange({ authorName: e.target.value }), placeholder: "Author display name" }), _jsx(Toggle, { label: "Featured post", checked: values.isFeatured, onChange: (checked) => onChange({ isFeatured: checked }) }), _jsxs(Div, { children: [_jsx(Text, { className: "mb-1.5", size: "sm", weight: "medium", children: "Coupons (optional)" }), _jsx(CouponInlineSelect, { scope: "admin", multiple: true, value: values.couponIds, onChange: (ids) => onChange({ couponIds: ids }), placeholder: "Search coupons to feature in this post\u2026", allowCreate: false }), _jsxs(Text, { className: "text-[var(--appkit-color-text-muted)] mt-1", size: "xs", children: ["Shown as copyable code cards below the article. Up to ", ATTACHED_COUPON_MAX, ". Expired and deactivated coupons are hidden automatically, so an old post never advertises a dead code."] })] }), isEdit && (_jsx(Div, { className: "border-t border-[var(--appkit-color-border)]", padding: "t-md", children: _jsx(Button, { type: "button", variant: "danger", size: "sm", onClick: () => setShowDeleteConfirm(true), isLoading: deleteMutation.isPending, children: "Delete post" }) }))] })),
|
|
241
249
|
},
|
|
242
250
|
];
|
|
243
251
|
const deleteModal = (_jsx(ConfirmDeleteModal, { isOpen: showDeleteConfirm, onConfirm: () => {
|
|
@@ -37,7 +37,7 @@ export interface AdminOrderEditorViewProps {
|
|
|
37
37
|
paymentReviewOutcome?: string;
|
|
38
38
|
/**
|
|
39
39
|
* Paid add-ons + applied coupon for this order. Operational, not decorative:
|
|
40
|
-
* `
|
|
40
|
+
* `whatsappNotifyAddon` is who a status change should message, and the
|
|
41
41
|
* coupon is what an admin needs when reconciling a discounted order.
|
|
42
42
|
*/
|
|
43
43
|
addons?: OrderAddonBadgesOrder;
|
|
@@ -255,6 +255,8 @@ export function AdminOrdersView({ children, ...props }) {
|
|
|
255
255
|
// Straight off the order document — the admin list route returns
|
|
256
256
|
// raw docs, so these need no serializer entry (cf. Root Cause #38).
|
|
257
257
|
addons: {
|
|
258
|
+
whatsappNotifyAddon: Boolean(item.whatsappNotifyAddon),
|
|
259
|
+
whatsappNotifyFee: Number(item.whatsappNotifyFee ?? 0) || undefined,
|
|
258
260
|
giftWrapAddon: Boolean(item.giftWrapAddon),
|
|
259
261
|
giftWrapFee: Number(item.giftWrapFee ?? 0) || undefined,
|
|
260
262
|
giftWrapMessage: toStringValue(item.giftWrapMessage) || undefined,
|