@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.
Files changed (179) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/server/features/checkout/actions.d.ts +2 -0
  3. package/dist/_internal/server/features/checkout/actions.js +22 -5
  4. package/dist/_internal/server/features/orders/adapters.js +3 -1
  5. package/dist/_internal/server/features/promotions/adapters.d.ts +25 -0
  6. package/dist/_internal/server/features/promotions/adapters.js +43 -0
  7. package/dist/_internal/server/features/promotions/data.d.ts +26 -0
  8. package/dist/_internal/server/features/promotions/data.js +45 -0
  9. package/dist/_internal/server/features/promotions/index.d.ts +2 -1
  10. package/dist/_internal/server/features/promotions/index.js +2 -1
  11. package/dist/_internal/server/features/site-settings/adapters.js +20 -2
  12. package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
  13. package/dist/_internal/server/features/stores/adapters.js +1 -0
  14. package/dist/_internal/server/jobs/core/analyticsAlertEvaluate.js +5 -2
  15. package/dist/_internal/server/jobs/core/jobRunners.js +5 -0
  16. package/dist/_internal/server/jobs/core/onOrderCreate.js +104 -10
  17. package/dist/_internal/server/jobs/core/onOrderStatusChange.d.ts +2 -0
  18. package/dist/_internal/server/jobs/core/onOrderStatusChange.js +1 -0
  19. package/dist/_internal/server/jobs/core/onScamReportCreate.js +1 -0
  20. package/dist/_internal/server/jobs/core/onScamReportRejected.js +1 -0
  21. package/dist/_internal/server/jobs/core/onScamReportVerified.js +1 -0
  22. package/dist/_internal/server/jobs/core/paymentWindowTimeout.js +1 -0
  23. package/dist/_internal/server/jobs/core/pendingOrderTimeout.js +1 -0
  24. package/dist/_internal/server/jobs/core/triggerEventRaffle.js +45 -14
  25. package/dist/_internal/server/jobs/core/whatsappNotify.d.ts +35 -0
  26. package/dist/_internal/server/jobs/core/whatsappNotify.js +140 -0
  27. package/dist/_internal/server/notifications/channel-health.d.ts +8 -6
  28. package/dist/_internal/server/notifications/channel-health.js +2 -2
  29. package/dist/_internal/server/notifications/send-guard.d.ts +1 -1
  30. package/dist/_internal/server/notifications/send-guard.js +4 -2
  31. package/dist/_internal/shared/actions/action-registry.js +28 -0
  32. package/dist/_internal/shared/checkout/order-math.d.ts +1 -1
  33. package/dist/_internal/shared/checkout/order-math.js +1 -1
  34. package/dist/_internal/shared/features/checkout/config.d.ts +2 -0
  35. package/dist/_internal/shared/features/checkout/config.js +2 -0
  36. package/dist/_internal/shared/features/messaging/config.d.ts +5 -7
  37. package/dist/_internal/shared/features/messaging/config.js +4 -1
  38. package/dist/_internal/shared/features/site-settings/fees.d.ts +2 -0
  39. package/dist/_internal/shared/features/site-settings/fees.js +2 -0
  40. package/dist/_internal/shared/features/site-settings/types.d.ts +27 -2
  41. package/dist/_internal/shared/fees/calculator.d.ts +8 -0
  42. package/dist/_internal/shared/fees/calculator.js +7 -0
  43. package/dist/cli/index.js +2 -0
  44. package/dist/client.d.ts +2 -2
  45. package/dist/client.js +1 -1
  46. package/dist/constants/api-endpoints.d.ts +15 -0
  47. package/dist/constants/api-endpoints.js +8 -0
  48. package/dist/constants/field-names.d.ts +4 -0
  49. package/dist/constants/field-names.js +4 -0
  50. package/dist/constants/notification-types.d.ts +1 -1
  51. package/dist/constants/notification-types.js +1 -1
  52. package/dist/core/integration-keys.d.ts +5 -0
  53. package/dist/core/integration-keys.js +10 -0
  54. package/dist/features/about/components/FeesView.js +6 -0
  55. package/dist/features/account/components/BuyerShoppingGuideView.js +1 -1
  56. package/dist/features/account/components/NotificationPreferencesPanel.js +4 -3
  57. package/dist/features/account/components/UserSidebar.js +1 -1
  58. package/dist/features/account/types/index.d.ts +2 -0
  59. package/dist/features/admin/actions/notification-actions.d.ts +17 -2
  60. package/dist/features/admin/actions/notification-actions.js +154 -9
  61. package/dist/features/admin/components/AdminBlogEditorView.js +10 -2
  62. package/dist/features/admin/components/AdminOrderEditorView.d.ts +1 -1
  63. package/dist/features/admin/components/AdminOrdersView.js +2 -0
  64. package/dist/features/admin/components/AdminSiteSettingsView.js +66 -6
  65. package/dist/features/admin/components/AdminStoreEditorView.js +1 -0
  66. package/dist/features/admin/constants/site-settings-tabs.d.ts +12 -7
  67. package/dist/features/admin/constants/site-settings-tabs.js +12 -6
  68. package/dist/features/admin/repository/site-settings.repository.js +1 -1
  69. package/dist/features/admin/schemas/admin-user-form.d.ts +61 -0
  70. package/dist/features/admin/schemas/admin-user-form.js +44 -3
  71. package/dist/features/admin/schemas/firestore.d.ts +73 -0
  72. package/dist/features/admin/schemas/firestore.js +8 -3
  73. package/dist/features/auth/checkout-value-otp.d.ts +1 -1
  74. package/dist/features/auth/checkout-value-otp.js +1 -1
  75. package/dist/features/auth/permissions/constants.d.ts +2 -2
  76. package/dist/features/auth/permissions/constants.js +2 -1
  77. package/dist/features/blog/schemas/blog-form.d.ts +3 -0
  78. package/dist/features/blog/schemas/blog-form.js +14 -0
  79. package/dist/features/blog/schemas/firestore.d.ts +17 -0
  80. package/dist/features/blog/schemas/firestore.js +1 -0
  81. package/dist/features/blog/schemas/index.d.ts +6 -0
  82. package/dist/features/blog/schemas/index.js +3 -0
  83. package/dist/features/blog/types/index.d.ts +2 -0
  84. package/dist/features/cart/components/CartPriceBreakdown.d.ts +2 -0
  85. package/dist/features/cart/components/CartPriceBreakdown.js +1 -1
  86. package/dist/features/cart/components/StoreAddonsPicker.d.ts +5 -2
  87. package/dist/features/cart/components/StoreAddonsPicker.js +5 -4
  88. package/dist/features/cart/repository/cart.repository.js +2 -1
  89. package/dist/features/cart/schemas/firestore.d.ts +2 -1
  90. package/dist/features/checkout/actions/checkout-value-otp-actions.d.ts +5 -10
  91. package/dist/features/checkout/actions/checkout-value-otp-actions.js +88 -2
  92. package/dist/features/events/components/AdminEventEditorView.js +64 -9
  93. package/dist/features/events/components/EventOfferCard.js +3 -18
  94. package/dist/features/events/components/EventRaffleWinnerView.d.ts +6 -0
  95. package/dist/features/events/components/EventRaffleWinnerView.js +11 -1
  96. package/dist/features/events/schemas/event-form.d.ts +10 -0
  97. package/dist/features/events/schemas/event-form.js +19 -0
  98. package/dist/features/events/schemas/firestore.d.ts +68 -0
  99. package/dist/features/events/schemas/firestore.js +25 -0
  100. package/dist/features/events/schemas/index.d.ts +7 -0
  101. package/dist/features/events/schemas/index.js +5 -0
  102. package/dist/features/events/types/index.d.ts +4 -0
  103. package/dist/features/homepage/lib/section-renderer.js +1 -1
  104. package/dist/features/orders/actions/refund-actions.js +1 -0
  105. package/dist/features/orders/components/OrderAddonBadges.d.ts +3 -1
  106. package/dist/features/orders/components/OrderAddonBadges.js +4 -3
  107. package/dist/features/orders/repository/orders.repository.d.ts +8 -0
  108. package/dist/features/orders/repository/orders.repository.js +2 -0
  109. package/dist/features/orders/schemas/firestore.d.ts +4 -0
  110. package/dist/features/orders/types/index.d.ts +5 -2
  111. package/dist/features/promotions/components/AttachedCouponsSection.d.ts +30 -0
  112. package/dist/features/promotions/components/AttachedCouponsSection.js +99 -0
  113. package/dist/features/promotions/components/CouponCodeCopyRow.d.ts +19 -0
  114. package/dist/features/promotions/components/CouponCodeCopyRow.js +39 -0
  115. package/dist/features/promotions/components/index.d.ts +4 -0
  116. package/dist/features/promotions/components/index.js +2 -0
  117. package/dist/features/promotions/constants/attached-coupons.d.ts +55 -0
  118. package/dist/features/promotions/constants/attached-coupons.js +24 -0
  119. package/dist/features/promotions/repository/coupons.repository.d.ts +20 -0
  120. package/dist/features/promotions/repository/coupons.repository.js +39 -0
  121. package/dist/features/promotions/schemas/attached-coupons.d.ts +39 -0
  122. package/dist/features/promotions/schemas/attached-coupons.js +66 -0
  123. package/dist/features/search/action-index/settings-entries.js +9 -0
  124. package/dist/features/store-extensions/repository/store-extensions.repositories.d.ts +6 -1
  125. package/dist/features/store-extensions/repository/store-extensions.repositories.js +10 -1
  126. package/dist/features/store-extensions/schemas/firestore.d.ts +17 -1
  127. package/dist/features/store-extensions/schemas/firestore.js +10 -0
  128. package/dist/features/stores/components/StoreCapabilitiesGuideView.js +1 -0
  129. package/dist/features/stores/components/StoreGuideHubView.js +1 -1
  130. package/dist/features/stores/components/StoreSettingsGuideView.js +6 -2
  131. package/dist/features/stores/repository/store.repository.d.ts +23 -1
  132. package/dist/features/stores/repository/store.repository.js +29 -1
  133. package/dist/features/stores/schemas/firestore.d.ts +16 -6
  134. package/dist/features/stores/schemas/firestore.js +3 -6
  135. package/dist/features/stores/schemas/index.d.ts +86 -0
  136. package/dist/features/stores/schemas/index.js +14 -0
  137. package/dist/features/whatsapp-bot/api/send-status/route.d.ts +12 -0
  138. package/dist/features/whatsapp-bot/api/send-status/route.js +38 -0
  139. package/dist/features/whatsapp-bot/api/webhook/route.d.ts +12 -0
  140. package/dist/features/whatsapp-bot/api/webhook/route.js +43 -0
  141. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.d.ts +57 -0
  142. package/dist/features/whatsapp-bot/components/SellerWhatsAppSettingsView.js +76 -0
  143. package/dist/features/whatsapp-bot/components/WhatsAppChatButton.d.ts +9 -0
  144. package/dist/features/whatsapp-bot/components/WhatsAppChatButton.js +7 -0
  145. package/dist/features/whatsapp-bot/components/index.d.ts +3 -0
  146. package/dist/features/whatsapp-bot/components/index.js +2 -0
  147. package/dist/features/whatsapp-bot/helpers/whatsapp.d.ts +52 -0
  148. package/dist/features/whatsapp-bot/helpers/whatsapp.js +288 -0
  149. package/dist/features/whatsapp-bot/index.d.ts +3 -0
  150. package/dist/features/whatsapp-bot/index.js +3 -0
  151. package/dist/features/whatsapp-bot/manifest.d.ts +2 -0
  152. package/dist/features/whatsapp-bot/manifest.js +13 -0
  153. package/dist/features/whatsapp-bot/messages/en.json +7 -0
  154. package/dist/features/whatsapp-bot/server.d.ts +7 -0
  155. package/dist/features/whatsapp-bot/server.js +7 -0
  156. package/dist/features/whatsapp-bot/types/index.d.ts +126 -0
  157. package/dist/features/whatsapp-bot/types/index.js +1 -0
  158. package/dist/index.d.ts +23 -6
  159. package/dist/index.js +38 -12
  160. package/dist/next/routing/route-map.d.ts +3 -0
  161. package/dist/next/routing/route-map.js +1 -0
  162. package/dist/providers/db-firebase/base.repository.d.ts +1 -1
  163. package/dist/providers/db-firebase/base.repository.js +1 -1
  164. package/dist/repositories/index.d.ts +1 -1
  165. package/dist/repositories/index.js +1 -1
  166. package/dist/schemas/registry.d.ts +121 -0
  167. package/dist/schemas/registry.js +2 -1
  168. package/dist/security/pii-encrypt.js +1 -1
  169. package/dist/server-entry.d.ts +1 -0
  170. package/dist/server-entry.js +2 -0
  171. package/dist/server.d.ts +11 -2
  172. package/dist/server.js +26 -2
  173. package/dist/styles.css +1 -1
  174. package/dist/tailwind-utilities.css +1 -1
  175. package/dist/ui/components/NavigationLoader.d.ts +14 -1
  176. package/dist/ui/components/NavigationLoader.js +79 -30
  177. package/package.json +1 -1
  178. package/dist/features/orders/utils/order-announcement-message.d.ts +0 -18
  179. package/dist/features/orders/utils/order-announcement-message.js +0 -16
