From 12a9ef66c9b0f2c41942ec50f730eb3642431548 Mon Sep 17 00:00:00 2001 From: Studio-18 Date: Wed, 4 Feb 2026 07:27:16 -0800 Subject: [PATCH] Add alternate options for private matches --- src/TennisMatchApp.jsx | 151 ++++++++++++++++++- src/components/MatchCreatorFlow.jsx | 218 +++++++++++++++++++++++++++- 2 files changed, 360 insertions(+), 9 deletions(-) diff --git a/src/TennisMatchApp.jsx b/src/TennisMatchApp.jsx index 285344ee..fb6da5ae 100644 --- a/src/TennisMatchApp.jsx +++ b/src/TennisMatchApp.jsx @@ -745,6 +745,7 @@ const TennisMatchApp = () => { skillLevel: null, format: "Doubles", dateTime: "", + availabilityOptions: [], location: "", latitude: null, longitude: null, @@ -2724,6 +2725,24 @@ const TennisMatchApp = () => { }); }; + const formatAlternateTimes = (options) => { + if (!Array.isArray(options)) return []; + const formatted = []; + options.forEach((option) => { + if (!option) return; + const parsed = new Date(option); + if (Number.isNaN(parsed.getTime())) return; + formatted.push(formatDateTime(parsed)); + }); + return formatted; + }; + + const buildAlternateTimesNote = (options) => { + const formatted = formatAlternateTimes(options); + if (formatted.length === 0) return ""; + return `Alternate options:\n- ${formatted.join("\n- ")}`; + }; + const formatHoursUntilStart = useCallback((hours) => { if (hours === null || hours === undefined) return null; if (!Number.isFinite(hours)) return null; @@ -4811,6 +4830,8 @@ const TennisMatchApp = () => { const CreateMatchScreen = () => { const [recentLocations, setRecentLocations] = useState(() => loadStoredLocations()); + const [alternateDateTime, setAlternateDateTime] = useState(""); + const [alternateError, setAlternateError] = useState(""); useEffect(() => { const syncRecentLocations = () => { @@ -4896,6 +4917,15 @@ const TennisMatchApp = () => { } } + const alternateTimesNote = + matchData.type === "closed" + ? buildAlternateTimesNote(matchData.availabilityOptions) + : ""; + const combinedNotes = [matchData.notes, alternateTimesNote] + .map((entry) => entry?.trim()) + .filter(Boolean) + .join("\n\n"); + const privacy = matchData.type === "closed" ? "private" : "open"; const skillLevelValue = matchData.type === "closed" @@ -4917,7 +4947,7 @@ const TennisMatchApp = () => { skillLevel: skillLevelValue, match_format: matchData.format || undefined, format: matchData.format || undefined, - notes: matchData.notes || undefined, + notes: combinedNotes || undefined, }; return Object.fromEntries( @@ -4996,6 +5026,20 @@ const TennisMatchApp = () => {

{formatDateTime(matchData.dateTime)}

+ {matchData.availabilityOptions.length > 0 && ( +
+

+ Alternate Options +

+ +
+ )} @@ -5168,6 +5212,8 @@ const TennisMatchApp = () => { setMatchData((prev) => ({ ...prev, type: type.id, + availabilityOptions: + type.id === "closed" ? prev.availabilityOptions : [], skillLevel: type.id === "closed" ? DEFAULT_SKILL_LEVEL : null, })) @@ -5205,6 +5251,105 @@ const TennisMatchApp = () => { } className="w-full px-4 py-3.5 border-2 border-gray-200 rounded-xl focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors font-bold text-gray-800" /> + {matchData.type === "closed" && ( +
+
+ + + Optional + +
+
+ { + setAlternateDateTime(e.target.value); + if (alternateError) setAlternateError(""); + }} + className="flex-1 px-4 py-3 border-2 border-gray-200 rounded-xl focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors font-semibold text-gray-800" + /> + +
+ {alternateError && ( +

+ {alternateError} +

+ )} + {matchData.availabilityOptions.length > 0 && ( +
+ {matchData.availabilityOptions.map((option, index) => { + const formatted = formatAlternateTimes([option])[0]; + if (!formatted) return null; + return ( +
+ {formatted} + +
+ ); + })} +
+ )} +
+ )}
@@ -5700,6 +5845,10 @@ const TennisMatchApp = () => { parts.push("You're invited to a tennis match!"); if (host) parts.push(`Host: ${host}`); if (matchData.dateTime) parts.push(`When: ${formatDateTime(matchData.dateTime)}`); + const alternateTimes = formatAlternateTimes(matchData.availabilityOptions); + if (alternateTimes.length > 0) { + parts.push(`Other options: ${alternateTimes.join("; ")}`); + } if (matchData.location) parts.push(`Where: ${matchData.location}`); if (matchData.format) parts.push(`Format: ${matchData.format}`); if (matchData.skillLevel && matchData.skillLevel !== "Any Level") { diff --git a/src/components/MatchCreatorFlow.jsx b/src/components/MatchCreatorFlow.jsx index 0578c863..68df5e8d 100644 --- a/src/components/MatchCreatorFlow.jsx +++ b/src/components/MatchCreatorFlow.jsx @@ -86,6 +86,7 @@ const initialMatchData = () => { skillLevel: "4.0", format: "Doubles", notes: "", + alternateTimes: [], invitedPlayers: [], manualInvitees: [], listingVisibility: "listed", @@ -151,6 +152,22 @@ const formatDateDisplay = (dateStr) => { }); }; +const formatAlternateSlotDisplay = (slot) => { + if (!slot?.date || !slot?.startTime) return ""; + return `${formatDateDisplay(slot.date)} at ${formatTimeDisplay(slot.startTime)}`; +}; + +const formatAlternateSlots = (slots) => + (Array.isArray(slots) ? slots : []) + .map((slot) => formatAlternateSlotDisplay(slot)) + .filter(Boolean); + +const buildAlternateTimesNote = (slots) => { + const formatted = formatAlternateSlots(slots); + if (formatted.length === 0) return ""; + return `Alternate options:\n- ${formatted.join("\n- ")}`; +}; + const formatRelativeDate = (isoValue) => { if (!isoValue) return "Recently active"; const target = new Date(isoValue); @@ -265,6 +282,9 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser const [contactName, setContactName] = useState(""); const [contactPhone, setContactPhone] = useState(""); const [contactError, setContactError] = useState(""); + const [alternateDate, setAlternateDate] = useState(""); + const [alternateTime, setAlternateTime] = useState(""); + const [alternateError, setAlternateError] = useState(""); const [isFormatManuallySelected, setIsFormatManuallySelected] = useState(false); const [recentLocations, setRecentLocations] = useState(() => loadStoredLocations()); const [recentPlayers, setRecentPlayers] = useState(() => loadStoredRecentPlayers()); @@ -337,6 +357,9 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser setContactName(""); setContactPhone(""); setContactError(""); + setAlternateDate(""); + setAlternateTime(""); + setAlternateError(""); setIsFormatManuallySelected(false); }, []); @@ -416,6 +439,50 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser [setMatchData], ); + const handleAlternateTimeChange = useCallback((value) => { + setAlternateTime(value ? clampTimeToRange(value) : ""); + }, []); + + const handleAddAlternateSlot = useCallback(() => { + setAlternateError(""); + if (!alternateDate || !alternateTime) { + setAlternateError("Add a date and time for this option."); + return; + } + const normalizedTime = clampTimeToRange(alternateTime); + const optionIso = combineDateAndTimeToIso(alternateDate, normalizedTime); + if (!optionIso) { + setAlternateError("That option time isn't valid."); + return; + } + const mainIso = combineDateAndTimeToIso(matchData.date, matchData.startTime); + if (mainIso && optionIso === mainIso) { + setAlternateError("That matches your main date/time."); + return; + } + const existingIso = (matchData.alternateTimes || []).some((slot) => { + const slotIso = combineDateAndTimeToIso(slot.date, slot.startTime); + return slotIso && slotIso === optionIso; + }); + if (existingIso) { + setAlternateError("That option is already added."); + return; + } + if ((matchData.alternateTimes || []).length >= 5) { + setAlternateError("You can add up to 5 alternate options."); + return; + } + setMatchData((prev) => ({ + ...prev, + alternateTimes: [ + ...(prev.alternateTimes || []), + { date: alternateDate, startTime: normalizedTime }, + ], + })); + setAlternateDate(""); + setAlternateTime(""); + }, [alternateDate, alternateTime, matchData, setMatchData]); + const recordRecentLocation = useCallback( (locationLabel, latitude, longitude) => { const next = persistRecentLocation(locationLabel, latitude, longitude); @@ -520,6 +587,15 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser return; } + const alternateTimesNote = + matchData.type === "private" + ? buildAlternateTimesNote(matchData.alternateTimes) + : ""; + const combinedNotes = [matchData.notes, alternateTimesNote] + .map((entry) => entry?.trim()) + .filter(Boolean) + .join("\n\n"); + const payload = { status: "upcoming", match_type: matchData.type === "private" ? "private" : "open", @@ -529,7 +605,7 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser longitude: matchData.longitude ?? undefined, player_limit: matchData.totalPlayers, match_format: matchData.format, - notes: matchData.notes || undefined, + notes: combinedNotes || undefined, }; if (matchData.type === "open" && matchData.skillLevel) { @@ -664,11 +740,21 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser }; const buildShareMessage = useCallback( - () => - `Join my ${matchData.format} match on ${formatDateDisplay(matchData.date)} at ${formatTimeDisplay( - matchData.startTime, - )} at ${matchData.location}.`, - [matchData.format, matchData.date, matchData.startTime, matchData.location] + () => { + const primary = `Join my ${matchData.format} match on ${formatDateDisplay( + matchData.date, + )} at ${formatTimeDisplay(matchData.startTime)} at ${matchData.location}.`; + const alternateOptions = formatAlternateSlots(matchData.alternateTimes); + if (alternateOptions.length === 0) return primary; + return `${primary} Other options: ${alternateOptions.join("; ")}.`; + }, + [ + matchData.format, + matchData.date, + matchData.startTime, + matchData.location, + matchData.alternateTimes, + ] ); const handleShare = (method) => { @@ -719,7 +805,12 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser if (matchData.type === "open" && matchData.skillLevel) { description += ` Skill level: NTRP ${matchData.skillLevel}.`; } - if (matchData.notes) description += ` ${matchData.notes}`; + const alternateTimesNote = buildAlternateTimesNote(matchData.alternateTimes); + const combinedNotes = [matchData.notes, alternateTimesNote] + .map((entry) => entry?.trim()) + .filter(Boolean) + .join("\n\n"); + if (combinedNotes) description += ` ${combinedNotes}`; const details = { title, @@ -827,7 +918,13 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser
+ {matchData.type === "private" && ( +
+
+
+

+ Alternate Options +

+

+ Add up to 5 backup dates/times for invitees. +

+
+ Optional +
+
+
+ + { + setAlternateDate(e.target.value); + if (alternateError) setAlternateError(""); + }} + className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm" + /> +
+
+ + { + handleAlternateTimeChange(e.target.value); + if (alternateError) setAlternateError(""); + }} + onBlur={(e) => handleAlternateTimeChange(e.target.value)} + className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm" + /> +
+
+ +
+
+ {alternateError && ( +

{alternateError}

+ )} + {matchData.alternateTimes?.length > 0 && ( +
+ {matchData.alternateTimes.map((slot, index) => { + const formatted = formatAlternateSlotDisplay(slot); + if (!formatted) return null; + return ( +
+ {formatted} + +
+ ); + })} +
+ )} +
+ )}

@@ -1579,6 +1766,21 @@ const MatchCreatorFlow = ({ onCancel, onReturnHome, onMatchCreated, currentUser {formatDateDisplay(matchData.date)}, {formatTimeDisplay(matchData.startTime)}

+ {matchData.alternateTimes?.length > 0 && ( +
+ +
+
+ Alternate Options +
+
    + {formatAlternateSlots(matchData.alternateTimes).map((option, index) => ( +
  • {option}
  • + ))} +
+
+
+ )}
{matchData.location}