@astratra/saas-kit-ui 1.0.1 → 1.1.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.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Astratra
3
+ Copyright (c) 2026 Astratra contributors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,9 +1,6 @@
1
1
  # @astratra/saas-kit-ui
2
2
 
3
- Interface React reutilisable pour demarrer rapidement un dashboard SaaS
4
- Astratra. Le package expose une application dashboard complete et quelques
5
- primitives (`AuthProvider`, `useAuth`, `apiFetch`) pour composer une interface
6
- plus specifique.
3
+ Interface React réutilisable pour démarrer rapidement un tableau de bord SaaS. Le paquet expose une application complète (connexion, vue d'ensemble, utilisateurs, réglages) et quelques primitives (`AuthProvider`, `useAuth`, `apiFetch`) pour composer une interface plus spécifique. Il est prévu pour un backend `@astratra/saas-kit`.
7
4
 
8
5
  ## Installation
9
6
 
@@ -11,10 +8,9 @@ plus specifique.
11
8
  npm install @astratra/saas-kit-ui react react-dom
12
9
  ```
13
10
 
14
- Dans le repo Astratra, `examples/dashboard-ui` montre son integration avec un
15
- backend `@astratra/saas-kit`.
11
+ React 18 ou plus récent. Le dépôt contient `examples/dashboard-ui`, qui montre l'intégration avec un backend `@astratra/saas-kit`.
16
12
 
17
- ## Utilisation
13
+ ## Exemple
18
14
 
19
15
  ```jsx
20
16
  import React from 'react';
@@ -28,42 +24,73 @@ createRoot(document.getElementById('root')).render(
28
24
  );
29
25
  ```
30
26
 
31
- Par defaut, l'interface appelle `http://localhost:4000`. Avec Vite, definis
32
- `VITE_API_URL` pour pointer vers ton backend :
27
+ Le paquet importe sa feuille de styles avec l'application ; elle est aussi disponible séparément via `@astratra/saas-kit-ui/styles.css`. Par défaut, l'interface appelle `http://localhost:4000`. Avec Vite, définissez `VITE_API_URL` pour pointer vers votre backend :
33
28
 
34
29
  ```bash
35
- VITE_API_URL=http://localhost:4000 npm run dev
30
+ VITE_API_URL=https://api.exemple.fr npm run dev
36
31
  ```
37
32
 
38
- ## Surface exportee
39
-
40
- - `AstratraDashboardApp` : dashboard complet login/users/settings/overview.
41
- - `AuthProvider` et `useAuth` : session bearer token en memoire.
42
- - `apiFetch` et `ApiError` : client minimal compatible avec les reponses
43
- `apiResponse` d'`@astratra/core`.
44
-
45
- Le package ne contient pas de serveur Express et ne fixe aucun domaine metier.
46
- Le backend reste fourni par `@astratra/saas-kit` ou par ton application.
47
-
48
- ## `AuthProvider`/`useAuth` vs `@astratra/react`
49
-
50
- Deux packages Astratra exposent des primitives de session React, avec des
51
- choix différents et volontaires — ce n'est pas un doublon accidentel :
52
-
53
- - **`AuthProvider`/`useAuth` (ce package)** : garde le JWT en mémoire JS et
54
- l'attache en `Authorization: Bearer` via `apiFetch`. Couplé au dashboard
55
- complet `AstratraDashboardApp` (login/users/settings/overview) — conçu
56
- pour démarrer vite avec un backend `@astratra/saas-kit` standard, pas pour
57
- être réutilisé isolément dans une UI déjà existante.
58
- - **`SessionProvider`/`useSession` (`@astratra/react`)** : ne stocke aucun
59
- token côté client — la session vit dans un cookie `HttpOnly` géré par le
60
- backend, l'app injecte juste `getSession`/`signIn`/`signOut`. Pas de
61
- dashboard imposé, pas de routes fixes ; à choisir quand tu construis ta
62
- propre UI par-dessus `@astratra/saas-kit` (avec les cookies de session
63
- activés côté `@astratra/security`) plutôt que d'utiliser
64
- `AstratraDashboardApp` tel quel.
65
-
66
- En résumé : utilise ce package si `AstratraDashboardApp` te convient tel
67
- quel ; utilise `@astratra/react` si tu construis ta propre interface et que
68
- ton backend utilise les sessions cookie `HttpOnly` plutôt qu'un token Bearer
69
- géré côté client.
33
+ ## Ce que le tableau de bord attend du backend
34
+
35
+ | Écran | Appels |
36
+ | --- | --- |
37
+ | Connexion | `POST /auth/login` avec `{ email, password }` ; réponse `{ token, user }`. |
38
+ | Vue d'ensemble | `GET /dashboard/summary` |
39
+ | Utilisateurs | `GET /users?limit=50&offset=0`, `POST /users` |
40
+ | Réglages | `GET /settings`, `PATCH /settings/<clé>` avec `{ value }` |
41
+
42
+ Les réponses suivent le format `apiResponse` d'`@astratra/core` : `{ success, data, message }`.
43
+
44
+ ## Exports
45
+
46
+ | Export | Rôle |
47
+ | --- | --- |
48
+ | `AstratraDashboardApp` (aussi export par défaut) | Tableau de bord complet. |
49
+ | `AuthProvider`, `useAuth` | Session par jeton Bearer gardé en mémoire ; `useAuth()` rend `isAuthenticated`, `token`, `user`, `login`, `logout` et `request`. |
50
+ | `apiFetch(chemin, options)`, `ApiError` | Client minimal. Options : `apiUrl`, `token`, `method`, `body`, `fetcher`, `onUnauthorized`. |
51
+ | `Layout`, `Notice` | Composants de mise en page et de message. |
52
+ | `DashboardScreen`, `LoginScreen`, `SettingsScreen`, `UsersScreen` | Les écrans, réutilisables séparément. |
53
+
54
+ ## Ce que le paquet ne fait pas
55
+
56
+ - Il ne contient pas de serveur et ne fixe aucun domaine métier ; le backend est fourni par `@astratra/saas-kit` ou par votre application.
57
+ - Le jeton n'est gardé qu'en mémoire : un rechargement de la page déconnecte l'utilisateur.
58
+ - Il n'est pas conçu pour être branché sur une interface existante qui utilise des sessions par cookie `HttpOnly` : voir `@astratra/react`.
59
+
60
+ ## `AuthProvider`/`useAuth` et `@astratra/react`
61
+
62
+ - **`AuthProvider`/`useAuth` (ce paquet)** : garde le JWT en mémoire et l'envoie en `Authorization: Bearer` via `apiFetch`. Couplé à `AstratraDashboardApp`.
63
+ - **`SessionProvider`/`useSession` (`@astratra/react`)** : ne stocke aucun jeton côté client ; la session vit dans un cookie `HttpOnly` géré par le backend. Pas de tableau de bord imposé.
64
+
65
+ Utilisez ce paquet si `AstratraDashboardApp` vous convient tel quel ; utilisez `@astratra/react` si vous construisez votre propre interface sur des sessions par cookie.
66
+
67
+ ## Licence
68
+
69
+ MIT.
70
+
71
+ ## FeedbackBar : 👍 / 👎 sous une réponse d'IA
72
+
73
+ Le même composant que dans `@astratra/native-ui`, pour le web. Contrôlé :
74
+ l'application garde `value` et parle à son serveur (par exemple
75
+ `createFeedbackRouter` de `@astratra/agent`).
76
+
77
+ ```jsx
78
+ import { FeedbackBar } from '@astratra/saas-kit-ui';
79
+
80
+ <FeedbackBar
81
+ value={feedback?.rating ?? null}
82
+ onSubmit={(payload) => api.giveFeedback(runId, payload)} // { rating, reason?, comment? }
83
+ onRemove={() => api.removeFeedback(runId)}
84
+ locale="fr"
85
+ />
86
+ ```
87
+
88
+ Le pouce haut part aussitôt ; le pouce bas ouvre un panneau (raison,
89
+ commentaire facultatif) ; appuyer sur le pouce choisi le retire. Libellés par
90
+ défaut en anglais et en français (`locale`), remplaçables un par un (`labels`) ;
91
+ raisons ajoutables (`reasons`) ; `icons` remplace les émojis ; `haptics` est une
92
+ fonction facultative (rien par défaut). Accessibilité : groupe nommé, boutons à
93
+ `aria-pressed`, puces `radio`, panneau `dialog`, Échap le ferme. Le style vient
94
+ des classes `.astratra-feedback*` de `styles.css` et des variables CSS du kit.
95
+ Les règles pures (`feedbackPress`, `buildFeedbackPayload`, `resolveFeedbackLabels`,
96
+ `feedbackReasonOptions`, `clampComment`) sont exportées.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@astratra/saas-kit-ui",
3
- "version": "1.0.1",
4
- "description": "Reusable React dashboard shell for Astratra SaaS applications.",
3
+ "version": "1.1.0",
4
+ "description": "Reusable React dashboard shell for Astratra SaaS applications, plus a thumbs up/down feedback bar for AI answers.",
5
5
  "engines": {
6
6
  "node": ">=20"
7
7
  },
