@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
@@ -25,6 +25,15 @@ export function QuickFormDrawer({ isOpen, onClose, title, fields, defaultValues,
25
25
  const drawerRef = useRef(null);
26
26
  const [values, setValues] = useState(() => initValues(fields, defaultValues));
27
27
  const [errors, setErrors] = useState({});
28
+ /*
29
+ * The drawer's `errors` state is already submit-gated (only `validate()`
30
+ * writes it), but the summary below reads `liveErrors`, which is a pure
31
+ * function of the current values and therefore non-empty from first paint.
32
+ * Without this flag the drawer opens listing every field it is about to
33
+ * ask for. Reset on close, so reopening starts clean.
34
+ */
35
+ const [submitAttemptCount, setSubmitAttemptCount] = useState(0);
36
+ const submitAttempted = submitAttemptCount > 0;
28
37
  const [submitting, setSubmitting] = useState(false);
29
38
  // Re-initialise when defaultValues change (e.g. edit mode)
30
39
  useEffect(() => {
@@ -101,6 +110,7 @@ export function QuickFormDrawer({ isOpen, onClose, title, fields, defaultValues,
101
110
  })();
102
111
  const handleSubmit = useCallback(async (e) => {
103
112
  e?.preventDefault();
113
+ setSubmitAttemptCount((n) => n + 1);
104
114
  if (!validate())
105
115
  return;
106
116
  setSubmitting(true);
@@ -118,6 +128,7 @@ export function QuickFormDrawer({ isOpen, onClose, title, fields, defaultValues,
118
128
  const handleClose = useCallback(() => {
119
129
  setValues(initValues(fields, defaultValues));
120
130
  setErrors({});
131
+ setSubmitAttemptCount(0);
121
132
  onClose();
122
133
  }, [fields, defaultValues, onClose]);
123
134
  // Keyboard: Esc → close; Tab → trap focus
@@ -175,7 +186,10 @@ export function QuickFormDrawer({ isOpen, onClose, title, fields, defaultValues,
175
186
  isDirty: false,
176
187
  isSubmitting: submitting,
177
188
  stepErrorCounts: [],
178
- }), [liveErrors, submitting]);
189
+ submitAttempted,
190
+ submitAttemptCount,
191
+ markSubmitAttempted: () => setSubmitAttemptCount((n) => n + 1),
192
+ }), [liveErrors, submitting, submitAttempted, submitAttemptCount]);
179
193
  if (!isOpen)
180
194
  return null;
181
195
  const busy = submitting || isLoading;
@@ -1,7 +1,7 @@
1
1
  import { type ReactNode } from "react";
2
2
  import type { JsonValue } from "@mohasinac/appkit/client";
3
3
  import type { ZodTypeAny } from "zod";
4
- import type { FormShellStep } from "../../ui/forms/FormShell";
4
+ import { type FormShellStep } from "../../ui/forms/FormShell";
5
5
  /**
6
6
  * A collapsible form segment. Replaces `StepDef` — the differences are the
7
7
  * point, not incidental:
@@ -64,6 +64,20 @@ export interface SectionFormProps<T extends object = Record<string, JsonValue>>
64
64
  expandMode?: "single" | "multi";
65
65
  /** Suppress the built-in submit row (a FormShell footer owns it instead). */
66
66
  hideActions?: boolean;
67
+ /**
68
+ * Secondary action for the pinned mobile bar. Omit on a settings page with
69
+ * nowhere to go back to — the bar then shows Save alone.
70
+ */
71
+ onCancel?: () => void;
72
+ cancelLabel?: string;
73
+ /**
74
+ * Publish Save/Cancel and the error sheet into the mobile bottom-chrome
75
+ * tier. On by default: a SectionForm is a page-level form, which is exactly
76
+ * the case the pinned bar exists for. The hook suppresses itself inside a
77
+ * Modal or SideDrawer regardless, so this is only for the rarer case of a
78
+ * page that wants to own the bar itself.
79
+ */
80
+ bottomBar?: boolean;
67
81
  /**
68
82
  * Fires whenever live validation recomputes. SectionForm deliberately does
69
83
  * NOT mount its own `FormShellContext.Provider` — the owning view keeps the
@@ -95,5 +109,5 @@ export interface UseSectionFormNavResult<T extends object> {
95
109
  export declare function useSectionFormNav<T extends object = Record<string, JsonValue>>(sections: SectionDef<T>[], values?: T): UseSectionFormNavResult<T>;
96
110
  /** DOM id for a section panel — kept in one place so nav and render agree. */
97
111
  export declare function sectionAnchorId(id: string): string;
98
- export declare function SectionForm<T extends object = Record<string, JsonValue>>({ sections, values, onChange, onSubmit, submitLabel, isLoading, schema, openIds: controlledOpenIds, onOpenChange, expandMode, hideActions, onValidationChange, }: SectionFormProps<T>): import("react").JSX.Element;
112
+ export declare function SectionForm<T extends object = Record<string, JsonValue>>({ sections, values, onChange, onSubmit, submitLabel, isLoading, schema, openIds: controlledOpenIds, onOpenChange, expandMode, hideActions, onValidationChange, onCancel, cancelLabel, bottomBar, }: SectionFormProps<T>): import("react").JSX.Element;
99
113
  export default SectionForm;
@@ -5,6 +5,8 @@ import { Button } from "../../ui/components/Button";
5
5
  import { CollapsibleSection } from "../../ui/components/CollapsibleSection";
6
6
  import { Badge } from "../../ui/components/Badge";
7
7
  import { Row, Span, Stack } from "../../ui";
8
+ import { FormShellContext } from "../../ui/forms/FormShell";
9
+ import { useFormBottomActions } from "../layout/hooks/useFormBottomActions";
8
10
  import { FormSchemaContext } from "./FormShell";
9
11
  /** Sections in render order: required first, original order preserved within each group. */
10
12
  export function orderSections(sections) {
@@ -53,8 +55,15 @@ export function useSectionFormNav(sections, values) {
53
55
  export function sectionAnchorId(id) {
54
56
  return `section-${id}`;
55
57
  }
56
- export function SectionForm({ sections, values, onChange, onSubmit, submitLabel = "Save", isLoading = false, schema, openIds: controlledOpenIds, onOpenChange, expandMode = "multi", hideActions = false, onValidationChange, }) {
58
+ export function SectionForm({ sections, values, onChange, onSubmit, submitLabel = "Save", isLoading = false, schema, openIds: controlledOpenIds, onOpenChange, expandMode = "multi", hideActions = false, onValidationChange, onCancel, cancelLabel, bottomBar = true, }) {
57
59
  const inheritedSchema = useContext(FormSchemaContext);
60
+ /*
61
+ * SectionForm deliberately does not OWN a FormShellContext (the owning view
62
+ * keeps the provider — see the prop docs above), but it does own the submit
63
+ * button, so it is the one place that knows an attempt happened. Null when
64
+ * a caller renders it outside a provider, which is legitimate.
65
+ */
66
+ const shellCtx = useContext(FormShellContext);
58
67
  const [fieldErrors, setFieldErrors] = useState({});
59
68
  const [uncontrolledOpenIds, setUncontrolledOpenIds] = useState(() => orderSections(sections).filter((s) => s.required).map((s) => s.id));
60
69
  const ordered = useMemo(() => orderSections(sections.filter((s) => (s.when ? s.when(values) : true))), [sections, values]);
@@ -100,6 +109,23 @@ export function SectionForm({ sections, values, onChange, onSubmit, submitLabel
100
109
  onChange(partial);
101
110
  runValidation({ ...values, ...partial });
102
111
  }, [values, onChange, runValidation]);
112
+ /*
113
+ * The one submit path, shared by the inline button below and the pinned
114
+ * mobile bar — so an attempt is recorded and the error sheet revealed
115
+ * identically whichever the user presses.
116
+ */
117
+ const handleSubmit = useCallback(() => {
118
+ shellCtx?.markSubmitAttempted();
119
+ void onSubmit();
120
+ }, [shellCtx, onSubmit]);
121
+ useFormBottomActions({
122
+ onSubmit: handleSubmit,
123
+ onCancel,
124
+ submitLabel,
125
+ cancelLabel,
126
+ isLoading,
127
+ enabled: bottomBar && !hideActions,
128
+ });
103
129
  const toggle = useCallback((id) => {
104
130
  const isOpen = openIds.includes(id);
105
131
  if (expandMode === "single") {
@@ -123,6 +149,6 @@ export function SectionForm({ sections, values, onChange, onSubmit, submitLabel
123
149
  toggle(section.id); }, keepMounted: section.keepMounted, renderHeaderExtra: errorCount > 0
124
150
  ? () => (_jsxs(Badge, { variant: "danger", children: [errorCount, " ", errorCount === 1 ? "issue" : "issues"] }))
125
151
  : undefined, children: section.render({ values, onChange: handleFieldChange, errors: fieldErrors }) }, section.id));
126
- }), !hideActions && (_jsx(Row, { justify: "end", paddingY: "y-sm", children: _jsx(Button, { variant: "primary", type: "submit", onClick: () => void onSubmit(), isLoading: isLoading, disabled: isLoading, children: submitLabel }) }))] }));
152
+ }), !hideActions && (_jsx(Row, { justify: "end", paddingY: "y-sm", children: _jsx(Button, { variant: "primary", type: "submit", onClick: handleSubmit, isLoading: isLoading, disabled: isLoading, children: submitLabel }) }))] }));
127
153
  }
