Nonprofit — Volunteer Portal
A warm, human volunteer dashboard for a fictional community pantry network, pairing logged-hours and meals-served impact stats with an animated monthly-goal thermometer. Volunteers browse upcoming, available, and past shifts through keyboard-navigable tabs, claim or cancel a shift to watch spot counts and totals recalculate live, scan a June schedule calendar, track earned badges and a top-volunteers leaderboard, and log new hours through an accessible modal with toast confirmations — all in vanilla JavaScript.
MCP
Code
:root {
--brand: #1f7a6d;
--brand-d: #155e54;
--accent: #e8743b;
--accent-d: #cc5d28;
--ink: #2a2722;
--ink-2: #524d44;
--muted: #7a7368;
--bg: #faf6f0;
--surface: #ffffff;
--line: rgba(42, 39, 34, 0.1);
--line-2: rgba(42, 39, 34, 0.18);
--ok: #2f9e6f;
--warn: #d98a2b;
--danger: #d4503e;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 22px;
--sh-sm: 0 1px 2px rgba(42, 39, 34, 0.06), 0 2px 8px rgba(42, 39, 34, 0.05);
--sh-md: 0 6px 22px rgba(42, 39, 34, 0.1);
--sh-lg: 0 18px 50px rgba(42, 39, 34, 0.18);
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.6;
color: var(--ink);
background:
radial-gradient(1100px 480px at 88% -8%, rgba(31, 122, 109, 0.08), transparent 60%),
radial-gradient(900px 420px at -6% 4%, rgba(232, 116, 59, 0.07), transparent 60%),
var(--bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2 { font-family: "Fraunces", Georgia, serif; font-weight: 600; line-height: 1.15; margin: 0; }
.wrap { width: min(1120px, 92vw); margin-inline: auto; }
.skip {
position: absolute; left: -999px; top: 0; z-index: 100;
background: var(--ink); color: #fff; padding: 10px 16px; border-radius: 0 0 var(--r-sm) 0;
}
.skip:focus { left: 0; }
/* ---------- topbar ---------- */
.topbar {
position: sticky; top: 0; z-index: 30;
background: rgba(255, 255, 255, 0.82);
backdrop-filter: saturate(1.4) blur(10px);
border-bottom: 1px solid var(--line);
}
.topbar-in { display: flex; align-items: center; gap: 18px; padding: 14px 0; }
.brand { display: flex; align-items: center; gap: 10px; }
.brand-mark {
width: 38px; height: 38px; display: grid; place-items: center;
background: linear-gradient(150deg, var(--brand), var(--brand-d));
color: #fff; border-radius: 12px; box-shadow: var(--sh-sm);
}
.brand-name { font-family: "Fraunces", serif; font-weight: 700; font-size: 1.18rem; }
.brand-sub {
font-size: .7rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted);
border-left: 1px solid var(--line-2); padding-left: 10px; margin-left: 2px;
}
.topnav { display: flex; gap: 4px; margin-left: auto; }
.topnav-link {
color: var(--ink-2); text-decoration: none; font-weight: 500; font-size: .92rem;
padding: 8px 12px; border-radius: var(--r-sm);
}
.topnav-link:hover { background: rgba(31, 122, 109, 0.08); color: var(--brand-d); }
.top-actions { display: flex; align-items: center; gap: 12px; }
.avatar {
width: 38px; height: 38px; border-radius: 50%; display: grid; place-items: center;
background: linear-gradient(150deg, #f1d8c5, #e8743b); color: #fff;
font-weight: 700; font-size: .82rem; box-shadow: var(--sh-sm);
}
/* ---------- buttons ---------- */
.btn {
font: inherit; font-weight: 600; cursor: pointer; border: 1px solid transparent;
border-radius: var(--r-sm); padding: 9px 15px; display: inline-flex; align-items: center;
gap: 7px; transition: transform .12s, box-shadow .15s, background .15s, color .15s;
}
.btn:active { transform: translateY(1px); }
.btn-accent {
background: linear-gradient(160deg, var(--accent), var(--accent-d)); color: #fff;
box-shadow: 0 3px 12px rgba(232, 116, 59, 0.32);
}
.btn-accent:hover { box-shadow: 0 6px 18px rgba(232, 116, 59, 0.42); transform: translateY(-1px); }
.btn-ghost { background: var(--surface); color: var(--ink-2); border-color: var(--line-2); }
.btn-ghost:hover { background: #f6efe7; }
.btn-claim { background: rgba(31, 122, 109, 0.12); color: var(--brand-d); }
.btn-claim:hover { background: var(--brand); color: #fff; }
.btn-cancel { background: var(--surface); color: var(--danger); border-color: rgba(212, 80, 62, 0.4); }
.btn-cancel:hover { background: rgba(212, 80, 62, 0.1); }
.icon-btn {
border: none; background: none; font-size: 1.7rem; line-height: 1; color: var(--muted);
cursor: pointer; border-radius: var(--r-sm); width: 36px; height: 36px;
}
.icon-btn:hover { background: var(--line); color: var(--ink); }
:focus-visible { outline: 3px solid rgba(31, 122, 109, 0.45); outline-offset: 2px; }
/* ---------- hero ---------- */
.hero {
display: flex; align-items: flex-end; justify-content: space-between; gap: 22px;
padding: 34px 0 6px; flex-wrap: wrap;
}
.eyebrow { margin: 0 0 4px; text-transform: uppercase; letter-spacing: .12em; font-size: .72rem; font-weight: 700; color: var(--brand); }
.hero h1 { font-size: clamp(1.55rem, 3.4vw, 2.3rem); max-width: 18ch; }
.hero-lead { color: var(--ink-2); margin: 10px 0 0; max-width: 46ch; }
.hero-lead strong { color: var(--ink); }
.hero-badges { display: flex; flex-direction: column; gap: 8px; }
.trust-badge {
display: inline-flex; align-items: center; gap: 7px; font-size: .8rem; font-weight: 600;
color: var(--ink-2); background: var(--surface); border: 1px solid var(--line);
padding: 6px 12px; border-radius: 999px; box-shadow: var(--sh-sm);
}
.trust-badge .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px rgba(47, 158, 111, 0.18); }
/* ---------- stats ---------- */
.stats {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin: 22px 0 26px;
}
.stat {
background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-md);
padding: 16px 18px; box-shadow: var(--sh-sm);
}
.stat.accent { background: linear-gradient(160deg, #fff, #fff5ee); border-color: rgba(232, 116, 59, 0.28); }
.stat-label { margin: 0; font-size: .78rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; }
.stat-num { margin: 6px 0 2px; font-family: "Fraunces", serif; font-weight: 700; font-size: 1.95rem; color: var(--ink); display: flex; align-items: baseline; gap: 5px; }
.stat.accent .stat-num { color: var(--accent-d); }
.stat-unit { font-size: .9rem; font-weight: 600; color: var(--muted); }
.stat-foot { margin: 0; font-size: .8rem; color: var(--ink-2); }
/* ---------- layout grid ---------- */
.grid { display: grid; grid-template-columns: 1fr 320px; gap: 22px; align-items: start; }
.col-main, .col-side { display: flex; flex-direction: column; gap: 22px; }
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-lg); padding: 20px; box-shadow: var(--sh-sm); }
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-bottom: 16px; flex-wrap: wrap; }
.card-head h2 { font-size: 1.22rem; }
.side-head { font-size: 1.05rem; margin-bottom: 14px; }
/* ---------- tabs ---------- */
.tabs { display: inline-flex; background: #f4ede3; border-radius: 999px; padding: 4px; gap: 2px; }
.tab {
border: none; background: none; font: inherit; font-weight: 600; font-size: .85rem;
color: var(--ink-2); cursor: pointer; padding: 7px 14px; border-radius: 999px;
display: inline-flex; align-items: center; gap: 7px; transition: background .15s, color .15s;
}
.tab:hover { color: var(--ink); }
.tab.is-active { background: var(--surface); color: var(--brand-d); box-shadow: var(--sh-sm); }
.pill {
background: rgba(31, 122, 109, 0.14); color: var(--brand-d); font-size: .72rem;
border-radius: 999px; padding: 1px 7px; min-width: 18px; text-align: center;
}
.tab.is-active .pill { background: var(--brand); color: #fff; }
/* ---------- shift rows ---------- */
.shift {
display: grid; grid-template-columns: auto 1fr auto; gap: 14px; align-items: center;
padding: 14px; border: 1px solid var(--line); border-radius: var(--r-md);
margin-bottom: 12px; transition: border-color .15s, box-shadow .15s, transform .12s;
}
.shift:last-child { margin-bottom: 0; }
.shift:hover { border-color: var(--line-2); box-shadow: var(--sh-sm); }
.shift.full { opacity: .6; }
.shift-date {
width: 58px; text-align: center; background: #f4ede3; border-radius: var(--r-sm);
padding: 8px 4px; line-height: 1.15;
}
.shift-date .d { font-family: "Fraunces", serif; font-weight: 700; font-size: 1.25rem; display: block; color: var(--ink); }
.shift-date .m { font-size: .68rem; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); font-weight: 700; }
.shift-body { min-width: 0; }
.shift-title { margin: 0; font-weight: 600; font-size: .98rem; }
.shift-meta { margin: 2px 0 0; font-size: .84rem; color: var(--ink-2); display: flex; flex-wrap: wrap; gap: 4px 12px; }
.shift-meta .role { color: var(--brand-d); font-weight: 600; }
.spots { font-size: .78rem; font-weight: 600; }
.spots.low { color: var(--warn); }
.spots.full { color: var(--danger); }
.shift-cta { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
.tag {
font-size: .68rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em;
padding: 3px 9px; border-radius: 999px;
}
.tag.confirmed { background: rgba(47, 158, 111, 0.14); color: var(--ok); }
.tag.done { background: var(--line); color: var(--muted); }
.empty { text-align: center; color: var(--muted); padding: 28px 0; font-size: .95rem; }
/* ---------- calendar ---------- */
.cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; }
.cal-head { font-size: .72rem; font-weight: 700; text-transform: uppercase; color: var(--muted); text-align: center; padding-bottom: 2px; }
.cal-cell {
aspect-ratio: 1; border: 1px solid var(--line); border-radius: var(--r-sm);
display: flex; flex-direction: column; align-items: center; justify-content: center;
font-size: .85rem; color: var(--ink-2); position: relative;
}
.cal-cell.blank { border: none; }
.cal-cell.today { border-color: var(--brand); font-weight: 700; color: var(--ink); }
.cal-cell.booked { background: rgba(31, 122, 109, 0.12); border-color: rgba(31, 122, 109, 0.3); color: var(--brand-d); font-weight: 700; cursor: default; }
.cal-cell.booked::after { content: ""; position: absolute; bottom: 5px; width: 5px; height: 5px; border-radius: 50%; background: var(--brand); }
.legend { font-size: .78rem; color: var(--ink-2); display: inline-flex; align-items: center; gap: 6px; }
.legend-dot { width: 10px; height: 10px; border-radius: 3px; }
.legend-dot.booked { background: rgba(31, 122, 109, 0.4); }
/* ---------- thermometer ---------- */
.thermo { height: 16px; background: #f0e6d9; border-radius: 999px; overflow: hidden; box-shadow: inset 0 1px 2px rgba(42, 39, 34, 0.08); }
.thermo-fill { height: 100%; width: 0; border-radius: 999px; background: linear-gradient(90deg, var(--brand), #46b39f); transition: width .8s cubic-bezier(.22, 1, .36, 1); }
.thermo-cap { margin: 10px 0 0; font-size: .88rem; color: var(--ink-2); }
.thermo-cap strong { color: var(--brand-d); font-family: "Fraunces", serif; font-size: 1.05rem; }
/* ---------- badges ---------- */
.badge-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.badge {
display: flex; align-items: center; gap: 10px; padding: 10px; border-radius: var(--r-md);
border: 1px solid var(--line); font-size: .82rem; font-weight: 600;
}
.badge.locked { opacity: .5; }
.badge-ic { width: 32px; height: 32px; border-radius: 9px; display: grid; place-items: center; font-size: 1.05rem; flex: none; }
.badge .name { line-height: 1.2; }
.badge .name small { display: block; font-weight: 500; color: var(--muted); font-size: .72rem; }
/* ---------- leaderboard ---------- */
.leader { list-style: none; margin: 0; padding: 0; }
.leader li { display: flex; align-items: center; gap: 10px; padding: 8px 0; font-size: .9rem; border-bottom: 1px solid var(--line); }
.leader li:last-child { border-bottom: none; }
.leader li.me { font-weight: 700; }
.rank { width: 20px; color: var(--muted); font-weight: 700; font-size: .82rem; }
.la { width: 30px; height: 30px; border-radius: 50%; background: #ecdfd0; color: var(--ink-2); display: grid; place-items: center; font-size: .72rem; font-weight: 700; }
.leader li.me .la { background: linear-gradient(150deg, #f1d8c5, #e8743b); color: #fff; }
.leader em { margin-left: auto; font-style: normal; color: var(--ink-2); font-weight: 600; font-size: .84rem; }
/* ---------- footer ---------- */
.foot { margin-top: 40px; border-top: 1px solid var(--line); padding: 22px 0 36px; }
.foot-in p { margin: 0; color: var(--muted); font-size: .85rem; }
.foot-trust { margin-top: 4px !important; font-size: .78rem !important; }
/* ---------- modal ---------- */
.modal-back {
position: fixed; inset: 0; z-index: 60; display: grid; place-items: center;
background: rgba(42, 39, 34, 0.45); backdrop-filter: blur(3px); padding: 18px;
animation: fade .18s ease;
}
.modal {
width: min(440px, 100%); background: var(--surface); border-radius: var(--r-lg);
box-shadow: var(--sh-lg); animation: pop .22s cubic-bezier(.22, 1, .36, 1);
}
.modal-head { display: flex; align-items: center; justify-content: space-between; padding: 18px 20px 8px; }
.modal-head h2 { font-size: 1.2rem; }
.modal-body { padding: 6px 20px 20px; display: flex; flex-direction: column; gap: 14px; }
.field { display: flex; flex-direction: column; gap: 5px; font-size: .85rem; font-weight: 600; color: var(--ink-2); }
.field em { color: var(--muted); font-style: normal; font-weight: 500; }
.field input, .field select {
font: inherit; font-weight: 500; color: var(--ink); padding: 10px 12px;
border: 1px solid var(--line-2); border-radius: var(--r-sm); background: #fdfbf8;
}
.field input:focus, .field select:focus { border-color: var(--brand); background: #fff; }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 4px; }
/* ---------- toast ---------- */
.toast-wrap { position: fixed; left: 0; right: 0; bottom: 22px; z-index: 80; display: flex; flex-direction: column; align-items: center; gap: 8px; pointer-events: none; }
.toast {
background: var(--ink); color: #fff; padding: 11px 18px; border-radius: 999px;
font-size: .88rem; font-weight: 500; box-shadow: var(--sh-md);
display: flex; align-items: center; gap: 9px; animation: toastIn .25s ease;
}
.toast .ic { width: 18px; height: 18px; border-radius: 50%; display: grid; place-items: center; font-size: .7rem; font-weight: 800; background: var(--ok); color: #fff; flex: none; }
.toast.warn .ic { background: var(--warn); }
@keyframes fade { from { opacity: 0; } }
@keyframes pop { from { opacity: 0; transform: translateY(10px) scale(.97); } }
@keyframes toastIn { from { opacity: 0; transform: translateY(12px); } }
/* ---------- responsive ---------- */
@media (max-width: 900px) {
.grid { grid-template-columns: 1fr; }
.stats { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 520px) {
.topnav { display: none; }
.brand-sub { display: none; }
.btn-accent .none { display: none; }
.hero { padding-top: 24px; }
.stats { grid-template-columns: 1fr 1fr; gap: 10px; }
.stat-num { font-size: 1.6rem; }
.card { padding: 16px; }
.tabs { width: 100%; justify-content: space-between; }
.tab { padding: 7px 10px; }
.shift { grid-template-columns: auto 1fr; }
.shift-cta { grid-column: 1 / -1; flex-direction: row; justify-content: space-between; align-items: center; }
.badge-list { grid-template-columns: 1fr; }
.field-row { grid-template-columns: 1fr; }
}(function () {
"use strict";
// ---------- state ----------
var state = {
lifetimeHours: 109, // hours before this month
monthHours: 18,
mealsPerHour: 12,
monthGoal: 32
};
var upcoming = [
{ id: "u1", d: 21, m: "Jun", title: "Riverside Pantry — distribution", role: "Floor lead", time: "Sat 9:00 AM – 12:00 PM", loc: "Riverside Pantry", hours: 3, status: "confirmed" },
{ id: "u2", d: 24, m: "Jun", title: "Mobile Pantry route", role: "Driver assistant", time: "Tue 1:00 PM – 4:00 PM", loc: "Eastside route", hours: 3, status: "confirmed" },
{ id: "u3", d: 28, m: "Jun", title: "Warehouse sorting", role: "Sorter", time: "Sat 8:00 AM – 11:00 AM", loc: "Central Warehouse", hours: 3, status: "confirmed" }
];
var available = [
{ id: "a1", d: 22, m: "Jun", title: "Community garden harvest", role: "Picker", time: "Sun 7:30 AM – 10:30 AM", loc: "Loma Verde Garden", hours: 3, spots: 4 },
{ id: "a2", d: 25, m: "Jun", title: "Riverside Pantry — meal prep", role: "Kitchen crew", time: "Wed 4:00 PM – 7:00 PM", loc: "Riverside Pantry", hours: 3, spots: 2 },
{ id: "a3", d: 27, m: "Jun", title: "Senior meal delivery", role: "Delivery", time: "Fri 11:00 AM – 1:00 PM", loc: "Hillcrest area", hours: 2, spots: 1 },
{ id: "a4", d: 29, m: "Jun", title: "Donation drive — intake", role: "Greeter", time: "Sun 10:00 AM – 2:00 PM", loc: "Civic Plaza", hours: 4, spots: 0 }
];
var past = [
{ id: "p1", d: 14, m: "Jun", title: "Riverside Pantry — distribution", role: "Floor lead", time: "Sat 9:00 AM – 12:00 PM", loc: "Riverside Pantry", hours: 3 },
{ id: "p2", d: 10, m: "Jun", title: "Warehouse sorting", role: "Sorter", time: "Tue 1:00 PM – 4:00 PM", loc: "Central Warehouse", hours: 3 },
{ id: "p3", d: 7, m: "Jun", title: "Mobile Pantry route", role: "Driver assistant", time: "Sat 8:00 AM – 12:00 PM", loc: "Westend route", hours: 4 }
];
// booked calendar days (this month)
var bookedDays = {};
function syncBookedDays() {
bookedDays = {};
upcoming.forEach(function (s) { if (s.m === "Jun") bookedDays[s.d] = true; });
}
var $ = function (s, r) { return (r || document).querySelector(s); };
var $$ = function (s, r) { return Array.prototype.slice.call((r || document).querySelectorAll(s)); };
// ---------- toast ----------
var toastWrap = $("#toastWrap");
function toast(msg, kind) {
var t = document.createElement("div");
t.className = "toast" + (kind === "warn" ? " warn" : "");
var ic = document.createElement("span");
ic.className = "ic";
ic.textContent = kind === "warn" ? "!" : "✓";
t.appendChild(ic);
t.appendChild(document.createTextNode(msg));
toastWrap.appendChild(t);
setTimeout(function () {
t.style.transition = "opacity .3s, transform .3s";
t.style.opacity = "0";
t.style.transform = "translateY(8px)";
setTimeout(function () { t.remove(); }, 320);
}, 2600);
}
// ---------- animated counters ----------
function animateCount(el, to, opts) {
opts = opts || {};
var from = parseFloat((el.textContent || "0").replace(/[^0-9.]/g, "")) || 0;
var dur = 700, start = null, dec = opts.decimals || 0;
function step(ts) {
if (start === null) start = ts;
var p = Math.min((ts - start) / dur, 1);
var eased = 1 - Math.pow(1 - p, 3);
var val = from + (to - from) * eased;
el.textContent = dec ? val.toFixed(dec) : Math.round(val).toLocaleString("en-US");
if (p < 1) requestAnimationFrame(step);
else el.textContent = dec ? to.toFixed(dec) : Math.round(to).toLocaleString("en-US");
}
requestAnimationFrame(step);
}
function refreshStats(animate) {
var lifetime = state.lifetimeHours + state.monthHours;
var meals = Math.round(lifetime * state.mealsPerHour);
var setters = [
["#hoursTotal", lifetime],
["#hoursMonth", state.monthHours],
["#mealsCount", meals]
];
setters.forEach(function (pair) {
var el = $(pair[0]);
if (!el) return;
if (animate) animateCount(el, pair[1]);
else el.textContent = pair[1].toLocaleString("en-US");
});
$("#shiftCount").textContent = upcoming.length;
$("#leaderMe").textContent = lifetime + " hrs";
// thermometer
var pct = Math.min((state.monthHours / state.monthGoal) * 100, 100);
$("#thermoFill").style.width = pct + "%";
var thermo = $("#thermo");
thermo.setAttribute("aria-valuenow", String(state.monthHours));
if (animate) animateCount($("#goalNow"), state.monthHours);
else $("#goalNow").textContent = state.monthHours;
updateBadges();
}
// ---------- shift rendering ----------
function shiftDateEl(s) {
return '<div class="shift-date"><span class="d">' + s.d + '</span><span class="m">' + s.m + '</span></div>';
}
function shiftBody(s) {
return '<div class="shift-body"><p class="shift-title">' + s.title + '</p>' +
'<p class="shift-meta"><span class="role">' + s.role + '</span><span>' + s.time + '</span><span>• ' + s.loc + '</span></p></div>';
}
function renderUpcoming() {
var p = $("#panel-upcoming");
if (!upcoming.length) { p.innerHTML = '<p class="empty">No upcoming shifts. Claim one from the Available tab!</p>'; return; }
p.innerHTML = upcoming.map(function (s) {
return '<div class="shift" data-id="' + s.id + '">' + shiftDateEl(s) + shiftBody(s) +
'<div class="shift-cta"><span class="tag confirmed">Confirmed</span>' +
'<button class="btn btn-cancel" data-cancel="' + s.id + '">Cancel</button></div></div>';
}).join("");
}
function renderAvailable() {
var p = $("#panel-available");
p.innerHTML = available.map(function (s) {
var full = s.spots <= 0;
var spotCls = full ? "full" : (s.spots <= 2 ? "low" : "");
var spotTxt = full ? "Full" : s.spots + " spot" + (s.spots === 1 ? "" : "s") + " left";
var cta = full
? '<button class="btn btn-ghost" disabled>Full</button>'
: '<button class="btn btn-claim" data-claim="' + s.id + '">Claim shift</button>';
return '<div class="shift' + (full ? " full" : "") + '" data-id="' + s.id + '">' + shiftDateEl(s) + shiftBody(s) +
'<div class="shift-cta"><span class="spots ' + spotCls + '">' + spotTxt + '</span>' + cta + '</div></div>';
}).join("");
}
function renderPast() {
var p = $("#panel-past");
p.innerHTML = past.map(function (s) {
return '<div class="shift" data-id="' + s.id + '">' + shiftDateEl(s) + shiftBody(s) +
'<div class="shift-cta"><span class="tag done">' + s.hours + ' hrs ✓</span></div></div>';
}).join("");
}
function refreshPills() {
$("#pill-upcoming").textContent = upcoming.length;
$("#pill-available").textContent = available.filter(function (s) { return s.spots > 0; }).length;
}
// ---------- claim / cancel ----------
function claimShift(id) {
var idx = available.findIndex(function (s) { return s.id === id; });
if (idx < 0) return;
var s = available[idx];
if (s.spots <= 0) return;
s.spots -= 1;
upcoming.push({ id: s.id, d: s.d, m: s.m, title: s.title, role: s.role, time: s.time, loc: s.loc, hours: s.hours, status: "confirmed" });
upcoming.sort(function (a, b) { return a.d - b.d; });
syncBookedDays();
renderUpcoming(); renderAvailable(); renderPast();
refreshPills(); renderCalendar(); refreshStats(true);
toast('Claimed "' + s.title + '" — see you there!');
}
function cancelShift(id) {
var idx = upcoming.findIndex(function (s) { return s.id === id; });
if (idx < 0) return;
var s = upcoming[idx];
upcoming.splice(idx, 1);
// restore a spot if it came from available pool
var av = available.find(function (a) { return a.id === id; });
if (av) av.spots += 1;
syncBookedDays();
renderUpcoming(); renderAvailable();
refreshPills(); renderCalendar(); refreshStats(true);
toast('Cancelled "' + s.title + '". A spot reopened.', "warn");
}
// event delegation
document.addEventListener("click", function (e) {
var c = e.target.closest("[data-claim]");
if (c) { claimShift(c.getAttribute("data-claim")); return; }
var x = e.target.closest("[data-cancel]");
if (x) { cancelShift(x.getAttribute("data-cancel")); return; }
});
// ---------- tabs ----------
var tabs = $$(".tab");
tabs.forEach(function (tab) {
tab.addEventListener("click", function () { activate(tab); });
tab.addEventListener("keydown", function (e) {
var i = tabs.indexOf(tab);
if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
e.preventDefault();
var n = e.key === "ArrowRight" ? (i + 1) % tabs.length : (i - 1 + tabs.length) % tabs.length;
tabs[n].focus(); activate(tabs[n]);
}
});
});
function activate(tab) {
tabs.forEach(function (t) {
var on = t === tab;
t.classList.toggle("is-active", on);
t.setAttribute("aria-selected", on ? "true" : "false");
var panel = $("#panel-" + t.getAttribute("data-tab"));
if (panel) panel.hidden = !on;
});
}
// ---------- calendar ----------
function renderCalendar() {
var grid = $("#calGrid");
var days = ["S", "M", "T", "W", "T", "F", "S"];
var html = days.map(function (d) { return '<div class="cal-head">' + d + '</div>'; }).join("");
// June 2026 starts on Monday (1st = Mon). offset blanks for Sunday-start grid = 1
var firstWeekday = 1;
var total = 30, today = 17;
for (var b = 0; b < firstWeekday; b++) html += '<div class="cal-cell blank"></div>';
for (var day = 1; day <= total; day++) {
var cls = "cal-cell";
if (bookedDays[day]) cls += " booked";
if (day === today) cls += " today";
html += '<div class="' + cls + '" ' + (bookedDays[day] ? 'title="You have a shift"' : "") + '>' + day + '</div>';
}
grid.innerHTML = html;
}
// ---------- badges ----------
var badges = [
{ ic: "🌱", bg: "rgba(47,158,111,.16)", name: "First Shift", sub: "Completed", need: 1 },
{ ic: "🔥", bg: "rgba(232,116,59,.16)", name: "50 Hours", sub: "Milestone", need: 50 },
{ ic: "🍽️", bg: "rgba(31,122,109,.14)", name: "1,000 Meals", sub: "Impact", need: 84 },
{ ic: "🏆", bg: "rgba(217,138,43,.16)", name: "Century Club", sub: "100 hours", need: 100 }
];
function updateBadges() {
var lifetime = state.lifetimeHours + state.monthHours;
var list = $("#badgeList");
list.innerHTML = badges.map(function (b) {
var earned = lifetime >= b.need;
return '<li class="badge' + (earned ? "" : " locked") + '">' +
'<span class="badge-ic" style="background:' + b.bg + '">' + b.ic + '</span>' +
'<span class="name">' + b.name + '<small>' + (earned ? b.sub : ((b.need - lifetime) + " hrs to go") ) + '</small></span></li>';
}).join("");
}
// ---------- modal ----------
var modal = $("#modal"), lastFocus = null;
function openModal() {
lastFocus = document.activeElement;
var dateEl = $("#logDate");
if (!dateEl.value) dateEl.value = "2026-06-17";
modal.hidden = false;
$("#logActivity").focus();
document.addEventListener("keydown", escClose);
}
function closeModal() {
modal.hidden = true;
document.removeEventListener("keydown", escClose);
if (lastFocus) lastFocus.focus();
}
function escClose(e) { if (e.key === "Escape") closeModal(); }
$("#logHoursBtn").addEventListener("click", openModal);
$("#modalClose").addEventListener("click", closeModal);
$("#modalCancel").addEventListener("click", closeModal);
modal.addEventListener("click", function (e) { if (e.target === modal) closeModal(); });
$("#logForm").addEventListener("submit", function (e) {
e.preventDefault();
var hrs = parseFloat($("#logHours").value) || 0;
if (hrs <= 0) { toast("Enter a valid number of hours.", "warn"); return; }
var activity = $("#logActivity").value;
state.monthHours += hrs;
refreshStats(true);
closeModal();
toast("Logged " + (hrs % 1 ? hrs.toFixed(1) : hrs) + " hrs — " + activity.split("—")[0].trim() + ". Thank you!");
$("#logForm").reset();
});
// ---------- init ----------
syncBookedDays();
renderUpcoming(); renderAvailable(); renderPast();
refreshPills(); renderCalendar();
refreshStats(true);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Helping Hands — Volunteer Portal</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=Fraunces:opsz,wght@9..144,500;9..144,600;9..144,700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
<header class="topbar">
<div class="wrap topbar-in">
<div class="brand">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21s-7.5-4.6-9.7-9A5 5 0 0 1 12 6.5 5 5 0 0 1 21.7 12C19.5 16.4 12 21 12 21z"/></svg>
</span>
<span class="brand-name">Helping Hands</span>
<span class="brand-sub">Volunteer Portal</span>
</div>
<nav class="topnav" aria-label="Primary">
<a href="#shifts" class="topnav-link">Find shifts</a>
<a href="#schedule" class="topnav-link">Schedule</a>
<a href="#impact" class="topnav-link">My impact</a>
</nav>
<div class="top-actions">
<button class="btn btn-accent" id="logHoursBtn" type="button">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Log hours
</button>
<span class="avatar" title="Maya Okonkwo" aria-hidden="true">MO</span>
</div>
</div>
</header>
<main id="main" class="wrap">
<section class="hero">
<div>
<p class="eyebrow">Welcome back</p>
<h1>Hi Maya, you've made a difference this month.</h1>
<p class="hero-lead">Thanks to volunteers like you, the Riverside Pantry served
<strong>4,820 meals</strong> this month. You're <strong>6 hours</strong> from your
next badge.</p>
</div>
<div class="hero-badges">
<span class="trust-badge"><span class="dot" aria-hidden="true"></span> Verified volunteer</span>
<span class="trust-badge">Registered charity #88-2041</span>
</div>
</section>
<section class="stats" aria-label="Your volunteering summary">
<article class="stat">
<p class="stat-label">Hours logged</p>
<p class="stat-num"><span id="hoursTotal" data-count>0</span><span class="stat-unit">hrs</span></p>
<p class="stat-foot">Lifetime total</p>
</article>
<article class="stat">
<p class="stat-label">This month</p>
<p class="stat-num"><span id="hoursMonth" data-count>0</span><span class="stat-unit">hrs</span></p>
<p class="stat-foot">Goal 32 hrs</p>
</article>
<article class="stat">
<p class="stat-label">Upcoming shifts</p>
<p class="stat-num"><span id="shiftCount">3</span></p>
<p class="stat-foot">Next: Sat 9:00 AM</p>
</article>
<article class="stat accent">
<p class="stat-label">Meals served</p>
<p class="stat-num"><span id="mealsCount" data-count>0</span></p>
<p class="stat-foot">Through your shifts</p>
</article>
</section>
<div class="grid">
<div class="col-main">
<section class="card" aria-labelledby="shiftsHead">
<div class="card-head">
<h2 id="shiftsHead">Shifts</h2>
<div class="tabs" role="tablist" aria-label="Shift views">
<button class="tab is-active" role="tab" aria-selected="true" id="tab-upcoming" data-tab="upcoming">Upcoming <span class="pill" id="pill-upcoming">3</span></button>
<button class="tab" role="tab" aria-selected="false" id="tab-available" data-tab="available">Available <span class="pill" id="pill-available">4</span></button>
<button class="tab" role="tab" aria-selected="false" id="tab-past" data-tab="past">Past</button>
</div>
</div>
<div class="tabpanel" id="panel-upcoming" role="tabpanel" aria-labelledby="tab-upcoming"></div>
<div class="tabpanel" id="panel-available" role="tabpanel" aria-labelledby="tab-available" hidden></div>
<div class="tabpanel" id="panel-past" role="tabpanel" aria-labelledby="tab-past" hidden></div>
</section>
<section class="card" id="schedule" aria-labelledby="schedHead">
<div class="card-head">
<h2 id="schedHead">June schedule</h2>
<span class="legend"><span class="legend-dot booked"></span> Your shift</span>
</div>
<div class="cal-grid" id="calGrid" aria-hidden="false"></div>
</section>
</div>
<aside class="col-side">
<section class="card progress-card" id="impact" aria-labelledby="goalHead">
<h2 id="goalHead" class="side-head">Monthly goal</h2>
<div class="thermo" role="progressbar" aria-valuemin="0" aria-valuemax="32" aria-valuenow="0" id="thermo">
<div class="thermo-fill" id="thermoFill"></div>
</div>
<p class="thermo-cap"><strong id="goalNow">0</strong> of 32 hours this month</p>
</section>
<section class="card badges-card" aria-labelledby="badgeHead">
<h2 id="badgeHead" class="side-head">Badges earned</h2>
<ul class="badge-list" id="badgeList"></ul>
</section>
<section class="card team-card" aria-labelledby="teamHead">
<h2 id="teamHead" class="side-head">Top volunteers</h2>
<ol class="leader">
<li><span class="rank">1</span><span class="la" aria-hidden="true">DR</span> Diego Ramos <em>148 hrs</em></li>
<li><span class="rank">2</span><span class="la" aria-hidden="true">SP</span> Sara Petrova <em>132 hrs</em></li>
<li class="me"><span class="rank">3</span><span class="la" aria-hidden="true">MO</span> You <em id="leaderMe">0 hrs</em></li>
<li><span class="rank">4</span><span class="la" aria-hidden="true">KW</span> Kwame Boateng <em>96 hrs</em></li>
</ol>
</section>
</aside>
</div>
</main>
<footer class="foot">
<div class="wrap foot-in">
<p>Helping Hands Foundation — a fictional community pantry network. Illustrative UI only.</p>
<p class="foot-trust">Tax ID 88-2041 · Volunteer hours are self-reported and reviewed by coordinators.</p>
</div>
</footer>
<!-- Log hours modal -->
<div class="modal-back" id="modal" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="modal-head">
<h2 id="modalTitle">Log volunteer hours</h2>
<button class="icon-btn" id="modalClose" type="button" aria-label="Close">×</button>
</div>
<form id="logForm" class="modal-body">
<label class="field">
<span>Activity</span>
<select id="logActivity" required>
<option>Riverside Pantry — meal prep</option>
<option>Riverside Pantry — distribution</option>
<option>Mobile Pantry route</option>
<option>Warehouse sorting</option>
<option>Community garden</option>
</select>
</label>
<div class="field-row">
<label class="field">
<span>Date</span>
<input type="date" id="logDate" required />
</label>
<label class="field">
<span>Hours</span>
<input type="number" id="logHours" min="0.5" max="12" step="0.5" value="3" required />
</label>
</div>
<label class="field">
<span>Notes <em>(optional)</em></span>
<input type="text" id="logNotes" placeholder="e.g. Packed 120 family boxes" maxlength="80" />
</label>
<div class="modal-actions">
<button type="button" class="btn btn-ghost" id="modalCancel">Cancel</button>
<button type="submit" class="btn btn-accent">Submit hours</button>
</div>
</form>
</div>
</div>
<div class="toast-wrap" id="toastWrap" aria-live="polite" aria-atomic="true"></div>
<script src="script.js"></script>
</body>
</html>Volunteer Portal
A polished self-service portal for Helping Hands Foundation, a fictional community pantry network. The dashboard greets volunteer Maya Okonkwo with a warm hero, transparency-focused impact stats (lifetime hours, this-month hours, upcoming shifts, and meals served), and trust badges for the registered charity. A monthly-goal thermometer animates up to her 32-hour target, and a badges grid plus a top-volunteers leaderboard add gentle recognition and motivation.
The shifts card is the interactive heart: keyboard-navigable tabs switch between Upcoming, Available, and Past views. Claiming an available shift decrements its remaining spots, moves it into your upcoming list, marks the day on the June schedule calendar, and recalculates hours and meals with animated counters; cancelling reopens the spot. A Log hours button opens an accessible modal (focus-managed, Escape-to-close) where submitting an activity, date, and hours updates every total live. Every action fires a friendly toast confirmation.
It is built with plain HTML, CSS, and vanilla JavaScript — no frameworks or build step — using a warm sand-and-teal palette, Fraunces and Inter typography, and a layout that reflows cleanly down to 360px.
Illustrative UI only — fictional organization, not a real charity or donation system.