Repository navigation
Expand file tree
/
Copy pathApp.tsx
More file actions
220 lines (201 loc) · 11.9 KB
/
Copy pathApp.tsx
File metadata and controls
220 lines (201 loc) · 11.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
import React, { useState, useMemo, useEffect } from 'react';
import { Search, Sparkles, MapPin, Sun, Moon, Globe, Heart, Database, PhoneForwarded, User as UserIcon, Shield } from 'lucide-react';
import { MapView } from './components/MapView';
import { TableView } from './components/TableView';
import { GeminiChat } from './components/GeminiChat';
import { IntroModal } from './components/IntroModal';
import { RemoteSupportModal } from './components/RemoteSupportModal';
import { ReferralModal } from './components/ReferralModal';
import { AboutModal } from './components/AboutModal';
import { PresentationModal } from './components/PresentationModal';
import { RegistryModal } from './components/RegistryModal';
import { PrivacyPolicyModal } from './components/PrivacyPolicyModal';
import { TermsOfServiceModal } from './components/TermsOfServiceModal';
import { INITIAL_ORGANIZATIONS, REGION_CONFIG } from './constants';
import { Organization, RegionName, UserSession, UserRole } from './types';
const App: React.FC = () => {
const [organizations] = useState<Organization[]>(INITIAL_ORGANIZATIONS);
const [isDarkMode, setIsDarkMode] = useState(() => localStorage.getItem('theme') === 'dark');
const [mobileTab, setMobileTab] = useState<'map' | 'list'>('map');
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [isChatOpen, setIsChatOpen] = useState(false);
const [activeRegion, setActiveRegion] = useState<RegionName>('All');
const [showIntro, setShowIntro] = useState(!localStorage.getItem('hide_intro_annotation'));
const [isRemoteSupportOpen, setIsRemoteSupportOpen] = useState(false);
const [isAboutOpen, setIsAboutOpen] = useState(false);
const [isPresentationOpen, setIsPresentationOpen] = useState(false);
const [isRegistryOpen, setIsRegistryOpen] = useState(false);
const [isPrivacyOpen, setIsPrivacyOpen] = useState(false);
const [isTermsOpen, setIsTermsOpen] = useState(false);
const [referralOrg, setReferralOrg] = useState<Organization | null>(null);
const [mapKey, setMapKey] = useState(0);
// RBAC State
const [currentUser, setCurrentUser] = useState<UserSession>({
id: 'user_1',
name: 'Гість',
role: 'Guest'
});
const [isProfileOpen, setIsProfileOpen] = useState(false);
useEffect(() => {
document.documentElement.classList.toggle('dark', isDarkMode);
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
}, [isDarkMode]);
const handleResetMap = () => {
setMapKey(prev => prev + 1);
};
const changeRole = (role: UserRole) => {
const names: Record<UserRole, string> = {
'Guest': 'Відвідувач',
'Partner': 'Партнер проекту',
'Manager': 'Кейс-менеджер',
'Admin': 'Адміністратор Платформи'
};
setCurrentUser({ ...currentUser, role, name: names[role] });
setIsProfileOpen(false);
};
const filteredOrgs = useMemo(() => {
return organizations.filter(o => {
if (activeRegion !== 'All' && o.region !== activeRegion) return false;
const term = searchTerm.toLowerCase();
return o.name.toLowerCase().includes(term) || o.address.toLowerCase().includes(term);
});
}, [organizations, activeRegion, searchTerm]);
return (
<div className="h-screen flex flex-col bg-slate-50 dark:bg-slate-950 overflow-hidden font-sans transition-colors duration-300">
{showIntro && (
<IntroModal
onComplete={() => setShowIntro(false)}
onOpenPrivacy={() => setIsPrivacyOpen(true)}
onOpenTerms={() => setIsTermsOpen(true)}
/>
)}
{isAboutOpen && (
<AboutModal
onClose={() => setIsAboutOpen(false)}
onOpenPresentation={() => setIsPresentationOpen(true)}
onOpenPrivacy={() => setIsPrivacyOpen(true)}
onOpenTerms={() => setIsTermsOpen(true)}
/>
)}
{isPresentationOpen && <PresentationModal onClose={() => setIsPresentationOpen(false)} />}
{isRegistryOpen && <RegistryModal organizations={organizations} user={currentUser} onClose={() => setIsRegistryOpen(false)} />}
{isPrivacyOpen && <PrivacyPolicyModal onClose={() => setIsPrivacyOpen(false)} />}
{isTermsOpen && <TermsOfServiceModal onClose={() => setIsTermsOpen(false)} />}
{referralOrg && <ReferralModal organization={referralOrg} onClose={() => setReferralOrg(null)} />}
{isRemoteSupportOpen && <RemoteSupportModal onClose={() => setIsRemoteSupportOpen(false)} />}
<header className="h-16 shrink-0 bg-white/80 dark:bg-slate-900/80 backdrop-blur-md border-b border-slate-200 dark:border-slate-800 flex items-center justify-between px-6 z-30">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-teal-600 rounded-xl flex items-center justify-center text-white shadow-lg shadow-teal-500/20">
<Heart size={20} fill="currentColor" />
</div>
<div className="hidden sm:block">
<h1 className="font-black text-sm uppercase tracking-tight text-slate-800 dark:text-white leading-none">Інклюзивна Мапа</h1>
<button onClick={() => setIsAboutOpen(true)} className="text-[10px] font-bold text-teal-600 uppercase flex items-center gap-1 mt-1">
<MapPin size={10} /> {REGION_CONFIG[activeRegion].label}
</button>
</div>
</div>
<div className="flex-1 max-w-lg mx-8 relative hidden md:block">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 w-4 h-4" />
<input
type="text"
placeholder="Пошук допомоги..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-12 pr-4 py-2.5 bg-slate-100 dark:bg-slate-800 border-none rounded-2xl text-sm focus:ring-2 focus:ring-teal-500/20 transition-all dark:text-white"
/>
</div>
<div className="flex items-center gap-3">
<div className="relative">
<button
onClick={() => setIsProfileOpen(!isProfileOpen)}
className={`flex items-center gap-2 p-2 pr-4 rounded-xl border transition-all ${
currentUser.role === 'Admin' ? 'border-rose-200 bg-rose-50 text-rose-700' :
currentUser.role === 'Manager' ? 'border-indigo-200 bg-indigo-50 text-indigo-700' :
currentUser.role === 'Partner' ? 'border-teal-200 bg-teal-50 text-teal-700' :
'border-slate-200 bg-slate-50 text-slate-600'
}`}
>
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${
currentUser.role === 'Admin' ? 'bg-rose-500' :
currentUser.role === 'Manager' ? 'bg-indigo-500' :
currentUser.role === 'Partner' ? 'bg-teal-500' :
'bg-slate-500'
} text-white`}>
<UserIcon size={16} />
</div>
<div className="text-left hidden lg:block">
<div className="text-[10px] font-black uppercase tracking-widest leading-none mb-1">{currentUser.role}</div>
<div className="text-xs font-bold leading-none">{currentUser.name}</div>
</div>
</button>
{isProfileOpen && (
<div className="absolute top-full right-0 mt-2 w-56 bg-white dark:bg-slate-900 shadow-2xl rounded-2xl border border-slate-100 dark:border-slate-800 p-2 z-[100] animate-in slide-in-from-top-2 duration-200">
<div className="px-3 py-2 text-[10px] font-black uppercase text-slate-400 tracking-widest border-b border-slate-50 dark:border-slate-800 mb-2">Змінити роль</div>
{(['Guest', 'Partner', 'Manager', 'Admin'] as UserRole[]).map(role => (
<button
key={role}
onClick={() => changeRole(role)}
className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold flex items-center justify-between hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors ${currentUser.role === role ? 'text-teal-600 bg-teal-50 dark:bg-teal-900/30' : 'text-slate-600 dark:text-slate-400'}`}
>
{role}
{role === 'Admin' && <Shield size={12} className="text-rose-500" />}
{currentUser.role === role && <div className="w-1.5 h-1.5 rounded-full bg-teal-500" />}
</button>
))}
</div>
)}
</div>
<button onClick={() => setIsDarkMode(!isDarkMode)} className="p-2.5 text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all hidden sm:flex">
{isDarkMode ? <Sun size={20} className="text-amber-400" /> : <Moon size={20} />}
</button>
<button onClick={() => setIsChatOpen(true)} className="px-5 py-2.5 bg-slate-900 dark:bg-teal-600 text-white rounded-xl text-xs font-black uppercase tracking-widest hover:scale-105 active:scale-95 transition-all shadow-xl">
<Sparkles size={14} className="inline mr-2 text-teal-400 dark:text-white" /> AI Чат
</button>
</div>
</header>
<main className="flex-1 flex overflow-hidden relative">
<div className="w-[450px] transition-all duration-500 hidden md:flex flex-col bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 z-20 overflow-hidden">
<div className="p-4 bg-slate-50 dark:bg-slate-800/50 border-b border-slate-100 dark:border-slate-700 flex justify-between items-center">
<span className="text-[10px] font-black uppercase tracking-widest text-slate-400">Результати: {filteredOrgs.length}</span>
<button
onClick={() => setIsRegistryOpen(true)}
className="text-[10px] font-black uppercase text-teal-600 hover:underline flex items-center gap-1"
>
<Database size={10} /> Реєстр
</button>
</div>
<TableView organizations={filteredOrgs} selectedOrgId={selectedOrgId} onSelectOrg={setSelectedOrgId} user={currentUser} />
</div>
<div className="flex-1 relative bg-slate-100 dark:bg-slate-950 h-full">
<MapView
key={mapKey}
organizations={filteredOrgs}
selectedOrgId={selectedOrgId}
onSelectOrg={setSelectedOrgId}
onOpenReferral={setReferralOrg}
center={REGION_CONFIG[activeRegion].center as [number, number]}
zoom={REGION_CONFIG[activeRegion].zoom}
isDarkMode={isDarkMode}
onResetKey={handleResetMap}
/>
</div>
<GeminiChat
organizations={filteredOrgs}
isOpen={isChatOpen}
onClose={() => setIsChatOpen(false)}
isDarkMode={isDarkMode}
user={currentUser}
/>
</main>
<div className="md:hidden fixed bottom-8 left-8 right-8 h-16 bg-white/90 dark:bg-slate-900/90 backdrop-blur-xl border border-white/20 dark:border-slate-800 shadow-2xl rounded-3xl flex items-center justify-around z-[100] px-4">
<button onClick={() => setMobileTab('map')} className={`p-2 ${mobileTab === 'map' ? 'text-teal-600' : 'text-slate-400'}`}><Globe size={24} /></button>
<button onClick={() => setMobileTab('list')} className={`p-2 ${mobileTab === 'list' ? 'text-teal-600' : 'text-slate-400'}`}><Database size={24} /></button>
<button onClick={() => setIsRemoteSupportOpen(true)} className="p-2 text-slate-400"><PhoneForwarded size={24} /></button>
<button onClick={() => setIsChatOpen(true)} className="p-3 bg-teal-600 text-white rounded-2xl shadow-lg -translate-y-4 animate-bounce"><Sparkles size={24} /></button>
</div>
</div>
);
};
export default App;