Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 42 additions & 12 deletions src/components/players/PlayerCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { CheckCircle2, MapPin } from "lucide-react";
import { MapPin, ShieldCheck } from "lucide-react";
import type { Player } from "../../data/mockPlayers";

import "../coaches/coaches.css";
Expand All @@ -12,17 +12,28 @@ type PlayerCardProps = {
onViewProfile?: (player: Player) => void;
};

const formatCourtLocation = (court: string) => {
const segments = court
.split(",")
.map((segment) => segment.trim())
.filter(Boolean);
const formatCourtLocation = (court: string) => court.split(",")[0]?.trim() || court.trim();

if (segments.length >= 2) {
return `${segments[0]}, ${segments[1]}`;
const getLastActiveMeta = (lastActive: string) => {
if (!lastActive) {
return null;
}

return court.trim();
const label = lastActive.trim();
if (!label) {
return null;
}

const normalized = label.toLowerCase();
if (normalized.includes("today")) {
return { label, tone: "today" as const };
}

if (/active\s+[23]d\s+ago/.test(normalized)) {
return { label, tone: "recent" as const };
}

return { label, tone: "older" as const };
};

const PlayerCard = ({ player, canConnect, onConnect, onViewProfile }: PlayerCardProps) => {
Expand Down Expand Up @@ -50,6 +61,8 @@ const PlayerCard = ({ player, canConnect, onConnect, onViewProfile }: PlayerCard
return "";
}, [player.bio]);

const lastActiveMeta = useMemo(() => getLastActiveMeta(player.lastActive), [player.lastActive]);

const localCourts = useMemo(() => {
const fallback = [player.favoriteCourt].filter(
(value): value is string => typeof value === "string" && value.trim().length > 0,
Expand All @@ -61,7 +74,10 @@ const PlayerCard = ({ player, canConnect, onConnect, onViewProfile }: PlayerCard
}, [player.favoriteCourt, player.localCourts]);

return (
<article className="fc-card fp-card" aria-label={`View ${player.name}'s match profile`}>
<article
className={`fc-card fp-card${player.verified ? " fp-card--verified" : ""}`}
aria-label={`View ${player.name}'s match profile`}
>
<header className="fp-card__header">
<div className="fp-card__identity-block">
<div className="fp-card__identity-media">
Expand All @@ -82,6 +98,20 @@ const PlayerCard = ({ player, canConnect, onConnect, onViewProfile }: PlayerCard
</div>
<div className="fp-card__identity">
<h3 className="fp-card__name">{player.name}</h3>
<div className="fp-card__meta-row">
{lastActiveMeta ? (
<span className={`fp-card__active fp-card__active--${lastActiveMeta.tone}`}>
<span className="fp-card__active-dot" aria-hidden="true" />
{lastActiveMeta.label}
</span>
) : null}
{player.distanceMiles > 0 ? (
<span className="fp-card__distance">
<MapPin size={13} aria-hidden="true" />
{player.distanceMiles.toFixed(1)} mi
</span>
) : null}
</div>
<div
className="fp-card__badges"
aria-label={`NTRP ${player.level}${player.verified ? ", verified rating" : ""}`}
Expand All @@ -95,8 +125,8 @@ const PlayerCard = ({ player, canConnect, onConnect, onViewProfile }: PlayerCard
aria-label="Verified rating"
title="Verified players have confirmed their identity and NTRP level through community reviews."
>
<CheckCircle2 size={14} strokeWidth={2} aria-hidden="true" />
Verified rating
<ShieldCheck size={14} strokeWidth={2} aria-hidden="true" />
Verified
</span>
) : null}
</div>
Expand Down
79 changes: 63 additions & 16 deletions src/components/players/PlayersFilterBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,12 @@ type PlayersFilterBarProps = {
radiusOptions: string[];
selectedRadius: string;
onRadiusChange: (value: string) => void;
levelOptions: string[];
selectedLevel: string;
onLevelChange: (value: string) => void;
minLevel: number;
maxLevel: number;
onNtrpRangeChange: (nextRange: [number, number]) => void;
sortOptions: Array<{ label: string; value: string }>;
selectedSort: string;
onSortChange: (value: string) => void;
genderOptions: string[];
selectedGender: string;
onGenderChange: (value: string) => void;
Expand All @@ -34,9 +37,12 @@ const PlayersFilterBar = ({
radiusOptions,
selectedRadius,
onRadiusChange,
levelOptions,
selectedLevel,
onLevelChange,
minLevel,
maxLevel,
onNtrpRangeChange,
sortOptions,
selectedSort,
onSortChange,
genderOptions,
selectedGender,
onGenderChange,
Expand All @@ -48,6 +54,18 @@ const PlayersFilterBar = ({
onSearch();
};

const handleMinLevelChange = (value: string) => {
const nextMin = Number.parseFloat(value);
if (Number.isNaN(nextMin)) return;
onNtrpRangeChange([Math.min(nextMin, maxLevel), maxLevel]);
};

const handleMaxLevelChange = (value: string) => {
const nextMax = Number.parseFloat(value);
if (Number.isNaN(nextMax)) return;
onNtrpRangeChange([minLevel, Math.max(nextMax, minLevel)]);
};

return (
<div className="fc-filter">
<div className="fc-filter__distance-row">
Expand Down Expand Up @@ -88,15 +106,44 @@ const PlayersFilterBar = ({
onChange={(event) => onSearchTermChange(event.target.value)}
/>
</div>
<div className="fp-range-filter" aria-label="Filter by NTRP range">
<span className="fp-range-filter__label">NTRP</span>
<div className="fp-range-filter__slider-wrap">
<input
type="range"
min={2}
max={5}
step={0.5}
value={minLevel}
aria-label="Minimum NTRP level"
className="fp-range-filter__slider"
onChange={(event) => handleMinLevelChange(event.target.value)}
/>
<input
type="range"
min={2}
max={5}
step={0.5}
value={maxLevel}
aria-label="Maximum NTRP level"
className="fp-range-filter__slider"
onChange={(event) => handleMaxLevelChange(event.target.value)}
/>
</div>
<div className="fp-range-filter__values">
<span>{minLevel.toFixed(1)}</span>
<span>{maxLevel.toFixed(1)}</span>
</div>
</div>
<div className="fc-filter__selects">
<div className="fc-select">
<select
aria-label="Filter by level"
value={selectedLevel}
aria-label="Filter by gender"
value={selectedGender}
className="fc-select__field"
onChange={(event) => onLevelChange(event.target.value)}
onChange={(event) => onGenderChange(event.target.value)}
>
{levelOptions.map((option) => (
{genderOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
Expand All @@ -107,14 +154,14 @@ const PlayersFilterBar = ({

<div className="fc-select">
<select
aria-label="Filter by gender"
value={selectedGender}
aria-label="Sort players"
value={selectedSort}
className="fc-select__field"
onChange={(event) => onGenderChange(event.target.value)}
onChange={(event) => onSortChange(event.target.value)}
>
{genderOptions.map((option) => (
<option key={option} value={option}>
{option}
{sortOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
Expand Down
113 changes: 110 additions & 3 deletions src/components/players/players.css
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,68 @@
color: var(--fc-color-text-muted);
}


.fp-filter-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}

.fp-results-legend {
margin-left: auto;
font-size: 13px;
font-weight: 600;
color: #047857;
}

.fp-range-filter {
min-width: 240px;
flex: 1 1 240px;
display: flex;
flex-direction: column;
gap: 8px;
}

.fp-range-filter__label {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
color: #6b7280;
}

.fp-range-filter__slider-wrap {
position: relative;
height: 24px;
}

.fp-range-filter__slider {
position: absolute;
left: 0;
top: 4px;
width: 100%;
margin: 0;
background: transparent;
pointer-events: none;
accent-color: #6b46c1;
}

.fp-range-filter__slider::-webkit-slider-thumb {
pointer-events: auto;
}

.fp-range-filter__slider::-moz-range-thumb {
pointer-events: auto;
}

.fp-range-filter__values {
display: flex;
justify-content: space-between;
font-size: 12px;
font-weight: 700;
color: #6b46c1;
}

.players-results-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
Expand All @@ -156,6 +218,11 @@
box-shadow: 0 28px 52px -26px rgba(79, 70, 229, 0.32);
}

.fp-card--verified {
border: 2px solid #a7f3d0;
box-shadow: 0 22px 42px -28px rgba(5, 150, 105, 0.35);
}

.fp-card__header {
display: flex;
flex-direction: column;
Expand Down Expand Up @@ -216,6 +283,44 @@
color: #0f172a;
}

.fp-card__meta-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
font-size: 0.88rem;
font-weight: 600;
}

.fp-card__active {
display: inline-flex;
align-items: center;
gap: 6px;
color: #475569;
}

.fp-card__active-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #9ca3af;
}

.fp-card__active--today .fp-card__active-dot {
background: #22c55e;
}

.fp-card__active--recent .fp-card__active-dot {
background: #f59e0b;
}

.fp-card__distance {
display: inline-flex;
align-items: center;
gap: 4px;
color: #64748b;
}

.fp-card__location {
margin: 0;
font-size: 0.95rem;
Expand Down Expand Up @@ -254,9 +359,11 @@
}

.fp-card__badge--verified {
background: rgba(5, 150, 105, 0.18);
border: 1px solid rgba(5, 150, 105, 0.42);
color: #065f46;
background: #dcfce7;
border: 1px solid #86efac;
color: #166534;
border-radius: 999px;
font-weight: 700;
}

.fp-card__badge--verified svg {
Expand Down
Loading