@mohasinac/appkit 4.60.1 → 4.62.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 (202) hide show
  1. package/dist/.build-stamp.json +3 -3
  2. package/dist/_internal/client/features/lottery/LotteryAdminEditView.js +19 -3
  3. package/dist/_internal/client/features/maintenance/views/AnalysisRunnerView.js +1 -1
  4. package/dist/_internal/client/features/maintenance/views/CloudLogsListView.js +1 -1
  5. package/dist/_internal/server/features/auctions/actions.d.ts +1 -1
  6. package/dist/_internal/server/features/auctions/actions.js +12 -1
  7. package/dist/_internal/server/features/blog/actions.d.ts +1 -1
  8. package/dist/_internal/server/features/blog/actions.js +12 -1
  9. package/dist/_internal/server/features/brands/actions.d.ts +1 -1
  10. package/dist/_internal/server/features/brands/actions.js +12 -1
  11. package/dist/_internal/server/features/cart/actions.d.ts +1 -1
  12. package/dist/_internal/server/features/cart/actions.js +12 -1
  13. package/dist/_internal/server/features/checkout/actions.js +0 -1
  14. package/dist/_internal/server/features/events/actions.d.ts +1 -1
  15. package/dist/_internal/server/features/events/actions.js +12 -1
  16. package/dist/_internal/server/features/gateway-events/redact.js +14 -2
  17. package/dist/_internal/server/features/history/actions.d.ts +1 -1
  18. package/dist/_internal/server/features/history/actions.js +12 -1
  19. package/dist/_internal/server/features/lottery/actions.d.ts +1 -1
  20. package/dist/_internal/server/features/lottery/actions.js +12 -1
  21. package/dist/_internal/server/features/orders/actions.d.ts +1 -1
  22. package/dist/_internal/server/features/orders/actions.js +12 -1
  23. package/dist/_internal/server/features/orders/cancellation-refund.js +0 -1
  24. package/dist/_internal/server/features/orders/service.js +31 -1
  25. package/dist/_internal/server/features/payouts/actions.d.ts +1 -1
  26. package/dist/_internal/server/features/payouts/actions.js +12 -1
  27. package/dist/_internal/server/features/points/award.js +9 -1
  28. package/dist/_internal/server/features/pre-orders/actions.d.ts +1 -1
  29. package/dist/_internal/server/features/pre-orders/actions.js +12 -1
  30. package/dist/_internal/server/features/prize-draws/actions.d.ts +1 -1
  31. package/dist/_internal/server/features/prize-draws/actions.js +12 -1
  32. package/dist/_internal/server/features/products/actions.d.ts +1 -1
  33. package/dist/_internal/server/features/products/actions.js +12 -1
  34. package/dist/_internal/server/features/promotions/actions.d.ts +1 -1
  35. package/dist/_internal/server/features/promotions/actions.js +12 -1
  36. package/dist/_internal/server/features/protection-claims/actions.d.ts +2 -2
  37. package/dist/_internal/server/features/protection-claims/actions.js +21 -12
  38. package/dist/_internal/server/features/raffle/actions.d.ts +1 -1
  39. package/dist/_internal/server/features/raffle/actions.js +12 -1
  40. package/dist/_internal/server/features/refunds/actions.d.ts +1 -3
  41. package/dist/_internal/server/features/refunds/actions.js +14 -2
  42. package/dist/_internal/server/features/reviews/actions.d.ts +1 -1
  43. package/dist/_internal/server/features/reviews/actions.js +12 -1
  44. package/dist/_internal/server/features/wishlist/actions.d.ts +1 -1
  45. package/dist/_internal/server/features/wishlist/actions.js +12 -1
  46. package/dist/_internal/server/jobs/core/obligationSweep.js +0 -1
  47. package/dist/_internal/server/jobs/core/paymentReconcile.js +93 -76
  48. package/dist/_internal/server/jobs/core/testDataCleanup.d.ts +1 -1
  49. package/dist/_internal/server/jobs/core/testDataCleanup.js +22 -1
  50. package/dist/_internal/server/jobs/handlers/onShipmentAllocationSync.js +16 -4
  51. package/dist/_internal/shared/actions/action-registry.js +149 -9
  52. package/dist/_internal/shared/features/promotions/schema.d.ts +66 -0
  53. package/dist/_internal/shared/features/promotions/schema.js +7 -0
  54. package/dist/_internal/shared/media/limits.d.ts +10 -0
  55. package/dist/_internal/shared/media/limits.js +10 -0
  56. package/dist/_internal/shared/obligations/deadlines.js +11 -1
  57. package/dist/_internal/shared/tester/window.d.ts +25 -0
  58. package/dist/_internal/shared/tester/window.js +66 -0
  59. package/dist/_internal/shared/types/action-result.js +15 -1
  60. package/dist/client/api/surface-error.js +16 -2
  61. package/dist/client/api/useApiMutation.js +17 -1
  62. package/dist/client.d.ts +1 -1
  63. package/dist/client.js +1 -1
  64. package/dist/constants/api-endpoints.d.ts +3 -0
  65. package/dist/constants/api-endpoints.js +1 -0
  66. package/dist/constants/field-names.d.ts +17 -0
  67. package/dist/constants/field-names.js +23 -0
  68. package/dist/core/server-action.js +19 -3
  69. package/dist/core/unit-of-work.js +15 -1
  70. package/dist/errors/error-mapping.js +26 -1
  71. package/dist/errors/firestore-codes.d.ts +37 -0
  72. package/dist/errors/firestore-codes.js +52 -0
  73. package/dist/errors/index.d.ts +0 -1
  74. package/dist/errors/index.js +12 -2
  75. package/dist/features/account/components/UserOffersPanel.js +5 -3
  76. package/dist/features/admin/components/AdminActionIndexView.js +100 -5
  77. package/dist/features/admin/components/AdminAddressClustersView.js +21 -2
  78. package/dist/features/admin/components/AdminBlogEditorView.js +8 -8
  79. package/dist/features/admin/components/AdminCarouselEditorView.js +1 -1
  80. package/dist/features/admin/components/AdminCarouselView.js +96 -4
  81. package/dist/features/admin/components/AdminCouponEditorView.js +8 -0
  82. package/dist/features/admin/components/AdminFulfillmentView.js +3 -2
  83. package/dist/features/admin/components/AdminGatewayEventsView.js +2 -2
  84. package/dist/features/admin/components/AdminMediaView.js +36 -3
  85. package/dist/features/admin/components/AdminNavigationView.js +4 -1
  86. package/dist/features/admin/components/AdminOffersView.js +2 -1
  87. package/dist/features/admin/components/AdminPaymentClustersView.js +19 -2
  88. package/dist/features/admin/components/AdminProductEditorView.js +2 -2
  89. package/dist/features/admin/components/AdminSectionsView.d.ts +10 -0
  90. package/dist/features/admin/components/AdminSectionsView.js +486 -224
  91. package/dist/features/admin/components/DataListingView.js +2 -0
  92. package/dist/features/admin/components/ReferenceRecordEditor.js +1 -1
  93. package/dist/features/admin/components/analytics/AdminStatCard.js +4 -1
  94. package/dist/features/admin/hooks/useOptimisticRowFlag.d.ts +33 -0
  95. package/dist/features/admin/hooks/useOptimisticRowFlag.js +87 -0
  96. package/dist/features/admin/repository/notification.repository.d.ts +15 -0
  97. package/dist/features/admin/repository/notification.repository.js +32 -0
  98. package/dist/features/admin/repository/site-settings.repository.d.ts +2 -1
  99. package/dist/features/admin/repository/site-settings.repository.js +7 -0
  100. package/dist/features/admin/schemas/action-index-form.d.ts +34 -0
  101. package/dist/features/admin/schemas/action-index-form.js +115 -0
  102. package/dist/features/auctions/components/MarketplaceAuctionCard.js +1 -1
  103. package/dist/features/auth/components/RegisterForm.js +2 -1
  104. package/dist/features/catalogue/actions/approve-catalogue-listing.d.ts +1 -1
  105. package/dist/features/catalogue/actions/approve-catalogue-listing.js +12 -1
  106. package/dist/features/catalogue/actions/list-from-catalogue.d.ts +1 -1
  107. package/dist/features/catalogue/actions/list-from-catalogue.js +12 -1
  108. package/dist/features/catalogue/actions/submit-for-approval.d.ts +1 -1
  109. package/dist/features/catalogue/actions/submit-for-approval.js +12 -1
  110. package/dist/features/catalogue/components/CatalogueItemEditorView.js +3 -3
  111. package/dist/features/categories/repository/price-observation.repository.d.ts +19 -0
  112. package/dist/features/categories/repository/price-observation.repository.js +21 -2
  113. package/dist/features/events/actions/event-actions.js +4 -1
  114. package/dist/features/events/components/AdminEventEditorView.js +79 -50
  115. package/dist/features/events/components/EventsListPageView.js +2 -1
  116. package/dist/features/events/schemas/firestore.d.ts +17 -0
  117. package/dist/features/events/schemas/firestore.js +1 -0
  118. package/dist/features/events/utils/sort-pinned.d.ts +33 -0
  119. package/dist/features/events/utils/sort-pinned.js +46 -0
  120. package/dist/features/gateway-events/repository/gateway-events.repository.js +3 -40
  121. package/dist/features/gateway-events/schemas/index.d.ts +2 -2
  122. package/dist/features/inventory/components/AdminStorageBoxesView.js +55 -39
  123. package/dist/features/inventory/components/ReceiveStationView.js +61 -17
  124. package/dist/features/inventory/repository/storage-boxes.repository.d.ts +3 -1
  125. package/dist/features/inventory/repository/storage-boxes.repository.js +36 -3
  126. package/dist/features/layout/hooks/useFormBottomActions.js +20 -1
  127. package/dist/features/obligations/repository/obligations.repository.js +10 -1
  128. package/dist/features/orders/components/EmiInstallmentsPanel.js +25 -7
  129. package/dist/features/orders/components/RefundRequestView.js +4 -4
  130. package/dist/features/orders/schemas/firestore.d.ts +19 -0
  131. package/dist/features/orders/schemas/firestore.js +19 -0
  132. package/dist/features/orders/schemas/index.d.ts +6 -6
  133. package/dist/features/payment-cases/repository/payment-cases.repository.d.ts +1 -0
  134. package/dist/features/payment-cases/repository/payment-cases.repository.js +3 -14
  135. package/dist/features/pre-orders/components/MarketplacePreorderCard.js +1 -1
  136. package/dist/features/products/components/GroupMemberPicker.js +4 -1
  137. package/dist/features/products/components/MarketplaceBundleCard.js +1 -1
  138. package/dist/features/products/components/MarketplacePrizeDrawCard.js +1 -1
  139. package/dist/features/products/components/PrizeDrawEntryActions.js +2 -1
  140. package/dist/features/products/components/ProductDetailActions.js +2 -1
  141. package/dist/features/products/components/ProductForm.js +2 -2
  142. package/dist/features/products/components/ProductGrid.js +1 -1
  143. package/dist/features/promotions/repository/claimed-coupons.repository.js +31 -1
  144. package/dist/features/promotions/schemas/firestore.d.ts +22 -0
  145. package/dist/features/promotions/schemas/firestore.js +22 -0
  146. package/dist/features/promotions/schemas/index.d.ts +479 -6
  147. package/dist/features/promotions/schemas/index.js +153 -0
  148. package/dist/features/protection-claims/repository/protection-claims.repository.js +22 -15
  149. package/dist/features/seller/components/FulfillmentView.js +3 -2
  150. package/dist/features/seller/components/SellerAddressesView.js +32 -4
  151. package/dist/features/seller/components/SellerAnalyticsAlertsView.js +62 -3
  152. package/dist/features/seller/components/SellerBundlesView.js +53 -14
  153. package/dist/features/seller/components/SellerGroupedListingsView.js +23 -3
  154. package/dist/features/seller/components/SellerListingTypeView.js +56 -10
  155. package/dist/features/seller/components/SellerPayoutMethodsView.js +33 -1
  156. package/dist/features/seller/components/SellerPayoutSettingsView.js +48 -9
  157. package/dist/features/seller/components/SellerProductsView.js +5 -1
  158. package/dist/features/seller/components/SellerShippingConfigsView.js +33 -1
  159. package/dist/features/seller/components/SellerShippingView.js +2 -1
  160. package/dist/features/seller/components/SellerStoreCategoriesView.js +33 -1
  161. package/dist/features/seller/components/SellerSupportView.js +69 -2
  162. package/dist/features/seller/schemas/coupon-form.d.ts +5 -0
  163. package/dist/features/seller/schemas/coupon-form.js +18 -0
  164. package/dist/features/shell/SectionForm.js +35 -9
  165. package/dist/features/shell/build-sections.js +14 -1
  166. package/dist/features/shipments/components/AdminShipmentLotItemsView.js +54 -1
  167. package/dist/features/shipments/components/AdminShipmentProjectionsView.js +3 -2
  168. package/dist/features/sticker-trace/components/StickerCommitPanel.js +2 -1
  169. package/dist/features/sticker-trace/components/StickerPrintPanel.js +2 -1
  170. package/dist/features/sticker-trace/components/StickerRetryPanel.js +24 -4
  171. package/dist/features/sticker-trace/components/StickerTraceStudio.js +2 -1
  172. package/dist/features/sticker-trace/server.d.ts +2 -0
  173. package/dist/features/tester/components/AdminTesterFeedbackIssuesView.js +3 -2
  174. package/dist/http/api-handler.js +4 -1
  175. package/dist/index.d.ts +8 -4
  176. package/dist/index.js +6 -4
  177. package/dist/next/routing/route-map.d.ts +4 -2
  178. package/dist/next/routing/route-map.js +19 -1
  179. package/dist/schemas/registry.d.ts +15 -10
  180. package/dist/schemas/sieve.d.ts +3 -3
  181. package/dist/server.d.ts +1 -2
  182. package/dist/server.js +6 -4
  183. package/dist/styles.css +51 -0
  184. package/dist/ui/components/Checkbox.js +10 -2
  185. package/dist/ui/components/DateInput.js +8 -1
  186. package/dist/ui/components/Input.js +22 -2
  187. package/dist/ui/components/PaginatedSelect.d.ts +8 -19
  188. package/dist/ui/components/PaginatedSelect.js +0 -0
  189. package/dist/ui/components/Radio.js +8 -1
  190. package/dist/ui/components/Select.js +10 -2
  191. package/dist/ui/components/TagInput.d.ts +8 -1
  192. package/dist/ui/components/TagInput.js +4 -3
  193. package/dist/ui/components/Textarea.js +10 -2
  194. package/dist/ui/forms/FieldCheckbox.js +1 -1
  195. package/dist/ui/forms/FormErrorList.d.ts +0 -16
  196. package/dist/ui/forms/FormErrorList.js +34 -1
  197. package/dist/ui/forms/apply-zod-issues.d.ts +19 -1
  198. package/dist/ui/forms/apply-zod-issues.js +23 -1
  199. package/package.json +1 -1
  200. package/scripts/seed-cli.mjs +1822 -1812
  201. package/dist/features/tester/utils/phases.d.ts +0 -48
  202. package/dist/features/tester/utils/phases.js +0 -139
