Добавлена интеграция с 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:
20
env.example
20
env.example
@@ -62,6 +62,26 @@ TODOIST_CLIENT_SECRET=
|
||||
# Получить в Developer Console: "Client secret for webhooks"
|
||||
TODOIST_WEBHOOK_SECRET=
|
||||
|
||||
# ============================================
|
||||
# Fitbit Integration Configuration
|
||||
# ============================================
|
||||
# Fitbit приложение для интеграции с Play Life
|
||||
# Настроить в: https://dev.fitbit.com/apps
|
||||
#
|
||||
# В настройках Fitbit приложения указать:
|
||||
# - OAuth 2.0 Application Type: Server
|
||||
# - Callback URL: <WEBHOOK_BASE_URL>/api/integrations/fitbit/oauth/callback
|
||||
# - Default Access Type: Read-Only
|
||||
# - Scopes: activity, profile
|
||||
# - Terms of Service URL: <WEBHOOK_BASE_URL>/terms
|
||||
# - Privacy Policy URL: <WEBHOOK_BASE_URL>/privacy
|
||||
|
||||
# Client ID Fitbit приложения
|
||||
FITBIT_CLIENT_ID=
|
||||
|
||||
# Client Secret Fitbit приложения
|
||||
FITBIT_CLIENT_SECRET=
|
||||
|
||||
# ============================================
|
||||
# Authentication Configuration
|
||||
# ============================================
|
||||
|
||||
@@ -3816,6 +3816,72 @@ func (a *App) startDailyReportScheduler() {
|
||||
// Планировщик будет работать в фоновом режиме
|
||||
}
|
||||
|
||||
// startFitbitSyncScheduler запускает планировщик для синхронизации данных Fitbit каждые 4 часа
|
||||
func (a *App) startFitbitSyncScheduler() {
|
||||
// Создаем планировщик в UTC (синхронизация не зависит от часового пояса пользователя)
|
||||
c := cron.New(cron.WithLocation(time.UTC))
|
||||
|
||||
// Добавляем задачу: каждые 4 часа
|
||||
// Cron выражение: "0 */4 * * *" означает: минута=0, каждый 4-й час, любой день месяца, любой месяц, любой день недели
|
||||
_, err := c.AddFunc("0 */4 * * *", func() {
|
||||
log.Printf("Scheduled task: Syncing Fitbit data for all users")
|
||||
if err := a.syncFitbitDataForAllUsers(); err != nil {
|
||||
log.Printf("Error in scheduled Fitbit sync: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error adding cron job for Fitbit sync: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Запускаем планировщик
|
||||
c.Start()
|
||||
log.Printf("Fitbit sync scheduler started: every 4 hours")
|
||||
|
||||
// Планировщик будет работать в фоновом режиме
|
||||
}
|
||||
|
||||
// syncFitbitDataForAllUsers синхронизирует данные Fitbit для всех подключенных пользователей
|
||||
func (a *App) syncFitbitDataForAllUsers() error {
|
||||
rows, err := a.DB.Query(`
|
||||
SELECT user_id FROM fitbit_integrations
|
||||
WHERE access_token IS NOT NULL
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get users: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var userIDs []int
|
||||
for rows.Next() {
|
||||
var userID int
|
||||
if err := rows.Scan(&userID); err != nil {
|
||||
log.Printf("Error scanning user_id: %v", err)
|
||||
continue
|
||||
}
|
||||
userIDs = append(userIDs, userID)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("error iterating users: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Syncing Fitbit data for %d users", len(userIDs))
|
||||
|
||||
// Синхронизируем данные за сегодня для каждого пользователя
|
||||
today := time.Now()
|
||||
for _, userID := range userIDs {
|
||||
if err := a.syncFitbitData(userID, today); err != nil {
|
||||
log.Printf("Failed to sync Fitbit data for user_id=%d: %v", userID, err)
|
||||
// Продолжаем синхронизацию для остальных пользователей
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// startEndOfDayTaskScheduler запускает планировщик для автовыполнения задач в конце дня
|
||||
// каждый день в 23:55 в указанном часовом поясе
|
||||
func (a *App) startEndOfDayTaskScheduler() {
|
||||
@@ -4078,6 +4144,9 @@ func main() {
|
||||
// Запускаем планировщик для автовыполнения задач в конце дня в 23:55
|
||||
app.startEndOfDayTaskScheduler()
|
||||
|
||||
// Запускаем планировщик синхронизации Fitbit каждые 4 часа
|
||||
app.startFitbitSyncScheduler()
|
||||
|
||||
r := mux.NewRouter()
|
||||
|
||||
// Public auth routes (no authentication required)
|
||||
@@ -4172,6 +4241,15 @@ func main() {
|
||||
protected.HandleFunc("/api/integrations/todoist/status", app.getTodoistStatusHandler).Methods("GET", "OPTIONS")
|
||||
protected.HandleFunc("/api/integrations/todoist/disconnect", app.todoistDisconnectHandler).Methods("DELETE", "OPTIONS")
|
||||
|
||||
// Fitbit OAuth endpoints
|
||||
protected.HandleFunc("/api/integrations/fitbit/oauth/connect", app.fitbitOAuthConnectHandler).Methods("GET")
|
||||
r.HandleFunc("/api/integrations/fitbit/oauth/callback", app.fitbitOAuthCallbackHandler).Methods("GET") // Публичный!
|
||||
protected.HandleFunc("/api/integrations/fitbit/status", app.getFitbitStatusHandler).Methods("GET", "OPTIONS")
|
||||
protected.HandleFunc("/api/integrations/fitbit/disconnect", app.fitbitDisconnectHandler).Methods("DELETE", "OPTIONS")
|
||||
protected.HandleFunc("/api/integrations/fitbit/goals", app.updateFitbitGoalsHandler).Methods("PUT", "OPTIONS")
|
||||
protected.HandleFunc("/api/integrations/fitbit/sync", app.fitbitSyncHandler).Methods("POST", "OPTIONS")
|
||||
protected.HandleFunc("/api/integrations/fitbit/stats", app.getFitbitStatsHandler).Methods("GET", "OPTIONS")
|
||||
|
||||
// Tasks
|
||||
protected.HandleFunc("/api/tasks", app.getTasksHandler).Methods("GET", "OPTIONS")
|
||||
protected.HandleFunc("/api/tasks", app.createTaskHandler).Methods("POST", "OPTIONS")
|
||||
@@ -10059,6 +10137,732 @@ func (a *App) todoistDisconnectHandler(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Fitbit OAuth handlers
|
||||
// ============================================
|
||||
|
||||
// generateFitbitOAuthState генерирует JWT state для Fitbit OAuth
|
||||
func generateFitbitOAuthState(userID int, jwtSecret []byte) (string, error) {
|
||||
claims := OAuthStateClaims{
|
||||
UserID: userID,
|
||||
Type: "fitbit_oauth",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), // 1 день
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(jwtSecret)
|
||||
}
|
||||
|
||||
// validateFitbitOAuthState проверяет и извлекает user_id из JWT state для Fitbit
|
||||
func validateFitbitOAuthState(stateString string, jwtSecret []byte) (int, error) {
|
||||
token, err := jwt.ParseWithClaims(stateString, &OAuthStateClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return jwtSecret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*OAuthStateClaims)
|
||||
if !ok || !token.Valid {
|
||||
return 0, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
if claims.Type != "fitbit_oauth" {
|
||||
return 0, fmt.Errorf("wrong token type")
|
||||
}
|
||||
|
||||
return claims.UserID, nil
|
||||
}
|
||||
|
||||
// exchangeFitbitCodeForToken обменивает OAuth code на access_token и refresh_token для Fitbit
|
||||
func exchangeFitbitCodeForToken(code, redirectURI, clientID, clientSecret string) (accessToken, refreshToken string, expiresIn int, err error) {
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", "authorization_code")
|
||||
data.Set("code", code)
|
||||
data.Set("redirect_uri", redirectURI)
|
||||
|
||||
req, err := http.NewRequest("POST", "https://api.fitbit.com/oauth2/token", strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return "", "", 0, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Fitbit требует Basic Auth для Server приложений
|
||||
auth := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSecret))
|
||||
req.Header.Set("Authorization", "Basic "+auth)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", 0, fmt.Errorf("failed to exchange code: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", 0, fmt.Errorf("token exchange failed (status %d): %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
TokenType string `json:"token_type"`
|
||||
UserID string `json:"user_id"`
|
||||
Error string `json:"error"`
|
||||
ErrorDesc string `json:"error_description"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||
return "", "", 0, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if result.Error != "" {
|
||||
return "", "", 0, fmt.Errorf("token exchange error: %s - %s", result.Error, result.ErrorDesc)
|
||||
}
|
||||
|
||||
return result.AccessToken, result.RefreshToken, result.ExpiresIn, nil
|
||||
}
|
||||
|
||||
// getFitbitUserInfo получает информацию о пользователе Fitbit
|
||||
func getFitbitUserInfo(accessToken string) (string, error) {
|
||||
req, err := http.NewRequest("GET", "https://api.fitbit.com/1/user/-/profile.json", nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get user info: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("get user info failed (status %d): %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
User struct {
|
||||
EncodedID string `json:"encodedId"`
|
||||
} `json:"user"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if result.User.EncodedID == "" {
|
||||
return "", fmt.Errorf("user ID not found in response")
|
||||
}
|
||||
|
||||
return result.User.EncodedID, nil
|
||||
}
|
||||
|
||||
// refreshFitbitToken обновляет access_token используя refresh_token
|
||||
func refreshFitbitToken(refreshToken, clientID, clientSecret string) (accessToken, newRefreshToken string, expiresIn int, err error) {
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", "refresh_token")
|
||||
data.Set("refresh_token", refreshToken)
|
||||
|
||||
req, err := http.NewRequest("POST", "https://api.fitbit.com/oauth2/token", strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return "", "", 0, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
auth := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSecret))
|
||||
req.Header.Set("Authorization", "Basic "+auth)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", 0, fmt.Errorf("failed to refresh token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", 0, fmt.Errorf("token refresh failed (status %d): %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Error string `json:"error"`
|
||||
ErrorDesc string `json:"error_description"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||
return "", "", 0, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if result.Error != "" {
|
||||
return "", "", 0, fmt.Errorf("token refresh error: %s - %s", result.Error, result.ErrorDesc)
|
||||
}
|
||||
|
||||
return result.AccessToken, result.RefreshToken, result.ExpiresIn, nil
|
||||
}
|
||||
|
||||
// fitbitOAuthConnectHandler инициирует OAuth flow для Fitbit
|
||||
func (a *App) fitbitOAuthConnectHandler(w http.ResponseWriter, r *http.Request) {
|
||||
setCORSHeaders(w)
|
||||
|
||||
userID, ok := getUserIDFromContext(r)
|
||||
if !ok {
|
||||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
clientID := getEnv("FITBIT_CLIENT_ID", "")
|
||||
clientSecret := getEnv("FITBIT_CLIENT_SECRET", "")
|
||||
baseURL := getEnv("WEBHOOK_BASE_URL", "")
|
||||
|
||||
if clientID == "" || clientSecret == "" {
|
||||
sendErrorWithCORS(w, "FITBIT_CLIENT_ID and FITBIT_CLIENT_SECRET must be configured", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if baseURL == "" {
|
||||
sendErrorWithCORS(w, "WEBHOOK_BASE_URL must be configured", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
redirectURI := strings.TrimRight(baseURL, "/") + "/api/integrations/fitbit/oauth/callback"
|
||||
|
||||
state, err := generateFitbitOAuthState(userID, a.jwtSecret)
|
||||
if err != nil {
|
||||
log.Printf("Fitbit OAuth: failed to generate state: %v", err)
|
||||
sendErrorWithCORS(w, "Failed to generate OAuth state", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Fitbit OAuth URL с необходимыми scopes
|
||||
authURL := fmt.Sprintf(
|
||||
"https://www.fitbit.com/oauth2/authorize?response_type=code&client_id=%s&redirect_uri=%s&scope=activity%%20profile&state=%s",
|
||||
url.QueryEscape(clientID),
|
||||
url.QueryEscape(redirectURI),
|
||||
url.QueryEscape(state),
|
||||
)
|
||||
|
||||
log.Printf("Fitbit OAuth: returning auth URL for user_id=%d", userID)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"auth_url": authURL,
|
||||
})
|
||||
}
|
||||
|
||||
// fitbitOAuthCallbackHandler обрабатывает OAuth callback от Fitbit
|
||||
func (a *App) fitbitOAuthCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
frontendURL := getEnv("WEBHOOK_BASE_URL", "")
|
||||
redirectSuccess := frontendURL + "/?integration=fitbit&status=connected"
|
||||
redirectError := frontendURL + "/?integration=fitbit&status=error"
|
||||
|
||||
clientID := getEnv("FITBIT_CLIENT_ID", "")
|
||||
clientSecret := getEnv("FITBIT_CLIENT_SECRET", "")
|
||||
baseURL := getEnv("WEBHOOK_BASE_URL", "")
|
||||
|
||||
if clientID == "" || clientSecret == "" || baseURL == "" {
|
||||
log.Printf("Fitbit OAuth: missing configuration")
|
||||
http.Redirect(w, r, redirectError+"&message=config_error", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
redirectURI := strings.TrimRight(baseURL, "/") + "/api/integrations/fitbit/oauth/callback"
|
||||
|
||||
// Проверяем state
|
||||
state := r.URL.Query().Get("state")
|
||||
userID, err := validateFitbitOAuthState(state, a.jwtSecret)
|
||||
if err != nil {
|
||||
log.Printf("Fitbit OAuth: invalid state: %v", err)
|
||||
http.Redirect(w, r, redirectError+"&message=invalid_state", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем code
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
log.Printf("Fitbit OAuth: no code in callback")
|
||||
http.Redirect(w, r, redirectError+"&message=no_code", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
// Обмениваем code на токены
|
||||
accessToken, refreshToken, expiresIn, err := exchangeFitbitCodeForToken(code, redirectURI, clientID, clientSecret)
|
||||
if err != nil {
|
||||
log.Printf("Fitbit OAuth: token exchange failed: %v", err)
|
||||
http.Redirect(w, r, redirectError+"&message=token_exchange_failed", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем информацию о пользователе
|
||||
fitbitUserID, err := getFitbitUserInfo(accessToken)
|
||||
if err != nil {
|
||||
log.Printf("Fitbit OAuth: get user info failed: %v", err)
|
||||
http.Redirect(w, r, redirectError+"&message=user_info_failed", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Fitbit OAuth: user_id=%d connected fitbit_user_id=%s", userID, fitbitUserID)
|
||||
|
||||
// Вычисляем время истечения токена
|
||||
tokenExpiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
|
||||
// Сохраняем в БД
|
||||
_, err = a.DB.Exec(`
|
||||
INSERT INTO fitbit_integrations (user_id, fitbit_user_id, access_token, refresh_token, token_expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
fitbit_user_id = $2,
|
||||
access_token = $3,
|
||||
refresh_token = $4,
|
||||
token_expires_at = $5,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`, userID, fitbitUserID, accessToken, refreshToken, tokenExpiresAt)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Fitbit OAuth: DB error: %v", err)
|
||||
http.Redirect(w, r, redirectError+"&message=db_error", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
// Редирект на страницу интеграций
|
||||
http.Redirect(w, r, redirectSuccess, http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
// getFitbitStatusHandler возвращает статус подключения Fitbit
|
||||
func (a *App) getFitbitStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "OPTIONS" {
|
||||
setCORSHeaders(w)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
setCORSHeaders(w)
|
||||
|
||||
userID, ok := getUserIDFromContext(r)
|
||||
if !ok {
|
||||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var fitbitUserID sql.NullString
|
||||
var goalStepsMin, goalStepsMax, goalFloorsMin, goalFloorsMax, goalAzmMin, goalAzmMax sql.NullInt64
|
||||
err := a.DB.QueryRow(`
|
||||
SELECT fitbit_user_id, goal_steps_min, goal_steps_max, goal_floors_min, goal_floors_max, goal_azm_min, goal_azm_max
|
||||
FROM fitbit_integrations
|
||||
WHERE user_id = $1 AND access_token IS NOT NULL
|
||||
`, userID).Scan(&fitbitUserID, &goalStepsMin, &goalStepsMax, &goalFloorsMin, &goalFloorsMax, &goalAzmMin, &goalAzmMax)
|
||||
|
||||
if err == sql.ErrNoRows || !fitbitUserID.Valid {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"connected": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Failed to get status: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"connected": true,
|
||||
"goals": map[string]interface{}{
|
||||
"steps": map[string]interface{}{
|
||||
"min": goalStepsMin.Int64,
|
||||
"max": goalStepsMax.Int64,
|
||||
},
|
||||
"floors": map[string]interface{}{
|
||||
"min": goalFloorsMin.Int64,
|
||||
"max": goalFloorsMax.Int64,
|
||||
},
|
||||
"azm": map[string]interface{}{
|
||||
"min": goalAzmMin.Int64,
|
||||
"max": goalAzmMax.Int64,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// fitbitDisconnectHandler отключает интеграцию Fitbit
|
||||
func (a *App) fitbitDisconnectHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "OPTIONS" {
|
||||
setCORSHeaders(w)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
setCORSHeaders(w)
|
||||
|
||||
userID, ok := getUserIDFromContext(r)
|
||||
if !ok {
|
||||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := a.DB.Exec(`
|
||||
DELETE FROM fitbit_integrations WHERE user_id = $1
|
||||
`, userID)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Fitbit disconnect: DB error: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Failed to disconnect: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Fitbit disconnected for user_id=%d", userID)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Fitbit disconnected",
|
||||
})
|
||||
}
|
||||
|
||||
// updateFitbitGoalsHandler обновляет цели пользователя
|
||||
func (a *App) updateFitbitGoalsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "OPTIONS" {
|
||||
setCORSHeaders(w)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
setCORSHeaders(w)
|
||||
|
||||
userID, ok := getUserIDFromContext(r)
|
||||
if !ok {
|
||||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Steps map[string]int64 `json:"steps"`
|
||||
Floors map[string]int64 `json:"floors"`
|
||||
Azm map[string]int64 `json:"azm"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := a.DB.Exec(`
|
||||
UPDATE fitbit_integrations
|
||||
SET goal_steps_min = $1, goal_steps_max = $2,
|
||||
goal_floors_min = $3, goal_floors_max = $4,
|
||||
goal_azm_min = $5, goal_azm_max = $6,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = $7
|
||||
`, req.Steps["min"], req.Steps["max"],
|
||||
req.Floors["min"], req.Floors["max"],
|
||||
req.Azm["min"], req.Azm["max"],
|
||||
userID)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Fitbit update goals: DB error: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Failed to update goals: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Goals updated",
|
||||
})
|
||||
}
|
||||
|
||||
// getFitbitAccessToken получает актуальный access_token (обновляет если нужно)
|
||||
func (a *App) getFitbitAccessToken(userID int) (string, error) {
|
||||
var accessToken, refreshToken sql.NullString
|
||||
var tokenExpiresAt sql.NullTime
|
||||
|
||||
err := a.DB.QueryRow(`
|
||||
SELECT access_token, refresh_token, token_expires_at
|
||||
FROM fitbit_integrations
|
||||
WHERE user_id = $1
|
||||
`, userID).Scan(&accessToken, &refreshToken, &tokenExpiresAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return "", fmt.Errorf("fitbit integration not found")
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get tokens: %w", err)
|
||||
}
|
||||
|
||||
if !accessToken.Valid {
|
||||
return "", fmt.Errorf("access token not found")
|
||||
}
|
||||
|
||||
// Проверяем, не истек ли токен (с запасом 5 минут)
|
||||
if tokenExpiresAt.Valid && time.Now().Add(5*time.Minute).After(tokenExpiresAt.Time) {
|
||||
// Токен истек или скоро истечет, обновляем
|
||||
if !refreshToken.Valid {
|
||||
return "", fmt.Errorf("refresh token not found")
|
||||
}
|
||||
|
||||
clientID := getEnv("FITBIT_CLIENT_ID", "")
|
||||
clientSecret := getEnv("FITBIT_CLIENT_SECRET", "")
|
||||
if clientID == "" || clientSecret == "" {
|
||||
return "", fmt.Errorf("FITBIT_CLIENT_ID and FITBIT_CLIENT_SECRET must be configured")
|
||||
}
|
||||
|
||||
newAccessToken, newRefreshToken, expiresIn, err := refreshFitbitToken(refreshToken.String, clientID, clientSecret)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to refresh token: %w", err)
|
||||
}
|
||||
|
||||
// Обновляем токены в БД
|
||||
tokenExpiresAtNew := time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
_, err = a.DB.Exec(`
|
||||
UPDATE fitbit_integrations
|
||||
SET access_token = $1, refresh_token = $2, token_expires_at = $3, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = $4
|
||||
`, newAccessToken, newRefreshToken, tokenExpiresAtNew, userID)
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to update tokens: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Fitbit token refreshed for user_id=%d", userID)
|
||||
return newAccessToken, nil
|
||||
}
|
||||
|
||||
return accessToken.String, nil
|
||||
}
|
||||
|
||||
// syncFitbitData синхронизирует данные из Fitbit API для указанной даты
|
||||
func (a *App) syncFitbitData(userID int, date time.Time) error {
|
||||
accessToken, err := a.getFitbitAccessToken(userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get access token: %w", err)
|
||||
}
|
||||
|
||||
dateStr := date.Format("2006-01-02")
|
||||
|
||||
// Получаем данные активности за день
|
||||
activityURL := fmt.Sprintf("https://api.fitbit.com/1/user/-/activities/date/%s.json", dateStr)
|
||||
req, err := http.NewRequest("GET", activityURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get activity data: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("get activity data failed (status %d): %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var activityData struct {
|
||||
Summary struct {
|
||||
Steps int `json:"steps"`
|
||||
Floors int `json:"floors"`
|
||||
} `json:"summary"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(bodyBytes, &activityData); err != nil {
|
||||
return fmt.Errorf("failed to decode activity data: %w", err)
|
||||
}
|
||||
|
||||
// Получаем Active Zone Minutes
|
||||
azmURL := fmt.Sprintf("https://api.fitbit.com/1/user/-/activities/active-zone-minutes/date/%s/1d.json", dateStr)
|
||||
reqAZM, err := http.NewRequest("GET", azmURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create AZM request: %w", err)
|
||||
}
|
||||
|
||||
reqAZM.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
reqAZM.Header.Set("Accept", "application/json")
|
||||
|
||||
respAZM, err := client.Do(reqAZM)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get AZM data: %w", err)
|
||||
}
|
||||
defer respAZM.Body.Close()
|
||||
|
||||
bodyBytesAZM, _ := io.ReadAll(respAZM.Body)
|
||||
|
||||
var azmValue int
|
||||
if respAZM.StatusCode == http.StatusOK {
|
||||
var azmData struct {
|
||||
ActivitiesActiveZoneMinutes []struct {
|
||||
Value struct {
|
||||
ActiveZoneMinutes int `json:"activeZoneMinutes"`
|
||||
} `json:"value"`
|
||||
} `json:"activities-active-zone-minutes"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(bodyBytesAZM, &azmData); err == nil {
|
||||
if len(azmData.ActivitiesActiveZoneMinutes) > 0 {
|
||||
azmValue = azmData.ActivitiesActiveZoneMinutes[0].Value.ActiveZoneMinutes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Сохраняем данные в БД
|
||||
_, err = a.DB.Exec(`
|
||||
INSERT INTO fitbit_daily_stats (user_id, date, steps, floors, active_zone_minutes, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (user_id, date) DO UPDATE SET
|
||||
steps = $3,
|
||||
floors = $4,
|
||||
active_zone_minutes = $5,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`, userID, dateStr, activityData.Summary.Steps, activityData.Summary.Floors, azmValue)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save stats: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Fitbit data synced for user_id=%d, date=%s: steps=%d, floors=%d, azm=%d",
|
||||
userID, dateStr, activityData.Summary.Steps, activityData.Summary.Floors, azmValue)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// fitbitSyncHandler выполняет ручную синхронизацию данных Fitbit
|
||||
func (a *App) fitbitSyncHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "OPTIONS" {
|
||||
setCORSHeaders(w)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
setCORSHeaders(w)
|
||||
|
||||
userID, ok := getUserIDFromContext(r)
|
||||
if !ok {
|
||||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Синхронизируем данные за сегодня
|
||||
err := a.syncFitbitData(userID, time.Now())
|
||||
if err != nil {
|
||||
log.Printf("Fitbit sync error: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Sync failed: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Data synced successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// getFitbitStatsHandler возвращает статистику Fitbit за указанную дату
|
||||
func (a *App) getFitbitStatsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "OPTIONS" {
|
||||
setCORSHeaders(w)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
setCORSHeaders(w)
|
||||
|
||||
userID, ok := getUserIDFromContext(r)
|
||||
if !ok {
|
||||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем дату из query параметра (по умолчанию сегодня)
|
||||
dateStr := r.URL.Query().Get("date")
|
||||
if dateStr == "" {
|
||||
dateStr = time.Now().Format("2006-01-02")
|
||||
}
|
||||
|
||||
var steps, floors, azm sql.NullInt64
|
||||
err := a.DB.QueryRow(`
|
||||
SELECT steps, floors, active_zone_minutes
|
||||
FROM fitbit_daily_stats
|
||||
WHERE user_id = $1 AND date = $2
|
||||
`, userID, dateStr).Scan(&steps, &floors, &azm)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
// Данных нет, возвращаем нули
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"date": dateStr,
|
||||
"steps": 0,
|
||||
"floors": 0,
|
||||
"azm": 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Failed to get stats: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем цели пользователя
|
||||
var goalStepsMin, goalStepsMax, goalFloorsMin, goalFloorsMax, goalAzmMin, goalAzmMax sql.NullInt64
|
||||
err = a.DB.QueryRow(`
|
||||
SELECT goal_steps_min, goal_steps_max, goal_floors_min, goal_floors_max, goal_azm_min, goal_azm_max
|
||||
FROM fitbit_integrations
|
||||
WHERE user_id = $1
|
||||
`, userID).Scan(&goalStepsMin, &goalStepsMax, &goalFloorsMin, &goalFloorsMax, &goalAzmMin, &goalAzmMax)
|
||||
|
||||
if err != nil {
|
||||
// Если целей нет, используем значения по умолчанию
|
||||
goalStepsMin = sql.NullInt64{Int64: 8000, Valid: true}
|
||||
goalStepsMax = sql.NullInt64{Int64: 10000, Valid: true}
|
||||
goalFloorsMin = sql.NullInt64{Int64: 8, Valid: true}
|
||||
goalFloorsMax = sql.NullInt64{Int64: 10, Valid: true}
|
||||
goalAzmMin = sql.NullInt64{Int64: 22, Valid: true}
|
||||
goalAzmMax = sql.NullInt64{Int64: 44, Valid: true}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"date": dateStr,
|
||||
"steps": map[string]interface{}{
|
||||
"value": steps.Int64,
|
||||
"goal": map[string]interface{}{
|
||||
"min": goalStepsMin.Int64,
|
||||
"max": goalStepsMax.Int64,
|
||||
},
|
||||
},
|
||||
"floors": map[string]interface{}{
|
||||
"value": floors.Int64,
|
||||
"goal": map[string]interface{}{
|
||||
"min": goalFloorsMin.Int64,
|
||||
"max": goalFloorsMax.Int64,
|
||||
},
|
||||
},
|
||||
"azm": map[string]interface{}{
|
||||
"value": azm.Int64,
|
||||
"goal": map[string]interface{}{
|
||||
"min": goalAzmMin.Int64,
|
||||
"max": goalAzmMax.Int64,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Wishlist handlers
|
||||
// ============================================
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS fitbit_daily_stats;
|
||||
DROP TABLE IF EXISTS fitbit_integrations;
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Fitbit integrations table (depends on users)
|
||||
CREATE TABLE fitbit_integrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
fitbit_user_id VARCHAR(255),
|
||||
access_token TEXT,
|
||||
refresh_token TEXT,
|
||||
token_expires_at TIMESTAMP WITH TIME ZONE,
|
||||
goal_steps_min INTEGER DEFAULT 8000,
|
||||
goal_steps_max INTEGER DEFAULT 10000,
|
||||
goal_floors_min INTEGER DEFAULT 8,
|
||||
goal_floors_max INTEGER DEFAULT 10,
|
||||
goal_azm_min INTEGER DEFAULT 22,
|
||||
goal_azm_max INTEGER DEFAULT 44,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fitbit_integrations_user_id_unique UNIQUE (user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_fitbit_integrations_user_id ON fitbit_integrations(user_id);
|
||||
CREATE UNIQUE INDEX idx_fitbit_integrations_fitbit_user_id ON fitbit_integrations(fitbit_user_id) WHERE fitbit_user_id IS NOT NULL;
|
||||
|
||||
-- Fitbit daily stats table (depends on users and fitbit_integrations)
|
||||
CREATE TABLE fitbit_daily_stats (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
date DATE NOT NULL,
|
||||
steps INTEGER DEFAULT 0,
|
||||
floors INTEGER DEFAULT 0,
|
||||
active_zone_minutes INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fitbit_daily_stats_user_date_unique UNIQUE (user_id, date)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_fitbit_daily_stats_user_id ON fitbit_daily_stats(user_id);
|
||||
CREATE INDEX idx_fitbit_daily_stats_date ON fitbit_daily_stats(date);
|
||||
CREATE INDEX idx_fitbit_daily_stats_user_date ON fitbit_daily_stats(user_id, date);
|
||||
@@ -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