@@ -0,0 +1,70 @@
1
+ import { createElement as h, useId, useState } from 'react';
2
+ import {
3
+ FEEDBACK_COMMENT_MAX,
4
+ FEEDBACK_REASONS,
5
+ buildFeedbackPayload,
6
+ feedbackPress,
7
+ feedbackReasonOptions,
8
+ resolveFeedbackLabels
9
+ } from '../lib/feedbackBar.js';
10
+
11
+ const THUMBS = { up: '\u{1F44D}', down: '\u{1F44E}' };
12
+
13
+ /**
14
+ * Thumbs up / thumbs down under an AI answer. Controlled: the app keeps
15
+ * `value` and talks to its server in `onSubmit` / `onRemove`. Up is sent at
16
+ * once; down opens a small panel for a reason and an optional comment;
17
+ * pressing the chosen thumb again withdraws it. Words come from `labels` and
18
+ * `locale`. Colours come from the kit's CSS variables, with fallbacks.
19
+ */
20
+ export default function FeedbackBar({
21
+ value = null, onSubmit, onRemove, disabled = false, reasons = FEEDBACK_REASONS, maxComment = FEEDBACK_COMMENT_MAX,
22
+ labels, locale, icons, haptics, defaultOpen = false, className, style, id
23
+ }) {
24
+ const words = resolveFeedbackLabels({ locale, labels });
25
+ const [asking, setAsking] = useState(Boolean(defaultOpen));
26
+ const [reason, setReason] = useState(null);
27
+ const [comment, setComment] = useState('');
28
+ const titleId = useId();
29
+ const tick = () => { if (typeof haptics === 'function') { try { haptics(); } catch (_error) { /* never blocks a rating */ } } };
30
+ const close = () => { setAsking(false); setReason(null); setComment(''); };
31
+ const press = (rating) => {
32
+ if (disabled) return;
33
+ tick();
34
+ const action = feedbackPress(value, rating);
35
+ if (action.type === 'remove') onRemove?.();
36
+ else if (action.type === 'ask') setAsking(true);
37
+ else if (action.type === 'submit') onSubmit?.(action.payload);
38
+ };
39
+ const send = () => {
40
+ tick();
41
+ const payload = buildFeedbackPayload({ rating: 'down', reason, comment, reasons, maxComment });
42
+ close();
43
+ onSubmit?.(payload);
44
+ };
45
+ const thumb = (rating) => h('button', {
46
+ key: rating, type: 'button', disabled, 'aria-pressed': value === rating, 'aria-label': words[rating], title: words[rating],
47
+ className: 'astratra-feedback__thumb', 'data-rating': rating, onClick: () => press(rating)
48
+ }, icons?.[rating] ?? h('span', { 'aria-hidden': 'true' }, THUMBS[rating]));
49
+
50
+ const options = feedbackReasonOptions({ reasons, labels: words, selected: reason });
51
+ return h('div', { id, role: 'group', 'aria-label': words.group, className: ['astratra-feedback', className].filter(Boolean).join(' '), style },
52
+ thumb('up'), thumb('down'),
53
+ asking && h('div', {
54
+ role: 'dialog', 'aria-modal': 'false', 'aria-labelledby': titleId, className: 'astratra-feedback__sheet',
55
+ onKeyDown: (event) => { if (event.key === 'Escape') close(); }
56
+ },
57
+ h('p', { id: titleId, className: 'astratra-feedback__title' }, words.sheetTitle),
58
+ h('div', { role: 'radiogroup', 'aria-labelledby': titleId, className: 'astratra-feedback__reasons' },
59
+ options.map((option) => h('button', {
60
+ key: option.id, type: 'button', role: 'radio', 'aria-checked': option.selected, className: 'astratra-feedback__chip',
61
+ onClick: () => setReason(option.selected ? null : option.id)
62
+ }, option.label))),
63
+ h('label', { className: 'astratra-feedback__comment' },
64
+ h('span', { className: 'astratra-feedback__label' }, words.commentLabel),
65
+ h('textarea', { value: comment, maxLength: maxComment, placeholder: words.commentPlaceholder, rows: 3, onChange: (event) => setComment(event.target.value) }),
66
+ h('span', { className: 'astratra-feedback__counter', 'aria-hidden': 'true' }, words.counter(Array.from(comment).length, maxComment))),
67
+ h('div', { className: 'astratra-feedback__actions' },
68
+ h('button', { type: 'button', className: 'astratra-feedback__cancel', onClick: close }, words.cancel),
69
+ h('button', { type: 'button', className: 'astratra-feedback__send', onClick: send }, words.submit))));
70
+ }
package/src/index.jsx CHANGED
@@ -7,3 +7,14 @@ export { default as DashboardScreen } from './screens/Dashboard.jsx';
7
7
  export { default as LoginScreen } from './screens/Login.jsx';
