a1ba5ee093
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.
370 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|