@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 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
- .scalio-settings-main { flex: 1 1 auto; min-width: 0; max-width: 64rem; }
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.6.1",
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
- // onSubmit({ subject, description }) sends the request (and shows the app's toast); a thrown error is
415
- // shown in the drawer. context: rows like [["URL", "/drifts"], ["Tenant", "Contoso"]].
416
- function SupportDrawer({ open, onClose, onSubmit, context, lang, labels }) {
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
- h(
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),
@@ -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 };