8
8
  export { default as SettingsScreen } from './screens/Settings.jsx';
9
9
  export { default as UsersScreen } from './screens/Users.jsx';
10
+ export { default as FeedbackBar } from './components/FeedbackBar.js';
11
+ export {
12
+ FEEDBACK_COMMENT_MAX,
13
+ FEEDBACK_LABELS,
14
+ FEEDBACK_REASONS,
15
+ buildFeedbackPayload,
16
+ clampComment,
17
+ feedbackPress,
18
+ feedbackReasonOptions,
19
+ resolveFeedbackLabels
20
+ } from './lib/feedbackBar.js';
@@ -0,0 +1,61 @@
1
+ // Pure rules of the feedback bar (thumbs up / down on an AI answer).
2
+ // Same rules as the mobile bar in @astratra/native-ui; no React here.
3
+
4
+ export const FEEDBACK_REASONS = Object.freeze(['inaccurate', 'incomplete', 'off_topic', 'wrong_source', 'too_long', 'unsafe', 'other']);
5
+ export const FEEDBACK_COMMENT_MAX = 500;
6
+
7
+ export const FEEDBACK_LABELS = Object.freeze({
8
+ en: Object.freeze({
9
+ group: 'Rate this answer', up: 'Good answer', down: 'Bad answer', sheetTitle: 'What went wrong?',
10
+ commentLabel: 'Comment (optional)', commentPlaceholder: 'Tell us more (optional)', submit: 'Send', cancel: 'Cancel',
11
+ reasons: Object.freeze({ inaccurate: 'Inaccurate', incomplete: 'Incomplete', off_topic: 'Off topic', wrong_source: 'Wrong source', too_long: 'Too long', unsafe: 'Unsafe', other: 'Other' }),
12
+ counter: (used, max) => `${used}/${max}`
13
+ }),
14
+ fr: Object.freeze({
15
+ group: 'Évaluer cette réponse', up: 'Bonne réponse', down: 'Mauvaise réponse', sheetTitle: "Qu'est-ce qui n'allait pas ?",
16
+ commentLabel: 'Commentaire (facultatif)', commentPlaceholder: 'Dites-nous en plus (facultatif)', submit: 'Envoyer', cancel: 'Annuler',
17
+ reasons: Object.freeze({ inaccurate: 'Inexacte', incomplete: 'Incomplète', off_topic: 'Hors sujet', wrong_source: 'Mauvaise source', too_long: 'Trop longue', unsafe: 'Dangereuse', other: 'Autre' }),
18
+ counter: (used, max) => `${used}/${max}`
19
+ })
20
+ });
21
+
22
+ const TEXT_KEYS = ['group', 'up', 'down', 'sheetTitle', 'commentLabel', 'commentPlaceholder', 'submit', 'cancel'];
23
+
24
+ /** Labels for a locale ('fr…' is French, anything else English), with the app's overrides on top. */
25
+ export function resolveFeedbackLabels({ locale, labels } = {}) {
26
+ const language = typeof locale === 'string' ? locale.toLowerCase().split(/[-_]/)[0] : 'en';
27
+ const base = FEEDBACK_LABELS[language] || FEEDBACK_LABELS.en;
28
+ const given = labels && typeof labels === 'object' ? labels : {};
29
+ const out = {};
30
+ for (const key of TEXT_KEYS) out[key] = typeof given[key] === 'string' && given[key] ? given[key] : base[key];
31
+ out.reasons = { ...base.reasons, ...(given.reasons && typeof given.reasons === 'object' ? given.reasons : {}) };
32
+ out.counter = typeof given.counter === 'function' ? given.counter : base.counter;
33
+ return out;
34
+ }
35
+
36
+ /** What pressing a thumb does: withdraw the chosen one, send up at once, ask why for down. */
37
+ export function feedbackPress(value, rating) {
38
+ if (rating !== 'up' && rating !== 'down') return { type: 'none' };
39
+ if (value === rating) return { type: 'remove' };
40
+ return rating === 'up' ? { type: 'submit', payload: { rating: 'up' } } : { type: 'ask' };
41
+ }
42
+
43
+ export function clampComment(text, max = FEEDBACK_COMMENT_MAX) {
44
+ if (typeof text !== 'string') return '';
45
+ const chars = Array.from(text);
46
+ return chars.length <= max ? text : chars.slice(0, max).join('');
47
+ }
48
+
49
+ /** The body to send: a reason only with 'down' and only if listed; blank comment dropped; trimmed and cut. */
50
+ export function buildFeedbackPayload({ rating, reason, comment, reasons = FEEDBACK_REASONS, maxComment = FEEDBACK_COMMENT_MAX } = {}) {
51
+ if (rating !== 'up' && rating !== 'down') throw new Error("rating must be 'up' or 'down'.");
52
+ const payload = { rating };
53
+ if (rating === 'down' && typeof reason === 'string' && reasons.includes(reason)) payload.reason = reason;
54
+ const text = clampComment(typeof comment === 'string' ? comment.trim() : '', maxComment).trim();
55
+ if (text) payload.comment = text;
56
+ return payload;
57
+ }
58
+
59
+ export function feedbackReasonOptions({ reasons = FEEDBACK_REASONS, labels, selected }) {
60
+ return reasons.map((id) => ({ id, label: labels.reasons[id] || id, selected: id === selected }));
61
+ }
@@ -96,3 +96,123 @@ h3 {
96
96
  transition-duration: 1ms !important;
97
97
  }
