@creezio/shell-ui 0.22.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 (232) hide show
  1. package/README.md +336 -0
  2. package/dist/adapters/nav-shell.d.ts +54 -0
  3. package/dist/adapters/nav-shell.d.ts.map +1 -0
  4. package/dist/adapters/nav-shell.js +112 -0
  5. package/dist/adapters/nav-shell.js.map +1 -0
  6. package/dist/brand.d.ts +64 -0
  7. package/dist/brand.d.ts.map +1 -0
  8. package/dist/brand.js +59 -0
  9. package/dist/brand.js.map +1 -0
  10. package/dist/core-nav.d.ts +17 -0
  11. package/dist/core-nav.d.ts.map +1 -0
  12. package/dist/core-nav.js +40 -0
  13. package/dist/core-nav.js.map +1 -0
  14. package/dist/index.d.ts +29 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +24 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/lib/api-scopes.d.ts +24 -0
  19. package/dist/lib/api-scopes.d.ts.map +1 -0
  20. package/dist/lib/api-scopes.js +78 -0
  21. package/dist/lib/api-scopes.js.map +1 -0
  22. package/dist/lib/catalog-suspense-key.d.ts +3 -0
  23. package/dist/lib/catalog-suspense-key.d.ts.map +1 -0
  24. package/dist/lib/catalog-suspense-key.js +12 -0
  25. package/dist/lib/catalog-suspense-key.js.map +1 -0
  26. package/dist/lib/data-changed.d.ts +51 -0
  27. package/dist/lib/data-changed.d.ts.map +1 -0
  28. package/dist/lib/data-changed.js +227 -0
  29. package/dist/lib/data-changed.js.map +1 -0
  30. package/dist/lib/desktop-home-path.d.ts +14 -0
  31. package/dist/lib/desktop-home-path.d.ts.map +1 -0
  32. package/dist/lib/desktop-home-path.js +29 -0
  33. package/dist/lib/desktop-home-path.js.map +1 -0
  34. package/dist/lib/geo-distance.d.ts +6 -0
  35. package/dist/lib/geo-distance.d.ts.map +1 -0
  36. package/dist/lib/geo-distance.js +27 -0
  37. package/dist/lib/geo-distance.js.map +1 -0
  38. package/dist/lib/img.d.ts +2 -0
  39. package/dist/lib/img.d.ts.map +1 -0
  40. package/dist/lib/img.js +40 -0
  41. package/dist/lib/img.js.map +1 -0
  42. package/dist/lib/keepalive-eviction.d.ts +14 -0
  43. package/dist/lib/keepalive-eviction.d.ts.map +1 -0
  44. package/dist/lib/keepalive-eviction.js +44 -0
  45. package/dist/lib/keepalive-eviction.js.map +1 -0
  46. package/dist/lib/ops-track.d.ts +29 -0
  47. package/dist/lib/ops-track.d.ts.map +1 -0
  48. package/dist/lib/ops-track.js +37 -0
  49. package/dist/lib/ops-track.js.map +1 -0
  50. package/dist/lib/optimize-cover-url.d.ts +10 -0
  51. package/dist/lib/optimize-cover-url.d.ts.map +1 -0
  52. package/dist/lib/optimize-cover-url.js +80 -0
  53. package/dist/lib/optimize-cover-url.js.map +1 -0
  54. package/dist/lib/page-trails.d.ts +9 -0
  55. package/dist/lib/page-trails.d.ts.map +1 -0
  56. package/dist/lib/page-trails.js +20 -0
  57. package/dist/lib/page-trails.js.map +1 -0
  58. package/dist/lib/public-origin.d.ts +32 -0
  59. package/dist/lib/public-origin.d.ts.map +1 -0
  60. package/dist/lib/public-origin.js +125 -0
  61. package/dist/lib/public-origin.js.map +1 -0
  62. package/dist/lib/server-incident.d.ts +12 -0
  63. package/dist/lib/server-incident.d.ts.map +1 -0
  64. package/dist/lib/server-incident.js +72 -0
  65. package/dist/lib/server-incident.js.map +1 -0
  66. package/dist/lib/tab-document-url.d.ts +13 -0
  67. package/dist/lib/tab-document-url.d.ts.map +1 -0
  68. package/dist/lib/tab-document-url.js +60 -0
  69. package/dist/lib/tab-document-url.js.map +1 -0
  70. package/dist/lib/utils.d.ts +20 -0
  71. package/dist/lib/utils.d.ts.map +1 -0
  72. package/dist/lib/utils.js +87 -0
  73. package/dist/lib/utils.js.map +1 -0
  74. package/dist/nav-catalog.d.ts +97 -0
  75. package/dist/nav-catalog.d.ts.map +1 -0
  76. package/dist/nav-catalog.js +282 -0
  77. package/dist/nav-catalog.js.map +1 -0
  78. package/dist/registry.d.ts +11 -0
  79. package/dist/registry.d.ts.map +1 -0
  80. package/dist/registry.js +60 -0
  81. package/dist/registry.js.map +1 -0
  82. package/dist/types.d.ts +22 -0
  83. package/dist/types.d.ts.map +1 -0
  84. package/dist/types.js +2 -0
  85. package/dist/types.js.map +1 -0
  86. package/dist-cjs/adapters/nav-shell.js +115 -0
  87. package/dist-cjs/adapters/nav-shell.js.map +1 -0
  88. package/dist-cjs/brand.js +66 -0
  89. package/dist-cjs/brand.js.map +1 -0
  90. package/dist-cjs/core-nav.js +43 -0
  91. package/dist-cjs/core-nav.js.map +1 -0
  92. package/dist-cjs/index.js +95 -0
  93. package/dist-cjs/index.js.map +1 -0
  94. package/dist-cjs/lib/api-scopes.js +86 -0
  95. package/dist-cjs/lib/api-scopes.js.map +1 -0
  96. package/dist-cjs/lib/catalog-suspense-key.js +15 -0
  97. package/dist-cjs/lib/catalog-suspense-key.js.map +1 -0
  98. package/dist-cjs/lib/data-changed.js +236 -0
  99. package/dist-cjs/lib/data-changed.js.map +1 -0
  100. package/dist-cjs/lib/desktop-home-path.js +34 -0
  101. package/dist-cjs/lib/desktop-home-path.js.map +1 -0
  102. package/dist-cjs/lib/geo-distance.js +30 -0
  103. package/dist-cjs/lib/geo-distance.js.map +1 -0
  104. package/dist-cjs/lib/img.js +43 -0
  105. package/dist-cjs/lib/img.js.map +1 -0
  106. package/dist-cjs/lib/keepalive-eviction.js +49 -0
  107. package/dist-cjs/lib/keepalive-eviction.js.map +1 -0
  108. package/dist-cjs/lib/ops-track.js +41 -0
  109. package/dist-cjs/lib/ops-track.js.map +1 -0
  110. package/dist-cjs/lib/optimize-cover-url.js +83 -0
  111. package/dist-cjs/lib/optimize-cover-url.js.map +1 -0
  112. package/dist-cjs/lib/page-trails.js +25 -0
  113. package/dist-cjs/lib/page-trails.js.map +1 -0
  114. package/dist-cjs/lib/public-origin.js +130 -0
  115. package/dist-cjs/lib/public-origin.js.map +1 -0
  116. package/dist-cjs/lib/server-incident.js +75 -0
  117. package/dist-cjs/lib/server-incident.js.map +1 -0
  118. package/dist-cjs/lib/tab-document-url.js +65 -0
  119. package/dist-cjs/lib/tab-document-url.js.map +1 -0
  120. package/dist-cjs/lib/utils.js +97 -0
  121. package/dist-cjs/lib/utils.js.map +1 -0
  122. package/dist-cjs/nav-catalog.js +293 -0
  123. package/dist-cjs/nav-catalog.js.map +1 -0
  124. package/dist-cjs/package.json +3 -0
  125. package/dist-cjs/registry.js +64 -0
  126. package/dist-cjs/registry.js.map +1 -0
  127. package/dist-cjs/types.js +3 -0
  128. package/dist-cjs/types.js.map +1 -0
  129. package/package.json +163 -0
  130. package/ui/app-error-boundary.tsx +59 -0
  131. package/ui/data-table.tsx +121 -0
  132. package/ui/desktop/auth-window-chrome.tsx +51 -0
  133. package/ui/desktop/desktop-bridge.tsx +87 -0
  134. package/ui/desktop/external-site-slot.tsx +313 -0
  135. package/ui/desktop/external-site-surface.ts +58 -0
  136. package/ui/desktop/site-link.tsx +105 -0
  137. package/ui/desktop/window-chrome-controls.tsx +5 -0
  138. package/ui/desktop-types.ts +30 -0
  139. package/ui/faceted-filters.tsx +119 -0
  140. package/ui/global-search-host.ts +20 -0
  141. package/ui/global-search.tsx +66 -0
  142. package/ui/index.ts +178 -0
  143. package/ui/kit.ts +30 -0
  144. package/ui/layout/app-shell.tsx +58 -0
  145. package/ui/layout/desktop-update-banner.tsx +100 -0
  146. package/ui/layout/entity-header.tsx +44 -0
  147. package/ui/layout/native-os-nav.ts +104 -0
  148. package/ui/layout/nav-catalog-loader.tsx +98 -0
  149. package/ui/layout/nav-icons.ts +67 -0
  150. package/ui/layout/page-chrome.tsx +90 -0
  151. package/ui/layout/page-toolbar-context.tsx +82 -0
  152. package/ui/layout/sandbox-banner.tsx +38 -0
  153. package/ui/layout/section-view-shell.tsx +73 -0
  154. package/ui/layout/sidebar-host.ts +144 -0
  155. package/ui/layout/sidebar.tsx +1150 -0
  156. package/ui/lib/ai-screencast-hub.ts +127 -0
  157. package/ui/lib/ai-workspace-client.ts +46 -0
  158. package/ui/lib/aid.ts +22 -0
  159. package/ui/lib/desktop-host.ts +114 -0
  160. package/ui/lib/fleet-tracker-client.ts +237 -0
  161. package/ui/lib/hermes-ui.ts +114 -0
  162. package/ui/lib/n8n-ui.ts +115 -0
  163. package/ui/lib/use-creezio-resource.tsx +121 -0
  164. package/ui/lib/use-shell-ui-brand.ts +22 -0
  165. package/ui/list-toolbar.tsx +309 -0
  166. package/ui/os-pages/desktop-settings-page.tsx +158 -0
  167. package/ui/os-pages/index.ts +8 -0
  168. package/ui/page-loading/entity-page-loading.tsx +59 -0
  169. package/ui/page-loading/list-page-loading.tsx +61 -0
  170. package/ui/pagination.tsx +56 -0
  171. package/ui/primitives/avatar.tsx +33 -0
  172. package/ui/primitives/badge.tsx +36 -0
  173. package/ui/primitives/breadcrumb.tsx +93 -0
  174. package/ui/primitives/button.tsx +44 -0
  175. package/ui/primitives/card.tsx +44 -0
  176. package/ui/primitives/chart.tsx +317 -0
  177. package/ui/primitives/command.tsx +111 -0
  178. package/ui/primitives/dialog.tsx +100 -0
  179. package/ui/primitives/dropdown-menu.tsx +187 -0
  180. package/ui/primitives/input.tsx +19 -0
  181. package/ui/primitives/label.tsx +17 -0
  182. package/ui/primitives/resizable.tsx +45 -0
  183. package/ui/primitives/scroll-area.tsx +41 -0
  184. package/ui/primitives/select.tsx +73 -0
  185. package/ui/primitives/separator.tsx +26 -0
  186. package/ui/primitives/sheet.tsx +90 -0
  187. package/ui/primitives/skeleton.tsx +7 -0
  188. package/ui/primitives/sonner.tsx +22 -0
  189. package/ui/primitives/tabs.tsx +51 -0
  190. package/ui/primitives/textarea.tsx +22 -0
  191. package/ui/primitives/tooltip.tsx +31 -0
  192. package/ui/pwa/client-error-reporter.tsx +62 -0
  193. package/ui/pwa/register-sw.tsx +50 -0
  194. package/ui/range-filters.tsx +174 -0
  195. package/ui/search/global-search-config.ts +32 -0
  196. package/ui/search/global-search-provider.tsx +528 -0
  197. package/ui/search/search-history.ts +74 -0
  198. package/ui/search-input.tsx +146 -0
  199. package/ui/settings/account-settings.tsx +140 -0
  200. package/ui/settings/agent-profile-settings.tsx +184 -0
  201. package/ui/settings/api-keys-settings.tsx +349 -0
  202. package/ui/settings/desktop-background-settings.tsx +128 -0
  203. package/ui/settings/desktop-connection-settings.tsx +251 -0
  204. package/ui/settings/desktop-embed-env-panel.tsx +257 -0
  205. package/ui/settings/desktop-fleet-telemetry-settings.tsx +269 -0
  206. package/ui/settings/desktop-hermes-settings.tsx +341 -0
  207. package/ui/settings/desktop-llm-keys.tsx +192 -0
  208. package/ui/settings/desktop-n8n-settings.tsx +306 -0
  209. package/ui/settings/desktop-tunnel.tsx +264 -0
  210. package/ui/settings/desktop-update-settings.tsx +175 -0
  211. package/ui/settings/factory-reset-settings.tsx +89 -0
  212. package/ui/settings/host-only-settings.tsx +62 -0
  213. package/ui/settings/locked-config-field.tsx +30 -0
  214. package/ui/settings/ops-diagnostic-settings.tsx +242 -0
  215. package/ui/settings/search-reindex-settings.tsx +158 -0
  216. package/ui/settings/server-mode-cards.tsx +801 -0
  217. package/ui/theme/tailwind-preset.cjs +82 -0
  218. package/ui/theme/theme.css +328 -0
  219. package/ui/workspace/ai-activity-panel-host.tsx +19 -0
  220. package/ui/workspace/ai-workspace-agent-host.tsx +54 -0
  221. package/ui/workspace/ai-workspace-banner.tsx +168 -0
  222. package/ui/workspace/impersonation-banner.tsx +39 -0
  223. package/ui/workspace/keep-alive.tsx +220 -0
  224. package/ui/workspace/tab-workspace-context.tsx +1172 -0
  225. package/ui/workspace/tab-workspace-host.ts +89 -0
  226. package/ui/workspace/types.ts +500 -0
  227. package/ui/workspace/use-location-search.tsx +50 -0
  228. package/ui/workspace/window-chrome-controls.tsx +116 -0
  229. package/ui/workspace/workspace-config.ts +47 -0
  230. package/ui/workspace/workspace-root.tsx +147 -0
  231. package/ui/workspace/workspace-shell.tsx +292 -0
  232. package/ui/workspace/workspace-tab-bar.tsx +572 -0
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Host marque pour le tab-workspace (nav / surfaces métier reste marque).
3
+ * O9 — injection ; pas de jumeau.
4
+ *
5
+ * Capacité native = ouvrir un **site externe** (onglet), pas un « fournisseur ».
6
+ * Les libellés métier (Fournisseur, Outil, …) = config/UI marque.
7
+ */
8
+
9
+ /** Options d’ouverture d’un site externe (partition /site/<id>). */
10
+ export type OpenExternalSiteOpts = {
11
+ url: string;
12
+ title?: string;
13
+ /** Id de partition persistante (0 = générique / hash host). */
14
+ siteId?: number;
15
+ electronTabId?: string;
16
+ navigateUrl?: boolean;
17
+ [key: string]: unknown;
18
+ };
19
+
20
+ /**
21
+ * @deprecated Utiliser `OpenExternalSiteOpts` + `siteId`.
22
+ * Alias temporaire : `fournisseurId` → `siteId`.
23
+ */
24
+ export type OpenSupplierSiteOpts = OpenExternalSiteOpts & {
25
+ /** @deprecated → `siteId` */
26
+ fournisseurId?: number;
27
+ };
28
+
29
+ export type TabWorkspaceHost = {
30
+ useTabWorkspace: () => any;
31
+ useOpenTab?: () => (...args: any[]) => any;
32
+ };
33
+
34
+ let host: TabWorkspaceHost | null = null;
35
+
36
+ export function configureTabWorkspaceHost(next: TabWorkspaceHost): void {
37
+ host = next;
38
+ }
39
+
40
+ export function useTabWorkspace(): any {
41
+ if (!host?.useTabWorkspace) {
42
+ throw new Error(
43
+ "@creezio/shell-ui: configureTabWorkspaceHost() requis avant useTabWorkspace",
44
+ );
45
+ }
46
+ return host.useTabWorkspace();
47
+ }
48
+
49
+ export function useOpenTab(): any {
50
+ if (host?.useOpenTab) return host.useOpenTab();
51
+ return () => {};
52
+ }
53
+
54
+ export function useTabWorkspaceOptional(): any {
55
+ if (!host?.useTabWorkspace) return null;
56
+ try {
57
+ return host.useTabWorkspace();
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /** Normalise opts marque (fournisseurId legacy → siteId). */
64
+ export function normalizeOpenExternalSiteOpts(
65
+ opts: OpenExternalSiteOpts | OpenSupplierSiteOpts,
66
+ ): OpenExternalSiteOpts {
67
+ const siteId =
68
+ opts.siteId ??
69
+ (opts as OpenSupplierSiteOpts).fournisseurId ??
70
+ 0;
71
+ const { fournisseurId: _drop, ...rest } = opts as OpenSupplierSiteOpts;
72
+ return { ...rest, siteId };
73
+ }
74
+
75
+ /**
76
+ * Ouvre un site externe via le host workspace (API générique).
77
+ * Accepte `openExternalSite` (SoT) ou `openSupplierSite` (alias déprécié marque).
78
+ */
79
+ export function openExternalSiteFromWorkspace(
80
+ workspace: any,
81
+ opts: OpenExternalSiteOpts | OpenSupplierSiteOpts,
82
+ ): void {
83
+ if (!workspace) return;
84
+ const normalized = normalizeOpenExternalSiteOpts(opts);
85
+ const open =
86
+ workspace.openExternalSite ??
87
+ workspace.openSupplierSite;
88
+ if (typeof open === "function") open(normalized);
89
+ }
@@ -0,0 +1,500 @@
1
+ /**
2
+ * Type de page CRM — standard unique.
3
+ * - section : listes (toolbar recherche + titre + actions sous les onglets)
4
+ * - entity : fiches détail (fil d'Ariane sticky ; titre via EntityHeader)
5
+ */
6
+ export type PageKind = "section" | "entity";
7
+
8
+ /** Fil d'Ariane publié vers le chrome sticky (sous les onglets). */
9
+ export type TrailCrumb = {
10
+ label: string;
11
+ href?: string;
12
+ };
13
+
14
+ export type TabMeta = {
15
+ title: string;
16
+ subtitle?: string;
17
+ kind?: PageKind;
18
+ /** Chemin de navigation sticky — obligatoire pour kind=entity. */
19
+ trail?: TrailCrumb[];
20
+ /** Masque le bandeau H1/trail — canvas plein écran sous les onglets. */
21
+ fullscreen?: boolean;
22
+ };
23
+
24
+ /** Métadonnées d'un onglet site externe (WebContentsView Electron). */
25
+ export type ExternalSiteTabMeta = {
26
+ siteId: number;
27
+ /** URL (rechargée au remount ; cookies partition conservés). */
28
+ url: string;
29
+ electronTabId?: string;
30
+ };
31
+
32
+ /** @deprecated → ExternalSiteTabMeta (siteId). */
33
+ export type SupplierTabMeta = ExternalSiteTabMeta & {
34
+ /** @deprecated → siteId */
35
+ fournisseurId?: number;
36
+ };
37
+
38
+
39
+ export type WorkspaceTab = {
40
+ id: string;
41
+ href: string;
42
+ title: string;
43
+ /** Verrouillage choisi par l'utilisateur (le Dashboard reste protégé séparément). */
44
+ locked?: boolean;
45
+ subtitle?: string;
46
+ kind?: PageKind;
47
+ trail?: TrailCrumb[];
48
+ fullscreen?: boolean;
49
+ /** Présent si l'onglet héberge un site externe Chromium. */
50
+ externalSite?: ExternalSiteTabMeta;
51
+ /** @deprecated → externalSite */
52
+ supplier?: SupplierTabMeta;
53
+
54
+ /** Historique back/forward propre à cet onglet. */
55
+ history: string[];
56
+ historyIndex: number;
57
+ };
58
+
59
+ export type WorkspacePersistedState = {
60
+ tabs: WorkspaceTab[];
61
+ activeTabId: string;
62
+ };
63
+
64
+ /** v3 : Dashboard unique épinglé + état des onglets persisté en session.
65
+ * Override marque via `configureWorkspaceStorageKey` (ex. tf2-workspace-tabs-v3).
66
+ */
67
+ export let WORKSPACE_STORAGE_KEY = "creezio-workspace-tabs-v3";
68
+ export const MAX_TABS = 12;
69
+ export const MAX_KEEPALIVE = 12;
70
+
71
+ export function configureWorkspaceStorageKey(key: string): void {
72
+ if (key.trim()) WORKSPACE_STORAGE_KEY = key.trim();
73
+ }
74
+
75
+ /** Chemin de l'onglet épinglé (toujours premier, non fermable). */
76
+ export const DASHBOARD_PATH = "/dashboard";
77
+ /**
78
+ * Chemins fullscreen optionnels — **override marque** via
79
+ * `configureFullscreenPaths`. Défauts vides côté kit (pas de domaine TF).
80
+ * Alias historiques TF (`/panier`, `/optimiser`) restent importables depuis la marque.
81
+ */
82
+ export let PANIER_PATH = "";
83
+ export let OPTIMISER_PATH = "";
84
+
85
+ /** @deprecated Chemins TF historiques — configurer via configureFullscreenPaths. */
86
+ export const TF_LEGACY_PANIER_PATH = "/panier";
87
+ /** @deprecated */
88
+ export const TF_LEGACY_OPTIMISER_PATH = "/optimiser";
89
+
90
+ export function configureFullscreenPaths(opts: {
91
+ panierPath?: string;
92
+ optimiserPath?: string;
93
+ }): void {
94
+ if (opts.panierPath != null) PANIER_PATH = opts.panierPath;
95
+ if (opts.optimiserPath != null) OPTIMISER_PATH = opts.optimiserPath;
96
+ }
97
+
98
+
99
+ /** Le href pointe-t-il vers le dashboard (query ignorée) ? */
100
+ export function isDashboardHref(href: string): boolean {
101
+ const path = normalizeHref(href).split("?")[0] || "/";
102
+ return path === DASHBOARD_PATH || path === "/";
103
+ }
104
+
105
+ /** Protection effective : Dashboard système ou verrouillage utilisateur. */
106
+ export function isWorkspaceTabLocked(
107
+ tab: Pick<WorkspaceTab, "id" | "locked">,
108
+ pinnedTabId?: string | null,
109
+ ): boolean {
110
+ return tab.id === pinnedTabId || tab.locked === true;
111
+ }
112
+
113
+ export function normalizeHref(href: string): string {
114
+ try {
115
+ const url = new URL(href, "http://local.invalid");
116
+ // "/" est une pure page de redirection vers /dashboard : la canoniser ici
117
+ // pour qu'aucun onglet, historique ou pane keep-alive ne référence "/".
118
+ // Une pane gelée sur "/" rejouerait le redirect à chaque rendu →
119
+ // router.replace('/dashboard') en boucle infinie → React #310 au premier
120
+ // changement de route (app desktop qui charge la racine).
121
+ const path = url.pathname === "/" ? DASHBOARD_PATH : url.pathname || "/";
122
+ const search = url.search || "";
123
+ return `${path}${search}`;
124
+ } catch {
125
+ return href.split("#")[0] || "/";
126
+ }
127
+ }
128
+
129
+ /** Roots entity — **injectés par la marque** (kit = plateforme only). */
130
+ let ENTITY_ROUTE_ROOTS = new Set<string>();
131
+
132
+ export function configureEntityRouteRoots(roots: string[]): void {
133
+ ENTITY_ROUTE_ROOTS = new Set(roots);
134
+ }
135
+
136
+
137
+ /**
138
+ * Infère kind avant publication AppShell (évite le flash H1
139
+ * « Catalogue · 123 » sur les fiches détail au premier paint).
140
+ */
141
+ export function pageKindFromHref(href: string): PageKind {
142
+ const path = normalizeHref(href).split("?")[0] || "/";
143
+ const parts = path.split("/").filter(Boolean);
144
+ if (parts[0] === "admin" && parts[1] === "agregateurs" && parts.length >= 3) {
145
+ return "entity";
146
+ }
147
+ if (parts.length >= 2 && ENTITY_ROUTE_ROOTS.has(parts[0]!)) {
148
+ return "entity";
149
+ }
150
+ return "section";
151
+ }
152
+
153
+ /** Libellé liste (section) vs fiche (entity) — évite « Catalogue · 123 » au clic. */
154
+ const SECTION_LABELS: Record<string, string> = {
155
+ dashboard: "Dashboard",
156
+ admin: "Admin",
157
+ parametres: "Préférences",
158
+ configuration: "Configuration",
159
+ setup: "Premier lancement",
160
+ site: "Site externe",
161
+ navigateur: "Navigateur",
162
+ taches: "Tâches",
163
+ mails: "Mails",
164
+ abonnement: "Abonnement",
165
+ collaborateurs: "Collaborateurs",
166
+ cockpit: "Cockpit",
167
+ };
168
+
169
+ /** Enrichit les libellés de section (marque = métier). */
170
+ export function configureSectionLabels(labels: Record<string, string>): void {
171
+ Object.assign(SECTION_LABELS, labels);
172
+ }
173
+
174
+
175
+ const ENTITY_LABELS: Record<string, string> = {};
176
+
177
+ export function configureEntityLabels(labels: Record<string, string>): void {
178
+ Object.assign(ENTITY_LABELS, labels);
179
+ }
180
+
181
+
182
+ export function titleFromHref(href: string): string {
183
+ const path = normalizeHref(href).split("?")[0] || "/";
184
+ const parts = path.split("/").filter(Boolean);
185
+ if (!parts.length) return "Dashboard";
186
+ if (parts[0] === "admin") {
187
+ if (parts[1] === "data-mapping") return "Data Mapping";
188
+ if (parts[1] === "agregateurs") {
189
+ return parts.length >= 3 ? "Agrégateur" : "Agrégateurs";
190
+ }
191
+ return "Admin";
192
+ }
193
+ const head = parts[0]!;
194
+ if (parts.length === 1) return SECTION_LABELS[head] || head;
195
+ // Fiche entité : titre générique court (le vrai nom arrive via AppShell).
196
+ if (ENTITY_ROUTE_ROOTS.has(head)) {
197
+ return ENTITY_LABELS[head] || SECTION_LABELS[head] || head;
198
+ }
199
+ const label = SECTION_LABELS[head] || head;
200
+ return `${label} · ${decodeURIComponent(parts[1] || "")}`;
201
+ }
202
+
203
+ /** Canvas atelier actif (`/optimiser?commande=…`). */
204
+ export function isOptimiserCanvasHref(href: string): boolean {
205
+ const normalized = normalizeHref(href);
206
+ try {
207
+ const url = new URL(normalized, "http://local.invalid");
208
+ if (url.pathname !== OPTIMISER_PATH) return false;
209
+ return url.searchParams.has("commande");
210
+ } catch {
211
+ return false;
212
+ }
213
+ }
214
+
215
+ export function isFullscreenHref(href: string): boolean {
216
+ if (isOptimiserCanvasHref(href) || isExternalSiteHref(href)) return true;
217
+ if (PANIER_PATH) {
218
+ const path = normalizeHref(href).split("?")[0] || "/";
219
+ if (path === PANIER_PATH || path.startsWith(PANIER_PATH + "/")) return true;
220
+ }
221
+ return false;
222
+ }
223
+
224
+ /** Onglet workspace hébergeant un site externe (`/site/<id>`). */
225
+ export function isExternalSiteHref(href: string): boolean {
226
+ const path = normalizeHref(href).split("?")[0] || "/";
227
+ return path === "/site" || path.startsWith("/site/");
228
+ }
229
+
230
+ /** @deprecated → isExternalSiteHref */
231
+ export const isSupplierHref = isExternalSiteHref;
232
+
233
+ export function siteIdFromHref(href: string): number | null {
234
+ const path = normalizeHref(href).split("?")[0] || "/";
235
+ const m = path.match(/^\/site\/(\d+)$/);
236
+ if (!m) return null;
237
+ const id = Number(m[1]);
238
+ return Number.isFinite(id) ? id : null;
239
+ }
240
+
241
+ /** @deprecated → siteIdFromHref */
242
+ export const fournisseurIdFromHref = siteIdFromHref;
243
+
244
+ export function externalSiteHref(siteId: number): string {
245
+ return `/site/${Math.floor(siteId)}`;
246
+ }
247
+
248
+ /** @deprecated → externalSiteHref */
249
+ export const supplierHref = externalSiteHref;
250
+
251
+ export function createExternalSiteTab(opts: {
252
+ siteId: number;
253
+ url: string;
254
+ title?: string;
255
+ electronTabId?: string;
256
+ id?: string;
257
+ }): WorkspaceTab {
258
+ const href = externalSiteHref(opts.siteId);
259
+ let title = (opts.title || "").trim();
260
+ if (!title) {
261
+ try {
262
+ title = new URL(opts.url).hostname.replace(/^www\./, "");
263
+ } catch {
264
+ title = `Site ${opts.siteId}`;
265
+ }
266
+ }
267
+ return {
268
+ ...createTab(href, {
269
+ id: opts.id,
270
+ title,
271
+ kind: "section",
272
+ fullscreen: true,
273
+ }),
274
+ externalSite: {
275
+ siteId: opts.siteId,
276
+ url: opts.url,
277
+ electronTabId: opts.electronTabId,
278
+ },
279
+ };
280
+ }
281
+
282
+ export function isWorkspacePath(pathname: string): boolean {
283
+ if (!pathname) return false;
284
+ if (pathname === "/login" || pathname.startsWith("/login/")) return false;
285
+ // Wizard first-run : plein écran dédié, sans sidebar / onglets / assistant.
286
+ if (pathname === "/setup" || pathname.startsWith("/setup/")) return false;
287
+ if (pathname === "/onboarding" || pathname.startsWith("/onboarding/")) return false;
288
+ if (pathname === "/health" || pathname.startsWith("/health/")) return false;
289
+ if (pathname.startsWith("/api/")) return false;
290
+ // Module scan : plein écran mobile hors shell (pas de keep-alive caméra).
291
+ if (pathname === "/scan" || pathname.startsWith("/scan/")) return false;
292
+ // Planche d'étiquettes QR : rendu print autonome, hors chrome workspace.
293
+ if (pathname === "/stack/qr-print") return false;
294
+ // Cockpit app Serveur : console ops autonome, sans sidebar CRM.
295
+ if (
296
+ pathname === "/server-cockpit" ||
297
+ pathname.startsWith("/server-cockpit/")
298
+ ) {
299
+ return false;
300
+ }
301
+ return true;
302
+ }
303
+
304
+ export function newTabId(): string {
305
+ if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
306
+ return crypto.randomUUID();
307
+ }
308
+ return `tab-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
309
+ }
310
+
311
+ function trailsEqual(a?: TrailCrumb[], b?: TrailCrumb[]): boolean {
312
+ if (a === b) return true;
313
+ if (!a?.length && !b?.length) return true;
314
+ if (!a || !b || a.length !== b.length) return false;
315
+ return a.every(
316
+ (c, i) => c.label === b[i]!.label && (c.href || "") === (b[i]!.href || ""),
317
+ );
318
+ }
319
+
320
+ /** Crée un onglet avec une pile d'historique initiale. */
321
+ export function createTab(
322
+ href: string,
323
+ overrides?: Partial<
324
+ Pick<
325
+ WorkspaceTab,
326
+ "id" | "title" | "locked" | "subtitle" | "kind" | "trail" | "fullscreen"
327
+ >
328
+ >,
329
+ ): WorkspaceTab {
330
+ const h = normalizeHref(href);
331
+ return {
332
+ id: overrides?.id || newTabId(),
333
+ href: h,
334
+ title: overrides?.title || titleFromHref(h),
335
+ locked: overrides?.locked === true,
336
+ subtitle: overrides?.subtitle,
337
+ kind: overrides?.kind,
338
+ trail: overrides?.trail,
339
+ fullscreen: overrides?.fullscreen ?? isFullscreenHref(h),
340
+ history: [h],
341
+ historyIndex: 0,
342
+ };
343
+ }
344
+
345
+ /** Kind chrome effectif (meta publiée, sinon inféré depuis l'URL). */
346
+ export function resolvePageKind(tab: Pick<WorkspaceTab, "href" | "kind">): PageKind {
347
+ return tab.kind ?? pageKindFromHref(tab.href);
348
+ }
349
+
350
+ /** Applique les métadonnées d'onglet sans toucher à l'historique. */
351
+ export function applyTabMeta(tab: WorkspaceTab, meta: TabMeta): WorkspaceTab {
352
+ const kind = meta.kind ?? pageKindFromHref(tab.href);
353
+ const trail = meta.trail;
354
+ const fullscreen = meta.fullscreen ?? isFullscreenHref(tab.href);
355
+ if (
356
+ tab.title === meta.title &&
357
+ tab.subtitle === meta.subtitle &&
358
+ (tab.kind ?? pageKindFromHref(tab.href)) === kind &&
359
+ trailsEqual(tab.trail, trail) &&
360
+ tab.fullscreen === fullscreen
361
+ ) {
362
+ return tab;
363
+ }
364
+ return {
365
+ ...tab,
366
+ title: meta.title || tab.title,
367
+ subtitle: meta.subtitle,
368
+ kind,
369
+ trail,
370
+ fullscreen,
371
+ };
372
+ }
373
+
374
+ /** Normalise un onglet persisté (migration v1 → v2 + pageHeader → kind). */
375
+ export function ensureTabHistory(
376
+ tab:
377
+ | WorkspaceTab
378
+ | (Omit<WorkspaceTab, "history" | "historyIndex"> &
379
+ Partial<Pick<WorkspaceTab, "history" | "historyIndex">> & {
380
+ pageHeader?: string;
381
+ }),
382
+ ): WorkspaceTab {
383
+ const href = normalizeHref(tab.href);
384
+ const history = (tab.history?.length ? tab.history : [href]).map(normalizeHref);
385
+ let historyIndex =
386
+ typeof tab.historyIndex === "number" ? tab.historyIndex : history.length - 1;
387
+ historyIndex = Math.min(Math.max(0, historyIndex), history.length - 1);
388
+
389
+ // Migration sessionStorage : ancien pageHeader → kind
390
+ const legacy = tab as WorkspaceTab & { pageHeader?: string };
391
+ let kind = tab.kind;
392
+ if (!kind && legacy.pageHeader) {
393
+ kind = legacy.pageHeader === "section" ? "section" : "entity";
394
+ }
395
+
396
+ let externalSite: ExternalSiteTabMeta | undefined;
397
+ if (tab.externalSite && typeof tab.externalSite.siteId === "number") {
398
+ externalSite = {
399
+ siteId: tab.externalSite.siteId,
400
+ url: String(tab.externalSite.url || ""),
401
+ electronTabId: tab.externalSite.electronTabId,
402
+ };
403
+ } else if (
404
+ tab.supplier &&
405
+ typeof (tab.supplier.siteId ?? tab.supplier.fournisseurId) === "number"
406
+ ) {
407
+ externalSite = {
408
+ siteId: Number(tab.supplier.siteId ?? tab.supplier.fournisseurId),
409
+ url: String(tab.supplier.url || ""),
410
+ electronTabId: tab.supplier.electronTabId,
411
+ };
412
+ } else if (isExternalSiteHref(href)) {
413
+ externalSite = {
414
+ siteId: siteIdFromHref(href) || 0,
415
+ url: "",
416
+ };
417
+ }
418
+
419
+ return {
420
+ id: tab.id || newTabId(),
421
+ href: history[historyIndex] || href,
422
+ title: tab.title || titleFromHref(href),
423
+ locked: tab.locked === true,
424
+ subtitle: tab.subtitle,
425
+ kind,
426
+ trail: tab.trail,
427
+ fullscreen: tab.fullscreen ?? isFullscreenHref(href),
428
+ externalSite,
429
+ /** @deprecated miroir compat */
430
+ supplier: externalSite
431
+ ? { ...externalSite, fournisseurId: externalSite.siteId }
432
+ : undefined,
433
+ history,
434
+ historyIndex,
435
+ };
436
+ }
437
+
438
+ /** @deprecated → createExternalSiteTab */
439
+ export function createSupplierTab(opts: {
440
+ fournisseurId: number;
441
+ url: string;
442
+ title?: string;
443
+ electronTabId?: string;
444
+ id?: string;
445
+ }): WorkspaceTab {
446
+ return createExternalSiteTab({
447
+ siteId: opts.fournisseurId,
448
+ url: opts.url,
449
+ title: opts.title,
450
+ electronTabId: opts.electronTabId,
451
+ id: opts.id,
452
+ });
453
+ }
454
+
455
+ /** Même pathname (seuls les query params changent). */
456
+ export function samePathname(a: string, b: string): boolean {
457
+ const pa = normalizeHref(a).split("?")[0] || "/";
458
+ const pb = normalizeHref(b).split("?")[0] || "/";
459
+ return pa === pb;
460
+ }
461
+
462
+ /** Une navigation de page doit-elle préserver l'onglet actif protégé ? */
463
+ export function shouldOpenLockedNavigationInNewTab(
464
+ activeTab: Pick<WorkspaceTab, "id" | "href" | "locked">,
465
+ targetHref: string,
466
+ pinnedTabId?: string | null,
467
+ ): boolean {
468
+ return (
469
+ isWorkspaceTabLocked(activeTab, pinnedTabId) &&
470
+ !samePathname(activeTab.href, targetHref)
471
+ );
472
+ }
473
+
474
+ export function replaceTabHistory(tab: WorkspaceTab, href: string): WorkspaceTab {
475
+ const h = normalizeHref(href);
476
+ const history = tab.history.length ? [...tab.history] : [h];
477
+ const idx = Math.min(Math.max(0, tab.historyIndex), history.length - 1);
478
+ history[idx] = h;
479
+ return { ...tab, href: h, history, historyIndex: idx };
480
+ }
481
+
482
+ export function pushTabHistory(tab: WorkspaceTab, href: string): WorkspaceTab {
483
+ const h = normalizeHref(href);
484
+ const trimmed = tab.history.slice(0, Math.max(0, tab.historyIndex) + 1);
485
+ if (trimmed[trimmed.length - 1] === h) {
486
+ return {
487
+ ...tab,
488
+ href: h,
489
+ history: trimmed,
490
+ historyIndex: trimmed.length - 1,
491
+ };
492
+ }
493
+ const history = [...trimmed, h].slice(-80);
494
+ return {
495
+ ...tab,
496
+ href: h,
497
+ history,
498
+ historyIndex: history.length - 1,
499
+ };
500
+ }
@@ -0,0 +1,50 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ const LOCATION_EVENT = "creezio-locationchange";
6
+ let historyPatched = false;
7
+
8
+ /**
9
+ * Next App Router change l'URL via history.pushState/replaceState, qui
10
+ * n'émettent AUCUN événement natif. Sans ça, une navigation qui ne change
11
+ * que la query (ex. /produits → /produits?view=sku) ne resynchronise jamais
12
+ * le hook (pathname identique, pas de popstate) : routeKey/cacheKey restent
13
+ * figés sur l'ancienne URL et les panes keep-alive se mélangent.
14
+ */
15
+ function patchHistoryOnce() {
16
+ if (historyPatched || typeof window === "undefined") return;
17
+ historyPatched = true;
18
+ for (const method of ["pushState", "replaceState"] as const) {
19
+ const original = window.history[method].bind(window.history);
20
+ window.history[method] = ((...args: Parameters<History["pushState"]>) => {
21
+ const result = original(...args);
22
+ window.dispatchEvent(new Event(LOCATION_EVENT));
23
+ return result;
24
+ }) as History["pushState"];
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Query string sans `?`, lue depuis window.location (évite Suspense useSearchParams).
30
+ * Se met à jour au changement de pathname, sur popstate ET sur tout
31
+ * pushState/replaceState (navigations Next qui ne changent que la query).
32
+ */
33
+ export function useLocationSearch(pathname: string): string {
34
+ const [, bump] = useState(0);
35
+
36
+ useEffect(() => {
37
+ patchHistoryOnce();
38
+ const sync = () => bump((n) => n + 1);
39
+ sync();
40
+ window.addEventListener("popstate", sync);
41
+ window.addEventListener(LOCATION_EVENT, sync);
42
+ return () => {
43
+ window.removeEventListener("popstate", sync);
44
+ window.removeEventListener(LOCATION_EVENT, sync);
45
+ };
46
+ }, [pathname]);
47
+
48
+ if (typeof window === "undefined") return "";
49
+ return window.location.search.replace(/^\?/, "");
50
+ }