@@ -835,11 +835,14 @@ export declare const eventItemSchema: z.ZodObject<{
835
835
  placeholder?: string | undefined;
836
836
  }[];
837
837
  }>>;
838
+ couponIds: z.ZodType<string[] | undefined, z.ZodTypeDef, string[] | undefined>;
838
839
  hasRaffle: z.ZodOptional<z.ZodBoolean>;
839
840
  raffleType: z.ZodOptional<z.ZodEnum<["top_n_scorers", "top_n_participants", "open_raffle", "spin_wheel"]>>;
840
841
  raffleTopN: z.ZodOptional<z.ZodNumber>;
841
842
  rafflePrize: z.ZodOptional<z.ZodString>;
843
+ /** @deprecated read-only legacy scalar — see `rafflePrizeCouponIdsOf`. */
842
844
  rafflePrizeCouponId: z.ZodOptional<z.ZodString>;
845
+ rafflePrizeCouponIds: z.ZodType<string[] | undefined, z.ZodTypeDef, string[] | undefined>;
843
846
  rafflePrizeProductIds: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
844
847
  raffleGithubFunctionUrl: z.ZodOptional<z.ZodString>;
845
848
  raffleWinnerUserId: z.ZodOptional<z.ZodString>;
@@ -992,11 +995,13 @@ export declare const eventItemSchema: z.ZodObject<{
992
995
  duration?: number | undefined;
993
996
  }[];
