1196 lines
40 KiB
TypeScript
1196 lines
40 KiB
TypeScript
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;
|