@inwink-mcp/backoffice-extension-helpers 1.0.0 → 1.0.2

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 (26) hide show
  1. package/dist/cli.js +2 -1
  2. package/package.json +1 -1
  3. package/templates/test-app/src/components/Header.tsx +36 -0
  4. package/templates/test-app/src/pages/HomePage.tsx.tpl +16 -14
  5. package/templates/vite-extension/AGENTS.md.tpl +61 -3
  6. package/templates/vite-extension/extension.manifest.json.tpl +1 -1
  7. package/templates/vite-extension/index.html.tpl +2 -0
  8. package/templates/vite-extension/src/App.tsx.tpl +14 -8
  9. package/templates/vite-extension/src/auth/AuthProvider.tsx +36 -18
  10. package/templates/vite-extension/src/components/AudiencePicker.tsx +25 -0
  11. package/templates/vite-extension/src/components/CommunityPicker.tsx +25 -0
  12. package/templates/vite-extension/src/components/ContextCombobox.css +110 -0
  13. package/templates/vite-extension/src/components/ContextCombobox.tsx +167 -0
  14. package/templates/vite-extension/src/components/EventPicker.tsx +25 -0
  15. package/templates/vite-extension/src/components/Header.css +25 -0
  16. package/templates/vite-extension/src/components/Header.tsx +37 -0
  17. package/templates/vite-extension/src/components/Layout.css +13 -0
  18. package/templates/vite-extension/src/components/Layout.tsx +19 -0
  19. package/templates/vite-extension/src/context/InwinkContextProvider.tsx +170 -0
  20. package/templates/vite-extension/src/hooks/useContextOptions.ts +106 -0
  21. package/templates/vite-extension/src/hooks/useInwinkApi.ts +53 -50
  22. package/templates/vite-extension/src/hooks/useInwinkContext.ts +13 -0
  23. package/templates/vite-extension/src/lib/inwinkContextParams.ts +80 -0
  24. package/templates/vite-extension/src/main.tsx +4 -1
  25. package/templates/vite-extension/src/pages/HomePage.tsx.tpl +22 -18
  26. package/templates/vite-extension/src/styles.css +68 -9