994
997
  slug?: string | undefined;
998
+ couponIds?: string[] | undefined;
995
999
  hasRaffle?: boolean | undefined;
996
1000
  raffleType?: "spin_wheel" | "open_raffle" | "top_n_scorers" | "top_n_participants" | undefined;
997
1001
  raffleTopN?: number | undefined;
998
1002
  rafflePrize?: string | undefined;
999
1003
  rafflePrizeCouponId?: string | undefined;
1004
+ rafflePrizeCouponIds?: string[] | undefined;
1000
1005
  raffleGithubFunctionUrl?: string | undefined;
1001
1006
  raffleWinnerUserId?: string | undefined;
1002
1007
  raffleWinnerDisplayName?: string | undefined;
@@ -1108,11 +1113,13 @@ export declare const eventItemSchema: z.ZodObject<{
1108
1113
  flaggedEntries: number;
1109
1114
  };
1110
1115
  slug?: string | undefined;
1116
+ couponIds?: string[] | undefined;
1111
1117
  hasRaffle?: boolean | undefined;
1112
1118
  raffleType?: "spin_wheel" | "open_raffle" | "top_n_scorers" | "top_n_participants" | undefined;
1113
1119
  raffleTopN?: number | undefined;
1114
1120
  rafflePrize?: string | undefined;
1115
1121
  rafflePrizeCouponId?: string | undefined;
1122
+ rafflePrizeCouponIds?: string[] | undefined;
1116
1123
  raffleGithubFunctionUrl?: string | undefined;
1117
1124
  raffleWinnerUserId?: string | undefined;
1118
1125
  raffleWinnerDisplayName?: string | undefined;
@@ -2,6 +2,7 @@ export * from "./firestore.js";
2
2
  import { z } from "zod";
