import React, { useCallback, useEffect, useState } from 'react'; import { CheckCircle2, Clock, Mail, TriangleAlert } from 'lucide-react'; import { usePatientPortalInviteService, type InvitationStatus, } from '../../../services/PatientPortalInviteService.tsx'; import { Alert, AlertDescription } from '../../../components/ui/alert.tsx'; import { relayServerFailure } from '../../../utils/fetchFailureMessage'; /** * - the step AFTER an import: telling those patients their record is online. * * THE IMPORT MUST NOT END IN "INVITE THEM ALL" * * Spencer's sequence, and each step is useful on its own: * 1. import the patients - the front desk can already find, book and bill them; * 2. *"We can invite these patients once your email domain is set up."* * 3. invite - staged and throttled. * * So the ABSENCE of a verified sending domain is presented as the NEXT STEP, never as a failure. A * practice that has just imported 2,000 patients and is shown an error has been told its import went * wrong, which is false. */ const DOMAIN_COPY: Record = { verified: null, 'not-configured': { title: 'Set up your email sending domain to invite these patients', detail: 'Patients need to see the invitation come from your practice rather than from us — an ' + 'email from a name they do not recognise is what phishing looks like. You can set this up ' + 'in your organization settings.', }, pending: { title: 'Your email sending domain is still verifying', detail: 'Once your DNS records have propagated you can invite these patients. That usually takes ' + 'a few minutes, and can take up to a couple of days.', }, failed: { title: 'Your email sending domain could not be verified', detail: 'Check the DNS records in your organization settings, then check the domain again. You ' + 'can invite these patients once it verifies.', }, }; /** Poll while a run is live. Long enough not to hammer the API, short enough to feel alive. */ const POLL_MS = 15_000; const InvitePatientsPanel: React.FC = () => { const { fetchInvitationStatus, startInvitations } = usePatientPortalInviteService(); const [status, setStatus] = useState(null); const [starting, setStarting] = useState(false); const [error, setError] = useState(null); // A THIRD STATE, not `status === null`. Loading and "we asked and could not tell" look identical // from a null, and rendering the invite button in either would offer an action we cannot say is // available the same three-state distinction landed for the practitioner's day. const [loaded, setLoaded] = useState(false); const refresh = useCallback(async () => { try { setStatus(await fetchInvitationStatus()); setError(null); } catch { // Silent about the CAUSE but not about the state: the panel renders nothing actionable // rather than claiming the practice cannot invite, which would be a claim about their // configuration rather than about our request. setStatus(null); } finally { setLoaded(true); } }, [fetchInvitationStatus]); useEffect(() => { void refresh(); }, [refresh]); // Polls ONLY while a run is live. A finished run is not re-read for ever, and a practice that // never starts one costs one request. useEffect(() => { if (!status?.running) return undefined; const timer = setInterval(() => { void refresh(); }, POLL_MS); return () => clearInterval(timer); }, [status?.running, refresh]); const onStart = useCallback(async () => { setStarting(true); setError(null); try { setStatus(await startInvitations()); } catch (startError) { setError( relayServerFailure(startError, 'The invitations could not be started. Please try again.'), ); } finally { setStarting(false); } }, [startInvitations]); if (!loaded || !status) return null; const blocked = DOMAIN_COPY[status.sendingDomain]; return (

Invite these patients to the portal

{blocked ? ( // A NEXT STEP, NOT AN ERROR - hence the neutral alert rather than a destructive one. {blocked.title} {blocked.detail} ) : ( <>

{/* ⚠️ The pacing is stated UP FRONT rather than discovered. A practice watching 50 go out on the first day needs to know that is deliberate, or the feature looks broken and they raise a ticket. */} Invitations go out gradually — a small number on the first day, increasing daily. That is what keeps your practice’s email out of patients’ spam folders later, including appointment reminders.

{status.running ? ( Inviting your patients — {status.sent} sent so far. You can leave this page; it carries on in the background. ) : ( )} {/* ⭐ A finished run still reports its total, because "how did it go?" is normally asked afterwards. */} {!status.running && status.sent > 0 && (

{status.sent} patients have been invited.

)} )} {error && ( {error} )}
); }; export default InvitePatientsPanel;