|
1
|
import React, { useState, useEffect } from "react"; |
|
2
|
import { useQuery } from "@tanstack/react-query"; |
|
3
|
import { useLocation } from "wouter"; |
|
4
|
import { Button } from "@/components/ui/button"; |
|
5
|
import { Input } from "@/components/ui/input"; |
|
6
|
import { |
|
7
|
Search, |
|
8
|
Laptop, |
|
9
|
Monitor, |
|
10
|
Wrench, |
|
11
|
ArrowRight, |
|
12
|
Cpu, |
|
13
|
HardDrive, |
|
14
|
MemoryStick, |
|
15
|
Sparkles, |
|
16
|
CheckCircle2, |
|
17
|
AlertTriangle, |
|
18
|
X, |
|
19
|
Layers, |
|
20
|
ChevronRight |
|
21
|
} from "lucide-react"; |
|
22
|
import GuideDetailModal from "@/components/repair/guide-detail-modal"; |
|
23
|
import type { DeviceBrand, DeviceModel, RepairGuide } from "@shared/schema"; |
|
24
|
import { cn } from "@/lib/utils"; |
|
25
|
|
|
26
|
export default function DeviceFinder() { |
|
27
|
const [, setLocation] = useLocation(); |
|
28
|
const [selectedDeviceType, setSelectedDeviceType] = useState<"laptop" | "desktop">("laptop"); |
|
29
|
const [selectedBrand, setSelectedBrand] = useState<string>(""); |
|
30
|
const [selectedModelId, setSelectedModelId] = useState<string>(""); |
|
31
|
const [searchQuery, setSearchQuery] = useState(""); |
|
32
|
const [selectedGuideId, setSelectedGuideId] = useState<string | null>(null); |
|
33
|
|
|
34
|
// Check URL params for preselected model |
|
35
|
useEffect(() => { |
|
36
|
const params = new URLSearchParams(window.location.search); |
|
37
|
const modelParam = params.get("model"); |
|
38
|
if (modelParam) { |
|
39
|
setSelectedModelId(modelParam); |
|
40
|
} |
|
41
|
}, []); |
|
42
|
|
|
43
|
// Fetch brands for selected device type |
|
44
|
const { data: brands = [], isLoading: brandsLoading } = useQuery<DeviceBrand[]>({ |
|
45
|
queryKey: ["/api/device-brands", selectedDeviceType], |
|
46
|
queryFn: () => fetch(`/api/device-brands?deviceType=${selectedDeviceType}`).then(res => res.json()), |
|
47
|
}); |
|
48
|
|
|
49
|
// Fetch models for selected brand or all models |
|
50
|
const { data: allModels = [], isLoading: modelsLoading } = useQuery<DeviceModel[]>({ |
|
51
|
queryKey: ["/api/device-models", selectedBrand, selectedDeviceType], |
|
52
|
queryFn: () => { |
|
53
|
const url = selectedBrand |
|
54
|
? `/api/brands/${selectedBrand}/models` |
|
55
|
: `/api/device-models?deviceType=${selectedDeviceType}`; |
|
56
|
return fetch(url).then(res => res.json()); |
|
57
|
}, |
|
58
|
}); |
|
59
|
|
|
60
|
// Search models when typing |
|
61
|
const { data: searchResults = [] } = useQuery<DeviceModel[]>({ |
|
62
|
queryKey: ["/api/device-models/search", searchQuery, selectedDeviceType], |
|
63
|
queryFn: () => fetch(`/api/device-models?search=${encodeURIComponent(searchQuery)}&deviceType=${selectedDeviceType}`).then(res => res.json()), |
|
64
|
enabled: searchQuery.trim().length > 1, |
|
65
|
}); |
|
66
|
|
|
67
|
// Fetch compatible guides for selected model |
|
68
|
const { data: compatibleGuides = [], isLoading: guidesLoading } = useQuery<RepairGuide[]>({ |
|
69
|
queryKey: ["/api/models", selectedModelId, "guides"], |
|
70
|
queryFn: () => selectedModelId ? fetch(`/api/models/${selectedModelId}/guides`).then(res => res.json()) : Promise.resolve([]), |
|
71
|
enabled: !!selectedModelId, |
|
72
|
}); |
|
73
|
|
|
74
|
const displayModels = searchQuery.trim().length > 1 ? searchResults : allModels; |
|
75
|
const activeModel = allModels.find(m => m.id === selectedModelId) || searchResults.find(m => m.id === selectedModelId); |
|
76
|
|
|
77
|
return ( |
|
78
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-10"> |
|
79
|
|
|
80
|
{/* Header Banner */} |
|
81
|
<div className="text-center max-w-3xl mx-auto space-y-4"> |
|
82
|
<div className="inline-flex items-center gap-2 px-3.5 py-1 rounded-full bg-primary/10 text-primary border border-primary/20 text-xs font-bold uppercase tracking-wider"> |
|
83
|
<Sparkles className="h-3.5 w-3.5" /> Comprehensive Model Catalog |
|
84
|
</div> |
|
85
|
<h1 className="text-3xl sm:text-4xl font-extrabold tracking-tight"> |
|
86
|
Find Your <span className="bg-gradient-to-r from-primary to-indigo-500 bg-clip-text text-transparent">Exact System</span> |
|
87
|
</h1> |
|
88
|
<p className="text-sm sm:text-base text-muted-foreground leading-relaxed"> |
|
89
|
Select your brand and model to view tailored disassembly guides, component compatibility, and factory common issues. |
|
90
|
</p> |
|
91
|
|
|
92
|
{/* Device Switcher Tabs */} |
|
93
|
<div className="inline-flex p-1.5 rounded-2xl bg-muted/60 border border-border/50 shadow-sm mx-auto"> |
|
94
|
<button |
|
95
|
onClick={() => { |
|
96
|
setSelectedDeviceType("laptop"); |
|
97
|
setSelectedBrand(""); |
|
98
|
setSelectedModelId(""); |
|
99
|
}} |
|
100
|
className={cn( |
|
101
|
"flex items-center gap-2 px-6 py-2 rounded-xl text-xs sm:text-sm font-bold transition-all", |
|
102
|
selectedDeviceType === "laptop" |
|
103
|
? "bg-background text-foreground shadow-sm" |
|
104
|
: "text-muted-foreground hover:text-foreground" |
|
105
|
)} |
|
106
|
> |
|
107
|
<Laptop className="h-4 w-4" /> Laptop Models |
|
108
|
</button> |
|
109
|
<button |
|
110
|
onClick={() => { |
|
111
|
setSelectedDeviceType("desktop"); |
|
112
|
setSelectedBrand(""); |
|
113
|
setSelectedModelId(""); |
|
114
|
}} |
|
115
|
className={cn( |
|
116
|
"flex items-center gap-2 px-6 py-2 rounded-xl text-xs sm:text-sm font-bold transition-all", |
|
117
|
selectedDeviceType === "desktop" |
|
118
|
? "bg-background text-foreground shadow-sm" |
|
119
|
: "text-muted-foreground hover:text-foreground" |
|
120
|
)} |
|
121
|
> |
|
122
|
<Monitor className="h-4 w-4" /> Desktop & Form Factors |
|
123
|
</button> |
|
124
|
</div> |
|
125
|
</div> |
|
126
|
|
|
127
|
{/* Search Input Bar */} |
|
128
|
<div className="max-w-2xl mx-auto relative group"> |
|
129
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground group-focus-within:text-primary transition-colors" /> |
|
130
|
<Input |
|
131
|
type="text" |
|
132
|
value={searchQuery} |
|
133
|
onChange={(e) => setSearchQuery(e.target.value)} |
|
134
|
placeholder={`Search ${selectedDeviceType}s (e.g. MacBook Pro, ThinkPad X1, Dell XPS, Alienware, ROG, OptiPlex)...`} |
|
135
|
className="w-full pl-12 pr-10 h-12 rounded-2xl bg-card/80 border border-border/60 shadow-sm text-sm focus-visible:ring-2 focus-visible:ring-primary/20" |
|
136
|
/> |
|
137
|
{searchQuery && ( |
|
138
|
<button |
|
139
|
onClick={() => setSearchQuery("")} |
|
140
|
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" |
|
141
|
> |
|
142
|
<X className="h-4 w-4" /> |
|
143
|
</button> |
|
144
|
)} |
|
145
|
</div> |
|
146
|
|
|
147
|
{/* Brands Grid / Filter */} |
|
148
|
{!searchQuery && ( |
|
149
|
<div className="space-y-3"> |
|
150
|
<div className="flex items-center justify-between"> |
|
151
|
<h3 className="text-sm font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-2"> |
|
152
|
<Layers className="h-4 w-4" /> Filter by Brand |
|
153
|
</h3> |
|
154
|
{selectedBrand && ( |
|
155
|
<Button |
|
156
|
variant="ghost" |
|
157
|
size="sm" |
|
158
|
onClick={() => setSelectedBrand("")} |
|
159
|
className="h-7 text-xs text-muted-foreground hover:text-foreground gap-1" |
|
160
|
> |
|
161
|
<X className="h-3 w-3" /> Show All Brands |
|
162
|
</Button> |
|
163
|
)} |
|
164
|
</div> |
|
165
|
|
|
166
|
<div className="flex flex-wrap gap-2"> |
|
167
|
<button |
|
168
|
onClick={() => setSelectedBrand("")} |
|
169
|
className={cn( |
|
170
|
"px-4 py-2 rounded-xl text-xs font-bold transition-all border", |
|
171
|
selectedBrand === "" |
|
172
|
? "bg-primary text-primary-foreground border-primary shadow-sm" |
|
173
|
: "bg-card/50 border-border/50 text-muted-foreground hover:text-foreground hover:bg-card" |
|
174
|
)} |
|
175
|
> |
|
176
|
All Brands ({brands.length}) |
|
177
|
</button> |
|
178
|
{brands.map((brand) => ( |
|
179
|
<button |
|
180
|
key={brand.id} |
|
181
|
onClick={() => { |
|
182
|
setSelectedBrand(selectedBrand === brand.id ? "" : brand.id); |
|
183
|
setSelectedModelId(""); |
|
184
|
}} |
|
185
|
className={cn( |
|
186
|
"px-4 py-2 rounded-xl text-xs font-bold transition-all border flex items-center gap-2", |
|
187
|
selectedBrand === brand.id |
|
188
|
? "bg-primary text-primary-foreground border-primary shadow-sm" |
|
189
|
: "bg-card/50 border-border/50 text-muted-foreground hover:text-foreground hover:bg-card" |
|
190
|
)} |
|
191
|
> |
|
192
|
<span>{brand.name}</span> |
|
193
|
{allModels.filter(m => m.brandId === brand.id).length > 0 && ( |
|
194
|
<span className={cn( |
|
195
|
"text-[10px] px-1.5 py-0.2 rounded-full", |
|
196
|
selectedBrand === brand.id ? "bg-white/20 text-white" : "bg-muted text-muted-foreground" |
|
197
|
)}> |
|
198
|
{allModels.filter(m => m.brandId === brand.id).length} |
|
199
|
</span> |
|
200
|
)} |
|
201
|
</button> |
|
202
|
))} |
|
203
|
</div> |
|
204
|
</div> |
|
205
|
)} |
|
206
|
|
|
207
|
{/* Main Grid: Models List & Active Model Inspection Panel */} |
|
208
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8"> |
|
209
|
|
|
210
|
{/* Left: Device Models Grid */} |
|
211
|
<div className={cn("space-y-4", activeModel ? "lg:col-span-7" : "lg:col-span-12")}> |
|
212
|
<div className="flex items-center justify-between"> |
|
213
|
<h2 className="text-lg font-bold"> |
|
214
|
Available Models ({displayModels.length}) |
|
215
|
</h2> |
|
216
|
<span className="text-xs text-muted-foreground">Select a system to inspect teardowns</span> |
|
217
|
</div> |
|
218
|
|
|
219
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3.5"> |
|
220
|
{displayModels.map((model) => { |
|
221
|
const isSelected = selectedModelId === model.id; |
|
222
|
const specs = model.specifications as Record<string, string> || {}; |
|
223
|
|
|
224
|
return ( |
|
225
|
<div |
|
226
|
key={model.id} |
|
227
|
onClick={() => setSelectedModelId(model.id)} |
|
228
|
className={cn( |
|
229
|
"p-4 rounded-2xl border transition-all duration-200 cursor-pointer flex flex-col justify-between text-left", |
|
230
|
isSelected |
|
231
|
? "bg-primary/10 border-primary shadow-md ring-1 ring-primary/30" |
|
232
|
: "bg-card/50 hover:bg-card/90 border-border/50 hover:border-primary/40 hover:shadow-sm" |
|
233
|
)} |
|
234
|
> |
|
235
|
<div className="space-y-1.5"> |
|
236
|
<div className="flex items-start justify-between gap-2"> |
|
237
|
<h4 className="text-sm font-bold leading-tight group-hover:text-primary transition-colors"> |
|
238
|
{model.name} |
|
239
|
</h4> |
|
240
|
{model.year && ( |
|
241
|
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-muted text-muted-foreground shrink-0"> |
|
242
|
{model.year} |
|
243
|
</span> |
|
244
|
)} |
|
245
|
</div> |
|
246
|
{model.series && ( |
|
247
|
<div className="text-xs text-primary/80 font-medium">{model.series} Series</div> |
|
248
|
)} |
|
249
|
{specs.cpu && ( |
|
250
|
<p className="text-[11px] text-muted-foreground line-clamp-1 flex items-center gap-1"> |
|
251
|
<Cpu className="h-3 w-3 shrink-0" /> {specs.cpu} |
|
252
|
</p> |
|
253
|
)} |
|
254
|
</div> |
|
255
|
|
|
256
|
<div className="mt-3 pt-2.5 border-t border-border/30 flex items-center justify-between text-xs"> |
|
257
|
<span className="text-[10px] font-semibold text-muted-foreground"> |
|
258
|
{model.guideCount || 12} Verified Guides |
|
259
|
</span> |
|
260
|
<span className="text-xs font-bold text-primary flex items-center gap-0.5"> |
|
261
|
Inspect <ChevronRight className="h-3 w-3" /> |
|
262
|
</span> |
|
263
|
</div> |
|
264
|
</div> |
|
265
|
); |
|
266
|
})} |
|
267
|
</div> |
|
268
|
|
|
269
|
{displayModels.length === 0 && ( |
|
270
|
<div className="text-center py-12 bg-muted/20 rounded-3xl border border-dashed border-border/60 space-y-2"> |
|
271
|
<h4 className="font-bold text-base">No models found</h4> |
|
272
|
<p className="text-xs text-muted-foreground">Try broadening your search query or choosing another brand.</p> |
|
273
|
</div> |
|
274
|
)} |
|
275
|
</div> |
|
276
|
|
|
277
|
{/* Right: Detailed Model Inspection & Compatible Guides */} |
|
278
|
{activeModel && ( |
|
279
|
<div className="lg:col-span-5 space-y-6"> |
|
280
|
<div className="p-6 rounded-3xl bg-card/80 backdrop-blur-xl border border-primary/30 shadow-lg space-y-6 sticky top-24"> |
|
281
|
|
|
282
|
{/* Header */} |
|
283
|
<div className="space-y-2 border-b border-border/40 pb-4"> |
|
284
|
<div className="flex items-center justify-between"> |
|
285
|
<span className="px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider rounded-full bg-primary/10 text-primary border border-primary/20"> |
|
286
|
Active System |
|
287
|
</span> |
|
288
|
<button |
|
289
|
onClick={() => setSelectedModelId("")} |
|
290
|
className="text-muted-foreground hover:text-foreground text-xs" |
|
291
|
> |
|
292
|
<X className="h-4 w-4" /> |
|
293
|
</button> |
|
294
|
</div> |
|
295
|
<h3 className="text-xl font-extrabold">{activeModel.name}</h3> |
|
296
|
<p className="text-xs text-muted-foreground"> |
|
297
|
{activeModel.series} Series • Release {activeModel.year || "2024"} |
|
298
|
</p> |
|
299
|
</div> |
|
300
|
|
|
301
|
{/* Hardware Specifications */} |
|
302
|
{activeModel.specifications && Object.keys(activeModel.specifications).length > 0 && ( |
|
303
|
<div className="space-y-2.5"> |
|
304
|
<h4 className="text-xs font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5"> |
|
305
|
<Cpu className="h-3.5 w-3.5 text-primary" /> Key Specifications |
|
306
|
</h4> |
|
307
|
<div className="grid grid-cols-1 gap-2 text-xs"> |
|
308
|
{Object.entries(activeModel.specifications as Record<string, string>).map(([key, val]) => ( |
|
309
|
<div key={key} className="p-2.5 rounded-xl bg-muted/40 border border-border/40 flex justify-between items-center"> |
|
310
|
<span className="font-semibold capitalize text-muted-foreground">{key}:</span> |
|
311
|
<span className="font-medium text-foreground text-right">{val}</span> |
|
312
|
</div> |
|
313
|
))} |
|
314
|
</div> |
|
315
|
</div> |
|
316
|
)} |
|
317
|
|
|
318
|
{/* Common Issues & Diagnostics */} |
|
319
|
{Array.isArray(activeModel.commonIssues) && activeModel.commonIssues.length > 0 && ( |
|
320
|
<div className="space-y-2.5"> |
|
321
|
<h4 className="text-xs font-bold uppercase tracking-wider text-amber-500 flex items-center gap-1.5"> |
|
322
|
<AlertTriangle className="h-3.5 w-3.5" /> Known Issues & Service Points |
|
323
|
</h4> |
|
324
|
<ul className="space-y-1.5 text-xs text-muted-foreground"> |
|
325
|
{(activeModel.commonIssues as string[]).map((issue, i) => ( |
|
326
|
<li key={i} className="flex items-start gap-2 bg-amber-500/5 p-2 rounded-xl border border-amber-500/20"> |
|
327
|
<CheckCircle2 className="h-3.5 w-3.5 text-amber-500 shrink-0 mt-0.5" /> |
|
328
|
<span>{issue}</span> |
|
329
|
</li> |
|
330
|
))} |
|
331
|
</ul> |
|
332
|
</div> |
|
333
|
)} |
|
334
|
|
|
335
|
{/* Compatible Repair Guides */} |
|
336
|
<div className="space-y-3 pt-2 border-t border-border/40"> |
|
337
|
<h4 className="text-xs font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5"> |
|
338
|
<Wrench className="h-3.5 w-3.5 text-indigo-500" /> Compatible Procedures ({compatibleGuides.length}) |
|
339
|
</h4> |
|
340
|
|
|
341
|
<div className="space-y-2 max-h-60 overflow-y-auto pr-1"> |
|
342
|
{compatibleGuides.map((guide) => ( |
|
343
|
<button |
|
344
|
key={guide.id} |
|
345
|
onClick={() => setSelectedGuideId(guide.id)} |
|
346
|
className="w-full p-2.5 rounded-xl bg-muted/30 hover:bg-primary/10 border border-border/40 hover:border-primary/40 transition-all flex items-center justify-between text-left group" |
|
347
|
> |
|
348
|
<div className="space-y-0.5"> |
|
349
|
<span className="text-xs font-bold block group-hover:text-primary transition-colors"> |
|
350
|
{guide.title} |
|
351
|
</span> |
|
352
|
<span className="text-[10px] text-muted-foreground capitalize"> |
|
353
|
{guide.category} • {guide.difficulty} • {guide.estimatedTime} |
|
354
|
</span> |
|
355
|
</div> |
|
356
|
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all shrink-0" /> |
|
357
|
</button> |
|
358
|
))} |
|
359
|
</div> |
|
360
|
</div> |
|
361
|
|
|
362
|
</div> |
|
363
|
</div> |
|
364
|
)} |
|
365
|
</div> |
|
366
|
|
|
367
|
{/* Guide Detail Modal */} |
|
368
|
{selectedGuideId && ( |
|
369
|
<GuideDetailModal |
|
370
|
guideId={selectedGuideId} |
|
371
|
onClose={() => setSelectedGuideId(null)} |
|
372
|
/> |
|
373
|
)} |
|
374
|
</div> |
|
375
|
); |
|
376
|
} |