@crmy/web 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.html +23 -0
- package/package.json +76 -0
- package/postcss.config.js +6 -0
- package/public/android-chrome-192x192.png +0 -0
- package/public/android-chrome-512x512.png +0 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon-16x16.png +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +13 -0
- package/public/site.webmanifest +1 -0
- package/src/App.tsx +158 -0
- package/src/api/client.ts +82 -0
- package/src/api/hooks.ts +689 -0
- package/src/assets/crmy-logo.png +0 -0
- package/src/components/CustomFields.tsx +240 -0
- package/src/components/NavLink.tsx +28 -0
- package/src/components/crm/AIFab.tsx +37 -0
- package/src/components/crm/AccountDrawer.tsx +372 -0
- package/src/components/crm/ActivityTimeline.tsx +115 -0
- package/src/components/crm/AssignmentDrawer.tsx +396 -0
- package/src/components/crm/BriefingPanel.tsx +217 -0
- package/src/components/crm/CommandPalette.tsx +254 -0
- package/src/components/crm/ContactAvatar.tsx +49 -0
- package/src/components/crm/ContactDrawer.tsx +438 -0
- package/src/components/crm/ContextPanel.tsx +200 -0
- package/src/components/crm/CrmWidgets.tsx +417 -0
- package/src/components/crm/DrawerShell.tsx +77 -0
- package/src/components/crm/ListToolbar.tsx +252 -0
- package/src/components/crm/OpportunityDrawer.tsx +372 -0
- package/src/components/crm/PaginationBar.tsx +111 -0
- package/src/components/crm/QuickAddDrawer.tsx +652 -0
- package/src/components/crm/ShortcutsOverlay.tsx +65 -0
- package/src/components/crm/UseCaseDrawer.tsx +454 -0
- package/src/components/layout/MobileNav.tsx +49 -0
- package/src/components/layout/Sidebar.tsx +157 -0
- package/src/components/layout/TopBar.tsx +54 -0
- package/src/components/settings/ActorsSettings.tsx +1190 -0
- package/src/components/ui/accordion.tsx +52 -0
- package/src/components/ui/alert-dialog.tsx +104 -0
- package/src/components/ui/alert.tsx +43 -0
- package/src/components/ui/aspect-ratio.tsx +5 -0
- package/src/components/ui/avatar.tsx +38 -0
- package/src/components/ui/badge.tsx +29 -0
- package/src/components/ui/breadcrumb.tsx +90 -0
- package/src/components/ui/button.tsx +47 -0
- package/src/components/ui/calendar.tsx +54 -0
- package/src/components/ui/card.tsx +43 -0
- package/src/components/ui/carousel.tsx +224 -0
- package/src/components/ui/chart.tsx +303 -0
- package/src/components/ui/checkbox.tsx +26 -0
- package/src/components/ui/collapsible.tsx +9 -0
- package/src/components/ui/command.tsx +132 -0
- package/src/components/ui/context-menu.tsx +178 -0
- package/src/components/ui/date-picker.tsx +313 -0
- package/src/components/ui/dialog.tsx +95 -0
- package/src/components/ui/drawer.tsx +87 -0
- package/src/components/ui/dropdown-menu.tsx +179 -0
- package/src/components/ui/form.tsx +129 -0
- package/src/components/ui/hover-card.tsx +27 -0
- package/src/components/ui/input-otp.tsx +61 -0
- package/src/components/ui/input.tsx +22 -0
- package/src/components/ui/label.tsx +17 -0
- package/src/components/ui/menubar.tsx +207 -0
- package/src/components/ui/navigation-menu.tsx +120 -0
- package/src/components/ui/pagination.tsx +81 -0
- package/src/components/ui/popover.tsx +29 -0
- package/src/components/ui/progress.tsx +23 -0
- package/src/components/ui/radio-group.tsx +36 -0
- package/src/components/ui/resizable.tsx +37 -0
- package/src/components/ui/scroll-area.tsx +38 -0
- package/src/components/ui/select.tsx +143 -0
- package/src/components/ui/separator.tsx +20 -0
- package/src/components/ui/sheet.tsx +107 -0
- package/src/components/ui/sidebar.tsx +637 -0
- package/src/components/ui/skeleton.tsx +7 -0
- package/src/components/ui/slider.tsx +23 -0
- package/src/components/ui/sonner.tsx +24 -0
- package/src/components/ui/switch.tsx +27 -0
- package/src/components/ui/table.tsx +72 -0
- package/src/components/ui/tabs.tsx +53 -0
- package/src/components/ui/textarea.tsx +21 -0
- package/src/components/ui/toast.tsx +111 -0
- package/src/components/ui/toaster.tsx +24 -0
- package/src/components/ui/toggle-group.tsx +49 -0
- package/src/components/ui/toggle.tsx +37 -0
- package/src/components/ui/tooltip.tsx +28 -0
- package/src/components/ui/use-toast.ts +1 -0
- package/src/components/ui/utils.ts +9 -0
- package/src/contexts/AgentSettingsContext.tsx +24 -0
- package/src/hooks/use-mobile.tsx +19 -0
- package/src/hooks/use-toast.ts +186 -0
- package/src/hooks/useKeyboardShortcuts.ts +95 -0
- package/src/hooks/useTheme.ts +24 -0
- package/src/index.css +245 -0
- package/src/lib/entityColors.ts +18 -0
- package/src/lib/stageConfig.ts +32 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.tsx +25 -0
- package/src/pages/Accounts.tsx +205 -0
- package/src/pages/Activities.tsx +251 -0
- package/src/pages/Agent.tsx +237 -0
- package/src/pages/AgentSettings.tsx +544 -0
- package/src/pages/Assignments.tsx +750 -0
- package/src/pages/Contacts.tsx +200 -0
- package/src/pages/Dashboard.tsx +143 -0
- package/src/pages/Inbox.tsx +615 -0
- package/src/pages/NotFound.tsx +24 -0
- package/src/pages/Opportunities.tsx +386 -0
- package/src/pages/SearchResults.tsx +49 -0
- package/src/pages/Settings.tsx +1884 -0
- package/src/pages/UseCases.tsx +396 -0
- package/src/pages/auth/Login.tsx +261 -0
- package/src/pages/hitl/HITL.tsx +101 -0
- package/src/store/appStore.ts +103 -0
- package/src/vite-env.d.ts +14 -0
- package/tailwind.config.js +121 -0
- package/tsconfig.json +24 -0
- package/vite.config.ts +27 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// Copyright 2026 CRMy Contributors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { useState, useMemo, useEffect } from 'react';
|
|
5
|
+
import { useNavigate } from 'react-router-dom';
|
|
6
|
+
import { TopBar } from '@/components/layout/TopBar';
|
|
7
|
+
import { useUseCases } from '@/api/hooks';
|
|
8
|
+
import { useAppStore } from '@/store/appStore';
|
|
9
|
+
import { useAgentSettings } from '@/contexts/AgentSettingsContext';
|
|
10
|
+
import { ListToolbar, type FilterConfig, type SortOption } from '@/components/crm/ListToolbar';
|
|
11
|
+
import { DatePicker } from '@/components/ui/date-picker';
|
|
12
|
+
import { motion } from 'framer-motion';
|
|
13
|
+
import { Columns3, List, BarChart3, Plus, Sparkles, ChevronUp, ChevronDown } from 'lucide-react';
|
|
14
|
+
import { PaginationBar } from '@/components/crm/PaginationBar';
|
|
15
|
+
import { useCaseStageConfig } from '@/lib/stageConfig';
|
|
16
|
+
|
|
17
|
+
type ViewMode = 'kanban' | 'table' | 'dashboard';
|
|
18
|
+
const kanbanStages = ['discovery', 'poc', 'production', 'scaling', 'sunset'];
|
|
19
|
+
|
|
20
|
+
type ProdDatePreset = 'all' | 'today' | 'this_week' | 'this_month' | 'this_quarter' | 'custom';
|
|
21
|
+
|
|
22
|
+
const PROD_DATE_OPTIONS: { value: ProdDatePreset; label: string }[] = [
|
|
23
|
+
{ value: 'all', label: 'All' },
|
|
24
|
+
{ value: 'today', label: 'Today' },
|
|
25
|
+
{ value: 'this_week', label: 'This Week' },
|
|
26
|
+
{ value: 'this_month', label: 'This Month' },
|
|
27
|
+
{ value: 'this_quarter', label: 'This Quarter' },
|
|
28
|
+
{ value: 'custom', label: 'Custom' },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
function getProdDateRange(preset: ProdDatePreset): { start: Date; end: Date } | null {
|
|
32
|
+
if (preset === 'all') return null;
|
|
33
|
+
const now = new Date();
|
|
34
|
+
if (preset === 'today') {
|
|
35
|
+
const start = new Date(now); start.setHours(0, 0, 0, 0);
|
|
36
|
+
const end = new Date(now); end.setHours(23, 59, 59, 999);
|
|
37
|
+
return { start, end };
|
|
38
|
+
}
|
|
39
|
+
if (preset === 'this_week') {
|
|
40
|
+
const start = new Date(now);
|
|
41
|
+
const day = start.getDay();
|
|
42
|
+
start.setDate(start.getDate() + (day === 0 ? -6 : 1 - day));
|
|
43
|
+
start.setHours(0, 0, 0, 0);
|
|
44
|
+
const end = new Date(start); end.setDate(start.getDate() + 6); end.setHours(23, 59, 59, 999);
|
|
45
|
+
return { start, end };
|
|
46
|
+
}
|
|
47
|
+
if (preset === 'this_month') {
|
|
48
|
+
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
49
|
+
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999);
|
|
50
|
+
return { start, end };
|
|
51
|
+
}
|
|
52
|
+
if (preset === 'this_quarter') {
|
|
53
|
+
const q = Math.floor(now.getMonth() / 3);
|
|
54
|
+
const start = new Date(now.getFullYear(), q * 3, 1);
|
|
55
|
+
const end = new Date(now.getFullYear(), q * 3 + 3, 0, 23, 59, 59, 999);
|
|
56
|
+
return { start, end };
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const filterConfigs: FilterConfig[] = [
|
|
62
|
+
{ key: 'stage', label: 'Stage', options: kanbanStages.map(k => ({ value: k, label: useCaseStageConfig[k]?.label ?? k })) },
|
|
63
|
+
];
|
|
64
|
+
const sortOptions: SortOption[] = [
|
|
65
|
+
{ key: 'name', label: 'Name' }, { key: 'stage', label: 'Stage' },
|
|
66
|
+
{ key: 'attributed_arr', label: 'ARR' }, { key: 'health_score', label: 'Health' },
|
|
67
|
+
{ key: 'target_prod_date', label: 'Prod Date' }, { key: 'created_at', label: 'Created' },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
71
|
+
type UseCase = any;
|
|
72
|
+
|
|
73
|
+
export default function UseCases() {
|
|
74
|
+
const { openDrawer, openQuickAdd, openAIWithContext } = useAppStore();
|
|
75
|
+
const { enabled: agentEnabled } = useAgentSettings();
|
|
76
|
+
const navigate = useNavigate();
|
|
77
|
+
const [view, setView] = useState<ViewMode>('table');
|
|
78
|
+
const [search, setSearch] = useState('');
|
|
79
|
+
const [activeFilters, setActiveFilters] = useState<Record<string, string[]>>({});
|
|
80
|
+
const [sort, setSort] = useState<{ key: string; dir: 'asc' | 'desc' } | null>(null);
|
|
81
|
+
const [prodDate, setProdDate] = useState<ProdDatePreset>('all');
|
|
82
|
+
const [customFrom, setCustomFrom] = useState('');
|
|
83
|
+
const [customTo, setCustomTo] = useState('');
|
|
84
|
+
const [page, setPage] = useState(1);
|
|
85
|
+
const [pageSize, setPageSize] = useState(25);
|
|
86
|
+
|
|
87
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
88
|
+
const { data, isLoading } = useUseCases({ q: search || undefined, limit: 200 }) as any;
|
|
89
|
+
const allUseCases: UseCase[] = data?.data ?? [];
|
|
90
|
+
|
|
91
|
+
const handleFilterChange = (key: string, values: string[]) => {
|
|
92
|
+
setActiveFilters(prev => { const next = { ...prev }; if (values.length === 0) delete next[key]; else next[key] = values; return next; });
|
|
93
|
+
};
|
|
94
|
+
const handleSortChange = (key: string) => {
|
|
95
|
+
setSort(prev => prev?.key === key ? { key, dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: 'asc' });
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const filtered = useMemo(() => {
|
|
99
|
+
let result = [...allUseCases];
|
|
100
|
+
if (activeFilters.stage?.length) result = result.filter(u => activeFilters.stage.includes(u.stage as string));
|
|
101
|
+
|
|
102
|
+
// Production date filtering
|
|
103
|
+
if (prodDate !== 'all') {
|
|
104
|
+
let start: Date, end: Date;
|
|
105
|
+
if (prodDate === 'custom') {
|
|
106
|
+
start = customFrom ? new Date(customFrom + 'T00:00:00') : new Date(0);
|
|
107
|
+
end = customTo ? new Date(customTo + 'T23:59:59') : new Date(8640000000000000);
|
|
108
|
+
} else {
|
|
109
|
+
const range = getProdDateRange(prodDate);
|
|
110
|
+
if (range) { start = range.start; end = range.end; }
|
|
111
|
+
else { start = new Date(0); end = new Date(8640000000000000); }
|
|
112
|
+
}
|
|
113
|
+
result = result.filter(d => {
|
|
114
|
+
if (!d.target_prod_date) return true; // unscheduled use cases always visible
|
|
115
|
+
const pd = new Date(d.target_prod_date);
|
|
116
|
+
return pd >= start && pd <= end;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (sort) {
|
|
121
|
+
result.sort((a, b) => {
|
|
122
|
+
const aVal = (a[sort.key] ?? '') as string | number;
|
|
123
|
+
const bVal = (b[sort.key] ?? '') as string | number;
|
|
124
|
+
if (typeof aVal === 'number' && typeof bVal === 'number') return sort.dir === 'asc' ? aVal - bVal : bVal - aVal;
|
|
125
|
+
return sort.dir === 'asc' ? String(aVal).localeCompare(String(bVal)) : String(bVal).localeCompare(String(aVal));
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
}, [allUseCases, activeFilters, sort, prodDate, customFrom, customTo]);
|
|
130
|
+
|
|
131
|
+
useEffect(() => { setPage(1); }, [search, activeFilters, sort, prodDate, customFrom, customTo]);
|
|
132
|
+
const paginated = filtered.slice((page - 1) * pageSize, page * pageSize);
|
|
133
|
+
|
|
134
|
+
const SortHeader = ({ label, sortKey }: { label: string; sortKey: string }) => (
|
|
135
|
+
<th onClick={() => handleSortChange(sortKey)} className="text-left px-4 py-3 text-xs font-display font-semibold text-muted-foreground cursor-pointer hover:text-foreground transition-colors select-none">
|
|
136
|
+
<span className="inline-flex items-center gap-1">
|
|
137
|
+
{label}
|
|
138
|
+
{sort?.key === sortKey && (sort.dir === 'asc' ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />)}
|
|
139
|
+
</span>
|
|
140
|
+
</th>
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<div className="flex flex-col h-full">
|
|
145
|
+
<TopBar title="Use Cases">
|
|
146
|
+
<div className="hidden md:flex items-center gap-1 bg-muted rounded-xl p-0.5">
|
|
147
|
+
{[
|
|
148
|
+
{ mode: 'kanban', icon: Columns3 },
|
|
149
|
+
{ mode: 'table', icon: List },
|
|
150
|
+
{ mode: 'dashboard', icon: BarChart3 },
|
|
151
|
+
].map(({ mode, icon: Icon }) => (
|
|
152
|
+
<button key={mode} onClick={() => setView(mode as ViewMode)}
|
|
153
|
+
className={`p-1.5 rounded-lg text-sm transition-all ${view === mode ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground'}`}>
|
|
154
|
+
<Icon className="w-4 h-4" />
|
|
155
|
+
</button>
|
|
156
|
+
))}
|
|
157
|
+
</div>
|
|
158
|
+
</TopBar>
|
|
159
|
+
|
|
160
|
+
{/* Production date selector */}
|
|
161
|
+
<div className="px-4 md:px-6 pt-3 pb-1 flex flex-wrap items-center gap-2">
|
|
162
|
+
<span className="text-xs text-muted-foreground font-semibold">Prod Date:</span>
|
|
163
|
+
<div className="inline-flex rounded-xl border border-border bg-muted/40 p-0.5 gap-0.5">
|
|
164
|
+
{PROD_DATE_OPTIONS.map(opt => (
|
|
165
|
+
<button key={opt.value} onClick={() => setProdDate(opt.value)}
|
|
166
|
+
className={['px-3 py-1.5 text-xs font-medium rounded-lg transition-all',
|
|
167
|
+
prodDate === opt.value ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
|
168
|
+
].join(' ')}>
|
|
169
|
+
{opt.label}
|
|
170
|
+
</button>
|
|
171
|
+
))}
|
|
172
|
+
</div>
|
|
173
|
+
{prodDate === 'custom' && (
|
|
174
|
+
<div className="flex items-center gap-2">
|
|
175
|
+
<DatePicker
|
|
176
|
+
value={customFrom}
|
|
177
|
+
onChange={setCustomFrom}
|
|
178
|
+
size="sm"
|
|
179
|
+
placeholder="From"
|
|
180
|
+
className="w-36"
|
|
181
|
+
/>
|
|
182
|
+
<span className="text-xs text-muted-foreground">to</span>
|
|
183
|
+
<DatePicker
|
|
184
|
+
value={customTo}
|
|
185
|
+
onChange={setCustomTo}
|
|
186
|
+
size="sm"
|
|
187
|
+
placeholder="To"
|
|
188
|
+
className="w-36"
|
|
189
|
+
/>
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
<ListToolbar searchValue={search} onSearchChange={setSearch} searchPlaceholder="Search use cases..."
|
|
195
|
+
filters={filterConfigs} activeFilters={activeFilters} onFilterChange={handleFilterChange}
|
|
196
|
+
onClearFilters={() => { setActiveFilters({}); setProdDate('all'); }} sortOptions={sortOptions} currentSort={sort}
|
|
197
|
+
onSortChange={handleSortChange} onAdd={() => openQuickAdd('use-case')} addLabel="New Use Case" entityType="use cases" />
|
|
198
|
+
|
|
199
|
+
<div className="flex-1 overflow-y-auto pb-24 md:pb-6">
|
|
200
|
+
{isLoading ? (
|
|
201
|
+
<div className="flex gap-4 px-4 md:px-6 pt-2">
|
|
202
|
+
{[...Array(4)].map((_, i) => <div key={i} className="flex-shrink-0 w-72 h-64 bg-muted/50 rounded-2xl animate-pulse" />)}
|
|
203
|
+
</div>
|
|
204
|
+
) : view === 'kanban' ? (
|
|
205
|
+
<>
|
|
206
|
+
<div className="border-t border-border mb-4" />
|
|
207
|
+
<div className="flex gap-3 md:gap-4 px-4 md:px-6 pb-4 overflow-x-auto min-h-full snap-x snap-mandatory md:snap-none no-scrollbar">
|
|
208
|
+
{kanbanStages.map((stage) => {
|
|
209
|
+
const config = useCaseStageConfig[stage] ?? { label: stage, color: '#94a3b8' };
|
|
210
|
+
const stageUCs = filtered.filter((u) => u.stage === stage);
|
|
211
|
+
const totalArr = stageUCs.reduce((sum, u) => sum + ((u.attributed_arr as number) || 0), 0);
|
|
212
|
+
return (
|
|
213
|
+
<div key={stage} className="flex-shrink-0 w-[280px] md:w-72 flex flex-col snap-center">
|
|
214
|
+
<div className="flex items-center justify-between mb-3 px-1">
|
|
215
|
+
<div className="flex items-center gap-2">
|
|
216
|
+
<span className="px-2.5 py-1 rounded-lg text-xs font-semibold" style={{ backgroundColor: config.color + '20', color: config.color }}>
|
|
217
|
+
{config.label}
|
|
218
|
+
</span>
|
|
219
|
+
<span className="text-xs text-muted-foreground font-mono">{stageUCs.length}</span>
|
|
220
|
+
</div>
|
|
221
|
+
{totalArr > 0 && <span className="text-xs text-muted-foreground font-mono">${(totalArr / 1000).toFixed(0)}K ARR</span>}
|
|
222
|
+
</div>
|
|
223
|
+
<div className="flex-1 space-y-2">
|
|
224
|
+
{stageUCs.map((uc, i) => {
|
|
225
|
+
const client = (uc.account_name ?? uc.client ?? '') as string;
|
|
226
|
+
const arr = (uc.attributed_arr as number) ?? 0;
|
|
227
|
+
const health = (uc.health_score as number) ?? 0;
|
|
228
|
+
return (
|
|
229
|
+
<motion.div key={uc.id as string} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: i * 0.03 }}
|
|
230
|
+
onClick={() => openDrawer('use-case', uc.id as string)}
|
|
231
|
+
className="bg-card border border-border rounded-2xl p-3.5 cursor-pointer hover:border-primary/30 hover:shadow-md transition-all press-scale group">
|
|
232
|
+
<div className="flex items-start justify-between">
|
|
233
|
+
<p className="text-sm font-display font-bold text-foreground">{uc.name as string}</p>
|
|
234
|
+
{agentEnabled && (
|
|
235
|
+
<button onClick={(e) => { e.stopPropagation(); openAIWithContext({ type: 'use-case', id: uc.id as string, name: uc.name as string, detail: client }); navigate('/agent'); }}
|
|
236
|
+
className="p-0.5 rounded-lg md:opacity-0 md:group-hover:opacity-100 hover:bg-accent/10 transition-all">
|
|
237
|
+
<Sparkles className="w-3.5 h-3.5 text-accent" />
|
|
238
|
+
</button>
|
|
239
|
+
)}
|
|
240
|
+
</div>
|
|
241
|
+
{client && <p className="text-xs text-muted-foreground mt-1">{client}</p>}
|
|
242
|
+
<div className="flex items-center justify-between mt-2.5">
|
|
243
|
+
{arr > 0 ? (
|
|
244
|
+
<span className="text-sm font-display font-extrabold text-foreground">
|
|
245
|
+
${arr >= 1000 ? `${(arr / 1000).toFixed(0)}K` : arr}
|
|
246
|
+
</span>
|
|
247
|
+
) : <span />}
|
|
248
|
+
{health > 0 && (
|
|
249
|
+
<span className={`px-2 py-0.5 rounded-lg text-xs font-semibold ${health >= 80 ? 'bg-green-500/15 text-green-400' : health >= 50 ? 'bg-yellow-500/15 text-yellow-400' : 'bg-red-500/15 text-red-400'}`}>
|
|
250
|
+
{health}
|
|
251
|
+
</span>
|
|
252
|
+
)}
|
|
253
|
+
</div>
|
|
254
|
+
</motion.div>
|
|
255
|
+
);
|
|
256
|
+
})}
|
|
257
|
+
<button onClick={() => openQuickAdd('use-case')}
|
|
258
|
+
className="w-full flex items-center justify-center gap-1 py-2.5 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded-xl transition-colors press-scale">
|
|
259
|
+
<Plus className="w-3.5 h-3.5" /> Add Use Case
|
|
260
|
+
</button>
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
})}
|
|
265
|
+
</div>
|
|
266
|
+
</>
|
|
267
|
+
) : view === 'table' ? (
|
|
268
|
+
<div className="px-4 md:px-6">
|
|
269
|
+
{filtered.length === 0 ? (
|
|
270
|
+
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
|
271
|
+
<p className="text-sm">No use cases found.</p>
|
|
272
|
+
<button onClick={() => { setSearch(''); setActiveFilters({}); setProdDate('all'); }} className="mt-2 text-xs text-primary font-semibold hover:underline">Clear all filters</button>
|
|
273
|
+
</div>
|
|
274
|
+
) : (
|
|
275
|
+
<div className="bg-card border border-border rounded-2xl overflow-hidden shadow-sm">
|
|
276
|
+
<div className="overflow-x-auto">
|
|
277
|
+
<table className="w-full text-sm">
|
|
278
|
+
<thead>
|
|
279
|
+
<tr className="border-b border-border bg-surface-sunken/50">
|
|
280
|
+
<SortHeader label="Use Case" sortKey="name" />
|
|
281
|
+
<th className="text-left px-4 py-3 text-xs font-display font-semibold text-muted-foreground">Client</th>
|
|
282
|
+
<SortHeader label="Stage" sortKey="stage" />
|
|
283
|
+
<SortHeader label="ARR" sortKey="attributed_arr" />
|
|
284
|
+
<SortHeader label="Health" sortKey="health_score" />
|
|
285
|
+
<SortHeader label="Prod Date" sortKey="target_prod_date" />
|
|
286
|
+
<SortHeader label="Created" sortKey="created_at" />
|
|
287
|
+
{agentEnabled && <th className="px-2 py-3 w-8"></th>}
|
|
288
|
+
</tr>
|
|
289
|
+
</thead>
|
|
290
|
+
<tbody>
|
|
291
|
+
{paginated.map((uc, i) => {
|
|
292
|
+
const stage = (uc.stage ?? '') as string;
|
|
293
|
+
const config = useCaseStageConfig[stage] ?? { label: stage, color: '#94a3b8' };
|
|
294
|
+
const arr = (uc.attributed_arr ?? 0) as number;
|
|
295
|
+
const health = (uc.health_score ?? 0) as number;
|
|
296
|
+
const client = (uc.account_name ?? uc.client ?? '') as string;
|
|
297
|
+
return (
|
|
298
|
+
<tr key={uc.id as string} onClick={() => openDrawer('use-case', uc.id as string)}
|
|
299
|
+
className={`border-b border-border last:border-0 hover:bg-primary/5 cursor-pointer transition-colors group ${i % 2 === 1 ? 'bg-surface-sunken/30' : ''}`}>
|
|
300
|
+
<td className="px-4 py-3 font-display font-bold text-foreground">{uc.name as string}</td>
|
|
301
|
+
<td className="px-4 py-3 text-muted-foreground">{client || '—'}</td>
|
|
302
|
+
<td className="px-4 py-3">
|
|
303
|
+
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold"
|
|
304
|
+
style={{ backgroundColor: config.color + '18', color: config.color }}>
|
|
305
|
+
{config.label}
|
|
306
|
+
</span>
|
|
307
|
+
</td>
|
|
308
|
+
<td className="px-4 py-3 font-display font-bold text-foreground">{arr > 0 ? `$${(arr / 1000).toFixed(0)}K` : '—'}</td>
|
|
309
|
+
<td className="px-4 py-3">
|
|
310
|
+
{health > 0 ? (
|
|
311
|
+
<span className={`px-2 py-0.5 rounded-lg text-xs font-semibold ${health >= 80 ? 'bg-green-500/15 text-green-400' : health >= 50 ? 'bg-yellow-500/15 text-yellow-400' : 'bg-red-500/15 text-red-400'}`}>
|
|
312
|
+
{health}
|
|
313
|
+
</span>
|
|
314
|
+
) : '—'}
|
|
315
|
+
</td>
|
|
316
|
+
<td className="px-4 py-3 text-muted-foreground text-xs">
|
|
317
|
+
{uc.target_prod_date ? new Date(uc.target_prod_date as string).toLocaleDateString() : '—'}
|
|
318
|
+
</td>
|
|
319
|
+
<td className="px-4 py-3 text-muted-foreground text-xs">
|
|
320
|
+
{uc.created_at ? new Date(uc.created_at as string).toLocaleDateString() : '—'}
|
|
321
|
+
</td>
|
|
322
|
+
{agentEnabled && (
|
|
323
|
+
<td className="px-2 py-3">
|
|
324
|
+
<button onClick={(e) => { e.stopPropagation(); openAIWithContext({ type: 'use-case', id: uc.id as string, name: uc.name as string, detail: client }); navigate('/agent'); }}
|
|
325
|
+
className="p-1.5 rounded-lg opacity-0 group-hover:opacity-100 hover:bg-accent/10 transition-all">
|
|
326
|
+
<Sparkles className="w-3.5 h-3.5 text-accent" />
|
|
327
|
+
</button>
|
|
328
|
+
</td>
|
|
329
|
+
)}
|
|
330
|
+
</tr>
|
|
331
|
+
);
|
|
332
|
+
})}
|
|
333
|
+
</tbody>
|
|
334
|
+
</table>
|
|
335
|
+
</div>
|
|
336
|
+
<div className="px-4">
|
|
337
|
+
<PaginationBar page={page} pageSize={pageSize} total={filtered.length} onPageChange={setPage} onPageSizeChange={setPageSize} />
|
|
338
|
+
</div>
|
|
339
|
+
</div>
|
|
340
|
+
)}
|
|
341
|
+
</div>
|
|
342
|
+
) : (
|
|
343
|
+
<div className="px-4 md:px-6">
|
|
344
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mb-6">
|
|
345
|
+
{[
|
|
346
|
+
{ label: 'Total ARR', value: filtered.filter(u => u.stage !== 'sunset').reduce((s, u) => s + ((u.attributed_arr as number) || 0), 0), isCount: false },
|
|
347
|
+
{ label: 'Production ARR', value: filtered.filter(u => u.stage === 'production' || u.stage === 'scaling').reduce((s, u) => s + ((u.attributed_arr as number) || 0), 0), isCount: false },
|
|
348
|
+
{ label: 'Active Use Cases', value: filtered.filter(u => u.stage !== 'sunset').length, isCount: true },
|
|
349
|
+
].map((card) => (
|
|
350
|
+
<div key={card.label} className="bg-card border border-border rounded-2xl p-5 shadow-sm">
|
|
351
|
+
<p className="text-xs text-muted-foreground font-display font-semibold">{card.label}</p>
|
|
352
|
+
<p className="text-2xl font-display font-extrabold text-foreground mt-1">
|
|
353
|
+
{card.isCount ? card.value : (
|
|
354
|
+
(card.value as number) >= 1_000_000
|
|
355
|
+
? `$${((card.value as number) / 1_000_000).toFixed(2)}M`
|
|
356
|
+
: `$${((card.value as number) / 1000).toFixed(0)}K`
|
|
357
|
+
)}
|
|
358
|
+
</p>
|
|
359
|
+
</div>
|
|
360
|
+
))}
|
|
361
|
+
</div>
|
|
362
|
+
<div className="bg-card border border-border rounded-2xl p-5 shadow-sm">
|
|
363
|
+
<h3 className="font-display font-bold text-foreground mb-4">ARR by stage</h3>
|
|
364
|
+
<div className="space-y-4">
|
|
365
|
+
{kanbanStages.map((stage) => {
|
|
366
|
+
const config = useCaseStageConfig[stage] ?? { label: stage, color: '#94a3b8' };
|
|
367
|
+
const total = filtered.filter(u => u.stage === stage).reduce((s, u) => s + ((u.attributed_arr as number) || 0), 0);
|
|
368
|
+
const count = filtered.filter(u => u.stage === stage).length;
|
|
369
|
+
const max = Math.max(...kanbanStages.map(s => filtered.filter(u => u.stage === s).reduce((sum, u) => sum + ((u.attributed_arr as number) || 0), 0)), 1);
|
|
370
|
+
return (
|
|
371
|
+
<div key={stage} className="flex items-center gap-3">
|
|
372
|
+
<span className="text-xs w-24 truncate font-semibold" style={{ color: config.color }}>{config.label}</span>
|
|
373
|
+
<div className="flex-1 h-8 bg-muted rounded-xl overflow-hidden">
|
|
374
|
+
<motion.div initial={{ width: 0 }} animate={{ width: total > 0 ? `${(total / max) * 100}%` : '0%' }}
|
|
375
|
+
transition={{ duration: 0.8, ease: 'easeOut' }}
|
|
376
|
+
className="h-full rounded-xl flex items-center px-3"
|
|
377
|
+
style={{ backgroundColor: config.color + '30' }}>
|
|
378
|
+
{total > 0 && (
|
|
379
|
+
<span className="text-xs font-display font-bold" style={{ color: config.color }}>
|
|
380
|
+
${(total / 1000).toFixed(0)}K
|
|
381
|
+
</span>
|
|
382
|
+
)}
|
|
383
|
+
</motion.div>
|
|
384
|
+
</div>
|
|
385
|
+
<span className="text-xs text-muted-foreground font-mono w-6 text-right">{count}</span>
|
|
386
|
+
</div>
|
|
387
|
+
);
|
|
388
|
+
})}
|
|
389
|
+
</div>
|
|
390
|
+
</div>
|
|
391
|
+
</div>
|
|
392
|
+
)}
|
|
393
|
+
</div>
|
|
394
|
+
</div>
|
|
395
|
+
);
|
|
396
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// Copyright 2026 CRMy Contributors
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
import { useNavigate, Navigate } from 'react-router-dom';
|
|
6
|
+
import { Button } from '../../components/ui/button';
|
|
7
|
+
import { Input } from '../../components/ui/input';
|
|
8
|
+
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../../components/ui/card';
|
|
9
|
+
import { auth, setToken, setUser } from '../../api/client';
|
|
10
|
+
import crMyLogo from '../../assets/crmy-logo.png';
|
|
11
|
+
import { X, Check, Sun, Moon } from 'lucide-react';
|
|
12
|
+
import { useAppStore } from '../../store/appStore';
|
|
13
|
+
|
|
14
|
+
const PASSWORD_RULES = [
|
|
15
|
+
{ label: 'At least 8 characters', test: (p: string) => p.length >= 8 },
|
|
16
|
+
{ label: 'One uppercase letter', test: (p: string) => /[A-Z]/.test(p) },
|
|
17
|
+
{ label: 'One number', test: (p: string) => /\d/.test(p) },
|
|
18
|
+
{ label: 'One special character', test: (p: string) => /[^A-Za-z0-9]/.test(p) },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
function isValidEmail(email: string) {
|
|
22
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isValidPassword(password: string) {
|
|
26
|
+
return PASSWORD_RULES.every((r) => r.test(password));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Light-mode inline overrides: card always appears as a dark panel to contrast against the light page background
|
|
30
|
+
const CHARCOAL_LIGHT_STYLE: React.CSSProperties = {
|
|
31
|
+
'--background': '220 10% 32%',
|
|
32
|
+
'--card': '220 14% 18%',
|
|
33
|
+
'--card-foreground': '36 15% 92%',
|
|
34
|
+
'--popover': '220 14% 22%',
|
|
35
|
+
'--popover-foreground': '36 15% 92%',
|
|
36
|
+
'--muted': '220 10% 24%',
|
|
37
|
+
'--muted-foreground': '220 8% 62%',
|
|
38
|
+
'--input': '220 10% 32%',
|
|
39
|
+
'--border': '220 10% 28%',
|
|
40
|
+
'--foreground': '36 15% 92%',
|
|
41
|
+
} as React.CSSProperties;
|
|
42
|
+
|
|
43
|
+
const WARM_LIGHT_STYLE: React.CSSProperties = {
|
|
44
|
+
'--background': '15 25% 32%',
|
|
45
|
+
'--card': '15 22% 18%',
|
|
46
|
+
'--card-foreground': '25 100% 95%',
|
|
47
|
+
'--popover': '15 22% 22%',
|
|
48
|
+
'--popover-foreground': '25 100% 95%',
|
|
49
|
+
'--muted': '15 20% 24%',
|
|
50
|
+
'--muted-foreground': '20 15% 62%',
|
|
51
|
+
'--input': '15 20% 32%',
|
|
52
|
+
'--border': '15 20% 28%',
|
|
53
|
+
'--foreground': '25 100% 95%',
|
|
54
|
+
} as React.CSSProperties;
|
|
55
|
+
|
|
56
|
+
export function LoginPage() {
|
|
57
|
+
const navigate = useNavigate();
|
|
58
|
+
const { darkVariant } = useAppStore();
|
|
59
|
+
const isCharcoal = darkVariant === 'charcoal';
|
|
60
|
+
|
|
61
|
+
const [mode, setMode] = useState<'login' | 'register'>('login');
|
|
62
|
+
const [email, setEmail] = useState('');
|
|
63
|
+
const [password, setPassword] = useState('');
|
|
64
|
+
const [name, setName] = useState('');
|
|
65
|
+
const [tenantName, setTenantName] = useState('');
|
|
66
|
+
const [error, setError] = useState('');
|
|
67
|
+
const [loading, setLoading] = useState(false);
|
|
68
|
+
const [touched, setTouched] = useState<Record<string, boolean>>({});
|
|
69
|
+
const [isDark, setIsDark] = useState(() => document.documentElement.classList.contains('dark'));
|
|
70
|
+
|
|
71
|
+
const toggleTheme = () => {
|
|
72
|
+
const next = !isDark;
|
|
73
|
+
setIsDark(next);
|
|
74
|
+
document.documentElement.classList.toggle('dark', next);
|
|
75
|
+
localStorage.setItem('crmy_theme', next ? 'dark' : 'light');
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
if (localStorage.getItem('crmy_token')) {
|
|
79
|
+
return <Navigate to="/" replace />;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const touch = (field: string) => setTouched((t) => ({ ...t, [field]: true }));
|
|
83
|
+
|
|
84
|
+
const emailError = touched.email && !isValidEmail(email) ? 'Enter a valid email address' : '';
|
|
85
|
+
const passwordError =
|
|
86
|
+
touched.password && mode === 'register' && !isValidPassword(password)
|
|
87
|
+
? 'Password does not meet requirements'
|
|
88
|
+
: touched.password && !password
|
|
89
|
+
? 'Password is required'
|
|
90
|
+
: '';
|
|
91
|
+
const nameError = touched.name && mode === 'register' && !name.trim() ? 'Name is required' : '';
|
|
92
|
+
const tenantError =
|
|
93
|
+
touched.tenantName && mode === 'register' && !tenantName.trim() ? 'Organization name is required' : '';
|
|
94
|
+
|
|
95
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
96
|
+
e.preventDefault();
|
|
97
|
+
setTouched({ email: true, password: true, name: true, tenantName: true });
|
|
98
|
+
|
|
99
|
+
if (!isValidEmail(email)) return;
|
|
100
|
+
if (!password) return;
|
|
101
|
+
if (mode === 'register') {
|
|
102
|
+
if (!isValidPassword(password) || !name.trim() || !tenantName.trim()) return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
setError('');
|
|
106
|
+
setLoading(true);
|
|
107
|
+
try {
|
|
108
|
+
const result =
|
|
109
|
+
mode === 'login'
|
|
110
|
+
? await auth.login(email, password)
|
|
111
|
+
: await auth.register({ email, password, name, tenant_name: tenantName });
|
|
112
|
+
setToken(result.token);
|
|
113
|
+
setUser(result.user);
|
|
114
|
+
navigate('/');
|
|
115
|
+
} catch (err) {
|
|
116
|
+
setError(err instanceof Error ? err.message : 'Authentication failed');
|
|
117
|
+
} finally {
|
|
118
|
+
setLoading(false);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const switchMode = (next: 'login' | 'register') => {
|
|
123
|
+
setMode(next);
|
|
124
|
+
setTouched({});
|
|
125
|
+
setError('');
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Page background: light mode uses a tinted background; dark mode uses CSS variable
|
|
129
|
+
const pageBg = isDark ? 'bg-background' : isCharcoal ? 'bg-[hsl(220_20%_93%)]' : 'bg-[hsl(30_25%_93%)]';
|
|
130
|
+
|
|
131
|
+
// Card wrapper: always rendered as a dark panel, variant-aware
|
|
132
|
+
const cardClass = isCharcoal ? 'dark charcoal' : 'dark';
|
|
133
|
+
// In light mode, override CSS vars so the card appears as a medium-dark panel against the light page
|
|
134
|
+
const cardStyle = !isDark ? (isCharcoal ? CHARCOAL_LIGHT_STYLE : WARM_LIGHT_STYLE) : undefined;
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div className={`flex min-h-screen items-center justify-center p-4 ${pageBg}`}>
|
|
138
|
+
<button
|
|
139
|
+
onClick={toggleTheme}
|
|
140
|
+
className="fixed top-4 right-4 p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
141
|
+
aria-label="Toggle theme"
|
|
142
|
+
>
|
|
143
|
+
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
|
144
|
+
</button>
|
|
145
|
+
<div className={`${cardClass} w-full max-w-md`} style={cardStyle}>
|
|
146
|
+
<Card className="w-full">
|
|
147
|
+
<CardHeader className="text-left">
|
|
148
|
+
<div className="flex items-center gap-3 mb-1">
|
|
149
|
+
<img src={crMyLogo} alt="CRMy" className="h-14 w-14 object-contain" />
|
|
150
|
+
<CardTitle className="font-brand font-bold text-2xl flex-1">
|
|
151
|
+
{mode === 'login' ? (
|
|
152
|
+
<>Sign in to <span className="text-primary">CRMy</span></>
|
|
153
|
+
) : (
|
|
154
|
+
<>Create your account</>
|
|
155
|
+
)}
|
|
156
|
+
</CardTitle>
|
|
157
|
+
{mode === 'register' && (
|
|
158
|
+
<button
|
|
159
|
+
onClick={() => switchMode('login')}
|
|
160
|
+
className="ml-auto p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
161
|
+
aria-label="Back to sign in"
|
|
162
|
+
>
|
|
163
|
+
<X className="w-5 h-5" />
|
|
164
|
+
</button>
|
|
165
|
+
)}
|
|
166
|
+
</div>
|
|
167
|
+
{mode === 'register' && (
|
|
168
|
+
<CardDescription className="text-center">Set up a new tenant and admin account</CardDescription>
|
|
169
|
+
)}
|
|
170
|
+
</CardHeader>
|
|
171
|
+
<CardContent>
|
|
172
|
+
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
|
173
|
+
{mode === 'register' && (
|
|
174
|
+
<>
|
|
175
|
+
<div>
|
|
176
|
+
<label className="mb-1 block text-sm font-medium">Your name <span className="text-destructive">*</span></label>
|
|
177
|
+
<Input
|
|
178
|
+
value={name}
|
|
179
|
+
onChange={(e) => setName(e.target.value)}
|
|
180
|
+
onBlur={() => touch('name')}
|
|
181
|
+
aria-invalid={!!nameError}
|
|
182
|
+
/>
|
|
183
|
+
{nameError && <p className="mt-1 text-xs text-destructive">{nameError}</p>}
|
|
184
|
+
</div>
|
|
185
|
+
<div>
|
|
186
|
+
<label className="mb-1 block text-sm font-medium">Organization name <span className="text-destructive">*</span></label>
|
|
187
|
+
<Input
|
|
188
|
+
value={tenantName}
|
|
189
|
+
onChange={(e) => setTenantName(e.target.value)}
|
|
190
|
+
onBlur={() => touch('tenantName')}
|
|
191
|
+
aria-invalid={!!tenantError}
|
|
192
|
+
/>
|
|
193
|
+
{tenantError && <p className="mt-1 text-xs text-destructive">{tenantError}</p>}
|
|
194
|
+
</div>
|
|
195
|
+
</>
|
|
196
|
+
)}
|
|
197
|
+
<div>
|
|
198
|
+
<label className="mb-1 block text-sm font-medium">Email <span className="text-destructive">*</span></label>
|
|
199
|
+
<Input
|
|
200
|
+
type="email"
|
|
201
|
+
value={email}
|
|
202
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
203
|
+
onBlur={() => touch('email')}
|
|
204
|
+
aria-invalid={!!emailError}
|
|
205
|
+
/>
|
|
206
|
+
{emailError && <p className="mt-1 text-xs text-destructive">{emailError}</p>}
|
|
207
|
+
</div>
|
|
208
|
+
<div>
|
|
209
|
+
<label className="mb-1 block text-sm font-medium">Password <span className="text-destructive">*</span></label>
|
|
210
|
+
<Input
|
|
211
|
+
type="password"
|
|
212
|
+
value={password}
|
|
213
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
214
|
+
onBlur={() => touch('password')}
|
|
215
|
+
aria-invalid={!!passwordError}
|
|
216
|
+
/>
|
|
217
|
+
{passwordError && mode !== 'register' && (
|
|
218
|
+
<p className="mt-1 text-xs text-destructive">{passwordError}</p>
|
|
219
|
+
)}
|
|
220
|
+
{mode === 'register' && touched.password && (
|
|
221
|
+
<ul className="mt-2 space-y-1">
|
|
222
|
+
{PASSWORD_RULES.map((rule) => {
|
|
223
|
+
const passing = rule.test(password);
|
|
224
|
+
return (
|
|
225
|
+
<li key={rule.label} className={`flex items-center gap-1.5 text-xs ${passing ? 'text-success' : 'text-muted-foreground'}`}>
|
|
226
|
+
<Check className={`w-3 h-3 ${passing ? 'opacity-100' : 'opacity-30'}`} />
|
|
227
|
+
{rule.label}
|
|
228
|
+
</li>
|
|
229
|
+
);
|
|
230
|
+
})}
|
|
231
|
+
</ul>
|
|
232
|
+
)}
|
|
233
|
+
</div>
|
|
234
|
+
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
235
|
+
<Button type="submit" className="w-full" disabled={loading}>
|
|
236
|
+
{loading ? 'Please wait...' : mode === 'login' ? 'Sign in' : 'Create account'}
|
|
237
|
+
</Button>
|
|
238
|
+
</form>
|
|
239
|
+
<div className="mt-4 text-center text-sm text-muted-foreground">
|
|
240
|
+
{mode === 'login' ? (
|
|
241
|
+
<>
|
|
242
|
+
Don't have an account?{' '}
|
|
243
|
+
<button className="text-primary hover:underline" onClick={() => switchMode('register')}>
|
|
244
|
+
Register
|
|
245
|
+
</button>
|
|
246
|
+
</>
|
|
247
|
+
) : (
|
|
248
|
+
<>
|
|
249
|
+
Already have an account?{' '}
|
|
250
|
+
<button className="text-primary hover:underline" onClick={() => switchMode('login')}>
|
|
251
|
+
Sign in
|
|
252
|
+
</button>
|
|
253
|
+
</>
|
|
254
|
+
)}
|
|
255
|
+
</div>
|
|
256
|
+
</CardContent>
|
|
257
|
+
</Card>
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
);
|
|
261
|
+
}
|