Fix form keyboard issues and sticky footers in agent screens
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import React, { memo } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { useTheme } from '../../../theme';
|
||||
import type { InsuranceCategory } from '../types';
|
||||
import { INSURANCE_CATEGORY_META } from '../types';
|
||||
|
||||
interface InsuranceCategoryChipProps {
|
||||
category: InsuranceCategory;
|
||||
selected: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
const InsuranceCategoryChipComponent = ({
|
||||
category,
|
||||
selected,
|
||||
onPress,
|
||||
}: InsuranceCategoryChipProps) => {
|
||||
const theme = useTheme();
|
||||
const meta = INSURANCE_CATEGORY_META[category];
|
||||
|
||||
return (
|
||||
<Pressable onPress={onPress}>
|
||||
<View
|
||||
style={[
|
||||
styles.chip,
|
||||
{
|
||||
borderColor: selected ? meta.accentColor : theme.colors.cardBorder,
|
||||
backgroundColor: selected ? `${meta.accentColor}10` : theme.colors.surface,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.iconWrap,
|
||||
{
|
||||
backgroundColor: selected ? `${meta.accentColor}18` : theme.colors.surfaceMuted,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
name={meta.icon as any}
|
||||
size={18}
|
||||
color={selected ? meta.accentColor : theme.colors.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
styles.label,
|
||||
{
|
||||
color: selected ? theme.colors.textPrimary : theme.colors.textSecondary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{category}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
chip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
borderWidth: 1,
|
||||
borderRadius: 16,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
iconWrap: {
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
label: {
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
||||
export const InsuranceCategoryChip = memo(InsuranceCategoryChipComponent);
|
||||
@@ -0,0 +1,78 @@
|
||||
import React, { memo } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { Card } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCurrency, formatDate } from '../../../utils/formatters';
|
||||
import type { InsuranceClaim } from '../types';
|
||||
import { InsuranceStatusPill } from './InsuranceStatusPill';
|
||||
|
||||
interface InsuranceClaimCardProps {
|
||||
claim: InsuranceClaim;
|
||||
}
|
||||
|
||||
const InsuranceClaimCardComponent = ({ claim }: InsuranceClaimCardProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Card style={styles.card}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.copy}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{claim.reason}</Text>
|
||||
<Text style={[styles.meta, { color: theme.colors.textSecondary }]}>
|
||||
{claim.claimNumber} · {claim.policyName}
|
||||
</Text>
|
||||
</View>
|
||||
<InsuranceStatusPill status={claim.status} />
|
||||
</View>
|
||||
<Text style={[styles.notes, { color: theme.colors.textMuted }]}>{claim.notes}</Text>
|
||||
<View style={styles.metrics}>
|
||||
<Text style={[styles.metricText, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(claim.amount)}
|
||||
</Text>
|
||||
<Text style={[styles.metricText, { color: theme.colors.textSecondary }]}>
|
||||
Event {formatDate(claim.eventDate)}
|
||||
</Text>
|
||||
<Text style={[styles.metricText, { color: theme.colors.textSecondary }]}>
|
||||
Updated {formatDate(claim.updatedAt)}
|
||||
</Text>
|
||||
</View>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
gap: 12,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
},
|
||||
copy: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
title: {
|
||||
fontSize: 15,
|
||||
fontWeight: '800',
|
||||
},
|
||||
meta: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
notes: {
|
||||
fontSize: 12,
|
||||
lineHeight: 18,
|
||||
},
|
||||
metrics: {
|
||||
gap: 4,
|
||||
},
|
||||
metricText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export const InsuranceClaimCard = memo(InsuranceClaimCardComponent);
|
||||
@@ -0,0 +1,106 @@
|
||||
import React, { memo } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatDate } from '../../../utils/formatters';
|
||||
import type { InsuranceDocument } from '../types';
|
||||
import { InsuranceStatusPill } from './InsuranceStatusPill';
|
||||
|
||||
interface InsuranceDocumentItemProps {
|
||||
document: InsuranceDocument;
|
||||
onView?: () => void;
|
||||
onReupload?: () => void;
|
||||
}
|
||||
|
||||
const InsuranceDocumentItemComponent = ({
|
||||
document,
|
||||
onView,
|
||||
onReupload,
|
||||
}: InsuranceDocumentItemProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.card,
|
||||
{
|
||||
borderColor: theme.colors.cardBorder,
|
||||
backgroundColor: theme.colors.surface,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={[styles.iconWrap, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Ionicons name="document-text-outline" size={20} color={theme.colors.primary} />
|
||||
</View>
|
||||
<View style={styles.copy}>
|
||||
<Text style={[styles.name, { color: theme.colors.textPrimary }]}>{document.name}</Text>
|
||||
<Text style={[styles.meta, { color: theme.colors.textMuted }]}>
|
||||
{document.type} · {formatDate(document.uploadedAt)}
|
||||
</Text>
|
||||
{document.rejectionReason ? (
|
||||
<Text style={[styles.reason, { color: theme.colors.error }]}>
|
||||
{document.rejectionReason}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.actions}>
|
||||
<InsuranceStatusPill status={document.status} />
|
||||
{onView ? (
|
||||
<Pressable onPress={onView}>
|
||||
<Text style={[styles.link, { color: theme.colors.primary }]}>View</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{onReupload ? (
|
||||
<Pressable onPress={onReupload}>
|
||||
<Text style={[styles.link, { color: theme.colors.primary }]}>Re-upload</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
padding: 14,
|
||||
gap: 12,
|
||||
},
|
||||
iconWrap: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
copy: {
|
||||
flex: 1,
|
||||
gap: 3,
|
||||
},
|
||||
name: {
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
},
|
||||
meta: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
reason: {
|
||||
fontSize: 11,
|
||||
lineHeight: 16,
|
||||
},
|
||||
actions: {
|
||||
alignItems: 'flex-end',
|
||||
gap: 6,
|
||||
},
|
||||
link: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
||||
export const InsuranceDocumentItem = memo(InsuranceDocumentItemComponent);
|
||||
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { AppModal, Button, Input } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
|
||||
interface InsuranceOtpModalProps {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
otp: string;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
mockCodeHint?: string;
|
||||
onOtpChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export const InsuranceOtpModal = ({
|
||||
visible,
|
||||
title,
|
||||
description,
|
||||
otp,
|
||||
loading = false,
|
||||
error,
|
||||
mockCodeHint,
|
||||
onOtpChange,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: InsuranceOtpModalProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<AppModal visible={visible} title={title} description={description} onClose={onClose}>
|
||||
<Input
|
||||
label="OTP"
|
||||
value={otp}
|
||||
onChangeText={onOtpChange}
|
||||
keyboardType="number-pad"
|
||||
maxLength={6}
|
||||
placeholder="Enter 6-digit OTP"
|
||||
error={error}
|
||||
/>
|
||||
{mockCodeHint ? (
|
||||
<View
|
||||
style={[styles.hintCard, { backgroundColor: theme.colors.surfaceMuted }]}
|
||||
>
|
||||
<Text style={[styles.hintLabel, { color: theme.colors.textSecondary }]}>
|
||||
Mock OTP
|
||||
</Text>
|
||||
<Text style={[styles.hintValue, { color: theme.colors.textPrimary }]}>
|
||||
{mockCodeHint}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<Button title="Verify & Continue" onPress={onConfirm} loading={loading} />
|
||||
</AppModal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
hintCard: {
|
||||
borderRadius: 14,
|
||||
padding: 12,
|
||||
gap: 4,
|
||||
},
|
||||
hintLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
hintValue: {
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import React, { memo } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { useTheme } from '../../../theme';
|
||||
import type { InsurancePaymentMethod } from '../types';
|
||||
|
||||
interface InsurancePaymentMethodTileProps {
|
||||
method: InsurancePaymentMethod;
|
||||
selected: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
const METHOD_META: Record<
|
||||
InsurancePaymentMethod,
|
||||
{ icon: string; label: string; description: string }
|
||||
> = {
|
||||
UPI: {
|
||||
icon: 'phone-portrait-outline',
|
||||
label: 'UPI',
|
||||
description: 'Google Pay, PhonePe, BHIM, and other UPI apps',
|
||||
},
|
||||
'Net Banking': {
|
||||
icon: 'globe-outline',
|
||||
label: 'Net Banking',
|
||||
description: 'Pay directly from your linked bank account',
|
||||
},
|
||||
Card: {
|
||||
icon: 'card-outline',
|
||||
label: 'Card',
|
||||
description: 'Credit and debit cards with 3DS verification',
|
||||
},
|
||||
};
|
||||
|
||||
const InsurancePaymentMethodTileComponent = ({
|
||||
method,
|
||||
selected,
|
||||
onPress,
|
||||
}: InsurancePaymentMethodTileProps) => {
|
||||
const theme = useTheme();
|
||||
const meta = METHOD_META[method];
|
||||
|
||||
return (
|
||||
<Pressable onPress={onPress}>
|
||||
<View
|
||||
style={[
|
||||
styles.tile,
|
||||
{
|
||||
borderColor: selected ? theme.colors.primary : theme.colors.cardBorder,
|
||||
borderWidth: selected ? 2 : 1,
|
||||
backgroundColor: selected ? `${theme.colors.primary}08` : theme.colors.surface,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.iconWrap,
|
||||
{
|
||||
backgroundColor: selected ? `${theme.colors.primary}14` : theme.colors.surfaceMuted,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
name={meta.icon as any}
|
||||
size={20}
|
||||
color={selected ? theme.colors.primary : theme.colors.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.copy}>
|
||||
<Text style={[styles.label, { color: theme.colors.textPrimary }]}>{meta.label}</Text>
|
||||
<Text style={[styles.description, { color: theme.colors.textMuted }]}>
|
||||
{meta.description}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons
|
||||
name={selected ? 'radio-button-on' : 'radio-button-off'}
|
||||
size={20}
|
||||
color={selected ? theme.colors.primary : theme.colors.textMuted}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
tile: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
borderRadius: 16,
|
||||
padding: 14,
|
||||
},
|
||||
iconWrap: {
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
copy: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
},
|
||||
description: {
|
||||
fontSize: 12,
|
||||
lineHeight: 17,
|
||||
},
|
||||
});
|
||||
|
||||
export const InsurancePaymentMethodTile = memo(InsurancePaymentMethodTileComponent);
|
||||
@@ -0,0 +1,230 @@
|
||||
import React, { memo } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { Button, Card } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import type { InsurancePlan } from '../types';
|
||||
import { formatCompactCurrency, formatCurrency } from '../../../utils/formatters';
|
||||
|
||||
interface InsurancePlanCardProps {
|
||||
plan: InsurancePlan;
|
||||
selected?: boolean;
|
||||
compared?: boolean;
|
||||
onSelect?: () => void;
|
||||
onCompare?: () => void;
|
||||
}
|
||||
|
||||
const InsurancePlanCardComponent = ({
|
||||
plan,
|
||||
selected = false,
|
||||
compared = false,
|
||||
onSelect,
|
||||
onCompare,
|
||||
}: InsurancePlanCardProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={[
|
||||
styles.card,
|
||||
{
|
||||
borderColor: selected ? theme.colors.primary : theme.colors.cardBorder,
|
||||
borderWidth: selected ? 2 : 1,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.copy}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>{plan.name}</Text>
|
||||
<Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>
|
||||
{plan.insurerName}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[styles.ratioBadge, { backgroundColor: `${theme.colors.accent}14` }]}
|
||||
>
|
||||
<Text style={[styles.ratioValue, { color: theme.colors.accent }]}>
|
||||
{plan.claimSettlementRatio}%
|
||||
</Text>
|
||||
<Text style={[styles.ratioLabel, { color: theme.colors.textSecondary }]}>
|
||||
CSR
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.summary, { color: theme.colors.textMuted }]}>{plan.summary}</Text>
|
||||
|
||||
<View style={styles.metrics}>
|
||||
<View style={[styles.metricCard, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.metricLabel, { color: theme.colors.textSecondary }]}>
|
||||
Premium
|
||||
</Text>
|
||||
<Text style={[styles.metricValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(plan.premium)}
|
||||
</Text>
|
||||
<Text style={[styles.metricCaption, { color: theme.colors.textMuted }]}>
|
||||
{plan.premiumFrequency}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.metricCard, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.metricLabel, { color: theme.colors.textSecondary }]}>
|
||||
Coverage
|
||||
</Text>
|
||||
<Text style={[styles.metricValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCompactCurrency(plan.coverAmount)}
|
||||
</Text>
|
||||
<Text style={[styles.metricCaption, { color: theme.colors.textMuted }]}>
|
||||
{plan.tenureYears} year tenure
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.listWrap}>
|
||||
{plan.benefits.slice(0, 3).map((benefit) => (
|
||||
<View key={benefit} style={styles.listRow}>
|
||||
<Ionicons name="checkmark-circle" size={16} color={theme.colors.accent} />
|
||||
<Text style={[styles.listText, { color: theme.colors.textPrimary }]}>{benefit}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.actionRow}>
|
||||
<Pressable
|
||||
onPress={onCompare}
|
||||
style={({ pressed }) => [
|
||||
styles.compareButton,
|
||||
{
|
||||
backgroundColor: compared ? `${theme.colors.info}12` : theme.colors.surfaceMuted,
|
||||
opacity: pressed ? 0.9 : 1,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Ionicons
|
||||
name={compared ? 'checkbox' : 'square-outline'}
|
||||
size={18}
|
||||
color={compared ? theme.colors.info : theme.colors.textMuted}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.compareLabel,
|
||||
{ color: compared ? theme.colors.info : theme.colors.textSecondary },
|
||||
]}
|
||||
>
|
||||
Compare
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Button
|
||||
title={selected ? 'Selected' : 'Select Plan'}
|
||||
onPress={onSelect}
|
||||
variant={selected ? 'secondary' : 'primary'}
|
||||
fullWidth={false}
|
||||
style={styles.selectButton}
|
||||
/>
|
||||
</View>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
gap: 14,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
copy: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
ratioBadge: {
|
||||
minWidth: 68,
|
||||
borderRadius: 14,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
ratioValue: {
|
||||
fontSize: 16,
|
||||
fontWeight: '900',
|
||||
},
|
||||
ratioLabel: {
|
||||
fontSize: 10,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
summary: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
metrics: {
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
},
|
||||
metricCard: {
|
||||
flex: 1,
|
||||
borderRadius: 14,
|
||||
padding: 12,
|
||||
gap: 4,
|
||||
},
|
||||
metricLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
metricValue: {
|
||||
fontSize: 18,
|
||||
fontWeight: '900',
|
||||
},
|
||||
metricCaption: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
},
|
||||
listWrap: {
|
||||
gap: 8,
|
||||
},
|
||||
listRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
listText: {
|
||||
flex: 1,
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
lineHeight: 17,
|
||||
},
|
||||
actionRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
alignItems: 'center',
|
||||
},
|
||||
compareButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
compareLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
},
|
||||
selectButton: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
export const InsurancePlanCard = memo(InsurancePlanCardComponent);
|
||||
@@ -0,0 +1,123 @@
|
||||
import React, { memo } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { Card } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCompactCurrency, formatCurrency, formatDate } from '../../../utils/formatters';
|
||||
import type { InsurancePolicy } from '../types';
|
||||
import { InsuranceStatusPill } from './InsuranceStatusPill';
|
||||
|
||||
interface InsurancePolicyCardProps {
|
||||
policy: InsurancePolicy;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
const InsurancePolicyCardComponent = ({ policy, onPress }: InsurancePolicyCardProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Pressable onPress={onPress}>
|
||||
<Card style={styles.card}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.copy}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>
|
||||
{policy.planName}
|
||||
</Text>
|
||||
<Text style={[styles.meta, { color: theme.colors.textSecondary }]}>
|
||||
{policy.policyNumber} · {policy.category}
|
||||
</Text>
|
||||
</View>
|
||||
<InsuranceStatusPill status={policy.status} />
|
||||
</View>
|
||||
|
||||
<View style={styles.metrics}>
|
||||
<View style={styles.metric}>
|
||||
<Text style={[styles.metricLabel, { color: theme.colors.textMuted }]}>Coverage</Text>
|
||||
<Text style={[styles.metricValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCompactCurrency(policy.coverageAmount)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.metric}>
|
||||
<Text style={[styles.metricLabel, { color: theme.colors.textMuted }]}>Premium</Text>
|
||||
<Text style={[styles.metricValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(policy.premiumAmount)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<View style={styles.footerItem}>
|
||||
<Ionicons name="time-outline" size={16} color={theme.colors.primary} />
|
||||
<Text style={[styles.footerText, { color: theme.colors.textSecondary }]}>
|
||||
Due {formatDate(policy.nextPremiumDueDate)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.footerItem}>
|
||||
<Ionicons name="document-text-outline" size={16} color={theme.colors.primary} />
|
||||
<Text style={[styles.footerText, { color: theme.colors.textSecondary }]}>
|
||||
{policy.documents.length} document{policy.documents.length === 1 ? '' : 's'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
gap: 14,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
},
|
||||
copy: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
},
|
||||
meta: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
metrics: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
metric: {
|
||||
flex: 1,
|
||||
},
|
||||
metricLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
metricValue: {
|
||||
fontSize: 18,
|
||||
fontWeight: '900',
|
||||
marginTop: 4,
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 14,
|
||||
},
|
||||
footerItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export const InsurancePolicyCard = memo(InsurancePolicyCardComponent);
|
||||
@@ -0,0 +1,146 @@
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
PanResponder,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
type LayoutChangeEvent,
|
||||
} from 'react-native';
|
||||
|
||||
import { useTheme } from '../../../theme';
|
||||
|
||||
interface InsuranceRangeSliderProps {
|
||||
label: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
value: number;
|
||||
suffix?: string;
|
||||
formatter?: (value: number) => string;
|
||||
onValueChange: (value: number) => void;
|
||||
}
|
||||
|
||||
export const InsuranceRangeSlider = ({
|
||||
label,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
value,
|
||||
suffix,
|
||||
formatter,
|
||||
onValueChange,
|
||||
}: InsuranceRangeSliderProps) => {
|
||||
const theme = useTheme();
|
||||
const [trackWidth, setTrackWidth] = useState(0);
|
||||
const latestWidth = useRef(0);
|
||||
|
||||
const percentage = useMemo(() => {
|
||||
if (max === min) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (value - min) / (max - min);
|
||||
}, [max, min, value]);
|
||||
|
||||
const updateFromPosition = (positionX: number) => {
|
||||
if (!latestWidth.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clamped = Math.max(0, Math.min(positionX, latestWidth.current));
|
||||
const rawValue = min + (clamped / latestWidth.current) * (max - min);
|
||||
const steppedValue = Math.round(rawValue / step) * step;
|
||||
const nextValue = Math.max(min, Math.min(steppedValue, max));
|
||||
onValueChange(nextValue);
|
||||
};
|
||||
|
||||
const panResponder = useMemo(
|
||||
() =>
|
||||
PanResponder.create({
|
||||
onStartShouldSetPanResponder: () => true,
|
||||
onMoveShouldSetPanResponder: () => true,
|
||||
onPanResponderGrant: (event) => updateFromPosition(event.nativeEvent.locationX),
|
||||
onPanResponderMove: (event) => updateFromPosition(event.nativeEvent.locationX),
|
||||
}),
|
||||
[max, min, step],
|
||||
);
|
||||
|
||||
const handleLayout = (event: LayoutChangeEvent) => {
|
||||
latestWidth.current = event.nativeEvent.layout.width;
|
||||
setTrackWidth(event.nativeEvent.layout.width);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.label, { color: theme.colors.textSecondary }]}>{label}</Text>
|
||||
<Text style={[styles.value, { color: theme.colors.textPrimary }]}>
|
||||
{formatter ? formatter(value) : `${value}${suffix ?? ''}`}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[styles.track, { backgroundColor: theme.colors.surfaceMuted }]}
|
||||
onLayout={handleLayout}
|
||||
{...panResponder.panHandlers}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.progress,
|
||||
{
|
||||
backgroundColor: theme.colors.primary,
|
||||
width: trackWidth * percentage,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
styles.thumb,
|
||||
{
|
||||
backgroundColor: theme.colors.white,
|
||||
borderColor: theme.colors.primary,
|
||||
left: Math.max(trackWidth * percentage - 10, 0),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
gap: 10,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: 10,
|
||||
},
|
||||
label: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
value: {
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
},
|
||||
track: {
|
||||
height: 8,
|
||||
borderRadius: 999,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
progress: {
|
||||
height: 8,
|
||||
borderRadius: 999,
|
||||
},
|
||||
thumb: {
|
||||
position: 'absolute',
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
borderWidth: 3,
|
||||
top: -6,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import React, { memo } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { useTheme } from '../../../theme';
|
||||
|
||||
interface InsuranceStatusPillProps {
|
||||
status: string;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
Active: '#10B981',
|
||||
'Renewal Due': '#F59E0B',
|
||||
Expired: '#EF4444',
|
||||
'Grace Period': '#F97316',
|
||||
Submitted: '#3B82F6',
|
||||
'Under Review': '#6366F1',
|
||||
Approved: '#10B981',
|
||||
Rejected: '#EF4444',
|
||||
Settled: '#059669',
|
||||
uploaded: '#3B82F6',
|
||||
verified: '#10B981',
|
||||
rejected: '#EF4444',
|
||||
};
|
||||
|
||||
const InsuranceStatusPillComponent = ({ status }: InsuranceStatusPillProps) => {
|
||||
const theme = useTheme();
|
||||
const color = STATUS_COLORS[status] ?? theme.colors.primary;
|
||||
|
||||
return (
|
||||
<View style={[styles.pill, { backgroundColor: `${color}16` }]}>
|
||||
<Text style={[styles.label, { color }]}>{status}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pill: {
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
label: {
|
||||
fontSize: 11,
|
||||
fontWeight: '800',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
});
|
||||
|
||||
export const InsuranceStatusPill = memo(InsuranceStatusPillComponent);
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { useGetInsuranceDashboardQuery } from '../services/insuranceApi';
|
||||
|
||||
export const useInsuranceDashboard = () => {
|
||||
const query = useGetInsuranceDashboardQuery();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
await query.refetch();
|
||||
setRefreshing(false);
|
||||
}, [query]);
|
||||
|
||||
return {
|
||||
...query,
|
||||
dashboard: query.data,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { InsuranceCategory } from '../types';
|
||||
import { useGetInsurancePlansQuery } from '../services/insuranceApi';
|
||||
|
||||
export const useInsurancePlans = (category?: InsuranceCategory) =>
|
||||
useGetInsurancePlansQuery(category ? { category } : undefined);
|
||||
@@ -0,0 +1,622 @@
|
||||
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 } 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', '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,
|
||||
fontWeight: '800',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
sectionCard: {
|
||||
gap: 12,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
},
|
||||
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,
|
||||
fontWeight: '600',
|
||||
},
|
||||
actionStack: {
|
||||
gap: 10,
|
||||
},
|
||||
summaryCard: {
|
||||
borderRadius: 14,
|
||||
padding: 14,
|
||||
gap: 4,
|
||||
},
|
||||
summaryTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '800',
|
||||
},
|
||||
summaryCopy: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
summaryAmount: {
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import React, { useEffect, useMemo } 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 type { InsuranceStackParamList } from '../../../navigation/types';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCompactCurrency, formatCurrency } from '../../../utils/formatters';
|
||||
import { InsuranceCategoryChip } from '../components/InsuranceCategoryChip';
|
||||
import { InsuranceRangeSlider } from '../components/InsuranceRangeSlider';
|
||||
import { useEstimateInsurancePremiumQuery } from '../services/insuranceApi';
|
||||
import { insuranceActions } from '../store/insuranceSlice';
|
||||
import { selectInsuranceCalculator } from '../store/insuranceSelectors';
|
||||
import { INSURANCE_CATEGORIES } from '../types';
|
||||
|
||||
type Props = NativeStackScreenProps<InsuranceStackParamList, 'InsuranceCalculator'>;
|
||||
|
||||
export default function InsuranceCalculatorScreen({ navigation, route }: Props) {
|
||||
const dispatch = useAppDispatch();
|
||||
const theme = useTheme();
|
||||
const calculator = useAppSelector(selectInsuranceCalculator);
|
||||
const effectiveCategory = calculator.category;
|
||||
|
||||
useEffect(() => {
|
||||
if (route.params?.category) {
|
||||
dispatch(
|
||||
insuranceActions.updateInsuranceCalculator({
|
||||
field: 'category',
|
||||
value: route.params.category,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, [dispatch, route.params?.category]);
|
||||
const estimateQuery = useEstimateInsurancePremiumQuery({
|
||||
...calculator,
|
||||
category: effectiveCategory,
|
||||
});
|
||||
|
||||
const estimate = estimateQuery.data;
|
||||
const coverageValue = useMemo(
|
||||
() => Number(calculator.coverageAmount || 0),
|
||||
[calculator.coverageAmount],
|
||||
);
|
||||
|
||||
return (
|
||||
<ScreenShell title="Calculator" subtitle="Estimate premiums by age, cover, and tenure">
|
||||
<ScrollView contentContainerStyle={styles.scroll} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled">
|
||||
<Card style={styles.sectionCard}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Policy category
|
||||
</Text>
|
||||
<View style={styles.categoryWrap}>
|
||||
{INSURANCE_CATEGORIES.map((category) => (
|
||||
<InsuranceCategoryChip
|
||||
key={category}
|
||||
category={category}
|
||||
selected={effectiveCategory === category}
|
||||
onPress={() =>
|
||||
dispatch(
|
||||
insuranceActions.updateInsuranceCalculator({
|
||||
field: 'category',
|
||||
value: category,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.sectionCard}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Adjust your assumptions
|
||||
</Text>
|
||||
<InsuranceRangeSlider
|
||||
label="Age"
|
||||
min={18}
|
||||
max={70}
|
||||
step={1}
|
||||
value={Number(calculator.age || 18)}
|
||||
onValueChange={(value) =>
|
||||
dispatch(
|
||||
insuranceActions.updateInsuranceCalculator({
|
||||
field: 'age',
|
||||
value: String(value),
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<InsuranceRangeSlider
|
||||
label="Coverage"
|
||||
min={100000}
|
||||
max={20000000}
|
||||
step={100000}
|
||||
value={coverageValue || 100000}
|
||||
formatter={(value) => formatCompactCurrency(value)}
|
||||
onValueChange={(value) =>
|
||||
dispatch(
|
||||
insuranceActions.updateInsuranceCalculator({
|
||||
field: 'coverageAmount',
|
||||
value: String(value),
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<InsuranceRangeSlider
|
||||
label="Tenure"
|
||||
min={1}
|
||||
max={30}
|
||||
step={1}
|
||||
value={Number(calculator.tenureYears || 1)}
|
||||
suffix=" yr"
|
||||
onValueChange={(value) =>
|
||||
dispatch(
|
||||
insuranceActions.updateInsuranceCalculator({
|
||||
field: 'tenureYears',
|
||||
value: String(value),
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.sectionCard}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Manual inputs
|
||||
</Text>
|
||||
<Input
|
||||
label="Age"
|
||||
keyboardType="number-pad"
|
||||
value={calculator.age}
|
||||
onChangeText={(value) =>
|
||||
dispatch(
|
||||
insuranceActions.updateInsuranceCalculator({ field: 'age', value }),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label="Coverage Amount"
|
||||
keyboardType="number-pad"
|
||||
value={calculator.coverageAmount}
|
||||
onChangeText={(value) =>
|
||||
dispatch(
|
||||
insuranceActions.updateInsuranceCalculator({
|
||||
field: 'coverageAmount',
|
||||
value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label="Tenure Years"
|
||||
keyboardType="number-pad"
|
||||
value={calculator.tenureYears}
|
||||
onChangeText={(value) =>
|
||||
dispatch(
|
||||
insuranceActions.updateInsuranceCalculator({
|
||||
field: 'tenureYears',
|
||||
value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.resultCard}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Estimated premium
|
||||
</Text>
|
||||
<View style={styles.resultGrid}>
|
||||
<View style={[styles.resultMetric, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.resultLabel, { color: theme.colors.textSecondary }]}>
|
||||
Monthly
|
||||
</Text>
|
||||
<Text style={[styles.resultValue, { color: theme.colors.textPrimary }]}>
|
||||
{estimate ? formatCurrency(estimate.monthlyPremium) : '--'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.resultMetric, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.resultLabel, { color: theme.colors.textSecondary }]}>
|
||||
Annual
|
||||
</Text>
|
||||
<Text style={[styles.resultValue, { color: theme.colors.textPrimary }]}>
|
||||
{estimate ? formatCurrency(estimate.annualPremium) : '--'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={[styles.recommendation, { color: theme.colors.textSecondary }]}>
|
||||
Recommended protection: {estimate ? formatCompactCurrency(estimate.recommendedCover) : '--'}
|
||||
</Text>
|
||||
<Button
|
||||
title="Buy This Cover"
|
||||
onPress={() =>
|
||||
navigation.navigate('BuyInsurance', { category: effectiveCategory })
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: {
|
||||
gap: 16,
|
||||
paddingBottom: 28,
|
||||
},
|
||||
sectionCard: {
|
||||
gap: 14,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
},
|
||||
categoryWrap: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 10,
|
||||
},
|
||||
resultCard: {
|
||||
gap: 14,
|
||||
},
|
||||
resultGrid: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
resultMetric: {
|
||||
flex: 1,
|
||||
borderRadius: 16,
|
||||
padding: 14,
|
||||
gap: 4,
|
||||
},
|
||||
resultLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
resultValue: {
|
||||
fontSize: 18,
|
||||
fontWeight: '900',
|
||||
},
|
||||
recommendation: {
|
||||
fontSize: 12,
|
||||
lineHeight: 18,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,392 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
FlatList,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
|
||||
import { Button, Card, EmptyState, Input, Loader, ScreenShell } from '../../../components';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import type { InsuranceStackParamList } from '../../../navigation/types';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCurrency, formatDate } from '../../../utils/formatters';
|
||||
import { InsuranceClaimCard } from '../components/InsuranceClaimCard';
|
||||
import { InsuranceDocumentItem } from '../components/InsuranceDocumentItem';
|
||||
import { InsuranceOtpModal } from '../components/InsuranceOtpModal';
|
||||
import {
|
||||
useGetInsuranceClaimsQuery,
|
||||
useGetInsurancePoliciesQuery,
|
||||
useRequestInsuranceOtpMutation,
|
||||
useSubmitInsuranceClaimMutation,
|
||||
useUploadInsuranceDocumentMutation,
|
||||
} from '../services/insuranceApi';
|
||||
import type { InsuranceClaimDraft } from '../types';
|
||||
import { validateInsuranceClaimDraft, validateInsuranceOtp } from '../utils/insuranceValidation';
|
||||
import type { InsuranceDocument } from '../types';
|
||||
|
||||
type Props = NativeStackScreenProps<InsuranceStackParamList, 'InsuranceClaims'>;
|
||||
|
||||
const initialDraft: InsuranceClaimDraft = {
|
||||
policyId: '',
|
||||
reason: '',
|
||||
eventDate: '',
|
||||
amount: '',
|
||||
description: '',
|
||||
documentIds: [],
|
||||
};
|
||||
|
||||
const SUPPORTING_DOCUMENTS = [
|
||||
{
|
||||
name: 'Hospital Invoice',
|
||||
type: 'Invoice' as const,
|
||||
category: 'claim' as const,
|
||||
mimeType: 'application/pdf',
|
||||
uri: 'https://mock.bankcbs.dev/uploads/hospital_invoice.pdf',
|
||||
sizeKb: 520,
|
||||
},
|
||||
{
|
||||
name: 'Medical Report',
|
||||
type: 'Medical Report' as const,
|
||||
category: 'claim' as const,
|
||||
mimeType: 'application/pdf',
|
||||
uri: 'https://mock.bankcbs.dev/uploads/medical_report.pdf',
|
||||
sizeKb: 440,
|
||||
},
|
||||
{
|
||||
name: 'Repair Invoice',
|
||||
type: 'Invoice' as const,
|
||||
category: 'claim' as const,
|
||||
mimeType: 'application/pdf',
|
||||
uri: 'https://mock.bankcbs.dev/uploads/repair_invoice.pdf',
|
||||
sizeKb: 468,
|
||||
},
|
||||
];
|
||||
|
||||
export default function InsuranceClaimsScreen({ route }: Props) {
|
||||
const theme = useTheme();
|
||||
const toast = useToast();
|
||||
const claimsQuery = useGetInsuranceClaimsQuery(
|
||||
route.params?.policyId ? { policyId: route.params.policyId } : undefined,
|
||||
);
|
||||
const policiesQuery = useGetInsurancePoliciesQuery();
|
||||
const [uploadDocument, uploadState] = useUploadInsuranceDocumentMutation();
|
||||
const [requestOtp, requestOtpState] = useRequestInsuranceOtpMutation();
|
||||
const [submitClaim, submitClaimState] = useSubmitInsuranceClaimMutation();
|
||||
const [draft, setDraft] = useState<InsuranceClaimDraft>({
|
||||
...initialDraft,
|
||||
policyId: route.params?.policyId ?? '',
|
||||
});
|
||||
const [attachedDocuments, setAttachedDocuments] = useState<InsuranceDocument[]>([]);
|
||||
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?.policyId) {
|
||||
setDraft((current) => ({ ...current, policyId: route.params?.policyId ?? '' }));
|
||||
}
|
||||
}, [route.params?.policyId]);
|
||||
|
||||
const selectedPolicy = useMemo(
|
||||
() => (policiesQuery.data ?? []).find((policy) => policy.id === draft.policyId) ?? null,
|
||||
[draft.policyId, policiesQuery.data],
|
||||
);
|
||||
|
||||
const handleUpload = async (index: number) => {
|
||||
const sample = SUPPORTING_DOCUMENTS[index];
|
||||
|
||||
if (!sample) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await uploadDocument(sample);
|
||||
|
||||
if ('data' in result && result.data) {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
documentIds: [...current.documentIds, result.data.id],
|
||||
}));
|
||||
setAttachedDocuments((current) => [result.data, ...current]);
|
||||
toast('Document attached', 'success', `${sample.name} added to this claim.`);
|
||||
return;
|
||||
}
|
||||
|
||||
toast('Unable to attach document', 'error', 'Please try again.');
|
||||
};
|
||||
|
||||
const handleRequestOtp = async () => {
|
||||
const validationErrors = validateInsuranceClaimDraft(draft);
|
||||
setErrors(validationErrors);
|
||||
|
||||
if (Object.keys(validationErrors).length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await requestOtp({ action: 'claim', policyId: draft.policyId });
|
||||
|
||||
if ('data' in result && result.data) {
|
||||
setMockCodeHint(result.data.mockCodeHint);
|
||||
setOtpVisible(true);
|
||||
setOtpError(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
toast('Unable to send OTP', 'error', 'Please try again.');
|
||||
};
|
||||
|
||||
const handleSubmitClaim = async () => {
|
||||
const error = validateInsuranceOtp(otp);
|
||||
|
||||
if (error) {
|
||||
setOtpError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await submitClaim({
|
||||
policyId: draft.policyId,
|
||||
reason: draft.reason,
|
||||
eventDate: draft.eventDate,
|
||||
amount: Number(draft.amount),
|
||||
description: draft.description,
|
||||
documentIds: draft.documentIds,
|
||||
otp,
|
||||
});
|
||||
|
||||
if ('data' in result && result.data) {
|
||||
setOtpVisible(false);
|
||||
setOtp('');
|
||||
setDraft({
|
||||
...initialDraft,
|
||||
policyId: route.params?.policyId ?? '',
|
||||
});
|
||||
setAttachedDocuments([]);
|
||||
setErrors({});
|
||||
toast(
|
||||
'Claim submitted',
|
||||
'success',
|
||||
`${result.data.referenceId} has been filed successfully.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setOtpError('Claim submission failed. Please try again.');
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenShell title="Claims" subtitle="File new claims and track history" scrollable={false}>
|
||||
<FlatList
|
||||
data={claimsQuery.data ?? []}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={claimsQuery.isFetching || policiesQuery.isFetching}
|
||||
onRefresh={() => {
|
||||
void claimsQuery.refetch();
|
||||
void policiesQuery.refetch();
|
||||
}}
|
||||
tintColor={theme.colors.primary}
|
||||
colors={[theme.colors.primary]}
|
||||
/>
|
||||
}
|
||||
ListHeaderComponent={
|
||||
<View style={styles.headerWrap}>
|
||||
<Card style={styles.formCard}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
File a new claim
|
||||
</Text>
|
||||
|
||||
<View style={styles.policyWrap}>
|
||||
{(policiesQuery.data ?? []).map((policy) => (
|
||||
<Button
|
||||
key={policy.id}
|
||||
title={policy.planName}
|
||||
variant={draft.policyId === policy.id ? 'primary' : 'secondary'}
|
||||
onPress={() => setDraft((current) => ({ ...current, policyId: policy.id }))}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
{errors.policyId ? (
|
||||
<Text style={[styles.errorText, { color: theme.colors.error }]}>
|
||||
{errors.policyId}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Input
|
||||
label="Claim Reason"
|
||||
value={draft.reason}
|
||||
onChangeText={(value) => setDraft((current) => ({ ...current, reason: value }))}
|
||||
error={errors.reason}
|
||||
placeholder="Hospitalization, accident, repair, trip delay, etc."
|
||||
/>
|
||||
<Input
|
||||
label="Event Date"
|
||||
value={draft.eventDate}
|
||||
onChangeText={(value) => setDraft((current) => ({ ...current, eventDate: value }))}
|
||||
error={errors.eventDate}
|
||||
placeholder="YYYY-MM-DD"
|
||||
/>
|
||||
<Input
|
||||
label="Claim Amount"
|
||||
value={draft.amount}
|
||||
keyboardType="number-pad"
|
||||
onChangeText={(value) => setDraft((current) => ({ ...current, amount: value }))}
|
||||
error={errors.amount}
|
||||
placeholder="Enter claim amount"
|
||||
/>
|
||||
<Input
|
||||
label="Claim Details"
|
||||
value={draft.description}
|
||||
onChangeText={(value) =>
|
||||
setDraft((current) => ({ ...current, description: value }))
|
||||
}
|
||||
error={errors.description}
|
||||
placeholder="Describe the event and supporting details"
|
||||
multiline
|
||||
/>
|
||||
|
||||
{selectedPolicy ? (
|
||||
<View
|
||||
style={[styles.policySummary, { backgroundColor: theme.colors.surfaceMuted }]}
|
||||
>
|
||||
<Text style={[styles.policySummaryTitle, { color: theme.colors.textPrimary }]}>
|
||||
{selectedPolicy.planName}
|
||||
</Text>
|
||||
<Text style={[styles.policySummaryText, { color: theme.colors.textSecondary }]}>
|
||||
{selectedPolicy.policyNumber} · Coverage {formatCurrency(selectedPolicy.coverageAmount)}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.attachRow}>
|
||||
<View style={styles.attachCol}>
|
||||
<Button
|
||||
title="Attach Invoice"
|
||||
variant="secondary"
|
||||
onPress={() => void handleUpload(0)}
|
||||
loading={uploadState.isLoading}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.attachCol}>
|
||||
<Button
|
||||
title="Attach Report"
|
||||
variant="secondary"
|
||||
onPress={() => void handleUpload(1)}
|
||||
loading={uploadState.isLoading}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<Button
|
||||
title="Attach Repair PDF"
|
||||
variant="secondary"
|
||||
onPress={() => void handleUpload(2)}
|
||||
loading={uploadState.isLoading}
|
||||
/>
|
||||
{errors.documents ? (
|
||||
<Text style={[styles.errorText, { color: theme.colors.error }]}>
|
||||
{errors.documents}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{attachedDocuments.map((document) => (
|
||||
<InsuranceDocumentItem key={document.id} document={document} />
|
||||
))}
|
||||
|
||||
<Button
|
||||
title="Request OTP & Submit"
|
||||
onPress={() => void handleRequestOtp()}
|
||||
loading={requestOtpState.isLoading}
|
||||
/>
|
||||
</Card>
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item }) => <InsuranceClaimCard claim={item} />}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
ListEmptyComponent={
|
||||
claimsQuery.isLoading ? (
|
||||
<Card>
|
||||
<Loader />
|
||||
</Card>
|
||||
) : claimsQuery.isError ? (
|
||||
<EmptyState
|
||||
title="Unable to load claims"
|
||||
description="Refresh and try again."
|
||||
actionLabel="Retry"
|
||||
onAction={() => void claimsQuery.refetch()}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No claims found"
|
||||
description="Submitted claims and settlement updates will appear here."
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<InsuranceOtpModal
|
||||
visible={otpVisible}
|
||||
title="Verify Claim Submission"
|
||||
description="Enter the OTP sent to your registered mobile number to authorize this claim request."
|
||||
otp={otp}
|
||||
onOtpChange={setOtp}
|
||||
onClose={() => setOtpVisible(false)}
|
||||
onConfirm={() => void handleSubmitClaim()}
|
||||
loading={submitClaimState.isLoading}
|
||||
error={otpError}
|
||||
mockCodeHint={mockCodeHint}
|
||||
/>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
listContent: {
|
||||
paddingBottom: 70,
|
||||
},
|
||||
headerWrap: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
formCard: {
|
||||
gap: 12,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
},
|
||||
policyWrap: {
|
||||
gap: 10,
|
||||
},
|
||||
policySummary: {
|
||||
borderRadius: 14,
|
||||
padding: 12,
|
||||
gap: 4,
|
||||
},
|
||||
policySummaryTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
},
|
||||
policySummaryText: {
|
||||
fontSize: 12,
|
||||
lineHeight: 17,
|
||||
},
|
||||
attachRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
attachCol: {
|
||||
flex: 1,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
separator: {
|
||||
height: 12,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import React from 'react';
|
||||
import { ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
|
||||
import { Button, Card, EmptyState, Loader, ScreenShell } from '../../../components';
|
||||
import type { InsuranceStackParamList } from '../../../navigation/types';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCompactCurrency, formatCurrency } from '../../../utils/formatters';
|
||||
import { useCompareInsurancePlansQuery } from '../services/insuranceApi';
|
||||
|
||||
type Props = NativeStackScreenProps<InsuranceStackParamList, 'InsuranceComparison'>;
|
||||
|
||||
const comparisonRows = [
|
||||
{ key: 'premium', label: 'Premium' },
|
||||
{ key: 'coverage', label: 'Coverage' },
|
||||
{ key: 'csr', label: 'Claim ratio' },
|
||||
{ key: 'benefits', label: 'Benefits' },
|
||||
] as const;
|
||||
|
||||
export default function InsuranceComparisonScreen({ navigation, route }: Props) {
|
||||
const theme = useTheme();
|
||||
const { data, isLoading, isError, refetch } = useCompareInsurancePlansQuery(route.params.planIds);
|
||||
|
||||
return (
|
||||
<ScreenShell title="Comparison" subtitle="Side-by-side plan evaluation">
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
<Loader />
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{isError ? (
|
||||
<EmptyState
|
||||
title="Unable to compare plans"
|
||||
description="Refresh and try again."
|
||||
actionLabel="Retry"
|
||||
onAction={refetch}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !isError && (!data || data.length < 2) ? (
|
||||
<EmptyState
|
||||
title="Select at least two plans"
|
||||
description="Add plans to the comparison tray from the buy insurance journey."
|
||||
actionLabel="Back to Buy Flow"
|
||||
onAction={() => navigation.navigate('BuyInsurance', { category: route.params.category })}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{data && data.length >= 2 ? (
|
||||
<ScrollView contentContainerStyle={styles.scroll} showsVerticalScrollIndicator={false}>
|
||||
<Card style={styles.tableCard}>
|
||||
{comparisonRows.map((row) => (
|
||||
<View
|
||||
key={row.key}
|
||||
style={[styles.compareRow, { borderBottomColor: theme.colors.cardBorder }]}
|
||||
>
|
||||
<Text style={[styles.rowLabel, { color: theme.colors.textSecondary }]}>
|
||||
{row.label}
|
||||
</Text>
|
||||
<View style={styles.valueColumns}>
|
||||
{data.map((plan) => (
|
||||
<View key={`${plan.id}_${row.key}`} style={styles.valueCell}>
|
||||
<Text style={[styles.valueText, { color: theme.colors.textPrimary }]}>
|
||||
{row.key === 'premium'
|
||||
? formatCurrency(plan.premium)
|
||||
: row.key === 'coverage'
|
||||
? formatCompactCurrency(plan.coverAmount)
|
||||
: row.key === 'csr'
|
||||
? `${plan.claimSettlementRatio}%`
|
||||
: `${plan.benefits.length} core benefits`}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View style={styles.planColumnWrap}>
|
||||
{data.map((plan) => (
|
||||
<Card key={plan.id} style={styles.planCard}>
|
||||
<Text style={[styles.planTitle, { color: theme.colors.textPrimary }]}>
|
||||
{plan.name}
|
||||
</Text>
|
||||
<Text style={[styles.planSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
{plan.insurerName}
|
||||
</Text>
|
||||
{plan.benefits.map((benefit) => (
|
||||
<Text key={benefit} style={[styles.planBullet, { color: theme.colors.textPrimary }]}>
|
||||
• {benefit}
|
||||
</Text>
|
||||
))}
|
||||
</Card>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<Button
|
||||
title="Back to Buy Flow"
|
||||
onPress={() => navigation.navigate('BuyInsurance', { category: route.params.category })}
|
||||
/>
|
||||
</ScrollView>
|
||||
) : null}
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: {
|
||||
gap: 16,
|
||||
paddingBottom: 28,
|
||||
},
|
||||
tableCard: {
|
||||
gap: 0,
|
||||
},
|
||||
compareRow: {
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
gap: 10,
|
||||
},
|
||||
rowLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
valueColumns: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
valueCell: {
|
||||
flex: 1,
|
||||
minWidth: 120,
|
||||
},
|
||||
valueText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
},
|
||||
planColumnWrap: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
paddingBottom: 4,
|
||||
},
|
||||
planCard: {
|
||||
width: 240,
|
||||
gap: 8,
|
||||
},
|
||||
planTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '800',
|
||||
},
|
||||
planSubtitle: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
planBullet: {
|
||||
fontSize: 12,
|
||||
lineHeight: 18,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,428 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||
|
||||
import { Button, Card, EmptyState, Loader, ScreenShell } from '../../../components';
|
||||
import type { InsuranceStackParamList } from '../../../navigation/types';
|
||||
import { useTheme } from '../../../theme';
|
||||
import {
|
||||
formatCompactCurrency,
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
} from '../../../utils/formatters';
|
||||
import { InsuranceCategoryChip } from '../components/InsuranceCategoryChip';
|
||||
import { InsuranceClaimCard } from '../components/InsuranceClaimCard';
|
||||
import { InsurancePolicyCard } from '../components/InsurancePolicyCard';
|
||||
import { useInsuranceDashboard } from '../hooks/useInsuranceDashboard';
|
||||
import { INSURANCE_CATEGORIES } from '../types';
|
||||
|
||||
type Props = NativeStackScreenProps<InsuranceStackParamList, 'InsuranceDashboard'>;
|
||||
|
||||
export default function InsuranceDashboardScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const { dashboard, isLoading, isError, refreshing, onRefresh } = useInsuranceDashboard();
|
||||
|
||||
return (
|
||||
<ScreenShell
|
||||
title="Insurance"
|
||||
subtitle="Policies, premiums, claims, and document servicing"
|
||||
scrollable={false}
|
||||
rightContent={
|
||||
<Pressable
|
||||
onPress={() => navigation.navigate('InsuranceNotifications')}
|
||||
style={[styles.iconButton, { backgroundColor: theme.colors.surfaceMuted }]}
|
||||
>
|
||||
<Ionicons name="notifications-outline" size={20} color={theme.colors.primary} />
|
||||
</Pressable>
|
||||
}
|
||||
>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={theme.colors.primary}
|
||||
colors={[theme.colors.primary]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{isLoading && !dashboard ? (
|
||||
<Card>
|
||||
<Loader />
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{isError && !dashboard ? (
|
||||
<EmptyState
|
||||
title="Unable to load insurance"
|
||||
description="Pull to refresh or try again in a moment."
|
||||
actionLabel="Retry"
|
||||
onAction={onRefresh}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{dashboard ? (
|
||||
<>
|
||||
<Animated.View entering={FadeInDown.springify()}>
|
||||
<Card style={styles.heroCard}>
|
||||
<View style={styles.heroHeader}>
|
||||
<View>
|
||||
<Text style={[styles.heroEyebrow, { color: theme.colors.primary }]}>
|
||||
Portfolio overview
|
||||
</Text>
|
||||
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>
|
||||
{formatCompactCurrency(dashboard.totalCoverageAmount)}
|
||||
</Text>
|
||||
<Text style={[styles.heroSubtitle, { color: theme.colors.textSecondary }]}>
|
||||
Total live coverage across {dashboard.activePoliciesCount} active policies
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[styles.heroBadge, { backgroundColor: `${theme.colors.accent}14` }]}
|
||||
>
|
||||
<Text style={[styles.heroBadgeValue, { color: theme.colors.accent }]}>
|
||||
{dashboard.policies.length}
|
||||
</Text>
|
||||
<Text style={[styles.heroBadgeLabel, { color: theme.colors.textSecondary }]}>
|
||||
policies
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.metricRow}>
|
||||
<View style={[styles.metricCard, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.metricLabel, { color: theme.colors.textSecondary }]}>
|
||||
Upcoming premium
|
||||
</Text>
|
||||
<Text style={[styles.metricValue, { color: theme.colors.textPrimary }]}>
|
||||
{dashboard.upcomingPremium
|
||||
? formatCurrency(dashboard.upcomingPremium.amount)
|
||||
: 'None'}
|
||||
</Text>
|
||||
<Text style={[styles.metricCaption, { color: theme.colors.textMuted }]}>
|
||||
{dashboard.upcomingPremium
|
||||
? formatDate(dashboard.upcomingPremium.dueDate)
|
||||
: 'No premium due'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.metricCard, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.metricLabel, { color: theme.colors.textSecondary }]}>
|
||||
Due premiums
|
||||
</Text>
|
||||
<Text style={[styles.metricValue, { color: theme.colors.textPrimary }]}>
|
||||
{dashboard.duePremiums.length}
|
||||
</Text>
|
||||
<Text style={[styles.metricCaption, { color: theme.colors.textMuted }]}>
|
||||
Renewal and recurring premium reminders
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInDown.delay(80).springify()}>
|
||||
<Card style={styles.sectionCard}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Quick actions
|
||||
</Text>
|
||||
<View style={styles.actionRow}>
|
||||
<View style={styles.actionCol}>
|
||||
<Button
|
||||
title="Buy Policy"
|
||||
onPress={() => navigation.navigate('BuyInsurance')}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.actionCol}>
|
||||
<Button
|
||||
title="Renew Policy"
|
||||
variant="secondary"
|
||||
onPress={() => navigation.navigate('InsuranceRenewals')}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.actionRow}>
|
||||
<View style={styles.actionCol}>
|
||||
<Button
|
||||
title="File Claim"
|
||||
variant="secondary"
|
||||
onPress={() => navigation.navigate('InsuranceClaims')}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.actionCol}>
|
||||
<Button
|
||||
title="View Policies"
|
||||
variant="secondary"
|
||||
onPress={() => navigation.navigate('InsurancePolicies')}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.actionRow}>
|
||||
<View style={styles.actionCol}>
|
||||
<Button
|
||||
title="Documents"
|
||||
variant="secondary"
|
||||
onPress={() => navigation.navigate('InsuranceDocuments')}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.actionCol}>
|
||||
<Button
|
||||
title="Calculator"
|
||||
variant="secondary"
|
||||
onPress={() => navigation.navigate('InsuranceCalculator')}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInDown.delay(120).springify()} style={styles.sectionWrap}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Categories
|
||||
</Text>
|
||||
<View style={styles.categoryWrap}>
|
||||
{INSURANCE_CATEGORIES.map((category) => (
|
||||
<InsuranceCategoryChip
|
||||
key={category}
|
||||
category={category}
|
||||
selected={false}
|
||||
onPress={() => navigation.navigate('BuyInsurance', { category })}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInDown.delay(160).springify()} style={styles.sectionWrap}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Active policies
|
||||
</Text>
|
||||
<Pressable onPress={() => navigation.navigate('InsurancePolicies')}>
|
||||
<Text style={[styles.linkText, { color: theme.colors.primary }]}>See all</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{dashboard.policies.map((policy) => (
|
||||
<InsurancePolicyCard
|
||||
key={policy.id}
|
||||
policy={policy}
|
||||
onPress={() =>
|
||||
navigation.navigate('InsurancePolicyDetails', { policyId: policy.id })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInDown.delay(200).springify()} style={styles.sectionWrap}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Recent claims
|
||||
</Text>
|
||||
<Pressable onPress={() => navigation.navigate('InsuranceClaims')}>
|
||||
<Text style={[styles.linkText, { color: theme.colors.primary }]}>Manage</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
{dashboard.recentClaims.length ? (
|
||||
dashboard.recentClaims.map((claim) => (
|
||||
<InsuranceClaimCard key={claim.id} claim={claim} />
|
||||
))
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No claims yet"
|
||||
description="Your submitted claims and settlement updates will appear here."
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInDown.delay(240).springify()} style={styles.sectionWrap}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Notifications
|
||||
</Text>
|
||||
<Pressable onPress={() => navigation.navigate('InsuranceNotifications')}>
|
||||
<Text style={[styles.linkText, { color: theme.colors.primary }]}>Open</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Card style={styles.notificationCard}>
|
||||
{dashboard.notifications.map((notification) => (
|
||||
<View
|
||||
key={notification.id}
|
||||
style={[styles.notificationRow, { borderBottomColor: theme.colors.cardBorder }]}
|
||||
>
|
||||
<View style={styles.notificationCopy}>
|
||||
<Text style={[styles.notificationTitle, { color: theme.colors.textPrimary }]}>
|
||||
{notification.title}
|
||||
</Text>
|
||||
<Text style={[styles.notificationBody, { color: theme.colors.textSecondary }]}>
|
||||
{notification.body}
|
||||
</Text>
|
||||
</View>
|
||||
{!notification.read ? (
|
||||
<View
|
||||
style={[styles.unreadDot, { backgroundColor: theme.colors.primary }]}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
</Card>
|
||||
</Animated.View>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<View style={styles.bottomSpacer} />
|
||||
</ScrollView>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scrollContent: {
|
||||
gap: 18,
|
||||
paddingBottom: 24,
|
||||
},
|
||||
iconButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
heroCard: {
|
||||
gap: 16,
|
||||
},
|
||||
heroHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
},
|
||||
heroEyebrow: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
heroTitle: {
|
||||
fontSize: 28,
|
||||
fontWeight: '900',
|
||||
marginTop: 4,
|
||||
},
|
||||
heroSubtitle: {
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
marginTop: 4,
|
||||
},
|
||||
heroBadge: {
|
||||
minWidth: 82,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
heroBadgeValue: {
|
||||
fontSize: 22,
|
||||
fontWeight: '900',
|
||||
},
|
||||
heroBadgeLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
metricRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
metricCard: {
|
||||
flex: 1,
|
||||
borderRadius: 16,
|
||||
padding: 14,
|
||||
gap: 4,
|
||||
},
|
||||
metricLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
metricValue: {
|
||||
fontSize: 18,
|
||||
fontWeight: '900',
|
||||
},
|
||||
metricCaption: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
lineHeight: 16,
|
||||
},
|
||||
sectionCard: {
|
||||
gap: 14,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 17,
|
||||
fontWeight: '800',
|
||||
},
|
||||
actionRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
actionCol: {
|
||||
flex: 1,
|
||||
},
|
||||
sectionWrap: {
|
||||
gap: 12,
|
||||
},
|
||||
categoryWrap: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 10,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
linkText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '800',
|
||||
},
|
||||
notificationCard: {
|
||||
gap: 0,
|
||||
},
|
||||
notificationRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: 10,
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
notificationCopy: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
notificationTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
},
|
||||
notificationBody: {
|
||||
fontSize: 12,
|
||||
lineHeight: 17,
|
||||
},
|
||||
unreadDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
marginTop: 6,
|
||||
},
|
||||
bottomSpacer: {
|
||||
height: 70,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
FlatList,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
|
||||
import { Button, Card, EmptyState, Loader, ScreenShell } from '../../../components';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import type { InsuranceStackParamList } from '../../../navigation/types';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { InsuranceDocumentItem } from '../components/InsuranceDocumentItem';
|
||||
import { useGetInsuranceDocumentsQuery, useUploadInsuranceDocumentMutation } from '../services/insuranceApi';
|
||||
|
||||
type Props = NativeStackScreenProps<InsuranceStackParamList, 'InsuranceDocuments'>;
|
||||
|
||||
const SAMPLE_UPLOADS = [
|
||||
{
|
||||
name: 'Aadhaar Front',
|
||||
type: 'Aadhaar' as const,
|
||||
category: 'kyc' as const,
|
||||
mimeType: 'application/pdf',
|
||||
uri: 'https://mock.bankcbs.dev/uploads/aadhaar_front.pdf',
|
||||
sizeKb: 224,
|
||||
},
|
||||
{
|
||||
name: 'PAN Card',
|
||||
type: 'PAN' as const,
|
||||
category: 'kyc' as const,
|
||||
mimeType: 'image/jpeg',
|
||||
uri: 'https://mock.bankcbs.dev/uploads/pan_card.jpg',
|
||||
sizeKb: 188,
|
||||
},
|
||||
{
|
||||
name: 'Claim Support PDF',
|
||||
type: 'Invoice' as const,
|
||||
category: 'claim' as const,
|
||||
mimeType: 'application/pdf',
|
||||
uri: 'https://mock.bankcbs.dev/uploads/claim_support.pdf',
|
||||
sizeKb: 464,
|
||||
},
|
||||
];
|
||||
|
||||
export default function InsuranceDocumentsScreen(_props: Props) {
|
||||
const theme = useTheme();
|
||||
const toast = useToast();
|
||||
const { data, isLoading, isError, refetch, isFetching } = useGetInsuranceDocumentsQuery();
|
||||
const [uploadDocument, uploadState] = useUploadInsuranceDocumentMutation();
|
||||
|
||||
const handleUpload = async (index: number) => {
|
||||
const sample = SAMPLE_UPLOADS[index];
|
||||
|
||||
if (!sample) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await uploadDocument(sample);
|
||||
|
||||
if ('data' in result) {
|
||||
toast('Document uploaded', 'success', `${sample.name} is now available in the secure vault.`);
|
||||
} else {
|
||||
toast('Upload failed', 'error', 'Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenShell title="Documents" subtitle="KYC vault and claim supporting files" scrollable={false}>
|
||||
<FlatList
|
||||
data={data ?? []}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isFetching}
|
||||
onRefresh={refetch}
|
||||
tintColor={theme.colors.primary}
|
||||
colors={[theme.colors.primary]}
|
||||
/>
|
||||
}
|
||||
ListHeaderComponent={
|
||||
<View style={styles.headerWrap}>
|
||||
<Card style={styles.summaryCard}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Upload shortcuts
|
||||
</Text>
|
||||
<View style={styles.actionRow}>
|
||||
<View style={styles.actionCol}>
|
||||
<Button
|
||||
title="Upload Aadhaar"
|
||||
variant="secondary"
|
||||
onPress={() => void handleUpload(0)}
|
||||
loading={uploadState.isLoading}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.actionCol}>
|
||||
<Button
|
||||
title="Upload PAN"
|
||||
variant="secondary"
|
||||
onPress={() => void handleUpload(1)}
|
||||
loading={uploadState.isLoading}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<Button
|
||||
title="Upload Claim PDF"
|
||||
variant="secondary"
|
||||
onPress={() => void handleUpload(2)}
|
||||
loading={uploadState.isLoading}
|
||||
/>
|
||||
</Card>
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<InsuranceDocumentItem
|
||||
document={item}
|
||||
onView={() =>
|
||||
toast('Document available', 'info', `${item.name} is stored in encrypted vault storage.`)
|
||||
}
|
||||
onReupload={() =>
|
||||
void uploadDocument({
|
||||
...SAMPLE_UPLOADS[item.category === 'kyc' ? 0 : 2]!,
|
||||
existingDocumentId: item.id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
ListEmptyComponent={
|
||||
isLoading ? (
|
||||
<Card>
|
||||
<Loader />
|
||||
</Card>
|
||||
) : isError ? (
|
||||
<EmptyState
|
||||
title="Unable to load documents"
|
||||
description="Please refresh and try again."
|
||||
actionLabel="Retry"
|
||||
onAction={refetch}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No documents uploaded"
|
||||
description="Upload your KYC and claim files to keep them ready for servicing."
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
listContent: {
|
||||
paddingBottom: 70,
|
||||
},
|
||||
headerWrap: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
summaryCard: {
|
||||
gap: 12,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
},
|
||||
actionRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
actionCol: {
|
||||
flex: 1,
|
||||
},
|
||||
separator: {
|
||||
height: 12,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
FlatList,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { Card, EmptyState, Loader, ScreenShell } from '../../../components';
|
||||
import type { InsuranceStackParamList } from '../../../navigation/types';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatDate } from '../../../utils/formatters';
|
||||
import {
|
||||
useGetInsuranceNotificationsQuery,
|
||||
useMarkInsuranceNotificationReadMutation,
|
||||
} from '../services/insuranceApi';
|
||||
|
||||
type Props = NativeStackScreenProps<InsuranceStackParamList, 'InsuranceNotifications'>;
|
||||
|
||||
export default function InsuranceNotificationsScreen(_props: Props) {
|
||||
const theme = useTheme();
|
||||
const { data, isLoading, isError, refetch, isFetching } = useGetInsuranceNotificationsQuery();
|
||||
const [markRead] = useMarkInsuranceNotificationReadMutation();
|
||||
|
||||
return (
|
||||
<ScreenShell title="Notifications" subtitle="Premiums, renewals, claim updates, and offers">
|
||||
<FlatList
|
||||
data={data ?? []}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isFetching}
|
||||
onRefresh={refetch}
|
||||
tintColor={theme.colors.primary}
|
||||
colors={[theme.colors.primary]}
|
||||
/>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<Pressable onPress={() => void markRead(item.id)}>
|
||||
<Card style={styles.card}>
|
||||
<View style={styles.row}>
|
||||
<View style={styles.copy}>
|
||||
<Text style={[styles.title, { color: theme.colors.textPrimary }]}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text style={[styles.body, { color: theme.colors.textSecondary }]}>
|
||||
{item.body}
|
||||
</Text>
|
||||
<Text style={[styles.date, { color: theme.colors.textMuted }]}>
|
||||
{formatDate(item.createdAt)}
|
||||
</Text>
|
||||
</View>
|
||||
{!item.read ? (
|
||||
<View style={[styles.unreadWrap, { backgroundColor: `${theme.colors.primary}12` }]}>
|
||||
<Ionicons name="ellipse" size={10} color={theme.colors.primary} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</Card>
|
||||
</Pressable>
|
||||
)}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
ListEmptyComponent={
|
||||
isLoading ? (
|
||||
<Card>
|
||||
<Loader />
|
||||
</Card>
|
||||
) : isError ? (
|
||||
<EmptyState
|
||||
title="Unable to load notifications"
|
||||
description="Please refresh and try again."
|
||||
actionLabel="Retry"
|
||||
onAction={refetch}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="You're all caught up"
|
||||
description="New premium reminders and claim updates will land here."
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
listContent: {
|
||||
paddingBottom: 70,
|
||||
},
|
||||
card: {
|
||||
gap: 0,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
},
|
||||
copy: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
title: {
|
||||
fontSize: 15,
|
||||
fontWeight: '800',
|
||||
},
|
||||
body: {
|
||||
fontSize: 12,
|
||||
lineHeight: 18,
|
||||
},
|
||||
date: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
},
|
||||
unreadWrap: {
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 13,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: 2,
|
||||
},
|
||||
separator: {
|
||||
height: 12,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
FlatList,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
|
||||
import { Button, Card, EmptyState, Loader, ScreenShell } from '../../../components';
|
||||
import type { InsuranceStackParamList } from '../../../navigation/types';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCompactCurrency } from '../../../utils/formatters';
|
||||
import { InsurancePolicyCard } from '../components/InsurancePolicyCard';
|
||||
import { useGetInsurancePoliciesQuery } from '../services/insuranceApi';
|
||||
|
||||
type Props = NativeStackScreenProps<InsuranceStackParamList, 'InsurancePolicies'>;
|
||||
|
||||
export default function InsurancePoliciesScreen({ navigation }: Props) {
|
||||
const theme = useTheme();
|
||||
const { data, isLoading, isError, refetch, isFetching } = useGetInsurancePoliciesQuery();
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const policies = data ?? [];
|
||||
|
||||
return {
|
||||
totalPolicies: policies.length,
|
||||
totalCoverage: policies.reduce((sum, policy) => sum + policy.coverageAmount, 0),
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<ScreenShell title="Policies" subtitle="All policy contracts and renewals" scrollable={false}>
|
||||
<FlatList
|
||||
data={data ?? []}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isFetching}
|
||||
onRefresh={refetch}
|
||||
tintColor={theme.colors.primary}
|
||||
colors={[theme.colors.primary]}
|
||||
/>
|
||||
}
|
||||
ListHeaderComponent={
|
||||
<View style={styles.headerWrap}>
|
||||
<Card style={styles.summaryCard}>
|
||||
<Text style={[styles.summaryLabel, { color: theme.colors.textSecondary }]}>
|
||||
Portfolio coverage
|
||||
</Text>
|
||||
<Text style={[styles.summaryValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCompactCurrency(summary.totalCoverage)}
|
||||
</Text>
|
||||
<Text style={[styles.summaryCaption, { color: theme.colors.textMuted }]}>
|
||||
{summary.totalPolicies} policies across active, due, and historical contracts
|
||||
</Text>
|
||||
</Card>
|
||||
<Button title="Buy New Policy" onPress={() => navigation.navigate('BuyInsurance')} />
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<InsurancePolicyCard
|
||||
policy={item}
|
||||
onPress={() => navigation.navigate('InsurancePolicyDetails', { policyId: item.id })}
|
||||
/>
|
||||
)}
|
||||
ListEmptyComponent={
|
||||
isLoading ? (
|
||||
<Card>
|
||||
<Loader />
|
||||
</Card>
|
||||
) : isError ? (
|
||||
<EmptyState
|
||||
title="Unable to load policies"
|
||||
description="Please refresh and try again."
|
||||
actionLabel="Retry"
|
||||
onAction={refetch}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No policies found"
|
||||
description="Buy your first policy to start building your protection portfolio."
|
||||
actionLabel="Buy Policy"
|
||||
onAction={() => navigation.navigate('BuyInsurance')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
/>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
listContent: {
|
||||
gap: 12,
|
||||
paddingBottom: 70,
|
||||
},
|
||||
headerWrap: {
|
||||
gap: 14,
|
||||
},
|
||||
summaryCard: {
|
||||
gap: 4,
|
||||
},
|
||||
summaryLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
summaryValue: {
|
||||
fontSize: 26,
|
||||
fontWeight: '900',
|
||||
},
|
||||
summaryCaption: {
|
||||
fontSize: 12,
|
||||
lineHeight: 17,
|
||||
},
|
||||
separator: {
|
||||
height: 2,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
import React from 'react';
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import Ionicons from '@react-native-vector-icons/ionicons';
|
||||
|
||||
import { Button, Card, EmptyState, Loader, ScreenShell } from '../../../components';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import type { InsuranceStackParamList } from '../../../navigation/types';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCompactCurrency, formatCurrency, formatDate } from '../../../utils/formatters';
|
||||
import { InsuranceStatusPill } from '../components/InsuranceStatusPill';
|
||||
import { useGetInsurancePolicyDetailQuery } from '../services/insuranceApi';
|
||||
|
||||
type Props = NativeStackScreenProps<InsuranceStackParamList, 'InsurancePolicyDetails'>;
|
||||
|
||||
export default function InsurancePolicyDetailsScreen({ navigation, route }: Props) {
|
||||
const theme = useTheme();
|
||||
const toast = useToast();
|
||||
const { data, isLoading, isError, refetch } = useGetInsurancePolicyDetailQuery(
|
||||
route.params.policyId,
|
||||
);
|
||||
|
||||
return (
|
||||
<ScreenShell title="Policy Details" subtitle="Coverage, insurer, and servicing actions">
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
<Loader />
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{isError || !data ? (
|
||||
<EmptyState
|
||||
title="Unable to load policy"
|
||||
description="Please retry in a moment."
|
||||
actionLabel="Retry"
|
||||
onAction={refetch}
|
||||
/>
|
||||
) : (
|
||||
<ScrollView contentContainerStyle={styles.scroll} showsVerticalScrollIndicator={false}>
|
||||
<Card style={styles.heroCard}>
|
||||
<View style={styles.heroHeader}>
|
||||
<View style={styles.heroCopy}>
|
||||
<Text style={[styles.heroTitle, { color: theme.colors.textPrimary }]}>
|
||||
{data.planName}
|
||||
</Text>
|
||||
<Text style={[styles.heroMeta, { color: theme.colors.textSecondary }]}>
|
||||
{data.policyNumber} · {data.insurer.name}
|
||||
</Text>
|
||||
</View>
|
||||
<InsuranceStatusPill status={data.status} />
|
||||
</View>
|
||||
|
||||
<View style={styles.metricGrid}>
|
||||
<View style={[styles.metricCard, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.metricLabel, { color: theme.colors.textSecondary }]}>
|
||||
Coverage
|
||||
</Text>
|
||||
<Text style={[styles.metricValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCompactCurrency(data.coverageAmount)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.metricCard, { backgroundColor: theme.colors.surfaceMuted }]}>
|
||||
<Text style={[styles.metricLabel, { color: theme.colors.textSecondary }]}>
|
||||
Premium
|
||||
</Text>
|
||||
<Text style={[styles.metricValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(data.premiumAmount)}
|
||||
</Text>
|
||||
<Text style={[styles.metricCaption, { color: theme.colors.textMuted }]}>
|
||||
{data.premiumFrequency}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.infoList}>
|
||||
<Text style={[styles.infoRow, { color: theme.colors.textSecondary }]}>
|
||||
Start date:{' '}
|
||||
<Text style={[styles.infoStrong, { color: theme.colors.textPrimary }]}>
|
||||
{formatDate(data.startDate)}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text style={[styles.infoRow, { color: theme.colors.textSecondary }]}>
|
||||
Expiry date:{' '}
|
||||
<Text style={[styles.infoStrong, { color: theme.colors.textPrimary }]}>
|
||||
{formatDate(data.expiryDate)}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text style={[styles.infoRow, { color: theme.colors.textSecondary }]}>
|
||||
Next premium due:{' '}
|
||||
<Text style={[styles.infoStrong, { color: theme.colors.textPrimary }]}>
|
||||
{formatDate(data.nextPremiumDueDate)}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text style={[styles.infoRow, { color: theme.colors.textSecondary }]}>
|
||||
Nominee:{' '}
|
||||
<Text style={[styles.infoStrong, { color: theme.colors.textPrimary }]}>
|
||||
{data.nomineeName} ({data.nomineeRelationship})
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.actionCard}>
|
||||
<View style={styles.actionRow}>
|
||||
<View style={styles.actionCol}>
|
||||
<Button
|
||||
title="Pay Premium"
|
||||
onPress={() =>
|
||||
navigation.navigate('InsuranceRenewals', { policyId: data.id })
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.actionCol}>
|
||||
<Button
|
||||
title="File Claim"
|
||||
variant="secondary"
|
||||
onPress={() =>
|
||||
navigation.navigate('InsuranceClaims', { policyId: data.id })
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<Button
|
||||
title="Download Policy PDF"
|
||||
variant="secondary"
|
||||
onPress={() =>
|
||||
toast(
|
||||
'Policy download ready',
|
||||
'success',
|
||||
`${data.documents[0]?.name ?? 'Policy schedule'} is ready from the secure vault.`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.sectionCard}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Benefits and inclusions
|
||||
</Text>
|
||||
{data.benefits.map((benefit) => (
|
||||
<View key={benefit} style={styles.listRow}>
|
||||
<Ionicons name="checkmark-circle" size={16} color={theme.colors.accent} />
|
||||
<Text style={[styles.listText, { color: theme.colors.textPrimary }]}>
|
||||
{benefit}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
{data.inclusions.map((inclusion) => (
|
||||
<View key={inclusion} style={styles.listRow}>
|
||||
<Ionicons name="shield-checkmark" size={16} color={theme.colors.primary} />
|
||||
<Text style={[styles.listText, { color: theme.colors.textPrimary }]}>
|
||||
{inclusion}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<Card style={styles.sectionCard}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Exclusions
|
||||
</Text>
|
||||
{data.exclusions.map((exclusion) => (
|
||||
<View key={exclusion} style={styles.listRow}>
|
||||
<Ionicons name="close-circle" size={16} color={theme.colors.error} />
|
||||
<Text style={[styles.listText, { color: theme.colors.textPrimary }]}>
|
||||
{exclusion}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<Card style={styles.sectionCard}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Insurer details
|
||||
</Text>
|
||||
<Text style={[styles.infoRow, { color: theme.colors.textSecondary }]}>
|
||||
Claims ratio:{' '}
|
||||
<Text style={[styles.infoStrong, { color: theme.colors.textPrimary }]}>
|
||||
{data.insurer.claimSettlementRatio}%
|
||||
</Text>
|
||||
</Text>
|
||||
<Text style={[styles.infoRow, { color: theme.colors.textSecondary }]}>
|
||||
Support:{' '}
|
||||
<Text style={[styles.infoStrong, { color: theme.colors.textPrimary }]}>
|
||||
{data.insurer.supportPhone}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text style={[styles.infoRow, { color: theme.colors.textSecondary }]}>
|
||||
Email:{' '}
|
||||
<Text style={[styles.infoStrong, { color: theme.colors.textPrimary }]}>
|
||||
{data.insurer.supportEmail}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text style={[styles.infoRow, { color: theme.colors.textSecondary }]}>
|
||||
Address:{' '}
|
||||
<Text style={[styles.infoStrong, { color: theme.colors.textPrimary }]}>
|
||||
{data.insurer.address}
|
||||
</Text>
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card style={styles.sectionCard}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Policy documents
|
||||
</Text>
|
||||
{data.documents.map((document) => (
|
||||
<Pressable
|
||||
key={document.id}
|
||||
onPress={() =>
|
||||
toast('Document available', 'info', `${document.name} is available in PDF.`)
|
||||
}
|
||||
style={[styles.documentRow, { backgroundColor: theme.colors.surfaceMuted }]}
|
||||
>
|
||||
<View style={styles.documentCopy}>
|
||||
<Text style={[styles.documentName, { color: theme.colors.textPrimary }]}>
|
||||
{document.name}
|
||||
</Text>
|
||||
<Text style={[styles.documentMeta, { color: theme.colors.textMuted }]}>
|
||||
{document.mimeType} · {document.sizeKb} KB
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="download-outline" size={18} color={theme.colors.primary} />
|
||||
</Pressable>
|
||||
))}
|
||||
</Card>
|
||||
</ScrollView>
|
||||
)}
|
||||
<View style={{ height: theme.spacing.xxl }} />
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scroll: {
|
||||
gap: 16,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
heroCard: {
|
||||
gap: 14,
|
||||
},
|
||||
heroHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
},
|
||||
heroCopy: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
heroTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '800',
|
||||
},
|
||||
heroMeta: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
metricGrid: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
metricCard: {
|
||||
flex: 1,
|
||||
borderRadius: 16,
|
||||
padding: 14,
|
||||
gap: 4,
|
||||
},
|
||||
metricLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
metricValue: {
|
||||
fontSize: 18,
|
||||
fontWeight: '900',
|
||||
},
|
||||
metricCaption: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
},
|
||||
infoList: {
|
||||
gap: 6,
|
||||
},
|
||||
infoRow: {
|
||||
fontSize: 12,
|
||||
lineHeight: 18,
|
||||
},
|
||||
infoStrong: {
|
||||
fontWeight: '700',
|
||||
},
|
||||
actionCard: {
|
||||
gap: 12,
|
||||
},
|
||||
actionRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
actionCol: {
|
||||
flex: 1,
|
||||
},
|
||||
sectionCard: {
|
||||
gap: 10,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
},
|
||||
listRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: 8,
|
||||
},
|
||||
listText: {
|
||||
flex: 1,
|
||||
fontSize: 12,
|
||||
lineHeight: 18,
|
||||
fontWeight: '600',
|
||||
},
|
||||
documentRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderRadius: 14,
|
||||
padding: 12,
|
||||
},
|
||||
documentCopy: {
|
||||
flex: 1,
|
||||
gap: 3,
|
||||
},
|
||||
documentName: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
},
|
||||
documentMeta: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
FlatList,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
|
||||
import { Button, Card, EmptyState, Loader, ScreenShell } from '../../../components';
|
||||
import { ToggleSwitch } from '../../cards/components/ToggleSwitch';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import type { InsuranceStackParamList } from '../../../navigation/types';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { formatCurrency, formatDate } from '../../../utils/formatters';
|
||||
import { InsuranceOtpModal } from '../components/InsuranceOtpModal';
|
||||
import { InsurancePaymentMethodTile } from '../components/InsurancePaymentMethodTile';
|
||||
import { InsuranceStatusPill } from '../components/InsuranceStatusPill';
|
||||
import {
|
||||
useGetInsurancePremiumDuesQuery,
|
||||
usePayInsurancePremiumMutation,
|
||||
useRequestInsuranceOtpMutation,
|
||||
useToggleInsurancePolicyAutoRenewMutation,
|
||||
} from '../services/insuranceApi';
|
||||
import type { InsurancePaymentMethod, InsurancePremiumDue } from '../types';
|
||||
import { validateInsuranceOtp } from '../utils/insuranceValidation';
|
||||
|
||||
type Props = NativeStackScreenProps<InsuranceStackParamList, 'InsuranceRenewals'>;
|
||||
|
||||
export default function InsuranceRenewalsScreen({ route }: Props) {
|
||||
const theme = useTheme();
|
||||
const toast = useToast();
|
||||
const { data, isLoading, isError, refetch, isFetching } = useGetInsurancePremiumDuesQuery();
|
||||
const [toggleAutoRenew] = useToggleInsurancePolicyAutoRenewMutation();
|
||||
const [requestOtp, requestOtpState] = useRequestInsuranceOtpMutation();
|
||||
const [payPremium, payPremiumState] = usePayInsurancePremiumMutation();
|
||||
const [selectedDueId, setSelectedDueId] = useState<string | null>(route.params?.policyId ?? null);
|
||||
const [paymentMethod, setPaymentMethod] = useState<InsurancePaymentMethod>('UPI');
|
||||
const [otp, setOtp] = useState('');
|
||||
const [otpError, setOtpError] = useState<string | undefined>();
|
||||
const [otpVisible, setOtpVisible] = useState(false);
|
||||
const [mockCodeHint, setMockCodeHint] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (route.params?.policyId) {
|
||||
setSelectedDueId(route.params.policyId);
|
||||
}
|
||||
}, [route.params?.policyId]);
|
||||
|
||||
const filteredDues = useMemo(() => {
|
||||
if (!route.params?.policyId) {
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
return (data ?? []).filter((due) => due.policyId === route.params?.policyId);
|
||||
}, [data, route.params?.policyId]);
|
||||
|
||||
const selectedDue = filteredDues.find((due) => due.policyId === selectedDueId) ?? null;
|
||||
|
||||
const handleToggleAutoRenew = async (due: InsurancePremiumDue, enabled: boolean) => {
|
||||
const result = await toggleAutoRenew({ policyId: due.policyId, enabled });
|
||||
|
||||
if ('data' in result && result.data) {
|
||||
toast(
|
||||
enabled ? 'Auto-renew enabled' : 'Auto-renew disabled',
|
||||
'success',
|
||||
`${due.planName} has been updated.`,
|
||||
);
|
||||
} else {
|
||||
toast('Unable to update auto-renew', 'error', 'Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestOtp = async () => {
|
||||
if (!selectedDue) {
|
||||
toast('Select a premium', 'info', 'Choose a due premium to continue.');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await requestOtp({ action: 'payment', policyId: selectedDue.policyId });
|
||||
|
||||
if ('data' in result && result.data) {
|
||||
setMockCodeHint(result.data.mockCodeHint);
|
||||
setOtpVisible(true);
|
||||
setOtpError(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
toast('Unable to send OTP', 'error', 'Please try again.');
|
||||
};
|
||||
|
||||
const handleConfirmPayment = async () => {
|
||||
if (!selectedDue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const error = validateInsuranceOtp(otp);
|
||||
|
||||
if (error) {
|
||||
setOtpError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await payPremium({
|
||||
policyId: selectedDue.policyId,
|
||||
amount: selectedDue.amount,
|
||||
method: paymentMethod,
|
||||
otp,
|
||||
});
|
||||
|
||||
if ('data' in result && result.data) {
|
||||
setOtpVisible(false);
|
||||
setOtp('');
|
||||
setOtpError(undefined);
|
||||
toast(
|
||||
'Premium paid',
|
||||
'success',
|
||||
`Next premium due on ${formatDate(result.data.nextDueDate)}.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setOtpError('OTP verification failed. Please try again.');
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenShell title="Renewals" subtitle="Pay premiums and manage auto-renew">
|
||||
<FlatList
|
||||
data={filteredDues}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isFetching}
|
||||
onRefresh={refetch}
|
||||
tintColor={theme.colors.primary}
|
||||
colors={[theme.colors.primary]}
|
||||
/>
|
||||
}
|
||||
ListHeaderComponent={
|
||||
selectedDue ? (
|
||||
<Card style={styles.paymentCard}>
|
||||
<Text style={[styles.sectionTitle, { color: theme.colors.textPrimary }]}>
|
||||
Payment method
|
||||
</Text>
|
||||
<InsurancePaymentMethodTile
|
||||
method="UPI"
|
||||
selected={paymentMethod === 'UPI'}
|
||||
onPress={() => setPaymentMethod('UPI')}
|
||||
/>
|
||||
<InsurancePaymentMethodTile
|
||||
method="Net Banking"
|
||||
selected={paymentMethod === 'Net Banking'}
|
||||
onPress={() => setPaymentMethod('Net Banking')}
|
||||
/>
|
||||
<InsurancePaymentMethodTile
|
||||
method="Card"
|
||||
selected={paymentMethod === 'Card'}
|
||||
onPress={() => setPaymentMethod('Card')}
|
||||
/>
|
||||
<Button
|
||||
title="Request OTP"
|
||||
onPress={() => void handleRequestOtp()}
|
||||
loading={requestOtpState.isLoading}
|
||||
/>
|
||||
</Card>
|
||||
) : null
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<Card style={styles.dueCard}>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.cardCopy}>
|
||||
<Text style={[styles.planTitle, { color: theme.colors.textPrimary }]}>
|
||||
{item.planName}
|
||||
</Text>
|
||||
<Text style={[styles.planMeta, { color: theme.colors.textSecondary }]}>
|
||||
{item.policyNumber} · Due {formatDate(item.dueDate)}
|
||||
</Text>
|
||||
</View>
|
||||
<InsuranceStatusPill status={item.lateFee ? 'Grace Period' : 'Renewal Due'} />
|
||||
</View>
|
||||
<View style={styles.metricRow}>
|
||||
<Text style={[styles.metricLabel, { color: theme.colors.textMuted }]}>Amount</Text>
|
||||
<Text style={[styles.metricValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(item.amount)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.metricRow}>
|
||||
<Text style={[styles.metricLabel, { color: theme.colors.textMuted }]}>Late fee</Text>
|
||||
<Text style={[styles.metricValue, { color: theme.colors.textPrimary }]}>
|
||||
{formatCurrency(item.lateFee)}
|
||||
</Text>
|
||||
</View>
|
||||
<ToggleSwitch
|
||||
label="Auto-renew"
|
||||
description="Automatically renew this policy on the due date."
|
||||
value={item.autoRenewEnabled}
|
||||
onValueChange={(value) => {
|
||||
void handleToggleAutoRenew(item, value);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
title={selectedDueId === item.policyId ? 'Selected for payment' : 'Pay this premium'}
|
||||
variant={selectedDueId === item.policyId ? 'secondary' : 'primary'}
|
||||
onPress={() => setSelectedDueId(item.policyId)}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
ItemSeparatorComponent={() => <View style={styles.separator} />}
|
||||
ListEmptyComponent={
|
||||
isLoading ? (
|
||||
<Card>
|
||||
<Loader />
|
||||
</Card>
|
||||
) : isError ? (
|
||||
<EmptyState
|
||||
title="Unable to load due premiums"
|
||||
description="Refresh and try again."
|
||||
actionLabel="Retry"
|
||||
onAction={refetch}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No premiums due"
|
||||
description="Your upcoming premium reminders will appear here."
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<InsuranceOtpModal
|
||||
visible={otpVisible}
|
||||
title="Verify Premium Payment"
|
||||
description="Enter the OTP sent to your registered mobile number to authorize this premium payment."
|
||||
otp={otp}
|
||||
onOtpChange={setOtp}
|
||||
onClose={() => setOtpVisible(false)}
|
||||
onConfirm={() => void handleConfirmPayment()}
|
||||
loading={payPremiumState.isLoading}
|
||||
error={otpError}
|
||||
mockCodeHint={mockCodeHint}
|
||||
/>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
listContent: {
|
||||
paddingBottom: 70,
|
||||
},
|
||||
paymentCard: {
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
},
|
||||
dueCard: {
|
||||
gap: 12,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
},
|
||||
cardCopy: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
planTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '800',
|
||||
},
|
||||
planMeta: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
metricRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
metricLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
metricValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '800',
|
||||
},
|
||||
separator: {
|
||||
height: 12,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
import { appConfig } from '../../../config/appConfig';
|
||||
import { apiClient } from '../../../services/api/apiClient';
|
||||
import { baseApi } from '../../../services/api/baseApi';
|
||||
import { endpoints } from '../../../services/api/endpoints';
|
||||
import { toApiError } from '../../../services/api/serviceHelpers';
|
||||
import { sleep } from '../../../utils/async';
|
||||
import type {
|
||||
InsuranceCalculatorEstimate,
|
||||
InsuranceCalculatorInput,
|
||||
InsuranceClaim,
|
||||
InsuranceDashboardPayload,
|
||||
InsuranceDocument,
|
||||
InsuranceNotification,
|
||||
InsuranceOtpSession,
|
||||
InsurancePlan,
|
||||
InsurancePolicy,
|
||||
InsurancePremiumDue,
|
||||
PayInsurancePremiumPayload,
|
||||
PayInsurancePremiumResult,
|
||||
PurchaseInsurancePayload,
|
||||
PurchaseInsuranceResult,
|
||||
RequestInsuranceOtpPayload,
|
||||
SubmitInsuranceClaimPayload,
|
||||
SubmitInsuranceClaimResult,
|
||||
UploadInsuranceDocumentPayload,
|
||||
} from '../types';
|
||||
import {
|
||||
compareInsurancePlans,
|
||||
getInsuranceDashboard,
|
||||
getInsurancePolicyDetail,
|
||||
listInsuranceClaims,
|
||||
listInsuranceDocuments,
|
||||
listInsuranceNotifications,
|
||||
listInsurancePlans,
|
||||
listInsurancePolicies,
|
||||
markInsuranceNotificationRead,
|
||||
payInsurancePremium,
|
||||
purchaseInsurancePolicy,
|
||||
requestInsuranceOtp,
|
||||
seedInsurancePlans as seedInsurancePlanCatalog,
|
||||
submitInsuranceClaim,
|
||||
toggleInsurancePolicyAutoRenew,
|
||||
uploadInsuranceDocument,
|
||||
} from './mockInsuranceData';
|
||||
import { estimateInsurancePremium } from '../utils/insuranceMath';
|
||||
|
||||
const handleMock = async <T>(factory: () => T, delayMs = 320) => {
|
||||
await sleep(delayMs);
|
||||
|
||||
try {
|
||||
return { data: factory() };
|
||||
} catch (error) {
|
||||
return { error: toApiError(error) };
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequest = async <T>(request: () => Promise<T>) => {
|
||||
try {
|
||||
return { data: await request() };
|
||||
} catch (error) {
|
||||
return { error: toApiError(error) };
|
||||
}
|
||||
};
|
||||
|
||||
export { seedInsurancePlanCatalog };
|
||||
|
||||
export const insuranceApi = baseApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getInsuranceDashboard: builder.query<InsuranceDashboardPayload, void>({
|
||||
queryFn: async () =>
|
||||
appConfig.features.useMockServer
|
||||
? handleMock(() => getInsuranceDashboard())
|
||||
: handleRequest(async () => {
|
||||
const response = await apiClient.get<InsuranceDashboardPayload>(
|
||||
endpoints.insurance.dashboard,
|
||||
);
|
||||
return response.data;
|
||||
}),
|
||||
providesTags: ['Insurance'],
|
||||
}),
|
||||
getInsurancePlans: builder.query<InsurancePlan[], { category?: string } | void>({
|
||||
queryFn: async (args) =>
|
||||
appConfig.features.useMockServer
|
||||
? handleMock(() => listInsurancePlans(args?.category as never))
|
||||
: handleRequest(async () => {
|
||||
const response = await apiClient.get<InsurancePlan[]>(endpoints.insurance.plans, {
|
||||
params: args?.category ? { category: args.category } : undefined,
|
||||
});
|
||||
return response.data;
|
||||
}),
|
||||
providesTags: ['Insurance'],
|
||||
}),
|
||||
compareInsurancePlans: builder.query<InsurancePlan[], string[]>({
|
||||
queryFn: async (planIds) =>
|
||||
appConfig.features.useMockServer
|
||||
? handleMock(() => compareInsurancePlans(planIds))
|
||||
: handleRequest(async () => {
|
||||
const response = await apiClient.post<InsurancePlan[]>(
|
||||
endpoints.insurance.comparePlans,
|
||||
{ planIds },
|
||||
);
|
||||
return response.data;
|
||||
}),
|
||||
providesTags: ['Insurance'],
|
||||
}),
|
||||
estimateInsurancePremium: builder.query<InsuranceCalculatorEstimate, InsuranceCalculatorInput>({
|
||||
queryFn: async (input) =>
|
||||
appConfig.features.useMockServer
|
||||
? handleMock(() => estimateInsurancePremium(input), 150)
|
||||
: handleRequest(async () => {
|
||||
const response = await apiClient.post<InsuranceCalculatorEstimate>(
|
||||
endpoints.insurance.calculator,
|
||||
input,
|
||||
);
|
||||
return response.data;
|
||||
}),
|
||||
providesTags: ['Insurance'],
|
||||
}),
|
||||
getInsurancePolicies: builder.query<InsurancePolicy[], void>({
|
||||
queryFn: async () =>
|
||||
appConfig.features.useMockServer
|
||||
? handleMock(() => listInsurancePolicies())
|
||||
: handleRequest(async () => {
|
||||
const response = await apiClient.get<InsurancePolicy[]>(endpoints.insurance.policies);
|
||||
return response.data;
|
||||
}),
|
||||
providesTags: ['Insurance'],
|
||||
}),
|
||||
getInsurancePolicyDetail: builder.query<InsurancePolicy, string>({
|
||||
queryFn: async (policyId) =>
|
||||
appConfig.features.useMockServer
|
||||
? handleMock(() => getInsurancePolicyDetail(policyId))
|
||||
: handleRequest(async () => {
|
||||
const response = await apiClient.get<InsurancePolicy>(
|
||||
endpoints.insurance.policyDetail(policyId),
|
||||
);
|
||||
return response.data;
|
||||
}),
|
||||
providesTags: ['Insurance'],
|
||||
}),
|
||||
getInsurancePremiumDues: builder.query<InsurancePremiumDue[], void>({
|
||||
queryFn: async () =>
|
||||
appConfig.features.useMockServer
|
||||
? handleMock(() => getInsuranceDashboard().duePremiums)
|
||||
: handleRequest(async () => {
|
||||
const response = await apiClient.get<InsurancePremiumDue[]>(
|
||||
endpoints.insurance.premiumDues,
|
||||
);
|
||||
return response.data;
|
||||
}),
|
||||
providesTags: ['Insurance'],
|
||||
}),
|
||||
requestInsuranceOtp: builder.mutation<InsuranceOtpSession, RequestInsuranceOtpPayload>({
|
||||
queryFn: async (payload) =>
|
||||
appConfig.features.useMockServer
|
||||
? handleMock(() => requestInsuranceOtp(payload), 220)
|
||||
: handleRequest(async () => {
|
||||
const response = await apiClient.post<InsuranceOtpSession>(
|
||||
endpoints.insurance.otp,
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
}),
|
||||
}),
|
||||
purchaseInsurancePolicy: builder.mutation<PurchaseInsuranceResult, PurchaseInsurancePayload>({
|
||||
queryFn: async (payload) =>
|
||||
appConfig.features.useMockServer
|
||||
? handleMock(() => purchaseInsurancePolicy(payload), 700)
|
||||
: handleRequest(async () => {
|
||||
const response = await apiClient.post<PurchaseInsuranceResult>(
|
||||
endpoints.insurance.purchase,
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
}),
|
||||
invalidatesTags: ['Insurance'],
|
||||
}),
|
||||
payInsurancePremium: builder.mutation<PayInsurancePremiumResult, PayInsurancePremiumPayload>({
|
||||
queryFn: async (payload) =>
|
||||
appConfig.features.useMockServer
|
||||
? handleMock(() => payInsurancePremium(payload), 650)
|
||||
: handleRequest(async () => {
|
||||
const response = await apiClient.post<PayInsurancePremiumResult>(
|
||||
endpoints.insurance.payPremium,
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
}),
|
||||
invalidatesTags: ['Insurance'],
|
||||
}),
|
||||
toggleInsurancePolicyAutoRenew: builder.mutation<
|
||||
InsurancePolicy,
|
||||
{ policyId: string; enabled: boolean }
|
||||
>({
|
||||
queryFn: async (payload) =>
|
||||
appConfig.features.useMockServer
|
||||
? handleMock(
|
||||
() => toggleInsurancePolicyAutoRenew(payload.policyId, payload.enabled),
|
||||
180,
|
||||
)
|
||||
: handleRequest(async () => {
|
||||
const response = await apiClient.patch<InsurancePolicy>(
|
||||
endpoints.insurance.autoRenew(payload.policyId),
|
||||
{ enabled: payload.enabled },
|
||||
);
|
||||
return response.data;
|
||||
}),
|
||||
invalidatesTags: ['Insurance'],
|
||||
}),
|
||||
getInsuranceClaims: builder.query<InsuranceClaim[], { policyId?: string } | void>({
|
||||
queryFn: async (args) =>
|
||||
appConfig.features.useMockServer
|
||||
? handleMock(() => listInsuranceClaims(args?.policyId))
|
||||
: handleRequest(async () => {
|
||||
const response = await apiClient.get<InsuranceClaim[]>(endpoints.insurance.claims, {
|
||||
params: args?.policyId ? { policyId: args.policyId } : undefined,
|
||||
});
|
||||
return response.data;
|
||||
}),
|
||||
providesTags: ['Insurance'],
|
||||
}),
|
||||
submitInsuranceClaim: builder.mutation<
|
||||
SubmitInsuranceClaimResult,
|
||||
SubmitInsuranceClaimPayload
|
||||
>({
|
||||
queryFn: async (payload) =>
|
||||
appConfig.features.useMockServer
|
||||
? handleMock(() => submitInsuranceClaim(payload), 680)
|
||||
: handleRequest(async () => {
|
||||
const response = await apiClient.post<SubmitInsuranceClaimResult>(
|
||||
endpoints.insurance.submitClaim,
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
}),
|
||||
invalidatesTags: ['Insurance'],
|
||||
}),
|
||||
getInsuranceDocuments: builder.query<
|
||||
InsuranceDocument[],
|
||||
{ category?: 'kyc' | 'claim' } | void
|
||||
>({
|
||||
queryFn: async (args) =>
|
||||
appConfig.features.useMockServer
|
||||
? handleMock(() => listInsuranceDocuments(args?.category))
|
||||
: handleRequest(async () => {
|
||||
const response = await apiClient.get<InsuranceDocument[]>(
|
||||
endpoints.insurance.documents,
|
||||
{
|
||||
params: args?.category ? { category: args.category } : undefined,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
}),
|
||||
providesTags: ['Insurance'],
|
||||
}),
|
||||
uploadInsuranceDocument: builder.mutation<InsuranceDocument, UploadInsuranceDocumentPayload>({
|
||||
queryFn: async (payload) =>
|
||||
appConfig.features.useMockServer
|
||||
? handleMock(() => uploadInsuranceDocument(payload), 420)
|
||||
: handleRequest(async () => {
|
||||
const response = await apiClient.post<InsuranceDocument>(
|
||||
endpoints.insurance.uploadDocument,
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
}),
|
||||
invalidatesTags: ['Insurance'],
|
||||
}),
|
||||
getInsuranceNotifications: builder.query<InsuranceNotification[], void>({
|
||||
queryFn: async () =>
|
||||
appConfig.features.useMockServer
|
||||
? handleMock(() => listInsuranceNotifications(), 180)
|
||||
: handleRequest(async () => {
|
||||
const response = await apiClient.get<InsuranceNotification[]>(
|
||||
endpoints.insurance.notifications,
|
||||
);
|
||||
return response.data;
|
||||
}),
|
||||
providesTags: ['Insurance'],
|
||||
}),
|
||||
markInsuranceNotificationRead: builder.mutation<{ success: boolean }, string>({
|
||||
queryFn: async (notificationId) =>
|
||||
appConfig.features.useMockServer
|
||||
? handleMock(() => markInsuranceNotificationRead(notificationId), 120)
|
||||
: handleRequest(async () => {
|
||||
const response = await apiClient.post<{ success: boolean }>(
|
||||
endpoints.insurance.markNotificationRead(notificationId),
|
||||
);
|
||||
return response.data;
|
||||
}),
|
||||
invalidatesTags: ['Insurance'],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useCompareInsurancePlansQuery,
|
||||
useEstimateInsurancePremiumQuery,
|
||||
useGetInsuranceClaimsQuery,
|
||||
useGetInsuranceDashboardQuery,
|
||||
useGetInsuranceDocumentsQuery,
|
||||
useGetInsuranceNotificationsQuery,
|
||||
useGetInsurancePlansQuery,
|
||||
useGetInsurancePoliciesQuery,
|
||||
useGetInsurancePolicyDetailQuery,
|
||||
useGetInsurancePremiumDuesQuery,
|
||||
useMarkInsuranceNotificationReadMutation,
|
||||
usePayInsurancePremiumMutation,
|
||||
usePurchaseInsurancePolicyMutation,
|
||||
useRequestInsuranceOtpMutation,
|
||||
useSubmitInsuranceClaimMutation,
|
||||
useToggleInsurancePolicyAutoRenewMutation,
|
||||
useUploadInsuranceDocumentMutation,
|
||||
} = insuranceApi;
|
||||
@@ -0,0 +1,928 @@
|
||||
import type {
|
||||
InsuranceCategory,
|
||||
InsuranceClaim,
|
||||
InsuranceDashboardPayload,
|
||||
InsuranceDocument,
|
||||
InsuranceInsurer,
|
||||
InsuranceNotification,
|
||||
InsuranceOtpSession,
|
||||
InsurancePlan,
|
||||
InsurancePolicy,
|
||||
InsurancePremiumDue,
|
||||
PayInsurancePremiumPayload,
|
||||
PayInsurancePremiumResult,
|
||||
PurchaseInsurancePayload,
|
||||
PurchaseInsuranceResult,
|
||||
RequestInsuranceOtpPayload,
|
||||
SubmitInsuranceClaimPayload,
|
||||
SubmitInsuranceClaimResult,
|
||||
UploadInsuranceDocumentPayload,
|
||||
} from '../types';
|
||||
import {
|
||||
addYears,
|
||||
getEarliestPremiumDue,
|
||||
getNextDateForFrequency,
|
||||
getPolicyStatusFromDueDate,
|
||||
getPolicyTotalCoverage,
|
||||
getPremiumLateFee,
|
||||
} from '../utils/insuranceMath';
|
||||
|
||||
const cloneValue = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
|
||||
|
||||
const insurers: InsuranceInsurer[] = [
|
||||
{
|
||||
id: 'ins_guardian',
|
||||
name: 'Guardian Health Assurance',
|
||||
claimSettlementRatio: 96.4,
|
||||
supportPhone: '1800-210-8899',
|
||||
supportEmail: 'care@guardianhealth.co.in',
|
||||
website: 'https://guardianhealth.example',
|
||||
address: 'Koramangala 5th Block, Bengaluru',
|
||||
},
|
||||
{
|
||||
id: 'ins_zenith',
|
||||
name: 'Zenith Life Assurance',
|
||||
claimSettlementRatio: 98.2,
|
||||
supportPhone: '1800-450-2100',
|
||||
supportEmail: 'support@zenithlife.co.in',
|
||||
website: 'https://zenithlife.example',
|
||||
address: 'BKC, Mumbai',
|
||||
},
|
||||
{
|
||||
id: 'ins_drivesure',
|
||||
name: 'DriveSure General Insurance',
|
||||
claimSettlementRatio: 94.9,
|
||||
supportPhone: '1800-120-7788',
|
||||
supportEmail: 'claims@drivesure.co.in',
|
||||
website: 'https://drivesure.example',
|
||||
address: 'Madhapur, Hyderabad',
|
||||
},
|
||||
{
|
||||
id: 'ins_globetrot',
|
||||
name: 'GlobeTrot Protect',
|
||||
claimSettlementRatio: 93.6,
|
||||
supportPhone: '1800-510-3344',
|
||||
supportEmail: 'travel@globetrotprotect.co.in',
|
||||
website: 'https://globetrotprotect.example',
|
||||
address: 'Viman Nagar, Pune',
|
||||
},
|
||||
{
|
||||
id: 'ins_safenest',
|
||||
name: 'SafeNest General Insurance',
|
||||
claimSettlementRatio: 95.1,
|
||||
supportPhone: '1800-880-4455',
|
||||
supportEmail: 'hello@safenest.co.in',
|
||||
website: 'https://safenest.example',
|
||||
address: 'T Nagar, Chennai',
|
||||
},
|
||||
];
|
||||
|
||||
const planCatalog: InsurancePlan[] = [
|
||||
{
|
||||
id: 'health_guard_plus',
|
||||
category: 'Health Insurance',
|
||||
insurerId: 'ins_guardian',
|
||||
insurerName: 'Guardian Health Assurance',
|
||||
name: 'Guardian Family Shield',
|
||||
premium: 2140,
|
||||
premiumFrequency: 'Monthly',
|
||||
coverAmount: 1500000,
|
||||
tenureYears: 1,
|
||||
summary: 'Family floater plan with cashless hospitalization and annual wellness checks.',
|
||||
benefits: ['Cashless hospitalization', 'No-claim bonus', 'Day-care coverage'],
|
||||
inclusions: ['Room rent without cap', 'Annual preventive check-up', 'Ambulance cover'],
|
||||
exclusions: ['Cosmetic procedures', 'Self-inflicted injuries', 'Waiting period ailments'],
|
||||
claimSettlementRatio: 96.4,
|
||||
waitingPeriodDays: 30,
|
||||
recommendedFor: 'Young families seeking cashless hospitalization cover.',
|
||||
addOns: ['Maternity add-on', 'Critical illness rider'],
|
||||
},
|
||||
{
|
||||
id: 'health_elite_family',
|
||||
category: 'Health Insurance',
|
||||
insurerId: 'ins_guardian',
|
||||
insurerName: 'Guardian Health Assurance',
|
||||
name: 'Guardian Elite Care',
|
||||
premium: 3560,
|
||||
premiumFrequency: 'Monthly',
|
||||
coverAmount: 2500000,
|
||||
tenureYears: 1,
|
||||
summary: 'Higher family cover with OPD benefits and annual wellness rewards.',
|
||||
benefits: ['Global emergency support', 'OPD allowance', 'Restoration benefit'],
|
||||
inclusions: ['Cashless hospital network', 'Second opinion', 'Tele-consult access'],
|
||||
exclusions: ['Non-medical consumables', 'Experimental treatment'],
|
||||
claimSettlementRatio: 96.4,
|
||||
waitingPeriodDays: 15,
|
||||
recommendedFor: 'Families wanting higher hospitalization cover and OPD support.',
|
||||
addOns: ['Unlimited restoration', 'Room upgrade'],
|
||||
},
|
||||
{
|
||||
id: 'life_secure_term',
|
||||
category: 'Life Insurance',
|
||||
insurerId: 'ins_zenith',
|
||||
insurerName: 'Zenith Life Assurance',
|
||||
name: 'Zenith Secure Term',
|
||||
premium: 1890,
|
||||
premiumFrequency: 'Monthly',
|
||||
coverAmount: 10000000,
|
||||
tenureYears: 20,
|
||||
summary: 'Pure term cover with flexible payout and wellness-linked discounts.',
|
||||
benefits: ['High sum assured', 'Terminal illness benefit', 'Payout flexibility'],
|
||||
inclusions: ['Monthly income option', 'Accidental death rider option', 'Digital issuance'],
|
||||
exclusions: ['Suicide within first year', 'Material misstatement'],
|
||||
claimSettlementRatio: 98.2,
|
||||
recommendedFor: 'Primary earners seeking long-duration family income protection.',
|
||||
addOns: ['Waiver of premium', 'Accidental disability rider'],
|
||||
},
|
||||
{
|
||||
id: 'life_income_shield',
|
||||
category: 'Life Insurance',
|
||||
insurerId: 'ins_zenith',
|
||||
insurerName: 'Zenith Life Assurance',
|
||||
name: 'Zenith Income Shield',
|
||||
premium: 2750,
|
||||
premiumFrequency: 'Monthly',
|
||||
coverAmount: 15000000,
|
||||
tenureYears: 25,
|
||||
summary: 'Term protection with staggered monthly family income support.',
|
||||
benefits: ['Monthly income payout', 'Return of premium option', 'Life-stage upgrades'],
|
||||
inclusions: ['Instant policy issuance', 'Wellness discount'],
|
||||
exclusions: ['Suicide exclusion clause', 'Fraudulent disclosure'],
|
||||
claimSettlementRatio: 98.2,
|
||||
recommendedFor: 'Households that prefer monthly income support for nominees.',
|
||||
addOns: ['Critical illness', 'Child income support'],
|
||||
},
|
||||
{
|
||||
id: 'motor_car_smart',
|
||||
category: 'Motor Insurance',
|
||||
insurerId: 'ins_drivesure',
|
||||
insurerName: 'DriveSure General Insurance',
|
||||
name: 'DriveSure Car Secure',
|
||||
premium: 12450,
|
||||
premiumFrequency: 'Yearly',
|
||||
coverAmount: 800000,
|
||||
tenureYears: 1,
|
||||
summary: 'Comprehensive car cover with zero-depreciation and roadside assistance.',
|
||||
benefits: ['Zero depreciation', 'Roadside assistance', 'Cashless garage network'],
|
||||
inclusions: ['Own damage cover', 'Third-party liability', 'Engine protection'],
|
||||
exclusions: ['Driving without license', 'Mechanical breakdown', 'War risks'],
|
||||
claimSettlementRatio: 94.9,
|
||||
deductibleAmount: 1000,
|
||||
recommendedFor: 'Private car owners who want broad repair and liability cover.',
|
||||
addOns: ['Consumables cover', 'Return to invoice'],
|
||||
},
|
||||
{
|
||||
id: 'motor_bike_smart',
|
||||
category: 'Motor Insurance',
|
||||
insurerId: 'ins_drivesure',
|
||||
insurerName: 'DriveSure General Insurance',
|
||||
name: 'DriveSure Bike Protect',
|
||||
premium: 4680,
|
||||
premiumFrequency: 'Yearly',
|
||||
coverAmount: 250000,
|
||||
tenureYears: 1,
|
||||
summary: 'Comprehensive bike cover with theft assistance and third-party protection.',
|
||||
benefits: ['Theft assistance', 'Garage network', 'Personal accident cover'],
|
||||
inclusions: ['Own damage cover', 'Third-party liability', '24x7 claims'],
|
||||
exclusions: ['Driving under influence', 'Commercial misuse'],
|
||||
claimSettlementRatio: 94.9,
|
||||
deductibleAmount: 500,
|
||||
recommendedFor: 'Bike owners seeking low-friction annual protection.',
|
||||
addOns: ['Return to invoice', 'Roadside assistance'],
|
||||
},
|
||||
{
|
||||
id: 'travel_global_trip',
|
||||
category: 'Travel Insurance',
|
||||
insurerId: 'ins_globetrot',
|
||||
insurerName: 'GlobeTrot Protect',
|
||||
name: 'Global Trip Shield',
|
||||
premium: 1450,
|
||||
premiumFrequency: 'Yearly',
|
||||
coverAmount: 5000000,
|
||||
tenureYears: 1,
|
||||
summary: 'Multi-trip cover for medical emergencies, delays, and baggage loss.',
|
||||
benefits: ['Medical emergency cover', 'Trip delay assistance', 'Baggage loss support'],
|
||||
inclusions: ['Emergency evacuation', 'Passport loss cover', 'Adventure sports add-on'],
|
||||
exclusions: ['High-risk countries', 'Pre-existing conditions', 'Illegal acts'],
|
||||
claimSettlementRatio: 93.6,
|
||||
recommendedFor: 'Frequent travellers who need annual international trip protection.',
|
||||
addOns: ['Golf cover', 'Home burglary cover'],
|
||||
},
|
||||
{
|
||||
id: 'travel_student_explore',
|
||||
category: 'Travel Insurance',
|
||||
insurerId: 'ins_globetrot',
|
||||
insurerName: 'GlobeTrot Protect',
|
||||
name: 'Student Explore',
|
||||
premium: 980,
|
||||
premiumFrequency: 'Yearly',
|
||||
coverAmount: 2000000,
|
||||
tenureYears: 1,
|
||||
summary: 'Student travel cover for tuition emergencies and health incidents abroad.',
|
||||
benefits: ['Sponsor protection', 'Study interruption cover', 'Medical emergencies'],
|
||||
inclusions: ['Loss of baggage', 'Compassionate visit', 'Laptop cover'],
|
||||
exclusions: ['Pre-existing ailments', 'Professional sports'],
|
||||
claimSettlementRatio: 93.6,
|
||||
recommendedFor: 'Students travelling overseas for study programs.',
|
||||
addOns: ['Flight cancellation', 'Device cover'],
|
||||
},
|
||||
{
|
||||
id: 'property_home_secure',
|
||||
category: 'Property Insurance',
|
||||
insurerId: 'ins_safenest',
|
||||
insurerName: 'SafeNest General Insurance',
|
||||
name: 'SafeNest Home Secure',
|
||||
premium: 5920,
|
||||
premiumFrequency: 'Yearly',
|
||||
coverAmount: 4500000,
|
||||
tenureYears: 1,
|
||||
summary: 'Structure and contents protection with burglary and fire cover.',
|
||||
benefits: ['Structure protection', 'Contents cover', 'Burglary assistance'],
|
||||
inclusions: ['Fire and allied perils', 'Natural calamity cover', 'Electrical equipment cover'],
|
||||
exclusions: ['Wear and tear', 'Intentional damage', 'Vacant property losses'],
|
||||
claimSettlementRatio: 95.1,
|
||||
deductibleAmount: 2500,
|
||||
recommendedFor: 'Homeowners safeguarding structure and interior contents.',
|
||||
addOns: ['Jewellery cover', 'Tenant liability'],
|
||||
},
|
||||
{
|
||||
id: 'property_landlord_plus',
|
||||
category: 'Property Insurance',
|
||||
insurerId: 'ins_safenest',
|
||||
insurerName: 'SafeNest General Insurance',
|
||||
name: 'SafeNest Landlord Plus',
|
||||
premium: 7380,
|
||||
premiumFrequency: 'Yearly',
|
||||
coverAmount: 7000000,
|
||||
tenureYears: 1,
|
||||
summary: 'Landlord-friendly cover with rental income disruption and liability support.',
|
||||
benefits: ['Rental disruption cover', 'Liability cover', 'Burglary support'],
|
||||
inclusions: ['Fire cover', 'Flood cover', 'Owner liability'],
|
||||
exclusions: ['Tenant negligence without evidence', 'Construction defects'],
|
||||
claimSettlementRatio: 95.1,
|
||||
deductibleAmount: 3500,
|
||||
recommendedFor: 'Landlords with higher-value rental properties.',
|
||||
addOns: ['Loss of rent', 'Glass breakage'],
|
||||
},
|
||||
];
|
||||
|
||||
const policySeed: InsurancePolicy[] = [
|
||||
{
|
||||
id: 'policy_health_001',
|
||||
planId: 'health_guard_plus',
|
||||
planName: 'Guardian Family Shield',
|
||||
category: 'Health Insurance',
|
||||
insurer: insurers[0]!,
|
||||
policyNumber: 'HLT-2026-441208',
|
||||
insuredName: 'Aarav Sharma',
|
||||
premiumAmount: 2140,
|
||||
premiumFrequency: 'Monthly',
|
||||
coverageAmount: 1500000,
|
||||
status: 'Active',
|
||||
startDate: '2026-01-03T00:00:00.000Z',
|
||||
expiryDate: '2027-01-02T00:00:00.000Z',
|
||||
nextPremiumDueDate: '2026-05-02T00:00:00.000Z',
|
||||
benefits: ['Cashless hospitalization', 'No-claim bonus', 'Day-care coverage'],
|
||||
inclusions: ['Room rent without cap', 'Annual preventive check-up', 'Ambulance cover'],
|
||||
exclusions: ['Cosmetic procedures', 'Self-inflicted injuries', 'Waiting period ailments'],
|
||||
nomineeName: 'Megha Sharma',
|
||||
nomineeRelationship: 'Spouse',
|
||||
autoRenewEnabled: true,
|
||||
membersCovered: 3,
|
||||
healthDetails: {
|
||||
familyMembers: 3,
|
||||
medicalHistory: 'None',
|
||||
smoker: 'No',
|
||||
},
|
||||
documents: [
|
||||
{
|
||||
id: 'policy_doc_health_001',
|
||||
name: 'Policy Schedule',
|
||||
mimeType: 'application/pdf',
|
||||
uploadedAt: '2026-01-03T09:00:00.000Z',
|
||||
url: 'https://mock.bankcbs.dev/insurance/policies/HLT-2026-441208.pdf',
|
||||
sizeKb: 612,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'policy_motor_001',
|
||||
planId: 'motor_car_smart',
|
||||
planName: 'DriveSure Car Secure',
|
||||
category: 'Motor Insurance',
|
||||
insurer: insurers[2]!,
|
||||
policyNumber: 'MTR-2025-778231',
|
||||
insuredName: 'Aarav Sharma',
|
||||
premiumAmount: 12450,
|
||||
premiumFrequency: 'Yearly',
|
||||
coverageAmount: 800000,
|
||||
status: 'Renewal Due',
|
||||
startDate: '2025-04-28T00:00:00.000Z',
|
||||
expiryDate: '2026-04-27T00:00:00.000Z',
|
||||
nextPremiumDueDate: '2026-04-27T00:00:00.000Z',
|
||||
benefits: ['Zero depreciation', 'Roadside assistance', 'Cashless garage network'],
|
||||
inclusions: ['Own damage cover', 'Third-party liability', 'Engine protection'],
|
||||
exclusions: ['Driving without license', 'Mechanical breakdown', 'War risks'],
|
||||
nomineeName: 'Megha Sharma',
|
||||
nomineeRelationship: 'Spouse',
|
||||
autoRenewEnabled: false,
|
||||
membersCovered: 1,
|
||||
deductibleAmount: 1000,
|
||||
vehicleDetails: {
|
||||
vehicleType: 'Car',
|
||||
makeModel: 'Honda City ZX',
|
||||
registrationNumber: 'KA01MN4421',
|
||||
registrationYear: '2023',
|
||||
},
|
||||
documents: [
|
||||
{
|
||||
id: 'policy_doc_motor_001',
|
||||
name: 'Policy Schedule',
|
||||
mimeType: 'application/pdf',
|
||||
uploadedAt: '2025-04-28T10:15:00.000Z',
|
||||
url: 'https://mock.bankcbs.dev/insurance/policies/MTR-2025-778231.pdf',
|
||||
sizeKb: 544,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'policy_life_001',
|
||||
planId: 'life_secure_term',
|
||||
planName: 'Zenith Secure Term',
|
||||
category: 'Life Insurance',
|
||||
insurer: insurers[1]!,
|
||||
policyNumber: 'LIF-2025-125504',
|
||||
insuredName: 'Aarav Sharma',
|
||||
premiumAmount: 1890,
|
||||
premiumFrequency: 'Monthly',
|
||||
coverageAmount: 10000000,
|
||||
status: 'Active',
|
||||
startDate: '2025-09-18T00:00:00.000Z',
|
||||
expiryDate: '2045-09-17T00:00:00.000Z',
|
||||
nextPremiumDueDate: '2026-04-18T00:00:00.000Z',
|
||||
benefits: ['High sum assured', 'Terminal illness benefit', 'Payout flexibility'],
|
||||
inclusions: ['Monthly income option', 'Accidental death rider option', 'Digital issuance'],
|
||||
exclusions: ['Suicide within first year', 'Material misstatement'],
|
||||
nomineeName: 'Megha Sharma',
|
||||
nomineeRelationship: 'Spouse',
|
||||
autoRenewEnabled: true,
|
||||
membersCovered: 1,
|
||||
lifeDetails: {
|
||||
annualIncome: 1800000,
|
||||
occupation: 'Product Manager',
|
||||
smoker: 'No',
|
||||
},
|
||||
documents: [
|
||||
{
|
||||
id: 'policy_doc_life_001',
|
||||
name: 'Policy Certificate',
|
||||
mimeType: 'application/pdf',
|
||||
uploadedAt: '2025-09-18T08:45:00.000Z',
|
||||
url: 'https://mock.bankcbs.dev/insurance/policies/LIF-2025-125504.pdf',
|
||||
sizeKb: 488,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const documentSeed: InsuranceDocument[] = [
|
||||
{
|
||||
id: 'ins_doc_kyc_001',
|
||||
name: 'Aadhaar Card',
|
||||
type: 'Aadhaar',
|
||||
category: 'kyc',
|
||||
status: 'verified',
|
||||
uploadedAt: '2026-01-02T07:40:00.000Z',
|
||||
mimeType: 'application/pdf',
|
||||
url: 'https://mock.bankcbs.dev/insurance/documents/aadhaar.pdf',
|
||||
sizeKb: 214,
|
||||
},
|
||||
{
|
||||
id: 'ins_doc_kyc_002',
|
||||
name: 'PAN Card',
|
||||
type: 'PAN',
|
||||
category: 'kyc',
|
||||
status: 'verified',
|
||||
uploadedAt: '2026-01-02T07:41:00.000Z',
|
||||
mimeType: 'image/jpeg',
|
||||
url: 'https://mock.bankcbs.dev/insurance/documents/pan.jpg',
|
||||
sizeKb: 188,
|
||||
},
|
||||
{
|
||||
id: 'ins_doc_claim_001',
|
||||
name: 'Hospital Bill',
|
||||
type: 'Invoice',
|
||||
category: 'claim',
|
||||
status: 'uploaded',
|
||||
uploadedAt: '2026-04-07T12:15:00.000Z',
|
||||
mimeType: 'application/pdf',
|
||||
url: 'https://mock.bankcbs.dev/insurance/documents/hospital_bill.pdf',
|
||||
sizeKb: 742,
|
||||
linkedClaimId: 'claim_health_001',
|
||||
},
|
||||
{
|
||||
id: 'ins_doc_claim_002',
|
||||
name: 'Discharge Summary',
|
||||
type: 'Medical Report',
|
||||
category: 'claim',
|
||||
status: 'uploaded',
|
||||
uploadedAt: '2026-04-07T12:20:00.000Z',
|
||||
mimeType: 'application/pdf',
|
||||
url: 'https://mock.bankcbs.dev/insurance/documents/discharge_summary.pdf',
|
||||
sizeKb: 580,
|
||||
linkedClaimId: 'claim_health_001',
|
||||
},
|
||||
{
|
||||
id: 'ins_doc_claim_003',
|
||||
name: 'Workshop Invoice',
|
||||
type: 'Invoice',
|
||||
category: 'claim',
|
||||
status: 'verified',
|
||||
uploadedAt: '2026-01-21T14:20:00.000Z',
|
||||
mimeType: 'application/pdf',
|
||||
url: 'https://mock.bankcbs.dev/insurance/documents/workshop_invoice.pdf',
|
||||
sizeKb: 436,
|
||||
linkedClaimId: 'claim_motor_001',
|
||||
},
|
||||
];
|
||||
|
||||
const claimSeed: InsuranceClaim[] = [
|
||||
{
|
||||
id: 'claim_health_001',
|
||||
claimNumber: 'CLM-H-2026-0931',
|
||||
policyId: 'policy_health_001',
|
||||
policyNumber: 'HLT-2026-441208',
|
||||
policyName: 'Guardian Family Shield',
|
||||
category: 'Health Insurance',
|
||||
reason: 'Hospitalization due to dengue',
|
||||
eventDate: '2026-04-05T00:00:00.000Z',
|
||||
amount: 85000,
|
||||
status: 'Under Review',
|
||||
createdAt: '2026-04-07T11:55:00.000Z',
|
||||
updatedAt: '2026-04-09T10:20:00.000Z',
|
||||
notes: 'Cashless request received. Hospital estimate and discharge papers under review.',
|
||||
documents: documentSeed.filter((item) => item.linkedClaimId === 'claim_health_001'),
|
||||
},
|
||||
{
|
||||
id: 'claim_motor_001',
|
||||
claimNumber: 'CLM-M-2026-0107',
|
||||
policyId: 'policy_motor_001',
|
||||
policyNumber: 'MTR-2025-778231',
|
||||
policyName: 'DriveSure Car Secure',
|
||||
category: 'Motor Insurance',
|
||||
reason: 'Rear bumper repair after low-speed collision',
|
||||
eventDate: '2026-01-18T00:00:00.000Z',
|
||||
amount: 32000,
|
||||
status: 'Settled',
|
||||
createdAt: '2026-01-19T09:10:00.000Z',
|
||||
updatedAt: '2026-01-24T16:10:00.000Z',
|
||||
notes: 'Claim settled to partnered cashless garage.',
|
||||
documents: documentSeed.filter((item) => item.linkedClaimId === 'claim_motor_001'),
|
||||
},
|
||||
];
|
||||
|
||||
const notificationSeed: InsuranceNotification[] = [
|
||||
{
|
||||
id: 'ins_notif_001',
|
||||
type: 'premium_due',
|
||||
title: 'Premium due in 5 days',
|
||||
body: 'Zenith Secure Term premium of Rs 1,890 is due on 18 Apr 2026.',
|
||||
createdAt: '2026-04-13T06:00:00.000Z',
|
||||
read: false,
|
||||
policyId: 'policy_life_001',
|
||||
},
|
||||
{
|
||||
id: 'ins_notif_002',
|
||||
type: 'policy_expiry',
|
||||
title: 'Motor policy expires soon',
|
||||
body: 'DriveSure Car Secure expires on 27 Apr 2026. Renew to avoid a break in cover.',
|
||||
createdAt: '2026-04-12T10:10:00.000Z',
|
||||
read: false,
|
||||
policyId: 'policy_motor_001',
|
||||
},
|
||||
{
|
||||
id: 'ins_notif_003',
|
||||
type: 'claim_update',
|
||||
title: 'Claim moved to review',
|
||||
body: 'Your health claim CLM-H-2026-0931 is now under review.',
|
||||
createdAt: '2026-04-09T10:20:00.000Z',
|
||||
read: true,
|
||||
claimId: 'claim_health_001',
|
||||
policyId: 'policy_health_001',
|
||||
},
|
||||
{
|
||||
id: 'ins_notif_004',
|
||||
type: 'offer',
|
||||
title: 'Upgrade with critical illness rider',
|
||||
body: 'You can add a critical illness rider to your life cover in under two minutes.',
|
||||
createdAt: '2026-04-08T07:45:00.000Z',
|
||||
read: true,
|
||||
policyId: 'policy_life_001',
|
||||
},
|
||||
];
|
||||
|
||||
let mockPlans = cloneValue(planCatalog);
|
||||
let mockPolicies = cloneValue(policySeed);
|
||||
let mockDocuments = cloneValue(documentSeed);
|
||||
let mockClaims = cloneValue(claimSeed);
|
||||
let mockNotifications = cloneValue(notificationSeed);
|
||||
|
||||
const findInsurer = (id: string) => insurers.find((insurer) => insurer.id === id) ?? insurers[0]!;
|
||||
|
||||
const getPolicyDocuments = (policyNumber: string): InsurancePolicy['documents'] => [
|
||||
{
|
||||
id: `policy_doc_${policyNumber}`,
|
||||
name: 'Policy Schedule',
|
||||
mimeType: 'application/pdf',
|
||||
uploadedAt: new Date().toISOString(),
|
||||
url: `https://mock.bankcbs.dev/insurance/policies/${policyNumber}.pdf`,
|
||||
sizeKb: 624,
|
||||
},
|
||||
];
|
||||
|
||||
const withStatus = (policy: InsurancePolicy): InsurancePolicy => ({
|
||||
...policy,
|
||||
status: getPolicyStatusFromDueDate(policy.nextPremiumDueDate, policy.expiryDate),
|
||||
});
|
||||
|
||||
const getPremiumDues = (): InsurancePremiumDue[] =>
|
||||
mockPolicies
|
||||
.map((policy) => ({
|
||||
id: `due_${policy.id}`,
|
||||
policyId: policy.id,
|
||||
planName: policy.planName,
|
||||
policyNumber: policy.policyNumber,
|
||||
amount: policy.premiumAmount + getPremiumLateFee(policy.nextPremiumDueDate, policy.premiumAmount),
|
||||
dueDate: policy.nextPremiumDueDate,
|
||||
frequency: policy.premiumFrequency,
|
||||
lateFee: getPremiumLateFee(policy.nextPremiumDueDate, policy.premiumAmount),
|
||||
autoRenewEnabled: policy.autoRenewEnabled,
|
||||
}))
|
||||
.sort(
|
||||
(left, right) => new Date(left.dueDate).getTime() - new Date(right.dueDate).getTime(),
|
||||
);
|
||||
|
||||
const buildDashboard = (): InsuranceDashboardPayload => {
|
||||
const policies = mockPolicies.map(withStatus);
|
||||
const duePremiums = getPremiumDues();
|
||||
|
||||
return {
|
||||
policies,
|
||||
totalCoverageAmount: getPolicyTotalCoverage(
|
||||
policies.filter((policy) => policy.status !== 'Expired'),
|
||||
),
|
||||
activePoliciesCount: policies.filter((policy) => policy.status !== 'Expired').length,
|
||||
duePremiums,
|
||||
upcomingPremium: getEarliestPremiumDue(duePremiums),
|
||||
recentClaims: [...mockClaims]
|
||||
.sort(
|
||||
(left, right) =>
|
||||
new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(),
|
||||
)
|
||||
.slice(0, 3)
|
||||
.map((claim) => ({ ...claim, documents: cloneValue(claim.documents) })),
|
||||
notifications: [...mockNotifications]
|
||||
.sort(
|
||||
(left, right) =>
|
||||
new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(),
|
||||
)
|
||||
.slice(0, 4)
|
||||
.map((item) => ({ ...item })),
|
||||
};
|
||||
};
|
||||
|
||||
export const seedInsurancePlans = () => cloneValue(planCatalog);
|
||||
|
||||
export const listInsurancePlans = (category?: InsuranceCategory) =>
|
||||
cloneValue(category ? mockPlans.filter((plan) => plan.category === category) : mockPlans);
|
||||
|
||||
export const compareInsurancePlans = (planIds: string[]) =>
|
||||
cloneValue(mockPlans.filter((plan) => planIds.includes(plan.id)));
|
||||
|
||||
export const listInsurancePolicies = () =>
|
||||
cloneValue(
|
||||
mockPolicies
|
||||
.map(withStatus)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
new Date(left.nextPremiumDueDate).getTime() - new Date(right.nextPremiumDueDate).getTime(),
|
||||
),
|
||||
);
|
||||
|
||||
export const getInsurancePolicyDetail = (policyId: string) => {
|
||||
const policy = mockPolicies.find((item) => item.id === policyId);
|
||||
|
||||
if (!policy) {
|
||||
throw new Error('Policy not found.');
|
||||
}
|
||||
|
||||
return cloneValue(withStatus(policy));
|
||||
};
|
||||
|
||||
export const getInsuranceDashboard = () => cloneValue(buildDashboard());
|
||||
|
||||
export const listInsuranceClaims = (policyId?: string) =>
|
||||
cloneValue(
|
||||
(policyId ? mockClaims.filter((claim) => claim.policyId === policyId) : mockClaims).sort(
|
||||
(left, right) =>
|
||||
new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(),
|
||||
),
|
||||
);
|
||||
|
||||
export const listInsuranceDocuments = (category?: 'kyc' | 'claim') =>
|
||||
cloneValue(category ? mockDocuments.filter((document) => document.category === category) : mockDocuments);
|
||||
|
||||
export const listInsuranceNotifications = () =>
|
||||
cloneValue(
|
||||
[...mockNotifications].sort(
|
||||
(left, right) =>
|
||||
new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(),
|
||||
),
|
||||
);
|
||||
|
||||
export const requestInsuranceOtp = (
|
||||
payload: RequestInsuranceOtpPayload,
|
||||
): InsuranceOtpSession => ({
|
||||
referenceId: `${payload.action.toUpperCase()}_${Date.now()}`,
|
||||
destinationMask: '+91 98*** 3210',
|
||||
mockCodeHint: '123456',
|
||||
});
|
||||
|
||||
const assertOtp = (otp: string) => {
|
||||
if (otp !== '123456') {
|
||||
throw new Error('The OTP you entered is invalid.');
|
||||
}
|
||||
};
|
||||
|
||||
export const purchaseInsurancePolicy = (
|
||||
payload: PurchaseInsurancePayload,
|
||||
): PurchaseInsuranceResult => {
|
||||
assertOtp(payload.otp);
|
||||
|
||||
const selectedPlan = mockPlans.find((plan) => plan.id === payload.draft.selectedPlanId);
|
||||
|
||||
if (!selectedPlan) {
|
||||
throw new Error('Select an insurance plan to continue.');
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const policyId = `policy_${Date.now()}`;
|
||||
const policyNumber = `INS-${new Date().getFullYear()}-${Math.floor(100000 + Math.random() * 900000)}`;
|
||||
const nextPremiumDueDate = getNextDateForFrequency(now, selectedPlan.premiumFrequency);
|
||||
const expiryDate =
|
||||
selectedPlan.category === 'Life Insurance'
|
||||
? addYears(now, selectedPlan.tenureYears)
|
||||
: addYears(now, 1);
|
||||
|
||||
const policy: InsurancePolicy = {
|
||||
id: policyId,
|
||||
planId: selectedPlan.id,
|
||||
planName: selectedPlan.name,
|
||||
category: selectedPlan.category,
|
||||
insurer: findInsurer(selectedPlan.insurerId),
|
||||
policyNumber,
|
||||
insuredName: payload.draft.personal.fullName,
|
||||
premiumAmount: selectedPlan.premium,
|
||||
premiumFrequency: selectedPlan.premiumFrequency,
|
||||
coverageAmount: selectedPlan.coverAmount,
|
||||
status: 'Active',
|
||||
startDate: now,
|
||||
expiryDate,
|
||||
nextPremiumDueDate,
|
||||
benefits: [...selectedPlan.benefits],
|
||||
inclusions: [...selectedPlan.inclusions],
|
||||
exclusions: [...selectedPlan.exclusions],
|
||||
nomineeName: payload.draft.nominee.name,
|
||||
nomineeRelationship: payload.draft.nominee.relationship,
|
||||
autoRenewEnabled: payload.draft.autoRenewEnabled,
|
||||
membersCovered: Number(payload.draft.specificDetails.familyMembers || 1),
|
||||
deductibleAmount: selectedPlan.deductibleAmount,
|
||||
documents: getPolicyDocuments(policyNumber),
|
||||
};
|
||||
|
||||
if (selectedPlan.category === 'Health Insurance') {
|
||||
policy.healthDetails = {
|
||||
familyMembers: Number(payload.draft.specificDetails.familyMembers || 1),
|
||||
medicalHistory: payload.draft.specificDetails.medicalHistory || 'None',
|
||||
smoker: payload.draft.specificDetails.smoker || 'No',
|
||||
};
|
||||
}
|
||||
|
||||
if (selectedPlan.category === 'Life Insurance') {
|
||||
policy.lifeDetails = {
|
||||
annualIncome: Number(payload.draft.specificDetails.annualIncome || 0),
|
||||
occupation: payload.draft.specificDetails.occupation || '',
|
||||
smoker: payload.draft.specificDetails.smoker || 'No',
|
||||
};
|
||||
}
|
||||
|
||||
if (selectedPlan.category === 'Motor Insurance') {
|
||||
policy.vehicleDetails = {
|
||||
vehicleType: payload.draft.specificDetails.vehicleType || '',
|
||||
makeModel: payload.draft.specificDetails.makeModel || '',
|
||||
registrationNumber: payload.draft.specificDetails.registrationNumber || '',
|
||||
registrationYear: payload.draft.specificDetails.registrationYear || '',
|
||||
};
|
||||
}
|
||||
|
||||
if (selectedPlan.category === 'Travel Insurance') {
|
||||
policy.travelDetails = {
|
||||
destination: payload.draft.specificDetails.destination || '',
|
||||
departureDate: payload.draft.specificDetails.departureDate || '',
|
||||
returnDate: payload.draft.specificDetails.returnDate || '',
|
||||
travellers: Number(payload.draft.specificDetails.travellers || 1),
|
||||
passportNumber: payload.draft.specificDetails.passportNumber || '',
|
||||
};
|
||||
}
|
||||
|
||||
if (selectedPlan.category === 'Property Insurance') {
|
||||
policy.propertyDetails = {
|
||||
propertyType: payload.draft.specificDetails.propertyType || '',
|
||||
propertyValue: Number(payload.draft.specificDetails.propertyValue || 0),
|
||||
constructionYear: payload.draft.specificDetails.constructionYear || '',
|
||||
propertyAddress: payload.draft.specificDetails.propertyAddress || '',
|
||||
};
|
||||
}
|
||||
|
||||
mockPolicies = [policy, ...mockPolicies];
|
||||
mockNotifications = [
|
||||
{
|
||||
id: `ins_notif_${Date.now()}`,
|
||||
type: 'offer',
|
||||
title: 'Policy issued successfully',
|
||||
body: `${selectedPlan.name} has been issued with policy number ${policyNumber}.`,
|
||||
createdAt: now,
|
||||
read: false,
|
||||
policyId,
|
||||
},
|
||||
...mockNotifications,
|
||||
];
|
||||
|
||||
return {
|
||||
policy: cloneValue(policy),
|
||||
transactionRef: `TXN${Date.now()}`,
|
||||
};
|
||||
};
|
||||
|
||||
export const payInsurancePremium = (
|
||||
payload: PayInsurancePremiumPayload,
|
||||
): PayInsurancePremiumResult => {
|
||||
assertOtp(payload.otp);
|
||||
|
||||
const policyIndex = mockPolicies.findIndex((policy) => policy.id === payload.policyId);
|
||||
|
||||
if (policyIndex < 0) {
|
||||
throw new Error('Policy not found.');
|
||||
}
|
||||
|
||||
const current = mockPolicies[policyIndex]!;
|
||||
const nextDueDate = getNextDateForFrequency(
|
||||
current.nextPremiumDueDate,
|
||||
current.premiumFrequency,
|
||||
);
|
||||
const shouldExtendExpiry =
|
||||
Math.abs(
|
||||
new Date(current.expiryDate).getTime() - new Date(current.nextPremiumDueDate).getTime(),
|
||||
) <= 24 * 60 * 60 * 1000;
|
||||
|
||||
const updatedPolicy: InsurancePolicy = {
|
||||
...current,
|
||||
nextPremiumDueDate: nextDueDate,
|
||||
expiryDate: shouldExtendExpiry ? addYears(current.expiryDate, 1) : current.expiryDate,
|
||||
status: 'Active',
|
||||
};
|
||||
|
||||
mockPolicies = mockPolicies.map((policy, index) =>
|
||||
index === policyIndex ? updatedPolicy : policy,
|
||||
);
|
||||
mockNotifications = [
|
||||
{
|
||||
id: `ins_notif_${Date.now()}`,
|
||||
type: 'premium_due',
|
||||
title: 'Premium paid successfully',
|
||||
body: `${updatedPolicy.planName} premium of Rs ${payload.amount.toLocaleString('en-IN')} paid successfully.`,
|
||||
createdAt: new Date().toISOString(),
|
||||
read: false,
|
||||
policyId: updatedPolicy.id,
|
||||
},
|
||||
...mockNotifications,
|
||||
];
|
||||
|
||||
return {
|
||||
success: true,
|
||||
transactionRef: `TXN${Date.now()}`,
|
||||
nextDueDate,
|
||||
};
|
||||
};
|
||||
|
||||
export const toggleInsurancePolicyAutoRenew = (policyId: string, enabled: boolean) => {
|
||||
const policyIndex = mockPolicies.findIndex((policy) => policy.id === policyId);
|
||||
|
||||
if (policyIndex < 0) {
|
||||
throw new Error('Policy not found.');
|
||||
}
|
||||
|
||||
const updatedPolicy = { ...mockPolicies[policyIndex]!, autoRenewEnabled: enabled };
|
||||
mockPolicies = mockPolicies.map((policy, index) =>
|
||||
index === policyIndex ? updatedPolicy : policy,
|
||||
);
|
||||
|
||||
return cloneValue(updatedPolicy);
|
||||
};
|
||||
|
||||
export const submitInsuranceClaim = (
|
||||
payload: SubmitInsuranceClaimPayload,
|
||||
): SubmitInsuranceClaimResult => {
|
||||
assertOtp(payload.otp);
|
||||
|
||||
const policy = mockPolicies.find((item) => item.id === payload.policyId);
|
||||
|
||||
if (!policy) {
|
||||
throw new Error('Choose a valid policy for the claim.');
|
||||
}
|
||||
|
||||
const claimDocuments = mockDocuments.filter((document) =>
|
||||
payload.documentIds.includes(document.id),
|
||||
);
|
||||
const claimId = `claim_${Date.now()}`;
|
||||
const claimNumber = `CLM-${new Date().getFullYear()}-${Math.floor(1000 + Math.random() * 9000)}`;
|
||||
const now = new Date().toISOString();
|
||||
const nextClaim: InsuranceClaim = {
|
||||
id: claimId,
|
||||
claimNumber,
|
||||
policyId: policy.id,
|
||||
policyNumber: policy.policyNumber,
|
||||
policyName: policy.planName,
|
||||
category: policy.category,
|
||||
reason: payload.reason,
|
||||
eventDate: payload.eventDate,
|
||||
amount: payload.amount,
|
||||
status: 'Submitted',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
notes: payload.description,
|
||||
documents: claimDocuments.map((document) => ({
|
||||
...document,
|
||||
linkedClaimId: claimId,
|
||||
})),
|
||||
};
|
||||
|
||||
mockClaims = [nextClaim, ...mockClaims];
|
||||
mockDocuments = mockDocuments.map((document) =>
|
||||
payload.documentIds.includes(document.id)
|
||||
? { ...document, linkedClaimId: claimId }
|
||||
: document,
|
||||
);
|
||||
mockNotifications = [
|
||||
{
|
||||
id: `ins_notif_${Date.now()}`,
|
||||
type: 'claim_update',
|
||||
title: 'Claim submitted',
|
||||
body: `${claimNumber} has been lodged and shared with ${policy.insurer.name}.`,
|
||||
createdAt: now,
|
||||
read: false,
|
||||
policyId: policy.id,
|
||||
claimId,
|
||||
},
|
||||
...mockNotifications,
|
||||
];
|
||||
|
||||
return {
|
||||
claim: cloneValue(nextClaim),
|
||||
referenceId: claimNumber,
|
||||
};
|
||||
};
|
||||
|
||||
export const uploadInsuranceDocument = (payload: UploadInsuranceDocumentPayload) => {
|
||||
if (payload.existingDocumentId) {
|
||||
mockDocuments = mockDocuments.filter((document) => document.id !== payload.existingDocumentId);
|
||||
}
|
||||
|
||||
const document: InsuranceDocument = {
|
||||
id: `ins_doc_${Date.now()}`,
|
||||
name: payload.name,
|
||||
type: payload.type,
|
||||
category: payload.category,
|
||||
status:
|
||||
payload.category === 'kyc' && (payload.type === 'Aadhaar' || payload.type === 'PAN')
|
||||
? 'verified'
|
||||
: 'uploaded',
|
||||
uploadedAt: new Date().toISOString(),
|
||||
mimeType: payload.mimeType,
|
||||
url: payload.uri,
|
||||
sizeKb: payload.sizeKb,
|
||||
linkedClaimId: payload.linkedClaimId,
|
||||
};
|
||||
|
||||
mockDocuments = [document, ...mockDocuments];
|
||||
|
||||
return cloneValue(document);
|
||||
};
|
||||
|
||||
export const markInsuranceNotificationRead = (notificationId: string) => {
|
||||
mockNotifications = mockNotifications.map((notification) =>
|
||||
notification.id === notificationId ? { ...notification, read: true } : notification,
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { RootState } from '../../../app/store';
|
||||
|
||||
export const selectInsuranceState = (state: RootState) => state.insurance;
|
||||
export const selectInsurancePlans = (state: RootState) => state.insurance.plans;
|
||||
export const selectInsuranceDraft = (state: RootState) => state.insurance.draft;
|
||||
export const selectInsurancePurchaseStep = (state: RootState) => state.insurance.purchaseStep;
|
||||
export const selectInsuranceCalculator = (state: RootState) => state.insurance.calculator;
|
||||
export const selectInsuranceCategory = (state: RootState) => state.insurance.draft.category;
|
||||
export const selectInsuranceComparedPlanIds = (state: RootState) =>
|
||||
state.insurance.draft.comparePlanIds;
|
||||
export const selectInsuranceSelectedPlanId = (state: RootState) =>
|
||||
state.insurance.draft.selectedPlanId;
|
||||
@@ -0,0 +1,160 @@
|
||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
import type { AsyncStatus } from '../../../types/app';
|
||||
import type {
|
||||
InsuranceAddressDetails,
|
||||
InsuranceCalculatorInput,
|
||||
InsuranceCategory,
|
||||
InsuranceKycDetails,
|
||||
InsuranceNomineeDetails,
|
||||
InsurancePaymentMethod,
|
||||
InsurancePersonalDetails,
|
||||
InsurancePlan,
|
||||
InsurancePurchaseDraft,
|
||||
InsurancePurchaseStep,
|
||||
} from '../types';
|
||||
import { seedInsurancePlans } from './insuranceThunk';
|
||||
|
||||
interface InsuranceState {
|
||||
status: AsyncStatus;
|
||||
plans: InsurancePlan[];
|
||||
purchaseStep: InsurancePurchaseStep;
|
||||
draft: InsurancePurchaseDraft;
|
||||
calculator: InsuranceCalculatorInput;
|
||||
}
|
||||
|
||||
const createInitialDraft = (
|
||||
category: InsuranceCategory = 'Health Insurance',
|
||||
): InsurancePurchaseDraft => ({
|
||||
category,
|
||||
personal: {
|
||||
fullName: 'Aarav Sharma',
|
||||
age: '31',
|
||||
gender: 'Male',
|
||||
email: 'aarav.sharma@bankcbs.dev',
|
||||
phone: '9876543210',
|
||||
},
|
||||
nominee: {
|
||||
name: 'Megha Sharma',
|
||||
relationship: 'Spouse',
|
||||
age: '29',
|
||||
},
|
||||
address: {
|
||||
line1: '18, 7th Main, Koramangala',
|
||||
city: 'Bengaluru',
|
||||
state: 'Karnataka',
|
||||
postalCode: '560095',
|
||||
},
|
||||
kyc: {
|
||||
aadhaarNumber: '123412341234',
|
||||
panNumber: 'ABCDE1234F',
|
||||
},
|
||||
specificDetails: {},
|
||||
selectedPlanId: null,
|
||||
paymentMethod: 'UPI',
|
||||
autoRenewEnabled: true,
|
||||
comparePlanIds: [],
|
||||
});
|
||||
|
||||
const initialState: InsuranceState = {
|
||||
status: 'idle',
|
||||
plans: [],
|
||||
purchaseStep: 'details',
|
||||
draft: createInitialDraft(),
|
||||
calculator: {
|
||||
category: 'Health Insurance',
|
||||
age: '31',
|
||||
coverageAmount: '1500000',
|
||||
tenureYears: '1',
|
||||
},
|
||||
};
|
||||
|
||||
const insuranceSlice = createSlice({
|
||||
name: 'insurance',
|
||||
initialState,
|
||||
reducers: {
|
||||
setInsuranceCategory(state, action: PayloadAction<InsuranceCategory>) {
|
||||
state.draft = createInitialDraft(action.payload);
|
||||
state.purchaseStep = 'details';
|
||||
state.calculator.category = action.payload;
|
||||
},
|
||||
setInsurancePurchaseStep(state, action: PayloadAction<InsurancePurchaseStep>) {
|
||||
state.purchaseStep = action.payload;
|
||||
},
|
||||
updateInsurancePersonal(
|
||||
state,
|
||||
action: PayloadAction<{ field: keyof InsurancePersonalDetails; value: string }>,
|
||||
) {
|
||||
state.draft.personal[action.payload.field] = action.payload.value as never;
|
||||
},
|
||||
updateInsuranceNominee(
|
||||
state,
|
||||
action: PayloadAction<{ field: keyof InsuranceNomineeDetails; value: string }>,
|
||||
) {
|
||||
state.draft.nominee[action.payload.field] = action.payload.value;
|
||||
},
|
||||
updateInsuranceAddress(
|
||||
state,
|
||||
action: PayloadAction<{ field: keyof InsuranceAddressDetails; value: string }>,
|
||||
) {
|
||||
state.draft.address[action.payload.field] = action.payload.value;
|
||||
},
|
||||
updateInsuranceKyc(
|
||||
state,
|
||||
action: PayloadAction<{ field: keyof InsuranceKycDetails; value: string }>,
|
||||
) {
|
||||
state.draft.kyc[action.payload.field] = action.payload.value;
|
||||
},
|
||||
updateInsuranceSpecificDetail(
|
||||
state,
|
||||
action: PayloadAction<{ key: string; value: string }>,
|
||||
) {
|
||||
state.draft.specificDetails[action.payload.key] = action.payload.value;
|
||||
},
|
||||
setInsuranceSelectedPlan(state, action: PayloadAction<string | null>) {
|
||||
state.draft.selectedPlanId = action.payload;
|
||||
},
|
||||
toggleInsuranceComparedPlan(state, action: PayloadAction<string>) {
|
||||
if (state.draft.comparePlanIds.includes(action.payload)) {
|
||||
state.draft.comparePlanIds = state.draft.comparePlanIds.filter(
|
||||
(id) => id !== action.payload,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
state.draft.comparePlanIds = [...state.draft.comparePlanIds, action.payload].slice(-3);
|
||||
},
|
||||
setInsurancePaymentMethod(state, action: PayloadAction<InsurancePaymentMethod>) {
|
||||
state.draft.paymentMethod = action.payload;
|
||||
},
|
||||
setInsuranceAutoRenew(state, action: PayloadAction<boolean>) {
|
||||
state.draft.autoRenewEnabled = action.payload;
|
||||
},
|
||||
resetInsuranceDraft(state) {
|
||||
state.purchaseStep = 'details';
|
||||
state.draft = createInitialDraft(state.draft.category);
|
||||
},
|
||||
updateInsuranceCalculator(
|
||||
state,
|
||||
action: PayloadAction<{ field: keyof InsuranceCalculatorInput; value: string }>,
|
||||
) {
|
||||
state.calculator[action.payload.field] = action.payload.value as never;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(seedInsurancePlans.pending, (state) => {
|
||||
state.status = 'loading';
|
||||
})
|
||||
.addCase(seedInsurancePlans.fulfilled, (state, action) => {
|
||||
state.status = 'succeeded';
|
||||
state.plans = action.payload;
|
||||
})
|
||||
.addCase(seedInsurancePlans.rejected, (state) => {
|
||||
state.status = 'failed';
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const insuranceActions = insuranceSlice.actions;
|
||||
export const insuranceReducer = insuranceSlice.reducer;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
|
||||
import { sleep } from '../../../utils/async';
|
||||
import type { InsurancePlan } from '../types';
|
||||
import { seedInsurancePlanCatalog } from '../services/insuranceApi';
|
||||
|
||||
export const seedInsurancePlans = createAsyncThunk<InsurancePlan[]>(
|
||||
'insurance/seedPlans',
|
||||
async () => {
|
||||
await sleep(140);
|
||||
return seedInsurancePlanCatalog();
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,515 @@
|
||||
export const INSURANCE_CATEGORIES = [
|
||||
'Health Insurance',
|
||||
'Life Insurance',
|
||||
'Motor Insurance',
|
||||
'Travel Insurance',
|
||||
'Property Insurance',
|
||||
] as const;
|
||||
|
||||
export const INSURANCE_PAYMENT_METHODS = ['UPI', 'Net Banking', 'Card'] as const;
|
||||
export const INSURANCE_PREMIUM_FREQUENCIES = ['Monthly', 'Quarterly', 'Yearly'] as const;
|
||||
export const INSURANCE_POLICY_STATUSES = ['Active', 'Renewal Due', 'Expired', 'Grace Period'] as const;
|
||||
export const INSURANCE_CLAIM_STATUSES = [
|
||||
'Submitted',
|
||||
'Under Review',
|
||||
'Approved',
|
||||
'Rejected',
|
||||
'Settled',
|
||||
] as const;
|
||||
export const INSURANCE_DOCUMENT_STATUSES = ['uploaded', 'verified', 'rejected'] as const;
|
||||
|
||||
export type InsuranceCategory = (typeof INSURANCE_CATEGORIES)[number];
|
||||
export type InsurancePaymentMethod = (typeof INSURANCE_PAYMENT_METHODS)[number];
|
||||
export type InsurancePremiumFrequency = (typeof INSURANCE_PREMIUM_FREQUENCIES)[number];
|
||||
export type InsurancePolicyStatus = (typeof INSURANCE_POLICY_STATUSES)[number];
|
||||
export type InsuranceClaimStatus = (typeof INSURANCE_CLAIM_STATUSES)[number];
|
||||
export type InsuranceDocumentStatus = (typeof INSURANCE_DOCUMENT_STATUSES)[number];
|
||||
export type InsuranceNotificationType =
|
||||
| 'premium_due'
|
||||
| 'policy_expiry'
|
||||
| 'claim_update'
|
||||
| 'offer';
|
||||
export type InsuranceDocumentCategory = 'kyc' | 'claim';
|
||||
export type InsuranceDocumentType =
|
||||
| 'Aadhaar'
|
||||
| 'PAN'
|
||||
| 'Passport'
|
||||
| 'Driving License'
|
||||
| 'RC Book'
|
||||
| 'Medical Report'
|
||||
| 'Travel Ticket'
|
||||
| 'Property Proof'
|
||||
| 'Invoice'
|
||||
| 'Photo'
|
||||
| 'Other';
|
||||
export type InsuranceGender = 'Male' | 'Female' | 'Other';
|
||||
export type InsurancePurchaseStep = 'details' | 'plans' | 'payment';
|
||||
|
||||
export interface InsuranceDynamicField {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
keyboardType?: 'default' | 'numeric' | 'email-address';
|
||||
required?: boolean;
|
||||
multiline?: boolean;
|
||||
}
|
||||
|
||||
export interface InsuranceCategoryMeta {
|
||||
title: InsuranceCategory;
|
||||
icon: string;
|
||||
accentColor: string;
|
||||
description: string;
|
||||
specificFields: InsuranceDynamicField[];
|
||||
}
|
||||
|
||||
export const INSURANCE_CATEGORY_META: Record<InsuranceCategory, InsuranceCategoryMeta> = {
|
||||
'Health Insurance': {
|
||||
title: 'Health Insurance',
|
||||
icon: 'medkit-outline',
|
||||
accentColor: '#10B981',
|
||||
description: 'Cashless hospitalization, wellness support, and family protection.',
|
||||
specificFields: [
|
||||
{
|
||||
key: 'familyMembers',
|
||||
label: 'Family members covered',
|
||||
placeholder: 'Enter number of members',
|
||||
keyboardType: 'numeric',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'medicalHistory',
|
||||
label: 'Medical history',
|
||||
placeholder: 'Declare existing conditions or enter None',
|
||||
required: true,
|
||||
multiline: true,
|
||||
},
|
||||
{
|
||||
key: 'smoker',
|
||||
label: 'Smoker status',
|
||||
placeholder: 'Yes or No',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
'Life Insurance': {
|
||||
title: 'Life Insurance',
|
||||
icon: 'heart-circle-outline',
|
||||
accentColor: '#F59E0B',
|
||||
description: 'Long-term income protection with nominee-first policy servicing.',
|
||||
specificFields: [
|
||||
{
|
||||
key: 'annualIncome',
|
||||
label: 'Annual income',
|
||||
placeholder: 'Enter annual income',
|
||||
keyboardType: 'numeric',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'occupation',
|
||||
label: 'Occupation',
|
||||
placeholder: 'Enter occupation',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'smoker',
|
||||
label: 'Smoker status',
|
||||
placeholder: 'Yes or No',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
'Motor Insurance': {
|
||||
title: 'Motor Insurance',
|
||||
icon: 'car-sport-outline',
|
||||
accentColor: '#3B82F6',
|
||||
description: 'Comprehensive protection for car and bike ownership journeys.',
|
||||
specificFields: [
|
||||
{
|
||||
key: 'vehicleType',
|
||||
label: 'Vehicle type',
|
||||
placeholder: 'Car or Bike',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'makeModel',
|
||||
label: 'Vehicle make and model',
|
||||
placeholder: 'Enter make and model',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'registrationNumber',
|
||||
label: 'Registration number',
|
||||
placeholder: 'Enter registration number',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'registrationYear',
|
||||
label: 'Registration year',
|
||||
placeholder: 'Enter registration year',
|
||||
keyboardType: 'numeric',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'previousClaims',
|
||||
label: 'Previous claims',
|
||||
placeholder: 'Enter count of claims in last 3 years',
|
||||
keyboardType: 'numeric',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
'Travel Insurance': {
|
||||
title: 'Travel Insurance',
|
||||
icon: 'airplane-outline',
|
||||
accentColor: '#8B5CF6',
|
||||
description: 'Trip protection for medical emergencies, delays, and baggage loss.',
|
||||
specificFields: [
|
||||
{
|
||||
key: 'destination',
|
||||
label: 'Destination',
|
||||
placeholder: 'Enter destination country or region',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'departureDate',
|
||||
label: 'Departure date',
|
||||
placeholder: 'YYYY-MM-DD',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'returnDate',
|
||||
label: 'Return date',
|
||||
placeholder: 'YYYY-MM-DD',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'travellers',
|
||||
label: 'Number of travellers',
|
||||
placeholder: 'Enter number of travellers',
|
||||
keyboardType: 'numeric',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'passportNumber',
|
||||
label: 'Passport number',
|
||||
placeholder: 'Enter passport number',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
'Property Insurance': {
|
||||
title: 'Property Insurance',
|
||||
icon: 'home-outline',
|
||||
accentColor: '#EF4444',
|
||||
description: 'Cover for home structure, contents, and landlord liability.',
|
||||
specificFields: [
|
||||
{
|
||||
key: 'propertyType',
|
||||
label: 'Property type',
|
||||
placeholder: 'Apartment, Villa, Office, etc.',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'propertyValue',
|
||||
label: 'Property value',
|
||||
placeholder: 'Enter insured property value',
|
||||
keyboardType: 'numeric',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'constructionYear',
|
||||
label: 'Construction year',
|
||||
placeholder: 'Enter construction year',
|
||||
keyboardType: 'numeric',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'propertyAddress',
|
||||
label: 'Property address',
|
||||
placeholder: 'Enter complete insured address',
|
||||
required: true,
|
||||
multiline: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export interface InsuranceInsurer {
|
||||
id: string;
|
||||
name: string;
|
||||
claimSettlementRatio: number;
|
||||
supportPhone: string;
|
||||
supportEmail: string;
|
||||
website: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
export interface InsurancePlan {
|
||||
id: string;
|
||||
category: InsuranceCategory;
|
||||
insurerId: string;
|
||||
insurerName: string;
|
||||
name: string;
|
||||
premium: number;
|
||||
premiumFrequency: InsurancePremiumFrequency;
|
||||
coverAmount: number;
|
||||
tenureYears: number;
|
||||
summary: string;
|
||||
benefits: string[];
|
||||
inclusions: string[];
|
||||
exclusions: string[];
|
||||
claimSettlementRatio: number;
|
||||
waitingPeriodDays?: number;
|
||||
deductibleAmount?: number;
|
||||
recommendedFor?: string;
|
||||
addOns?: string[];
|
||||
}
|
||||
|
||||
export interface InsurancePolicyDocument {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
uploadedAt: string;
|
||||
url: string;
|
||||
sizeKb: number;
|
||||
}
|
||||
|
||||
export interface InsurancePolicy {
|
||||
id: string;
|
||||
planId: string;
|
||||
planName: string;
|
||||
category: InsuranceCategory;
|
||||
insurer: InsuranceInsurer;
|
||||
policyNumber: string;
|
||||
insuredName: string;
|
||||
premiumAmount: number;
|
||||
premiumFrequency: InsurancePremiumFrequency;
|
||||
coverageAmount: number;
|
||||
status: InsurancePolicyStatus;
|
||||
startDate: string;
|
||||
expiryDate: string;
|
||||
nextPremiumDueDate: string;
|
||||
benefits: string[];
|
||||
inclusions: string[];
|
||||
exclusions: string[];
|
||||
nomineeName: string;
|
||||
nomineeRelationship: string;
|
||||
autoRenewEnabled: boolean;
|
||||
membersCovered: number;
|
||||
deductibleAmount?: number;
|
||||
vehicleDetails?: {
|
||||
vehicleType: string;
|
||||
makeModel: string;
|
||||
registrationNumber: string;
|
||||
registrationYear: string;
|
||||
};
|
||||
healthDetails?: {
|
||||
familyMembers: number;
|
||||
medicalHistory: string;
|
||||
smoker: string;
|
||||
};
|
||||
lifeDetails?: {
|
||||
annualIncome: number;
|
||||
occupation: string;
|
||||
smoker: string;
|
||||
};
|
||||
travelDetails?: {
|
||||
destination: string;
|
||||
departureDate: string;
|
||||
returnDate: string;
|
||||
travellers: number;
|
||||
passportNumber: string;
|
||||
};
|
||||
propertyDetails?: {
|
||||
propertyType: string;
|
||||
propertyValue: number;
|
||||
constructionYear: string;
|
||||
propertyAddress: string;
|
||||
};
|
||||
documents: InsurancePolicyDocument[];
|
||||
}
|
||||
|
||||
export interface InsurancePremiumDue {
|
||||
id: string;
|
||||
policyId: string;
|
||||
planName: string;
|
||||
policyNumber: string;
|
||||
amount: number;
|
||||
dueDate: string;
|
||||
frequency: InsurancePremiumFrequency;
|
||||
lateFee: number;
|
||||
autoRenewEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface InsuranceDocument {
|
||||
id: string;
|
||||
name: string;
|
||||
type: InsuranceDocumentType;
|
||||
category: InsuranceDocumentCategory;
|
||||
status: InsuranceDocumentStatus;
|
||||
uploadedAt: string;
|
||||
mimeType: string;
|
||||
url: string;
|
||||
sizeKb: number;
|
||||
linkedClaimId?: string;
|
||||
rejectionReason?: string;
|
||||
}
|
||||
|
||||
export interface InsuranceClaim {
|
||||
id: string;
|
||||
claimNumber: string;
|
||||
policyId: string;
|
||||
policyNumber: string;
|
||||
policyName: string;
|
||||
category: InsuranceCategory;
|
||||
reason: string;
|
||||
eventDate: string;
|
||||
amount: number;
|
||||
status: InsuranceClaimStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
notes: string;
|
||||
documents: InsuranceDocument[];
|
||||
}
|
||||
|
||||
export interface InsuranceNotification {
|
||||
id: string;
|
||||
type: InsuranceNotificationType;
|
||||
title: string;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
read: boolean;
|
||||
policyId?: string;
|
||||
claimId?: string;
|
||||
}
|
||||
|
||||
export interface InsuranceDashboardPayload {
|
||||
policies: InsurancePolicy[];
|
||||
totalCoverageAmount: number;
|
||||
activePoliciesCount: number;
|
||||
duePremiums: InsurancePremiumDue[];
|
||||
upcomingPremium: InsurancePremiumDue | null;
|
||||
recentClaims: InsuranceClaim[];
|
||||
notifications: InsuranceNotification[];
|
||||
}
|
||||
|
||||
export interface InsurancePersonalDetails {
|
||||
fullName: string;
|
||||
age: string;
|
||||
gender: InsuranceGender | '';
|
||||
email: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export interface InsuranceNomineeDetails {
|
||||
name: string;
|
||||
relationship: string;
|
||||
age: string;
|
||||
}
|
||||
|
||||
export interface InsuranceAddressDetails {
|
||||
line1: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postalCode: string;
|
||||
}
|
||||
|
||||
export interface InsuranceKycDetails {
|
||||
aadhaarNumber: string;
|
||||
panNumber: string;
|
||||
}
|
||||
|
||||
export interface InsurancePurchaseDraft {
|
||||
category: InsuranceCategory;
|
||||
personal: InsurancePersonalDetails;
|
||||
nominee: InsuranceNomineeDetails;
|
||||
address: InsuranceAddressDetails;
|
||||
kyc: InsuranceKycDetails;
|
||||
specificDetails: Record<string, string>;
|
||||
selectedPlanId: string | null;
|
||||
paymentMethod: InsurancePaymentMethod;
|
||||
autoRenewEnabled: boolean;
|
||||
comparePlanIds: string[];
|
||||
}
|
||||
|
||||
export interface InsuranceClaimDraft {
|
||||
policyId: string;
|
||||
reason: string;
|
||||
eventDate: string;
|
||||
amount: string;
|
||||
description: string;
|
||||
documentIds: string[];
|
||||
}
|
||||
|
||||
export interface InsuranceCalculatorInput {
|
||||
category: InsuranceCategory;
|
||||
age: string;
|
||||
coverageAmount: string;
|
||||
tenureYears: string;
|
||||
}
|
||||
|
||||
export interface InsuranceCalculatorEstimate {
|
||||
monthlyPremium: number;
|
||||
annualPremium: number;
|
||||
recommendedCover: number;
|
||||
}
|
||||
|
||||
export interface PurchaseInsurancePayload {
|
||||
draft: InsurancePurchaseDraft;
|
||||
otp: string;
|
||||
}
|
||||
|
||||
export interface PurchaseInsuranceResult {
|
||||
policy: InsurancePolicy;
|
||||
transactionRef: string;
|
||||
}
|
||||
|
||||
export interface PayInsurancePremiumPayload {
|
||||
policyId: string;
|
||||
amount: number;
|
||||
method: InsurancePaymentMethod;
|
||||
otp: string;
|
||||
}
|
||||
|
||||
export interface PayInsurancePremiumResult {
|
||||
success: boolean;
|
||||
transactionRef: string;
|
||||
nextDueDate: string;
|
||||
}
|
||||
|
||||
export interface SubmitInsuranceClaimPayload {
|
||||
policyId: string;
|
||||
reason: string;
|
||||
eventDate: string;
|
||||
amount: number;
|
||||
description: string;
|
||||
documentIds: string[];
|
||||
otp: string;
|
||||
}
|
||||
|
||||
export interface SubmitInsuranceClaimResult {
|
||||
claim: InsuranceClaim;
|
||||
referenceId: string;
|
||||
}
|
||||
|
||||
export interface UploadInsuranceDocumentPayload {
|
||||
name: string;
|
||||
type: InsuranceDocumentType;
|
||||
category: InsuranceDocumentCategory;
|
||||
mimeType: string;
|
||||
uri: string;
|
||||
sizeKb: number;
|
||||
linkedClaimId?: string;
|
||||
existingDocumentId?: string;
|
||||
}
|
||||
|
||||
export interface RequestInsuranceOtpPayload {
|
||||
action: 'purchase' | 'claim' | 'payment';
|
||||
policyId?: string;
|
||||
}
|
||||
|
||||
export interface InsuranceOtpSession {
|
||||
referenceId: string;
|
||||
destinationMask: string;
|
||||
mockCodeHint?: string;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type {
|
||||
InsuranceCalculatorEstimate,
|
||||
InsuranceCalculatorInput,
|
||||
InsuranceCategory,
|
||||
InsurancePolicy,
|
||||
InsurancePremiumDue,
|
||||
InsurancePremiumFrequency,
|
||||
} from '../types';
|
||||
|
||||
const CATEGORY_RATE_MAP: Record<InsuranceCategory, number> = {
|
||||
'Health Insurance': 0.024,
|
||||
'Life Insurance': 0.006,
|
||||
'Motor Insurance': 0.031,
|
||||
'Travel Insurance': 0.012,
|
||||
'Property Insurance': 0.01,
|
||||
};
|
||||
|
||||
const CATEGORY_RECOMMENDED_MULTIPLIER: Record<InsuranceCategory, number> = {
|
||||
'Health Insurance': 6,
|
||||
'Life Insurance': 150,
|
||||
'Motor Insurance': 1.2,
|
||||
'Travel Insurance': 4,
|
||||
'Property Insurance': 1,
|
||||
};
|
||||
|
||||
const DAY_IN_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export const addMonths = (value: string, months: number) => {
|
||||
const date = new Date(value);
|
||||
const next = new Date(date);
|
||||
next.setMonth(date.getMonth() + months);
|
||||
return next.toISOString();
|
||||
};
|
||||
|
||||
export const addYears = (value: string, years: number) => {
|
||||
const date = new Date(value);
|
||||
const next = new Date(date);
|
||||
next.setFullYear(date.getFullYear() + years);
|
||||
return next.toISOString();
|
||||
};
|
||||
|
||||
export const getNextDateForFrequency = (
|
||||
value: string,
|
||||
frequency: InsurancePremiumFrequency,
|
||||
) => {
|
||||
if (frequency === 'Monthly') {
|
||||
return addMonths(value, 1);
|
||||
}
|
||||
|
||||
if (frequency === 'Quarterly') {
|
||||
return addMonths(value, 3);
|
||||
}
|
||||
|
||||
return addYears(value, 1);
|
||||
};
|
||||
|
||||
export const getRecommendedCover = (
|
||||
category: InsuranceCategory,
|
||||
baseAmount: number,
|
||||
) => Math.round(baseAmount * CATEGORY_RECOMMENDED_MULTIPLIER[category]);
|
||||
|
||||
export const estimateInsurancePremium = (
|
||||
input: InsuranceCalculatorInput,
|
||||
): InsuranceCalculatorEstimate => {
|
||||
const age = Number(input.age || 0);
|
||||
const coverageAmount = Number(input.coverageAmount || 0);
|
||||
const tenureYears = Number(input.tenureYears || 0);
|
||||
|
||||
const normalizedAge = age > 0 ? age : 28;
|
||||
const normalizedCoverage = coverageAmount > 0 ? coverageAmount : 500000;
|
||||
const normalizedTenure = tenureYears > 0 ? tenureYears : 1;
|
||||
|
||||
const baseRate = CATEGORY_RATE_MAP[input.category];
|
||||
const ageFactor =
|
||||
normalizedAge < 30 ? 0.92 : normalizedAge < 40 ? 1 : normalizedAge < 50 ? 1.18 : 1.36;
|
||||
const coverageFactor =
|
||||
normalizedCoverage >= 10000000 ? 0.95 : normalizedCoverage >= 5000000 ? 0.98 : 1;
|
||||
const tenureFactor = normalizedTenure >= 5 ? 0.92 : normalizedTenure >= 3 ? 0.96 : 1;
|
||||
|
||||
const annualPremium = Math.round(
|
||||
normalizedCoverage * baseRate * ageFactor * coverageFactor * tenureFactor,
|
||||
);
|
||||
|
||||
return {
|
||||
monthlyPremium: Math.max(Math.round(annualPremium / 12), 1),
|
||||
annualPremium,
|
||||
recommendedCover: getRecommendedCover(input.category, normalizedCoverage),
|
||||
};
|
||||
};
|
||||
|
||||
export const estimateAnnualPremium = (monthlyPremium: number) => monthlyPremium * 12;
|
||||
|
||||
export const getPolicyTotalCoverage = (policies: InsurancePolicy[]) =>
|
||||
policies.reduce((sum, policy) => sum + policy.coverageAmount, 0);
|
||||
|
||||
export const getEarliestPremiumDue = (dues: InsurancePremiumDue[]) =>
|
||||
[...dues].sort(
|
||||
(left, right) => new Date(left.dueDate).getTime() - new Date(right.dueDate).getTime(),
|
||||
)[0] ?? null;
|
||||
|
||||
export const getDaysUntil = (value: string) =>
|
||||
Math.max(Math.ceil((new Date(value).getTime() - Date.now()) / DAY_IN_MS), 0);
|
||||
|
||||
export const getPremiumLateFee = (dueDate: string, amount: number) => {
|
||||
const daysLate = Math.floor((Date.now() - new Date(dueDate).getTime()) / DAY_IN_MS);
|
||||
|
||||
if (daysLate <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(Math.round(amount * 0.015), 2000);
|
||||
};
|
||||
|
||||
export const getPolicyStatusFromDueDate = (
|
||||
nextPremiumDueDate: string,
|
||||
expiryDate: string,
|
||||
) => {
|
||||
const dueAt = new Date(nextPremiumDueDate).getTime();
|
||||
const expiryAt = new Date(expiryDate).getTime();
|
||||
const now = Date.now();
|
||||
|
||||
if (expiryAt < now) {
|
||||
return 'Expired' as const;
|
||||
}
|
||||
|
||||
if (dueAt < now) {
|
||||
return 'Grace Period' as const;
|
||||
}
|
||||
|
||||
if (dueAt - now <= 15 * DAY_IN_MS) {
|
||||
return 'Renewal Due' as const;
|
||||
}
|
||||
|
||||
return 'Active' as const;
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
import { isValidOtp, isValidPhone } from '../../../utils/validation';
|
||||
import type {
|
||||
InsuranceCategory,
|
||||
InsuranceClaimDraft,
|
||||
InsuranceDocumentType,
|
||||
InsurancePurchaseDraft,
|
||||
} from '../types';
|
||||
import { INSURANCE_CATEGORY_META } from '../types';
|
||||
|
||||
const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
|
||||
const isValidPan = (value: string) =>
|
||||
/^[A-Z]{5}[0-9]{4}[A-Z]$/.test(value.trim().toUpperCase());
|
||||
const isValidAadhaar = (value: string) => /^[0-9]{12}$/.test(value.replace(/\s/g, ''));
|
||||
const isValidDateInput = (value: string) =>
|
||||
/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(value) && !Number.isNaN(new Date(value).getTime());
|
||||
|
||||
export type InsuranceValidationErrors = Record<string, string>;
|
||||
|
||||
export const validateInsurancePurchaseDraft = (
|
||||
draft: InsurancePurchaseDraft,
|
||||
category: InsuranceCategory,
|
||||
): InsuranceValidationErrors => {
|
||||
const errors: InsuranceValidationErrors = {};
|
||||
const categoryMeta = INSURANCE_CATEGORY_META[category];
|
||||
|
||||
if (!draft.personal.fullName.trim()) {
|
||||
errors.personalFullName = 'Full name is required.';
|
||||
}
|
||||
|
||||
if (!draft.personal.age.trim() || Number(draft.personal.age) < 18) {
|
||||
errors.personalAge = 'Applicant must be at least 18 years old.';
|
||||
}
|
||||
|
||||
if (!draft.personal.gender) {
|
||||
errors.personalGender = 'Gender is required.';
|
||||
}
|
||||
|
||||
if (!isValidEmail(draft.personal.email)) {
|
||||
errors.personalEmail = 'Enter a valid email address.';
|
||||
}
|
||||
|
||||
if (!isValidPhone(draft.personal.phone)) {
|
||||
errors.personalPhone = 'Enter a valid 10-digit mobile number.';
|
||||
}
|
||||
|
||||
if (!draft.nominee.name.trim()) {
|
||||
errors.nomineeName = 'Nominee name is required.';
|
||||
}
|
||||
|
||||
if (!draft.nominee.relationship.trim()) {
|
||||
errors.nomineeRelationship = 'Nominee relationship is required.';
|
||||
}
|
||||
|
||||
if (!draft.nominee.age.trim()) {
|
||||
errors.nomineeAge = 'Nominee age is required.';
|
||||
}
|
||||
|
||||
if (!draft.address.line1.trim()) {
|
||||
errors.addressLine1 = 'Address is required.';
|
||||
}
|
||||
|
||||
if (!draft.address.city.trim()) {
|
||||
errors.addressCity = 'City is required.';
|
||||
}
|
||||
|
||||
if (!draft.address.state.trim()) {
|
||||
errors.addressState = 'State is required.';
|
||||
}
|
||||
|
||||
if (!/^[0-9]{6}$/.test(draft.address.postalCode.trim())) {
|
||||
errors.addressPostalCode = 'Enter a valid 6-digit PIN code.';
|
||||
}
|
||||
|
||||
if (!isValidAadhaar(draft.kyc.aadhaarNumber)) {
|
||||
errors.kycAadhaar = 'Enter a valid 12-digit Aadhaar number.';
|
||||
}
|
||||
|
||||
if (!isValidPan(draft.kyc.panNumber)) {
|
||||
errors.kycPan = 'Enter a valid PAN number.';
|
||||
}
|
||||
|
||||
categoryMeta.specificFields.forEach((field) => {
|
||||
const value = draft.specificDetails[field.key]?.trim() ?? '';
|
||||
|
||||
if (field.required && !value) {
|
||||
errors[`specific_${field.key}`] = `${field.label} is required.`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (field.keyboardType === 'numeric' && Number(value) <= 0) {
|
||||
errors[`specific_${field.key}`] = `Enter a valid ${field.label.toLowerCase()}.`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (field.key.toLowerCase().includes('date') && !isValidDateInput(value)) {
|
||||
errors[`specific_${field.key}`] = `Use YYYY-MM-DD for ${field.label.toLowerCase()}.`;
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
export const validateInsuranceClaimDraft = (
|
||||
draft: InsuranceClaimDraft,
|
||||
): InsuranceValidationErrors => {
|
||||
const errors: InsuranceValidationErrors = {};
|
||||
|
||||
if (!draft.policyId) {
|
||||
errors.policyId = 'Choose a policy to continue.';
|
||||
}
|
||||
|
||||
if (!draft.reason.trim()) {
|
||||
errors.reason = 'Claim reason is required.';
|
||||
}
|
||||
|
||||
if (!isValidDateInput(draft.eventDate)) {
|
||||
errors.eventDate = 'Use YYYY-MM-DD for event date.';
|
||||
}
|
||||
|
||||
if (!draft.amount.trim() || Number(draft.amount) <= 0) {
|
||||
errors.amount = 'Enter a valid claim amount.';
|
||||
}
|
||||
|
||||
if (!draft.description.trim()) {
|
||||
errors.description = 'Brief claim details are required.';
|
||||
}
|
||||
|
||||
if (!draft.documentIds.length) {
|
||||
errors.documents = 'Upload at least one supporting document.';
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
export const validateInsuranceOtp = (otp: string) =>
|
||||
isValidOtp(otp) ? undefined : 'Enter the 6-digit OTP.';
|
||||
|
||||
export const validateInsuranceDocumentUpload = (
|
||||
name: string,
|
||||
sizeKb: number,
|
||||
type: InsuranceDocumentType,
|
||||
) => {
|
||||
if (!name.trim()) {
|
||||
return 'Document name is required.';
|
||||
}
|
||||
|
||||
if (sizeKb <= 0 || sizeKb > 10240) {
|
||||
return 'File size should be between 1 KB and 10 MB.';
|
||||
}
|
||||
|
||||
if (!type) {
|
||||
return 'Document type is required.';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
Reference in New Issue
Block a user