build: 444813b5-5daf-49b2-bf1a-7e4caeab4e17

This commit is contained in:
AppCakes
2026-07-03 21:36:11 +00:00
commit ffc7af6fc7
149 changed files with 27775 additions and 0 deletions
+155
View File
@@ -0,0 +1,155 @@
import React from 'react';
import { IonIcon } from '@ionic/react';
import {
medkitOutline,
flashOutline,
phonePortraitOutline,
chevronForwardOutline,
} from 'ionicons/icons';
import momImage from '../assets/mom.jpg';
import dadImage from '../assets/dad.jpg';
import basketIcon from '../assets/basket.png';
interface ActivityListItemProps {
id: string;
eventType: string;
title: string;
subtitle: string;
amount?: number;
statusText?: string;
statusTone?: 'success' | 'warning' | 'partial';
avatarLabel?: string;
avatarTone?: string;
avatarImage?: string;
onClick?: (id: string) => void;
onStatusClick?: () => void;
}
const ActivityListItem: React.FC<ActivityListItemProps> = ({
id,
eventType,
title,
subtitle,
amount,
statusText,
statusTone = 'warning',
avatarLabel,
avatarTone = 'brand',
avatarImage,
onClick,
onStatusClick,
}) => {
const getIconAndColor = () => {
if (
eventType.includes('grocery') ||
title.toLowerCase().includes('grocer')
) {
return {
iconSrc: basketIcon,
badgeClass: 'grocery',
};
}
if (
eventType.includes('medication') ||
title.toLowerCase().includes('medic')
) {
return {
icon: medkitOutline,
badgeClass: 'medication',
};
}
if (
eventType.includes('airtime') ||
title.toLowerCase().includes('airtime')
) {
return {
icon: phonePortraitOutline,
badgeClass: 'airtime',
};
}
return {
icon: flashOutline,
badgeClass: 'electricity',
};
};
const { icon, iconSrc, badgeClass } = getIconAndColor();
const avatarSearchText =
`${title} ${subtitle} ${avatarLabel ?? ''}`.toLowerCase();
const resolvedAvatar =
avatarImage ??
(avatarSearchText.includes('mum') || avatarSearchText.includes('mom')
? momImage
: avatarSearchText.includes('dad') || avatarSearchText.includes('father')
? dadImage
: undefined);
return (
<button
type="button"
onClick={() => onClick && onClick(id)}
className="activity-feed-item"
disabled={!onClick}
>
<div className="activity-feed-avatar-wrap">
<div
className={`activity-feed-avatar activity-feed-avatar-${avatarTone}`}
>
{resolvedAvatar ? (
<img
src={resolvedAvatar}
alt=""
className="activity-feed-avatar-image"
/>
) : (
<span>{avatarLabel ?? 'KU'}</span>
)}
<div
className={`activity-feed-avatar-badge activity-feed-avatar-badge-${badgeClass}`}
>
{iconSrc ? (
<img src={iconSrc} alt="" className="activity-feed-badge-image" />
) : (
<IonIcon icon={icon} />
)}
</div>
</div>
</div>
<div className="activity-feed-main">
<div className="activity-feed-row">
<div className="activity-feed-copy">
<p className="activity-feed-title">{title}</p>
<p className="activity-feed-subtitle">{subtitle}</p>
</div>
<div className="activity-feed-right">
{amount !== undefined && (
<p className="activity-feed-amount">${amount.toFixed(2)}</p>
)}
{statusText && (
<button
type="button"
className={`activity-feed-status activity-feed-status-${statusTone}`}
onClick={(event) => {
event.stopPropagation();
if (onStatusClick) {
onStatusClick();
return;
}
onClick?.(id);
}}
>
<span>{statusText}</span>
<IonIcon icon={chevronForwardOutline} />
</button>
)}
</div>
</div>
</div>
</button>
);
};
export default ActivityListItem;
+116
View File
@@ -0,0 +1,116 @@
import React, { useState } from 'react';
import { eyeOffOutline, eyeOutline } from 'ionicons/icons';
import { IonIcon } from '@ionic/react';
interface AuthFormFieldsProps {
mode: 'login' | 'register';
email: string;
password: string;
confirmPassword?: string;
onEmailChange: (val: string) => void;
onPasswordChange: (val: string) => void;
onConfirmPasswordChange?: (val: string) => void;
errors?: { [key: string]: string };
disabled?: boolean;
}
const AuthFormFields: React.FC<AuthFormFieldsProps> = ({
mode,
email,
password,
confirmPassword,
onEmailChange,
onPasswordChange,
onConfirmPasswordChange,
errors = {},
disabled = false,
}) => {
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
return (
<div className="auth-form-fields">
<div className="field-group">
<label className="auth-field-label">Email address</label>
<input
className={`auth-text-input ${errors.email ? 'has-error' : ''}`}
type="email"
value={email}
onChange={(e) => onEmailChange(e.target.value)}
disabled={disabled}
placeholder="you@example.com"
autoComplete="email"
/>
{errors.email ? (
<p className="auth-field-error">{errors.email}</p>
) : null}
</div>
<div className="field-group">
<label className="auth-field-label">Password</label>
<div className="auth-password-wrap">
<input
className={`auth-text-input ${errors.password ? 'has-error' : ''}`}
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => onPasswordChange(e.target.value)}
disabled={disabled}
placeholder="Enter your password"
autoComplete={
mode === 'login' ? 'current-password' : 'new-password'
}
/>
<button
type="button"
className="auth-password-toggle"
onClick={() => setShowPassword((value) => !value)}
disabled={disabled}
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
<IonIcon icon={showPassword ? eyeOffOutline : eyeOutline} />
</button>
</div>
{errors.password ? (
<p className="auth-field-error">{errors.password}</p>
) : null}
</div>
{mode === 'register' && onConfirmPasswordChange && (
<div className="field-group">
<label className="auth-field-label">Confirm password</label>
<div className="auth-password-wrap">
<input
className={`auth-text-input ${errors.confirmPassword ? 'has-error' : ''}`}
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => onConfirmPasswordChange(e.target.value)}
disabled={disabled}
placeholder="Re-enter your password"
autoComplete="new-password"
/>
<button
type="button"
className="auth-password-toggle"
onClick={() => setShowConfirmPassword((value) => !value)}
disabled={disabled}
aria-label={
showConfirmPassword
? 'Hide confirm password'
: 'Show confirm password'
}
>
<IonIcon
icon={showConfirmPassword ? eyeOffOutline : eyeOutline}
/>
</button>
</div>
{errors.confirmPassword ? (
<p className="auth-field-error">{errors.confirmPassword}</p>
) : null}
</div>
)}
</div>
);
};
export default AuthFormFields;
+121
View File
@@ -0,0 +1,121 @@
import React, { useRef } from 'react';
import { IonIcon } from '@ionic/react';
import { camera, personOutline } from 'ionicons/icons';
interface AvatarPickerProps {
previewUrl: string | null;
onFileChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
initials?: string;
disabled?: boolean;
}
const AvatarPicker: React.FC<AvatarPickerProps> = ({
previewUrl,
onFileChange,
initials,
disabled = false,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleClick = () => {
if (!disabled) {
fileInputRef.current?.click();
}
};
return (
<div
className="avatar-picker-container"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
margin: '8px 0 4px',
}}
>
<button
type="button"
aria-label={previewUrl ? 'Change photo' : 'Add photo'}
onClick={handleClick}
disabled={disabled}
style={{
position: 'relative',
width: '96px',
height: '96px',
padding: 0,
border: 'none',
background: 'transparent',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.6 : 1,
}}
>
<div
className="avatar-preview-circle"
style={{
width: '88px',
height: '88px',
margin: '0 auto',
borderRadius: '24px',
backgroundColor: previewUrl
? 'transparent'
: 'rgba(109,40,217,0.10)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
}}
>
{previewUrl ? (
<img
src={previewUrl}
alt="Avatar preview"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : initials ? (
<span
style={{ fontSize: '28px', fontWeight: '700', color: '#6d28d9' }}
>
{initials}
</span>
) : (
<IonIcon
icon={personOutline}
style={{ fontSize: '32px', color: '#6d28d9' }}
/>
)}
</div>
<div
style={{
position: 'absolute',
right: '0px',
bottom: '0px',
width: '36px',
height: '36px',
borderRadius: '12px',
background: '#ffffff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 8px 16px rgba(109, 40, 217, 0.15)',
}}
>
<IonIcon
icon={camera}
style={{ fontSize: '20px', color: '#6d28d9' }}
/>
</div>
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={onFileChange}
disabled={disabled}
style={{ display: 'none' }}
/>
</div>
);
};
export default AvatarPicker;
+88
View File
@@ -0,0 +1,88 @@
import React from 'react';
import { IonIcon } from '@ionic/react';
import {
chevronForwardOutline,
timeOutline,
flashOutline,
wifiOutline,
medkitOutline,
} from 'ionicons/icons';
interface CareAlertPillProps {
id: string;
type: string;
title: string;
severity: 'warning' | 'neutral';
onClick: (id: string) => void;
}
const CareAlertPill: React.FC<CareAlertPillProps> = ({
id,
type,
title,
severity,
onClick,
}) => {
const getIconForType = () => {
switch (type) {
case 'electricity_low':
return flashOutline;
case 'airtime_expiring':
return wifiOutline;
case 'medication_due':
return medkitOutline;
default:
return timeOutline;
}
};
const getColors = () => {
if (severity === 'warning') {
return { bg: 'rgba(245,158,11,0.08)', icon: '#f59e0b', text: '#111827' };
}
return { bg: '#ffffff', icon: '#6d28d9', text: '#111827' };
};
const colors = getColors();
return (
<button
onClick={() => onClick(id)}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
backgroundColor: colors.bg,
borderRadius: '24px',
padding: '14px 16px',
border: 'none',
flexShrink: 0,
maxWidth: '280px',
cursor: 'pointer',
}}
>
<IonIcon
icon={getIconForType()}
style={{ fontSize: '20px', color: colors.icon, flexShrink: 0 }}
/>
<span
style={{
fontSize: '13px',
fontWeight: '600',
color: colors.text,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{title}
</span>
<IonIcon
icon={chevronForwardOutline}
style={{ fontSize: '16px', color: '#9ca3af', marginLeft: 'auto' }}
/>
</button>
);
};
export default CareAlertPill;
+246
View File
@@ -0,0 +1,246 @@
import React from 'react';
import { IonSkeletonText } from '@ionic/react';
const DashboardSkeleton: React.FC = () => {
return (
<div style={{ padding: '0' }}>
{/* Top Greeting Row */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px 20px 12px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<IonSkeletonText
animated
style={{ width: '48px', height: '48px', borderRadius: '50%' }}
/>
<div>
<IonSkeletonText
animated
style={{
width: '120px',
height: '18px',
borderRadius: '4px',
marginBottom: '8px',
}}
/>
<IonSkeletonText
animated
style={{ width: '80px', height: '14px', borderRadius: '4px' }}
/>
</div>
</div>
</div>
{/* Loved Ones Carousel */}
<div
style={{
padding: '0 20px 6px',
display: 'flex',
gap: '12px',
overflowX: 'hidden',
}}
>
<div
style={{
backgroundColor: '#ffffff',
borderRadius: '24px',
padding: '16px',
width: '292px',
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
gap: '14px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<IonSkeletonText
animated
style={{ width: '48px', height: '48px', borderRadius: '16px' }}
/>
<div style={{ flex: 1 }}>
<IonSkeletonText
animated
style={{
width: '100px',
height: '16px',
borderRadius: '4px',
marginBottom: '4px',
}}
/>
<IonSkeletonText
animated
style={{ width: '80px', height: '12px', borderRadius: '4px' }}
/>
</div>
</div>
<IonSkeletonText
animated
style={{ width: '100%', height: '48px', borderRadius: '12px' }}
/>
<IonSkeletonText
animated
style={{
width: '100%',
height: '48px',
borderRadius: '999px',
marginTop: 'auto',
}}
/>
</div>
<div
style={{
backgroundColor: '#ffffff',
borderRadius: '24px',
padding: '16px',
width: '292px',
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
gap: '14px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<IonSkeletonText
animated
style={{ width: '48px', height: '48px', borderRadius: '16px' }}
/>
<div style={{ flex: 1 }}>
<IonSkeletonText
animated
style={{
width: '100px',
height: '16px',
borderRadius: '4px',
marginBottom: '4px',
}}
/>
<IonSkeletonText
animated
style={{ width: '80px', height: '12px', borderRadius: '4px' }}
/>
</div>
</div>
<IonSkeletonText
animated
style={{ width: '100%', height: '48px', borderRadius: '12px' }}
/>
<IonSkeletonText
animated
style={{
width: '100%',
height: '48px',
borderRadius: '999px',
marginTop: 'auto',
}}
/>
</div>
</div>
{/* Quick Actions */}
<div style={{ padding: '0 20px', margin: '20px 0' }}>
<IonSkeletonText
animated
style={{
width: '120px',
height: '16px',
borderRadius: '4px',
marginBottom: '16px',
}}
/>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: '12px',
}}
>
{[1, 2, 3, 4].map((i) => (
<div
key={i}
style={{
backgroundColor: '#ffffff',
borderRadius: '24px',
padding: '16px',
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
<IonSkeletonText
animated
style={{ width: '44px', height: '44px', borderRadius: '12px' }}
/>
<IonSkeletonText
animated
style={{ width: '60px', height: '14px', borderRadius: '4px' }}
/>
</div>
))}
</div>
</div>
{/* Activity List */}
<div style={{ padding: '0 20px', margin: '20px 0' }}>
<IonSkeletonText
animated
style={{
width: '100px',
height: '16px',
borderRadius: '4px',
marginBottom: '16px',
}}
/>
<div
style={{
backgroundColor: '#ffffff',
borderRadius: '24px',
padding: '8px 0',
}}
>
{[1, 2, 3].map((i) => (
<div
key={i}
style={{
display: 'flex',
alignItems: 'center',
padding: '14px 16px',
gap: '12px',
}}
>
<IonSkeletonText
animated
style={{ width: '40px', height: '40px', borderRadius: '12px' }}
/>
<div style={{ flex: 1 }}>
<IonSkeletonText
animated
style={{
width: '140px',
height: '14px',
borderRadius: '4px',
marginBottom: '6px',
}}
/>
<IonSkeletonText
animated
style={{
width: '100px',
height: '12px',
borderRadius: '4px',
}}
/>
</div>
</div>
))}
</div>
</div>
</div>
);
};
export default DashboardSkeleton;
+86
View File
@@ -0,0 +1,86 @@
import React from 'react';
import { IonIcon } from '@ionic/react';
import { searchOutline, optionsOutline } from 'ionicons/icons';
interface ListSearchRowProps {
value: string;
onChange: (val: string) => void;
placeholder?: string;
onFilterClick?: () => void;
showFilter?: boolean;
}
const ListSearchRow: React.FC<ListSearchRowProps> = ({
value,
onChange,
placeholder = 'Search...',
onFilterClick,
showFilter = false,
}) => {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
margin: '0 20px 12px',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
flex: 1,
backgroundColor: '#ffffff',
borderRadius: '16px',
padding: '0 16px',
height: '48px',
}}
>
<IonIcon
icon={searchOutline}
style={{ fontSize: '20px', color: '#9ca3af', flexShrink: 0 }}
/>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
style={{
flex: 1,
border: 'none',
outline: 'none',
backgroundColor: 'transparent',
padding: '0 12px',
fontSize: '15px',
color: '#111827',
}}
/>
</div>
{showFilter && (
<button
onClick={onFilterClick}
style={{
width: '48px',
height: '48px',
borderRadius: '16px',
backgroundColor: '#ffffff',
border: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
cursor: 'pointer',
}}
>
<IonIcon
icon={optionsOutline}
style={{ fontSize: '20px', color: '#6d28d9' }}
/>
</button>
)}
</div>
);
};
export default ListSearchRow;
+210
View File
@@ -0,0 +1,210 @@
import React from 'react';
import { IonIcon } from '@ionic/react';
import {
chevronForwardOutline,
checkmarkCircleOutline,
timeOutline,
closeCircleOutline,
} from 'ionicons/icons';
export interface OrderSummary {
id: string;
recipientName: string;
serviceType: string;
merchantName?: string;
amount: number;
amountLabel?: string;
status: string;
createdAt: string;
avatarUrl?: string | null;
}
interface OrderSummaryCardProps {
order: OrderSummary;
onClick: (id: string) => void;
}
const OrderSummaryCard: React.FC<OrderSummaryCardProps> = ({
order,
onClick,
}) => {
const getStatusDisplay = (status: string) => {
switch (status) {
case 'paid':
return {
text: 'Paid',
color: '#3b82f6',
bg: 'rgba(59,130,246,0.1)',
icon: checkmarkCircleOutline,
};
case 'ready_for_redemption':
return {
text: 'Ready',
color: '#16a34a',
bg: 'rgba(22,163,74,0.1)',
icon: checkmarkCircleOutline,
};
case 'redeemed':
return {
text: 'Redeemed',
color: '#16a34a',
bg: 'rgba(22,163,74,0.1)',
icon: checkmarkCircleOutline,
};
case 'delivered':
return {
text: 'Delivered',
color: '#16a34a',
bg: 'rgba(22,163,74,0.1)',
icon: checkmarkCircleOutline,
};
case 'pending_payment':
return {
text: 'Pending',
color: '#f59e0b',
bg: 'rgba(245,158,11,0.1)',
icon: timeOutline,
};
case 'failed':
case 'cancelled':
return {
text: 'Failed',
color: '#ef4444',
bg: 'rgba(239,68,68,0.1)',
icon: closeCircleOutline,
};
default:
return {
text: status,
color: '#6b7280',
bg: '#f3f4f6',
icon: timeOutline,
};
}
};
const statusDisplay = getStatusDisplay(order.status);
return (
<div
onClick={() => onClick(order.id)}
style={{
display: 'flex',
alignItems: 'center',
backgroundColor: '#ffffff',
borderRadius: '24px',
padding: '16px',
margin: '0 20px 12px',
gap: '14px',
cursor: 'pointer',
}}
>
<div
style={{
width: '48px',
height: '48px',
borderRadius: '16px',
backgroundColor: 'rgba(109,40,217,0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
flexShrink: 0,
}}
>
{order.avatarUrl ? (
<img
src={order.avatarUrl}
alt={order.recipientName}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<span
style={{ fontSize: '18px', fontWeight: '700', color: '#6d28d9' }}
>
{order.recipientName.charAt(0)}
</span>
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: '15px',
fontWeight: '700',
color: '#111827',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{order.serviceType} for {order.recipientName}
</div>
<div
style={{
fontSize: '13px',
fontWeight: '400',
color: '#6b7280',
marginTop: '2px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{order.merchantName || 'Service provider'}
</div>
<div
style={{
fontSize: '12px',
fontWeight: '400',
color: '#9ca3af',
marginTop: '4px',
}}
>
{new Date(order.createdAt).toLocaleDateString()}
</div>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
gap: '6px',
}}
>
<span style={{ fontSize: '15px', fontWeight: '700', color: '#111827' }}>
{order.amountLabel ?? `${order.amount.toFixed(2)}`}
</span>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
backgroundColor: statusDisplay.bg,
padding: '2px 8px',
borderRadius: '999px',
}}
>
<span
style={{
fontSize: '11px',
fontWeight: '700',
color: statusDisplay.color,
}}
>
{statusDisplay.text}
</span>
<IonIcon
icon={statusDisplay.icon}
style={{ fontSize: '12px', color: statusDisplay.color }}
/>
</div>
</div>
<IonIcon
icon={chevronForwardOutline}
style={{ fontSize: '16px', color: '#9ca3af', marginLeft: '4px' }}
/>
</div>
);
};
export default OrderSummaryCard;
+82
View File
@@ -0,0 +1,82 @@
import React, { useRef } from 'react';
interface OtpInputSlotsProps {
value: string[];
onChange: (index: number, val: string) => void;
onPaste: (e: React.ClipboardEvent<HTMLInputElement>) => void;
disabled?: boolean;
}
const OtpInputSlots: React.FC<OtpInputSlotsProps> = ({
value,
onChange,
onPaste,
disabled = false,
}) => {
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
const handleKeyDown = (
index: number,
e: React.KeyboardEvent<HTMLInputElement>
) => {
if (e.key === 'Backspace' && value[index] === '') {
if (index > 0) {
inputRefs.current[index - 1]?.focus();
}
}
};
const handleInput = (
index: number,
e: React.ChangeEvent<HTMLInputElement>
) => {
const val = e.target.value.replace(/[^0-9]/g, '');
const lastChar = val.slice(-1);
onChange(index, lastChar);
if (lastChar && index < 5) {
inputRefs.current[index + 1]?.focus();
}
};
return (
<div
className="otp-input-row"
style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}
>
{[0, 1, 2, 3, 4, 5].map((index) => (
<input
key={index}
ref={(el) => {
inputRefs.current[index] = el;
}}
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={1}
value={value[index] || ''}
onChange={(e) => handleInput(index, e)}
onKeyDown={(e) => handleKeyDown(index, e)}
onPaste={onPaste}
disabled={disabled}
style={{
width: '44px',
height: '56px',
textAlign: 'center',
fontSize: '24px',
fontWeight: '700',
color: value[index] ? '#6d28d9' : '#111827',
backgroundColor: value[index] ? 'rgba(109,40,217,0.08)' : '#fafafa',
border: 'none',
borderRadius: '12px',
outline: 'none',
}}
className="otp-slot"
/>
))}
</div>
);
};
export default OtpInputSlots;
+40
View File
@@ -0,0 +1,40 @@
import React from 'react';
import { Route, Redirect } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import SplashPage from '../pages/SplashPage';
interface Props {
component: React.ComponentType<any>;
path: string | string[];
exact?: boolean;
}
const BYPASS_AUTH = false;
const ProtectedRouteLoader: React.FC = () => (
<SplashPage title="Opening Kumusha" />
);
const ProtectedRoute: React.FC<Props> = ({ component: Component, ...rest }) => {
const { user, profile, profileStatus } = useAuth();
return (
<Route
{...rest}
render={(props) => {
if (!user) {
return <Redirect to="/auth" />;
}
if (profileStatus === 'loading') {
return <ProtectedRouteLoader />;
}
if (profileStatus === 'missing') {
return <Redirect to="/setup-profile" />;
}
return <Component {...props} />;
}}
/>
);
};
export default ProtectedRoute;
+94
View File
@@ -0,0 +1,94 @@
import React from 'react';
import { IonIcon } from '@ionic/react';
import {
cartOutline,
medkitOutline,
phonePortraitOutline,
flashOutline,
} from 'ionicons/icons';
interface QuickSupportActionCardProps {
serviceType: 'grocery' | 'medication' | 'airtime' | 'electricity';
label: string;
onClick: () => void;
}
const QuickSupportActionCard: React.FC<QuickSupportActionCardProps> = ({
serviceType,
label,
onClick,
}) => {
const getIconAndColor = () => {
switch (serviceType) {
case 'grocery':
return {
icon: cartOutline,
color: '#6d28d9',
bg: 'rgba(109,40,217,0.1)',
};
case 'medication':
return {
icon: medkitOutline,
color: '#ef4444',
bg: 'rgba(239,68,68,0.1)',
};
case 'airtime':
return {
icon: phonePortraitOutline,
color: '#3b82f6',
bg: 'rgba(59,130,246,0.1)',
};
case 'electricity':
return {
icon: flashOutline,
color: '#f59e0b',
bg: 'rgba(245,158,11,0.1)',
};
default:
return {
icon: cartOutline,
color: '#6d28d9',
bg: 'rgba(109,40,217,0.1)',
};
}
};
const { icon, color, bg } = getIconAndColor();
return (
<button
onClick={onClick}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
backgroundColor: '#ffffff',
borderRadius: '24px',
padding: '16px',
border: 'none',
width: '100%',
cursor: 'pointer',
}}
>
<div
style={{
width: '44px',
height: '44px',
borderRadius: '12px',
backgroundColor: bg,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<IonIcon icon={icon} style={{ fontSize: '20px', color }} />
</div>
<span style={{ fontSize: '14px', fontWeight: '700', color: '#111827' }}>
{label}
</span>
</button>
);
};
export default QuickSupportActionCard;
+239
View File
@@ -0,0 +1,239 @@
import React from 'react';
import { IonIcon } from '@ionic/react';
import {
cartOutline,
medkitOutline,
flashOutline,
timeOutline,
checkmarkCircleOutline,
ellipsisVertical,
} from 'ionicons/icons';
export interface CareStatus {
serviceType: 'grocery' | 'medication' | 'electricity' | 'airtime';
label: string;
statusText: string;
isOk: boolean;
}
export interface RecipientSummary {
id: string;
firstName: string;
lastName: string;
location: string;
avatarUrl: string | null;
statuses: CareStatus[];
}
interface RecipientCareCardProps {
recipient: RecipientSummary;
onSendSupport: (id: string) => void;
onMenuClick?: (id: string) => void;
}
const getIconForService = (type: string) => {
switch (type) {
case 'grocery':
return cartOutline;
case 'medication':
return medkitOutline;
case 'electricity':
return flashOutline;
default:
return cartOutline;
}
};
const getColorForService = (type: string) => {
switch (type) {
case 'grocery':
return '#6d28d9';
case 'medication':
return '#ef4444';
case 'electricity':
return '#f59e0b';
default:
return '#3b82f6';
}
};
const RecipientCareCard: React.FC<RecipientCareCardProps> = ({
recipient,
onSendSupport,
onMenuClick,
}) => {
return (
<div
style={{
backgroundColor: '#ffffff',
borderRadius: '24px',
padding: '16px',
width: '292px',
display: 'flex',
flexDirection: 'column',
gap: '14px',
flexShrink: 0,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div
style={{
width: '48px',
height: '48px',
borderRadius: '16px',
backgroundColor: 'rgba(109,40,217,0.1)',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{recipient.avatarUrl ? (
<img
src={recipient.avatarUrl}
alt={recipient.firstName}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<span
style={{
fontSize: '18px',
fontWeight: '700',
color: '#6d28d9',
}}
>
{recipient.firstName.charAt(0)}
</span>
)}
</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<h3
style={{
margin: 0,
fontSize: '16px',
fontWeight: '700',
color: '#111827',
}}
>
{recipient.firstName}
</h3>
<div
style={{
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: '#16a34a',
}}
/>
</div>
<p
style={{
margin: '2px 0 0',
fontSize: '13px',
fontWeight: '400',
color: '#6b7280',
}}
>
{recipient.location}
</p>
</div>
</div>
<button
onClick={() => onMenuClick && onMenuClick(recipient.id)}
style={{ background: 'transparent', border: 'none', padding: '4px' }}
>
<IonIcon
icon={ellipsisVertical}
style={{ fontSize: '20px', color: '#6b7280' }}
/>
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{recipient.statuses.map((status, idx) => (
<div
key={idx}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: '#fafafa',
borderRadius: '12px',
padding: '12px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<IonIcon
icon={getIconForService(status.serviceType)}
style={{
fontSize: '18px',
color: getColorForService(status.serviceType),
}}
/>
<div>
<div
style={{
fontSize: '13px',
fontWeight: '700',
color: '#111827',
}}
>
{status.label}
</div>
<div
style={{
fontSize: '12px',
fontWeight: '500',
color: status.isOk ? '#16a34a' : '#f59e0b',
marginTop: '2px',
}}
>
{status.statusText}
</div>
</div>
</div>
<IonIcon
icon={status.isOk ? checkmarkCircleOutline : timeOutline}
style={{
fontSize: '18px',
color: status.isOk ? '#16a34a' : '#f59e0b',
}}
/>
</div>
))}
</div>
<button
onClick={() => onSendSupport(recipient.id)}
style={{
width: '100%',
padding: '14px',
backgroundColor: '#6d28d9',
color: '#ffffff',
border: 'none',
borderRadius: '999px',
fontSize: '14px',
fontWeight: '700',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
marginTop: 'auto',
}}
>
<IonIcon icon={cartOutline} style={{ fontSize: '18px' }} />
Send Again
</button>
</div>
);
};
export default RecipientCareCard;
+117
View File
@@ -0,0 +1,117 @@
import React from 'react';
import { IonIcon } from '@ionic/react';
import { chevronForwardOutline } from 'ionicons/icons';
export interface RecipientListItem {
id: string;
firstName: string;
lastName: string;
relationship: string;
location: string;
lastActivityText?: string;
avatarUrl: string | null;
}
interface RecipientListCardProps {
recipient: RecipientListItem;
onClick: (id: string) => void;
}
const RecipientListCard: React.FC<RecipientListCardProps> = ({
recipient,
onClick,
}) => {
return (
<div
onClick={() => onClick(recipient.id)}
style={{
display: 'flex',
alignItems: 'center',
backgroundColor: '#ffffff',
borderRadius: '24px',
padding: '16px',
margin: '0 20px 12px',
gap: '14px',
cursor: 'pointer',
}}
>
<div
style={{
width: '52px',
height: '52px',
borderRadius: '16px',
backgroundColor: 'rgba(109,40,217,0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
flexShrink: 0,
}}
>
{recipient.avatarUrl ? (
<img
src={recipient.avatarUrl}
alt={recipient.firstName}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<span
style={{ fontSize: '20px', fontWeight: '700', color: '#6d28d9' }}
>
{recipient.firstName.charAt(0)}
</span>
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<h3
style={{
margin: 0,
fontSize: '16px',
fontWeight: '700',
color: '#111827',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{recipient.firstName} {recipient.lastName}
</h3>
<span
style={{ fontSize: '13px', fontWeight: '600', color: '#6d28d9' }}
>
{recipient.relationship}
</span>
</div>
<div
style={{
margin: '4px 0 0',
fontSize: '13px',
fontWeight: '400',
color: '#6b7280',
}}
>
{recipient.location}
</div>
{recipient.lastActivityText && (
<div
style={{
marginTop: '2px',
fontSize: '12px',
fontWeight: '400',
color: '#6b7280',
}}
>
Last activity: {recipient.lastActivityText}
</div>
)}
</div>
<IonIcon
icon={chevronForwardOutline}
style={{ fontSize: '18px', color: '#9ca3af', flexShrink: 0 }}
/>
</div>
);
};
export default RecipientListCard;
+115
View File
@@ -0,0 +1,115 @@
import React from 'react';
import { IonIcon, IonToggle } from '@ionic/react';
import { chevronForwardOutline } from 'ionicons/icons';
interface SettingsRowProps {
icon: string;
iconColor?: string;
iconBg?: string;
title: string;
subtitle?: string;
onClick?: () => void;
type?: 'link' | 'toggle' | 'button';
checked?: boolean;
onToggle?: (checked: boolean) => void;
destructive?: boolean;
}
const SettingsRow: React.FC<SettingsRowProps> = ({
icon,
iconColor = '#6d28d9',
iconBg = 'rgba(109,40,217,0.1)',
title,
subtitle,
onClick,
type = 'link',
checked = false,
onToggle,
destructive = false,
}) => {
const content = (
<>
<div
style={{
width: '36px',
height: '36px',
borderRadius: '12px',
backgroundColor: destructive ? 'rgba(239,68,68,0.1)' : iconBg,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<IonIcon
icon={icon}
style={{
fontSize: '18px',
color: destructive ? '#ef4444' : iconColor,
}}
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: '15px',
fontWeight: '600',
color: destructive ? '#ef4444' : '#111827',
}}
>
{title}
</div>
{subtitle && (
<div
style={{
fontSize: '13px',
fontWeight: '400',
color: '#6b7280',
marginTop: '2px',
}}
>
{subtitle}
</div>
)}
</div>
{type === 'link' && (
<IonIcon
icon={chevronForwardOutline}
style={{ fontSize: '18px', color: '#9ca3af' }}
/>
)}
{type === 'toggle' && (
<IonToggle
checked={checked}
onIonChange={(e) => onToggle && onToggle(e.detail.checked)}
style={{ padding: 0 }}
/>
)}
</>
);
const containerStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
padding: '12px 16px',
gap: '14px',
minHeight: '56px',
cursor: onClick || type === 'toggle' ? 'pointer' : 'default',
backgroundColor: 'transparent',
border: 'none',
width: '100%',
textAlign: 'left',
};
if (type === 'link' || type === 'button') {
return (
<button onClick={onClick} style={containerStyle}>
{content}
</button>
);
}
return <div style={containerStyle}>{content}</div>;
};
export default SettingsRow;
+55
View File
@@ -0,0 +1,55 @@
import React from 'react';
interface SocialAuthButtonProps {
provider: 'google' | 'apple';
label: string;
onClick: () => void;
disabled?: boolean;
}
const SocialAuthButton: React.FC<SocialAuthButtonProps> = ({
provider,
label,
onClick,
disabled = false,
}) => {
return (
<button
type="button"
className="social-auth-button"
onClick={onClick}
disabled={disabled}
>
<span className="social-auth-button__icon" aria-hidden="true">
{provider === 'google' && (
<svg width="18" height="18" viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
)}
{provider === 'apple' && (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
</svg>
)}
</span>
<span className="social-auth-button__label">Continue with {label}</span>
</button>
);
};
export default SocialAuthButton;