@hgdc/ui 0.44.0 → 0.46.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.
@@ -15,7 +15,7 @@ export declare const APP_ORIGIN = "https://app.hgdc.ch";
15
15
  * console seule, `status.hgdc.ch` garde les pages publiques et la console de secours. Vérifiée contre
16
16
  * le manifeste privé de la famille (`famille check`) : un produit relayé s'ajoute ici et là.
17
17
  */
18
- export declare const APP_PRODUCTS: readonly ["analytics", "mail", "host", "docs", "status", "iassistant"];
18
+ export declare const APP_PRODUCTS: readonly ["analytics", "mail", "host", "docs", "status", "iassistant", "portal"];
19
19
  /** Vrai si le produit est servi sous `app.hgdc.ch`. */
20
20
  export declare function isAppProduct(product: string): boolean;
21
21
  /**
@@ -62,3 +62,30 @@ export type ProductAddresses = {
62
62
  * `x-forwarded-host` valent l'origine relayée).
63
63
  */
64
64
  export declare function productAddresses(product: string, env?: AddressOverrides): ProductAddresses;
65
+ /**
66
+ * Vue d'un produit : `app` sous `https://app.hgdc.ch/<mot>` (barre des produits), `seul` sur
67
+ * `https://<mot>.hgdc.ch` (le produit seul). Même build : la passerelle ajoute la base sur
68
+ * `<mot>.hgdc.ch`, toute adresse `/<mot>/…` y reste valide.
69
+ */
70
+ export type ProductView = "app" | "seul";
71
+ /** Origine de la vue seule d'un produit : `https://mail.hgdc.ch`. */
72
+ export declare function soloOrigin(product: string): string;
73
+ /** Origine publique d'une vue : une constante, jamais tirée de la requête. */
74
+ export declare function viewOrigin(product: string, view: ProductView): string;
75
+ /**
76
+ * Vue d'après le nom de la page (`location.hostname`) : `seul` sur `<mot>.hgdc.ch` d'un produit servi
77
+ * sous `app.hgdc.ch`, `app` sinon (Account n'a qu'une vue).
78
+ */
79
+ export declare function viewFromHostname(product: string, hostname: string): ProductView;
80
+ /**
81
+ * Origines publiques des consoles, pour une liste fermée (CORS d'Account) : `app.hgdc.ch` et la vue
82
+ * seule de chaque produit servi sous `app.hgdc.ch`.
83
+ */
84
+ export declare const CONSOLE_ORIGINS: ReadonlyArray<string>;
85
+ /**
86
+ * Adresse affichée en vue seule : la base retirée (`/mail/o/acme` devient `/o/acme`). À donner au
87
+ * routeur dans le navigateur, sur `<mot>.hgdc.ch` seulement :
88
+ * `createBrowserHistory({ createHref: (href) => soloHref(basePath, href) })`. Le serveur reçoit
89
+ * toujours la base (la passerelle l'ajoute) ; un chemin qui la garde reste valide.
90
+ */
91
+ export declare function soloHref(basePath: string, href: string): string;
package/dist/addresses.js CHANGED
@@ -15,7 +15,7 @@ export const APP_ORIGIN = "https://app.hgdc.ch";
15
15
  * console seule, `status.hgdc.ch` garde les pages publiques et la console de secours. Vérifiée contre
16
16
  * le manifeste privé de la famille (`famille check`) : un produit relayé s'ajoute ici et là.
17
17
  */
18
- export const APP_PRODUCTS = ["analytics", "mail", "host", "docs", "status", "iassistant"];
18
+ export const APP_PRODUCTS = ["analytics", "mail", "host", "docs", "status", "iassistant", "portal"];
19
19
  /** Vrai si le produit est servi sous `app.hgdc.ch`. */
20
20
  export function isAppProduct(product) {
21
21
  return APP_PRODUCTS.includes(product.toLowerCase());
@@ -71,3 +71,48 @@ export function productAddresses(product, env = {}) {
71
71
  withBase: (path) => `${basePath}${path}`,
72
72
  };
73
73
  }
