🐕

Meet your AI coach

Tell us about your dog

Step 1 of 4 ~2 min
About your dog
📷
Add a photo (optional)
Shows on your win cards and weekly recap
Pick your coaching style
Select all that apply

Pre-filled for your dog's age. Toggle off anything you don't want.

🚽
Potty breaks
Loading...
🍖
Feeding times
Loading...
🦮
Walks
Loading...
Terms of Service · Privacy Policy · Help
🐕
🎯 Today's Training
Building today's plan…
💪 Pick up where you left off.
🎁 Invite a friend, get a free month
More stats →
When they subscribe as a founding member, you drop to $4/mo for life.
Friends
invited
Subscribed
Free months
earned
Link copied! 🎉
🐾 Invite a friend — you both get 14 free days
Know someone with a new pup? Share FetchCoach. The moment they sign up, you both get 14 extra free days.
Friends
joined
Bonus days
earned
Link copied! 🎉
Today's training plan
☀️ Morning Personalized for your dog
🐾 Week 2 unlocks now
Your dog just nailed Day 3.
Week 2 unlocks Down, Leave It, Drop It, Place, Crate, and Impulse Control — /200 founding spots left at $5/mo locked for life.
Down Leave It Drop It Place Crate Impulse Control
🎙️ Voice coaching
Talk to Coach about your dog
Tap the mic, say one sentence, and Coach will talk you through it. Hands-free and real-time.
Try saying

"Walk me through the next rep of today's skill."

Start a 5-minute session →
🐕 Start Walk
Voice coach on the leash — hands-free
🎙️ Voice sessions Start session →
Suggested exercises
🎁
Invite a friend, get a free month
They get a 14-day trial. You get $5 off your next bill.
🤙

Hey! Let's get training

Ask me anything about your dog's training

🐕
⚠️
Weekly limit reached 🐾
You've used your 20 free coaching messages this week. Founding Pro is $79/yr — locked for life.
🎙️
Voice mode is on. Tap the mic, speak your question, and the coach replies aloud — perfect on a walk.
Voice mode
📅 Daily Routine
Loading...
🐾
0 / 0 done
Today
Building your routine…
Link copied! 🎉
📚 My Training Skills
Loading…
Loading…
Loading…
Commands Library
🎯 Commands
Loading…
Loading commands…
Status
Training Plan
Progress Videos
No videos yet — record your first one to track progress
Check-ins
No check-ins yet. Log your first session above.
Loading…
🏆
Leave a final note on what worked?
🐕🐕

Multiple dogs require Founding Pro

Free accounts support one dog profile. Upgrade to Founding Pro to coach multiple dogs — each with their own curriculum, history, and progress tracking.

