This commit is contained in:
LockeShor
2026-03-06 00:56:43 -05:00
commit 95e03442c5
25 changed files with 3695 additions and 0 deletions

786
family.html Normal file
View File

@@ -0,0 +1,786 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cary Swim Club Family Check-In</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Archivo+Black&family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root {
--ink: #10231a;
--ink-soft: #4b5f56;
--surface: #ffffff;
--surface-soft: #f4fbef;
--line: #d8e8cf;
--good: #3e9f14;
--warn: #ba5d17;
--deep: #2b6ed4;
--deep-bg: #dbe9ff;
--safe: #9f3232;
--accent: #4cbb17;
--accent-strong: #3b9612;
--bg-a: #f6fbf2;
--bg-b: #deefd1;
--hero-a: #1f3e22;
--hero-b: #2f642d;
--focus-ring: rgba(76, 187, 23, 0.28);
--shadow: 0 16px 36px rgba(19, 45, 23, 0.14);
}
body[data-theme="dark"] {
--ink: #e7f6df;
--ink-soft: #a5c1af;
--surface: #132118;
--surface-soft: #1a2d21;
--line: #264032;
--good: #65d82c;
--warn: #f2a45b;
--deep: #7db7ff;
--deep-bg: #1b3157;
--safe: #f08181;
--accent: #65d82c;
--accent-strong: #4cbb17;
--bg-a: #0a140d;
--bg-b: #12211a;
--hero-a: #15341c;
--hero-b: #1f4c28;
--focus-ring: rgba(101, 216, 44, 0.25);
--shadow: 0 18px 40px rgba(0, 0, 0, 0.45);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: "Space Grotesk", "Segoe UI", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at 20% 0%, rgba(76, 187, 23, 0.22), transparent 30%),
radial-gradient(circle at 95% 95%, rgba(61, 120, 67, 0.2), transparent 28%),
linear-gradient(165deg, var(--bg-a), var(--bg-b));
background-attachment: fixed;
padding: 24px;
transition: background 260ms ease, color 260ms ease;
}
.page {
max-width: 1050px;
margin: 0 auto;
display: grid;
gap: 16px;
}
.top {
background:
linear-gradient(120deg, rgba(255, 255, 255, 0.05), transparent 42%),
linear-gradient(140deg, var(--hero-a), var(--hero-b));
border-radius: 18px;
padding: 20px;
color: #efffe8;
box-shadow: var(--shadow);
position: relative;
overflow: hidden;
}
.top::after {
content: "";
position: absolute;
width: 210px;
height: 210px;
right: -60px;
top: -102px;
border-radius: 50%;
background: radial-gradient(circle, rgba(151, 255, 109, 0.25), transparent 70%);
}
.top h1 {
margin: 0;
font-family: "Archivo Black", "Impact", sans-serif;
letter-spacing: 0.04em;
font-size: clamp(1.4rem, 2vw + 1rem, 2.2rem);
}
.top p {
margin: 8px 0 0;
color: #d8f6c8;
}
.top-controls {
margin-top: 14px;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.guest-pass-panel {
background: rgba(10, 31, 10, 0.22);
border: 1px solid rgba(180, 244, 148, 0.35);
border-radius: 14px;
padding: 10px 12px;
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 260px;
}
.guest-pass-label {
display: grid;
gap: 2px;
min-width: 110px;
}
.guest-pass-label strong {
font-size: 0.8rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #d8f6c8;
}
.guest-pass-value {
font-family: "Archivo Black", "Impact", sans-serif;
font-size: 1.6rem;
line-height: 1;
color: #f7ffe9;
}
.guest-pass-actions {
margin-left: auto;
display: inline-grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.pass-btn {
border: 1px solid rgba(196, 250, 166, 0.35);
background: rgba(6, 28, 12, 0.34);
color: #f0ffe5;
border-radius: 9px;
min-width: 50px;
padding: 8px 10px;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.pass-btn:disabled {
cursor: default;
opacity: 0.5;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.btn {
border: 0;
border-radius: 12px;
padding: 10px 14px;
font: inherit;
font-weight: 700;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-main {
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
color: #fff;
}
.btn-ghost {
background: var(--surface-soft);
color: var(--ink);
border: 1px solid var(--line);
}
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 16px;
padding: 16px;
box-shadow: var(--shadow);
backdrop-filter: blur(2px);
transition: background 220ms ease, border-color 220ms ease;
}
.rule-banner {
margin-bottom: 10px;
border-radius: 12px;
border: 1px dashed var(--line);
background: var(--surface-soft);
color: var(--ink-soft);
padding: 10px 12px;
font-size: 0.9rem;
}
.section-block {
display: grid;
gap: 10px;
margin-top: 10px;
}
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.section-title {
margin: 0;
font-size: 1rem;
letter-spacing: 0.02em;
}
.section-meta {
margin: 0;
font-size: 0.82rem;
color: var(--ink-soft);
}
.member-list {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
align-items: start;
}
.member {
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px;
background: linear-gradient(135deg, var(--surface), var(--surface-soft));
display: grid;
gap: 8px;
align-content: start;
transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease;
animation: card-in 260ms ease both;
align-self: start;
position: relative;
overflow: hidden;
}
.member::after {
content: "";
position: absolute;
right: -20px;
bottom: -25px;
width: 80px;
height: 80px;
border-radius: 50%;
background: radial-gradient(circle, rgba(76, 187, 23, 0.18), rgba(76, 187, 23, 0.02));
}
.member:hover {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--accent) 40%, var(--line));
box-shadow: 0 12px 24px rgba(19, 45, 79, 0.12);
}
.member-head {
display: flex;
justify-content: flex-start;
gap: 12px;
align-items: flex-start;
flex-wrap: wrap;
position: relative;
z-index: 1;
}
.member-title-wrap {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.member-icon {
width: 28px;
height: 28px;
border-radius: 9px;
background: linear-gradient(135deg, var(--accent), #2e8e34);
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
flex: 0 0 auto;
align-self: center;
}
.member-icon svg {
width: 15px;
height: 15px;
fill: currentColor;
}
.member-name {
margin: 0;
display: grid;
gap: 1px;
max-width: 100%;
}
.member-first,
.member-last {
display: block;
font-size: 1.16rem;
font-weight: 700;
line-height: 1.08;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.member-sub {
margin: 4px 0 0;
color: var(--ink-soft);
font-size: 0.88rem;
overflow-wrap: anywhere;
}
.chip {
display: inline-block;
border-radius: 999px;
padding: 4px 10px;
font-size: 0.78rem;
font-weight: 700;
}
.chip-in {
background: color-mix(in srgb, var(--accent) 20%, var(--surface));
color: var(--good);
}
.chip-out {
background: #fbe8d9;
color: var(--warn);
}
.chip-deep {
background: var(--deep-bg);
color: var(--deep);
}
.chip-shallow {
background: #fde7e7;
color: var(--safe);
}
.row {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
position: relative;
z-index: 1;
}
.toggle {
background: color-mix(in srgb, var(--surface-soft) 85%, transparent);
border: 1px solid var(--line);
border-radius: 12px;
display: grid;
grid-template-columns: 1fr 1fr;
padding: 3px;
gap: 3px;
}
.toggle button {
border: 0;
border-radius: 9px;
padding: 8px 8px;
font: inherit;
font-size: 0.83rem;
font-weight: 700;
cursor: pointer;
color: var(--ink);
background: transparent;
}
.toggle button.active {
color: #fff;
box-shadow: 0 8px 20px rgba(9, 35, 67, 0.2);
}
.state-in.active {
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
}
.state-out.active {
background: linear-gradient(135deg, #ce7a32, #b75f17);
}
.state-pass.active {
background: linear-gradient(135deg, #3f87eb, #2b6ed4);
}
.state-fail.active {
background: linear-gradient(135deg, #ad3b3b, #912a2a);
}
.empty-note {
color: var(--ink-soft);
font-size: 0.86rem;
border: 1px dashed var(--line);
border-radius: 10px;
padding: 9px 10px;
background: var(--surface-soft);
}
.chip-wrap {
display: inline-flex;
gap: 5px;
flex-wrap: wrap;
justify-content: flex-end;
max-width: 100%;
}
.status {
border: 1px solid var(--line);
border-radius: 12px;
padding: 8px 10px;
font-size: 0.82rem;
color: var(--ink-soft);
background: var(--surface-soft);
opacity: 0.86;
margin-top: 12px;
}
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
transition: none !important;
}
}
@keyframes card-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 700px) {
body { padding: 14px; }
.member-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 460px) {
.member-list { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main class="page">
<section class="top">
<h1 id="familyTitle">Family</h1>
<p id="familyMeta">Loading family...</p>
<div class="top-controls">
<div class="guest-pass-panel" aria-live="polite">
<div class="guest-pass-label">
<strong>Guest Passes</strong>
<span id="guestPassCount" class="guest-pass-value">0</span>
</div>
<div class="guest-pass-actions">
<button id="decreasePassBtn" class="pass-btn" type="button" aria-label="Decrease guest passes">-1</button>
<button id="increasePassBtn" class="pass-btn" type="button" aria-label="Increase guest passes">+1</button>
</div>
</div>
</div>
</section>
<section class="toolbar">
<a class="btn btn-ghost" href="/">Back To Family Search</a>
<button id="refreshBtn" class="btn btn-main" type="button">Refresh Family</button>
</section>
<section class="panel">
<div id="ruleBanner" class="rule-banner">Swim test required under age 13.</div>
<section class="section-block">
<div class="section-head">
<h2 class="section-title">Adults</h2>
<p class="section-meta" id="adultsMeta">0 members</p>
</div>
<div id="adultsList" class="member-list"></div>
<div id="adultsEmpty" class="empty-note">No adults in this family.</div>
</section>
<section class="section-block">
<div class="section-head">
<h2 class="section-title">Children</h2>
<p class="section-meta" id="childrenMeta">0 members</p>
</div>
<div id="childrenList" class="member-list"></div>
<div id="childrenEmpty" class="empty-note">No children in this family.</div>
</section>
<div id="status" class="status">Ready.</div>
</section>
</main>
<script>
function getPreferredTheme() {
const saved = window.localStorage.getItem("csc-theme");
if (saved === "dark" || saved === "light") {
return saved;
}
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function applyTheme(theme) {
document.body.setAttribute("data-theme", theme);
}
applyTheme(getPreferredTheme());
const familyTitle = document.getElementById("familyTitle");
const familyMeta = document.getElementById("familyMeta");
const ruleBanner = document.getElementById("ruleBanner");
const adultsList = document.getElementById("adultsList");
const childrenList = document.getElementById("childrenList");
const adultsMeta = document.getElementById("adultsMeta");
const childrenMeta = document.getElementById("childrenMeta");
const adultsEmpty = document.getElementById("adultsEmpty");
const childrenEmpty = document.getElementById("childrenEmpty");
const statusBox = document.getElementById("status");
const refreshBtn = document.getElementById("refreshBtn");
const guestPassCount = document.getElementById("guestPassCount");
const decreasePassBtn = document.getElementById("decreasePassBtn");
const increasePassBtn = document.getElementById("increasePassBtn");
let currentMinSwimTestAge = 13;
let currentGuestPasses = 0;
let isUpdatingGuestPasses = false;
function currentFamilyId() {
const parts = window.location.pathname.split("/").filter(Boolean);
const maybeId = parts[parts.length - 1];
return Number.parseInt(maybeId, 10);
}
async function api(path, options = {}) {
const response = await fetch(path, {
headers: { "Content-Type": "application/json" },
...options,
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || payload.message || "Request failed");
}
return payload;
}
function setStatus(message) {
statusBox.textContent = message;
}
function buildToggle(options, activeValue, onChoose) {
const container = document.createElement("div");
container.className = "toggle";
options.forEach((opt) => {
const btn = document.createElement("button");
btn.type = "button";
btn.textContent = opt.label;
btn.className = `${opt.className} ${opt.value === activeValue ? "active" : ""}`;
btn.addEventListener("click", () => onChoose(opt.value));
container.append(btn);
});
return container;
}
async function setCheckIn(memberId, checkedIn) {
await api(`/api/members/${memberId}/checkin`, {
method: "PATCH",
body: JSON.stringify({ checkedIn }),
});
}
async function setSwimTest(memberId, swimTestPassed) {
await api(`/api/members/${memberId}/swim-test`, {
method: "PATCH",
body: JSON.stringify({ swimTestPassed }),
});
}
function renderGuestPasses() {
guestPassCount.textContent = String(currentGuestPasses);
decreasePassBtn.disabled = isUpdatingGuestPasses || currentGuestPasses <= 0;
increasePassBtn.disabled = isUpdatingGuestPasses;
}
async function updateGuestPasses(nextCount) {
if (!Number.isInteger(nextCount) || nextCount < 0) {
return;
}
const familyId = currentFamilyId();
if (!familyId || Number.isNaN(familyId)) {
setStatus("Invalid family id.");
return;
}
isUpdatingGuestPasses = true;
renderGuestPasses();
try {
setStatus("Updating guest passes...");
const payload = await api(`/api/families/${familyId}/guest-passes`, {
method: "PATCH",
body: JSON.stringify({ guestPasses: nextCount }),
});
currentGuestPasses = payload.item.guestPasses;
setStatus(`Guest passes set to ${currentGuestPasses}.`);
} catch (error) {
setStatus(`Error: ${error.message}`);
} finally {
isUpdatingGuestPasses = false;
renderGuestPasses();
}
}
function renderMember(member, refreshFamily) {
const card = document.createElement("article");
card.className = "member";
const head = document.createElement("div");
head.className = "member-head";
const idWrap = document.createElement("div");
idWrap.className = "member-title-wrap";
const icon = document.createElement("span");
icon.className = "member-icon";
icon.innerHTML = "<svg viewBox='0 0 24 24' aria-hidden='true'><path d='M12 12a5 5 0 1 0-5-5 5 5 0 0 0 5 5zm0 2c-4.42 0-8 2.24-8 5v1h16v-1c0-2.76-3.58-5-8-5z'/></svg>";
const textWrap = document.createElement("div");
const title = document.createElement("h3");
title.className = "member-name";
title.innerHTML = `<span class="member-first">${member.firstName}</span><span class="member-last">${member.lastName}</span>`;
const subtitle = document.createElement("p");
subtitle.className = "member-sub";
subtitle.textContent = `Age ${member.age}`;
textWrap.append(title, subtitle);
idWrap.append(icon, textWrap);
head.append(idWrap);
const controls = document.createElement("div");
controls.className = "row";
controls.append(
buildToggle(
[
{ label: "Checked Out", value: false, className: "state-out" },
{ label: "Checked In", value: true, className: "state-in" },
],
!!member.checkedIn,
async (value) => {
try {
setStatus(`Updating check-in for ${member.fullName}...`);
await setCheckIn(member.id, value);
await refreshFamily();
setStatus(`Updated check-in for ${member.fullName}.`);
} catch (error) {
setStatus(`Error: ${error.message}`);
}
}
)
);
if (member.requiresSwimTest) {
controls.append(
buildToggle(
[
{ label: "Not Passed", value: false, className: "state-fail" },
{ label: "Passed", value: true, className: "state-pass" },
],
!!member.swimTestPassed,
async (value) => {
try {
setStatus(`Updating swim test for ${member.fullName}...`);
await setSwimTest(member.id, value);
await refreshFamily();
setStatus(`Updated swim test for ${member.fullName}.`);
} catch (error) {
setStatus(`Error: ${error.message}`);
}
}
)
);
} else {
const notRequired = document.createElement("div");
notRequired.className = "toggle";
notRequired.innerHTML = `<button type="button" class="state-pass active">Swim Test Not Required</button><button type="button">Age ${member.age}</button>`;
controls.append(notRequired);
}
card.append(head, controls);
return card;
}
async function loadFamily() {
const familyId = currentFamilyId();
if (!familyId || Number.isNaN(familyId)) {
setStatus("Invalid family id.");
return;
}
const payload = await api(`/api/families/${familyId}`);
const family = payload.item;
currentMinSwimTestAge = family.minSwimTestAge;
currentGuestPasses = Number.isInteger(family.guestPasses) ? family.guestPasses : 0;
familyTitle.textContent = `${family.familyName} Family`;
familyMeta.textContent = `${family.members.length} members • ${family.primaryPhone || "No phone number on file"}`;
ruleBanner.textContent = `Swim test required under age ${currentMinSwimTestAge}.`;
renderGuestPasses();
const adults = family.members.filter((member) => member.age >= 18);
const children = family.members.filter((member) => member.age < 18);
adultsMeta.textContent = `${adults.length} member${adults.length === 1 ? "" : "s"}`;
childrenMeta.textContent = `${children.length} member${children.length === 1 ? "" : "s"}`;
adultsList.innerHTML = "";
childrenList.innerHTML = "";
adults.forEach((member) => {
adultsList.append(renderMember(member, refreshFamily));
});
children.forEach((member) => {
childrenList.append(renderMember(member, refreshFamily));
});
adultsEmpty.style.display = adults.length ? "none" : "block";
childrenEmpty.style.display = children.length ? "none" : "block";
}
async function refreshFamily() {
try {
await loadFamily();
} catch (error) {
setStatus(`Error: ${error.message}`);
}
}
refreshBtn.addEventListener("click", refreshFamily);
decreasePassBtn.addEventListener("click", () => updateGuestPasses(Math.max(0, currentGuestPasses - 1)));
increasePassBtn.addEventListener("click", () => updateGuestPasses(currentGuestPasses + 1));
refreshFamily();
</script>
</body>
</html>