- Created reusable BeneficiaryMenu component with Modal backdrop - Menu closes on outside tap (proper Modal + Pressable implementation) - Removed debug panel from subscription and beneficiary detail pages - Fixed subscription creation and equipment status handling - Backend improvements for Stripe integration
570 lines
16 KiB
TypeScript
570 lines
16 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
ScrollView,
|
|
TouchableOpacity,
|
|
ActivityIndicator,
|
|
RefreshControl,
|
|
} from 'react-native';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
import { router, useLocalSearchParams } from 'expo-router';
|
|
import { api } from '@/services/api';
|
|
import { useToast } from '@/components/ui/Toast';
|
|
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
|
import { FullScreenError } from '@/components/ui/ErrorMessage';
|
|
import {
|
|
AppColors,
|
|
BorderRadius,
|
|
FontSizes,
|
|
FontWeights,
|
|
Spacing,
|
|
Shadows,
|
|
} from '@/constants/theme';
|
|
import type { Beneficiary } from '@/types';
|
|
import { hasBeneficiaryDevices } from '@/services/BeneficiaryDetailController';
|
|
|
|
type EquipmentStatus = 'ordered' | 'shipped' | 'delivered';
|
|
|
|
interface StatusStep {
|
|
key: EquipmentStatus | 'placed' | 'preparing';
|
|
label: string;
|
|
icon: keyof typeof Ionicons.glyphMap;
|
|
}
|
|
|
|
const STATUS_STEPS: StatusStep[] = [
|
|
{ key: 'placed', label: 'Order placed', icon: 'checkmark-circle' },
|
|
{ key: 'preparing', label: 'Preparing', icon: 'cube-outline' },
|
|
{ key: 'shipped', label: 'Shipped', icon: 'airplane-outline' },
|
|
{ key: 'delivered', label: 'Delivered', icon: 'home-outline' },
|
|
];
|
|
|
|
const getActiveStepIndex = (status: EquipmentStatus): number => {
|
|
switch (status) {
|
|
case 'ordered':
|
|
return 1; // Preparing stage
|
|
case 'shipped':
|
|
return 2;
|
|
case 'delivered':
|
|
return 3;
|
|
default:
|
|
return 0;
|
|
}
|
|
};
|
|
|
|
const getStatusTitle = (status: EquipmentStatus): string => {
|
|
switch (status) {
|
|
case 'ordered':
|
|
return 'Kit Ordered';
|
|
case 'shipped':
|
|
return 'Kit Shipped';
|
|
case 'delivered':
|
|
return 'Kit Delivered';
|
|
default:
|
|
return 'Order Status';
|
|
}
|
|
};
|
|
|
|
const getStatusDescription = (status: EquipmentStatus): string => {
|
|
switch (status) {
|
|
case 'ordered':
|
|
return 'Your WellNuo kit is being prepared for shipping';
|
|
case 'shipped':
|
|
return 'Your kit is on its way! You should receive it soon.';
|
|
case 'delivered':
|
|
return 'Your kit has arrived! Time to set it up.';
|
|
default:
|
|
return '';
|
|
}
|
|
};
|
|
|
|
export default function EquipmentStatusScreen() {
|
|
const { id } = useLocalSearchParams<{ id: string }>();
|
|
const toast = useToast();
|
|
|
|
const [beneficiary, setBeneficiary] = useState<Beneficiary | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isConfirming, setIsConfirming] = useState(false);
|
|
|
|
const loadBeneficiary = useCallback(async () => {
|
|
if (!id) return;
|
|
|
|
if (!isRefreshing) {
|
|
setIsLoading(true);
|
|
}
|
|
setError(null);
|
|
|
|
try {
|
|
const response = await api.getWellNuoBeneficiary(parseInt(id, 10));
|
|
if (response.ok && response.data) {
|
|
setBeneficiary(response.data);
|
|
|
|
// Self-guard: Redirect if user has devices - shouldn't be on this page
|
|
if (hasBeneficiaryDevices(response.data)) {
|
|
router.replace(`/(tabs)/beneficiaries/${id}`);
|
|
return;
|
|
}
|
|
|
|
// Redirect if no equipment order (status is 'none')
|
|
const status = response.data.equipmentStatus;
|
|
if (!status || status === 'none') {
|
|
router.replace(`/(tabs)/beneficiaries/${id}/purchase`);
|
|
return;
|
|
}
|
|
} else {
|
|
setError(response.error?.message || 'Failed to load beneficiary');
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
|
} finally {
|
|
setIsLoading(false);
|
|
setIsRefreshing(false);
|
|
}
|
|
}, [id, isRefreshing]);
|
|
|
|
useEffect(() => {
|
|
loadBeneficiary();
|
|
}, [loadBeneficiary]);
|
|
|
|
const handleRefresh = useCallback(() => {
|
|
setIsRefreshing(true);
|
|
loadBeneficiary();
|
|
}, [loadBeneficiary]);
|
|
|
|
const handleConfirmReceived = async () => {
|
|
if (!beneficiary || !id) return;
|
|
|
|
setIsConfirming(true);
|
|
|
|
try {
|
|
// Call API to mark equipment as delivered/received
|
|
const response = await api.updateBeneficiaryEquipmentStatus(parseInt(id, 10), 'delivered');
|
|
|
|
if (response.ok) {
|
|
toast.success('Kit Received!', 'Now let\'s connect your sensors.');
|
|
// Navigate to activation screen
|
|
router.replace({
|
|
pathname: '/(auth)/activate',
|
|
params: { beneficiaryId: id, lovedOneName: beneficiary.name },
|
|
});
|
|
} else {
|
|
toast.error('Error', response.error?.message || 'Failed to update status');
|
|
}
|
|
} catch (error) {
|
|
toast.error('Error', 'Failed to confirm receipt');
|
|
} finally {
|
|
setIsConfirming(false);
|
|
}
|
|
};
|
|
|
|
const handleGoToActivation = () => {
|
|
if (!beneficiary || !id) return;
|
|
|
|
router.push({
|
|
pathname: '/(auth)/activate',
|
|
params: { beneficiaryId: id, lovedOneName: beneficiary.name },
|
|
});
|
|
};
|
|
|
|
if (isLoading) {
|
|
return <LoadingSpinner fullScreen message="Loading..." />;
|
|
}
|
|
|
|
if (error || !beneficiary) {
|
|
return (
|
|
<FullScreenError
|
|
message={error || 'Beneficiary not found'}
|
|
onRetry={loadBeneficiary}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const equipmentStatus = (beneficiary.equipmentStatus as EquipmentStatus) || 'ordered';
|
|
const activeStepIndex = getActiveStepIndex(equipmentStatus);
|
|
const isDelivered = equipmentStatus === 'delivered';
|
|
|
|
return (
|
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
|
{/* Header */}
|
|
<View style={styles.header}>
|
|
<TouchableOpacity style={styles.backButton} onPress={() => router.replace('/(tabs)')}>
|
|
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
|
|
</TouchableOpacity>
|
|
<Text style={styles.headerTitle}>{beneficiary.name}</Text>
|
|
<View style={styles.placeholder} />
|
|
</View>
|
|
|
|
<ScrollView
|
|
style={styles.content}
|
|
contentContainerStyle={styles.scrollContent}
|
|
showsVerticalScrollIndicator={false}
|
|
refreshControl={
|
|
<RefreshControl refreshing={isRefreshing} onRefresh={handleRefresh} />
|
|
}
|
|
>
|
|
{/* Status Icon */}
|
|
<View style={styles.iconContainer}>
|
|
<Ionicons
|
|
name={isDelivered ? 'checkmark-circle' : 'cube-outline'}
|
|
size={64}
|
|
color={isDelivered ? AppColors.success : AppColors.primary}
|
|
/>
|
|
</View>
|
|
|
|
{/* Title */}
|
|
<Text style={styles.title}>{getStatusTitle(equipmentStatus)}</Text>
|
|
<Text style={styles.subtitle}>{getStatusDescription(equipmentStatus)}</Text>
|
|
|
|
{/* Progress Steps */}
|
|
<View style={styles.stepsContainer}>
|
|
{STATUS_STEPS.map((step, index) => {
|
|
const isCompleted = index < activeStepIndex;
|
|
const isActive = index === activeStepIndex;
|
|
const isPending = index > activeStepIndex;
|
|
|
|
return (
|
|
<View key={step.key} style={styles.stepWrapper}>
|
|
{/* Step indicator */}
|
|
<View style={styles.stepRow}>
|
|
<View
|
|
style={[
|
|
styles.stepCircle,
|
|
isCompleted && styles.stepCircleCompleted,
|
|
isActive && styles.stepCircleActive,
|
|
isPending && styles.stepCirclePending,
|
|
]}
|
|
>
|
|
{isCompleted ? (
|
|
<Ionicons name="checkmark" size={16} color={AppColors.white} />
|
|
) : (
|
|
<View
|
|
style={[
|
|
styles.stepDot,
|
|
isActive && styles.stepDotActive,
|
|
]}
|
|
/>
|
|
)}
|
|
</View>
|
|
<Text
|
|
style={[
|
|
styles.stepLabel,
|
|
isCompleted && styles.stepLabelCompleted,
|
|
isActive && styles.stepLabelActive,
|
|
isPending && styles.stepLabelPending,
|
|
]}
|
|
>
|
|
{step.label}
|
|
</Text>
|
|
</View>
|
|
|
|
{/* Connector line */}
|
|
{index < STATUS_STEPS.length - 1 && (
|
|
<View style={styles.connectorWrapper}>
|
|
<View
|
|
style={[
|
|
styles.connector,
|
|
isCompleted && styles.connectorCompleted,
|
|
]}
|
|
/>
|
|
</View>
|
|
)}
|
|
</View>
|
|
);
|
|
})}
|
|
</View>
|
|
|
|
{/* Action Button */}
|
|
<View style={styles.actionSection}>
|
|
{isDelivered ? (
|
|
<TouchableOpacity
|
|
style={styles.primaryButton}
|
|
onPress={handleGoToActivation}
|
|
>
|
|
<Ionicons name="flash" size={20} color={AppColors.white} />
|
|
<Text style={styles.primaryButtonText}>Set Up My Kit</Text>
|
|
</TouchableOpacity>
|
|
) : (
|
|
<TouchableOpacity
|
|
style={[styles.outlineButton, isConfirming && styles.buttonDisabled]}
|
|
onPress={handleConfirmReceived}
|
|
disabled={isConfirming}
|
|
>
|
|
{isConfirming ? (
|
|
<ActivityIndicator color={AppColors.primary} />
|
|
) : (
|
|
<>
|
|
<Ionicons name="checkmark-circle-outline" size={20} color={AppColors.primary} />
|
|
<Text style={styles.outlineButtonText}>I received my kit</Text>
|
|
</>
|
|
)}
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
|
|
{/* Info Card */}
|
|
<View style={styles.infoCard}>
|
|
<View style={styles.infoHeader}>
|
|
<Ionicons name="information-circle" size={20} color={AppColors.info} />
|
|
<Text style={styles.infoTitle}>What's in the box?</Text>
|
|
</View>
|
|
<View style={styles.infoList}>
|
|
<Text style={styles.infoItem}>• Motion sensor (PIR)</Text>
|
|
<Text style={styles.infoItem}>• Door/window sensor</Text>
|
|
<Text style={styles.infoItem}>• Temperature & humidity sensor</Text>
|
|
<Text style={styles.infoItem}>• WellNuo Hub</Text>
|
|
<Text style={styles.infoItem}>• Quick start guide</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Support Link */}
|
|
<TouchableOpacity style={styles.supportLink}>
|
|
<Ionicons name="help-circle-outline" size={20} color={AppColors.textMuted} />
|
|
<Text style={styles.supportText}>Need help? Contact support</Text>
|
|
<Ionicons name="chevron-forward" size={18} color={AppColors.textMuted} />
|
|
</TouchableOpacity>
|
|
|
|
{/* Back to Loved Ones Button */}
|
|
<TouchableOpacity
|
|
style={styles.backToLovedOnesButton}
|
|
onPress={() => router.replace('/(tabs)/beneficiaries')}
|
|
>
|
|
<Ionicons name="arrow-back" size={20} color={AppColors.primary} />
|
|
<Text style={styles.backToLovedOnesText}>Back to My Loved Ones</Text>
|
|
</TouchableOpacity>
|
|
</ScrollView>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: AppColors.background,
|
|
},
|
|
header: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
paddingHorizontal: Spacing.md,
|
|
paddingVertical: Spacing.sm,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: AppColors.border,
|
|
},
|
|
backButton: {
|
|
padding: Spacing.xs,
|
|
},
|
|
headerTitle: {
|
|
fontSize: FontSizes.lg,
|
|
fontWeight: FontWeights.semibold,
|
|
color: AppColors.textPrimary,
|
|
},
|
|
placeholder: {
|
|
width: 32,
|
|
},
|
|
content: {
|
|
flex: 1,
|
|
},
|
|
scrollContent: {
|
|
padding: Spacing.lg,
|
|
paddingBottom: Spacing.xxl,
|
|
},
|
|
iconContainer: {
|
|
width: 120,
|
|
height: 120,
|
|
borderRadius: 60,
|
|
backgroundColor: AppColors.primaryLighter,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
alignSelf: 'center',
|
|
marginBottom: Spacing.lg,
|
|
},
|
|
title: {
|
|
fontSize: FontSizes['2xl'],
|
|
fontWeight: FontWeights.bold,
|
|
color: AppColors.textPrimary,
|
|
textAlign: 'center',
|
|
marginBottom: Spacing.sm,
|
|
},
|
|
subtitle: {
|
|
fontSize: FontSizes.base,
|
|
color: AppColors.textSecondary,
|
|
textAlign: 'center',
|
|
marginBottom: Spacing.xl,
|
|
lineHeight: 22,
|
|
},
|
|
// Progress Steps
|
|
stepsContainer: {
|
|
backgroundColor: AppColors.surface,
|
|
borderRadius: BorderRadius.xl,
|
|
padding: Spacing.lg,
|
|
marginBottom: Spacing.lg,
|
|
...Shadows.sm,
|
|
},
|
|
stepWrapper: {
|
|
marginBottom: 0,
|
|
},
|
|
stepRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: Spacing.md,
|
|
},
|
|
stepCircle: {
|
|
width: 28,
|
|
height: 28,
|
|
borderRadius: 14,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
borderWidth: 2,
|
|
borderColor: AppColors.border,
|
|
backgroundColor: AppColors.surface,
|
|
},
|
|
stepCircleCompleted: {
|
|
backgroundColor: AppColors.success,
|
|
borderColor: AppColors.success,
|
|
},
|
|
stepCircleActive: {
|
|
borderColor: AppColors.primary,
|
|
backgroundColor: AppColors.surface,
|
|
},
|
|
stepCirclePending: {
|
|
borderColor: AppColors.border,
|
|
backgroundColor: AppColors.surface,
|
|
},
|
|
stepDot: {
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: 4,
|
|
backgroundColor: AppColors.border,
|
|
},
|
|
stepDotActive: {
|
|
backgroundColor: AppColors.primary,
|
|
},
|
|
stepLabel: {
|
|
fontSize: FontSizes.base,
|
|
color: AppColors.textPrimary,
|
|
fontWeight: FontWeights.medium,
|
|
},
|
|
stepLabelCompleted: {
|
|
color: AppColors.success,
|
|
},
|
|
stepLabelActive: {
|
|
color: AppColors.primary,
|
|
fontWeight: FontWeights.semibold,
|
|
},
|
|
stepLabelPending: {
|
|
color: AppColors.textMuted,
|
|
},
|
|
connectorWrapper: {
|
|
paddingLeft: 13, // Half of circle width (28/2) - half of line width
|
|
height: 24,
|
|
justifyContent: 'center',
|
|
},
|
|
connector: {
|
|
width: 2,
|
|
height: '100%',
|
|
backgroundColor: AppColors.border,
|
|
},
|
|
connectorCompleted: {
|
|
backgroundColor: AppColors.success,
|
|
},
|
|
// Action Section
|
|
actionSection: {
|
|
marginBottom: Spacing.lg,
|
|
},
|
|
primaryButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: AppColors.primary,
|
|
paddingVertical: Spacing.md,
|
|
borderRadius: BorderRadius.lg,
|
|
gap: Spacing.sm,
|
|
},
|
|
primaryButtonText: {
|
|
fontSize: FontSizes.base,
|
|
fontWeight: FontWeights.semibold,
|
|
color: AppColors.white,
|
|
},
|
|
outlineButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: AppColors.surface,
|
|
paddingVertical: Spacing.md,
|
|
borderRadius: BorderRadius.lg,
|
|
borderWidth: 1,
|
|
borderColor: AppColors.primary,
|
|
gap: Spacing.sm,
|
|
},
|
|
outlineButtonText: {
|
|
fontSize: FontSizes.base,
|
|
fontWeight: FontWeights.semibold,
|
|
color: AppColors.primary,
|
|
},
|
|
buttonDisabled: {
|
|
opacity: 0.7,
|
|
},
|
|
// Info Card
|
|
infoCard: {
|
|
backgroundColor: AppColors.infoLight,
|
|
borderRadius: BorderRadius.lg,
|
|
padding: Spacing.md,
|
|
marginBottom: Spacing.lg,
|
|
},
|
|
infoHeader: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: Spacing.sm,
|
|
marginBottom: Spacing.sm,
|
|
},
|
|
infoTitle: {
|
|
fontSize: FontSizes.sm,
|
|
fontWeight: FontWeights.semibold,
|
|
color: AppColors.info,
|
|
},
|
|
infoList: {
|
|
gap: 4,
|
|
},
|
|
infoItem: {
|
|
fontSize: FontSizes.sm,
|
|
color: AppColors.info,
|
|
lineHeight: 20,
|
|
},
|
|
// Support Link
|
|
supportLink: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
backgroundColor: AppColors.surface,
|
|
padding: Spacing.md,
|
|
borderRadius: BorderRadius.lg,
|
|
gap: Spacing.md,
|
|
},
|
|
supportText: {
|
|
flex: 1,
|
|
fontSize: FontSizes.base,
|
|
color: AppColors.textSecondary,
|
|
},
|
|
// Back to Loved Ones Button
|
|
backToLovedOnesButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: AppColors.surface,
|
|
paddingVertical: Spacing.md,
|
|
borderRadius: BorderRadius.lg,
|
|
borderWidth: 1,
|
|
borderColor: AppColors.border,
|
|
gap: Spacing.sm,
|
|
marginTop: Spacing.lg,
|
|
},
|
|
backToLovedOnesText: {
|
|
fontSize: FontSizes.base,
|
|
fontWeight: FontWeights.medium,
|
|
color: AppColors.primary,
|
|
},
|
|
});
|