diff --git a/src/components/ClubCard.tsx b/src/components/ClubCard.tsx index 4cd20dd..7d47852 100644 --- a/src/components/ClubCard.tsx +++ b/src/components/ClubCard.tsx @@ -1,80 +1,128 @@ -import React, { useState, useEffect, useCallback } from 'react'; +// src/components/ClubCard.tsx +import React, { useState, useEffect, useCallback, useRef } from 'react'; import type { ClubBasic, ClubDetail } from '@types'; import { getClubDetails } from '@utils/apiService'; +import activityDetailLimiter from '@utils/concurrencyLimiter'; interface ClubCardProps { club: ClubBasic; initialDetails?: ClubDetail | null; onDetailsLoaded?: (clubId: string, details: ClubDetail) => void; - onViewMore: (clubDetail: ClubDetail) => void; // Callback to open modal in parent + onViewMore: (clubDetail: ClubDetail) => void; } const ClubCard: React.FC = ({ club, initialDetails = null, onDetailsLoaded, onViewMore }) => { const [details, setDetails] = useState(initialDetails); - const [isLoadingDetails, setIsLoadingDetails] = useState(!initialDetails); + const [isLoadingDetails, setIsLoadingDetails] = useState(false); // Don't set to true initially if not visible const [error, setError] = useState(null); + const [isVisible, setIsVisible] = useState(false); + const cardRef = useRef(null); const fetchDetailsIfNeeded = useCallback(async () => { - if (details || !club.id) return; // Already have details or no ID + if (details || !club.id || !isVisible) return; // Already have details, no ID, or not visible setIsLoadingDetails(true); setError(null); try { - const fetchedDetails = await getClubDetails(club.id); + // Run the API call through the concurrency limiter + const fetchedDetails = await activityDetailLimiter.run(() => getClubDetails(club.id)); setDetails(fetchedDetails); if (onDetailsLoaded) { onDetailsLoaded(club.id, fetchedDetails); } - } catch (err) { - console.error(`Failed to fetch details for club ${club.id}:`, err); - setError('Could not load some details.'); // Less critical error now + } catch (err: any) { + console.error(`Failed to fetch details for club ${club.id} (concurrency controlled):`, err); + setError(`Could not load some details. ${err.message || ''}`); // Show more specific error } finally { setIsLoadingDetails(false); } - }, [club.id, details, onDetailsLoaded]); + }, [club.id, details, onDetailsLoaded, isVisible]); // Add isVisible dependency - // Fetch details when component mounts if not already provided + // IntersectionObserver for lazy loading useEffect(() => { - fetchDetailsIfNeeded(); - }, [fetchDetailsIfNeeded]); + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setIsVisible(true); + observer.unobserve(entry.target); // Stop observing once visible + } + }, + { + rootMargin: '0px', // Can be adjusted, e.g., '100px' to load slightly before visible + threshold: 0.1 // Trigger when 10% of the element is visible + } + ); - const handleViewMoreClick = () => { - if (details) { - onViewMore(details); - } else if (!isLoadingDetails) { - fetchDetailsIfNeeded().then(() => { - // Re-check details state after fetch attempt - if(details) onViewMore(details); - }); + if (cardRef.current) { + observer.observe(cardRef.current); + } + + return () => { + if (cardRef.current) { + observer.unobserve(cardRef.current); + } + }; + }, []); // Run once on mount + + // Fetch details when component becomes visible and details are needed + useEffect(() => { + if (isVisible && !initialDetails) { // Only fetch if visible and no initial details + fetchDetailsIfNeeded(); + } + }, [isVisible, fetchDetailsIfNeeded, initialDetails]); + + const handleViewMoreClick = async () => { + let currentDetails = details; + if (!currentDetails && !isLoadingDetails) { + // If details somehow weren't loaded and not currently loading, try again then open + // This attempt will also go through the limiter and retry logic + setError(null); // Clear previous error before manual retry via button + setIsLoadingDetails(true); + try { + currentDetails = await activityDetailLimiter.run(() => getClubDetails(club.id)); + setDetails(currentDetails); + if (onDetailsLoaded && currentDetails) { + onDetailsLoaded(club.id, currentDetails); + } + } catch (err: any) { + console.error(`Manual fetch failed for club ${club.id}:`, err); + setError(`Failed to load details. ${err.message || ''}`); + } finally { + setIsLoadingDetails(false); + } + } + + if (currentDetails) { + onViewMore(currentDetails); + } else { + // Optionally inform user that details couldn't be loaded even after click + console.warn("Details not available to show in modal for club:", club.id); } }; const renderDetailItem = (label: string, value: string | undefined | null) => { - if (isLoadingDetails && !details) return

{label}: Loading...

; - // Check specifically for the case where details ARE loaded but the value is missing/null/undefined + // Show "Loading..." only if it's visible and actively loading + if (isVisible && isLoadingDetails && !details) return

{label}: Loading...

; if (details && !value) return

{label}: N/A

; - // If details are not loaded yet, and we are not actively loading, show N/A or nothing? Let's stick to Loading... or the value - if (!details && !isLoadingDetails) return

{label}: N/A

// Or maybe return null - // If we have a value (implies details are loaded) + if (!details && !isLoadingDetails && isVisible) return

