Gustav2811 commited on
Commit
ad8945a
·
1 Parent(s): e42b636

Upgraded UI

Browse files
Files changed (2) hide show
  1. app.py +269 -49
  2. public/elements/CarSearchResults.jsx +114 -211
app.py CHANGED
@@ -25,6 +25,24 @@ openai_client = AsyncOpenAI(api_key=OPENAI_API_KEY) if OPENAI_API_KEY else None
25
  # Local tools definition - empty since we handle UI display in Python code directly
26
  local_tools = []
27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
  @cl.oauth_callback
30
  def oauth_callback(
@@ -79,12 +97,15 @@ async def on_mcp_connect(connection, session: ClientSession):
79
  log.info("Auto-executing 'run-me-first' tool...")
80
  result = await session.call_tool("run-me-first", {})
81
 
82
- # Extract the result text
83
- if hasattr(result, "content") and result.content:
84
- result_text = (
85
- result.content[0].text
86
- if hasattr(result.content[0], "text")
87
- else str(result)
 
 
 
88
  )
89
  else:
90
  result_text = str(result)
@@ -129,7 +150,7 @@ async def start():
129
  [
130
  {
131
  "role": "system",
132
- "content": """You are a helpful AI assistant for car searches. When a user asks for cars, use the 'search-cars' tool. After the tool runs and the results are displayed, simply confirm this to the user with a brief message like 'I've found [X] cars for you, take a look at the results below.' Do NOT list the car details in your own response.""",
133
  }
134
  ],
135
  )
@@ -147,14 +168,40 @@ async def show_car_search_results(
147
  "hits": hits or [],
148
  }
149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  car_results_element = cl.CustomElement(name="CarSearchResults", props=props)
151
  await cl.Message(
152
- content="", elements=[car_results_element], author="CarSearchResults"
 
 
153
  ).send()
154
  return f"Car search results displayed: {total_documents:,} vehicles found across {len(facet_counts or [])} categories with {len(hits or [])} detailed listings"
155
 
156
 
157
- # --- THIS IS THE FINAL REFACTORED on_message FUNCTION ---
158
  @cl.on_message
159
  async def main(message: cl.Message):
160
  history = cl.user_session.get("message_history")
@@ -165,28 +212,47 @@ async def main(message: cl.Message):
165
  async with cl.Step(name="Thinking", type="llm") as thinking_step:
166
  thinking_step.input = message.content
167
 
168
- # Get the AI's plan: does it want to use a tool?
169
  all_mcp_tools = cl.user_session.get("mcp_tools", {})
170
  aggregated_tools = [
171
  tool for conn_tools in all_mcp_tools.values() for tool in conn_tools
172
  ]
 
 
173
 
174
  response = await openai_client.chat.completions.create(
175
  model="gpt-4o",
176
  messages=history,
177
  tools=aggregated_tools if aggregated_tools else None,
178
- tool_choice="auto",
179
  )
180
  response_message = response.choices[0].message
181
  thinking_step.output = response_message
182
 
183
- # If the AI wants to use tools, handle them within their own steps.
184
  if response_message.tool_calls:
185
- history.append(response_message)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
 
187
  for tool_call in response_message.tool_calls:
188
  tool_name = tool_call.function.name
189
  tool_args = json.loads(tool_call.function.arguments)
 
190
 
191
  # Create a specific step for the tool call for UI feedback.
192
  async with cl.Step(
@@ -194,57 +260,211 @@ async def main(message: cl.Message):
194
  ) as tool_step:
195
  tool_step.input = tool_args
196
 
197
- # We only need special handling for our primary tool.
198
- if tool_name == "search-cars":
199
- mcp_connection_name = next(
200
- (
201
- conn_name
202
- for conn_name, tools in all_mcp_tools.items()
203
- if any(
204
- t["function"]["name"] == tool_name for t in tools
 
 
 
 
 
 
 
 
 
 
205
  )
206
- ),
207
- None,
208
- )
209
 
210
- if mcp_connection_name:
211
  mcp_session, _ = cl.context.session.mcp_sessions.get(
212
  mcp_connection_name
213
  )
214
- tool_task = mcp_session.call_tool(tool_name, tool_args)
215
- result = await asyncio.wait_for(tool_task, timeout=30.0)
216
- raw_json_output = (
217
- str(result.content[0].text)
218
- if hasattr(result, "content") and result.content
219
- else str(result)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  )
221
 
