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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
$$;
|
||||
@@ -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<AccountWorkspace | null> {
|
||||
const result = await db.query<WorkspaceRow>(
|
||||
`
|
||||
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<SummaryRow>(
|
||||
`
|
||||
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<AppUser> {
|
||||
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<AccountPageData> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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' });
|
||||
|
||||
|
||||
@@ -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.' });
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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, {
|
||||
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 } });
|
||||
|
||||
@@ -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;
|
||||
|
||||
+45
-51
@@ -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 (
|
||||
<div className="flex h-screen items-center justify-center bg-stone-50">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-emerald-500 border-t-transparent"></div>
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-stone-900 border-t-transparent"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -270,6 +272,12 @@ export default function App() {
|
||||
)}
|
||||
{activeTab === 'dashboard' && <Dashboard user={user} />}
|
||||
{activeTab === 'map' && <MapView user={user} jobIds={selectedJobIds} />}
|
||||
{activeTab === 'account' && (
|
||||
<AccountPage
|
||||
user={user}
|
||||
onUserUpdated={(nextUser) => setUser((currentUser) => (currentUser ? { ...nextUser, sessionId: currentUser.sessionId } : currentUser))}
|
||||
/>
|
||||
)}
|
||||
</Layout>
|
||||
</APIProvider>
|
||||
);
|
||||
@@ -370,12 +378,12 @@ function LandingPage(props: {
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(16,185,129,0.12),_transparent_28%),linear-gradient(180deg,#fafaf9_0%,#f5f5f4_100%)] text-stone-900">
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(16,185,129,0.08),_transparent_28%),linear-gradient(180deg,#fafaf9_0%,#f5f5f4_100%)] text-stone-900">
|
||||
<div className="mx-auto max-w-7xl px-4 pb-16 pt-4 sm:px-6 lg:px-8 lg:pb-24">
|
||||
<header className="sticky top-4 z-10 rounded-3xl border border-white/70 bg-white/80 px-5 py-4 shadow-sm backdrop-blur md:px-6">
|
||||
<header className="sticky top-4 z-10 rounded-3xl border border-stone-200/80 bg-white/90 px-5 py-4 shadow-sm backdrop-blur md:px-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-2xl bg-emerald-600 p-2.5 text-white shadow-sm">
|
||||
<div className="rounded-2xl bg-stone-900 p-2.5 text-white shadow-sm">
|
||||
<Briefcase className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -401,11 +409,7 @@ function LandingPage(props: {
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onGoToAuth('sign_up')}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-emerald-600 px-4 py-2 text-white shadow-sm transition hover:bg-emerald-700"
|
||||
>
|
||||
<button type="button" onClick={() => onGoToAuth('sign_up')} className="inline-flex items-center gap-2 rounded-full bg-stone-900 px-4 py-2 text-white shadow-sm transition hover:bg-stone-800">
|
||||
Sign Up
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -415,10 +419,10 @@ function LandingPage(props: {
|
||||
|
||||
<section className="py-12 lg:py-16">
|
||||
<div className="space-y-8">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-700">
|
||||
<Badge variant="primary" className="px-4 py-2 text-sm">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Built for local lead generation workflows
|
||||
</div>
|
||||
</Badge>
|
||||
|
||||
<div className="space-y-5">
|
||||
<h1 className="max-w-4xl text-5xl font-bold tracking-tight text-stone-950 sm:text-6xl">
|
||||
@@ -430,14 +434,10 @@ function LandingPage(props: {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onGoToAuth('sign_up')}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-emerald-600 px-6 py-4 text-sm font-semibold text-white shadow-sm transition hover:bg-emerald-700"
|
||||
>
|
||||
<Button type="button" onClick={() => onGoToAuth('sign_up')} size="lg" className="rounded-2xl bg-stone-900 hover:bg-stone-800">
|
||||
Sign Up
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
</Button>
|
||||
<a
|
||||
href="#pricing"
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-stone-200 bg-white px-6 py-4 text-sm font-semibold text-stone-700 shadow-sm transition hover:border-stone-300 hover:bg-stone-50"
|
||||
@@ -639,12 +639,12 @@ function AuthPage(props: {
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(16,185,129,0.12),_transparent_30%),linear-gradient(180deg,#fafaf9_0%,#f5f5f4_100%)] text-stone-900">
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(16,185,129,0.08),_transparent_30%),linear-gradient(180deg,#fafaf9_0%,#f5f5f4_100%)] text-stone-900">
|
||||
<div className="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
|
||||
<header className="rounded-3xl border border-white/70 bg-white/80 px-5 py-4 shadow-sm backdrop-blur md:px-6">
|
||||
<header className="rounded-3xl border border-stone-200/80 bg-white/90 px-5 py-4 shadow-sm backdrop-blur md:px-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<button type="button" onClick={onGoHome} className="flex items-center gap-3 text-left">
|
||||
<div className="rounded-2xl bg-emerald-600 p-2.5 text-white shadow-sm">
|
||||
<div className="rounded-2xl bg-stone-900 p-2.5 text-white shadow-sm">
|
||||
<Briefcase className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -663,10 +663,10 @@ function AuthPage(props: {
|
||||
|
||||
<section className="grid gap-10 py-12 lg:grid-cols-[minmax(0,1fr)_440px] lg:items-center lg:py-16">
|
||||
<div className="space-y-8">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-700">
|
||||
<Badge variant="primary" className="px-4 py-2 text-sm">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Secure access to your lead workspace
|
||||
</div>
|
||||
</Badge>
|
||||
|
||||
<div className="space-y-5">
|
||||
<h1 className="max-w-3xl text-5xl font-bold tracking-tight text-stone-950 sm:text-6xl">
|
||||
@@ -683,15 +683,15 @@ function AuthPage(props: {
|
||||
['Map review', 'Inspect returned businesses on a cleaner map built for operational use.'],
|
||||
['Persistent history', 'Keep lead runs and saved businesses available whenever you come back.'],
|
||||
].map(([title, description]) => (
|
||||
<div key={title} className="rounded-3xl border border-stone-200 bg-white p-5 shadow-sm">
|
||||
<Surface key={title} className="p-5">
|
||||
<p className="text-base font-bold tracking-tight text-stone-900">{title}</p>
|
||||
<p className="mt-2 text-sm leading-7 text-stone-600">{description}</p>
|
||||
</div>
|
||||
</Surface>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[2rem] border border-stone-200 bg-white p-6 shadow-xl shadow-stone-200/70 sm:p-8">
|
||||
<Card className="rounded-[2rem] p-6 shadow-xl shadow-stone-200/70 sm:p-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-emerald-600">Account Access</p>
|
||||
@@ -702,7 +702,7 @@ function AuthPage(props: {
|
||||
{authMode === 'sign_up' ? 'Set up your account to start using Leads4less.' : 'Use your account to continue where you left off.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-emerald-50 p-3 text-emerald-600">
|
||||
<div className="rounded-2xl bg-stone-100 p-3 text-stone-900">
|
||||
{authMode === 'sign_up' ? <UserPlus className="h-6 w-6" /> : <LogIn className="h-6 w-6" />}
|
||||
</div>
|
||||
</div>
|
||||
@@ -729,16 +729,12 @@ function AuthPage(props: {
|
||||
</div>
|
||||
|
||||
{authError && (
|
||||
<div className="mt-5 flex items-start gap-3 rounded-2xl border border-red-100 bg-red-50 p-4 text-sm text-red-700">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold">Authentication Error</p>
|
||||
<p className="mt-1 opacity-90">{authError}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Alert variant="error" title="Authentication Error" className="mt-5">
|
||||
<p>{authError}</p>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{authNotice && <div className="mt-5 rounded-2xl border border-emerald-100 bg-emerald-50 p-4 text-sm text-emerald-700">{authNotice}</div>}
|
||||
{authNotice && <Alert variant="success" className="mt-5">{authNotice}</Alert>}
|
||||
|
||||
<form
|
||||
className="mt-5 space-y-4"
|
||||
@@ -749,46 +745,44 @@ function AuthPage(props: {
|
||||
>
|
||||
{authMode === 'sign_up' && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-semibold text-stone-700">Name</label>
|
||||
<input
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<Input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(event) => onDisplayNameChange(event.target.value)}
|
||||
placeholder="Your name"
|
||||
className="w-full rounded-2xl 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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-semibold text-stone-700">Email</label>
|
||||
<input
|
||||
<FieldLabel>Email</FieldLabel>
|
||||
<Input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(event) => onEmailChange(event.target.value)}
|
||||
placeholder="you@example.com"
|
||||
className="w-full rounded-2xl 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-semibold text-stone-700">Password</label>
|
||||
<input
|
||||
<FieldLabel>Password</FieldLabel>
|
||||
<Input
|
||||
type="password"
|
||||
required
|
||||
minLength={6}
|
||||
value={password}
|
||||
onChange={(event) => onPasswordChange(event.target.value)}
|
||||
placeholder="At least 6 characters"
|
||||
className="w-full rounded-2xl 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isAuthenticating}
|
||||
className="flex w-full items-center justify-center gap-3 rounded-2xl bg-emerald-600 px-4 py-3.5 text-sm font-semibold text-white shadow-sm transition-all hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
size="lg"
|
||||
className="w-full rounded-2xl bg-stone-900 hover:bg-stone-800"
|
||||
>
|
||||
{isAuthenticating ? (
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
|
||||
@@ -804,9 +798,9 @@ function AuthPage(props: {
|
||||
: authMode === 'sign_up'
|
||||
? 'Create Account'
|
||||
: 'Sign In'}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -824,11 +818,11 @@ function ConfigScreen(props: {
|
||||
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-stone-50 p-6 font-sans">
|
||||
<div className="w-full max-w-lg rounded-2xl bg-white p-8 text-center shadow-xl">
|
||||
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-red-100 text-red-600">
|
||||
<Card className="w-full max-w-lg p-8 text-center shadow-xl">
|
||||
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-stone-100 text-stone-900">
|
||||
{icon}
|
||||
</div>
|
||||
<h2 className="mb-4 text-2xl font-bold text-stone-900">{title}</h2>
|
||||
<h2 className="mb-4 text-2xl font-semibold text-stone-950">{title}</h2>
|
||||
<p className="mb-6 text-left text-stone-600">{description}</p>
|
||||
<div className="mb-6 space-y-4 rounded-xl border border-stone-200 bg-stone-50 p-6 text-left">
|
||||
<p className="text-sm font-medium text-stone-900">Follow these steps:</p>
|
||||
@@ -839,7 +833,7 @@ function ConfigScreen(props: {
|
||||
</ol>
|
||||
</div>
|
||||
<p className="text-xs text-stone-400">{footer}</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<AccountPageData | null>(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<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(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 (
|
||||
<PageShell>
|
||||
<PageContainer>
|
||||
<LoadingState message="Loading account settings..." />
|
||||
</PageContainer>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
return (
|
||||
<PageShell>
|
||||
<PageContainer>
|
||||
<Alert variant="error">{error || 'Account data is unavailable.'}</Alert>
|
||||
</PageContainer>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageContainer>
|
||||
<SectionHeader
|
||||
title="Account"
|
||||
description="Manage your profile, workspace, and upcoming billing settings."
|
||||
/>
|
||||
|
||||
{error ? <Alert variant="error">{error}</Alert> : null}
|
||||
{notice ? <Alert variant="success">{notice}</Alert> : null}
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1.25fr)_minmax(320px,0.75fr)]">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">My Profile</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-stone-950">Personal details</h2>
|
||||
<p className="mt-2 text-sm text-stone-600">Update the information shown across your workspace.</p>
|
||||
</div>
|
||||
<img
|
||||
src={avatarUrl.trim() || account.profile.avatarUrl || `https://ui-avatars.com/api/?name=${encodeURIComponent(displayName || account.profile.email)}`}
|
||||
alt={displayName || account.profile.email}
|
||||
className="h-14 w-14 rounded-full border border-stone-200 object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<FieldLabel>Display Name</FieldLabel>
|
||||
<Input value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder="Your name" />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Email</FieldLabel>
|
||||
<Input value={account.profile.email} disabled className="cursor-not-allowed opacity-80" />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<FieldLabel>Avatar URL</FieldLabel>
|
||||
<Input value={avatarUrl} onChange={(event) => setAvatarUrl(event.target.value)} placeholder="https://example.com/avatar.png" />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Joined</FieldLabel>
|
||||
<Input value={new Date(account.profile.createdAt).toLocaleDateString()} disabled className="cursor-not-allowed opacity-80" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t border-stone-100 pt-6">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Workspace</p>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<FieldLabel>Workspace Name</FieldLabel>
|
||||
<Input value={workspaceName} onChange={(event) => setWorkspaceName(event.target.value)} placeholder="Workspace name" />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Role</FieldLabel>
|
||||
<div className="flex h-11 items-center rounded-xl border border-stone-200 bg-stone-50 px-4 text-sm text-stone-700">
|
||||
<Badge variant="neutral">{account.workspace.role}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button onClick={() => void handleSave()} disabled={saving} size="lg" className="w-full sm:w-auto">
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-xl bg-stone-100 p-3 text-stone-900">
|
||||
<Building2 className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Workspace</p>
|
||||
<h3 className="text-lg font-semibold text-stone-950">{account.workspace.name}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Badge variant="primary">{account.workspace.workspaceType}</Badge>
|
||||
<Badge>{account.workspace.memberCount === 1 ? '1 member' : `${account.workspace.memberCount} members`}</Badge>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-stone-600">
|
||||
This workspace is the foundation for future team access, billing, and shared company management.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-xl bg-stone-100 p-3 text-stone-900">
|
||||
<CreditCard className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Plan & Billing</p>
|
||||
<h3 className="text-lg font-semibold text-stone-950">Billing coming soon</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-stone-600">{account.billing.message}</p>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-xl bg-stone-100 p-3 text-stone-900">
|
||||
<Users className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Team</p>
|
||||
<h3 className="text-lg font-semibold text-stone-950">Member management placeholder</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-stone-600">{account.team.message}</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<StatCard title="Research Runs" value={account.summary.totalSearchJobs} icon={Building2} />
|
||||
<StatCard title="Deep Research Batches" value={account.summary.totalDeepResearchBatches} icon={Users} />
|
||||
<StatCard title="Saved Businesses" value={account.summary.totalBusinesses} icon={Shield} />
|
||||
</div>
|
||||
</PageContainer>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -12,12 +12,12 @@ export function BasicResearchMap({ pin, radiusKm, onPinChange }: BasicResearchMa
|
||||
const defaultCenter = useMemo(() => pin ?? { lat: 39.5, lng: -98.35 }, [pin]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full min-h-[540px] overflow-hidden rounded-3xl border border-stone-200 bg-stone-100 shadow-sm">
|
||||
<div className="relative h-[280px] overflow-hidden rounded-3xl border border-stone-200 bg-stone-100 shadow-sm sm:h-[360px] lg:h-full lg:min-h-[540px]">
|
||||
<Map
|
||||
defaultCenter={defaultCenter}
|
||||
defaultZoom={5}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
gestureHandling="greedy"
|
||||
gestureHandling="cooperative"
|
||||
{...cleanMapOptions}
|
||||
onClick={(event) => {
|
||||
const latLng = event.detail.latLng;
|
||||
@@ -43,7 +43,7 @@ export function BasicResearchMap({ pin, radiusKm, onPinChange }: BasicResearchMa
|
||||
</Map>
|
||||
|
||||
{!pin && (
|
||||
<div className="pointer-events-none absolute inset-x-6 top-6 rounded-2xl border border-white/20 bg-white/90 p-4 text-sm text-stone-600 shadow-lg backdrop-blur-sm">
|
||||
<div className="pointer-events-none absolute inset-x-4 top-4 rounded-2xl border border-white/20 bg-white/90 p-3 text-sm text-stone-600 shadow-lg backdrop-blur-sm sm:inset-x-6 sm:top-6 sm:p-4">
|
||||
Click anywhere on the map to drop a pin. Use the Area field to adjust the search circle.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Basic Results</p>
|
||||
<h2 className="mt-2 text-2xl font-bold text-stone-900">Basic research runs</h2>
|
||||
<p className="mt-2 text-sm text-stone-600">Filter the grid to find specific runs, select the ones you want, then send the full selection to the map.</p>
|
||||
</div>
|
||||
<div className="rounded-full border border-stone-200 bg-white px-4 py-2 text-sm font-medium text-stone-600 shadow-sm">
|
||||
<SectionHeader
|
||||
eyebrow="Basic Results"
|
||||
title="Basic research runs"
|
||||
description="Filter the grid to find specific runs, select the ones you want, then send the full selection to the map."
|
||||
actions={
|
||||
<MetricPill>
|
||||
{filteredJobs.length} shown of {jobs.length}
|
||||
</div>
|
||||
</div>
|
||||
</MetricPill>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-4 rounded-2xl border border-stone-200 bg-white p-4 shadow-sm">
|
||||
<Card className="space-y-4 p-4">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_220px_220px]">
|
||||
<label className="space-y-2">
|
||||
<span className="flex items-center gap-2 text-sm font-semibold text-stone-700">
|
||||
<SearchIcon className="h-4 w-4 text-stone-400" />
|
||||
Search
|
||||
</span>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search by name, type, or location"
|
||||
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"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -137,32 +137,30 @@ export function BasicResultsView({ user, selectedJobIds, onToggleJobSelection, o
|
||||
<SlidersHorizontal className="h-4 w-4 text-stone-400" />
|
||||
Status
|
||||
</span>
|
||||
<select
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as JobStatusFilter)}
|
||||
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"
|
||||
>
|
||||
{statusOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-semibold text-stone-700">Sort</span>
|
||||
<select
|
||||
<Select
|
||||
value={sortOrder}
|
||||
onChange={(e) => setSortOrder(e.target.value as SortOption)}
|
||||
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"
|
||||
>
|
||||
{sortOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -173,29 +171,23 @@ export function BasicResultsView({ user, selectedJobIds, onToggleJobSelection, o
|
||||
<p className="text-sm text-stone-600">Use the selection action to open all selected jobs together on the map.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<button type="button" onClick={onShowSelectedOnMap} className="rounded-xl bg-emerald-600 px-4 py-3 text-sm font-semibold text-white shadow-sm transition-all hover:bg-emerald-700">
|
||||
<Button type="button" onClick={onShowSelectedOnMap}>
|
||||
Show selected on map ({selectedJobCount})
|
||||
</button>
|
||||
<button type="button" onClick={onClearSelection} className="rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm font-semibold text-stone-700 transition-all hover:bg-stone-50">
|
||||
</Button>
|
||||
<Button type="button" onClick={onClearSelection} variant="secondary">
|
||||
Clear selection
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{error && <div className="rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
|
||||
{error && <Alert variant="error">{error}</Alert>}
|
||||
|
||||
{isLoadingHistory ? (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-stone-200 bg-white p-5 text-sm text-stone-500 shadow-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading research jobs...
|
||||
</div>
|
||||
<LoadingState message="Loading research jobs..." />
|
||||
) : filteredJobs.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-stone-300 bg-white p-10 text-center shadow-sm">
|
||||
<p className="text-lg font-semibold text-stone-900">No research jobs match the current filters.</p>
|
||||
<p className="mt-2 text-sm text-stone-500">Try adjusting the search term, status filter, or sort order.</p>
|
||||
</div>
|
||||
<EmptyState title="No research jobs match the current filters." description="Try adjusting the search term, status filter, or sort order." />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredJobs.map((job) => {
|
||||
@@ -218,11 +210,10 @@ export function BasicResultsView({ user, selectedJobIds, onToggleJobSelection, o
|
||||
<p className="mt-1 truncate text-sm text-stone-500">{job.businessType}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span className={statusMeta.badgeClass}>
|
||||
<statusMeta.icon className={statusMeta.icon === Loader2 ? 'h-3.5 w-3.5 animate-spin' : 'h-3.5 w-3.5'} />
|
||||
{statusMeta.label}
|
||||
</span>
|
||||
<span className={`inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold ${isSelected ? 'bg-emerald-600 text-white' : 'bg-stone-100 text-stone-500'}`}>
|
||||
<Badge variant={statusMeta.variant} icon={statusMeta.icon}>
|
||||
<span className={statusMeta.icon === Loader2 ? 'inline-flex animate-pulse' : ''}>{statusMeta.label}</span>
|
||||
</Badge>
|
||||
<span className={`inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold ${isSelected ? 'bg-stone-900 text-white' : 'bg-stone-100 text-stone-500'}`}>
|
||||
{isSelected ? <Check className="h-3.5 w-3.5" /> : <span className="h-2 w-2 rounded-full bg-current opacity-60"></span>}
|
||||
{isSelected ? 'Selected' : 'Click to select'}
|
||||
</span>
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+110
-91
@@ -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 (
|
||||
<div className="flex flex-1 items-center justify-center bg-stone-50">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-emerald-500" />
|
||||
</div>
|
||||
<PageShell>
|
||||
<PageContainer>
|
||||
<LoadingState message="Loading dashboard data..." />
|
||||
</PageContainer>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-stone-50 p-8">
|
||||
<div className="mx-auto max-w-7xl space-y-8">
|
||||
<header className="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-stone-900">Lead Dashboard</h1>
|
||||
<p className="mt-1 text-stone-600">Browse saved search results from your local workspace and export targeted lead lists.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="flex items-center justify-center gap-2 rounded-xl bg-stone-900 px-6 py-3 font-semibold text-white shadow-sm transition-all hover:bg-stone-800"
|
||||
>
|
||||
<PageShell>
|
||||
<PageContainer>
|
||||
<SectionHeader
|
||||
title="Lead Dashboard"
|
||||
description="Browse saved search results from your local workspace and export targeted lead lists."
|
||||
actions={
|
||||
<Button onClick={handleExport} size="lg" className="w-full sm:w-auto">
|
||||
<Download className="h-5 w-5" />
|
||||
Export CSV
|
||||
</button>
|
||||
</header>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
{error && <Alert variant="error">{error}</Alert>}
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{kpis.map((kpi) => (
|
||||
<div key={kpi.name} className="flex items-center gap-4 rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
|
||||
<div className={cn('rounded-xl p-3 text-white', kpi.color)}>
|
||||
<kpi.icon className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-500">{kpi.name}</p>
|
||||
<p className="text-2xl font-bold text-stone-900">{kpi.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatCard key={kpi.name} title={kpi.name} value={kpi.value} icon={kpi.icon} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_220px_auto_auto]">
|
||||
<input
|
||||
<Card className="space-y-4 p-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-[minmax(0,1fr)_220px_auto_auto]">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search by name, city, or category..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
|
||||
<select
|
||||
<Select
|
||||
value={filterJobId}
|
||||
onChange={(e) => setFilterJobId(e.target.value)}
|
||||
className="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"
|
||||
>
|
||||
<option value="all">All jobs</option>
|
||||
{jobs.map((job) => (
|
||||
@@ -234,39 +217,80 @@ export function Dashboard({ user }: DashboardProps) {
|
||||
{job.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Select>
|
||||
|
||||
<button
|
||||
<Button
|
||||
onClick={() => setFilterHasWebsite(!filterHasWebsite)}
|
||||
className={cn(
|
||||
'rounded-xl border px-4 py-2 text-sm font-medium transition-all',
|
||||
filterHasWebsite
|
||||
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
||||
: 'border-stone-200 bg-white text-stone-600 hover:bg-stone-50',
|
||||
)}
|
||||
variant={filterHasWebsite ? 'primary' : 'secondary'}
|
||||
className={cn('w-full px-4 sm:w-auto')}
|
||||
>
|
||||
Has Website
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<button
|
||||
<Button
|
||||
onClick={() => setFilterHasPhone(!filterHasPhone)}
|
||||
className={cn(
|
||||
'rounded-xl border px-4 py-2 text-sm font-medium transition-all',
|
||||
filterHasPhone
|
||||
? 'border-orange-200 bg-orange-50 text-orange-700'
|
||||
: 'border-stone-200 bg-white text-stone-600 hover:bg-stone-50',
|
||||
)}
|
||||
variant={filterHasPhone ? 'primary' : 'secondary'}
|
||||
className={cn('w-full px-4 sm:w-auto')}
|
||||
>
|
||||
Has Phone
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{filteredBusinesses.length === 0 ? (
|
||||
<EmptyState title="No leads found matching your filters." description="Try adjusting your search term or filters to see more businesses." />
|
||||
) : (
|
||||
<Card className="overflow-hidden p-0">
|
||||
<div className="md:hidden">
|
||||
<div className="divide-y divide-stone-100">
|
||||
{paginatedBusinesses.map((business) => (
|
||||
<article key={business.id} className="space-y-4 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-base font-semibold text-stone-950">{business.name}</p>
|
||||
<p className="mt-1 line-clamp-2 text-sm text-stone-500">{business.address || business.city || 'Location unavailable'}</p>
|
||||
</div>
|
||||
<Badge>{business.category || 'Uncategorized'}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 rounded-2xl bg-stone-50 p-4 text-sm">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-stone-400">Location</p>
|
||||
<p className="mt-1 text-sm font-medium text-stone-700">{business.city || 'Unknown'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-stone-400">Rating</p>
|
||||
<div className="mt-1 flex items-center gap-1 text-sm font-semibold text-amber-600">
|
||||
<Star className="h-4 w-4 fill-amber-500 text-amber-500" />
|
||||
{business.rating || 'N/A'}
|
||||
<span className="text-xs font-normal text-stone-400">({business.reviewCount || 0})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border border-stone-200 bg-white shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="flex items-center gap-4 text-sm text-stone-500">
|
||||
{business.website ? (
|
||||
<a href={business.website} target="_blank" rel="noopener" className="inline-flex items-center gap-2 transition hover:text-emerald-700">
|
||||
<Globe className="h-4 w-4" />
|
||||
Website
|
||||
</a>
|
||||
) : null}
|
||||
{business.phone ? (
|
||||
<a href={`tel:${business.phone}`} className="inline-flex items-center gap-2 transition hover:text-stone-900">
|
||||
<Phone className="h-4 w-4" />
|
||||
Call
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden overflow-x-auto md:block">
|
||||
<table className="w-full border-collapse text-left">
|
||||
<thead>
|
||||
<tr className="border-bottom bg-stone-50 border-stone-200">
|
||||
<tr className="border-b border-stone-200 bg-stone-50">
|
||||
<th className="cursor-pointer px-6 py-4 text-xs font-bold uppercase tracking-wider text-stone-500 hover:text-stone-900" onClick={() => toggleSort('name')}>
|
||||
<div className="flex items-center gap-2">
|
||||
Business Name
|
||||
@@ -285,27 +309,20 @@ export function Dashboard({ user }: DashboardProps) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-100">
|
||||
{paginatedBusinesses.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-12 text-center italic text-stone-500">
|
||||
No leads found matching your filters.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
paginatedBusinesses.map((business) => (
|
||||
{paginatedBusinesses.map((business) => (
|
||||
<tr key={business.id} className="group transition-colors hover:bg-stone-50">
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-bold text-stone-900">{business.name}</div>
|
||||
<div className="font-semibold text-stone-950">{business.name}</div>
|
||||
<div className="mt-0.5 max-w-[260px] truncate text-xs text-stone-400">{business.address}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-stone-600">{business.city || 'Unknown'}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="inline-flex items-center rounded-full bg-stone-100 px-2.5 py-0.5 text-xs font-medium text-stone-800">
|
||||
<Badge>
|
||||
{business.category || 'Uncategorized'}
|
||||
</span>
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-1 text-sm font-bold text-amber-600">
|
||||
<div className="flex items-center gap-1 text-sm font-semibold text-amber-600">
|
||||
<Star className="h-4 w-4 fill-amber-500 text-amber-500" />
|
||||
{business.rating || 'N/A'}
|
||||
<span className="text-xs font-normal text-stone-400">({business.reviewCount || 0})</span>
|
||||
@@ -314,51 +331,53 @@ export function Dashboard({ user }: DashboardProps) {
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{business.website && (
|
||||
<a href={business.website} target="_blank" rel="noopener" className="text-emerald-600 hover:text-emerald-700">
|
||||
<a href={business.website} target="_blank" rel="noopener" className="text-stone-500 transition hover:text-emerald-700">
|
||||
<Globe className="h-5 w-5" />
|
||||
</a>
|
||||
)}
|
||||
{business.phone && (
|
||||
<a href={`tel:${business.phone}`} className="text-orange-600 hover:text-orange-700">
|
||||
<a href={`tel:${business.phone}`} className="text-stone-500 transition hover:text-stone-900">
|
||||
<Phone className="h-5 w-5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredBusinesses.length > itemsPerPage && (
|
||||
<div className="flex items-center justify-between border-t border-stone-200 bg-stone-50 px-6 py-4">
|
||||
<div className="flex flex-col gap-3 border-t border-stone-200 bg-stone-50 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6">
|
||||
<p className="text-sm text-stone-500">
|
||||
Showing <span className="font-medium">{(currentPage - 1) * itemsPerPage + 1}</span> to{' '}
|
||||
<span className="font-medium">{Math.min(currentPage * itemsPerPage, filteredBusinesses.length)}</span> of{' '}
|
||||
<span className="font-medium">{filteredBusinesses.length}</span> leads
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="rounded-lg border border-stone-200 bg-white p-2 text-stone-600 disabled:opacity-50 hover:bg-stone-50"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="rounded-lg border border-stone-200 bg-white p-2 text-stone-600 disabled:opacity-50 hover:bg-stone-50"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</PageContainer>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,12 +26,12 @@ export function DeepResearchPreviewMap({ pin, preview, onPinChange }: DeepResear
|
||||
}, [pin, preview]);
|
||||
|
||||
return (
|
||||
<div className="relative h-[440px] overflow-hidden rounded-3xl border border-stone-200 bg-stone-100 shadow-sm">
|
||||
<div className="relative h-[280px] overflow-hidden rounded-3xl border border-stone-200 bg-stone-100 shadow-sm sm:h-[360px] lg:h-[440px]">
|
||||
<Map
|
||||
defaultCenter={defaultCenter}
|
||||
defaultZoom={5}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
gestureHandling="greedy"
|
||||
gestureHandling="cooperative"
|
||||
{...cleanMapOptions}
|
||||
onClick={(event) => {
|
||||
const latLng = event.detail.latLng;
|
||||
@@ -57,7 +57,7 @@ export function DeepResearchPreviewMap({ pin, preview, onPinChange }: DeepResear
|
||||
</Map>
|
||||
|
||||
{!pin && (
|
||||
<div className="pointer-events-none absolute inset-x-6 top-6 rounded-2xl border border-white/20 bg-white/90 p-4 text-sm text-stone-600 shadow-lg backdrop-blur-sm">
|
||||
<div className="pointer-events-none absolute inset-x-4 top-4 rounded-2xl border border-white/20 bg-white/90 p-3 text-sm text-stone-600 shadow-lg backdrop-blur-sm sm:inset-x-6 sm:top-6 sm:p-4">
|
||||
Click anywhere on the map to drop a pin and preview the ZIP/FSA areas included in the deep research run.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Deep Research Results</p>
|
||||
<h2 className="mt-2 text-2xl font-bold text-stone-900">Previous deep research batches</h2>
|
||||
<p className="mt-2 text-sm text-stone-600">Review completed or failed batch runs and open their bundled map results.</p>
|
||||
</div>
|
||||
<div className="rounded-full border border-stone-200 bg-white px-4 py-2 text-sm font-medium text-stone-600 shadow-sm">
|
||||
{batches.length} batches
|
||||
</div>
|
||||
</div>
|
||||
<SectionHeader
|
||||
eyebrow="Deep Research Results"
|
||||
title="Previous deep research batches"
|
||||
description="Review completed or failed batch runs and open their bundled map results."
|
||||
actions={<MetricPill>{batches.length} batches</MetricPill>}
|
||||
/>
|
||||
|
||||
{error && <div className="rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
|
||||
{error && <Alert variant="error">{error}</Alert>}
|
||||
|
||||
{isLoadingBatches ? (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-stone-200 bg-white p-5 text-sm text-stone-500 shadow-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading deep research batches...
|
||||
</div>
|
||||
<LoadingState message="Loading deep research batches..." />
|
||||
) : batches.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-stone-300 bg-white p-10 text-center shadow-sm">
|
||||
<p className="text-lg font-semibold text-stone-900">No deep research batches yet.</p>
|
||||
<p className="mt-2 text-sm text-stone-500">Preview a pin on the map and run your first deep research batch.</p>
|
||||
</div>
|
||||
<EmptyState title="No deep research batches yet." description="Preview a pin on the map and run your first deep research batch." />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
{batches.map((batch) => (
|
||||
<div key={batch.id} className="rounded-2xl border border-stone-200 bg-white p-5 shadow-sm">
|
||||
<Surface key={batch.id} className="p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-lg font-bold text-stone-900">{batch.businessType}</p>
|
||||
<p className="text-lg font-semibold text-stone-950">{batch.businessType}</p>
|
||||
<p className="mt-1 text-sm text-stone-500">{batch.basePostalCode ? `${batch.basePostalCode} · ${batch.countryCode ?? 'N/A'}` : 'Base postal area unavailable'}</p>
|
||||
</div>
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${statusBadgeClass(batch.status)}`}>{batch.status}</span>
|
||||
<Badge variant={statusBadgeVariant(batch.status)}>{batch.status}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 rounded-2xl bg-stone-50 p-4 text-sm text-stone-600">
|
||||
@@ -109,16 +99,16 @@ export function DeepResearchResultsView({ onShowBatchOnMap }: DeepResearchResult
|
||||
|
||||
<div className="mt-4 flex items-center justify-between border-t border-stone-100 pt-4 text-sm text-stone-500">
|
||||
<span>Created {new Date(batch.createdAt).toLocaleDateString()}</span>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleOpenBatchOnMap(batch.id)}
|
||||
disabled={activeBatchId === batch.id}
|
||||
className="rounded-xl bg-emerald-600 px-4 py-2 font-semibold text-white transition-all hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
size="sm"
|
||||
>
|
||||
{activeBatchId === batch.id ? 'Loading...' : 'Show bundle on map'}
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</Surface>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex-1 overflow-y-auto bg-stone-50 p-6 sm:p-8">
|
||||
<div className="mx-auto max-w-7xl space-y-8">
|
||||
<PageShell>
|
||||
<PageContainer>
|
||||
{topContent}
|
||||
|
||||
<header className="space-y-2">
|
||||
<h1 className="text-3xl font-bold text-stone-900">Deep Research</h1>
|
||||
<p className="max-w-3xl text-stone-600">
|
||||
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.
|
||||
</p>
|
||||
</header>
|
||||
<SectionHeader
|
||||
title="Deep Research"
|
||||
description="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."
|
||||
/>
|
||||
|
||||
<section className="grid grid-cols-1 gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<div className="space-y-6 rounded-3xl border border-stone-200 bg-white p-6 shadow-sm">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-emerald-600">Planner</p>
|
||||
<h2 className="mt-2 text-2xl font-bold text-stone-900">Configure the deep research batch</h2>
|
||||
<p className="mt-2 text-sm text-stone-600">
|
||||
The preview uses local postal boundaries to determine which child research jobs will be created.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-semibold text-stone-700">Business Type</label>
|
||||
<input
|
||||
<section className="grid grid-cols-1 items-stretch gap-6 xl:grid-cols-[400px_minmax(0,1fr)]">
|
||||
<Surface className="p-5 sm:p-7">
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<FieldLabel>Business Type</FieldLabel>
|
||||
<Input
|
||||
type="text"
|
||||
value={businessType}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-semibold text-stone-700">Keywords</label>
|
||||
<input
|
||||
<div className="space-y-2">
|
||||
<FieldLabel>Keywords</FieldLabel>
|
||||
<Input
|
||||
type="text"
|
||||
value={keywords}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-semibold text-stone-700">Propagation</label>
|
||||
<div className="space-y-2.5">
|
||||
<FieldLabel>Location</FieldLabel>
|
||||
<div className="rounded-2xl border border-stone-200 bg-stone-50 p-4 text-sm text-stone-600">
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pin && (
|
||||
<Alert variant="success" title="Active research center">
|
||||
<p>{pin.lat.toFixed(5)}, {pin.lng.toFixed(5)}</p>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<FieldLabel>Propagation</FieldLabel>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
@@ -144,7 +146,7 @@ export function DeepResearchView({ onShowBatchOnMap, topContent }: DeepResearchV
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-stone-500">
|
||||
<span>Base postal area only</span>
|
||||
<span className="rounded-full bg-emerald-50 px-3 py-1 font-semibold text-emerald-700">{propagation} hop{propagation === 1 ? '' : 's'}</span>
|
||||
<Badge variant="primary">{propagation} hop{propagation === 1 ? '' : 's'}</Badge>
|
||||
<span>Expand outward</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,82 +159,85 @@ export function DeepResearchView({ onShowBatchOnMap, topContent }: DeepResearchV
|
||||
<p className="mt-2">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.</p>
|
||||
{pin && <p className="mt-3 font-medium text-stone-800">Pin: {pin.lat.toFixed(5)}, {pin.lng.toFixed(5)}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{previewError && (
|
||||
<div className="flex items-start gap-3 rounded-2xl border border-red-100 bg-red-50 p-4 text-sm text-red-700">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0" />
|
||||
<span>{previewError}</span>
|
||||
</div>
|
||||
<Alert variant="error">{previewError}</Alert>
|
||||
)}
|
||||
|
||||
{previewSummary && (
|
||||
<div className="rounded-2xl border border-emerald-200 bg-emerald-50/70 p-4 text-sm text-emerald-900">
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Preview ready
|
||||
</div>
|
||||
<p className="mt-2">{previewSummary}</p>
|
||||
<Alert variant="success" title="Preview ready">
|
||||
<p>{previewSummary}</p>
|
||||
<p className="mt-2 text-emerald-800">
|
||||
Base area: <span className="font-semibold">{preview?.baseArea.displayName}</span>
|
||||
</p>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handlePreview()}
|
||||
disabled={!canPreview || isPreviewing || isRunning}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm font-semibold text-stone-700 transition-all hover:bg-stone-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
>
|
||||
{isPreviewing ? <Loader2 className="h-4 w-4 animate-spin" /> : <MapPinned className="h-4 w-4" />}
|
||||
Preview areas
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleRunDeepResearch()}
|
||||
disabled={!canPreview || isPreviewing || isRunning}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-emerald-600 px-4 py-3 text-sm font-semibold text-white transition-all hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="flex-1"
|
||||
>
|
||||
{isRunning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
|
||||
Run deep research
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
<DeepResearchPreviewMap pin={pin} preview={preview} onPinChange={setPin} />
|
||||
|
||||
<Surface className="p-5">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">
|
||||
<MapPinned className="h-4 w-4" />
|
||||
Map Controls
|
||||
</div>
|
||||
<h3 className="mt-3 text-lg font-semibold text-stone-950">Preview center and coverage</h3>
|
||||
<p className="mt-2 text-sm text-stone-600">
|
||||
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.
|
||||
</p>
|
||||
</Surface>
|
||||
|
||||
{preview && (
|
||||
<div className="rounded-3xl border border-stone-200 bg-white p-6 shadow-sm">
|
||||
<Surface className="p-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-stone-900">Preview coverage</h3>
|
||||
<h3 className="text-lg font-semibold text-stone-950">Preview coverage</h3>
|
||||
<p className="mt-1 text-sm text-stone-600">These postal areas will become child researches in the batch.</p>
|
||||
</div>
|
||||
<div className="rounded-full border border-stone-200 bg-stone-50 px-4 py-2 text-sm font-medium text-stone-700">
|
||||
<MetricPill className="bg-stone-50 text-stone-700 shadow-none">
|
||||
{preview.totalAreas} areas
|
||||
</div>
|
||||
</MetricPill>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{preview.areas.map((area) => (
|
||||
<span
|
||||
<Badge
|
||||
key={area.id}
|
||||
className={`rounded-full px-3 py-1 text-xs font-semibold ${
|
||||
area.propagationRing === 0 ? 'bg-emerald-100 text-emerald-800' : 'bg-stone-100 text-stone-700'
|
||||
}`}
|
||||
variant={area.propagationRing === 0 ? 'primary' : 'neutral'}
|
||||
>
|
||||
{area.displayName} · ring {area.propagationRing}
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Surface>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
+71
-33
@@ -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 (
|
||||
<div className="flex h-screen bg-stone-50 overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 bg-white border-r border-stone-200 flex flex-col">
|
||||
<div className="p-6 flex items-center gap-3">
|
||||
<div className="bg-emerald-600 p-2 rounded-lg text-white">
|
||||
<div className="flex h-[100dvh] flex-col overflow-hidden bg-stone-100 lg:h-screen lg:flex-row">
|
||||
<header className="flex items-center justify-between border-b border-stone-200 bg-white px-4 py-3 lg:hidden">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="rounded-xl bg-stone-900 p-2 text-white shadow-sm">
|
||||
<Briefcase className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-stone-950">Leads4less</p>
|
||||
<p className="truncate text-xs text-stone-500">{activeNavigationItem.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={onLogout} variant="secondary" size="sm" className="shrink-0 px-3 text-stone-600 hover:text-stone-900">
|
||||
<LogOut className="h-4 w-4" />
|
||||
Sign Out
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<aside className="hidden w-72 flex-col border-r border-stone-200 bg-white lg:flex">
|
||||
<div className="border-b border-stone-100 px-6 py-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-xl bg-stone-900 p-2.5 text-white shadow-sm">
|
||||
<Briefcase className="h-6 w-6" />
|
||||
</div>
|
||||
<span className="font-bold text-xl tracking-tight text-stone-900">Leads4less</span>
|
||||
<div>
|
||||
<p className="text-lg font-semibold tracking-tight text-stone-950">Leads4less</p>
|
||||
<p className="text-sm text-stone-500">Professional lead research workspace</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-4 py-4 space-y-1">
|
||||
<nav className="flex-1 space-y-1 px-4 py-5">
|
||||
{navigation.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActiveTab(item.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all",
|
||||
'flex w-full items-center gap-3 rounded-xl px-4 py-3 text-sm font-semibold transition',
|
||||
activeTab === item.id
|
||||
? "bg-emerald-50 text-emerald-700 shadow-sm"
|
||||
: "text-stone-600 hover:bg-stone-100 hover:text-stone-900"
|
||||
? 'bg-stone-900 text-white shadow-sm'
|
||||
: 'text-stone-600 hover:bg-stone-100 hover:text-stone-900',
|
||||
)}
|
||||
>
|
||||
<item.icon className={cn("h-5 w-5", activeTab === item.id ? "text-emerald-600" : "text-stone-400")} />
|
||||
<item.icon className={cn('h-5 w-5', activeTab === item.id ? 'text-white' : 'text-stone-400')} />
|
||||
{item.name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-stone-100">
|
||||
<div className="flex items-center gap-3 px-4 py-3 mb-2">
|
||||
<div className="border-t border-stone-100 p-4">
|
||||
<div className="mb-3 rounded-2xl border border-stone-200 bg-stone-50 px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={userAvatarUrl || `https://ui-avatars.com/api/?name=${encodeURIComponent(userDisplayName)}`}
|
||||
alt={userDisplayName}
|
||||
className="h-8 w-8 rounded-full border border-stone-200"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-stone-900 truncate">{userDisplayName}</p>
|
||||
<p className="text-xs text-stone-500 truncate">{user.email}</p>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-stone-900">{userDisplayName}</p>
|
||||
<p className="truncate text-xs text-stone-500">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="w-full flex items-center gap-3 px-4 py-2 text-sm font-medium text-stone-600 rounded-xl hover:bg-red-50 hover:text-red-600 transition-all"
|
||||
>
|
||||
</div>
|
||||
<Button onClick={onLogout} variant="secondary" className="w-full justify-start gap-3 text-stone-600 hover:text-stone-900">
|
||||
<LogOut className="h-5 w-5" />
|
||||
Sign Out
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 flex flex-col overflow-hidden relative">
|
||||
{children}
|
||||
</main>
|
||||
<main className="relative flex min-h-0 flex-1 flex-col overflow-hidden pb-16 lg:pb-0">{children}</main>
|
||||
|
||||
<nav className="fixed inset-x-0 bottom-0 z-40 border-t border-stone-200 bg-white/95 px-2 pb-[calc(env(safe-area-inset-bottom,0px)+0.5rem)] pt-2 backdrop-blur lg:hidden">
|
||||
<div className="grid grid-cols-5 gap-1">
|
||||
{navigation.map((item) => {
|
||||
const isActive = activeTab === item.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(item.id)}
|
||||
className={cn(
|
||||
'flex min-w-0 flex-col items-center justify-center gap-1 rounded-xl px-2 py-2 text-[11px] font-semibold transition',
|
||||
isActive ? 'bg-stone-900 text-white shadow-sm' : 'text-stone-500 hover:bg-stone-100 hover:text-stone-900',
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{item.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+18
-21
@@ -5,6 +5,7 @@ import { listBusinesses, listBusinessesForJobs } from '../lib/database';
|
||||
import { cleanMapOptions } from '../lib/map-styles';
|
||||
import type { Business } from '../types';
|
||||
import type { AppUser } from '../../shared/types';
|
||||
import { Alert, Badge, EmptyState } from './ui';
|
||||
|
||||
interface MapViewProps {
|
||||
user: AppUser;
|
||||
@@ -101,17 +102,15 @@ export function MapView({ user, jobIds }: MapViewProps) {
|
||||
if (businesses.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center bg-stone-50 p-8">
|
||||
<div className="w-full max-w-lg rounded-3xl border border-stone-200 bg-white p-8 text-center shadow-sm">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-stone-100 text-stone-500">
|
||||
<MapPin className="h-6 w-6" />
|
||||
</div>
|
||||
<h2 className="mt-4 text-2xl font-bold text-stone-900">No leads to show on the map</h2>
|
||||
<p className="mt-3 text-sm text-stone-600">
|
||||
{selectedJobCount > 0
|
||||
<div className="w-full max-w-lg">
|
||||
<EmptyState
|
||||
icon={MapPin}
|
||||
title="No leads to show on the map"
|
||||
description={selectedJobCount > 0
|
||||
? 'The selected research jobs do not have saved map results yet. Try completed jobs or run the research again.'
|
||||
: 'No saved leads are available yet. Run a research job to populate the map.'}
|
||||
</p>
|
||||
{error && <p className="mt-4 text-sm font-medium text-red-700">{error}</p>}
|
||||
/>
|
||||
{error ? <Alert variant="error" className="mt-4">{error}</Alert> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -120,9 +119,7 @@ export function MapView({ user, jobIds }: MapViewProps) {
|
||||
return (
|
||||
<div className="relative flex-1 bg-stone-100">
|
||||
{error && (
|
||||
<div className="absolute left-8 top-8 z-10 rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700 shadow-lg">
|
||||
{error}
|
||||
</div>
|
||||
<Alert variant="error" className="absolute left-4 right-4 top-4 z-10 shadow-lg lg:left-8 lg:right-auto lg:top-8 lg:max-w-md">{error}</Alert>
|
||||
)}
|
||||
|
||||
<Map
|
||||
@@ -154,7 +151,7 @@ export function MapView({ user, jobIds }: MapViewProps) {
|
||||
<InfoWindow position={{ lat: selected.latitude, lng: selected.longitude }} onCloseClick={() => setSelected(null)}>
|
||||
<div className="max-w-[280px] space-y-3 p-2">
|
||||
<header>
|
||||
<h3 className="text-base font-bold leading-tight text-stone-900">{selected.name}</h3>
|
||||
<h3 className="text-base font-semibold leading-tight text-stone-950">{selected.name}</h3>
|
||||
<div className="mt-1 flex items-center gap-1 text-xs text-stone-500">
|
||||
<MapPin className="h-3 w-3" />
|
||||
<span className="truncate">{selected.address}</span>
|
||||
@@ -162,14 +159,14 @@ export function MapView({ user, jobIds }: MapViewProps) {
|
||||
</header>
|
||||
|
||||
<div className="flex items-center gap-3 border-y border-stone-100 py-2">
|
||||
<div className="flex items-center gap-1 text-sm font-bold text-amber-600">
|
||||
<div className="flex items-center gap-1 text-sm font-semibold text-amber-600">
|
||||
<Star className="h-4 w-4 fill-amber-500 text-amber-500" />
|
||||
{selected.rating || 'N/A'}
|
||||
<span className="text-xs font-normal text-stone-400">({selected.reviewCount || 0})</span>
|
||||
</div>
|
||||
<span className="rounded-full bg-stone-100 px-2 py-0.5 text-xs font-medium text-stone-600">
|
||||
<Badge>
|
||||
{selected.category || 'Uncategorized'}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -178,7 +175,7 @@ export function MapView({ user, jobIds }: MapViewProps) {
|
||||
href={selected.website}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-emerald-600 py-2 text-xs font-semibold text-white transition-all hover:bg-emerald-700"
|
||||
className="inline-flex h-9 flex-1 items-center justify-center gap-2 rounded-xl bg-emerald-600 px-3 text-xs font-semibold text-white transition hover:bg-emerald-700"
|
||||
>
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
Website
|
||||
@@ -187,7 +184,7 @@ export function MapView({ user, jobIds }: MapViewProps) {
|
||||
{selected.phone && (
|
||||
<a
|
||||
href={`tel:${selected.phone}`}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-stone-900 py-2 text-xs font-semibold text-white transition-all hover:bg-stone-800"
|
||||
className="inline-flex h-9 flex-1 items-center justify-center gap-2 rounded-xl border border-stone-200 bg-white px-3 text-xs font-semibold text-stone-700 transition hover:bg-stone-50"
|
||||
>
|
||||
<Phone className="h-3.5 w-3.5" />
|
||||
Call
|
||||
@@ -209,8 +206,8 @@ export function MapView({ user, jobIds }: MapViewProps) {
|
||||
)}
|
||||
</Map>
|
||||
|
||||
<div className="absolute bottom-8 left-8 z-10 max-w-xs rounded-2xl border border-white/20 bg-white/90 p-4 shadow-xl backdrop-blur-sm">
|
||||
<h4 className="mb-2 text-sm font-bold text-stone-900">Map Summary</h4>
|
||||
<div className="absolute inset-x-4 bottom-4 z-10 rounded-2xl border border-white/20 bg-white/90 p-4 shadow-xl backdrop-blur-sm lg:inset-x-auto lg:bottom-8 lg:left-8 lg:max-w-xs">
|
||||
<h4 className="mb-2 text-sm font-semibold text-stone-950">Map Summary</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-stone-500">Total Leads on Map</span>
|
||||
@@ -218,7 +215,7 @@ export function MapView({ user, jobIds }: MapViewProps) {
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-stone-500">Selected Lead</span>
|
||||
<span className="max-w-[120px] truncate font-bold text-stone-900">{selected ? selected.name : 'None'}</span>
|
||||
<span className="max-w-[140px] truncate font-bold text-stone-900 lg:max-w-[120px]">{selected ? selected.name : 'None'}</span>
|
||||
</div>
|
||||
{selectedJobCount > 0 && (
|
||||
<div className="mt-2 border-t border-stone-200 pt-2">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { MapPinned, Search } from 'lucide-react';
|
||||
import type { AppUser } from '../../shared/types';
|
||||
import { DeepResearchView } from './DeepResearchView';
|
||||
import { SearchSetup } from './SearchSetup';
|
||||
import { SegmentedTabs } from './ui';
|
||||
|
||||
type ResearchTab = 'research' | 'deepResearch';
|
||||
|
||||
@@ -27,40 +28,7 @@ export function ResearchWorkspace({
|
||||
}: ResearchWorkspaceProps) {
|
||||
const [activeTab, setActiveTab] = useState<ResearchTab>('research');
|
||||
|
||||
const tabs = (
|
||||
<div className="sticky top-0 z-20 -mx-2 bg-stone-50/95 px-2 pb-2 pt-1 backdrop-blur-sm">
|
||||
<div className="rounded-3xl border border-stone-200 bg-white p-2 shadow-sm">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{[
|
||||
{
|
||||
id: 'research' as const,
|
||||
label: 'Basic',
|
||||
icon: Search,
|
||||
},
|
||||
{
|
||||
id: 'deepResearch' as const,
|
||||
label: 'Deep Research',
|
||||
icon: MapPinned,
|
||||
},
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center justify-center gap-2 rounded-2xl px-4 py-3 text-sm font-semibold transition-all ${
|
||||
activeTab === tab.id
|
||||
? 'bg-emerald-50 text-emerald-700 shadow-sm'
|
||||
: 'text-stone-600 hover:bg-stone-50 hover:text-stone-900'
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="h-4 w-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const tabs = <SegmentedTabs tabs={[{ value: 'research', label: 'Basic', icon: Search }, { value: 'deepResearch', label: 'Deep Research', icon: MapPinned }]} value={activeTab} onChange={(value) => setActiveTab(value)} />;
|
||||
|
||||
return activeTab === 'research' ? (
|
||||
<SearchSetup
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Files, MapPinned } from 'lucide-react';
|
||||
import type { AppUser } from '../../shared/types';
|
||||
import { BasicResultsView } from './BasicResultsView';
|
||||
import { DeepResearchResultsView } from './DeepResearchResultsView';
|
||||
import { PageContainer, PageShell, SectionHeader, SegmentedTabs } from './ui';
|
||||
|
||||
type ResultsTab = 'basic' | 'deepResearch';
|
||||
|
||||
@@ -19,35 +20,14 @@ export function ResultsWorkspace({ user, selectedJobIds, onToggleJobSelection, o
|
||||
const [activeTab, setActiveTab] = useState<ResultsTab>('basic');
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-stone-50 p-6 sm:p-8">
|
||||
<div className="mx-auto max-w-7xl space-y-8">
|
||||
<div className="sticky top-0 z-20 -mx-2 bg-stone-50/95 px-2 pb-2 pt-1 backdrop-blur-sm">
|
||||
<div className="rounded-3xl border border-stone-200 bg-white p-2 shadow-sm">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{[
|
||||
{ id: 'basic' as const, label: 'Basic', icon: Files },
|
||||
{ id: 'deepResearch' as const, label: 'Deep Research', icon: MapPinned },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center justify-center gap-2 rounded-2xl px-4 py-3 text-sm font-semibold transition-all ${
|
||||
activeTab === tab.id ? 'bg-emerald-50 text-emerald-700 shadow-sm' : 'text-stone-600 hover:bg-stone-50 hover:text-stone-900'
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="h-4 w-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PageShell>
|
||||
<PageContainer>
|
||||
<SegmentedTabs tabs={[{ value: 'basic', label: 'Basic', icon: Files }, { value: 'deepResearch', label: 'Deep Research', icon: MapPinned }]} value={activeTab} onChange={(value) => setActiveTab(value)} />
|
||||
|
||||
<header className="space-y-2">
|
||||
<h1 className="text-3xl font-bold text-stone-900">Results</h1>
|
||||
<p className="max-w-3xl text-stone-600">Browse previous Basic and Deep Research runs, select items, and send them to the map when needed.</p>
|
||||
</header>
|
||||
<SectionHeader
|
||||
title="Results"
|
||||
description="Browse previous Basic and Deep Research runs, select items, and send them to the map when needed."
|
||||
/>
|
||||
|
||||
{activeTab === 'basic' ? (
|
||||
<BasicResultsView
|
||||
@@ -60,7 +40,7 @@ export function ResultsWorkspace({ user, selectedJobIds, onToggleJobSelection, o
|
||||
) : (
|
||||
<DeepResearchResultsView onShowBatchOnMap={onShowBatchOnMap} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AlertCircle, LocateFixed, Loader2, MapPin, Play } from 'lucide-react';
|
||||
import { runSearch } from '../lib/database';
|
||||
import type { AppUser } from '../../shared/types';
|
||||
import { BasicResearchMap } from './BasicResearchMap';
|
||||
import { Alert, Button, FieldLabel, Input, PageContainer, PageShell, SectionHeader, Surface } from './ui';
|
||||
|
||||
interface SearchSetupProps {
|
||||
user: AppUser;
|
||||
@@ -89,108 +90,96 @@ export function SearchSetup({
|
||||
const hasLocationPin = Boolean(pin);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto bg-stone-50 p-6 sm:p-8">
|
||||
<div className="mx-auto max-w-7xl space-y-8">
|
||||
<PageShell>
|
||||
<PageContainer>
|
||||
{topContent}
|
||||
|
||||
<header className="space-y-2">
|
||||
<h1 className="text-3xl font-bold text-stone-900">Basic Research</h1>
|
||||
<p className="max-w-3xl text-stone-600">
|
||||
Drop a pin on the map, define the search area, and run standard research before reviewing saved jobs below.
|
||||
</p>
|
||||
</header>
|
||||
<SectionHeader
|
||||
title="Basic Research"
|
||||
description="Drop a pin on the map, define the search area, and run standard research before reviewing saved jobs below."
|
||||
/>
|
||||
|
||||
<section className="grid grid-cols-1 items-stretch gap-6 xl:grid-cols-[400px_minmax(0,1fr)]">
|
||||
<div className="rounded-3xl border border-stone-200 bg-white p-5 shadow-sm sm:p-7">
|
||||
<Surface className="p-5 sm:p-7">
|
||||
<form onSubmit={handleRunSearch} className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-stone-700">Research Name</label>
|
||||
<input
|
||||
<FieldLabel>Research Name</FieldLabel>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Give this research a memorable name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-2.5 outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-stone-700">Business Type</label>
|
||||
<input
|
||||
<FieldLabel>Business Type</FieldLabel>
|
||||
<Input
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g. coffee shop, plumber"
|
||||
value={businessType}
|
||||
onChange={(e) => setBusinessType(e.target.value)}
|
||||
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-2.5 outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-stone-700">Keywords</label>
|
||||
<input
|
||||
<FieldLabel>Keywords</FieldLabel>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="e.g. organic, emergency, family-owned"
|
||||
value={keywords}
|
||||
onChange={(e) => setKeywords(e.target.value)}
|
||||
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-2.5 outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<label className="text-sm font-semibold text-stone-700">Location Source</label>
|
||||
<button
|
||||
<FieldLabel>Location Source</FieldLabel>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleUseMyLocation}
|
||||
disabled={locationAction !== null}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-stone-200 bg-white px-4 py-2.5 text-sm font-semibold text-stone-700 transition hover:bg-stone-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
variant="secondary"
|
||||
>
|
||||
<LocateFixed className="h-4 w-4" />
|
||||
{locationAction === 'geolocate' ? 'Locating...' : 'Use my location'}
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
{locationError && (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-red-100 bg-red-50 p-4 text-sm text-red-700">
|
||||
<AlertCircle className="h-5 w-5 flex-shrink-0" />
|
||||
<span>{locationError}</span>
|
||||
</div>
|
||||
<Alert variant="error">{locationError}</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-stone-700">Area (km radius)</label>
|
||||
<input
|
||||
<FieldLabel>Area (km radius)</FieldLabel>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
value={radius}
|
||||
onChange={(e) => setRadius(Number.parseInt(e.target.value, 10) || 1)}
|
||||
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-2.5 outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-stone-200 bg-stone-50 px-4 py-2.5 text-sm text-stone-600">
|
||||
<div className="rounded-2xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm text-stone-600">
|
||||
Drop a pin directly on the map or use your current location. The map circle always reflects the current area value.
|
||||
</div>
|
||||
|
||||
{hasLocationPin && (
|
||||
<div className="rounded-2xl border border-emerald-200 bg-emerald-50/70 p-3.5 text-sm text-emerald-900">
|
||||
<p className="font-semibold">Active search center</p>
|
||||
<p className="mt-1">{pin!.lat.toFixed(5)}, {pin!.lng.toFixed(5)}</p>
|
||||
</div>
|
||||
<Alert variant="success" title="Active search center">
|
||||
<p>{pin!.lat.toFixed(5)}, {pin!.lng.toFixed(5)}</p>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-red-100 bg-red-50 p-3.5 text-sm text-red-700">
|
||||
<AlertCircle className="h-5 w-5 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
<Alert variant="error">{error}</Alert>
|
||||
)}
|
||||
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSearching}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-xl bg-emerald-600 py-3 font-semibold text-white shadow-sm transition-all hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto sm:px-8"
|
||||
size="lg"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isSearching ? (
|
||||
<>
|
||||
@@ -203,21 +192,25 @@ export function SearchSetup({
|
||||
Run Research
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
<BasicResearchMap pin={pin} radiusKm={radius} onPinChange={(nextPin) => void applyPin(nextPin)} />
|
||||
<div className="rounded-3xl border border-stone-200 bg-white p-5 shadow-sm">
|
||||
<h3 className="text-lg font-bold text-stone-900">Map controls</h3>
|
||||
<Surface className="p-5">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">
|
||||
<MapPin className="h-4 w-4" />
|
||||
Map Controls
|
||||
</div>
|
||||
<h3 className="mt-3 text-lg font-semibold text-stone-950">Search center and radius</h3>
|
||||
<p className="mt-2 text-sm text-stone-600">
|
||||
Drop a pin directly on the map or use your current location. The circle updates from the Area field and the search runs from the active pin.
|
||||
</p>
|
||||
</div>
|
||||
</Surface>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import type { ButtonHTMLAttributes, HTMLAttributes, InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from 'react';
|
||||
import { AlertCircle, CheckCircle2, Info, type LucideIcon } from 'lucide-react';
|
||||
import { cn } from '../lib/cn';
|
||||
|
||||
export function PageShell({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('flex-1 overflow-y-auto bg-stone-50 px-4 py-5 sm:p-6 lg:p-8', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function PageContainer({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('mx-auto max-w-7xl space-y-6 lg:space-y-8', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function SectionHeader({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
className,
|
||||
}: {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between', className)}>
|
||||
<div>
|
||||
{eyebrow ? <p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">{eyebrow}</p> : null}
|
||||
<h1 className={cn(eyebrow ? 'mt-2' : '', 'text-3xl font-semibold tracking-tight text-stone-950')}>{title}</h1>
|
||||
{description ? <p className="mt-2 max-w-3xl text-sm leading-7 text-stone-600">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="w-full sm:w-auto sm:shrink-0">{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('rounded-2xl border border-stone-200 bg-white shadow-sm', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function Surface({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('rounded-3xl border border-stone-200 bg-white shadow-sm', className)} {...props} />;
|
||||
}
|
||||
|
||||
const buttonVariants = {
|
||||
primary: 'bg-emerald-600 text-white shadow-sm hover:bg-emerald-700 focus-visible:ring-emerald-500',
|
||||
secondary: 'border border-stone-200 bg-white text-stone-700 hover:bg-stone-50 focus-visible:ring-emerald-500',
|
||||
subtle: 'bg-stone-100 text-stone-700 hover:bg-stone-200 focus-visible:ring-emerald-500',
|
||||
danger: 'bg-red-600 text-white shadow-sm hover:bg-red-700 focus-visible:ring-red-500',
|
||||
} as const;
|
||||
|
||||
const buttonSizes = {
|
||||
sm: 'h-10 px-4 text-sm',
|
||||
md: 'h-11 px-4 text-sm',
|
||||
lg: 'h-12 px-6 text-sm',
|
||||
icon: 'h-10 w-10',
|
||||
} as const;
|
||||
|
||||
export function Button({
|
||||
className,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: keyof typeof buttonVariants;
|
||||
size?: keyof typeof buttonSizes;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-2 rounded-xl font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
buttonVariants[variant],
|
||||
buttonSizes[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Input({ className, ...props }: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
'w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm text-stone-900 outline-none transition placeholder:text-stone-400 focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Select({ className, ...props }: SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return (
|
||||
<select
|
||||
className={cn(
|
||||
'w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm text-stone-900 outline-none transition focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function FieldLabel({ className, ...props }: HTMLAttributes<HTMLLabelElement>) {
|
||||
return <label className={cn('mb-2 block text-sm font-semibold text-stone-700', className)} {...props} />;
|
||||
}
|
||||
|
||||
const alertVariants = {
|
||||
error: {
|
||||
shell: 'border-red-100 bg-red-50 text-red-700',
|
||||
icon: AlertCircle,
|
||||
title: 'Issue',
|
||||
},
|
||||
success: {
|
||||
shell: 'border-emerald-200 bg-emerald-50/80 text-emerald-900',
|
||||
icon: CheckCircle2,
|
||||
title: 'Success',
|
||||
},
|
||||
info: {
|
||||
shell: 'border-stone-200 bg-stone-50 text-stone-700',
|
||||
icon: Info,
|
||||
title: 'Info',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function Alert({
|
||||
variant = 'info',
|
||||
title,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
variant?: keyof typeof alertVariants;
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const config = alertVariants[variant];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-start gap-3 rounded-2xl border p-4 text-sm', config.shell, className)}>
|
||||
<Icon className="mt-0.5 h-5 w-5 shrink-0" />
|
||||
<div>
|
||||
{title ? <p className="font-semibold">{title}</p> : null}
|
||||
<div className={cn(title ? 'mt-1' : '')}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const badgeVariants = {
|
||||
neutral: 'border-stone-200 bg-stone-100 text-stone-700',
|
||||
primary: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||
success: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||
warning: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||
danger: 'border-red-200 bg-red-50 text-red-700',
|
||||
info: 'border-sky-200 bg-sky-50 text-sky-700',
|
||||
} as const;
|
||||
|
||||
export function Badge({
|
||||
className,
|
||||
variant = 'neutral',
|
||||
icon: Icon,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
variant?: keyof typeof badgeVariants;
|
||||
icon?: LucideIcon;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span className={cn('inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs font-semibold', badgeVariants[variant], className)}>
|
||||
{Icon ? <Icon className="h-3.5 w-3.5" /> : null}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricPill({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('rounded-full border border-stone-200 bg-white px-4 py-2 text-sm font-medium text-stone-600 shadow-sm', className)} {...props} />;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
}: {
|
||||
icon?: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className={cn('border-dashed p-6 text-center sm:p-10', className)}>
|
||||
{Icon ? (
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-stone-100 text-stone-500">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
) : null}
|
||||
<p className={cn('text-lg font-semibold text-stone-900', Icon ? 'mt-4' : '')}>{title}</p>
|
||||
<p className="mt-2 text-sm text-stone-500">{description}</p>
|
||||
{action ? <div className="mt-5">{action}</div> : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingState({ message }: { message: string }) {
|
||||
return (
|
||||
<Card className="flex items-center gap-3 p-5 text-sm text-stone-500">
|
||||
<svg className="h-4 w-4 animate-spin text-emerald-600" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" className="opacity-20" stroke="currentColor" strokeWidth="4" />
|
||||
<path d="M22 12a10 10 0 0 0-10-10" className="opacity-100" stroke="currentColor" strokeWidth="4" />
|
||||
</svg>
|
||||
{message}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function SegmentedTabs<T extends string>({
|
||||
tabs,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
tabs: Array<{ value: T; label: string; icon?: LucideIcon }>;
|
||||
value: T;
|
||||
onChange: (value: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="sticky top-0 z-20 -mx-1 bg-stone-50/95 px-1 pb-2 pt-1 backdrop-blur-sm sm:-mx-2 sm:px-2">
|
||||
<Surface className="p-2">
|
||||
<div className={cn('grid gap-2', tabs.length === 2 ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1 sm:grid-cols-3')}>
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = tab.value === value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
onClick={() => onChange(tab.value)}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 rounded-2xl px-4 py-3 text-sm font-semibold transition',
|
||||
isActive ? 'bg-stone-900 text-white shadow-sm' : 'text-stone-600 hover:bg-stone-50 hover:text-stone-900',
|
||||
)}
|
||||
>
|
||||
{Icon ? <Icon className="h-4 w-4" /> : null}
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Surface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
}: {
|
||||
title: string;
|
||||
value: ReactNode;
|
||||
icon: LucideIcon;
|
||||
}) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="rounded-xl bg-stone-100 p-3 text-emerald-700">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-stone-500">{title}</p>
|
||||
<p className="text-2xl font-semibold tracking-tight text-stone-950">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1 +1,18 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: rgb(250 250 249);
|
||||
color: rgb(28 25 23);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { AccountPageData, UpdateAccountProfileRequest } from '../../shared/types';
|
||||
import { apiRequest } from './api';
|
||||
|
||||
export async function getAccountPageData() {
|
||||
const response = await apiRequest<{ account: AccountPageData }>('/account/me');
|
||||
return response.account;
|
||||
}
|
||||
|
||||
export async function updateAccountProfile(payload: UpdateAccountProfileRequest) {
|
||||
const response = await apiRequest<{ account: AccountPageData }>('/account/me', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
return response.account;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Reference in New Issue
Block a user