Добавлена интеграция с Fitbit
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m25s

This commit is contained in:
poignatov
2026-02-06 20:50:49 +03:00
parent f1c590de43
commit dfccba4e55
13 changed files with 1711 additions and 21 deletions

View File

@@ -86,6 +86,17 @@ server {
add_header Cache-Control "public, immutable";
}
# Статические HTML страницы (Terms и Privacy)
location = /terms {
try_files /terms.html =404;
add_header Cache-Control "public, max-age=3600";
}
location = /privacy {
try_files /privacy.html =404;
add_header Cache-Control "public, max-age=3600";
}
# Handle React Router (SPA)
location / {
try_files $uri $uri/ /index.html;

View File

@@ -1,6 +1,6 @@
{
"name": "play-life-web",
"version": "4.26.0",
"version": "4.26.1",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -0,0 +1,159 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Политика конфиденциальности - Play Life</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #333;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 {
color: #4f46e5;
margin-bottom: 30px;
font-size: 2em;
}
h2 {
color: #1f2937;
margin-top: 30px;
margin-bottom: 15px;
font-size: 1.5em;
}
p {
margin-bottom: 15px;
text-align: justify;
}
ul {
margin-left: 20px;
margin-bottom: 15px;
}
li {
margin-bottom: 8px;
}
.last-updated {
color: #6b7280;
font-size: 0.9em;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
}
</style>
</head>
<body>
<div class="container">
<h1>Политика конфиденциальности</h1>
<p><strong>Дата вступления в силу:</strong> 1 января 2024 года</p>
<h2>1. Введение</h2>
<p>Play Life ("мы", "наш", "нас") уважает вашу конфиденциальность и обязуется защищать ваши личные данные. Настоящая Политика конфиденциальности объясняет, как мы собираем, используем, храним и защищаем вашу информацию при использовании нашего приложения.</p>
<h2>2. Собираемая информация</h2>
<p>Мы собираем следующие типы информации:</p>
<h3>2.1. Информация, предоставляемая вами</h3>
<ul>
<li>Имя и адрес электронной почты при регистрации</li>
<li>Данные о ваших проектах, задачах и целях</li>
<li>Списки желаний и связанная информация</li>
<li>Словари и слова для изучения</li>
</ul>
<h3>2.2. Информация из интеграций</h3>
<ul>
<li><strong>Todoist:</strong> Информация о ваших задачах (только при подключении интеграции)</li>
<li><strong>Telegram:</strong> ID пользователя Telegram (только при подключении бота)</li>
<li><strong>Fitbit:</strong> Данные о физической активности, включая шаги, этажи и активные зоны минут (только при подключении интеграции)</li>
</ul>
<h3>2.3. Автоматически собираемая информация</h3>
<ul>
<li>Данные об использовании приложения (логи доступа, ошибки)</li>
<li>Техническая информация (версия браузера, тип устройства)</li>
</ul>
<h2>3. Использование информации</h2>
<p>Мы используем собранную информацию для:</p>
<ul>
<li>Предоставления и улучшения функциональности приложения</li>
<li>Обработки ваших запросов и транзакций</li>
<li>Отправки уведомлений и обновлений (если вы подписаны)</li>
<li>Обеспечения безопасности и предотвращения мошенничества</li>
<li>Соблюдения юридических обязательств</li>
</ul>
<h2>4. Хранение данных</h2>
<p>Ваши данные хранятся на защищенных серверах. Мы применяем соответствующие технические и организационные меры для защиты ваших данных от несанкционированного доступа, изменения, раскрытия или уничтожения.</p>
<h2>5. Обмен данными</h2>
<p>Мы не продаем и не передаем ваши личные данные третьим лицам, за исключением:</p>
<ul>
<li>Когда это необходимо для предоставления услуг (например, интеграции с Fitbit, Todoist, Telegram)</li>
<li>Когда это требуется по закону или по запросу государственных органов</li>
<li>С вашего явного согласия</li>
</ul>
<h2>6. Интеграции с третьими сторонами</h2>
<p>При использовании интеграций с Fitbit, Todoist или Telegram, ваши данные могут передаваться этим сервисам в соответствии с их политиками конфиденциальности:</p>
<ul>
<li><strong>Fitbit:</strong> Мы получаем доступ только к данным о физической активности (шаги, этажи, активные зоны минут) с вашего явного разрешения через OAuth.</li>
<li><strong>Todoist:</strong> Мы получаем доступ только к информации о завершенных задачах для синхронизации с вашими проектами.</li>
<li><strong>Telegram:</strong> Мы получаем только ваш Telegram ID для связи с ботом.</li>
</ul>
<h2>7. Ваши права</h2>
<p>Вы имеете право:</p>
<ul>
<li>Получить доступ к вашим личным данным</li>
<li>Исправить неточные данные</li>
<li>Удалить ваши данные</li>
<li>Отозвать согласие на обработку данных</li>
<li>Ограничить обработку ваших данных</li>
<li>Получить копию ваших данных в структурированном формате</li>
</ul>
<p>Для осуществления этих прав свяжитесь с нами через приложение.</p>
<h2>8. Cookies и аналогичные технологии</h2>
<p>Мы используем cookies и аналогичные технологии для улучшения работы приложения, анализа использования и персонализации контента. Вы можете управлять настройками cookies в вашем браузере.</p>
<h2>9. Безопасность</h2>
<p>Мы применяем различные меры безопасности для защиты ваших данных, включая шифрование, контроль доступа и регулярные проверки безопасности. Однако ни один метод передачи через Интернет или электронного хранения не является на 100% безопасным.</p>
<h2>10. Хранение данных</h2>
<p>Мы храним ваши данные до тех пор, пока это необходимо для предоставления услуг или до тех пор, пока вы не попросите нас удалить их. Некоторые данные могут храниться дольше в соответствии с требованиями законодательства.</p>
<h2>11. Дети</h2>
<p>Наше приложение не предназначено для лиц младше 13 лет. Мы сознательно не собираем личную информацию от детей младше 13 лет.</p>
<h2>12. Изменения в политике</h2>
<p>Мы можем периодически обновлять настоящую Политику конфиденциальности. Мы уведомим вас о любых существенных изменениях, разместив новую политику на этой странице и обновив дату "Последнее обновление".</p>
<h2>13. Контактная информация</h2>
<p>Если у вас есть вопросы или запросы относительно настоящей Политики конфиденциальности или обработки ваших данных, пожалуйста, свяжитесь с нами через приложение.</p>
<h2>14. Применимое законодательство</h2>
<p>Настоящая Политика конфиденциальности регулируется законодательством Российской Федерации, включая Федеральный закон "О персональных данных" № 152-ФЗ.</p>
<div class="last-updated">
<p><strong>Последнее обновление:</strong> 1 января 2024 года</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,128 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Условия использования - Play Life</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #333;
background: #f5f5f5;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 {
color: #4f46e5;
margin-bottom: 30px;
font-size: 2em;
}
h2 {
color: #1f2937;
margin-top: 30px;
margin-bottom: 15px;
font-size: 1.5em;
}
p {
margin-bottom: 15px;
text-align: justify;
}
ul {
margin-left: 20px;
margin-bottom: 15px;
}
li {
margin-bottom: 8px;
}
.last-updated {
color: #6b7280;
font-size: 0.9em;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
}
</style>
</head>
<body>
<div class="container">
<h1>Условия использования</h1>
<p><strong>Дата вступления в силу:</strong> 1 января 2024 года</p>
<h2>1. Принятие условий</h2>
<p>Используя приложение Play Life, вы соглашаетесь с настоящими Условиями использования. Если вы не согласны с какими-либо условиями, пожалуйста, не используйте наше приложение.</p>
<h2>2. Описание сервиса</h2>
<p>Play Life — это приложение для отслеживания продуктивности и личных целей, которое позволяет пользователям:</p>
<ul>
<li>Отслеживать прогресс по проектам и задачам</li>
<li>Управлять списками желаний</li>
<li>Изучать слова и создавать словари</li>
<li>Интегрироваться с внешними сервисами (Todoist, Telegram, Fitbit)</li>
</ul>
<h2>3. Регистрация и учетные записи</h2>
<p>Для использования некоторых функций приложения требуется создание учетной записи. Вы обязуетесь:</p>
<ul>
<li>Предоставлять точную и актуальную информацию</li>
<li>Поддерживать безопасность вашей учетной записи</li>
<li>Нести ответственность за все действия, совершенные под вашей учетной записью</li>
<li>Немедленно уведомлять нас о любом несанкционированном использовании</li>
</ul>
<h2>4. Использование сервиса</h2>
<p>Вы соглашаетесь использовать Play Life только в законных целях и не будете:</p>
<ul>
<li>Нарушать какие-либо применимые законы или нормативные акты</li>
<li>Передавать вредоносное программное обеспечение или код</li>
<li>Пытаться получить несанкционированный доступ к сервису</li>
<li>Использовать сервис для спама или рассылки нежелательных сообщений</li>
<li>Нарушать права интеллектуальной собственности других лиц</li>
</ul>
<h2>5. Интеграции с третьими сторонами</h2>
<p>Play Life может интегрироваться с внешними сервисами (Todoist, Telegram, Fitbit). Использование этих интеграций регулируется условиями использования соответствующих сервисов. Мы не несем ответственности за действия или политики этих третьих сторон.</p>
<h2>6. Интеллектуальная собственность</h2>
<p>Все материалы, содержащиеся в Play Life, включая, но не ограничиваясь текстом, графикой, логотипами, иконками, изображениями, являются собственностью Play Life или их соответствующих владельцев и защищены законами об авторском праве.</p>
<h2>7. Конфиденциальность</h2>
<p>Использование ваших личных данных регулируется нашей <a href="/privacy.html">Политикой конфиденциальности</a>. Используя Play Life, вы соглашаетесь с обработкой ваших данных в соответствии с этой политикой.</p>
<h2>8. Отказ от ответственности</h2>
<p>Play Life предоставляется "как есть" без каких-либо гарантий, явных или подразумеваемых. Мы не гарантируем, что сервис будет бесперебойным, безопасным или безошибочным.</p>
<h2>9. Ограничение ответственности</h2>
<p>В максимальной степени, разрешенной законом, Play Life не несет ответственности за любые прямые, косвенные, случайные, особые или последующие убытки, возникающие в результате использования или невозможности использования сервиса.</p>
<h2>10. Изменения в условиях</h2>
<p>Мы оставляем за собой право изменять настоящие Условия использования в любое время. Изменения вступают в силу с момента их публикации. Продолжение использования сервиса после внесения изменений означает ваше согласие с новыми условиями.</p>
<h2>11. Прекращение использования</h2>
<p>Мы можем приостановить или прекратить ваш доступ к сервису в любое время, с уведомлением или без него, по любой причине, включая нарушение настоящих Условий использования.</p>
<h2>12. Применимое право</h2>
<p>Настоящие Условия использования регулируются и толкуются в соответствии с законодательством Российской Федерации.</p>
<h2>13. Контактная информация</h2>
<p>Если у вас есть вопросы относительно настоящих Условий использования, пожалуйста, свяжитесь с нами через приложение.</p>
<div class="last-updated">
<p><strong>Последнее обновление:</strong> 1 января 2024 года</p>
</div>
</div>
</body>
</html>