@@ -18,9 +18,32 @@ export const discountConfigSchema = z.object({
18
18
  maxDiscount: rupeesSchema.optional(),
19
19
  minPurchase: rupeesSchema.optional(),
20
20
  });
21
+ /*
22
+ * 🛑 THIS IS THE ONLY bxgy VALIDATOR. The four admin write paths — the POST
23
+ * and PATCH routes, both server actions, and `createCouponSchema` below — all
24
+ * import it rather than restating the shape.
25
+ *
26
+ * That is not tidiness. Until 2026-10-22 none of those four declared `bxgy` at
27
+ * all, and every one of them is a `z.object()`, which STRIPS unknown keys. So
28
+ * `AdminCouponEditorView` built a correct `bxgy` payload, the route answered
29
+ * 200, and the field was never written — an admin could create a BOGO coupon
30
+ * that discounted nothing, which is the exact silent-strip trap the `tiers`
31
+ * comment in the POST route already describes, recurring one field over.
32
+ *
33
+ * `maxItemPrice` is deliberately `.optional()` with no default: absent means
34
+ * "no ceiling", which is what every BOGO coupon written before it existed
35
+ * means. A default would retroactively shrink them.
36
+ */
21
37
  export const bxgyConfigSchema = z.object({
22
38
  buyQuantity: z.number().int().positive(),
23
39
  getQuantity: z.number().int().positive(),
40
+ // `.refine` rather than `.positive()` — `rupeesSchema` is a ZodEffects (it
41
+ // carries the 2-decimal check), and ZodNumber's numeric helpers are not on
42
+ // it. A zero ceiling would disqualify every unit, so it is rejected here as
43
+ // well as being treated as absent by `calculateBxgyDiscount`.
44
+ maxItemPrice: rupeesSchema
45
+ .refine((n) => n > 0, { message: "Price ceiling must be greater than zero" })
46
+ .optional(),
24
47
  applicableProducts: z.array(z.string()).optional(),
25
48
  applicableCategories: z.array(z.string()).optional(),
26
49
  });
@@ -70,6 +93,136 @@ export const couponFirestoreSchema = z.object({
70
93
  stats: couponStatsSchema,
71
94
  ...auditTimestampsShape,
72
95
  });