128
154
  export default SectionForm;
@@ -11,6 +11,7 @@ import { ACTIONS } from "../../../_internal/shared/actions/action-registry";
11
11
  import { createShipmentSchema } from "../schemas/validation";
12
12
  import { useShipment } from "../hooks/useShipments";
13
13
  import { formatCurrency } from "../../../utils/number.formatter";
14
+ import { RecordStatusTimeline } from "../../status-history/components/RecordStatusTimeline";
14
15
  const STATUS_OPTIONS = [
15
16
  { label: "Planning", value: "planning" },
16
17
  { label: "Ordered", value: "ordered" },
@@ -77,7 +78,9 @@ export function AdminShipmentEditorView({ shipmentId, onSaved }) {
77
78
  onSaved?.(saved.id);
78
79
  },
79
80
  });
80
- return (_jsx(Form, { schema: createShipmentSchema, spacing: "md", children: ({ setFieldError }) => (_jsxs(_Fragment, { children: [_jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(FieldInput, { name: "shipmentNumber", label: "Shipment Number", required: true, value: shipmentNumber, onChange: setShipmentNumber, placeholder: "SH-2026-0001" }), _jsx(FieldInput, { name: "supplierName", label: "Supplier Name", required: true, value: supplierName, onChange: setSupplierName }), _jsx(FieldInput, { name: "originCountry", label: "Origin Country", value: originCountry, onChange: setOriginCountry }), _jsx(FieldSelect, { name: "status", label: "Status", required: true, value: status, onChange: setStatus, options: STATUS_OPTIONS }), _jsx(FieldInput, { name: "trackingNumber", label: "Tracking Number", value: trackingNumber, onChange: setTrackingNumber }), _jsx(FieldInput, { name: "carrier", label: "Carrier", value: carrier, onChange: setCarrier }), _jsx(FieldInput, { name: "etaDate", label: "ETA Date", type: "date", value: etaDate, onChange: setEtaDate }), _jsx(FieldInput, { name: "laborHoursSpent", label: "Labor Hours Spent", type: "number", min: "0", value: laborHoursSpent, onChange: setLaborHoursSpent }), _jsx(FieldInput, { name: "customsTotal", label: "Customs Total (\u20B9)", type: "number", min: "0", value: customsTotalRupees, onChange: setCustomsTotalRupees, hint: "Split across lots by declared value" }), _jsx(FieldInput, { name: "shippingTotal", label: "Shipping Total (\u20B9)", type: "number", min: "0", value: shippingTotalRupees, onChange: setShippingTotalRupees, hint: "Split across lots by weight" })] }), _jsx(FieldTextarea, { name: "notes", label: "Notes", value: notes, onChange: setNotes, rows: 3 }), _jsx(FormErrorSummary, {}), _jsx(Button, { type: "button", isLoading: saveMutation.isPending, onClick: () => {
81
+ return (_jsx(Form, { schema: createShipmentSchema, spacing: "md", children: ({ setFieldError, markSubmitAttempted }) => (_jsxs(_Fragment, { children: [_jsxs(Grid, { cols: 2, gap: "md", children: [_jsx(FieldInput, { name: "shipmentNumber", label: "Shipment Number", required: true, value: shipmentNumber, onChange: setShipmentNumber, placeholder: "SH-2026-0001" }), _jsx(FieldInput, { name: "supplierName", label: "Supplier Name", required: true, value: supplierName, onChange: setSupplierName }), _jsx(FieldInput, { name: "originCountry", label: "Origin Country", value: originCountry, onChange: setOriginCountry }), _jsx(FieldSelect, { name: "status", label: "Status", required: true, value: status, onChange: setStatus, options: STATUS_OPTIONS }), _jsx(FieldInput, { name: "trackingNumber", label: "Tracking Number", value: trackingNumber, onChange: setTrackingNumber }), _jsx(FieldInput, { name: "carrier", label: "Carrier", value: carrier, onChange: setCarrier }), _jsx(FieldInput, { name: "etaDate", label: "ETA Date", type: "date", value: etaDate, onChange: setEtaDate }), _jsx(FieldInput, { name: "laborHoursSpent", label: "Labor Hours Spent", type: "number", min: "0", value: laborHoursSpent, onChange: setLaborHoursSpent }), _jsx(FieldInput, { name: "customsTotal", label: "Customs Total (\u20B9)", type: "number", min: "0", value: customsTotalRupees, onChange: setCustomsTotalRupees, hint: "Split across lots by declared value" }), _jsx(FieldInput, { name: "shippingTotal", label: "Shipping Total (\u20B9)", type: "number", min: "0", value: shippingTotalRupees, onChange: setShippingTotalRupees, hint: "Split across lots by weight" })] }), _jsx(FieldTextarea, { name: "notes", label: "Notes", value: notes, onChange: setNotes, rows: 3 }), _jsx(FormErrorSummary, {}), _jsx(Button, { type: "button", isLoading: saveMutation.isPending, onClick: () => {
82
+ // type="button" — no native submit, so unhide the summary here.
83
+ markSubmitAttempted();
81
84
  const parsed = createShipmentSchema.safeParse({
82
85
  shipmentNumber,
83
86
  supplierName,
@@ -92,7 +95,7 @@ export function AdminShipmentEditorView({ shipmentId, onSaved }) {
92
95
  return;
93
96
  }
94
97
  saveMutation.mutate();
95
- }, children: isCreate ? "Create Shipment" : "Save Changes" }), !isCreate && shipmentId && (_jsx(ShipmentLotsSection, { shipmentId: shipmentId, lots: lots, onLotsChanged: refetchLots }))] })) }));
98
+ }, children: isCreate ? "Create Shipment" : "Save Changes" }), !isCreate && shipmentId && (_jsx(ShipmentLotsSection, { shipmentId: shipmentId, lots: lots, onLotsChanged: refetchLots })), !isCreate && (_jsx(RecordStatusTimeline, { entries: shipment?.statusHistory, truncatedCount: shipment?.statusHistoryTruncated }))] })) }));
96
99
  }
