import React, { useState, useEffect } from 'react'; import { assetUrl } from '../../utils/assetUrl'; import { usePracticeTypeOptions } from '../../config/serviceCategories'; import { Spinner } from '../../components/ui/spinner'; import { Card, CardBody, CardMedia, CardHeader } from '../../components/ui/wp-card'; import { InputControl } from '../../components/ui/input-control'; import { DismissibleAlert } from '../../components/ui/dismissible-alert'; import { Heading } from '../../components/ui/heading'; import { Flex, FlexBlock } from '../../components/ui/flex'; import { Text } from '../../components/ui/text'; import { Spacer } from '../../components/ui/spacer'; import { IValueSet, useFhirServiceContext, useOrganizationServiceContext, useValueSetServiceContext } from '../../services'; import { Bundle, Organization } from 'fhir/r4'; import { FhirCode, FhirCodeableConcept, ProfileLayoutContainer } from '../../components'; import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'; import { usePracticeAddressApi } from '../../services/practiceAddressApi'; import { usePracticeAddressCheck } from '../../hooks/usePracticeAddressCheck'; import { isAddressReady, normalizeAddressLabel } from '../../utils/practiceAddress'; import PracticeAddressField from '../../components/PracticeAddress/PracticeAddressField'; import PracticeAddressConfirm from '../../components/PracticeAddress/PracticeAddressConfirm'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../../components/ui/dialog'; import { relayServerFailure } from '../../utils/fetchFailureMessage'; const ADDRESS_DEBOUNCE_MS = 300; import AdoptInstallationCode from './AdoptInstallationCode'; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../../components/ui/accordion'; import { rememberFounderLanding } from '../../utils/founderLanding'; import { storeSetupToken } from '../../utils/wpAdminApi'; interface OrganizationSetupPageProps { installationId: string; // Rereads the new practice in place, which moves the founder on to the first-administrator sign-up. onFounded: () => void | Promise; } const OrganizationSetupPage: React.FC = ({ installationId, onFounded }) => { const { transformToFhirOrganization, createWordPressOrganization, fetchWordPressOrganization, fetchOrganization, setOrganization, getOrganization } = useOrganizationServiceContext(); const { fetchTimezoneOptions } = useValueSetServiceContext(); const { unpackBundle } = useFhirServiceContext(); const practiceTypeOptions = usePracticeTypeOptions(); const [organizationId, setOrganizationId] = useState(null); const [loading, setLoading] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [pollingStatus, setPollingStatus] = useState(null); // value sets const [timezones, setTimezones] = useState([]); const spacerMargin = '24px'; const methods = useForm({ defaultValues: { name: '', subdomain: '', active: true, telecom: [ { system: 'phone', use: 'work', value: '' }, { system: 'email', use: 'work', value: '' } ], type: [] } }); const { control, handleSubmit, getValues, trigger } = methods; const { recommendAddress } = usePracticeAddressApi(); const practiceName = useWatch({ control, name: 'name' }) as string | undefined; const subdomain = useWatch({ control, name: 'subdomain' }) as string | undefined; const addressEdited = React.useRef(false); const { check: addressCheck, retry: retryAddressCheck } = usePracticeAddressCheck(subdomain ?? ''); // The founding values waiting on the permanent-address confirmation. const [confirming, setConfirming] = useState(); // The permanent address is its own step after the details; one form holds both, so nothing typed is lost. const [step, setStep] = useState<'details' | 'address'>('details'); useEffect(() => { if (addressEdited.current) return; const name = practiceName ?? ''; const timer = setTimeout(async () => { const label = await recommendAddress(name); if (!addressEdited.current && getValues('name').trim() === name.trim()) { methods.setValue('subdomain', label ?? ''); } }, ADDRESS_DEBOUNCE_MS); return () => clearTimeout(timer); }, [practiceName, recommendAddress, methods, getValues]); // Keyed by label inside the hook, so a slower answer for an earlier address never counts here. const addressReady = isAddressReady(subdomain ?? '', addressCheck); useEffect(() => { const loadValueSets = async () => { setLoading(true); try { const timezonesValueSet = await fetchTimezoneOptions(); setTimezones(timezonesValueSet) } catch (error) { console.error('Failed to load value sets:', error); } finally { setLoading(false); } }; loadValueSets(); }, [fetchTimezoneOptions]); const checkOrganizationIndexed = async (maxAttempts = 20): Promise => { let attempts = 0; let consecutiveSuccesses = 0; const requiredConsecutiveSuccesses = 3; const checkIndexed = () => { return new Promise((resolve) => { const attemptCheck = async () => { attempts++; setPollingStatus(`Checking if organization is ready... (Attempt ${attempts}/${maxAttempts})`); try { let organizationFound = false; if (organizationId) { const organization: Organization = await fetchOrganization(organizationId, installationId); organizationFound = !!organization; } else { const bundle: Bundle = await fetchWordPressOrganization(installationId); const organizations = unpackBundle(bundle); organizationFound = organizations && organizations.length > 0; } if (organizationFound) { consecutiveSuccesses++; if (consecutiveSuccesses >= requiredConsecutiveSuccesses) { setPollingStatus('Organization is ready! Redirecting...'); resolve(true); return; } } else { consecutiveSuccesses = 0; } if (attempts >= maxAttempts) { setPollingStatus('Maximum attempts reached. Organization may not be ready yet.'); resolve(false); return; } setTimeout(attemptCheck, 3000); } catch (error) { console.error('Error checking organization indexing:', error); consecutiveSuccesses = 0; setPollingStatus(`Error occurred, retrying... (Attempt ${attempts}/${maxAttempts})`); if (attempts >= maxAttempts) { setPollingStatus('Maximum attempts reached due to errors.'); resolve(false); return; } setTimeout(attemptCheck, 3000); } }; attemptCheck(); }); }; return await checkIndexed(); }; // The address is permanent, so founding waits for the admin to confirm it exactly. const onSubmit = (data: any) => { if (step === 'details') { setError(null); setStep('address'); return; } setConfirming(data); }; const found = async (data: any) => { setConfirming(undefined); if (!installationId) { setError('WordPress installation ID is missing'); return; } setSubmitting(true); setError(null); try { const organization: Organization = { ...transformToFhirOrganization(data), }; const { organization: response, setupToken } = await createWordPressOrganization(organization, installationId, getValues('subdomain')); if (response) { sessionStorage.setItem('akashere_setup_token', setupToken); // AND A SECOND COPY BEHIND THE WORDPRESS ADMIN LOGIN, because sessionStorage dies with // the tab and the organization does not. Once it exists it is tagged to this install and // this form never returns - so a token lost here left the practice with a practice it // could not get into. Awaited before the founder can move on. await storeSetupToken(response.id!, setupToken); setOrganizationId(response.id!); setOrganization(response); setPollingStatus('Organization created. Waiting for it to be ready...'); const isIndexed = await checkOrganizationIndexed(); if (isIndexed) { // Straight on to the first administrator's sign-up, in place; their first sign-in // lands on the practice dashboard with its setup guide. rememberFounderLanding(response.id!); await onFounded(); } else { setError('Organization was created but is not yet available. Please refresh the page in a few moments.'); setSubmitting(false); } } } catch (error) { console.error('Error creating organization:', error); const reason = (error as { body?: { reason?: unknown } } | null)?.body?.reason; if (typeof reason === 'string' && reason.startsWith('subdomain-')) { // Refused with nothing created: back to the address with everything typed still there. setStep('address'); setError(relayServerFailure(error, 'That address is no longer available. Choose another name.')); retryAddressCheck(); } else { // The server's own reason when it gave one: a daily founding limit says try tomorrow. setError(relayServerFailure(error, 'Failed to create organization. Please try again.')); } setSubmitting(false); } }; // One field on both steps: the address step shows the name its suggestion comes from. const nameField = (label: string) => ( ( <> {fieldState.error && ( Organization name is required )} )}/> ); return (
{/* Card 364 — always OUR mark here, so it names us. */} Akashic Health Records {step === 'details' ? ( <> Welcome to Akashic Health Records
Before you can use the application, you need to set up your organization.
) : (
Choose your practice web address Comes first, before the rest of practice setup
)} {error && ( <> {error} )} {step === 'address' ? ( <> addressReady || 'Choose an Akashic web address that is available', }} render={({ field }) => ( { addressEdited.current = value.trim() !== ''; field.onChange(value.toLowerCase()); }} check={addressCheck} suggested={!addressEdited.current} onRetry={retryAddressCheck} disabled={submitting} > {nameField('Practice name')} )}/> {pollingStatus && ( <> Creating your organization, this can take a few moments... )} {/* Continuation waits on an available, checked address; Review opens the exact confirmation. */}
) : ( <> {nameField('Organization Name *')} {/* main work phone */} { const email = getValues('telecom.1.value'); return !!value?.trim() || !!email?.trim() || 'Main phone or main email is required'; } }} render={({ field, fieldState }) => ( <> { field.onChange(value); trigger('telecom.1.value'); }}/> {fieldState.error && ( {fieldState.error.message} )} )}/> {/* main work email */} { const phone = getValues('telecom.0.value'); return !!value?.trim() || !!phone?.trim() || 'Main phone or main email is required'; } }} render={({ field, fieldState }) => ( <> { field.onChange(value); trigger('telecom.0.value'); }}/> {fieldState.error && ( {fieldState.error.message} )} )}/> {/* timezone */} {/* practice type */} {/* BLUE, not the green this sweep otherwise uses for submit — taking Spencer's LoginPage ruling (08-09) rather than re-deciding it. This is the third screen of the same pre-app onboarding flow and structurally that screen's twin: one full-width action, no cancel, and a `{busy ? : text}` body. At sweep time blue was also the conservative choice on pixels, since `btn btn-blue` and WordPress's is-primary resolved to the same value and only the 40px -> 36px height moved. Card 235 has since repointed `--primary` at the logo blue, so the colour does now move — the ruling was "blue", not a specific hex, and it still holds. No `disabled` added: the original gated only `isBusy`, which is cosmetic in WordPress, and disabling here would be a real behaviour change smuggled in under a styling sweep. */} )}
{/* BELOW the form, and OUTSIDE it. Below, because creating an organization is the path most installs take and a code box in front of it asks every new practice a question that does not apply to them. Outside, because a nested
is invalid HTML and the inner submit would be swallowed by the outer one - the code would appear to do nothing at all. */} {step === 'details' && ( <> {/* Closed by default: a practice setting itself up never needs it. */} Installing for a practice as a contractor? )}
!open && setConfirming(undefined)}> Save this permanent address? Comes first, before the rest of practice setup setConfirming(undefined)} onSave={() => void found(confirming)} />
); }; export default OrganizationSetupPage;