96
+ /**
97
+ * The admin create-coupon REQUEST BODY.
98
+ *
99
+ * 🛑 It lives here, in appkit, because `bxgyConfigSchema` does — and a zod
100
+ * schema CANNOT cross the version boundary. The consumer root is on zod 4 and
101
+ * appkit depends on zod 3, so `/api/admin/coupons` composing
102
+ * `bxgy: bxgyConfigSchema.optional()` into a consumer `z.object({...})` did
103
+ * not merely infer `unknown`: zod 4 **throws** on it —
104
+ *
105
+ * Invalid element at key "bx": expected a Zod schema
106
+ *
107
+ * — so every POST to that route failed. The type error was the only signal,
108
+ * and it reads as a cosmetic inference problem.
109
+ *
110
+ * Composed from the pieces above rather than restated, which is the concern
111
+ * the route's own comment raised about `bxgy`: "four write paths validate this
112
+ * shape and a fifth copy is how they drift". Re-declaring the shape in
113
+ * consumer zod would have been that fifth copy; moving the whole body schema
114
+ * to where the pieces live is the version of that fix which also works.
115
+ */
116
+ export const couponCreateBodySchema = z
117
+ .object({
118
+ code: z.string().min(2).max(50),
119
+ name: z.string().min(1),
120
+ description: z.string().default(""),
121
+ type: couponTypeEnumSchema,
122
+ tiers: z.array(tieredDiscountSchema).max(6).optional(),
123
+ bxgy: bxgyConfigSchema.optional(),
124
+ discount: z.object({
125
+ value: z.number().min(0),
126
+ maxDiscount: z.number().optional(),
127
+ minPurchase: z.number().optional(),
128
+ }),
129
+ usage: z.object({
130
+ totalLimit: z.number().optional(),
131
+ perUserLimit: z.number().optional(),
132
+ currentUsage: z.number().default(0),
133
+ }),
134
+ validity: z.object({
135
+ startDate: z.string().transform((v) => new Date(v)),
136
+ endDate: z
137
+ .string()
138
+ .optional()
139
+ .transform((v) => (v ? new Date(v) : undefined)),
140
+ isActive: z.boolean().default(false),
141
+ }),
142
+ restrictions: z
143
+ .object({
144
+ applicableProducts: z.array(z.string()).optional(),
145
+ applicableCategories: z.array(z.string()).optional(),
146
+ applicableSellers: z.array(z.string()).optional(),
147
+ excludeProducts: z.array(z.string()).optional(),
148
+ excludeCategories: z.array(z.string()).optional(),
149
+ firstTimeUserOnly: z.boolean().default(false),
150
+ })
151
+ .default({ firstTimeUserOnly: false }),
152
+ createdBy: z.string().optional().default("admin"),
153
+ })
154
+ .superRefine((data, ctx) => {
155
+ if (data.type === "percentage" && data.discount.value > 100) {
156
+ ctx.addIssue({
157
+ code: z.ZodIssueCode.custom,
158
+ message: "Percentage discount cannot exceed 100%",
159
+ path: ["discount", "value"],
160
+ });
161
+ }
162
+ });
163
+ /**
164
+ * The admin update-coupon REQUEST BODY (`PATCH /api/admin/coupons/[id]`).
165
+ *
166
+ * 🛑 HERE FOR THE SAME REASON AS `couponCreateBodySchema` — read its note
167
+ * first. It embeds `bxgyConfigSchema`, and a zod-3 schema composed into a
168
+ * consumer zod-4 `z.object({...})` THROWS at parse time rather than merely
169
+ * mistyping. The POST route was migrated when that was discovered; this is
170
+ * the sibling PATCH, which has the same embed and would fail the same way.
171
+ *
172
+ * 🛑 NOT `couponCreateBodySchema.partial()`. That schema carries a
173
+ * `.superRefine`, so it is a ZodEffects and has no `.partial()` — and the
174
+ * update body is genuinely a different shape anyway: no `code` (a coupon's
175
+ * code is immutable after creation), plus the `action` discriminator the
176
+ * route uses for activate/deactivate.
177
+ *
178
+ * Every nested object stays OPTIONAL as a whole, which is what lets the route
179
+ * merge `validity` and `restrictions` against the stored document instead of
180
+ * replacing them — a caller sending only `{isActive: false}` must not wipe
181
+ * the coupon's real dates.
182
+ */
183
+ export const couponUpdateBodySchema = z.object({
184
+ name: z.string().min(1).optional(),
185
+ description: z.string().optional(),
186
+ tiers: z.array(tieredDiscountSchema).max(6).optional(),
187
+ bxgy: bxgyConfigSchema.optional(),
188
+ discount: z
189
+ .object({
190
+ value: z.number().min(0),
191
+ maxDiscount: z.number().optional(),
192
+ minPurchase: z.number().optional(),
193
+ })
194
+ .optional(),
195
+ usage: z
196
+ .object({
197
+ totalLimit: z.number().optional(),
198
+ perUserLimit: z.number().optional(),
199
+ })
200
+ .optional(),
201
+ validity: z
202
+ .object({
203
+ startDate: z
204
+ .string()
205
+ .transform((v) => new Date(v))
206
+ .optional(),
207
+ endDate: z
208
+ .string()
209
+ .optional()
210
+ .transform((v) => (v ? new Date(v) : undefined)),
211
+ isActive: z.boolean().optional(),
212
+ })
213
+ .optional(),
214
+ restrictions: z
215
+ .object({
216
+ applicableProducts: z.array(z.string()).optional(),
217
+ applicableCategories: z.array(z.string()).optional(),
218
+ applicableSellers: z.array(z.string()).optional(),
219
+ excludeProducts: z.array(z.string()).optional(),
220
+ excludeCategories: z.array(z.string()).optional(),
221
+ firstTimeUserOnly: z.boolean().optional(),
222
+ })
223
+ .optional(),
224
+ action: z.enum(["activate", "deactivate"]).optional(),
225
+ });
73
226
  export const couponUsageFirestoreSchema = z.object({
74
227
  id: z.string(),
75
228
  userId: z.string(),
@@ -1,20 +1,11 @@
1
+ // One shared predicate — this file had its own copy; see firestore-codes.ts.
2
+ import { isAlreadyExistsError } from "../../../errors/firestore-codes.js";
1
3
  import { BaseRepository, prepareForFirestore } from "../../../providers/db-firebase/index.js";
2
4
  import { normalizeError } from "../../../errors/normalize.js";
3
5
  import { buildHistoryEntry } from "../../../_internal/shared/history/history.js";
4
6
  import { ConflictError } from "../../../errors/index.js";
5
7
  import { PROTECTION_CLAIMS_COLLECTION, PROTECTION_CLAIM_ACTIVE_STATUSES, PROTECTION_CLAIM_FIELDS, createProtectionClaimId, } from "../schemas/firestore.js";
6
- const GRPC_ALREADY_EXISTS = 6;
7
- function isAlreadyExists(err) {
8
- if (typeof err !== "object" || err === null)
9
- return false;
10
- const code = err.code;
11
- if (code === GRPC_ALREADY_EXISTS)
12
- return true;
13
- if (code === "already-exists" || code === "ALREADY_EXISTS")
14
- return true;
15
- const message = err.message;
16
- return typeof message === "string" && message.includes("ALREADY_EXISTS");
17
- }
8
+ import { serverLogger } from "../../../monitoring/index.js";
18
9
  export class ProtectionClaimsRepository extends BaseRepository {
19
10
  constructor() {
20
11
  super(PROTECTION_CLAIMS_COLLECTION);
@@ -64,7 +55,7 @@ export class ProtectionClaimsRepository extends BaseRepository {
64
55
  return doc;
65
56
  }
66
57
  catch (err) {
67
- if (isAlreadyExists(err)) {
58
+ if (isAlreadyExistsError(err)) {
68
59
  void normalizeError(err);
69
60
  throw new ConflictError(`A ${input.kind} protection claim is already open on this order. Add to that claim rather than filing a second one.`);
70
61
  }
@@ -118,7 +109,15 @@ export class ProtectionClaimsRepository extends BaseRepository {
118
109
  await this.getCollection().doc(id).update({ refundId, updatedAt: now });
119
110
  }
120
111
  catch (err) {
121
- void normalizeError(err);
112
+ /*
113
+ * Best effort, but RECORDED. `void normalizeError(err)` alone types the
114
+ * error and reports it nowhere — as silent as `catch {}` while looking
115
+ * handled, which is what audit-silent-degrade calls normalize theatre.
116
+ * Swallowing is still correct here (the claim row is already durable; this is a best-effort annotation); losing the reason is not.
117
+ */
118
+ serverLogger.warn("protectionClaims.write failed", {
119
+ error: normalizeError(err).message,
120
+ });
122
121
  }
123
122
  }
124
123
  async setStatus(id, status, reason, now = new Date()) {
@@ -142,7 +141,15 @@ export class ProtectionClaimsRepository extends BaseRepository {
142
141
  });
143
142
  }
144
143
  catch (err) {
145
- void normalizeError(err);
144
+ /*
145
+ * Best effort, but RECORDED. `void normalizeError(err)` alone types the
146
+ * error and reports it nowhere — as silent as `catch {}` while looking
147
+ * handled, which is what audit-silent-degrade calls normalize theatre.
148
+ * Swallowing is still correct here (the claim row is already durable; this is a best-effort annotation); losing the reason is not.
149
+ */
150
+ serverLogger.warn("protectionClaims.write failed", {
151
+ error: normalizeError(err).message,
152
+ });
146
153
  }
147
154
  }
148
155
  async findByOrderAndKind(orderId, kind) {
@@ -9,7 +9,8 @@ import { apiClient } from "../../../http/index.js";
9
9
  import { SELLER_ENDPOINTS } from "../../../constants/api-endpoints.js";
10
10
  import { BarcodeField } from "./BarcodeField.js";
11
11
  import { normalizeError } from "../../../errors/normalize.js";
12
- import { useToast } from "../../../ui/index.js";
12
+ import { useToast, TextLink } from "../../../ui/index.js";
13
+ import { ROUTES } from "../../../next/routing/route-map.js";
13
14
  function statusBadgeVariant(status) {
14
15
  if (status === "confirmed")
15
16
  return "warning";
@@ -118,5 +119,5 @@ export function FulfillmentView({ currentUserId, }) {
118
119
  setScanError(null);
119
120
  }, children: "Cancel" })] })] })), !scanResult && !scanError && (_jsx(Text, { color: "muted", size: "sm", children: orders.length === 0 && !isLoadingOrders
120
121
  ? "No orders waiting — great work!"
121
- : `${orders.length} order${orders.length === 1 ? "" : "s"} in queue.` }))] })), mode === "list" && (_jsxs(Stack, { gap: "sm", children: [_jsxs(Row, { align: "center", gap: "sm", children: [_jsx(Button, { variant: filterMine ? "primary" : "secondary", size: "sm", onClick: () => setFilterMine((v) => !v), children: filterMine ? "Showing My Orders" : "My Orders" }), _jsx(Text, { size: "xs", color: "muted", children: isLoadingOrders ? "Loading…" : `${visibleOrders.length} order${visibleOrders.length === 1 ? "" : "s"}` })] }), visibleOrders.length === 0 && !isLoadingOrders && (_jsx(Text, { color: "muted", children: "No orders waiting \u2014 great work!" })), visibleOrders.map((order) => (_jsxs(Stack, { surface: "card", padding: "sm", rounded: "md", gap: "xs", children: [_jsxs(Row, { align: "center", gap: "xs", wrap: true, children: [_jsxs(Text, { size: "sm", weight: "medium", children: ["#", order.id] }), _jsx(Badge, { variant: statusBadgeVariant(order.status), children: order.status }), order.pickedAt && (_jsx(Badge, { variant: "success", children: "Picked" })), order.packedAt && (_jsx(Badge, { variant: "success", children: "Packed" }))] }), _jsxs(Text, { size: "xs", color: "muted", children: [order.userName, order.items && order.items.length > 0 && (_jsxs(_Fragment, { children: [" \u00B7 ", order.items.map((i) => i.productTitle).join(", ")] }))] }), order.assignedWorkerId && (_jsxs(Text, { size: "xs", color: "muted", children: ["Assigned: ", order.assignedWorkerId === currentUserId ? "Me" : order.assignedWorkerId] })), _jsxs(Row, { gap: "xs", children: [!order.pickedAt && (_jsx(Button, { size: "sm", variant: "secondary", onClick: () => handleMarkPicked(order.id), isLoading: actionLoadingId === order.id + ":picked", children: "Mark Picked" })), order.pickedAt && !order.packedAt && (_jsx(Button, { size: "sm", variant: "secondary", onClick: () => handleMarkPacked(order.id), isLoading: actionLoadingId === order.id + ":packed", children: "Mark Packed" }))] })] }, order.id)))] }))] }));
122
+ : `${orders.length} order${orders.length === 1 ? "" : "s"} in queue.` }))] })), mode === "list" && (_jsxs(Stack, { gap: "sm", children: [_jsxs(Row, { align: "center", gap: "sm", children: [_jsx(Button, { variant: filterMine ? "primary" : "secondary", size: "sm", onClick: () => setFilterMine((v) => !v), children: filterMine ? "Showing My Orders" : "My Orders" }), _jsx(Text, { size: "xs", color: "muted", children: isLoadingOrders ? "Loading…" : `${visibleOrders.length} order${visibleOrders.length === 1 ? "" : "s"}` })] }), visibleOrders.length === 0 && !isLoadingOrders && (_jsx(Text, { color: "muted", children: "No orders waiting \u2014 great work!" })), visibleOrders.map((order) => (_jsxs(Stack, { surface: "card", padding: "sm", rounded: "md", gap: "xs", children: [_jsxs(Row, { align: "center", gap: "xs", wrap: true, children: [_jsxs(TextLink, { href: String(ROUTES.STORE.ORDER_DETAIL(order.id)), size: "sm", weight: "medium", children: ["#", order.id] }), _jsx(Badge, { variant: statusBadgeVariant(order.status), children: order.status }), order.pickedAt && (_jsx(Badge, { variant: "success", children: "Picked" })), order.packedAt && (_jsx(Badge, { variant: "success", children: "Packed" }))] }), _jsxs(Text, { size: "xs", color: "muted", children: [order.userName, order.items && order.items.length > 0 && (_jsxs(_Fragment, { children: [" \u00B7 ", order.items.map((i) => i.productTitle).join(", ")] }))] }), order.assignedWorkerId && (_jsxs(Text, { size: "xs", color: "muted", children: ["Assigned: ", order.assignedWorkerId === currentUserId ? "Me" : order.assignedWorkerId] })), _jsxs(Row, { gap: "xs", children: [!order.pickedAt && (_jsx(Button, { size: "sm", variant: "secondary", onClick: () => handleMarkPicked(order.id), isLoading: actionLoadingId === order.id + ":picked", children: "Mark Picked" })), order.pickedAt && !order.packedAt && (_jsx(Button, { size: "sm", variant: "secondary", onClick: () => handleMarkPacked(order.id), isLoading: actionLoadingId === order.id + ":packed", children: "Mark Packed" }))] })] }, order.id)))] }))] }));
122
123
  }
