Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions __tests__/commerce/order-chat.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> = {}) => ({ 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);
});
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 44 additions & 0 deletions src/commerce/http.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> };

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, any>): 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<string, any>; body?: Record<string, any>; headers?: Record<string, string> } = {}): Promise<any> {
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);
}
118 changes: 118 additions & 0 deletions src/commerce/order-chat.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>; body?: Record<string, any> }) => Promise<any>;

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<string, ChatMessage>();
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<OrderChat> {
return parseChat(await request(`orders/${encodeURIComponent(orderId)}/chat`));
}

export async function fetchOlderMessages(request: ChatRequest, orderId: string, beforeId: string, limit = 30): Promise<ChatMessage[]> {
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<ChatMessage> {
const body: Record<string, any> = {};
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<void> {
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;
}
1 change: 1 addition & 0 deletions src/extensions/screens/default-screens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') },
Expand Down
1 change: 1 addition & 0 deletions src/extensions/screens/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/extensions/screens/screen-ids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
20 changes: 20 additions & 0 deletions src/hooks/use-chat-request.ts
Original file line number Diff line number Diff line change
@@ -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<ChatRequest>(
(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]
);
}
Loading