diff --git a/code-challenge/src/App.css b/code-challenge/src/App.css index 27781fd9b..096691489 100644 --- a/code-challenge/src/App.css +++ b/code-challenge/src/App.css @@ -1,120 +1,592 @@ +/* Mobile-first responsive design */ +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + .App { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.App-header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 1rem; text-align: center; - max-width: 800px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); +} + +.App-header h1 { + margin: 0 0 0.5rem 0; + font-size: 1.5rem; + font-weight: 600; +} + +.App-header p { + margin: 0; + font-size: 0.9rem; + opacity: 0.9; +} + +main { + flex: 1; + padding: 1rem; + max-width: 1200px; margin: 0 auto; - padding: 20px; + width: 100%; } -.App-logo { - height: 40vmin; - pointer-events: none; +/* Courts List Styles */ +.courts-list { + width: 100%; } -@media (prefers-reduced-motion: no-preference) { - .App-logo { - animation: App-logo-spin infinite 20s linear; - } +.search-section { + margin-bottom: 1.5rem; } -.App-header { - background-color: #282c34; - min-height: 100vh; +.search-section h2 { + margin: 0 0 1rem 0; + color: #333; + font-size: 1.3rem; +} + +.search-container { + position: relative; + margin-bottom: 1rem; +} + +.search-input { + width: 100%; + padding: 0.75rem 2.5rem 0.75rem 1rem; + border: 2px solid #e1e5e9; + border-radius: 25px; + font-size: 1rem; + background-color: white; + transition: border-color 0.3s ease; +} + +.search-input:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.search-icon { + position: absolute; + right: 1rem; + top: 50%; + transform: translateY(-50%); + font-size: 1.2rem; + color: #666; +} + +.courts-grid { + display: grid; + gap: 1rem; + grid-template-columns: 1fr; +} + +.court-card { + background: white; + border-radius: 12px; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + transition: transform 0.2s ease, box-shadow 0.2s ease; + cursor: pointer; +} + +.court-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(0,0,0,0.15); +} + +.court-image { + position: relative; + height: 200px; + overflow: hidden; +} + +.court-image img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.court-surface { + position: absolute; + top: 0.75rem; + right: 0.75rem; + background: rgba(0,0,0,0.7); + color: white; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.8rem; + font-weight: 500; +} + +.court-info { + padding: 1rem; +} + +.court-name { + margin: 0 0 0.5rem 0; + font-size: 1.1rem; + font-weight: 600; + color: #333; +} + +.court-location { + margin: 0 0 0.75rem 0; + color: #666; + font-size: 0.9rem; +} + +.court-rating { display: flex; - flex-direction: column; align-items: center; - justify-content: center; - font-size: calc(10px + 2vmin); - color: white; - margin-bottom: 30px; + gap: 0.5rem; + margin-bottom: 0.75rem; } -.App-link { - color: #61dafb; +.stars { + display: flex; + gap: 0.1rem; } -@keyframes App-logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } +.star { + font-size: 1rem; + color: #ffc107; } -.tabs { +.star.empty { + color: #ddd; +} + +.rating-text { + font-size: 0.9rem; + color: #666; +} + +.court-price { + font-size: 1rem; + font-weight: 600; + color: #667eea; + margin-bottom: 0.75rem; +} + +.court-amenities { display: flex; - justify-content: center; - margin-top: 20px; + flex-wrap: wrap; + gap: 0.5rem; } -.tabs button { - background-color: #f0f0f0; - border: 1px solid #ccc; - padding: 10px 20px; - cursor: pointer; - font-size: 16px; - transition: background-color 0.3s; +.amenity-tag { + background: #f0f4ff; + color: #667eea; + padding: 0.25rem 0.5rem; + border-radius: 12px; + font-size: 0.8rem; + font-weight: 500; +} + +.amenity-tag.more { + background: #e9ecef; + color: #6c757d; } -.tabs button.active { - background-color: #61dafb; +.court-actions { + padding: 0 1rem 1rem 1rem; +} + +.view-details-btn { + width: 100%; + background: #667eea; color: white; + border: none; + padding: 0.75rem; + border-radius: 8px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.3s ease; +} + +.view-details-btn:hover { + background: #5a6fd8; } -table { +/* Court Detail Styles */ +.court-detail { width: 100%; - border-collapse: collapse; - margin-top: 20px; } -table th, table td { - border: 1px solid #ddd; - padding: 12px; - text-align: left; +.back-btn { + background: none; + border: none; + color: #667eea; + font-size: 1rem; + cursor: pointer; + padding: 0.5rem 0; + margin-bottom: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; } -table th { - background-color: #f2f2f2; +.back-btn:hover { + color: #5a6fd8; +} + +.court-hero { + position: relative; + height: 250px; + border-radius: 12px; + overflow: hidden; + margin-bottom: 1.5rem; +} + +.court-hero-image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.court-hero-overlay { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: linear-gradient(transparent, rgba(0,0,0,0.7)); + color: white; + padding: 2rem 1rem 1rem 1rem; +} + +.court-title { + margin: 0 0 0.5rem 0; + font-size: 1.5rem; + font-weight: 600; +} + +.court-subtitle { + margin: 0; + font-size: 1rem; + opacity: 0.9; +} + +.court-content { + display: grid; + gap: 2rem; +} + +.court-info-section { + background: white; + padding: 1.5rem; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); +} + +.court-stats { + display: grid; + grid-template-columns: 1fr; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.stat { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.stat-label { + font-size: 0.9rem; + color: #666; + font-weight: 500; +} + +.stat-value { + font-size: 1.1rem; + font-weight: 600; + color: #333; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.court-description h3, +.court-amenities h3 { + margin: 0 0 1rem 0; + color: #333; + font-size: 1.1rem; +} + +.court-description p { + margin: 0; + line-height: 1.6; + color: #666; +} + +.amenities-grid { + display: grid; + grid-template-columns: 1fr; + gap: 0.75rem; +} + +.amenity-item { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.amenity-icon { + color: #28a745; + font-weight: bold; +} + +.amenity-name { + color: #333; +} + +/* Reviews Section */ +.reviews-section { + background: white; + padding: 1.5rem; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); +} + +.reviews-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.reviews-header h3 { + margin: 0; + color: #333; + font-size: 1.1rem; +} + +.add-review-btn { + background: #667eea; + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 6px; + font-size: 0.9rem; + cursor: pointer; + transition: background-color 0.3s ease; +} + +.add-review-btn:hover { + background: #5a6fd8; +} + +.review-form { + background: #f8f9fa; + padding: 1.5rem; + border-radius: 8px; + margin-bottom: 1.5rem; } .form-group { - margin-bottom: 20px; - text-align: left; + margin-bottom: 1rem; } -label { +.form-group label { display: block; - margin-bottom: 8px; + margin-bottom: 0.5rem; + font-weight: 500; + color: #333; } -select, input[type="range"] { +.form-group input, +.form-group textarea { width: 100%; - padding: 8px; - box-sizing: border-box; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 6px; + font-size: 1rem; + font-family: inherit; +} + +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.rating-input { + display: flex; + gap: 0.25rem; +} + +.rating-input .star { + cursor: pointer; + font-size: 1.5rem; + transition: transform 0.2s ease; } -button[type="submit"] { - background-color: #61dafb; +.rating-input .star:hover { + transform: scale(1.1); +} + +.submit-review-btn { + background: #28a745; color: white; border: none; - padding: 10px 20px; - font-size: 16px; + padding: 0.75rem 1.5rem; + border-radius: 6px; + font-size: 1rem; cursor: pointer; - transition: background-color 0.3s; + transition: background-color 0.3s ease; } -button[type="submit"]:hover { - background-color: #21a1cb; +.submit-review-btn:hover:not(:disabled) { + background: #218838; } -button[type="submit"]:disabled { - background-color: #cccccc; +.submit-review-btn:disabled { + background: #6c757d; cursor: not-allowed; } +.reviews-list { + display: grid; + gap: 1rem; +} + +.review-item { + padding: 1rem; + border: 1px solid #e9ecef; + border-radius: 8px; + background: #f8f9fa; +} + +.review-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.review-author { + font-weight: 600; + color: #333; +} + +.review-date { + font-size: 0.9rem; + color: #666; +} + +.review-rating { + margin-bottom: 0.75rem; +} + +.review-comment { + color: #333; + line-height: 1.5; +} + +/* Utility Classes */ +.loading, .error, .no-results, .no-reviews { + text-align: center; + padding: 2rem; + color: #666; +} + +.error { + color: #dc3545; +} + .message { - margin-top: 20px; - padding: 10px; - background-color: #f8f9fa; - border-radius: 4px; + padding: 0.75rem 1rem; + border-radius: 6px; + margin-bottom: 1rem; + font-weight: 500; +} + +.message.success { + background: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; +} + +.message.error { + background: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; +} + +.retry-btn, .clear-search-btn { + background: #667eea; + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 6px; + cursor: pointer; + margin-top: 1rem; +} + +.retry-btn:hover, .clear-search-btn:hover { + background: #5a6fd8; +} + +/* Tablet and Desktop Styles */ +@media (min-width: 768px) { + .App-header h1 { + font-size: 2rem; + } + + .App-header p { + font-size: 1rem; + } + + main { + padding: 2rem; + } + + .courts-grid { + grid-template-columns: repeat(2, 1fr); + } + + .court-stats { + grid-template-columns: repeat(3, 1fr); + } + + .amenities-grid { + grid-template-columns: repeat(2, 1fr); + } + + .court-hero { + height: 300px; + } + + .court-title { + font-size: 2rem; + } +} + +@media (min-width: 1024px) { + .courts-grid { + grid-template-columns: repeat(3, 1fr); + } + + .court-content { + grid-template-columns: 1fr 1fr; + align-items: start; + } + + .amenities-grid { + grid-template-columns: repeat(3, 1fr); + } } diff --git a/code-challenge/src/App.js b/code-challenge/src/App.js index f83069c67..6ec1d7221 100644 --- a/code-challenge/src/App.js +++ b/code-challenge/src/App.js @@ -1,47 +1,37 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import './App.css'; -import PlayersList from './components/PlayersList'; -import MatchRating from './components/MatchRating'; -import { fetchPlayers } from './api/playerApi'; +import CourtsList from './components/CourtsList'; +import CourtDetail from './components/CourtDetail'; function App() { - const [activeTab, setActiveTab] = useState('players'); - const [players, setPlayers] = useState([]); + const [currentPage, setCurrentPage] = useState('courts-list'); + const [selectedCourt, setSelectedCourt] = useState(null); - useEffect(() => { - // Load initial player data - const loadPlayers = async () => { - const loadedPlayers = await fetchPlayers(); - setPlayers(loadedPlayers); - }; - - loadPlayers(); - }, []); + const handleCourtSelect = (court) => { + setSelectedCourt(court); + setCurrentPage('court-detail'); + }; + + const handleBackToList = () => { + setSelectedCourt(null); + setCurrentPage('courts-list'); + }; return (
-

USTA Player Rating System

-
- - -
+

Tennis Court Reviews

+

Find and review the best tennis courts in your area

+
- {activeTab === 'players' ? ( - + {currentPage === 'courts-list' ? ( + ) : ( - + )}
diff --git a/code-challenge/src/api/courtsApi.js b/code-challenge/src/api/courtsApi.js new file mode 100644 index 000000000..d42021303 --- /dev/null +++ b/code-challenge/src/api/courtsApi.js @@ -0,0 +1,217 @@ +// Mock data for tennis courts +const mockCourts = [ + { + id: 1, + name: "Central Park Tennis Center", + location: "Central Park, New York, NY", + surface: "Hard Court", + price: "$25/hour", + rating: 4.2, + reviewCount: 0, + amenities: ["Lighting", "Parking", "Pro Shop", "Restrooms"], + description: "Beautiful courts in the heart of Central Park with excellent facilities and professional staff.", + image: "https://images.unsplash.com/photo-1554068865-24cecd4e34b8?w=400&h=300&fit=crop" + }, + { + id: 2, + name: "Brooklyn Bridge Park Courts", + location: "Brooklyn Bridge Park, Brooklyn, NY", + surface: "Clay Court", + price: "$20/hour", + rating: 4.5, + reviewCount: 0, + amenities: ["Lighting", "Parking", "Water Fountain", "Restrooms"], + description: "Scenic clay courts with stunning views of the Manhattan skyline and Brooklyn Bridge.", + image: "https://images.unsplash.com/photo-1622279457486-62dcc4a431d6?w=400&h=300&fit=crop" + }, + { + id: 3, + name: "Queens Tennis Academy", + location: "Flushing Meadows, Queens, NY", + surface: "Hard Court", + price: "$30/hour", + rating: 4.7, + reviewCount: 0, + amenities: ["Lighting", "Parking", "Pro Shop", "Restrooms", "Locker Rooms", "Café"], + description: "Professional-grade courts with top-notch facilities and coaching services available.", + image: "https://images.unsplash.com/photo-1551698618-1dfe5d97d256?w=400&h=300&fit=crop" + }, + { + id: 4, + name: "Riverside Park Courts", + location: "Riverside Park, Manhattan, NY", + surface: "Hard Court", + price: "$18/hour", + rating: 3.9, + reviewCount: 0, + amenities: ["Lighting", "Restrooms"], + description: "Affordable courts along the Hudson River with basic amenities and good value.", + image: "https://images.unsplash.com/photo-1595435934249-3b2b5b5b5b5b?w=400&h=300&fit=crop" + }, + { + id: 5, + name: "Prospect Park Tennis Center", + location: "Prospect Park, Brooklyn, NY", + surface: "Clay Court", + price: "$22/hour", + rating: 4.3, + reviewCount: 0, + amenities: ["Lighting", "Parking", "Pro Shop", "Restrooms", "Water Fountain"], + description: "Well-maintained clay courts in the beautiful Prospect Park setting.", + image: "https://images.unsplash.com/photo-1551698618-1dfe5d97d256?w=400&h=300&fit=crop" + }, + { + id: 6, + name: "Astoria Park Courts", + location: "Astoria Park, Queens, NY", + surface: "Hard Court", + price: "$15/hour", + rating: 3.8, + reviewCount: 0, + amenities: ["Lighting", "Restrooms"], + description: "Budget-friendly courts with basic facilities, perfect for casual play.", + image: "https://images.unsplash.com/photo-1554068865-24cecd4e34b8?w=400&h=300&fit=crop" + }, + { + id: 7, + name: "Central Park Tennis Center", + location: "Central Park, New York, NY", + surface: "Hard Court", + price: "$25/hour", + rating: 4.2, + reviewCount: 0, + amenities: ["Lighting", "Parking", "Pro Shop", "Restrooms"], + description: "Beautiful courts in the heart of Central Park with excellent facilities and professional staff.", + image: "https://images.unsplash.com/photo-1554068865-24cecd4e34b8?w=400&h=300&fit=crop" + }, + { + id: 8, + name: "Brooklyn Bridge Park Courts", + location: "Brooklyn Bridge Park, Brooklyn, NY", + surface: "Clay Court", + price: "$20/hour", + rating: 4.5, + reviewCount: 0, + amenities: ["Lighting", "Parking", "Water Fountain", "Restrooms"], + description: "Scenic clay courts with stunning views of the Manhattan skyline and Brooklyn Bridge.", + image: "https://images.unsplash.com/photo-1622279457486-62dcc4a431d6?w=400&h=300&fit=crop" + }, + { + id: 9, + name: "Queens Tennis Academy", + location: "Flushing Meadows, Queens, NY", + surface: "Hard Court", + price: "$30/hour", + rating: 4.7, + reviewCount: 0, + amenities: ["Lighting", "Parking", "Pro Shop", "Restrooms", "Locker Rooms", "Café"], + description: "Professional-grade courts with top-notch facilities and coaching services available.", + image: "https://images.unsplash.com/photo-1551698618-1dfe5d97d256?w=400&h=300&fit=crop" + }, + { + id: 10, + name: "Riverside Park Courts", + location: "Riverside Park, Manhattan, NY", + surface: "Hard Court", + price: "$18/hour", + rating: 3.9, + reviewCount: 0, + amenities: ["Lighting", "Restrooms"], + description: "Affordable courts along the Hudson River with basic amenities and good value.", + image: "https://images.unsplash.com/photo-1595435934249-3b2b5b5b5b5b?w=400&h=300&fit=crop" + }, + { + id: 11, + name: "Prospect Park Tennis Center", + location: "Prospect Park, Brooklyn, NY", + surface: "Clay Court", + price: "$22/hour", + rating: 4.3, + reviewCount: 0, + amenities: ["Lighting", "Parking", "Pro Shop", "Restrooms", "Water Fountain"], + description: "Well-maintained clay courts in the beautiful Prospect Park setting.", + image: "https://images.unsplash.com/photo-1551698618-1dfe5d97d256?w=400&h=300&fit=crop" + }, + { + id: 12, + name: "Astoria Park Courts", + location: "Astoria Park, Queens, NY", + surface: "Hard Court", + price: "$15/hour", + rating: 3.8, + reviewCount: 0, + amenities: ["Lighting", "Restrooms"], + description: "Budget-friendly courts with basic facilities, perfect for casual play.", + image: "https://images.unsplash.com/photo-1554068865-24cecd4e34b8?w=400&h=300&fit=crop" + } +]; + +// Mock reviews data +const mockReviews = []; + +// Simulate API delay +const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)); + +// Fetch all courts +export const fetchCourts = async () => { + await delay(500); // Simulate network delay + return [...mockCourts]; +}; + +// Fetch a specific court by ID +export const fetchCourtById = async (id) => { + await delay(300); + const court = mockCourts.find(c => c.id === parseInt(id)); + if (!court) { + throw new Error('Court not found'); + } + return court; +}; + +// Fetch reviews for a specific court +export const fetchCourtReviews = async (courtId) => { + await delay(300); + return mockReviews.filter(review => review.courtId === parseInt(courtId)); +}; + +// Submit a new review +export const submitReview = async (courtId, reviewData) => { + await delay(500); + + const newReview = { + id: mockReviews.length + 1, + courtId: parseInt(courtId), + author: reviewData.author || "Anonymous", + rating: parseInt(reviewData.rating), + comment: reviewData.comment, + date: new Date().toISOString().split('T')[0] + }; + + mockReviews.push(newReview); + + // Update court rating + const court = mockCourts.find(c => c.id === parseInt(courtId)); + if (court) { + const courtReviews = mockReviews.filter(r => r.courtId === parseInt(courtId)); + const newAverageRating = courtReviews.reduce((sum, review) => sum + review.rating, 0) / courtReviews.length; + court.rating = Math.round(newAverageRating * 10) / 10; + court.reviewCount = courtReviews.length; + } + + return newReview; +}; + +// Search courts by name or location +export const searchCourts = async (query) => { + await delay(300); + if (!query || query.trim() === '') { + return [...mockCourts]; + } + + const searchTerm = query.toLowerCase(); + return mockCourts.filter(court => + court.name.toLowerCase().includes(searchTerm) || + court.location.toLowerCase().includes(searchTerm) || + court.surface.toLowerCase().includes(searchTerm) + ); +}; diff --git a/code-challenge/src/components/CourtDetail.js b/code-challenge/src/components/CourtDetail.js new file mode 100644 index 000000000..26da29998 --- /dev/null +++ b/code-challenge/src/components/CourtDetail.js @@ -0,0 +1,265 @@ +import React, { useState, useEffect } from 'react'; +import { fetchCourtReviews, submitReview } from '../api/courtsApi'; + +const CourtDetail = ({ court, onBack }) => { + const [reviews, setReviews] = useState([]); + const [loading, setLoading] = useState(true); + const [submittingReview, setSubmittingReview] = useState(false); + const [showReviewForm, setShowReviewForm] = useState(false); + const [reviewForm, setReviewForm] = useState({ + author: '', + rating: 5, + comment: '' + }); + const [message, setMessage] = useState(''); + + useEffect(() => { + if (court) { + loadReviews(); + } + }, [court]); + + const loadReviews = async () => { + try { + setLoading(true); + const reviewsData = await fetchCourtReviews(court.id); + setReviews(reviewsData); + } catch (err) { + console.error('Error loading reviews:', err); + } finally { + setLoading(false); + } + }; + + const handleReviewSubmit = async (e) => { + e.preventDefault(); + + if (!reviewForm.comment.trim()) { + setMessage('Please enter a comment'); + return; + } + + try { + setSubmittingReview(true); + await submitReview(court.id, reviewForm); + + // Reload reviews to show the new one + await loadReviews(); + + // Reset form + setReviewForm({ + author: '', + rating: 5, + comment: '' + }); + setShowReviewForm(false); + setMessage('Review submitted successfully!'); + + // Clear message after 3 seconds + setTimeout(() => setMessage(''), 3000); + } catch (err) { + setMessage('Failed to submit review. Please try again.'); + console.error('Error submitting review:', err); + } finally { + setSubmittingReview(false); + } + }; + + const handleInputChange = (e) => { + const { name, value } = e.target; + setReviewForm(prev => ({ + ...prev, + [name]: value + })); + }; + + const renderStars = (rating, interactive = false, onChange = null) => { + const stars = []; + + for (let i = 1; i <= 5; i++) { + const isFilled = i <= rating; + stars.push( + onChange(i) : undefined} + > + {isFilled ? '★' : '☆'} + + ); + } + + return stars; + }; + + const formatDate = (dateString) => { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + }; + + if (!court) { + return ( +
+
Court not found
+
+ ); + } + + return ( +
+
+ + +
+ {court.name} +
+

{court.name}

+

{court.location}

+
+
+
+ +
+
+
+
+ Rating +
+
+ {renderStars(court.rating)} +
+ {court.rating} + ({court.reviewCount} reviews) +
+
+ +
+ Surface + {court.surface} +
+ +
+ Price + {court.price} +
+
+ +
+

About this court

+

{court.description}

+
+ +
+

Amenities

+
+ {court.amenities.map((amenity, index) => ( +
+ + {amenity} +
+ ))} +
+
+
+ +
+
+

Reviews ({reviews.length})

+ +
+ + {message && ( +
+ {message} +
+ )} + + {showReviewForm && ( +
+
+ + +
+ +
+ +
+ {renderStars(reviewForm.rating, true, (rating) => + setReviewForm(prev => ({ ...prev, rating })) + )} +
+
+ +
+ +