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 (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+

+
+
{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 && (
+
+ )}
+
+
+ {loading ? (
+
Loading reviews...
+ ) : reviews.length === 0 ? (
+
+
No reviews yet. Be the first to review this court!
+
+ ) : (
+ reviews.map(review => (
+
+
+
{review.author}
+
{formatDate(review.date)}
+
+
+
+ {renderStars(review.rating)}
+
+
+
+ {review.comment}
+
+
+ ))
+ )}
+
+
+
+
+ );
+};
+
+export default CourtDetail;
diff --git a/code-challenge/src/components/CourtsList.js b/code-challenge/src/components/CourtsList.js
new file mode 100644
index 000000000..19948b94c
--- /dev/null
+++ b/code-challenge/src/components/CourtsList.js
@@ -0,0 +1,157 @@
+import React, { useState, useEffect } from 'react';
+import { fetchCourts, searchCourts } from '../api/courtsApi';
+
+const CourtsList = ({ onCourtSelect }) => {
+ const [courts, setCourts] = useState([]);
+ const [filteredCourts, setFilteredCourts] = useState([]);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ loadCourts();
+ }, []);
+
+ useEffect(() => {
+ if (searchQuery.trim() === '') {
+ setFilteredCourts(courts);
+ } else {
+ performSearch(searchQuery);
+ }
+ }, [searchQuery, courts]);
+
+ const loadCourts = async () => {
+ try {
+ setLoading(true);
+ const courtsData = await fetchCourts();
+ setCourts(courtsData);
+ setFilteredCourts(courtsData);
+ } catch (err) {
+ setError('Failed to load courts');
+ console.error('Error loading courts:', err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const performSearch = async (query) => {
+ try {
+ const searchResults = await searchCourts(query);
+ setFilteredCourts(searchResults);
+ } catch (err) {
+ setError('Search failed');
+ console.error('Error searching courts:', err);
+ }
+ };
+
+ const handleSearchChange = (e) => {
+ setSearchQuery(e.target.value);
+ };
+
+ const renderStars = (rating) => {
+ const stars = [];
+ const fullStars = Math.floor(rating);
+ const hasHalfStar = rating % 1 !== 0;
+
+ for (let i = 0; i < fullStars; i++) {
+ stars.push(★);
+ }
+
+ if (hasHalfStar) {
+ stars.push(★);
+ }
+
+ const emptyStars = 5 - Math.ceil(rating);
+ for (let i = 0; i < emptyStars; i++) {
+ stars.push(☆);
+ }
+
+ return stars;
+ };
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
Tennis Courts
+
+
+ 🔍
+
+
+
+
+ {filteredCourts.length === 0 ? (
+
+
No courts found matching your search.
+
+
+ ) : (
+ filteredCourts.map(court => (
+
onCourtSelect(court)}>
+
+

+
{court.surface}
+
+
+
+
{court.name}
+
{court.location}
+
+
+
+ {renderStars(court.rating)}
+
+
+ {court.rating} ({court.reviewCount} reviews)
+
+
+
+
{court.price}
+
+
+ {court.amenities.slice(0, 3).map((amenity, index) => (
+ {amenity}
+ ))}
+ {court.amenities.length > 3 && (
+ +{court.amenities.length - 3} more
+ )}
+
+
+
+
+
+
+
+ ))
+ )}
+
+
+ );
+};
+
+export default CourtsList;