Files
reactShop/src/components/header/SearchBar.js
sebseb7 4f5a44dc7d feat(i18n): add 'more' translation key across multiple languages and enhance SearchBar
- 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.
2025-11-16 07:58:08 +01:00

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;