diff --git a/__tests__/commerce/order-chat.test.ts b/__tests__/commerce/order-chat.test.ts new file mode 100644 index 00000000..3fd46d45 --- /dev/null +++ b/__tests__/commerce/order-chat.test.ts @@ -0,0 +1,104 @@ +import { ApiError, apiRequest } from '../../src/commerce/http'; +import { chatDriver, fetchChat, fetchOlderMessages, markChatRead, mergeMessages, parseChat, parseChatMessage, sendChatMessage } from '../../src/commerce/order-chat'; + +const message = (id: string, createdAt: string | null, extra: Record = {}) => ({ id, content: `m-${id}`, is_mine: false, sender: { name: 'Ravi K.' }, attachments: [], read: false, created_at: createdAt, ...extra }); + +const chatJson = { + id: 'chat_1', + channel: 'chat.chat_1', + order: 'order_1', + status: 'open', + participants: [ + { id: 'p1', role: 'customer', name: 'Ada B.', avatar_url: null, is_online: true }, + { id: 'p2', role: 'driver', name: 'Ravi K.', avatar_url: 'https://cdn.test/r.jpg', is_online: false }, + ], + messages: [message('m1', '2026-10-07T06:00:00Z'), message('', null)], + unread_count: 2, +}; + +describe('order chat', () => { + test('parses the chat and its messages', () => { + const chat = parseChat(chatJson); + expect(chat).toMatchObject({ id: 'chat_1', channel: 'chat.chat_1', orderId: 'order_1', status: 'open', unread: 2 }); + expect(chat.messages.map((entry) => entry.id)).toEqual(['m1']); + expect(chatDriver(chat)).toEqual({ id: 'p2', role: 'driver', name: 'Ravi K.', avatarUrl: 'https://cdn.test/r.jpg', online: false }); + expect(chatDriver(null)).toBeNull(); + expect(parseChat({ status: 'closed', participants: [{ role: 'x' }] })).toMatchObject({ status: 'closed', channel: null, unread: 0, participants: [{ id: '', role: 'driver', name: null, avatarUrl: null, online: false }] }); + expect(parseChatMessage({ id: 'm2', is_mine: true, read: true, attachments: [{ id: 'f1', url: 'https://cdn.test/p.jpg', type: 'image/jpeg' }, { id: 'f2' }, { url: 'https://cdn.test/q.jpg' }] })).toEqual({ + id: 'm2', + content: '', + mine: true, + senderName: null, + attachments: [ + { id: 'f1', url: 'https://cdn.test/p.jpg', type: 'image/jpeg' }, + { id: 'https://cdn.test/q.jpg', url: 'https://cdn.test/q.jpg', type: null }, + ], + read: true, + createdAt: null, + }); + }); + + test('merges messages, keeping local ones last until confirmed', () => { + const a = parseChatMessage(message('a', '2026-10-07T06:00:00Z')); + const b = parseChatMessage(message('b', '2026-10-07T06:01:00Z')); + const pending = { ...parseChatMessage(message('local-1', null)), state: 'sending' as const }; + const merged = mergeMessages([b, pending], [a, { ...b, read: true }]); + expect(merged.map((entry) => entry.id)).toEqual(['a', 'b', 'local-1']); + expect(merged[1].read).toBe(true); + expect(mergeMessages([], [parseChatMessage(message('x', null)), a]).map((entry) => entry.id)).toEqual(['a', 'x']); + }); + + test('calls the chat endpoints', async () => { + const request = jest.fn(); + request.mockResolvedValueOnce(chatJson); + expect((await fetchChat(request, 'order 1')).id).toBe('chat_1'); + request.mockResolvedValueOnce({ messages: [message('m0', '2026-10-07T05:00:00Z'), message('', null)] }); + expect((await fetchOlderMessages(request, 'order_1', 'm1')).map((entry) => entry.id)).toEqual(['m0']); + request.mockResolvedValueOnce(null); + expect(await fetchOlderMessages(request, 'order_1', 'm1', 10)).toEqual([]); + request.mockResolvedValueOnce({ message: message('m9', '2026-10-07T06:05:00Z', { is_mine: true }) }); + expect((await sendChatMessage(request, 'order_1', { content: ' Tower B ', photos: [1, 2, 3, 4, 5].map((n) => ({ data: `d${n}`, type: 'image/jpeg' })) })).mine).toBe(true); + request.mockResolvedValueOnce({ message: message('m10', null) }); + await sendChatMessage(request, 'order_1', {}); + request.mockResolvedValueOnce({ read: 2 }); + await markChatRead(request, 'order_1'); + expect(request.mock.calls).toEqual([ + ['orders/order%201/chat'], + ['orders/order_1/chat/messages', { query: { before: 'm1', limit: 30 } }], + ['orders/order_1/chat/messages', { query: { before: 'm1', limit: 10 } }], + ['orders/order_1/chat/messages', { method: 'POST', body: { content: 'Tower B', files: [1, 2, 3, 4].map((n) => ({ data: `d${n}`, type: 'image/jpeg' })) } }], + ['orders/order_1/chat/messages', { method: 'POST', body: {} }], + ['orders/order_1/chat/read', { method: 'POST' }], + ]); + }); +}); + +describe('apiRequest', () => { + const original = (globalThis as any).fetch; + afterEach(() => { + (globalThis as any).fetch = original; + }); + const target = { host: 'https://api.test', namespace: 'storefront/v1', headers: { Authorization: 'Bearer key' } }; + + test('sends JSON and returns the body', async () => { + (globalThis as any).fetch = jest.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ ok: 1 }) }); + expect(await apiRequest(target, 'orders/1/chat', { query: { before: 'm1', limit: 30, empty: '', none: null }, headers: { 'Customer-Token': 't' } })).toEqual({ ok: 1 }); + expect((globalThis as any).fetch).toHaveBeenCalledWith('https://api.test/storefront/v1/orders/1/chat?before=m1&limit=30', { method: 'GET', headers: { Authorization: 'Bearer key', 'Customer-Token': 't', Accept: 'application/json' } }); + await apiRequest(target, 'orders/1/chat/messages', { method: 'POST', body: { content: 'hi' } }); + expect((globalThis as any).fetch).toHaveBeenLastCalledWith('https://api.test/storefront/v1/orders/1/chat/messages', { method: 'POST', headers: { Authorization: 'Bearer key', Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ content: 'hi' }) }); + }); + + test('keeps the status and reason of refusals', async () => { + (globalThis as any).fetch = jest.fn().mockResolvedValue({ ok: false, status: 409, statusText: 'Conflict', json: () => Promise.resolve({ error: 'No driver yet', reason: 'driver_not_assigned' }) }); + await expect(apiRequest(target, 'x')).rejects.toMatchObject({ name: 'ApiError', status: 409, reason: 'driver_not_assigned', message: 'No driver yet' }); + (globalThis as any).fetch = jest.fn().mockResolvedValue({ ok: false, status: 500, statusText: 'Server Error', json: () => Promise.reject(new Error('html')) }); + await expect(apiRequest(target, 'x')).rejects.toMatchObject({ status: 500, reason: null, message: 'Server Error' }); + (globalThis as any).fetch = jest.fn().mockResolvedValue({ ok: false, status: 403, json: () => Promise.resolve({ errors: ['Not yours'] }) }); + await expect(apiRequest(target, 'x')).rejects.toMatchObject({ status: 403, message: 'Not yours' }); + (globalThis as any).fetch = jest.fn().mockRejectedValue(new Error('offline')); + await expect(apiRequest(target, 'x')).rejects.toMatchObject({ status: 0, reason: 'network', message: 'offline' }); + (globalThis as any).fetch = jest.fn().mockRejectedValue({}); + await expect(apiRequest(target, 'x')).rejects.toMatchObject({ reason: 'network', message: 'Network request failed' }); + await expect(apiRequest({}, 'x')).rejects.toBeInstanceOf(ApiError); + }); +}); diff --git a/package.json b/package.json index c58993c7..0a6e5939 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "test": "jest", "test:ci": "jest --runInBand", "lint": "eslint .", - "lint:strict": "eslint src/ui storefront.extensions.ts storefront.brand.ts src/env.ts src/branding src/hooks/use-app-theme.ts src/contexts/ThemeContext.tsx src/extensions src/navigation/NetworkNavigator.tsx src/navigation/AppNavigator.tsx src/network src/screens/network src/navigation/NetworkNavigator.tsx src/contexts/StorefrontRuntimeContext.tsx src/contexts/CartContext.tsx src/screens/BootScreen.tsx src/screens/LocationPermissionScreen.tsx src/commerce src/components/checkout src/components/booking src/hooks/use-cart-promotions.ts src/hooks/use-review-request.ts src/screens/reviews src/screens/offers src/components/offers src/screens/notifications src/hooks/use-customer-request.ts src/hooks/use-open-notification.ts src/hooks/use-unread-notifications.ts src/hooks/use-footer-offset.ts src/screens/CartScreen.tsx src/screens/OrderScreen.tsx src/screens/StripeCheckoutScreen.tsx src/screens/QPayCheckoutScreen.tsx web/react-native-mmkv-storage.js web/react-native-device-info.web.js __tests__", + "lint:strict": "eslint src/ui storefront.extensions.ts storefront.brand.ts src/env.ts src/branding src/hooks/use-app-theme.ts src/contexts/ThemeContext.tsx src/extensions src/navigation/NetworkNavigator.tsx src/navigation/AppNavigator.tsx src/network src/screens/network src/navigation/NetworkNavigator.tsx src/contexts/StorefrontRuntimeContext.tsx src/contexts/CartContext.tsx src/screens/BootScreen.tsx src/screens/LocationPermissionScreen.tsx src/commerce src/components/checkout src/components/booking src/hooks/use-cart-promotions.ts src/hooks/use-review-request.ts src/screens/reviews src/screens/offers src/components/offers src/screens/notifications src/screens/chat src/hooks/use-chat-request.ts src/hooks/use-customer-request.ts src/hooks/use-open-notification.ts src/hooks/use-unread-notifications.ts src/hooks/use-footer-offset.ts src/screens/CartScreen.tsx src/screens/OrderScreen.tsx src/screens/StripeCheckoutScreen.tsx src/screens/QPayCheckoutScreen.tsx web/react-native-mmkv-storage.js web/react-native-device-info.web.js __tests__", "typecheck:strict": "tsc --noEmit -p tsconfig.strict.json", "format": "prettier --write .", "tamagui:build": "./node_modules/.bin/tamagui-build", diff --git a/src/commerce/http.ts b/src/commerce/http.ts new file mode 100644 index 00000000..125573de --- /dev/null +++ b/src/commerce/http.ts @@ -0,0 +1,44 @@ +/** + * Storefront API requests that keep the error body. The SDK adapter rejects with only + * `errors[0]` or the status text, but some endpoints explain refusals as + * `{ error, reason }` with a meaningful status (409, 423, …) the app needs to show. + */ + +export type ApiTarget = { host?: string; namespace?: string; headers?: Record }; + +export class ApiError extends Error { + status: number; + reason: string | null; + + constructor(message: string, status: number, reason: string | null) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.reason = reason; + } +} + +function queryString(query: Record): string { + const params = Object.entries(query) + .filter(([, value]) => value !== undefined && value !== null && value !== '') + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + return params.length ? `?${params.join('&')}` : ''; +} + +export async function apiRequest(target: ApiTarget, path: string, { method = 'GET', query = {}, body, headers = {} }: { method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; query?: Record; body?: Record; headers?: Record } = {}): Promise { + if (!target.host || !target.namespace) throw new ApiError('Storefront is not ready', 0, null); + let response: Response; + try { + response = await fetch(`${target.host}/${target.namespace}/${path}${queryString(query)}`, { + method, + headers: { ...(target.headers ?? {}), ...headers, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + } catch (error: any) { + throw new ApiError(error?.message ?? 'Network request failed', 0, 'network'); + } + const json = await response.json().catch(() => null); + if (response.ok) return json; + const message = json?.error ?? json?.errors?.[0] ?? response.statusText ?? 'Request failed'; + throw new ApiError(String(message), response.status, typeof json?.reason === 'string' ? json.reason : null); +} diff --git a/src/commerce/order-chat.ts b/src/commerce/order-chat.ts new file mode 100644 index 00000000..c8fbb6e8 --- /dev/null +++ b/src/commerce/order-chat.ts @@ -0,0 +1,118 @@ +/** + * Chat between a customer and the driver delivering their order + * (storefront/v1/orders/{id}/chat), sent with the customer token: + * + * - GET orders/{id}/chat the chat with its latest messages + * 409 driver_not_assigned no driver yet + * - GET orders/{id}/chat/messages?before&limit older messages + * - POST orders/{id}/chat/messages { content?, files? } 423 chat_closed once the order ends + * - POST orders/{id}/chat/read mark the driver's messages read + * + * New messages arrive on the chat's socket channel (`chat.{id}`) as `chat_message.*` + * events; the app re-reads the latest page when one arrives. + */ + +export type ChatRequest = (path: string, options?: { method?: 'GET' | 'POST'; query?: Record; body?: Record }) => Promise; + +export type ChatAttachment = { id: string; url: string; type: string | null }; + +export type ChatMessage = { + id: string; + content: string; + mine: boolean; + senderName: string | null; + attachments: ChatAttachment[]; + read: boolean; + createdAt: string | null; + /** Sent from this device and not confirmed yet, or failed to send. */ + state?: 'sending' | 'failed'; +}; + +export type ChatParticipant = { id: string; role: 'customer' | 'driver'; name: string | null; avatarUrl: string | null; online: boolean }; + +export type OrderChat = { + id: string; + channel: string | null; + orderId: string | null; + status: 'open' | 'closed'; + participants: ChatParticipant[]; + messages: ChatMessage[]; + unread: number; +}; + +export const QUICK_REPLIES = ['comingDown', 'leaveAtDoor', 'callOnArrival', 'whereAreYou'] as const; +export const MAX_CHAT_PHOTOS = 4; + +export function parseChatMessage(json: any): ChatMessage { + return { + id: String(json?.id ?? ''), + content: String(json?.content ?? ''), + mine: json?.is_mine === true, + senderName: json?.sender?.name ?? null, + attachments: (Array.isArray(json?.attachments) ? json.attachments : []) + .filter((attachment: any) => typeof attachment?.url === 'string') + .map((attachment: any) => ({ id: String(attachment.id ?? attachment.url), url: attachment.url, type: attachment.type ?? null })), + read: json?.read === true, + createdAt: json?.created_at ?? null, + }; +} + +export function parseChat(json: any): OrderChat { + return { + id: String(json?.id ?? ''), + channel: typeof json?.channel === 'string' ? json.channel : null, + orderId: json?.order ?? null, + status: json?.status === 'closed' ? 'closed' : 'open', + participants: (Array.isArray(json?.participants) ? json.participants : []).map((participant: any) => ({ + id: String(participant?.id ?? ''), + role: participant?.role === 'customer' ? 'customer' : 'driver', + name: participant?.name ?? null, + avatarUrl: participant?.avatar_url ?? null, + online: participant?.is_online === true, + })), + messages: (Array.isArray(json?.messages) ? json.messages : []).map(parseChatMessage).filter((message: ChatMessage) => message.id), + unread: Math.max(0, Number(json?.unread_count) || 0), + }; +} + +const time = (message: ChatMessage) => (message.createdAt ? new Date(message.createdAt).getTime() : Infinity); + +/** + * Messages merged by id and ordered oldest first. Server copies replace local ones; + * messages still sending or failed stay at the end until they are confirmed. + */ +export function mergeMessages(current: ChatMessage[], incoming: ChatMessage[]): ChatMessage[] { + const byId = new Map(); + for (const message of current) byId.set(message.id, message); + for (const message of incoming) byId.set(message.id, message); + const all = [...byId.values()]; + const confirmed = all.filter((message) => !message.state).sort((a, b) => time(a) - time(b)); + const local = all.filter((message) => message.state); + return [...confirmed, ...local]; +} + +export async function fetchChat(request: ChatRequest, orderId: string): Promise { + return parseChat(await request(`orders/${encodeURIComponent(orderId)}/chat`)); +} + +export async function fetchOlderMessages(request: ChatRequest, orderId: string, beforeId: string, limit = 30): Promise { + const json = await request(`orders/${encodeURIComponent(orderId)}/chat/messages`, { query: { before: beforeId, limit } }); + return (Array.isArray(json?.messages) ? json.messages : []).map(parseChatMessage).filter((message: ChatMessage) => message.id); +} + +export async function sendChatMessage(request: ChatRequest, orderId: string, { content = '', photos = [] }: { content?: string; photos?: { data: string; type: string }[] }): Promise { + const body: Record = {}; + if (content.trim()) body.content = content.trim().slice(0, 2000); + if (photos.length) body.files = photos.slice(0, MAX_CHAT_PHOTOS); + const json = await request(`orders/${encodeURIComponent(orderId)}/chat/messages`, { method: 'POST', body }); + return parseChatMessage(json?.message); +} + +export async function markChatRead(request: ChatRequest, orderId: string): Promise { + await request(`orders/${encodeURIComponent(orderId)}/chat/read`, { method: 'POST' }); +} + +/** The driver taking part in the chat, if any. */ +export function chatDriver(chat: OrderChat | null): ChatParticipant | null { + return chat?.participants.find((participant) => participant.role === 'driver') ?? null; +} diff --git a/src/extensions/screens/default-screens.ts b/src/extensions/screens/default-screens.ts index 27d10017..0049933a 100644 --- a/src/extensions/screens/default-screens.ts +++ b/src/extensions/screens/default-screens.ts @@ -34,6 +34,7 @@ export const defaultScreens: ScreenRegistryDefinition = { 'network.product': { load: () => import('../../screens/network/NetworkProductScreen') }, 'reviews.list': { load: () => import('../../screens/reviews/StoreReviewsScreen') }, 'reviews.write': { load: () => import('../../screens/reviews/WriteReviewScreen') }, + 'order.chat': { load: () => import('../../screens/chat/OrderChatScreen') }, 'notifications.inbox': { load: () => import('../../screens/notifications/NotificationsScreen') }, 'notifications.settings': { load: () => import('../../screens/notifications/NotificationSettingsScreen') }, 'offers.list': { load: () => import('../../screens/offers/OffersScreen') }, diff --git a/src/extensions/screens/params.ts b/src/extensions/screens/params.ts index d590afe4..a8de7dc7 100644 --- a/src/extensions/screens/params.ts +++ b/src/extensions/screens/params.ts @@ -32,6 +32,7 @@ export type ScreenParamMap = { 'network.store': { storeId: string; store?: Serialized }; 'network.product': { storeId?: string; productId: string; product?: Serialized; store?: Serialized }; 'reviews.list': { storeId: string; storeName?: string; storeLogo?: string | null }; + 'order.chat': { orderId: string; orderReference?: string; storeName?: string; driverName?: string; driverPhone?: string }; 'notifications.inbox': undefined; 'notifications.settings': undefined; 'offers.list': { storeId?: string } | undefined; diff --git a/src/extensions/screens/screen-ids.ts b/src/extensions/screens/screen-ids.ts index eedcef4d..95461278 100644 --- a/src/extensions/screens/screen-ids.ts +++ b/src/extensions/screens/screen-ids.ts @@ -37,6 +37,7 @@ export const SCREEN_IDS = [ 'offers.detail', 'notifications.inbox', 'notifications.settings', + 'order.chat', ] as const; export type ScreenId = (typeof SCREEN_IDS)[number]; diff --git a/src/hooks/use-chat-request.ts b/src/hooks/use-chat-request.ts new file mode 100644 index 00000000..6651c0ae --- /dev/null +++ b/src/hooks/use-chat-request.ts @@ -0,0 +1,20 @@ +import { useCallback } from 'react'; +import useStorefront from './use-storefront'; +import { useAuth } from '../contexts/AuthContext'; +import { apiRequest } from '../commerce/http'; +import type { ChatRequest } from '../commerce/order-chat'; + +/** Order chat requests as the signed-in customer, keeping refusal reasons (409, 423 …). */ +export default function useChatRequest(): ChatRequest { + const { storefront } = useStorefront(); + const { customer } = useAuth(); + + return useCallback( + (path, options = {}) => { + const adapter = storefront?.getAdapter?.() ?? {}; + const token = customer?.token ?? customer?.getAttribute?.('token'); + return apiRequest({ host: adapter.host, namespace: adapter.namespace, headers: adapter.headers }, path, { ...options, headers: token ? { 'Customer-Token': token } : {} }); + }, + [customer, storefront] + ); +} diff --git a/src/navigation/stacks/OrderStack.tsx b/src/navigation/stacks/OrderStack.tsx index 56629c06..f9de2293 100644 --- a/src/navigation/stacks/OrderStack.tsx +++ b/src/navigation/stacks/OrderStack.tsx @@ -14,6 +14,8 @@ export const Order = { export const StoreReviews = { screen: screenSlot('reviews.list'), options: { headerShown: false } }; export const WriteReview = { screen: screenSlot('reviews.write'), options: { presentation: 'modal', headerShown: false } }; +export const OrderChat = { screen: screenSlot('order.chat'), options: { headerShown: false } }; + export const Receipt = { screen: screenSlot('order.receipt'), options: ({ navigation, route }) => { @@ -65,6 +67,7 @@ const OrderStack = { OrderHistory, StoreReviews, WriteReview, + OrderChat, }; export default OrderStack; diff --git a/src/screens/OrderScreen.tsx b/src/screens/OrderScreen.tsx index b287fdd9..d2b89c5f 100644 --- a/src/screens/OrderScreen.tsx +++ b/src/screens/OrderScreen.tsx @@ -1,11 +1,13 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Image, Linking, RefreshControl, ScrollView } from 'react-native'; +import { Image, Linking, Pressable, RefreshControl, ScrollView } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; -import { faCheck, faPhone, faReceipt, faStar, faXmark } from '@fortawesome/free-solid-svg-icons'; +import { faCheck, faComment, faPhone, faReceipt, faStar, faXmark } from '@fortawesome/free-solid-svg-icons'; import { parseScheduledAt } from '../commerce/booking'; import { fetchEligibility, type Eligibility } from '../commerce/reviews'; +import { fetchChat, type OrderChat } from '../commerce/order-chat'; +import useChatRequest from '../hooks/use-chat-request'; import useReviewRequest from '../hooks/use-review-request'; import { XStack, YStack, useTheme } from 'tamagui'; import { Order } from '@fleetbase/sdk'; @@ -56,6 +58,8 @@ const OrderScreen = ({ route }: any) => { const [pickupSheet, setPickupSheet] = useState(false); const [reviewState, setReviewState] = useState(null); const reviewRequest = useReviewRequest(); + const chatRequest = useChatRequest(); + const [chat, setChat] = useState(null); const storeId = useMemo(() => order.getAttribute('meta.storefront_id'), [order]); const [store, setStore] = useStorage(`${storeId}`, info); @@ -200,6 +204,28 @@ const OrderScreen = ({ route }: any) => { }; }, [customer, finished, order.id, reviewRequest, storeId]); + // The driver chat: its unread count while the order is active, its history after. + const hasDriver = !!order.getAttribute('driver_assigned') || !!order.getAttribute('driver_assigned_uuid'); + useEffect(() => { + if (!customer || !loaded || isPickup || (!hasDriver && !progress.finished)) return; + let active = true; + fetchChat(chatRequest, order.id) + .then((result) => active && setChat(result)) + .catch(() => active && setChat(null)); + return () => { + active = false; + }; + }, [chatRequest, customer, hasDriver, isPickup, loaded, order.id, progress.finished, status]); + + const openChat = () => + navigation.navigate('OrderChat', { + orderId: order.id, + orderReference: order.getAttribute('tracking_number.tracking_number') ?? order.id, + storeName: store?.name ?? undefined, + driverName: order.getAttribute('driver_assigned.name') ?? undefined, + driverPhone: order.getAttribute('driver_assigned.phone') ?? undefined, + }); + const close = () => { if (params.justPlaced || !navigation.canGoBack()) { navigation.navigate(mode === 'network' ? 'NetworkHomeTab' : 'StoreHomeTab'); @@ -377,7 +403,8 @@ const OrderScreen = ({ route }: any) => { )} - {!!driver?.phone && Linking.openURL(`tel:${driver.phone}`)} />} + {!!driver?.phone && Linking.openURL(`tel:${driver.phone}`)} />} + {!!customer && } )} @@ -406,6 +433,18 @@ const OrderScreen = ({ route }: any) => { ))} + {progress.finished && !!chat && chat.messages.length > 0 && ( + + + + {t('Chat.closedNote')}{' '} + + {t('Chat.viewMessages')} + + + + )} + diff --git a/src/screens/chat/OrderChatScreen.tsx b/src/screens/chat/OrderChatScreen.tsx new file mode 100644 index 00000000..046d20ac --- /dev/null +++ b/src/screens/chat/OrderChatScreen.tsx @@ -0,0 +1,276 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { FlatList, Image, KeyboardAvoidingView, Linking, Platform, Pressable, ScrollView, TextInput } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { launchImageLibrary } from 'react-native-image-picker'; +import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; +import { faArrowRotateRight, faCamera, faChevronLeft, faComments, faPaperPlane, faPhone } from '@fortawesome/free-solid-svg-icons'; +import { XStack, YStack, useTheme } from 'tamagui'; +import { useLanguage } from '../../contexts/LanguageContext'; +import { useAuth } from '../../contexts/AuthContext'; +import useChatRequest from '../../hooks/use-chat-request'; +import useSocketClusterClient from '../../hooks/use-socket-cluster-client'; +import { ApiError } from '../../commerce/http'; +import { MAX_CHAT_PHOTOS, QUICK_REPLIES, chatDriver, fetchChat, fetchOlderMessages, markChatRead, mergeMessages, sendChatMessage, type ChatMessage, type OrderChat } from '../../commerce/order-chat'; +import { shortName } from '../../commerce/order-progress'; +import { Button, EmptyState, ErrorState, IconButton, Skeleton, UIText, formatClock, initials, radius, space, usesTwelveHourClock } from '../../ui'; + +type Photo = { data: string; type: string; uri: string }; +type Row = ChatMessage & { photos?: Photo[] }; + +/** + * Real-time chat with the driver delivering an order. Messages load from the chat API, + * new ones arrive on the chat's socket channel, and sending is optimistic: a message shows + * straight away and can be retried if it fails. The chat becomes read-only once the order + * is finished. + */ +const OrderChatScreen = ({ route }: any) => { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const theme = useTheme(); + const { t, locale } = useLanguage(); + const { customer } = useAuth(); + const request = useChatRequest(); + const { listen, isConnected } = useSocketClusterClient(); + const hour12 = usesTwelveHourClock(locale); + const { orderId, orderReference, storeName, driverPhone } = route.params ?? {}; + const [chat, setChat] = useState(null); + const [messages, setMessages] = useState([]); + const [error, setError] = useState<'no_driver' | 'failed' | null>(null); + const [draft, setDraft] = useState(''); + const [loadingOlder, setLoadingOlder] = useState(false); + const [noMoreOlder, setNoMoreOlder] = useState(false); + const [closedNow, setClosedNow] = useState(false); + const listRef = useRef(null); + const counter = useRef(0); + + const refresh = useCallback(async () => { + if (!orderId) return; + try { + const next = await fetchChat(request, orderId); + setChat(next); + setMessages((current) => mergeMessages(current, next.messages)); + setError(null); + if (next.unread > 0) markChatRead(request, orderId).catch(() => {}); + } catch (failure) { + setError(failure instanceof ApiError && failure.reason === 'driver_not_assigned' ? 'no_driver' : 'failed'); + } + }, [orderId, request]); + + useEffect(() => { + refresh(); + }, [refresh]); + + // Live updates: any message or receipt event on the channel re-reads the latest page. + useEffect(() => { + if (!chat?.channel) return; + let listener: any = null; + let stopped = false; + listen(chat.channel, (event: any) => { + if (String(event?.event ?? '').startsWith('chat_')) refresh(); + }).then((subscription: any) => { + if (stopped) subscription?.stop?.(); + else listener = subscription; + }); + return () => { + stopped = true; + listener?.stop?.(); + }; + }, [chat?.channel, listen, refresh]); + + const loadOlder = async () => { + const oldest = messages.find((message) => !message.state); + if (!orderId || !oldest || loadingOlder || noMoreOlder) return; + setLoadingOlder(true); + try { + const older = await fetchOlderMessages(request, orderId, oldest.id); + setMessages((current) => mergeMessages(current, older)); + setNoMoreOlder(older.length === 0); + } catch { + setNoMoreOlder(true); + } finally { + setLoadingOlder(false); + } + }; + + const send = async (content: string, photos: Photo[] = [], retryId?: string) => { + if (!orderId || (!content.trim() && photos.length === 0)) return; + const localId = retryId ?? `local-${Date.now()}-${counter.current++}`; + const local: Row = { id: localId, content: content.trim(), mine: true, senderName: null, attachments: photos.map((photo, index) => ({ id: `${localId}-${index}`, url: photo.uri, type: photo.type })), read: false, createdAt: null, state: 'sending', photos }; + setMessages((current) => [...current.filter((message) => message.id !== localId), local]); + try { + const sent = await sendChatMessage(request, orderId, { content, photos: photos.map(({ data, type }) => ({ data, type })) }); + setMessages((current) => mergeMessages(current.filter((message) => message.id !== localId), [sent])); + } catch (failure) { + if (failure instanceof ApiError && failure.reason === 'chat_closed') { + setClosedNow(true); + setMessages((current) => current.filter((message) => message.id !== localId)); + return; + } + setMessages((current) => current.map((message) => (message.id === localId ? { ...message, state: 'failed' } : message))); + } + }; + + const submit = () => { + const text = draft; + setDraft(''); + send(text); + }; + + const pickPhotos = async () => { + const result = await launchImageLibrary({ mediaType: 'photo', includeBase64: true, selectionLimit: MAX_CHAT_PHOTOS, maxWidth: 1600, maxHeight: 1600, quality: 0.8 }); + const photos = (result.assets ?? []).filter((asset) => asset.base64 && asset.uri).map((asset) => ({ data: asset.base64!, type: asset.type ?? 'image/jpeg', uri: asset.uri! })); + if (photos.length) send('', photos); + }; + + const driver = chatDriver(chat); + const driverName = driver?.name ? shortName(driver.name) : (route.params?.driverName ?? t('Tracking.yourDriver')); + const closed = chat?.status === 'closed' || closedNow; + const data = useMemo(() => [...messages].reverse(), [messages]); + const time = (value: string | null) => { + if (!value) return ''; + const at = new Date(value); + return formatClock(at.getHours() * 60 + at.getMinutes(), hour12); + }; + + const header = ( + + navigation.goBack()} /> + + {driver?.avatarUrl ? : {initials(driverName)}} + {!!chat && } + + + + {driverName} + + + {[t('Chat.yourDriver'), orderReference ? `#${orderReference}` : null, closed ? t('Chat.closedShort') : driver?.online ? t('Chat.online') : null].filter(Boolean).join(' · ')} + + + {!!driverPhone && !closed && Linking.openURL(`tel:${driverPhone}`)} />} + + ); + + if (!customer) { + return ( + + {header} + + + ); + } + + return ( + + {header} + {!isConnected && !closed && !!chat && ( + + + + {t('Chat.reconnecting')} + + + )} + + {error === 'no_driver' ? ( + + ) : error === 'failed' && !chat ? ( + + ) : !chat ? ( + + + + + + ) : ( + + item.id} + onEndReached={loadOlder} + onEndReachedThreshold={0.3} + contentContainerStyle={{ padding: space.gutter, gap: 8 }} + accessibilityRole='list' + accessibilityLabel={t('Chat.messages')} + ListFooterComponent={ + + + {t('Chat.intro', { driver: driverName, store: storeName ?? t('StoreSwitch.thisStore') })} + + + } + renderItem={({ item }) => send(item.content, item.photos ?? [], item.id)} />} + /> + + {closed ? ( + + + {t('Chat.closedTitle')} + + + {t('Chat.closedBody', { store: storeName ?? t('StoreSwitch.thisStore') })} + + + ) : ( + + + {QUICK_REPLIES.map((key) => ( + send(t(`Chat.quick.${key}`))} accessibilityRole='button' style={{ height: 36, paddingHorizontal: 14, borderRadius: radius.pill, borderWidth: 1, borderColor: theme.borderColor.val, justifyContent: 'center' }}> + {t(`Chat.quick.${key}`)} + + ))} + + + + + + + + )} + + )} + + ); +}; + +function Bubble({ message, time, onRetry }: { message: Row; time: string; onRetry: () => void }) { + const theme = useTheme(); + const { t } = useLanguage(); + const meta = message.state === 'sending' ? t('Chat.sending') : message.state === 'failed' ? t('Chat.failed') : [time, message.mine && message.read ? t('Chat.read') : null].filter(Boolean).join(' · '); + return ( + + {message.attachments.map((attachment) => ( + + ))} + {!!message.content && ( + + {message.content} + + )} + {message.state === 'failed' ? ( + + ) : ( + + {meta} + + )} + + ); +} + +export default OrderChatScreen; diff --git a/translations/en.json b/translations/en.json index bb046d6f..d25dcf6e 100644 --- a/translations/en.json +++ b/translations/en.json @@ -1007,5 +1007,38 @@ "promotionsBody": "Deals and announcements from stores on {{name}}." }, "prefsNote": "Messages from your driver about an active order always come through. Receipts and account security emails aren’t affected by these settings." + }, + "Chat": { + "backToOrder": "Back to order tracking", + "yourDriver": "Your driver", + "online": "online", + "closedShort": "chat closed", + "messageDriver": "Message {{driver}}", + "messages": "Messages", + "intro": "{{driver}} is delivering your order from {{store}}. Messages are shared with the store if there’s a problem.", + "reconnecting": "Reconnecting… messages you send still go out, and new ones appear when you’re back online.", + "noDriverTitle": "No driver yet", + "noDriverBody": "You can message your driver once one is assigned to this order.", + "loadFailed": "Couldn’t load the chat", + "signInBody": "Sign in to message your driver.", + "quickReplies": "Quick replies", + "quick": { + "comingDown": "Coming down now", + "leaveAtDoor": "Please leave it at the door", + "callOnArrival": "Call me when you arrive", + "whereAreYou": "Where are you now?" + }, + "sendPhoto": "Send a photo", + "placeholder": "Message {{driver}}", + "send": "Send", + "sending": "Sending…", + "failed": "Not sent", + "retry": "Not sent · Tap to retry", + "read": "Read", + "photo": "Photo", + "closedTitle": "This chat closed when your order was finished", + "closedBody": "Need help with the order? Contact {{store}} from the order page.", + "closedNote": "Chat with your driver closed when the order was finished.", + "viewMessages": "View messages" } } diff --git a/translations/mn.json b/translations/mn.json index be0c38e9..bca4a428 100644 --- a/translations/mn.json +++ b/translations/mn.json @@ -1007,5 +1007,38 @@ "promotionsBody": "{{name}} дээрх дэлгүүрүүдийн урамшуулал, зарлал." }, "prefsNote": "Идэвхтэй захиалгын жолоочийн мессеж үргэлж ирнэ. Баримт болон бүртгэлийн аюулгүй байдлын имэйлд эдгээр тохиргоо нөлөөлөхгүй." + }, + "Chat": { + "backToOrder": "Захиалгын хяналт руу буцах", + "yourDriver": "Таны жолооч", + "online": "онлайн", + "closedShort": "чат хаагдсан", + "messageDriver": "{{driver}}-д бичих", + "messages": "Мессежүүд", + "intro": "{{driver}} таны захиалгыг {{store}}-с хүргэж байна. Асуудал гарвал мессежүүд дэлгүүрт харагдана.", + "reconnecting": "Дахин холбогдож байна… илгээсэн мессеж тань очих бөгөөд холбогдмогц шинэ мессежүүд харагдана.", + "noDriverTitle": "Жолооч хараахан томилогдоогүй", + "noDriverBody": "Жолооч томилогдсоны дараа түүнд бичих боломжтой.", + "loadFailed": "Чатыг ачаалж чадсангүй", + "signInBody": "Жолоочид бичихийн тулд нэвтэрнэ үү.", + "quickReplies": "Түргэн хариулт", + "quick": { + "comingDown": "Одоо буулаа", + "leaveAtDoor": "Хаалганы өмнө үлдээнэ үү", + "callOnArrival": "Ирэхдээ залгаарай", + "whereAreYou": "Та одоо хаана байна?" + }, + "sendPhoto": "Зураг илгээх", + "placeholder": "{{driver}}-д бичих", + "send": "Илгээх", + "sending": "Илгээж байна…", + "failed": "Илгээгдээгүй", + "retry": "Илгээгдээгүй · Дахин оролдох", + "read": "Уншсан", + "photo": "Зураг", + "closedTitle": "Захиалга дууссан тул чат хаагдсан", + "closedBody": "Захиалгын талаар тусламж хэрэгтэй юу? Захиалгын хуудаснаас {{store}}-тэй холбогдоно уу.", + "closedNote": "Захиалга дууссан тул жолоочтой хийсэн чат хаагдсан.", + "viewMessages": "Мессежүүдийг харах" } } diff --git a/translations/uk.json b/translations/uk.json index bc859cef..ee5971da 100644 --- a/translations/uk.json +++ b/translations/uk.json @@ -1021,5 +1021,38 @@ "promotionsBody": "Акції та оголошення магазинів на {{name}}." }, "prefsNote": "Повідомлення від водія щодо активного замовлення надходять завжди. Квитанції та листи щодо безпеки акаунта ці налаштування не змінюють." + }, + "Chat": { + "backToOrder": "Назад до відстеження", + "yourDriver": "Ваш водій", + "online": "в мережі", + "closedShort": "чат закрито", + "messageDriver": "Написати {{driver}}", + "messages": "Повідомлення", + "intro": "{{driver}} доставляє ваше замовлення з {{store}}. Якщо виникне проблема, повідомлення побачить магазин.", + "reconnecting": "Повторне підключення… надіслані повідомлення все одно підуть, а нові з’являться, коли з’єднання відновиться.", + "noDriverTitle": "Водія ще не призначено", + "noDriverBody": "Написати водієві можна, щойно його призначать на це замовлення.", + "loadFailed": "Не вдалося завантажити чат", + "signInBody": "Увійдіть, щоб написати водієві.", + "quickReplies": "Швидкі відповіді", + "quick": { + "comingDown": "Вже спускаюся", + "leaveAtDoor": "Залиште, будь ласка, біля дверей", + "callOnArrival": "Зателефонуйте, коли приїдете", + "whereAreYou": "Де ви зараз?" + }, + "sendPhoto": "Надіслати фото", + "placeholder": "Написати {{driver}}", + "send": "Надіслати", + "sending": "Надсилання…", + "failed": "Не надіслано", + "retry": "Не надіслано · Повторити", + "read": "Прочитано", + "photo": "Фото", + "closedTitle": "Чат закрито, бо замовлення завершено", + "closedBody": "Потрібна допомога із замовленням? Зв’яжіться з {{store}} на сторінці замовлення.", + "closedNote": "Чат із водієм закрито після завершення замовлення.", + "viewMessages": "Переглянути повідомлення" } }