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]
|
## [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]
|
## [2026-05-01]
|
||||||
|
|
||||||
### Added
|
### 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 { getEnv } from './config/env.js';
|
||||||
import { deepResearchRoutes } from './routes/deep-research.js';
|
import { deepResearchRoutes } from './routes/deep-research.js';
|
||||||
import { authRoutes } from './routes/auth.js';
|
import { authRoutes } from './routes/auth.js';
|
||||||
|
import { accountRoutes } from './routes/account.js';
|
||||||
import { healthRoutes } from './routes/health.js';
|
import { healthRoutes } from './routes/health.js';
|
||||||
import { searchJobRoutes } from './routes/search-jobs.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(healthRoutes, { prefix: '/api' });
|
||||||
await app.register(authRoutes, { prefix: '/api' });
|
await app.register(authRoutes, { prefix: '/api' });
|
||||||
|
await app.register(accountRoutes, { prefix: '/api' });
|
||||||
await app.register(searchJobRoutes, { prefix: '/api' });
|
await app.register(searchJobRoutes, { prefix: '/api' });
|
||||||
await app.register(deepResearchRoutes, { 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 { hashPassword, verifyPassword } from '../auth/passwords.js';
|
||||||
import { clearSessionCookie, createSession, deleteSessionById, deleteSessionByToken, getSessionTokenFromRequest, getSessionUserByToken, setSessionCookie, } from '../auth/sessions.js';
|
import { clearSessionCookie, createSession, deleteSessionById, deleteSessionByToken, getSessionTokenFromRequest, getSessionUserByToken, setSessionCookie, } from '../auth/sessions.js';
|
||||||
import { createUser, getUserByEmail, toAppUser } from '../auth/users.js';
|
import { createUser, getUserByEmail, toAppUser } from '../auth/users.js';
|
||||||
|
import { createDefaultWorkspaceForUser } from '../account/repository.js';
|
||||||
import { getDbPool } from '../db/pool.js';
|
import { getDbPool } from '../db/pool.js';
|
||||||
|
|
||||||
const signUpSchema = z.object({
|
const signUpSchema = z.object({
|
||||||
@@ -50,13 +51,27 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await hashPassword(payload.password);
|
const passwordHash = await hashPassword(payload.password);
|
||||||
const user = await createUser(db, {
|
const client = await db.connect();
|
||||||
email: payload.email,
|
let user;
|
||||||
passwordHash,
|
let session;
|
||||||
displayName: payload.displayName,
|
|
||||||
});
|
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);
|
setSessionCookie(reply, session.token, session.expiresAt);
|
||||||
|
|
||||||
return reply.code(201).send({ user: { ...toAppUser(user), sessionId: session.sessionId } });
|
return reply.code(201).send({ user: { ...toAppUser(user), sessionId: session.sessionId } });
|
||||||
|
|||||||
@@ -13,6 +13,48 @@ export interface SessionUser extends AppUser {
|
|||||||
sessionId: string;
|
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 {
|
export interface GeoJsonGeometry {
|
||||||
type: 'Polygon' | 'MultiPolygon';
|
type: 'Polygon' | 'MultiPolygon';
|
||||||
coordinates: unknown;
|
coordinates: unknown;
|
||||||
|
|||||||
+45
-51
@@ -16,10 +16,12 @@ import {
|
|||||||
UserPlus,
|
UserPlus,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Layout, type AppTab } from './components/Layout';
|
import { Layout, type AppTab } from './components/Layout';
|
||||||
|
import { AccountPage } from './components/AccountPage';
|
||||||
import { Dashboard } from './components/Dashboard';
|
import { Dashboard } from './components/Dashboard';
|
||||||
import { MapView } from './components/MapView';
|
import { MapView } from './components/MapView';
|
||||||
import { ResearchWorkspace } from './components/ResearchWorkspace';
|
import { ResearchWorkspace } from './components/ResearchWorkspace';
|
||||||
import { ResultsWorkspace } from './components/ResultsWorkspace';
|
import { ResultsWorkspace } from './components/ResultsWorkspace';
|
||||||
|
import { Alert, Badge, Button, Card, FieldLabel, Input, Surface } from './components/ui';
|
||||||
import type { SessionUser } from '../shared/types';
|
import type { SessionUser } from '../shared/types';
|
||||||
import { getLocalSessionUser, signInWithLocalAuth, signOutWithLocalAuth, signUpWithLocalAuth } from './lib/auth';
|
import { getLocalSessionUser, signInWithLocalAuth, signOutWithLocalAuth, signUpWithLocalAuth } from './lib/auth';
|
||||||
import { hasApiConfig } from './lib/api';
|
import { hasApiConfig } from './lib/api';
|
||||||
@@ -163,7 +165,7 @@ export default function App() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen items-center justify-center bg-stone-50">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -270,6 +272,12 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
{activeTab === 'dashboard' && <Dashboard user={user} />}
|
{activeTab === 'dashboard' && <Dashboard user={user} />}
|
||||||
{activeTab === 'map' && <MapView user={user} jobIds={selectedJobIds} />}
|
{activeTab === 'map' && <MapView user={user} jobIds={selectedJobIds} />}
|
||||||
|
{activeTab === 'account' && (
|
||||||
|
<AccountPage
|
||||||
|
user={user}
|
||||||
|
onUserUpdated={(nextUser) => setUser((currentUser) => (currentUser ? { ...nextUser, sessionId: currentUser.sessionId } : currentUser))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Layout>
|
</Layout>
|
||||||
</APIProvider>
|
</APIProvider>
|
||||||
);
|
);
|
||||||
@@ -370,12 +378,12 @@ function LandingPage(props: {
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
return (
|
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">
|
<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 flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<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" />
|
<Briefcase className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -401,11 +409,7 @@ function LandingPage(props: {
|
|||||||
>
|
>
|
||||||
Sign In
|
Sign In
|
||||||
</button>
|
</button>
|
||||||
<button
|
<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">
|
||||||
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"
|
|
||||||
>
|
|
||||||
Sign Up
|
Sign Up
|
||||||
<ArrowRight className="h-4 w-4" />
|
<ArrowRight className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -415,10 +419,10 @@ function LandingPage(props: {
|
|||||||
|
|
||||||
<section className="py-12 lg:py-16">
|
<section className="py-12 lg:py-16">
|
||||||
<div className="space-y-8">
|
<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" />
|
<Sparkles className="h-4 w-4" />
|
||||||
Built for local lead generation workflows
|
Built for local lead generation workflows
|
||||||
</div>
|
</Badge>
|
||||||
|
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<h1 className="max-w-4xl text-5xl font-bold tracking-tight text-stone-950 sm:text-6xl">
|
<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>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row">
|
<div className="flex flex-col gap-3 sm:flex-row">
|
||||||
<button
|
<Button type="button" onClick={() => onGoToAuth('sign_up')} size="lg" className="rounded-2xl bg-stone-900 hover:bg-stone-800">
|
||||||
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"
|
|
||||||
>
|
|
||||||
Sign Up
|
Sign Up
|
||||||
<ArrowRight className="h-4 w-4" />
|
<ArrowRight className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
<a
|
<a
|
||||||
href="#pricing"
|
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"
|
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;
|
} = props;
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
<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">
|
<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" />
|
<Briefcase className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<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="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" />
|
<Sparkles className="h-4 w-4" />
|
||||||
Secure access to your lead workspace
|
Secure access to your lead workspace
|
||||||
</div>
|
</Badge>
|
||||||
|
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<h1 className="max-w-3xl text-5xl font-bold tracking-tight text-stone-950 sm:text-6xl">
|
<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.'],
|
['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.'],
|
['Persistent history', 'Keep lead runs and saved businesses available whenever you come back.'],
|
||||||
].map(([title, description]) => (
|
].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="text-base font-bold tracking-tight text-stone-900">{title}</p>
|
||||||
<p className="mt-2 text-sm leading-7 text-stone-600">{description}</p>
|
<p className="mt-2 text-sm leading-7 text-stone-600">{description}</p>
|
||||||
</div>
|
</Surface>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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 className="mb-6 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-emerald-600">Account Access</p>
|
<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.'}
|
{authMode === 'sign_up' ? 'Set up your account to start using Leads4less.' : 'Use your account to continue where you left off.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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" />}
|
{authMode === 'sign_up' ? <UserPlus className="h-6 w-6" /> : <LogIn className="h-6 w-6" />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -729,16 +729,12 @@ function AuthPage(props: {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{authError && (
|
{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">
|
<Alert variant="error" title="Authentication Error" className="mt-5">
|
||||||
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0" />
|
<p>{authError}</p>
|
||||||
<div>
|
</Alert>
|
||||||
<p className="font-semibold">Authentication Error</p>
|
|
||||||
<p className="mt-1 opacity-90">{authError}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{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
|
<form
|
||||||
className="mt-5 space-y-4"
|
className="mt-5 space-y-4"
|
||||||
@@ -749,46 +745,44 @@ function AuthPage(props: {
|
|||||||
>
|
>
|
||||||
{authMode === 'sign_up' && (
|
{authMode === 'sign_up' && (
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-semibold text-stone-700">Name</label>
|
<FieldLabel>Name</FieldLabel>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={displayName}
|
value={displayName}
|
||||||
onChange={(event) => onDisplayNameChange(event.target.value)}
|
onChange={(event) => onDisplayNameChange(event.target.value)}
|
||||||
placeholder="Your name"
|
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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-semibold text-stone-700">Email</label>
|
<FieldLabel>Email</FieldLabel>
|
||||||
<input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
required
|
required
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(event) => onEmailChange(event.target.value)}
|
onChange={(event) => onEmailChange(event.target.value)}
|
||||||
placeholder="you@example.com"
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-semibold text-stone-700">Password</label>
|
<FieldLabel>Password</FieldLabel>
|
||||||
<input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
minLength={6}
|
minLength={6}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(event) => onPasswordChange(event.target.value)}
|
onChange={(event) => onPasswordChange(event.target.value)}
|
||||||
placeholder="At least 6 characters"
|
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>
|
</div>
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isAuthenticating}
|
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 ? (
|
{isAuthenticating ? (
|
||||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
|
<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'
|
: authMode === 'sign_up'
|
||||||
? 'Create Account'
|
? 'Create Account'
|
||||||
: 'Sign In'}
|
: 'Sign In'}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -824,11 +818,11 @@ function ConfigScreen(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen items-center justify-center bg-stone-50 p-6 font-sans">
|
<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">
|
<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-red-100 text-red-600">
|
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-stone-100 text-stone-900">
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</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>
|
<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">
|
<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>
|
<p className="text-sm font-medium text-stone-900">Follow these steps:</p>
|
||||||
@@ -839,7 +833,7 @@ function ConfigScreen(props: {
|
|||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-stone-400">{footer}</p>
|
<p className="text-xs text-stone-400">{footer}</p>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</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]);
|
const defaultCenter = useMemo(() => pin ?? { lat: 39.5, lng: -98.35 }, [pin]);
|
||||||
|
|
||||||
return (
|
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
|
<Map
|
||||||
defaultCenter={defaultCenter}
|
defaultCenter={defaultCenter}
|
||||||
defaultZoom={5}
|
defaultZoom={5}
|
||||||
style={{ width: '100%', height: '100%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
gestureHandling="greedy"
|
gestureHandling="cooperative"
|
||||||
{...cleanMapOptions}
|
{...cleanMapOptions}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
const latLng = event.detail.latLng;
|
const latLng = event.detail.latLng;
|
||||||
@@ -43,7 +43,7 @@ export function BasicResearchMap({ pin, radiusKm, onPinChange }: BasicResearchMa
|
|||||||
</Map>
|
</Map>
|
||||||
|
|
||||||
{!pin && (
|
{!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.
|
Click anywhere on the map to drop a pin. Use the Area field to adjust the search circle.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { listSearchJobs } from '../lib/database';
|
import { listSearchJobs } from '../lib/database';
|
||||||
import type { SearchJob, SearchJobStatus } from '../types';
|
import type { SearchJob, SearchJobStatus } from '../types';
|
||||||
import type { AppUser } from '../../shared/types';
|
import type { AppUser } from '../../shared/types';
|
||||||
|
import { Alert, Badge, Button, Card, EmptyState, LoadingState, MetricPill, SectionHeader, Select, Input } from './ui';
|
||||||
|
|
||||||
interface BasicResultsViewProps {
|
interface BasicResultsViewProps {
|
||||||
user: AppUser;
|
user: AppUser;
|
||||||
@@ -105,30 +106,29 @@ export function BasicResultsView({ user, selectedJobIds, onToggleJobSelection, o
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
<SectionHeader
|
||||||
<div>
|
eyebrow="Basic Results"
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Basic Results</p>
|
title="Basic research runs"
|
||||||
<h2 className="mt-2 text-2xl font-bold text-stone-900">Basic research runs</h2>
|
description="Filter the grid to find specific runs, select the ones you want, then send the full selection to the map."
|
||||||
<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>
|
actions={
|
||||||
</div>
|
<MetricPill>
|
||||||
<div className="rounded-full border border-stone-200 bg-white px-4 py-2 text-sm font-medium text-stone-600 shadow-sm">
|
|
||||||
{filteredJobs.length} shown of {jobs.length}
|
{filteredJobs.length} shown of {jobs.length}
|
||||||
</div>
|
</MetricPill>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<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]">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_220px_220px]">
|
||||||
<label className="space-y-2">
|
<label className="space-y-2">
|
||||||
<span className="flex items-center gap-2 text-sm font-semibold text-stone-700">
|
<span className="flex items-center gap-2 text-sm font-semibold text-stone-700">
|
||||||
<SearchIcon className="h-4 w-4 text-stone-400" />
|
<SearchIcon className="h-4 w-4 text-stone-400" />
|
||||||
Search
|
Search
|
||||||
</span>
|
</span>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
placeholder="Search by name, type, or location"
|
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>
|
</label>
|
||||||
|
|
||||||
@@ -137,32 +137,30 @@ export function BasicResultsView({ user, selectedJobIds, onToggleJobSelection, o
|
|||||||
<SlidersHorizontal className="h-4 w-4 text-stone-400" />
|
<SlidersHorizontal className="h-4 w-4 text-stone-400" />
|
||||||
Status
|
Status
|
||||||
</span>
|
</span>
|
||||||
<select
|
<Select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => setStatusFilter(e.target.value as JobStatusFilter)}
|
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) => (
|
{statusOptions.map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option key={option.value} value={option.value}>
|
||||||
{option.label}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</Select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="space-y-2">
|
<label className="space-y-2">
|
||||||
<span className="text-sm font-semibold text-stone-700">Sort</span>
|
<span className="text-sm font-semibold text-stone-700">Sort</span>
|
||||||
<select
|
<Select
|
||||||
value={sortOrder}
|
value={sortOrder}
|
||||||
onChange={(e) => setSortOrder(e.target.value as SortOption)}
|
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) => (
|
{sortOptions.map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option key={option.value} value={option.value}>
|
||||||
{option.label}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</Select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
<p className="text-sm text-stone-600">Use the selection action to open all selected jobs together on the map.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
<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})
|
Show selected on map ({selectedJobCount})
|
||||||
</button>
|
</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 type="button" onClick={onClearSelection} variant="secondary">
|
||||||
Clear selection
|
Clear selection
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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 ? (
|
{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">
|
<LoadingState message="Loading research jobs..." />
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
Loading research jobs...
|
|
||||||
</div>
|
|
||||||
) : filteredJobs.length === 0 ? (
|
) : filteredJobs.length === 0 ? (
|
||||||
<div className="rounded-2xl border border-dashed border-stone-300 bg-white p-10 text-center shadow-sm">
|
<EmptyState title="No research jobs match the current filters." description="Try adjusting the search term, status filter, or sort order." />
|
||||||
<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>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{filteredJobs.map((job) => {
|
{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>
|
<p className="mt-1 truncate text-sm text-stone-500">{job.businessType}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-2">
|
<div className="flex flex-col items-end gap-2">
|
||||||
<span className={statusMeta.badgeClass}>
|
<Badge variant={statusMeta.variant} icon={statusMeta.icon}>
|
||||||
<statusMeta.icon className={statusMeta.icon === Loader2 ? 'h-3.5 w-3.5 animate-spin' : 'h-3.5 w-3.5'} />
|
<span className={statusMeta.icon === Loader2 ? 'inline-flex animate-pulse' : ''}>{statusMeta.label}</span>
|
||||||
{statusMeta.label}
|
</Badge>
|
||||||
</span>
|
<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'}`}>
|
||||||
<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'}`}>
|
|
||||||
{isSelected ? <Check className="h-3.5 w-3.5" /> : <span className="h-2 w-2 rounded-full bg-current opacity-60"></span>}
|
{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'}
|
{isSelected ? 'Selected' : 'Click to select'}
|
||||||
</span>
|
</span>
|
||||||
@@ -278,32 +269,32 @@ function getStatusMeta(status: SearchJobStatus) {
|
|||||||
return {
|
return {
|
||||||
label: 'Completed',
|
label: 'Completed',
|
||||||
icon: CheckCircle2,
|
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':
|
case 'running':
|
||||||
return {
|
return {
|
||||||
label: 'Running',
|
label: 'Running',
|
||||||
icon: Loader2,
|
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':
|
case 'failed':
|
||||||
return {
|
return {
|
||||||
label: 'Failed',
|
label: 'Failed',
|
||||||
icon: AlertCircle,
|
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':
|
case 'stopped':
|
||||||
return {
|
return {
|
||||||
label: 'Stopped',
|
label: 'Stopped',
|
||||||
icon: CircleOff,
|
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':
|
case 'pending':
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
label: 'Pending',
|
label: 'Pending',
|
||||||
icon: Clock3,
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+113
-94
@@ -11,15 +11,11 @@ import {
|
|||||||
Phone,
|
Phone,
|
||||||
Star,
|
Star,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { clsx, type ClassValue } from 'clsx';
|
|
||||||
import { twMerge } from 'tailwind-merge';
|
|
||||||
import { listBusinesses, listJobResultLinks, listSearchJobs, type SearchJobResultLink } from '../lib/database';
|
import { listBusinesses, listJobResultLinks, listSearchJobs, type SearchJobResultLink } from '../lib/database';
|
||||||
import type { Business, SearchJob } from '../types';
|
import type { Business, SearchJob } from '../types';
|
||||||
import type { AppUser } from '../../shared/types';
|
import type { AppUser } from '../../shared/types';
|
||||||
|
import { cn } from '../lib/cn';
|
||||||
function cn(...inputs: ClassValue[]) {
|
import { Alert, Badge, Button, Card, EmptyState, Input, LoadingState, PageContainer, PageShell, SectionHeader, Select, StatCard } from './ui';
|
||||||
return twMerge(clsx(inputs));
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DashboardProps {
|
interface DashboardProps {
|
||||||
user: AppUser;
|
user: AppUser;
|
||||||
@@ -121,10 +117,10 @@ export function Dashboard({ user }: DashboardProps) {
|
|||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ name: 'Total Leads', value: total, icon: Briefcase, color: 'bg-blue-500' },
|
{ name: 'Total Leads', value: total, icon: Briefcase },
|
||||||
{ name: 'With Website', value: withWebsite, icon: Globe, color: 'bg-emerald-500' },
|
{ name: 'With Website', value: withWebsite, icon: Globe },
|
||||||
{ name: 'With Phone', value: withPhone, icon: Phone, color: 'bg-orange-500' },
|
{ name: 'With Phone', value: withPhone, icon: Phone },
|
||||||
{ name: 'Avg Rating', value: avgRating.toFixed(1), icon: Star, color: 'bg-amber-500' },
|
{ name: 'Avg Rating', value: avgRating.toFixed(1), icon: Star },
|
||||||
];
|
];
|
||||||
}, [businesses]);
|
}, [businesses]);
|
||||||
|
|
||||||
@@ -172,61 +168,48 @@ export function Dashboard({ user }: DashboardProps) {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 items-center justify-center bg-stone-50">
|
<PageShell>
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-emerald-500" />
|
<PageContainer>
|
||||||
</div>
|
<LoadingState message="Loading dashboard data..." />
|
||||||
|
</PageContainer>
|
||||||
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto bg-stone-50 p-8">
|
<PageShell>
|
||||||
<div className="mx-auto max-w-7xl space-y-8">
|
<PageContainer>
|
||||||
<header className="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
|
<SectionHeader
|
||||||
<div>
|
title="Lead Dashboard"
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-stone-900">Lead Dashboard</h1>
|
description="Browse saved search results from your local workspace and export targeted lead lists."
|
||||||
<p className="mt-1 text-stone-600">Browse saved search results from your local workspace and export targeted lead lists.</p>
|
actions={
|
||||||
</div>
|
<Button onClick={handleExport} size="lg" className="w-full sm:w-auto">
|
||||||
<button
|
<Download className="h-5 w-5" />
|
||||||
onClick={handleExport}
|
Export CSV
|
||||||
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"
|
</Button>
|
||||||
>
|
}
|
||||||
<Download className="h-5 w-5" />
|
/>
|
||||||
Export CSV
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{error && (
|
{error && <Alert variant="error">{error}</Alert>}
|
||||||
<div className="rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{kpis.map((kpi) => (
|
{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">
|
<StatCard key={kpi.name} title={kpi.name} value={kpi.value} icon={kpi.icon} />
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 rounded-2xl border border-stone-200 bg-white p-6 shadow-sm">
|
<Card className="space-y-4 p-6">
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[minmax(0,1fr)_220px_auto_auto]">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-[minmax(0,1fr)_220px_auto_auto]">
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by name, city, or category..."
|
placeholder="Search by name, city, or category..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
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}
|
value={filterJobId}
|
||||||
onChange={(e) => setFilterJobId(e.target.value)}
|
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>
|
<option value="all">All jobs</option>
|
||||||
{jobs.map((job) => (
|
{jobs.map((job) => (
|
||||||
@@ -234,39 +217,80 @@ export function Dashboard({ user }: DashboardProps) {
|
|||||||
{job.name}
|
{job.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</Select>
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
onClick={() => setFilterHasWebsite(!filterHasWebsite)}
|
onClick={() => setFilterHasWebsite(!filterHasWebsite)}
|
||||||
className={cn(
|
variant={filterHasWebsite ? 'primary' : 'secondary'}
|
||||||
'rounded-xl border px-4 py-2 text-sm font-medium transition-all',
|
className={cn('w-full px-4 sm:w-auto')}
|
||||||
filterHasWebsite
|
|
||||||
? 'border-emerald-200 bg-emerald-50 text-emerald-700'
|
|
||||||
: 'border-stone-200 bg-white text-stone-600 hover:bg-stone-50',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
Has Website
|
Has Website
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
onClick={() => setFilterHasPhone(!filterHasPhone)}
|
onClick={() => setFilterHasPhone(!filterHasPhone)}
|
||||||
className={cn(
|
variant={filterHasPhone ? 'primary' : 'secondary'}
|
||||||
'rounded-xl border px-4 py-2 text-sm font-medium transition-all',
|
className={cn('w-full px-4 sm:w-auto')}
|
||||||
filterHasPhone
|
|
||||||
? 'border-orange-200 bg-orange-50 text-orange-700'
|
|
||||||
: 'border-stone-200 bg-white text-stone-600 hover:bg-stone-50',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
Has Phone
|
Has Phone
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-2xl border border-stone-200 bg-white shadow-sm">
|
{filteredBusinesses.length === 0 ? (
|
||||||
<div className="overflow-x-auto">
|
<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="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">
|
<table className="w-full border-collapse text-left">
|
||||||
<thead>
|
<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')}>
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
Business Name
|
Business Name
|
||||||
@@ -285,27 +309,20 @@ export function Dashboard({ user }: DashboardProps) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-stone-100">
|
<tbody className="divide-y divide-stone-100">
|
||||||
{paginatedBusinesses.length === 0 ? (
|
{paginatedBusinesses.map((business) => (
|
||||||
<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) => (
|
|
||||||
<tr key={business.id} className="group transition-colors hover:bg-stone-50">
|
<tr key={business.id} className="group transition-colors hover:bg-stone-50">
|
||||||
<td className="px-6 py-4">
|
<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>
|
<div className="mt-0.5 max-w-[260px] truncate text-xs text-stone-400">{business.address}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-sm text-stone-600">{business.city || 'Unknown'}</td>
|
<td className="px-6 py-4 text-sm text-stone-600">{business.city || 'Unknown'}</td>
|
||||||
<td className="px-6 py-4">
|
<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'}
|
{business.category || 'Uncategorized'}
|
||||||
</span>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<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" />
|
<Star className="h-4 w-4 fill-amber-500 text-amber-500" />
|
||||||
{business.rating || 'N/A'}
|
{business.rating || 'N/A'}
|
||||||
<span className="text-xs font-normal text-stone-400">({business.reviewCount || 0})</span>
|
<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">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{business.website && (
|
{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" />
|
<Globe className="h-5 w-5" />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{business.phone && (
|
{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" />
|
<Phone className="h-5 w-5" />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))}
|
||||||
)}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredBusinesses.length > itemsPerPage && (
|
{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">
|
<p className="text-sm text-stone-500">
|
||||||
Showing <span className="font-medium">{(currentPage - 1) * itemsPerPage + 1}</span> to{' '}
|
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">{Math.min(currentPage * itemsPerPage, filteredBusinesses.length)}</span> of{' '}
|
||||||
<span className="font-medium">{filteredBusinesses.length}</span> leads
|
<span className="font-medium">{filteredBusinesses.length}</span> leads
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<button
|
<Button
|
||||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||||
disabled={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" />
|
<ChevronLeft className="h-5 w-5" />
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
||||||
disabled={currentPage === totalPages}
|
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" />
|
<ChevronRight className="h-5 w-5" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</PageContainer>
|
||||||
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,12 +26,12 @@ export function DeepResearchPreviewMap({ pin, preview, onPinChange }: DeepResear
|
|||||||
}, [pin, preview]);
|
}, [pin, preview]);
|
||||||
|
|
||||||
return (
|
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
|
<Map
|
||||||
defaultCenter={defaultCenter}
|
defaultCenter={defaultCenter}
|
||||||
defaultZoom={5}
|
defaultZoom={5}
|
||||||
style={{ width: '100%', height: '100%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
gestureHandling="greedy"
|
gestureHandling="cooperative"
|
||||||
{...cleanMapOptions}
|
{...cleanMapOptions}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
const latLng = event.detail.latLng;
|
const latLng = event.detail.latLng;
|
||||||
@@ -39,7 +39,7 @@ export function DeepResearchPreviewMap({ pin, preview, onPinChange }: DeepResear
|
|||||||
onPinChange(latLng);
|
onPinChange(latLng);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PreviewOverlay overlay={preview?.overlay ?? null} pin={pin} preview={preview} />
|
<PreviewOverlay overlay={preview?.overlay ?? null} pin={pin} preview={preview} />
|
||||||
{pin && (
|
{pin && (
|
||||||
<Marker
|
<Marker
|
||||||
@@ -57,7 +57,7 @@ export function DeepResearchPreviewMap({ pin, preview, onPinChange }: DeepResear
|
|||||||
</Map>
|
</Map>
|
||||||
|
|
||||||
{!pin && (
|
{!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.
|
Click anywhere on the map to drop a pin and preview the ZIP/FSA areas included in the deep research run.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { Loader2 } from 'lucide-react';
|
|
||||||
import type { DeepResearchBatchSummary } from '../../shared/types';
|
import type { DeepResearchBatchSummary } from '../../shared/types';
|
||||||
import { getDeepResearchBatch, listDeepResearchBatches } from '../lib/database';
|
import { getDeepResearchBatch, listDeepResearchBatches } from '../lib/database';
|
||||||
|
import { Alert, Badge, Button, EmptyState, LoadingState, MetricPill, SectionHeader, Surface } from './ui';
|
||||||
|
|
||||||
interface DeepResearchResultsViewProps {
|
interface DeepResearchResultsViewProps {
|
||||||
onShowBatchOnMap: (jobIds: string[]) => void;
|
onShowBatchOnMap: (jobIds: string[]) => void;
|
||||||
@@ -51,39 +51,29 @@ export function DeepResearchResultsView({ onShowBatchOnMap }: DeepResearchResult
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
<SectionHeader
|
||||||
<div>
|
eyebrow="Deep Research Results"
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-stone-500">Deep Research Results</p>
|
title="Previous deep research batches"
|
||||||
<h2 className="mt-2 text-2xl font-bold text-stone-900">Previous deep research batches</h2>
|
description="Review completed or failed batch runs and open their bundled map results."
|
||||||
<p className="mt-2 text-sm text-stone-600">Review completed or failed batch runs and open their bundled map results.</p>
|
actions={<MetricPill>{batches.length} batches</MetricPill>}
|
||||||
</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>
|
|
||||||
|
|
||||||
{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 ? (
|
{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">
|
<LoadingState message="Loading deep research batches..." />
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
Loading deep research batches...
|
|
||||||
</div>
|
|
||||||
) : batches.length === 0 ? (
|
) : batches.length === 0 ? (
|
||||||
<div className="rounded-2xl border border-dashed border-stone-300 bg-white p-10 text-center shadow-sm">
|
<EmptyState title="No deep research batches yet." description="Preview a pin on the map and run your first deep research batch." />
|
||||||
<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>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||||
{batches.map((batch) => (
|
{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 className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<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>
|
<p className="mt-1 text-sm text-stone-500">{batch.basePostalCode ? `${batch.basePostalCode} · ${batch.countryCode ?? 'N/A'}` : 'Base postal area unavailable'}</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-2 gap-3 rounded-2xl bg-stone-50 p-4 text-sm text-stone-600">
|
<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">
|
<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>
|
<span>Created {new Date(batch.createdAt).toLocaleDateString()}</span>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleOpenBatchOnMap(batch.id)}
|
onClick={() => void handleOpenBatchOnMap(batch.id)}
|
||||||
disabled={activeBatchId === 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'}
|
{activeBatchId === batch.id ? 'Loading...' : 'Show bundle on map'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Surface>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -126,18 +116,18 @@ export function DeepResearchResultsView({ onShowBatchOnMap }: DeepResearchResult
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusBadgeClass(status: DeepResearchBatchSummary['status']) {
|
function statusBadgeVariant(status: DeepResearchBatchSummary['status']) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'completed':
|
case 'completed':
|
||||||
return 'bg-emerald-100 text-emerald-800';
|
return 'success' as const;
|
||||||
case 'failed':
|
case 'failed':
|
||||||
return 'bg-red-100 text-red-700';
|
return 'danger' as const;
|
||||||
case 'running':
|
case 'running':
|
||||||
return 'bg-sky-100 text-sky-700';
|
return 'info' as const;
|
||||||
case 'stopped':
|
case 'stopped':
|
||||||
return 'bg-stone-200 text-stone-700';
|
return 'neutral' as const;
|
||||||
case 'pending':
|
case 'pending':
|
||||||
default:
|
default:
|
||||||
return 'bg-amber-100 text-amber-700';
|
return 'warning' as const;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
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 type { DeepResearchPreview } from '../../shared/types';
|
||||||
import { createDeepResearchBatch, previewDeepResearch } from '../lib/database';
|
import { createDeepResearchBatch, previewDeepResearch } from '../lib/database';
|
||||||
import { DeepResearchPreviewMap } from './DeepResearchPreviewMap';
|
import { DeepResearchPreviewMap } from './DeepResearchPreviewMap';
|
||||||
|
import { Alert, Badge, Button, FieldLabel, Input, MetricPill, PageContainer, PageShell, SectionHeader, Surface } from './ui';
|
||||||
|
|
||||||
interface DeepResearchViewProps {
|
interface DeepResearchViewProps {
|
||||||
onShowBatchOnMap: (jobIds: string[]) => void;
|
onShowBatchOnMap: (jobIds: string[]) => void;
|
||||||
@@ -88,52 +89,53 @@ export function DeepResearchView({ onShowBatchOnMap, topContent }: DeepResearchV
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto bg-stone-50 p-6 sm:p-8">
|
<PageShell>
|
||||||
<div className="mx-auto max-w-7xl space-y-8">
|
<PageContainer>
|
||||||
{topContent}
|
{topContent}
|
||||||
|
|
||||||
<header className="space-y-2">
|
<SectionHeader
|
||||||
<h1 className="text-3xl font-bold text-stone-900">Deep Research</h1>
|
title="Deep Research"
|
||||||
<p className="max-w-3xl text-stone-600">
|
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."
|
||||||
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>
|
|
||||||
|
|
||||||
<section className="grid grid-cols-1 gap-6 xl:grid-cols-[360px_minmax(0,1fr)]">
|
<section className="grid grid-cols-1 items-stretch gap-6 xl:grid-cols-[400px_minmax(0,1fr)]">
|
||||||
<div className="space-y-6 rounded-3xl border border-stone-200 bg-white p-6 shadow-sm">
|
<Surface className="p-5 sm:p-7">
|
||||||
<div>
|
<div className="space-y-5">
|
||||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-emerald-600">Planner</p>
|
<div className="space-y-2">
|
||||||
<h2 className="mt-2 text-2xl font-bold text-stone-900">Configure the deep research batch</h2>
|
<FieldLabel>Business Type</FieldLabel>
|
||||||
<p className="mt-2 text-sm text-stone-600">
|
<Input
|
||||||
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
|
|
||||||
type="text"
|
type="text"
|
||||||
value={businessType}
|
value={businessType}
|
||||||
onChange={(event) => setBusinessType(event.target.value)}
|
onChange={(event) => setBusinessType(event.target.value)}
|
||||||
placeholder="e.g. dentists, HVAC, bakeries"
|
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>
|
||||||
|
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label className="mb-2 block text-sm font-semibold text-stone-700">Keywords</label>
|
<FieldLabel>Keywords</FieldLabel>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={keywords}
|
value={keywords}
|
||||||
onChange={(event) => setKeywords(event.target.value)}
|
onChange={(event) => setKeywords(event.target.value)}
|
||||||
placeholder="Optional comma-separated keywords"
|
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>
|
||||||
|
|
||||||
<div>
|
<div className="space-y-2.5">
|
||||||
<label className="mb-2 block text-sm font-semibold text-stone-700">Propagation</label>
|
<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
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="0"
|
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">
|
<div className="mt-2 flex items-center justify-between text-xs text-stone-500">
|
||||||
<span>Base postal area only</span>
|
<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>
|
<span>Expand outward</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<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>}
|
{pin && <p className="mt-3 font-medium text-stone-800">Pin: {pin.lat.toFixed(5)}, {pin.lng.toFixed(5)}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{previewError && (
|
{previewError && (
|
||||||
<div className="flex items-start gap-3 rounded-2xl border border-red-100 bg-red-50 p-4 text-sm text-red-700">
|
<Alert variant="error">{previewError}</Alert>
|
||||||
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0" />
|
)}
|
||||||
<span>{previewError}</span>
|
|
||||||
|
{previewSummary && (
|
||||||
|
<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>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handlePreview()}
|
||||||
|
disabled={!canPreview || isPreviewing || isRunning}
|
||||||
|
variant="secondary"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{isPreviewing ? <Loader2 className="h-4 w-4 animate-spin" /> : <MapPinned className="h-4 w-4" />}
|
||||||
|
Preview areas
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleRunDeepResearch()}
|
||||||
|
disabled={!canPreview || isPreviewing || isRunning}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{isRunning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
|
||||||
|
Run deep research
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{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>
|
|
||||||
<p className="mt-2 text-emerald-800">
|
|
||||||
Base area: <span className="font-semibold">{preview?.baseArea.displayName}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row">
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
{isPreviewing ? <Loader2 className="h-4 w-4 animate-spin" /> : <MapPinned className="h-4 w-4" />}
|
|
||||||
Preview areas
|
|
||||||
</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"
|
|
||||||
>
|
|
||||||
{isRunning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
|
|
||||||
Run deep research
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Surface>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="flex h-full flex-col gap-4">
|
||||||
<DeepResearchPreviewMap pin={pin} preview={preview} onPinChange={setPin} />
|
<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 && (
|
{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 className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<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>
|
<p className="mt-1 text-sm text-stone-600">These postal areas will become child researches in the batch.</p>
|
||||||
</div>
|
</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
|
{preview.totalAreas} areas
|
||||||
</div>
|
</MetricPill>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
{preview.areas.map((area) => (
|
{preview.areas.map((area) => (
|
||||||
<span
|
<Badge
|
||||||
key={area.id}
|
key={area.id}
|
||||||
className={`rounded-full px-3 py-1 text-xs font-semibold ${
|
variant={area.propagationRing === 0 ? 'primary' : 'neutral'}
|
||||||
area.propagationRing === 0 ? 'bg-emerald-100 text-emerald-800' : 'bg-stone-100 text-stone-700'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{area.displayName} · ring {area.propagationRing}
|
{area.displayName} · ring {area.propagationRing}
|
||||||
</span>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Surface>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</PageContainer>
|
||||||
</div>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+78
-40
@@ -1,15 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Search, LayoutDashboard, Map as MapIcon, LogOut, Briefcase, Files } from 'lucide-react';
|
import { Search, LayoutDashboard, Map as MapIcon, LogOut, Briefcase, Files, UserRound } from 'lucide-react';
|
||||||
import { clsx, type ClassValue } from 'clsx';
|
|
||||||
import { twMerge } from 'tailwind-merge';
|
|
||||||
import type { AppUser } from '../../shared/types';
|
import type { AppUser } from '../../shared/types';
|
||||||
import { getUserAvatarUrl, getUserDisplayName } from '../lib/auth';
|
import { getUserAvatarUrl, getUserDisplayName } from '../lib/auth';
|
||||||
|
import { cn } from '../lib/cn';
|
||||||
|
import { Button } from './ui';
|
||||||
|
|
||||||
export type AppTab = 'setup' | 'results' | 'dashboard' | 'map';
|
export type AppTab = 'setup' | 'results' | 'dashboard' | 'map' | 'account';
|
||||||
|
|
||||||
function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs));
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
user: AppUser;
|
user: AppUser;
|
||||||
@@ -28,64 +24,106 @@ export function Layout({ user, activeTab, setActiveTab, onLogout, children }: La
|
|||||||
{ id: 'results', name: 'Results', icon: Files },
|
{ id: 'results', name: 'Results', icon: Files },
|
||||||
{ id: 'dashboard', name: 'Dashboard', icon: LayoutDashboard },
|
{ id: 'dashboard', name: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ id: 'map', name: 'Map View', icon: MapIcon },
|
{ id: 'map', name: 'Map View', icon: MapIcon },
|
||||||
|
{ id: 'account', name: 'Account', icon: UserRound },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const activeNavigationItem = navigation.find((item) => item.id === activeTab) ?? navigation[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-stone-50 overflow-hidden">
|
<div className="flex h-[100dvh] flex-col overflow-hidden bg-stone-100 lg:h-screen lg:flex-row">
|
||||||
{/* Sidebar */}
|
<header className="flex items-center justify-between border-b border-stone-200 bg-white px-4 py-3 lg:hidden">
|
||||||
<aside className="w-64 bg-white border-r border-stone-200 flex flex-col">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<div className="p-6 flex items-center gap-3">
|
<div className="rounded-xl bg-stone-900 p-2 text-white shadow-sm">
|
||||||
<div className="bg-emerald-600 p-2 rounded-lg text-white">
|
<Briefcase className="h-5 w-5" />
|
||||||
<Briefcase className="h-6 w-6" />
|
</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>
|
||||||
|
<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>
|
||||||
<span className="font-bold text-xl tracking-tight text-stone-900">Leads4less</span>
|
|
||||||
</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) => (
|
{navigation.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => setActiveTab(item.id)}
|
onClick={() => setActiveTab(item.id)}
|
||||||
className={cn(
|
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
|
activeTab === item.id
|
||||||
? "bg-emerald-50 text-emerald-700 shadow-sm"
|
? 'bg-stone-900 text-white shadow-sm'
|
||||||
: "text-stone-600 hover:bg-stone-100 hover:text-stone-900"
|
: '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}
|
{item.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="p-4 border-t border-stone-100">
|
<div className="border-t border-stone-100 p-4">
|
||||||
<div className="flex items-center gap-3 px-4 py-3 mb-2">
|
<div className="mb-3 rounded-2xl border border-stone-200 bg-stone-50 px-4 py-3">
|
||||||
<img
|
<div className="flex items-center gap-3">
|
||||||
src={userAvatarUrl || `https://ui-avatars.com/api/?name=${encodeURIComponent(userDisplayName)}`}
|
<img
|
||||||
alt={userDisplayName}
|
src={userAvatarUrl || `https://ui-avatars.com/api/?name=${encodeURIComponent(userDisplayName)}`}
|
||||||
className="h-8 w-8 rounded-full border border-stone-200"
|
alt={userDisplayName}
|
||||||
referrerPolicy="no-referrer"
|
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>
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-xs text-stone-500 truncate">{user.email}</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button onClick={onLogout} variant="secondary" className="w-full justify-start gap-3 text-stone-600 hover:text-stone-900">
|
||||||
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"
|
|
||||||
>
|
|
||||||
<LogOut className="h-5 w-5" />
|
<LogOut className="h-5 w-5" />
|
||||||
Sign Out
|
Sign Out
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main Content */}
|
<main className="relative flex min-h-0 flex-1 flex-col overflow-hidden pb-16 lg:pb-0">{children}</main>
|
||||||
<main className="flex-1 flex flex-col overflow-hidden relative">
|
|
||||||
{children}
|
<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">
|
||||||
</main>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-21
@@ -5,6 +5,7 @@ import { listBusinesses, listBusinessesForJobs } from '../lib/database';
|
|||||||
import { cleanMapOptions } from '../lib/map-styles';
|
import { cleanMapOptions } from '../lib/map-styles';
|
||||||
import type { Business } from '../types';
|
import type { Business } from '../types';
|
||||||
import type { AppUser } from '../../shared/types';
|
import type { AppUser } from '../../shared/types';
|
||||||
|
import { Alert, Badge, EmptyState } from './ui';
|
||||||
|
|
||||||
interface MapViewProps {
|
interface MapViewProps {
|
||||||
user: AppUser;
|
user: AppUser;
|
||||||
@@ -101,17 +102,15 @@ export function MapView({ user, jobIds }: MapViewProps) {
|
|||||||
if (businesses.length === 0) {
|
if (businesses.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 items-center justify-center bg-stone-50 p-8">
|
<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="w-full max-w-lg">
|
||||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-stone-100 text-stone-500">
|
<EmptyState
|
||||||
<MapPin className="h-6 w-6" />
|
icon={MapPin}
|
||||||
</div>
|
title="No leads to show on the map"
|
||||||
<h2 className="mt-4 text-2xl font-bold text-stone-900">No leads to show on the map</h2>
|
description={selectedJobCount > 0
|
||||||
<p className="mt-3 text-sm text-stone-600">
|
|
||||||
{selectedJobCount > 0
|
|
||||||
? 'The selected research jobs do not have saved map results yet. Try completed jobs or run the research again.'
|
? '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.'}
|
: '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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -120,9 +119,7 @@ export function MapView({ user, jobIds }: MapViewProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="relative flex-1 bg-stone-100">
|
<div className="relative flex-1 bg-stone-100">
|
||||||
{error && (
|
{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">
|
<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>
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Map
|
<Map
|
||||||
@@ -154,7 +151,7 @@ export function MapView({ user, jobIds }: MapViewProps) {
|
|||||||
<InfoWindow position={{ lat: selected.latitude, lng: selected.longitude }} onCloseClick={() => setSelected(null)}>
|
<InfoWindow position={{ lat: selected.latitude, lng: selected.longitude }} onCloseClick={() => setSelected(null)}>
|
||||||
<div className="max-w-[280px] space-y-3 p-2">
|
<div className="max-w-[280px] space-y-3 p-2">
|
||||||
<header>
|
<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">
|
<div className="mt-1 flex items-center gap-1 text-xs text-stone-500">
|
||||||
<MapPin className="h-3 w-3" />
|
<MapPin className="h-3 w-3" />
|
||||||
<span className="truncate">{selected.address}</span>
|
<span className="truncate">{selected.address}</span>
|
||||||
@@ -162,14 +159,14 @@ export function MapView({ user, jobIds }: MapViewProps) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 border-y border-stone-100 py-2">
|
<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" />
|
<Star className="h-4 w-4 fill-amber-500 text-amber-500" />
|
||||||
{selected.rating || 'N/A'}
|
{selected.rating || 'N/A'}
|
||||||
<span className="text-xs font-normal text-stone-400">({selected.reviewCount || 0})</span>
|
<span className="text-xs font-normal text-stone-400">({selected.reviewCount || 0})</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="rounded-full bg-stone-100 px-2 py-0.5 text-xs font-medium text-stone-600">
|
<Badge>
|
||||||
{selected.category || 'Uncategorized'}
|
{selected.category || 'Uncategorized'}
|
||||||
</span>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -178,7 +175,7 @@ export function MapView({ user, jobIds }: MapViewProps) {
|
|||||||
href={selected.website}
|
href={selected.website}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener"
|
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" />
|
<Globe className="h-3.5 w-3.5" />
|
||||||
Website
|
Website
|
||||||
@@ -187,7 +184,7 @@ export function MapView({ user, jobIds }: MapViewProps) {
|
|||||||
{selected.phone && (
|
{selected.phone && (
|
||||||
<a
|
<a
|
||||||
href={`tel:${selected.phone}`}
|
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" />
|
<Phone className="h-3.5 w-3.5" />
|
||||||
Call
|
Call
|
||||||
@@ -209,8 +206,8 @@ export function MapView({ user, jobIds }: MapViewProps) {
|
|||||||
)}
|
)}
|
||||||
</Map>
|
</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">
|
<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-bold text-stone-900">Map Summary</h4>
|
<h4 className="mb-2 text-sm font-semibold text-stone-950">Map Summary</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between text-xs">
|
<div className="flex items-center justify-between text-xs">
|
||||||
<span className="text-stone-500">Total Leads on Map</span>
|
<span className="text-stone-500">Total Leads on Map</span>
|
||||||
@@ -218,7 +215,7 @@ export function MapView({ user, jobIds }: MapViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-xs">
|
<div className="flex items-center justify-between text-xs">
|
||||||
<span className="text-stone-500">Selected Lead</span>
|
<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>
|
</div>
|
||||||
{selectedJobCount > 0 && (
|
{selectedJobCount > 0 && (
|
||||||
<div className="mt-2 border-t border-stone-200 pt-2">
|
<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 type { AppUser } from '../../shared/types';
|
||||||
import { DeepResearchView } from './DeepResearchView';
|
import { DeepResearchView } from './DeepResearchView';
|
||||||
import { SearchSetup } from './SearchSetup';
|
import { SearchSetup } from './SearchSetup';
|
||||||
|
import { SegmentedTabs } from './ui';
|
||||||
|
|
||||||
type ResearchTab = 'research' | 'deepResearch';
|
type ResearchTab = 'research' | 'deepResearch';
|
||||||
|
|
||||||
@@ -27,40 +28,7 @@ export function ResearchWorkspace({
|
|||||||
}: ResearchWorkspaceProps) {
|
}: ResearchWorkspaceProps) {
|
||||||
const [activeTab, setActiveTab] = useState<ResearchTab>('research');
|
const [activeTab, setActiveTab] = useState<ResearchTab>('research');
|
||||||
|
|
||||||
const tabs = (
|
const tabs = <SegmentedTabs tabs={[{ value: 'research', label: 'Basic', icon: Search }, { value: 'deepResearch', label: 'Deep Research', icon: MapPinned }]} value={activeTab} onChange={(value) => setActiveTab(value)} />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
|
|
||||||
return activeTab === 'research' ? (
|
return activeTab === 'research' ? (
|
||||||
<SearchSetup
|
<SearchSetup
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Files, MapPinned } from 'lucide-react';
|
|||||||
import type { AppUser } from '../../shared/types';
|
import type { AppUser } from '../../shared/types';
|
||||||
import { BasicResultsView } from './BasicResultsView';
|
import { BasicResultsView } from './BasicResultsView';
|
||||||
import { DeepResearchResultsView } from './DeepResearchResultsView';
|
import { DeepResearchResultsView } from './DeepResearchResultsView';
|
||||||
|
import { PageContainer, PageShell, SectionHeader, SegmentedTabs } from './ui';
|
||||||
|
|
||||||
type ResultsTab = 'basic' | 'deepResearch';
|
type ResultsTab = 'basic' | 'deepResearch';
|
||||||
|
|
||||||
@@ -19,35 +20,14 @@ export function ResultsWorkspace({ user, selectedJobIds, onToggleJobSelection, o
|
|||||||
const [activeTab, setActiveTab] = useState<ResultsTab>('basic');
|
const [activeTab, setActiveTab] = useState<ResultsTab>('basic');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto bg-stone-50 p-6 sm:p-8">
|
<PageShell>
|
||||||
<div className="mx-auto max-w-7xl space-y-8">
|
<PageContainer>
|
||||||
<div className="sticky top-0 z-20 -mx-2 bg-stone-50/95 px-2 pb-2 pt-1 backdrop-blur-sm">
|
<SegmentedTabs tabs={[{ value: 'basic', label: 'Basic', icon: Files }, { value: 'deepResearch', label: 'Deep Research', icon: MapPinned }]} value={activeTab} onChange={(value) => setActiveTab(value)} />
|
||||||
<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>
|
|
||||||
|
|
||||||
<header className="space-y-2">
|
<SectionHeader
|
||||||
<h1 className="text-3xl font-bold text-stone-900">Results</h1>
|
title="Results"
|
||||||
<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>
|
description="Browse previous Basic and Deep Research runs, select items, and send them to the map when needed."
|
||||||
</header>
|
/>
|
||||||
|
|
||||||
{activeTab === 'basic' ? (
|
{activeTab === 'basic' ? (
|
||||||
<BasicResultsView
|
<BasicResultsView
|
||||||
@@ -60,7 +40,7 @@ export function ResultsWorkspace({ user, selectedJobIds, onToggleJobSelection, o
|
|||||||
) : (
|
) : (
|
||||||
<DeepResearchResultsView onShowBatchOnMap={onShowBatchOnMap} />
|
<DeepResearchResultsView onShowBatchOnMap={onShowBatchOnMap} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</PageContainer>
|
||||||
</div>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { AlertCircle, LocateFixed, Loader2, MapPin, Play } from 'lucide-react';
|
|||||||
import { runSearch } from '../lib/database';
|
import { runSearch } from '../lib/database';
|
||||||
import type { AppUser } from '../../shared/types';
|
import type { AppUser } from '../../shared/types';
|
||||||
import { BasicResearchMap } from './BasicResearchMap';
|
import { BasicResearchMap } from './BasicResearchMap';
|
||||||
|
import { Alert, Button, FieldLabel, Input, PageContainer, PageShell, SectionHeader, Surface } from './ui';
|
||||||
|
|
||||||
interface SearchSetupProps {
|
interface SearchSetupProps {
|
||||||
user: AppUser;
|
user: AppUser;
|
||||||
@@ -89,108 +90,96 @@ export function SearchSetup({
|
|||||||
const hasLocationPin = Boolean(pin);
|
const hasLocationPin = Boolean(pin);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto bg-stone-50 p-6 sm:p-8">
|
<PageShell>
|
||||||
<div className="mx-auto max-w-7xl space-y-8">
|
<PageContainer>
|
||||||
{topContent}
|
{topContent}
|
||||||
|
|
||||||
<header className="space-y-2">
|
<SectionHeader
|
||||||
<h1 className="text-3xl font-bold text-stone-900">Basic Research</h1>
|
title="Basic Research"
|
||||||
<p className="max-w-3xl text-stone-600">
|
description="Drop a pin on the map, define the search area, and run standard research before reviewing saved jobs below."
|
||||||
Drop a pin on the map, define the search area, and run standard research before reviewing saved jobs below.
|
/>
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="grid grid-cols-1 items-stretch gap-6 xl:grid-cols-[400px_minmax(0,1fr)]">
|
<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">
|
<form onSubmit={handleRunSearch} className="space-y-5">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-semibold text-stone-700">Research Name</label>
|
<FieldLabel>Research Name</FieldLabel>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Give this research a memorable name"
|
placeholder="Give this research a memorable name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-semibold text-stone-700">Business Type</label>
|
<FieldLabel>Business Type</FieldLabel>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
placeholder="e.g. coffee shop, plumber"
|
placeholder="e.g. coffee shop, plumber"
|
||||||
value={businessType}
|
value={businessType}
|
||||||
onChange={(e) => setBusinessType(e.target.value)}
|
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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-semibold text-stone-700">Keywords</label>
|
<FieldLabel>Keywords</FieldLabel>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="e.g. organic, emergency, family-owned"
|
placeholder="e.g. organic, emergency, family-owned"
|
||||||
value={keywords}
|
value={keywords}
|
||||||
onChange={(e) => setKeywords(e.target.value)}
|
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>
|
||||||
|
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
<label className="text-sm font-semibold text-stone-700">Location Source</label>
|
<FieldLabel>Location Source</FieldLabel>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleUseMyLocation}
|
onClick={handleUseMyLocation}
|
||||||
disabled={locationAction !== null}
|
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" />
|
<LocateFixed className="h-4 w-4" />
|
||||||
{locationAction === 'geolocate' ? 'Locating...' : 'Use my location'}
|
{locationAction === 'geolocate' ? 'Locating...' : 'Use my location'}
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
{locationError && (
|
{locationError && (
|
||||||
<div className="flex items-center gap-3 rounded-xl border border-red-100 bg-red-50 p-4 text-sm text-red-700">
|
<Alert variant="error">{locationError}</Alert>
|
||||||
<AlertCircle className="h-5 w-5 flex-shrink-0" />
|
|
||||||
<span>{locationError}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-semibold text-stone-700">Area (km radius)</label>
|
<FieldLabel>Area (km radius)</FieldLabel>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="50"
|
max="50"
|
||||||
value={radius}
|
value={radius}
|
||||||
onChange={(e) => setRadius(Number.parseInt(e.target.value, 10) || 1)}
|
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>
|
||||||
|
|
||||||
<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.
|
Drop a pin directly on the map or use your current location. The map circle always reflects the current area value.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasLocationPin && (
|
{hasLocationPin && (
|
||||||
<div className="rounded-2xl border border-emerald-200 bg-emerald-50/70 p-3.5 text-sm text-emerald-900">
|
<Alert variant="success" title="Active search center">
|
||||||
<p className="font-semibold">Active search center</p>
|
<p>{pin!.lat.toFixed(5)}, {pin!.lng.toFixed(5)}</p>
|
||||||
<p className="mt-1">{pin!.lat.toFixed(5)}, {pin!.lng.toFixed(5)}</p>
|
</Alert>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{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">
|
<Alert variant="error">{error}</Alert>
|
||||||
<AlertCircle className="h-5 w-5 flex-shrink-0" />
|
|
||||||
<span>{error}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSearching}
|
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 ? (
|
{isSearching ? (
|
||||||
<>
|
<>
|
||||||
@@ -203,21 +192,25 @@ export function SearchSetup({
|
|||||||
Run Research
|
Run Research
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</Surface>
|
||||||
|
|
||||||
<div className="flex h-full flex-col gap-4">
|
<div className="flex h-full flex-col gap-4">
|
||||||
<BasicResearchMap pin={pin} radiusKm={radius} onPinChange={(nextPin) => void applyPin(nextPin)} />
|
<BasicResearchMap pin={pin} radiusKm={radius} onPinChange={(nextPin) => void applyPin(nextPin)} />
|
||||||
<div className="rounded-3xl border border-stone-200 bg-white p-5 shadow-sm">
|
<Surface className="p-5">
|
||||||
<h3 className="text-lg font-bold text-stone-900">Map controls</h3>
|
<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">
|
<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.
|
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>
|
</p>
|
||||||
</div>
|
</Surface>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</PageContainer>
|
||||||
</div>
|
</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";
|
@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