@open-mercato/ui 0.6.3-develop.3766.1.33102bfc91 → 0.6.3-develop.3778.1.25fdb35f2e

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.
@@ -6,23 +6,31 @@ import { useRouter } from "next/navigation";
6
6
  import { Button } from "../../primitives/button.js";
7
7
  import { apiCall } from "../utils/apiCall.js";
8
8
  import { flash } from "../FlashMessages.js";
9
- import { useLastOperation, markUndoSuccess } from "./store.js";
9
+ import { useLastOperation, markUndoSuccess, dismissOperation, operationStackConstants } from "./store.js";
10
10
  import { useT } from "@open-mercato/shared/lib/i18n/context";
11
11
  function LastOperationBanner() {
12
12
  const t = useT();
13
13
  const operation = useLastOperation();
14
14
  const [pendingToken, setPendingToken] = React.useState(null);
15
15
  const router = useRouter();
16
+ const undoToken = operation?.undoToken ?? null;
17
+ const isPending = undoToken !== null && pendingToken === undoToken;
18
+ React.useEffect(() => {
19
+ if (!undoToken || isPending) return;
20
+ const timer = setTimeout(() => {
21
+ dismissOperation(undoToken);
22
+ }, operationStackConstants.LAST_OPERATION_AUTO_DISMISS_MS);
23
+ return () => clearTimeout(timer);
24
+ }, [undoToken, isPending]);
16
25
  if (!operation) return null;
17
26
  const rawLabel = operation.actionLabel ?? operation.commandId;
18
27
  const translatedLabel = t(rawLabel);
19
28
  const label = translatedLabel === rawLabel ? rawLabel : translatedLabel;
20
- const isPending = pendingToken === operation.undoToken;
21
29
  async function handleUndo() {
22
- const undoToken = operation?.undoToken;
23
- if (!undoToken || isPending) return;
24
- const tokens = operation.bulkUndoTokens && operation.bulkUndoTokens.length > 0 ? operation.bulkUndoTokens : [undoToken];
25
- setPendingToken(undoToken);
30
+ const undoToken2 = operation?.undoToken;
31
+ if (!undoToken2 || isPending) return;
32
+ const tokens = operation.bulkUndoTokens && operation.bulkUndoTokens.length > 0 ? operation.bulkUndoTokens : [undoToken2];
33
+ setPendingToken(undoToken2);
26
34
  const completed = [];
27
35
  try {
28
36
  for (const token of tokens.slice().reverse()) {
@@ -57,10 +65,10 @@ function LastOperationBanner() {
57
65
  setPendingToken(null);
58
66
  }
59
67
  }
60
- return /* @__PURE__ */ jsxs("div", { className: "mb-4 flex items-center justify-between gap-3 rounded-md border border-amber-200/80 bg-amber-50/95 pl-3 pr-2 py-2 text-sm text-amber-900 shadow-xs sm:pr-3", children: [
68
+ return /* @__PURE__ */ jsxs("div", { className: "mb-4 flex items-center justify-between gap-3 rounded-md border border-status-warning-border bg-status-warning-bg pl-3 pr-2 py-2 text-sm text-status-warning-text shadow-xs sm:pr-3", children: [
61
69
  /* @__PURE__ */ jsxs("div", { className: "min-w-0 truncate", children: [
62
- /* @__PURE__ */ jsx("span", { className: "font-medium text-amber-950", children: t("audit_logs.banner.last_operation") }),
63
- /* @__PURE__ */ jsx("span", { className: "ml-2 truncate text-amber-900", children: label })
70
+ /* @__PURE__ */ jsx("span", { className: "font-medium text-status-warning-text", children: t("audit_logs.banner.last_operation") }),
71
+ /* @__PURE__ */ jsx("span", { className: "ml-2 truncate text-status-warning-text", children: label })
64
72
  ] }),
65
73
  /* @__PURE__ */ jsxs(
66
74
  Button,
@@ -71,7 +79,7 @@ function LastOperationBanner() {
71
79
  void handleUndo();
72
80
  },
73
81
  disabled: isPending,
74
- className: "border-amber-200/80 bg-amber-50 text-amber-900 hover:bg-amber-100 hover:text-amber-900 px-2.5 sm:px-3",
82
+ className: "border-status-warning-border bg-status-warning-bg text-status-warning-text hover:bg-status-warning-border hover:text-status-warning-text px-2.5 sm:px-3",
75
83
  children: [
76
84
  /* @__PURE__ */ jsx(Undo2, { className: "mr-1 size-4", "aria-hidden": "true" }),
77
85
  isPending ? t("audit_logs.actions.undoing") : t("audit_logs.banner.undo")
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/backend/operations/LastOperationBanner.tsx"],
4
- "sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { Undo2 } from 'lucide-react'\nimport { useRouter } from 'next/navigation'\nimport { Button } from '../../primitives/button'\nimport { apiCall } from '../utils/apiCall'\nimport { flash } from '../FlashMessages'\nimport { useLastOperation, markUndoSuccess } from './store'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\nexport function LastOperationBanner() {\n const t = useT()\n const operation = useLastOperation()\n const [pendingToken, setPendingToken] = React.useState<string | null>(null)\n const router = useRouter()\n\n if (!operation) return null\n\n const rawLabel = operation.actionLabel ?? operation.commandId\n const translatedLabel = t(rawLabel)\n const label = translatedLabel === rawLabel ? rawLabel : translatedLabel\n const isPending = pendingToken === operation.undoToken\n\n async function handleUndo() {\n const undoToken = operation?.undoToken\n if (!undoToken || isPending) return\n const tokens = operation.bulkUndoTokens && operation.bulkUndoTokens.length > 0\n ? operation.bulkUndoTokens\n : [undoToken]\n setPendingToken(undoToken)\n const completed: string[] = []\n try {\n for (const token of tokens.slice().reverse()) {\n const call = await apiCall<Record<string, unknown>>('/api/audit_logs/audit-logs/actions/undo', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ undoToken: token }),\n })\n if (!call.ok) {\n const message =\n (call.result && typeof call.result.error === 'string' && call.result.error) ||\n ''\n throw new Error(message || t('audit_logs.banner.undo_failed', 'Failed to undo'))\n }\n completed.push(token)\n }\n markUndoSuccess(tokens)\n flash(t('audit_logs.banner.undo_success'), 'success')\n router.refresh()\n if (typeof window !== 'undefined') {\n try {\n const isJSDOM = typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string'\n ? navigator.userAgent.toLowerCase().includes('jsdom')\n : false\n if (!isJSDOM && typeof window.location?.reload === 'function') {\n window.location.reload()\n }\n } catch {\n // noop in non-browser or jsdom environments\n }\n }\n } catch (err) {\n if (completed.length > 0) markUndoSuccess(completed)\n const message = err instanceof Error && err.message ? err.message : t('audit_logs.banner.undo_error')\n flash(message, 'error')\n } finally {\n setPendingToken(null)\n }\n }\n\n return (\n <div className=\"mb-4 flex items-center justify-between gap-3 rounded-md border border-amber-200/80 bg-amber-50/95 pl-3 pr-2 py-2 text-sm text-amber-900 shadow-xs sm:pr-3\">\n <div className=\"min-w-0 truncate\">\n <span className=\"font-medium text-amber-950\">\n {t('audit_logs.banner.last_operation')}\n </span>\n <span className=\"ml-2 truncate text-amber-900\">\n {label}\n </span>\n </div>\n <Button\n variant=\"outline\"\n size=\"sm\"\n onClick={() => { void handleUndo() }}\n disabled={isPending}\n className=\"border-amber-200/80 bg-amber-50 text-amber-900 hover:bg-amber-100 hover:text-amber-900 px-2.5 sm:px-3\"\n >\n <Undo2 className=\"mr-1 size-4\" aria-hidden=\"true\" />\n {isPending ? t('audit_logs.actions.undoing') : t('audit_logs.banner.undo')}\n </Button>\n </div>\n )\n}\n"],
5
- "mappings": ";AAwEM,SACE,KADF;AAvEN,YAAY,WAAW;AACvB,SAAS,aAAa;AACtB,SAAS,iBAAiB;AAC1B,SAAS,cAAc;AACvB,SAAS,eAAe;AACxB,SAAS,aAAa;AACtB,SAAS,kBAAkB,uBAAuB;AAClD,SAAS,YAAY;AAEd,SAAS,sBAAsB;AACpC,QAAM,IAAI,KAAK;AACf,QAAM,YAAY,iBAAiB;AACnC,QAAM,CAAC,cAAc,eAAe,IAAI,MAAM,SAAwB,IAAI;AAC1E,QAAM,SAAS,UAAU;AAEzB,MAAI,CAAC,UAAW,QAAO;AAEvB,QAAM,WAAW,UAAU,eAAe,UAAU;AACpD,QAAM,kBAAkB,EAAE,QAAQ;AAClC,QAAM,QAAQ,oBAAoB,WAAW,WAAW;AACxD,QAAM,YAAY,iBAAiB,UAAU;AAE7C,iBAAe,aAAa;AAC1B,UAAM,YAAY,WAAW;AAC7B,QAAI,CAAC,aAAa,UAAW;AAC7B,UAAM,SAAS,UAAU,kBAAkB,UAAU,eAAe,SAAS,IACzE,UAAU,iBACV,CAAC,SAAS;AACd,oBAAgB,SAAS;AACzB,UAAM,YAAsB,CAAC;AAC7B,QAAI;AACF,iBAAW,SAAS,OAAO,MAAM,EAAE,QAAQ,GAAG;AAC5C,cAAM,OAAO,MAAM,QAAiC,2CAA2C;AAAA,UAC7F,QAAQ;AAAA,UACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,UAC9C,MAAM,KAAK,UAAU,EAAE,WAAW,MAAM,CAAC;AAAA,QAC3C,CAAC;AACD,YAAI,CAAC,KAAK,IAAI;AACZ,gBAAM,UACH,KAAK,UAAU,OAAO,KAAK,OAAO,UAAU,YAAY,KAAK,OAAO,SACrE;AACF,gBAAM,IAAI,MAAM,WAAW,EAAE,iCAAiC,gBAAgB,CAAC;AAAA,QACjF;AACA,kBAAU,KAAK,KAAK;AAAA,MACtB;AACA,sBAAgB,MAAM;AACtB,YAAM,EAAE,gCAAgC,GAAG,SAAS;AACpD,aAAO,QAAQ;AACf,UAAI,OAAO,WAAW,aAAa;AACjC,YAAI;AACF,gBAAM,UAAU,OAAO,cAAc,eAAe,OAAO,UAAU,cAAc,WAC/E,UAAU,UAAU,YAAY,EAAE,SAAS,OAAO,IAClD;AACJ,cAAI,CAAC,WAAW,OAAO,OAAO,UAAU,WAAW,YAAY;AAC7D,mBAAO,SAAS,OAAO;AAAA,UACzB;AAAA,QACF,QAAQ;AAAA,QAER;AAAA,MACF;AAAA,IACF,SAAS,KAAK;AACZ,UAAI,UAAU,SAAS,EAAG,iBAAgB,SAAS;AACnD,YAAM,UAAU,eAAe,SAAS,IAAI,UAAU,IAAI,UAAU,EAAE,8BAA8B;AACpG,YAAM,SAAS,OAAO;AAAA,IACxB,UAAE;AACA,sBAAgB,IAAI;AAAA,IACtB;AAAA,EACF;AAEA,SACE,qBAAC,SAAI,WAAU,6JACb;AAAA,yBAAC,SAAI,WAAU,oBACb;AAAA,0BAAC,UAAK,WAAU,8BACb,YAAE,kCAAkC,GACvC;AAAA,MACA,oBAAC,UAAK,WAAU,gCACb,iBACH;AAAA,OACF;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACC,SAAQ;AAAA,QACR,MAAK;AAAA,QACL,SAAS,MAAM;AAAE,eAAK,WAAW;AAAA,QAAE;AAAA,QACnC,UAAU;AAAA,QACV,WAAU;AAAA,QAEV;AAAA,8BAAC,SAAM,WAAU,eAAc,eAAY,QAAO;AAAA,UACjD,YAAY,EAAE,4BAA4B,IAAI,EAAE,wBAAwB;AAAA;AAAA;AAAA,IAC3E;AAAA,KACF;AAEJ;",
6
- "names": []
4
+ "sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { Undo2 } from 'lucide-react'\nimport { useRouter } from 'next/navigation'\nimport { Button } from '../../primitives/button'\nimport { apiCall } from '../utils/apiCall'\nimport { flash } from '../FlashMessages'\nimport { useLastOperation, markUndoSuccess, dismissOperation, operationStackConstants } from './store'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\nexport function LastOperationBanner() {\n const t = useT()\n const operation = useLastOperation()\n const [pendingToken, setPendingToken] = React.useState<string | null>(null)\n const router = useRouter()\n\n const undoToken = operation?.undoToken ?? null\n const isPending = undoToken !== null && pendingToken === undoToken\n\n React.useEffect(() => {\n if (!undoToken || isPending) return\n const timer = setTimeout(() => {\n dismissOperation(undoToken)\n }, operationStackConstants.LAST_OPERATION_AUTO_DISMISS_MS)\n return () => clearTimeout(timer)\n }, [undoToken, isPending])\n\n if (!operation) return null\n\n const rawLabel = operation.actionLabel ?? operation.commandId\n const translatedLabel = t(rawLabel)\n const label = translatedLabel === rawLabel ? rawLabel : translatedLabel\n\n async function handleUndo() {\n const undoToken = operation?.undoToken\n if (!undoToken || isPending) return\n const tokens = operation.bulkUndoTokens && operation.bulkUndoTokens.length > 0\n ? operation.bulkUndoTokens\n : [undoToken]\n setPendingToken(undoToken)\n const completed: string[] = []\n try {\n for (const token of tokens.slice().reverse()) {\n const call = await apiCall<Record<string, unknown>>('/api/audit_logs/audit-logs/actions/undo', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ undoToken: token }),\n })\n if (!call.ok) {\n const message =\n (call.result && typeof call.result.error === 'string' && call.result.error) ||\n ''\n throw new Error(message || t('audit_logs.banner.undo_failed', 'Failed to undo'))\n }\n completed.push(token)\n }\n markUndoSuccess(tokens)\n flash(t('audit_logs.banner.undo_success'), 'success')\n router.refresh()\n if (typeof window !== 'undefined') {\n try {\n const isJSDOM = typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string'\n ? navigator.userAgent.toLowerCase().includes('jsdom')\n : false\n if (!isJSDOM && typeof window.location?.reload === 'function') {\n window.location.reload()\n }\n } catch {\n // noop in non-browser or jsdom environments\n }\n }\n } catch (err) {\n if (completed.length > 0) markUndoSuccess(completed)\n const message = err instanceof Error && err.message ? err.message : t('audit_logs.banner.undo_error')\n flash(message, 'error')\n } finally {\n setPendingToken(null)\n }\n }\n\n return (\n <div className=\"mb-4 flex items-center justify-between gap-3 rounded-md border border-status-warning-border bg-status-warning-bg pl-3 pr-2 py-2 text-sm text-status-warning-text shadow-xs sm:pr-3\">\n <div className=\"min-w-0 truncate\">\n <span className=\"font-medium text-status-warning-text\">\n {t('audit_logs.banner.last_operation')}\n </span>\n <span className=\"ml-2 truncate text-status-warning-text\">\n {label}\n </span>\n </div>\n <Button\n variant=\"outline\"\n size=\"sm\"\n onClick={() => { void handleUndo() }}\n disabled={isPending}\n className=\"border-status-warning-border bg-status-warning-bg text-status-warning-text hover:bg-status-warning-border hover:text-status-warning-text px-2.5 sm:px-3\"\n >\n <Undo2 className=\"mr-1 size-4\" aria-hidden=\"true\" />\n {isPending ? t('audit_logs.actions.undoing') : t('audit_logs.banner.undo')}\n </Button>\n </div>\n )\n}\n"],
5
+ "mappings": "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",
6
+ "names": ["undoToken"]
7
7
  }
@@ -4,6 +4,18 @@ const DEFAULT_STATE = { stack: [], undone: [] };
4
4
  const STORAGE_KEY = "om:last-operations:v1";
5
5
  const STACK_LIMIT = 20;
6
6
  const LAST_OPERATION_TTL_MS = 6e4;
7
+ const DEFAULT_LAST_OPERATION_AUTO_DISMISS_MS = 1e4;
8
+ function resolveAutoDismissMs(raw) {
9
+ if (raw == null || raw === "") return DEFAULT_LAST_OPERATION_AUTO_DISMISS_MS;
10
+ const parsed = Number(raw);
11
+ if (!Number.isFinite(parsed) || parsed <= 0) {
12
+ return DEFAULT_LAST_OPERATION_AUTO_DISMISS_MS;
13
+ }
14
+ return Math.floor(parsed);
15
+ }
16
+ const LAST_OPERATION_AUTO_DISMISS_MS = resolveAutoDismissMs(
17
+ process.env.NEXT_PUBLIC_OM_UNDO_BANNER_TIMEOUT_MS
18
+ );
7
19
  const STACK_RETENTION_MS = 10 * 6e4;
8
20
  let internalState = DEFAULT_STATE;
9
21
  if (typeof window !== "undefined") {
@@ -141,6 +153,29 @@ function markUndoSuccess(undoTokens) {
141
153
  return { stack: nextStack, undone };
142
154
  });
143
155
  }
156
+ function dismissOperation(undoTokens) {
157
+ if (typeof window === "undefined") return;
158
+ const tokenSet = new Set(Array.isArray(undoTokens) ? undoTokens : [undoTokens]);
159
+ if (tokenSet.size === 0) return;
160
+ updateState((prev) => {
161
+ const nextStack = [];
162
+ for (const entry of prev.stack) {
163
+ if (tokenSet.has(entry.undoToken)) continue;
164
+ const bulk = entry.bulkUndoTokens && entry.bulkUndoTokens.length > 0 ? entry.bulkUndoTokens : null;
165
+ if (!bulk) {
166
+ nextStack.push(entry);
167
+ continue;
168
+ }
169
+ const remaining = bulk.filter((token) => !tokenSet.has(token));
170
+ if (remaining.length === bulk.length) {
171
+ nextStack.push(entry);
172
+ } else if (remaining.length > 0) {
173
+ nextStack.push({ ...entry, bulkUndoTokens: remaining, bulkCount: remaining.length });
174
+ }
175
+ }
176
+ return { stack: nextStack, undone: prev.undone };
177
+ });
178
+ }
144
179
  function generateBulkId(seed) {
145
180
  const cryptoRef = typeof globalThis !== "undefined" ? globalThis.crypto : void 0;
146
181
  if (cryptoRef && typeof cryptoRef.randomUUID === "function") {
@@ -221,11 +256,13 @@ function clearAllOperations() {
221
256
  emit();
222
257
  }
223
258
  const operationStackConstants = {
224
- LAST_OPERATION_TTL_MS
259
+ LAST_OPERATION_TTL_MS,
260
+ LAST_OPERATION_AUTO_DISMISS_MS
225
261
  };
226
262
  export {
227
263
  clearAllOperations,
228
264
  coalesceLastOperations,
265
+ dismissOperation,
229
266
  getLastOperation,
230
267
  hasRedoCandidate,
231
268
  markRedoConsumed,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/backend/operations/store.ts"],
4
- "sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport type { OperationMetadataPayload } from '@open-mercato/shared/lib/commands/operationMetadata'\n\nexport type OperationEntry = OperationMetadataPayload & {\n receivedAt: number\n bulkUndoTokens?: string[]\n bulkCount?: number\n}\n\nexport type UndoneEntry = OperationEntry & {\n undoneAt: number\n}\n\ntype OperationStoreState = {\n stack: OperationEntry[]\n undone: UndoneEntry[]\n}\n\nconst DEFAULT_STATE: OperationStoreState = { stack: [], undone: [] }\n\nconst STORAGE_KEY = 'om:last-operations:v1'\nconst STACK_LIMIT = 20\nconst LAST_OPERATION_TTL_MS = 60_000\nconst STACK_RETENTION_MS = 10 * 60_000\n\nlet internalState: OperationStoreState = DEFAULT_STATE\n\nif (typeof window !== 'undefined') {\n internalState = loadState()\n}\n\nconst emitter = new EventTarget()\n\nfunction now() {\n return typeof performance !== 'undefined' && performance.now\n ? Math.round(performance.timeOrigin + performance.now())\n : Date.now()\n}\n\nfunction loadState(): OperationStoreState {\n try {\n const raw = window.localStorage.getItem(STORAGE_KEY)\n if (!raw) return DEFAULT_STATE\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') return DEFAULT_STATE\n const stack = Array.isArray(parsed.stack) ? parsed.stack.filter(isValidEntry).map(hydrateEntry) : []\n const undone = Array.isArray(parsed.undone)\n ? parsed.undone.filter(isValidEntry).map((raw: unknown) => {\n const hydrated = hydrateEntry(raw)\n const candidate = raw as { undoneAt?: unknown }\n const undoneAt = typeof candidate.undoneAt === 'number' ? candidate.undoneAt : now()\n return { ...hydrated, undoneAt }\n })\n : []\n return pruneState({ stack, undone })\n } catch {\n return DEFAULT_STATE\n }\n}\n\nfunction isValidEntry(entry: unknown): entry is OperationEntry {\n if (entry == null || typeof entry !== 'object') return false\n const candidate = entry as Record<string, unknown>\n return (\n typeof candidate.id === 'string'\n && typeof candidate.undoToken === 'string'\n && typeof candidate.commandId === 'string'\n && typeof candidate.receivedAt === 'number'\n && typeof candidate.executedAt === 'string'\n )\n}\n\nfunction hydrateEntry(entry: unknown): OperationEntry {\n const source = entry as Partial<OperationEntry> & Record<string, unknown>\n const bulkTokens = Array.isArray(source.bulkUndoTokens)\n ? source.bulkUndoTokens.filter((t): t is string => typeof t === 'string' && t.length > 0)\n : undefined\n const bulkCount = typeof source.bulkCount === 'number' && Number.isFinite(source.bulkCount)\n ? source.bulkCount\n : undefined\n return {\n id: String(source.id),\n undoToken: String(source.undoToken),\n commandId: String(source.commandId),\n actionLabel: typeof source.actionLabel === 'string' ? source.actionLabel : null,\n resourceKind: typeof source.resourceKind === 'string' ? source.resourceKind : null,\n resourceId: typeof source.resourceId === 'string' ? source.resourceId : null,\n executedAt: typeof source.executedAt === 'string' ? source.executedAt : new Date((source.receivedAt as number | undefined) || now()).toISOString(),\n receivedAt: typeof source.receivedAt === 'number' ? source.receivedAt : now(),\n ...(bulkTokens && bulkTokens.length > 0 ? { bulkUndoTokens: bulkTokens } : {}),\n ...(bulkCount && bulkCount > 0 ? { bulkCount } : {}),\n }\n}\n\nfunction persist(state: OperationStoreState) {\n if (typeof window === 'undefined') return\n try {\n window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state))\n } catch {\n // ignore storage quota errors\n }\n}\n\nfunction pruneState(state: OperationStoreState): OperationStoreState {\n const timestamp = now()\n const stack = state.stack\n .filter((entry, index, arr) => {\n // Deduplicate by id/undoToken keeping latest\n const duplicateIndex = arr.findIndex((candidate) => candidate.id === entry.id || candidate.undoToken === entry.undoToken)\n if (duplicateIndex !== index) return false\n return timestamp - entry.receivedAt <= STACK_RETENTION_MS\n })\n .sort((a, b) => a.receivedAt - b.receivedAt)\n .slice(-STACK_LIMIT)\n const undone = state.undone\n .filter((entry) => timestamp - entry.undoneAt <= STACK_RETENTION_MS)\n .sort((a, b) => a.undoneAt - b.undoneAt)\n .slice(-STACK_LIMIT)\n const next = { stack, undone }\n return next\n}\n\nfunction emit() {\n emitter.dispatchEvent(new Event('change'))\n}\n\nfunction updateState(updater: (prev: OperationStoreState) => OperationStoreState) {\n const next = pruneState(updater(internalState))\n internalState = next\n persist(next)\n emit()\n}\n\nfunction subscribe(listener: () => void) {\n const wrapped = () => listener()\n emitter.addEventListener('change', wrapped)\n return () => emitter.removeEventListener('change', wrapped)\n}\n\nfunction getClientSnapshot(): OperationStoreState {\n internalState = pruneState(internalState)\n return internalState\n}\n\nexport function useOperationStore<T>(selector: (state: OperationStoreState) => T): T {\n return React.useSyncExternalStore(\n subscribe,\n () => selector(getClientSnapshot()),\n () => selector(DEFAULT_STATE),\n )\n}\n\nexport function pushOperation(meta: OperationMetadataPayload) {\n if (typeof window === 'undefined') return\n updateState((prev) => {\n const entry: OperationEntry = {\n ...meta,\n receivedAt: now(),\n }\n const stack = prev.stack.filter((item) => item.id !== entry.id && item.undoToken !== entry.undoToken)\n stack.push(entry)\n return { stack, undone: [] }\n })\n}\n\nexport function markUndoSuccess(undoTokens: string | string[]) {\n if (typeof window === 'undefined') return\n const tokenSet = new Set(Array.isArray(undoTokens) ? undoTokens : [undoTokens])\n if (tokenSet.size === 0) return\n const removed: OperationEntry[] = []\n updateState((prev) => {\n const nextStack: OperationEntry[] = []\n for (const entry of prev.stack) {\n const bulk = entry.bulkUndoTokens && entry.bulkUndoTokens.length > 0 ? entry.bulkUndoTokens : null\n if (!bulk) {\n if (tokenSet.has(entry.undoToken)) removed.push(entry)\n else nextStack.push(entry)\n continue\n }\n const consumed: string[] = []\n const remaining: string[] = []\n for (const token of bulk) {\n if (tokenSet.has(token)) consumed.push(token)\n else remaining.push(token)\n }\n if (consumed.length === 0) {\n nextStack.push(entry)\n } else if (remaining.length === 0) {\n removed.push(entry)\n } else {\n removed.push({ ...entry, bulkUndoTokens: consumed, bulkCount: consumed.length })\n nextStack.push({ ...entry, bulkUndoTokens: remaining, bulkCount: remaining.length })\n }\n }\n const undone = removed.length\n ? [...prev.undone, ...removed.map((entry) => ({ ...entry, undoneAt: now() }))]\n : prev.undone\n return { stack: nextStack, undone }\n })\n}\n\nexport type CoalesceOptions = {\n commandId?: string\n actionLabel?: string | null\n resourceKind?: string | null\n}\n\nfunction generateBulkId(seed: string): string {\n const cryptoRef = typeof globalThis !== 'undefined' ? (globalThis as { crypto?: { randomUUID?: () => string } }).crypto : undefined\n if (cryptoRef && typeof cryptoRef.randomUUID === 'function') {\n return `bulk:${cryptoRef.randomUUID()}`\n }\n return `bulk:${seed}:${now()}`\n}\n\nexport function coalesceLastOperations(count: number, options: CoalesceOptions = {}): void {\n if (typeof window === 'undefined' || count <= 1) return\n updateState((prev) => {\n if (prev.stack.length < count) return prev\n const tail = prev.stack.slice(-count)\n if (options.commandId && !tail.every((entry) => entry.commandId === options.commandId)) {\n return prev\n }\n const head = prev.stack.slice(0, prev.stack.length - count)\n const last = tail[tail.length - 1]\n const tokens = tail.map((entry) => entry.undoToken)\n const bulkId = generateBulkId(last.id)\n const synthetic: OperationEntry = {\n ...last,\n id: bulkId,\n undoToken: bulkId,\n actionLabel: options.actionLabel ?? last.actionLabel,\n resourceKind: options.resourceKind ?? last.resourceKind,\n resourceId: null,\n bulkUndoTokens: tokens,\n bulkCount: tail.length,\n receivedAt: now(),\n }\n return { stack: [...head, synthetic], undone: prev.undone }\n })\n}\n\nexport function markRedoConsumed(logId: string) {\n if (typeof window === 'undefined') return\n updateState((prev) => ({\n stack: prev.stack,\n undone: prev.undone.filter((entry) => entry.id !== logId),\n }))\n}\n\nexport function getLastOperation(): OperationEntry | null {\n const state = getClientSnapshot()\n if (!state.stack.length) return null\n const last = state.stack[state.stack.length - 1]\n const lastExecuted = Date.parse(last.executedAt)\n const cutoff = now() - LAST_OPERATION_TTL_MS\n if (Number.isFinite(lastExecuted) && lastExecuted < cutoff) return null\n if (!Number.isFinite(lastExecuted) && last.receivedAt < cutoff) return null\n return last\n}\n\nexport function useLastOperation(): OperationEntry | null {\n return useOperationStore(getLastOperationFromState)\n}\n\nfunction getLastOperationFromState(state: OperationStoreState): OperationEntry | null {\n if (!state.stack.length) return null\n const last = state.stack[state.stack.length - 1]\n const timestamp = now()\n const executedAt = Date.parse(last.executedAt)\n const cutoff = timestamp - LAST_OPERATION_TTL_MS\n if (Number.isFinite(executedAt)) {\n return executedAt >= cutoff ? last : null\n }\n return last.receivedAt >= cutoff ? last : null\n}\n\nexport function useRedoCandidate(): UndoneEntry | null {\n return useOperationStore((state) => (state.undone.length ? state.undone[state.undone.length - 1] : null))\n}\n\nexport function hasRedoCandidate(logId: string): boolean {\n const state = getClientSnapshot()\n if (!state.undone.length) return false\n const top = state.undone[state.undone.length - 1]\n return top.id === logId\n}\n\nexport function clearAllOperations() {\n if (typeof window === 'undefined') return\n internalState = DEFAULT_STATE\n persist(internalState)\n emit()\n}\n\nexport const operationStackConstants = {\n LAST_OPERATION_TTL_MS,\n}\n"],
5
- "mappings": ";AACA,YAAY,WAAW;AAkBvB,MAAM,gBAAqC,EAAE,OAAO,CAAC,GAAG,QAAQ,CAAC,EAAE;AAEnE,MAAM,cAAc;AACpB,MAAM,cAAc;AACpB,MAAM,wBAAwB;AAC9B,MAAM,qBAAqB,KAAK;AAEhC,IAAI,gBAAqC;AAEzC,IAAI,OAAO,WAAW,aAAa;AACjC,kBAAgB,UAAU;AAC5B;AAEA,MAAM,UAAU,IAAI,YAAY;AAEhC,SAAS,MAAM;AACb,SAAO,OAAO,gBAAgB,eAAe,YAAY,MACrD,KAAK,MAAM,YAAY,aAAa,YAAY,IAAI,CAAC,IACrD,KAAK,IAAI;AACf;AAEA,SAAS,YAAiC;AACxC,MAAI;AACF,UAAM,MAAM,OAAO,aAAa,QAAQ,WAAW;AACnD,QAAI,CAAC,IAAK,QAAO;AACjB,UAAM,SAAS,KAAK,MAAM,GAAG;AAC7B,QAAI,CAAC,UAAU,OAAO,WAAW,SAAU,QAAO;AAClD,UAAM,QAAQ,MAAM,QAAQ,OAAO,KAAK,IAAI,OAAO,MAAM,OAAO,YAAY,EAAE,IAAI,YAAY,IAAI,CAAC;AACnG,UAAM,SAAS,MAAM,QAAQ,OAAO,MAAM,IACtC,OAAO,OAAO,OAAO,YAAY,EAAE,IAAI,CAACA,SAAiB;AACvD,YAAM,WAAW,aAAaA,IAAG;AACjC,YAAM,YAAYA;AAClB,YAAM,WAAW,OAAO,UAAU,aAAa,WAAW,UAAU,WAAW,IAAI;AACnF,aAAO,EAAE,GAAG,UAAU,SAAS;AAAA,IACjC,CAAC,IACD,CAAC;AACL,WAAO,WAAW,EAAE,OAAO,OAAO,CAAC;AAAA,EACrC,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,aAAa,OAAyC;AAC7D,MAAI,SAAS,QAAQ,OAAO,UAAU,SAAU,QAAO;AACvD,QAAM,YAAY;AAClB,SACE,OAAO,UAAU,OAAO,YACrB,OAAO,UAAU,cAAc,YAC/B,OAAO,UAAU,cAAc,YAC/B,OAAO,UAAU,eAAe,YAChC,OAAO,UAAU,eAAe;AAEvC;AAEA,SAAS,aAAa,OAAgC;AACpD,QAAM,SAAS;AACf,QAAM,aAAa,MAAM,QAAQ,OAAO,cAAc,IAClD,OAAO,eAAe,OAAO,CAAC,MAAmB,OAAO,MAAM,YAAY,EAAE,SAAS,CAAC,IACtF;AACJ,QAAM,YAAY,OAAO,OAAO,cAAc,YAAY,OAAO,SAAS,OAAO,SAAS,IACtF,OAAO,YACP;AACJ,SAAO;AAAA,IACL,IAAI,OAAO,OAAO,EAAE;AAAA,IACpB,WAAW,OAAO,OAAO,SAAS;AAAA,IAClC,WAAW,OAAO,OAAO,SAAS;AAAA,IAClC,aAAa,OAAO,OAAO,gBAAgB,WAAW,OAAO,cAAc;AAAA,IAC3E,cAAc,OAAO,OAAO,iBAAiB,WAAW,OAAO,eAAe;AAAA,IAC9E,YAAY,OAAO,OAAO,eAAe,WAAW,OAAO,aAAa;AAAA,IACxE,YAAY,OAAO,OAAO,eAAe,WAAW,OAAO,aAAa,IAAI,KAAM,OAAO,cAAqC,IAAI,CAAC,EAAE,YAAY;AAAA,IACjJ,YAAY,OAAO,OAAO,eAAe,WAAW,OAAO,aAAa,IAAI;AAAA,IAC5E,GAAI,cAAc,WAAW,SAAS,IAAI,EAAE,gBAAgB,WAAW,IAAI,CAAC;AAAA,IAC5E,GAAI,aAAa,YAAY,IAAI,EAAE,UAAU,IAAI,CAAC;AAAA,EACpD;AACF;AAEA,SAAS,QAAQ,OAA4B;AAC3C,MAAI,OAAO,WAAW,YAAa;AACnC,MAAI;AACF,WAAO,aAAa,QAAQ,aAAa,KAAK,UAAU,KAAK,CAAC;AAAA,EAChE,QAAQ;AAAA,EAER;AACF;AAEA,SAAS,WAAW,OAAiD;AACnE,QAAM,YAAY,IAAI;AACtB,QAAM,QAAQ,MAAM,MACjB,OAAO,CAAC,OAAO,OAAO,QAAQ;AAE7B,UAAM,iBAAiB,IAAI,UAAU,CAAC,cAAc,UAAU,OAAO,MAAM,MAAM,UAAU,cAAc,MAAM,SAAS;AACxH,QAAI,mBAAmB,MAAO,QAAO;AACrC,WAAO,YAAY,MAAM,cAAc;AAAA,EACzC,CAAC,EACA,KAAK,CAAC,GAAG,MAAM,EAAE,aAAa,EAAE,UAAU,EAC1C,MAAM,CAAC,WAAW;AACrB,QAAM,SAAS,MAAM,OAClB,OAAO,CAAC,UAAU,YAAY,MAAM,YAAY,kBAAkB,EAClE,KAAK,CAAC,GAAG,MAAM,EAAE,WAAW,EAAE,QAAQ,EACtC,MAAM,CAAC,WAAW;AACrB,QAAM,OAAO,EAAE,OAAO,OAAO;AAC7B,SAAO;AACT;AAEA,SAAS,OAAO;AACd,UAAQ,cAAc,IAAI,MAAM,QAAQ,CAAC;AAC3C;AAEA,SAAS,YAAY,SAA6D;AAChF,QAAM,OAAO,WAAW,QAAQ,aAAa,CAAC;AAC9C,kBAAgB;AAChB,UAAQ,IAAI;AACZ,OAAK;AACP;AAEA,SAAS,UAAU,UAAsB;AACvC,QAAM,UAAU,MAAM,SAAS;AAC/B,UAAQ,iBAAiB,UAAU,OAAO;AAC1C,SAAO,MAAM,QAAQ,oBAAoB,UAAU,OAAO;AAC5D;AAEA,SAAS,oBAAyC;AAChD,kBAAgB,WAAW,aAAa;AACxC,SAAO;AACT;AAEO,SAAS,kBAAqB,UAAgD;AACnF,SAAO,MAAM;AAAA,IACX;AAAA,IACA,MAAM,SAAS,kBAAkB,CAAC;AAAA,IAClC,MAAM,SAAS,aAAa;AAAA,EAC9B;AACF;AAEO,SAAS,cAAc,MAAgC;AAC5D,MAAI,OAAO,WAAW,YAAa;AACnC,cAAY,CAAC,SAAS;AACpB,UAAM,QAAwB;AAAA,MAC5B,GAAG;AAAA,MACH,YAAY,IAAI;AAAA,IAClB;AACA,UAAM,QAAQ,KAAK,MAAM,OAAO,CAAC,SAAS,KAAK,OAAO,MAAM,MAAM,KAAK,cAAc,MAAM,SAAS;AACpG,UAAM,KAAK,KAAK;AAChB,WAAO,EAAE,OAAO,QAAQ,CAAC,EAAE;AAAA,EAC7B,CAAC;AACH;AAEO,SAAS,gBAAgB,YAA+B;AAC7D,MAAI,OAAO,WAAW,YAAa;AACnC,QAAM,WAAW,IAAI,IAAI,MAAM,QAAQ,UAAU,IAAI,aAAa,CAAC,UAAU,CAAC;AAC9E,MAAI,SAAS,SAAS,EAAG;AACzB,QAAM,UAA4B,CAAC;AACnC,cAAY,CAAC,SAAS;AACpB,UAAM,YAA8B,CAAC;AACrC,eAAW,SAAS,KAAK,OAAO;AAC9B,YAAM,OAAO,MAAM,kBAAkB,MAAM,eAAe,SAAS,IAAI,MAAM,iBAAiB;AAC9F,UAAI,CAAC,MAAM;AACT,YAAI,SAAS,IAAI,MAAM,SAAS,EAAG,SAAQ,KAAK,KAAK;AAAA,YAChD,WAAU,KAAK,KAAK;AACzB;AAAA,MACF;AACA,YAAM,WAAqB,CAAC;AAC5B,YAAM,YAAsB,CAAC;AAC7B,iBAAW,SAAS,MAAM;AACxB,YAAI,SAAS,IAAI,KAAK,EAAG,UAAS,KAAK,KAAK;AAAA,YACvC,WAAU,KAAK,KAAK;AAAA,MAC3B;AACA,UAAI,SAAS,WAAW,GAAG;AACzB,kBAAU,KAAK,KAAK;AAAA,MACtB,WAAW,UAAU,WAAW,GAAG;AACjC,gBAAQ,KAAK,KAAK;AAAA,MACpB,OAAO;AACL,gBAAQ,KAAK,EAAE,GAAG,OAAO,gBAAgB,UAAU,WAAW,SAAS,OAAO,CAAC;AAC/E,kBAAU,KAAK,EAAE,GAAG,OAAO,gBAAgB,WAAW,WAAW,UAAU,OAAO,CAAC;AAAA,MACrF;AAAA,IACF;AACA,UAAM,SAAS,QAAQ,SACnB,CAAC,GAAG,KAAK,QAAQ,GAAG,QAAQ,IAAI,CAAC,WAAW,EAAE,GAAG,OAAO,UAAU,IAAI,EAAE,EAAE,CAAC,IAC3E,KAAK;AACT,WAAO,EAAE,OAAO,WAAW,OAAO;AAAA,EACpC,CAAC;AACH;AAQA,SAAS,eAAe,MAAsB;AAC5C,QAAM,YAAY,OAAO,eAAe,cAAe,WAA0D,SAAS;AAC1H,MAAI,aAAa,OAAO,UAAU,eAAe,YAAY;AAC3D,WAAO,QAAQ,UAAU,WAAW,CAAC;AAAA,EACvC;AACA,SAAO,QAAQ,IAAI,IAAI,IAAI,CAAC;AAC9B;AAEO,SAAS,uBAAuB,OAAe,UAA2B,CAAC,GAAS;AACzF,MAAI,OAAO,WAAW,eAAe,SAAS,EAAG;AACjD,cAAY,CAAC,SAAS;AACpB,QAAI,KAAK,MAAM,SAAS,MAAO,QAAO;AACtC,UAAM,OAAO,KAAK,MAAM,MAAM,CAAC,KAAK;AACpC,QAAI,QAAQ,aAAa,CAAC,KAAK,MAAM,CAAC,UAAU,MAAM,cAAc,QAAQ,SAAS,GAAG;AACtF,aAAO;AAAA,IACT;AACA,UAAM,OAAO,KAAK,MAAM,MAAM,GAAG,KAAK,MAAM,SAAS,KAAK;AAC1D,UAAM,OAAO,KAAK,KAAK,SAAS,CAAC;AACjC,UAAM,SAAS,KAAK,IAAI,CAAC,UAAU,MAAM,SAAS;AAClD,UAAM,SAAS,eAAe,KAAK,EAAE;AACrC,UAAM,YAA4B;AAAA,MAChC,GAAG;AAAA,MACH,IAAI;AAAA,MACJ,WAAW;AAAA,MACX,aAAa,QAAQ,eAAe,KAAK;AAAA,MACzC,cAAc,QAAQ,gBAAgB,KAAK;AAAA,MAC3C,YAAY;AAAA,MACZ,gBAAgB;AAAA,MAChB,WAAW,KAAK;AAAA,MAChB,YAAY,IAAI;AAAA,IAClB;AACA,WAAO,EAAE,OAAO,CAAC,GAAG,MAAM,SAAS,GAAG,QAAQ,KAAK,OAAO;AAAA,EAC5D,CAAC;AACH;AAEO,SAAS,iBAAiB,OAAe;AAC9C,MAAI,OAAO,WAAW,YAAa;AACnC,cAAY,CAAC,UAAU;AAAA,IACrB,OAAO,KAAK;AAAA,IACZ,QAAQ,KAAK,OAAO,OAAO,CAAC,UAAU,MAAM,OAAO,KAAK;AAAA,EAC1D,EAAE;AACJ;AAEO,SAAS,mBAA0C;AACxD,QAAM,QAAQ,kBAAkB;AAChC,MAAI,CAAC,MAAM,MAAM,OAAQ,QAAO;AAChC,QAAM,OAAO,MAAM,MAAM,MAAM,MAAM,SAAS,CAAC;AAC/C,QAAM,eAAe,KAAK,MAAM,KAAK,UAAU;AAC/C,QAAM,SAAS,IAAI,IAAI;AACvB,MAAI,OAAO,SAAS,YAAY,KAAK,eAAe,OAAQ,QAAO;AACnE,MAAI,CAAC,OAAO,SAAS,YAAY,KAAK,KAAK,aAAa,OAAQ,QAAO;AACvE,SAAO;AACT;AAEO,SAAS,mBAA0C;AACxD,SAAO,kBAAkB,yBAAyB;AACpD;AAEA,SAAS,0BAA0B,OAAmD;AACpF,MAAI,CAAC,MAAM,MAAM,OAAQ,QAAO;AAChC,QAAM,OAAO,MAAM,MAAM,MAAM,MAAM,SAAS,CAAC;AAC/C,QAAM,YAAY,IAAI;AACtB,QAAM,aAAa,KAAK,MAAM,KAAK,UAAU;AAC7C,QAAM,SAAS,YAAY;AAC3B,MAAI,OAAO,SAAS,UAAU,GAAG;AAC/B,WAAO,cAAc,SAAS,OAAO;AAAA,EACvC;AACA,SAAO,KAAK,cAAc,SAAS,OAAO;AAC5C;AAEO,SAAS,mBAAuC;AACrD,SAAO,kBAAkB,CAAC,UAAW,MAAM,OAAO,SAAS,MAAM,OAAO,MAAM,OAAO,SAAS,CAAC,IAAI,IAAK;AAC1G;AAEO,SAAS,iBAAiB,OAAwB;AACvD,QAAM,QAAQ,kBAAkB;AAChC,MAAI,CAAC,MAAM,OAAO,OAAQ,QAAO;AACjC,QAAM,MAAM,MAAM,OAAO,MAAM,OAAO,SAAS,CAAC;AAChD,SAAO,IAAI,OAAO;AACpB;AAEO,SAAS,qBAAqB;AACnC,MAAI,OAAO,WAAW,YAAa;AACnC,kBAAgB;AAChB,UAAQ,aAAa;AACrB,OAAK;AACP;AAEO,MAAM,0BAA0B;AAAA,EACrC;AACF;",
4
+ "sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport type { OperationMetadataPayload } from '@open-mercato/shared/lib/commands/operationMetadata'\n\nexport type OperationEntry = OperationMetadataPayload & {\n receivedAt: number\n bulkUndoTokens?: string[]\n bulkCount?: number\n}\n\nexport type UndoneEntry = OperationEntry & {\n undoneAt: number\n}\n\ntype OperationStoreState = {\n stack: OperationEntry[]\n undone: UndoneEntry[]\n}\n\nconst DEFAULT_STATE: OperationStoreState = { stack: [], undone: [] }\n\nconst STORAGE_KEY = 'om:last-operations:v1'\nconst STACK_LIMIT = 20\nconst LAST_OPERATION_TTL_MS = 60_000\nconst DEFAULT_LAST_OPERATION_AUTO_DISMISS_MS = 10_000\n\nfunction resolveAutoDismissMs(raw: string | undefined): number {\n if (raw == null || raw === '') return DEFAULT_LAST_OPERATION_AUTO_DISMISS_MS\n const parsed = Number(raw)\n if (!Number.isFinite(parsed) || parsed <= 0) {\n return DEFAULT_LAST_OPERATION_AUTO_DISMISS_MS\n }\n return Math.floor(parsed)\n}\n\nconst LAST_OPERATION_AUTO_DISMISS_MS = resolveAutoDismissMs(\n process.env.NEXT_PUBLIC_OM_UNDO_BANNER_TIMEOUT_MS,\n)\nconst STACK_RETENTION_MS = 10 * 60_000\n\nlet internalState: OperationStoreState = DEFAULT_STATE\n\nif (typeof window !== 'undefined') {\n internalState = loadState()\n}\n\nconst emitter = new EventTarget()\n\nfunction now() {\n return typeof performance !== 'undefined' && performance.now\n ? Math.round(performance.timeOrigin + performance.now())\n : Date.now()\n}\n\nfunction loadState(): OperationStoreState {\n try {\n const raw = window.localStorage.getItem(STORAGE_KEY)\n if (!raw) return DEFAULT_STATE\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') return DEFAULT_STATE\n const stack = Array.isArray(parsed.stack) ? parsed.stack.filter(isValidEntry).map(hydrateEntry) : []\n const undone = Array.isArray(parsed.undone)\n ? parsed.undone.filter(isValidEntry).map((raw: unknown) => {\n const hydrated = hydrateEntry(raw)\n const candidate = raw as { undoneAt?: unknown }\n const undoneAt = typeof candidate.undoneAt === 'number' ? candidate.undoneAt : now()\n return { ...hydrated, undoneAt }\n })\n : []\n return pruneState({ stack, undone })\n } catch {\n return DEFAULT_STATE\n }\n}\n\nfunction isValidEntry(entry: unknown): entry is OperationEntry {\n if (entry == null || typeof entry !== 'object') return false\n const candidate = entry as Record<string, unknown>\n return (\n typeof candidate.id === 'string'\n && typeof candidate.undoToken === 'string'\n && typeof candidate.commandId === 'string'\n && typeof candidate.receivedAt === 'number'\n && typeof candidate.executedAt === 'string'\n )\n}\n\nfunction hydrateEntry(entry: unknown): OperationEntry {\n const source = entry as Partial<OperationEntry> & Record<string, unknown>\n const bulkTokens = Array.isArray(source.bulkUndoTokens)\n ? source.bulkUndoTokens.filter((t): t is string => typeof t === 'string' && t.length > 0)\n : undefined\n const bulkCount = typeof source.bulkCount === 'number' && Number.isFinite(source.bulkCount)\n ? source.bulkCount\n : undefined\n return {\n id: String(source.id),\n undoToken: String(source.undoToken),\n commandId: String(source.commandId),\n actionLabel: typeof source.actionLabel === 'string' ? source.actionLabel : null,\n resourceKind: typeof source.resourceKind === 'string' ? source.resourceKind : null,\n resourceId: typeof source.resourceId === 'string' ? source.resourceId : null,\n executedAt: typeof source.executedAt === 'string' ? source.executedAt : new Date((source.receivedAt as number | undefined) || now()).toISOString(),\n receivedAt: typeof source.receivedAt === 'number' ? source.receivedAt : now(),\n ...(bulkTokens && bulkTokens.length > 0 ? { bulkUndoTokens: bulkTokens } : {}),\n ...(bulkCount && bulkCount > 0 ? { bulkCount } : {}),\n }\n}\n\nfunction persist(state: OperationStoreState) {\n if (typeof window === 'undefined') return\n try {\n window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state))\n } catch {\n // ignore storage quota errors\n }\n}\n\nfunction pruneState(state: OperationStoreState): OperationStoreState {\n const timestamp = now()\n const stack = state.stack\n .filter((entry, index, arr) => {\n // Deduplicate by id/undoToken keeping latest\n const duplicateIndex = arr.findIndex((candidate) => candidate.id === entry.id || candidate.undoToken === entry.undoToken)\n if (duplicateIndex !== index) return false\n return timestamp - entry.receivedAt <= STACK_RETENTION_MS\n })\n .sort((a, b) => a.receivedAt - b.receivedAt)\n .slice(-STACK_LIMIT)\n const undone = state.undone\n .filter((entry) => timestamp - entry.undoneAt <= STACK_RETENTION_MS)\n .sort((a, b) => a.undoneAt - b.undoneAt)\n .slice(-STACK_LIMIT)\n const next = { stack, undone }\n return next\n}\n\nfunction emit() {\n emitter.dispatchEvent(new Event('change'))\n}\n\nfunction updateState(updater: (prev: OperationStoreState) => OperationStoreState) {\n const next = pruneState(updater(internalState))\n internalState = next\n persist(next)\n emit()\n}\n\nfunction subscribe(listener: () => void) {\n const wrapped = () => listener()\n emitter.addEventListener('change', wrapped)\n return () => emitter.removeEventListener('change', wrapped)\n}\n\nfunction getClientSnapshot(): OperationStoreState {\n internalState = pruneState(internalState)\n return internalState\n}\n\nexport function useOperationStore<T>(selector: (state: OperationStoreState) => T): T {\n return React.useSyncExternalStore(\n subscribe,\n () => selector(getClientSnapshot()),\n () => selector(DEFAULT_STATE),\n )\n}\n\nexport function pushOperation(meta: OperationMetadataPayload) {\n if (typeof window === 'undefined') return\n updateState((prev) => {\n const entry: OperationEntry = {\n ...meta,\n receivedAt: now(),\n }\n const stack = prev.stack.filter((item) => item.id !== entry.id && item.undoToken !== entry.undoToken)\n stack.push(entry)\n return { stack, undone: [] }\n })\n}\n\nexport function markUndoSuccess(undoTokens: string | string[]) {\n if (typeof window === 'undefined') return\n const tokenSet = new Set(Array.isArray(undoTokens) ? undoTokens : [undoTokens])\n if (tokenSet.size === 0) return\n const removed: OperationEntry[] = []\n updateState((prev) => {\n const nextStack: OperationEntry[] = []\n for (const entry of prev.stack) {\n const bulk = entry.bulkUndoTokens && entry.bulkUndoTokens.length > 0 ? entry.bulkUndoTokens : null\n if (!bulk) {\n if (tokenSet.has(entry.undoToken)) removed.push(entry)\n else nextStack.push(entry)\n continue\n }\n const consumed: string[] = []\n const remaining: string[] = []\n for (const token of bulk) {\n if (tokenSet.has(token)) consumed.push(token)\n else remaining.push(token)\n }\n if (consumed.length === 0) {\n nextStack.push(entry)\n } else if (remaining.length === 0) {\n removed.push(entry)\n } else {\n removed.push({ ...entry, bulkUndoTokens: consumed, bulkCount: consumed.length })\n nextStack.push({ ...entry, bulkUndoTokens: remaining, bulkCount: remaining.length })\n }\n }\n const undone = removed.length\n ? [...prev.undone, ...removed.map((entry) => ({ ...entry, undoneAt: now() }))]\n : prev.undone\n return { stack: nextStack, undone }\n })\n}\n\nexport function dismissOperation(undoTokens: string | string[]) {\n if (typeof window === 'undefined') return\n const tokenSet = new Set(Array.isArray(undoTokens) ? undoTokens : [undoTokens])\n if (tokenSet.size === 0) return\n updateState((prev) => {\n const nextStack: OperationEntry[] = []\n for (const entry of prev.stack) {\n if (tokenSet.has(entry.undoToken)) continue\n const bulk = entry.bulkUndoTokens && entry.bulkUndoTokens.length > 0 ? entry.bulkUndoTokens : null\n if (!bulk) {\n nextStack.push(entry)\n continue\n }\n const remaining = bulk.filter((token) => !tokenSet.has(token))\n if (remaining.length === bulk.length) {\n nextStack.push(entry)\n } else if (remaining.length > 0) {\n nextStack.push({ ...entry, bulkUndoTokens: remaining, bulkCount: remaining.length })\n }\n }\n return { stack: nextStack, undone: prev.undone }\n })\n}\n\nexport type CoalesceOptions = {\n commandId?: string\n actionLabel?: string | null\n resourceKind?: string | null\n}\n\nfunction generateBulkId(seed: string): string {\n const cryptoRef = typeof globalThis !== 'undefined' ? (globalThis as { crypto?: { randomUUID?: () => string } }).crypto : undefined\n if (cryptoRef && typeof cryptoRef.randomUUID === 'function') {\n return `bulk:${cryptoRef.randomUUID()}`\n }\n return `bulk:${seed}:${now()}`\n}\n\nexport function coalesceLastOperations(count: number, options: CoalesceOptions = {}): void {\n if (typeof window === 'undefined' || count <= 1) return\n updateState((prev) => {\n if (prev.stack.length < count) return prev\n const tail = prev.stack.slice(-count)\n if (options.commandId && !tail.every((entry) => entry.commandId === options.commandId)) {\n return prev\n }\n const head = prev.stack.slice(0, prev.stack.length - count)\n const last = tail[tail.length - 1]\n const tokens = tail.map((entry) => entry.undoToken)\n const bulkId = generateBulkId(last.id)\n const synthetic: OperationEntry = {\n ...last,\n id: bulkId,\n undoToken: bulkId,\n actionLabel: options.actionLabel ?? last.actionLabel,\n resourceKind: options.resourceKind ?? last.resourceKind,\n resourceId: null,\n bulkUndoTokens: tokens,\n bulkCount: tail.length,\n receivedAt: now(),\n }\n return { stack: [...head, synthetic], undone: prev.undone }\n })\n}\n\nexport function markRedoConsumed(logId: string) {\n if (typeof window === 'undefined') return\n updateState((prev) => ({\n stack: prev.stack,\n undone: prev.undone.filter((entry) => entry.id !== logId),\n }))\n}\n\nexport function getLastOperation(): OperationEntry | null {\n const state = getClientSnapshot()\n if (!state.stack.length) return null\n const last = state.stack[state.stack.length - 1]\n const lastExecuted = Date.parse(last.executedAt)\n const cutoff = now() - LAST_OPERATION_TTL_MS\n if (Number.isFinite(lastExecuted) && lastExecuted < cutoff) return null\n if (!Number.isFinite(lastExecuted) && last.receivedAt < cutoff) return null\n return last\n}\n\nexport function useLastOperation(): OperationEntry | null {\n return useOperationStore(getLastOperationFromState)\n}\n\nfunction getLastOperationFromState(state: OperationStoreState): OperationEntry | null {\n if (!state.stack.length) return null\n const last = state.stack[state.stack.length - 1]\n const timestamp = now()\n const executedAt = Date.parse(last.executedAt)\n const cutoff = timestamp - LAST_OPERATION_TTL_MS\n if (Number.isFinite(executedAt)) {\n return executedAt >= cutoff ? last : null\n }\n return last.receivedAt >= cutoff ? last : null\n}\n\nexport function useRedoCandidate(): UndoneEntry | null {\n return useOperationStore((state) => (state.undone.length ? state.undone[state.undone.length - 1] : null))\n}\n\nexport function hasRedoCandidate(logId: string): boolean {\n const state = getClientSnapshot()\n if (!state.undone.length) return false\n const top = state.undone[state.undone.length - 1]\n return top.id === logId\n}\n\nexport function clearAllOperations() {\n if (typeof window === 'undefined') return\n internalState = DEFAULT_STATE\n persist(internalState)\n emit()\n}\n\nexport const operationStackConstants = {\n LAST_OPERATION_TTL_MS,\n LAST_OPERATION_AUTO_DISMISS_MS,\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["raw"]
7
7
  }
@@ -38,7 +38,12 @@ const PasswordInput = React.forwardRef(
38
38
  ref,
39
39
  type: revealed ? "text" : "password",
40
40
  disabled,
41
- className: cn(inputElementVariants({ size }), inputClassName),
41
+ className: cn(
42
+ inputElementVariants({ size }),
43
+ // Suppress Edge/IE native password reveal + clear controls — we render our own toggle.
44
+ "[&::-ms-reveal]:hidden [&::-ms-clear]:hidden",
45
+ inputClassName
46
+ ),
42
47
  ...props
43
48
  }
44
49
  ),
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/primitives/password-input.tsx"],
4
- "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Eye, EyeOff, Lock } from 'lucide-react'\nimport type { VariantProps } from 'class-variance-authority'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { inputWrapperVariants, inputElementVariants } from './input'\n\nexport type PasswordInputProps = Omit<React.ComponentPropsWithoutRef<'input'>, 'size' | 'type'> &\n VariantProps<typeof inputWrapperVariants> & {\n /** Render the leading lock icon per Figma `Text Input [1.1]` Password variant. Defaults to `true`. */\n showLockIcon?: boolean\n /** Allow the user to toggle reveal (eye / eye-off). Defaults to `true`. */\n revealable?: boolean\n /** Optional controlled reveal state (otherwise managed internally). */\n revealed?: boolean\n /** Called when the reveal state changes. */\n onRevealedChange?: (next: boolean) => void\n /** Optional className on the wrapper. */\n className?: string\n /** Optional className on the inner `<input>`. */\n inputClassName?: string\n /** Translated aria-label for the reveal button when password is hidden. */\n showLabel?: string\n /** Translated aria-label for the reveal button when password is shown. */\n hideLabel?: string\n }\n\n/**\n * Password input matching Figma `Text Input [1.1]` (node `266:5251`) **Password** variant \u2014 a\n * trailing `Eye` / `EyeOff` toggle that switches the inner `<input>`'s `type` between `\"password\"`\n * (default) and `\"text\"`. The toggle is a proper `<button>` (focusable, screen-reader labelled).\n *\n * Internally managed reveal state when `revealed` / `onRevealedChange` are not passed. Otherwise\n * the consumer owns the toggle state (useful for \"show password\" master toggle on login screens).\n */\nexport const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(\n (\n {\n className,\n inputClassName,\n size,\n showLockIcon = true,\n revealable = true,\n revealed: revealedProp,\n onRevealedChange,\n showLabel,\n hideLabel,\n disabled,\n ...props\n },\n ref,\n ) => {\n const t = useT()\n const resolvedShowLabel = showLabel ?? t('ui.inputs.passwordInput.show', 'Show password')\n const resolvedHideLabel = hideLabel ?? t('ui.inputs.passwordInput.hide', 'Hide password')\n const [internalRevealed, setInternalRevealed] = React.useState(false)\n const isControlled = revealedProp !== undefined\n const revealed = isControlled ? revealedProp! : internalRevealed\n\n const toggleRevealed = React.useCallback(() => {\n const next = !revealed\n if (!isControlled) setInternalRevealed(next)\n onRevealedChange?.(next)\n }, [revealed, isControlled, onRevealedChange])\n\n return (\n <div className={cn(inputWrapperVariants({ size }), className)} data-slot=\"password-input-wrapper\">\n {showLockIcon ? (\n <Lock className=\"size-4 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n ) : null}\n <input\n ref={ref}\n type={revealed ? 'text' : 'password'}\n disabled={disabled}\n className={cn(inputElementVariants({ size }), inputClassName)}\n {...props}\n />\n {revealable ? (\n <button\n type=\"button\"\n onClick={toggleRevealed}\n aria-label={revealed ? resolvedHideLabel : resolvedShowLabel}\n aria-pressed={revealed}\n disabled={disabled}\n className=\"flex shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {revealed ? (\n <EyeOff className=\"size-4\" aria-hidden=\"true\" />\n ) : (\n <Eye className=\"size-4\" aria-hidden=\"true\" />\n )}\n </button>\n ) : null}\n </div>\n )\n },\n)\nPasswordInput.displayName = 'PasswordInput'\n"],
5
- "mappings": ";AAoEM,SAEI,KAFJ;AAlEN,YAAY,WAAW;AACvB,SAAS,KAAK,QAAQ,YAAY;AAElC,SAAS,YAAY;AACrB,SAAS,UAAU;AACnB,SAAS,sBAAsB,4BAA4B;AA8BpD,MAAM,gBAAgB,MAAM;AAAA,EACjC,CACE;AAAA,IACE;AAAA,IACA;AAAA,IACA;AAAA,IACA,eAAe;AAAA,IACf,aAAa;AAAA,IACb,UAAU;AAAA,IACV;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,GAAG;AAAA,EACL,GACA,QACG;AACH,UAAM,IAAI,KAAK;AACf,UAAM,oBAAoB,aAAa,EAAE,gCAAgC,eAAe;AACxF,UAAM,oBAAoB,aAAa,EAAE,gCAAgC,eAAe;AACxF,UAAM,CAAC,kBAAkB,mBAAmB,IAAI,MAAM,SAAS,KAAK;AACpE,UAAM,eAAe,iBAAiB;AACtC,UAAM,WAAW,eAAe,eAAgB;AAEhD,UAAM,iBAAiB,MAAM,YAAY,MAAM;AAC7C,YAAM,OAAO,CAAC;AACd,UAAI,CAAC,aAAc,qBAAoB,IAAI;AAC3C,yBAAmB,IAAI;AAAA,IACzB,GAAG,CAAC,UAAU,cAAc,gBAAgB,CAAC;AAE7C,WACE,qBAAC,SAAI,WAAW,GAAG,qBAAqB,EAAE,KAAK,CAAC,GAAG,SAAS,GAAG,aAAU,0BACtE;AAAA,qBACC,oBAAC,QAAK,WAAU,yCAAwC,eAAY,QAAO,IACzE;AAAA,MACJ;AAAA,QAAC;AAAA;AAAA,UACC;AAAA,UACA,MAAM,WAAW,SAAS;AAAA,UAC1B;AAAA,UACA,WAAW,GAAG,qBAAqB,EAAE,KAAK,CAAC,GAAG,cAAc;AAAA,UAC3D,GAAG;AAAA;AAAA,MACN;AAAA,MACC,aACC;AAAA,QAAC;AAAA;AAAA,UACC,MAAK;AAAA,UACL,SAAS;AAAA,UACT,cAAY,WAAW,oBAAoB;AAAA,UAC3C,gBAAc;AAAA,UACd;AAAA,UACA,WAAU;AAAA,UAET,qBACC,oBAAC,UAAO,WAAU,UAAS,eAAY,QAAO,IAE9C,oBAAC,OAAI,WAAU,UAAS,eAAY,QAAO;AAAA;AAAA,MAE/C,IACE;AAAA,OACN;AAAA,EAEJ;AACF;AACA,cAAc,cAAc;",
4
+ "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Eye, EyeOff, Lock } from 'lucide-react'\nimport type { VariantProps } from 'class-variance-authority'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { inputWrapperVariants, inputElementVariants } from './input'\n\nexport type PasswordInputProps = Omit<React.ComponentPropsWithoutRef<'input'>, 'size' | 'type'> &\n VariantProps<typeof inputWrapperVariants> & {\n /** Render the leading lock icon per Figma `Text Input [1.1]` Password variant. Defaults to `true`. */\n showLockIcon?: boolean\n /** Allow the user to toggle reveal (eye / eye-off). Defaults to `true`. */\n revealable?: boolean\n /** Optional controlled reveal state (otherwise managed internally). */\n revealed?: boolean\n /** Called when the reveal state changes. */\n onRevealedChange?: (next: boolean) => void\n /** Optional className on the wrapper. */\n className?: string\n /** Optional className on the inner `<input>`. */\n inputClassName?: string\n /** Translated aria-label for the reveal button when password is hidden. */\n showLabel?: string\n /** Translated aria-label for the reveal button when password is shown. */\n hideLabel?: string\n }\n\n/**\n * Password input matching Figma `Text Input [1.1]` (node `266:5251`) **Password** variant \u2014 a\n * trailing `Eye` / `EyeOff` toggle that switches the inner `<input>`'s `type` between `\"password\"`\n * (default) and `\"text\"`. The toggle is a proper `<button>` (focusable, screen-reader labelled).\n *\n * Internally managed reveal state when `revealed` / `onRevealedChange` are not passed. Otherwise\n * the consumer owns the toggle state (useful for \"show password\" master toggle on login screens).\n */\nexport const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(\n (\n {\n className,\n inputClassName,\n size,\n showLockIcon = true,\n revealable = true,\n revealed: revealedProp,\n onRevealedChange,\n showLabel,\n hideLabel,\n disabled,\n ...props\n },\n ref,\n ) => {\n const t = useT()\n const resolvedShowLabel = showLabel ?? t('ui.inputs.passwordInput.show', 'Show password')\n const resolvedHideLabel = hideLabel ?? t('ui.inputs.passwordInput.hide', 'Hide password')\n const [internalRevealed, setInternalRevealed] = React.useState(false)\n const isControlled = revealedProp !== undefined\n const revealed = isControlled ? revealedProp! : internalRevealed\n\n const toggleRevealed = React.useCallback(() => {\n const next = !revealed\n if (!isControlled) setInternalRevealed(next)\n onRevealedChange?.(next)\n }, [revealed, isControlled, onRevealedChange])\n\n return (\n <div className={cn(inputWrapperVariants({ size }), className)} data-slot=\"password-input-wrapper\">\n {showLockIcon ? (\n <Lock className=\"size-4 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n ) : null}\n <input\n ref={ref}\n type={revealed ? 'text' : 'password'}\n disabled={disabled}\n className={cn(\n inputElementVariants({ size }),\n // Suppress Edge/IE native password reveal + clear controls \u2014 we render our own toggle.\n '[&::-ms-reveal]:hidden [&::-ms-clear]:hidden',\n inputClassName,\n )}\n {...props}\n />\n {revealable ? (\n <button\n type=\"button\"\n onClick={toggleRevealed}\n aria-label={revealed ? resolvedHideLabel : resolvedShowLabel}\n aria-pressed={revealed}\n disabled={disabled}\n className=\"flex shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n {revealed ? (\n <EyeOff className=\"size-4\" aria-hidden=\"true\" />\n ) : (\n <Eye className=\"size-4\" aria-hidden=\"true\" />\n )}\n </button>\n ) : null}\n </div>\n )\n },\n)\nPasswordInput.displayName = 'PasswordInput'\n"],
5
+ "mappings": ";AAoEM,SAEI,KAFJ;AAlEN,YAAY,WAAW;AACvB,SAAS,KAAK,QAAQ,YAAY;AAElC,SAAS,YAAY;AACrB,SAAS,UAAU;AACnB,SAAS,sBAAsB,4BAA4B;AA8BpD,MAAM,gBAAgB,MAAM;AAAA,EACjC,CACE;AAAA,IACE;AAAA,IACA;AAAA,IACA;AAAA,IACA,eAAe;AAAA,IACf,aAAa;AAAA,IACb,UAAU;AAAA,IACV;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,GAAG;AAAA,EACL,GACA,QACG;AACH,UAAM,IAAI,KAAK;AACf,UAAM,oBAAoB,aAAa,EAAE,gCAAgC,eAAe;AACxF,UAAM,oBAAoB,aAAa,EAAE,gCAAgC,eAAe;AACxF,UAAM,CAAC,kBAAkB,mBAAmB,IAAI,MAAM,SAAS,KAAK;AACpE,UAAM,eAAe,iBAAiB;AACtC,UAAM,WAAW,eAAe,eAAgB;AAEhD,UAAM,iBAAiB,MAAM,YAAY,MAAM;AAC7C,YAAM,OAAO,CAAC;AACd,UAAI,CAAC,aAAc,qBAAoB,IAAI;AAC3C,yBAAmB,IAAI;AAAA,IACzB,GAAG,CAAC,UAAU,cAAc,gBAAgB,CAAC;AAE7C,WACE,qBAAC,SAAI,WAAW,GAAG,qBAAqB,EAAE,KAAK,CAAC,GAAG,SAAS,GAAG,aAAU,0BACtE;AAAA,qBACC,oBAAC,QAAK,WAAU,yCAAwC,eAAY,QAAO,IACzE;AAAA,MACJ;AAAA,QAAC;AAAA;AAAA,UACC;AAAA,UACA,MAAM,WAAW,SAAS;AAAA,UAC1B;AAAA,UACA,WAAW;AAAA,YACT,qBAAqB,EAAE,KAAK,CAAC;AAAA;AAAA,YAE7B;AAAA,YACA;AAAA,UACF;AAAA,UACC,GAAG;AAAA;AAAA,MACN;AAAA,MACC,aACC;AAAA,QAAC;AAAA;AAAA,UACC,MAAK;AAAA,UACL,SAAS;AAAA,UACT,cAAY,WAAW,oBAAoB;AAAA,UAC3C,gBAAc;AAAA,UACd;AAAA,UACA,WAAU;AAAA,UAET,qBACC,oBAAC,UAAO,WAAU,UAAS,eAAY,QAAO,IAE9C,oBAAC,OAAI,WAAU,UAAS,eAAY,QAAO;AAAA;AAAA,MAE/C,IACE;AAAA,OACN;AAAA,EAEJ;AACF;AACA,cAAc,cAAc;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ui",
3
- "version": "0.6.3-develop.3766.1.33102bfc91",
3
+ "version": "0.6.3-develop.3778.1.25fdb35f2e",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -132,8 +132,8 @@
132
132
  "@radix-ui/react-radio-group": "^1.2.3",
133
133
  "@radix-ui/react-select": "^2.1.6",
134
134
  "@radix-ui/react-tooltip": "^1.2.8",
135
- "@tanstack/react-virtual": "^3.13.24",
136
- "date-fns": "4.2.1",
135
+ "@tanstack/react-virtual": "^3.13.25",
136
+ "date-fns": "4.3.0",
137
137
  "react-big-calendar": "^1.19.4",
138
138
  "react-day-picker": "^10.0.1",
139
139
  "react-markdown": "^10.1.0",
@@ -141,25 +141,25 @@
141
141
  "remark-gfm": "^4.0.1"
142
142
  },
143
143
  "peerDependencies": {
144
- "@open-mercato/shared": "0.6.3-develop.3766.1.33102bfc91",
144
+ "@open-mercato/shared": "0.6.3-develop.3778.1.25fdb35f2e",
145
145
  "react": ">=18.0.0",
146
146
  "react-dom": ">=18.0.0",
147
147
  "react-is": ">=18.0.0"
148
148
  },
149
149
  "devDependencies": {
150
- "@open-mercato/shared": "0.6.3-develop.3766.1.33102bfc91",
150
+ "@open-mercato/shared": "0.6.3-develop.3778.1.25fdb35f2e",
151
151
  "@testing-library/dom": "^10.4.1",
152
152
  "@testing-library/jest-dom": "^6.9.1",
153
153
  "@testing-library/react": "^16.3.1",
154
154
  "@types/jest": "^30.0.0",
155
- "@types/react": "^19.2.14",
155
+ "@types/react": "^19.2.15",
156
156
  "@types/react-dom": "^19.2.3",
157
157
  "jest": "^30.4.2",
158
158
  "jest-environment-jsdom": "^30.4.1",
159
159
  "react": "19.2.6",
160
160
  "react-dom": "19.2.6",
161
161
  "react-is": "^19.2.6",
162
- "ts-jest": "^29.4.9"
162
+ "ts-jest": "^29.4.11"
163
163
  },
164
164
  "publishConfig": {
165
165
  "access": "public"
@@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation'
5
5
  import { Button } from '../../primitives/button'
6
6
  import { apiCall } from '../utils/apiCall'
7
7
  import { flash } from '../FlashMessages'
8
- import { useLastOperation, markUndoSuccess } from './store'
8
+ import { useLastOperation, markUndoSuccess, dismissOperation, operationStackConstants } from './store'
9
9
  import { useT } from '@open-mercato/shared/lib/i18n/context'
10
10
 
11
11
  export function LastOperationBanner() {
@@ -14,12 +14,22 @@ export function LastOperationBanner() {
14
14
  const [pendingToken, setPendingToken] = React.useState<string | null>(null)
15
15
  const router = useRouter()
16
16
 
17
+ const undoToken = operation?.undoToken ?? null
18
+ const isPending = undoToken !== null && pendingToken === undoToken
19
+
20
+ React.useEffect(() => {
21
+ if (!undoToken || isPending) return
22
+ const timer = setTimeout(() => {
23
+ dismissOperation(undoToken)
24
+ }, operationStackConstants.LAST_OPERATION_AUTO_DISMISS_MS)
25
+ return () => clearTimeout(timer)
26
+ }, [undoToken, isPending])
27
+
17
28
  if (!operation) return null
18
29
 
19
30
  const rawLabel = operation.actionLabel ?? operation.commandId
20
31
  const translatedLabel = t(rawLabel)
21
32
  const label = translatedLabel === rawLabel ? rawLabel : translatedLabel
22
- const isPending = pendingToken === operation.undoToken
23
33
 
24
34
  async function handleUndo() {
25
35
  const undoToken = operation?.undoToken
@@ -69,12 +79,12 @@ export function LastOperationBanner() {
69
79
  }
70
80
 
71
81
  return (
72
- <div className="mb-4 flex items-center justify-between gap-3 rounded-md border border-amber-200/80 bg-amber-50/95 pl-3 pr-2 py-2 text-sm text-amber-900 shadow-xs sm:pr-3">
82
+ <div className="mb-4 flex items-center justify-between gap-3 rounded-md border border-status-warning-border bg-status-warning-bg pl-3 pr-2 py-2 text-sm text-status-warning-text shadow-xs sm:pr-3">
73
83
  <div className="min-w-0 truncate">
74
- <span className="font-medium text-amber-950">
84
+ <span className="font-medium text-status-warning-text">
75
85
  {t('audit_logs.banner.last_operation')}
76
86
  </span>
77
- <span className="ml-2 truncate text-amber-900">
87
+ <span className="ml-2 truncate text-status-warning-text">
78
88
  {label}
79
89
  </span>
80
90
  </div>
@@ -83,7 +93,7 @@ export function LastOperationBanner() {
83
93
  size="sm"
84
94
  onClick={() => { void handleUndo() }}
85
95
  disabled={isPending}
86
- className="border-amber-200/80 bg-amber-50 text-amber-900 hover:bg-amber-100 hover:text-amber-900 px-2.5 sm:px-3"
96
+ className="border-status-warning-border bg-status-warning-bg text-status-warning-text hover:bg-status-warning-border hover:text-status-warning-text px-2.5 sm:px-3"
87
97
  >
88
98
  <Undo2 className="mr-1 size-4" aria-hidden="true" />
89
99
  {isPending ? t('audit_logs.actions.undoing') : t('audit_logs.banner.undo')}
@@ -8,7 +8,7 @@ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWith
8
8
  import { LastOperationBanner } from '../LastOperationBanner'
9
9
  import { apiCall } from '../../utils/apiCall'
10
10
  import { flash } from '../../FlashMessages'
11
- import { markUndoSuccess, useLastOperation } from '../store'
11
+ import { markUndoSuccess, dismissOperation, useLastOperation } from '../store'
12
12
 
13
13
  jest.setTimeout(20000)
14
14
 
@@ -23,6 +23,11 @@ jest.mock('../../FlashMessages', () => ({
23
23
  jest.mock('../store', () => ({
24
24
  useLastOperation: jest.fn(),
25
25
  markUndoSuccess: jest.fn(),
26
+ dismissOperation: jest.fn(),
27
+ operationStackConstants: {
28
+ LAST_OPERATION_TTL_MS: 60_000,
29
+ LAST_OPERATION_AUTO_DISMISS_MS: 10_000,
30
+ },
26
31
  }))
27
32
 
28
33
  jest.mock('next/navigation', () => ({
@@ -127,6 +132,83 @@ describe('LastOperationBanner', () => {
127
132
  expect(markUndoSuccess).toHaveBeenCalledWith(['tk-3'])
128
133
  })
129
134
 
135
+ it('auto-dismisses the banner after the configured timeout when no undo is in flight', () => {
136
+ jest.useFakeTimers()
137
+ try {
138
+ renderWithProviders(<LastOperationBanner />, { dict })
139
+ expect(dismissOperation).not.toHaveBeenCalled()
140
+ jest.advanceTimersByTime(9_999)
141
+ expect(dismissOperation).not.toHaveBeenCalled()
142
+ jest.advanceTimersByTime(1)
143
+ expect(dismissOperation).toHaveBeenCalledTimes(1)
144
+ expect(dismissOperation).toHaveBeenCalledWith('token-123')
145
+ } finally {
146
+ jest.useRealTimers()
147
+ }
148
+ })
149
+
150
+ it('does not auto-dismiss while an undo request is in flight', async () => {
151
+ jest.useFakeTimers()
152
+ try {
153
+ let resolveCall: ((value: unknown) => void) | null = null
154
+ ;(apiCall as jest.Mock).mockImplementation(() => new Promise((resolve) => { resolveCall = resolve }))
155
+
156
+ renderWithProviders(<LastOperationBanner />, { dict })
157
+ fireEvent.click(screen.getByRole('button', { name: /undo/i }))
158
+
159
+ jest.advanceTimersByTime(10_000)
160
+ expect(dismissOperation).not.toHaveBeenCalled()
161
+
162
+ resolveCall?.({ ok: true, status: 200, result: {}, response: createMockResponse(200) })
163
+ await Promise.resolve()
164
+ } finally {
165
+ jest.useRealTimers()
166
+ }
167
+ })
168
+
169
+ it('does not fire a spurious dismiss after a successful undo removes the operation', async () => {
170
+ jest.useFakeTimers()
171
+ try {
172
+ let resolveCall: ((value: unknown) => void) | null = null
173
+ ;(apiCall as jest.Mock).mockImplementation(() => new Promise((resolve) => { resolveCall = resolve }))
174
+
175
+ const { rerender } = renderWithProviders(<LastOperationBanner />, { dict })
176
+ fireEvent.click(screen.getByRole('button', { name: /undo/i }))
177
+
178
+ resolveCall?.({ ok: true, status: 200, result: {}, response: createMockResponse(200) })
179
+ await Promise.resolve()
180
+ await Promise.resolve()
181
+ await Promise.resolve()
182
+
183
+ ;(useLastOperation as jest.Mock).mockReturnValue(null)
184
+ rerender(<LastOperationBanner />)
185
+
186
+ jest.advanceTimersByTime(10_000)
187
+ expect(dismissOperation).not.toHaveBeenCalled()
188
+ } finally {
189
+ jest.useRealTimers()
190
+ }
191
+ })
192
+
193
+ it('clears the auto-dismiss timer when the operation is replaced before timeout fires', () => {
194
+ jest.useFakeTimers()
195
+ try {
196
+ const { rerender } = renderWithProviders(<LastOperationBanner />, { dict })
197
+ jest.advanceTimersByTime(2_000)
198
+
199
+ ;(useLastOperation as jest.Mock).mockReturnValue({ ...mockOperation, undoToken: 'token-456' })
200
+ rerender(<LastOperationBanner />)
201
+
202
+ jest.advanceTimersByTime(9_999)
203
+ expect(dismissOperation).not.toHaveBeenCalled()
204
+ jest.advanceTimersByTime(1)
205
+ expect(dismissOperation).toHaveBeenCalledTimes(1)
206
+ expect(dismissOperation).toHaveBeenCalledWith('token-456')
207
+ } finally {
208
+ jest.useRealTimers()
209
+ }
210
+ })
211
+
130
212
  it('surfaces undo errors from the API', async () => {
131
213
  ;(apiCall as jest.Mock).mockResolvedValue({
132
214
  ok: false,
@@ -4,9 +4,11 @@
4
4
 
5
5
  import {
6
6
  coalesceLastOperations,
7
+ dismissOperation,
7
8
  markUndoSuccess,
8
9
  pushOperation,
9
10
  clearAllOperations,
11
+ operationStackConstants,
10
12
  } from '../store'
11
13
 
12
14
  function makeMeta(id: string, undoToken: string, commandId = 'customers.companies.delete') {
@@ -140,3 +142,136 @@ describe('markUndoSuccess — bulk-aware', () => {
140
142
  expect(state.stack).toHaveLength(0)
141
143
  })
142
144
  })
145
+
146
+ describe('dismissOperation — auto-dismiss without redo pollution', () => {
147
+ beforeEach(() => {
148
+ clearAllOperations()
149
+ })
150
+
151
+ it('removes a single entry from the stack without adding it to the undone list', () => {
152
+ pushOperation(makeMeta('op-1', 'tk-1'))
153
+ pushOperation(makeMeta('op-2', 'tk-2'))
154
+
155
+ dismissOperation('tk-2')
156
+
157
+ const state = JSON.parse(window.localStorage.getItem('om:last-operations:v1')!)
158
+ expect(state.stack).toHaveLength(1)
159
+ expect(state.stack[0].undoToken).toBe('tk-1')
160
+ expect(state.undone).toHaveLength(0)
161
+ })
162
+
163
+ it('removes a bulk entry from the stack when ALL of its bulkUndoTokens are dismissed', () => {
164
+ pushOperation(makeMeta('op-1', 'tk-1'))
165
+ pushOperation(makeMeta('op-2', 'tk-2'))
166
+ coalesceLastOperations(2, {
167
+ commandId: 'customers.companies.delete',
168
+ actionLabel: 'Delete 2 companies',
169
+ })
170
+
171
+ const before = JSON.parse(window.localStorage.getItem('om:last-operations:v1')!)
172
+ const bulkToken = before.stack[0].undoToken as string
173
+
174
+ dismissOperation(bulkToken)
175
+
176
+ const after = JSON.parse(window.localStorage.getItem('om:last-operations:v1')!)
177
+ expect(after.stack.find((entry: { undoToken: string }) => entry.undoToken === bulkToken)).toBeUndefined()
178
+ expect(after.undone).toHaveLength(0)
179
+ })
180
+
181
+ it('splits a bulk entry on partial dismissal without populating undone', () => {
182
+ pushOperation(makeMeta('op-1', 'tk-1'))
183
+ pushOperation(makeMeta('op-2', 'tk-2'))
184
+ pushOperation(makeMeta('op-3', 'tk-3'))
185
+ coalesceLastOperations(3, {
186
+ commandId: 'customers.companies.delete',
187
+ actionLabel: 'Delete 3 companies',
188
+ })
189
+
190
+ dismissOperation(['tk-2', 'tk-3'])
191
+
192
+ const state = JSON.parse(window.localStorage.getItem('om:last-operations:v1')!)
193
+ expect(state.stack).toHaveLength(1)
194
+ expect(state.stack[0].bulkUndoTokens).toEqual(['tk-1'])
195
+ expect(state.stack[0].bulkCount).toBe(1)
196
+ expect(state.undone).toHaveLength(0)
197
+ })
198
+
199
+ it('is a no-op when the token does not match any entry', () => {
200
+ pushOperation(makeMeta('op-1', 'tk-1'))
201
+
202
+ dismissOperation('tk-missing')
203
+
204
+ const state = JSON.parse(window.localStorage.getItem('om:last-operations:v1')!)
205
+ expect(state.stack).toHaveLength(1)
206
+ expect(state.stack[0].undoToken).toBe('tk-1')
207
+ expect(state.undone).toHaveLength(0)
208
+ })
209
+
210
+ it('exports the auto-dismiss timeout via operationStackConstants', () => {
211
+ expect(operationStackConstants.LAST_OPERATION_AUTO_DISMISS_MS).toBeGreaterThan(0)
212
+ expect(operationStackConstants.LAST_OPERATION_AUTO_DISMISS_MS)
213
+ .toBeLessThan(operationStackConstants.LAST_OPERATION_TTL_MS)
214
+ })
215
+ })
216
+
217
+ describe('LAST_OPERATION_AUTO_DISMISS_MS — env override (NEXT_PUBLIC_OM_UNDO_BANNER_TIMEOUT_MS)', () => {
218
+ const originalValue = process.env.NEXT_PUBLIC_OM_UNDO_BANNER_TIMEOUT_MS
219
+
220
+ afterEach(() => {
221
+ if (originalValue === undefined) {
222
+ delete process.env.NEXT_PUBLIC_OM_UNDO_BANNER_TIMEOUT_MS
223
+ } else {
224
+ process.env.NEXT_PUBLIC_OM_UNDO_BANNER_TIMEOUT_MS = originalValue
225
+ }
226
+ jest.resetModules()
227
+ })
228
+
229
+ function loadStoreWithEnv(envValue: string | undefined): typeof import('../store') {
230
+ if (envValue === undefined) {
231
+ delete process.env.NEXT_PUBLIC_OM_UNDO_BANNER_TIMEOUT_MS
232
+ } else {
233
+ process.env.NEXT_PUBLIC_OM_UNDO_BANNER_TIMEOUT_MS = envValue
234
+ }
235
+ let mod: typeof import('../store') | undefined
236
+ jest.isolateModules(() => {
237
+ mod = jest.requireActual('../store')
238
+ })
239
+ if (!mod) throw new Error('failed to load store module')
240
+ return mod
241
+ }
242
+
243
+ it('defaults to 10_000 ms when the env var is unset', () => {
244
+ const mod = loadStoreWithEnv(undefined)
245
+ expect(mod.operationStackConstants.LAST_OPERATION_AUTO_DISMISS_MS).toBe(10_000)
246
+ })
247
+
248
+ it('defaults to 10_000 ms when the env var is an empty string', () => {
249
+ const mod = loadStoreWithEnv('')
250
+ expect(mod.operationStackConstants.LAST_OPERATION_AUTO_DISMISS_MS).toBe(10_000)
251
+ })
252
+
253
+ it('uses the configured env value when it is a positive integer', () => {
254
+ const mod = loadStoreWithEnv('20000')
255
+ expect(mod.operationStackConstants.LAST_OPERATION_AUTO_DISMISS_MS).toBe(20_000)
256
+ })
257
+
258
+ it('floors a positive non-integer to an integer ms value', () => {
259
+ const mod = loadStoreWithEnv('7500.9')
260
+ expect(mod.operationStackConstants.LAST_OPERATION_AUTO_DISMISS_MS).toBe(7_500)
261
+ })
262
+
263
+ it('falls back to the default when the env value is non-numeric', () => {
264
+ const mod = loadStoreWithEnv('not-a-number')
265
+ expect(mod.operationStackConstants.LAST_OPERATION_AUTO_DISMISS_MS).toBe(10_000)
266
+ })
267
+
268
+ it('falls back to the default when the env value is zero', () => {
269
+ const mod = loadStoreWithEnv('0')
270
+ expect(mod.operationStackConstants.LAST_OPERATION_AUTO_DISMISS_MS).toBe(10_000)
271
+ })
272
+
273
+ it('falls back to the default when the env value is negative', () => {
274
+ const mod = loadStoreWithEnv('-1000')
275
+ expect(mod.operationStackConstants.LAST_OPERATION_AUTO_DISMISS_MS).toBe(10_000)
276
+ })
277
+ })
@@ -22,6 +22,20 @@ const DEFAULT_STATE: OperationStoreState = { stack: [], undone: [] }
22
22
  const STORAGE_KEY = 'om:last-operations:v1'
23
23
  const STACK_LIMIT = 20
24
24
  const LAST_OPERATION_TTL_MS = 60_000
25
+ const DEFAULT_LAST_OPERATION_AUTO_DISMISS_MS = 10_000
26
+
27
+ function resolveAutoDismissMs(raw: string | undefined): number {
28
+ if (raw == null || raw === '') return DEFAULT_LAST_OPERATION_AUTO_DISMISS_MS
29
+ const parsed = Number(raw)
30
+ if (!Number.isFinite(parsed) || parsed <= 0) {
31
+ return DEFAULT_LAST_OPERATION_AUTO_DISMISS_MS
32
+ }
33
+ return Math.floor(parsed)
34
+ }
35
+
36
+ const LAST_OPERATION_AUTO_DISMISS_MS = resolveAutoDismissMs(
37
+ process.env.NEXT_PUBLIC_OM_UNDO_BANNER_TIMEOUT_MS,
38
+ )
25
39
  const STACK_RETENTION_MS = 10 * 60_000
26
40
 
27
41
  let internalState: OperationStoreState = DEFAULT_STATE
@@ -200,6 +214,30 @@ export function markUndoSuccess(undoTokens: string | string[]) {
200
214
  })
201
215
  }
202
216
 
217
+ export function dismissOperation(undoTokens: string | string[]) {
218
+ if (typeof window === 'undefined') return
219
+ const tokenSet = new Set(Array.isArray(undoTokens) ? undoTokens : [undoTokens])
220
+ if (tokenSet.size === 0) return
221
+ updateState((prev) => {
222
+ const nextStack: OperationEntry[] = []
223
+ for (const entry of prev.stack) {
224
+ if (tokenSet.has(entry.undoToken)) continue
225
+ const bulk = entry.bulkUndoTokens && entry.bulkUndoTokens.length > 0 ? entry.bulkUndoTokens : null
226
+ if (!bulk) {
227
+ nextStack.push(entry)
228
+ continue
229
+ }
230
+ const remaining = bulk.filter((token) => !tokenSet.has(token))
231
+ if (remaining.length === bulk.length) {
232
+ nextStack.push(entry)
233
+ } else if (remaining.length > 0) {
234
+ nextStack.push({ ...entry, bulkUndoTokens: remaining, bulkCount: remaining.length })
235
+ }
236
+ }
237
+ return { stack: nextStack, undone: prev.undone }
238
+ })
239
+ }
240
+
203
241
  export type CoalesceOptions = {
204
242
  commandId?: string
205
243
  actionLabel?: string | null
@@ -296,4 +334,5 @@ export function clearAllOperations() {
296
334
 
297
335
  export const operationStackConstants = {
298
336
  LAST_OPERATION_TTL_MS,
337
+ LAST_OPERATION_AUTO_DISMISS_MS,
299
338
  }
@@ -90,4 +90,18 @@ describe('PasswordInput primitive', () => {
90
90
  expect(ref.current).not.toBeNull()
91
91
  expect(ref.current?.tagName).toBe('INPUT')
92
92
  })
93
+
94
+ it('renders exactly one reveal toggle (suppresses Edge/IE native ::-ms-reveal duplicate)', () => {
95
+ // Regression for issue #2037: Microsoft Edge renders a native password reveal
96
+ // pseudo-element (::-ms-reveal) inside <input type="password">, which appeared
97
+ // alongside our custom toggle as a second eye icon. The fix is a Tailwind arbitrary
98
+ // variant on the inner input that hides ::-ms-reveal (and ::-ms-clear). JSDOM does
99
+ // not render UA pseudo-elements, so we verify the suppression class is present on
100
+ // the input and that our custom button is the sole reveal control.
101
+ const { container } = render(<PasswordInput defaultValue="secret" />)
102
+ const input = container.querySelector('input') as HTMLInputElement
103
+ expect(input.className).toContain('[&::-ms-reveal]:hidden')
104
+ expect(input.className).toContain('[&::-ms-clear]:hidden')
105
+ expect(screen.getAllByRole('button', { name: /show password|hide password/i })).toHaveLength(1)
106
+ })
93
107
  })
@@ -74,7 +74,12 @@ export const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputPro
74
74
  ref={ref}
75
75
  type={revealed ? 'text' : 'password'}
76
76
  disabled={disabled}
77
- className={cn(inputElementVariants({ size }), inputClassName)}
77
+ className={cn(
78
+ inputElementVariants({ size }),
79
+ // Suppress Edge/IE native password reveal + clear controls — we render our own toggle.
80
+ '[&::-ms-reveal]:hidden [&::-ms-clear]:hidden',
81
+ inputClassName,
82
+ )}
78
83
  {...props}
79
84
  />
80
85
  {revealable ? (