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 : 12 px ;
--radius-lg : 20 px ;
}
html , body {
height : 100 % ;
background : var ( -- bg );
color : var ( -- white );
font-family : var ( -- font - body );
font-size : 15 px ;
line-height : 1.5 ;
-webkit- font-smoothing : antialiased ;
}
# app {
min-height : 100 vh ;
display : flex ;
flex-direction : column ;
}
/* ── SCREENS ── */
. screen { display : none ; flex-direction : column ; min-height : 100 vh ; }
. screen . active { display : flex ; }
/* ── NAV ── */
nav {
display : flex ;
align-items : center ;
justify-content : space-between ;
padding : 16 px 24 px ;
border-bottom : 1 px 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 : 20 px ;
color : var ( -- teal );
letter-spacing : -0.5 px ;
}
. nav-logo span { color : var ( -- white ); }
. nav-user {
display : flex ;
align-items : center ;
gap : 12 px ;
}
. avatar {
width : 36 px ;
height : 36 px ;
border-radius : 50 % ;
background : var ( -- teal - dim );
border : 1.5 px solid var ( -- teal );
display : flex ;
align-items : center ;
justify-content : center ;
font-family : var ( -- font - display );
font-weight : 700 ;
font-size : 14 px ;
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.2 s ;
}
. modal-overlay . open {
opacity : 1 ;
pointer-events : all ;
}
. modal {
background : var ( -- surface );
border : 1 px solid var ( -- border );
border-radius : var ( -- radius - lg ) var ( -- radius - lg ) 0 0 ;
padding : 24 px 24 px max ( 24 px , env ( safe - area - inset - bottom ));
width : 100 % ;
max-width : 520 px ;
transform : translateY ( 40 px );
transition : transform 0.3 s cubic-bezier ( 0.16 , 1 , 0.3 , 1 );
}
. modal-overlay . open . modal {
transform : translateY ( 0 );
}
. modal-handle {
width : 36 px ;
height : 4 px ;
background : var ( -- border );
border-radius : 2 px ;
margin : 0 auto 20 px ;
}
. modal h3 {
font-family : var ( -- font - display );
font-size : 20 px ;
font-weight : 700 ;
margin-bottom : 20 px ;
}
. modal-avatar-preview {
display : flex ;
align-items : center ;
gap : 16 px ;
margin-bottom : 20 px ;
}
. modal-avatar-preview . avatar-lg {
width : 64 px ;
height : 64 px ;
border-radius : 50 % ;
border : 2 px 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 : 24 px ;
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 : 13 px ;
color : var ( -- muted );
line-height : 1.5 ;
}
. modal-avatar-preview . avatar-preview-name {
font-family : var ( -- font - display );
font-weight : 600 ;
font-size : 16 px ;
color : var ( -- white );
margin-bottom : 2 px ;
}
. modal-actions {
display : flex ;
gap : 10 px ;
margin-top : 4 px ;
}
. modal-actions . btn { flex : 1 ; }
/* ── SAFE AREA / MOBILE ── */
. dashboard-content {
2026-06-18 14:22:23 -07:00
padding-bottom : 40 px ;
2026-06-18 13:46:17 -07:00
}
/* ── TOUCH TARGETS ── */
. btn { min-height : 46 px ; }
. btn-sm { min-height : 38 px ; }
. tab-btn { min-height : 38 px ; }
. bn-item { min-height : 56 px ; }
/* ── CONTAINERS ── */
. container {
width : 100 % ;
max-width : 680 px ;
margin : 0 auto ;
padding : 0 20 px ;
}
/* ── 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 : 40 px 20 px ;
}
. auth-box {
width : 100 % ;
max-width : 420 px ;
text-align : center ;
}
. auth-logo {
font-family : var ( -- font - display );
font-size : 42 px ;
font-weight : 700 ;
color : var ( -- teal );
letter-spacing : -1.5 px ;
line-height : 1 ;
margin-bottom : 6 px ;
}
. auth-logo span { color : var ( -- white ); }
. auth-tagline {
color : var ( -- muted );
font-size : 15 px ;
margin-bottom : 48 px ;
}
. auth-card {
background : var ( -- surface );
border : 1 px solid var ( -- border );
border-radius : var ( -- radius - lg );
padding : 32 px ;
text-align : left ;
}
. auth-card h2 {
font-family : var ( -- font - display );
font-size : 22 px ;
font-weight : 600 ;
margin-bottom : 6 px ;
}
. auth-card p {
color : var ( -- muted );
font-size : 14 px ;
margin-bottom : 24 px ;
}
/* ── FORMS ── */
label {
display : block ;
font-size : 13 px ;
font-weight : 500 ;
color : var ( -- muted );
margin-bottom : 6 px ;
letter-spacing : 0.3 px ;
text-transform : uppercase ;
font-size : 11 px ;
}
input [ type = "email" ],
input [ type = "text" ] {
width : 100 % ;
background : var ( -- bg );
border : 1.5 px solid var ( -- border );
border-radius : var ( -- radius );
color : var ( -- white );
font-family : var ( -- font - body );
font-size : 15 px ;
padding : 12 px 16 px ;
outline : none ;
transition : border-color 0.15 s ;
margin-bottom : 16 px ;
}
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 : 8 px ;
padding : 12 px 22 px ;
border-radius : var ( -- radius );
font-family : var ( -- font - display );
font-size : 15 px ;
font-weight : 600 ;
cursor : pointer ;
border : none ;
transition : opacity 0.15 s , transform 0.1 s ;
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.5 px solid var ( -- border );
}
. btn-ghost : hover { border-color : var ( -- teal ); color : var ( -- teal ); }
. btn-danger {
background : transparent ;
color : var ( -- danger );
border : 1.5 px solid var ( -- danger );
}
2026-06-18 16:15:15 -07:00
. btn-danger : hover { background : rgba ( 255 , 107 , 107 , 0.1 ); }
2026-06-18 13:46:17 -07:00
. btn-sm { padding : 7 px 14 px ; font-size : 13 px ; }
2026-06-18 16:15:15 -07:00
/* ── FRIEND ROW WITH CHECKBOX ── */
. friend-row {
display : flex ;
align-items : center ;
gap : 12 px ;
background : var ( -- surface );
border : 1 px solid var ( -- border );
border-radius : var ( -- radius );
padding : 12 px 14 px ;
transition : border-color 0.15 s ;
margin-bottom : 8 px ;
}
. 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 : 18 px ;
height : 18 px ;
accent-color : var ( -- teal );
cursor : pointer ;
flex-shrink : 0 ;
margin-bottom : 0 ;
}
. friend-row . fr-date {
font-size : 11 px ;
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 : 15 px ;
white-space : nowrap ;
overflow : hidden ;
text-overflow : ellipsis ;
}
. friend-row . fr-nick {
font-size : 12 px ;
color : var ( -- teal );
margin-top : 1 px ;
white-space : nowrap ;
overflow : hidden ;
text-overflow : ellipsis ;
}
2026-06-18 13:46:17 -07:00
/* ── ALERT / INFO BOXES ── */
. info-box {
background : var ( -- teal - dim );
border : 1 px solid var ( -- teal - glow );
border-radius : var ( -- radius );
padding : 14 px 16 px ;
font-size : 14 px ;
color : var ( -- teal );
margin-bottom : 20 px ;
line-height : 1.5 ;
}
. warning-box {
background : rgba ( 255 , 179 , 71 , 0.1 );
border : 1 px solid rgba ( 255 , 179 , 71 , 0.25 );
border-radius : var ( -- radius );
padding : 14 px 16 px ;
font-size : 14 px ;
color : var ( -- amber );
margin-bottom : 20 px ;
}
. error-msg {
color : var ( -- danger );
font-size : 13 px ;
margin-top : -10 px ;
margin-bottom : 14 px ;
}
/* ── ONBOARDING ── */
# screen-onboarding {
align-items : center ;
justify-content : center ;
padding : 40 px 20 px ;
background : radial-gradient ( ellipse at 50 % 0 % , rgba ( 0 , 201 , 177 , 0.06 ) 0 % , transparent 60 % ), var ( -- bg );
}
. onboarding-box {
width : 100 % ;
max-width : 480 px ;
}
. step-indicator {
display : flex ;
gap : 6 px ;
margin-bottom : 32 px ;
}
. step-dot {
height : 3 px ;
flex : 1 ;
border-radius : 2 px ;
background : var ( -- border );
transition : background 0.3 s ;
}
. 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 : 26 px ;
font-weight : 700 ;
margin-bottom : 8 px ;
letter-spacing : -0.5 px ;
}
. onboarding-step . sub {
color : var ( -- muted );
font-size : 15 px ;
margin-bottom : 28 px ;
}
/* ── BRIDGE APP CARDS ── */
. bridge-cards {
display : grid ;
grid-template-columns : 1 fr 1 fr ;
gap : 14 px ;
margin-bottom : 20 px ;
}
. bridge-card {
background : var ( -- surface );
border : 1.5 px solid var ( -- border );
border-radius : var ( -- radius );
padding : 16 px ;
text-decoration : none ;
color : var ( -- white );
transition : border-color 0.15 s ;
display : flex ;
flex-direction : column ;
gap : 8 px ;
}
. bridge-card : hover { border-color : var ( -- teal ); }
. bridge-card . bc-icon { font-size : 28 px ; }
. bridge-card . bc-name {
font-family : var ( -- font - display );
font-weight : 600 ;
font-size : 14 px ;
}
. bridge-card . bc-platform {
font-size : 12 px ;
color : var ( -- muted );
}
. bridge-card . bc-badge {
font-size : 11 px ;
background : var ( -- teal - dim );
color : var ( -- teal );
padding : 2 px 8 px ;
border-radius : 20 px ;
width : fit-content ;
}
/* ── WEBHOOK URL BOX ── */
. webhook-box {
background : var ( -- bg );
border : 1.5 px solid var ( -- border );
border-radius : var ( -- radius );
padding : 14 px 16 px ;
margin-bottom : 20 px ;
}
. webhook-box . wh-label {
font-size : 11 px ;
text-transform : uppercase ;
letter-spacing : 0.5 px ;
color : var ( -- muted );
margin-bottom : 8 px ;
}
. webhook-url-row {
display : flex ;
gap : 8 px ;
align-items : center ;
}
. webhook-url {
font-family : monospace ;
font-size : 12 px ;
color : var ( -- teal );
background : var ( -- surface2 );
padding : 8 px 12 px ;
border-radius : 8 px ;
flex : 1 ;
overflow : hidden ;
text-overflow : ellipsis ;
white-space : nowrap ;
border : 1 px solid var ( -- border );
}
. copy-btn {
background : var ( -- teal - dim );
border : 1 px solid var ( -- teal - glow );
color : var ( -- teal );
border-radius : 8 px ;
padding : 8 px 12 px ;
cursor : pointer ;
font-size : 13 px ;
font-weight : 600 ;
transition : background 0.15 s ;
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 : 28 px 0 80 px ;
}
/* ── MY STEPS CARD ── */
. my-steps-card {
background : linear-gradient ( 135 deg , var ( -- surface ) 0 % , var ( -- surface2 ) 100 % );
border : 1 px solid var ( -- border );
border-radius : var ( -- radius - lg );
padding : 28 px ;
margin-bottom : 24 px ;
position : relative ;
overflow : hidden ;
}
. my-steps-card :: before {
content : '' ;
position : absolute ;
top : -40 px ;
right : -40 px ;
width : 160 px ;
height : 160 px ;
background : radial-gradient ( circle , var ( -- teal - glow ) 0 % , transparent 70 % );
pointer-events : none ;
}
. msc-label {
font-size : 11 px ;
text-transform : uppercase ;
letter-spacing : 0.8 px ;
color : var ( -- muted );
margin-bottom : 6 px ;
}
. msc-steps {
font-family : var ( -- font - display );
font-size : 56 px ;
font-weight : 700 ;
color : var ( -- teal );
letter-spacing : -2 px ;
line-height : 1 ;
margin-bottom : 4 px ;
}
. msc-steps . loading { opacity : 0.3 ; }
. msc-sub {
color : var ( -- muted );
font-size : 13 px ;
margin-bottom : 20 px ;
}
. msc-bar-wrap {
background : var ( -- bg );
border-radius : 4 px ;
height : 6 px ;
overflow : hidden ;
}
. msc-bar {
height : 100 % ;
background : linear-gradient ( 90 deg , var ( -- teal ), #00e5cc );
border-radius : 4 px ;
transition : width 1 s cubic-bezier ( 0.16 , 1 , 0.3 , 1 );
width : 0 % ;
}
. msc-goal {
display : flex ;
justify-content : space-between ;
font-size : 12 px ;
color : var ( -- muted );
margin-top : 6 px ;
}
/* ── TIME TABS ── */
. time-tabs {
display : flex ;
gap : 6 px ;
background : var ( -- surface );
border : 1 px solid var ( -- border );
border-radius : 10 px ;
padding : 4 px ;
margin-bottom : 24 px ;
}
. tab-btn {
flex : 1 ;
padding : 8 px 4 px ;
border-radius : 7 px ;
border : none ;
background : transparent ;
color : var ( -- muted );
font-family : var ( -- font - display );
font-size : 13 px ;
font-weight : 600 ;
cursor : pointer ;
transition : all 0.15 s ;
}
. tab-btn . active {
background : var ( -- teal );
color : var ( -- bg );
}
/* ── SECTION HEADERS ── */
. section-header {
display : flex ;
align-items : center ;
justify-content : space-between ;
margin-bottom : 16 px ;
}
. section-title {
font-family : var ( -- font - display );
font-size : 17 px ;
font-weight : 600 ;
}
/* ── FRIEND CARDS ── */
. friends-list {
display : flex ;
flex-direction : column ;
gap : 10 px ;
}
. friend-card {
background : var ( -- surface );
border : 1 px solid var ( -- border );
border-radius : var ( -- radius );
padding : 16 px 18 px ;
display : flex ;
align-items : center ;
gap : 14 px ;
transition : border-color 0.15 s ;
}
. friend-card : hover { border-color : var ( -- border ); }
. friend-avatar {
width : 44 px ;
height : 44 px ;
border-radius : 50 % ;
background : var ( -- teal - dim );
border : 1.5 px solid var ( -- border );
display : flex ;
align-items : center ;
justify-content : center ;
font-family : var ( -- font - display );
font-weight : 700 ;
font-size : 17 px ;
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 : 15 px ;
white-space : nowrap ;
overflow : hidden ;
text-overflow : ellipsis ;
}
. friend-steps-sub {
font-size : 12 px ;
color : var ( -- muted );
margin-top : 1 px ;
}
. friend-steps {
font-family : var ( -- font - display );
font-weight : 700 ;
font-size : 22 px ;
color : var ( -- white );
letter-spacing : -0.5 px ;
flex-shrink : 0 ;
}
/* ── PENDING REQUESTS ── */
. pending-card {
background : rgba ( 255 , 179 , 71 , 0.06 );
border : 1 px solid rgba ( 255 , 179 , 71 , 0.2 );
border-radius : var ( -- radius );
padding : 14 px 16 px ;
display : flex ;
align-items : center ;
gap : 12 px ;
margin-bottom : 8 px ;
}
. pending-card . pending-info { flex : 1 ; }
. pending-card . pending-name { font-weight : 600 ; font-size : 14 px ; }
. pending-card . pending-sub { font-size : 12 px ; color : var ( -- muted ); }
. pending-actions { display : flex ; gap : 8 px ; }
/* ── ADD FRIEND PANEL ── */
. add-friend-panel {
background : var ( -- surface );
border : 1 px solid var ( -- border );
border-radius : var ( -- radius );
padding : 18 px ;
margin-bottom : 24 px ;
}
. add-friend-panel h3 {
font-family : var ( -- font - display );
font-size : 15 px ;
font-weight : 600 ;
margin-bottom : 12 px ;
}
. add-friend-row {
display : flex ;
gap : 8 px ;
}
. 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 : 48 px 24 px ;
color : var ( -- muted );
}
. empty-state . es-icon { font-size : 40 px ; margin-bottom : 12 px ; }
. empty-state h3 {
font-family : var ( -- font - display );
font-size : 17 px ;
font-weight : 600 ;
color : var ( -- white );
margin-bottom : 6 px ;
}
. empty-state p { font-size : 14 px ; }
/* ── TOAST ── */
# toast {
position : fixed ;
bottom : 24 px ;
left : 50 % ;
transform : translateX ( -50 % ) translateY ( 80 px );
background : var ( -- surface2 );
border : 1 px solid var ( -- border );
color : var ( -- white );
padding : 12 px 20 px ;
border-radius : 30 px ;
font-size : 14 px ;
font-weight : 500 ;
z-index : 9999 ;
transition : transform 0.3 s 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 : 8 px 0 ;
}
. magic-sent . big-icon { font-size : 48 px ; margin-bottom : 16 px ; }
. magic-sent h3 {
font-family : var ( -- font - display );
font-size : 20 px ;
font-weight : 600 ;
margin-bottom : 8 px ;
}
. magic-sent p { color : var ( -- muted ); font-size : 14 px ; line-height : 1.6 ; }
. magic-sent . resend-link {
color : var ( -- teal );
cursor : pointer ;
text-decoration : underline ;
font-size : 14 px ;
margin-top : 20 px ;
display : inline-block ;
}
2026-06-18 14:22:23 -07:00
/* ── HAMBURGER MENU ── */
. hamburger-btn {
width : 38 px ;
height : 38 px ;
2026-06-18 13:46:17 -07:00
display : flex ;
flex-direction : column ;
align-items : center ;
2026-06-18 14:22:23 -07:00
justify-content : center ;
gap : 5 px ;
2026-06-18 13:46:17 -07:00
cursor : pointer ;
background : transparent ;
2026-06-18 14:22:23 -07:00
border : none ;
padding : 4 px ;
border-radius : 8 px ;
transition : background 0.15 s ;
2026-06-18 13:46:17 -07:00
}
2026-06-18 14:22:23 -07:00
. hamburger-btn : hover { background : var ( -- surface2 ); }
. hamburger-btn span {
display : block ;
width : 20 px ;
height : 2 px ;
background : var ( -- white );
border-radius : 2 px ;
transition : all 0.2 s ;
}
. hamburger-btn . open span : nth-child ( 1 ) { transform : translateY ( 7 px ) rotate ( 45 deg ); }
. hamburger-btn . open span : nth-child ( 2 ) { opacity : 0 ; }
. hamburger-btn . open span : nth-child ( 3 ) { transform : translateY ( -7 px ) rotate ( -45 deg ); }
. 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.2 s ;
}
. menu-overlay . open . menu-backdrop { opacity : 1 ; }
. menu-drawer {
position : absolute ;
top : 0 ;
right : 0 ;
bottom : 0 ;
width : 240 px ;
background : var ( -- surface );
border-left : 1 px solid var ( -- border );
display : flex ;
flex-direction : column ;
transform : translateX ( 100 % );
transition : transform 0.25 s cubic-bezier ( 0.16 , 1 , 0.3 , 1 );
padding : 0 0 max ( 24 px , env ( safe - area - inset - bottom ));
}
. menu-overlay . open . menu-drawer { transform : translateX ( 0 ); }
. menu-header {
padding : 20 px 20 px 16 px ;
border-bottom : 1 px solid var ( -- border );
margin-bottom : 8 px ;
}
. menu-header . menu-user-name {
font-family : var ( -- font - display );
font-weight : 700 ;
font-size : 16 px ;
}
. menu-header . menu-user-email {
font-size : 12 px ;
color : var ( -- muted );
margin-top : 2 px ;
overflow : hidden ;
text-overflow : ellipsis ;
white-space : nowrap ;
}
. menu-item {
display : flex ;
align-items : center ;
gap : 14 px ;
padding : 14 px 20 px ;
cursor : pointer ;
background : transparent ;
border : none ;
color : var ( -- white );
font-family : var ( -- font - body );
font-size : 15 px ;
width : 100 % ;
text-align : left ;
transition : background 0.15 s ;
2026-06-18 13:46:17 -07:00
position : relative ;
}
2026-06-18 14:22:23 -07:00
. menu-item : hover { background : var ( -- surface2 ); }
. menu-item . active { color : var ( -- teal ); }
. menu-item . mi-icon { font-size : 20 px ; width : 24 px ; text-align : center ; }
. menu-item . mi-badge {
margin-left : auto ;
2026-06-18 13:46:17 -07:00
background : var ( -- danger );
color : white ;
2026-06-18 14:22:23 -07:00
font-size : 10 px ;
2026-06-18 13:46:17 -07:00
font-weight : 700 ;
border-radius : 10 px ;
2026-06-18 14:22:23 -07:00
padding : 2 px 7 px ;
2026-06-18 13:46:17 -07:00
display : none ;
}
2026-06-18 14:22:23 -07:00
. menu-item . mi-badge . show { display : block ; }
. menu-divider {
height : 1 px ;
background : var ( -- border );
margin : 8 px 20 px ;
}
. menu-spacer { flex : 1 ; }
2026-06-18 13:46:17 -07:00
/* ── PANEL VIEWS ── */
. panel { display : none ; }
. panel . active { display : block ; }
/* ── SETUP REMINDER ── */
. setup-remind {
background : linear-gradient ( 135 deg , rgba ( 255 , 179 , 71 , 0.08 ), rgba ( 255 , 179 , 71 , 0.03 ));
border : 1 px solid rgba ( 255 , 179 , 71 , 0.2 );
border-radius : var ( -- radius );
padding : 16 px 18 px ;
margin-bottom : 20 px ;
display : flex ;
align-items : center ;
gap : 14 px ;
}
. setup-remind . sr-icon { font-size : 26 px ; flex-shrink : 0 ; }
. setup-remind . sr-text h4 {
font-family : var ( -- font - display );
font-weight : 600 ;
font-size : 14 px ;
margin-bottom : 2 px ;
}
. setup-remind . sr-text p { font-size : 13 px ; color : var ( -- muted ); }
. setup-remind . sr-link {
color : var ( -- amber );
cursor : pointer ;
font-weight : 600 ;
text-decoration : underline ;
font-size : 13 px ;
white-space : nowrap ;
flex-shrink : 0 ;
background : none ;
border : none ;
}
/* ── MANUAL STEP ENTRY ── */
. manual-entry {
background : var ( -- surface );
border : 1 px solid var ( -- border );
border-radius : var ( -- radius );
padding : 18 px ;
margin-bottom : 20 px ;
}
. manual-entry h3 {
font-family : var ( -- font - display );
font-size : 14 px ;
font-weight : 600 ;
margin-bottom : 4 px ;
}
. manual-entry p { font-size : 13 px ; color : var ( -- muted ); margin-bottom : 14 px ; }
. manual-row { display : flex ; gap : 8 px ; }
. manual-row input { margin-bottom : 0 ; flex : 1 ; }
. manual-row . btn { width : auto ; }
/* ── RESPONSIVE ── */
@ media ( max-width : 480px ) {
. bridge-cards { grid-template-columns : 1 fr ; }
. msc-steps { font-size : 44 px ; }
. auth-card { padding : 24 px ; }
. time-tabs { gap : 3 px ; }
. tab-btn { font-size : 12 px ; padding : 8 px 2 px ; }
}
2026-06-18 14:12:29 -07:00
/* ── PULL TO REFRESH ── */
. ptr-indicator {
position : fixed ;
top : 0 ;
left : 50 % ;
transform : translateX ( -50 % ) translateY ( -48 px );
background : var ( -- surface2 );
border : 1 px solid var ( -- border );
border-radius : 20 px ;
padding : 8 px 16 px ;
font-size : 13 px ;
color : var ( -- teal );
z-index : 200 ;
transition : transform 0.2 s ;
pointer-events : none ;
white-space : nowrap ;
}
. ptr-indicator . visible { transform : translateX ( -50 % ) translateY ( 12 px ); }
2026-06-18 13:46:17 -07:00
. spinner {
display : inline-block ;
width : 18 px ;
height : 18 px ;
border : 2 px solid rgba ( 255 , 255 , 255 , 0.2 );
border-top-color : currentColor ;
border-radius : 50 % ;
animation : spin 0.6 s linear infinite ;
}
@ keyframes spin { to { transform : rotate ( 360 deg ); } }
. loader-full {
display : flex ;
align-items : center ;
justify-content : center ;
flex : 1 ;
flex-direction : column ;
gap : 16 px ;
color : var ( -- muted );
font-size : 14 px ;
}
. loader-full . spinner { width : 32 px ; height : 32 px ; border-width : 3 px ; 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 >
2026-06-18 14:38:34 -07:00
<!-- 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 >
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 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 >
2026-06-18 14:22:23 -07:00
< div style = "display:flex;align-items:center;gap:10px" >
< div class = "avatar" id = "nav-avatar" onclick = "openProfileModal()" title = "Edit profile" style = "cursor:pointer" >
2026-06-18 13:46:17 -07:00
< span id = "nav-avatar-initial" > ?</ span >
</ div >
2026-06-18 14:22:23 -07:00
< 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 >
2026-06-18 14:22:23 -07:00
<!-- 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 >
<!-- 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 >
2026-06-18 14:58:56 -07:00
< span id = "goal-label" onclick = "openGoalModal()" style = "cursor:pointer;text-decoration:underline;text-underline-offset:3px;text-decoration-style:dotted" > Goal: 5,000</ span >
2026-06-18 13:46:17 -07:00
</ 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 >
2026-06-18 14:22:23 -07:00
<!-- PANEL: MY STEPS -->
2026-06-18 13:46:17 -07:00
< div id = "panel-home" class = "panel active" >
2026-06-18 14:22:23 -07:00
</ div >
2026-06-18 13:46:17 -07:00
2026-06-18 14:22:23 -07:00
<!-- PANEL: FRIENDS -->
< div id = "panel-friends" class = "panel" >
2026-06-18 13:46:17 -07:00
<!-- Pending received requests -->
2026-06-18 16:15:15 -07:00
< div id = "pending-received-section" style = "display:none;margin-bottom:20px" >
2026-06-18 13:46:17 -07:00
< div class = "section-header" >
< div class = "section-title" > Friend Requests</ div >
</ div >
< div id = "pending-received-list" ></ div >
</ div >
2026-06-18 16:15:15 -07:00
<!-- 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 >
2026-06-18 13:46:17 -07:00
</ div >
<!-- Sent requests -->
2026-06-18 16:15:15 -07:00
< div id = "pending-sent-section" style = "display:none;margin-top:24px" >
2026-06-18 13:46:17 -07:00
< div class = "section-header" >
2026-06-18 16:15:15 -07:00
< div class = "section-title" > Sent Invites</ div >
2026-06-18 13:46:17 -07:00
</ 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 >
2026-06-18 16:15:15 -07:00
<!-- 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" >
< button class = "btn btn-ghost" onclick = "closeInviteModal()" > Cancel</ button >
< button class = "btn btn-primary" onclick = "shareInvite()" > 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 >
2026-06-18 13:46:17 -07:00
<!-- 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 >
2026-06-18 14:58:56 -07:00
<!-- 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 >
2026-06-18 13:46:17 -07:00
</ div > <!-- /app -->
2026-06-18 14:12:29 -07:00
< div id = "ptr-indicator" class = "ptr-indicator" > ↓ Refreshing…</ div >
2026-06-18 13:46:17 -07:00
< 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 ( /\/$/ , '' );
2026-06-18 14:58:56 -07:00
const STEP_GOAL = 5000 ;
2026-06-18 13:46:17 -07:00
// ─────────────────────────────────────────────
// 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 {
2026-06-18 19:09:20 -07:00
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' );
2026-06-18 13:46:17 -07:00
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 || '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 )) {
2026-06-18 14:38:34 -07:00
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 ) {
2026-06-18 14:38:34 -07:00
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 ) {
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' );
}
});
2026-06-18 14:38:34 -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 ) {
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 {
2026-06-18 16:25:59 -07:00
await fetch ( ` ${ window . STEPMATES_CONFIG . adminNotifyUrl } ?uid= ${ uid } &email= ${ encodeURIComponent ( email ) } ` );
2026-06-18 13:46:17 -07:00
} 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' );
2026-06-18 16:15:15 -07:00
nicknames = currentUserData . nicknames || {};
2026-06-18 13:46:17 -07:00
refreshProfileUI ();
document . getElementById ( 'settings-email' ). textContent = currentUser . email ;
document . getElementById ( 'settings-webhook-url' ). textContent = buildWebhookUrl ( currentUserData . webhookToken );
renderMySteps ();
loadFriends ();
checkSetupReminder ();
2026-06-18 16:15:15 -07:00
handleInviteLink ();
2026-06-18 13:46:17 -07:00
}
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 ;
2026-06-18 14:22:23 -07:00
document . getElementById ( 'menu-user-name' ). textContent = name ;
document . getElementById ( 'menu-user-email' ). textContent = currentUser . email ;
2026-06-18 13:46:17 -07:00
}
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' };
2026-06-18 14:58:56 -07:00
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 };
}
2026-06-18 13:46:17 -07:00
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 ) {
2026-06-18 14:28:45 -07:00
const parts = k . split ( '-' );
const d = new Date ( parseInt ( parts [ 0 ]), parseInt ( parts [ 1 ]) - 1 , parseInt ( parts [ 2 ]));
2026-06-18 13:46:17 -07:00
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 ();
2026-06-18 14:58:56 -07:00
const GOALS = getGoals ();
2026-06-18 13:46:17 -07:00
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 ();
2026-06-18 14:16:01 -07:00
await updateDoc ( doc ( db , 'users' , currentUser . uid ), { [ `stepsByDate. ${ today } ` ] : val });
await updateDoc ( doc ( db , 'profiles' , currentUser . uid ), { [ `stepsByDate. ${ today } ` ] : val });
2026-06-18 13:46:17 -07:00
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' ) } ` ;
}
// ─────────────────────────────────────────────
2026-06-18 14:12:29 -07:00
// FRIENDS SYSTEM — REAL-TIME LISTENERS
2026-06-18 13:46:17 -07:00
// ─────────────────────────────────────────────
let friendsList = [];
let pendingReceived = [];
let pendingSent = [];
2026-06-18 14:12:29 -07:00
let friendUnsubscribers = [];
2026-06-18 13:46:17 -07:00
2026-06-18 14:12:29 -07:00
function teardownListeners () {
friendUnsubscribers . forEach ( fn => fn ());
friendUnsubscribers = [];
}
function loadFriends () {
2026-06-18 13:46:17 -07:00
if ( ! currentUser ) return ;
2026-06-18 14:12:29 -07:00
teardownListeners ();
2026-06-18 13:46:17 -07:00
const uid = currentUser . uid ;
const frRef = collection ( db , 'friendRequests' );
2026-06-18 14:12:29 -07:00
const unsubOwn = onSnapshot ( doc ( db , 'users' , uid ), snap => {
if ( ! snap . exists ()) return ;
currentUserData = { ... currentUserData , ... snap . data () };
renderMySteps ();
checkSetupReminder ();
});
friendUnsubscribers . push ( unsubOwn );
2026-06-18 13:46:17 -07:00
2026-06-18 14:12:29 -07:00
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 () }));
2026-06-18 13:46:17 -07:00
2026-06-18 14:12:29 -07:00
pendingSent = sentSnap . docs
. filter ( d => d . data (). status === 'pending' )
. map ( d => ({ id : d . id , ... d . data () }));
2026-06-18 13:46:17 -07:00
2026-06-18 14:12:29 -07:00
const confirmedFromMe = sentSnap . docs
. filter ( d => d . data (). status === 'accepted' )
. map ( d => d . data (). toUid );
2026-06-18 13:46:17 -07:00
2026-06-18 14:12:29 -07:00
const confirmedToMe = receivedSnap . docs
. filter ( d => d . data (). status === 'accepted' )
. map ( d => d . data (). fromUid );
2026-06-18 13:46:17 -07:00
2026-06-18 14:12:29 -07:00
const allFriendUids = [... new Set ([... confirmedFromMe , ... confirmedToMe ])];
2026-06-18 13:46:17 -07:00
2026-06-18 14:12:29 -07:00
friendUnsubscribers . filter ( fn => fn . _isFriendProfile ). forEach ( fn => fn ());
friendUnsubscribers = friendUnsubscribers . filter ( fn => ! fn . _isFriendProfile );
2026-06-18 13:46:17 -07:00
2026-06-18 14:12:29 -07:00
friendsList = [];
for ( const fuid of allFriendUids ) {
2026-06-18 15:35:42 -07:00
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 ;
2026-06-18 14:12:29 -07:00
const unsubProfile = onSnapshot ( doc ( db , 'profiles' , fuid ), profileSnap => {
if ( ! profileSnap . exists ()) return ;
const idx = friendsList . findIndex ( f => f . uid === fuid );
2026-06-18 15:35:42 -07:00
const data = { uid : fuid , addedAt , ... profileSnap . data () };
2026-06-18 14:12:29 -07:00
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 );
2026-06-18 13:46:17 -07:00
}
2026-06-18 16:15:15 -07:00
let friendsVisibleCount = 10 ;
let nicknames = {};
function getFriendName ( f ) {
return nicknames [ f . uid ] || f . displayName || 'No Display Name' ;
}
2026-06-18 13:46:17 -07:00
function avatarHtml ( f , size = 44 ) {
2026-06-18 16:15:15 -07:00
const name = f . displayName || '?' ;
2026-06-18 13:46:17 -07:00
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' );
2026-06-18 16:15:15 -07:00
if ( ! el ) return ;
2026-06-18 13:46:17 -07:00
if ( friendsList . length === 0 ) {
2026-06-18 16:15:15 -07:00
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>` ;
2026-06-18 13:46:17 -07:00
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 );
2026-06-18 16:15:15 -07:00
const name = getFriendName ( f );
2026-06-18 13:46:17 -07:00
return `
2026-06-18 16:15:15 -07:00
<div class="friend-card" onclick="openFriendProfile(' ${ f . uid } ')" style="cursor:pointer">
2026-06-18 13:46:17 -07:00
${ avatarHtml ( f ) }
<div class="friend-info">
2026-06-18 16:15:15 -07:00
<div class="friend-name"> ${ esc ( name ) } </div>
2026-06-18 13:46:17 -07:00
<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>
2026-06-18 16:15:15 -07:00
<div class="friend-steps-sub">Invite pending…</div>
2026-06-18 13:46:17 -07:00
</div>
</div>` ). join ( '' );
} else {
psSection . style . display = 'none' ;
}
const fml = document . getElementById ( 'friends-manage-list' );
2026-06-18 16:15:15 -07:00
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 )));
2026-06-18 13:46:17 -07:00
} else {
2026-06-18 16:15:15 -07:00
filtered . sort (( a , b ) => ( b . addedAt || 0 ) - ( a . addedAt || 0 ));
2026-06-18 13:46:17 -07:00
}
2026-06-18 16:15:15 -07:00
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 ();
2026-06-18 13:46:17 -07:00
}
function updateBadge () {
2026-06-18 14:22:23 -07:00
const badge = document . getElementById ( 'mi-friends-badge' );
if ( pendingReceived . length > 0 ) {
badge . textContent = pendingReceived . length ;
badge . classList . add ( 'show' );
} else {
badge . classList . remove ( 'show' );
}
2026-06-18 13:46:17 -07:00
}
2026-06-18 16:15:15 -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 allChk = document . getElementById ( 'select-all-friends' );
const allChecked = friendsList . every ( f => document . getElementById ( 'chk-' + f . uid ) ? . checked );
if ( allChk ) allChk . checked = allChecked ;
}
2026-06-18 13:46:17 -07:00
2026-06-18 16:15:15 -07:00
function updateBulkRemoveBtn () {
const anyChecked = friendsList . some ( f => document . getElementById ( 'chk-' + f . uid ) ? . checked );
document . getElementById ( 'bulk-remove-btn' ). style . display = anyChecked ? 'inline-flex' : 'none' ;
}
2026-06-18 13:46:17 -07:00
2026-06-18 16:15:15 -07:00
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 );
2026-06-18 13:46:17 -07:00
});
2026-06-18 16:15:15 -07:00
updateBulkRemoveBtn ();
2026-06-18 13:46:17 -07:00
}
2026-06-18 16:15:15 -07:00
window . showMoreFriends = function () {
friendsVisibleCount += 10 ;
renderFriendsPanel ();
2026-06-18 13:46:17 -07:00
}
2026-06-18 16:15:15 -07:00
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 ) {
2026-06-18 15:35:42 -07:00
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 )]);
2026-06-18 16:15:15 -07:00
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 . 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 ();
2026-06-18 15:35:42 -07:00
}
2026-06-18 13:46:17 -07:00
// ─────────────────────────────────────────────
// PANEL SWITCHING
// ─────────────────────────────────────────────
window . switchPanel = function ( name ) {
document . querySelectorAll ( '.panel' ). forEach ( p => p . classList . remove ( 'active' ));
document . getElementById ( 'panel-' + name ). classList . add ( 'active' );
2026-06-18 14:22:23 -07:00
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
if ( name === 'friends' ) loadFriends ();
}
2026-06-18 14:22:23 -07:00
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' );
}
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 14:58:56 -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 ();
});
2026-06-18 13:46:17 -07:00
// ─────────────────────────────────────────────
// 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' ;
}
}
2026-06-18 16:15:15 -07:00
// ─────────────────────────────────────────────
// INVITE LINK HANDLER
// ─────────────────────────────────────────────
async function handleInviteLink () {
const params = new URLSearchParams ( window . location . search );
const inviteToken = params . get ( 'invite' );
const fromUid = params . get ( 'from' );
if ( ! inviteToken || ! fromUid || fromUid === currentUser . uid ) return ;
window . history . replaceState ({}, document . title , APP_URL );
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 );
}
}
2026-06-18 13:46:17 -07:00
// ─────────────────────────────────────────────
// SIGN OUT
// ─────────────────────────────────────────────
window . signOut = async function () {
if ( ! confirm ( 'Sign out?' )) return ;
2026-06-18 14:12:29 -07:00
teardownListeners ();
2026-06-18 13:46:17 -07:00
await fbSignOut ( auth );
currentUser = null ;
currentUserData = null ;
2026-06-18 14:12:29 -07:00
friendsList = [];
2026-06-18 13:46:17 -07:00
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 , '&' ). replace ( /</g , '<' ). replace ( />/g , '>' );
}
// Allow Enter key on auth email input
document . getElementById ( 'auth-email' ). addEventListener ( 'keydown' , e => {
if ( e . key === 'Enter' ) sendMagicLink ();
});
2026-06-18 14:38:34 -07:00
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 => {
2026-06-18 14:58:56 -07:00
if ( e . key === 'Escape' ) { closeProfileModal (); closeGoalModal (); closeMenu (); }
2026-06-18 13:46:17 -07:00
});
2026-06-18 14:12:29 -07:00
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 });
2026-06-18 13:46:17 -07:00
</ script >
</ body >
</ html >