@rapidmx/web-client 0.6.0 → 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.
Files changed (79) hide show
  1. package/apps/shared/components/admin/settings/BrandingForm.tsx +398 -385
  2. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -112
  3. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +159 -152
  4. package/apps/shared/components/admin/settings/PluginsManager.tsx +1288 -1235
  5. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +9 -1
  6. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -290
  7. package/apps/shared/components/admin/setup/SetupWizard.tsx +443 -416
  8. package/apps/shared/components/layout/AppShell.tsx +288 -288
  9. package/apps/shared/components/mail/ConversationList.tsx +225 -33
  10. package/apps/shared/components/mail/ConversationThreadPane.tsx +434 -216
  11. package/apps/shared/components/mail/MailListToolbar.tsx +265 -0
  12. package/apps/shared/components/mail/MailSelectionBar.tsx +222 -0
  13. package/apps/shared/components/mail/MenuButton.tsx +384 -0
  14. package/apps/shared/components/mail/MessageDetailPane.tsx +280 -215
  15. package/apps/shared/components/mail/MoveToFolderDialog.tsx +263 -0
  16. package/apps/shared/components/mail/compose/ComposeContext.tsx +29 -2
  17. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1700 -1652
  18. package/apps/shared/components/mail/compose/RecipientInput.tsx +356 -0
  19. package/apps/shared/components/mail/compose/RichTextEditor.tsx +38 -3
  20. package/apps/shared/components/mail/compose/quotedBody.ts +100 -0
  21. package/apps/shared/components/mail/compose/recipients.ts +104 -0
  22. package/apps/shared/components/mail/labelMenu.tsx +278 -0
  23. package/apps/shared/components/mail/layout/MailShell.tsx +445 -420
  24. package/apps/shared/components/mail/listPreferences.ts +228 -0
  25. package/apps/shared/components/settings/layout/SettingsShell.tsx +0 -1
  26. package/apps/shared/styles/app.css +28 -5
  27. package/apps/www/index.tsx +1854 -1314
  28. package/apps/www/messages/[uid].tsx +5 -4
  29. package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +7 -3
  30. package/dist/apps/shared/components/admin/settings/BrandingForm.js +6 -5
  31. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.d.ts +4 -1
  32. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +2 -2
  33. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.d.ts +4 -1
  34. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +2 -2
  35. package/dist/apps/shared/components/admin/settings/PluginsManager.d.ts +8 -1
  36. package/dist/apps/shared/components/admin/settings/PluginsManager.js +33 -14
  37. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.d.ts +4 -1
  38. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +2 -2
  39. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +1 -1
  40. package/dist/apps/shared/components/admin/setup/SetupWizard.js +14 -10
  41. package/dist/apps/shared/components/mail/ConversationList.d.ts +42 -6
  42. package/dist/apps/shared/components/mail/ConversationList.js +84 -10
  43. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +52 -13
  44. package/dist/apps/shared/components/mail/ConversationThreadPane.js +217 -60
  45. package/dist/apps/shared/components/mail/MailListToolbar.d.ts +55 -0
  46. package/dist/apps/shared/components/mail/MailListToolbar.js +149 -0
  47. package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +59 -0
  48. package/dist/apps/shared/components/mail/MailSelectionBar.js +48 -0
  49. package/dist/apps/shared/components/mail/MenuButton.d.ts +71 -0
  50. package/dist/apps/shared/components/mail/MenuButton.js +211 -0
  51. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +27 -19
  52. package/dist/apps/shared/components/mail/MessageDetailPane.js +126 -129
  53. package/dist/apps/shared/components/mail/MoveToFolderDialog.d.ts +57 -0
  54. package/dist/apps/shared/components/mail/MoveToFolderDialog.js +138 -0
  55. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +16 -1
  56. package/dist/apps/shared/components/mail/compose/ComposeContext.js +3 -1
  57. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +52 -20
  58. package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +36 -0
  59. package/dist/apps/shared/components/mail/compose/RecipientInput.js +203 -0
  60. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +10 -1
  61. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +21 -3
  62. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +37 -0
  63. package/dist/apps/shared/components/mail/compose/quotedBody.js +85 -0
  64. package/dist/apps/shared/components/mail/compose/recipients.d.ts +24 -0
  65. package/dist/apps/shared/components/mail/compose/recipients.js +87 -0
  66. package/dist/apps/shared/components/mail/labelMenu.d.ts +97 -0
  67. package/dist/apps/shared/components/mail/labelMenu.js +134 -0
  68. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +8 -0
  69. package/dist/apps/shared/components/mail/layout/MailShell.js +15 -3
  70. package/dist/apps/shared/components/mail/listPreferences.d.ts +101 -0
  71. package/dist/apps/shared/components/mail/listPreferences.js +186 -0
  72. package/dist/apps/shared/components/settings/layout/SettingsShell.js +0 -1
  73. package/dist/apps/shared/styles/app.css +28 -5
  74. package/dist/apps/www/index.js +506 -112
  75. package/dist/apps/www/messages/[uid].js +2 -3
  76. package/package.json +2 -2
  77. package/apps/www/settings/focused-inbox/index.tsx +0 -150
  78. package/dist/apps/www/settings/focused-inbox/index.d.ts +0 -4
  79. package/dist/apps/www/settings/focused-inbox/index.js +0 -71
