@scalio-cloud/ui 0.6.1 → 0.7.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/CHANGELOG.md +11 -0
- package/README.md +7 -2
- package/nav.css +29 -1
- package/package.json +1 -1
- package/react/index.js +1 -1
- package/react/labels.js +74 -0
- package/react/nav.js +8 -4
- package/react/support.js +336 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0
|
|
4
|
+
- `SupportDrawer` takes `client` (from the new `supportApi(api)`) and `page`: tabs "Neue Anfrage" (category,
|
|
5
|
+
subject, description, technical details) and "Meine Tickets" (list, answers from support, reply, rating) for the
|
|
6
|
+
central support desk (ADR 0022). New settings card `SupportConnection` (portal address and one-time code, status,
|
|
7
|
+
check, disconnect). `LABELS` get the support texts. **Visible change only for apps that pass `client`**; without
|
|
8
|
+
it the drawer keeps its single form.
|
|
9
|
+
|
|
10
|
+
## 0.6.2
|
|
11
|
+
- **Visible change for apps using `SettingsLayout`:** the settings area uses the full width of the window (was at most
|
|
12
|
+
64rem), so wide screens show more cards side by side. No change for other apps.
|
|
13
|
+
|
|
3
14
|
## 0.6.1
|
|
4
15
|
- **Visible change for apps using `SettingsLayout`:** the settings page has no outer padding of its own any more;
|
|
5
16
|
the app's content area brings it (0.6.0 had both, so the page got a double margin). No change for other apps.
|
package/README.md
CHANGED
|
@@ -106,11 +106,16 @@ const apps = useScalioApps(() => api.get("/apps").then((r) => r.data)); // onc
|
|
|
106
106
|
>
|
|
107
107
|
<Routes>...</Routes>
|
|
108
108
|
</AppShell>
|
|
109
|
-
<SupportDrawer open={supportOpen} onClose={...} lang={lang}
|
|
110
|
-
onSubmit={(d) => api.post("/support/tickets", { ...d, url: location.pathname }).then(() => toast.success(...))}
|
|
109
|
+
<SupportDrawer open={supportOpen} onClose={...} lang={lang} client={supportApi(api)} page={location.pathname}
|
|
111
110
|
context={[["URL", location.pathname], [L.tenant, current?.name]]} />
|
|
112
111
|
```
|
|
113
112
|
|
|
113
|
+
With `client` the drawer has the tabs "Neue Anfrage" (category, subject, description, technical details) and
|
|
114
|
+
"Meine Tickets" (list, answers, reply, rating); tickets go to the central support desk in Compass (scalio-docs ADR
|
|
115
|
+
0022), the backend routes come from scalio-connect `scalio_connect.support.web`. `supportApi(api)` takes the app's
|
|
116
|
+
axios instance. The settings card `<SupportConnection client={supportApi(api)} lang={lang} />` connects the
|
|
117
|
+
installation with a one-time code (admins). Without `client` the drawer keeps the old single form with `onSubmit`.
|
|
118
|
+
|
|
114
119
|
`link` is the router's `NavLink` (it gets `to`, `end`, a `className` function and `onClick`); without it the
|
|
115
120
|
sidebar renders plain links and marks the item for `activePath`. Item test ids default to `nav-<path>`
|
|
116
121
|
(`nav-overview` for `/`), or set `testId`. Page blocks: `KpiTile` (tone `error` = red number),
|
package/nav.css
CHANGED
|
@@ -144,6 +144,33 @@
|
|
|
144
144
|
.scalio-support-title { margin: 0; font-family: 'Chivo', sans-serif; font-weight: 700; font-size: 1.125rem; text-transform: uppercase; }
|
|
145
145
|
.scalio-support-context { margin-bottom: 1rem; padding: 0.75rem; border: 1px solid hsl(var(--border)); border-radius: 2px; background: hsl(var(--muted)); font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; overflow-wrap: anywhere; }
|
|
146
146
|
.scalio-support-form { display: flex; flex-direction: column; gap: 0.375rem; }
|
|
147
|
+
.scalio-support-tabs { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
|
|
148
|
+
.scalio-support-tab { flex: 1; padding: 0 0 0.5rem; border: 0; border-bottom: 2px solid transparent; background: none; color: hsl(var(--muted-foreground)); font: inherit; font-size: 0.875rem; cursor: pointer; }
|
|
149
|
+
.scalio-support-tab.is-active { border-bottom-color: hsl(var(--primary)); color: hsl(var(--foreground)); font-weight: 600; }
|
|
150
|
+
.scalio-support-check { display: flex; align-items: flex-start; gap: 0.5rem; margin-top: 0.5rem; font-size: 0.75rem; color: hsl(var(--muted-foreground)); }
|
|
151
|
+
.scalio-support-check input { margin-top: 0.125rem; }
|
|
152
|
+
.scalio-support-list, .scalio-support-detail, .scalio-support-conn { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
153
|
+
.scalio-support-item { display: block; width: 100%; padding: 0.75rem; border: 1px solid hsl(var(--border)); border-radius: 2px; background: none; color: inherit; font: inherit; font-size: 0.875rem; text-align: left; cursor: pointer; }
|
|
154
|
+
.scalio-support-item:hover { background: hsl(var(--muted)); }
|
|
155
|
+
.scalio-support-item.is-waiting { border-style: dashed; cursor: default; }
|
|
156
|
+
.scalio-support-item-head { display: flex; justify-content: space-between; gap: 0.5rem; }
|
|
157
|
+
.scalio-support-item-title { margin: 0; font-weight: 600; }
|
|
158
|
+
.scalio-support-number { flex-shrink: 0; font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; color: hsl(var(--primary)); }
|
|
159
|
+
.scalio-support-meta { margin: 0; font-size: 0.75rem; color: hsl(var(--muted-foreground)); }
|
|
160
|
+
.scalio-support-empty { padding: 2rem 0; text-align: center; font-size: 0.875rem; color: hsl(var(--muted-foreground)); }
|
|
161
|
+
.scalio-support-back { align-self: flex-start; padding: 0; border: 0; background: none; color: hsl(var(--muted-foreground)); font: inherit; font-size: 0.75rem; cursor: pointer; }
|
|
162
|
+
.scalio-support-back:hover { color: hsl(var(--foreground)); }
|
|
163
|
+
.scalio-support-msg { margin: 0; padding: 0.75rem; border: 1px solid hsl(var(--border)); border-radius: 2px; font-size: 0.875rem; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
164
|
+
.scalio-support-msg.is-support { border-color: hsl(var(--primary) / 0.3); background: hsl(var(--primary) / 0.05); }
|
|
165
|
+
.scalio-support-stars { display: flex; gap: 0.25rem; }
|
|
166
|
+
.scalio-support-stars button { border: 0; background: none; font-size: 1.25rem; line-height: 1; color: #f59e0b; cursor: pointer; }
|
|
167
|
+
.scalio-support-conn { font-size: 0.875rem; }
|
|
168
|
+
.scalio-support-conn-head { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
|
|
169
|
+
.scalio-support-conn-facts { display: grid; grid-template-columns: auto 1fr; gap: 0.25rem 1rem; margin: 0; font-size: 0.75rem; }
|
|
170
|
+
.scalio-support-conn-facts dt { color: hsl(var(--muted-foreground)); }
|
|
171
|
+
.scalio-support-conn-facts dd { margin: 0; font-family: 'JetBrains Mono', monospace; overflow-wrap: anywhere; }
|
|
172
|
+
.scalio-support-conn-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
|
173
|
+
.scalio-btn-danger { color: hsl(var(--destructive)); }
|
|
147
174
|
.scalio-field-label { margin-top: 0.5rem; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: hsl(var(--muted-foreground)); }
|
|
148
175
|
.scalio-input {
|
|
149
176
|
width: 100%; height: 2.5rem; padding: 0 0.75rem; border: 1px solid hsl(var(--input)); border-radius: 2px;
|
|
@@ -238,7 +265,8 @@
|
|
|
238
265
|
.scalio-settings-item.is-active { border-left-color: hsl(var(--primary)); color: hsl(var(--foreground)); font-weight: 600; background: hsl(var(--muted)); }
|
|
239
266
|
.scalio-settings-item:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: -2px; }
|
|
240
267
|
.scalio-settings-empty { margin: 0; padding: 0 0.75rem; font-size: 0.8125rem; color: hsl(var(--muted-foreground)); }
|
|
241
|
-
|
|
268
|
+
/* Full width: the areas arrange their cards in a grid that grows with the window. */
|
|
269
|
+
.scalio-settings-main { flex: 1 1 auto; min-width: 0; }
|
|
242
270
|
.scalio-settings-head { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 1rem; margin-bottom: 1.5rem; }
|
|
243
271
|
.scalio-settings-body { display: flex; flex-direction: column; }
|
|
244
272
|
.scalio-settings-section-footer { display: flex; justify-content: flex-end; gap: 0.5rem; padding-top: 0.25rem; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scalio-cloud/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Scalio Control Room design tokens, Tailwind preset, self-hosted fonts, the shared app header, sign-in layout, sidebar and page blocks",
|
|
5
5
|
"license": "SEE LICENSE IN README.md",
|
|
6
6
|
"private": false,
|
package/react/index.js
CHANGED
|
@@ -333,6 +333,6 @@ module.exports = {
|
|
|
333
333
|
TEST_IDS,
|
|
334
334
|
initials,
|
|
335
335
|
};
|
|
336
|
-
Object.assign(module.exports, Nav, Blocks, require("./settings"));
|
|
336
|
+
Object.assign(module.exports, Nav, Blocks, require("./settings"), require("./support"));
|
|
337
337
|
// After the exports above: auth.js uses LanguageToggle and ThemeToggle from this module.
|
|
338
338
|
Object.assign(module.exports, require("./auth"));
|
package/react/labels.js
CHANGED
|
@@ -59,6 +59,43 @@ const LABELS = {
|
|
|
59
59
|
send: "Anfrage senden",
|
|
60
60
|
sending: "Wird gesendet …",
|
|
61
61
|
close: "Schliessen",
|
|
62
|
+
supportNew: "Neue Anfrage",
|
|
63
|
+
supportMine: "Meine Tickets",
|
|
64
|
+
supportCategory: "Kategorie",
|
|
65
|
+
supportCategories: { question: "Frage", bug: "Fehler", wish: "Wunsch", license: "Lizenz", billing: "Rechnung" },
|
|
66
|
+
supportDiagnostics: "Technische Angaben mitsenden (Version, Lizenzstufe, Browser, letzte Fehler; keine Personendaten)",
|
|
67
|
+
supportSentAs: "Gesendet als {n}. Die Antwort sehen Sie unter «Meine Tickets» und per E-Mail.",
|
|
68
|
+
supportQueued: "Gespeichert. Die Anfrage wird gesendet, sobald das Support-Portal erreichbar ist.",
|
|
69
|
+
supportWaiting: "Wartet auf Versand",
|
|
70
|
+
supportNotSent: "Nicht gesendet. Bitte schreiben Sie an den Support.",
|
|
71
|
+
supportNotConnected: "Diese Installation ist noch nicht mit dem Support verbunden. Anfragen werden bis zu 3 Tage aufbewahrt.",
|
|
72
|
+
supportUnreachable: "Das Support-Portal ist gerade nicht erreichbar.",
|
|
73
|
+
supportNone: "Noch keine Tickets.",
|
|
74
|
+
supportFromSupport: "Scalio Support",
|
|
75
|
+
supportReply: "Antworten",
|
|
76
|
+
supportReplyPlaceholder: "Ihre Antwort",
|
|
77
|
+
supportReplySent: "Antwort gesendet.",
|
|
78
|
+
supportRate: "Wie zufrieden sind Sie mit der Lösung?",
|
|
79
|
+
supportThanks: "Danke für Ihre Bewertung.",
|
|
80
|
+
supportStatus: { new: "Neu", open: "Offen", in_progress: "In Bearbeitung", waiting: "Wartet auf Sie", waiting_customer: "Wartet auf Sie", resolved: "Gelöst", closed: "Geschlossen" },
|
|
81
|
+
supportConnStatus: { unpaired: "Nicht verbunden", pending: "Wartet auf Bestätigung", active: "Verbunden", blocked: "Gesperrt" },
|
|
82
|
+
supportConnHelp: "Tickets aus dieser Installation gehen an den Scalio Support. Den einmaligen Code erhalten Sie im Scalio-Portal unter Support -> Verbundene Apps.",
|
|
83
|
+
supportConnHelpConnected: "Tickets aus dieser Installation gehen an den Scalio Support.",
|
|
84
|
+
supportConnPending: "Ein Administrator Ihrer Organisation muss die Verbindung im Scalio-Portal bestätigen.",
|
|
85
|
+
supportConnBlocked: "Diese Installation ist für den Support gesperrt. Bitte wenden Sie sich an Scalio.",
|
|
86
|
+
supportConnKeyLost: "Der Schlüssel dieser Verbindung kann nicht mehr gelesen werden. Bitte verbinden Sie neu.",
|
|
87
|
+
supportPortal: "Support-Portal",
|
|
88
|
+
supportCode: "Einmaliger Code",
|
|
89
|
+
supportInstance: "Instanz",
|
|
90
|
+
supportSince: "Verbunden seit",
|
|
91
|
+
supportOutbox: "Warteschlange: {pending} wartend, {failed} nicht gesendet",
|
|
92
|
+
supportConnect: "Verbinden",
|
|
93
|
+
supportConnected: "Verbindung hergestellt.",
|
|
94
|
+
supportCheck: "Verbindung prüfen",
|
|
95
|
+
supportChecked: "Verbindung geprüft.",
|
|
96
|
+
supportRemove: "Trennen",
|
|
97
|
+
supportRemoveConfirm: "Verbindung zum Support trennen? Neue Tickets warten dann, bis wieder verbunden ist.",
|
|
98
|
+
supportRemoved: "Verbindung getrennt.",
|
|
62
99
|
// States
|
|
63
100
|
loading: "Wird geladen …",
|
|
64
101
|
empty: "Noch keine Einträge.",
|
|
@@ -130,6 +167,43 @@ const LABELS = {
|
|
|
130
167
|
send: "Send request",
|
|
131
168
|
sending: "Sending …",
|
|
132
169
|
close: "Close",
|
|
170
|
+
supportNew: "New request",
|
|
171
|
+
supportMine: "My tickets",
|
|
172
|
+
supportCategory: "Category",
|
|
173
|
+
supportCategories: { question: "Question", bug: "Bug", wish: "Wish", license: "License", billing: "Billing" },
|
|
174
|
+
supportDiagnostics: "Include technical details (version, license tier, browser, recent errors; no personal data)",
|
|
175
|
+
supportSentAs: "Sent as {n}. You will see the answer under “My tickets” and by email.",
|
|
176
|
+
supportQueued: "Saved. The request will be sent as soon as the support portal is reachable.",
|
|
177
|
+
supportWaiting: "Waiting to be sent",
|
|
178
|
+
supportNotSent: "Not sent. Please write to support.",
|
|
179
|
+
supportNotConnected: "This installation is not connected to support yet. Requests are kept for up to 3 days.",
|
|
180
|
+
supportUnreachable: "The support portal is not reachable right now.",
|
|
181
|
+
supportNone: "No tickets yet.",
|
|
182
|
+
supportFromSupport: "Scalio Support",
|
|
183
|
+
supportReply: "Reply",
|
|
184
|
+
supportReplyPlaceholder: "Your reply",
|
|
185
|
+
supportReplySent: "Reply sent.",
|
|
186
|
+
supportRate: "How satisfied are you with the solution?",
|
|
187
|
+
supportThanks: "Thank you for your rating.",
|
|
188
|
+
supportStatus: { new: "New", open: "Open", in_progress: "In progress", waiting: "Waiting for you", waiting_customer: "Waiting for you", resolved: "Resolved", closed: "Closed" },
|
|
189
|
+
supportConnStatus: { unpaired: "Not connected", pending: "Waiting for confirmation", active: "Connected", blocked: "Blocked" },
|
|
190
|
+
supportConnHelp: "Tickets from this installation go to Scalio Support. You get the one-time code in the Scalio portal under Support -> Connected apps.",
|
|
191
|
+
supportConnHelpConnected: "Tickets from this installation go to Scalio Support.",
|
|
192
|
+
supportConnPending: "An administrator of your organisation must confirm the connection in the Scalio portal.",
|
|
193
|
+
supportConnBlocked: "This installation is blocked for support. Please contact Scalio.",
|
|
194
|
+
supportConnKeyLost: "The key of this connection can no longer be read. Please connect again.",
|
|
195
|
+
supportPortal: "Support portal",
|
|
196
|
+
supportCode: "One-time code",
|
|
197
|
+
supportInstance: "Instance",
|
|
198
|
+
supportSince: "Connected since",
|
|
199
|
+
supportOutbox: "Queue: {pending} waiting, {failed} not sent",
|
|
200
|
+
supportConnect: "Connect",
|
|
201
|
+
supportConnected: "Connected.",
|
|
202
|
+
supportCheck: "Check connection",
|
|
203
|
+
supportChecked: "Connection checked.",
|
|
204
|
+
supportRemove: "Disconnect",
|
|
205
|
+
supportRemoveConfirm: "Disconnect from support? New tickets then wait until the instance is connected again.",
|
|
206
|
+
supportRemoved: "Disconnected.",
|
|
133
207
|
loading: "Loading …",
|
|
134
208
|
empty: "No entries yet.",
|
|
135
209
|
sections: {
|
package/react/nav.js
CHANGED
|
@@ -411,9 +411,11 @@ function resetScalioApps() {
|
|
|
411
411
|
}
|
|
412
412
|
|
|
413
413
|
// --- Support drawer ---------------------------------------------------------------------------
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
|
|
414
|
+
// With client (supportApi(api), see support.js): tabs "Neue Anfrage" and "Meine Tickets"; tickets go to the
|
|
415
|
+
// central support desk (ADR 0022), page is the current path. Without client: one form, onSubmit({ subject,
|
|
416
|
+
// description }) sends it (and shows the app's toast); a thrown error is shown in the drawer.
|
|
417
|
+
// context: rows like [["URL", "/drifts"], ["Tenant", "Contoso"]].
|
|
418
|
+
function SupportDrawer({ open, onClose, onSubmit, client, page, context, lang, labels }) {
|
|
417
419
|
const L = labelsFor(lang, labels);
|
|
418
420
|
const [subject, setSubject] = React.useState("");
|
|
419
421
|
const [description, setDescription] = React.useState("");
|
|
@@ -467,7 +469,9 @@ function SupportDrawer({ open, onClose, onSubmit, context, lang, labels }) {
|
|
|
467
469
|
context.map((row, i) => h("div", { key: i }, `${row[0]}: ${row[1] == null || row[1] === "" ? "—" : row[1]}`))
|
|
468
470
|
)
|
|
469
471
|
: null,
|
|
470
|
-
|
|
472
|
+
client
|
|
473
|
+
? h(require("./support").SupportDesk, { client, page, lang, labels })
|
|
474
|
+
: h(
|
|
471
475
|
"form",
|
|
472
476
|
{ className: "scalio-support-form", onSubmit: submit },
|
|
473
477
|
h("label", { className: "scalio-field-label", htmlFor: "support-subject" }, L.subject),
|
package/react/support.js
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Support desk in the apps (ADR 0022): tickets go to the central Scalio support desk in Compass, the app only
|
|
3
|
+
// relays them. The backend routes come from scalio-connect (`scalio_connect.support.web`); supportApi(api) maps
|
|
4
|
+
// them for an axios-like client. SupportDesk is the body of SupportDrawer when it gets a `client`:
|
|
5
|
+
// "Neue Anfrage" (category, subject, description, diagnostics) and "Meine Tickets" (list, detail, reply, rating).
|
|
6
|
+
// SupportConnection is the settings card "Support-Verbindung" (portal address + one-time code).
|
|
7
|
+
const React = require("react");
|
|
8
|
+
const h = React.createElement;
|
|
9
|
+
const { labelsFor } = require("./labels");
|
|
10
|
+
|
|
11
|
+
const CATEGORIES = ["question", "bug", "wish", "license", "billing"];
|
|
12
|
+
const DONE = ["resolved", "closed"];
|
|
13
|
+
|
|
14
|
+
function cx() {
|
|
15
|
+
return Array.prototype.slice.call(arguments).filter(Boolean).join(" ");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// api: axios instance with the app's base URL (/api). Every method resolves to the response body.
|
|
19
|
+
function supportApi(api) {
|
|
20
|
+
const data = (p) => p.then((r) => r.data);
|
|
21
|
+
const enc = encodeURIComponent;
|
|
22
|
+
return {
|
|
23
|
+
create: (ticket) => data(api.post("/support/tickets", ticket)),
|
|
24
|
+
list: () => data(api.get("/support/tickets")),
|
|
25
|
+
get: (id) => data(api.get(`/support/tickets/${enc(id)}`)),
|
|
26
|
+
reply: (id, body) => data(api.post(`/support/tickets/${enc(id)}/messages`, { body })),
|
|
27
|
+
rate: (id, score) => data(api.post(`/support/tickets/${enc(id)}/rating`, { score })),
|
|
28
|
+
connection: () => data(api.get("/support/connection")),
|
|
29
|
+
connect: (portal_url, code) => data(api.post("/support/connection", { portal_url, code })),
|
|
30
|
+
check: () => data(api.post("/support/connection/check")),
|
|
31
|
+
disconnect: () => data(api.delete("/support/connection")),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function errorText(err) {
|
|
36
|
+
const d = err && err.response && err.response.data && err.response.data.detail;
|
|
37
|
+
if (typeof d === "string") return d;
|
|
38
|
+
if (d && typeof d.message === "string") return d.message;
|
|
39
|
+
return (err && err.message) || String(err);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function fmtDate(value, lang) {
|
|
43
|
+
if (!value) return "";
|
|
44
|
+
const d = new Date(value);
|
|
45
|
+
if (isNaN(d.getTime())) return String(value);
|
|
46
|
+
return d.toLocaleString(lang === "en" ? "en-GB" : "de-CH", { dateStyle: "medium", timeStyle: "short" });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function statusText(L, s) {
|
|
50
|
+
return (L.supportStatus && L.supportStatus[s]) || s;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function NewTicket({ client, page, L, onSent }) {
|
|
54
|
+
const [category, setCategory] = React.useState("question");
|
|
55
|
+
const [subject, setSubject] = React.useState("");
|
|
56
|
+
const [description, setDescription] = React.useState("");
|
|
57
|
+
const [diagnostics, setDiagnostics] = React.useState(true);
|
|
58
|
+
const [busy, setBusy] = React.useState(false);
|
|
59
|
+
const [error, setError] = React.useState("");
|
|
60
|
+
const submit = async (e) => {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
setBusy(true);
|
|
63
|
+
setError("");
|
|
64
|
+
try {
|
|
65
|
+
const browser = diagnostics && typeof navigator !== "undefined" ? String(navigator.userAgent || "").slice(0, 300) : "";
|
|
66
|
+
const res = await client.create({ category, subject, description, url: page || "", diagnostics, browser });
|
|
67
|
+
setSubject("");
|
|
68
|
+
setDescription("");
|
|
69
|
+
setCategory("question");
|
|
70
|
+
onSent(res && res.number ? L.supportSentAs.replace("{n}", res.number) : L.supportQueued);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
setError(errorText(err));
|
|
73
|
+
} finally {
|
|
74
|
+
setBusy(false);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
return h(
|
|
78
|
+
"form",
|
|
79
|
+
{ className: "scalio-support-form", onSubmit: submit },
|
|
80
|
+
h("label", { className: "scalio-field-label", htmlFor: "support-category" }, L.supportCategory),
|
|
81
|
+
h(
|
|
82
|
+
"select",
|
|
83
|
+
{ id: "support-category", className: "scalio-input", value: category, onChange: (e) => setCategory(e.target.value), "data-testid": "ticket-category-select" },
|
|
84
|
+
CATEGORIES.map((c) => h("option", { key: c, value: c }, L.supportCategories[c]))
|
|
85
|
+
),
|
|
86
|
+
h("label", { className: "scalio-field-label", htmlFor: "support-subject" }, L.subject),
|
|
87
|
+
h("input", {
|
|
88
|
+
id: "support-subject", className: "scalio-input", value: subject, required: true, minLength: 3, maxLength: 200,
|
|
89
|
+
onChange: (e) => setSubject(e.target.value), "data-testid": "ticket-subject-input",
|
|
90
|
+
}),
|
|
91
|
+
h("label", { className: "scalio-field-label", htmlFor: "support-description" }, L.description),
|
|
92
|
+
h("textarea", {
|
|
93
|
+
id: "support-description", className: "scalio-input scalio-textarea", rows: 5, value: description, required: true,
|
|
94
|
+
minLength: 3, maxLength: 5000, onChange: (e) => setDescription(e.target.value), "data-testid": "ticket-description-input",
|
|
95
|
+
}),
|
|
96
|
+
h(
|
|
97
|
+
"label",
|
|
98
|
+
{ className: "scalio-support-check", "data-testid": "ticket-diagnostics-toggle" },
|
|
99
|
+
h("input", { type: "checkbox", checked: diagnostics, onChange: (e) => setDiagnostics(e.target.checked) }),
|
|
100
|
+
h("span", null, L.supportDiagnostics)
|
|
101
|
+
),
|
|
102
|
+
error ? h("div", { className: "scalio-notice is-error", role: "alert", "data-testid": "support-error" }, error) : null,
|
|
103
|
+
h("button", { type: "submit", className: "scalio-btn-primary", disabled: busy, "data-testid": "ticket-submit-btn" }, busy ? L.sending : L.send)
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function Waiting({ item, L }) {
|
|
108
|
+
return h(
|
|
109
|
+
"div",
|
|
110
|
+
{ className: "scalio-support-item is-waiting" },
|
|
111
|
+
item.subject ? h("div", { className: "scalio-support-item-title" }, item.subject) : null,
|
|
112
|
+
h("div", { className: "scalio-support-meta" }, item.state === "failed" ? L.supportNotSent : L.supportWaiting)
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function MyTickets({ client, L, lang, onOpen }) {
|
|
117
|
+
const [data, setData] = React.useState(null);
|
|
118
|
+
React.useEffect(() => {
|
|
119
|
+
let alive = true;
|
|
120
|
+
client.list().then((d) => alive && setData(d)).catch(() => alive && setData({ tickets: [], queued: [], reachable: false }));
|
|
121
|
+
return () => { alive = false; };
|
|
122
|
+
}, [client]);
|
|
123
|
+
if (!data) return h("div", { className: "scalio-support-meta" }, L.loading);
|
|
124
|
+
const tickets = data.tickets || [];
|
|
125
|
+
const queued = data.queued || [];
|
|
126
|
+
return h(
|
|
127
|
+
"div",
|
|
128
|
+
{ className: "scalio-support-list", "data-testid": "support-my-tickets" },
|
|
129
|
+
data.connected === false ? h("div", { className: "scalio-support-meta" }, L.supportNotConnected) : null,
|
|
130
|
+
data.reachable === false ? h("div", { className: "scalio-notice is-warn" }, L.supportUnreachable) : null,
|
|
131
|
+
queued.map((q) => h(Waiting, { key: q.id, item: q, L })),
|
|
132
|
+
tickets.map((t) =>
|
|
133
|
+
h(
|
|
134
|
+
"button",
|
|
135
|
+
{ key: t.id, type: "button", className: "scalio-support-item", onClick: () => onOpen(t.id), "data-testid": `support-ticket-${t.id}` },
|
|
136
|
+
h(
|
|
137
|
+
"div",
|
|
138
|
+
{ className: "scalio-support-item-head" },
|
|
139
|
+
h("span", { className: "scalio-support-item-title" }, t.title),
|
|
140
|
+
t.number ? h("span", { className: "scalio-support-number" }, t.number) : null
|
|
141
|
+
),
|
|
142
|
+
h("div", { className: "scalio-support-meta" }, `${statusText(L, t.status)} · ${fmtDate(t.last_activity_at || t.created_at, lang)}`)
|
|
143
|
+
)
|
|
144
|
+
),
|
|
145
|
+
!tickets.length && !queued.length ? h("div", { className: "scalio-support-empty" }, L.supportNone) : null
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function TicketDetail({ client, id, L, lang, onBack }) {
|
|
150
|
+
const [tk, setTk] = React.useState(null);
|
|
151
|
+
const [reply, setReply] = React.useState("");
|
|
152
|
+
const [busy, setBusy] = React.useState(false);
|
|
153
|
+
const [error, setError] = React.useState("");
|
|
154
|
+
const [note, setNote] = React.useState("");
|
|
155
|
+
const load = React.useCallback(() => client.get(id).then(setTk).catch((err) => { setError(errorText(err)); setTk(false); }), [client, id]);
|
|
156
|
+
React.useEffect(() => { load(); }, [load]);
|
|
157
|
+
const send = async (fn, ok) => {
|
|
158
|
+
setBusy(true);
|
|
159
|
+
setError("");
|
|
160
|
+
try {
|
|
161
|
+
await fn();
|
|
162
|
+
setReply("");
|
|
163
|
+
setNote(ok);
|
|
164
|
+
await load();
|
|
165
|
+
} catch (err) {
|
|
166
|
+
setError(errorText(err));
|
|
167
|
+
} finally {
|
|
168
|
+
setBusy(false);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
const back = h("button", { type: "button", className: "scalio-support-back", onClick: onBack, "data-testid": "support-back" }, `‹ ${L.supportMine}`);
|
|
172
|
+
if (tk === null) return h("div", null, back, h("div", { className: "scalio-support-meta" }, L.loading));
|
|
173
|
+
if (tk === false) return h("div", null, back, h("div", { className: "scalio-notice is-error", role: "alert" }, error));
|
|
174
|
+
return h(
|
|
175
|
+
"div",
|
|
176
|
+
{ className: "scalio-support-detail", "data-testid": "support-ticket-detail" },
|
|
177
|
+
back,
|
|
178
|
+
h("div", { className: "scalio-support-number" }, `${tk.number || ""} · ${statusText(L, tk.status)}`),
|
|
179
|
+
h("h4", { className: "scalio-support-item-title" }, tk.title),
|
|
180
|
+
tk.description ? h("p", { className: "scalio-support-msg" }, tk.description) : null,
|
|
181
|
+
(tk.messages || []).map((m) =>
|
|
182
|
+
h(
|
|
183
|
+
"div",
|
|
184
|
+
{ key: m.id, className: cx("scalio-support-msg", m.from_support && "is-support") },
|
|
185
|
+
h("div", { className: "scalio-support-meta" }, `${m.from_support ? L.supportFromSupport : m.author_name || ""} · ${fmtDate(m.created_at, lang)}`),
|
|
186
|
+
h("div", null, m.body)
|
|
187
|
+
)
|
|
188
|
+
),
|
|
189
|
+
(tk.queued || []).map((q) => h(Waiting, { key: q.id, item: q, L })),
|
|
190
|
+
note ? h("div", { className: "scalio-notice is-ok", "data-testid": "support-note" }, note) : null,
|
|
191
|
+
error ? h("div", { className: "scalio-notice is-error", role: "alert", "data-testid": "support-error" }, error) : null,
|
|
192
|
+
tk.status !== "closed"
|
|
193
|
+
? h(
|
|
194
|
+
"div",
|
|
195
|
+
{ className: "scalio-support-form" },
|
|
196
|
+
h("textarea", {
|
|
197
|
+
className: "scalio-input scalio-textarea", rows: 3, value: reply, placeholder: L.supportReplyPlaceholder,
|
|
198
|
+
"aria-label": L.supportReply, onChange: (e) => setReply(e.target.value), "data-testid": "ticket-reply-input",
|
|
199
|
+
}),
|
|
200
|
+
h(
|
|
201
|
+
"button",
|
|
202
|
+
{ type: "button", className: "scalio-btn-primary", disabled: busy || !reply.trim(), "data-testid": "ticket-reply-btn",
|
|
203
|
+
onClick: () => send(() => client.reply(id, reply.trim()), L.supportReplySent) },
|
|
204
|
+
L.supportReply
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
: null,
|
|
208
|
+
DONE.includes(tk.status) && !tk.csat_score
|
|
209
|
+
? h(
|
|
210
|
+
"div",
|
|
211
|
+
{ className: "scalio-support-rating", "data-testid": "ticket-rating" },
|
|
212
|
+
h("div", { className: "scalio-support-meta" }, L.supportRate),
|
|
213
|
+
h(
|
|
214
|
+
"div",
|
|
215
|
+
{ className: "scalio-support-stars" },
|
|
216
|
+
[1, 2, 3, 4, 5].map((n) =>
|
|
217
|
+
h("button", { key: n, type: "button", disabled: busy, "aria-label": String(n), "data-testid": `ticket-rate-${n}`,
|
|
218
|
+
onClick: () => send(() => client.rate(id, n), L.supportThanks) }, "★")
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
: null
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function SupportDesk({ client, page, lang, labels }) {
|
|
227
|
+
const L = labelsFor(lang, labels);
|
|
228
|
+
const [tab, setTab] = React.useState("new");
|
|
229
|
+
const [ticket, setTicket] = React.useState(null);
|
|
230
|
+
const [note, setNote] = React.useState("");
|
|
231
|
+
const tabBtn = (key, label) =>
|
|
232
|
+
h("button", {
|
|
233
|
+
type: "button", role: "tab", "aria-selected": tab === key, className: cx("scalio-support-tab", tab === key && "is-active"),
|
|
234
|
+
onClick: () => { setTab(key); setTicket(null); setNote(""); }, "data-testid": `support-tab-${key}`,
|
|
235
|
+
}, label);
|
|
236
|
+
return h(
|
|
237
|
+
React.Fragment,
|
|
238
|
+
null,
|
|
239
|
+
h("div", { className: "scalio-support-tabs", role: "tablist" }, tabBtn("new", L.supportNew), tabBtn("mine", L.supportMine)),
|
|
240
|
+
note ? h("div", { className: "scalio-notice is-ok", "data-testid": "support-note" }, note) : null,
|
|
241
|
+
tab === "new" ? h(NewTicket, { client, page, L, onSent: (text) => { setNote(text); setTab("mine"); } }) : null,
|
|
242
|
+
tab === "mine" && !ticket ? h(MyTickets, { client, L, lang, onOpen: (id) => { setNote(""); setTicket(id); } }) : null,
|
|
243
|
+
tab === "mine" && ticket ? h(TicketDetail, { client, id: ticket, L, lang, onBack: () => setTicket(null) }) : null
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const CONN_TONE = { active: "is-ok", pending: "is-warn", blocked: "is-error", unpaired: "is-neutral" };
|
|
248
|
+
|
|
249
|
+
// Settings card. client: supportApi(api). Shown to admins only; the backend answers 403 for others.
|
|
250
|
+
function SupportConnection({ client, lang, labels, onChange }) {
|
|
251
|
+
const L = labelsFor(lang, labels);
|
|
252
|
+
const [st, setSt] = React.useState(null);
|
|
253
|
+
const [portal, setPortal] = React.useState("");
|
|
254
|
+
const [code, setCode] = React.useState("");
|
|
255
|
+
const [busy, setBusy] = React.useState(false);
|
|
256
|
+
const [error, setError] = React.useState("");
|
|
257
|
+
const [note, setNote] = React.useState("");
|
|
258
|
+
React.useEffect(() => {
|
|
259
|
+
let alive = true;
|
|
260
|
+
client.connection().then((d) => alive && setSt(d)).catch((err) => alive && (setError(errorText(err)), setSt(false)));
|
|
261
|
+
return () => { alive = false; };
|
|
262
|
+
}, [client]);
|
|
263
|
+
const run = async (fn, ok) => {
|
|
264
|
+
setBusy(true);
|
|
265
|
+
setError("");
|
|
266
|
+
setNote("");
|
|
267
|
+
try {
|
|
268
|
+
const d = await fn();
|
|
269
|
+
setSt(d);
|
|
270
|
+
setCode("");
|
|
271
|
+
setNote(ok);
|
|
272
|
+
if (onChange) onChange(d);
|
|
273
|
+
} catch (err) {
|
|
274
|
+
setError(errorText(err));
|
|
275
|
+
} finally {
|
|
276
|
+
setBusy(false);
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
if (st === null) return h("div", { className: "scalio-support-meta" }, L.loading);
|
|
280
|
+
if (st === false) return h("div", { className: "scalio-notice is-error", role: "alert" }, error);
|
|
281
|
+
const connected = st.status !== "unpaired";
|
|
282
|
+
const outbox = st.outbox || {};
|
|
283
|
+
return h(
|
|
284
|
+
"div",
|
|
285
|
+
{ className: "scalio-support-conn", "data-testid": "support-connection" },
|
|
286
|
+
h(
|
|
287
|
+
"div",
|
|
288
|
+
{ className: "scalio-support-conn-head" },
|
|
289
|
+
h("span", { className: cx("scalio-badge", CONN_TONE[st.status] || "is-neutral"), "data-testid": "support-connection-status" },
|
|
290
|
+
(L.supportConnStatus && L.supportConnStatus[st.status]) || st.status),
|
|
291
|
+
st.status === "active" && st.detail ? h("span", null, st.detail) : null
|
|
292
|
+
),
|
|
293
|
+
h("p", { className: "scalio-support-meta" }, connected ? L.supportConnHelpConnected : L.supportConnHelp),
|
|
294
|
+
st.status === "pending" ? h("div", { className: "scalio-notice is-warn" }, L.supportConnPending) : null,
|
|
295
|
+
st.status === "blocked" ? h("div", { className: "scalio-notice is-error" }, L.supportConnBlocked) : null,
|
|
296
|
+
connected && st.key_ok === false ? h("div", { className: "scalio-notice is-error" }, L.supportConnKeyLost) : null,
|
|
297
|
+
connected
|
|
298
|
+
? h(
|
|
299
|
+
"dl",
|
|
300
|
+
{ className: "scalio-support-conn-facts" },
|
|
301
|
+
h("dt", null, L.supportPortal), h("dd", null, st.portal_url),
|
|
302
|
+
h("dt", null, L.supportInstance), h("dd", null, st.instance_id),
|
|
303
|
+
st.paired_at ? h(React.Fragment, null, h("dt", null, L.supportSince), h("dd", null, fmtDate(st.paired_at, lang))) : null
|
|
304
|
+
)
|
|
305
|
+
: null,
|
|
306
|
+
outbox.pending || outbox.failed
|
|
307
|
+
? h("div", { className: "scalio-support-meta", "data-testid": "support-outbox" },
|
|
308
|
+
L.supportOutbox.replace("{pending}", outbox.pending || 0).replace("{failed}", outbox.failed || 0))
|
|
309
|
+
: null,
|
|
310
|
+
note ? h("div", { className: "scalio-notice is-ok", "data-testid": "support-note" }, note) : null,
|
|
311
|
+
error ? h("div", { className: "scalio-notice is-error", role: "alert", "data-testid": "support-error" }, error) : null,
|
|
312
|
+
connected
|
|
313
|
+
? h(
|
|
314
|
+
"div",
|
|
315
|
+
{ className: "scalio-support-conn-actions" },
|
|
316
|
+
h("button", { type: "button", className: "scalio-btn", disabled: busy, "data-testid": "support-connection-check",
|
|
317
|
+
onClick: () => run(() => client.check(), L.supportChecked) }, L.supportCheck),
|
|
318
|
+
h("button", { type: "button", className: "scalio-btn scalio-btn-danger", disabled: busy, "data-testid": "support-connection-remove",
|
|
319
|
+
onClick: () => (typeof window === "undefined" || window.confirm(L.supportRemoveConfirm)) && run(() => client.disconnect(), L.supportRemoved) },
|
|
320
|
+
L.supportRemove)
|
|
321
|
+
)
|
|
322
|
+
: h(
|
|
323
|
+
"form",
|
|
324
|
+
{ className: "scalio-support-form", onSubmit: (e) => { e.preventDefault(); run(() => client.connect(portal.trim(), code.trim()), L.supportConnected); } },
|
|
325
|
+
h("label", { className: "scalio-field-label", htmlFor: "support-portal" }, L.supportPortal),
|
|
326
|
+
h("input", { id: "support-portal", className: "scalio-input", placeholder: "https://portal.scaliocloud.com", value: portal, required: true,
|
|
327
|
+
onChange: (e) => setPortal(e.target.value), "data-testid": "support-portal-input" }),
|
|
328
|
+
h("label", { className: "scalio-field-label", htmlFor: "support-code" }, L.supportCode),
|
|
329
|
+
h("input", { id: "support-code", className: "scalio-input", placeholder: "spc_…", value: code, required: true,
|
|
330
|
+
onChange: (e) => setCode(e.target.value), "data-testid": "support-code-input" }),
|
|
331
|
+
h("button", { type: "submit", className: "scalio-btn-primary", disabled: busy, "data-testid": "support-connect-btn" }, L.supportConnect)
|
|
332
|
+
)
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
module.exports = { supportApi, SupportDesk, SupportConnection, SUPPORT_CATEGORIES: CATEGORIES };
|