From 88aa25951faca80d78c27d025418968457306b32 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 02:21:00 +0000 Subject: [PATCH] feat(order): move COD verification photo into a dedicated capture step Picking COD used to embed the camera inline in the customer-details sheet, so the viewfinder was squeezed small and, once a photo was added, the sheet's content outgrew the fixed-height frame with nothing to scroll it. Now COD hands off to its own full sheet (CodPhotoCaptureSheet) with a full-width viewfinder, while the details sheet gets a scrollable body with pinned action buttons. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EGS6rwnshDjEFNh2ZYFcxe --- libs/ui/src/domain/usecases/checkout.test.ts | 17 +- libs/ui/src/domain/usecases/checkout.ts | 20 +- .../handlers/order/CartHandler.test.tsx | 33 +++ .../handlers/order/CartHandler.tsx | 15 +- .../components/base/CameraCapture/index.tsx | 10 +- .../checkout/CodPhotoCaptureSheet.stories.tsx | 34 +++ .../checkout/CodPhotoCaptureSheet.tsx | 41 +++ .../checkout/CustomerDetailsSheet.stories.tsx | 5 +- .../checkout/CustomerDetailsSheet.tsx | 262 +++++++++--------- .../screens/order/CartScreen.stories.tsx | 1 + .../views/screens/order/CartScreen.tsx | 7 + 11 files changed, 301 insertions(+), 144 deletions(-) create mode 100644 libs/ui/src/presentation/views/components/checkout/CodPhotoCaptureSheet.stories.tsx create mode 100644 libs/ui/src/presentation/views/components/checkout/CodPhotoCaptureSheet.tsx diff --git a/libs/ui/src/domain/usecases/checkout.test.ts b/libs/ui/src/domain/usecases/checkout.test.ts index af547469..d799a1d8 100644 --- a/libs/ui/src/domain/usecases/checkout.test.ts +++ b/libs/ui/src/domain/usecases/checkout.test.ts @@ -427,6 +427,7 @@ describe('CheckoutUsecase', () => { checkout.dispatch({ type: 'ASK_DETAILS' }); checkout.dispatch({ type: 'CHANGE_METHOD', method: 'cod' }); + checkout.dispatch({ type: 'OPEN_PHOTO_CAPTURE' }); checkout.dispatch({ type: 'CAPTURE_PHOTO', photo: 'base64jpeg' }); checkout.dispatch({ type: 'SUBMIT_DETAILS' }); expect(checkout.state.type).toBe('creatingPayment'); @@ -444,16 +445,24 @@ describe('CheckoutUsecase', () => { expect(checkout.state.payment?.method).toBe('cod'); }); - it('should clear the captured photo on RETAKE_PHOTO', () => { + it('should move to a dedicated capturingPhoto step and back, keeping the photo already taken', () => { const repository = new MockPaymentRepository(); const checkout = createTester(repository); checkout.dispatch({ type: 'ASK_DETAILS' }); checkout.dispatch({ type: 'CHANGE_METHOD', method: 'cod' }); + checkout.dispatch({ type: 'OPEN_PHOTO_CAPTURE' }); + expect(checkout.state.type).toBe('capturingPhoto'); + checkout.dispatch({ type: 'CAPTURE_PHOTO', photo: 'base64jpeg' }); - checkout.dispatch({ type: 'RETAKE_PHOTO' }); + expect(checkout.state.type).toBe('askingDetails'); + expect(checkout.state.verificationPhoto).toBe('base64jpeg'); - expect(checkout.state.verificationPhoto).toBeNull(); + checkout.dispatch({ type: 'OPEN_PHOTO_CAPTURE' }); + checkout.dispatch({ type: 'BACK_TO_DETAILS' }); + + expect(checkout.state.type).toBe('askingDetails'); + expect(checkout.state.verificationPhoto).toBe('base64jpeg'); }); it('should clear the captured photo when the method changes away from cod', () => { @@ -462,6 +471,7 @@ describe('CheckoutUsecase', () => { checkout.dispatch({ type: 'ASK_DETAILS' }); checkout.dispatch({ type: 'CHANGE_METHOD', method: 'cod' }); + checkout.dispatch({ type: 'OPEN_PHOTO_CAPTURE' }); checkout.dispatch({ type: 'CAPTURE_PHOTO', photo: 'base64jpeg' }); checkout.dispatch({ type: 'CHANGE_METHOD', method: 'qris' }); @@ -495,6 +505,7 @@ describe('CheckoutUsecase', () => { checkout.dispatch({ type: 'ASK_DETAILS' }); checkout.dispatch({ type: 'CHANGE_METHOD', method: 'cod' }); + checkout.dispatch({ type: 'OPEN_PHOTO_CAPTURE' }); checkout.dispatch({ type: 'CAPTURE_PHOTO', photo: 'base64jpeg' }); checkout.dispatch({ type: 'SUBMIT_DETAILS' }); await flushMicrotasks(); diff --git a/libs/ui/src/domain/usecases/checkout.ts b/libs/ui/src/domain/usecases/checkout.ts index 5a6e4753..e07f2612 100644 --- a/libs/ui/src/domain/usecases/checkout.ts +++ b/libs/ui/src/domain/usecases/checkout.ts @@ -29,6 +29,7 @@ type Context = { export type CheckoutState = ( | { type: 'idle' } | { type: 'askingDetails' } + | { type: 'capturingPhoto' } | { type: 'creatingPayment' } | { type: 'created' } | { type: 'error' } @@ -41,8 +42,9 @@ export type CheckoutAction = | { type: 'CHANGE_WHATSAPP_NUMBER'; whatsappNumber: string } | { type: 'CHANGE_METHOD'; method: PaymentMethod } | { type: 'CHANGE_DINING_OPTION'; diningOption: PaymentDiningOption } + | { type: 'OPEN_PHOTO_CAPTURE' } | { type: 'CAPTURE_PHOTO'; photo: string } - | { type: 'RETAKE_PHOTO' } + | { type: 'BACK_TO_DETAILS' } | { type: 'CANCEL_DETAILS' } | { type: 'SUBMIT_DETAILS' } | { type: 'CHECKOUT_SUCCESS'; payment: Payment } @@ -161,12 +163,20 @@ export class CheckoutUsecase extends Usecase< ([state, { diningOption }]) => ({ ...state, diningOption }) ) .with( - [{ type: 'askingDetails' }, { type: 'CAPTURE_PHOTO' }], - ([state, { photo }]) => ({ ...state, verificationPhoto: photo }) + [{ type: 'askingDetails' }, { type: 'OPEN_PHOTO_CAPTURE' }], + ([state]) => ({ ...state, type: 'capturingPhoto' }) ) .with( - [{ type: 'askingDetails' }, { type: 'RETAKE_PHOTO' }], - ([state]) => ({ ...state, verificationPhoto: null }) + [{ type: 'capturingPhoto' }, { type: 'CAPTURE_PHOTO' }], + ([state, { photo }]) => ({ + ...state, + type: 'askingDetails', + verificationPhoto: photo, + }) + ) + .with( + [{ type: 'capturingPhoto' }, { type: 'BACK_TO_DETAILS' }], + ([state]) => ({ ...state, type: 'askingDetails' }) ) .with( [{ type: 'askingDetails' }, { type: 'CANCEL_DETAILS' }], diff --git a/libs/ui/src/presentation/handlers/order/CartHandler.test.tsx b/libs/ui/src/presentation/handlers/order/CartHandler.test.tsx index cf31a74a..e5239a13 100644 --- a/libs/ui/src/presentation/handlers/order/CartHandler.test.tsx +++ b/libs/ui/src/presentation/handlers/order/CartHandler.test.tsx @@ -638,6 +638,9 @@ describe('CartHandler', () => { ).disabled ).toBe(true); + await user.click( + screen.getByRole('button', { name: 'Ambil foto verifikasi' }) + ); await capturePhoto(user, container); const submitButton = screen.getByRole('button', { @@ -661,6 +664,36 @@ describe('CartHandler', () => { `/orders/${paymentRepository.payment.reference}` ); }); + + it('opens a dedicated photo capture step, and going back leaves the details sheet without a photo', async () => { + stubCamera(); + const user = userEvent.setup(); + const cartRepository = new MockCartRepository(); + await addItemToCart(cartRepository); + renderHandler({ + cartRepository, + customerName: 'Budi', + customerWhatsappNumber: '081234567890', + enabledMethods: ['qris', 'cod'], + }); + await settle(); + + await user.click(screen.getByRole('button', { name: payButtonName })); + await user.click(screen.getByLabelText('Bayar dengan COD')); + await user.click( + screen.getByRole('button', { name: 'Ambil foto verifikasi' }) + ); + + expect(screen.getByText('Foto verifikasi')).toBeTruthy(); + expect(screen.queryByPlaceholderText('Nama Anda')).toBeNull(); + + await user.click(screen.getByRole('button', { name: 'Kembali' })); + + expect(screen.getByPlaceholderText('Nama Anda')).toBeTruthy(); + expect( + screen.getByRole('button', { name: 'Ambil foto verifikasi' }) + ).toBeTruthy(); + }); }); it('submits directly when the sheet opens prefilled with a valid name and WhatsApp number', async () => { diff --git a/libs/ui/src/presentation/handlers/order/CartHandler.tsx b/libs/ui/src/presentation/handlers/order/CartHandler.tsx index 7b5739c2..b376cff3 100644 --- a/libs/ui/src/presentation/handlers/order/CartHandler.tsx +++ b/libs/ui/src/presentation/handlers/order/CartHandler.tsx @@ -13,6 +13,7 @@ import { useCart } from '../hooks/useCart'; import { useCheckout } from '../hooks/useCheckout'; import { usePaymentCancel } from '../hooks/usePaymentCancel'; import { useTableResolve } from '../hooks/useTableResolve'; +import { CodPhotoCaptureSheetProps } from '../../views/components/checkout/CodPhotoCaptureSheet'; import { CustomerDetailsSheetProps } from '../../views/components/checkout/CustomerDetailsSheet'; import { CartItemEditScreenProps } from '../../views/screens/order/CartItemEditScreen'; import { @@ -241,10 +242,19 @@ export const CartHandler = ({ checkout.dispatch({ type: 'CHANGE_DINING_OPTION', diningOption }), cashierLocation, verificationPhoto: checkout.state.verificationPhoto, + onOpenPhotoCapture: () => + checkout.dispatch({ type: 'OPEN_PHOTO_CAPTURE' }), + isSubmitting: isSubmittingDetails, + } + : null; + + const photoCapture: (CodPhotoCaptureSheetProps & { isOpen: true }) | null = + checkout.state.type === 'capturingPhoto' + ? { + isOpen: true, onCapturePhoto: (photo) => checkout.dispatch({ type: 'CAPTURE_PHOTO', photo }), - onRetakePhoto: () => checkout.dispatch({ type: 'RETAKE_PHOTO' }), - isSubmitting: isSubmittingDetails, + onBackPress: () => checkout.dispatch({ type: 'BACK_TO_DETAILS' }), } : null; @@ -311,6 +321,7 @@ export const CartHandler = ({ onCheckoutPress={() => checkout.dispatch({ type: 'ASK_DETAILS' })} onCheckoutRetryPress={() => checkout.dispatch({ type: 'SUBMIT_DETAILS' })} detailsSheet={detailsSheet} + photoCapture={photoCapture} lockedNotice={lockedNotice} cancelConfirmation={cancelConfirmation} /> diff --git a/libs/ui/src/presentation/views/components/base/CameraCapture/index.tsx b/libs/ui/src/presentation/views/components/base/CameraCapture/index.tsx index 67224b4a..9bcdab72 100644 --- a/libs/ui/src/presentation/views/components/base/CameraCapture/index.tsx +++ b/libs/ui/src/presentation/views/components/base/CameraCapture/index.tsx @@ -13,7 +13,7 @@ type CameraState = | { type: 'denied' } | { type: 'unsupported' }; -const VIEWFINDER_SIZE = 280; +const VIEWFINDER_ASPECT_RATIO = 3 / 4; const MAX_CAPTURE_DIMENSION = 1280; const JPEG_QUALITY = 0.7; @@ -144,8 +144,8 @@ export const CameraCapture = ({ onCapture }: CameraCaptureProps) => { @@ -164,8 +164,8 @@ export const CameraCapture = ({ onCapture }: CameraCaptureProps) => { {GUIDANCE_TEXT} Promise) { + Object.defineProperty(navigator, 'mediaDevices', { + configurable: true, + value: { getUserMedia: impl }, + }); +} + +const meta: Meta = { + title: 'Components/Checkout/CodPhotoCaptureSheet', + component: CodPhotoCaptureSheet, + args: { + isOpen: true, + onCapturePhoto: fn(), + onBackPress: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +export const Requesting: Story = { + render: (args) => { + stubGetUserMedia(() => new Promise(() => undefined)); + return ; + }, +}; + +export const Closed: Story = { + args: { isOpen: false }, +}; diff --git a/libs/ui/src/presentation/views/components/checkout/CodPhotoCaptureSheet.tsx b/libs/ui/src/presentation/views/components/checkout/CodPhotoCaptureSheet.tsx new file mode 100644 index 00000000..db6d729c --- /dev/null +++ b/libs/ui/src/presentation/views/components/checkout/CodPhotoCaptureSheet.tsx @@ -0,0 +1,41 @@ +import { Button, Paragraph, ScrollView, XStack, YStack } from 'tamagui'; +import { ChevronLeft } from '@tamagui/lucide-icons'; +import { Sheet } from '../base/Sheet'; +import { CameraCapture } from '../base/CameraCapture'; + +export type CodPhotoCaptureSheetProps = { + isOpen: boolean; + onCapturePhoto: (photo: string) => void; + onBackPress: () => void; +}; + +export const CodPhotoCaptureSheet = ({ + isOpen, + onCapturePhoto, + onBackPress, +}: CodPhotoCaptureSheetProps) => { + return ( + !open && onBackPress()}> + + + - ))} - - + /> + {whatsappNumberErrorMessage ? ( + + {whatsappNumberErrorMessage} + + ) : null} + + Nomor ini akan digunakan untuk mengabari Anda apabila pesanan + siap diambil. + + - {enabledMethods.length > 1 ? ( - - Metode Pembayaran - - {enabledMethods.map((option) => { - const { - icon: Icon, - accessibilityLabel, - label, - description, - } = methodOptions[option]; - return ( + + + Makan di sini atau bawa pulang? + + + {(['dine_in', 'takeaway'] as const).map((option) => ( - ); - })} - - - ) : null} + + + + {label} + + + {description} + + + + ); + })} + + + ) : null} - {method === 'cod' ? ( - - {verificationPhoto ? ( - - - + {method === 'cod' ? ( + + {verificationPhoto ? ( + + + + + ) : ( + + )} - ) : ( - - )} + ) : null} - ) : null} + - +