@mohasinac/appkit 4.21.0 → 4.23.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 (102) hide show
  1. package/dist/_internal/server/features/checkout/locked-lines.js +12 -2
  2. package/dist/_internal/server/features/stores/adapters.d.ts +1 -1
  3. package/dist/_internal/server/features/stores/adapters.js +5 -0
  4. package/dist/_internal/server/jobs/core/auctionSettlement.js +10 -4
  5. package/dist/_internal/server/jobs/core/offerExpiry.js +10 -2
  6. package/dist/_internal/server/jobs/core/payoutBatch.js +15 -3
  7. package/dist/_internal/shared/features/products/schema.d.ts +8 -8
  8. package/dist/_internal/shared/features/promotions/schema.d.ts +4 -4
  9. package/dist/_internal/shared/history/index.d.ts +1 -1
  10. package/dist/_internal/shared/history/types.d.ts +16 -0
  11. package/dist/client.d.ts +7 -0
  12. package/dist/client.js +4 -0
  13. package/dist/constants/field-names.d.ts +12 -0
  14. package/dist/constants/field-names.js +12 -0
  15. package/dist/features/account/components/AddressForm.js +45 -29
  16. package/dist/features/admin/actions/admin-actions.js +13 -3
  17. package/dist/features/admin/components/AdminBidsView.js +7 -1
  18. package/dist/features/admin/components/AdminBundleEditorView.js +3 -0
  19. package/dist/features/admin/components/AdminScammerEditorView.d.ts +5 -1
  20. package/dist/features/admin/components/AdminScammerEditorView.js +3 -2
  21. package/dist/features/admin/components/AdminSupportTicketDetailView.d.ts +5 -1
  22. package/dist/features/admin/components/AdminSupportTicketDetailView.js +3 -2
  23. package/dist/features/auctions/components/PlaceBidFormClient.js +3 -1
  24. package/dist/features/auctions/repository/bid.repository.d.ts +21 -8
  25. package/dist/features/auctions/repository/bid.repository.js +57 -33
  26. package/dist/features/auctions/schemas/firestore.d.ts +27 -0
  27. package/dist/features/auctions/schemas/firestore.js +21 -0
  28. package/dist/features/auth/schemas/index.d.ts +2 -2
  29. package/dist/features/catalogue/actions/approve-catalogue-listing.js +7 -6
  30. package/dist/features/catalogue/actions/list-from-catalogue.js +2 -2
  31. package/dist/features/catalogue/actions/submit-for-approval.js +1 -4
  32. package/dist/features/catalogue/components/AdminCatalogueApprovalsView.js +5 -1
  33. package/dist/features/catalogue/components/CatalogueItemEditorView.js +3 -1
  34. package/dist/features/catalogue/repository/catalogue.repository.d.ts +23 -0
  35. package/dist/features/catalogue/repository/catalogue.repository.js +28 -1
  36. package/dist/features/catalogue/schemas/firestore.d.ts +19 -0
  37. package/dist/features/catalogue/schemas/firestore.js +12 -0
  38. package/dist/features/categories/schemas/index.d.ts +2 -2
  39. package/dist/features/layout/BottomActions.js +18 -1
  40. package/dist/features/layout/BottomActionsContext.d.ts +19 -0
  41. package/dist/features/layout/BottomActionsContext.js +37 -0
  42. package/dist/features/layout/hooks/useBottomActions.d.ts +10 -0
  43. package/dist/features/layout/hooks/useBottomActions.js +48 -11
  44. package/dist/features/layout/hooks/useFormBottomActions.d.ts +57 -0
  45. package/dist/features/layout/hooks/useFormBottomActions.js +89 -0
  46. package/dist/features/layout/index.d.ts +2 -0
  47. package/dist/features/layout/index.js +1 -0
  48. package/dist/features/messages/schemas/index.d.ts +6 -6
  49. package/dist/features/orders/schemas/index.d.ts +12 -12
  50. package/dist/features/payments/repository/payout.repository.d.ts +18 -5
  51. package/dist/features/payments/repository/payout.repository.js +45 -19
  52. package/dist/features/payments/schemas/firestore.d.ts +33 -1
  53. package/dist/features/payments/schemas/firestore.js +39 -0
  54. package/dist/features/payments/schemas/index.d.ts +12 -12
  55. package/dist/features/products/components/MakeOfferButton.js +5 -3
  56. package/dist/features/products/schemas/index.d.ts +2 -2
  57. package/dist/features/promotions/schemas/index.d.ts +12 -12
  58. package/dist/features/scams/repository/scammer.repository.d.ts +18 -1
  59. package/dist/features/scams/repository/scammer.repository.js +24 -2
  60. package/dist/features/scams/schemas/firestore.d.ts +24 -0
  61. package/dist/features/scams/schemas/firestore.js +30 -0
  62. package/dist/features/shell/QuickFormDrawer.js +15 -1
  63. package/dist/features/shell/SectionForm.d.ts +16 -2
  64. package/dist/features/shell/SectionForm.js +28 -2
  65. package/dist/features/shipments/components/AdminShipmentEditorView.js +5 -2
  66. package/dist/features/shipments/repository/shipments.repository.d.ts +18 -0
  67. package/dist/features/shipments/repository/shipments.repository.js +23 -1
  68. package/dist/features/shipments/schemas/firestore.d.ts +14 -0
  69. package/dist/features/shipments/schemas/firestore.js +19 -0
  70. package/dist/features/status-history/components/RecordStatusTimeline.d.ts +12 -0
  71. package/dist/features/status-history/components/RecordStatusTimeline.js +90 -0
  72. package/dist/features/status-history/index.d.ts +2 -0
  73. package/dist/features/status-history/index.js +1 -0
  74. package/dist/features/stores/repository/store.repository.d.ts +30 -2
  75. package/dist/features/stores/repository/store.repository.js +43 -4
  76. package/dist/features/stores/schemas/firestore.d.ts +29 -0
  77. package/dist/features/stores/schemas/firestore.js +31 -0
  78. package/dist/features/support/repository/support.repository.d.ts +47 -3
  79. package/dist/features/support/repository/support.repository.js +72 -12
  80. package/dist/features/support/schemas/firestore.d.ts +38 -1
  81. package/dist/features/support/schemas/firestore.js +34 -0
  82. package/dist/features/support/schemas/index.d.ts +2 -2
  83. package/dist/features/tester/seed-data/tester-checklist-seed-data.js +90 -0
  84. package/dist/index.d.ts +7 -1
  85. package/dist/index.js +16 -1
  86. package/dist/schemas/registry.d.ts +32 -32
  87. package/dist/seed/bids-seed-data.js +8 -0
  88. package/dist/ui/components/Form.d.ts +22 -1
  89. package/dist/ui/components/Form.js +30 -2
  90. package/dist/ui/components/Modal.js +4 -3
  91. package/dist/ui/components/SideDrawer.js +5 -4
  92. package/dist/ui/components/overlay-context.d.ts +21 -0
  93. package/dist/ui/components/overlay-context.js +25 -0
  94. package/dist/ui/forms/FormErrorList.d.ts +29 -0
  95. package/dist/ui/forms/FormErrorList.js +36 -0
  96. package/dist/ui/forms/FormErrorSummary.d.ts +8 -0
  97. package/dist/ui/forms/FormErrorSummary.js +15 -8
  98. package/dist/ui/forms/FormShell.d.ts +25 -1
  99. package/dist/ui/forms/FormShell.js +39 -4
  100. package/dist/ui/forms/index.d.ts +2 -0
  101. package/dist/ui/forms/index.js +1 -0
  102. package/package.json +1 -1
