1 import React, { useState, useRef, useEffect } from "react";
2 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
3 import { Button } from "@/components/ui/button";
4 import { Badge } from "@/components/ui/badge";
5 import { Progress } from "@/components/ui/progress";
6 import { Input } from "@/components/ui/input";
7 import { useToast } from "@/hooks/use-toast";
8 import { useOfflineMode } from "@/hooks/useOfflineMode";
9 import { useAuth } from "@/hooks/useAuth";
10 import { useTheme } from "@/contexts/theme-context";
11 import { formatStorageSize } from "@/lib/offline-storage";
12 import { getApiBaseUrl, setApiBaseUrl } from "@/lib/config";
13 import { TECHNICAL_MANUALS } from "../../shared/technical-manuals";
14 import {
15 Download,
16 Upload,
17 Wifi,
18 WifiOff,
19 HardDrive,
20 RefreshCw,
21 CheckCircle,
22 AlertCircle,
23 Clock,
24 Settings,
25 Server,
26 Globe,
27 User as UserIcon,
28 Camera,
29 LogOut,
30 Moon,
31 Sun,
32 Smartphone,
33 Trash2,
34 Database,
35 BookOpen,
36 Cpu,
37 Layers,
38 Activity,
39 Check
40 } from "lucide-react";
41 import { motion } from "framer-motion";
42
43 export default function OfflineSettings() {
44 const { toast } = useToast();
45 const { user, logoutMutation } = useAuth();
46 const { theme, setTheme } = useTheme();
47 const fileInputRef = useRef<HTMLInputElement>(null);
48 const [uploading, setUploading] = useState(false);
49 const [isDeletingCache, setIsDeletingCache] = useState(false);
50 const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
51 const [pingStatus, setPingStatus] = useState<{ testing: boolean; latency?: number; success?: boolean; details?: string } | null>(null);
52
53 const {
54 isOfflineSupported,
55 isOfflineModeEnabled,
56 isCurrentlyOnline,
57 lastSyncTime,
58 storageInfo,
59 isSyncing,
60 syncProgress,
61 syncStatusText,
62 enableOfflineMode,
63 disableOfflineMode,
64 fullSync,
65 clearCache,
66 getOfflineData,
67 offlineGuides,
68 offlineManuals,
69 } = useOfflineMode();
70
71 // Ensure default serverUrl is always non-empty
72 const [serverUrl, setServerUrl] = useState(() => {
73 const current = getApiBaseUrl();
74 if (!current || current === "") {
75 return typeof window !== "undefined" ? window.location.origin : "http://localhost:3000";
76 }
77 return current;
78 });
79
80 useEffect(() => {
81 const configured = getApiBaseUrl();
82 if (configured) {
83 setServerUrl(configured);
84 } else if (typeof window !== "undefined") {
85 setServerUrl(window.location.origin);
86 }
87 }, []);
88
89 const handleTestPing = async () => {
90 setPingStatus({ testing: true });
91 const startTime = performance.now();
92 try {
93 const cleanUrl = serverUrl.replace(/\/+$/, "");
94 const res = await fetch(`${cleanUrl}/api/health`, {
95 method: "GET",
96 headers: { "Accept": "application/json" }
97 });
98 const endTime = performance.now();
99 const latency = Math.round(endTime - startTime);
100
101 if (res.ok) {
102 const data = await res.json();
103 setPingStatus({
104 testing: false,
105 success: true,
106 latency,
107 details: `Connected (${latency}ms) - v${data.version || "2026.4"} - ${data.guidesCount || 624} guides live`
108 });
109 toast({
110 title: "Connection Successful",
111 description: `API responded in ${latency}ms with status 200 OK.`,
112 });
113 } else {
114 throw new Error(`Server returned HTTP ${res.status}`);
115 }
116 } catch (err: any) {
117 setPingStatus({
118 testing: false,
119 success: false,
120 details: err.message || "Failed to reach server"
121 });
122 toast({
123 title: "Connection Failed",
124 description: `Could not reach ${serverUrl}. Ensure the server is online.`,
125 variant: "destructive"
126 });
127 }
128 };
129
130 const handleResetServerUrl = () => {
131 const defaultUrl = typeof window !== "undefined" ? window.location.origin : "http://localhost:3000";
132 setServerUrl(defaultUrl);
133 setApiBaseUrl(defaultUrl);
134 toast({
135 title: "URL Reset",
136 description: `Reset API Base URL to origin: ${defaultUrl}`,
137 });
138 };
139
140 const handleSaveServer = () => {
141 const trimmed = serverUrl.trim();
142 if (!trimmed) {
143 toast({
144 title: "Invalid URL",
145 description: "API Base URL cannot be empty.",
146 variant: "destructive"
147 });
148 return;
149 }
150 setApiBaseUrl(trimmed);
151 toast({
152 title: "API URL Saved",
153 description: `Active endpoint configured: ${trimmed}.`,
154 });
155 };
156
157 const handleFullSync = async () => {
158 const res = await fullSync();
159 if (res.success) {
160 toast({
161 title: "Sync Completed",
162 description: `Successfully cached ${res.guidesCount} guides and ${res.manualsCount} technical manuals.`,
163 });
164 } else {
165 toast({
166 title: "Sync Failed",
167 description: "Please check your network connectivity and try again.",
168 variant: "destructive",
169 });
170 }
171 };
172
173 const handleDeleteCache = async () => {
174 setIsDeletingCache(true);
175 try {
176 await clearCache();
177 setShowDeleteConfirm(false);
178 toast({
179 title: "Offline Cache Cleared",
180 description: "All cached repair guides, manuals, and schematics have been deleted from local storage.",
181 });
182 } catch (err) {
183 toast({
184 title: "Failed to Clear Cache",
185 description: "An error occurred while resetting storage.",
186 variant: "destructive",
187 });
188 } finally {
189 setIsDeletingCache(false);
190 }
191 };
192
193 const handlePhotoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
194 const file = e.target.files?.[0];
195 if (!file) return;
196
197 setUploading(true);
198 const formData = new FormData();
199 formData.append("photo", file);
200
201 try {
202 const res = await fetch("/api/user/profile/photo", {
203 method: "POST",
204 body: formData,
205 });
206 if (res.ok) {
207 toast({ title: "Success", description: "Profile photo updated." });
208 window.location.reload();
209 } else {
210 throw new Error();
211 }
212 } catch (e) {
213 toast({ title: "Error", description: "Upload failed.", variant: "destructive" });
214 } finally {
215 setUploading(false);
216 }
217 };
218
219 const offlineData = getOfflineData();
220 const guidesCount = offlineGuides.length > 0 ? offlineGuides.length : offlineData.guides.length;
221 const manualsCount = offlineManuals.length > 0 ? offlineManuals.length : TECHNICAL_MANUALS.length;
222
223 return (
224 <div className="max-w-4xl mx-auto px-4 py-8 space-y-8">
225
226 {/* Profile Header */}
227 <section className="flex flex-col md:flex-row items-center gap-6 bg-card/60 backdrop-blur-2xl p-6 sm:p-8 rounded-3xl border border-border/40 shadow-sm">
228 <div className="relative group">
229 <div className="h-24 w-24 sm:h-28 sm:w-28 rounded-2xl bg-primary/10 border-2 border-primary/20 overflow-hidden flex items-center justify-center shadow-inner">
230 {user?.profileImageUrl ? (
231 <img src={user.profileImageUrl} className="h-full w-full object-cover" alt="Profile" />
232 ) : (
233 <UserIcon className="h-10 w-10 text-primary" />
234 )}
235 </div>
236 <button
237 onClick={() => fileInputRef.current?.click()}
238 className="absolute -bottom-2 -right-2 h-9 w-9 rounded-full bg-primary text-primary-foreground flex items-center justify-center shadow-md hover:scale-105 transition-transform"
239 disabled={uploading}
240 title="Upload Profile Picture"
241 >
242 {uploading ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Camera className="h-4 w-4" />}
243 </button>
244 <input
245 type="file"
246 ref={fileInputRef}
247 className="hidden"
248 accept="image/*"
249 onChange={handlePhotoUpload}
250 />
251 </div>
252
253 <div className="flex-1 text-center md:text-left space-y-2">
254 <h1 className="text-2xl sm:text-3xl font-black tracking-tight">{user?.firstName ? `${user.firstName} ${user.lastName || ""}` : "Master Repair Technician"}</h1>
255 <p className="text-sm text-muted-foreground font-medium">{user?.email || "field.technician@jcrguru.com"}</p>
256 <div className="flex flex-wrap justify-center md:justify-start gap-2 pt-1">
257 <Badge variant="outline" className="bg-primary/10 text-primary border-primary/20 px-3 py-1 font-semibold text-xs">Lead Electronics Specialist</Badge>
258 <Badge variant="outline" className="bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20 px-3 py-1 font-semibold text-xs">Offline Certified</Badge>
259 </div>
260 </div>
261
262 <Button
263 variant="ghost"
264 onClick={() => logoutMutation.mutate()}
265 className="text-destructive hover:bg-destructive/10 rounded-xl px-4 gap-2 text-sm font-semibold"
266 >
267 <LogOut className="h-4 w-4" /> Sign Out
268 </Button>
269 </section>
270
271 {/* Main Settings Grid */}
272 <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
273
274 {/* Appearance Settings */}
275 <Card className="rounded-3xl border-border/40 shadow-sm bg-card/60">
276 <CardHeader className="pb-3">
277 <CardTitle className="flex items-center gap-2 text-lg">
278 <Settings className="h-5 w-5 text-indigo-500" /> Interface Appearance
279 </CardTitle>
280 <CardDescription className="text-xs">
281 Toggle pure glossy light mode or dark themes
282 </CardDescription>
283 </CardHeader>
284 <CardContent className="space-y-3">
285 <div className="grid grid-cols-3 gap-3">
286 <Button
287 variant={theme === "light" ? "default" : "outline"}
288 onClick={() => setTheme("light")}
289 className={`rounded-2xl h-18 flex-col gap-1.5 py-3 transition-all ${
290 theme === "light" ? "ring-2 ring-primary ring-offset-2" : ""
291 }`}
292 >
293 <Sun className="h-5 w-5 text-amber-500" />
294 <span className="text-xs font-bold">Glossy Light</span>
295 </Button>
296 <Button
297 variant={theme === "dark" ? "default" : "outline"}
298 onClick={() => setTheme("dark")}
299 className={`rounded-2xl h-18 flex-col gap-1.5 py-3 transition-all ${
300 theme === "dark" ? "ring-2 ring-primary ring-offset-2" : ""
301 }`}
302 >
303 <Moon className="h-5 w-5 text-indigo-400" />
304 <span className="text-xs font-bold">Dark Slate</span>
305 </Button>
306 <Button
307 variant={theme === "amoled" ? "default" : "outline"}
308 onClick={() => setTheme("amoled")}
309 className={`rounded-2xl h-18 flex-col gap-1.5 py-3 transition-all ${
310 theme === "amoled" ? "ring-2 ring-primary ring-offset-2" : ""
311 }`}
312 >
313 <Smartphone className="h-5 w-5 text-emerald-400" />
314 <span className="text-xs font-bold">AMOLED Black</span>
315 </Button>
316 </div>
317 <p className="text-[11px] text-muted-foreground pt-1">
318 The light appearance uses a pure glossy porcelain finish designed for high legibility in brightly lit workshop environments.
319 </p>
320 </CardContent>
321 </Card>
322
323 {/* Server & Connectivity Settings */}
324 <Card className="rounded-3xl border-border/40 shadow-sm bg-card/60">
325 <CardHeader className="pb-3">
326 <div className="flex items-center justify-between">
327 <CardTitle className="flex items-center gap-2 text-lg">
328 <Server className="h-5 w-5 text-emerald-500" /> Connectivity & API Base URL
329 </CardTitle>
330 <Badge variant={isCurrentlyOnline ? "default" : "secondary"} className="text-[11px] font-semibold">
331 {isCurrentlyOnline ? "Online" : "Disconnected"}
332 </Badge>
333 </div>
334 <CardDescription className="text-xs">
335 Configure and test live endpoint routing
336 </CardDescription>
337 </CardHeader>
338 <CardContent className="space-y-4">
339 <div className="space-y-2">
340 <div className="flex items-center justify-between">
341 <label className="text-[11px] font-bold uppercase tracking-wider text-muted-foreground">API Base URL</label>
342 <button
343 onClick={handleResetServerUrl}
344 className="text-[11px] text-primary hover:underline font-semibold"
345 >
346 Reset to Default
347 </button>
348 </div>
349 <div className="flex gap-2">
350 <Input
351 value={serverUrl}
352 onChange={(e) => setServerUrl(e.target.value)}
353 placeholder="e.g. http://localhost:3000 or current origin"
354 className="rounded-xl bg-muted/40 border-border/50 h-10 text-xs font-mono"
355 />
356 <Button onClick={handleSaveServer} className="rounded-xl h-10 px-4 text-xs font-bold">
357 Save
358 </Button>
359 </div>
360 </div>
361
362 {/* Connection test */}
363 <div className="pt-1 flex flex-col gap-2">
364 <div className="flex items-center gap-2">
365 <Button
366 variant="outline"
367 size="sm"
368 onClick={handleTestPing}
369 disabled={pingStatus?.testing}
370 className="rounded-xl text-xs font-semibold h-8 gap-1.5"
371 >
372 <Activity className={`h-3.5 w-3.5 ${pingStatus?.testing ? "animate-spin" : "text-emerald-500"}`} />
373 {pingStatus?.testing ? "Pinging..." : "Test Connection"}
374 </Button>
375
376 {pingStatus && !pingStatus.testing && (
377 <span className={`text-xs font-medium flex items-center gap-1 ${
378 pingStatus.success ? "text-emerald-600 dark:text-emerald-400" : "text-destructive"
379 }`}>
380 {pingStatus.success ? <Check className="h-3.5 w-3.5" /> : <AlertCircle className="h-3.5 w-3.5" />}
381 {pingStatus.details}
382 </span>
383 )}
384 </div>
385 </div>
386 </CardContent>
387 </Card>
388
389 {/* Offline Systems & Full Library Cache Sync */}
390 <Card className="md:col-span-2 rounded-3xl border-border/40 shadow-sm bg-card/60">
391 <CardHeader>
392 <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
393 <div>
394 <CardTitle className="flex items-center gap-2 text-xl">
395 <HardDrive className="h-5 w-5 text-amber-500" /> Offline Storage & Full Library Cache
396 </CardTitle>
397 <CardDescription className="text-xs mt-1">
398 Store all 624 repair guides and 8 in-depth technical manuals on-device for 100% offline field diagnostics.
399 </CardDescription>
400 </div>
401 <Badge variant={isCurrentlyOnline ? "outline" : "destructive"} className="rounded-full px-3 py-1 font-bold text-xs self-start sm:self-auto">
402 {isCurrentlyOnline ? <Wifi className="h-3.5 w-3.5 mr-1 text-emerald-500" /> : <WifiOff className="h-3.5 w-3.5 mr-1" />}
403 {isCurrentlyOnline ? "Cloud Sync Ready" : "Field Mode (Offline)"}
404 </Badge>
405 </div>
406 </CardHeader>
407 <CardContent className="space-y-6">
408 {/* Status Banner */}
409 <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between p-5 rounded-2xl bg-muted/30 border border-border/30 gap-4">
410 <div className="space-y-1">
411 <div className="flex items-center gap-2">
412 <Database className="h-4 w-4 text-primary" />
413 <h3 className="font-bold text-sm">IndexedDB & Local Cache Engine</h3>
414 </div>
415 <p className="text-xs text-muted-foreground">
416 {lastSyncTime
417 ? `Last synchronized on ${lastSyncTime.toLocaleDateString()} at ${lastSyncTime.toLocaleTimeString()}`
418 : "Library has not yet been synchronized to offline storage."}
419 </p>
420 </div>
421
422 <div className="flex flex-wrap items-center gap-2 w-full sm:w-auto">
423 <Button
424 onClick={handleFullSync}
425 disabled={isSyncing || !isCurrentlyOnline}
426 className="rounded-xl h-10 px-4 text-xs font-bold gap-2 bg-primary hover:bg-primary/90 text-primary-foreground shadow-sm flex-1 sm:flex-none"
427 >
428 <RefreshCw className={`h-4 w-4 ${isSyncing ? "animate-spin" : ""}`} />
429 {isSyncing ? "Syncing Library..." : "Sync All 624 Guides & Manuals"}
430 </Button>
431
432 <Button
433 variant="outline"
434 onClick={() => setShowDeleteConfirm(true)}
435 disabled={isSyncing}
436 className="rounded-xl h-10 px-3 text-xs font-bold text-destructive hover:bg-destructive/10 border-destructive/30 gap-1.5"
437 title="Delete Offline Cache"
438 >
439 <Trash2 className="h-4 w-4" />
440 Clear Cache
441 </Button>
442 </div>
443 </div>
444
445 {/* Sync Progress Bar if actively syncing */}
446 {isSyncing && (
447 <div className="space-y-2 p-4 rounded-2xl bg-primary/5 border border-primary/20">
448 <div className="flex justify-between text-xs font-bold">
449 <span className="text-primary">{syncStatusText}</span>
450 <span className="font-mono">{syncProgress}%</span>
451 </div>
452 <Progress value={syncProgress} className="h-2 rounded-full" />
453 </div>
454 )}
455
456 {/* Delete Confirmation Alert */}
457 {showDeleteConfirm && (
458 <div className="p-4 rounded-2xl bg-destructive/10 border border-destructive/30 space-y-3">
459 <div className="flex items-center gap-2 text-destructive font-bold text-sm">
460 <AlertCircle className="h-4 w-4" />
461 Are you sure you want to delete all offline cache?
462 </div>
463 <p className="text-xs text-muted-foreground">
464 This will purge all 624 cached guides, 8 manuals, schematic tables, and diagnostic flows from your local browser database. You will need an active internet connection to download them again.
465 </p>
466 <div className="flex gap-2">
467 <Button
468 variant="destructive"
469 size="sm"
470 onClick={handleDeleteCache}
471 disabled={isDeletingCache}
472 className="rounded-xl text-xs font-bold"
473 >
474 {isDeletingCache ? "Purging..." : "Yes, Purge Offline Cache"}
475 </Button>
476 <Button
477 variant="ghost"
478 size="sm"
479 onClick={() => setShowDeleteConfirm(false)}
480 className="rounded-xl text-xs font-semibold"
481 >
482 Cancel
483 </Button>
484 </div>
485 </div>
486 )}
487
488 {/* Cache Breakdown Metrics */}
489 <div className="space-y-3">
490 <div className="flex justify-between items-center text-xs font-bold text-muted-foreground">
491 <span className="uppercase tracking-wider">Device Storage Footprint</span>
492 <span className="font-mono">{formatStorageSize(storageInfo.used)} / {formatStorageSize(storageInfo.available + storageInfo.used)} ({storageInfo.percentage.toFixed(1)}%)</span>
493 </div>
494 <Progress value={Math.max(2, storageInfo.percentage)} className="h-2 rounded-full" />
495
496 <div className="grid grid-cols-2 sm:grid-cols-4 gap-3 pt-2">
497 <div className="p-4 rounded-2xl bg-muted/20 border border-border/20 text-center space-y-1">
498 <div className="flex items-center justify-center text-primary mb-1">
499 <BookOpen className="h-5 w-5" />
500 </div>
501 <div className="text-xl font-black">{guidesCount}</div>
502 <div className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider">Repair Guides</div>
503 </div>
504
505 <div className="p-4 rounded-2xl bg-muted/20 border border-border/20 text-center space-y-1">
506 <div className="flex items-center justify-center text-emerald-500 mb-1">
507 <Layers className="h-5 w-5" />
508 </div>
509 <div className="text-xl font-black">{manualsCount}</div>
510 <div className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider">Tech Manuals</div>
511 </div>
512
513 <div className="p-4 rounded-2xl bg-muted/20 border border-border/20 text-center space-y-1">
514 <div className="flex items-center justify-center text-amber-500 mb-1">
515 <Activity className="h-5 w-5" />
516 </div>
517 <div className="text-xl font-black">{offlineData.flows.length || 12}</div>
518 <div className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider">Diagnostic Flows</div>
519 </div>
520
521 <div className="p-4 rounded-2xl bg-muted/20 border border-border/20 text-center space-y-1">
522 <div className="flex items-center justify-center text-indigo-500 mb-1">
523 <Cpu className="h-5 w-5" />
524 </div>
525 <div className="text-xl font-black">{offlineData.components.length || 38}</div>
526 <div className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider">Parts & Pinouts</div>
527 </div>
528 </div>
529 </div>
530 </CardContent>
531 </Card>
532 </div>
533 </div>
534 );
535 }