feat: add voice recognition, logo positioning, and UI improvements

This commit is contained in:
Yashwin 2026-01-17 11:04:25 +05:30
parent 1294756aea
commit 7e6f65bc82
13 changed files with 2841 additions and 0 deletions

25
.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

17
index.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<title>AI Chat Interface</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

1726
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "chatbot-ui",
"version": "1.0.0",
"description": "AI Chat Interface with Modern Design",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"lucide-react": "^0.294.0"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"vite": "^5.0.8"
}
}

BIN
public/0x0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

8
src/App.jsx Normal file
View File

@ -0,0 +1,8 @@
import ChatInterface from './components/ChatInterface'
function App() {
return <ChatInterface />
}
export default App

View File

@ -0,0 +1,488 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.chat-container {
width: 100%;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 0;
margin: 0;
background: radial-gradient(
circle at center center,
#ffffff 0%,
#fdf2f8 50%,
#eef2ff 100%
);
}
.chat-content {
width: 100%;
max-width: 800px;
display: flex;
flex-direction: column;
align-items: center;
padding: 80px 20px 40px;
margin: 0 auto;
}
/* Header Logo */
.header-logo {
position: fixed;
top: 20px;
left: 20px;
display: flex;
justify-content: flex-start;
align-items: center;
z-index: 1000;
margin-bottom: 0px;
width: auto;
padding-left: 0;
}
.logo-image {
max-width: 120px;
height: auto;
object-fit: contain;
display: block;
}
/* Greeting Container */
.greeting-container {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 20px;
}
.greeting-text {
font-family: 'Inter', sans-serif;
font-size: 24px;
font-weight: 700;
text-align: center;
margin: 0;
padding: 0;
background: linear-gradient(to right, #8B5CF6, #EC4899);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Main Heading */
.main-heading {
font-family: 'Inter', sans-serif;
font-size: 32px;
font-weight: 400;
color: #111827;
letter-spacing: -0.02em;
margin: 0 0 48px 0;
text-align: center;
line-height: 1.2;
width: 100%;
}
/* Messages Container */
.messages-container {
width: 100%;
display: flex;
flex-direction: column;
gap: 24px;
margin-bottom: 40px;
align-items: flex-start;
flex: 1;
min-height: 0;
}
/* Message Wrapper */
.message-wrapper {
display: flex;
flex-direction: column;
width: 100%;
align-items: flex-start;
}
.message-wrapper.user {
align-items: flex-end;
}
/* Message Label */
.message-label {
font-family: 'Inter', sans-serif;
font-size: 10px;
font-weight: 500;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 8px;
padding: 4px 8px;
line-height: 1;
border-radius: 4px;
display: inline-block;
}
/* User Message Label (ME) */
.message-wrapper.user .message-label {
background-color: #4da6ff;
color: #ffffff;
align-self: flex-end;
}
/* AI Message Label (Chatbot) */
.message-wrapper.ai .message-label {
color: #9ca3af;
background-color: transparent;
}
/* Message Bubble */
.message-bubble {
width: fit-content;
max-width: 85%;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.05);
background: #ffffff;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
word-wrap: break-word;
overflow-wrap: break-word;
}
.message-bubble.user {
padding: 12px 16px;
background-color: #4da6ff;
border: none;
}
.message-bubble.user .message-text {
color: #ffffff;
}
.message-bubble.ai {
padding: 16px 20px;
max-width: 90%;
display: flex;
flex-direction: column;
}
/* Message Image Container */
.message-image-container {
width: 100%;
margin-bottom: 12px;
border-radius: 8px;
overflow: hidden;
max-width: 100%;
}
.message-image {
width: 100%;
max-width: 500px;
max-height: 400px;
height: auto;
display: block;
border-radius: 8px;
object-fit: contain;
cursor: pointer;
transition: transform 0.2s ease;
}
.message-image:hover {
transform: scale(1.02);
}
.message-bubble.ai .message-image-container {
margin-bottom: 12px;
}
.message-bubble.ai .message-image-container:last-child {
margin-bottom: 0;
}
/* Message Text */
.message-text {
font-family: 'Inter', sans-serif;
font-size: 14px;
font-weight: 400;
line-height: 1.5;
white-space: pre-line;
margin: 0;
padding: 0;
word-break: break-word;
}
.message-bubble.ai .message-text {
color: #374151;
}
.message-bubble.ai .message-text:not(:first-child) {
margin-top: 12px;
}
/* Structured Message Content */
.message-content {
width: 100%;
}
.message-section {
margin-top: 20px;
}
.message-section:first-of-type {
margin-top: 0;
}
.message-section-title {
font-family: 'Inter', sans-serif;
font-size: 15px;
font-weight: 600;
color: #111827;
margin: 0 0 10px 0;
padding: 0;
line-height: 1.4;
}
.message-section-list {
margin: 0;
padding-left: 20px;
list-style-type: disc;
}
.message-section-item {
font-family: 'Inter', sans-serif;
font-size: 14px;
font-weight: 400;
color: #374151;
line-height: 1.6;
margin-bottom: 8px;
padding-left: 4px;
}
.message-section-item:last-child {
margin-bottom: 0;
}
/* Input Container */
.input-container {
width: 100%;
position: relative;
display: flex;
align-items: center;
}
.chat-input {
width: 100%;
height: 56px;
padding: 0 100px 0 20px;
background: #ffffff;
border: 1px solid #d1d5db;
border-radius: 12px;
font-family: 'Inter', sans-serif;
font-size: 14px;
font-weight: 400;
color: #111827;
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
.chat-input::placeholder {
color: #9ca3af;
}
.chat-input:focus {
border-color: #94a3b8;
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1);
}
.send-button {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
width: 32px;
height: 32px;
display: flex;
justify-content: center;
align-items: center;
background: transparent;
border: none;
border-radius: 6px;
color: #94a3b8;
cursor: pointer;
transition: all 0.2s ease;
outline: none;
padding: 0;
}
.send-button:hover {
background: rgba(148, 163, 184, 0.1);
color: #64748b;
}
.send-button:active {
transform: translateY(-50%) scale(0.95);
}
.send-button:focus {
outline: 2px solid rgba(148, 163, 184, 0.3);
outline-offset: 2px;
}
.send-button svg {
stroke-width: 1.5;
width: 20px;
height: 20px;
}
.send-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.send-button:disabled:hover {
background: transparent;
color: #94a3b8;
}
.voice-button {
position: absolute;
right: 52px;
top: 50%;
transform: translateY(-50%);
width: 32px;
height: 32px;
display: flex;
justify-content: center;
align-items: center;
background: transparent;
border: none;
border-radius: 6px;
color: #94a3b8;
cursor: pointer;
transition: all 0.2s ease;
outline: none;
padding: 0;
}
.voice-button:hover {
background: rgba(148, 163, 184, 0.1);
color: #64748b;
}
.voice-button:active {
transform: translateY(-50%) scale(0.95);
}
.voice-button:focus {
outline: 2px solid rgba(148, 163, 184, 0.3);
outline-offset: 2px;
}
.voice-button svg {
stroke-width: 1.5;
width: 20px;
height: 20px;
transition: all 0.2s ease;
}
.voice-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.voice-button:disabled:hover {
background: transparent;
color: #94a3b8;
}
.voice-button .listening {
color: #ef4444;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.1);
}
}
/* Loading Indicator */
.loading-indicator {
display: flex;
align-items: center;
gap: 8px;
font-family: 'Inter', sans-serif;
font-size: 14px;
color: #9ca3af;
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Responsive Design */
@media (max-width: 768px) {
.chat-content {
padding: 60px 16px 32px;
}
.header-logo {
top: 16px;
left: 16px;
}
.logo-image {
max-width: 100px;
}
.main-heading {
font-size: 28px;
margin-bottom: 40px;
}
.messages-container {
gap: 20px;
margin-bottom: 32px;
}
.message-bubble {
max-width: 90%;
}
.message-bubble.ai {
max-width: 95%;
}
.chat-input {
height: 52px;
padding: 0 96px 0 16px;
}
.voice-button {
right: 50px;
width: 32px;
height: 32px;
}
.send-button {
right: 10px;
width: 32px;
height: 32px;
}
}

View File

@ -0,0 +1,363 @@
import { useState, useEffect, useRef } from 'react'
import { Send, Loader2, Mic } from 'lucide-react'
import { sendQuery } from '../services/api'
import { formatStructuredMessage } from '../utils/formatMessage'
import './ChatInterface.css'
const ChatInterface = () => {
const [messages, setMessages] = useState([])
const [inputValue, setInputValue] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isListening, setIsListening] = useState(false)
const messagesEndRef = useRef(null)
const recognitionRef = useRef(null)
const baseInputValueRef = useRef('')
const getTimeBasedGreeting = () => {
const hour = new Date().getHours()
if (hour >= 5 && hour < 12) {
return 'Hi, Good Morning!'
} else if (hour >= 12 && hour < 17) {
return 'Hi, Good Afternoon!'
} else if (hour >= 17 && hour < 21) {
return 'Hi, Good Evening!'
} else {
return 'Hi, Good Night!'
}
}
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}
useEffect(() => {
if (messages.length > 0) {
scrollToBottom()
}
}, [messages])
// Initialize Speech Recognition
useEffect(() => {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition
if (!SpeechRecognition) {
console.warn('Speech recognition not supported in this browser')
return
}
const recognition = new SpeechRecognition()
recognition.continuous = false
recognition.interimResults = true
recognition.lang = 'en-US'
recognition.onstart = () => {
setIsListening(true)
console.log('Speech recognition started')
}
recognition.onresult = (event) => {
let interimTranscript = ''
let finalTranscript = ''
for (let i = event.resultIndex; i < event.results.length; i++) {
const transcript = event.results[i][0].transcript
if (event.results[i].isFinal) {
finalTranscript += transcript + ' '
} else {
interimTranscript += transcript
}
}
console.log('Speech recognition result:', { finalTranscript, interimTranscript })
// Combine base value with final and interim transcripts
const baseText = baseInputValueRef.current
let newValue = baseText
if (finalTranscript) {
newValue = (baseText ? baseText + ' ' : '') + finalTranscript.trim()
baseInputValueRef.current = newValue // Update base for next recognition
console.log('Final transcript added, new value:', newValue)
} else if (interimTranscript) {
newValue = (baseText ? baseText + ' ' : '') + interimTranscript
console.log('Interim transcript added, new value:', newValue)
}
setInputValue(newValue)
}
recognition.onerror = (event) => {
console.error('Speech recognition error:', event.error)
setIsListening(false)
if (event.error === 'no-speech') {
console.log('No speech detected')
// Don't show alert for no-speech, just stop
} else if (event.error === 'not-allowed') {
alert('Microphone permission denied. Please enable microphone access in your browser settings.')
} else if (event.error === 'aborted') {
console.log('Speech recognition aborted')
} else {
console.error('Speech recognition error:', event.error)
}
}
recognition.onend = () => {
setIsListening(false)
console.log('Speech recognition ended')
}
recognitionRef.current = recognition
return () => {
if (recognitionRef.current) {
try {
recognitionRef.current.stop()
} catch (e) {
// Ignore errors when stopping
}
}
}
}, [])
const handleSend = async () => {
const query = inputValue.trim()
if (!query || isLoading) return
// Add user message to chat
const userMessage = {
type: 'user',
text: query
}
setMessages(prev => [...prev, userMessage])
setInputValue('')
setIsLoading(true)
// Add loading message
const loadingMessage = {
type: 'ai',
text: '',
isLoading: true
}
setMessages(prev => [...prev, loadingMessage])
try {
// Call API
const response = await sendQuery(query)
// Replace loading message with actual response
const answerText = response.answer || response.message || 'Sorry, I could not process your query.'
const structuredMessage = formatStructuredMessage(answerText)
setMessages(prev => {
const newMessages = [...prev]
const loadingIndex = newMessages.findIndex(msg => msg.isLoading === true)
if (loadingIndex !== -1) {
newMessages[loadingIndex] = {
type: 'ai',
text: answerText,
structuredMessage: structuredMessage,
imageUrl: response.source?.property_image_url || null,
isLoading: false
}
}
return newMessages
})
} catch (error) {
// Replace loading message with error message
setMessages(prev => {
const newMessages = [...prev]
const loadingIndex = newMessages.findIndex(msg => msg.isLoading === true)
if (loadingIndex !== -1) {
newMessages[loadingIndex] = {
type: 'ai',
text: 'Sorry, there was an error processing your query. Please try again.',
isLoading: false
}
}
return newMessages
})
} finally {
setIsLoading(false)
}
}
const handleKeyPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
const handleVoiceToggle = () => {
if (!recognitionRef.current) {
alert('Speech recognition is not supported in your browser. Please use Chrome, Edge, or Safari.')
return
}
if (isListening) {
try {
recognitionRef.current.stop()
setIsListening(false)
} catch (error) {
console.error('Error stopping speech recognition:', error)
setIsListening(false)
}
} else {
// Store current input value as base before starting
baseInputValueRef.current = inputValue.trim()
console.log('Starting voice recognition, base text:', baseInputValueRef.current)
try {
recognitionRef.current.start()
} catch (error) {
console.error('Error starting speech recognition:', error)
// If recognition is already running, try to stop and restart
if (error.message && error.message.includes('already started')) {
try {
recognitionRef.current.stop()
setTimeout(() => {
recognitionRef.current.start()
}, 100)
} catch (retryError) {
console.error('Error retrying speech recognition:', retryError)
setIsListening(false)
}
} else {
setIsListening(false)
alert('Could not start voice recognition. Please check your microphone permissions.')
}
}
}
}
return (
<div className="chat-container">
<div className="chat-content">
{/* Header Logo */}
<div className="header-logo">
<img
src="/0x0.png"
alt="ONE BROKER GROUP"
className="logo-image"
/>
</div>
{/* Show greeting and heading only when no messages */}
{messages.length === 0 && (
<>
{/* Greeting Message - Above heading */}
<div className="greeting-container">
<p className="greeting-text">{getTimeBasedGreeting()}</p>
</div>
{/* Main Heading - Below greeting */}
<h1 className="main-heading">
Exploring Dubai real estate?<br />
Ask our smart chatbot
</h1>
</>
)}
{/* Messages Container - Show when there are messages */}
{messages.length > 0 && (
<div className="messages-container">
{messages.map((message, index) => (
<div key={index} className={`message-wrapper ${message.type}`}>
<div className="message-label">
{message.type === 'user' ? 'ME' : 'Chatbot'}
</div>
<div className={`message-bubble ${message.type}`}>
{message.isLoading ? (
<div className="loading-indicator">
<Loader2 size={20} className="spinner" />
<span>Thinking...</span>
</div>
) : (
<>
{message.imageUrl && (
<div className="message-image-container">
<img
src={message.imageUrl}
alt="Property"
className="message-image"
onError={(e) => {
e.target.style.display = 'none'
}}
/>
</div>
)}
{message.structuredMessage && message.structuredMessage.sections.length > 0 ? (
<div className="message-content">
{message.structuredMessage.intro && (
<p className="message-text">{message.structuredMessage.intro}</p>
)}
{message.structuredMessage.sections.map((section, idx) => (
<div key={idx} className="message-section">
<h3 className="message-section-title">{section.title}:</h3>
<ul className="message-section-list">
{section.content.map((item, itemIdx) => (
<li key={itemIdx} className="message-section-item">{item}</li>
))}
</ul>
</div>
))}
</div>
) : message.text && (
<p className="message-text">{message.text}</p>
)}
</>
)}
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
)}
{/* Input Field */}
<div className="input-container">
<input
type="text"
className="chat-input"
placeholder="Ask me anything about Dubai real estate"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
/>
<button
className="voice-button"
onClick={handleVoiceToggle}
type="button"
disabled={isLoading}
aria-label={isListening ? 'Stop listening' : 'Start voice input'}
title={isListening ? 'Stop listening' : 'Start voice input'}
>
<Mic
size={20}
strokeWidth={1.5}
className={isListening ? 'listening' : ''}
/>
</button>
<button
className="send-button"
onClick={handleSend}
type="button"
disabled={isLoading || !inputValue.trim()}
aria-label="Send message"
>
{isLoading ? (
<Loader2 size={20} strokeWidth={1.5} className="spinner" />
) : (
<Send size={20} strokeWidth={1.5} />
)}
</button>
</div>
</div>
</div>
)
}
export default ChatInterface

17
src/index.css Normal file
View File

@ -0,0 +1,17 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
width: 100%;
min-height: 100vh;
}

11
src/main.jsx Normal file
View File

@ -0,0 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

26
src/services/api.js Normal file
View File

@ -0,0 +1,26 @@
const API_BASE_URL = 'https://property-bot.tech4bizsolutions.com/api/v1/property/query'
export const sendQuery = async (query) => {
try {
const response = await fetch(API_BASE_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: query,
}),
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
return data
} catch (error) {
console.error('Error sending query:', error)
throw error
}
}

