feat: Apple Watch app + Paywall + Privacy Policy + rebranding
## Major Features - Apple Watch companion app (6 phases complete) - WatchConnectivity iPhone ↔ Watch - HealthKit integration (HR, calories) - SwiftUI premium UI - 9 complication types - Always-On Display support - Paywall screen with RevenueCat integration - Privacy Policy screen - App rebranding: tabatago → TabataFit - Bundle ID: com.millianlmx.tabatafit ## Changes - New: ios/TabataFit Watch App/ (complete Watch app) - New: app/paywall.tsx (subscription UI) - New: app/privacy.tsx (privacy policy) - New: src/features/watch/ (Watch sync hooks) - New: admin-web/ (admin dashboard) - Updated: app.json, package.json (branding) - Updated: profile.tsx (paywall + privacy links) - Updated: i18n translations (EN/FR/DE/ES) - New: app icon 1024x1024 ## Watch App Files - TabataFitWatchApp.swift (entry point) - ContentView.swift (premium UI) - HealthKitManager.swift (HR + calories) - WatchSessionManager.swift (communication) - Complications/ (WidgetKit) - UserDefaults+Shared.swift (data sharing)
This commit is contained in:
64
src/admin/components/AdminAuthProvider.tsx
Normal file
64
src/admin/components/AdminAuthProvider.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
|
||||
import { adminService } from '../services/adminService'
|
||||
|
||||
interface AdminAuthContextType {
|
||||
isAuthenticated: boolean
|
||||
isAdmin: boolean
|
||||
isLoading: boolean
|
||||
signIn: (email: string, password: string) => Promise<void>
|
||||
signOut: () => Promise<void>
|
||||
}
|
||||
|
||||
const AdminAuthContext = createContext<AdminAuthContextType | undefined>(undefined)
|
||||
|
||||
export function AdminAuthProvider({ children }: { children: ReactNode }) {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth()
|
||||
}, [])
|
||||
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const user = await adminService.getCurrentUser()
|
||||
if (user) {
|
||||
setIsAuthenticated(true)
|
||||
const adminStatus = await adminService.isAdmin()
|
||||
setIsAdmin(adminStatus)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const signIn = async (email: string, password: string) => {
|
||||
await adminService.signIn(email, password)
|
||||
setIsAuthenticated(true)
|
||||
const adminStatus = await adminService.isAdmin()
|
||||
setIsAdmin(adminStatus)
|
||||
}
|
||||
|
||||
const signOut = async () => {
|
||||
await adminService.signOut()
|
||||
setIsAuthenticated(false)
|
||||
setIsAdmin(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminAuthContext.Provider value={{ isAuthenticated, isAdmin, isLoading, signIn, signOut }}>
|
||||
{children}
|
||||
</AdminAuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAdminAuth() {
|
||||
const context = useContext(AdminAuthContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useAdminAuth must be used within an AdminAuthProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
282
src/admin/services/adminService.ts
Normal file
282
src/admin/services/adminService.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import { supabase, isSupabaseConfigured } from '../../shared/supabase'
|
||||
import type { Database } from '../../shared/supabase/database.types'
|
||||
|
||||
type WorkoutInsert = Database['public']['Tables']['workouts']['Insert']
|
||||
type WorkoutUpdate = Database['public']['Tables']['workouts']['Update']
|
||||
type TrainerInsert = Database['public']['Tables']['trainers']['Insert']
|
||||
type TrainerUpdate = Database['public']['Tables']['trainers']['Update']
|
||||
type CollectionInsert = Database['public']['Tables']['collections']['Insert']
|
||||
type CollectionWorkoutInsert = Database['public']['Tables']['collection_workouts']['Insert']
|
||||
|
||||
export class AdminService {
|
||||
private checkConfiguration(): boolean {
|
||||
if (!isSupabaseConfigured()) {
|
||||
throw new Error('Supabase is not configured. Please set EXPO_PUBLIC_SUPABASE_URL and EXPO_PUBLIC_SUPABASE_ANON_KEY')
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Workouts
|
||||
async createWorkout(workout: Omit<WorkoutInsert, 'id' | 'created_at' | 'updated_at'>): Promise<string> {
|
||||
this.checkConfiguration()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('workouts')
|
||||
.insert(workout)
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to create workout: ${error.message}`)
|
||||
}
|
||||
|
||||
return data.id
|
||||
}
|
||||
|
||||
async updateWorkout(id: string, workout: WorkoutUpdate): Promise<void> {
|
||||
this.checkConfiguration()
|
||||
|
||||
const { error } = await supabase
|
||||
.from('workouts')
|
||||
.update({ ...workout, updated_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to update workout: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async deleteWorkout(id: string): Promise<void> {
|
||||
this.checkConfiguration()
|
||||
|
||||
const { error } = await supabase
|
||||
.from('workouts')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to delete workout: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Trainers
|
||||
async createTrainer(trainer: Omit<TrainerInsert, 'id' | 'created_at' | 'updated_at'>): Promise<string> {
|
||||
this.checkConfiguration()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('trainers')
|
||||
.insert(trainer)
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to create trainer: ${error.message}`)
|
||||
}
|
||||
|
||||
return data.id
|
||||
}
|
||||
|
||||
async updateTrainer(id: string, trainer: TrainerUpdate): Promise<void> {
|
||||
this.checkConfiguration()
|
||||
|
||||
const { error } = await supabase
|
||||
.from('trainers')
|
||||
.update({ ...trainer, updated_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to update trainer: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTrainer(id: string): Promise<void> {
|
||||
this.checkConfiguration()
|
||||
|
||||
const { error } = await supabase
|
||||
.from('trainers')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to delete trainer: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Collections
|
||||
async createCollection(
|
||||
collection: Omit<CollectionInsert, 'id' | 'created_at' | 'updated_at'>,
|
||||
workoutIds: string[]
|
||||
): Promise<string> {
|
||||
this.checkConfiguration()
|
||||
|
||||
const { data: collectionData, error: collectionError } = await supabase
|
||||
.from('collections')
|
||||
.insert(collection)
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (collectionError) {
|
||||
throw new Error(`Failed to create collection: ${collectionError.message}`)
|
||||
}
|
||||
|
||||
const collectionWorkouts: CollectionWorkoutInsert[] = workoutIds.map((workoutId, index) => ({
|
||||
collection_id: collectionData.id,
|
||||
workout_id: workoutId,
|
||||
sort_order: index,
|
||||
}))
|
||||
|
||||
const { error: linkError } = await supabase
|
||||
.from('collection_workouts')
|
||||
.insert(collectionWorkouts)
|
||||
|
||||
if (linkError) {
|
||||
throw new Error(`Failed to link workouts to collection: ${linkError.message}`)
|
||||
}
|
||||
|
||||
return collectionData.id
|
||||
}
|
||||
|
||||
async updateCollectionWorkouts(collectionId: string, workoutIds: string[]): Promise<void> {
|
||||
this.checkConfiguration()
|
||||
|
||||
const { error: deleteError } = await supabase
|
||||
.from('collection_workouts')
|
||||
.delete()
|
||||
.eq('collection_id', collectionId)
|
||||
|
||||
if (deleteError) {
|
||||
throw new Error(`Failed to remove existing workouts: ${deleteError.message}`)
|
||||
}
|
||||
|
||||
const collectionWorkouts: CollectionWorkoutInsert[] = workoutIds.map((workoutId, index) => ({
|
||||
collection_id: collectionId,
|
||||
workout_id: workoutId,
|
||||
sort_order: index,
|
||||
}))
|
||||
|
||||
const { error: insertError } = await supabase
|
||||
.from('collection_workouts')
|
||||
.insert(collectionWorkouts)
|
||||
|
||||
if (insertError) {
|
||||
throw new Error(`Failed to add new workouts: ${insertError.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Storage
|
||||
async uploadVideo(file: File, path: string): Promise<string> {
|
||||
this.checkConfiguration()
|
||||
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('videos')
|
||||
.upload(path, file)
|
||||
|
||||
if (uploadError) {
|
||||
throw new Error(`Failed to upload video: ${uploadError.message}`)
|
||||
}
|
||||
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from('videos')
|
||||
.getPublicUrl(path)
|
||||
|
||||
return publicUrl
|
||||
}
|
||||
|
||||
async uploadThumbnail(file: File, path: string): Promise<string> {
|
||||
this.checkConfiguration()
|
||||
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('thumbnails')
|
||||
.upload(path, file)
|
||||
|
||||
if (uploadError) {
|
||||
throw new Error(`Failed to upload thumbnail: ${uploadError.message}`)
|
||||
}
|
||||
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from('thumbnails')
|
||||
.getPublicUrl(path)
|
||||
|
||||
return publicUrl
|
||||
}
|
||||
|
||||
async uploadAvatar(file: File, path: string): Promise<string> {
|
||||
this.checkConfiguration()
|
||||
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('avatars')
|
||||
.upload(path, file)
|
||||
|
||||
if (uploadError) {
|
||||
throw new Error(`Failed to upload avatar: ${uploadError.message}`)
|
||||
}
|
||||
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from('avatars')
|
||||
.getPublicUrl(path)
|
||||
|
||||
return publicUrl
|
||||
}
|
||||
|
||||
async deleteVideo(path: string): Promise<void> {
|
||||
this.checkConfiguration()
|
||||
|
||||
const { error } = await supabase.storage
|
||||
.from('videos')
|
||||
.remove([path])
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to delete video: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async deleteThumbnail(path: string): Promise<void> {
|
||||
this.checkConfiguration()
|
||||
|
||||
const { error } = await supabase.storage
|
||||
.from('thumbnails')
|
||||
.remove([path])
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to delete thumbnail: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Admin authentication
|
||||
async signIn(email: string, password: string): Promise<void> {
|
||||
this.checkConfiguration()
|
||||
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Authentication failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async signOut(): Promise<void> {
|
||||
await supabase.auth.signOut()
|
||||
}
|
||||
|
||||
async getCurrentUser() {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
return user
|
||||
}
|
||||
|
||||
async isAdmin(): Promise<boolean> {
|
||||
const user = await this.getCurrentUser()
|
||||
if (!user) return false
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('admin_users')
|
||||
.select('*')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
return !error && !!data
|
||||
}
|
||||
}
|
||||
|
||||
export const adminService = new AdminService()
|
||||
Reference in New Issue
Block a user