276 lines
12 KiB
TypeScript
276 lines
12 KiB
TypeScript
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||
import { Link, useParams } from "react-router-dom";
|
||
import {
|
||
Button,
|
||
Card,
|
||
DismissibleAlert,
|
||
FormField,
|
||
LoadingFrame,
|
||
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",
|
||
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}>
|
||
<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}>
|
||
<input
|
||
type="password"
|
||
autoComplete="current-password"
|
||
value={password}
|
||
onChange={(event) => setPassword(event.target.value)}
|
||
/>
|
||
</FormField>
|
||
</div>
|
||
<div className="scheduling-public-actions">
|
||
<Button type="submit" variant="primary">{I18N.accessRequest}</Button>
|
||
</div>
|
||
</form>
|
||
</Card>
|
||
)}
|
||
|
||
{response && (
|
||
<form className="scheduling-public-content" onSubmit={saveResponse}>
|
||
<Card title={response.title}>
|
||
{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}>{I18N.submit}</Button>
|
||
</div>
|
||
)}
|
||
</Card>}
|
||
</form>
|
||
)}
|
||
</LoadingFrame>
|
||
</main>
|
||
);
|
||
}
|