feat(receipts): upload guide page + scanner head-tag fix
Adds /invoices/upload-receipts as the dedicated explainer for the mobile scanner PWA: install instructions for iOS/Android, direct deep-link button, and a walkthrough of the scan -> verify -> save flow. Sidebar entry replaces the old "Scan receipt" tab so the desktop side picks up the install steps before sending users to the mobile-only surface. Scanner layout moves PWA manifest + apple-* meta tags from inline JSX into Next.js's metadata/viewport exports so the App Router doesn't try to render a second <head>, fixing a hydration error that surfaced as two console warnings on the scan page. Scanner shell gains a centered Port Nimara logo header so the standalone PWA looks branded when launched from the home screen without the dashboard chrome. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Camera, Loader2, RotateCcw, AlertTriangle, CheckCircle2, Save } from 'lucide-react';
|
||||
|
||||
const LOGO_URL =
|
||||
'https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -122,7 +126,7 @@ function VerifyForm({
|
||||
<div className="flex items-start gap-2 rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">Low-confidence read — please double-check the fields</p>
|
||||
<p className="font-medium">Low-confidence read - please double-check the fields</p>
|
||||
<p className="text-xs mt-0.5">
|
||||
{engineLabel} returned {Math.round(parsed.confidence * 100)}% confidence.
|
||||
</p>
|
||||
@@ -132,7 +136,7 @@ function VerifyForm({
|
||||
<div className="flex items-start gap-2 rounded-lg border border-emerald-300 bg-emerald-50 px-3 py-2 text-sm text-emerald-900">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">Receipt parsed — confirm the fields and save</p>
|
||||
<p className="font-medium">Receipt parsed - confirm the fields and save</p>
|
||||
<p className="text-xs mt-0.5">
|
||||
{engineLabel} · {Math.round(parsed.confidence * 100)}% confidence.
|
||||
</p>
|
||||
@@ -302,14 +306,14 @@ export function ScanShell() {
|
||||
setImagePreview(URL.createObjectURL(file));
|
||||
setState({ kind: 'processing', engine: 'tesseract' });
|
||||
|
||||
// Always run Tesseract first — it's free, on-device, and gives us a
|
||||
// Always run Tesseract first - it's free, on-device, and gives us a
|
||||
// baseline parse we can fall back to if the optional AI pass is off
|
||||
// or fails. The WASM bundle dynamic-imports inside `runTesseract`.
|
||||
let tesseract: Awaited<ReturnType<typeof runTesseract>> | null = null;
|
||||
try {
|
||||
tesseract = await runTesseract(file);
|
||||
} catch (err) {
|
||||
// Tesseract.js itself failed (corrupt image, OOM, etc). Don't bail —
|
||||
// Tesseract.js itself failed (corrupt image, OOM, etc). Don't bail -
|
||||
// give the user the manual form so they can still save the expense.
|
||||
setState({
|
||||
kind: 'verify',
|
||||
@@ -348,7 +352,7 @@ export function ScanShell() {
|
||||
const body = (await res.json()) as ScanResp;
|
||||
|
||||
if (body.data.source === 'ai' && body.data.parsed.confidence >= tesseract.parsed.confidence) {
|
||||
// AI did at least as well as Tesseract — prefer its result.
|
||||
// AI did at least as well as Tesseract - prefer its result.
|
||||
setState({
|
||||
kind: 'verify',
|
||||
parsed: body.data.parsed,
|
||||
@@ -360,7 +364,7 @@ export function ScanShell() {
|
||||
}
|
||||
|
||||
// Either AI is disabled (`source: 'manual', reason: 'ai-disabled'`),
|
||||
// not configured, or it underperformed — fall back to Tesseract.
|
||||
// not configured, or it underperformed - fall back to Tesseract.
|
||||
setState({
|
||||
kind: 'verify',
|
||||
parsed: tesseract.parsed,
|
||||
@@ -369,7 +373,7 @@ export function ScanShell() {
|
||||
providerError: body.data.providerError,
|
||||
});
|
||||
} catch {
|
||||
// Server unreachable — still let the user verify with the Tesseract
|
||||
// Server unreachable - still let the user verify with the Tesseract
|
||||
// result and save the expense. We don't surface the network error
|
||||
// because the local parse is usable.
|
||||
setState({
|
||||
@@ -392,7 +396,7 @@ export function ScanShell() {
|
||||
}) {
|
||||
setState({ kind: 'saving' });
|
||||
try {
|
||||
// Upload the image (multipart — apiFetch wraps JSON, so use raw fetch).
|
||||
// Upload the image (multipart - apiFetch wraps JSON, so use raw fetch).
|
||||
const fd = new FormData();
|
||||
fd.append('file', input.file);
|
||||
fd.append('category', 'receipt');
|
||||
@@ -443,13 +447,26 @@ export function ScanShell() {
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-[100dvh] w-full max-w-xl flex-col gap-4 px-4 py-6 sm:py-10">
|
||||
<header className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">Scan a receipt</h1>
|
||||
{state.kind !== 'idle' ? (
|
||||
<Button variant="ghost" size="sm" onClick={reset}>
|
||||
Start over
|
||||
</Button>
|
||||
) : null}
|
||||
{/* Brand header - logo centered, page title underneath. Establishes
|
||||
the standalone identity (this is the PWA home for the scanner). */}
|
||||
<header className="flex flex-col items-center gap-3">
|
||||
<Image
|
||||
src={LOGO_URL}
|
||||
alt="Port Nimara"
|
||||
width={64}
|
||||
height={64}
|
||||
className="rounded-full shadow-md"
|
||||
priority
|
||||
unoptimized
|
||||
/>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">Scan a receipt</h1>
|
||||
{state.kind !== 'idle' ? (
|
||||
<Button variant="ghost" size="sm" onClick={reset}>
|
||||
Start over
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{state.kind === 'idle' ? (
|
||||
|
||||
Reference in New Issue
Block a user