feat: add Supabase-backed local lead finder app
This commit is contained in:
+367
@@ -0,0 +1,367 @@
|
||||
import { type ReactElement, type SVGProps, useEffect, useState } from 'react';
|
||||
import type { User } from '@supabase/supabase-js';
|
||||
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 { hasSupabaseConfig, supabase } from './lib/supabase';
|
||||
|
||||
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<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<'setup' | 'dashboard' | 'map'>('setup');
|
||||
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
|
||||
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 () => {
|
||||
const { data, error } = await supabase.auth.getSession();
|
||||
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
setAuthError(error.message);
|
||||
}
|
||||
|
||||
setUser(data.session?.user ?? null);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
void loadSession();
|
||||
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(session?.user ?? null);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSelectJob = (jobId: string) => {
|
||||
setSelectedJobId(jobId);
|
||||
setActiveTab('map');
|
||||
};
|
||||
|
||||
const handleLogin = async () => {
|
||||
setAuthError(null);
|
||||
setAuthNotice(null);
|
||||
setIsAuthenticating(true);
|
||||
|
||||
if (authMode === 'sign_up') {
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
data: {
|
||||
full_name: displayName.trim() || undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setAuthError(error.message);
|
||||
setIsAuthenticating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthNotice(
|
||||
data.session
|
||||
? 'Account created and signed in.'
|
||||
: 'Account created. If email confirmation is enabled, check your inbox before signing in.',
|
||||
);
|
||||
setIsAuthenticating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setAuthError(error.message);
|
||||
setIsAuthenticating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAuthenticating(false);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
const { error } = await supabase.auth.signOut();
|
||||
|
||||
if (error) {
|
||||
setAuthError(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedJobId(null);
|
||||
setAuthNotice(null);
|
||||
};
|
||||
|
||||
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 (!hasSupabaseConfig) {
|
||||
return (
|
||||
<ConfigScreen
|
||||
icon={<ShieldAlert className="h-8 w-8" />}
|
||||
title="Supabase Config Required"
|
||||
description="Add your Supabase project URL and anon key before running the app."
|
||||
steps={[
|
||||
'Create a Supabase project.',
|
||||
'Add VITE_SUPABASE_URL to your local environment.',
|
||||
'Add VITE_SUPABASE_ANON_KEY to your local environment.',
|
||||
'Restart the Vite dev server after updating env vars.',
|
||||
]}
|
||||
footer="The app uses Supabase Auth, Postgres, and Edge Functions now."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 Supabase search function."
|
||||
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 in Supabase Edge Function secrets.',
|
||||
'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">Lead Finder</h1>
|
||||
<p className="mt-2 text-stone-600">Use Supabase email auth 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={(tab) => {
|
||||
setActiveTab(tab);
|
||||
if (tab !== 'map') {
|
||||
setSelectedJobId(null);
|
||||
}
|
||||
}}
|
||||
onLogout={() => void handleLogout()}
|
||||
>
|
||||
{activeTab === 'setup' && <SearchSetup user={user} onSelectJob={handleSelectJob} />}
|
||||
{activeTab === 'dashboard' && <Dashboard user={user} />}
|
||||
{activeTab === 'map' && <MapView user={user} jobId={selectedJobId} />}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user