Skip to content

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

AppContextGuide Frame
POS ElectronEDC receipt photo after card paymentReceipt-sized portrait rectangle
store-adminSettlement evidence photosLandscape rectangle
buyer-appPurchase receipt captureReceipt-sized portrait rectangle
stock-app-mobileDamaged goods evidenceSquare frame

Anatomy

+-------------------------------------------+
|  [X Close]                    [Flash icon] |
|                                            |
|          +------------------+              |
|          |                  |              |
|          |  Guide frame     |              |
|          |  (dashed border) |              |
|          |                  |              |
|          +------------------+              |
|          "Posisikan struk di dalam bingkai"|
|                                            |
|              [ Capture button ]            |
|         [Gallery]          [Switch cam]    |
+-------------------------------------------+

Visual Specifications

ElementStyle
BackgroundCamera feed, full screen
Guide frameDashed border, 2px, white at 60% opacity, rounded-lg
Guide text13px, white, text-shadow for readability, centered below frame
Capture button64px circle, white border 3px, inner circle 56px white filled
Close button32px, semi-transparent dark bg, white X icon, top-left
Flash toggle32px, same style as close, top-right
Gallery button40px, rounded-lg, show last photo thumbnail
Switch camera40px, Lucide SwitchCamera, white

Capture Flow

  1. Camera opens in full-screen overlay (or modal on desktop)
  2. User positions subject within guide frame
  3. Tap capture button --- brief flash animation (white overlay, 100ms)
  4. Show captured photo with confirm/retake options
  5. 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

ContextMax WidthQualityMax File Size
EDC receipt1280px0.7500KB
Settlement evidence1920px0.81MB
Damaged goods1920px0.81MB

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

RetailOS - Sistem ERP Retail Modern untuk Indonesia