Phase 3: Vue 3 + Nuxt UI v4 dashboard

Implements CLAUDE.md Build Order Phase 3 — the 7-page dashboard,
matching the approved bettersight-dashboard.html mockup's design
system (dot-grid motif, dark sidebar, card shadows, badges) exactly.

Scope note: nuxt-ui-templates/dashboard-vue (the template CLAUDE.md
says to clone) ships entirely in TypeScript, which directly conflicts
with §18's "JavaScript only, no .ts files" rule. Per user decision,
converted the template to plain JS and rebuilt the dashboard chrome
(sidebar/topbar) as custom components matching the mockup instead of
Nuxt UI's own dashboard components, since the mockup is the approved
design (§25) and Nuxt UI's dashboard components have their own default
look that would fight against it.

- Core plumbing: pocketbase.js client, api_service.js (X-API-Key +
  JWT + 401-refresh interceptor), auth_store/analysis_store/
  notifications_store (Pinia), 5 repositories, explicit vue-router
  (v4, not v5 — matching CLAUDE.md's stack table) with an auth guard
- Shared components: programmatic DotGrid.vue, StatCard, EmptyState,
  CompetitorForm, SeatManager, BattlecardCard, SubscriptionCard,
  CsvUpload+ImportPreviewModal, FeedItem, AlertCard
- All 7 pages: Login (+Google OAuth, forgot password), ResetPassword,
  Account (subscription, upgrade prompt, seats, onboarding), Competitors
  (CSV bulk import), Analyse (full find-urls → confirm → poll → results
  → confidence → push-to-sheet flow with all 4 JobProgress terminal
  states), Activity (stat row, feed, alerts, onboarding tour trigger),
  Battlecards
- Shepherd.js 5-step onboarding tour ported verbatim from §21
- Sentry init (prod-only) + global error handler in main.js

Two backend gaps surfaced and fixed while building this:
- Added GET /account/me (dashboard had no way to learn its own
  tenant_id after login) and fixed GET /account/<tenant_id> to verify
  the session's tenant matches the requested one (previously any valid
  X-API-Key could read any tenant's account data)
- Added GET /alerts and PATCH /alerts/<id>/read (no route existed to
  list or acknowledge alerts, needed by the sidebar badge and
  ActivityPage's alerts panel)

Deliberately deferred: the mockup's Price Position sparkline/corridor
card has no defined data contract anywhere in CLAUDE.md — omitted
rather than backed with fabricated numbers. /sheet/push and
/sheet/tabs still return their Phase-1 structured 501 pending Phase 4.

Verified: full production build succeeds, dev server serves the login
page correctly (screenshotted, zero console errors), and the router's
auth guard correctly redirects an unauthenticated session to
/login?return=... with no console errors. Backend: 202 tests still
passing after the two route additions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
JasonFraser 2026-07-01 13:16:31 -04:00
parent 6a259a5012
commit 11610b8b4a
54 changed files with 9311 additions and 2 deletions

View File

@ -16,6 +16,7 @@ from repositories.competitor_repository import competitor_repository
from repositories.scrape_repository import scrape_repository
from repositories.battlecard_repository import battlecard_repository
from repositories.comparable_repository import comparable_repository
from repositories.alert_repository import alert_repository
from services import licence_service
from services import trip_finder_service
@ -381,6 +382,42 @@ def get_comparable_trips():
return jsonify(comparable_repository.list_for_tenant(tenant['id']))
@api_bp.route('/alerts', methods=['GET'])
@require_api_key
def get_alerts():
"""
Returns the current session's tenant's recent alerts, for the
ActivityPage alerts panel and the sidebar's unread-count badge.
unread_only=true narrows to just what still needs attention.
"""
tenant_id = auth_service.get_tenant_id_from_request(request)
if not tenant_id:
return error_response('unauthorized', 'Could not resolve tenant from session', 401)
unread_only = request.args.get('unread_only', 'false').lower() == 'true'
if unread_only:
return jsonify(alert_repository.list_unread(tenant_id))
limit = int(request.args.get('limit', 50))
return jsonify(alert_repository.list_recent(tenant_id, limit=limit))
@api_bp.route('/alerts/<alert_id>/read', methods=['PATCH'])
@require_api_key
def mark_alert_read(alert_id):
"""Marks a single alert as read — called when the PM dismisses/opens it on the dashboard."""
tenant_id = auth_service.get_tenant_id_from_request(request)
if not tenant_id:
return error_response('unauthorized', 'Could not resolve tenant from session', 401)
alert = alert_repository.get_by_id(alert_id)
if not alert:
return error_response('not_found', 'Unknown alert', 404)
if alert.get('tenant_id') != tenant_id:
return error_response('forbidden', 'Not authorized for this alert', 403)
return jsonify(alert_repository.mark_read(alert_id))
# ── APPS SCRIPT EXPORT INTEGRATION ────────────────────────────────────
# NOTE: the Apps Script is a standalone script running in the PM's own
# Google account (CLAUDE.md §15) — it is not deployed or addressable by
@ -430,9 +467,35 @@ def sheet_tabs():
# ── DASHBOARD ACCOUNT ──────────────────────────────────────────────────
@api_bp.route('/account/me', methods=['GET'])
@require_api_key
def get_my_account():
"""
Resolves the caller's own tenant from their session JWT and returns
the same shape as GET /account/<tenant_id> this is how the
dashboard discovers its own tenant_id right after login, since it
has no other way to know it before this call.
"""
tenant_id = auth_service.get_tenant_id_from_request(request)
if not tenant_id:
return error_response('unauthorized', 'Could not resolve tenant from session', 401)
tenant = tenant_repository.get_by_id(tenant_id)
seats = seat_repository.list_for_tenant(tenant_id)
return jsonify({**tenant, 'seats': seats})
@api_bp.route('/account/<tenant_id>', methods=['GET'])
@require_api_key
def get_account(tenant_id):
"""
Same payload as GET /account/me, addressed by id. Still requires
the caller's own session to resolve to this exact tenant_id — a
valid X-API-Key alone must never be enough to read another
tenant's account data.
"""
session_tenant_id = auth_service.get_tenant_id_from_request(request)
if session_tenant_id != tenant_id:
return error_response('forbidden', 'Not authorized for this tenant', 403)
tenant = tenant_repository.get_by_id(tenant_id)
if not tenant:
return error_response('not_found', 'Unknown tenant', 404)

View File

@ -11,6 +11,9 @@ class AlertRepository:
def create(self, data):
return pb_client.create(COLLECTION, data)
def get_by_id(self, alert_id):
return pb_client.get_one(COLLECTION, alert_id)
def list_unread(self, tenant_id):
return pb_client.list(
COLLECTION, filter_str=f'tenant_id = "{tenant_id}" && read = false', sort='-created', per_page=200
@ -35,6 +38,9 @@ class MockAlertRepository:
self._records[record_id] = record
return record
def get_by_id(self, alert_id):
return self._records.get(alert_id)
def list_unread(self, tenant_id):
return [r for r in self._records.values() if r.get('tenant_id') == tenant_id and not r.get('read')]

View File

@ -12,6 +12,7 @@ from repositories.competitor_repository import competitor_repository
from repositories.scrape_repository import scrape_repository
from repositories.battlecard_repository import battlecard_repository
from repositories.comparable_repository import comparable_repository
from repositories.alert_repository import alert_repository
def _make_tenant(**overrides):
@ -43,9 +44,10 @@ def _install_fake_module(monkeypatch, dotted_name, **attrs):
# ── Account ────────────────────────────────────────────────────────
def test_get_account_returns_tenant_with_seats(client, api_headers):
def test_get_account_returns_tenant_with_seats(client, api_headers, monkeypatch):
tenant = _make_tenant()
seat_repository.create({'tenant_id': tenant['id'], 'email': 'pm@gadventures.com', 'active': True})
monkeypatch.setattr('api.auth_service.get_tenant_id_from_request', lambda req: tenant['id'])
response = client.get(f'/account/{tenant["id"]}', headers=api_headers)
@ -54,11 +56,37 @@ def test_get_account_returns_tenant_with_seats(client, api_headers):
assert len(response.json['seats']) == 1
def test_get_account_unknown_tenant_404(client, api_headers):
def test_get_account_unknown_tenant_404(client, api_headers, monkeypatch):
monkeypatch.setattr('api.auth_service.get_tenant_id_from_request', lambda req: 'does-not-exist')
response = client.get('/account/does-not-exist', headers=api_headers)
assert response.status_code == 404
def test_get_account_rejects_mismatched_session_tenant(client, api_headers, monkeypatch):
tenant = _make_tenant()
other_tenant = _make_tenant(domain='otherco.com', email='pm@otherco.com')
monkeypatch.setattr('api.auth_service.get_tenant_id_from_request', lambda req: other_tenant['id'])
response = client.get(f'/account/{tenant["id"]}', headers=api_headers)
assert response.status_code == 403
def test_get_my_account_requires_session(client, api_headers):
response = client.get('/account/me', headers=api_headers)
assert response.status_code == 401
def test_get_my_account_returns_own_tenant(client, api_headers, monkeypatch):
tenant = _make_tenant()
monkeypatch.setattr('api.auth_service.get_tenant_id_from_request', lambda req: tenant['id'])
response = client.get('/account/me', headers=api_headers)
assert response.status_code == 200
assert response.json['name'] == 'G Adventures'
def test_update_competitor(client, api_headers):
tenant = _make_tenant()
competitor = competitor_repository.create({
@ -386,6 +414,71 @@ def test_comparable_trips_route(client, api_headers):
assert len(response.json) == 1
# ── Alerts ─────────────────────────────────────────────────────────
def test_get_alerts_requires_session(client, api_headers):
response = client.get('/alerts', headers=api_headers)
assert response.status_code == 401
def test_get_alerts_returns_recent_for_session_tenant(client, api_headers, monkeypatch):
tenant = _make_tenant()
alert_repository.create({'tenant_id': tenant['id'], 'alert_type': 'price_change', 'message': 'x'})
monkeypatch.setattr('api.auth_service.get_tenant_id_from_request', lambda req: tenant['id'])
response = client.get('/alerts', headers=api_headers)
assert response.status_code == 200
assert len(response.json) == 1
def test_get_alerts_unread_only(client, api_headers, monkeypatch):
tenant = _make_tenant()
alert_repository.create({'tenant_id': tenant['id'], 'alert_type': 'price_change', 'message': 'x', 'read': True})
alert_repository.create({'tenant_id': tenant['id'], 'alert_type': 'new_product', 'message': 'y', 'read': False})
monkeypatch.setattr('api.auth_service.get_tenant_id_from_request', lambda req: tenant['id'])
response = client.get('/alerts', query_string={'unread_only': 'true'}, headers=api_headers)
assert response.status_code == 200
assert len(response.json) == 1
assert response.json[0]['message'] == 'y'
def test_mark_alert_read_requires_session(client, api_headers):
response = client.patch('/alerts/a1/read', headers=api_headers)
assert response.status_code == 401
def test_mark_alert_read_unknown_alert_404(client, api_headers, monkeypatch):
tenant = _make_tenant()
monkeypatch.setattr('api.auth_service.get_tenant_id_from_request', lambda req: tenant['id'])
response = client.patch('/alerts/does-not-exist/read', headers=api_headers)
assert response.status_code == 404
def test_mark_alert_read_rejects_other_tenants_alert(client, api_headers, monkeypatch):
tenant = _make_tenant()
other_tenant = _make_tenant(domain='otherco.com', email='pm@otherco.com')
alert = alert_repository.create({'tenant_id': other_tenant['id'], 'alert_type': 'price_change', 'message': 'x'})
monkeypatch.setattr('api.auth_service.get_tenant_id_from_request', lambda req: tenant['id'])
response = client.patch(f'/alerts/{alert["id"]}/read', headers=api_headers)
assert response.status_code == 403
def test_mark_alert_read_success(client, api_headers, monkeypatch):
tenant = _make_tenant()
alert = alert_repository.create({'tenant_id': tenant['id'], 'alert_type': 'price_change', 'message': 'x'})
monkeypatch.setattr('api.auth_service.get_tenant_id_from_request', lambda req: tenant['id'])
response = client.patch(f'/alerts/{alert["id"]}/read', headers=api_headers)
assert response.status_code == 200
assert response.json['read'] is True
def test_preview_prompt(client, api_headers, monkeypatch):
_install_fake_module(
monkeypatch, 'industries.adventure_travel.prompt',

5
frontend/.env.example Normal file
View File

@ -0,0 +1,5 @@
VITE_POCKETBASE_URL=http://localhost:8090
VITE_API_BASE_URL=http://localhost:5000
VITE_API_KEY=
VITE_SENTRY_DSN=
VITE_ENV=development

33
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,33 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Auto-generated type declarations
auto-imports.d.ts
components.d.ts
tsconfig.app.tsbuildinfo
tsconfig.node.tsbuildinfo
# Environment variables
.env

21
frontend/index.html Normal file
View File

@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#EEF1F6" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet" />
<title>Bettersight</title>
<meta name="description" content="Competitive intelligence for adventure travel operators. Better sight, better moves.">
<style>html, body { background-color: #EEF1F6; }</style>
</head>
<body>
<div id="app" class="isolate"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

6030
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
frontend/package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "bettersight-dashboard",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@nuxt/ui": "^4.0.0",
"@sentry/vue": "^7.120.3",
"axios": "^1.7.9",
"date-fns": "^3.6.0",
"papaparse": "^5.4.1",
"pinia": "^2.2.6",
"pocketbase": "^0.25.1",
"shepherd.js": "^13.0.3",
"tailwindcss": "^4.3.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.0.7"
}
}

8
frontend/public/logo.svg Normal file
View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<style>
.st0 { fill: #42B883; }
.st1 { fill: #35495E; }
</style>
<path class="st0" d="M78.8,10L64,35.4L49.2,10H0l64,110l64-110C128,10,78.8,10,78.8,10z" />
<path class="st1" d="M78.8,10L64,35.4L49.2,10H25.6L64,76l38.4-66H78.8z" />
</svg>

After

Width:  |  Height:  |  Size: 316 B

11
frontend/src/App.vue Normal file
View File

@ -0,0 +1,11 @@
<script setup>
// Light theme only, always CLAUDE.md §25 ("Do not use a dark theme.
// The dashboard is light. The sidebar is the only dark element.").
// No color-mode logic of any kind belongs in this file.
</script>
<template>
<UApp>
<RouterView />
</UApp>
</template>

View File

@ -0,0 +1,286 @@
@import "tailwindcss" theme(static);
@import "@nuxt/ui";
@theme static {
--font-sans: 'Inter', sans-serif;
}
/*
Bettersight design system ported directly from the approved
dashboard mockup (bettersight-dashboard.html) per CLAUDE.md §25.
Light theme only; the sidebar is the only dark element. Do not add
a .dark variant anywhere in this file.
*/
:root {
/* Brand blues */
--blue-vivid: #1B8EF8;
--blue-bright: #3B9EFF;
--blue-light: #E8F4FF;
--blue-mid: #B8D9FF;
--cyan: #00D4FF;
--cyan-light: #E0F8FF;
/* Green accent */
--green: #10D98A;
--green-light: #D4F9ED;
--green-dark: #059669;
/* Semantic */
--red: #F04438;
--red-light: #FEE4E2;
--amber: #F79009;
--amber-light: #FEF0C7;
/* Neutrals */
--page: #EEF1F6;
--surface: #FFFFFF;
--surface-2: #F8FAFD;
--border: rgba(28, 56, 121, .09);
--border-2: rgba(28, 56, 121, .14);
--dot-color: rgba(27, 142, 248, .25);
/* Sidebar */
--sidebar-bg: #0C1A3A;
--sidebar-w: 232px;
/* Text */
--t1: #0D1526;
--t2: #2D3A52;
--t3: #6B7A99;
--t4: #9BA8C0;
/* Card */
--card-radius: 18px;
--card-shadow: 0 2px 8px rgba(12, 26, 60, .06), 0 8px 28px rgba(12, 26, 60, .09), 0 0 0 1px rgba(28, 56, 121, .06);
--card-hover: 0 4px 16px rgba(12, 26, 60, .10), 0 16px 48px rgba(12, 26, 60, .13), 0 0 0 1px rgba(27, 142, 248, .15);
}
*, *::before, *::after { box-sizing: border-box; }
html { font-size: 14px; }
body {
font-family: 'Inter', sans-serif;
background: var(--page);
color: var(--t1);
min-height: 100vh;
background-image: radial-gradient(circle, rgba(27, 142, 248, .07) 1px, transparent 1px);
background-size: 28px 28px;
}
/* ─── LAYOUT SHELL ────────────────────────────────────────────── */
.bs-shell { display: flex; min-height: 100vh; }
.bs-main { margin-left: var(--sidebar-w); flex: 1; display: flex; flex-direction: column; min-width: 0; }
.bs-pb { padding: 22px 28px; flex: 1; }
/* ─── SIDEBAR ─────────────────────────────────────────────────── */
.sb {
width: var(--sidebar-w);
background: var(--sidebar-bg);
min-height: 100vh;
position: fixed; top: 0; left: 0; bottom: 0;
display: flex; flex-direction: column;
z-index: 50;
}
.sb-logo { padding: 22px 18px 20px; border-bottom: 1px solid rgba(255, 255, 255, .07); display: flex; align-items: center; gap: 10px; }
.sb-logo-text { font-size: 15px; font-weight: 700; letter-spacing: -.3px; color: #fff; }
.sb-logo-text em { color: var(--cyan); font-style: normal; }
.sb-nav { flex: 1; padding: 14px 10px; display: flex; flex-direction: column; gap: 1px; overflow-y: auto; }
.sb-label { font-size: 9.5px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: rgba(255, 255, 255, .28); padding: 14px 10px 6px; }
.sb-item {
display: flex; align-items: center; gap: 9px;
padding: 8px 12px; border-radius: 10px;
color: rgba(255, 255, 255, .5); font-size: 13px; font-weight: 500;
cursor: pointer; transition: all .15s; text-decoration: none;
}
.sb-item:hover { background: rgba(255, 255, 255, .07); color: rgba(255, 255, 255, .85); }
.sb-item.on { background: rgba(27, 142, 248, .18); color: #fff; }
.sb-item.on .si { color: var(--cyan); }
.si { width: 15px; height: 15px; flex-shrink: 0; opacity: .9; }
.sb-dot { margin-left: auto; min-width: 18px; height: 18px; background: var(--red); color: #fff; font-size: 10px; font-weight: 700; border-radius: 9px; display: flex; align-items: center; justify-content: center; padding: 0 5px; }
.sb-dot.am { background: var(--amber); }
.sb-foot { padding: 14px 10px; border-top: 1px solid rgba(255, 255, 255, .07); }
.sb-user { display: flex; align-items: center; gap: 9px; padding: 9px 10px; border-radius: 10px; cursor: pointer; }
.sb-user:hover { background: rgba(255, 255, 255, .06); }
.sb-av { width: 30px; height: 30px; border-radius: 50%; flex-shrink: 0; background: linear-gradient(135deg, var(--blue-vivid), var(--cyan)); display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 800; color: #fff; }
.sb-uname { font-size: 12.5px; font-weight: 600; color: #fff; }
.sb-uplan { font-size: 11px; color: rgba(255, 255, 255, .38); }
/* ─── TOPBAR ──────────────────────────────────────────────────── */
.topbar {
background: rgba(255, 255, 255, .82);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
padding: 0 28px; height: 58px;
display: flex; align-items: center; justify-content: space-between;
position: sticky; top: 0; z-index: 40;
}
.topbar-title { font-size: 15px; font-weight: 800; letter-spacing: -.3px; color: var(--t1); }
.topbar-sub { font-size: 11.5px; color: var(--t3); margin-top: 1px; }
.topbar-r { display: flex; align-items: center; gap: 8px; }
.tsearch { display: flex; align-items: center; gap: 7px; background: var(--surface-2); border: 1px solid var(--border-2); border-radius: 10px; padding: 7px 12px; width: 200px; }
.tsearch input { border: none; background: transparent; font-size: 12.5px; color: var(--t2); outline: none; width: 100%; font-family: inherit; }
.tsearch input::placeholder { color: var(--t4); }
.tbtn { display: flex; align-items: center; gap: 6px; padding: 7px 14px; border-radius: 9px; font-size: 12.5px; font-weight: 600; cursor: pointer; border: none; font-family: inherit; transition: all .15s; }
.tbtn-outline { background: var(--surface); color: var(--t2); border: 1px solid var(--border-2); }
.tbtn-outline:hover { border-color: var(--blue-vivid); color: var(--blue-vivid); }
.tbtn-primary { background: linear-gradient(135deg, var(--blue-vivid), #0B7AE8); color: #fff; box-shadow: 0 2px 8px rgba(27, 142, 248, .35); }
.tbtn-primary:hover { box-shadow: 0 4px 14px rgba(27, 142, 248, .45); transform: translateY(-1px); }
.tbtn:disabled { opacity: .5; cursor: not-allowed; transform: none !important; }
.tbell { width: 34px; height: 34px; border-radius: 9px; border: 1px solid var(--border-2); background: var(--surface); display: flex; align-items: center; justify-content: center; cursor: pointer; position: relative; }
.tbell-dot { position: absolute; top: 6px; right: 6px; width: 7px; height: 7px; background: var(--red); border-radius: 50%; border: 1.5px solid #fff; }
/* ─── STAT ROW ────────────────────────────────────────────────── */
.stat-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 22px; }
.sc { background: var(--surface); border-radius: var(--card-radius); box-shadow: var(--card-shadow); padding: 20px 20px 18px; display: flex; flex-direction: column; gap: 0; position: relative; overflow: hidden; transition: box-shadow .2s, transform .2s; cursor: default; }
.sc:hover { box-shadow: var(--card-hover); transform: translateY(-2px); }
.sc::after { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px; border-radius: 18px 18px 0 0; }
.sc.blue::after { background: linear-gradient(90deg, var(--blue-vivid), var(--cyan)); }
.sc.green::after { background: linear-gradient(90deg, var(--green), #00C9A7); }
.sc.red::after { background: var(--red); }
.sc.amber::after { background: var(--amber); }
.sc-top { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 14px; }
.sc-label { font-size: 11px; font-weight: 700; letter-spacing: .07em; text-transform: uppercase; color: var(--t3); }
.sc-ico { width: 34px; height: 34px; border-radius: 9px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.sc-ico.blue { background: var(--blue-light); color: var(--blue-vivid); }
.sc-ico.green { background: var(--green-light); color: var(--green-dark); }
.sc-ico.red { background: var(--red-light); color: var(--red); }
.sc-ico.amber { background: var(--amber-light); color: var(--amber); }
.sc-val { font-size: 32px; font-weight: 900; letter-spacing: -1.5px; color: var(--t1); line-height: 1; margin-bottom: 8px; }
.sc-val.blue-num { background: linear-gradient(135deg, var(--blue-vivid), var(--cyan)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.sc-val.green-num { background: linear-gradient(135deg, var(--green), #00C9A7); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.sc-meta { display: flex; align-items: center; gap: 6px; font-size: 12px; }
.sc-delta { font-weight: 700; font-size: 12px; }
.sc-delta.up { color: var(--green-dark); }
.sc-delta.dn { color: var(--red); }
.sc-period { color: var(--t4); }
.sc-dots { position: absolute; bottom: 14px; right: 14px; opacity: .35; }
/* ─── CONTENT GRID ────────────────────────────────────────────── */
.cg { display: grid; grid-template-columns: 1fr 336px; gap: 18px; }
.cg-left { display: flex; flex-direction: column; gap: 18px; min-width: 0; }
.cg-right { display: flex; flex-direction: column; gap: 18px; }
/* ─── CARD ────────────────────────────────────────────────────── */
.card { background: var(--surface); border-radius: var(--card-radius); box-shadow: var(--card-shadow); overflow: hidden; transition: box-shadow .2s; }
.card:hover { box-shadow: var(--card-hover); }
.ch { padding: 16px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
.ct { font-size: 14px; font-weight: 800; letter-spacing: -.3px; color: var(--t1); }
.cs { font-size: 12px; color: var(--t3); margin-top: 2px; }
.ca { font-size: 12.5px; font-weight: 600; color: var(--blue-vivid); background: var(--blue-light); padding: 5px 12px; border-radius: 8px; cursor: pointer; border: none; font-family: inherit; transition: all .15s; }
.ca:hover { background: var(--blue-mid); }
/* ─── TABS ────────────────────────────────────────────────────── */
.tab-row { display: flex; gap: 0; padding: 0 20px; background: var(--surface-2); border-bottom: 1px solid var(--border); overflow-x: auto; }
.tab { padding: 10px 14px; font-size: 12.5px; font-weight: 500; color: var(--t3); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; transition: all .15s; white-space: nowrap; }
.tab.on { color: var(--blue-vivid); border-bottom-color: var(--blue-vivid); font-weight: 700; }
.tab-count { font-size: 10.5px; font-weight: 700; padding: 1px 6px; border-radius: 9px; margin-left: 5px; }
.tab-count.blue { background: var(--blue-light); color: var(--blue-vivid); }
.tab-count.red { background: var(--red-light); color: var(--red); }
.tab-count.cyan { background: var(--cyan-light); color: #0284C7; }
/* ─── FEED ────────────────────────────────────────────────────── */
.fi { display: flex; align-items: flex-start; gap: 0; padding: 0; border-bottom: 1px solid var(--border); transition: background .12s; cursor: pointer; }
.fi:last-child { border-bottom: none; }
.fi:hover { background: var(--surface-2); }
.fi-dots { width: 52px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; padding: 16px 0; border-right: 1px solid var(--border); }
.fi-body { flex: 1; padding: 13px 16px; min-width: 0; }
.fi-title { font-size: 13px; font-weight: 700; color: var(--t1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 3px; }
.fi-meta { font-size: 11.5px; color: var(--t3); line-height: 1.4; }
.fi-right { padding: 13px 16px; flex-shrink: 0; display: flex; flex-direction: column; align-items: flex-end; gap: 5px; }
.fi-time { font-size: 11px; color: var(--t4); }
/* ─── BADGE ───────────────────────────────────────────────────── */
.badge { display: inline-flex; align-items: center; gap: 3px; padding: 2px 8px; border-radius: 20px; font-size: 11px; font-weight: 700; line-height: 17px; }
.bg-red { background: var(--red-light); color: #C02020; }
.bg-green { background: var(--green-light); color: var(--green-dark); }
.bg-amber { background: var(--amber-light); color: #B45309; }
.bg-blue { background: var(--blue-light); color: var(--blue-vivid); }
.bg-cyan { background: var(--cyan-light); color: #0284C7; }
.bg-gray { background: #F1F4FA; color: var(--t3); }
/* ─── TABLE ───────────────────────────────────────────────────── */
.tbl { width: 100%; border-collapse: collapse; }
.tbl th { text-align: left; font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .07em; color: var(--t4); padding: 10px 0 10px 20px; background: var(--surface-2); border-bottom: 1px solid var(--border); }
.tbl th:first-child { padding-left: 64px; }
.tbl td { padding: 12px 0 12px 20px; border-bottom: 1px solid var(--border); font-size: 13px; }
.tbl td:first-child { padding-left: 0; }
.tbl tr:last-child td { border-bottom: none; }
.tbl tr:hover td { background: var(--surface-2); }
.tbl-cell-dots { width: 64px; padding: 0; display: flex; align-items: center; justify-content: center; border-right: 1px solid var(--border); }
.comp-name { font-weight: 700; color: var(--t1); font-size: 13.5px; }
.comp-url { font-size: 11.5px; color: var(--t3); margin-top: 1px; }
.pdelta { font-weight: 800; font-size: 13px; }
.pdelta.dn { color: var(--red); }
.pdelta.up { color: var(--green-dark); }
/* ─── ALERT CARD ──────────────────────────────────────────────── */
.ai { display: flex; align-items: flex-start; gap: 0; border-bottom: 1px solid var(--border); transition: background .12s; cursor: pointer; }
.ai:last-child { border-bottom: none; }
.ai:hover { background: var(--surface-2); }
.ai-dots { width: 48px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; padding: 14px 0; border-right: 1px solid var(--border); }
.ai-body { padding: 13px 14px; flex: 1; }
.ai-title { font-size: 13px; font-weight: 700; color: var(--t1); }
.ai-desc { font-size: 12px; color: var(--t3); margin-top: 2px; line-height: 1.4; }
.ai-time { font-size: 10.5px; color: var(--t4); margin-top: 4px; }
/* ─── PROGRESS ────────────────────────────────────────────────── */
.pr { display: flex; align-items: center; gap: 0; border-bottom: 1px solid var(--border); }
.pr:last-child { border-bottom: none; }
.pr-dots { width: 48px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; padding: 13px 0; border-right: 1px solid var(--border); }
.pr-inner { flex: 1; padding: 13px 16px; display: flex; align-items: center; gap: 12px; }
.pr-label { width: 114px; font-size: 12.5px; font-weight: 600; color: var(--t2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-shrink: 0; }
.pr-track { flex: 1; height: 7px; background: var(--page); border-radius: 4px; overflow: hidden; }
.pr-fill { height: 100%; border-radius: 4px; transition: width .3s; }
.pr-fill.blue { background: linear-gradient(90deg, var(--blue-vivid), var(--cyan)); }
.pr-fill.green { background: linear-gradient(90deg, var(--green), #00C9A7); }
.pr-fill.gray { background: var(--t4); }
.pr-val { width: 38px; text-align: right; font-size: 12.5px; font-weight: 700; color: var(--t2); }
/* ─── BATTLECARD ──────────────────────────────────────────────── */
.bc { padding: 14px 18px; border-bottom: 1px solid var(--border); }
.bc:last-child { border-bottom: none; }
.bc-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
.bc-name { font-size: 13.5px; font-weight: 800; color: var(--t1); }
.bc-ts { font-size: 11px; color: var(--t4); }
.bc-pos { font-size: 12.5px; color: var(--t2); line-height: 1.5; margin-bottom: 9px; }
.bc-pills { display: flex; flex-wrap: wrap; gap: 5px; }
.pill { font-size: 11px; font-weight: 500; padding: 3px 9px; border-radius: 20px; background: var(--surface-2); color: var(--t3); border: 1px solid var(--border-2); }
.pill.warn { background: var(--amber-light); color: #B45309; border-color: transparent; }
.pill.danger { background: var(--red-light); color: #C02020; border-color: transparent; }
.pill.info { background: var(--cyan-light); color: #0284C7; border-color: transparent; }
/* ─── DOT GRID helper ─────────────────────────────────────────── */
.dg svg { display: block; }
/* ─── SPARKLINE ───────────────────────────────────────────────── */
.spark-wrap { padding: 16px 20px 20px; }
.spark-labels { display: flex; justify-content: space-between; font-size: 11px; color: var(--t4); margin-bottom: 6px; }
.spark-svg { width: 100%; height: 72px; }
/* ─── EMPTY STATE ─────────────────────────────────────────────── */
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 56px 24px; gap: 6px; }
.empty-state-icon { font-size: 32px; margin-bottom: 6px; }
.empty-state-title { font-size: 15px; font-weight: 800; color: var(--t1); }
.empty-state-desc { font-size: 12.5px; color: var(--t3); max-width: 320px; line-height: 1.5; margin-bottom: 10px; }
/* ─── RESPONSIVE ──────────────────────────────────────────────── */
@media (max-width: 1100px) {
.cg { grid-template-columns: 1fr; }
.stat-row { grid-template-columns: repeat(2, 1fr); }
}

View File

@ -0,0 +1,62 @@
/* Custom Shepherd.js styles matching the Bettersight design system
ported from CLAUDE.md §21 verbatim. */
.bs-tour-step .shepherd-content {
border-radius: 14px;
box-shadow: var(--card-hover);
border: 1px solid var(--border-2);
font-family: 'Inter', sans-serif;
max-width: 320px;
}
.bs-tour-step .shepherd-text {
font-size: 13.5px;
line-height: 1.6;
color: var(--t2);
padding: 16px 18px;
}
.bs-tour-step .shepherd-footer {
padding: 12px 18px;
border-top: 1px solid var(--border);
display: flex;
justify-content: space-between;
gap: 8px;
}
.bs-btn-primary {
background: linear-gradient(135deg, #1B8EF8, #0B7AE8);
color: white;
border: none;
border-radius: 8px;
padding: 7px 14px;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
}
.bs-btn-ghost {
background: transparent;
color: var(--t3);
border: 1px solid var(--border-2);
border-radius: 8px;
padding: 7px 14px;
font-size: 12.5px;
font-weight: 500;
cursor: pointer;
}
.shepherd-progress {
display: flex;
gap: 5px;
justify-content: center;
padding-bottom: 12px;
}
.shepherd-progress-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--border-2);
}
.shepherd-progress-dot.active {
background: var(--blue-vivid);
}

View File

@ -0,0 +1,60 @@
<script setup>
import { computed } from 'vue'
// Reusable dot grid SVG the signature visual element from the
// approved mockup (bettersight-dashboard.html), reused in every feed
// row, table row, alert row, progress row, and stat card per
// CLAUDE.md §25. Dots fade diagonally from low opacity (top-left) to
// full opacity (bottom-right); `colorEnd` optionally blends toward a
// second brand color as that fade progresses, mirroring the mockup's
// two-tone grids (e.g. blue cyan).
const props = defineProps({
rows: { type: Number, default: 3 },
cols: { type: Number, default: 3 },
cellSize: { type: Number, default: 8 },
dotRadius: { type: Number, default: 2 },
color: { type: String, required: true },
colorEnd: { type: String, default: null },
opacityStart: { type: Number, default: 0.20 },
opacityEnd: { type: Number, default: 1.0 }
})
const canvasSize = computed(() => ({
width: (props.cols - 1) * props.cellSize + props.dotRadius * 2 + 2,
height: (props.rows - 1) * props.cellSize + props.dotRadius * 2 + 2
}))
const dots = computed(() => {
const maxFraction = (props.rows - 1) + (props.cols - 1)
const list = []
for (let r = 0; r < props.rows; r++) {
for (let c = 0; c < props.cols; c++) {
const fraction = maxFraction === 0 ? 1 : (r + c) / maxFraction
const opacity = props.opacityStart + fraction * (props.opacityEnd - props.opacityStart)
list.push({
cx: c * props.cellSize + props.dotRadius + 1,
cy: r * props.cellSize + props.dotRadius + 1,
opacity: Math.round(opacity * 100) / 100,
fill: props.colorEnd && fraction >= 0.5 ? props.colorEnd : props.color
})
}
}
return list
})
</script>
<template>
<div class="dg">
<svg :width="canvasSize.width" :height="canvasSize.height" fill="none">
<circle
v-for="(dot, i) in dots"
:key="i"
:cx="dot.cx"
:cy="dot.cy"
:r="dotRadius"
:fill="dot.fill"
:fill-opacity="dot.opacity"
/>
</svg>
</div>
</template>

View File

@ -0,0 +1,37 @@
<script setup>
import { formatDistanceToNow } from 'date-fns'
import DotGrid from '../DotGrid.vue'
const props = defineProps({
alert: { type: Object, required: true }
})
const emit = defineEmits(['read'])
const ALERT_COLORS = {
price_change: '#F04438',
new_product: '#00D4FF',
product_removed: '#9BA8C0',
new_comparable: '#10D98A'
}
function dotColor() {
return ALERT_COLORS[props.alert.alert_type] || '#1B8EF8'
}
function timeAgo() {
return props.alert.created ? formatDistanceToNow(new Date(props.alert.created), { addSuffix: true }) : ''
}
</script>
<template>
<div class="ai" @click="!alert.read && emit('read', alert.id)">
<div class="ai-dots">
<DotGrid :rows="3" :cols="3" :cell-size="8" :dot-radius="2" :color="dotColor()" />
</div>
<div class="ai-body">
<div class="ai-title">{{ alert.message }}</div>
<div v-if="alert.change_percent != null" class="ai-desc">Change: {{ alert.change_percent }}%</div>
<div class="ai-time">{{ timeAgo() }}</div>
</div>
</div>
</template>

View File

@ -0,0 +1,29 @@
<script setup>
import DotGrid from '../DotGrid.vue'
defineProps({
dotColor: { type: String, required: true },
dotColorEnd: { type: String, default: null },
title: { type: String, required: true },
meta: { type: String, required: true },
time: { type: String, required: true },
badgeText: { type: String, default: null },
badgeClass: { type: String, default: 'bg-gray' }
})
</script>
<template>
<div class="fi">
<div class="fi-dots">
<DotGrid :rows="3" :cols="3" :cell-size="8" :dot-radius="2" :color="dotColor" :color-end="dotColorEnd" />
</div>
<div class="fi-body">
<div class="fi-title">{{ title }}</div>
<div class="fi-meta">{{ meta }}</div>
</div>
<div class="fi-right">
<div class="fi-time">{{ time }}</div>
<span v-if="badgeText" class="badge" :class="badgeClass">{{ badgeText }}</span>
</div>
</div>
</template>

View File

@ -0,0 +1,38 @@
<script setup>
import { computed } from 'vue'
const props = defineProps({
name: { type: String, required: true },
relevancy: { type: Number, default: null } // 1-5 scale from the extraction
})
const percent = computed(() => props.relevancy != null ? Math.round((props.relevancy / 5) * 100) : null)
const status = computed(() => {
if (percent.value == null) return { text: 'No score', cls: 'gray' }
if (percent.value < 60) return { text: 'Low confidence ⚠', cls: 'red' }
if (percent.value < 80) return { text: 'Review recommended', cls: 'amber' }
return { text: 'Good match', cls: 'green' }
})
const fillClass = computed(() => {
if (percent.value == null) return 'gray'
if (percent.value < 60) return 'gray'
return status.value.cls === 'green' ? 'green' : 'blue'
})
</script>
<template>
<div class="pr">
<div class="pr-inner">
<div class="pr-label">{{ name }}</div>
<div class="pr-track"><div class="pr-fill" :class="fillClass" :style="{ width: `${percent ?? 0}%` }" /></div>
<div class="pr-val">{{ percent != null ? `${percent}%` : '—' }}</div>
<span
class="badge"
:class="{ 'bg-green': status.cls === 'green', 'bg-amber': status.cls === 'amber', 'bg-red': status.cls === 'red', 'bg-gray': status.cls === 'gray' }"
style="margin-left:8px;white-space:nowrap;"
>{{ status.text }}</span>
</div>
</div>
</template>

View File

@ -0,0 +1,75 @@
<script setup>
import DotGrid from '../DotGrid.vue'
const FAILURE_MESSAGES = {
scrape_blocked: 'The scraper was blocked by this site. Try again in a few hours or enter the URL manually.',
scrape_timeout: 'The page took too long to load. This may be a temporary issue.',
extract_failed: 'The AI could not extract structured data from this page. The page structure may have changed.',
url_not_found: 'No matching trip page was found for this competitor. Try entering the URL manually.',
proxy_error: 'Proxy connection failed. Retrying with fallback provider.'
}
const props = defineProps({
status: { type: String, required: true }, // 'queued' | 'running' | 'complete' | 'failed'
progress: { type: Object, required: true },
results: { type: Object, default: null },
error: { type: String, default: null }
})
const emit = defineEmits(['retry', 'skip'])
function failureMessage(errorText) {
const key = Object.keys(FAILURE_MESSAGES).find((k) => errorText?.toLowerCase().includes(k.replace('_', ' ')))
return FAILURE_MESSAGES[key] || errorText || 'This competitor could not be analysed.'
}
</script>
<template>
<div class="card">
<div class="ch">
<div class="ct">
<template v-if="status === 'queued'">Queued</template>
<template v-else-if="status === 'running'">Analysing competitors</template>
<template v-else-if="status === 'complete'">Analysis complete</template>
<template v-else>Analysis failed</template>
</div>
<div v-if="status === 'running' || status === 'queued'" class="cs">
{{ progress.done }} of {{ progress.total }} done
</div>
</div>
<div v-if="status === 'queued'" style="padding:32px;text-align:center;color:var(--t3);font-size:13px;">
Queued waiting for a worker to pick this job up
</div>
<div v-else-if="status === 'running'" class="pr">
<div class="pr-dots"><DotGrid :rows="3" :cols="3" color="#1B8EF8" /></div>
<div class="pr-inner">
<div class="pr-track"><div class="pr-fill blue" :style="{ width: `${(progress.done / Math.max(progress.total, 1)) * 100}%` }" /></div>
<div class="pr-val">{{ progress.done }}/{{ progress.total }}</div>
</div>
</div>
<template v-else-if="status === 'complete' && results">
<div v-for="item in results.failed || []" :key="item.competitor_id" style="padding:16px 20px;border-bottom:1px solid var(--border);">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<span style="color:var(--amber);"></span>
<strong style="font-size:13px;">Analysis failed {{ item.name }}</strong>
</div>
<p style="font-size:12.5px;color:var(--t3);margin:0 0 10px;">{{ failureMessage(item.error) }}</p>
<div style="display:flex;gap:8px;">
<UButton size="xs" color="primary" variant="soft" @click="emit('retry', item)"> Retry this competitor</UButton>
<UButton size="xs" color="neutral" variant="ghost" @click="emit('skip', item)">Skip & continue</UButton>
</div>
</div>
</template>
<div v-else-if="status === 'failed'" style="padding:24px 20px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<span style="color:var(--red);"></span>
<strong style="font-size:13px;">The analysis job failed</strong>
</div>
<p style="font-size:12.5px;color:var(--t3);margin:0 0 10px;">{{ error || 'Something went wrong running this analysis.' }}</p>
<UButton size="xs" color="primary" variant="soft" @click="emit('retry')"> Retry</UButton>
</div>
</div>
</template>

View File

@ -0,0 +1,56 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useAuthStore } from '../../stores/auth_store'
import * as analysisRepository from '../../repositories/analysis_repository'
const props = defineProps({
jobId: { type: String, required: true }
})
const authStore = useAuthStore()
const tabs = ref([])
const selectedTab = ref(null)
const pushing = ref(false)
const notice = ref(null)
onMounted(async () => {
try {
const data = await analysisRepository.getSheetTabs(authStore.email)
tabs.value = data.tabs || []
selectedTab.value = tabs.value[0] || null
} catch {
// Apps Script plugin (Phase 4) isn't installed/deployed yet the
// backend returns a structured 501 for this, which we surface as
// a plain, non-alarming notice rather than an error toast.
notice.value = 'Push to Sheet needs the Bettersight Sheets plugin — install it from your Account page.'
}
})
async function push() {
pushing.value = true
try {
await analysisRepository.pushToSheet(authStore.email, props.jobId, selectedTab.value)
await authStore.updateOnboarding({ push_to_sheet: true })
notice.value = 'Pushed to Sheet ✓'
} catch {
notice.value = 'Push to Sheet needs the Bettersight Sheets plugin — install it from your Account page.'
} finally {
pushing.value = false
}
}
</script>
<template>
<div class="card" style="padding:16px 20px;display:flex;align-items:center;gap:10px;" data-tour="push-to-sheet">
<USelect
v-if="tabs.length"
v-model="selectedTab"
:items="tabs"
style="width:200px;"
/>
<UButton color="primary" :disabled="!tabs.length" :loading="pushing" @click="push">
Push to Sheet
</UButton>
<span v-if="notice" style="font-size:12px;color:var(--t3);">{{ notice }}</span>
</div>
</template>

View File

@ -0,0 +1,49 @@
<script setup>
// Multi-currency display rule (CLAUDE.md §6 AnalysePage spec): show
// the competitor's own primary-market currency as the hero price,
// USD always alongside in muted secondary text, never converted.
const CURRENCY_MAP = {
GB: { field: 'gbpPrice', symbol: '£' },
AU: { field: 'audPrice', symbol: 'A$' },
CA: { field: 'cadPrice', symbol: 'C$' },
DE: { field: 'eurPrice', symbol: '€' }, FR: { field: 'eurPrice', symbol: '€' },
ES: { field: 'eurPrice', symbol: '€' }, IT: { field: 'eurPrice', symbol: '€' }, NL: { field: 'eurPrice', symbol: '€' }
}
defineProps({
results: { type: Array, required: true } // [{ competitor_id, name, path, result, product }]
})
function formatPrice(extracted, primaryMarket) {
if (!extracted) return '—'
const usd = extracted.majorityPrice ?? extracted.majority_price_usd
const mapping = CURRENCY_MAP[primaryMarket]
const heroValue = mapping ? extracted[mapping.field] : null
if (heroValue != null) {
const usdText = usd != null ? ` $${Number(usd).toLocaleString()} USD` : ''
return `${mapping.symbol}${Number(heroValue).toLocaleString()}${usdText}`
}
return usd != null ? `$${Number(usd).toLocaleString()} USD` : '—'
}
</script>
<template>
<div class="card">
<div class="ch"><div class="ct">Results</div></div>
<table class="tbl">
<thead>
<tr><th style="padding-left:20px;">Competitor</th><th>Trip</th><th>Price</th><th>Duration</th><th>Comments</th></tr>
</thead>
<tbody>
<tr v-for="item in results" :key="item.competitor_id">
<td style="padding-left:20px;"><div class="comp-name">{{ item.name }}</div></td>
<td>{{ item.result?.tripName || '—' }}</td>
<td class="pdelta">{{ formatPrice(item.result, item.primary_market) }}</td>
<td>{{ item.result?.duration ?? '—' }} days</td>
<td style="max-width:280px;color:var(--t3);font-size:12px;">{{ item.result?.comments || '—' }}</td>
</tr>
</tbody>
</table>
</div>
</template>

View File

@ -0,0 +1,22 @@
<script setup>
defineProps({
history: { type: Array, required: true }
})
const emit = defineEmits(['restore'])
</script>
<template>
<div class="card">
<div class="ch"><div class="ct">Run history</div><div class="cs">Last 5 runs</div></div>
<div v-if="!history.length" style="padding:20px;color:var(--t3);font-size:12.5px;">No previous runs yet.</div>
<div v-for="run in history" :key="run.id" class="fi" style="padding:0;">
<div class="fi-body" style="padding:12px 16px;">
<div class="fi-title">{{ run.started_at }} · {{ run.competitors_total }} competitors</div>
<div class="fi-meta">Status: {{ run.status }}</div>
</div>
<div class="fi-right">
<UButton size="xs" color="neutral" variant="ghost" @click="emit('restore', run.id)">Restore</UButton>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,85 @@
<script setup>
import { ref } from 'vue'
const props = defineProps({
competitors: { type: Array, required: true },
submitting: { type: Boolean, default: false }
})
const emit = defineEmits(['submit'])
const TRAVEL_STYLES = ['Classic', 'Premium', 'Basic', 'NGS']
const productName = ref('')
const destination = ref('')
const duration = ref('')
const travelStyle = ref('Classic')
const tabType = ref('Standard')
const selectedIds = ref([])
function toggle(id) {
const i = selectedIds.value.indexOf(id)
if (i === -1) selectedIds.value.push(id)
else selectedIds.value.splice(i, 1)
}
function submit() {
emit('submit', {
productName: productName.value,
destination: destination.value,
duration: Number(duration.value),
travelStyle: travelStyle.value,
tabType: tabType.value,
competitorIds: selectedIds.value
})
}
</script>
<template>
<div class="card" style="padding:20px;">
<div class="ct" style="margin-bottom:16px;">Describe the trip to research</div>
<form style="display:flex;flex-direction:column;gap:14px;" @submit.prevent="submit">
<UFormField label="Product name">
<UInput v-model="productName" placeholder="Inca Trail Classic" class="w-full" />
</UFormField>
<div style="display:grid;grid-template-columns:2fr 1fr;gap:12px;">
<UFormField label="Destination">
<UInput v-model="destination" placeholder="Peru" required class="w-full" />
</UFormField>
<UFormField label="Duration (days)">
<UInput v-model="duration" type="number" placeholder="15" required class="w-full" />
</UFormField>
</div>
<UFormField label="Travel style">
<USelect v-model="travelStyle" :items="TRAVEL_STYLES" class="w-full" />
</UFormField>
<UFormField label="Tab type">
<URadioGroup v-model="tabType" :items="['Standard', 'NGS']" orientation="horizontal" />
</UFormField>
<div>
<div style="font-size:12.5px;font-weight:700;color:var(--t3);margin-bottom:8px;">Competitors</div>
<div style="display:flex;flex-direction:column;gap:6px;">
<label
v-for="competitor in competitors"
:key="competitor.id"
style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer;"
>
<UCheckbox :model-value="selectedIds.includes(competitor.id)" @update:model-value="toggle(competitor.id)" />
{{ competitor.name }}
</label>
</div>
</div>
<UButton
type="submit"
color="primary"
block
:loading="submitting"
:disabled="!selectedIds.length || !destination || !duration"
>
Find & Analyse
</UButton>
</form>
</div>
</template>

View File

@ -0,0 +1,61 @@
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
matches: { type: Array, required: true },
submitting: { type: Boolean, default: false }
})
const emit = defineEmits(['confirm', 'back'])
const editableUrls = ref({})
const editingId = ref(null)
watch(() => props.matches, (matches) => {
editableUrls.value = Object.fromEntries(matches.map((m) => [m.competitor_id, m.url || '']))
}, { immediate: true })
function confirm() {
const competitors = props.matches.map((m) => ({
id: m.competitor_id,
name: m.competitor_name,
url: editableUrls.value[m.competitor_id]
}))
emit('confirm', competitors)
}
const allResolved = () => props.matches.every((m) => editableUrls.value[m.competitor_id]?.trim())
</script>
<template>
<div class="card">
<div class="ch">
<div class="ct">Confirm matched pages</div>
</div>
<div v-for="match in matches" :key="match.competitor_id" class="pr" style="align-items:center;">
<div class="pr-inner" style="gap:16px;">
<div class="pr-label" style="width:140px;">{{ match.competitor_name }}</div>
<template v-if="editingId === match.competitor_id">
<UInput v-model="editableUrls[match.competitor_id]" class="flex-1" placeholder="https://…" />
<UButton size="xs" color="primary" @click="editingId = null">Done</UButton>
</template>
<template v-else-if="editableUrls[match.competitor_id]">
<div style="flex:1;font-size:12.5px;color:var(--t3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
{{ editableUrls[match.competitor_id] }}
</div>
<span v-if="match.found" class="badge bg-green"> {{ Math.round((match.confidence || 0) * 100) }}%</span>
<UButton size="xs" color="neutral" variant="ghost" @click="editingId = match.competitor_id"> Swap</UButton>
</template>
<template v-else>
<div style="flex:1;font-size:12.5px;color:var(--red);">No match found</div>
<UButton size="xs" color="primary" variant="soft" @click="editingId = match.competitor_id">Enter URL manually</UButton>
</template>
</div>
</div>
<div style="padding:16px 20px;display:flex;justify-content:space-between;">
<UButton color="neutral" variant="ghost" @click="emit('back')"> Back</UButton>
<UButton color="primary" :loading="submitting" :disabled="!allResolved()" @click="confirm">Confirm & Run</UButton>
</div>
</div>
</template>

View File

@ -0,0 +1,30 @@
<script setup>
import Sidebar from './Sidebar.vue'
import Topbar from './Topbar.vue'
defineProps({
title: { type: String, required: true },
subtitle: { type: String, default: '' },
showExport: { type: Boolean, default: false },
showRunAnalysis: { type: Boolean, default: true }
})
defineEmits(['export'])
</script>
<template>
<div class="bs-shell">
<Sidebar />
<main class="bs-main">
<Topbar
:title="title"
:subtitle="subtitle"
:show-export="showExport"
:show-run-analysis="showRunAnalysis"
@export="$emit('export')"
/>
<div class="bs-pb">
<slot />
</div>
</main>
</div>
</template>

View File

@ -0,0 +1,75 @@
<script setup>
import { computed, onMounted } from 'vue'
import { useAuthStore } from '../../stores/auth_store'
import { useNotificationsStore } from '../../stores/notifications_store'
const authStore = useAuthStore()
const notificationsStore = useNotificationsStore()
onMounted(() => {
notificationsStore.fetchUnreadCount()
})
const initials = computed(() => {
const name = authStore.tenant?.name || authStore.email || '?'
return name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase()
})
const planLabel = computed(() => {
const tier = authStore.tier ? authStore.tier[0].toUpperCase() + authStore.tier.slice(1) : ''
const used = authStore.tenant?.seats?.length ?? 0
const max = authStore.tenant?.max_seats ?? '—'
return `${tier} · ${used} of ${max} seats`
})
</script>
<template>
<aside class="sb">
<div class="sb-logo">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<rect width="28" height="28" rx="7" fill="rgba(27,142,248,.15)" />
<path d="M5 15.5L11 9L16 14L22 8" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M14 14.5L18.5 10.5L22 14L18.5 17.5L14 14.5Z" fill="#00D4FF" opacity=".9" />
<path d="M6 18L11 22L16 18" stroke="rgba(255,255,255,.4)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span class="sb-logo-text">Better<em>sight</em></span>
</div>
<nav class="sb-nav">
<div class="sb-label">Intelligence</div>
<RouterLink to="/activity" class="sb-item" active-class="on" data-tour="nav-activity">
<svg class="si" viewBox="0 0 16 16" fill="currentColor"><path d="M1 1h6v6H1zm8 0h6v6H9zM1 9h6v6H1zm8 0h6v6H9z" /></svg>
Market Movement
<span v-if="notificationsStore.unreadCount > 0" class="sb-dot">{{ notificationsStore.unreadCount }}</span>
</RouterLink>
<RouterLink to="/battlecards" class="sb-item" active-class="on" data-tour="nav-battlecards">
<svg class="si" viewBox="0 0 16 16" fill="currentColor"><path d="M3 3h10v1.5H3zm0 4h10v1.5H3zm0 4h7v1.5H3z" /></svg>
Battlecards
</RouterLink>
<RouterLink to="/analyse" class="sb-item" active-class="on" data-tour="nav-analyse">
<svg class="si" viewBox="0 0 16 16" fill="currentColor"><path d="M14 11H9V9h5zm-6 0H2V9h6zm6-4H9V5h5zM8 7H2V5h6zm6 8H9v-2h5zm-6 0H2v-2h6z" /></svg>
Analyse
</RouterLink>
<div class="sb-label">Setup</div>
<RouterLink to="/competitors" class="sb-item" active-class="on" data-tour="nav-competitors">
<svg class="si" viewBox="0 0 16 16" fill="currentColor"><path d="M8 1a5 5 0 100 10A5 5 0 008 1zm0 1.5a3.5 3.5 0 110 7 3.5 3.5 0 010-7zM1 14.5c0-2.2 3.1-3.5 7-3.5s7 1.3 7 3.5H1z" /></svg>
Competitors
</RouterLink>
<RouterLink to="/account" class="sb-item" active-class="on" data-tour="nav-account">
<svg class="si" viewBox="0 0 16 16" fill="currentColor"><path d="M8 1a2 2 0 012 2v.5h2.5a.5.5 0 01.5.5v9a.5.5 0 01-.5.5h-9a.5.5 0 01-.5-.5V4a.5.5 0 01.5-.5H6V3a2 2 0 012-2zm0 1.5a.5.5 0 00-.5.5v.5h1V3a.5.5 0 00-.5-.5z" /></svg>
Account
</RouterLink>
</nav>
<div class="sb-foot">
<RouterLink to="/account" class="sb-user">
<div class="sb-av">{{ initials }}</div>
<div style="flex:1;min-width:0;">
<div class="sb-uname">{{ authStore.tenant?.name || authStore.email }}</div>
<div class="sb-uplan">{{ planLabel }}</div>
</div>
</RouterLink>
</div>
</aside>
</template>

View File

@ -0,0 +1,42 @@
<script setup>
import { useRouter } from 'vue-router'
import { useNotificationsStore } from '../../stores/notifications_store'
defineProps({
title: { type: String, required: true },
subtitle: { type: String, default: '' },
showExport: { type: Boolean, default: false },
showRunAnalysis: { type: Boolean, default: true }
})
const emit = defineEmits(['export'])
const router = useRouter()
const notificationsStore = useNotificationsStore()
</script>
<template>
<header class="topbar">
<div>
<div class="topbar-title">{{ title }}</div>
<div v-if="subtitle" class="topbar-sub">{{ subtitle }}</div>
</div>
<div class="topbar-r">
<div class="tsearch">
<svg width="13" height="13" viewBox="0 0 16 16" fill="#9BA8C0"><path d="M11.5 7a4.5 4.5 0 10-9 0 4.5 4.5 0 009 0zm-.8 4.3l3 3-1.1 1.1-3-3a6 6 0 111.1-1.1z" /></svg>
<input type="text" placeholder="Search…">
</div>
<button class="tbell" @click="router.push('/activity')">
<svg width="15" height="15" viewBox="0 0 16 16" fill="#6B7A99"><path d="M8 1a5.5 5.5 0 00-5.5 5.5c0 2.1-.6 3.7-1.5 4.5h14c-.9-.8-1.5-2.4-1.5-4.5A5.5 5.5 0 008 1zM6.5 13a1.5 1.5 0 003 0h-3z" /></svg>
<div v-if="notificationsStore.unreadCount > 0" class="tbell-dot" />
</button>
<button v-if="showExport" class="tbtn tbtn-outline" @click="emit('export')">
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor"><path d="M3 4h10M3 8h10M3 12h6" /></svg>
Export
</button>
<button v-if="showRunAnalysis" class="tbtn tbtn-primary" @click="router.push('/analyse')">
<svg width="13" height="13" viewBox="0 0 16 16" fill="white"><path d="M8 3v10M3 8h10" /></svg>
Run Analysis
</button>
</div>
</header>
</template>

View File

@ -0,0 +1,33 @@
<script setup>
import { formatDistanceToNow } from 'date-fns'
defineProps({
battlecard: { type: Object, required: true }
})
function timeAgo(ts) {
return ts ? formatDistanceToNow(new Date(ts), { addSuffix: true }) : 'Not yet generated'
}
</script>
<template>
<div class="bc">
<div class="bc-row">
<div class="bc-name">{{ battlecard.competitor_name }}</div>
<div class="bc-ts">Updated {{ timeAgo(battlecard.generated_at) }}</div>
</div>
<div class="bc-pos">{{ battlecard.content_json?.positioning || battlecard.content }}</div>
<div class="bc-pills">
<span
v-for="(strength, i) in (battlecard.content_json?.strengths || [])"
:key="`s-${i}`"
class="pill"
>{{ strength }}</span>
<span
v-for="(weakness, i) in (battlecard.content_json?.weaknesses || [])"
:key="`w-${i}`"
class="pill danger"
>{{ weakness }}</span>
</div>
</div>
</template>

View File

@ -0,0 +1,89 @@
<script setup>
import { ref, watch } from 'vue'
import { z } from 'zod'
const props = defineProps({
open: { type: Boolean, required: true },
competitor: { type: Object, default: null } // present = edit mode
})
const emit = defineEmits(['update:open', 'submit'])
const MARKETS = [
{ label: 'United Kingdom', value: 'GB' }, { label: 'United States', value: 'US' },
{ label: 'Australia', value: 'AU' }, { label: 'New Zealand', value: 'NZ' },
{ label: 'Canada', value: 'CA' }, { label: 'Germany', value: 'DE' },
{ label: 'France', value: 'FR' }, { label: 'Spain', value: 'ES' },
{ label: 'Italy', value: 'IT' }, { label: 'Netherlands', value: 'NL' }
]
const schema = z.object({
name: z.string().min(1, 'Name is required'),
website: z.string().url('Must be a valid URL'),
catalogue_url: z.string().url('Must be a valid URL'),
primary_market: z.string().min(2, 'Primary market is required')
})
const state = ref({ name: '', website: '', catalogue_url: '', primary_market: '' })
const errors = ref({})
watch(() => props.open, (isOpen) => {
if (isOpen) {
state.value = props.competitor
? { ...props.competitor }
: { name: '', website: '', catalogue_url: '', primary_market: '' }
errors.value = {}
}
})
function submit() {
const result = schema.safeParse(state.value)
if (!result.success) {
errors.value = Object.fromEntries(
result.error.issues.map((issue) => [issue.path[0], issue.message])
)
return
}
emit('submit', result.data)
emit('update:open', false)
}
</script>
<template>
<UModal :open="open" @update:open="(v) => emit('update:open', v)">
<template #content>
<UCard>
<template #header>
<div class="ct">{{ competitor ? 'Edit competitor' : 'Add competitor' }}</div>
</template>
<div style="display:flex;flex-direction:column;gap:14px;">
<UFormField label="Name" :error="errors.name">
<UInput v-model="state.name" placeholder="Intrepid Travel" />
</UFormField>
<UFormField label="Website" :error="errors.website">
<UInput v-model="state.website" placeholder="https://intrepidtravel.com" />
</UFormField>
<UFormField label="Catalogue URL" :error="errors.catalogue_url">
<UInput v-model="state.catalogue_url" placeholder="https://intrepidtravel.com/adventures" />
</UFormField>
<UFormField label="Primary market" :error="errors.primary_market">
<USelect
v-model="state.primary_market"
:items="MARKETS"
value-key="value"
placeholder="Select a market"
data-tour="primary-market-field"
/>
</UFormField>
</div>
<template #footer>
<div style="display:flex;justify-content:flex-end;gap:8px;">
<UButton color="neutral" variant="ghost" @click="emit('update:open', false)">Cancel</UButton>
<UButton color="primary" @click="submit">{{ competitor ? 'Save changes' : 'Add competitor' }}</UButton>
</div>
</template>
</UCard>
</template>
</UModal>
</template>

View File

@ -0,0 +1,72 @@
<script setup>
import { ref } from 'vue'
import Papa from 'papaparse'
const emit = defineEmits(['parsed'])
const fileInput = ref(null)
const VALID_MARKETS = [
'GB', 'US', 'AU', 'NZ', 'CA', 'DE', 'FR', 'ES', 'IT',
'NL', 'ZA', 'IN', 'JP', 'SG', 'AE', 'BR', 'MX', 'TT'
]
function isValidUrl(url) {
try {
new URL(url)
return true
} catch {
return false
}
}
function validateRow(row, lineNumber) {
const errors = []
if (!row.name?.trim()) errors.push(`Line ${lineNumber}: name is required`)
if (!isValidUrl(row.website)) errors.push(`Line ${lineNumber}: website must be a valid URL`)
if (!isValidUrl(row.catalogue_url)) errors.push(`Line ${lineNumber}: catalogue_url must be a valid URL`)
if (!VALID_MARKETS.includes(row.primary_market?.toUpperCase())) {
errors.push(`Line ${lineNumber}: primary_market must be a valid ISO country code`)
}
return errors
}
// Parses an uploaded CSV file and validates each row against the
// competitor schema CLAUDE.md §22, ported near-verbatim.
function parseCompetitorCsv(file) {
return new Promise((resolve, reject) => {
Papa.parse(file, {
header: true,
skipEmptyLines: true,
complete: (results) => {
const valid = []
const invalid = []
results.data.forEach((row, index) => {
if (row.name?.startsWith('#')) return // skip commented example rows
const errors = validateRow(row, index + 2)
if (errors.length === 0) valid.push(row)
else invalid.push({ row, errors })
})
resolve({ valid, invalid })
},
error: (err) => reject(err)
})
})
}
async function onFileChange(event) {
const file = event.target.files?.[0]
if (!file) return
const { valid, invalid } = await parseCompetitorCsv(file)
emit('parsed', { valid, invalid })
fileInput.value.value = ''
}
</script>
<template>
<div>
<input ref="fileInput" type="file" accept=".csv" style="display:none;" @change="onFileChange">
<UButton color="neutral" variant="outline" icon="i-lucide-upload" @click="fileInput.click()">
Upload CSV
</UButton>
</div>
</template>

View File

@ -0,0 +1,28 @@
<script setup>
// Every page has a defined empty state never a blank page (CLAUDE.md
// "Empty States First Run Experience"). Exactly one action button.
defineProps({
icon: { type: String, default: '📡' },
title: { type: String, required: true },
description: { type: String, required: true },
actionLabel: { type: String, default: null },
actionTo: { type: String, default: null }
})
const emit = defineEmits(['action'])
</script>
<template>
<div class="card">
<div class="empty-state">
<div class="empty-state-icon">{{ icon }}</div>
<div class="empty-state-title">{{ title }}</div>
<div class="empty-state-desc">{{ description }}</div>
<RouterLink v-if="actionLabel && actionTo" :to="actionTo" class="tbtn tbtn-primary" style="text-decoration:none;">
{{ actionLabel }}
</RouterLink>
<button v-else-if="actionLabel" class="tbtn tbtn-primary" @click="emit('action')">
{{ actionLabel }}
</button>
</div>
</div>
</template>

View File

@ -0,0 +1,50 @@
<script setup>
const props = defineProps({
open: { type: Boolean, required: true },
valid: { type: Array, required: true },
invalid: { type: Array, required: true }
})
const emit = defineEmits(['update:open', 'confirm'])
</script>
<template>
<UModal :open="open" @update:open="(v) => emit('update:open', v)">
<template #content>
<UCard>
<template #header>
<div class="ct">Import preview {{ valid.length + invalid.length }} competitors</div>
</template>
<div style="max-height:360px;overflow-y:auto;display:flex;flex-direction:column;gap:6px;">
<div v-for="(row, i) in valid" :key="`v-${i}`" style="display:flex;align-items:center;gap:8px;font-size:13px;">
<span class="badge bg-green"></span>
<span style="font-weight:700;">{{ row.name }}</span>
<span style="color:var(--t3);">{{ row.website }}</span>
<span style="margin-left:auto;color:var(--t3);">{{ row.primary_market }}</span>
</div>
<div v-for="(item, i) in invalid" :key="`i-${i}`" style="display:flex;flex-direction:column;gap:2px;font-size:12.5px;">
<div style="display:flex;align-items:center;gap:8px;">
<span class="badge bg-amber"></span>
<span style="font-weight:700;">{{ item.row.name || 'Unknown' }}</span>
</div>
<div v-for="(err, j) in item.errors" :key="j" style="color:var(--red);margin-left:26px;">{{ err }}</div>
</div>
</div>
<div style="margin-top:12px;font-size:12.5px;color:var(--t3);">
{{ valid.length }} valid · {{ invalid.length }} error{{ invalid.length === 1 ? '' : 's' }}
<span v-if="invalid.length">(fix before importing)</span>
</div>
<template #footer>
<div style="display:flex;justify-content:flex-end;gap:8px;">
<UButton color="neutral" variant="ghost" @click="emit('update:open', false)">Cancel</UButton>
<UButton color="primary" :disabled="!valid.length" @click="emit('confirm', valid)">
Import {{ valid.length }} valid row{{ valid.length === 1 ? '' : 's' }}
</UButton>
</div>
</template>
</UCard>
</template>
</UModal>
</template>

View File

@ -0,0 +1,57 @@
<script setup>
import { ref } from 'vue'
const props = defineProps({
seats: { type: Array, required: true },
maxSeats: { type: Number, required: true }
})
const emit = defineEmits(['add', 'remove'])
const newEmail = ref('')
function addSeat() {
if (!newEmail.value.trim()) return
emit('add', newEmail.value.trim())
newEmail.value = ''
}
</script>
<template>
<div class="card">
<div class="ch">
<div>
<div class="ct">Seats</div>
<div class="cs">{{ seats.length }} of {{ maxSeats }} used</div>
</div>
</div>
<table class="tbl">
<thead>
<tr><th style="padding-left:20px;">Email</th><th>Last accessed</th><th></th></tr>
</thead>
<tbody>
<tr v-for="seat in seats" :key="seat.id">
<td style="padding-left:20px;">{{ seat.email }}</td>
<td style="color:var(--t3);font-size:12px;">{{ seat.last_accessed || 'Never' }}</td>
<td style="text-align:right;padding-right:20px;">
<UButton color="error" variant="ghost" size="xs" icon="i-lucide-trash-2" @click="emit('remove', seat.id)" />
</td>
</tr>
</tbody>
</table>
<div style="padding:14px 20px;border-top:1px solid var(--border);display:flex;gap:8px;">
<UInput
v-model="newEmail"
placeholder="teammate@company.com"
class="flex-1"
:disabled="seats.length >= maxSeats"
@keyup.enter="addSeat"
/>
<UButton color="primary" :disabled="seats.length >= maxSeats" @click="addSeat">Add seat</UButton>
</div>
<div v-if="seats.length >= maxSeats" style="padding:0 20px 14px;font-size:11.5px;color:var(--t3);">
Seat limit reached for your tier upgrade to add more seats.
</div>
</div>
</template>

View File

@ -0,0 +1,42 @@
<script setup>
import DotGrid from '../DotGrid.vue'
// Matches the mockup's four-variant stat card exactly (§25). `accent`
// picks the top bar gradient/dot-grid color and icon tint; `valueStyle`
// picks between a plain color and the gradient-text treatment used on
// the two hero metrics (Price Changes, New Products).
const props = defineProps({
label: { type: String, required: true },
value: { type: [String, Number], required: true },
accent: { type: String, required: true }, // 'blue' | 'green' | 'red' | 'amber'
valueGradient: { type: Boolean, default: false },
valueColor: { type: String, default: null } // used when valueGradient is false and the default --t1 isn't right
})
const ACCENT_HEX = { blue: '#1B8EF8', green: '#10D98A', red: '#F04438', amber: '#F79009' }
const ACCENT_HEX_END = { blue: '#00D4FF', green: '#00C9A7' }
</script>
<template>
<div class="sc" :class="accent">
<div class="sc-top">
<div class="sc-label">{{ label }}</div>
<div class="sc-ico" :class="accent">
<slot name="icon" />
</div>
</div>
<div
class="sc-val"
:class="{ 'blue-num': valueGradient && accent === 'blue', 'green-num': valueGradient && accent === 'green' }"
:style="!valueGradient && valueColor ? { color: valueColor } : {}"
>
{{ value }}
</div>
<div class="sc-meta">
<slot name="meta" />
</div>
<div class="sc-dots">
<DotGrid :rows="4" :cols="6" :cell-size="8" :dot-radius="1.8" :color="ACCENT_HEX[accent]" :color-end="ACCENT_HEX_END[accent]" :opacity-start="0.12" :opacity-end="1" />
</div>
</div>
</template>

View File

@ -0,0 +1,51 @@
<script setup>
import { computed } from 'vue'
import { differenceInCalendarDays } from 'date-fns'
const props = defineProps({
tenant: { type: Object, required: true }
})
const emit = defineEmits(['manage-billing'])
const tierLabel = computed(() => {
const t = props.tenant.tier
return t ? t[0].toUpperCase() + t.slice(1) : ''
})
const trialDaysLeft = computed(() => {
if (props.tenant.status !== 'trial' || !props.tenant.trial_ends_at) return null
return Math.max(0, differenceInCalendarDays(new Date(props.tenant.trial_ends_at), new Date()))
})
const statusBadgeClass = computed(() => {
if (props.tenant.status === 'active') return 'bg-green'
if (props.tenant.status === 'trial') return 'bg-blue'
return 'bg-gray'
})
</script>
<template>
<div class="card">
<div class="ch">
<div>
<div class="ct">Subscription</div>
<div class="cs">{{ tierLabel }} tier</div>
</div>
<span class="badge" :class="statusBadgeClass">{{ tenant.status }}</span>
</div>
<div style="padding:18px 20px;display:flex;flex-direction:column;gap:10px;">
<div style="display:flex;justify-content:space-between;font-size:13px;">
<span style="color:var(--t3);">Seats</span>
<span style="font-weight:700;">{{ tenant.seats?.length ?? 0 }} of {{ tenant.max_seats }}</span>
</div>
<div v-if="trialDaysLeft !== null" style="display:flex;justify-content:space-between;font-size:13px;">
<span style="color:var(--t3);">Trial ends in</span>
<span style="font-weight:700;color:var(--amber);">{{ trialDaysLeft }} day{{ trialDaysLeft === 1 ? '' : 's' }}</span>
</div>
<UButton color="neutral" variant="outline" block @click="emit('manage-billing')">
Manage billing
</UButton>
</div>
</div>
</template>

View File

@ -0,0 +1,126 @@
import Shepherd from 'shepherd.js'
import 'shepherd.js/dist/css/shepherd.css'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth_store'
// 5-step guided tour — fires on first login, resumes from last step if
// interrupted, never fires again once completed. Ported from CLAUDE.md
// §21 verbatim (step copy, anchors, button labels), adapted to call
// authStore.updateOnboarding() instead of the doc's tenantStore example.
export function useOnboardingTour() {
const router = useRouter()
const authStore = useAuthStore()
const tour = new Shepherd.Tour({
useModalOverlay: true,
defaultStepOptions: {
cancelIcon: { enabled: true },
classes: 'bs-tour-step',
scrollTo: { behavior: 'smooth', block: 'center' },
when: { cancel: () => markTourSkipped() }
}
})
tour.addStep({
id: 'welcome',
text: `
<strong>Welcome to Bettersight 👋</strong><br><br>
Let's get you to your first competitive analysis in under 5 minutes.
`,
buttons: [
{ text: 'Skip tour', action: tour.cancel, classes: 'bs-btn-ghost' },
{ text: "Let's go →", action: tour.next, classes: 'bs-btn-primary' }
]
})
tour.addStep({
id: 'add-competitor',
attachTo: { element: '[data-tour="nav-competitors"]', on: 'right' },
text: `
<strong>Add your first competitor 🔍</strong><br><br>
Click Competitors to add the first company you want to track.
You'll need their website URL and primary operating market.
`,
buttons: [
{ text: '← Back', action: tour.back, classes: 'bs-btn-ghost' },
{
text: 'Go to Competitors →',
action: () => { router.push('/competitors'); saveTourStep(2); tour.next() },
classes: 'bs-btn-primary'
}
]
})
tour.addStep({
id: 'primary-market',
attachTo: { element: '[data-tour="primary-market-field"]', on: 'bottom' },
text: `
<strong>Set the primary market 🌍</strong><br><br>
This tells Bettersight which country to use when scraping so you see
the right pricing and currency for that market.
`,
buttons: [
{ text: '← Back', action: tour.back, classes: 'bs-btn-ghost' },
{ text: 'Next →', action: () => { saveTourStep(3); tour.next() }, classes: 'bs-btn-primary' }
]
})
tour.addStep({
id: 'run-analysis',
attachTo: { element: '[data-tour="nav-analyse"]', on: 'right' },
text: `
<strong>Run your first analysis </strong><br><br>
Describe the trip you want to research destination, duration, and
travel style. Bettersight finds matching competitor pages
automatically. No URLs needed.
`,
buttons: [
{ text: '← Back', action: tour.back, classes: 'bs-btn-ghost' },
{
text: 'Go to Analyse →',
action: () => { router.push('/analyse'); saveTourStep(4); tour.next() },
classes: 'bs-btn-primary'
}
]
})
tour.addStep({
id: 'push-to-sheet',
attachTo: { element: '[data-tour="push-to-sheet"]', on: 'top' },
text: `
<strong>Push results to your Sheet 📊</strong><br><br>
Once your analysis completes, push results directly into your Google
Sheet with one click. Select which tab to write to.
`,
buttons: [
{ text: '← Back', action: tour.back, classes: 'bs-btn-ghost' },
{
text: "Done — let's go ✓",
action: () => { markTourCompleted(); tour.complete() },
classes: 'bs-btn-primary'
}
]
})
async function saveTourStep(step) {
await authStore.updateOnboarding({ tour_step: step })
}
async function markTourCompleted() {
await authStore.updateOnboarding({ tour_completed: true, tour_step: 5 })
}
async function markTourSkipped() {
await authStore.updateOnboarding({ tour_skipped: true })
}
function shouldShowTour(onboarding) {
return !onboarding.tour_completed && !onboarding.tour_skipped
}
function getResumeStep(onboarding) {
return onboarding.tour_step || 0
}
return { tour, shouldShowTour, getResumeStep }
}

59
frontend/src/main.js Normal file
View File

@ -0,0 +1,59 @@
import './assets/css/main.css'
import './assets/css/tour.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ui from '@nuxt/ui/vue-plugin'
import * as Sentry from '@sentry/vue'
import App from './App.vue'
import router from './router'
import { useAuthStore } from './stores/auth_store'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ui)
// Sentry — production only, per CLAUDE.md §19 (a Phase 7 ship-gate
// item). Guarded on import.meta.env.PROD so local/staging dev work
// never silently depends on VITE_SENTRY_DSN being set.
if (import.meta.env.PROD) {
Sentry.init({
app,
dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.VITE_ENV,
tracesSampleRate: 0.1,
// Never send user emails to Sentry.
beforeSend: (event) => {
if (event.user) delete event.user.email
return event
}
})
}
// Global error handler — session expiry redirects to login, rate
// limits toast, everything else reports to Sentry in prod and toasts
// a generic message. Matches CLAUDE.md's JobProgress/error-handling
// section verbatim.
app.config.errorHandler = (err, instance, info) => {
const authStore = useAuthStore()
if (err?.response?.status === 401) {
authStore.clearSession()
router.push(`/login?reason=session_expired&return=${encodeURIComponent(router.currentRoute.value.fullPath)}`)
return
}
if (err?.response?.status === 429) {
console.error('Too many requests. Please wait a moment.')
return
}
if (import.meta.env.PROD) Sentry.captureException(err)
console.error('Something went wrong. Please try again.', err, info)
}
// Restores the persisted PocketBase session (if any) before the first
// route render, so an already-logged-in PM isn't bounced to /login.
useAuthStore().restoreSession()
app.mount('#app')

View File

@ -0,0 +1,139 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import DashboardLayout from '../components/layout/DashboardLayout.vue'
import SubscriptionCard from '../components/shared/SubscriptionCard.vue'
import SeatManager from '../components/shared/SeatManager.vue'
import { useAuthStore } from '../stores/auth_store'
import * as tenantRepository from '../repositories/tenant_repository'
import * as seatRepository from '../repositories/seat_repository'
import { useOnboardingTour } from '../composables/useOnboardingTour'
const authStore = useAuthStore()
const { tour } = useOnboardingTour()
const loading = ref(true)
const busy = ref(false)
const NEXT_TIER = { analyse: 'discover', discover: 'intelligence' }
const TIER_PITCH = {
discover: {
title: 'Upgrade to Discover',
features: [
'Weekly catalogue auto-discovery',
'Spot new competitor trips automatically',
'Slack, Teams and WhatsApp alerts'
],
price: '$499/month', seats: '3 more seats', trial: '21-day trial'
},
intelligence: {
title: 'Upgrade to Intelligence',
features: [
'Full OSINT signal pipeline (15+ sources)',
'Competitor Trajectory Score',
'Monthly narrative intelligence report'
],
price: '$999/month', seats: '2 more seats', trial: '30-day trial'
}
}
const nextTier = computed(() => NEXT_TIER[authStore.tier])
const upgradePitch = computed(() => nextTier.value ? TIER_PITCH[nextTier.value] : null)
onMounted(async () => {
await authStore.fetchTenant()
loading.value = false
})
async function addSeat(email) {
busy.value = true
try {
await seatRepository.addSeat(authStore.tenantId, email)
await authStore.fetchTenant()
} finally {
busy.value = false
}
}
async function removeSeat(seatId) {
busy.value = true
try {
await seatRepository.removeSeat(seatId)
await authStore.fetchTenant()
} finally {
busy.value = false
}
}
async function manageBilling() {
const url = await tenantRepository.getBillingPortalUrl()
window.location.href = url
}
async function upgrade() {
const checkoutUrl = await tenantRepository.createUpgradeCheckout(authStore.tenantId, nextTier.value)
window.location.href = checkoutUrl
}
async function downloadTemplate() {
const url = await tenantRepository.getTemplateUrl()
await authStore.updateOnboarding({ download_template: true })
window.open(url, '_blank')
}
function replayTour() {
authStore.updateOnboarding({ tour_completed: false, tour_skipped: false, tour_step: 0 })
tour.start()
}
</script>
<template>
<DashboardLayout title="Account" subtitle="Subscription, seats and setup" :show-run-analysis="false">
<div v-if="loading" style="padding:40px;text-align:center;color:var(--t3);">Loading</div>
<div v-else class="cg">
<div class="cg-left">
<SubscriptionCard :tenant="authStore.tenant" @manage-billing="manageBilling" />
<div v-if="upgradePitch" class="card" style="padding:20px;">
<div style="font-size:14px;font-weight:800;margin-bottom:12px;">🚀 {{ upgradePitch.title }}</div>
<ul style="margin:0 0 14px;padding:0;list-style:none;display:flex;flex-direction:column;gap:6px;">
<li v-for="(feature, i) in upgradePitch.features" :key="i" style="font-size:12.5px;color:var(--t2);">
+ {{ feature }}
</li>
</ul>
<div style="font-size:12px;color:var(--t3);margin-bottom:14px;">
{{ upgradePitch.price }} · {{ upgradePitch.seats }} · {{ upgradePitch.trial }}
</div>
<UButton color="primary" block @click="upgrade">Upgrade now </UButton>
</div>
<div class="card" style="padding:20px;">
<div class="ct" style="margin-bottom:10px;">Google Sheet template</div>
<p style="font-size:12.5px;color:var(--t3);margin-bottom:12px;">
Download the Bettersight tab to import into your existing workbook.
</p>
<UButton color="neutral" variant="outline" @click="downloadTemplate">Download template</UButton>
</div>
</div>
<div class="cg-right">
<SeatManager
:seats="authStore.tenant?.seats || []"
:max-seats="authStore.tenant?.max_seats || 0"
@add="addSeat"
@remove="removeSeat"
/>
<div class="card" style="padding:18px 20px;">
<p
v-if="authStore.onboarding.tour_skipped || authStore.onboarding.tour_completed"
style="font-size:12.5px;color:var(--blue-vivid);cursor:pointer;margin:0;"
@click="replayTour"
>
Take the guided tour again
</p>
</div>
</div>
</div>
</DashboardLayout>
</template>

View File

@ -0,0 +1,169 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { formatDistanceToNow } from 'date-fns'
import DashboardLayout from '../components/layout/DashboardLayout.vue'
import StatCard from '../components/shared/StatCard.vue'
import EmptyState from '../components/shared/EmptyState.vue'
import FeedItem from '../components/activity/FeedItem.vue'
import AlertCard from '../components/activity/AlertCard.vue'
import { useAuthStore } from '../stores/auth_store'
import { useNotificationsStore } from '../stores/notifications_store'
import { useOnboardingTour } from '../composables/useOnboardingTour'
import * as alertRepository from '../repositories/alert_repository'
import * as competitorRepository from '../repositories/competitor_repository'
const authStore = useAuthStore()
const notificationsStore = useNotificationsStore()
const { tour, shouldShowTour, getResumeStep } = useOnboardingTour()
const loading = ref(true)
const allAlerts = ref([])
const unreadAlerts = ref([])
const competitorCount = ref(0)
const activeTab = ref('all')
onMounted(async () => {
const [alerts, unread, competitors] = await Promise.all([
alertRepository.listAlerts({ limit: 100 }),
alertRepository.listAlerts({ unreadOnly: true }),
competitorRepository.listCompetitors(authStore.email)
])
allAlerts.value = alerts
unreadAlerts.value = unread
competitorCount.value = competitors.length
loading.value = false
const onboarding = authStore.onboarding
if (shouldShowTour(onboarding)) {
const resumeStep = getResumeStep(onboarding)
tour.start()
if (resumeStep > 0) tour.show(resumeStep)
}
})
const priceChanges = computed(() => allAlerts.value.filter((a) => a.alert_type === 'price_change'))
const priceDrops = computed(() => priceChanges.value.filter((a) => (a.change_percent ?? 0) < 0))
const newProducts = computed(() => allAlerts.value.filter((a) => a.alert_type === 'new_product'))
const filteredFeed = computed(() => {
if (activeTab.value === 'price') return priceChanges.value
if (activeTab.value === 'new') return newProducts.value
return allAlerts.value
})
function feedDotColor(alert) {
if (alert.alert_type === 'price_change') return (alert.change_percent ?? 0) < 0 ? '#F04438' : '#10D98A'
if (alert.alert_type === 'new_product') return '#00D4FF'
if (alert.alert_type === 'product_removed') return '#9BA8C0'
return '#1B8EF8'
}
function feedBadge(alert) {
if (alert.alert_type === 'price_change') {
const dropping = (alert.change_percent ?? 0) < 0
return { text: `${dropping ? '↓' : '↑'} ${alert.change_percent}%`, cls: dropping ? 'bg-red' : 'bg-green' }
}
if (alert.alert_type === 'new_product') return { text: 'NEW', cls: 'bg-cyan' }
if (alert.alert_type === 'product_removed') return { text: 'Removed', cls: 'bg-gray' }
return { text: 'Match', cls: 'bg-blue' }
}
function timeAgo(ts) {
return ts ? formatDistanceToNow(new Date(ts), { addSuffix: true }) : ''
}
async function markRead(alertId) {
await alertRepository.markAlertRead(alertId)
unreadAlerts.value = unreadAlerts.value.filter((a) => a.id !== alertId)
const alert = allAlerts.value.find((a) => a.id === alertId)
if (alert) alert.read = true
notificationsStore.decrementUnread()
}
</script>
<template>
<DashboardLayout title="Market Movement" :subtitle="`${authStore.tenant?.name || ''} · Refreshed today`">
<div v-if="loading" style="padding:40px;text-align:center;color:var(--t3);">Loading</div>
<template v-else>
<div class="stat-row">
<StatCard label="Price Changes" :value="priceChanges.length" accent="blue" value-gradient>
<template #icon>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M2 12.5L6 7.5l3 3L12 6l4 3H2z" /></svg>
</template>
<template #meta><span class="sc-period">this week</span></template>
</StatCard>
<StatCard label="Price Drops" :value="priceDrops.length" accent="red" :value-color="'#F04438'">
<template #icon>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 13L3 6h10L8 13z" /></svg>
</template>
<template #meta><span class="sc-period">this week</span></template>
</StatCard>
<StatCard label="New Products" :value="newProducts.length" accent="green" value-gradient>
<template #icon>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 2l1.6 4.8H15l-4 2.9 1.5 4.6L8 11.5l-4.5 2.8 1.5-4.6-4-2.9h5.4L8 2z" /></svg>
</template>
<template #meta><span class="sc-period">this week</span></template>
</StatCard>
<StatCard label="Competitors" :value="competitorCount" accent="amber" :value-color="'#F79009'">
<template #icon>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 1a3.5 3.5 0 100 7A3.5 3.5 0 008 1zm0 1.5a2 2 0 110 4 2 2 0 010-4zM1 14.5c0-2.2 3.1-3.5 7-3.5s7 1.3 7 3.5H1z" /></svg>
</template>
<template #meta><span class="badge bg-green"> Tracked</span></template>
</StatCard>
</div>
<div class="cg">
<div class="cg-left">
<EmptyState
v-if="!allAlerts.length"
icon="📡"
title="Nothing to report yet"
description="Market movements will appear here after your first overnight scrape (6am daily)."
action-label="Run your first analysis"
action-to="/analyse"
/>
<div v-else class="card">
<div class="ch">
<div><div class="ct">Activity Feed</div><div class="cs">All competitor movements this week</div></div>
</div>
<div class="tab-row">
<div class="tab" :class="{ on: activeTab === 'all' }" @click="activeTab = 'all'">
All <span class="tab-count blue">{{ allAlerts.length }}</span>
</div>
<div class="tab" :class="{ on: activeTab === 'price' }" @click="activeTab = 'price'">
Price changes <span class="tab-count red">{{ priceChanges.length }}</span>
</div>
<div class="tab" :class="{ on: activeTab === 'new' }" @click="activeTab = 'new'">
New products <span class="tab-count cyan">{{ newProducts.length }}</span>
</div>
</div>
<FeedItem
v-for="alert in filteredFeed"
:key="alert.id"
:dot-color="feedDotColor(alert)"
:title="alert.message"
:meta="alert.change_amount != null ? `Change: ${alert.change_amount}` : ''"
:time="timeAgo(alert.created)"
:badge-text="feedBadge(alert).text"
:badge-class="feedBadge(alert).cls"
/>
</div>
</div>
<div class="cg-right">
<div class="card">
<div class="ch">
<div><div class="ct">Alerts</div><div class="cs">Requires attention</div></div>
<span v-if="unreadAlerts.length" class="badge bg-red">{{ unreadAlerts.length }} new</span>
</div>
<div v-if="!unreadAlerts.length" style="padding:20px;color:var(--t3);font-size:12.5px;">
You're all caught up.
</div>
<AlertCard v-for="alert in unreadAlerts" :key="alert.id" :alert="alert" @read="markRead" />
</div>
</div>
</div>
</template>
</DashboardLayout>
</template>

View File

@ -0,0 +1,207 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import DashboardLayout from '../components/layout/DashboardLayout.vue'
import EmptyState from '../components/shared/EmptyState.vue'
import TripIntentForm from '../components/analyse/TripIntentForm.vue'
import UrlConfirmation from '../components/analyse/UrlConfirmation.vue'
import JobProgress from '../components/analyse/JobProgress.vue'
import ResultsTable from '../components/analyse/ResultsTable.vue'
import ConfidenceBar from '../components/analyse/ConfidenceBar.vue'
import PushToSheet from '../components/analyse/PushToSheet.vue'
import RunHistory from '../components/analyse/RunHistory.vue'
import { useAuthStore } from '../stores/auth_store'
import { useAnalysisStore } from '../stores/analysis_store'
import * as competitorRepository from '../repositories/competitor_repository'
import * as analysisRepository from '../repositories/analysis_repository'
const authStore = useAuthStore()
const analysisStore = useAnalysisStore()
const loadingCompetitors = ref(true)
const competitors = ref([])
const step = ref('form') // 'form' | 'confirm' | 'progress' | 'results'
const findingUrls = ref(false)
const confirming = ref(false)
const matches = ref([])
const intent = ref(null)
const displayResults = ref([])
onMounted(async () => {
competitors.value = await competitorRepository.listCompetitors(authStore.email)
loadingCompetitors.value = false
await analysisStore.loadHistory(authStore.email)
analysisStore.checkForPendingJob()
if (analysisStore.activeJobId) step.value = 'progress'
})
watch(() => analysisStore.status, (status) => {
if (status === 'complete' || status === 'failed') {
buildDisplayResults()
step.value = 'results'
}
})
function buildDisplayResults() {
const success = analysisStore.results?.success || []
displayResults.value = success.map((item) => ({
...item,
primary_market: competitors.value.find((c) => c.id === item.competitor_id)?.primary_market
}))
}
async function handleIntentSubmit(formIntent) {
intent.value = formIntent
findingUrls.value = true
try {
matches.value = await analysisRepository.findUrls({
destination: formIntent.destination,
duration: formIntent.duration,
travelStyle: formIntent.travelStyle,
competitorIds: formIntent.competitorIds
})
step.value = 'confirm'
} finally {
findingUrls.value = false
}
}
async function handleConfirm(confirmedCompetitors) {
confirming.value = true
try {
const { job_id } = await analysisRepository.runResearch({
tenant_id: authStore.tenantId,
destination: intent.value.destination,
duration: intent.value.duration,
travel_style: intent.value.travelStyle,
tab_type: intent.value.tabType,
productName: intent.value.productName,
competitors: confirmedCompetitors
})
analysisStore.startPolling(job_id)
step.value = 'progress'
await authStore.updateOnboarding({ run_analysis: true })
} finally {
confirming.value = false
}
}
function backToForm() {
step.value = 'form'
}
function startOver() {
analysisStore.stopPolling()
step.value = 'form'
matches.value = []
}
async function retryCompetitor(item) {
const competitor = competitors.value.find((c) => c.id === item.competitor_id)
if (!competitor) return
const { job_id } = await analysisRepository.runResearch({
tenant_id: authStore.tenantId,
destination: intent.value.destination,
duration: intent.value.duration,
travel_style: intent.value.travelStyle,
tab_type: intent.value.tabType,
productName: intent.value.productName,
competitors: [{ id: competitor.id, name: competitor.name, url: competitor.url }]
})
const retryResult = await pollUntilDone(job_id)
const success = retryResult?.results?.success?.[0]
if (success) {
displayResults.value = displayResults.value.filter((r) => r.competitor_id !== item.competitor_id)
displayResults.value.push({ ...success, primary_market: competitor.primary_market })
}
}
async function pollUntilDone(jobId, attempts = 30) {
for (let i = 0; i < attempts; i++) {
const data = await analysisRepository.getStatus(jobId)
if (data.status === 'complete' || data.status === 'failed') return data
await new Promise((resolve) => setTimeout(resolve, 3000))
}
return null
}
function skipCompetitor(item) {
analysisStore.results.failed = (analysisStore.results.failed || []).filter(
(f) => f.competitor_id !== item.competitor_id
)
}
async function restoreFromHistory(jobId) {
await analysisStore.restoreFromHistory(jobId)
buildDisplayResults()
step.value = 'results'
}
</script>
<template>
<DashboardLayout title="Analyse" subtitle="Run a competitor analysis" :show-run-analysis="false">
<div v-if="loadingCompetitors" style="padding:40px;text-align:center;color:var(--t3);">Loading</div>
<EmptyState
v-else-if="!competitors.length"
icon="🏁"
title="Add competitors first"
description="You need at least one competitor to run an analysis."
action-label="→ Go to Competitors"
action-to="/competitors"
/>
<div v-else class="cg">
<div class="cg-left">
<TripIntentForm
v-if="step === 'form'"
:competitors="competitors"
:submitting="findingUrls"
@submit="handleIntentSubmit"
/>
<UrlConfirmation
v-else-if="step === 'confirm'"
:matches="matches"
:submitting="confirming"
@confirm="handleConfirm"
@back="backToForm"
/>
<JobProgress
v-else-if="step === 'progress'"
:status="analysisStore.status"
:progress="analysisStore.progress"
@retry="retryCompetitor"
/>
<template v-else-if="step === 'results'">
<JobProgress
:status="analysisStore.status"
:progress="analysisStore.progress"
:results="analysisStore.results"
:error="analysisStore.error"
@retry="retryCompetitor"
@skip="skipCompetitor"
/>
<ResultsTable v-if="displayResults.length" :results="displayResults" />
<div v-if="displayResults.length" class="card">
<div class="ch"><div class="ct">Confidence</div></div>
<ConfidenceBar
v-for="item in displayResults"
:key="item.competitor_id"
:name="item.name"
:relevancy="item.result?.relevancy"
/>
</div>
<PushToSheet v-if="analysisStore.activeJobId" :job-id="analysisStore.activeJobId" />
<UButton color="neutral" variant="ghost" @click="startOver"> Run another analysis</UButton>
</template>
</div>
<div class="cg-right">
<RunHistory :history="analysisStore.history" @restore="restoreFromHistory" />
</div>
</div>
</DashboardLayout>
</template>

View File

@ -0,0 +1,65 @@
<script setup>
import { ref, onMounted } from 'vue'
import DashboardLayout from '../components/layout/DashboardLayout.vue'
import EmptyState from '../components/shared/EmptyState.vue'
import BattlecardCard from '../components/shared/BattlecardCard.vue'
import { useAuthStore } from '../stores/auth_store'
import * as analysisRepository from '../repositories/analysis_repository'
const authStore = useAuthStore()
const loading = ref(true)
const battlecards = ref([])
const comparableTrips = ref([])
onMounted(async () => {
const [cards, comparables] = await Promise.all([
analysisRepository.getBattlecards(authStore.email),
analysisRepository.getComparableTrips(authStore.email)
])
battlecards.value = cards
comparableTrips.value = comparables
loading.value = false
})
</script>
<template>
<DashboardLayout title="Battlecards" subtitle="Auto-updated after each scrape" :show-run-analysis="false">
<div v-if="loading" style="padding:40px;text-align:center;color:var(--t3);">Loading</div>
<EmptyState
v-else-if="!battlecards.length"
icon="📋"
title="No battlecards yet"
description="Battlecards are generated automatically after your first analysis run."
action-label="→ Run Analysis"
action-to="/analyse"
/>
<div v-else class="cg">
<div class="cg-left">
<div class="card">
<div class="ch"><div class="ct">Battlecards</div><div class="cs">{{ battlecards.length }} competitors</div></div>
<BattlecardCard v-for="card in battlecards" :key="card.competitor_name" :battlecard="card" />
</div>
</div>
<div class="cg-right">
<div class="card">
<div class="ch"><div class="ct">Comparable trips</div><div class="cs">Semantic matches</div></div>
<div v-if="!comparableTrips.length" style="padding:20px;color:var(--t3);font-size:12.5px;">
No comparable trips matched yet.
</div>
<div v-for="match in comparableTrips" :key="match.id" class="bc">
<div class="bc-row">
<div class="bc-name" style="font-size:12.5px;">{{ match.client_product }}</div>
<span class="badge bg-blue">{{ Math.round((match.similarity_score || 0) * 100) }}% match</span>
</div>
<div class="bc-pos" style="font-size:12px;">
Δ price {{ match.price_difference ?? '—' }} · Δ duration {{ match.duration_difference ?? '—' }} days
</div>
</div>
</div>
</div>
</div>
</DashboardLayout>
</template>

View File

@ -0,0 +1,151 @@
<script setup>
import { ref, onMounted } from 'vue'
import DashboardLayout from '../components/layout/DashboardLayout.vue'
import DotGrid from '../components/DotGrid.vue'
import EmptyState from '../components/shared/EmptyState.vue'
import CompetitorForm from '../components/shared/CompetitorForm.vue'
import CsvUpload from '../components/shared/CsvUpload.vue'
import ImportPreviewModal from '../components/shared/ImportPreviewModal.vue'
import { useAuthStore } from '../stores/auth_store'
import * as competitorRepository from '../repositories/competitor_repository'
const authStore = useAuthStore()
const competitors = ref([])
const loading = ref(true)
const formOpen = ref(false)
const editingCompetitor = ref(null)
const previewOpen = ref(false)
const previewValid = ref([])
const previewInvalid = ref([])
const STATUS_BADGE = {
current: { text: '✓ Current', cls: 'bg-green' },
changed: { text: '⚠ Changed', cls: 'bg-amber' },
stale: { text: '○ Stale', cls: 'bg-gray' }
}
const STATUS_DOT = { current: '#10D98A', changed: '#F79009', stale: '#9BA8C0' }
function countryFlag(code) {
if (!code || code.length !== 2) return ''
return String.fromCodePoint(...[...code.toUpperCase()].map((c) => 0x1F1E6 + c.charCodeAt(0) - 65))
}
async function load() {
loading.value = true
competitors.value = await competitorRepository.listCompetitors(authStore.email)
loading.value = false
}
onMounted(load)
function openAdd() {
editingCompetitor.value = null
formOpen.value = true
}
function openEdit(competitor) {
editingCompetitor.value = competitor
formOpen.value = true
}
async function handleSubmit(data) {
if (editingCompetitor.value) {
await competitorRepository.updateCompetitor(editingCompetitor.value.id, data)
} else {
await competitorRepository.addCompetitor(authStore.tenantId, data)
await authStore.updateOnboarding({ add_competitor: true })
}
await load()
}
async function handleDelete(competitor) {
await competitorRepository.deleteCompetitor(competitor.id)
await load()
}
function downloadTemplate() {
window.open(competitorRepository.downloadTemplateUrl(), '_blank')
}
function handleParsed({ valid, invalid }) {
previewValid.value = valid
previewInvalid.value = invalid
previewOpen.value = true
}
async function confirmImport(rows) {
await competitorRepository.bulkImportCompetitors(authStore.tenantId, rows)
previewOpen.value = false
await load()
}
</script>
<template>
<DashboardLayout title="Competitors" :subtitle="`${competitors.length} tracked`" :show-run-analysis="false">
<div v-if="loading" style="padding:40px;text-align:center;color:var(--t3);">Loading</div>
<EmptyState
v-else-if="!competitors.length"
icon="🔍"
title="Add your first competitor"
description="Add competitor websites to start tracking pricing, new products, and market moves."
action-label="+ Add Competitor"
@action="openAdd"
/>
<div v-else class="card">
<div class="ch">
<div>
<div class="ct">Competitors</div>
<div class="cs">{{ competitors.length }} tracked</div>
</div>
<div style="display:flex;gap:8px;">
<button class="tbtn tbtn-outline" @click="downloadTemplate"> CSV Template</button>
<CsvUpload @parsed="handleParsed" />
<button class="ca" data-tour="add-competitor" @click="openAdd">+ Add competitor</button>
</div>
</div>
<table class="tbl">
<thead>
<tr>
<th style="width:56px;"></th>
<th>Competitor</th>
<th>Market</th>
<th>Status</th>
<th>Δ Price</th>
<th>Scraped</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="competitor in competitors" :key="competitor.id">
<td class="tbl-cell-dots" style="min-height:54px;">
<DotGrid :rows="3" :cols="3" :cell-size="8" :dot-radius="1.6" :color="STATUS_DOT[competitor.status]" />
</td>
<td>
<div class="comp-name">{{ competitor.name }}</div>
<div class="comp-url">{{ competitor.url }}</div>
</td>
<td><span style="margin-right:5px;">{{ countryFlag(competitor.primary_market) }}</span>{{ competitor.primary_market }}</td>
<td><span class="badge" :class="STATUS_BADGE[competitor.status]?.cls">{{ STATUS_BADGE[competitor.status]?.text }}</span></td>
<!-- Δ Price is a placeholder pending a lightweight price-delta
summary field on GET /competitors not fabricating data. -->
<td style="color:var(--t4);"></td>
<td style="color:var(--t3);font-size:12px;">{{ competitor.last_scraped || 'Never' }}</td>
<td style="text-align:right;padding-right:16px;">
<UButton color="neutral" variant="ghost" size="xs" icon="i-lucide-pencil" @click="openEdit(competitor)" />
<UButton color="error" variant="ghost" size="xs" icon="i-lucide-trash-2" @click="handleDelete(competitor)" />
</td>
</tr>
</tbody>
</table>
</div>
<CompetitorForm v-model:open="formOpen" :competitor="editingCompetitor" @submit="handleSubmit" />
<ImportPreviewModal v-model:open="previewOpen" :valid="previewValid" :invalid="previewInvalid" @confirm="confirmImport" />
</DashboardLayout>
</template>

View File

@ -0,0 +1,129 @@
<script setup>
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth_store'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const plan = route.query.plan || 'analyse'
const TRIAL_COPY = {
analyse: '14-day free trial — no credit card required',
discover: '21-day free trial — no credit card required',
intelligence: '30-day free trial — no credit card required'
}
const trialCopy = TRIAL_COPY[plan] || TRIAL_COPY.analyse
const sessionExpired = computed(() => route.query.reason === 'session_expired')
const email = ref('')
const password = ref('')
const formError = ref(null)
const submitting = ref(false)
const showForgotPassword = ref(false)
const resetEmail = ref('')
const resetSent = ref(false)
async function submitLogin() {
formError.value = null
submitting.value = true
try {
await authStore.loginWithPassword(email.value, password.value)
router.push(route.query.return || '/activity')
} catch (err) {
formError.value = authStore.error || 'Invalid email or password'
} finally {
submitting.value = false
}
}
async function loginWithGoogle() {
formError.value = null
try {
await authStore.loginWithGoogle()
router.push(route.query.return || '/activity')
} catch (err) {
formError.value = authStore.error || 'Google sign-in failed'
}
}
async function submitPasswordReset() {
await authStore.requestPasswordReset(resetEmail.value)
resetSent.value = true
}
</script>
<template>
<div style="min-height:100vh;display:flex;align-items:center;justify-content:center;background:var(--page);padding:24px;">
<div class="card" style="width:100%;max-width:380px;padding:32px 28px;">
<div style="text-align:center;margin-bottom:20px;">
<span style="font-size:18px;font-weight:800;color:var(--t1);">Better<em style="color:var(--blue-vivid);font-style:normal;">sight</em></span>
</div>
<UAlert
v-if="sessionExpired"
color="warning"
variant="soft"
title="Your session expired — please log back in."
style="margin-bottom:14px;"
/>
<template v-if="!showForgotPassword">
<div style="text-align:center;font-size:12.5px;color:var(--t3);margin-bottom:20px;">
{{ trialCopy }}
</div>
<UAlert v-if="formError" color="error" variant="soft" :title="formError" style="margin-bottom:14px;" />
<form style="display:flex;flex-direction:column;gap:12px;" @submit.prevent="submitLogin">
<UFormField label="Email">
<UInput v-model="email" type="email" placeholder="pm@yourcompany.com" required class="w-full" />
</UFormField>
<UFormField label="Password">
<UInput v-model="password" type="password" placeholder="••••••••" required class="w-full" />
</UFormField>
<p style="font-size:12px;color:var(--blue-vivid);cursor:pointer;text-align:right;margin:0;" @click="showForgotPassword = true">
Forgot password?
</p>
<UButton type="submit" color="primary" block :loading="submitting">Sign in</UButton>
</form>
<div style="display:flex;align-items:center;gap:10px;margin:18px 0;">
<div style="flex:1;height:1px;background:var(--border-2);" />
<span style="font-size:11px;color:var(--t4);">or</span>
<div style="flex:1;height:1px;background:var(--border-2);" />
</div>
<UButton color="neutral" variant="outline" block icon="i-simple-icons-google" @click="loginWithGoogle">
Continue with Google
</UButton>
<p style="font-size:11px;color:var(--t4);text-align:center;margin-top:20px;">
By continuing, you agree to Bettersight's
<a href="https://bettersight.io/terms" style="color:var(--t3);">Terms</a> and
<a href="https://bettersight.io/privacy" style="color:var(--t3);">Privacy Policy</a>.
</p>
</template>
<template v-else>
<div v-if="!resetSent">
<p style="font-size:12.5px;color:var(--t3);margin-bottom:14px;">
Enter your email and we'll send you a reset link.
</p>
<form style="display:flex;flex-direction:column;gap:12px;" @submit.prevent="submitPasswordReset">
<UFormField label="Email">
<UInput v-model="resetEmail" type="email" placeholder="pm@yourcompany.com" required class="w-full" />
</UFormField>
<UButton type="submit" color="primary" block>Send reset link</UButton>
</form>
</div>
<UAlert v-else color="success" variant="soft" title="Reset link sent — check your inbox." />
<p style="font-size:12px;color:var(--blue-vivid);cursor:pointer;text-align:center;margin-top:16px;" @click="showForgotPassword = false; resetSent = false">
Back to sign in
</p>
</template>
</div>
</div>
</template>

View File

@ -0,0 +1,61 @@
<script setup>
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth_store'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const password = ref('')
const passwordConfirm = ref('')
const error = ref(null)
const submitting = ref(false)
async function submit() {
error.value = null
if (password.value !== passwordConfirm.value) {
error.value = 'Passwords do not match'
return
}
if (password.value.length < 8) {
error.value = 'Password must be at least 8 characters'
return
}
submitting.value = true
try {
await authStore.confirmPasswordReset(route.query.token, password.value, passwordConfirm.value)
router.push('/login?reset=success')
} catch (err) {
error.value = 'This reset link is invalid or has expired — request a new one from the login page.'
} finally {
submitting.value = false
}
}
</script>
<template>
<div style="min-height:100vh;display:flex;align-items:center;justify-content:center;background:var(--page);padding:24px;">
<div class="card" style="width:100%;max-width:380px;padding:32px 28px;">
<div style="text-align:center;margin-bottom:20px;">
<span style="font-size:18px;font-weight:800;color:var(--t1);">Better<em style="color:var(--blue-vivid);font-style:normal;">sight</em></span>
</div>
<p style="font-size:13px;color:var(--t2);text-align:center;margin-bottom:18px;">Choose a new password</p>
<UAlert v-if="error" color="error" variant="soft" :title="error" style="margin-bottom:14px;" />
<template v-if="!route.query.token">
<UAlert color="error" variant="soft" title="No reset token found in this link." />
</template>
<form v-else style="display:flex;flex-direction:column;gap:12px;" @submit.prevent="submit">
<UFormField label="New password">
<UInput v-model="password" type="password" placeholder="••••••••" required class="w-full" />
</UFormField>
<UFormField label="Confirm new password">
<UInput v-model="passwordConfirm" type="password" placeholder="••••••••" required class="w-full" />
</UFormField>
<UButton type="submit" color="primary" block :loading="submitting">Reset password</UButton>
</form>
</div>
</div>
</template>

View File

@ -0,0 +1,11 @@
import apiService from '../services/api_service'
export async function listAlerts({ unreadOnly = false, limit = 50 } = {}) {
const { data } = await apiService.get('/alerts', { params: { unread_only: unreadOnly, limit } })
return data
}
export async function markAlertRead(alertId) {
const { data } = await apiService.patch(`/alerts/${alertId}/read`)
return data
}

View File

@ -0,0 +1,53 @@
import apiService from '../services/api_service'
export async function findUrls({ destination, duration, travelStyle, competitorIds }) {
const { data } = await apiService.post('/research/find-urls', {
destination, duration, travel_style: travelStyle, competitor_ids: competitorIds
})
return data.matches
}
export async function runResearch(payload) {
const { data } = await apiService.post('/research', payload)
return data
}
export async function getStatus(jobId) {
const { data } = await apiService.get(`/research/status/${jobId}`)
return data
}
export async function getHistory(email, limit = 5) {
const { data } = await apiService.get('/research/history', { params: { email, limit } })
return data
}
export async function getHistoryDetail(jobId) {
const { data } = await apiService.get(`/research/history/${jobId}`)
return data
}
export async function previewPrompt(payload) {
const { data } = await apiService.post('/preview-prompt', payload)
return data.prompt
}
export async function pushToSheet(email, jobId, tabName) {
const { data } = await apiService.post('/sheet/push', { email, job_id: jobId, tab_name: tabName })
return data
}
export async function getSheetTabs(email) {
const { data } = await apiService.get('/sheet/tabs', { headers: { 'X-User-Email': email } })
return data
}
export async function getBattlecards(email) {
const { data } = await apiService.post('/battlecards', { email })
return data
}
export async function getComparableTrips(email) {
const { data } = await apiService.post('/comparable-trips', { email })
return data
}

View File

@ -0,0 +1,30 @@
import apiService from '../services/api_service'
export async function listCompetitors(email) {
const { data } = await apiService.get('/competitors', { headers: { 'X-User-Email': email } })
return data
}
export async function addCompetitor(tenantId, competitor) {
const { data } = await apiService.post('/account/competitors', { tenant_id: tenantId, ...competitor })
return data
}
export async function updateCompetitor(competitorId, updates) {
const { data } = await apiService.put(`/account/competitors/${competitorId}`, updates)
return data
}
export async function deleteCompetitor(competitorId) {
const { data } = await apiService.delete(`/account/competitors/${competitorId}`)
return data
}
export async function bulkImportCompetitors(tenantId, rows) {
const { data } = await apiService.post('/account/competitors/bulk', { tenant_id: tenantId, rows })
return data
}
export function downloadTemplateUrl() {
return `${import.meta.env.VITE_API_BASE_URL}/account/competitors/template`
}

View File

@ -0,0 +1,11 @@
import apiService from '../services/api_service'
export async function addSeat(tenantId, email) {
const { data } = await apiService.post('/account/seats', { tenant_id: tenantId, email })
return data
}
export async function removeSeat(seatId) {
const { data } = await apiService.delete(`/account/seats/${seatId}`)
return data
}

View File

@ -0,0 +1,40 @@
import apiService from '../services/api_service'
// All Flask API calls for the tenant/account domain. Returns plain
// data objects only — no component or store references, per CLAUDE.md
// §5b's frontend separation-of-concerns rules.
export async function getMe() {
const { data } = await apiService.get('/account/me')
return data
}
export async function getAccount(tenantId) {
const { data } = await apiService.get(`/account/${tenantId}`)
return data
}
export async function updateOnboarding(updates) {
const { data } = await apiService.patch('/account/onboarding', updates)
return data.onboarding_checklist
}
export async function getTemplateUrl() {
const { data } = await apiService.get('/account/template')
return data.template_url
}
export async function deleteAccount(tenantId) {
const { data } = await apiService.post('/account/delete', { tenant_id: tenantId })
return data
}
export async function getBillingPortalUrl() {
const { data } = await apiService.get('/billing/portal')
return data.url
}
export async function createUpgradeCheckout(tenantId, targetTier) {
const { data } = await apiService.post('/billing/upgrade', { tenant_id: tenantId, target_tier: targetTier })
return data.checkout_url
}

View File

@ -0,0 +1,33 @@
import { createRouter, createWebHistory } from 'vue-router'
import { pb } from '../services/pocketbase'
const routes = [
{ path: '/login', name: 'login', component: () => import('../pages/LoginPage.vue'), meta: { public: true } },
{ path: '/reset-password', name: 'reset-password', component: () => import('../pages/ResetPasswordPage.vue'), meta: { public: true } },
{ path: '/account', name: 'account', component: () => import('../pages/AccountPage.vue') },
{ path: '/competitors', name: 'competitors', component: () => import('../pages/CompetitorsPage.vue') },
{ path: '/analyse', name: 'analyse', component: () => import('../pages/AnalysePage.vue') },
{ path: '/battlecards', name: 'battlecards', component: () => import('../pages/BattlecardsPage.vue') },
{ path: '/activity', name: 'activity', component: () => import('../pages/ActivityPage.vue') },
{ path: '/', redirect: '/activity' },
{ path: '/:pathMatch(.*)*', redirect: '/activity' }
]
const router = createRouter({
history: createWebHistory(),
routes
})
// Every route except /login and /reset-password requires a valid
// PocketBase session — matches the return-to behaviour in CLAUDE.md
// §21 (PM lands back on the exact page they were on before the
// redirect to login).
router.beforeEach((to) => {
if (to.meta.public) return true
if (!pb.authStore.isValid) {
return { path: '/login', query: { return: to.fullPath } }
}
return true
})
export default router

View File

@ -0,0 +1,53 @@
import axios from 'axios'
import { pb } from './pocketbase'
// Single Axios instance — all Flask API calls go through here.
// X-API-Key matches CLAUDE.md §9/§18 ("Every authenticated route
// checks X-API-Key"). Note this key is bundled into the built JS and
// is therefore visible to anyone inspecting network requests — actual
// per-request authorization comes from the PocketBase JWT below, not
// from this header; it exists purely to keep the API from being
// trivially scriptable by an anonymous client with no PocketBase
// session at all.
const apiService = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
headers: {
'X-API-Key': import.meta.env.VITE_API_KEY
}
})
apiService.interceptors.request.use((config) => {
if (pb.authStore.isValid) {
config.headers.Authorization = `Bearer ${pb.authStore.token}`
}
return config
})
// On 401: attempt a PocketBase token refresh and retry the original
// request once. On refresh failure, clear the session and preserve
// the current route so the PM returns to exactly where they were
// after logging back in (CLAUDE.md §21 Session Management).
apiService.interceptors.response.use(
(response) => response,
async (error) => {
const original = error.config
if (error.response?.status === 401 && !original._retried) {
original._retried = true
try {
await pb.collection('users').authRefresh()
original.headers.Authorization = `Bearer ${pb.authStore.token}`
return apiService(original)
} catch {
const { useAuthStore } = await import('../stores/auth_store')
const { default: router } = await import('../router')
const authStore = useAuthStore()
authStore.clearSession()
const returnTo = router.currentRoute.value.fullPath
router.push(`/login?reason=session_expired&return=${encodeURIComponent(returnTo)}`)
}
}
return Promise.reject(error)
}
)
export default apiService

View File

@ -0,0 +1,8 @@
import PocketBase from 'pocketbase'
// Single PocketBase client for the whole app — handles auth
// (email/password + Google OAuth), password reset, and token refresh.
// pb.authStore persists the session to localStorage automatically and
// is reactive via pb.authStore.onChange(), so auth_store.js wraps this
// rather than re-implementing session persistence itself.
export const pb = new PocketBase(import.meta.env.VITE_POCKETBASE_URL)

View File

@ -0,0 +1,97 @@
import { defineStore } from 'pinia'
import * as analysisRepository from '../repositories/analysis_repository'
const PENDING_JOB_KEY = 'pending_job_id'
const POLL_INTERVAL_MS = 3000
const POLL_TIMEOUT_ATTEMPTS = 100 // ~5 minutes at 3s per CLAUDE.md §15 Research.gs timeout precedent
// Active job lifecycle + last-5-runs history. Job progress survives a
// session expiry mid-analysis (CLAUDE.md §21): the job_id is stashed
// in localStorage, and AnalysePage calls resumePolling() on mount to
// pick the same job back up after the PM logs back in.
export const useAnalysisStore = defineStore('analysis', {
state: () => ({
activeJobId: null,
status: null, // 'queued' | 'running' | 'complete' | 'failed'
progress: { total: 0, done: 0 },
results: null,
error: null,
history: [],
_pollTimer: null,
_pollAttempts: 0
}),
actions: {
startPolling(jobId) {
this.activeJobId = jobId
this.status = 'queued'
this.results = null
this.error = null
this._pollAttempts = 0
localStorage.setItem(PENDING_JOB_KEY, jobId)
this._poll()
},
resumePolling(jobId) {
this.startPolling(jobId)
},
async _poll() {
this._clearTimer()
try {
const data = await analysisRepository.getStatus(this.activeJobId)
this.status = data.status
this.progress = data.progress || { total: 0, done: 0 }
this.error = data.error
if (data.status === 'complete' || data.status === 'failed') {
this.results = data.results
localStorage.removeItem(PENDING_JOB_KEY)
return
}
this._pollAttempts += 1
if (this._pollAttempts >= POLL_TIMEOUT_ATTEMPTS) {
this.status = 'failed'
this.error = 'Analysis timed out — please try again.'
localStorage.removeItem(PENDING_JOB_KEY)
return
}
this._pollTimer = setTimeout(() => this._poll(), POLL_INTERVAL_MS)
} catch (err) {
this.status = 'failed'
this.error = err?.response?.data?.message || 'Lost connection while checking analysis status.'
localStorage.removeItem(PENDING_JOB_KEY)
}
},
_clearTimer() {
if (this._pollTimer) {
clearTimeout(this._pollTimer)
this._pollTimer = null
}
},
stopPolling() {
this._clearTimer()
this.activeJobId = null
},
checkForPendingJob() {
const pending = localStorage.getItem(PENDING_JOB_KEY)
if (pending) this.resumePolling(pending)
},
async loadHistory(email) {
this.history = await analysisRepository.getHistory(email, 5)
},
async restoreFromHistory(jobId) {
const run = await analysisRepository.getHistoryDetail(jobId)
this.activeJobId = run.id
this.status = run.status
this.results = run.results
}
}
})

View File

@ -0,0 +1,89 @@
import { defineStore } from 'pinia'
import { pb } from '../services/pocketbase'
import * as tenantRepository from '../repositories/tenant_repository'
// Global auth/tenant state — session persistence itself is handled by
// pb.authStore (localStorage-backed), this store layers Bettersight's
// tenant/tier/onboarding state on top and exposes the actions pages
// call, per CLAUDE.md §5b ("Stores → global state only, calls
// repositories for data, NO API calls directly").
export const useAuthStore = defineStore('auth', {
state: () => ({
email: null,
tenant: null,
loading: false,
error: null
}),
getters: {
isAuthenticated: () => pb.authStore.isValid,
tenantId: (state) => state.tenant?.id ?? null,
tier: (state) => state.tenant?.tier ?? null,
onboarding: (state) => state.tenant?.onboarding_checklist ?? {}
},
actions: {
async loginWithPassword(email, password) {
this.loading = true
this.error = null
try {
await pb.collection('users').authWithPassword(email, password)
await this.fetchTenant()
} catch (err) {
this.error = err?.response?.message || 'Invalid email or password'
throw err
} finally {
this.loading = false
}
},
async loginWithGoogle() {
this.loading = true
this.error = null
try {
await pb.collection('users').authWithOAuth2({ provider: 'google' })
await this.fetchTenant()
} catch (err) {
this.error = err?.message || 'Google sign-in failed'
throw err
} finally {
this.loading = false
}
},
async requestPasswordReset(email) {
await pb.collection('users').requestPasswordReset(email)
},
async confirmPasswordReset(token, password, passwordConfirm) {
await pb.collection('users').confirmPasswordReset(token, password, passwordConfirm)
},
async fetchTenant() {
this.email = pb.authStore.model?.email ?? null
this.tenant = await tenantRepository.getMe()
},
async restoreSession() {
if (!pb.authStore.isValid) return
try {
await pb.collection('users').authRefresh()
await this.fetchTenant()
} catch {
this.clearSession()
}
},
async updateOnboarding(updates) {
const checklist = await tenantRepository.updateOnboarding(updates)
if (this.tenant) this.tenant.onboarding_checklist = checklist
return checklist
},
clearSession() {
pb.authStore.clear()
this.email = null
this.tenant = null
}
}
})

View File

@ -0,0 +1,23 @@
import { defineStore } from 'pinia'
import * as alertRepository from '../repositories/alert_repository'
// Small, dedicated store (not in CLAUDE.md's explicit stores list, but
// the sidebar's unread-alert badge is visible on every page, not just
// ActivityPage, so its count needs a home outside any single page's
// local state.
export const useNotificationsStore = defineStore('notifications', {
state: () => ({
unreadCount: 0
}),
actions: {
async fetchUnreadCount() {
const unread = await alertRepository.listAlerts({ unreadOnly: true })
this.unreadCount = unread.length
},
decrementUnread() {
if (this.unreadCount > 0) this.unreadCount -= 1
}
}
})

28
frontend/vite.config.js Normal file
View File

@ -0,0 +1,28 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import ui from '@nuxt/ui/vite'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
ui({
ui: {
colors: {
// 'blue' is the closest built-in Nuxt UI token to the exact
// brand hex (#1B8EF8) — Nuxt UI components (UButton, UBadge,
// etc.) use this. Custom-built elements (sidebar, cards, dot
// grid) use the exact hex values directly via main.css, per
// CLAUDE.md §25.
primary: 'blue',
neutral: 'slate'
}
}
})
],
resolve: {
alias: {
'@': '/src'
}
}
})