e3e7d2f60b
Run Tests on Branches / Detect Changes (push) Successful in 17s
Run Tests on Branches / Backend Tests (push) Successful in 2m44s
Run Tests on Branches / Frontend Tests (push) Successful in 1m59s
Run Tests on Branches / Frontend Mobile Tests (push) Has been skipped
Run Tests on Branches / Parapharmacy API Tests (push) Has been skipped
Run Tests on Branches / PIP Platform Tests (push) Has been skipped
- Add is_open_now and is_always_open helpers in backend - Add backend endpoint enrichment with is_open and is_24h fields - Add client-side getOpenStatus with 24h, opens-at, opens-tomorrow support - Add open-now filter toggle in PublicView and SearchView - Add pharmacy no-hours fallback display - Add sticky pharmacy controls on scroll - Add admin hours editor with 24h toggle - Add translations (es/ca) for all hour-related strings - Add backend tests for hours logic and pharmacy endpoint - Remove unused backup test files
431 lines
15 KiB
React
431 lines
15 KiB
React
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
|
import SearchBar from '../components/SearchBar';
|
|
import MedicineResults from '../components/MedicineResults';
|
|
import ProductResults from '../components/ProductResults';
|
|
import PharmacyList from '../components/PharmacyList';
|
|
import PharmacyMap from '../components/PharmacyMap';
|
|
import { haversineKm, getUserPosition, hasCachedPosition } from '../utils/geo';
|
|
import { useTranslation } from '../i18n';
|
|
import { getOpenStatus } from '../utils/hours';
|
|
import './SearchView.css';
|
|
|
|
const suggestions = [
|
|
{ name: 'Paracetamol', icon: 'medication', color: 'neutral-1' },
|
|
{ name: 'Ibuprofeno', icon: 'pill', color: 'neutral-2' },
|
|
{ name: 'Aspirina', icon: 'vaccines', color: 'neutral-3' },
|
|
{ name: 'Omeprazol', icon: 'emergency_home', color: 'neutral-4' },
|
|
];
|
|
|
|
function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigateToProduct }) {
|
|
const { t } = useTranslation();
|
|
const [searchQuery, setSearchQuery] = useState(initialQuery);
|
|
const [medicines, setMedicines] = useState([]);
|
|
const [products, setProducts] = useState([]);
|
|
const [selectedMedicine, setSelectedMedicine] = useState(null);
|
|
const [pharmacies, setPharmacies] = useState([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [userPosition, setUserPosition] = useState(null);
|
|
const [positionSource, setPositionSource] = useState(null);
|
|
const [sortByDistance, setSortByDistance] = useState(false);
|
|
const [openNow, setOpenNow] = useState(false);
|
|
const [locating, setLocating] = useState(false);
|
|
const [locationError, setLocationError] = useState('');
|
|
const [recentSearches, setRecentSearches] = useState([]);
|
|
|
|
// Precache position on mount — makes first "sort by distance" nearly instant
|
|
useEffect(() => {
|
|
if (hasCachedPosition()) {
|
|
getUserPosition().then(pos => {
|
|
setUserPosition(pos);
|
|
}).catch(() => {});
|
|
}
|
|
}, []);
|
|
|
|
// Fetch recent searches when user is logged in
|
|
useEffect(() => {
|
|
if (!currentUser) {
|
|
setRecentSearches([]);
|
|
return;
|
|
}
|
|
fetch('/api/search/recent', { credentials: 'include' })
|
|
.then((r) => r.json())
|
|
.then((data) => setRecentSearches(Array.isArray(data) ? data : []))
|
|
.catch(() => setRecentSearches([]));
|
|
}, [currentUser]);
|
|
|
|
const saveToRecent = useCallback((medicine) => {
|
|
if (!currentUser) return;
|
|
fetch('/api/search/recent', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ medicine }),
|
|
}).catch(() => {});
|
|
// Update local state immediately
|
|
setRecentSearches((prev) => {
|
|
const filtered = prev.filter((m) => m.id !== medicine.id);
|
|
return [
|
|
{
|
|
id: medicine.id,
|
|
name: medicine.name,
|
|
active_ingredient: medicine.active_ingredient,
|
|
dosage: medicine.dosage,
|
|
form: medicine.form,
|
|
timestamp: Date.now(),
|
|
},
|
|
...filtered,
|
|
].slice(0, 5);
|
|
});
|
|
}, [currentUser]);
|
|
|
|
useEffect(() => {
|
|
const searchAll = async () => {
|
|
if (searchQuery.trim().length < 2) {
|
|
setMedicines([]);
|
|
setProducts([]);
|
|
setSelectedMedicine(null);
|
|
setPharmacies([]);
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
const query = searchQuery.trim();
|
|
|
|
try {
|
|
// Search both CIMA and Parapharmacy simultaneously
|
|
const [medicinesRes, productsRes] = await Promise.allSettled([
|
|
fetch(`/api/medicines/search?q=${encodeURIComponent(query)}`),
|
|
fetch(`/api/products/parapharmacy/search?q=${encodeURIComponent(query)}`)
|
|
]);
|
|
|
|
// Only update if this search is still the current one
|
|
if (query !== searchQuery.trim()) return;
|
|
|
|
if (medicinesRes.status === 'fulfilled' && medicinesRes.value.ok) {
|
|
const medicinesData = await medicinesRes.value.json();
|
|
setMedicines(medicinesData);
|
|
}
|
|
|
|
if (productsRes.status === 'fulfilled' && productsRes.value.ok) {
|
|
const productsData = await productsRes.value.json();
|
|
setProducts(productsData.results || []);
|
|
}
|
|
} catch (error) {
|
|
console.error('Search error:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
const timeoutId = setTimeout(searchAll, 300);
|
|
return () => clearTimeout(timeoutId);
|
|
}, [searchQuery]);
|
|
|
|
useEffect(() => {
|
|
const fetchPharmacies = async () => {
|
|
if (!selectedMedicine) {
|
|
setPharmacies([]);
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
try {
|
|
const response = await fetch(`/api/medicines/${selectedMedicine.id}/pharmacies`);
|
|
const data = await response.json();
|
|
setPharmacies(data);
|
|
} catch (error) {
|
|
console.error('Error fetching pharmacies:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
fetchPharmacies();
|
|
}, [selectedMedicine]);
|
|
|
|
const savedLat = currentUser?.latitude;
|
|
const savedLon = currentUser?.longitude;
|
|
const hasSavedCoords = savedLat != null && savedLon != null;
|
|
|
|
const handleSortByDistance = async () => {
|
|
if (sortByDistance) {
|
|
setSortByDistance(false);
|
|
return;
|
|
}
|
|
setLocationError('');
|
|
if (hasSavedCoords) {
|
|
setUserPosition({ lat: savedLat, lon: savedLon });
|
|
setPositionSource('profile');
|
|
setSortByDistance(true);
|
|
// Save to search history
|
|
try {
|
|
fetch('/api/search-history', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({
|
|
address: currentUser?.address,
|
|
latitude: savedLat,
|
|
longitude: savedLon,
|
|
}),
|
|
});
|
|
} catch (err) {
|
|
console.error('Error saving search history:', err);
|
|
}
|
|
return;
|
|
}
|
|
if (userPosition) {
|
|
setSortByDistance(true);
|
|
setPositionSource('cached');
|
|
return;
|
|
}
|
|
setLocating(true);
|
|
try {
|
|
const pos = await getUserPosition();
|
|
setUserPosition(pos);
|
|
setPositionSource('browser');
|
|
setSortByDistance(true);
|
|
// Save to search history
|
|
try {
|
|
fetch('/api/search-history', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({
|
|
address: 'Ubicación actual',
|
|
latitude: pos.lat,
|
|
longitude: pos.lon,
|
|
}),
|
|
});
|
|
} catch (err) {
|
|
console.error('Error saving search history:', err);
|
|
}
|
|
} catch (err) {
|
|
let msg = t('search.locationError');
|
|
if (err && typeof err.code === 'number') {
|
|
if (err.code === 1) msg = t('search.locationDenied');
|
|
else if (err.code === 2) msg = t('search.locationUnavailable');
|
|
else if (err.code === 3) msg = t('search.locationTimeout');
|
|
}
|
|
setLocationError(msg);
|
|
} finally {
|
|
setLocating(false);
|
|
}
|
|
};
|
|
|
|
const displayedPharmacies = useMemo(() => {
|
|
let result = pharmacies;
|
|
if (openNow) {
|
|
result = result.filter((p) => {
|
|
if (p.is_open === true) return true;
|
|
if (p.is_open == null) {
|
|
const s = getOpenStatus(p.opening_hours);
|
|
return s && s.status === 'open';
|
|
}
|
|
return false;
|
|
});
|
|
}
|
|
if (!sortByDistance || !userPosition) return result;
|
|
return [...result].sort((a, b) => {
|
|
if (a.latitude == null || a.longitude == null) return 1;
|
|
if (b.latitude == null || b.longitude == null) return -1;
|
|
return (
|
|
haversineKm(userPosition.lat, userPosition.lon, a.latitude, a.longitude) -
|
|
haversineKm(userPosition.lat, userPosition.lon, b.latitude, b.longitude)
|
|
);
|
|
});
|
|
}, [pharmacies, sortByDistance, userPosition, openNow]);
|
|
|
|
return (
|
|
<div className="search-view">
|
|
<div className="search-content">
|
|
<SearchBar
|
|
value={searchQuery}
|
|
onChange={setSearchQuery}
|
|
placeholder={t('search.placeholder')}
|
|
/>
|
|
|
|
{loading && <div className="loading">{t('search.searching')}</div>}
|
|
|
|
{!searchQuery && !selectedMedicine && (
|
|
<>
|
|
<section className="suggestions-section">
|
|
<h2 className="section-title">{t('search.suggestions')}</h2>
|
|
<div className="suggestions-grid">
|
|
{suggestions.map((s, i) => (
|
|
<button
|
|
key={i}
|
|
className={`suggestion-btn suggestion-btn--${s.color}`}
|
|
onClick={() => setSearchQuery(s.name)}
|
|
>
|
|
<div className="suggestion-icon">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M4 8h4V4H4v4zm6 12h4v-4h-4v4zm-6 0h4v-4H4v4zm0-6h4v-4H4v4zm6 0h4v-4h-4v4zm6-10v4h4V4h-4zm-6 4h4V4h-4v4zm6 6h4v-4h-4v4zm0 6h4v-4h-4v4z" />
|
|
</svg>
|
|
</div>
|
|
<span className="suggestion-name">{s.name}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
{currentUser && recentSearches.length > 0 && (
|
|
<section className="recent-section">
|
|
<h2 className="section-title">{t('search.recentResults')}</h2>
|
|
<div className="recent-list">
|
|
{recentSearches.map((r) => (
|
|
<div
|
|
key={r.id}
|
|
className="recent-card"
|
|
onClick={() => setSelectedMedicine(r)}
|
|
style={{ cursor: 'pointer' }}
|
|
>
|
|
<div className="recent-card-top">
|
|
<div>
|
|
<h3 className="recent-name">{r.name}</h3>
|
|
<p className="recent-detail">
|
|
{r.dosage && `${r.dosage}`}
|
|
{r.dosage && r.form && ' \u2022 '}
|
|
{r.form}
|
|
</p>
|
|
</div>
|
|
{r.active_ingredient && (
|
|
<span className="recent-tag">{r.active_ingredient}</span>
|
|
)}
|
|
</div>
|
|
<button
|
|
className="recent-btn"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setSelectedMedicine(r);
|
|
}}
|
|
>
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="12" cy="12" r="10" />
|
|
<polyline points="12 6 12 12 16 14" />
|
|
</svg>
|
|
{t('search.findNearby')}
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{searchQuery && !selectedMedicine && (
|
|
<>
|
|
<div className="results-summary">
|
|
{(medicines.length + products.length) > 0 && (
|
|
<span>{medicines.length + products.length} {t('search.resultsFound')}</span>
|
|
)}
|
|
</div>
|
|
|
|
{medicines.length > 0 && (
|
|
<MedicineResults
|
|
medicines={medicines}
|
|
onSelect={(m) => {
|
|
saveToRecent(m);
|
|
setSelectedMedicine(m);
|
|
}}
|
|
query={searchQuery}
|
|
currentUser={currentUser}
|
|
onLoginRequest={onLoginRequest}
|
|
/>
|
|
)}
|
|
|
|
{products.length > 0 && (
|
|
<div className="products-section">
|
|
<h3 className="section-subtitle">{t('search.parapharmacy')}</h3>
|
|
<ProductResults
|
|
products={products}
|
|
onSelect={(p) => {
|
|
const productId = p._id || p.id;
|
|
if (onNavigateToProduct) {
|
|
onNavigateToProduct(p.source, productId);
|
|
} else {
|
|
window.location.href = `/product/${p.source}/${productId}`;
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{selectedMedicine && (
|
|
<div className="selected-medicine-section">
|
|
<div className="medicine-info">
|
|
<h2>{selectedMedicine.name}</h2>
|
|
<div className="medicine-details">
|
|
<span><strong>{t('search.activeIngredient')}</strong> {selectedMedicine.active_ingredient}</span>
|
|
<span><strong>{t('search.dosage')}</strong> {selectedMedicine.dosage}</span>
|
|
<span><strong>{t('search.form')}</strong> {selectedMedicine.form}</span>
|
|
</div>
|
|
<button
|
|
className="back-button"
|
|
onClick={() => {
|
|
setSelectedMedicine(null);
|
|
setPharmacies([]);
|
|
}}
|
|
>
|
|
{t('search.backToSearch')}
|
|
</button>
|
|
</div>
|
|
|
|
{pharmacies.length > 0 && (
|
|
<div className="pharmacy-controls">
|
|
<button
|
|
className={`sort-distance-button ${openNow ? 'active' : ''}`}
|
|
onClick={() => setOpenNow((v) => !v)}
|
|
>
|
|
{openNow ? t('pharmacy.filterOpenNowActive') : t('pharmacy.filterOpenNow')}
|
|
</button>
|
|
<button
|
|
className={`sort-distance-button ${sortByDistance ? 'active' : ''}`}
|
|
onClick={handleSortByDistance}
|
|
disabled={locating}
|
|
>
|
|
{locating
|
|
? t('search.locating')
|
|
: sortByDistance
|
|
? t('search.sortedByDistance')
|
|
: hasSavedCoords
|
|
? t('search.sortBySavedLocation')
|
|
: t('search.sortByDistance')}
|
|
</button>
|
|
{sortByDistance && positionSource === 'profile' && (
|
|
<span className="location-source">{t('search.usingSavedAddress')}</span>
|
|
)}
|
|
{sortByDistance && positionSource === 'cached' && (
|
|
<span className="location-source">{t('search.usingRecentLocation')}</span>
|
|
)}
|
|
{locationError && (
|
|
<span className="location-error">
|
|
{locationError}
|
|
<button className="retry-location-btn" onClick={handleSortByDistance}>
|
|
{t('search.retry')}
|
|
</button>
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
{openNow && pharmacies.length > 0 && displayedPharmacies.length === 0 && (
|
|
<div className="no-pharmacies">{t('pharmacy.filterNoResults')}</div>
|
|
)}
|
|
|
|
<PharmacyMap pharmacies={displayedPharmacies} />
|
|
<PharmacyList
|
|
pharmacies={displayedPharmacies}
|
|
loading={loading}
|
|
userPosition={sortByDistance ? userPosition : null}
|
|
medicine={selectedMedicine}
|
|
currentUser={currentUser}
|
|
onLoginRequest={onLoginRequest}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default SearchView;
|