3
3
  import { mediaUrlSchema } from "../../../validation/schemas.js";
4
4
  import { mediaFieldSchema } from "../../media/types/index.js";
5
+ import { attachedCouponIdsSchema } from "../../promotions/schemas/attached-coupons.js";
5
6
  const eventCoverImageSchema = z
6
7
  .union([
7
8
  mediaFieldSchema,
@@ -125,12 +126,16 @@ export const eventItemSchema = z.object({
125
126
  pollConfig: pollConfigSchema.optional(),
126
127
  surveyConfig: surveyConfigSchema.optional(),
127
128
  feedbackConfig: feedbackConfigSchema.optional(),
129
+ // Promotional coupons shown on the event page — NOT prizes (see below).
130
+ couponIds: attachedCouponIdsSchema,
128
131
  // Raffle fields
129
132
  hasRaffle: z.boolean().optional(),
130
133
  raffleType: z.enum(["top_n_scorers", "top_n_participants", "open_raffle", "spin_wheel"]).optional(),
131
134
  raffleTopN: z.number().optional(),
132
135
  rafflePrize: z.string().optional(),
136
+ /** @deprecated read-only legacy scalar — see `rafflePrizeCouponIdsOf`. */
133
137
  rafflePrizeCouponId: z.string().optional(),
138
+ rafflePrizeCouponIds: attachedCouponIdsSchema,
134
139
  rafflePrizeProductIds: z.array(z.string()).optional(),
135
140
  raffleGithubFunctionUrl: z.string().optional(),
136
141
  raffleWinnerUserId: z.string().optional(),
@@ -121,11 +121,15 @@ export interface EventItem {
121
121
  pollConfig?: PollConfig;
122
122
  surveyConfig?: SurveyConfig;
123
123
  feedbackConfig?: FeedbackConfig;
124
+ /** Promotional coupons shown on the page — see `EventDocument.couponIds`. */
125
+ couponIds?: string[];
124
126
  hasRaffle?: boolean;
125
127
  raffleType?: RaffleType;
126
128
  raffleTopN?: number;
127
129
  rafflePrize?: string;
130
+ /** @deprecated legacy scalar — resolve via `rafflePrizeCouponIdsOf`. */
128
131
  rafflePrizeCouponId?: string;
132
+ rafflePrizeCouponIds?: string[];
129
133
  rafflePrizeProductIds?: string[];
130
134
  raffleGithubFunctionUrl?: string;
131
135
  raffleWinnerUserId?: string;
@@ -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 || undefined, benefits: cfg?.benefits ?? [], testimonial: cfg?.testimonial, buttonText: cfg?.buttonText }));
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: gift wrap tells the
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.giftWrapAddon ||
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 },
@@ -822,6 +822,10 @@ export interface OrderDocument extends BaseDocument {
822
822
  codRemainingAmount?: number;
823
823
  /** COD handling fee charged to the buyer: min(subtotal × codHandlingFeePercent / 100, codHandlingFeeCap). Only set when paymentMethod === "cod". */
824
824
  codHandlingFee?: number;
825
+ /** Buyer opted into WhatsApp order-update messages at checkout (unchecked by default). */
826
+ whatsappNotifyAddon?: boolean;
827
+ /** Fee charged for the WhatsApp addon, decimal rupees — snapshot of siteSettings.commissions.whatsappNotifyFee at order time. */
828
+ whatsappNotifyFee?: number;
825
829
  /** Buyer opted into gift wrapping at checkout (unchecked by default). */
826
830
  giftWrapAddon?: boolean;
827
831
  /** Fee charged for gift wrap, decimal rupees — snapshot of siteSettings.commissions.giftWrapFee at order time. */
@@ -231,12 +231,15 @@ export interface Order {
231
231
  /**
232
232
  * Paid add-ons the buyer opted into for THIS order (= this store).
233
233
  *
234
- * These are operational flags, not just billing records:
235
- * `giftWrapAddon`/`shipmentProtectionAddon` are what
234
+ * These are operational flags, not just billing records: `whatsappNotifyAddon`
235
+ * is what the status-change notifier checks to decide whether to send a
236
+ * WhatsApp message, and `giftWrapAddon`/`shipmentProtectionAddon` are what
236
237
  * the packer needs to see before dispatch. They live on the order itself and
237
238
  * are filterable (`orders` SIEVE_FIELDS) precisely so nobody has to keep a
238
239
  * separate list of who asked for what.
239
240
  */
241
+ whatsappNotifyAddon?: boolean;
242
+ whatsappNotifyFee?: number;
240
243
  giftWrapAddon?: boolean;
241
244
  giftWrapFee?: number;
242
245
  /** The buyer's gift message — the packer has to physically include this. */
@@ -0,0 +1,30 @@
1
+ import type { AttachedCoupon } from "../constants/attached-coupons";
2
+ export interface AttachedCouponsSectionProps {
3
+ /** Already resolved, already filtered — see `getAttachedCoupons`. */
4
+ coupons: AttachedCoupon[];
5
+ /** Section heading. Events and blog posts word this differently. */
6
+ title?: string;
7
+ /** Optional one-line lead-in under the heading. */
8
+ description?: string;
9
+ }
10
+ /**
11
+ * The coupons a piece of content advertises — events and blog posts alike.
12
+ *
13
+ * Copy-code only, by design: the reader takes the code to checkout, where
14
+ * every existing validation, per-user limit and stacking rule already applies
15
+ * against a real cart. An "apply to cart" button here would have to reproduce
16
+ * `detectCouponConflict`'s one-seller-coupon-per-store-plus-one-admin rule and
17
+ * explain its refusals on a page that knows nothing about the cart.
18
+ *
19
+ * 🛑 Renders NOTHING when there are no coupons — not an empty state. This sits
20
+ * inside an article or an event description, and a "no coupons available" box
21
+ * on the 95% of posts that never had one is noise that makes the 5% harder to
22
+ * notice.
23
+ *
24
+ * 🛑 It deliberately does NOT label which seller a coupon belongs to, even
25
+ * though `CouponDocument` carries `scope`/`storeId`. A reader cannot act on
26
+ * that: the coupon either applies to their cart or it does not, and checkout
27
+ * is where that is decided and explained. Printing a store name here would
28
+ * imply a scope the card cannot actually enforce.
29
+ */
30
+ export declare function AttachedCouponsSection({ coupons, title, description, }: AttachedCouponsSectionProps): import("react").JSX.Element | null;
@@ -0,0 +1,99 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useId } from "react";
4
+ import { Badge, Div, Grid, Heading, Stack, Text } from "../../../ui/index.js";
5
+ import { formatCurrency } from "../../../utils/number.formatter.js";
6
+ import { CouponCodeCopyRow } from "./CouponCodeCopyRow.js";
7
+ /**
8
+ * Headline summary of what a coupon gives — "25% off", "₹200 off",
9
+ * "Free shipping".
10
+ *
11
+ * 🛑 `type` is a plain `string` here, not the `CouponType` union, because this
12
+ * renders a PROJECTION of a document rather than the document. That is a
13
+ * deliberate trade: a new coupon type added upstream falls through to the
14
+ * coupon's own `name` instead of failing to compile. The alternative —
15
+ * `Record<CouponType, …>` — is the right shape when the exhaustiveness is the
16
+ * point (Root Cause #61), and the wrong one across a serialisation boundary
17
+ * where the value has already been stringified.
18
+ */
19
+ function summarise(coupon) {
20
+ switch (coupon.type) {
21
+ case "percentage":
22
+ return `${coupon.discountValue}% off`;
23
+ case "fixed":
24
+ return `${formatCurrency(coupon.discountValue, "INR")} off`;
25
+ case "free_shipping":
26
+ return "Free shipping";
27
+ case "points_earn":
28
+ return `${coupon.discountValue}% back in points`;
29
+ default:
30
+ // buy_x_get_y and anything added later: the name already says it.
31
+ return null;
32
+ }
33
+ }
34
+ /** "Min. spend ₹999 · Up to ₹500 off · Ends 10 Nov" — only the parts that exist. */
35
+ function conditionsOf(coupon) {
36
+ const parts = [];
37
+ if (coupon.minPurchase)
38
+ parts.push(`Min. spend ${formatCurrency(coupon.minPurchase, "INR")}`);
39
+ if (coupon.maxDiscount)
40
+ parts.push(`Up to ${formatCurrency(coupon.maxDiscount, "INR")} off`);
41
+ /*
42
+ * 🛑 An absent `endsAt` means NO END DATE, not "expired" — it is `undefined`
43
+ * for every open-ended coupon. Rendering "Expired" here would libel every
44
+ * one of them, and rendering nothing is correct: there is no deadline to
45
+ * report. Expiry is not this component's job at all; `getAttachedCoupons`
46
+ * has already dropped anything past its window.
47
+ */
48
+ if (coupon.endsAt) {
49
+ parts.push(`Ends ${new Date(coupon.endsAt).toLocaleDateString("en-IN", { day: "numeric", month: "short" })}`);
50
+ }
51
+ return parts.length > 0 ? parts.join(" · ") : null;
52
+ }
53
+ /**
54
+ * The coupons a piece of content advertises — events and blog posts alike.
55
+ *
56
+ * Copy-code only, by design: the reader takes the code to checkout, where
57
+ * every existing validation, per-user limit and stacking rule already applies
58
+ * against a real cart. An "apply to cart" button here would have to reproduce
59
+ * `detectCouponConflict`'s one-seller-coupon-per-store-plus-one-admin rule and
60
+ * explain its refusals on a page that knows nothing about the cart.
61
+ *
62
+ * 🛑 Renders NOTHING when there are no coupons — not an empty state. This sits
63
+ * inside an article or an event description, and a "no coupons available" box
64
+ * on the 95% of posts that never had one is noise that makes the 5% harder to
65
+ * notice.
66
+ *
67
+ * 🛑 It deliberately does NOT label which seller a coupon belongs to, even
68
+ * though `CouponDocument` carries `scope`/`storeId`. A reader cannot act on
69
+ * that: the coupon either applies to their cart or it does not, and checkout
70
+ * is where that is decided and explained. Printing a store name here would
71
+ * imply a scope the card cannot actually enforce.
72
+ */
73
+ export function AttachedCouponsSection({ coupons, title = "Coupons", description, }) {
74
+ /*
75
+ * 🛑 `useId`, not a literal. This component is reusable and nothing stops a
76
+ * page mounting it twice — a hardcoded id would then appear on two elements
77
+ * and `aria-labelledby` would resolve to whichever came first, silently
78
+ * mislabelling the second section. React generates one id per instance.
79
+ *
80
+ * Called before the empty-return below because it is a HOOK: an early
81
+ * return above it would change the hook order between a post with coupons
82
+ * and one without.
83
+ */
84
+ const headingId = useId();
85
+ if (coupons.length === 0)
86
+ return null;
87
+ return (_jsxs(Stack, { gap: "md", "aria-labelledby": headingId, children: [_jsxs(Stack, { gap: "xs", children: [_jsx(Heading, { id: headingId, level: 2, weight: "semibold", size: "xl", children: title }), description ? (_jsx(Text, { size: "sm", color: "muted", children: description })) : null] }), _jsx(Grid, { cols: "cardsWide", gap: "md", children: coupons.map((coupon) => {
88
+ const summary = summarise(coupon);
89
+ const conditions = conditionsOf(coupon);
90
+ /*
91
+ * `surface="card"` already carries the border, radius and shadow —
92
+ * adding `border=` / `rounded=` beside it emits a second, competing
93
+ * set of utilities, and with `important: true` in both Tailwind
94
+ * configs the winner is decided by emission order rather than by
95
+ * anything readable here (Root Cause #79).
96
+ */
97
+ return (_jsx(Div, { surface: "card", padding: "card", children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Stack, { gap: "xs", children: [summary ? (_jsx(Div, { children: _jsx(Badge, { variant: "primary", children: summary }) })) : null, _jsx(Text, { weight: "semibold", children: coupon.name }), coupon.description ? (_jsx(Text, { size: "sm", color: "muted", children: coupon.description })) : null] }), _jsx(CouponCodeCopyRow, { code: coupon.code, ariaContext: coupon.name }), conditions ? (_jsx(Text, { size: "xs", color: "muted", children: conditions })) : null] }) }, coupon.id));
98
+ }) })] }));
99
+ }
@@ -0,0 +1,19 @@
1
+ export interface CouponCodeCopyRowProps {
2
+ /** The code to copy. Rendered verbatim — it is what the buyer must type. */
3
+ code: string;
4
+ /** Accessible context, e.g. the coupon name. Appended to the button label for screen readers. */
5
+ ariaContext?: string;
6
+ }
7
+ /**
8
+ * The code + "Copy code" row. ONE implementation, shared by `EventOfferCard`
9
+ * (offer events, one coupon) and `AttachedCouponsSection` (events and blog
10
+ * posts, several).
11
+ *
12
+ * Extracted rather than copied: this is the second consumer, and the
13
+ * Duplication Framework's keep-it rule ("Rule of Three — two copies is
14
+ * observation") is outranked here by its consolidate rule #2, the bug-fix
15
+ * multiplier. The clipboard call has a real failure mode — it is denied
16
+ * outright on insecure origins and by permission — and a fix to that handling
17
+ * in one copy would silently not reach the other.
18
+ */
19
+ export declare function CouponCodeCopyRow({ code, ariaContext }: CouponCodeCopyRowProps): import("react").JSX.Element;
@@ -0,0 +1,39 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useState } from "react";
4
+ import { normalizeError } from "../../../errors/normalize.js";
5
+ import { Code, Row } from "../../../ui/index.js";
6
+ import { Button } from "../../../ui/components/Button.js";
7
+ /**
8
+ * The code + "Copy code" row. ONE implementation, shared by `EventOfferCard`
9
+ * (offer events, one coupon) and `AttachedCouponsSection` (events and blog
10
+ * posts, several).
11
+ *
12
+ * Extracted rather than copied: this is the second consumer, and the
13
+ * Duplication Framework's keep-it rule ("Rule of Three — two copies is
14
+ * observation") is outranked here by its consolidate rule #2, the bug-fix
15
+ * multiplier. The clipboard call has a real failure mode — it is denied
16
+ * outright on insecure origins and by permission — and a fix to that handling
17
+ * in one copy would silently not reach the other.
18
+ */
19
+ export function CouponCodeCopyRow({ code, ariaContext }) {
20
+ const [copied, setCopied] = useState(false);
21
+ const copyCode = async () => {
22
+ try {
23
+ await navigator.clipboard.writeText(code);
24
+ setCopied(true);
25
+ setTimeout(() => setCopied(false), 2000);
26
+ }
27
+ catch (_err) {
28
+ /*
29
+ * Clipboard access is denied outright on insecure origins and by user
30
+ * permission. The button simply never flips to "Copied", and the code is
31
+ * still on screen to select by hand — so the failure costs convenience,
32
+ * never the ability to use the coupon. Nothing is shown to the user,
33
+ * because there is nothing they could do about it.
34
+ */
35
+ void normalizeError(_err);
36
+ }
37
+ };
38
+ return (_jsxs(Row, { surface: "muted", padding: "inline", align: "center", gap: "3", rounded: "lg", children: [_jsx(Code, { className: "flex-1", weight: "bold", size: "base", color: "primary", children: code }), _jsx(Button, { type: "button", variant: "outline", onClick: copyCode, "aria-label": ariaContext ? `Copy coupon code ${code} — ${ariaContext}` : `Copy coupon code ${code}`, children: copied ? "Copied!" : "Copy code" })] }));
39
+ }
@@ -1,3 +1,7 @@
1
+ export { AttachedCouponsSection } from "./AttachedCouponsSection";
2
+ export type { AttachedCouponsSectionProps } from "./AttachedCouponsSection";
3
+ export { CouponCodeCopyRow } from "./CouponCodeCopyRow";
4
+ export type { CouponCodeCopyRowProps } from "./CouponCodeCopyRow";
1
5
  export { CouponCard } from "./CouponCard";