@@ -0,0 +1,25 @@
1
+ import ContextCombobox from './ContextCombobox';
2
+ import { useContextOptions } from '../hooks/useContextOptions';
3
+ import { useInwinkContext } from '../hooks/useInwinkContext';
4
+
5
+ /**
6
+ * Sélecteur d'événement courant. Pré-rempli depuis le contexte (URL) et modifiable.
7
+ * Composant autonome, montable où l'on veut (header, page…). Non monté par défaut.
8
+ */
9
+ export default function EventPicker() {
10
+ const { eventId, setEvent, loadEvents } = useInwinkContext();
11
+ const { options, isLoading, error, search } = useContextOptions(loadEvents, eventId);
12
+
13
+ return (
14
+ <ContextCombobox
15
+ label="Événement"
16
+ placeholder="Choisir un événement"
17
+ value={eventId}
18
+ options={options}
19
+ isLoading={isLoading}
20
+ error={error}
21
+ onChange={setEvent}
22
+ onSearch={search}
23
+ />
24
+ );
25
+ }
@@ -0,0 +1,25 @@
1
+ .app-header {
2
+ position: sticky;
3
+ top: 0;
4
+ z-index: 10;
5
+ display: flex;
6
+ align-items: center;
7
+ justify-content: space-between;
8
+ gap: 1rem;
9
+ padding: 0.75rem 1.5rem;
10
+ background: var(--color-bg);
11
+ border-bottom: 1px solid var(--color-border);
12
+ box-shadow: var(--shadow-sm);
13
+ }
14
+
15
+ .app-header__brand {
16
+ font-family: var(--font-title);
17
+ font-weight: 600;
18
+ font-size: 1.1rem;
19
+ }
20
+
21
+ .app-header__actions {
22
+ display: flex;
23
+ align-items: center;
24
+ gap: 0.75rem;
25
+ }
@@ -0,0 +1,37 @@
1
+ import './Header.css';
2
+ import { FiLogIn, FiLogOut } from 'react-icons/fi';
3
+ import { useAuth } from '../hooks/useAuth';
4
+
5
+ interface HeaderProps {
6
+ title: string;
7
+ }
8
+
9
+ /**
10
+ * En-tête de l'application : marque à gauche, bouton de connexion/déconnexion à droite.
11
+ *
12
+ * Composant volontairement isolé dans son propre fichier pour être facilement
13
+ * surchargé/personnalisé (ex. y monter un EventPicker / CommunityPicker selon
14
+ * l'usage de l'extension). Les pickers ne sont PAS montés ici par défaut.
15
+ */
16
+ export default function Header({ title }: HeaderProps) {
17
+ const { isAuthenticated, isLoading, login, logout } = useAuth();
18
+
19
+ return (
20
+ <header className="app-header">
21
+ <div className="app-header__brand">{title}</div>
22
+ <div className="app-header__actions">
23
+ {isAuthenticated ? (
24
+ <button onClick={() => void logout()}>
25
+ <FiLogOut aria-hidden />
26
+ Se déconnecter
27
+ </button>
28
+ ) : (
29
+ <button className="btn-primary" disabled={isLoading} onClick={() => void login()}>
30
+ <FiLogIn aria-hidden />
31
+ {isLoading ? 'Initialisation…' : 'Se connecter'}
32
+ </button>
33
+ )}
34
+ </div>
35
+ </header>
36
+ );
37
+ }
@@ -0,0 +1,13 @@
1
+ .app-layout {
2
+ min-height: 100vh;
3
+ display: flex;
4
+ flex-direction: column;
5
+ }
6
+
7
+ .app-content {
8
+ flex: 1;
9
+ width: 100%;
10
+ max-width: 960px;
11
+ margin: 0 auto;
12
+ padding: 2rem 1.5rem;
13
+ }
@@ -0,0 +1,19 @@
1
+ import './Layout.css';
2
+ import { Outlet } from 'react-router-dom';
3
+ import Header from './Header';
4
+
5
+ interface LayoutProps {
6
+ title: string;
7
+ }
8
+
9
+ /** Gabarit applicatif : Header + zone de contenu (routes enfants via Outlet). */
10
+ export default function Layout({ title }: LayoutProps) {
11
+ return (
12
+ <div className="app-layout">
13
+ <Header title={title} />
14
+ <main className="app-content">
15
+ <Outlet />
16
+ </main>
17
+ </div>
18
+ );
19
+ }
@@ -0,0 +1,170 @@
1
+ // Contexte inwink : expose l'objet courant (event / community / tenant) déduit
2
+ // de l'URL ou choisi par l'utilisateur, et les loaders de listes pour les pickers.
3
+ import { createContext, useMemo, useState, type PropsWithChildren } from 'react';
4
+ import { useInwinkCustomerApi } from '../hooks/useInwinkCustomerApi';
5
+ import {
6
+ deriveScope,
7
+ loadPersistedContext,
8
+ persistContext,
9
+ readInwinkContextFromUrl,
10
+ type InwinkContextParams,
11
+ type InwinkScope,
12
+ } from '../lib/inwinkContextParams';
13
+
14
+ /** Option affichable dans un picker (événement, communauté ou tenant). */
15
+ export interface InwinkContextOption {
16
+ id: string;
17
+ label: string;
18
+ }
19
+
20
+ export interface IInwinkContextQuery {
21
+ search?: string;
22
+ id?: string;
23
+ }
24
+
25
+ interface InwinkContextValue {
26
+ scope: InwinkScope;
27
+ eventId?: string;
28
+ communityId?: string;
29
+ tenantId?: string;
30
+ /** Sélectionne un événement (réinitialise la communauté). */
31
+ setEvent: (id: string) => void;
32
+ /** Sélectionne une communauté (réinitialise l'événement). */
33
+ setCommunity: (id: string) => void;
34
+ /** Sélectionne un tenant / audience (réinitialise event + community). */
35
+ setTenant: (id: string) => void;
36
+ /**
37
+ * Liste les événements de l'utilisateur (API Customer `me/events/query`),
38
+ * triés par date décroissante. `query` filtre côté serveur par nom ou id.
39
+ */
40
+ loadEvents: (query?: IInwinkContextQuery) => Promise<InwinkContextOption[]>;
41
+ /**
42
+ * Liste les communautés de l'utilisateur (API Customer `me/communities/query`).
43
+ * `query` filtre côté serveur par nom ou id.
44
+ */
45
+ loadCommunities: (query?: IInwinkContextQuery) => Promise<InwinkContextOption[]>;
46
+ /**
47
+ * Liste les tenants / audiences de l'utilisateur (API Customer `me/tenants/query`).
48
+ * `query` filtre côté serveur par nom ou id.
49
+ */
50
+ loadTenants: (query?: IInwinkContextQuery) => Promise<InwinkContextOption[]>;
51
+ }
52
+
53
+ export const InwinkContext = createContext<InwinkContextValue | null>(null);
54
+
55
+ // Pagination des requêtes `*/query`. Les listes sont paginées côté serveur :
56
+ // la recherche (`query`) sert à retrouver un objet au-delà de la 1re page.
57
+ const PAGE = { index: 0, size: 200 };
58
+
59
+ // Construit le corps d'une requête de liste (`me/<entité>/query`) :
60
+ // - `query` → filtre serveur par id (égalité) OU libellé (`$contains`) ;
61
+ // - `labelField` → champ texte de l'entité (`title` pour les events, `name` sinon) ;
62
+ // - `orders` → tri serveur (ex. date décroissante pour les events).
63
+ // Ajuste `selects` / `filters` selon les entités après discovery MCP (cf. AGENTS.md).
64
+ function buildQueryBody(opts: {
65
+ query?: IInwinkContextQuery;
66
+ labelField: string;
67
+ orders?: unknown[];
68
+ }): Record<string, unknown> {
69
+ const body: Record<string, any> = { page: PAGE };
70
+ if (opts.orders) body.orders = opts.orders;
71
+ if (opts.query?.id) {
72
+ body.filters ??= {};
73
+ body.filters.id = opts.query.id;
74
+ } else if (opts.query?.search) {
75
+ body.filters ??= {};
76
+ const q = opts.query?.search?.trim();
77
+ if (q) {
78
+ body.filters.$and ??= [];
79
+ //detect if q is a guid
80
+ const guidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
81
+ if (guidRegex.test(q)) {
82
+ body.filters.$and.push({ id: q });
83
+ } else {
84
+ body.filters.$and.push({ [opts.labelField]: { $contains: q } });
85
+ }
86
+
87
+ }
88
+ }
89
+ return body;
90
+ }
91
+
92
+ function asRecord(value: unknown): Record<string, unknown> {
93
+ return value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
94
+ }
95
+
96
+ // Normalise une réponse `*/query` en options { id, label }, quel que soit le wrapping.
97
+ function normalizeOptions(raw: unknown): InwinkContextOption[] {
98
+ const root = asRecord(raw);
99
+ const list: unknown[] = Array.isArray(raw)
100
+ ? raw
101
+ : Array.isArray(root.data)
102
+ ? (root.data as unknown[])
103
+ : Array.isArray(root.items)
104
+ ? (root.items as unknown[])
105
+ : [];
106
+
107
+ const options: InwinkContextOption[] = [];
108
+ for (const entry of list) {
109
+ const item = asRecord(entry);
110
+ const id = item.id ?? item.eventId ?? item.communityId ?? item.tenantId;
111
+ if (id == null) continue;
112
+ const label = item.title ?? item.name ?? item.displayName ?? id;
113
+ options.push({ id: String(id), label: String(label) });
114
+ }
115
+ return options;
116
+ }
117
+
118
+ /** Init : l'URL fait foi ; sinon on retombe sur le contexte persisté en session. */
119
+ function initialParams(): InwinkContextParams {
120
+ const fromUrl = readInwinkContextFromUrl();
121
+ if (fromUrl.eventId || fromUrl.communityId || fromUrl.tenantId) {
122
+ persistContext(fromUrl);
123
+ return fromUrl;
124
+ }
125
+ return loadPersistedContext();
126
+ }
127
+
128
+ export function InwinkContextProvider({ children }: PropsWithChildren) {
129
+ const customerApi = useInwinkCustomerApi();
130
+ const [params, setParams] = useState<InwinkContextParams>(initialParams);
131
+
132
+ const value = useMemo<InwinkContextValue>(() => {
133
+ const apply = (next: InwinkContextParams) => {
134
+ setParams(next);
135
+ persistContext(next);
136
+ };
137
+ return {
138
+ scope: deriveScope(params),
139
+ eventId: params.eventId,
140
+ communityId: params.communityId,
141
+ tenantId: params.tenantId,
142
+ setEvent: (id) => apply({ ...params, eventId: id, communityId: undefined }),
143
+ setCommunity: (id) => apply({ ...params, communityId: id, eventId: undefined }),
144
+ setTenant: (id) => apply({ tenantId: id }),
145
+ loadEvents: async (query?: IInwinkContextQuery) =>
146
+ normalizeOptions(
147
+ await customerApi.post(
148
+ '/00000000-0000-0000-0000-000000000000/my/events/query',
149
+ buildQueryBody({ query, labelField: 'title', orders: [{ desc: true, value: { startDate: {} } }] }),
150
+ ),
151
+ ),
152
+ loadCommunities: async (query?: IInwinkContextQuery) =>
153
+ normalizeOptions(
154
+ await customerApi.post(
155
+ '/00000000-0000-0000-0000-000000000000/my/communities/query',
156
+ buildQueryBody({ query, labelField: 'name' }),
157
+ ),
158
+ ),
159
+ loadTenants: async (query?: IInwinkContextQuery) =>
160
+ normalizeOptions(
161
+ await customerApi.post(
162
+ '/00000000-0000-0000-0000-000000000000/my/tenants/query',
163
+ buildQueryBody({ query, labelField: 'name' }),
164
+ ),
165
+ ),
166
+ };
167
+ }, [params, customerApi]);
168
+
169
+ return <InwinkContext.Provider value={value}>{children}</InwinkContext.Provider>;
170
+ }
@@ -0,0 +1,106 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import type { IInwinkContextQuery, InwinkContextOption } from '../context/InwinkContextProvider';
3
+
4
+ interface ContextOptionsState {
5
+ options: InwinkContextOption[];
6
+ isLoading: boolean;
7
+ error: string | null;
8
+ /** Relance le loader avec un terme de recherche texte (debouncé). */
9
+ search: (query: string) => void;
10
+ }
11
+
12
+ const DEBOUNCE_MS = 250;
13
+
14
+ /**
15
+ * Charge une liste d'options de contexte via le loader fourni
16
+ * (`loadEvents` / `loadCommunities` / `loadTenants` de useInwinkContext).
17
+ * Mutualise la gestion chargement / erreur des pickers et expose `search()`
18
+ * pour relancer le loader côté serveur (par nom ou id) en debounçant la saisie.
19
+ *
20
+ * `selectedId` (id courant, typiquement issu de l'URL) est fetché par id et
21
+ * fusionné dans les options : la liste `me/<entité>/query` étant paginée (1ʳᵉ page
22
+ * seulement), l'objet sélectionné peut ne pas y figurer alors qu'il existe — sans ce
23
+ * fetch le picker afficherait l'id brut au lieu de son libellé.
24
+ *
25
+ * Les réponses obsolètes (saisie rapide, démontage) sont ignorées.
26
+ */
27
+ export function useContextOptions(
28
+ load: (query?: IInwinkContextQuery) => Promise<InwinkContextOption[]>,
29
+ selectedId?: string,
30
+ ): ContextOptionsState {
31
+ const [options, setOptions] = useState<InwinkContextOption[]>([]);
32
+ const [selectedOption, setSelectedOption] = useState<InwinkContextOption | null>(null);
33
+ const [isLoading, setIsLoading] = useState(true);
34
+ const [error, setError] = useState<string | null>(null);
35
+
36
+ // `load` provient du contexte et change d'identité à chaque rendu : on le garde
37
+ // dans une ref pour ne pas relancer le chargement initial à chaque rendu.
38
+ const loadRef = useRef(load);
39
+ loadRef.current = load;
40
+
41
+ // Identifiant monotone de requête : seule la dernière en vol met à jour l'état.
42
+ const reqId = useRef(0);
43
+ const debounce = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
44
+
45
+ const run = useCallback((query?: IInwinkContextQuery) => {
46
+ const id = ++reqId.current;
47
+ setIsLoading(true);
48
+ setError(null);
49
+ loadRef.current(query)
50
+ .then((opts) => {
51
+ if (id === reqId.current) setOptions(opts);
52
+ })
53
+ .catch((err: unknown) => {
54
+ if (id === reqId.current) setError(err instanceof Error ? err.message : String(err));
55
+ })
56
+ .finally(() => {
57
+ if (id === reqId.current) setIsLoading(false);
58
+ });
59
+ }, []);
60
+
61
+ // Chargement initial (sans filtre) au montage ; invalide les requêtes en vol au démontage.
62
+ useEffect(() => {
63
+ run();
64
+ return () => {
65
+ reqId.current++;
66
+ clearTimeout(debounce.current);
67
+ };
68
+ }, [run]);
69
+
70
+ // Fetch du détail de l'objet sélectionné par id (cf. doc ci-dessus).
71
+ useEffect(() => {
72
+ if (!selectedId) {
73
+ setSelectedOption(null);
74
+ return;
75
+ }
76
+ let active = true;
77
+ loadRef.current({ id: selectedId })
78
+ .then((opts) => {
79
+ if (active) setSelectedOption(opts.find((o) => o.id === selectedId) ?? opts[0] ?? null);
80
+ })
81
+ .catch(() => {
82
+ // En cas d'échec, on laisse le picker afficher l'id brut : non bloquant.
83
+ });
84
+ return () => {
85
+ active = false;
86
+ };
87
+ }, [selectedId]);
88
+
89
+ const search = useCallback(
90
+ (query: string) => {
91
+ clearTimeout(debounce.current);
92
+ const q = query.trim();
93
+ debounce.current = setTimeout(() => run(q ? { search: q } : undefined), DEBOUNCE_MS);
94
+ },
95
+ [run],
96
+ );
97
+
98
+ // Garantit que l'option sélectionnée est toujours présente (et en tête) même si
99
+ // elle est absente de la page courante ou d'un résultat de recherche filtré.
100
+ const mergedOptions = useMemo(() => {
101
+ if (!selectedOption || options.some((o) => o.id === selectedOption.id)) return options;
102
+ return [selectedOption, ...options];
103
+ }, [options, selectedOption]);
104
+
105
+ return { options: mergedOptions, isLoading, error, search };
106
+ }
@@ -1,3 +1,4 @@
1
+ import { useMemo } from 'react';
1
2
  import { useAuth } from './useAuth';
