WellNuo/components/screens/purchase/PurchaseScreen.native.tsx
Sergei 5e0b38748b Update Stripe integration, API services, and purchase screens
- Update purchase screens (auth and beneficiary)
- Update Stripe configuration and setup scripts
- Update api.ts services
- Update espProvisioning and sherpaTTS services
- Update verify-otp flow
- Package updates
2026-01-12 21:44:57 -08:00

385 lines
11 KiB
TypeScript

import React, { useState, useEffect, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
ActivityIndicator,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
import { router, useLocalSearchParams } from 'expo-router';
import { usePaymentSheet } from '@stripe/stripe-react-native';
import { api } from '@/services/api';
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { useAuth } from '@/contexts/AuthContext';
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';
const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
const STARTER_KIT = {
name: 'WellNuo Starter Kit',
price: '$399',
priceValue: 399,
description: '5 smart sensors that easily plug into any outlet and set up through the app in minutes',
};
export default function PurchaseScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { user } = useAuth();
const toast = useToast();
const [beneficiary, setBeneficiary] = useState<Beneficiary | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const { initPaymentSheet, presentPaymentSheet } = usePaymentSheet();
const loadBeneficiary = useCallback(async () => {
if (!id) return;
setIsLoading(true);
setError(null);
try {
const response = await api.getWellNuoBeneficiary(parseInt(id, 10));
if (response.ok && response.data) {
setBeneficiary(response.data);
// Redirect if user already has devices - shouldn't be on purchase page
if (hasBeneficiaryDevices(response.data)) {
router.replace(`/(tabs)/beneficiaries/${id}`);
return;
}
// Redirect if equipment is ordered/shipped/delivered
const status = response.data.equipmentStatus;
if (status && ['ordered', 'shipped', 'delivered'].includes(status)) {
router.replace(`/(tabs)/beneficiaries/${id}/equipment-status`);
return;
}
} else {
setError(response.error?.message || 'Failed to load beneficiary');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setIsLoading(false);
}
}, [id]);
useEffect(() => {
loadBeneficiary();
}, [loadBeneficiary]);
const handlePurchase = async () => {
if (!beneficiary) return;
setIsProcessing(true);
try {
const response = await fetch(`${STRIPE_API_URL}/create-payment-sheet`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: user?.email || 'guest@wellnuo.com',
amount: STARTER_KIT.priceValue * 100,
metadata: {
userId: user?.user_id || 'guest',
beneficiaryName: beneficiary.name,
beneficiaryId: id,
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);
}
// Payment successful - update equipment status to 'ordered'
const statusResponse = await api.updateBeneficiaryEquipmentStatus(
parseInt(id, 10),
'ordered'
);
if (!statusResponse.ok) {
console.warn('Failed to update equipment status:', statusResponse.error?.message);
// Continue anyway - payment was successful
}
// Show success and navigate to equipment tracking
toast.success('Order Placed!', 'Your WellNuo Starter Kit is on its way.');
router.replace(`/(tabs)/beneficiaries/${id}/equipment-status`);
} catch (error) {
console.error('Payment error:', error);
toast.error(
'Payment Failed',
error instanceof Error ? error.message : 'Something went wrong. Please try again.'
);
} finally {
setIsProcessing(false);
}
};
const handleAlreadyHaveSensors = () => {
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}
/>
);
}
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.headerTitle}>Get Started</Text>
<View style={styles.placeholder} />
</View>
<ScrollView
style={styles.content}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{/* Icon */}
<View style={styles.iconContainer}>
<Ionicons name="hardware-chip-outline" size={64} color={AppColors.primary} />
</View>
{/* Title */}
<Text style={styles.title}>Start Monitoring {beneficiary.name}</Text>
<Text style={styles.subtitle}>
To monitor wellness, you need WellNuo sensors installed in their home.
</Text>
{/* Kit Card */}
<View style={styles.kitCard}>
<Text style={styles.kitName}>{STARTER_KIT.name}</Text>
<Text style={styles.kitPrice}>{STARTER_KIT.price}</Text>
<Text style={styles.kitDescription}>{STARTER_KIT.description}</Text>
{/* Security Badge */}
<View style={styles.securityBadge}>
<Ionicons name="shield-checkmark" size={16} color={AppColors.success} />
<Text style={styles.securityText}>Secure payment by Stripe</Text>
</View>
</View>
{/* Actions */}
<View style={styles.actionsSection}>
<TouchableOpacity
style={[styles.buyButton, isProcessing && styles.buyButtonDisabled]}
onPress={handlePurchase}
disabled={isProcessing}
>
{isProcessing ? (
<ActivityIndicator color={AppColors.white} />
) : (
<>
<Ionicons name="card" size={20} color={AppColors.white} />
<Text style={styles.buyButtonText}>Buy Now</Text>
</>
)}
</TouchableOpacity>
<TouchableOpacity style={styles.alreadyHaveButton} onPress={handleAlreadyHaveSensors}>
<Text style={styles.alreadyHaveText}>I already have sensors</Text>
</TouchableOpacity>
</View>
</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,
},
kitCard: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.lg,
marginBottom: Spacing.lg,
...Shadows.md,
},
kitName: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
textAlign: 'center',
},
kitPrice: {
fontSize: FontSizes['3xl'],
fontWeight: FontWeights.bold,
color: AppColors.primary,
textAlign: 'center',
marginVertical: Spacing.md,
},
kitDescription: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
textAlign: 'center',
lineHeight: 22,
},
actionsSection: {
gap: Spacing.md,
},
buyButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: AppColors.primary,
paddingVertical: Spacing.md,
borderRadius: BorderRadius.lg,
gap: Spacing.sm,
},
buyButtonDisabled: {
opacity: 0.7,
},
buyButtonText: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
securityBadge: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginTop: Spacing.md,
gap: Spacing.xs,
},
securityText: {
fontSize: FontSizes.xs,
color: AppColors.success,
},
alreadyHaveButton: {
alignItems: 'center',
paddingVertical: Spacing.sm,
},
alreadyHaveText: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
textDecorationLine: 'underline',
},
});