Files
appcakes-builds/src/pages/RecipientFormPage.tsx
T
2026-06-21 21:12:46 +00:00

683 lines
22 KiB
TypeScript

import React, { useEffect, useRef, useState } from 'react';
import {
IonButton,
IonButtons,
IonContent,
IonHeader,
IonIcon,
IonInput,
IonList,
IonModal,
IonPage,
IonTitle,
IonToolbar,
} from '@ionic/react';
import {
cameraOutline,
chevronBackOutline,
closeOutline,
} from 'ionicons/icons';
import { useHistory, useParams } from 'react-router-dom';
import { supabase } from '../supabase';
import { useAuth } from '../contexts/AuthContext';
import AvatarPicker from '../components/AvatarPicker';
import '../styles/recipients.css';
type Params = { id?: string };
type RecipientRow = {
id: string;
first_name: string;
last_name: string;
relationship: string;
country: string;
city: string;
mobile_number: string;
photo_path: string | null;
};
const countryOptions = [
{ name: 'Zimbabwe', flag: '🇿🇼', dialCode: '+263' },
{ name: 'South Africa', flag: '🇿🇦', dialCode: '+27' },
{ name: 'Zambia', flag: '🇿🇲', dialCode: '+260' },
{ name: 'Botswana', flag: '🇧🇼', dialCode: '+267' },
{ name: 'Mozambique', flag: '🇲🇿', dialCode: '+258' },
{ name: 'United Kingdom', flag: '🇬🇧', dialCode: '+44' },
{ name: 'United States', flag: '🇺🇸', dialCode: '+1' },
{ name: 'Canada', flag: '🇨🇦', dialCode: '+1' },
{ name: 'Australia', flag: '🇦🇺', dialCode: '+61' },
];
const locationOptionsByCountry: Record<
string,
Array<{
group: string;
options: Array<{ name: string; description: string }>;
}>
> = {
Zimbabwe: [
{
group: 'Major cities',
options: [
{ name: 'Harare', description: 'Capital city coverage' },
{ name: 'Bulawayo', description: 'City merchants and pharmacies' },
{ name: 'Mutare', description: 'Eastern Highlands coverage' },
{ name: 'Gweru', description: 'Midlands city coverage' },
],
},
{
group: 'Towns',
options: [
{ name: 'Chitungwiza', description: 'Harare metro support' },
{ name: 'Masvingo', description: 'Town and surrounding areas' },
{ name: 'Kwekwe', description: 'Supported collection points' },
{ name: 'Kadoma', description: 'Supported collection points' },
{ name: 'Victoria Falls', description: 'Town coverage' },
],
},
{
group: 'Rural districts',
options: [
{ name: 'Murehwa District', description: 'Rural collection support' },
{ name: 'Gokwe District', description: 'Rural collection support' },
{ name: 'Buhera District', description: 'Rural collection support' },
{ name: 'Zaka District', description: 'Rural collection support' },
{ name: 'Guruve District', description: 'Rural collection support' },
{
name: 'Tsholotsho District',
description: 'Rural collection support',
},
],
},
],
Zambia: [
{
group: 'Major cities',
options: [
{ name: 'Lusaka', description: 'Capital city coverage' },
{ name: 'Ndola', description: 'Copperbelt coverage' },
{ name: 'Kitwe', description: 'Copperbelt coverage' },
{ name: 'Livingstone', description: 'Town coverage' },
],
},
{
group: 'Rural districts',
options: [
{ name: 'Chongwe District', description: 'Rural collection support' },
{ name: 'Monze District', description: 'Rural collection support' },
],
},
],
Botswana: [
{
group: 'Cities and towns',
options: [
{ name: 'Gaborone', description: 'Capital city coverage' },
{ name: 'Francistown', description: 'Town coverage' },
{ name: 'Maun', description: 'Town coverage' },
],
},
{
group: 'Rural districts',
options: [
{ name: 'Kweneng District', description: 'Rural collection support' },
{ name: 'Central District', description: 'Rural collection support' },
],
},
],
};
const fallbackLocationGroups = [
{
group: 'Supported areas',
options: [
{ name: 'Main city', description: 'Available merchant coverage' },
{ name: 'Nearby town', description: 'Supported collection points' },
{ name: 'Rural district', description: 'Rural collection support' },
],
},
];
const getCountryMeta = (countryName: string) =>
countryOptions.find((option) => option.name === countryName) ??
countryOptions[0];
const getLocationGroups = (countryName: string) =>
locationOptionsByCountry[countryName] ?? fallbackLocationGroups;
const stripDialCode = (value: string, dialCode: string) => {
const normalizedValue = value.trim();
if (normalizedValue.startsWith(dialCode)) {
return normalizedValue.slice(dialCode.length).trimStart();
}
return normalizedValue;
};
const sanitizePhoneInput = (value: string) =>
value.replace(/[^\d\s()-]/g, '').replace(/\s{2,}/g, ' ');
const normalizePhoneInput = (value: string) =>
sanitizePhoneInput(value).replace(/^0+/, '');
const formatNameInput = (value: string) =>
value
.toLowerCase()
.replace(
/(^|[\s'-])([a-z])/g,
(_match, separator: string, letter: string) =>
`${separator}${letter.toUpperCase()}`
);
const RecipientFormPage: React.FC = () => {
const history = useHistory();
const { id } = useParams<Params>();
const { user } = useAuth();
const isEdit = Boolean(id);
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [relationship, setRelationship] = useState('');
const [country, setCountry] = useState('Zimbabwe');
const [city, setCity] = useState('');
const [mobileNumber, setMobileNumber] = useState('');
const [showCountrySheet, setShowCountrySheet] = useState(false);
const [showLocationSheet, setShowLocationSheet] = useState(false);
const selectedCountry = getCountryMeta(country);
const locationGroups = getLocationGroups(country);
const [photoFile, setPhotoFile] = useState<File | null>(null);
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
const previewObjectUrlRef = useRef<string | null>(null);
const [existingPhotoPath, setExistingPhotoPath] = useState<string | null>(
null
);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const firstNameInputRef = useRef<HTMLIonInputElement>(null);
const lastNameInputRef = useRef<HTMLIonInputElement>(null);
const relationshipInputRef = useRef<HTMLIonInputElement>(null);
const mobileInputRef = useRef<HTMLIonInputElement>(null);
useEffect(() => {
if (isEdit) {
void loadRecipient();
}
}, [id, isEdit]);
useEffect(() => {
return () => {
if (previewObjectUrlRef.current) {
URL.revokeObjectURL(previewObjectUrlRef.current);
}
};
}, []);
const showError = (message: string) => {
setError(message);
setTimeout(() => setError(null), 4000);
};
const loadRecipient = async () => {
if (!user || !id) return;
setLoading(true);
const { data, error: loadError } = await supabase
.from('recipients')
.select(
'id,first_name,last_name,relationship,country,city,mobile_number,photo_path'
)
.eq('id', id)
.eq('user_id', user.id)
.single();
if (loadError || !data) {
showError(loadError?.message ?? 'Recipient not found');
setLoading(false);
history.replace('/recipients');
return;
}
const recipient = data as RecipientRow;
setFirstName(recipient.first_name);
setLastName(recipient.last_name);
setRelationship(recipient.relationship);
setCountry(recipient.country);
setCity(recipient.city);
const recipientCountry = getCountryMeta(recipient.country);
setMobileNumber(
stripDialCode(recipient.mobile_number, recipientCountry.dialCode)
);
setExistingPhotoPath(recipient.photo_path);
if (recipient.photo_path) {
const { data: signed } = await supabase.storage
.from('recipient-photos')
.createSignedUrl(recipient.photo_path, 3600);
setPhotoPreview(signed?.signedUrl ?? null);
}
setLoading(false);
};
const handlePhotoChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (previewObjectUrlRef.current) {
URL.revokeObjectURL(previewObjectUrlRef.current);
}
const objectUrl = URL.createObjectURL(file);
previewObjectUrlRef.current = objectUrl;
setPhotoFile(file);
setPhotoPreview(objectUrl);
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!user) {
showError('Please sign in again to save this loved one');
return;
}
const payload = {
first_name: firstName.trim(),
last_name: lastName.trim(),
relationship: relationship.trim(),
country: country.trim(),
city: city.trim(),
mobile_number:
`${selectedCountry.dialCode} ${mobileNumber.trim()}`.trim(),
updated_at: new Date().toISOString(),
};
if (
!payload.first_name ||
!payload.last_name ||
!payload.relationship ||
!payload.country ||
!payload.city ||
!payload.mobile_number
) {
showError('Please complete all required fields');
return;
}
setSaving(true);
setError(null);
let photoPath = existingPhotoPath;
if (photoFile) {
const extension = photoFile.name.split('.').pop() || 'jpg';
photoPath = `${user.id}/${Date.now()}.${extension}`;
const { error: uploadError } = await supabase.storage
.from('recipient-photos')
.upload(photoPath, photoFile);
if (uploadError) {
showError(uploadError.message);
setSaving(false);
return;
}
}
if (isEdit && id) {
const { error: updateError } = await supabase
.from('recipients')
.update({ ...payload, photo_path: photoPath })
.eq('id', id)
.eq('user_id', user.id);
if (updateError) {
showError(updateError.message);
setSaving(false);
return;
}
setSaving(false);
history.push(`/recipients/${id}`);
return;
}
const { data, error: insertError } = await supabase
.from('recipients')
.insert({
user_id: user.id,
...payload,
photo_path: photoPath,
})
.select('id')
.single();
if (insertError || !data) {
showError(insertError?.message ?? 'Could not create recipient');
setSaving(false);
return;
}
setSaving(false);
history.replace(`/recipients/${data.id}`);
};
const initials = `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
return (
<IonPage style={{ backgroundColor: '#fafafa' }}>
<IonHeader className="ion-no-border">
<IonToolbar
style={
{
'--background': '#fafafa',
'--border-width': '0px',
'--color': '#111827',
} as React.CSSProperties
}
>
<IonButtons slot="start">
<IonButton
className="recipients-back-button"
fill="clear"
onClick={() =>
history.length > 1
? history.goBack()
: history.replace('/recipients')
}
aria-label="Go back"
>
<IonIcon icon={chevronBackOutline} slot="icon-only" />
</IonButton>
</IonButtons>
<IonTitle style={{ fontSize: '18px', fontWeight: 700 }}>
{isEdit ? 'Edit loved one' : 'Add loved one'}
</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent
style={
{
'--background': '#fafafa',
'--padding-start': '0px',
'--padding-end': '0px',
'--padding-top': '0px',
'--padding-bottom': 'calc(32px + var(--ion-safe-area-bottom, 0px))',
} as React.CSSProperties
}
>
<form onSubmit={handleSubmit}>
<div className="recipient-form-card">
<div className="rf-photo-picker">
<IonIcon icon={cameraOutline} style={{ display: 'none' }} />
<AvatarPicker
previewUrl={photoPreview}
onFileChange={handlePhotoChange}
initials={initials || undefined}
disabled={loading || saving}
/>
</div>
<button
type="button"
className="rf-input-shell rf-text-shell"
onClick={() => void firstNameInputRef.current?.setFocus()}
disabled={loading || saving}
aria-label="Enter first name"
>
<span className="rf-field-label">First name</span>
<IonInput
ref={firstNameInputRef}
className="rf-field"
type="text"
placeholder="Sarah"
value={firstName}
disabled={loading || saving}
onIonInput={(event) =>
setFirstName(formatNameInput(event.detail.value ?? ''))
}
/>
</button>
<button
type="button"
className="rf-input-shell rf-text-shell"
onClick={() => void lastNameInputRef.current?.setFocus()}
disabled={loading || saving}
aria-label="Enter last name"
>
<span className="rf-field-label">Last name</span>
<IonInput
ref={lastNameInputRef}
className="rf-field"
type="text"
placeholder="Moyo"
value={lastName}
disabled={loading || saving}
onIonInput={(event) =>
setLastName(formatNameInput(event.detail.value ?? ''))
}
/>
</button>
<button
type="button"
className="rf-input-shell rf-text-shell"
onClick={() => void relationshipInputRef.current?.setFocus()}
disabled={loading || saving}
aria-label="Enter relationship"
>
<span className="rf-field-label">Relationship</span>
<IonInput
ref={relationshipInputRef}
className="rf-field"
type="text"
placeholder="Mum, Brother, Aunt"
value={relationship}
disabled={loading || saving}
onIonInput={(event) =>
setRelationship(formatNameInput(event.detail.value ?? ''))
}
/>
</button>
<button
type="button"
className="rf-country-trigger"
onClick={() => setShowCountrySheet(true)}
disabled={loading || saving}
aria-label="Select country"
>
<span className="rf-country-label">Country</span>
<span className="rf-country-value-row">
<span className="rf-country-value">
{`${selectedCountry.flag} ${selectedCountry.name} (${selectedCountry.dialCode})`}
</span>
<IonIcon
icon={chevronBackOutline}
className="rf-country-chevron"
/>
</span>
</button>
<button
type="button"
className="rf-country-trigger"
onClick={() => setShowLocationSheet(true)}
disabled={loading || saving}
aria-label="Select recipient location"
>
<span className="rf-country-label">Location</span>
<span className="rf-country-value-row">
<span
className={`rf-country-value${city ? '' : ' is-placeholder'}`}
>
{city || 'City, town, or rural area'}
</span>
<IonIcon
icon={chevronBackOutline}
className="rf-country-chevron"
/>
</span>
</button>
<button
type="button"
className="rf-input-shell rf-phone-shell"
onClick={() => void mobileInputRef.current?.setFocus()}
disabled={loading || saving}
aria-label="Enter mobile number"
>
<span className="rf-field-label">Mobile number</span>
<div className="rf-phone-row">
<div className="rf-phone-prefix" aria-hidden="true">
<span className="rf-phone-flag">{selectedCountry.flag}</span>
<span className="rf-phone-code">
{selectedCountry.dialCode}
</span>
</div>
<IonInput
ref={mobileInputRef}
className="rf-field rf-phone-field"
type="tel"
inputMode="tel"
placeholder="77 123 4567"
value={mobileNumber}
disabled={loading || saving}
onIonInput={(event) =>
setMobileNumber(
normalizePhoneInput(event.detail.value ?? '')
)
}
/>
</div>
</button>
{error && (
<p style={{ margin: 0, color: '#dc2626', fontSize: '13px' }}>
{error}
</p>
)}
</div>
<div className="rf-actions">
<IonButton
type="submit"
expand="block"
className="rf-submit-btn"
disabled={loading || saving}
>
{saving ? 'Saving...' : isEdit ? 'Save changes' : 'Add loved one'}
</IonButton>
</div>
</form>
<IonModal
isOpen={showCountrySheet}
onDidDismiss={() => setShowCountrySheet(false)}
initialBreakpoint={1}
breakpoints={[0, 1]}
handle={true}
className="country-sheet-modal"
>
<div className="country-sheet-shell">
<div className="country-sheet-header">
<h2>Select country</h2>
<IonButton
fill="clear"
className="country-sheet-close"
onClick={() => setShowCountrySheet(false)}
aria-label="Close country selector"
>
<IonIcon icon={closeOutline} slot="icon-only" />
</IonButton>
</div>
<div className="country-sheet-scroll">
<IonList lines="none" className="country-sheet-list">
{countryOptions.map((option) => {
const isSelected = option.name === country;
return (
<button
key={option.name}
type="button"
className={`country-sheet-option${isSelected ? ' is-selected' : ''}`}
onClick={() => {
setCountry(option.name);
setCity('');
setShowCountrySheet(false);
}}
>
<span className="country-sheet-option-text">
<span className="country-sheet-flag">
{option.flag}
</span>
<span className="country-sheet-name">
{option.name}
</span>
<span className="country-sheet-code">
({option.dialCode})
</span>
</span>
</button>
);
})}
</IonList>
</div>
</div>
</IonModal>
<IonModal
isOpen={showLocationSheet}
onDidDismiss={() => setShowLocationSheet(false)}
initialBreakpoint={1}
breakpoints={[0, 1]}
handle={true}
className="country-sheet-modal"
>
<div className="country-sheet-shell">
<div className="country-sheet-header">
<h2>Select location</h2>
<IonButton
fill="clear"
className="country-sheet-close"
onClick={() => setShowLocationSheet(false)}
aria-label="Close location selector"
>
<IonIcon icon={closeOutline} slot="icon-only" />
</IonButton>
</div>
<div className="country-sheet-scroll">
<div className="location-sheet-helper">
Choose a city, town, or rural district where support collection
is available.
</div>
{locationGroups.map((group) => (
<div key={group.group} className="location-sheet-group">
<p className="location-sheet-group-title">{group.group}</p>
{group.options.map((option) => {
const isSelected = option.name === city;
return (
<button
key={option.name}
type="button"
className={`country-sheet-option location-sheet-option${isSelected ? ' is-selected' : ''}`}
onClick={() => {
setCity(option.name);
setShowLocationSheet(false);
}}
>
<span className="location-sheet-option-text">
<span className="location-sheet-name">
{option.name}
</span>
<span className="location-sheet-description">
{option.description}
</span>
</span>
</button>
);
})}
</div>
))}
</div>
</div>
</IonModal>
</IonContent>
</IonPage>
);
};
export default RecipientFormPage;