turash/bugulma/frontend/pages/DiscoveryPage.tsx
2025-12-15 10:06:41 +01:00

328 lines
12 KiB
TypeScript

import DiscoverySearchBar from '@/components/discovery/DiscoverySearchBar';
import MatchCardImage from '@/components/discovery/MatchCardImage';
import MatchCardMetadata from '@/components/discovery/MatchCardMetadata';
import MatchCardPricing from '@/components/discovery/MatchCardPricing';
import { MainLayout } from '@/components/layout/MainLayout';
import Badge from '@/components/ui/Badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/Card';
import { EmptyState } from '@/components/ui/EmptyState';
import Flex from '@/components/ui/Flex';
import Grid from '@/components/ui/Grid';
import { Container } from '@/components/ui/layout';
import { LoadingState } from '@/components/ui/LoadingState';
import Stack from '@/components/ui/Stack';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/Tabs';
import { Heading, Text } from '@/components/ui/Typography';
import { useTranslation } from '@/hooks/useI18n';
import { useNavigation } from '@/hooks/useNavigation';
import { universalSearch, type DiscoveryMatch, type SearchQuery } from '@/services/discovery-api';
import { MapPin, Package, Users, Wrench } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
export default function DiscoveryPage() {
const { t } = useTranslation();
const { handleFooterNavigate } = useNavigation();
const [searchParams, setSearchParams] = useSearchParams();
const [loading, setLoading] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
const [hasActiveSearch, setHasActiveSearch] = useState(false); // Track if there's an actual search query
const [activeTab, setActiveTab] = useState('all');
const [results, setResults] = useState<{
products: DiscoveryMatch[];
services: DiscoveryMatch[];
community: DiscoveryMatch[];
}>({
products: [],
services: [],
community: [],
});
const initialQuery: SearchQuery = {
query: searchParams.get('q') || undefined,
categories: searchParams.get('category') ? [searchParams.get('category')!] : undefined,
radius_km: searchParams.get('radius') ? parseFloat(searchParams.get('radius')!) : undefined,
limit: 20,
};
const handleSearch = async (query: SearchQuery) => {
setLoading(true);
try {
const response = await universalSearch(query);
setResults({
products: response.product_matches || [],
services: response.service_matches || [],
community: response.community_matches || [],
});
setHasSearched(true);
// Determine if this is an actual search (has query, category, or filters) vs just browsing
const isActiveSearch = !!(
query.query?.trim() ||
query.categories?.length ||
query.min_price != null ||
query.max_price != null ||
query.availability_status ||
query.tags?.length
);
setHasActiveSearch(isActiveSearch);
// Update URL params
const newParams = new URLSearchParams();
if (query.query) newParams.set('q', query.query);
if (query.categories?.[0]) newParams.set('category', query.categories[0]);
if (query.radius_km) newParams.set('radius', query.radius_km.toString());
setSearchParams(newParams);
} catch (error) {
console.error('Search failed:', error);
setHasSearched(true);
setHasActiveSearch(false);
} finally {
setLoading(false);
}
};
useEffect(() => {
// Perform initial search on page load
// If query params exist, use them; otherwise perform a default browse to show available listings
if (searchParams.get('q') || searchParams.get('category')) {
handleSearch(initialQuery);
} else {
// Browse mode: show available listings without search filters
// This is not a "search" so hasActiveSearch will be false
handleSearch({ limit: 20 });
}
}, []);
const renderMatchCard = (match: DiscoveryMatch) => {
const item = match.product || match.service || match.community_listing;
if (!item) return null;
const isProduct = !!match.product;
const isService = !!match.service;
const isCommunity = !!match.community_listing;
// Get organization information
const orgName = match.organization?.name || '';
// Get images
const images = isProduct
? match.product?.Images
: isService
? []
: match.community_listing?.images || [];
const hasImage = images && images.length > 0;
// Get item ID
const itemId = isProduct
? match.product?.ID
: isService
? match.service?.ID
: match.community_listing?.id || Math.random().toString();
// Get category
const category = isProduct
? match.product?.Category
: isService
? match.service?.Type
: match.community_listing?.category;
// Get title
const title = isProduct
? match.product?.Name
: isService
? match.service?.Domain
: match.community_listing?.title;
// Get description
const description = isProduct
? match.product?.Description
: isService
? match.service?.Description
: match.community_listing?.description;
// Get availability status
const availabilityStatus = isProduct
? match.product?.AvailabilityStatus
: isService
? match.service?.AvailabilityStatus
: match.community_listing?.availability_status;
// Get tags
const tags = isProduct
? match.product?.Tags || []
: isService
? match.service?.Tags || []
: match.community_listing?.tags || [];
return (
<Card
key={match.match_type + '-' + itemId}
className="hover:shadow-lg transition-shadow overflow-hidden"
>
{/* Product/Service Image */}
{hasImage && <MatchCardImage imageUrl={images[0]} alt={title || 'Product image'} />}
<CardHeader>
<Flex justify="between" align="start" gap="sm">
<div className="flex-1 min-w-0">
{/* Category Badge */}
{category && (
<div className="mb-2">
<Badge variant="outline" className="text-xs">
{category}
</Badge>
</div>
)}
{/* Title */}
<CardTitle className="text-lg mb-1">{title}</CardTitle>
{/* Description */}
{description && (
<CardDescription className="line-clamp-2 text-sm">{description}</CardDescription>
)}
</div>
{hasActiveSearch && (
<Badge variant="secondary" className="ml-2 shrink-0">
{t('discoveryPage.match', { score: Math.round(match.relevance_score * 100) })}
</Badge>
)}
</Flex>
</CardHeader>
<CardContent>
<Stack spacing="md">
{/* Pricing */}
{isProduct && match.product && (
<MatchCardPricing
type="product"
unitPrice={match.product.UnitPrice ?? null}
moq={match.product.MOQ}
onRequestTKey="discoveryPage.priceOnRequest"
/>
)}
{isService && match.service && (
<MatchCardPricing
type="service"
hourlyRate={match.service.HourlyRate ?? null}
serviceAreaKm={match.service.ServiceAreaKm}
onRequestTKey="discoveryPage.priceOnRequest"
/>
)}
{isCommunity && match.community_listing && (
<MatchCardPricing
type="community"
price={match.community_listing.price ?? null}
priceType={match.community_listing.price_type}
freeTKey="discoveryPage.free"
/>
)}
{/* Metadata */}
<MatchCardMetadata
organizationName={orgName}
distanceKm={match.distance_km}
availabilityStatus={availabilityStatus}
tags={tags}
organizationLabelTKey={t('discoveryPage.organization')}
distanceTKey="discoveryPage.distance"
/>
</Stack>
</CardContent>
</Card>
);
};
return (
<MainLayout onNavigate={handleFooterNavigate}>
<Container size="2xl" className="py-8">
<div className="mb-8">
<Heading level="h1" tKey="discoveryPage.title" className="mb-2" />
<Text variant="muted" tKey="discoveryPage.subtitle" />
</div>
<div className="mb-6">
<DiscoverySearchBar onSearch={handleSearch} initialQuery={initialQuery} />
</div>
{loading ? (
<LoadingState message={t('discoveryPage.loading')} size="lg" />
) : (
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList>
<TabsTrigger value="all">
{t('discoveryPage.tabs.all')} (
{results.products.length + results.services.length + results.community.length})
</TabsTrigger>
<TabsTrigger value="products">
<Package className="h-4 w-4 mr-2" />
{t('discoveryPage.tabs.products')} ({results.products.length})
</TabsTrigger>
<TabsTrigger value="services">
<Wrench className="h-4 w-4 mr-2" />
{t('discoveryPage.tabs.services')} ({results.services.length})
</TabsTrigger>
<TabsTrigger value="community">
<Users className="h-4 w-4 mr-2" />
{t('discoveryPage.tabs.community')} ({results.community.length})
</TabsTrigger>
</TabsList>
<TabsContent value="all" className="mt-6">
<Grid cols={{ sm: 1, md: 2, lg: 3 }} gap="md">
{[...results.products, ...results.services, ...results.community]
.sort((a, b) => {
// When browsing (no active search), don't sort by relevance score
// When searching, sort by relevance score (higher = better match)
if (hasActiveSearch) {
return b.relevance_score - a.relevance_score;
}
// For browsing, maintain original order or sort by date/name
return 0;
})
.map((match) => renderMatchCard(match))}
</Grid>
{hasSearched &&
results.products.length === 0 &&
results.services.length === 0 &&
results.community.length === 0 && (
<EmptyState type="search" title={t('discoveryPage.results.noResults')} />
)}
</TabsContent>
<TabsContent value="products" className="mt-6">
<Grid cols={{ sm: 1, md: 2, lg: 3 }} gap="md">
{results.products.map((match) => renderMatchCard(match))}
</Grid>
{results.products.length === 0 && (
<EmptyState type="no-data" title={t('discoveryPage.results.noProducts')} />
)}
</TabsContent>
<TabsContent value="services" className="mt-6">
<Grid cols={{ sm: 1, md: 2, lg: 3 }} gap="md">
{results.services.map((match) => renderMatchCard(match))}
</Grid>
{results.services.length === 0 && (
<EmptyState type="no-data" title={t('discoveryPage.results.noServices')} />
)}
</TabsContent>
<TabsContent value="community" className="mt-6">
<Grid cols={{ sm: 1, md: 2, lg: 3 }} gap="md">
{results.community.map((match) => renderMatchCard(match))}
</Grid>
{results.community.length === 0 && (
<EmptyState type="no-data" title={t('discoveryPage.results.noCommunity')} />
)}
</TabsContent>
</Tabs>
)}
</Container>
</MainLayout>
);
}