Auth added and UI improvements
CI / build (push) Has been skipped
CI / deploy (push) Successful in 49s

This commit is contained in:
2026-03-18 18:40:56 -05:00
parent 74e859ef71
commit f9a19eb4c4
8 changed files with 317 additions and 38 deletions
+21 -1
View File
@@ -1,14 +1,25 @@
import type { Component } from 'solid-js'; import type { Component } from 'solid-js';
import { A } from '@solidjs/router'; import { A, useLocation, useNavigate } from '@solidjs/router';
import { Show } from 'solid-js';
import { authStore } from './store/authStore';
interface AppContainerProps { interface AppContainerProps {
children?: any; children?: any;
} }
const AppContainer: Component<AppContainerProps> = (props) => { const AppContainer: Component<AppContainerProps> = (props) => {
const location = useLocation();
const navigate = useNavigate();
const handleLogout = () => {
authStore.logout();
navigate('/login');
};
return ( return (
<div class="min-h-screen bg-gray-50/50"> <div class="min-h-screen bg-gray-50/50">
{/* Top Navigation Bar */} {/* Top Navigation Bar */}
<Show when={location.pathname !== '/login'}>
<header class="bg-white border-b border-gray-200 sticky top-0 z-10 no-print"> <header class="bg-white border-b border-gray-200 sticky top-0 z-10 no-print">
<div class="max-w-[1800px] w-full mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-[1800px] w-full mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16"> <div class="flex justify-between h-16">
@@ -41,9 +52,18 @@ const AppContainer: Component<AppContainerProps> = (props) => {
</A> </A>
</nav> </nav>
</div> </div>
<div class="flex items-center gap-4">
<button
onClick={handleLogout}
class="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 shadow-sm transition-colors"
>
Sign out
</button>
</div>
</div> </div>
</div> </div>
</header> </header>
</Show>
{/* Main Content Area */} {/* Main Content Area */}
<main class="max-w-[1800px] w-full mx-auto p-4 sm:p-6 lg:p-8"> <main class="max-w-[1800px] w-full mx-auto p-4 sm:p-6 lg:p-8">
+101 -25
View File
@@ -7,7 +7,7 @@ import IgnoredLinesList from './components/IgnoredLinesList';
import Toast from './components/Toast'; import Toast from './components/Toast';
import { extractItemsFromCSV } from './utils/csv-parser'; import { extractItemsFromCSV } from './utils/csv-parser';
import type { ExtractedItem } from './utils/csv-parser'; import type { ExtractedItem } from './utils/csv-parser';
import { FileSpreadsheet, Download } from 'lucide-solid'; import { FileSpreadsheet, Download, Plus, FilePlus, ArrowLeft } from 'lucide-solid';
import { appStore } from './store/appStore'; import { appStore } from './store/appStore';
import CloudLoadModal from './components/CloudLoadModal'; import CloudLoadModal from './components/CloudLoadModal';
@@ -23,10 +23,12 @@ const ItemExtractor: Component<ItemExtractorProps> = () => {
const ignoredLines = appStore.ignoredLines; const ignoredLines = appStore.ignoredLines;
const setIgnoredLines = appStore.setIgnoredLines; const setIgnoredLines = appStore.setIgnoredLines;
const estimateItems = appStore.estimateItems; const estimateItems = appStore.estimateItems;
const scopes = appStore.scopes;
const [showToast] = createSignal(false); const [showToast] = createSignal(false);
const [toastMessage] = createSignal(''); const [toastMessage] = createSignal('');
const [showLoadModal, setShowLoadModal] = createSignal(false); const [showLoadModal, setShowLoadModal] = createSignal(false);
const [mode, setMode] = createSignal<'initial' | 'new-selection'>('initial');
const handleFileSelect = (csvText: string) => { const handleFileSelect = (csvText: string) => {
const { items: extracted, ignored } = extractItemsFromCSV(csvText); const { items: extracted, ignored } = extractItemsFromCSV(csvText);
@@ -34,42 +36,116 @@ const ItemExtractor: Component<ItemExtractorProps> = () => {
setIgnoredLines(ignored); setIgnoredLines(ignored);
}; };
const handleNewBlankEstimate = () => {
appStore.resetEstimate();
// Add a default empty scope to trigger the builder view
appStore.setScopes([{
id: Math.random().toString(36).substr(2, 9),
name: 'Main Scope',
managementFee: 0,
managementFeeType: 'percent',
managementFeeQuantity: 0,
managementFeeRate: 0,
deliveryFee: 0,
deliveryFeeType: 'percent',
deliveryFeeQuantity: 0,
deliveryFeeRate: 0,
useManagementLogic: 'simple',
useDeliveryLogic: 'simple'
}]);
};
return ( return (
<div class="flex flex-col items-center py-10 px-4 font-sans border border-gray-200 rounded-xl bg-gray-50/50 backdrop-blur-sm print:bg-transparent print:border-none print:p-0"> <div class="flex flex-col items-center py-10 px-4 font-sans border border-gray-200 rounded-xl bg-gray-50/50 backdrop-blur-sm print:bg-transparent print:border-none print:p-0">
{/* Header - Hidden after upload or load */} {/* Header - Hidden after upload or load */}
<Show when={items().length === 0 && estimateItems.length === 0}> <Show when={items().length === 0 && estimateItems.length === 0 && scopes.length === 0}>
<div class="max-w-4xl w-full bg-white rounded-xl shadow-sm p-8 mb-8 border border-gray-100 no-print"> <div class="max-w-4xl w-full bg-white rounded-xl shadow-sm p-8 mb-8 border border-gray-100 no-print">
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-3xl font-bold text-gray-800 mb-2">Item Parser & Extractor</h1>
<p class="text-gray-600">Upload your CSV to extract "Item" fields and split quantities.</p>
</div>
<div class="text-blue-600 text-4xl">
<FileSpreadsheet class="w-12 h-12" />
</div>
</div>
<FileUpload onFileSelect={handleFileSelect} /> <Show when={mode() === 'initial'}>
<div class="flex flex-col items-center justify-center space-y-8 py-12">
<div class="text-center mb-4">
<h1 class="text-4xl font-black text-gray-900 mb-3 tracking-tight lowercase">EstiMaker</h1>
<p class="text-gray-500 text-lg">Start a new project or continue where you left off.</p>
</div>
<div class="mt-8 flex flex-col items-center justify-center"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6 w-full max-w-2xl">
<div class="flex items-center w-full max-w-sm mb-6"> <button
<div class="flex-1 h-px bg-gray-200"></div> onClick={() => setMode('new-selection')}
<span class="px-4 text-[10px] font-black text-gray-400 uppercase tracking-[0.2em]">OR</span> class="flex flex-col items-center gap-4 p-8 bg-white border-2 border-dashed border-gray-200 rounded-3xl hover:border-blue-400 hover:bg-blue-50/30 transition-all group"
<div class="flex-1 h-px bg-gray-200"></div> >
<div class="w-16 h-16 rounded-2xl bg-blue-50 flex items-center justify-center text-blue-600 group-hover:scale-110 transition-transform">
<Plus class="w-8 h-8" />
</div>
<div class="text-center">
<h3 class="text-xl font-bold text-gray-800">New Estimate</h3>
<p class="text-gray-500 text-sm mt-1">Create from CSV or scratch</p>
</div>
</button>
<button
onClick={() => setShowLoadModal(true)}
class="flex flex-col items-center gap-4 p-8 bg-white border-2 border-dashed border-gray-200 rounded-3xl hover:border-green-400 hover:bg-green-50/30 transition-all group"
>
<div class="w-16 h-16 rounded-2xl bg-green-50 flex items-center justify-center text-green-600 group-hover:scale-110 transition-transform">
<Download class="w-8 h-8" />
</div>
<div class="text-center">
<h3 class="text-xl font-bold text-gray-800">Load Estimate</h3>
<p class="text-gray-500 text-sm mt-1">Open from cloud storage</p>
</div>
</button>
</div>
</div> </div>
<button </Show>
onClick={() => setShowLoadModal(true)}
class="flex items-center justify-center gap-3 w-full max-w-sm px-6 py-4 bg-white text-gray-700 rounded-2xl font-bold shadow-sm border border-gray-200 hover:bg-gray-50 hover:border-gray-300 hover:shadow transition-all" <Show when={mode() === 'new-selection'}>
> <div class="relative">
<Download class="w-5 h-5 text-blue-600" /> Load Saved Estimate <button
</button> onClick={() => setMode('initial')}
</div> class="absolute -top-4 -left-4 p-2 text-gray-400 hover:text-gray-600 transition-colors"
>
<ArrowLeft class="w-6 h-6" />
</button>
<div class="flex items-center justify-between mb-8 pt-4">
<div>
<h1 class="text-3xl font-bold text-gray-800 mb-2">Create New Estimate</h1>
<p class="text-gray-600">Choose how you want to start your estimate.</p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div class="p-6 bg-blue-50/50 rounded-2xl border border-blue-100">
<div class="flex items-center gap-3 mb-4">
<FileSpreadsheet class="w-6 h-6 text-blue-600" />
<h3 class="font-bold text-gray-900">Item Extractor</h3>
</div>
<p class="text-sm text-gray-600 mb-6">Upload a CSV file to automatically extract items and quantities.</p>
<FileUpload onFileSelect={handleFileSelect} />
</div>
<button
onClick={handleNewBlankEstimate}
class="flex flex-col items-start p-6 bg-gray-50 rounded-2xl border border-gray-200 hover:border-gray-300 hover:bg-white transition-all text-left group"
>
<div class="flex items-center gap-3 mb-4">
<FilePlus class="w-6 h-6 text-gray-600" />
<h3 class="font-bold text-gray-900">Blank Estimate</h3>
</div>
<p class="text-sm text-gray-600 mb-6">Start with a completely empty slate and build manually.</p>
<div class="mt-auto w-full py-3 bg-white border border-gray-200 rounded-xl text-center font-bold text-gray-700 group-hover:bg-gray-50 transition-colors">
Start Blank
</div>
</button>
</div>
</div>
</Show>
</div> </div>
</Show> </Show>
{/* Results */} {/* Results */}
<Show <Show
when={items().length > 0 || estimateItems.length > 0} when={items().length > 0 || estimateItems.length > 0 || scopes.length > 0}
fallback={ fallback={
<div class="text-gray-400 text-center mt-12 opacity-50"> <div class="text-gray-400 text-center mt-12 opacity-50">
<p>Results will appear here</p> <p>Results will appear here</p>
+7 -1
View File
@@ -1,7 +1,7 @@
import type { Component } from 'solid-js'; import type { Component } from 'solid-js';
import { createSignal, Show, For, createEffect } from 'solid-js'; import { createSignal, Show, For, createEffect } from 'solid-js';
import { Portal } from 'solid-js/web'; import { Portal } from 'solid-js/web';
import { X } from 'lucide-solid'; import { X, User } from 'lucide-solid';
import { pb, COLLECTIONS } from '../utils/db'; import { pb, COLLECTIONS } from '../utils/db';
import { appStore } from '../store/appStore'; import { appStore } from '../store/appStore';
import { normalizeScopes } from '../utils/scope-utils'; import { normalizeScopes } from '../utils/scope-utils';
@@ -85,6 +85,12 @@ const CloudLoadModal: Component<CloudLoadModalProps> = (props) => {
<p class="text-[10px] text-muted-foreground font-medium uppercase tracking-wider"> <p class="text-[10px] text-muted-foreground font-medium uppercase tracking-wider">
{new Date(record.created).toLocaleDateString()} {new Date(record.created).toLocaleDateString()}
</p> </p>
<Show when={record.items?.savedBy}>
<div class="flex items-center gap-1.5 mt-1.5 grayscale opacity-70">
<User class="w-3 h-3 text-muted-foreground" />
<span class="text-[10px] font-bold text-muted-foreground uppercase tracking-tight">{record.items.savedBy}</span>
</div>
</Show>
</div> </div>
<button <button
onClick={() => loadFromCloud(record)} onClick={() => loadFromCloud(record)}
@@ -1,6 +1,6 @@
import { Show, For } from 'solid-js'; import { Show, For } from 'solid-js';
import { Portal } from 'solid-js/web'; import { Portal } from 'solid-js/web';
import { X, History, CheckCircle2, Clock } from 'lucide-solid'; import { X, History, CheckCircle2, Clock, User } from 'lucide-solid';
import { appStore } from '../../store/appStore'; import { appStore } from '../../store/appStore';
interface HistoryModalProps { interface HistoryModalProps {
@@ -59,6 +59,12 @@ const HistoryModal = (props: HistoryModalProps) => {
<p class="text-[10px] text-muted-foreground font-medium uppercase tracking-wider mt-1"> <p class="text-[10px] text-muted-foreground font-medium uppercase tracking-wider mt-1">
The current tip of the project The current tip of the project
</p> </p>
<Show when={appStore.latestSnapshot()?.savedBy}>
<div class="flex items-center gap-1.5 mt-1.5 grayscale opacity-70">
<User class="w-3 h-3 text-muted-foreground" />
<span class="text-[10px] font-bold text-muted-foreground uppercase tracking-tight">Saved by {appStore.latestSnapshot().savedBy}</span>
</div>
</Show>
</div> </div>
</div> </div>
@@ -102,6 +108,11 @@ const HistoryModal = (props: HistoryModalProps) => {
{sum.fieldChanges} field adjustments {sum.fieldChanges} field adjustments
</p> </p>
</Show> </Show>
<Show when={sum.scopeMetadataUpdated}>
<p class="text-[9px] text-blue-600 ml-1 font-bold">
Scope Description/Notes updated
</p>
</Show>
</div> </div>
)} )}
</For> </For>
@@ -128,6 +139,12 @@ const HistoryModal = (props: HistoryModalProps) => {
<p class="text-[10px] text-muted-foreground font-medium uppercase tracking-wider mt-1"> <p class="text-[10px] text-muted-foreground font-medium uppercase tracking-wider mt-1">
{new Date(snapshot.timestamp || Date.now()).toLocaleString()} {new Date(snapshot.timestamp || Date.now()).toLocaleString()}
</p> </p>
<Show when={snapshot.savedBy}>
<div class="flex items-center gap-1.5 mt-1.5 grayscale opacity-70">
<User class="w-3 h-3 text-muted-foreground" />
<span class="text-[10px] font-bold text-muted-foreground uppercase tracking-tight">Saved by {snapshot.savedBy}</span>
</div>
</Show>
</div> </div>
</div> </div>
@@ -169,6 +186,11 @@ const HistoryModal = (props: HistoryModalProps) => {
{sum.fieldChanges} field adjustments {sum.fieldChanges} field adjustments
</p> </p>
</Show> </Show>
<Show when={sum.scopeMetadataUpdated}>
<p class="text-[9px] text-blue-600 ml-1 font-bold">
Scope Description/Notes updated
</p>
</Show>
</div> </div>
)} )}
</For> </For>
@@ -74,13 +74,14 @@ export function useEstimateSync(
jobInfo: { ...jobInfo }, jobInfo: { ...jobInfo },
generalEstimateNotes: generalEstimateNotes(), generalEstimateNotes: generalEstimateNotes(),
generalPreNotes: generalPreNotes(), generalPreNotes: generalPreNotes(),
timestamp: new Date().toISOString() timestamp: new Date().toISOString(),
savedBy: pb.authStore.model?.name || pb.authStore.model?.email || 'Unknown'
}; };
}; };
const calculateChangeSummary = (prevData: any, newData: any) => { const calculateChangeSummary = (prevData: any, newData: any) => {
const summary: Record<string, { scopeName: string, itemsAdded: number, itemsRemoved: number, itemsUpdated: number, fieldChanges: number }> = {}; const summary: Record<string, { scopeName: string, itemsAdded: number, itemsRemoved: number, itemsUpdated: number, fieldChanges: number, scopeMetadataUpdated: boolean }> = {};
const prevItems = prevData.items || []; const prevItems = prevData.items || [];
const newItems = newData.items || []; const newItems = newData.items || [];
@@ -104,6 +105,19 @@ export function useEstimateSync(
let removed = 0; let removed = 0;
let updated = 0; let updated = 0;
let fields = 0; let fields = 0;
let scopeMetadataUpdated = false;
// Check Scope Metadata Changes
const prevScope = (prevData.scopes || []).find((s: any) => s.id === scopeId);
const currentScope = (newData.scopes || []).find((s: any) => s.id === scopeId);
if (prevScope && currentScope) {
['bidDescription', 'estimatorNotes', 'name', 'managementFee', 'deliveryFee', 'useManagementLogic', 'useDeliveryLogic'].forEach(k => {
if (String(currentScope[k]) !== String(prevScope[k])) {
fields++;
scopeMetadataUpdated = true;
}
});
}
const scopePrevMap = new Map(scopePrevItems.map((i: any) => [i.id, i])); const scopePrevMap = new Map(scopePrevItems.map((i: any) => [i.id, i]));
const scopeNewMap = new Map(scopeNewItems.map((i: any) => [i.id, i])); const scopeNewMap = new Map(scopeNewItems.map((i: any) => [i.id, i]));
@@ -114,7 +128,7 @@ export function useEstimateSync(
added++; added++;
} else { } else {
let changed = false; let changed = false;
['description', 'quantity', 'unit', 'price', 'type'].forEach(k => { ['description', 'quantity', 'unitPrice', 'markup', 'markupType', 'contingency', 'contingencyType'].forEach(k => {
const v1 = (item as any)[k]; const v1 = (item as any)[k];
const v2 = (prev as any)[k]; const v2 = (prev as any)[k];
if (String(v1) !== String(v2)) { if (String(v1) !== String(v2)) {
@@ -132,8 +146,15 @@ export function useEstimateSync(
} }
}); });
if (added > 0 || removed > 0 || updated > 0 || fields > 0) { if (added > 0 || removed > 0 || updated > 0 || fields > 0 || scopeMetadataUpdated) {
(summary as any)[scopeId] = { scopeName, itemsAdded: added, itemsRemoved: removed, itemsUpdated: updated, fieldChanges: fields }; summary[scopeId] = {
scopeName: String(scopeName),
itemsAdded: added,
itemsRemoved: removed,
itemsUpdated: updated,
fieldChanges: fields,
scopeMetadataUpdated
};
} }
}); });
@@ -147,7 +168,8 @@ export function useEstimateSync(
const record = await pb.collection(COLLECTIONS.ESTIMATES).create({ const record = await pb.collection(COLLECTIONS.ESTIMATES).create({
name, name,
items: { ...data, changeSummary: null }, items: { ...data, changeSummary: null },
history: [] history: [],
userId: pb.authStore.model?.id
}); });
setShowSaveModal(false); setShowSaveModal(false);
@@ -184,7 +206,8 @@ export function useEstimateSync(
await pb.collection(COLLECTIONS.ESTIMATES).update(id, { await pb.collection(COLLECTIONS.ESTIMATES).update(id, {
items: newTip, items: newTip,
history: [...updatedHistory, record.items] history: [...updatedHistory, record.items],
userId: pb.authStore.model?.id
}); });
setHistory([...updatedHistory, record.items]); setHistory([...updatedHistory, record.items]);
+25 -2
View File
@@ -1,10 +1,13 @@
import { render } from 'solid-js/web'; import { render } from 'solid-js/web';
import { Router, Route } from '@solidjs/router'; import { Router, Route, useNavigate, useLocation } from '@solidjs/router';
import { createEffect, Show } from 'solid-js';
import './index.css'; import './index.css';
import AppContainer from './AppContainer'; import AppContainer from './AppContainer';
import ItemExtractor from './ItemExtractor'; import ItemExtractor from './ItemExtractor';
import TemplateCreator from './views/TemplateCreator'; import TemplateCreator from './views/TemplateCreator';
import StandardPricing from './views/StandardPricing'; import StandardPricing from './views/StandardPricing';
import Login from './views/Login';
import { authStore } from './store/authStore';
const root = document.getElementById('root'); const root = document.getElementById('root');
@@ -14,8 +17,28 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
); );
} }
function AuthGuard(props: { children: any }) {
const navigate = useNavigate();
const location = useLocation();
createEffect(() => {
if (!authStore.isAuthenticated() && location.pathname !== '/login') {
navigate('/login', { replace: true });
} else if (authStore.isAuthenticated() && location.pathname === '/login') {
navigate('/', { replace: true });
}
});
return (
<Show when={authStore.isAuthenticated() || location.pathname === '/login'}>
{props.children}
</Show>
);
}
render(() => ( render(() => (
<Router root={AppContainer}> <Router root={(props) => <AuthGuard><AppContainer>{props.children}</AppContainer></AuthGuard>}>
<Route path="/login" component={Login} />
<Route path="/" component={ItemExtractor as any} /> <Route path="/" component={ItemExtractor as any} />
<Route path="/templates" component={TemplateCreator} /> <Route path="/templates" component={TemplateCreator} />
<Route path="/standard-pricing" component={StandardPricing} /> <Route path="/standard-pricing" component={StandardPricing} />
+22
View File
@@ -0,0 +1,22 @@
import { createSignal } from 'solid-js';
import { pb } from '../utils/db';
const [isAuthenticated, setIsAuthenticated] = createSignal(pb.authStore.isValid);
const [currentUser, setCurrentUser] = createSignal(pb.authStore.model);
// Listen for auth changes from PocketBase
pb.authStore.onChange((_, model) => {
setIsAuthenticated(pb.authStore.isValid);
setCurrentUser(model);
});
export const authStore = {
isAuthenticated,
currentUser,
login: async (email: string, pass: string) => {
return await pb.collection('users').authWithPassword(email, pass);
},
logout: () => {
pb.authStore.clear();
}
};
+87
View File
@@ -0,0 +1,87 @@
import { createSignal } from 'solid-js';
import { useNavigate } from '@solidjs/router';
import { authStore } from '../store/authStore';
export default function Login() {
const [email, setEmail] = createSignal('');
const [password, setPassword] = createSignal('');
const [error, setError] = createSignal('');
const [isLoading, setIsLoading] = createSignal(false);
const navigate = useNavigate();
const handleLogin = async (e: Event) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await authStore.login(email(), password());
// Successful login, navigate to home
navigate('/', { replace: true });
} catch (err: any) {
console.error('Login error:', err);
setError(err.message || 'Failed to login. Please check your credentials.');
} finally {
setIsLoading(false);
}
};
return (
<div class="min-h-[80vh] flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8 bg-white p-8 rounded-xl shadow-lg border border-gray-100">
<div>
<h2 class="mt-2 text-center text-3xl font-extrabold text-gray-900 tracking-tight">
Sign in to EstiMaker
</h2>
</div>
<form class="mt-8 space-y-6" onSubmit={handleLogin}>
{error() && (
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md relative text-sm" role="alert">
<span class="block sm:inline">{error()}</span>
</div>
)}
<div class="rounded-md shadow-sm -space-y-px">
<div>
<label for="email-address" class="sr-only">Email address</label>
<input
id="email-address"
name="email"
type="email"
autocomplete="email"
required
class="appearance-none rounded-none relative block w-full px-3 py-2.5 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Email address"
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
/>
</div>
<div>
<label for="password" class="sr-only">Password</label>
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
required
class="appearance-none rounded-none relative block w-full px-3 py-2.5 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="Password"
value={password()}
onInput={(e) => setPassword(e.currentTarget.value)}
/>
</div>
</div>
<div>
<button
type="submit"
disabled={isLoading()}
class="group relative w-full flex justify-center py-2.5 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm transition-colors"
>
{isLoading() ? 'Signing in...' : 'Sign in'}
</button>
</div>
</form>
</div>
</div>
);
}