Files
CBS-Mobile-App/src/modules/insurance/screens/BuyInsuranceScreen.tsx
T

623 lines
21 KiB
TypeScript

import React, { useEffect, useMemo, useState } from 'react';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import { useAppDispatch, useAppSelector } from '../../../app/hooks';
import { Button, Card, Input, ScreenShell } from '../../../components';
import { ToggleSwitch } from '../../cards/components/ToggleSwitch';
import { useToast } from '../../../hooks/useToast';
import type { InsuranceStackParamList } from '../../../navigation/types';
import { useTheme, fontFamily } from '../../../theme';
import { formatCompactCurrency, formatCurrency } from '../../../utils/formatters';
import { InsuranceCategoryChip } from '../components/InsuranceCategoryChip';
import { InsuranceOtpModal } from '../components/InsuranceOtpModal';
import { InsurancePaymentMethodTile } from '../components/InsurancePaymentMethodTile';
import { InsurancePlanCard } from '../components/InsurancePlanCard';
import { useInsurancePlans } from '../hooks/useInsurancePlans';
import {
usePurchaseInsurancePolicyMutation,
useRequestInsuranceOtpMutation,
} from '../services/insuranceApi';
import {
selectInsuranceComparedPlanIds,
selectInsuranceDraft,
selectInsurancePurchaseStep,
} from '../store/insuranceSelectors';
import { insuranceActions } from '../store/insuranceSlice';
import { INSURANCE_CATEGORIES, INSURANCE_CATEGORY_META } from '../types';
import { validateInsuranceOtp, validateInsurancePurchaseDraft } from '../utils/insuranceValidation';
type Props = NativeStackScreenProps<InsuranceStackParamList, 'BuyInsurance'>;
const STEP_LABELS = [
{ id: 'details', label: 'Details' },
{ id: 'plans', label: 'Plans' },
{ id: 'payment', label: 'Payment' },
] as const;
export default function BuyInsuranceScreen({ navigation, route }: Props) {
const dispatch = useAppDispatch();
const theme = useTheme();
const toast = useToast();
const draft = useAppSelector(selectInsuranceDraft);
const comparedPlanIds = useAppSelector(selectInsuranceComparedPlanIds);
const purchaseStep = useAppSelector(selectInsurancePurchaseStep);
const { data: plans = [], isLoading } = useInsurancePlans(draft.category);
const [requestOtp, requestOtpState] = useRequestInsuranceOtpMutation();
const [purchasePolicy, purchasePolicyState] = usePurchaseInsurancePolicyMutation();
const [errors, setErrors] = useState<Record<string, string>>({});
const [otp, setOtp] = useState('');
const [otpVisible, setOtpVisible] = useState(false);
const [otpError, setOtpError] = useState<string | undefined>();
const [mockCodeHint, setMockCodeHint] = useState<string | undefined>();
useEffect(() => {
if (route.params?.category) {
dispatch(insuranceActions.setInsuranceCategory(route.params.category));
}
}, [dispatch, route.params?.category]);
const selectedPlan = useMemo(
() => plans.find((plan) => plan.id === draft.selectedPlanId) ?? null,
[draft.selectedPlanId, plans],
);
const handleContinueFromDetails = () => {
const validationErrors = validateInsurancePurchaseDraft(draft, draft.category);
setErrors(validationErrors);
if (Object.keys(validationErrors).length) {
return;
}
dispatch(insuranceActions.setInsurancePurchaseStep('plans'));
};
const handleContinueFromPlans = () => {
if (!draft.selectedPlanId) {
setErrors({ selectedPlanId: 'Select a plan to continue.' });
return;
}
setErrors({});
dispatch(insuranceActions.setInsurancePurchaseStep('payment'));
};
const handleRequestOtp = async () => {
const error = !draft.selectedPlanId ? 'Select a plan to continue.' : undefined;
if (error) {
setErrors({ selectedPlanId: error });
return;
}
const result = await requestOtp({ action: 'purchase' });
if ('data' in result && result.data) {
setMockCodeHint(result.data.mockCodeHint);
setOtpVisible(true);
setOtpError(undefined);
return;
}
toast('Unable to send OTP', 'error', 'We could not send your code right now. Please try again.');
};
const handleConfirmPurchase = async () => {
const error = validateInsuranceOtp(otp);
if (error) {
setOtpError(error);
return;
}
const result = await purchasePolicy({ draft, otp });
if ('data' in result && result.data) {
setOtpVisible(false);
setOtp('');
setOtpError(undefined);
toast(
'Policy issued',
'success',
`${result.data.policy.planName} is now active under ${result.data.policy.policyNumber}.`,
);
dispatch(insuranceActions.resetInsuranceDraft());
navigation.replace('InsurancePolicyDetails', { policyId: result.data.policy.id });
return;
}
setOtpError('Verification failed. Please try again.');
};
const renderStepPills = () => (
<View style={styles.stepRow}>
{STEP_LABELS.map((step) => {
const isActive = purchaseStep === step.id;
return (
<View
key={step.id}
style={[
styles.stepPill,
{
backgroundColor: isActive ? `${theme.colors.primary}12` : theme.colors.surfaceMuted,
},
]}
>
<Text
style={[
styles.stepLabel,
{ color: isActive ? theme.colors.primary : theme.colors.textSecondary },
]}
>
{step.label}
</Text>
</View>
);
})}
</View>
);
return (
<ScreenShell title="Buy Insurance" subtitle="Dynamic policy purchase flow with secure confirmation" scrollable={false}>
<ScrollView contentContainerStyle={styles.scroll} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled">
{renderStepPills()}
<Card style={styles.sectionCard}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Choose category
</Text>
<View style={styles.categoryWrap}>
{INSURANCE_CATEGORIES.map((category) => (
<InsuranceCategoryChip
key={category}
category={category}
selected={draft.category === category}
onPress={() => {
dispatch(insuranceActions.setInsuranceCategory(category));
setErrors({});
}}
/>
))}
</View>
<Text style={[styles.categoryCopy, { color: theme.colors.textSecondary }]}>
{INSURANCE_CATEGORY_META[draft.category].description}
</Text>
</Card>
{purchaseStep === 'details' ? (
<>
<Card style={styles.sectionCard}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Personal details
</Text>
<Input
label="Full Name"
value={draft.personal.fullName}
onChangeText={(value) =>
dispatch(
insuranceActions.updateInsurancePersonal({ field: 'fullName', value }),
)
}
error={errors.personalFullName}
/>
<View style={styles.inputRow}>
<View style={styles.inputCol}>
<Input
label="Age"
keyboardType="number-pad"
value={draft.personal.age}
onChangeText={(value) =>
dispatch(
insuranceActions.updateInsurancePersonal({ field: 'age', value }),
)
}
error={errors.personalAge}
/>
</View>
<View style={styles.inputCol}>
<Input
label="Gender"
value={draft.personal.gender}
onChangeText={(value) =>
dispatch(
insuranceActions.updateInsurancePersonal({ field: 'gender', value }),
)
}
error={errors.personalGender}
/>
</View>
</View>
<Input
label="Email"
keyboardType="email-address"
value={draft.personal.email}
onChangeText={(value) =>
dispatch(
insuranceActions.updateInsurancePersonal({ field: 'email', value }),
)
}
error={errors.personalEmail}
/>
<Input
label="Mobile Number"
keyboardType="number-pad"
value={draft.personal.phone}
onChangeText={(value) =>
dispatch(
insuranceActions.updateInsurancePersonal({ field: 'phone', value }),
)
}
error={errors.personalPhone}
/>
</Card>
<Card style={styles.sectionCard}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Nominee and address
</Text>
<Input
label="Nominee Name"
value={draft.nominee.name}
onChangeText={(value) =>
dispatch(
insuranceActions.updateInsuranceNominee({ field: 'name', value }),
)
}
error={errors.nomineeName}
/>
<View style={styles.inputRow}>
<View style={styles.inputCol}>
<Input
label="Relationship"
value={draft.nominee.relationship}
onChangeText={(value) =>
dispatch(
insuranceActions.updateInsuranceNominee({
field: 'relationship',
value,
}),
)
}
error={errors.nomineeRelationship}
/>
</View>
<View style={styles.inputCol}>
<Input
label="Nominee Age"
keyboardType="number-pad"
value={draft.nominee.age}
onChangeText={(value) =>
dispatch(
insuranceActions.updateInsuranceNominee({ field: 'age', value }),
)
}
error={errors.nomineeAge}
/>
</View>
</View>
<Input
label="Address"
value={draft.address.line1}
onChangeText={(value) =>
dispatch(
insuranceActions.updateInsuranceAddress({ field: 'line1', value }),
)
}
error={errors.addressLine1}
/>
<View style={styles.inputRow}>
<View style={styles.inputCol}>
<Input
label="City"
value={draft.address.city}
onChangeText={(value) =>
dispatch(
insuranceActions.updateInsuranceAddress({ field: 'city', value }),
)
}
error={errors.addressCity}
/>
</View>
<View style={styles.inputCol}>
<Input
label="State"
value={draft.address.state}
onChangeText={(value) =>
dispatch(
insuranceActions.updateInsuranceAddress({ field: 'state', value }),
)
}
error={errors.addressState}
/>
</View>
</View>
<Input
label="PIN Code"
keyboardType="number-pad"
value={draft.address.postalCode}
onChangeText={(value) =>
dispatch(
insuranceActions.updateInsuranceAddress({
field: 'postalCode',
value,
}),
)
}
error={errors.addressPostalCode}
/>
</Card>
<Card style={styles.sectionCard}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
KYC and policy-specific details
</Text>
<Input
label="Aadhaar Number"
keyboardType="number-pad"
value={draft.kyc.aadhaarNumber}
onChangeText={(value) =>
dispatch(insuranceActions.updateInsuranceKyc({ field: 'aadhaarNumber', value }))
}
error={errors.kycAadhaar}
/>
<Input
label="PAN Number"
value={draft.kyc.panNumber}
onChangeText={(value) =>
dispatch(insuranceActions.updateInsuranceKyc({ field: 'panNumber', value }))
}
error={errors.kycPan}
/>
{INSURANCE_CATEGORY_META[draft.category].specificFields.map((field) => (
<Input
key={field.key}
label={field.label}
value={draft.specificDetails[field.key] ?? ''}
onChangeText={(value) =>
dispatch(
insuranceActions.updateInsuranceSpecificDetail({
key: field.key,
value,
}),
)
}
keyboardType={field.keyboardType}
placeholder={field.placeholder}
multiline={field.multiline}
error={errors[`specific_${field.key}`]}
/>
))}
</Card>
<Button title="Continue to Plans" onPress={handleContinueFromDetails} />
</>
) : null}
{purchaseStep === 'plans' ? (
<>
<Card style={styles.sectionCard}>
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Available plans
</Text>
{comparedPlanIds.length >= 2 ? (
<Button
title="Compare"
variant="secondary"
fullWidth={false}
onPress={() =>
navigation.navigate('InsuranceComparison', {
planIds: comparedPlanIds,
category: draft.category,
})
}
/>
) : null}
</View>
{isLoading ? (
<Text style={[styles.helperText, { color: theme.colors.textSecondary }]}>
Loading plans...
</Text>
) : (
plans.map((plan) => (
<InsurancePlanCard
key={plan.id}
plan={plan}
selected={draft.selectedPlanId === plan.id}
compared={comparedPlanIds.includes(plan.id)}
onSelect={() => dispatch(insuranceActions.setInsuranceSelectedPlan(plan.id))}
onCompare={() =>
dispatch(insuranceActions.toggleInsuranceComparedPlan(plan.id))
}
/>
))
)}
{errors.selectedPlanId ? (
<Text style={[styles.errorText, { color: theme.colors.error }]}>
{errors.selectedPlanId}
</Text>
) : null}
</Card>
<View style={styles.actionStack}>
<Button
title="Back to Details"
variant="secondary"
onPress={() => dispatch(insuranceActions.setInsurancePurchaseStep('details'))}
/>
<Button title="Continue to Payment" onPress={handleContinueFromPlans} />
</View>
</>
) : null}
{purchaseStep === 'payment' ? (
<>
<Card style={styles.sectionCard}>
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
Selected plan
</Text>
{selectedPlan ? (
<>
<View
style={[styles.summaryCard, { backgroundColor: theme.colors.surfaceMuted }]}
>
<Text style={[styles.summaryTitle, { color: theme.colors.textPrimary }]}>
{selectedPlan.name}
</Text>
<Text style={[styles.summaryCopy, { color: theme.colors.textSecondary }]}>
{selectedPlan.insurerName}
</Text>
<Text style={[styles.summaryAmount, { color: theme.colors.textPrimary }]}>
{formatCurrency(selectedPlan.premium)} · Coverage{' '}
{formatCompactCurrency(selectedPlan.coverAmount)}
</Text>
</View>
<InsurancePaymentMethodTile
method="UPI"
selected={draft.paymentMethod === 'UPI'}
onPress={() => dispatch(insuranceActions.setInsurancePaymentMethod('UPI'))}
/>
<InsurancePaymentMethodTile
method="Net Banking"
selected={draft.paymentMethod === 'Net Banking'}
onPress={() =>
dispatch(insuranceActions.setInsurancePaymentMethod('Net Banking'))
}
/>
<InsurancePaymentMethodTile
method="Card"
selected={draft.paymentMethod === 'Card'}
onPress={() => dispatch(insuranceActions.setInsurancePaymentMethod('Card'))}
/>
<ToggleSwitch
label="Enable auto-renew"
description="Keep this policy active by auto-paying the premium on the due date."
value={draft.autoRenewEnabled}
onValueChange={(value) => dispatch(insuranceActions.setInsuranceAutoRenew(value))}
/>
</>
) : (
<Text style={[styles.helperText, { color: theme.colors.textSecondary }]}>
No plan selected.
</Text>
)}
</Card>
<View style={styles.actionStack}>
<Button
title="Back to Plans"
variant="secondary"
onPress={() => dispatch(insuranceActions.setInsurancePurchaseStep('plans'))}
/>
<Button
title="Request OTP & Buy"
onPress={() => void handleRequestOtp()}
loading={requestOtpState.isLoading}
/>
</View>
</>
) : null}
</ScrollView>
<InsuranceOtpModal
visible={otpVisible}
title="Verify Policy Purchase"
description="Enter the OTP sent to your registered mobile number to authorize this insurance purchase."
otp={otp}
onOtpChange={setOtp}
onClose={() => setOtpVisible(false)}
onConfirm={() => void handleConfirmPurchase()}
loading={purchasePolicyState.isLoading}
error={otpError}
mockCodeHint={mockCodeHint}
/>
<View style={{ height: 20 }} />
</ScreenShell>
);
}
const styles = StyleSheet.create({
keyboardAvoid: {
flex: 1,
},
scroll: {
gap: 16,
paddingBottom: 28,
},
stepRow: {
flexDirection: 'row',
gap: 10,
},
stepPill: {
flex: 1,
borderRadius: 999,
alignItems: 'center',
paddingVertical: 10,
},
stepLabel: {
fontSize: 11,
fontFamily: fontFamily.bold,
textTransform: 'uppercase',
letterSpacing: 0.5,
},
sectionCard: {
gap: 12,
},
sectionTitle: {
fontSize: 16,
fontFamily: fontFamily.bold,
},
categoryWrap: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 10,
},
categoryCopy: {
fontSize: 12,
lineHeight: 18,
},
inputRow: {
flexDirection: 'row',
gap: 12,
},
inputCol: {
flex: 1,
},
helperText: {
fontSize: 12,
lineHeight: 18,
},
errorText: {
fontSize: 12,
fontFamily: fontFamily.semibold,
},
actionStack: {
gap: 10,
},
summaryCard: {
borderRadius: 14,
padding: 14,
gap: 4,
},
summaryTitle: {
fontSize: 15,
fontFamily: fontFamily.bold,
},
summaryCopy: {
fontSize: 12,
fontFamily: fontFamily.semibold,
},
summaryAmount: {
fontSize: 13,
fontFamily: fontFamily.bold,
},
sectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
},
});