98
98
  }
99
+
100
+ /* FeedbackBar: thumbs up / down under an AI answer. */
101
+ .astratra-feedback {
102
+ position: relative;
103
+ display: inline-flex;
104
+ align-items: center;
105
+ gap: 6px;
106
+ }
107
+
108
+ .astratra-feedback__thumb {
109
+ width: 32px;
110
+ height: 32px;
111
+ display: inline-grid;
112
+ place-items: center;
113
+ border: 1px solid var(--line, #2e3a31);
114
+ border-radius: 999px;
115
+ background: transparent;
116
+ cursor: pointer;
117
+ opacity: 0.7;
118
+ transition: opacity 120ms, background 120ms;
119
+ }
120
+
121
+ .astratra-feedback__thumb:hover:not(:disabled),
122
+ .astratra-feedback__thumb:focus-visible {
123
+ opacity: 1;
124
+ background: var(--panel-raised, #1a231d);
125
+ }
126
+
127
+ .astratra-feedback__thumb[aria-pressed="true"] {
128
+ opacity: 1;
129
+ border-color: var(--lime, #d9ff66);
130
+ background: var(--panel-raised, #1a231d);
131
+ }
132
+
133
+ .astratra-feedback__thumb:disabled {
134
+ cursor: default;
135
+ opacity: 0.4;
136
+ }
137
+
138
+ .astratra-feedback__sheet {
139
+ position: absolute;
140
+ top: calc(100% + 8px);
141
+ left: 0;
142
+ z-index: 10;
143
+ width: min(360px, 90vw);
144
+ display: grid;
145
+ gap: 10px;
146
+ padding: 14px;
147
+ border: 1px solid var(--line-bright, #46584c);
148
+ border-radius: 12px;
149
+ background: var(--panel, #141b17);
150
+ color: var(--cream, #f4f1e6);
151
+ }
152
+
153
+ .astratra-feedback__title {
154
+ margin: 0;
155
+ font-weight: 600;
156
+ }
157
+
158
+ .astratra-feedback__reasons {
159
+ display: flex;
160
+ flex-wrap: wrap;
161
+ gap: 6px;
162
+ }
163
+
164
+ .astratra-feedback__chip {
165
+ padding: 5px 10px;
166
+ border: 1px solid var(--line, #2e3a31);
167
+ border-radius: 999px;
168
+ background: transparent;
169
+ cursor: pointer;
170
+ }
171
+
172
+ .astratra-feedback__chip[aria-checked="true"] {
173
+ border-color: var(--lime, #d9ff66);
174
+ background: var(--panel-raised, #1a231d);
175
+ }
176
+
177
+ .astratra-feedback__comment {
178
+ display: grid;
179
+ gap: 4px;
180
+ }
181
+
182
+ .astratra-feedback__label,
183
+ .astratra-feedback__counter {
184
+ font-size: 0.8em;
185
+ color: var(--muted, #7c8a7a);
186
+ }
187
+
188
+ .astratra-feedback__counter {
189
+ text-align: right;
190
+ }
191
+
192
+ .astratra-feedback__comment textarea {
193
+ resize: vertical;
194
+ padding: 8px;
195
+ border: 1px solid var(--line, #2e3a31);
196
+ border-radius: 8px;
197
+ background: var(--bg, #0d1310);
198
+ }
199
+
200
+ .astratra-feedback__actions {
201
+ display: flex;
202
+ justify-content: flex-end;
203
+ gap: 8px;
204
+ }
205
+
206
+ .astratra-feedback__cancel,
207
+ .astratra-feedback__send {
208
+ padding: 6px 14px;
209
+ border: 1px solid var(--line, #2e3a31);
210
+ border-radius: 8px;
211
+ background: transparent;
212
+ cursor: pointer;
213
+ }
214
+
215
+ .astratra-feedback__send {
216
+ border-color: var(--lime, #d9ff66);
217
+ color: var(--lime, #d9ff66);
218
+ }