/* ============================================================
Screens: MyBookings, Profile + Booking / Edit / Cancel modals
============================================================ */
/* -------------------- CLAIM SPOT MODAL -------------------- */
function ClaimModal({ slot, dayLabel, member, onConfirm, onClose }) {
const open = 4 - slot.players.length;
const [transport, setTransport] = useState("walk");
return (
{slot.time}
{open} of 4 spots open
{slot.players.length > 0 && (
Players in this group
{slot.players.map((p, i) => (
{p.name}
HI {p.hi} · GHIN {p.ghin}
))}
)}
You'll join as
{member.name}
HI {member.handicapIndex ?? "—"} · GHIN {member.ghin || "—"} · CH {member.handicapIndex == null ? "—" : window.YCC.courseHandicapForTee(member.handicapIndex, member.tee || "Blue")} ({member.tee || "Blue"})
Your USGA handicap travels with you into this group's weekend game.
Walking or riding?
{[["walk", "walk", "Walk", "Carry or push"], ["ride", "cart", "Ride", "Take a cart"]].map(([val, icon, label, sub]) => {
const on = transport === val;
return (
setTransport(val)} style={{
display: "flex", alignItems: "center", gap: 11, padding: "13px 14px", borderRadius: "var(--radius-sm)", textAlign: "left",
border: "1.5px solid " + (on ? "var(--brand)" : "var(--line-strong)"),
background: on ? "var(--brand-soft)" : "var(--surface)", transition: "all .14s",
}}>
{label}
{sub}
);
})}
Defaults to walking — you can change this anytime before play.
Cancel
onConfirm(transport)}> Confirm my spot
);
}
/* -------------------- MOVE SPOT MODAL -------------------- */
function MoveSpotModal({ booking, fromTime, dateLabel, openSlots, onPick, onClose }) {
const [sel, setSel] = useState(null);
const sorted = window.YCC.sortByTime(openSlots);
return (
{sorted.length === 0 &&
No other groups with an open spot that day.
}
{sorted.map(s => (
setSel(s.id)} style={{
padding: "11px 8px", borderRadius: 9, fontSize: 14, fontWeight: 700, fontFamily: "var(--font-display)",
border: "1px solid " + (sel === s.id ? "var(--brand)" : "var(--line-strong)"),
background: sel === s.id ? "var(--brand)" : "var(--surface)", color: sel === s.id ? "var(--on-brand)" : "var(--ink)",
transition: "all .12s",
}}>{s.time}{4 - s.players.length} open
))}
Keep current
onPick(sorted.find(s => s.id === sel))}>Move my spot
);
}
/* -------------------- MANAGER: MOVE ANY PLAYER -------------------- */
function ManagerMoveModal({ player, fromTime, dateLabel, openSlots, onPick, onClose }) {
const [sel, setSel] = useState(null);
const sorted = window.YCC.sortByTime(openSlots);
return (
{player.name}{player.guest && Added by manager }
HI {player.hi} · GHIN {player.ghin}
Move to another group
{sorted.length === 0 &&
No other groups with an open spot that day.
}
{sorted.map(s => (
setSel(s.id)} style={{
padding: "11px 8px", borderRadius: 9, fontSize: 14, fontWeight: 700, fontFamily: "var(--font-display)",
border: "1px solid " + (sel === s.id ? "var(--brand)" : "var(--line-strong)"),
background: sel === s.id ? "var(--brand)" : "var(--surface)", color: sel === s.id ? "var(--on-brand)" : "var(--ink)",
transition: "all .12s",
}}>{s.time}{4 - s.players.length} open
))}
Cancel
onPick(sorted.find(s => s.id === sel))}> Move player
);
}
/* -------------------- MANAGER: ADD OFF-APP MEMBER -------------------- */
function AddPlayerModal({ slot, dayLabel, members, weekSlots, onAdd, onClose }) {
const Y = window.YCC;
const list = members || [];
const fullName = (m) => `${m.first_name || ""} ${m.last_name || ""}`.trim() || m.email;
const confirmed = (m) => !!m.handicap_confirmed_at;
const signedNames = new Set();
Object.values(weekSlots || {}).forEach(slots => (slots || []).forEach(s => s.players.forEach(p => signedNames.add(p.name))));
const inThisSlot = new Set(slot.players.map(p => p.name.toLowerCase()));
const signedUpConfirmed = list.filter(m => signedNames.has(fullName(m)) && confirmed(m));
const signedIds = new Set(signedUpConfirmed.map(m => m.id));
const others = list.filter(m => !signedIds.has(m.id));
const [pick, setPick] = useState(list.length ? "" : "__guest__");
const [form, setForm] = useState({ name: "", hi: "", ghin: "", tee: "White", transport: "walk" });
const open = 4 - slot.players.length;
const dot = { Blue: "#2f5db0", White: "#c7c8b3", Green: "#3a7a4f", Gold: "#b08d4f", Red: "#9e3b2f" };
function selectPick(v) {
setPick(v);
if (v === "__guest__" || v === "") { setForm({ name: "", hi: "", ghin: "", tee: "White", transport: "walk" }); return; }
const m = list.find(x => x.id === v);
if (m) setForm({ name: fullName(m), hi: m.handicap_index == null ? "" : String(m.handicap_index), ghin: m.ghin || "", tee: m.tee || "White", transport: "walk" });
}
const isGuest = pick === "__guest__";
const isMember = !!pick && pick !== "__guest__";
const selMember = isMember ? list.find(m => m.id === pick) : null;
const dupe = (isMember || isGuest) && inThisSlot.has((form.name || "").trim().toLowerCase());
const canAdd = open > 0 && !!form.name.trim() && (isGuest || isMember) && !dupe;
function submit() {
onAdd({ name: form.name.trim(), hi: form.hi, ghin: form.ghin, tee: form.tee, transport: form.transport, guest: isGuest, memberId: isMember ? pick : undefined });
}
return (
Who are you adding?
selectPick(e.target.value)} style={{ appearance: "auto" }}>
Select a member…
{signedUpConfirmed.length > 0 && (
{signedUpConfirmed.map(m => {fullName(m)} · HI {m.handicap_index ?? "—"} )}
)}
{others.length > 0 && (
{others.map(m => {fullName(m)}{confirmed(m) ? " ✓" : ""} · HI {m.handicap_index ?? "—"} )}
)}
Other — add a guest…
{dupe &&
Already in this group.
}
{isGuest && (
<>
A guest who isn't on the DIV Tour app. You're adding them on their behalf — their handicap drives the weekend game.
Full name setForm({ ...form, name: e.target.value })} placeholder="e.g. Tom Bradley" />
>
)}
{isMember && selMember && (
s[0]).join("").slice(0, 2).toUpperCase()} size={34} />
{form.name}
{confirmed(selMember)
? Confirmed
: Not confirmed }
GHIN {form.ghin || "—"}
)}
{(isGuest || isMember) && (
<>
Tees
{Y.TEES.map(t => {
const on = form.tee === t.name;
return (
setForm({ ...form, tee: t.name })} style={{
display: "flex", alignItems: "center", gap: 7, padding: "9px 11px", borderRadius: 9,
border: "1.5px solid " + (on ? "var(--brand)" : "var(--line-strong)"),
background: on ? "var(--brand-soft)" : "var(--surface)", transition: "all .12s",
}}>
{t.name}
);
})}
Walking or riding?
{[["walk", "walk", "Walk"], ["ride", "cart", "Ride"]].map(([val, icon, label]) => {
const on = form.transport === val;
return (
setForm({ ...form, transport: val })} style={{
flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: 8, padding: "11px", borderRadius: 9,
border: "1.5px solid " + (on ? "var(--brand)" : "var(--line-strong)"),
background: on ? "var(--brand-soft)" : "var(--surface)", color: on ? "var(--brand)" : "var(--ink-soft)", fontWeight: 700, fontSize: 13.5, transition: "all .12s",
}}> {label}
);
})}
>
)}
Cancel
Add to group
);
}
/* -------------------- REQUEST A CHANGE (email the primary manager) -------------------- */
// Once member self-service closes (Wed 11:59 PM ET) the tee sheet is managed by
// the tour manager. Members compose a change request that opens in their email app,
// pre-addressed to the primary manager with their current spots filled in.
function RequestChangeModal({ member, primary, primaryEmail, bookings, slotOf, tour, phase, toast, onClose }) {
const Y = window.YCC;
const rows = (bookings || [])
.map(b => ({ b, slot: slotOf(b) }))
.filter(x => x.slot)
.sort((a, b) => a.b.dateISO.localeCompare(b.b.dateISO) || Y.parseTime(a.slot.time) - Y.parseTime(b.slot.time));
const KINDS = [
["move", "Move to a different tee time"],
["release", "Release / cancel a spot"],
["add", "Add me to a group"],
["group", "Change who's in my group"],
["other", "Something else"],
];
const [kind, setKind] = useState("move");
const [note, setNote] = useState("");
const contacts = (Y.CLUB_CONTACTS || []);
const [cc, setCc] = useState([]);
const toggleCc = (email) => setCc((prev) => prev.includes(email) ? prev.filter((e) => e !== email) : [...prev, email]);
const primaryName = (primary && primary.name) || "the DIV Tour Manager";
const lockedCopy = phase === "locked"
? "The Friday 9:00 AM ET lock has passed, so groups are final."
: "Member sign-ups are closed for this week.";
function buildBody() {
const kindLabel = (KINDS.find(k => k[0] === kind) || [])[1] || "Change request";
const lines = [];
lines.push(`Hi ${primaryName.split(" ")[0]},`);
lines.push("");
lines.push(`I'd like to request a change to my DIV Tour entry for the weekend of ${tour.weekendLabel}.`);
lines.push("");
lines.push(`Request type: ${kindLabel}`);
if (rows.length) {
lines.push("");
lines.push("My current spot(s):");
rows.forEach(({ b, slot }) => {
lines.push(` • ${Y.fmtDate(new Date(b.dateISO + "T00:00:00"))} — ${slot.time}`);
});
} else {
lines.push("");
lines.push("I don't currently have a spot this weekend.");
}
lines.push("");
lines.push("Details:");
lines.push(note.trim() || "(add any specifics here)");
lines.push("");
lines.push("Thanks,");
lines.push(member.name || "");
lines.push(`HI ${member.handicapIndex ?? "—"} · GHIN ${member.ghin || "—"}${member.phone ? " · " + member.phone : ""}`);
return lines.join("\n");
}
function send() {
const subject = `DIV Tour change request — ${member.name} (${tour.weekendLabel})`;
// Always copy the requester at the email they signed up with, plus any chosen club inboxes.
const selfEmail = (member.email || "").trim();
const ccList = [...cc];
if (selfEmail && selfEmail.toLowerCase() !== (primaryEmail || "").toLowerCase() && !ccList.some((e) => e.toLowerCase() === selfEmail.toLowerCase())) ccList.push(selfEmail);
const ccPart = ccList.length ? `&cc=${encodeURIComponent(ccList.join(","))}` : "";
const url = `mailto:${encodeURIComponent(primaryEmail)}?subject=${encodeURIComponent(subject)}${ccPart}&body=${encodeURIComponent(buildBody())}`;
try { window.location.href = url; } catch (e) {}
if (toast) toast("Opening your email app…");
onClose();
}
return (
{lockedCopy} Send your request to the primary manager and they'll take care of it.
Goes to
{primaryName}
{primaryEmail}
Primary
{contacts.length > 0 && (
Also notify (optional CC)
{contacts.map((c) => {
const on = cc.includes(c.email);
return (
toggleCc(c.email)} title={c.email} style={{
display: "inline-flex", alignItems: "center", gap: 8, padding: "8px 13px", borderRadius: 999,
border: "1.5px solid " + (on ? "var(--brand)" : "var(--line-strong)"),
background: on ? "var(--brand-soft)" : "var(--surface)", color: on ? "var(--brand)" : "var(--ink)", transition: "all .12s",
}}>
{c.name}
);
})}
)}
{rows.length > 0 && (
Your current spot{rows.length > 1 ? "s" : ""}
{rows.map(({ b, slot }) => (
{slot.time}
{Y.fmtDate(new Date(b.dateISO + "T00:00:00"))}
))}
)}
What do you need?
setKind(e.target.value)} style={{ appearance: "auto" }}>
{KINDS.map(([v, l]) => {l} )}
Cancel
Compose email
);
}
/* -------------------- CANCEL CONFIRM -------------------- */
function ConfirmModal({ title, body, confirmLabel, danger, onConfirm, onClose }) {
return (
{title}
{body}
Keep it
{confirmLabel}
);
}
/* -------------------- MY TEE TIMES -------------------- */
function MyBookings({ ctx }) {
const Y = window.YCC;
const { bookings, member, slotOf, go, requestEdit, requestCancel, daysUntil, setTransport, phase, entry, confirmedNames, requestManagerEmail, primaryManager } = ctx;
const memberOpen = phase === "open";
const approved = !!(member && confirmedNames && confirmedNames.has(member.name));
const rows = bookings
.map(b => ({ b, slot: slotOf(b) }))
.filter(x => x.slot)
.sort((a, b) => a.b.dateISO.localeCompare(b.b.dateISO) || Y.parseTime(a.slot.time) - Y.parseTime(b.slot.time));
return (
DIV Tour · My spots
My tee times
{memberOpen ? "The spots you've claimed in this weekend's entry. Move or release your own spot until Wednesday 11:59 PM ET — after that, changes go through your manager." : "Member changes are closed for this weekend. Contact your manager to add, move, or release a spot."}
memberOpen ? go("book") : requestManagerEmail()}>
{memberOpen ? <> Claim another spot> : <> Request a change>}
{!memberOpen && (
{phase === "locked" ? "Groups are final for this weekend." : "Member sign-ups are closed."} Need to move, release, or change a spot? Email {(primaryManager && primaryManager.name) || "your manager"} to request it.
Request a change
)}
{rows.length === 0 ? (
No spots claimed
Head to the tee times to claim an open spot for the weekend.
go("book")}>View tee times
) : (
{rows.map(({ b, slot }) => {
const dateLabel = Y.fmtDate(new Date(b.dateISO + "T00:00:00"));
return (
{slot.time}
{dateLabel}
{daysUntil(b.dateISO)}
Championship 18 · DIV Tour
{slot.players.length} of 4
{slot.players.map((p, i) => (
{p.name === member.name ? "You" : p.name} · {p.hi}
))}
Transport
p.name === member.name)?.transport || "walk"} size="md"
onToggle={() => setTransport(b.dateISO, slot.id, member.name, (slot.players.find(p => p.name === member.name)?.transport || "walk") === "walk" ? "ride" : "walk")} />
{memberOpen ? (
<>
requestEdit(b)}> Move spot
requestCancel(b)}> Release
>
) : (
Request change
)}
{approved ? "Approved" : "Pending HCP"}
);
})}
)}
);
}
/* -------------------- PROFILE -------------------- */
function Profile({ ctx }) {
const { member, setMember, toast } = ctx;
const Y = window.YCC;
const [form, setForm] = useState({ first: member.first, last: member.last, phone: member.phone, ghin: member.ghin, tee: member.tee || "White", handicapIndex: member.handicapIndex ?? "" });
const dirty = form.first !== member.first || form.last !== member.last || form.phone !== member.phone || form.ghin !== member.ghin || form.tee !== (member.tee || "White") || String(form.handicapIndex) !== String(member.handicapIndex ?? "");
function save() {
const hi = form.handicapIndex === "" || form.handicapIndex == null ? null : Number(form.handicapIndex);
const name = `${form.first} ${form.last}`.trim();
// Saving a handicap counts as the weekly handicap confirmation (unblocks booking).
if (hi != null) { try { localStorage.setItem("ycc_ghin_sync_" + ((member.email) || "").toLowerCase(), String(Date.now())); } catch (e) {} }
setSaveState({ busy: true, msg: "", ok: null });
const next = { ...member, first: form.first, last: form.last, name, initials: (name || "?").split(/\s+/).map(s => s[0]).join("").slice(0, 2).toUpperCase(), phone: form.phone, ghin: form.ghin, tee: form.tee, handicapIndex: hi };
Promise.resolve(setMember(next)).then((res) => {
if (res && res.ok === false) setSaveState({ busy: false, ok: false, msg: res.error || "Save failed." });
else setSaveState({ busy: false, ok: true, msg: res && res.local ? "Saved on this device." : "Saved to your account." });
});
}
const [saveState, setSaveState] = useState({ busy: false, ok: null, msg: "" });
const incomplete = !(member.first && member.last && member.ghin);
const [ghinBusy, setGhinBusy] = useState(false);
const [ghinMsg, setGhinMsg] = useState("");
const [ghinOpen, setGhinOpen] = useState(false);
const [gLogin, setGLogin] = useState({ id: member.ghin || "", pw: "" });
function openGhin() { setGhinMsg(""); setGLogin(g => ({ ...g, id: g.id || member.ghin || "" })); setGhinOpen(true); }
function connectGhin() {
const db = window.YCCdb;
setGhinMsg(""); setGhinBusy(true);
const finish = (msg) => { setGhinBusy(false); setGhinMsg(msg); };
if (!db || !db.loginGhin) { finish("GHIN connect isn't available yet."); return; }
const timer = setTimeout(() => finish("GHIN is taking too long — try again or enter your index manually."), 12000);
db.loginGhin(gLogin.id.trim(), gLogin.pw).then((res) => {
clearTimeout(timer); setGhinBusy(false);
if (res && res.handicapIndex != null) {
try { localStorage.setItem("ycc_ghin_sync_" + ((member.email) || "").toLowerCase(), String(Date.now())); } catch (e) {}
setForm(f => ({
...f,
handicapIndex: res.handicapIndex,
ghin: res.ghin || f.ghin,
first: f.first || res.first || "",
last: f.last || res.last || "",
}));
setGhinMsg(`Connected — index ${res.handicapIndex}${res.ghin ? " · GHIN " + res.ghin : ""}. Review and save below.`);
setGLogin({ id: "", pw: "" });
setGhinOpen(false);
} else {
setGhinMsg((res && res.error) || "Couldn't retrieve your handicap. You can enter it manually.");
}
}).catch(() => { clearTimeout(timer); finish("Couldn't reach GHIN. Enter your index manually."); });
}
return (
Member account
Profile
{incomplete ? "Welcome! Complete your profile below to start booking tee times." : "Your membership details and USGA handicap information."}
{incomplete && (
Add your name and GHIN number, then save to finish setting up your account.
)}
{/* identity card */}
{member.initials}
{member.name}
{member.tier}{member.memberSince ? " · Since " + member.memberSince : ""}
Handicap Index
{member.handicapIndex ?? "—"}
Course Handicap
{member.handicapIndex == null ? "—" : Y.courseHandicapForTee(member.handicapIndex, member.tee || "Blue")}
Tees
{member.tee || "Blue"} · {Y.teeByName(member.tee || "Blue").rating.toFixed(1)}/{Y.teeByName(member.tee || "Blue").slope}
Home Club
{member.homeClub}
GHIN Number
{member.ghin}
{/* editable details */}
Account details {dirty && Unsaved changes }
Default tees — used for your handicap
{Y.TEES.map(t => {
const on = form.tee === t.name;
const dot = { Blue: "#2f5db0", White: "#c7c8b3", Green: "#3a7a4f", Gold: "#b08d4f", Red: "#9e3b2f" }[t.name];
return (
setForm({ ...form, tee: t.name })} style={{
display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 4, padding: "10px 12px", borderRadius: 10, textAlign: "left",
border: "1.5px solid " + (on ? "var(--brand)" : "var(--line-strong)"),
background: on ? "var(--brand-soft)" : "var(--surface)", transition: "all .12s",
}}>
{t.name}
{t.rating.toFixed(1)}/{t.slope}
CH {member.handicapIndex == null ? "—" : Y.courseHandicapForTee(member.handicapIndex, t.name)}
);
})}
Determines your course handicap and the strokes you get on each hole. Red & Gold play to a different index than Blue/White.
{saveState.msg && (
{saveState.msg}
)}
setForm({ first: member.first, last: member.last, phone: member.phone, ghin: member.ghin, tee: member.tee || "White", handicapIndex: member.handicapIndex ?? "" })}>Reset
{saveState.busy ? "Saving…" : "Save changes"}
);
}
Object.assign(window, { ClaimModal, MoveSpotModal, ManagerMoveModal, AddPlayerModal, ConfirmModal, RequestChangeModal, MyBookings, Profile });