Was this clear?
What got in the way? (optional)
Got it — thank you 🐾
🐾
Add FetchCoach to your home screen
One tap to your coach — no App Store needed.
Add to your Home Screen
One tap to your coach — no App Store needed. Follow these steps:
1️⃣ Tap the Share button at the bottom of Safari (the box with an arrow pointing up)
2️⃣ Scroll down and tap "Add to Home Screen"
3️⃣ Tap "Add" in the top right — done! 🎉
Training Calendar
0
Current
0
Best
+ annual + '/yr locked forever.' : ' Your ' + (window.__FC_PRICING && window.__FC_PRICING.FOUNDING_MONTHLY_DISPLAY || '$9.99/mo') + ' rate is locked forever.'; setTimeout(() => showToast('🐕 Welcome, Founding Member!' + cycleMsg), 800); // Show referral share prompt for new founding members after welcome toast setTimeout(() => showPostSubscribeReferralBanner(), 2500); } else { setTimeout(() => showToast('🎉 Welcome to FetchCoach Pro! Unlimited coaching unlocked.'), 800); } }) .catch(function() { setTimeout(() => showToast('🎉 Welcome to FetchCoach Pro! Unlimited coaching unlocked.'), 800); }); } // Handle post-trial-recap upgrade redirect (?upgraded=1 from one-click checkout) if (urlParams.get('upgraded') === '1') { window.history.replaceState(null, '', '/app'); // Confetti burst — reuse existing helper if present try { spawnFsmConfetti(); } catch(e) {} setTimeout(() => showToast('🎉 Welcome, Founding Member! Your coaching continues — no interruption.'), 700); trackEvent('trial_recap_upgrade_completed', { session_id: sessionId }); } // Handle post-annual-upgrade redirect if (urlParams.get('upgraded_to_annual') === '1') { window.history.replaceState(null, '', '/app'); setTimeout(() => showToast('✅ Switched to annual — $50/yr locked for life. You saved $10!'), 800); } // Track email click and surface the upsell banner (from day-30 email CTA) if (urlParams.get('annual_upsell_email') === '1') { window.history.replaceState(null, '', '/app'); trackEvent('annual_upsell_email_clicked', { session_id: sessionId }); } // Track page view trackEvent('app_view', { returning: isReturning }); if (isReturning) { trackEvent('returning_user', { msg_count: getUserMessageCount() }); } try { const res = await fetch(`/api/profile/${sessionId}`); const data = await res.json(); if (data.success && data.profile) { profile = data.profile; showApp(); // Deep link: if on /app/call, show the call launcher overlay if (window.location.pathname === '/app/call') { showDeeplinkLauncher(); } return; } } catch (e) { console.error('Failed to load profile:', e); } // Deep link with no profile — redirect to onboarding if (window.location.pathname === '/app/call') { window.history.replaceState(null, '', '/app'); } trackEvent('onboarding_start', { new_user: !isReturning }); // Fire step 0 view event so funnel shows exact entry rate trackEvent('onboarding_step', { step: 0, step_name: 'dog_info', direction: 'enter', from_step: null }); // Server-side funnel: record step 0 entered trackStepEvent(0, 'dog_info', 'entered', { new_user: !isReturning }); document.getElementById('onboarding').style.display = 'flex'; // Pre-fill breed from ?breed= querystring (used by breed SEO landing pages) var breedParam = urlParams.get('breed'); if (breedParam) { var breedInput = document.getElementById('breed'); if (breedInput && !breedInput.value) { // Normalise slug format (labrador-retriever) or plain name to display name var breedDisplay = breedParam .replace(/-/g, ' ') .replace(/\b\w/g, function(l) { return l.toUpperCase(); }); breedInput.value = breedDisplay; // Clean the breed param from the URL so it doesn't persist after onboarding try { var cleanUrl = window.location.pathname; window.history.replaceState(null, '', cleanUrl); } catch(e) {} } } // Pre-fill skill from ?prefill_skill= querystring (used by skills SEO landing pages) var prefillSkillParam = urlParams.get('prefill_skill'); if (prefillSkillParam) { // Store globally so it can be used after onboarding completes window._prefillSkillSlug = prefillSkillParam; // Show a subtle onboarding hint that a skill will be saved var skillDisplay = prefillSkillParam .replace(/-/g, ' ') .replace(/\b\w/g, function(l) { return l.toUpperCase(); }); window._prefillSkillDisplay = skillDisplay; // Clean the param from the URL try { window.history.replaceState(null, '', window.location.pathname); } catch(e) {} } } function generateSessionId() { const arr = new Uint8Array(24); crypto.getRandomValues(arr); return Array.from(arr, b => b.toString(16).padStart(2, '0')).join(''); } // ============================================ // Onboarding Steps // ============================================ const ONBOARDING_STEP_NAMES = ['dog_info', 'coach_selection', 'behavior_assessment', 'reminder_setup']; function goToStep(step) { // Validate step 0 before advancing if (step === 1) { const name = document.getElementById('dogName').value.trim(); if (!name) { showToast('Please enter your dog\'s name'); return; } const breedCheck = document.getElementById('breed').value.trim(); if (!breedCheck) { showToast('Please enter your dog\'s breed'); document.getElementById('breed').focus(); return; } } // Enhanced analytics: include step name, direction, and completion context const direction = step > currentOnboardingStep ? 'forward' : 'back'; const stepMeta = { step: step, step_name: ONBOARDING_STEP_NAMES[step] || 'unknown', direction: direction, from_step: currentOnboardingStep }; // Attach completion context when moving forward if (direction === 'forward') { if (currentOnboardingStep === 0) { // Completing dog_info step const nameVal = document.getElementById('dogName').value.trim(); const breedVal = document.getElementById('breed').value.trim(); const ageVal = document.getElementById('age').value; stepMeta.dog_name_length = nameVal.length; stepMeta.breed_filled = breedVal.length > 0; stepMeta.age_selected = ageVal !== ''; // Instrumentation: count filled optional fields at step 0 exit // name+breed always required; age is the optional field counted here const optionalFieldCount = (ageVal !== '' ? 1 : 0); trackEvent('onboarding_step_0_field_count', { field_count: 2 + optionalFieldCount, // 2 required + optional name_filled: true, breed_filled: true, age_filled: ageVal !== '' }); } else if (currentOnboardingStep === 1) { // Completing coach_selection step const coach = document.querySelector('input[name="coach"]:checked'); stepMeta.coach_selected = coach ? coach.value : 'none'; } } // Server-side funnel tracking (fire-and-forget) if (direction === 'forward') { trackStepEvent(currentOnboardingStep, ONBOARDING_STEP_NAMES[currentOnboardingStep], 'completed', stepMeta); trackStepEvent(step, ONBOARDING_STEP_NAMES[step], 'entered', {}); } trackEvent('onboarding_step', stepMeta); currentOnboardingStep = step; // Update step visibility document.querySelectorAll('.onboarding-step').forEach(s => s.classList.remove('active')); document.getElementById(`step${step}`).classList.add('active'); // Update progress bar const progressFill = document.getElementById('stepProgressFill'); const progressText = document.getElementById('stepProgressText'); const progressTime = document.getElementById('stepProgressTime'); const timeLabels = ['~2 min', '~1.5 min', '~1 min', 'Last step']; if (progressFill) progressFill.style.width = ((step + 1) / 4 * 100) + '%'; if (progressText) progressText.textContent = 'Step ' + (step + 1) + ' of 4'; if (progressTime) progressTime.textContent = timeLabels[step] || ''; // Legacy dot update (no-op if dots removed) document.querySelectorAll('.step-dot').forEach((d, i) => { d.classList.remove('active', 'done'); if (i < step) d.classList.add('done'); if (i === step) d.classList.add('active'); }); // Update titles (personalized with dog name on behavior step) const dogNameVal = document.getElementById('dogName').value.trim() || 'your dog'; const titles = [ { title: 'Meet your AI coach', sub: 'Tell us about your dog' }, { title: 'Pick your style', sub: 'Choose how your coach talks to you' }, { title: `What are you working on with ${dogNameVal}?`, sub: 'Select all that apply — we\'ll tailor your plan' }, { title: `Smart reminders for ${dogNameVal}`, sub: 'Daily nudges so the routine actually sticks' } ]; // Pre-populate reminder step defaults when arriving at step 3 if (step === 3) { populateReminderStep(); } document.getElementById('onboardingTitle').textContent = titles[step].title; document.getElementById('onboardingSub').textContent = titles[step].sub; // Scroll onboarding container to top so header + CTA are in view // This ensures the CTA is not hidden above the viewport on mobile const container = document.getElementById('onboarding'); if (container) { container.scrollTo({ top: 0, behavior: 'smooth' }); } window.scrollTo({ top: 0, behavior: 'smooth' }); } function selectAgeChip(btn) { // Toggle: tap selected chip to deselect (make truly optional) const wasSelected = btn.classList.contains('selected'); document.querySelectorAll('.age-chip').forEach(c => c.classList.remove('selected')); if (!wasSelected) { btn.classList.add('selected'); document.getElementById('age').value = btn.dataset.age; } else { document.getElementById('age').value = ''; } } function toggleBehavior(chip) { const issue = chip.dataset.issue; chip.classList.toggle('selected'); if (chip.classList.contains('selected')) { if (!selectedBehaviors.includes(issue)) selectedBehaviors.push(issue); } else { selectedBehaviors = selectedBehaviors.filter(b => b !== issue); } } async function handleOnboarding() { const tosCheckbox = document.getElementById('tosCheckbox'); if (!tosCheckbox || !tosCheckbox.checked) { const tosAgree = document.querySelector('.tos-agree'); if (tosAgree) { tosAgree.classList.add('tos-shake'); setTimeout(() => tosAgree.classList.remove('tos-shake'), 600); } return; } const btn = document.getElementById('startBtn'); btn.disabled = true; btn.textContent = 'Setting up...'; const dogName = document.getElementById('dogName').value.trim(); const breed = document.getElementById('breed').value.trim(); const age = document.getElementById('age').value; const coachStyle = document.querySelector('input[name="coach"]:checked')?.value || 'buddy'; // Read invite code cookie (set by /invite/:code) function getWinInviteCode() { try { var match = document.cookie.match(/(?:^|;\s*)win_invite_code=([^;]+)/); return match ? decodeURIComponent(match[1]) : null; } catch(e) { return null; } } const winInviteCode = getWinInviteCode(); // Read Pack Pass cookie (set by /join/:code) function getPackPassCode() { try { var match = document.cookie.match(/(?:^|;\s*)pack_pass_code=([^;]+)/); return match ? decodeURIComponent(match[1]) : null; } catch(e) { return null; } } const packPassCode = getPackPassCode(); try { const profileBody = { session_id: sessionId, dog_name: dogName, breed: breed, age: age, coach_style: coachStyle, behavior_issues: selectedBehaviors }; if (winInviteCode) profileBody.invite_code = winInviteCode; const res = await fetch('/api/profile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(profileBody) }); const data = await res.json(); if (data.success) { profile = data.profile; trackEvent('onboarding_complete', { coach_style: profile.coach_style, breed: profile.breed, age: profile.age, behavior_count: (profile.behavior_issues || []).length }); // Server-side funnel: step 3 completed (full onboarding done) trackStepEvent(3, 'reminder_setup', 'completed', { coach_style: profile.coach_style, behavior_count: (profile.behavior_issues || []).length }); // Mark onboarding done — unlocks push permission prompt localStorage.setItem('fc_onboarding_done', '1'); // Show push prompt after a short delay (user just finished onboarding) setTimeout(checkPushPromptCard, 1800); // Upload onboarding photo if user selected one — fire-and-forget, non-blocking if (_onboardingPhotoFile) { const photoFile = _onboardingPhotoFile; _onboardingPhotoFile = null; savePhotoFile(photoFile).catch(function() {}); } // Pack Pass attribution: if user arrived via /join/:code, attribute signup if (packPassCode && sessionId) { fetch('/api/pack-pass/attribute', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, pack_pass_code: packPassCode }) }).catch(function() {}); } // Demo continuity: if user came from /demo, seed chat history from demo transcript // fc_demo_token is stored as an httpOnly cookie by /api/demo/set-cookie // The server reads it and connects the demo session to this dog profile. if (sessionId) { fetch('/api/demo/connect-session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId }) }).catch(function() {}); // fire-and-forget, non-blocking } // Save reminder schedules if configured in onboarding step 3 if (window._pendingReminders && sessionId) { const { schedules: _rs, email: _re } = window._pendingReminders; window._pendingReminders = null; fetch(`/api/reminders/${sessionId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ schedules: _rs, email: _re || undefined }) }).catch(() => {}); trackEvent('reminder_setup_completed', { types_enabled: (_rs || []).filter(r => r.enabled).map(r => r.type), has_email: !!_re }); } // Fire Meta Pixel Lead event if (typeof fbq === 'function') { fbq('track', 'Lead'); } // Auto-save skill from ?prefill_skill= if set (skills SEO landing pages) if (window._prefillSkillSlug) { const savedSkillSlug = window._prefillSkillSlug; const savedSkillDisplay = window._prefillSkillDisplay || savedSkillSlug.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); window._prefillSkillSlug = null; window._prefillSkillDisplay = null; try { await fetch('/api/skills', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, name: savedSkillDisplay, status: 'not_started' }) }); setTimeout(() => showToast('🎓 "' + savedSkillDisplay + '" saved to your Skills tab!'), 1200); // Track skill save completed from skills library fetch('/api/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event_type: 'skill_save_completed', metadata: { skill: savedSkillSlug, source: 'skills_library' } }) }).catch(() => {}); } catch(e) {} } // Also detect timezone try { const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; if (tz) { fetch(`/api/preferences/${sessionId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ timezone: tz }) }); } } catch(e) {} // Claim referral reward if this session arrived via a referral link. // Fire-and-forget — never blocks onboarding completion. // fc_ref cookie/localStorage is set by /r/:code landing page. claimFriendReferralOnSignup(sessionId).catch(function() {}); // Show Dog ID reveal if we have one if (profile.dog_id) { document.getElementById('revealDogIdCode').textContent = profile.dog_id; document.getElementById('revealCopyBtn').innerHTML = '📋 Copy'; document.getElementById('revealCopyBtn').classList.remove('copied'); // Personalize modal with dog name var _dn = profile.dog_name || 'your dog'; var _revealNameEl = document.getElementById('revealDogNameSpan'); if (_revealNameEl) _revealNameEl.textContent = _dn; var _revealCtaNameEl = document.getElementById('revealDogNameCta'); if (_revealCtaNameEl) _revealCtaNameEl.textContent = _dn; document.getElementById('dogIdRevealOverlay').classList.add('visible'); } else { showApp(); } } else { showToast('Something went wrong. Try again.'); btn.disabled = false; btn.textContent = 'Start Training'; } } catch (err) { showToast('Connection error. Try again.'); btn.disabled = false; btn.textContent = 'Start Training'; } } function quickStart() { // Auto-accept ToS and submit with no behavior issues selected const tosCheckbox = document.getElementById('tosCheckbox'); if (tosCheckbox) tosCheckbox.checked = true; selectedBehaviors = []; trackEvent('onboarding_quick_start', {}); handleOnboarding(); } // ============================================ // Reminder Setup (Onboarding Step 3) // ============================================ function formatTimesLabel(times) { if (!times || !times.length) return 'None'; if (times.length <= 3) { return times.map(t => { const [h, m] = t.split(':').map(Number); const ampm = h >= 12 ? 'pm' : 'am'; const h12 = h % 12 || 12; return m === 0 ? `${h12}${ampm}` : `${h12}:${m.toString().padStart(2,'0')}${ampm}`; }).join(', '); } const first = times[0], last = times[times.length - 1]; const fmt = t => { const [h, m] = t.split(':').map(Number); const ampm = h >= 12 ? 'pm' : 'am'; const h12 = h % 12 || 12; return m === 0 ? `${h12}${ampm}` : `${h12}:${m.toString().padStart(2,'0')}${ampm}`; }; return `${times.length}x/day (${fmt(first)} – ${fmt(last)})`; } function getReminderDefaults(breed, age) { const b = (breed || '').toLowerCase(); const isHigh = ['border collie','australian shepherd','labrador','golden retriever','german shepherd', 'husky','siberian husky','vizsla','weimaraner','jack russell','dalmatian','belgian malinois', 'boxer','springer spaniel','pointer','beagle','corgi','pembroke welsh corgi','brittany'].some(x => b.includes(x)); const isLow = ['bulldog','basset','shih tzu','pug','chihuahua','maltese','french bulldog', 'chow chow','pekingese','cavalier','bichon','scottish terrier','great dane'].some(x => b.includes(x)); const a = (age || '').toLowerCase(); const isPuppyU6 = a.includes('under 6'); const isPuppy6_12 = a.includes('6-12') || a.includes('6 to 12'); const isAdolescent = a.includes('dolescent'); let potty, feeding; if (isPuppyU6) { potty = ['08:00','10:00','12:00','14:00','16:00','18:00','20:00','22:00']; feeding = ['08:00','13:00','18:00']; } else if (isPuppy6_12) { potty = ['08:00','10:30','13:00','15:30','18:00','20:30']; feeding = ['08:00','13:00','18:00']; } else if (isAdolescent) { potty = ['08:00','12:00','16:00','20:00']; feeding = ['08:00','18:00']; } else { potty = ['08:00','12:00','17:00','21:00']; feeding = ['08:00','18:00']; } const walk = isLow ? ['09:00'] : (isHigh ? ['08:00','17:00'] : ['09:00','17:00']); return { potty, feeding, walk }; } function populateReminderStep() { const breed = document.getElementById('breed')?.value || ''; const age = document.getElementById('age')?.value || ''; const dogName = (document.getElementById('dogName')?.value || '').trim() || 'your pup'; const el = document.getElementById('reminderAgeLabel'); if (el) el.textContent = age ? age.replace('Puppy', 'puppy').split('(')[0].trim() : 'your dog'; const defs = getReminderDefaults(breed, age); const pottyEl = document.getElementById('reminderPottyDesc'); const feedEl = document.getElementById('reminderFeedingDesc'); const walkEl = document.getElementById('reminderWalkDesc'); if (pottyEl) pottyEl.textContent = formatTimesLabel(defs.potty); if (feedEl) feedEl.textContent = formatTimesLabel(defs.feeding); if (walkEl) walkEl.textContent = formatTimesLabel(defs.walk); // Store defaults for use in startWithReminders window._reminderDefaults = defs; } async function startWithReminders(requestPush) { const btn = document.getElementById('reminderStartBtn'); if (btn) { btn.disabled = true; btn.textContent = 'Setting up...'; } // Request push permission if user tapped "Enable" if (requestPush && 'Notification' in window) { try { await Notification.requestPermission(); } catch(e) {} } // Read enabled toggles + email const email = (document.getElementById('reminderEmail')?.value || '').trim(); const channel = email ? 'both' : 'push'; const defs = window._reminderDefaults || getReminderDefaults( document.getElementById('breed')?.value || '', document.getElementById('age')?.value || '' ); const schedules = [ { type: 'potty', times: defs.potty, enabled: document.getElementById('reminderPottyEnabled')?.checked !== false, channel }, { type: 'feeding', times: defs.feeding, enabled: document.getElementById('reminderFeedingEnabled')?.checked !== false, channel }, { type: 'walk', times: defs.walk, enabled: document.getElementById('reminderWalkEnabled')?.checked !== false, channel } ]; // Store for after profile save window._pendingReminders = { schedules, email: email || null }; // Restore button if things go sideways if (btn) { btn.textContent = requestPush ? '🔔 Enable & Start' : 'Start Training'; btn.disabled = false; } // Run normal onboarding flow (saves profile, shows dog ID) await handleOnboarding(); } function urlBase64ToUint8Array(base64String) { const padding = '='.repeat((4 - base64String.length % 4) % 4); const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); const rawData = window.atob(base64); return Uint8Array.from(Array.from(rawData).map(c => c.charCodeAt(0))); } async function subscribeToServerPush(registration) { try { const keyRes = await fetch('/api/vapid-public-key'); const { publicKey } = await keyRes.json(); if (!publicKey) return false; const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey) }); await fetch('/api/push/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, subscription }) }); return true; } catch(e) { console.warn('[Push] Server push subscription failed:', e.message); return false; } } // ============================================ // App & Tabs // ============================================ function showApp() { const onboardingEl = document.getElementById('onboarding'); onboardingEl.style.display = ''; // Clear inline style so CSS class takes effect onboardingEl.classList.add('hidden'); document.getElementById('tabBar').classList.add('visible'); document.getElementById('settingsBtn').style.display = 'flex'; document.getElementById('voiceToggleBtn').style.display = 'flex'; document.getElementById('callCoachBar').style.display = ''; updateVoiceBtn(); // Update nav badge const info = COACH_INFO[profile.coach_style] || COACH_INFO.buddy; const badge = document.getElementById('navCoachBadge'); badge.textContent = `${info.emoji} ${profile.dog_name}`; badge.style.display = 'inline-flex'; // Show nav dog avatar updateNavAvatar(); // Deep link: ?tab=skills opens the skills tab directly (e.g. from nurture email CTA) const _initTabParam = new URLSearchParams(window.location.search).get('tab'); switchTab(['dashboard', 'schedule', 'skills', 'chat'].includes(_initTabParam) ? _initTabParam : 'dashboard'); // Deep link: ?action=potty/feeding/walk/training from push notification click const _initActionParam = new URLSearchParams(window.location.search).get('action'); if (_initActionParam) { // Small delay so the dashboard has time to render setTimeout(() => handleReminderAction(_initActionParam), 600); } loadSuggestions(); // Wire up notifications initServiceWorker().then(() => { if (Notification.permission === 'granted') { startScheduleNotifChecker(); } }); // Reset daily notified cache at midnight const todayKey = new Date().toLocaleDateString('en-CA'); const storedDay = localStorage.getItem('fc_notif_day'); if (storedDay !== todayKey) { localStorage.setItem('fc_notif_day', todayKey); localStorage.setItem('fc_notified', '[]'); notifiedItems = new Set(); } // Load referral card in background (non-blocking) loadReferralCard(); // Check voice nudge eligibility (non-blocking) checkVoiceNudge(); // Show feedback strip + floating coach button initFeedbackWidget(); // Check push opt-in card for returning users who already chatted (non-blocking) if (localStorage.getItem('fc_first_chat_sent')) { setTimeout(checkPushPromptCard, 800); } // Load trial banner for trial users (days remaining + stats, non-blocking) loadTrialBanner(); // Load upgrade banner for free users (non-blocking) // Deferred until after first coaching interaction — trust-repair pass #3. // Gate: user has sent first message (fc_first_chat_sent) OR logged a rep (fc_first_coaching_win). if (localStorage.getItem('fc_first_chat_sent') || localStorage.getItem('fc_first_coaching_win')) { loadUpgradeBanner(); } // Load founding chip in nav for free users (non-blocking) // Same gate — scarcity messaging before first coaching value erodes trust. if (localStorage.getItem('fc_first_chat_sent') || localStorage.getItem('fc_first_coaching_win')) { loadFoundingChip(); } // Refresh paywall status (non-blocking — sets Pro badge, chat cap, reminder locks) refreshPaywallStatus(); // Load streak widget (non-blocking) loadStreakWidget(); // Load rep logger card (non-blocking — shows after first skill activity) loadRepLogger(); // FIRST: Load today's coaching brief (top of dashboard, non-blocking) // This is the core value prop — shown FIRST before any growth/monetization layers loadCoachingBrief(); // Load activation quick-wins checklist (above-the-fold, non-blocking — new accounts only) loadActivationChecklist(); // Load first-rep coach nudge (above-the-fold, non-blocking — zero-rep users only) loadFirstRepNudge(); // Load today's schedule card (dashboard, below coaching brief, non-blocking) loadDashboardSchedule(); // Load skills & progress card (dashboard, below schedule, non-blocking) loadSkillsProgress(); // Load Day-3 upgrade nudge ONLY AFTER first rep logged (trial-gated, trial-only) // Delayed until user has experienced coaching value loadDay3ConversionNudge(); // Load voice-activation card ONLY AFTER first rep logged (T+12h–T+72h trialers with 0 voice sessions) // Delayed until user has experienced coaching value loadVoiceActivationCard(); // Load owner trainer profile section (non-blocking) loadTrainerProfile(); // Load founding-member referral card (non-blocking — only shows for founding members) loadFoundingReferralCard(); // Load universal friend refer-a-friend card (all users — trial + paid) loadFriendReferralCard(); // Load public dog profile share card (non-blocking — shows once slug is available) loadDogProfileShareCard(); // Load voice history card (non-blocking — only shows if summaries exist) loadVoiceHistory(); // Load one-tap voice quickstart card (above-the-fold CTA, non-blocking) loadVoiceQuickstartCard(); // Check for first-skill mastered celebration (non-blocking — shows modal if due) setTimeout(checkFirstSkillCelebration, 1200); // Check subscription status + paywall eligibility (non-blocking) // Sets userIsSubscribed flag and fires day_7 trigger if eligible. // Also restores the persistent founding-member badge if already triggered before. if (sessionId) { if (localStorage.getItem('fc_paywall_ever_triggered') === '1') { showFoundingMemberBadge(); } fetch('/api/paywall-eligibility?sessionId=' + encodeURIComponent(sessionId)) .then(function(r) { return r.json(); }) .then(function(data) { userIsSubscribed = !!(data && data.active); if (!userIsSubscribed && data && data.triggers) { if (data.triggers.day_7) { setTimeout(function() { triggerPaywall('day_7'); }, 1500); } } // For active subscribers, check subscription details for banner logic if (userIsSubscribed) { fetch('/api/check-subscription?sessionId=' + encodeURIComponent(sessionId)) .then(function(r) { return r.json(); }) .then(function(sub) { if (!sub.active) return; // Legacy green annual-switch banner (device-level dismiss) if (sub.isFoundingMember && sub.billingCycle === 'monthly' && localStorage.getItem('fc_annual_banner_dismissed') !== '1') { var banner = document.getElementById('annualSwitchBanner'); if (banner) banner.style.display = 'flex'; } // New amber annual upsell banner — server-side flag controls eligibility if (sub.annualUpsellShow && sub.billingCycle === 'monthly') { var upsellBanner = document.getElementById('annualUpsellBanner'); if (upsellBanner) { // Personalise subline with dog name if (profile && profile.dog_name) { var sublineEl = document.getElementById('annualUpsellSubline'); if (sublineEl) sublineEl.textContent = '$50 today covers ' + profile.dog_name + '\u2019s training for a year. Founding rate stays $5/mo locked even if you switch back to monthly later.'; } // Hide legacy green banner to avoid double-banner var legacyBanner = document.getElementById('annualSwitchBanner'); if (legacyBanner) legacyBanner.style.display = 'none'; upsellBanner.style.display = 'flex'; trackEvent('annual_upsell_shown', { session_id: sessionId }); } } }) .catch(function() {}); } }) .catch(function() {}); } } function switchTab(tab) { // Stop voice input and TTS when switching tabs if (isListening) stopListening(); stopAudio(); currentTab = tab; document.getElementById('dashboardContainer').classList.remove('active'); document.getElementById('chatContainer').classList.remove('active'); document.getElementById('scheduleContainer').classList.remove('active'); document.getElementById('skillsContainer').classList.remove('active'); document.getElementById('tabDashboard').classList.remove('active'); document.getElementById('tabChat').classList.remove('active'); document.getElementById('tabSchedule').classList.remove('active'); document.getElementById('tabSkills').classList.remove('active'); if (tab === 'dashboard') { document.getElementById('dashboardContainer').classList.add('active'); document.getElementById('tabDashboard').classList.add('active'); updateDashboard(); } else if (tab === 'schedule') { document.getElementById('scheduleContainer').classList.add('active'); document.getElementById('tabSchedule').classList.add('active'); loadSchedule(); } else if (tab === 'skills') { document.getElementById('skillsContainer').classList.add('active'); document.getElementById('tabSkills').classList.add('active'); loadSkills(); } else { document.getElementById('chatContainer').classList.add('active'); document.getElementById('tabChat').classList.add('active'); setupChat(); } } // ============================================ // Upgrade Banner (free users) // ============================================ function loadUpgradeBanner() { if (!sessionId) return; // Respect persistent dismissal if (localStorage.getItem('fc_upgrade_banner_dismissed') === '1') return; fetch('/api/activation-banner?sessionId=' + encodeURIComponent(sessionId)) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.show) return; var banner = document.getElementById('upgradeBanner'); var spotEl = document.getElementById('upgradeBannerSpot'); if (!banner || !spotEl) return; spotEl.textContent = data.spotNumber || '—'; if (data.isHighEngaged) { banner.classList.add('high-engaged'); } banner.style.display = 'flex'; }) .catch(function() {}); } // ============================================ // Founding Chip (nav pill for free users) // ============================================ function loadFoundingChip() { // Skip for subscribed users if (userIsSubscribed) return; var chip = document.getElementById('navFoundingChip'); if (!chip) return; // Static founding pricing — no live count chip.classList.add('visible'); } // ============================================ // Trial Banner — days remaining + stats for trial users // ============================================ var _trialBannerData = null; function loadTrialBanner() { if (!sessionId) return; // Per-session dismissal — banner reappears on next visit if (sessionStorage.getItem('fc_trial_banner_dismissed') === '1') return; // Never show for subscribed users if (userIsSubscribed) return; fetch('/api/trial-banner?sessionId=' + encodeURIComponent(sessionId)) .then(function(r) { return r.json(); }) .then(function(data) { if (!data || !data.show) return; _trialBannerData = data; var banner = document.getElementById('trialBanner'); var daysEl = document.getElementById('tbDaysText'); var statsEl = document.getElementById('tbStatsText'); var spotsEl = document.getElementById('tbSpots'); var ctaBtn = document.getElementById('tbCtaBtn'); if (!banner || !daysEl) return; // Set urgency class banner.classList.remove('tb-neutral', 'tb-warm', 'tb-urgent', 'tb-lapsed'); banner.classList.add('tb-' + (data.phase || 'neutral')); // Days text by phase if (data.phase === 'lapsed') { daysEl.textContent = 'Your trial ended — we held your founding spot for 48 hours.'; } else if (data.phase === 'urgent') { daysEl.innerHTML = '⚡ Last day of your trial. Your progress resets if you don\'t upgrade.'; } else if (data.phase === 'warm') { var d = data.daysRemaining; daysEl.innerHTML = '⏳ ' + d + ' day' + (d === 1 ? '' : 's') + ' left in your trial.'; } else { var d2 = data.daysRemaining; daysEl.innerHTML = d2 + ' day' + (d2 === 1 ? '' : 's') + ' left in your trial.'; } // Stats line var parts = []; if (data.totalReps > 0) parts.push(data.totalReps + ' rep' + (data.totalReps === 1 ? '' : 's') + ' logged'); if (data.activeSkills > 0) parts.push(data.activeSkills + ' skill' + (data.activeSkills === 1 ? '' : 's') + ' active'); statsEl.textContent = parts.length ? parts.join(' · ') : ''; // Founding spots if (spotsEl) spotsEl.textContent = data.foundingRemaining; // Update keep modal note var keepNote = document.getElementById('keepModalNote'); if (keepNote) keepNote.textContent = data.foundingRemaining + ' of 200 founding spots remaining'; // Sold out: change CTA to standard pricing link if (data.foundingRemaining <= 0 && ctaBtn) { ctaBtn.textContent = 'Upgrade to Pro →'; } // If no token, use /api/checkout path instead if (!data.unsubscribeToken && ctaBtn) { ctaBtn.setAttribute('data-checkout-mode', '1'); } banner.style.display = 'flex'; trackEvent('trial_banner_shown', { phase: data.phase, days_remaining: data.daysRemaining, session_id: sessionId }); }) .catch(function() {}); } function dismissTrialBanner() { sessionStorage.setItem('fc_trial_banner_dismissed', '1'); var banner = document.getElementById('trialBanner'); if (banner) banner.style.display = 'none'; trackEvent('trial_banner_dismissed', { session_id: sessionId }); } function trialBannerUpgrade() { var ctaBtn = document.getElementById('tbCtaBtn'); if (ctaBtn) { ctaBtn.disabled = true; ctaBtn.textContent = 'Redirecting…'; } var keepCta = document.getElementById('keepModalCtaBtn'); if (keepCta) { keepCta.disabled = true; keepCta.textContent = 'Redirecting…'; } trackEvent('trial_banner_upgrade_clicked', { phase: _trialBannerData ? _trialBannerData.phase : null, session_id: sessionId }); // If we have an unsubscribe token, use the /upgrade path (same as conversion emails) var token = _trialBannerData && _trialBannerData.unsubscribeToken; if (token) { window.location.href = '/upgrade?token=' + encodeURIComponent(token) + '&ref=trial_banner'; return; } // Fallback: use the normal in-app checkout (same as paywall) fetch('/api/checkout?sessionId=' + encodeURIComponent(sessionId) + '&billing_cycle=monthly&utm_source=trial_banner') .then(function(r) { return r.json(); }) .then(function(d) { if (d && d.url) { window.location.href = d.url; } else { window.location.href = '/pricing?utm_source=trial_banner'; } }) .catch(function() { window.location.href = '/pricing?utm_source=trial_banner'; }); } // ============================================ // Keep Modal — what carries over on upgrade // ============================================ function openKeepModal() { var overlay = document.getElementById('keepModalOverlay'); if (overlay) overlay.classList.add('open'); trackEvent('keep_modal_opened', { session_id: sessionId }); } function closeKeepModal() { var overlay = document.getElementById('keepModalOverlay'); if (overlay) overlay.classList.remove('open'); } // ============================================ // Streak Widget V2 — daily_activity source of truth // ============================================ var _streakLoaded = false; var _streakData = null; var _recoveryDrillPrompt = null; function loadStreakWidget() { if (!sessionId) return; fetch('/api/streak-v2/' + encodeURIComponent(sessionId)) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success) return; _streakData = data; var widget = document.getElementById('streakWidget'); var numEl = document.getElementById('streakNum'); var dotsEl = document.getElementById('streakDots'); var bestEl = document.getElementById('streakBest'); var banner = document.getElementById('streakBrokenBanner'); var zeroState = document.getElementById('streakZeroState'); var recoveryCta = document.getElementById('streakRecoveryCta'); var dogName = (profile && profile.dog_name) ? profile.dog_name : 'your dog'; // Hide all streak UI first if (widget) widget.style.display = 'none'; if (banner) banner.style.display = 'none'; if (zeroState) zeroState.style.display = 'none'; if (recoveryCta) recoveryCta.classList.remove('visible'); if (!data.hasAnyActivity) { // Day-0: gentle 'start your streak' prompt if (zeroState) zeroState.style.display = 'block'; } else if (data.isActive && data.streak > 0) { // Active streak — show widget if (numEl) numEl.textContent = '\uD83D\uDD25 ' + data.streak; if (bestEl && data.longestStreak > 0) { bestEl.textContent = data.longestStreak + ' day best'; } // Render 7-dot row if (dotsEl && data.last7Days && data.last7Days.length) { dotsEl.innerHTML = ''; data.last7Days.forEach(function(active) { var dot = document.createElement('span'); dot.className = 'streak-dot' + (active ? ' active' : ''); dotsEl.appendChild(dot); }); } if (widget) widget.style.display = ''; if (banner) banner.style.display = 'none'; } else { // Inactive streak — show broken banner (not recovery CTA — that's separate) if (banner) { var nameEl = document.getElementById('streakDogNameBanner'); if (nameEl) nameEl.textContent = dogName; banner.style.display = 'flex'; } } // Recovery CTA: missed yesterday, had streak ≥ 3 if (data.hasRecovery && data.recoveryDrillPrompt) { _recoveryDrillPrompt = data.recoveryDrillPrompt; var recovText = document.getElementById('streakRecoveryText'); if (recovText) { recovText.textContent = dogName + ' is ready when you are. Quick drill?'; } if (recoveryCta) recoveryCta.classList.add('visible'); // Badge on chat tab _addChatTabBadge(); } // Legacy milestone modal (existing email system) var legacyMilestone = data.legacyMilestone; if (legacyMilestone) { setTimeout(function() { showMilestoneModal(legacyMilestone, dogName); }, 800); } // New chat-thread milestone: badge on chat tab + switch after delay if (data.milestone) { _addChatTabBadge(); } // Win card check setTimeout(function() { checkForWinCard(); }, (legacyMilestone || data.milestone) ? 3800 : 1400); _streakLoaded = true; }) .catch(function() {}); } // ============================================ // Rep Logger — one-tap daily rep logger // ============================================ var _rlSkills = []; var _rlSelectedSkill = null; var _rlSelectedOutcome = null; function loadRepLogger() { if (!sessionId) return; fetch('/api/reps/skills?session_id=' + encodeURIComponent(sessionId)) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success || !data.skills || !data.skills.length) { // No skills yet — show card with placeholder chips so user can type document.getElementById('repLoggerCard').style.display = 'block'; _rlRenderSkillChips([]); return; } _rlSkills = data.skills; document.getElementById('repLoggerCard').style.display = 'block'; _rlRenderSkillChips(data.skills); // Pre-select the most-recently-practiced skill if (data.skills[0]) { _rlSetSkill(data.skills[0].name); var headline = document.getElementById('rlSkillHeadline'); if (headline) headline.textContent = 'How did ' + data.skills[0].name + ' go?'; } // Show streak badge if streak > 0 if (_streakData && _streakData.streak > 0) { var badge = document.getElementById('rlStreakBadge'); if (badge) { badge.textContent = '🔥 ' + _streakData.streak + ' days'; badge.style.display = 'block'; } } }) .catch(function() {}); } function _rlRenderSkillChips(skills) { var container = document.getElementById('rlSkillChips'); if (!container) return; container.innerHTML = ''; var display = skills.slice(0, 5); // show top 5 display.forEach(function(sk) { var btn = document.createElement('button'); btn.textContent = sk.name; btn.setAttribute('data-skill', sk.name); btn.style.cssText = 'border:1.5px solid #e5e7eb;border-radius:20px;padding:5px 12px;background:#fff;cursor:pointer;font-family:inherit;font-size:12px;font-weight:600;color:#374151;transition:all 0.15s;white-space:nowrap;'; btn.onclick = function() { _rlSetSkill(sk.name); }; container.appendChild(btn); }); } function _rlSetSkill(name) { _rlSelectedSkill = name; var input = document.getElementById('rlSkillInput'); if (input) input.value = name; var headline = document.getElementById('rlSkillHeadline'); if (headline) headline.textContent = 'How did ' + name + ' go?'; // Highlight selected chip var chips = document.querySelectorAll('#rlSkillChips button'); chips.forEach(function(c) { var isSelected = c.getAttribute('data-skill') === name; c.style.background = isSelected ? '#065f46' : '#fff'; c.style.color = isSelected ? '#fff' : '#374151'; c.style.borderColor = isSelected ? '#065f46' : '#e5e7eb'; }); _rlUpdateSubmitState(); } function rlOnSkillInput(val) { _rlSelectedSkill = val.trim() || null; // Clear chip selection when user types if (val.trim()) { var chips = document.querySelectorAll('#rlSkillChips button'); chips.forEach(function(c) { c.style.background = '#fff'; c.style.color = '#374151'; c.style.borderColor = '#e5e7eb'; }); } _rlUpdateSubmitState(); } function rlSelectOutcome(outcome) { _rlSelectedOutcome = outcome; var outcomes = ['great', 'okay', 'struggled']; var labels = { great: '✅ Great', okay: '🤔 Okay', struggled: '😓 Struggled' }; var colors = { great: '#065f46', okay: '#B45309', struggled: '#7C3AED' }; outcomes.forEach(function(o) { var btn = document.getElementById('rlOutcome_' + o); if (!btn) return; var selected = o === outcome; btn.style.background = selected ? colors[o] : '#fff'; btn.style.color = selected ? '#fff' : '#374151'; btn.style.borderColor = selected ? colors[o] : '#e5e7eb'; }); _rlUpdateSubmitState(); } function _rlUpdateSubmitState() { var btn = document.getElementById('rlSubmitBtn'); if (!btn) return; var ready = !!_rlSelectedSkill && !!_rlSelectedOutcome; btn.disabled = !ready; btn.style.opacity = ready ? '1' : '0.45'; btn.style.cursor = ready ? 'pointer' : 'default'; } function rlSubmitRep() { if (!_rlSelectedSkill || !_rlSelectedOutcome) return; var btn = document.getElementById('rlSubmitBtn'); var successMsg = document.getElementById('rlSuccessMsg'); if (btn) { btn.disabled = true; btn.textContent = 'Logging…'; } var note = (document.getElementById('rlNoteInput') || {}).value || ''; fetch('/api/reps', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, skill_name: _rlSelectedSkill, outcome: _rlSelectedOutcome, notes: note.trim() || undefined, }), }) .then(function(r) { return r.json(); }) .then(function(data) { if (data.success) { // Optimistic success if (btn) { btn.textContent = 'Log rep'; btn.disabled = false; btn.style.opacity = '0.45'; } if (successMsg) { successMsg.style.display = 'block'; successMsg.textContent = '✓ Logged ' + _rlSelectedSkill + ' (' + _rlSelectedOutcome + ') — ' + data.checkin_count + ' reps total'; setTimeout(function() { if (successMsg) successMsg.style.display = 'none'; }, 4000); } // Reset outcome selection _rlSelectedOutcome = null; ['great','okay','struggled'].forEach(function(o) { var b = document.getElementById('rlOutcome_' + o); if (b) { b.style.background = '#fff'; b.style.color = '#374151'; b.style.borderColor = '#e5e7eb'; } }); // Clear note var noteEl = document.getElementById('rlNoteInput'); if (noteEl) noteEl.value = ''; // Mark first coaching win — unlocks upgrade banner + founding chip on next page load localStorage.setItem('fc_first_coaching_win', '1'); // Activation checklist: mark log_rep step complete acMarkStepComplete('log_rep', 'rep_logger'); // Reload streak widget to reflect new streak setTimeout(loadStreakWidget, 600); } else { if (btn) { btn.textContent = 'Log rep'; btn.disabled = false; btn.style.opacity = '1'; } alert('Failed to log rep: ' + (data.message || 'unknown error')); } }) .catch(function(err) { if (btn) { btn.textContent = 'Log rep'; btn.disabled = false; btn.style.opacity = '1'; } console.error('[repLogger] submit error:', err); }); } function _addChatTabBadge() { var tabChat = document.getElementById('tabChat'); if (!tabChat) return; if (tabChat.querySelector('.tab-badge')) return; // already there tabChat.style.position = 'relative'; var badge = document.createElement('span'); badge.className = 'tab-badge'; badge.textContent = '1'; tabChat.appendChild(badge); } function startRecoveryDrill() { var prompt = _recoveryDrillPrompt || ''; // Remove recovery CTA var cta = document.getElementById('streakRecoveryCta'); if (cta) cta.classList.remove('visible'); // Remove chat tab badge var tabChat = document.getElementById('tabChat'); if (tabChat) { var badge = tabChat.querySelector('.tab-badge'); if (badge) badge.remove(); } // Switch to chat tab with pre-filled prompt switchTab('chat'); if (prompt) { setTimeout(function() { var input = document.getElementById('chatInput') || document.querySelector('textarea[data-chat-input]') || document.querySelector('.chat-input'); if (input) { input.value = prompt; input.focus(); // Trigger input event so send button activates input.dispatchEvent(new Event('input', { bubbles: true })); } }, 200); } } // ============================================ // Activity Calendar // ============================================ function openStreakCalendar() { var sheet = document.getElementById('streakCalendarSheet'); if (!sheet) return; if (!_streakData) return; // no data yet // Populate stats var cur = document.getElementById('calCurrentStreak'); var lng = document.getElementById('calLongestStreak'); if (cur) cur.textContent = _streakData.streak || 0; if (lng) lng.textContent = _streakData.longestStreak || 0; // Render calendar renderCalendarGrid(_streakData.monthActivity || {}); sheet.classList.add('open'); document.body.style.overflow = 'hidden'; } function closeStreakCalendar() { var sheet = document.getElementById('streakCalendarSheet'); if (sheet) sheet.classList.remove('open'); document.body.style.overflow = ''; } function renderCalendarGrid(monthActivity) { var grid = document.getElementById('calendarMonthGrid'); if (!grid) return; grid.innerHTML = ''; var now = new Date(); var year = now.getFullYear(); var month = now.getMonth(); // 0-based var monthName = now.toLocaleString('en-US', { month: 'long', year: 'numeric' }); var label = document.createElement('div'); label.className = 'cal-month-label'; label.textContent = monthName; grid.appendChild(label); // Day-of-week headers var dowWrap = document.createElement('div'); dowWrap.className = 'cal-grid'; ['Su','Mo','Tu','We','Th','Fr','Sa'].forEach(function(d) { var el = document.createElement('div'); el.className = 'cal-dow'; el.textContent = d; dowWrap.appendChild(el); }); grid.appendChild(dowWrap); // Day cells var daysGrid = document.createElement('div'); daysGrid.className = 'cal-grid'; var firstDay = new Date(year, month, 1).getDay(); // 0=Sun var daysInMonth = new Date(year, month + 1, 0).getDate(); var todayDate = now.getDate(); // Leading empty cells for (var i = 0; i < firstDay; i++) { var empty = document.createElement('div'); empty.className = 'cal-day empty'; empty.textContent = ''; daysGrid.appendChild(empty); } // Day cells for (var d = 1; d <= daysInMonth; d++) { var mm = String(month + 1).padStart(2, '0'); var dd2 = String(d).padStart(2, '0'); var dateStr = year + '-' + mm + '-' + dd2; var isActive = !!monthActivity[dateStr]; var isToday = (d === todayDate); var cell = document.createElement('div'); cell.className = 'cal-day'; if (isActive) cell.classList.add('active'); if (isToday) cell.classList.add('today'); cell.textContent = d; daysGrid.appendChild(cell); } grid.appendChild(daysGrid); } function loadTrainerProfile() { if (!sessionId) return; fetch('/api/trainer-stats/' + encodeURIComponent(sessionId)) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success) return; var section = document.getElementById('trainerProfileSection'); if (!section) return; var dogName = (profile && profile.dog_name) ? profile.dog_name : 'your dog'; // Update dog name label var nameLabels = section.querySelectorAll('.trainer-dog-name-label'); nameLabels.forEach(function(el) { el.textContent = dogName; }); // Stats document.getElementById('trainerSessionsThisWeek').textContent = data.session_days_this_week || 0; document.getElementById('trainerSkillsCoached').textContent = data.skills_coached || 0; document.getElementById('trainerMilestonesHit').textContent = data.milestones_hit || 0; // Trainer level badge var levelBadge = document.getElementById('trainerLevelBadge'); var levelIcons = { 'Apprentice': '🎓', 'Coach': '🐾', 'Pro Trainer': '⭐', 'Master Trainer': '🏆' }; var icon = levelIcons[data.trainer_level] || '🎓'; levelBadge.textContent = icon + ' ' + (data.trainer_level || 'Apprentice'); // Progress bar var barFill = document.getElementById('trainerLevelBarFill'); var nextLabel = document.getElementById('trainerLevelNext'); barFill.style.width = (data.trainer_level_progress || 0) + '%'; nextLabel.textContent = data.trainer_level_next ? '→ ' + data.trainer_level_next : 'Max level'; section.style.display = ''; // Load skills progress card loadSkillsProgressCard(); }) .catch(function() {}); } function loadSkillsProgressCard() { if (!sessionId) return; fetch('/api/progress/' + encodeURIComponent(sessionId)) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success || !data.skills || data.skills.length === 0) return; var card = document.getElementById('skillsProgressCard'); var dogNameEl = document.getElementById('skillsProgressDogName'); var subEl = document.getElementById('skillsProgressSub'); if (!card) return; var dogName = data.dog_name || 'your dog'; if (dogNameEl) dogNameEl.textContent = dogName; // Build sub-text: "7 skills · 1 mastered · 3 reliable" var parts = []; parts.push(data.total + ' skill' + (data.total !== 1 ? 's' : '')); if (data.counts && data.counts.mastered > 0) parts.push(data.counts.mastered + ' mastered'); if (data.counts && data.counts.reliable > 0) parts.push(data.counts.reliable + ' reliable'); if (subEl) subEl.textContent = parts.join(' · '); card.style.display = ''; }) .catch(function() {}); } // ============================================ // Day-3 Conversion Nudge // ============================================ function loadDay3ConversionNudge() { if (!sessionId) return; // Don't show day3 nudge until user has logged at least 1 rep (coaching value first) if (profile && profile.rep_count && profile.rep_count < 1) return; fetch('/api/conversion-nudge/check?session_id=' + encodeURIComponent(sessionId)) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.show) return; var card = document.getElementById('day3NudgeCard'); if (!card) return; // Personalise headline with dog name + most recent skill var dogName = data.dog_name || (profile && profile.dog_name) || 'Your dog'; var skill = data.most_recent_skill || null; var headlineEl = document.getElementById('day3NudgeHeadline'); if (headlineEl) { headlineEl.textContent = skill ? dogName + ' just nailed ' + skill + '. Week 2 is next.' : dogName + ' is on a roll. Week 2 is next.'; } // Founding spots count var spotsEl = document.getElementById('day3NudgeSpots'); if (spotsEl && data.founding_remaining != null) { spotsEl.textContent = data.founding_remaining; } // Show card card.classList.add('visible'); // Record impression (fire-and-forget) fetch('/api/conversion-nudge/shown', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId }) }).catch(function() {}); trackEvent('day3_nudge_shown', { session_id: sessionId }); }) .catch(function() {}); // fail silently — never block user } function dismissDay3Nudge() { var card = document.getElementById('day3NudgeCard'); if (card) card.style.display = 'none'; if (!sessionId) return; fetch('/api/conversion-nudge/dismiss', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId }) }).catch(function() {}); trackEvent('day3_nudge_dismissed', { session_id: sessionId }); } function day3NudgeCtaClick() { if (!sessionId) return; fetch('/api/conversion-nudge/clicked', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId }) }).catch(function() {}); trackEvent('day3_nudge_clicked', { session_id: sessionId }); } // ============================================ // Voice-Activation Card // T+12h–T+72h trialers with 0 voice sessions // ============================================ function loadVoiceActivationCard() { if (!sessionId) return; // Don't show voice activation card until user has logged at least 1 rep (coaching value first) if (profile && profile.rep_count && profile.rep_count < 1) return; fetch('/api/voice-activation/card-check?session_id=' + encodeURIComponent(sessionId)) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.show) return; var card = document.getElementById('voiceActivationCard'); if (!card) return; // Personalise headline var dogName = data.dog_name || (profile && profile.dog_name) || 'your dog'; var skillName = data.active_skill_name || null; var skillSlug = data.active_skill_slug || null; var headlineEl = document.getElementById('vacHeadline'); if (headlineEl) { headlineEl.textContent = 'Talk to Coach about ' + dogName; } // Suggested prompt based on active skill var promptEl = document.getElementById('vacPrompt'); if (promptEl && skillName) { promptEl.textContent = '"' + dogName + ' and I are working on ' + skillName + '. Walk me through the next rep."'; } else if (promptEl) { promptEl.textContent = '"' + dogName + ' just started training. What should we work on first?"'; } // Update CTA href with skill context var ctaEl = document.getElementById('vacCta'); if (ctaEl && skillSlug) { ctaEl.href = '/app/voice?from=activation&skill=' + encodeURIComponent(skillSlug); } // Show card card.classList.add('visible'); // Record impression (fire-and-forget) fetch('/api/voice-activation/card-shown', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId }) }).catch(function() {}); trackEvent('voice_activation_card_shown', { session_id: sessionId, skill: skillSlug }); }) .catch(function() {}); // fail silently } function vacCtaClick() { if (!sessionId) return; fetch('/api/voice-activation/card-clicked', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId }) }).catch(function() {}); trackEvent('voice_activation_card_clicked', { session_id: sessionId }); } // ============================================ // Today's Coaching Brief // ============================================ function loadCoachingBrief() { if (!sessionId) return; var card = document.getElementById('coachingBriefCard'); if (!card) return; card.style.display = ''; // Set today's date label var dateEl = document.getElementById('briefTodayDate'); if (dateEl) { var now = new Date(); dateEl.textContent = now.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); } // Show loading state document.getElementById('briefLoading').style.display = ''; document.getElementById('briefContent').style.display = 'none'; document.getElementById('briefCompleted').style.display = 'none'; briefFocusedSkill = null; briefSessionLive = false; fetch('/api/coaching-brief/' + encodeURIComponent(sessionId)) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success || !data.brief) return; renderCoachingBrief(data.brief); }) .catch(function() { // Silently hide card on error — non-critical var card = document.getElementById('coachingBriefCard'); if (card) card.style.display = 'none'; }); } // Tracks which brief type is currently shown (for skip telemetry) var currentBriefType = 'skill'; function renderCoachingBrief(brief) { document.getElementById('briefLoading').style.display = 'none'; if (brief.completed) { // Show completed state document.getElementById('briefCompleted').style.display = ''; document.getElementById('briefContent').style.display = 'none'; var dogName = (profile && profile.dog_name) ? profile.dog_name : 'your dog'; var briefType = brief.brief_type || 'skill'; var completeSubText = briefType === 'foundations_day1' ? 'You read through the training methodology with ' + dogName + ' in mind. That\'s the foundation everything else runs on.' : briefType === 'foundations_day2' ? 'Toolkit sorted. ' + dogName + ' is ready for real sessions starting tomorrow.' : 'You worked on ' + brief.skill_name + ' today with ' + dogName + '. That counts.'; document.getElementById('briefCompleteSub').textContent = completeSubText; return; } document.getElementById('briefContent').style.display = ''; document.getElementById('briefGreeting').textContent = brief.greeting || ''; var briefType = brief.brief_type || 'skill'; currentBriefType = briefType; var isFoundations = briefType === 'foundations_day1' || briefType === 'foundations_day2'; // Show/hide foundations badge var badge = document.getElementById('briefFoundationsBadge'); if (badge) badge.style.display = isFoundations ? '' : 'none'; // Skill name — show on both skill and foundations briefs document.getElementById('briefSkillName').textContent = brief.skill_name || ''; // Plan label var planLabel = document.getElementById('briefPlanLabel'); if (planLabel) planLabel.textContent = isFoundations ? 'What to do' : '3-minute plan'; // Render steps var stepsEl = document.getElementById('briefSteps'); stepsEl.innerHTML = ''; var steps = Array.isArray(brief.steps) ? brief.steps : []; steps.forEach(function(s) { var row = document.createElement('div'); row.className = 'brief-step'; var num = document.createElement('span'); num.className = 'brief-step-num'; num.textContent = s.num || ''; var text = document.createElement('span'); text.textContent = s.text || ''; row.appendChild(num); row.appendChild(text); stepsEl.appendChild(row); }); // Set focused skill for voice call injection (only for real skill briefs) briefFocusedSkill = isFoundations ? null : brief.skill_name; briefSessionLive = false; if (isFoundations) { // Show foundations CTA, hide normal skill CTA document.getElementById('briefSkillCtaRow').style.display = 'none'; var foundationsCtaRow = document.getElementById('briefFoundationsCtaRow'); if (foundationsCtaRow) foundationsCtaRow.style.display = ''; // Set CTA link and label var ctaEl = document.getElementById('briefFoundationsCta'); var ctaLabelEl = document.getElementById('briefFoundationsCtaLabel'); if (ctaEl && brief.cta_url) ctaEl.href = brief.cta_url; if (ctaLabelEl && brief.cta_label) ctaLabelEl.textContent = brief.cta_label; } else { // Show normal skill CTA, hide foundations CTA document.getElementById('briefSkillCtaRow').style.display = ''; var foundationsCtaRow = document.getElementById('briefFoundationsCtaRow'); if (foundationsCtaRow) foundationsCtaRow.style.display = 'none'; // Reset start button var btn = document.getElementById('briefStartBtn'); var btnIcon = document.getElementById('briefStartBtnIcon'); var btnLabel = document.getElementById('briefStartBtnLabel'); if (btn) { btn.classList.remove('session-live'); btn.onclick = handleBriefStartBtn; } if (btnIcon) btnIcon.textContent = '▶'; if (btnLabel) btnLabel.textContent = 'Start 3-min session'; var altBtn = document.getElementById('briefAltBtn'); if (altBtn) altBtn.style.display = ''; } } // Called when user taps the "Read the guide" CTA on a foundations day. // Fires coaching_brief_session_started telemetry (same as skill brief), // then flips button to "Mark as read". function handleFoundationsCtaClick(ctaEl) { if (!sessionId) return; // Track the CTA click as a "session started" equivalent fetch('/api/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, event_type: 'coaching_brief_session_started', metadata: { brief_type: currentBriefType, source: 'cta_click' } }) }).catch(function() {}); // Show mark-complete button more prominently after click var markBtn = document.getElementById('briefFoundationsMarkBtn'); if (markBtn) { markBtn.style.color = '#065F46'; markBtn.style.fontWeight = '600'; } } function markFoundationsComplete() { if (!sessionId) return; fetch('/api/coaching-brief/' + encodeURIComponent(sessionId) + '/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' } }) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success) return; // Flip to completed state document.getElementById('briefContent').style.display = 'none'; document.getElementById('briefCompleted').style.display = ''; var dogName = (profile && profile.dog_name) ? profile.dog_name : 'your dog'; var completeSubText = currentBriefType === 'foundations_day1' ? 'You read through the training methodology with ' + dogName + ' in mind. That\'s the foundation everything else runs on.' : 'Toolkit sorted. ' + dogName + ' is ready for real sessions starting tomorrow.'; document.getElementById('briefCompleteSub').textContent = completeSubText; briefSessionLive = false; }) .catch(function() {}); } function handleBriefStartBtn() { if (!briefFocusedSkill) return; if (briefSessionLive) { // Second tap = Mark complete markBriefComplete(); return; } // Transition button to "Mark complete" state briefSessionLive = true; var btn = document.getElementById('briefStartBtn'); var btnIcon = document.getElementById('briefStartBtnIcon'); var btnLabel = document.getElementById('briefStartBtnLabel'); if (btn) btn.classList.add('session-live'); if (btnIcon) btnIcon.textContent = '✓'; if (btnLabel) btnLabel.textContent = 'Mark complete'; var altBtn = document.getElementById('briefAltBtn'); if (altBtn) altBtn.style.display = 'none'; // Launch session — try voice first, fallback to chat var SpeechRec = window.SpeechRecognition || window.webkitSpeechRecognition; if (SpeechRec) { startCall(); } else { // Chat fallback — switch to chat tab and prefill message switchTab('chat'); setTimeout(function() { var input = document.getElementById('chatInput'); if (input) { input.value = "Let's work on " + briefFocusedSkill + ". I want to do a focused 3-minute session."; autoResize(input); document.getElementById('sendBtn').disabled = false; sendMessage(); } }, 300); } } function markBriefComplete() { if (!sessionId) return; fetch('/api/coaching-brief/' + encodeURIComponent(sessionId) + '/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' } }) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success) return; // Flip to completed state document.getElementById('briefContent').style.display = 'none'; document.getElementById('briefCompleted').style.display = ''; var dogName = (profile && profile.dog_name) ? profile.dog_name : 'your dog'; document.getElementById('briefCompleteSub').textContent = 'You worked on ' + (briefFocusedSkill || 'a skill') + ' today with ' + dogName + '. That counts.'; briefSessionLive = false; // Refresh trainer stats to reflect the new checkin loadTrainerProfile(); }) .catch(function() {}); } function openBriefAlternates() { if (!sessionId) return; // Fire skip telemetry if this is a foundations day var isFoundationsDay = currentBriefType === 'foundations_day1' || currentBriefType === 'foundations_day2'; if (isFoundationsDay) { fetch('/api/coaching-brief/' + encodeURIComponent(sessionId) + '/skip', { method: 'POST', headers: { 'Content-Type': 'application/json' } }).catch(function() {}); } fetch('/api/coaching-brief/' + encodeURIComponent(sessionId) + '/alternates') .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success) return; var list = document.getElementById('briefAltsList'); list.innerHTML = ''; (data.alternates || []).forEach(function(skillName) { var item = document.createElement('div'); item.className = 'brief-alt-item'; item.innerHTML = '' + skillName + ''; item.onclick = function() { chooseBriefAlternate(skillName); }; list.appendChild(item); }); document.getElementById('briefAltsOverlay').style.display = 'flex'; }) .catch(function() {}); } function closeBriefAlternates() { document.getElementById('briefAltsOverlay').style.display = 'none'; } function closeBriefAltsOnBackdrop(e) { if (e.target === document.getElementById('briefAltsOverlay')) { closeBriefAlternates(); } } function chooseBriefAlternate(skillName) { closeBriefAlternates(); if (!sessionId) return; // Reset to loading, regenerate with chosen skill document.getElementById('briefLoading').style.display = ''; document.getElementById('briefContent').style.display = 'none'; document.getElementById('briefCompleted').style.display = 'none'; fetch('/api/coaching-brief/' + encodeURIComponent(sessionId) + '?skill=' + encodeURIComponent(skillName)) .then(function(r) { return r.json(); }) .then(function(data) { if (data.success && data.brief) renderCoachingBrief(data.brief); else document.getElementById('briefLoading').style.display = 'none'; }) .catch(function() { document.getElementById('briefLoading').style.display = 'none'; }); } function showMilestoneModal(days, dogName) { var messages = { 3: 'Three days in a row. That\'s the beginning of a habit — ' + dogName + '\'s brain is wiring the pattern every time you show up.', 7: 'One full week. The research says the first seven days are where most people quit. You didn\'t. ' + dogName + ' had you every single day.', 14: 'Two weeks straight. The dogs that learn fastest aren\'t the smartest — they\'re the ones with owners who kept coming back. That\'s you.', 30: 'One month. Consistency is what separates dogs that learn from dogs that don\'t. You\'re proving that with ' + dogName + ' every day.', 60: 'Two months. That kind of consistency doesn\'t just change how a dog behaves — it changes the relationship. ' + dogName + ' trusts you. That took sixty days.', 100: 'A hundred days. Most people don\'t make it to seven. You made it to a hundred. That\'s exceptional.' }; var body = messages[days] || ('Day ' + days + ' — ' + dogName + ' has trained with you ' + days + ' days in a row. That\'s the secret.'); document.getElementById('milestoneDayNum').textContent = 'Day ' + days; document.getElementById('milestoneLabelText').textContent = days + '-day training streak'; document.getElementById('milestoneBody').textContent = body; document.getElementById('milestoneModal').classList.add('visible'); // Confetti burst spawnConfetti(); trackEvent('streak_milestone_shown', { days: days, dog_name: dogName }); } function dismissMilestoneModal() { document.getElementById('milestoneModal').classList.remove('visible'); } function spawnConfetti() { var colors = ['#F59E0B','#065F46','#10B981','#FCD34D','#D97706','#34D399','#6EE7B7']; var container = document.body; for (var i = 0; i < 60; i++) { (function(idx) { setTimeout(function() { var el = document.createElement('div'); el.className = 'confetti-piece'; el.style.left = Math.random() * 100 + 'vw'; el.style.top = '-10px'; el.style.background = colors[Math.floor(Math.random() * colors.length)]; el.style.width = (6 + Math.random() * 6) + 'px'; el.style.height = (6 + Math.random() * 6) + 'px'; el.style.animationDuration = (1.5 + Math.random() * 1.5) + 's'; el.style.animationDelay = '0s'; container.appendChild(el); setTimeout(function() { el.remove(); }, 3500); }, idx * 30); })(i); } } // ============================================ // Win Card Modal // ============================================ var _winCardData = null; var _winCardCheckDone = false; var _onboardingPhotoFile = null; // photo selected in onboarding step0, uploaded after profile save function checkForWinCard() { if (!sessionId || _winCardCheckDone) return; _winCardCheckDone = true; fetch('/api/win-card-pending/' + encodeURIComponent(sessionId)) .then(function(r) { return r.json(); }) .then(function(data) { if (data.success && data.pending) { _winCardData = data.pending; setTimeout(function() { showWinCardModal(data.pending); }, 600); } }) .catch(function() {}); } function showWinCardModal(card) { var dogName = (profile && profile.dog_name) ? profile.dog_name : (card.dog_name || 'Your dog'); document.getElementById('wcModalHeadline').textContent = 'You unlocked a Win Card'; document.getElementById('wcModalLabel').textContent = card.label; var img = document.getElementById('wcCardImg'); img.src = card.image_url; img.alt = dogName + ' — ' + card.label; // X share button var xBtn = document.getElementById('wcShareX'); xBtn.href = card.tweet_url; // iMessage button var smsBtn = document.getElementById('wcShareSMS'); var smsBody = encodeURIComponent(dogName + ' just hit a milestone: ' + card.label + ' 🐕 ' + card.share_url); smsBtn.href = 'sms:?body=' + smsBody; // Instagram copy var igBtn = document.getElementById('wcShareIG'); igBtn.setAttribute('data-caption', card.instagram_caption); igBtn.setAttribute('data-img', card.image_url); // Copy link var copyBtn = document.getElementById('wcCopyLink'); copyBtn.setAttribute('data-url', card.share_url); // Pack Pass badge: show if user joined via Pack Pass invite var ppBadge = document.getElementById('wcPackPassBadge'); if (card.pack_pass_badge) { ppBadge.textContent = '🐾 Joined via Pack Pass from ' + card.pack_pass_badge; ppBadge.style.display = 'block'; } else { ppBadge.style.display = 'none'; } // Pack Pass invite section: show for subscribed users so they can share their own codes var ppInvite = document.getElementById('wcPackPassInvite'); if (userIsSubscribed) { ppInvite.style.display = 'block'; } else { ppInvite.style.display = 'none'; } // Annual upgrade nudge: show for 30-day streak when user is on monthly plan var annualNudge = document.getElementById('wcAnnualUpgrade'); var annualCta = document.getElementById('wcAnnualUpgradeCta'); if (card.annual_upgrade_eligible && annualNudge && annualCta) { var upgradeUrl = '/account/billing/switch-to-annual?sessionId=' + encodeURIComponent(sessionId || '') + '&utm_source=win_card_nudge&utm_campaign=annual_switch'; annualCta.href = upgradeUrl; annualNudge.style.display = 'block'; trackEvent('annual_upgrade_nudge_shown', { event_type: card.event_type }); } else if (annualNudge) { annualNudge.style.display = 'none'; } // Photo prompt: show if user has no dog photo yet var wcPhotoPrompt = document.getElementById('wcPhotoPrompt'); var wcPhotoPromptName = document.getElementById('wcPhotoPromptName'); if (wcPhotoPrompt) { if (!card.has_photo) { if (wcPhotoPromptName) wcPhotoPromptName.textContent = dogName; wcPhotoPrompt.style.display = 'block'; } else { wcPhotoPrompt.style.display = 'none'; } } document.getElementById('winCardModal').classList.add('visible'); spawnConfetti(); trackEvent('win_card_modal_shown', { event_type: card.event_type, dog_name: dogName }); } // Handle photo upload from the Win Card modal photo prompt async function handleWcPhotoUpload(event) { const file = event.target.files[0]; if (!file) return; event.target.value = ''; await savePhotoFile(file); // Reload the win card image to show the updated photo (cache-bust) if (_winCardData) { const img = document.getElementById('wcCardImg'); if (img) { const base = img.src.split('?')[0]; img.src = base + '?t=' + Date.now(); } } } function dismissWinCardModal() { document.getElementById('winCardModal').classList.remove('visible'); _winCardData = null; } function winCardShareX() { if (!_winCardData) return; var xBtn = document.getElementById('wcShareX'); window.open(xBtn.href, '_blank', 'width=550,height=420'); fetch('/api/win-card-share-click/' + _winCardData.event_id, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ channel: 'twitter' }) }).catch(function() {}); trackEvent('win_card_shared', { channel: 'twitter', event_type: _winCardData.event_type }); } function winCardShareIG() { if (!_winCardData) return; var igBtn = document.getElementById('wcShareIG'); var caption = igBtn.getAttribute('data-caption') || ''; var imgUrl = igBtn.getAttribute('data-img') || ''; // Download image var a = document.createElement('a'); a.href = imgUrl; a.download = 'fetchcoach-win-card.svg'; document.body.appendChild(a); a.click(); document.body.removeChild(a); // Copy caption if (navigator.clipboard) { navigator.clipboard.writeText(caption).then(function() { igBtn.textContent = '✓ Caption copied!'; setTimeout(function() { igBtn.innerHTML = '📸 Instagram'; }, 2000); }).catch(function() {}); } fetch('/api/win-card-share-click/' + _winCardData.event_id, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ channel: 'instagram' }) }).catch(function() {}); trackEvent('win_card_shared', { channel: 'instagram', event_type: _winCardData.event_type }); } function winCardCopyLink() { if (!_winCardData) return; var copyBtn = document.getElementById('wcCopyLink'); var url = copyBtn.getAttribute('data-url') || _winCardData.share_url; if (navigator.clipboard) { navigator.clipboard.writeText(url).then(function() { copyBtn.textContent = '✓ Copied!'; setTimeout(function() { copyBtn.textContent = '🔗 Copy link'; }, 2000); }).catch(function() {}); } else { // Fallback var ta = document.createElement('textarea'); ta.value = url; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch(e) {} document.body.removeChild(ta); copyBtn.textContent = '✓ Copied!'; setTimeout(function() { copyBtn.textContent = '🔗 Copy link'; }, 2000); } fetch('/api/win-card-share-click/' + _winCardData.event_id, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ channel: 'copy' }) }).catch(function() {}); trackEvent('win_card_shared', { channel: 'copy', event_type: _winCardData.event_type }); } function winCardShareSMS() { if (!_winCardData) return; fetch('/api/win-card-share-click/' + _winCardData.event_id, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ channel: 'imessage' }) }).catch(function() {}); trackEvent('win_card_shared', { channel: 'imessage', event_type: _winCardData.event_type }); // The href on the button handles the actual sms: deep link } // ============================================ // Dashboard // ============================================ function updateDashboard() { if (!profile) return; document.getElementById('dashDogName').textContent = profile.dog_name; const ageDisplay = profile.age || null; document.getElementById('dashDetails').textContent = ageDisplay ? `${profile.breed} · ${ageDisplay}` : profile.breed; // Update avatar — show photo if available, else emoji const avatarEl = document.getElementById('dashAvatar'); const emojiEl = document.getElementById('dashAvatarEmoji'); const _dashPhotoUrl = getDogPhotoUrl(); if (_dashPhotoUrl) { emojiEl.style.display = 'none'; let img = avatarEl.querySelector('img'); if (!img) { img = document.createElement('img'); img.alt = profile.dog_name; avatarEl.appendChild(img); } img.src = _dashPhotoUrl; } else { emojiEl.style.display = ''; const img = avatarEl.querySelector('img'); if (img) img.remove(); const info = COACH_INFO[profile.coach_style] || COACH_INFO.buddy; emojiEl.textContent = info.emoji; } // Update nav avatar updateNavAvatar(); // Update chat welcome avatar updateChatWelcomeAvatar(); // Time-based greeting const hour = new Date().getHours(); let period = 'morning'; if (hour >= 10 && hour < 14) period = 'midday'; else if (hour >= 14 && hour < 18) period = 'afternoon'; else if (hour >= 18 && hour < 21) period = 'evening'; else if (hour >= 21 || hour < 5) period = 'night'; const timeInfo = TIME_GREETINGS[period]; document.getElementById('greetingText').textContent = `${timeInfo.greeting} Here's your plan for training ${profile.dog_name}`; document.getElementById('timeBadge').textContent = `${timeInfo.icon} ${timeInfo.label} focus`; // Show enrichment card if age is still missing and user has chatted checkProfileEnrichCard(); // Load the 7-day onboarding plan module (non-blocking) loadOnboardingPlanCard(); // Load today's training plan (non-blocking) loadTodayPlan(); } // ============================================ // 7-Day Onboarding Plan Module // ============================================ var _opPlanData = null; // cached plan response var _opExpanded = false; // all-days panel expanded async function loadOnboardingPlanCard() { if (!sessionId) return; var card = document.getElementById('onboardingPlanCard'); if (!card) return; try { var res = await fetch('/api/onboarding-plan?session_id=' + encodeURIComponent(sessionId)); var data = await res.json(); if (!data.success || !data.plan) { // Still generating (< 5 min account) — show subtle shimmer if (data.generating) { card.style.display = 'block'; document.getElementById('opDayHeadline').textContent = 'Your plan is generating…'; document.getElementById('opTodayItems').innerHTML = '

