From f40d3c0d7db34c2736f0851724bce4fba9cf99d3 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 7 Oct 2026 02:46:51 +0800 Subject: [PATCH] feat(orders): order history and receipts - Order history pins orders in progress (with a track button) above past orders, shows what was ordered, the date, item count and total, and marks canceled orders. Pages load as you scroll; the first page is kept on the device so the list opens instantly. Loading, empty and error states included. - Every order now has a receipt, not just QPay ones: store, reference, items with their options and bookings, each charge, promotions, total, how it was paid and where it went. It can be shared as text. QPay orders keep the e-barimt tax receipt as a section. - Tracking links to the receipt for every order. - Account: canceled orders no longer show as the active order. --- __tests__/commerce/order-summary.test.ts | 198 ++++++++++ package.json | 2 +- src/commerce/order-summary.ts | 184 ++++++++++ src/extensions/screens/default-screens.ts | 4 +- src/extensions/screens/params.ts | 2 +- src/navigation/stacks/OrderStack.tsx | 38 +- src/screens/OrderHistoryScreen.tsx | 188 ---------- src/screens/OrderScreen.tsx | 103 +++++- src/screens/ReceiptScreen.tsx | 416 ---------------------- src/screens/account/AccountHomeScreen.tsx | 8 +- src/screens/orders/OrderHistoryScreen.tsx | 252 +++++++++++++ src/screens/orders/ReceiptScreen.tsx | 299 ++++++++++++++++ translations/en.json | 63 ++++ translations/mn.json | 63 ++++ translations/uk.json | 63 ++++ 15 files changed, 1219 insertions(+), 664 deletions(-) create mode 100644 __tests__/commerce/order-summary.test.ts create mode 100644 src/commerce/order-summary.ts delete mode 100644 src/screens/OrderHistoryScreen.tsx delete mode 100644 src/screens/ReceiptScreen.tsx create mode 100644 src/screens/orders/OrderHistoryScreen.tsx create mode 100644 src/screens/orders/ReceiptScreen.tsx diff --git a/__tests__/commerce/order-summary.test.ts b/__tests__/commerce/order-summary.test.ts new file mode 100644 index 00000000..3a5ab2b0 --- /dev/null +++ b/__tests__/commerce/order-summary.test.ts @@ -0,0 +1,198 @@ +import { groupOrders, itemsPreview, parseOrderLine, paymentKey, placeLine, receiptText, summarizeOrder } from '../../src/commerce/order-summary'; + +const order = (overrides: any = {}) => ({ + id: 'order_1', + status: 'driver_enroute', + created_at: '2026-10-01T10:00:00Z', + tracking_number: { tracking_number: 'SF-1001' }, + notes: ' Leave at door ', + meta: { + storefront: 'Rochor Noodle House', + storefront_id: 'store_1', + subtotal: 2400, + delivery_fee: '399', + tip: 200, + delivery_tip: 100, + discount: 300, + promotions: [ + { name: 'Welcome', code: 'HELLO', amount: 200, delivery_amount: 100 }, + { name: null, code: null }, + ], + total: 2799, + currency: 'SGD', + gateway: 'stripe', + is_pickup: false, + }, + payload: { + pickup: { name: 'Rochor Noodle House', street1: '1 Rochor Rd' }, + dropoff: { name: 'Home', street1: '612 Hougang Ave 8' }, + entities: [ + { + id: 'entity_1', + name: 'Laksa', + photo_url: 'https://img/laksa.jpg', + meta: { quantity: 2, subtotal: 1600, variants: [{ name: 'Large' }], addons: [{ name: 'Extra prawns' }, { name: '' }] }, + }, + { id: 'entity_2', name: 'Kopi', price: 400, meta: { quantity: '2' } }, + ], + }, + ...overrides, +}); + +describe('summarizeOrder', () => { + it('reads what checkout recorded', () => { + const summary = summarizeOrder(order()); + expect(summary).toMatchObject({ + id: 'order_1', + reference: 'SF-1001', + storeName: 'Rochor Noodle House', + storeId: 'store_1', + phase: 'onTheWay', + active: true, + canceled: false, + isPickup: false, + currency: 'SGD', + itemCount: 4, + subtotal: 2400, + deliveryFee: 399, + tip: 200, + deliveryTip: 100, + discount: 300, + total: 2799, + gateway: 'stripe', + address: 'Home, 612 Hougang Ave 8', + notes: 'Leave at door', + }); + expect(summary.promotions).toEqual([{ name: 'Welcome', code: 'HELLO', amount: 300 }]); + expect(summary.lines[0]).toEqual({ + id: 'entity_1', + name: 'Laksa', + quantity: 2, + subtotal: 1600, + imageUrl: 'https://img/laksa.jpg', + options: ['Large', 'Extra prawns'], + scheduledAt: null, + }); + expect(summary.lines[1]).toMatchObject({ quantity: 2, subtotal: 800, imageUrl: null, options: [] }); + }); + + it('handles pickup, finished and canceled orders', () => { + const pickup = summarizeOrder(order({ status: 'completed', meta: { is_pickup: 1, delivery_fee: 500, storefront: null } })); + expect(pickup).toMatchObject({ isPickup: true, phase: 'collected', active: false, deliveryFee: 0, address: 'Rochor Noodle House, 1 Rochor Rd', storeName: 'Rochor Noodle House' }); + + const canceled = summarizeOrder(order({ status: 'canceled' })); + expect(canceled).toMatchObject({ canceled: true, active: false }); + }); + + it('copes with missing data', () => { + const empty = summarizeOrder({}); + expect(empty).toMatchObject({ + id: '', + reference: '', + storeName: '', + lines: [], + itemCount: 0, + total: 0, + currency: null, + gateway: null, + address: null, + notes: null, + phase: 'placed', + active: true, + }); + expect(summarizeOrder({ id: 'order_2', tracking: 'TRK', payload: { payment_method: 'qpay' }, meta: { total: 'MNT 1,000' } })).toMatchObject({ + reference: 'TRK', + gateway: 'qpay', + total: 1000, + }); + expect(summarizeOrder({ id: 'order_3', meta: { total: 'n/a' } }).total).toBe(0); + }); +}); + +describe('order lines', () => { + it('defaults the quantity and uses the price when there is no subtotal', () => { + expect(parseOrderLine({ name: 'Tea', price: 300, meta: { image_url: 'https://img/tea.jpg', scheduled_at: '2026-10-02 09:00', variants: [{ label: 'Hot' }] } }, 3)).toEqual({ + id: '3', + name: 'Tea', + quantity: 1, + subtotal: 300, + imageUrl: 'https://img/tea.jpg', + options: ['Hot'], + scheduledAt: '2026-10-02 09:00', + }); + expect(parseOrderLine(null).name).toBe(''); + }); + + it('previews what was ordered', () => { + const lines = summarizeOrder(order()).lines; + const more = (count: number) => `and ${count} more`; + expect(itemsPreview(lines, more)).toBe('2× Laksa, 2× Kopi'); + expect(itemsPreview([...lines, parseOrderLine({ name: 'Cake' })], more)).toBe('2× Laksa, 2× Kopi and 1 more'); + expect(itemsPreview([], more)).toBe(''); + }); +}); + +describe('placeLine', () => { + it('joins the name and street without repeating them', () => { + expect(placeLine({ name: 'Home', street1: 'Home' })).toBe('Home'); + expect(placeLine({ address: '1 Main St, Singapore' })).toBe('1 Main St, Singapore'); + expect(placeLine({})).toBeNull(); + expect(placeLine(null)).toBeNull(); + }); +}); + +describe('groupOrders', () => { + it('pins orders in progress and sorts each group newest first', () => { + const a = summarizeOrder(order({ id: 'a', status: 'completed', created_at: '2026-09-01T00:00:00Z' })); + const b = summarizeOrder(order({ id: 'b', status: 'completed', created_at: '2026-09-05T00:00:00Z' })); + const c = summarizeOrder(order({ id: 'c', status: 'preparing', created_at: '2026-09-02T00:00:00Z' })); + const d = summarizeOrder(order({ id: 'd', status: 'created', created_at: null })); + const { active, past } = groupOrders([a, b, c, d, b, summarizeOrder({})]); + expect(active.map((entry) => entry.id)).toEqual(['c', 'd']); + expect(past.map((entry) => entry.id)).toEqual(['b', 'a']); + }); +}); + +describe('paymentKey', () => { + it.each([ + ['stripe', 'card'], + ['QPay', 'qpay'], + ['paypal', 'paypal'], + ['cash', 'cash'], + ['cod', 'cash'], + ['other', 'other'], + [null, 'other'], + ])('%s → %s', (gateway, key) => expect(paymentKey(gateway)).toBe(key)); +}); + +describe('receiptText', () => { + const t = (key: string, params?: Record) => (params ? `${key} ${JSON.stringify(params)}` : key); + const money = (value: number) => `$${(value / 100).toFixed(2)}`; + + it('lists the lines and every charge that applies', () => { + const text = receiptText(summarizeOrder(order()), { t, money, date: 'Oct 1, 2026' }); + expect(text.split('\n')).toEqual([ + 'Rochor Noodle House', + 'Receipt.orderNumber {"number":"SF-1001"}', + 'Oct 1, 2026', + '', + '2 × Laksa (Large, Extra prawns) $16.00', + '2 × Kopi $8.00', + '', + 'Receipt.subtotal: $24.00', + 'Receipt.deliveryFee: $3.99', + 'Receipt.tip: $2.00', + 'Receipt.driverTip: $1.00', + 'Receipt.discount: −$3.00', + 'Receipt.total: $27.99', + ]); + }); + + it('leaves out charges that are zero and a missing date', () => { + const text = receiptText(summarizeOrder(order({ meta: { subtotal: 500, total: 500, is_pickup: true } })), { t, money, date: null }); + expect(text).not.toContain('deliveryFee'); + expect(text).not.toContain('tip'); + expect(text).not.toContain('discount'); + expect(text.split('\n')[2]).toBe(''); + }); +}); diff --git a/package.json b/package.json index 45510621..5c4c1c77 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/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__ src/screens/auth src/screens/account src/components/auth src/navigation/auth-redirect.ts", + "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__ src/screens/auth src/screens/account src/components/auth src/navigation/auth-redirect.ts src/screens/orders src/navigation/stacks/OrderStack.tsx", "typecheck:strict": "tsc --noEmit -p tsconfig.strict.json", "format": "prettier --write .", "tamagui:build": "./node_modules/.bin/tamagui-build", diff --git a/src/commerce/order-summary.ts b/src/commerce/order-summary.ts new file mode 100644 index 00000000..ff946dc7 --- /dev/null +++ b/src/commerce/order-summary.ts @@ -0,0 +1,184 @@ +import { orderProgress, type OrderPhase } from './order-progress'; + +/** + * Plain summaries of a storefront order (the Fleetbase order resource) for the order + * history and the receipt. Checkout records what the customer saw in `meta`: + * `storefront`, `storefront_id`, `subtotal`, `delivery_fee`, `tip`, `delivery_tip`, + * `discount`, `promotions`, `total`, `currency`, `gateway` and `is_pickup`. Each line + * is a payload entity whose `meta` holds `quantity`, `subtotal`, `variants` and `addons`. + */ + +export type OrderLine = { + id: string; + name: string; + quantity: number; + subtotal: number; + imageUrl: string | null; + /** Chosen variant options and add-ons, e.g. ["Large", "Extra shot"]. */ + options: string[]; + scheduledAt: string | null; +}; + +export type OrderSummary = { + id: string; + reference: string; + storeName: string; + storeId: string | null; + createdAt: string | null; + status: string; + phase: OrderPhase; + active: boolean; + canceled: boolean; + isPickup: boolean; + currency: string | null; + lines: OrderLine[]; + itemCount: number; + subtotal: number; + deliveryFee: number; + tip: number; + deliveryTip: number; + discount: number; + promotions: { name: string; code: string | null; amount: number }[]; + total: number; + gateway: string | null; + address: string | null; + notes: string | null; +}; + +const amount = (value: unknown): number => { + const number = typeof value === 'string' ? Number(value.replace(/[^\d.-]/g, '')) : Number(value); + return Number.isFinite(number) ? number : 0; +}; + +const text = (value: unknown): string | null => (typeof value === 'string' && value.trim() ? value.trim() : null); + +function optionNames(meta: any): string[] { + const names: string[] = []; + for (const variant of Array.isArray(meta?.variants) ? meta.variants : []) { + const name = text(variant?.name) ?? text(variant?.label); + if (name) names.push(name); + } + for (const addon of Array.isArray(meta?.addons) ? meta.addons : []) { + const name = text(addon?.name); + if (name) names.push(name); + } + return names; +} + +export function parseOrderLine(entity: any, index = 0): OrderLine { + const meta = entity?.meta ?? {}; + const quantity = Math.max(1, Math.round(amount(meta.quantity)) || 1); + return { + id: String(entity?.id ?? entity?.public_id ?? index), + name: text(entity?.name) ?? '', + quantity, + subtotal: meta.subtotal != null ? amount(meta.subtotal) : amount(entity?.price) * quantity, + imageUrl: text(entity?.photo_url) ?? text(meta.image_url), + options: optionNames(meta), + scheduledAt: text(meta.scheduled_at), + }; +} + +/** One line for an address: name and street, falling back to the full address. */ +export function placeLine(place: any): string | null { + if (!place) return null; + const parts = [text(place.name), text(place.street1)].filter(Boolean) as string[]; + if (parts.length) return [...new Set(parts)].join(', '); + return text(place.address); +} + +export function summarizeOrder(json: any): OrderSummary { + const meta = json?.meta ?? {}; + const payload = json?.payload ?? {}; + const isPickup = meta.is_pickup === true || meta.is_pickup === 1 || meta.is_pickup === '1'; + const status = String(json?.status ?? 'created'); + const progress = orderProgress({ status, isPickup, trackingStatuses: json?.tracking_statuses ?? [], createdAt: json?.created_at ?? null }); + const lines = (Array.isArray(payload.entities) ? payload.entities : []).map(parseOrderLine); + + return { + id: String(json?.id ?? json?.public_id ?? ''), + reference: text(json?.tracking_number?.tracking_number) ?? text(json?.tracking) ?? String(json?.id ?? ''), + storeName: text(meta.storefront) ?? text(payload.pickup?.name) ?? '', + storeId: text(meta.storefront_id), + createdAt: text(json?.created_at), + status, + phase: progress.phase, + active: !progress.finished && !progress.canceled, + canceled: progress.canceled, + isPickup, + currency: text(meta.currency), + lines, + itemCount: lines.reduce((sum: number, line: OrderLine) => sum + line.quantity, 0), + subtotal: amount(meta.subtotal), + deliveryFee: isPickup ? 0 : amount(meta.delivery_fee), + tip: amount(meta.tip), + deliveryTip: amount(meta.delivery_tip), + discount: amount(meta.discount), + promotions: (Array.isArray(meta.promotions) ? meta.promotions : []) + .map((promotion: any) => ({ + name: text(promotion?.name) ?? text(promotion?.code) ?? '', + code: text(promotion?.code), + amount: amount(promotion?.amount) + amount(promotion?.delivery_amount), + })) + .filter((promotion: { name: string }) => promotion.name), + total: amount(meta.total), + gateway: text(meta.gateway) ?? text(payload.payment_method), + address: placeLine(isPickup ? payload.pickup : payload.dropoff), + notes: text(json?.notes), + }; +} + +/** "Latte, Croissant and 2 more" style preview of what was ordered. */ +export function itemsPreview(lines: OrderLine[], more: (count: number) => string, shown = 2): string { + const names = lines.map((line) => (line.quantity > 1 ? `${line.quantity}× ${line.name}` : line.name)).filter(Boolean); + if (names.length <= shown) return names.join(', '); + return `${names.slice(0, shown).join(', ')} ${more(names.length - shown)}`; +} + +/** Orders still in progress first (newest first), then the rest newest first. */ +export function groupOrders(orders: OrderSummary[]): { active: OrderSummary[]; past: OrderSummary[] } { + const newest = (a: OrderSummary, b: OrderSummary) => (b.createdAt ? Date.parse(b.createdAt) : 0) - (a.createdAt ? Date.parse(a.createdAt) : 0); + const unique = [...new Map(orders.filter((order) => order.id).map((order) => [order.id, order])).values()]; + return { active: unique.filter((order) => order.active).sort(newest), past: unique.filter((order) => !order.active).sort(newest) }; +} + +/** Translation key for how the order was paid. */ +export function paymentKey(gateway: string | null): string { + switch ((gateway ?? '').toLowerCase()) { + case 'stripe': + return 'card'; + case 'qpay': + return 'qpay'; + case 'paypal': + return 'paypal'; + case 'cash': + case 'cod': + return 'cash'; + default: + return 'other'; + } +} + +/** The receipt as plain text, for sharing. */ +export function receiptText( + summary: OrderSummary, + { t, money, date }: { t: (key: string, params?: Record) => string; money: (value: number) => string; date: string | null } +): string { + const lines = [ + summary.storeName, + t('Receipt.orderNumber', { number: summary.reference }), + date, + '', + ...summary.lines.map((line) => `${line.quantity} × ${line.name}${line.options.length ? ` (${line.options.join(', ')})` : ''} ${money(line.subtotal)}`), + '', + `${t('Receipt.subtotal')}: ${money(summary.subtotal)}`, + summary.deliveryFee ? `${t('Receipt.deliveryFee')}: ${money(summary.deliveryFee)}` : null, + summary.tip ? `${t('Receipt.tip')}: ${money(summary.tip)}` : null, + summary.deliveryTip ? `${t('Receipt.driverTip')}: ${money(summary.deliveryTip)}` : null, + summary.discount ? `${t('Receipt.discount')}: −${money(summary.discount)}` : null, + `${t('Receipt.total')}: ${money(summary.total)}`, + ]; + return lines.filter((line) => line !== null).join('\n'); +} + +export const ORDER_PAGE_SIZE = 20; diff --git a/src/extensions/screens/default-screens.ts b/src/extensions/screens/default-screens.ts index bfd6e6f1..8c192a15 100644 --- a/src/extensions/screens/default-screens.ts +++ b/src/extensions/screens/default-screens.ts @@ -20,8 +20,8 @@ export const defaultScreens: ScreenRegistryDefinition = { 'cart.item': { load: () => import('../../screens/CartItemScreen') }, checkout: { load: () => import('../../screens/CheckoutScreen') }, 'order.detail': { load: () => import('../../screens/OrderScreen') }, - 'order.receipt': { load: () => import('../../screens/ReceiptScreen') }, - 'order.history': { load: () => import('../../screens/OrderHistoryScreen') }, + 'order.receipt': { load: () => import('../../screens/orders/ReceiptScreen') }, + 'order.history': { load: () => import('../../screens/orders/OrderHistoryScreen') }, 'account.profile': { load: () => import('../../screens/account/AccountHomeScreen') }, 'account.details': { load: () => import('../../screens/AccountScreen') }, 'auth.login': { load: () => import('../../screens/auth/SignInScreen') }, diff --git a/src/extensions/screens/params.ts b/src/extensions/screens/params.ts index a8de7dc7..7043638c 100644 --- a/src/extensions/screens/params.ts +++ b/src/extensions/screens/params.ts @@ -19,7 +19,7 @@ export type ScreenParamMap = { 'cart.item': { cartItem?: Serialized; product?: Serialized; [key: string]: any }; checkout: Serialized | undefined; 'order.detail': { order: Serialized; [key: string]: any }; - 'order.receipt': { order: Serialized; [key: string]: any }; + 'order.receipt': { order?: Serialized; orderId?: string; [key: string]: any }; 'order.history': undefined; 'account.profile': undefined; 'account.details': undefined; diff --git a/src/navigation/stacks/OrderStack.tsx b/src/navigation/stacks/OrderStack.tsx index f9de2293..045a716a 100644 --- a/src/navigation/stacks/OrderStack.tsx +++ b/src/navigation/stacks/OrderStack.tsx @@ -1,7 +1,3 @@ -import BackButton from '../../components/BackButton'; -import { PortalHost } from '@gorhom/portal'; -import { getTheme } from '../../utils'; -import { translate as t } from '../../utils/localize'; import { screenSlot } from '../../extensions'; export const Order = { @@ -18,21 +14,8 @@ export const OrderChat = { screen: screenSlot('order.chat'), options: { headerSh export const Receipt = { screen: screenSlot('order.receipt'), - options: ({ navigation, route }) => { - const params = route.params ?? {}; - return { - presentation: 'modal', - title: params.order.id, - headerTitleStyle: { - color: getTheme('textPrimary'), - }, - headerTransparent: true, - headerShadowVisible: false, - headerLeft: () => { - return navigation.goBack()} />; - }, - }; - }, + // The receipt draws its own header with back and share. + options: { presentation: 'modal', headerShown: false }, }; export const OrderModal = { @@ -43,22 +26,7 @@ export const OrderModal = { export const OrderHistory = { screen: screenSlot('order.history'), - options: ({ navigation }) => { - return { - title: t('OrderHistoryScreen.orderHistory'), - headerTitleStyle: { - color: getTheme('textPrimary'), - }, - headerTransparent: true, - headerShadowVisible: false, - headerLeft: () => { - return navigation.goBack()} mr='$3' />; - }, - headerRight: () => { - return ; - }, - }; - }, + options: { headerShown: false }, }; const OrderStack = { diff --git a/src/screens/OrderHistoryScreen.tsx b/src/screens/OrderHistoryScreen.tsx deleted file mode 100644 index d6375c88..00000000 --- a/src/screens/OrderHistoryScreen.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import React, { useCallback, useMemo, useRef, useState } from 'react'; -import { FlatList, Pressable, RefreshControl, Platform } from 'react-native'; -import { useNavigation, useFocusEffect } from '@react-navigation/native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; -import { faChevronRight } from '@fortawesome/free-solid-svg-icons'; -import { Spinner, Separator, Text, XStack, YStack, useTheme } from 'tamagui'; -import { Portal } from '@gorhom/portal'; -import { format as formatDate } from 'date-fns'; -import { formatCurrency, titleize } from '../utils/format'; -import { Order } from '@fleetbase/sdk'; -import { useAuth } from '../contexts/AuthContext'; -import { useLanguage } from '../contexts/LanguageContext'; -import { adapter as fleetbaseAdapter } from '../hooks/use-fleetbase'; -import { usePromiseWithLoading } from '../hooks/use-promise-with-loading'; -import useStorage from '../hooks/use-storage'; -import Badge from '../components/Badge'; -import Spacer from '../components/Spacer'; -import ScreenWrapper from '../components/ScreenWrapper'; - -const restoreOrders = (orders: any[] = []) => orders.map((order) => new Order(order, fleetbaseAdapter)); - -const OrderHistoryScreen = () => { - const theme = useTheme(); - const navigation = useNavigation(); - const insets = useSafeAreaInsets(); - const { customer } = useAuth(); - const { t } = useLanguage(); - const { runWithLoading, isLoading } = usePromiseWithLoading(); - - // Persist serialized orders - const [storedOrders, setStoredOrders] = useStorage(`${customer?.id}_orders`, []); - const [ordersDirty, setOrdersDirty] = useStorage(`${customer?.id}_orders_dirty`, false); - - const [refreshing, setRefreshing] = useState(false); - const [fetchingOrders, setFetchingOrders] = useState(false); - - /** Prevent overlapping fetch calls */ - const isFetchingRef = useRef(false); - - /** NEW: Prevent infinite fetch caused by empty results */ - const hasFetchedOnce = useRef(false); - - /** Hydrate SDK Order instances */ - const orders = useMemo(() => restoreOrders(storedOrders), [storedOrders]); - - /** Fetch orders from API */ - const fetchOrders = useCallback(async () => { - if (!customer) return; - if (isFetchingRef.current) return; - - isFetchingRef.current = true; - setFetchingOrders(true); - - try { - const result = await runWithLoading( - customer.getOrderHistory({ - sort: '-created_at', - limit: 25, - }) - ); - - // Store serialized orders - const serialized = result.map((order: Order) => order.serialize()); - setStoredOrders(serialized); - - // Mark as clean - setOrdersDirty(false); - - // Mark that we fetched at least once - hasFetchedOnce.current = true; - } catch (err) { - console.error('Failed to fetch orders:', err); - } finally { - isFetchingRef.current = false; - setFetchingOrders(false); - } - }, [customer, runWithLoading, setStoredOrders, setOrdersDirty]); - - /** Pull-down refresh */ - const handleRefresh = useCallback(async () => { - if (!customer) return; - - setRefreshing(true); - await fetchOrders(); - setRefreshing(false); - }, [customer, fetchOrders]); - - /** View order details */ - const handleViewOrder = useCallback( - (order: Order) => { - navigation.navigate('Order', { order: order.serialize() }); - }, - [navigation] - ); - - /** - * Focus Effect (Fired when screen becomes visible) - * - * Runs EXACTLY in this order: - * - If never fetched before → fetch once (even if empty) - * - Else if dirty → fetch - * - Otherwise → do nothing - * - * Stored orders being empty will NOT re-trigger fetches. - */ - useFocusEffect( - useCallback(() => { - if (!customer) return; - - const isNotFetching = !isFetchingRef.current; - - // 1. First time viewing screen → fetch once - if (!hasFetchedOnce.current && isNotFetching) { - fetchOrders(); - return; - } - - // 2. If dirty (e.g., new order placed) → fetch - if (ordersDirty && isNotFetching) { - fetchOrders(); - return; - } - - // 3. Otherwise → no action (no infinite loops) - }, [customer, ordersDirty, fetchOrders]) - ); - - return ( - - {(isLoading() || fetchingOrders) && !refreshing && ( - - - - - - )} - - order.id?.toString?.() ?? order.getAttribute('public_id') ?? String(order.getAttribute('uuid'))} - renderItem={({ item: order }) => ( - handleViewOrder(order)}> - - - - - {order.getAttribute('meta.storefront')} - - - - - {order.getAttribute('id')} - - - - {formatDate(order.createdAt, 'MMM d, yyyy h:mm aa')} - - - - - - {formatCurrency(order.getAttribute('meta.total'), order.getAttribute('meta.currency'))} - - - - - {t(`orderStatuses.${order.getAttribute('status')}`, { - defaultValue: titleize(order.getAttribute('status')), - })} - - - - - )} - contentContainerStyle={{ paddingBottom: 16 }} - ItemSeparatorComponent={() => } - showsHorizontalScrollIndicator={false} - showsVerticalScrollIndicator={false} - refreshControl={} - ListHeaderComponent={} - ListFooterComponent={} - /> - - ); -}; - -export default OrderHistoryScreen; diff --git a/src/screens/OrderScreen.tsx b/src/screens/OrderScreen.tsx index d2b89c5f..262dc3aa 100644 --- a/src/screens/OrderScreen.tsx +++ b/src/screens/OrderScreen.tsx @@ -72,7 +72,6 @@ const OrderScreen = ({ route }: any) => { const isPickup = !!order.getAttribute('meta.is_pickup'); const status = order.getAttribute('status'); const foodTruckId = order.getAttribute('meta.food_truck_id'); - const usedQpay = order.getAttribute('payload.payment_method') === 'qpay' || order.getAttribute('meta.gateway') === 'qpay'; const currency = order.getAttribute('meta.currency') ?? info?.currency ?? 'USD'; const money = (amount: unknown) => formatCurrency(Number(amount) || 0, currency); const progress = useMemo( @@ -299,7 +298,18 @@ const OrderScreen = ({ route }: any) => { {canRenderRoute && (isPickup ? : )} - + @@ -321,7 +331,9 @@ const OrderScreen = ({ route }: any) => { value={0} size={32} label={t('Reviews.ratingLabel', { store: storeName })} - onChange={(rating) => navigation.navigate('WriteReview', { storeId, storeName, storeLogo: store?.logo_url ?? null, orderId: order.id, orderReference: reference, rating })} + onChange={(rating) => + navigation.navigate('WriteReview', { storeId, storeName, storeLogo: store?.logo_url ?? null, orderId: order.id, orderReference: reference, rating }) + } /> {t('Reviews.tapToWrite')} @@ -342,7 +354,13 @@ const OrderScreen = ({ route }: any) => { {progress.phase === 'ready' && ( - {!!qrCode && } + {!!qrCode && ( + + )} {t('Tracking.readyBody')} - )} + {formatDate(new Date(order.getAttribute('created_at') ?? Date.now()), 'PPp')} @@ -494,7 +561,9 @@ const OrderScreen = ({ route }: any) => { {money(order.getAttribute('meta.total'))} - {isPickup ? t('Tracking.pickupAt', { place: [pickup?.name, pickup?.street1].filter(Boolean).join(', ') }) : t('Tracking.deliveringTo', { place: [dropoff?.name, dropoff?.street1].filter(Boolean).join(', ') })} + {isPickup + ? t('Tracking.pickupAt', { place: [pickup?.name, pickup?.street1].filter(Boolean).join(', ') }) + : t('Tracking.deliveringTo', { place: [dropoff?.name, dropoff?.street1].filter(Boolean).join(', ') })} {!!order.getAttribute('notes') && ( diff --git a/src/screens/ReceiptScreen.tsx b/src/screens/ReceiptScreen.tsx deleted file mode 100644 index 3ae329f3..00000000 --- a/src/screens/ReceiptScreen.tsx +++ /dev/null @@ -1,416 +0,0 @@ -import React, { useEffect, useState, useCallback, useMemo } from 'react'; -import { ScrollView, RefreshControl, ActivityIndicator, Alert } from 'react-native'; -import { Button, Text, YStack, XStack, useTheme, Separator } from 'tamagui'; -import { Order } from '@fleetbase/sdk'; -import { adapter as fleetbaseAdapter } from '../hooks/use-fleetbase'; -import { format as formatDate } from 'date-fns'; -import useStorefront from '../hooks/use-storefront'; -import { useAuth } from '../contexts/AuthContext'; -import { useLanguage } from '../contexts/LanguageContext'; -import QRCode from 'react-native-qrcode-svg'; -import useStorage from '../hooks/use-storage'; -import { isBlank } from '../utils'; - -const ReceiptScreen = ({ route }) => { - const params = route.params || {}; - const theme = useTheme(); - const { t } = useLanguage(); - const { adapter: storefrontAdapter } = useStorefront(); - const { customer } = useAuth(); - - const [order] = useState(() => new Order(params.order, fleetbaseAdapter)); - const [response, setResponse] = useState(null); - const [loading, setLoading] = useState(false); - const [refreshing, setRefreshing] = useState(false); - const [error, setError] = useState(null); - - // Stable key based on order id - const qrStorageKey = `receipt_qr_${order.id}`; - - // Persist QR data across sessions - const [cachedQrData, setCachedQrData] = useStorage(qrStorageKey, null); - - /** Receipt data: API response > order meta > null */ - const receiptData = useMemo(() => { - if (response) { - return response; - } - - if (order.getAttribute('payload.payment_method') === 'qpay' && order.getAttribute('meta.ebarimt')) { - return order.getAttribute('meta.ebarimt'); - } - - return null; - }, [order, response]); - - /** QR data: API response > cached storage > order meta */ - const qrData = useMemo(() => { - if (response && response.ebarimt_qr_data) { - return response.ebarimt_qr_data; - } - - if (cachedQrData) { - return cachedQrData; - } - - const orderMeta = order.getAttribute('meta', {}) || {}; - return orderMeta.ebarimt_qr_data || (orderMeta.ebarimt && orderMeta.ebarimt.ebarimt_qr_data) || null; - }, [response, cachedQrData, order]); - - /** Whether receipt already exists in order meta */ - const hasExistingReceipt = useMemo(() => { - return order.getAttribute('payload.payment_method') === 'qpay' && order.getAttribute('meta.ebarimt') != null; - }, [order]); - - /** Fetch receipt data from API */ - const getReceiptData = useCallback( - async (isRefresh) => { - // If we already have receipt in meta and not explicitly refreshing, skip - if (hasExistingReceipt && !isRefresh) { - return; - } - - if (!storefrontAdapter) { - return; - } - - try { - if (isRefresh) { - setRefreshing(true); - } else { - setLoading(true); - } - - setError(null); - - const ebarimt_receiver = customer?.getAttribute('meta.ebarimt_registration_no', '') ?? ''; - const ebarimt_receiver_type = isBlank(ebarimt_receiver) ? 'CITIZEN' : 'COMPANY'; - - const data = await storefrontAdapter.post('orders/receipt', { - order: order.id, - ebarimt_receiver, - ebarimt_receiver_type, - }); - - setResponse(data); - } catch (err) { - console.error('Error fetching receipt:', err); - setError(err.message || 'Failed to load receipt data'); - Alert.alert(t('ReceiptScreen.error_alert_title'), t('ReceiptScreen.error_alert_message')); - } finally { - setLoading(false); - setRefreshing(false); - } - }, - [storefrontAdapter, order.id, hasExistingReceipt, t] - ); - - /** Pull-to-refresh handler */ - const onRefresh = useCallback(() => { - getReceiptData(true); - }, [getReceiptData]); - - /** Initial load: only if needed */ - useEffect(() => { - if (storefrontAdapter && !hasExistingReceipt && !response) { - getReceiptData(false); - } - }, [storefrontAdapter, hasExistingReceipt, response, getReceiptData]); - - /** Sync qrData into persistent storage (no loops) */ - useEffect(() => { - const orderMeta = order.getAttribute('meta', {}) || {}; - const metaQr = orderMeta.ebarimt_qr_data || (orderMeta.ebarimt && orderMeta.ebarimt.ebarimt_qr_data) || null; - const latest = (response && response.ebarimt_qr_data) || metaQr || null; - - if (latest && latest !== cachedQrData) { - setCachedQrData(latest); - } - }, [order, response, cachedQrData, setCachedQrData]); - - /** Formatters */ - const formatAmount = (amount) => { - const numAmount = parseFloat(amount || 0); - return numAmount.toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }); - }; - - const formatReceiptDate = (dateString) => { - if (!dateString) return t('ReceiptScreen.not_available'); - - try { - return formatDate(new Date(dateString), 'MMM dd, yyyy HH:mm'); - } catch (e) { - return dateString; - } - }; - - /** Loading state */ - if (loading && !receiptData) { - return ( - - - - {t('ReceiptScreen.loading_receipt')} - - - ); - } - - /** Error state */ - if (error && !receiptData) { - return ( - - - ⚠️ {t('ReceiptScreen.error_title')} - - - {error} - - - - ); - } - - /** No receipt state */ - if (!receiptData) { - return ( - - - 📄 {t('ReceiptScreen.no_receipt_title')} - - - {t('ReceiptScreen.no_receipt_message')} - - - ); - } - - /** Main receipt display */ - return ( - }> - - {/* Header */} - - - {t('ReceiptScreen.title')} - - - {receiptData.ebarimt_receipt_id || t('ReceiptScreen.not_available')} - - - - - - {/* QR Code Section */} - - - {t('ReceiptScreen.scan_qr_code')} - - {qrData ? ( - - - - ) : ( - {t('ReceiptScreen.qr_not_available')} - )} - - - - - {/* Amount Breakdown Section */} - - - {t('ReceiptScreen.amount_details')} - - - - - {t('ReceiptScreen.total_amount')} - - - ₮{formatAmount(receiptData.amount)} - - - - - - {t('ReceiptScreen.vat_amount')} - - - ₮{formatAmount(receiptData.vat_amount)} - - - - - - {t('ReceiptScreen.city_tax_amount')} - - - ₮{formatAmount(receiptData.city_tax_amount)} - - - - - - - {/* Receipt Information Section */} - - - {t('ReceiptScreen.receipt_information')} - - - - - {t('ReceiptScreen.date')} - - - {formatReceiptDate(receiptData.barimt_status_date || receiptData.created_date)} - - - - - - {t('ReceiptScreen.lottery_number')} - - - {receiptData.ebarimt_lottery || t('ReceiptScreen.not_available')} - - - - - - {t('ReceiptScreen.status')} - - - {receiptData.barimt_status || t('ReceiptScreen.not_available')} - - - - - - {t('ReceiptScreen.receiver_type')} - - - {receiptData.ebarimt_receiver_type || t('ReceiptScreen.not_available')} - - - - {receiptData.ebarimt_receiver ? ( - - - {t('ReceiptScreen.receiver_id')} - - - {receiptData.ebarimt_receiver} - - - ) : null} - - - - - {/* Merchant Information Section */} - - - {t('ReceiptScreen.merchant_information')} - - - {/* - - {t('ReceiptScreen.merchant_id')} - - - {receiptData.g_merchant_id || t('ReceiptScreen.not_available')} - - */} - - - - {t('ReceiptScreen.register_no')} - - - {receiptData.merchant_register_no || t('ReceiptScreen.not_available')} - - - - - - {t('ReceiptScreen.tin')} - - - {receiptData.merchant_tin || t('ReceiptScreen.not_available')} - - - - {receiptData.merchant_branch_code && ( - - - {t('ReceiptScreen.branch_code')} - - - {receiptData.merchant_branch_code} - - - )} - - {receiptData.merchant_staff_code && ( - - - {t('ReceiptScreen.staff_code')} - - - {receiptData.merchant_staff_code} - - - )} - - - - - {/* Provider Information Section */} - - - {t('ReceiptScreen.provider_information')} - - - - - {t('ReceiptScreen.ebarimt_by')} - - - {receiptData.ebarimt_by || t('ReceiptScreen.not_available')} - - - - - - {t('ReceiptScreen.payment_id')} - - - {receiptData.g_payment_id || t('ReceiptScreen.not_available')} - - - - - - {t('ReceiptScreen.paid_by')} - - - {receiptData.paid_by || t('ReceiptScreen.not_available')} - - - - - {/* Footer spacing */} - - - - ); -}; - -export default ReceiptScreen; diff --git a/src/screens/account/AccountHomeScreen.tsx b/src/screens/account/AccountHomeScreen.tsx index eb934214..7dae5d39 100644 --- a/src/screens/account/AccountHomeScreen.tsx +++ b/src/screens/account/AccountHomeScreen.tsx @@ -25,7 +25,7 @@ import useAppTheme from '../../hooks/use-app-theme'; import useStorage from '../../hooks/use-storage'; import useUnreadNotifications from '../../hooks/use-unread-notifications'; import useFooterOffset from '../../hooks/use-footer-offset'; -import { orderProgress } from '../../commerce/order-progress'; +import { summarizeOrder } from '../../commerce/order-summary'; import { storefrontConfig } from '../../utils'; import { toast } from '../../utils/toast'; import { Badge, Button, Card, MediaImage, Sheet, UIText, initials, radius, space, tintFor } from '../../ui'; @@ -152,7 +152,7 @@ const AccountHomeScreen = () => { // The most recent order still in progress, from the order history cache. const activeOrder = useMemo(() => { const orders = Array.isArray(storedOrders) ? storedOrders : []; - return orders.find((order: any) => order?.id && !orderProgress({ status: order.status, isPickup: order.meta?.is_pickup === true }).finished) ?? null; + return orders.map(summarizeOrder).find((order) => order.id && order.active) ?? null; }, [storedOrders]); const languageName = languages.find((language: any) => language.code === locale)?.native ?? locale.toUpperCase(); @@ -233,7 +233,7 @@ const AccountHomeScreen = () => { navigation.navigate('Order', { orderId: activeOrder.id })} accessibilityRole='button' - accessibilityLabel={t('Account.activeOrderLabel', { id: activeOrder.public_id ?? activeOrder.id })} + accessibilityLabel={t('Account.activeOrderLabel', { id: activeOrder.reference })} > @@ -245,7 +245,7 @@ const AccountHomeScreen = () => { {t('Account.activeOrder')} - {t(`Tracking.phase.${orderProgress({ status: activeOrder.status, isPickup: activeOrder.meta?.is_pickup === true }).phase}.title`, { + {t(`Tracking.phase.${activeOrder.phase}.title`, { store: '', driver: t('Tracking.yourDriver'), })} diff --git a/src/screens/orders/OrderHistoryScreen.tsx b/src/screens/orders/OrderHistoryScreen.tsx new file mode 100644 index 00000000..c343d501 --- /dev/null +++ b/src/screens/orders/OrderHistoryScreen.tsx @@ -0,0 +1,252 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { ActivityIndicator, Pressable, RefreshControl, SectionList, View } from 'react-native'; +import { useFocusEffect, useNavigation } from '@react-navigation/native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; +import { faChevronLeft, faChevronRight, faReceipt } from '@fortawesome/free-solid-svg-icons'; +import { format as formatDate } from 'date-fns'; +import { XStack, YStack, useTheme } from 'tamagui'; +import { useAuth } from '../../contexts/AuthContext'; +import { useLanguage } from '../../contexts/LanguageContext'; +import { useStorefrontRuntime } from '../../contexts/StorefrontRuntimeContext'; +import useStorage from '../../hooks/use-storage'; +import useFooterOffset from '../../hooks/use-footer-offset'; +import { ORDER_PAGE_SIZE, groupOrders, itemsPreview, summarizeOrder, type OrderSummary } from '../../commerce/order-summary'; +import { formatCurrency } from '../../utils/format'; +import { Badge, Button, Card, EmptyState, ErrorState, IconButton, Skeleton, StoreLogo, UIText, radius, space } from '../../ui'; + +type Translate = (key: string, params?: Record) => string; + +function OrderMeta({ order, t }: { order: OrderSummary; t: Translate }) { + const date = order.createdAt ? formatDate(new Date(order.createdAt), 'PP') : null; + return ( + + {[date, t('Orders.items', { count: order.itemCount }), formatCurrency(order.total, order.currency ?? undefined)].filter(Boolean).join(' · ')} + + ); +} + +/** An order still in progress: where it is now and a way to follow it. */ +function ActiveOrderCard({ order, t, onPress }: { order: OrderSummary; t: Translate; onPress: () => void }) { + return ( + + + + + + + {order.storeName} + + + + + + + {itemsPreview(order.lines, (count) => t('Orders.andMore', { count }))} + + + + + ); +} + +/** A finished order: what, when and how much, with its receipt one tap away. */ +function PastOrderRow({ order, t, onPress, onReceipt }: { order: OrderSummary; t: Translate; onPress: () => void; onReceipt: () => void }) { + const theme = useTheme(); + return ( + + + + + + + {order.storeName} + + {order.canceled && } + + + {itemsPreview(order.lines, (count) => t('Orders.andMore', { count }))} + + + + + + {!order.canceled && } + + ); +} + +function HistorySkeleton() { + return ( + + + {[0, 1, 2, 3].map((key) => ( + + + + + + + + ))} + + ); +} + +/** + * The customer's orders: those in progress pinned on top, then past orders newest first, + * loaded a page at a time. The first page is kept on the device so the list (and the + * account screen's active order) shows instantly next time. + */ +const OrderHistoryScreen = () => { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const footer = useFooterOffset(false); + const { t } = useLanguage(); + const { mode } = useStorefrontRuntime(); + const { customer } = useAuth() as any; + const [cached, setCached] = useStorage(`${customer?.id}_orders`, []); + const [dirty, setDirty] = useStorage(`${customer?.id}_orders_dirty`, false); + const [pages, setPages] = useState(null); + const [state, setState] = useState<'idle' | 'loading' | 'refreshing' | 'more' | 'error'>('idle'); + const [exhausted, setExhausted] = useState(false); + const busy = useRef(false); + + const raw = useMemo(() => pages ?? (Array.isArray(cached) ? cached : []), [pages, cached]); + const { active, past } = useMemo(() => groupOrders(raw.map(summarizeOrder)), [raw]); + const byId = useMemo(() => new Map(raw.map((json: any) => [String(json?.id), json])), [raw]); + + const load = useCallback( + async (kind: 'loading' | 'refreshing' | 'more') => { + if (!customer || busy.current) return; + busy.current = true; + setState(kind); + const offset = kind === 'more' ? (pages?.length ?? 0) : 0; + try { + const result = await customer.getOrderHistory({ sort: '-created_at', limit: ORDER_PAGE_SIZE, offset }); + const serialized = (Array.isArray(result) ? result : Array.from(result ?? [])).map((order: any) => (typeof order?.serialize === 'function' ? order.serialize() : order)); + setExhausted(serialized.length < ORDER_PAGE_SIZE); + if (kind === 'more') { + setPages((current) => [...(current ?? []), ...serialized]); + } else { + setPages(serialized); + setCached(serialized); + setDirty(false); + } + setState('idle'); + } catch { + setState('error'); + } finally { + busy.current = false; + } + }, + [customer, pages, setCached, setDirty] + ); + + // Fetch on first view, and again when a new order has been placed since. + useFocusEffect( + useCallback(() => { + if (pages === null || dirty) load('loading'); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dirty]) + ); + + const open = (summary: OrderSummary) => navigation.navigate('Order', { order: byId.get(summary.id) ?? { id: summary.id } }); + const receipt = (summary: OrderSummary) => navigation.navigate('Receipt', { order: byId.get(summary.id) ?? { id: summary.id } }); + + const firstLoad = pages === null && raw.length === 0; + const sections = [ + { key: 'active', title: t('Orders.inProgress'), data: active }, + { key: 'past', title: t('Orders.past'), data: past }, + ].filter((section) => section.data.length); + + return ( + + + {navigation.canGoBack() && navigation.goBack()} />} + + {t('Orders.title')} + + + + {firstLoad && state === 'error' ? ( + + load('loading')} /> + + ) : firstLoad ? ( + + + + ) : ( + order.id} + stickySectionHeadersEnabled={false} + contentContainerStyle={{ paddingHorizontal: space.gutter, paddingBottom: footer + insets.bottom + 24, flexGrow: 1 }} + renderSectionHeader={({ section }) => ( + + {section.title} + + )} + renderItem={({ item, section }) => + section.key === 'active' ? ( + + open(item)} /> + + ) : ( + open(item)} onReceipt={() => receipt(item)} /> + ) + } + // Plain views: on the web the list hands these a style array. + ListEmptyComponent={ + + navigation.navigate(mode === 'network' ? 'NetworkHomeTab' : 'StoreHomeTab')} + /> + + } + ListFooterComponent={ + state === 'more' ? ( + + ) : state === 'error' && raw.length ? ( + + {t('Orders.loadFailed')} + + + ) : null + } + onEndReachedThreshold={0.4} + onEndReached={() => { + if (pages && !exhausted && state === 'idle') load('more'); + }} + refreshControl={ load('refreshing')} />} + /> + )} + + ); +}; + +export default OrderHistoryScreen; diff --git a/src/screens/orders/ReceiptScreen.tsx b/src/screens/orders/ReceiptScreen.tsx new file mode 100644 index 00000000..c16efd4e --- /dev/null +++ b/src/screens/orders/ReceiptScreen.tsx @@ -0,0 +1,299 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Platform, RefreshControl, ScrollView, Share } from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { faArrowUpFromBracket, faChevronLeft, faReceipt } from '@fortawesome/free-solid-svg-icons'; +import { format as formatDate } from 'date-fns'; +import QRCode from 'react-native-qrcode-svg'; +import { Order } from '@fleetbase/sdk'; +import { XStack, YStack } from 'tamagui'; +import { useAuth } from '../../contexts/AuthContext'; +import { useLanguage } from '../../contexts/LanguageContext'; +import useStorefront from '../../hooks/use-storefront'; +import useStorage from '../../hooks/use-storage'; +import { adapter as fleetbaseAdapter } from '../../hooks/use-fleetbase'; +import { paymentKey, receiptText, summarizeOrder } from '../../commerce/order-summary'; +import { formatCurrency } from '../../utils/format'; +import { Badge, Button, Card, EmptyState, ErrorState, IconButton, Skeleton, StoreLogo, UIText, radius, space } from '../../ui'; + +function Row({ label, value, tone, strong = false }: { label: string; value: string; tone?: 'secondary' | 'success'; strong?: boolean }) { + return ( + + + {label} + + + {value} + + + ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( + + + {title} + + {children} + + ); +} + +/** + * Mongolian e-barimt tax receipt for orders paid with QPay: the QR code to register it, + * the lottery number and the tax amounts. Comes from the order when checkout recorded + * it, otherwise it is requested from the storefront. + */ +function TaxReceipt({ order }: { order: any }) { + const { t } = useLanguage(); + const { adapter } = useStorefront(); + const { customer } = useAuth() as any; + const recorded = order?.meta?.ebarimt ?? null; + const [fetched, setFetched] = useState(null); + const [failed, setFailed] = useState(false); + const [cachedQr, setCachedQr] = useStorage(`receipt_qr_${order?.id}`, null); + const data = fetched ?? recorded; + const qr = data?.ebarimt_qr_data ?? order?.meta?.ebarimt_qr_data ?? cachedQr; + + const request = useCallback(async () => { + if (!adapter || !order?.id) return; + setFailed(false); + try { + const receiver = customer?.getAttribute?.('meta.ebarimt_registration_no', '') ?? ''; + setFetched(await adapter.post('orders/receipt', { order: order.id, ebarimt_receiver: receiver, ebarimt_receiver_type: receiver ? 'COMPANY' : 'CITIZEN' })); + } catch { + setFailed(true); + } + }, [adapter, customer, order?.id]); + + useEffect(() => { + if (!recorded) request(); + }, [recorded, request]); + + useEffect(() => { + if (qr && qr !== cachedQr) setCachedQr(qr); + }, [qr, cachedQr, setCachedQr]); + + const tugrik = (value: unknown) => formatCurrency(Number(value) || 0, 'MNT'); + const na = t('ReceiptScreen.not_available'); + + if (!data) { + return ( +
+ {failed ? ( + + + {t('ReceiptScreen.error_alert_message')} + + + + ) : ( + + )} +
+ ); + } + + return ( +
+ + {qr ? ( + + + + ) : ( + {t('ReceiptScreen.qr_not_available')} + )} + {!!qr && ( + + {t('ReceiptScreen.scan_qr_code')} + + )} + + + + + + + + {!!data.ebarimt_receiver && } + + + {!!data.ebarimt_receipt_id && } +
+ ); +} + +/** + * The receipt for one order: the store, what was ordered with its options, every charge + * and discount, how it was paid and where it went. Can be shared as text. + */ +const ReceiptScreen = ({ route }: any) => { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const { t } = useLanguage(); + const params = route?.params ?? {}; + const initial = params.order ?? (params.orderId ? { id: params.orderId } : null); + // A reloaded copy wins over the order passed in, while it is for the same order. + const [reloaded, setReloaded] = useState(null); + const json = reloaded && reloaded.id === initial?.id ? reloaded : initial; + const [state, setState] = useState<'ready' | 'loading' | 'refreshing' | 'error'>(initial?.meta ? 'ready' : 'loading'); + + const reload = useCallback( + async (kind: 'loading' | 'refreshing') => { + if (!initial?.id) return setState('error'); + setState(kind); + try { + const order = await new Order({ id: initial.id }, fleetbaseAdapter).reload(); + setReloaded(order.serialize()); + setState('ready'); + } catch { + setState(kind === 'refreshing' && json?.meta ? 'ready' : 'error'); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [initial?.id] + ); + + useEffect(() => { + if (initial?.meta) setState('ready'); + else reload('loading'); + }, [initial?.meta, reload]); + + const summary = useMemo(() => (json?.meta ? summarizeOrder(json) : null), [json]); + const money = (value: number) => formatCurrency(value, summary?.currency ?? undefined); + const canShare = Platform.OS !== 'web' || typeof (globalThis as any).navigator?.share === 'function'; + + const share = async () => { + if (!summary) return; + try { + await Share.share({ message: receiptText(summary, { t, money, date: summary.createdAt ? formatDate(new Date(summary.createdAt), 'PPp') : null }), title: t('Receipt.title') }); + } catch { + // Dismissed or unsupported; nothing to do. + } + }; + + const header = ( + + + navigation.goBack()} /> + + {t('Receipt.title')} + + + {!!summary && canShare && } + + ); + + if (!summary) { + return ( + + {header} + {state === 'error' ? ( + + {initial?.id ? ( + reload('loading')} /> + ) : ( + + )} + + ) : ( + + + + + + + )} + + ); + } + + const paidWith = t(`Receipt.paidWith.${paymentKey(summary.gateway)}`); + + return ( + + {header} + reload('refreshing')} />} + > + + + + + + {summary.storeName} + + + {t('Receipt.orderNumber', { number: summary.reference })} + + {!!summary.createdAt && ( + + {formatDate(new Date(summary.createdAt), 'PPp')} + + )} + + + + + +
+ {summary.lines.map((line) => ( + + + {line.quantity}× + + + {line.name} + {!!line.options.length && ( + + {line.options.join(' · ')} + + )} + {!!line.scheduledAt && ( + + {t('Receipt.bookedFor', { when: formatDate(new Date(line.scheduledAt.replace(' ', 'T')), 'PPp') })} + + )} + + {money(line.subtotal)} + + ))} +
+ +
+ + {!summary.isPickup && } + {summary.tip > 0 && } + {summary.deliveryTip > 0 && } + {summary.promotions.length + ? summary.promotions.map((promotion) => ( + + )) + : summary.discount > 0 && } + + + + + {summary.canceled ? t('Receipt.canceledNote') : paidWith} + +
+ + {(!!summary.address || !!summary.notes) && ( +
+ {!!summary.address && {summary.address}} + {!!summary.notes && {t('Receipt.notes', { notes: summary.notes })}} +
+ )} + + {summary.gateway === 'qpay' && !summary.canceled && } +
+
+ ); +}; + +export default ReceiptScreen; diff --git a/translations/en.json b/translations/en.json index 00044f1c..fa443a01 100644 --- a/translations/en.json +++ b/translations/en.json @@ -1123,5 +1123,68 @@ "deleteBody": "We'll text you a code to confirm. Deleting removes your account, order history and saved places, and can't be undone.", "sendDeleteCode": "Send confirmation code", "keepAccount": "Keep my account" + }, + "Orders": { + "title": "Orders", + "inProgress": "In progress", + "past": "Past orders", + "items": { + "one": "1 item", + "other": "{{count}} items" + }, + "andMore": "and {{count}} more", + "track": "Track order", + "viewPickup": "View pickup details", + "activeLabel": "{{store}}, {{status}}. Open to track", + "pastLabel": "{{store}}, {{status}}. Open order", + "receiptFor": "Receipt for {{store}}", + "emptyTitle": "No orders yet", + "emptyBody": "When you place an order it shows up here, so you can follow it and find its receipt.", + "startShopping": "Start shopping", + "loadFailed": "We couldn't load your orders.", + "retry": "Try again", + "status": { + "placed": "Placed", + "accepted": "Accepted", + "preparing": "Preparing", + "driverToStore": "Driver heading to store", + "pickedUp": "Picked up", + "onTheWay": "On the way", + "delivered": "Delivered", + "ready": "Ready for pickup", + "collected": "Collected", + "canceled": "Canceled" + } + }, + "Receipt": { + "title": "Receipt", + "share": "Share receipt", + "orderNumber": "Order {{number}}", + "items": { + "one": "1 item", + "other": "{{count}} items" + }, + "bookedFor": "Booked for {{when}}", + "payment": "Payment", + "subtotal": "Subtotal", + "deliveryFee": "Delivery fee", + "tip": "Tip", + "driverTip": "Driver tip", + "discount": "Discount", + "total": "Total", + "paidWith": { + "card": "Paid by card", + "qpay": "Paid with QPay", + "paypal": "Paid with PayPal", + "cash": "Cash on delivery", + "other": "Paid in the app" + }, + "canceledNote": "This order was canceled.", + "delivery": "Delivered to", + "pickup": "Picked up from", + "notes": "Notes: {{notes}}", + "taxReceipt": "Tax receipt (e-barimt)", + "taxReceiptId": "Receipt ID", + "loadFailed": "We couldn't load this receipt." } } diff --git a/translations/mn.json b/translations/mn.json index 89c6382b..16bf9aa9 100644 --- a/translations/mn.json +++ b/translations/mn.json @@ -1123,5 +1123,68 @@ "deleteBody": "Баталгаажуулах кодыг SMS-ээр илгээнэ. Устгавал таны бүртгэл, захиалгын түүх, хадгалсан хаягууд бүрмөсөн устах бөгөөд сэргээх боломжгүй.", "sendDeleteCode": "Баталгаажуулах код илгээх", "keepAccount": "Бүртгэлээ үлдээх" + }, + "Orders": { + "title": "Захиалгууд", + "inProgress": "Явагдаж буй", + "past": "Өмнөх захиалгууд", + "items": { + "one": "1 бараа", + "other": "{{count}} бараа" + }, + "andMore": "болон өөр {{count}}", + "track": "Захиалга хянах", + "viewPickup": "Авах мэдээлэл харах", + "activeLabel": "{{store}}, {{status}}. Хянахын тулд нээх", + "pastLabel": "{{store}}, {{status}}. Захиалга нээх", + "receiptFor": "{{store}}-н баримт", + "emptyTitle": "Захиалга алга байна", + "emptyBody": "Захиалга өгөхөд энд харагдах тул хянах болон баримтаа олох боломжтой.", + "startShopping": "Худалдан авалт эхлэх", + "loadFailed": "Захиалгуудыг ачаалж чадсангүй.", + "retry": "Дахин оролдох", + "status": { + "placed": "Өгсөн", + "accepted": "Хүлээн авсан", + "preparing": "Бэлтгэж байна", + "driverToStore": "Жолооч дэлгүүр рүү явж байна", + "pickedUp": "Авсан", + "onTheWay": "Замдаа", + "delivered": "Хүргэсэн", + "ready": "Авахад бэлэн", + "collected": "Авсан", + "canceled": "Цуцалсан" + } + }, + "Receipt": { + "title": "Баримт", + "share": "Баримт хуваалцах", + "orderNumber": "Захиалга {{number}}", + "items": { + "one": "1 бараа", + "other": "{{count}} бараа" + }, + "bookedFor": "{{when}}-д захиалсан", + "payment": "Төлбөр", + "subtotal": "Дүн", + "deliveryFee": "Хүргэлтийн төлбөр", + "tip": "Цай", + "driverTip": "Жолоочийн цай", + "discount": "Хөнгөлөлт", + "total": "Нийт", + "paidWith": { + "card": "Картаар төлсөн", + "qpay": "QPay-ээр төлсөн", + "paypal": "PayPal-ээр төлсөн", + "cash": "Хүргэлтээр бэлнээр", + "other": "Аппаар төлсөн" + }, + "canceledNote": "Энэ захиалга цуцлагдсан.", + "delivery": "Хүргэсэн хаяг", + "pickup": "Авсан газар", + "notes": "Тэмдэглэл: {{notes}}", + "taxReceipt": "И-баримт", + "taxReceiptId": "Баримтын дугаар", + "loadFailed": "Баримтыг ачаалж чадсангүй." } } diff --git a/translations/uk.json b/translations/uk.json index ba22891e..249da259 100644 --- a/translations/uk.json +++ b/translations/uk.json @@ -1137,5 +1137,68 @@ "deleteBody": "Ми надішлемо код у SMS для підтвердження. Видалення прибере ваш обліковий запис, історію замовлень і збережені адреси, і його не можна скасувати.", "sendDeleteCode": "Надіслати код підтвердження", "keepAccount": "Залишити обліковий запис" + }, + "Orders": { + "title": "Замовлення", + "inProgress": "Виконуються", + "past": "Попередні замовлення", + "items": { + "one": "1 товар", + "other": "Товарів: {{count}}" + }, + "andMore": "і ще {{count}}", + "track": "Відстежити замовлення", + "viewPickup": "Деталі самовивозу", + "activeLabel": "{{store}}, {{status}}. Відкрити для відстеження", + "pastLabel": "{{store}}, {{status}}. Відкрити замовлення", + "receiptFor": "Чек для {{store}}", + "emptyTitle": "Замовлень поки немає", + "emptyBody": "Коли ви оформите замовлення, воно з'явиться тут, щоб ви могли стежити за ним і знайти чек.", + "startShopping": "Почати покупки", + "loadFailed": "Не вдалося завантажити замовлення.", + "retry": "Спробувати ще раз", + "status": { + "placed": "Оформлено", + "accepted": "Прийнято", + "preparing": "Готується", + "driverToStore": "Водій прямує до магазину", + "pickedUp": "Забрано", + "onTheWay": "В дорозі", + "delivered": "Доставлено", + "ready": "Готово до видачі", + "collected": "Отримано", + "canceled": "Скасовано" + } + }, + "Receipt": { + "title": "Чек", + "share": "Поділитися чеком", + "orderNumber": "Замовлення {{number}}", + "items": { + "one": "1 товар", + "other": "Товарів: {{count}}" + }, + "bookedFor": "Заброньовано на {{when}}", + "payment": "Оплата", + "subtotal": "Проміжна сума", + "deliveryFee": "Доставка", + "tip": "Чайові", + "driverTip": "Чайові водію", + "discount": "Знижка", + "total": "Разом", + "paidWith": { + "card": "Оплачено карткою", + "qpay": "Оплачено через QPay", + "paypal": "Оплачено через PayPal", + "cash": "Оплата готівкою при отриманні", + "other": "Оплачено в застосунку" + }, + "canceledNote": "Це замовлення скасовано.", + "delivery": "Доставлено за адресою", + "pickup": "Отримано в", + "notes": "Примітки: {{notes}}", + "taxReceipt": "Податковий чек (e-barimt)", + "taxReceiptId": "Номер чека", + "loadFailed": "Не вдалося завантажити чек." } }