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:
Matt Ciaccio
2026-05-04 22:55:42 +02:00
parent 77ad10ced1
commit 089f4a67a4
4 changed files with 333 additions and 30 deletions

View File

@@ -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' ? (