Frontend Complete Guide
Comprehensive Frontend Development: Everything you need to build, test, and deploy the AltSportsLeagues.ai frontend application.
Frontend Architecture
Technology Stack
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Frontend Architecture β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β Framework Layer β
β ββ Next.js 16 (App Router) β
β ββ React 18.2 β
β ββ TypeScript 5.x β
β β
β UI Layer β
β ββ Tailwind CSS β
β ββ shadcn/ui Components β
β ββ Framer Motion (animations) β
β ββ D3.js (graph visualizations) β
β β
β State Management β
β ββ Zustand (global state) β
β ββ React Query (server state) β
β ββ React Context (theme, auth) β
β β
β Data Layer β
β ββ API Client (/lib/api-client.ts) β
β ββ Firebase (auth, real-time) β
β ββ WebSocket (live updates) β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββProject Structure
clients/frontend/
ββ app/ # Next.js App Router
β ββ layout.tsx # Root layout
β ββ page.tsx # Homepage
β ββ leagues/ # League pages
β β ββ page.tsx # List view
β β ββ [id]/page.tsx # Detail view
β ββ dashboard/ # User dashboard
β ββ onboarding/ # Onboarding flow
β ββ api/ # API routes (if needed)
β
ββ components/ # React components
β ββ ui/ # shadcn/ui base components
β ββ leagues/ # League-specific components
β ββ dashboard/ # Dashboard components
β ββ shared/ # Shared components
β
ββ lib/ # Utilities and helpers
β ββ api-client.ts # API client
β ββ firebase.ts # Firebase config
β ββ utils.ts # Helper functions
β ββ constants.ts # App constants
β
ββ hooks/ # Custom React hooks
β ββ useAuth.ts # Authentication
β ββ useLeagues.ts # League data
β ββ useWebSocket.ts # WebSocket connection
β
ββ store/ # Zustand stores
β ββ auth.ts # Auth state
β ββ leagues.ts # League state
β ββ ui.ts # UI state (sidebar, theme)
β
ββ types/ # TypeScript types
β ββ league.ts # League types
β ββ api.ts # API response types
β ββ index.ts # Barrel exports
β
ββ styles/ # Global styles
β ββ globals.css # Tailwind + custom CSS
β
ββ public/ # Static assets
β ββ images/ # Images
β ββ icons/ # Icons/logos
β
ββ e2e/ # Playwright tests
ββ leagues.spec.ts # League tests
ββ auth.spec.ts # Auth testsGetting Started
Initial Setup
# Clone and navigate
cd clients/frontend
# Install dependencies
npm install
# Set up environment variables
cp .env.example .env.local
# Required environment variables
NEXT_PUBLIC_API_URL=https://api.altsportsleagues.ai
NEXT_PUBLIC_FIREBASE_API_KEY=your_key
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your_domain
NEXT_PUBLIC_FIREBASE_PROJECT_ID=your_project_idDevelopment Server
# Start dev server (port 3031)
npm run dev
# Start with production build locally
npm run build
npm start
# Open in browser
open http://localhost:3031Development Workflow
-
Create a feature branch
git checkout -b feature/league-search -
Make changes (components, pages, styles)
-
Test locally
npm run dev # Test in browser -
Run type check
npm run type-check -
Run linter
npm run lint -
Run E2E tests
npm run test:e2e -
Commit and push
git add . git commit -m "feat: add league search functionality" git push origin feature/league-search
Component Development
Creating a New Component
// components/leagues/LeagueCard.tsx
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import Link from 'next/link';
import type { League } from '@/types/league';
interface LeagueCardProps {
league: League;
onClick?: () => void;
}
export function LeagueCard({ league, onClick }: LeagueCardProps) {
return (
<Card className="hover:shadow-lg transition-shadow cursor-pointer" onClick={onClick}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{league.name}</CardTitle>
<Badge variant="secondary">{league.sport}</Badge>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm text-muted-foreground">
<p>Region: {league.region}</p>
<p>Teams: {league.teams_count}</p>
<p>Founded: {league.founded}</p>
</div>
<Link
href={`/leagues/${league.id}`}
className="mt-4 inline-block text-primary hover:underline"
>
View Details β
</Link>
</CardContent>
</Card>
);
}shadcn/ui Component Usage
# Add a new shadcn component
npx shadcn-ui@latest add button
npx shadcn-ui@latest add card
npx shadcn-ui@latest add dialog
# Components are added to components/ui/// Using shadcn components
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
export function MyComponent() {
return (
<Dialog>
<DialogContent>
<DialogHeader>
<DialogTitle>League Details</DialogTitle>
</DialogHeader>
<Button onClick={handleSave}>Save</Button>
</DialogContent>
</Dialog>
);
}Component Best Practices
-
Use TypeScript for props
interface Props { title: string; onSubmit: (data: FormData) => Promise<void>; disabled?: boolean; } -
Export components from index files
// components/leagues/index.ts export { LeagueCard } from './LeagueCard'; export { LeagueList } from './LeagueList'; export { LeagueDetail } from './LeagueDetail'; -
Use Tailwind utility classes
<div className="flex items-center gap-4 p-4 rounded-lg bg-card"> -
Implement loading and error states
if (isLoading) return <Skeleton />; if (error) return <ErrorMessage error={error} />; return <LeagueList leagues={data} />;
State Management
Zustand Store Pattern
// store/leagues.ts
import { create } from 'zustand';
import type { League } from '@/types/league';
interface LeaguesState {
leagues: League[];
selectedLeague: League | null;
isLoading: boolean;
error: string | null;
// Actions
setLeagues: (leagues: League[]) => void;
setSelectedLeague: (league: League | null) => void;
addLeague: (league: League) => void;
updateLeague: (id: string, updates: Partial<League>) => void;
deleteLeague: (id: string) => void;
}
export const useLeaguesStore = create<LeaguesState>((set) => ({
leagues: [],
selectedLeague: null,
isLoading: false,
error: null,
setLeagues: (leagues) => set({ leagues }),
setSelectedLeague: (league) => set({ selectedLeague: league }),
addLeague: (league) =>
set((state) => ({ leagues: [...state.leagues, league] })),
updateLeague: (id, updates) =>
set((state) => ({
leagues: state.leagues.map((l) =>
l.id === id ? { ...l, ...updates } : l
)
})),
deleteLeague: (id) =>
set((state) => ({
leagues: state.leagues.filter((l) => l.id !== id)
}))
}));Using Zustand Store
// components/leagues/LeagueList.tsx
import { useLeaguesStore } from '@/store/leagues';
export function LeagueList() {
const leagues = useLeaguesStore((state) => state.leagues);
const setSelectedLeague = useLeaguesStore((state) => state.setSelectedLeague);
return (
<div className="grid grid-cols-3 gap-4">
{leagues.map((league) => (
<LeagueCard
key={league.id}
league={league}
onClick={() => setSelectedLeague(league)}
/>
))}
</div>
);
}React Query for Server State
// hooks/useLeagues.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
export function useLeagues(params?: LeagueFilters) {
return useQuery({
queryKey: ['leagues', params],
queryFn: () => apiClient.getLeagues(params),
staleTime: 5 * 60 * 1000 // 5 minutes
});
}
export function useLeague(id: string) {
return useQuery({
queryKey: ['league', id],
queryFn: () => apiClient.getLeague(id),
enabled: !!id
});
}
export function useCreateLeague() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: LeagueInput) => apiClient.createLeague(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['leagues'] });
}
});
}API Integration
API Client Setup
// lib/api-client.ts
import { auth } from './firebase';
class APIClient {
private baseUrl: string;
constructor() {
this.baseUrl = process.env.NEXT_PUBLIC_API_URL || 'https://api.altsportsleagues.ai';
}
private async getToken(): Promise<string> {
const user = auth.currentUser;
if (!user) throw new Error('Not authenticated');
return user.getIdToken();
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const token = await this.getToken();
const response = await fetch(`${this.baseUrl}${endpoint}`, {
...options,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
...options.headers
}
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || response.statusText);
}
return response.json();
}
// League endpoints
async getLeagues(params?: LeagueFilters) {
const query = new URLSearchParams(params as any).toString();
return this.request<LeaguesResponse>(`/v1/leagues?${query}`);
}
async getLeague(id: string) {
return this.request<League>(`/v1/leagues/${id}`);
}
async createLeague(data: LeagueInput) {
return this.request<League>('/v1/leagues', {
method: 'POST',
body: JSON.stringify(data)
});
}
async updateLeague(id: string, data: Partial<LeagueInput>) {
return this.request<League>(`/v1/leagues/${id}`, {
method: 'PATCH',
body: JSON.stringify(data)
});
}
async deleteLeague(id: string) {
return this.request<void>(`/v1/leagues/${id}`, {
method: 'DELETE'
});
}
// Search
async searchLeagues(query: string) {
return this.request<League[]>(`/v1/leagues/discovery?query=${query}`);
}
}
export const apiClient = new APIClient();Error Handling
// lib/error-handler.ts
import { toast } from '@/components/ui/use-toast';
export function handleAPIError(error: unknown) {
if (error instanceof Error) {
const message = error.message;
if (message.includes('401') || message.includes('Unauthorized')) {
toast({
title: 'Authentication Error',
description: 'Please sign in again',
variant: 'destructive'
});
// Redirect to login
window.location.href = '/login';
return;
}
if (message.includes('429') || message.includes('Too Many Requests')) {
toast({
title: 'Rate Limited',
description: 'Too many requests. Please try again later.',
variant: 'destructive'
});
return;
}
toast({
title: 'Error',
description: message,
variant: 'destructive'
});
} else {
toast({
title: 'Unknown Error',
description: 'Something went wrong',
variant: 'destructive'
});
}
}Routing & Navigation
App Router Page Structure
// app/leagues/page.tsx (List page)
import { LeagueList } from '@/components/leagues/LeagueList';
import { Suspense } from 'react';
export default function LeaguesPage() {
return (
<div className="container py-8">
<h1 className="text-4xl font-bold mb-8">Sports Leagues</h1>
<Suspense fallback={<LeagueListSkeleton />}>
<LeagueList />
</Suspense>
</div>
);
}// app/leagues/[id]/page.tsx (Detail page)
import { notFound } from 'next/navigation';
import { LeagueDetail } from '@/components/leagues/LeagueDetail';
interface Props {
params: { id: string };
}
export default async function LeagueDetailPage({ params }: Props) {
const league = await getLeague(params.id);
if (!league) {
notFound();
}
return <LeagueDetail league={league} />;
}Navigation Component
// components/shared/Navbar.tsx
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
const links = [
{ href: '/', label: 'Home' },
{ href: '/leagues', label: 'Leagues' },
{ href: '/dashboard', label: 'Dashboard' }
];
export function Navbar() {
const pathname = usePathname();
return (
<nav className="border-b">
<div className="container flex items-center gap-6 h-16">
<Link href="/" className="font-bold text-xl">
AltSportsLeagues
</Link>
<div className="flex gap-4">
{links.map((link) => (
<Link
key={link.href}
href={link.href}
className={cn(
'text-sm font-medium transition-colors hover:text-primary',
pathname === link.href
? 'text-primary'
: 'text-muted-foreground'
)}
>
{link.label}
</Link>
))}
</div>
</div>
</nav>
);
}Programmatic Navigation
'use client';
import { useRouter } from 'next/navigation';
export function MyComponent() {
const router = useRouter();
const handleNavigate = () => {
router.push('/leagues');
// router.replace('/leagues'); // No history entry
// router.back(); // Go back
// router.refresh(); // Reload current route
};
return <button onClick={handleNavigate}>View Leagues</button>;
}Authentication
Firebase Auth Setup
// lib/firebase.ts
import { initializeApp } from 'firebase/app';
import { getAuth, GoogleAuthProvider } from 'firebase/auth';
const firebaseConfig = {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID
};
export const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);
export const googleProvider = new GoogleAuthProvider();Auth Hook
// hooks/useAuth.ts
import { useEffect, useState } from 'react';
import { auth } from '@/lib/firebase';
import { onAuthStateChanged, User } from 'firebase/auth';
export function useAuth() {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (user) => {
setUser(user);
setLoading(false);
});
return () => unsubscribe();
}, []);
return { user, loading };
}Login Component
// components/auth/LoginButton.tsx
'use client';
import { signInWithPopup, signOut } from 'firebase/auth';
import { auth, googleProvider } from '@/lib/firebase';
import { Button } from '@/components/ui/button';
import { useAuth } from '@/hooks/useAuth';
export function LoginButton() {
const { user, loading } = useAuth();
const handleLogin = async () => {
try {
await signInWithPopup(auth, googleProvider);
} catch (error) {
console.error('Login failed:', error);
}
};
const handleLogout = async () => {
try {
await signOut(auth);
} catch (error) {
console.error('Logout failed:', error);
}
};
if (loading) return <Button disabled>Loading...</Button>;
if (user) {
return (
<div className="flex items-center gap-4">
<span className="text-sm">{user.email}</span>
<Button onClick={handleLogout} variant="outline">
Sign Out
</Button>
</div>
);
}
return (
<Button onClick={handleLogin}>
Sign in with Google
</Button>
);
}Protected Routes
// components/auth/ProtectedRoute.tsx
'use client';
import { useAuth } from '@/hooks/useAuth';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!loading && !user) {
router.push('/login');
}
}, [user, loading, router]);
if (loading) {
return <div>Loading...</div>;
}
if (!user) {
return null;
}
return <>{children}</>;
}Styling
Tailwind Configuration
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ['class'],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}'
],
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px'
}
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
}
}
}
},
plugins: [require('tailwindcss-animate')]
};Global Styles
/* styles/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
}
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}Dark Mode Toggle
// components/ui/ThemeToggle.tsx
'use client';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
import { Button } from '@/components/ui/button';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
>
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
);
}Testing
E2E Testing with Playwright
# Install Playwright
npm install -D @playwright/test
# Install browsers
npx playwright install
# Run tests
npm run test:e2e
# Run tests in UI mode
npx playwright test --ui
# Run specific test
npx playwright test leagues.spec.tsExample E2E Test
// e2e/leagues.spec.ts
import { test, expect } from '@playwright/test';
test.describe('League Discovery', () => {
test('should display league list', async ({ page }) => {
await page.goto('http://localhost:3031/leagues');
// Wait for leagues to load
await page.waitForSelector('[data-testid="league-card"]');
// Check that leagues are displayed
const leagues = await page.locator('[data-testid="league-card"]').count();
expect(leagues).toBeGreaterThan(0);
});
test('should search for leagues', async ({ page }) => {
await page.goto('http://localhost:3031/leagues');
// Enter search query
await page.fill('[data-testid="search-input"]', 'basketball');
await page.keyboard.press('Enter');
// Check results
await page.waitForSelector('[data-testid="league-card"]');
const firstLeague = await page.locator('[data-testid="league-card"]').first();
await expect(firstLeague).toContainText('basketball');
});
test('should navigate to league detail', async ({ page }) => {
await page.goto('http://localhost:3031/leagues');
// Click first league
await page.locator('[data-testid="league-card"]').first().click();
// Check URL and content
expect(page.url()).toContain('/leagues/');
await expect(page.locator('h1')).toBeVisible();
});
});Component Testing
// __tests__/LeagueCard.test.tsx
import { render, screen } from '@testing-library/react';
import { LeagueCard } from '@/components/leagues/LeagueCard';
const mockLeague = {
id: 'nba',
name: 'National Basketball Association',
sport: 'basketball',
region: 'north_america',
teams_count: 30,
founded: 1946
};
describe('LeagueCard', () => {
it('renders league information', () => {
render(<LeagueCard league={mockLeague} />);
expect(screen.getByText('National Basketball Association')).toBeInTheDocument();
expect(screen.getByText('basketball')).toBeInTheDocument();
expect(screen.getByText(/Teams: 30/)).toBeInTheDocument();
});
it('calls onClick when clicked', () => {
const handleClick = jest.fn();
render(<LeagueCard league={mockLeague} onClick={handleClick} />);
screen.getByText('National Basketball Association').click();
expect(handleClick).toHaveBeenCalled();
});
});Deployment
Local Build
# Build for production
npm run build
# Start production server
npm start
# Test production build locally
open http://localhost:3031Vercel Deployment
# Install Vercel CLI
npm install -g vercel
# Login to Vercel
vercel login
# Deploy (production)
vercel --prod
# Deploy (preview)
vercelEnvironment Variables in Vercel
- Go to Vercel Dashboard β Project β Settings β Environment Variables
- Add all required variables:
NEXT_PUBLIC_API_URLNEXT_PUBLIC_FIREBASE_API_KEYNEXT_PUBLIC_FIREBASE_AUTH_DOMAIN- etc.
vercel.json Configuration
{
"buildCommand": "npm run build",
"devCommand": "npm run dev",
"installCommand": "npm install",
"framework": "nextjs",
"rewrites": [
{
"source": "/api/v1/:path*",
"destination": "https://api.altsportsleagues.ai/v1/:path*"
}
],
"redirects": [
{
"source": "/docs/:path*",
"destination": "https://docs.altsportsleagues.ai/:path*"
}
]
}Performance Optimization
Image Optimization
import Image from 'next/image';
<Image
src="/logo.png"
alt="Logo"
width={200}
height={50}
priority // For above-the-fold images
/>Code Splitting
// Dynamic imports for large components
import dynamic from 'next/dynamic';
const GraphVisualization = dynamic(
() => import('@/components/GraphVisualization'),
{
ssr: false,
loading: () => <p>Loading...</p>
}
);Caching Strategy
// React Query caching
const { data } = useQuery({
queryKey: ['leagues'],
queryFn: fetchLeagues,
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000 // 10 minutes (was cacheTime)
});Best Practices
- Use TypeScript everywhere - Catch errors at compile time
- Component composition - Build small, reusable components
- Server Components by default - Use 'use client' only when needed
- Optimize images - Use Next.js Image component
- Code splitting - Dynamic imports for large components
- Error boundaries - Catch and handle errors gracefully
- Loading states - Always show loading indicators
- Accessibility - Use semantic HTML and ARIA labels
- SEO optimization - Use metadata API in Next.js 13+
- Performance monitoring - Use Web Vitals
Troubleshooting
Common Issues
1. Hydration Errors
// Solution: Use useEffect for client-only code
useEffect(() => {
// Client-only code
}, []);2. API Token Expired
// Solution: Refresh token automatically
auth.onIdTokenChanged(async (user) => {
if (user) {
const token = await user.getIdToken(true);
// Update token
}
});3. Build Errors
# Clear Next.js cache
rm -rf .next
npm run build4. Type Errors
# Check types
npm run type-check
# Update types from backend
npm run sync-typesResources
- Next.js Docs: https://nextjs.org/docs (opens in a new tab)
- React Docs: https://react.dev (opens in a new tab)
- Tailwind CSS: https://tailwindcss.com/docs (opens in a new tab)
- shadcn/ui: https://ui.shadcn.com (opens in a new tab)
- React Query: https://tanstack.com/query/latest (opens in a new tab)
- Zustand: https://docs.pmnd.rs/zustand (opens in a new tab)
- Playwright: https://playwright.dev (opens in a new tab)
Last Updated: November 2024 Version: 2.1.0