From 1f7737e5cb9cd731968043d0378935ed54ad0781 Mon Sep 17 00:00:00 2001 From: pguerrerox Date: Thu, 7 May 2026 17:40:10 +0000 Subject: [PATCH] feat: add workspace account page and mobile app shell Normalize the UI with shared primitives, add a workspace-backed account surface, and improve authenticated mobile navigation, map behavior, and dashboard browsing. --- CHANGELOG.md | 8 + db/migrations/0002_workspaces.sql | 59 +++++ server/src/account/repository.ts | 198 ++++++++++++++ server/src/app.ts | 2 + server/src/routes/account.ts | 58 +++++ server/src/routes/auth.ts | 27 +- shared/types.ts | 42 +++ src/App.tsx | 96 ++++--- src/components/AccountPage.tsx | 228 +++++++++++++++++ src/components/BasicResearchMap.tsx | 6 +- src/components/BasicResultsView.tsx | 75 +++--- src/components/Dashboard.tsx | 207 ++++++++------- src/components/DeepResearchPreviewMap.tsx | 8 +- src/components/DeepResearchResultsView.tsx | 56 ++-- src/components/DeepResearchView.tsx | 173 +++++++------ src/components/Layout.tsx | 118 ++++++--- src/components/MapView.tsx | 39 ++- src/components/ResearchWorkspace.tsx | 36 +-- src/components/ResultsWorkspace.tsx | 40 +-- src/components/SearchSetup.tsx | 87 +++---- src/components/ui.tsx | 284 +++++++++++++++++++++ src/index.css | 17 ++ src/lib/account.ts | 16 ++ src/lib/cn.ts | 6 + 24 files changed, 1397 insertions(+), 489 deletions(-) create mode 100644 db/migrations/0002_workspaces.sql create mode 100644 server/src/account/repository.ts create mode 100644 server/src/routes/account.ts create mode 100644 src/components/AccountPage.tsx create mode 100644 src/components/ui.tsx create mode 100644 src/lib/account.ts create mode 100644 src/lib/cn.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dd5305..9cf75f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added +- Added an authenticated `Account` page with editable profile settings, workspace details, usage summaries, and placeholders for upcoming billing and team management. +- Added workspace and workspace-membership schema foundations plus new account API endpoints so each user now has a default personal workspace for future company, billing, and team features. + +### Changed +- Normalized the product UI around shared design primitives for buttons, cards, alerts, tabs, and page shells to keep public, auth, research, results, dashboard, map, and account surfaces visually aligned. +- Refined the authenticated app for mobile with a phone-friendly top bar, bottom tab navigation, shorter inline research maps, touch-friendlier map gestures, and a mobile lead-card presentation in the dashboard while preserving desktop layouts. + ## [2026-05-01] ### Added diff --git a/db/migrations/0002_workspaces.sql b/db/migrations/0002_workspaces.sql new file mode 100644 index 0000000..53636c5 --- /dev/null +++ b/db/migrations/0002_workspaces.sql @@ -0,0 +1,59 @@ +create table if not exists public.workspaces ( + id uuid primary key default gen_random_uuid(), + name text not null, + workspace_type text not null check (workspace_type in ('personal', 'company')), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists public.workspace_memberships ( + id uuid primary key default gen_random_uuid(), + workspace_id uuid not null references public.workspaces (id) on delete cascade, + user_id uuid not null references public.users (id) on delete cascade, + role text not null check (role in ('owner', 'admin', 'member')), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint workspace_memberships_workspace_user_key unique (workspace_id, user_id) +); + +create index if not exists workspace_memberships_user_id_idx on public.workspace_memberships (user_id); +create index if not exists workspace_memberships_workspace_id_idx on public.workspace_memberships (workspace_id); + +drop trigger if exists set_workspaces_updated_at on public.workspaces; +create trigger set_workspaces_updated_at +before update on public.workspaces +for each row +execute function public.set_updated_at(); + +drop trigger if exists set_workspace_memberships_updated_at on public.workspace_memberships; +create trigger set_workspace_memberships_updated_at +before update on public.workspace_memberships +for each row +execute function public.set_updated_at(); + +do $$ +declare + workspace_user record; + next_workspace_id uuid; +begin + for workspace_user in + select u.id, u.email, u.display_name + from public.users u + where not exists ( + select 1 + from public.workspace_memberships wm + where wm.user_id = u.id + ) + loop + insert into public.workspaces (name, workspace_type) + values ( + concat(coalesce(nullif(workspace_user.display_name, ''), split_part(workspace_user.email, '@', 1), 'User'), '''s Workspace'), + 'personal' + ) + returning id into next_workspace_id; + + insert into public.workspace_memberships (workspace_id, user_id, role) + values (next_workspace_id, workspace_user.id, 'owner'); + end loop; +end +$$; diff --git a/server/src/account/repository.ts b/server/src/account/repository.ts new file mode 100644 index 0000000..b8ba6cd --- /dev/null +++ b/server/src/account/repository.ts @@ -0,0 +1,198 @@ +import type { Pool, PoolClient } from 'pg'; +import type { AccountPageData, AccountWorkspace, AppUser, WorkspaceType, WorkspaceRole } from '../../../shared/types.js'; + +type DbClient = Pool | PoolClient; + +type WorkspaceRow = { + id: string; + name: string; + workspace_type: WorkspaceType; + role: WorkspaceRole; + member_count: string; +}; + +type SummaryRow = { + total_search_jobs: string; + total_deep_research_batches: string; + total_businesses: string; +}; + +export function buildDefaultWorkspaceName(user: { displayName?: string | null; email: string }) { + const baseName = user.displayName?.trim() || user.email.split('@')[0] || 'User'; + return `${baseName}'s Workspace`; +} + +export async function createDefaultWorkspaceForUser( + db: DbClient, + user: { id: string; email: string; displayName?: string | null }, +) { + const workspaceResult = await db.query<{ id: string }>( + ` + insert into public.workspaces (name, workspace_type) + values ($1, 'personal') + returning id + `, + [buildDefaultWorkspaceName(user)], + ); + + const workspaceId = workspaceResult.rows[0].id; + + await db.query( + ` + insert into public.workspace_memberships (workspace_id, user_id, role) + values ($1, $2, 'owner') + `, + [workspaceId, user.id], + ); + + return workspaceId; +} + +export async function ensureWorkspaceForUser( + db: DbClient, + user: { id: string; email: string; displayName?: string | null }, +) { + const existingWorkspace = await getPrimaryWorkspaceForUser(db, user.id); + if (existingWorkspace) { + return existingWorkspace; + } + + await createDefaultWorkspaceForUser(db, user); + return getPrimaryWorkspaceForUser(db, user.id); +} + +export async function getPrimaryWorkspaceForUser(db: DbClient, userId: string): Promise { + const result = await db.query( + ` + select w.id, w.name, w.workspace_type, wm.role, + ( + select count(*)::text + from public.workspace_memberships member + where member.workspace_id = w.id + ) as member_count + from public.workspace_memberships wm + join public.workspaces w on w.id = wm.workspace_id + where wm.user_id = $1 + order by wm.created_at asc + limit 1 + `, + [userId], + ); + + if (result.rowCount === 0) { + return null; + } + + return mapWorkspaceRow(result.rows[0]); +} + +export async function getAccountSummaryForUser(db: DbClient, userId: string) { + const result = await db.query( + ` + select + (select count(*)::text from public.search_jobs where user_id = $1) as total_search_jobs, + (select count(*)::text from public.deep_research_batches where user_id = $1) as total_deep_research_batches, + (select count(*)::text from public.businesses where user_id = $1) as total_businesses + `, + [userId], + ); + + const row = result.rows[0]; + + return { + totalSearchJobs: Number(row.total_search_jobs), + totalDeepResearchBatches: Number(row.total_deep_research_batches), + totalBusinesses: Number(row.total_businesses), + }; +} + +export async function updateUserProfile( + db: DbClient, + userId: string, + input: { displayName?: string; avatarUrl?: string | null }, +): Promise { + const result = await db.query<{ + id: string; + email: string; + display_name: string | null; + avatar_url: string | null; + created_at: string; + updated_at: string; + }>( + ` + update public.users + set + display_name = coalesce($2, display_name), + avatar_url = case when $3::boolean then nullif($4, '') else avatar_url end + where id = $1 + returning id, email, display_name, avatar_url, created_at, updated_at + `, + [userId, input.displayName?.trim() || null, Object.prototype.hasOwnProperty.call(input, 'avatarUrl'), input.avatarUrl?.trim() || null], + ); + + return mapUserRow(result.rows[0]); +} + +export async function updateWorkspaceName(db: DbClient, workspaceId: string, name: string) { + await db.query( + ` + update public.workspaces + set name = $2 + where id = $1 + `, + [workspaceId, name.trim()], + ); +} + +export async function buildAccountPageData(db: DbClient, user: AppUser): Promise { + const workspace = await ensureWorkspaceForUser(db, user); + + if (!workspace) { + throw new Error('Failed to load workspace.'); + } + + const summary = await getAccountSummaryForUser(db, user.id); + + return { + profile: user, + workspace, + summary, + billing: { + status: 'not_configured', + planName: null, + message: 'Billing is not configured yet. Subscription management will appear here in a future update.', + }, + team: { + canManageMembers: workspace.role === 'owner' || workspace.role === 'admin', + message: 'Workspace member management is coming soon.', + }, + }; +} + +function mapWorkspaceRow(row: WorkspaceRow): AccountWorkspace { + return { + id: row.id, + name: row.name, + workspaceType: row.workspace_type, + role: row.role, + memberCount: Number(row.member_count), + }; +} + +function mapUserRow(row: { + id: string; + email: string; + display_name: string | null; + avatar_url: string | null; + created_at: string; + updated_at: string; +}): AppUser { + return { + id: row.id, + email: row.email, + displayName: row.display_name || row.email.split('@')[0] || 'User', + avatarUrl: row.avatar_url, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} diff --git a/server/src/app.ts b/server/src/app.ts index 58bac6b..e58afb5 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -4,6 +4,7 @@ import cors from '@fastify/cors'; import { getEnv } from './config/env.js'; import { deepResearchRoutes } from './routes/deep-research.js'; import { authRoutes } from './routes/auth.js'; +import { accountRoutes } from './routes/account.js'; import { healthRoutes } from './routes/health.js'; import { searchJobRoutes } from './routes/search-jobs.js'; @@ -48,6 +49,7 @@ export async function buildApp() { await app.register(healthRoutes, { prefix: '/api' }); await app.register(authRoutes, { prefix: '/api' }); + await app.register(accountRoutes, { prefix: '/api' }); await app.register(searchJobRoutes, { prefix: '/api' }); await app.register(deepResearchRoutes, { prefix: '/api' }); diff --git a/server/src/routes/account.ts b/server/src/routes/account.ts new file mode 100644 index 0000000..29126b2 --- /dev/null +++ b/server/src/routes/account.ts @@ -0,0 +1,58 @@ +import type { FastifyPluginAsync } from 'fastify'; +import { ZodError, z } from 'zod'; +import { requireAuth } from '../auth/middleware.js'; +import { getDbPool } from '../db/pool.js'; +import { buildAccountPageData, ensureWorkspaceForUser, updateUserProfile, updateWorkspaceName } from '../account/repository.js'; + +const updateAccountSchema = z.object({ + displayName: z.string().trim().min(1).max(120).optional(), + avatarUrl: z.string().trim().url().nullable().optional().or(z.literal('')), + workspaceName: z.string().trim().min(1).max(160).optional(), +}); + +export const accountRoutes: FastifyPluginAsync = async (app) => { + app.get('/account/me', { preHandler: requireAuth }, async (request, reply) => { + try { + const account = await buildAccountPageData(getDbPool(), request.authUser!); + return { account }; + } catch (error) { + request.log.error(error); + return reply.code(500).send({ error: 'Failed to load account page.' }); + } + }); + + app.patch('/account/me', { preHandler: requireAuth }, async (request, reply) => { + try { + const payload = updateAccountSchema.parse(request.body); + const db = getDbPool(); + const workspace = await ensureWorkspaceForUser(db, request.authUser!); + + if (!workspace) { + return reply.code(500).send({ error: 'Failed to load workspace.' }); + } + + if (payload.workspaceName && workspace.role !== 'owner' && workspace.role !== 'admin') { + return reply.code(403).send({ error: 'You do not have permission to update this workspace.' }); + } + + const profile = await updateUserProfile(db, request.authUser!.id, { + displayName: payload.displayName, + avatarUrl: payload.avatarUrl === '' ? null : payload.avatarUrl, + }); + + if (payload.workspaceName) { + await updateWorkspaceName(db, workspace.id, payload.workspaceName); + } + + const account = await buildAccountPageData(db, profile); + return { account }; + } catch (error) { + if (error instanceof ZodError) { + return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid account payload.' }); + } + + request.log.error(error); + return reply.code(500).send({ error: 'Failed to update account.' }); + } + }); +}; diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 37e7e77..3141df1 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -3,6 +3,7 @@ import { ZodError, z } from 'zod'; import { hashPassword, verifyPassword } from '../auth/passwords.js'; import { clearSessionCookie, createSession, deleteSessionById, deleteSessionByToken, getSessionTokenFromRequest, getSessionUserByToken, setSessionCookie, } from '../auth/sessions.js'; import { createUser, getUserByEmail, toAppUser } from '../auth/users.js'; +import { createDefaultWorkspaceForUser } from '../account/repository.js'; import { getDbPool } from '../db/pool.js'; const signUpSchema = z.object({ @@ -50,13 +51,27 @@ export const authRoutes: FastifyPluginAsync = async (app) => { } const passwordHash = await hashPassword(payload.password); - const user = await createUser(db, { - email: payload.email, - passwordHash, - displayName: payload.displayName, - }); + const client = await db.connect(); + let user; + let session; + + try { + await client.query('begin'); + user = await createUser(client, { + email: payload.email, + passwordHash, + displayName: payload.displayName, + }); + await createDefaultWorkspaceForUser(client, user); + session = await createSession(client, user.id, getRequestMetadata(request)); + await client.query('commit'); + } catch (error) { + await client.query('rollback'); + throw error; + } finally { + client.release(); + } - const session = await createSession(db, user.id, getRequestMetadata(request)); setSessionCookie(reply, session.token, session.expiresAt); return reply.code(201).send({ user: { ...toAppUser(user), sessionId: session.sessionId } }); diff --git a/shared/types.ts b/shared/types.ts index 3b5157e..be084a3 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -13,6 +13,48 @@ export interface SessionUser extends AppUser { sessionId: string; } +export type WorkspaceType = 'personal' | 'company'; +export type WorkspaceRole = 'owner' | 'admin' | 'member'; + +export interface AccountWorkspace { + id: string; + name: string; + workspaceType: WorkspaceType; + role: WorkspaceRole; + memberCount: number; +} + +export interface AccountSummary { + totalSearchJobs: number; + totalDeepResearchBatches: number; + totalBusinesses: number; +} + +export interface AccountBillingPlaceholder { + status: 'not_configured'; + planName: string | null; + message: string; +} + +export interface AccountTeamPlaceholder { + canManageMembers: boolean; + message: string; +} + +export interface AccountPageData { + profile: AppUser; + workspace: AccountWorkspace; + summary: AccountSummary; + billing: AccountBillingPlaceholder; + team: AccountTeamPlaceholder; +} + +export interface UpdateAccountProfileRequest { + displayName?: string; + avatarUrl?: string | null; + workspaceName?: string; +} + export interface GeoJsonGeometry { type: 'Polygon' | 'MultiPolygon'; coordinates: unknown; diff --git a/src/App.tsx b/src/App.tsx index 3361254..82ee46f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,10 +16,12 @@ import { UserPlus, } from 'lucide-react'; import { Layout, type AppTab } from './components/Layout'; +import { AccountPage } from './components/AccountPage'; import { Dashboard } from './components/Dashboard'; import { MapView } from './components/MapView'; import { ResearchWorkspace } from './components/ResearchWorkspace'; import { ResultsWorkspace } from './components/ResultsWorkspace'; +import { Alert, Badge, Button, Card, FieldLabel, Input, Surface } from './components/ui'; import type { SessionUser } from '../shared/types'; import { getLocalSessionUser, signInWithLocalAuth, signOutWithLocalAuth, signUpWithLocalAuth } from './lib/auth'; import { hasApiConfig } from './lib/api'; @@ -163,7 +165,7 @@ export default function App() { if (loading) { return (
-
+
); } @@ -270,6 +272,12 @@ export default function App() { )} {activeTab === 'dashboard' && } {activeTab === 'map' && } + {activeTab === 'account' && ( + setUser((currentUser) => (currentUser ? { ...nextUser, sessionId: currentUser.sessionId } : currentUser))} + /> + )} ); @@ -370,12 +378,12 @@ function LandingPage(props: { ] as const; return ( -
+
-
+
-
+
@@ -401,11 +409,7 @@ function LandingPage(props: { > Sign In - @@ -415,10 +419,10 @@ function LandingPage(props: {
-
+ Built for local lead generation workflows -
+

@@ -430,14 +434,10 @@ function LandingPage(props: {

- + +
-
+
+ -
+
@@ -824,11 +818,11 @@ function ConfigScreen(props: { return (
-
-
+ +
{icon}
-

{title}

+

{title}

{description}

Follow these steps:

@@ -839,7 +833,7 @@ function ConfigScreen(props: {

{footer}

-
+
); } diff --git a/src/components/AccountPage.tsx b/src/components/AccountPage.tsx new file mode 100644 index 0000000..7c50e61 --- /dev/null +++ b/src/components/AccountPage.tsx @@ -0,0 +1,228 @@ +import { Building2, CreditCard, Loader2, Shield, Users } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import type { AccountPageData, AppUser } from '../../shared/types'; +import { getAccountPageData, updateAccountProfile } from '../lib/account'; +import { Alert, Badge, Button, Card, FieldLabel, Input, LoadingState, PageContainer, PageShell, SectionHeader, StatCard } from './ui'; + +interface AccountPageProps { + user: AppUser; + onUserUpdated: (user: AppUser) => void; +} + +export function AccountPage({ user, onUserUpdated }: AccountPageProps) { + const [account, setAccount] = useState(null); + const [displayName, setDisplayName] = useState(user.displayName); + const [avatarUrl, setAvatarUrl] = useState(user.avatarUrl ?? ''); + const [workspaceName, setWorkspaceName] = useState(''); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + + useEffect(() => { + let isMounted = true; + + const loadAccount = async () => { + setLoading(true); + setError(null); + + try { + const nextAccount = await getAccountPageData(); + + if (!isMounted) { + return; + } + + setAccount(nextAccount); + setDisplayName(nextAccount.profile.displayName); + setAvatarUrl(nextAccount.profile.avatarUrl ?? ''); + setWorkspaceName(nextAccount.workspace.name); + } catch (nextError) { + if (!isMounted) { + return; + } + + setError(nextError instanceof Error ? nextError.message : 'Failed to load account page.'); + } finally { + if (isMounted) { + setLoading(false); + } + } + }; + + void loadAccount(); + + return () => { + isMounted = false; + }; + }, []); + + const handleSave = async () => { + setSaving(true); + setError(null); + setNotice(null); + + try { + const nextAccount = await updateAccountProfile({ + displayName, + avatarUrl: avatarUrl.trim() || null, + workspaceName, + }); + + setAccount(nextAccount); + onUserUpdated(nextAccount.profile); + setNotice('Account settings updated.'); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : 'Failed to update account.'); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( + + + + + + ); + } + + if (!account) { + return ( + + + {error || 'Account data is unavailable.'} + + + ); + } + + return ( + + + + + {error ? {error} : null} + {notice ? {notice} : null} + +
+ +
+
+

My Profile

+

Personal details

+

Update the information shown across your workspace.

+
+ {displayName +
+ +
+
+ Display Name + setDisplayName(event.target.value)} placeholder="Your name" /> +
+
+ Email + +
+
+ Avatar URL + setAvatarUrl(event.target.value)} placeholder="https://example.com/avatar.png" /> +
+
+ Joined + +
+
+ +
+

Workspace

+
+
+ Workspace Name + setWorkspaceName(event.target.value)} placeholder="Workspace name" /> +
+
+ Role +
+ {account.workspace.role} +
+
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+
+

Workspace

+

{account.workspace.name}

+
+
+
+ {account.workspace.workspaceType} + {account.workspace.memberCount === 1 ? '1 member' : `${account.workspace.memberCount} members`} +
+

+ This workspace is the foundation for future team access, billing, and shared company management. +

+
+ + +
+
+ +
+
+

Plan & Billing

+

Billing coming soon

+
+
+

{account.billing.message}

+
+ + +
+
+ +
+
+

Team

+

Member management placeholder

+
+
+

{account.team.message}

+
+
+
+ +
+ + + +
+
+
+ ); +} diff --git a/src/components/BasicResearchMap.tsx b/src/components/BasicResearchMap.tsx index 73a0448..6f07f64 100644 --- a/src/components/BasicResearchMap.tsx +++ b/src/components/BasicResearchMap.tsx @@ -12,12 +12,12 @@ export function BasicResearchMap({ pin, radiusKm, onPinChange }: BasicResearchMa const defaultCenter = useMemo(() => pin ?? { lat: 39.5, lng: -98.35 }, [pin]); return ( -
+
{ const latLng = event.detail.latLng; @@ -43,7 +43,7 @@ export function BasicResearchMap({ pin, radiusKm, onPinChange }: BasicResearchMa {!pin && ( -
+
Click anywhere on the map to drop a pin. Use the Area field to adjust the search circle.
)} diff --git a/src/components/BasicResultsView.tsx b/src/components/BasicResultsView.tsx index 268f24f..04059cd 100644 --- a/src/components/BasicResultsView.tsx +++ b/src/components/BasicResultsView.tsx @@ -14,6 +14,7 @@ import { import { listSearchJobs } from '../lib/database'; import type { SearchJob, SearchJobStatus } from '../types'; import type { AppUser } from '../../shared/types'; +import { Alert, Badge, Button, Card, EmptyState, LoadingState, MetricPill, SectionHeader, Select, Input } from './ui'; interface BasicResultsViewProps { user: AppUser; @@ -105,30 +106,29 @@ export function BasicResultsView({ user, selectedJobIds, onToggleJobSelection, o return (
-
-
-

Basic Results

-

Basic research runs

-

Filter the grid to find specific runs, select the ones you want, then send the full selection to the map.

-
-
+ {filteredJobs.length} shown of {jobs.length} -
-
+ + } + /> -
+
@@ -137,32 +137,30 @@ export function BasicResultsView({ user, selectedJobIds, onToggleJobSelection, o Status - +
@@ -173,29 +171,23 @@ export function BasicResultsView({ user, selectedJobIds, onToggleJobSelection, o

Use the selection action to open all selected jobs together on the map.

- - + +
)} -
+ - {error &&
{error}
} + {error && {error}} {isLoadingHistory ? ( -
- - Loading research jobs... -
+ ) : filteredJobs.length === 0 ? ( -
-

No research jobs match the current filters.

-

Try adjusting the search term, status filter, or sort order.

-
+ ) : (
{filteredJobs.map((job) => { @@ -218,11 +210,10 @@ export function BasicResultsView({ user, selectedJobIds, onToggleJobSelection, o

{job.businessType}

- - - {statusMeta.label} - - + + {statusMeta.label} + + {isSelected ? : } {isSelected ? 'Selected' : 'Click to select'} @@ -278,32 +269,32 @@ function getStatusMeta(status: SearchJobStatus) { return { label: 'Completed', icon: CheckCircle2, - badgeClass: 'inline-flex items-center gap-1 rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700', + variant: 'success' as const, }; case 'running': return { label: 'Running', icon: Loader2, - badgeClass: 'inline-flex items-center gap-1 rounded-full border border-sky-200 bg-sky-50 px-3 py-1 text-xs font-semibold text-sky-700', + variant: 'info' as const, }; case 'failed': return { label: 'Failed', icon: AlertCircle, - badgeClass: 'inline-flex items-center gap-1 rounded-full border border-red-200 bg-red-50 px-3 py-1 text-xs font-semibold text-red-700', + variant: 'danger' as const, }; case 'stopped': return { label: 'Stopped', icon: CircleOff, - badgeClass: 'inline-flex items-center gap-1 rounded-full border border-stone-200 bg-stone-100 px-3 py-1 text-xs font-semibold text-stone-700', + variant: 'neutral' as const, }; case 'pending': default: return { label: 'Pending', icon: Clock3, - badgeClass: 'inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700', + variant: 'warning' as const, }; } } diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 8764161..aca082a 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -11,15 +11,11 @@ import { Phone, Star, } from 'lucide-react'; -import { clsx, type ClassValue } from 'clsx'; -import { twMerge } from 'tailwind-merge'; import { listBusinesses, listJobResultLinks, listSearchJobs, type SearchJobResultLink } from '../lib/database'; import type { Business, SearchJob } from '../types'; import type { AppUser } from '../../shared/types'; - -function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} +import { cn } from '../lib/cn'; +import { Alert, Badge, Button, Card, EmptyState, Input, LoadingState, PageContainer, PageShell, SectionHeader, Select, StatCard } from './ui'; interface DashboardProps { user: AppUser; @@ -121,10 +117,10 @@ export function Dashboard({ user }: DashboardProps) { : 0; return [ - { name: 'Total Leads', value: total, icon: Briefcase, color: 'bg-blue-500' }, - { name: 'With Website', value: withWebsite, icon: Globe, color: 'bg-emerald-500' }, - { name: 'With Phone', value: withPhone, icon: Phone, color: 'bg-orange-500' }, - { name: 'Avg Rating', value: avgRating.toFixed(1), icon: Star, color: 'bg-amber-500' }, + { name: 'Total Leads', value: total, icon: Briefcase }, + { name: 'With Website', value: withWebsite, icon: Globe }, + { name: 'With Phone', value: withPhone, icon: Phone }, + { name: 'Avg Rating', value: avgRating.toFixed(1), icon: Star }, ]; }, [businesses]); @@ -172,61 +168,48 @@ export function Dashboard({ user }: DashboardProps) { if (loading) { return ( -
- -
+ + + + + ); } return ( -
-
-
-
-

Lead Dashboard

-

Browse saved search results from your local workspace and export targeted lead lists.

-
- -
+ + + + + Export CSV + + } + /> - {error && ( -
{error}
- )} + {error && {error}}
{kpis.map((kpi) => ( -
-
- -
-
-

{kpi.name}

-

{kpi.value}

-
-
+ ))}
-
-
- +
+ setSearchTerm(e.target.value)} - className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500" /> - + - + - +
-
+ -
-
+ {filteredBusinesses.length === 0 ? ( + + ) : ( + + + +
- + - {paginatedBusinesses.length === 0 ? ( - - - - ) : ( - paginatedBusinesses.map((business) => ( + {paginatedBusinesses.map((business) => ( - )) - )} + ))}
toggleSort('name')}>
Business Name @@ -285,27 +309,20 @@ export function Dashboard({ user }: DashboardProps) {
- No leads found matching your filters. -
-
{business.name}
+
{business.name}
{business.address}
{business.city || 'Unknown'} - + {business.category || 'Uncategorized'} - + -
+
{business.rating || 'N/A'} ({business.reviewCount || 0}) @@ -314,51 +331,53 @@ export function Dashboard({ user }: DashboardProps) {
{business.website && ( - + )} {business.phone && ( - + )}
{filteredBusinesses.length > itemsPerPage && ( -
+

Showing {(currentPage - 1) * itemsPerPage + 1} to{' '} {Math.min(currentPage * itemsPerPage, filteredBusinesses.length)} of{' '} {filteredBusinesses.length} leads

-
- - +
)} -
-
-
+ + )} + + ); } diff --git a/src/components/DeepResearchPreviewMap.tsx b/src/components/DeepResearchPreviewMap.tsx index cc2d486..ee4fbe6 100644 --- a/src/components/DeepResearchPreviewMap.tsx +++ b/src/components/DeepResearchPreviewMap.tsx @@ -26,12 +26,12 @@ export function DeepResearchPreviewMap({ pin, preview, onPinChange }: DeepResear }, [pin, preview]); return ( -
+
{ const latLng = event.detail.latLng; @@ -39,7 +39,7 @@ export function DeepResearchPreviewMap({ pin, preview, onPinChange }: DeepResear onPinChange(latLng); } }} - > + > {pin && ( {!pin && ( -
+
Click anywhere on the map to drop a pin and preview the ZIP/FSA areas included in the deep research run.
)} diff --git a/src/components/DeepResearchResultsView.tsx b/src/components/DeepResearchResultsView.tsx index 5d4e5a9..5da45eb 100644 --- a/src/components/DeepResearchResultsView.tsx +++ b/src/components/DeepResearchResultsView.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { Loader2 } from 'lucide-react'; import type { DeepResearchBatchSummary } from '../../shared/types'; import { getDeepResearchBatch, listDeepResearchBatches } from '../lib/database'; +import { Alert, Badge, Button, EmptyState, LoadingState, MetricPill, SectionHeader, Surface } from './ui'; interface DeepResearchResultsViewProps { onShowBatchOnMap: (jobIds: string[]) => void; @@ -51,39 +51,29 @@ export function DeepResearchResultsView({ onShowBatchOnMap }: DeepResearchResult return (
-
-
-

Deep Research Results

-

Previous deep research batches

-

Review completed or failed batch runs and open their bundled map results.

-
-
- {batches.length} batches -
-
+ {batches.length} batches} + /> - {error &&
{error}
} + {error && {error}} {isLoadingBatches ? ( -
- - Loading deep research batches... -
+ ) : batches.length === 0 ? ( -
-

No deep research batches yet.

-

Preview a pin on the map and run your first deep research batch.

-
+ ) : (
{batches.map((batch) => ( -
+
-

{batch.businessType}

+

{batch.businessType}

{batch.basePostalCode ? `${batch.basePostalCode} · ${batch.countryCode ?? 'N/A'}` : 'Base postal area unavailable'}

- {batch.status} + {batch.status}
@@ -109,16 +99,16 @@ export function DeepResearchResultsView({ onShowBatchOnMap }: DeepResearchResult
Created {new Date(batch.createdAt).toLocaleDateString()} - +
-
+
))}
)} @@ -126,18 +116,18 @@ export function DeepResearchResultsView({ onShowBatchOnMap }: DeepResearchResult ); } -function statusBadgeClass(status: DeepResearchBatchSummary['status']) { +function statusBadgeVariant(status: DeepResearchBatchSummary['status']) { switch (status) { case 'completed': - return 'bg-emerald-100 text-emerald-800'; + return 'success' as const; case 'failed': - return 'bg-red-100 text-red-700'; + return 'danger' as const; case 'running': - return 'bg-sky-100 text-sky-700'; + return 'info' as const; case 'stopped': - return 'bg-stone-200 text-stone-700'; + return 'neutral' as const; case 'pending': default: - return 'bg-amber-100 text-amber-700'; + return 'warning' as const; } } diff --git a/src/components/DeepResearchView.tsx b/src/components/DeepResearchView.tsx index 68f6a06..4654a13 100644 --- a/src/components/DeepResearchView.tsx +++ b/src/components/DeepResearchView.tsx @@ -1,8 +1,9 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { AlertCircle, Crosshair, Loader2, MapPinned, Sparkles } from 'lucide-react'; +import { Crosshair, Loader2, MapPinned, Sparkles } from 'lucide-react'; import type { DeepResearchPreview } from '../../shared/types'; import { createDeepResearchBatch, previewDeepResearch } from '../lib/database'; import { DeepResearchPreviewMap } from './DeepResearchPreviewMap'; +import { Alert, Badge, Button, FieldLabel, Input, MetricPill, PageContainer, PageShell, SectionHeader, Surface } from './ui'; interface DeepResearchViewProps { onShowBatchOnMap: (jobIds: string[]) => void; @@ -88,52 +89,53 @@ export function DeepResearchView({ onShowBatchOnMap, topContent }: DeepResearchV }; return ( -
-
+ + {topContent} -
-

Deep Research

-

- Drop a pin, choose a propagation depth, preview the ZIP or FSA areas that will be covered, and run one bundled research batch across every adjacent postal area. -

-
+ -
-
-
-

Planner

-

Configure the deep research batch

-

- The preview uses local postal boundaries to determine which child research jobs will be created. -

-
- -
-
- - + +
+
+ Business Type + setBusinessType(event.target.value)} placeholder="e.g. dentists, HVAC, bakeries" - className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500" />
-
- - + Keywords + setKeywords(event.target.value)} placeholder="Optional comma-separated keywords" - className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500" />
-
- +
+ Location +
+ Click directly on the map to place the deep research center. The preview uses that active pin to find the base ZIP or FSA and expand outward. +
+
+ + {pin && ( + +

{pin.lat.toFixed(5)}, {pin.lng.toFixed(5)}

+
+ )} + +
+ Propagation
Base postal area only - {propagation} hop{propagation === 1 ? '' : 's'} + {propagation} hop{propagation === 1 ? '' : 's'} Expand outward
@@ -157,82 +159,85 @@ export function DeepResearchView({ onShowBatchOnMap, topContent }: DeepResearchV

Click the map to drop a pin. The preview will find the containing ZIP or FSA and expand to adjacent postal areas based on the propagation depth.

{pin &&

Pin: {pin.lat.toFixed(5)}, {pin.lng.toFixed(5)}

}
-
- {previewError && ( -
- - {previewError} + {previewError && ( + {previewError} + )} + + {previewSummary && ( + +

{previewSummary}

+

+ Base area: {preview?.baseArea.displayName} +

+
+ )} + +
+ +
- )} - - {previewSummary && ( -
-
- - Preview ready -
-

{previewSummary}

-

- Base area: {preview?.baseArea.displayName} -

-
- )} - -
- -
-
+
-
+
+ +
+ + Map Controls +
+

Preview center and coverage

+

+ Drop a pin directly on the map to define the base postal area. Preview colors and rings show how propagation expands the deep research batch into adjacent areas. +

+
+ {preview && ( -
+
-

Preview coverage

+

Preview coverage

These postal areas will become child researches in the batch.

-
+ {preview.totalAreas} areas -
+
{preview.areas.map((area) => ( - {area.displayName} · ring {area.propagationRing} - + ))}
-
+ )}
-
-
+ + ); } diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 4915f84..01215d1 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,15 +1,11 @@ import React from 'react'; -import { Search, LayoutDashboard, Map as MapIcon, LogOut, Briefcase, Files } from 'lucide-react'; -import { clsx, type ClassValue } from 'clsx'; -import { twMerge } from 'tailwind-merge'; +import { Search, LayoutDashboard, Map as MapIcon, LogOut, Briefcase, Files, UserRound } from 'lucide-react'; import type { AppUser } from '../../shared/types'; import { getUserAvatarUrl, getUserDisplayName } from '../lib/auth'; +import { cn } from '../lib/cn'; +import { Button } from './ui'; -export type AppTab = 'setup' | 'results' | 'dashboard' | 'map'; - -function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} +export type AppTab = 'setup' | 'results' | 'dashboard' | 'map' | 'account'; interface LayoutProps { user: AppUser; @@ -28,64 +24,106 @@ export function Layout({ user, activeTab, setActiveTab, onLogout, children }: La { id: 'results', name: 'Results', icon: Files }, { id: 'dashboard', name: 'Dashboard', icon: LayoutDashboard }, { id: 'map', name: 'Map View', icon: MapIcon }, + { id: 'account', name: 'Account', icon: UserRound }, ] as const; + const activeNavigationItem = navigation.find((item) => item.id === activeTab) ?? navigation[0]; + return ( -
- {/* Sidebar */} -