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()}>
+
+
+
+
+ Foto verifikasi
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/libs/ui/src/presentation/views/components/checkout/CustomerDetailsSheet.stories.tsx b/libs/ui/src/presentation/views/components/checkout/CustomerDetailsSheet.stories.tsx
index 84d48e16..ad15e624 100644
--- a/libs/ui/src/presentation/views/components/checkout/CustomerDetailsSheet.stories.tsx
+++ b/libs/ui/src/presentation/views/components/checkout/CustomerDetailsSheet.stories.tsx
@@ -33,10 +33,7 @@ const meta: Meta = {
},
cashierLocation: 'Lantai 1',
verificationPhoto: null,
- onCapturePhoto: () => {
- // Storybook action stand-in
- },
- onRetakePhoto: () => {
+ onOpenPhotoCapture: () => {
// Storybook action stand-in
},
isSubmitting: false,
diff --git a/libs/ui/src/presentation/views/components/checkout/CustomerDetailsSheet.tsx b/libs/ui/src/presentation/views/components/checkout/CustomerDetailsSheet.tsx
index a0f4f1f5..08984c16 100644
--- a/libs/ui/src/presentation/views/components/checkout/CustomerDetailsSheet.tsx
+++ b/libs/ui/src/presentation/views/components/checkout/CustomerDetailsSheet.tsx
@@ -3,6 +3,7 @@ import {
Image,
Input,
Paragraph,
+ ScrollView,
Spinner,
XStack,
YStack,
@@ -13,8 +14,7 @@ import {
PaymentMethod,
} from '../../../../domain/entities/Payment';
import { Sheet } from '../base/Sheet';
-import { CameraCapture } from '../base/CameraCapture';
-import { QrCode, RotateCcw, Wallet } from '@tamagui/lucide-icons';
+import { Camera, QrCode, RotateCcw, Wallet } from '@tamagui/lucide-icons';
export type CustomerDetailsSheetProps = {
isOpen: boolean;
@@ -33,8 +33,7 @@ export type CustomerDetailsSheetProps = {
onDiningOptionChange: (diningOption: PaymentDiningOption) => void;
cashierLocation: string;
verificationPhoto: string | null;
- onCapturePhoto: (photo: string) => void;
- onRetakePhoto: () => void;
+ onOpenPhotoCapture: () => void;
isSubmitting: boolean;
};
@@ -96,8 +95,7 @@ export const CustomerDetailsSheet = ({
diningOption,
onDiningOptionChange,
verificationPhoto,
- onCapturePhoto,
- onRetakePhoto,
+ onOpenPhotoCapture,
isSubmitting,
}: CustomerDetailsSheetProps) => {
const isCodPhotoMissing = method === 'cod' && !verificationPhoto;
@@ -112,140 +110,154 @@ export const CustomerDetailsSheet = ({
isOpen={isOpen}
onOpenChange={(open) => !open && !isSubmitting && onCancelPress()}
>
-
-
- Data pemesan
-
+
+
+
+
+ Data pemesan
+
-
-
- {nameErrorMessage ? (
- {nameErrorMessage}
- ) : null}
-
-
-
-
- {whatsappNumberErrorMessage ? (
- {whatsappNumberErrorMessage}
- ) : null}
-
- Nomor ini akan digunakan untuk mengabari Anda apabila pesanan siap
- diambil.
-
-
+
+
+ {nameErrorMessage ? (
+ {nameErrorMessage}
+ ) : null}
+
-
-
- Makan di sini atau bawa pulang?
-
-
- {(['dine_in', 'takeaway'] as const).map((option) => (
-
- ))}
-
-
+ />
+ {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) => (
+
+
+ {enabledMethods.length > 1 ? (
+
+ Metode Pembayaran
+
+ {enabledMethods.map((option) => {
+ const {
+ icon: Icon,
+ accessibilityLabel,
+ label,
+ description,
+ } = methodOptions[option];
+ return (
+
-
- );
- })}
-
-
- ) : null}
+
+
+
+ {label}
+
+
+ {description}
+
+
+
+ );
+ })}
+
+
+ ) : null}
- {method === 'cod' ? (
-
- {verificationPhoto ? (
-
-
-
+ {method === 'cod' ? (
+
+ {verificationPhoto ? (
+
+
+
+
+ ) : (
+
+ )}
- ) : (
-
- )}
+ ) : null}
- ) : null}
+
-
+