222
- # On success, parse data and display the UI component.
223
- search_data = json.loads(raw_json_output)
224
- PAGINATION_LIMIT = 12
225
- paginated_hits = search_data.get("hits", [])[
226
- :PAGINATION_LIMIT
227
- ]
228
-
229
- await show_car_search_results(
230
- total_documents=search_data.get("found", 0),
231
- search_time_ms=search_data.get("search_time_ms", 0),
232
- facet_counts=search_data.get("facet_counts", []),
233
- hits=paginated_hits,
234
  )
 
 
 
 
235
 
236
- # The turn is now complete. The UI is the response.
237
- tool_step.output = search_data
238
- else:
239
- tool_step.error = f"Error: Tool '{tool_name}' not found."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
 
241
- # NOTE: No final summary message is generated.
 
 
 
 
242
  else:
243
  # If no tool was called, the AI's first response is the final answer.
244
  final_answer = response_message.content
245
  if final_answer:
246
  await cl.Message(content=final_answer).send()
247
- history.append({"role": "assistant", "content": final_answer})
248
 
249
  except Exception as e:
250
  log.error(f"Error in main message loop: {e}")
 
25
  # Local tools definition - empty since we handle UI display in Python code directly
26
  local_tools = []
27
 
28
+ # Regular tools that the AI can choose to call - REMOVED show_car_search_results
29
+ # The AI only needs to know about search-cars, Python will handle the UI display
30
+ regular_tools = []
31
+
32
+ # Convert regular tools to OpenAI function format
33
+ local_tools = []
34
+ for tool in regular_tools:
35
+ local_tools.append(
36
+ {
37
+ "type": "function",
38
+ "function": {
39
+ "name": tool["name"],
40
+ "description": tool["description"],
41
+ "parameters": tool["input_schema"],
42
+ },
43
+ }
44
+ )
45
+
46
 
47
  @cl.oauth_callback
48
  def oauth_callback(
 
97
  log.info("Auto-executing 'run-me-first' tool...")
98
  result = await session.call_tool("run-me-first", {})
99
 
100
+ # Extract the result text using improved chunk collection
101
+ if isinstance(result, dict):
102
+ result_text = json.dumps(result)
103
+ elif hasattr(result, "content") and result.content:
104
+ log.info(
105
+ f"Received {len(result.content)} content blocks from run-me-first"
106
+ )
107
+ result_text = "".join(
108
+ getattr(block, "text", "") for block in result.content
109
  )
110
  else:
111
  result_text = str(result)
 
150
  [
151
  {
152
  "role": "system",
153
+ "content": """You are a helpful AI assistant for car searches. When a user asks for cars, use the 'search-cars' tool to find vehicles. The system will automatically display the results in a beautiful UI format, so you don't need to worry about formatting - just focus on understanding what the user wants and calling the search tool with appropriate parameters.""",
154
  }
155
  ],
156
  )
 
168
  "hits": hits or [],
169
  }
170
 
171
+ # Console log the props for debugging
172
+ log.info("=== CarSearchResults Component Props ===")
173
+ log.info(f"total_documents: {props['total_documents']}")
174
+ log.info(f"search_time_ms: {props['search_time_ms']}")
175
+ log.info(f"facet_counts length: {len(props['facet_counts'])}")
176
+ log.info(f"hits length: {len(props['hits'])}")
177
+
178
+ # Log first hit structure for debugging data shape
179
+ if props["hits"]:
180
+ log.info("=== First Hit Structure ===")
181
+ first_hit = props["hits"][0]
182
+ log.info(
183
+ f"First hit keys: {list(first_hit.keys()) if isinstance(first_hit, dict) else 'Not a dict'}"
184
+ )
185
+ if isinstance(first_hit, dict) and "document" in first_hit:
186
+ log.info(f"Document keys: {list(first_hit['document'].keys())}")
187
+ if "vehicle_make_brand" in first_hit["document"]:
188
+ log.info(
189
+ f"vehicle_make_brand: {first_hit['document']['vehicle_make_brand']}"
190
+ )
191
+ log.info(f"Full first hit: {json.dumps(first_hit, indent=2)}")
192
+
193
+ log.info("=== End Props Debug ===")
194
+
195
  car_results_element = cl.CustomElement(name="CarSearchResults", props=props)
196
  await cl.Message(
197
+ content=f"🔎 Found {total_documents:,} vehicles in {search_time_ms} ms:",
198
+ elements=[car_results_element],
199
+ author="CarSearchResults",
200
  ).send()
201
  return f"Car search results displayed: {total_documents:,} vehicles found across {len(facet_counts or [])} categories with {len(hits or [])} detailed listings"
202
 
203
 
204
+ # --- THIS IS THE FINAL, DEFINITIVE on_message FUNCTION ---
205
  @cl.on_message
206
  async def main(message: cl.Message):
207
  history = cl.user_session.get("message_history")
 
