@inwink-mcp/backoffice-extension-helpers 1.0.0 → 1.0.1
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.
- package/package.json +1 -1
- package/templates/test-app/src/components/Header.tsx +36 -0
- package/templates/test-app/src/pages/HomePage.tsx.tpl +16 -14
- package/templates/vite-extension/AGENTS.md.tpl +61 -3
- package/templates/vite-extension/extension.manifest.json.tpl +1 -1
- package/templates/vite-extension/index.html.tpl +2 -0
- package/templates/vite-extension/src/App.tsx.tpl +14 -8
- package/templates/vite-extension/src/components/AudiencePicker.tsx +25 -0
- package/templates/vite-extension/src/components/CommunityPicker.tsx +25 -0
- package/templates/vite-extension/src/components/ContextCombobox.css +110 -0
- package/templates/vite-extension/src/components/ContextCombobox.tsx +167 -0
- package/templates/vite-extension/src/components/EventPicker.tsx +25 -0
- package/templates/vite-extension/src/components/Header.css +25 -0
- package/templates/vite-extension/src/components/Header.tsx +37 -0
- package/templates/vite-extension/src/components/Layout.css +13 -0
- package/templates/vite-extension/src/components/Layout.tsx +19 -0
- package/templates/vite-extension/src/context/InwinkContextProvider.tsx +170 -0
- package/templates/vite-extension/src/hooks/useContextOptions.ts +106 -0
- package/templates/vite-extension/src/hooks/useInwinkContext.ts +13 -0
- package/templates/vite-extension/src/lib/inwinkContextParams.ts +80 -0
- package/templates/vite-extension/src/main.tsx +4 -1
- package/templates/vite-extension/src/pages/HomePage.tsx.tpl +22 -18
- package/templates/vite-extension/src/styles.css +68 -9
package/package.json
CHANGED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import './Header.css';
|
|
2
|
+
import { FiLogIn, FiLogOut } from 'react-icons/fi';
|
|
3
|
+
import { useAuth } from '../hooks/useAuth';
|
|
4
|
+
import EventPicker from './EventPicker';
|
|
5
|
+
|
|
6
|
+
interface HeaderProps {
|
|
7
|
+
title: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Version surchargée du Header par l'overlay "test-app".
|
|
11
|
+
// Démontre qu'on peut personnaliser le Header (ici : montage d'un EventPicker)
|
|
12
|
+
// sans toucher au Layout ni à App — et force la compilation des pickers + du
|
|
13
|
+
// contexte inwink dans le build de test.
|
|
14
|
+
export default function Header({ title }: HeaderProps) {
|
|
15
|
+
const { isAuthenticated, isLoading, login, logout } = useAuth();
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<header className="app-header">
|
|
19
|
+
<div className="app-header__brand">{title}</div>
|
|
20
|
+
<div className="app-header__actions">
|
|
21
|
+
{isAuthenticated && <EventPicker />}
|
|
22
|
+
{isAuthenticated ? (
|
|
23
|
+
<button onClick={() => void logout()}>
|
|
24
|
+
<FiLogOut aria-hidden />
|
|
25
|
+
Se déconnecter
|
|
26
|
+
</button>
|
|
27
|
+
) : (
|
|
28
|
+
<button className="btn-primary" disabled={isLoading} onClick={() => void login()}>
|
|
29
|
+
<FiLogIn aria-hidden />
|
|
30
|
+
{isLoading ? 'Initialisation…' : 'Se connecter'}
|
|
31
|
+
</button>
|
|
32
|
+
)}
|
|
33
|
+
</div>
|
|
34
|
+
</header>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { FiRefreshCw } from 'react-icons/fi';
|
|
3
3
|
import { useAuth } from '../hooks/useAuth';
|
|
4
|
+
import { useInwinkContext } from '../hooks/useInwinkContext';
|
|
4
5
|
import { useInwinkEventApi } from '../hooks/useInwinkEventApi';
|
|
5
6
|
|
|
6
7
|
// Version de HomePage générée par le template overlay "test-app".
|
|
7
|
-
// Sert à vérifier que le squelette bootstrappé compile et que les hooks
|
|
8
|
-
// (
|
|
8
|
+
// Sert à vérifier que le squelette bootstrappé compile et que les hooks
|
|
9
|
+
// (useInwinkEventApi, useInwinkContext) sont utilisables. Le login/logout vit
|
|
10
|
+
// désormais dans le Header (lui-même surchargé par cet overlay).
|
|
9
11
|
export default function HomePage() {
|
|
10
|
-
const { isAuthenticated,
|
|
12
|
+
const { isAuthenticated, user } = useAuth();
|
|
13
|
+
const { scope, eventId, communityId, tenantId } = useInwinkContext();
|
|
11
14
|
const eventApi = useInwinkEventApi();
|
|
12
15
|
const [result, setResult] = useState<string>('');
|
|
13
16
|
const [error, setError] = useState<string>('');
|
|
@@ -28,28 +31,27 @@ export default function HomePage() {
|
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
return (
|
|
31
|
-
<
|
|
34
|
+
<section>
|
|
32
35
|
<h1>__DISPLAY_NAME__ — test API Event</h1>
|
|
33
36
|
{!isAuthenticated ? (
|
|
34
|
-
<
|
|
35
|
-
<FiLogIn aria-hidden />
|
|
36
|
-
{isLoading ? 'Initialisation...' : 'Se connecter'}
|
|
37
|
-
</button>
|
|
37
|
+
<p>Connectez-vous via le bouton « Se connecter » en haut à droite.</p>
|
|
38
38
|
) : (
|
|
39
39
|
<>
|
|
40
40
|
<p>Connecté en tant que : {user?.profile.email ?? 'Inconnu'}</p>
|
|
41
|
+
<p>
|
|
42
|
+
Contexte courant : {scope ?? 'aucun'}
|
|
43
|
+
{eventId ? ` — événement ${eventId}` : ''}
|
|
44
|
+
{communityId ? ` — communauté ${communityId}` : ''}
|
|
45
|
+
{tenantId ? ` — audience ${tenantId}` : ''}
|
|
46
|
+
</p>
|
|
41
47
|
<button onClick={() => void callEventApi()}>
|
|
42
48
|
<FiRefreshCw aria-hidden />
|
|
43
49
|
Appeler l'API Event
|
|
44
50
|
</button>
|
|
45
|
-
<button onClick={() => void logout()}>
|
|
46
|
-
<FiLogOut aria-hidden />
|
|
47
|
-
Se déconnecter
|
|
48
|
-
</button>
|
|
49
51
|
{result && <pre>{result}</pre>}
|
|
50
52
|
{error && <p role="alert">Erreur : {error}</p>}
|
|
51
53
|
</>
|
|
52
54
|
)}
|
|
53
|
-
</
|
|
55
|
+
</section>
|
|
54
56
|
);
|
|
55
57
|
}
|
|
@@ -25,9 +25,20 @@ __NAME__/
|
|
|
25
25
|
├── vite.config.ts # Config Vite (base: './' + copie du manifest)
|
|
26
26
|
├── scripts/zip-build.mjs # Packaging zip {name}-{version}.zip
|
|
27
27
|
└── src/
|
|
28
|
-
├── main.tsx # Bootstrap React (HashRouter + AuthProvider)
|
|
29
|
-
├── App.tsx # Routes
|
|
30
|
-
├── styles.css # Styles
|
|
28
|
+
├── main.tsx # Bootstrap React (HashRouter + AuthProvider + InwinkContextProvider)
|
|
29
|
+
├── App.tsx # Routes (via Layout + Outlet)
|
|
30
|
+
├── styles.css # Styles globaux : polices, tokens (couleurs/accent), base
|
|
31
|
+
├── components/
|
|
32
|
+
│ ├── Layout.tsx + .css # Gabarit : Header + zone de contenu (Outlet)
|
|
33
|
+
│ ├── Header.tsx + .css # En-tête (marque + login/logout) — surchargeable
|
|
34
|
+
│ ├── ContextCombobox.tsx + .css # Combobox ergonomique réutilisable (recherche + clavier)
|
|
35
|
+
│ ├── EventPicker.tsx # Sélecteur d'événement (autonome, non monté par défaut)
|
|
36
|
+
│ ├── CommunityPicker.tsx # Sélecteur de communauté (idem)
|
|
37
|
+
│ └── AudiencePicker.tsx # Sélecteur de tenant/audience (idem)
|
|
38
|
+
├── context/
|
|
39
|
+
│ └── InwinkContextProvider.tsx # Contexte event/community/tenant + loaders de listes
|
|
40
|
+
├── lib/
|
|
41
|
+
│ └── inwinkContextParams.ts # Lecture/persistance des params URL (eventid/communityid/tenantid)
|
|
31
42
|
├── pages/
|
|
32
43
|
│ └── HomePage.tsx # Page d'accueil (logique métier / UI)
|
|
33
44
|
├── api/
|
|
@@ -38,6 +49,8 @@ __NAME__/
|
|
|
38
49
|
│ └── ProtectedRoute.tsx
|
|
39
50
|
└── hooks/
|
|
40
51
|
├── useAuth.ts # Hook d'accès au contexte auth
|
|
52
|
+
├── useInwinkContext.ts # Hook d'accès au contexte inwink (event/community/tenant)
|
|
53
|
+
├── useContextOptions.ts # Chargement (1×) des listes pour les pickers
|
|
41
54
|
├── useInwinkApi.ts # Hook générique d'appel API (Bearer + renouvellement 401)
|
|
42
55
|
├── useInwinkEventApi.ts # Hook API Event
|
|
43
56
|
├── useInwinkCommunityApi.ts # Hook API Community
|
|
@@ -85,6 +98,50 @@ Bonnes pratiques :
|
|
|
85
98
|
- Ne déclarer dans `permissions.platform` que les scopes réellement utilisés.
|
|
86
99
|
- Déclarer les hôtes externes dans `permissions.externalHosts` (HTTPS uniquement).
|
|
87
100
|
|
|
101
|
+
## Structure de pages & Header
|
|
102
|
+
|
|
103
|
+
L'app est rendue dans un `Layout` (`components/Layout.tsx`) = `Header` + zone de contenu (`<Outlet />`). Le `Header` (`components/Header.tsx`) est **minimal par défaut** (marque + bouton login/logout) et **conçu pour être surchargé** : c'est l'endroit naturel où monter un picker de contexte si l'extension en a besoin.
|
|
104
|
+
|
|
105
|
+
Pour **ajouter une page** : créer le composant dans `src/pages/`, puis ajouter une `<Route>` enfant du `<Route element={<Layout .../>}>` dans `App.tsx` (elle s'affichera dans l'`Outlet`).
|
|
106
|
+
|
|
107
|
+
## Contexte inwink (event / community / tenant)
|
|
108
|
+
|
|
109
|
+
Quand l'extension est ouverte depuis le back-office sur un objet précis, le BO passe `eventid`, `communityid` ou `tenantid` dans l'URL. Le template gère ça nativement :
|
|
110
|
+
|
|
111
|
+
- `lib/inwinkContextParams.ts` lit ces paramètres (query classique **et** query du hash), insensible à la casse, et les **persiste en sessionStorage** — indispensable car le callback OIDC nettoie l'URL et le `redirect_uri` ne réémet pas la query.
|
|
112
|
+
- `context/InwinkContextProvider.tsx` expose le contexte courant et les setters via le hook **`useInwinkContext`** :
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
const { scope, eventId, communityId, tenantId, setEvent, setCommunity, setTenant } = useInwinkContext();
|
|
116
|
+
// scope = 'event' | 'community' | 'tenant' | null (tenant = audience)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Le contexte issu de l'URL est **pré-rempli mais modifiable** (l'utilisateur peut changer d'objet via un picker).
|
|
120
|
+
|
|
121
|
+
## Pickers de contexte (event / community / audience)
|
|
122
|
+
|
|
123
|
+
Trois composants autonomes existent dans `components/` : **`EventPicker`**, **`CommunityPicker`**, **`AudiencePicker`**. Ils consomment `useInwinkContext`, listent les objets de l'utilisateur via l'API Customer (`me/events/query`, `me/communities/query`, `me/tenants/query`) et appliquent la sélection. L'UI est mutualisée dans **`ContextCombobox`** (bouton + popover avec recherche et navigation clavier) — réutilise-le pour tout sélecteur similaire.
|
|
124
|
+
|
|
125
|
+
Ces listes sont **paginées côté serveur** (1ʳᵉ page seulement). La recherche du combobox est donc déléguée au serveur via `useContextOptions` (loaders `loadEvents/loadCommunities/loadTenants`, paramètre `IInwinkContextQuery = { search?, id? }`) : la saisie est debouncée et filtre par nom **ou** id (`{ filters: { $or: [{ id }, { <label>: { $contains } }] } }`). Les événements sont triés par date décroissante (`orders`). Comme l'objet pré-sélectionné via l'URL peut ne pas figurer dans la page courante, `useContextOptions` **fetch son détail par id** et le fusionne dans les options pour afficher son libellé.
|
|
126
|
+
|
|
127
|
+
**Ils ne sont PAS montés par défaut.** À toi de décider, selon l'usage de l'extension, d'en monter un — typiquement dans le `Header` (cf. ci-dessus) ou dans une page :
|
|
128
|
+
|
|
129
|
+
```tsx
|
|
130
|
+
import EventPicker from '../components/EventPicker';
|
|
131
|
+
// ...
|
|
132
|
+
{isAuthenticated && <EventPicker />}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Branding inwink
|
|
136
|
+
|
|
137
|
+
- Polices : **Lato** (texte) + **Poppins** (titres), chargées via Google Fonts dans `styles.css`. Les hôtes `fonts.googleapis.com` / `fonts.gstatic.com` sont déjà déclarés dans `permissions.externalHosts`.
|
|
138
|
+
- Couleur d'accentuation : **`#e94c1e`** (variable CSS `--color-accent`).
|
|
139
|
+
- Variables/tokens globaux dans `styles.css` ; classe `.btn-primary` pour l'action principale.
|
|
140
|
+
|
|
141
|
+
## Convention CSS (obligatoire)
|
|
142
|
+
|
|
143
|
+
**Tout nouveau composant doit avoir un fichier `.css` co-localisé**, importé en tête de son `.tsx` (`import './MonComposant.css';`). Les styles globaux et les tokens (`--font-*`, `--color-*`) restent dans `styles.css` ; les styles spécifiques d'un composant vivent dans son `.css`. Vite bundle automatiquement ces CSS dans `dist/assets/` (packagé dans le zip) — ne pas les oublier.
|
|
144
|
+
|
|
88
145
|
## Règles et contraintes
|
|
89
146
|
|
|
90
147
|
| Règle | Détail |
|
|
@@ -96,6 +153,7 @@ Bonnes pratiques :
|
|
|
96
153
|
| Pas d'imports Node.js | `fs`, `path`, `http`, `child_process`, etc. interdits dans le code source |
|
|
97
154
|
| Pas de `fetch` direct | Toujours passer par `useInwinkEventApi` / `useInwinkCommunityApi` / `useInwinkCustomerApi` (ou `useInwinkApi` pour un hôte externe déclaré) |
|
|
98
155
|
| Icônes via `react-icons` | Ne pas embarquer de SVG inline ni d'images d'icônes |
|
|
156
|
+
| CSS co-localisé par composant | Chaque composant a un `.css` importé en tête de son `.tsx` ; tokens/global dans `styles.css` |
|
|
99
157
|
| `HashRouter` obligatoire | Pour compatibilité avec l'hébergement versionné |
|
|
100
158
|
| `base: './'` obligatoire | Dans `vite.config.ts`, pour les chemins d'assets relatifs |
|
|
101
159
|
| `response_type: 'code'` | PKCE obligatoire, pas de flux implicit |
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
7
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
6
8
|
<title>__DISPLAY_NAME__</title>
|
|
7
9
|
</head>
|
|
8
10
|
<body>
|
|
@@ -1,22 +1,28 @@
|
|
|
1
1
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
|
2
|
+
import Layout from './components/Layout';
|
|
2
3
|
import ProtectedRoute from './auth/ProtectedRoute';
|
|
3
4
|
import HomePage from './pages/HomePage';
|
|
4
5
|
// Pour appeler une API inwink, utilisez un hook dédié (jamais fetch directement) :
|
|
5
6
|
// import { useInwinkEventApi } from './hooks/useInwinkEventApi';
|
|
6
7
|
// const eventApi = useInwinkEventApi();
|
|
7
8
|
// const data = await eventApi.get('/v1/...');
|
|
9
|
+
//
|
|
10
|
+
// Pour ajouter une page : créez-la dans src/pages/ puis ajoutez une <Route> enfant
|
|
11
|
+
// du <Route element={<Layout .../>}> ci-dessous (elle s'affichera dans l'<Outlet> du Layout).
|
|
8
12
|
|
|
9
13
|
export default function App() {
|
|
10
14
|
return (
|
|
11
15
|
<Routes>
|
|
12
|
-
<Route
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
<
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
<Route element={<Layout title="__DISPLAY_NAME__" />}>
|
|
17
|
+
<Route
|
|
18
|
+
path="/"
|
|
19
|
+
element={
|
|
20
|
+
<ProtectedRoute>
|
|
21
|
+
<HomePage />
|
|
22
|
+
</ProtectedRoute>
|
|
23
|
+
}
|
|
24
|
+
/>
|
|
25
|
+
</Route>
|
|
20
26
|
<Route path="*" element={<Navigate to="/" replace />} />
|
|
21
27
|
</Routes>
|
|
22
28
|
);
|
|
@@ -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 de tenant / audience 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 AudiencePicker() {
|
|
10
|
+
const { tenantId, setTenant, loadTenants } = useInwinkContext();
|
|
11
|
+
const { options, isLoading, error, search } = useContextOptions(loadTenants, tenantId);
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<ContextCombobox
|
|
15
|
+
label="Audience"
|
|
16
|
+
placeholder="Choisir une audience"
|
|
17
|
+
value={tenantId}
|
|
18
|
+
options={options}
|
|
19
|
+
isLoading={isLoading}
|
|
20
|
+
error={error}
|
|
21
|
+
onChange={setTenant}
|
|
22
|
+
onSearch={search}
|
|
23
|
+
/>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -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 de communauté courante. 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 CommunityPicker() {
|
|
10
|
+
const { communityId, setCommunity, loadCommunities } = useInwinkContext();
|
|
11
|
+
const { options, isLoading, error, search } = useContextOptions(loadCommunities, communityId);
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<ContextCombobox
|
|
15
|
+
label="Communauté"
|
|
16
|
+
placeholder="Choisir une communauté"
|
|
17
|
+
value={communityId}
|
|
18
|
+
options={options}
|
|
19
|
+
isLoading={isLoading}
|
|
20
|
+
error={error}
|
|
21
|
+
onChange={setCommunity}
|
|
22
|
+
onSearch={search}
|
|
23
|
+
/>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
.context-combobox {
|
|
2
|
+
position: relative;
|
|
3
|
+
display: inline-flex;
|
|
4
|
+
flex-direction: column;
|
|
5
|
+
gap: 0.2rem;
|
|
6
|
+
font-size: 0.85rem;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.context-combobox__label {
|
|
10
|
+
color: var(--color-text-muted);
|
|
11
|
+
font-weight: 600;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.context-combobox__trigger {
|
|
15
|
+
display: inline-flex;
|
|
16
|
+
align-items: center;
|
|
17
|
+
justify-content: space-between;
|
|
18
|
+
gap: 0.5rem;
|
|
19
|
+
min-width: 14rem;
|
|
20
|
+
max-width: 20rem;
|
|
21
|
+
padding: 0.4rem 0.6rem;
|
|
22
|
+
border-radius: var(--radius);
|
|
23
|
+
border: 1px solid var(--color-border);
|
|
24
|
+
background: var(--color-bg);
|
|
25
|
+
color: var(--color-text);
|
|
26
|
+
font: inherit;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.context-combobox__trigger:hover:not(:disabled) {
|
|
30
|
+
border-color: var(--color-accent);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.context-combobox__value {
|
|
34
|
+
overflow: hidden;
|
|
35
|
+
text-overflow: ellipsis;
|
|
36
|
+
white-space: nowrap;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.context-combobox__chevron {
|
|
40
|
+
flex-shrink: 0;
|
|
41
|
+
color: var(--color-text-muted);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.context-combobox__panel {
|
|
45
|
+
position: absolute;
|
|
46
|
+
top: calc(100% + 0.25rem);
|
|
47
|
+
left: 0;
|
|
48
|
+
z-index: 20;
|
|
49
|
+
width: max(100%, 16rem);
|
|
50
|
+
background: var(--color-bg);
|
|
51
|
+
border: 1px solid var(--color-border);
|
|
52
|
+
border-radius: var(--radius);
|
|
53
|
+
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
|
|
54
|
+
overflow: hidden;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.context-combobox__search {
|
|
58
|
+
display: flex;
|
|
59
|
+
align-items: center;
|
|
60
|
+
gap: 0.4rem;
|
|
61
|
+
padding: 0.5rem 0.6rem;
|
|
62
|
+
border-bottom: 1px solid var(--color-border);
|
|
63
|
+
color: var(--color-text-muted);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.context-combobox__search input {
|
|
67
|
+
flex: 1;
|
|
68
|
+
border: none;
|
|
69
|
+
outline: none;
|
|
70
|
+
font: inherit;
|
|
71
|
+
background: transparent;
|
|
72
|
+
color: var(--color-text);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.context-combobox__list {
|
|
76
|
+
list-style: none;
|
|
77
|
+
margin: 0;
|
|
78
|
+
padding: 0.25rem;
|
|
79
|
+
max-height: 16rem;
|
|
80
|
+
overflow-y: auto;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.context-combobox__empty {
|
|
84
|
+
padding: 0.5rem 0.6rem;
|
|
85
|
+
color: var(--color-text-muted);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.context-combobox__option {
|
|
89
|
+
display: flex;
|
|
90
|
+
align-items: center;
|
|
91
|
+
justify-content: space-between;
|
|
92
|
+
gap: 0.5rem;
|
|
93
|
+
width: 100%;
|
|
94
|
+
padding: 0.45rem 0.6rem;
|
|
95
|
+
border: none;
|
|
96
|
+
border-radius: calc(var(--radius) - 2px);
|
|
97
|
+
background: transparent;
|
|
98
|
+
color: var(--color-text);
|
|
99
|
+
font: inherit;
|
|
100
|
+
text-align: left;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.context-combobox__option.is-highlighted {
|
|
104
|
+
background: var(--color-surface);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.context-combobox__option.is-selected {
|
|
108
|
+
color: var(--color-accent);
|
|
109
|
+
font-weight: 600;
|
|
110
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import './ContextCombobox.css';
|
|
2
|
+
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { FiCheck, FiChevronDown, FiSearch } from 'react-icons/fi';
|
|
4
|
+
import type { InwinkContextOption } from '../context/InwinkContextProvider';
|
|
5
|
+
|
|
6
|
+
interface ContextComboboxProps {
|
|
7
|
+
label: string;
|
|
8
|
+
placeholder: string;
|
|
9
|
+
value?: string;
|
|
10
|
+
options: InwinkContextOption[];
|
|
11
|
+
isLoading: boolean;
|
|
12
|
+
error: string | null;
|
|
13
|
+
onChange: (id: string) => void;
|
|
14
|
+
/**
|
|
15
|
+
* Si fourni, la recherche est déléguée au serveur (le terme saisi est transmis)
|
|
16
|
+
* et la liste n'est PAS refiltrée côté client. Sans ce prop, le filtrage se fait
|
|
17
|
+
* côté client sur le libellé.
|
|
18
|
+
*/
|
|
19
|
+
onSearch?: (query: string) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Sélecteur ergonomique réutilisable : bouton déclencheur + popover avec champ de
|
|
24
|
+
* recherche, liste filtrable et navigation clavier. Mutualise l'UI des pickers de
|
|
25
|
+
* contexte (Event / Community / Audience).
|
|
26
|
+
*/
|
|
27
|
+
export default function ContextCombobox({
|
|
28
|
+
label,
|
|
29
|
+
placeholder,
|
|
30
|
+
value,
|
|
31
|
+
options,
|
|
32
|
+
isLoading,
|
|
33
|
+
error,
|
|
34
|
+
onChange,
|
|
35
|
+
onSearch,
|
|
36
|
+
}: ContextComboboxProps) {
|
|
37
|
+
const [open, setOpen] = useState(false);
|
|
38
|
+
const [query, setQuery] = useState('');
|
|
39
|
+
const [highlight, setHighlight] = useState(0);
|
|
40
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
41
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
42
|
+
const listId = useId();
|
|
43
|
+
|
|
44
|
+
const selected = options.find((option) => option.id === value);
|
|
45
|
+
const triggerLabel = selected?.label ?? (value && isLoading ? 'Chargement…' : placeholder);
|
|
46
|
+
|
|
47
|
+
// En mode serveur (`onSearch`), la liste est déjà filtrée par l'API : on ne refiltre
|
|
48
|
+
// pas côté client (une recherche par id n'apparaît pas dans le libellé).
|
|
49
|
+
const filtered = useMemo(() => {
|
|
50
|
+
if (onSearch) return options;
|
|
51
|
+
const q = query.trim().toLowerCase();
|
|
52
|
+
return q ? options.filter((option) => option.label.toLowerCase().includes(q)) : options;
|
|
53
|
+
}, [options, query, onSearch]);
|
|
54
|
+
|
|
55
|
+
// Fermeture au clic extérieur.
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!open) return;
|
|
58
|
+
const onPointerDown = (event: MouseEvent) => {
|
|
59
|
+
if (!containerRef.current?.contains(event.target as Node)) {
|
|
60
|
+
setOpen(false);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
document.addEventListener('mousedown', onPointerDown);
|
|
64
|
+
return () => document.removeEventListener('mousedown', onPointerDown);
|
|
65
|
+
}, [open]);
|
|
66
|
+
|
|
67
|
+
// Focus du champ de recherche à l'ouverture, et réinitialisation de la liste
|
|
68
|
+
// (en mode serveur, on recharge la liste complète non filtrée).
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (open) {
|
|
71
|
+
setQuery('');
|
|
72
|
+
setHighlight(0);
|
|
73
|
+
inputRef.current?.focus();
|
|
74
|
+
onSearch?.('');
|
|
75
|
+
}
|
|
76
|
+
}, [open, onSearch]);
|
|
77
|
+
|
|
78
|
+
const select = (id: string) => {
|
|
79
|
+
onChange(id);
|
|
80
|
+
setOpen(false);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const onInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
84
|
+
if (event.key === 'ArrowDown') {
|
|
85
|
+
event.preventDefault();
|
|
86
|
+
setHighlight((h) => Math.min(h + 1, filtered.length - 1));
|
|
87
|
+
} else if (event.key === 'ArrowUp') {
|
|
88
|
+
event.preventDefault();
|
|
89
|
+
setHighlight((h) => Math.max(h - 1, 0));
|
|
90
|
+
} else if (event.key === 'Enter') {
|
|
91
|
+
event.preventDefault();
|
|
92
|
+
const option = filtered[highlight];
|
|
93
|
+
if (option) select(option.id);
|
|
94
|
+
} else if (event.key === 'Escape') {
|
|
95
|
+
setOpen(false);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div className="context-combobox" ref={containerRef}>
|
|
101
|
+
<span className="context-combobox__label">{label}</span>
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
className="context-combobox__trigger"
|
|
105
|
+
disabled={Boolean(error)}
|
|
106
|
+
aria-haspopup="listbox"
|
|
107
|
+
aria-expanded={open}
|
|
108
|
+
title={error ?? undefined}
|
|
109
|
+
onClick={() => setOpen((o) => !o)}
|
|
110
|
+
>
|
|
111
|
+
<span className="context-combobox__value">
|
|
112
|
+
{error ? 'Erreur de chargement' : triggerLabel}
|
|
113
|
+
</span>
|
|
114
|
+
<FiChevronDown aria-hidden className="context-combobox__chevron" />
|
|
115
|
+
</button>
|
|
116
|
+
|
|
117
|
+
{open && (
|
|
118
|
+
<div className="context-combobox__panel">
|
|
119
|
+
<div className="context-combobox__search">
|
|
120
|
+
<FiSearch aria-hidden />
|
|
121
|
+
<input
|
|
122
|
+
ref={inputRef}
|
|
123
|
+
type="text"
|
|
124
|
+
value={query}
|
|
125
|
+
placeholder="Rechercher…"
|
|
126
|
+
aria-label={`Rechercher ${label.toLowerCase()}`}
|
|
127
|
+
onChange={(e) => {
|
|
128
|
+
setQuery(e.target.value);
|
|
129
|
+
setHighlight(0);
|
|
130
|
+
onSearch?.(e.target.value);
|
|
131
|
+
}}
|
|
132
|
+
onKeyDown={onInputKeyDown}
|
|
133
|
+
/>
|
|
134
|
+
</div>
|
|
135
|
+
<ul className="context-combobox__list" role="listbox" id={listId}>
|
|
136
|
+
{isLoading && <li className="context-combobox__empty">Chargement…</li>}
|
|
137
|
+
{!isLoading && filtered.length === 0 && (
|
|
138
|
+
<li className="context-combobox__empty">Aucun résultat</li>
|
|
139
|
+
)}
|
|
140
|
+
{!isLoading &&
|
|
141
|
+
filtered.map((option, index) => {
|
|
142
|
+
const isSelected = option.id === value;
|
|
143
|
+
const isHighlighted = index === highlight;
|
|
144
|
+
return (
|
|
145
|
+
<li key={option.id} role="option" aria-selected={isSelected}>
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
className={
|
|
149
|
+
'context-combobox__option' +
|
|
150
|
+
(isHighlighted ? ' is-highlighted' : '') +
|
|
151
|
+
(isSelected ? ' is-selected' : '')
|
|
152
|
+
}
|
|
153
|
+
onMouseEnter={() => setHighlight(index)}
|
|
154
|
+
onClick={() => select(option.id)}
|
|
155
|
+
>
|
|
156
|
+
<span>{option.label}</span>
|
|
157
|
+
{isSelected && <FiCheck aria-hidden />}
|
|
158
|
+
</button>
|
|
159
|
+
</li>
|
|
160
|
+
);
|
|
161
|
+
})}
|
|
162
|
+
</ul>
|
|
163
|
+
</div>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -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,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
|
+
}
|
|
@@ -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
|
-
<
|
|
13
|
+
<InwinkContextProvider>
|
|
14
|
+
<App />
|
|
15
|
+
</InwinkContextProvider>
|
|
13
16
|
</AuthProvider>
|
|
14
17
|
</HashRouter>
|
|
15
18
|
</React.StrictMode>,
|
|
@@ -1,26 +1,30 @@
|
|
|
1
|
-
import { FiLogIn, FiLogOut } from 'react-icons/fi';
|
|
2
1
|
import { useAuth } from '../hooks/useAuth';
|
|
2
|
+
import { useInwinkContext } from '../hooks/useInwinkContext';
|
|
3
3
|
|
|
4
4
|
export default function HomePage() {
|
|
5
|
-
const { isAuthenticated,
|
|
5
|
+
const { isAuthenticated, user } = useAuth();
|
|
6
|
+
const { scope, eventId, communityId, tenantId } = useInwinkContext();
|
|
7
|
+
|
|
8
|
+
if (!isAuthenticated) {
|
|
9
|
+
return (
|
|
10
|
+
<section>
|
|
11
|
+
<h1>__DISPLAY_NAME__</h1>
|
|
12
|
+
<p>Connectez-vous via le bouton « Se connecter » en haut à droite.</p>
|
|
13
|
+
</section>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
6
16
|
|
|
7
17
|
return (
|
|
8
|
-
<
|
|
18
|
+
<section>
|
|
9
19
|
<h1>__DISPLAY_NAME__</h1>
|
|
10
|
-
{
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
<FiLogOut aria-hidden />
|
|
20
|
-
Se déconnecter
|
|
21
|
-
</button>
|
|
22
|
-
</>
|
|
23
|
-
)}
|
|
24
|
-
</main>
|
|
20
|
+
<p>Connecté en tant que : {user?.profile.email ?? 'Inconnu'}</p>
|
|
21
|
+
<p>
|
|
22
|
+
Contexte courant : {scope ?? 'aucun'}
|
|
23
|
+
{eventId ? ` — événement ${eventId}` : ''}
|
|
24
|
+
{communityId ? ` — communauté ${communityId}` : ''}
|
|
25
|
+
{tenantId ? ` — audience ${tenantId}` : ''}
|
|
26
|
+
</p>
|
|
27
|
+
{/* Ajoutez ici la logique métier de l'extension. */}
|
|
28
|
+
</section>
|
|
25
29
|
);
|
|
26
30
|
}
|
|
@@ -1,32 +1,91 @@
|
|
|
1
|
+
/* Polices inwink : Lato (texte) + Poppins (titres) via Google Fonts.
|
|
2
|
+
Nécessite que fonts.googleapis.com / fonts.gstatic.com soient déclarés
|
|
3
|
+
dans extension.manifest.json > permissions.externalHosts. */
|
|
4
|
+
@import url('https://fonts.googleapis.com/css2?family=Lato:wght@400;700&family=Poppins:wght@500;600;700&display=swap');
|
|
5
|
+
|
|
6
|
+
/* Design tokens — inspirés de l'identité inwink. */
|
|
1
7
|
:root {
|
|
2
|
-
|
|
8
|
+
/* Polices */
|
|
9
|
+
--font-base: 'Lato', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
|
10
|
+
--font-title: 'Poppins', var(--font-base);
|
|
11
|
+
|
|
12
|
+
/* Couleur d'accentuation */
|
|
13
|
+
--color-accent: #e94c1e;
|
|
14
|
+
--color-accent-hover: #cf3f15;
|
|
15
|
+
--color-accent-contrast: #ffffff;
|
|
16
|
+
|
|
17
|
+
/* Neutres */
|
|
18
|
+
--color-bg: #ffffff;
|
|
19
|
+
--color-surface: #f7f7f8;
|
|
20
|
+
--color-border: #e2e2e6;
|
|
21
|
+
--color-text: #1d1d1f;
|
|
22
|
+
--color-text-muted: #6b6b73;
|
|
23
|
+
|
|
24
|
+
/* Rayons & ombres */
|
|
25
|
+
--radius: 8px;
|
|
26
|
+
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
|
|
27
|
+
|
|
28
|
+
font-family: var(--font-base);
|
|
3
29
|
line-height: 1.5;
|
|
4
|
-
color
|
|
30
|
+
color: var(--color-text);
|
|
31
|
+
background: var(--color-bg);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
* {
|
|
35
|
+
box-sizing: border-box;
|
|
5
36
|
}
|
|
6
37
|
|
|
7
38
|
body {
|
|
8
39
|
margin: 0;
|
|
9
|
-
|
|
40
|
+
font-family: var(--font-base);
|
|
41
|
+
background: var(--color-bg);
|
|
42
|
+
color: var(--color-text);
|
|
10
43
|
}
|
|
11
44
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
45
|
+
h1,
|
|
46
|
+
h2,
|
|
47
|
+
h3 {
|
|
48
|
+
font-family: var(--font-title);
|
|
49
|
+
font-weight: 600;
|
|
50
|
+
line-height: 1.2;
|
|
15
51
|
}
|
|
16
52
|
|
|
53
|
+
a {
|
|
54
|
+
color: var(--color-accent);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* Boutons — base générique réutilisable.
|
|
58
|
+
.btn-primary = action principale (accent), sinon variante neutre. */
|
|
17
59
|
button {
|
|
18
60
|
cursor: pointer;
|
|
19
61
|
display: inline-flex;
|
|
20
62
|
align-items: center;
|
|
21
63
|
gap: 0.5rem;
|
|
22
64
|
padding: 0.5rem 1rem;
|
|
23
|
-
border-radius:
|
|
24
|
-
border: 1px solid
|
|
25
|
-
background:
|
|
65
|
+
border-radius: var(--radius);
|
|
66
|
+
border: 1px solid var(--color-border);
|
|
67
|
+
background: var(--color-bg);
|
|
68
|
+
color: var(--color-text);
|
|
26
69
|
font: inherit;
|
|
70
|
+
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
button:hover:not(:disabled) {
|
|
74
|
+
border-color: var(--color-accent);
|
|
27
75
|
}
|
|
28
76
|
|
|
29
77
|
button:disabled {
|
|
30
78
|
cursor: default;
|
|
31
79
|
opacity: 0.6;
|
|
32
80
|
}
|
|
81
|
+
|
|
82
|
+
button.btn-primary {
|
|
83
|
+
background: var(--color-accent);
|
|
84
|
+
border-color: var(--color-accent);
|
|
85
|
+
color: var(--color-accent-contrast);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
button.btn-primary:hover:not(:disabled) {
|
|
89
|
+
background: var(--color-accent-hover);
|
|
90
|
+
border-color: var(--color-accent-hover);
|
|
91
|
+
}
|