623 lines
21 KiB
TypeScript
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,
|
|
},
|
|
});
|