Public Access
1
0
Files
leads4less/src/App.tsx
T
pguerrerox a1ba5ee093 feat: migrate app to local Fastify and Postgres stack
Replace Supabase auth and search runtime with a local Fastify API, PostgreSQL/PostGIS schema, and local session handling. Scaffold the worker and deep-research foundations while keeping the existing research, dashboard, and map flows running on the new backend.
2026-03-27 13:56:54 +00:00

370 lines
13 KiB
TypeScript

import { type ReactElement, type SVGProps, useEffect, useState } from 'react';
import { APIProvider } from '@vis.gl/react-google-maps';
import { AlertCircle, Briefcase, LogIn, ShieldAlert, UserPlus } from 'lucide-react';
import { Layout } from './components/Layout';
import { SearchSetup } from './components/SearchSetup';
import { Dashboard } from './components/Dashboard';
import { MapView } from './components/MapView';
import type { AppUser } from '../shared/types';
import { getLocalSessionUser, signInWithLocalAuth, signOutWithLocalAuth, signUpWithLocalAuth } from './lib/auth';
import { hasApiConfig } from './lib/api';
const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_PLATFORM_KEY ?? '';
const hasValidMapsKey = Boolean(GOOGLE_MAPS_API_KEY) && GOOGLE_MAPS_API_KEY !== 'YOUR_API_KEY';
export default function App() {
const [user, setUser] = useState<AppUser | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<'setup' | 'dashboard' | 'map'>('setup');
const [selectedJobIds, setSelectedJobIds] = useState<string[]>([]);
const [authError, setAuthError] = useState<string | null>(null);
const [authNotice, setAuthNotice] = useState<string | null>(null);
const [isAuthenticating, setIsAuthenticating] = useState(false);
const [authMode, setAuthMode] = useState<'sign_in' | 'sign_up'>('sign_in');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
useEffect(() => {
let isMounted = true;
const loadSession = async () => {
try {
const sessionUser = await getLocalSessionUser();
if (!isMounted) {
return;
}
setUser(sessionUser);
} catch (error) {
if (!isMounted) {
return;
}
setAuthError(error instanceof Error ? error.message : 'Failed to load session.');
} finally {
if (isMounted) {
setLoading(false);
}
}
};
void loadSession();
return () => {
isMounted = false;
};
}, []);
const handleToggleJobSelection = (jobId: string) => {
setSelectedJobIds((currentJobIds) =>
currentJobIds.includes(jobId) ? currentJobIds.filter((currentJobId) => currentJobId !== jobId) : [...currentJobIds, jobId],
);
};
const handleSelectCreatedJob = (jobId: string) => {
setSelectedJobIds((currentJobIds) => (currentJobIds.includes(jobId) ? currentJobIds : [...currentJobIds, jobId]));
};
const handleShowSelectedOnMap = () => {
if (selectedJobIds.length === 0) {
return;
}
setActiveTab('map');
};
const handleClearSelectedJobs = () => {
setSelectedJobIds([]);
};
const handleLogin = async () => {
setAuthError(null);
setAuthNotice(null);
setIsAuthenticating(true);
try {
if (authMode === 'sign_up') {
const nextUser = await signUpWithLocalAuth({
email,
password,
displayName: displayName.trim() || undefined,
});
setUser(nextUser);
setAuthNotice('Account created and signed in.');
return;
}
const nextUser = await signInWithLocalAuth({
email,
password,
});
setUser(nextUser);
} catch (error) {
setAuthError(error instanceof Error ? error.message : 'Authentication failed.');
} finally {
setIsAuthenticating(false);
}
};
const handleLogout = async () => {
try {
await signOutWithLocalAuth();
setUser(null);
setSelectedJobIds([]);
setAuthNotice(null);
} catch (error) {
setAuthError(error instanceof Error ? error.message : 'Failed to sign out.');
}
};
if (loading) {
return (
<div className="flex h-screen items-center justify-center bg-stone-50">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-emerald-500 border-t-transparent"></div>
</div>
);
}
if (!hasApiConfig) {
return (
<ConfigScreen
icon={<ShieldAlert className="h-8 w-8" />}
title="Local API Config Required"
description="Add your local API base URL before running the app."
steps={[
'Start the local Fastify API server.',
'Add VITE_API_BASE_URL to your local environment.',
'Ensure the API can reach your local PostgreSQL database.',
'Restart the Vite dev server after updating env vars.',
]}
footer="The app now uses a local API, PostgreSQL, and local session auth."
/>
);
}
if (!hasValidMapsKey) {
return (
<ConfigScreen
icon={<MapIcon className="h-8 w-8" />}
title="Google Maps API Key Required"
description="Add a browser key for map rendering and a server key for the local search API."
steps={[
'Create a Google Maps Platform API key for the browser app.',
'Set VITE_GOOGLE_MAPS_PLATFORM_KEY locally for the frontend.',
'Set GOOGLE_MAPS_SERVER_KEY for the local API server.',
'Enable Geocoding API and Places API in Google Cloud.',
]}
footer="The app will start once the browser key is available."
/>
);
}
if (!user) {
return (
<div className="flex h-screen flex-col items-center justify-center bg-stone-50 p-4">
<div className="w-full max-w-md space-y-8 rounded-2xl bg-white p-8 shadow-xl">
<div className="text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100 text-emerald-600">
<Briefcase className="h-8 w-8" />
</div>
<h1 className="mt-6 text-3xl font-bold tracking-tight text-stone-900">Leads4less</h1>
<p className="mt-2 text-stone-600">Create a local account to access your lead workspace.</p>
</div>
<div className="grid grid-cols-2 gap-2 rounded-xl bg-stone-100 p-1">
<button
type="button"
onClick={() => {
setAuthMode('sign_in');
setAuthError(null);
setAuthNotice(null);
}}
className={`rounded-lg px-3 py-2 text-sm font-semibold transition-all ${
authMode === 'sign_in' ? 'bg-white text-stone-900 shadow-sm' : 'text-stone-500 hover:text-stone-900'
}`}
>
Sign In
</button>
<button
type="button"
onClick={() => {
setAuthMode('sign_up');
setAuthError(null);
setAuthNotice(null);
}}
className={`rounded-lg px-3 py-2 text-sm font-semibold transition-all ${
authMode === 'sign_up' ? 'bg-white text-stone-900 shadow-sm' : 'text-stone-500 hover:text-stone-900'
}`}
>
Create Account
</button>
</div>
{authError && (
<div className="flex items-start gap-3 rounded-xl border border-red-100 bg-red-50 p-4 text-sm text-red-700">
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0" />
<div>
<p className="font-semibold">Authentication Error</p>
<p className="mt-1 opacity-90">{authError}</p>
</div>
</div>
)}
{authNotice && (
<div className="rounded-xl border border-emerald-100 bg-emerald-50 p-4 text-sm text-emerald-700">
{authNotice}
</div>
)}
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
void handleLogin();
}}
>
{authMode === 'sign_up' && (
<div>
<label className="mb-2 block text-sm font-semibold text-stone-700">Name</label>
<input
type="text"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
placeholder="Your name"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
/>
</div>
)}
<div>
<label className="mb-2 block text-sm font-semibold text-stone-700">Email</label>
<input
type="email"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="you@example.com"
className="w-full rounded-xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm outline-none transition-all focus:border-emerald-500 focus:ring-2 focus:ring-emerald-500"
/>
</div>
<div>
<label className="mb-2 block text-sm font-semibold text-stone-700">Password</label>
<input
type="password"
required
minLength={6}
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="At least 6 characters"
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>
<button
type="submit"
disabled={isAuthenticating}
className="flex w-full items-center justify-center gap-3 rounded-xl bg-emerald-600 px-4 py-3 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"
>
{isAuthenticating ? (
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
) : authMode === 'sign_up' ? (
<UserPlus className="h-5 w-5" />
) : (
<LogIn className="h-5 w-5" />
)}
{isAuthenticating
? authMode === 'sign_up'
? 'Creating account...'
: 'Signing in...'
: authMode === 'sign_up'
? 'Create Account'
: 'Sign In'}
</button>
</form>
</div>
</div>
);
}
return (
<APIProvider apiKey={GOOGLE_MAPS_API_KEY} version="weekly">
<Layout
user={user}
activeTab={activeTab}
setActiveTab={setActiveTab}
onLogout={() => void handleLogout()}
>
{activeTab === 'setup' && (
<SearchSetup
user={user}
selectedJobIds={selectedJobIds}
onToggleJobSelection={handleToggleJobSelection}
onSelectCreatedJob={handleSelectCreatedJob}
onShowSelectedOnMap={handleShowSelectedOnMap}
onClearSelection={handleClearSelectedJobs}
/>
)}
{activeTab === 'dashboard' && <Dashboard user={user} />}
{activeTab === 'map' && <MapView user={user} jobIds={selectedJobIds} />}
</Layout>
</APIProvider>
);
}
function ConfigScreen(props: {
icon: ReactElement;
title: string;
description: string;
steps: string[];
footer: string;
}) {
const { icon, title, description, steps, footer } = props;
return (
<div className="flex h-screen items-center justify-center bg-stone-50 p-6 font-sans">
<div className="w-full max-w-lg rounded-2xl bg-white p-8 text-center shadow-xl">
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-red-100 text-red-600">
{icon}
</div>
<h2 className="mb-4 text-2xl font-bold text-stone-900">{title}</h2>
<p className="mb-6 text-left text-stone-600">{description}</p>
<div className="mb-6 space-y-4 rounded-xl border border-stone-200 bg-stone-50 p-6 text-left">
<p className="text-sm font-medium text-stone-900">Follow these steps:</p>
<ol className="list-inside list-decimal space-y-2 text-sm text-stone-600">
{steps.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
</div>
<p className="text-xs text-stone-400">{footer}</p>
</div>
</div>
);
}
function MapIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21" />
<line x1="9" y1="3" x2="9" y2="18" />
<line x1="15" y1="6" x2="15" y2="21" />
</svg>
);
}