feat: implement real-time customer support chat system with socket.io integration

This commit is contained in:
Atanu Das 2026-07-24 16:21:27 +05:30
parent eecbf9484c
commit 495c1f5f6b
23 changed files with 1839 additions and 85 deletions

View File

@ -117,3 +117,5 @@ dependencies {
implementation jscFlavor
}
}
apply from: file("../../node_modules/react-native-vector-icons/fonts.gradle")

View File

@ -9,3 +9,4 @@ export * from './orderApi';
export * from './offerApi';
export * from './reviewApi';
export * from './walletApi';
export * from './supportApi';

48
app/api/supportApi.ts Normal file
View File

@ -0,0 +1,48 @@
import { apiClient } from '@services';
import {
SupportTicket,
CreateTicketPayload,
PostMessagePayload,
SupportMessage,
} from '@interfaces';
export const createTicketApi = async (payload: CreateTicketPayload) => {
return await apiClient.post<SupportTicket>('/support/tickets', payload);
};
export const getMyTicketsApi = async () => {
return await apiClient.get<SupportTicket[]>('/support/tickets');
};
export const getTicketDetailsApi = async (ticketId: string) => {
return await apiClient.get<SupportTicket>(`/support/tickets/${ticketId}`);
};
export const postMessageApi = async (
ticketId: string,
payload: PostMessagePayload,
) => {
return await apiClient.post<SupportMessage>(
`/support/tickets/${ticketId}/messages`,
payload,
);
};
export const reopenTicketApi = async (ticketId: string) => {
return await apiClient.patch<SupportTicket>(
`/support/tickets/${ticketId}/reopen`,
{},
);
};
export const uploadSupportAttachmentApi = async (formData: FormData) => {
return await apiClient.post<{ id: string; filename: string; url: string }>(
'/support/upload',
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
},
);
};

View File

