Photo Capture
The photo capture pattern provides a camera interface for capturing receipts, documents, and evidence photos. It uses a full-screen camera view with a guide frame overlay.
Arahkan kamera ke receipt
Use Cases
| App | Context | Guide Frame |
|---|---|---|
| POS Electron | EDC receipt photo after card payment | Receipt-sized portrait rectangle |
| store-admin | Settlement evidence photos | Landscape rectangle |
| buyer-app | Purchase receipt capture | Receipt-sized portrait rectangle |
| stock-app-mobile | Damaged goods evidence | Square frame |
Anatomy
+-------------------------------------------+
| [X Close] [Flash icon] |
| |
| +------------------+ |
| | | |
| | Guide frame | |
| | (dashed border) | |
| | | |
| +------------------+ |
| "Posisikan struk di dalam bingkai"|
| |
| [ Capture button ] |
| [Gallery] [Switch cam] |
+-------------------------------------------+Visual Specifications
| Element | Style |
|---|---|
| Background | Camera feed, full screen |
| Guide frame | Dashed border, 2px, white at 60% opacity, rounded-lg |
| Guide text | 13px, white, text-shadow for readability, centered below frame |
| Capture button | 64px circle, white border 3px, inner circle 56px white filled |
| Close button | 32px, semi-transparent dark bg, white X icon, top-left |
| Flash toggle | 32px, same style as close, top-right |
| Gallery button | 40px, rounded-lg, show last photo thumbnail |
| Switch camera | 40px, Lucide SwitchCamera, white |
Capture Flow
- Camera opens in full-screen overlay (or modal on desktop)
- User positions subject within guide frame
- Tap capture button --- brief flash animation (white overlay, 100ms)
- Show captured photo with confirm/retake options
- On confirm: compress to JPEG (quality 0.8, max 1920px wide), return base64 or blob
Review Screen
After capture, show:
+-------------------------------------------+
| [< Retake] [Use Photo] |
| |
| [Captured photo preview] |
| |
+-------------------------------------------+- Retake: Ghost button, returns to camera
- Use Photo: Primary green button, confirms selection
React Component (Web)
tsx
interface PhotoCaptureProps {
guideShape: 'portrait' | 'landscape' | 'square'
guideText?: string
onCapture: (blob: Blob) => void
onClose: () => void
maxWidth?: number
quality?: number
}
export function PhotoCapture({
guideShape,
guideText = 'Posisikan di dalam bingkai',
onCapture,
onClose,
maxWidth = 1920,
quality = 0.8,
}: PhotoCaptureProps) {
const videoRef = useRef<HTMLVideoElement>(null)
const [captured, setCaptured] = useState<Blob | null>(null)
useEffect(() => {
navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width: { ideal: 1920 } },
}).then(stream => {
if (videoRef.current) videoRef.current.srcObject = stream
})
return () => {
// Stop all tracks on unmount
}
}, [])
const guideStyles = {
portrait: 'w-64 h-96',
landscape: 'w-96 h-64',
square: 'w-72 h-72',
}
const capture = () => {
const video = videoRef.current
if (!video) return
const canvas = document.createElement('canvas')
canvas.width = Math.min(video.videoWidth, maxWidth)
canvas.height = (canvas.width / video.videoWidth) * video.videoHeight
canvas.getContext('2d')?.drawImage(video, 0, 0, canvas.width, canvas.height)
canvas.toBlob(blob => {
if (blob) setCaptured(blob)
}, 'image/jpeg', quality)
}
if (captured) {
return (
<div className="fixed inset-0 z-50 bg-black flex flex-col">
<div className="flex-1 flex items-center justify-center p-4">
<img src={URL.createObjectURL(captured)} className="max-w-full max-h-full rounded-lg" />
</div>
<div className="flex justify-between p-4">
<button onClick={() => setCaptured(null)} className="text-white text-sm font-semibold">
Ulangi
</button>
<button
onClick={() => onCapture(captured)}
className="px-6 py-2 bg-brand text-white rounded-lg font-semibold"
>
Gunakan Foto
</button>
</div>
</div>
)
}
return (
<div className="fixed inset-0 z-50 bg-black flex flex-col">
<video ref={videoRef} autoPlay playsInline className="absolute inset-0 w-full h-full object-cover" />
{/* Close */}
<button onClick={onClose} className="absolute top-4 left-4 z-10 w-8 h-8 rounded-full bg-black/40 flex items-center justify-center">
<X className="w-4 h-4 text-white" />
</button>
{/* Guide frame */}
<div className="flex-1 flex flex-col items-center justify-center relative z-10">
<div className={cn('border-2 border-dashed border-white/60 rounded-lg', guideStyles[guideShape])} />
<p className="text-white/80 text-[13px] mt-3 text-center drop-shadow">{guideText}</p>
</div>
{/* Capture button */}
<div className="flex justify-center pb-8 relative z-10">
<button onClick={capture} className="w-16 h-16 rounded-full border-[3px] border-white flex items-center justify-center">
<div className="w-14 h-14 rounded-full bg-white" />
</button>
</div>
</div>
)
}Compression Guidelines
| Context | Max Width | Quality | Max File Size |
|---|---|---|---|
| EDC receipt | 1280px | 0.7 | 500KB |
| Settlement evidence | 1920px | 0.8 | 1MB |
| Damaged goods | 1920px | 0.8 | 1MB |
Offline support
Captured photos should be stored in IndexedDB (web) or filesystem (mobile) when offline, then uploaded when connection is restored via the sync agent.
Don't
- Don't auto-capture without user pressing the button
- Don't upload photos without showing the review screen first
- Don't store uncompressed photos --- always compress before saving