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:
204
supabase/migrations/001_initial_schema.sql
Normal file
204
supabase/migrations/001_initial_schema.sql
Normal file
@@ -0,0 +1,204 @@
|
||||
-- Supabase Schema for TabataFit
|
||||
-- Run this in your Supabase SQL Editor
|
||||
|
||||
-- Enable UUID extension
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Trainers table
|
||||
CREATE TABLE trainers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
specialty TEXT NOT NULL,
|
||||
color TEXT NOT NULL,
|
||||
avatar_url TEXT,
|
||||
workout_count INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Workouts table
|
||||
CREATE TABLE workouts (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
trainer_id TEXT NOT NULL REFERENCES trainers(id) ON DELETE CASCADE,
|
||||
category TEXT NOT NULL CHECK (category IN ('full-body', 'core', 'upper-body', 'lower-body', 'cardio')),
|
||||
level TEXT NOT NULL CHECK (level IN ('Beginner', 'Intermediate', 'Advanced')),
|
||||
duration INTEGER NOT NULL CHECK (duration IN (4, 8, 12, 20)),
|
||||
calories INTEGER NOT NULL,
|
||||
rounds INTEGER NOT NULL,
|
||||
prep_time INTEGER NOT NULL DEFAULT 10,
|
||||
work_time INTEGER NOT NULL DEFAULT 20,
|
||||
rest_time INTEGER NOT NULL DEFAULT 10,
|
||||
equipment TEXT[] DEFAULT '{}',
|
||||
music_vibe TEXT NOT NULL CHECK (music_vibe IN ('electronic', 'hip-hop', 'pop', 'rock', 'chill')),
|
||||
exercises JSONB NOT NULL DEFAULT '[]',
|
||||
thumbnail_url TEXT,
|
||||
video_url TEXT,
|
||||
is_featured BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Collections table
|
||||
CREATE TABLE collections (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
icon TEXT NOT NULL,
|
||||
gradient TEXT[],
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Collection-Workout junction table
|
||||
CREATE TABLE collection_workouts (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
workout_id TEXT NOT NULL REFERENCES workouts(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
UNIQUE(collection_id, workout_id)
|
||||
);
|
||||
|
||||
-- Programs table
|
||||
CREATE TABLE programs (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
weeks INTEGER NOT NULL,
|
||||
workouts_per_week INTEGER NOT NULL,
|
||||
level TEXT NOT NULL CHECK (level IN ('Beginner', 'Intermediate', 'Advanced')),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Program-Workout junction table
|
||||
CREATE TABLE program_workouts (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
program_id TEXT NOT NULL REFERENCES programs(id) ON DELETE CASCADE,
|
||||
workout_id TEXT NOT NULL REFERENCES workouts(id) ON DELETE CASCADE,
|
||||
week_number INTEGER NOT NULL,
|
||||
day_number INTEGER NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
UNIQUE(program_id, week_number, day_number)
|
||||
);
|
||||
|
||||
-- Achievements table
|
||||
CREATE TABLE achievements (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
icon TEXT NOT NULL,
|
||||
requirement INTEGER NOT NULL,
|
||||
type TEXT NOT NULL CHECK (type IN ('workouts', 'streak', 'minutes', 'calories')),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Admin users table (for dashboard access)
|
||||
CREATE TABLE admin_users (
|
||||
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
email TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'editor' CHECK (role IN ('admin', 'editor')),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
last_login TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- Create storage buckets
|
||||
INSERT INTO storage.buckets (id, name, public) VALUES
|
||||
('videos', 'videos', true),
|
||||
('thumbnails', 'thumbnails', true),
|
||||
('avatars', 'avatars', true)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Set up storage policies
|
||||
CREATE POLICY "Public videos access"
|
||||
ON storage.objects FOR SELECT
|
||||
USING (bucket_id = 'videos');
|
||||
|
||||
CREATE POLICY "Admin videos upload"
|
||||
ON storage.objects FOR INSERT
|
||||
WITH CHECK (
|
||||
bucket_id = 'videos'
|
||||
AND EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid())
|
||||
);
|
||||
|
||||
CREATE POLICY "Public thumbnails access"
|
||||
ON storage.objects FOR SELECT
|
||||
USING (bucket_id = 'thumbnails');
|
||||
|
||||
CREATE POLICY "Admin thumbnails upload"
|
||||
ON storage.objects FOR INSERT
|
||||
WITH CHECK (
|
||||
bucket_id = 'thumbnails'
|
||||
AND EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid())
|
||||
);
|
||||
|
||||
CREATE POLICY "Public avatars access"
|
||||
ON storage.objects FOR SELECT
|
||||
USING (bucket_id = 'avatars');
|
||||
|
||||
CREATE POLICY "Admin avatars upload"
|
||||
ON storage.objects FOR INSERT
|
||||
WITH CHECK (
|
||||
bucket_id = 'avatars'
|
||||
AND EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid())
|
||||
);
|
||||
|
||||
-- Row Level Security policies
|
||||
ALTER TABLE trainers ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE workouts ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE collections ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE collection_workouts ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE programs ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE program_workouts ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE achievements ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE admin_users ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Public read access for all content
|
||||
CREATE POLICY "Public trainers read" ON trainers FOR SELECT USING (true);
|
||||
CREATE POLICY "Public workouts read" ON workouts FOR SELECT USING (true);
|
||||
CREATE POLICY "Public collections read" ON collections FOR SELECT USING (true);
|
||||
CREATE POLICY "Public collection_workouts read" ON collection_workouts FOR SELECT USING (true);
|
||||
CREATE POLICY "Public programs read" ON programs FOR SELECT USING (true);
|
||||
CREATE POLICY "Public program_workouts read" ON program_workouts FOR SELECT USING (true);
|
||||
CREATE POLICY "Public achievements read" ON achievements FOR SELECT USING (true);
|
||||
|
||||
-- Admin write access
|
||||
CREATE POLICY "Admin trainers all" ON trainers
|
||||
FOR ALL USING (EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid()));
|
||||
CREATE POLICY "Admin workouts all" ON workouts
|
||||
FOR ALL USING (EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid()));
|
||||
CREATE POLICY "Admin collections all" ON collections
|
||||
FOR ALL USING (EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid()));
|
||||
CREATE POLICY "Admin collection_workouts all" ON collection_workouts
|
||||
FOR ALL USING (EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid()));
|
||||
CREATE POLICY "Admin programs all" ON programs
|
||||
FOR ALL USING (EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid()));
|
||||
CREATE POLICY "Admin program_workouts all" ON program_workouts
|
||||
FOR ALL USING (EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid()));
|
||||
CREATE POLICY "Admin achievements all" ON achievements
|
||||
FOR ALL USING (EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid()));
|
||||
CREATE POLICY "Admin users manage" ON admin_users
|
||||
FOR ALL USING (EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid()));
|
||||
|
||||
-- Function to update updated_at timestamp
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Triggers for updated_at
|
||||
CREATE TRIGGER update_trainers_updated_at BEFORE UPDATE ON trainers
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_workouts_updated_at BEFORE UPDATE ON workouts
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_collections_updated_at BEFORE UPDATE ON collections
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_programs_updated_at BEFORE UPDATE ON programs
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_achievements_updated_at BEFORE UPDATE ON achievements
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
183
supabase/seed.ts
Normal file
183
supabase/seed.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* TabataFit Seed Script
|
||||
*
|
||||
* This script seeds your Supabase database with the initial data.
|
||||
* Run this after setting up your Supabase project.
|
||||
*
|
||||
* Usage:
|
||||
* 1. Set your Supabase credentials in .env
|
||||
* 2. Run: npx ts-node supabase/seed.ts
|
||||
*/
|
||||
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { WORKOUTS } from '../src/shared/data/workouts'
|
||||
import { TRAINERS } from '../src/shared/data/trainers'
|
||||
import { COLLECTIONS, PROGRAMS } from '../src/shared/data/collections'
|
||||
import { ACHIEVEMENTS } from '../src/shared/data/achievements'
|
||||
|
||||
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL
|
||||
const supabaseKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY
|
||||
|
||||
if (!supabaseUrl || !supabaseKey) {
|
||||
console.error('Missing Supabase credentials. Please set EXPO_PUBLIC_SUPABASE_URL and EXPO_PUBLIC_SUPABASE_ANON_KEY')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseKey)
|
||||
|
||||
async function seedTrainers() {
|
||||
console.log('Seeding trainers...')
|
||||
|
||||
const trainers = TRAINERS.map(t => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
specialty: t.specialty,
|
||||
color: t.color,
|
||||
avatar_url: t.avatarUrl || null,
|
||||
workout_count: t.workoutCount,
|
||||
}))
|
||||
|
||||
const { error } = await supabase.from('trainers').upsert(trainers)
|
||||
if (error) throw error
|
||||
console.log(`✅ Seeded ${trainers.length} trainers`)
|
||||
}
|
||||
|
||||
async function seedWorkouts() {
|
||||
console.log('Seeding workouts...')
|
||||
|
||||
const workouts = WORKOUTS.map(w => ({
|
||||
id: w.id,
|
||||
title: w.title,
|
||||
trainer_id: w.trainerId,
|
||||
category: w.category,
|
||||
level: w.level,
|
||||
duration: w.duration,
|
||||
calories: w.calories,
|
||||
rounds: w.rounds,
|
||||
prep_time: w.prepTime,
|
||||
work_time: w.workTime,
|
||||
rest_time: w.restTime,
|
||||
equipment: w.equipment,
|
||||
music_vibe: w.musicVibe,
|
||||
exercises: w.exercises,
|
||||
thumbnail_url: w.thumbnailUrl || null,
|
||||
video_url: w.videoUrl || null,
|
||||
is_featured: w.isFeatured || false,
|
||||
}))
|
||||
|
||||
const { error } = await supabase.from('workouts').upsert(workouts)
|
||||
if (error) throw error
|
||||
console.log(`✅ Seeded ${workouts.length} workouts`)
|
||||
}
|
||||
|
||||
async function seedCollections() {
|
||||
console.log('Seeding collections...')
|
||||
|
||||
const collections = COLLECTIONS.map(c => ({
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
description: c.description,
|
||||
icon: c.icon,
|
||||
gradient: c.gradient || null,
|
||||
}))
|
||||
|
||||
const { error } = await supabase.from('collections').upsert(collections)
|
||||
if (error) throw error
|
||||
|
||||
// Seed collection-workout relationships
|
||||
const collectionWorkouts = COLLECTIONS.flatMap(c =>
|
||||
c.workoutIds.map((workoutId, index) => ({
|
||||
collection_id: c.id,
|
||||
workout_id: workoutId,
|
||||
sort_order: index,
|
||||
}))
|
||||
)
|
||||
|
||||
const { error: linkError } = await supabase.from('collection_workouts').upsert(collectionWorkouts)
|
||||
if (linkError) throw linkError
|
||||
|
||||
console.log(`✅ Seeded ${collections.length} collections with ${collectionWorkouts.length} workout links`)
|
||||
}
|
||||
|
||||
async function seedPrograms() {
|
||||
console.log('Seeding programs...')
|
||||
|
||||
const programs = PROGRAMS.map(p => ({
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
weeks: p.weeks,
|
||||
workouts_per_week: p.workoutsPerWeek,
|
||||
level: p.level,
|
||||
}))
|
||||
|
||||
const { error } = await supabase.from('programs').upsert(programs)
|
||||
if (error) throw error
|
||||
|
||||
// Seed program-workout relationships
|
||||
let programWorkouts: { program_id: string; workout_id: string; week_number: number; day_number: number }[] = []
|
||||
|
||||
PROGRAMS.forEach(program => {
|
||||
let week = 1
|
||||
let day = 1
|
||||
program.workoutIds.forEach((workoutId, index) => {
|
||||
programWorkouts.push({
|
||||
program_id: program.id,
|
||||
workout_id: workoutId,
|
||||
week_number: week,
|
||||
day_number: day,
|
||||
})
|
||||
day++
|
||||
if (day > program.workoutsPerWeek) {
|
||||
day = 1
|
||||
week++
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const { error: linkError } = await supabase.from('program_workouts').upsert(programWorkouts)
|
||||
if (linkError) throw linkError
|
||||
|
||||
console.log(`✅ Seeded ${programs.length} programs with ${programWorkouts.length} workout links`)
|
||||
}
|
||||
|
||||
async function seedAchievements() {
|
||||
console.log('Seeding achievements...')
|
||||
|
||||
const achievements = ACHIEVEMENTS.map(a => ({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
description: a.description,
|
||||
icon: a.icon,
|
||||
requirement: a.requirement,
|
||||
type: a.type,
|
||||
}))
|
||||
|
||||
const { error } = await supabase.from('achievements').upsert(achievements)
|
||||
if (error) throw error
|
||||
console.log(`✅ Seeded ${achievements.length} achievements`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
console.log('🌱 Starting database seed...\n')
|
||||
|
||||
await seedTrainers()
|
||||
await seedWorkouts()
|
||||
await seedCollections()
|
||||
await seedPrograms()
|
||||
await seedAchievements()
|
||||
|
||||
console.log('\n✨ Database seeded successfully!')
|
||||
console.log('\nNext steps:')
|
||||
console.log('1. Set up storage buckets in Supabase Dashboard')
|
||||
console.log('2. Upload video and thumbnail files')
|
||||
console.log('3. Create an admin user in the admin_users table')
|
||||
console.log('4. Access the admin dashboard at /admin')
|
||||
} catch (error) {
|
||||
console.error('\n❌ Seeding failed:', error)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
Reference in New Issue
Block a user