feat: implement real-time customer support chat system with socket.io integration
This commit is contained in:
parent
eecbf9484c
commit
495c1f5f6b
@ -117,3 +117,5 @@ dependencies {
|
||||
implementation jscFlavor
|
||||
}
|
||||
}
|
||||
|
||||
apply from: file("../../node_modules/react-native-vector-icons/fonts.gradle")
|
||||
@ -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
48
app/api/supportApi.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
@ -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 */}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@ -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,39 +29,297 @@ 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();
|
||||
|
||||
// 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}>
|
||||
<Text style={styles.faqQuestion}>{faq.q}</Text>
|
||||
<Text style={styles.faqAnswer}>{faq.a}</Text>
|
||||
</View>
|
||||
))}
|
||||
|
||||
<Text style={styles.sectionTitle}>Contact Support</Text>
|
||||
<TouchableOpacity style={styles.supportCard} activeOpacity={0.7}>
|
||||
<Text style={styles.supportIcon}>📞</Text>
|
||||
<View>
|
||||
<Text style={styles.supportLabel}>Call us</Text>
|
||||
<Text style={styles.supportValue}>+91 1800 123 4567</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.supportCard} activeOpacity={0.7}>
|
||||
<Text style={styles.supportIcon}>✉️</Text>
|
||||
<View>
|
||||
<Text style={styles.supportLabel}>Email us</Text>
|
||||
<Text style={styles.supportValue}>support@sgdelivery.com</Text>
|
||||
</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()} />
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
<Text style={styles.sectionTitle}>Frequently Asked Questions</Text>
|
||||
{FAQS.map((faq, index) => (
|
||||
<View key={index} style={styles.faqItem}>
|
||||
<Text style={styles.faqQuestion}>{faq.q}</Text>
|
||||
<Text style={styles.faqAnswer}>{faq.a}</Text>
|
||||
</View>
|
||||
))}
|
||||
|
||||
<Text style={styles.sectionTitle}>Contact Support</Text>
|
||||
<TouchableOpacity style={styles.supportCard} activeOpacity={0.7}>
|
||||
<Text style={styles.supportIcon}>📞</Text>
|
||||
<View>
|
||||
<Text style={styles.supportLabel}>Call us</Text>
|
||||
<Text style={styles.supportValue}>+91 1800 123 4567</Text>
|
||||
<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>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.supportCard} activeOpacity={0.7}>
|
||||
<Text style={styles.supportIcon}>✉️</Text>
|
||||
<View>
|
||||
<Text style={styles.supportLabel}>Email us</Text>
|
||||
<Text style={styles.supportValue}>support@sgdelivery.com</Text>
|
||||
</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>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
export default HelpSupportScreen;
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export * from './helpSupportScreen';
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -22,3 +22,4 @@ export * from './accountScreen';
|
||||
export * from './writeReviewScreen';
|
||||
export * from './orderDetailsScreen';
|
||||
export * from './walletScreen';
|
||||
export * from './supportChatScreen'
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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);
|
||||
@ -150,15 +150,15 @@ export const ProviderDetailsScreen: React.FC = () => {
|
||||
const images = product?.media?.length
|
||||
? product.media
|
||||
: [
|
||||
{
|
||||
id: 'fallback',
|
||||
url: product?.imageUrl || '',
|
||||
mediaType: 'IMAGE',
|
||||
sortOrder: 0,
|
||||
productId: '',
|
||||
createdAt: '',
|
||||
},
|
||||
];
|
||||
{
|
||||
id: 'fallback',
|
||||
url: product?.imageUrl || '',
|
||||
mediaType: 'IMAGE',
|
||||
sortOrder: 0,
|
||||
productId: '',
|
||||
createdAt: '',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<View style={styles.carouselWrap}>
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -342,9 +342,9 @@ export const ProviderDetailsScreen: React.FC = () => {
|
||||
<Text style={styles.reviewDate}>
|
||||
{review.createdAt
|
||||
? new Date(review.createdAt).toLocaleDateString('en-IN', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})
|
||||
: ''}
|
||||
</Text>
|
||||
</View>
|
||||
@ -500,8 +500,8 @@ export const ProviderDetailsScreen: React.FC = () => {
|
||||
{!product
|
||||
? 'Loading...'
|
||||
: product.isTrackStock && product.stockQuantity === 0
|
||||
? 'Out of Stock'
|
||||
: 'Add to Cart'}
|
||||
? 'Out of Stock'
|
||||
: 'Add to Cart'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
|
||||
1
app/features/screens/supportChatScreen/index.ts
Normal file
1
app/features/screens/supportChatScreen/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './supportChatScreen';
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
373
app/features/screens/supportChatScreen/supportChatScreen.tsx
Normal file
373
app/features/screens/supportChatScreen/supportChatScreen.tsx
Normal 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;
|
||||
@ -0,0 +1 @@
|
||||
export * from './useCustomerSupport';
|
||||
246
app/hooks/useCustomerSupport.ts
Normal file
246
app/hooks/useCustomerSupport.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@ -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
51
app/interfaces/support.ts
Normal 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[];
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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",
|
||||
|
||||
37
yarn.lock
37
yarn.lock
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user