212
  async with cl.Step(name="Thinking", type="llm") as thinking_step:
213
  thinking_step.input = message.content
214
 
 
215
  all_mcp_tools = cl.user_session.get("mcp_tools", {})
216
  aggregated_tools = [
217
  tool for conn_tools in all_mcp_tools.values() for tool in conn_tools
218
  ]
219
+ # Add local tools to the aggregated tools
220
+ aggregated_tools.extend(local_tools)
221
 
222
  response = await openai_client.chat.completions.create(
223
  model="gpt-4o",
224
  messages=history,
225
  tools=aggregated_tools if aggregated_tools else None,
226
+ tool_choice="auto" if aggregated_tools else None,
227
  )
228
  response_message = response.choices[0].message
229
  thinking_step.output = response_message
230
 
231
+ # If the AI wants to use tools, handle them.
232
  if response_message.tool_calls:
233
+ # Add the assistant's message with tool calls to history
234
+ history.append(
235
+ {
236
+ "role": "assistant",
237
+ "content": response_message.content,
238
+ "tool_calls": [
239
+ {
240
+ "id": tool_call.id,
241
+ "type": "function",
242
+ "function": {
243
+ "name": tool_call.function.name,
244
+ "arguments": tool_call.function.arguments,
245
+ },
246
+ }
247
+ for tool_call in response_message.tool_calls
248
+ ],
249
+ }
250
+ )
251
 
252
  for tool_call in response_message.tool_calls:
253
  tool_name = tool_call.function.name
254
  tool_args = json.loads(tool_call.function.arguments)
255
+ tool_result = "No result" # Default fallback
256
 
257
  # Create a specific step for the tool call for UI feedback.
258
  async with cl.Step(
 
260
  ) as tool_step:
261
  tool_step.input = tool_args
262
 
263
+ try:
264
+ if tool_name == "search-cars":
265
+ log.info("Executing 'search-cars' tool.")
266
+ mcp_connection_name = next(
267
+ (
268
+ conn_name
269
+ for conn_name, tools in all_mcp_tools.items()
270
+ if any(
271
+ t["function"]["name"] == tool_name
272
+ for t in tools
273
+ )
274
+ ),
275
+ None,
276
+ )
277
+
278
+ if not mcp_connection_name:
279
+ raise Exception(
280
+ f"Tool '{tool_name}' not found in any MCP connection"
281
  )
 
 
 
282
 
 
283
  mcp_session, _ = cl.context.session.mcp_sessions.get(
284
  mcp_connection_name
285
  )
286
+ if not mcp_session:
287
+ raise Exception(
288
+ f"MCP session for '{mcp_connection_name}' not found"
289
+ )
290
+
291
+ log.info(f"Calling search-cars with args: {tool_args}")
292
+
293
+ try:
294
+ tool_task = mcp_session.call_tool(tool_name, tool_args)
295
+ result = await asyncio.wait_for(
296
+ tool_task, timeout=45.0
297
+ ) # Increased timeout
298
+
299
+ # 1) Collect all chunks (if any) into one string
300
+ if isinstance(result, dict):
301
+ search_data = result
302
+ full_text = json.dumps(result)
303
+ elif hasattr(result, "content") and result.content:
304
+ log.info(
305
+ f"Received {len(result.content)} content blocks from search-cars"
306
+ )
307
+ full_text = "".join(
308
+ getattr(block, "text", "")
309
+ for block in result.content
310
+ )
311
+ else:
312
+ full_text = str(result)
313
+
314
+ # 2) Parse the complete JSON (if not already parsed)
315
+ if not isinstance(result, dict):
316
+ try:
317
+ search_data = json.loads(full_text)
318
+ except json.JSONDecodeError as e:
319
+ log.error(
320
+ f"Failed to parse JSON from search-cars: {e}"
321
+ )
322
+ # fallback: show raw response so you can debug
323
+ await cl.Message(
324
+ content=f"⚠️ Invalid JSON from search-cars:\n```json\n{full_text}\n```"
325
+ ).send()
326
+ tool_step.output = f"JSON Parse Error: {str(e)}"
327
+ tool_result = f"JSON Parse Error: {str(e)}"
328
+ continue
329
+
330
+ log.info(
331
+ f"Raw search result length: {len(full_text)} characters"
332
+ )
333
+ log.info(f"Raw search response: {full_text}")
334
+ tool_step.output = search_data
335
+ tool_result = full_text
336
+
337
+ # 3) Extract data with proper fallbacks
338
+ found = search_data.get("found", 0)
339
+ hits = search_data.get("hits", [])
340
+ facets = search_data.get("facet_counts", [])
341
+ time_ms = search_data.get("search_time_ms", 0)
342
+
343
+ log.info(
344
+ f"Extracted data - found: {found}, hits: {len(hits)}, facets: {len(facets)}, time_ms: {time_ms}"
345
+ )
346
+
347
+ # PYTHON ORCHESTRATION: Automatically display results after search
348
+ log.info(
349
+ "Search completed, automatically displaying results..."
350
+ )
351
+ try:
352
+ ui_result = await show_car_search_results(
353
+ total_documents=found,
354
+ search_time_ms=time_ms,
355
+ hits=hits,
356
+ facet_counts=facets,
357
+ )
358
+ log.info(f"UI display result: {ui_result}")
359
+ except Exception as ui_error:
360
+ log.error(
361
+ f"Error displaying search results: {ui_error}"
362
+ )
363
+ # Show raw response as fallback
364
+ await cl.Message(
365
+ content=f"Search completed but UI display failed. Raw response:\n```json\n{full_text}\n```"
366
+ ).send()
367
+
368
+ except asyncio.TimeoutError:
369
+ error_msg = f"Search operation timed out after 45 seconds. The search server may be overloaded."
370
+ log.error(error_msg)
371
+ tool_step.output = error_msg
372
+ tool_result = error_msg
373
+
374
+ else:
375
+ # Handle other MCP tools
376
+ mcp_connection_name = next(
377
+ (
378
+ conn_name
379
+ for conn_name, tools in all_mcp_tools.items()
380
+ if any(
381
+ t["function"]["name"] == tool_name
382
+ for t in tools
383
+ )
384
+ ),
385
+ None,
386
  )