Just a moment while we personalise your 7-day plan.

'; } return; } if (!data.planActive) { // Past day 7 AND 100% done — show collapsed "view plan" chip only card.style.display = 'block'; card.style.borderColor = '#d1fae5'; document.getElementById('opDayHeadline').textContent = 'Week 1 complete 🎉'; document.getElementById('opTodayItems').innerHTML = '

You finished your first-week plan. Check out what\'s next →

'; document.getElementById('opProgressPct').textContent = '100%'; document.getElementById('opProgressBar').style.width = '100%'; document.getElementById('opViewAllRow').style.display = 'none'; return; } _opPlanData = data; renderOnboardingPlanCard(data); card.style.display = 'block'; } catch (e) { /* non-fatal — card stays hidden */ } } function renderOnboardingPlanCard(data) { var dogName = (data.dogName || (profile && profile.dog_name) || 'your dog'); var planDayNum = data.planDayNumber || 1; var pct = data.progressPct || 0; // Header document.getElementById('opDogName').textContent = dogName; document.getElementById('opDayHeadline').textContent = 'Day ' + planDayNum + ' of 7'; document.getElementById('opProgressPct').textContent = pct + '%'; document.getElementById('opProgressBar').style.width = pct + '%'; // Streak badge var badge = document.getElementById('opStreakBadge'); if (planDayNum > 1) { badge.textContent = '🔥 Day ' + planDayNum + ' streak'; badge.style.display = ''; } // Today's items var todayPlan = data.plan && data.plan[planDayNum - 1]; if (todayPlan) { renderDayItems('opTodayItems', todayPlan, data.planDayNumber); } // Toggle button label document.getElementById('opToggleBtn').textContent = _opExpanded ? 'Show less ▴' : 'See all 7 days ▾'; // All days panel (rendered but may be hidden) renderAllDaysPanel(data); } function renderDayItems(containerId, dayObj, planDayNum) { var container = document.getElementById(containerId); if (!container || !dayObj) return; var items = dayObj.items || []; var html = ''; if (dayObj.theme) { html += '
' + escHtml(dayObj.theme) + '
'; } items.forEach(function(item, idx) { var isChecked = item.checked || false; var checkKey = 'op_checked_' + sessionId + '_' + planDayNum + '_' + idx; // Persist checks in localStorage as optimistic UI if (!isChecked && localStorage.getItem(checkKey)) isChecked = true; var link = item.skill_slug ? '/skills/' + encodeURIComponent(item.skill_slug) + '/day-1' : '/app#onboarding-plan'; html += '
'; html += ''; html += '
'; html += '' + escHtml(item.title) + ''; html += '
' + escHtml(item.why_it_matters || '') + ' · ' + (item.time_minutes || 5) + ' min
'; html += '
'; }); container.innerHTML = html; } function renderAllDaysPanel(data) { var panel = document.getElementById('opAllDaysPanel'); if (!panel || !data.plan) return; var html = ''; data.plan.forEach(function(dayObj) { var isToday = dayObj.day === data.planDayNumber; var allChecked = dayObj.allChecked; html += '
'; html += '
'; html += ''; html += (allChecked ? '✓ ' : (isToday ? '→ ' : '')) + 'Day ' + dayObj.day; html += ''; html += '' + escHtml(dayObj.theme || '') + ''; html += '' + (dayObj.checkedCount || 0) + '/' + (dayObj.items ? dayObj.items.length : 0) + ''; html += '
'; // Only expand today by default if (isToday) { html += '
'; var itemsHtml = ''; (dayObj.items || []).forEach(function(item, idx) { var checked = item.checked || !!localStorage.getItem('op_checked_' + sessionId + '_' + dayObj.day + '_' + idx); itemsHtml += '
'; itemsHtml += '· ' + escHtml(item.title) + ' (' + (item.time_minutes || 5) + ' min)'; itemsHtml += '
'; }); html += itemsHtml + '
'; } html += '
'; }); panel.innerHTML = html; } function onboardingItemChecked(checkbox, day, itemIndex) { if (!sessionId) return; // Optimistic localStorage update var key = 'op_checked_' + sessionId + '_' + day + '_' + itemIndex; if (checkbox.checked) { localStorage.setItem(key, '1'); } else { localStorage.removeItem(key); } // POST to server (fire-and-forget) fetch('/api/onboarding/check', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, day: day, item_index: itemIndex }), }).then(function(r) { return r.json(); }).then(function(data) { if (data.progressPct !== undefined) { document.getElementById('opProgressPct').textContent = data.progressPct + '%'; document.getElementById('opProgressBar').style.width = data.progressPct + '%'; // If 100% complete and past day 7, hide the full module if (data.progressPct === 100 && _opPlanData && _opPlanData.accountDayNumber >= 7) { var card = document.getElementById('onboardingPlanCard'); if (card) card.style.display = 'none'; } } }).catch(function() { /* non-fatal */ }); } function toggleOnboardingPlanExpanded() { _opExpanded = !_opExpanded; var panel = document.getElementById('opAllDaysPanel'); var btn = document.getElementById('opToggleBtn'); if (panel) panel.style.display = _opExpanded ? 'block' : 'none'; if (btn) btn.textContent = _opExpanded ? 'Show less ▴' : 'See all 7 days ▾'; } function toggleOpDayExpand(headerEl, day) { var inner = headerEl.parentElement.querySelector('.op-day-items-inner'); if (!inner) return; inner.style.display = inner.style.display === 'none' ? 'block' : 'none'; } function escHtml(str) { if (!str) return ''; return String(str).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } // ============================================ // Founding Member Referral Card // ============================================ var _frcShareUrl = null; function loadFoundingReferralCard() { if (!sessionId) return; fetch('/api/referral-card?sessionId=' + encodeURIComponent(sessionId)) .then(function(r) { return r.json(); }) .then(function(data) { if (!data || !data.show) return; // not a founding member var card = document.getElementById('foundingReferralCard'); if (!card) return; _frcShareUrl = data.shareUrl; var linkInput = document.getElementById('frcLinkInput'); if (linkInput) linkInput.value = data.shareUrl; var fullLink = document.getElementById('frcFullLink'); if (fullLink) fullLink.href = '/refer?s=' + encodeURIComponent(sessionId); var stats = data.stats || {}; var signupsEl = document.getElementById('frcStatSignups'); var convsEl = document.getElementById('frcStatConversions'); var creditsEl = document.getElementById('frcStatCredits'); if (signupsEl) signupsEl.textContent = stats.signups || 0; if (convsEl) convsEl.textContent = stats.conversions || 0; if (creditsEl) creditsEl.textContent = stats.creditMonthsEarned || 0; card.style.display = 'block'; trackEvent('founding_referral_card_shown', { ref_code: data.refCode }); }) .catch(function() { /* non-fatal — card stays hidden */ }); } // ── Friend refer-a-friend card (all users — trial + paid) ──────────────── var _frafShareUrl = null; function loadFriendReferralCard() { if (!sessionId) return; // Skip if already dismissed if (localStorage.getItem('fc_card_dismissed_frafCard') === '1') return; fetch('/api/friend-referral/card?session_id=' + encodeURIComponent(sessionId)) .then(function(r) { return r.json(); }) .then(function(data) { if (!data || !data.show) return; var card = document.getElementById('frafCard'); if (!card) return; _frafShareUrl = data.shareUrl; var linkInput = document.getElementById('frafLinkInput'); if (linkInput) linkInput.value = data.shareUrl; // Personalise sub copy with dog name var sub = document.getElementById('frafSub'); if (sub && data.dogName) { var n = data.dogName.charAt(0).toUpperCase() + data.dogName.slice(1).toLowerCase(); sub.textContent = 'Know someone with a new pup? Share FetchCoach for ' + n + '. The moment they sign up, you both get 14 extra free days.'; } // Show native share button only if Web Share API is available var nativeBtn = document.getElementById('frafNativeBtn'); if (nativeBtn && navigator.share) { nativeBtn.style.display = ''; } // Stats var stats = data.stats || {}; var redeemedEl = document.getElementById('frafStatRedeemed'); var bonusEl = document.getElementById('frafStatBonusDays'); if (redeemedEl) redeemedEl.textContent = stats.redeemed || 0; if (bonusEl) bonusEl.textContent = stats.bonusDaysEarned || 0; card.style.display = 'block'; trackEvent('friend_referral_card_shown', { ref_code: data.refCode }); }) .catch(function() { /* non-fatal — card stays hidden */ }); } function frafCopyLink() { if (!_frafShareUrl) return; navigator.clipboard.writeText(_frafShareUrl).catch(function() { var input = document.getElementById('frafLinkInput'); if (input) { input.select(); document.execCommand('copy'); } }); var toast = document.getElementById('frafToast'); if (toast) { toast.classList.add('visible'); setTimeout(function() { toast.classList.remove('visible'); }, 2500); } if (sessionId) { fetch('/api/friend-referral/track-click', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, channel: 'copy_link' }), }).catch(function() {}); } trackEvent('friend_referral_copy', { channel: 'copy_link' }); } function frafShare(channel) { if (!_frafShareUrl) return; var shareText = 'Positive-reinforcement dog training that actually works 🐾 ' + _frafShareUrl; if (channel === 'native' && navigator.share) { navigator.share({ title: 'FetchCoach — AI dog training coach', text: 'Personalized training for your pup. Start free — we both get 14 bonus days.', url: _frafShareUrl, }).catch(function() {}); } else if (channel === 'sms') { var body = encodeURIComponent('Hey! Check out FetchCoach — it\'s an AI dog training coach. Use my link and we both get 14 extra free days: ' + _frafShareUrl); window.open('sms:?&body=' + body, '_blank'); } else if (channel === 'twitter') { var tweet = encodeURIComponent('My dog and I have been using @FetchCoachApp — really solid positive-reinforcement coaching. Use my link and we both get 14 free days: ' + _frafShareUrl); window.open('https://twitter.com/intent/tweet?text=' + tweet, '_blank', 'width=560,height=420'); } if (sessionId) { fetch('/api/friend-referral/track-click', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, channel: channel }), }).catch(function() {}); } trackEvent('friend_referral_share', { channel: channel }); } // ─── Claim referral reward on first signup ───────────────────────────────── // Called once during onboarding completion. Reads the fc_ref cookie/localStorage, // posts to /api/friend-referral/claim, then clears the referral cookie so repeat // visits don't re-attempt. Idempotent at DB level; safe to call more than once. async function claimFriendReferralOnSignup(sid) { if (!sid) return; // Only claim once per browser session if (localStorage.getItem('fc_ref_claimed') === '1') return; var refCode = null; try { var cookieMatch = document.cookie.match(/(?:^|;\s*)fc_ref=([^;]+)/); if (cookieMatch) refCode = decodeURIComponent(cookieMatch[1]); else refCode = localStorage.getItem('fc_ref') || null; if (refCode && !/^[a-z0-9-]{2,32}$/i.test(refCode)) refCode = null; } catch(e) { refCode = null; } if (!refCode) return; try { var emailEl = document.getElementById('email'); var email = (emailEl && emailEl.value) ? emailEl.value.trim().toLowerCase() : (profile && profile.email ? profile.email : ''); var res = await fetch('/api/friend-referral/claim', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sid, ref_code: refCode, email: email }), }); var data = await res.json(); if (data.success) { // Mark claimed so we don't retry localStorage.setItem('fc_ref_claimed', '1'); trackEvent('friend_referral_claimed', { ref_code: refCode, bonus_days: data.bonusDays }); // Clear the cookie so future fresh signups on this device start clean document.cookie = 'fc_ref=; Max-Age=0; Path=/; SameSite=Lax'; } } catch(e) { /* non-fatal */ } } // ── Dog profile share card (dashboard inline) ────────────────────────────── var _dogProfileUrl = null; // ============================================ // First Skill Mastered Celebration // ============================================ var _fsmShareUrl = null; var FSM_EMOJI = { 'marker-word': '🎯', 'nose-touch': '👃', 'sit': '🐕', 'name-recognition': '🏷️', 'bite-inhibition': '🤝', 'handling-grooming': '✂️', 'down': '⬇️', 'leave-it': '🚫', 'drop-it': '🎾', 'place-settle': '🛏️', 'crate-training': '🏠', 'impulse-control': '🧘', }; function checkFirstSkillCelebration() { if (!sessionId) return; // Only show once per session load (localStorage gate prevents repeated shows after dismiss) if (localStorage.getItem('fc_skill_celebrated_v1') === '1') return; fetch('/api/skill-share/check?session_id=' + encodeURIComponent(sessionId)) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.celebration_due) return; _fsmShareUrl = data.share_url; // Stamp server-side immediately (so refreshes don't re-trigger) fetch('/api/skill-share/celebrate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId }), }).catch(function() {}); // Set localStorage gate localStorage.setItem('fc_skill_celebrated_v1', '1'); // Populate modal var dogName = (profile && profile.dog_name) ? profile.dog_name : 'Your dog'; var emojiEl = document.getElementById('fsmEmoji'); var headlineEl = document.getElementById('fsmHeadline'); var statEl = document.getElementById('fsmStat'); var skillEl = document.getElementById('fsmSkillName'); var shareBtn = document.getElementById('fsmShareBtn'); if (emojiEl) emojiEl.textContent = FSM_EMOJI[data.skill_slug] || '⭐'; if (headlineEl) headlineEl.textContent = dogName + ' just mastered ' + data.skill_name + '!'; if (statEl) statEl.textContent = data.rep_count + ' reps logged across ' + (data.days_active || 7) + ' days of training'; if (skillEl) skillEl.textContent = data.skill_name; if (shareBtn) shareBtn.textContent = '🔗 Share ' + dogName + '\u2019s win'; // Show modal with confetti var overlay = document.getElementById('fsmOverlay'); if (overlay) { overlay.classList.add('visible'); spawnFsmConfetti(); } }) .catch(function() { /* non-fatal */ }); } function openSkillSharePage() { if (_fsmShareUrl) { window.open(_fsmShareUrl, '_blank', 'noopener'); } dismissSkillCelebration(); } function dismissSkillCelebration() { var overlay = document.getElementById('fsmOverlay'); if (overlay) overlay.classList.remove('visible'); } function spawnFsmConfetti() { // Lightweight confetti burst — 30 colored dots var colors = ['#D97706', '#2D6A4F', '#FCD34D', '#6EE7B7', '#F59E0B', '#065F46']; for (var i = 0; i < 30; i++) { (function(idx) { setTimeout(function() { var dot = document.createElement('div'); dot.className = 'confetti-piece'; dot.style.cssText = [ 'position:fixed', 'width:' + (6 + Math.random() * 8) + 'px', 'height:' + (6 + Math.random() * 8) + 'px', 'border-radius:50%', 'background:' + colors[Math.floor(Math.random() * colors.length)], 'left:' + (10 + Math.random() * 80) + 'vw', 'top:-10px', 'z-index:9200', 'pointer-events:none', 'animation:confettiFall ' + (1.2 + Math.random() * 1.8) + 's ease-in forwards', ].join(';'); document.body.appendChild(dot); setTimeout(function() { dot.remove(); }, 3200); }, idx * 60); })(i); } } function loadDogProfileShareCard() { if (!sessionId) return; fetch('/api/dog-profile/settings?session_id=' + encodeURIComponent(sessionId)) .then(function(r) { return r.json(); }) .then(function(data) { if (!data || !data.profile_slug || !data.profile_public) return; var card = document.getElementById('dogProfileShareCard'); if (!card) return; _dogProfileUrl = data.profile_url; // Capture slug for activation checklist share step if (data.profile_slug && !_acProfileSlug) { _acProfileSlug = data.profile_slug; } var title = document.getElementById('dogProfileShareCardTitle'); if (title && profile && profile.dog_name) { title.textContent = 'Share ' + profile.dog_name + '\u2019s training profile'; } var viewBtn = document.getElementById('dogProfileViewCardBtn'); if (viewBtn && data.profile_url) viewBtn.href = data.profile_url; card.style.display = 'block'; }) .catch(function() { /* non-fatal */ }); } function copyDogProfileLinkFromCard() { if (!_dogProfileUrl) return; // Activation checklist: mark share_profile step complete acMarkStepComplete('share_profile', 'copy_link'); if (navigator.clipboard) { navigator.clipboard.writeText(_dogProfileUrl).then(function() { var t = document.getElementById('dogProfileCardToast'); if (t) { t.classList.add('visible'); setTimeout(function() { t.classList.remove('visible'); }, 2500); } }); } else { var ta = document.createElement('textarea'); ta.value = _dogProfileUrl; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); var t = document.getElementById('dogProfileCardToast'); if (t) { t.classList.add('visible'); setTimeout(function() { t.classList.remove('visible'); }, 2500); } } } function frcCopyLink() { if (!_frcShareUrl) return; navigator.clipboard.writeText(_frcShareUrl).then(function() { var toast = document.getElementById('frcToast'); if (toast) { toast.classList.add('visible'); setTimeout(function() { toast.classList.remove('visible'); }, 3000); } trackEvent('founding_referral_link_copied', { source: 'dashboard_card' }); }).catch(function() { var input = document.getElementById('frcLinkInput'); if (input) { input.select(); document.execCommand('copy'); } trackEvent('founding_referral_link_copied', { source: 'dashboard_card_fallback' }); }); } // ============================================ // Progressive Profile Enrichment Card // ============================================ function getProfileEnrichQuestions() { const name = (profile && profile.dog_name) || 'your dog'; return [ { id: 'age', question: `How old is ${name}?`, answered: function() { return !!(profile && profile.age); }, options: [ { label: 'Under 6 mo', value: 'Puppy (under 6 months)' }, { label: '6–12 mo', value: 'Puppy (6-12 months)' }, { label: '1–2 years', value: 'Adolescent (1-2 years)' }, { label: '2–7 years', value: 'Adult (2-7 years)' }, { label: '7+ years', value: 'Senior (7+ years)' } ] } ]; } function checkProfileEnrichCard() { if (!profile || !sessionId) return; // Only show after user has sent at least one chat message if (!localStorage.getItem('fc_first_chat_sent')) return; // Don't show if dismissed this session if (sessionStorage.getItem('fc_enrich_dismissed')) return; var questions = getProfileEnrichQuestions(); var pending = questions.find(function(q) { return !q.answered(); }); if (!pending) { var card = document.getElementById('profileEnrichCard'); if (card) card.style.display = 'none'; return; } showProfileEnrichCard(pending); } function showProfileEnrichCard(question) { var card = document.getElementById('profileEnrichCard'); if (!card) return; var dogNameEl = document.getElementById('enrichCardDogName'); var questionEl = document.getElementById('enrichCardQuestion'); var optsEl = document.getElementById('enrichCardOptions'); if (dogNameEl) dogNameEl.textContent = (profile && profile.dog_name) || 'your dog'; if (questionEl) questionEl.textContent = question.question; if (optsEl) { optsEl.innerHTML = ''; question.options.forEach(function(opt) { var btn = document.createElement('button'); btn.textContent = opt.label; btn.style.cssText = 'background:rgba(255,255,255,0.07);border:1px solid rgba(255,255,255,0.14);color:#CBD5E1;border-radius:20px;padding:0.32rem 0.8rem;font-size:0.82rem;cursor:pointer;font-family:inherit;transition:background 0.12s,color 0.12s;white-space:nowrap;'; btn.onmouseenter = function() { this.style.background = 'rgba(255,255,255,0.14)'; this.style.color = '#F1F5F9'; }; btn.onmouseleave = function() { this.style.background = 'rgba(255,255,255,0.07)'; this.style.color = '#CBD5E1'; }; var val = opt.value; var qid = question.id; btn.onclick = function() { answerProfileEnrichQuestion(qid, val); }; optsEl.appendChild(btn); }); } card.style.display = ''; } function answerProfileEnrichQuestion(field, value) { if (!profile || !sessionId) return; // Optimistically update local profile object profile[field] = value; // Refresh the details line in the profile card var detailsEl = document.getElementById('dashDetails'); if (detailsEl) { detailsEl.textContent = profile.breed + (profile.age ? ' · ' + profile.age : ''); } // Persist to server (fire-and-forget — non-blocking) fetch('/api/profile/enrich', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, field: field, value: value }) }).catch(function() {}); trackEvent('profile_enrichment_answered', { field: field, source: 'progressive_card' }); // Check for remaining questions var questions = getProfileEnrichQuestions(); var next = questions.find(function(q) { return !q.answered(); }); if (next) { showProfileEnrichCard(next); } else { var card = document.getElementById('profileEnrichCard'); if (card) card.style.display = 'none'; } } function dismissProfileEnrichCard() { sessionStorage.setItem('fc_enrich_dismissed', '1'); var card = document.getElementById('profileEnrichCard'); if (card) card.style.display = 'none'; trackEvent('profile_enrichment_dismissed', {}); } function updateChatWelcomeAvatar() { if (!profile) return; const avatarEl = document.getElementById('chatWelcomeAvatar'); const emojiEl = document.getElementById('welcomeEmoji'); if (!avatarEl || !emojiEl) return; const _cwPhotoUrl = getDogPhotoUrl(); if (_cwPhotoUrl) { emojiEl.style.display = 'none'; let img = avatarEl.querySelector('img'); if (!img) { img = document.createElement('img'); img.alt = profile.dog_name; avatarEl.appendChild(img); } img.src = _cwPhotoUrl; } else { emojiEl.style.display = ''; const img = avatarEl.querySelector('img'); if (img) img.remove(); const info = COACH_INFO[profile.coach_style] || COACH_INFO.buddy; emojiEl.textContent = info.emoji; } } function updateNavAvatar() { if (!profile) return; const navAvatar = document.getElementById('navDogAvatar'); if (!navAvatar) return; navAvatar.classList.add('visible'); const _navPhotoUrl = getDogPhotoUrl(); if (_navPhotoUrl) { let img = navAvatar.querySelector('img'); if (!img) { img = document.createElement('img'); img.alt = profile.dog_name; navAvatar.appendChild(img); } img.src = _navPhotoUrl; navAvatar.textContent = ''; navAvatar.appendChild(img); } else { const img = navAvatar.querySelector('img'); if (img) img.remove(); const info = COACH_INFO[profile.coach_style] || COACH_INFO.buddy; navAvatar.textContent = info.emoji; } } async function loadSuggestions() { try { const res = await fetch(`/api/suggestions/${sessionId}`); const data = await res.json(); if (data.success) { renderSuggestions(data.suggestions, data.timeContext); } } catch (err) { console.error('Failed to load suggestions:', err); document.getElementById('suggestionsList').innerHTML = '

Couldn\'t load suggestions. Try refreshing.

'; } } async function refreshSuggestions() { const btn = document.getElementById('refreshBtn'); btn.classList.add('spinning'); btn.disabled = true; try { const res = await fetch(`/api/suggestions/${sessionId}?refresh=true`); const data = await res.json(); if (data.success) { renderSuggestions(data.suggestions, data.timeContext); showToast('Fresh training ideas loaded!'); } } catch (err) { showToast('Couldn\'t refresh. Try again.'); } btn.classList.remove('spinning'); btn.disabled = false; } function renderSuggestions(suggestions, timeContext) { const list = document.getElementById('suggestionsList'); if (!suggestions || suggestions.length === 0) { list.innerHTML = '

No suggestions yet. Try refreshing!

'; return; } list.innerHTML = suggestions.map((s, i) => { const catClass = `cat-${s.category || 'obedience'}`; const diffClass = `diff-${s.difficulty || 'medium'}`; return `
${s.icon || '🎯'}
${s.title || 'Training Exercise'}
${s.description || ''}
${s.duration || 5} min ${s.difficulty || 'medium'}
`; }).join(''); } function startSuggestionChat(index) { const card = document.querySelectorAll('.suggestion-card')[index]; if (!card) return; const title = card.dataset.title; const desc = card.dataset.desc; switchTab('chat'); const msg = `I want to work on "${title}" with ${profile.dog_name}. ${desc} Can you walk me through how to do this step by step?`; document.getElementById('chatInput').value = msg; sendMessage(); } // ============================================ // Daily Schedule / Routine // ============================================ let scheduleData = null; let scheduleCheckInterval = null; let notifiedItems = new Set(JSON.parse(localStorage.getItem('fc_notified') || '[]')); // ============================================ // Today's Schedule — dashboard card // ============================================ // Voice History card — shows last 3 summaries if any exist // ============================================ async function loadVoiceHistory() { if (!sessionId) return; const card = document.getElementById('voiceHistoryCard'); const list = document.getElementById('voiceHistoryList'); if (!card || !list) return; try { const res = await fetch('/api/voice/summary/recent/' + encodeURIComponent(sessionId)); const data = await res.json(); if (!data.success || !data.summaries || data.summaries.length === 0) return; card.classList.add('visible'); list.innerHTML = data.summaries.map(function(s) { const date = s.started_at ? new Date(s.started_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : 'Recent session'; const dur = s.duration_sec ? (s.duration_sec < 60 ? s.duration_sec + 's' : Math.round(s.duration_sec / 60) + 'm') : ''; const tags = (s.skill_tags || []).slice(0, 3).map(function(t) { return '' + t + ''; }).join(''); const recap = (s.recap_md || '').replace(/&/g,'&').replace(//g,'>'); return '' + '
' + '
🎙️ ' + date + (dur ? ' · ' + dur : '') + '
' + '
' + recap + '
' + (tags ? '
' + tags + '
' : '') + '
' + '' + '
'; }).join(''); } catch(e) { // Non-fatal — voice history card stays hidden } } // ============================================ // Voice Quickstart Card — one-tap coach CTA above the fold // Fetches current skill + voice usage; renders cap state or live CTA. // ============================================ var _vqsCtx = null; // cached context for vqsCardClick async function loadVoiceQuickstartCard() { if (!sessionId) return; const wrap = document.getElementById('vqsCardWrap'); const card = document.getElementById('vqsCard'); const titleEl = document.getElementById('vqsTitle'); const skillEl = document.getElementById('vqsSkill'); const meterEl = document.getElementById('vqsMeter'); const meterTextEl = document.getElementById('vqsMeterText'); const dismissBtn = document.getElementById('vqsDismissBtn'); if (!wrap || !card) return; // Already dismissed this session const dismissed = sessionStorage.getItem('fc_vqs_dismissed'); if (dismissed) return; try { var res = await fetch('/api/voice/quickstart-context?session_id=' + encodeURIComponent(sessionId)); var data = await res.json(); if (!data.success || !data.context) { // Fallback: show generic voice card var fallback = document.getElementById('voiceCoachCardWrap'); if (fallback) fallback.style.display = 'block'; return; } _vqsCtx = data.context; var ctx = data.context; var voice = ctx.voice; if (voice.blocked) { // Show blocked state — no dismiss, no mic card.classList.add('vqs-blocked'); if (dismissBtn) dismissBtn.style.display = 'none'; titleEl.textContent = 'Voice paused'; skillEl.textContent = voice.capReason || 'Monthly limit reached.'; if (meterEl && meterTextEl) { meterEl.style.display = 'flex'; meterTextEl.innerHTML = voice.capRetryHint ? '' + escHtml(voice.capRetryHint) + '' : 'Resets next month'; } } else { // Live CTA state var skillName = ctx.currentSkill; if (skillName) { titleEl.textContent = 'Coach me on ' + skillName; skillEl.textContent = buildLastRepLine(ctx); } else { titleEl.textContent = 'Talk to coach'; skillEl.textContent = 'Real-time voice — knows your dog'; } // Minutes meter if (meterEl && meterTextEl) { meterEl.style.display = 'flex'; meterTextEl.innerHTML = '' + voice.minutesRemaining + ' of ' + voice.minutesLimit + ' min left this month' + '  ·  ' + '' + voice.sessionMinutesRemaining + ' min per session'; } } wrap.style.display = 'block'; } catch(e) { // Non-fatal — show fallback generic card var fallback2 = document.getElementById('voiceCoachCardWrap'); if (fallback2) fallback2.style.display = 'block'; } } function buildLastRepLine(ctx) { if (!ctx.lastReps || !ctx.lastReps.length) return 'No reps yet — let\'s start'; var last = ctx.lastReps[0]; var label = last.outcome === 'great' ? '✅ great' : last.outcome === 'okay' ? '🟡 okay' : last.outcome === 'struggled' ? '⚠️ struggled' : last.outcome; return 'Last rep: ' + label + (last.notes ? ' — ' + last.notes.slice(0, 50) : ''); } function vqsCardClick() { var ctx = _vqsCtx; if (!ctx) { window.location.href = '/app/voice'; return; } if (ctx.voice && ctx.voice.blocked) return; // do nothing — show reason // Log the quickstart click (fire-and-forget) if (sessionId) { fetch('/api/voice/quickstart-start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, skill_name: ctx.currentSkill || null, skill_id: ctx.currentSkillId || null, dog_id: ctx.dog ? ctx.dog.dogId : null, }) }).then(function(r) { return r.json(); }).then(function(d) { // Store event_id in sessionStorage so voice.html can report completion if (d && d.event_id) sessionStorage.setItem('fc_vqs_event_id', String(d.event_id)); if (d && d.first_ever) sessionStorage.setItem('fc_vqs_first_ever', '1'); }).catch(function(){}); } // Build voice URL with quickstart params var url = '/app/voice?from=quickstart'; if (ctx.currentSkill) url += '&qs_skill=' + encodeURIComponent(ctx.currentSkill); if (ctx.lastOutcome) url += '&qs_outcome=' + encodeURIComponent(ctx.lastOutcome); window.location.href = url; } // Start a walk-mode voice session — pre-configured walk context function startWalkSession() { window.location.href = '/app/voice?mode=walk'; } // Dismiss handler calls sessionStorage so we don't re-show within the same session var _origDismissDashCard = typeof dismissDashCard === 'function' ? dismissDashCard : null; // ============================================ let dashSchedEditorOpen = false; async function loadDashboardSchedule() { if (!sessionId) return; const card = document.getElementById('dashScheduleCard'); if (card) card.style.display = 'block'; try { const res = await fetch('/api/schedule/' + encodeURIComponent(sessionId)); const data = await res.json(); if (data.success) { if (data.wake_time) { const wi = document.getElementById('dashWakeInput'); if (wi) wi.value = data.wake_time; } if (data.sleep_time) { const si = document.getElementById('dashSleepInput'); if (si) si.value = data.sleep_time; } renderDashboardSchedule(data.schedule.items); renderDashSchedWhy(); } } catch (err) { console.error('Dashboard schedule load error:', err); const tl = document.getElementById('dashSchedTimeline'); if (tl) tl.innerHTML = '

Couldn\'t load schedule. Refresh to retry.

'; } } function renderDashboardSchedule(items) { const container = document.getElementById('dashSchedTimeline'); if (!container) return; if (!items || items.length === 0) { container.innerHTML = '

No schedule yet.

'; return; } const now = new Date(); const nowMins = now.getHours() * 60 + now.getMinutes(); // Find the first pending item that is within 5 min of now or in the future let nextIdx = -1; for (let i = 0; i < items.length; i++) { const [h, m] = (items[i].time || '00:00').split(':').map(Number); const iMins = h * 60 + m; if (items[i].status === 'pending' && iMins >= nowMins - 5) { nextIdx = i; break; } } const html = items.map(function(item, idx) { const parts = (item.time || '00:00').split(':').map(Number); const h = parts[0], m = parts[1]; const iMins = h * 60 + m; const isNext = (idx === nextIdx); const isPast = item.status === 'pending' && iMins < nowMins - 5 && !isNext; const period = h < 12 ? 'am' : 'pm'; const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h; const timeStr = h12 + ':' + String(m).padStart(2, '0') + '' + period + ''; let cls = 'dash-sched-item'; if (isNext) cls += ' is-next'; if (isPast) cls += ' is-past'; const badge = isNext ? 'Up next' : ''; const ctx = item.context ? '
' + item.context + '
' : ''; return '
' + '
' + timeStr + '
' + '
' + (item.emoji || '🐾') + '
' + '
' + '
' + item.title + '
' + ctx + '
' + badge + '
'; }).join(''); container.innerHTML = html; } function renderDashSchedWhy() { const body = document.getElementById('dashSchedWhyBody'); if (!body || !profile) return; const a = (profile.age || '').toLowerCase(); let g = 'adult'; if (a.includes('under 6') || (a.includes('puppy') && !a.includes('6-12'))) g = 'young_puppy'; else if (a.includes('6-12')) g = 'older_puppy'; else if (a.includes('adolescent') || a.includes('1-2')) g = 'adolescent'; else if (a.includes('senior') || a.includes('7+')) g = 'senior'; const dn = profile.dog_name || 'Your dog'; const texts = { young_puppy: '' + dn + ' is under 6 months — the most critical window for setting lifetime habits.' + '

Potty breaks every 90 min match a young puppy\'s bladder capacity. Gaps longer than this cause accidents that undermine house training.' + '

3 meals a day stabilise blood sugar and make elimination predictable — consistent meal timing is house training\'s secret weapon.' + '

5-min training sessions twice daily beat one long session — puppies this age can\'t sustain focus, but short repetitions build deep conditioning fast.' + '

Scheduled naps are not optional. Overtired puppies bite, bark, and can\'t retain learning. The midday rest is the single biggest lever for afternoon behaviour. → Why overtiredness drives puppy biting', older_puppy: '' + dn + ' is 6–12 months — the adolescent phase where training consistency matters most.' + '

Potty breaks every 2 hours reflect improving bladder control. You\'re extending the interval gradually to build real holding capacity.' + '

10-min training sessions twice daily reinforce everything learned in the puppy phase. Two sessions beats one — dogs consolidate between them.' + '

Naps still matter — adolescent dogs don\'t show tiredness as visibly as puppies, but the rest consolidates learning. → Training through adolescence', adolescent: '' + dn + ' is in the 1–2 year window — energy is high and impulse control is still developing.' + '

15-min training sessions focus on proofing skills in distracting environments, not just the backyard.' + '

Two walks a day are the minimum. Under-exercised adolescents create their own entertainment — destructively.' + '

Potty breaks every 3 hours reflect a fully developed bladder. You\'re now training timing habits, not managing a physical limitation.', senior: '' + dn + ' benefits most from predictable routine — seniors thrive on consistency.' + '

Shorter training sessions respect reduced stamina while keeping the mind sharp. Mental exercise is just as important for ageing dogs as physical.' + '

Gentle walks maintain joint mobility without impact stress. Consistency beats intensity at this life stage.', adult: '' + dn + '\'s schedule is built for adult maintenance — consistent exercise, reinforced training, reliable meal timing.' + '

15-min training sessions twice daily keep skills sharp and the owner–dog relationship active. Dogs don\'t retain skills without regular practice.' + '

Two meals a day is the proven standard — once-daily feeding increases bloat risk in larger breeds.' + '

Potty breaks every 4 hours respect adult bladder capacity while preventing the discomfort that leads to accidents.' }; body.innerHTML = texts[g] || texts.adult; } function toggleDashSchedEditor() { dashSchedEditorOpen = !dashSchedEditorOpen; const el = document.getElementById('dashSchedEditor'); if (el) el.classList.toggle('open', dashSchedEditorOpen); } async function saveDashWakeSleep() { if (!sessionId) return; const wake = (document.getElementById('dashWakeInput') || {}).value; const sleep = (document.getElementById('dashSleepInput') || {}).value; if (!wake || !sleep) return; const btn = document.querySelector('.dash-sched-save-btn'); if (btn) { btn.disabled = true; btn.textContent = 'Saving…'; } try { const res = await fetch('/api/schedule/' + encodeURIComponent(sessionId) + '/wake-sleep', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ wake_time: wake, sleep_time: sleep }) }); const data = await res.json(); if (data.success) { renderDashboardSchedule(data.schedule.items); dashSchedEditorOpen = false; var editor = document.getElementById('dashSchedEditor'); if (editor) editor.classList.remove('open'); showToast('Schedule updated for your day 🐾'); } else { showToast('Couldn\'t save. Try again.'); } } catch (err) { showToast('Couldn\'t save. Try again.'); } finally { if (btn) { btn.disabled = false; btn.textContent = 'Save'; } } } function toggleDashSchedWhy() { const body = document.getElementById('dashSchedWhyBody'); const chevron = document.getElementById('dashSchedWhyChevron'); if (!body) return; const isOpen = body.classList.toggle('open'); if (chevron) chevron.style.transform = isOpen ? 'rotate(90deg)' : ''; } // ============================================ // End Today's Schedule dashboard card // ============================================ async function loadSchedule() { if (!profile) return; const today = new Date().toLocaleDateString('en-CA'); // YYYY-MM-DD document.getElementById('schedSubtitle').textContent = `${profile.dog_name}'s plan for today`; document.getElementById('schedDateLabel').textContent = new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' }); // Show loading if no data yet if (!scheduleData || scheduleData.schedule_date !== today) { document.getElementById('scheduleTimeline').innerHTML = `
Building ${profile.dog_name}'s routine…
`; } try { const res = await fetch(`/api/schedule/${sessionId}`); const data = await res.json(); if (data.success) { scheduleData = data.schedule; renderSchedule(data.schedule.items); } } catch (err) { console.error('Schedule load error:', err); document.getElementById('scheduleTimeline').innerHTML = '

Couldn\'t load routine. Try again.

'; } checkNotifBanner(); } async function regenerateSchedule() { const btn = document.getElementById('regenBtn'); btn.disabled = true; btn.innerHTML = ' Generating…'; try { const res = await fetch(`/api/schedule/${sessionId}?refresh=true`); const data = await res.json(); if (data.success) { scheduleData = data.schedule; // Clear notified cache for today notifiedItems = new Set(); localStorage.setItem('fc_notified', '[]'); renderSchedule(data.schedule.items); showToast('Fresh routine generated!'); } } catch (err) { showToast('Couldn\'t regenerate. Try again.'); } btn.disabled = false; btn.innerHTML = ' Regenerate'; } function renderSchedule(items) { if (!items || items.length === 0) { document.getElementById('scheduleTimeline').innerHTML = '

No routine yet. Tap Regenerate to create one.

'; return; } // Calculate progress const doneCount = items.filter(i => i.status === 'done').length; const total = items.length; const pct = total > 0 ? Math.round((doneCount / total) * 100) : 0; document.getElementById('progressBarFill').style.width = `${pct}%`; document.getElementById('progressLabel').textContent = `${doneCount} / ${total} done`; document.getElementById('progressEmoji').textContent = pct === 0 ? '🐾' : pct < 50 ? '🌱' : pct < 100 ? '🔥' : '🏆'; // Current time for highlighting "current" item const now = new Date(); const nowMins = now.getHours() * 60 + now.getMinutes(); const html = items.map(item => { const [hh, mm] = (item.time || '00:00').split(':').map(Number); const itemMins = hh * 60 + mm; const diffMins = nowMins - itemMins; const isCurrent = item.status === 'pending' && diffMins >= -5 && diffMins < 30; const isPast = item.status === 'pending' && diffMins >= 30; let statusBadge = ''; let actionBtns = ''; let itemClass = `sched-item status-${item.status}`; if (isCurrent) itemClass += ' status-current'; if (item.status === 'done') { statusBadge = '✓ Done'; actionBtns = ``; } else if (item.status === 'skipped') { statusBadge = 'Skipped'; actionBtns = ``; } else if (item.status === 'snoozed') { const snoozeTime = item.snooze_until ? new Date(item.snooze_until).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : ''; statusBadge = `⏰ ${snoozeTime}`; actionBtns = ``; } else { actionBtns = ` `; } // Format time to 12h const period = hh < 12 ? 'am' : 'pm'; const h12 = hh === 0 ? 12 : hh > 12 ? hh - 12 : hh; const timeStr = `${h12}:${String(mm).padStart(2,'0')}${period}`; const pastStyle = isPast && item.status === 'pending' ? 'opacity:0.5;' : ''; return `
${timeStr}
${item.emoji || '🐾'}
${item.title}
${item.duration_min} min · ${item.type}
${statusBadge}
${actionBtns}
`; }).join(''); document.getElementById('scheduleTimeline').innerHTML = html; } async function updateSchedItem(itemId, status, snoozeMins) { try { const body = { item_id: itemId, status }; if (status === 'snoozed' && snoozeMins) body.snooze_minutes = snoozeMins; const res = await fetch(`/api/schedule/${sessionId}/item`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const data = await res.json(); if (data.success) { scheduleData = data.schedule; renderSchedule(data.schedule.items); if (status === 'done') showToast('✅ Marked done!'); else if (status === 'snoozed') showToast('⏰ Snoozed 15 min'); } } catch (err) { showToast('Couldn\'t update. Try again.'); } } // ============================================ // Skills / Command Tracker // ============================================ let commandsCache = null; let mySkillsCache = null; let currentSkillDetailId = null; let pendingSkillProposal = null; // { name, instructions } const STATUS_LABELS = { 'not_started': '⬜ Not started', 'working_on_it': '🔄 Working on it', 'solid': '✅ Solid' }; const STATUS_NEXT = { 'not_started': 'working_on_it', 'working_on_it': 'solid', 'solid': 'not_started' }; const DIFFICULTY_LABELS = { 'beginner': '🟢 Beginner', 'intermediate': '🟡 Intermediate', 'advanced': '🔴 Advanced' }; // Skill status labels + cycle order const MY_SKILL_STATUS_LABELS = { 'not_started': '⬜ Not started', 'in_progress': '🔄 In progress', 'mastered': '✅ Mastered', 'archived': '📦 Archived' }; const MY_SKILL_STATUS_NEXT = { 'not_started': 'in_progress', 'in_progress': 'mastered', 'mastered': 'not_started', 'archived': 'not_started' }; // Current training tab state let currentTrainingTab = 'active'; function switchTrainingTab(tab) { currentTrainingTab = tab; ['active', 'library', 'archived'].forEach(t => { document.getElementById('trainingTab' + t.charAt(0).toUpperCase() + t.slice(1)) .classList.toggle('active', t === tab); document.getElementById('trainingPanel' + t.charAt(0).toUpperCase() + t.slice(1)) .classList.toggle('active', t === tab); }); } async function reactivateSkill(event, skillId) { event.stopPropagation(); const btn = event.currentTarget; btn.disabled = true; try { const res = await fetch(`/api/skills/${sessionId}/${skillId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'in_progress' }) }); const data = await res.json(); if (data.success) { if (mySkillsCache) { const skill = mySkillsCache.find(s => s.id === skillId); if (skill) skill.status = 'in_progress'; renderMySkills(getMergedSkillsList()); } switchTrainingTab('active'); showToast('🔄 Skill moved back to Active'); } else { showToast('Couldn\'t re-activate. Try again.'); btn.disabled = false; } } catch (err) { showToast('Couldn\'t re-activate. Try again.'); btn.disabled = false; } } // ── Skill Tree (tier-gated curriculum) ────────────────────────────────── const SKILL_TIER_ICONS = { locked: '🔒', not_started: '○', practicing: '✦', mastered: '✓' }; const SKILL_TIER_LABELS = { locked: 'Locked', not_started: 'Not started', practicing: 'Practicing', mastered: 'Mastered' }; async function loadSkillTree() { if (!sessionId) return; const section = document.getElementById('skillTreeSection'); const tiersEl = document.getElementById('skillTreeTiers'); try { const res = await fetch(`/api/skill-tree/${sessionId}`); if (!res.ok) return; const data = await res.json(); if (!data.success || !data.tiers || !data.tiers.length) return; section.style.display = 'block'; // Show /101 Foundations CTA for Tier 1 users who haven't unlocked Tier 2 yet const isNewUser = data.unlockedTiers && data.unlockedTiers.length === 1 && data.unlockedTiers[0] === 1; const foundationsCta = document.getElementById('skillTreeFoundationsCta'); if (foundationsCta) foundationsCta.classList.toggle('visible', isNewUser); // Update skill tree title for new users to signal Week 1 focus const titleEl = document.getElementById('skillTreeTitle'); if (titleEl) titleEl.textContent = isNewUser ? '🌳 Week 1 Skill Tree' : '🌳 Skill Tree'; tiersEl.innerHTML = data.tiers.map((tier, i) => { const masteredCount = tier.skills.filter(s => s.state === 'mastered').length; const practicingCount = tier.skills.filter(s => s.state === 'practicing').length; let metaStr = ''; if (!tier.is_unlocked) { metaStr = 'Locked'; } else if (masteredCount > 0) { metaStr = `${masteredCount}/${tier.skills.length} mastered`; } else if (practicingCount > 0) { metaStr = `${practicingCount} in progress`; } else { metaStr = `${tier.skills.length} skills`; } // Default open tier 1 (lowest unlocked) const isOpen = i === 0 && tier.is_unlocked; return `
${tier.tier}
${escapeHtml(tier.name)}
${metaStr}
${tier.skills.map(sk => { const icon = sk.state === 'mastered' ? '✓' : sk.state === 'practicing' ? '✦' : sk.state === 'locked' ? '🔒' : '○'; const label = SKILL_TIER_LABELS[sk.state] || sk.state; const prereqs = sk.prerequisites && sk.prerequisites.length ? ` title="Requires: ${sk.prerequisites.join(', ')}"` : ''; const safeSlug = sk.slug.replace(/'/g, "\\'"); const safeName = sk.name.replace(/'/g, "\\'"); const clickAttr = sk.state !== 'locked' ? ` onclick="openSkillTreeDetail('${safeSlug}', '${safeName}')"` : ''; return `
${icon} ${escapeHtml(sk.name)} ${label}
`; }).join('')}
`; }).join(''); } catch (_) { // Non-critical — skill tree is progressive enhancement } } function toggleSkillTreeTier(tierEl) { tierEl.classList.toggle('tier-open'); } // Tier 1 + Tier 2 skills that have day-by-day micro-lesson pages const SKILLS_WITH_DAYS = new Set([ // Tier 1 (Week 1 Foundations) — days 1-7 'marker-word', 'nose-touch', 'sit', 'name-recognition', 'bite-inhibition', 'handling-grooming', // Tier 2 (Week 2) — days 1-3 'down', 'leave-it', 'drop-it', 'place-settle', 'crate-training', 'impulse-control' ]); // Tier 3/4 skills with SEO description pages but no day lessons yet const SKILLS_WITH_SEO_ONLY = new Set(['recall', 'stay', 'loose-leash', 'place']); function openSkillTreeDetail(slug, name) { // Priority 1: Skills with day-by-day lessons go to the activity page if (SKILLS_WITH_DAYS.has(slug)) { window.location.href = '/skills/' + slug + '/day-1'; return; } // Priority 2: Skills with SEO pages but no day lessons go to description if (SKILLS_WITH_SEO_ONLY.has(slug)) { window.location.href = '/skills/' + slug; return; } // Priority 3: Open detail overlay if user has this skill in their My Training list const match = mySkillsCache && mySkillsCache.find(s => s.name.toLowerCase().includes(name.toLowerCase()) || name.toLowerCase().includes(s.name.toLowerCase()) ); if (match) { openSkillDetail(match.id); return; } // Priority 4: Redirect to coach chat with prefilled prompt const prompt = encodeURIComponent('I want to start working on "' + name + '" \u2014 what should I focus on?'); window.location.href = '/app?tab=chat&prompt=' + prompt; } function showTierUnlockBanner(tierNum, tierName) { const banner = document.getElementById('skillTreeUnlockBanner'); const titleEl = document.getElementById('skillTreeUnlockTitle'); const subEl = document.getElementById('skillTreeUnlockSub'); if (!banner) return; titleEl.textContent = `🔓 You unlocked ${tierName}!`; subEl.textContent = 'New skills are now available in your Skill Tree.'; banner.classList.add('visible'); setTimeout(() => banner.classList.remove('visible'), 8000); } async function loadSkills() { if (!sessionId) return; // Load custom skills, pre-built commands, AND skill tree in parallel const [mySkillsData, commandsData] = await Promise.allSettled([ fetch(`/api/skills/${sessionId}`).then(r => r.json()), fetch(`/api/commands/${sessionId}`).then(r => r.json()) ]); // Collect custom skills let customSkills = []; if (mySkillsData.status === 'fulfilled' && mySkillsData.value.success) { customSkills = mySkillsData.value.skills || []; } // Collect active commands (working_on_it or solid) and map to skill-like objects let activeCommandSkills = []; if (commandsData.status === 'fulfilled' && commandsData.value.success) { commandsCache = commandsData.value.commands; activeCommandSkills = commandsData.value.commands .filter(c => c.status === 'working_on_it' || c.status === 'solid') .map(c => ({ id: 'cmd_' + c.id, name: c.name, status: c.status === 'solid' ? 'mastered' : 'in_progress', instructions: c.description || c.tips || '', isCommand: true, command_id: c.id })); } // Merge: custom skills first, then active commands (skip duplicates by name) const customNames = new Set(customSkills.map(s => s.name.toLowerCase().trim())); const deduped = activeCommandSkills.filter(c => !customNames.has(c.name.toLowerCase().trim())); const merged = [...customSkills, ...deduped]; mySkillsCache = customSkills; // keep cache as real skills only for detail/edit renderMySkills(merged); // Deep link: ?skill=ID from nurture email opens that skill's detail overlay const _skillIdParam = new URLSearchParams(window.location.search).get('skill'); if (_skillIdParam) { const _skillId = parseInt(_skillIdParam, 10); if (!isNaN(_skillId) && mySkillsCache.find(s => s.id === _skillId)) { setTimeout(() => openSkillDetail(_skillId), 80); } } // Render commands library const contentEl = document.getElementById('skillsContent'); if (commandsData.status === 'fulfilled' && commandsData.value.success) { renderSkills(commandsData.value.commands, commandsData.value.summary); } else { contentEl.innerHTML = '
Couldn\'t load commands. Try again.
'; } // Load skill tree in background — progressive enhancement loadSkillTree().catch(() => {}); } // Merge custom skills with active commands for unified My Training Skills view function getMergedSkillsList() { const customSkills = mySkillsCache || []; let activeCommandSkills = []; if (commandsCache && commandsCache.length) { activeCommandSkills = commandsCache .filter(c => c.status === 'working_on_it' || c.status === 'solid') .map(c => ({ id: 'cmd_' + c.id, name: c.name, status: c.status === 'solid' ? 'mastered' : 'in_progress', instructions: c.description || c.tips || '', isCommand: true, command_id: c.id })); } const customNames = new Set(customSkills.map(s => s.name.toLowerCase().trim())); const deduped = activeCommandSkills.filter(c => !customNames.has(c.name.toLowerCase().trim())); return [...customSkills, ...deduped]; } function renderMySkills(skills) { const metaEl = document.getElementById('mySkillsMeta'); if (!skills || skills.length === 0) { metaEl.textContent = ''; const emptyHtml = `
No skills yetAsk your coach how to train a specific skill — they\'ll offer to save it here so you can track progress.
`; document.getElementById('mySkillsListActive').innerHTML = emptyHtml; document.getElementById('mySkillsListLibrary').innerHTML = `
Skills Library emptySkills you\'ve mastered will appear here as an achievement record.
`; document.getElementById('mySkillsListArchived').innerHTML = `
Nothing archivedSkills you dismiss from Active training appear here.
`; return; } // Split skills by status const activeSkills = skills.filter(s => s.status === 'in_progress' || s.status === 'not_started'); const masteredSkills = skills.filter(s => s.status === 'mastered'); const archivedSkills = skills.filter(s => s.status === 'archived'); // Update tab button labels with counts const libCount = masteredSkills.length; const arcCount = archivedSkills.length; document.getElementById('trainingTabLibrary').textContent = libCount ? `Skills Library (${libCount})` : 'Skills Library'; document.getElementById('trainingTabArchived').textContent = arcCount ? `Archived (${arcCount})` : 'Archived'; // Meta summary const inProgress = activeSkills.filter(s => s.status === 'in_progress').length; metaEl.textContent = libCount ? `${libCount} mastered` : inProgress ? `${inProgress} in progress` : `${skills.length} saved`; // Helper: render a skill card for the Active tab function renderActiveCard(skill) { const statusClass = 's-' + skill.status; const statusLabel = MY_SKILL_STATUS_LABELS[skill.status] || skill.status; const preview = skill.instructions ? skill.instructions.substring(0, 100) + (skill.instructions.length > 100 ? '…' : '') : 'Tap to view training plan'; if (skill.isCommand) { return `
${escapeHtml(skill.name)} from Commands
${escapeHtml(preview)}
${statusLabel}
`; } return `
${escapeHtml(skill.name)}
${escapeHtml(preview)}
`; } // Helper: render a mastered skill card for the Library tab function renderLibraryCard(skill) { const preview = skill.instructions ? skill.instructions.substring(0, 90) + (skill.instructions.length > 90 ? '…' : '') : 'Tap to view training history'; const clickHandler = skill.isCommand ? `document.getElementById('skillsContent').scrollIntoView({behavior:'smooth'})` : `openSkillDetail(${skill.id})`; const reactivateBtn = skill.isCommand ? '' : ``; return `
${escapeHtml(skill.name)} ⭐ Mastered
${escapeHtml(preview)}
${reactivateBtn}
`; } // Helper: render archived skill card function renderArchivedCard(skill) { const preview = skill.instructions ? skill.instructions.substring(0, 90) + (skill.instructions.length > 90 ? '…' : '') : ''; return `
${escapeHtml(skill.name)}
${escapeHtml(preview) || 'Archived'}
`; } // Render Active tab const activeEl = document.getElementById('mySkillsListActive'); if (activeSkills.length === 0) { activeEl.innerHTML = `
All caught up!You\'re not working on any skills right now. Start a skill in the Commands section below, or ask your coach for a new one.
`; } else { activeEl.innerHTML = activeSkills.map(renderActiveCard).join(''); } // Render Library tab const libEl = document.getElementById('mySkillsListLibrary'); if (masteredSkills.length === 0) { libEl.innerHTML = `
Skills Library emptyMaster a skill to add it here. Mastered skills become your achievement record and reference material.
`; } else { libEl.innerHTML = masteredSkills.map(renderLibraryCard).join(''); } // Render Archived tab const arcEl = document.getElementById('mySkillsListArchived'); if (archivedSkills.length === 0) { arcEl.innerHTML = `
Nothing archivedArchive a skill from the skill detail view to remove it from Active training without deleting it.
`; } else { arcEl.innerHTML = archivedSkills.map(renderArchivedCard).join(''); } } async function cycleMySkillStatus(event, skillId, currentStatus) { event.stopPropagation(); const newStatus = MY_SKILL_STATUS_NEXT[currentStatus] || 'not_started'; const btn = event.currentTarget; btn.disabled = true; try { const res = await fetch(`/api/skills/${sessionId}/${skillId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: newStatus }) }); const data = await res.json(); if (data.success) { if (mySkillsCache) { const skill = mySkillsCache.find(s => s.id === skillId); if (skill) skill.status = newStatus; renderMySkills(getMergedSkillsList()); } if (newStatus === 'mastered') { switchTrainingTab('library'); showToast('✅ Skill mastered! Moved to Skills Library'); } else if (newStatus === 'in_progress') { showToast('🔄 Marked as in progress'); } else { showToast('Moved back to not started'); } } } catch (err) { showToast('Couldn\'t update. Try again.'); } finally { btn.disabled = false; } } function openSkillDetail(skillId) { const skill = mySkillsCache && mySkillsCache.find(s => s.id === skillId); if (!skill) return; currentSkillDetailId = skillId; document.getElementById('skillDetailName').textContent = skill.name; document.getElementById('skillDetailInstructions').textContent = skill.instructions || 'No training plan saved.'; const dateStr = skill.created_at ? new Date(skill.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : ''; document.getElementById('skillDetailDate').textContent = dateStr ? `Saved ${dateStr}` : ''; const statusBtn = document.getElementById('skillDetailStatusBtn'); statusBtn.textContent = MY_SKILL_STATUS_LABELS[skill.status] || skill.status; statusBtn.className = 'my-skill-status-btn s-' + skill.status; // Sessions counter const sessionsEl = document.getElementById('skillDetailSessions'); sessionsEl.textContent = skill.sessions > 0 ? `${skill.sessions} session${skill.sessions === 1 ? '' : 's'}` : ''; // Reset check-in panel const panel = document.getElementById('skillCheckinPanel'); panel.style.display = 'none'; document.getElementById('checkinNoteInput').value = ''; selectedCheckinType = null; checkinAttachedVideoId = null; document.querySelectorAll('.checkin-type-btn').forEach(b => b.classList.remove('selected')); document.getElementById('checkinVideoAttach').style.display = 'none'; // Reset video strip document.getElementById('skillVideoStrip').innerHTML = '
Loading…
'; document.getElementById('skillVideoUploadProgress').classList.remove('active'); document.getElementById('skillVideoUploadStatus').classList.remove('active'); skillVideosCache = []; // Reset check-ins feed document.getElementById('skillCheckinsFeed').innerHTML = '
Loading…
'; document.getElementById('skillDetailOverlay').classList.add('visible'); // Load check-ins + videos async loadSkillCheckins(skillId); loadSkillVideos(skillId); } function closeSkillDetail() { document.getElementById('skillDetailOverlay').classList.remove('visible'); currentSkillDetailId = null; } async function cycleSkillStatusFromDetail() { if (!currentSkillDetailId) return; const skill = mySkillsCache && mySkillsCache.find(s => s.id === currentSkillDetailId); if (!skill) return; const newStatus = MY_SKILL_STATUS_NEXT[skill.status] || 'not_started'; try { const res = await fetch(`/api/skills/${sessionId}/${currentSkillDetailId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: newStatus }) }); const data = await res.json(); if (data.success) { skill.status = newStatus; renderMySkills(getMergedSkillsList()); const statusBtn = document.getElementById('skillDetailStatusBtn'); statusBtn.textContent = MY_SKILL_STATUS_LABELS[newStatus] || newStatus; statusBtn.className = 'my-skill-status-btn s-' + newStatus; if (newStatus === 'mastered') { // Show mastered reflection prompt, then switch to library tab document.getElementById('masteredTitle').textContent = `${skill.name} — mastered! 🎉`; document.getElementById('masteredReflectionInput').value = ''; document.getElementById('masteredOverlay').classList.add('visible'); switchTrainingTab('library'); } else if (newStatus === 'in_progress') { showToast('🔄 Marked as in progress'); } else { showToast('Moved back to not started'); } } } catch (err) { showToast('Couldn\'t update. Try again.'); } } async function archiveCurrentSkill() { if (!currentSkillDetailId) return; try { const res = await fetch(`/api/skills/${sessionId}/${currentSkillDetailId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'archived' }) }); const data = await res.json(); if (data.success) { if (mySkillsCache) { const skill = mySkillsCache.find(s => s.id === currentSkillDetailId); if (skill) skill.status = 'archived'; renderMySkills(getMergedSkillsList()); } closeSkillDetail(); switchTrainingTab('archived'); showToast('📦 Skill archived'); } else { showToast('Couldn\'t archive. Try again.'); } } catch (err) { showToast('Couldn\'t archive. Try again.'); } } async function deleteCurrentSkill() { if (!currentSkillDetailId) return; try { await fetch(`/api/skills/${sessionId}/${currentSkillDetailId}`, { method: 'DELETE' }); if (mySkillsCache) { mySkillsCache = mySkillsCache.filter(s => s.id !== currentSkillDetailId); renderMySkills(getMergedSkillsList()); } closeSkillDetail(); showToast('Skill removed'); } catch (err) { showToast('Couldn\'t delete. Try again.'); } } // Fuzzy skill name match — true if proposal name matches an existing skill function findDuplicateSkill(proposalName) { const normalize = s => s.toLowerCase().replace(/[^a-z0-9]/g, ''); const needle = normalize(proposalName); // Check custom skills cache if (mySkillsCache && mySkillsCache.length) { const match = mySkillsCache.find(s => { const hay = normalize(s.name); return hay === needle || hay.includes(needle) || needle.includes(hay); }); if (match) return match; } // Also check commands cache for active commands if (commandsCache && commandsCache.length) { const cmdMatch = commandsCache.find(c => { if (c.status === 'not_started') return false; const hay = normalize(c.name); return hay === needle || hay.includes(needle) || needle.includes(hay); }); if (cmdMatch) return { id: 'cmd_' + cmdMatch.id, name: cmdMatch.name, isCommand: true }; } return null; } // Show skill proposal card after an AI message let _proposalCounter = 0; function showSkillProposal(proposal) { pendingSkillProposal = proposal; const container = document.getElementById('chatMessages'); const card = document.createElement('div'); card.className = 'skill-proposal-card'; _proposalCounter++; card.id = 'skillProposalCard_' + _proposalCounter; card.dataset.proposalId = _proposalCounter; const duplicate = findDuplicateSkill(proposal.name); if (duplicate) { // Already working on this skill — surface the existing one const openAction = duplicate.isCommand ? `dismissSkillProposal();switchTab('skills');setTimeout(()=>document.getElementById('skillsContent').scrollIntoView({behavior:'smooth'}),150)` : `dismissSkillProposal();switchTab('skills');setTimeout(()=>openSkillDetail(${duplicate.id}),150)`; card.innerHTML = `
📚
You\'re already tracking "${escapeHtml(duplicate.name)}"${duplicate.isCommand ? ' in Commands' : ''}
${duplicate.isCommand ? 'Check your Commands Library to update your progress.' : 'Open it in your Skills tab to log a check-in or chat about your progress.'}
`; } else { card.innerHTML = `
📚
Save "${escapeHtml(proposal.name)}" as a skill?
I\'ll keep the training steps in your Skills tab so you can track progress anytime.
`; } // Store proposal data on the card element for multi-proposal support container.appendChild(card); card._proposal = proposal; scrollToBottom(); } async function saveSkillFromProposal(btn) { // Find the closest proposal card and its stored proposal data const card = btn ? btn.closest('.skill-proposal-card') : document.querySelector('.skill-proposal-card'); if (!card) return; const proposal = card._proposal || pendingSkillProposal; if (!proposal) return; pendingSkillProposal = null; card.remove(); try { const res = await fetch('/api/skills', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, name: proposal.name, instructions: proposal.instructions || '', status: 'in_progress' }) }); const data = await res.json(); if (data.success) { // Update cache if (!mySkillsCache) mySkillsCache = []; mySkillsCache.unshift(data.skill); // Trigger paywall on 3rd skill saved if (mySkillsCache.length === 3) { setTimeout(function() { triggerPaywall('skill_3'); }, 600); } // Show confirmation in chat const container = document.getElementById('chatMessages'); const confirm = document.createElement('div'); confirm.className = 'skill-saved-confirm'; confirm.textContent = `✅ "${proposal.name}" saved to your Skills tab`; container.appendChild(confirm); scrollToBottom(); showToast('📚 Skill saved!'); } else { showToast('Couldn\'t save skill. Try again.'); } } catch (err) { showToast('Couldn\'t save skill. Try again.'); } } function dismissSkillProposal(btn) { pendingSkillProposal = null; const card = btn ? btn.closest('.skill-proposal-card') : document.querySelector('.skill-proposal-card'); if (card) card.remove(); } // ============================================ // Phase 2: Check-ins // ============================================ let selectedCheckinType = null; let skillVideosCache = []; let checkinAttachedVideoId = null; function toggleCheckinPanel() { const panel = document.getElementById('skillCheckinPanel'); const isVisible = panel.style.display !== 'none'; panel.style.display = isVisible ? 'none' : 'block'; } function selectCheckinType(type) { selectedCheckinType = type; document.querySelectorAll('.checkin-type-btn').forEach(b => b.classList.remove('selected')); const btn = document.getElementById('checkinType' + type.charAt(0).toUpperCase() + type.slice(1)); if (btn) btn.classList.add('selected'); } async function submitCheckin() { if (!selectedCheckinType) { showToast('Pick a type first — Success, Learning, or Feedback'); return; } if (!currentSkillDetailId) return; const note = document.getElementById('checkinNoteInput').value.trim(); const btn = document.getElementById('checkinSubmitBtn'); btn.disabled = true; btn.textContent = 'Saving…'; // Track telemetry trackVideoEvent('skill_checkin_submitted', { has_video: !!checkinAttachedVideoId }); try { const res = await fetch(`/api/skills/${sessionId}/${currentSkillDetailId}/checkins`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: selectedCheckinType, note: note || null, video_id: checkinAttachedVideoId || null }) }); const data = await res.json(); if (data.success) { // Win-moment redirect: first check-in or streak milestone if (data.redirect_to) { window.location.href = data.redirect_to; return; } // Tier unlock notification if (data.tier_unlocked && data.tier_name) { showTierUnlockBanner(data.tier_unlocked, data.tier_name); loadSkillTree().catch(() => {}); // Refresh tree to show newly unlocked skills } // Update sessions in local cache const skill = mySkillsCache && mySkillsCache.find(s => s.id === currentSkillDetailId); if (skill) { skill.sessions = (skill.sessions || 0) + 1; document.getElementById('skillDetailSessions').textContent = `${skill.sessions} session${skill.sessions === 1 ? '' : 's'}`; } // Reset panel document.getElementById('checkinNoteInput').value = ''; selectedCheckinType = null; checkinAttachedVideoId = null; document.querySelectorAll('.checkin-type-btn').forEach(b => b.classList.remove('selected')); document.getElementById('skillCheckinPanel').style.display = 'none'; document.getElementById('checkinVideoAttach').style.display = 'none'; // Reload feed + videos (video is now attached) await loadSkillCheckins(currentSkillDetailId); await loadSkillVideos(currentSkillDetailId); showToast('✓ Check-in logged!'); } else { showToast('Couldn\'t save. Try again.'); } } catch (err) { showToast('Couldn\'t save. Try again.'); } finally { btn.disabled = false; btn.textContent = 'Save check-in'; } } async function loadSkillCheckins(skillId) { try { const res = await fetch(`/api/skills/${sessionId}/${skillId}/checkins`); const data = await res.json(); if (data.success) { renderCheckins(data.checkins); } } catch (err) { document.getElementById('skillCheckinsFeed').innerHTML = '
Couldn\'t load check-ins.
'; } } function renderCheckins(checkins) { const feed = document.getElementById('skillCheckinsFeed'); if (!checkins || checkins.length === 0) { feed.innerHTML = '
No check-ins yet. Log your first session above.
'; return; } const typeIcon = { success: '✅', learning: '💡', feedback: '💬' }; feed.innerHTML = checkins.map(c => { const icon = typeIcon[c.type] || '📌'; const dateStr = new Date(c.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); const pinnedBadge = c.is_pinned ? '📌 Pinned' : ''; const videoBadge = c.video_id ? '📹 Video' : ''; return `
${icon}
${c.note ? `
${escapeHtml(c.note)}
` : ''}
${c.type.charAt(0).toUpperCase() + c.type.slice(1)} · ${dateStr}${pinnedBadge}${videoBadge}
`; }).join(''); } // ============================================ // Phase 2: Mastered Reflection // ============================================ async function submitMasteredReflection() { if (!currentSkillDetailId) { closeMasteredOverlay(); return; } const note = document.getElementById('masteredReflectionInput').value.trim(); if (note) { try { await fetch(`/api/skills/${sessionId}/${currentSkillDetailId}/checkins`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'success', note, is_pinned: true }) }); // Update sessions in local cache const skill = mySkillsCache && mySkillsCache.find(s => s.id === currentSkillDetailId); if (skill) { skill.sessions = (skill.sessions || 0) + 1; document.getElementById('skillDetailSessions').textContent = `${skill.sessions} session${skill.sessions === 1 ? '' : 's'}`; } await loadSkillCheckins(currentSkillDetailId); } catch (err) { // Non-fatal } } closeMasteredOverlay(); showToast('🏆 Mastered! Reflection pinned.'); } function closeMasteredOverlay() { document.getElementById('masteredOverlay').classList.remove('visible'); } // ============================================ // Phase 3: Skill Progress Videos // ============================================ // Telemetry helper function trackVideoEvent(eventName, extra) { try { fetch('/api/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, event_type: eventName, metadata: { skill_id: currentSkillDetailId, ...extra } }) }).catch(() => {}); } catch (_) {} } // "Record progress" button click function handleVideoRecord() { trackVideoEvent('skill_video_record_clicked'); document.getElementById('skillVideoFileInput').click(); } // File selected — validate + upload async function handleVideoFileSelected(event) { const file = event.target.files && event.target.files[0]; event.target.value = ''; if (!file || !currentSkillDetailId) return; const MAX_BYTES = 50 * 1024 * 1024; // 50MB const allowedTypes = ['video/mp4', 'video/quicktime', 'video/webm', 'video/x-m4v']; if (!allowedTypes.includes(file.type) && !file.type.startsWith('video/')) { showToast('Only mp4, mov, and webm videos are allowed'); return; } if (file.size > MAX_BYTES) { showToast('Video must be under 50MB'); return; } // Get duration via a temporary object URL let duration = null; try { duration = await getVideoDuration(file); if (duration > 62) { showToast('Video must be 60 seconds or less'); return; } } catch (_) {} trackVideoEvent('skill_video_upload_started', { file_size: file.size, duration }); await uploadSkillVideo(file, currentSkillDetailId, duration); } function getVideoDuration(file) { return new Promise((resolve, reject) => { const url = URL.createObjectURL(file); const v = document.createElement('video'); v.preload = 'metadata'; v.onloadedmetadata = () => { URL.revokeObjectURL(url); resolve(v.duration); }; v.onerror = () => { URL.revokeObjectURL(url); reject(); }; v.src = url; }); } async function uploadSkillVideo(file, skillId, duration) { const progressEl = document.getElementById('skillVideoUploadProgress'); const progressBar = document.getElementById('skillVideoUploadProgressBar'); const statusEl = document.getElementById('skillVideoUploadStatus'); const recordBtn = document.getElementById('skillVideoRecordBtn'); progressEl.classList.add('active'); statusEl.classList.add('active'); statusEl.textContent = 'Uploading…'; progressBar.style.width = '10%'; recordBtn.disabled = true; try { const formData = new FormData(); formData.append('video', file); if (duration) formData.append('duration_seconds', String(Math.round(duration))); // Simulate progress while uploading (XHR for real progress) const progInterval = setInterval(() => { const cur = parseFloat(progressBar.style.width) || 10; if (cur < 85) progressBar.style.width = (cur + 5) + '%'; }, 300); const res = await fetch(`/api/skills/${sessionId}/${skillId}/videos`, { method: 'POST', body: formData }); clearInterval(progInterval); progressBar.style.width = '100%'; const data = await res.json(); if (data.success) { trackVideoEvent('skill_video_upload_completed', { video_id: data.video.id }); statusEl.textContent = '✓ Video saved!'; setTimeout(() => { progressEl.classList.remove('active'); statusEl.classList.remove('active'); progressBar.style.width = '0%'; }, 2000); // Refresh video strip await loadSkillVideos(skillId); showToast('📹 Video saved!'); // Fire paywall trigger on first video recorded setTimeout(function() { triggerPaywall('video_1'); }, 1200); } else { throw new Error(data.message || 'Upload failed'); } } catch (err) { trackVideoEvent('skill_video_upload_failed', { error: err.message }); progressEl.classList.remove('active'); statusEl.classList.remove('active'); progressBar.style.width = '0%'; showToast('Couldn\'t upload. ' + (err.message.includes('R2') ? 'Storage error — try again.' : 'Try again.')); } finally { recordBtn.disabled = false; } } async function loadSkillVideos(skillId) { try { const res = await fetch(`/api/skills/${sessionId}/${skillId}/videos`); const data = await res.json(); if (data.success) { skillVideosCache = data.videos || []; renderVideoStrip(skillVideosCache); updateCheckinVideoAttachButton(); } } catch (err) { document.getElementById('skillVideoStrip').innerHTML = '
Couldn\'t load videos.
'; } } function renderVideoStrip(videos) { const strip = document.getElementById('skillVideoStrip'); if (!videos || videos.length === 0) { strip.innerHTML = '
No videos yet — tap 📹 Record to capture a clip
'; return; } // Show up to 3 most recent strip.innerHTML = videos.slice(0, 3).map(v => { const dateStr = new Date(v.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); const labelClass = v.checkin_type || ''; const labelText = v.checkin_type ? { success: 'Success', learning: 'Learning', feedback: 'Feedback' }[v.checkin_type] || '' : ''; return `
${dateStr} ${labelText ? `${labelText}` : ''}
`; }).join(''); } function openVideoPlayer(videoId) { const video = skillVideosCache.find(v => v.id === videoId); if (!video) return; trackVideoEvent('skill_video_played', { video_id: videoId }); const player = document.getElementById('skillVideoPlayer'); player.src = video.play_url; player.load(); const dateStr = new Date(video.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); const checkinLabel = video.checkin_type ? ` · ${video.checkin_type.charAt(0).toUpperCase() + video.checkin_type.slice(1)}` : ''; document.getElementById('skillVideoPlayerMeta').textContent = dateStr + checkinLabel; document.getElementById('skillVideoPlayerOverlay').classList.add('visible'); } function closeVideoPlayer() { document.getElementById('skillVideoPlayerOverlay').classList.remove('visible'); const player = document.getElementById('skillVideoPlayer'); player.pause(); player.src = ''; } // Show "Attach video" option in check-in panel if there's a recent unattached video function updateCheckinVideoAttachButton() { const attachRow = document.getElementById('checkinVideoAttach'); const attachLabel = document.getElementById('checkinVideoAttachLabel'); const attachBtn = document.getElementById('checkinVideoAttachBtn'); const unattached = skillVideosCache.find(v => !v.checkin_id); if (unattached) { const dateStr = new Date(unattached.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); attachLabel.textContent = `📹 Attach recent video (${dateStr})?`; attachRow.style.display = 'flex'; if (checkinAttachedVideoId === unattached.id) { attachBtn.textContent = '✓ Attached'; attachBtn.classList.add('attached'); } else { attachBtn.textContent = 'Attach'; attachBtn.classList.remove('attached'); } } else { attachRow.style.display = 'none'; } } function toggleAttachVideo() { const unattached = skillVideosCache.find(v => !v.checkin_id); if (!unattached) return; if (checkinAttachedVideoId === unattached.id) { checkinAttachedVideoId = null; } else { checkinAttachedVideoId = unattached.id; } updateCheckinVideoAttachButton(); } // Show attach button when check-in panel opens (called from toggleCheckinPanel via HTML onclick) // ============================================ // Phase 2: Skill-Scoped Chat // ============================================ let currentSkillChatId = null; let skillChatSending = false; async function openSkillChat() { if (!currentSkillDetailId) return; currentSkillChatId = currentSkillDetailId; const skill = mySkillsCache && mySkillsCache.find(s => s.id === currentSkillDetailId); const skillName = skill ? skill.name : 'Skill'; document.getElementById('skillChatTitle').textContent = skillName; document.getElementById('skillChatMessages').innerHTML = '
Loading conversation…
'; document.getElementById('skillChatInput').value = ''; document.getElementById('skillChatOverlay').classList.add('visible'); // Load history try { const res = await fetch(`/api/skills/${sessionId}/${currentSkillChatId}/chat`); const data = await res.json(); if (data.success) { renderSkillChatMessages(data.messages); if (data.messages.length === 0) { // Seed with a welcome bubble appendSkillChatMsg('assistant', `Let's focus on ${skillName}. What's going on in training? Any wins or blockers to work through?`); } } } catch (err) { document.getElementById('skillChatMessages').innerHTML = '
Couldn\'t load history.
'; } } function closeSkillChat() { document.getElementById('skillChatOverlay').classList.remove('visible'); currentSkillChatId = null; } function renderSkillChatMessages(messages) { const container = document.getElementById('skillChatMessages'); if (!messages || messages.length === 0) { container.innerHTML = ''; return; } container.innerHTML = ''; messages.forEach(m => appendSkillChatMsg(m.role, m.content)); } function appendSkillChatMsg(role, content) { const container = document.getElementById('skillChatMessages'); const el = document.createElement('div'); el.className = 'skill-chat-msg ' + role; el.textContent = content; container.appendChild(el); container.scrollTop = container.scrollHeight; } async function sendSkillChatMessage() { if (skillChatSending || !currentSkillChatId) return; const input = document.getElementById('skillChatInput'); const message = input.value.trim(); if (!message) return; skillChatSending = true; input.value = ''; input.disabled = true; appendSkillChatMsg('user', message); const thinking = document.createElement('div'); thinking.className = 'skill-chat-msg thinking'; thinking.textContent = 'Thinking…'; document.getElementById('skillChatMessages').appendChild(thinking); document.getElementById('skillChatMessages').scrollTop = document.getElementById('skillChatMessages').scrollHeight; try { const res = await fetch(`/api/skills/${sessionId}/${currentSkillChatId}/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message }) }); const data = await res.json(); thinking.remove(); if (data.success) { appendSkillChatMsg('assistant', data.response); } else { appendSkillChatMsg('assistant', 'Couldn\'t get a response. Try again.'); } } catch (err) { thinking.remove(); appendSkillChatMsg('assistant', 'Couldn\'t connect. Try again.'); } finally { skillChatSending = false; input.disabled = false; input.focus(); } } function renderSkills(commands, summary) { // Update progress const pct = summary.total ? Math.round((summary.solid / summary.total) * 100) : 0; document.getElementById('skillsProgressFill').style.width = pct + '%'; const workingText = summary.working ? `, ${summary.working} in progress` : ''; document.getElementById('skillsProgressLabel').textContent = `${summary.solid} of ${summary.total} mastered${workingText}`; // Group by difficulty const groups = { beginner: [], intermediate: [], advanced: [] }; commands.forEach(cmd => groups[cmd.difficulty] && groups[cmd.difficulty].push(cmd)); let html = ''; ['beginner', 'intermediate', 'advanced'].forEach(diff => { const cmds = groups[diff]; if (!cmds.length) return; html += `
${DIFFICULTY_LABELS[diff]}
`; cmds.forEach(cmd => { const statusClass = 'status-' + cmd.status; const btnClass = 's-' + cmd.status; html += `
${escapeHtml(cmd.name)}
${escapeHtml(cmd.description)}
💡 ${escapeHtml(cmd.tips)}
`; }); html += '
'; }); document.getElementById('skillsContent').innerHTML = html || '
No commands found.
'; } function toggleCmdExpand(commandId) { const card = document.getElementById('cmd-' + commandId); if (card) card.classList.toggle('expanded'); } async function cycleCommandStatus(event, commandId, currentStatus) { event.stopPropagation(); const newStatus = STATUS_NEXT[currentStatus] || 'not_started'; const btn = event.currentTarget; btn.disabled = true; try { const res = await fetch(`/api/commands/${sessionId}/${commandId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: newStatus }) }); const data = await res.json(); if (data.success) { // Update local cache if (commandsCache) { const cmd = commandsCache.find(c => c.id === commandId); if (cmd) cmd.status = newStatus; const solid = commandsCache.filter(c => c.status === 'solid').length; const working = commandsCache.filter(c => c.status === 'working_on_it').length; renderSkills(commandsCache, { total: commandsCache.length, solid, working }); } if (newStatus === 'solid') showToast('✅ Marked as solid!'); else if (newStatus === 'working_on_it') showToast('🔄 Working on it!'); else showToast('Moved back to not started'); } } catch (err) { showToast('Couldn\'t update. Try again.'); } finally { btn.disabled = false; } } function escapeHtml(str) { return str.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } // ============================================ // Notifications // ============================================ let swReg = null; async function initServiceWorker() { if (!('serviceWorker' in navigator)) return; try { swReg = await navigator.serviceWorker.register('/sw.js'); // Listen for updates from SW (schedule updates + reminder action deep links) navigator.serviceWorker.addEventListener('message', (event) => { if (event.data?.type === 'SCHEDULE_UPDATED' && currentTab === 'schedule') { loadSchedule(); } // Reminder notification tap while app was open — handle action if (event.data?.type === 'REMINDER_ACTION' && event.data.action) { handleReminderAction(event.data.action); } }); // If push permission is already granted, ensure we have a server push subscription if (Notification.permission === 'granted' && sessionId) { subscribeToServerPush(swReg).catch(() => {}); } } catch (e) { console.warn('SW registration failed:', e); } } // ============================================ // Reminder Action Deep Links // ============================================ // Called when user taps a push notification; action = potty/feeding/walk/training. // Switches to the most relevant tab and shows a log prompt so one tap logs the activity. function handleReminderAction(action) { if (!action) return; const actionMap = { potty: { tab: 'schedule', toast: '🚽 Time for a potty break — tap to log it' }, feeding: { tab: 'schedule', toast: '🍖 Feeding time — tap to log it' }, walk: { tab: 'schedule', toast: '🦮 Walk time — tap to log it' }, training: { tab: 'skills', toast: '🎯 Training window — open a skill to log reps' }, }; const mapped = actionMap[action] || { tab: 'dashboard', toast: '🐾 Time to check in with ' + (profile?.dog_name || 'your pup') }; switchTab(mapped.tab); showToast(mapped.toast); // Track the deep link hit if (sessionId) { fetch('/api/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event_type: 'reminder_deeplink_opened', metadata: { action } }) }).catch(() => {}); } } // ============================================ // PWA Install Prompt Logic // ============================================ function maybeShowPwaPrompt() { // Only fire once per session if (pwaPromptShown) return; // Don't show if already installed if (localStorage.getItem('fc_pwa_installed')) return; // Don't show if dismissed within 14 days const dismissedAt = parseInt(localStorage.getItem('fc_pwa_dismiss_at') || '0', 10); if (dismissedAt && Date.now() - dismissedAt < 14 * 24 * 60 * 60 * 1000) return; pwaPromptShown = true; if (isIosSafari && !localStorage.getItem('fc_ios_pwa_shown')) { // iOS Safari: show one-time Add to Home Screen tutorial setTimeout(() => showIosPwaOverlay(), 800); } else if (deferredInstallPrompt) { // Chrome/Android: show slide-up banner setTimeout(() => showPwaBanner(), 800); } } function showPwaBanner() { const banner = document.getElementById('pwaBanner'); if (!banner) return; banner.classList.add('visible'); trackEvent('pwa_install_prompt_shown', { method: 'beforeinstallprompt' }); } function hidePwaBanner() { const banner = document.getElementById('pwaBanner'); if (banner) banner.classList.remove('visible'); } async function acceptPwaInstall() { hidePwaBanner(); if (!deferredInstallPrompt) return; trackEvent('pwa_install_accepted', {}); deferredInstallPrompt.prompt(); const { outcome } = await deferredInstallPrompt.userChoice; deferredInstallPrompt = null; if (outcome === 'accepted') { localStorage.setItem('fc_pwa_installed', '1'); } else { // User dismissed the OS-level prompt — treat as dismiss localStorage.setItem('fc_pwa_dismiss_at', String(Date.now())); } } function dismissPwaBanner() { hidePwaBanner(); localStorage.setItem('fc_pwa_dismiss_at', String(Date.now())); trackEvent('pwa_install_dismissed', { method: 'banner' }); } function showIosPwaOverlay() { const overlay = document.getElementById('iosPwaOverlay'); if (!overlay) return; overlay.classList.add('visible'); localStorage.setItem('fc_ios_pwa_shown', '1'); trackEvent('pwa_install_prompt_shown', { method: 'ios_tutorial' }); } function dismissIosPwaOverlay() { const overlay = document.getElementById('iosPwaOverlay'); if (overlay) overlay.classList.remove('visible'); localStorage.setItem('fc_pwa_dismiss_at', String(Date.now())); trackEvent('pwa_install_dismissed', { method: 'ios_tutorial' }); } function checkNotifBanner() { const banner = document.getElementById('notifBanner'); if (!banner) return; if (Notification.permission === 'default') { banner.style.display = 'flex'; } else { banner.style.display = 'none'; } } async function requestNotifPermission() { if (!('Notification' in window)) return; const permission = await Notification.requestPermission(); document.getElementById('notifBanner').style.display = 'none'; if (permission === 'granted') { showToast('🔔 Reminders enabled!'); startScheduleNotifChecker(); } } function startScheduleNotifChecker() { if (scheduleCheckInterval) clearInterval(scheduleCheckInterval); checkScheduleNotifications(); scheduleCheckInterval = setInterval(checkScheduleNotifications, 60 * 1000); // every minute } async function checkScheduleNotifications() { if (Notification.permission !== 'granted') return; if (!sessionId) return; // Load latest schedule if needed if (!scheduleData) { try { const res = await fetch(`/api/schedule/${sessionId}`); const d = await res.json(); if (d.success) scheduleData = d.schedule; } catch (e) { return; } } if (!scheduleData) return; const now = new Date(); const nowMins = now.getHours() * 60 + now.getMinutes(); for (const item of scheduleData.items) { if (item.status !== 'pending') continue; if (notifiedItems.has(item.id)) continue; // Skip if snoozed and not yet expired if (item.snooze_until && new Date(item.snooze_until) > now) continue; const [hh, mm] = (item.time || '00:00').split(':').map(Number); const itemMins = hh * 60 + mm; const diff = nowMins - itemMins; // Fire notification when item is within 2 minutes of its time if (diff >= -2 && diff <= 5) { notifiedItems.add(item.id); // Persist notified set (resets daily) localStorage.setItem('fc_notified', JSON.stringify([...notifiedItems])); const dogName = profile ? profile.dog_name : 'your dog'; const title = `Time for ${dogName}'s ${item.title}`; const body = `${item.emoji || ''} ${item.duration_min} min · Tap Done or Snooze 15 min`; if (swReg) { // Use service worker for notification with actions swReg.active?.postMessage({ type: 'SHOW_NOTIFICATION', itemId: item.id, title, body, sessionId }); } else if (Notification.permission === 'granted') { // Fallback: basic notification new Notification(title, { body, icon: '/favicon.ico', tag: `sched-${item.id}` }); } } } } // ============================================ // Chat // ============================================ let chatLoaded = false; function setupChat() { if (!profile) return; const coach = COACH_WELCOME[profile.coach_style] || COACH_WELCOME.buddy; document.getElementById('welcomeEmoji').textContent = coach.emoji; document.getElementById('welcomeTitle').textContent = coach.title; document.getElementById('welcomeSubtitle').textContent = coach.subtitle; // Show dog photo in welcome avatar if available updateChatWelcomeAvatar(); // Sync voice mode pill with current state updateVoiceModePill(); // Show "Try voice" tip once per device after deploy maybeShowVoiceTip(); if (!chatLoaded) { loadChatHistory(); chatLoaded = true; } setTimeout(() => document.getElementById('chatInput')?.focus(), 200); } async function loadChatHistory() { try { const res = await fetch(`/api/chat/${sessionId}`); const data = await res.json(); if (data.success && data.messages.length > 0) { document.getElementById('chatWelcome').style.display = 'none'; document.getElementById('quickActions').style.display = 'none'; // Show kickoff "New from your coach" badge if user hasn't replied yet if (data.kickoff_pending) { const badge = document.createElement('div'); badge.className = 'kickoff-badge'; badge.id = 'kickoffBadge'; badge.innerHTML = 'New from your coach'; document.getElementById('chatMessages').appendChild(badge); } data.messages.forEach((m, idx) => { appendMessage(m.role === 'user' ? 'user' : 'ai', m.content); // After first AI message (kickoff), inject exercise CTA if (idx === 0 && m.role === 'assistant' && data.kickoff_pending && data.kickoff_skill) { const skillNameMap = { 'loose-leash': 'Loose-Leash Walking', 'recall': 'Recall', 'settle': 'Settle on Mat', 'leave-it': 'Leave It', 'crate': 'Crate Training', 'place': 'Place', 'potty-on-cue': 'Potty Training' }; const skillLabel = skillNameMap[data.kickoff_skill] || 'today\'s exercise'; const ctaWrap = document.createElement('div'); ctaWrap.className = 'kickoff-cta-wrap'; ctaWrap.id = 'kickoffCtaWrap'; const btn = document.createElement('button'); btn.className = 'kickoff-cta-btn'; btn.innerHTML = 'Try the ' + skillLabel + ' exercise '; btn.onclick = () => { const input = document.getElementById('chatInput'); if (input) { input.value = 'Ready to try today\'s exercise. Walk me through it step by step.'; input.focus(); // Scroll to input input.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } dismissKickoffUI(); }; ctaWrap.appendChild(btn); document.getElementById('chatMessages').appendChild(ctaWrap); } }); scrollToBottom(); } } catch (e) { console.error('Failed to load history:', e); } } function dismissKickoffUI() { const badge = document.getElementById('kickoffBadge'); const cta = document.getElementById('kickoffCtaWrap'); if (badge) badge.remove(); if (cta) cta.remove(); } function appendMessage(type, content) { const container = document.getElementById('chatMessages'); const div = document.createElement('div'); div.className = `message message-${type}`; const avatar = document.createElement('div'); avatar.className = 'message-avatar'; const bubble = document.createElement('div'); bubble.className = 'message-bubble'; if (type === 'user') { // Show dog's photo in user message avatar if available const userPhotoUrl = getDogPhotoUrl(); if (userPhotoUrl) { const img = document.createElement('img'); img.src = userPhotoUrl; img.alt = (profile && profile.dog_name) || 'Dog'; img.style.cssText = 'width:100%;height:100%;object-fit:cover;border-radius:50%;display:block;'; avatar.style.overflow = 'hidden'; avatar.style.padding = '0'; avatar.appendChild(img); } else { avatar.textContent = '\uD83D\uDC3E'; } bubble.textContent = content; div.appendChild(avatar); div.appendChild(bubble); } else { avatar.textContent = '\uD83D\uDC15'; bubble.innerHTML = formatAIResponse(content); div.appendChild(avatar); // Add speaker button for messages within TTS char limit if (content.length <= 1500) { const aiWrap = document.createElement('div'); aiWrap.className = 'message-ai-wrap'; const speakBtn = document.createElement('button'); speakBtn.className = 'msg-speak-btn'; speakBtn.title = 'Play voice'; speakBtn.setAttribute('aria-label', 'Play voice'); speakBtn.innerHTML = ''; speakBtn.onclick = () => playMessageTTS(speakBtn, content); aiWrap.appendChild(bubble); aiWrap.appendChild(speakBtn); div.appendChild(aiWrap); } else { div.appendChild(bubble); } } container.appendChild(div); } // On-demand TTS for individual messages — caches audio blobs to avoid re-billing async function playMessageTTS(btn, content) { // Toggle off if this button is already playing if (activeSpeakBtn === btn) { stopAudio(); btn.classList.remove('playing'); activeSpeakBtn = null; return; } // Stop any currently playing audio stopAudio(); if (activeSpeakBtn) { activeSpeakBtn.classList.remove('playing'); activeSpeakBtn = null; } activeSpeakBtn = btn; btn.classList.add('playing'); // Use first 120 chars as cache key (good enough for uniqueness) const cacheKey = content.substring(0, 120); try { let audioUrl; if (ttsAudioCache.has(cacheKey)) { audioUrl = ttsAudioCache.get(cacheKey); } else { const res = await fetch('/api/tts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: content, coach_style: (typeof profile !== 'undefined' && profile?.coach_style) || 'buddy', session_id: (typeof sessionId !== 'undefined') ? sessionId : null }) }); if (!res.ok) throw new Error('TTS request failed'); const blob = await res.blob(); audioUrl = URL.createObjectURL(blob); ttsAudioCache.set(cacheKey, audioUrl); } currentAudio = new Audio(audioUrl); setPlayingState(true); currentAudio.onended = () => { setPlayingState(false); btn.classList.remove('playing'); if (activeSpeakBtn === btn) activeSpeakBtn = null; currentAudio = null; }; currentAudio.onerror = () => { setPlayingState(false); btn.classList.remove('playing'); if (activeSpeakBtn === btn) activeSpeakBtn = null; currentAudio = null; showToast('Voice playback failed'); }; await currentAudio.play(); } catch (err) { btn.classList.remove('playing'); if (activeSpeakBtn === btn) activeSpeakBtn = null; showToast('Voice unavailable \u2014 please try again'); } } function formatAIResponse(text) { let html = text .replace(/\*\*(.*?)\*\*/g, '$1') .replace(/^(\d+)\.\s+(.+)$/gm, '
  • $2
  • ') .replace(/^[-•]\s+(.+)$/gm, '
  • $1
  • ') .split('\n\n').map(p => { p = p.trim(); if (!p) return ''; if (p.includes('
  • ')) return '
      ' + p + '
    '; return '

    ' + p.replace(/\n/g, '
    ') + '

    '; }).join(''); return html; } async function sendMessage() { const input = document.getElementById('chatInput'); const msg = input.value.trim(); if (!msg || isSending) return; stopAudio(); // stop any playing TTS before sending new message const isVoiceMsg = nextMsgIsVoice; nextMsgIsVoice = false; // reset immediately isSending = true; input.value = ''; autoResize(input); document.getElementById('sendBtn').disabled = true; document.getElementById('chatWelcome').style.display = 'none'; document.getElementById('quickActions').style.display = 'none'; // Dismiss kickoff badge/CTA on first user message dismissKickoffUI(); appendMessage('user', msg); scrollToBottom(); trackEvent('message_sent', { message_number: serverMessageCount + 1 }); document.getElementById('typingIndicator').classList.add('visible'); scrollToBottom(); try { const res = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, message: msg, modality: isVoiceMsg ? 'voice' : 'text' }) }); // 402 = gated — show hard paywall if (res.status === 402) { document.getElementById('typingIndicator').classList.remove('visible'); isSending = false; document.getElementById('sendBtn').disabled = false; input.focus(); // Remove the optimistically appended user message const msgs = document.getElementById('chatMessages'); if (msgs.lastChild) msgs.removeChild(msgs.lastChild); // Restore the message in the input input.value = msg; autoResize(input); showSubscribeModal(); return; } const data = await res.json(); document.getElementById('typingIndicator').classList.remove('visible'); if (data.success) { appendMessage('ai', data.response); // Update server-side count if (typeof data.message_count === 'number') { serverMessageCount = data.message_count; } // Update paywall banner based on remaining quota if (typeof data.weekly_remaining === 'number' && typeof data.weekly_cap === 'number') { updatePaywallAfterChat(data.weekly_remaining, data.weekly_cap); } // Track session chat count and fire paywall trigger at 5 messages sessionChatCount++; if (sessionChatCount === 1) { // Mark first chat sent — unlocks progressive profile enrichment card + push opt-in localStorage.setItem('fc_first_chat_sent', '1'); if (currentTab === 'dashboard') { checkProfileEnrichCard(); } setTimeout(checkPushPromptCard, 1200); } // chat_5_msg trigger removed — trust-repair pass #3. // Firing a paywall at message 5 in every session (no once-ever guard) is premature. // The once-ever first_chat_6msg trigger below handles this milestone. // Value-moment: fire once-ever upgrade prompt at 6th user message if (sessionChatCount === 6 && !userIsSubscribed && !localStorage.getItem('fc_upgrade_chat_shown')) { localStorage.setItem('fc_upgrade_chat_shown', '1'); recordUpgradePrompt('first_chat'); window._upgradeRef = 'first_chat'; setTimeout(function() { triggerPaywall('first_chat_6msg'); }, 1200); } // Show skill proposal card(s) if coach detected skill intent const proposals = data.skill_proposals || (data.skill_proposal && data.skill_proposal.name ? [data.skill_proposal] : []); proposals.forEach((p, i) => { if (p && p.name) { setTimeout(() => showSkillProposal(p), 200 + i * 300); } }); speakResponse(data.response, profile?.coach_style || 'buddy'); // PWA install prompt: show after first completed chat round-trip maybeShowPwaPrompt(); } else { appendMessage('ai', 'Sorry, I had trouble responding. Please try again!'); } } catch (err) { document.getElementById('typingIndicator').classList.remove('visible'); appendMessage('ai', 'Connection error. Please check your internet and try again.'); } isSending = false; document.getElementById('sendBtn').disabled = false; input.focus(); scrollToBottom(); } function sendQuick(text) { document.getElementById('chatInput').value = text; sendMessage(); } function scrollToBottom() { const container = document.getElementById('chatMessages'); setTimeout(() => { container.scrollTop = container.scrollHeight; }, 50); } function handleKeyDown(e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } } function autoResize(el) { el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight, 100) + 'px'; document.getElementById('sendBtn').disabled = !el.value.trim(); } document.getElementById('chatInput')?.addEventListener('input', function() { document.getElementById('sendBtn').disabled = !this.value.trim() || isSending; }); // ============================================ // Settings // ============================================ function openSettings() { if (!profile) return; document.getElementById('sDogName').value = profile.dog_name; document.getElementById('sBreed').value = profile.breed; document.getElementById('sAge').value = profile.age; const radio = document.querySelector(`input[name="sCoach"][value="${profile.coach_style}"]`); if (radio) radio.checked = true; // Preferences if (profile.timezone) document.getElementById('sTimezone').value = profile.timezone; if (profile.session_length_minutes) document.getElementById('sSessionLength').value = profile.session_length_minutes; // Reminders link (include session ID) const remLink = document.getElementById('remindersSettingsLink'); if (remLink && sessionId) remLink.href = `/settings/reminders?sid=${sessionId}`; // Dog ID const dogIdEl = document.getElementById('settingsDogId'); if (dogIdEl) dogIdEl.textContent = profile.dog_id || 'Not generated yet'; // Check founding member status + Pro upgrade card visibility fetch('/api/check-subscription?sessionId=' + sessionId) .then(function(r) { return r.json(); }) .then(function(sub) { var badge = document.getElementById('foundingBadge'); var upgradeCard = document.getElementById('proUpgradeCard'); var isPaidUser = sub.active; if (badge) { if (sub.active && sub.isFoundingMember) { badge.classList.add('visible'); } else { badge.classList.remove('visible'); } } // Show Pro upgrade card for non-paying users if (upgradeCard) upgradeCard.classList.toggle('visible', !isPaidUser); // Reminder lock var reminderLock = document.getElementById('reminderLockRow'); if (reminderLock) reminderLock.classList.toggle('visible', !isPaidUser); }) .catch(function() {}); const copyBtn = document.getElementById('settingsCopyBtn'); if (copyBtn) { copyBtn.innerHTML = '📋 Copy'; copyBtn.classList.remove('copied'); } // Photo preview populatePhotoPreview(getDogPhotoUrl()); // Load public dog profile share card loadDogProfileShare(); // Load /d/:slug opt-in shareable profile state loadDProfile(); // Load coach memory count loadCoachMemorySection(); document.getElementById('settingsModal').classList.add('visible'); } function loadDogProfileShare() { if (!sessionId) return; fetch('/api/dog-profile/settings?session_id=' + sessionId) .then(function(r) { return r.json(); }) .then(function(data) { var section = document.getElementById('dogProfileShareSection'); if (!section) return; if (data.profile_slug) { section.style.display = 'block'; var viewBtn = document.getElementById('dogProfileViewBtn'); if (viewBtn && data.profile_url) viewBtn.href = data.profile_url; var sub = document.getElementById('dogProfileShareSub'); if (sub) sub.textContent = (profile && profile.dog_name ? profile.dog_name + ' has' : 'Your dog has') + ' a public training profile anyone can view.'; var toggle = document.getElementById('dogProfilePublicToggle'); if (toggle) toggle.checked = data.profile_public !== false; // Store URL for copy section.dataset.profileUrl = data.profile_url || ''; } }) .catch(function() {}); } var _coachMemoryTopThread = null; function loadCoachMemorySection() { if (!sessionId) return; // Load summary and per-fact count in parallel Promise.all([ fetch('/api/memory/coach-summary?session_id=' + encodeURIComponent(sessionId)).then(function(r){return r.json();}).catch(function(){return null;}), fetch('/api/memory?session_id=' + encodeURIComponent(sessionId)).then(function(r){return r.json();}).catch(function(){return null;}), fetch('/api/dog-profile/settings?session_id=' + encodeURIComponent(sessionId)).then(function(r){return r.json();}).catch(function(){return null;}) ]).then(function(results) { var summaryData = results[0]; var factsData = results[1]; var settingsData = results[2]; var section = document.getElementById('coachMemorySection'); if (!section) return; section.style.display = 'block'; var dogN = (profile && profile.dog_name) ? profile.dog_name : 'your dog'; // Set memory link URL var btn = document.getElementById('coachMemoryBtn'); if (btn && settingsData && settingsData.profile_slug) { btn.href = '/dogs/' + settingsData.profile_slug + '/memory'; } // Capture slug for activation checklist share step if (settingsData && settingsData.profile_slug) { _acProfileSlug = settingsData.profile_slug; } // Show rolling session summary if available if (summaryData && summaryData.success && summaryData.exists) { if (summaryData.last_session_summary) { var lastEl = document.getElementById('coachMemoryLastSession'); if (lastEl) { lastEl.style.display = 'block'; lastEl.textContent = '📝 Last session: ' + summaryData.last_session_summary; } } // Show active threads var activeThreads = (summaryData.open_threads || []).filter(function(t){ return !t.resolved_at; }).slice(0, 3); if (activeThreads.length > 0) { var threadsEl = document.getElementById('coachMemoryThreads'); if (threadsEl) { threadsEl.style.display = 'block'; var html = '
    Working on
    '; activeThreads.forEach(function(t) { html += '
    • ' + escStr(t.text) + '
    '; }); threadsEl.innerHTML = html; // Save top thread for pick-up CTA _coachMemoryTopThread = activeThreads[0] ? activeThreads[0].text : null; } } // Show "Pick up where we left off" button if we have a thread var pickupBtn = document.getElementById('coachMemoryPickupBtn'); if (pickupBtn && _coachMemoryTopThread) { pickupBtn.style.display = 'inline-block'; } // Update subtitle var sub = document.getElementById('coachMemorySub'); if (sub) { var factCount = (factsData && factsData.count) || 0; sub.textContent = factCount + ' thing' + (factCount !== 1 ? 's' : '') + ' logged about ' + dogN + '.'; } } else { // No rolling memory yet — fall back to fact count var sub2 = document.getElementById('coachMemorySub'); var count2 = (factsData && factsData.count) || 0; if (sub2) sub2.textContent = 'Coach knows ' + count2 + ' thing' + (count2 !== 1 ? 's' : '') + ' about ' + dogN + '.'; } }).catch(function() {}); } function escStr(s) { return String(s || '').replace(/&/g,'&').replace(//g,'>'); } function pickUpWhereWeLeftOff() { if (!_coachMemoryTopThread) return; // Switch to chat tab and pre-seed with the top thread var chatTabBtn = document.querySelector('[data-tab="chat"]') || document.getElementById('chatTabBtn'); if (chatTabBtn) chatTabBtn.click(); // Give the tab switch a moment, then focus and pre-fill the chat input setTimeout(function() { var chatInput = document.getElementById('chatInput') || document.querySelector('textarea[placeholder*="Ask"]') || document.querySelector('.chat-input'); if (chatInput) { chatInput.value = "Let\u2019s keep working on: " + _coachMemoryTopThread; chatInput.focus(); chatInput.dispatchEvent(new Event('input', { bubbles: true })); } }, 300); } function copyDogProfileLink() { var section = document.getElementById('dogProfileShareSection'); var url = section && section.dataset.profileUrl; if (!url) return; if (navigator.clipboard) { navigator.clipboard.writeText(url).then(function() { showDogProfileToast(); }); } else { var ta = document.createElement('textarea'); ta.value = url; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); showDogProfileToast(); } } function showDogProfileToast() { var t = document.getElementById('dogProfileCopyToast'); if (!t) return; t.classList.add('visible'); setTimeout(function() { t.classList.remove('visible'); }, 2500); } function toggleDogProfilePublic(isPublic) { if (!sessionId) return; fetch('/api/dog-profile/visibility', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, is_public: isPublic }) }).catch(function() {}); } // ── /d/:slug shareable profile ────────────────────────────────────────── var _dProfileUrl = ''; function loadDProfile() { if (!sessionId) return; fetch('/api/d/settings', { headers: { 'x-session-id': sessionId } }) .then(function(r) { return r.json(); }) .then(function(data) { var section = document.getElementById('dProfileSection'); if (!section) return; section.style.display = 'block'; var dogName = (profile && profile.dog_name) ? profile.dog_name : 'your dog'; var nameSpan = document.getElementById('dProfileDogName'); var labelSpan = document.getElementById('dProfileToggleLabel'); if (nameSpan) nameSpan.textContent = dogName; if (labelSpan) labelSpan.textContent = dogName + "'s"; var toggle = document.getElementById('dProfileToggle'); if (toggle) toggle.checked = !!data.enabled; var shareRow = document.getElementById('dProfileShareRow'); if (shareRow) shareRow.style.display = data.enabled ? 'flex' : 'none'; if (data.public_url) { _dProfileUrl = data.public_url; var viewBtn = document.getElementById('dProfileViewBtn'); if (viewBtn) viewBtn.href = data.public_url; var twitterBtn = document.getElementById('dProfileTwitterBtn'); if (twitterBtn) { var tweetText = encodeURIComponent('Check out ' + dogName + "'s training progress 🐾 " + data.public_url); twitterBtn.href = 'https://twitter.com/intent/tweet?text=' + tweetText; } } }) .catch(function() { // Non-fatal — just don't show the section if fetch fails }); } function toggleDProfile(enabled) { if (!sessionId) return; var endpoint = enabled ? '/api/d/enable' : '/api/d/disable'; fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-session-id': sessionId }, }) .then(function(r) { return r.json(); }) .then(function(data) { var shareRow = document.getElementById('dProfileShareRow'); if (shareRow) shareRow.style.display = enabled ? 'flex' : 'none'; if (enabled && data.public_url) { _dProfileUrl = data.public_url; var viewBtn = document.getElementById('dProfileViewBtn'); if (viewBtn) viewBtn.href = data.public_url; var twitterBtn = document.getElementById('dProfileTwitterBtn'); if (twitterBtn) { var dogName = (profile && profile.dog_name) ? profile.dog_name : 'your dog'; var tweetText = encodeURIComponent('Check out ' + dogName + "'s training progress 🐾 " + data.public_url); twitterBtn.href = 'https://twitter.com/intent/tweet?text=' + tweetText; } } }) .catch(function() {}); } function copyDProfileLink() { if (!_dProfileUrl) return; if (navigator.clipboard) { navigator.clipboard.writeText(_dProfileUrl).then(function() { showDProfileToast(); }); } else { var ta = document.createElement('textarea'); ta.value = _dProfileUrl; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); showDProfileToast(); } } function showDProfileToast() { var t = document.getElementById('dProfileCopyToast'); if (!t) return; t.classList.add('visible'); setTimeout(function() { t.classList.remove('visible'); }, 2500); } // ── end /d/:slug ───────────────────────────────────────────────────────── function populatePhotoPreview(photoDataUrl) { const previewEl = document.getElementById('photoPreview'); const emojiEl = document.getElementById('photoPreviewEmoji'); const removeBtn = document.getElementById('photoRemoveBtn'); if (!previewEl || !emojiEl) return; if (photoDataUrl) { emojiEl.style.display = 'none'; let img = previewEl.querySelector('img'); if (!img) { img = document.createElement('img'); img.alt = 'Dog photo'; previewEl.appendChild(img); } img.src = photoDataUrl; if (removeBtn) removeBtn.classList.add('visible'); document.querySelector('.photo-change-btn').textContent = '📷 Change photo'; } else { emojiEl.style.display = ''; const img = previewEl.querySelector('img'); if (img) img.remove(); if (removeBtn) removeBtn.classList.remove('visible'); document.querySelector('.photo-change-btn').textContent = '📷 Upload photo'; } } function closeSettings() { document.getElementById('settingsModal').classList.remove('visible'); } function switchSettingsTab(tab, btn) { document.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.settings-panel').forEach(p => p.classList.remove('active')); btn.classList.add('active'); document.getElementById(tab === 'profile' ? 'settingsProfile' : 'settingsPrefs').classList.add('active'); } async function saveProfileSettings() { const dogName = document.getElementById('sDogName').value.trim(); const breed = document.getElementById('sBreed').value.trim(); const age = document.getElementById('sAge').value; const coachStyle = document.querySelector('input[name="sCoach"]:checked')?.value || 'buddy'; if (!dogName || !breed || !age) { showToast('Please fill in all fields'); return; } try { const res = await fetch('/api/profile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, dog_name: dogName, breed: breed, age: age, coach_style: coachStyle, behavior_issues: profile.behavior_issues || [] }) }); const data = await res.json(); if (data.success) { profile = data.profile; const info = COACH_INFO[profile.coach_style] || COACH_INFO.buddy; document.getElementById('navCoachBadge').textContent = `${info.emoji} ${profile.dog_name}`; updateNavAvatar(); updateDashboard(); closeSettings(); showToast('Profile updated!'); } } catch (err) { showToast('Failed to save. Try again.'); } } async function savePreferences() { const timezone = document.getElementById('sTimezone').value; const sessionLength = document.getElementById('sSessionLength').value; try { const res = await fetch(`/api/preferences/${sessionId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ timezone: timezone, session_length_minutes: parseInt(sessionLength) }) }); const data = await res.json(); if (data.success) { profile = data.profile; closeSettings(); showToast('Preferences saved! Refreshing suggestions...'); refreshSuggestions(); } } catch (err) { showToast('Failed to save. Try again.'); } } function resetProfile() { // Show add-dog paywall for free users — they can still proceed but we pitch Pro if (!paywallStatus || !paywallStatus.isPro) { showAddDogPaywall(); return; } if (!confirm('Start over with a new dog? This will clear your chat history.')) return; localStorage.removeItem('fc_session_id'); window.location.reload(); } // Called from add-dog paywall modal "Maybe later" — allows reset anyway for free users function resetProfileForce() { closeAddDogPaywall(); if (!confirm('Start over with a new dog? This will clear your chat history.')) return; localStorage.removeItem('fc_session_id'); window.location.reload(); } // ============================================ // Dog Photo Upload // ============================================ // Returns the effective photo URL: prefers R2 photo_url, falls back to legacy dog_photo base64 function getDogPhotoUrl() { if (!profile) return null; return profile.photo_url || profile.dog_photo || null; } // Onboarding step0: user picks a photo before profile exists // File is stored in _onboardingPhotoFile and uploaded after profile save function handleOnboardingPhotoSelect(event) { const file = event.target.files[0]; if (!file) return; _onboardingPhotoFile = file; // Show instant preview in the onboarding photo circle const reader = new FileReader(); reader.onload = function(e) { const preview = document.getElementById('obPhotoPreview'); const emoji = document.getElementById('obPhotoEmoji'); if (!preview) return; if (emoji) emoji.style.display = 'none'; let img = preview.querySelector('img'); if (!img) { img = document.createElement('img'); img.style.cssText = 'width:100%;height:100%;object-fit:cover;border-radius:50%;display:block;'; preview.appendChild(img); } img.src = e.target.result; // Update hint text const dogName = (document.getElementById('dogName').value.trim()) || 'your dog'; const nameHint = document.getElementById('obPhotoHint'); if (nameHint) nameHint.textContent = `${dogName}'s photo saved — will appear in achievements`; }; reader.readAsDataURL(file); event.target.value = ''; } async function handlePhotoUpload(event) { const file = event.target.files[0]; if (!file) return; if (!file.type.startsWith('image/')) { showToast('Please select an image file'); event.target.value = ''; return; } // Show instant preview from client-side compressed canvas try { const compressed = await compressImage(file, 200); if (compressed) populatePhotoPreview(compressed); } catch (_) { /* preview-only, non-fatal */ } // Upload original file to server (sharp does proper resize/WebP conversion) await savePhotoFile(file); // Reset input so same file can be re-selected event.target.value = ''; } function compressImage(file, maxKB) { return new Promise((resolve) => { const reader = new FileReader(); reader.onload = function(e) { const img = new Image(); img.onload = function() { const canvas = document.createElement('canvas'); // Target max dimension ~800px for decent quality let w = img.width, h = img.height; const maxDim = 800; if (w > maxDim || h > maxDim) { if (w > h) { h = Math.round(h * maxDim / w); w = maxDim; } else { w = Math.round(w * maxDim / h); h = maxDim; } } canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, w, h); // Try quality 0.8 first, then 0.5 if still too big let dataUrl = canvas.toDataURL('image/jpeg', 0.8); if (dataUrl.length > maxKB * 1024 * 1.4) { dataUrl = canvas.toDataURL('image/jpeg', 0.5); } if (dataUrl.length > maxKB * 1024 * 1.4) { dataUrl = canvas.toDataURL('image/jpeg', 0.3); } // Final size check (~200KB in base64 ≈ ~150KB file) if (dataUrl.length > 300000) { resolve(null); // still too big } else { resolve(dataUrl); } }; img.src = e.target.result; }; reader.readAsDataURL(file); }); } // Upload original file to /api/dog/photo (server handles resize + R2 storage) async function savePhotoFile(file) { try { const formData = new FormData(); formData.append('session_id', sessionId); formData.append('photo', file); const res = await fetch('/api/dog/photo', { method: 'POST', body: formData }); const data = await res.json(); if (data.success) { profile = data.profile; // Update preview with final R2 URL (replaces instant compressed preview) populatePhotoPreview(data.photo_url); updateDashboard(); const dogName = (profile && profile.dog_name) || 'your dog'; showToast(`Now coach really knows ${dogName} 🐾`); // If we're in the win card modal and it's showing a photo prompt, hide it const wcPhotoPrompt = document.getElementById('wcPhotoPrompt'); if (wcPhotoPrompt) wcPhotoPrompt.style.display = 'none'; } else { showToast(data.message || 'Failed to save photo'); // Revert preview to existing photo populatePhotoPreview(getDogPhotoUrl()); } } catch (err) { console.error('Save photo error:', err); showToast('Failed to save photo. Try again.'); populatePhotoPreview(getDogPhotoUrl()); } } // Legacy savePhoto kept for backward compatibility (used if base64 path still needed) async function savePhoto(dataUrl) { // Redirects to the new file-based upload logic isn't possible here since we only have dataUrl. // Fall back to old JSON patch endpoint. try { const res = await fetch(`/api/profile/${sessionId}/photo`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dog_photo: dataUrl }) }); const data = await res.json(); if (data.success) { profile = data.profile; updateDashboard(); showToast('Photo saved! 🐾'); } else { showToast(data.message || 'Failed to save photo'); populatePhotoPreview(getDogPhotoUrl()); } } catch (err) { console.error('Save photo error:', err); showToast('Failed to save photo. Try again.'); populatePhotoPreview(getDogPhotoUrl()); } } async function removePhoto() { if (!confirm('Remove your dog\'s photo?')) return; try { const res = await fetch(`/api/profile/${sessionId}/photo`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dog_photo: null }) }); const data = await res.json(); if (data.success) { profile = data.profile; populatePhotoPreview(null); updateDashboard(); showToast('Photo removed'); } } catch (err) { showToast('Failed to remove photo. Try again.'); } } // Close modal on overlay click document.getElementById('settingsModal')?.addEventListener('click', function(e) { if (e.target === this) closeSettings(); }); // ============================================ // Post-subscribe referral banner (founding members only, shown once) function showPostSubscribeReferralBanner() { // Show the bottom banner — link already points to /refer var banner = document.getElementById('referralWelcomeBanner'); var refLink = document.getElementById('rwbReferLink'); if (refLink && sessionId) { refLink.href = '/refer?s=' + encodeURIComponent(sessionId); } if (banner) banner.style.display = 'block'; } // ============================================ // Paywall — free-tier gates, usage tracking, upgrade CTA // ============================================ // Cached paywall status (refreshed on showApp + on chat tab open) let paywallStatus = null; // Fetch paywall status from API and update all paywall surfaces. async function refreshPaywallStatus() { if (!sessionId) return; try { const res = await fetch('/api/paywall/status?sessionId=' + encodeURIComponent(sessionId)); if (!res.ok) return; paywallStatus = await res.json(); applyPaywallUI(); } catch (_) { /* non-fatal */ } } // Apply paywall state to all surfaces. function applyPaywallUI() { if (!paywallStatus) return; const isPro = paywallStatus.isPro; // 1. Nav Pro badge (show for Pro, hide founding chip/link for Pro) var proBadge = document.getElementById('navProBadge'); if (proBadge) proBadge.classList.toggle('visible', isPro); if (isPro) { var foundingChip = document.getElementById('navFoundingChip'); if (foundingChip) foundingChip.classList.remove('visible'); var foundingLink = document.getElementById('navFoundingLink'); if (foundingLink) foundingLink.classList.remove('visible'); } // 2. Settings: pro upgrade card vs founding badge var upgradeCard = document.getElementById('proUpgradeCard'); if (upgradeCard) upgradeCard.classList.toggle('visible', !isPro); // foundingBadge visibility handled by existing check-subscription call in openSettings() // 3. Reminder lock row var reminderLockRow = document.getElementById('reminderLockRow'); if (reminderLockRow) reminderLockRow.classList.toggle('visible', !isPro); // 4. Chat composer paywall updateChatPaywallUI(); } // Update chat usage banner and composer block state. function updateChatPaywallUI() { if (!paywallStatus || paywallStatus.isPro) { hideChatPaywall(); return; } var count = paywallStatus.weeklyMessageCount || 0; var cap = paywallStatus.weeklyMessageCap || 20; var blocked = paywallStatus.chatBlocked; var pct = Math.min(100, Math.round((count / cap) * 100)); var banner = document.getElementById('chatUsageBanner'); var bannerMsg = document.getElementById('chatUsageBannerMsg'); var blockedOverlay = document.getElementById('chatBlockedOverlay'); var inputWrapper = document.querySelector('.chat-input-wrapper'); var voiceModeRow = document.getElementById('voiceModeRow'); if (!banner || !blockedOverlay) return; if (blocked) { // Hard block: hide input, show overlay banner.classList.remove('visible'); blockedOverlay.classList.add('visible'); if (inputWrapper) inputWrapper.style.display = 'none'; if (voiceModeRow) voiceModeRow.style.display = 'none'; // Log upgrade_card_shown (once per session) if (!sessionStorage.getItem('pw_chat_blocked_logged')) { sessionStorage.setItem('pw_chat_blocked_logged', '1'); logPaywallEvent('upgrade_card_shown', 'chat'); } } else if (pct >= 80) { // 80% warning banner var remaining = cap - count; banner.classList.remove('blocked'); banner.classList.add('warn', 'visible'); var icon = document.getElementById('chatUsageBannerIcon'); if (icon) icon.textContent = '⚠️'; if (bannerMsg) bannerMsg.textContent = remaining + ' free message' + (remaining !== 1 ? 's' : '') + ' left this week. Upgrade for unlimited coaching.'; blockedOverlay.classList.remove('visible'); if (inputWrapper) inputWrapper.style.display = ''; if (voiceModeRow) voiceModeRow.style.display = ''; // Log upgrade_card_shown (once per session) if (!sessionStorage.getItem('pw_chat_warn_logged')) { sessionStorage.setItem('pw_chat_warn_logged', '1'); logPaywallEvent('upgrade_card_shown', 'chat'); } } else { hideChatPaywall(); } } function hideChatPaywall() { var banner = document.getElementById('chatUsageBanner'); var blockedOverlay = document.getElementById('chatBlockedOverlay'); var inputWrapper = document.querySelector('.chat-input-wrapper'); var voiceModeRow = document.getElementById('voiceModeRow'); if (banner) banner.classList.remove('visible', 'warn', 'blocked'); if (blockedOverlay) blockedOverlay.classList.remove('visible'); if (inputWrapper) inputWrapper.style.display = ''; if (voiceModeRow) voiceModeRow.style.display = ''; } // Called after each successful chat message to update local paywall counter. function updatePaywallAfterChat(weeklyRemaining, weeklyCap) { if (!paywallStatus || paywallStatus.isPro) return; if (typeof weeklyRemaining === 'number' && typeof weeklyCap === 'number') { paywallStatus.weeklyMessageCount = weeklyCap - weeklyRemaining; paywallStatus.weeklyMessageCap = weeklyCap; paywallStatus.chatBlocked = weeklyRemaining <= 0; var pct = Math.min(100, Math.round(((weeklyCap - weeklyRemaining) / weeklyCap) * 100)); paywallStatus.weeklyMessagePct = pct; updateChatPaywallUI(); } } // Master upgrade handler: POST to /api/upgrade-pro/create-session, redirect to Stripe. // Auto-applies Founding Pro price ($9.99/mo) for partner-referred users. async function paywallUpgrade(surface) { logPaywallEvent('upgrade_cta_clicked', surface); logPaywallEvent('checkout_started', surface); try { const res = await fetch('/api/upgrade-pro/create-session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId }), }); const data = await res.json(); if (data.url) { window.location.href = data.url; } else { window.location.href = '/upgrade-pro?session_id=' + encodeURIComponent(sessionId || ''); } } catch (_) { window.location.href = '/upgrade-pro?session_id=' + encodeURIComponent(sessionId || ''); } } // Fire-and-forget conversion event log. function logPaywallEvent(eventKey, surface) { fetch('/api/paywall/event', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: sessionId, eventKey: eventKey, surface: surface }), }).catch(function() {}); } // Show/hide add-dog paywall modal. function showAddDogPaywall() { var overlay = document.getElementById('addDogPaywallOverlay'); if (overlay) { overlay.classList.add('visible'); logPaywallEvent('upgrade_card_shown', 'add_dog'); } } function closeAddDogPaywall() { var overlay = document.getElementById('addDogPaywallOverlay'); if (overlay) overlay.classList.remove('visible'); } // Toast // ============================================ function showToast(msg) { const toast = document.getElementById('toast'); toast.textContent = msg; toast.classList.add('visible'); setTimeout(() => toast.classList.remove('visible'), 2500); } // ============================================ // Voice Input (Web Speech API) // ============================================ let recognition = null; let isListening = false; // ============================================ // Push-to-talk mic — MediaRecorder + Whisper STT // ============================================ let mediaRecorder = null; let recordingChunks = []; let recordingTimerInterval = null; let recordingSeconds = 0; const MAX_RECORDING_SECONDS = 60; // Always show mic button — MediaRecorder is supported in every modern browser (function initMic() { if (!navigator.mediaDevices || !window.MediaRecorder) return; const micBtn = document.getElementById('micBtn'); if (micBtn) micBtn.classList.add('supported'); // Show voice hint in the empty state const hint = document.getElementById('voiceHint'); if (hint) hint.style.display = 'block'; })(); function updateRecordingTimer() { recordingSeconds++; const preview = document.getElementById('micTranscript'); const mins = Math.floor(recordingSeconds / 60); const secs = recordingSeconds % 60; preview.textContent = '\uD83D\uDD34 ' + mins + ':' + secs.toString().padStart(2, '0'); preview.classList.add('visible'); if (recordingSeconds >= MAX_RECORDING_SECONDS) { stopListening(); } } async function toggleListening() { if (isListening) { stopListening(); return; } // Request mic permission let stream; try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch (err) { const preview = document.getElementById('micTranscript'); preview.textContent = 'Mic blocked \u2014 enable in browser settings'; preview.classList.add('visible'); setTimeout(() => preview.classList.remove('visible'), 4000); return; } // Start recording isListening = true; recordingChunks = []; recordingSeconds = 0; const micBtn = document.getElementById('micBtn'); micBtn.classList.add('listening'); document.getElementById('micIconIdle').style.display = 'none'; document.getElementById('micIconActive').style.display = 'block'; // Show 0:00 timer immediately const preview = document.getElementById('micTranscript'); preview.textContent = '\uD83D\uDD34 0:00'; preview.classList.add('visible'); recordingTimerInterval = setInterval(updateRecordingTimer, 1000); // Pick best supported audio format const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : MediaRecorder.isTypeSupported('audio/ogg;codecs=opus') ? 'audio/ogg;codecs=opus' : ''; mediaRecorder = new MediaRecorder(stream, mimeType ? { mimeType } : {}); mediaRecorder.ondataavailable = e => { if (e.data.size > 0) recordingChunks.push(e.data); }; mediaRecorder.onstop = async () => { // Release mic stream.getTracks().forEach(t => t.stop()); if (recordingChunks.length === 0) return; const blob = new Blob(recordingChunks, { type: mimeType || 'audio/webm' }); recordingChunks = []; // Show transcribing state const preview2 = document.getElementById('micTranscript'); preview2.textContent = '\u2728 Transcribing\u2026'; preview2.classList.add('visible'); try { const formData = new FormData(); formData.append('audio', blob, 'audio.webm'); if (typeof sessionId !== 'undefined' && sessionId) formData.append('session_id', sessionId); const res = await fetch('/api/voice/transcribe', { method: 'POST', body: formData }); const data = await res.json(); preview2.classList.remove('visible'); if (data.voiceBlocked) { // Daily cap hit — friendly toast, let user continue via text showToast(data.message || "You've been chatting a lot today \u2014 text is unlimited!"); } else if (data.success && data.transcript) { // Auto-send: set voice flag, pre-fill input, send immediately nextMsgIsVoice = true; const input = document.getElementById('chatInput'); input.value = data.transcript; autoResize(input); sendMessage(); } else { showToast(data.message || 'Could not understand audio \u2014 please try again'); } } catch (fetchErr) { document.getElementById('micTranscript').classList.remove('visible'); showToast('Transcription failed \u2014 check your connection'); } }; mediaRecorder.start(); } function stopListening() { isListening = false; clearInterval(recordingTimerInterval); recordingTimerInterval = null; const micBtn = document.getElementById('micBtn'); if (micBtn) micBtn.classList.remove('listening'); document.getElementById('micIconIdle').style.display = 'block'; document.getElementById('micIconActive').style.display = 'none'; if (mediaRecorder && mediaRecorder.state !== 'inactive') { mediaRecorder.stop(); } // micTranscript stays visible during transcribing — cleared in onstop handler above } // ============================================ // TTS (Text-to-Speech Coach Responses) // ============================================ let voiceEnabled = localStorage.getItem('fc_voice') !== 'off'; let currentAudio = null; let isPlayingTTS = false; // Per-message TTS audio cache: maps content hash → blob URL (avoids re-billing on re-tap) const ttsAudioCache = new Map(); let activeSpeakBtn = null; // currently-playing speaker button function updateVoiceBtn() { const btn = document.getElementById('voiceToggleBtn'); if (!btn) return; const iconOn = document.getElementById('voiceIconOn'); const iconOff = document.getElementById('voiceIconOff'); if (voiceEnabled) { btn.title = 'Mute voice'; btn.classList.remove('voice-muted'); if (iconOn) iconOn.style.display = 'block'; if (iconOff) iconOff.style.display = 'none'; } else { btn.title = 'Unmute voice'; btn.classList.add('voice-muted'); if (iconOn) iconOn.style.display = 'none'; if (iconOff) iconOff.style.display = 'block'; } // Keep in-chat voice mode pill in sync updateVoiceModePill(); } function updateVoiceModePill() { const pill = document.getElementById('voiceModePill'); const micBtn = document.getElementById('micBtn'); if (pill) pill.classList.toggle('on', voiceEnabled); // Make mic button visually prominent when voice mode is active if (micBtn) micBtn.classList.toggle('voice-mode-on', voiceEnabled); } // In-chat toggle — called by the pill button in the input area function chatToggleVoiceMode() { voiceEnabled = !voiceEnabled; localStorage.setItem('fc_voice', voiceEnabled ? 'on' : 'off'); if (!voiceEnabled) stopAudio(); updateVoiceBtn(); // updates nav button + pill showToast(voiceEnabled ? '🔊 Voice mode on — coach speaks replies' : '🔇 Voice mode off — text only'); } function toggleVoice() { voiceEnabled = !voiceEnabled; localStorage.setItem('fc_voice', voiceEnabled ? 'on' : 'off'); if (!voiceEnabled) stopAudio(); updateVoiceBtn(); showToast(voiceEnabled ? '🔊 Voice on' : '🔇 Voice muted'); } // "Try voice" first-visit tip — shown once per device after deploy v20260514 const VOICE_TIP_VERSION = 'v20260514'; function maybeShowVoiceTip() { if (localStorage.getItem('fc_voice_tip_shown') === VOICE_TIP_VERSION) return; // Only show if MediaRecorder is available (mic button will be visible) if (!navigator.mediaDevices || !window.MediaRecorder) return; const banner = document.getElementById('voiceTipBanner'); if (banner) banner.classList.add('visible'); } function dismissVoiceTip() { localStorage.setItem('fc_voice_tip_shown', VOICE_TIP_VERSION); const banner = document.getElementById('voiceTipBanner'); if (banner) banner.classList.remove('visible'); } function setPlayingState(playing) { isPlayingTTS = playing; const btn = document.getElementById('voiceToggleBtn'); if (!btn) return; if (playing) { btn.classList.add('voice-playing'); } else { btn.classList.remove('voice-playing'); } } function stopAudio() { if (currentAudio) { currentAudio.pause(); currentAudio.src = ''; currentAudio = null; } if (window.speechSynthesis) window.speechSynthesis.cancel(); setPlayingState(false); // Clear any active speaker button state if (typeof activeSpeakBtn !== 'undefined' && activeSpeakBtn) { activeSpeakBtn.classList.remove('playing'); activeSpeakBtn = null; } } function showVoiceBanner() { // Remove existing banner if any const existing = document.getElementById('voiceUnavailableBanner'); if (existing) existing.remove(); const banner = document.createElement('div'); banner.id = 'voiceUnavailableBanner'; banner.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:9999;background:#f59e0b;color:#78350f;text-align:center;padding:10px 16px;font-size:14px;font-weight:500;box-shadow:0 2px 8px rgba(0,0,0,0.15);'; banner.textContent = '\uD83D\uDD07 Voice temporarily unavailable \u2014 reading mode'; document.body.appendChild(banner); setTimeout(() => { if (banner.parentNode) banner.remove(); }, 10000); } async function speakResponse(text, coachStyle) { if (!voiceEnabled || !text) return; stopAudio(); setPlayingState(true); try { const res = await fetch('/api/tts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, coach_style: coachStyle, session_id: sessionId }) }); if (res.status === 429) { console.warn('TTS quota exceeded, falling back to browser synthesis'); showVoiceBanner(); setPlayingState(false); speakWithSynthesis(text); return; } if (!res.ok) throw new Error('TTS API failed'); const blob = await res.blob(); const url = URL.createObjectURL(blob); currentAudio = new Audio(url); currentAudio.onended = () => { setPlayingState(false); URL.revokeObjectURL(url); currentAudio = null; }; currentAudio.onerror = () => { setPlayingState(false); URL.revokeObjectURL(url); currentAudio = null; speakWithSynthesis(text); // browser fallback }; await currentAudio.play(); } catch (err) { console.warn('TTS API unavailable, using browser synthesis:', err.message); setPlayingState(false); speakWithSynthesis(text); // browser fallback } } function speakWithSynthesis(text) { if (!window.speechSynthesis || !voiceEnabled) return; window.speechSynthesis.cancel(); const clean = text .replace(/\*\*(.*?)\*\*/g, '$1') .replace(/\*(.*?)\*/g, '$1') .replace(/^[\d]+\.\s+/gm, '') .replace(/^[-•]\s+/gm, '') .replace(/\n+/g, ' ') .trim() .substring(0, 300); const utter = new SpeechSynthesisUtterance(clean); utter.rate = 0.95; utter.pitch = 1; setPlayingState(true); utter.onend = () => setPlayingState(false); utter.onerror = () => setPlayingState(false); window.speechSynthesis.speak(utter); } // ============================================ // Call Coach — HTTP-based (SpeechRecognition + chat API + TTS) // Works on all browsers incl. Safari iOS. No WebSocket required. // ============================================ let callActive = false; let callTimerInterval = null; let callStartTime = null; let callTranscriptLines = []; let callSpeechRec = null; // SpeechRecognition instance let callIsProcessing = false; // true while waiting for server response let callCurrentAudio = null; // currently playing AudioBufferSourceNode let callAudioCtx = null; // AudioContext — unlocked during user gesture in startCall() const COACH_EMOJIS = { drill_sergeant: '🎖️', sage: '🧘', best_in_show: '🏆', buddy: '🤙' }; const COACH_NAMES_CALL = { drill_sergeant: 'Sarge', sage: 'The Sage', best_in_show: 'Best-in-Show', buddy: 'The Buddy' }; function setCallStatus(text, type) { const el = document.getElementById('callStatus'); if (type === 'connecting') { el.innerHTML = `${text}`; } else { el.textContent = text; } } function setCallVisualizer(mode) { // mode: 'idle' | 'ai-speaking' | 'user-speaking' const v = document.getElementById('callVisualizer'); v.className = 'call-visualizer' + (mode !== 'idle' ? ' ' + mode : ''); const avatar = document.getElementById('callAvatar'); if (mode === 'ai-speaking') avatar.classList.add('speaking'); else avatar.classList.remove('speaking'); } function addCallTranscript(text, isUser) { callTranscriptLines.push((isUser ? 'You: ' : 'Coach: ') + text); if (callTranscriptLines.length > 3) callTranscriptLines.shift(); document.getElementById('callTranscript').textContent = callTranscriptLines.join('\n'); } function updateCallTimer() { if (!callStartTime) return; const elapsed = Math.floor((Date.now() - callStartTime) / 1000); const m = Math.floor(elapsed / 60); const s = String(elapsed % 60).padStart(2, '0'); document.getElementById('callTimer').textContent = `${m}:${s}`; } function startCallTimer() { callStartTime = Date.now(); clearInterval(callTimerInterval); callTimerInterval = setInterval(updateCallTimer, 1000); } function startListening() { if (!callActive || callIsProcessing || !callSpeechRec) return; try { callSpeechRec.start(); setCallStatus('Listening…'); setCallVisualizer('user-speaking'); document.getElementById('callMicDot').className = 'call-mic-dot active'; document.getElementById('callMicLabel').textContent = 'Mic active'; } catch(e) { // Already running — ignore } } async function processTurn(transcript) { if (!callActive) return; callIsProcessing = true; // Pause listening while coach responds try { callSpeechRec.stop(); } catch(e) {} setCallStatus('Coach is thinking…'); setCallVisualizer('idle'); document.getElementById('callMicDot').className = 'call-mic-dot'; document.getElementById('callMicLabel').textContent = 'Mic paused'; addCallTranscript(transcript, true); try { const callPayload = { session_id: sessionId, transcript }; if (briefFocusedSkill) callPayload.focused_skill = briefFocusedSkill; const resp = await fetch('/api/call-turn', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(callPayload) }); if (resp.status === 429) { // Voice quota exceeded — end call gracefully with coach text if available let coachText = ''; try { const data = await resp.json(); coachText = data.coachText || ''; } catch(e) {} if (coachText) addCallTranscript(coachText, false); callIsProcessing = false; endCall(); showToast('Voice session limit reached. You can continue in text chat.'); return; } if (!resp.ok) throw new Error(`Server error ${resp.status}`); // Check voice usage headers from server if (resp.headers.get('X-Voice-Blocked') === '1') { callIsProcessing = false; endCall(); showToast("You've used all your monthly voice minutes — continue in text chat!"); return; } if (resp.headers.get('X-Voice-Warning') === '1') { showToast("⚠️ You've used about 80% of your monthly voice minutes."); } // Read coach text from header for transcript display const b64Text = resp.headers.get('X-Coach-Text'); if (b64Text) { try { const coachText = atob(b64Text); addCallTranscript(coachText, false); } catch(e) { /* ignore */ } } if (!callActive) { callIsProcessing = false; return; } setCallStatus('Coach is speaking…'); setCallVisualizer('ai-speaking'); // Stop any current audio if (callCurrentAudio) { try { callCurrentAudio.stop(); } catch(ex) {} callCurrentAudio = null; } // Use Web Audio API — AudioContext was unlocked in startCall() user gesture // This bypasses iOS Safari autoplay restrictions const arrayBuffer = await resp.arrayBuffer(); if (!callAudioCtx || callAudioCtx.state === 'closed') { callAudioCtx = new (window.AudioContext || window.webkitAudioContext)(); } // Ensure context is running (might be suspended on iOS) if (callAudioCtx.state === 'suspended') { await callAudioCtx.resume(); } const audioBuffer = await callAudioCtx.decodeAudioData(arrayBuffer); const source = callAudioCtx.createBufferSource(); source.buffer = audioBuffer; source.connect(callAudioCtx.destination); callCurrentAudio = source; source.onended = () => { callCurrentAudio = null; callIsProcessing = false; if (callActive) startListening(); }; source.start(0); } catch(e) { console.error('[Call] Turn error:', e); callIsProcessing = false; if (callActive) { showToast('Coach had trouble responding — try again'); startListening(); } } } async function startCall() { if (callActive) return; if (!profile) { showToast('Set up your dog profile first'); return; } // Track voice open count and fire paywall trigger on 2nd use var voiceCount = parseInt(localStorage.getItem('fc_voice_open_count') || '0', 10) + 1; localStorage.setItem('fc_voice_open_count', String(voiceCount)); if (voiceCount === 2) { // Slight delay so the call overlay renders first setTimeout(function() { triggerPaywall('voice_2'); }, 2000); } // Check speech recognition support (Chrome + Safari) const SpeechRec = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SpeechRec) { showToast('Voice calls need Chrome or Safari'); return; } // Check voice minute limit before starting if (sessionId) { try { const vsResp = await fetch(`/api/voice-status/${sessionId}`); if (vsResp.ok) { const vsData = await vsResp.json(); if (vsData.blocked) { showToast(`Voice minutes used up for this month — continue in text chat!`); return; } if (vsData.warning) { showToast(`⚠️ You've used about 80% of your monthly voice minutes.`); } } } catch(e) { /* non-blocking — proceed with call */ } } callActive = true; callIsProcessing = false; callTranscriptLines = []; // Create and unlock AudioContext during user gesture — critical for iOS Safari // Once unlocked here, all subsequent playback through this context works without gesture if (!callAudioCtx || callAudioCtx.state === 'closed') { callAudioCtx = new (window.AudioContext || window.webkitAudioContext)(); } if (callAudioCtx.state === 'suspended') { callAudioCtx.resume(); } // Play a tiny silent buffer to fully unlock audio output on Safari try { const silentBuf = callAudioCtx.createBuffer(1, 1, 22050); const silentSrc = callAudioCtx.createBufferSource(); silentSrc.buffer = silentBuf; silentSrc.connect(callAudioCtx.destination); silentSrc.start(0); } catch(e) { /* non-critical */ } // Show overlay — use dog photo as call avatar if available, else coach emoji const _callAvatarEl = document.getElementById('callAvatar'); const _callPhotoUrl = getDogPhotoUrl(); if (_callPhotoUrl) { _callAvatarEl.textContent = ''; _callAvatarEl.style.padding = '0'; _callAvatarEl.style.overflow = 'hidden'; let _callImg = _callAvatarEl.querySelector('img'); if (!_callImg) { _callImg = document.createElement('img'); _callImg.style.cssText = 'width:100%;height:100%;object-fit:cover;border-radius:50%;display:block;'; _callAvatarEl.appendChild(_callImg); } _callImg.src = _callPhotoUrl; _callImg.alt = profile.dog_name || 'Dog'; } else { const _callImgOld = _callAvatarEl.querySelector('img'); if (_callImgOld) _callImgOld.remove(); _callAvatarEl.textContent = COACH_EMOJIS[profile.coach_style] || '🐕'; } document.getElementById('callCoachName').textContent = COACH_NAMES_CALL[profile.coach_style] || 'Your Coach'; document.getElementById('callDogLabel').textContent = `Training ${profile.dog_name}`; document.getElementById('callTimer').textContent = '0:00'; document.getElementById('callTranscript').textContent = '👂 Listening for you…'; document.getElementById('callMicDot').className = 'call-mic-dot'; document.getElementById('callMicLabel').textContent = 'Mic active'; setCallStatus('Ready — just start talking!'); setCallVisualizer('idle'); document.getElementById('callOverlay').classList.add('active'); stopAudio(); // pause existing TTS startCallTimer(); // Init speech recognition callSpeechRec = new SpeechRec(); callSpeechRec.continuous = false; callSpeechRec.interimResults = false; callSpeechRec.lang = 'en-US'; callSpeechRec.onresult = (e) => { const transcript = Array.from(e.results) .map(r => r[0].transcript) .join(' ') .trim(); if (transcript && callActive && !callIsProcessing) { processTurn(transcript); } }; callSpeechRec.onerror = (e) => { if (!callActive) return; if (e.error === 'no-speech') { // Timeout with no speech — restart setTimeout(() => { if (callActive && !callIsProcessing) startListening(); }, 300); } else if (e.error === 'aborted') { // Intentional stop — ignore } else { console.error('[Call] Speech error:', e.error); setTimeout(() => { if (callActive && !callIsProcessing) startListening(); }, 500); } }; callSpeechRec.onend = () => { // Auto-restart unless actively processing or call ended if (callActive && !callIsProcessing) { setTimeout(() => { if (callActive && !callIsProcessing) startListening(); }, 200); } }; // Start listening startListening(); } function endCall() { if (!callActive) return; callActive = false; callIsProcessing = false; clearInterval(callTimerInterval); callTimerInterval = null; callStartTime = null; // Stop speech recognition if (callSpeechRec) { callSpeechRec.onresult = null; callSpeechRec.onerror = null; callSpeechRec.onend = null; try { callSpeechRec.stop(); } catch(e) {} callSpeechRec = null; } // Stop any playing audio if (callCurrentAudio) { try { callCurrentAudio.stop(); } catch(e) {} callCurrentAudio = null; } // Suspend AudioContext to free resources (don't close — can reuse) if (callAudioCtx && callAudioCtx.state === 'running') { callAudioCtx.suspend().catch(() => {}); } // Hide overlay document.getElementById('callOverlay').classList.remove('active'); showToast('Call ended'); // Value-moment: fire once-ever upgrade prompt after first voice session completion if (!userIsSubscribed && !localStorage.getItem('fc_upgrade_voice_shown')) { localStorage.setItem('fc_upgrade_voice_shown', '1'); recordUpgradePrompt('first_voice'); window._upgradeRef = 'first_voice'; setTimeout(function() { triggerPaywall('first_voice'); }, 1200); } // Activation checklist: mark voice_session step complete acMarkStepComplete('voice_session', 'voice_end'); // Check for AI-extracted reps from this voice session. // Polls the pending extraction endpoint ~8s after call ends to give the server time. if (typeof window._checkVoiceRecap === 'function') { var sid = localStorage.getItem('fc_session_id') || null; window._checkVoiceRecap(sid); } } // ============================================ // Deep Link Call Launcher (/app/call) // ============================================ function showDeeplinkLauncher() { if (!profile) return; const info = COACH_INFO[profile.coach_style] || COACH_INFO.buddy; document.getElementById('deeplinkAvatar').textContent = info.emoji; document.getElementById('deeplinkCoachName').textContent = info.name; document.getElementById('deeplinkDogLabel').textContent = `Training ${profile.dog_name}`; document.getElementById('deeplinkOverlay').classList.add('active'); } function startDeeplinkCall() { document.getElementById('deeplinkOverlay').classList.remove('active'); startCall(); } // ============================================ // Quick Access helpers // ============================================ function copyCallLink() { const link = 'https://fetchcoach.app/app/call'; navigator.clipboard.writeText(link).then(() => { showToast('Link copied!'); }).catch(() => { const ta = document.createElement('textarea'); ta.value = link; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); showToast('Link copied!'); } catch(e) {} document.body.removeChild(ta); }); } // ============================================ // Dog ID System // ============================================ function copyRevealDogId() { const code = document.getElementById('revealDogIdCode').textContent; if (!code || code === '—') return; navigator.clipboard.writeText(code).catch(() => { // Fallback for older browsers const ta = document.createElement('textarea'); ta.value = code; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); }); const btn = document.getElementById('revealCopyBtn'); btn.innerHTML = ' Copied!'; btn.classList.add('copied'); setTimeout(() => { btn.innerHTML = '📋 Copy'; btn.classList.remove('copied'); }, 2500); } function closeDogIdReveal() { document.getElementById('dogIdRevealOverlay').classList.remove('visible'); // Show post-onboarding celebration screen if user is not already subscribed if (!userIsSubscribed) { showCelebrationScreen(); } else { showApp(); } } // ============================================================ // Post-Onboarding Celebration Screen // ============================================================ function showCelebrationScreen() { // Populate coach name if (profile) { var dogName = profile.dog_name || 'your dog'; document.getElementById('fccDogName').textContent = dogName; var info = COACH_INFO[profile.coach_style] || COACH_INFO.buddy; document.getElementById('fccCoachName').textContent = info.emoji + ' ' + info.name; } // Show overlay document.getElementById('fcCelebrationOverlay').classList.add('visible'); // Track that this prompt was shown trackEvent('upgrade_prompt_shown', { source: 'post_onboarding' }); recordUpgradePrompt('post_onboarding'); // Fetch first skill from coaching brief (non-blocking) if (sessionId) { fetch('/api/coaching-brief/' + encodeURIComponent(sessionId)) .then(function(r) { return r.json(); }) .then(function(data) { if (data && data.brief && data.brief.skill_name) { document.getElementById('fccSkillName').textContent = data.brief.skill_name; } else { document.getElementById('fccSkillName').textContent = 'personalized to your dog'; } }) .catch(function() { document.getElementById('fccSkillName').textContent = 'personalized to your dog'; }); } else { document.getElementById('fccSkillName').textContent = 'personalized to your dog'; } // Fetch founding spots count fetch('/api/founding-members/remaining') .then(function(r) { return r.json(); }) .then(function(data) { var spotsEl = document.getElementById('fccSpotsNum'); if (spotsEl) spotsEl.textContent = data.remaining != null ? data.remaining : '—'; // If sold out, hide the CTA card if (data.remaining <= 0) { var card = document.querySelector('.fcc-founding-card'); if (card) card.style.display = 'none'; } }) .catch(function() {}); } function closeCelebration() { var el = document.getElementById('fcCelebrationOverlay'); if (el) el.classList.remove('visible'); trackEvent('upgrade_prompt_dismissed', { source: 'post_onboarding' }); showApp(); } function startCelebrationCheckout() { trackEvent('upgrade_prompt_clicked', { source: 'post_onboarding' }); var btn = document.getElementById('fccCtaBtn'); if (btn) { btn.disabled = true; btn.textContent = 'Loading…'; } window._upgradeRef = 'post_onboarding'; startCheckout(); } function openRestoreModal() { const input = document.getElementById('dogIdRestoreInput'); if (input) { input.value = ''; input.focus(); } const err = document.getElementById('dogIdRestoreError'); if (err) err.classList.remove('visible'); const btn = document.getElementById('dogIdRestoreBtn'); if (btn) { btn.textContent = 'Restore Profile'; btn.disabled = false; } document.getElementById('dogIdRestoreOverlay').classList.add('visible'); setTimeout(() => { const inp = document.getElementById('dogIdRestoreInput'); if (inp) inp.focus(); }, 150); } function closeRestoreModal() { document.getElementById('dogIdRestoreOverlay').classList.remove('visible'); } async function restoreByDogId() { const input = document.getElementById('dogIdRestoreInput'); const code = (input?.value || '').trim().toUpperCase(); if (!code || code.length < 4) { showRestoreError('Please enter your Dog ID code.'); return; } const btn = document.getElementById('dogIdRestoreBtn'); btn.textContent = 'Restoring…'; btn.disabled = true; document.getElementById('dogIdRestoreError').classList.remove('visible'); try { const res = await fetch(`/api/restore/${encodeURIComponent(code)}`); const data = await res.json(); if (data.success && data.profile) { // Adopt the session_id from the restored profile sessionId = data.profile.session_id; localStorage.setItem('fc_session_id', sessionId); profile = data.profile; closeRestoreModal(); // Reset chat state so history is reloaded chatLoaded = false; scheduleData = null; showApp(); showToast('✅ Profile restored! Welcome back.'); } else { showRestoreError(data.message || 'Dog ID not found. Double-check and try again.'); btn.textContent = 'Restore Profile'; btn.disabled = false; } } catch (err) { showRestoreError('Connection error. Try again.'); btn.textContent = 'Restore Profile'; btn.disabled = false; } } function showRestoreError(msg) { const err = document.getElementById('dogIdRestoreError'); if (err) { err.textContent = msg; err.classList.add('visible'); } } function copySettingsDogId() { const code = document.getElementById('settingsDogId')?.textContent; if (!code || code === '—' || code === 'Not generated yet') return; navigator.clipboard.writeText(code).catch(() => { const ta = document.createElement('textarea'); ta.value = code; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); }); const btn = document.getElementById('settingsCopyBtn'); if (btn) { btn.innerHTML = ' Copied!'; btn.classList.add('copied'); setTimeout(() => { btn.innerHTML = '📋 Copy'; btn.classList.remove('copied'); }, 2500); } } // Close restore modal on overlay click document.getElementById('dogIdRestoreOverlay')?.addEventListener('click', function(e) { if (e.target === this) closeRestoreModal(); }); // Close reveal modal on overlay click (don't auto-proceed to app — use button) document.getElementById('dogIdRevealOverlay')?.addEventListener('click', function(e) { if (e.target === this) closeDogIdReveal(); }); // Close skill detail on overlay click document.getElementById('skillDetailOverlay')?.addEventListener('click', function(e) { if (e.target === this) closeSkillDetail(); }); // Close video player on overlay click or Escape document.getElementById('skillVideoPlayerOverlay')?.addEventListener('click', function(e) { if (e.target === this) closeVideoPlayer(); }); document.addEventListener('keydown', function(e) { if (e.key === 'Escape') { if (document.getElementById('skillVideoPlayerOverlay')?.classList.contains('visible')) { closeVideoPlayer(); } } }); // ============================================ // Skills & Progress // ============================================ // State for log-session modal var _logSessionDogSkillId = null; var _logSessionSkillName = null; var _logSessionRating = null; function loadSkillsProgress() { if (!sessionId) return; var card = document.getElementById('skillsProgressCard'); if (!card) return; card.style.display = 'block'; // Reset to loading skeleton var body = document.getElementById('skillsProgressBody'); body.innerHTML = '
    '; fetch('/api/dog-skills/' + encodeURIComponent(sessionId)) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success) { card.style.display = 'none'; return; } renderSkillsProgress(data); }) .catch(function() { card.style.display = 'none'; }); } function renderSkillsProgress(data) { var body = document.getElementById('skillsProgressBody'); var html = ''; // ── In Progress ─────────────────────────────────── var active = data.in_progress || []; if (active.length > 0) { html += '
    In Progress
    '; active.forEach(function(skill) { var stageNum = skill.current_stage || 1; var total = skill.total_stages || 3; var stageName = skill.current_stage_name || ('Stage ' + stageNum); var stageDesc = skill.current_stage_desc || skill.description || ''; var sessionsCount = skill.sessions_count || 0; var daysLabel = sessionsCount === 0 ? 'No sessions yet' : (sessionsCount + ' session' + (sessionsCount === 1 ? '' : 's')); // Stage dots var dotsHtml = ''; for (var i = 1; i <= total; i++) { var cls = i < stageNum ? 'done' : i === stageNum ? 'active' : ''; dotsHtml += '
    '; } html += '
    ' + '
    ' + '
    ' + escHtml(skill.name) + '
    ' + 'Stage ' + stageNum + ': ' + escHtml(stageName) + '' + '
    ' + '
    ' + escHtml(stageDesc) + '
    ' + '
    ' + '
    ' + dotsHtml + '
    ' + '' + escHtml(daysLabel) + '' + '
    ' + '' + (stageNum < total ? '' : '' ) + '
    '; }); } else { html += '
    In Progress
    '; html += '
    No active skills yet — activate one below.
    '; } // ── Mastered ─────────────────────────────────────── var mastered = data.mastered || []; if (mastered.length > 0) { html += '
    '; html += ''; html += '
    '; mastered.forEach(function(skill) { var dateStr = skill.mastered_at ? new Date(skill.mastered_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : ''; html += '
    ' + '' + escHtml(skill.name) + '' + '' + escHtml(dateStr) + '' + '
    '; }); html += '
    '; } // ── Up Next ──────────────────────────────────────── var available = data.available || []; // Cap to 2 recommendations; prefer beginner category var upNext = available.filter(function(s) { return s.category === 'beginner'; }).slice(0, 2); if (upNext.length < 2) { available.forEach(function(s) { if (upNext.length < 2 && !upNext.find(function(u) { return u.catalog_id === s.catalog_id; })) { upNext.push(s); } }); } var canActivate = active.length < 3; if (upNext.length > 0) { html += '
    '; html += '
    Up Next
    '; html += '
    '; upNext.forEach(function(skill) { var disabledAttr = canActivate ? '' : ' disabled title="Finish an active skill first"'; html += '
    ' + '
    ' + '
    ' + escHtml(skill.name) + '
    ' + '
    ' + escHtml(skill.description || '') + '
    ' + '
    ' + '' + '
    '; }); html += '
    '; } document.getElementById('skillsProgressBody').innerHTML = html; } function escHtml(str) { if (!str) return ''; return String(str).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } function toggleMasteredList(btn) { var list = document.getElementById('masteredList'); var chevron = document.getElementById('masteredChevron'); if (!list) return; var open = list.classList.toggle('open'); if (chevron) chevron.classList.toggle('open', open); } function openLogSessionModal(dogSkillId, skillName) { _logSessionDogSkillId = dogSkillId; _logSessionSkillName = skillName; _logSessionRating = null; document.getElementById('logSheetTitle').textContent = skillName + ' — how did it go?'; document.getElementById('logNoteInput').value = ''; document.getElementById('logSubmitBtn').disabled = true; // Reset selection state on rating buttons document.querySelectorAll('.log-rating-btn').forEach(function(b) { b.classList.remove('selected'); }); document.getElementById('logSessionModal').classList.add('open'); document.body.style.overflow = 'hidden'; } function closeLogSessionModal() { document.getElementById('logSessionModal').classList.remove('open'); document.body.style.overflow = ''; _logSessionDogSkillId = null; _logSessionRating = null; } function selectLogRating(rating, btn) { _logSessionRating = rating; document.querySelectorAll('.log-rating-btn').forEach(function(b) { b.classList.remove('selected'); }); btn.classList.add('selected'); document.getElementById('logSubmitBtn').disabled = false; } function submitLogSession() { if (!_logSessionDogSkillId || !_logSessionRating) return; var btn = document.getElementById('logSubmitBtn'); btn.disabled = true; btn.textContent = 'Saving…'; var note = document.getElementById('logNoteInput').value.trim(); fetch('/api/dog-skills/' + encodeURIComponent(sessionId) + '/' + _logSessionDogSkillId + '/log-session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rating: _logSessionRating, note: note || null }) }) .then(function(r) { return r.json(); }) .then(function(data) { closeLogSessionModal(); if (data.success) { showToast(data.message || 'Session logged ✓'); loadSkillsProgress(); // Refresh the card } else { showToast(data.message || 'Could not save session'); } }) .catch(function() { closeLogSessionModal(); showToast('Could not save session'); }); } function activateSkill(catalogId, btn) { if (btn) btn.disabled = true; fetch('/api/dog-skills/' + encodeURIComponent(sessionId) + '/activate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ catalog_id: catalogId }) }) .then(function(r) { return r.json(); }) .then(function(data) { if (data.success) { showToast('Skill activated! Start practicing today. 🐾'); loadSkillsProgress(); } else { showToast(data.message || 'Could not activate skill'); if (btn) btn.disabled = false; } }) .catch(function() { showToast('Could not activate skill'); if (btn) btn.disabled = false; }); } function advanceSkillStage(dogSkillId) { fetch('/api/dog-skills/' + encodeURIComponent(sessionId) + '/' + dogSkillId + '/advance', { method: 'POST', headers: { 'Content-Type': 'application/json' } }) .then(function(r) { return r.json(); }) .then(function(data) { if (data.success) { showToast(data.message || 'Stage advanced!'); loadSkillsProgress(); } else { showToast(data.message || 'Could not advance stage'); } }) .catch(function() { showToast('Could not advance stage'); }); } // Close log modal on overlay tap document.getElementById('logSessionModal').addEventListener('click', function(e) { if (e.target === this) closeLogSessionModal(); }); // ============================================ // Push Notification Opt-in Card // ============================================ function checkPushPromptCard() { // Don't show if already granted or not supported if (!('Notification' in window)) return; if (Notification.permission === 'granted') return; // Only show after first chat OR first rep logged OR onboarding step 3 completed if (!localStorage.getItem('fc_first_chat_sent') && !localStorage.getItem('fc_first_coaching_win') && !localStorage.getItem('fc_onboarding_done')) return; // Respect 7-day dismiss const dismissedAt = parseInt(localStorage.getItem('fc_push_prompt_dismissed_at') || '0', 10); if (dismissedAt && Date.now() - dismissedAt < 7 * 24 * 60 * 60 * 1000) return; const card = document.getElementById('pushPromptCard'); if (!card) return; // Personalize dog name const dogNameEl = document.getElementById('pushPromptDogName'); if (dogNameEl && profile && profile.dog_name) dogNameEl.textContent = profile.dog_name; // iOS Safari without standalone: show Add to Home Screen instruction const isStandalone = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone; if (isIosSafari && !isStandalone) { const iosMsg = document.getElementById('pushPromptIosMsg'); if (iosMsg) iosMsg.style.display = ''; const enableBtn = document.getElementById('pushEnableBtn'); if (enableBtn) enableBtn.style.display = 'none'; } card.style.display = ''; trackEvent('push_opt_in_shown', {}); } async function enablePushFromPrompt() { if (!('Notification' in window)) return; const btn = document.getElementById('pushEnableBtn'); if (btn) { btn.textContent = 'Enabling…'; btn.disabled = true; btn.style.opacity = '0.7'; } try { const permission = await Notification.requestPermission(); const card = document.getElementById('pushPromptCard'); if (card) card.style.display = 'none'; localStorage.setItem('fc_push_prompt_dismissed_at', String(Date.now())); if (permission === 'granted') { showToast('🔔 Reminders enabled!'); trackEvent('push_opt_in_accepted', {}); try { const reg = await navigator.serviceWorker.ready; await subscribeToServerPush(reg); } catch (e) { /* non-fatal */ } startScheduleNotifChecker(); } else { trackEvent('push_opt_in_denied', {}); if (btn) { btn.textContent = 'Enable reminders'; btn.disabled = false; btn.style.opacity = ''; } } } catch (e) { if (btn) { btn.textContent = 'Enable reminders'; btn.disabled = false; btn.style.opacity = ''; } } } function dismissPushPromptCard() { const card = document.getElementById('pushPromptCard'); if (card) card.style.display = 'none'; localStorage.setItem('fc_push_prompt_dismissed_at', String(Date.now())); trackEvent('push_opt_in_dismissed', {}); } // ============================================ // Feedback Widget // ============================================ // Detect trainer mode: ?mode=trainer URL param OR profile.is_trainer_reviewer flag function isTrainerMode() { var urlMode = new URLSearchParams(window.location.search).get('mode'); if (urlMode === 'trainer') return true; return !!(profile && profile.is_trainer_reviewer); } // Initialise the feedback strip and coach button after login function initFeedbackWidget() { var strip = document.getElementById('feedbackStrip'); var coachBtn = document.getElementById('coachNoteBtn'); if (strip) strip.classList.add('visible'); if (coachBtn) coachBtn.classList.add('visible'); // Trainer mode: update label and textarea placeholder if (isTrainerMode()) { var lbl = document.getElementById('fbLabel'); if (lbl) { lbl.textContent = 'Trainer review:'; lbl.classList.add('trainer-mode'); } var ta = document.getElementById('fbNoteText'); if (ta) ta.placeholder = 'What would you change? What\u2019s missing? What\u2019s overwhelming?'; var expandLbl = document.getElementById('fbExpandLabel'); if (expandLbl) expandLbl.textContent = 'Your trainer notes:'; var cnTa = document.getElementById('cnNoteText'); if (cnTa) cnTa.placeholder = 'What would you change? What\u2019s missing? What\u2019s overwhelming for an owner?'; } } var _fbCurrentId = null; // feedback row id from the vote call var _fbVoteValue = null; // 'thumbs_up' | 'thumbs_down' function fbVote(vote) { if (_fbVoteValue === vote) return; // already voted this value _fbVoteValue = vote; _fbCurrentId = null; var upBtn = document.getElementById('fbThumbUp'); var downBtn = document.getElementById('fbThumbDown'); var expand = document.getElementById('fbExpand'); if (upBtn) upBtn.className = 'fb-btn' + (vote === 'thumbs_up' ? ' active-up' : ''); if (downBtn) downBtn.className = 'fb-btn' + (vote === 'thumbs_down' ? ' active-down' : ''); // Reset expand area if (expand) { expand.classList.remove('visible'); var thanks = document.getElementById('fbThanks'); var submitBtn = document.getElementById('fbNoteSubmit'); if (thanks) thanks.style.display = 'none'; if (submitBtn) submitBtn.style.display = ''; } // Fire the vote to the server (non-blocking) var pagePath = window.location.pathname; var dogId = profile && profile.dog_id ? profile.dog_id : null; var dogName = profile && profile.dog_name ? profile.dog_name : null; var breed = profile && profile.breed ? profile.breed : null; fetch('/api/feedback/vote', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ page_path: pagePath, session_id: sessionId, dog_id: dogId, vote: vote, user_agent: navigator.userAgent, is_trainer_reviewer: isTrainerMode(), }) }) .then(function(r) { return r.json(); }) .then(function(data) { if (data && data.id) _fbCurrentId = data.id; }) .catch(function() {}); // Thumbs down: expand free-text area if (vote === 'thumbs_down' && expand) { expand.classList.add('visible'); var ta = document.getElementById('fbNoteText'); if (ta) setTimeout(function() { ta.focus(); }, 100); } } function fbSubmitNote() { var ta = document.getElementById('fbNoteText'); var btn = document.getElementById('fbNoteSubmit'); var thx = document.getElementById('fbThanks'); var note = ta ? ta.value.trim() : ''; if (!note) return; if (btn) btn.disabled = true; var pagePath = window.location.pathname; var dogName = profile && profile.dog_name ? profile.dog_name : null; var breed = profile && profile.breed ? profile.breed : null; var dogId = profile && profile.dog_id ? profile.dog_id : null; fetch('/api/feedback/note', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: _fbCurrentId, note: note, page_path: pagePath, session_id: sessionId, dog_id: dogId, dog_name: dogName, breed: breed, is_trainer_reviewer: isTrainerMode(), }) }) .then(function() { if (thx) { thx.style.display = ''; } if (btn) { btn.style.display = 'none'; } }) .catch(function() { if (btn) btn.disabled = false; }); } function openCoachNoteModal() { var overlay = document.getElementById('coachNoteOverlay'); if (overlay) overlay.classList.add('visible'); var ta = document.getElementById('cnNoteText'); if (ta) setTimeout(function() { ta.focus(); }, 120); // Reset state var confirm = document.getElementById('cnConfirm'); var btn = document.getElementById('cnSubmit'); if (confirm) confirm.style.display = 'none'; if (btn) { btn.style.display = ''; btn.disabled = false; } if (ta) ta.value = ''; } function closeCoachNoteModal() { var overlay = document.getElementById('coachNoteOverlay'); if (overlay) overlay.classList.remove('visible'); } function submitCoachNote() { var ta = document.getElementById('cnNoteText'); var btn = document.getElementById('cnSubmit'); var confirm = document.getElementById('cnConfirm'); var msg = ta ? ta.value.trim() : ''; if (!msg) { if (ta) ta.focus(); return; } if (btn) btn.disabled = true; var pagePath = window.location.pathname; var dogId = profile && profile.dog_id ? profile.dog_id : null; var dogName = profile && profile.dog_name ? profile.dog_name : null; var breed = profile && profile.breed ? profile.breed : null; var tier = profile && profile.current_tier ? profile.current_tier : null; var daysActive = profile && profile.days_active ? profile.days_active : null; fetch('/api/feedback/coach-note', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ page_path: pagePath, session_id: sessionId, dog_id: dogId, message: msg, dog_name: dogName, breed: breed, tier: tier, days_active: daysActive, }) }) .then(function() { if (btn) btn.style.display = 'none'; if (confirm) confirm.style.display = ''; // Auto-close after 2s setTimeout(closeCoachNoteModal, 2200); }) .catch(function() { if (btn) btn.disabled = false; }); } // ============================================ // Dashboard card dismiss / restore system // ============================================ var DISMISSIBLE_CARDS = { 'coachingBriefCard': "Today's Coaching Brief", 'voiceCoachCardWrap': 'Talk to coach', 'voiceHistoryCard': 'Voice sessions', 'dashScheduleCard': "Today's Schedule", 'skillsProgressCard': 'Skills & Progress', 'foundingReferralCard': 'Referral card', 'trainerProfileSection': 'Trainer profile', 'inviteFriendCardWrap': 'Invite a friend', 'voiceActivationCard': 'Voice coaching' }; var FC_DISMISSED_KEY = 'fc_dismissed_cards_v1'; function getDismissedCards() { try { return JSON.parse(localStorage.getItem(FC_DISMISSED_KEY) || '[]'); } catch(e) { return []; } } function saveDismissedCards(list) { try { localStorage.setItem(FC_DISMISSED_KEY, JSON.stringify(list)); } catch(e) {} } function dismissDashCard(id, label) { var el = document.getElementById(id); if (!el) return; // Use class so !important overrides any JS that later sets style.display el.classList.add('fc-card-dismissed'); var list = getDismissedCards(); if (list.indexOf(id) === -1) list.push(id); saveDismissedCards(list); renderDismissedSection(); trackEvent('dashboard_card_dismissed', { card_id: id }); } function restoreDashCard(id) { var el = document.getElementById(id); if (!el) return; el.classList.remove('fc-card-dismissed'); // Re-apply the original display style for cards that start hidden // Wrappers and always-visible cards: clearing style.display is sufficient // Cards that were hidden by JS (dashScheduleCard etc) will be re-shown by next loadDashboard call // but we show them immediately for a good UX var blockCards = ['dashScheduleCard','skillsProgressCard','coachingBriefCard','trainerProfileSection','voiceCoachCardWrap','inviteFriendCardWrap']; if (blockCards.indexOf(id) !== -1) { // Clear any inline display:none so the block/flex CSS can apply el.style.display = ''; } if (id === 'voiceHistoryCard') el.classList.add('visible'); var list = getDismissedCards().filter(function(i){ return i !== id; }); saveDismissedCards(list); renderDismissedSection(); trackEvent('dashboard_card_restored', { card_id: id }); } function renderDismissedSection() { var section = document.getElementById('dismissedCardsSection'); var list = document.getElementById('dismissedCardsList'); if (!section || !list) return; var dismissed = getDismissedCards(); list.innerHTML = ''; dismissed.forEach(function(id) { var name = DISMISSIBLE_CARDS[id] || id; var row = document.createElement('div'); row.className = 'dismissed-card-row'; row.innerHTML = '' + name + '' + ''; list.appendChild(row); }); section.style.display = dismissed.length > 0 ? '' : 'none'; } function initDismissedCards() { var dismissed = getDismissedCards(); dismissed.forEach(function(id) { var el = document.getElementById(id); if (el) el.classList.add('fc-card-dismissed'); }); renderDismissedSection(); } // ============================================ // Share-a-clip — public video gallery upload // ============================================ async function handleShareClipSelected(event) { const file = event.target.files && event.target.files[0]; event.target.value = ''; if (!file) return; const statusEl = document.getElementById('shareClipStatus'); const btn = document.getElementById('shareClipBtn'); const MAX_BYTES = 50 * 1024 * 1024; if (file.size > MAX_BYTES) { statusEl.textContent = 'File must be under 50MB.'; return; } // Soft duration check — only reject if clearly > 62s try { const dur = await getVideoDuration(file); if (dur > 62) { statusEl.textContent = 'Clip must be 60 seconds or less.'; return; } } catch (_) {} btn.disabled = true; btn.textContent = 'Uploading…'; statusEl.textContent = ''; try { const skillSlug = document.getElementById('shareClipSkillSelect').value || ''; const formData = new FormData(); formData.append('video', file); formData.append('session_id', sessionId); if (skillSlug) formData.append('skill_slug', skillSlug); const res = await fetch('/api/videos/upload', { method: 'POST', body: formData }); const data = await res.json(); if (data.success) { btn.textContent = '✓ Submitted for review'; statusEl.textContent = 'Thanks! Your clip will appear in the gallery after a quick review.'; // Reset skill selector document.getElementById('shareClipSkillSelect').value = ''; setTimeout(() => { btn.disabled = false; btn.textContent = '📹 Share another clip'; statusEl.textContent = ''; }, 5000); } else { throw new Error(data.message || 'Upload failed'); } } catch (err) { btn.disabled = false; btn.textContent = '📹 Choose clip to share'; statusEl.textContent = 'Upload failed — ' + (err.message || 'try again.'); } } // ============================================ // TODAY — Daily training plan // ============================================ var _todayPlan = null; var _todayCompletedKeys = new Set(); function formatTodayDate() { const d = new Date(); return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); } function getCategoryIcon(category) { const icons = { foundation: '🐾', impulse: '🧠', recall: '📣', issue: '🎯', enrichment: '🌿', }; return icons[category] || '🎯'; } function renderExerciseCard(ex, isCompleted) { const icon = getCategoryIcon(ex.category); const steps = (ex.steps || []).map((step, idx) => `
  • ${idx + 1}${step}
  • ` ).join(''); const breedNote = ex.breedNote ? `
    🐕 ${ex.breedNote}
    ` : ''; const doneClass = isCompleted ? 'done' : ''; const btnLabel = isCompleted ? '✓ Done' : 'Mark complete'; const btnClass = isCompleted ? 'exercise-complete-btn completed' : 'exercise-complete-btn'; const chatPrompt = encodeURIComponent(`Tell me more about the "${ex.title}" exercise for my dog`); return `
    ${icon}
    ${ex.title}
    ${ex.why}
    ${breedNote}
    ⏱ ${ex.minutes} min
      ${steps}
    `; } function renderTodayCards(plan) { const cardsEl = document.getElementById('todayCards'); const loadingEl = document.getElementById('todayLoading'); const allDoneEl = document.getElementById('todayAllDone'); if (!cardsEl) return; if (loadingEl) loadingEl.style.display = 'none'; const exercises = plan.exercises || []; const completedKeys = new Set(plan.completedKeys || []); _todayCompletedKeys = completedKeys; if (plan.planCompleted) { cardsEl.innerHTML = ''; if (allDoneEl) { allDoneEl.style.display = 'block'; const streakEl = document.getElementById('todayAllDoneStreak'); if (streakEl) streakEl.textContent = plan.streak || 0; } } else { cardsEl.innerHTML = exercises.map(ex => renderExerciseCard(ex, completedKeys.has(ex.key)) ).join(''); if (allDoneEl) allDoneEl.style.display = 'none'; } // Streak chip const streakChip = document.getElementById('todayStreakChip'); const streakNum = document.getElementById('todayStreakNum'); if (streakChip && plan.streak > 0) { streakChip.style.display = 'flex'; if (streakNum) streakNum.textContent = plan.streak; } } async function loadTodayPlan() { if (!sessionId) return; const dateLabel = document.getElementById('todayDateLabel'); if (dateLabel) dateLabel.textContent = '· ' + formatTodayDate(); try { const res = await fetch('/api/today/plan?sessionId=' + encodeURIComponent(sessionId)); if (!res.ok) throw new Error('plan_error'); const data = await res.json(); _todayPlan = data; renderTodayCards(data); } catch (e) { const loadingEl = document.getElementById('todayLoading'); if (loadingEl) loadingEl.textContent = 'Couldn\'t load today\'s plan — refresh to try again.'; } } async function completeExercise(exerciseKey, exerciseTitle) { if (!sessionId) return; if (_todayCompletedKeys.has(exerciseKey)) return; // Optimistic UI update const card = document.getElementById('exercise-card-' + exerciseKey); const btn = document.getElementById('complete-btn-' + exerciseKey); if (card) card.classList.add('done'); if (btn) { btn.disabled = true; btn.textContent = '✓ Done'; btn.className = 'exercise-complete-btn completed'; } _todayCompletedKeys.add(exerciseKey); try { const res = await fetch('/api/today/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId, exerciseKey, exerciseTitle }), }); const data = await res.json(); // Update streak chip if (data.streak !== undefined) { const streakChip = document.getElementById('todayStreakChip'); const streakNum = document.getElementById('todayStreakNum'); if (streakChip) streakChip.style.display = 'flex'; if (streakNum) streakNum.textContent = data.streak; } // Check if all done if (_todayPlan && _todayPlan.exercises) { const allDone = _todayPlan.exercises.every(ex => _todayCompletedKeys.has(ex.key)); if (allDone) { setTimeout(function() { const cardsEl = document.getElementById('todayCards'); const allDoneEl = document.getElementById('todayAllDone'); if (cardsEl) cardsEl.innerHTML = ''; if (allDoneEl) { allDoneEl.style.display = 'block'; const streakEl = document.getElementById('todayAllDoneStreak'); if (streakEl) streakEl.textContent = data.streak || 0; } }, 600); } } } catch (e) { // Silently keep optimistic UI } } // ============================================ // Activation Quick-Wins Checklist // Shown at the top of the dashboard for new accounts (< 14 days, < 5 steps done). // State lives server-side (user_activation_checklist table). // ============================================ // Deep-link destinations per step key var AC_STEP_LINKS = { log_rep: '#', // Replaced at runtime with active skill rep-logger deep link set_reminder: '/settings/reminders', voice_session: '/app/voice', save_memory: '#', // Replaced at runtime with /dogs/[slug]/memory URL share_profile: '#', // Handled via shareProfileForChecklist() }; var _acDogName = null; var _acProfileSlug = null; var _acCollapsed = false; function loadActivationChecklist() { if (!sessionId) return; fetch('/api/activation-checklist/' + encodeURIComponent(sessionId)) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.eligible) return; var card = document.getElementById('activationChecklist'); if (!card) return; _acDogName = data.dog_name || null; // Personalise title var titleEl = document.getElementById('acTitle'); if (titleEl && _acDogName) { titleEl.textContent = 'Get the most out of ' + _acDogName + '\u2019s coach'; } // Personalise step 4 label var s4label = document.getElementById('acStep-save_memory-label'); if (s4label && _acDogName) { s4label.textContent = 'Save a Coach Memory for ' + _acDogName; } // Personalise step 5 label var s5label = document.getElementById('acStep-share_profile-label'); if (s5label && _acDogName) { s5label.textContent = 'Share ' + _acDogName + '\u2019s profile'; } // Render step states acUpdateSteps(data.steps, data.steps_complete); card.style.display = ''; }) .catch(function() {}); } function acUpdateSteps(steps, totalComplete) { var keys = ['log_rep', 'set_reminder', 'voice_session', 'save_memory', 'share_profile']; keys.forEach(function(key) { var row = document.getElementById('acStep-' + key); if (!row) return; var done = steps[key] && steps[key].completed; var check = row.querySelector('.ac-check'); var doBtn = row.querySelector('.ac-do-btn'); if (done) { row.style.opacity = '0.5'; row.style.cursor = 'default'; if (check) check.style.display = 'inline'; if (doBtn) doBtn.style.display = 'none'; } else { row.style.opacity = ''; row.style.cursor = 'pointer'; if (check) check.style.display = 'none'; if (doBtn) doBtn.style.display = ''; } }); // Update progress bar var pct = (totalComplete / 5) * 100; var bar = document.getElementById('acProgressBar'); var lbl = document.getElementById('acProgressLabel'); if (bar) bar.style.width = pct + '%'; if (lbl) lbl.textContent = totalComplete + ' of 5'; } function toggleActivationChecklist() { _acCollapsed = !_acCollapsed; var list = document.getElementById('acStepList'); var btn = document.getElementById('acToggleBtn'); if (list) list.style.display = _acCollapsed ? 'none' : ''; if (btn) btn.textContent = _acCollapsed ? '\u25BC' : '\u25B2'; } function acStepTap(stepKey) { // If already done, do nothing var row = document.getElementById('acStep-' + stepKey); if (row && row.style.cursor === 'default') return; if (stepKey === 'share_profile') { shareProfileForChecklist(); return; } if (stepKey === 'save_memory' && _acProfileSlug) { window.location.href = '/dogs/' + _acProfileSlug + '/memory'; return; } if (stepKey === 'log_rep') { // Scroll to + open the rep logger if visible, otherwise go to Skills tab if (typeof openRepLogger === 'function') { openRepLogger(); return; } switchTab('skills'); return; } var link = AC_STEP_LINKS[stepKey]; if (link && link !== '#') window.location.href = link; } function shareProfileForChecklist() { if (!_acProfileSlug) { showToast('Enable your public profile first'); return; } var shareUrl = 'https://fetchcoach.app/d/' + _acProfileSlug; var shareTitle = (_acDogName ? _acDogName + '\u2019s Training Progress' : 'Dog Training Progress') + ' \u00B7 FetchCoach'; var shareText = 'Check out ' + (_acDogName || 'my dog') + ' training with @FetchCoach \uD83D\uDC3E'; var doShare = function() { // Mark step complete before navigating away acMarkStepComplete('share_profile', 'share_tap'); }; if (navigator.share) { navigator.share({ title: shareTitle, text: shareText, url: shareUrl }) .then(doShare) .catch(function() {}); } else { // Fallback: copy link + mark done if (navigator.clipboard) { navigator.clipboard.writeText(shareUrl).then(function() { showToast('Profile link copied!'); doShare(); }).catch(function() {}); } else { window.open(shareUrl, '_blank'); doShare(); } } } /** * Mark a step complete via the server API and refresh the card UI. * Called from detection hooks in other parts of the app. */ function acMarkStepComplete(stepKey, source) { if (!sessionId) return; fetch('/api/activation-checklist/' + encodeURIComponent(sessionId) + '/complete/' + stepKey, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source: source || 'client' }), }) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success) return; // Refresh card to reflect new state — also check if now fully complete fetch('/api/activation-checklist/' + encodeURIComponent(sessionId)) .then(function(r) { return r.json(); }) .then(function(fresh) { if (!fresh.eligible) { // All done or expired — hide with a small celebration delay setTimeout(function() { var card = document.getElementById('activationChecklist'); if (card) card.style.display = 'none'; }, 1500); return; } acUpdateSteps(fresh.steps, fresh.steps_complete); }) .catch(function() {}); }) .catch(function() {}); } // ============================================ // First-Rep Coach Nudge // Shown above the fold on the dashboard for zero-rep users aged 4h–7d. // State lives server-side (first_rep_nudge_dismissals table). // ============================================ var _firstRepOutcome = null; // 'great'|'okay'|'struggled' function loadFirstRepNudge() { if (!sessionId) return; fetch('/api/first-rep-nudge/' + encodeURIComponent(sessionId)) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.eligible) return; var panel = document.getElementById('firstRepNudgePanel'); if (!panel) return; // Personalise the message with dog name if (data.dog_name) { var msg = document.getElementById('firstRepNudgeMsg'); if (msg) { msg.textContent = 'Hey \u2014 I haven\u2019t seen ' + data.dog_name + ' practice anything yet. Let\u2019s log your first rep. What did you work on today, even if it was tiny?'; } } // Populate skill picker: first_skill as default + user can type custom var sel = document.getElementById('firstRepNudgeSkill'); if (sel) { sel.innerHTML = ''; if (data.first_skill) { var opt = document.createElement('option'); opt.value = data.first_skill; opt.textContent = data.first_skill; opt.selected = true; sel.appendChild(opt); } // Common starters var starters = ['Sit', 'Stay', 'Come', 'Down', 'Loose leash', 'Leave it', 'Name recognition']; starters.forEach(function(s) { if (data.first_skill && s.toLowerCase() === data.first_skill.toLowerCase()) return; var o = document.createElement('option'); o.value = s; o.textContent = s; sel.appendChild(o); }); // "Other" option var other = document.createElement('option'); other.value = '_other'; other.textContent = 'Something else\u2026'; sel.appendChild(other); } panel.style.display = ''; // Emit shown telemetry (fire-and-forget) fetch('/api/first-rep-nudge/' + encodeURIComponent(sessionId) + '/shown', { method: 'POST', headers: { 'Content-Type': 'application/json' } }).catch(function() {}); }) .catch(function() {}); } function selectFirstRepOutcome(outcome, btn) { _firstRepOutcome = outcome; // Toggle active styling on the three buttons document.querySelectorAll('.frn-outcome-btn').forEach(function(b) { b.style.borderColor = '#E7E5E4'; b.style.background = '#fff'; b.style.color = '#1C1917'; }); btn.style.borderColor = '#065F46'; btn.style.background = 'rgba(6,95,70,0.06)'; btn.style.color = '#065F46'; } function dismissFirstRepNudge() { var panel = document.getElementById('firstRepNudgePanel'); if (panel) panel.style.display = 'none'; if (!sessionId) return; fetch('/api/first-rep-nudge/' + encodeURIComponent(sessionId) + '/dismiss', { method: 'POST', headers: { 'Content-Type': 'application/json' } }).catch(function() {}); } async function submitFirstRepNudge() { if (!sessionId) return; var sel = document.getElementById('firstRepNudgeSkill'); var noteEl = document.getElementById('firstRepNudgeNote'); var submitBtn = document.getElementById('firstRepNudgeSubmitBtn'); var skillName = sel ? sel.value : ''; // "Something else" means the user needs to type — prompt them if (skillName === '_other') { var custom = window.prompt('What skill did you work on?'); if (!custom || !custom.trim()) return; skillName = custom.trim(); } if (!skillName) { showToast('Pick a skill first'); return; } if (!_firstRepOutcome) { showToast('Tap how it went'); return; } if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Logging\u2026'; } try { var res = await fetch('/api/first-rep-nudge/' + encodeURIComponent(sessionId) + '/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ skill_name: skillName, outcome: _firstRepOutcome, note: noteEl ? noteEl.value.trim() : '' }) }); var data = await res.json(); if (!data.success) { showToast(data.message || 'Couldn\u2019t log rep \u2014 try again'); if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Log this rep \u2192'; } return; } // Flip to done state document.getElementById('firstRepNudgeIdle').style.display = 'none'; var doneEl = document.getElementById('firstRepNudgeDone'); var replyEl = document.getElementById('firstRepNudgeReply'); if (replyEl) replyEl.textContent = data.message || 'Rep logged. Nice work.'; if (doneEl) doneEl.style.display = ''; // Mark fc_first_coaching_win so upgrade banner + founding chip unlock localStorage.setItem('fc_first_coaching_win', '1'); // Activation checklist: mark log_rep step complete acMarkStepComplete('log_rep', 'first_rep_nudge'); // Trigger push permission prompt on first rep (gated inside checkPushPromptCard) setTimeout(checkPushPromptCard, 1200); // Refresh streak widget to reflect the new rep if (typeof loadStreakWidget === 'function') loadStreakWidget(); // Auto-dismiss done state after 8 seconds setTimeout(function() { var panel = document.getElementById('firstRepNudgePanel'); if (panel) panel.style.display = 'none'; }, 8000); } catch (e) { showToast('Couldn\u2019t log rep \u2014 try again'); if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Log this rep \u2192'; } } } // ============================================ // Boot // ============================================ initDismissedCards(); init();
    🐾
    Add FetchCoach to your home screen
    One tap to your coach — no App Store needed.
    Add to your Home Screen
    One tap to your coach — no App Store needed. Follow these steps:
    1️⃣ Tap the Share button at the bottom of Safari (the box with an arrow pointing up)
    2️⃣ Scroll down and tap "Add to Home Screen"
    3️⃣ Tap "Add" in the top right — done! 🎉
    Training Calendar
    0
    Current
    0
    Best