diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..974f3ac
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,56 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+
+ # The Firestore emulator needs Java.
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: 21
+
+ - name: Cache emulator jars
+ uses: actions/cache@v4
+ with:
+ path: ~/.cache/firebase/emulators
+ key: firebase-emulators-15.8.0
+
+ - run: npm ci
+ - run: npm install -g firebase-tools@15.8.0
+
+ - name: Typecheck
+ run: npm run typecheck
+
+ - name: Build
+ run: npm run build
+
+ - name: Check the production bundle
+ run: node scripts/check-bundle.mjs
+
+ - name: Rules tests (emulator)
+ run: npm run test:rules
+
+ - name: Hosting rewrites and headers (emulator)
+ run: npm run test:hosting
+
+ - name: Check an emulator build
+ env:
+ VITE_USE_EMULATOR: 'true'
+ run: npm run build && node scripts/check-bundle.mjs --emulator
diff --git a/.gitignore b/.gitignore
index d1851dd..e3dddfb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,9 @@ dist/
# Firebase cache
.firebase/
+# Temp config written by scripts/emulators.mjs
+.firebase.emulators.json
+
# Firebase emulator persisted data (npm run emulators:persist)
.emulator-data/
diff --git a/README.md b/README.md
index b0af950..b780c07 100644
--- a/README.md
+++ b/README.md
@@ -37,12 +37,30 @@ proscan-web/
```bash
npm install # one-time
-npm run dev # local dev server with HMR
-npm run build # build to dist/
-npm run preview # serve the built dist/ locally
-npm run deploy # build + firebase deploy --only hosting (project: proscanbot)
+npm run dev # landing page dev server
+npm run dev:dashboard # dashboard dev server, talks to the emulators
+npm run emulators # auth + firestore + hosting emulators (project demo-proscan)
+npm run seed # demo data into the running emulators
+npm run build # clean dist/, then build landing + dashboard
+npm test # typecheck, build, bundle check, rules tests
+npm run test:hosting # rewrites and cache headers (Linux or WSL only)
+npm run deploy # hosting + firestore rules + indexes to proscanbot
```
+The dashboard talks to the emulators in dev and to production in a build.
+`VITE_USE_EMULATOR=true` or `=false` overrides that at build time.
+
+Emulator ports come from `EMU_AUTH_PORT`, `EMU_FIRESTORE_PORT`,
+`EMU_HOSTING_PORT`, `EMU_UI_PORT`, `EMU_HUB_PORT` and `EMU_LOGGING_PORT`
+(defaults in `firebase.json`). `npm run test:rules` and `npm run emulators`
+both go through `scripts/emulators.mjs`, which applies them.
+
+Every build is stamped with its commit: `GET /version.json`, the
+`proscan-build` meta tag, or the sidebar footer in the dashboard. `deploy`
+refuses a dirty tree or a branch other than `main`, so the stamp always
+names a real commit. CI (`.github/workflows/ci.yml`) runs the same checks
+on every pull request.
+
## Revamp
Adds sign-in, saving client/competitor data, and integration with the ProScan
diff --git a/dashboard/src/auth/AuthGate.tsx b/dashboard/src/auth/AuthGate.tsx
index 25691a7..9bbdf60 100644
--- a/dashboard/src/auth/AuthGate.tsx
+++ b/dashboard/src/auth/AuthGate.tsx
@@ -1,10 +1,12 @@
import { useEffect, useState, type ReactNode } from 'react';
import { doc, getDoc, serverTimestamp, setDoc } from 'firebase/firestore';
-import type { User } from 'firebase/auth';
-import { db } from '../firebase';
+import { signOut, type User } from 'firebase/auth';
+import { auth, db } from '../firebase';
import { useAuthUser, WorkspaceProvider } from '../lib/hooks';
import { DEFAULT_WORKSPACE_SETTINGS } from '../lib/types';
import { RadarIcon } from '../components/EmptyState';
+import Button from '../components/Button';
+import ErrorState from '../components/ErrorState';
import SignIn from './SignIn';
import './auth.css';
@@ -59,11 +61,14 @@ async function bootstrapWorkspace(user: User): Promise {
}
/** Wraps the whole app: splash while auth resolves, SignIn when signed out,
- * bootstrap-then-children when signed in. Also mounts the single shared
- * workspace listener (WorkspaceProvider). */
+ * bootstrap-then-children when signed in. A failed bootstrap blocks with a
+ * retry screen that can also sign out (the Sidebar isn't mounted yet).
+ * Also mounts the single shared workspace listener. */
export default function AuthGate({ children }: { children: ReactNode }) {
const { user, loading } = useAuthUser();
const [bootstrappedUid, setBootstrappedUid] = useState(null);
+ const [bootError, setBootError] = useState(null);
+ const [attempt, setAttempt] = useState(0);
const uid = user?.uid ?? null;
@@ -73,22 +78,38 @@ export default function AuthGate({ children }: { children: ReactNode }) {
return;
}
let cancelled = false;
+ setBootError(null);
bootstrapWorkspace(user)
+ .then(() => {
+ if (!cancelled) setBootstrappedUid(user.uid);
+ })
.catch((err) => {
- // Non-fatal: the app degrades to live-query errors rather than a
- // hard wall; first write retries on next sign-in.
console.error('[proscan] workspace bootstrap failed', err);
- })
- .finally(() => {
- if (!cancelled) setBootstrappedUid(user.uid);
+ if (!cancelled) setBootError(err);
});
return () => {
cancelled = true;
};
- }, [user]);
+ }, [user, attempt]);
if (loading) return ;
if (!user || !uid) return ;
+ if (bootError) {
+ return (
+
+ setAttempt((n) => n + 1)}
+ extra={
+ void signOut(auth)}>
+ Sign out
+
+ }
+ />
+
+ );
+ }
if (bootstrappedUid !== uid) return ;
return {children} ;
diff --git a/dashboard/src/auth/SignIn.tsx b/dashboard/src/auth/SignIn.tsx
index 2c3b3f9..d161943 100644
--- a/dashboard/src/auth/SignIn.tsx
+++ b/dashboard/src/auth/SignIn.tsx
@@ -6,7 +6,7 @@ import {
signInWithPopup,
} from 'firebase/auth';
import { FirebaseError } from 'firebase/app';
-import { auth, googleProvider } from '../firebase';
+import { auth, googleProvider, USE_EMULATOR } from '../firebase';
import Button from '../components/Button';
import { RadarIcon } from '../components/EmptyState';
import GoogleButton from './GoogleButton';
@@ -317,7 +317,7 @@ export default function SignIn() {
Terms and Privacy Policy .
- {import.meta.env.DEV ? (
+ {USE_EMULATOR ? (
void;
+ extra?: ReactNode;
+}
+
+/** Failure view that shows the real Firestore code and message. */
+export default function ErrorState({ title, error, onRetry, extra }: ErrorStateProps) {
+ const info = describeError(error);
+ return (
+
+
{title}
+
+ {info.code ? {info.code} : null}
+ {info.message}
+
+ {info.indexUrl ? (
+
+
+ Create the missing index
+
+
+ ) : null}
+ {extra}
+
window.location.reload())}>
+ Retry
+
+
+ );
+}
diff --git a/dashboard/src/components/Toaster.tsx b/dashboard/src/components/Toaster.tsx
new file mode 100644
index 0000000..c7930ad
--- /dev/null
+++ b/dashboard/src/components/Toaster.tsx
@@ -0,0 +1,24 @@
+import { dismissToast, useToasts } from '../lib/errors';
+import './components.css';
+
+export default function Toaster() {
+ const toasts = useToasts();
+ if (toasts.length === 0) return null;
+ return (
+
+ {toasts.map((t) => (
+
+ {t.text}
+ dismissToast(t.id)}
+ >
+ ×
+
+
+ ))}
+
+ );
+}
diff --git a/dashboard/src/components/components.css b/dashboard/src/components/components.css
index d56a2b2..91c2cf0 100644
--- a/dashboard/src/components/components.css
+++ b/dashboard/src/components/components.css
@@ -401,3 +401,48 @@
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
+
+/* ── ErrorState / Toaster ───────────────────────────────────────── */
+.errstate {
+ max-width: 520px;
+}
+.errstate .empty__body {
+ overflow-wrap: anywhere;
+}
+.errstate__code {
+ font-family: var(--font-mono);
+ color: var(--red);
+ margin-right: var(--space-2);
+}
+.toaster {
+ position: fixed;
+ right: var(--space-4);
+ bottom: var(--space-4);
+ z-index: 1000;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ max-width: min(420px, calc(100vw - 2 * var(--space-4)));
+}
+.toast {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-3);
+ padding: var(--space-3) var(--space-4);
+ border: 1px solid var(--red);
+ border-radius: var(--radius-sm);
+ background: var(--surface);
+ color: var(--ink);
+ box-shadow: var(--shadow-drawer);
+ font-size: 13px;
+ overflow-wrap: anywhere;
+}
+.toast__close {
+ margin-left: auto;
+ border: 0;
+ background: none;
+ color: var(--muted);
+ font-size: 16px;
+ line-height: 1;
+ cursor: pointer;
+}
diff --git a/dashboard/src/env.d.ts b/dashboard/src/env.d.ts
new file mode 100644
index 0000000..461c60e
--- /dev/null
+++ b/dashboard/src/env.d.ts
@@ -0,0 +1,19 @@
+///
+
+interface ImportMetaEnv {
+ /** 'true' or 'false'. Unset means: emulator in dev, production in a build. */
+ readonly VITE_USE_EMULATOR?: string;
+ readonly VITE_AUTH_EMULATOR_PORT?: string;
+ readonly VITE_FIRESTORE_EMULATOR_PORT?: string;
+}
+
+declare const __PROSCAN_BUILD__: {
+ sha: string;
+ short: string;
+ dirty: boolean;
+ builtAt: string;
+};
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/dashboard/src/features/board/columns.tsx b/dashboard/src/features/board/columns.tsx
index 43a8a77..12f4320 100644
--- a/dashboard/src/features/board/columns.tsx
+++ b/dashboard/src/features/board/columns.tsx
@@ -227,6 +227,17 @@ function TagsCell({ p }: { p: Product }) {
/* ── column definitions ─────────────────────────────────────────── */
+// Nothing writes rank, spread or scores yet (F-25): the extension's sync
+// only sends price, rating and reviews. These columns stay hidden until a
+// loaded row carries the field. Phase 5 (offers and spread) fills them.
+export function dataColumnVisibility(rows: Product[]): Record {
+ return {
+ rank: rows.some((p) => p.latest?.rk !== undefined),
+ spread: rows.some((p) => p.spread !== undefined),
+ maxBuy: rows.some((p) => p.scores?.maxBuy !== undefined),
+ };
+}
+
/** Client-side sortable columns over the loaded set. `sortUndefined: 'last'`
* keeps absent values (no delta / spread / scores) at the bottom. */
export const boardColumns: ColumnDef[] = [
diff --git a/dashboard/src/features/drawer/HistoryDrawer.tsx b/dashboard/src/features/drawer/HistoryDrawer.tsx
index fdfc499..bb6afc4 100644
--- a/dashboard/src/features/drawer/HistoryDrawer.tsx
+++ b/dashboard/src/features/drawer/HistoryDrawer.tsx
@@ -10,6 +10,8 @@ import { lazy, Suspense, useEffect, useMemo, useState } from 'react';
import Chip from '../../components/Chip';
import Drawer from '../../components/Drawer';
import EmptyState from '../../components/EmptyState';
+import ErrorState from '../../components/ErrorState';
+import { errorLabel } from '../../lib/errors';
import KeyValue from '../../components/KeyValue';
import Skeleton from '../../components/Skeleton';
import Sparkline, { type SparklinePoint } from '../../components/Sparkline';
@@ -145,6 +147,8 @@ export default function HistoryDrawer({ wid, asin, onClose }: HistoryDrawerProps
{asin}} onClose={onClose}>
{product.loading ? (
+ ) : product.error ? (
+
) : !p ? (
- {/* 2 · THE VERDICT BLOCK (signature) */}
-
- {typeof maxBuy === 'number' && typeof spread?.md === 'number' ? (
+ {/* 2 · THE VERDICT BLOCK (signature). Hidden without spread data,
+ which nothing writes until Phase 5 (F-25). */}
+ {typeof maxBuy === 'number' && typeof spread?.md === 'number' ? (
+
Buy below {money(maxBuy)} to clear{' '}
@@ -204,12 +209,8 @@ export default function HistoryDrawer({ wid, asin, onClose }: HistoryDrawerProps
MAX BUY {money(maxBuy)}
- ) : (
-
- Run a spread analysis in the extension to get a Max Buy price.
-
- )}
-
+
+ ) : null}
{/* 3 · price history */}
@@ -223,6 +224,10 @@ export default function HistoryDrawer({ wid, asin, onClose }: HistoryDrawerProps
{history.loading ? (
+ ) : history.error ? (
+
+ Couldn't load history: {errorLabel(history.error)}
+
) : pricePointCount < 2 ? (
History builds with each scan.
) : (
diff --git a/dashboard/src/features/drawer/LeadTriage.tsx b/dashboard/src/features/drawer/LeadTriage.tsx
index c4fa114..b6ad1eb 100644
--- a/dashboard/src/features/drawer/LeadTriage.tsx
+++ b/dashboard/src/features/drawer/LeadTriage.tsx
@@ -10,6 +10,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { appendEvent, leadStageUpdate } from '../../lib/queries';
import { LEAD_STAGES, type LeadStage, type ProductLead } from '../../lib/types';
import { leadFieldPatch } from './data';
+import { errorLabel, reportError } from '../../lib/errors';
import './drawer.css';
export const STAGE_LABELS: Record = {
@@ -44,7 +45,10 @@ function useDebouncedSave(save: (value: string) => Promise, delayMs = 600)
saveRef
.current(value)
.then(() => setStatus('saved'))
- .catch(() => setStatus('error'));
+ .catch((err: unknown) => {
+ setStatus('error');
+ reportError('save the lead note', err);
+ });
}, []);
const queue = useCallback(
@@ -127,10 +131,11 @@ export default function LeadTriage({
appendEvent(wid, asin, { type: 'stageChange', from: prev, to: next }),
])
.then(() => setBusy(false))
- .catch(() => {
+ .catch((err: unknown) => {
+ console.error('[proscan] stage change failed', err);
onStageChange(prev);
setBusy(false);
- setError('Stage change failed — check your connection and retry.');
+ setError(`Stage change failed: ${errorLabel(err)}`);
});
};
diff --git a/dashboard/src/features/drawer/data.ts b/dashboard/src/features/drawer/data.ts
index 8fb2b69..04e5669 100644
--- a/dashboard/src/features/drawer/data.ts
+++ b/dashboard/src/features/drawer/data.ts
@@ -67,7 +67,8 @@ export function useLatestOfferSnapshot(
.then((snap) => {
if (!cancelled) setState({ data: snap, loading: false });
})
- .catch(() => {
+ .catch((err: unknown) => {
+ console.error('[proscan] offer snapshot read failed', err);
if (!cancelled) setState({ data: null, loading: false });
});
return () => {
diff --git a/dashboard/src/features/drawer/drawer.css b/dashboard/src/features/drawer/drawer.css
index 94e7d0f..f8578db 100644
--- a/dashboard/src/features/drawer/drawer.css
+++ b/dashboard/src/features/drawer/drawer.css
@@ -131,11 +131,6 @@
padding: 0 var(--space-4);
font-size: 15px;
}
-.hd-verdict--empty {
- font-size: 13px;
- font-weight: 400;
- color: var(--muted);
-}
/* ── 3 · price history chart tooltip ─────────────────────────────── */
.hd-tip {
diff --git a/dashboard/src/features/export/ExportButton.tsx b/dashboard/src/features/export/ExportButton.tsx
index a26374e..fb8949f 100644
--- a/dashboard/src/features/export/ExportButton.tsx
+++ b/dashboard/src/features/export/ExportButton.tsx
@@ -6,6 +6,7 @@ import { useEffect, useRef, useState } from 'react';
import Button from '../../components/Button';
import type { Product } from '../../lib/types';
import { exportProductsXlsx } from './exportXlsx';
+import { reportError } from '../../lib/errors';
import './export.css';
export interface ExportButtonProps {
@@ -47,7 +48,7 @@ export default function ExportButton({ rows, disabled }: ExportButtonProps) {
setPhase('idle');
}, CONFIRM_MS);
} catch (err) {
- console.error('[proscan] XLSX export failed', err);
+ reportError('export XLSX', err);
if (mountedRef.current) setPhase('idle');
}
};
diff --git a/dashboard/src/features/runs/runs.css b/dashboard/src/features/runs/runs.css
index f64465e..7f229b0 100644
--- a/dashboard/src/features/runs/runs.css
+++ b/dashboard/src/features/runs/runs.css
@@ -134,16 +134,6 @@ button.run-card:hover {
}
/* ── inline query-error notice ──────────────────────────────────── */
-.runs-error {
- max-width: 760px;
- padding: var(--space-3) var(--space-4);
- border: 1px solid var(--red);
- border-radius: var(--radius-sm);
- background: var(--red-soft);
- color: var(--red);
- font-size: 13px;
-}
-
@media (prefers-reduced-motion: reduce) {
.run-progress__fill {
animation: none;
diff --git a/dashboard/src/features/watchlist/NicknameCell.tsx b/dashboard/src/features/watchlist/NicknameCell.tsx
index c809c5a..243167e 100644
--- a/dashboard/src/features/watchlist/NicknameCell.tsx
+++ b/dashboard/src/features/watchlist/NicknameCell.tsx
@@ -4,6 +4,7 @@
import { useRef, useState, type KeyboardEvent } from 'react';
import { sourcePatch } from '../../lib/queries';
+import { reportError } from '../../lib/errors';
import type { Source } from '../../lib/types';
import './watchlist.css';
@@ -30,8 +31,7 @@ export default function NicknameCell({ wid, source }: NicknameCellProps) {
const next = draft.trim();
if (next === (source.nickname ?? '')) return;
sourcePatch(wid, source.sourceId, { nickname: next || null }).catch(
- (err: unknown) =>
- console.error('[proscan:watchlist] nickname patch failed', err),
+ (err: unknown) => reportError('save the nickname', err),
);
};
diff --git a/dashboard/src/features/watchlist/WatchlistTable.tsx b/dashboard/src/features/watchlist/WatchlistTable.tsx
index a0f9c36..d6a23a8 100644
--- a/dashboard/src/features/watchlist/WatchlistTable.tsx
+++ b/dashboard/src/features/watchlist/WatchlistTable.tsx
@@ -8,6 +8,7 @@ import { useNavigate } from 'react-router-dom';
import Chip from '../../components/Chip';
import Tag from '../../components/Tag';
import { sourcePatch } from '../../lib/queries';
+import { reportError } from '../../lib/errors';
import {
compactNumber,
relativeTime,
@@ -50,7 +51,7 @@ function identity(source: Source): string {
}
function logPatchError(err: unknown): void {
- console.error('[proscan:watchlist] source patch failed', err);
+ reportError('save the watchlist change', err);
}
/* ── inline icons (16px, stroke = currentColor) ─────────────────── */
@@ -126,11 +127,13 @@ function WatchlistRow({
wid,
source,
stale,
+ showCatalog,
onOpen,
}: {
wid: string;
source: Source;
stale: Staleness;
+ showCatalog: boolean;
onOpen: (sourceId: string) => void;
}) {
const watched = source.watched === true;
@@ -190,9 +193,11 @@ function WatchlistRow({
{ident}
-
- {compactNumber(source.catalogSize)}
-
+ {showCatalog ? (
+
+ {compactNumber(source.catalogSize)}
+
+ ) : null}
{relativeTime(source.lastScrapedAt)}
@@ -288,6 +293,9 @@ export default function WatchlistTable({ wid, sources }: WatchlistTableProps) {
navigate(`/?source=${encodeURIComponent(sourceId)}`);
};
+ // The extension does not write catalogSize yet (F-25); hide until it does.
+ const showCatalog = sources.some((s) => s.catalogSize !== undefined);
+
return (
@@ -297,7 +305,7 @@ export default function WatchlistTable({ wid, sources }: WatchlistTableProps) {
Nickname
Type
Seller / keyword
- Catalog
+ {showCatalog ? Catalog : null}
Last scan
Status
Cadence
@@ -312,6 +320,7 @@ export default function WatchlistTable({ wid, sources }: WatchlistTableProps) {
wid={wid}
source={source}
stale={stale}
+ showCatalog={showCatalog}
onOpen={openSource}
/>
))}
diff --git a/dashboard/src/firebase.ts b/dashboard/src/firebase.ts
index 79029b7..a883757 100644
--- a/dashboard/src/firebase.ts
+++ b/dashboard/src/firebase.ts
@@ -2,9 +2,17 @@ import { initializeApp } from 'firebase/app';
import { connectAuthEmulator, getAuth, GoogleAuthProvider } from 'firebase/auth';
import { connectFirestoreEmulator, getFirestore } from 'firebase/firestore';
-// In dev the app runs entirely against the local emulator suite. The 'demo-'
-// project-id prefix is reserved by Firebase for emulator-only projects, so the
-// SDK can never reach production from a dev build.
+// Vite inlines VITE_* values at build time, so this is a literal in the
+// bundle. `VITE_USE_EMULATOR=true npm run build:dashboard` gives a prod-shaped
+// bundle that talks to the emulators, and dev can opt out with =false.
+const flag = import.meta.env.VITE_USE_EMULATOR;
+export const USE_EMULATOR = flag === 'true' || (flag !== 'false' && import.meta.env.DEV);
+
+const AUTH_PORT = Number(import.meta.env.VITE_AUTH_EMULATOR_PORT || 9099);
+const FIRESTORE_PORT = Number(import.meta.env.VITE_FIRESTORE_EMULATOR_PORT || 8080);
+
+// The 'demo-' project-id prefix is reserved by Firebase for emulator-only
+// projects, so this config can never reach production.
const devConfig = {
projectId: 'demo-proscan',
apiKey: 'demo-key',
@@ -22,7 +30,7 @@ const prodConfig = {
messagingSenderId: '886322190589',
};
-export const app = initializeApp(import.meta.env.DEV ? devConfig : prodConfig);
+export const app = initializeApp(USE_EMULATOR ? devConfig : prodConfig);
export const auth = getAuth(app);
// Single shared Google provider. `select_account` forces the chooser every
@@ -33,7 +41,7 @@ export const googleProvider = new GoogleAuthProvider();
googleProvider.setCustomParameters({ prompt: 'select_account' });
export const db = getFirestore(app);
-if (import.meta.env.DEV) {
- connectAuthEmulator(auth, 'http://127.0.0.1:9099', { disableWarnings: true });
- connectFirestoreEmulator(db, '127.0.0.1', 8080);
+if (USE_EMULATOR) {
+ connectAuthEmulator(auth, `http://127.0.0.1:${AUTH_PORT}`, { disableWarnings: true });
+ connectFirestoreEmulator(db, '127.0.0.1', FIRESTORE_PORT);
}
diff --git a/dashboard/src/lib/errors.ts b/dashboard/src/lib/errors.ts
new file mode 100644
index 0000000..53fbacc
--- /dev/null
+++ b/dashboard/src/lib/errors.ts
@@ -0,0 +1,63 @@
+// Error plumbing: every failure is logged in every build, and write
+// failures also raise a toast so they never disappear into the console.
+
+import { useSyncExternalStore } from 'react';
+
+export interface ErrorInfo {
+ code: string | null;
+ message: string;
+ /** Firestore puts a "create this index" console link in failed-precondition errors. */
+ indexUrl: string | null;
+}
+
+export function describeError(err: unknown): ErrorInfo {
+ const e = err as { code?: unknown; message?: unknown } | null;
+ const code = typeof e?.code === 'string' ? e.code : null;
+ const message =
+ typeof e?.message === 'string' && e.message ? e.message : String(err ?? 'Unknown error');
+ const link = message.match(/https:\/\/console\.firebase\.google\.com\/\S+/);
+ return { code, message, indexUrl: link ? link[0] : null };
+}
+
+/** Short form for inline text: the Firestore code, else the message. */
+export function errorLabel(err: unknown): string {
+ const info = describeError(err);
+ return info.code ?? info.message;
+}
+
+/* ── toasts ─────────────────────────────────────────────────────── */
+
+export interface Toast {
+ id: number;
+ text: string;
+}
+
+let toasts: Toast[] = [];
+let nextId = 1;
+const listeners = new Set<() => void>();
+const emit = () => listeners.forEach((fn) => fn());
+
+export function dismissToast(id: number): void {
+ toasts = toasts.filter((t) => t.id !== id);
+ emit();
+}
+
+/** Log a failure and show it to the user. `what` reads as "Couldn't ". */
+export function reportError(what: string, err: unknown): void {
+ console.error(`[proscan] ${what} failed`, err);
+ const id = nextId++;
+ toasts = [...toasts, { id, text: `Couldn't ${what}: ${errorLabel(err)}` }].slice(-4);
+ emit();
+ window.setTimeout(() => dismissToast(id), 8000);
+}
+
+export function useToasts(): Toast[] {
+ return useSyncExternalStore(
+ (fn) => {
+ listeners.add(fn);
+ return () => listeners.delete(fn);
+ },
+ () => toasts,
+ () => toasts,
+ );
+}
diff --git a/dashboard/src/lib/hooks.ts b/dashboard/src/lib/hooks.ts
index 72f5da1..416ae5d 100644
--- a/dashboard/src/lib/hooks.ts
+++ b/dashboard/src/lib/hooks.ts
@@ -18,6 +18,7 @@ import {
import { onAuthStateChanged, type User } from 'firebase/auth';
import {
doc,
+ getCountFromServer,
getDoc,
onSnapshot,
type DocumentReference,
@@ -142,9 +143,7 @@ export function useSnapshotQuery(
firstSnapshot = false;
scanEnd(token);
}
- if (import.meta.env.DEV) {
- console.debug(`[proscan:reads] ${debugLabel ?? 'query'} error`, error);
- }
+ console.error(`[proscan] ${debugLabel ?? 'query'} listener failed`, error);
setState({ data: [], loading: false, error });
},
);
@@ -158,6 +157,35 @@ export function useSnapshotQuery(
return state;
}
+/* ── server-side count (aggregate, no documents read) ───────────────── */
+
+/** getCountFromServer for a query. `null` while unknown or when the count
+ * failed; callers fall back to "showing first N". Re-runs on dep change. */
+export function useServerCount(
+ queryFactory: () => Query | null,
+ deps: DependencyList,
+): number | null {
+ const [count, setCount] = useState(null);
+ useEffect(() => {
+ const q = queryFactory();
+ setCount(null);
+ if (!q) return;
+ let cancelled = false;
+ getCountFromServer(q)
+ .then((snap) => {
+ if (!cancelled) setCount(snap.data().count);
+ })
+ .catch((error: unknown) => {
+ console.error('[proscan] count query failed', error);
+ });
+ return () => {
+ cancelled = true;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, deps);
+ return count;
+}
+
/* ── one-shot doc read (history / snapshots — NEVER listeners) ──────── */
export interface DocOnceState {
@@ -196,6 +224,7 @@ export function useDocOnce(ref: DocumentReference | null): DocOnceState
});
})
.catch((error: Error) => {
+ console.error(`[proscan] read ${ref.path} failed`, error);
if (!cancelled) setState({ data: null, loading: false, error });
});
return () => {
@@ -214,12 +243,14 @@ export interface WorkspaceState {
wid: string | null;
workspace: Workspace | null;
loading: boolean;
+ error: Error | null;
}
const WorkspaceContext = createContext({
wid: null,
workspace: null,
loading: false,
+ error: null,
});
/** Mounted ONCE by AuthGate after sign-in: owns the single doc listener on
@@ -236,10 +267,11 @@ export function WorkspaceProvider({
wid: uid,
workspace: null,
loading: true,
+ error: null,
});
useEffect(() => {
- setState({ wid: uid, workspace: null, loading: true });
+ setState({ wid: uid, workspace: null, loading: true, error: null });
const unsubscribe = onSnapshot(
doc(db, 'workspaces', uid),
(snap) => {
@@ -247,9 +279,13 @@ export function WorkspaceProvider({
wid: uid,
workspace: snap.exists() ? (snap.data() as Workspace) : null,
loading: false,
+ error: null,
});
},
- () => setState({ wid: uid, workspace: null, loading: false }),
+ (error) => {
+ console.error('[proscan] workspace listener failed', error);
+ setState({ wid: uid, workspace: null, loading: false, error });
+ },
);
return unsubscribe;
}, [uid]);
diff --git a/dashboard/src/lib/queries.ts b/dashboard/src/lib/queries.ts
index cd9db39..0afb005 100644
--- a/dashboard/src/lib/queries.ts
+++ b/dashboard/src/lib/queries.ts
@@ -57,30 +57,35 @@ function productsCol(wid: string) {
/* ── product queries (ALWAYS scoped) ────────────────────────────────── */
+// Listener queries always pass a number. `null` drops the limit and is
+// only for getCountFromServer, which counts without reading documents.
+
/** Per-source product board — the default table view.
* Index: products(sourceIds contains, latest.at desc). */
export function productsBySource(
wid: string,
sourceId: string,
- lim = 400,
+ lim: number | null = 400,
): Query {
- return query(
+ const q = query(
productsCol(wid),
where('sourceIds', 'array-contains', sourceId),
orderBy('latest.at', 'desc'),
- limit(lim),
);
+ return lim === null ? q : query(q, limit(lim));
}
/** Global Movers / Flip Radar: biggest price DROPS first (delta.pPct asc —
* most-negative = best buying opportunity). Single-field index. */
-export function topMovers(wid: string, lim = 100): Query {
- return query(productsCol(wid), orderBy('delta.pPct', 'asc'), limit(lim));
+export function topMovers(wid: string, lim: number | null = 100): Query {
+ const q = query(productsCol(wid), orderBy('delta.pPct', 'asc'));
+ return lim === null ? q : query(q, limit(lim));
}
/** Most recently observed products across all sources. */
-export function recentProducts(wid: string, lim = 200): Query {
- return query(productsCol(wid), orderBy('latest.at', 'desc'), limit(lim));
+export function recentProducts(wid: string, lim: number | null = 200): Query {
+ const q = query(productsCol(wid), orderBy('latest.at', 'desc'));
+ return lim === null ? q : query(q, limit(lim));
}
/* ── runs / sources ─────────────────────────────────────────────────── */
diff --git a/dashboard/src/main.tsx b/dashboard/src/main.tsx
index 2ec7af9..54b4bf9 100644
--- a/dashboard/src/main.tsx
+++ b/dashboard/src/main.tsx
@@ -11,6 +11,23 @@ import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { router } from './router';
+// After a deploy, an open tab asks for chunk hashes that no longer exist.
+// Reload once to pick up the new index.html; the guard stops a reload loop.
+window.addEventListener('vite:preloadError', (event) => {
+ const KEY = 'proscan:preload-reload';
+ let recent = true;
+ try {
+ recent = Date.now() - Number(sessionStorage.getItem(KEY) || 0) < 10_000;
+ sessionStorage.setItem(KEY, String(Date.now()));
+ } catch {
+ /* storage blocked: no guard, so let the error surface instead */
+ }
+ if (!recent) {
+ event.preventDefault();
+ window.location.reload();
+ }
+});
+
const container = document.getElementById('root');
if (!container) {
throw new Error('Root element #root not found');
diff --git a/dashboard/src/router.tsx b/dashboard/src/router.tsx
index 79543df..0810c59 100644
--- a/dashboard/src/router.tsx
+++ b/dashboard/src/router.tsx
@@ -5,6 +5,7 @@ import Products from './routes/Products';
import Runs from './routes/Runs';
import Watchlist from './routes/Watchlist';
import NotFound from './routes/NotFound';
+import RouteError from './routes/RouteError';
// AuthGate wraps the shell: splash -> sign-in -> bootstrap -> app.
// The Firebase Hosting rewrite sends every unknown /dashboard/* path into
@@ -17,6 +18,7 @@ export const router = createBrowserRouter(
),
+ errorElement: ,
children: [
{ index: true, element: },
{ path: 'runs', element: },
diff --git a/dashboard/src/routes/Products.tsx b/dashboard/src/routes/Products.tsx
index 07b0ad0..d0d96e1 100644
--- a/dashboard/src/routes/Products.tsx
+++ b/dashboard/src/routes/Products.tsx
@@ -8,8 +8,9 @@
// source -> productsBySource(wid, sourceId, 500)
// default -> recentProducts(wid, 300)
// plus the sanctioned tiny sources listener for the scope dropdown.
-// Search, sorting and the movers' has-delta filter are client-side over
-// the loaded set only.
+// Search, sorting, export and the movers' has-delta filter are client-side
+// over the loaded set only, so the header says when that set is capped and
+// shows the server-side total (F-45). Real pagination lands in Phase 4.
import { useMemo, useState, type ReactNode } from 'react';
import { useSearchParams } from 'react-router-dom';
@@ -19,7 +20,7 @@ import {
useReactTable,
type SortingState,
} from '@tanstack/react-table';
-import { useSnapshotQuery, useWorkspace } from '../lib/hooks';
+import { useServerCount, useSnapshotQuery, useWorkspace } from '../lib/hooks';
import {
productsBySource,
recentProducts,
@@ -29,6 +30,7 @@ import {
import type { Product } from '../lib/types';
import PageHeader from '../components/PageHeader';
import EmptyState from '../components/EmptyState';
+import ErrorState from '../components/ErrorState';
import Skeleton from '../components/Skeleton';
import Button from '../components/Button';
import HistoryDrawer from '../features/drawer/HistoryDrawer';
@@ -38,12 +40,29 @@ import BoardToolbar, {
type BoardView,
} from '../features/board/BoardToolbar';
import BoardTable from '../features/board/BoardTable';
-import { boardColumns } from '../features/board/columns';
+import { boardColumns, dataColumnVisibility } from '../features/board/columns';
import '../features/board/board.css';
-function countLabel(visible: number, total: number): string {
- const noun = total === 1 ? 'product' : 'products';
- return visible === total ? `${total} ${noun}` : `${visible} of ${total} ${noun}`;
+const LIMITS = { movers: 100, source: 500, recent: 300 } as const;
+
+const fmt = (n: number) => n.toLocaleString('en-US');
+
+/** Header count. Never presents a capped window as the whole workspace. */
+function countLabel(
+ visible: number,
+ loaded: number,
+ capped: boolean,
+ total: number | null,
+): string {
+ const noun = loaded === 1 ? 'product' : 'products';
+ if (!capped || (total !== null && total <= loaded)) {
+ return visible === loaded ? `${fmt(loaded)} ${noun}` : `${fmt(visible)} of ${fmt(loaded)} ${noun}`;
+ }
+ const span =
+ total !== null
+ ? `first ${fmt(loaded)} of ${fmt(total)} ${noun}`
+ : `first ${fmt(loaded)} ${noun}, more not loaded`;
+ return visible === loaded ? `Showing the ${span}` : `${fmt(visible)} matches in the ${span}`;
}
export default function Products() {
@@ -59,17 +78,32 @@ export default function Products() {
const [search, setSearch] = useState('');
const [sorting, setSorting] = useState([]);
+ const limit =
+ view === 'movers' ? LIMITS.movers : sourceId ? LIMITS.source : LIMITS.recent;
+
const products = useSnapshotQuery(
() => {
if (!wid) return null;
- if (view === 'movers') return topMovers(wid, 100);
- if (sourceId) return productsBySource(wid, sourceId, 500);
- return recentProducts(wid, 300);
+ if (view === 'movers') return topMovers(wid, limit);
+ if (sourceId) return productsBySource(wid, sourceId, limit);
+ return recentProducts(wid, limit);
},
[wid, view, sourceId],
view === 'movers' ? 'board:movers' : sourceId ? 'board:by-source' : 'board:recent',
);
+ // Only ask the server for a total once the window is actually full.
+ const capped = products.data.length >= limit;
+ const total = useServerCount(
+ () => {
+ if (!wid || !capped) return null;
+ if (view === 'movers') return topMovers(wid, null);
+ if (sourceId) return productsBySource(wid, sourceId, null);
+ return recentProducts(wid, null);
+ },
+ [wid, view, sourceId, capped],
+ );
+
const sourcesState = useSnapshotQuery(
() => (wid ? sourcesQuery(wid) : null),
[wid],
@@ -98,10 +132,12 @@ export default function Products() {
);
}, [scopedRows, query]);
+ const columnVisibility = useMemo(() => dataColumnVisibility(products.data), [products.data]);
+
const table = useReactTable({
data: rows,
columns: boardColumns,
- state: { sorting },
+ state: { sorting, columnVisibility },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
@@ -145,8 +181,12 @@ export default function Products() {
const subtitle = loading
? 'Loading the board…'
: [
- countLabel(rows.length, scopedRows.length),
- view === 'movers' ? 'biggest price drops first' : 'latest observations',
+ countLabel(rows.length, scopedRows.length, capped && !(view === 'movers' && sourceId), total),
+ view === 'movers'
+ ? sourceId
+ ? `biggest drops within the global top ${limit}`
+ : 'biggest price drops first'
+ : 'latest observations',
sourceName ? `source: ${sourceName}` : null,
]
.filter(Boolean)
@@ -163,10 +203,7 @@ export default function Products() {
);
} else if (products.error) {
body = (
-
+
);
} else if (scopedRows.length === 0) {
if (view === 'movers') {
diff --git a/dashboard/src/routes/RouteError.tsx b/dashboard/src/routes/RouteError.tsx
new file mode 100644
index 0000000..c2cbdc4
--- /dev/null
+++ b/dashboard/src/routes/RouteError.tsx
@@ -0,0 +1,11 @@
+// Router errorElement: a render crash or a failed lazy chunk lands here
+// instead of a blank page.
+
+import { useRouteError } from 'react-router-dom';
+import ErrorState from '../components/ErrorState';
+
+export default function RouteError() {
+ const error = useRouteError();
+ console.error('[proscan] route crashed', error);
+ return ;
+}
diff --git a/dashboard/src/routes/Runs.tsx b/dashboard/src/routes/Runs.tsx
index afb6b46..55866f6 100644
--- a/dashboard/src/routes/Runs.tsx
+++ b/dashboard/src/routes/Runs.tsx
@@ -11,6 +11,7 @@ import { runsRecent, sources } from '../lib/queries';
import type { Source } from '../lib/types';
import PageHeader from '../components/PageHeader';
import EmptyState from '../components/EmptyState';
+import ErrorState from '../components/ErrorState';
import Button from '../components/Button';
import { CWS_URL } from '../auth/SignIn';
import RunCard, { RunCardSkeleton } from '../features/runs/RunCard';
@@ -56,9 +57,7 @@ export default function Runs() {
);
} else if (runsState.error) {
body = (
-
- Could not load runs: {runsState.error.message}
-
+
);
} else if (runsState.data.length === 0) {
body = (
diff --git a/dashboard/src/routes/Watchlist.tsx b/dashboard/src/routes/Watchlist.tsx
index 3aca747..54b01a6 100644
--- a/dashboard/src/routes/Watchlist.tsx
+++ b/dashboard/src/routes/Watchlist.tsx
@@ -8,6 +8,7 @@ import { sources } from '../lib/queries';
import { staleness } from '../lib/format';
import PageHeader from '../components/PageHeader';
import EmptyState from '../components/EmptyState';
+import ErrorState from '../components/ErrorState';
import Skeleton from '../components/Skeleton';
import Button from '../components/Button';
import { CWS_URL } from '../auth/SignIn';
@@ -48,10 +49,7 @@ export default function Watchlist() {
);
} else if (error) {
body = (
-
+
);
} else if (!wid || data.length === 0) {
body = (
diff --git a/dashboard/src/shell/Layout.tsx b/dashboard/src/shell/Layout.tsx
index f45c243..58992e1 100644
--- a/dashboard/src/shell/Layout.tsx
+++ b/dashboard/src/shell/Layout.tsx
@@ -1,6 +1,7 @@
import { Outlet, useLocation } from 'react-router-dom';
import Sidebar from './Sidebar';
import Topbar from './Topbar';
+import Toaster from '../components/Toaster';
import './shell.css';
/** App shell grid: 232px chrome sidebar + topbar + light content area.
@@ -19,6 +20,7 @@ export default function Layout() {
+
);
}
diff --git a/dashboard/src/shell/Sidebar.tsx b/dashboard/src/shell/Sidebar.tsx
index e27478d..f547b40 100644
--- a/dashboard/src/shell/Sidebar.tsx
+++ b/dashboard/src/shell/Sidebar.tsx
@@ -75,6 +75,12 @@ export default function Sidebar() {
>
Sign out
+
+ build {__PROSCAN_BUILD__.short}
+
);
diff --git a/dashboard/src/shell/shell.css b/dashboard/src/shell/shell.css
index 8d5359e..1e02d6d 100644
--- a/dashboard/src/shell/shell.css
+++ b/dashboard/src/shell/shell.css
@@ -128,6 +128,11 @@
color: var(--gold);
border-color: var(--gold);
}
+.sidebar__build {
+ font-size: 10px;
+ color: var(--chrome-muted);
+ opacity: 0.7;
+}
/* ── Main column ────────────────────────────────────────────────── */
.shell__main {
@@ -180,7 +185,8 @@
padding: var(--space-4) 0 var(--space-5);
}
.sidebar__wordmark,
- .sidebar__email {
+ .sidebar__email,
+ .sidebar__build {
display: none;
}
.nav-link {
diff --git a/firebase.json b/firebase.json
index 98e12ab..d531fc0 100644
--- a/firebase.json
+++ b/firebase.json
@@ -3,11 +3,14 @@
"public": "dist",
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
"rewrites": [
- { "source": "/dashboard/**", "destination": "/dashboard/index.html" }
+ { "regex": "^/dashboard(/[^.]*)?$", "destination": "/dashboard/index.html" }
],
"headers": [
{ "source": "/assets/**", "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }] },
- { "source": "/dashboard/assets/**", "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }] }
+ { "source": "/dashboard/assets/**", "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }] },
+ { "regex": "^/[^.]*$", "headers": [{ "key": "Cache-Control", "value": "no-cache" }] },
+ { "source": "**/*.html", "headers": [{ "key": "Cache-Control", "value": "no-cache" }] },
+ { "source": "/version.json", "headers": [{ "key": "Cache-Control", "value": "no-cache" }] }
]
},
"firestore": { "rules": "firestore.rules", "indexes": "firestore.indexes.json" },
diff --git a/firestore.rules b/firestore.rules
index a2beb18..a120a80 100644
--- a/firestore.rules
+++ b/firestore.rules
@@ -52,9 +52,11 @@ service cloud.firestore {
request.resource.data.latest.dayKey is string
&& (!('p' in request.resource.data.latest) || request.resource.data.latest.p is int)
&& (!('v' in request.resource.data.latest) || request.resource.data.latest.v is int)))
+ // A lead with no stored stage is 'new' (the drawer never writes it).
&& (!('lead' in request.resource.data)
- || request.resource.data.lead.stage in
- ['new', 'reviewing', 'approved', 'purchased', 'rejected', 'archived']);
+ || (request.resource.data.lead is map
+ && request.resource.data.lead.get('stage', 'new') in
+ ['new', 'reviewing', 'approved', 'purchased', 'rejected', 'archived']));
match /history/{seriesId} {
allow read: if member(wid);
diff --git a/index.html b/index.html
index 3b005f5..84429b4 100644
--- a/index.html
+++ b/index.html
@@ -3,12 +3,13 @@
- ProScan – Competitor Price Tracking for Amazon Sellers
+ ProScan – Amazon Product Research in Your Browser
+
- ProScan
+ ProScan
Features
Dashboard
@@ -22,7 +23,7 @@
Outsmart Your Competition.
-
ProScan empowers you to track competitor pricing, analyze product strategies, and uncover critical market insights to dominate your niche.
+
ProScan scans Amazon search results and seller storefronts page by page, and scores every product so you can see which ones are worth a closer look.
Get Your Edge - It's Free!
@@ -35,7 +36,7 @@
Outsmart Your Competition.
- https://example-store.com/products
+ https://www.amazon.com/s?k=electronics
@@ -137,19 +138,19 @@
Outsmart Your Competition.
Uncover Your Competitors' Strategies
-
Gain a competitive advantage by effortlessly analyzing product data from across the market.
+
Everything runs in your browser, on the Amazon pages you choose to scan.
-
Competitor Price Monitoring
-
Keep a close eye on competitor pricing strategies. ProScan automatically tracks price changes on products you're watching, giving you the data to react swiftly.
+
Scan Whole Result Sets
+
Start a scan on a search or a seller's storefront and ProScan works through the result pages for you, collecting each product's title, ASIN, price, rating, review count and Prime badge.
-
Product Data Extraction
-
Effortlessly scrape key product details from listings – titles, descriptions, images, and more – to build your competitive dataset.
+
Opportunity Scores
+
Each product gets a score built from its rating, review count and price, and ProScan flags well-rated products priced far below the rest of your scan.
-
Market Trend Analysis
-
Leverage historical price and listing data to understand market trends, identify opportunities, and make informed decisions for your product catalog.
+
Exports and AI Chat
+
Export a scan to Excel, CSV or JSON. Add your own Google Gemini API key and you can ask questions about your scan in plain English.
@@ -158,7 +159,7 @@ Market Trend Analysis
Ready to Dominate Your Market?
-
Start leveraging competitive insights with ProScan. See what your rivals are doing and make smarter, data-driven decisions. Installation is quick and easy!
+
ProScan is free and needs no account. Install it from the Chrome Web Store and run your first scan in under a minute.
Install ProScan & Get Ahead
diff --git a/package.json b/package.json
index 7c9ac19..1a3e3a7 100644
--- a/package.json
+++ b/package.json
@@ -9,13 +9,17 @@
"dev:dashboard": "vite --config vite.dashboard.config.ts",
"build:landing": "vite build",
"build:dashboard": "vite build --config vite.dashboard.config.ts",
- "build": "npm run build:landing && npm run build:dashboard",
+ "build": "npm run clean && npm run build:landing && npm run build:dashboard",
"typecheck": "tsc -p dashboard/tsconfig.json --noEmit",
"preview": "vite preview",
- "deploy": "npm run build && firebase deploy --only hosting",
- "emulators": "firebase emulators:start --project demo-proscan",
- "emulators:persist": "firebase emulators:start --project demo-proscan --import=./.emulator-data --export-on-exit=./.emulator-data",
- "seed": "node scripts/seed-emulators.mjs"
+ "deploy": "node scripts/deploy-check.mjs && npm run build && node scripts/check-bundle.mjs && firebase deploy --project proscanbot --only hosting,firestore:rules,firestore:indexes",
+ "emulators": "node scripts/emulators.mjs start",
+ "emulators:persist": "node scripts/emulators.mjs start --import=./.emulator-data --export-on-exit=./.emulator-data",
+ "seed": "node scripts/seed-emulators.mjs",
+ "test:rules": "node scripts/emulators.mjs exec \"node scripts/qa-sync-rules.mjs && node scripts/qa-lead-rules.mjs && node scripts/qa-board-count.mjs\"",
+ "test:hosting": "node scripts/emulators.mjs exec --only hosting \"node scripts/qa-hosting.mjs\"",
+ "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
+ "test": "npm run typecheck && npm run build && node scripts/check-bundle.mjs && npm run test:rules"
},
"dependencies": {
"@fontsource-variable/archivo": "^5.2.8",
diff --git a/public/privacy/index.html b/public/privacy/index.html
index be397e4..712aa26 100644
--- a/public/privacy/index.html
+++ b/public/privacy/index.html
@@ -4,7 +4,7 @@
Privacy Policy · ProScan
-
+