Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b96e7fe
Gate the dashboard emulator wiring on a build-time VITE_USE_EMULATOR …
EnesYilmazcode Sep 24, 2026
d89d11d
Make emulator ports configurable through EMU_* variables
EnesYilmazcode Sep 24, 2026
4089798
Free the emulator ports after a run on Windows
EnesYilmazcode Sep 24, 2026
a1fd171
Accept lead notes on products that have no stored stage
EnesYilmazcode Sep 24, 2026
1a161e3
Show real Firestore errors in the dashboard
EnesYilmazcode Sep 24, 2026
ec0d532
Say when the product board is a capped window
EnesYilmazcode Sep 24, 2026
e6359ee
Hide board columns that nothing writes yet
EnesYilmazcode Sep 24, 2026
0c7e308
Stop serving index.html for missing dashboard assets
EnesYilmazcode Sep 24, 2026
09975fb
Stamp every build with its git commit
EnesYilmazcode Sep 24, 2026
1a54148
Deploy hosting, rules and indexes together, from a clean main
EnesYilmazcode Sep 24, 2026
95560dd
Add GitHub Actions CI
EnesYilmazcode Sep 24, 2026
5b4048c
Let the e2e run use an already-installed Chromium via QA_CHROMIUM
EnesYilmazcode Sep 24, 2026
3637edd
Document the emulator ports, build stamp and deploy steps
EnesYilmazcode Sep 24, 2026
005d1e1
Drop the capped label when the server count fits the window
EnesYilmazcode Sep 24, 2026
1032db8
Rewrite the privacy policy to match extension 2.1
EnesYilmazcode Sep 24, 2026
c75877b
Describe what a scan does and the Gemini key in the terms
EnesYilmazcode Sep 24, 2026
67fde05
Stop promising automatic tracking, images and trends on the landing page
EnesYilmazcode Sep 24, 2026
17f857a
Say plainly why the privacy policy mentions last-seen values
EnesYilmazcode Sep 24, 2026
72834c4
Drop the sponsored field from the privacy policy, since scans never r…
EnesYilmazcode Sep 24, 2026
0ee5a9c
Only reap emulator ports that were free when the runner started
EnesYilmazcode Sep 24, 2026
d3db464
List the scan source and chat history in what the Gemini chat sends
EnesYilmazcode Sep 24, 2026
d7db1cc
Offer sign out on the workspace setup error screen
EnesYilmazcode Sep 24, 2026
1202b18
Refuse to deploy unless HEAD matches origin/main
EnesYilmazcode Sep 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand Down
26 changes: 22 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 31 additions & 10 deletions dashboard/src/auth/AuthGate.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -59,11 +61,14 @@ async function bootstrapWorkspace(user: User): Promise<void> {
}

/** 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<string | null>(null);
const [bootError, setBootError] = useState<unknown>(null);
const [attempt, setAttempt] = useState(0);

const uid = user?.uid ?? null;

Expand All @@ -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 <Splash />;
if (!user || !uid) return <SignIn />;
if (bootError) {
return (
<div className="auth-splash">
<ErrorState
title="Couldn't set up your workspace"
error={bootError}
onRetry={() => setAttempt((n) => n + 1)}
extra={
<Button variant="ghost" onClick={() => void signOut(auth)}>
Sign out
</Button>
}
/>
</div>
);
}
if (bootstrappedUid !== uid) return <Splash />;

return <WorkspaceProvider uid={uid}>{children}</WorkspaceProvider>;
Expand Down
4 changes: 2 additions & 2 deletions dashboard/src/auth/SignIn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -317,7 +317,7 @@ export default function SignIn() {
<a href="/terms">Terms</a> and <a href="/privacy">Privacy Policy</a>.
</p>

{import.meta.env.DEV ? (
{USE_EMULATOR ? (
<button
type="button"
className="auth__hint"
Expand Down
37 changes: 37 additions & 0 deletions dashboard/src/components/ErrorState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { ReactNode } from 'react';
import { describeError } from '../lib/errors';
import Button from './Button';
import './components.css';

export interface ErrorStateProps {
title: string;
error: unknown;
/** Defaults to a full page reload. */
onRetry?: () => 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 (
<div className="empty errstate" role="alert">
<div className="empty__title">{title}</div>
<p className="empty__body">
{info.code ? <code className="errstate__code">{info.code}</code> : null}
{info.message}
</p>
{info.indexUrl ? (
<p className="empty__body">
<a href={info.indexUrl} target="_blank" rel="noopener noreferrer">
Create the missing index
</a>
</p>
) : null}
{extra}
<Button variant="ghost" onClick={onRetry ?? (() => window.location.reload())}>
Retry
</Button>
</div>
);
}
24 changes: 24 additions & 0 deletions dashboard/src/components/Toaster.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="toaster" role="status" aria-live="polite">
{toasts.map((t) => (
<div key={t.id} className="toast">
<span>{t.text}</span>
<button
type="button"
className="toast__close"
aria-label="Dismiss"
onClick={() => dismissToast(t.id)}
>
×
</button>
</div>
))}
</div>
);
}
45 changes: 45 additions & 0 deletions dashboard/src/components/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
19 changes: 19 additions & 0 deletions dashboard/src/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/// <reference types="vite/client" />

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;
}
11 changes: 11 additions & 0 deletions dashboard/src/features/board/columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, boolean> {
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<Product>[] = [
Expand Down
Loading
Loading