feat(events): add competitor number (bib) support
Allow participants to set their bib/competitor number per event. Display as badge next to username in participant lists. - Add competitorNumber field to EventParticipant model - Add PUT /events/:slug/competitor-number endpoint - Include competitorNumber in heats/me and heats/all responses - Add input field in HeatsBanner component - Display badge in UserListItem component - Add unit tests for competitor number feature
This commit is contained in:
@@ -8,6 +8,7 @@ import UserListItem from '../users/UserListItem';
|
||||
* @param {Array} users - Array of user objects to display (already filtered)
|
||||
* @param {Array} activeUsers - Array of currently online users (for count)
|
||||
* @param {Map} userHeats - Map of userId -> heats array
|
||||
* @param {Map} userCompetitorNumbers - Map of userId -> competitor number (bib)
|
||||
* @param {Array} myHeats - Current user's heats (for filter checkbox visibility)
|
||||
* @param {boolean} hideMyHeats - Filter state
|
||||
* @param {function} onHideMyHeatsChange - Filter change handler
|
||||
@@ -19,6 +20,7 @@ import UserListItem from '../users/UserListItem';
|
||||
* users={filteredUsers}
|
||||
* activeUsers={activeUsers}
|
||||
* userHeats={userHeats}
|
||||
* userCompetitorNumbers={userCompetitorNumbers}
|
||||
* myHeats={myHeats}
|
||||
* hideMyHeats={hideMyHeats}
|
||||
* onHideMyHeatsChange={setHideMyHeats}
|
||||
@@ -29,6 +31,7 @@ const ParticipantsSidebar = ({
|
||||
users = [],
|
||||
activeUsers = [],
|
||||
userHeats = new Map(),
|
||||
userCompetitorNumbers = new Map(),
|
||||
myHeats = [],
|
||||
hideMyHeats = false,
|
||||
onHideMyHeatsChange,
|
||||
@@ -73,6 +76,7 @@ const ParticipantsSidebar = ({
|
||||
<div className="space-y-2">
|
||||
{users.map((displayUser) => {
|
||||
const thisUserHeats = userHeats.get(displayUser.userId) || [];
|
||||
const competitorNumber = userCompetitorNumbers.get(displayUser.userId);
|
||||
const hasHeats = thisUserHeats.length > 0;
|
||||
|
||||
return (
|
||||
@@ -80,6 +84,7 @@ const ParticipantsSidebar = ({
|
||||
key={displayUser.userId}
|
||||
user={displayUser}
|
||||
heats={thisUserHeats}
|
||||
competitorNumber={competitorNumber}
|
||||
showHeats={true}
|
||||
actionButton={
|
||||
<button
|
||||
|
||||
@@ -2,10 +2,11 @@ import { useState, useEffect } from 'react';
|
||||
import { X, Plus, Trash2, Loader2 } from 'lucide-react';
|
||||
import { divisionsAPI, competitionTypesAPI, heatsAPI } from '../../services/api';
|
||||
|
||||
export default function HeatsBanner({ slug, onSave, onDismiss, existingHeats = null }) {
|
||||
export default function HeatsBanner({ slug, onSave, onDismiss, existingHeats = null, existingCompetitorNumber = null }) {
|
||||
const [divisions, setDivisions] = useState([]);
|
||||
const [competitionTypes, setCompetitionTypes] = useState([]);
|
||||
const [heats, setHeats] = useState([{ divisionId: '', competitionTypeId: '', heatNumber: '', role: '' }]);
|
||||
const [competitorNumber, setCompetitorNumber] = useState(existingCompetitorNumber || '');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
@@ -80,6 +81,11 @@ export default function HeatsBanner({ slug, onSave, onDismiss, existingHeats = n
|
||||
|
||||
await heatsAPI.saveHeats(slug, heatsToSave);
|
||||
|
||||
// Save competitor number if provided
|
||||
if (competitorNumber) {
|
||||
await heatsAPI.setCompetitorNumber(slug, parseInt(competitorNumber));
|
||||
}
|
||||
|
||||
if (onSave) {
|
||||
onSave();
|
||||
}
|
||||
@@ -132,6 +138,28 @@ export default function HeatsBanner({ slug, onSave, onDismiss, existingHeats = n
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg p-4 shadow-sm border border-amber-200 mb-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-end">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Competitor Number (Bib)
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
Your bib number for this event (same across all divisions)
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="9999"
|
||||
value={competitorNumber}
|
||||
onChange={(e) => setCompetitorNumber(e.target.value)}
|
||||
placeholder="e.g., 123"
|
||||
className="w-full max-w-[150px] px-3 py-2 border border-gray-300 rounded-md focus:ring-primary-500 focus:border-primary-500 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{heats.map((heat, index) => (
|
||||
<div key={index} className="bg-white rounded-lg p-4 shadow-sm border border-amber-200">
|
||||
|
||||
@@ -8,6 +8,7 @@ import HeatBadges from '../heats/HeatBadges';
|
||||
*
|
||||
* @param {object} user - User object with { userId, username, avatar, isOnline, firstName, lastName }
|
||||
* @param {Array} heats - Array of heat objects for this user
|
||||
* @param {number} competitorNumber - User's bib/competitor number (optional)
|
||||
* @param {boolean} showHeats - Whether to display heat badges (default: true)
|
||||
* @param {React.ReactNode} actionButton - Optional action button/element to display on right
|
||||
* @param {function} onClick - Optional click handler for the entire item
|
||||
@@ -18,6 +19,7 @@ import HeatBadges from '../heats/HeatBadges';
|
||||
* <UserListItem
|
||||
* user={displayUser}
|
||||
* heats={userHeats.get(userId)}
|
||||
* competitorNumber={123}
|
||||
* actionButton={
|
||||
* <button onClick={() => handleMatch(userId)}>
|
||||
* <UserPlus />
|
||||
@@ -28,6 +30,7 @@ import HeatBadges from '../heats/HeatBadges';
|
||||
const UserListItem = ({
|
||||
user,
|
||||
heats = [],
|
||||
competitorNumber,
|
||||
showHeats = true,
|
||||
actionButton,
|
||||
onClick,
|
||||
@@ -66,7 +69,14 @@ const UserListItem = ({
|
||||
title={user.username}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
{usernameContent}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{usernameContent}
|
||||
{competitorNumber && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 text-xs font-medium bg-amber-100 text-amber-800 rounded">
|
||||
#{competitorNumber}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Full name (optional) */}
|
||||
{(user.firstName || user.lastName) && (
|
||||
<p className="text-xs text-gray-500 truncate">
|
||||
|
||||
@@ -43,7 +43,9 @@ const EventChatPage = () => {
|
||||
|
||||
// Heats state
|
||||
const [myHeats, setMyHeats] = useState([]);
|
||||
const [myCompetitorNumber, setMyCompetitorNumber] = useState(null);
|
||||
const [userHeats, setUserHeats] = useState(new Map());
|
||||
const [userCompetitorNumbers, setUserCompetitorNumbers] = useState(new Map());
|
||||
const [showHeatsBanner, setShowHeatsBanner] = useState(false);
|
||||
const [hideMyHeats, setHideMyHeats] = useState(false);
|
||||
const [showHeatsModal, setShowHeatsModal] = useState(false);
|
||||
@@ -87,18 +89,25 @@ const EventChatPage = () => {
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
// Load my heats
|
||||
const myHeatsData = await heatsAPI.getMyHeats(slug);
|
||||
// Load my heats and competitor number
|
||||
const myHeatsResponse = await heatsAPI.getMyHeats(slug);
|
||||
const myHeatsData = myHeatsResponse.data || [];
|
||||
setMyHeats(myHeatsData);
|
||||
setMyCompetitorNumber(myHeatsResponse.competitorNumber);
|
||||
setShowHeatsBanner(myHeatsData.length === 0);
|
||||
|
||||
// Load all users' heats
|
||||
// Load all users' heats and competitor numbers
|
||||
const allHeatsData = await heatsAPI.getAllHeats(slug);
|
||||
const heatsMap = new Map();
|
||||
const competitorNumbersMap = new Map();
|
||||
allHeatsData.forEach((userHeat) => {
|
||||
heatsMap.set(userHeat.userId, userHeat.heats);
|
||||
if (userHeat.competitorNumber) {
|
||||
competitorNumbersMap.set(userHeat.userId, userHeat.competitorNumber);
|
||||
}
|
||||
});
|
||||
setUserHeats(heatsMap);
|
||||
setUserCompetitorNumbers(competitorNumbersMap);
|
||||
|
||||
// Load all checked-in users (participants)
|
||||
const eventDetails = await eventsAPI.getDetails(slug);
|
||||
@@ -384,6 +393,7 @@ const EventChatPage = () => {
|
||||
slug={slug}
|
||||
onSave={handleHeatsSave}
|
||||
onDismiss={() => setShowHeatsBanner(false)}
|
||||
existingCompetitorNumber={myCompetitorNumber}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -392,6 +402,7 @@ const EventChatPage = () => {
|
||||
users={getAllDisplayUsers().filter(u => !shouldHideUser(u.userId))}
|
||||
activeUsers={activeUsers}
|
||||
userHeats={userHeats}
|
||||
userCompetitorNumbers={userCompetitorNumbers}
|
||||
myHeats={myHeats}
|
||||
hideMyHeats={hideMyHeats}
|
||||
onHideMyHeatsChange={setHideMyHeats}
|
||||
@@ -457,6 +468,7 @@ const EventChatPage = () => {
|
||||
onSave={handleHeatsSave}
|
||||
onDismiss={() => setShowHeatsModal(false)}
|
||||
existingHeats={myHeats}
|
||||
existingCompetitorNumber={myCompetitorNumber}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -292,7 +292,8 @@ export const heatsAPI = {
|
||||
|
||||
async getMyHeats(slug) {
|
||||
const data = await fetchAPI(`/events/${slug}/heats/me`);
|
||||
return data.data;
|
||||
// Returns { data: heats[], competitorNumber: number|null }
|
||||
return data;
|
||||
},
|
||||
|
||||
async getAllHeats(slug) {
|
||||
@@ -306,6 +307,14 @@ export const heatsAPI = {
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async setCompetitorNumber(slug, competitorNumber) {
|
||||
const data = await fetchAPI(`/events/${slug}/competitor-number`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ competitorNumber }),
|
||||
});
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
// Matches API (Phase 2)
|
||||
|
||||
Reference in New Issue
Block a user