@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
|
@@ -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 ||
|
|
262
|
+
memberCount: cfg?.memberCount, groupLink: cfg?.groupLink || "https://chat.whatsapp.com/", benefits: cfg?.benefits ?? [], testimonial: cfg?.testimonial, buttonText: cfg?.buttonText }));
|
|
263
263
|
},
|
|
264
264
|
},
|
|
265
265
|
faq: {
|
|
@@ -79,6 +79,7 @@ export async function issuePartialRefund(adminUid, orderId, deductFees, refundNo
|
|
|
79
79
|
: `Your full refund of ₹${grossRefund} is being processed.`,
|
|
80
80
|
relatedId: orderId,
|
|
81
81
|
relatedType: "order",
|
|
82
|
+
storeId: order.storeId,
|
|
82
83
|
});
|
|
83
84
|
serverLogger.info("Admin partial refund initiated", {
|
|
84
85
|
adminUid,
|
|
@@ -2,7 +2,7 @@ import React from "react";
|
|
|
2
2
|
/**
|
|
3
3
|
* Icon badges for an order's paid add-ons and applied coupon.
|
|
4
4
|
*
|
|
5
|
-
* These are fulfilment instructions, not decoration:
|
|
5
|
+
* These are fulfilment instructions, not decoration: WhatsApp tells the
|
|
6
6
|
* status-change notifier to message this buyer, gift wrap tells the packer to
|
|
7
7
|
* wrap the parcel and enclose the message, and either protection changes how a
|
|
8
8
|
* claim is handled. They are recorded on the order (and filterable in the
|
|
@@ -14,6 +14,8 @@ import React from "react";
|
|
|
14
14
|
* at once instead of being wired per screen.
|
|
15
15
|
*/
|
|
16
16
|
export interface OrderAddonBadgesOrder {
|
|
17
|
+
whatsappNotifyAddon?: boolean;
|
|
18
|
+
whatsappNotifyFee?: number;
|
|
17
19
|
giftWrapAddon?: boolean;
|
|
18
20
|
giftWrapFee?: number;
|
|
19
21
|
giftWrapMessage?: string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { Gift, ShieldAlert, ShieldCheck, Tag } from "lucide-react";
|
|
3
|
+
import { Gift, MessageCircle, ShieldAlert, ShieldCheck, Tag } from "lucide-react";
|
|
4
4
|
import { Row, Span, Stack, Text } from "../../../ui/index.js";
|
|
5
5
|
import { formatCurrency } from "../../../utils/number.formatter.js";
|
|
6
6
|
const CHIP_BASE = "inline-flex items-center gap-[var(--appkit-space-1)] rounded-full px-[var(--appkit-space-2)] py-[var(--appkit-space-0-5)] text-[length:var(--appkit-text-xs)] font-medium";
|
|
@@ -15,14 +15,15 @@ function Chip({ icon, label, tone, title, }) {
|
|
|
15
15
|
export function OrderAddonBadges({ order, variant = "compact", showCoupon = true, currency = "INR", className = "", }) {
|
|
16
16
|
const money = (v) => (typeof v === "number" && v > 0 ? formatCurrency(v, currency) : undefined);
|
|
17
17
|
const hasCoupon = showCoupon && !!order.couponCode;
|
|
18
|
-
const hasAny = order.
|
|
18
|
+
const hasAny = order.whatsappNotifyAddon ||
|
|
19
|
+
order.giftWrapAddon ||
|
|
19
20
|
order.shipmentProtectionAddon ||
|
|
20
21
|
order.saleProtectionAddon ||
|
|
21
22
|
hasCoupon;
|
|
22
23
|
if (!hasAny)
|
|
23
24
|
return null;
|
|
24
25
|
const isDetail = variant === "detail";
|
|
25
|
-
return (_jsxs(Stack, { gap: "xs", className: `min-w-0 ${className}`, children: [_jsxs(Row, { gap: "xs", align: "center", wrap: true, className: "min-w-0", children: [order.giftWrapAddon && (_jsx(Chip, { icon: _jsx(Gift, { size: 12 }), label: isDetail && money(order.giftWrapFee) ? `Gift wrap · ${money(order.giftWrapFee)}` : "Gift wrap", tone: "brand", title: "This parcel must be gift-wrapped before dispatch." })), order.shipmentProtectionAddon && (_jsx(Chip, { icon: _jsx(ShieldCheck, { size: 12 }), label: isDetail && money(order.shipmentProtectionFee) ? `Protected · ${money(order.shipmentProtectionFee)}` : "Protected", tone: "info", title: "Shipment protection purchased \u2014 loss or damage in transit is covered, claimed against the held funds." })), order.saleProtectionAddon && (_jsx(Chip, { icon: _jsx(ShieldAlert, { size: 12 }), label: isDetail && money(order.saleProtectionFee) ? `Sale protected · ${money(order.saleProtectionFee)}` : "Sale protected", tone: "info", title: "Sale protection purchased \u2014 if the item is not as described the buyer is refunded, no questions asked." })), hasCoupon && (_jsx(Chip, { icon: _jsx(Tag, { size: 12 }), label: isDetail && money(order.couponDiscount)
|
|
26
|
+
return (_jsxs(Stack, { gap: "xs", className: `min-w-0 ${className}`, children: [_jsxs(Row, { gap: "xs", align: "center", wrap: true, className: "min-w-0", children: [order.whatsappNotifyAddon && (_jsx(Chip, { icon: _jsx(MessageCircle, { size: 12 }), label: isDetail && money(order.whatsappNotifyFee) ? `WhatsApp updates · ${money(order.whatsappNotifyFee)}` : "WhatsApp updates", tone: "success", title: "Buyer opted into WhatsApp order-status updates \u2014 status changes on this order should notify them on WhatsApp." })), order.giftWrapAddon && (_jsx(Chip, { icon: _jsx(Gift, { size: 12 }), label: isDetail && money(order.giftWrapFee) ? `Gift wrap · ${money(order.giftWrapFee)}` : "Gift wrap", tone: "brand", title: "This parcel must be gift-wrapped before dispatch." })), order.shipmentProtectionAddon && (_jsx(Chip, { icon: _jsx(ShieldCheck, { size: 12 }), label: isDetail && money(order.shipmentProtectionFee) ? `Protected · ${money(order.shipmentProtectionFee)}` : "Protected", tone: "info", title: "Shipment protection purchased \u2014 loss or damage in transit is covered, claimed against the held funds." })), order.saleProtectionAddon && (_jsx(Chip, { icon: _jsx(ShieldAlert, { size: 12 }), label: isDetail && money(order.saleProtectionFee) ? `Sale protected · ${money(order.saleProtectionFee)}` : "Sale protected", tone: "info", title: "Sale protection purchased \u2014 if the item is not as described the buyer is refunded, no questions asked." })), hasCoupon && (_jsx(Chip, { icon: _jsx(Tag, { size: 12 }), label: isDetail && money(order.couponDiscount)
|
|
26
27
|
? `${order.couponCode} · −${money(order.couponDiscount)}`
|
|
27
28
|
: order.couponCode, tone: "success", title: `Coupon ${order.couponCode} applied to this order.` }))] }), isDetail && order.giftWrapAddon && order.giftWrapMessage && (_jsxs(Stack, { gap: "xs", className: "min-w-0", children: [_jsx(Text, { size: "xs", color: "muted", weight: "semibold", transform: "uppercase", className: "tracking-wide", children: "Gift message" }), _jsx(Text, { size: "sm", color: "primary", className: "min-w-0 whitespace-pre-wrap break-words", children: order.giftWrapMessage })] }))] }));
|
|
28
29
|
}
|
|
@@ -215,6 +215,10 @@ declare class OrderRepository extends BaseRepository<OrderDocument> {
|
|
|
215
215
|
canFilter: boolean;
|
|
216
216
|
canSort: boolean;
|
|
217
217
|
};
|
|
218
|
+
whatsappNotifyAddon: {
|
|
219
|
+
canFilter: boolean;
|
|
220
|
+
canSort: boolean;
|
|
221
|
+
};
|
|
218
222
|
giftWrapAddon: {
|
|
219
223
|
canFilter: boolean;
|
|
220
224
|
canSort: boolean;
|
|
@@ -335,6 +339,10 @@ declare class OrderRepository extends BaseRepository<OrderDocument> {
|
|
|
335
339
|
canFilter: boolean;
|
|
336
340
|
canSort: boolean;
|
|
337
341
|
};
|
|
342
|
+
whatsappNotifyAddon: {
|
|
343
|
+
canFilter: boolean;
|
|
344
|
+
canSort: boolean;
|
|
345
|
+
};
|
|
338
346
|
giftWrapAddon: {
|
|
339
347
|
canFilter: boolean;
|
|
340
348
|
canSort: boolean;
|
|
@@ -1364,6 +1364,7 @@ OrderRepository.SELLER_SIEVE_FIELDS = {
|
|
|
1364
1364
|
// Fulfilment flags. Filterable so "which of my orders need gift wrap?" is a
|
|
1365
1365
|
// query against the orders themselves — the whole point of recording the
|
|
1366
1366
|
// add-on on the order rather than keeping a side list of who asked for what.
|
|
1367
|
+
whatsappNotifyAddon: { canFilter: true, canSort: false },
|
|
1367
1368
|
giftWrapAddon: { canFilter: true, canSort: false },
|
|
1368
1369
|
shipmentProtectionAddon: { canFilter: true, canSort: false },
|
|
1369
1370
|
saleProtectionAddon: { canFilter: true, canSort: false },
|
|
@@ -1396,6 +1397,7 @@ OrderRepository.ADMIN_SIEVE_FIELDS = {
|
|
|
1396
1397
|
// Same fulfilment flags as SELLER_SIEVE_FIELDS — admin needs to answer
|
|
1397
1398
|
// "who opted into WhatsApp updates?" when a status change goes out, without
|
|
1398
1399
|
// maintaining a parallel tracker.
|
|
1400
|
+
whatsappNotifyAddon: { canFilter: true, canSort: false },
|
|
1399
1401
|
giftWrapAddon: { canFilter: true, canSort: false },
|
|
1400
1402
|
shipmentProtectionAddon: { canFilter: true, canSort: false },
|
|
1401
1403
|
saleProtectionAddon: { canFilter: true, canSort: false },
|
|
@@ -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
|
-
*
|
|
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;
|