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:
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "event_participants" ADD COLUMN "competitor_number" INTEGER;
|
||||||
@@ -182,10 +182,11 @@ model Rating {
|
|||||||
|
|
||||||
// Event participants (tracks which users joined which events)
|
// Event participants (tracks which users joined which events)
|
||||||
model EventParticipant {
|
model EventParticipant {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
userId Int @map("user_id")
|
userId Int @map("user_id")
|
||||||
eventId Int @map("event_id")
|
eventId Int @map("event_id")
|
||||||
joinedAt DateTime @default(now()) @map("joined_at")
|
competitorNumber Int? @map("competitor_number") // Bib number - one per user per event
|
||||||
|
joinedAt DateTime @default(now()) @map("joined_at")
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
@@ -711,6 +711,166 @@ describe('Events API Tests', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('PUT /api/events/:slug/competitor-number', () => {
|
||||||
|
it('should set competitor number for participant', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.put(`/api/events/${testEvent.slug}/competitor-number`)
|
||||||
|
.set('Authorization', `Bearer ${testToken1}`)
|
||||||
|
.send({ competitorNumber: 123 })
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('success', true);
|
||||||
|
expect(response.body).toHaveProperty('competitorNumber', 123);
|
||||||
|
|
||||||
|
// Verify in database
|
||||||
|
const participant = await prisma.eventParticipant.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_eventId: {
|
||||||
|
userId: testUser1.id,
|
||||||
|
eventId: testEvent.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(participant.competitorNumber).toBe(123);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear competitor number when set to null', async () => {
|
||||||
|
// First set a number
|
||||||
|
await request(app)
|
||||||
|
.put(`/api/events/${testEvent.slug}/competitor-number`)
|
||||||
|
.set('Authorization', `Bearer ${testToken1}`)
|
||||||
|
.send({ competitorNumber: 456 })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
// Then clear it
|
||||||
|
const response = await request(app)
|
||||||
|
.put(`/api/events/${testEvent.slug}/competitor-number`)
|
||||||
|
.set('Authorization', `Bearer ${testToken1}`)
|
||||||
|
.send({ competitorNumber: null })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('success', true);
|
||||||
|
expect(response.body.competitorNumber).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid competitor number (too high)', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.put(`/api/events/${testEvent.slug}/competitor-number`)
|
||||||
|
.set('Authorization', `Bearer ${testToken1}`)
|
||||||
|
.send({ competitorNumber: 10000 })
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('success', false);
|
||||||
|
expect(response.body.error).toContain('between 1 and 9999');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid competitor number (zero)', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.put(`/api/events/${testEvent.slug}/competitor-number`)
|
||||||
|
.set('Authorization', `Bearer ${testToken1}`)
|
||||||
|
.send({ competitorNumber: 0 })
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('success', false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid competitor number (negative)', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.put(`/api/events/${testEvent.slug}/competitor-number`)
|
||||||
|
.set('Authorization', `Bearer ${testToken1}`)
|
||||||
|
.send({ competitorNumber: -5 })
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('success', false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 403 for non-participant', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.put(`/api/events/${testEvent2.slug}/competitor-number`)
|
||||||
|
.set('Authorization', `Bearer ${testToken1}`) // testUser1 is not participant of testEvent2
|
||||||
|
.send({ competitorNumber: 100 })
|
||||||
|
.expect(403);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('success', false);
|
||||||
|
expect(response.body.error).toContain('must be a participant');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent event', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/api/events/non-existent-slug/competitor-number')
|
||||||
|
.set('Authorization', `Bearer ${testToken1}`)
|
||||||
|
.send({ competitorNumber: 100 })
|
||||||
|
.expect(404);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('success', false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/events/:slug/heats/me - competitorNumber', () => {
|
||||||
|
it('should return competitorNumber in response', async () => {
|
||||||
|
// Set a competitor number first
|
||||||
|
await request(app)
|
||||||
|
.put(`/api/events/${testEvent.slug}/competitor-number`)
|
||||||
|
.set('Authorization', `Bearer ${testToken1}`)
|
||||||
|
.send({ competitorNumber: 789 })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get(`/api/events/${testEvent.slug}/heats/me`)
|
||||||
|
.set('Authorization', `Bearer ${testToken1}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('success', true);
|
||||||
|
expect(response.body).toHaveProperty('competitorNumber', 789);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null competitorNumber when not set', async () => {
|
||||||
|
// Clear competitor number
|
||||||
|
await request(app)
|
||||||
|
.put(`/api/events/${testEvent.slug}/competitor-number`)
|
||||||
|
.set('Authorization', `Bearer ${testToken1}`)
|
||||||
|
.send({ competitorNumber: null })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get(`/api/events/${testEvent.slug}/heats/me`)
|
||||||
|
.set('Authorization', `Bearer ${testToken1}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('competitorNumber', null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/events/:slug/heats/all - competitorNumber', () => {
|
||||||
|
it('should return competitorNumber for each user', async () => {
|
||||||
|
// Set competitor number for testUser1
|
||||||
|
await request(app)
|
||||||
|
.put(`/api/events/${testEvent.slug}/competitor-number`)
|
||||||
|
.set('Authorization', `Bearer ${testToken1}`)
|
||||||
|
.send({ competitorNumber: 111 })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get(`/api/events/${testEvent.slug}/heats/all`)
|
||||||
|
.set('Authorization', `Bearer ${testToken1}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body).toHaveProperty('success', true);
|
||||||
|
|
||||||
|
// Find testUser1 in the response
|
||||||
|
const user1Data = response.body.data.find(u => u.userId === testUser1.id);
|
||||||
|
if (user1Data) {
|
||||||
|
expect(user1Data).toHaveProperty('competitorNumber', 111);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other users should have competitorNumber (either number or null)
|
||||||
|
response.body.data.forEach(userData => {
|
||||||
|
expect(userData).toHaveProperty('competitorNumber');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('DELETE /api/events/:slug/heats/:id', () => {
|
describe('DELETE /api/events/:slug/heats/:id', () => {
|
||||||
let heatToDelete;
|
let heatToDelete;
|
||||||
|
|
||||||
|
|||||||
@@ -643,6 +643,14 @@ router.get('/:slug/heats/me', authenticate, async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get user's participation (for competitor number)
|
||||||
|
const participation = await prisma.eventParticipant.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_eventId: { userId, eventId: event.id },
|
||||||
|
},
|
||||||
|
select: { competitorNumber: true },
|
||||||
|
});
|
||||||
|
|
||||||
// Get user's heats
|
// Get user's heats
|
||||||
const heats = await prisma.eventUserHeat.findMany({
|
const heats = await prisma.eventUserHeat.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -675,6 +683,7 @@ router.get('/:slug/heats/me', authenticate, async (req, res, next) => {
|
|||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
count: heats.length,
|
count: heats.length,
|
||||||
|
competitorNumber: participation?.competitorNumber || null,
|
||||||
data: heats,
|
data: heats,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -700,6 +709,18 @@ router.get('/:slug/heats/all', authenticate, async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get all participants with competitor numbers
|
||||||
|
const participants = await prisma.eventParticipant.findMany({
|
||||||
|
where: { eventId: event.id },
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
competitorNumber: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const competitorNumbers = new Map(
|
||||||
|
participants.map((p) => [p.userId, p.competitorNumber])
|
||||||
|
);
|
||||||
|
|
||||||
// Get all heats with user info
|
// Get all heats with user info
|
||||||
const heats = await prisma.eventUserHeat.findMany({
|
const heats = await prisma.eventUserHeat.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -741,6 +762,7 @@ router.get('/:slug/heats/all', authenticate, async (req, res, next) => {
|
|||||||
userId: heat.user.id,
|
userId: heat.user.id,
|
||||||
username: heat.user.username,
|
username: heat.user.username,
|
||||||
avatar: heat.user.avatar,
|
avatar: heat.user.avatar,
|
||||||
|
competitorNumber: competitorNumbers.get(userId) || null,
|
||||||
heats: [],
|
heats: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -768,6 +790,70 @@ router.get('/:slug/heats/all', authenticate, async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PUT /api/events/:slug/competitor-number - Set competitor number (bib number)
|
||||||
|
router.put('/:slug/competitor-number', authenticate, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { slug } = req.params;
|
||||||
|
const { competitorNumber } = req.body;
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
// Validate competitor number (positive integer or null)
|
||||||
|
if (competitorNumber !== null && competitorNumber !== undefined) {
|
||||||
|
const num = parseInt(competitorNumber, 10);
|
||||||
|
if (isNaN(num) || num < 1 || num > 9999) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Competitor number must be between 1 and 9999',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find event
|
||||||
|
const event = await prisma.event.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Event not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is participant
|
||||||
|
const participant = await prisma.eventParticipant.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_eventId: { userId, eventId: event.id },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!participant) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: 'You must be a participant to set competitor number',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update competitor number
|
||||||
|
const updated = await prisma.eventParticipant.update({
|
||||||
|
where: {
|
||||||
|
userId_eventId: { userId, eventId: event.id },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
competitorNumber: competitorNumber ? parseInt(competitorNumber, 10) : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
competitorNumber: updated.competitorNumber,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// DELETE /api/events/:slug/heats/:id - Delete specific heat
|
// DELETE /api/events/:slug/heats/:id - Delete specific heat
|
||||||
router.delete('/:slug/heats/:id', authenticate, async (req, res, next) => {
|
router.delete('/:slug/heats/:id', authenticate, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import UserListItem from '../users/UserListItem';
|
|||||||
* @param {Array} users - Array of user objects to display (already filtered)
|
* @param {Array} users - Array of user objects to display (already filtered)
|
||||||
* @param {Array} activeUsers - Array of currently online users (for count)
|
* @param {Array} activeUsers - Array of currently online users (for count)
|
||||||
* @param {Map} userHeats - Map of userId -> heats array
|
* @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 {Array} myHeats - Current user's heats (for filter checkbox visibility)
|
||||||
* @param {boolean} hideMyHeats - Filter state
|
* @param {boolean} hideMyHeats - Filter state
|
||||||
* @param {function} onHideMyHeatsChange - Filter change handler
|
* @param {function} onHideMyHeatsChange - Filter change handler
|
||||||
@@ -19,6 +20,7 @@ import UserListItem from '../users/UserListItem';
|
|||||||
* users={filteredUsers}
|
* users={filteredUsers}
|
||||||
* activeUsers={activeUsers}
|
* activeUsers={activeUsers}
|
||||||
* userHeats={userHeats}
|
* userHeats={userHeats}
|
||||||
|
* userCompetitorNumbers={userCompetitorNumbers}
|
||||||
* myHeats={myHeats}
|
* myHeats={myHeats}
|
||||||
* hideMyHeats={hideMyHeats}
|
* hideMyHeats={hideMyHeats}
|
||||||
* onHideMyHeatsChange={setHideMyHeats}
|
* onHideMyHeatsChange={setHideMyHeats}
|
||||||
@@ -29,6 +31,7 @@ const ParticipantsSidebar = ({
|
|||||||
users = [],
|
users = [],
|
||||||
activeUsers = [],
|
activeUsers = [],
|
||||||
userHeats = new Map(),
|
userHeats = new Map(),
|
||||||
|
userCompetitorNumbers = new Map(),
|
||||||
myHeats = [],
|
myHeats = [],
|
||||||
hideMyHeats = false,
|
hideMyHeats = false,
|
||||||
onHideMyHeatsChange,
|
onHideMyHeatsChange,
|
||||||
@@ -73,6 +76,7 @@ const ParticipantsSidebar = ({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{users.map((displayUser) => {
|
{users.map((displayUser) => {
|
||||||
const thisUserHeats = userHeats.get(displayUser.userId) || [];
|
const thisUserHeats = userHeats.get(displayUser.userId) || [];
|
||||||
|
const competitorNumber = userCompetitorNumbers.get(displayUser.userId);
|
||||||
const hasHeats = thisUserHeats.length > 0;
|
const hasHeats = thisUserHeats.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -80,6 +84,7 @@ const ParticipantsSidebar = ({
|
|||||||
key={displayUser.userId}
|
key={displayUser.userId}
|
||||||
user={displayUser}
|
user={displayUser}
|
||||||
heats={thisUserHeats}
|
heats={thisUserHeats}
|
||||||
|
competitorNumber={competitorNumber}
|
||||||
showHeats={true}
|
showHeats={true}
|
||||||
actionButton={
|
actionButton={
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import { useState, useEffect } from 'react';
|
|||||||
import { X, Plus, Trash2, Loader2 } from 'lucide-react';
|
import { X, Plus, Trash2, Loader2 } from 'lucide-react';
|
||||||
import { divisionsAPI, competitionTypesAPI, heatsAPI } from '../../services/api';
|
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 [divisions, setDivisions] = useState([]);
|
||||||
const [competitionTypes, setCompetitionTypes] = useState([]);
|
const [competitionTypes, setCompetitionTypes] = useState([]);
|
||||||
const [heats, setHeats] = useState([{ divisionId: '', competitionTypeId: '', heatNumber: '', role: '' }]);
|
const [heats, setHeats] = useState([{ divisionId: '', competitionTypeId: '', heatNumber: '', role: '' }]);
|
||||||
|
const [competitorNumber, setCompetitorNumber] = useState(existingCompetitorNumber || '');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -80,6 +81,11 @@ export default function HeatsBanner({ slug, onSave, onDismiss, existingHeats = n
|
|||||||
|
|
||||||
await heatsAPI.saveHeats(slug, heatsToSave);
|
await heatsAPI.saveHeats(slug, heatsToSave);
|
||||||
|
|
||||||
|
// Save competitor number if provided
|
||||||
|
if (competitorNumber) {
|
||||||
|
await heatsAPI.setCompetitorNumber(slug, parseInt(competitorNumber));
|
||||||
|
}
|
||||||
|
|
||||||
if (onSave) {
|
if (onSave) {
|
||||||
onSave();
|
onSave();
|
||||||
}
|
}
|
||||||
@@ -132,6 +138,28 @@ export default function HeatsBanner({ slug, onSave, onDismiss, existingHeats = n
|
|||||||
</div>
|
</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">
|
<div className="space-y-3">
|
||||||
{heats.map((heat, index) => (
|
{heats.map((heat, index) => (
|
||||||
<div key={index} className="bg-white rounded-lg p-4 shadow-sm border border-amber-200">
|
<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 {object} user - User object with { userId, username, avatar, isOnline, firstName, lastName }
|
||||||
* @param {Array} heats - Array of heat objects for this user
|
* @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 {boolean} showHeats - Whether to display heat badges (default: true)
|
||||||
* @param {React.ReactNode} actionButton - Optional action button/element to display on right
|
* @param {React.ReactNode} actionButton - Optional action button/element to display on right
|
||||||
* @param {function} onClick - Optional click handler for the entire item
|
* @param {function} onClick - Optional click handler for the entire item
|
||||||
@@ -18,6 +19,7 @@ import HeatBadges from '../heats/HeatBadges';
|
|||||||
* <UserListItem
|
* <UserListItem
|
||||||
* user={displayUser}
|
* user={displayUser}
|
||||||
* heats={userHeats.get(userId)}
|
* heats={userHeats.get(userId)}
|
||||||
|
* competitorNumber={123}
|
||||||
* actionButton={
|
* actionButton={
|
||||||
* <button onClick={() => handleMatch(userId)}>
|
* <button onClick={() => handleMatch(userId)}>
|
||||||
* <UserPlus />
|
* <UserPlus />
|
||||||
@@ -28,6 +30,7 @@ import HeatBadges from '../heats/HeatBadges';
|
|||||||
const UserListItem = ({
|
const UserListItem = ({
|
||||||
user,
|
user,
|
||||||
heats = [],
|
heats = [],
|
||||||
|
competitorNumber,
|
||||||
showHeats = true,
|
showHeats = true,
|
||||||
actionButton,
|
actionButton,
|
||||||
onClick,
|
onClick,
|
||||||
@@ -66,7 +69,14 @@ const UserListItem = ({
|
|||||||
title={user.username}
|
title={user.username}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<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) */}
|
{/* Full name (optional) */}
|
||||||
{(user.firstName || user.lastName) && (
|
{(user.firstName || user.lastName) && (
|
||||||
<p className="text-xs text-gray-500 truncate">
|
<p className="text-xs text-gray-500 truncate">
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ const EventChatPage = () => {
|
|||||||
|
|
||||||
// Heats state
|
// Heats state
|
||||||
const [myHeats, setMyHeats] = useState([]);
|
const [myHeats, setMyHeats] = useState([]);
|
||||||
|
const [myCompetitorNumber, setMyCompetitorNumber] = useState(null);
|
||||||
const [userHeats, setUserHeats] = useState(new Map());
|
const [userHeats, setUserHeats] = useState(new Map());
|
||||||
|
const [userCompetitorNumbers, setUserCompetitorNumbers] = useState(new Map());
|
||||||
const [showHeatsBanner, setShowHeatsBanner] = useState(false);
|
const [showHeatsBanner, setShowHeatsBanner] = useState(false);
|
||||||
const [hideMyHeats, setHideMyHeats] = useState(false);
|
const [hideMyHeats, setHideMyHeats] = useState(false);
|
||||||
const [showHeatsModal, setShowHeatsModal] = useState(false);
|
const [showHeatsModal, setShowHeatsModal] = useState(false);
|
||||||
@@ -87,18 +89,25 @@ const EventChatPage = () => {
|
|||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
// Load my heats
|
// Load my heats and competitor number
|
||||||
const myHeatsData = await heatsAPI.getMyHeats(slug);
|
const myHeatsResponse = await heatsAPI.getMyHeats(slug);
|
||||||
|
const myHeatsData = myHeatsResponse.data || [];
|
||||||
setMyHeats(myHeatsData);
|
setMyHeats(myHeatsData);
|
||||||
|
setMyCompetitorNumber(myHeatsResponse.competitorNumber);
|
||||||
setShowHeatsBanner(myHeatsData.length === 0);
|
setShowHeatsBanner(myHeatsData.length === 0);
|
||||||
|
|
||||||
// Load all users' heats
|
// Load all users' heats and competitor numbers
|
||||||
const allHeatsData = await heatsAPI.getAllHeats(slug);
|
const allHeatsData = await heatsAPI.getAllHeats(slug);
|
||||||
const heatsMap = new Map();
|
const heatsMap = new Map();
|
||||||
|
const competitorNumbersMap = new Map();
|
||||||
allHeatsData.forEach((userHeat) => {
|
allHeatsData.forEach((userHeat) => {
|
||||||
heatsMap.set(userHeat.userId, userHeat.heats);
|
heatsMap.set(userHeat.userId, userHeat.heats);
|
||||||
|
if (userHeat.competitorNumber) {
|
||||||
|
competitorNumbersMap.set(userHeat.userId, userHeat.competitorNumber);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
setUserHeats(heatsMap);
|
setUserHeats(heatsMap);
|
||||||
|
setUserCompetitorNumbers(competitorNumbersMap);
|
||||||
|
|
||||||
// Load all checked-in users (participants)
|
// Load all checked-in users (participants)
|
||||||
const eventDetails = await eventsAPI.getDetails(slug);
|
const eventDetails = await eventsAPI.getDetails(slug);
|
||||||
@@ -384,6 +393,7 @@ const EventChatPage = () => {
|
|||||||
slug={slug}
|
slug={slug}
|
||||||
onSave={handleHeatsSave}
|
onSave={handleHeatsSave}
|
||||||
onDismiss={() => setShowHeatsBanner(false)}
|
onDismiss={() => setShowHeatsBanner(false)}
|
||||||
|
existingCompetitorNumber={myCompetitorNumber}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -392,6 +402,7 @@ const EventChatPage = () => {
|
|||||||
users={getAllDisplayUsers().filter(u => !shouldHideUser(u.userId))}
|
users={getAllDisplayUsers().filter(u => !shouldHideUser(u.userId))}
|
||||||
activeUsers={activeUsers}
|
activeUsers={activeUsers}
|
||||||
userHeats={userHeats}
|
userHeats={userHeats}
|
||||||
|
userCompetitorNumbers={userCompetitorNumbers}
|
||||||
myHeats={myHeats}
|
myHeats={myHeats}
|
||||||
hideMyHeats={hideMyHeats}
|
hideMyHeats={hideMyHeats}
|
||||||
onHideMyHeatsChange={setHideMyHeats}
|
onHideMyHeatsChange={setHideMyHeats}
|
||||||
@@ -457,6 +468,7 @@ const EventChatPage = () => {
|
|||||||
onSave={handleHeatsSave}
|
onSave={handleHeatsSave}
|
||||||
onDismiss={() => setShowHeatsModal(false)}
|
onDismiss={() => setShowHeatsModal(false)}
|
||||||
existingHeats={myHeats}
|
existingHeats={myHeats}
|
||||||
|
existingCompetitorNumber={myCompetitorNumber}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -292,7 +292,8 @@ export const heatsAPI = {
|
|||||||
|
|
||||||
async getMyHeats(slug) {
|
async getMyHeats(slug) {
|
||||||
const data = await fetchAPI(`/events/${slug}/heats/me`);
|
const data = await fetchAPI(`/events/${slug}/heats/me`);
|
||||||
return data.data;
|
// Returns { data: heats[], competitorNumber: number|null }
|
||||||
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
async getAllHeats(slug) {
|
async getAllHeats(slug) {
|
||||||
@@ -306,6 +307,14 @@ export const heatsAPI = {
|
|||||||
});
|
});
|
||||||
return data;
|
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)
|
// Matches API (Phase 2)
|
||||||
|
|||||||
Reference in New Issue
Block a user