387
 
388
+ if not mcp_connection_name:
389
+ raise Exception(
390
+ f"Tool '{tool_name}' not found in any MCP connection"
391
+ )
392
+
393
+ mcp_session, _ = cl.context.session.mcp_sessions.get(
394
+ mcp_connection_name
 
 
 
 
 
395
  )
396
+ if not mcp_session:
397
+ raise Exception(
398
+ f"MCP session for '{mcp_connection_name}' not found"
399
+ )
400
 
401
+ log.info(f"Calling {tool_name} with args: {tool_args}")
402
+ tool_task = mcp_session.call_tool(tool_name, tool_args)
403
+ result = await asyncio.wait_for(
404
+ tool_task, timeout=45.0
405
+ ) # Increased timeout
406
+
407
+ # Collect all chunks (if any) into one string
408
+ if isinstance(result, dict):
409
+ raw_output = json.dumps(result)
410
+ elif hasattr(result, "content") and result.content:
411
+ log.info(
412
+ f"Received {len(result.content)} content blocks from {tool_name}"
413
+ )
414
+ raw_output = "".join(
415
+ getattr(block, "text", "")
416
+ for block in result.content
417
+ )
418
+ else:
419
+ raw_output = str(result)
420
+
421
+ log.info(f"Tool {tool_name} completed successfully")
422
+ tool_step.output = raw_output
423
+ tool_result = raw_output
424
+
425
+ except Exception as e:
426
+ error_msg = f"Error executing tool '{tool_name}': {str(e) or 'Unknown error'}"
427
+ log.error(error_msg)
428
+ log.error(f"Exception type: {type(e).__name__}")
429
+ tool_step.error = error_msg
430
+ tool_result = error_msg
431
+
432
+ # ALWAYS add the tool result to history - this is critical for OpenAI API
433
+ history.append(
434
+ {
435
+ "role": "tool",
436
+ "tool_call_id": tool_call.id,
437
+ "content": str(tool_result),
438
+ }
439
+ )
440
+ log.info(
441
+ f"Added tool result to history for {tool_name}: {str(tool_result)[:100]}..."
442
+ )
443
+
444
+ # Now let the AI provide a final response with the tool results
445
+ async with cl.Step(name="Responding", type="llm") as response_step:
446
+ response_step.input = "Generating response based on tool results"
447
+
448
+ final_response = await openai_client.chat.completions.create(
449
+ model="gpt-4o",
450
+ messages=history,
451
+ tools=aggregated_tools if aggregated_tools else None,
452
+ tool_choice="none", # No more tool calls - just respond
453
+ )
454
+
455
+ final_message = final_response.choices[0].message
456
+ response_step.output = final_message
457
 
458
+ if final_message.content:
459
+ await cl.Message(content=final_message.content).send()
460
+ history.append(
461
+ {"role": "assistant", "content": final_message.content}
462
+ )
463
  else:
464
  # If no tool was called, the AI's first response is the final answer.
465
  final_answer = response_message.content
466
  if final_answer:
467
  await cl.Message(content=final_answer).send()
 
468
 
469
  except Exception as e:
