@rapidmx/web-client 0.3.1 → 0.5.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 (202) hide show
  1. package/apps/admin/audit-log/index.tsx +35 -8
  2. package/apps/admin/branding/index.tsx +39 -404
  3. package/apps/admin/data-requests/index.tsx +480 -485
  4. package/apps/admin/distribution-lists/[uid].tsx +19 -1
  5. package/apps/admin/domains/[uid].tsx +92 -259
  6. package/apps/admin/encryption-policy/index.tsx +19 -0
  7. package/apps/admin/escrow-scopes/[uid].tsx +348 -237
  8. package/apps/admin/escrow-scopes/new/index.tsx +8 -2
  9. package/apps/admin/index.tsx +118 -82
  10. package/apps/admin/ingest-queue/index.tsx +44 -5
  11. package/apps/admin/mailbox-policy/index.tsx +19 -0
  12. package/apps/admin/mailboxes/[uid].tsx +211 -184
  13. package/apps/admin/mailboxes/new/index.tsx +28 -291
  14. package/apps/admin/plugins/index.tsx +15 -0
  15. package/apps/admin/quarantine/index.tsx +46 -14
  16. package/apps/admin/retention-policy/index.tsx +39 -132
  17. package/apps/admin/setup/index.tsx +15 -0
  18. package/apps/admin/transport-rules/[uid].tsx +30 -1
  19. package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
  20. package/apps/admin/transport-rules/index.tsx +24 -6
  21. package/apps/admin/transport-rules/new/index.tsx +30 -1
  22. package/apps/escrow/_layout.tsx +2 -3
  23. package/apps/escrow/audit-log/index.tsx +196 -168
  24. package/apps/escrow/matters/[uid].tsx +621 -521
  25. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
  26. package/apps/shared/components/admin/layout/AdminShell.tsx +251 -210
  27. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
  28. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
  29. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
  30. package/apps/shared/components/admin/settings/BrandingForm.tsx +385 -0
  31. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +187 -0
  32. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +112 -0
  33. package/apps/shared/components/admin/settings/LoadedSettingsForm.tsx +34 -0
  34. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +308 -0
  35. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +152 -0
  36. package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -0
  37. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -0
  38. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +290 -0
  39. package/apps/shared/components/admin/setup/SetupWizard.tsx +416 -0
  40. package/apps/shared/components/admin/signOut.ts +79 -0
  41. package/apps/shared/components/admin/usePagedList.tsx +129 -0
  42. package/apps/shared/components/calendar/CalendarListSidebar.tsx +120 -76
  43. package/apps/shared/components/calendar/EventModal.tsx +717 -513
  44. package/apps/shared/components/calendar/MonthView.tsx +173 -139
  45. package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
  46. package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
  47. package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
  48. package/apps/shared/components/calendar/allDay.ts +124 -0
  49. package/apps/shared/components/calendar/layout/CalendarShell.tsx +80 -99
  50. package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
  51. package/apps/shared/components/contacts/ContactForm.tsx +377 -314
  52. package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
  53. package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
  54. package/apps/shared/components/contacts/contactKeys.ts +78 -0
  55. package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
  56. package/apps/shared/components/layout/AppShell.tsx +270 -169
  57. package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
  58. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +150 -26
  59. package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
  60. package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
  61. package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -0
  62. package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
  63. package/apps/shared/components/mail/MessageDetailPane.tsx +799 -76
  64. package/apps/shared/components/mail/compose/ComposeContext.tsx +26 -16
  65. package/apps/shared/components/mail/compose/ComposeWindow.tsx +1652 -765
  66. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
  67. package/apps/shared/components/mail/layout/MailShell.tsx +418 -302
  68. package/apps/shared/components/mail/pinnedSigners.ts +124 -0
  69. package/apps/shared/components/mail/verificationSeals.ts +125 -0
  70. package/apps/shared/components/mail/writableMailboxes.ts +101 -0
  71. package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
  72. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  73. package/apps/shared/mail/findWellKnownFolderUid.ts +13 -0
  74. package/apps/shared/mail/listAllPages.ts +39 -0
  75. package/apps/shared/search/LocalIndexLifecycle.tsx +98 -0
  76. package/apps/shared/search/localIndexBlockCipher.ts +83 -0
  77. package/apps/shared/search/localIndexBuilder.ts +481 -0
  78. package/apps/shared/search/localIndexKey.ts +27 -0
  79. package/apps/shared/search/localIndexRpcClient.ts +316 -0
  80. package/apps/shared/search/localIndexSchema.ts +236 -0
  81. package/apps/shared/search/localIndexSizePreference.ts +88 -0
  82. package/apps/shared/search/localIndexStorage.ts +98 -0
  83. package/apps/shared/search/localIndexVFS.ts +266 -0
  84. package/apps/shared/search/localIndexWorker.ts +926 -0
  85. package/apps/shared/search/searchTier2.ts +79 -0
  86. package/apps/shared/search/wa-sqlite-shims.d.ts +44 -0
  87. package/apps/www/calendar/index.tsx +438 -407
  88. package/apps/www/contacts/[uid].tsx +21 -2
  89. package/apps/www/contacts/index.tsx +148 -17
  90. package/apps/www/index.tsx +1314 -486
  91. package/apps/www/messages/[uid].tsx +23 -7
  92. package/apps/www/settings/auto-reply/index.tsx +134 -129
  93. package/apps/www/settings/booking-types/[uid].tsx +306 -300
  94. package/apps/www/settings/encryption/index.tsx +800 -215
  95. package/apps/www/settings/filters/[uid].tsx +171 -155
  96. package/apps/www/settings/filters/new/index.tsx +8 -1
  97. package/apps/www/settings/focused-inbox/index.tsx +150 -148
  98. package/apps/www/settings/privacy/index.tsx +494 -436
  99. package/apps/www/settings/read-receipts/index.tsx +148 -125
  100. package/apps/www/settings/sharing/index.tsx +274 -0
  101. package/apps/www/tasks/index.tsx +638 -530
  102. package/dist/apps/admin/audit-log/index.js +38 -8
  103. package/dist/apps/admin/branding/index.js +4 -98
  104. package/dist/apps/admin/data-requests/index.js +52 -72
  105. package/dist/apps/admin/distribution-lists/[uid].js +1 -1
  106. package/dist/apps/admin/domains/[uid].js +5 -68
  107. package/dist/apps/admin/encryption-policy/index.js +8 -0
  108. package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
  109. package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
  110. package/dist/apps/admin/index.js +17 -1
  111. package/dist/apps/admin/ingest-queue/index.js +24 -6
  112. package/dist/apps/admin/mailbox-policy/index.js +8 -0
  113. package/dist/apps/admin/mailboxes/[uid].js +20 -4
  114. package/dist/apps/admin/mailboxes/new/index.js +4 -89
  115. package/dist/apps/admin/plugins/index.js +6 -0
  116. package/dist/apps/admin/quarantine/index.js +20 -11
  117. package/dist/apps/admin/retention-policy/index.js +3 -36
  118. package/dist/apps/admin/setup/index.js +6 -0
  119. package/dist/apps/admin/transport-rules/[uid].js +18 -2
  120. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
  121. package/dist/apps/admin/transport-rules/index.js +22 -6
  122. package/dist/apps/admin/transport-rules/new/index.js +18 -2
  123. package/dist/apps/escrow/_layout.js +3 -3
  124. package/dist/apps/escrow/audit-log/index.js +26 -1
  125. package/dist/apps/escrow/matters/[uid].js +77 -42
  126. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
  127. package/dist/apps/shared/components/admin/layout/AdminShell.js +37 -4
  128. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
  129. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
  130. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
  131. package/dist/apps/shared/components/admin/settings/BrandingForm.js +116 -0
  132. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +83 -0
  133. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +62 -0
  134. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.js +25 -0
  135. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +112 -0
  136. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +74 -0
  137. package/dist/apps/shared/components/admin/settings/PluginsManager.js +572 -0
  138. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +73 -0
  139. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +147 -0
  140. package/dist/apps/shared/components/admin/setup/SetupWizard.js +208 -0
  141. package/dist/apps/shared/components/admin/signOut.js +75 -0
  142. package/dist/apps/shared/components/admin/usePagedList.js +99 -0
  143. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +24 -13
  144. package/dist/apps/shared/components/calendar/EventModal.js +154 -21
  145. package/dist/apps/shared/components/calendar/MonthView.js +12 -5
  146. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
  147. package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
  148. package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
  149. package/dist/apps/shared/components/calendar/allDay.js +111 -0
  150. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +47 -39
  151. package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
  152. package/dist/apps/shared/components/contacts/ContactForm.js +45 -12
  153. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  154. package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
  155. package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
  156. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
  157. package/dist/apps/shared/components/layout/AppShell.js +100 -8
  158. package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
  159. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +63 -14
  160. package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
  161. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
  162. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +136 -0
  163. package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
  164. package/dist/apps/shared/components/mail/MessageDetailPane.js +464 -34
  165. package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
  166. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +807 -116
  167. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
  168. package/dist/apps/shared/components/mail/layout/MailShell.js +116 -41
  169. package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
  170. package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
  171. package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
  172. package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
  173. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  174. package/dist/apps/shared/mail/findWellKnownFolderUid.js +12 -0
  175. package/dist/apps/shared/mail/listAllPages.js +25 -0
  176. package/dist/apps/shared/search/LocalIndexLifecycle.js +80 -0
  177. package/dist/apps/shared/search/localIndexBlockCipher.js +63 -0
  178. package/dist/apps/shared/search/localIndexBuilder.js +408 -0
  179. package/dist/apps/shared/search/localIndexKey.js +25 -0
  180. package/dist/apps/shared/search/localIndexRpcClient.js +241 -0
  181. package/dist/apps/shared/search/localIndexSchema.js +185 -0
  182. package/dist/apps/shared/search/localIndexSizePreference.js +78 -0
  183. package/dist/apps/shared/search/localIndexStorage.js +86 -0
  184. package/dist/apps/shared/search/localIndexVFS.js +258 -0
  185. package/dist/apps/shared/search/localIndexWorker.js +687 -0
  186. package/dist/apps/shared/search/searchTier2.js +38 -0
  187. package/dist/apps/www/calendar/index.js +38 -18
  188. package/dist/apps/www/contacts/[uid].js +13 -2
  189. package/dist/apps/www/contacts/index.js +94 -15
  190. package/dist/apps/www/index.js +770 -137
  191. package/dist/apps/www/messages/[uid].js +23 -7
  192. package/dist/apps/www/settings/auto-reply/index.js +9 -4
  193. package/dist/apps/www/settings/booking-types/[uid].js +17 -12
  194. package/dist/apps/www/settings/encryption/index.js +541 -96
  195. package/dist/apps/www/settings/filters/[uid].js +22 -7
  196. package/dist/apps/www/settings/filters/new/index.js +8 -2
  197. package/dist/apps/www/settings/focused-inbox/index.js +3 -1
  198. package/dist/apps/www/settings/privacy/index.js +57 -22
  199. package/dist/apps/www/settings/read-receipts/index.js +10 -3
  200. package/dist/apps/www/settings/sharing/index.js +124 -0
  201. package/dist/apps/www/tasks/index.js +76 -14
  202. package/package.json +3 -2
