Добавлена интеграция с Fitbit
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m25s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m25s
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "play-life-web",
|
||||
"version": "4.26.0",
|
||||
"version": "4.26.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
159
play-life-web/public/privacy.html
Normal file
159
play-life-web/public/privacy.html
Normal 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>
|
||||
128
play-life-web/public/terms.html
Normal file
128
play-life-web/public/terms.html
Normal 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>
|
||||
@@ -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')}>
|
||||
|
||||
480
play-life-web/src/components/FitbitIntegration.jsx
Normal file
480
play-life-web/src/components/FitbitIntegration.jsx
Normal 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
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user