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

@@ -0,0 +1,16 @@
import type { Metadata } from 'next';
import { UploadReceiptsGuide } from '@/components/invoices/upload-receipts-guide';
export const metadata: Metadata = {
title: 'How to upload receipts',
};
export default async function UploadReceiptsPage({
params,
}: {
params: Promise<{ portSlug: string }>;
}) {
const { portSlug } = await params;
return <UploadReceiptsGuide portSlug={portSlug} />;
}

View File

@@ -1,20 +1,51 @@
import type { Metadata, Viewport } from 'next';
import { redirect } from 'next/navigation';
import { headers } from 'next/headers';
import { eq } from 'drizzle-orm';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { ports as portsTable } from '@/lib/db/schema/ports';
import { QueryProvider } from '@/providers/query-provider';
import { PortProvider } from '@/providers/port-provider';
import { eq } from 'drizzle-orm';
/**
* Minimal layout for the mobile receipt-scanner PWA. No sidebar, no
* topbar the scanner is its own contained surface. Adds the PWA
* manifest link + theme color so iOS/Android pick up "Add to Home
* Screen". Auth check matches the dashboard layout so unauthorized
* users still bounce to /login.
* topbar - the scanner is its own contained surface. PWA manifest +
* iOS web-app meta tags are emitted via Next.js's metadata/viewport
* exports so React doesn't try to render a second `<head>` mid-tree
* (which throws hydration errors in the App Router). Auth check
* matches the dashboard layout so unauthorized users still bounce.
*/
export async function generateMetadata({
params,
}: {
params: Promise<{ portSlug: string }>;
}): Promise<Metadata> {
const { portSlug } = await params;
return {
manifest: `/${portSlug}/scan/manifest.webmanifest`,
appleWebApp: {
capable: true,
title: 'PN Scanner',
statusBarStyle: 'default',
},
other: {
// Android/Chrome equivalent of the apple-* meta. metadata.appleWebApp
// covers iOS only; this preserves the existing PWA hint for Chrome.
'mobile-web-app-capable': 'yes',
},
};
}
export const viewport: Viewport = {
themeColor: '#3a7bc8',
width: 'device-width',
initialScale: 1,
viewportFit: 'cover',
};
export default async function ScannerLayout({
children,
params,
@@ -33,16 +64,7 @@ export default async function ScannerLayout({
return (
<QueryProvider>
<PortProvider ports={port ? [port] : []} defaultPortId={port?.id ?? null}>
<head>
<link rel="manifest" href={`/${portSlug}/scan/manifest.webmanifest`} />
<meta name="theme-color" content="#3a7bc8" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="PN Scanner" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
</head>
<PortProvider ports={[port]} defaultPortId={port.id}>
<div className="min-h-[100dvh] bg-background">{children}</div>
</PortProvider>
</QueryProvider>

View File

@@ -0,0 +1,248 @@
'use client';
/**
* Explainer page that lives under /<portSlug>/invoices/upload-receipts.
*
* The actual scanner UI is mobile-only and lives at /<portSlug>/scan as a
* standalone PWA surface. This page tells operators how to install it on
* their phone's home screen and walks through the workflow. It also
* surfaces a direct "Open scanner" button as a fallback for users who
* just want to navigate without installing.
*/
import Link from 'next/link';
import { Camera, Smartphone, Apple, Globe, ArrowRight, Sparkles } from 'lucide-react';
import { PageHeader } from '@/components/shared/page-header';
import { Button } from '@/components/ui/button';
interface Props {
portSlug: string;
}
export function UploadReceiptsGuide({ portSlug }: Props) {
const scannerUrl = `/${portSlug}/scan`;
return (
<div className="space-y-6">
<PageHeader
title="How to upload receipts for reimbursement"
eyebrow="Business expenses"
description="When you spend your own money on a business expense for the marina, use this to log it. Snap a photo of the receipt with your phone, the system reads it for you, and finance approves it on the parent company's side."
variant="gradient"
actions={
<Button asChild>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Link href={scannerUrl as any}>
<Camera className="mr-2 h-4 w-4" />
Open scanner
</Link>
</Button>
}
/>
{/* What it does, in plain English */}
<section className="rounded-xl border border-border bg-card p-5 shadow-xs">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand/10 text-brand">
<Sparkles className="h-5 w-5" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<h2 className="text-base font-semibold">What does it actually do?</h2>
<p className="mt-1 text-sm text-muted-foreground">
You paid out of pocket for something the marina needs (fuel, hardware, a part run,
lunch with a broker). Snap a photo of the receipt and this tool turns it into a
reimbursement request. It pulls out the vendor, the date, the total, and the currency,
drops them into the expense form, and queues the whole thing for the parent
company&apos;s finance team to approve and pay you back.
</p>
<p className="mt-2 text-sm text-muted-foreground">
The behind-the-scenes part is called OCR (short for &ldquo;optical character
recognition&rdquo;). Think of it as a fancy phone camera that knows how to read
printed text. Combined with a bit of AI to figure out which number is the total and
which is the tax, it turns a paper receipt into a ready-to-save expense in about five
seconds. No typing. No spreadsheets. No chasing finance for the form.
</p>
</div>
</div>
</section>
{/* Two-step explainer: install first, then use */}
<div className="grid gap-4 lg:grid-cols-2">
<Step
number={1}
icon={<Smartphone className="h-5 w-5" />}
title="Add the scanner to your phone"
description="One-time setup. After this, the scanner opens like a normal app from your home screen."
>
<PlatformBlock
icon={<Apple className="h-4 w-4" />}
label="iPhone or iPad (Safari)"
steps={[
<>
Open{' '}
<Link
href={scannerUrl as never}
className="text-brand underline-offset-2 hover:underline"
>
this link
</Link>{' '}
in Safari on your phone.
</>,
'Tap the Share button at the bottom of the screen (the square with the arrow pointing up).',
<>
Scroll down and tap <span className="font-medium">Add to Home Screen</span>.
</>,
'Confirm the name "Scanner" and tap Add. The icon now sits on your home screen.',
]}
/>
<PlatformBlock
icon={<Globe className="h-4 w-4" />}
label="Android phone (Chrome)"
steps={[
<>
Open{' '}
<Link
href={scannerUrl as never}
className="text-brand underline-offset-2 hover:underline"
>
this link
</Link>{' '}
in Chrome on your phone.
</>,
'Tap the three-dot menu in the top-right corner.',
<>
Tap <span className="font-medium">Install app</span> (older versions of Chrome say{' '}
<span className="font-medium">Add to Home screen</span>).
</>,
'Confirm to install. The icon now sits on your home screen.',
]}
/>
</Step>
<Step
number={2}
icon={<Camera className="h-5 w-5" />}
title="Snap a photo of a receipt"
description="Open the scanner from your home screen and follow the prompts. The whole thing takes about ten seconds."
>
<ol className="space-y-3 pl-4 text-sm text-muted-foreground list-decimal">
<li>
<span className="font-medium text-foreground">Tap the camera tile.</span> Your phone
opens its camera. Hold the receipt flat, get the whole thing in the frame, and snap.
</li>
<li>
<span className="font-medium text-foreground">Wait a few seconds.</span> The system
reads the receipt and fills in the merchant, date, total, and currency for you. A
loading spinner shows while this happens.
</li>
<li>
<span className="font-medium text-foreground">Glance over the numbers.</span> Most of
the time everything is correct. If something looks off (wrong total, wrong category),
tap the field and fix it. The category is the field you most often need to set
yourself.
</li>
<li>
<span className="font-medium text-foreground">Tap Save.</span> The receipt becomes a
pending expense ready for reimbursement. The parent company&apos;s finance team will
review it on the{' '}
<Link
href={`/${portSlug}/expenses` as never}
className="text-brand underline-offset-2 hover:underline"
>
Expenses page
</Link>{' '}
and approve it for payback. You can check the status of any expense you submitted from
there too.
</li>
</ol>
</Step>
</div>
{/* Tips */}
<div className="rounded-xl border border-dashed border-border bg-muted/30 p-5">
<h3 className="text-sm font-semibold">Tips for the best results</h3>
<ul className="mt-2 space-y-1.5 text-sm text-muted-foreground list-disc pl-5">
<li>
Get the whole receipt in the frame. If the edges are cut off, the total or date might be
missed and finance might bounce it back to you.
</li>
<li>Hold the camera steady. Blurry photos are harder to read. Retake if needed.</li>
<li>
Receipts in foreign currencies are fine. The scanner picks up the currency code if it is
printed on the slip. The parent company handles the conversion when they reimburse you.
</li>
<li>If the camera looks dim, just turn on a light. Bright, even lighting works best.</li>
<li>
Add a quick note in the description if the expense needs context (who you met, what the
part was for, etc.). Saves finance from having to ask.
</li>
<li>
Lost the home-screen icon? Open this page on your phone again and tap the{' '}
<span className="font-medium">Open scanner</span> button at the top.
</li>
</ul>
</div>
</div>
);
}
function Step({
number,
icon,
title,
description,
children,
}: {
number: number;
icon: React.ReactNode;
title: string;
description: string;
children: React.ReactNode;
}) {
return (
<section className="rounded-xl border border-border bg-card p-5 shadow-xs">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand/10 text-brand">
{icon}
</div>
<div className="min-w-0 flex-1">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Step {number}
</div>
<h2 className="mt-0.5 text-base font-semibold">{title}</h2>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
</div>
</div>
<div className="mt-4 space-y-4">{children}</div>
</section>
);
}
function PlatformBlock({
icon,
label,
steps,
}: {
icon: React.ReactNode;
label: string;
steps: React.ReactNode[];
}) {
return (
<div className="rounded-lg border border-border/60 bg-background/50 p-3">
<div className="flex items-center gap-2 text-sm font-medium">
{icon}
{label}
</div>
<ol className="mt-2 space-y-1.5 pl-5 text-sm text-muted-foreground list-decimal">
{steps.map((s, i) => (
<li key={i}>{s}</li>
))}
</ol>
<div className="mt-2 flex items-center text-xs text-muted-foreground/80">
<ArrowRight className="mr-1 h-3 w-3" aria-hidden />
Done. The scanner now opens from your home screen like a normal app.
</div>
</div>
);
}

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