@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.
Files changed (47) hide show
  1. package/dist/_internal/server/features/bundles/actions.js +7 -0
  2. package/dist/_internal/server/features/checkout/actions.js +24 -16
  3. package/dist/_internal/shared/actions/action-registry.js +41 -0
  4. package/dist/_internal/shared/checkout/order-math.d.ts +23 -5
  5. package/dist/_internal/shared/checkout/order-math.js +29 -6
  6. package/dist/_internal/shared/checkout/rules/_limits.d.ts +12 -2
  7. package/dist/_internal/shared/checkout/rules/_limits.js +12 -2
  8. package/dist/_internal/shared/features/cart/config.d.ts +2 -1
  9. package/dist/_internal/shared/features/cart/config.js +2 -1
  10. package/dist/_internal/shared/features/cart/schema.d.ts +6 -6
  11. package/dist/_internal/shared/features/notifications/action-url.d.ts +47 -0
  12. package/dist/_internal/shared/features/notifications/action-url.js +111 -0
  13. package/dist/constants/notification-types.d.ts +38 -10
  14. package/dist/constants/notification-types.js +39 -28
  15. package/dist/features/account/types/index.d.ts +1 -10
  16. package/dist/features/admin/actions/notification-actions.d.ts +12 -0
  17. package/dist/features/admin/actions/notification-actions.js +149 -4
  18. package/dist/features/admin/schemas/firestore.d.ts +14 -3
  19. package/dist/features/admin/schemas/firestore.js +79 -0
  20. package/dist/features/cart/actions/cart-actions.d.ts +16 -0
  21. package/dist/features/cart/actions/cart-actions.js +26 -12
  22. package/dist/features/cart/components/CartDrawer.js +2 -1
  23. package/dist/features/cart/schemas/firestore.d.ts +132 -8
  24. package/dist/features/cart/schemas/firestore.js +22 -0
  25. package/dist/features/email/index.d.ts +1 -1
  26. package/dist/features/email/index.js +5 -1
  27. package/dist/features/email/notification-templates.d.ts +35 -0
  28. package/dist/features/email/notification-templates.js +96 -0
  29. package/dist/features/orders/schemas/index.d.ts +2 -2
  30. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +30 -0
  31. package/dist/index.d.ts +3 -3
  32. package/dist/index.js +3 -1
  33. package/dist/schemas/registry.d.ts +2 -2
  34. package/dist/seed/factories/notification.factory.d.ts +2 -1
  35. package/dist/server-entry.d.ts +1 -0
  36. package/dist/server-entry.js +5 -0
  37. package/dist/server.d.ts +5 -1
  38. package/dist/server.js +10 -1
  39. package/dist/styles.css +58 -1
  40. package/dist/tailwind-utilities.css +1 -1
  41. package/dist/ui/components/QuantityStepper.d.ts +38 -0
  42. package/dist/ui/components/QuantityStepper.js +20 -0
  43. package/dist/ui/components/QuantityStepper.style.css +56 -0
  44. package/dist/ui/components/index.style.css +1 -0
  45. package/dist/ui/index.d.ts +2 -0
  46. package/dist/ui/index.js +1 -0
  47. package/package.json +1 -1
@@ -1,18 +1,46 @@
1
1
  /**
2
- * Notification Type Registry — W1-33
2
+ * Notification Type Registry — DERIVED, not restated.
3
3
  *
4
- * Canonical list of notification type IDs. Shared across:
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
- * Add new types here. Don't redefine the list inline in components.
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 const NOTIFICATION_TYPES: readonly ["order_placed", "order_shipped", "order_delivered", "order_cancelled", "bid_placed", "bid_outbid", "bid_won", "review_posted", "payout_processed"];
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
- * Prepended with an "All" option for the typical filter-chip pattern.
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 — W1-33
2
+ * Notification Type Registry — DERIVED, not restated.
3
3
  *
4
- * Canonical list of notification type IDs. Shared across:
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
- * Add new types here. Don't redefine the list inline in components.
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
- export const NOTIFICATION_TYPES = [
12
- "order_placed",
13
- "order_shipped",
14
- "order_delivered",
15
- "order_cancelled",
16
- "bid_placed",
17
- "bid_outbid",
18
- "bid_won",
19
- "review_posted",
20
- "payout_processed",
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
- * Prepended with an "All" option for the typical filter-chip pattern.
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: "order_placed", label: "Order placed" },
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 = "order" | "offer" | "promo" | "system" | "message";
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
- /** Notification type → the flat `SiteSettingsCredentials` field holding its approved Meta template name. */
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(notifInput);
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
- html: emailHtml ?? `<p>${message}</p>`,
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 type NotificationType = "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";
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?: "order" | "product" | "bid" | "review" | "blog" | "user" | "offer" | "support_ticket" | "scammer";
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", "==", NotificationType];
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
- // Lane gate. While a higher-obligation lane (a won auction, then an accepted
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 && (_jsxs(Row, { gap: "sm", children: [_jsx(Button, { rounded: "full", onClick: () => onQtyChange(item.id, item.quantity - 1), disabled: item.quantity <= 1, variant: "outline", size: "sm", className: "h-7 w-7 justify-center border border-neutral-200 border-[var(--appkit-color-border)] text-[length:var(--appkit-text-sm)] disabled:opacity-40", children: "\u2212" }), _jsx(Span, { size: "sm", className: "min-w-[1.5rem]", align: "center", children: item.quantity }), _jsx(Button, { rounded: "full", onClick: () => onQtyChange(item.id, item.quantity + 1), variant: "outline", size: "sm", className: "h-7 w-7 justify-center border border-neutral-200 border-[var(--appkit-color-border)] text-[length:var(--appkit-text-sm)]", children: "+" })] })), 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
+ .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();