470
  log.error(f"Error in main message loop: {e}")
public/elements/CarSearchResults.jsx CHANGED
@@ -1,252 +1,155 @@
1
  import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
2
  import { Badge } from "@/components/ui/badge"
3
- import { MapPin, Gauge, Heart } from "lucide-react"
4
- import { useState, useMemo } from 'react'
5
 
6
- export default function CarSearchResults(props) {
7
- // State for sorting and filtering
8
- const [sortBy, setSortBy] = useState('default')
9
- const [activeFilters, setActiveFilters] = useState({})
10
-
11
- // --- Helper Functions ---
12
  const formatPrice = (priceInCents) => {
13
- return `R ${new Intl.NumberFormat('en-ZA').format(priceInCents / 100)}`
14
  }
15
 
16
  const formatMileage = (mileageInKm) => {
17
- return `${new Intl.NumberFormat('en-ZA').format(mileageInKm)} km`
18
  }
19
 
20
  const getConditionColor = (condition) => {
21
  const colors = {
22
- 'excellent': 'bg-green-100 text-green-800 border-green-200',
23
- 'good': 'bg-blue-100 text-blue-800 border-blue-200',
24
- 'fair': 'bg-yellow-100 text-yellow-800 border-yellow-200',
25
- 'used': 'bg-gray-100 text-gray-800 border-gray-200'
26
  }
27
- return colors[condition?.toLowerCase()] || 'bg-gray-100 text-gray-800 border-gray-200'
28
  }
29
 
30
- // --- Event Handlers ---
31
- const handleToggleFilter = (filterType, value) => {
32
- setActiveFilters(prev => ({
33
- ...prev,
34
- // If the current filter is already set to this value, clear it. Otherwise, set it.
35
- [filterType]: prev[filterType] === value ? null : value
36
- }))
 
 
 
 
 
 
 
 
 
 
37
  }
38
 
39
- const handleClearFilters = () => {
40
- setActiveFilters({});
41
- };
42
-
43
- // --- Memoized Data Processing ---
44
- // useMemo ensures this complex filtering/sorting only re-runs when necessary
45
- const processedCars = useMemo(() => {
46
- const cars = props.hits || []
47
-
48
- return [...cars]
49
- .filter((car) => {
50
- if (activeFilters.brand && car.document.vehicle_make_brand !== activeFilters.brand) return false
51
- if (activeFilters.condition && car.document.vehicle_condition_status !== activeFilters.condition) return false
52
- return true
53
- })
54
- .sort((a, b) => {
55
- switch (sortBy) {
56
- case 'price_asc':
57
- return a.document.vehicle_price_in_cents - b.document.vehicle_price_in_cents
58
- case 'price_desc':
59
- return b.document.vehicle_price_in_cents - a.document.vehicle_price_in_cents
60
- case 'mileage_asc':
61
- return a.document.vehicle_mileage_in_km - b.document.vehicle_mileage_in_km
62
- case 'year_desc':
63
- return b.document.vehicle_manufacturing_year - a.document.vehicle_manufacturing_year
64
- default:
65
- return 0
66
- }
67
- })
68
- }, [props.hits, activeFilters, sortBy])
69
 
70
- // --- Render Logic ---
71
- const hasActiveFilters = Object.values(activeFilters).some(val => val !== null);
72
- const brandFacet = props.facet_counts?.find(f => f.field_name === 'vehicle_make_brand');
73
- const conditionFacet = props.facet_counts?.find(f => f.field_name === 'vehicle_condition_status');
74
 
75
  return (
76
- <div className="w-full max-w-7xl mx-auto space-y-6 font-sans p-4">
77
  {/* Header */}
78
- <div className="mb-4">
79
- <h2 className="text-3xl font-bold text-gray-800 dark:text-white">
80
- {new Intl.NumberFormat('en-ZA').format(props.total_documents || 0)} vehicles found
81
  </h2>
82
- <p className="text-sm text-gray-500 dark:text-gray-400">
83
  Search completed in {props.search_time_ms || 0}ms
84
  </p>
85
  </div>
86
 
87
- {/* Filter and Sort Controls */}
88
- <div className="flex flex-col lg:flex-row justify-between items-start lg:items-center gap-4 mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
89
- <div className="flex flex-wrap gap-x-6 gap-y-4">
90
- {/* Brand Filter */}
91
- {brandFacet?.counts && (
92
- <div className="flex flex-wrap items-center gap-2">
93
- <span className="font-medium text-gray-700 dark:text-gray-300">Brand:</span>
94
- {brandFacet.counts.slice(0, 5).map(brand => (
95
- <button
96
- key={brand.value}
97
- onClick={() => handleToggleFilter('brand', brand.value)}
98
- className={`px-3 py-1 border rounded-full text-sm transition-all duration-200 ${
99
- activeFilters.brand === brand.value
100
- ? 'bg-blue-600 text-white border-blue-600 shadow-md'
101
- : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-600'
102
- }`}
103
- >
104
- {brand.value}
105
- </button>
106
- ))}
107
- </div>
108
- )}
109
- {/* Condition Filter */}
110
- {conditionFacet?.counts && (
111
- <div className="flex flex-wrap items-center gap-2">
112
- <span className="font-medium text-gray-700 dark:text-gray-300">Condition:</span>
113
- {conditionFacet.counts.map(condition => (
114
- <button
115
- key={condition.value}
116
- onClick={() => handleToggleFilter('condition', condition.value)}
117
- className={`px-3 py-1 border rounded-full text-sm transition-all duration-200 ${
118
- activeFilters.condition === condition.value
119
- ? 'bg-blue-600 text-white border-blue-600 shadow-md'
120
- : 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-600'
121
- }`}
122
- >
123
- {condition.value}
124
  </button>
125
- ))}
126
- </div>
127
- )}
128
- </div>
129
 
130
- {/* Sort Dropdown */}
131
- <div className="flex items-center gap-2 flex-shrink-0">
132
- <label htmlFor="sort-by" className="font-medium text-gray-700 dark:text-gray-300">Sort by:</label>
133
- <select
134
- id="sort-by"
135
- value={sortBy}
136
- onChange={(e) => setSortBy(e.target.value)}
137
- className="border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
138
- >
139
- <option value="default">Relevance</option>
140
- <option value="price_asc">Price: Low to High</option>
141
- <option value="price_desc">Price: High to Low</option>
142
- <option value="mileage_asc">Mileage: Low to High</option>
143
- <option value="year_desc">Year: Newest First</option>
144
- </select>
145
- </div>
146
- </div>
147
 
148
- {/* Active Filters Display */}
149
- {hasActiveFilters && (
150
- <div className="flex flex-wrap items-center gap-2 mb-4">
151
- {Object.entries(activeFilters).map(([key, value]) => value && (
152
- <span key={key} className="inline-flex items-center px-2.5 py-1 rounded-full text-sm font-medium bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200">
153
- {key}: {value}
154
- <button
155
- onClick={() => handleToggleFilter(key, value)}
156
- className="ml-2 text-blue-600 dark:text-blue-300 hover:text-blue-800 dark:hover:text-blue-100"
157
- aria-label={`Remove ${value} filter`}
158
- >
159
- &times;
160
- </button>
161
- </span>
162
- ))}
163
- <button
164
- onClick={handleClearFilters}
165
- className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-white underline"
166
- >
167
- Clear all
168
- </button>
169
- </div>
170
- )}
171
 
172
- {/* Car Listings - Responsive Grid */}
173
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
174
- {processedCars.map((car) => (
175
- <Card key={car.document?.id || car.document?.vehicle_listing_id} className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 overflow-hidden hover:shadow-xl hover:-translate-y-1 transition-all duration-300 flex flex-col rounded-lg">
176
- <div className="w-full h-48 bg-gray-200 dark:bg-gray-700 relative">
177
- <img
178
- src={car.document.vehicle_listing_image_urls?.[0]}
179
- alt={`${car.document.vehicle_make_brand} ${car.document.vehicle_model_name}`}
180
- className="w-full h-full object-cover"
181
- onError={(e) => { e.target.onerror = null; e.target.src='https://placehold.co/600x400/cccccc/ffffff?text=No+Image'; }}
182
- />
183
- <button className="absolute top-3 right-3 p-2 bg-white/80 rounded-full hover:bg-white transition-colors shadow-md">
184
- <Heart className="h-4 w-4 text-red-500" />
185
- </button>
186
- </div>
187
- <div className="p-4 flex-1 flex flex-col">
188
- <div className="mb-3">
189
- <h3 className="text-md font-bold text-gray-900 dark:text-white mb-1 line-clamp-1">
190
- {car.document.vehicle_manufacturing_year} {car.document.vehicle_make_brand}
191
- </h3>
192
- <p className="text-gray-700 dark:text-gray-300 text-sm line-clamp-1">
193
- {car.document.vehicle_model_name} {car.document.vehicle_variant_full_name}
194
- </p>
195
- </div>
196
- <div className="flex flex-wrap items-center gap-x-3 gap-y-2 mb-4 text-xs">
197
- <div className="flex items-center gap-1 text-gray-600 dark:text-gray-400" title="Mileage">
198
- <Gauge className="h-4 w-4" />
199
- <span>{formatMileage(car.document.vehicle_mileage_in_km)}</span>
200
- </div>
201
- <div className="flex items-center gap-1 text-gray-600 dark:text-gray-400" title="Location">
202
- <MapPin className="h-4 w-4" />
203
- <span className="truncate">{car.document.vehicle_location_city}</span>
204
  </div>
205
- </div>
206
- {car.document.vehicle_condition_status && (
207
- <Badge className={`mb-4 w-min ${getConditionColor(car.document.vehicle_condition_status)}`}>
208
- {car.document.vehicle_condition_status}
209
- </Badge>
210
- )}
211
- <div className="mt-auto border-t border-gray-200 dark:border-gray-700 pt-3">
212
- <div className="text-xl font-extrabold text-gray-800 dark:text-white mb-2">
213
- {formatPrice(car.document.vehicle_price_in_cents)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  </div>
215
- <a
216
- href="https://www.naked.insure/platform/quote/car"
217
- target="_blank"
218
- rel="noopener noreferrer"
219
- className="text-xs text-blue-600 dark:text-blue-400 font-medium hover:text-blue-800 dark:hover:text-blue-300 hover:underline transition-colors"
220
- >
221
- Est. Insurance from R{new Intl.NumberFormat('en-ZA').format(
222
- Math.round(car.document.vehicle_indicative_quote / 100)
223
- )}/month from naked insurance
224
- </a>
225
  </div>
226
  </div>
227
  </Card>
228
  ))}
229
  </div>
230
 
231
- {/* No Results States */}
232
- {processedCars.length === 0 && hasActiveFilters && (
233
- <div className="text-center py-16">
234
- <h3 className="text-lg font-semibold text-gray-800 dark:text-white mb-2">No vehicles match your filters</h3>
235
- <p className="text-gray-500 dark:text-gray-400 mb-4">Try adjusting or clearing your filters to see more results.</p>
236
- <button
237
- onClick={handleClearFilters}
238
- className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
239
- >
240
- Clear all filters
241
- </button>
242
- </div>
243
- )}
244
- {processedCars.length === 0 && !hasActiveFilters && props.hits?.length === 0 && (
245
- <div className="text-center py-16">
246
- <h3 className="text-lg font-semibold text-gray-800 dark:text-white mb-2">No vehicles found</h3>
247
- <p className="text-gray-500 dark:text-gray-400">Try adjusting your search criteria in the chat.</p>
248
  </div>
249
  )}
250
  </div>
251
  )
252
- }
 
1
  import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
2
  import { Badge } from "@/components/ui/badge"
3
+ import { MapPin, Calendar, Gauge, Heart, MoreHorizontal } from "lucide-react"
 
4
 
5
+ export default function CarSearchResults() {
6
+ console.log("CarSearchResults props:", props)
 
 
 
 
7
  const formatPrice = (priceInCents) => {
8
+ return `R ${new Intl.NumberFormat().format(priceInCents / 100)}`
9
  }
10
 
11
  const formatMileage = (mileageInKm) => {
12
+ return `${new Intl.NumberFormat().format(mileageInKm)} km`
13
  }
14
 
15
  const getConditionColor = (condition) => {
16
  const colors = {
17
+ 'excellent': 'bg-green-100 text-green-800',
18
+ 'good': 'bg-blue-100 text-blue-800',
19
+ 'fair': 'bg-yellow-100 text-yellow-800',
20
+ 'used': 'bg-gray-100 text-gray-800'
21
  }
22
+ return colors[condition?.toLowerCase()]?.toUpperCase() || 'bg-gray-100 text-gray-800'
23
  }
24
 
25
+ const calculateInsuranceQuote = (priceInCents, year, condition) => {
26
+ const vehicleValue = priceInCents / 100
27
+ const currentYear = new Date().getFullYear()
28
+ const age = currentYear - parseInt(year)
29
+
30
+ let baseRate = 0.08
31
+
32
+ if (age <= 3) baseRate = 0.06
33
+ else if (age <= 7) baseRate = 0.07
34
+ else if (age <= 12) baseRate = 0.08
35
+ else baseRate = 0.10
36
+
37
+ if (condition?.toLowerCase() === 'excellent') baseRate *= 0.9
38
+ else if (condition?.toLowerCase() === 'fair') baseRate *= 1.1
39
+
40
+ const monthlyQuote = (vehicleValue * baseRate) / 12
41
+ return Math.round(monthlyQuote)
42
  }
43
 
44
+ const cars = props.hits || []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
+ console.log(props);
 
 
 
47
 
48
  return (
49
+ <div className="w-full max-w-4xl mx-auto space-y-4">
50
  {/* Header */}
51
+ <div className="mb-6">
52
+ <h2 className="text-2xl font-bold text-gray-900">
53
+ {new Intl.NumberFormat().format(props.total_documents || 0)} vehicles found
54
  </h2>
55
+ <p className="text-gray-600">
56
  Search completed in {props.search_time_ms || 0}ms
57
  </p>
58
  </div>
59
 
60
+ {/* Car Listings */}
61
+ <div className="space-y-4">
62
+ {cars.map((car) => (
63
+ <Card key={car.document?.vehicle_listing_id} className="overflow-hidden hover:shadow-lg transition-shadow">
64
+ <div className="flex">
65
+ {/* Car Image */}
66
+ <div className="w-64 h-48 bg-gray-200 flex-shrink-0 relative">
67
+ {car.document.vehicle_listing_image_urls?.[0] ? (
68
+ <img
69
+ src={car.document.vehicle_listing_image_urls[0]}
70
+ alt={`${car.document.vehicle_make_brand} ${car.document.vehicle_model_name}`}
71
+ className="w-full h-full object-cover"
72
+ />
73
+ ) : (
74
+ <div className="w-full h-full flex items-center justify-center text-gray-400">
75
+ No Image
76
+ </div>
77
+ )}
78
+ <button className="absolute top-3 right-3 p-2 bg-white/80 rounded-full hover:bg-white transition-colors">
79
+ <Heart className="h-4 w-4 text-gray-600" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  </button>
81
+ </div>
 
 
 
82
 
83
+ {/* Car Details */}
84
+ <div className="flex-1 p-6">
85
+ <div className="flex justify-between items-start mb-4">
86
+ <div>
87
+ <h3 className="text-xl font-bold text-gray-900 mb-1 dark:text-gray-400">
88
+ {car.document.vehicle_manufacturing_year} {car.document.vehicle_make_brand} {car.document.vehicle_model_name}
89
+ </h3>
90
+ {car.document.vehicle_variant_full_name && (
91
+ <p className="text-gray-600 mb-2 dark:text-gray-400 text-sm">
92
+ {car.document.vehicle_variant_full_name}
93
+ </p>
94
+ )}
95
+ </div>
96
+ </div>
 
 
 
97
 
98
+ {/* Car Stats */}
99
+ <div className="flex items-center gap-6 mb-4 text-sm text-gray-600 dark:text-gray-400">
100
+ <div className="flex items-center gap-1">
101
+ <Gauge className="h-4 w-4" />
102
+ <span>{formatMileage(car.document.vehicle_mileage_in_km)}</span>
103
+ </div>
104
+
105
+ {car.document.vehicle_condition_status && (
106
+ <Badge className={getConditionColor(car.document.vehicle_condition_status)}>
107
+ {car.document.vehicle_condition_status}
108
+ </Badge>
109
+ )}
 
 
 
 
 
 
 
 
 
 
 
110
 
111
+ <div className="flex items-center gap-1">
112
+ <MapPin className="h-4 w-4" />
113
+ <span>{car.document.vehicle_location_suburb || car.document.vehicle_location_city}</span>
114
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  </div>
116
+
117
+ {/* Price and Insurance Quote */}
118
+ <div className="flex justify-between items-end">
119
+ <div>
120
+ <div className="text-2xl font-bold text-green-600 mb-2">
121
+ {formatPrice(car.document.vehicle_price_in_cents)}
122
+ </div>
123
+ <div className="flex flex-col gap-1">
124
+ <div className="text-sm text-blue-600 font-medium">
125
+ Insurance from R{new Intl.NumberFormat().format(
126
+ car.document.vehicle_indicative_quote/100
127
+ )}/month
128
+ </div>
129
+ <div className="flex items-center gap-1 text-xs text-gray-500">
130
+ <img
131
+ src="https://www.naked.insure/favicons/apple-icon-180x180.png"
132
+ alt="Naked Insurance"
133
+ className="h-4 w-auto"
134
+ />
135
+ <span className="font-medium">Naked Insurance</span>
136
+ </div>
137
+ </div>
138
+ </div>
139
  </div>
 
 
 
 
 
 
 
 
 
 
140
  </div>
141
  </div>
142
  </Card>
143
  ))}
144
  </div>
145
 
146
+ {/* No Results */}
147
+ {cars.length === 0 && (
148
+ <div className="text-center py-12">
149
+ <div className="text-gray-400 text-lg mb-2">No vehicles found</div>
150
+ <div className="text-gray-500">Try adjusting your search criteria</div>
 
 
 
 
 
 
 
 
 
 
 
 
151
  </div>
152
  )}
153
  </div>
154
  )
155
+ }