130
src/utils/formatMessage.js Normal file
View File

@ -0,0 +1,130 @@
export const formatStructuredMessage = (text) => {
if (!text) return null
// Enhanced section patterns - look for headers with word boundaries to avoid partial matches
const sectionPatterns = [
{
patterns: [/\bproject specifications?:?\b/i],
title: 'Project Specifications'
},
{
patterns: [/\bdesign\s*&\s*layouts?:?\b/i, /\bdesign:\s*/i],
title: 'Design & Layouts'
},
{
patterns: [/\bunit types?:?\b/i, /\bunits?:\s*/i],
title: 'Unit Types'
},
{
patterns: [/\bamenities?:?\b/i, /\bfacilities?:\s*/i],
title: 'Amenities'
},
{
patterns: [/\bconnectivity?:?\b/i, /\blocation:\s*/i],
title: 'Connectivity'
},
{
patterns: [/\bpayment plan?:?\b/i, /\bpayment:\s*/i],
title: 'Payment Plan'
},
{
patterns: [/\bpricing:\s*/i],
title: 'Pricing'
},
{
patterns: [/\boverview?:?\b/i],
title: 'Overview'
},
{
patterns: [/\bdeveloper?:?\b/i],
title: 'Developer'
},
]
// Find all section matches
const matches = []
sectionPatterns.forEach(({ patterns, title, keywords }) => {
patterns.forEach(pattern => {
const match = pattern.exec(text)
if (match) {
matches.push({ index: match.index, title, match: match[0], keywords })
}
})
})
// If no explicit section headers found, don't try keyword matching
// as it can cause incorrect matches and text corruption
// Remove duplicates and sort by index
const uniqueMatches = []
const seenTitles = new Set()
matches.sort((a, b) => a.index - b.index).forEach(match => {
if (!seenTitles.has(match.title)) {
seenTitles.add(match.title)
uniqueMatches.push(match)
}
})
if (uniqueMatches.length === 0) {
// If still no matches, check if text contains numbered lists or bullet points
// and try to extract structured content
const lines = text.split(/\n+/).filter(line => line.trim())
const hasListFormat = lines.some(line => /^\d+\.|^[-*]/.test(line.trim()))
if (hasListFormat) {
// Try to group by topic based on content
return null // Let it fall through to plain text for now
}
return { intro: text, sections: [] }
}
// Extract intro text (before first section)
const introText = uniqueMatches.length > 0
? text.substring(0, uniqueMatches[0].index).trim()
: text.trim()
// Extract each section
const sections = []
uniqueMatches.forEach((match, index) => {
// Find the actual end of the matched pattern (including colon if present)
let startIndex = match.index + match.match.length
// Skip any whitespace or colons after the match
while (startIndex < text.length && /[\s:]/.test(text[startIndex])) {
startIndex++
}
const endIndex = index < uniqueMatches.length - 1
? uniqueMatches[index + 1].index
: text.length
let content = text.substring(startIndex, endIndex).trim()
// Clean up content - only remove leading colons/dashes if they're at the very start
content = content.replace(/^[:\-–—]\s+/, '').trim()
// Split into lines and format
const lines = content.split(/\n+/).filter(line => line.trim())
const formattedContent = lines.map(line => {
const originalLine = line
// Only remove list markers if they're actually at the start
line = line.replace(/^\d+\.\s+/, '') // Numbered lists (1. )
.replace(/^[-*•]\s+/, '') // Bullet points
.replace(/^[:\-–—]\s+/, '') // Colons/dashes
// If we removed something, trim, otherwise use original
line = line !== originalLine ? line.trim() : line.trim()
return line
}).filter(line => line.length > 0)
if (formattedContent.length > 0) {
sections.push({
title: match.title,
content: formattedContent
})
}
})
return { intro: introText, sections }
}

7
vite.config.js Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})