build: 444813b5-5daf-49b2-bf1a-7e4caeab4e17
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user