2
6
  export { CouponHelpDetails } from "./CouponHelpDetails";
3
7
  export type { CouponHelpDetailsProps } from "./CouponHelpDetails";
@@ -1,3 +1,5 @@
1
+ export { AttachedCouponsSection } from "./AttachedCouponsSection.js";
2
+ export { CouponCodeCopyRow } from "./CouponCodeCopyRow.js";
1
3
  export { CouponCard } from "./CouponCard.js";
2
4
  export { CouponHelpDetails } from "./CouponHelpDetails.js";
3
5
  export { CouponsIndexListing } from "./CouponsIndexListing.js";
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Attached coupons — the contract shared by every layer that carries a set of
3
+ * coupon ids on a piece of CONTENT (`EventDocument.couponIds`,
4
+ * `BlogPostDocument.couponIds`).
5
+ *
6
+ * This file is deliberately dependency-free: the Zod write schemas, the admin
7
+ * editors, the repository's batch read and the public renderer all import the
8
+ * cap from here, so the number cannot drift between what an editor may save
9
+ * and what a page is willing to read. A cap enforced in one of those four
10
+ * places is not a cap.
11
+ */
12
+ /**
13
+ * Most coupons one event or blog post may carry.
14
+ *
15
+ * 🛑 This is a Rule #6 bound, not a style preference. The public event and
16
+ * blog pages resolve these ids in a Server Component, so the number is a
17
+ * direct multiplier on a guest-reachable read. Ten is already generous for a
18
+ * promotion readers are expected to scan; the write schemas reject more, and
19
+ * `couponsRepository.findByIds` re-clamps so a document hand-edited in the
20
+ * Firestore console still cannot widen the read.
21
+ *
22
+ * Raising it means re-checking both: the schemas AND the repository.
23
+ */
24
+ export declare const ATTACHED_COUPON_MAX = 10;
25
+ /**
26
+ * The public projection of a coupon when it is attached to content.
27
+ *
28
+ * 🛑 This is an ALLOW-LIST, and the reason it exists is Root Cause #70: a
29
+ * public payload built by spreading a document and deleting keys publishes
30
+ * every field nobody thought to delete — including fields the TypeScript
31
+ * interface never declared. `CouponDocument` carries `usage.currentUsage`,
32
+ * `analytics.totalRevenue`, `analytics.totalDiscount`, the whole
33
+ * `restrictions` block and `createdBy`; none of that is a reader's business,
34
+ * and `analytics.*` is seller revenue.
35
+ *
36
+ * Note the existing `GET /api/coupons` returns whole documents. That is
37
+ * pre-existing and out of scope here — but it is precisely why this path
38
+ * starts with a projection instead of inheriting the habit.
39
+ */
40
+ export interface AttachedCoupon {
41
+ id: string;
42
+ code: string;
43
+ name: string;
44
+ description: string;
45
+ /** `percentage` | `fixed` | `free_shipping` | `buy_x_get_y` | `points_earn` */
46
+ type: string;
47
+ /** The headline number: percent for `percentage`, rupees for `fixed`. */
48
+ discountValue: number;
49
+ /** Cap on a percentage discount, in rupees. */
50
+ maxDiscount?: number;
51
+ /** Minimum cart value before the coupon applies, in rupees. */
52
+ minPurchase?: number;
53
+ /** ISO string — `undefined` means no end date, NOT "expired". */
54
+ endsAt?: string;
55
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Attached coupons — the contract shared by every layer that carries a set of
3
+ * coupon ids on a piece of CONTENT (`EventDocument.couponIds`,
4
+ * `BlogPostDocument.couponIds`).
5
+ *
6
+ * This file is deliberately dependency-free: the Zod write schemas, the admin
7
+ * editors, the repository's batch read and the public renderer all import the
8
+ * cap from here, so the number cannot drift between what an editor may save
9
+ * and what a page is willing to read. A cap enforced in one of those four
10
+ * places is not a cap.
11
+ */
12
+ /**
13
+ * Most coupons one event or blog post may carry.
14
+ *
15
+ * 🛑 This is a Rule #6 bound, not a style preference. The public event and
16
+ * blog pages resolve these ids in a Server Component, so the number is a
17
+ * direct multiplier on a guest-reachable read. Ten is already generous for a
18
+ * promotion readers are expected to scan; the write schemas reject more, and
19
+ * `couponsRepository.findByIds` re-clamps so a document hand-edited in the
20
+ * Firestore console still cannot widen the read.
21
+ *
22
+ * Raising it means re-checking both: the schemas AND the repository.
23
+ */
24
+ export const ATTACHED_COUPON_MAX = 10;
@@ -27,6 +27,26 @@ export declare class CouponsRepository extends BaseRepository<CouponDocument> {
27
27
  * @returns Promise<CouponDocument | null>
28
28
  */
29
29
  getCouponByCode(code: string): Promise<CouponDocument | null>;
30
+ /**
31
+ * Batch-read coupons by id, for content that ATTACHES a set of them
32
+ * (`EventDocument.couponIds`, `BlogPostDocument.couponIds`).
33
+ *
34
+ * 🛑 ONE `getAll`, never a loop of `findById`. A blog post carrying six
35
+ * coupons would otherwise be six sequential round trips on a public,
36
+ * ISR-regenerated page — Rule #6 budgets about three for a whole request.
37
+ *
38
+ * 🛑 Order is PRESERVED and missing ids are DROPPED, not nulled — the same
39
+ * contract as `categoriesRepository.findByIds`, deliberately, so the two
40
+ * batch readers in this codebase cannot behave differently. A content
41
+ * editor's ordering is the display order, and a coupon that was deleted
42
+ * after being attached must not render as a hole in the grid.
43
+ *
44
+ * Bounded at `ATTACHED_COUPON_MAX`. The cap is the reason this is safe to
45
+ * call from a Server Component: the write schemas enforce the same number,
46
+ * so a hand-edited Firestore document cannot turn a public page into an
47
+ * unbounded read.
48
+ */
49
+ findByIds(ids: string[]): Promise<CouponDocument[]>;
30
50
  /**
31
51
  * Get all active coupons
32
52
  *
@@ -9,6 +9,7 @@ import { serverLogger } from "../../../monitoring/server-logger.js";
9
9
  */
10
10
  import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase/index.js";
11
11
  import { COUPONS_COLLECTION, COUPON_FIELDS, isValidCouponCode, isCouponValid, canUserUseCoupon, calculateDiscount, createCouponId, } from "../schemas/index.js";
12
+ import { ATTACHED_COUPON_MAX } from "../constants/attached-coupons.js";
12
13
  import { USER_COLLECTION } from "../../auth/schemas/index.js";
13
14
  import { DatabaseError } from "../../../errors/index.js";
14
15
  import { increment, arrayUnion, serverTimestamp } from "../../../contracts/field-ops.js";
@@ -71,6 +72,44 @@ export class CouponsRepository extends BaseRepository {
71
72
  throw new DatabaseError(`Failed to retrieve coupon by code: ${error instanceof Error ? error.message : "Unknown error"}`);
72
73
  }
73
74
  }
75
+ /**
76
+ * Batch-read coupons by id, for content that ATTACHES a set of them
77
+ * (`EventDocument.couponIds`, `BlogPostDocument.couponIds`).
78
+ *
79
+ * 🛑 ONE `getAll`, never a loop of `findById`. A blog post carrying six
80
+ * coupons would otherwise be six sequential round trips on a public,
81
+ * ISR-regenerated page — Rule #6 budgets about three for a whole request.
82
+ *
83
+ * 🛑 Order is PRESERVED and missing ids are DROPPED, not nulled — the same
84
+ * contract as `categoriesRepository.findByIds`, deliberately, so the two
85
+ * batch readers in this codebase cannot behave differently. A content
86
+ * editor's ordering is the display order, and a coupon that was deleted
87
+ * after being attached must not render as a hole in the grid.
88
+ *
89
+ * Bounded at `ATTACHED_COUPON_MAX`. The cap is the reason this is safe to
90
+ * call from a Server Component: the write schemas enforce the same number,
91
+ * so a hand-edited Firestore document cannot turn a public page into an
92
+ * unbounded read.
93
+ */
94
+ async findByIds(ids) {
95
+ const unique = Array.from(new Set(ids.filter(Boolean))).slice(0, ATTACHED_COUPON_MAX);
96
+ if (unique.length === 0)
97
+ return [];
98
+ try {
99
+ const refs = unique.map((id) => this.db.collection(this.collection).doc(id));
100
+ const snaps = await this.db.getAll(...refs);
101
+ return snaps.filter((s) => s.exists).map((s) => this.mapDoc(s));
102
+ }
103
+ catch (error) {
104
+ const e = normalizeError(error);
105
+ /*
106
+ * `kind` and a COUNT, never the raw message and never the ids —
107
+ * Root Cause #86 (a thrown message is written for a developer and this
108
+ * one can reach a user through a 500) plus Hobby's ~4 KB/s log budget.
109
+ */
110
+ throw new DatabaseError(`Failed to batch-read ${unique.length} coupon(s) by id (${e.kind})`);
111
+ }
112
+ }
74
113
  /**
75
114
  * Get all active coupons
76
115
  *
@@ -0,0 +1,39 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * The write shape for any `couponIds`-style field — one definition, imported
4
+ * by the event schema, the blog schema and both admin API routes.
5
+ *
6
+ * 🛑 ONE definition on purpose. Four hand-written `z.array(z.string())`s would
7
+ * be four chances for the cap to drift, and a cap enforced on three of four
8
+ * write paths is not a cap — the fourth is simply the way in. This is the same
9
+ * failure as the ten hand-maintained copies of the `ListingType` union in
10
+ * Root Cause #61.
11
+ *
12
+ * `.max()` carries a message because this one is reachable by a human in an
13
+ * admin form, and "Too big: expected array to have <=10 items" is not an
14
+ * instruction.
15
+ */
16
+ export declare const attachedCouponIdsSchema: z.ZodType<string[] | undefined>;
17
+ /**
18
+ * Dedupe and drop blanks. Still ONE definition — it moved out of the schema,
19
+ * it did not become four copies.
20
+ *
21
+ * 🛑 WHY IT IS NOT A `.transform()` ANY MORE, which is where it started:
22
+ *
23
+ * `.transform()` wraps the schema in `z.ZodEffects`, and **`ZodEffects` is a
24
+ * zod 3 type that does not exist in zod 4**. This repo runs both — appkit is
25
+ * pinned to zod 3.25.76 (peer `^3.24.0`) while the consumer app resolves zod
26
+ * 4.4.3 — so appkit's emitted `.d.ts` referenced a type the consumer's zod
27
+ * cannot resolve, the inference collapsed to `unknown`, and four admin routes
28
+ * failed to typecheck with `Type 'unknown' is not assignable to type
29
+ * 'string[] | undefined'`.
30
+ *
31
+ * The declared peer range is the thing that is actually wrong here, and
32
+ * aligning the two majors is a real migration rather than a drive-by. Until
33
+ * then: no schema crossing the package boundary may use `.transform()`,
34
+ * `.refine()` or `.preprocess()`, because all three produce `ZodEffects`.
35
+ *
36
+ * Callers apply this after parsing, which is the same boundary the dedupe
37
+ * always ran at — the route, not the client.
38
+ */
39
+ export declare function dedupeAttachedCouponIds(ids: readonly string[] | undefined): string[] | undefined;