@@ -11,6 +11,7 @@ import { useEntityDelete } from "../../../react/hooks/useEntityDelete.js";
11
11
  import { normalizeError } from "../../../errors/normalize.js";
12
12
  import { TextLink } from "../../../ui/index.js";
13
13
  import { ROUTES } from "../../../constants/index.js";
14
+ import { toUserMessage } from "../../../errors/error-display-map.js";
14
15
  const __O = {
15
16
  xAuto: "overflow-x-auto",
16
17
  };
@@ -47,9 +48,9 @@ function fromDoc(doc) {
47
48
  isDefault: doc.isDefault,
48
49
  };
49
50
  }
50
- function AddressCard({ address, onEdit, onDelete, }) {
51
+ function AddressCard({ address, onEdit, onDelete, onSetDefault, }) {
51
52
  const isBanned = address.banStatus === "banned";
52
- return (_jsxs(Stack, { surface: "card", padding: "sm", gap: "sm", children: [_jsxs(Row, { align: "start", justify: "between", gap: "xs", children: [_jsxs(Row, { gap: "xs", wrap: true, className: "min-w-0", children: [_jsx(MapPin, { className: "h-4 w-4 shrink-0 text-[var(--appkit-color-primary)]" }), _jsx(Span, { size: "sm", weight: "semibold", className: "truncate", color: "primary", children: address.label }), address.isDefault && (_jsxs(Span, { size: "xs", weight: "medium", className: CLS_DEFAULT_PILL, children: [_jsx(Star, { className: "h-3 w-3" }), "Default"] })), isBanned && _jsx(Badge, { variant: "danger", size: "sm", children: "Banned" })] }), _jsx(Row, { gap: "px", className: "shrink-0", children: isBanned ? (_jsx(Span, { size: "xs", color: "muted", title: "Contact support to resolve address ban", children: "Contact support" })) : (_jsxs(_Fragment, { children: [_jsx(IconButton, { type: "button", onClick: onEdit, title: "Edit address", "aria-label": "Edit address", variant: "ghost", rounded: "lg", className: "text-[var(--appkit-color-text-muted)] hover:bg-[var(--appkit-color-surface-elevated)] hover:text-[var(--appkit-color-text-muted)]", children: _jsx(Pencil, { className: "h-4 w-4" }) }), _jsx(IconButton, { type: "button", onClick: onDelete, title: "Delete address", "aria-label": "Delete address", variant: "ghost", rounded: "lg", className: CLS_DELETE_BTN, children: _jsx(Trash2, { className: "h-4 w-4" }) })] })) })] }), _jsxs(Text, { size: "sm", color: "muted", children: [address.fullName, " \u00B7 ", address.phone] }), _jsxs(Text, { className: "leading-relaxed", color: "muted", size: "xs", children: [address.addressLine1, address.addressLine2 ? `, ${address.addressLine2}` : "", address.landmark ? ` (near ${address.landmark})` : "", _jsx("br", {}), address.city, ", ", address.state, " ", address.postalCode, ", ", address.country] })] }));
53
+ return (_jsxs(Stack, { surface: "card", padding: "sm", gap: "sm", children: [_jsxs(Row, { align: "start", justify: "between", gap: "xs", children: [_jsxs(Row, { gap: "xs", wrap: true, className: "min-w-0", children: [_jsx(MapPin, { className: "h-4 w-4 shrink-0 text-[var(--appkit-color-primary)]" }), _jsx(Span, { size: "sm", weight: "semibold", className: "truncate", color: "primary", children: address.label }), address.isDefault && (_jsxs(Span, { size: "xs", weight: "medium", className: CLS_DEFAULT_PILL, children: [_jsx(Star, { className: "h-3 w-3" }), "Default"] })), isBanned && _jsx(Badge, { variant: "danger", size: "sm", children: "Banned" })] }), _jsx(Row, { gap: "px", className: "shrink-0", children: isBanned ? (_jsx(Span, { size: "xs", color: "muted", title: "Contact support to resolve address ban", children: "Contact support" })) : (_jsxs(_Fragment, { children: [!address.isDefault && (_jsx(IconButton, { type: "button", onClick: onSetDefault, title: "Set as default pickup address", "aria-label": `Set ${address.label} as the default pickup address`, variant: "ghost", rounded: "lg", className: "text-[var(--appkit-color-text-muted)] hover:bg-[var(--appkit-color-surface-elevated)] hover:text-[var(--appkit-color-text-muted)]", children: _jsx(Star, { className: "h-4 w-4" }) })), _jsx(IconButton, { type: "button", onClick: onEdit, title: "Edit address", "aria-label": "Edit address", variant: "ghost", rounded: "lg", className: "text-[var(--appkit-color-text-muted)] hover:bg-[var(--appkit-color-surface-elevated)] hover:text-[var(--appkit-color-text-muted)]", children: _jsx(Pencil, { className: "h-4 w-4" }) }), _jsx(IconButton, { type: "button", onClick: onDelete, title: "Delete address", "aria-label": "Delete address", variant: "ghost", rounded: "lg", className: CLS_DELETE_BTN, children: _jsx(Trash2, { className: "h-4 w-4" }) })] })) })] }), _jsxs(Text, { size: "sm", color: "muted", children: [address.fullName, " \u00B7 ", address.phone] }), _jsxs(Text, { className: "leading-relaxed", color: "muted", size: "xs", children: [address.addressLine1, address.addressLine2 ? `, ${address.addressLine2}` : "", address.landmark ? ` (near ${address.landmark})` : "", _jsx("br", {}), address.city, ", ", address.state, " ", address.postalCode, ", ", address.country] })] }));
53
54
  }
54
55
  // ---------------------------------------------------------------------------
55
56
  // Main Component
@@ -129,7 +130,7 @@ export function SellerAddressesView({ apiBase = SELLER_ENDPOINTS.STORE_ADDRESSES
129
130
  }
130
131
  catch (err) {
131
132
  void normalizeError(err);
132
- setSaveError(err instanceof Error ? err.message : "Failed to save address");
133
+ setSaveError(toUserMessage(err?.code, undefined, { fallback: "Failed to save address" }));
133
134
  }
134
135
  finally {
135
136
  setSaving(false);
@@ -141,9 +142,36 @@ export function SellerAddressesView({ apiBase = SELLER_ENDPOINTS.STORE_ADDRESSES
141
142
  onSuccess: () => { load(); },
142
143
  });
143
144
  const handleDelete = (addr) => setDeleteTargetAddr(addr);
145
+ /**
146
+ * Promote one pickup address to the default.
147
+ *
148
+ * A partial PUT rather than a dedicated endpoint: unlike buyer addresses,
149
+ * which have `/api/user/addresses/[id]/set-default`, the store side has no
150
+ * such route — but `addressUpdateSchema` is the form schema `.partial()`,
151
+ * so `{ isDefault: true }` on its own is a valid body.
152
+ */
153
+ const handleSetDefault = async (addr) => {
154
+ try {
155
+ // Same request shape as `handleSave` below, rather than a second
156
+ // hand-rolled fetch beside it.
157
+ const method = "PUT";
158
+ const res = await fetch(`${apiBase}/${addr.id}`, {
159
+ method,
160
+ headers: { "Content-Type": "application/json" },
161
+ body: JSON.stringify({ isDefault: true }),
162
+ });
163
+ if (!res.ok)
164
+ throw new Error("Failed to set default address");
165
+ await load();
166
+ }
167
+ catch (err) {
168
+ const normalized = normalizeError(err);
169
+ setSaveError(toUserMessage(normalized.code, undefined, { fallback: "Failed to set default address" }));
170
+ }
171
+ };
144
172
  const set = (key, value) => setDraft((p) => ({ ...p, [key]: value }));
145
173
  const setField = (key) => (value) => set(key, value);
146
- return (_jsxs(Div, { className: "min-h-screen", children: [_jsx(StickyToolbar, { offset: "header", tone: "default", border: true, padding: "md", children: _jsxs(Row, { justify: "between", children: [_jsxs(Stack, { gap: "none", children: [_jsx(Heading, { level: 2, size: "base", weight: "semibold", color: "primary", children: "Pickup Addresses" }), _jsx(Text, { size: "xs", color: "muted", className: "mt-0.5", children: "Manage your store's pickup and return locations" })] }), _jsxs(Row, { gap: "sm", align: "center", children: [_jsx(TextLink, { href: String(ROUTES.STORE.ADDRESSES_NEW), children: "Open full page \u2192" }), _jsxs(Button, { gap: "sm", size: "sm", onClick: openAdd, children: [_jsx(Plus, { className: "h-4 w-4" }), _jsx(Span, { children: "Add Address" })] })] })] }) }), _jsxs(Div, { paddingX: "x-md-lg", className: "max-w-2xl", padding: "y-lg", children: [errorMessage && (_jsx(Div, { textSize: "sm", className: "mb-4 border border-error/20", color: "error", surface: "danger-surface", padding: "inline", rounded: "xl", children: errorMessage })), isLoading ? (_jsx(Row, { justify: "center", padding: "y-4xl", children: _jsx(Div, { className: "h-6 w-6 animate-spin border-2 border-[var(--appkit-color-primary)] border-t-transparent", rounded: "full" }) })) : addresses.length === 0 ? (_jsxs(Stack, { border: "default", className: "border-2 border-dashed", padding: "y-4xl", align: "center", gap: "3", rounded: "xl", children: [_jsx(MapPin, { className: "h-8 w-8 text-[var(--appkit-color-text-muted)]" }), _jsx(Text, { size: "sm", color: "muted", children: "No pickup addresses yet" }), _jsx(Button, { size: "sm", variant: "outline", onClick: openAdd, children: "Add your first address" })] })) : (_jsxs(_Fragment, { children: [_jsxs(Row, { justify: "end", className: "mb-2", children: [_jsx(Button, { size: "sm", variant: listView === "table" ? "primary" : "ghost", onClick: () => setListView("table"), children: "Table" }), _jsx(Button, { size: "sm", variant: listView === "cards" ? "primary" : "ghost", onClick: () => setListView("cards"), children: "Cards" })] }), listView === "cards" ? (_jsx(Grid, { gap: "sm", children: addresses.map((addr) => (_jsx(Div, { className: deletingId === addr.id ? "opacity-50 pointer-events-none" : "", children: _jsx(AddressCard, { address: addr, onEdit: () => openEdit(addr), onDelete: () => handleDelete(addr) }) }, addr.id))) })) : (_jsx(Div, { className: `${__O.xAuto}`, rounded: "lg", border: "default", children: _jsxs(Table, { size: "sm", children: [_jsx(Thead, { surface: "muted", children: _jsxs(Tr, { children: [_jsx(Th, { className: "text-left", padding: "sm", weight: "semibold", children: "Label" }), _jsx(Th, { className: "text-left", padding: "sm", weight: "semibold", children: "Name" }), _jsx(Th, { className: "text-left", padding: "sm", weight: "semibold", children: "City" }), _jsx(Th, { className: "text-left", padding: "sm", weight: "semibold", children: "Phone" }), _jsx(Th, { className: "text-right", padding: "sm", weight: "semibold", children: "Actions" })] }) }), _jsx(Tbody, { children: addresses.map((addr) => {
174
+ return (_jsxs(Div, { className: "min-h-screen", children: [_jsx(StickyToolbar, { offset: "header", tone: "default", border: true, padding: "md", children: _jsxs(Row, { justify: "between", children: [_jsxs(Stack, { gap: "none", children: [_jsx(Heading, { level: 2, size: "base", weight: "semibold", color: "primary", children: "Pickup Addresses" }), _jsx(Text, { size: "xs", color: "muted", className: "mt-0.5", children: "Manage your store's pickup and return locations" })] }), _jsxs(Row, { gap: "sm", align: "center", children: [_jsx(TextLink, { href: String(ROUTES.STORE.ADDRESSES_NEW), children: "Open full page \u2192" }), _jsxs(Button, { gap: "sm", size: "sm", onClick: openAdd, children: [_jsx(Plus, { className: "h-4 w-4" }), _jsx(Span, { children: "Add Address" })] })] })] }) }), _jsxs(Div, { paddingX: "x-md-lg", className: "max-w-2xl", padding: "y-lg", children: [errorMessage && (_jsx(Div, { textSize: "sm", className: "mb-4 border border-error/20", color: "error", surface: "danger-surface", padding: "inline", rounded: "xl", children: errorMessage })), isLoading ? (_jsx(Row, { justify: "center", padding: "y-4xl", children: _jsx(Div, { className: "h-6 w-6 animate-spin border-2 border-[var(--appkit-color-primary)] border-t-transparent", rounded: "full" }) })) : addresses.length === 0 ? (_jsxs(Stack, { border: "default", className: "border-2 border-dashed", padding: "y-4xl", align: "center", gap: "3", rounded: "xl", children: [_jsx(MapPin, { className: "h-8 w-8 text-[var(--appkit-color-text-muted)]" }), _jsx(Text, { size: "sm", color: "muted", children: "No pickup addresses yet" }), _jsx(Button, { size: "sm", variant: "outline", onClick: openAdd, children: "Add your first address" })] })) : (_jsxs(_Fragment, { children: [_jsxs(Row, { justify: "end", className: "mb-2", children: [_jsx(Button, { size: "sm", variant: listView === "table" ? "primary" : "ghost", onClick: () => setListView("table"), children: "Table" }), _jsx(Button, { size: "sm", variant: listView === "cards" ? "primary" : "ghost", onClick: () => setListView("cards"), children: "Cards" })] }), listView === "cards" ? (_jsx(Grid, { gap: "sm", children: addresses.map((addr) => (_jsx(Div, { className: deletingId === addr.id ? "opacity-50 pointer-events-none" : "", children: _jsx(AddressCard, { address: addr, onEdit: () => openEdit(addr), onDelete: () => handleDelete(addr), onSetDefault: () => void handleSetDefault(addr) }) }, addr.id))) })) : (_jsx(Div, { className: `${__O.xAuto}`, rounded: "lg", border: "default", children: _jsxs(Table, { size: "sm", children: [_jsx(Thead, { surface: "muted", children: _jsxs(Tr, { children: [_jsx(Th, { className: "text-left", padding: "sm", weight: "semibold", children: "Label" }), _jsx(Th, { className: "text-left", padding: "sm", weight: "semibold", children: "Name" }), _jsx(Th, { className: "text-left", padding: "sm", weight: "semibold", children: "City" }), _jsx(Th, { className: "text-left", padding: "sm", weight: "semibold", children: "Phone" }), _jsx(Th, { className: "text-right", padding: "sm", weight: "semibold", children: "Actions" })] }) }), _jsx(Tbody, { children: addresses.map((addr) => {
147
175
  const isBanned = addr.banStatus === "banned";
148
176
  return (_jsxs(Tr, { className: `border-t border-[var(--appkit-color-border)] ${deletingId === addr.id ? "opacity-50" : ""}`, children: [_jsx(Td, { padding: "sm", children: _jsxs(Row, { gap: "xs", align: "center", children: [_jsx(Span, { children: addr.label }), isBanned && _jsx(Badge, { variant: "danger", size: "sm", children: "Banned" })] }) }), _jsx(Td, { padding: "sm", children: addr.fullName }), _jsxs(Td, { padding: "sm", children: [addr.city, ", ", addr.state] }), _jsx(Td, { className: "tabular-nums", padding: "sm", children: addr.phone }), _jsx(Td, { className: "text-right", padding: "sm", children: isBanned ? (_jsx(Span, { size: "xs", color: "muted", title: "Contact support to resolve address ban", children: "Contact support" })) : (_jsxs(Row, { justify: "end", gap: "xs", children: [_jsx(Button, { size: "sm", variant: "ghost", onClick: () => openEdit(addr), children: ROW_ACTION_META[ROW_ACTION_ID.EDIT].label }), _jsx(Button, { size: "sm", variant: "ghost", onClick: () => handleDelete(addr), children: ROW_ACTION_META[ROW_ACTION_ID.DELETE].label })] })) })] }, addr.id));
149
177
  }) })] }) }))] }))] }), _jsx(SideDrawer, { isOpen: drawerOpen, onClose: closeDrawer, title: editingId ? "Edit Address" : "Add Address", footer:
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useApiMutation } from "@mohasinac/appkit/client";
4
4
  import React, { useState, useCallback } from "react";
5
+ import { z } from "zod";
5
6
  import { alertMetricsForScope } from "../../store-extensions/constants/alert-metrics.js";
6
7
  import { useQuery, useQueryClient } from "@tanstack/react-query";
7
8
  import { Alert, Badge, Button, Div, Heading, Row, Section, Stack, Text, Toggle, useToast } from "../../../ui/index.js";
@@ -14,6 +15,7 @@ import { applyZodIssues } from "../../../ui/forms/apply-zod-issues.js";
14
15
  import { buildSectionsFromSchema, visibleValues } from "../../shell/build-sections.js";
15
16
  import { SectionForm, useSectionFormNav } from "../../shell/SectionForm.js";
16
17
  import { ACTIONS } from "../../../_internal/shared/actions/action-registry.js";
18
+ import { QuickEditMenu } from "../../admin/components/QuickEditMenu.js";
17
19
  const __P = {
18
20
  p5: "p-[var(--appkit-space-5)]",
19
21
  };
@@ -80,11 +82,55 @@ const OPERATOR_BADGE = {
80
82
  // ---------------------------------------------------------------------------
81
83
  // Sub-components
82
84
  // ---------------------------------------------------------------------------
83
- function AlertCard({ alert, onToggle, onDelete, }) {
85
+ /**
86
+ * The three values a seller actually tunes once an alert is live.
87
+ *
88
+ * Deliberately not `metric` or `operator`: changing either turns the alert
89
+ * into a different alert while keeping its firing history, so
90
+ * `lastTriggeredAt`/`lastObservedValue` on the card would then describe a rule
91
+ * that no longer exists. Those stay a delete-and-recreate.
92
+ */
93
+ const alertQuickEditSchema = z.object({
94
+ label: z.string().trim().min(1, "A name is required."),
95
+ threshold: z.coerce.number().min(0, "Threshold cannot be negative."),
96
+ windowHours: z.coerce.number().int().min(1),
97
+ });
98
+ const ALERT_QUICK_FIELDS = [
99
+ { name: "label", label: "Name", type: "text", required: true },
100
+ {
101
+ name: "threshold",
102
+ label: "Threshold",
103
+ type: "number",
104
+ required: true,
105
+ helperText: "The value the metric is compared against.",
106
+ },
107
+ {
108
+ name: "windowHours",
109
+ label: "Window",
110
+ type: "select",
111
+ required: true,
112
+ options: WINDOW_OPTIONS,
113
+ },
114
+ ];
115
+ function AlertCard({ alert, onToggle, onDelete, onEdit, }) {
84
116
  const metricLabel = METRIC_OPTIONS.find((m) => m.value === alert.metric)?.label ?? alert.metric;
85
117
  const opLabel = OPERATOR_OPTIONS.find((o) => o.value === alert.operator)?.label ?? alert.operator;
86
118
  const windowLabel = WINDOW_OPTIONS.find((w) => w.value === String(alert.windowHours))?.label ?? `${alert.windowHours}h`;
87
- return (_jsxs(Row, { surface: "card", padding: "sm", align: "start", justify: "between", gap: "md", children: [_jsxs(Div, { className: "flex-1 min-w-0", children: [_jsxs(Row, { className: "mb-1", gap: "sm", wrap: true, children: [_jsx(Text, { size: "sm", weight: "medium", color: "primary", children: alert.label }), _jsx(Badge, { variant: alert.isActive ? "success" : "default", children: alert.isActive ? "Active" : "Paused" })] }), _jsxs(Text, { className: "mb-1", color: "muted", size: "xs", children: [metricLabel, " ", opLabel, " ", alert.threshold, " \u00B7 ", windowLabel] }), METRIC_DESCRIPTIONS[alert.metric] ? (_jsx(Text, { className: "mb-1", color: "muted", size: "xs", children: METRIC_DESCRIPTIONS[alert.metric] })) : null, alert.lastTriggeredAt ? (_jsxs(Text, { className: "mb-1", color: "muted", size: "xs", children: ["Last fired ", new Date(alert.lastTriggeredAt).toLocaleString(), alert.lastObservedValue != null ? ` at ${alert.lastObservedValue}` : ""] })) : null, _jsx(Row, { gap: "xs", wrap: true, children: alert.notifyChannels.map((ch) => (_jsx(Badge, { variant: OPERATOR_BADGE[alert.operator] ?? "default", children: ch }, ch))) }), alert.lastTriggeredAt && (_jsxs(Text, { className: "mt-1", color: "faint", size: "xs", children: ["Last triggered: ", new Date(alert.lastTriggeredAt).toLocaleString("en-IN")] }))] }), _jsxs(Row, { className: "shrink-0", align: "center", gap: "sm", children: [_jsx(Toggle, { checked: alert.isActive, onChange: (v) => onToggle(alert.id, v), size: "sm" }), _jsx(Button, { variant: "ghost", size: "sm", action: ACTIONS.STORE["delete-analytics-alert"], onClick: () => onDelete(alert.id), className: "text-error hover:text-error" })] })] }));
119
+ return (_jsxs(Row, { surface: "card", padding: "sm", align: "start", justify: "between", gap: "md", children: [_jsxs(Div, { className: "flex-1 min-w-0", children: [_jsxs(Row, { className: "mb-1", gap: "sm", wrap: true, children: [_jsx(Text, { size: "sm", weight: "medium", color: "primary", children: alert.label }), _jsx(Badge, { variant: alert.isActive ? "success" : "default", children: alert.isActive ? "Active" : "Paused" })] }), _jsxs(Text, { className: "mb-1", color: "muted", size: "xs", children: [metricLabel, " ", opLabel, " ", alert.threshold, " \u00B7 ", windowLabel] }), METRIC_DESCRIPTIONS[alert.metric] ? (_jsx(Text, { className: "mb-1", color: "muted", size: "xs", children: METRIC_DESCRIPTIONS[alert.metric] })) : null, alert.lastTriggeredAt ? (_jsxs(Text, { className: "mb-1", color: "muted", size: "xs", children: ["Last fired ", new Date(alert.lastTriggeredAt).toLocaleString(), alert.lastObservedValue != null ? ` at ${alert.lastObservedValue}` : ""] })) : null, _jsx(Row, { gap: "xs", wrap: true, children: alert.notifyChannels.map((ch) => (_jsx(Badge, { variant: OPERATOR_BADGE[alert.operator] ?? "default", children: ch }, ch))) }), alert.lastTriggeredAt && (_jsxs(Text, { className: "mt-1", color: "faint", size: "xs", children: ["Last triggered: ", new Date(alert.lastTriggeredAt).toLocaleString("en-IN")] }))] }), _jsxs(Row, { className: "shrink-0", align: "center", gap: "sm", children: [_jsx(Toggle, { checked: alert.isActive, onChange: (v) => onToggle(alert.id, v), size: "sm", ariaLabel: `${alert.isActive ? "Pause" : "Resume"} ${alert.label}` }), _jsx(QuickEditMenu, { actions: [
120
+ {
121
+ label: "Edit alert",
122
+ formTitle: `Edit "${alert.label}"`,
123
+ schema: alertQuickEditSchema,
124
+ fields: ALERT_QUICK_FIELDS,
125
+ defaultValues: {
126
+ label: alert.label,
127
+ threshold: alert.threshold,
128
+ windowHours: String(alert.windowHours),
129
+ },
130
+ onSubmit: (values) => onEdit(alert.id, values),
131
+ submitLabel: "Save alert",
132
+ },
133
+ ] }), _jsx(Button, { variant: "ghost", size: "sm", action: ACTIONS.STORE["delete-analytics-alert"], onClick: () => onDelete(alert.id), className: "text-error hover:text-error" })] })] }));
88
134
  }
89
135
  const EMPTY_DRAFT = {
90
136
  label: "",
@@ -142,6 +188,17 @@ export function SellerAnalyticsAlertsView({ labels = {}, }) {
142
188
  void queryClient.invalidateQueries({ queryKey: ["seller", "analytics-alerts"] });
143
189
  },
144
190
  });
191
+ const editMutation = useApiMutation({
192
+ errorMessage: "Failed to update alert",
193
+ mutationFn: async ({ id, values }) => {
194
+ const parsed = alertQuickEditSchema.parse(values);
195
+ return apiClient.patch(SELLER_ENDPOINTS.ANALYTICS_ALERT_BY_ID(id), parsed);
196
+ },
197
+ onSuccess: () => {
198
+ showToast("Alert updated", "success");
199
+ void queryClient.invalidateQueries({ queryKey: ["seller", "analytics-alerts"] });
200
+ },
201
+ });
145
202
  const toggleChannel = useCallback((ch) => {
146
203
  setDraft((d) => ({
147
204
  ...d,
@@ -192,5 +249,7 @@ export function SellerAnalyticsAlertsView({ labels = {}, }) {
192
249
  * page's own form — a viewport-pinned Save would claim the
193
250
  * bottom tier for a control the reader may have scrolled past.
194
251
  */
195
- bottomBar: false })] })] })), isLoading && (_jsx(Stack, { gap: "sm", children: [1, 2, 3].map((i) => (_jsx(Div, { className: "h-16 animate-pulse", surface: "subtle", rounded: "lg" }, i))) })), !isLoading && alerts.length === 0 && !showForm && (_jsx(Alert, { variant: "info", children: "No alerts configured. Create an alert to get notified when a key metric crosses a threshold." })), !isLoading && alerts.length > 0 && (_jsx(Stack, { gap: "3", children: alerts.map((alert) => (_jsx(AlertCard, { alert: alert, onToggle: (id, isActive) => toggleMutation.mutate({ id, isActive }), onDelete: (id) => deleteMutation.mutate(id) }, alert.id))) }))] }));
252
+ bottomBar: false })] })] })), isLoading && (_jsx(Stack, { gap: "sm", children: [1, 2, 3].map((i) => (_jsx(Div, { className: "h-16 animate-pulse", surface: "subtle", rounded: "lg" }, i))) })), !isLoading && alerts.length === 0 && !showForm && (_jsx(Alert, { variant: "info", children: "No alerts configured. Create an alert to get notified when a key metric crosses a threshold." })), !isLoading && alerts.length > 0 && (_jsx(Stack, { gap: "3", children: alerts.map((alert) => (_jsx(AlertCard, { alert: alert, onToggle: (id, isActive) => toggleMutation.mutate({ id, isActive }), onDelete: (id) => deleteMutation.mutate(id), onEdit: async (id, values) => {
253
+ await editMutation.mutateAsync({ id, values });
254
+ } }, alert.id))) }))] }));
196
255
  }
@@ -1,12 +1,14 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { SIEVE_OP, sieveFilter, sortBy } from "@mohasinac/appkit/client";
4
- import { Badge, Button, FilterChipGroup, Stack, Text, TextLink } from "../../../ui/index.js";
4
+ import { Badge, Button, FilterChipGroup, Stack, Text, TextLink, Toggle } from "../../../ui/index.js";
5
5
  import { SELLER_ENDPOINTS } from "../../../constants/api-endpoints.js";
6
6
  import { ROW_ACTION_META, ROW_ACTION_ID } from "../../products/constants/action-defs.js";
7
7
  import { SELLER_BULK_ACTIONS } from "../../products/constants/action-defs.js";
8
8
  import { BUNDLE_COPY, BUNDLE_STOCK_VARIANT, } from "../../../_internal/shared/features/categories/bundle-copy.js";
9
9
  import { toRecordArray, toRelativeDate, toStringValue, } from "../../admin/hooks/useAdminListingData.js";
10
+ import { useOptimisticRowFlag } from "../../admin/hooks/useOptimisticRowFlag.js";
11
+ import { apiClient } from "../../../http/index.js";
10
12
  import { DataListingView } from "../../admin/components/DataListingView.js";
11
13
  function formatPrice(value) {
12
14
  if (typeof value !== "number" || value <= 0)
@@ -38,6 +40,22 @@ const COLUMNS = [
38
40
  { key: "updatedAt", header: "Updated", className: "w-28" },
39
41
  ];
40
42
  export function SellerBundlesView({ editHrefTemplate, newHref }) {
43
+ /*
44
+ * 🛑 `isActive` was a READ-ONLY badge here, and the update schema was built
45
+ * to take it on its own — shipping-config's even says so in a comment: "a
46
+ * PATCH may legitimately carry only `isActive`". Nothing in the client had
47
+ * ever sent one, so flipping one boolean meant a full-editor round trip.
48
+ *
49
+ * The optimistic/rollback logic lives in the hook because eight listings
50
+ * grew this control at once; see its header for why rollback must DELETE
51
+ * the override key rather than write back a captured value.
52
+ */
53
+ const activeFlag = useOptimisticRowFlag({
54
+ mutate: (id, next) => apiClient.put(SELLER_ENDPOINTS.BUNDLE_BY_ID(id), { isActive: next }),
55
+ invalidate: [["seller", "bundles", "listing"], ["bundles"]],
56
+ successMessage: (next) => (next ? "Bundle activated." : "Bundle deactivated."),
57
+ errorMessage: "Could not change that bundle's status.",
58
+ });
41
59
  const config = {
42
60
  portal: "seller",
43
61
  title: "Bundles",
@@ -60,19 +78,29 @@ export function SellerBundlesView({ editHrefTemplate, newHref }) {
60
78
  { value: sortBy("createdAt", "ASC"), label: "Oldest" },
61
79
  ],
62
80
  columns: COLUMNS,
63
- mapRows: (response) => toRecordArray(response.items).map((item, index) => ({
64
- id: toStringValue(item.id, `bundle-${index}`),
65
- primary: toStringValue(item.name, "Untitled bundle"),
66
- secondary: toStringValue(item.slug, "no-slug"),
67
- price: formatPrice(item.bundlePrice),
68
- members: String(Array.isArray(item.bundleProductIds) ? item.bundleProductIds.length : 0),
69
- stockStatus: toStringValue(item.bundleStockStatus, "in_stock"),
70
- isActive: item.isActive === true,
71
- status: item.isActive === true
72
- ? BUNDLE_COPY.adminList.activeBadge
73
- : BUNDLE_COPY.adminList.inactiveBadge,
74
- updatedAt: toRelativeDate(item.updatedAt ?? item.createdAt),
75
- })),
81
+ mapRows: (response) => toRecordArray(response.items).map((item, index) => {
82
+ const id = toStringValue(item.id, `bundle-${index}`);
83
+ /*
84
+ * Resolved ONCE, through the pending override, and then used for both
85
+ * `isActive` and the `status` LABEL below. Deriving the label from the
86
+ * raw value while the flag read the override is how the badge ends up
87
+ * saying "Inactive" beside a switch that is on.
88
+ */
89
+ const isActive = activeFlag.resolve(id, item.isActive === true);
90
+ return {
91
+ id,
92
+ primary: toStringValue(item.name, "Untitled bundle"),
93
+ secondary: toStringValue(item.slug, "no-slug"),
94
+ price: formatPrice(item.bundlePrice),
95
+ members: String(Array.isArray(item.bundleProductIds) ? item.bundleProductIds.length : 0),
96
+ stockStatus: toStringValue(item.bundleStockStatus, "in_stock"),
97
+ isActive,
98
+ status: isActive
99
+ ? BUNDLE_COPY.adminList.activeBadge
100
+ : BUNDLE_COPY.adminList.inactiveBadge,
101
+ updatedAt: toRelativeDate(item.updatedAt ?? item.createdAt),
102
+ };
103
+ }),
76
104
  getTotal: (response, mappedRows) => typeof response.total === "number" ? response.total : mappedRows.length,
77
105
  buildFilters: (filterState) => {
78
106
  const parts = [];
@@ -83,6 +111,17 @@ export function SellerBundlesView({ editHrefTemplate, newHref }) {
83
111
  return parts.join(",") || undefined;
84
112
  },
85
113
  rowHrefTemplate: editHrefTemplate,
114
+ /*
115
+ * 🛑 This view had NO renderRowActions at all — the only per-row
116
+ * affordance was the row link to the editor, so a bundle could only be
117
+ * activated or deactivated by opening it.
118
+ *
119
+ * The toggle lives here rather than in a column because `rowHrefTemplate`
120
+ * makes the row an anchor: a normal column cell does not stop
121
+ * propagation, so a switch there would flip AND navigate. This slot is
122
+ * already click-stopped in all three renderers.
123
+ */
124
+ renderRowActions: (row) => (_jsx(Toggle, { size: "sm", checked: row.isActive, disabled: activeFlag.pendingId === row.id, ariaLabel: `${row.isActive ? "Deactivate" : "Activate"} ${row.primary}`, onChange: (next) => { void activeFlag.set(row.id, next); } })),
86
125
  toolbarExtra: (_jsx(Button, { asChild: true, size: "sm", variant: "primary", children: _jsx(TextLink, { href: newHref, layout: "flex", align: "center", gap: "xs", children: "+ New Bundle" }) })),
87
126
  // Rule #7: bulk-action array sourced from the SELLER_BULK_ACTIONS preset.
88
127
  buildBulkActions: (selection) => {
@@ -1,11 +1,13 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Row, sortBy } from "@mohasinac/appkit/client";
4
- import { Badge, Button, Span } from "../../../ui/index.js";
4
+ import { Badge, Button, Span, Toggle } from "../../../ui/index.js";
5
5
  import { SELLER_ENDPOINTS } from "../../../constants/api-endpoints.js";
6
6
  import { ACTIONS } from "../../../_internal/shared/actions/action-registry.js";
7
7
  import { buildBulkAction } from "../../../_internal/shared/actions/bulk-helpers.js";
8
8
  import { toRecordArray, toRelativeDate, toStringValue, } from "../../admin/hooks/useAdminListingData.js";
9
+ import { useOptimisticRowFlag } from "../../admin/hooks/useOptimisticRowFlag.js";
10
+ import { apiClient } from "../../../http/index.js";
9
11
  import { DataListingView } from "../../admin/components/DataListingView.js";
10
12
  const THEME_LABELS = {
11
13
  related: "Related",
@@ -51,6 +53,22 @@ const COLUMNS = [
51
53
  },
52
54
  ];
53
55
  export function SellerGroupedListingsView({ onCreateClick, onEditClick, onDeleteClick, }) {
56
+ /*
57
+ * 🛑 `isActive` was a READ-ONLY badge here, and the update schema was built
58
+ * to take it on its own — shipping-config's even says so in a comment: "a
59
+ * PATCH may legitimately carry only `isActive`". Nothing in the client had
60
+ * ever sent one, so flipping one boolean meant a full-editor round trip.
61
+ *
62
+ * The optimistic/rollback logic lives in the hook because eight listings
63
+ * grew this control at once; see its header for why rollback must DELETE
64
+ * the override key rather than write back a captured value.
65
+ */
66
+ const activeFlag = useOptimisticRowFlag({
67
+ mutate: (id, next) => apiClient.patch(SELLER_ENDPOINTS.GROUPED_LISTING_BY_ID(id), { isActive: next }),
68
+ invalidate: [["seller", "grouped-listings"], ["grouped-listings"]],
69
+ successMessage: (next) => (next ? "Group activated." : "Group deactivated."),
70
+ errorMessage: "Could not change that group's status.",
71
+ });
54
72
  const config = {
55
73
  portal: "seller",
56
74
  title: "Grouped Listings",
@@ -72,7 +90,9 @@ export function SellerGroupedListingsView({ onCreateClick, onEditClick, onDelete
72
90
  title: toStringValue(item.title, "Untitled group"),
73
91
  groupTheme: toStringValue(item.groupTheme, "generic"),
74
92
  productCount: Array.isArray(item.productIds) ? item.productIds.length : 0,
75
- isActive: item.isActive === true,
93
+ // Resolved through the pending override so the Status badge and the
94
+ // row toggle cannot disagree between the click and the refetch.
95
+ isActive: activeFlag.resolve(toStringValue(item.id, `group-${index}`), item.isActive === true),
76
96
  visibilityStatus: toStringValue(item.visibilityStatus, "hidden"),
77
97
  createdAt: toRelativeDate(item.createdAt),
78
98
  })),
@@ -92,7 +112,7 @@ export function SellerGroupedListingsView({ onCreateClick, onEditClick, onDelete
92
112
  : undefined,
93
113
  // Mirrors the "Edit" row action below so table rows and cards both navigate on click.
94
114
  onRowClick: onEditClick ? (row) => onEditClick(row.id) : undefined,
95
- renderRowActions: (row) => (_jsxs(Row, { gap: "xs", children: [_jsx(Button, { size: "sm", variant: "ghost", onClick: () => onEditClick?.(row.id), children: ACTIONS.STORE["edit-listing"].label }), _jsx(Button, { size: "sm", variant: "ghost", onClick: () => onDeleteClick?.(row.id), children: ACTIONS.STORE["delete-listing"].label })] })),
115
+ renderRowActions: (row) => (_jsxs(Row, { gap: "xs", align: "center", children: [_jsx(Toggle, { size: "sm", checked: row.isActive, disabled: activeFlag.pendingId === row.id, ariaLabel: `${row.isActive ? "Deactivate" : "Activate"} ${row.title}`, onChange: (next) => { void activeFlag.set(row.id, next); } }), _jsx(Button, { size: "sm", variant: "ghost", onClick: () => onEditClick?.(row.id), children: ACTIONS.STORE["edit-listing"].label }), _jsx(Button, { size: "sm", variant: "ghost", onClick: () => onDeleteClick?.(row.id), children: ACTIONS.STORE["delete-listing"].label })] })),
96
116
  };
97
117
  return _jsx(DataListingView, { config: config });
98
118
  }