97
100
  function ShipmentLotsSection({ shipmentId, lots, onLotsChanged, }) {
98
101
  const [showAddLot, setShowAddLot] = React.useState(false);
@@ -4,8 +4,26 @@
4
4
  */
5
5
  import { BaseRepository, parseSieveDateValue, type SieveModel, type FirebaseSieveResult } from "../../../providers/db-firebase";
6
6
  import { type ShipmentDocument, type ShipmentCreateInput, type ShipmentUpdateInput } from "../schemas/firestore";
7
+ import { type HistoryActor } from "../../../_internal/shared/history/index";
8
+ /** Who/why for a shipment write that lands in `statusHistory`. */
9
+ export interface ShipmentWriteContext {
10
+ actor?: HistoryActor;
11
+ trigger?: string;
12
+ reason?: string;
13
+ note?: string;
14
+ }
7
15
  export declare class ShipmentsRepository extends BaseRepository<ShipmentDocument> {
8
16
  constructor();
17
+ /**
18
+ * The admin edit path. Two route callers, no funnel before this.
19
+ *
20
+ * `prior` is optional and unused by the current caller — the admin PATCH
21
+ * route does NOT fetch the shipment first (its GET sibling does), so this
22
+ * takes one read. That is the honest cost here rather than a hidden one:
23
+ * without the before-value a slipped ETA would record as
24
+ * `undefined → newDate`, which cannot answer "how many times did this move".
25
+ */
26
+ adminUpdate(shipmentId: string, patch: Partial<ShipmentDocument>, ctx?: ShipmentWriteContext, prior?: ShipmentDocument | null): Promise<ShipmentDocument>;
9
27
  findByShipmentNumber(shipmentNumber: string): Promise<ShipmentDocument | null>;
10
28
  private assertShipmentNumberAvailable;
11
29
  create(input: ShipmentCreateInput): Promise<ShipmentDocument>;
@@ -4,11 +4,33 @@
4
4
  */
5
5
  import { BaseRepository, prepareForFirestore, parseSieveDateValue, } from "../../../providers/db-firebase";
6
6
  import { ConflictError } from "../../../errors";
7
- import { SHIPMENT_COLLECTION, SHIPMENT_FIELDS, SHIPMENT_ITEM_COLLECTION, DEFAULT_SHIPMENT_TOTALS, createShipmentId, } from "../schemas/firestore";
7
+ import { SHIPMENT_COLLECTION, SHIPMENT_FIELDS, SHIPMENT_ITEM_COLLECTION, DEFAULT_SHIPMENT_TOTALS, createShipmentId, SHIPMENT_TRACKED_FIELDS, SHIPMENT_HISTORY_PII_FIELDS, } from "../schemas/firestore";
8
+ import { withHistory } from "../../../_internal/shared/history/index";
8
9
  export class ShipmentsRepository extends BaseRepository {
9
10
  constructor() {
10
11
  super(SHIPMENT_COLLECTION);
11
12
  }
13
+ /**
14
+ * The admin edit path. Two route callers, no funnel before this.
15
+ *
16
+ * `prior` is optional and unused by the current caller — the admin PATCH
17
+ * route does NOT fetch the shipment first (its GET sibling does), so this
18
+ * takes one read. That is the honest cost here rather than a hidden one:
19
+ * without the before-value a slipped ETA would record as
20
+ * `undefined → newDate`, which cannot answer "how many times did this move".
21
+ */
22
+ async adminUpdate(shipmentId, patch, ctx, prior) {
23
+ const current = prior !== undefined ? prior : await this.findById(shipmentId);
24
+ const withEntry = withHistory(current, patch, {
25
+ tracked: SHIPMENT_TRACKED_FIELDS,
26
+ actor: ctx?.actor ?? { role: "system" },
27
+ trigger: ctx?.trigger ?? "adminUpdateShipment",
28
+ reason: ctx?.reason,
29
+ note: ctx?.note,
30
+ piiFields: SHIPMENT_HISTORY_PII_FIELDS,
31
+ });
32
+ return this.update(shipmentId, withEntry ?? patch);
33
+ }
12
34
  async findByShipmentNumber(shipmentNumber) {
13
35
  return this.findOneBy(SHIPMENT_FIELDS.SHIPMENT_NUMBER, shipmentNumber);
14
36
  }
@@ -13,6 +13,7 @@
13
13
  * never inline in a Vercel route or React component.
14
14
  */
15
15
  import { type GenerateShipmentIdInput } from "../../../utils/id-generators";
16
+ import type { StatusChangeEntry } from "../../../_internal/shared/history/types";
16
17
  import type { BaseDocument } from "../../../_internal/shared/types/base-document";
17
18
  import type { MediaField } from "../../media/types";
18
19
  import type { ListingType } from "../../products/types";
@@ -57,6 +58,9 @@ export interface ShipmentDocument extends BaseDocument {
57
58
  totals: ShipmentTotals;
58
59
  totalsComputedAt?: Date;
59
60
  createdBy: string;
61
+ /** Who changed what, when, and why. See § "Status History" in CLAUDE.md. */
62
+ statusHistory?: StatusChangeEntry[];
63
+ statusHistoryTruncated?: number;
60
64
  }
61
65
  export interface ShipmentLot extends BaseDocument {
62
66
  shipmentId: string;
@@ -148,3 +152,13 @@ export declare const shipmentQueryHelpers: {
148
152
  readonly notCancelled: () => readonly ["status", "!=", ShipmentStatus];
149
153
  };
150
154
  export declare function createShipmentId(input: GenerateShipmentIdInput): string;
155
+ /**
156
+ * The fields whose changes earn a timeline entry.
157
+ *
158
+ * A procurement shipment's dates ARE its status for practical purposes — "ETA
159
+ * slipped twice before it landed" is the question this answers, and each slip
160
+ * overwrites the previous `etaDate`.
161
+ */
162
+ export declare const SHIPMENT_TRACKED_FIELDS: readonly ["status", "etaDate", "receivedDate", "trackingNumber"];
163
+ /** PII on this document, for `withHistory`'s scrub. */
164
+ export declare const SHIPMENT_HISTORY_PII_FIELDS: readonly ["supplierEmail", "supplierPhone", "contactName"];
@@ -74,3 +74,22 @@ export const shipmentQueryHelpers = {
74
74
  export function createShipmentId(input) {
75
75
  return generateShipmentId(input);
76
76
  }
77
+ /**
78
+ * The fields whose changes earn a timeline entry.
79
+ *
80
+ * A procurement shipment's dates ARE its status for practical purposes — "ETA
81
+ * slipped twice before it landed" is the question this answers, and each slip
82
+ * overwrites the previous `etaDate`.
83
+ */
84
+ export const SHIPMENT_TRACKED_FIELDS = [
85
+ "status",
86
+ "etaDate",
87
+ "receivedDate",
88
+ "trackingNumber",
89
+ ];
90
+ /** PII on this document, for `withHistory`'s scrub. */
91
+ export const SHIPMENT_HISTORY_PII_FIELDS = [
92
+ "supplierEmail",
93
+ "supplierPhone",
94
+ "contactName",
95
+ ];
@@ -0,0 +1,12 @@
1
+ import type { StatusChangeEntry } from "../../../_internal/shared/history/types";
2
+ export interface RecordStatusTimelineProps {
3
+ /** The record's `statusHistory`. Absent or empty renders `emptyLabel`. */
4
+ entries?: StatusChangeEntry[];
5
+ /** The record's `statusHistoryTruncated` — "earlier history trimmed". */
6
+ truncatedCount?: number;
7
+ title?: string;
8
+ emptyLabel?: string;
9
+ className?: string;
10
+ }
11
+ export declare function RecordStatusTimeline({ entries, truncatedCount, title, emptyLabel, className, }: RecordStatusTimelineProps): import("react").JSX.Element;
12
+ export default RecordStatusTimeline;
@@ -0,0 +1,90 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { StatusTimeline } from "./StatusTimeline";
4
+ /**
5
+ * The timeline for an entity with no fixed phase vocabulary.
6
+ *
7
+ * ## Why this exists rather than seven more wrappers
8
+ *
9
+ * `OrderStatusTimeline` and `OfferPhaseTimeline` each supply a KNOWN sequence
10
+ * of phases — placed → paid → shipped → delivered, made → countered →
11
+ * accepted → paid — and render the steps a record has not reached yet as
12
+ * upcoming. That shape earns a dedicated wrapper because the vocabulary is
13
+ * part of the product.
14
+ *
15
+ * Support tickets, payouts, stores, scammer profiles, bids, shipments,
16
+ * catalogue items and ads have no such sequence: a store goes active →
17
+ * suspended → active, a bid goes active → outbid → active → won, a payout
18
+ * retries. There is nothing to pre-render. So the timeline is simply the
19
+ * entries that happened, in order — and writing that eight times would be
20
+ * eight chances for the actor label, the em-dash rule or the negative tone to
21
+ * drift, which is the bug-fix multiplier the Duplication Framework names.
22
+ *
23
+ * ## Never fabricate a step
24
+ *
25
+ * A record written before its entity adopted history has no `statusHistory`
26
+ * at all. That renders `emptyLabel`, NOT a step derived from `updatedAt` —
27
+ * "last write of any kind" is not an event, and a made-up date makes the
28
+ * record lie. Same rule the offer timeline follows for `expired`.
29
+ */
30
+ /**
31
+ * Statuses that render in the error tone.
32
+ *
33
+ * Deliberately a shared set rather than a per-entity prop: these words mean
34
+ * the same thing on every entity that uses them, and a per-entity list is one
35
+ * more thing to keep aligned. A value not listed renders neutral, which is
36
+ * the safe direction — a real transition shown in the wrong tone is a smaller
37
+ * defect than a normal one shown as a failure.
38
+ */
39
+ const NEGATIVE_STATUSES = new Set([
40
+ "cancelled",
41
+ "canceled",
42
+ "rejected",
43
+ "failed",
44
+ "forfeited",
45
+ "suspended",
46
+ "removed",
47
+ "declined",
48
+ "expired",
49
+ "lost",
50
+ ]);
51
+ /** Ended, but not a failure — the record simply moved on. */
52
+ const MUTED_STATUSES = new Set(["outbid", "closed", "superseded", "archived"]);
53
+ function humanise(value) {
54
+ if (value === null || value === undefined || value === "")
55
+ return "—";
56
+ if (typeof value === "boolean")
57
+ return value ? "yes" : "no";
58
+ const s = String(value);
59
+ return s.replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase());
60
+ }
61
+ /**
62
+ * One entry → one step.
63
+ *
64
+ * `status` is the headline when it changed; otherwise the step is named after
65
+ * the fields that did move, so an entry recording only "ETA moved" still
66
+ * reads as a real event rather than a blank row.
67
+ */
68
+ function toStep(entry, index) {
69
+ const statusChange = entry.changes["status"] ?? entry.changes["listingStatus"];
70
+ const to = statusChange?.to;
71
+ const label = statusChange
72
+ ? humanise(to)
73
+ : Object.keys(entry.changes).map(humanise).join(", ") || "Updated";
74
+ const toKey = typeof to === "string" ? to.toLowerCase() : "";
75
+ return {
76
+ key: `${entry.at instanceof Date ? entry.at.toISOString() : String(entry.at)}-${index}`,
77
+ label,
78
+ date: entry.at instanceof Date ? entry.at.toISOString() : String(entry.at),
79
+ negative: NEGATIVE_STATUSES.has(toKey),
80
+ muted: MUTED_STATUSES.has(toKey),
81
+ actorRole: entry.actorRole,
82
+ reason: entry.reason,
83
+ note: entry.note,
84
+ };
85
+ }
86
+ export function RecordStatusTimeline({ entries, truncatedCount, title = "History", emptyLabel = "No recorded history yet", className, }) {
87
+ const steps = (entries ?? []).map(toStep);
88
+ return (_jsx(StatusTimeline, { title: title, steps: steps, truncatedCount: truncatedCount, emptyLabel: emptyLabel, className: className }));
89
+ }
90
+ export default RecordStatusTimeline;
@@ -9,3 +9,5 @@
9
9
  */
10
10
  export { StatusTimeline, stepsFromEntries } from "./components/StatusTimeline";
11
11
  export type { StatusTimelineProps, TimelineEntry, TimelineStep } from "./components/StatusTimeline";
12
+ export { RecordStatusTimeline } from "./components/RecordStatusTimeline";
13
+ export type { RecordStatusTimelineProps } from "./components/RecordStatusTimeline";
@@ -8,3 +8,4 @@
8
8
  * is an entirely unrelated concept.
9
9
  */
10
10
  export { StatusTimeline, stepsFromEntries } from "./components/StatusTimeline";
11
+ export { RecordStatusTimeline } from "./components/RecordStatusTimeline";
@@ -6,6 +6,14 @@
6
6
  */
7
7
  import { BaseRepository, type SieveModel, type FirebaseSieveResult, type DocumentSnapshot } from "../../../providers/db-firebase";
8
8
  import { StoreDocument } from "../schemas";
9
+ import { type HistoryActor } from "../../../_internal/shared/history/index";
10
+ /** Who/why for a store write that lands in `statusHistory`. */
11
+ export interface StoreWriteContext {
12
+ actor?: HistoryActor;
13
+ trigger?: string;
14
+ reason?: string;
15
+ note?: string;
16
+ }
9
17
  export declare class StoreRepository extends BaseRepository<StoreDocument> {
10
18
  constructor();
11
19
  private encryptSecrets;
@@ -33,9 +41,29 @@ export declare class StoreRepository extends BaseRepository<StoreDocument> {
33
41
  */
34
42
  updateStore(storeSlug: string, data: Partial<Omit<StoreDocument, "id" | "storeSlug" | "ownerId" | "createdAt">>): Promise<StoreDocument>;
35
43
  /**
36
- * Admin: approve or reject a store.
44
+ * Admin: approve, reject or suspend a store — and the ONLY correct way to
45
+ * move `status`, because `isPublic` must move with it.
46
+ *
47
+ * `status` and `isPublic` are two distinct fields and public visibility
48
+ * gates on `isPublic`. The admin PATCH route wrote `status` directly and
49
+ * never touched `isPublic` until 2026-08-26, so approving a pending store
50
+ * left it `isPublic: false` — active, and invisible on every public
51
+ * surface, with nothing anywhere reporting a problem. Same two-field trap
52
+ * CLAUDE.md already records for `becomeSeller` / `createStore`.
53
+ *
54
+ * `extra` carries the other admin fields so a single save is a single
55
+ * write and a single timeline entry; `prior` keeps that entry read-free
56
+ * for the route, which has already fetched the store to 404 on a missing
57
+ * one (Rule #6).
58
+ */
59
+ setStatus(storeSlug: string, status: StoreDocument["status"], extra?: Partial<StoreDocument>, ctx?: StoreWriteContext, prior?: StoreDocument | null): Promise<StoreDocument>;
60
+ /**
61
+ * Admin field edits that do NOT move `status` — verification, featuring,
62
+ * capabilities, the suspension reason on an already-suspended store.
37
63
  */
38
- setStatus(storeSlug: string, status: StoreDocument["status"]): Promise<StoreDocument>;
64
+ adminUpdate(storeSlug: string, patch: Partial<StoreDocument>, ctx?: StoreWriteContext, prior?: StoreDocument | null): Promise<StoreDocument>;
65
+ /** Append a timeline entry when the patch touches a tracked field. */
66
+ private withStoreHistory;
39
67
  /**
40
68
  * Paginated list of stores with Sieve filtering/sorting.
41
69
  * Filters to active + public stores for the public listing.
@@ -6,10 +6,11 @@
6
6
  */
7
7
  import { BaseRepository, prepareForFirestore, applySieveToFirestore, } from "../../../providers/db-firebase";
8
8
  import { increment } from "../../../contracts/field-ops";
9
- import { STORE_COLLECTION, STORE_FIELDS, DEFAULT_STORE_DATA, StoreStatusValues, } from "../schemas";
9
+ import { STORE_COLLECTION, STORE_FIELDS, DEFAULT_STORE_DATA, StoreStatusValues, STORE_TRACKED_FIELDS, STORE_HISTORY_PII_FIELDS, } from "../schemas";
10
10
  import { DatabaseError } from "../../../errors";
11
11
  import { normalizeError } from "../../../errors/normalize";
12
12
  import { encryptSecret, decryptSecret, } from "../../../security/settings-encryption";
13
+ import { withHistory } from "../../../_internal/shared/history/index";
13
14
  export class StoreRepository extends BaseRepository {
14
15
  constructor() {
15
16
  super(STORE_COLLECTION);
@@ -97,16 +98,54 @@ export class StoreRepository extends BaseRepository {
97
98
  });
98
99
  }
99
100
  /**
100
- * Admin: approve or reject a store.
101
+ * Admin: approve, reject or suspend a store — and the ONLY correct way to
102
+ * move `status`, because `isPublic` must move with it.
103
+ *
104
+ * `status` and `isPublic` are two distinct fields and public visibility
105
+ * gates on `isPublic`. The admin PATCH route wrote `status` directly and
106
+ * never touched `isPublic` until 2026-08-26, so approving a pending store
107
+ * left it `isPublic: false` — active, and invisible on every public
108
+ * surface, with nothing anywhere reporting a problem. Same two-field trap
109
+ * CLAUDE.md already records for `becomeSeller` / `createStore`.
110
+ *
111
+ * `extra` carries the other admin fields so a single save is a single
112
+ * write and a single timeline entry; `prior` keeps that entry read-free
113
+ * for the route, which has already fetched the store to 404 on a missing
114
+ * one (Rule #6).
101
115
  */
102
- async setStatus(storeSlug, status) {
103
- return this.update(storeSlug, {
116
+ async setStatus(storeSlug, status, extra, ctx, prior) {
117
+ const current = prior !== undefined ? prior : await this.findById(storeSlug);
118
+ return this.update(storeSlug, this.withStoreHistory(current, {
104
119
  status,
105
120
  // Auto-set isPublic when approved
106
121
  ...(status === StoreStatusValues.ACTIVE && { isPublic: true }),
107
122
  ...(status !== StoreStatusValues.ACTIVE && { isPublic: false }),
123
+ ...extra,
108
124
  updatedAt: new Date(),
125
+ }, ctx, "setStatus"));
126
+ }
127
+ /**
128
+ * Admin field edits that do NOT move `status` — verification, featuring,
129
+ * capabilities, the suspension reason on an already-suspended store.
130
+ */
131
+ async adminUpdate(storeSlug, patch, ctx, prior) {
132
+ const current = prior !== undefined ? prior : await this.findById(storeSlug);
133
+ return this.update(storeSlug, this.withStoreHistory(current, { ...patch, updatedAt: new Date() }, ctx, "adminUpdateStore"));
134
+ }
135
+ /** Append a timeline entry when the patch touches a tracked field. */
136
+ withStoreHistory(current, patch, ctx, defaultTrigger) {
137
+ const withEntry = withHistory(current, patch, {
138
+ tracked: STORE_TRACKED_FIELDS,
139
+ actor: ctx?.actor ?? { role: "system" },
140
+ trigger: ctx?.trigger ?? defaultTrigger,
141
+ reason: ctx?.reason,
142
+ note: ctx?.note,
143
+ // `mapDoc` decrypts whatsappConfig.accessToken on EVERY read, so a
144
+ // live Meta credential is in memory whenever this diff runs, and
145
+ // `encryptPiiFields` never descends into arrays.
146
+ piiFields: STORE_HISTORY_PII_FIELDS,
109
147
  });
148
+ return withEntry ?? patch;
110
149
  }
111
150
  /**
112
151
  * Paginated list of stores with Sieve filtering/sorting.
@@ -4,6 +4,7 @@
4
4
  * Canonical Firestore document interfaces, collection names, field constants,
5
5
  * and helpers for the stores feature.
6
6
  */
7
+ import type { StatusChangeEntry } from "../../../_internal/shared/history/types";
7
8
  import type { StoreCapability } from "../../auth/permissions/constants";
8
9
  import type { BaseDocument } from "../../../_internal/shared/types/base-document";
9
10
  export type ShippingProviderType = "self-courier" | "store-pickup" | "custom";
@@ -139,7 +140,35 @@ export interface StoreDocument extends BaseDocument {
139
140
  /** When the seller first connected their account */
140
141
  connectedAt?: Date;
141
142
  };
143
+ /**
144
+ * Who changed what, when, and why. See § "Status History" in CLAUDE.md.
145
+ *
146
+ * The question this answers for a seller is "why is my store suspended and
147
+ * who did it" — previously recoverable only from `adminAuditLog`, which is
148
+ * admin-only and which the seller can never see.
149
+ */
150
+ statusHistory?: StatusChangeEntry[];
151
+ statusHistoryTruncated?: number;
142
152
  }
153
+ /**
154
+ * The fields whose changes earn a timeline entry.
155
+ *
156
+ * `isPublic` is tracked alongside `status` deliberately: the two are supposed
157
+ * to move together (`setStatus` syncs them) and the admin PATCH route did NOT
158
+ * until 2026-08-26, so an approved store stayed invisible. A timeline that
159
+ * shows them diverging is how the next instance gets noticed.
160
+ *
161
+ * `adminNotes` is excluded — its own schema comment says it is never shown to
162
+ * the store owner or the public, and `statusHistory` rides on the store
163
+ * document the owner can read.
164
+ */
165
+ export declare const STORE_TRACKED_FIELDS: readonly ["status", "isPublic", "isVerified", "isFeatured", "suspensionReason", "capabilities"];
166
+ /**
167
+ * PII on this document, for `withHistory`'s scrub. `whatsappConfig.accessToken`
168
+ * is a live Meta credential; `mapDoc` decrypts it on EVERY read, so it is
169
+ * present in memory whenever a diff runs.
170
+ */
171
+ export declare const STORE_HISTORY_PII_FIELDS: readonly ["accessToken", "payoutDetails", "upiVpa", "accountNumber"];
143
172
  export type { StoreCapability } from "../../auth/permissions/constants";
144
173
  export declare const STORE_COLLECTION: "stores";
145
174
  export declare const STORE_FIELDS: {
@@ -12,6 +12,37 @@ export const StoreStatusValues = {
12
12
  SUSPENDED: "suspended",
13
13
  REJECTED: "rejected",
14
14
  };
15
+ /**
16
+ * The fields whose changes earn a timeline entry.
17
+ *
18
+ * `isPublic` is tracked alongside `status` deliberately: the two are supposed
19
+ * to move together (`setStatus` syncs them) and the admin PATCH route did NOT
20
+ * until 2026-08-26, so an approved store stayed invisible. A timeline that
21
+ * shows them diverging is how the next instance gets noticed.
22
+ *
23
+ * `adminNotes` is excluded — its own schema comment says it is never shown to
24
+ * the store owner or the public, and `statusHistory` rides on the store
25
+ * document the owner can read.
26
+ */
27
+ export const STORE_TRACKED_FIELDS = [
28
+ "status",
29
+ "isPublic",
30
+ "isVerified",
31
+ "isFeatured",
32
+ "suspensionReason",
33
+ "capabilities",
34
+ ];
35
+ /**
36
+ * PII on this document, for `withHistory`'s scrub. `whatsappConfig.accessToken`
37
+ * is a live Meta credential; `mapDoc` decrypts it on EVERY read, so it is
38
+ * present in memory whenever a diff runs.
39
+ */
40
+ export const STORE_HISTORY_PII_FIELDS = [
41
+ "accessToken",
42
+ "payoutDetails",
43
+ "upiVpa",
44
+ "accountNumber",
45
+ ];
15
46
  export const STORE_COLLECTION = "stores";
16
47
  export const STORE_FIELDS = {
17
48
  ID: "id",