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:
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
248
src/components/invoices/upload-receipts-guide.tsx
Normal file
248
src/components/invoices/upload-receipts-guide.tsx
Normal 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'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 “optical character
|
||||
recognition”). 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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