WellNuo/app/(auth)/purchase.tsx
Sergei dad084c775 Add setup progress indicator to onboarding flow
Add SetupProgressIndicator component that shows users their current
position in the 4-step onboarding journey: Name → Beneficiary →
Equipment → Connect. The indicator displays:
- Visual progress bar with percentage fill
- Step circles with icons showing completed/current/pending status
- Current step label with "Step X of 4" text

Integrate the indicator into all four auth screens:
- enter-name.tsx (Step 1)
- add-loved-one.tsx (Step 2)
- purchase.tsx (Step 3)
- activate.tsx (Step 4)

Also add @expo/vector-icons mock to jest.setup.js for testing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-31 17:14:44 -08:00

474 lines
14 KiB
TypeScript

import React, { useState, useEffect, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ActivityIndicator,
Alert,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { router, useLocalSearchParams } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { usePaymentSheet } from '@stripe/stripe-react-native';
import { AppColors, Spacing, BorderRadius, FontSizes, FontWeights, Shadows } from '@/constants/theme';
import { useAuth } from '@/contexts/AuthContext';
import { api } from '@/services/api';
import { SetupProgressIndicator } from '@/components/SetupProgressIndicator';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { hasBeneficiaryDevices } from '@/services/BeneficiaryDetailController';
const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
const STARTER_KIT = {
name: 'WellNuo Starter Kit',
price: '$399',
priceValue: 399,
};
export default function PurchaseScreen() {
const params = useLocalSearchParams<{ lovedOneName?: string; beneficiaryId?: string }>();
const lovedOneName = params.lovedOneName || '';
const beneficiaryId = params.beneficiaryId;
const [isProcessing, setIsProcessing] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [step, setStep] = useState<'purchase' | 'order_placed'>('purchase');
const { user } = useAuth();
const { initPaymentSheet, presentPaymentSheet } = usePaymentSheet();
// Check if equipment is already ordered - redirect to equipment-status
const checkEquipmentStatus = useCallback(async () => {
if (!beneficiaryId) {
setIsLoading(false);
return;
}
try {
const response = await api.getWellNuoBeneficiary(parseInt(beneficiaryId, 10));
if (response.ok && response.data) {
// If user already has devices - go to main screen
if (hasBeneficiaryDevices(response.data)) {
router.replace(`/(tabs)/beneficiaries/${beneficiaryId}`);
return;
}
// If equipment is ordered/shipped/delivered - go to equipment-status
const status = response.data.equipmentStatus;
if (status && ['ordered', 'shipped', 'delivered'].includes(status)) {
router.replace(`/(tabs)/beneficiaries/${beneficiaryId}/equipment-status`);
return;
}
}
} catch (error) {
// Failed to check equipment status, continue to purchase screen
}
setIsLoading(false);
}, [beneficiaryId]);
useEffect(() => {
checkEquipmentStatus();
}, [checkEquipmentStatus]);
const handlePurchase = async () => {
setIsProcessing(true);
try {
const userId = user?.user_id;
if (!userId) {
Alert.alert('Error', 'User not authenticated. Please log in again.');
setIsProcessing(false);
return;
}
if (!beneficiaryId) {
Alert.alert('Error', 'Beneficiary not found. Please try again.');
setIsProcessing(false);
return;
}
const token = await api.getToken();
if (!token) {
Alert.alert('Error', 'Please log in again');
setIsProcessing(false);
return;
}
const response = await fetch(`${STRIPE_API_URL}/create-payment-sheet`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
email: user?.email,
amount: STARTER_KIT.priceValue * 100,
metadata: {
userId: String(userId),
beneficiaryId: String(beneficiaryId),
beneficiaryName: lovedOneName || 'To be configured',
orderType: 'starter_kit',
},
}),
});
const data = await response.json();
if (!data.paymentIntent) {
throw new Error(data.error || 'Failed to create payment sheet');
}
const { error: initError } = await initPaymentSheet({
merchantDisplayName: 'WellNuo',
paymentIntentClientSecret: data.paymentIntent,
customerId: data.customer,
customerEphemeralKeySecret: data.ephemeralKey,
defaultBillingDetails: {
email: user?.email || '',
},
returnURL: 'wellnuo://stripe-redirect',
applePay: {
merchantCountryCode: 'US',
},
googlePay: {
merchantCountryCode: 'US',
testEnv: true,
},
});
if (initError) {
throw new Error(initError.message);
}
const { error: presentError } = await presentPaymentSheet();
if (presentError) {
if (presentError.code === 'Canceled') {
setIsProcessing(false);
return;
}
throw new Error(presentError.message);
}
await api.updateBeneficiaryEquipmentStatus(
parseInt(beneficiaryId, 10),
'ordered'
);
await api.setOnboardingCompleted(true);
// Redirect directly to equipment-status page (skip order_placed screen)
router.replace(`/(tabs)/beneficiaries/${beneficiaryId}/equipment-status`);
} catch (error) {
Alert.alert(
'Payment Failed',
error instanceof Error ? error.message : 'Something went wrong. Please try again.'
);
}
setIsProcessing(false);
};
const handleAlreadyHaveSensors = () => {
router.replace({
pathname: '/(auth)/activate',
params: { beneficiaryId, lovedOneName },
});
};
const handleGoToEquipmentStatus = () => {
if (beneficiaryId) {
router.replace(`/(tabs)/beneficiaries/${beneficiaryId}/equipment-status`);
} else {
router.replace('/(tabs)');
}
};
// Loading state - checking equipment status
if (isLoading) {
return <LoadingSpinner fullScreen message="Loading..." />;
}
// Order Placed Screen
if (step === 'order_placed') {
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.orderPlacedContainer}>
<View style={styles.successIcon}>
<Ionicons name="checkmark" size={64} color={AppColors.white} />
</View>
<Text style={styles.orderPlacedTitle}>Order Placed!</Text>
<Text style={styles.orderPlacedSubtitle}>
Thank you for your purchase
</Text>
<View style={styles.orderInfoCard}>
<View style={styles.orderInfoRow}>
<Text style={styles.orderInfoLabel}>Item</Text>
<Text style={styles.orderInfoValue}>{STARTER_KIT.name}</Text>
</View>
<View style={styles.orderInfoRow}>
<Text style={styles.orderInfoLabel}>For</Text>
<Text style={styles.orderInfoValue}>{lovedOneName || 'Your loved one'}</Text>
</View>
<View style={[styles.orderInfoRow, { borderBottomWidth: 0 }]}>
<Text style={styles.orderInfoLabel}>Total</Text>
<Text style={[styles.orderInfoValue, styles.orderInfoPrice]}>{STARTER_KIT.price}</Text>
</View>
</View>
<TouchableOpacity style={styles.primaryButton} onPress={handleGoToEquipmentStatus}>
<Text style={styles.primaryButtonText}>Track My Order</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.content}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => router.canGoBack() ? router.back() : router.replace('/(tabs)')}
>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.title}>Get Started</Text>
<View style={styles.placeholder} />
</View>
{/* Setup Progress */}
<SetupProgressIndicator currentStep="purchase" />
{/* Product Card */}
<View style={styles.productCard}>
<View style={styles.productIcon}>
<Ionicons name="hardware-chip" size={48} color={AppColors.primary} />
</View>
<Text style={styles.productName}>{STARTER_KIT.name}</Text>
<Text style={styles.productPrice}>{STARTER_KIT.price}</Text>
<Text style={styles.productDescription}>
5 smart sensors that easily plug into any outlet and set up through the app in minutes
</Text>
{/* Security Badge */}
<View style={styles.securityBadge}>
<Ionicons name="shield-checkmark" size={16} color={AppColors.success} />
<Text style={styles.securityText}>Secure payment powered by Stripe</Text>
</View>
</View>
{/* Bottom Actions */}
<View style={styles.bottomActions}>
<TouchableOpacity
style={[styles.purchaseButton, isProcessing && styles.buttonDisabled]}
onPress={handlePurchase}
disabled={isProcessing}
>
{isProcessing ? (
<ActivityIndicator color={AppColors.white} />
) : (
<>
<Ionicons name="card" size={20} color={AppColors.white} />
<Text style={styles.purchaseButtonText}>Buy Now</Text>
</>
)}
</TouchableOpacity>
<TouchableOpacity style={styles.skipButton} onPress={handleAlreadyHaveSensors}>
<Text style={styles.skipButtonText}>I already have sensors</Text>
</TouchableOpacity>
</View>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: AppColors.background,
},
content: {
flex: 1,
padding: Spacing.lg,
justifyContent: 'space-between',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
backButton: {
padding: Spacing.sm,
marginLeft: -Spacing.sm,
},
title: {
fontSize: FontSizes.xl,
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
},
placeholder: {
width: 40,
},
productCard: {
backgroundColor: AppColors.white,
borderRadius: BorderRadius.xl,
padding: Spacing.xl,
alignItems: 'center',
borderWidth: 2,
borderColor: AppColors.primary,
...Shadows.md,
},
productIcon: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: `${AppColors.primary}15`,
alignItems: 'center',
justifyContent: 'center',
marginBottom: Spacing.lg,
},
productName: {
fontSize: FontSizes.xl,
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
textAlign: 'center',
marginBottom: Spacing.xs,
},
productPrice: {
fontSize: FontSizes['3xl'],
fontWeight: FontWeights.bold,
color: AppColors.primary,
marginBottom: Spacing.lg,
},
productDescription: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
textAlign: 'center',
lineHeight: 22,
marginBottom: Spacing.lg,
},
securityBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.xs,
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.md,
backgroundColor: `${AppColors.success}10`,
borderRadius: BorderRadius.lg,
},
securityText: {
fontSize: FontSizes.sm,
color: AppColors.success,
},
bottomActions: {
gap: Spacing.md,
},
purchaseButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: Spacing.sm,
backgroundColor: AppColors.primary,
paddingVertical: Spacing.lg,
borderRadius: BorderRadius.lg,
...Shadows.primary,
},
buttonDisabled: {
opacity: 0.7,
},
purchaseButtonText: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
skipButton: {
alignItems: 'center',
paddingVertical: Spacing.sm,
},
skipButtonText: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
textDecorationLine: 'underline',
},
// Order Placed Screen
orderPlacedContainer: {
flex: 1,
padding: Spacing.lg,
alignItems: 'center',
justifyContent: 'center',
},
successIcon: {
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: AppColors.success,
alignItems: 'center',
justifyContent: 'center',
marginBottom: Spacing.xl,
...Shadows.lg,
},
orderPlacedTitle: {
fontSize: FontSizes['2xl'],
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
marginBottom: Spacing.xs,
},
orderPlacedSubtitle: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
marginBottom: Spacing.xl,
},
orderInfoCard: {
width: '100%',
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
padding: Spacing.lg,
marginBottom: Spacing.xl,
...Shadows.sm,
},
orderInfoRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: Spacing.sm,
borderBottomWidth: 1,
borderBottomColor: AppColors.border,
},
orderInfoLabel: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
},
orderInfoValue: {
fontSize: FontSizes.base,
fontWeight: FontWeights.medium,
color: AppColors.textPrimary,
},
orderInfoPrice: {
color: AppColors.primary,
fontWeight: FontWeights.bold,
},
primaryButton: {
width: '100%',
backgroundColor: AppColors.primary,
paddingVertical: Spacing.lg,
borderRadius: BorderRadius.lg,
alignItems: 'center',
...Shadows.primary,
},
primaryButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
});