@rapidmx/web-client 0.14.1 → 0.15.1
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/apps/admin/domains/[uid].tsx +3 -2
- package/apps/shared/appearance/AppearanceForm.tsx +9 -1
- package/apps/shared/calendar/bookingPlugin.ts +100 -0
- package/apps/shared/calendar/calendarLiveUpdates.ts +27 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +37 -5
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +3 -1
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1545 -1491
- package/apps/shared/components/calendar/AppointmentQuickForm.tsx +327 -0
- package/apps/shared/components/calendar/DescriptionEditor.tsx +274 -0
- package/apps/shared/components/calendar/EventDescriptionView.tsx +70 -0
- package/apps/shared/components/calendar/EventDetails.tsx +401 -0
- package/apps/shared/components/calendar/EventEditor.tsx +640 -0
- package/apps/shared/components/calendar/EventExpandedForm.tsx +210 -0
- package/apps/shared/components/calendar/EventFormParts.tsx +622 -0
- package/apps/shared/components/calendar/EventModal.tsx +126 -866
- package/apps/shared/components/calendar/EventQuickForm.tsx +116 -0
- package/apps/shared/components/calendar/EventShell.tsx +202 -0
- package/apps/shared/components/calendar/FindATime.tsx +379 -0
- package/apps/shared/components/calendar/LazyDescriptionEditor.tsx +70 -0
- package/apps/shared/components/calendar/MonthView.tsx +15 -3
- package/apps/shared/components/calendar/QuickCreateFaces.tsx +87 -0
- package/apps/shared/components/calendar/QuickCreateTabs.tsx +81 -0
- package/apps/shared/components/calendar/QuickFaceFrame.tsx +81 -0
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +10 -5
- package/apps/shared/components/calendar/RequestChangeForm.tsx +271 -0
- package/apps/shared/components/calendar/SplitDayView.tsx +6 -5
- package/apps/shared/components/calendar/TaskQuickForm.tsx +289 -0
- package/apps/shared/components/calendar/TimeGridView.tsx +8 -6
- package/apps/shared/components/calendar/eventDialogFields.ts +86 -0
- package/apps/shared/components/calendar/eventForm.ts +108 -0
- package/apps/shared/components/calendar/eventFormat.ts +183 -0
- package/apps/shared/components/calendar/findTimeGrid.ts +90 -0
- package/apps/shared/components/calendar/swipeNavigation.ts +14 -0
- package/apps/shared/components/layout/UserMenu.tsx +80 -28
- package/apps/shared/components/mail/ConversationList.tsx +100 -48
- package/apps/shared/components/mail/ConversationThreadPane.tsx +490 -486
- package/apps/shared/components/mail/InviteCard.tsx +387 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +24 -2
- package/apps/shared/components/mail/SwipeRow.tsx +120 -0
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +7 -3
- package/apps/shared/components/mail/invite/InviteRowChip.tsx +104 -0
- package/apps/shared/components/mail/invite/InviteRsvpPopover.tsx +271 -0
- package/apps/shared/components/mail/invite/InviteTimeline.tsx +200 -0
- package/apps/shared/components/mail/invite/ProposeTimeForm.tsx +109 -0
- package/apps/shared/components/mail/invite/inviteFormat.ts +110 -0
- package/apps/shared/components/mail/invite/inviteStore.ts +114 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +522 -490
- package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
- package/apps/shared/gestures/useEnterSlide.ts +54 -0
- package/apps/shared/gestures/useSwipe.ts +114 -0
- package/apps/shared/gestures/useSwipeSlide.ts +113 -0
- package/apps/shared/gestures/useWheelPaging.ts +107 -0
- package/apps/shared/mail/newMailNotifications.ts +171 -183
- package/apps/shared/mail/useNewMailNotifications.ts +178 -178
- package/apps/shared/notifications/preferences.ts +36 -0
- package/apps/shared/notifications/store.ts +560 -551
- package/apps/www/_routes.ts +1 -0
- package/apps/www/calendar/index.tsx +139 -37
- package/apps/www/index.tsx +273 -126
- package/apps/www/messages/[uid].tsx +31 -4
- package/apps/www/settings/profile/index.tsx +248 -0
- package/dist/apps/admin/domains/[uid].js +4 -2
- package/dist/apps/shared/appearance/AppearanceForm.js +9 -1
- package/dist/apps/shared/calendar/bookingPlugin.d.ts +50 -0
- package/dist/apps/shared/calendar/bookingPlugin.js +57 -0
- package/dist/apps/shared/calendar/calendarLiveUpdates.d.ts +11 -0
- package/dist/apps/shared/calendar/calendarLiveUpdates.js +23 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
- package/dist/apps/shared/components/admin/layout/AdminShell.js +7 -3
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +3 -1
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +40 -6
- package/dist/apps/shared/components/calendar/AppointmentQuickForm.d.ts +47 -0
- package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +135 -0
- package/dist/apps/shared/components/calendar/DescriptionEditor.d.ts +26 -0
- package/dist/apps/shared/components/calendar/DescriptionEditor.js +192 -0
- package/dist/apps/shared/components/calendar/EventDescriptionView.d.ts +14 -0
- package/dist/apps/shared/components/calendar/EventDescriptionView.js +39 -0
- package/dist/apps/shared/components/calendar/EventDetails.d.ts +33 -0
- package/dist/apps/shared/components/calendar/EventDetails.js +137 -0
- package/dist/apps/shared/components/calendar/EventEditor.d.ts +59 -0
- package/dist/apps/shared/components/calendar/EventEditor.js +549 -0
- package/dist/apps/shared/components/calendar/EventExpandedForm.d.ts +14 -0
- package/dist/apps/shared/components/calendar/EventExpandedForm.js +44 -0
- package/dist/apps/shared/components/calendar/EventFormParts.d.ts +69 -0
- package/dist/apps/shared/components/calendar/EventFormParts.js +202 -0
- package/dist/apps/shared/components/calendar/EventModal.d.ts +26 -13
- package/dist/apps/shared/components/calendar/EventModal.js +57 -459
- package/dist/apps/shared/components/calendar/EventQuickForm.d.ts +18 -0
- package/dist/apps/shared/components/calendar/EventQuickForm.js +25 -0
- package/dist/apps/shared/components/calendar/EventShell.d.ts +64 -0
- package/dist/apps/shared/components/calendar/EventShell.js +134 -0
- package/dist/apps/shared/components/calendar/FindATime.d.ts +18 -0
- package/dist/apps/shared/components/calendar/FindATime.js +190 -0
- package/dist/apps/shared/components/calendar/LazyDescriptionEditor.d.ts +8 -0
- package/dist/apps/shared/components/calendar/LazyDescriptionEditor.js +44 -0
- package/dist/apps/shared/components/calendar/MonthView.d.ts +5 -1
- package/dist/apps/shared/components/calendar/MonthView.js +11 -5
- package/dist/apps/shared/components/calendar/QuickCreateFaces.d.ts +30 -0
- package/dist/apps/shared/components/calendar/QuickCreateFaces.js +44 -0
- package/dist/apps/shared/components/calendar/QuickCreateTabs.d.ts +24 -0
- package/dist/apps/shared/components/calendar/QuickCreateTabs.js +46 -0
- package/dist/apps/shared/components/calendar/QuickFaceFrame.d.ts +25 -0
- package/dist/apps/shared/components/calendar/QuickFaceFrame.js +19 -0
- package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +4 -1
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +2 -2
- package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +36 -0
- package/dist/apps/shared/components/calendar/RequestChangeForm.js +138 -0
- package/dist/apps/shared/components/calendar/SplitDayView.d.ts +3 -2
- package/dist/apps/shared/components/calendar/SplitDayView.js +3 -2
- package/dist/apps/shared/components/calendar/TaskQuickForm.d.ts +40 -0
- package/dist/apps/shared/components/calendar/TaskQuickForm.js +101 -0
- package/dist/apps/shared/components/calendar/TimeGridView.d.ts +4 -2
- package/dist/apps/shared/components/calendar/TimeGridView.js +3 -2
- package/dist/apps/shared/components/calendar/eventDialogFields.d.ts +28 -0
- package/dist/apps/shared/components/calendar/eventDialogFields.js +59 -0
- package/dist/apps/shared/components/calendar/eventForm.d.ts +98 -0
- package/dist/apps/shared/components/calendar/eventForm.js +1 -0
- package/dist/apps/shared/components/calendar/eventFormat.d.ts +67 -0
- package/dist/apps/shared/components/calendar/eventFormat.js +163 -0
- package/dist/apps/shared/components/calendar/findTimeGrid.d.ts +30 -0
- package/dist/apps/shared/components/calendar/findTimeGrid.js +83 -0
- package/dist/apps/shared/components/calendar/swipeNavigation.d.ts +9 -0
- package/dist/apps/shared/components/calendar/swipeNavigation.js +8 -0
- package/dist/apps/shared/components/layout/UserMenu.d.ts +10 -8
- package/dist/apps/shared/components/layout/UserMenu.js +70 -23
- package/dist/apps/shared/components/mail/ConversationList.d.ts +12 -1
- package/dist/apps/shared/components/mail/ConversationList.js +21 -4
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
- package/dist/apps/shared/components/mail/InviteCard.d.ts +20 -0
- package/dist/apps/shared/components/mail/InviteCard.js +150 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +13 -2
- package/dist/apps/shared/components/mail/SwipeRow.d.ts +22 -0
- package/dist/apps/shared/components/mail/SwipeRow.js +71 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +8 -3
- package/dist/apps/shared/components/mail/invite/InviteRowChip.d.ts +23 -0
- package/dist/apps/shared/components/mail/invite/InviteRowChip.js +46 -0
- package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.d.ts +20 -0
- package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.js +141 -0
- package/dist/apps/shared/components/mail/invite/InviteTimeline.d.ts +27 -0
- package/dist/apps/shared/components/mail/invite/InviteTimeline.js +120 -0
- package/dist/apps/shared/components/mail/invite/ProposeTimeForm.d.ts +17 -0
- package/dist/apps/shared/components/mail/invite/ProposeTimeForm.js +45 -0
- package/dist/apps/shared/components/mail/invite/inviteFormat.d.ts +28 -0
- package/dist/apps/shared/components/mail/invite/inviteFormat.js +94 -0
- package/dist/apps/shared/components/mail/invite/inviteStore.d.ts +28 -0
- package/dist/apps/shared/components/mail/invite/inviteStore.js +95 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +5 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +14 -3
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
- package/dist/apps/shared/gestures/useEnterSlide.d.ts +14 -0
- package/dist/apps/shared/gestures/useEnterSlide.js +47 -0
- package/dist/apps/shared/gestures/useSwipe.d.ts +45 -0
- package/dist/apps/shared/gestures/useSwipe.js +90 -0
- package/dist/apps/shared/gestures/useSwipeSlide.d.ts +40 -0
- package/dist/apps/shared/gestures/useSwipeSlide.js +95 -0
- package/dist/apps/shared/gestures/useWheelPaging.d.ts +32 -0
- package/dist/apps/shared/gestures/useWheelPaging.js +85 -0
- package/dist/apps/shared/mail/newMailNotifications.d.ts +3 -8
- package/dist/apps/shared/mail/newMailNotifications.js +3 -12
- package/dist/apps/shared/mail/useNewMailNotifications.js +3 -2
- package/dist/apps/shared/notifications/preferences.d.ts +13 -0
- package/dist/apps/shared/notifications/preferences.js +36 -0
- package/dist/apps/shared/notifications/store.d.ts +1 -0
- package/dist/apps/shared/notifications/store.js +7 -23
- package/dist/apps/www/_routes.js +1 -0
- package/dist/apps/www/calendar/index.js +76 -6
- package/dist/apps/www/index.js +140 -39
- package/dist/apps/www/messages/[uid].js +13 -5
- package/dist/apps/www/settings/profile/index.d.ts +4 -0
- package/dist/apps/www/settings/profile/index.js +124 -0
- package/package.json +2 -2
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { ReactNode, useRef, useState } from "react";
|
|
6
|
+
import { HiOutlineBars3BottomLeft, HiOutlineBell, HiOutlineMapPin, HiOutlineUserGroup, HiOutlineVideoCamera, HiOutlineXMark } from "react-icons/hi2";
|
|
7
|
+
import { AttendeeRole, EventVisibility, RecurrenceRule, WeekdayCode } from "@rapidmx/react-shared/calendar/calendarApi.js";
|
|
8
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
9
|
+
import LazyDescriptionEditor from "./LazyDescriptionEditor.js";
|
|
10
|
+
import RecurrenceEditor from "./RecurrenceEditor.js";
|
|
11
|
+
import ResourcePicker from "./ResourcePicker.js";
|
|
12
|
+
import { EventFormController } from "./eventForm.js";
|
|
13
|
+
import { hasDescriptionText } from "./eventDialogFields.js";
|
|
14
|
+
import {
|
|
15
|
+
REMINDER_UNITS,
|
|
16
|
+
ReminderUnit,
|
|
17
|
+
RESPONSE_STATUS_LABEL,
|
|
18
|
+
VISIBILITIES,
|
|
19
|
+
VISIBILITY_HELP,
|
|
20
|
+
VISIBILITY_LABEL,
|
|
21
|
+
bestReminderUnit,
|
|
22
|
+
} from "./eventFormat.js";
|
|
23
|
+
|
|
24
|
+
export const INPUT_CLASS =
|
|
25
|
+
"w-full text-sm py-2 px-3 border border-border rounded-md bg-surface text-text focus:outline-none focus:border-primary";
|
|
26
|
+
export const SELECT_CLASS =
|
|
27
|
+
"text-sm py-2 px-2 border border-border rounded-md bg-surface text-text focus:outline-none focus:border-primary";
|
|
28
|
+
/** A text field that reads as plain text until it is hovered or focused, as a row of the quick-create popover does. */
|
|
29
|
+
const QUIET_INPUT_CLASS =
|
|
30
|
+
"w-full min-w-0 text-sm py-1.5 bg-transparent text-text border-0 border-b border-transparent hover:border-border focus:border-primary focus:outline-none placeholder:text-text-muted";
|
|
31
|
+
const ROW_ICON_CLASS = "shrink-0 text-text-muted mt-1.5";
|
|
32
|
+
|
|
33
|
+
const ATTENDEE_ROLES: AttendeeRole[] = ["required", "optional", "resource"];
|
|
34
|
+
|
|
35
|
+
/** A form row: an icon in the left gutter and its content. */
|
|
36
|
+
export function IconRow({ icon, children }: { icon: ReactNode; children: ReactNode }) {
|
|
37
|
+
return (
|
|
38
|
+
<div className="flex items-start gap-3">
|
|
39
|
+
<span aria-hidden="true" className={ROW_ICON_CLASS}>
|
|
40
|
+
{icon}
|
|
41
|
+
</span>
|
|
42
|
+
<div className="flex-1 min-w-0">{children}</div>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The location row: a text box that is plain text until it is used. */
|
|
48
|
+
export function LocationRow({ c }: { c: EventFormController }) {
|
|
49
|
+
return (
|
|
50
|
+
<IconRow icon={<HiOutlineMapPin size={20} />}>
|
|
51
|
+
<input
|
|
52
|
+
type="text"
|
|
53
|
+
aria-label="Location"
|
|
54
|
+
placeholder="Add location"
|
|
55
|
+
className={QUIET_INPUT_CLASS}
|
|
56
|
+
value={c.values.location}
|
|
57
|
+
onChange={(e) => c.update({ location: e.target.value })}
|
|
58
|
+
/>
|
|
59
|
+
</IconRow>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** "Add video conferencing" and, once the event has a meeting, the button that joins it. */
|
|
64
|
+
export function VideoConferencingRow({ c }: { c: EventFormController }) {
|
|
65
|
+
const { values } = c;
|
|
66
|
+
return (
|
|
67
|
+
<IconRow icon={<HiOutlineVideoCamera size={20} />}>
|
|
68
|
+
<label className="flex items-center gap-2 text-sm py-1.5">
|
|
69
|
+
<input type="checkbox" checked={values.videoEnabled} onChange={(e) => c.update({ videoEnabled: e.target.checked })} />
|
|
70
|
+
Add video conferencing
|
|
71
|
+
</label>
|
|
72
|
+
{values.videoEnabled && (
|
|
73
|
+
<p className="text-xs text-text-muted mb-1">
|
|
74
|
+
Changing attendees after enabling video conferencing won’t update meeting invitees — turn this off and back on to
|
|
75
|
+
reissue links to the current attendee list.
|
|
76
|
+
</p>
|
|
77
|
+
)}
|
|
78
|
+
{c.videoError && (
|
|
79
|
+
<p role="alert" className="text-xs text-danger mb-1">
|
|
80
|
+
{c.videoError}
|
|
81
|
+
</p>
|
|
82
|
+
)}
|
|
83
|
+
{c.videoMeetingUid && (
|
|
84
|
+
<div className="flex items-center gap-3 mb-1">
|
|
85
|
+
<Button
|
|
86
|
+
type="button"
|
|
87
|
+
variant="secondary"
|
|
88
|
+
className="!w-auto"
|
|
89
|
+
disabled={!c.joinUrl}
|
|
90
|
+
onClick={() => window.open(c.joinUrl, "_blank", "noopener,noreferrer")}
|
|
91
|
+
>
|
|
92
|
+
Join video call
|
|
93
|
+
</Button>
|
|
94
|
+
{!c.joinUrl && (
|
|
95
|
+
<span className="text-xs text-text-muted">
|
|
96
|
+
{c.organizerJoinUrl === undefined ? "Loading the join link…" : "This meeting’s join link isn’t available."}
|
|
97
|
+
</span>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
</IconRow>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The guests field: an address box (Enter, a comma or a semicolon adds what is typed) and what has been added - chips in the quick popover,
|
|
107
|
+
* rows with a role, the answer so far and a remove button in the full form, which also offers rooms and equipment.
|
|
108
|
+
*/
|
|
109
|
+
export function GuestsField({ c, compact }: { c: EventFormController; compact: boolean }) {
|
|
110
|
+
const { values } = c;
|
|
111
|
+
const [hint, setHint] = useState<string | null>(null);
|
|
112
|
+
const [pickerOpen, setPickerOpen] = useState(false);
|
|
113
|
+
const pickerAnchor = useRef<HTMLButtonElement>(null);
|
|
114
|
+
|
|
115
|
+
function commit() {
|
|
116
|
+
const invalid = c.addGuests(values.guestDraft);
|
|
117
|
+
setHint(invalid.length > 0 ? `“${invalid[0]}” isn’t a valid email address.` : null);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<div className="flex flex-col gap-2">
|
|
122
|
+
<input
|
|
123
|
+
type="text"
|
|
124
|
+
aria-label="Add guests"
|
|
125
|
+
placeholder="Add guests"
|
|
126
|
+
className={compact ? QUIET_INPUT_CLASS : INPUT_CLASS}
|
|
127
|
+
value={values.guestDraft}
|
|
128
|
+
onChange={(e) => {
|
|
129
|
+
setHint(null);
|
|
130
|
+
c.update({ guestDraft: e.target.value });
|
|
131
|
+
}}
|
|
132
|
+
onKeyDown={(e) => {
|
|
133
|
+
if (e.key === "Enter" || e.key === "," || e.key === ";") {
|
|
134
|
+
e.preventDefault();
|
|
135
|
+
commit();
|
|
136
|
+
}
|
|
137
|
+
}}
|
|
138
|
+
/>
|
|
139
|
+
{hint && (
|
|
140
|
+
<p role="alert" className="text-xs text-danger">
|
|
141
|
+
{hint}
|
|
142
|
+
</p>
|
|
143
|
+
)}
|
|
144
|
+
{compact ? (
|
|
145
|
+
values.attendees.length > 0 && (
|
|
146
|
+
<ul className="flex flex-wrap gap-1.5">
|
|
147
|
+
{values.attendees.map((attendee, i) => (
|
|
148
|
+
<li key={i} className="flex items-center gap-1 text-xs rounded-full bg-surface-alt pl-2.5 pr-1 py-0.5">
|
|
149
|
+
<span className="truncate max-w-[16rem]">{attendee.address}</span>
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
onClick={() => c.removeAttendee(i)}
|
|
153
|
+
className="w-4 h-4 flex items-center justify-center rounded-full text-text-muted hover:text-text"
|
|
154
|
+
aria-label={`Remove attendee ${i + 1}`}
|
|
155
|
+
>
|
|
156
|
+
<HiOutlineXMark size={12} aria-hidden="true" />
|
|
157
|
+
</button>
|
|
158
|
+
</li>
|
|
159
|
+
))}
|
|
160
|
+
</ul>
|
|
161
|
+
)
|
|
162
|
+
) : (
|
|
163
|
+
<>
|
|
164
|
+
{values.attendees.length > 0 && (
|
|
165
|
+
<ul className="flex flex-col gap-2">
|
|
166
|
+
{values.attendees.map((attendee, i) => (
|
|
167
|
+
<li key={i} className="flex items-center gap-2">
|
|
168
|
+
<span
|
|
169
|
+
aria-hidden="true"
|
|
170
|
+
className="w-7 h-7 shrink-0 rounded-full bg-primary/15 text-primary-dark text-xs font-semibold flex items-center justify-center uppercase"
|
|
171
|
+
>
|
|
172
|
+
{(attendee.displayName || attendee.address).charAt(0)}
|
|
173
|
+
</span>
|
|
174
|
+
<div className="flex-1 min-w-0">
|
|
175
|
+
<div className="text-sm truncate">{attendee.displayName || attendee.address}</div>
|
|
176
|
+
<div className="text-xs text-text-muted truncate">
|
|
177
|
+
{attendee.displayName ? `${attendee.address} · ` : ""}
|
|
178
|
+
{RESPONSE_STATUS_LABEL[attendee.responseStatus]}
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
<select
|
|
182
|
+
className={SELECT_CLASS}
|
|
183
|
+
value={attendee.role}
|
|
184
|
+
onChange={(e) => c.updateAttendee(i, { role: e.target.value as AttendeeRole })}
|
|
185
|
+
aria-label={`Attendee role ${i + 1}`}
|
|
186
|
+
>
|
|
187
|
+
{ATTENDEE_ROLES.map((role) => (
|
|
188
|
+
<option key={role} value={role}>
|
|
189
|
+
{role}
|
|
190
|
+
</option>
|
|
191
|
+
))}
|
|
192
|
+
</select>
|
|
193
|
+
<button
|
|
194
|
+
type="button"
|
|
195
|
+
onClick={() => c.removeAttendee(i)}
|
|
196
|
+
className="w-7 h-7 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text"
|
|
197
|
+
aria-label={`Remove attendee ${i + 1}`}
|
|
198
|
+
>
|
|
199
|
+
<HiOutlineXMark size={16} aria-hidden="true" />
|
|
200
|
+
</button>
|
|
201
|
+
</li>
|
|
202
|
+
))}
|
|
203
|
+
</ul>
|
|
204
|
+
)}
|
|
205
|
+
<button
|
|
206
|
+
ref={pickerAnchor}
|
|
207
|
+
type="button"
|
|
208
|
+
onClick={() => setPickerOpen(true)}
|
|
209
|
+
className="self-start text-xs font-medium text-primary-dark hover:underline"
|
|
210
|
+
>
|
|
211
|
+
+ Add room/equipment
|
|
212
|
+
</button>
|
|
213
|
+
{pickerOpen && (
|
|
214
|
+
<ResourcePicker
|
|
215
|
+
anchorRef={pickerAnchor}
|
|
216
|
+
onClose={() => setPickerOpen(false)}
|
|
217
|
+
onSelect={(mailbox) => {
|
|
218
|
+
c.addResource(mailbox);
|
|
219
|
+
setPickerOpen(false);
|
|
220
|
+
}}
|
|
221
|
+
excludeAddresses={c.guestAddresses}
|
|
222
|
+
/>
|
|
223
|
+
)}
|
|
224
|
+
</>
|
|
225
|
+
)}
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The guests row of the quick popover. */
|
|
231
|
+
export function GuestsRow({ c }: { c: EventFormController }) {
|
|
232
|
+
return (
|
|
233
|
+
<IconRow icon={<HiOutlineUserGroup size={20} />}>
|
|
234
|
+
<GuestsField c={c} compact />
|
|
235
|
+
</IconRow>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The reminder row: the one notification an event has - an amount, its unit and a way to remove it - or "Add notification". */
|
|
240
|
+
export function ReminderRow({ value, onChange }: { value: string; onChange: (minutes: string) => void }) {
|
|
241
|
+
const [active, setActive] = useState(value.trim() !== "");
|
|
242
|
+
const [unit, setUnit] = useState<ReminderUnit>(() => (value.trim() === "" ? "minutes" : bestReminderUnit(Number(value)).unit));
|
|
243
|
+
const factor = REMINDER_UNITS.find((u) => u.unit === unit)!.factor;
|
|
244
|
+
// What the amount box shows: the stored minutes in the chosen unit. A blank stays blank while the row is open.
|
|
245
|
+
const amount = value.trim() === "" ? "" : String(Math.round((Number(value) / factor) * 100) / 100);
|
|
246
|
+
|
|
247
|
+
function change(nextAmount: string, nextUnit: ReminderUnit) {
|
|
248
|
+
const nextFactor = REMINDER_UNITS.find((u) => u.unit === nextUnit)!.factor;
|
|
249
|
+
onChange(nextAmount.trim() === "" ? "" : String(Math.round(Number(nextAmount) * nextFactor)));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (!active) {
|
|
253
|
+
return (
|
|
254
|
+
<IconRow icon={<HiOutlineBell size={20} />}>
|
|
255
|
+
<button
|
|
256
|
+
type="button"
|
|
257
|
+
className="text-sm text-text-muted hover:text-text py-1.5"
|
|
258
|
+
onClick={() => {
|
|
259
|
+
setActive(true);
|
|
260
|
+
setUnit("minutes");
|
|
261
|
+
onChange("30");
|
|
262
|
+
}}
|
|
263
|
+
>
|
|
264
|
+
Add notification
|
|
265
|
+
</button>
|
|
266
|
+
</IconRow>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
return (
|
|
270
|
+
<IconRow icon={<HiOutlineBell size={20} />}>
|
|
271
|
+
<div className="flex items-center gap-2 text-sm">
|
|
272
|
+
<span>Notification</span>
|
|
273
|
+
<input
|
|
274
|
+
type="number"
|
|
275
|
+
min={0}
|
|
276
|
+
className={`${INPUT_CLASS} !w-20`}
|
|
277
|
+
aria-label="Reminder (minutes before)"
|
|
278
|
+
value={amount}
|
|
279
|
+
onChange={(e) => change(e.target.value, unit)}
|
|
280
|
+
/>
|
|
281
|
+
<select
|
|
282
|
+
className={SELECT_CLASS}
|
|
283
|
+
aria-label="Reminder unit"
|
|
284
|
+
value={unit}
|
|
285
|
+
onChange={(e) => {
|
|
286
|
+
const nextUnit = e.target.value as ReminderUnit;
|
|
287
|
+
setUnit(nextUnit);
|
|
288
|
+
change(amount, nextUnit);
|
|
289
|
+
}}
|
|
290
|
+
>
|
|
291
|
+
{REMINDER_UNITS.map((u) => (
|
|
292
|
+
<option key={u.unit} value={u.unit}>
|
|
293
|
+
{u.unit}
|
|
294
|
+
</option>
|
|
295
|
+
))}
|
|
296
|
+
</select>
|
|
297
|
+
<span className="text-text-muted">before</span>
|
|
298
|
+
<button
|
|
299
|
+
type="button"
|
|
300
|
+
aria-label="Remove notification"
|
|
301
|
+
className="ml-auto w-7 h-7 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text"
|
|
302
|
+
onClick={() => {
|
|
303
|
+
setActive(false);
|
|
304
|
+
onChange("");
|
|
305
|
+
}}
|
|
306
|
+
>
|
|
307
|
+
<HiOutlineXMark size={16} aria-hidden="true" />
|
|
308
|
+
</button>
|
|
309
|
+
</div>
|
|
310
|
+
</IconRow>
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
type RecurrencePresetKey = "none" | "daily" | "weekly" | "monthly" | "yearly" | "weekdays" | "custom";
|
|
315
|
+
|
|
316
|
+
const WEEKDAYS_ONLY: WeekdayCode[] = ["MO", "TU", "WE", "TH", "FR"];
|
|
317
|
+
const WEEKDAY_NAME: Record<WeekdayCode, string> = {
|
|
318
|
+
MO: "Monday",
|
|
319
|
+
TU: "Tuesday",
|
|
320
|
+
WE: "Wednesday",
|
|
321
|
+
TH: "Thursday",
|
|
322
|
+
FR: "Friday",
|
|
323
|
+
SA: "Saturday",
|
|
324
|
+
SU: "Sunday",
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
function presetRule(key: Exclude<RecurrencePresetKey, "none" | "custom">, startWeekday: WeekdayCode | undefined): RecurrenceRule {
|
|
328
|
+
switch (key) {
|
|
329
|
+
case "daily":
|
|
330
|
+
return { freq: "daily", interval: 1, exceptions: [] };
|
|
331
|
+
case "weekly":
|
|
332
|
+
return { freq: "weekly", interval: 1, byDay: [startWeekday ?? "MO"], exceptions: [] };
|
|
333
|
+
case "monthly":
|
|
334
|
+
return { freq: "monthly", interval: 1, exceptions: [] };
|
|
335
|
+
case "yearly":
|
|
336
|
+
return { freq: "yearly", interval: 1, exceptions: [] };
|
|
337
|
+
case "weekdays":
|
|
338
|
+
return { freq: "weekly", interval: 1, byDay: [...WEEKDAYS_ONLY], exceptions: [] };
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Which menu entry a rule is: one of the plain repeats, or "custom" for anything with an interval, an end or other weekdays. */
|
|
343
|
+
function presetOf(rule: RecurrenceRule | null, startWeekday: WeekdayCode | undefined): RecurrencePresetKey {
|
|
344
|
+
if (!rule) {
|
|
345
|
+
return "none";
|
|
346
|
+
}
|
|
347
|
+
if (rule.interval !== 1 || rule.count || rule.until || rule.byMonthDay?.length || rule.byMonth?.length) {
|
|
348
|
+
return "custom";
|
|
349
|
+
}
|
|
350
|
+
if (rule.freq === "weekly") {
|
|
351
|
+
const days = [...(rule.byDay ?? [])].sort().join(",");
|
|
352
|
+
if (days === [...WEEKDAYS_ONLY].sort().join(",")) {
|
|
353
|
+
return "weekdays";
|
|
354
|
+
}
|
|
355
|
+
return days === (startWeekday ?? "MO") ? "weekly" : "custom";
|
|
356
|
+
}
|
|
357
|
+
return rule.byDay?.length ? "custom" : rule.freq;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* "Does not repeat" and the plain repeats as a menu, with "Custom…" opening `RecurrenceEditor`'s full set (an interval, weekdays, an end).
|
|
362
|
+
* Choosing one of the plain repeats keeps the rule's skipped dates; the menu shows "Custom…" for any rule it has no plain entry for.
|
|
363
|
+
*/
|
|
364
|
+
export function RecurrenceSelect({
|
|
365
|
+
value,
|
|
366
|
+
onChange,
|
|
367
|
+
allDay,
|
|
368
|
+
startWeekday,
|
|
369
|
+
}: {
|
|
370
|
+
value: RecurrenceRule | null;
|
|
371
|
+
onChange: (rule: RecurrenceRule | null) => void;
|
|
372
|
+
allDay: boolean;
|
|
373
|
+
startWeekday: WeekdayCode | undefined;
|
|
374
|
+
}) {
|
|
375
|
+
// Picking "Custom…" for a rule that is also a plain repeat still opens the editor.
|
|
376
|
+
const [customRequested, setCustomRequested] = useState(false);
|
|
377
|
+
const selected: RecurrencePresetKey = customRequested && value ? "custom" : presetOf(value, startWeekday);
|
|
378
|
+
|
|
379
|
+
function choose(key: RecurrencePresetKey) {
|
|
380
|
+
setCustomRequested(key === "custom");
|
|
381
|
+
if (key === "none") {
|
|
382
|
+
onChange(null);
|
|
383
|
+
} else if (key === "custom") {
|
|
384
|
+
onChange(value ?? presetRule("weekly", startWeekday));
|
|
385
|
+
} else {
|
|
386
|
+
onChange({ ...presetRule(key, startWeekday), exceptions: value?.exceptions ?? [] });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return (
|
|
391
|
+
<div className="flex flex-col gap-2">
|
|
392
|
+
<select
|
|
393
|
+
id="event-recurrence"
|
|
394
|
+
aria-label="Recurrence"
|
|
395
|
+
className={SELECT_CLASS}
|
|
396
|
+
value={selected}
|
|
397
|
+
onChange={(e) => choose(e.target.value as RecurrencePresetKey)}
|
|
398
|
+
>
|
|
399
|
+
<option value="none">Does not repeat</option>
|
|
400
|
+
<option value="daily">Daily</option>
|
|
401
|
+
<option value="weekly">{startWeekday ? `Weekly on ${WEEKDAY_NAME[startWeekday]}` : "Weekly"}</option>
|
|
402
|
+
<option value="monthly">Monthly</option>
|
|
403
|
+
<option value="yearly">Annually</option>
|
|
404
|
+
<option value="weekdays">Every weekday (Monday to Friday)</option>
|
|
405
|
+
<option value="custom">Custom…</option>
|
|
406
|
+
</select>
|
|
407
|
+
{selected === "custom" && <RecurrenceEditor value={value} onChange={onChange} allDay={allDay} startWeekday={startWeekday} hideToggle />}
|
|
408
|
+
</div>
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** [start date] [start time] to [end time] [end date], then "All day", the "Time zone" link (and its menu) and how the event repeats. */
|
|
413
|
+
export function DateTimeControls({ c }: { c: EventFormController }) {
|
|
414
|
+
const { values } = c;
|
|
415
|
+
const [zoneOpen, setZoneOpen] = useState(false);
|
|
416
|
+
return (
|
|
417
|
+
<div className="flex flex-col gap-3">
|
|
418
|
+
<div className="flex flex-wrap items-center gap-2 text-sm">
|
|
419
|
+
<input
|
|
420
|
+
type="date"
|
|
421
|
+
aria-label="Event start date"
|
|
422
|
+
className={`${SELECT_CLASS} min-w-0`}
|
|
423
|
+
value={values.start.slice(0, 10)}
|
|
424
|
+
onChange={(e) => c.setStart(`${e.target.value}${values.start.slice(10)}`)}
|
|
425
|
+
/>
|
|
426
|
+
{!values.allDay && (
|
|
427
|
+
<input
|
|
428
|
+
type="time"
|
|
429
|
+
aria-label="Event start time"
|
|
430
|
+
className={`${SELECT_CLASS} min-w-0`}
|
|
431
|
+
value={values.start.slice(11, 16)}
|
|
432
|
+
onChange={(e) => c.setStart(`${values.start.slice(0, 11)}${e.target.value}`)}
|
|
433
|
+
/>
|
|
434
|
+
)}
|
|
435
|
+
<span className="text-text-muted">to</span>
|
|
436
|
+
{!values.allDay && (
|
|
437
|
+
<input
|
|
438
|
+
type="time"
|
|
439
|
+
aria-label="Event end time"
|
|
440
|
+
className={`${SELECT_CLASS} min-w-0`}
|
|
441
|
+
value={values.end.slice(11, 16)}
|
|
442
|
+
onChange={(e) => c.setEnd(`${values.end.slice(0, 11)}${e.target.value}`)}
|
|
443
|
+
/>
|
|
444
|
+
)}
|
|
445
|
+
<input
|
|
446
|
+
type="date"
|
|
447
|
+
aria-label="Event end date"
|
|
448
|
+
className={`${SELECT_CLASS} min-w-0`}
|
|
449
|
+
value={values.end.slice(0, 10)}
|
|
450
|
+
onChange={(e) => c.setEnd(`${e.target.value}${values.end.slice(10)}`)}
|
|
451
|
+
/>
|
|
452
|
+
</div>
|
|
453
|
+
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
|
|
454
|
+
<label className="flex items-center gap-2">
|
|
455
|
+
<input type="checkbox" checked={values.allDay} onChange={(e) => c.onAllDayChange(e.target.checked)} />
|
|
456
|
+
All day
|
|
457
|
+
</label>
|
|
458
|
+
<button
|
|
459
|
+
type="button"
|
|
460
|
+
aria-expanded={zoneOpen}
|
|
461
|
+
className="text-primary-dark hover:underline"
|
|
462
|
+
onClick={() => setZoneOpen((open) => !open)}
|
|
463
|
+
>
|
|
464
|
+
Time zone
|
|
465
|
+
</button>
|
|
466
|
+
{!c.editingSingleOccurrence && (
|
|
467
|
+
<RecurrenceSelect value={values.recurrenceRule} onChange={(rule) => c.update({ recurrenceRule: rule })} allDay={values.allDay} startWeekday={c.startWeekday} />
|
|
468
|
+
)}
|
|
469
|
+
</div>
|
|
470
|
+
{zoneOpen && (
|
|
471
|
+
<select
|
|
472
|
+
aria-label="Time zone"
|
|
473
|
+
className={`${SELECT_CLASS} self-start max-w-full`}
|
|
474
|
+
value={values.formZone}
|
|
475
|
+
onChange={(e) => c.onTimeZoneChange(e.target.value)}
|
|
476
|
+
>
|
|
477
|
+
{c.timeZones.map((zone) => (
|
|
478
|
+
<option key={zone} value={zone}>
|
|
479
|
+
{zone}
|
|
480
|
+
</option>
|
|
481
|
+
))}
|
|
482
|
+
</select>
|
|
483
|
+
)}
|
|
484
|
+
</div>
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** The calendar this event is in: its colour and name, a choice between calendars (and mailboxes) when an event is being created. */
|
|
489
|
+
export function CalendarField({ c }: { c: EventFormController }) {
|
|
490
|
+
const { values } = c;
|
|
491
|
+
const mailboxChoices = !c.occurrence && c.mailboxOptions && c.mailboxOptions.length > 1 ? c.mailboxOptions : undefined;
|
|
492
|
+
const calendarChoices = !c.occurrence && c.calendarChoices && c.calendarChoices.length > 1 ? c.calendarChoices : undefined;
|
|
493
|
+
return (
|
|
494
|
+
<div className="flex flex-col gap-2">
|
|
495
|
+
{mailboxChoices && (
|
|
496
|
+
<select
|
|
497
|
+
aria-label="Mailbox"
|
|
498
|
+
className={`${SELECT_CLASS} self-start max-w-full`}
|
|
499
|
+
value={values.targetMailboxUid}
|
|
500
|
+
onChange={(e) => c.onMailboxChange(e.target.value)}
|
|
501
|
+
>
|
|
502
|
+
{mailboxChoices.map(({ mailbox }) => (
|
|
503
|
+
<option key={mailbox.uid} value={mailbox.uid}>
|
|
504
|
+
{mailbox.displayName}
|
|
505
|
+
{mailbox.ownerUserUid ? "" : " (shared)"}
|
|
506
|
+
</option>
|
|
507
|
+
))}
|
|
508
|
+
</select>
|
|
509
|
+
)}
|
|
510
|
+
<div className="flex items-center gap-2 text-sm">
|
|
511
|
+
<span aria-hidden="true" className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: c.calendarColor }} />
|
|
512
|
+
{calendarChoices ? (
|
|
513
|
+
<select
|
|
514
|
+
aria-label="Calendar"
|
|
515
|
+
className={`${SELECT_CLASS} min-w-0 max-w-full`}
|
|
516
|
+
value={values.targetFolderUid}
|
|
517
|
+
onChange={(e) => c.update({ targetFolderUid: e.target.value })}
|
|
518
|
+
>
|
|
519
|
+
{calendarChoices.map((cal) => (
|
|
520
|
+
<option key={cal.uid} value={cal.uid}>
|
|
521
|
+
{cal.name}
|
|
522
|
+
</option>
|
|
523
|
+
))}
|
|
524
|
+
</select>
|
|
525
|
+
) : (
|
|
526
|
+
<span className="truncate">{c.calendarName}</span>
|
|
527
|
+
)}
|
|
528
|
+
</div>
|
|
529
|
+
</div>
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* The description row: the rich-text box (`DescriptionEditor`). In the full card it is always there; in the quick-create popover (`collapsible`) it is an
|
|
535
|
+
* "Add description" row that opens the same box in place, as Google Calendar does - and stays open once there is text in it.
|
|
536
|
+
*/
|
|
537
|
+
export function DescriptionRow({ c, collapsible }: { c: EventFormController; collapsible: boolean }) {
|
|
538
|
+
const [opened, setOpened] = useState(false);
|
|
539
|
+
if (collapsible && !opened && !hasDescriptionText(c.values.descriptionHtml)) {
|
|
540
|
+
return (
|
|
541
|
+
<IconRow icon={<HiOutlineBars3BottomLeft size={20} />}>
|
|
542
|
+
<button type="button" className="text-sm text-text-muted hover:text-text py-1.5" onClick={() => setOpened(true)}>
|
|
543
|
+
Add description
|
|
544
|
+
</button>
|
|
545
|
+
</IconRow>
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
return (
|
|
549
|
+
<IconRow icon={<HiOutlineBars3BottomLeft size={20} />}>
|
|
550
|
+
<LazyDescriptionEditor value={c.values.descriptionHtml} onChange={(html) => c.update({ descriptionHtml: html })} autoFocus={collapsible && opened} />
|
|
551
|
+
</IconRow>
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Who may see the event's details, with what each choice means underneath. */
|
|
556
|
+
export function VisibilitySelect({ c }: { c: EventFormController }) {
|
|
557
|
+
const { visibility } = c.values;
|
|
558
|
+
return (
|
|
559
|
+
<div className="flex flex-col gap-1">
|
|
560
|
+
<select
|
|
561
|
+
aria-label="Visibility"
|
|
562
|
+
aria-describedby="event-visibility-help"
|
|
563
|
+
className={`${SELECT_CLASS} !w-auto self-start`}
|
|
564
|
+
value={visibility}
|
|
565
|
+
onChange={(e) => c.update({ visibility: e.target.value as EventVisibility })}
|
|
566
|
+
>
|
|
567
|
+
{VISIBILITIES.map((option) => (
|
|
568
|
+
<option key={option} value={option}>
|
|
569
|
+
{VISIBILITY_LABEL[option]}
|
|
570
|
+
</option>
|
|
571
|
+
))}
|
|
572
|
+
</select>
|
|
573
|
+
<p id="event-visibility-help" className="text-xs text-text-muted">
|
|
574
|
+
{VISIBILITY_HELP[visibility]}
|
|
575
|
+
</p>
|
|
576
|
+
</div>
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const GUEST_PERMISSIONS = [
|
|
581
|
+
{
|
|
582
|
+
key: "guestsCanModify",
|
|
583
|
+
label: "Modify event",
|
|
584
|
+
help: "Guests can ask you to change the title, location, description or time. What they ask for is sent to you and applied automatically when this is on.",
|
|
585
|
+
},
|
|
586
|
+
{
|
|
587
|
+
key: "guestsCanInviteOthers",
|
|
588
|
+
label: "Invite others",
|
|
589
|
+
help: "Guests can ask you to add more guests; those are added automatically when this is on.",
|
|
590
|
+
},
|
|
591
|
+
{
|
|
592
|
+
key: "guestsCanSeeGuestList",
|
|
593
|
+
label: "See guest list",
|
|
594
|
+
help: "When this is off, each guest is sent an invitation that names only themselves and you.",
|
|
595
|
+
},
|
|
596
|
+
] as const;
|
|
597
|
+
|
|
598
|
+
/** What guests may do with the event: ask for it to change, ask for guests to be added, see who else is invited. Only the organizer sets these. */
|
|
599
|
+
export function GuestPermissions({ c }: { c: EventFormController }) {
|
|
600
|
+
return (
|
|
601
|
+
<fieldset className="flex flex-col gap-2 min-w-0">
|
|
602
|
+
<legend className="text-sm font-semibold mb-1">Guest permissions</legend>
|
|
603
|
+
{GUEST_PERMISSIONS.map(({ key, label, help }) => (
|
|
604
|
+
<div key={key}>
|
|
605
|
+
<label className="flex items-center gap-2 text-sm">
|
|
606
|
+
<input
|
|
607
|
+
type="checkbox"
|
|
608
|
+
aria-describedby={`event-${key}-help`}
|
|
609
|
+
checked={c.values[key]}
|
|
610
|
+
onChange={(e) => c.update({ [key]: e.target.checked })}
|
|
611
|
+
/>
|
|
612
|
+
{label}
|
|
613
|
+
</label>
|
|
614
|
+
<p id={`event-${key}-help`} className="text-xs text-text-muted pl-6">
|
|
615
|
+
{help}
|
|
616
|
+
</p>
|
|
617
|
+
</div>
|
|
618
|
+
))}
|
|
619
|
+
</fieldset>
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
|