Files
appcakes-builds/src/pages/HomePage.tsx
T
2026-07-02 15:07:53 +00:00

1196 lines
40 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<img
src={item.iconSrc}
alt={item.iconAlt ?? ''}
className={`home-custom-icon ${isPill ? 'is-pill' : ''} ${isBasket ? 'is-basket' : ''}`}
/>
);
}
return item.icon ? <IonIcon icon={item.icon} /> : null;
};
const HomePage: React.FC = () => {
const history = useHistory();
const { user, profileStatus } = useAuth();
const [alerts, setAlerts] = useState<HomeAlert[]>([]);
const [alertsLoading, setAlertsLoading] = useState(true);
const [alertsError, setAlertsError] = useState<string | null>(null);
const [lovedOnes, setLovedOnes] = useState<any[]>([]);
const [lovedOnesLoading, setLovedOnesLoading] = useState(true);
const [lovedOnesError, setLovedOnesError] = useState<string | null>(null);
const [activities, setActivities] = useState<any[]>([]);
const [activitiesLoading, setActivitiesLoading] = useState(true);
const [activitiesError, setActivitiesError] = useState<string | null>(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<any>(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<string, string>();
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<string, string>();
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 (
<IonPage>
<IonContent
fullscreen
style={{ '--background': 'var(--color-bg)' } as React.CSSProperties}
>
{showInitialDashboardSkeleton ? (
<DashboardSkeleton />
) : (
<div className="home-shell">
<div className="home-topline">
<div className="home-topline-left">
<div className="home-main-avatar-wrap">
<img
src={sarahImage}
alt="Sarah"
className="home-main-avatar"
/>
</div>
<div className="home-greeting-block">
<h1 className="home-greeting">Hi Sarah 👋</h1>
</div>
</div>
<button
type="button"
className="home-notification-card"
aria-label="Notifications"
onClick={() =>
history.push('/notifications', { parentRoot: '/home' })
}
>
<IonIcon icon={notificationsOutline} />
<span className="home-dot" />
</button>
</div>
<div className="home-header-actions-row">
<div className="home-tagline-wrap">
<p className="home-subcopy">Supporting 2 loved ones </p>
</div>
<div className="home-month-pill">
<div className="home-month-icon">
<IonIcon icon={heart} />
</div>
<div className="home-month-copy">
<p className="home-month-label">{currentMonthLabel}</p>
<p className="home-month-value">{monthlySentLabel}</p>
</div>
</div>
</div>
{(alertsLoading || alertsError || alerts.length > 0) && (
<>
<div className="home-section-row">
<h2 className="home-section-heading">Care Alerts</h2>
</div>
<div className="home-alert-marquee">
{alertsLoading && alerts.length === 0 ? (
<div className="home-alert-overlay-state">
<div
className="home-alert-card home-alert-skeleton"
aria-label="Loading care alerts"
>
<div className="home-alert-icon" />
<div className="home-alert-content">
<span className="home-alert-skeleton-title" />
<span className="home-alert-skeleton-subtitle" />
<span className="home-alert-skeleton-cta" />
</div>
</div>
</div>
) : alertsError ? (
<div className="home-alert-overlay-state">
<p className="home-alert-error">{alertsError}</p>
</div>
) : alerts.length > 0 ? (
<div className="home-alert-marquee-track">
{[...alerts, ...alerts].map((alert, index) => (
<button
key={`${alert.id}-${index}`}
type="button"
className="home-alert-card"
onClick={() => handleAlertTap(alert)}
>
<div
className={`home-alert-icon home-tone-${alert.tone}`}
>
{alert.iconType === 'image' ? (
<img
src={alert.icon as string}
alt=""
className="home-custom-icon is-basket"
/>
) : (
<IonIcon icon={alert.icon as any} />
)}
</div>
<div className="home-alert-content">
<span className="home-alert-title">
{alert.title}
</span>
<span className="home-alert-subtitle">
{alert.subtitle}
</span>
</div>
<div
className={`home-alert-chevron-chip home-tone-${alert.tone}`}
>
<IonIcon icon={chevronForwardOutline} />
</div>
</button>
))}
</div>
) : null}
</div>
</>
)}
<div className="home-section-row home-section-row-spaced">
<h2 className="home-section-heading">Your Loved Ones</h2>
<button
type="button"
className="home-section-link"
onClick={handleRecipientsClick}
>
View all
</button>
</div>
<div className="home-loved-ones-scroll">
{lovedOnesLoading && lovedOnes.length === 0 ? (
<div
className="home-person-card home-person-card-lavender"
style={{ opacity: 0.5 }}
>
<div className="home-person-header">
<div className="home-person-meta">
<div className="home-person-avatar-shell"></div>
<div>
<p className="home-person-name">Loading...</p>
</div>
</div>
</div>
</div>
) : lovedOnesError ? (
<p className="home-alert-error">{lovedOnesError}</p>
) : lovedOnes.length === 0 ? (
<div
className="home-empty-alerts"
style={{ margin: '0 20px', width: 'auto' }}
>
<div className="home-empty-alerts-icon">
<IonIcon icon={heart} />
</div>
<h3 className="home-empty-alerts-msg">No loved ones yet</h3>
<p className="home-empty-alerts-helper">
Add family members to start supporting them.
</p>
<button
className="home-empty-alerts-cta"
onClick={handleRecipientsClick}
>
Add loved one
</button>
</div>
) : (
lovedOnes.map((person) => (
<div
key={person.id}
className={`home-person-card home-person-card-${person.cardTone}`}
>
<div className="home-person-header">
<div className="home-person-meta">
<div className="home-person-avatar-shell">
{person.avatar ? (
<img
src={person.avatar}
alt={person.name}
className="home-person-avatar"
/>
) : (
<span className="home-person-initial">
{person.fallbackInitial}
</span>
)}
<span className="home-person-online" />
</div>
<div>
<p className="home-person-name">
{person.name} <span>{person.emoji}</span>
</p>
<p className="home-person-location">
{person.lastSupportLabel}
</p>
</div>
</div>
<button
type="button"
className="home-card-menu"
aria-label="More options"
>
<IonIcon icon={ellipsisVertical} />
</button>
</div>
<div className="home-status-stack">
{person.statuses.length > 0 ? (
person.statuses.slice(0, 3).map((item: any) => {
const isSuccess = item.status === 'success';
return (
<div
key={`${person.id}-${item.label}`}
className="home-status-row"
>
<div
className={`home-status-icon home-tone-${item.iconTone}`}
>
{renderHomeIcon(item)}
</div>
<div className="home-status-copy">
<p className="home-status-label">
{item.label}
</p>
<p
className={`home-status-detail ${isSuccess ? 'is-success' : 'is-warning'}`}
>
{item.detail}
</p>
</div>
<IonIcon
icon={
isSuccess
? checkmarkCircleOutline
: timeOutline
}
className={`home-status-trailing ${isSuccess ? 'is-success' : 'is-warning'}`}
/>
</div>
);
})
) : (
<div className="home-status-empty-row">
<IonIcon icon={heart} />
<span>No support sent yet</span>
</div>
)}
</div>
<button
type="button"
className={`home-send-button home-tone-${person.repeatAction.tone}`}
onClick={() =>
history.push('/support/new', {
recipientId: person.id,
serviceType: person.primaryServiceType,
parentRoot: '/home',
})
}
>
<span className="home-send-button-icon">
{renderHomeIcon(person.repeatAction)}
</span>
<span>{person.repeatCta}</span>
</button>
</div>
))
)}
</div>
<div className="home-carousel-indicator">
<span className="home-dot-active" />
<span className="home-dot-inactive" />
<span className="home-dot-inactive" />
</div>
<div className="home-section-row home-section-row-spaced">
<h2 className="home-section-heading">Send Support</h2>
</div>
<div className="home-action-grid">
{quickActions.map((action) => (
<button
key={action.label}
type="button"
className="home-action-card"
onClick={() => handleSupportClick(action.tone)}
>
<div className={`home-action-icon home-tone-${action.tone}`}>
{renderHomeIcon(action)}
</div>
<div className="home-action-text-block">
<span className="home-action-label">{action.label}</span>
<span className="home-action-helper">{action.helper}</span>
</div>
</button>
))}
</div>
<div className="home-section-row">
<h2 className="home-section-heading">Family Updates</h2>
<button
type="button"
className="home-section-link"
onClick={() => history.push('/activity')}
>
View all
</button>
</div>
<div className="home-activity-card">
{activitiesLoading && activities.length === 0 ? (
<div
style={{
padding: '20px',
textAlign: 'center',
color: 'var(--ion-color-medium)',
}}
>
Loading activity...
</div>
) : activitiesError ? (
<div style={{ padding: '20px', textAlign: 'center' }}>
<p className="home-alert-error">{activitiesError}</p>
</div>
) : activities.length === 0 ? (
<div
className="home-empty-alerts"
style={{ boxShadow: 'none' }}
>
<div
className="home-empty-alerts-icon"
style={{
background: 'rgba(23, 24, 39, 0.04)',
color: 'var(--ion-color-medium)',
}}
>
<IonIcon icon={timeOutline} />
</div>
<h3 className="home-empty-alerts-msg">No recent activity</h3>
<p className="home-empty-alerts-helper">
Support history will appear here once you send support.
</p>
<button
className="home-empty-alerts-cta"
onClick={() => handleSupportClick()}
>
Send first support
</button>
</div>
) : (
activities.map((activity, index) => (
<button
key={activity.id}
type="button"
className={`home-activity-row home-activity-row-button ${
index === activities.length - 1 ? 'is-last' : ''
}`}
onClick={() =>
history.push(`/recipients/${activity.recipientId}`)
}
>
<div className="home-activity-avatar-shell">
{activity.avatar ? (
<img
src={activity.avatar}
alt=""
className="home-activity-avatar"
/>
) : (
<div className="home-activity-avatar home-activity-avatar-fallback">
<span>{activity.fallbackInitial}</span>
</div>
)}
<div
className={`home-activity-avatar-badge home-tone-${activity.iconTone}`}
>
{renderHomeIcon(activity.icon)}
</div>
</div>
<div className="home-activity-copy">
<div className="home-activity-head-row">
<div className="home-activity-text-block">
<p className="home-activity-title">
{activity.title}
</p>
<p className="home-activity-subtitle">
{activity.subtitle}
</p>
</div>
<div className="home-activity-right">
<p className="home-activity-amount">
{formatMoney(Number(activity.amount), 'USD')}
</p>
<div
className={`home-activity-badge home-badge-${activity.tone}`}
>
<span>{activity.status}</span>
</div>
</div>
</div>
</div>
</button>
))
)}
</div>
</div>
)}
</IonContent>
</IonPage>
);
};
export default HomePage;