@ -37,12 +37,12 @@ const MENU_SECTIONS: MenuSectionData[] = [
{
title: 'Account',
items: [
{
icon: '📍',
label: 'My Addresses',
subLabel: 'Manage delivery addresses',
tint: '#FDECEA',
},
// {
// icon: '📍',
// label: 'My Addresses',
// subLabel: 'Manage delivery addresses',
// tint: '#FDECEA',
// },
{
icon: '💳',
label: 'Wallet',
@ -136,12 +136,12 @@ export const AccountScreen: React.FC = () => {
</Text>
</View>
<TouchableOpacity
{/* <TouchableOpacity
style={styles.editProfileButton}
activeOpacity={0.75}
>
<Text style={styles.editProfileButtonText}>Edit Profile</Text>
</TouchableOpacity>
</TouchableOpacity> */}
</View>
{/* Quick stats */}

View File

@ -59,4 +59,190 @@ export const getStyles = (colors: any) => StyleSheet.create({
fontWeight: typography.fontWeight.semibold,
color: colors.text,
},
tabContainer: {
flexDirection: 'row',
backgroundColor: colors.surface,
borderRadius: 8,
padding: 4,
marginBottom: 16,
},
tabButton: {
flex: 1,
paddingVertical: 10,
alignItems: 'center',
borderRadius: 6,
},
activeTabButton: {
backgroundColor: colors.cardBg,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
tabText: {
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.semibold,
color: colors.textSecondary,
},
activeTabText: {
color: colors.primary,
},
ticketCard: {
backgroundColor: colors.cardBg,
borderRadius: 12,
padding: 16,
marginBottom: 12,
borderWidth: 1,
borderColor: colors.border,
},
ticketHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
ticketNumber: {
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.bold,
color: colors.text,
},
ticketDate: {
fontSize: typography.fontSize.xs,
color: colors.textSecondary,
},
ticketSubjectText: {
fontSize: typography.fontSize.sm,
color: colors.text,
marginBottom: 8,
},
ticketFooter: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
borderTopWidth: 1,
borderTopColor: colors.border,
paddingTop: 8,
marginTop: 4,
},
categoryBadge: {
backgroundColor: colors.surface,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 4,
},
categoryText: {
fontSize: 11,
color: colors.textSecondary,
},
statusBadge: {
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 12,
},
statusText: {
fontSize: 11,
fontWeight: typography.fontWeight.bold,
},
createTicketBtn: {
backgroundColor: colors.primary,
borderRadius: 12,
paddingVertical: 14,
alignItems: 'center',
marginTop: 12,
marginBottom: 24,
},
createTicketBtnText: {
color: '#FFFFFF',
fontSize: typography.fontSize.md,
fontWeight: typography.fontWeight.bold,
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
modalContent: {
backgroundColor: colors.cardBg,
borderRadius: 16,
width: '100%',
maxHeight: '90%',
padding: 20,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.25,
shadowRadius: 4,
elevation: 5,
},
modalTitle: {
fontSize: typography.fontSize.lg,
fontWeight: typography.fontWeight.bold,
color: colors.text,
marginBottom: 16,
textAlign: 'center',
},
label: {
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.semibold,
color: colors.text,
marginBottom: 6,
marginTop: 12,
},
input: {
backgroundColor: colors.background,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: typography.fontSize.sm,
color: colors.text,
},
textArea: {
minHeight: 80,
textAlignVertical: 'top',
},
categorySelect: {
backgroundColor: colors.background,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 8,
padding: 12,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
categorySelectText: {
fontSize: typography.fontSize.sm,
color: colors.text,
},
modalActions: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 20,
},
modalBtn: {
flex: 1,
paddingVertical: 12,
borderRadius: 8,
alignItems: 'center',
},
cancelBtn: {
backgroundColor: colors.border,
marginRight: 10,
},
submitBtn: {
backgroundColor: colors.primary,
marginLeft: 10,
},
cancelBtnText: {
color: colors.text,
fontWeight: typography.fontWeight.bold,
},
submitBtnText: {
color: '#FFFFFF',
fontWeight: typography.fontWeight.bold,
},
});

View File

@ -1,11 +1,24 @@
import React from 'react';
import { View, Text, ScrollView, TouchableOpacity } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import React, { useState, useEffect } from 'react';
import {
View,
Text,
ScrollView,
TouchableOpacity,
FlatList,
Modal,
TextInput,
ActivityIndicator,
Alert,
} from 'react-native';
import { useNavigation, useIsFocused } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';
import { getStyles } from './helpSupportScreen.styles';
import { Header } from '@components';
import { useAppTheme } from '@theme';
import { AppStackParamList } from '../../../navigation/appStack';
import { useCustomerSupport } from '../../../hooks/useCustomerSupport';
import { TicketCategory, TicketPriority } from '@interfaces';
import { formatDate } from '../../../utils/helper';
type HelpSupportNavProp = StackNavigationProp<AppStackParamList, 'HelpSupportScreen'>;
@ -16,15 +29,87 @@ const FAQS = [
{ q: 'What payment methods are accepted?', a: 'UPI, Credit/Debit Card, Wallet, and Cash on Delivery.' },
];
const TICKET_CATEGORIES: { value: TicketCategory; label: string }[] = [
{ value: 'ORDER', label: 'Order' },
{ value: 'PAYMENT', label: 'Payment' },
{ value: 'PAYOUT', label: 'Payout' },
{ value: 'TECHNICAL_ISSUE', label: 'Technical Issue' },
{ value: 'ACCOUNT', label: 'Account' },
{ value: 'OTHER', label: 'Other' },
];
export const HelpSupportScreen: React.FC = () => {
const { colors } = useAppTheme();
const styles = getStyles(colors);
const navigation = useNavigation<HelpSupportNavProp>();
const isFocused = useIsFocused();
return (
<View style={styles.container}>
<Header title="Help & Support" onBack={() => navigation.goBack()} />
<ScrollView contentContainerStyle={styles.content}>
// Tab State
const [activeTab, setActiveTab] = useState<'faq' | 'tickets'>('faq');
// Custom Support Hook
const { tickets, loading, fetchTickets, createTicket } = useCustomerSupport();
// Create Ticket Modal State
const [createModalVisible, setCreateModalVisible] = useState(false);
const [categorySelectVisible, setCategorySelectVisible] = useState(false);
const [category, setCategory] = useState<TicketCategory>('ORDER');
const [priority] = useState<TicketPriority>('MEDIUM');
const [subject, setSubject] = useState('');
const [description, setDescription] = useState('');
useEffect(() => {
if (isFocused && activeTab === 'tickets') {
fetchTickets();
}
}, [isFocused, activeTab, fetchTickets]);
const handleCreateTicketSubmit = async () => {
if (!subject.trim()) {
Alert.alert('Error', 'Please enter a subject.');
return;
}
if (!description.trim()) {
Alert.alert('Error', 'Please enter ticket details.');
return;
}
try {
const newTicket = await createTicket({
category,
priority,
subject: subject.trim(),
description: description.trim(),
});
setCreateModalVisible(false);
setSubject('');
setDescription('');
Alert.alert('Success', 'Support Ticket created successfully!');
// Navigate to chat
navigation.navigate('SupportChatScreen', { ticketId: newTicket.id });
} catch (error) {
Alert.alert('Error', 'Failed to create support ticket. Please try again.');
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'OPEN':
return { bg: '#E3F2FD', text: '#1E88E5' }; // Blue
case 'IN_PROGRESS':
return { bg: '#FFF3E0', text: '#FB8C00' }; // Amber
case 'RESOLVED':
return { bg: '#E8F5E9', text: '#43A047' }; // Green
case 'CLOSED':
return { bg: '#ECEFF1', text: '#546E7A' }; // Grey
default:
return { bg: '#F5F5F5', text: '#9E9E9E' };
}
};
const renderFaqTab = () => (
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<Text style={styles.sectionTitle}>Frequently Asked Questions</Text>
{FAQS.map((faq, index) => (
<View key={index} style={styles.faqItem}>
@ -49,6 +134,192 @@ export const HelpSupportScreen: React.FC = () => {
</View>
</TouchableOpacity>
</ScrollView>
);
const renderTicketsTab = () => (
<View style={[styles.container, { padding: 16 }]}>
{loading && tickets.length === 0 ? (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={{ marginTop: 8, color: colors.textSecondary }}>Fetching tickets...</Text>
</View>
) : (
<FlatList
data={tickets}
keyExtractor={(item) => item.id}
showsVerticalScrollIndicator={false}
ListEmptyComponent={
<View style={{ paddingVertical: 40, alignItems: 'center' }}>
<Text style={{ fontSize: 16, color: colors.textSecondary, marginBottom: 12 }}>
No support tickets found
</Text>
<Text style={{ fontSize: 12, color: colors.textSecondary, textAlign: 'center' }}>
Need help with your account or order? Create a ticket below!
</Text>
</View>
}
renderItem={({ item }) => {
const statusStyle = getStatusColor(item.status);
return (
<TouchableOpacity
style={styles.ticketCard}
activeOpacity={0.7}
onPress={() => navigation.navigate('SupportChatScreen', { ticketId: item.id })}
>
<View style={styles.ticketHeader}>
<Text style={styles.ticketNumber}>Ticket #{item.ticketNumber}</Text>
<Text style={styles.ticketDate}>{formatDate(item.createdAt)}</Text>
</View>
<Text style={styles.ticketSubjectText} numberOfLines={1}>
{item.subject}
</Text>
<View style={styles.ticketFooter}>
<View style={styles.categoryBadge}>
<Text style={styles.categoryText}>{item.category.replace('_', ' ')}</Text>
</View>
<View style={[styles.statusBadge, { backgroundColor: statusStyle.bg }]}>
<Text style={[styles.statusText, { color: statusStyle.text }]}>
{item.status.replace('_', ' ')}
</Text>
</View>
</View>
</TouchableOpacity>
);
}}
/>
)}
<TouchableOpacity
style={styles.createTicketBtn}
onPress={() => setCreateModalVisible(true)}
activeOpacity={0.8}
>
<Text style={styles.createTicketBtnText}>+ Create Support Ticket</Text>
</TouchableOpacity>
</View>
);
return (
<View style={styles.container}>
<Header title="Help & Support" onBack={() => navigation.goBack()} />
<View style={{ paddingHorizontal: 16, paddingTop: 16 }}>
<View style={styles.tabContainer}>
<TouchableOpacity
style={[styles.tabButton, activeTab === 'faq' && styles.activeTabButton]}
onPress={() => setActiveTab('faq')}
>
<Text style={[styles.tabText, activeTab === 'faq' && styles.activeTabText]}>FAQs</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.tabButton, activeTab === 'tickets' && styles.activeTabButton]}
onPress={() => setActiveTab('tickets')}
>
<Text style={[styles.tabText, activeTab === 'tickets' && styles.activeTabText]}>
My Support Tickets
</Text>
</TouchableOpacity>
</View>
</View>
{activeTab === 'faq' ? renderFaqTab() : renderTicketsTab()}
{/* Create Ticket Modal */}
<Modal
visible={createModalVisible}
transparent
animationType="slide"
onRequestClose={() => setCreateModalVisible(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<Text style={styles.modalTitle}>Create Support Ticket</Text>
<ScrollView showsVerticalScrollIndicator={false}>
<Text style={styles.label}>Category</Text>
<TouchableOpacity
style={styles.categorySelect}
onPress={() => setCategorySelectVisible(true)}
>
<Text style={styles.categorySelectText}>
{TICKET_CATEGORIES.find((c) => c.value === category)?.label || 'Select category'}
</Text>
<Text style={{ color: colors.textSecondary }}></Text>
</TouchableOpacity>
<Text style={styles.label}>Subject</Text>
<TextInput
style={styles.input}
value={subject}
onChangeText={setSubject}
placeholder="What is the issue about?"
placeholderTextColor={colors.textSecondary}
/>
<Text style={styles.label}>Description</Text>
<TextInput
style={[styles.input, styles.textArea]}
value={description}
onChangeText={setDescription}
placeholder="Please describe your issue in detail..."
placeholderTextColor={colors.textSecondary}
multiline
numberOfLines={4}
/>
<View style={styles.modalActions}>
<TouchableOpacity
style={[styles.modalBtn, styles.cancelBtn]}
onPress={() => setCreateModalVisible(false)}
>
<Text style={styles.cancelBtnText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.modalBtn, styles.submitBtn]}
onPress={handleCreateTicketSubmit}
>
<Text style={styles.submitBtnText}>Submit</Text>
</TouchableOpacity>
</View>
</ScrollView>
</View>
</View>
</Modal>
{/* Category Dropdown Modal */}
<Modal
visible={categorySelectVisible}
transparent
animationType="fade"
onRequestClose={() => setCategorySelectVisible(false)}
>
<View style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'center', padding: 24 }}>
<View style={{ backgroundColor: colors.cardBg, borderRadius: 12, padding: 16 }}>
<Text style={{ fontSize: 18, fontWeight: 'bold', color: colors.text, marginBottom: 12, textAlign: 'center' }}>
Select Category
</Text>
{TICKET_CATEGORIES.map((item) => (
<TouchableOpacity
key={item.value}
style={{ paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: colors.border }}
onPress={() => {
setCategory(item.value);
setCategorySelectVisible(false);
}}
>
<Text style={{ fontSize: 16, color: colors.text }}>{item.label}</Text>
</TouchableOpacity>
))}
<TouchableOpacity
style={{ marginTop: 16, paddingVertical: 12, alignItems: 'center' }}
onPress={() => setCategorySelectVisible(false)}
>
<Text style={{ color: colors.primary, fontWeight: 'bold' }}>Close</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
</View>
);
};
export default HelpSupportScreen;

View File

@ -1 +1,2 @@
export * from './helpSupportScreen';

View File

@ -31,6 +31,7 @@ import {
} from '@store';
import { getAllCategoriesThunk, getAllProductsThunk } from './thunk';
import { getCategoryEmoji } from '@utils';
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
type NavProp = CompositeNavigationProp<
BottomTabNavigationProp<MainTabParamList, 'HomeScreen'>,
@ -121,7 +122,11 @@ export const HomeScreen: React.FC = () => {
<View style={styles.topBar}>
<TouchableOpacity style={styles.addressBar} activeOpacity={0.7}>
<View style={styles.pinBadge}>
<Text style={styles.pinEmoji}>📍</Text>
<MaterialCommunityIcons
name="map-marker"
size={22}
color="#05824C"
/>
</View>
<View style={styles.addressTextWrap}>
<Text style={styles.addressLabel}>Deliver to</Text>
@ -139,7 +144,11 @@ export const HomeScreen: React.FC = () => {
activeOpacity={0.7}
onPress={() => navigation.navigate('CartScreen')}
>
<Text style={styles.avatarEmoji}>🧺</Text>
<MaterialCommunityIcons
name="cart"
size={26}
color="#666"
/>
<View style={styles.notifDot} />
</TouchableOpacity>
</View>

View File

@ -22,3 +22,4 @@ export * from './accountScreen';
export * from './writeReviewScreen';
export * from './orderDetailsScreen';
export * from './walletScreen';
export * from './supportChatScreen'

View File

@ -252,4 +252,104 @@ export const getStyles = (colors: any) =>
color: '#92400E',
fontWeight: typography.fontWeight.medium,
},
orderNeedHelpBtn: {
marginTop: 14,
paddingVertical: 10,
paddingHorizontal: 12,
backgroundColor: '#E8F5E9',
borderRadius: 8,
borderWidth: 1,
borderColor: '#C8E6C9',
alignItems: 'center',
justifyContent: 'center',
},
orderNeedHelpText: {
color: colors.primary,
fontWeight: typography.fontWeight.bold,
fontSize: typography.fontSize.sm,
},
// Help modal specific styles
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
modalContent: {
backgroundColor: colors.cardBg,
borderRadius: 16,
width: '100%',
padding: 20,
},
modalTitle: {
fontSize: typography.fontSize.md,
fontWeight: typography.fontWeight.bold,
color: colors.text,
marginBottom: 16,
textAlign: 'center',
},
label: {
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.semibold,
color: colors.text,
marginBottom: 6,
marginTop: 12,
},
input: {
backgroundColor: colors.background,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: typography.fontSize.sm,
color: colors.text,
},
textArea: {
minHeight: 80,
textAlignVertical: 'top',
},
categorySelect: {
backgroundColor: colors.background,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 8,
padding: 12,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
categorySelectText: {
fontSize: typography.fontSize.sm,
color: colors.text,
},
modalActions: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 20,
},
modalBtn: {
flex: 1,
paddingVertical: 12,
borderRadius: 8,
alignItems: 'center',
},
cancelBtn: {
backgroundColor: colors.border,
marginRight: 10,
},
submitBtn: {
backgroundColor: colors.primary,
marginLeft: 10,
},
cancelBtnText: {
color: colors.text,
fontWeight: typography.fontWeight.bold,
},
submitBtnText: {
color: '#FFFFFF',
fontWeight: typography.fontWeight.bold,
},
});

View File

@ -5,6 +5,9 @@ import {
ScrollView,
ActivityIndicator,
TouchableOpacity,
Modal,
TextInput,
Alert,
} from 'react-native';
import { useRoute, useNavigation } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';
@ -16,8 +19,9 @@ import { RootState, useAppDispatch, useAppSelector } from '@store';
import { getOrderByIdThunk } from '../checkoutPaymentScreen';
import { formatDate, formatTime } from '@utils/helper';
import { submitReview } from './thunk';
import { giveRatingPayload, OrderItem } from '@interfaces';
import { giveRatingPayload, OrderItem, TicketCategory } from '@interfaces';
import { AppStackParamList } from '@navigation';
import { useCustomerSupport } from '../../../hooks/useCustomerSupport';
type OrderDetailsRouteProp = RouteProp<AppStackParamList, 'OrderDetailsScreen'>;
type OrderDetailsNavProp = StackNavigationProp<AppStackParamList>;
@ -45,6 +49,43 @@ export const OrderDetailsScreen: React.FC = () => {
const isDelivered = orderDetails?.status === 'DELIVERED';
// ── Support / Need Help modal state ───────────────────────────────────────
const [helpModalVisible, setHelpModalVisible] = useState(false);
const [categorySelectVisible, setCategorySelectVisible] = useState(false);
const [helpCategory, setHelpCategory] = useState<TicketCategory>('ORDER');
const [helpDescription, setHelpDescription] = useState('');
const [submittingTicket, setSubmittingTicket] = useState(false);
const { createTicket } = useCustomerSupport();
const handleHelpSubmit = async () => {
if (!orderDetails) return;
if (!helpDescription.trim()) {
Alert.alert('Error', 'Please describe the issue in detail.');
return;
}
setSubmittingTicket(true);
try {
const orderShortId = orderDetails.orderNumber?.split('-').pop() || orderDetails.id;
const newTicket = await createTicket({
category: helpCategory,
priority: 'MEDIUM',
subject: `Issue with Order #${orderShortId}`,
description: helpDescription.trim(),
orderId: orderDetails.id,
});
setHelpModalVisible(false);
setHelpDescription('');
Alert.alert('Success', 'Support ticket created successfully!');
navigation.navigate('SupportChatScreen', { ticketId: newTicket.id });
} catch (error) {
Alert.alert('Error', 'Failed to create support ticket. Please try again.');
} finally {
setSubmittingTicket(false);
}
};
useEffect(() => {
if (orderId) {
dispatch(getOrderByIdThunk(orderId));
@ -140,6 +181,15 @@ export const OrderDetailsScreen: React.FC = () => {
{orderDetails.merchant?.name || 'Store'}
</Text>
</View>
{/* Need Help Button */}
<TouchableOpacity
style={styles.orderNeedHelpBtn}
onPress={() => setHelpModalVisible(true)}
activeOpacity={0.7}
>
<Text style={styles.orderNeedHelpText}> Need help with this order?</Text>
</TouchableOpacity>
</View>
{/* Order Status Timeline Section */}
@ -289,6 +339,113 @@ export const OrderDetailsScreen: React.FC = () => {
onSubmit={handleRatingSubmit}
onClose={handleModalClose}
/>
{/* Need Help / Ticket Creation Modal */}
<Modal
visible={helpModalVisible}
transparent
animationType="slide"
onRequestClose={() => setHelpModalVisible(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<Text style={styles.modalTitle}>Report Issue with Order</Text>
<ScrollView showsVerticalScrollIndicator={false}>
<Text style={styles.label}>Category</Text>
<TouchableOpacity
style={styles.categorySelect}
onPress={() => setCategorySelectVisible(true)}
>
<Text style={styles.categorySelectText}>
{helpCategory.replace('_', ' ')}
</Text>
<Text style={{ color: colors.textSecondary }}></Text>
</TouchableOpacity>
<Text style={styles.label}>Order ID</Text>
<TextInput
style={[styles.input, { opacity: 0.6 }]}
value={orderDetails.orderNumber || orderDetails.id}
editable={false}
/>
<Text style={styles.label}>Details</Text>
<TextInput
style={[styles.input, styles.textArea]}
value={helpDescription}
onChangeText={setHelpDescription}
placeholder="What seems to be the problem with this order?"
placeholderTextColor={colors.textSecondary}
multiline
numberOfLines={4}
/>
<View style={styles.modalActions}>
<TouchableOpacity
style={[styles.modalBtn, styles.cancelBtn]}
onPress={() => setHelpModalVisible(false)}
disabled={submittingTicket}
>
<Text style={styles.cancelBtnText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.modalBtn, styles.submitBtn]}
onPress={handleHelpSubmit}
disabled={submittingTicket}
>
{submittingTicket ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<Text style={styles.submitBtnText}>Submit</Text>
)}
</TouchableOpacity>
</View>
</ScrollView>
</View>
</View>
</Modal>
{/* Help Category Select Modal */}
<Modal
visible={categorySelectVisible}
transparent
animationType="fade"
onRequestClose={() => setCategorySelectVisible(false)}
>
<View style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'center', padding: 24 }}>
<View style={{ backgroundColor: colors.cardBg, borderRadius: 12, padding: 16 }}>
<Text style={{ fontSize: 18, fontWeight: 'bold', color: colors.text, marginBottom: 12, textAlign: 'center' }}>
Select Issue Category
</Text>
{[
{ value: 'ORDER', label: 'Order' },
{ value: 'PAYMENT', label: 'Payment' },
{ value: 'PAYOUT', label: 'Payout' },
{ value: 'TECHNICAL_ISSUE', label: 'Technical Issue' },
{ value: 'ACCOUNT', label: 'Account' },
{ value: 'OTHER', label: 'Other' },
].map((item) => (
<TouchableOpacity
key={item.value}
style={{ paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: colors.border }}
onPress={() => {
setHelpCategory(item.value as TicketCategory);
setCategorySelectVisible(false);
}}
>
<Text style={{ fontSize: 16, color: colors.text }}>{item.label}</Text>
</TouchableOpacity>
))}
<TouchableOpacity
style={{ marginTop: 16, paddingVertical: 12, alignItems: 'center' }}
onPress={() => setCategorySelectVisible(false)}
>
<Text style={{ color: colors.primary, fontWeight: 'bold' }}>Close</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
</View>
);
};

View File

@ -112,9 +112,9 @@ export const ProviderDetailsScreen: React.FC = () => {
dispatch(updateCartItemThunk({ productId: product.id, quantity: newQty }));
};
const handleRateProduct = () => {
navigation.navigate('WriteReviewScreen', { productId: product?.id ?? '' });
};
// const handleRateProduct = () => {
// navigation.navigate('WriteReviewScreen', { productId: product?.id ?? '' });
// };
const renderStars = (value: number, size = 14) => {
const rounded = Math.round(value);
@ -310,7 +310,7 @@ export const ProviderDetailsScreen: React.FC = () => {
<View style={styles.emptyRatingsCard}>
<Text style={styles.emptyRatingsIcon}></Text>
<Text style={styles.emptyRatingsTitle}>No ratings yet</Text>
<Text style={styles.emptyRatingsSubtitle}>
{/* <Text style={styles.emptyRatingsSubtitle}>
Be the first to share what you think of this product.
</Text>
<TouchableOpacity
@ -319,7 +319,7 @@ export const ProviderDetailsScreen: React.FC = () => {
onPress={handleRateProduct}
>
<Text style={styles.rateProductBtnText}>Rate this product</Text>
</TouchableOpacity>
</TouchableOpacity> */}
</View>
)}

View File

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

View File

@ -0,0 +1,236 @@
import { StyleSheet } from 'react-native';
import { typography } from '@theme';
export const getStyles = (colors: any) =>
StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background,
},
headerRight: {
marginRight: 16,
},
statusBadge: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 12,
},
statusText: {
fontSize: typography.fontSize.xs,
fontWeight: typography.fontWeight.bold,
},
ticketInfoBar: {
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: colors.cardBg,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
ticketSubject: {
fontSize: typography.fontSize.md,
fontWeight: typography.fontWeight.bold,
color: colors.text,
marginBottom: 4,
},
ticketMeta: {
fontSize: typography.fontSize.xs,
color: colors.textSecondary,
},
chatList: {
padding: 16,
paddingBottom: 24,
},
messageRow: {
flexDirection: 'row',
marginBottom: 16,
width: '100%',
},
customerRow: {
justifyContent: 'flex-end',
},
agentRow: {
justifyContent: 'flex-start',
},
bubble: {
maxWidth: '75%',
borderRadius: 16,
paddingHorizontal: 14,
paddingVertical: 10,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
customerBubble: {
backgroundColor: colors.primary,
borderBottomRightRadius: 2,
},
agentBubble: {
backgroundColor: colors.cardBg,
borderBottomLeftRadius: 2,
borderWidth: 1,
borderColor: colors.border,
},
senderName: {
fontSize: 11,
fontWeight: typography.fontWeight.semibold,
color: colors.textSecondary,
marginBottom: 4,
},
messageText: {
fontSize: typography.fontSize.sm,
lineHeight: 20,
},
customerText: {
color: '#FFFFFF',
},
agentText: {
color: colors.text,
},
timeText: {
fontSize: 9,
marginTop: 4,
textAlign: 'right',
},
customerTime: {
color: 'rgba(255,255,255,0.7)',
},
agentTime: {
color: colors.textSecondary,
},
attachmentImage: {
width: 200,
height: 150,
borderRadius: 8,
marginBottom: 4,
},
typingContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 8,
backgroundColor: colors.background,
},
typingText: {
fontSize: typography.fontSize.xs,
color: colors.textSecondary,
fontStyle: 'italic',
marginLeft: 6,
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 10,
backgroundColor: colors.cardBg,
borderTopWidth: 1,
borderTopColor: colors.border,
},
attachmentBtn: {
padding: 8,
marginRight: 4,
justifyContent: 'center',
alignItems: 'center',
},
input: {
flex: 1,
minHeight: 40,
maxHeight: 100,
backgroundColor: colors.background,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 20,
paddingHorizontal: 16,
paddingVertical: 8,
fontSize: typography.fontSize.sm,
color: colors.text,
marginRight: 8,
},
sendBtn: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.primary,
justifyContent: 'center',
alignItems: 'center',
},
sendBtnDisabled: {
backgroundColor: colors.border,
},
sendIcon: {
fontSize: 16,
color: '#FFFFFF',
},
previewContainer: {
flexDirection: 'row',
padding: 12,
backgroundColor: colors.cardBg,
borderTopWidth: 1,
borderTopColor: colors.border,
},
previewImageWrapper: {
position: 'relative',
marginRight: 12,
},
previewImage: {
width: 80,
height: 80,
borderRadius: 8,
},
removePreviewBtn: {
position: 'absolute',
top: -6,
right: -6,
backgroundColor: '#FF3B30',
width: 20,
height: 20,
borderRadius: 10,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
borderColor: '#FFFFFF',
},
removePreviewText: {
color: '#FFFFFF',
fontSize: 10,
fontWeight: 'bold',
},
reopenContainer: {
padding: 16,
backgroundColor: colors.cardBg,
borderTopWidth: 1,
borderTopColor: colors.border,
alignItems: 'center',
},
reopenTitle: {
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.semibold,
color: colors.textSecondary,
marginBottom: 10,
textAlign: 'center',
},
reopenBtn: {
paddingHorizontal: 24,
paddingVertical: 12,
backgroundColor: colors.primary,
borderRadius: 24,
width: '100%',
alignItems: 'center',
},
reopenBtnText: {
color: '#FFFFFF',
fontSize: typography.fontSize.sm,
fontWeight: typography.fontWeight.bold,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 12,
fontSize: typography.fontSize.sm,
color: colors.textSecondary,
},
});

View File

@ -0,0 +1,373 @@
import React, { useState, useEffect, useRef } from 'react';
import {
View,
Text,
TextInput,
TouchableOpacity,
FlatList,
Image,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
Alert,
Modal,
} from 'react-native';
import { useRoute, useNavigation, RouteProp } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';
import { Header } from '@components';
import { useAppTheme } from '@theme';
import { useCustomerSupport } from '@hooks';
import { getStyles } from './supportChatScreen.styles';
import { AppStackParamList } from '../../../navigation/appStack';
import { formatTime, getFullUrl } from '../../../utils/helper';
type SupportChatRouteProp = RouteProp<AppStackParamList, 'SupportChatScreen'>;
type SupportChatNavProp = StackNavigationProp<AppStackParamList>;
// Mock attachments that users can select to simulate image attachments
const MOCK_ATTACHMENTS = [
{
id: 'att1',
label: 'Receipt Invoice',
url: 'https://images.unsplash.com/photo-1554415707-6e8cfc93fe23?w=500&auto=format&fit=crop&q=60',
},
{
id: 'att2',
label: 'Damaged Packing',
url: 'https://images.unsplash.com/photo-1607344645866-009c320c5ab8?w=500&auto=format&fit=crop&q=60',
},
{
id: 'att3',
label: 'Wrong Item Delivered',
url: 'https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=500&auto=format&fit=crop&q=60',
},
];
export const SupportChatScreen: React.FC = () => {
const { colors } = useAppTheme();
const styles = getStyles(colors);
const route = useRoute<SupportChatRouteProp>();
const navigation = useNavigation<SupportChatNavProp>();
const { ticketId } = route.params;
const {
currentTicket,
messages,
loading,
isAgentTyping,
fetchTicketDetails,
sendMessage,
reopenTicket,
emitTypingStart,
emitTypingStop,
} = useCustomerSupport(ticketId);
const [inputText, setInputText] = useState('');
const [selectedAttachmentUrl, setSelectedAttachmentUrl] = useState<string | null>(null);
const [attachmentModalVisible, setAttachmentModalVisible] = useState(false);
const flatListRef = useRef<FlatList>(null);
const typingTimeoutRef = useRef<any>(null);
useEffect(() => {
if (ticketId) {
fetchTicketDetails(ticketId);
}
}, [ticketId, fetchTicketDetails]);
// Auto scroll to bottom when messages change
useEffect(() => {
if (messages.length > 0) {
setTimeout(() => {
flatListRef.current?.scrollToEnd({ animated: true });
}, 100);
}
}, [messages]);
const handleSend = async () => {
if (!inputText.trim() && !selectedAttachmentUrl) return;
try {
const textToSend = inputText.trim();
const attachmentsToSend = selectedAttachmentUrl ? [selectedAttachmentUrl] : undefined;
setInputText('');
setSelectedAttachmentUrl(null);
emitTypingStop();
await sendMessage(ticketId, textToSend, attachmentsToSend);
} catch (error) {
Alert.alert('Error', 'Failed to send message. Please try again.');
}
};
const handleInputChange = (text: string) => {
setInputText(text);
// Emit typing start and manage typing stop timeout
emitTypingStart();
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
typingTimeoutRef.current = setTimeout(() => {
emitTypingStop();
}, 2000);
};
const handleReopen = async () => {
try {
await reopenTicket(ticketId);
Alert.alert('Success', 'Ticket has been reopened.');
} catch (error) {
Alert.alert('Error', 'Failed to reopen ticket.');
}
};
const getStatusColor = (status?: string) => {
switch (status) {
case 'OPEN':
return { bg: '#E3F2FD', text: '#1E88E5' }; // Blue
case 'IN_PROGRESS':
return { bg: '#FFF3E0', text: '#FB8C00' }; // Amber
case 'RESOLVED':
return { bg: '#E8F5E9', text: '#43A047' }; // Green
case 'CLOSED':
return { bg: '#ECEFF1', text: '#546E7A' }; // Grey
default:
return { bg: '#F5F5F5', text: '#9E9E9E' };
}
};
const statusStyle = getStatusColor(currentTicket?.status);
if (loading && !currentTicket) {
return (
<View style={styles.container}>
<Header title="Support Ticket" onBack={() => navigation.goBack()} />
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.loadingText}>Loading support ticket...</Text>
</View>
</View>
);
}
const isClosed = currentTicket?.status === 'RESOLVED' || currentTicket?.status === 'CLOSED';
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<Header
title={`Support Chat`}
onBack={() => navigation.goBack()}
rightComponent={
currentTicket && (
<View style={[styles.statusBadge, { backgroundColor: statusStyle.bg }]}>
<Text style={[styles.statusText, { color: statusStyle.text }]}>
{currentTicket.status.replace('_', ' ')}
</Text>
</View>
)
}
/>
{currentTicket && (
<View style={styles.ticketInfoBar}>
<Text style={styles.ticketSubject}>{currentTicket.subject}</Text>
<Text style={styles.ticketMeta}>
ID: #{currentTicket.ticketNumber} | Category: {currentTicket.category.replace('_', ' ')}
</Text>
</View>
)}
<FlatList
ref={flatListRef}
data={messages}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.chatList}
renderItem={({ item }) => {
const isCustomer = item.sender.roleEnum === 'CUSTOMER';
return (
<View
style={[
styles.messageRow,
isCustomer ? styles.customerRow : styles.agentRow,
]}
>
<View
style={[
styles.bubble,
isCustomer ? styles.customerBubble : styles.agentBubble,
]}
>
{!isCustomer && (
<Text style={styles.senderName}>{item.sender.name} (Support)</Text>
)}
{item.attachments && item.attachments.map((att: string, idx: number) => (
<Image
key={idx}
source={{ uri: getFullUrl(att) }}
style={styles.attachmentImage}
resizeMode="cover"
/>
))}
{item.message.trim() ? (
<Text
style={[
styles.messageText,
isCustomer ? styles.customerText : styles.agentText,
]}
>
{item.message}
</Text>
) : null}
<Text
style={[
styles.timeText,
isCustomer ? styles.customerTime : styles.agentTime,
]}
>
{formatTime(item.createdAt)}
</Text>
</View>
</View>
);
}}
onContentSizeChange={() => flatListRef.current?.scrollToEnd({ animated: true })}
/>
{isAgentTyping && (
<View style={styles.typingContainer}>
<ActivityIndicator size="small" color={colors.textSecondary} />
<Text style={styles.typingText}>Agent is typing...</Text>
</View>
)}
{/* Selected attachment preview */}
{selectedAttachmentUrl && (
<View style={styles.previewContainer}>
<View style={styles.previewImageWrapper}>
<Image source={{ uri: selectedAttachmentUrl }} style={styles.previewImage} />
<TouchableOpacity
style={styles.removePreviewBtn}
onPress={() => setSelectedAttachmentUrl(null)}
>
<Text style={styles.removePreviewText}></Text>
</TouchableOpacity>
</View>
</View>
)}
{/* Input or Reopen prompt */}
{isClosed ? (
<View style={styles.reopenContainer}>
<Text style={styles.reopenTitle}>
This ticket has been resolved or closed.
</Text>
<TouchableOpacity style={styles.reopenBtn} onPress={handleReopen} activeOpacity={0.8}>
<Text style={styles.reopenBtnText}>Reopen Ticket</Text>
</TouchableOpacity>
</View>
) : (
<View style={styles.inputContainer}>
<TouchableOpacity
style={styles.attachmentBtn}
onPress={() => setAttachmentModalVisible(true)}
>
<Text style={{ fontSize: 22 }}>📎</Text>
</TouchableOpacity>
<TextInput
style={styles.input}
value={inputText}
onChangeText={handleInputChange}
placeholder="Type a message..."
placeholderTextColor={colors.textSecondary}
multiline
onBlur={emitTypingStop}
/>
<TouchableOpacity
style={[
styles.sendBtn,
(!inputText.trim() && !selectedAttachmentUrl) && styles.sendBtnDisabled,
]}
onPress={handleSend}
disabled={!inputText.trim() && !selectedAttachmentUrl}
activeOpacity={0.8}
>
<Text style={styles.sendIcon}></Text>
</TouchableOpacity>
</View>
)}
{/* Attachment Modal */}
<Modal
visible={attachmentModalVisible}
transparent
animationType="fade"
onRequestClose={() => setAttachmentModalVisible(false)}
>
<View
style={{
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'flex-end',
}}
>
<View
style={{
backgroundColor: colors.cardBg,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
padding: 20,
}}
>
<Text
style={{
fontSize: 18,
fontWeight: 'bold',
color: colors.text,
marginBottom: 16,
textAlign: 'center',
}}
>
Select Mock Attachment
</Text>
{MOCK_ATTACHMENTS.map((item) => (
<TouchableOpacity
key={item.id}
style={{
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: colors.border,
}}
onPress={() => {
setSelectedAttachmentUrl(item.url);
setAttachmentModalVisible(false);
}}
>
<Text style={{ fontSize: 24, marginRight: 12 }}>🖼</Text>
<Text style={{ fontSize: 16, color: colors.text }}>{item.label}</Text>
</TouchableOpacity>
))}
<TouchableOpacity
style={{
marginTop: 16,
paddingVertical: 12,
backgroundColor: colors.border,
borderRadius: 10,
alignItems: 'center',
}}
onPress={() => setAttachmentModalVisible(false)}
>
<Text style={{ fontSize: 16, fontWeight: 'bold', color: colors.text }}>Cancel</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
</KeyboardAvoidingView>
);
};
export default SupportChatScreen;

View File

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

View File

@ -0,0 +1,246 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { io, Socket } from 'socket.io-client';
import { tokenManager } from '@services';
import {
SupportTicket,
SupportMessage,
CreateTicketPayload,
} from '@interfaces';
import {
createTicketApi,
getMyTicketsApi,
getTicketDetailsApi,
postMessageApi,
reopenTicketApi,
uploadSupportAttachmentApi,
} from '../api/supportApi';
const BASE_URL = 'https://accb-115-187-33-94.ngrok-free.app';
const SUPPORT_SOCKET_URL = `${BASE_URL}/support`;
export const useCustomerSupport = (ticketId?: string) => {
const [tickets, setTickets] = useState<SupportTicket[]>([]);
const [currentTicket, setCurrentTicket] = useState<SupportTicket | null>(null);
const [messages, setMessages] = useState<SupportMessage[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [isAgentTyping, setIsAgentTyping] = useState<boolean>(false);
const [socketConnected, setSocketConnected] = useState<boolean>(false);
const socketRef = useRef<Socket | null>(null);
// 1. Fetch all my tickets
const fetchTickets = useCallback(async () => {
setLoading(true);
try {
const data = await getMyTicketsApi();
setTickets(data);
} catch (error) {
console.error('[useCustomerSupport] fetchTickets error:', error);
} finally {
setLoading(false);
}
}, []);
// 2. Fetch single ticket details
const fetchTicketDetails = useCallback(async (id: string) => {
setLoading(true);
try {
const data = await getTicketDetailsApi(id);
setCurrentTicket(data);
setMessages(data.messages || []);
} catch (error) {
console.error('[useCustomerSupport] fetchTicketDetails error:', error);
} finally {
setLoading(false);
}
}, []);
// 3. Create ticket
const createTicket = useCallback(async (payload: CreateTicketPayload) => {
setLoading(true);
try {
const newTicket = await createTicketApi(payload);
setTickets(prev => [newTicket, ...prev]);
return newTicket;
} catch (error) {
console.error('[useCustomerSupport] createTicket error:', error);
throw error;
} finally {
setLoading(false);
}
}, []);
// 4. Send reply message
const sendMessage = useCallback(async (id: string, text: string, attachments?: string[]) => {
try {
const newMsg = await postMessageApi(id, { message: text, attachments });
setMessages(prev => {
if (prev.some(m => m.id === newMsg.id)) return prev;
return [...prev, newMsg];
});
return newMsg;
} catch (error) {
console.error('[useCustomerSupport] sendMessage error:', error);
throw error;
}
}, []);
// 5. Reopen ticket
const reopenTicket = useCallback(async (id: string) => {
setLoading(true);
try {
const updatedTicket = await reopenTicketApi(id);
setCurrentTicket(updatedTicket);
return updatedTicket;
} catch (error) {
console.error('[useCustomerSupport] reopenTicket error:', error);
throw error;
} finally {
setLoading(false);
}
}, []);
// 6. Upload attachment
const uploadAttachment = useCallback(async (file: { uri: string; name: string; type: string }) => {
const formData = new FormData();
formData.append('file', {
uri: file.uri,
name: file.name,
type: file.type,
} as any);
try {
const response = await uploadSupportAttachmentApi(formData);
return response;
} catch (error) {
console.error('[useCustomerSupport] uploadAttachment error:', error);
throw error;
}
}, []);
// 7. Client typing emits
const emitTypingStart = useCallback(() => {
if (socketRef.current && ticketId) {
socketRef.current.emit('typing_start', { ticketId });
}
}, [ticketId]);
const emitTypingStop = useCallback(() => {
if (socketRef.current && ticketId) {
socketRef.current.emit('typing_stop', { ticketId });
}
}, [ticketId]);
// 8. Socket Connection and Event Listeners Lifecycle
useEffect(() => {
if (!ticketId) {
if (socketRef.current) {
socketRef.current.disconnect();
socketRef.current = null;
setSocketConnected(false);
}
return;
}
let active = true;
const setupSocket = async () => {
const token = await tokenManager.getAccessToken();
if (!token || !active) return;
const socket = io(SUPPORT_SOCKET_URL, {
auth: { token },
transports: ['websocket'],
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 2000,
});
socketRef.current = socket;
socket.on('connect', () => {
if (!active) return;
setSocketConnected(true);
console.log('[useCustomerSupport Socket] Connected to /support');
socket.emit('join_ticket_room', { ticketId });
});
socket.on('disconnect', (reason) => {
if (!active) return;
setSocketConnected(false);
console.log('[useCustomerSupport Socket] Disconnected:', reason);
});
socket.on('support:joined', (data) => {
console.log('[useCustomerSupport Socket] Joined room status:', data);
});
socket.on('support:new_message', (msg: SupportMessage) => {
if (!active) return;
if (msg.ticketId === ticketId) {
setMessages(prev => {
if (prev.some(m => m.id === msg.id)) return prev;
return [...prev, msg];
});
}
});
socket.on('support:status_changed', (data: { ticketId: string; status: any }) => {
if (!active) return;
if (data.ticketId === ticketId) {
setCurrentTicket(prev => prev ? { ...prev, status: data.status } : null);
}
});
socket.on('support:typing_start', (data: { ticketId: string; role: string }) => {
if (!active) return;
if (data.ticketId === ticketId && data.role === 'ADMIN') {
setIsAgentTyping(true);
}
});
socket.on('support:typing_stop', (data: { ticketId: string }) => {
if (!active) return;
if (data.ticketId === ticketId) {
setIsAgentTyping(false);
}
});
};
setupSocket();
return () => {
active = false;
if (socketRef.current) {
socketRef.current.emit('leave_ticket_room', { ticketId });
socketRef.current.off('connect');
socketRef.current.off('disconnect');
socketRef.current.off('support:joined');
socketRef.current.off('support:new_message');
socketRef.current.off('support:status_changed');
socketRef.current.off('support:typing_start');
socketRef.current.off('support:typing_stop');
socketRef.current.disconnect();
socketRef.current = null;
setSocketConnected(false);
}
};
}, [ticketId]);
return {
tickets,
currentTicket,
messages,
loading,
isAgentTyping,
socketConnected,
fetchTickets,
fetchTicketDetails,
createTicket,
sendMessage,
reopenTicket,
uploadAttachment,
emitTypingStart,
emitTypingStop,
};
};

View File

@ -99,3 +99,4 @@ export * from './order';
export * from './offers';
export * from './productRating';
export * from './wallet';
export * from './support';

51
app/interfaces/support.ts Normal file
View File

@ -0,0 +1,51 @@
export type TicketCategory =
| 'ORDER'
| 'PAYMENT'
| 'PAYOUT'
| 'TECHNICAL_ISSUE'
| 'ACCOUNT'
| 'OTHER';
export type TicketPriority = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
export type TicketStatus = 'OPEN' | 'IN_PROGRESS' | 'RESOLVED' | 'CLOSED';
export interface SupportMessage {
id: string;
ticketId: string;
senderId: string;
message: string;
attachments?: string[];
sender: {
name: string;
roleEnum: 'CUSTOMER' | 'DRIVER' | 'ADMIN';
};
createdAt: string;
}
export interface SupportTicket {
id: string;
ticketNumber: string;
category: TicketCategory;
priority: TicketPriority;
status: TicketStatus;
subject: string;
description: string;
orderId?: string;
customerId: string;
createdAt: string;
updatedAt: string;
messages?: SupportMessage[];
}
export interface CreateTicketPayload {
category: TicketCategory;
priority: TicketPriority;
subject: string;
description: string;
orderId?: string;
}
export interface PostMessagePayload {
message: string;
attachments?: string[];
}

View File

@ -15,6 +15,7 @@ import {
HelpSupportScreen,
WriteReviewScreen,
OrderDetailsScreen,
SupportChatScreen,
} from '@features/screens';
import WalletScreen from '@features/screens/walletScreen/walletScreen';
@ -32,6 +33,7 @@ export type AppStackParamList = {
HelpSupportScreen: undefined;
WriteReviewScreen: { productId: string } | undefined;
OrderDetailsScreen: { orderId: string } | undefined;
SupportChatScreen: { ticketId: string };
WalletScreen: undefined;
};
@ -71,6 +73,7 @@ export const AppStack: React.FC = () => {
<Stack.Screen name="HelpSupportScreen" component={HelpSupportScreen} />
<Stack.Screen name="WriteReviewScreen" component={WriteReviewScreen} />
<Stack.Screen name="OrderDetailsScreen" component={OrderDetailsScreen} />
<Stack.Screen name="SupportChatScreen" component={SupportChatScreen} />
<Stack.Screen name="WalletScreen" component={WalletScreen} />
</Stack.Navigator>
);

View File

@ -1,6 +1,7 @@
import React from 'react';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { Text } from 'react-native';
import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons';
import {
HomeScreen,
SearchScreen,
@ -19,33 +20,58 @@ export type MainTabParamList = {
const Tab = createBottomTabNavigator<MainTabParamList>();
const tabIcons: Record<string, string> = {
HomeScreen: '🏠',
SearchScreen: '🔍',
MyOrdersScreen: '📦',
OffersScreen: '🏷️',
AccountScreen: '👤',
};
const renderTabIcon = (routeName: string, focused: boolean) => (
<Text style={{ fontSize: 22, opacity: focused ? 1 : 0.5 }}>
{tabIcons[routeName]}
</Text>
);
export const MainTabNavigator: React.FC = () => {
return (
<Tab.Navigator
screenOptions={({ route }) => ({
headerShown: false,
tabBarIcon: ({ focused }) => renderTabIcon(route.name, focused),
tabBarIcon: ({ color, size, focused }) => {
let iconName: string;
switch (route.name) {
case 'HomeScreen':
iconName = focused ? 'home' : 'home-outline';
break;
case 'SearchScreen':
iconName = 'magnify';
break;
case 'MyOrdersScreen':
iconName = focused ? 'package-variant' : 'package-variant-closed';
break;
case 'OffersScreen':
iconName = focused ? 'tag' : 'tag-outline';
break;
case 'AccountScreen':
iconName = focused ? 'account' : 'account-outline';
break;
default:
iconName = 'circle';
}
return (
<MaterialCommunityIcons
name={iconName}
size={size}
color={color}
/>
);
},
tabBarActiveTintColor: '#05824C',
tabBarInactiveTintColor: '#666666',
tabBarStyle: {
height: 65,
paddingBottom: 8,
paddingTop: 8,
height: 60,
},
tabBarLabelStyle: {
fontSize: 11,
fontWeight: '500',
@ -57,21 +83,25 @@ export const MainTabNavigator: React.FC = () => {
component={HomeScreen}
options={{ tabBarLabel: 'Home' }}
/>
<Tab.Screen
name="SearchScreen"
component={SearchScreen}
options={{ tabBarLabel: 'Search' }}
/>
<Tab.Screen
name="MyOrdersScreen"
component={MyOrdersScreen}
options={{ tabBarLabel: 'Orders' }}
/>
<Tab.Screen
name="OffersScreen"
component={OffersScreen}
options={{ tabBarLabel: 'Offers' }}
/>
<Tab.Screen
name="AccountScreen"
component={AccountScreen}

View File

@ -30,6 +30,7 @@
"react-native-screens": "^4.25.2",
"react-native-svg": "^15.15.5",
"react-native-uuid": "^2.0.4",
"react-native-vector-icons": "^10.3.0",
"react-native-worklets": "^0.10.0",
"react-redux": "^9.3.0",
"reactotron-react-native": "^5.2.0",

View File

@ -2686,6 +2686,15 @@ cliui@^6.0.0:
strip-ansi "^6.0.0"
wrap-ansi "^6.2.0"
cliui@^7.0.2:
version "7.0.4"
resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f"
integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==
dependencies:
string-width "^4.2.0"
strip-ansi "^6.0.0"
wrap-ansi "^7.0.0"
cliui@^8.0.1:
version "8.0.1"
resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa"
@ -5731,7 +5740,7 @@ prompts@^2.0.1, prompts@^2.4.2:
kleur "^3.0.3"
sisteransi "^1.0.5"
prop-types@^15.8.1:
prop-types@^15.7.2, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
@ -5894,6 +5903,14 @@ react-native-uuid@^2.0.4:
resolved "https://registry.yarnpkg.com/react-native-uuid/-/react-native-uuid-2.0.4.tgz#0c4f345523c5feac0282d3a51fe19887727df988"
integrity sha512-LSJNeh559qC17fgVPBsWuTSW/OygFp2dwTcf94IQBLYft5FzIQS9pCsuT36OPvyvDOMb6yiGr6TafaJDnz9PPQ==
react-native-vector-icons@^10.3.0:
version "10.3.0"
resolved "https://registry.yarnpkg.com/react-native-vector-icons/-/react-native-vector-icons-10.3.0.tgz#de440f2627a2ed1079ce3b99d5b9d4f86894df28"
integrity sha512-IFQ0RE57819hOUdFvgK4FowM5aMXg7C7XKsuGLevqXkkIJatc3QopN0wYrb2IrzUgmdpfP+QVIbI3S6h7M0btw==
dependencies:
prop-types "^15.7.2"
yargs "^16.1.1"
react-native-worklets@^0.10.0:
version "0.10.1"
resolved "https://registry.yarnpkg.com/react-native-worklets/-/react-native-worklets-0.10.1.tgz#7a3038a3b5ec66cab030a00e1568b6599696f5f2"
@ -7064,6 +7081,11 @@ yargs-parser@^18.1.2:
camelcase "^5.0.0"
decamelize "^1.2.0"
yargs-parser@^20.2.2:
version "20.2.9"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee"
integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==
yargs-parser@^21.1.1:
version "21.1.1"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35"
@ -7086,6 +7108,19 @@ yargs@^15.1.0:
y18n "^4.0.0"
yargs-parser "^18.1.2"
yargs@^16.1.1:
version "16.2.2"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.2.tgz#c56731dca0d2788ae0866dd3c83907d6bab85f7d"
integrity sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==
dependencies:
cliui "^7.0.2"
escalade "^3.1.1"
get-caller-file "^2.0.5"
require-directory "^2.1.1"
string-width "^4.2.0"
y18n "^5.0.5"
yargs-parser "^20.2.2"
yargs@^17.3.1, yargs@^17.6.2:
version "17.7.3"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.3.tgz#779dffe6bcafec596a7172e983289a588647faaa"