@@ -1623,12 +1735,14 @@ window.finishOnboarding = async function() {
// ─────────────────────────────────────────────
function loadDashboard() {
showScreen('dashboard');
+ nicknames = currentUserData.nicknames || {};
refreshProfileUI();
document.getElementById('settings-email').textContent = currentUser.email;
document.getElementById('settings-webhook-url').textContent = buildWebhookUrl(currentUserData.webhookToken);
renderMySteps();
loadFriends();
checkSetupReminder();
+ handleInviteLink();
}
function refreshProfileUI() {
@@ -1834,8 +1948,15 @@ function loadFriends() {
friendUnsubscribers.push(unsubReceived);
}
+let friendsVisibleCount = 10;
+let nicknames = {};
+
+function getFriendName(f) {
+ return nicknames[f.uid] || f.displayName || 'No Display Name';
+}
+
function avatarHtml(f, size=44) {
- const name = f.displayName || f.email || '?';
+ const name = f.displayName || '?';
const initial = name[0].toUpperCase();
const url = f.avatarUrl || '';
if (url) {
@@ -1846,18 +1967,20 @@ function avatarHtml(f, size=44) {
function renderFriendSteps() {
const el = document.getElementById('friends-steps-list');
+ if (!el) return;
if (friendsList.length === 0) {
- el.innerHTML = `
`;
return;
}
const sorted = [...friendsList].sort((a,b) => getStepsForPeriod(b.stepsByDate, currentTab) - getStepsForPeriod(a.stepsByDate, currentTab));
el.innerHTML = sorted.map(f => {
const steps = getStepsForPeriod(f.stepsByDate, currentTab);
+ const name = getFriendName(f);
return `
-
+
${avatarHtml(f)}
-
${esc(f.displayName || f.email)}
+
${esc(name)}
${TAB_LABELS[currentTab]}
${steps.toLocaleString()}
@@ -1894,7 +2017,7 @@ function renderFriendsPanel() {
${(req.toEmail||'?')[0].toUpperCase()}
${esc(req.toEmail)}
-
Request pending…
+
Invite pending…
`).join('');
} else {
@@ -1902,23 +2025,46 @@ function renderFriendsPanel() {
}
const fml = document.getElementById('friends-manage-list');
- if (friendsList.length === 0) {
- fml.innerHTML = `
🤝
No friends yet
Send a request above to add your first StepMate.
`;
+ const search = (document.getElementById('friends-search')?.value || '').toLowerCase();
+ const sortBy = document.getElementById('friends-sort')?.value || 'date';
+
+ let filtered = friendsList.filter(f => {
+ const name = getFriendName(f).toLowerCase();
+ const display = (f.displayName || '').toLowerCase();
+ return !search || name.includes(search) || display.includes(search);
+ });
+
+ if (sortBy === 'name') {
+ filtered.sort((a,b) => getFriendName(a).localeCompare(getFriendName(b)));
} else {
- const sortedByAdded = [...friendsList].sort((a,b) => (b.addedAt || 0) - (a.addedAt || 0));
- fml.innerHTML = sortedByAdded.map(f => {
- const steps = getStepsForPeriod(f.stepsByDate, currentTab);
- return `
-
- ${avatarHtml(f)}
-
-
${esc(f.displayName || f.email)}
-
${steps.toLocaleString()} steps today
-
-
-
`;
- }).join('');
+ filtered.sort((a,b) => (b.addedAt || 0) - (a.addedAt || 0));
}
+
+ if (filtered.length === 0) {
+ fml.innerHTML = `
🤝
${search ? 'No matches' : 'No friends yet'}
${search ? 'Try a different search.' : 'Tap + Invite to add your first StepMate.'}
`;
+ document.getElementById('friends-show-more').style.display = 'none';
+ return;
+ }
+
+ const visible = filtered.slice(0, friendsVisibleCount);
+ fml.innerHTML = visible.map(f => {
+ const nick = nicknames[f.uid] || '';
+ const displayName = f.displayName || 'No Display Name';
+ const addedDate = f.addedAt ? new Date(f.addedAt).toLocaleDateString('en-US', { month:'short', day:'numeric', year:'numeric' }) : '—';
+ return `
+
+
+
${addedDate}
+ ${avatarHtml(f, 36)}
+
+
${esc(nick || displayName)}
+ ${nick ? `
${esc(displayName)}
` : ''}
+
+
`;
+ }).join('');
+
+ document.getElementById('friends-show-more').style.display = filtered.length > friendsVisibleCount ? 'block' : 'none';
+ updateBulkRemoveBtn();
}
function updateBadge() {
@@ -1931,91 +2077,188 @@ function updateBadge() {
}
}
-window.sendFriendRequest = async function() {
- const emailInput = document.getElementById('add-friend-email');
- const errEl = document.getElementById('add-friend-error');
- const btn = document.querySelector('#panel-friends .add-friend-row .btn');
- const email = emailInput.value.trim().toLowerCase();
- errEl.style.display = 'none';
- errEl.style.color = '';
+window.onFriendCheck = function(e, uid) {
+ e.stopPropagation();
+ const row = document.getElementById('fr-' + uid);
+ const chk = document.getElementById('chk-' + uid);
+ row.classList.toggle('selected', chk.checked);
+ updateBulkRemoveBtn();
+ const allChk = document.getElementById('select-all-friends');
+ const allChecked = friendsList.every(f => document.getElementById('chk-' + f.uid)?.checked);
+ if (allChk) allChk.checked = allChecked;
+}
- if (!email || !/\S+@\S+\.\S+/.test(email)) {
- errEl.textContent = 'Please enter a valid email.';
- errEl.style.display = 'block';
- return;
- }
- if (email === currentUser.email.toLowerCase()) {
- errEl.textContent = "That's your own email!";
- errEl.style.display = 'block';
- return;
- }
+function updateBulkRemoveBtn() {
+ const anyChecked = friendsList.some(f => document.getElementById('chk-' + f.uid)?.checked);
+ document.getElementById('bulk-remove-btn').style.display = anyChecked ? 'inline-flex' : 'none';
+}
- btn.disabled = true;
- btn.innerHTML = '
';
-
- const usersQ = query(collection(db, 'users'), where('email', '==', email));
- const snap = await getDocs(usersQ);
- if (snap.empty) {
- errEl.textContent = 'No StepMates account found for that email.';
- errEl.style.display = 'block';
- btn.disabled = false;
- btn.innerHTML = 'Send Request';
- return;
- }
-
- const toUser = snap.docs[0];
- const toUid = toUser.id;
-
- const existingQ = query(collection(db, 'friendRequests'),
- where('fromUid', '==', currentUser.uid),
- where('toUid', '==', toUid)
- );
- const existingSnap = await getDocs(existingQ);
- if (!existingSnap.empty) {
- errEl.textContent = 'You already sent a request to this person.';
- errEl.style.display = 'block';
- btn.disabled = false;
- btn.innerHTML = 'Send Request';
- return;
- }
-
- await addDoc(collection(db, 'friendRequests'), {
- fromUid: currentUser.uid,
- fromEmail: currentUser.email,
- fromName: currentUserData.displayName || currentUser.email,
- toUid,
- toEmail: email,
- status: 'pending',
- createdAt: serverTimestamp()
+window.toggleSelectAll = function(checked) {
+ friendsList.forEach(f => {
+ const chk = document.getElementById('chk-' + f.uid);
+ const row = document.getElementById('fr-' + f.uid);
+ if (chk) chk.checked = checked;
+ if (row) row.classList.toggle('selected', checked);
});
-
- emailInput.value = '';
- btn.disabled = false;
- btn.innerHTML = 'Send Request';
- errEl.textContent = '✓ Friend request sent!';
- errEl.style.color = 'var(--success)';
- errEl.style.display = 'block';
- setTimeout(() => { errEl.style.display = 'none'; }, 4000);
- await loadFriends();
+ updateBulkRemoveBtn();
}
-window.respondRequest = async function(requestId, status) {
- await updateDoc(doc(db, 'friendRequests', requestId), { status });
- showToast(status === 'accepted' ? 'Friend added! 🎉' : 'Request declined', status === 'accepted' ? 'success' : '');
- await loadFriends();
+window.showMoreFriends = function() {
+ friendsVisibleCount += 10;
+ renderFriendsPanel();
}
-window.removeFriend = async function(friendUid, friendName) {
- if (!confirm(`Remove ${friendName} from your friends?`)) return;
+window.bulkRemoveFriends = async function() {
+ const selected = friendsList.filter(f => document.getElementById('chk-' + f.uid)?.checked);
+ if (!selected.length) return;
+ const names = selected.map(f => getFriendName(f)).join(', ');
+ if (!confirm(`Remove ${selected.length} friend${selected.length > 1 ? 's' : ''}?\n${names}`)) return;
+ for (const f of selected) await doRemoveFriend(f.uid);
+ showToast(`${selected.length} friend${selected.length > 1 ? 's' : ''} removed`, '');
+ document.getElementById('select-all-friends').checked = false;
+}
+
+async function doRemoveFriend(friendUid) {
const uid = currentUser.uid;
const frRef = collection(db, 'friendRequests');
const q1 = query(frRef, where('fromUid', '==', uid), where('toUid', '==', friendUid));
const q2 = query(frRef, where('fromUid', '==', friendUid), where('toUid', '==', uid));
const [s1, s2] = await Promise.all([getDocs(q1), getDocs(q2)]);
- const deletes = [...s1.docs, ...s2.docs].map(d => updateDoc(doc(db, 'friendRequests', d.id), { status: 'removed' }));
- await Promise.all(deletes);
- showToast(`${friendName} removed`, '');
- await loadFriends();
+ await Promise.all([...s1.docs, ...s2.docs].map(d => updateDoc(doc(db, 'friendRequests', d.id), { status: 'removed' })));
+ delete nicknames[friendUid];
+ await updateDoc(doc(db, 'users', currentUser.uid), { nicknames });
+}
+
+window.respondRequest = async function(requestId, status) {
+ await updateDoc(doc(db, 'friendRequests', requestId), { status });
+ showToast(status === 'accepted' ? 'Friend added! 🎉' : 'Request declined', status === 'accepted' ? 'success' : '');
+}
+
+// ─────────────────────────────────────────────
+// FRIEND PROFILE
+// ─────────────────────────────────────────────
+let currentFriendProfile = null;
+
+window.openFriendProfile = function(uid) {
+ const f = friendsList.find(x => x.uid === uid);
+ if (!f) return;
+ currentFriendProfile = f;
+ const name = getFriendName(f);
+ const displayName = f.displayName || 'No Display Name';
+ const nick = nicknames[uid] || '';
+
+ const avatarEl = document.getElementById('fp-avatar');
+ if (f.avatarUrl) {
+ avatarEl.innerHTML = `
})
`;
+ } else {
+ avatarEl.textContent = displayName[0].toUpperCase();
+ }
+
+ document.getElementById('fp-name-text').textContent = nick || displayName;
+ const nickShown = document.getElementById('fp-nickname-shown');
+ if (nick) {
+ nickShown.textContent = displayName;
+ nickShown.style.display = 'block';
+ } else {
+ nickShown.style.display = 'none';
+ }
+
+ const joined = f.createdAt?.toDate ? f.createdAt.toDate() : (f.createdAt ? new Date(f.createdAt) : null);
+ document.getElementById('fp-joined').textContent = joined ? joined.toLocaleDateString('en-US', { month:'long', day:'numeric', year:'numeric' }) : '—';
+
+ const steps = Object.values(f.stepsByDate || {});
+ const days = Object.keys(f.stepsByDate || {}).length;
+ const avg = days > 0 ? Math.round(steps.reduce((a,b) => a+b, 0) / days) : 0;
+ document.getElementById('fp-avg').textContent = avg.toLocaleString() + ' steps';
+
+ showScreen('friend-profile');
+}
+
+window.closeFriendProfile = function() {
+ showScreen('dashboard');
+ switchPanel('friends');
+}
+
+window.removeFriendFromProfile = async function() {
+ if (!currentFriendProfile) return;
+ const name = getFriendName(currentFriendProfile);
+ if (!confirm(`Remove ${name} from your friends?`)) return;
+ await doRemoveFriend(currentFriendProfile.uid);
+ showToast(`${name} removed`, '');
+ closeFriendProfile();
+}
+
+// ─────────────────────────────────────────────
+// NICKNAME MODAL
+// ─────────────────────────────────────────────
+window.openNicknameModal = function() {
+ const uid = currentFriendProfile?.uid;
+ if (!uid) return;
+ document.getElementById('nickname-input').value = nicknames[uid] || '';
+ document.getElementById('nickname-modal').classList.add('open');
+ setTimeout(() => document.getElementById('nickname-input').focus(), 300);
+}
+
+window.closeNicknameModal = function() {
+ document.getElementById('nickname-modal').classList.remove('open');
+}
+
+window.closeNicknameModalOutside = function(e) {
+ if (e.target === document.getElementById('nickname-modal')) closeNicknameModal();
+}
+
+window.saveNickname = async function() {
+ const uid = currentFriendProfile?.uid;
+ if (!uid) return;
+ const val = document.getElementById('nickname-input').value.trim();
+ if (val) nicknames[uid] = val;
+ else delete nicknames[uid];
+ await updateDoc(doc(db, 'users', currentUser.uid), { nicknames });
+ currentUserData.nicknames = nicknames;
+ closeNicknameModal();
+ openFriendProfile(uid);
+ renderFriendsPanel();
+ renderFriendSteps();
+ showToast(val ? 'Nickname saved ✓' : 'Nickname cleared', 'success');
+}
+
+// ─────────────────────────────────────────────
+// INVITE MODAL
+// ─────────────────────────────────────────────
+window.openInviteModal = async function() {
+ const token = generateToken().substring(0, 12);
+ const inviteUrl = `${APP_URL}?invite=${token}&from=${currentUser.uid}`;
+ await setDoc(doc(db, 'invites', token), {
+ fromUid: currentUser.uid,
+ fromName: currentUserData.displayName || 'Someone',
+ createdAt: serverTimestamp(),
+ expiresAt: new Date(Date.now() + 72 * 60 * 60 * 1000)
+ });
+ const name = currentUserData.displayName || 'Someone';
+ const msg = `${name} has invited you to join them on StepMates — a free step tracker that syncs steps from your phone and lets you compete with friends.\n\nTap the link to get started:\n${inviteUrl}`;
+ document.getElementById('invite-message-preview').textContent = msg;
+ document.getElementById('invite-modal').dataset.url = inviteUrl;
+ document.getElementById('invite-modal').dataset.msg = msg;
+ document.getElementById('invite-modal').classList.add('open');
+}
+
+window.closeInviteModal = function() {
+ document.getElementById('invite-modal').classList.remove('open');
+}
+
+window.closeInviteModalOutside = function(e) {
+ if (e.target === document.getElementById('invite-modal')) closeInviteModal();
+}
+
+window.shareInvite = function() {
+ const msg = document.getElementById('invite-modal').dataset.msg;
+ if (navigator.share) {
+ navigator.share({ text: msg });
+ } else {
+ navigator.clipboard.writeText(msg);
+ showToast('Invite copied to clipboard ✓', 'success');
+ }
+ closeInviteModal();
}
// ─────────────────────────────────────────────
@@ -2166,6 +2409,53 @@ window.saveProfile = async function() {
}
}
+// ─────────────────────────────────────────────
+// INVITE LINK HANDLER
+// ─────────────────────────────────────────────
+async function handleInviteLink() {
+ const params = new URLSearchParams(window.location.search);
+ const inviteToken = params.get('invite');
+ const fromUid = params.get('from');
+ if (!inviteToken || !fromUid || fromUid === currentUser.uid) return;
+ window.history.replaceState({}, document.title, APP_URL);
+ try {
+ const inviteRef = doc(db, 'invites', inviteToken);
+ const inviteSnap = await getDoc(inviteRef);
+ if (!inviteSnap.exists()) return;
+ const invite = inviteSnap.data();
+ if (invite.expiresAt.toDate() < new Date()) {
+ showToast('This invite link has expired.', 'error');
+ return;
+ }
+ const existingQ = query(collection(db, 'friendRequests'),
+ where('fromUid', '==', fromUid),
+ where('toUid', '==', currentUser.uid)
+ );
+ const existing = await getDocs(existingQ);
+ if (!existing.empty) {
+ const req = existing.docs[0];
+ if (req.data().status !== 'accepted') {
+ await updateDoc(doc(db, 'friendRequests', req.id), { status: 'accepted' });
+ showToast(`You and ${invite.fromName} are now StepMates! 🎉`, 'success');
+ }
+ return;
+ }
+ await addDoc(collection(db, 'friendRequests'), {
+ fromUid,
+ fromEmail: '',
+ fromName: invite.fromName,
+ toUid: currentUser.uid,
+ toEmail: currentUser.email,
+ status: 'accepted',
+ createdAt: serverTimestamp()
+ });
+ showToast(`You and ${invite.fromName} are now StepMates! 🎉`, 'success');
+ loadFriends();
+ } catch(e) {
+ console.error('Invite error:', e);
+ }
+}
+
// ─────────────────────────────────────────────
// SIGN OUT
// ─────────────────────────────────────────────