mirror of
https://github.com/SamyRai/turash.git
synced 2025-12-26 23:01:33 +00:00
328 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|