Files
step-tracker/index.html
T

2580 lines
93 KiB
HTML

<!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;
}
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;
}
/* ── SCREENS ── */
.screen { display: none; flex-direction: column; min-height: 100vh; }
.screen.active { display: flex; }
/* ── NAV ── */
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;
}
.nav-logo span { color: var(--white); }
.nav-user {
display: flex;
align-items: center;
gap: 12px;
}
.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%;
}
/* ── 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-avatar-preview {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
}
.modal-avatar-preview .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;
}
.modal-avatar-preview .avatar-lg img {
width: 100%;
height: 100%;
object-fit: cover;
}
.modal-avatar-preview .avatar-preview-text {
font-size: 13px;
color: var(--muted);
line-height: 1.5;
}
.modal-avatar-preview .avatar-preview-name {
font-family: var(--font-display);
font-weight: 600;
font-size: 16px;
color: var(--white);
margin-bottom: 2px;
}
.modal-actions {
display: flex;
gap: 10px;
margin-top: 4px;
}
.modal-actions .btn { flex: 1; }
/* ── SAFE AREA / MOBILE ── */
.dashboard-content {
padding-bottom: 40px;
}
/* ── TOUCH TARGETS ── */
.btn { min-height: 46px; }
.btn-sm { min-height: 38px; }
.tab-btn { min-height: 38px; }
.bn-item { min-height: 56px; }
/* ── CONTAINERS ── */
.container {
width: 100%;
max-width: 680px;
margin: 0 auto;
padding: 0 20px;
}
/* ── AUTH SCREEN ── */
#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;
}
.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;
}
/* ── FORMS ── */
label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--muted);
margin-bottom: 6px;
letter-spacing: 0.3px;
text-transform: uppercase;
font-size: 11px;
}
input[type="email"],
input[type="text"] {
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);
}
input::placeholder { color: var(--muted); opacity: 0.6; }
.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;
}
.btn:active { transform: scale(0.98); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-primary {
background: var(--teal);
color: var(--bg);
width: 100%;
}
.btn-primary:hover:not(:disabled) { opacity: 0.9; }
.btn-ghost {
background: transparent;
color: var(--muted);
border: 1.5px solid var(--border);
}
.btn-ghost:hover { border-color: var(--teal); color: var(--teal); }
.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; }
/* ── FRIEND ROW WITH CHECKBOX ── */
.friend-row {
display: flex;
align-items: center;
gap: 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 14px;
transition: border-color 0.15s;
margin-bottom: 8px;
}
.friend-row:hover { border-color: var(--teal-glow); }
.friend-row.selected { border-color: var(--teal); background: var(--teal-dim); }
.friend-row input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--teal);
cursor: pointer;
flex-shrink: 0;
margin-bottom: 0;
}
.friend-row .fr-date {
font-size: 11px;
color: var(--muted);
white-space: nowrap;
flex-shrink: 0;
}
.friend-row .fr-info { flex: 1; min-width: 0; cursor: pointer; }
.friend-row .fr-name {
font-family: var(--font-display);
font-weight: 600;
font-size: 15px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.friend-row .fr-nick {
font-size: 12px;
color: var(--teal);
margin-top: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── ALERT / INFO BOXES ── */
.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;
}
.warning-box {
background: rgba(255,179,71,0.1);
border: 1px solid rgba(255,179,71,0.25);
border-radius: var(--radius);
padding: 14px 16px;
font-size: 14px;
color: var(--amber);
margin-bottom: 20px;
}
.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;
}
.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;
}
/* ── BRIDGE APP 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;
}
.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;
}
/* ── WEBHOOK URL BOX ── */
.webhook-box {
background: var(--bg);
border: 1.5px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
margin-bottom: 20px;
}
.webhook-box .wh-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--muted);
margin-bottom: 8px;
}
.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);
}
.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 80px;
}
/* ── 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-steps.loading { opacity: 0.3; }
.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;
}
/* ── 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;
}
.tab-btn.active {
background: var(--teal);
color: var(--bg);
}
/* ── SECTION HEADERS ── */
.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;
}
/* ── FRIEND CARDS ── */
.friends-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.friend-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 18px;
display: flex;
align-items: center;
gap: 14px;
transition: border-color 0.15s;
}
.friend-card:hover { border-color: var(--border); }
.friend-avatar {
width: 44px;
height: 44px;
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: 17px;
color: var(--teal);
flex-shrink: 0;
overflow: hidden;
}
.friend-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
.friend-info { flex: 1; min-width: 0; }
.friend-name {
font-family: var(--font-display);
font-weight: 600;
font-size: 15px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.friend-steps-sub {
font-size: 12px;
color: var(--muted);
margin-top: 1px;
}
.friend-steps {
font-family: var(--font-display);
font-weight: 700;
font-size: 22px;
color: var(--white);
letter-spacing: -0.5px;
flex-shrink: 0;
}
/* ── PENDING REQUESTS ── */
.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;
}
.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; }
/* ── ADD FRIEND PANEL ── */
.add-friend-panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
margin-bottom: 24px;
}
.add-friend-panel h3 {
font-family: var(--font-display);
font-size: 15px;
font-weight: 600;
margin-bottom: 12px;
}
.add-friend-row {
display: flex;
gap: 8px;
}
.add-friend-row input {
margin-bottom: 0;
flex: 1;
}
.add-friend-row .btn {
width: auto;
white-space: nowrap;
}
/* ── 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; }
/* ── 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); }
/* ── 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;
}
/* ── HAMBURGER MENU ── */
.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; }
/* ── PANEL VIEWS ── */
.panel { display: none; }
.panel.active { display: block; }
/* ── SETUP REMINDER ── */
.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;
}
.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;
}
.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;
}
/* ── MANUAL STEP 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;
}
.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; }
/* ── RESPONSIVE ── */
@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; }
}
/* ── PULL TO REFRESH ── */
.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 {
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); }
</style>
</head>
<body>
<div id="app">
<!-- LOADING -->
<div id="screen-loading" class="screen active">
<div class="loader-full">
<div class="spinner"></div>
Loading…
</div>
</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>
<p>Enter your email to sign in or create an account — no password needed.</p>
<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>
</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 (magic link opened on different device) -->
<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>
<!-- 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 be able to sign in once it's approved.</p>
<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">
<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>
<!-- Step 1: Display Name -->
<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>
<!-- Step 2: Download bridge app -->
<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. It reads your steps from Apple Health or Android Health and sends them here.</p>
<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>
</a>
<a href="https://github.com/mcnaveen/health-connect-webhook/releases" target="_blank" class="bridge-card">
<div class="bc-icon">🤖</div>
<div class="bc-name">HC Webhook</div>
<div class="bc-platform">Android 9+</div>
<div class="bc-badge">Free & open source</div>
</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>
<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>
<!-- Step 3: Webhook URL -->
<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. Your steps will sync automatically from that point on.</p>
<div class="webhook-box">
<div class="wh-label">Your Webhook URL</div>
<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="margin-bottom:16px">
<p style="font-size:13px;color:var(--muted);margin-bottom:10px;font-weight:500">Where to paste it:</p>
<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">
<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 (HC Webhook):</strong><br>
Add Webhook → paste URL → enable Steps → set sync interval
</div>
</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" style="cursor:pointer">
<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>
</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>
<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>
<!-- MY STEPS -->
<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>
<!-- SETUP REMINDER (shown if no webhook synced yet) -->
<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="showSetupPanel()">Set up →</button>
</div>
<!-- MANUAL ENTRY -->
<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>
<!-- PANEL: MY STEPS -->
<div id="panel-home" class="panel active">
</div>
<!-- PANEL: FRIENDS -->
<div id="panel-friends" class="panel">
<!-- Pending received requests -->
<div id="pending-received-section" style="display:none;margin-bottom:20px">
<div class="section-header">
<div class="section-title">Friend Requests</div>
</div>
<div id="pending-received-list"></div>
</div>
<!-- Friends header: search, sort, select all, + button -->
<div class="section-header" style="margin-bottom:12px">
<div class="section-title">My Friends</div>
<button class="btn btn-primary btn-sm" onclick="openInviteModal()" style="width:auto;gap:6px">
<span style="font-size:18px;line-height:1">+</span> Invite
</button>
</div>
<div style="display:flex;gap:8px;margin-bottom:12px;align-items:center">
<input type="text" id="friends-search" placeholder="Search friends…" style="margin-bottom:0;flex:1" oninput="renderFriendsPanel()">
<select id="friends-sort" onchange="renderFriendsPanel()" style="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">
<option value="date">Date Added</option>
<option value="name">Name</option>
</select>
</div>
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;padding:0 2px">
<input type="checkbox" id="select-all-friends" onchange="toggleSelectAll(this.checked)" style="width:18px;height:18px;accent-color:var(--teal);cursor:pointer;flex-shrink:0">
<label for="select-all-friends" style="font-size:13px;color:var(--muted);cursor:pointer;user-select:none">Select all</label>
<button class="btn btn-danger btn-sm" id="bulk-remove-btn" onclick="bulkRemoveFriends()" style="display:none;margin-left:auto;width:auto">Remove Selected</button>
</div>
<div id="friends-manage-list" class="friends-list"></div>
<div id="friends-show-more" style="display:none;text-align:center;margin-top:16px">
<button class="btn btn-ghost" onclick="showMoreFriends()" style="width:auto">Show More…</button>
</div>
<!-- Sent requests -->
<div id="pending-sent-section" style="display:none;margin-top:24px">
<div class="section-header">
<div class="section-title">Sent Invites</div>
</div>
<div id="pending-sent-list" class="friends-list" style="margin-bottom:24px"></div>
</div>
</div>
<!-- PANEL: SETTINGS / WEBHOOK -->
<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. Steps will sync automatically.
</div>
<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>
Automations → New Automation → Webhook → paste URL<br>
Select <em>Steps</em> → set to run Daily<br><br>
<strong style="color:var(--white)">Android — HC Webhook:</strong><br>
<a href="https://github.com/mcnaveen/health-connect-webhook/releases" target="_blank" style="color:var(--teal)">Download from GitHub</a> → Add Webhook → paste URL<br>
Enable Steps → set sync interval
</div>
<div class="section-header">
<div class="section-title">Bridge App Downloads</div>
</div>
<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>
</a>
<a href="https://github.com/mcnaveen/health-connect-webhook/releases" target="_blank" class="bridge-card">
<div class="bc-icon">🤖</div>
<div class="bc-name">HC Webhook</div>
<div class="bc-platform">Android 9+</div>
<div class="bc-badge">Free & open source</div>
</a>
</div>
<div class="section-header">
<div class="section-title">Account</div>
</div>
<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>
<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><!-- /container -->
</div>
<!-- INVITE MODAL -->
<div class="modal-overlay" id="invite-modal" onclick="closeInviteModalOutside(event)">
<div class="modal">
<div class="modal-handle"></div>
<h3>Invite a Friend</h3>
<p style="font-size:14px;color:var(--muted);margin-bottom:20px;line-height:1.6">Share this message with a friend. When they tap the link they'll be taken straight to StepMates.</p>
<div style="background:var(--bg);border:1.5px solid var(--border);border-radius:var(--radius);padding:16px;font-size:14px;line-height:1.7;margin-bottom:20px;color:var(--white)" id="invite-message-preview"></div>
<div class="modal-actions" style="flex-wrap:wrap;gap:8px">
<button class="btn btn-ghost" onclick="closeInviteModal()" style="flex:1;min-width:80px">Cancel</button>
<button class="btn btn-ghost" onclick="copyInviteLink()" id="copy-invite-btn" style="flex:1;min-width:100px">Copy Link</button>
<button class="btn btn-primary" onclick="shareInvite()" style="flex:2;min-width:120px">Share Invite</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" style="width:80px;height:80px;border-radius:50%;background:var(--teal-dim);border:2px solid var(--teal);display:flex;align-items:center;justify-content:center;font-family:var(--font-display);font-weight:700;font-size:32px;color:var(--teal);margin:0 auto 16px;overflow:hidden"></div>
<div id="fp-name" onclick="openNicknameModal()" style="font-family:var(--font-display);font-size:26px;font-weight:700;cursor:pointer;display:inline-flex;align-items:center;gap:8px">
<span id="fp-name-text">—</span>
<span style="font-size:14px;color:var(--teal);font-weight:500">✏️</span>
</div>
<div id="fp-nickname-shown" style="font-size:13px;color:var(--muted);margin-top:2px;display:none"></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;padding:14px 18px;border-bottom:1px solid var(--border)">
<span style="color:var(--muted);font-size:14px">Joined StepMates</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">Avg. steps per day</span>
<span style="font-size:14px;font-weight:500" id="fp-avg">—</span>
</div>
</div>
<button class="btn btn-danger" style="width:100%" onclick="removeFriendFromProfile()" id="fp-remove-btn">Remove Friend</button>
</div>
</div>
<!-- NICKNAME MODAL -->
<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 MODAL -->
<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 class="avatar-preview-name" id="profile-modal-name-preview">Your Name</div>
<div class="avatar-preview-text">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 for="profile-avatar-input">Avatar image URL <span style="font-weight:400;text-transform:none;font-size:11px;color:var(--muted)">(optional — paste any image link)</span></label>
<input type="text" id="profile-avatar-input" placeholder="https://example.com/photo.jpg">
<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 MODAL -->
<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 style="font-family:var(--font-display);font-size:52px;font-weight:700;color:var(--teal);letter-spacing:-2px;line-height:1" 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><!-- /app -->
<div id="ptr-indicator" class="ptr-indicator">↓ Refreshing…</div>
<div id="toast"></div>
<!-- Firebase SDK -->
<!-- Local config — copy config.example.js → config.js and fill in your values -->
<script src="config.js"></script>
<script type="module">
import { initializeApp } from 'https://www.gstatic.com/firebasejs/10.12.0/firebase-app.js';
import { getAuth, sendSignInLinkToEmail, isSignInWithEmailLink, signInWithEmailLink, signOut as fbSignOut, onAuthStateChanged } from 'https://www.gstatic.com/firebasejs/10.12.0/firebase-auth.js';
import { getFirestore, doc, getDoc, setDoc, updateDoc, collection, query, where, getDocs, addDoc, onSnapshot, Timestamp, orderBy, serverTimestamp } from 'https://www.gstatic.com/firebasejs/10.12.0/firebase-firestore.js';
// ─────────────────────────────────────────────
// ─────────────────────────────────────────────
// 🔧 CONFIGURATION
// Copy config.example.js → config.js and fill in your values.
// config.js is git-ignored and never committed.
// ─────────────────────────────────────────────
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 style="background:#1e2e40;padding:2px 6px;border-radius:4px">config.example.js</code> to <code style="background:#1e2e40;padding:2px 6px;border-radius:4px">config.js</code> and fill in your Firebase credentials.</p>
<p style="color:#8899a6;font-size:14px">See README.md for setup instructions.</p>
</div>`;
throw new Error('STEPMATES_CONFIG not found. Did you create config.js?');
}
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 STEP_GOAL = 5000;
const _initialParams = new URLSearchParams(window.location.search);
const _savedInviteToken = _initialParams.get('invite');
const _savedFromUid = _initialParams.get('from');
if (_savedInviteToken && _savedFromUid) {
localStorage.setItem('pendingInviteToken', _savedInviteToken);
localStorage.setItem('pendingInviteFrom', _savedFromUid);
}
// ─────────────────────────────────────────────
// INIT
// ─────────────────────────────────────────────
const fbApp = initializeApp(FIREBASE_CONFIG);
const auth = getAuth(fbApp);
const db = getFirestore(fbApp);
let currentUser = null;
let currentUserData = null;
let currentTab = 'day';
let friendsUnsubscribe = null;
// ─────────────────────────────────────────────
// SCREEN ROUTING
// ─────────────────────────────────────────────
function showScreen(id) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById('screen-' + id).classList.add('active');
}
window.showScreen = showScreen;
// ─────────────────────────────────────────────
// AUTH FLOW
// ─────────────────────────────────────────────
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 currentPageParams = new URLSearchParams(window.location.search);
const inviteToken = currentPageParams.get('invite') || localStorage.getItem('pendingInviteToken');
const inviteFrom = currentPageParams.get('from') || localStorage.getItem('pendingInviteFrom');
if (inviteToken && inviteFrom) {
localStorage.setItem('pendingInviteToken', inviteToken);
localStorage.setItem('pendingInviteFrom', inviteFrom);
localStorage.setItem('pendingInviteEmail', email);
}
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';
}
}
window.showAuthForm = function() {
document.getElementById('auth-card').style.display = 'block';
document.getElementById('magic-sent-card').style.display = 'none';
const btn = document.getElementById('auth-submit-btn');
btn.disabled = false;
btn.innerHTML = 'Continue with Email';
}
// ─────────────────────────────────────────────
// AUTH STATE
// ─────────────────────────────────────────────
onAuthStateChanged(auth, async (user) => {
if (isSignInWithEmailLink(auth, window.location.href)) {
const urlParams = new URLSearchParams(window.location.search);
const email = urlParams.get('email') || localStorage.getItem('emailForSignIn');
if (!email) {
showScreen('confirm');
document.getElementById('confirm-email-input').focus();
return;
}
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) {
showToast('Sign-in link expired or already used. Please try again.', 'error');
showScreen('auth');
return;
}
}
if (user) {
currentUser = user;
await initUser(user);
} else {
showScreen('auth');
}
});
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';
}
}
async function initUser(user) {
currentUser = user;
const userRef = doc(db, 'users', user.uid);
const snap = await getDoc(userRef);
if (!snap.exists()) {
const webhookToken = generateToken();
await setDoc(userRef, {
email: user.email,
displayName: '',
avatarUrl: '',
webhookToken,
stepsByDate: {},
createdAt: serverTimestamp(),
setupComplete: false,
approved: false
});
await setDoc(doc(db, 'profiles', user.uid), {
displayName: '',
avatarUrl: '',
stepsByDate: {}
});
currentUserData = { email: user.email, displayName: '', avatarUrl: '', webhookToken, stepsByDate: {}, setupComplete: false, approved: false };
await notifyAdminOfNewUser(user.email, user.uid);
showScreen('pending');
} else {
currentUserData = snap.data();
if (currentUserData.approved === false) {
showScreen('pending');
} else if (!currentUserData.setupComplete) {
showScreen('onboarding');
document.getElementById('ob-webhook-url').textContent = buildWebhookUrl(currentUserData.webhookToken);
} else {
loadDashboard();
}
}
}
async function notifyAdminOfNewUser(email, uid) {
try {
await fetch(`${window.STEPMATES_CONFIG.adminNotifyUrl}?uid=${uid}&email=${encodeURIComponent(email)}`);
} catch(e) {
console.warn('Admin notify failed', e);
}
}
function buildWebhookUrl(token) {
return `${WEBHOOK_BASE}?token=${token}`;
}
function generateToken() {
return Array.from(crypto.getRandomValues(new Uint8Array(20))).map(b => b.toString(16).padStart(2,'0')).join('');
}
// ─────────────────────────────────────────────
// 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;
}
errEl.style.display = 'none';
await updateDoc(doc(db, 'users', currentUser.uid), { displayName: name });
currentUserData.displayName = name;
}
goToObStep(from + 1);
}
window.obSkip = function() {
goToObStep(2);
}
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 loadDashboard() {
showScreen('dashboard');
nicknames = currentUserData.nicknames || {};
refreshProfileUI();
document.getElementById('settings-email').textContent = currentUser.email;
document.getElementById('settings-webhook-url').textContent = buildWebhookUrl(currentUserData.webhookToken);
renderMySteps();
loadFriends();
checkSetupReminder();
handleInviteLink();
}
function refreshProfileUI() {
const name = currentUserData.displayName || currentUser.email.split('@')[0];
const initial = name[0].toUpperCase();
const avatarUrl = currentUserData.avatarUrl || '';
setAvatarEl(document.getElementById('nav-avatar'), 'nav-avatar-initial', initial, avatarUrl, 36);
setAvatarEl(document.getElementById('settings-avatar-display'), 'settings-avatar-initial', initial, avatarUrl, 48);
document.getElementById('settings-display-name-show').textContent = name;
document.getElementById('menu-user-name').textContent = name;
document.getElementById('menu-user-email').textContent = currentUser.email;
}
function setAvatarEl(wrapEl, initialId, initial, url, size) {
if (url) {
wrapEl.innerHTML = `<img src="${esc(url)}" alt="${initial}" onerror="this.parentElement.innerHTML='${initial}'">`;
} else {
wrapEl.innerHTML = `<span id="${initialId}">${initial}</span>`;
}
}
function checkSetupReminder() {
const steps = currentUserData.stepsByDate || {};
const hasAnySteps = Object.keys(steps).length > 0;
document.getElementById('setup-remind').style.display = hasAnySteps ? 'none' : 'flex';
document.getElementById('manual-entry-panel').style.display = hasAnySteps ? 'none' : 'block';
}
window.showSetupPanel = function() {
switchPanel('settings');
}
// ─────────────────────────────────────────────
// TABS & STEP DISPLAY
// ─────────────────────────────────────────────
const TAB_LABELS = { day: 'Today', week: 'This Week', month: 'This Month', year: 'This Year', alltime: 'All Time' };
function getDailyGoal() {
return currentUserData.dailyGoal || 5000;
}
function getGoals() {
const d = getDailyGoal();
return { day: d, week: d * 7, month: d * 30, year: d * 365, alltime: null };
}
window.setTab = function(tab, el) {
currentTab = tab;
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
el.classList.add('active');
document.getElementById('tab-label').textContent = TAB_LABELS[tab];
renderMySteps();
renderFriendSteps();
}
function getStepsForPeriod(stepsByDate, period) {
const today = new Date();
const keys = Object.keys(stepsByDate || {});
let total = 0;
for (const k of keys) {
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 += stepsByDate[k];
} else if (period === 'week') {
if (isSameWeek(d, today)) total += stepsByDate[k];
} else if (period === 'month') {
if (d.getMonth() === today.getMonth() && d.getFullYear() === today.getFullYear()) total += stepsByDate[k];
} else if (period === 'year') {
if (d.getFullYear() === today.getFullYear()) total += stepsByDate[k];
} else {
total += stepsByDate[k];
}
}
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 renderMySteps() {
const steps = getStepsForPeriod(currentUserData.stepsByDate, currentTab);
document.getElementById('my-step-count').textContent = steps.toLocaleString();
const GOALS = getGoals();
const goal = GOALS[currentTab];
if (goal) {
const pct = Math.min(100, Math.round((steps / goal) * 100));
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 = '';
}
}
// ─────────────────────────────────────────────
// MANUAL STEP LOG
// ─────────────────────────────────────────────
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, 'users', currentUser.uid), { [`stepsByDate.${today}`]: val });
await updateDoc(doc(db, 'profiles', currentUser.uid), { [`stepsByDate.${today}`]: val });
document.getElementById('manual-step-input').value = '';
showToast('Steps logged ✓', 'success');
}
function todayKey() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
}
// ─────────────────────────────────────────────
// FRIENDS SYSTEM — REAL-TIME LISTENERS
// ─────────────────────────────────────────────
let friendsList = [];
let pendingReceived = [];
let pendingSent = [];
let friendUnsubscribers = [];
function teardownListeners() {
friendUnsubscribers.forEach(fn => fn());
friendUnsubscribers = [];
}
function loadFriends() {
if (!currentUser) return;
teardownListeners();
const uid = currentUser.uid;
const frRef = collection(db, 'friendRequests');
const unsubOwn = onSnapshot(doc(db, 'users', uid), snap => {
if (!snap.exists()) return;
currentUserData = { ...currentUserData, ...snap.data() };
renderMySteps();
checkSetupReminder();
});
friendUnsubscribers.push(unsubOwn);
const unsubReceived = onSnapshot(query(frRef, where('toUid', '==', uid)), async receivedSnap => {
const unsubSent = onSnapshot(query(frRef, where('fromUid', '==', uid)), async sentSnap => {
pendingReceived = receivedSnap.docs
.filter(d => d.data().status === 'pending')
.map(d => ({ id: d.id, ...d.data() }));
pendingSent = sentSnap.docs
.filter(d => d.data().status === 'pending')
.map(d => ({ id: d.id, ...d.data() }));
const confirmedFromMe = sentSnap.docs
.filter(d => d.data().status === 'accepted')
.map(d => d.data().toUid);
const confirmedToMe = receivedSnap.docs
.filter(d => d.data().status === 'accepted')
.map(d => d.data().fromUid);
const allFriendUids = [...new Set([...confirmedFromMe, ...confirmedToMe])];
friendUnsubscribers.filter(fn => fn._isFriendProfile).forEach(fn => fn());
friendUnsubscribers = friendUnsubscribers.filter(fn => !fn._isFriendProfile);
friendsList = [];
for (const fuid of allFriendUids) {
const req = [...sentSnap.docs, ...receivedSnap.docs].find(d =>
(d.data().fromUid === currentUser.uid && d.data().toUid === fuid) ||
(d.data().toUid === currentUser.uid && d.data().fromUid === fuid)
);
const addedAt = req?.data()?.createdAt?.toMillis?.() || 0;
const unsubProfile = onSnapshot(doc(db, 'profiles', fuid), profileSnap => {
if (!profileSnap.exists()) return;
const idx = friendsList.findIndex(f => f.uid === fuid);
const data = { uid: fuid, addedAt, ...profileSnap.data() };
if (idx >= 0) {
friendsList[idx] = data;
} else {
friendsList.push(data);
}
renderFriendSteps();
renderFriendsPanel();
});
unsubProfile._isFriendProfile = true;
friendUnsubscribers.push(unsubProfile);
}
renderFriendsPanel();
updateBadge();
});
friendUnsubscribers.push(unsubSent);
});
friendUnsubscribers.push(unsubReceived);
}
let friendsVisibleCount = 10;
let nicknames = {};
function getFriendName(f) {
return nicknames[f.uid] || f.displayName || 'No Display Name';
}
function avatarHtml(f, size=44) {
const name = f.displayName || '?';
const initial = name[0].toUpperCase();
const url = f.avatarUrl || '';
if (url) {
return `<div class="friend-avatar" style="width:${size}px;height:${size}px"><img src="${esc(url)}" alt="${initial}" onerror="this.parentElement.innerHTML='${initial}'"></div>`;
}
return `<div class="friend-avatar" style="width:${size}px;height:${size}px">${initial}</div>`;
}
function renderFriendSteps() {
const el = document.getElementById('friends-steps-list');
if (!el) return;
if (friendsList.length === 0) {
el.innerHTML = `<div class="empty-state"><div class="es-icon">👣</div><h3>No friends yet</h3><p>Invite friends to see their steps here.</p></div>`;
return;
}
const sorted = [...friendsList].sort((a,b) => getStepsForPeriod(b.stepsByDate, currentTab) - getStepsForPeriod(a.stepsByDate, currentTab));
el.innerHTML = sorted.map(f => {
const steps = getStepsForPeriod(f.stepsByDate, currentTab);
const name = getFriendName(f);
return `
<div class="friend-card" onclick="openFriendProfile('${f.uid}')" style="cursor:pointer">
${avatarHtml(f)}
<div class="friend-info">
<div class="friend-name">${esc(name)}</div>
<div class="friend-steps-sub">${TAB_LABELS[currentTab]}</div>
</div>
<div class="friend-steps">${steps.toLocaleString()}</div>
</div>`;
}).join('');
}
function renderFriendsPanel() {
const prSection = document.getElementById('pending-received-section');
const prList = document.getElementById('pending-received-list');
if (pendingReceived.length > 0) {
prSection.style.display = 'block';
prList.innerHTML = pendingReceived.map(req => `
<div class="pending-card">
<div class="pending-info">
<div class="pending-name">${esc(req.fromName || req.fromEmail)}</div>
<div class="pending-sub">wants to be your StepMate</div>
</div>
<div class="pending-actions">
<button class="btn btn-primary btn-sm" onclick="respondRequest('${req.id}','accepted')">Accept</button>
<button class="btn btn-ghost btn-sm" onclick="respondRequest('${req.id}','denied')">Decline</button>
</div>
</div>`).join('');
} else {
prSection.style.display = 'none';
}
const psSection = document.getElementById('pending-sent-section');
const psList = document.getElementById('pending-sent-list');
if (pendingSent.length > 0) {
psSection.style.display = 'block';
psList.innerHTML = pendingSent.map(req => `
<div class="friend-card" style="opacity:0.7">
<div class="friend-avatar" style="border-color:var(--amber);color:var(--amber)">${(req.toEmail||'?')[0].toUpperCase()}</div>
<div class="friend-info">
<div class="friend-name">${esc(req.toEmail)}</div>
<div class="friend-steps-sub">Invite pending…</div>
</div>
</div>`).join('');
} else {
psSection.style.display = 'none';
}
const fml = document.getElementById('friends-manage-list');
const search = (document.getElementById('friends-search')?.value || '').toLowerCase();
const sortBy = document.getElementById('friends-sort')?.value || 'date';
let filtered = friendsList.filter(f => {
const name = getFriendName(f).toLowerCase();
const display = (f.displayName || '').toLowerCase();
return !search || name.includes(search) || display.includes(search);
});
if (sortBy === 'name') {
filtered.sort((a,b) => getFriendName(a).localeCompare(getFriendName(b)));
} else {
filtered.sort((a,b) => (b.addedAt || 0) - (a.addedAt || 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(f => {
const nick = nicknames[f.uid] || '';
const displayName = f.displayName || 'No Display Name';
const addedDate = f.addedAt ? new Date(f.addedAt).toLocaleDateString('en-US', { month:'short', day:'numeric', year:'numeric' }) : '—';
return `
<div class="friend-row" id="fr-${f.uid}">
<input type="checkbox" id="chk-${f.uid}" onclick="onFriendCheck(event,'${f.uid}')">
<span class="fr-date">${addedDate}</span>
${avatarHtml(f, 36)}
<div class="fr-info" onclick="openFriendProfile('${f.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();
}
function updateBadge() {
const badge = document.getElementById('mi-friends-badge');
if (pendingReceived.length > 0) {
badge.textContent = pendingReceived.length;
badge.classList.add('show');
} else {
badge.classList.remove('show');
}
}
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 allChk = document.getElementById('select-all-friends');
const allChecked = friendsList.every(f => document.getElementById('chk-' + f.uid)?.checked);
if (allChk) allChk.checked = allChecked;
}
function updateBulkRemoveBtn() {
const anyChecked = friendsList.some(f => document.getElementById('chk-' + f.uid)?.checked);
document.getElementById('bulk-remove-btn').style.display = anyChecked ? 'inline-flex' : 'none';
}
window.toggleSelectAll = function(checked) {
friendsList.forEach(f => {
const chk = document.getElementById('chk-' + f.uid);
const row = document.getElementById('fr-' + f.uid);
if (chk) chk.checked = checked;
if (row) row.classList.toggle('selected', checked);
});
updateBulkRemoveBtn();
}
window.showMoreFriends = function() {
friendsVisibleCount += 10;
renderFriendsPanel();
}
window.bulkRemoveFriends = async function() {
const selected = friendsList.filter(f => document.getElementById('chk-' + f.uid)?.checked);
if (!selected.length) return;
const names = selected.map(f => getFriendName(f)).join(', ');
if (!confirm(`Remove ${selected.length} friend${selected.length > 1 ? 's' : ''}?\n${names}`)) return;
for (const f of selected) await doRemoveFriend(f.uid);
showToast(`${selected.length} friend${selected.length > 1 ? 's' : ''} removed`, '');
document.getElementById('select-all-friends').checked = false;
}
async function doRemoveFriend(friendUid) {
const uid = currentUser.uid;
const frRef = collection(db, 'friendRequests');
const q1 = query(frRef, where('fromUid', '==', uid), where('toUid', '==', friendUid));
const q2 = query(frRef, where('fromUid', '==', friendUid), where('toUid', '==', uid));
const [s1, s2] = await Promise.all([getDocs(q1), getDocs(q2)]);
await Promise.all([...s1.docs, ...s2.docs].map(d => updateDoc(doc(db, 'friendRequests', d.id), { status: 'removed' })));
delete nicknames[friendUid];
await updateDoc(doc(db, 'users', currentUser.uid), { nicknames });
}
window.respondRequest = async function(requestId, status) {
await updateDoc(doc(db, 'friendRequests', requestId), { status });
showToast(status === 'accepted' ? 'Friend added! 🎉' : 'Request declined', status === 'accepted' ? 'success' : '');
}
// ─────────────────────────────────────────────
// FRIEND PROFILE
// ─────────────────────────────────────────────
let currentFriendProfile = null;
window.openFriendProfile = function(uid) {
const f = friendsList.find(x => x.uid === uid);
if (!f) return;
currentFriendProfile = f;
const name = getFriendName(f);
const displayName = f.displayName || 'No Display Name';
const nick = nicknames[uid] || '';
const avatarEl = document.getElementById('fp-avatar');
if (f.avatarUrl) {
avatarEl.innerHTML = `<img src="${esc(f.avatarUrl)}" style="width:100%;height:100%;object-fit:cover;border-radius:50%">`;
} else {
avatarEl.textContent = displayName[0].toUpperCase();
}
document.getElementById('fp-name-text').textContent = nick || displayName;
const nickShown = document.getElementById('fp-nickname-shown');
if (nick) {
nickShown.textContent = displayName;
nickShown.style.display = 'block';
} else {
nickShown.style.display = 'none';
}
const joined = f.createdAt?.toDate ? f.createdAt.toDate() : (f.createdAt ? new Date(f.createdAt) : null);
document.getElementById('fp-joined').textContent = joined ? joined.toLocaleDateString('en-US', { month:'long', day:'numeric', year:'numeric' }) : '—';
const steps = Object.values(f.stepsByDate || {});
const days = Object.keys(f.stepsByDate || {}).length;
const avg = days > 0 ? Math.round(steps.reduce((a,b) => a+b, 0) / days) : 0;
document.getElementById('fp-avg').textContent = avg.toLocaleString() + ' steps';
showScreen('friend-profile');
}
window.closeFriendProfile = function() {
showScreen('dashboard');
switchPanel('friends');
}
window.removeFriendFromProfile = async function() {
if (!currentFriendProfile) return;
const name = getFriendName(currentFriendProfile);
if (!confirm(`Remove ${name} from your friends?`)) return;
await doRemoveFriend(currentFriendProfile.uid);
showToast(`${name} removed`, '');
closeFriendProfile();
}
// ─────────────────────────────────────────────
// NICKNAME MODAL
// ─────────────────────────────────────────────
window.openNicknameModal = function() {
const uid = currentFriendProfile?.uid;
if (!uid) return;
document.getElementById('nickname-input').value = nicknames[uid] || '';
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 = currentFriendProfile?.uid;
if (!uid) return;
const val = document.getElementById('nickname-input').value.trim();
if (val) nicknames[uid] = val;
else delete nicknames[uid];
await updateDoc(doc(db, 'users', currentUser.uid), { nicknames });
currentUserData.nicknames = nicknames;
closeNicknameModal();
openFriendProfile(uid);
renderFriendsPanel();
renderFriendSteps();
showToast(val ? 'Nickname saved ✓' : 'Nickname cleared', 'success');
}
// ─────────────────────────────────────────────
// INVITE MODAL
// ─────────────────────────────────────────────
window.openInviteModal = async function() {
const token = generateToken().substring(0, 12);
const inviteUrl = `${APP_URL}?invite=${token}&from=${currentUser.uid}`;
await setDoc(doc(db, 'invites', token), {
fromUid: currentUser.uid,
fromName: currentUserData.displayName || 'Someone',
createdAt: serverTimestamp(),
expiresAt: new Date(Date.now() + 72 * 60 * 60 * 1000)
});
const name = currentUserData.displayName || 'Someone';
const msg = `${name} has invited you to join them on StepMates — a free step tracker that syncs steps from your phone and lets you compete with friends.\n\nTap the link to get started:\n${inviteUrl}`;
document.getElementById('invite-message-preview').textContent = msg;
document.getElementById('invite-modal').dataset.url = inviteUrl;
document.getElementById('invite-modal').dataset.msg = msg;
document.getElementById('invite-modal').classList.add('open');
}
window.closeInviteModal = function() {
document.getElementById('invite-modal').classList.remove('open');
}
window.closeInviteModalOutside = function(e) {
if (e.target === document.getElementById('invite-modal')) closeInviteModal();
}
window.copyInviteLink = function() {
const url = document.getElementById('invite-modal').dataset.url;
navigator.clipboard.writeText(url);
const btn = document.getElementById('copy-invite-btn');
btn.textContent = '✓ Copied!';
btn.style.color = 'var(--success)';
btn.style.borderColor = 'var(--success)';
setTimeout(() => {
btn.textContent = 'Copy Link';
btn.style.color = '';
btn.style.borderColor = '';
}, 2000);
}
window.shareInvite = function() {
const msg = document.getElementById('invite-modal').dataset.msg;
if (navigator.share) {
navigator.share({ text: msg });
} else {
navigator.clipboard.writeText(msg);
showToast('Invite copied to clipboard ✓', 'success');
}
closeInviteModal();
}
// ─────────────────────────────────────────────
// 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');
if (name === 'friends') loadFriends();
}
window.toggleMenu = function() {
const overlay = document.getElementById('menu-overlay');
const btn = document.getElementById('hamburger-btn');
const isOpen = overlay.classList.contains('open');
if (isOpen) { 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');
}
// ─────────────────────────────────────────────
// 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);
});
}
// ─────────────────────────────────────────────
// 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();
});
// ─────────────────────────────────────────────
// PROFILE MODAL
// ─────────────────────────────────────────────
window.openProfileModal = function() {
const modal = document.getElementById('profile-modal');
document.getElementById('profile-name-input').value = currentUserData.displayName || '';
document.getElementById('profile-avatar-input').value = currentUserData.avatarUrl || '';
document.getElementById('profile-error').style.display = 'none';
updateProfilePreview();
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 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) {
avatarEl.innerHTML = `<img src="${esc(url)}" alt="${initial}" onerror="this.parentElement.innerHTML='${initial}'">`;
} else {
avatarEl.innerHTML = `<span id="profile-modal-initial">${initial}</span>`;
}
}
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')) {
errEl.textContent = 'Avatar URL must start with http:// or https://';
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, 'users', currentUser.uid), { displayName: name, avatarUrl });
await updateDoc(doc(db, 'profiles', currentUser.uid), { displayName: name, avatarUrl });
currentUserData.displayName = name;
currentUserData.avatarUrl = avatarUrl;
refreshProfileUI();
renderFriendSteps();
renderFriendsPanel();
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';
}
}
// ─────────────────────────────────────────────
// INVITE LINK HANDLER
// ─────────────────────────────────────────────
async function handleInviteLink() {
const inviteToken = _savedInviteToken || localStorage.getItem('pendingInviteToken');
const fromUid = _savedFromUid || localStorage.getItem('pendingInviteFrom');
const inviteEmail = localStorage.getItem('pendingInviteEmail');
if (!inviteToken || !fromUid || fromUid === currentUser.uid) {
localStorage.removeItem('pendingInviteToken');
localStorage.removeItem('pendingInviteFrom');
localStorage.removeItem('pendingInviteEmail');
return;
}
if (inviteEmail && inviteEmail.toLowerCase() !== currentUser.email.toLowerCase()) {
return;
}
localStorage.removeItem('pendingInviteToken');
localStorage.removeItem('pendingInviteFrom');
localStorage.removeItem('pendingInviteEmail');
try {
const inviteRef = doc(db, 'invites', inviteToken);
const inviteSnap = await getDoc(inviteRef);
if (!inviteSnap.exists()) return;
const invite = inviteSnap.data();
if (invite.expiresAt.toDate() < new Date()) {
showToast('This invite link has expired.', 'error');
return;
}
const existingQ = query(collection(db, 'friendRequests'),
where('fromUid', '==', fromUid),
where('toUid', '==', currentUser.uid)
);
const existing = await getDocs(existingQ);
if (!existing.empty) {
const req = existing.docs[0];
if (req.data().status !== 'accepted') {
await updateDoc(doc(db, 'friendRequests', req.id), { status: 'accepted' });
showToast(`You and ${invite.fromName} are now StepMates! 🎉`, 'success');
}
return;
}
await addDoc(collection(db, 'friendRequests'), {
fromUid,
fromEmail: '',
fromName: invite.fromName,
toUid: currentUser.uid,
toEmail: currentUser.email,
status: 'accepted',
createdAt: serverTimestamp()
});
showToast(`You and ${invite.fromName} are now StepMates! 🎉`, 'success');
loadFriends();
} catch(e) {
console.error('Invite error:', e);
}
}
// ─────────────────────────────────────────────
// SIGN OUT
// ─────────────────────────────────────────────
window.signOut = async function() {
if (!confirm('Sign out?')) return;
teardownListeners();
await fbSignOut(auth);
currentUser = null;
currentUserData = null;
friendsList = [];
showScreen('auth');
showAuthForm();
}
// ─────────────────────────────────────────────
// TOAST
// ─────────────────────────────────────────────
let toastTimer;
window.showToast = function(msg, type='') {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'show ' + type;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { el.className = ''; }, 3000);
}
// ─────────────────────────────────────────────
// UTILITIES
// ─────────────────────────────────────────────
function esc(str) {
return (str || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// Allow Enter key on auth email input
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();
});
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(); }
});
let ptrStartY = 0;
let ptrTriggered = false;
document.addEventListener('touchstart', e => {
if (window.scrollY === 0) ptrStartY = e.touches[0].clientY;
}, { passive: true });
document.addEventListener('touchmove', e => {
if (!ptrStartY) return;
const delta = e.touches[0].clientY - ptrStartY;
if (delta > 80 && !ptrTriggered && document.getElementById('screen-dashboard').classList.contains('active')) {
ptrTriggered = true;
const ind = document.getElementById('ptr-indicator');
ind.classList.add('visible');
}
}, { passive: true });
document.addEventListener('touchend', () => {
if (ptrTriggered) {
teardownListeners();
loadFriends();
setTimeout(() => {
document.getElementById('ptr-indicator').classList.remove('visible');
}, 1000);
}
ptrStartY = 0;
ptrTriggered = false;
}, { passive: true });
</script>
</body>
</html>