@@ -1,385 +1,398 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import React, { ChangeEvent, FormEvent, useRef, useState } from "react";
6
- import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
- import {
8
- Branding,
9
- deleteBrandingIcon,
10
- deleteBrandingLogo,
11
- deleteBrandingStylesheet,
12
- updateBranding,
13
- uploadBrandingIcon,
14
- uploadBrandingLogo,
15
- uploadBrandingStylesheet,
16
- } from "@rapidmx/react-shared/branding/brandingApi.js";
17
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
18
- import Button from "@rapidmx/react-shared/components/buttons/Button.js";
19
-
20
- const INPUT_CLASS =
21
- "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
22
-
23
- const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
24
-
25
- /** SVG logo/icon uploads are refused (the server refuses them too): an SVG served from this origin can carry script. */
26
- export function isSvgFile(file: File): boolean {
27
- return file.type === "image/svg+xml" || file.name.toLowerCase().endsWith(".svg");
28
- }
29
-
30
- const RASTER_IMAGE_ACCEPT = "image/png,image/jpeg,image/gif,image/webp,image/x-icon,image/vnd.microsoft.icon";
31
-
32
- /** The branding editor, shared by the Branding page and the setup wizard. */
33
- export default function BrandingForm({ branding, onChange }: { branding: Branding; onChange: (b: Branding) => void }) {
34
- const [companyName, setCompanyName] = useState(branding.companyName);
35
- const [title, setTitle] = useState(branding.title);
36
- const [headerHtml, setHeaderHtml] = useState(branding.headerHtml ?? "");
37
- const [footerHtml, setFooterHtml] = useState(branding.footerHtml ?? "");
38
- const [logoUrlInput, setLogoUrlInput] = useState(branding.logoUrl ?? "");
39
- const [iconUrlInput, setIconUrlInput] = useState(branding.iconUrl ?? "");
40
- const [stylesheetUrlInput, setStylesheetUrlInput] = useState(branding.stylesheetUrl ?? "");
41
-
42
- const [error, setError] = useState<string | null>(null);
43
- const [saving, setSaving] = useState(false);
44
- const [saved, setSaved] = useState(false);
45
- const [assetBusy, setAssetBusy] = useState<string | null>(null);
46
-
47
- const logoFileRef = useRef<HTMLInputElement>(null);
48
- const iconFileRef = useRef<HTMLInputElement>(null);
49
- const stylesheetFileRef = useRef<HTMLInputElement>(null);
50
-
51
- function runAsset(name: string, action: () => Promise<Branding>) {
52
- setError(null);
53
- setAssetBusy(name);
54
- return action()
55
- .then((updated) => {
56
- onChange(updated);
57
- setLogoUrlInput(updated.logoUrl ?? "");
58
- setIconUrlInput(updated.iconUrl ?? "");
59
- setStylesheetUrlInput(updated.stylesheetUrl ?? "");
60
- })
61
- .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not update branding."))
62
- .finally(() => setAssetBusy(null));
63
- }
64
-
65
- function handleLogoFileChange(e: ChangeEvent<HTMLInputElement>) {
66
- const file = e.target.files?.[0];
67
- if (!file) return;
68
- if (isSvgFile(file)) {
69
- setError(`"${file.name}" is an SVG — upload a PNG, JPEG, GIF, or WebP logo instead.`);
70
- } else if (file.size > MAX_UPLOAD_BYTES) {
71
- setError(`"${file.name}" is too large — logos must be 5MB or smaller.`);
72
- } else {
73
- void runAsset("logo-upload", () => uploadBrandingLogo(file));
74
- }
75
- e.target.value = "";
76
- }
77
-
78
- function handleIconFileChange(e: ChangeEvent<HTMLInputElement>) {
79
- const file = e.target.files?.[0];
80
- if (!file) return;
81
- if (isSvgFile(file)) {
82
- setError(`"${file.name}" is an SVG — upload a PNG, JPEG, GIF, WebP, or ICO icon instead.`);
83
- } else if (file.size > MAX_UPLOAD_BYTES) {
84
- setError(`"${file.name}" is too large — icons must be 5MB or smaller.`);
85
- } else {
86
- void runAsset("icon-upload", () => uploadBrandingIcon(file));
87
- }
88
- e.target.value = "";
89
- }
90
-
91
- function handleStylesheetFileChange(e: ChangeEvent<HTMLInputElement>) {
92
- const file = e.target.files?.[0];
93
- if (!file) return;
94
- if (file.size > MAX_UPLOAD_BYTES) {
95
- setError(`"${file.name}" is too large — stylesheets must be 5MB or smaller.`);
96
- } else {
97
- void runAsset("stylesheet-upload", () => uploadBrandingStylesheet(file));
98
- }
99
- e.target.value = "";
100
- }
101
-
102
- async function handleSubmit(e: FormEvent) {
103
- e.preventDefault();
104
- setError(null);
105
- setSaved(false);
106
- setSaving(true);
107
- try {
108
- const updated = await updateBranding({ companyName, title, headerHtml, footerHtml });
109
- onChange(updated);
110
- setSaved(true);
111
- } catch (err) {
112
- setError(err instanceof ApiRequestError ? err.message : "Could not save branding.");
113
- } finally {
114
- setSaving(false);
115
- }
116
- }
117
-
118
- return (
119
- <div className="max-w-2xl">
120
- <h1 className="text-xl font-bold uppercase tracking-wide mb-1">Branding</h1>
121
- <p className="text-sm text-text-muted mb-5">
122
- Customize the logo, nav-header icon, product name, and chrome shown to every visitor of the
123
- webmail and admin console — including anonymous booking-page visitors.
124
- </p>
125
-
126
- {error && <Alert>{error}</Alert>}
127
- {saved && !error && <div className="mb-4 text-sm text-success font-medium">Saved.</div>}
128
-
129
- <div className="flex flex-col gap-6">
130
- <section className="flex flex-col gap-3">
131
- <h2 className="text-sm font-bold uppercase tracking-wide text-text-muted">Logo</h2>
132
- <div className="flex items-center gap-4">
133
- <img
134
- src={branding.logoUrl || "/images/logo.svg"}
135
- alt="Current logo"
136
- width="64"
137
- height="64"
138
- className="border border-border rounded-sm bg-surface-alt p-1"
139
- />
140
- <div className="flex flex-col gap-2">
141
- <Button
142
- type="button"
143
- variant="secondary"
144
- className="!w-auto"
145
- loading={assetBusy === "logo-upload"}
146
- disabled={assetBusy !== null}
147
- onClick={() => logoFileRef.current?.click()}
148
- >
149
- Upload logo
150
- </Button>
151
- <input
152
- ref={logoFileRef}
153
- type="file"
154
- accept={RASTER_IMAGE_ACCEPT}
155
- aria-label="Upload logo"
156
- className="hidden"
157
- onChange={handleLogoFileChange}
158
- />
159
- {branding.logoUrl && (
160
- <Button
161
- type="button"
162
- variant="text"
163
- disabled={assetBusy !== null}
164
- loading={assetBusy === "logo-delete"}
165
- onClick={() => void runAsset("logo-delete", async () => {
166
- await deleteBrandingLogo();
167
- return { ...branding, logoUrl: undefined };
168
- })}
169
- >
170
- Remove logo
171
- </Button>
172
- )}
173
- </div>
174
- </div>
175
- <label className="flex flex-col gap-1.5 text-sm">
176
- <span className="font-semibold">Or use an external image URL</span>
177
- <div className="flex gap-2">
178
- <input
179
- aria-label="Logo URL"
180
- className={INPUT_CLASS}
181
- value={logoUrlInput}
182
- onChange={(e) => setLogoUrlInput(e.target.value)}
183
- />
184
- <Button
185
- type="button"
186
- variant="secondary"
187
- className="!w-auto"
188
- disabled={assetBusy !== null || logoUrlInput === (branding.logoUrl ?? "")}
189
- loading={assetBusy === "logo-url"}
190
- onClick={() => void runAsset("logo-url", () => updateBranding({ logoUrl: logoUrlInput }))}
191
- >
192
- Set
193
- </Button>
194
- </div>
195
- </label>
196
- </section>
197
-
198
- <section className="flex flex-col gap-3">
199
- <h2 className="text-sm font-bold uppercase tracking-wide text-text-muted">Icon</h2>
200
- <p className="text-xs text-text-muted -mt-1">
201
- A compact mark for navigation headers, independent of the full logo above. Falls back to
202
- the logo, then a default asset, when not set.
203
- </p>
204
- <div className="flex items-center gap-4">
205
- <img
206
- src={branding.iconUrl || branding.logoUrl || "/images/logo.svg"}
207
- alt="Current icon"
208
- width="64"
209
- height="64"
210
- className="border border-border rounded-sm bg-surface-alt p-1"
211
- />
212
- <div className="flex flex-col gap-2">
213
- <Button
214
- type="button"
215
- variant="secondary"
216
- className="!w-auto"
217
- loading={assetBusy === "icon-upload"}
218
- disabled={assetBusy !== null}
219
- onClick={() => iconFileRef.current?.click()}
220
- >
221
- Upload icon
222
- </Button>
223
- <input
224
- ref={iconFileRef}
225
- type="file"
226
- accept={RASTER_IMAGE_ACCEPT}
227
- aria-label="Upload icon"
228
- className="hidden"
229
- onChange={handleIconFileChange}
230
- />
231
- {branding.iconUrl && (
232
- <Button
233
- type="button"
234
- variant="text"
235
- disabled={assetBusy !== null}
236
- loading={assetBusy === "icon-delete"}
237
- onClick={() => void runAsset("icon-delete", async () => {
238
- await deleteBrandingIcon();
239
- return { ...branding, iconUrl: undefined };
240
- })}
241
- >
242
- Remove icon
243
- </Button>
244
- )}
245
- </div>
246
- </div>
247
- <label className="flex flex-col gap-1.5 text-sm">
248
- <span className="font-semibold">Or use an external image URL</span>
249
- <div className="flex gap-2">
250
- <input
251
- aria-label="Icon URL"
252
- className={INPUT_CLASS}
253
- value={iconUrlInput}
254
- onChange={(e) => setIconUrlInput(e.target.value)}
255
- />
256
- <Button
257
- type="button"
258
- variant="secondary"
259
- className="!w-auto"
260
- disabled={assetBusy !== null || iconUrlInput === (branding.iconUrl ?? "")}
261
- loading={assetBusy === "icon-url"}
262
- onClick={() => void runAsset("icon-url", () => updateBranding({ iconUrl: iconUrlInput }))}
263
- >
264
- Set
265
- </Button>
266
- </div>
267
- </label>
268
- </section>
269
-
270
- <section className="flex flex-col gap-3">
271
- <h2 className="text-sm font-bold uppercase tracking-wide text-text-muted">Custom stylesheet</h2>
272
- <div className="flex items-center gap-3">
273
- <span className="text-sm text-text-muted truncate">
274
- {branding.stylesheetUrl || "None configured"}
275
- </span>
276
- </div>
277
- <div className="flex gap-2">
278
- <Button
279
- type="button"
280
- variant="secondary"
281
- className="!w-auto"
282
- loading={assetBusy === "stylesheet-upload"}
283
- disabled={assetBusy !== null}
284
- onClick={() => stylesheetFileRef.current?.click()}
285
- >
286
- Upload CSS file
287
- </Button>
288
- <input
289
- ref={stylesheetFileRef}
290
- type="file"
291
- accept="text/css,.css"
292
- aria-label="Upload stylesheet"
293
- className="hidden"
294
- onChange={handleStylesheetFileChange}
295
- />
296
- {branding.stylesheetUrl && (
297
- <Button
298
- type="button"
299
- variant="text"
300
- disabled={assetBusy !== null}
301
- loading={assetBusy === "stylesheet-delete"}
302
- onClick={() => void runAsset("stylesheet-delete", async () => {
303
- await deleteBrandingStylesheet();
304
- return { ...branding, stylesheetUrl: undefined };
305
- })}
306
- >
307
- Remove stylesheet
308
- </Button>
309
- )}
310
- </div>
311
- <label className="flex flex-col gap-1.5 text-sm">
312
- <span className="font-semibold">Or use an external stylesheet URL</span>
313
- <div className="flex gap-2">
314
- <input
315
- aria-label="Stylesheet URL"
316
- className={INPUT_CLASS}
317
- value={stylesheetUrlInput}
318
- onChange={(e) => setStylesheetUrlInput(e.target.value)}
319
- />
320
- <Button
321
- type="button"
322
- variant="secondary"
323
- className="!w-auto"
324
- disabled={assetBusy !== null || stylesheetUrlInput === (branding.stylesheetUrl ?? "")}
325
- loading={assetBusy === "stylesheet-url"}
326
- onClick={() =>
327
- void runAsset("stylesheet-url", () => updateBranding({ stylesheetUrl: stylesheetUrlInput }))
328
- }
329
- >
330
- Set
331
- </Button>
332
- </div>
333
- </label>
334
- </section>
335
-
336
- <form onSubmit={handleSubmit} className="flex flex-col gap-4 pt-2 border-t border-border">
337
- <label className="flex flex-col gap-1.5 text-sm">
338
- <span className="font-semibold">Company name</span>
339
- <input
340
- aria-label="Company name"
341
- className={INPUT_CLASS}
342
- value={companyName}
343
- onChange={(e) => setCompanyName(e.target.value)}
344
- />
345
- </label>
346
- <label className="flex flex-col gap-1.5 text-sm">
347
- <span className="font-semibold">Product title</span>
348
- <input
349
- aria-label="Product title"
350
- className={INPUT_CLASS}
351
- value={title}
352
- onChange={(e) => setTitle(e.target.value)}
353
- />
354
- <span className="text-xs text-text-muted">Shown as the browser tab title.</span>
355
- </label>
356
- <label className="flex flex-col gap-1.5 text-sm">
357
- <span className="font-semibold">Header HTML (optional)</span>
358
- <textarea
359
- aria-label="Header HTML"
360
- className={`${INPUT_CLASS} font-mono`}
361
- rows={3}
362
- value={headerHtml}
363
- onChange={(e) => setHeaderHtml(e.target.value)}
364
- />
365
- </label>
366
- <label className="flex flex-col gap-1.5 text-sm">
367
- <span className="font-semibold">Footer HTML (optional)</span>
368
- <textarea
369
- aria-label="Footer HTML"
370
- className={`${INPUT_CLASS} font-mono`}
371
- rows={3}
372
- value={footerHtml}
373
- onChange={(e) => setFooterHtml(e.target.value)}
374
- />
375
- </label>
376
- <div>
377
- <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
378
- Save
379
- </Button>
380
- </div>
381
- </form>
382
- </div>
383
- </div>
384
- );
385
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { ChangeEvent, FormEvent, useRef, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import {
8
+ Branding,
9
+ deleteBrandingIcon,
10
+ deleteBrandingLogo,
11
+ deleteBrandingStylesheet,
12
+ updateBranding,
13
+ uploadBrandingIcon,
14
+ uploadBrandingLogo,
15
+ uploadBrandingStylesheet,
16
+ } from "@rapidmx/react-shared/branding/brandingApi.js";
17
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
18
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
19
+
20
+ const INPUT_CLASS =
21
+ "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
22
+
23
+ const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
24
+
25
+ /** SVG logo/icon uploads are refused (the server refuses them too): an SVG served from this origin can carry script. */
26
+ export function isSvgFile(file: File): boolean {
27
+ return file.type === "image/svg+xml" || file.name.toLowerCase().endsWith(".svg");
28
+ }
29
+
30
+ const RASTER_IMAGE_ACCEPT = "image/png,image/jpeg,image/gif,image/webp,image/x-icon,image/vnd.microsoft.icon";
31
+
32
+ export interface BrandingFormProps {
33
+ branding: Branding;
34
+ onChange: (b: Branding) => void;
35
+ /** Set when shown in a page that already introduces branding (the setup wizard): the title and introduction are
36
+ * left out, and the section headings sit one level lower. */
37
+ embedded?: boolean;
38
+ }
39
+
40
+ /** The branding editor, shared by the Branding page and the setup wizard. */
41
+ export default function BrandingForm({ branding, onChange, embedded = false }: BrandingFormProps) {
42
+ const SectionHeading = embedded ? "h3" : "h2";
43
+ const [companyName, setCompanyName] = useState(branding.companyName);
44
+ const [title, setTitle] = useState(branding.title);
45
+ const [headerHtml, setHeaderHtml] = useState(branding.headerHtml ?? "");
46
+ const [footerHtml, setFooterHtml] = useState(branding.footerHtml ?? "");
47
+ const [logoUrlInput, setLogoUrlInput] = useState(branding.logoUrl ?? "");
48
+ const [iconUrlInput, setIconUrlInput] = useState(branding.iconUrl ?? "");
49
+ const [stylesheetUrlInput, setStylesheetUrlInput] = useState(branding.stylesheetUrl ?? "");
50
+
51
+ const [error, setError] = useState<string | null>(null);
52
+ const [saving, setSaving] = useState(false);
53
+ const [saved, setSaved] = useState(false);
54
+ const [assetBusy, setAssetBusy] = useState<string | null>(null);
55
+
56
+ const logoFileRef = useRef<HTMLInputElement>(null);
57
+ const iconFileRef = useRef<HTMLInputElement>(null);
58
+ const stylesheetFileRef = useRef<HTMLInputElement>(null);
59
+
60
+ function runAsset(name: string, action: () => Promise<Branding>) {
61
+ setError(null);
62
+ setAssetBusy(name);
63
+ return action()
64
+ .then((updated) => {
65
+ onChange(updated);
66
+ setLogoUrlInput(updated.logoUrl ?? "");
67
+ setIconUrlInput(updated.iconUrl ?? "");
68
+ setStylesheetUrlInput(updated.stylesheetUrl ?? "");
69
+ })
70
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not update branding."))
71
+ .finally(() => setAssetBusy(null));
72
+ }
73
+
74
+ function handleLogoFileChange(e: ChangeEvent<HTMLInputElement>) {
75
+ const file = e.target.files?.[0];
76
+ if (!file) return;
77
+ if (isSvgFile(file)) {
78
+ setError(`"${file.name}" is an SVG — upload a PNG, JPEG, GIF, or WebP logo instead.`);
79
+ } else if (file.size > MAX_UPLOAD_BYTES) {
80
+ setError(`"${file.name}" is too large — logos must be 5MB or smaller.`);
81
+ } else {
82
+ void runAsset("logo-upload", () => uploadBrandingLogo(file));
83
+ }
84
+ e.target.value = "";
85
+ }
86
+
87
+ function handleIconFileChange(e: ChangeEvent<HTMLInputElement>) {
88
+ const file = e.target.files?.[0];
89
+ if (!file) return;
90
+ if (isSvgFile(file)) {
91
+ setError(`"${file.name}" is an SVG — upload a PNG, JPEG, GIF, WebP, or ICO icon instead.`);
92
+ } else if (file.size > MAX_UPLOAD_BYTES) {
93
+ setError(`"${file.name}" is too large — icons must be 5MB or smaller.`);
94
+ } else {
95
+ void runAsset("icon-upload", () => uploadBrandingIcon(file));
96
+ }
97
+ e.target.value = "";
98
+ }
99
+
100
+ function handleStylesheetFileChange(e: ChangeEvent<HTMLInputElement>) {
101
+ const file = e.target.files?.[0];
102
+ if (!file) return;
103
+ if (file.size > MAX_UPLOAD_BYTES) {
104
+ setError(`"${file.name}" is too large — stylesheets must be 5MB or smaller.`);
105
+ } else {
106
+ void runAsset("stylesheet-upload", () => uploadBrandingStylesheet(file));
107
+ }
108
+ e.target.value = "";
109
+ }
110
+
111
+ async function handleSubmit(e: FormEvent) {
112
+ e.preventDefault();
113
+ setError(null);
114
+ setSaved(false);
115
+ setSaving(true);
116
+ try {
117
+ const updated = await updateBranding({ companyName, title, headerHtml, footerHtml });
118
+ onChange(updated);
119
+ setSaved(true);
120
+ } catch (err) {
121
+ setError(err instanceof ApiRequestError ? err.message : "Could not save branding.");
122
+ } finally {
123
+ setSaving(false);
124
+ }
125
+ }
126
+
127
+ return (
128
+ <div className="max-w-2xl">
129
+ {!embedded && (
130
+ <>
131
+ <h1 className="text-xl font-bold uppercase tracking-wide mb-1">Branding</h1>
132
+ <p className="text-sm text-text-muted mb-5">
133
+ Customize the logo, nav-header icon, product name, and chrome shown to every visitor of the
134
+ webmail and admin console — including anonymous booking-page visitors.
135
+ </p>
136
+ </>
137
+ )}
138
+
139
+ {error && <Alert>{error}</Alert>}
140
+ {saved && !error && <div className="mb-4 text-sm text-success font-medium">Saved.</div>}
141
+
142
+ <div className="flex flex-col gap-6">
143
+ <section className="flex flex-col gap-3">
144
+ <SectionHeading className="text-sm font-bold uppercase tracking-wide text-text-muted">Logo</SectionHeading>
145
+ <div className="flex items-center gap-4">
146
+ <img
147
+ src={branding.logoUrl || "/images/logo.svg"}
148
+ alt="Current logo"
149
+ width="64"
150
+ height="64"
151
+ className="border border-border rounded-sm bg-surface-alt p-1"
152
+ />
153
+ <div className="flex flex-col gap-2">
154
+ <Button
155
+ type="button"
156
+ variant="secondary"
157
+ className="!w-auto"
158
+ loading={assetBusy === "logo-upload"}
159
+ disabled={assetBusy !== null}
160
+ onClick={() => logoFileRef.current?.click()}
161
+ >
162
+ Upload logo
163
+ </Button>
164
+ <input
165
+ ref={logoFileRef}
166
+ type="file"
167
+ accept={RASTER_IMAGE_ACCEPT}
168
+ aria-label="Upload logo"
169
+ className="hidden"
170
+ onChange={handleLogoFileChange}
171
+ />
172
+ {branding.logoUrl && (
173
+ <Button
174
+ type="button"
175
+ variant="text"
176
+ disabled={assetBusy !== null}
177
+ loading={assetBusy === "logo-delete"}
178
+ onClick={() => void runAsset("logo-delete", async () => {
179
+ await deleteBrandingLogo();
180
+ return { ...branding, logoUrl: undefined };
181
+ })}
182
+ >
183
+ Remove logo
184
+ </Button>
185
+ )}
186
+ </div>
187
+ </div>
188
+ <label className="flex flex-col gap-1.5 text-sm">
189
+ <span className="font-semibold">Or use an external image URL</span>
190
+ <div className="flex gap-2">
191
+ <input
192
+ aria-label="Logo URL"
193
+ className={INPUT_CLASS}
194
+ value={logoUrlInput}
195
+ onChange={(e) => setLogoUrlInput(e.target.value)}
196
+ />
197
+ <Button
198
+ type="button"
199
+ variant="secondary"
200
+ className="!w-auto"
201
+ disabled={assetBusy !== null || logoUrlInput === (branding.logoUrl ?? "")}
202
+ loading={assetBusy === "logo-url"}
203
+ onClick={() => void runAsset("logo-url", () => updateBranding({ logoUrl: logoUrlInput }))}
204
+ >
205
+ Set
206
+ </Button>
207
+ </div>
208
+ </label>
209
+ </section>
210
+
211
+ <section className="flex flex-col gap-3">
212
+ <SectionHeading className="text-sm font-bold uppercase tracking-wide text-text-muted">Icon</SectionHeading>
213
+ <p className="text-xs text-text-muted -mt-1">
214
+ A compact mark for navigation headers, independent of the full logo above. Falls back to
215
+ the logo, then a default asset, when not set.
216
+ </p>
217
+ <div className="flex items-center gap-4">
218
+ <img
219
+ src={branding.iconUrl || branding.logoUrl || "/images/logo.svg"}
220
+ alt="Current icon"
221
+ width="64"
222
+ height="64"
223
+ className="border border-border rounded-sm bg-surface-alt p-1"
224
+ />
225
+ <div className="flex flex-col gap-2">
226
+ <Button
227
+ type="button"
228
+ variant="secondary"
229
+ className="!w-auto"
230
+ loading={assetBusy === "icon-upload"}
231
+ disabled={assetBusy !== null}
232
+ onClick={() => iconFileRef.current?.click()}
233
+ >
234
+ Upload icon
235
+ </Button>
236
+ <input
237
+ ref={iconFileRef}
238
+ type="file"
239
+ accept={RASTER_IMAGE_ACCEPT}
240
+ aria-label="Upload icon"
241
+ className="hidden"
242
+ onChange={handleIconFileChange}
243
+ />
244
+ {branding.iconUrl && (
245
+ <Button
246
+ type="button"
247
+ variant="text"
248
+ disabled={assetBusy !== null}
249
+ loading={assetBusy === "icon-delete"}
250
+ onClick={() => void runAsset("icon-delete", async () => {
251
+ await deleteBrandingIcon();
252
+ return { ...branding, iconUrl: undefined };
253
+ })}
254
+ >
255
+ Remove icon
256
+ </Button>
257
+ )}
258
+ </div>
259
+ </div>
260
+ <label className="flex flex-col gap-1.5 text-sm">
261
+ <span className="font-semibold">Or use an external image URL</span>
262
+ <div className="flex gap-2">
263
+ <input
264
+ aria-label="Icon URL"
265
+ className={INPUT_CLASS}
266
+ value={iconUrlInput}
267
+ onChange={(e) => setIconUrlInput(e.target.value)}
268
+ />
269
+ <Button
270
+ type="button"
271
+ variant="secondary"
272
+ className="!w-auto"
273
+ disabled={assetBusy !== null || iconUrlInput === (branding.iconUrl ?? "")}
274
+ loading={assetBusy === "icon-url"}
275
+ onClick={() => void runAsset("icon-url", () => updateBranding({ iconUrl: iconUrlInput }))}
276
+ >
277
+ Set
278
+ </Button>
279
+ </div>
280
+ </label>
281
+ </section>
282
+
283
+ <section className="flex flex-col gap-3">
284
+ <SectionHeading className="text-sm font-bold uppercase tracking-wide text-text-muted">Custom stylesheet</SectionHeading>
285
+ <div className="flex items-center gap-3">
286
+ <span className="text-sm text-text-muted truncate">
287
+ {branding.stylesheetUrl || "None configured"}
288
+ </span>
289
+ </div>
290
+ <div className="flex gap-2">
291
+ <Button
292
+ type="button"
293
+ variant="secondary"
294
+ className="!w-auto"
295
+ loading={assetBusy === "stylesheet-upload"}
296
+ disabled={assetBusy !== null}
297
+ onClick={() => stylesheetFileRef.current?.click()}
298
+ >
299
+ Upload CSS file
300
+ </Button>
301
+ <input
302
+ ref={stylesheetFileRef}
303
+ type="file"
304
+ accept="text/css,.css"
305
+ aria-label="Upload stylesheet"
306
+ className="hidden"
307
+ onChange={handleStylesheetFileChange}
308
+ />
309
+ {branding.stylesheetUrl && (
310
+ <Button
311
+ type="button"
312
+ variant="text"
313
+ disabled={assetBusy !== null}
314
+ loading={assetBusy === "stylesheet-delete"}
315
+ onClick={() => void runAsset("stylesheet-delete", async () => {
316
+ await deleteBrandingStylesheet();
317
+ return { ...branding, stylesheetUrl: undefined };
318
+ })}
319
+ >
320
+ Remove stylesheet
321
+ </Button>
322
+ )}
323
+ </div>
324
+ <label className="flex flex-col gap-1.5 text-sm">
325
+ <span className="font-semibold">Or use an external stylesheet URL</span>
326
+ <div className="flex gap-2">
327
+ <input
328
+ aria-label="Stylesheet URL"
329
+ className={INPUT_CLASS}
330
+ value={stylesheetUrlInput}
331
+ onChange={(e) => setStylesheetUrlInput(e.target.value)}
332
+ />
333
+ <Button
334
+ type="button"
335
+ variant="secondary"
336
+ className="!w-auto"
337
+ disabled={assetBusy !== null || stylesheetUrlInput === (branding.stylesheetUrl ?? "")}
338
+ loading={assetBusy === "stylesheet-url"}
339
+ onClick={() =>
340
+ void runAsset("stylesheet-url", () => updateBranding({ stylesheetUrl: stylesheetUrlInput }))
341
+ }
342
+ >
343
+ Set
344
+ </Button>
345
+ </div>
346
+ </label>
347
+ </section>
348
+
349
+ <form onSubmit={handleSubmit} className="flex flex-col gap-4 pt-2 border-t border-border">
350
+ <label className="flex flex-col gap-1.5 text-sm">
351
+ <span className="font-semibold">Company name</span>
352
+ <input
353
+ aria-label="Company name"
354
+ className={INPUT_CLASS}
355
+ value={companyName}
356
+ onChange={(e) => setCompanyName(e.target.value)}
357
+ />
358
+ </label>
359
+ <label className="flex flex-col gap-1.5 text-sm">
360
+ <span className="font-semibold">Product title</span>
361
+ <input
362
+ aria-label="Product title"
363
+ className={INPUT_CLASS}
364
+ value={title}
365
+ onChange={(e) => setTitle(e.target.value)}
366
+ />
367
+ <span className="text-xs text-text-muted">Shown as the browser tab title.</span>
368
+ </label>
369
+ <label className="flex flex-col gap-1.5 text-sm">
370
+ <span className="font-semibold">Header HTML (optional)</span>
371
+ <textarea
372
+ aria-label="Header HTML"
373
+ className={`${INPUT_CLASS} font-mono`}
374
+ rows={3}
375
+ value={headerHtml}
376
+ onChange={(e) => setHeaderHtml(e.target.value)}
377
+ />
378
+ </label>
379
+ <label className="flex flex-col gap-1.5 text-sm">
380
+ <span className="font-semibold">Footer HTML (optional)</span>
381
+ <textarea
382
+ aria-label="Footer HTML"
383
+ className={`${INPUT_CLASS} font-mono`}
384
+ rows={3}
385
+ value={footerHtml}
386
+ onChange={(e) => setFooterHtml(e.target.value)}
387
+ />
388
+ </label>
389
+ <div>
390
+ <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
391
+ Save
392
+ </Button>
393
+ </div>
394
+ </form>
395
+ </div>
396
+ </div>
397
+ );
398
+ }