2026-02-06 20:50:49 +03:00
|
|
|
|
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('')
|
2026-02-06 21:15:08 +03:00
|
|
|
|
const [oauthError, setOauthError] = useState('')
|
2026-02-06 20:50:49 +03:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-02-06 21:15:08 +03:00
|
|
|
|
// Сохраняем OAuth статус из URL в ref, чтобы проверить после checkStatus
|
|
|
|
|
|
const oauthStatusRef = React.useRef(null)
|
|
|
|
|
|
|
2026-02-06 20:50:49 +03:00
|
|
|
|
useEffect(() => {
|
2026-02-06 21:15:08 +03:00
|
|
|
|
// Проверяем URL параметры для сообщений ДО вызова checkStatus
|
2026-02-06 20:50:49 +03:00
|
|
|
|
const params = new URLSearchParams(window.location.search)
|
|
|
|
|
|
const integration = params.get('integration')
|
|
|
|
|
|
const status = params.get('status')
|
|
|
|
|
|
if (integration === 'fitbit') {
|
2026-02-06 21:15:08 +03:00
|
|
|
|
oauthStatusRef.current = status
|
2026-02-06 20:50:49 +03:00
|
|
|
|
if (status === 'connected') {
|
2026-02-06 21:15:08 +03:00
|
|
|
|
setMessage('Fitbit успешно подключен!')
|
2026-02-06 20:50:49 +03:00
|
|
|
|
} else if (status === 'error') {
|
2026-02-06 21:15:08 +03:00
|
|
|
|
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}`)
|
2026-02-06 20:50:49 +03:00
|
|
|
|
}
|
2026-02-06 21:15:08 +03:00
|
|
|
|
// Очищаем URL параметры
|
|
|
|
|
|
window.history.replaceState({}, '', window.location.pathname)
|
2026-02-06 20:50:49 +03:00
|
|
|
|
}
|
2026-02-06 21:15:08 +03:00
|
|
|
|
checkStatus()
|
2026-02-06 20:50:49 +03:00
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-02-06 21:15:08 +03:00
|
|
|
|
// Если OAuth вернул status=connected, но бэкенд не подтвердил подключение
|
|
|
|
|
|
if (oauthStatusRef.current === 'connected' && !data.connected) {
|
|
|
|
|
|
setOauthError('Авторизация в Fitbit прошла, но подключение не сохранилось. Попробуйте ещё раз или обратитесь к администратору.')
|
|
|
|
|
|
setMessage('')
|
|
|
|
|
|
}
|
|
|
|
|
|
oauthStatusRef.current = null
|
2026-02-06 20:50:49 +03:00
|
|
|
|
} 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()
|
|
|
|
|
|
setStats(data)
|
|
|
|
|
|
// Обновляем цели из ответа
|
|
|
|
|
|
if (data.steps?.goal) {
|
|
|
|
|
|
setGoals({
|
|
|
|
|
|
steps: data.steps.goal,
|
|
|
|
|
|
floors: data.floors.goal,
|
|
|
|
|
|
azm: data.azm.goal
|
|
|
|
|
|
})
|
|
|
|
|
|
setEditedGoals({
|
|
|
|
|
|
steps: data.steps.goal,
|
|
|
|
|
|
floors: data.floors.goal,
|
|
|
|
|
|
azm: data.azm.goal
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
} 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>
|
|
|
|
|
|
)}
|
2026-02-06 21:15:08 +03:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-02-06 20:50:49 +03:00
|
|
|
|
|
|
|
|
|
|
{/* Статистика */}
|
|
|
|
|
|
<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, stats.steps.goal.min, stats.steps.goal.max)}`}>
|
|
|
|
|
|
{stats.steps.value.toLocaleString()} / {stats.steps.goal.min}-{stats.steps.goal.max}
|
|
|
|
|
|
</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, stats.steps.goal.min, stats.steps.goal.max))}%` }}
|
|
|
|
|
|
></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, stats.floors.goal.min, stats.floors.goal.max)}`}>
|
|
|
|
|
|
{stats.floors.value} / {stats.floors.goal.min}-{stats.floors.goal.max}
|
|
|
|
|
|
</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, stats.floors.goal.min, stats.floors.goal.max))}%` }}
|
|
|
|
|
|
></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, stats.azm.goal.min, stats.azm.goal.max)}`}>
|
|
|
|
|
|
{stats.azm.value} / {stats.azm.goal.min}-{stats.azm.goal.max}
|
|
|
|
|
|
</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, stats.azm.goal.min, stats.azm.goal.max))}%` }}
|
|
|
|
|
|
></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>
|
2026-02-06 21:15:08 +03:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-02-06 20:50:49 +03:00
|
|
|
|
<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
|