2
3
 
3
4
  /** Erreur levée lorsqu'une réponse API n'est pas OK (hors 401 géré par renouvellement). */
@@ -38,64 +39,66 @@ async function safeBody(response: Response): Promise<unknown> {
38
39
  export function useInwinkApi(baseUrl: string): InwinkApi {
39
40
  const { getAccessToken, renewToken, login } = useAuth();
40
41
 
41
- async function doFetch(path: string, token: string, init?: RequestInit): Promise<Response> {
42
- return fetch(`${baseUrl}${path}`, {
43
- ...init,
44
- headers: {
45
- Authorization: `Bearer ${token}`,
46
- 'Content-Type': 'application/json',
47
- ...(init?.headers as Record<string, string> | undefined),
48
- },
49
- });
50
- }
51
-
52
- async function request<T = unknown>(path: string, init?: RequestInit): Promise<T> {
53
- let token = getAccessToken();
54
- if (!token) {
55
- await login();
56
- throw new InwinkApiError(401, 'Non authentifié.');
42
+ return useMemo(() => {
43
+ async function doFetch(path: string, token: string, init?: RequestInit): Promise<Response> {
44
+ return fetch(`${baseUrl}${path}`, {
45
+ ...init,
46
+ headers: {
47
+ Authorization: `Bearer ${token}`,
48
+ 'Content-Type': 'application/json',
49
+ ...(init?.headers as Record<string, string> | undefined),
50
+ },
51
+ });
57
52
  }
58
53
 
59
- let response = await doFetch(path, token, init);
60
-
61
- if (response.status === 401) {
62
- const renewed = await renewToken();
63
- if (renewed) {
64
- token = renewed;
65
- response = await doFetch(path, token, init);
54
+ async function request<T = unknown>(path: string, init?: RequestInit): Promise<T> {
55
+ let token = getAccessToken();
56
+ if (!token) {
57
+ await login();
58
+ throw new InwinkApiError(401, 'Non authentifié.');
66
59
  }
60
+
61
+ let response = await doFetch(path, token, init);
62
+
67
63
  if (response.status === 401) {
68
- await login();
69
- throw new InwinkApiError(401, 'Session expirée, reconnexion requise.');
64
+ const renewed = await renewToken();
65
+ if (renewed) {
66
+ token = renewed;
67
+ response = await doFetch(path, token, init);
68
+ }
69
+ if (response.status === 401) {
70
+ await login();
71
+ throw new InwinkApiError(401, 'Session expirée, reconnexion requise.');
72
+ }
70
73
  }
71
- }
72
74
 
73
- if (!response.ok) {
74
- throw new InwinkApiError(response.status, await safeBody(response));
75
- }
75
+ if (!response.ok) {
76
+ throw new InwinkApiError(response.status, await safeBody(response));
77
+ }
76
78
 
77
- if (response.status === 204) {
78
- return undefined as T;
79
+ if (response.status === 204) {
80
+ return undefined as T;
81
+ }
82
+ return (await response.json()) as T;
79
83
  }
80
- return (await response.json()) as T;
81
- }
82
84
 
83
- const withBody =
84
- (method: string) =>
85
- <T = unknown>(path: string, body?: unknown, init?: RequestInit): Promise<T> =>
86
- request<T>(path, {
87
- ...init,
88
- method,
89
- body: body === undefined ? init?.body : JSON.stringify(body),
90
- });
85
+ const withBody =
86
+ (method: string) =>
87
+ <T = unknown>(path: string, body?: unknown, init?: RequestInit): Promise<T> =>
88
+ request<T>(path, {
89
+ ...init,
90
+ method,
91
+ body: body === undefined ? init?.body : JSON.stringify(body),
92
+ });
91
93
 
92
- return {
93
- request,
94
- get: <T = unknown>(path: string, init?: RequestInit) =>
95
- request<T>(path, { ...init, method: 'GET' }),
96
- post: withBody('POST'),
97
- put: withBody('PUT'),
98
- del: <T = unknown>(path: string, init?: RequestInit) =>
99
- request<T>(path, { ...init, method: 'DELETE' }),
100
- };
94
+ return {
95
+ request,
96
+ get: <T = unknown>(path: string, init?: RequestInit) =>
97
+ request<T>(path, { ...init, method: 'GET' }),
98
+ post: withBody('POST'),
99
+ put: withBody('PUT'),
100
+ del: <T = unknown>(path: string, init?: RequestInit) =>
101
+ request<T>(path, { ...init, method: 'DELETE' }),
102
+ };
103
+ }, [baseUrl, getAccessToken, renewToken, login]);
101
104
  }
@@ -0,0 +1,13 @@
1
+ import { useContext } from 'react';
2
+ import { InwinkContext } from '../context/InwinkContextProvider';
3
+
4
+ /** Accès au contexte inwink courant (event / community / tenant + loaders de listes). */
5
+ export function useInwinkContext() {
6
+ const context = useContext(InwinkContext);
7
+
8
+ if (!context) {
9
+ throw new Error('useInwinkContext must be used within InwinkContextProvider.');
10
+ }
11
+
12
+ return context;
13
+ }
@@ -0,0 +1,80 @@
1
+ // Lecture / persistance du contexte inwink (event / community / tenant) passé
2
+ // par le back-office dans l'URL à l'ouverture de l'extension.
3
+ //
4
+ // Pourquoi persister ? Le callback OIDC nettoie la query string
5
+ // (`window.history.replaceState({}, '', pathname)`) et le `redirect_uri` ne
6
+ // réémet pas la query : sans persistance, le contexte serait perdu après login.
7
+ // On capture donc les paramètres dès le chargement dans sessionStorage.
8
+
9
+ export type InwinkScope = 'event' | 'community' | 'tenant' | null;
10
+
11
+ export interface InwinkContextParams {
12
+ eventId?: string;
13
+ communityId?: string;
14
+ tenantId?: string;
15
+ }
16
+
17
+ const STORAGE_KEY = 'inwink.context';
18
+
19
+ /** Recherche une clé insensible à la casse dans un jeu de paramètres. */
20
+ function pick(params: URLSearchParams, key: string): string | undefined {
21
+ for (const [k, v] of params.entries()) {
22
+ if (k.toLowerCase() === key && v) {
23
+ return v;
24
+ }
25
+ }
26
+ return undefined;
27
+ }
28
+
29
+ function paramsFromHashQuery(): URLSearchParams {
30
+ const hash = window.location.hash;
31
+ const queryIndex = hash.indexOf('?');
32
+ return queryIndex >= 0
33
+ ? new URLSearchParams(hash.slice(queryIndex + 1))
34
+ : new URLSearchParams();
35
+ }
36
+
37
+ /**
38
+ * Lit `eventid` / `communityid` / `tenantid` depuis l'URL.
39
+ * Couvre la query classique (`?eventid=`) ET la query dans le hash (`#/...?eventid=`),
40
+ * car le routage utilise HashRouter.
41
+ */
42
+ export function readInwinkContextFromUrl(): InwinkContextParams {
43
+ const search = new URLSearchParams(window.location.search);
44
+ const hash = paramsFromHashQuery();
45
+ const get = (key: string) => pick(search, key) ?? pick(hash, key);
46
+
47
+ const params: InwinkContextParams = {};
48
+ const eventId = get('eventid');
49
+ const communityId = get('communityid');
50
+ const tenantId = get('tenantid');
51
+ if (eventId) params.eventId = eventId;
52
+ if (communityId) params.communityId = communityId;
53
+ if (tenantId) params.tenantId = tenantId;
54
+ return params;
55
+ }
56
+
57
+ export function persistContext(params: InwinkContextParams): void {
58
+ try {
59
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify(params));
60
+ } catch {
61
+ // sessionStorage indisponible (mode privé strict) : on ignore silencieusement.
62
+ }
63
+ }
64
+
65
+ export function loadPersistedContext(): InwinkContextParams {
66
+ try {
67
+ const raw = sessionStorage.getItem(STORAGE_KEY);
68
+ return raw ? (JSON.parse(raw) as InwinkContextParams) : {};
69
+ } catch {
70
+ return {};
71
+ }
72
+ }
73
+
74
+ /** Détermine le scope courant. Priorité : event > community > tenant. */
75
+ export function deriveScope(params: InwinkContextParams): InwinkScope {
76
+ if (params.eventId) return 'event';
77
+ if (params.communityId) return 'community';
78
+ if (params.tenantId) return 'tenant';
79
+ return null;
80
+ }
@@ -3,13 +3,16 @@ import ReactDOM from 'react-dom/client';
3
3
  import { HashRouter } from 'react-router-dom';
4
4
  import App from './App';
5
5
  import { AuthProvider } from './auth/AuthProvider';
6
+ import { InwinkContextProvider } from './context/InwinkContextProvider';
6
7
  import './styles.css';
7
8
 
8
9
  ReactDOM.createRoot(document.getElementById('root')!).render(
9
10
  <React.StrictMode>
10
11
  <HashRouter>
11
12
  <AuthProvider>
12
- <App />
13
+ <InwinkContextProvider>
14
+ <App />
15
+ </InwinkContextProvider>
13
16
  </AuthProvider>
14
17
  </HashRouter>
15
18
  </React.StrictMode>,