74
+ /** Origine de la vue seule d'un produit : `https://mail.hgdc.ch`. */
75
+ export function soloOrigin(product) {
76
+ const word = product.toLowerCase();
77
+ if (!/^[a-z]+$/.test(word))
78
+ throw new Error(`@hgdc/ui/addresses : produit invalide « ${product} »`);
79
+ return `https://${word}.hgdc.ch`;
80
+ }
81
+ /** Origine publique d'une vue : une constante, jamais tirée de la requête. */
82
+ export function viewOrigin(product, view) {
83
+ return view === "seul" ? soloOrigin(product) : APP_ORIGIN;
84
+ }
85
+ /**
86
+ * Vue d'après le nom de la page (`location.hostname`) : `seul` sur `<mot>.hgdc.ch` d'un produit servi
87
+ * sous `app.hgdc.ch`, `app` sinon (Account n'a qu'une vue).
88
+ */
89
+ export function viewFromHostname(product, hostname) {
90
+ if (!isAppProduct(product))
91
+ return "app";
92
+ return `https://${hostname.toLowerCase()}` === soloOrigin(product) ? "seul" : "app";
93
+ }
94
+ /**
95
+ * Origines publiques des consoles, pour une liste fermée (CORS d'Account) : `app.hgdc.ch` et la vue
96
+ * seule de chaque produit servi sous `app.hgdc.ch`.
97
+ */
98
+ export const CONSOLE_ORIGINS = [APP_ORIGIN, ...APP_PRODUCTS.map(soloOrigin)];
99
+ /**
100
+ * Adresse affichée en vue seule : la base retirée (`/mail/o/acme` devient `/o/acme`). À donner au
101
+ * routeur dans le navigateur, sur `<mot>.hgdc.ch` seulement :
102
+ * `createBrowserHistory({ createHref: (href) => soloHref(basePath, href) })`. Le serveur reçoit
103
+ * toujours la base (la passerelle l'ajoute) ; un chemin qui la garde reste valide.
104
+ */
105
+ export function soloHref(basePath, href) {
106
+ const base = normalizeProductBasePath(basePath);
107
+ if (!base || !href.startsWith(base))
108
+ return href;
109
+ const rest = href.slice(base.length);
110
+ if (rest === "")
111
+ return "/";
112
+ if (rest.startsWith("?") || rest.startsWith("#"))
113
+ return `/${rest}`;
114
+ // `/mailbox` n'est pas sous `/mail` ; `//x` resterait une adresse externe.
115
+ if (!rest.startsWith("/") || rest.startsWith("//") || rest.includes("\\"))
116
+ return href;
117
+ return rest;
118
+ }
@@ -1,6 +1,7 @@
1
1
  import { type IconSvgElement } from "@hugeicons/react";
2
2
  import { type ReactNode } from "react";
3
3
  import type { FamilyProduct } from "./family-svg.ts";
4
+ import { type ProductView } from "./addresses.ts";
4
5
  /**
5
6
  * À poser dans le `<head>`, avec `themeInitScript` : avant la première peinture, il remet le
6
7
  * rail (la variante CSS `rail:` le lit, rien ne saute au chargement) et la mémoire de la coque
@@ -125,7 +126,7 @@ export type AppShellAssistant = {
125
126
  * deux images), le H n'apparaît alors jamais ; une page à charger le montre aussitôt après.
126
127
  */
127
128
  export declare const PRODUCT_LEAVE_DELAY_MS = 100;