View File

@@ -16,6 +16,7 @@ import BoardForm from './components/BoardForm'
import BoardJoinPreview from './components/BoardJoinPreview'
import TodoistIntegration from './components/TodoistIntegration'
import TelegramIntegration from './components/TelegramIntegration'
import FitbitIntegration from './components/FitbitIntegration'
import Tracking from './components/Tracking'
import TrackingAccess from './components/TrackingAccess'
import TrackingInviteAccept from './components/TrackingInviteAccept'
@@ -29,24 +30,13 @@ const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b'
// Определяем основные табы (без крестика) и глубокие табы (с крестиком)
const mainTabs = ['current', 'tasks', 'wishlist', 'profile']
const deepTabs = ['add-words', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'words', 'dictionaries', 'todoist-integration', 'telegram-integration', 'full', 'priorities', 'tracking', 'tracking-access', 'tracking-invite']
const deepTabs = ['add-words', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'words', 'dictionaries', 'todoist-integration', 'telegram-integration', 'fitbit-integration', 'full', 'priorities', 'tracking', 'tracking-access', 'tracking-invite']
function AppContent() {
const { authFetch, isAuthenticated, loading: authLoading } = useAuth()
const prevIsAuthenticatedRef = useRef(null)
// Show loading while checking auth
if (authLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
<div className="text-white text-xl">Загрузка...</div>
</div>
)
}
// Show auth screen if not authenticated
if (!isAuthenticated) {
return <AuthScreen />
}
// Все хуки должны быть объявлены до условных возвратов
const [activeTab, setActiveTab] = useState('current')
const [selectedProject, setSelectedProject] = useState(null)
const [loadedTabs, setLoadedTabs] = useState({
@@ -67,6 +57,7 @@ function AppContent() {
profile: false,
'todoist-integration': false,
'telegram-integration': false,
'fitbit-integration': false,
tracking: false,
'tracking-access': false,
'tracking-invite': false,
@@ -91,6 +82,7 @@ function AppContent() {
profile: false,
'todoist-integration': false,
'telegram-integration': false,
'fitbit-integration': false,
tracking: false,
'tracking-access': false,
'tracking-invite': false,
@@ -147,6 +139,36 @@ function AppContent() {
// Восстанавливаем последний выбранный таб после перезагрузки
const [isInitialized, setIsInitialized] = useState(false)
// Переключение на экран прогрессии после успешной авторизации
useEffect(() => {
// Обновляем ref только после того, как authLoading стал false
if (!authLoading) {
const wasNotAuthenticated = prevIsAuthenticatedRef.current === false
prevIsAuthenticatedRef.current = isAuthenticated
// Проверяем, что это новая авторизация (переход с false на true)
// и что инициализация уже завершена (чтобы не конфликтовать с восстановлением из URL/localStorage)
if (wasNotAuthenticated && isAuthenticated && isInitialized) {
// Переключаемся на экран прогресса только если нет таба в URL
const urlParams = new URLSearchParams(window.location.search)
const tabFromUrl = urlParams.get('tab')
// Если в URL нет таба, переключаемся на current (экран прогресса)
if (!tabFromUrl) {
setActiveTab('current')
setLoadedTabs(prev => ({ ...prev, current: true }))
// Очищаем URL, так как current - это основной таб
const url = new URL(window.location)
url.searchParams.delete('tab')
url.searchParams.forEach((value, key) => {
url.searchParams.delete(key)
})
window.history.replaceState({ tab: 'current' }, '', url)
}
}
}
}, [isAuthenticated, isInitialized, authLoading])
// Инициализация из URL (только для глубоких табов) или localStorage
useEffect(() => {
if (isInitialized) return
@@ -183,7 +205,7 @@ function AppContent() {
// Проверяем URL только для глубоких табов
const urlParams = new URLSearchParams(window.location.search)
const tabFromUrl = urlParams.get('tab')
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'profile', 'todoist-integration', 'telegram-integration', 'tracking', 'tracking-access', 'tracking-invite']
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'profile', 'todoist-integration', 'telegram-integration', 'fitbit-integration', 'tracking', 'tracking-access', 'tracking-invite']
if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl)) {
// Если в URL есть глубокий таб, восстанавливаем его
@@ -494,6 +516,7 @@ function AppContent() {
profile: false,
'todoist-integration': false,
'telegram-integration': false,
'fitbit-integration': false,
tracking: false,
'tracking-access': false,
'tracking-invite': false,
@@ -507,6 +530,10 @@ function AppContent() {
todayEntries: null,
})
// Refs для отслеживания активного таба
const prevActiveTabRef = useRef(null)
const lastLoadedTabRef = useRef(null) // Отслеживаем последний загруженный таб, чтобы избежать двойной загрузки
// Обновляем ref при изменении данных
useEffect(() => {
cacheRef.current.current = currentWeekData
@@ -886,9 +913,6 @@ function AppContent() {
}
// Загружаем данные при открытии таба (когда таб становится активным)
const prevActiveTabRef = useRef(null)
const lastLoadedTabRef = useRef(null) // Отслеживаем последний загруженный таб, чтобы избежать двойной загрузки
useEffect(() => {
if (!activeTab || !loadedTabs[activeTab]) return
@@ -946,8 +970,23 @@ function AppContent() {
}
}, [activeTab])
// Show loading while checking auth
if (authLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
<div className="text-white text-xl">Загрузка...</div>
</div>
)
}
// Show auth screen if not authenticated
if (!isAuthenticated) {
prevIsAuthenticatedRef.current = false
return <AuthScreen />
}
// Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов)
const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'task-form' || activeTab === 'wishlist-form' || activeTab === 'wishlist-detail' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'full' || activeTab === 'priorities' || activeTab === 'words' || activeTab === 'dictionaries' || activeTab === 'tracking' || activeTab === 'tracking-access' || activeTab === 'tracking-invite'
const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'task-form' || activeTab === 'wishlist-form' || activeTab === 'wishlist-detail' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'fitbit-integration' || activeTab === 'full' || activeTab === 'priorities' || activeTab === 'words' || activeTab === 'dictionaries' || activeTab === 'tracking' || activeTab === 'tracking-access' || activeTab === 'tracking-invite'
// Функция для получения классов скролл-контейнера для каждого таба
// Каждый таб имеет свой изолированный скролл-контейнер для автоматического сохранения позиции скролла
@@ -1209,6 +1248,14 @@ function AppContent() {
</div>
)}
{loadedTabs['fitbit-integration'] && (
<div className={getTabContainerClasses('fitbit-integration')}>
<div className={getInnerContainerClasses('fitbit-integration')}>
<FitbitIntegration onNavigate={handleNavigate} />
</div>
</div>
)}
{loadedTabs.tracking && (
<div className={getTabContainerClasses('tracking')}>
<div className={getInnerContainerClasses('tracking')}>

View File

@@ -0,0 +1,480 @@
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 [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)
useEffect(() => {
checkStatus()
// Проверяем URL параметры для сообщений
const params = new URLSearchParams(window.location.search)
const integration = params.get('integration')
const status = params.get('status')
if (integration === 'fitbit') {
if (status === 'connected') {
setMessage('✅ Fitbit успешно подключен!')
// Очищаем URL параметры
window.history.replaceState({}, '', window.location.pathname)
} else if (status === 'error') {
const errorMsg = params.get('message') || 'Произошла ошибка'
setToastMessage({ text: errorMsg, type: 'error' })
window.history.replaceState({}, '', window.location.pathname)
}
}
}, [])
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)
}
} 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>
)}
{/* Статистика */}
<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>
<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

View File

@@ -8,6 +8,7 @@ function Profile({ onNavigate }) {
const integrations = [
{ id: 'todoist-integration', name: 'TODOist' },
{ id: 'telegram-integration', name: 'Telegram' },
{ id: 'fitbit-integration', name: 'Fitbit' },
]
const handleLogout = async () => {