Добавлена интеграция с 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"
|
# Получить в Developer Console: "Client secret for webhooks"
|
||||||
TODOIST_WEBHOOK_SECRET=
|
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
|
# 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 запускает планировщик для автовыполнения задач в конце дня
|
// startEndOfDayTaskScheduler запускает планировщик для автовыполнения задач в конце дня
|
||||||
// каждый день в 23:55 в указанном часовом поясе
|
// каждый день в 23:55 в указанном часовом поясе
|
||||||
func (a *App) startEndOfDayTaskScheduler() {
|
func (a *App) startEndOfDayTaskScheduler() {
|
||||||
@@ -4078,6 +4144,9 @@ func main() {
|
|||||||
// Запускаем планировщик для автовыполнения задач в конце дня в 23:55
|
// Запускаем планировщик для автовыполнения задач в конце дня в 23:55
|
||||||
app.startEndOfDayTaskScheduler()
|
app.startEndOfDayTaskScheduler()
|
||||||
|
|
||||||
|
// Запускаем планировщик синхронизации Fitbit каждые 4 часа
|
||||||
|
app.startFitbitSyncScheduler()
|
||||||
|
|
||||||
r := mux.NewRouter()
|
r := mux.NewRouter()
|
||||||
|
|
||||||
// Public auth routes (no authentication required)
|
// 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/status", app.getTodoistStatusHandler).Methods("GET", "OPTIONS")
|
||||||
protected.HandleFunc("/api/integrations/todoist/disconnect", app.todoistDisconnectHandler).Methods("DELETE", "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
|
// Tasks
|
||||||
protected.HandleFunc("/api/tasks", app.getTasksHandler).Methods("GET", "OPTIONS")
|
protected.HandleFunc("/api/tasks", app.getTasksHandler).Methods("GET", "OPTIONS")
|
||||||
protected.HandleFunc("/api/tasks", app.createTaskHandler).Methods("POST", "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
|
// 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";
|
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)
|
# Handle React Router (SPA)
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "play-life-web",
|
"name": "play-life-web",
|
||||||
"version": "4.26.0",
|
"version": "4.26.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"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 BoardJoinPreview from './components/BoardJoinPreview'
|
||||||
import TodoistIntegration from './components/TodoistIntegration'
|
import TodoistIntegration from './components/TodoistIntegration'
|
||||||
import TelegramIntegration from './components/TelegramIntegration'
|
import TelegramIntegration from './components/TelegramIntegration'
|
||||||
|
import FitbitIntegration from './components/FitbitIntegration'
|
||||||
import Tracking from './components/Tracking'
|
import Tracking from './components/Tracking'
|
||||||
import TrackingAccess from './components/TrackingAccess'
|
import TrackingAccess from './components/TrackingAccess'
|
||||||
import TrackingInviteAccept from './components/TrackingInviteAccept'
|
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 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() {
|
function AppContent() {
|
||||||
const { authFetch, isAuthenticated, loading: authLoading } = useAuth()
|
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 [activeTab, setActiveTab] = useState('current')
|
||||||
const [selectedProject, setSelectedProject] = useState(null)
|
const [selectedProject, setSelectedProject] = useState(null)
|
||||||
const [loadedTabs, setLoadedTabs] = useState({
|
const [loadedTabs, setLoadedTabs] = useState({
|
||||||
@@ -67,6 +57,7 @@ function AppContent() {
|
|||||||
profile: false,
|
profile: false,
|
||||||
'todoist-integration': false,
|
'todoist-integration': false,
|
||||||
'telegram-integration': false,
|
'telegram-integration': false,
|
||||||
|
'fitbit-integration': false,
|
||||||
tracking: false,
|
tracking: false,
|
||||||
'tracking-access': false,
|
'tracking-access': false,
|
||||||
'tracking-invite': false,
|
'tracking-invite': false,
|
||||||
@@ -91,6 +82,7 @@ function AppContent() {
|
|||||||
profile: false,
|
profile: false,
|
||||||
'todoist-integration': false,
|
'todoist-integration': false,
|
||||||
'telegram-integration': false,
|
'telegram-integration': false,
|
||||||
|
'fitbit-integration': false,
|
||||||
tracking: false,
|
tracking: false,
|
||||||
'tracking-access': false,
|
'tracking-access': false,
|
||||||
'tracking-invite': false,
|
'tracking-invite': false,
|
||||||
@@ -147,6 +139,36 @@ function AppContent() {
|
|||||||
// Восстанавливаем последний выбранный таб после перезагрузки
|
// Восстанавливаем последний выбранный таб после перезагрузки
|
||||||
const [isInitialized, setIsInitialized] = useState(false)
|
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
|
// Инициализация из URL (только для глубоких табов) или localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isInitialized) return
|
if (isInitialized) return
|
||||||
@@ -183,7 +205,7 @@ function AppContent() {
|
|||||||
// Проверяем URL только для глубоких табов
|
// Проверяем URL только для глубоких табов
|
||||||
const urlParams = new URLSearchParams(window.location.search)
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
const tabFromUrl = urlParams.get('tab')
|
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)) {
|
if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl)) {
|
||||||
// Если в URL есть глубокий таб, восстанавливаем его
|
// Если в URL есть глубокий таб, восстанавливаем его
|
||||||
@@ -494,6 +516,7 @@ function AppContent() {
|
|||||||
profile: false,
|
profile: false,
|
||||||
'todoist-integration': false,
|
'todoist-integration': false,
|
||||||
'telegram-integration': false,
|
'telegram-integration': false,
|
||||||
|
'fitbit-integration': false,
|
||||||
tracking: false,
|
tracking: false,
|
||||||
'tracking-access': false,
|
'tracking-access': false,
|
||||||
'tracking-invite': false,
|
'tracking-invite': false,
|
||||||
@@ -507,6 +530,10 @@ function AppContent() {
|
|||||||
todayEntries: null,
|
todayEntries: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Refs для отслеживания активного таба
|
||||||
|
const prevActiveTabRef = useRef(null)
|
||||||
|
const lastLoadedTabRef = useRef(null) // Отслеживаем последний загруженный таб, чтобы избежать двойной загрузки
|
||||||
|
|
||||||
// Обновляем ref при изменении данных
|
// Обновляем ref при изменении данных
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
cacheRef.current.current = currentWeekData
|
cacheRef.current.current = currentWeekData
|
||||||
@@ -886,9 +913,6 @@ function AppContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Загружаем данные при открытии таба (когда таб становится активным)
|
// Загружаем данные при открытии таба (когда таб становится активным)
|
||||||
const prevActiveTabRef = useRef(null)
|
|
||||||
const lastLoadedTabRef = useRef(null) // Отслеживаем последний загруженный таб, чтобы избежать двойной загрузки
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeTab || !loadedTabs[activeTab]) return
|
if (!activeTab || !loadedTabs[activeTab]) return
|
||||||
|
|
||||||
@@ -946,8 +970,23 @@ function AppContent() {
|
|||||||
}
|
}
|
||||||
}, [activeTab])
|
}, [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 экранов)
|
// Определяем, нужно ли скрывать нижнюю панель (для 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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{loadedTabs['fitbit-integration'] && (
|
||||||
|
<div className={getTabContainerClasses('fitbit-integration')}>
|
||||||
|
<div className={getInnerContainerClasses('fitbit-integration')}>
|
||||||
|
<FitbitIntegration onNavigate={handleNavigate} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{loadedTabs.tracking && (
|
{loadedTabs.tracking && (
|
||||||
<div className={getTabContainerClasses('tracking')}>
|
<div className={getTabContainerClasses('tracking')}>
|
||||||
<div className={getInnerContainerClasses('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 = [
|
const integrations = [
|
||||||
{ id: 'todoist-integration', name: 'TODOist' },
|
{ id: 'todoist-integration', name: 'TODOist' },
|
||||||
{ id: 'telegram-integration', name: 'Telegram' },
|
{ id: 'telegram-integration', name: 'Telegram' },
|
||||||
|
{ id: 'fitbit-integration', name: 'Fitbit' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user