Files
govoplan-scheduling/webui/src/features/scheduling/SchedulingPublicPage.tsx
T

305 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { Link, useParams } from "react-router";
import {
Button,
Card,
DismissibleAlert,
DocumentationHelpLink,
FormField,
LoadingFrame,
PasswordField,
formatDateTime,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
import {
getPublicSchedulingParticipation,
submitPublicSchedulingParticipation,
type SchedulingAvailabilityValue,
type SchedulingPublicParticipationAccessPayload,
type SchedulingPublicParticipationResponse
} from "../../api/scheduling";
import { applySchedulingAvailabilityChoice } from "./schedulingViewModel";
type SchedulingPublicPageProps = {
settings: ApiSettings;
auth: AuthInfo | null;
};
const I18N = {
accessDetails: "i18n:govoplan-scheduling.access_details.79c06b89",
accessHelp: "i18n:govoplan-scheduling.enter_the_details_supplied_with_the_invitation_for_privacy.81ba419c",
accessRequest: "i18n:govoplan-scheduling.open_scheduling_request.31829cce",
alreadySubmitted: "i18n:govoplan-scheduling.responses_may_be_updated_while_this_request_remains_open.4faecbbe",
answerRequired: "i18n:govoplan-scheduling.choose_availability_for_at_least_one_candidate_slot.28d2111f",
available: "i18n:govoplan-scheduling.available.7c62a142",
backToScheduling: "i18n:govoplan-scheduling.open_in_scheduling.48df1541",
comment: "i18n:govoplan-scheduling.comment.d03495b1",
cancelled: "i18n:govoplan-scheduling.this_scheduling_request_was_cancelled.1af3c85e",
cancellationNoticeUntil: "i18n:govoplan-scheduling.cancellation_notice_available_until.f840d1e6",
deadline: "i18n:govoplan-scheduling.response_deadline.7fd9e3aa",
email: "i18n:govoplan-scheduling.participant_email.2cadfd9e",
invalidAccess: "i18n:govoplan-scheduling.this_scheduling_link_is_invalid_expired_or_the_access_details.8e7aa197",
loading: "i18n:govoplan-scheduling.loading_scheduling_request.43c39c1b",
maybe: "i18n:govoplan-scheduling.maybe.56dd8d0b",
noLongerOpen: "i18n:govoplan-scheduling.this_scheduling_request_is_no_longer_accepting_responses.c612e78a",
password: "i18n:govoplan-scheduling.guest_password.94545e82",
response: "i18n:govoplan-scheduling.your_availability.f86c8215",
saved: "i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d",
saving: "i18n:govoplan-scheduling.saving.56a2285c",
submit: "i18n:govoplan-scheduling.submit_response.a5f0c053",
unavailable: "i18n:govoplan-scheduling.unavailable.2c9c1f79"
} as const;
function accessPayload(email: string, password: string): SchedulingPublicParticipationAccessPayload {
return {
participant_email: email.trim() || null,
password: password || null
};
}
function initialAvailability(response: SchedulingPublicParticipationResponse): Record<string, SchedulingAvailabilityValue | ""> {
const previous = new Map(response.answers.map((answer) => [answer.slot_id, answer.value]));
return Object.fromEntries(response.slots.map((slot) => [slot.id, previous.get(slot.id) ?? ""]));
}
function newIdempotencyKey(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
return `scheduling-response-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export default function SchedulingPublicPage({ settings, auth }: SchedulingPublicPageProps) {
const { requestId = "", token = "" } = useParams();
const [response, setResponse] = useState<SchedulingPublicParticipationResponse | null>(null);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [availability, setAvailability] = useState<Record<string, SchedulingAvailabilityValue | "">>({});
const [comment, setComment] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [accessAttempted, setAccessAttempted] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const slotIds = useMemo(() => response?.slots.map((slot) => slot.id) ?? [], [response]);
const collecting = response?.status === "collecting";
function applyResponse(next: SchedulingPublicParticipationResponse) {
setResponse(next);
setAvailability(initialAvailability(next));
setComment(next.comment ?? "");
setError("");
}
useEffect(() => {
let cancelled = false;
setLoading(true);
setResponse(null);
setAccessAttempted(false);
setError("");
void getPublicSchedulingParticipation(settings, requestId, token, {})
.then((next) => {
if (!cancelled) applyResponse(next);
})
.catch(() => undefined)
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [requestId, settings.apiBaseUrl, settings.apiKey, token]);
async function openRequest(event: FormEvent) {
event.preventDefault();
setLoading(true);
setAccessAttempted(true);
setError("");
try {
applyResponse(await getPublicSchedulingParticipation(settings, requestId, token, accessPayload(email, password)));
} catch {
setResponse(null);
setError(I18N.invalidAccess);
} finally {
setLoading(false);
}
}
async function saveResponse(event: FormEvent) {
event.preventDefault();
if (!response) return;
if (!response.slots.some((slot) => availability[slot.id])) {
setError(I18N.answerRequired);
return;
}
setSaving(true);
setError("");
setSuccess("");
try {
const next = await submitPublicSchedulingParticipation(settings, requestId, token, {
...accessPayload(email, password),
answers: response.slots
.filter((slot) => availability[slot.id])
.map((slot) => ({
slot_id: slot.id,
value: availability[slot.id] as SchedulingAvailabilityValue,
option_revision: slot.revision
})),
comment: response.allow_comments ? comment : null,
idempotency_key: newIdempotencyKey()
});
applyResponse(next);
setSuccess(I18N.saved);
} catch {
setError(I18N.invalidAccess);
} finally {
setSaving(false);
}
}
return (
<main className="scheduling-public-page">
<LoadingFrame loading={loading} label={I18N.loading}>
{auth && (
<div className="scheduling-public-deep-link">
<Link className="btn btn-secondary" to={`/scheduling?request_id=${encodeURIComponent(requestId)}`}>
{I18N.backToScheduling}
</Link>
</div>
)}
{!response && !loading && (
<Card
title={I18N.accessDetails}
actions={(
<DocumentationHelpLink
reference={{
topicId: "scheduling.find-and-decide-meeting-time",
documentationType: "user"
}} />
)}>
<form className="scheduling-public-access-form" onSubmit={openRequest}>
<p className="muted">{I18N.accessHelp}</p>
{accessAttempted && error && <DismissibleAlert tone="danger">{error}</DismissibleAlert>}
<div className="form-grid two-col">
<FormField label={I18N.email}>
<input
type="email"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
</FormField>
<FormField label={I18N.password}>
<PasswordField
autoComplete="current-password"
value={password}
onValueChange={setPassword} />
</FormField>
</div>
<div className="scheduling-public-actions">
<Button
type="submit"
variant="primary"
disabled={loading}
disabledReason={loading ? I18N.loading : undefined}>
{I18N.accessRequest}
</Button>
</div>
</form>
</Card>
)}
{response && (
<form className="scheduling-public-content" onSubmit={saveResponse}>
<Card
title={response.title}
actions={(
<DocumentationHelpLink
reference={{
topicId: "scheduling.find-and-decide-meeting-time",
documentationType: "user"
}} />
)}>
{response.description && <p className="scheduling-public-description">{response.description}</p>}
<dl className="scheduling-public-summary">
{response.location && <><dt>i18n:govoplan-scheduling.location.d219c681</dt><dd>{response.location}</dd></>}
{response.deadline_at && <><dt>{I18N.deadline}</dt><dd>{formatDateTime(response.deadline_at)}</dd></>}
</dl>
{response.cancellation_notice_only
? <DismissibleAlert tone="warning" dismissible={false}>{I18N.cancelled}</DismissibleAlert>
: !collecting && <DismissibleAlert tone="info" dismissible={false}>{I18N.noLongerOpen}</DismissibleAlert>}
{response.has_response && collecting && <DismissibleAlert tone="info" dismissible={false}>{I18N.alreadySubmitted}</DismissibleAlert>}
{response.cancellation_notice_only && response.cancellation_notice_until && (
<p className="muted">{I18N.cancellationNoticeUntil}: {formatDateTime(response.cancellation_notice_until)}</p>
)}
</Card>
{error && <DismissibleAlert tone="danger">{error}</DismissibleAlert>}
{success && <DismissibleAlert tone="success">{success}</DismissibleAlert>}
{!response.cancellation_notice_only && <Card title={I18N.response}>
<div className="scheduling-public-slots">
{response.slots.map((slot) => (
<fieldset className="scheduling-public-slot" key={slot.id} disabled={!collecting || saving}>
<legend>{slot.label}</legend>
<p>{formatDateTime(slot.start_at)} {formatDateTime(slot.end_at)}</p>
{slot.description && <p className="muted">{slot.description}</p>}
{(slot.location || response.location) && <p className="muted">{slot.location || response.location}</p>}
<div className="scheduling-public-choice-group">
{([
["available", I18N.available],
...(response.allow_maybe ? [["maybe", I18N.maybe] as const] : []),
["unavailable", I18N.unavailable]
] as Array<[SchedulingAvailabilityValue, string]>).map(([value, label]) => (
<label key={value}>
<input
type="radio"
name={`slot-${slot.id}`}
value={value}
checked={availability[slot.id] === value}
onChange={() => setAvailability((current) => applySchedulingAvailabilityChoice(
slotIds,
current,
slot.id,
value,
response.single_choice
))}
/>
<span>{label}</span>
</label>
))}
</div>
</fieldset>
))}
</div>
{response.allow_comments && (
<FormField label={I18N.comment}>
<textarea
rows={4}
maxLength={4000}
disabled={!collecting || saving}
value={comment}
onChange={(event) => setComment(event.target.value)}
/>
</FormField>
)}
{collecting && (
<div className="scheduling-public-actions">
<Button
type="submit"
variant="primary"
disabled={saving}
disabledReason={saving ? I18N.saving : undefined}>
{I18N.submit}
</Button>
</div>
)}
</Card>}
</form>
)}
</LoadingFrame>
</main>
);
}