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:
parent
6a259a5012
commit
11610b8b4a
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')]
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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>
|
||||
|
|
@ -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); }
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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`
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue