// integration.jsx — backend connectivity adapter without changing visual design. // Loaded after design components and before the router. It exposes data + actions only. (function () { const parentConfig = (() => { try { return window.parent && window.parent !== window ? window.parent.__FITBODY_CONFIG__ : null; } catch (e) { return null; } })(); const env = window.FITBODY_ENV || window.__FITBODY_CONFIG__ || parentConfig || {}; const API_BASE = pick(env.API_BASE, env.apiBaseUrl, safeLocal('fb_api_base'), ''); const CONTENT_BASE = pick(env.CONTENT_BASE, env.contentBaseUrl, safeLocal('fb_content_base'), API_BASE); const ANALYTICS_BASE = pick(env.ANALYTICS_BASE, env.analyticsBaseUrl, safeLocal('fb_analytics_base'), API_BASE); const REALTIME_URL = pick(env.REALTIME_URL, env.realtimeUrl, safeLocal('fb_realtime_url'), realtimeFromLocation()); const state = { ready: false, offline: false, errors: [], me: null, profile: null, preferences: null, workouts: [], sessions: [], recommendations: [], plans: [], subscription: null, leaderboard: null, notifications: [], activeSessions: {}, }; function pick() { for (let i = 0; i < arguments.length; i++) { if (arguments[i] !== undefined && arguments[i] !== null && arguments[i] !== '') return arguments[i]; } return ''; } function safeLocal(key) { try { return localStorage.getItem(key) || ''; } catch (e) { return ''; } } function safeSetLocal(key, value) { try { localStorage.setItem(key, value); } catch (e) {} } function realtimeFromLocation() { if (!window.location || !window.location.host) return ''; const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; return `${scheme}//${window.location.host}/v1/realtime`; } function url(base, path) { if (!base) return path; return `${base.replace(/\/$/, '')}${path}`; } function getToken() { return safeLocal('fb_access_token') || safeLocal('fitbody_access_token') || env.devAccessToken || ''; } function setTokens(payload) { const access = payload && (payload.accessToken || payload.access_token); const refresh = payload && (payload.refreshToken || payload.refresh_token); if (access) safeSetLocal('fb_access_token', access); if (refresh) safeSetLocal('fb_refresh_token', refresh); } function headers(extra, method) { const h = { Accept: 'application/json', 'X-Request-Id': idempotencyKey('req'), ...(extra || {}) }; const token = getToken(); if (token) h.Authorization = `Bearer ${token}`; if (method && method !== 'GET' && method !== 'HEAD') { if (!h['Content-Type']) h['Content-Type'] = 'application/json'; if (!h['Idempotency-Key']) h['Idempotency-Key'] = idempotencyKey('idem'); } return h; } function idempotencyKey(prefix) { return `${prefix}-${ulid()}`; } const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; function ulid() { let time = Date.now(); let out = ''; for (let i = 9; i >= 0; i--) { out = CROCKFORD[time % 32] + out; time = Math.floor(time / 32); } const bytes = new Uint8Array(16); if (window.crypto && window.crypto.getRandomValues) window.crypto.getRandomValues(bytes); else for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256); for (let i = 0; i < 16; i++) out += CROCKFORD[bytes[i] & 31]; return out; } async function request(base, path, options) { const method = (options && options.method) || 'GET'; const response = await fetch(url(base, path), { ...options, method, headers: headers(options && options.headers, method), }); const text = await response.text(); if (!response.ok) { const error = new Error(`API ${method} ${path} failed: ${response.status}`); error.status = response.status; error.body = text; throw error; } return text ? JSON.parse(text) : null; } async function optional(base, path, fallback, options) { try { return await request(base, path, options); } catch (error) { if (!error.status || error.status >= 500) state.offline = true; state.errors.push({ path, status: error.status || 0 }); return fallback; } } async function ensureToken() { if (getToken()) return getToken(); const code = env.devOAuthCode || ''; if (!code) return ''; try { const payload = await request(API_BASE, '/v1/auth/oauth/google/callback', { method: 'POST', body: JSON.stringify({ code, device_name: 'fitbody-mobile-web' }), }); setTokens(payload); return getToken(); } catch (error) { state.errors.push({ path: '/v1/auth/oauth/google/callback', status: error.status || 0 }); return ''; } } function asArray(value, keys) { if (Array.isArray(value)) return value; if (!value) return []; for (let i = 0; i < keys.length; i++) { if (Array.isArray(value[keys[i]])) return value[keys[i]]; } return []; } async function bootstrap() { state.offline = false; state.errors = []; await ensureToken(); let [me, profile, preferences, workouts, sessions, recommendations, plans, subscription, notifications, leaderboard] = await Promise.all([ optional(API_BASE, '/v1/me', null), optional(API_BASE, '/v1/me/profile', null), optional(API_BASE, '/v1/me/preferences', null), optional(API_BASE, '/v1/me/workouts', null), optional(API_BASE, '/v1/me/sessions', null), optional(CONTENT_BASE, '/v1/catalog/recommendations?context=today', null), optional(API_BASE, '/v1/billing/plans', []), optional(API_BASE, '/v1/billing/me/subscription', null), optional(API_BASE, '/v1/me/notifications', []), optional(API_BASE, '/v1/leaderboard/weekly', null), ]); if (getToken() && asArray(workouts, ['workouts', 'items', 'sessions']).length === 0) { await optional(API_BASE, '/v1/scheduler/plans:generate', null, { method: 'POST', body: JSON.stringify({ goal: 'daily_health_action', level: 'beginner', days_per_week: 5, available_minutes: 2, equipment: [] }), }); workouts = await optional(API_BASE, '/v1/me/workouts', workouts); } state.me = me; state.profile = profile; state.preferences = preferences; state.workouts = asArray(workouts, ['workouts', 'items', 'sessions']); state.sessions = asArray(sessions, ['sessions', 'items']); state.recommendations = asArray(recommendations, ['items', 'recommendations', 'videos']); state.plans = asArray(plans, ['plans', 'items']); state.subscription = subscription; state.leaderboard = leaderboard; state.notifications = asArray(notifications, ['notifications', 'items']); state.ready = true; return snapshot(); } function questFromWorkout(workout) { if (!workout) return null; const exercise = workout.exercise || workout.family || 'mobility'; const seconds = Math.max(10, Math.min(120, workout.hold_seconds || (workout.minutes ? workout.minutes * 60 : 30))); const title = workout.name || exercise || '오늘의 Quest'; return { id: workout.id, workout_id: workout.id, title, seconds, xp: seconds <= 20 ? 8 : 10, meta: `${seconds}초 · ${workout.mode || '앉아서 가능'}`, cat: categoryFor(exercise), icon: iconFor(exercise), tone: toneFor(exercise), steps: stepsFor(exercise), avaLine: '오늘 컨디션에 맞춰 짧게 완료해요', }; } function questFromRecommendation(item) { const source = item && (item.video || item.workout || item.quest || item); if (!source) return null; const seconds = Math.max(10, Math.min(120, source.duration_sec || source.durationSec || source.seconds || 30)); const title = source.title || source.name || '추천 Quest'; return { id: source.id || source.video_id || source.videoId || title, workout_id: source.workout_id || source.workoutId || source.id, title, seconds, xp: seconds <= 20 ? 8 : 10, meta: `${seconds}초 · 추천`, cat: 'mobility', icon: 'wind', steps: source.steps || ['편하게 준비해요', '천천히 움직여요', '길게 호흡해요'], avaLine: '완료 가능한 작은 행동부터 시작해요', }; } function categoryFor(exercise) { const e = String(exercise || '').toLowerCase(); if (e.includes('breath') || e.includes('recovery')) return 'recovery'; if (e.includes('squat') || e.includes('core') || e.includes('strength')) return 'strength'; if (e.includes('walk') || e.includes('cardio')) return 'cardio'; return 'mobility'; } function iconFor(exercise) { const cat = categoryFor(exercise); if (cat === 'recovery') return 'leaf'; if (cat === 'strength') return 'dumbbell'; if (cat === 'cardio') return 'bolt'; return 'wind'; } function toneFor(exercise) { const cat = categoryFor(exercise); if (cat === 'recovery') return T.calm; if (cat === 'strength') return T.violet; if (cat === 'cardio') return T.teal; return T.coral; } function stepsFor(exercise) { const e = String(exercise || '').toLowerCase(); if (e.includes('breath') || e.includes('recovery')) return ['편하게 자세를 잡아요', '천천히 들이마셔요', '길게 내쉬어요']; if (e.includes('squat')) return ['발을 안정적으로 놓아요', '엉덩이를 뒤로 보내요', '천천히 올라와요']; if (e.includes('core')) return ['등을 곧게 세워요', '배에 힘을 줘요', '천천히 호흡해요']; return ['어깨를 부드럽게 올려요', '뒤로 천천히 돌려요', '숨을 길게 내쉬어요']; } function normalizeUser(me, profile, subscription) { const displayName = pick(profile && (profile.display_name || profile.displayName), me && (me.name || me.display_name || me.displayName), '지민'); const first = String(displayName).trim().charAt(0) || '지'; const tier = subscription && (subscription.tier || subscription.plan || subscription.plan_id || subscription.planId || subscription.status); return { displayName, initial: first, pro: Boolean(subscription && tier && !String(tier).toLowerCase().includes('free')), }; } function dataFromSnapshot(snap, current) { const cur = current || {}; const user = normalizeUser(snap.me, snap.profile, snap.subscription); const finished = (snap.sessions || []).filter(s => String(s.status || '').toLowerCase() === 'finished' || s.finished_at || s.finishedAt); const today = new Date().toISOString().slice(0, 10); const completedToday = finished.some(s => String(s.finished_at || s.finishedAt || s.started_at || s.startedAt || '').slice(0, 10) === today); const sourceQuest = questFromWorkout((snap.workouts || []).find(w => String(w.status || '').toLowerCase() !== 'completed') || (snap.workouts || [])[0]) || questFromRecommendation((snap.recommendations || [])[0]); const stations = buildStations(sourceQuest, completedToday || cur.completedToday); const xpFromSessions = finished.length ? finished.length * 10 : cur.xp; return { ...cur, ...user, completedToday: completedToday || cur.completedToday, homeVariant: (completedToday || cur.completedToday) ? 'completed' : (cur.homeVariant || 'default'), xp: xpFromSessions || cur.xp, bestStreak: Math.max(cur.bestStreak || 6, cur.streak || 0), todayQuest: sourceQuest || cur.todayQuest, questStations: stations || cur.questStations, notificationCount: (snap.notifications || []).filter(n => !n.read_at && !n.readAt).length, leaderboard: snap.leaderboard || cur.leaderboard, backendReady: Boolean(snap.ready), backendOffline: Boolean(snap.offline), }; } function buildStations(todayQuest, done) { if (!todayQuest) return null; return [ { id: 'today', cat: todayQuest.cat || 'mobility', icon: todayQuest.icon || 'wind', title: todayQuest.title, meta: todayQuest.meta || `${todayQuest.seconds || 30}초`, state: done ? 'done' : 'active', quest: todayQuest }, { id: 'core', cat: 'strength', icon: 'dumbbell', title: '코어 세우기', meta: '20초 · 앉아서', state: done ? 'active' : 'locked', quest: { title: '코어 세우기', seconds: 20, tone: T.violet, steps: stepsFor('core'), avaLine: '허리가 아니라 배에 힘을', xp: 12 } }, { id: 'reward', cat: 'breath', icon: 'star', title: '보너스 상자', meta: '연속 2개 완료 시 열림', state: 'locked', reward: true }, { id: 'recovery', cat: 'recovery', icon: 'leaf', title: '회복 호흡', meta: '30초 · 침대에서', state: 'locked', quest: { title: '회복 호흡', seconds: 30, tone: T.calm, steps: stepsFor('recovery'), avaLine: '회복도 훈련이에요', xp: 12 } }, ]; } function rowFromBackend(row) { return { rank: row.rank, name: row.name, pts: row.points || row.pts || 0, days: row.healthy_days || row.healthyDays || row.days || 0, me: Boolean(row.me), priv: Boolean(row.private || row.priv), tag: row.tag, }; } function leaderboardRows(leaderboard) { return leaderboard && Array.isArray(leaderboard.rows) ? leaderboard.rows.map(rowFromBackend) : null; } function friendRows(leaderboard) { return leaderboard && Array.isArray(leaderboard.friends) ? leaderboard.friends.map(rowFromBackend) : null; } function scoreRows(leaderboard) { if (!leaderboard || !Array.isArray(leaderboard.score_breakdown)) return null; return leaderboard.score_breakdown.map(item => [item.label, `${item.points >= 0 ? '+' : ''}${item.points}`, item.points >= 0 ? T.coral : T.ink3]); } function squadData(leaderboard) { if (!leaderboard || !leaderboard.squad) return null; const squad = leaderboard.squad; return { name: squad.name, progress: squad.progress, goals: Array.isArray(squad.goals) ? squad.goals.map(g => [g.label, g.value, g.goal]) : null, members: Array.isArray(squad.members) ? squad.members.map(m => [m.name, m.status, toneByName(m.tone)]) : null, }; } function toneByName(name) { if (name === 'good') return T.good; if (name === 'calm') return T.calm; if (name === 'coral') return T.coral; return T.ink3; } function questKey(quest) { return String((quest && (quest.session_id || quest.sessionId || quest.workout_id || quest.workoutId || quest.id || quest.title)) || 'quest'); } async function beginQuest(quest) { const key = questKey(quest); if (state.activeSessions[key]) return state.activeSessions[key]; const session = await request(API_BASE, '/v1/sessions', { method: 'POST', body: JSON.stringify({ workout_id: quest && (quest.workout_id || quest.workoutId || quest.id || quest.title), device_mode: 'self-browser' }), }); state.activeSessions[key] = session; await emit('quest_started', { quest_id: key, session_id: session.session_id || session.sessionId }); return session; } async function completeQuest(quest) { const q = quest || {}; const key = questKey(q); const session = state.activeSessions[key] || await beginQuest(q); const sessionId = session.session_id || session.sessionId || session.id; const event = { eventId: ulid(), monotonicSeq: 1, sessionId, nonce: idempotencyKey('nonce'), type: 'quest_completed', exercise: q.title || key, duration_sec: q.seconds || 0, reps: q.selfReport ? 1 : 0, }; await request(API_BASE, `/v1/sessions/${encodeURIComponent(sessionId)}/events:batch`, { method: 'POST', body: JSON.stringify({ events: [event] }), }); const report = await request(API_BASE, `/v1/sessions/${encodeURIComponent(sessionId)}/finish`, { method: 'POST', body: JSON.stringify({}), }); await emit('quest_completed', { quest_id: key, session_id: sessionId, xp: q.xp || 10, seconds: q.seconds || 0 }); delete state.activeSessions[key]; return report; } async function emit(name, payload) { const userID = state.me && (state.me.id || state.me.ID || state.me.user_id || state.me.userId); const event = { event_id: ulid(), event_name: name, event_version: '1.0', ts_ms: Date.now(), user_id: userID || undefined, anon_id: userID ? undefined : (safeLocal('fb_anon_id') || idempotencyKey('anon')), session_id: payload && (payload.session_id || payload.sessionId), source: 'web', privacy_class: 'P1', payload: payload || {}, }; if (event.anon_id) safeSetLocal('fb_anon_id', event.anon_id); try { return await request(ANALYTICS_BASE, '/v1/analytics/events/single', { method: 'POST', body: JSON.stringify(event), }); } catch (error) { state.offline = true; state.errors.push({ path: '/v1/analytics/events/single', status: error.status || 0 }); return null; } } async function subscribe(planId) { const out = await request(API_BASE, '/v1/billing/checkout', { method: 'POST', body: JSON.stringify({ PlanID: planId || 'pro' }), }); return out && out.url; } async function markNotificationRead(id) { return request(API_BASE, `/v1/me/notifications/${encodeURIComponent(id)}/read`, { method: 'POST', body: JSON.stringify({}) }); } async function recordBodyCheck(report) { const payload = { body_check_id: report && report.id, captured_at: report && report.capturedAt, upper_alignment: report && report.upperAlignment, pelvis_balance: report && report.pelvisBalance, readiness: report && report.readiness, recommendation: report && report.recommendation, frame: report && report.frame, image_stored: false, privacy_class: 'P1', }; return emit('body_check_completed', payload); } async function recordMotionLens(report) { const box = report && report.movementBox; const payload = { duration_ms: report && report.durationMs, peak_motion: report && report.peakMotion, frames: report && report.frames, movement_box: box ? { x: box.x, y: box.y, width: box.width, height: box.height, active: box.active, } : null, movement_detected: !!(report && report.ok && (report.peakMotion > 2 || (box && box.active > 8))), privacy_class: 'P1', }; return emit('motion_lens_completed', payload); } function snapshot() { return JSON.parse(JSON.stringify({ ready: state.ready, offline: state.offline, errors: state.errors, me: state.me, profile: state.profile, preferences: state.preferences, workouts: state.workouts, sessions: state.sessions, recommendations: state.recommendations, plans: state.plans, subscription: state.subscription, leaderboard: state.leaderboard, notifications: state.notifications, })); } window.FitbodyBackend = { apiBase: API_BASE, contentBase: CONTENT_BASE, analyticsBase: ANALYTICS_BASE, realtimeUrl: REALTIME_URL, bootstrap, beginQuest, completeQuest, emit, subscribe, markNotificationRead, recordBodyCheck, recordMotionLens, leaderboardRows, friendRows, scoreRows, squadData, snapshot, normalizeUser: (me) => normalizeUser(me, null, null), dataFromSnapshot, }; })();