@mohasinac/appkit 4.23.0 → 4.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_internal/server/features/bundles/actions.js +7 -0
- package/dist/_internal/server/features/checkout/actions.js +24 -16
- package/dist/_internal/shared/actions/action-registry.js +41 -0
- package/dist/_internal/shared/checkout/order-math.d.ts +23 -5
- package/dist/_internal/shared/checkout/order-math.js +29 -6
- package/dist/_internal/shared/checkout/rules/_limits.d.ts +12 -2
- package/dist/_internal/shared/checkout/rules/_limits.js +12 -2
- package/dist/_internal/shared/features/cart/config.d.ts +2 -1
- package/dist/_internal/shared/features/cart/config.js +2 -1
- package/dist/_internal/shared/features/cart/schema.d.ts +6 -6
- package/dist/_internal/shared/features/notifications/action-url.d.ts +47 -0
- package/dist/_internal/shared/features/notifications/action-url.js +111 -0
- package/dist/constants/notification-types.d.ts +38 -10
- package/dist/constants/notification-types.js +39 -28
- package/dist/features/account/types/index.d.ts +1 -10
- package/dist/features/admin/actions/notification-actions.d.ts +12 -0
- package/dist/features/admin/actions/notification-actions.js +149 -4
- package/dist/features/admin/schemas/firestore.d.ts +14 -3
- package/dist/features/admin/schemas/firestore.js +79 -0
- package/dist/features/cart/actions/cart-actions.d.ts +16 -0
- package/dist/features/cart/actions/cart-actions.js +26 -12
- package/dist/features/cart/components/CartDrawer.js +2 -1
- package/dist/features/cart/schemas/firestore.d.ts +132 -8
- package/dist/features/cart/schemas/firestore.js +22 -0
- package/dist/features/email/index.d.ts +1 -1
- package/dist/features/email/index.js +5 -1
- package/dist/features/email/notification-templates.d.ts +35 -0
- package/dist/features/email/notification-templates.js +96 -0
- package/dist/features/orders/schemas/index.d.ts +2 -2
- package/dist/features/tester/seed-data/tester-checklist-seed-data.js +30 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -1
- package/dist/schemas/registry.d.ts +2 -2
- package/dist/seed/factories/notification.factory.d.ts +2 -1
- package/dist/server-entry.d.ts +1 -0
- package/dist/server-entry.js +5 -0
- package/dist/server.d.ts +5 -1
- package/dist/server.js +10 -1
- package/dist/styles.css +58 -1
- package/dist/tailwind-utilities.css +1 -1
- package/dist/ui/components/QuantityStepper.d.ts +38 -0
- package/dist/ui/components/QuantityStepper.js +20 -0
- package/dist/ui/components/QuantityStepper.style.css +56 -0
- package/dist/ui/components/index.style.css +1 -0
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.js +1 -0
- package/package.json +1 -1
|
@@ -1,18 +1,46 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Notification Type Registry —
|
|
2
|
+
* Notification Type Registry — DERIVED, not restated.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* - AdminNotificationsView filter chips
|
|
6
|
-
* - NotificationPreferencesPanel (per-type toggles)
|
|
7
|
-
* - sendNotification() dispatch (type-based channel routing)
|
|
4
|
+
* ## What this file used to be, and why it mattered
|
|
8
5
|
*
|
|
9
|
-
*
|
|
6
|
+
* A hand-written 9-value list that called itself "canonical" while
|
|
7
|
+
* `NotificationDocument.type` held 27. Two of its nine —`review_posted` and
|
|
8
|
+
* `payout_processed` — were not real values at all, so those chips matched
|
|
9
|
+
* zero rows forever. Its two consumers were not cosmetic:
|
|
10
|
+
*
|
|
11
|
+
* · `AdminNotificationsView`'s filter chips, so an admin could not filter
|
|
12
|
+
* **18 of the 27 real types** and was offered two that never appear.
|
|
13
|
+
* · `AdminSiteSettingsView`'s per-channel allow-list, so an admin literally
|
|
14
|
+
* **could not allow-list `offer_received` or `payment_review`** for email
|
|
15
|
+
* or WhatsApp — the toggle did not exist.
|
|
16
|
+
*
|
|
17
|
+
* Everything here now derives from `NOTIFICATION_TYPE_VALUES`, so a new
|
|
18
|
+
* notification type is one edit and cannot be missing from either surface.
|
|
19
|
+
*
|
|
20
|
+
* ## Why the import points where it does
|
|
21
|
+
*
|
|
22
|
+
* `features/admin/schemas/firestore.ts` imports nothing from `constants/`,
|
|
23
|
+
* so this direction is acyclic. That is NOT true of `field-names.ts`, which
|
|
24
|
+
* is a deliberate leaf that `features/products/types` imports — which is why
|
|
25
|
+
* that file keeps its duplicated values and relies on an audit instead.
|
|
26
|
+
*/
|
|
27
|
+
import { NOTIFICATION_TYPE_VALUES } from "../features/admin/schemas/firestore";
|
|
28
|
+
import type { NotificationType } from "../features/admin/schemas/firestore";
|
|
29
|
+
export { NOTIFICATION_TYPE_VALUES };
|
|
30
|
+
export type { NotificationType };
|
|
31
|
+
/** Alias kept for the two existing importers. */
|
|
32
|
+
export declare const NOTIFICATION_TYPES: readonly ["order_placed", "order_confirmed", "order_shipped", "order_delivered", "order_cancelled", "bid_placed", "bid_outbid", "bid_won", "bid_lost", "auction_ended", "review_approved", "review_replied", "product_available", "promotion", "system", "welcome", "account_action", "offer_received", "offer_responded", "offer_expired", "offer_counter_accepted", "refund_initiated", "prize_won", "prize_reveal_expired", "emi_installment_due_soon", "emi_installment_overdue", "payment_review", "catalogue_images_stale"];
|
|
33
|
+
/**
|
|
34
|
+
* `order_shipped` → `Order shipped`.
|
|
35
|
+
*
|
|
36
|
+
* Derived rather than hand-labelled: a hand-written label map is the same
|
|
37
|
+
* drift risk one layer down, and every one of these values is already a
|
|
38
|
+
* readable snake_case phrase.
|
|
10
39
|
*/
|
|
11
|
-
export declare
|
|
12
|
-
export type NotificationType = (typeof NOTIFICATION_TYPES)[number];
|
|
40
|
+
export declare function notificationTypeLabel(type: NotificationType | string): string;
|
|
13
41
|
/**
|
|
14
|
-
* Shape used by filter-chip rows (id + display label)
|
|
15
|
-
*
|
|
42
|
+
* Shape used by filter-chip rows (id + display label), with the usual "All"
|
|
43
|
+
* option prepended.
|
|
16
44
|
*/
|
|
17
45
|
export declare const NOTIFICATION_TYPE_TABS: ReadonlyArray<{
|
|
18
46
|
id: string;
|
|
@@ -1,37 +1,48 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Notification Type Registry —
|
|
2
|
+
* Notification Type Registry — DERIVED, not restated.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* - AdminNotificationsView filter chips
|
|
6
|
-
* - NotificationPreferencesPanel (per-type toggles)
|
|
7
|
-
* - sendNotification() dispatch (type-based channel routing)
|
|
4
|
+
* ## What this file used to be, and why it mattered
|
|
8
5
|
*
|
|
9
|
-
*
|
|
6
|
+
* A hand-written 9-value list that called itself "canonical" while
|
|
7
|
+
* `NotificationDocument.type` held 27. Two of its nine —`review_posted` and
|
|
8
|
+
* `payout_processed` — were not real values at all, so those chips matched
|
|
9
|
+
* zero rows forever. Its two consumers were not cosmetic:
|
|
10
|
+
*
|
|
11
|
+
* · `AdminNotificationsView`'s filter chips, so an admin could not filter
|
|
12
|
+
* **18 of the 27 real types** and was offered two that never appear.
|
|
13
|
+
* · `AdminSiteSettingsView`'s per-channel allow-list, so an admin literally
|
|
14
|
+
* **could not allow-list `offer_received` or `payment_review`** for email
|
|
15
|
+
* or WhatsApp — the toggle did not exist.
|
|
16
|
+
*
|
|
17
|
+
* Everything here now derives from `NOTIFICATION_TYPE_VALUES`, so a new
|
|
18
|
+
* notification type is one edit and cannot be missing from either surface.
|
|
19
|
+
*
|
|
20
|
+
* ## Why the import points where it does
|
|
21
|
+
*
|
|
22
|
+
* `features/admin/schemas/firestore.ts` imports nothing from `constants/`,
|
|
23
|
+
* so this direction is acyclic. That is NOT true of `field-names.ts`, which
|
|
24
|
+
* is a deliberate leaf that `features/products/types` imports — which is why
|
|
25
|
+
* that file keeps its duplicated values and relies on an audit instead.
|
|
10
26
|
*/
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
27
|
+
import { NOTIFICATION_TYPE_VALUES } from "../features/admin/schemas/firestore";
|
|
28
|
+
export { NOTIFICATION_TYPE_VALUES };
|
|
29
|
+
/** Alias kept for the two existing importers. */
|
|
30
|
+
export const NOTIFICATION_TYPES = NOTIFICATION_TYPE_VALUES;
|
|
31
|
+
/**
|
|
32
|
+
* `order_shipped` → `Order shipped`.
|
|
33
|
+
*
|
|
34
|
+
* Derived rather than hand-labelled: a hand-written label map is the same
|
|
35
|
+
* drift risk one layer down, and every one of these values is already a
|
|
36
|
+
* readable snake_case phrase.
|
|
37
|
+
*/
|
|
38
|
+
export function notificationTypeLabel(type) {
|
|
39
|
+
return String(type).replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase());
|
|
40
|
+
}
|
|
22
41
|
/**
|
|
23
|
-
* Shape used by filter-chip rows (id + display label)
|
|
24
|
-
*
|
|
42
|
+
* Shape used by filter-chip rows (id + display label), with the usual "All"
|
|
43
|
+
* option prepended.
|
|
25
44
|
*/
|
|
26
45
|
export const NOTIFICATION_TYPE_TABS = [
|
|
27
46
|
{ id: "All", label: "All" },
|
|
28
|
-
{ id
|
|
29
|
-
{ id: "order_shipped", label: "Order shipped" },
|
|
30
|
-
{ id: "order_delivered", label: "Order delivered" },
|
|
31
|
-
{ id: "order_cancelled", label: "Order cancelled" },
|
|
32
|
-
{ id: "bid_placed", label: "Bid placed" },
|
|
33
|
-
{ id: "bid_outbid", label: "Bid outbid" },
|
|
34
|
-
{ id: "bid_won", label: "Bid won" },
|
|
35
|
-
{ id: "review_posted", label: "Review posted" },
|
|
36
|
-
{ id: "payout_processed", label: "Payout processed" },
|
|
47
|
+
...NOTIFICATION_TYPE_VALUES.map((id) => ({ id, label: notificationTypeLabel(id) })),
|
|
37
48
|
];
|
|
@@ -37,16 +37,7 @@ export interface UserOrder {
|
|
|
37
37
|
createdAt?: string;
|
|
38
38
|
updatedAt?: string;
|
|
39
39
|
}
|
|
40
|
-
export type NotificationType
|
|
41
|
-
export interface UserNotification {
|
|
42
|
-
id: string;
|
|
43
|
-
title: string;
|
|
44
|
-
message: string;
|
|
45
|
-
type: NotificationType;
|
|
46
|
-
isRead: boolean;
|
|
47
|
-
createdAt: string;
|
|
48
|
-
href?: string;
|
|
49
|
-
}
|
|
40
|
+
export type { NotificationType } from "../../admin/schemas/firestore";
|
|
50
41
|
export type OfferStatus = "pending" | "countered" | "accepted" | "paid" | "declined" | "expired" | "withdrawn";
|
|
51
42
|
export interface UserOffer {
|
|
52
43
|
id: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { NotificationDocument, NotificationCreateInput } from "../schemas";
|
|
2
|
+
import { type NotificationAudience } from "../../../_internal/shared/features/notifications/action-url";
|
|
2
3
|
export declare function markNotificationRead(id: string): Promise<void>;
|
|
3
4
|
export declare function markAllNotificationsRead(userId: string): Promise<number>;
|
|
4
5
|
export declare function deleteNotification(id: string): Promise<void>;
|
|
@@ -16,6 +17,17 @@ export interface SendNotificationInput extends NotificationCreateInput {
|
|
|
16
17
|
emailHtml?: string;
|
|
17
18
|
/** Buyer paid the ₹10 WhatsApp order-updates addon — enables WhatsApp channel for this notification even when the user has no standing WhatsApp subscription. */
|
|
18
19
|
orderWhatsappAddonPaid?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Which portal the recipient reads this in — decides where `actionUrl`
|
|
22
|
+
* points when the caller does not supply one.
|
|
23
|
+
*
|
|
24
|
+
* Defaults from the notification TYPE, not from the recipient's role: a
|
|
25
|
+
* seller is also a buyer, and `order_shipped` belongs in `/user` while the
|
|
26
|
+
* payout for that same order belongs in `/store`. Pass it explicitly only
|
|
27
|
+
* when one type genuinely reaches two audiences — `offer_received` goes to
|
|
28
|
+
* the seller, `offer_responded` back to the buyer.
|
|
29
|
+
*/
|
|
30
|
+
audience?: NotificationAudience;
|
|
19
31
|
}
|
|
20
32
|
export interface SendNotificationResult {
|
|
21
33
|
notification: NotificationDocument;
|
|
@@ -7,6 +7,30 @@ import { isChannelHealthy, withChannelRetry } from "../../../_internal/server/no
|
|
|
7
7
|
import { enqueueJob } from "../../jobs/actions/enqueue-job";
|
|
8
8
|
import { serverLogger } from "../../../monitoring";
|
|
9
9
|
import { decryptPii } from "../../../security/index";
|
|
10
|
+
import { resolveNotificationActionUrl, } from "../../../_internal/shared/features/notifications/action-url";
|
|
11
|
+
import { renderNotificationEmail } from "../../email/notification-templates";
|
|
12
|
+
/**
|
|
13
|
+
* A notification's `actionUrl` is a site-relative path — correct for the
|
|
14
|
+
* in-app bell, useless in an email, where a bare `/user/orders/x` is not a
|
|
15
|
+
* link at all.
|
|
16
|
+
*
|
|
17
|
+
* `NEXT_PUBLIC_APP_URL` is the same source `ApiClient` uses for its own
|
|
18
|
+
* server-side base, so there is one answer to "what host is this" rather than
|
|
19
|
+
* a second one on `siteSettings` to drift from it.
|
|
20
|
+
*
|
|
21
|
+
* Falls back to returning the path unchanged rather than guessing a host: a
|
|
22
|
+
* wrong absolute URL sends the reader to someone else's site, while a
|
|
23
|
+
* relative one merely fails to be clickable. The template also drops the
|
|
24
|
+
* button entirely when this is undefined.
|
|
25
|
+
*/
|
|
26
|
+
function absoluteActionUrl(path) {
|
|
27
|
+
if (!path)
|
|
28
|
+
return undefined;
|
|
29
|
+
if (/^https?:\/\//i.test(path))
|
|
30
|
+
return path;
|
|
31
|
+
const base = process.env.NEXT_PUBLIC_APP_URL?.replace(/\/+$/, "");
|
|
32
|
+
return base ? `${base}${path}` : path;
|
|
33
|
+
}
|
|
10
34
|
import { DEFAULT_NOTIFICATION_CHANNELS, meetsMinPriority, } from "../schemas/firestore";
|
|
11
35
|
/**
|
|
12
36
|
* Notification types whose WhatsApp send is gated behind the buyer's
|
|
@@ -24,7 +48,27 @@ const ORDER_GATED_TYPES = new Set([
|
|
|
24
48
|
"order_cancelled",
|
|
25
49
|
"refund_initiated",
|
|
26
50
|
]);
|
|
27
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* Notification type → the flat `SiteSettingsCredentials` field holding its
|
|
53
|
+
* approved Meta template name.
|
|
54
|
+
*
|
|
55
|
+
* 🛑 **`Partial` here is CORRECT and is not the `typeToPrefsKey` mistake.**
|
|
56
|
+
*
|
|
57
|
+
* The distinction is what a missing key MEANS. A missing `typeToPrefsKey`
|
|
58
|
+
* entry silently ignored a user's opt-out — a real setting that read as
|
|
59
|
+
* honoured and was not. A missing entry here means "no Meta-approved template
|
|
60
|
+
* exists for this type", which is a fact about the Meta account, not a bug:
|
|
61
|
+
* six of the 28 types have one, and the rest legitimately fall back to
|
|
62
|
+
* free-form (which Meta accepts inside the 24-hour customer-service window
|
|
63
|
+
* and rejects outside it — the runner already logs that rejection).
|
|
64
|
+
*
|
|
65
|
+
* Getting six approved templates is a Meta Business review process, not a
|
|
66
|
+
* code change, so listing all 28 here with empty values would be a
|
|
67
|
+
* `Record<…>` that lies about coverage.
|
|
68
|
+
*
|
|
69
|
+
* The six that ARE approved are the order lifecycle, which is also the set
|
|
70
|
+
* gated behind the buyer's ₹10 addon above — the two lists agree on purpose.
|
|
71
|
+
*/
|
|
28
72
|
const WHATSAPP_TEMPLATE_FIELD = {
|
|
29
73
|
order_placed: "whatsappTemplateOrderPlaced",
|
|
30
74
|
order_confirmed: "whatsappTemplateOrderConfirmed",
|
|
@@ -56,6 +100,46 @@ export async function listNotifications(userId, limit = 20) {
|
|
|
56
100
|
export async function getUnreadNotificationCount(userId) {
|
|
57
101
|
return notificationRepository.getUnreadCount(userId);
|
|
58
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Which portal each notification type belongs to.
|
|
105
|
+
*
|
|
106
|
+
* `Record<NotificationType, …>`, so a new type cannot compile without an
|
|
107
|
+
* answer — the same discipline `typeToPrefsKey` needed after it silently
|
|
108
|
+
* omitted three types and ignored those users' opt-outs.
|
|
109
|
+
*/
|
|
110
|
+
const TYPE_AUDIENCE = {
|
|
111
|
+
// Buyer-facing: the order lifecycle they are waiting on.
|
|
112
|
+
order_placed: "buyer",
|
|
113
|
+
order_confirmed: "buyer",
|
|
114
|
+
order_shipped: "buyer",
|
|
115
|
+
order_delivered: "buyer",
|
|
116
|
+
order_cancelled: "buyer",
|
|
117
|
+
refund_initiated: "buyer",
|
|
118
|
+
payment_review: "buyer",
|
|
119
|
+
emi_installment_due_soon: "buyer",
|
|
120
|
+
emi_installment_overdue: "buyer",
|
|
121
|
+
prize_won: "buyer",
|
|
122
|
+
prize_reveal_expired: "buyer",
|
|
123
|
+
product_available: "buyer",
|
|
124
|
+
bid_placed: "buyer",
|
|
125
|
+
bid_outbid: "buyer",
|
|
126
|
+
bid_won: "buyer",
|
|
127
|
+
bid_lost: "buyer",
|
|
128
|
+
offer_responded: "buyer",
|
|
129
|
+
offer_counter_accepted: "buyer",
|
|
130
|
+
offer_expired: "buyer",
|
|
131
|
+
review_replied: "buyer",
|
|
132
|
+
catalogue_images_stale: "buyer",
|
|
133
|
+
// Seller-facing.
|
|
134
|
+
auction_ended: "seller", // "your auction closed with no winner"
|
|
135
|
+
offer_received: "seller",
|
|
136
|
+
review_approved: "seller",
|
|
137
|
+
// Account-level, which lives in the user portal for everyone.
|
|
138
|
+
welcome: "buyer",
|
|
139
|
+
account_action: "buyer",
|
|
140
|
+
system: "buyer",
|
|
141
|
+
promotion: "buyer",
|
|
142
|
+
};
|
|
59
143
|
/**
|
|
60
144
|
* Create an in-app notification and fan out to enabled external channels.
|
|
61
145
|
*
|
|
@@ -67,9 +151,26 @@ export async function getUnreadNotificationCount(userId) {
|
|
|
67
151
|
* 5. The required credentials are set in siteSettings.credentials
|
|
68
152
|
*/
|
|
69
153
|
export async function sendNotification(input) {
|
|
70
|
-
const { userEmail, userPhone, emailHtml, ...notifInput } = input;
|
|
154
|
+
const { userEmail, userPhone, emailHtml, audience, ...notifInput } = input;
|
|
155
|
+
/*
|
|
156
|
+
* Fill `actionUrl` when the caller did not.
|
|
157
|
+
*
|
|
158
|
+
* It was set by 5 of 40 call sites, so 35 notifications arrived in the bell
|
|
159
|
+
* as text with nothing to click. Resolving it HERE rather than asking 35
|
|
160
|
+
* writers to remember means the destination is right by construction, and
|
|
161
|
+
* changing where a `bid` notification lands is one edit rather than a sweep.
|
|
162
|
+
*
|
|
163
|
+
* A caller-supplied value always wins — a few writers legitimately point at
|
|
164
|
+
* something the relatedType cannot express (the payment-proof upload page
|
|
165
|
+
* rather than the order page).
|
|
166
|
+
*/
|
|
167
|
+
const resolvedActionUrl = notifInput.actionUrl ??
|
|
168
|
+
resolveNotificationActionUrl(notifInput.relatedType, notifInput.relatedId, audience ?? TYPE_AUDIENCE[notifInput.type] ?? "buyer");
|
|
71
169
|
// Always write the in-app notification first.
|
|
72
|
-
const notification = await notificationRepository.create(
|
|
170
|
+
const notification = await notificationRepository.create({
|
|
171
|
+
...notifInput,
|
|
172
|
+
...(resolvedActionUrl ? { actionUrl: resolvedActionUrl } : {}),
|
|
173
|
+
});
|
|
73
174
|
// Load channel config + credentials (one Firestore read).
|
|
74
175
|
let settings;
|
|
75
176
|
try {
|
|
@@ -102,6 +203,20 @@ export async function sendNotification(input) {
|
|
|
102
203
|
const resolvedPhone = userPhone ??
|
|
103
204
|
(fetchedUserDoc?.phoneNumber ? decryptPii(fetchedUserDoc.phoneNumber) : undefined);
|
|
104
205
|
// Map NotificationType to a NotificationTypePrefs key.
|
|
206
|
+
/*
|
|
207
|
+
* 🛑 COMPLETE and compile-checked: `Record<NotificationType, …>`, not
|
|
208
|
+
* `Partial<Record<string, …>>`.
|
|
209
|
+
*
|
|
210
|
+
* The partial version silently omitted `emi_installment_due_soon`,
|
|
211
|
+
* `emi_installment_overdue` and `payment_review` — and a missing key means
|
|
212
|
+
* `typeKey` is undefined, so the opt-out check below never fires and the
|
|
213
|
+
* user's preference is IGNORED. A silently-ignored opt-out is worse than a
|
|
214
|
+
* missing feature: the toggle is right there, it reads as honoured, and
|
|
215
|
+
* mail keeps arriving.
|
|
216
|
+
*
|
|
217
|
+
* Typed on the union so the next type added cannot compile without an
|
|
218
|
+
* answer to "which toggle silences this".
|
|
219
|
+
*/
|
|
105
220
|
const typeToPrefsKey = {
|
|
106
221
|
order_placed: "orderUpdates", order_confirmed: "orderUpdates",
|
|
107
222
|
order_shipped: "orderUpdates", order_delivered: "orderUpdates",
|
|
@@ -116,6 +231,19 @@ export async function sendNotification(input) {
|
|
|
116
231
|
product_available: "system",
|
|
117
232
|
prize_won: "orderUpdates",
|
|
118
233
|
prize_reveal_expired: "orderUpdates",
|
|
234
|
+
// Money owed on an order. Under orderUpdates rather than a new toggle:
|
|
235
|
+
// an EMI installment IS an order update, and inventing an eighth
|
|
236
|
+
// preference key means an existing user's saved prefs have no value for
|
|
237
|
+
// it — which reads as "off" or "on" depending on the default and is a
|
|
238
|
+
// migration nobody asked for.
|
|
239
|
+
emi_installment_due_soon: "orderUpdates",
|
|
240
|
+
emi_installment_overdue: "orderUpdates",
|
|
241
|
+
// The manual-payment review queue — the buyer's proof was verified,
|
|
242
|
+
// rejected, or needs re-uploading. Squarely an order update.
|
|
243
|
+
payment_review: "orderUpdates",
|
|
244
|
+
// "Your catalogue photos are going stale" — housekeeping about the user's
|
|
245
|
+
// own account, which is what `system` covers.
|
|
246
|
+
catalogue_images_stale: "system",
|
|
119
247
|
};
|
|
120
248
|
const typeKey = typeToPrefsKey[type];
|
|
121
249
|
// If the user has explicitly disabled this notification type, skip all external channels.
|
|
@@ -138,7 +266,24 @@ export async function sendNotification(input) {
|
|
|
138
266
|
await withChannelRetry("email", () => emailProvider.send({
|
|
139
267
|
to: resolvedEmail,
|
|
140
268
|
subject: title,
|
|
141
|
-
|
|
269
|
+
/*
|
|
270
|
+
* A real template, not `<p>{message}</p>`.
|
|
271
|
+
*
|
|
272
|
+
* `emailHtml` existed on the input from the day the channel was
|
|
273
|
+
* built and NOT ONE of the 40 call sites passed it, so all 28
|
|
274
|
+
* types shipped a bare paragraph — no framing, no branding,
|
|
275
|
+
* nothing to click. Rendering here rather than asking 40 writers
|
|
276
|
+
* to remember is what makes that structurally impossible to
|
|
277
|
+
* regress: a caller-supplied `emailHtml` still wins, for the
|
|
278
|
+
* handful of flows with genuinely bespoke mail.
|
|
279
|
+
*/
|
|
280
|
+
html: emailHtml ??
|
|
281
|
+
renderNotificationEmail(type, {
|
|
282
|
+
title,
|
|
283
|
+
message,
|
|
284
|
+
siteName: fromName,
|
|
285
|
+
actionUrl: absoluteActionUrl(resolvedActionUrl),
|
|
286
|
+
}),
|
|
142
287
|
text: message,
|
|
143
288
|
}));
|
|
144
289
|
emailStatus = "sent";
|
|
@@ -4,7 +4,18 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { AboutContentDocument } from "../../about/schemas/firestore";
|
|
6
6
|
export declare const ADMIN_CHECKOUT_BYPASS_FLAG_KEY: "adminCheckoutBypass";
|
|
7
|
-
export
|
|
7
|
+
export declare const NOTIFICATION_TYPE_VALUES: readonly ["order_placed", "order_confirmed", "order_shipped", "order_delivered", "order_cancelled", "bid_placed", "bid_outbid", "bid_won", "bid_lost", "auction_ended", "review_approved", "review_replied", "product_available", "promotion", "system", "welcome", "account_action", "offer_received", "offer_responded", "offer_expired", "offer_counter_accepted", "refund_initiated", "prize_won", "prize_reveal_expired", "emi_installment_due_soon", "emi_installment_overdue", "payment_review", "catalogue_images_stale"];
|
|
8
|
+
export type NotificationType = (typeof NOTIFICATION_TYPE_VALUES)[number];
|
|
9
|
+
/**
|
|
10
|
+
* What a notification points AT. Drives the shared `actionUrl` resolver, so a
|
|
11
|
+
* value here without a per-record page in some role is a notification that
|
|
12
|
+
* lands nowhere.
|
|
13
|
+
*
|
|
14
|
+
* Runtime array for the same reason as the type union above — and
|
|
15
|
+
* `catalogueItem` was being sent with an `as never` cast rather than added.
|
|
16
|
+
*/
|
|
17
|
+
export declare const NOTIFICATION_RELATED_TYPE_VALUES: readonly ["order", "product", "bid", "review", "blog", "user", "offer", "support_ticket", "scammer", "catalogueItem", "payout", "store"];
|
|
18
|
+
export type NotificationRelatedType = (typeof NOTIFICATION_RELATED_TYPE_VALUES)[number];
|
|
8
19
|
import type { BaseDocument } from "../../../_internal/shared/types/base-document";
|
|
9
20
|
export type NotificationPriority = "low" | "normal" | "high";
|
|
10
21
|
export interface NotificationDocument extends BaseDocument {
|
|
@@ -19,7 +30,7 @@ export interface NotificationDocument extends BaseDocument {
|
|
|
19
30
|
isRead: boolean;
|
|
20
31
|
readAt?: Date;
|
|
21
32
|
relatedId?: string;
|
|
22
|
-
relatedType?:
|
|
33
|
+
relatedType?: NotificationRelatedType;
|
|
23
34
|
/** Outcome of the async whatsappNotify job for this notification's WhatsApp send, if one was enqueued. */
|
|
24
35
|
whatsappStatus?: "queued" | "sent" | "failed" | "skipped";
|
|
25
36
|
/** jobs/{id} doc that ran (or is running) the WhatsApp send for this notification. */
|
|
@@ -82,7 +93,7 @@ export type NotificationUpdateInput = Partial<Pick<NotificationDocument, "isRead
|
|
|
82
93
|
export declare const notificationQueryHelpers: {
|
|
83
94
|
readonly byUser: (userId: string) => readonly ["userId", "==", string];
|
|
84
95
|
readonly unread: (userId: string) => readonly [readonly ["userId", "==", string], readonly ["isRead", "==", false]];
|
|
85
|
-
readonly byType: (type: NotificationType) => readonly ["type", "==",
|
|
96
|
+
readonly byType: (type: NotificationType) => readonly ["type", "==", "system" | "welcome" | "promotion" | "order_placed" | "order_confirmed" | "order_shipped" | "order_delivered" | "order_cancelled" | "bid_placed" | "bid_outbid" | "bid_won" | "bid_lost" | "auction_ended" | "review_approved" | "review_replied" | "product_available" | "account_action" | "offer_received" | "offer_responded" | "offer_expired" | "offer_counter_accepted" | "refund_initiated" | "prize_won" | "prize_reveal_expired" | "emi_installment_due_soon" | "emi_installment_overdue" | "payment_review" | "catalogue_images_stale"];
|
|
86
97
|
};
|
|
87
98
|
export interface ChatRoomDocument extends BaseDocument {
|
|
88
99
|
buyerId: string;
|
|
@@ -8,6 +8,85 @@ import { DEFAULT_AUCTION_BID_INCREMENT_TIERS } from "../../../_internal/shared/f
|
|
|
8
8
|
// single occurrence in schemas. Splitting the literal makes the substring
|
|
9
9
|
// audit-invisible while keeping the runtime key correct.
|
|
10
10
|
export const ADMIN_CHECKOUT_BYPASS_FLAG_KEY = "adminCheckoutBypass";
|
|
11
|
+
/*
|
|
12
|
+
* 🛑 The ONE notification-type union. Four existed until 2026-08-26:
|
|
13
|
+
*
|
|
14
|
+
* · this one (27) — what `NotificationDocument.type` actually holds
|
|
15
|
+
* · `constants/notification-types.ts` (9) — fed the admin filter chips and
|
|
16
|
+
* the per-channel allow-list, so the admin list could not filter 18 of
|
|
17
|
+
* the 27 real types and an admin literally could not allow-list
|
|
18
|
+
* `offer_received` or `payment_review` for email or WhatsApp
|
|
19
|
+
* · `features/account/types/index.ts` (5: order|offer|promo|system|message)
|
|
20
|
+
* — a different vocabulary entirely, on a `UserNotification` type with
|
|
21
|
+
* zero real consumers
|
|
22
|
+
* · `seed/factories/notification.factory.ts` (9) — and THIS is the one
|
|
23
|
+
* `appkit/src/index.ts` exported publicly as `NotificationType`, so every
|
|
24
|
+
* external consumer got the seed factory's guess
|
|
25
|
+
*
|
|
26
|
+
* Between them they invented FOUR values no notification has ever had:
|
|
27
|
+
* `review_posted`, `payout_processed`, `review_received`, `payout_completed`.
|
|
28
|
+
* Note the first two and the last two are different spellings of the same two
|
|
29
|
+
* imagined concepts, which is how independent copies drift.
|
|
30
|
+
*
|
|
31
|
+
* A runtime array with the type derived from it, so a Zod enum, a chip list
|
|
32
|
+
* and a `Record<NotificationType, …>` all resolve here and a new value is one
|
|
33
|
+
* edit. Same shape as `ALL_LISTING_TYPES` after Root Cause #61.
|
|
34
|
+
*/
|
|
35
|
+
export const NOTIFICATION_TYPE_VALUES = [
|
|
36
|
+
"order_placed",
|
|
37
|
+
"order_confirmed",
|
|
38
|
+
"order_shipped",
|
|
39
|
+
"order_delivered",
|
|
40
|
+
"order_cancelled",
|
|
41
|
+
"bid_placed",
|
|
42
|
+
"bid_outbid",
|
|
43
|
+
"bid_won",
|
|
44
|
+
"bid_lost",
|
|
45
|
+
"auction_ended",
|
|
46
|
+
"review_approved",
|
|
47
|
+
"review_replied",
|
|
48
|
+
"product_available",
|
|
49
|
+
"promotion",
|
|
50
|
+
"system",
|
|
51
|
+
"welcome",
|
|
52
|
+
"account_action",
|
|
53
|
+
"offer_received",
|
|
54
|
+
"offer_responded",
|
|
55
|
+
"offer_expired",
|
|
56
|
+
"offer_counter_accepted",
|
|
57
|
+
"refund_initiated",
|
|
58
|
+
"prize_won",
|
|
59
|
+
"prize_reveal_expired",
|
|
60
|
+
"emi_installment_due_soon",
|
|
61
|
+
"emi_installment_overdue",
|
|
62
|
+
"payment_review",
|
|
63
|
+
// Was being SENT with an `as never` cast on the whole payload, which is how
|
|
64
|
+
// it stayed off this union: the cast silenced the one signal that would
|
|
65
|
+
// have said so, and with it every other field's type-check on that call.
|
|
66
|
+
"catalogue_images_stale",
|
|
67
|
+
];
|
|
68
|
+
/**
|
|
69
|
+
* What a notification points AT. Drives the shared `actionUrl` resolver, so a
|
|
70
|
+
* value here without a per-record page in some role is a notification that
|
|
71
|
+
* lands nowhere.
|
|
72
|
+
*
|
|
73
|
+
* Runtime array for the same reason as the type union above — and
|
|
74
|
+
* `catalogueItem` was being sent with an `as never` cast rather than added.
|
|
75
|
+
*/
|
|
76
|
+
export const NOTIFICATION_RELATED_TYPE_VALUES = [
|
|
77
|
+
"order",
|
|
78
|
+
"product",
|
|
79
|
+
"bid",
|
|
80
|
+
"review",
|
|
81
|
+
"blog",
|
|
82
|
+
"user",
|
|
83
|
+
"offer",
|
|
84
|
+
"support_ticket",
|
|
85
|
+
"scammer",
|
|
86
|
+
"catalogueItem",
|
|
87
|
+
"payout",
|
|
88
|
+
"store",
|
|
89
|
+
];
|
|
11
90
|
export const NOTIFICATIONS_COLLECTION = "notifications";
|
|
12
91
|
export const NOTIFICATIONS_INDEXED_FIELDS = [
|
|
13
92
|
"userId",
|
|
@@ -5,6 +5,22 @@
|
|
|
5
5
|
* are handled by the calling server action in the consumer.
|
|
6
6
|
*/
|
|
7
7
|
import type { AddToCartInput, CartDocument, UpdateCartItemInput } from "../schemas";
|
|
8
|
+
/**
|
|
9
|
+
* Lane gate. While a higher-obligation lane (a won auction, then an accepted
|
|
10
|
+
* offer) is outstanding, no NEW shopping may be added — otherwise the buyer can
|
|
11
|
+
* keep deferring something they already committed to by piling more into the
|
|
12
|
+
* cart. Enforced on the server, not only in the UI: the UI hint is a courtesy,
|
|
13
|
+
* this is the rule.
|
|
14
|
+
*
|
|
15
|
+
* 🛑 Every add-to-cart entry point must call this. It lived inline inside
|
|
16
|
+
* `addItemToCart` and therefore covered exactly one of the four ways a line can
|
|
17
|
+
* be created — `addBundleToCartAction` and `POST /api/cart` both call
|
|
18
|
+
* `cartRepository.addItem` directly and so bypassed it entirely, letting a buyer
|
|
19
|
+
* with an unpaid auction win keep shopping.
|
|
20
|
+
*
|
|
21
|
+
* Returns the loaded cart so the caller doesn't pay a second read.
|
|
22
|
+
*/
|
|
23
|
+
export declare function assertCanAddNewItems(userId: string): Promise<CartDocument>;
|
|
8
24
|
export declare function addItemToCart(userId: string, input: AddToCartInput): Promise<CartDocument>;
|
|
9
25
|
/** Error code the client maps to a lane-aware message. */
|
|
10
26
|
export declare const CART_LANE_BLOCKED = "CART_LANE_BLOCKED";
|
|
@@ -27,6 +27,31 @@ async function resolveStoreName(storeId, candidate) {
|
|
|
27
27
|
const store = await storeRepository.findById(storeId).catch(() => null);
|
|
28
28
|
return store?.storeName ?? storeId;
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Lane gate. While a higher-obligation lane (a won auction, then an accepted
|
|
32
|
+
* offer) is outstanding, no NEW shopping may be added — otherwise the buyer can
|
|
33
|
+
* keep deferring something they already committed to by piling more into the
|
|
34
|
+
* cart. Enforced on the server, not only in the UI: the UI hint is a courtesy,
|
|
35
|
+
* this is the rule.
|
|
36
|
+
*
|
|
37
|
+
* 🛑 Every add-to-cart entry point must call this. It lived inline inside
|
|
38
|
+
* `addItemToCart` and therefore covered exactly one of the four ways a line can
|
|
39
|
+
* be created — `addBundleToCartAction` and `POST /api/cart` both call
|
|
40
|
+
* `cartRepository.addItem` directly and so bypassed it entirely, letting a buyer
|
|
41
|
+
* with an unpaid auction win keep shopping.
|
|
42
|
+
*
|
|
43
|
+
* Returns the loaded cart so the caller doesn't pay a second read.
|
|
44
|
+
*/
|
|
45
|
+
export async function assertCanAddNewItems(userId) {
|
|
46
|
+
const cart = await cartRepository.getOrCreate(userId);
|
|
47
|
+
if (!canAddNewItems(cart.items ?? [])) {
|
|
48
|
+
const blocking = activeLane(cart.items ?? []);
|
|
49
|
+
throw new ValidationError(blocking === CART_LANE.AUCTION
|
|
50
|
+
? "Settle your won auction first — you can add other items once it's paid for."
|
|
51
|
+
: "Complete your accepted offer first — you can add other items once it's paid for.", { code: CART_LANE_BLOCKED });
|
|
52
|
+
}
|
|
53
|
+
return cart;
|
|
54
|
+
}
|
|
30
55
|
export async function addItemToCart(userId, input) {
|
|
31
56
|
serverLogger.debug("addItemToCart", { userId, productId: input.productId });
|
|
32
57
|
// SB-UNI-F 2026-05-13 — capability gate. Classified + live listings reject
|
|
@@ -36,18 +61,7 @@ export async function addItemToCart(userId, input) {
|
|
|
36
61
|
if (!canAddToCart(input.listingType)) {
|
|
37
62
|
throw new ValidationError(`Listings of type "${input.listingType}" cannot be added to the cart.`);
|
|
38
63
|
}
|
|
39
|
-
|
|
40
|
-
// offer) is outstanding, no NEW shopping may be added — otherwise the buyer
|
|
41
|
-
// can keep deferring something they already committed to by piling more into
|
|
42
|
-
// the cart. Enforced here, on the server, not only in the UI: the UI hint is
|
|
43
|
-
// a courtesy, this is the rule.
|
|
44
|
-
const cart = await cartRepository.getOrCreate(userId);
|
|
45
|
-
if (!canAddNewItems(cart.items ?? [])) {
|
|
46
|
-
const blocking = activeLane(cart.items ?? []);
|
|
47
|
-
throw new ValidationError(blocking === CART_LANE.AUCTION
|
|
48
|
-
? "Settle your won auction first — you can add other items once it's paid for."
|
|
49
|
-
: "Complete your accepted offer first — you can add other items once it's paid for.", { code: CART_LANE_BLOCKED });
|
|
50
|
-
}
|
|
64
|
+
await assertCanAddNewItems(userId);
|
|
51
65
|
const storeName = await resolveStoreName(input.storeId, input.storeName);
|
|
52
66
|
return cartRepository.addItem(userId, { ...input, storeName });
|
|
53
67
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { Aside, Button, Div, Heading, Row, Span, Stack, Text, TextLink } from "../../../ui";
|
|
4
4
|
import { MediaImage } from "../../media/MediaImage";
|
|
5
|
+
import { QuantityStepper } from "../../../ui/components/QuantityStepper";
|
|
5
6
|
import { formatCurrency } from "../../../utils/number.formatter";
|
|
6
7
|
import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
|
|
7
8
|
import { useHandMode } from "../../../_internal/client/hand-mode";
|
|
@@ -18,7 +19,7 @@ export function CartItemRow({ item, onQtyChange, onRemove, href, isOutOfStock =
|
|
|
18
19
|
return (_jsxs(Div, { layout: "flex", gap: isRow ? "3" : "4", surface: isRow ? "none" : "card", padding: isRow ? "none" : "sm", className: `min-w-0 transition-opacity ${isOutOfStock ? "opacity-60" : ""}`, children: [_jsx(Div, { surface: "muted", className: `relative ${isRow ? "h-16 w-16" : "h-20 w-20"} flex-shrink-0 ${__O.hidden}`, rounded: "lg", children: item.meta.image && (_jsx(MediaImage, { src: item.meta.image, alt: item.meta.title, size: "thumbnail" })) }), _jsxs(Stack, { justify: "between", className: "flex-1 min-w-0", children: [_jsxs(Row, { gap: "xs", className: "min-w-0", align: "start", children: [href ? (_jsx(TextLink, { href: href, target: "_blank", rel: "noopener noreferrer", weight: "medium", className: "text-[var(--appkit-color-text)] hover:underline underline-offset-2 line-clamp-2", children: item.meta.title })) : (_jsx(Text, { truncate: 2, weight: "medium", color: "primary", children: item.meta.title })), isOutOfStock && (_jsx(Span, { padding: "pill-2xs", color: "error", surface: "danger-surface", weight: "semibold", className: "flex-shrink-0 text-[10px] tracking-wide", rounded: "default", transform: "uppercase", children: "Out of Stock" }))] }), item.meta.attributes &&
|
|
19
20
|
Object.keys(item.meta.attributes).length > 0 && (_jsx(Text, { className: "text-[var(--appkit-color-text-muted)]", size: "xs", truncate: 1, children: Object.entries(item.meta.attributes)
|
|
20
21
|
.map(([k, v]) => `${k}: ${v}`)
|
|
21
|
-
.join(", ") })), _jsxs(Row, { justify: "between", className: "min-w-0", gap: "sm", children: [_jsx(Text, { className: "text-[var(--appkit-color-text)]", weight: "semibold", children: formatCurrency(item.meta.price * item.quantity, item.meta.currency) }), onQtyChange && !isOutOfStock && (
|
|
22
|
+
.join(", ") })), _jsxs(Row, { justify: "between", className: "min-w-0", gap: "sm", children: [_jsx(Text, { className: "text-[var(--appkit-color-text)]", weight: "semibold", children: formatCurrency(item.meta.price * item.quantity, item.meta.currency) }), onQtyChange && !isOutOfStock && (_jsx(QuantityStepper, { value: item.quantity, onChange: (next) => onQtyChange(item.id, next), min: 1, ariaLabel: `Quantity for ${item.meta.title}`, decrementLabel: ACTIONS.CART["decrease-quantity"].ariaLabel, incrementLabel: ACTIONS.CART["increase-quantity"].ariaLabel })), isOutOfStock && (_jsxs(Span, { size: "xs", color: "muted", children: ["Qty: ", item.quantity] }))] })] }), onRemove && (_jsx(Button, { onClick: () => onRemove(item.id), variant: "ghost", size: "sm", "aria-label": ACTIONS.CART["remove-item"].ariaLabel, className: CLS_REMOVE_BTN, children: "\u2715" }))] }));
|
|
22
23
|
}
|
|
23
24
|
export function CartDrawer({ isOpen, onClose, items, subtotal = 0, currency, isLoading, onQtyChange, onRemove, onCheckout, labels = {}, }) {
|
|
24
25
|
const { hand } = useHandMode();
|