@@ -1,300 +1,306 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import React, { FormEvent, useEffect, useState } from "react";
6
- import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
- import {
8
- BookingAvailabilityWindow,
9
- BookingType,
10
- deleteBookingType,
11
- getBookingType,
12
- updateBookingType,
13
- } from "@rapidmx/react-shared/booking/bookingApi.js";
14
- import SettingsShell, { SettingsShellProps, useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.js";
15
- import AvailabilityEditor from "../../../shared/components/booking/AvailabilityEditor.js";
16
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
17
- import Button from "@rapidmx/react-shared/components/buttons/Button.js";
18
- import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
19
- import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
20
-
21
- const INPUT_CLASS =
22
- "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
23
-
24
- export type BookingTypeDetailPageProps = Omit<SettingsShellProps, "active"> & { params: { uid: string } };
25
-
26
- export default function BookingTypeDetailPage(props: BookingTypeDetailPageProps) {
27
- return (
28
- <SettingsShell {...props} active="booking-types">
29
- <BookingTypeDetailContent uid={props.params.uid} />
30
- </SettingsShell>
31
- );
32
- }
33
-
34
- function BookingTypeDetailContent({ uid }: { uid: string }) {
35
- const { mailboxUid } = useSettingsShell();
36
- const [bookingType, setBookingType] = useState<BookingType | null>(null);
37
- const [name, setName] = useState("");
38
- const [description, setDescription] = useState("");
39
- const [hostDisplayName, setHostDisplayName] = useState("");
40
- const [durationMinutes, setDurationMinutes] = useState(30);
41
- const [timezone, setTimezone] = useState("");
42
- const [availability, setAvailability] = useState<BookingAvailabilityWindow[]>([]);
43
- const [minimumNoticeMinutes, setMinimumNoticeMinutes] = useState(60);
44
- const [bookingWindowDays, setBookingWindowDays] = useState(30);
45
- const [requiresApproval, setRequiresApproval] = useState(false);
46
- const [enabled, setEnabled] = useState(true);
47
- const [loading, setLoading] = useState(true);
48
- const [error, setError] = useState<string | null>(null);
49
- const [saving, setSaving] = useState(false);
50
- const [saved, setSaved] = useState(false);
51
- const [copied, setCopied] = useState(false);
52
- const [confirmingDelete, setConfirmingDelete] = useState(false);
53
- const [deleting, setDeleting] = useState(false);
54
- const [deleteError, setDeleteError] = useState<string | null>(null);
55
-
56
- useEffect(() => {
57
- setLoading(true);
58
- setError(null);
59
- getBookingType(uid)
60
- .then((bt) => {
61
- setBookingType(bt);
62
- setName(bt.name);
63
- setDescription(bt.description ?? "");
64
- setHostDisplayName(bt.hostDisplayName);
65
- setDurationMinutes(bt.durationMinutes);
66
- setTimezone(bt.timezone);
67
- setAvailability(bt.availability);
68
- setMinimumNoticeMinutes(bt.minimumNoticeMinutes);
69
- setBookingWindowDays(bt.bookingWindowDays);
70
- setRequiresApproval(bt.requiresApproval);
71
- setEnabled(bt.enabled);
72
- })
73
- .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load this booking link."))
74
- .finally(() => setLoading(false));
75
- }, [uid]);
76
-
77
- async function handleSubmit(e: FormEvent) {
78
- e.preventDefault();
79
- setError(null);
80
- setSaved(false);
81
- setSaving(true);
82
- try {
83
- const updated = await updateBookingType({
84
- uid: bookingType!.uid,
85
- version: bookingType!.version,
86
- name,
87
- description: description.trim() || undefined,
88
- hostDisplayName,
89
- durationMinutes,
90
- timezone,
91
- availability,
92
- minimumNoticeMinutes,
93
- bookingWindowDays,
94
- requiresApproval,
95
- enabled,
96
- });
97
- setBookingType(updated);
98
- setSaved(true);
99
- } catch (err) {
100
- setError(err instanceof ApiRequestError ? err.message : "Could not save this booking link.");
101
- } finally {
102
- setSaving(false);
103
- }
104
- }
105
-
106
- async function handleCopy(value: string) {
107
- try {
108
- await navigator.clipboard.writeText(value);
109
- setCopied(true);
110
- setTimeout(() => setCopied(false), 2000);
111
- } catch {
112
- // Clipboard access can be denied by the browser — the value is still selectable/copyable by hand.
113
- }
114
- }
115
-
116
- async function handleDelete() {
117
- setDeleting(true);
118
- setDeleteError(null);
119
- try {
120
- await deleteBookingType(bookingType!.uid, bookingType!.version);
121
- window.location.href = `/settings/booking-types?mailboxUid=${encodeURIComponent(mailboxUid!)}`;
122
- } catch (err) {
123
- setDeleteError(err instanceof ApiRequestError ? err.message : "Could not delete this booking link.");
124
- setDeleting(false);
125
- }
126
- }
127
-
128
- if (loading) {
129
- return <p className="p-6 text-sm text-text-muted">Loading&hellip;</p>;
130
- }
131
- if (error || !bookingType) {
132
- // `error` is always set whenever `bookingType` is falsy here: the load effect above destructures
133
- // every field off the resolved value directly (`bt.name`, `bt.hostDisplayName`, ...), so a
134
- // successful-but-empty response throws into `.catch()` — which sets `error` — before this
135
- // component ever renders past the `loading` guard above. `!bookingType` alone (with `error` still
136
- // `null`) is therefore unreachable, unlike e.g. `messages/[uid].tsx`'s identical-looking check,
137
- // which stores its fetched value whole rather than destructuring it.
138
- return (
139
- <div className="p-6">
140
- <Alert>{error!}</Alert>
141
- </div>
142
- );
143
- }
144
-
145
- // `bookingType` only ever becomes non-null via the client-only fetch above, so by the time this line
146
- // runs `window` is always defined — the `!bookingType` early return just above covers every real SSR
147
- // pass, where `bookingType` is still `null`.
148
- const publicUrl = `${window.location.origin}/book/${encodeURIComponent(bookingType.slug)}`;
149
-
150
- return (
151
- <div className="flex-1 min-w-0 overflow-y-auto p-6">
152
- <div className="max-w-2xl">
153
- <div className="flex items-start justify-between gap-4 mb-1">
154
- <div>
155
- <a
156
- href={`/settings/booking-types?mailboxUid=${encodeURIComponent(mailboxUid!)}`}
157
- className="text-sm text-primary-dark hover:underline"
158
- >
159
- &larr; All booking links
160
- </a>
161
- <h1 className="text-xl font-bold tracking-tight mt-1">{bookingType.name}</h1>
162
- </div>
163
- <Button
164
- type="button"
165
- variant="secondary"
166
- className="!w-auto shrink-0 !border-danger !text-danger hover:!border-danger hover:!text-danger"
167
- onClick={() => setConfirmingDelete(true)}
168
- >
169
- Delete
170
- </Button>
171
- </div>
172
-
173
- <div className="flex items-center gap-2 mb-5">
174
- <code className="flex-1 text-xs bg-surface-alt border border-border rounded-sm py-2 px-3 overflow-x-auto whitespace-nowrap">
175
- {publicUrl}
176
- </code>
177
- <Button type="button" variant="secondary" className="!w-auto shrink-0" onClick={() => handleCopy(publicUrl)}>
178
- {copied ? "Copied" : "Copy"}
179
- </Button>
180
- </div>
181
-
182
- {saved && !error && <div className="mb-4 text-sm text-success font-medium">Saved.</div>}
183
-
184
- <form onSubmit={handleSubmit} className="flex flex-col gap-1">
185
- <label className="flex items-center gap-2 text-sm mb-3">
186
- <input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
187
- Enabled (publicly bookable)
188
- </label>
189
- <FormField label="Name" htmlFor="name">
190
- <input id="name" type="text" className={INPUT_CLASS} value={name} onChange={(e) => setName(e.target.value)} />
191
- </FormField>
192
- <FormField label="Description (optional)" htmlFor="description">
193
- <textarea
194
- id="description"
195
- className={INPUT_CLASS}
196
- rows={2}
197
- value={description}
198
- onChange={(e) => setDescription(e.target.value)}
199
- />
200
- </FormField>
201
- <FormField label="Host name shown to visitors" htmlFor="hostDisplayName">
202
- <input
203
- id="hostDisplayName"
204
- type="text"
205
- className={INPUT_CLASS}
206
- value={hostDisplayName}
207
- onChange={(e) => setHostDisplayName(e.target.value)}
208
- />
209
- </FormField>
210
- <div className="grid grid-cols-2 gap-3">
211
- <FormField label="Duration (minutes)" htmlFor="durationMinutes">
212
- <input
213
- id="durationMinutes"
214
- type="number"
215
- min={1}
216
- className={INPUT_CLASS}
217
- value={durationMinutes}
218
- onChange={(e) => setDurationMinutes(Number(e.target.value))}
219
- />
220
- </FormField>
221
- <FormField label="Timezone" htmlFor="timezone">
222
- <input
223
- id="timezone"
224
- type="text"
225
- className={INPUT_CLASS}
226
- value={timezone}
227
- onChange={(e) => setTimezone(e.target.value)}
228
- />
229
- </FormField>
230
- </div>
231
- <div className="grid grid-cols-2 gap-3">
232
- <FormField label="Minimum notice (minutes)" htmlFor="minimumNoticeMinutes">
233
- <input
234
- id="minimumNoticeMinutes"
235
- type="number"
236
- min={0}
237
- className={INPUT_CLASS}
238
- value={minimumNoticeMinutes}
239
- onChange={(e) => setMinimumNoticeMinutes(Number(e.target.value))}
240
- />
241
- </FormField>
242
- <FormField label="Booking window (days ahead)" htmlFor="bookingWindowDays">
243
- <input
244
- id="bookingWindowDays"
245
- type="number"
246
- min={1}
247
- className={INPUT_CLASS}
248
- value={bookingWindowDays}
249
- onChange={(e) => setBookingWindowDays(Number(e.target.value))}
250
- />
251
- </FormField>
252
- </div>
253
- <label className="flex items-center gap-2 text-sm my-3">
254
- <input type="checkbox" checked={requiresApproval} onChange={(e) => setRequiresApproval(e.target.checked)} />
255
- Require my approval before confirming a booking
256
- </label>
257
-
258
- <div className="mb-4">
259
- <span className="block text-sm font-semibold mb-1.5 text-text">Weekly availability</span>
260
- <AvailabilityEditor value={availability} onChange={setAvailability} />
261
- </div>
262
-
263
- <div>
264
- <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
265
- Save
266
- </Button>
267
- </div>
268
- </form>
269
-
270
- <Modal open={confirmingDelete} onClose={() => setConfirmingDelete(false)} title="Delete booking link">
271
- <p className="text-sm mb-5">
272
- Are you sure you want to delete <strong>{bookingType.name}</strong>? Its public link will stop
273
- working immediately. This cannot be undone.
274
- </p>
275
- {deleteError && <Alert>{deleteError}</Alert>}
276
- <div className="flex gap-3 justify-end mt-5">
277
- <Button
278
- type="button"
279
- variant="secondary"
280
- className="!w-auto"
281
- disabled={deleting}
282
- onClick={() => setConfirmingDelete(false)}
283
- >
284
- Cancel
285
- </Button>
286
- <Button
287
- type="button"
288
- className="!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger"
289
- loading={deleting}
290
- disabled={deleting}
291
- onClick={handleDelete}
292
- >
293
- Delete
294
- </Button>
295
- </div>
296
- </Modal>
297
- </div>
298
- </div>
299
- );
300
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { FormEvent, useEffect, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import {
8
+ BookingAvailabilityWindow,
9
+ BookingType,
10
+ deleteBookingType,
11
+ getBookingType,
12
+ updateBookingType,
13
+ } from "@rapidmx/react-shared/booking/bookingApi.js";
14
+ import SettingsShell, { SettingsShellProps, useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.js";
15
+ import AvailabilityEditor from "../../../shared/components/booking/AvailabilityEditor.js";
16
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
17
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
18
+ import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
19
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
20
+
21
+ const INPUT_CLASS =
22
+ "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
23
+
24
+ export type BookingTypeDetailPageProps = Omit<SettingsShellProps, "active"> & { params: { uid: string } };
25
+
26
+ export default function BookingTypeDetailPage(props: BookingTypeDetailPageProps) {
27
+ return (
28
+ <SettingsShell {...props} active="booking-types">
29
+ <BookingTypeDetailContent uid={props.params.uid} />
30
+ </SettingsShell>
31
+ );
32
+ }
33
+
34
+ function BookingTypeDetailContent({ uid }: { uid: string }) {
35
+ const { mailboxUid } = useSettingsShell();
36
+ const [bookingType, setBookingType] = useState<BookingType | null>(null);
37
+ const [name, setName] = useState("");
38
+ const [description, setDescription] = useState("");
39
+ const [hostDisplayName, setHostDisplayName] = useState("");
40
+ const [durationMinutes, setDurationMinutes] = useState(30);
41
+ const [timezone, setTimezone] = useState("");
42
+ const [availability, setAvailability] = useState<BookingAvailabilityWindow[]>([]);
43
+ const [minimumNoticeMinutes, setMinimumNoticeMinutes] = useState(60);
44
+ const [bookingWindowDays, setBookingWindowDays] = useState(30);
45
+ const [requiresApproval, setRequiresApproval] = useState(false);
46
+ const [enabled, setEnabled] = useState(true);
47
+ const [loading, setLoading] = useState(true);
48
+ // Split so a failed save keeps the form (and the user's edits) on screen - only a failed *load* has
49
+ // nothing to show but the error.
50
+ const [loadError, setLoadError] = useState<string | null>(null);
51
+ const [saveError, setSaveError] = useState<string | null>(null);
52
+ const [saving, setSaving] = useState(false);
53
+ const [saved, setSaved] = useState(false);
54
+ const [copied, setCopied] = useState(false);
55
+ const [confirmingDelete, setConfirmingDelete] = useState(false);
56
+ const [deleting, setDeleting] = useState(false);
57
+ const [deleteError, setDeleteError] = useState<string | null>(null);
58
+
59
+ useEffect(() => {
60
+ setLoading(true);
61
+ setLoadError(null);
62
+ getBookingType(uid)
63
+ .then((bt) => {
64
+ setBookingType(bt);
65
+ setName(bt.name);
66
+ setDescription(bt.description ?? "");
67
+ setHostDisplayName(bt.hostDisplayName);
68
+ setDurationMinutes(bt.durationMinutes);
69
+ setTimezone(bt.timezone);
70
+ setAvailability(bt.availability);
71
+ setMinimumNoticeMinutes(bt.minimumNoticeMinutes);
72
+ setBookingWindowDays(bt.bookingWindowDays);
73
+ setRequiresApproval(bt.requiresApproval);
74
+ setEnabled(bt.enabled);
75
+ })
76
+ .catch((err) => setLoadError(err instanceof ApiRequestError ? err.message : "Could not load this booking link."))
77
+ .finally(() => setLoading(false));
78
+ }, [uid]);
79
+
80
+ async function handleSubmit(e: FormEvent) {
81
+ e.preventDefault();
82
+ setSaveError(null);
83
+ setSaved(false);
84
+ setSaving(true);
85
+ try {
86
+ const updated = await updateBookingType({
87
+ uid: bookingType!.uid,
88
+ version: bookingType!.version,
89
+ name,
90
+ // `null` (not omitted) clears a saved description - an omitted field is left unchanged by the
91
+ // server's partial update. `UpdateBookingTypeInput` doesn't declare `null` yet, hence the cast.
92
+ description: description.trim() || (null as unknown as undefined),
93
+ hostDisplayName,
94
+ durationMinutes,
95
+ timezone,
96
+ availability,
97
+ minimumNoticeMinutes,
98
+ bookingWindowDays,
99
+ requiresApproval,
100
+ enabled,
101
+ });
102
+ setBookingType(updated);
103
+ setSaved(true);
104
+ } catch (err) {
105
+ setSaveError(err instanceof ApiRequestError ? err.message : "Could not save this booking link.");
106
+ } finally {
107
+ setSaving(false);
108
+ }
109
+ }
110
+
111
+ async function handleCopy(value: string) {
112
+ try {
113
+ await navigator.clipboard.writeText(value);
114
+ setCopied(true);
115
+ setTimeout(() => setCopied(false), 2000);
116
+ } catch {
117
+ // Clipboard access can be denied by the browser — the value is still selectable/copyable by hand.
118
+ }
119
+ }
120
+
121
+ async function handleDelete() {
122
+ setDeleting(true);
123
+ setDeleteError(null);
124
+ try {
125
+ await deleteBookingType(bookingType!.uid, bookingType!.version);
126
+ window.location.href = `/settings/booking-types?mailboxUid=${encodeURIComponent(mailboxUid!)}`;
127
+ } catch (err) {
128
+ setDeleteError(err instanceof ApiRequestError ? err.message : "Could not delete this booking link.");
129
+ setDeleting(false);
130
+ }
131
+ }
132
+
133
+ if (loading) {
134
+ return <p className="p-6 text-sm text-text-muted">Loading&hellip;</p>;
135
+ }
136
+ if (loadError || !bookingType) {
137
+ // `loadError` is always set whenever `bookingType` is falsy here: the load effect above destructures
138
+ // every field off the resolved value directly (`bt.name`, `bt.hostDisplayName`, ...), so a
139
+ // successful-but-empty response throws into `.catch()` — which sets `loadError` — before this
140
+ // component ever renders past the `loading` guard above. `!bookingType` alone (with `loadError` still
141
+ // `null`) is therefore unreachable, unlike e.g. `messages/[uid].tsx`'s identical-looking check,
142
+ // which stores its fetched value whole rather than destructuring it.
143
+ return (
144
+ <div className="p-6">
145
+ <Alert>{loadError!}</Alert>
146
+ </div>
147
+ );
148
+ }
149
+
150
+ // `bookingType` only ever becomes non-null via the client-only fetch above, so by the time this line
151
+ // runs `window` is always defined — the `!bookingType` early return just above covers every real SSR
152
+ // pass, where `bookingType` is still `null`.
153
+ const publicUrl = `${window.location.origin}/book/${encodeURIComponent(bookingType.slug)}`;
154
+
155
+ return (
156
+ <div className="flex-1 min-w-0 overflow-y-auto p-6">
157
+ <div className="max-w-2xl">
158
+ <div className="flex items-start justify-between gap-4 mb-1">
159
+ <div>
160
+ <a
161
+ href={`/settings/booking-types?mailboxUid=${encodeURIComponent(mailboxUid!)}`}
162
+ className="text-sm text-primary-dark hover:underline"
163
+ >
164
+ &larr; All booking links
165
+ </a>
166
+ <h1 className="text-xl font-bold tracking-tight mt-1">{bookingType.name}</h1>
167
+ </div>
168
+ <Button
169
+ type="button"
170
+ variant="secondary"
171
+ className="!w-auto shrink-0 !border-danger !text-danger hover:!border-danger hover:!text-danger"
172
+ onClick={() => setConfirmingDelete(true)}
173
+ >
174
+ Delete
175
+ </Button>
176
+ </div>
177
+
178
+ <div className="flex items-center gap-2 mb-5">
179
+ <code className="flex-1 text-xs bg-surface-alt border border-border rounded-sm py-2 px-3 overflow-x-auto whitespace-nowrap">
180
+ {publicUrl}
181
+ </code>
182
+ <Button type="button" variant="secondary" className="!w-auto shrink-0" onClick={() => handleCopy(publicUrl)}>
183
+ {copied ? "Copied" : "Copy"}
184
+ </Button>
185
+ </div>
186
+
187
+ {saveError && <Alert>{saveError}</Alert>}
188
+ {saved && !saveError && <div className="mb-4 text-sm text-success font-medium">Saved.</div>}
189
+
190
+ <form onSubmit={handleSubmit} className="flex flex-col gap-1">
191
+ <label className="flex items-center gap-2 text-sm mb-3">
192
+ <input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
193
+ Enabled (publicly bookable)
194
+ </label>
195
+ <FormField label="Name" htmlFor="name">
196
+ <input id="name" type="text" className={INPUT_CLASS} value={name} onChange={(e) => setName(e.target.value)} />
197
+ </FormField>
198
+ <FormField label="Description (optional)" htmlFor="description">
199
+ <textarea
200
+ id="description"
201
+ className={INPUT_CLASS}
202
+ rows={2}
203
+ value={description}
204
+ onChange={(e) => setDescription(e.target.value)}
205
+ />
206
+ </FormField>
207
+ <FormField label="Host name shown to visitors" htmlFor="hostDisplayName">
208
+ <input
209
+ id="hostDisplayName"
210
+ type="text"
211
+ className={INPUT_CLASS}
212
+ value={hostDisplayName}
213
+ onChange={(e) => setHostDisplayName(e.target.value)}
214
+ />
215
+ </FormField>
216
+ <div className="grid grid-cols-2 gap-3">
217
+ <FormField label="Duration (minutes)" htmlFor="durationMinutes">
218
+ <input
219
+ id="durationMinutes"
220
+ type="number"
221
+ min={1}
222
+ className={INPUT_CLASS}
223
+ value={durationMinutes}
224
+ onChange={(e) => setDurationMinutes(Number(e.target.value))}
225
+ />
226
+ </FormField>
227
+ <FormField label="Timezone" htmlFor="timezone">
228
+ <input
229
+ id="timezone"
230
+ type="text"
231
+ className={INPUT_CLASS}
232
+ value={timezone}
233
+ onChange={(e) => setTimezone(e.target.value)}
234
+ />
235
+ </FormField>
236
+ </div>
237
+ <div className="grid grid-cols-2 gap-3">
238
+ <FormField label="Minimum notice (minutes)" htmlFor="minimumNoticeMinutes">
239
+ <input
240
+ id="minimumNoticeMinutes"
241
+ type="number"
242
+ min={0}
243
+ className={INPUT_CLASS}
244
+ value={minimumNoticeMinutes}
245
+ onChange={(e) => setMinimumNoticeMinutes(Number(e.target.value))}
246
+ />
247
+ </FormField>
248
+ <FormField label="Booking window (days ahead)" htmlFor="bookingWindowDays">
249
+ <input
250
+ id="bookingWindowDays"
251
+ type="number"
252
+ min={1}
253
+ className={INPUT_CLASS}
254
+ value={bookingWindowDays}
255
+ onChange={(e) => setBookingWindowDays(Number(e.target.value))}
256
+ />
257
+ </FormField>
258
+ </div>
259
+ <label className="flex items-center gap-2 text-sm my-3">
260
+ <input type="checkbox" checked={requiresApproval} onChange={(e) => setRequiresApproval(e.target.checked)} />
261
+ Require my approval before confirming a booking
262
+ </label>
263
+
264
+ <div className="mb-4">
265
+ <span className="block text-sm font-semibold mb-1.5 text-text">Weekly availability</span>
266
+ <AvailabilityEditor value={availability} onChange={setAvailability} />
267
+ </div>
268
+
269
+ <div>
270
+ <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
271
+ Save
272
+ </Button>
273
+ </div>
274
+ </form>
275
+
276
+ <Modal open={confirmingDelete} onClose={() => setConfirmingDelete(false)} title="Delete booking link">
277
+ <p className="text-sm mb-5">
278
+ Are you sure you want to delete <strong>{bookingType.name}</strong>? Its public link will stop
279
+ working immediately. This cannot be undone.
280
+ </p>
281
+ {deleteError && <Alert>{deleteError}</Alert>}
282
+ <div className="flex gap-3 justify-end mt-5">
283
+ <Button
284
+ type="button"
285
+ variant="secondary"
286
+ className="!w-auto"
287
+ disabled={deleting}
288
+ onClick={() => setConfirmingDelete(false)}
289
+ >
290
+ Cancel
291
+ </Button>
292
+ <Button
293
+ type="button"
294
+ className="!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger"
295
+ loading={deleting}
296
+ disabled={deleting}
297
+ onClick={handleDelete}
298
+ >
299
+ Delete
300
+ </Button>
301
+ </div>
302
+ </Modal>
303
+ </div>
304
+ </div>
305
+ );
306
+ }