- Introduce a new 'more' translation key in various language files to improve internationalization support. - Update SearchBar component to include an IconButton for additional actions, enhancing user interaction. - Ensure consistency in language context by adding the 'more' key in Arabic, Bulgarian, Czech, German, Greek, English, Spanish, French, Croatian, Hungarian, Italian, Polish, Romanian, Russian, Slovak, Slovenian, Albanian, Serbian, Swedish, Turkish, Ukrainian, and Chinese.
382 lines
12 KiB
JavaScript
382 lines
12 KiB
JavaScript
import React from "react";
|
|
import Box from "@mui/material/Box";
|
|
import TextField from "@mui/material/TextField";
|
|
import InputAdornment from "@mui/material/InputAdornment";
|
|
import Paper from "@mui/material/Paper";
|
|
import List from "@mui/material/List";
|
|
import ListItem from "@mui/material/ListItem";
|
|
import ListItemText from "@mui/material/ListItemText";
|
|
import Typography from "@mui/material/Typography";
|
|
import IconButton from "@mui/material/IconButton";
|
|
import SearchIcon from "@mui/icons-material/Search";
|
|
import KeyboardReturnIcon from "@mui/icons-material/KeyboardReturn";
|
|
import { useNavigate, useLocation } from "react-router-dom";
|
|
import { useTranslation } from "react-i18next";
|
|
import { LanguageContext } from "../../i18n/withTranslation.js";
|
|
|
|
const SearchBar = () => {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const searchParams = new URLSearchParams(location.search);
|
|
const { t, i18n } = useTranslation();
|
|
const languageContext = React.useContext(LanguageContext);
|
|
|
|
// State management
|
|
const [searchQuery, setSearchQuery] = React.useState(
|
|
searchParams.get("q") || ""
|
|
);
|
|
const [suggestions, setSuggestions] = React.useState([]);
|
|
const [showSuggestions, setShowSuggestions] = React.useState(false);
|
|
const [selectedIndex, setSelectedIndex] = React.useState(-1);
|
|
|
|
// Refs for debouncing and timers
|
|
const debounceTimerRef = React.useRef(null);
|
|
const autocompleteTimerRef = React.useRef(null);
|
|
const isFirstKeystrokeRef = React.useRef(true);
|
|
const inputRef = React.useRef(null);
|
|
const suggestionBoxRef = React.useRef(null);
|
|
|
|
const handleSearch = (e) => {
|
|
e.preventDefault();
|
|
delete window.currentSearchQuery;
|
|
setShowSuggestions(false);
|
|
if (searchQuery.trim()) {
|
|
navigate(`/search?q=${encodeURIComponent(searchQuery)}`);
|
|
}
|
|
};
|
|
|
|
const updateSearchState = (value) => {
|
|
setSearchQuery(value);
|
|
|
|
// Dispatch global custom event with search query value
|
|
const searchEvent = new CustomEvent("search-query-change", {
|
|
detail: { query: value },
|
|
});
|
|
// Store the current search query in the window object
|
|
window.currentSearchQuery = value;
|
|
window.dispatchEvent(searchEvent);
|
|
};
|
|
|
|
// @note Autocomplete function using getSearchProducts Socket.io API - returns objects with name and seoName
|
|
const fetchAutocomplete = React.useCallback(
|
|
(query) => {
|
|
if (!query || query.length < 2) {
|
|
setSuggestions([]);
|
|
setShowSuggestions(false);
|
|
return;
|
|
}
|
|
|
|
const currentLanguage = languageContext?.currentLanguage || i18n?.language || 'de';
|
|
|
|
window.socketManager.emit(
|
|
"getSearchProducts",
|
|
{
|
|
query: query.trim(),
|
|
limit: 8,
|
|
language: currentLanguage,
|
|
requestTranslation: currentLanguage === 'de' ? false : true,
|
|
},
|
|
(response) => {
|
|
if (response && response.products) {
|
|
// getSearchProducts returns response.products array
|
|
const suggestions = response.products.slice(0, 8); // Limit to 8 suggestions
|
|
setSuggestions(suggestions);
|
|
setShowSuggestions(suggestions.length > 0);
|
|
setSelectedIndex(-1); // Reset selection
|
|
} else {
|
|
setSuggestions([]);
|
|
setShowSuggestions(false);
|
|
console.log("getSearchProducts failed or no products:", response);
|
|
}
|
|
}
|
|
);
|
|
},
|
|
[languageContext, i18n]
|
|
);
|
|
|
|
const handleSearchChange = (e) => {
|
|
const value = e.target.value;
|
|
|
|
// Always update the input field immediately for responsiveness
|
|
setSearchQuery(value);
|
|
|
|
// Clear any existing timers
|
|
if (debounceTimerRef.current) {
|
|
clearTimeout(debounceTimerRef.current);
|
|
}
|
|
if (autocompleteTimerRef.current) {
|
|
clearTimeout(autocompleteTimerRef.current);
|
|
}
|
|
|
|
// Set the debounce timer for search state update
|
|
const delay = isFirstKeystrokeRef.current ? 100 : 200;
|
|
|
|
debounceTimerRef.current = setTimeout(() => {
|
|
updateSearchState(value);
|
|
isFirstKeystrokeRef.current = false;
|
|
|
|
// Reset first keystroke flag after 1 second of inactivity
|
|
debounceTimerRef.current = setTimeout(() => {
|
|
isFirstKeystrokeRef.current = true;
|
|
}, 1000);
|
|
}, delay);
|
|
|
|
// Set autocomplete timer with longer delay to reduce API calls
|
|
autocompleteTimerRef.current = setTimeout(() => {
|
|
fetchAutocomplete(value);
|
|
}, 300);
|
|
};
|
|
|
|
// Handle keyboard navigation in suggestions
|
|
const handleKeyDown = (e) => {
|
|
if (!showSuggestions || suggestions.length === 0) return;
|
|
|
|
switch (e.key) {
|
|
case "ArrowDown":
|
|
e.preventDefault();
|
|
setSelectedIndex((prev) =>
|
|
prev < suggestions.length - 1 ? prev + 1 : prev
|
|
);
|
|
break;
|
|
case "ArrowUp":
|
|
e.preventDefault();
|
|
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1));
|
|
break;
|
|
case "Enter":
|
|
e.preventDefault();
|
|
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
|
|
const selectedSuggestion = suggestions[selectedIndex];
|
|
setSearchQuery(selectedSuggestion.name);
|
|
updateSearchState(selectedSuggestion.name);
|
|
setShowSuggestions(false);
|
|
navigate(`/Artikel/${selectedSuggestion.seoName}`);
|
|
} else {
|
|
handleSearch(e);
|
|
}
|
|
break;
|
|
case "Escape":
|
|
setShowSuggestions(false);
|
|
setSelectedIndex(-1);
|
|
inputRef.current?.blur();
|
|
break;
|
|
}
|
|
};
|
|
|
|
// Handle suggestion click - navigate to product page directly
|
|
const handleSuggestionClick = (suggestion) => {
|
|
setSearchQuery(suggestion.name);
|
|
updateSearchState(suggestion.name);
|
|
setShowSuggestions(false);
|
|
navigate(`/Artikel/${suggestion.seoName}`);
|
|
};
|
|
|
|
// Handle input focus
|
|
const handleFocus = () => {
|
|
if (suggestions.length > 0 && searchQuery.length >= 2) {
|
|
setShowSuggestions(true);
|
|
}
|
|
};
|
|
|
|
// Handle input blur with delay to allow suggestion clicks
|
|
const handleBlur = () => {
|
|
setTimeout(() => {
|
|
setShowSuggestions(false);
|
|
setSelectedIndex(-1);
|
|
}, 200);
|
|
};
|
|
|
|
// Get delivery days based on availability
|
|
const getDeliveryDays = (product) => {
|
|
if (product.available === 1) {
|
|
return t('delivery.times.standard2to3Days');
|
|
} else if (product.incoming === 1 || product.availableSupplier === 1) {
|
|
return t('delivery.times.supplier7to9Days');
|
|
}
|
|
};
|
|
|
|
// Handle enter icon click
|
|
const handleEnterClick = () => {
|
|
delete window.currentSearchQuery;
|
|
setShowSuggestions(false);
|
|
if (searchQuery.trim()) {
|
|
navigate(`/search?q=${encodeURIComponent(searchQuery)}`);
|
|
}
|
|
};
|
|
|
|
// Clean up timers on unmount
|
|
React.useEffect(() => {
|
|
return () => {
|
|
if (debounceTimerRef.current) {
|
|
clearTimeout(debounceTimerRef.current);
|
|
}
|
|
if (autocompleteTimerRef.current) {
|
|
clearTimeout(autocompleteTimerRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// Close suggestions when clicking outside
|
|
React.useEffect(() => {
|
|
const handleClickOutside = (event) => {
|
|
if (
|
|
suggestionBoxRef.current &&
|
|
!suggestionBoxRef.current.contains(event.target) &&
|
|
!inputRef.current?.contains(event.target)
|
|
) {
|
|
setShowSuggestions(false);
|
|
setSelectedIndex(-1);
|
|
}
|
|
};
|
|
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, []);
|
|
|
|
return (
|
|
<Box
|
|
component="form"
|
|
onSubmit={handleSearch}
|
|
sx={{
|
|
flexGrow: 1,
|
|
mx: { xs: 0, sm: 2, md: 4 },
|
|
position: "relative",
|
|
}}
|
|
>
|
|
<TextField
|
|
ref={inputRef}
|
|
placeholder={t('search.searchProducts')}
|
|
variant="outlined"
|
|
size="small"
|
|
fullWidth
|
|
value={searchQuery}
|
|
onChange={handleSearchChange}
|
|
onKeyDown={handleKeyDown}
|
|
onFocus={handleFocus}
|
|
onBlur={handleBlur}
|
|
autoComplete="off"
|
|
autoCorrect="off"
|
|
autoCapitalize="off"
|
|
spellCheck="false"
|
|
InputProps={{
|
|
startAdornment: (
|
|
<InputAdornment position="start">
|
|
<SearchIcon />
|
|
</InputAdornment>
|
|
),
|
|
endAdornment: (
|
|
<InputAdornment position="end">
|
|
<IconButton
|
|
size="small"
|
|
onClick={handleEnterClick}
|
|
aria-label="Suche starten"
|
|
sx={{
|
|
p: 0.5,
|
|
color: "text.secondary",
|
|
"&:hover": {
|
|
color: "primary.main",
|
|
},
|
|
}}
|
|
>
|
|
<KeyboardReturnIcon fontSize="small" />
|
|
</IconButton>
|
|
</InputAdornment>
|
|
),
|
|
sx: { borderRadius: 2, bgcolor: "background.paper" },
|
|
}}
|
|
/>
|
|
|
|
{/* Autocomplete Suggestions Dropdown */}
|
|
{showSuggestions && suggestions.length > 0 && (
|
|
<Paper
|
|
ref={suggestionBoxRef}
|
|
elevation={4}
|
|
sx={{
|
|
position: "absolute",
|
|
top: "100%",
|
|
left: 0,
|
|
right: 0,
|
|
zIndex: 1300,
|
|
mt: 0.5,
|
|
borderRadius: 2,
|
|
}}
|
|
>
|
|
<List disablePadding>
|
|
{suggestions.map((suggestion, index) => (
|
|
<ListItem
|
|
key={`${suggestion.seoName || 'suggestion'}-${index}`}
|
|
component="button"
|
|
selected={index === selectedIndex}
|
|
onClick={() => handleSuggestionClick(suggestion)}
|
|
sx={{
|
|
cursor: "pointer",
|
|
border: "none",
|
|
background: "none",
|
|
padding: 0,
|
|
margin: 0,
|
|
width: "100%",
|
|
textAlign: "left",
|
|
px: 2, // Add horizontal padding back
|
|
"&:hover": {
|
|
backgroundColor: "action.hover",
|
|
},
|
|
"&.Mui-selected": {
|
|
backgroundColor: "action.selected",
|
|
"&:hover": {
|
|
backgroundColor: "action.selected",
|
|
},
|
|
},
|
|
py: 1,
|
|
}}
|
|
>
|
|
<ListItemText
|
|
primary={
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2 }}>
|
|
<Box sx={{ flexGrow: 1, minWidth: 0, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
|
<Typography variant="body2" noWrap sx={{ mb: 0.5 }}>
|
|
{suggestion.name}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.7rem' }}>
|
|
{getDeliveryDays(suggestion)}
|
|
</Typography>
|
|
</Box>
|
|
<Box sx={{ textAlign: 'right', flexShrink: 0, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
|
<Typography variant="body1" color="primary" sx={{ fontWeight: 'bold', mb: 0.5 }}>
|
|
{new Intl.NumberFormat('de-DE', {style: 'currency', currency: 'EUR'}).format(suggestion.price)}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.65rem' }}>
|
|
{t('product.inclVat', { vat: suggestion.vat })}
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
}
|
|
/>
|
|
</ListItem>
|
|
))}
|
|
</List>
|
|
<Box sx={{ p: 1, borderTop: 1, borderColor: 'divider' }}>
|
|
<IconButton
|
|
fullWidth
|
|
onClick={handleEnterClick}
|
|
sx={{
|
|
justifyContent: 'center',
|
|
py: 1,
|
|
color: 'primary.main',
|
|
'&:hover': {
|
|
backgroundColor: 'action.hover',
|
|
},
|
|
}}
|
|
>
|
|
<Typography variant="body2" sx={{ mr: 1 }}>
|
|
{t('common.more')}
|
|
</Typography>
|
|
<KeyboardReturnIcon fontSize="small" />
|
|
</IconButton>
|
|
</Box>
|
|
</Paper>
|
|
)}
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export default SearchBar;
|