@@ -237,7 +237,10 @@ export async function finalizeLockedLines(items, orderId) {
237
237
  }
238
238
  if (item.bidId) {
239
239
  try {
240
- await bidRepository.attachOrder(item.bidId, orderId);
240
+ await bidRepository.attachOrder(item.bidId, orderId, {
241
+ actor: { role: "system" },
242
+ trigger: "finalizeLockedLines",
243
+ });
241
244
  }
242
245
  catch (err) {
243
246
  void normalizeError(err);
@@ -270,7 +273,14 @@ async function closeOutBuyoutLosers(productId, winningBidId) {
270
273
  const losers = active.filter((e) => e.id !== winningBidId);
271
274
  if (losers.length === 0)
272
275
  return;
273
- await bidRepository.markManyLost(losers.map((e) => e.ref));
276
+ // Pass the {ref, data} pairs whole, not just the refs — `getActiveByProduct`
277
+ // already returned the documents, so the timeline gets a real before-value
278
+ // at zero read cost.
279
+ await bidRepository.markManyLost(losers, {
280
+ actor: { role: "system" },
281
+ trigger: "finalizeLockedLines:buyoutClosedAuction",
282
+ reason: "A Buy Now purchase ended this auction.",
283
+ });
274
284
  serverLogger.info("finalizeLockedLines: buyout closed out losing bids", {
275
285
  productId,
276
286
  winningBidId,
@@ -27,7 +27,7 @@ import type { StoreDetail, StoreListItem } from "../../../../features/stores/typ
27
27
  */
28
28
  export declare const PUBLIC_STORE_FIELDS: readonly ["id", "storeSlug", "ownerId", "storeName", "storeDescription", "storeCategory", "storeLogoURL", "storeBannerURL", "status", "isPublic", "stats", "bio", "location", "website", "socialLinks", "returnPolicy", "shippingPolicy", "isVacationMode", "vacationMessage", "createdAt"];
29
29
  /** Everything else, with the reason it stays server-side. */
30
- export declare const PRIVATE_STORE_FIELDS: readonly ["updatedAt", "whatsappConfig", "adminNotes", "isVerified", "suspensionReason", "capabilities", "customCommissionRate", "googleReviews", "shippingConfig", "emiEnabled", "isFeatured", "vacationReturnDate", "isTestData", "testDataExpiresAt"];
30
+ export declare const PRIVATE_STORE_FIELDS: readonly ["updatedAt", "whatsappConfig", "adminNotes", "statusHistory", "statusHistoryTruncated", "isVerified", "suspensionReason", "capabilities", "customCommissionRate", "googleReviews", "shippingConfig", "emiEnabled", "isFeatured", "vacationReturnDate", "isTestData", "testDataExpiresAt"];
31
31
  /**
32
32
  * Accepted input: a `StoreDocument` (the common case) or a loosely-typed
33
33
  * Firestore row. The legacy top-level counters below predate `stats` and are
@@ -29,6 +29,11 @@ export const PRIVATE_STORE_FIELDS = [
29
29
  "updatedAt", // bookkeeping; no public reader
30
30
  "whatsappConfig", // Meta access token + WABA/catalog ids — secret
31
31
  "adminNotes", // schema comment: never shown to the owner or the public
32
+ // Admin decisions with actor UIDs and suspension reasons. The store OWNER
33
+ // sees this on their own admin surfaces; an anonymous visitor to /stores has
34
+ // no business reading who suspended whom and why.
35
+ "statusHistory",
36
+ "statusHistoryTruncated",
32
37
  "isVerified", // admin moderation state; surfaced via a separate badge feed if ever needed
33
38
  "suspensionReason", // admin moderation note
34
39
  "capabilities", // internal feature gating; server-enforced, never client-read
@@ -32,7 +32,11 @@ async function releasePendingBuyouts(ctx, product, holds) {
32
32
  for (const hold of holds) {
33
33
  try {
34
34
  await cartRepository.removeItemsByBidId(hold.data.userId, hold.id);
35
- await bidRepository.markCancelled(hold.id);
35
+ await bidRepository.markCancelled(hold.id, {
36
+ actor: { role: "system" },
37
+ trigger: "auctionSettlement:lapsedBuyoutHold",
38
+ reason: "The auction ended before this Buy Now claim was paid for.",
39
+ });
36
40
  await sendNotification({
37
41
  userId: hold.data.userId,
38
42
  type: "auction_ended",
@@ -84,7 +88,9 @@ async function settleAuction(ctx, product) {
84
88
  const reserve = product.reservePrice;
85
89
  if (typeof reserve === "number" && reserve > 0 && winnerEntry.data.bidAmount < reserve) {
86
90
  const batch = ctx.db.batch();
87
- activeBids.forEach(({ ref }) => bidRepository.markLost(batch, ref));
91
+ // `data` comes from the same query as `ref`, so the timeline entry is
92
+ // free even on an auction that closed with hundreds of bids (Rule #6).
93
+ activeBids.forEach(({ ref, data }) => bidRepository.markLost(batch, ref, { actor: { role: "system" }, trigger: "auctionSettlement" }, data));
88
94
  productRepository.updateStatusInBatch(batch, product.id, "archived");
89
95
  await batch.commit();
90
96
  const reserveStore = product.storeId
@@ -117,8 +123,8 @@ async function settleAuction(ctx, product) {
117
123
  return;
118
124
  }
119
125
  const batch = ctx.db.batch();
120
- bidRepository.markWon(batch, winnerEntry.ref);
121
- loserEntries.forEach(({ ref }) => bidRepository.markLost(batch, ref));
126
+ bidRepository.markWon(batch, winnerEntry.ref, { actor: { role: "system" }, trigger: "auctionSettlement" }, winnerEntry.data);
127
+ loserEntries.forEach(({ ref, data }) => bidRepository.markLost(batch, ref, { actor: { role: "system" }, trigger: "auctionSettlement" }, data));
122
128
  // Two-axis model: mark as sold without changing publish status.
123
129
  batch.update(ctx.db.collection("products").doc(product.id), {
124
130
  isSold: true,
@@ -155,7 +155,11 @@ async function lapseUnpaidAuctionWins(ctx) {
155
155
  for (const { userId, item } of buyoutHolds) {
156
156
  try {
157
157
  await cartRepository.removeItemsByBidId(userId, item.bidId);
158
- await bidRepository.markCancelled(item.bidId);
158
+ await bidRepository.markCancelled(item.bidId, {
159
+ actor: { role: "system" },
160
+ trigger: "runOfferExpiry:buyoutLapsed",
161
+ reason: "The Buy Now checkout window closed without payment.",
162
+ });
159
163
  await sendNotification({
160
164
  userId,
161
165
  type: "auction_ended",
@@ -211,7 +215,11 @@ async function lapseUnpaidAuctionWins(ctx) {
211
215
  for (const { userId, item } of auctionLines) {
212
216
  try {
213
217
  await cartRepository.removeItemsByBidId(userId, item.bidId);
214
- await bidRepository.markForfeited(item.bidId);
218
+ await bidRepository.markForfeited(item.bidId, {
219
+ actor: { role: "system" },
220
+ trigger: "runOfferExpiry:winUnpaid",
221
+ reason: "The 48-hour checkout window closed without payment.",
222
+ });
215
223
  await sendNotification({
216
224
  userId,
217
225
  type: "auction_ended",
@@ -9,7 +9,19 @@ import { payoutRepository } from "../../../../repositories";
9
9
  const MAX_FAILURES = 3;
10
10
  async function dispatch(ctx, entry) {
11
11
  const { ref, data: payout } = entry;
12
- await payoutRepository.markProcessing(ref);
12
+ /*
13
+ * The batch's running copy of the document, threaded through every write.
14
+ *
15
+ * Two reasons it is not just `payout`. (a) The snapshot came from the
16
+ * pending query, so history costs no extra read. (b) The sequence is
17
+ * pending → processing → failed; diffing the final write against the
18
+ * ORIGINAL snapshot would record "pending → failed" and lose the dispatch
19
+ * attempt that is the whole reason anyone opens the timeline.
20
+ */
21
+ let current = payout;
22
+ const actor = { role: "system" };
23
+ await payoutRepository.markProcessing(ref, { actor }, current);
24
+ current = { ...current, status: "processing" };
13
25
  try {
14
26
  const keyId = ctx.env("RAZORPAY_KEY_ID") ?? "";
15
27
  const keySecret = ctx.env("RAZORPAY_KEY_SECRET") ?? "";
@@ -67,7 +79,7 @@ async function dispatch(ctx, entry) {
67
79
  throw new Error(`Razorpay ${response.status}: ${errBody}`);
68
80
  }
69
81
  const result = (await response.json());
70
- await payoutRepository.recordSuccess(ref, result.id, result.status);
82
+ await payoutRepository.recordSuccess(ref, result.id, result.status, { actor }, current);
71
83
  ctx.logger.info(`Payout ${payout.id} dispatched`, { razorpayId: result.id, razorpayStatus: result.status });
72
84
  }
73
85
  catch (error) {
@@ -75,7 +87,7 @@ async function dispatch(ctx, entry) {
75
87
  const failureCount = (payout.failureCount ?? 0) + 1;
76
88
  const isFinal = failureCount >= MAX_FAILURES;
77
89
  const reason = error instanceof Error ? error.message : String(error);
78
- await payoutRepository.recordFailure(ref, failureCount, reason, isFinal);
90
+ await payoutRepository.recordFailure(ref, failureCount, reason, isFinal, { actor }, current);
79
91
  if (isFinal) {
80
92
  ctx.logger.error(`Payout ${payout.id} permanently failed after ${MAX_FAILURES} attempts`, error, {
81
93
  sellerId: payout.storeId,
@@ -41,7 +41,7 @@ export declare const productInputSchema: z.ZodObject<{
41
41
  condition?: "new" | "used" | "refurbished" | "like_new" | "good" | "fair" | "poor" | "broken" | undefined;
42
42
  insurance?: boolean | undefined;
43
43
  insuranceCost?: number | undefined;
44
- shippingPaidBy?: "seller" | "buyer" | undefined;
44
+ shippingPaidBy?: "buyer" | "seller" | undefined;
45
45
  seoTitle?: string | undefined;
46
46
  seoDescription?: string | undefined;
47
47
  seoKeywords?: string[] | undefined;
@@ -65,7 +65,7 @@ export declare const productInputSchema: z.ZodObject<{
65
65
  condition?: "new" | "used" | "refurbished" | "like_new" | "good" | "fair" | "poor" | "broken" | undefined;
66
66
  insurance?: boolean | undefined;
67
67
  insuranceCost?: number | undefined;
68
- shippingPaidBy?: "seller" | "buyer" | undefined;
68
+ shippingPaidBy?: "buyer" | "seller" | undefined;
69
69
  seoTitle?: string | undefined;
70
70
  seoDescription?: string | undefined;
71
71
  seoKeywords?: string[] | undefined;
@@ -114,7 +114,7 @@ export declare const productUpdateSchema: z.ZodObject<{
114
114
  condition?: "new" | "used" | "refurbished" | "like_new" | "good" | "fair" | "poor" | "broken" | undefined;
115
115
  insurance?: boolean | undefined;
116
116
  insuranceCost?: number | undefined;
117
- shippingPaidBy?: "seller" | "buyer" | undefined;
117
+ shippingPaidBy?: "buyer" | "seller" | undefined;
118
118
  seoTitle?: string | undefined;
119
119
  seoDescription?: string | undefined;
120
120
  seoKeywords?: string[] | undefined;
@@ -138,7 +138,7 @@ export declare const productUpdateSchema: z.ZodObject<{
138
138
  condition?: "new" | "used" | "refurbished" | "like_new" | "good" | "fair" | "poor" | "broken" | undefined;
139
139
  insurance?: boolean | undefined;
140
140
  insuranceCost?: number | undefined;
141
- shippingPaidBy?: "seller" | "buyer" | undefined;
141
+ shippingPaidBy?: "buyer" | "seller" | undefined;
142
142
  seoTitle?: string | undefined;
143
143
  seoDescription?: string | undefined;
144
144
  seoKeywords?: string[] | undefined;
@@ -201,7 +201,7 @@ export declare const auctionInputSchema: z.ZodObject<{
201
201
  condition?: "new" | "used" | "refurbished" | "like_new" | "good" | "fair" | "poor" | "broken" | undefined;
202
202
  insurance?: boolean | undefined;
203
203
  insuranceCost?: number | undefined;
204
- shippingPaidBy?: "seller" | "buyer" | undefined;
204
+ shippingPaidBy?: "buyer" | "seller" | undefined;
205
205
  reservePrice?: number | undefined;
206
206
  buyNowPrice?: number | undefined;
207
207
  minBidIncrement?: number | undefined;
@@ -233,7 +233,7 @@ export declare const auctionInputSchema: z.ZodObject<{
233
233
  condition?: "new" | "used" | "refurbished" | "like_new" | "good" | "fair" | "poor" | "broken" | undefined;
234
234
  insurance?: boolean | undefined;
235
235
  insuranceCost?: number | undefined;
236
- shippingPaidBy?: "seller" | "buyer" | undefined;
236
+ shippingPaidBy?: "buyer" | "seller" | undefined;
237
237
  reservePrice?: number | undefined;
238
238
  buyNowPrice?: number | undefined;
239
239
  minBidIncrement?: number | undefined;
@@ -299,7 +299,7 @@ export declare const preOrderInputSchema: z.ZodObject<{
299
299
  condition?: "new" | "used" | "refurbished" | "like_new" | "good" | "fair" | "poor" | "broken" | undefined;
300
300
  insurance?: boolean | undefined;
301
301
  insuranceCost?: number | undefined;
302
- shippingPaidBy?: "seller" | "buyer" | undefined;
302
+ shippingPaidBy?: "buyer" | "seller" | undefined;
303
303
  preOrderDepositPercent?: number | undefined;
304
304
  preOrderMaxQuantity?: number | undefined;
305
305
  seoTitle?: string | undefined;
@@ -327,7 +327,7 @@ export declare const preOrderInputSchema: z.ZodObject<{
327
327
  condition?: "new" | "used" | "refurbished" | "like_new" | "good" | "fair" | "poor" | "broken" | undefined;
328
328
  insurance?: boolean | undefined;
329
329
  insuranceCost?: number | undefined;
330
- shippingPaidBy?: "seller" | "buyer" | undefined;
330
+ shippingPaidBy?: "buyer" | "seller" | undefined;
331
331
  preOrderDepositPercent?: number | undefined;
332
332
  preOrderMaxQuantity?: number | undefined;
333
333
  preOrderProductionStatus?: "upcoming" | "in_production" | "ready_to_ship" | undefined;
@@ -75,7 +75,7 @@ export declare const createCouponSchema: z.ZodObject<{
75
75
  }, "strip", z.ZodTypeAny, {
76
76
  code: string;
77
77
  type: "fixed" | "percentage" | "free_shipping" | "buy_x_get_y";
78
- scope: "admin" | "seller";
78
+ scope: "seller" | "admin";
79
79
  name: string;
80
80
  discount: {
81
81
  value: number;
@@ -99,7 +99,7 @@ export declare const createCouponSchema: z.ZodObject<{
99
99
  }, {
100
100
  code: string;
101
101
  type: "fixed" | "percentage" | "free_shipping" | "buy_x_get_y";
102
- scope: "admin" | "seller";
102
+ scope: "seller" | "admin";
103
103
  name: string;
104
104
  discount: {
105
105
  value: number;
@@ -177,7 +177,7 @@ export declare const updateCouponSchema: z.ZodObject<Omit<{
177
177
  }>>>;
178
178
  }, "code">, "strip", z.ZodTypeAny, {
179
179
  type?: "fixed" | "percentage" | "free_shipping" | "buy_x_get_y" | undefined;
180
- scope?: "admin" | "seller" | undefined;
180
+ scope?: "seller" | "admin" | undefined;
181
181
  name?: string | undefined;
182
182
  discount?: {
183
183
  value: number;
@@ -200,7 +200,7 @@ export declare const updateCouponSchema: z.ZodObject<Omit<{
200
200
  } | undefined;
201
201
  }, {
202
202
  type?: "fixed" | "percentage" | "free_shipping" | "buy_x_get_y" | undefined;
203
- scope?: "admin" | "seller" | undefined;
203
+ scope?: "seller" | "admin" | undefined;
204
204
  name?: string | undefined;
205
205
  discount?: {
206
206
  value: number;
@@ -1,4 +1,4 @@
1
- export type { HistoryActorRole, HistoryActor, FieldChange, StatusChangeEntry, AppendHistoryResult, } from "./types";
1
+ export type { HistoryActorRole, HistoryActor, FieldChange, StatusChangeEntry, AppendHistoryResult, SerialisedStatusChangeEntry, } from "./types";
2
2
  export { STATUS_HISTORY_MAX } from "./types";
3
3
  export type { BuildHistoryEntryInput } from "./history";
4
4
  export { diffTrackedFields, buildHistoryEntry, appendHistoryEntry, withHistory, } from "./history";
@@ -59,3 +59,19 @@ export interface AppendHistoryResult {
59
59
  */
60
60
  truncatedCount: number;
61
61
  }
62
+ /**
63
+ * A `StatusChangeEntry` after it has crossed the RSC boundary.
64
+ *
65
+ * Identical except `at` is an ISO string. Server Components must serialise
66
+ * before handing history to a `"use client"` component: every other date prop
67
+ * on those drawers is already a string, and mixing the two is how a
68
+ * `toLocaleString` on a string ends up rendering "Invalid Date".
69
+ *
70
+ * Declared once here rather than restated per page — two client files had
71
+ * hand-written copies, which is both a duplication and (because each spelled
72
+ * `changes` as `Record<string, { from: unknown; to: unknown }>`) an untyped
73
+ * seam `audit-unknown-leakage` correctly refused.
74
+ */
75
+ export interface SerialisedStatusChangeEntry extends Omit<StatusChangeEntry, "at"> {
76
+ at: string;
77
+ }
package/dist/client.d.ts CHANGED
@@ -164,6 +164,13 @@ export type { ColorPickerFieldProps } from "./ui/forms";
164
164
  export { ColorPickerField } from "./ui/forms";
165
165
  export type { FormErrorSummaryProps } from "./ui/forms";
166
166
  export { FormErrorSummary } from "./ui/forms";
167
+ export { RecordStatusTimeline } from "./features/status-history/index";
168
+ export type { SerialisedStatusChangeEntry } from "./_internal/shared/history/types";
169
+ export type { FormErrorListProps } from "./ui/forms";
170
+ export { FormErrorList } from "./ui/forms";
171
+ export { useFormBottomActions } from "./features/layout/index";
172
+ export type { UseFormBottomActionsOptions } from "./features/layout/index";
173
+ export { OverlayContext, useIsInsideOverlay } from "./ui/components/overlay-context";
167
174
  export { Select } from "./ui/components/Select";
168
175
  export type { SelectOption, SelectProps } from "./ui/components/Select";
169
176
  export { Heading } from "./ui/components/Typography";
package/dist/client.js CHANGED
@@ -222,6 +222,10 @@ export { FieldCheckbox } from "./ui/forms";
222
222
  export { FieldTextarea } from "./ui/forms";
223
223
  export { ColorPickerField } from "./ui/forms";
224
224
  export { FormErrorSummary } from "./ui/forms";
225
+ export { RecordStatusTimeline } from "./features/status-history/index";
226
+ export { FormErrorList } from "./ui/forms";
227
+ export { useFormBottomActions } from "./features/layout/index";
228
+ export { OverlayContext, useIsInsideOverlay } from "./ui/components/overlay-context";
225
229
  export { Select } from "./ui/components/Select";
226
230
  export { Heading } from "./ui/components/Typography";
227
231
  export { Label, Text, Span, Caption } from "./ui/components/Typography";
@@ -341,6 +341,18 @@ export declare const AD_FIELDS: {
341
341
  readonly PAUSED: "paused";
342
342
  };
343
343
  };
344
+ /**
345
+ * The ad fields whose changes earn a `statusHistory` entry.
346
+ *
347
+ * Ads live inside the `siteSettings` singleton and have no repository, so
348
+ * `withHistory` is called from the route directly — it is a pure function, so
349
+ * that is fine. This list lives here beside `AD_FIELDS` rather than in a
350
+ * schema module because ads have no schema module of their own.
351
+ *
352
+ * The scheduling dates are tracked alongside `status`: an ad silently
353
+ * rescheduled reads on the timeline as the same kind of event as one paused.
354
+ */
355
+ export declare const AD_TRACKED_FIELDS: readonly ["status", "startAt", "endAt"];
344
356
  export declare const EVENT_FIELDS: {
345
357
  readonly ID: "id";
346
358
  readonly TITLE: "title";
@@ -390,6 +390,18 @@ export const AD_FIELDS = {
390
390
  PAUSED: "paused",
391
391
  },
392
392
  };
393
+ /**
394
+ * The ad fields whose changes earn a `statusHistory` entry.
395
+ *
396
+ * Ads live inside the `siteSettings` singleton and have no repository, so
397
+ * `withHistory` is called from the route directly — it is a pure function, so
398
+ * that is fine. This list lives here beside `AD_FIELDS` rather than in a
399
+ * schema module because ads have no schema module of their own.
400
+ *
401
+ * The scheduling dates are tracked alongside `status`: an ad silently
402
+ * rescheduled reads on the timeline as the same kind of event as one paused.
403
+ */
404
+ export const AD_TRACKED_FIELDS = ["status", "startAt", "endAt"];
393
405
  // ============================================================================
394
406
  // EVENT FIELDS
395
407
  // ============================================================================
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { normalizeError } from "../../../errors/normalize";
4
- import { useEffect, useState } from "react";
4
+ import { useCallback, useEffect, useState } from "react";
5
5
  import { z } from "zod";
6
6
  import { Button, Checkbox, FormField, FormGroup, Row, useToast } from "../../../ui";
7
7
  import { Form } from "../../../ui/components/Form";
@@ -64,7 +64,43 @@ export function AddressForm({ initialData, onSubmit, onCancel, isLoading = false
64
64
  country: initialData?.country || defaultCountry,
65
65
  isDefault: initialData?.isDefault || false,
66
66
  });
67
- const { shellCtx, validate } = useFormShellState(addressFormSchema);
67
+ const { shellCtx, validate, setFieldError, clearErrors, markSubmitAttempted } = useFormShellState(addressFormSchema);
68
+ /*
69
+ * ONE submit path, shared by the inline Save button and the pinned mobile
70
+ * bar. Two copies would be two chances for the bar to skip a check the
71
+ * button performs — and the bar is the one a phone user actually presses,
72
+ * since the inline row sits below the fold on a form this long.
73
+ */
74
+ const submitAddress = useCallback(async (h) => {
75
+ const setErr = h?.setFieldError ?? setFieldError;
76
+ const clear = h?.clearErrors ?? clearErrors;
77
+ markSubmitAttempted();
78
+ clear();
79
+ const errs = {};
80
+ if (!formData.fullName.trim())
81
+ errs["fullName"] = "Full name is required";
82
+ if (!formData.phone.trim())
83
+ errs["phone"] = "Phone number is required";
84
+ if (!formData.addressLine1.trim())
85
+ errs["addressLine1"] = "Address is required";
86
+ if (!formData.city.trim())
87
+ errs["city"] = "City is required";
88
+ if (!formData.state.trim())
89
+ errs["state"] = "State is required";
90
+ if (!formData.postalCode.trim())
91
+ errs["postalCode"] = "Postal code is required";
92
+ if (Object.keys(errs).length > 0) {
93
+ Object.entries(errs).forEach(([k, v]) => setErr(k, v));
94
+ return;
95
+ }
96
+ try {
97
+ await onSubmit(formData);
98
+ }
99
+ catch (err) {
100
+ void normalizeError(err);
101
+ showToast(err instanceof Error ? err.message : "Failed to save address", "error");
102
+ }
103
+ }, [formData, onSubmit, showToast, setFieldError, clearErrors, markSubmitAttempted]);
68
104
  useEffect(() => {
69
105
  validate(formData);
70
106
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -72,31 +108,11 @@ export function AddressForm({ initialData, onSubmit, onCancel, isLoading = false
72
108
  const handleChange = (field, value) => {
73
109
  setFormData((prev) => ({ ...prev, [field]: value }));
74
110
  };
75
- return (_jsx(Form, { onSubmit: (e) => e.preventDefault(), spacing: "md", shellCtx: shellCtx, children: ({ setFieldError, clearErrors }) => (_jsxs(_Fragment, { children: [_jsx(FormField, { label: mergedLabels.label, name: "label", type: "text", value: formData.label, onChange: (value) => handleChange("label", value), placeholder: mergedPlaceholders.label, required: true }), _jsx(FormField, { label: mergedLabels.fullName, name: "fullName", type: "text", value: formData.fullName, onChange: (value) => handleChange("fullName", value), placeholder: mergedPlaceholders.fullName, required: true }), _jsx(FormField, { label: mergedLabels.phone, name: "phone", type: "tel", value: formData.phone, onChange: (value) => handleChange("phone", value), placeholder: mergedPlaceholders.phone, required: true }), _jsx(FormField, { label: mergedLabels.addressLine1, name: "addressLine1", type: "text", value: formData.addressLine1, onChange: (value) => handleChange("addressLine1", value), placeholder: mergedPlaceholders.addressLine1, required: true }), _jsx(FormField, { label: mergedLabels.addressLine2, name: "addressLine2", type: "text", value: formData.addressLine2, onChange: (value) => handleChange("addressLine2", value), placeholder: mergedPlaceholders.addressLine2 }), _jsxs(FormGroup, { columns: 3, children: [_jsx(FormField, { label: mergedLabels.city, name: "city", type: "text", value: formData.city, onChange: (value) => handleChange("city", value), placeholder: mergedPlaceholders.city, required: true }), _jsx(FormField, { label: mergedLabels.state, name: "state", type: "text", value: formData.state, onChange: (value) => handleChange("state", value), placeholder: mergedPlaceholders.state, required: true }), _jsx(FormField, { label: mergedLabels.postalCode, name: "postalCode", type: "text", value: formData.postalCode, onChange: (value) => handleChange("postalCode", value), placeholder: mergedPlaceholders.postalCode, required: true })] }), _jsx(FormField, { label: mergedLabels.country, name: "country", type: "text", value: formData.country, onChange: (value) => handleChange("country", value), placeholder: mergedPlaceholders.country, required: true }), _jsx(Checkbox, { checked: formData.isDefault, onChange: (e) => handleChange("isDefault", e.target.checked), label: mergedLabels.setDefault }), _jsx(FormErrorSummary, {}), _jsxs(Row, { padding: "t-xs", align: "center", justify: "start", gap: "xs", children: [_jsx(Button, { type: "button", variant: "outline", onClick: onCancel, disabled: isLoading, children: mergedLabels.cancel }), _jsx(Button, { type: "submit", variant: "primary", disabled: isLoading, isLoading: isLoading, onClick: async () => {
76
- clearErrors();
77
- const errs = {};
78
- if (!formData.fullName.trim())
79
- errs["fullName"] = "Full name is required";
80
- if (!formData.phone.trim())
81
- errs["phone"] = "Phone number is required";
82
- if (!formData.addressLine1.trim())
83
- errs["addressLine1"] = "Address is required";
84
- if (!formData.city.trim())
85
- errs["city"] = "City is required";
86
- if (!formData.state.trim())
87
- errs["state"] = "State is required";
88
- if (!formData.postalCode.trim())
89
- errs["postalCode"] = "Postal code is required";
90
- if (Object.keys(errs).length > 0) {
91
- Object.entries(errs).forEach(([k, v]) => setFieldError(k, v));
92
- return;
93
- }
94
- try {
95
- await onSubmit(formData);
96
- }
97
- catch (err) {
98
- void normalizeError(err);
99
- showToast(err instanceof Error ? err.message : "Failed to save address", "error");
100
- }
101
- }, children: isLoading ? mergedLabels.loading : effectiveSubmitLabel })] })] })) }));
111
+ return (_jsx(Form, { onSubmit: (e) => e.preventDefault(), spacing: "md", shellCtx: shellCtx, bottomBar: {
112
+ onSubmit: submitAddress,
113
+ onCancel,
114
+ submitLabel: effectiveSubmitLabel,
115
+ cancelLabel: mergedLabels.cancel,
116
+ isLoading,
117
+ }, children: ({ setFieldError, clearErrors }) => (_jsxs(_Fragment, { children: [_jsx(FormField, { label: mergedLabels.label, name: "label", type: "text", value: formData.label, onChange: (value) => handleChange("label", value), placeholder: mergedPlaceholders.label, required: true }), _jsx(FormField, { label: mergedLabels.fullName, name: "fullName", type: "text", value: formData.fullName, onChange: (value) => handleChange("fullName", value), placeholder: mergedPlaceholders.fullName, required: true }), _jsx(FormField, { label: mergedLabels.phone, name: "phone", type: "tel", value: formData.phone, onChange: (value) => handleChange("phone", value), placeholder: mergedPlaceholders.phone, required: true }), _jsx(FormField, { label: mergedLabels.addressLine1, name: "addressLine1", type: "text", value: formData.addressLine1, onChange: (value) => handleChange("addressLine1", value), placeholder: mergedPlaceholders.addressLine1, required: true }), _jsx(FormField, { label: mergedLabels.addressLine2, name: "addressLine2", type: "text", value: formData.addressLine2, onChange: (value) => handleChange("addressLine2", value), placeholder: mergedPlaceholders.addressLine2 }), _jsxs(FormGroup, { columns: 3, children: [_jsx(FormField, { label: mergedLabels.city, name: "city", type: "text", value: formData.city, onChange: (value) => handleChange("city", value), placeholder: mergedPlaceholders.city, required: true }), _jsx(FormField, { label: mergedLabels.state, name: "state", type: "text", value: formData.state, onChange: (value) => handleChange("state", value), placeholder: mergedPlaceholders.state, required: true }), _jsx(FormField, { label: mergedLabels.postalCode, name: "postalCode", type: "text", value: formData.postalCode, onChange: (value) => handleChange("postalCode", value), placeholder: mergedPlaceholders.postalCode, required: true })] }), _jsx(FormField, { label: mergedLabels.country, name: "country", type: "text", value: formData.country, onChange: (value) => handleChange("country", value), placeholder: mergedPlaceholders.country, required: true }), _jsx(Checkbox, { checked: formData.isDefault, onChange: (e) => handleChange("isDefault", e.target.checked), label: mergedLabels.setDefault }), _jsx(FormErrorSummary, {}), _jsxs(Row, { padding: "t-xs", align: "center", justify: "start", gap: "xs", children: [_jsx(Button, { type: "button", variant: "outline", onClick: onCancel, disabled: isLoading, children: mergedLabels.cancel }), _jsx(Button, { type: "submit", variant: "primary", disabled: isLoading, isLoading: isLoading, onClick: () => void submitAddress({ setFieldError, clearErrors }), children: isLoading ? mergedLabels.loading : effectiveSubmitLabel })] })] })) }));
102
118
  }
@@ -72,8 +72,11 @@ export async function adminUpdatePayout(adminId, id, input) {
72
72
  throw new NotFoundError("Payout not found");
73
73
  }
74
74
  const updateData = {
75
- ...(input.status !== undefined ? { status: input.status } : {}),
76
75
  ...(input.adminNote !== undefined ? { adminNote: input.adminNote } : {}),
76
+ // The payment reference. Absent from this pick until 2026-08-26, so every
77
+ // payout marked paid was stored WITHOUT the UTR the modal required — see
78
+ // PAYOUT_ADMIN_UPDATEABLE_FIELDS for the full trace.
79
+ ...(input.transactionId !== undefined ? { transactionId: input.transactionId } : {}),
77
80
  ...(input.processedAt
78
81
  ? {
79
82
  processedAt: input.processedAt instanceof Date
@@ -82,12 +85,19 @@ export async function adminUpdatePayout(adminId, id, input) {
82
85
  }
83
86
  : {}),
84
87
  };
85
- const updated = await payoutRepository.update(id, updateData);
88
+ /*
89
+ * Through `updateStatus`, not a bare `update`, so the change lands on the
90
+ * payout's own timeline — `existing` is threaded in as `prior`, so that
91
+ * costs no second read of a document this function already holds.
92
+ */
93
+ const updated = input.status !== undefined
94
+ ? await payoutRepository.updateStatus(id, input.status, updateData, { actor: { role: "admin", uid: adminId }, trigger: "adminUpdatePayout" }, existing)
95
+ : await payoutRepository.update(id, updateData);
86
96
  serverLogger.info("adminUpdatePayout", {
87
97
  adminId,
88
98
  payoutId: id,
89
99
  });
90
- if (updateData.status === "paid") {
100
+ if (input.status === "paid") {
91
101
  void recordAdminAction({
92
102
  actorUid: adminId,
93
103
  action: AdminAuditActionValues.PAYOUT_MARK_PAID,
@@ -14,6 +14,7 @@ import { buildBidDetailFields, bidStatusBadge } from "../../auctions/components/
14
14
  import { toRecordArray, toRelativeDate, toCurrency, toStringValue, } from "../hooks/useAdminListingData";
15
15
  import { DataListingView } from "./DataListingView";
16
16
  import { apiClient } from "../../../http";
17
+ import { RecordStatusTimeline } from "../../status-history/components/RecordStatusTimeline";
17
18
  export function AdminBidsView({ children, ...props }) {
18
19
  const queryClient = useQueryClient();
19
20
  const { showToast } = useToast();
@@ -98,7 +99,12 @@ export function AdminBidsView({ children, ...props }) {
98
99
  const detailBadge = detail ? bidStatusBadge(detail.status) : null;
99
100
  return (_jsxs(_Fragment, { children: [_jsx(DataListingView, { config: config }), _jsx(RecordDetailModal, { isOpen: detail !== null, onClose: () => setDetail(null), title: toStringValue(detail?.detail?.productTitle, "Bid"), badges: detailBadge ? [{ label: detailBadge.label }] : undefined, fields: detail
100
101
  ? buildBidDetailFields(detail.detail, "admin")
101
- : undefined }), _jsx(ConfirmDeleteModal, { isOpen: cancelOpen, onClose: () => {
102
+ : undefined, extra:
103
+ // A bid moves through more states than any record a buyer owns, and
104
+ // every one of them happened inside a settlement batch with nothing
105
+ // recording it until W18. Absent history renders the empty label,
106
+ // never a step invented from `updatedAt`.
107
+ _jsx(RecordStatusTimeline, { entries: detail?.detail?.statusHistory, truncatedCount: detail?.detail?.statusHistoryTruncated }) }), _jsx(ConfirmDeleteModal, { isOpen: cancelOpen, onClose: () => {
102
108
  setCancelOpen(false);
103
109
  setSelectedRow(null);
104
110
  }, onConfirm: () => {
@@ -142,6 +142,9 @@ export function AdminBundleEditorView({ bundleId, onSaved, onDeleted, scope = "a
142
142
  };
143
143
  }, [bundleId, endpoints]);
144
144
  const handleSave = useCallback(async () => {
145
+ // Save is a type="button" onClick — <Form>'s native submit never fires,
146
+ // so the error summary has to be unhidden explicitly.
147
+ formHelpersRef.current?.markSubmitAttempted();
145
148
  clearErrors();
146
149
  setApiError(null);
147
150
  const price = parsePriceRupees(form.priceRupees);
@@ -1,4 +1,5 @@
1
1
  import React from "react";
2
+ import type { StatusChangeEntry } from "../../../_internal/shared/history/types";
2
3
  import { type ScammerStatus } from "../../scams/schemas/firestore";
3
4
  export interface AdminScammerEditorViewProps {
4
5
  open: boolean;
@@ -11,7 +12,10 @@ export interface AdminScammerEditorViewProps {
11
12
  upiIds?: string[];
12
13
  currentStatus?: ScammerStatus;
13
14
  verificationNote?: string;
15
+ /** The profile's own decision history. Absent on profiles predating W18. */
16
+ statusHistory?: StatusChangeEntry[];
17
+ statusHistoryTruncated?: number;
14
18
  reportedBy?: string;
15
19
  reportedByAnon?: boolean;
16
20
  }
17
- export declare function AdminScammerEditorView({ open, onClose, scammerId, displayNames, scamType, description, phones, upiIds, currentStatus, verificationNote, reportedBy, reportedByAnon, }: AdminScammerEditorViewProps): React.JSX.Element;
21
+ export declare function AdminScammerEditorView({ open, onClose, scammerId, displayNames, scamType, description, phones, upiIds, currentStatus, verificationNote, statusHistory, statusHistoryTruncated, reportedBy, reportedByAnon, }: AdminScammerEditorViewProps): React.JSX.Element;
@@ -6,6 +6,7 @@ import { useQueryClient } from "@tanstack/react-query";
6
6
  import { Button, Code, Div, FormActions, HorizontalRule, Label, Row, Select, SideDrawer, Span, Stack, Text, Textarea, useToast } from "../../../ui";
7
7
  import { apiClient } from "../../../http";
8
8
  import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
9
+ import { RecordStatusTimeline } from "../../status-history/components/RecordStatusTimeline";
9
10
  import { ScammerStatusValues, } from "../../scams/schemas/firestore";
10
11
  import { SCAMMER_STATUS_BADGE, SCAMMER_STATUS_LABEL, SCAMMER_STATUS_OPTIONS, } from "../../scams/constants/scammer-status-display";
11
12
  const __P = {
@@ -13,7 +14,7 @@ const __P = {
13
14
  p4: "p-[var(--appkit-space-4)]",
14
15
  };
15
16
  const CLS_SECTION_LABEL = "text-[length:var(--appkit-text-xs)] font-medium text-[var(--appkit-color-text-muted)] uppercase tracking-wide";
16
- export function AdminScammerEditorView({ open, onClose, scammerId, displayNames = [], scamType, description, phones = [], upiIds = [], currentStatus, verificationNote, reportedBy, reportedByAnon, }) {
17
+ export function AdminScammerEditorView({ open, onClose, scammerId, displayNames = [], scamType, description, phones = [], upiIds = [], currentStatus, verificationNote, statusHistory, statusHistoryTruncated, reportedBy, reportedByAnon, }) {
17
18
  const queryClient = useQueryClient();
18
19
  const { showToast } = useToast();
19
20
  const [status, setStatus] = React.useState(currentStatus ?? "pending_review");
@@ -53,5 +54,5 @@ export function AdminScammerEditorView({ open, onClose, scammerId, displayNames
53
54
  showToast(err?.message ?? "Failed to delete profile.", "error");
54
55
  },
55
56
  });
56
- return (_jsx(SideDrawer, { isOpen: open, onClose: onClose, title: displayNames.length > 0 ? displayNames[0] : "Scammer Profile", children: _jsxs(Stack, { className: `${__P.p4}`, gap: "md", children: [_jsxs(Row, { align: "center", gap: "sm", children: [_jsx(Span, { size: "xs", weight: "medium", className: `inline-flex ${SCAMMER_STATUS_BADGE[currentStatus ?? ScammerStatusValues.PENDING_REVIEW]}`, rounded: "full", padding: "pill-sm-tall", children: SCAMMER_STATUS_LABEL[currentStatus ?? ScammerStatusValues.PENDING_REVIEW] }), scamType && (_jsx(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: scamType.replace(/_/g, " ") }))] }), displayNames.length > 0 && (_jsxs(Div, { children: [_jsx(Text, { className: "mb-1 tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Names / Aliases" }), _jsx(Row, { wrap: true, gap: "xs", children: displayNames.map((name, i) => (_jsx(Span, { size: "xs", rounded: "full", surface: "subtle", padding: "pill-sm", children: name }, i))) })] })), (phones.length > 0 || upiIds.length > 0) && (_jsxs(Div, { className: `${__P.p3}`, rounded: "lg", surface: "muted", border: "default", children: [phones.length > 0 && (_jsxs(Div, { className: "mb-2", children: [_jsx(Text, { className: CLS_SECTION_LABEL, children: "Phone numbers" }), _jsx(Row, { wrap: true, gap: "xs", children: phones.map((p, i) => (_jsx(Code, { size: "xs", rounded: "default", padding: "sm", surface: "subtle", children: p }, i))) })] })), upiIds.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { className: CLS_SECTION_LABEL, children: "UPI IDs" }), _jsx(Row, { wrap: true, gap: "xs", children: upiIds.map((u, i) => (_jsx(Code, { size: "xs", rounded: "default", padding: "sm", surface: "subtle", children: u }, i))) })] }))] })), description && (_jsxs(Div, { className: `${__P.p3}`, rounded: "lg", surface: "muted", border: "default", children: [_jsx(Text, { className: CLS_SECTION_LABEL, children: "Description" }), _jsx(Text, { className: "whitespace-pre-wrap", color: "primary", size: "sm", children: description })] })), _jsxs(Div, { textSize: "xs", color: "muted", children: ["Reported by:", " ", _jsx(Span, { weight: "medium", color: "muted", children: reportedByAnon ? "Anonymous" : (reportedBy ?? "Unknown") })] }), _jsx(HorizontalRule, { tone: "subtle", spacing: "comfortable" }), _jsx(Select, { label: "Status", options: SCAMMER_STATUS_OPTIONS, value: status, onValueChange: setStatus }), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { className: "uppercase tracking-wide", color: "muted", size: "xs", weight: "semibold", children: "Verification note (internal)" }), _jsx(Textarea, { value: note, onChange: (e) => setNote(e.target.value), rows: 3, placeholder: "e.g. Verified via 3 independent reports\u2026" })] }), _jsxs(FormActions, { align: "right", children: [_jsx(Button, { type: "button", variant: "danger", size: "sm", disabled: !scammerId, isLoading: deleteMutation.isPending, onClick: () => deleteMutation.mutate(), children: "Delete" }), _jsx(Button, { type: "button", variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "button", isLoading: updateMutation.isPending, disabled: !scammerId || updateMutation.isPending, onClick: () => updateMutation.mutate(), children: "Save changes" })] })] }) }));
57
+ return (_jsx(SideDrawer, { isOpen: open, onClose: onClose, title: displayNames.length > 0 ? displayNames[0] : "Scammer Profile", children: _jsxs(Stack, { className: `${__P.p4}`, gap: "md", children: [_jsx(RecordStatusTimeline, { title: "Decision history", entries: statusHistory, truncatedCount: statusHistoryTruncated }), _jsxs(Row, { align: "center", gap: "sm", children: [_jsx(Span, { size: "xs", weight: "medium", className: `inline-flex ${SCAMMER_STATUS_BADGE[currentStatus ?? ScammerStatusValues.PENDING_REVIEW]}`, rounded: "full", padding: "pill-sm-tall", children: SCAMMER_STATUS_LABEL[currentStatus ?? ScammerStatusValues.PENDING_REVIEW] }), scamType && (_jsx(Span, { size: "xs", rounded: "full", padding: "pill-sm-tall", surface: "subtle", color: "muted", children: scamType.replace(/_/g, " ") }))] }), displayNames.length > 0 && (_jsxs(Div, { children: [_jsx(Text, { className: "mb-1 tracking-wide", color: "muted", size: "xs", weight: "semibold", transform: "uppercase", children: "Names / Aliases" }), _jsx(Row, { wrap: true, gap: "xs", children: displayNames.map((name, i) => (_jsx(Span, { size: "xs", rounded: "full", surface: "subtle", padding: "pill-sm", children: name }, i))) })] })), (phones.length > 0 || upiIds.length > 0) && (_jsxs(Div, { className: `${__P.p3}`, rounded: "lg", surface: "muted", border: "default", children: [phones.length > 0 && (_jsxs(Div, { className: "mb-2", children: [_jsx(Text, { className: CLS_SECTION_LABEL, children: "Phone numbers" }), _jsx(Row, { wrap: true, gap: "xs", children: phones.map((p, i) => (_jsx(Code, { size: "xs", rounded: "default", padding: "sm", surface: "subtle", children: p }, i))) })] })), upiIds.length > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { className: CLS_SECTION_LABEL, children: "UPI IDs" }), _jsx(Row, { wrap: true, gap: "xs", children: upiIds.map((u, i) => (_jsx(Code, { size: "xs", rounded: "default", padding: "sm", surface: "subtle", children: u }, i))) })] }))] })), description && (_jsxs(Div, { className: `${__P.p3}`, rounded: "lg", surface: "muted", border: "default", children: [_jsx(Text, { className: CLS_SECTION_LABEL, children: "Description" }), _jsx(Text, { className: "whitespace-pre-wrap", color: "primary", size: "sm", children: description })] })), _jsxs(Div, { textSize: "xs", color: "muted", children: ["Reported by:", " ", _jsx(Span, { weight: "medium", color: "muted", children: reportedByAnon ? "Anonymous" : (reportedBy ?? "Unknown") })] }), _jsx(HorizontalRule, { tone: "subtle", spacing: "comfortable" }), _jsx(Select, { label: "Status", options: SCAMMER_STATUS_OPTIONS, value: status, onValueChange: setStatus }), _jsxs(Stack, { gap: "xs", children: [_jsx(Label, { className: "uppercase tracking-wide", color: "muted", size: "xs", weight: "semibold", children: "Verification note (internal)" }), _jsx(Textarea, { value: note, onChange: (e) => setNote(e.target.value), rows: 3, placeholder: "e.g. Verified via 3 independent reports\u2026" })] }), _jsxs(FormActions, { align: "right", children: [_jsx(Button, { type: "button", variant: "danger", size: "sm", disabled: !scammerId, isLoading: deleteMutation.isPending, onClick: () => deleteMutation.mutate(), children: "Delete" }), _jsx(Button, { type: "button", variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "button", isLoading: updateMutation.isPending, disabled: !scammerId || updateMutation.isPending, onClick: () => updateMutation.mutate(), children: "Save changes" })] })] }) }));
57
58
  }
@@ -1,4 +1,5 @@
1
1
  import React from "react";
2
+ import type { StatusChangeEntry } from "../../../_internal/shared/history/types";
2
3
  interface TicketMessageClient {
3
4
  id?: string;
4
5
  authorId?: string;
@@ -28,6 +29,9 @@ export interface AdminSupportTicketDetailViewProps {
28
29
  orderId?: string;
29
30
  /** ST-6 — current relatedParties snapshot from the ticket document. */
30
31
  relatedParties?: RelatedPartiesClient;
32
+ /** The ticket's `statusHistory`. Absent on tickets predating W18. */
33
+ statusHistory?: StatusChangeEntry[];
34
+ statusHistoryTruncated?: number;
31
35
  }
32
- export declare function AdminSupportTicketDetailView({ open, onClose, ticketId, subject, userDisplayName, category, currentStatus, currentPriority, description, messages, internalNotes, orderId, relatedParties, }: AdminSupportTicketDetailViewProps): React.JSX.Element;
36
+ export declare function AdminSupportTicketDetailView({ open, onClose, ticketId, subject, userDisplayName, category, currentStatus, currentPriority, description, messages, internalNotes, orderId, relatedParties, statusHistory, statusHistoryTruncated, }: AdminSupportTicketDetailViewProps): React.JSX.Element;
33
37
  export {};