import React, { useMemo, useState } from 'react'; import { IonContent, IonIcon, IonPage, useIonViewWillEnter, } from '@ionic/react'; import { useHistory } from 'react-router-dom'; import { chevronForwardOutline, ellipsisVertical, flashOutline, heart, notificationsOutline, phonePortraitOutline, checkmarkCircleOutline, timeOutline, wifiOutline, medkitOutline, } from 'ionicons/icons'; import sarahImage from '../assets/sarah.jpg'; import momImage from '../assets/mom.jpg'; import dadImage from '../assets/dad.jpg'; import basketIcon from '../assets/basket.png'; import { setStatusBarStyle, Style } from '../utils/statusBar'; import { supabase } from '../supabase'; import { useAuth } from '../contexts/AuthContext'; import DashboardSkeleton from '../components/DashboardSkeleton'; import { formatMoney } from '../utils/formatMoney'; import { buildCacheKey, readCache, writeCache } from '../utils/localCache'; import '../styles/home.css'; type ActivityStatusTone = 'success' | 'warning' | 'info' | 'partial'; type HomeAlert = { id: string; scheduleId: string; recipientId: string; serviceType: string; title: string; subtitle: string; icon: string; iconType?: 'ion' | 'image'; tone: 'grocery' | 'medication' | 'airtime' | 'electricity' | 'default'; cta: string; }; type HomeRenderableIcon = { iconType: 'ion' | 'image'; icon?: any; iconSrc?: string; iconAlt?: string; }; type SupportCategoryTone = 'grocery' | 'medication' | 'airtime' | 'electricity'; const quickActions: Array< HomeRenderableIcon & { label: string; helper: string; tone: SupportCategoryTone; } > = [ { label: 'Grocery', helper: 'Voucher for partner stores', iconType: 'image', iconSrc: basketIcon, iconAlt: 'Groceries', tone: 'grocery', }, { label: 'Medication', helper: 'Voucher for trusted pharmacies', iconType: 'ion', icon: medkitOutline, tone: 'medication', }, { label: 'Airtime & Data', helper: 'Top up mobile credit quickly', iconType: 'ion', icon: wifiOutline, tone: 'airtime', }, { label: 'Electricity', helper: 'Send ZESA meter support', iconType: 'ion', icon: flashOutline, tone: 'electricity', }, ]; const renderHomeIcon = (item: HomeRenderableIcon) => { if (item.iconType === 'image' && item.iconSrc) { const isPill = item.iconSrc.includes('pill'); const isBasket = item.iconSrc.includes('basket'); return ( {item.iconAlt ); } return item.icon ? : null; }; const HomePage: React.FC = () => { const history = useHistory(); const { user, profileStatus } = useAuth(); const [alerts, setAlerts] = useState([]); const [alertsLoading, setAlertsLoading] = useState(true); const [alertsError, setAlertsError] = useState(null); const [lovedOnes, setLovedOnes] = useState([]); const [lovedOnesLoading, setLovedOnesLoading] = useState(true); const [lovedOnesError, setLovedOnesError] = useState(null); const [activities, setActivities] = useState([]); const [activitiesLoading, setActivitiesLoading] = useState(true); const [activitiesError, setActivitiesError] = useState(null); const [monthlySentTotal, setMonthlySentTotal] = useState(0); const formatRelativeSupportDate = (value?: string | null) => { if (!value) return 'No support yet'; const date = new Date(value); if (Number.isNaN(date.getTime())) return 'Recent support'; const today = new Date(); const startOfToday = new Date( today.getFullYear(), today.getMonth(), today.getDate() ); const startOfDate = new Date( date.getFullYear(), date.getMonth(), date.getDate() ); const diffDays = Math.floor( (startOfToday.getTime() - startOfDate.getTime()) / 86_400_000 ); if (diffDays <= 0) return 'Last support: today'; if (diffDays === 1) return 'Last support: yesterday'; if (diffDays < 30) return `Last support: ${diffDays} days ago`; return `Last support: ${date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', })}`; }; const getSeededRecipientImage = ( firstName?: string | null, photoPath?: string | null ) => { const normalizedPath = photoPath?.trim().toLowerCase(); if (normalizedPath === 'mom.jpg' || normalizedPath === 'mum.jpg') { return momImage; } if (normalizedPath === 'dad.jpg' || normalizedPath === 'father.jpg') { return dadImage; } const normalizedName = firstName?.trim().toLowerCase(); if (normalizedName === 'mum' || normalizedName === 'mom') return momImage; if (normalizedName === 'dad' || normalizedName === 'father') { return dadImage; } return null; }; const getRecipientAvatarUrl = async ( photoPath?: string | null, firstName?: string | null ) => { const seededImage = getSeededRecipientImage(firstName, photoPath); if (seededImage) return seededImage; if (photoPath) { const { data: urlData } = await supabase.storage .from('recipient-photos') .createSignedUrl(photoPath, 3600); if (urlData?.signedUrl) return urlData.signedUrl; } return null; }; const formatAlertSubtitle = (firstName: string, dueAt?: string | null) => { if (!dueAt) return `For ${firstName}`; const date = new Date(dueAt); if (Number.isNaN(date.getTime())) return `For ${firstName}`; const today = new Date(); const startOfToday = new Date( today.getFullYear(), today.getMonth(), today.getDate() ); const startOfDate = new Date( date.getFullYear(), date.getMonth(), date.getDate() ); const diffDays = Math.round( (startOfDate.getTime() - startOfToday.getTime()) / 86_400_000 ); if (diffDays < 0) return `For ${firstName} · overdue`; if (diffDays === 0) return `For ${firstName} · today`; if (diffDays === 1) return `For ${firstName} · tomorrow`; return `For ${firstName} · in ${diffDays} days`; }; const getNormalizedServiceType = (value: string) => { const normalized = value.toLowerCase(); if (normalized.includes('grocery')) return 'grocery'; if (normalized.includes('medication') || normalized.includes('pharmacy')) { return 'medication'; } if (normalized.includes('airtime') || normalized.includes('data')) { return 'airtime'; } if (normalized.includes('electricity') || normalized.includes('zesa')) { return 'electricity'; } return normalized; }; const getAlertPresentation = (serviceType: string) => { switch (serviceType) { case 'grocery': return { title: 'Grocery', icon: basketIcon, tone: 'grocery' as const, cta: 'Send support', }; case 'medication': return { title: 'Medication', icon: medkitOutline, tone: 'medication' as const, cta: 'Send support', }; case 'airtime': return { title: 'Airtime', icon: phonePortraitOutline, tone: 'airtime' as const, cta: 'Top up', }; case 'electricity': return { title: 'Electricity', icon: flashOutline, tone: 'electricity' as const, cta: 'Top up', }; default: return { title: 'Support', icon: timeOutline, tone: 'default' as const, cta: 'Send support', }; } }; const getActivityStatusTone = ( serviceType: string, status: string ): ActivityStatusTone => { const normalizedStatus = status.toLowerCase(); const normalizedService = serviceType.toLowerCase(); const isVoucherService = normalizedService === 'grocery' || normalizedService === 'medication'; if (isVoucherService) { if (normalizedStatus.includes('redeemed')) return 'success'; if (normalizedStatus.includes('partial')) return 'partial'; return 'warning'; } if ( normalizedStatus.includes('completed') || normalizedStatus.includes('active') ) { return 'success'; } if ( normalizedStatus.includes('delivered') || normalizedStatus.includes('created') || normalizedStatus === 'ready_for_redemption' ) { return 'warning'; } return 'info'; }; const getActivityStatusLabel = (serviceType: string, status: string) => { const normalizedService = serviceType.toLowerCase(); const normalizedStatus = status.toLowerCase(); const isVoucherService = normalizedService === 'grocery' || normalizedService === 'medication'; if (isVoucherService) { if (normalizedStatus.includes('redeemed')) return 'redeemed'; if (normalizedStatus.includes('partial')) return 'partial'; return 'created'; } if (normalizedStatus === 'ready_for_redemption') return 'created'; return normalizedStatus.replace(/_/g, ' '); }; useIonViewWillEnter(() => { setStatusBarStyle(Style.Light); loadHomePageData(); }); const loadHomePageData = async () => { const activeUserId = user?.id ?? '00000000-0000-0000-0000-000000000000'; const now = new Date(); const monthStart = new Date(now.getFullYear(), now.getMonth(), 1) .toISOString() .slice(0, 10); const nextMonthStart = new Date(now.getFullYear(), now.getMonth() + 1, 1) .toISOString() .slice(0, 10); let freshMonthlySentTotal = 0; let freshAlerts: HomeAlert[] = []; let freshLovedOnes: any[] = []; let freshActivities: any[] = []; const cacheKey = buildCacheKey(activeUserId, 'homeSnapshot'); let hasCache = false; try { const cached = await readCache(cacheKey); if ( cached && typeof cached === 'object' && Array.isArray(cached.alerts) && Array.isArray(cached.lovedOnes) && Array.isArray(cached.activities) && typeof cached.monthlySentTotal === 'number' ) { freshAlerts = cached.alerts; freshLovedOnes = cached.lovedOnes.map((person: any) => ({ ...person, statuses: Array.isArray(person.statuses) ? person.statuses.slice(0, 3) : [], })); freshActivities = cached.activities; freshMonthlySentTotal = cached.monthlySentTotal; setAlerts(cached.alerts); setLovedOnes(freshLovedOnes); setActivities(cached.activities); setMonthlySentTotal(cached.monthlySentTotal); setAlertsLoading(false); setLovedOnesLoading(false); setActivitiesLoading(false); hasCache = true; } } catch (err) { console.error('[home cache] error reading', err); } if (!hasCache) { setAlertsLoading(true); setLovedOnesLoading(true); setActivitiesLoading(true); } setAlertsError(null); setLovedOnesError(null); setActivitiesError(null); const { data: monthlyOrdersData, error: monthlyOrdersError } = await supabase .from('support_orders') .select('amount, created_at') .eq('user_id', activeUserId) .gte('created_at', monthStart) .lt('created_at', nextMonthStart); if (monthlyOrdersError) { console.error('[home monthly sent]', monthlyOrdersError); } else { const total = (monthlyOrdersData || []).reduce( (sum, order) => sum + Number(order.amount || 0), 0 ); freshMonthlySentTotal = total; setMonthlySentTotal(total); } const { data: alertsData, error: alertsErr } = await supabase .from('care_alerts') .select('id, title, body, severity, service_type, due_at, recipient_id') .eq('user_id', activeUserId) .is('dismissed_at', null) .order('due_at', { ascending: true, nullsFirst: false }) .limit(5); if (alertsErr) { console.error('[home care alerts]', alertsErr); setAlertsError('Could not load care alerts'); setAlerts([]); } else if (alertsData) { const recipientIds = Array.from( new Set(alertsData.map((alert) => alert.recipient_id).filter(Boolean)) ); const recipientNameById = new Map(); if (recipientIds.length > 0) { const { data: alertRecipients, error: alertRecipientsError } = await supabase .from('recipients') .select('id, first_name') .in('id', recipientIds); if (alertRecipientsError) { console.error('[home care alert recipients]', alertRecipientsError); } (alertRecipients || []).forEach((recipient) => { recipientNameById.set(recipient.id, recipient.first_name); }); } freshAlerts = alertsData.map((alert) => { const presentation = getAlertPresentation( alert.service_type || 'default' ); const firstName = recipientNameById.get(alert.recipient_id) || 'Loved one'; return { id: alert.id, scheduleId: alert.id, recipientId: alert.recipient_id, serviceType: alert.service_type || 'support', title: presentation.title, subtitle: formatAlertSubtitle(firstName, alert.due_at), icon: presentation.icon, iconType: presentation.icon === basketIcon ? 'image' : 'ion', tone: presentation.tone, cta: presentation.cta, }; }); setAlerts(freshAlerts); } else { freshAlerts = []; setAlerts([]); } setAlertsLoading(false); const { data: recipientsData, error: recipientsErr } = await supabase .from('recipients') .select( 'id, first_name, last_name, country, city, photo_path, relationship' ) .eq('user_id', activeUserId); if (recipientsErr) { setLovedOnesError('Could not load loved ones'); } else if (recipientsData) { const mappedLovedOnes = await Promise.all( recipientsData.map(async (rec: any) => { const avatarUrl = await getRecipientAvatarUrl( rec.photo_path, rec.first_name ); const { data: recentOrders } = await supabase .from('support_orders') .select('service_type, status, created_at, merchants(name)') .eq('recipient_id', rec.id) .eq('user_id', activeUserId) .order('created_at', { ascending: false }) .limit(3); const orderedStatuses = [...(recentOrders || [])] .sort( (a: any, b: any) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() ) .slice(0, 3); const primaryOrder = orderedStatuses[0]; const statuses = orderedStatuses.slice(0, 3).map((order: any) => { const tone = getAlertPresentation(order.service_type).tone; const isSuccess = order.status === 'redeemed' || order.status === 'completed'; let detail = getActivityStatusLabel( order.service_type, order.status ); if (order.status === 'redeemed' && order.merchants?.name) { detail = `redeemed • ${order.merchants.name}`; } return { iconType: tone === 'grocery' ? 'image' : 'ion', iconSrc: tone === 'grocery' ? basketIcon : undefined, icon: tone !== 'grocery' ? getAlertPresentation(order.service_type).icon : undefined, iconAlt: order.service_type, label: order.service_type === 'grocery' ? 'Grocery voucher' : order.service_type === 'medication' ? 'Medication voucher' : order.service_type === 'electricity' ? 'Electricity' : order.service_type === 'airtime' ? 'Airtime' : `${order.service_type.charAt(0).toUpperCase()}${order.service_type.slice(1)}`, detail, status: isSuccess ? 'success' : 'warning', iconTone: tone, serviceType: order.service_type, }; }); const primaryTone = primaryOrder ? getAlertPresentation(primaryOrder.service_type).tone : 'grocery'; const repeatCta = primaryOrder?.service_type === 'medication' ? 'Send Medication' : primaryOrder?.service_type === 'airtime' ? 'Top Up Again' : primaryOrder?.service_type === 'electricity' ? 'Send Electricity' : 'Send Groceries'; return { id: rec.id, name: rec.first_name, emoji: rec.relationship === 'Mother' || rec.first_name === 'Mum' ? '💜' : '💚', location: `${rec.city || ''}, ${rec.country || ''}`.replace( /^, | , $/g, '' ), cardTone: rec.first_name === 'Mum' ? 'lavender' : 'mint', avatar: avatarUrl, fallbackInitial: rec.first_name?.charAt(0)?.toUpperCase() ?? '?', lastSupportLabel: formatRelativeSupportDate( primaryOrder?.created_at ), repeatCta, repeatAction: { iconType: primaryTone === 'grocery' ? 'image' : 'ion', iconSrc: primaryTone === 'grocery' ? basketIcon : undefined, icon: primaryTone !== 'grocery' && primaryOrder ? getAlertPresentation(primaryOrder.service_type).icon : undefined, tone: primaryTone as SupportCategoryTone, }, statuses, primaryServiceType: primaryOrder?.service_type ?? 'grocery', }; }) ); freshLovedOnes = mappedLovedOnes; setLovedOnes(mappedLovedOnes); } setLovedOnesLoading(false); const { data: recentActivityData, error: activityErr } = await supabase .from('support_orders') .select( 'id, service_type, status, amount, created_at, recipient_id, merchant_id' ) .eq('user_id', activeUserId) .order('created_at', { ascending: false }) .limit(5); if (activityErr) { setActivitiesError('Could not load activity'); } else if (recentActivityData) { const activityRecipientIds = Array.from( new Set( recentActivityData .map((activity) => activity.recipient_id) .filter(Boolean) ) ); const activityMerchantIds = Array.from( new Set( recentActivityData .map((activity) => activity.merchant_id) .filter(Boolean) ) ); const recipientsById = new Map< string, { first_name: string; photo_path: string | null } >(); const merchantsById = new Map(); if (activityRecipientIds.length > 0) { const { data: activityRecipients } = await supabase .from('recipients') .select('id, first_name, photo_path') .in('id', activityRecipientIds); (activityRecipients || []).forEach((recipient) => { recipientsById.set(recipient.id, { first_name: recipient.first_name, photo_path: recipient.photo_path, }); }); } if (activityMerchantIds.length > 0) { const { data: activityMerchants } = await supabase .from('merchants') .select('id, name') .in('id', activityMerchantIds); (activityMerchants || []).forEach((merchant) => { merchantsById.set(merchant.id, merchant.name); }); } const mappedActivity = await Promise.all( recentActivityData.map(async (act) => { const recipient = recipientsById.get(act.recipient_id); const avatarUrl = await getRecipientAvatarUrl( recipient?.photo_path, recipient?.first_name ); const tone = getAlertPresentation(act.service_type).tone; const activityTitle = act.service_type === 'grocery' ? 'Grocery voucher' : act.service_type === 'medication' ? 'Medication voucher' : act.service_type === 'airtime' ? 'Airtime' : act.service_type === 'data' ? 'Data' : act.service_type === 'electricity' ? 'Electricity' : `${act.service_type.charAt(0).toUpperCase()}${act.service_type.slice(1)}`; const normalizedServiceType = getNormalizedServiceType( act.service_type ); const statusLabel = getActivityStatusLabel( act.service_type, act.status ); const merchantName = (act.merchant_id && merchantsById.get(act.merchant_id)) || null; const isVoucherSupport = normalizedServiceType === 'grocery' || normalizedServiceType === 'medication'; let subtitle = merchantName; if (!subtitle) { if (isVoucherSupport) { subtitle = statusLabel === 'redeemed' ? 'Voucher redeemed' : 'Voucher ready for collection'; } else if (normalizedServiceType === 'electricity') { subtitle = 'Meter support sent'; } else if (normalizedServiceType === 'airtime') { subtitle = 'Top-up sent'; } else { subtitle = 'Support sent'; } } return { id: act.id, recipientId: act.recipient_id, title: activityTitle, subtitle, amount: `${Number(act.amount).toFixed(2)}`, status: statusLabel, tone: getActivityStatusTone(act.service_type, act.status), icon: { iconType: tone === 'grocery' ? 'image' : 'ion', iconSrc: tone === 'grocery' ? basketIcon : undefined, icon: tone !== 'grocery' ? getAlertPresentation(act.service_type).icon : undefined, }, iconTone: tone, avatar: avatarUrl, fallbackInitial: recipient?.first_name?.charAt(0)?.toUpperCase() ?? '?', }; }) ); freshActivities = mappedActivity; setActivities(mappedActivity); } setActivitiesLoading(false); // Save cache after all network updates void writeCache(cacheKey, { monthlySentTotal: freshMonthlySentTotal, alerts: freshAlerts, lovedOnes: freshLovedOnes, activities: freshActivities, cachedAt: new Date().toISOString(), }); }; const handleAlertTap = (alert: HomeAlert) => { history.push('/support/new', { recipientId: alert.recipientId, serviceType: alert.serviceType, parentRoot: '/home', }); }; const handleSupportClick = (serviceType?: SupportCategoryTone) => { history.push( '/support/new', serviceType ? { serviceType, parentRoot: '/home' } : { parentRoot: '/home' } ); }; const handleRecipientsClick = () => { history.push('/recipients'); }; const isPreviewMode = !user; const currentMonthLabel = useMemo( () => new Intl.DateTimeFormat(undefined, { month: 'long', }).format(new Date()), [] ); const monthlySentLabel = useMemo( () => formatMoney(monthlySentTotal, 'USD'), [monthlySentTotal] ); const showInitialDashboardSkeleton = !isPreviewMode && profileStatus === 'loading' && lovedOnesLoading && activitiesLoading && alertsLoading && lovedOnes.length === 0 && activities.length === 0 && alerts.length === 0; return ( {showInitialDashboardSkeleton ? ( ) : (
Sarah

Hi Sarah 👋

Supporting 2 loved ones ❤️

{currentMonthLabel}

{monthlySentLabel}

{(alertsLoading || alertsError || alerts.length > 0) && ( <>

Care Alerts

{alertsLoading && alerts.length === 0 ? (
) : alertsError ? (

{alertsError}

) : alerts.length > 0 ? (
{[...alerts, ...alerts].map((alert, index) => ( ))}
) : null}
)}

Your Loved Ones

{lovedOnesLoading && lovedOnes.length === 0 ? (

Loading...

) : lovedOnesError ? (

{lovedOnesError}

) : lovedOnes.length === 0 ? (

No loved ones yet

Add family members to start supporting them.

) : ( lovedOnes.map((person) => (
{person.avatar ? ( {person.name} ) : ( {person.fallbackInitial} )}

{person.name} {person.emoji}

{person.lastSupportLabel}

{person.statuses.length > 0 ? ( person.statuses.slice(0, 3).map((item: any) => { const isSuccess = item.status === 'success'; return (
{renderHomeIcon(item)}

{item.label}

{item.detail}

); }) ) : (
No support sent yet
)}
)) )}

Send Support

{quickActions.map((action) => ( ))}

Family Updates

{activitiesLoading && activities.length === 0 ? (
Loading activity...
) : activitiesError ? (

{activitiesError}

) : activities.length === 0 ? (

No recent activity

Support history will appear here once you send support.

) : ( activities.map((activity, index) => ( )) )}
)} ); }; export default HomePage;