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