feat(app): improve design

This commit is contained in:
uttam 2026-07-24 13:15:31 +05:30
parent db1dc98300
commit e96ea0a490
29 changed files with 964 additions and 518 deletions

View File

@ -3,54 +3,70 @@ import { ThemeColors } from '../../theme';
export const getStyles = (colors: ThemeColors) =>
StyleSheet.create({
headerCard: {
card: {
backgroundColor: colors.card,
borderRadius: 16,
padding: 20,
alignItems: 'center',
borderRadius: 14,
padding: 14,
marginBottom: 16,
borderWidth: 1,
borderColor: colors.border,
shadowColor: '#0F172A',
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.08,
shadowRadius: 8,
elevation: 3,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
avatarCircle: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: colors.icon,
justifyContent: 'center',
topRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
},
avatarCircle: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.icon,
justifyContent: 'center',
alignItems: 'center',
marginRight: 10,
flexShrink: 0,
},
avatarInitial: {
color: '#FFFFFF',
fontSize: 26,
fontSize: 16,
fontWeight: '700',
},
companyName: {
fontSize: 20,
fontWeight: '800',
info: {
flex: 1,
marginRight: 8,
},
name: {
fontSize: 15,
fontWeight: '700',
color: colors.text,
textAlign: 'center',
marginBottom: 1,
},
fullName: {
fontSize: 14,
company: {
fontSize: 12,
color: colors.textSecondary,
marginTop: 4,
textAlign: 'center',
marginBottom: 2,
},
badgeRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 10,
gap: 8,
contact: {
fontSize: 11,
color: colors.textMuted,
lineHeight: 16,
},
badge: {
paddingHorizontal: 10,
dateText: {
fontSize: 11,
color: colors.textMuted,
marginTop: 2,
},
statusBadge: {
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 20,
flexShrink: 0,
alignSelf: 'flex-start',
},
activeBadge: {
backgroundColor: '#D1FAE5',
@ -58,34 +74,31 @@ export const getStyles = (colors: ThemeColors) =>
inactiveBadge: {
backgroundColor: '#FEE2E2',
},
badgeText: {
fontSize: 12,
statusText: {
fontSize: 10,
fontWeight: '700',
},
activeBadgeText: {
activeText: {
color: '#059669',
},
inactiveBadgeText: {
inactiveText: {
color: '#DC2626',
},
dateText: {
fontSize: 12,
color: colors.textMuted,
},
actionRow: {
flexDirection: 'row',
gap: 10,
marginTop: 16,
width: '100%',
borderTopWidth: 1,
borderTopColor: colors.border,
paddingTop: 10,
},
actionBtn: {
flex: 1,
flexDirection: 'row',
height: 40,
borderRadius: 10,
height: 36,
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center',
gap: 6,
gap: 5,
borderWidth: 1,
},
primaryBtn: {
@ -98,11 +111,11 @@ export const getStyles = (colors: ThemeColors) =>
fontSize: 13,
},
secondaryBtn: {
backgroundColor: colors.card,
backgroundColor: colors.background,
borderColor: colors.border,
},
secondaryBtnText: {
color: colors.text,
color: colors.textSecondary,
fontWeight: '600',
fontSize: 13,
},

View File

@ -25,45 +25,53 @@ export const CustomerDetailHeader: React.FC<CustomerDetailHeaderProps> = ({
const initials = getInitials(displayName);
return (
<View style={styles.headerCard}>
<View style={styles.card}>
{/* Top Row: Avatar + Info + Active Badge */}
<View style={styles.topRow}>
<View style={styles.avatarCircle}>
<Text style={styles.avatarInitial}>{initials}</Text>
</View>
<Text style={styles.companyName}>{displayName}</Text>
<View style={styles.info}>
<Text style={styles.name} numberOfLines={1}>{displayName}</Text>
{fullname && company ? (
<Text style={styles.fullName}>{fullname}</Text>
<Text style={styles.company} numberOfLines={1}>{fullname}</Text>
) : null}
<View style={styles.badgeRow}>
<View
style={[
styles.badge,
active === '1' ? styles.activeBadge : styles.inactiveBadge,
]}>
<Text
style={[
styles.badgeText,
active === '1' ? styles.activeBadgeText : styles.inactiveBadgeText,
]}>
{active === '1' ? 'Active Customer' : 'Inactive'}
<Text style={styles.contact} numberOfLines={1}>
<Icon name="call-outline" size={10} color={colors.textMuted} />
{' '}{phonenumber || 'N/A'}{' '}
<Icon name="mail-outline" size={10} color={colors.textMuted} />
{' '}{email || 'N/A'}
</Text>
</View>
{datecreated ? (
<Text style={styles.dateText}>
<Text style={styles.dateText} numberOfLines={1}>
Added {formatDate(datecreated)}
</Text>
) : null}
</View>
{/* Quick Actions */}
<View
style={[
styles.statusBadge,
active === '1' ? styles.activeBadge : styles.inactiveBadge,
]}>
<Text
style={[
styles.statusText,
active === '1' ? styles.activeText : styles.inactiveText,
]}>
{active === '1' ? 'Active' : 'Inactive'}
</Text>
</View>
</View>
{/* Bottom Row: Action Buttons */}
<View style={styles.actionRow}>
{phonenumber ? (
<TouchableOpacity
style={[styles.actionBtn, styles.primaryBtn]}
onPress={onPhonePress}>
<Icon name="call" size={16} color="#FFFFFF" />
<Icon name="call" size={14} color="#FFFFFF" />
<Text style={styles.primaryBtnText}>Call</Text>
</TouchableOpacity>
) : null}
@ -72,7 +80,7 @@ export const CustomerDetailHeader: React.FC<CustomerDetailHeaderProps> = ({
<TouchableOpacity
style={[styles.actionBtn, styles.secondaryBtn]}
onPress={onEmailPress}>
<Icon name="mail" size={16} color={colors.text} />
<Icon name="mail" size={14} color={colors.textSecondary} />
<Text style={styles.secondaryBtnText}>Email</Text>
</TouchableOpacity>
) : null}
@ -81,7 +89,7 @@ export const CustomerDetailHeader: React.FC<CustomerDetailHeaderProps> = ({
<TouchableOpacity
style={[styles.actionBtn, styles.secondaryBtn]}
onPress={onWebsitePress}>
<Icon name="globe" size={16} color={colors.text} />
<Icon name="globe" size={14} color={colors.textSecondary} />
<Text style={styles.secondaryBtnText}>Website</Text>
</TouchableOpacity>
) : null}

View File

@ -9,6 +9,8 @@ export interface FormInputProps extends Omit<TextInputProps, 'style'> {
multiline?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
prefix?: string;
prefixStyle?: StyleProp<TextStyle>;
containerStyle?: StyleProp<ViewStyle>;
inputContainerStyle?: StyleProp<ViewStyle>;
inputStyle?: StyleProp<TextStyle>;

View File

@ -32,6 +32,11 @@ export const getStyles = (colors: ThemeColors) =>
justifyContent: 'center',
alignItems: 'center',
},
prefix: {
fontSize: 15,
color: colors.textSecondary,
marginRight: 4,
},
rightIconWrapper: {
marginLeft: 8,
justifyContent: 'center',

View File

@ -14,6 +14,8 @@ export const FormInput: React.FC<FormInputProps> = ({
keyboardType = 'default',
leftIcon,
rightIcon,
prefix,
prefixStyle,
containerStyle,
inputContainerStyle,
inputStyle,
@ -32,6 +34,9 @@ export const FormInput: React.FC<FormInputProps> = ({
) : null}
<View style={[styles.inputWrapper, inputContainerStyle]}>
{leftIcon ? <View style={styles.leftIconWrapper}>{leftIcon}</View> : null}
{prefix ? (
<Text style={[styles.prefix, prefixStyle]}>{prefix}</Text>
) : null}
<TextInput
style={[styles.input, multiline && styles.multilineInput, inputStyle]}
value={value}

View File

@ -5,96 +5,99 @@ export const getStyles = (colors: ThemeColors) =>
StyleSheet.create({
card: {
backgroundColor: colors.card,
borderRadius: 20,
padding: 20,
alignItems: 'center',
borderRadius: 14,
padding: 14,
marginBottom: 16,
borderWidth: 1,
borderColor: colors.border,
shadowColor: '#5B4CF5',
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.08,
shadowRadius: 16,
elevation: 4,
shadowColor: '#0F172A',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
avatar: {
width: 76,
height: 76,
borderRadius: 38,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 14,
},
avatarText: {
fontSize: 26,
fontWeight: '800',
color: '#FFFFFF',
letterSpacing: 0.5,
},
name: {
fontSize: 20,
fontWeight: '700',
color: colors.text,
textAlign: 'center',
marginBottom: 4,
},
companyRow: {
topRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
marginBottom: 12,
},
avatar: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
marginRight: 10,
flexShrink: 0,
},
avatarText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '700',
},
info: {
flex: 1,
marginRight: 8,
},
name: {
fontSize: 15,
fontWeight: '700',
color: colors.text,
marginBottom: 1,
},
company: {
fontSize: 13,
fontSize: 12,
color: colors.textSecondary,
fontWeight: '500',
marginBottom: 2,
},
contact: {
fontSize: 11,
color: colors.textMuted,
lineHeight: 16,
},
statusBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: 12,
paddingVertical: 5,
gap: 4,
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 20,
marginBottom: 16,
flexShrink: 0,
},
statusDot: {
width: 6,
height: 6,
borderRadius: 3,
width: 5,
height: 5,
borderRadius: 2.5,
},
statusText: {
fontSize: 11,
fontSize: 10,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.5,
letterSpacing: 0.3,
},
quickActions: {
actionRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 12,
width: '100%',
paddingTop: 14,
borderTopWidth: StyleSheet.hairlineWidth,
gap: 10,
borderTopWidth: 1,
borderTopColor: colors.border,
paddingTop: 10,
},
actionBtn: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
height: 36,
borderRadius: 8,
justifyContent: 'center',
gap: 6,
paddingVertical: 10,
borderRadius: 12,
alignItems: 'center',
gap: 5,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surface,
},
actionBtnText: {
fontSize: 12,
secondaryBtn: {
backgroundColor: colors.background,
borderColor: colors.border,
},
btnText: {
fontSize: 13,
fontWeight: '600',
color: colors.text,
},
});

View File

@ -26,20 +26,24 @@ export const LeadDetailHeader: React.FC<LeadDetailHeaderProps> = ({
return (
<View style={styles.card}>
{/* Top Row: Avatar + Info + Status */}
<View style={styles.topRow}>
<View style={[styles.avatar, { backgroundColor: accent }]}>
<Text style={styles.avatarText}>{getInitials(name)}</Text>
</View>
<Text style={styles.name} numberOfLines={1}>
{name || 'No Name'}
<View style={styles.info}>
<Text style={styles.name} numberOfLines={1}>{name || 'No Name'}</Text>
{company ? (
<Text style={styles.company} numberOfLines={1}>{company}</Text>
) : null}
<Text style={styles.contact} numberOfLines={1}>
<Icon name="call-outline" size={10} color={colors.textMuted} />
{' '}{phonenumber || 'N/A'}{' '}
<Icon name="mail-outline" size={10} color={colors.textMuted} />
{' '}{email || 'N/A'}
</Text>
{company && (
<View style={styles.companyRow}>
<Icon name="business-outline" size={13} color={colors.textMuted} />
<Text style={styles.company}>{company}</Text>
</View>
)}
<TouchableOpacity
onPress={onStatusPress}
@ -52,27 +56,22 @@ export const LeadDetailHeader: React.FC<LeadDetailHeaderProps> = ({
</Text>
</View>
</TouchableOpacity>
</View>
<View style={styles.quickActions}>
{email && (
{/* Bottom Row: Call + Email Buttons */}
<View style={styles.actionRow}>
<TouchableOpacity
style={styles.actionBtn}
onPress={onEmailPress}
activeOpacity={0.7}>
<Icon name="mail-outline" size={16} color={colors.icon} />
<Text style={styles.actionBtnText}>Email</Text>
style={[styles.actionBtn, { backgroundColor: `${accent}12`, borderColor: `${accent}30` }]}
onPress={onPhonePress}>
<Icon name="call" size={14} color={accent} />
<Text style={[styles.btnText, { color: accent }]}>Call</Text>
</TouchableOpacity>
)}
{phonenumber && (
<TouchableOpacity
style={styles.actionBtn}
onPress={onPhonePress}
activeOpacity={0.7}>
<Icon name="call-outline" size={16} color={colors.icon} />
<Text style={styles.actionBtnText}>Call</Text>
style={[styles.actionBtn, styles.secondaryBtn]}
onPress={onEmailPress}>
<Icon name="mail" size={14} color={colors.textSecondary} />
<Text style={[styles.btnText, { color: colors.textSecondary }]}>Email</Text>
</TouchableOpacity>
)}
</View>
</View>
);

View File

@ -3,174 +3,109 @@ import { ThemeColors } from '../../theme';
export const getStyles = (colors: ThemeColors) =>
StyleSheet.create({
cardWrapper: {
marginBottom: 16,
borderRadius: 20,
shadowColor: '#5B4CF5',
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.10,
shadowRadius: 18,
elevation: 6,
},
card: {
customerCard: {
backgroundColor: colors.card,
borderRadius: 20,
flexDirection: 'row',
overflow: 'hidden',
borderWidth: 1,
borderColor: colors.border,
},
leftStrip: {
width: 4,
borderTopLeftRadius: 20,
borderBottomLeftRadius: 20,
},
cardInner: {
flex: 1,
padding: 15,
paddingLeft: 14,
},
topSection: {
borderRadius: 14,
padding: 16,
marginBottom: 12,
shadowColor: '#0F172A',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
cardHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
borderBottomWidth: 1,
borderBottomColor: colors.border,
paddingBottom: 12,
marginBottom: 12,
},
avatar: {
width: 48,
height: 48,
borderRadius: 24,
cardHeaderRight: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
cardHeaderLeft: {
flexDirection: 'row',
alignItems: 'flex-start',
flex: 1,
marginRight: 10,
},
avatarCircle: {
width: 42,
height: 42,
borderRadius: 21,
backgroundColor: colors.icon,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
marginRight: 10,
},
avatarText: {
fontSize: 16,
fontWeight: '800',
avatarInitial: {
color: '#FFFFFF',
letterSpacing: 0.5,
fontSize: 16,
fontWeight: '700',
},
meta: {
cardInfo: {
flex: 1,
minWidth: 0,
},
name: {
customerName: {
fontSize: 15,
fontWeight: '700',
color: colors.text,
marginBottom: 3,
letterSpacing: 0.05,
},
companyRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
gap: 5,
flexWrap: 'wrap',
},
companyText: {
contactPerson: {
fontSize: 12,
color: colors.textSecondary,
fontWeight: '500',
flexShrink: 1,
marginTop: 2,
},
titleText: {
fontSize: 11,
location: {
fontSize: 12,
color: colors.textMuted,
flexShrink: 1,
marginTop: 3,
},
dot: {
width: 3,
height: 3,
borderRadius: 2,
backgroundColor: colors.textMuted,
opacity: 0.6,
},
statusBadge: {
alignSelf: 'flex-start',
flexDirection: 'row',
alignItems: 'center',
gap: 5,
badge: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 20,
backgroundColor: `${colors.icon}15`,
},
statusDot: {
width: 5,
height: 5,
borderRadius: 3,
},
statusText: {
fontSize: 10,
badgeText: {
fontSize: 11,
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: 0.7,
color: colors.icon,
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.border,
marginVertical: 11,
cardFooter: {
flexDirection: 'row',
gap: 10,
},
contacts: {
gap: 8,
},
contactRow: {
actionButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 9,
},
iconWrap: {
width: 28,
height: 28,
borderRadius: 9,
justifyContent: 'center',
alignItems: 'center',
},
contactText: {
fontSize: 12,
color: colors.textSecondary,
paddingVertical: 10,
borderRadius: 10,
flex: 1,
gap: 6,
},
actionBtn: {
width: 28,
height: 28,
borderRadius: 9,
justifyContent: 'center',
alignItems: 'center',
callButton: {
backgroundColor: `${colors.icon}10`,
},
footer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 12,
paddingTop: 10,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.border,
},
sourcePill: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.surface,
borderRadius: 20,
paddingHorizontal: 9,
paddingVertical: 4,
gap: 5,
emailButton: {
backgroundColor: colors.background,
borderWidth: 1,
borderColor: colors.border,
},
sourceText: {
fontSize: 10,
color: colors.textMuted,
callButtonText: {
fontSize: 13,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.5,
color: colors.icon,
},
dateRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
dateText: {
fontSize: 10,
color: colors.textMuted,
emailButtonText: {
fontSize: 13,
fontWeight: '600',
color: colors.textSecondary,
},
});

View File

@ -4,109 +4,81 @@ import Icon from 'react-native-vector-icons/Ionicons';
import { useTheme } from '@theme';
import { getStyles } from './leadItemCard.styles';
import { LeadItemCardProps } from './leadItemCard.props';
import { getInitials, handleEmailPress, handlePhonePress, toRgba, formatDate } from '@utils';
import { getInitials, handleEmailPress, handlePhonePress, formatDate } from '@utils';
export const LeadItemCard: React.FC<LeadItemCardProps> = ({ item, onPress }) => {
const { theme: colors, isDark } = useTheme();
const { theme: colors } = useTheme();
const styles = getStyles(colors);
const accent = item.color?.startsWith('#') ? item.color : '#5B4CF5';
const tint = (a: number) => toRgba(accent, isDark ? a + 0.08 : a);
const displayName = item.name || 'No Name';
const initials = getInitials(displayName);
const accent = item.color?.startsWith('#') ? item.color : colors.icon;
return (
<TouchableOpacity style={styles.cardWrapper} onPress={onPress} activeOpacity={0.8}>
<View style={styles.card}>
<View style={[styles.leftStrip, { backgroundColor: accent }]} />
<View style={styles.cardInner}>
<View style={styles.topSection}>
<View style={[styles.avatar, { backgroundColor: accent }]}>
<Text style={styles.avatarText}>{getInitials(item.name)}</Text>
<TouchableOpacity
activeOpacity={onPress ? 0.8 : 1}
onPress={onPress}
style={styles.customerCard}>
<View style={styles.cardHeader}>
<View style={styles.cardHeaderLeft}>
<View style={[styles.avatarCircle, { backgroundColor: accent }]}>
<Text style={styles.avatarInitial}>{initials}</Text>
</View>
<View style={styles.meta}>
<Text style={styles.name} numberOfLines={1}>
{item.name || 'No Name'}
<View style={styles.cardInfo}>
<Text style={styles.customerName} numberOfLines={1}>
{displayName}
</Text>
{(item.company || item.title) && (
<View style={styles.companyRow}>
{item.company && (
{item.company ? (
<Text style={styles.contactPerson} numberOfLines={1}>
{item.company}
</Text>
) : null}
<Text style={styles.location} numberOfLines={1}>
<Icon name="call-outline" size={11} color={colors.textMuted} />
{' '}{item.phonenumber || 'N/A'}{' • '}
<Icon name="mail-outline" size={11} color={colors.textMuted} />
{' '}{item.email || 'N/A'}
</Text>
{item.source_name || item.source || item.dateadded ? (
<Text style={styles.location} numberOfLines={1}>
{item.source_name || item.source ? (
<>
<Icon name="business-outline" size={11} color={colors.textMuted} />
<Text style={styles.companyText} numberOfLines={1}>{item.company}</Text>
<Icon name="radio-outline" size={11} color={colors.textMuted} />
{' '}{item.source_name || item.source}
</>
)}
{item.company && item.title && <View style={styles.dot} />}
{item.title && (
<Text style={styles.titleText} numberOfLines={1}>{item.title}</Text>
)}
) : null}
{item.dateadded ? `${formatDate(item.dateadded)}` : ''}
</Text>
) : null}
</View>
</View>
)}
<View style={[styles.statusBadge, { backgroundColor: tint(0.10) }]}>
<View style={[styles.statusDot, { backgroundColor: accent }]} />
<Text style={[styles.statusText, { color: accent }]}>
{item.status_name || item.status || 'No Status'}
<View style={styles.cardHeaderRight}>
{item.status_name || item.status ? (
<View style={[styles.badge, { backgroundColor: `${accent}15` }]}>
<Text style={[styles.badgeText, { color: accent }]}>
{item.status_name || item.status}
</Text>
</View>
) : null}
</View>
</View>
{(item.email || item.phonenumber) && (
<>
<View style={styles.separator} />
<View style={styles.contacts}>
{item.email && (
<View style={styles.contactRow}>
<View style={[styles.iconWrap, { backgroundColor: tint(0.08) }]}>
<Icon name="mail-outline" size={13} color={accent} />
</View>
<Text style={styles.contactText} numberOfLines={1}>{item.email}</Text>
<View style={styles.cardFooter}>
<TouchableOpacity
style={[styles.actionBtn, { backgroundColor: tint(0.08) }]}
onPress={() => handleEmailPress(item.email)}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Icon name="send-outline" size={12} color={accent} />
style={[styles.actionButton, styles.callButton, { backgroundColor: `${accent}10` }]}
onPress={() => handlePhonePress(item.phonenumber)}>
<Icon name="call" size={15} color={accent} />
<Text style={[styles.callButtonText, { color: accent }]}>Call</Text>
</TouchableOpacity>
</View>
)}
{item.phonenumber && (
<View style={styles.contactRow}>
<View style={[styles.iconWrap, { backgroundColor: tint(0.08) }]}>
<Icon name="call-outline" size={13} color={accent} />
</View>
<Text style={styles.contactText} numberOfLines={1}>{item.phonenumber}</Text>
<TouchableOpacity
style={[styles.actionBtn, { backgroundColor: tint(0.08) }]}
onPress={() => handlePhonePress(item.phonenumber)}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Icon name="call-outline" size={12} color={accent} />
style={[styles.actionButton, styles.emailButton]}
onPress={() => handleEmailPress(item.email)}>
<Icon name="mail" size={15} color={colors.textSecondary} />
<Text style={styles.emailButtonText}>Email</Text>
</TouchableOpacity>
</View>
)}
</View>
</>
)}
<View style={styles.footer}>
{(item.source_name || item.source) ? (
<View style={styles.sourcePill}>
<Icon name="radio-outline" size={10} color={colors.textMuted} />
<Text style={styles.sourceText}>{item.source_name || item.source}</Text>
</View>
) : <View />}
{item.dateadded && (
<View style={styles.dateRow}>
<Icon name="time-outline" size={10} color={colors.textMuted} />
<Text style={styles.dateText}>{formatDate(item.dateadded)}</Text>
</View>
)}
</View>
</View>
</View>
</TouchableOpacity>
);
};

View File

@ -16,5 +16,6 @@ export * from './tasks';
export * from './tickets';
export * from './customerDetails';
export * from './addCustomer';
export * from './notification';

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import {
Text,
View,
@ -9,6 +9,7 @@ import {
ImageBackground,
ActivityIndicator,
StatusBar,
Keyboard,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import Icon from 'react-native-vector-icons/Ionicons';
@ -33,6 +34,7 @@ export const LoginScreen = () => {
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [focusedField, setFocusedField] = useState<string | null>(null);
const scrollViewRef = useRef<ScrollView>(null);
const [localError, setLocalError] = useState('');
const [deviceToken, setDeviceToken] = useState<string | null>(null);
@ -68,11 +70,13 @@ export const LoginScreen = () => {
password,
device_token: fcmToken ?? '',
};
const cleanTenancy = tenancy.trim().replace(/^(https?:\/\/)?/, '');
const fullTenancy = `https://${cleanTenancy}`;
console.log('payload-', payload)
console.log('tenancy-', config.BASE_URL)
if (tenancy == config.BASE_URL) {
if (fullTenancy === config.BASE_URL) {
try {
await AsyncStorage.setItem('base_url', tenancy);
await AsyncStorage.setItem('base_url', fullTenancy);
await AsyncStorage.setItem('email', email);
await AsyncStorage.setItem('password', password);
} catch (e) {
@ -91,7 +95,10 @@ export const LoginScreen = () => {
const savedEmail = await AsyncStorage.getItem('email');
const savedPassword = await AsyncStorage.getItem('password');
if (savedBaseUrl) setTenancy(savedBaseUrl);
if (savedBaseUrl) {
const cleanBaseUrl = savedBaseUrl.replace(/^(https?:\/\/)?/, '');
setTenancy(cleanBaseUrl);
}
if (savedEmail) setEmail(savedEmail);
if (savedPassword) setPassword(savedPassword);
} catch (e) {
@ -123,6 +130,8 @@ export const LoginScreen = () => {
index: 0,
routes: [{ name: 'DrawerStack' }],
});
// Show permission popup after the user has landed in the app
setTimeout(() => NotificationService.requestPermission(), 1000);
}
}, [loginSuccess, token, user_data, navigation]);
@ -139,14 +148,17 @@ export const LoginScreen = () => {
/>
<View style={styles.overlay}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.container}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 20}
>
<ScrollView
ref={scrollViewRef}
contentContainerStyle={styles.scrollContainer}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
bounces={false}
keyboardDismissMode="none"
>
{/* Top Area Spacer displaying background illustration */}
<View style={styles.topSpacer} />
@ -173,7 +185,11 @@ export const LoginScreen = () => {
<FormInput
label="Tenancy Name"
value={tenancy}
onChangeText={setTenancy}
onChangeText={(text) => {
const cleaned = text.replace(/^(https?:\/\/)?/, '');
setTenancy(cleaned);
}}
prefix="https://"
placeholder="company-name"
autoCapitalize="none"
autoCorrect={false}
@ -182,7 +198,10 @@ export const LoginScreen = () => {
styles.inputWrapper,
focusedField === 'tenancy' && styles.inputWrapperFocused,
]}
onFocus={() => setFocusedField('tenancy')}
onFocus={() => {
setFocusedField('tenancy');
setTimeout(() => scrollViewRef.current?.scrollToEnd({ animated: true }), 100);
}}
onBlur={() => setFocusedField(null)}
leftIcon={
<Icon
@ -211,7 +230,10 @@ export const LoginScreen = () => {
styles.inputWrapper,
focusedField === 'email' && styles.inputWrapperFocused,
]}
onFocus={() => setFocusedField('email')}
onFocus={() => {
setFocusedField('email');
setTimeout(() => scrollViewRef.current?.scrollToEnd({ animated: true }), 100);
}}
onBlur={() => setFocusedField(null)}
leftIcon={
<Icon
@ -240,7 +262,10 @@ export const LoginScreen = () => {
styles.inputWrapper,
focusedField === 'password' && styles.inputWrapperFocused,
]}
onFocus={() => setFocusedField('password')}
onFocus={() => {
setFocusedField('password');
setTimeout(() => scrollViewRef.current?.scrollToEnd({ animated: true }), 100);
}}
onBlur={() => setFocusedField(null)}
leftIcon={
<Icon

View File

@ -19,10 +19,9 @@ export const getStyles = (colors: ThemeColors, isDark: boolean) =>
},
scrollContainer: {
flexGrow: 1,
justifyContent: 'space-between',
justifyContent: 'flex-end',
},
topSpacer: {
flex: 1,
minHeight: 120,
},
bottomSheetCard: {

View File

@ -0,0 +1 @@
export * from './notification.screen';

View File

@ -0,0 +1,105 @@
import React, { useLayoutEffect, useState } from 'react';
import {
View,
Text,
FlatList,
TouchableOpacity,
StatusBar,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import Icon from 'react-native-vector-icons/Ionicons';
import { useTheme } from '../../theme';
import { getStyles } from './notification.styles';
import { INITIAL_NOTIFICATIONS } from '@mock-data';
export const NotificationScreen = () => {
const navigation = useNavigation();
const { theme: colors } = useTheme();
const styles = getStyles(colors);
const [notifications, setNotifications] = useState(INITIAL_NOTIFICATIONS);
// Mark all as read header button action
const handleMarkAllRead = () => {
setNotifications(prev =>
prev.map(item => ({ ...item, unread: false }))
);
};
useLayoutEffect(() => {
navigation.setOptions({
headerTitle: 'Notifications',
headerRight: () => {
const hasUnread = notifications.some(n => n.unread);
if (!hasUnread) return null;
return (
<TouchableOpacity
style={styles.headerRightBtn}
onPress={handleMarkAllRead}
activeOpacity={0.7}
>
<Text style={styles.clearAllText}>Read All</Text>
</TouchableOpacity>
);
},
});
}, [navigation, notifications, styles]);
const toggleSingleRead = (id: string) => {
setNotifications(prev =>
prev.map(item =>
item.id === id ? { ...item, unread: !item.unread } : item
)
);
};
const renderItem = ({ item }: { item: typeof INITIAL_NOTIFICATIONS[0] }) => {
return (
<TouchableOpacity
style={[styles.card, item.unread && styles.unreadCard]}
activeOpacity={0.7}
onPress={() => toggleSingleRead(item.id)}
>
{/* Left Side Styled Icon Circle */}
<View style={[styles.iconWrapper, { backgroundColor: `${item.iconColor}12` }]}>
<Icon name={item.iconName} size={18} color={item.iconColor} />
</View>
{/* Content Section */}
<View style={styles.contentContainer}>
<View style={styles.titleRow}>
<Text style={styles.title} numberOfLines={1}>
{item.title}
</Text>
{item.unread && <View style={styles.unreadDot} />}
</View>
<Text style={styles.description}>{item.description}</Text>
<Text style={styles.time}>{item.time}</Text>
</View>
</TouchableOpacity>
);
};
return (
<View style={styles.container}>
{/* <StatusBar barStyle="light-content" /> */}
{notifications.length > 0 ? (
<FlatList
data={notifications}
keyExtractor={item => item.id}
renderItem={renderItem}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
/>
) : (
<View style={styles.emptyContainer}>
<Icon name="notifications-off-outline" size={48} color={colors.textMuted} />
<Text style={styles.emptyTitle}>All caught up!</Text>
<Text style={styles.emptySubtitle}>
When you get new updates or alerts, they will show up here.
</Text>
</View>
)}
</View>
);
};

View File

@ -0,0 +1,94 @@
import { StyleSheet } from 'react-native';
import { ThemeColors } from '../../theme';
export const getStyles = (colors: ThemeColors) =>
StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
headerRightBtn: {
paddingHorizontal: 12,
paddingVertical: 6,
},
clearAllText: {
fontSize: 14,
fontWeight: '600',
color: colors.icon,
},
listContent: {
paddingVertical: 8,
},
card: {
backgroundColor: colors.surface,
paddingVertical: 14,
paddingHorizontal: 16,
borderBottomWidth: 1,
borderBottomColor: colors.border,
flexDirection: 'row',
alignItems: 'flex-start',
},
unreadCard: {
backgroundColor: `${colors.icon}06`,
},
iconWrapper: {
width: 36,
height: 36,
borderRadius: 18,
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
marginTop: 2,
},
contentContainer: {
flex: 1,
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 3,
},
title: {
fontSize: 14,
fontWeight: '600',
color: colors.text,
flex: 1,
},
unreadDot: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: '#EF4444',
marginLeft: 6,
},
description: {
fontSize: 13,
color: colors.textSecondary,
lineHeight: 18,
marginBottom: 6,
},
time: {
fontSize: 11,
color: colors.textMuted,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 32,
paddingTop: 100,
},
emptyTitle: {
fontSize: 16,
fontWeight: '600',
color: colors.text,
marginTop: 16,
marginBottom: 6,
},
emptySubtitle: {
fontSize: 13,
color: colors.textMuted,
textAlign: 'center',
lineHeight: 18,
},
});

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import {
Text,
View,
@ -19,6 +19,7 @@ export const ProfileScreen = () => {
const { theme: colors, isDark, toggleTheme } = useTheme();
const styles = getStyles(colors);
const dispatch = useAppDispatch();
const [pushEnabled, setPushEnabled] = useState(true);
const userData = useAppSelector((state: RootState) => state.auth.user_data);
@ -114,7 +115,7 @@ export const ProfileScreen = () => {
<Text style={styles.sectionHeader}>Personal Details</Text>
<ProfileInfoRow
icon="person-outline"
label="Full Name"
label="Name"
value={fullName}
/>
<ProfileInfoRow
@ -124,17 +125,17 @@ export const ProfileScreen = () => {
/>
<ProfileInfoRow
icon="mail-outline"
label="Email Address"
label="Email"
value={email}
/>
<ProfileInfoRow
icon="call-outline"
label="Phone Number"
label="Phone"
value={phone}
/>
<ProfileInfoRow
icon="shield-checkmark-outline"
label="Account Role"
label="Role"
value={roleText}
isLast
/>
@ -227,7 +228,7 @@ export const ProfileScreen = () => {
/>
</View>
<TouchableOpacity style={styles.preferenceRow} activeOpacity={0.7}>
{/* <TouchableOpacity style={styles.preferenceRow} activeOpacity={0.7}>
<View style={styles.preferenceLabelGroup}>
<View style={styles.iconWrapper}>
<Icon
@ -239,23 +240,22 @@ export const ProfileScreen = () => {
<Text style={styles.preferenceText}>Push Notifications</Text>
</View>
<Icon name="chevron-forward" size={18} color={colors.textMuted} />
</TouchableOpacity>
</TouchableOpacity> */}
<TouchableOpacity
style={[styles.preferenceRow, styles.noBorder]}
activeOpacity={0.7}>
<View style={[styles.preferenceRow, styles.noBorder]}>
<View style={styles.preferenceLabelGroup}>
<View style={styles.iconWrapper}>
<Icon
name="shield-checkmark-outline"
size={18}
color={colors.icon}
<Icon name="notifications-outline" size={18} color={colors.icon} />
</View>
<Text style={styles.preferenceText}>Push Notifications</Text>
</View>
<Switch
value={pushEnabled}
onValueChange={setPushEnabled}
trackColor={{ false: '#767577', true: colors.icon }}
thumbColor={pushEnabled ? '#ffffff' : '#f4f3f4'}
/>
</View>
<Text style={styles.preferenceText}>Security &amp; Privacy</Text>
</View>
<Icon name="chevron-forward" size={18} color={colors.textMuted} />
</TouchableOpacity>
</View>
{/* ── Sign Out ── */}

View File

@ -2,11 +2,13 @@ import { route } from "../utils/route";
export const menuItems = [
{ label: 'Dashboard', route: 'home', icon: 'grid-outline' },
{ label: 'Leads', route: route.leads, icon: 'funnel-outline' },
{ label: 'Add Lead', route: route.addLead, icon: 'person-add-outline' },
{ label: 'Customers', route: route.customers, icon: 'business-outline' },
{ label: 'Proposals', route: route.proposals, icon: 'document-text-outline' },
{ label: 'Estimates', route: route.estimates, icon: 'calculator-outline' },
{ label: 'Invoices', route: route.invoices, icon: 'card-outline' },
{ label: 'Projects', route: route.projects, icon: 'rocket-outline' },
{ label: 'Tasks', route: route.tasks, icon: 'checkbox-outline' },
{ label: 'Tickets', route: route.tickets, icon: 'bug-outline' },
// { label: 'Proposals', route: route.proposals, icon: 'document-text-outline' },
// { label: 'Estimates', route: route.estimates, icon: 'calculator-outline' },
// { label: 'Invoices', route: route.invoices, icon: 'card-outline' },
// { label: 'Projects', route: route.projects, icon: 'rocket-outline' },
// { label: 'Tasks', route: route.tasks, icon: 'checkbox-outline' },
// { label: 'Tickets', route: route.tickets, icon: 'bug-outline' },
];

View File

@ -8,3 +8,4 @@ export * from './projects';
export * from './proposal';
export * from './tasks';
export * from './tickets';
export * from './notification'

View File

@ -0,0 +1,48 @@
// Premium Mock Notification Data
export const INITIAL_NOTIFICATIONS = [
{
id: '1',
title: 'New Lead Assigned',
description: 'A new high-priority lead "Acme Corp Ltd" has been successfully assigned to you.',
time: '2 hours ago',
unread: true,
iconName: 'person-add-outline',
iconColor: '#3B82F6',
},
{
id: '2',
title: 'Invoice Payment Received',
description: 'Payment of $1,250.00 for Invoice #INV-1042 was received from Sarah Jenkins.',
time: '5 hours ago',
unread: true,
iconName: 'cash-outline',
iconColor: '#10B981',
},
{
id: '3',
title: 'Proposal View Alert',
description: 'Workspace admin viewed your custom proposal "Website Redesign Quote".',
time: '1 day ago',
unread: false,
iconName: 'eye-outline',
iconColor: '#8B5CF6',
},
{
id: '4',
title: 'Support Ticket #TCK-402',
description: 'Ticket resolved: "Cannot sign in to tenant workspace". Click to view details.',
time: '2 days ago',
unread: false,
iconName: 'alert-circle-outline',
iconColor: '#EF4444',
},
{
id: '5',
title: 'Task Deadline Nearing',
description: 'Your project task "Perform complete audit" is due in 3 hours.',
time: '3 days ago',
unread: false,
iconName: 'time-outline',
iconColor: '#F59E0B',
},
];

View File

@ -124,14 +124,37 @@ export const CustomDrawerContent = (props: DrawerContentComponentProps) => {
<ScrollView contentContainerStyle={styles.scrollContent}>
<View style={styles.menuContainer}>
{menuItems.map(item => {
const isFocused = activeRouteName === item.route;
// Find currently active nested tab route
let activeTabName = '';
try {
let currentRoute = state.routes[state.index];
if (currentRoute.name === 'home' && currentRoute.state) {
const nestedIndex = currentRoute.state.index ?? 0;
activeTabName = currentRoute.state.routes[nestedIndex].name;
} else {
activeTabName = currentRoute.name;
}
} catch (e) {
activeTabName = '';
}
const isFocused =
(item.route === 'home' && activeTabName === route.dashboard) ||
(item.route !== 'home' && activeTabName === item.route);
return (
<DrawerItem
key={item.route}
label={item.label}
iconName={item.icon}
focused={isFocused}
onPress={() => navigation.navigate(item.route)}
onPress={() => {
if (item.route === 'home') {
navigation.navigate('home', { screen: route.dashboard });
} else {
navigation.navigate('home', { screen: item.route });
}
}}
/>
);
})}

View File

@ -0,0 +1,16 @@
import { Platform, StyleSheet } from "react-native";
export const getStyles = (colors: any) =>
StyleSheet.create({
drawerButton: {
width: 38,
height: 38,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
marginLeft: Platform.OS === 'ios' ? 0 : 0,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
},
});

View File

@ -1,10 +1,12 @@
import React from 'react';
import { TouchableOpacity } from 'react-native';
import { TouchableOpacity, StyleSheet, Platform } from 'react-native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { DrawerNavigationProp } from '@react-navigation/drawer';
import Icon from 'react-native-vector-icons/Ionicons';
import { CustomersScreen, CustomerDetailsScreen, AddCustomerScreen } from '@features';
import { route, RouteParams } from '@utils';
import { useTheme } from '@theme';
import { getStyles } from './customersStack.styles';
export type CustomersStackParamList = Pick<
RouteParams,
@ -15,6 +17,7 @@ const Stack = createNativeStackNavigator<CustomersStackParamList>();
export const CustomersStack = () => {
const { theme: colors } = useTheme();
const styles = getStyles(colors);
return (
<Stack.Navigator
@ -36,6 +39,21 @@ export const CustomersStack = () => {
component={CustomersScreen}
options={({ navigation }) => ({
headerTitle: 'Customers',
headerLeft: () => (
<TouchableOpacity
style={styles.drawerButton}
activeOpacity={0.7}
onPress={() => {
const parentNav = navigation.getParent<DrawerNavigationProp<any>>();
if (parentNav) {
parentNav.openDrawer();
} else {
(navigation as any).openDrawer?.();
}
}}>
<Icon name="menu-outline" size={20} color={colors.text} />
</TouchableOpacity>
),
headerRight: () => (
<TouchableOpacity
onPress={() => navigation.navigate(route.addCustomer)}

View File

@ -1,29 +1,12 @@
import React from 'react';
import { createDrawerNavigator } from '@react-navigation/drawer';
import { TabStack } from './tabStack';
import { CustomersStack } from './customersStack';
import { CustomDrawerContent } from './customDrawerContent';
import {
ProposalsScreen,
EstimatesScreen,
InvoicesScreen,
ProjectsScreen,
TasksScreen,
TicketsScreen,
} from '@features';
import { route, RouteParams } from '@utils';
import { useTheme } from '@theme';
export type DrawerStackParamList = { home: undefined } & Pick<
RouteParams,
| 'customers'
| 'proposals'
| 'estimates'
| 'invoices'
| 'projects'
| 'tasks'
| 'tickets'
>;
export type DrawerStackParamList = {
home: undefined;
};
const Drawer = createDrawerNavigator<DrawerStackParamList>();
@ -53,17 +36,6 @@ export const DrawerStack = () => {
component={TabStack}
options={{ headerShown: false }}
/>
<Drawer.Screen
name={route.customers}
component={CustomersStack}
options={{ headerShown: false }}
/>
<Drawer.Screen name={route.proposals} component={ProposalsScreen} />
<Drawer.Screen name={route.estimates} component={EstimatesScreen} />
<Drawer.Screen name={route.invoices} component={InvoicesScreen} />
<Drawer.Screen name={route.projects} component={ProjectsScreen} />
<Drawer.Screen name={route.tasks} component={TasksScreen} />
<Drawer.Screen name={route.tickets} component={TicketsScreen} />
</Drawer.Navigator>
);
};

View File

@ -0,0 +1,16 @@
import { Platform, StyleSheet } from "react-native";
export const getStyles = (colors: any) =>
StyleSheet.create({
drawerButton: {
width: 38,
height: 38,
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
marginLeft: Platform.OS === 'ios' ? 0 : 0,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
},
});

View File

@ -1,5 +1,8 @@
import React from 'react';
import { TouchableOpacity, StyleSheet, Platform } from 'react-native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { DrawerNavigationProp } from '@react-navigation/drawer';
import Icon from 'react-native-vector-icons/Ionicons';
import {
LeadsScreen,
LeadDetailsScreen,
@ -9,6 +12,7 @@ import {
} from '@features';
import { route, RouteParams } from '@utils';
import { useTheme } from '@theme';
import { getStyles } from './leadsStack.styles';
export type LeadsStackParamList = Pick<
RouteParams,
@ -19,6 +23,7 @@ const Stack = createNativeStackNavigator<LeadsStackParamList>();
export const LeadsStack = () => {
const { theme: colors } = useTheme();
const styles = getStyles(colors);
return (
<Stack.Navigator
@ -38,7 +43,24 @@ export const LeadsStack = () => {
<Stack.Screen
name={route.leadsList}
component={LeadsScreen}
options={{ headerTitle: 'My Leads' }}
options={({ navigation }) => ({
headerTitle: 'My Leads',
headerLeft: () => (
<TouchableOpacity
style={styles.drawerButton}
activeOpacity={0.7}
onPress={() => {
const parentNav = navigation.getParent<DrawerNavigationProp<any>>();
if (parentNav) {
parentNav.openDrawer();
} else {
(navigation as any).openDrawer?.();
}
}}>
<Icon name="menu-outline" size={20} color={colors.text} />
</TouchableOpacity>
),
})}
/>
<Stack.Screen
name={route.leadDetails}

View File

@ -68,9 +68,48 @@ export const getStyles = (colors: ThemeColors) =>
borderRadius: 12,
justifyContent: 'center',
alignItems: 'center',
marginLeft: 14,
marginLeft: 0,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
},
notificationButton: {
width: 36,
height: 36,
borderRadius: 18,
justifyContent: 'center',
alignItems: 'center',
marginRight: 8,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.border,
position: 'relative',
// Subtle premium shadow
shadowColor: '#000000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.08,
shadowRadius: 2,
elevation: 2,
},
badgeContainer: {
position: 'absolute',
top: -2,
right: -2,
backgroundColor: '#EF4444',
minWidth: 15,
height: 15,
borderRadius: 7.5,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 2,
borderWidth: 1.5,
borderColor: colors.surface,
},
badgeText: {
color: '#FFFFFF',
fontSize: 7.5,
fontWeight: '800',
textAlign: 'center',
lineHeight: 11,
},
});

View File

@ -1,6 +1,7 @@
import React from 'react';
import { TouchableOpacity, View } from 'react-native';
import { TouchableOpacity, View, Text } from 'react-native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { DrawerNavigationProp } from '@react-navigation/drawer';
import Icon from 'react-native-vector-icons/Ionicons';
import { route, RouteParams } from '@utils';
@ -8,10 +9,12 @@ import {
DashboardScreen,
AddLeadScreen,
ProfileScreen,
NotificationScreen,
} from '@features';
import { LeadsStack } from './leadsStack';
import { CustomersStack } from './customersStack';
import { useTheme } from '@theme';
import { useAppSelector, RootState } from '@store';
import { getStyles } from './tabStack.styles';
export type TabStackParamList = Pick<
@ -21,6 +24,169 @@ export type TabStackParamList = Pick<
const Tab = createBottomTabNavigator<TabStackParamList>();
const DashboardStackNav = createNativeStackNavigator();
const DashboardStack = () => {
const { theme: colors } = useTheme();
const styles = getStyles(colors);
const userData = useAppSelector((state: RootState) => state.auth.user_data);
const unreadCount = parseInt(userData?.total_unread_notifications ?? '0', 10);
return (
<DashboardStackNav.Navigator
screenOptions={{
headerStyle: {
backgroundColor: colors.header,
},
headerTitleStyle: {
fontSize: 17,
fontWeight: '700',
color: colors.text,
},
headerTitleAlign: 'center',
headerTintColor: colors.text,
headerShadowVisible: false,
}}>
<DashboardStackNav.Screen
name="dashboardList"
component={DashboardScreen}
options={({ navigation }) => ({
headerTitle: 'Convex CRM',
headerLeft: () => (
<TouchableOpacity
style={styles.drawerButton}
activeOpacity={0.7}
onPress={() => {
const parentNav = navigation.getParent<DrawerNavigationProp<any>>();
if (parentNav) {
parentNav.openDrawer();
} else {
(navigation as any).openDrawer?.();
}
}}>
<Icon name="menu-outline" size={20} color={colors.text} />
</TouchableOpacity>
),
headerRight: () => (
<TouchableOpacity
style={styles.notificationButton}
activeOpacity={0.7}
onPress={() => {
// Navigate to notification screen if exists, or profile/other suitable route
navigation.navigate(route.notifications);
}}>
<Icon name="notifications-outline" size={20} color='#EF4444' />
{unreadCount > 0 ? (
<View style={styles.badgeContainer}>
<Text style={styles.badgeText}>
{unreadCount > 99 ? '99+' : unreadCount}
</Text>
</View>
) : null}
</TouchableOpacity>
),
})}
/>
<DashboardStackNav.Screen
name={route.notifications}
component={NotificationScreen}
options={{
headerTitle: 'Notifications',
}}
/>
</DashboardStackNav.Navigator>
);
};
const AddLeadStackNav = createNativeStackNavigator();
const AddLeadStack = () => {
const { theme: colors } = useTheme();
const styles = getStyles(colors);
return (
<AddLeadStackNav.Navigator
screenOptions={{
headerStyle: {
backgroundColor: colors.header,
},
headerTitleStyle: {
fontSize: 17,
fontWeight: '700',
color: colors.text,
},
headerTitleAlign: 'center',
headerTintColor: colors.text,
headerShadowVisible: false,
}}>
<AddLeadStackNav.Screen
name="addLeadForm"
component={AddLeadScreen}
options={({ navigation }) => ({
headerTitle: 'Add Lead',
headerLeft: () => (
<TouchableOpacity
style={styles.drawerButton}
activeOpacity={0.7}
onPress={() => {
const parentNav = navigation.getParent<DrawerNavigationProp<any>>();
if (parentNav) {
parentNav.openDrawer();
} else {
(navigation as any).openDrawer?.();
}
}}>
<Icon name="menu-outline" size={20} color={colors.text} />
</TouchableOpacity>
),
})}
/>
</AddLeadStackNav.Navigator>
);
};
const ProfileStackNav = createNativeStackNavigator();
const ProfileStack = () => {
const { theme: colors } = useTheme();
const styles = getStyles(colors);
return (
<ProfileStackNav.Navigator
screenOptions={{
headerStyle: {
backgroundColor: colors.header,
},
headerTitleStyle: {
fontSize: 17,
fontWeight: '700',
color: colors.text,
},
headerTitleAlign: 'center',
headerTintColor: colors.text,
headerShadowVisible: false,
}}>
<ProfileStackNav.Screen
name="profileForm"
component={ProfileScreen}
options={({ navigation }) => ({
headerTitle: 'Profile',
headerLeft: () => (
<TouchableOpacity
style={styles.drawerButton}
activeOpacity={0.7}
onPress={() => {
const parentNav = navigation.getParent<DrawerNavigationProp<any>>();
if (parentNav) {
parentNav.openDrawer();
} else {
(navigation as any).openDrawer?.();
}
}}>
<Icon name="menu-outline" size={20} color={colors.text} />
</TouchableOpacity>
),
})}
/>
</ProfileStackNav.Navigator>
);
};
export const TabStack = () => {
const { theme: colors } = useTheme();
const styles = getStyles(colors);
@ -58,47 +224,27 @@ export const TabStack = () => {
tabBarInactiveTintColor: colors.textMuted,
tabBarStyle: styles.tabBar,
tabBarLabelStyle: styles.tabBarLabel,
headerStyle: styles.header,
headerTitleStyle: styles.headerTitle,
headerTitleAlign: 'center',
headerShown: false,
})}>
<Tab.Screen
name={route.dashboard}
component={DashboardScreen}
options={({ navigation }) => ({
component={DashboardStack}
options={{
tabBarLabel: 'Dashboard',
headerTitle: 'Convex CRM',
headerLeft: () => (
<TouchableOpacity
style={styles.drawerButton}
activeOpacity={0.7}
onPress={() => {
const parentNav = navigation.getParent<DrawerNavigationProp<any>>();
if (parentNav) {
parentNav.openDrawer();
} else {
(navigation as any).openDrawer?.();
}
}}>
<Icon name="menu-outline" size={20} color={colors.text} />
</TouchableOpacity>
),
})}
}}
/>
<Tab.Screen
name={route.leads}
component={LeadsStack}
options={{
tabBarLabel: 'Leads',
headerShown: false,
}}
/>
<Tab.Screen
name={route.addLead}
component={AddLeadScreen}
component={AddLeadStack}
options={{
tabBarLabel: 'Add Lead',
headerTitle: 'Add Lead',
}}
/>
<Tab.Screen
@ -106,15 +252,13 @@ export const TabStack = () => {
component={CustomersStack}
options={{
tabBarLabel: 'Customers',
headerShown: false,
}}
/>
<Tab.Screen
name={route.profile}
component={ProfileScreen}
component={ProfileStack}
options={{
tabBarLabel: 'Profile',
headerTitle: 'Profile',
}}
/>
</Tab.Navigator>

View File

@ -3,7 +3,6 @@ import messaging, {
} from '@react-native-firebase/messaging';
import notifee, {
AndroidImportance,
AndroidVisibility,
AuthorizationStatus,
EventType,
} from '@notifee/react-native';
@ -11,7 +10,6 @@ import { Platform } from 'react-native';
const CHANNEL_ID = 'convex_crm_default';
const CHANNEL_NAME = 'Convex CRM Notifications';
const CHANNEL_DESCRIPTION = 'General push notifications for Convex CRM';
export interface NotificationData {
[key: string]: string;
@ -45,16 +43,8 @@ export async function createDefaultChannel(): Promise<void> {
await notifee.createChannel({
id: CHANNEL_ID,
name: CHANNEL_NAME,
description: CHANNEL_DESCRIPTION,
importance: AndroidImportance.HIGH,
visibility: AndroidVisibility.PUBLIC,
sound: 'default',
vibration: true,
vibrationPattern: [300, 500],
lights: true,
lightColor: '#4F46E5', // indigo accent
});
console.log('[NotificationService] Default Android channel created:', CHANNEL_ID);
} catch (error) {
console.error('[NotificationService] createDefaultChannel error:', error);
}
@ -101,18 +91,7 @@ export async function displayNotification(
data,
android: {
channelId: CHANNEL_ID,
importance: AndroidImportance.HIGH,
smallIcon: 'ic_launcher', // use ic_notification once drawable is added to android/app/src/main/res/drawable
pressAction: { id: 'default' },
color: '#4F46E5',
},
ios: {
sound: 'default',
foregroundPresentationOptions: {
alert: true,
badge: true,
sound: true,
},
},
});
} catch (error) {
@ -259,13 +238,10 @@ export async function initialize(
): Promise<() => void> {
const { onNotificationOpen, onTokenRefreshed } = options;
// 1. Permission
await requestPermission();
// 2. Android channel
// 1. Android channel (permission is requested explicitly after login)
await createDefaultChannel();
// 3. FCM token
// 2. FCM token
await getFCMToken();
// 4. Token refresh

View File

@ -27,6 +27,7 @@ export const route = {
customerDetails: 'customerDetails',
addCustomer: 'addCustomer',
profile: 'profile',
notifications: 'notifications',
} as const;
// ─── Route Param Types ────────────────────────────────────────────────────────
@ -54,6 +55,7 @@ export type RouteParams = {
customerDetails: { userid: string; customer?: CustomerItem };
addCustomer: undefined;
profile: undefined;
notifications: undefined;
};