Files
Parsons/src/components/organisms/ArrangementDialog/ArrangementDialog.tsx
Richie 1e73cd2a89 Batch 5: ArrangementDialog organism — two-step modal (D-E)
New organism consolidating PreviewStep + AuthGateStep into a
single MUI Dialog with two internal steps:

- Step 1 (preview): ProviderCardCompact, package summary with
  sections/items/total, "What happens next" checklist, Continue CTA
  (pre-planning adds "Explore other options")
- Step 2 (auth): SSO buttons (Google/Microsoft), email entry,
  progressive disclosure for details (name, phone, contact pref),
  verification code, terms

Parent controls step state + auth form values. Dialog has back
arrow to return from auth → preview, close button on both steps.

Stories: Default (full flow), AuthStep, AuthDetails, PrePlanning

PreviewStep and AuthGateStep kept for now — to be deprecated
once ArrangementDialog is wired into the flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:33:01 +11:00

542 lines
18 KiB
TypeScript

import React from 'react';
import Box from '@mui/material/Box';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import IconButton from '@mui/material/IconButton';
import TextField from '@mui/material/TextField';
import MenuItem from '@mui/material/MenuItem';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import CloseIcon from '@mui/icons-material/Close';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import GoogleIcon from '@mui/icons-material/Google';
import MicrosoftIcon from '@mui/icons-material/Window';
import type { SxProps, Theme } from '@mui/material/styles';
import type { PackageSection } from '../PackageDetail';
import { ProviderCardCompact } from '../../molecules/ProviderCardCompact';
import { Collapse } from '../../atoms/Collapse';
import { Typography } from '../../atoms/Typography';
import { Button } from '../../atoms/Button';
import { Divider } from '../../atoms/Divider';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Which step of the dialog is active */
export type DialogStep = 'preview' | 'auth';
/** Provider summary */
export interface DialogProvider {
name: string;
location: string;
imageUrl?: string;
rating?: number;
reviewCount?: number;
}
/** Package summary for preview */
export interface DialogPackage {
name: string;
price: number;
sections: PackageSection[];
total?: number;
}
/** A step in the "What's next?" checklist */
export interface NextStepItem {
number: number;
label: string;
}
/** Auth sub-step */
export type AuthSubStep = 'email' | 'details' | 'verify';
/** Contact preference */
export type ContactPreference = 'call_anytime' | 'email_preferred' | 'email_only';
/** Auth form values */
export interface AuthValues {
subStep: AuthSubStep;
email: string;
firstName: string;
lastName: string;
phone: string;
contactPreference: ContactPreference;
verificationCode: string;
}
/** Auth field errors */
export interface AuthErrors {
email?: string;
firstName?: string;
lastName?: string;
phone?: string;
verificationCode?: string;
}
/** Props for the ArrangementDialog */
export interface ArrangementDialogProps {
/** Whether the dialog is open */
open: boolean;
/** Callback when the dialog is closed */
onClose: () => void;
/** Current dialog step */
step: DialogStep;
/** Callback when the step changes */
onStepChange: (step: DialogStep) => void;
// ─── Preview data ───
/** Provider summary */
provider: DialogProvider;
/** Selected package */
selectedPackage: DialogPackage;
/** What's next checklist */
nextSteps?: NextStepItem[];
/** Pre-planning flow */
isPrePlanning?: boolean;
/** Callback for "Explore other options" (pre-planning) */
onExplore?: () => void;
// ─── Auth data ───
/** Auth form values */
authValues: AuthValues;
/** Callback when auth values change */
onAuthChange: (values: AuthValues) => void;
/** Auth field errors */
authErrors?: AuthErrors;
/** Callback for Google SSO */
onGoogleSSO?: () => void;
/** Callback for Microsoft SSO */
onMicrosoftSSO?: () => void;
// ─── Actions ───
/** Callback when the final auth CTA is clicked */
onContinue: () => void;
/** Loading state for CTAs */
loading?: boolean;
/** MUI sx prop */
sx?: SxProps<Theme>;
}
// ─── Constants ──────────────────────────────────────────────────────────────
const DEFAULT_NEXT_STEPS: NextStepItem[] = [
{ number: 1, label: 'Create your account to save your selections' },
{ number: 2, label: 'Choose a date and time for the service' },
{ number: 3, label: 'Select a venue' },
{ number: 4, label: 'Choose a coffin' },
{ number: 5, label: 'Review and confirm your arrangement' },
];
// ─── Helpers ────────────────────────────────────────────────────────────────
function getAuthCTALabel(subStep: AuthSubStep): string {
switch (subStep) {
case 'email':
return 'Continue with email';
case 'details':
return 'Continue';
case 'verify':
return 'Verify and continue';
}
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Two-step arrangement dialog (D-E).
*
* Consolidates PreviewStep + AuthGateStep into a single modal:
* - **Step 1 (preview):** Package summary, provider info, "What's next" checklist
* - **Step 2 (auth):** SSO buttons, email entry, details, verification
*
* The dialog is opened after a user selects a package (from PackagesStep).
* The parent controls which step is shown and manages auth form state.
*
* Pure presentation component — props in, callbacks out.
*/
export const ArrangementDialog: React.FC<ArrangementDialogProps> = ({
open,
onClose,
step,
onStepChange,
provider,
selectedPackage,
nextSteps = DEFAULT_NEXT_STEPS,
isPrePlanning = false,
onExplore,
authValues,
onAuthChange,
authErrors,
onGoogleSSO,
onMicrosoftSSO,
onContinue,
loading = false,
sx,
}) => {
const isEmailOnly = authValues.contactPreference === 'email_only';
const handleAuthField = (field: keyof AuthValues, value: string) => {
onAuthChange({ ...authValues, [field]: value });
};
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
scroll="body"
aria-labelledby="arrangement-dialog-title"
sx={sx}
PaperProps={{
sx: { borderRadius: 3 },
}}
>
{/* ─── Header ─── */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 3,
pt: 2.5,
pb: 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{step === 'auth' && (
<IconButton
size="small"
onClick={() => onStepChange('preview')}
aria-label="Back to preview"
>
<ArrowBackIcon fontSize="small" />
</IconButton>
)}
<Typography id="arrangement-dialog-title" variant="h5">
{step === 'preview' ? 'Your selected package' : 'Save your plan'}
</Typography>
</Box>
<IconButton size="small" onClick={onClose} aria-label="Close">
<CloseIcon fontSize="small" />
</IconButton>
</Box>
<DialogContent sx={{ px: 3, pb: 3 }}>
{/* ═══════════ Step 1: Preview ═══════════ */}
{step === 'preview' && (
<Box>
{/* Provider */}
<Box sx={{ mb: 3 }}>
<ProviderCardCompact
name={provider.name}
location={provider.location}
imageUrl={provider.imageUrl}
rating={provider.rating}
reviewCount={provider.reviewCount}
/>
</Box>
{/* Package summary */}
<Box
sx={{
bgcolor: 'var(--fa-color-brand-50)',
borderRadius: 2,
p: 2.5,
mb: 3,
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
mb: 2,
}}
>
<Typography variant="h6">{selectedPackage.name}</Typography>
<Typography variant="h6" color="primary">
${(selectedPackage.total ?? selectedPackage.price).toLocaleString('en-AU')}
</Typography>
</Box>
{selectedPackage.sections.map((section) => (
<Box key={section.heading} sx={{ mb: 1.5 }}>
<Typography variant="labelSm" color="text.secondary" sx={{ mb: 0.5 }}>
{section.heading}
</Typography>
{section.items.map((item) => (
<Typography key={item.name} variant="body2" sx={{ pl: 1 }}>
{item.name}
</Typography>
))}
</Box>
))}
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
You&apos;ll be able to customise everything in the next steps.
</Typography>
{/* What's next */}
<Box sx={{ mb: 3 }}>
<Typography variant="h6" sx={{ mb: 1.5 }}>
What happens next
</Typography>
<List disablePadding>
{nextSteps.map((s) => (
<ListItem key={s.number} disablePadding sx={{ mb: 1, alignItems: 'flex-start' }}>
<ListItemIcon sx={{ minWidth: 36, mt: 0.25 }}>
<Box
sx={{
width: 24,
height: 24,
borderRadius: '50%',
bgcolor: 'var(--fa-color-brand-100)',
color: 'var(--fa-color-brand-700)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '0.75rem',
fontWeight: 600,
}}
>
{s.number}
</Box>
</ListItemIcon>
<ListItemText
primary={s.label}
primaryTypographyProps={{ variant: 'body2', color: 'text.primary' }}
/>
</ListItem>
))}
</List>
</Box>
<Divider sx={{ mb: 3 }} />
{/* CTAs */}
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
gap: 2,
justifyContent: 'flex-end',
}}
>
{isPrePlanning && onExplore && (
<Button variant="outlined" color="secondary" size="large" onClick={onExplore}>
Explore other options
</Button>
)}
<Button
variant="contained"
size="large"
onClick={() => onStepChange('auth')}
loading={loading}
>
Continue with this package
</Button>
</Box>
</Box>
)}
{/* ═══════════ Step 2: Auth ═══════════ */}
{step === 'auth' && (
<Box
component="form"
noValidate
aria-busy={loading}
onSubmit={(e: React.FormEvent) => {
e.preventDefault();
if (!loading) onContinue();
}}
>
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
{isPrePlanning
? 'Save your plan to return and update it anytime.'
: 'We need a few details so a funeral arranger can help you with the next steps.'}
</Typography>
{/* SSO buttons */}
<Box
sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, mb: 3 }}
role="group"
aria-label="Sign in options"
>
<Button
variant="outlined"
color="secondary"
size="large"
fullWidth
startIcon={<GoogleIcon />}
onClick={onGoogleSSO}
type="button"
>
Continue with Google
</Button>
<Button
variant="outlined"
color="secondary"
size="large"
fullWidth
startIcon={<MicrosoftIcon />}
onClick={onMicrosoftSSO}
type="button"
>
Continue with Microsoft
</Button>
</Box>
<Divider sx={{ my: 3 }}>
<Typography variant="body2" color="text.secondary">
or
</Typography>
</Divider>
{/* Email */}
<TextField
label="Your email address"
type="email"
value={authValues.email}
onChange={(e) => handleAuthField('email', e.target.value)}
error={!!authErrors?.email}
helperText={authErrors?.email}
placeholder="you@example.com"
autoComplete="email"
inputMode="email"
fullWidth
required
disabled={authValues.subStep !== 'email'}
sx={{ mb: 3 }}
/>
{/* Details (after email) */}
<Collapse in={authValues.subStep === 'details' || authValues.subStep === 'verify'}>
<Box
sx={{ display: 'flex', flexDirection: 'column', gap: 2.5, mb: 3 }}
role="group"
aria-label="Your details"
>
<Typography variant="labelLg" component="h2" sx={{ mb: 0.5 }}>
A few details to save your plan
</Typography>
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, gap: 2 }}>
<TextField
label="First name"
value={authValues.firstName}
onChange={(e) => handleAuthField('firstName', e.target.value)}
error={!!authErrors?.firstName}
helperText={authErrors?.firstName}
autoComplete="given-name"
fullWidth
required
disabled={authValues.subStep === 'verify'}
/>
<TextField
label="Last name"
value={authValues.lastName}
onChange={(e) => handleAuthField('lastName', e.target.value)}
error={!!authErrors?.lastName}
helperText={authErrors?.lastName}
autoComplete="family-name"
fullWidth
required
disabled={authValues.subStep === 'verify'}
/>
</Box>
<TextField
label={isEmailOnly ? 'Phone (optional)' : 'Best number to reach you'}
type="tel"
value={authValues.phone}
onChange={(e) => handleAuthField('phone', e.target.value)}
error={!!authErrors?.phone}
helperText={authErrors?.phone}
placeholder="e.g. 0412 345 678"
autoComplete="tel"
inputMode="tel"
fullWidth
required={!isEmailOnly}
disabled={authValues.subStep === 'verify'}
/>
<TextField
select
label="Contact preference"
value={authValues.contactPreference}
onChange={(e) => handleAuthField('contactPreference', e.target.value)}
fullWidth
disabled={authValues.subStep === 'verify'}
>
<MenuItem value="call_anytime">Call me anytime</MenuItem>
<MenuItem value="email_preferred">Email is preferred</MenuItem>
<MenuItem value="email_only">Only contact by email</MenuItem>
</TextField>
</Box>
</Collapse>
{/* Verification */}
<Collapse in={authValues.subStep === 'verify'}>
<Box sx={{ mb: 3 }} role="group" aria-label="Email verification">
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
We&apos;ve sent a 6-digit code to <strong>{authValues.email}</strong>. Please
enter it below.
</Typography>
<TextField
label="Verification code"
value={authValues.verificationCode}
onChange={(e) => handleAuthField('verificationCode', e.target.value)}
error={!!authErrors?.verificationCode}
helperText={
authErrors?.verificationCode || 'Check your email for the 6-digit code'
}
placeholder="Enter 6-digit code"
autoComplete="one-time-code"
inputMode="numeric"
fullWidth
required
/>
</Box>
</Collapse>
{/* Terms */}
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 3 }}>
By continuing, you agree to the{' '}
<Box
component="a"
href="#"
sx={{
color: 'var(--fa-color-text-brand)',
textDecoration: 'underline',
fontSize: 'inherit',
}}
>
terms and conditions
</Box>
.
</Typography>
<Divider sx={{ my: 3 }} />
{/* CTA */}
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button type="submit" variant="contained" size="large" loading={loading}>
{getAuthCTALabel(authValues.subStep)}
</Button>
</Box>
</Box>
)}
</DialogContent>
</Dialog>
);
};
ArrangementDialog.displayName = 'ArrangementDialog';
export default ArrangementDialog;