Files

1512 lines
83 KiB
HTML
Raw Permalink Normal View History

2026-06-18 13:46:17 -07:00
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#0F1923">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>StepMates — Walk Together</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=Space+Grotesk:wght@400;500;600;700&family=Inter:wght@400;500&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0F1923; --surface: #182433; --surface2: #1E2E40; --border: #2A3D52;
--teal: #00C9B1; --teal-dim: rgba(0,201,177,0.12); --teal-glow: rgba(0,201,177,0.25);
--amber: #FFB347; --white: #F8F9FA; --muted: #8899A6; --danger: #FF6B6B; --success: #4ECA8B;
--font-display: 'Space Grotesk', sans-serif; --font-body: 'Inter', sans-serif;
--radius: 12px; --radius-lg: 20px;
2026-06-18 13:46:17 -07:00
}
html, body { height: 100%; background: var(--bg); color: var(--white); font-family: var(--font-body); font-size: 15px; line-height: 1.5; -webkit-font-smoothing: antialiased; }
#app { min-height: 100vh; display: flex; flex-direction: column; }
2026-06-18 13:46:17 -07:00
.screen { display: none; flex-direction: column; min-height: 100vh; }
.screen.active { display: flex; }
nav { display: flex; align-items: center; justify-content: space-between; padding: 16px 24px; border-bottom: 1px solid var(--border); background: var(--bg); position: sticky; top: 0; z-index: 100; }
.nav-logo { font-family: var(--font-display); font-weight: 700; font-size: 20px; color: var(--teal); letter-spacing: -0.5px; }
2026-06-18 13:46:17 -07:00
.nav-logo span { color: var(--white); }
.avatar { width: 36px; height: 36px; border-radius: 50%; background: var(--teal-dim); border: 1.5px solid var(--teal); display: flex; align-items: center; justify-content: center; font-family: var(--font-display); font-weight: 700; font-size: 14px; color: var(--teal); cursor: pointer; overflow: hidden; flex-shrink: 0; }
.avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
.container { width: 100%; max-width: 680px; margin: 0 auto; padding: 0 20px; }
2026-06-18 13:46:17 -07:00
/* AUTH */
#screen-auth { align-items: center; justify-content: center; background: radial-gradient(ellipse at 50% 0%, rgba(0,201,177,0.08) 0%, transparent 60%), var(--bg); padding: 40px 20px; }
.auth-box { width: 100%; max-width: 420px; text-align: center; }
.auth-logo { font-family: var(--font-display); font-size: 42px; font-weight: 700; color: var(--teal); letter-spacing: -1.5px; line-height: 1; margin-bottom: 6px; }
2026-06-18 13:46:17 -07:00
.auth-logo span { color: var(--white); }
.auth-tagline { color: var(--muted); font-size: 15px; margin-bottom: 48px; }
.auth-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 32px; text-align: left; }
.auth-card h2 { font-family: var(--font-display); font-size: 22px; font-weight: 600; margin-bottom: 6px; }
.auth-card p { color: var(--muted); font-size: 14px; margin-bottom: 24px; }
2026-06-18 13:46:17 -07:00
/* FORMS */
label { display: block; font-size: 11px; font-weight: 500; color: var(--muted); margin-bottom: 6px; letter-spacing: 0.3px; text-transform: uppercase; }
input[type="email"], input[type="text"], input[type="number"] { width: 100%; background: var(--bg); border: 1.5px solid var(--border); border-radius: var(--radius); color: var(--white); font-family: var(--font-body); font-size: 15px; padding: 12px 16px; outline: none; transition: border-color 0.15s; margin-bottom: 16px; }
input:focus { border-color: var(--teal); }
2026-06-18 13:46:17 -07:00
input::placeholder { color: var(--muted); opacity: 0.6; }
select { background: var(--surface); border: 1.5px solid var(--border); border-radius: var(--radius); color: var(--white); font-family: var(--font-body); font-size: 14px; padding: 11px 12px; outline: none; cursor: pointer; }
2026-06-18 13:46:17 -07:00
/* BUTTONS */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 12px 22px; border-radius: var(--radius); font-family: var(--font-display); font-size: 15px; font-weight: 600; cursor: pointer; border: none; transition: opacity 0.15s, transform 0.1s; text-decoration: none; min-height: 46px; }
2026-06-18 13:46:17 -07:00
.btn:active { transform: scale(0.98); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-primary { background: var(--teal); color: var(--bg); width: 100%; }
2026-06-18 13:46:17 -07:00
.btn-primary:hover:not(:disabled) { opacity: 0.9; }
.btn-ghost { background: transparent; color: var(--muted); border: 1.5px solid var(--border); }
2026-06-18 13:46:17 -07:00
.btn-ghost:hover { border-color: var(--teal); color: var(--teal); }
2026-06-25 10:49:46 -07:00
.btn-google { background: #fff; color: #3c4043; border: 1.5px solid #dadce0; font-family: var(--font-body); font-size: 15px; font-weight: 500; width: 100%; margin-bottom: 4px; gap: 10px; }
.btn-google:hover { background: #f8f9fa; border-color: #c6c6c6; }
.btn-danger { background: transparent; color: var(--danger); border: 1.5px solid var(--danger); }
.btn-danger:hover { background: rgba(255,107,107,0.1); }
.btn-sm { padding: 7px 14px; font-size: 13px; min-height: 38px; }
2026-06-18 13:46:17 -07:00
/* ALERTS */
.info-box { background: var(--teal-dim); border: 1px solid var(--teal-glow); border-radius: var(--radius); padding: 14px 16px; font-size: 14px; color: var(--teal); margin-bottom: 20px; line-height: 1.5; }
.error-msg { color: var(--danger); font-size: 13px; margin-top: -10px; margin-bottom: 14px; }
/* ONBOARDING */
#screen-onboarding { align-items: center; justify-content: center; padding: 40px 20px; background: radial-gradient(ellipse at 50% 0%, rgba(0,201,177,0.06) 0%, transparent 60%), var(--bg); }
.onboarding-box { width: 100%; max-width: 480px; }
.step-indicator { display: flex; gap: 6px; margin-bottom: 32px; }
.step-dot { height: 3px; flex: 1; border-radius: 2px; background: var(--border); transition: background 0.3s; }
2026-06-18 13:46:17 -07:00
.step-dot.done { background: var(--teal); }
.onboarding-step { display: none; }
.onboarding-step.active { display: block; }
.onboarding-step h2 { font-family: var(--font-display); font-size: 26px; font-weight: 700; margin-bottom: 8px; letter-spacing: -0.5px; }
.onboarding-step .sub { color: var(--muted); font-size: 15px; margin-bottom: 28px; }
2026-06-18 13:46:17 -07:00
/* BRIDGE CARDS */
.bridge-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 20px; }
.bridge-card { background: var(--surface); border: 1.5px solid var(--border); border-radius: var(--radius); padding: 16px; text-decoration: none; color: var(--white); transition: border-color 0.15s; display: flex; flex-direction: column; gap: 8px; }
2026-06-18 13:46:17 -07:00
.bridge-card:hover { border-color: var(--teal); }
.bridge-card .bc-icon { font-size: 28px; }
.bridge-card .bc-name { font-family: var(--font-display); font-weight: 600; font-size: 14px; }
.bridge-card .bc-platform { font-size: 12px; color: var(--muted); }
.bridge-card .bc-badge { font-size: 11px; background: var(--teal-dim); color: var(--teal); padding: 2px 8px; border-radius: 20px; width: fit-content; }
2026-06-18 13:46:17 -07:00
/* WEBHOOK */
.webhook-box { background: var(--bg); border: 1.5px solid var(--border); border-radius: var(--radius); padding: 14px 16px; margin-bottom: 20px; }
.webhook-url-row { display: flex; gap: 8px; align-items: center; }
.webhook-url { font-family: monospace; font-size: 12px; color: var(--teal); background: var(--surface2); padding: 8px 12px; border-radius: 8px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border: 1px solid var(--border); }
.copy-btn { background: var(--teal-dim); border: 1px solid var(--teal-glow); color: var(--teal); border-radius: 8px; padding: 8px 12px; cursor: pointer; font-size: 13px; font-weight: 600; transition: background 0.15s; white-space: nowrap; font-family: var(--font-display); }
2026-06-18 13:46:17 -07:00
.copy-btn:hover { background: var(--teal-glow); }
.copy-btn.copied { color: var(--success); border-color: var(--success); background: rgba(78,202,139,0.1); }
/* DASHBOARD */
#screen-dashboard { background: var(--bg); }
.dashboard-content { padding: 28px 0 40px; }
2026-06-18 13:46:17 -07:00
/* MY STEPS CARD */
.my-steps-card { background: linear-gradient(135deg, var(--surface) 0%, var(--surface2) 100%); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 28px; margin-bottom: 24px; position: relative; overflow: hidden; }
.my-steps-card::before { content: ''; position: absolute; top: -40px; right: -40px; width: 160px; height: 160px; background: radial-gradient(circle, var(--teal-glow) 0%, transparent 70%); pointer-events: none; }
.msc-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.8px; color: var(--muted); margin-bottom: 6px; }
.msc-steps { font-family: var(--font-display); font-size: 56px; font-weight: 700; color: var(--teal); letter-spacing: -2px; line-height: 1; margin-bottom: 4px; }
.msc-sub { color: var(--muted); font-size: 13px; margin-bottom: 20px; }
.msc-bar-wrap { background: var(--bg); border-radius: 4px; height: 6px; overflow: hidden; }
.msc-bar { height: 100%; background: linear-gradient(90deg, var(--teal), #00e5cc); border-radius: 4px; transition: width 1s cubic-bezier(0.16, 1, 0.3, 1); width: 0%; }
.msc-goal { display: flex; justify-content: space-between; font-size: 12px; color: var(--muted); margin-top: 6px; }
2026-06-18 13:46:17 -07:00
/* TIME TABS */
.time-tabs { display: flex; gap: 6px; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 4px; margin-bottom: 24px; }
.tab-btn { flex: 1; padding: 8px 4px; border-radius: 7px; border: none; background: transparent; color: var(--muted); font-family: var(--font-display); font-size: 13px; font-weight: 600; cursor: pointer; transition: all 0.15s; min-height: 38px; }
.tab-btn.active { background: var(--teal); color: var(--bg); }
2026-06-18 13:46:17 -07:00
/* SECTION */
.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.section-title { font-family: var(--font-display); font-size: 17px; font-weight: 600; }
2026-06-18 13:46:17 -07:00
/* LEADERBOARD ROW */
.lb-row { display: flex; align-items: center; gap: 12px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; margin-bottom: 8px; cursor: pointer; transition: border-color 0.15s; }
.lb-row:hover { border-color: var(--teal-glow); }
.lb-row.is-me { border-color: var(--teal); background: var(--teal-dim); }
.lb-rank { font-family: var(--font-display); font-weight: 700; font-size: 18px; color: var(--muted); width: 28px; text-align: center; flex-shrink: 0; }
.lb-rank.top1 { color: #FFD700; }
.lb-rank.top2 { color: #C0C0C0; }
.lb-rank.top3 { color: #CD7F32; }
.lb-avatar { width: 40px; height: 40px; border-radius: 50%; background: var(--teal-dim); border: 1.5px solid var(--border); display: flex; align-items: center; justify-content: center; font-family: var(--font-display); font-weight: 700; font-size: 16px; color: var(--teal); flex-shrink: 0; overflow: hidden; }
.lb-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
.lb-info { flex: 1; min-width: 0; }
.lb-name { font-family: var(--font-display); font-weight: 600; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lb-sub { font-size: 12px; color: var(--muted); margin-top: 1px; }
.lb-steps { font-family: var(--font-display); font-weight: 700; font-size: 20px; letter-spacing: -0.5px; flex-shrink: 0; }
2026-06-18 13:46:17 -07:00
/* FRIENDS MANAGE */
.friend-row { display: flex; align-items: center; gap: 12px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; margin-bottom: 8px; }
.friend-row.pending { background: rgba(255,179,71,0.06); border-color: rgba(255,179,71,0.2); }
.friend-row input[type="checkbox"] { width: 18px; height: 18px; accent-color: var(--teal); cursor: pointer; flex-shrink: 0; margin-bottom: 0; }
.friend-row.selected { border-color: var(--teal); background: var(--teal-dim); }
.fr-date { font-size: 11px; color: var(--muted); white-space: nowrap; flex-shrink: 0; }
.fr-info { flex: 1; min-width: 0; cursor: pointer; }
.fr-name { font-family: var(--font-display); font-weight: 600; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fr-nick { font-size: 12px; color: var(--teal); margin-top: 1px; }
.fr-status { font-size: 12px; color: var(--amber); margin-top: 1px; }
/* PENDING CARD */
.pending-card { background: rgba(255,179,71,0.06); border: 1px solid rgba(255,179,71,0.2); border-radius: var(--radius); padding: 14px 16px; display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
2026-06-18 13:46:17 -07:00
.pending-card .pending-info { flex: 1; }
.pending-card .pending-name { font-weight: 600; font-size: 14px; }
.pending-card .pending-sub { font-size: 12px; color: var(--muted); }
.pending-actions { display: flex; gap: 8px; }
/* SETUP REMIND */
.setup-remind { background: linear-gradient(135deg, rgba(255,179,71,0.08), rgba(255,179,71,0.03)); border: 1px solid rgba(255,179,71,0.2); border-radius: var(--radius); padding: 16px 18px; margin-bottom: 20px; display: flex; align-items: center; gap: 14px; }
2026-06-18 13:46:17 -07:00
.setup-remind .sr-icon { font-size: 26px; flex-shrink: 0; }
.setup-remind .sr-text h4 { font-family: var(--font-display); font-weight: 600; font-size: 14px; margin-bottom: 2px; }
2026-06-18 13:46:17 -07:00
.setup-remind .sr-text p { font-size: 13px; color: var(--muted); }
.setup-remind .sr-link { color: var(--amber); cursor: pointer; font-weight: 600; text-decoration: underline; font-size: 13px; white-space: nowrap; flex-shrink: 0; background: none; border: none; }
2026-06-18 13:46:17 -07:00
/* MANUAL ENTRY */
.manual-entry { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; margin-bottom: 20px; }
.manual-entry h3 { font-family: var(--font-display); font-size: 14px; font-weight: 600; margin-bottom: 4px; }
2026-06-18 13:46:17 -07:00
.manual-entry p { font-size: 13px; color: var(--muted); margin-bottom: 14px; }
.manual-row { display: flex; gap: 8px; }
.manual-row input { margin-bottom: 0; flex: 1; }
.manual-row .btn { width: auto; }
/* HAMBURGER */
.hamburger-btn { width: 38px; height: 38px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 5px; cursor: pointer; background: transparent; border: none; padding: 4px; border-radius: 8px; transition: background 0.15s; }
.hamburger-btn:hover { background: var(--surface2); }
.hamburger-btn span { display: block; width: 20px; height: 2px; background: var(--white); border-radius: 2px; transition: all 0.2s; }
.hamburger-btn.open span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
.hamburger-btn.open span:nth-child(2) { opacity: 0; }
.hamburger-btn.open span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
.menu-overlay { position: fixed; inset: 0; z-index: 150; pointer-events: none; }
.menu-overlay.open { pointer-events: all; }
.menu-backdrop { position: absolute; inset: 0; background: rgba(0,0,0,0.5); opacity: 0; transition: opacity 0.2s; }
.menu-overlay.open .menu-backdrop { opacity: 1; }
.menu-drawer { position: absolute; top: 0; right: 0; bottom: 0; width: 240px; background: var(--surface); border-left: 1px solid var(--border); display: flex; flex-direction: column; transform: translateX(100%); transition: transform 0.25s cubic-bezier(0.16,1,0.3,1); padding: 0 0 max(24px, env(safe-area-inset-bottom)); }
.menu-overlay.open .menu-drawer { transform: translateX(0); }
.menu-header { padding: 20px 20px 16px; border-bottom: 1px solid var(--border); margin-bottom: 8px; }
.menu-header .menu-user-name { font-family: var(--font-display); font-weight: 700; font-size: 16px; }
.menu-header .menu-user-email { font-size: 12px; color: var(--muted); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.menu-item { display: flex; align-items: center; gap: 14px; padding: 14px 20px; cursor: pointer; background: transparent; border: none; color: var(--white); font-family: var(--font-body); font-size: 15px; width: 100%; text-align: left; transition: background 0.15s; position: relative; }
.menu-item:hover { background: var(--surface2); }
.menu-item.active { color: var(--teal); }
.menu-item .mi-icon { font-size: 20px; width: 24px; text-align: center; }
.menu-item .mi-badge { margin-left: auto; background: var(--danger); color: white; font-size: 10px; font-weight: 700; border-radius: 10px; padding: 2px 7px; display: none; }
.menu-item .mi-badge.show { display: block; }
.menu-divider { height: 1px; background: var(--border); margin: 8px 20px; }
.menu-spacer { flex: 1; }
/* PANELS */
.panel { display: none; }
.panel.active { display: block; }
/* MODAL */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 500; display: flex; align-items: flex-end; justify-content: center; opacity: 0; pointer-events: none; transition: opacity 0.2s; }
.modal-overlay.open { opacity: 1; pointer-events: all; }
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg) var(--radius-lg) 0 0; padding: 24px 24px max(24px, env(safe-area-inset-bottom)); width: 100%; max-width: 520px; transform: translateY(40px); transition: transform 0.3s cubic-bezier(0.16,1,0.3,1); }
.modal-overlay.open .modal { transform: translateY(0); }
.modal-handle { width: 36px; height: 4px; background: var(--border); border-radius: 2px; margin: 0 auto 20px; }
.modal h3 { font-family: var(--font-display); font-size: 20px; font-weight: 700; margin-bottom: 20px; }
.modal-actions { display: flex; gap: 10px; margin-top: 4px; flex-wrap: wrap; }
.modal-actions .btn { flex: 1; }
/* PROFILE MODAL AVATAR */
.modal-avatar-preview { display: flex; align-items: center; gap: 16px; margin-bottom: 20px; }
.avatar-lg { width: 64px; height: 64px; border-radius: 50%; border: 2px solid var(--teal); background: var(--teal-dim); display: flex; align-items: center; justify-content: center; font-family: var(--font-display); font-weight: 700; font-size: 24px; color: var(--teal); overflow: hidden; flex-shrink: 0; }
.avatar-lg img { width: 100%; height: 100%; object-fit: cover; }
/* GOAL SLIDER */
.goal-display { font-family: var(--font-display); font-size: 52px; font-weight: 700; color: var(--teal); letter-spacing: -2px; line-height: 1; text-align: center; margin-bottom: 4px; }
/* EMPTY STATE */
.empty-state { text-align: center; padding: 48px 24px; color: var(--muted); }
.empty-state .es-icon { font-size: 40px; margin-bottom: 12px; }
.empty-state h3 { font-family: var(--font-display); font-size: 17px; font-weight: 600; color: var(--white); margin-bottom: 6px; }
.empty-state p { font-size: 14px; }
/* MAGIC LINK SENT */
.magic-sent { text-align: center; padding: 8px 0; }
.magic-sent .big-icon { font-size: 48px; margin-bottom: 16px; }
.magic-sent h3 { font-family: var(--font-display); font-size: 20px; font-weight: 600; margin-bottom: 8px; }
.magic-sent p { color: var(--muted); font-size: 14px; line-height: 1.6; }
.magic-sent .resend-link { color: var(--teal); cursor: pointer; text-decoration: underline; font-size: 14px; margin-top: 20px; display: inline-block; }
/* TOAST */
#toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(80px); background: var(--surface2); border: 1px solid var(--border); color: var(--white); padding: 12px 20px; border-radius: 30px; font-size: 14px; font-weight: 500; z-index: 9999; transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1); white-space: nowrap; pointer-events: none; }
#toast.show { transform: translateX(-50%) translateY(0); }
#toast.success { border-color: var(--teal); color: var(--teal); }
#toast.error { border-color: var(--danger); color: var(--danger); }
/* PTR */
.ptr-indicator { position: fixed; top: 0; left: 50%; transform: translateX(-50%) translateY(-48px); background: var(--surface2); border: 1px solid var(--border); border-radius: 20px; padding: 8px 16px; font-size: 13px; color: var(--teal); z-index: 200; transition: transform 0.2s; pointer-events: none; white-space: nowrap; }
.ptr-indicator.visible { transform: translateX(-50%) translateY(12px); }
/* SPINNER */
.spinner { display: inline-block; width: 18px; height: 18px; border: 2px solid rgba(255,255,255,0.2); border-top-color: currentColor; border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.loader-full { display: flex; align-items: center; justify-content: center; flex: 1; flex-direction: column; gap: 16px; color: var(--muted); font-size: 14px; }
.loader-full .spinner { width: 32px; height: 32px; border-width: 3px; color: var(--teal); }
/* SAFE AREA */
.dashboard-content { padding-bottom: 40px; }
/* RESPONSIVE */
2026-06-18 13:46:17 -07:00
@media (max-width: 480px) {
.bridge-cards { grid-template-columns: 1fr; }
.msc-steps { font-size: 44px; }
.auth-card { padding: 24px; }
.time-tabs { gap: 3px; }
.tab-btn { font-size: 12px; padding: 8px 2px; }
}
</style>
</head>
<body>
<div id="app">
<!-- LOADING -->
<div id="screen-loading" class="screen active">
<div class="loader-full"><div class="spinner"></div>Loading…</div>
2026-06-18 13:46:17 -07:00
</div>
<!-- AUTH -->
<div id="screen-auth" class="screen">
<div class="auth-box">
<div class="auth-logo">Step<span>Mates</span></div>
<p class="auth-tagline">Walk more. Compete with friends.</p>
<div class="auth-card" id="auth-card">
<h2>Welcome</h2>
2026-06-25 10:49:46 -07:00
<p>Sign in or create an account to get started.</p>
2026-06-25 11:06:25 -07:00
<button class="btn btn-google" id="google-signin-btn" onclick="signInWithGoogle()" style="display:none">
2026-06-25 10:49:46 -07:00
<svg width="18" height="18" viewBox="0 0 18 18" style="flex-shrink:0"><path fill="#4285F4" d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.875 2.684-6.615z"/><path fill="#34A853" d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.258c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332C2.438 15.983 5.482 18 9 18z"/><path fill="#FBBC05" d="M3.964 10.707c-.18-.54-.282-1.117-.282-1.707s.102-1.167.282-1.707V4.961H.957C.347 6.175 0 7.55 0 9s.348 2.825.957 4.039l3.007-2.332z"/><path fill="#EA4335" d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0 5.482 0 2.438 2.017.957 4.961L3.964 7.293C4.672 5.166 6.656 3.58 9 3.58z"/></svg>
Sign in with Google
</button>
2026-06-25 11:06:25 -07:00
<div id="google-divider" style="display:none;align-items:center;gap:12px;margin:16px 0">
2026-06-25 10:49:46 -07:00
<div style="flex:1;height:1px;background:var(--border)"></div>
<span style="font-size:12px;color:var(--muted)">or</span>
<div style="flex:1;height:1px;background:var(--border)"></div>
</div>
2026-06-18 13:46:17 -07:00
<label for="auth-email">Email address</label>
<input type="email" id="auth-email" placeholder="you@example.com" autocomplete="email">
<div class="error-msg" id="auth-error" style="display:none"></div>
<button class="btn btn-primary" id="auth-submit-btn" onclick="sendMagicLink()">Continue with Email</button>
2026-06-18 13:46:17 -07:00
</div>
<div class="auth-card" id="magic-sent-card" style="display:none">
<div class="magic-sent">
<div class="big-icon">✉️</div>
<h3>Check your email</h3>
<p>We sent a sign-in link to <strong id="magic-sent-email"></strong>.<br>Tap the link in the email to continue.</p>
<span class="resend-link" onclick="showAuthForm()">← Use a different email</span>
</div>
</div>
</div>
</div>
<!-- CONFIRM EMAIL -->
<div id="screen-confirm" class="screen">
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;padding:40px 24px;background:radial-gradient(ellipse at 50% 0%,rgba(0,201,177,0.08) 0%,transparent 60%),var(--bg)">
<div style="width:100%;max-width:400px">
<div style="font-size:48px;text-align:center;margin-bottom:20px">✉️</div>
<h2 style="font-family:var(--font-display);font-size:24px;font-weight:700;margin-bottom:8px;text-align:center">Confirm your email</h2>
<p style="color:var(--muted);font-size:15px;text-align:center;margin-bottom:28px;line-height:1.6">You opened this link on a different device. Enter your email to complete sign-in.</p>
<label for="confirm-email-input">Email address</label>
<input type="email" id="confirm-email-input" placeholder="you@example.com" autocomplete="email">
<div class="error-msg" id="confirm-email-error" style="display:none"></div>
<button class="btn btn-primary" id="confirm-email-btn" onclick="confirmEmailSignIn()">Sign in</button>
</div>
</div>
</div>
2026-06-18 13:46:17 -07:00
<!-- PENDING APPROVAL -->
<div id="screen-pending" class="screen">
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;padding:40px 24px;text-align:center;background:radial-gradient(ellipse at 50% 0%,rgba(0,201,177,0.06) 0%,transparent 60%),var(--bg)">
<div style="font-size:52px;margin-bottom:20px">⏳</div>
<h2 style="font-family:var(--font-display);font-size:24px;font-weight:700;margin-bottom:10px">Waiting for approval</h2>
<p style="color:var(--muted);font-size:15px;max-width:320px;line-height:1.6;margin-bottom:32px">Your account request has been sent to the administrator. You'll receive an email when your account is approved.</p>
2026-06-18 13:46:17 -07:00
<button class="btn btn-ghost" onclick="signOut()" style="width:auto">Use a different email</button>
</div>
</div>
<!-- ONBOARDING -->
<div id="screen-onboarding" class="screen">
<div class="onboarding-box" style="padding:40px 20px;margin:0 auto">
2026-06-18 13:46:17 -07:00
<div class="step-indicator">
<div class="step-dot done" id="dot-0"></div>
<div class="step-dot" id="dot-1"></div>
<div class="step-dot" id="dot-2"></div>
</div>
<div class="onboarding-step active" id="ob-step-0">
<h2>What should friends call you?</h2>
<p class="sub">Choose a display name. This is what your friends will see.</p>
<label for="ob-name">Display name</label>
<input type="text" id="ob-name" placeholder="e.g. Alex or RunnerAlex" maxlength="30">
<div class="error-msg" id="ob-name-error" style="display:none"></div>
<button class="btn btn-primary" onclick="obNext(0)">Continue</button>
</div>
<div class="onboarding-step" id="ob-step-1">
<h2>Sync your steps automatically</h2>
<p class="sub">Download the free bridge app for your phone.</p>
2026-06-18 13:46:17 -07:00
<div class="bridge-cards">
<a href="https://apps.apple.com/us/app/health-auto-export-json-csv/id1115567069" target="_blank" class="bridge-card">
<div class="bc-icon">🍎</div><div class="bc-name">Health Auto Export</div>
<div class="bc-platform">iPhone · iOS 16+</div><div class="bc-badge">Free tier</div>
2026-06-18 13:46:17 -07:00
</a>
<a href="https://github.com/JustinOros/stepmates-sync/releases/download/v1.1/StepMates-Sync-Android.apk" target="_blank" class="bridge-card">
<div class="bc-icon">🤖</div><div class="bc-name">StepMates Sync</div>
<div class="bc-platform">Android 9+</div><div class="bc-badge">Free & open source</div>
2026-06-18 13:46:17 -07:00
</a>
</div>
<div class="info-box">💡 Once installed, the app will ask for a Webhook URL — you'll get yours on the next screen.</div>
2026-06-18 13:46:17 -07:00
<button class="btn btn-primary" onclick="obNext(1)">I've downloaded it</button>
<br><br>
<button class="btn btn-ghost" style="width:100%" onclick="obSkip()">Skip for now — I'll enter steps manually</button>
</div>
<div class="onboarding-step" id="ob-step-2">
<h2>Your personal sync URL</h2>
<p class="sub">Copy this URL and paste it into the bridge app.</p>
2026-06-18 13:46:17 -07:00
<div class="webhook-box">
<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.5px;color:var(--muted);margin-bottom:8px">Your Webhook URL</div>
2026-06-18 13:46:17 -07:00
<div class="webhook-url-row">
<div class="webhook-url" id="ob-webhook-url">Generating…</div>
<button class="copy-btn" id="ob-copy-btn" onclick="copyWebhook('ob-webhook-url','ob-copy-btn')">Copy</button>
</div>
</div>
<div style="background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius);padding:14px 16px;font-size:13px;color:var(--muted);line-height:1.8;margin-bottom:16px">
<strong style="color:var(--white)">iPhone (Health Auto Export):</strong><br>
Automations → New Automation → Webhook → paste URL → select Steps → set schedule Daily<br><br>
<strong style="color:var(--white)">Android (StepMates Sync):</strong><br>
Download and install the app → paste your Webhook URL → tap Sync
2026-06-18 13:46:17 -07:00
</div>
<button class="btn btn-primary" onclick="finishOnboarding()">Go to my dashboard →</button>
</div>
</div>
</div>
<!-- DASHBOARD -->
<div id="screen-dashboard" class="screen">
<nav>
<div class="nav-logo">Step<span>Mates</span></div>
<div style="display:flex;align-items:center;gap:10px">
<div class="avatar" id="nav-avatar" onclick="openProfileModal()" title="Edit profile"><span id="nav-avatar-initial">?</span></div>
<button class="hamburger-btn" id="hamburger-btn" onclick="toggleMenu()" aria-label="Menu">
<span></span><span></span><span></span>
</button>
2026-06-18 13:46:17 -07:00
</div>
</nav>
<!-- SLIDE-OUT MENU -->
<div class="menu-overlay" id="menu-overlay">
<div class="menu-backdrop" onclick="closeMenu()"></div>
<div class="menu-drawer">
<div class="menu-header">
<div class="menu-user-name" id="menu-user-name">—</div>
<div class="menu-user-email" id="menu-user-email">—</div>
</div>
<button class="menu-item active" id="mi-home" onclick="switchPanel('home');closeMenu()"><span class="mi-icon">👟</span> My Steps</button>
<button class="menu-item" id="mi-friends" onclick="switchPanel('friends');closeMenu()">
<span class="mi-icon">👥</span> Friends
<span class="mi-badge" id="mi-friends-badge"></span>
</button>
<button class="menu-item" id="mi-settings" onclick="switchPanel('settings');closeMenu()"><span class="mi-icon">⚙️</span> Setup & Sync</button>
<div class="menu-divider"></div>
<button class="menu-item" onclick="openProfileModal();closeMenu()"><span class="mi-icon">✏️</span> Edit Profile</button>
<div class="menu-spacer"></div>
<div class="menu-divider"></div>
<button class="menu-item" onclick="signOut()" style="color:var(--muted)"><span class="mi-icon">🚪</span> Sign out</button>
</div>
</div>
2026-06-18 13:46:17 -07:00
<div class="container dashboard-content">
<!-- TIME TABS -->
<div class="time-tabs">
<button class="tab-btn active" onclick="setTab('day',this)">Today</button>
<button class="tab-btn" onclick="setTab('week',this)">Week</button>
<button class="tab-btn" onclick="setTab('month',this)">Month</button>
<button class="tab-btn" onclick="setTab('year',this)">Year</button>
<button class="tab-btn" onclick="setTab('alltime',this)">All Time</button>
</div>
<!-- PANEL: STEPS -->
2026-06-18 13:46:17 -07:00
<div id="panel-home" class="panel active">
<div class="my-steps-card">
<div class="msc-label">My Steps — <span id="tab-label">Today</span></div>
<div class="msc-steps" id="my-step-count">—</div>
<div class="msc-sub" id="my-step-sub">Loading…</div>
<div class="msc-bar-wrap"><div class="msc-bar" id="my-step-bar"></div></div>
<div class="msc-goal">
<span>0</span>
<span id="goal-label" onclick="openGoalModal()" style="cursor:pointer;text-decoration:underline;text-underline-offset:3px;text-decoration-style:dotted">Goal: 5,000</span>
</div>
</div>
<div class="setup-remind" id="setup-remind" style="display:none">
<div class="sr-icon">📱</div>
<div class="sr-text"><h4>Sync your steps</h4><p>You haven't synced yet. Set up auto-sync or log manually.</p></div>
<button class="sr-link" onclick="switchPanel('settings');closeMenu()">Set up →</button>
</div>
<div class="manual-entry" id="manual-entry-panel" style="display:none">
<h3>Log today's steps manually</h3>
<p>No bridge app yet? Enter your steps from your phone's Health app.</p>
<div class="manual-row">
<input type="text" id="manual-step-input" placeholder="e.g. 8432" inputmode="numeric">
<button class="btn btn-primary btn-sm" onclick="logManualSteps()">Log</button>
</div>
</div>
</div>
2026-06-18 13:46:17 -07:00
<!-- PANEL: FRIENDS LEADERBOARD -->
<div id="panel-friends" class="panel">
<!-- Pending incoming requests -->
<div id="pending-received-section" style="display:none;margin-bottom:20px">
<div class="section-header"><div class="section-title">Friend Requests</div></div>
2026-06-18 13:46:17 -07:00
<div id="pending-received-list"></div>
</div>
<!-- Leaderboard -->
<div class="section-header">
<div class="section-title">Leaderboard</div>
<button class="btn btn-primary btn-sm" onclick="openInviteModal()" style="width:auto;gap:6px"><span style="font-size:18px;line-height:1">+</span> Add Friend</button>
</div>
<div id="leaderboard-list"></div>
<div id="lb-show-more" style="display:none;text-align:center;margin-top:16px">
<button class="btn btn-ghost" onclick="showMoreLb()" style="width:auto">Show More…</button>
</div>
2026-06-18 13:46:17 -07:00
</div>
<!-- PANEL: SETTINGS -->
2026-06-18 13:46:17 -07:00
<div id="panel-settings" class="panel">
<div class="section-header"><div class="section-title">Sync Setup</div></div>
<div class="info-box" style="margin-bottom:20px">Paste your Webhook URL into the bridge app on your phone.</div>
2026-06-18 13:46:17 -07:00
<div style="margin-bottom:8px;font-size:13px;font-weight:500;color:var(--muted);text-transform:uppercase;letter-spacing:0.5px">Your Webhook URL</div>
<div class="webhook-box" style="margin-bottom:24px">
<div class="webhook-url-row">
<div class="webhook-url" id="settings-webhook-url">Loading…</div>
<button class="copy-btn" id="settings-copy-btn" onclick="copyWebhook('settings-webhook-url','settings-copy-btn')">Copy</button>
</div>
</div>
<div style="background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius);padding:16px;font-size:13px;color:var(--muted);line-height:1.9;margin-bottom:28px">
<strong style="color:var(--white)">iPhone — Health Auto Export:</strong><br>
1. Download <a href="https://apps.apple.com/us/app/health-auto-export-json-csv/id1115567069" target="_blank" style="color:var(--teal)">Health Auto Export</a> from the App Store<br>
2. Automations → New Automation → Webhook → paste your URL above<br>
3. Select <em>Steps</em> → set Date Range to <em>Default</em> → set schedule to <em>Daily</em><br><br>
<strong style="color:var(--white)">Android — StepMates Sync App:</strong><br>
1. Download <a href="https://github.com/JustinOros/stepmates-sync/releases/download/v1.1/StepMates-Sync-Android.apk" target="_blank" style="color:var(--teal)">StepMates Sync Android App</a><br>
2. Install it (allow unknown sources when prompted)<br>
3. Paste your Webhook URL above into the app → tap Sync
2026-06-18 13:46:17 -07:00
</div>
<div class="section-header"><div class="section-title">Bridge App Downloads</div></div>
2026-06-18 13:46:17 -07:00
<div class="bridge-cards" style="margin-bottom:32px">
<a href="https://apps.apple.com/us/app/health-auto-export-json-csv/id1115567069" target="_blank" class="bridge-card">
<div class="bc-icon">🍎</div><div class="bc-name">Health Auto Export</div><div class="bc-platform">iPhone · iOS 16+</div><div class="bc-badge">Free tier</div>
2026-06-18 13:46:17 -07:00
</a>
<a href="https://github.com/JustinOros/stepmates-sync/releases/download/v1.1/StepMates-Sync-Android.apk" target="_blank" class="bridge-card">
<div class="bc-icon">🤖</div><div class="bc-name">StepMates Sync</div><div class="bc-platform">Android 9+</div><div class="bc-badge">Free & open source</div>
2026-06-18 13:46:17 -07:00
</a>
</div>
<div class="section-header"><div class="section-title">Account</div></div>
2026-06-18 13:46:17 -07:00
<div style="background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px 18px;margin-bottom:16px">
<div style="display:flex;align-items:center;gap:14px">
<div class="avatar" style="width:48px;height:48px;font-size:18px;cursor:default" id="settings-avatar-display"><span id="settings-avatar-initial">?</span></div>
2026-06-18 13:46:17 -07:00
<div style="flex:1;min-width:0">
<div style="font-family:var(--font-display);font-weight:600;font-size:15px" id="settings-display-name-show">—</div>
<div style="font-size:13px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap" id="settings-email"></div>
</div>
<button class="btn btn-ghost btn-sm" onclick="openProfileModal()">Edit</button>
</div>
</div>
<button class="btn btn-ghost" style="width:100%" onclick="signOut()">Sign out</button>
</div>
</div>
</div>
<!-- FRIEND PROFILE SCREEN -->
<div id="screen-friend-profile" class="screen" style="background:var(--bg)">
<nav>
<button class="btn btn-ghost btn-sm" onclick="closeFriendProfile()" style="width:auto;gap:6px">← Back</button>
<div class="nav-logo">Step<span>Mates</span></div>
<div style="width:80px"></div>
</nav>
<div class="container" style="padding-top:32px;padding-bottom:40px;max-width:480px">
<div style="text-align:center;margin-bottom:32px">
<div id="fp-avatar" class="avatar-lg" style="margin:0 auto 16px"></div>
</div>
<div style="background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;margin-bottom:24px">
<div style="display:flex;justify-content:space-between;align-items:center;padding:14px 18px;border-bottom:1px solid var(--border)">
<span style="color:var(--muted);font-size:14px">Display Name</span>
<span id="fp-name-text" onclick="openNicknameModal()" style="font-size:14px;font-weight:600;cursor:pointer;color:var(--teal);text-decoration:underline;text-underline-offset:3px;text-decoration-style:dotted">—</span>
</div>
<div style="display:flex;justify-content:space-between;padding:14px 18px;border-bottom:1px solid var(--border)">
<span style="color:var(--muted);font-size:14px">Date Joined</span>
<span style="font-size:14px;font-weight:500" id="fp-joined">—</span>
</div>
<div style="display:flex;justify-content:space-between;padding:14px 18px">
<span style="color:var(--muted);font-size:14px">Last Synced</span>
<span style="font-size:14px;font-weight:500" id="fp-last-synced">—</span>
</div>
</div>
<div style="display:flex;gap:12px">
<button class="btn btn-danger" style="flex:1" onclick="removeFriendFromProfile()">Remove Friend</button>
<button class="btn btn-primary" style="flex:1" onclick="closeFriendProfile()">OK</button>
</div>
</div>
</div>
2026-06-18 13:46:17 -07:00
</div><!-- /app -->
<!-- MODALS -->
<!-- FRIEND REQUEST -->
<div class="modal-overlay" id="invite-modal" onclick="closeInviteModalOutside(event)">
<div class="modal">
<div class="modal-handle"></div>
<h3>Add a Friend</h3>
<p style="font-size:14px;color:var(--muted);margin-bottom:20px;line-height:1.6">Enter your friend's email address. They'll receive an email with a sign-in link and your friend request.</p>
<label for="invite-email-input">Friend's email address</label>
<input type="email" id="invite-email-input" placeholder="friend@example.com" autocomplete="off">
<div class="error-msg" id="invite-email-error" style="display:none"></div>
<div class="modal-actions">
<button class="btn btn-ghost" onclick="closeInviteModal()">Cancel</button>
<button class="btn btn-primary" id="invite-send-btn" onclick="sendFriendRequestByEmail()">Send Request</button>
</div>
</div>
</div>
2026-06-18 14:12:29 -07:00
<!-- NICKNAME -->
<div class="modal-overlay" id="nickname-modal" onclick="closeNicknameModalOutside(event)">
<div class="modal">
<div class="modal-handle"></div>
<h3>Set Nickname</h3>
<p style="font-size:14px;color:var(--muted);margin-bottom:20px">Only you see this. Leave blank to use their display name.</p>
<label for="nickname-input">Nickname</label>
<input type="text" id="nickname-input" placeholder="e.g. John from Accounting" maxlength="30">
<div class="modal-actions">
<button class="btn btn-ghost" onclick="closeNicknameModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveNickname()">Save</button>
</div>
</div>
</div>
<!-- PROFILE -->
<div class="modal-overlay" id="profile-modal" onclick="closeProfileModalOutside(event)">
<div class="modal">
<div class="modal-handle"></div>
<h3>Edit Profile</h3>
<div class="modal-avatar-preview">
<div class="avatar-lg" id="profile-modal-avatar"><span id="profile-modal-initial">?</span></div>
<div>
<div style="font-family:var(--font-display);font-weight:600;font-size:16px;margin-bottom:2px" id="profile-modal-name-preview">Your Name</div>
<div style="font-size:13px;color:var(--muted)">This is how friends see you</div>
</div>
</div>
<label for="profile-name-input">Display name</label>
<input type="text" id="profile-name-input" placeholder="e.g. Alex or RunnerAlex" maxlength="30">
<label>Avatar Emoji</label>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:4px">
<div id="emoji-preview" style="width:48px;height:48px;border-radius:50%;background:var(--surface-2);border:2px solid var(--teal);display:flex;align-items:center;justify-content:center;font-size:26px;flex-shrink:0">🏃</div>
<input type="text" id="profile-avatar-input" placeholder="Click here, then press the emoji key (⊞+. on Windows, Ctrl+Cmd+Space on Mac, or emoji keyboard on mobile)" style="flex:1;font-size:24px;text-align:center" oninput="updateEmojiPreview(this.value)">
</div>
<div style="font-size:12px;color:var(--muted);margin-bottom:8px">Mac: <strong>Ctrl+Cmd+Space</strong> · Windows: <strong>Win+.</strong> · Mobile: switch to emoji keyboard</div>
<div class="error-msg" id="profile-error" style="display:none"></div>
<div class="modal-actions">
<button class="btn btn-ghost" onclick="closeProfileModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveProfile()">Save changes</button>
</div>
</div>
</div>
<!-- GOAL -->
<div class="modal-overlay" id="goal-modal" onclick="closeGoalModalOutside(event)">
<div class="modal">
<div class="modal-handle"></div>
<h3>Daily Step Goal</h3>
<div style="text-align:center;margin-bottom:24px">
<div class="goal-display" id="goal-modal-display">5,000</div>
<div style="font-size:13px;color:var(--muted);margin-top:4px">steps per day</div>
</div>
<div style="padding:0 4px;margin-bottom:24px">
<input type="range" id="goal-slider" min="1000" max="100000" step="1000" value="5000" style="width:100%;accent-color:var(--teal);height:6px;cursor:pointer">
<div style="display:flex;justify-content:space-between;font-size:11px;color:var(--muted);margin-top:6px"><span>1,000</span><span>100,000</span></div>
</div>
<div style="display:flex;gap:8px;margin-bottom:8px">
<button class="btn btn-ghost btn-sm" style="flex:1" onclick="setGoalPreset(5000)">5K</button>
<button class="btn btn-ghost btn-sm" style="flex:1" onclick="setGoalPreset(7500)">7.5K</button>
<button class="btn btn-ghost btn-sm" style="flex:1" onclick="setGoalPreset(10000)">10K</button>
<button class="btn btn-ghost btn-sm" style="flex:1" onclick="setGoalPreset(15000)">15K</button>
<button class="btn btn-ghost btn-sm" style="flex:1" onclick="setGoalPreset(20000)">20K</button>
</div>
<div class="modal-actions" style="margin-top:12px">
<button class="btn btn-ghost" onclick="closeGoalModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveGoal()">Save goal</button>
</div>
</div>
</div>
<div id="ptr-indicator" class="ptr-indicator">↓ Refreshing…</div>
2026-06-18 13:46:17 -07:00
<div id="toast"></div>
<script src="config.js"></script>
<script type="module">
import { initializeApp } from 'https://www.gstatic.com/firebasejs/10.12.0/firebase-app.js';
import { getAuth, isSignInWithEmailLink, signInWithEmailLink, signInWithPopup, signInWithRedirect, getRedirectResult, GoogleAuthProvider, signOut as fbSignOut, onAuthStateChanged } from 'https://www.gstatic.com/firebasejs/10.12.0/firebase-auth.js';
import { getFirestore, doc, getDoc, setDoc, updateDoc, deleteField, collection, query, where, getDocs, addDoc, onSnapshot, serverTimestamp } from 'https://www.gstatic.com/firebasejs/10.12.0/firebase-firestore.js';
2026-06-18 13:46:17 -07:00
if (!window.STEPMATES_CONFIG) {
document.body.innerHTML = `<div style="font-family:sans-serif;padding:40px;max-width:500px;margin:0 auto;color:#f8f9fa;background:#0f1923;min-height:100vh"><h2 style="color:#00C9B1;margin-bottom:16px">⚙️ Setup required</h2><p style="margin-bottom:12px">Copy <code>config.example.js</code> to <code>config.js</code> and fill in your Firebase credentials.</p></div>`;
throw new Error('STEPMATES_CONFIG not found.');
2026-06-18 13:46:17 -07:00
}
const FIREBASE_CONFIG = window.STEPMATES_CONFIG.firebase;
const WEBHOOK_BASE = window.STEPMATES_CONFIG.webhookBase;
const APP_URL = window.location.origin + window.location.pathname.replace(/\/$/, '');
const fbApp = initializeApp(FIREBASE_CONFIG);
const auth = getAuth(fbApp);
const db = getFirestore(fbApp);
2026-06-25 11:06:25 -07:00
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
if (!isIOS) {
document.getElementById('google-signin-btn').style.display = 'flex';
document.getElementById('google-divider').style.display = 'flex';
}
2026-06-25 11:06:25 -07:00
2026-06-18 13:46:17 -07:00
let currentUser = null;
let currentUserData = null;
let currentProfileData = null;
let currentStepsData = null;
2026-06-18 13:46:17 -07:00
let currentTab = 'day';
let friendsData = {};
let friendProfiles = {};
let friendSteps = {};
let lbVisibleCount = 10;
let friendsVisibleCount = 10;
let currentFriendUid = null;
let unsubscribers = [];
const TAB_LABELS = { day: 'Today', week: 'This Week', month: 'This Month', year: 'This Year', alltime: 'All Time' };
const GOALS = (d) => ({ day: d, week: d*7, month: d*30, year: d*365, alltime: null });
2026-06-18 13:46:17 -07:00
function showScreen(id) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById('screen-' + id).classList.add('active');
}
window.showScreen = showScreen;
function generateToken() {
return Array.from(crypto.getRandomValues(new Uint8Array(20))).map(b => b.toString(16).padStart(2,'0')).join('');
2026-06-18 13:46:17 -07:00
}
function buildWebhookUrl(token) {
return `${WEBHOOK_BASE}?token=${token}`;
2026-06-18 13:46:17 -07:00
}
function getDailyGoal() { return currentUserData?.dailyGoal || 5000; }
function getStepsForPeriod(stepsMap, period) {
const today = new Date();
let total = 0;
for (const [k, v] of Object.entries(stepsMap || {})) {
const parts = k.split('-');
const d = new Date(parseInt(parts[0]), parseInt(parts[1])-1, parseInt(parts[2]));
if (period === 'day') { if (isSameDay(d,today)) total += v; }
else if (period === 'week') { if (isSameWeek(d,today)) total += v; }
else if (period === 'month') { if (d.getMonth()===today.getMonth()&&d.getFullYear()===today.getFullYear()) total += v; }
else if (period === 'year') { if (d.getFullYear()===today.getFullYear()) total += v; }
else total += v;
}
return total;
}
function isSameDay(a,b) { return a.getFullYear()===b.getFullYear()&&a.getMonth()===b.getMonth()&&a.getDate()===b.getDate(); }
function isSameWeek(d,today) {
const start = new Date(today); start.setDate(today.getDate()-today.getDay()); start.setHours(0,0,0,0);
const end = new Date(start); end.setDate(start.getDate()+7);
return d>=start&&d<end;
}
function todayKey() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
}
function esc(str) { return (str||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
2026-06-28 19:57:17 -07:00
function isEmoji(str) {
if (!str) return false;
const cp = str.codePointAt(0);
return (cp >= 0x1F300 && cp <= 0x1FAFF) || (cp >= 0x2600 && cp <= 0x27BF) || (cp >= 0xFE00 && cp <= 0xFE0F);
}
function avatarHtml(profile, size=40, nickname='') {
const displayName = profile?.displayName || 'No Display Name';
const initial = (nickname || displayName)[0].toUpperCase();
const url = profile?.avatarUrl || '';
2026-06-28 19:57:17 -07:00
if (url && isEmoji(url)) return `<div class="lb-avatar" style="width:${size}px;height:${size}px;font-size:${Math.round(size*0.55)}px">${url}</div>`;
if (url && url.startsWith('http')) return `<div class="lb-avatar" style="width:${size}px;height:${size}px"><img src="${esc(url)}" alt="${initial}" onerror="this.parentElement.innerHTML='🏃'"></div>`;
return `<div class="lb-avatar" style="width:${size}px;height:${size}px;font-size:${Math.round(size*0.55)}px">🏃</div>`;
}
function getFriendDisplayName(uid) {
const nick = friendsData[uid]?.nickname || '';
const displayName = friendProfiles[uid]?.displayName || 'No Display Name';
return nick || displayName;
}
2026-06-25 11:14:37 -07:00
let _initializingUser = false;
// AUTH
2026-06-25 11:06:25 -07:00
onAuthStateChanged(auth, async (user) => {
2026-06-25 11:14:37 -07:00
if (_initializingUser) return;
2026-06-18 13:46:17 -07:00
if (isSignInWithEmailLink(auth, window.location.href)) {
const urlParams = new URLSearchParams(window.location.search);
const email = urlParams.get('email') || localStorage.getItem('emailForSignIn');
2026-06-18 13:46:17 -07:00
if (!email) {
showScreen('confirm');
document.getElementById('confirm-email-input').focus();
return;
2026-06-18 13:46:17 -07:00
}
try {
const result = await signInWithEmailLink(auth, email, window.location.href);
localStorage.removeItem('emailForSignIn');
window.history.replaceState({}, document.title, APP_URL);
await initUser(result.user);
return;
} catch(e) {
showScreen('auth');
2026-06-20 23:09:44 -07:00
window.history.replaceState({}, document.title, APP_URL);
2026-06-18 13:46:17 -07:00
return;
}
}
if (user) { currentUser = user; await initUser(user); }
else showScreen('auth');
2026-06-18 13:46:17 -07:00
});
window.confirmEmailSignIn = async function() {
const email = document.getElementById('confirm-email-input').value.trim();
const errEl = document.getElementById('confirm-email-error');
const btn = document.getElementById('confirm-email-btn');
errEl.style.display = 'none';
if (!email || !/\S+@\S+\.\S+/.test(email)) { errEl.textContent = 'Please enter a valid email address.'; errEl.style.display = 'block'; return; }
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span>';
try {
const result = await signInWithEmailLink(auth, email, window.location.href);
window.history.replaceState({}, document.title, APP_URL);
await initUser(result.user);
} catch(e) {
errEl.textContent = "That email doesn't match this sign-in link. Please try again.";
errEl.style.display = 'block'; btn.disabled = false; btn.innerHTML = 'Sign in';
}
}
2026-06-18 13:46:17 -07:00
async function initUser(user) {
2026-06-25 11:14:37 -07:00
_initializingUser = true;
2026-06-18 13:46:17 -07:00
currentUser = user;
const userRef = doc(db, 'users', user.uid);
const snap = await getDoc(userRef);
2026-06-18 13:46:17 -07:00
if (!snap.exists()) {
const webhookToken = generateToken();
await setDoc(userRef, {
email: user.email,
webhookToken,
approved: true,
dailyGoal: 5000,
createdAt: serverTimestamp()
2026-06-18 13:46:17 -07:00
});
await setDoc(doc(db, 'profiles', user.uid), { displayName: user.displayName || '', avatarUrl: '🏃' });
await setDoc(doc(db, 'steps', user.uid), { stepsByDate: {} });
currentUserData = { email: user.email, webhookToken, approved: true, dailyGoal: 5000 };
const profileSnap = await getDoc(doc(db, 'profiles', user.uid));
currentProfileData = profileSnap.exists() ? profileSnap.data() : {};
currentStepsData = {};
loadDashboard();
2026-06-18 13:46:17 -07:00
} else {
currentUserData = snap.data();
if (!currentUserData.setupComplete && currentUserData.approved === false) {
await updateDoc(userRef, { approved: true });
currentUserData.approved = true;
2026-06-18 13:46:17 -07:00
}
const profileSnap = await getDoc(doc(db, 'profiles', user.uid));
currentProfileData = profileSnap.exists() ? profileSnap.data() : {};
const stepsSnap = await getDoc(doc(db, 'steps', user.uid));
currentStepsData = stepsSnap.exists() ? stepsSnap.data().stepsByDate || {} : {};
loadDashboard();
const pendingCount = getPendingFriendUids().length;
if (pendingCount > 0) switchPanel('friends');
2026-06-18 13:46:17 -07:00
}
}
async function notifyAdminOfNewUser(email, uid) {
// Admin approval removed - auto-approval enabled
2026-06-18 13:46:17 -07:00
}
// ONBOARDING
let obStepIndex = 0;
window.obNext = async function(from) {
if (from === 0) {
const name = document.getElementById('ob-name').value.trim();
const errEl = document.getElementById('ob-name-error');
if (!name || name.length < 2) { errEl.textContent = 'Please enter at least 2 characters.'; errEl.style.display = 'block'; return; }
2026-06-18 13:46:17 -07:00
errEl.style.display = 'none';
await updateDoc(doc(db, 'profiles', currentUser.uid), { displayName: name });
currentProfileData = { ...currentProfileData, displayName: name };
2026-06-18 13:46:17 -07:00
}
goToObStep(from + 1);
}
window.obSkip = function() { goToObStep(2); }
2026-06-18 13:46:17 -07:00
function goToObStep(idx) {
document.getElementById('ob-step-' + obStepIndex).classList.remove('active');
document.getElementById('dot-' + obStepIndex).classList.add('done');
obStepIndex = idx;
const next = document.getElementById('ob-step-' + idx);
if (next) next.classList.add('active');
if (idx < 3) document.getElementById('dot-' + idx)?.classList.add('done');
}
window.finishOnboarding = async function() {
await updateDoc(doc(db, 'users', currentUser.uid), { setupComplete: true });
currentUserData.setupComplete = true;
loadDashboard();
}
// DASHBOARD
function teardown() { unsubscribers.forEach(fn => fn()); unsubscribers = []; }
window._debug = { get friendsData() { return friendsData; }, get friendProfiles() { return friendProfiles; } };
2026-06-18 23:31:55 -07:00
2026-06-18 13:46:17 -07:00
function loadDashboard() {
showScreen('dashboard');
refreshProfileUI();
document.getElementById('settings-email').textContent = currentUser.email;
document.getElementById('settings-webhook-url').textContent = buildWebhookUrl(currentUserData.webhookToken);
renderMySteps();
checkSetupReminder();
loadFriendsRealtime();
2026-06-18 13:46:17 -07:00
}
function refreshProfileUI() {
const name = currentProfileData?.displayName || 'No Display Name';
2026-06-18 13:46:17 -07:00
const initial = name[0].toUpperCase();
const url = currentProfileData?.avatarUrl || '';
document.getElementById('menu-user-name').textContent = name;
document.getElementById('menu-user-email').textContent = currentUser.email;
setAvatarEl(document.getElementById('nav-avatar'), 'nav-avatar-initial', initial, url, 36);
setAvatarEl(document.getElementById('settings-avatar-display'), 'settings-avatar-initial', initial, url, 48);
document.getElementById('settings-display-name-show').textContent = name;
2026-06-18 13:46:17 -07:00
}
function setAvatarEl(wrapEl, initialId, initial, url, size) {
2026-06-28 19:57:17 -07:00
if (url && isEmoji(url)) wrapEl.innerHTML = `<span style="font-size:${Math.round(size*0.55)}px">${url}</span>`;
else if (url && url.startsWith('http')) wrapEl.innerHTML = `<img src="${esc(url)}" alt="${initial}" onerror="this.parentElement.innerHTML='🏃'">`;
else wrapEl.innerHTML = `<span style="font-size:${Math.round(size*0.55)}px">🏃</span>`;
2026-06-18 13:46:17 -07:00
}
function checkSetupReminder() {
const hasSteps = Object.keys(currentStepsData || {}).length > 0;
document.getElementById('setup-remind').style.display = hasSteps ? 'none' : 'flex';
document.getElementById('manual-entry-panel').style.display = hasSteps ? 'none' : 'block';
2026-06-18 13:46:17 -07:00
}
// STEPS
2026-06-18 13:46:17 -07:00
function renderMySteps() {
const steps = getStepsForPeriod(currentStepsData, currentTab);
2026-06-18 13:46:17 -07:00
document.getElementById('my-step-count').textContent = steps.toLocaleString();
document.getElementById('tab-label').textContent = TAB_LABELS[currentTab];
const goal = GOALS(getDailyGoal())[currentTab];
2026-06-18 13:46:17 -07:00
if (goal) {
const pct = Math.min(100, Math.round((steps/goal)*100));
2026-06-18 13:46:17 -07:00
document.getElementById('my-step-bar').style.width = pct + '%';
document.getElementById('my-step-sub').textContent = pct + '% of goal';
document.getElementById('goal-label').textContent = 'Goal: ' + goal.toLocaleString();
} else {
document.getElementById('my-step-bar').style.width = '100%';
document.getElementById('my-step-sub').textContent = 'All time total';
document.getElementById('goal-label').textContent = '';
}
}
window.setTab = function(tab, el) {
currentTab = tab;
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
el.classList.add('active');
renderMySteps();
renderLeaderboard();
}
2026-06-18 13:46:17 -07:00
window.logManualSteps = async function() {
const val = parseInt(document.getElementById('manual-step-input').value.replace(/\D/g,''));
if (!val || val < 1) { showToast('Enter a valid step count', 'error'); return; }
const today = todayKey();
await updateDoc(doc(db, 'steps', currentUser.uid), { [`stepsByDate.${today}`]: val });
currentStepsData = { ...currentStepsData, [today]: val };
2026-06-18 13:46:17 -07:00
document.getElementById('manual-step-input').value = '';
renderMySteps();
checkSetupReminder();
2026-06-18 13:46:17 -07:00
showToast('Steps logged ✓', 'success');
}
// FRIENDS REALTIME
function loadFriendsRealtime() {
teardown();
let firstLoad = true;
const myFriendsRef = collection(db, 'friends', currentUser.uid, 'list');
const unsubFriends = onSnapshot(myFriendsRef, async (snap) => {
friendsData = {};
friendProfiles = {};
friendSteps = {};
snap.forEach(d => { friendsData[d.id] = d.data(); });
const approvedUids = Object.entries(friendsData)
2026-06-28 20:33:45 -07:00
.filter(([uid, data]) => data.status === 'active')
.map(([uid]) => uid);
const pendingUids = Object.entries(friendsData)
.filter(([uid, data]) => data.status === 'pending' && data.initiator !== 'me')
.map(([uid]) => uid);
await loadFriendProfilesAndSteps(approvedUids);
for (const uid of pendingUids) {
if (!friendProfiles[uid]) {
const pSnap = await getDoc(doc(db, 'profiles', uid));
friendProfiles[uid] = pSnap.exists() ? pSnap.data() : {};
}
}
renderLeaderboard();
renderPendingRequests();
renderManageFriends();
updateBadge();
if (firstLoad) {
firstLoad = false;
if (approvedUids.length > 0) switchPanel('friends');
}
});
unsubscribers.push(unsubFriends);
2026-06-18 13:46:17 -07:00
const unsubMySteps = onSnapshot(doc(db, 'steps', currentUser.uid), snap => {
if (snap.exists()) currentStepsData = snap.data().stepsByDate || {};
2026-06-18 14:12:29 -07:00
renderMySteps();
checkSetupReminder();
renderLeaderboard();
}, err => console.warn('Steps listener error:', err.code));
unsubscribers.push(unsubMySteps);
2026-06-18 13:46:17 -07:00
}
function getMutualFriendUids() {
return Object.entries(friendsData)
2026-06-28 20:33:45 -07:00
.filter(([uid, data]) => data.status === 'active')
.map(([uid]) => uid);
}
function getPendingFriendUids() {
return Object.entries(friendsData)
.filter(([uid, data]) => data.status === 'pending' && data.initiator !== 'me')
.map(([uid]) => uid);
}
async function loadFriendProfilesAndSteps(uids) {
for (const uid of uids) {
if (!friendProfiles[uid]) {
const pSnap = await getDoc(doc(db, 'profiles', uid));
friendProfiles[uid] = pSnap.exists() ? pSnap.data() : {};
}
if (!friendSteps[uid]) {
const sSnap = await getDoc(doc(db, 'steps', uid));
friendSteps[uid] = sSnap.exists() ? sSnap.data().stepsByDate || {} : {};
}
2026-06-18 13:46:17 -07:00
}
}
// LEADERBOARD
function renderLeaderboard() {
const el = document.getElementById('leaderboard-list');
const mutualUids = getMutualFriendUids();
const entries = [
{ uid: currentUser.uid, profile: currentProfileData, steps: getStepsForPeriod(currentStepsData, currentTab), isMe: true },
...mutualUids.map(uid => ({
uid, profile: friendProfiles[uid] || {}, steps: getStepsForPeriod(friendSteps[uid] || {}, currentTab), isMe: false
}))
].sort((a,b) => b.steps - a.steps);
if (entries.length === 0) {
el.innerHTML = `<div class="empty-state"><div class="es-icon">👣</div><h3>No friends yet</h3><p>Invite friends to compete with you.</p></div>`;
document.getElementById('lb-show-more').style.display = 'none';
2026-06-18 13:46:17 -07:00
return;
}
const visible = entries.slice(0, lbVisibleCount);
el.innerHTML = visible.map((e, i) => {
const rank = i + 1;
const rankClass = rank===1?'top1':rank===2?'top2':rank===3?'top3':'';
const name = e.isMe ? (currentProfileData?.displayName || 'Me') : getFriendDisplayName(e.uid);
const nick = e.isMe ? '' : (friendsData[e.uid]?.nickname || '');
const avatar = avatarHtml(e.profile, 40, nick);
2026-06-18 13:46:17 -07:00
return `
2026-06-20 23:11:17 -07:00
<div class="lb-row${e.isMe?' is-me':''}" ${e.isMe ? '' : `data-uid="${e.uid}"`} style="${e.isMe?'cursor:default':''}">
<div class="lb-rank ${rankClass}">${rank}</div>
${avatar}
<div class="lb-info">
<div class="lb-name">${esc(name)}${e.isMe?' (you)':''}</div>
<div class="lb-sub">${TAB_LABELS[currentTab]}</div>
2026-06-18 13:46:17 -07:00
</div>
<div class="lb-steps" style="${e.isMe?'color:var(--teal)':''}">${e.steps.toLocaleString()}</div>
2026-06-18 13:46:17 -07:00
</div>`;
}).join('');
2026-06-20 23:11:17 -07:00
el.querySelectorAll('.lb-row[data-uid]').forEach(row => {
row.addEventListener('click', () => openFriendProfile(row.dataset.uid));
});
document.getElementById('lb-show-more').style.display = entries.length > lbVisibleCount ? 'block' : 'none';
2026-06-18 13:46:17 -07:00
}
window.showMoreLb = function() { lbVisibleCount += 10; renderLeaderboard(); }
// MANAGE FRIENDS
function renderPendingRequests() {
const pendingUids = getPendingFriendUids();
2026-06-18 13:46:17 -07:00
const prSection = document.getElementById('pending-received-section');
const prList = document.getElementById('pending-received-list');
if (!prSection || !prList) return;
if (pendingUids.length > 0) {
2026-06-18 13:46:17 -07:00
prSection.style.display = 'block';
prList.innerHTML = pendingUids.map(uid => {
const name = friendProfiles[uid]?.displayName || 'No Display Name';
return `
<div class="pending-card">
<div class="pending-info">
<div class="pending-name">${esc(name)}</div>
<div class="pending-sub">wants to be your StepMate</div>
</div>
<div class="pending-actions">
<button class="btn btn-primary btn-sm" onclick="approveFriend('${uid}')">Accept</button>
<button class="btn btn-ghost btn-sm" onclick="denyFriend('${uid}')">Decline</button>
</div>
</div>`;
}).join('');
2026-06-18 13:46:17 -07:00
} else {
prSection.style.display = 'none';
}
}
function renderManageFriends() {
const fml = document.getElementById('friends-manage-list');
if (!fml) return;
2026-06-18 13:46:17 -07:00
const search = (document.getElementById('friends-search')?.value || '').toLowerCase();
const mutualUids = getMutualFriendUids();
let filtered = mutualUids.filter(uid => {
const name = getFriendDisplayName(uid).toLowerCase();
const display = (friendProfiles[uid]?.displayName || '').toLowerCase();
return !search || name.includes(search) || display.includes(search);
});
filtered.sort((a,b) => (friendsData[b]?.addedAt?.toMillis?.() || 0) - (friendsData[a]?.addedAt?.toMillis?.() || 0));
if (filtered.length === 0) {
fml.innerHTML = `<div class="empty-state" style="padding:24px 0"><div class="es-icon">🤝</div><h3>${search?'No matches':'No friends yet'}</h3><p>${search?'Try a different search.':'Tap + Invite to add your first StepMate.'}</p></div>`;
document.getElementById('friends-show-more').style.display = 'none';
return;
}
const visible = filtered.slice(0, friendsVisibleCount);
fml.innerHTML = visible.map(uid => {
const nick = friendsData[uid]?.nickname || '';
const displayName = friendProfiles[uid]?.displayName || 'No Display Name';
const addedAt = friendsData[uid]?.addedAt?.toDate?.() || null;
const dateStr = addedAt ? addedAt.toLocaleDateString('en-US', {month:'short',day:'numeric',year:'numeric'}) : '—';
return `
<div class="friend-row" id="fr-${uid}">
<span class="fr-date">${dateStr}</span>
${avatarHtml(friendProfiles[uid]||{}, 36)}
<div class="fr-info" onclick="openFriendProfile('${uid}')">
<div class="fr-name">${esc(nick||displayName)}</div>
${nick ? `<div class="fr-nick">${esc(displayName)}</div>` : ''}
</div>
</div>`;
}).join('');
document.getElementById('friends-show-more').style.display = filtered.length > friendsVisibleCount ? 'block' : 'none';
updateBulkRemoveBtn();
2026-06-18 13:46:17 -07:00
}
function updateBadge() {
const badge = document.getElementById('mi-friends-badge');
const pendingCount = getPendingFriendUids().length;
if (pendingCount > 0) { badge.textContent = pendingCount; badge.classList.add('show'); }
else badge.classList.remove('show');
2026-06-18 13:46:17 -07:00
}
window.onFriendCheck = function(e, uid) {
e.stopPropagation();
const row = document.getElementById('fr-' + uid);
const chk = document.getElementById('chk-' + uid);
row.classList.toggle('selected', chk.checked);
updateBulkRemoveBtn();
const mutualUids = getMutualFriendUids();
const allChecked = mutualUids.every(u => document.getElementById('chk-'+u)?.checked);
const selAll = document.getElementById('select-all-friends');
if (selAll) selAll.checked = allChecked;
}
2026-06-18 13:46:17 -07:00
function updateBulkRemoveBtn() {
const anyChecked = Object.keys(friendsData).some(uid => document.getElementById('chk-'+uid)?.checked);
document.getElementById('bulk-remove-btn').style.display = anyChecked ? 'inline-flex' : 'none';
}
2026-06-18 13:46:17 -07:00
window.toggleSelectAll = function(checked) {
getMutualFriendUids().forEach(uid => {
const chk = document.getElementById('chk-'+uid);
const row = document.getElementById('fr-'+uid);
if (chk) chk.checked = checked;
if (row) row.classList.toggle('selected', checked);
2026-06-18 13:46:17 -07:00
});
updateBulkRemoveBtn();
2026-06-18 13:46:17 -07:00
}
window.showMoreFriends = function() { friendsVisibleCount += 10; renderManageFriends(); }
2026-06-18 13:46:17 -07:00
window.bulkRemoveFriends = async function() {
const selected = getMutualFriendUids().filter(uid => document.getElementById('chk-'+uid)?.checked);
if (!selected.length) return;
if (!confirm(`Remove ${selected.length} friend${selected.length>1?'s':''}?`)) return;
for (const uid of selected) await doRemoveFriend(uid);
showToast(`${selected.length} friend${selected.length>1?'s':''} removed`, '');
document.getElementById('select-all-friends').checked = false;
}
async function doRemoveFriend(friendUid) {
const idToken = await currentUser.getIdToken();
await fetch(`${window.STEPMATES_CONFIG.friendRequestUrl}?action=remove&fromUid=${currentUser.uid}&toUid=${friendUid}&idToken=${idToken}`);
2026-06-28 20:33:45 -07:00
if (friendsData[friendUid]) friendsData[friendUid].status = 'removed';
delete friendProfiles[friendUid];
delete friendSteps[friendUid];
renderLeaderboard();
renderManageFriends();
}
window.approveFriend = async function(uid) {
2026-06-28 20:33:45 -07:00
try {
const idToken = await currentUser.getIdToken();
await fetch(`${STEPMATES_CONFIG.acceptFriendUrl}?fromUid=${currentUser.uid}&requesterUid=${uid}&idToken=${encodeURIComponent(idToken)}`);
await loadFriendProfilesAndSteps([uid]);
showToast('Friend added! 🎉', 'success');
} catch(e) {
await updateDoc(doc(db, 'friends', currentUser.uid, 'list', uid), { status: 'active' });
showToast('Friend added! 🎉', 'success');
}
}
window.denyFriend = async function(uid) {
2026-06-28 20:33:45 -07:00
await updateDoc(doc(db, 'friends', currentUser.uid, 'list', uid), { status: 'removed' });
showToast('Request declined', '');
}
// FRIEND PROFILE
window.openFriendProfile = async function(uid) {
if (!uid) return;
currentFriendUid = uid;
const profile = friendProfiles[uid] || {};
const nick = friendsData[uid]?.nickname || '';
const displayName = profile.displayName || 'No Display Name';
const avatarEl = document.getElementById('fp-avatar');
2026-06-28 20:00:16 -07:00
if (profile.avatarUrl && isEmoji(profile.avatarUrl)) {
avatarEl.innerHTML = `<span style="font-size:32px">${profile.avatarUrl}</span>`;
} else if (profile.avatarUrl) {
avatarEl.innerHTML = `<img src="${esc(profile.avatarUrl)}" style="width:100%;height:100%;object-fit:cover;border-radius:50%">`;
} else {
avatarEl.textContent = (nick || displayName)[0].toUpperCase();
}
document.getElementById('fp-name-text').textContent = nick || displayName;
const addedAt = friendsData[uid]?.addedAt?.toDate?.();
document.getElementById('fp-joined').textContent = addedAt ? addedAt.toLocaleDateString('en-US', {month:'long', day:'numeric', year:'numeric'}) : '—';
try {
const stepsDoc = await getDoc(doc(db, 'steps', uid));
const syncTs = stepsDoc.data()?.lastSynced?.toDate?.();
document.getElementById('fp-last-synced').textContent = syncTs
? syncTs.toLocaleString('en-US', {month:'short', day:'numeric', year:'numeric', hour:'numeric', minute:'2-digit'})
: 'Never';
} catch(e) {
document.getElementById('fp-last-synced').textContent = 'Never';
}
showScreen('friend-profile');
}
window.closeFriendProfile = function() { showScreen('dashboard'); switchPanel('friends'); }
window.removeFriendFromProfile = async function() {
if (!currentFriendUid) return;
const name = getFriendDisplayName(currentFriendUid);
if (!confirm(`Remove ${name} from your friends?`)) return;
await doRemoveFriend(currentFriendUid);
showToast(`${name} removed`, '');
closeFriendProfile();
}
// NICKNAME
window.openNicknameModal = function() {
if (!currentFriendUid) return;
document.getElementById('nickname-input').value = friendsData[currentFriendUid]?.nickname || '';
document.getElementById('nickname-modal').classList.add('open');
setTimeout(() => document.getElementById('nickname-input').focus(), 300);
}
window.closeNicknameModal = function() { document.getElementById('nickname-modal').classList.remove('open'); }
window.closeNicknameModalOutside = function(e) { if (e.target===document.getElementById('nickname-modal')) closeNicknameModal(); }
window.saveNickname = async function() {
const uid = currentFriendUid;
if (!uid) return;
const val = document.getElementById('nickname-input').value.trim();
await updateDoc(doc(db, 'friends', currentUser.uid, 'list', uid), { nickname: val || deleteField() });
if (friendsData[uid]) friendsData[uid].nickname = val;
closeNicknameModal();
const displayName = friendProfiles[uid]?.displayName || 'No Display Name';
document.getElementById('fp-name-text').textContent = val || displayName;
renderLeaderboard();
showToast(val ? 'Nickname saved ✓' : 'Nickname cleared', 'success');
}
// FRIEND REQUEST BY EMAIL
window.openInviteModal = function() {
document.getElementById('invite-email-input').value = '';
document.getElementById('invite-email-error').style.display = 'none';
document.getElementById('invite-send-btn').disabled = false;
document.getElementById('invite-send-btn').innerHTML = 'Send Request';
document.getElementById('invite-modal').classList.add('open');
setTimeout(() => document.getElementById('invite-email-input').focus(), 300);
}
window.closeInviteModal = function() { document.getElementById('invite-modal').classList.remove('open'); }
window.closeInviteModalOutside = function(e) { if (e.target===document.getElementById('invite-modal')) closeInviteModal(); }
window.sendFriendRequestByEmail = async function() {
const email = document.getElementById('invite-email-input').value.trim().toLowerCase();
const errEl = document.getElementById('invite-email-error');
const btn = document.getElementById('invite-send-btn');
errEl.style.display = 'none';
if (!email || !/\S+@\S+\.\S+/.test(email)) {
errEl.textContent = 'Please enter a valid email address.';
errEl.style.display = 'block';
return;
}
if (email === currentUser.email.toLowerCase()) {
errEl.textContent = "That's your own email!";
errEl.style.display = 'block';
return;
}
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span>';
try {
const idToken = await currentUser.getIdToken();
const res = await fetch(`${window.STEPMATES_CONFIG.friendRequestUrl}?fromUid=${currentUser.uid}&fromName=${encodeURIComponent(currentProfileData?.displayName || 'Someone')}&toEmail=${encodeURIComponent(email)}&idToken=${idToken}`);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to send');
closeInviteModal();
showToast('Friend request sent! ✓', 'success');
} catch(e) {
errEl.textContent = e.message || 'Something went wrong. Please try again.';
errEl.style.display = 'block';
btn.disabled = false;
btn.innerHTML = 'Send Request';
}
2026-06-18 13:46:17 -07:00
}
// SEND MAGIC LINK
window.sendMagicLink = async function() {
const email = document.getElementById('auth-email').value.trim();
const errEl = document.getElementById('auth-error');
errEl.style.display = 'none';
if (!email || !/\S+@\S+\.\S+/.test(email)) { errEl.textContent = 'Please enter a valid email address.'; errEl.style.display = 'block'; return; }
const btn = document.getElementById('auth-submit-btn');
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Sending…';
try {
const res = await fetch(`${window.STEPMATES_CONFIG.sendMagicLinkUrl}?email=${encodeURIComponent(email)}`);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to send');
localStorage.setItem('emailForSignIn', email);
document.getElementById('auth-card').style.display = 'none';
document.getElementById('magic-sent-card').style.display = 'block';
document.getElementById('magic-sent-email').textContent = email;
} catch(e) {
errEl.textContent = e.message.includes('429') || e.message.includes('Too many')
? 'Too many requests — please wait a few minutes and try again.'
: (e.message || 'Failed to send link. Try again.');
errEl.style.display = 'block';
}
btn.disabled = false; btn.innerHTML = 'Continue with Email';
}
2026-06-25 10:49:46 -07:00
window.signInWithGoogle = async function() {
const btn = document.getElementById('google-signin-btn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner" style="color:#4285F4"></span> Signing in…';
try {
const provider = new GoogleAuthProvider();
const result = await signInWithPopup(auth, provider);
_initializingUser = true;
await initUser(result.user);
2026-06-25 10:49:46 -07:00
} catch(e) {
btn.disabled = false;
btn.innerHTML = `<svg width="18" height="18" viewBox="0 0 18 18" style="flex-shrink:0"><path fill="#4285F4" d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.875 2.684-6.615z"/><path fill="#34A853" d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.258c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332C2.438 15.983 5.482 18 9 18z"/><path fill="#FBBC05" d="M3.964 10.707c-.18-.54-.282-1.117-.282-1.707s.102-1.167.282-1.707V4.961H.957C.347 6.175 0 7.55 0 9s.348 2.825.957 4.039l3.007-2.332z"/><path fill="#EA4335" d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0 5.482 0 2.438 2.017.957 4.961L3.964 7.293C4.672 5.166 6.656 3.58 9 3.58z"/></svg> Sign in with Google`;
if (e.code !== 'auth/popup-closed-by-user' && e.code !== 'auth/cancelled-popup-request') {
document.getElementById('auth-error').textContent = 'Google sign-in failed. Please try again or use email.';
document.getElementById('auth-error').style.display = 'block';
}
2026-06-25 10:49:46 -07:00
}
}
window.showAuthForm = function() {
document.getElementById('auth-card').style.display = 'block';
document.getElementById('magic-sent-card').style.display = 'none';
document.getElementById('auth-submit-btn').disabled = false;
document.getElementById('auth-submit-btn').innerHTML = 'Continue with Email';
}
// PROFILE MODAL
window.openProfileModal = function() {
document.getElementById('profile-name-input').value = currentProfileData?.displayName || '';
const avatarUrl = currentProfileData?.avatarUrl || '';
document.getElementById('profile-avatar-input').value = isEmoji(avatarUrl) ? avatarUrl : '';
document.getElementById('profile-error').style.display = 'none';
updateProfilePreview();
document.getElementById('profile-modal').classList.add('open');
setTimeout(() => document.getElementById('profile-name-input').focus(), 300);
}
window.closeProfileModal = function() { document.getElementById('profile-modal').classList.remove('open'); }
window.closeProfileModalOutside = function(e) { if (e.target===document.getElementById('profile-modal')) closeProfileModal(); }
function updateEmojiPreview(val) {
const preview = document.getElementById('emoji-preview');
if (!preview) return;
const emoji = val.trim();
preview.textContent = emoji || '🏃';
}
function updateProfilePreview() {
const name = document.getElementById('profile-name-input').value.trim() || 'Your Name';
const url = document.getElementById('profile-avatar-input').value.trim();
const initial = name[0].toUpperCase();
document.getElementById('profile-modal-name-preview').textContent = name;
const avatarEl = document.getElementById('profile-modal-avatar');
if (url && isEmoji(url)) avatarEl.innerHTML = `<span style="font-size:28px">${url}</span>`;
else if (url) avatarEl.innerHTML = `<img src="${esc(url)}" alt="${initial}" onerror="this.parentElement.innerHTML='${initial}'">`;
else avatarEl.innerHTML = `<span id="profile-modal-initial">${initial}</span>`;
updateEmojiPreview(url);
}
window.saveProfile = async function() {
const name = document.getElementById('profile-name-input').value.trim();
const avatarUrl = document.getElementById('profile-avatar-input').value.trim();
const errEl = document.getElementById('profile-error');
errEl.style.display = 'none';
if (!name || name.length < 2) { errEl.textContent = 'Display name must be at least 2 characters.'; errEl.style.display = 'block'; return; }
if (avatarUrl && !avatarUrl.startsWith('http') && !isEmoji(avatarUrl)) { errEl.textContent = 'Please enter a valid emoji'; errEl.style.display = 'block'; return; }
const btn = document.querySelector('#profile-modal .btn-primary');
btn.disabled = true; btn.innerHTML = '<span class="spinner"></span>';
try {
await updateDoc(doc(db, 'profiles', currentUser.uid), { displayName: name, avatarUrl });
currentProfileData = { ...currentProfileData, displayName: name, avatarUrl };
refreshProfileUI();
renderLeaderboard();
renderManageFriends();
closeProfileModal();
showToast('Profile updated ✓', 'success');
} catch(e) { errEl.textContent = 'Failed to save. Please try again.'; errEl.style.display = 'block'; }
finally { btn.disabled = false; btn.innerHTML = 'Save changes'; }
2026-06-18 13:46:17 -07:00
}
// GOAL MODAL
window.openGoalModal = function() {
const current = getDailyGoal();
document.getElementById('goal-slider').value = current;
document.getElementById('goal-modal-display').textContent = current.toLocaleString();
document.getElementById('goal-modal').classList.add('open');
}
window.closeGoalModal = function() { document.getElementById('goal-modal').classList.remove('open'); }
window.closeGoalModalOutside = function(e) { if (e.target===document.getElementById('goal-modal')) closeGoalModal(); }
window.setGoalPreset = function(val) { document.getElementById('goal-slider').value = val; document.getElementById('goal-modal-display').textContent = val.toLocaleString(); }
window.saveGoal = async function() {
const val = parseInt(document.getElementById('goal-slider').value);
await updateDoc(doc(db, 'users', currentUser.uid), { dailyGoal: val });
currentUserData.dailyGoal = val;
renderMySteps();
closeGoalModal();
showToast('Goal updated ✓', 'success');
}
document.getElementById('goal-slider').addEventListener('input', function() {
document.getElementById('goal-modal-display').textContent = parseInt(this.value).toLocaleString();
});
// PANEL SWITCHING
window.switchPanel = function(name) {
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
document.getElementById('panel-' + name).classList.add('active');
document.querySelectorAll('.menu-item').forEach(b => b.classList.remove('active'));
const mi = document.getElementById('mi-' + name);
if (mi) mi.classList.add('active');
2026-06-18 13:46:17 -07:00
}
// MENU
window.toggleMenu = function() {
const overlay = document.getElementById('menu-overlay');
const btn = document.getElementById('hamburger-btn');
if (overlay.classList.contains('open')) closeMenu();
else { overlay.classList.add('open'); btn.classList.add('open'); }
}
window.closeMenu = function() {
document.getElementById('menu-overlay').classList.remove('open');
document.getElementById('hamburger-btn').classList.remove('open');
2026-06-18 13:46:17 -07:00
}
// COPY WEBHOOK
window.copyWebhook = function(urlElId, btnId) {
const url = document.getElementById(urlElId).textContent;
navigator.clipboard.writeText(url).then(() => {
const btn = document.getElementById(btnId);
btn.textContent = '✓ Copied'; btn.classList.add('copied');
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 2000);
});
2026-06-18 13:46:17 -07:00
}
// SIGN OUT
window.signOut = async function() {
if (!confirm('Sign out?')) return;
teardown();
2026-06-18 13:46:17 -07:00
await fbSignOut(auth);
_initializingUser = false;
currentUser = null; currentUserData = null; currentProfileData = null; currentStepsData = null;
friendsData = {}; friendProfiles = {}; friendSteps = {};
const btn = document.getElementById('google-signin-btn');
if (btn) { btn.disabled = false; btn.innerHTML = `<svg width="18" height="18" viewBox="0 0 18 18" style="flex-shrink:0"><path fill="#4285F4" d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.875 2.684-6.615z"/><path fill="#34A853" d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.258c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332C2.438 15.983 5.482 18 9 18z"/><path fill="#FBBC05" d="M3.964 10.707c-.18-.54-.282-1.117-.282-1.707s.102-1.167.282-1.707V4.961H.957C.347 6.175 0 7.55 0 9s.348 2.825.957 4.039l3.007-2.332z"/><path fill="#EA4335" d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0 5.482 0 2.438 2.017.957 4.961L3.964 7.293C4.672 5.166 6.656 3.58 9 3.58z"/></svg> Sign in with Google`; }
showScreen('auth'); showAuthForm();
2026-06-18 13:46:17 -07:00
}
// TOAST
let toastTimer;
window.showToast = function(msg, type='') {
const el = document.getElementById('toast');
el.textContent = msg; el.className = 'show ' + type;
2026-06-18 13:46:17 -07:00
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { el.className = ''; }, 3500);
2026-06-18 13:46:17 -07:00
}
// KEYBOARD & TOUCH
document.getElementById('auth-email').addEventListener('keydown', e => { if (e.key==='Enter') sendMagicLink(); });
document.getElementById('confirm-email-input').addEventListener('keydown', e => { if (e.key==='Enter') confirmEmailSignIn(); });
2026-06-18 13:46:17 -07:00
document.getElementById('profile-name-input').addEventListener('input', updateProfilePreview);
document.getElementById('profile-avatar-input').addEventListener('input', updateProfilePreview);
document.addEventListener('keydown', e => { if (e.key==='Escape') { closeProfileModal(); closeGoalModal(); closeMenu(); closeNicknameModal(); closeInviteModal(); } });
2026-06-18 13:46:17 -07:00
let ptrStartY = 0, ptrTriggered = false;
document.addEventListener('touchstart', e => { if (window.scrollY===0) ptrStartY = e.touches[0].clientY; }, { passive: true });
2026-06-18 14:12:29 -07:00
document.addEventListener('touchmove', e => {
if (!ptrStartY) return;
if (e.touches[0].clientY - ptrStartY > 80 && !ptrTriggered && document.getElementById('screen-dashboard').classList.contains('active')) {
2026-06-18 14:12:29 -07:00
ptrTriggered = true;
document.getElementById('ptr-indicator').classList.add('visible');
2026-06-18 14:12:29 -07:00
}
}, { passive: true });
document.addEventListener('touchend', () => {
if (ptrTriggered) { teardown(); loadFriendsRealtime(); setTimeout(() => document.getElementById('ptr-indicator').classList.remove('visible'), 1000); }
ptrStartY = 0; ptrTriggered = false;
}, { passive: true });
2026-06-18 14:12:29 -07:00
2026-06-18 13:46:17 -07:00
</script>
</body>
</html>