128
- export declare function AppShell({ product, organization, productRail, accountHref, nav, links, account, sidebarFooter, header, onSearch, searchLabel, assistant, children, contentKey, navigating, mainClassName, statusPage, }: {
129
+ export declare function AppShell({ product, organization, productRail: productRailOption, view: viewOption, accountHref, nav, links, account, sidebarFooter, header, onSearch, searchLabel, assistant, children, contentKey, navigating, mainClassName, statusPage, }: {
129
130
  /** Le produit affiché (`"host"`) : sa marque en haut de la barre, « vous êtes ici » dans la barre des produits. */
130
131
  product: FamilyProduct;
131
132
  /**
@@ -136,9 +137,16 @@ export declare function AppShell({ product, organization, productRail, accountHr
136
137
  organization?: ReactNode;
137
138
  /**
138
139
  * `false` : pas de barre des produits (un écran hors de la famille). L'organisation passe alors
139
- * sous la marque, dans la barre latérale.
140
+ * sous la marque, dans la barre latérale. Absent : présente, ou, si `view` est donné, selon la vue
141
+ * et le réglage « Barre des produits » d'Account (ADR 0014), relu par la coque.
140
142
  */
141
143
  productRail?: boolean;
144
+ /**
145
+ * Vue de la page : `app` sous `app.hgdc.ch/<mot>`, `seul` sur `<mot>.hgdc.ch` (ADR 0014). Donnée
146
+ * par le serveur (`requestView` de `@hgdc/auth/server`), la même au rendu et dans le navigateur.
147
+ * Absent : un produit qui n'a qu'une vue, barre présente et pas de lien vers l'autre vue.
148
+ */
149
+ view?: ProductView | undefined;
142
150
  /** Adresse d'Account dans la barre des produits (un Account local en développement). */
143
151
  accountHref?: string | undefined;
144
152
  /** `SidebarNav` et ses `SidebarNavItem`. */
package/dist/app-shell.js CHANGED
@@ -5,6 +5,7 @@ import ArrowLeft01Icon from "@hugeicons/core-free-icons/ArrowLeft01Icon";
5
5
  import ArrowRight01Icon from "@hugeicons/core-free-icons/ArrowRight01Icon";
6
6
  import BookOpen01Icon from "@hugeicons/core-free-icons/BookOpen01Icon";
7
7
  import Cancel01Icon from "@hugeicons/core-free-icons/Cancel01Icon";
8
+ import LinkSquare02Icon from "@hugeicons/core-free-icons/LinkSquare02Icon";
8
9
  import Menu01Icon from "@hugeicons/core-free-icons/Menu01Icon";
9
10
  import PanelLeftCloseIcon from "@hugeicons/core-free-icons/PanelLeftCloseIcon";
10
11
  import PanelLeftOpenIcon from "@hugeicons/core-free-icons/PanelLeftOpenIcon";
@@ -17,7 +18,10 @@ import { Drawer, DrawerContent, DrawerTitle } from "./drawer.js";
17
18
  import { FamilyLoaderHost, FamilyLoaderRootContext } from "./family-loader.js";
18
19
  import { isApplePlatform, Kbd, KbdGroup } from "./kbd.js";
19
20
  import { CONTENT_LOADING_LAYER, ContentLoadingContext, PAGE_PENDING_MIN_MS, PAGE_PENDING_MS, PagePending, } from "./page-pending.js";
21
+ import { soloHref } from "./addresses.js";
20
22
  import { ProductBadge } from "./product-badge.js";
23
+ import { productBarShown, useProductBarPreference, useProductBarSync } from "./product-bar.js";
24
+ import { otherViewHref, otherViewLabel } from "./products.js";
21
25
  import { ProductRail } from "./product-rail.js";
22
26
  import { useProductSwitch } from "./product-switch.js";
23
27
  import { ProductRailShownContext, ShellLeaveContext, StatusPageContext } from "./shell-placement.js";
@@ -244,7 +248,7 @@ const COMMON_LINKS = [
244
248
  { key: "settings", label: "Réglages", icon: Settings01Icon },
245
249
  { key: "status", label: "État du service", icon: Activity01Icon },
246
250
  ];
247
- function ShellCommonLinks({ links, statusPageUrl, }) {
251
+ function ShellCommonLinks({ links, statusPageUrl, otherView, }) {
248
252
  const resolved = {
249
253
  documentation: links.documentation,
250
254
  settings: links.settings,
@@ -261,7 +265,21 @@ function ShellCommonLinks({ links, statusPageUrl, }) {
261
265
  });
262
266
  if (!items.length)
263
267
  return null;
264
- return (_jsx("nav", { "aria-label": "Acc\u00E8s communs", children: _jsx("ul", { "data-slot": "app-shell-links", className: "grid gap-0.5", children: items }) }));
268
+ return (_jsx("nav", { "aria-label": "Acc\u00E8s communs", children: _jsxs("ul", { "data-slot": "app-shell-links", className: "grid gap-0.5", children: [items, otherView] }) }));
269
+ }
270
+ /**
271
+ * « Ouvrir seul » (vers `<mot>.hgdc.ch`) ou « Ouvrir dans app.hgdc.ch », discret, sous les accès communs.
272
+ * Le chemin courant n'est lu qu'au clic : le lien rendu par le serveur mène à l'accueil.
273
+ */
274
+ function OtherViewLink({ product, view }) {
275
+ const href = otherViewHref(product, view);
276
+ if (!href)
277
+ return null;
278
+ const onClick = (event) => {
279
+ const path = soloHref(`/${product}`, `${window.location.pathname}${window.location.search}`);
280
+ event.currentTarget.href = otherViewHref(product, view, path) ?? href;
281
+ };
282
+ return (_jsx(SidebarNavItem, { icon: LinkSquare02Icon, label: otherViewLabel(view), render: _jsx("a", { href: href, onClick: onClick }) }));
265
283
  }
266
284
  /** Largeur à partir de laquelle la page et l'assistant tiennent côte à côte (`xl`). */
267
285
  const ASSISTANT_SIDE_BY_SIDE = "(min-width: 80rem)";
@@ -412,8 +430,12 @@ function useContentLoading({ navigating, pending }) {
412
430
  * deux images), le H n'apparaît alors jamais ; une page à charger le montre aussitôt après.
413
431
  */
414
432
  export const PRODUCT_LEAVE_DELAY_MS = 100;
415
- export function AppShell({ product, organization, productRail = true, accountHref, nav, links, account, sidebarFooter, header, onSearch, searchLabel, assistant, children, contentKey, navigating = false, mainClassName, statusPage, }) {
433
+ export function AppShell({ product, organization, productRail: productRailOption, view: viewOption, accountHref, nav, links, account, sidebarFooter, header, onSearch, searchLabel, assistant, children, contentKey, navigating = false, mainClassName, statusPage, }) {
416
434
  const [menuOpen, setMenuOpen] = useState(false);
435
+ const preference = useProductBarPreference();
436
+ // Relu chez Account seulement par un produit passé aux deux vues (il donne `view`, ADR 0014).
437
+ useProductBarSync(productRailOption === undefined && viewOption !== undefined);
438
+ const productRail = productRailOption ?? (viewOption === undefined || productBarShown(viewOption, preference));
417
439
  // Barre des produits élargie : la barre latérale se plie le temps du survol (ADR 0011).
418
440
  const [railPeek, setRailPeek] = useState(false);
419
441
  useToggleShortcut();
@@ -490,6 +512,6 @@ export function AppShell({ product, organization, productRail = true, accountHre
490
512
  const assistantPanelId = useId();
491
513
  const assistantButtonRef = useRef(null);
492
514
  useAssistantShortcut(assistant);
493
- const footer = (_jsxs(_Fragment, { children: [account !== undefined ? sidebarFooter : null, _jsx(ShellCommonLinks, { links: links ?? {}, statusPageUrl: statusPageUrl }), account ?? sidebarFooter] }));
515
+ const footer = (_jsxs(_Fragment, { children: [account !== undefined ? sidebarFooter : null, _jsx(ShellCommonLinks, { links: links ?? {}, statusPageUrl: statusPageUrl, otherView: viewOption === undefined ? null : _jsx(OtherViewLink, { product: product, view: viewOption }) }), account ?? sidebarFooter] }));
494
516
  return (_jsx(ShellContext.Provider, { value: { closeMobileNav: () => setMenuOpen(false), product }, children: _jsx(ProductRailShownContext, { value: productRail, children: _jsx(ShellLeaveContext, { value: leave, children: _jsx(StatusPageContext, { value: statusPageUrl, children: _jsxs("div", { "data-slot": "app-shell", "data-rail-peek": railPeek ? "" : undefined, "data-product-rail": productRail ? "" : undefined, "data-loading": loading ? "" : undefined, "data-hydrated": hydrated ? "" : undefined, "data-assistant": assistantOpen ? "" : undefined, className: cn("min-h-dvh lg:grid lg:h-dvh lg:bg-shell", productRail ? "lg:grid-cols-[auto_auto_minmax(0,1fr)_auto]" : "lg:grid-cols-[auto_minmax(0,1fr)_auto]"), children: [_jsx("a", { href: "#contenu", className: "sr-only z-50 rounded-xl bg-background px-4 py-2 text-sm font-medium focus:not-sr-only focus:fixed focus:top-3 focus:left-3 focus:ring-2 focus:ring-ring", children: "Aller au contenu" }), productRail ? (_jsx("div", { className: "contents", inert: pageBlocked || undefined, children: _jsx(ProductRail, { current: product, organization: organization, accountHref: accountHref, onExpandedChange: setRailPeek }) })) : null, _jsxs("aside", { ref: sidebarRef, "data-slot": "app-shell-sidebar", "aria-label": "Barre lat\u00E9rale", inert: pageBlocked || undefined, className: "group/sidebar hidden overflow-hidden text-shell-foreground transition-[width] duration-(--duration-base) ease-ios motion-reduce:transition-none lg:flex lg:w-64 lg:flex-col lg:rail:w-16", children: [_jsx(SidebarHead, { product: product }), !productRail && organization ? _jsx("div", { className: "shrink-0 px-3 pb-1", children: organization }) : null, nav, _jsx("div", { "data-slot": "app-shell-footer", className: "grid gap-0.5 px-3 pt-1 pb-3 empty:hidden", children: footer })] }), _jsx("div", { inert: pageBlocked || undefined, className: "flex min-h-dvh flex-col lg:min-h-0 lg:p-2 lg:pl-0", children: _jsxs("div", { ref: contentRef, className: "relative flex flex-1 flex-col lg:min-h-0 lg:overflow-y-auto lg:rounded-xl lg:bg-background lg:[scrollbar-gutter:stable]", children: [_jsxs("header", { "data-slot": "app-shell-header", className: "sticky top-[env(safe-area-inset-top,0px)] z-30 flex min-h-14 shrink-0 flex-wrap items-center gap-x-1 gap-y-1 border-b border-foreground/5 bg-background px-2 py-1.5 sm:px-4 lg:top-0", children: [_jsx("button", { type: "button", onClick: () => setMenuOpen(true), "aria-label": "Ouvrir le menu", className: "touch-target grid size-10 shrink-0 place-items-center rounded-xl text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 lg:hidden", children: _jsx(HugeiconsIcon, { icon: Menu01Icon, "aria-hidden": true, className: "size-5" }) }), _jsxs("div", { "data-slot": "app-shell-brand", className: "flex shrink-0 items-center gap-2 pr-1 lg:hidden", children: [_jsx(ProductBadge, { product: product, className: "size-6" }), _jsx("span", { className: "font-heading text-[0.9375rem] font-semibold", children: familyProductName(product) })] }), _jsx("div", { className: cn("flex min-w-0 items-center gap-2", header ? "flex-[1_1_7rem]" : "flex-1"), children: header }), onSearch ? (_jsx(CommandMenuButton, { onClick: onSearch, compact: "mobile", className: "shrink-0 lg:w-96 lg:max-w-none", ...(searchLabel !== undefined ? { label: searchLabel } : {}) })) : null, assistant ? (_jsx(AssistantButton, { assistant: assistant, panelId: assistantPanelId, buttonRef: assistantButtonRef })) : null] }), _jsx("div", { "data-slot": "app-shell-loading", "aria-hidden": !loading, className: CONTENT_LOADING_LAYER, children: loading ? (_jsx("span", { role: "status", className: "sr-only", children: "Chargement\u2026" })) : null }), _jsxs("main", { id: "contenu", tabIndex: -1, "aria-busy": loading ? true : undefined, inert: loading || undefined, className: cn("mx-auto w-full max-w-6xl flex-1 px-4 pt-6 pb-16 outline-none sm:px-6 lg:px-10 lg:pt-8", mainClassName), children: [_jsx(ServiceStatus, { pageUrl: statusPageUrl, className: "mb-6" }), _jsx(ContentLoadingContext, { value: registerPagePending, children: contentKey === undefined ? (children) : (_jsx(Suspense, { fallback: _jsx(PagePending, {}), children: children }, contentKey)) })] })] }) }), assistant && assistantMounted ? (_jsx(AssistantPanel, { assistant: assistant, panelId: assistantPanelId, wide: wide, openAtShellMount: assistantOpenAtMount, buttonRef: assistantButtonRef })) : null, hasRoot ? null : _jsx(FamilyLoaderHost, {}), _jsx(Drawer, { side: "bottom", open: menuOpen, onOpenChange: setMenuOpen, children: _jsxs(DrawerContent, { className: "lg:hidden", children: [_jsx(DrawerTitle, { className: "sr-only", children: "Menu" }), organization ? _jsx("div", { className: "px-3 pt-1", children: organization }) : null, nav, _jsx("div", { className: "grid gap-0.5 border-t border-foreground/5 px-3 pt-2 pb-[max(env(safe-area-inset-bottom),0.75rem)] empty:hidden", children: footer })] }) })] }) }) }) }) }));
495
517
  }
@@ -0,0 +1,29 @@
1
+ import type { ProductView } from "./addresses.ts";
2
+ /**
3
+ * Réglage « Barre des produits » (ADR 0014 de la famille), choisi dans Account :
4
+ * - `auto` : la barre sous `app.hgdc.ch`, pas sur `<mot>.hgdc.ch` ;
5
+ * - `always` : toujours ;
6
+ * - `never` : jamais.
7
+ *
8
+ * Account le garde ; chaque produit le relit (`ACCOUNT_PREFERENCES_URL`, CORS avec le cookie
9
+ * d'Account, même site `hgdc.ch`) et retient la dernière valeur dans `localStorage` de son origine.
10
+ * Account injoignable : dernière valeur connue, sinon `auto`.
11
+ */
12
+ export type ProductBarPreference = "auto" | "always" | "never";
13
+ export declare const PRODUCT_BAR_PREFERENCES: ReadonlyArray<ProductBarPreference>;
14
+ /** Libellés du réglage, dans l'ordre d'affichage. */
15
+ export declare const PRODUCT_BAR_LABELS: Readonly<Record<ProductBarPreference, string>>;
16
+ /** Lecture du réglage chez Account : `{ productBar }`, rien d'autre. */
17
+ export declare const ACCOUNT_PREFERENCES_URL = "https://account.hgdc.ch/api/preferences";
18
+ /** Valeur reçue (Account, stockage) : un réglage connu, sinon `auto`. */
19
+ export declare function parseProductBar(value: unknown): ProductBarPreference;
20
+ /** La barre des produits est-elle montrée dans cette vue, avec ce réglage ? */
21
+ export declare function productBarShown(view: ProductView, preference: ProductBarPreference): boolean;
22
+ /** Retient le réglage dans cette origine et prévient la coque. */
23
+ export declare function rememberProductBar(preference: ProductBarPreference): void;
24
+ /** Le réglage retenu dans cette origine ; `auto` au rendu serveur. */
25
+ export declare function useProductBarPreference(): ProductBarPreference;
26
+ /** Relit le réglage chez Account une fois par chargement de page ; une erreur garde la dernière valeur. */
27
+ export declare function syncProductBar(fetchImpl?: typeof fetch): Promise<void>;
28
+ /** À monter une fois (la coque le fait) : relecture du réglage après l'hydratation. */
29
+ export declare function useProductBarSync(enabled?: boolean): void;
@@ -0,0 +1,82 @@
1
+ import { useEffect, useSyncExternalStore } from "react";
2
+ export const PRODUCT_BAR_PREFERENCES = ["auto", "always", "never"];
3
+ /** Libellés du réglage, dans l'ordre d'affichage. */
4
+ export const PRODUCT_BAR_LABELS = {
5
+ auto: "Automatique (selon l'adresse)",
6
+ always: "Toujours",
7
+ never: "Jamais",
8
+ };
9
+ /** Lecture du réglage chez Account : `{ productBar }`, rien d'autre. */
10
+ export const ACCOUNT_PREFERENCES_URL = "https://account.hgdc.ch/api/preferences";
11
+ const STORAGE_KEY = "hgdc-product-bar";
12
+ const EVENT = "hgdc-product-bar";
13
+ /** Valeur reçue (Account, stockage) : un réglage connu, sinon `auto`. */
14
+ export function parseProductBar(value) {
15
+ return PRODUCT_BAR_PREFERENCES.includes(value) ? value : "auto";
16
+ }
17
+ /** La barre des produits est-elle montrée dans cette vue, avec ce réglage ? */
18
+ export function productBarShown(view, preference) {
19
+ if (preference === "always")
20
+ return true;
21
+ if (preference === "never")
22
+ return false;
23
+ return view === "app";
24
+ }
25
+ function stored() {
26
+ try {
27
+ return parseProductBar(window.localStorage.getItem(STORAGE_KEY));
28
+ }
29
+ catch {
30
+ return "auto";
31
+ }
32
+ }
33
+ function subscribe(onChange) {
34
+ window.addEventListener(EVENT, onChange);
35
+ window.addEventListener("storage", onChange);
36
+ return () => {
37
+ window.removeEventListener(EVENT, onChange);
38
+ window.removeEventListener("storage", onChange);
39
+ };
40
+ }
41
+ /** Retient le réglage dans cette origine et prévient la coque. */
42
+ export function rememberProductBar(preference) {
43
+ try {
44
+ if (preference === "auto")
45
+ window.localStorage.removeItem(STORAGE_KEY);
46
+ else
47
+ window.localStorage.setItem(STORAGE_KEY, preference);
48
+ }
49
+ catch {
50
+ // Stockage bloqué : la coque garde « automatique ».
51
+ }
52
+ window.dispatchEvent(new Event(EVENT));
53
+ }
54
+ /** Le réglage retenu dans cette origine ; `auto` au rendu serveur. */
55
+ export function useProductBarPreference() {
56
+ return useSyncExternalStore(subscribe, stored, () => "auto");
57
+ }
58
+ /** Relit le réglage chez Account une fois par chargement de page ; une erreur garde la dernière valeur. */
59
+ export async function syncProductBar(fetchImpl = fetch) {
60
+ try {
61
+ const response = await fetchImpl(ACCOUNT_PREFERENCES_URL, { credentials: "include", cache: "no-store" });
62
+ if (!response.ok)
63
+ return;
64
+ const body = await response.json();
65
+ if (typeof body === "object" && body !== null && "productBar" in body)
66
+ rememberProductBar(parseProductBar(body.productBar));
67
+ }
68
+ catch {
69
+ // Account injoignable ou hors ligne : rien ne change.
70
+ }
71
+ }
72
+ let synced = false;
73
+ /** À monter une fois (la coque le fait) : relecture du réglage après l'hydratation. */
74
+ export function useProductBarSync(enabled = true) {
75
+ useEffect(() => {
76
+ // Account n'accepte que les consoles de `hgdc.ch` : en local, en test, rien à demander.
77
+ if (!enabled || synced || !window.location.hostname.endsWith(".hgdc.ch"))
78
+ return;
79
+ synced = true;
80
+ void syncProductBar();
81
+ }, [enabled]);
82
+ }
@@ -1,4 +1,5 @@
1
1
  import type { IconSvgElement } from "@hugeicons/react";
2
+ import { type ProductView } from "./addresses.ts";
2
3
  import { type EmailAccent } from "./email-palette.ts";
3
4
  import { type FamilyProduct } from "./family-svg.ts";
4
5
  /** Où en est un produit : seul ce qui répond s'ouvre depuis les menus. */
@@ -131,3 +132,13 @@ export declare function productRailEntries(options?: {
131
132
  products: Array<ProductRailEntry>;
132
133
  account: ProductRailEntry;
133
134
  };
135
+ /**
136
+ * Lien vers l'autre vue du même produit (ADR 0014) : depuis `app.hgdc.ch/<mot>`, « Ouvrir seul » vers
137
+ * `<mot>.hgdc.ch` ; depuis `<mot>.hgdc.ch`, « Ouvrir dans app.hgdc.ch ». Passe par l'entrée
138
+ * (`/auth/hgdc?next=<chemin>`) : la session de l'autre origine s'ouvre sans clic si celle
139
+ * d'Account vit. `path` est un chemin du produit, sans la base ; un autre texte mène à l'accueil.
140
+ * `null` pour un produit qui n'a pas deux vues.
141
+ */
142
+ export declare function otherViewHref(word: string, view: ProductView, path?: string): string | null;
143
+ /** Libellé du lien vers l'autre vue. */
144
+ export declare function otherViewLabel(view: ProductView): string;
package/dist/products.js CHANGED
@@ -6,7 +6,7 @@ import Briefcase01Icon from "@hugeicons/core-free-icons/Briefcase01Icon";
6
6
  import Mail01Icon from "@hugeicons/core-free-icons/Mail01Icon";
7
7
  import Rocket01Icon from "@hugeicons/core-free-icons/Rocket01Icon";
8
8
  import UserCircleIcon from "@hugeicons/core-free-icons/UserCircleIcon";
9
- import { APP_ORIGIN, APP_PRODUCTS } from "./addresses.js";
9
+ import { APP_ORIGIN, APP_PRODUCTS, isAppProduct, soloOrigin } from "./addresses.js";
10
10
  import { HGDC_EMAIL_ACCENT } from "./email-palette.js";
11
11
  import { isFamilyProduct } from "./family-svg.js";
12
12
  export const PRODUCT_STAGE_LABEL = {
@@ -145,3 +145,22 @@ export function productRailEntries(options = {}) {
145
145
  account: entry(HGDC_ACCOUNT, options.accountHref ?? HGDC_ACCOUNT.entryHref, false),
146
146
  };
147
147
  }
148
+ /**
149
+ * Lien vers l'autre vue du même produit (ADR 0014) : depuis `app.hgdc.ch/<mot>`, « Ouvrir seul » vers
150
+ * `<mot>.hgdc.ch` ; depuis `<mot>.hgdc.ch`, « Ouvrir dans app.hgdc.ch ». Passe par l'entrée
151
+ * (`/auth/hgdc?next=<chemin>`) : la session de l'autre origine s'ouvre sans clic si celle
152
+ * d'Account vit. `path` est un chemin du produit, sans la base ; un autre texte mène à l'accueil.
153
+ * `null` pour un produit qui n'a pas deux vues.
154
+ */
155
+ export function otherViewHref(word, view, path = "/") {
156
+ const key = word.toLowerCase();
157
+ if (!isAppProduct(key))
158
+ return null;
159
+ const next = path.startsWith("/") && !path.startsWith("//") && !path.includes("\\") ? path : "/";
160
+ const target = view === "app" ? soloOrigin(key) : `${APP_ORIGIN}/${key}`;
161
+ return `${target}${PRODUCT_ENTRY_PATH}?next=${encodeURIComponent(next)}`;
162
+ }
163
+ /** Libellé du lien vers l'autre vue. */
164
+ export function otherViewLabel(view) {
165
+ return view === "app" ? "Ouvrir seul" : "Ouvrir dans app.hgdc.ch";
166
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hgdc/ui",
3
- "version": "0.44.0",
3
+ "version": "0.46.0",
4
4
  "description": "Interface de la H Collection : jetons de la DA, thèmes, polices et composants.",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://github.com/hugogebel-boop/hgdc-kit/tree/main/packages/ui#readme",