WellNuo/app/(tabs)/beneficiaries/[id]/equipment-status.tsx
Sergei 06802c237b Improve subscription flow, Stripe integration & auth context
- Refactor subscription page with simplified UI flow
- Update Stripe routes and config for price handling
- Improve AuthContext with better profile management
- Fix equipment status and beneficiary screens
- Update voice screen and profile pages
- Simplify purchase flow
2026-01-08 21:35:24 -08:00

543 lines
15 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>
</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,
},
});