diff --git a/src/app/(dashboard)/[portSlug]/invoices/upload-receipts/page.tsx b/src/app/(dashboard)/[portSlug]/invoices/upload-receipts/page.tsx new file mode 100644 index 0000000..716a8a9 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/invoices/upload-receipts/page.tsx @@ -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 ; +} diff --git a/src/app/(scanner)/[portSlug]/scan/layout.tsx b/src/app/(scanner)/[portSlug]/scan/layout.tsx index 6c65708..83fb311 100644 --- a/src/app/(scanner)/[portSlug]/scan/layout.tsx +++ b/src/app/(scanner)/[portSlug]/scan/layout.tsx @@ -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 `` 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 { + 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 ( - - - - - - - - - - +
{children}
diff --git a/src/components/invoices/upload-receipts-guide.tsx b/src/components/invoices/upload-receipts-guide.tsx new file mode 100644 index 0000000..fcdee7c --- /dev/null +++ b/src/components/invoices/upload-receipts-guide.tsx @@ -0,0 +1,248 @@ +'use client'; + +/** + * Explainer page that lives under //invoices/upload-receipts. + * + * The actual scanner UI is mobile-only and lives at //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 ( +
+ + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + + + Open scanner + + + } + /> + + {/* What it does, in plain English */} +
+
+
+ +
+
+

What does it actually do?

+

+ 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. +

+

+ 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. +

+
+
+
+ + {/* Two-step explainer: install first, then use */} +
+ } + title="Add the scanner to your phone" + description="One-time setup. After this, the scanner opens like a normal app from your home screen." + > + } + label="iPhone or iPad (Safari)" + steps={[ + <> + Open{' '} + + this 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 Add to Home Screen. + , + 'Confirm the name "Scanner" and tap Add. The icon now sits on your home screen.', + ]} + /> + } + label="Android phone (Chrome)" + steps={[ + <> + Open{' '} + + this link + {' '} + in Chrome on your phone. + , + 'Tap the three-dot menu in the top-right corner.', + <> + Tap Install app (older versions of Chrome say{' '} + Add to Home screen). + , + 'Confirm to install. The icon now sits on your home screen.', + ]} + /> + + + } + 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." + > +
    +
  1. + Tap the camera tile. Your phone + opens its camera. Hold the receipt flat, get the whole thing in the frame, and snap. +
  2. +
  3. + Wait a few seconds. The system + reads the receipt and fills in the merchant, date, total, and currency for you. A + loading spinner shows while this happens. +
  4. +
  5. + Glance over the numbers. 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. +
  6. +
  7. + Tap Save. The receipt becomes a + pending expense ready for reimbursement. The parent company's finance team will + review it on the{' '} + + Expenses page + {' '} + and approve it for payback. You can check the status of any expense you submitted from + there too. +
  8. +
+
+
+ + {/* Tips */} +
+

Tips for the best results

+
    +
  • + 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. +
  • +
  • Hold the camera steady. Blurry photos are harder to read. Retake if needed.
  • +
  • + 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. +
  • +
  • If the camera looks dim, just turn on a light. Bright, even lighting works best.
  • +
  • + 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. +
  • +
  • + Lost the home-screen icon? Open this page on your phone again and tap the{' '} + Open scanner button at the top. +
  • +
+
+
+ ); +} + +function Step({ + number, + icon, + title, + description, + children, +}: { + number: number; + icon: React.ReactNode; + title: string; + description: string; + children: React.ReactNode; +}) { + return ( +
+
+
+ {icon} +
+
+
+ Step {number} +
+

{title}

+

{description}

+
+
+
{children}
+
+ ); +} + +function PlatformBlock({ + icon, + label, + steps, +}: { + icon: React.ReactNode; + label: string; + steps: React.ReactNode[]; +}) { + return ( +
+
+ {icon} + {label} +
+
    + {steps.map((s, i) => ( +
  1. {s}
  2. + ))} +
+
+ + Done. The scanner now opens from your home screen like a normal app. +
+
+ ); +} diff --git a/src/components/scan/scan-shell.tsx b/src/components/scan/scan-shell.tsx index 0399a67..933c8f8 100644 --- a/src/components/scan/scan-shell.tsx +++ b/src/components/scan/scan-shell.tsx @@ -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({
-

Low-confidence read — please double-check the fields

+

Low-confidence read - please double-check the fields

{engineLabel} returned {Math.round(parsed.confidence * 100)}% confidence.

@@ -132,7 +136,7 @@ function VerifyForm({
-

Receipt parsed — confirm the fields and save

+

Receipt parsed - confirm the fields and save

{engineLabel} · {Math.round(parsed.confidence * 100)}% confidence.

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

Scan a receipt

- {state.kind !== 'idle' ? ( - - ) : null} + {/* Brand header - logo centered, page title underneath. Establishes + the standalone identity (this is the PWA home for the scanner). */} +
+ Port Nimara +
+

Scan a receipt

+ {state.kind !== 'idle' ? ( + + ) : null} +
{state.kind === 'idle' ? (