{label}: N/A

; + if (!isVisible && !initialDetails) return

{label}: (Scroll to load)

; // Placeholder before visible if (value) return

{label}: {value}

; - // Fallback for the loading state (already handled above, but as safety) - return

{label}: Loading...

; - } + if (initialDetails && !value) return

{label}: N/A

; // For initialDetails case + return

{label}: N/A

; // Default placeholder + }; return ( -
+
- {/* === Updated img tag below === */} {club.name} - {/* === End of updated img tag === */}

{club.name}

@@ -88,9 +136,9 @@ const ClubCard: React.FC = ({ club, initialDetails = null, onDeta
diff --git a/src/utils/apiService.ts b/src/utils/apiService.ts index 8468e54..bbbb3f8 100644 --- a/src/utils/apiService.ts +++ b/src/utils/apiService.ts @@ -1,3 +1,4 @@ +// src/utils/apiService.ts import type { ApiClubListResponse, ClubDetail, @@ -10,19 +11,42 @@ import type { const BASE_URL = 'https://dsas-cca.jamesflare.com/v1'; -async function fetchWithErrorHandling(url: string): Promise { +// Helper function for delaying +const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +async function fetchWithErrorHandling(url: string, isRetryable: boolean = false, attempt: number = 1): Promise { + const MAX_RETRY_ATTEMPTS_FOR_NON_PERSISTENT = 3; // Max retries for general errors if not persistent + const RETRY_DELAY_MS = 1000; // 1 second for persistent retry + try { const response = await fetch(url); if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: "Failed to parse error response" })); + // For 4xx errors, typically don't retry indefinitely as it's a client/request issue + if (response.status >= 400 && response.status < 500 && isRetryable) { + console.warn(`API Client Error for ${url}: ${response.status}. Not retrying.`); + const errorData = await response.json().catch(() => ({ error: "Failed to parse client error response" })); + throw new Error(`API Client Error: ${response.status} ${response.statusText}. ${errorData?.error || ''}`); + } + // For 5xx server errors or network issues, retry is more appropriate + const errorData = await response.json().catch(() => ({ error: "Failed to parse server error response" })); throw new Error( `API Error: ${response.status} ${response.statusText}. ${errorData?.error || ''}` ); } return response.json() as Promise; - } catch (error) { - console.error(`Failed to fetch from ${url}:`, error); - throw error; // Re-throw to be handled by the caller + } catch (error: any) { + if (isRetryable) { + console.warn(`Attempt ${attempt} failed for ${url}: ${error.message}. Retrying in ${RETRY_DELAY_MS / 1000}s...`); + await delay(RETRY_DELAY_MS); + return fetchWithErrorHandling(url, isRetryable, attempt + 1); // Persistent retry + } else if (attempt < MAX_RETRY_ATTEMPTS_FOR_NON_PERSISTENT) { + // Non-persistent retry for general errors (e.g., initial list fetches) + console.warn(`Attempt ${attempt} failed for ${url}: ${error.message}. Retrying (non-persistent)...`); + await delay(RETRY_DELAY_MS * attempt); // Simple increasing backoff + return fetchWithErrorHandling(url, isRetryable, attempt + 1); + } + console.error(`Failed to fetch from ${url} after multiple attempts:`, error); + throw error; // Re-throw to be handled by the caller after max retries or if not retryable } } @@ -46,8 +70,10 @@ export async function filterClubs(params: { return Object.entries(data).map(([id, club]) => ({ id, ...club })); } +// Make getClubDetails retryable persistently export async function getClubDetails(activityId: string): Promise { - return fetchWithErrorHandling(`${BASE_URL}/activity/${activityId}`); + // Pass `true` for isRetryable to enable persistent retries for this specific endpoint + return fetchWithErrorHandling(`${BASE_URL}/activity/${activityId}`, true); } export async function getAvailableCategories(): Promise { diff --git a/src/utils/concurrencyLimiter.ts b/src/utils/concurrencyLimiter.ts new file mode 100644 index 0000000..f6b9130 --- /dev/null +++ b/src/utils/concurrencyLimiter.ts @@ -0,0 +1,44 @@ +// src/utils/concurrencyLimiter.ts +type AsyncTask = () => Promise; + +class ConcurrencyLimiter { + private limit: number; + private activeCount: number; + private queue: Array<{ task: AsyncTask; resolve: (value: any) => void; reject: (reason?: any) => void }>; + + constructor(limit: number) { + this.limit = limit; + this.activeCount = 0; + this.queue = []; + } + + private async next() { + if (this.activeCount >= this.limit || this.queue.length === 0) { + return; + } + + this.activeCount++; + const { task, resolve, reject } = this.queue.shift()!; + + try { + const result = await task(); + resolve(result); + } catch (error) { + reject(error); + } finally { + this.activeCount--; + this.next(); + } + } + + public run(task: AsyncTask): Promise { + return new Promise((resolve, reject) => { + this.queue.push({ task, resolve, reject }); + this.next(); + }); + } +} + +// Export a single instance +const activityDetailLimiter = new ConcurrencyLimiter(16); // As requested +export default activityDetailLimiter; \ No newline at end of file