Skip to content
Extraits de code Groupes Projets
Valider 3b537866 rédigé par oussama aftys's avatar oussama aftys
Parcourir les fichiers

Merge branch 'feature/MS-96' into 'develop'

Resolve MS-96 "Feature/"

Closes MS-96

See merge request !490
parents 66f6a0d4 df04d8a6
Branches
1 requête de fusion!490Resolve MS-96 "Feature/"
......@@ -40,8 +40,8 @@ NEXT_PUBLIC_DEFAULT_IMAGE_URL=https://mydressin-rec.s3.eu-west-3.amazonaws.com/I
#GATEWAY API URL
NEXT_PUBLIC_MYDRESSIN_GATEWAY_API_URL=https://api-gateway.mydressin-server.com/
NEXT_PUBLIC_MYDRESSIN_CLIENT_URL=https://app.mydressin-server.com/*
NEXT_PUBLIC_MYDRESSIN_GATEWAY_API_URL=https://api-gateway.mydressin-server.com
NEXT_PUBLIC_MYDRESSIN_CLIENT_URL=https://app.mydressin-server.com
......
......@@ -4,6 +4,7 @@ import { useMemo } from 'react';
import axiosInstance, { endpoints, fetcher } from "./server";
import useSWR, { mutate} from 'swr';
import { tr } from "date-fns/locale";
import axios from 'axios';
const options = {
......@@ -518,8 +519,59 @@ export function useSearchProductsLive(page: number, size: number, keyword: strin
}
export async function searchProduct(keyword: string) {
const URL = `${endpoints.Addons.PromoCode.getProductsPublishedWebsite(0, 5, keyword)}`;
const response = await axiosInstance.get(URL);
return response.data.content as IProductListItem[];
// Normalize the keyword (trim and lowercase)
const normalizedKeyword = keyword.trim().toLowerCase();
if (!normalizedKeyword) {
return [];
}
// Setup retry logic
const maxRetries = 2;
let retries = 0;
let lastError: unknown = null;
// Try with retry logic
while (retries <= maxRetries) {
try {
const URL = `${endpoints.Addons.PromoCode.getProductsPublishedWebsite(0, 10, normalizedKeyword)}`;
// Make the request without cache headers
const response = await axiosInstance.get(URL);
// Validate response format before returning
const data = response.data?.content;
if (!Array.isArray(data)) {
console.warn('Invalid product search response format:', response.data);
return [];
}
return data as IProductListItem[];
} catch (error: unknown) {
lastError = error;
// Only retry on network errors or 5xx server errors
const isNetworkError = error instanceof Error && error.message?.includes('Network Error');
const isServerError = axios.isAxiosError(error) && error.response?.status !== undefined && error.response.status >= 500;
if (isNetworkError || isServerError) {
retries++;
// Exponential backoff delay
const delay = Math.pow(2, retries) * 300;
await new Promise(resolve => setTimeout(resolve, delay));
} else {
// Don't retry for client errors
break;
}
}
}
// Log the error
console.error('Product search failed after retries:', lastError);
// Throw the error to be handled by the caller
if (lastError instanceof Error) {
throw lastError;
} else {
throw new Error('Failed to search products');
}
}
// components/ProductsSearch.tsx
import React, { useMemo, useCallback } from 'react';
import React, { useMemo, useCallback, useState, useEffect } from 'react';
import {
Box,
TextField,
......@@ -11,6 +11,10 @@ import {
Avatar,
SxProps,
Theme,
CircularProgress,
IconButton,
Chip,
Stack,
} from '@mui/material';
import { autocompleteClasses } from '@mui/material/Autocomplete';
import Iconify from '@/shared/components/iconify';
......@@ -25,18 +29,52 @@ type Props = {
onSearch: (inputValue: string) => void;
loading: boolean;
sx?: SxProps<Theme>;
minSearchChars?: number;
};
// Maximum number of search history items to store
const MAX_SEARCH_HISTORY = 5;
const ProductsSearch: React.FC<Props> = ({
query,
results,
onSearch,
loading,
sx,
minSearchChars = 3,
}) => {
const { addProductToLiveFunction, products } = useLiveProducts();
const [searchHistory, setSearchHistory] = useState<string[]>([]);
// Log when props change
useEffect(() => {
console.log('LiveSearch component received results:', results);
console.log('Current query:', query);
console.log('Loading state:', loading);
}, [results, query, loading]);
// Save search to history (in-memory only)
const saveSearchToHistory = useCallback((searchTerm: string) => {
if (!searchTerm || searchTerm.trim().length < minSearchChars) return;
setSearchHistory(prevHistory => {
const normalizedTerm = searchTerm.trim();
// Remove duplicates and add new term at the beginning
const newHistory = [
normalizedTerm,
...prevHistory.filter(term => term !== normalizedTerm)
].slice(0, MAX_SEARCH_HISTORY);
return newHistory;
});
}, [minSearchChars]);
const { addProductToLiveFunction , products} = useLiveProducts();
// When a user selects something and there are results, add to history
useEffect(() => {
if (results.length > 0 && query.trim().length >= minSearchChars) {
saveSearchToHistory(query);
}
}, [results, query, minSearchChars, saveSearchToHistory]);
const isProductInList = useCallback(
(product: IProductListItem) => products.some((p) => p.sku === product.ugs),
......@@ -61,8 +99,17 @@ const ProductsSearch: React.FC<Props> = ({
[addProductToLiveFunction]
);
const handleHistoryItemClick = useCallback((term: string) => {
onSearch(term);
}, [onSearch]);
const handleClearHistory = useCallback(() => {
setSearchHistory([]);
}, []);
const renderOption = useCallback(
(props: React.HTMLAttributes<HTMLLIElement>, product: IProductListItem) => {
console.log('Rendering option for product:', product);
const isAdded = isProductInList(product);
return (
<Box
......@@ -106,6 +153,10 @@ const ProductsSearch: React.FC<Props> = ({
},
[isProductInList, handleAdd]
);
// Placeholder message based on character count
const placeholderText = useMemo(() => {
return `Rechercher produits par UGS (min. ${minSearchChars} caractères)...`;
}, [minSearchChars]);
const noOptions = useMemo(
() => <SearchNotFound query={query} sx={{ bgcolor: 'unset' }} />,
......@@ -113,46 +164,87 @@ const ProductsSearch: React.FC<Props> = ({
);
return (
<Autocomplete
sx={sx}
inputValue={query}
loading={loading}
autoHighlight
popupIcon={null}
options={results}
onInputChange={(event, newValue) => onSearch(newValue)}
getOptionLabel={(option) => option.name}
noOptionsText={noOptions}
isOptionEqualToValue={(option, value) => option.id === value.id}
slotProps={{
popper: {
placement: 'bottom-start',
sx: { minWidth: 320 },
},
paper: {
sx: {
[` .${autocompleteClasses.option}`]: {
pl: 0.75,
<Box sx={{ width: '100%', ...sx }}>
<Autocomplete
inputValue={query}
loading={loading}
autoHighlight
popupIcon={null}
options={results}
getOptionKey={(option) => option.id}
onInputChange={(event, newValue) => onSearch(newValue)}
getOptionLabel={(option) => option.name}
noOptionsText={loading ? 'Chargement...' : noOptions}
isOptionEqualToValue={(option, value) => option.id === value.id}
filterOptions={(x) => x}
blurOnSelect
clearOnBlur={false}
disableListWrap
slotProps={{
popper: {
placement: 'bottom-start',
sx: { minWidth: 320 },
},
paper: {
sx: {
[` .${autocompleteClasses.option}`]: {
pl: 0.75,
},
},
},
},
}}
renderInput={(params) => (
<TextField
{...params}
placeholder="Ajouter produits par UGS..."
InputProps={{
...params.InputProps,
startAdornment: (
<InputAdornment position="start">
<Iconify icon="simple-line-icons:plus" sx={{ ml: 1, color: 'text.disabled' }} />
</InputAdornment>
),
}}
/>
}}
renderInput={(params) => (
<TextField
{...params}
placeholder={placeholderText}
InputProps={{
...params.InputProps,
startAdornment: (
<InputAdornment position="start">
<Iconify icon="eva:search-fill" sx={{ ml: 1, color: 'text.disabled' }} />
</InputAdornment>
),
endAdornment: (
<InputAdornment position="end">
{loading ? <CircularProgress size={20} /> : null}
{params.InputProps.endAdornment}
</InputAdornment>
),
}}
/>
)}
renderOption={renderOption}
/>
{/* Search History Section */}
{searchHistory.length > 0 && (
<Box sx={{ mt: 1, p: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Box sx={{ fontSize: '0.75rem', color: 'text.secondary' }}>
Recherches récentes
</Box>
<IconButton
size="small"
onClick={handleClearHistory}
sx={{ p: 0.5 }}
>
<Iconify icon="eva:trash-2-outline" width={14} />
</IconButton>
</Box>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{searchHistory.map((term, index) => (
<Chip
key={index}
label={term}
size="small"
onClick={() => handleHistoryItemClick(term)}
sx={{ mb: 0.5 }}
/>
))}
</Stack>
</Box>
)}
renderOption={renderOption}
/>
</Box>
);
};
......
import { useResponsive } from "@/hooks/use-responsive";
import { Alert, Box, Button, Card, Grid, Modal } from "@mui/material";
import { Alert, Box, Button, Card, Grid, Modal, Typography } from "@mui/material";
import ProductCard from "./details-product-card";
import Scrollbar from "@/shared/components/scrollbar";
import useScroll from "@/hooks/use-scroll";
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { searchProduct } from "@/shared/api/live";
import { ILiveProduct, IProductListItem } from "@/shared/types/live";
import LiveSearch from "./details-products-search";
......@@ -11,6 +11,25 @@ import useLiveProducts from "@/contexts/live-products/use-live-products";
import { SplashScreen } from "@/shared/components/loading-screen";
import { useLiveData } from "@/contexts/live-details";
// Custom hook for debouncing
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
console.log('Debounce hook received new value, setting timeout');
const timer = setTimeout(() => {
console.log('Debounce timeout completed, updating value');
setDebouncedValue(value);
}, delay);
return () => {
console.log('Cleaning up previous debounce timeout');
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}
export default function ProductsSection() {
const lgUp = useResponsive('up', 'lg');
......@@ -18,12 +37,18 @@ export default function ProductsSection() {
const { products, productsLoading , productsError} = useLiveProducts();
const liveData = useLiveData();
const { endRef: productsEndRef } = useScroll(products);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState("");
const [searchResults, setSearchResults] = useState<IProductListItem[]>([]);
const [searchResults, setSearchResults] = useState<IProductListItem[]>([]);
const [searchError, setSearchError] = useState<string | null>(null);
// Minimum characters before search is triggered
const MIN_SEARCH_CHARS = 3;
// Use the custom debounce hook
const debouncedQuery = useDebounce(query, 400);
function handleClose() {
setOpen(false);
......@@ -34,28 +59,70 @@ export default function ProductsSection() {
}
function handleSearch(inputValue: string) {
console.log('Search input received:', inputValue);
setQuery(inputValue);
setSearchError(null);
}
useEffect(() => {
if (query.trim() === "") {
// Improved search function
const performSearch = useCallback((searchQuery: string) => {
console.log('Performing search with query:', searchQuery);
// Reset results if query is too short
if (searchQuery.trim().length < MIN_SEARCH_CHARS) {
console.log('Query too short, minimum required:', MIN_SEARCH_CHARS);
setSearchResults([]);
return;
}
// Normalize the query
const normalizedQuery = searchQuery.trim().toLowerCase();
console.log('Normalized query:', normalizedQuery);
setLoading(true);
setSearchError(null);
searchProduct(query)
console.log('Calling search API with query:', normalizedQuery);
searchProduct(normalizedQuery)
.then((data) => {
setSearchResults(data || []);
console.log('Search API response:', data);
console.log('Number of results:', data.length);
setSearchResults(data);
})
.catch((error) => {
.catch((error) => {
console.error('Search API error:', error);
setSearchError('Erreur lors de la recherche de produits');
setSearchResults([]);
})
.finally(() => {
console.log('Search request completed');
setLoading(false);
});
}, [query]);
}, []);
// Effect to trigger search when debounced query changes
useEffect(() => {
console.log('Debounced query changed:', debouncedQuery);
performSearch(debouncedQuery);
}, [debouncedQuery, performSearch]);
// Get the appropriate message for search status
const searchStatusMessage = useMemo(() => {
if (searchError) {
return <Alert severity="error">{searchError}</Alert>;
}
if (query.trim().length >= MIN_SEARCH_CHARS && !loading && searchResults.length === 0) {
return <Typography variant="body2" sx={{ p: 2, textAlign: 'center', color: 'text.secondary' }}>
Aucun produit trouvé pour "{query}"
</Typography>;
}
if (query.trim().length > 0 && query.trim().length < MIN_SEARCH_CHARS) {
return <Typography variant="body2" sx={{ p: 1, textAlign: 'center', color: 'text.secondary' }}>
Saisissez au moins {MIN_SEARCH_CHARS} caractères pour rechercher
</Typography>;
}
return null;
}, [searchError, query, loading, searchResults.length]);
if (productsLoading) {
return <SplashScreen />;
......@@ -75,7 +142,9 @@ export default function ProductsSection() {
onSearch={handleSearch}
loading={loading}
sx={{ marginBottom: { xs: 0, lg: 2 } }}
minSearchChars={MIN_SEARCH_CHARS}
/>
{searchStatusMessage}
<Card sx={{ height: 652 }}>
<Scrollbar ref={productsEndRef} sx={{ px: 2, py: 5, height: 1 }}>
{products.map((product: ILiveProduct) => (
......@@ -112,7 +181,9 @@ export default function ProductsSection() {
results={searchResults}
onSearch={handleSearch}
loading={loading}
minSearchChars={MIN_SEARCH_CHARS}
/>
{searchStatusMessage}
<Scrollbar ref={productsEndRef} sx={{ px: 2, py: 5, height: 1, overflowY: 'auto' }}>
<Box>
{products.map((product: ILiveProduct) => (
......
0% ou .
You are about to add 0 people to the discussion. Proceed with caution.
Terminez d'abord l'édition de ce message.
Veuillez vous inscrire ou vous pour commenter