Files
play-life/play-life-web/src/components/FitbitIntegration.jsx
poignatov 405d30bead
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m7s
4.27.3: Фикс undefined в статистике Fitbit
2026-02-06 21:17:47 +03:00

529 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from 'react'
import { useAuth } from './auth/AuthContext'
import LoadingError from './LoadingError'
import Toast from './Toast'
import './Integrations.css'
function FitbitIntegration({ onNavigate }) {
const { authFetch } = useAuth()
const [connected, setConnected] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [message, setMessage] = useState('')
const [oauthError, setOauthError] = useState('')
const [toastMessage, setToastMessage] = useState(null)
const [isLoadingError, setIsLoadingError] = useState(false)
const [goals, setGoals] = useState({
steps: { min: 8000, max: 10000 },
floors: { min: 8, max: 10 },
azm: { min: 22, max: 44 }
})
const [stats, setStats] = useState({
steps: { value: 0, goal: { min: 8000, max: 10000 } },
floors: { value: 0, goal: { min: 8, max: 10 } },
azm: { value: 0, goal: { min: 22, max: 44 } }
})
const [isEditingGoals, setIsEditingGoals] = useState(false)
const [editedGoals, setEditedGoals] = useState(goals)
const [syncing, setSyncing] = useState(false)
// Сохраняем OAuth статус из URL в ref, чтобы проверить после checkStatus
const oauthStatusRef = React.useRef(null)
useEffect(() => {
// Проверяем URL параметры для сообщений ДО вызова checkStatus
const params = new URLSearchParams(window.location.search)
const integration = params.get('integration')
const status = params.get('status')
if (integration === 'fitbit') {
oauthStatusRef.current = status
if (status === 'connected') {
setMessage('Fitbit успешно подключен!')
} else if (status === 'error') {
const errorMsg = params.get('message') || 'unknown_error'
const errorMessages = {
'config_error': 'Ошибка конфигурации сервера. Обратитесь к администратору.',
'invalid_state': 'Недействительный токен авторизации. Попробуйте ещё раз.',
'no_code': 'Не получен код авторизации от Fitbit. Попробуйте ещё раз.',
'token_exchange_failed': 'Не удалось обменять код на токен. Проверьте настройки Fitbit приложения.',
'user_info_failed': 'Не удалось получить информацию о пользователе Fitbit.',
'db_error': 'Ошибка сохранения данных. Попробуйте ещё раз.',
'unknown_error': 'Произошла неизвестная ошибка при подключении Fitbit.'
}
setOauthError(errorMessages[errorMsg] || `Ошибка: ${errorMsg}`)
}
// Очищаем URL параметры
window.history.replaceState({}, '', window.location.pathname)
}
checkStatus()
}, [])
useEffect(() => {
if (connected) {
loadStats()
}
}, [connected])
const checkStatus = async () => {
try {
setLoading(true)
setError('')
const response = await authFetch('/api/integrations/fitbit/status')
if (!response.ok) {
throw new Error('Ошибка при проверке статуса')
}
const data = await response.json()
setConnected(data.connected || false)
if (data.connected && data.goals) {
setGoals(data.goals)
setEditedGoals(data.goals)
}
// Если OAuth вернул status=connected, но бэкенд не подтвердил подключение
if (oauthStatusRef.current === 'connected' && !data.connected) {
setOauthError('Авторизация в Fitbit прошла, но подключение не сохранилось. Попробуйте ещё раз или обратитесь к администратору.')
setMessage('')
}
oauthStatusRef.current = null
} catch (error) {
console.error('Error checking status:', error)
setError(error.message || 'Не удалось проверить статус')
setIsLoadingError(true)
} finally {
setLoading(false)
}
}
const loadStats = async () => {
try {
const response = await authFetch('/api/integrations/fitbit/stats')
if (!response.ok) {
throw new Error('Ошибка при загрузке статистики')
}
const data = await response.json()
// Нормализуем данные, чтобы избежать undefined
const defaultGoal = { min: 0, max: 0 }
const normalizedStats = {
steps: {
value: data.steps?.value ?? 0,
goal: data.steps?.goal ?? defaultGoal
},
floors: {
value: data.floors?.value ?? 0,
goal: data.floors?.goal ?? defaultGoal
},
azm: {
value: data.azm?.value ?? 0,
goal: data.azm?.goal ?? defaultGoal
}
}
setStats(normalizedStats)
// Обновляем цели из ответа
if (data.steps?.goal) {
setGoals({
steps: data.steps.goal,
floors: data.floors?.goal ?? defaultGoal,
azm: data.azm?.goal ?? defaultGoal
})
setEditedGoals({
steps: data.steps.goal,
floors: data.floors?.goal ?? defaultGoal,
azm: data.azm?.goal ?? defaultGoal
})
}
} catch (error) {
console.error('Error loading stats:', error)
// Не показываем ошибку, просто не обновляем статистику
}
}
const handleConnect = async () => {
try {
setLoading(true)
setError('')
const response = await authFetch('/api/integrations/fitbit/oauth/connect')
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Ошибка при подключении Fitbit')
}
const data = await response.json()
if (data.auth_url) {
window.location.href = data.auth_url
} else {
throw new Error('URL для авторизации не получен')
}
} catch (error) {
console.error('Error connecting Fitbit:', error)
setToastMessage({ text: error.message || 'Не удалось подключить Fitbit', type: 'error' })
setLoading(false)
}
}
const handleDisconnect = async () => {
if (!window.confirm('Вы уверены, что хотите отключить Fitbit?')) {
return
}
try {
setLoading(true)
setError('')
const response = await authFetch('/api/integrations/fitbit/disconnect', {
method: 'DELETE',
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Ошибка при отключении')
}
setConnected(false)
setStats({
steps: { value: 0, goal: { min: 8000, max: 10000 } },
floors: { value: 0, goal: { min: 8, max: 10 } },
azm: { value: 0, goal: { min: 22, max: 44 } }
})
setToastMessage({ text: 'Fitbit отключен', type: 'success' })
} catch (error) {
console.error('Error disconnecting:', error)
setToastMessage({ text: error.message || 'Не удалось отключить Fitbit', type: 'error' })
} finally {
setLoading(false)
}
}
const handleSync = async () => {
try {
setSyncing(true)
const response = await authFetch('/api/integrations/fitbit/sync', {
method: 'POST',
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Ошибка при синхронизации')
}
setToastMessage({ text: 'Данные синхронизированы', type: 'success' })
await loadStats()
} catch (error) {
console.error('Error syncing:', error)
setToastMessage({ text: error.message || 'Не удалось синхронизировать данные', type: 'error' })
} finally {
setSyncing(false)
}
}
const handleSaveGoals = async () => {
try {
const response = await authFetch('/api/integrations/fitbit/goals', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
steps: editedGoals.steps,
floors: editedGoals.floors,
azm: editedGoals.azm,
}),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Ошибка при сохранении целей')
}
setGoals(editedGoals)
setIsEditingGoals(false)
setToastMessage({ text: 'Цели сохранены', type: 'success' })
await loadStats()
} catch (error) {
console.error('Error saving goals:', error)
setToastMessage({ text: error.message || 'Не удалось сохранить цели', type: 'error' })
}
}
const handleCancelEdit = () => {
setEditedGoals(goals)
setIsEditingGoals(false)
}
const getProgressPercent = (value, min, max) => {
if (value >= max) return 100
if (value <= min) return (value / min) * 50
return 50 + ((value - min) / (max - min)) * 50
}
const getProgressColor = (value, min, max) => {
if (value >= max) return 'text-green-600'
if (value >= min) return 'text-blue-600'
return 'text-gray-600'
}
if (isLoadingError && !loading) {
return (
<div className="p-4 md:p-6">
<button className="close-x-button" onClick={() => onNavigate?.('profile')} title="Закрыть">
</button>
<LoadingError onRetry={checkStatus} />
</div>
)
}
return (
<div className="p-4 md:p-6">
<button className="close-x-button" onClick={() => onNavigate?.('profile')} title="Закрыть">
</button>
<h1 className="text-2xl font-bold mb-6">Fitbit интеграция</h1>
{loading ? (
<div className="fixed inset-0 flex justify-center items-center">
<div className="flex flex-col items-center">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
<div className="text-gray-600 font-medium">Загрузка...</div>
</div>
</div>
) : connected ? (
<div>
{message && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
<p className="text-green-800">{message}</p>
</div>
)}
{oauthError && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<p className="text-red-800">{oauthError}</p>
<button onClick={() => setOauthError('')} className="text-red-600 text-sm underline mt-1">Скрыть</button>
</div>
)}
{/* Статистика */}
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">Статистика за сегодня</h2>
<button
onClick={handleSync}
disabled={syncing}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
{syncing ? 'Синхронизация...' : 'Синхронизировать'}
</button>
</div>
{/* Шаги */}
<div className="mb-6">
<div className="flex justify-between items-center mb-2">
<span className="text-gray-700 font-medium">Шаги</span>
<span className={`font-bold ${getProgressColor(stats.steps?.value ?? 0, stats.steps?.goal?.min ?? 0, stats.steps?.goal?.max ?? 0)}`}>
{(stats.steps?.value ?? 0).toLocaleString()} / {stats.steps?.goal?.min ?? 0}-{stats.steps?.goal?.max ?? 0}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className="bg-indigo-600 h-3 rounded-full transition-all"
style={{ width: `${Math.min(100, getProgressPercent(stats.steps?.value ?? 0, stats.steps?.goal?.min ?? 0, stats.steps?.goal?.max ?? 0))}%` }}
></div>
</div>
</div>
{/* Этажи */}
<div className="mb-6">
<div className="flex justify-between items-center mb-2">
<span className="text-gray-700 font-medium">Этажи</span>
<span className={`font-bold ${getProgressColor(stats.floors?.value ?? 0, stats.floors?.goal?.min ?? 0, stats.floors?.goal?.max ?? 0)}`}>
{stats.floors?.value ?? 0} / {stats.floors?.goal?.min ?? 0}-{stats.floors?.goal?.max ?? 0}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className="bg-indigo-600 h-3 rounded-full transition-all"
style={{ width: `${Math.min(100, getProgressPercent(stats.floors?.value ?? 0, stats.floors?.goal?.min ?? 0, stats.floors?.goal?.max ?? 0))}%` }}
></div>
</div>
</div>
{/* Баллы кардио (AZM) */}
<div className="mb-6">
<div className="flex justify-between items-center mb-2">
<span className="text-gray-700 font-medium">Баллы кардио</span>
<span className={`font-bold ${getProgressColor(stats.azm?.value ?? 0, stats.azm?.goal?.min ?? 0, stats.azm?.goal?.max ?? 0)}`}>
{stats.azm?.value ?? 0} / {stats.azm?.goal?.min ?? 0}-{stats.azm?.goal?.max ?? 0}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className="bg-indigo-600 h-3 rounded-full transition-all"
style={{ width: `${Math.min(100, getProgressPercent(stats.azm?.value ?? 0, stats.azm?.goal?.min ?? 0, stats.azm?.goal?.max ?? 0))}%` }}
></div>
</div>
</div>
</div>
{/* Настройка целей */}
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">Дневные цели</h2>
{!isEditingGoals && (
<button
onClick={() => setIsEditingGoals(true)}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors text-sm"
>
Изменить
</button>
)}
</div>
{isEditingGoals ? (
<div className="space-y-4">
{/* Шаги */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Шаги (мин - макс)</label>
<div className="flex gap-2">
<input
type="number"
value={editedGoals.steps.min}
onChange={(e) => setEditedGoals({ ...editedGoals, steps: { ...editedGoals.steps, min: parseInt(e.target.value) || 0 } })}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
/>
<input
type="number"
value={editedGoals.steps.max}
onChange={(e) => setEditedGoals({ ...editedGoals, steps: { ...editedGoals.steps, max: parseInt(e.target.value) || 0 } })}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
</div>
{/* Этажи */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Этажи (мин - макс)</label>
<div className="flex gap-2">
<input
type="number"
value={editedGoals.floors.min}
onChange={(e) => setEditedGoals({ ...editedGoals, floors: { ...editedGoals.floors, min: parseInt(e.target.value) || 0 } })}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
/>
<input
type="number"
value={editedGoals.floors.max}
onChange={(e) => setEditedGoals({ ...editedGoals, floors: { ...editedGoals.floors, max: parseInt(e.target.value) || 0 } })}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
</div>
{/* Баллы кардио */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Баллы кардио (мин - макс)</label>
<div className="flex gap-2">
<input
type="number"
value={editedGoals.azm.min}
onChange={(e) => setEditedGoals({ ...editedGoals, azm: { ...editedGoals.azm, min: parseInt(e.target.value) || 0 } })}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
/>
<input
type="number"
value={editedGoals.azm.max}
onChange={(e) => setEditedGoals({ ...editedGoals, azm: { ...editedGoals.azm, max: parseInt(e.target.value) || 0 } })}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
</div>
<div className="flex gap-2">
<button
onClick={handleSaveGoals}
className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
>
Сохранить
</button>
<button
onClick={handleCancelEdit}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
>
Отмена
</button>
</div>
</div>
) : (
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-600">Шаги:</span>
<span className="font-medium">{goals.steps.min} - {goals.steps.max}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Этажи:</span>
<span className="font-medium">{goals.floors.min} - {goals.floors.max}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Баллы кардио:</span>
<span className="font-medium">{goals.azm.min} - {goals.azm.max}</span>
</div>
</div>
)}
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
<h3 className="text-lg font-semibold mb-3 text-blue-900">
Как это работает
</h3>
<p className="text-gray-700 mb-2">
Fitbit подключен! Данные синхронизируются автоматически каждые 4 часа.
</p>
<p className="text-gray-600 text-sm">
Вы также можете синхронизировать данные вручную, нажав кнопку "Синхронизировать".
</p>
</div>
<button
onClick={handleDisconnect}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
Отключить Fitbit
</button>
</div>
) : (
<div>
{oauthError && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<p className="text-red-800 font-medium">Ошибка подключения Fitbit</p>
<p className="text-red-700 mt-1">{oauthError}</p>
<button onClick={() => setOauthError('')} className="text-red-600 text-sm underline mt-2">Скрыть</button>
</div>
)}
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-lg font-semibold mb-4">Подключение Fitbit</h2>
<p className="text-gray-700 mb-4">
Подключите свой Fitbit аккаунт для отслеживания шагов, этажей и баллов кардионагрузки.
</p>
<button
onClick={handleConnect}
className="px-6 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-semibold"
>
Подключить Fitbit
</button>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
<h3 className="text-lg font-semibold mb-3 text-blue-900">
Что нужно сделать
</h3>
<ol className="list-decimal list-inside space-y-2 text-gray-700">
<li>Нажмите кнопку "Подключить Fitbit"</li>
<li>Авторизуйтесь в Fitbit</li>
<li>Разрешите доступ к данным о физической активности</li>
<li>Готово! Данные будут синхронизироваться автоматически</li>
</ol>
</div>
</div>
)}
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
)}
</div>
)
}
export default FitbitIntegration