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:
Millian Lamiaux
2026-03-11 09:43:53 +01:00
parent f80798069b
commit 2ad7ae3a34
86 changed files with 19648 additions and 365 deletions

View File

@@ -0,0 +1 @@
../../.agents/skills/building-native-ui

4
.env Normal file
View File

@@ -0,0 +1,4 @@
# TabataFit Environment Variables
# Supabase Configuration
EXPO_PUBLIC_SUPABASE_URL=https://supabase.1000co.fr
EXPO_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzcyMjMzMjAwLCJleHAiOjE5Mjk5OTk2MDB9.SlYN046eGvUSObW0tFQHcMRUqFvtMqBLfFRlZliSx_w

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
# TabataFit Environment Variables
# Copy this file to .env and fill in your Supabase credentials
# Supabase Configuration
EXPO_PUBLIC_SUPABASE_URL=your_supabase_project_url
EXPO_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
# Admin Dashboard (optional - for admin authentication)
EXPO_PUBLIC_ADMIN_EMAIL=admin@tabatafit.app

182
SUPABASE_MUSIC_SETUP.md Normal file
View File

@@ -0,0 +1,182 @@
# Supabase Music Storage Setup
This guide walks you through setting up the Supabase Storage bucket for music tracks in TabataFit.
## Overview
TabataFit loads background music from Supabase Storage based on workout `musicVibe` values. The music service organizes tracks by vibe folders.
## Step 1: Create the Storage Bucket
1. Go to your Supabase Dashboard → Storage
2. Click **New bucket**
3. Name: `music`
4. Enable **Public access** (tracks are streamed to authenticated users)
5. Click **Create bucket**
## Step 2: Set Up Folder Structure
Create folders for each music vibe:
```
music/
├── electronic/
├── hip-hop/
├── pop/
├── rock/
└── chill/
```
### Via Dashboard:
1. Open the `music` bucket
2. Click **New folder** for each vibe
3. Name folders exactly as the `MusicVibe` type values
### Via SQL (optional):
```sql
-- Storage folders are virtual in Supabase
-- Just upload files with path prefixes like "electronic/track.mp3"
```
## Step 3: Upload Music Tracks
### Supported Formats
- MP3 (recommended)
- M4A (AAC)
- OGG (if needed)
### File Naming Convention
Name files with artist and title for best results:
```
Artist Name - Track Title.mp3
```
Examples:
```
Neon Dreams - Energy Pulse.mp3
Urban Flow - Street Heat.mp3
The Popstars - Summer Energy.mp3
```
### Upload via Dashboard:
1. Open a vibe folder (e.g., `electronic/`)
2. Click **Upload files**
3. Select your audio files
4. Ensure the path shows `music/electronic/`
### Upload via CLI:
```bash
# Install Supabase CLI if not already installed
npm install -g supabase
# Login
supabase login
# Link your project
supabase link --project-ref your-project-ref
# Upload tracks
supabase storage upload music/electronic/ "path/to/your/tracks/*.mp3"
```
## Step 4: Configure Storage Policies
### RLS Policy for Authenticated Users
Go to Supabase Dashboard → Storage → Policies → `music` bucket
Add these policies:
#### 1. Select Policy (Read Access)
```sql
CREATE POLICY "Allow authenticated users to read music"
ON storage.objects FOR SELECT
TO authenticated
USING (bucket_id = 'music');
```
#### 2. Insert Policy (Admin Upload Only)
```sql
CREATE POLICY "Allow admin uploads"
ON storage.objects FOR INSERT
TO authenticated
WITH CHECK (
bucket_id = 'music'
AND (auth.jwt() ->> 'role') = 'admin'
);
```
### Via Dashboard UI:
1. Go to **Storage****Policies**
2. Under `music` bucket, click **New policy**
3. Select **For SELECT** (get)
4. Allowed operation: **Authenticated**
5. Policy definition: `true` (or custom check)
## Step 5: Test the Setup
1. Start your Expo app: `npx expo start`
2. Start a workout with music enabled
3. Check console logs for:
```
[Music] Loaded X tracks for vibe: electronic
```
### Troubleshooting
**No tracks loading:**
- Check Supabase credentials in `.env`
- Verify folder names match `MusicVibe` type exactly
- Check Storage RLS policies allow read access
**Tracks not playing:**
- Ensure files are accessible (try signed URL in browser)
- Check audio format is supported by expo-av
- Verify CORS settings in Supabase
**CORS errors:**
Add to Supabase Dashboard → Settings → API → CORS:
```
app://*
http://localhost:8081
```
## Step 6: Environment Variables
Ensure your `.env` file has:
```env
EXPO_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
EXPO_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
```
## Music Vibes Reference
The app supports these vibe categories:
| Vibe | Description | Typical BPM |
|------|-------------|-------------|
| `electronic` | EDM, House, Techno | 128-140 |
| `hip-hop` | Rap, Trap, Beats | 85-110 |
| `pop` | Pop hits, Dance-pop | 100-130 |
| `rock` | Rock, Alternative | 120-160 |
| `chill` | Lo-fi, Ambient, Downtempo | 60-90 |
## Best Practices
1. **Track Duration**: 3-5 minutes ideal for Tabata workouts
2. **File Size**: Keep under 10MB for faster loading
3. **Bitrate**: 128-192kbps MP3 for good quality/size balance
4. **Loudness**: Normalize tracks to similar levels (-14 LUFS)
5. **Metadata**: Include ID3 tags for artist/title info
## Alternative: Local Development
If Supabase is not configured, the app uses mock tracks automatically. To force mock data, temporarily set invalid Supabase credentials.
## Next Steps
- [ ] Upload initial track library (5-10 tracks per vibe)
- [ ] Test on physical device
- [ ] Consider CDN for production scale
- [ ] Implement track favoriting/personal playlists

228
SUPABASE_SETUP.md Normal file
View File

@@ -0,0 +1,228 @@
# TabataFit Supabase Integration
This document explains how to set up and use the Supabase backend for TabataFit.
## Overview
TabataFit now uses Supabase as its backend for:
- **Database**: Storing workouts, trainers, collections, programs, and achievements
- **Storage**: Managing video files, thumbnails, and trainer avatars
- **Authentication**: Admin dashboard access control
- **Real-time**: Future support for live features
## Setup Instructions
### 1. Create Supabase Project
1. Go to [Supabase Dashboard](https://app.supabase.com)
2. Create a new project
3. Note your project URL and anon key
### 2. Configure Environment Variables
Create a `.env` file in the project root:
```bash
EXPO_PUBLIC_SUPABASE_URL=your_supabase_project_url
EXPO_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
```
### 3. Run Database Migrations
1. Go to your Supabase project's SQL Editor
2. Open `supabase/migrations/001_initial_schema.sql`
3. Run the entire script
This creates:
- All necessary tables (workouts, trainers, collections, programs, achievements)
- Storage buckets (videos, thumbnails, avatars)
- Row Level Security policies
- Triggers for auto-updating timestamps
### 4. Seed the Database
Run the seed script to populate your database with initial data:
```bash
npx ts-node supabase/seed.ts
```
This will import all 50 workouts, 5 trainers, 6 collections, 3 programs, and 8 achievements.
### 5. Set Up Admin User
1. Go to Supabase Dashboard → Authentication → Users
2. Create a new user with email/password
3. Go to SQL Editor and run:
```sql
INSERT INTO admin_users (id, email, role)
VALUES ('USER_UUID_HERE', 'admin@example.com', 'admin');
```
Replace `USER_UUID_HERE` with the actual user UUID from step 2.
### 6. Configure Storage
In Supabase Dashboard → Storage:
1. Verify buckets exist: `videos`, `thumbnails`, `avatars`
2. Set bucket privacy to public for all three
3. Configure CORS if needed for your domain
## Architecture
### Data Flow
```
┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐
│ React Native │────▶│ Supabase │────▶│ PostgreSQL │
│ Client │ │ Client │ │ Database │
└─────────────────┘ └──────────────┘ └─────────────────┘
│ ┌──────────────┐
└──────────────▶│ Admin │
│ Dashboard │
└──────────────┘
```
### Key Components
1. **Supabase Client** (`src/shared/supabase/`)
- `client.ts`: Configured Supabase client
- `database.types.ts`: TypeScript definitions for tables
2. **Data Service** (`src/shared/data/dataService.ts`)
- `SupabaseDataService`: Handles all database operations
- Falls back to mock data if Supabase is not configured
3. **React Hooks** (`src/shared/hooks/useSupabaseData.ts`)
- `useWorkouts`, `useTrainers`, `useCollections`, etc.
- Automatic loading states and error handling
4. **Admin Service** (`src/admin/services/adminService.ts`)
- CRUD operations for content management
- File upload/delete for storage
- Admin authentication
5. **Admin Dashboard** (`app/admin/`)
- `/admin/login`: Authentication screen
- `/admin`: Main dashboard with stats
- `/admin/workouts`: Manage workouts
- `/admin/trainers`: Manage trainers
- `/admin/collections`: Manage collections
- `/admin/media`: Storage management
## Database Schema
### Tables
| Table | Description |
|-------|-------------|
| `trainers` | Trainer profiles with colors and avatars |
| `workouts` | Workout definitions with exercises and metadata |
| `collections` | Curated workout collections |
| `collection_workouts` | Many-to-many link between collections and workouts |
| `programs` | Multi-week workout programs |
| `program_workouts` | Link between programs and workouts with week/day |
| `achievements` | User achievement definitions |
| `admin_users` | Admin dashboard access control |
### Storage Buckets
| Bucket | Purpose | Access |
|--------|---------|--------|
| `videos` | Workout videos | Public read, Admin write |
| `thumbnails` | Workout thumbnails | Public read, Admin write |
| `avatars` | Trainer avatars | Public read, Admin write |
## Using the App
### As a User
The app works seamlessly:
- If Supabase is configured: Loads data from the cloud
- If not configured: Falls back to local mock data
### As an Admin
1. Navigate to `/admin` in the app
2. Sign in with your admin credentials
3. Manage content through the dashboard:
- View all workouts, trainers, collections
- Delete items (create/edit coming soon)
- Upload media files
## Development
### Adding New Workouts
```typescript
import { adminService } from '@/src/admin/services/adminService'
await adminService.createWorkout({
title: 'New Workout',
trainer_id: 'emma',
category: 'full-body',
level: 'Beginner',
duration: 4,
calories: 45,
rounds: 8,
prep_time: 10,
work_time: 20,
rest_time: 10,
equipment: ['No equipment'],
music_vibe: 'electronic',
exercises: [
{ name: 'Jumping Jacks', duration: 20 },
// ...
],
})
```
### Uploading Media
```typescript
const videoUrl = await adminService.uploadVideo(file, 'workout-1.mp4')
const thumbnailUrl = await adminService.uploadThumbnail(file, 'workout-1.jpg')
```
## Troubleshooting
### "Supabase is not configured" Warning
This is expected if environment variables are not set. The app will use mock data.
### Authentication Errors
1. Verify admin user exists in `admin_users` table
2. Check that email/password match
3. Ensure user is confirmed in Supabase Auth
### Storage Upload Failures
1. Verify storage buckets exist
2. Check RLS policies allow admin uploads
3. Ensure file size is within limits
### Data Not Syncing
1. Check network connection
2. Verify Supabase URL and key are correct
3. Check browser console for errors
## Security Considerations
- Row Level Security (RLS) is enabled on all tables
- Public can only read content
- Only admin users can write content
- Storage buckets have public read access
- Storage uploads restricted to admin users
## Future Enhancements
- [ ] Real-time workout tracking
- [ ] User progress sync across devices
- [ ] Offline support with local caching
- [ ] Push notifications
- [ ] Analytics and user insights

BIN
admin-login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

41
admin-web/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

139
admin-web/README.md Normal file
View File

@@ -0,0 +1,139 @@
# TabataFit Admin Dashboard
A web-based admin dashboard for managing TabataFit content, built with Next.js, Tailwind CSS, and shadcn/ui.
## Features
- **Dashboard**: Overview with stats and quick actions
- **Workouts**: Manage workout library (view, delete)
- **Trainers**: Manage trainer profiles
- **Collections**: Organize workouts into collections
- **Media**: Upload videos, thumbnails, and avatars
## Tech Stack
- **Framework**: Next.js 15 (App Router)
- **Styling**: Tailwind CSS
- **Components**: shadcn/ui
- **Backend**: Supabase
- **Icons**: Lucide React
## Getting Started
### 1. Install Dependencies
```bash
cd admin-web
npm install
```
### 2. Configure Environment Variables
Create a `.env.local` file:
```bash
NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
```
### 3. Run Development Server
```bash
npm run dev
```
Open [http://localhost:3000](http://localhost:3000) in your browser.
## Project Structure
```
app/
├── login/ # Login page
├── workouts/ # Workouts management
├── trainers/ # Trainers management
├── collections/ # Collections management
├── media/ # Media upload
├── page.tsx # Dashboard
├── layout.tsx # Root layout with sidebar
components/
├── sidebar.tsx # Navigation sidebar
├── ui/ # shadcn/ui components
lib/
├── supabase.ts # Supabase client + types
├── utils.ts # Utility functions
```
## Usage
### Login
Navigate to `/login` and sign in with your admin credentials. The user must exist in both:
- Supabase Auth
- `admin_users` table
### Managing Content
1. **Dashboard**: View stats and access quick actions
2. **Workouts**: View all workouts, delete with confirmation
3. **Trainers**: View trainer cards with color indicators
4. **Collections**: View collections with gradient previews
5. **Media**: Upload files to Supabase Storage buckets
### Authentication Flow
1. User signs in at `/login`
2. App checks if user exists in `admin_users` table
3. If authorized, redirects to dashboard
4. Sidebar provides navigation to all sections
5. Logout clears session and redirects to login
## Supabase Schema
The dashboard expects these tables:
- `workouts` - Workout definitions
- `trainers` - Trainer profiles
- `collections` - Workout collections
- `collection_workouts` - Collection-workout links
- `admin_users` - Admin access control
And storage buckets:
- `videos` - Workout videos
- `thumbnails` - Workout thumbnails
- `avatars` - Trainer avatars
See the main project `SUPABASE_SETUP.md` for full schema details.
## Building for Production
```bash
npm run build
```
The build output will be in `.next/`. You can deploy to Vercel, Netlify, or any Next.js-compatible host.
## Customization
### Theme
The dashboard uses a dark theme with:
- Background: `neutral-950` (#0a0a0a)
- Cards: `neutral-900` (#171717)
- Accent: Orange (#f97316)
Modify `app/globals.css` and Tailwind config to change colors.
### Adding Pages
1. Create a new directory in `app/`
2. Add `page.tsx` with your component
3. Add link to `components/sidebar.tsx`
### Adding shadcn Components
```bash
npx shadcn add component-name
```
## License
Same as TabataFit project

View File

@@ -0,0 +1,94 @@
"use client";
import { useEffect, useState } from "react";
import { supabase } from "@/lib/supabase";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Plus, Trash2, Edit, Loader2, FolderOpen } from "lucide-react";
import type { Database } from "@/lib/supabase";
type Collection = Database["public"]["Tables"]["collections"]["Row"];
export default function CollectionsPage() {
const [collections, setCollections] = useState<Collection[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchCollections();
}, []);
const fetchCollections = async () => {
try {
const { data, error } = await supabase.from("collections").select("*");
if (error) throw error;
setCollections(data || []);
} catch (error) {
console.error("Failed to fetch collections:", error);
} finally {
setLoading(false);
}
};
return (
<div className="p-8">
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Collections</h1>
<p className="text-neutral-400">Organize workouts into collections</p>
</div>
<Button className="bg-orange-500 hover:bg-orange-600">
<Plus className="w-4 h-4 mr-2" />
Add Collection
</Button>
</div>
{loading ? (
<div className="flex justify-center p-8">
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{collections.map((collection) => (
<Card key={collection.id} className="bg-neutral-900 border-neutral-800">
<CardContent className="p-6">
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-neutral-800 rounded-xl flex items-center justify-center text-2xl">
{collection.icon}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between">
<div>
<h3 className="text-lg font-semibold text-white">{collection.title}</h3>
<p className="text-neutral-400 text-sm mt-1">{collection.description}</p>
</div>
<div className="flex gap-2">
<Button variant="ghost" size="icon" className="text-neutral-400 hover:text-white">
<Edit className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="text-neutral-400 hover:text-red-500">
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
{collection.gradient && (
<div
className="mt-3 h-2 rounded-full"
style={{
background: `linear-gradient(to right, ${collection.gradient[0]}, ${collection.gradient[1]})`,
}}
/>
)}
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}

BIN
admin-web/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

126
admin-web/app/globals.css Normal file
View File

@@ -0,0 +1,126 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

33
admin-web/app/layout.tsx Normal file
View File

@@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { Geist } from "next/font/google";
import "./globals.css";
import { Sidebar } from "@/components/sidebar";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "TabataFit Admin",
description: "Admin dashboard for TabataFit",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="dark">
<body className={`${geistSans.variable} antialiased bg-neutral-950 text-white`}>
<div className="flex min-h-screen">
<Sidebar />
<main className="flex-1 overflow-auto">
{children}
</main>
</div>
</body>
</html>
);
}

View File

@@ -0,0 +1,119 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { supabase } from "@/lib/supabase";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Flame, Loader2 } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const router = useRouter();
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
try {
const { error: authError } = await supabase.auth.signInWithPassword({
email,
password,
});
if (authError) throw authError;
const { data: adminUser } = await supabase
.from("admin_users")
.select("*")
.single();
if (!adminUser) {
await supabase.auth.signOut();
throw new Error("Not authorized as admin");
}
router.push("/");
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-neutral-950 p-4">
<Card className="w-full max-w-md border-neutral-800 bg-neutral-900">
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<div className="w-12 h-12 bg-orange-500 rounded-xl flex items-center justify-center">
<Flame className="w-7 h-7 text-white" />
</div>
</div>
<CardTitle className="text-2xl text-white">TabataFit Admin</CardTitle>
<CardDescription className="text-neutral-400">
Sign in to manage your content
</CardDescription>
</CardHeader>
<CardContent>
{error && (
<Alert variant="destructive" className="mb-4">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email" className="text-neutral-300">Email</Label>
<Input
id="email"
type="email"
placeholder="admin@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="bg-neutral-950 border-neutral-800 text-white"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-neutral-300">Password</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="bg-neutral-950 border-neutral-800 text-white"
/>
</div>
<Button
type="submit"
className="w-full bg-orange-500 hover:bg-orange-600"
disabled={loading}
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Signing in...
</>
) : (
"Sign In"
)}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,127 @@
"use client";
import { useState, useRef } from "react";
import { supabase } from "@/lib/supabase";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Upload, Film, Image as ImageIcon, User, Loader2 } from "lucide-react";
const BUCKETS = [
{ id: "videos", label: "Videos", icon: Film, types: "video/*" },
{ id: "thumbnails", label: "Thumbnails", icon: ImageIcon, types: "image/*" },
{ id: "avatars", label: "Avatars", icon: User, types: "image/*" },
];
export default function MediaPage() {
const [uploading, setUploading] = useState(false);
const [activeTab, setActiveTab] = useState("videos");
const fileInputRef = useRef<HTMLInputElement>(null);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const fileExt = file.name.split(".").pop();
const fileName = `${Date.now()}.${fileExt}`;
const filePath = `${activeTab}/${fileName}`;
const { error: uploadError } = await supabase.storage
.from(activeTab)
.upload(filePath, file);
if (uploadError) throw uploadError;
alert("File uploaded successfully!");
} catch (error) {
console.error("Upload failed:", error);
alert("Upload failed: " + (error instanceof Error ? error.message : "Unknown error"));
} finally {
setUploading(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
};
const activeBucket = BUCKETS.find((b) => b.id === activeTab);
return (
<div className="p-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Media Library</h1>
<p className="text-neutral-400">Upload and manage media files</p>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="bg-neutral-900 border-neutral-800 mb-6">
{BUCKETS.map((bucket) => (
<TabsTrigger
key={bucket.id}
value={bucket.id}
className="data-[state=active]:bg-orange-500 data-[state=active]:text-white"
>
<bucket.icon className="w-4 h-4 mr-2" />
{bucket.label}
</TabsTrigger>
))}
</TabsList>
{BUCKETS.map((bucket) => (
<TabsContent key={bucket.id} value={bucket.id}>
<Card className="bg-neutral-900 border-neutral-800">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<bucket.icon className="w-5 h-5 text-orange-500" />
Upload {bucket.label}
</CardTitle>
</CardHeader>
<CardContent>
<div className="border-2 border-dashed border-neutral-700 rounded-lg p-12 text-center hover:border-orange-500/50 transition-colors">
<input
type="file"
ref={fileInputRef}
onChange={handleUpload}
accept={bucket.types}
className="hidden"
id={`file-upload-${bucket.id}`}
/>
<label
htmlFor={`file-upload-${bucket.id}`}
className="cursor-pointer flex flex-col items-center"
>
<div className="w-16 h-16 bg-neutral-800 rounded-full flex items-center justify-center mb-4">
{uploading ? (
<Loader2 className="w-8 h-8 text-orange-500 animate-spin" />
) : (
<Upload className="w-8 h-8 text-neutral-400" />
)}
</div>
<p className="text-white font-medium mb-2">
{uploading ? "Uploading..." : `Click to upload ${bucket.label.toLowerCase()}`}
</p>
<p className="text-neutral-500 text-sm">
{bucket.id === "videos"
? "MP4, MOV up to 100MB"
: "JPG, PNG up to 5MB"}
</p>
</label>
</div>
<div className="mt-6 p-4 bg-neutral-950 rounded-lg">
<h4 className="text-sm font-medium text-white mb-2">Storage Path</h4>
<code className="text-sm text-neutral-400">
{bucket.id}/filename.ext
</code>
</div>
</CardContent>
</Card>
</TabsContent>
))}
</Tabs>
</div>
);
}

132
admin-web/app/page.tsx Normal file
View File

@@ -0,0 +1,132 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { supabase } from "@/lib/supabase";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dumbbell, Users, FolderOpen, Flame } from "lucide-react";
interface Stats {
workouts: number;
trainers: number;
collections: number;
}
export default function DashboardPage() {
const [stats, setStats] = useState<Stats>({ workouts: 0, trainers: 0, collections: 0 });
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchStats();
}, []);
const fetchStats = async () => {
try {
const [{ count: workouts }, { count: trainers }, { count: collections }] = await Promise.all([
supabase.from("workouts").select("*", { count: "exact", head: true }),
supabase.from("trainers").select("*", { count: "exact", head: true }),
supabase.from("collections").select("*", { count: "exact", head: true }),
]);
setStats({
workouts: workouts || 0,
trainers: trainers || 0,
collections: collections || 0,
});
} catch (error) {
console.error("Failed to fetch stats:", error);
} finally {
setLoading(false);
}
};
const statCards = [
{ title: "Workouts", value: stats.workouts, icon: Dumbbell, href: "/workouts", color: "text-orange-500" },
{ title: "Trainers", value: stats.trainers, icon: Users, href: "/trainers", color: "text-blue-500" },
{ title: "Collections", value: stats.collections, icon: FolderOpen, href: "/collections", color: "text-green-500" },
];
return (
<div className="p-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Dashboard</h1>
<p className="text-neutral-400">Overview of your TabataFit content</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
{statCards.map((stat) => {
const Icon = stat.icon;
return (
<Link key={stat.title} href={stat.href}>
<Card className="bg-neutral-900 border-neutral-800 hover:border-neutral-700 transition-colors cursor-pointer">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-neutral-400">
{stat.title}
</CardTitle>
<Icon className={`w-5 h-5 ${stat.color}`} />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-white">
{loading ? "-" : stat.value}
</div>
</CardContent>
</Card>
</Link>
);
})}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="bg-neutral-900 border-neutral-800">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Flame className="w-5 h-5 text-orange-500" />
Quick Actions
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Link
href="/workouts"
className="block p-4 bg-neutral-950 rounded-lg hover:bg-neutral-800 transition-colors"
>
<div className="font-medium text-white">Manage Workouts</div>
<div className="text-sm text-neutral-400">Add, edit, or remove workouts</div>
</Link>
<Link
href="/trainers"
className="block p-4 bg-neutral-950 rounded-lg hover:bg-neutral-800 transition-colors"
>
<div className="font-medium text-white">Manage Trainers</div>
<div className="text-sm text-neutral-400">Update trainer profiles and photos</div>
</Link>
<Link
href="/media"
className="block p-4 bg-neutral-950 rounded-lg hover:bg-neutral-800 transition-colors"
>
<div className="font-medium text-white">Upload Media</div>
<div className="text-sm text-neutral-400">Add videos and thumbnails</div>
</Link>
</CardContent>
</Card>
<Card className="bg-neutral-900 border-neutral-800">
<CardHeader>
<CardTitle className="text-white">Getting Started</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-neutral-400">
<p>
Welcome to the TabataFit Admin Dashboard. Here you can manage all your
fitness content.
</p>
<ul className="list-disc list-inside space-y-2 text-sm">
<li>Create and edit Tabata workouts</li>
<li>Manage trainer profiles</li>
<li>Organize workouts into collections</li>
<li>Upload workout videos and thumbnails</li>
</ul>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
"use client";
import { useEffect, useState } from "react";
import { supabase } from "@/lib/supabase";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Plus, Trash2, Edit, Loader2 } from "lucide-react";
import type { Database } from "@/lib/supabase";
type Trainer = Database["public"]["Tables"]["trainers"]["Row"];
export default function TrainersPage() {
const [trainers, setTrainers] = useState<Trainer[]>([]);
const [loading, setLoading] = useState(true);
const [deletingId, setDeletingId] = useState<string | null>(null);
useEffect(() => {
fetchTrainers();
}, []);
const fetchTrainers = async () => {
try {
const { data, error } = await supabase.from("trainers").select("*");
if (error) throw error;
setTrainers(data || []);
} catch (error) {
console.error("Failed to fetch trainers:", error);
} finally {
setLoading(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Are you sure you want to delete this trainer?")) return;
setDeletingId(id);
try {
const { error } = await supabase.from("trainers").delete().eq("id", id);
if (error) throw error;
setTrainers(trainers.filter((t) => t.id !== id));
} catch (error) {
console.error("Failed to delete trainer:", error);
alert("Failed to delete trainer");
} finally {
setDeletingId(null);
}
};
return (
<div className="p-8">
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Trainers</h1>
<p className="text-neutral-400">Manage your fitness trainers</p>
</div>
<Button className="bg-orange-500 hover:bg-orange-600">
<Plus className="w-4 h-4 mr-2" />
Add Trainer
</Button>
</div>
{loading ? (
<div className="flex justify-center p-8">
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{trainers.map((trainer) => (
<Card key={trainer.id} className="bg-neutral-900 border-neutral-800">
<CardContent className="p-6">
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div
className="w-12 h-12 rounded-full flex items-center justify-center text-white font-bold"
style={{ backgroundColor: trainer.color }}
>
{trainer.name[0]}
</div>
<div>
<h3 className="text-lg font-semibold text-white">{trainer.name}</h3>
<p className="text-neutral-400">{trainer.specialty}</p>
<p className="text-sm text-neutral-500">
{trainer.workout_count} workouts
</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="ghost" size="icon" className="text-neutral-400 hover:text-white">
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-neutral-400 hover:text-red-500"
onClick={() => handleDelete(trainer.id)}
disabled={deletingId === trainer.id}
>
{deletingId === trainer.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,152 @@
"use client";
import { useEffect, useState } from "react";
import { supabase } from "@/lib/supabase";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Plus, Trash2, Edit, Loader2 } from "lucide-react";
import type { Database } from "@/lib/supabase";
type Workout = Database["public"]["Tables"]["workouts"]["Row"];
export default function WorkoutsPage() {
const [workouts, setWorkouts] = useState<Workout[]>([]);
const [loading, setLoading] = useState(true);
const [deletingId, setDeletingId] = useState<string | null>(null);
useEffect(() => {
fetchWorkouts();
}, []);
const fetchWorkouts = async () => {
try {
const { data, error } = await supabase
.from("workouts")
.select("*")
.order("created_at", { ascending: false });
if (error) throw error;
setWorkouts(data || []);
} catch (error) {
console.error("Failed to fetch workouts:", error);
} finally {
setLoading(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Are you sure you want to delete this workout?")) return;
setDeletingId(id);
try {
const { error } = await supabase.from("workouts").delete().eq("id", id);
if (error) throw error;
setWorkouts(workouts.filter((w) => w.id !== id));
} catch (error) {
console.error("Failed to delete workout:", error);
alert("Failed to delete workout");
} finally {
setDeletingId(null);
}
};
const getCategoryColor = (category: string) => {
const colors: Record<string, string> = {
"full-body": "bg-orange-500/20 text-orange-500",
core: "bg-blue-500/20 text-blue-500",
"upper-body": "bg-green-500/20 text-green-500",
"lower-body": "bg-purple-500/20 text-purple-500",
cardio: "bg-red-500/20 text-red-500",
};
return colors[category] || "bg-neutral-500/20 text-neutral-500";
};
return (
<div className="p-8">
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Workouts</h1>
<p className="text-neutral-400">Manage your workout library</p>
</div>
<Button className="bg-orange-500 hover:bg-orange-600">
<Plus className="w-4 h-4 mr-2" />
Add Workout
</Button>
</div>
<Card className="bg-neutral-900 border-neutral-800">
<CardContent className="p-0">
{loading ? (
<div className="p-8 flex justify-center">
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
</div>
) : (
<Table>
<TableHeader>
<TableRow className="border-neutral-800">
<TableHead className="text-neutral-400">Title</TableHead>
<TableHead className="text-neutral-400">Category</TableHead>
<TableHead className="text-neutral-400">Level</TableHead>
<TableHead className="text-neutral-400">Duration</TableHead>
<TableHead className="text-neutral-400">Rounds</TableHead>
<TableHead className="text-neutral-400 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{workouts.map((workout) => (
<TableRow key={workout.id} className="border-neutral-800">
<TableCell className="text-white font-medium">
{workout.title}
{workout.is_featured && (
<Badge className="ml-2 bg-orange-500/20 text-orange-500">
Featured
</Badge>
)}
</TableCell>
<TableCell>
<Badge className={getCategoryColor(workout.category)}>
{workout.category}
</Badge>
</TableCell>
<TableCell className="text-neutral-300">{workout.level}</TableCell>
<TableCell className="text-neutral-300">{workout.duration} min</TableCell>
<TableCell className="text-neutral-300">{workout.rounds}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button variant="ghost" size="icon" className="text-neutral-400 hover:text-white">
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-neutral-400 hover:text-red-500"
onClick={() => handleDelete(workout.id)}
disabled={deletingId === workout.id}
>
{deletingId === workout.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}

23
admin-web/components.json Normal file
View File

@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@@ -0,0 +1,77 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { supabase } from "@/lib/supabase";
import { Button } from "@/components/ui/button";
import {
LayoutDashboard,
Dumbbell,
Users,
FolderOpen,
ImageIcon,
LogOut,
Flame,
} from "lucide-react";
const navItems = [
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
{ href: "/workouts", label: "Workouts", icon: Dumbbell },
{ href: "/trainers", label: "Trainers", icon: Users },
{ href: "/collections", label: "Collections", icon: FolderOpen },
{ href: "/media", label: "Media", icon: ImageIcon },
];
export function Sidebar() {
const pathname = usePathname();
const router = useRouter();
const handleLogout = async () => {
await supabase.auth.signOut();
router.push("/login");
};
return (
<aside className="w-64 bg-neutral-950 border-r border-neutral-800 flex flex-col h-screen sticky top-0">
<div className="p-6 border-b border-neutral-800">
<div className="flex items-center gap-2">
<Flame className="w-6 h-6 text-orange-500" />
<span className="text-lg font-bold text-white">TabataFit</span>
</div>
<p className="text-neutral-500 text-sm mt-1">Admin Dashboard</p>
</div>
<nav className="flex-1 p-4 space-y-1">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors ${
isActive
? "bg-orange-500/10 text-orange-500"
: "text-neutral-400 hover:text-white hover:bg-neutral-900"
}`}
>
<Icon className="w-5 h-5" />
{item.label}
</Link>
);
})}
</nav>
<div className="p-4 border-t border-neutral-800">
<Button
variant="ghost"
className="w-full justify-start text-neutral-400 hover:text-white"
onClick={handleLogout}
>
<LogOut className="w-5 h-5 mr-3" />
Logout
</Button>
</div>
</aside>
);
}

View File

@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,91 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

130
admin-web/lib/supabase.ts Normal file
View File

@@ -0,0 +1,130 @@
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL === 'your_supabase_project_url'
? 'http://localhost:54321'
: (process.env.NEXT_PUBLIC_SUPABASE_URL || 'http://localhost:54321')
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY === 'your_supabase_anon_key'
? 'placeholder-key'
: (process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || 'placeholder-key')
export const supabase = createClient(supabaseUrl, supabaseKey)
export const isSupabaseConfigured = () => {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL
return url !== 'your_supabase_project_url' && url !== 'http://localhost:54321' && !!url
}
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export interface Database {
public: {
Tables: {
workouts: {
Row: {
id: string
title: string
trainer_id: string
category: 'full-body' | 'core' | 'upper-body' | 'lower-body' | 'cardio'
level: 'Beginner' | 'Intermediate' | 'Advanced'
duration: number
calories: number
rounds: number
prep_time: number
work_time: number
rest_time: number
equipment: string[]
music_vibe: 'electronic' | 'hip-hop' | 'pop' | 'rock' | 'chill'
exercises: { name: string; duration: number }[]
thumbnail_url: string | null
video_url: string | null
is_featured: boolean
created_at: string
updated_at: string
}
Insert: {
id?: string
title: string
trainer_id: string
category: 'full-body' | 'core' | 'upper-body' | 'lower-body' | 'cardio'
level: 'Beginner' | 'Intermediate' | 'Advanced'
duration: number
calories: number
rounds: number
prep_time: number
work_time: number
rest_time: number
equipment?: string[]
music_vibe: 'electronic' | 'hip-hop' | 'pop' | 'rock' | 'chill'
exercises: { name: string; duration: number }[]
thumbnail_url?: string | null
video_url?: string | null
is_featured?: boolean
}
Update: Partial<Database['public']['Tables']['workouts']['Insert']>
}
trainers: {
Row: {
id: string
name: string
specialty: string
color: string
avatar_url: string | null
workout_count: number
created_at: string
updated_at: string
}
Insert: {
id?: string
name: string
specialty: string
color: string
avatar_url?: string | null
workout_count?: number
}
Update: Partial<Omit<Database['public']['Tables']['trainers']['Insert'], 'id'>>
}
collections: {
Row: {
id: string
title: string
description: string
icon: string
gradient: string[] | null
created_at: string
updated_at: string
}
Insert: {
id?: string
title: string
description: string
icon: string
gradient?: string[] | null
}
Update: Partial<Omit<Database['public']['Tables']['collections']['Insert'], 'id'>>
}
collection_workouts: {
Row: {
id: string
collection_id: string
workout_id: string
sort_order: number
}
}
admin_users: {
Row: {
id: string
email: string
role: 'admin' | 'editor'
created_at: string
last_login: string | null
}
}
}
}
}

6
admin-web/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

7
admin-web/next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

11817
admin-web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
admin-web/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "my-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.576.0",
"next": "16.1.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"shadcn": "^3.8.5",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

34
admin-web/tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

View File

@@ -1,16 +1,17 @@
{
"expo": {
"name": "tabatago",
"slug": "tabatago",
"name": "TabataFit",
"slug": "tabatafit",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "tabatago",
"scheme": "tabatafit",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.anonymous.tabatago"
"bundleIdentifier": "com.millianlmx.tabatafit",
"buildNumber": "1"
},
"android": {
"adaptiveIcon": {
@@ -20,7 +21,8 @@
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
"predictiveBackGestureEnabled": false,
"package": "com.millianlmx.tabatafit"
},
"web": {
"output": "static",
@@ -41,7 +43,6 @@
}
],
"expo-video",
"expo-notifications",
"expo-localization",
"./plugins/withStoreKitConfig"
],

View File

@@ -12,4 +12,22 @@
| #5054 | " | ✅ | Re-added Host import to home screen | ~184 |
| #5043 | 8:22 AM | ✅ | Removed closing Host tag from profile screen | ~210 |
| #5042 | " | ✅ | Removed opening Host tag from profile screen | ~164 |
| #5041 | " | ✅ | Removed closing Host tag from browse screen | ~187 |
| #5040 | " | ✅ | Removed opening Host tag from browse screen | ~159 |
| #5039 | 8:21 AM | ✅ | Removed closing Host tag from activity screen | ~193 |
| #5038 | " | ✅ | Removed opening Host tag from activity screen | ~154 |
| #5037 | " | ✅ | Removed closing Host tag from workouts screen | ~195 |
| #5036 | " | ✅ | Removed opening Host tag from workouts screen | ~164 |
| #5035 | " | ✅ | Removed closing Host tag from home screen JSX | ~197 |
| #5034 | " | ✅ | Removed Host wrapper from home screen JSX | ~139 |
| #5031 | 8:19 AM | ✅ | Removed Host import from profile screen | ~184 |
| #5030 | " | ✅ | Removed Host import from browse screen | ~190 |
| #5029 | 8:18 AM | ✅ | Removed Host import from activity screen | ~183 |
| #5028 | " | ✅ | Removed Host import from workouts screen | ~189 |
| #5027 | " | ✅ | Removed Host import from home screen index.tsx | ~180 |
| #5024 | " | 🔵 | Activity screen properly wraps content with Host component | ~237 |
| #5023 | " | 🔵 | Profile screen properly wraps content with Host component | ~246 |
| #5022 | 8:14 AM | 🔵 | Browse screen properly wraps content with Host component | ~217 |
| #5021 | " | 🔵 | Workouts screen properly wraps content with Host component | ~228 |
| #5020 | 8:13 AM | 🔵 | Home screen properly wraps content with Host component | ~238 |
</claude-mem-context>

View File

@@ -1,40 +1,35 @@
/**
* TabataFit Profile Screen
* React Native + SwiftUI Islands — wired to shared data
* SwiftUI-first settings with native iOS look
*/
import { View, StyleSheet, ScrollView, Text as RNText } from 'react-native'
import { View, StyleSheet, ScrollView } from 'react-native'
import { useRouter } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { LinearGradient } from 'expo-linear-gradient'
import { BlurView } from 'expo-blur'
import Ionicons from '@expo/vector-icons/Ionicons'
import {
Host,
List,
Section,
Switch,
Text,
LabeledContent,
DateTimePicker,
Button,
VStack,
Text,
} from '@expo/ui/swift-ui'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useUserStore } from '@/src/shared/stores'
import { requestNotificationPermissions, usePurchases } from '@/src/shared/hooks'
import { StyledText } from '@/src/shared/components/StyledText'
import { useThemeColors, BRAND, GRADIENTS } from '@/src/shared/theme'
import { useThemeColors, BRAND } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
const FONTS = {
LARGE_TITLE: 34,
TITLE_2: 22,
HEADLINE: 17,
SUBHEADLINE: 15,
CAPTION_1: 12,
}
@@ -44,6 +39,7 @@ const FONTS = {
export default function ProfileScreen() {
const { t } = useTranslation('screens')
const router = useRouter()
const insets = useSafeAreaInsets()
const colors = useThemeColors()
const styles = useMemo(() => createStyles(colors), [colors])
@@ -53,7 +49,7 @@ export default function ProfileScreen() {
const { restorePurchases } = usePurchases()
const isPremium = profile.subscription !== 'free'
const planLabel = isPremium ? 'TabataFit+' : 'Free'
const planLabel = isPremium ? 'TabataFit+' : t('profile.freePlan')
const handleRestore = async () => {
await restorePurchases()
@@ -79,7 +75,31 @@ export default function ProfileScreen() {
const pickerDate = new Date(today.getFullYear(), today.getMonth(), today.getDate(), rh, rm)
const pickerInitial = pickerDate.toISOString()
const settingsHeight = settings.reminders ? 430 : 385
// Calculate total height for single SwiftUI island
// insetGrouped style: ~50px top/bottom margins, section header ~35px, row ~44px
const basePadding = 100 // top + bottom margins for insetGrouped
// Account section
const accountRows = 1 + (isPremium ? 1 : 0) // plan, [+ restore]
const accountHeight = 35 + accountRows * 44
// Upgrade section (free users only)
const upgradeHeight = isPremium ? 0 : 35 + 80 // header + VStack content
// Workout section
const workoutHeight = 35 + 3 * 44 // haptics, sound, voice
// Notifications section
const notificationRows = settings.reminders ? 2 : 1
const notificationHeight = 35 + notificationRows * 44
// About section
const aboutHeight = 35 + 2 * 44 // version, privacy
// Sign out section
const signOutHeight = 44 // single button row
const totalHeight = basePadding + accountHeight + upgradeHeight + workoutHeight + notificationHeight + aboutHeight + signOutHeight
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
@@ -93,76 +113,61 @@ export default function ProfileScreen() {
{t('profile.title')}
</StyledText>
{/* Profile Card */}
<View style={styles.profileCard}>
<BlurView intensity={colors.glass.blurMedium} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
{/* Profile Header Card */}
<View style={styles.profileHeader}>
<View style={styles.avatarContainer}>
<LinearGradient
colors={GRADIENTS.CTA}
style={StyleSheet.absoluteFill}
/>
<StyledText size={FONTS.LARGE_TITLE} weight="bold" color="#FFFFFF">
{profile.name[0]}
{profile.name?.[0] || '?'}
</StyledText>
</View>
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>
{profile.name}
</StyledText>
<StyledText size={FONTS.SUBHEADLINE} color={colors.text.tertiary}>
{profile.email}
</StyledText>
{isPremium && (
<View style={styles.premiumBadge}>
<Ionicons name="star" size={12} color={BRAND.PRIMARY} />
<StyledText size={FONTS.CAPTION_1} weight="semibold" color={colors.text.primary}>
{planLabel}
</StyledText>
</View>
)}
</View>
{/* Subscription */}
{isPremium && (
<View style={styles.subscriptionCard}>
<LinearGradient
colors={GRADIENTS.CTA}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={StyleSheet.absoluteFill}
/>
<View style={styles.subscriptionContent}>
<Ionicons name="ribbon" size={24} color="#FFFFFF" />
<View style={styles.subscriptionInfo}>
<StyledText size={FONTS.HEADLINE} weight="bold" color="#FFFFFF">
<View style={styles.profileInfo}>
<StyledText size={FONTS.TITLE_2} weight="semibold" color={colors.text.primary}>
{profile.name || t('profile.guest')}
</StyledText>
{isPremium && (
<View style={styles.premiumBadge}>
<StyledText size={FONTS.CAPTION_1} weight="semibold" color={BRAND.PRIMARY}>
{planLabel}
</StyledText>
<StyledText size={FONTS.CAPTION_1} color="rgba(255,255,255,0.8)">
{t('profile.memberSince', { date: profile.joinDate })}
</StyledText>
</View>
</View>
)}
</View>
)}
</View>
{/* SwiftUI Island: Subscription Section */}
<Host useViewportSizeMeasurement colorScheme={colors.colorScheme} style={{ height: 60, marginTop: -20 }}>
{/* All Settings in Single SwiftUI Island */}
<Host useViewportSizeMeasurement colorScheme={colors.colorScheme} style={{ height: totalHeight }}>
<List listStyle="insetGrouped" scrollEnabled={false}>
<Section title={t('profile.sectionSubscription')}>
<Button
variant="borderless"
onPress={handleRestore}
color={colors.text.primary}
>
{t('profile.restorePurchases')}
</Button>
{/* Account Section */}
<Section header={t('profile.sectionAccount')}>
<LabeledContent label={t('profile.plan')}>
<Text color={isPremium ? BRAND.PRIMARY : undefined}>{planLabel}</Text>
</LabeledContent>
{isPremium && (
<Button variant="borderless" onPress={handleRestore} color={colors.text.tertiary}>
{t('profile.restorePurchases')}
</Button>
)}
</Section>
</List>
</Host>
{/* SwiftUI Island: Settings */}
<Host useViewportSizeMeasurement colorScheme={colors.colorScheme} style={{ height: settingsHeight }}>
<List listStyle="insetGrouped" scrollEnabled={false}>
<Section title={t('profile.sectionWorkout')}>
{/* Upgrade CTA for Free Users */}
{!isPremium && (
<Section>
<VStack alignment="leading" spacing={8}>
<Text font="headline" color={BRAND.PRIMARY}>
{t('profile.upgradeTitle')}
</Text>
<Text color="systemSecondary" font="subheadline">
{t('profile.upgradeDescription')}
</Text>
</VStack>
<Button variant="borderless" onPress={() => router.push('/paywall')} color={BRAND.PRIMARY}>
{t('profile.learnMore')}
</Button>
</Section>
)}
{/* Workout Settings */}
<Section header={t('profile.sectionWorkout')} footer={t('profile.workoutSettingsFooter')}>
<Switch
label={t('profile.hapticFeedback')}
value={settings.haptics}
@@ -182,7 +187,9 @@ export default function ProfileScreen() {
color={BRAND.PRIMARY}
/>
</Section>
<Section title={t('profile.sectionNotifications')}>
{/* Notification Settings */}
<Section header={t('profile.sectionNotifications')} footer={settings.reminders ? t('profile.reminderFooter') : undefined}>
<Switch
label={t('profile.dailyReminders')}
value={settings.reminders}
@@ -201,13 +208,25 @@ export default function ProfileScreen() {
</LabeledContent>
)}
</Section>
{/* About Section */}
<Section header={t('profile.sectionAbout')}>
<LabeledContent label={t('profile.version')}>
<Text color="systemSecondary">1.0.0</Text>
</LabeledContent>
<Button variant="borderless" color="systemSecondary" onPress={() => router.push('/privacy')}>
{t('profile.privacyPolicy')}
</Button>
</Section>
{/* Sign Out */}
<Section>
<Button variant="borderless" role="destructive" onPress={() => {}}>
{t('profile.signOut')}
</Button>
</Section>
</List>
</Host>
{/* Version */}
<StyledText size={FONTS.CAPTION_1} color={colors.text.tertiary} style={styles.versionText}>
{t('profile.version')}
</StyledText>
</ScrollView>
</View>
)
@@ -230,61 +249,31 @@ function createStyles(colors: ThemeColors) {
paddingHorizontal: LAYOUT.SCREEN_PADDING,
},
// Profile Card
profileCard: {
borderRadius: RADIUS.GLASS_CARD,
overflow: 'hidden',
marginBottom: SPACING[6],
marginTop: SPACING[4],
alignItems: 'center',
paddingVertical: SPACING[6],
borderWidth: 1,
borderColor: colors.border.glass,
...colors.shadow.md,
},
avatarContainer: {
width: 80,
height: 80,
borderRadius: 40,
alignItems: 'center',
justifyContent: 'center',
marginBottom: SPACING[3],
overflow: 'hidden',
},
premiumBadge: {
// Profile Header
profileHeader: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: SPACING[5],
gap: SPACING[4],
},
avatarContainer: {
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: BRAND.PRIMARY,
alignItems: 'center',
justifyContent: 'center',
},
profileInfo: {
flex: 1,
},
premiumBadge: {
backgroundColor: 'rgba(255, 107, 53, 0.15)',
paddingHorizontal: SPACING[3],
paddingVertical: SPACING[1],
borderRadius: RADIUS.FULL,
marginTop: SPACING[3],
gap: SPACING[1],
},
// Subscription Card
subscriptionCard: {
height: 80,
borderRadius: RADIUS.LG,
overflow: 'hidden',
marginBottom: SPACING[6],
...colors.shadow.BRAND_GLOW,
},
subscriptionContent: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: SPACING[4],
gap: SPACING[3],
},
subscriptionInfo: {
flex: 1,
},
// Version Text
versionText: {
textAlign: 'center',
marginTop: SPACING[6],
borderRadius: 12,
alignSelf: 'flex-start',
marginTop: SPACING[1],
},
})
}

View File

@@ -10,35 +10,31 @@
| #5001 | 9:35 AM | 🔵 | Host Wrapper Located at Root Layout Level | ~153 |
| #4964 | 9:23 AM | 🔴 | Added Host Wrapper to Root Layout | ~228 |
| #4963 | 9:22 AM | ✅ | Root layout wraps Stack in View with pure black background | ~279 |
| #4910 | 8:16 AM | 🟣 | Added Workout Detail and Complete Screen Routes | ~348 |
### Feb 20, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #5391 | 10:20 PM | 🟣 | RevenueCat subscription system fully integrated and tested | ~656 |
| #5384 | 9:28 PM | 🟣 | Implemented RevenueCat subscription system | ~190 |
| #5360 | 7:49 PM | 🟣 | Restore Button Style Added to Paywall | ~143 |
| #5359 | " | 🟣 | RevenueCat Purchase Flow Integrated in Paywall | ~273 |
| #5358 | " | 🟣 | usePurchases Hook Imported in Onboarding | ~134 |
| #5357 | " | 🟣 | RevenueCat Initialization Triggered After Store Hydration | ~176 |
| #5356 | 7:48 PM | 🟣 | RevenueCat Initialization Added to Root Layout | ~157 |
| #5313 | 2:58 PM | 🔵 | Onboarding flow architecture examined | ~482 |
| #5279 | 2:43 PM | 🔵 | Reviewed onboarding.tsx structure for notification permission integration | ~289 |
| #5268 | 2:27 PM | ✅ | Onboarding Feature Text Simplified | ~151 |
| #5230 | 1:25 PM | 🟣 | Implemented category and collection detail screens with Inter font loading | ~481 |
| #5228 | " | 🔄 | Removed v1 features and old scaffolding from TabataFit codebase | ~591 |
| #5227 | 1:24 PM | ✅ | Category and Collection screens staged for git commit | ~345 |
| #5224 | " | ✅ | Stage v1.1 files prepared for git commit - SwiftUI Button refactoring complete | ~434 |
| #5206 | 1:03 PM | ⚖️ | SwiftUI component usage mandated for TabataFit app | ~349 |
| #5115 | 8:57 AM | 🔵 | Root Layout Stack Configuration with Screen Animations | ~256 |
| #5061 | 8:47 AM | 🔵 | Expo Router Tab Navigation Structure Found | ~196 |
| #5053 | 8:23 AM | ✅ | Completed removal of all Host wrappers from application | ~255 |
| #5052 | " | ✅ | Removed Host wrapper from root layout entirely | ~224 |
| #5019 | 8:13 AM | 🔵 | Root layout properly wraps Stack with Host component | ~198 |
### Feb 21, 2026
### Feb 28, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #5551 | 12:02 AM | 🔄 | Converted onboarding and player screens to theme system | ~261 |
| #5598 | 9:22 PM | 🟣 | Enabled PostHog analytics in development mode | ~253 |
| #5597 | " | 🔄 | PostHogProvider initialization updated with client check and autocapture config | ~303 |
| #5589 | 7:51 PM | 🟣 | PostHog screen tracking added to onboarding flow | ~246 |
| #5588 | 7:50 PM | ✅ | Added trackScreen function to onboarding analytics imports | ~203 |
| #5585 | " | ✅ | Enhanced PostHogProvider initialization with null safety | ~239 |
| #5584 | 7:49 PM | ✅ | Imported trackScreen function in root layout | ~202 |
| #5583 | " | 🟣 | PostHog user identification added to onboarding completion | ~291 |
| #5582 | " | ✅ | Enhanced onboarding analytics with user identification | ~187 |
| #5579 | 7:47 PM | 🔵 | Comprehensive analytics tracking in onboarding flow | ~345 |
| #5575 | 7:44 PM | 🔵 | PostHog integration architecture in root layout | ~279 |
| #5572 | 7:43 PM | 🔵 | PostHog integration points identified | ~228 |
</claude-mem-context>

View File

@@ -28,7 +28,7 @@ import { ThemeProvider, useThemeColors } from '@/src/shared/theme'
import { useUserStore } from '@/src/shared/stores'
import { useNotifications } from '@/src/shared/hooks'
import { initializePurchases } from '@/src/shared/services/purchases'
import { initializeAnalytics, getPostHogClient } from '@/src/shared/services/analytics'
import { initializeAnalytics, getPostHogClient, trackScreen } from '@/src/shared/services/analytics'
Notifications.setNotificationHandler({
handleNotification: async () => ({
@@ -105,7 +105,7 @@ function RootLayoutInner() {
<Stack.Screen
name="workout/[id]"
options={{
animation: 'slide_from_bottom',
animation: 'slide_from_right',
}}
/>
<Stack.Screen
@@ -137,13 +137,21 @@ function RootLayoutInner() {
</View>
)
// Skip PostHogProvider in dev to avoid SDK errors without a real API key
if (__DEV__) {
const posthogClient = getPostHogClient()
// Only wrap with PostHogProvider if client is initialized
if (!posthogClient) {
return content
}
return (
<PostHogProvider client={getPostHogClient() ?? undefined} autocapture={{ captureScreens: true }}>
<PostHogProvider
client={posthogClient}
autocapture={{
captureScreens: true,
captureTouches: true,
}}
>
{content}
</PostHogProvider>
)

35
app/admin/_layout.tsx Normal file
View File

@@ -0,0 +1,35 @@
import { Stack } from 'expo-router'
import { AdminAuthProvider, useAdminAuth } from '../../src/admin/components/AdminAuthProvider'
import { View, ActivityIndicator } from 'react-native'
import { Redirect } from 'expo-router'
function AdminLayoutContent({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isAdmin, isLoading } = useAdminAuth()
if (isLoading) {
return (
<View style={{ flex: 1, backgroundColor: '#000', justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" color="#FF6B35" />
</View>
)
}
if (!isAuthenticated || !isAdmin) {
return <Redirect href="/admin/login" />
}
return (
<>
<Stack.Screen options={{ headerShown: false }} />
{children}
</>
)
}
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<AdminAuthProvider>
<AdminLayoutContent>{children}</AdminLayoutContent>
</AdminAuthProvider>
)
}

201
app/admin/collections.tsx Normal file
View File

@@ -0,0 +1,201 @@
import { useState } from 'react'
import {
View,
Text,
ScrollView,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
Alert,
} from 'react-native'
import { useRouter } from 'expo-router'
import { useCollections } from '../../src/shared/hooks/useSupabaseData'
import { adminService } from '../../src/admin/services/adminService'
import type { Collection } from '../../src/shared/types'
export default function AdminCollectionsScreen() {
const router = useRouter()
const { collections, loading, refetch } = useCollections()
const [updatingId, setUpdatingId] = useState<string | null>(null)
const handleDelete = (collection: Collection) => {
Alert.alert(
'Delete Collection',
`Are you sure you want to delete "${collection.title}"?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
Alert.alert('Info', 'Collection deletion not yet implemented')
}
},
]
)
}
if (loading) {
return (
<View style={[styles.container, styles.centered]}>
<ActivityIndicator size="large" color="#FF6B35" />
</View>
)
}
return (
<View style={styles.container}>
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Text style={styles.backText}> Back</Text>
</TouchableOpacity>
<Text style={styles.title}>Collections</Text>
<TouchableOpacity style={styles.addButton}>
<Text style={styles.addButtonText}>+ Add</Text>
</TouchableOpacity>
</View>
<ScrollView style={styles.content}>
{collections.map((collection) => (
<View key={collection.id} style={styles.collectionCard}>
<View style={styles.iconContainer}>
<Text style={styles.icon}>{collection.icon}</Text>
</View>
<View style={styles.collectionInfo}>
<Text style={styles.collectionTitle}>{collection.title}</Text>
<Text style={styles.collectionDescription}>{collection.description}</Text>
<Text style={styles.collectionMeta}>
{collection.workoutIds.length} workouts
</Text>
</View>
<View style={styles.actions}>
<TouchableOpacity style={styles.editButton}>
<Text style={styles.editText}>Edit</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.deleteButton, updatingId === collection.id && styles.disabledButton]}
onPress={() => handleDelete(collection)}
disabled={updatingId === collection.id}
>
<Text style={styles.deleteText}>
{updatingId === collection.id ? '...' : 'Delete'}
</Text>
</TouchableOpacity>
</View>
</View>
))}
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
centered: {
justifyContent: 'center',
alignItems: 'center',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
paddingTop: 60,
borderBottomWidth: 1,
borderBottomColor: '#1C1C1E',
},
backButton: {
padding: 8,
},
backText: {
color: '#FF6B35',
fontSize: 16,
},
title: {
fontSize: 20,
fontWeight: 'bold',
color: '#fff',
},
addButton: {
backgroundColor: '#FF6B35',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
},
addButtonText: {
color: '#000',
fontWeight: 'bold',
},
content: {
flex: 1,
padding: 16,
},
collectionCard: {
backgroundColor: '#1C1C1E',
borderRadius: 12,
padding: 16,
marginBottom: 12,
flexDirection: 'row',
alignItems: 'center',
},
iconContainer: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: '#2C2C2E',
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
icon: {
fontSize: 24,
},
collectionInfo: {
flex: 1,
},
collectionTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#fff',
marginBottom: 4,
},
collectionDescription: {
fontSize: 14,
color: '#999',
marginBottom: 4,
},
collectionMeta: {
fontSize: 12,
color: '#666',
},
actions: {
flexDirection: 'row',
gap: 8,
},
editButton: {
backgroundColor: '#2C2C2E',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 6,
},
editText: {
color: '#5AC8FA',
},
deleteButton: {
backgroundColor: '#2C2C2E',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 6,
},
disabledButton: {
opacity: 0.5,
},
deleteText: {
color: '#FF3B30',
},
})

212
app/admin/index.tsx Normal file
View File

@@ -0,0 +1,212 @@
import { useState, useCallback } from 'react'
import {
View,
Text,
ScrollView,
TouchableOpacity,
StyleSheet,
RefreshControl,
} from 'react-native'
import { useRouter } from 'expo-router'
import { useAdminAuth } from '../../src/admin/components/AdminAuthProvider'
import { useWorkouts, useTrainers, useCollections } from '../../src/shared/hooks/useSupabaseData'
export default function AdminDashboardScreen() {
const router = useRouter()
const { signOut } = useAdminAuth()
const [refreshing, setRefreshing] = useState(false)
const {
workouts,
loading: workoutsLoading,
refetch: refetchWorkouts
} = useWorkouts()
const {
trainers,
loading: trainersLoading,
refetch: refetchTrainers
} = useTrainers()
const {
collections,
loading: collectionsLoading,
refetch: refetchCollections
} = useCollections()
const onRefresh = useCallback(async () => {
setRefreshing(true)
await Promise.all([
refetchWorkouts(),
refetchTrainers(),
refetchCollections(),
])
setRefreshing(false)
}, [refetchWorkouts, refetchTrainers, refetchCollections])
const handleLogout = async () => {
await signOut()
router.replace('/admin/login')
}
const isLoading = workoutsLoading || trainersLoading || collectionsLoading
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Admin Dashboard</Text>
<TouchableOpacity onPress={handleLogout} style={styles.logoutButton}>
<Text style={styles.logoutText}>Logout</Text>
</TouchableOpacity>
</View>
<ScrollView
style={styles.content}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor="#FF6B35" />
}
>
<View style={styles.statsGrid}>
<View style={styles.statCard}>
<Text style={styles.statNumber}>{workouts.length}</Text>
<Text style={styles.statLabel}>Workouts</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statNumber}>{trainers.length}</Text>
<Text style={styles.statLabel}>Trainers</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statNumber}>{collections.length}</Text>
<Text style={styles.statLabel}>Collections</Text>
</View>
</View>
<Text style={styles.sectionTitle}>Quick Actions</Text>
<View style={styles.actionsGrid}>
<TouchableOpacity
style={styles.actionCard}
onPress={() => router.push('/admin/workouts')}
>
<Text style={styles.actionIcon}>💪</Text>
<Text style={styles.actionTitle}>Manage Workouts</Text>
<Text style={styles.actionDescription}>Add, edit, or delete workouts</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.actionCard}
onPress={() => router.push('/admin/trainers')}
>
<Text style={styles.actionIcon}>👥</Text>
<Text style={styles.actionTitle}>Manage Trainers</Text>
<Text style={styles.actionDescription}>Update trainer profiles</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.actionCard}
onPress={() => router.push('/admin/collections')}
>
<Text style={styles.actionIcon}>📁</Text>
<Text style={styles.actionTitle}>Manage Collections</Text>
<Text style={styles.actionDescription}>Organize workout collections</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.actionCard}
onPress={() => router.push('/admin/media')}
>
<Text style={styles.actionIcon}>🎬</Text>
<Text style={styles.actionTitle}>Media Library</Text>
<Text style={styles.actionDescription}>Upload videos and images</Text>
</TouchableOpacity>
</View>
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
paddingTop: 60,
borderBottomWidth: 1,
borderBottomColor: '#1C1C1E',
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: '#fff',
},
logoutButton: {
padding: 8,
},
logoutText: {
color: '#FF6B35',
fontSize: 16,
},
content: {
flex: 1,
padding: 20,
},
statsGrid: {
flexDirection: 'row',
gap: 12,
marginBottom: 32,
},
statCard: {
flex: 1,
backgroundColor: '#1C1C1E',
borderRadius: 12,
padding: 16,
alignItems: 'center',
},
statNumber: {
fontSize: 32,
fontWeight: 'bold',
color: '#FF6B35',
},
statLabel: {
fontSize: 14,
color: '#999',
marginTop: 4,
},
sectionTitle: {
fontSize: 20,
fontWeight: 'bold',
color: '#fff',
marginBottom: 16,
},
actionsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
actionCard: {
width: '47%',
backgroundColor: '#1C1C1E',
borderRadius: 12,
padding: 20,
marginBottom: 12,
},
actionIcon: {
fontSize: 32,
marginBottom: 12,
},
actionTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#fff',
marginBottom: 4,
},
actionDescription: {
fontSize: 14,
color: '#999',
},
})

124
app/admin/login.tsx Normal file
View File

@@ -0,0 +1,124 @@
import { useState } from 'react'
import {
View,
Text,
TextInput,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
} from 'react-native'
import { useAdminAuth } from '../../src/admin/components/AdminAuthProvider'
export default function AdminLoginScreen() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const { signIn, isLoading } = useAdminAuth()
const handleLogin = async () => {
if (!email || !password) {
setError('Please enter both email and password')
return
}
setError('')
try {
await signIn(email, password)
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed')
}
}
return (
<View style={styles.container}>
<View style={styles.card}>
<Text style={styles.title}>TabataFit Admin</Text>
<Text style={styles.subtitle}>Sign in to manage content</Text>
{error ? <Text style={styles.errorText}>{error}</Text> : null}
<TextInput
style={styles.input}
placeholder="Email"
placeholderTextColor="#666"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
<TextInput
style={styles.input}
placeholder="Password"
placeholderTextColor="#666"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<TouchableOpacity
style={styles.button}
onPress={handleLogin}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator color="#000" />
) : (
<Text style={styles.buttonText}>Sign In</Text>
)}
</TouchableOpacity>
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
card: {
backgroundColor: '#1C1C1E',
borderRadius: 16,
padding: 32,
width: '100%',
maxWidth: 400,
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: '#fff',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: '#999',
marginBottom: 24,
},
errorText: {
color: '#FF3B30',
marginBottom: 16,
},
input: {
backgroundColor: '#2C2C2E',
borderRadius: 8,
padding: 16,
marginBottom: 16,
color: '#fff',
fontSize: 16,
},
button: {
backgroundColor: '#FF6B35',
borderRadius: 8,
padding: 16,
alignItems: 'center',
},
buttonText: {
color: '#000',
fontSize: 16,
fontWeight: 'bold',
},
})

201
app/admin/media.tsx Normal file
View File

@@ -0,0 +1,201 @@
import { useState } from 'react'
import {
View,
Text,
ScrollView,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
Alert,
} from 'react-native'
import { useRouter } from 'expo-router'
import { supabase } from '../../src/shared/supabase'
export default function AdminMediaScreen() {
const router = useRouter()
const [uploading, setUploading] = useState(false)
const [activeTab, setActiveTab] = useState<'videos' | 'thumbnails' | 'avatars'>('videos')
const handleUpload = async () => {
Alert.alert('Info', 'File upload requires file picker integration. This is a placeholder.')
}
const handleDelete = async (path: string) => {
Alert.alert(
'Delete File',
`Are you sure you want to delete "${path}"?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
try {
const { error } = await supabase.storage
.from(activeTab)
.remove([path])
if (error) throw error
Alert.alert('Success', 'File deleted')
} catch (err) {
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to delete')
}
}
},
]
)
}
return (
<View style={styles.container}>
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Text style={styles.backText}> Back</Text>
</TouchableOpacity>
<Text style={styles.title}>Media Library</Text>
<TouchableOpacity style={styles.uploadButton} onPress={handleUpload}>
<Text style={styles.uploadButtonText}>Upload</Text>
</TouchableOpacity>
</View>
<View style={styles.tabs}>
{(['videos', 'thumbnails', 'avatars'] as const).map((tab) => (
<TouchableOpacity
key={tab}
style={[styles.tab, activeTab === tab && styles.activeTab]}
onPress={() => setActiveTab(tab)}
>
<Text style={[styles.tabText, activeTab === tab && styles.activeTabText]}>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
</Text>
</TouchableOpacity>
))}
</View>
<ScrollView style={styles.content}>
<View style={styles.infoCard}>
<Text style={styles.infoTitle}>Storage Buckets</Text>
<Text style={styles.infoText}>
videos - Workout videos (MP4, MOV){'\n'}
thumbnails - Workout thumbnails (JPG, PNG){'\n'}
avatars - Trainer avatars (JPG, PNG)
</Text>
</View>
<View style={styles.placeholderCard}>
<Text style={styles.placeholderIcon}>🎬</Text>
<Text style={styles.placeholderTitle}>Media Management</Text>
<Text style={styles.placeholderText}>
Upload and manage media files for workouts and trainers.{'\n\n'}
This feature requires file picker integration.{'\n'}
Files will be stored in Supabase Storage.
</Text>
</View>
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
paddingTop: 60,
borderBottomWidth: 1,
borderBottomColor: '#1C1C1E',
},
backButton: {
padding: 8,
},
backText: {
color: '#FF6B35',
fontSize: 16,
},
title: {
fontSize: 20,
fontWeight: 'bold',
color: '#fff',
},
uploadButton: {
backgroundColor: '#FF6B35',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
},
uploadButtonText: {
color: '#000',
fontWeight: 'bold',
},
tabs: {
flexDirection: 'row',
padding: 16,
gap: 8,
},
tab: {
flex: 1,
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 8,
backgroundColor: '#1C1C1E',
alignItems: 'center',
},
activeTab: {
backgroundColor: '#FF6B35',
},
tabText: {
color: '#999',
fontWeight: '600',
},
activeTabText: {
color: '#000',
},
content: {
flex: 1,
padding: 16,
},
infoCard: {
backgroundColor: '#1C1C1E',
borderRadius: 12,
padding: 16,
marginBottom: 16,
},
infoTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#fff',
marginBottom: 8,
},
infoText: {
fontSize: 14,
color: '#999',
lineHeight: 20,
},
placeholderCard: {
backgroundColor: '#1C1C1E',
borderRadius: 12,
padding: 32,
alignItems: 'center',
},
placeholderIcon: {
fontSize: 48,
marginBottom: 16,
},
placeholderTitle: {
fontSize: 18,
fontWeight: 'bold',
color: '#fff',
marginBottom: 8,
},
placeholderText: {
fontSize: 14,
color: '#999',
textAlign: 'center',
lineHeight: 20,
},
})

194
app/admin/trainers.tsx Normal file
View File

@@ -0,0 +1,194 @@
import { useState } from 'react'
import {
View,
Text,
ScrollView,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
Alert,
} from 'react-native'
import { useRouter } from 'expo-router'
import { useTrainers } from '../../src/shared/hooks/useSupabaseData'
import { adminService } from '../../src/admin/services/adminService'
import type { Trainer } from '../../src/shared/types'
export default function AdminTrainersScreen() {
const router = useRouter()
const { trainers, loading, refetch } = useTrainers()
const [deletingId, setDeletingId] = useState<string | null>(null)
const handleDelete = (trainer: Trainer) => {
Alert.alert(
'Delete Trainer',
`Are you sure you want to delete "${trainer.name}"?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
setDeletingId(trainer.id)
try {
await adminService.deleteTrainer(trainer.id)
await refetch()
} catch (err) {
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to delete')
} finally {
setDeletingId(null)
}
}
},
]
)
}
if (loading) {
return (
<View style={[styles.container, styles.centered]}>
<ActivityIndicator size="large" color="#FF6B35" />
</View>
)
}
return (
<View style={styles.container}>
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Text style={styles.backText}> Back</Text>
</TouchableOpacity>
<Text style={styles.title}>Trainers</Text>
<TouchableOpacity style={styles.addButton}>
<Text style={styles.addButtonText}>+ Add</Text>
</TouchableOpacity>
</View>
<ScrollView style={styles.content}>
{trainers.map((trainer) => (
<View key={trainer.id} style={styles.trainerCard}>
<View style={[styles.colorIndicator, { backgroundColor: trainer.color }]} />
<View style={styles.trainerInfo}>
<Text style={styles.trainerName}>{trainer.name}</Text>
<Text style={styles.trainerMeta}>
{trainer.specialty} {trainer.workoutCount} workouts
</Text>
</View>
<View style={styles.actions}>
<TouchableOpacity style={styles.editButton}>
<Text style={styles.editText}>Edit</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.deleteButton, deletingId === trainer.id && styles.disabledButton]}
onPress={() => handleDelete(trainer)}
disabled={deletingId === trainer.id}
>
<Text style={styles.deleteText}>
{deletingId === trainer.id ? '...' : 'Delete'}
</Text>
</TouchableOpacity>
</View>
</View>
))}
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
centered: {
justifyContent: 'center',
alignItems: 'center',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
paddingTop: 60,
borderBottomWidth: 1,
borderBottomColor: '#1C1C1E',
},
backButton: {
padding: 8,
},
backText: {
color: '#FF6B35',
fontSize: 16,
},
title: {
fontSize: 20,
fontWeight: 'bold',
color: '#fff',
},
addButton: {
backgroundColor: '#FF6B35',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
},
addButtonText: {
color: '#000',
fontWeight: 'bold',
},
content: {
flex: 1,
padding: 16,
},
trainerCard: {
backgroundColor: '#1C1C1E',
borderRadius: 12,
padding: 16,
marginBottom: 12,
flexDirection: 'row',
alignItems: 'center',
},
colorIndicator: {
width: 12,
height: 12,
borderRadius: 6,
marginRight: 12,
},
trainerInfo: {
flex: 1,
},
trainerName: {
fontSize: 16,
fontWeight: 'bold',
color: '#fff',
marginBottom: 4,
},
trainerMeta: {
fontSize: 14,
color: '#999',
},
actions: {
flexDirection: 'row',
gap: 8,
},
editButton: {
backgroundColor: '#2C2C2E',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 6,
},
editText: {
color: '#5AC8FA',
},
deleteButton: {
backgroundColor: '#2C2C2E',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 6,
},
disabledButton: {
opacity: 0.5,
},
deleteText: {
color: '#FF3B30',
},
})

190
app/admin/workouts.tsx Normal file
View File

@@ -0,0 +1,190 @@
import { useState } from 'react'
import {
View,
Text,
ScrollView,
TouchableOpacity,
StyleSheet,
ActivityIndicator,
Alert,
} from 'react-native'
import { useRouter } from 'expo-router'
import { useWorkouts } from '../../src/shared/hooks/useSupabaseData'
import { adminService } from '../../src/admin/services/adminService'
import type { Workout } from '../../src/shared/types'
export default function AdminWorkoutsScreen() {
const router = useRouter()
const { workouts, loading, error, refetch } = useWorkouts()
const [deletingId, setDeletingId] = useState<string | null>(null)
const handleDelete = (workout: Workout) => {
Alert.alert(
'Delete Workout',
`Are you sure you want to delete "${workout.title}"?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
setDeletingId(workout.id)
try {
await adminService.deleteWorkout(workout.id)
await refetch()
} catch (err) {
Alert.alert('Error', err instanceof Error ? err.message : 'Failed to delete')
} finally {
setDeletingId(null)
}
}
},
]
)
}
if (loading) {
return (
<View style={[styles.container, styles.centered]}>
<ActivityIndicator size="large" color="#FF6B35" />
</View>
)
}
return (
<View style={styles.container}>
<View style={styles.header}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Text style={styles.backText}> Back</Text>
</TouchableOpacity>
<Text style={styles.title}>Workouts</Text>
<TouchableOpacity style={styles.addButton}>
<Text style={styles.addButtonText}>+ Add</Text>
</TouchableOpacity>
</View>
<ScrollView style={styles.content}>
{workouts.map((workout) => (
<View key={workout.id} style={styles.workoutCard}>
<View style={styles.workoutInfo}>
<Text style={styles.workoutTitle}>{workout.title}</Text>
<Text style={styles.workoutMeta}>
{workout.category} {workout.level} {workout.duration}min
</Text>
<Text style={styles.workoutMeta}>
{workout.rounds} rounds {workout.calories} cal
</Text>
</View>
<View style={styles.actions}>
<TouchableOpacity style={styles.editButton}>
<Text style={styles.editText}>Edit</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.deleteButton, deletingId === workout.id && styles.disabledButton]}
onPress={() => handleDelete(workout)}
disabled={deletingId === workout.id}
>
<Text style={styles.deleteText}>
{deletingId === workout.id ? '...' : 'Delete'}
</Text>
</TouchableOpacity>
</View>
</View>
))}
</ScrollView>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
centered: {
justifyContent: 'center',
alignItems: 'center',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 20,
paddingTop: 60,
borderBottomWidth: 1,
borderBottomColor: '#1C1C1E',
},
backButton: {
padding: 8,
},
backText: {
color: '#FF6B35',
fontSize: 16,
},
title: {
fontSize: 20,
fontWeight: 'bold',
color: '#fff',
},
addButton: {
backgroundColor: '#FF6B35',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
},
addButtonText: {
color: '#000',
fontWeight: 'bold',
},
content: {
flex: 1,
padding: 16,
},
workoutCard: {
backgroundColor: '#1C1C1E',
borderRadius: 12,
padding: 16,
marginBottom: 12,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
workoutInfo: {
flex: 1,
},
workoutTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#fff',
marginBottom: 4,
},
workoutMeta: {
fontSize: 14,
color: '#999',
},
actions: {
flexDirection: 'row',
gap: 8,
},
editButton: {
backgroundColor: '#2C2C2E',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 6,
},
editText: {
color: '#5AC8FA',
},
deleteButton: {
backgroundColor: '#2C2C2E',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 6,
},
disabledButton: {
opacity: 0.5,
},
deleteText: {
color: '#FF3B30',
},
})

View File

@@ -29,7 +29,7 @@ import type { ThemeColors } from '@/src/shared/theme/types'
import { SPACING, LAYOUT } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
import { DURATION, EASE, SPRING } from '@/src/shared/constants/animations'
import { track } from '@/src/shared/services/analytics'
import { track, identifyUser, setUserProperties, trackScreen } from '@/src/shared/services/analytics'
import type { FitnessLevel, FitnessGoal, WeeklyFrequency } from '@/src/shared/types'
@@ -938,25 +938,43 @@ export default function OnboardingScreen() {
// Track onboarding_started + first step viewed on mount
useEffect(() => {
trackScreen('onboarding')
track('onboarding_started')
track('onboarding_step_viewed', { step: 1, step_name: STEP_NAMES[1] })
}, [])
const finishOnboarding = useCallback(
(plan: 'free' | 'premium-monthly' | 'premium-yearly') => {
const totalTime = Date.now() - onboardingStartTime.current
track('onboarding_completed', {
plan,
total_time_ms: Date.now() - onboardingStartTime.current,
total_time_ms: totalTime,
steps_completed: step,
})
completeOnboarding({
const userData = {
name: name.trim() || 'Athlete',
fitnessLevel: level,
goal,
weeklyFrequency: frequency,
barriers,
}
completeOnboarding(userData)
// Identify user in PostHog for session replay linking
const userId = `user_${Date.now()}` // In production, use actual user ID from backend
identifyUser(userId, {
name: userData.name,
fitness_level: level,
fitness_goal: goal,
weekly_frequency: frequency,
subscription_plan: plan,
onboarding_completed_at: new Date().toISOString(),
barriers: barriers.join(','),
})
if (plan !== 'free') {
setSubscription(plan)
}

409
app/paywall.tsx Normal file
View File

@@ -0,0 +1,409 @@
/**
* TabataFit Paywall Screen
* Premium subscription purchase flow
*/
import React from 'react'
import {
View,
StyleSheet,
ScrollView,
Pressable,
Text,
} from 'react-native'
import { useRouter } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { LinearGradient } from 'expo-linear-gradient'
import Ionicons from '@expo/vector-icons/Ionicons'
import { useTranslation } from 'react-i18next'
import { useHaptics, usePurchases } from '@/src/shared/hooks'
import { BRAND, darkColors } from '@/src/shared/theme'
import { SPACING } from '@/src/shared/constants/spacing'
import { RADIUS } from '@/src/shared/constants/borderRadius'
// ═══════════════════════════════════════════════════════════════════════════
// FEATURES LIST
// ═══════════════════════════════════════════════════════════════════════════
const PREMIUM_FEATURES = [
{ icon: 'musical-notes', key: 'music' },
{ icon: 'infinity', key: 'workouts' },
{ icon: 'stats-chart', key: 'stats' },
{ icon: 'flame', key: 'calories' },
{ icon: 'notifications', key: 'reminders' },
{ icon: 'close-circle', key: 'ads' },
]
// ═══════════════════════════════════════════════════════════════════════════
// COMPONENTS
// ═══════════════════════════════════════════════════════════════════════════
function PlanCard({
title,
price,
period,
savings,
isSelected,
onPress,
}: {
title: string
price: string
period: string
savings?: string
isSelected: boolean
onPress: () => void
}) {
const haptics = useHaptics()
const handlePress = () => {
haptics.selection()
onPress()
}
return (
<Pressable
onPress={handlePress}
style={({ pressed }) => [
styles.planCard,
isSelected && styles.planCardSelected,
pressed && styles.planCardPressed,
]}
>
{savings && (
<View style={styles.savingsBadge}>
<Text style={styles.savingsText}>{savings}</Text>
</View>
)}
<View style={styles.planInfo}>
<Text style={styles.planTitle}>{title}</Text>
<Text style={styles.planPeriod}>{period}</Text>
</View>
<Text style={styles.planPrice}>{price}</Text>
{isSelected && (
<View style={styles.checkmark}>
<Ionicons name="checkmark-circle" size={24} color={BRAND.PRIMARY} />
</View>
)}
</Pressable>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// MAIN SCREEN
// ═══════════════════════════════════════════════════════════════════════════
export default function PaywallScreen() {
const { t } = useTranslation('screens')
const router = useRouter()
const insets = useSafeAreaInsets()
const haptics = useHaptics()
const {
monthlyPackage,
annualPackage,
purchasePackage,
restorePurchases,
isLoading,
} = usePurchases()
const [selectedPlan, setSelectedPlan] = React.useState<'monthly' | 'annual'>('annual')
// Get prices from RevenueCat packages
const monthlyPrice = monthlyPackage?.product.priceString ?? '$4.99'
const annualPrice = annualPackage?.product.priceString ?? '$29.99'
const annualMonthlyEquivalent = annualPackage
? (annualPackage.product.price / 12).toFixed(2)
: '2.49'
const handlePurchase = async () => {
haptics.buttonTap()
const pkg = selectedPlan === 'monthly' ? monthlyPackage : annualPackage
if (!pkg) {
console.log('[Paywall] No package available for purchase')
return
}
const result = await purchasePackage(pkg)
if (result.success) {
haptics.workoutComplete()
router.back()
} else if (!result.cancelled) {
console.log('[Paywall] Purchase error:', result.error)
}
}
const handleRestore = async () => {
haptics.selection()
const restored = await restorePurchases()
if (restored) {
haptics.workoutComplete()
router.back()
}
}
const handleClose = () => {
haptics.selection()
router.back()
}
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* Background Gradient */}
<LinearGradient
colors={['#1a1a2e', '#16213e', '#0f0f1a']}
style={styles.gradient}
/>
{/* Close Button */}
<Pressable style={styles.closeButton} onPress={handleClose}>
<Ionicons name="close" size={28} color={darkColors.text.secondary} />
</Pressable>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[
styles.scrollContent,
{ paddingBottom: insets.bottom + 100 },
]}
showsVerticalScrollIndicator={false}
>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>TabataFit+</Text>
<Text style={styles.subtitle}>{t('paywall.subtitle')}</Text>
</View>
{/* Features Grid */}
<View style={styles.featuresGrid}>
{PREMIUM_FEATURES.map((feature) => (
<View key={feature.key} style={styles.featureItem}>
<View style={styles.featureIcon}>
<Ionicons name={feature.icon as any} size={22} color={BRAND.PRIMARY} />
</View>
<Text style={styles.featureText}>
{t(`paywall.features.${feature.key}`)}
</Text>
</View>
))}
</View>
{/* Plan Selection */}
<View style={styles.plansContainer}>
<PlanCard
title={t('paywall.yearly')}
price={annualPrice}
period={t('paywall.perYear')}
savings={t('paywall.save50')}
isSelected={selectedPlan === 'annual'}
onPress={() => setSelectedPlan('annual')}
/>
<PlanCard
title={t('paywall.monthly')}
price={monthlyPrice}
period={t('paywall.perMonth')}
isSelected={selectedPlan === 'monthly'}
onPress={() => setSelectedPlan('monthly')}
/>
</View>
{/* Price Note */}
{selectedPlan === 'annual' && (
<Text style={styles.priceNote}>
{t('paywall.equivalent', { price: annualMonthlyEquivalent })}
</Text>
)}
{/* CTA Button */}
<Pressable
style={[styles.ctaButton, isLoading && styles.ctaButtonDisabled]}
onPress={handlePurchase}
disabled={isLoading}
>
<LinearGradient
colors={[BRAND.PRIMARY, '#FF8A5B']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.ctaGradient}
>
<Text style={styles.ctaText}>
{isLoading ? t('paywall.processing') : t('paywall.subscribe')}
</Text>
</LinearGradient>
</Pressable>
{/* Restore & Terms */}
<View style={styles.footer}>
<Pressable onPress={handleRestore}>
<Text style={styles.restoreText}>{t('paywall.restore')}</Text>
</Pressable>
<Text style={styles.termsText}>{t('paywall.terms')}</Text>
</View>
</ScrollView>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// STYLES
// ═══════════════════════════════════════════════════════════════════════════
const styles = StyleSheet.create({
container: {
flex: 1,
},
gradient: {
...StyleSheet.absoluteFillObject,
},
closeButton: {
position: 'absolute',
top: SPACING[4],
right: SPACING[4],
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center',
zIndex: 10,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingHorizontal: SPACING[5],
paddingTop: SPACING[8],
},
header: {
alignItems: 'center',
},
title: {
fontSize: 32,
fontWeight: '700',
color: '#FFF',
textAlign: 'center',
},
subtitle: {
fontSize: 16,
color: darkColors.text.secondary,
textAlign: 'center',
marginTop: SPACING[2],
},
featuresGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
marginTop: SPACING[6],
marginHorizontal: -SPACING[2],
},
featureItem: {
width: '33%',
alignItems: 'center',
paddingVertical: SPACING[3],
},
featureIcon: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: 'rgba(255, 107, 53, 0.15)',
alignItems: 'center',
justifyContent: 'center',
marginBottom: SPACING[2],
},
featureText: {
fontSize: 13,
color: darkColors.text.secondary,
textAlign: 'center',
},
plansContainer: {
marginTop: SPACING[6],
gap: SPACING[3],
},
planCard: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.08)',
borderRadius: RADIUS.LG,
padding: SPACING[4],
borderWidth: 2,
borderColor: 'transparent',
},
planCardSelected: {
borderColor: BRAND.PRIMARY,
backgroundColor: 'rgba(255, 107, 53, 0.1)',
},
planCardPressed: {
opacity: 0.8,
},
savingsBadge: {
position: 'absolute',
top: -8,
right: SPACING[3],
backgroundColor: BRAND.PRIMARY,
paddingHorizontal: SPACING[2],
paddingVertical: 2,
borderRadius: RADIUS.SM,
},
savingsText: {
fontSize: 10,
fontWeight: '700',
color: '#FFF',
},
planInfo: {
flex: 1,
},
planTitle: {
fontSize: 16,
fontWeight: '600',
color: darkColors.text.primary,
},
planPeriod: {
fontSize: 13,
color: darkColors.text.tertiary,
marginTop: 2,
},
planPrice: {
fontSize: 20,
fontWeight: '700',
color: BRAND.PRIMARY,
},
checkmark: {
marginLeft: SPACING[2],
},
priceNote: {
fontSize: 13,
color: darkColors.text.tertiary,
textAlign: 'center',
marginTop: SPACING[3],
},
ctaButton: {
borderRadius: RADIUS.LG,
overflow: 'hidden',
marginTop: SPACING[6],
},
ctaButtonDisabled: {
opacity: 0.6,
},
ctaGradient: {
paddingVertical: SPACING[4],
alignItems: 'center',
},
ctaText: {
fontSize: 17,
fontWeight: '600',
color: '#FFF',
},
footer: {
marginTop: SPACING[5],
alignItems: 'center',
gap: SPACING[4],
},
restoreText: {
fontSize: 14,
color: darkColors.text.tertiary,
},
termsText: {
fontSize: 11,
color: darkColors.text.tertiary,
textAlign: 'center',
lineHeight: 18,
paddingHorizontal: SPACING[4],
},
})

View File

@@ -29,9 +29,11 @@ import { useTranslation } from 'react-i18next'
import { useTimer } from '@/src/shared/hooks/useTimer'
import { useHaptics } from '@/src/shared/hooks/useHaptics'
import { useAudio } from '@/src/shared/hooks/useAudio'
import { useMusicPlayer } from '@/src/shared/hooks/useMusicPlayer'
import { useActivityStore } from '@/src/shared/stores'
import { getWorkoutById } from '@/src/shared/data'
import { useTranslatedWorkout } from '@/src/shared/data/useTranslatedData'
import { useWatchSync } from '@/src/features/watch'
import { track } from '@/src/shared/services/analytics'
import { BRAND, PHASE_COLORS, GRADIENTS, darkColors } from '@/src/shared/theme'
@@ -280,8 +282,37 @@ export default function PlayerScreen() {
const timer = useTimer(rawWorkout ?? null)
const audio = useAudio()
// Music player - synced with workout timer
useMusicPlayer({
vibe: workout?.musicVibe ?? 'electronic',
isPlaying: timer.isRunning && !timer.isPaused,
})
const [showControls, setShowControls] = useState(true)
// Watch sync integration
const { isAvailable: isWatchAvailable, sendWorkoutState } = useWatchSync({
onPlay: () => {
timer.resume()
track('watch_control_play', { workout_id: workout?.id ?? id })
},
onPause: () => {
timer.pause()
track('watch_control_pause', { workout_id: workout?.id ?? id })
},
onSkip: () => {
timer.skip()
haptics.selection()
track('watch_control_skip', { workout_id: workout?.id ?? id })
},
onStop: () => {
haptics.phaseChange()
timer.stop()
router.back()
track('watch_control_stop', { workout_id: workout?.id ?? id })
},
})
// Animation refs
const timerScaleAnim = useRef(new Animated.Value(0.8)).current
const phaseColor = PHASE_COLORS[timer.phase].fill
@@ -398,6 +429,34 @@ export default function PlayerScreen() {
}
}, [timer.timeRemaining])
// Sync workout state with Apple Watch
useEffect(() => {
if (!isWatchAvailable || !timer.isRunning) return;
sendWorkoutState({
phase: timer.phase,
timeRemaining: timer.timeRemaining,
currentRound: timer.currentRound,
totalRounds: timer.totalRounds,
currentExercise: timer.currentExercise,
nextExercise: timer.nextExercise,
calories: timer.calories,
isPaused: timer.isPaused,
isPlaying: timer.isRunning && !timer.isPaused,
});
}, [
timer.phase,
timer.timeRemaining,
timer.currentRound,
timer.totalRounds,
timer.currentExercise,
timer.nextExercise,
timer.calories,
timer.isPaused,
timer.isRunning,
isWatchAvailable,
]);
return (
<View style={styles.container}>
<StatusBar hidden />

212
app/privacy.tsx Normal file
View File

@@ -0,0 +1,212 @@
/**
* TabataFit Privacy Policy Screen
* Required for App Store submission
*/
import React from 'react'
import { View, ScrollView, StyleSheet, Text, Pressable } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import Ionicons from '@expo/vector-icons/Ionicons'
import { useTranslation } from 'react-i18next'
import { useHaptics } from '@/src/shared/hooks'
import { darkColors, BRAND } from '@/src/shared/theme'
import { SPACING } from '@/src/shared/constants/spacing'
export default function PrivacyPolicyScreen() {
const { t } = useTranslation('screens')
const router = useRouter()
const insets = useSafeAreaInsets()
const haptics = useHaptics()
const handleClose = () => {
haptics.selection()
router.back()
}
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
{/* Header */}
<View style={styles.header}>
<Pressable style={styles.backButton} onPress={handleClose}>
<Ionicons name="chevron-back" size={28} color={darkColors.text.primary} />
</Pressable>
<Text style={styles.headerTitle}>{t('privacy.title')}</Text>
<View style={{ width: 44 }} />
</View>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[
styles.content,
{ paddingBottom: insets.bottom + 40 },
]}
showsVerticalScrollIndicator={false}
>
<Section title={t('privacy.lastUpdated')} />
<Section title={t('privacy.intro.title')}>
<Paragraph>{t('privacy.intro.content')}</Paragraph>
</Section>
<Section title={t('privacy.dataCollection.title')}>
<Paragraph>{t('privacy.dataCollection.content')}</Paragraph>
<BulletList
items={[
t('privacy.dataCollection.items.workouts'),
t('privacy.dataCollection.items.settings'),
t('privacy.dataCollection.items.device'),
]}
/>
</Section>
<Section title={t('privacy.usage.title')}>
<Paragraph>{t('privacy.usage.content')}</Paragraph>
</Section>
<Section title={t('privacy.sharing.title')}>
<Paragraph>{t('privacy.sharing.content')}</Paragraph>
</Section>
<Section title={t('privacy.security.title')}>
<Paragraph>{t('privacy.security.content')}</Paragraph>
</Section>
<Section title={t('privacy.rights.title')}>
<Paragraph>{t('privacy.rights.content')}</Paragraph>
</Section>
<Section title={t('privacy.contact.title')}>
<Paragraph>{t('privacy.contact.content')}</Paragraph>
<Text style={styles.email}>privacy@tabatafit.app</Text>
</Section>
<View style={styles.footer}>
<Text style={styles.footerText}>
TabataFit v1.0.0
</Text>
</View>
</ScrollView>
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// HELPER COMPONENTS
// ═══════════════════════════════════════════════════════════════════════════
function Section({
title,
children,
}: {
title: string
children?: React.ReactNode
}) {
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{title}</Text>
{children}
</View>
)
}
function Paragraph({ children }: { children: string }) {
return <Text style={styles.paragraph}>{children}</Text>
}
function BulletList({ items }: { items: string[] }) {
return (
<View style={styles.bulletList}>
{items.map((item, index) => (
<View key={index} style={styles.bulletItem}>
<Text style={styles.bullet}></Text>
<Text style={styles.bulletText}>{item}</Text>
</View>
))}
</View>
)
}
// ═══════════════════════════════════════════════════════════════════════════
// STYLES
// ═══════════════════════════════════════════════════════════════════════════
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: darkColors.bg.base,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: SPACING[4],
paddingVertical: SPACING[3],
borderBottomWidth: 1,
borderBottomColor: darkColors.border.glass,
},
backButton: {
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
fontSize: 17,
fontWeight: '600',
color: darkColors.text.primary,
},
scrollView: {
flex: 1,
},
content: {
paddingHorizontal: SPACING[5],
paddingTop: SPACING[4],
},
section: {
marginBottom: SPACING[6],
},
sectionTitle: {
fontSize: 20,
fontWeight: '700',
color: darkColors.text.primary,
marginBottom: SPACING[3],
},
paragraph: {
fontSize: 15,
lineHeight: 22,
color: darkColors.text.secondary,
},
bulletList: {
marginTop: SPACING[3],
},
bulletItem: {
flexDirection: 'row',
marginBottom: SPACING[2],
},
bullet: {
fontSize: 15,
color: BRAND.PRIMARY,
marginRight: SPACING[2],
},
bulletText: {
flex: 1,
fontSize: 15,
lineHeight: 22,
color: darkColors.text.secondary,
},
email: {
fontSize: 15,
color: BRAND.PRIMARY,
marginTop: SPACING[2],
},
footer: {
marginTop: SPACING[8],
alignItems: 'center',
},
footerText: {
fontSize: 13,
color: darkColors.text.tertiary,
},
})

View File

@@ -1,22 +1,22 @@
/**
* TabataFit Pre-Workout Detail Screen
* Dynamic data via route params
* Clean modal with workout info
*/
import { useState, useEffect, useMemo } from 'react'
import { View, Text as RNText, StyleSheet, ScrollView, Pressable } from 'react-native'
import { useRouter, useLocalSearchParams } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { LinearGradient } from 'expo-linear-gradient'
import { BlurView } from 'expo-blur'
import Ionicons from '@expo/vector-icons/Ionicons'
import { useTranslation } from 'react-i18next'
import { Host, Button, HStack } from '@expo/ui/swift-ui'
import { glassEffect, padding } from '@expo/ui/swift-ui/modifiers'
import { useHaptics } from '@/src/shared/hooks'
import { track } from '@/src/shared/services/analytics'
import { getWorkoutById } from '@/src/shared/data'
import { useTranslatedWorkout, useMusicVibeLabel } from '@/src/shared/data/useTranslatedData'
import { VideoPlayer } from '@/src/shared/components/VideoPlayer'
import { useThemeColors, BRAND } from '@/src/shared/theme'
import type { ThemeColors } from '@/src/shared/theme/types'
@@ -56,7 +56,7 @@ export default function WorkoutDetailScreen() {
if (!workout) {
return (
<View style={[styles.container, { paddingTop: insets.top, alignItems: 'center', justifyContent: 'center' }]}>
<View style={[styles.container, styles.centered]}>
<RNText style={{ color: colors.text.primary, fontSize: 17 }}>{t('screens:workout.notFound')}</RNText>
</View>
)
@@ -67,11 +67,6 @@ export default function WorkoutDetailScreen() {
router.push(`/player/${workout.id}`)
}
const handleGoBack = () => {
haptics.selection()
router.back()
}
const toggleSave = () => {
haptics.selection()
setIsSaved(!isSaved)
@@ -80,81 +75,59 @@ export default function WorkoutDetailScreen() {
const repeatCount = Math.max(1, Math.floor(workout.rounds / workout.exercises.length))
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
<View style={styles.container}>
{/* Header with SwiftUI glass button */}
<View style={[styles.header, { paddingTop: insets.top + SPACING[3] }]}>
<RNText style={styles.headerTitle} numberOfLines={1}>
{workout.title}
</RNText>
{/* SwiftUI glass button */}
<View style={styles.glassButtonContainer}>
<Host matchContents useViewportSizeMeasurement colorScheme="dark">
<HStack
alignment="center"
modifiers={[
padding({ all: 8 }),
glassEffect({ glass: { variant: 'regular' } }),
]}
>
<Button
variant="borderless"
onPress={toggleSave}
color={isSaved ? '#FF3B30' : '#FFFFFF'}
>
{isSaved ? '♥' : '♡'}
</Button>
</HStack>
</Host>
</View>
</View>
{/* Content */}
<ScrollView
style={styles.scrollView}
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 100 }]}
showsVerticalScrollIndicator={false}
>
{/* Video Preview */}
<View style={styles.videoPreview}>
<VideoPlayer
videoUrl={workout.videoUrl}
gradientColors={[BRAND.PRIMARY, BRAND.PRIMARY_DARK]}
mode="preview"
style={StyleSheet.absoluteFill}
/>
<LinearGradient
colors={['rgba(0,0,0,0.3)', 'transparent', 'rgba(0,0,0,0.7)']}
style={StyleSheet.absoluteFill}
/>
{/* Header overlay — on video, keep white */}
<View style={styles.headerOverlay}>
<Pressable onPress={handleGoBack} style={styles.headerButton}>
<BlurView intensity={colors.glass.blurLight} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
<Ionicons name="chevron-back" size={24} color="#FFFFFF" />
</Pressable>
<View style={styles.headerRight}>
<Pressable onPress={toggleSave} style={styles.headerButton}>
<BlurView intensity={colors.glass.blurLight} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
<Ionicons
name={isSaved ? 'heart' : 'heart-outline'}
size={24}
color={isSaved ? '#FF3B30' : '#FFFFFF'}
/>
</Pressable>
<Pressable style={styles.headerButton}>
<BlurView intensity={colors.glass.blurLight} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
<Ionicons name="ellipsis-horizontal" size={24} color="#FFFFFF" />
</Pressable>
</View>
{/* Quick stats */}
<View style={styles.quickStats}>
<View style={[styles.statBadge, { backgroundColor: 'rgba(255, 107, 53, 0.15)' }]}>
<Ionicons name="barbell" size={14} color={BRAND.PRIMARY} />
<RNText style={[styles.statBadgeText, { color: BRAND.PRIMARY }]}>
{t(`levels.${workout.level.toLowerCase()}`)}
</RNText>
</View>
{/* Workout icon — on brand bg, keep white */}
<View style={styles.trainerPreview}>
<View style={[styles.trainerAvatarLarge, { backgroundColor: BRAND.PRIMARY }]}>
<Ionicons name="flame" size={36} color="#FFFFFF" />
</View>
<View style={[styles.statBadge, { backgroundColor: colors.bg.surface }]}>
<Ionicons name="time" size={14} color={colors.text.secondary} />
<RNText style={styles.statBadgeText}>{t('units.minUnit', { count: workout.duration })}</RNText>
</View>
<View style={[styles.statBadge, { backgroundColor: colors.bg.surface }]}>
<Ionicons name="flame" size={14} color={colors.text.secondary} />
<RNText style={styles.statBadgeText}>{t('units.calUnit', { count: workout.calories })}</RNText>
</View>
</View>
{/* Title Section */}
<View style={styles.titleSection}>
<RNText style={styles.title}>{workout.title}</RNText>
{/* Quick stats */}
<View style={styles.quickStats}>
<View style={styles.statItem}>
<Ionicons name="barbell" size={16} color={BRAND.PRIMARY} />
<RNText style={styles.statText}>{t(`levels.${workout.level.toLowerCase()}`)}</RNText>
</View>
<RNText style={styles.statDot}></RNText>
<View style={styles.statItem}>
<Ionicons name="time" size={16} color={BRAND.PRIMARY} />
<RNText style={styles.statText}>{t('units.minUnit', { count: workout.duration })}</RNText>
</View>
<RNText style={styles.statDot}></RNText>
<View style={styles.statItem}>
<Ionicons name="flame" size={16} color={BRAND.PRIMARY} />
<RNText style={styles.statText}>{t('units.calUnit', { count: workout.calories })}</RNText>
</View>
</View>
</View>
<View style={styles.divider} />
{/* Equipment */}
<View style={styles.section}>
<RNText style={styles.sectionTitle}>{t('screens:workout.whatYoullNeed')}</RNText>
@@ -178,7 +151,7 @@ export default function WorkoutDetailScreen() {
<RNText style={styles.exerciseNumberText}>{index + 1}</RNText>
</View>
<RNText style={styles.exerciseName}>{exercise.name}</RNText>
<RNText style={styles.exerciseDuration}>{exercise.duration}s work</RNText>
<RNText style={styles.exerciseDuration}>{exercise.duration}s</RNText>
</View>
))}
<View style={styles.repeatNote}>
@@ -207,18 +180,16 @@ export default function WorkoutDetailScreen() {
{/* Fixed Start Button */}
<View style={[styles.bottomBar, { paddingBottom: insets.bottom + SPACING[4] }]}>
<BlurView intensity={colors.glass.blurHeavy} tint={colors.glass.blurTint} style={StyleSheet.absoluteFill} />
<View style={styles.startButtonContainer}>
<Pressable
style={({ pressed }) => [
styles.startButton,
pressed && styles.startButtonPressed,
]}
onPress={handleStartWorkout}
>
<RNText style={styles.startButtonText}>{t('screens:workout.startWorkout')}</RNText>
</Pressable>
</View>
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
<Pressable
style={({ pressed }) => [
styles.startButton,
pressed && styles.startButtonPressed,
]}
onPress={handleStartWorkout}
>
<RNText style={styles.startButtonText}>{t('screens:workout.startWorkout')}</RNText>
</Pressable>
</View>
</View>
)
@@ -234,6 +205,10 @@ function createStyles(colors: ThemeColors) {
flex: 1,
backgroundColor: colors.bg.base,
},
centered: {
alignItems: 'center',
justifyContent: 'center',
},
scrollView: {
flex: 1,
},
@@ -241,80 +216,53 @@ function createStyles(colors: ThemeColors) {
paddingHorizontal: LAYOUT.SCREEN_PADDING,
},
// Video Preview
videoPreview: {
height: 280,
marginHorizontal: -LAYOUT.SCREEN_PADDING,
marginBottom: SPACING[4],
backgroundColor: colors.bg.surface,
},
headerOverlay: {
// Header
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: SPACING[4],
justifyContent: 'space-between',
paddingHorizontal: SPACING[4],
paddingBottom: SPACING[3],
},
headerButton: {
headerTitle: {
flex: 1,
...TYPOGRAPHY.HEADLINE,
color: colors.text.primary,
marginRight: SPACING[3],
},
glassButtonContainer: {
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
headerRight: {
flexDirection: 'row',
gap: SPACING[2],
},
trainerPreview: {
position: 'absolute',
bottom: SPACING[4],
left: 0,
right: 0,
alignItems: 'center',
},
trainerAvatarLarge: {
width: 80,
height: 80,
borderRadius: 40,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 3,
borderColor: '#FFFFFF',
},
trainerInitial: {
...TYPOGRAPHY.HERO,
color: '#FFFFFF',
},
// Title Section
titleSection: {
marginBottom: SPACING[4],
},
title: {
...TYPOGRAPHY.LARGE_TITLE,
color: colors.text.primary,
marginBottom: SPACING[3],
},
// Quick Stats
quickStats: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
gap: SPACING[2],
marginBottom: SPACING[5],
},
statItem: {
statBadge: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: SPACING[3],
paddingVertical: SPACING[2],
borderRadius: RADIUS.FULL,
gap: SPACING[1],
},
statText: {
...TYPOGRAPHY.BODY,
statBadgeText: {
...TYPOGRAPHY.CAPTION_1,
color: colors.text.secondary,
fontWeight: '600',
},
statDot: {
color: colors.text.tertiary,
// Section
section: {
paddingVertical: SPACING[3],
},
sectionTitle: {
...TYPOGRAPHY.HEADLINE,
color: colors.text.primary,
marginBottom: SPACING[3],
},
// Divider
@@ -324,16 +272,6 @@ function createStyles(colors: ThemeColors) {
marginVertical: SPACING[2],
},
// Section
section: {
paddingVertical: SPACING[4],
},
sectionTitle: {
...TYPOGRAPHY.HEADLINE,
color: colors.text.primary,
marginBottom: SPACING[3],
},
// Equipment
equipmentItem: {
flexDirection: 'row',
@@ -434,10 +372,6 @@ function createStyles(colors: ThemeColors) {
borderTopWidth: 1,
borderTopColor: colors.border.glass,
},
startButtonContainer: {
height: 56,
justifyContent: 'center',
},
// Start Button
startButton: {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

After

Width:  |  Height:  |  Size: 151 KiB

67
assets/images/icon.svg Normal file
View File

@@ -0,0 +1,67 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Background gradient -->
<defs>
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1a1a2e"/>
<stop offset="100%" style="stop-color:#16213e"/>
</linearGradient>
<linearGradient id="timerGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#FF6B35"/>
<stop offset="100%" style="stop-color:#FF8A5B"/>
</linearGradient>
<linearGradient id="flameGradient" x1="0%" y1="100%" x2="0%" y2="0%">
<stop offset="0%" style="stop-color:#FF6B35"/>
<stop offset="50%" style="stop-color:#FF8A5B"/>
<stop offset="100%" style="stop-color:#FFB347"/>
</linearGradient>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="8" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- Background -->
<rect width="1024" height="1024" rx="220" fill="url(#bgGradient)"/>
<!-- Timer ring background -->
<circle cx="512" cy="512" r="320" stroke="#2a2a4a" stroke-width="40" fill="none"/>
<!-- Timer ring (animated feel - 75% complete) -->
<circle cx="512" cy="512" r="320" stroke="url(#timerGradient)" stroke-width="40" fill="none"
stroke-linecap="round" stroke-dasharray="1508" stroke-dashoffset="377"
transform="rotate(-90 512 512)" filter="url(#glow)"/>
<!-- Timer ticks -->
<g stroke="#3a3a5a" stroke-width="8" opacity="0.6">
<line x1="512" y1="152" x2="512" y2="192"/>
<line x1="512" y1="832" x2="512" y2="872"/>
<line x1="152" y1="512" x2="192" y2="512"/>
<line x1="832" y1="512" x2="872" y2="512"/>
</g>
<!-- Flame -->
<g transform="translate(512, 480)" filter="url(#glow)">
<!-- Outer flame -->
<path d="M0,-200 C60,-160 100,-80 80,0 C70,50 40,80 0,100 C-40,80 -70,50 -80,0 C-100,-80 -60,-160 0,-200 Z"
fill="url(#flameGradient)" opacity="0.9"/>
<!-- Middle flame -->
<path d="M0,-160 C40,-130 60,-60 50,0 C40,30 20,50 0,60 C-20,50 -40,30 -50,0 C-60,-60 -40,-130 0,-160 Z"
fill="#FFB347" opacity="0.8"/>
<!-- Inner flame (hot core) -->
<path d="M0,-100 C20,-80 30,-40 25,0 C20,15 10,25 0,30 C-10,25 -20,15 -25,0 C-30,-40 -20,-80 0,-100 Z"
fill="#FFF5E6" opacity="0.9"/>
</g>
<!-- Timer indicator dot -->
<circle cx="512" cy="192" r="24" fill="url(#timerGradient)" filter="url(#glow)"/>
<!-- Play button hint at bottom of flame -->
<g transform="translate(512, 620)">
<polygon points="0,-30 26,15 -26,15" fill="#FF6B35" opacity="0.8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

147
package-lock.json generated
View File

@@ -15,6 +15,7 @@
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@supabase/supabase-js": "^2.98.0",
"expo": "~54.0.33",
"expo-application": "~7.0.8",
"expo-av": "~16.0.8",
@@ -39,7 +40,9 @@
"expo-video": "~3.0.16",
"expo-web-browser": "~15.0.10",
"i18next": "^25.8.12",
"lucide-react": "^0.576.0",
"posthog-react-native": "^4.36.0",
"posthog-react-native-session-replay": "^1.5.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-i18next": "^16.5.4",
@@ -3218,6 +3221,107 @@
"@sinonjs/commons": "^3.0.0"
}
},
"node_modules/@supabase/auth-js": {
"version": "2.98.0",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.98.0.tgz",
"integrity": "sha512-GBH361T0peHU91AQNzOlIrjUZw9TZbB9YDRiyFgk/3Kvr3/Z1NWUZ2athWTfHhwNNi8IrW00foyFxQD9IO/Trg==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.98.0",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.98.0.tgz",
"integrity": "sha512-N/xEyiNU5Org+d+PNCpv+TWniAXRzxIURxDYsS/m2I/sfAB/HcM9aM2Dmf5edj5oWb9GxID1OBaZ8NMmPXL+Lg==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/postgrest-js": {
"version": "2.98.0",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.98.0.tgz",
"integrity": "sha512-v6e9WeZuJijzUut8HyXu6gMqWFepIbaeaMIm1uKzei4yLg9bC9OtEW9O14LE/9ezqNbSAnSLO5GtOLFdm7Bpkg==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.98.0",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.98.0.tgz",
"integrity": "sha512-rOWt28uGyFipWOSd+n0WVMr9kUXiWaa7J4hvyLCIHjRFqWm1z9CaaKAoYyfYMC1Exn3WT8WePCgiVhlAtWC2yw==",
"license": "MIT",
"dependencies": {
"@types/phoenix": "^1.6.6",
"@types/ws": "^8.18.1",
"tslib": "2.8.1",
"ws": "^8.18.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js/node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/@supabase/storage-js": {
"version": "2.98.0",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.98.0.tgz",
"integrity": "sha512-tzr2mG+v7ILSAZSfZMSL9OPyIH4z1ikgQ8EcQTKfMRz4EwmlFt3UnJaGzSOxyvF5b+fc9So7qdSUWTqGgeLokQ==",
"license": "MIT",
"dependencies": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.98.0",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.98.0.tgz",
"integrity": "sha512-Ohc97CtInLwZyiSASz7tT9/Abm/vqnIbO9REp+PivVUII8UZsuI3bngRQnYgJdFoOIwvaEII1fX1qy8x0CyNiw==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.98.0",
"@supabase/functions-js": "2.98.0",
"@supabase/postgrest-js": "2.98.0",
"@supabase/realtime-js": "2.98.0",
"@supabase/storage-js": "2.98.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -3339,6 +3443,12 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/phoenix": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
"integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.1.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz",
@@ -3355,6 +3465,15 @@
"integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==",
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yargs": {
"version": "17.0.35",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
@@ -7798,6 +7917,15 @@
}
}
},
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -9231,6 +9359,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "0.576.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.576.0.tgz",
"integrity": "sha512-koNxU14BXrxUfZQ9cUaP0ES1uyPZKYDjk31FQZB6dQ/x+tXk979sVAn9ppZ/pVeJJyOxVM8j1E+8QEuSc02Vug==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/makeerror": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
@@ -10514,6 +10651,16 @@
}
}
},
"node_modules/posthog-react-native-session-replay": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/posthog-react-native-session-replay/-/posthog-react-native-session-replay-1.5.0.tgz",
"integrity": "sha512-3XYGSpaWDfB0s4WrZlekN+dNO/kVSWCPAUBDmayIbFfL7SJ1OTCoYQrJp+JJdm8Wf+wJmrAv7LoPOvl/mY5A0g==",
"license": "MIT",
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",

View File

@@ -1,5 +1,5 @@
{
"name": "tabatago",
"name": "tabatafit",
"main": "expo-router/entry",
"version": "1.0.0",
"scripts": {
@@ -18,6 +18,7 @@
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@supabase/supabase-js": "^2.98.0",
"expo": "~54.0.33",
"expo-application": "~7.0.8",
"expo-av": "~16.0.8",
@@ -42,7 +43,9 @@
"expo-video": "~3.0.16",
"expo-web-browser": "~15.0.10",
"i18next": "^25.8.12",
"lucide-react": "^0.576.0",
"posthog-react-native": "^4.36.0",
"posthog-react-native-session-replay": "^1.5.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-i18next": "^16.5.4",

View 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
}

View 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()

View File

@@ -0,0 +1,2 @@
export * from './types';
export { useWatchSync } from './useWatchSync';

View File

@@ -0,0 +1,82 @@
/**
* Watch Communication Types
* Types for iPhone ↔ Watch bidirectional communication
*/
export type TimerPhase = 'PREP' | 'WORK' | 'REST' | 'COMPLETE';
export type WatchControlAction = 'play' | 'pause' | 'skip' | 'stop' | 'previous';
export interface WorkoutState {
phase: TimerPhase;
timeRemaining: number;
currentRound: number;
totalRounds: number;
currentExercise: string;
nextExercise?: string;
calories: number;
isPaused: boolean;
isPlaying: boolean;
}
export interface WatchAvailability {
isSupported: boolean;
isPaired: boolean;
isWatchAppInstalled: boolean;
isReachable: boolean;
}
export interface WatchMessage {
type: string;
[key: string]: any;
}
export interface WatchControlMessage extends WatchMessage {
type: 'control';
action: WatchControlAction;
timestamp?: number;
}
export interface WatchStateMessage extends WatchMessage {
type: 'workoutState';
phase: TimerPhase;
timeRemaining: number;
currentRound: number;
totalRounds: number;
currentExercise: string;
nextExercise?: string;
calories: number;
isPaused: boolean;
isPlaying: boolean;
}
export interface HeartRateMessage extends WatchMessage {
type: 'heartRate';
value: number;
timestamp: number;
}
export interface WatchConnectivityStatus {
reachable: boolean;
}
export interface WatchStateChanged {
isPaired: boolean;
isWatchAppInstalled: boolean;
isReachable: boolean;
}
export type WatchEventName =
| 'WatchConnectivityStatus'
| 'WatchStateChanged'
| 'WatchControlReceived'
| 'WatchMessageReceived';
export interface WatchBridgeModule {
isWatchAvailable(): Promise<WatchAvailability>;
sendWorkoutState(state: WorkoutState): void;
sendMessage(message: WatchMessage): void;
sendControl(action: WatchControlAction): void;
addListener(eventName: WatchEventName, callback: (data: any) => void): void;
removeListener(eventName: WatchEventName, callback: (data: any) => void): void;
}

View File

@@ -0,0 +1,215 @@
import { useEffect, useRef, useCallback } from 'react';
import {
NativeModules,
NativeEventEmitter,
Platform,
} from 'react-native';
import type {
WorkoutState,
WatchControlAction,
WatchAvailability,
WatchControlMessage,
} from './types';
const { WatchBridge } = NativeModules;
interface UseWatchSyncOptions {
onPlay?: () => void;
onPause?: () => void;
onSkip?: () => void;
onStop?: () => void;
onPrevious?: () => void;
onHeartRateUpdate?: (heartRate: number) => void;
enabled?: boolean;
}
interface UseWatchSyncReturn {
isAvailable: boolean;
isReachable: boolean;
sendWorkoutState: (state: WorkoutState) => void;
checkAvailability: () => Promise<WatchAvailability>;
}
/**
* Hook to sync workout state with Apple Watch
*
* @example
* ```tsx
* const { isAvailable, sendWorkoutState } = useWatchSync({
* onPlay: () => resume(),
* onPause: () => pause(),
* onSkip: () => skip(),
* onStop: () => stop(),
* });
*
* // Send state updates
* useEffect(() => {
* if (isAvailable && isRunning) {
* sendWorkoutState({
* phase,
* timeRemaining,
* currentRound,
* totalRounds,
* currentExercise,
* nextExercise,
* calories,
* isPaused,
* isPlaying: isRunning && !isPaused,
* });
* }
* }, [phase, timeRemaining, currentRound, isPaused]);
* ```
*/
export function useWatchSync(options: UseWatchSyncOptions = {}): UseWatchSyncReturn {
const {
onPlay,
onPause,
onSkip,
onStop,
onPrevious,
onHeartRateUpdate,
enabled = true,
} = options;
const isAvailableRef = useRef(false);
const isReachableRef = useRef(false);
const eventEmitterRef = useRef<NativeEventEmitter | null>(null);
// Initialize event emitter
useEffect(() => {
if (Platform.OS !== 'ios' || !enabled) return;
if (!WatchBridge) {
console.warn('WatchBridge native module not found');
return;
}
eventEmitterRef.current = new NativeEventEmitter(WatchBridge);
return () => {
// Cleanup will be handled by individual subscriptions
};
}, [enabled]);
// Listen for control events from Watch
useEffect(() => {
if (!eventEmitterRef.current || !enabled) return;
const subscriptions: any[] = [];
// Listen for control commands from Watch
const controlSubscription = eventEmitterRef.current.addListener(
'WatchControlReceived',
(data: { action: WatchControlAction }) => {
console.log('Received control from Watch:', data.action);
switch (data.action) {
case 'play':
onPlay?.();
break;
case 'pause':
onPause?.();
break;
case 'skip':
onSkip?.();
break;
case 'stop':
onStop?.();
break;
case 'previous':
onPrevious?.();
break;
}
}
);
subscriptions.push(controlSubscription);
// Listen for connectivity status changes
const statusSubscription = eventEmitterRef.current.addListener(
'WatchConnectivityStatus',
(data: { reachable: boolean }) => {
console.log('Watch connectivity changed:', data.reachable);
isReachableRef.current = data.reachable;
}
);
subscriptions.push(statusSubscription);
// Listen for general messages (including heart rate)
const messageSubscription = eventEmitterRef.current.addListener(
'WatchMessageReceived',
(data: { type: string; value?: number }) => {
if (data.type === 'heartRate' && typeof data.value === 'number') {
onHeartRateUpdate?.(data.value);
}
}
);
subscriptions.push(messageSubscription);
// Listen for watch state changes
const stateSubscription = eventEmitterRef.current.addListener(
'WatchStateChanged',
(data: { isReachable: boolean; isWatchAppInstalled: boolean }) => {
console.log('Watch state changed:', data);
isReachableRef.current = data.isReachable;
isAvailableRef.current = data.isWatchAppInstalled;
}
);
subscriptions.push(stateSubscription);
return () => {
subscriptions.forEach(sub => sub.remove());
};
}, [enabled, onPlay, onPause, onSkip, onStop, onPrevious, onHeartRateUpdate]);
// Check initial availability
useEffect(() => {
if (Platform.OS !== 'ios' || !enabled || !WatchBridge) return;
checkAvailability().catch(console.error);
}, [enabled]);
const checkAvailability = useCallback(async (): Promise<WatchAvailability> => {
if (Platform.OS !== 'ios' || !WatchBridge) {
return {
isSupported: false,
isPaired: false,
isWatchAppInstalled: false,
isReachable: false,
};
}
try {
const availability = await WatchBridge.isWatchAvailable();
isAvailableRef.current = availability.isWatchAppInstalled;
isReachableRef.current = availability.isReachable;
return availability;
} catch (error) {
console.error('Failed to check Watch availability:', error);
return {
isSupported: false,
isPaired: false,
isWatchAppInstalled: false,
isReachable: false,
};
}
}, []);
const sendWorkoutState = useCallback((state: WorkoutState) => {
if (Platform.OS !== 'ios' || !WatchBridge || !isReachableRef.current) {
return;
}
try {
WatchBridge.sendWorkoutState(state);
} catch (error) {
console.error('Failed to send workout state:', error);
}
}, []);
return {
isAvailable: isAvailableRef.current,
isReachable: isReachableRef.current,
sendWorkoutState,
checkAvailability,
};
}

View File

@@ -0,0 +1,314 @@
import { supabase, isSupabaseConfigured } from '../supabase'
import { WORKOUTS, TRAINERS, COLLECTIONS, PROGRAMS, ACHIEVEMENTS } from './index'
import type { Workout, Trainer, Collection, Program } from '../types'
import type { Database } from '../supabase/database.types'
type WorkoutRow = Database['public']['Tables']['workouts']['Row']
type TrainerRow = Database['public']['Tables']['trainers']['Row']
type CollectionRow = Database['public']['Tables']['collections']['Row']
type CollectionWorkoutRow = Database['public']['Tables']['collection_workouts']['Row']
type ProgramRow = Database['public']['Tables']['programs']['Row']
type ProgramWorkoutRow = Database['public']['Tables']['program_workouts']['Row']
function mapWorkoutFromDB(row: WorkoutRow): Workout {
return {
id: row.id,
title: row.title,
trainerId: row.trainer_id,
category: row.category,
level: row.level,
duration: row.duration as 4 | 8 | 12 | 20,
calories: row.calories,
rounds: row.rounds,
prepTime: row.prep_time,
workTime: row.work_time,
restTime: row.rest_time,
equipment: row.equipment,
musicVibe: row.music_vibe,
exercises: row.exercises,
thumbnailUrl: row.thumbnail_url ?? undefined,
videoUrl: row.video_url ?? undefined,
isFeatured: row.is_featured,
}
}
function mapTrainerFromDB(row: TrainerRow): Trainer {
return {
id: row.id,
name: row.name,
specialty: row.specialty,
color: row.color,
avatarUrl: row.avatar_url ?? undefined,
workoutCount: row.workout_count,
}
}
function mapCollectionFromDB(
row: CollectionRow,
workoutIds: string[]
): Collection {
return {
id: row.id,
title: row.title,
description: row.description,
icon: row.icon,
workoutIds,
gradient: row.gradient ? (row.gradient as [string, string]) : undefined,
}
}
function mapProgramFromDB(
row: ProgramRow,
workoutIds: string[]
): Program {
return {
id: row.id,
title: row.title,
description: row.description,
weeks: row.weeks,
workoutsPerWeek: row.workouts_per_week,
level: row.level,
workoutIds,
}
}
class SupabaseDataService {
async getAllWorkouts(): Promise<Workout[]> {
if (!isSupabaseConfigured()) {
return WORKOUTS
}
const { data, error } = await supabase
.from('workouts')
.select('*')
.order('created_at', { ascending: false })
if (error) {
console.error('Error fetching workouts:', error)
return WORKOUTS
}
return data?.map(mapWorkoutFromDB) ?? WORKOUTS
}
async getWorkoutById(id: string): Promise<Workout | undefined> {
if (!isSupabaseConfigured()) {
return WORKOUTS.find((w: Workout) => w.id === id)
}
const { data, error } = await supabase
.from('workouts')
.select('*')
.eq('id', id)
.single()
if (error || !data) {
console.error('Error fetching workout:', error)
return WORKOUTS.find((w: Workout) => w.id === id)
}
return mapWorkoutFromDB(data)
}
async getWorkoutsByCategory(category: string): Promise<Workout[]> {
if (!isSupabaseConfigured()) {
return WORKOUTS.filter((w: Workout) => w.category === category)
}
const { data, error } = await supabase
.from('workouts')
.select('*')
.eq('category', category)
if (error) {
console.error('Error fetching workouts by category:', error)
return WORKOUTS.filter((w: Workout) => w.category === category)
}
return data?.map(mapWorkoutFromDB) ?? WORKOUTS.filter((w: Workout) => w.category === category)
}
async getWorkoutsByTrainer(trainerId: string): Promise<Workout[]> {
if (!isSupabaseConfigured()) {
return WORKOUTS.filter((w: Workout) => w.trainerId === trainerId)
}
const { data, error } = await supabase
.from('workouts')
.select('*')
.eq('trainer_id', trainerId)
if (error) {
console.error('Error fetching workouts by trainer:', error)
return WORKOUTS.filter((w: Workout) => w.trainerId === trainerId)
}
return data?.map(mapWorkoutFromDB) ?? WORKOUTS.filter((w: Workout) => w.trainerId === trainerId)
}
async getFeaturedWorkouts(): Promise<Workout[]> {
if (!isSupabaseConfigured()) {
return WORKOUTS.filter((w: Workout) => w.isFeatured)
}
const { data, error } = await supabase
.from('workouts')
.select('*')
.eq('is_featured', true)
if (error) {
console.error('Error fetching featured workouts:', error)
return WORKOUTS.filter((w: Workout) => w.isFeatured)
}
return data?.map(mapWorkoutFromDB) ?? WORKOUTS.filter((w: Workout) => w.isFeatured)
}
async getAllTrainers(): Promise<Trainer[]> {
if (!isSupabaseConfigured()) {
return TRAINERS
}
const { data, error } = await supabase
.from('trainers')
.select('*')
if (error) {
console.error('Error fetching trainers:', error)
return TRAINERS
}
return data?.map(mapTrainerFromDB) ?? TRAINERS
}
async getTrainerById(id: string): Promise<Trainer | undefined> {
if (!isSupabaseConfigured()) {
return TRAINERS.find((t: Trainer) => t.id === id)
}
const { data, error } = await supabase
.from('trainers')
.select('*')
.eq('id', id)
.single()
if (error || !data) {
console.error('Error fetching trainer:', error)
return TRAINERS.find((t: Trainer) => t.id === id)
}
return mapTrainerFromDB(data)
}
async getAllCollections(): Promise<Collection[]> {
if (!isSupabaseConfigured()) {
return COLLECTIONS
}
const { data: collectionsData, error: collectionsError } = await supabase
.from('collections')
.select('*')
if (collectionsError) {
console.error('Error fetching collections:', collectionsError)
return COLLECTIONS
}
const { data: workoutLinks, error: linksError } = await supabase
.from('collection_workouts')
.select('*')
.order('sort_order')
if (linksError) {
console.error('Error fetching collection workouts:', linksError)
return COLLECTIONS
}
const workoutIdsByCollection: Record<string, string[]> = {}
workoutLinks?.forEach((link: CollectionWorkoutRow) => {
if (!workoutIdsByCollection[link.collection_id]) {
workoutIdsByCollection[link.collection_id] = []
}
workoutIdsByCollection[link.collection_id].push(link.workout_id)
})
return collectionsData?.map((row: CollectionRow) =>
mapCollectionFromDB(row, workoutIdsByCollection[row.id] || [])
) ?? COLLECTIONS
}
async getCollectionById(id: string): Promise<Collection | undefined> {
if (!isSupabaseConfigured()) {
return COLLECTIONS.find((c: Collection) => c.id === id)
}
const { data: collection, error: collectionError } = await supabase
.from('collections')
.select('*')
.eq('id', id)
.single()
if (collectionError || !collection) {
console.error('Error fetching collection:', collectionError)
return COLLECTIONS.find((c: Collection) => c.id === id)
}
const { data: workoutLinks, error: linksError } = await supabase
.from('collection_workouts')
.select('workout_id')
.eq('collection_id', id)
.order('sort_order')
if (linksError) {
console.error('Error fetching collection workouts:', linksError)
return COLLECTIONS.find((c: Collection) => c.id === id)
}
const workoutIds = workoutLinks?.map((link: { workout_id: string }) => link.workout_id) || []
return mapCollectionFromDB(collection, workoutIds)
}
async getAllPrograms(): Promise<Program[]> {
if (!isSupabaseConfigured()) {
return PROGRAMS
}
const { data: programsData, error: programsError } = await supabase
.from('programs')
.select('*')
if (programsError) {
console.error('Error fetching programs:', programsError)
return PROGRAMS
}
const { data: workoutLinks, error: linksError } = await supabase
.from('program_workouts')
.select('*')
.order('week_number')
.order('day_number')
if (linksError) {
console.error('Error fetching program workouts:', linksError)
return PROGRAMS
}
const workoutIdsByProgram: Record<string, string[]> = {}
workoutLinks?.forEach((link: ProgramWorkoutRow) => {
if (!workoutIdsByProgram[link.program_id]) {
workoutIdsByProgram[link.program_id] = []
}
workoutIdsByProgram[link.program_id].push(link.workout_id)
})
return programsData?.map((row: ProgramRow) =>
mapProgramFromDB(row, workoutIdsByProgram[row.id] || [])
) ?? PROGRAMS
}
async getAchievements() {
return ACHIEVEMENTS
}
}
export const dataService = new SupabaseDataService()

View File

@@ -6,5 +6,18 @@ export { useTimer } from './useTimer'
export type { TimerPhase } from './useTimer'
export { useHaptics } from './useHaptics'
export { useAudio } from './useAudio'
export { useMusicPlayer } from './useMusicPlayer'
export { useNotifications, requestNotificationPermissions } from './useNotifications'
export { usePurchases } from './usePurchases'
export {
useWorkouts,
useWorkout,
useWorkoutsByCategory,
useTrainers,
useTrainer,
useCollections,
useCollection,
usePrograms,
useFeaturedWorkouts,
useWorkoutsByTrainer,
} from './useSupabaseData'

View File

@@ -0,0 +1,240 @@
/**
* TabataFit Music Player Hook
* Manages background music playback synced with workout timer
* Loads tracks from Supabase Storage based on workout's musicVibe
*/
import { useRef, useEffect, useCallback, useState } from 'react'
import { Audio } from 'expo-av'
import { useUserStore } from '../stores'
import { musicService, type MusicTrack } from '../services/music'
import type { MusicVibe } from '../types'
interface UseMusicPlayerOptions {
vibe: MusicVibe
isPlaying: boolean
volume?: number
}
interface UseMusicPlayerReturn {
/** Current track being played */
currentTrack: MusicTrack | null
/** Whether music is loaded and ready */
isReady: boolean
/** Error message if loading failed */
error: string | null
/** Set volume (0-1) */
setVolume: (volume: number) => void
/** Skip to next track */
nextTrack: () => void
}
export function useMusicPlayer(options: UseMusicPlayerOptions): UseMusicPlayerReturn {
const { vibe, isPlaying, volume = 0.5 } = options
const musicEnabled = useUserStore((s) => s.settings.musicEnabled)
const soundRef = useRef<Audio.Sound | null>(null)
const tracksRef = useRef<MusicTrack[]>([])
const currentTrackIndexRef = useRef(0)
const [currentTrack, setCurrentTrack] = useState<MusicTrack | null>(null)
const [isReady, setIsReady] = useState(false)
const [error, setError] = useState<string | null>(null)
// Configure audio session for background music
useEffect(() => {
Audio.setAudioModeAsync({
playsInSilentModeIOS: true,
staysActiveInBackground: true,
shouldDuckAndroid: true,
// Mix with other audio (allows sound effects to play over music)
interruptionModeIOS: 1, // INTERRUPTION_MODE_IOS_MIX_WITH_OTHERS
interruptionModeAndroid: 1, // INTERRUPTION_MODE_ANDROID_DUCK_OTHERS
})
return () => {
// Cleanup on unmount
if (soundRef.current) {
soundRef.current.unloadAsync().catch(() => {})
soundRef.current = null
}
}
}, [])
// Load tracks when vibe changes
useEffect(() => {
let isMounted = true
async function loadTracks() {
try {
setIsReady(false)
setError(null)
// Unload current track if any
if (soundRef.current) {
await soundRef.current.unloadAsync()
soundRef.current = null
}
const tracks = await musicService.loadTracksForVibe(vibe)
if (!isMounted) return
tracksRef.current = tracks
if (tracks.length > 0) {
// Select random starting track
currentTrackIndexRef.current = Math.floor(Math.random() * tracks.length)
const track = tracks[currentTrackIndexRef.current]
setCurrentTrack(track)
await loadAndPlayTrack(track, false)
} else {
setError('No tracks available')
}
setIsReady(true)
} catch (err) {
if (!isMounted) return
setError(err instanceof Error ? err.message : 'Failed to load music')
setIsReady(false)
}
}
if (musicEnabled) {
loadTracks()
}
return () => {
isMounted = false
}
}, [vibe, musicEnabled])
// Load and prepare a track
const loadAndPlayTrack = useCallback(async (track: MusicTrack, autoPlay: boolean = true) => {
try {
if (soundRef.current) {
await soundRef.current.unloadAsync()
}
// For mock tracks without URLs, skip loading
if (!track.url) {
console.log(`[MusicPlayer] Mock track: ${track.title} - ${track.artist}`)
return
}
const { sound } = await Audio.Sound.createAsync(
{ uri: track.url },
{
shouldPlay: autoPlay && isPlaying && musicEnabled,
volume: volume,
isLooping: false,
},
onPlaybackStatusUpdate
)
soundRef.current = sound
} catch (err) {
console.error('[MusicPlayer] Error loading track:', err)
}
}, [isPlaying, musicEnabled, volume])
// Handle playback status updates
const onPlaybackStatusUpdate = useCallback((status: Audio.PlaybackStatus) => {
if (!status.isLoaded) return
// Track finished playing - load next
if (status.didJustFinish) {
playNextTrack()
}
}, [])
// Play next track
const playNextTrack = useCallback(async () => {
if (tracksRef.current.length === 0) return
currentTrackIndexRef.current = (currentTrackIndexRef.current + 1) % tracksRef.current.length
const nextTrack = tracksRef.current[currentTrackIndexRef.current]
setCurrentTrack(nextTrack)
await loadAndPlayTrack(nextTrack, isPlaying && musicEnabled)
}, [isPlaying, musicEnabled, loadAndPlayTrack])
// Handle play/pause based on workout state
useEffect(() => {
async function updatePlayback() {
if (!soundRef.current || !musicEnabled) return
try {
const status = await soundRef.current.getStatusAsync()
if (!status.isLoaded) return
if (isPlaying && !status.isPlaying) {
await soundRef.current.playAsync()
} else if (!isPlaying && status.isPlaying) {
await soundRef.current.pauseAsync()
}
} catch (err) {
console.error('[MusicPlayer] Error updating playback:', err)
}
}
updatePlayback()
}, [isPlaying, musicEnabled])
// Update volume when it changes
useEffect(() => {
async function updateVolume() {
if (!soundRef.current) return
try {
const status = await soundRef.current.getStatusAsync()
if (status.isLoaded) {
await soundRef.current.setVolumeAsync(volume)
}
} catch (err) {
console.error('[MusicPlayer] Error updating volume:', err)
}
}
updateVolume()
}, [volume])
// Update music enabled setting
useEffect(() => {
async function handleMusicToggle() {
if (!soundRef.current) return
try {
if (!musicEnabled) {
await soundRef.current.pauseAsync()
} else if (isPlaying) {
await soundRef.current.playAsync()
}
} catch (err) {
console.error('[MusicPlayer] Error toggling music:', err)
}
}
handleMusicToggle()
}, [musicEnabled, isPlaying])
// Set volume function
const setVolume = useCallback((newVolume: number) => {
const clampedVolume = Math.max(0, Math.min(1, newVolume))
// Volume is controlled via the store or parent component
// This function can be used to update the store
}, [])
// Next track function
const nextTrack = useCallback(async () => {
await playNextTrack()
}, [playNextTrack])
return {
currentTrack,
isReady,
error,
setVolume,
nextTrack,
}
}

View File

@@ -0,0 +1,397 @@
import { useState, useEffect, useCallback } from 'react'
import { dataService } from '../data/dataService'
import type { Workout, Trainer, Collection, Program } from '../types'
export function useWorkouts() {
const [workouts, setWorkouts] = useState<Workout[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const fetchWorkouts = useCallback(async () => {
try {
setLoading(true)
const data = await dataService.getAllWorkouts()
setWorkouts(data)
setError(null)
} catch (err) {
setError(err as Error)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
let mounted = true
async function load() {
try {
setLoading(true)
const data = await dataService.getAllWorkouts()
if (mounted) {
setWorkouts(data)
setError(null)
}
} catch (err) {
if (mounted) {
setError(err as Error)
}
} finally {
if (mounted) {
setLoading(false)
}
}
}
load()
return () => { mounted = false }
}, [])
return { workouts, loading, error, refetch: fetchWorkouts }
}
export function useWorkout(id: string | undefined) {
const [workout, setWorkout] = useState<Workout | undefined>()
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
let mounted = true
async function load() {
if (!id) {
setLoading(false)
return
}
try {
setLoading(true)
const data = await dataService.getWorkoutById(id)
if (mounted) {
setWorkout(data)
setError(null)
}
} catch (err) {
if (mounted) {
setError(err as Error)
}
} finally {
if (mounted) {
setLoading(false)
}
}
}
load()
return () => { mounted = false }
}, [id])
return { workout, loading, error }
}
export function useWorkoutsByCategory(category: string) {
const [workouts, setWorkouts] = useState<Workout[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
let mounted = true
async function load() {
try {
setLoading(true)
const data = await dataService.getWorkoutsByCategory(category)
if (mounted) {
setWorkouts(data)
setError(null)
}
} catch (err) {
if (mounted) {
setError(err as Error)
}
} finally {
if (mounted) {
setLoading(false)
}
}
}
load()
return () => { mounted = false }
}, [category])
return { workouts, loading, error }
}
export function useTrainers() {
const [trainers, setTrainers] = useState<Trainer[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const fetchTrainers = useCallback(async () => {
try {
setLoading(true)
const data = await dataService.getAllTrainers()
setTrainers(data)
setError(null)
} catch (err) {
setError(err as Error)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
let mounted = true
async function load() {
try {
setLoading(true)
const data = await dataService.getAllTrainers()
if (mounted) {
setTrainers(data)
setError(null)
}
} catch (err) {
if (mounted) {
setError(err as Error)
}
} finally {
if (mounted) {
setLoading(false)
}
}
}
load()
return () => { mounted = false }
}, [])
return { trainers, loading, error, refetch: fetchTrainers }
}
export function useTrainer(id: string | undefined) {
const [trainer, setTrainer] = useState<Trainer | undefined>()
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
let mounted = true
async function load() {
if (!id) {
setLoading(false)
return
}
try {
setLoading(true)
const data = await dataService.getTrainerById(id)
if (mounted) {
setTrainer(data)
setError(null)
}
} catch (err) {
if (mounted) {
setError(err as Error)
}
} finally {
if (mounted) {
setLoading(false)
}
}
}
load()
return () => { mounted = false }
}, [id])
return { trainer, loading, error }
}
export function useCollections() {
const [collections, setCollections] = useState<Collection[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const fetchCollections = useCallback(async () => {
try {
setLoading(true)
const data = await dataService.getAllCollections()
setCollections(data)
setError(null)
} catch (err) {
setError(err as Error)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
let mounted = true
async function load() {
try {
setLoading(true)
const data = await dataService.getAllCollections()
if (mounted) {
setCollections(data)
setError(null)
}
} catch (err) {
if (mounted) {
setError(err as Error)
}
} finally {
if (mounted) {
setLoading(false)
}
}
}
load()
return () => { mounted = false }
}, [])
return { collections, loading, error, refetch: fetchCollections }
}
export function useCollection(id: string | undefined) {
const [collection, setCollection] = useState<Collection | undefined>()
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
let mounted = true
async function load() {
if (!id) {
setLoading(false)
return
}
try {
setLoading(true)
const data = await dataService.getCollectionById(id)
if (mounted) {
setCollection(data)
setError(null)
}
} catch (err) {
if (mounted) {
setError(err as Error)
}
} finally {
if (mounted) {
setLoading(false)
}
}
}
load()
return () => { mounted = false }
}, [id])
return { collection, loading, error }
}
export function usePrograms() {
const [programs, setPrograms] = useState<Program[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
let mounted = true
async function load() {
try {
setLoading(true)
const data = await dataService.getAllPrograms()
if (mounted) {
setPrograms(data)
setError(null)
}
} catch (err) {
if (mounted) {
setError(err as Error)
}
} finally {
if (mounted) {
setLoading(false)
}
}
}
load()
return () => { mounted = false }
}, [])
return { programs, loading, error }
}
export function useFeaturedWorkouts() {
const [workouts, setWorkouts] = useState<Workout[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
let mounted = true
async function load() {
try {
setLoading(true)
const data = await dataService.getFeaturedWorkouts()
if (mounted) {
setWorkouts(data)
setError(null)
}
} catch (err) {
if (mounted) {
setError(err as Error)
}
} finally {
if (mounted) {
setLoading(false)
}
}
}
load()
return () => { mounted = false }
}, [])
return { workouts, loading, error }
}
export function useWorkoutsByTrainer(trainerId: string) {
const [workouts, setWorkouts] = useState<Workout[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
let mounted = true
async function load() {
try {
setLoading(true)
const data = await dataService.getWorkoutsByTrainer(trainerId)
if (mounted) {
setWorkouts(data)
setError(null)
}
} catch (err) {
if (mounted) {
setError(err as Error)
}
} finally {
if (mounted) {
setLoading(false)
}
}
}
load()
return () => { mounted = false }
}, [trainerId])
return { workouts, loading, error }
}

View File

@@ -50,17 +50,30 @@
"profile": {
"title": "Profil",
"guest": "Gast",
"memberSince": "Mitglied seit {{date}}",
"sectionAccount": "KONTO",
"sectionWorkout": "WORKOUT",
"sectionNotifications": "BENACHRICHTIGUNGEN",
"sectionAbout": "\u00dcBER",
"sectionSubscription": "ABONNEMENT",
"email": "E-Mail",
"plan": "Plan",
"freePlan": "Kostenlos",
"restorePurchases": "K\u00e4ufe wiederherstellen",
"hapticFeedback": "Haptisches Feedback",
"soundEffects": "Soundeffekte",
"voiceCoaching": "Sprachcoaching",
"dailyReminders": "T\u00e4gliche Erinnerungen",
"reminderTime": "Erinnerungszeit",
"version": "TabataFit v1.0.0"
"reminderFooter": "Erhalte eine t\u00e4gliche Erinnerung, um deine Serie zu halten",
"workoutSettingsFooter": "Passe dein Workout-Erlebnis an",
"upgradeTitle": "TabataFit+ freischalten",
"upgradeDescription": "Unbegrenzte Workouts, Offline-Downloads und mehr.",
"learnMore": "Mehr erfahren",
"version": "Version",
"privacyPolicy": "Datenschutzrichtlinie",
"signOut": "Abmelden"
},
"player": {

View File

@@ -50,17 +50,30 @@
"profile": {
"title": "Profile",
"guest": "Guest",
"memberSince": "Member since {{date}}",
"sectionAccount": "ACCOUNT",
"sectionWorkout": "WORKOUT",
"sectionNotifications": "NOTIFICATIONS",
"sectionAbout": "ABOUT",
"sectionSubscription": "SUBSCRIPTION",
"email": "Email",
"plan": "Plan",
"freePlan": "Free",
"restorePurchases": "Restore Purchases",
"hapticFeedback": "Haptic Feedback",
"soundEffects": "Sound Effects",
"voiceCoaching": "Voice Coaching",
"dailyReminders": "Daily Reminders",
"reminderTime": "Reminder Time",
"version": "TabataFit v1.0.0"
"reminderFooter": "Get a daily reminder to keep your streak going",
"workoutSettingsFooter": "Customize your workout experience",
"upgradeTitle": "Unlock TabataFit+",
"upgradeDescription": "Get unlimited workouts, offline downloads, and more.",
"learnMore": "Learn More",
"version": "Version",
"privacyPolicy": "Privacy Policy",
"signOut": "Sign Out"
},
"player": {
@@ -178,21 +191,62 @@
}
},
"paywall": {
"title": "Keep the momentum.\nWithout limits.",
"subtitle": "Unlock all features and reach your goals faster",
"features": {
"unlimited": "Unlimited workouts",
"offline": "Offline downloads",
"stats": "Advanced stats & Apple Watch",
"noAds": "No ads + Family Sharing"
"music": "Premium Music",
"workouts": "Unlimited Workouts",
"stats": "Advanced Stats",
"calories": "Calorie Tracking",
"reminders": "Daily Reminders",
"ads": "No Ads"
},
"bestValue": "BEST VALUE",
"yearlyPrice": "$49.99",
"monthlyPrice": "$6.99",
"savePercent": "Save 42%",
"trialCta": "START FREE TRIAL (7 days)",
"guarantees": "Cancel anytime \u00B7 30-day money-back guarantee",
"restorePurchases": "Restore Purchases",
"skipButton": "Continue without subscription"
"yearly": "Yearly",
"monthly": "Monthly",
"perYear": "per year",
"perMonth": "per month",
"save50": "SAVE 50%",
"equivalent": "Just ${{price}}/month",
"subscribe": "Subscribe Now",
"processing": "Processing...",
"restore": "Restore Purchases",
"terms": "Payment will be charged to your Apple ID at confirmation. Subscription auto-renews unless cancelled at least 24 hours before end of period. Manage in Account Settings."
},
"privacy": {
"title": "Privacy Policy",
"lastUpdated": "Last Updated: March 2026",
"intro": {
"title": "Introduction",
"content": "TabataFit is committed to protecting your privacy. This policy explains how we collect, use, and safeguard your information when you use our fitness app."
},
"dataCollection": {
"title": "Data We Collect",
"content": "We collect only the information necessary to provide you with the best workout experience:",
"items": {
"workouts": "Workout history and preferences",
"settings": "App settings and configurations",
"device": "Device type and OS version for optimization"
}
},
"usage": {
"title": "How We Use Your Data",
"content": "Your data is used solely to: personalize your workout experience, track your progress and achievements, sync your data across devices, and improve our app functionality."
},
"sharing": {
"title": "Data Sharing",
"content": "We do not sell or share your personal information with third parties. Your workout data remains private and secure on your device and in encrypted cloud storage."
},
"security": {
"title": "Security",
"content": "We implement industry-standard security measures to protect your data, including encryption and secure authentication."
},
"rights": {
"title": "Your Rights",
"content": "You have the right to access, modify, or delete your personal data at any time. You can export or delete your data from the app settings."
},
"contact": {
"title": "Contact Us",
"content": "If you have questions about this privacy policy, please contact us at:"
}
}
}
}

View File

@@ -50,17 +50,30 @@
"profile": {
"title": "Perfil",
"guest": "Invitado",
"memberSince": "Miembro desde {{date}}",
"sectionAccount": "CUENTA",
"sectionWorkout": "ENTRENAMIENTO",
"sectionNotifications": "NOTIFICACIONES",
"sectionAbout": "ACERCA DE",
"sectionSubscription": "SUSCRIPCI\u00d3N",
"email": "Correo electr\u00f3nico",
"plan": "Plan",
"freePlan": "Gratis",
"restorePurchases": "Restaurar compras",
"hapticFeedback": "Retroalimentaci\u00f3n h\u00e1ptica",
"soundEffects": "Efectos de sonido",
"voiceCoaching": "Coaching por voz",
"dailyReminders": "Recordatorios diarios",
"reminderTime": "Hora del recordatorio",
"version": "TabataFit v1.0.0"
"reminderFooter": "Recibe un recordatorio diario para mantener tu racha",
"workoutSettingsFooter": "Personaliza tu experiencia de entrenamiento",
"upgradeTitle": "Desbloquear TabataFit+",
"upgradeDescription": "Obt\u00e9n entrenos ilimitados, descargas sin conexi\u00f3n y m\u00e1s.",
"learnMore": "M\u00e1s informaci\u00f3n",
"version": "Versi\u00f3n",
"privacyPolicy": "Pol\u00edtica de privacidad",
"signOut": "Cerrar sesi\u00f3n"
},
"player": {

View File

@@ -50,17 +50,30 @@
"profile": {
"title": "Profil",
"guest": "Invit\u00e9",
"memberSince": "Membre depuis {{date}}",
"sectionAccount": "COMPTE",
"sectionWorkout": "ENTRA\u00ceNEMENT",
"sectionNotifications": "NOTIFICATIONS",
"sectionAbout": "\u00c0 PROPOS",
"sectionSubscription": "ABONNEMENT",
"email": "E-mail",
"plan": "Formule",
"freePlan": "Gratuit",
"restorePurchases": "Restaurer les achats",
"hapticFeedback": "Retour haptique",
"soundEffects": "Effets sonores",
"voiceCoaching": "Coaching vocal",
"dailyReminders": "Rappels quotidiens",
"reminderTime": "Heure du rappel",
"version": "TabataFit v1.0.0"
"reminderFooter": "Recevez un rappel quotidien pour maintenir votre s\u00e9rie",
"workoutSettingsFooter": "Personnalisez votre exp\u00e9rience d'entra\u00eenement",
"upgradeTitle": "D\u00e9bloquer TabataFit+",
"upgradeDescription": "Acc\u00e9dez \u00e0 des entra\u00eenements illimit\u00e9s, t\u00e9l\u00e9chargements hors ligne et plus.",
"learnMore": "En savoir plus",
"version": "Version",
"privacyPolicy": "Politique de confidentialit\u00e9",
"signOut": "Se d\u00e9connecter"
},
"player": {
@@ -178,21 +191,62 @@
}
},
"paywall": {
"title": "Gardez l'\u00e9lan.\nSans limites.",
"subtitle": "Débloquez toutes les fonctionnalités et atteignez vos objectifs plus vite",
"features": {
"unlimited": "Entra\u00eenements illimit\u00e9s",
"offline": "T\u00e9l\u00e9chargements hors ligne",
"stats": "Statistiques avanc\u00e9es & Apple Watch",
"noAds": "Sans pub + Partage familial"
"music": "Musique Premium",
"workouts": "Entraînements illimités",
"stats": "Statistiques avancées",
"calories": "Suivi des calories",
"reminders": "Rappels quotidiens",
"ads": "Sans publicités"
},
"bestValue": "MEILLEURE OFFRE",
"yearlyPrice": "49,99 $",
"monthlyPrice": "6,99 $",
"savePercent": "\u00c9conomisez 42%",
"trialCta": "ESSAI GRATUIT (7 jours)",
"guarantees": "Annulation \u00e0 tout moment \u00B7 Garantie satisfait ou rembours\u00e9 30 jours",
"restorePurchases": "Restaurer les achats",
"skipButton": "Continuer sans abonnement"
"yearly": "Annuel",
"monthly": "Mensuel",
"perYear": "par an",
"perMonth": "par mois",
"save50": "ÉCONOMISEZ 50%",
"equivalent": "Seulement {{price}} $/mois",
"subscribe": "S'abonner maintenant",
"processing": "Traitement...",
"restore": "Restaurer les achats",
"terms": "Le paiement sera débité sur votre identifiant Apple à la confirmation. L'abonnement se renouvelle automatiquement sauf annulation au moins 24h avant la fin de la période. Gérez dans les réglages du compte."
},
"privacy": {
"title": "Politique de Confidentialité",
"lastUpdated": "Dernière mise à jour : Mars 2026",
"intro": {
"title": "Introduction",
"content": "TabataFit s'engage à protéger votre vie privée. Cette politique explique comment nous collectons, utilisons et protégeons vos informations lorsque vous utilisez notre application de fitness."
},
"dataCollection": {
"title": "Données Collectées",
"content": "Nous collectons uniquement les informations nécessaires pour vous offrir la meilleure expérience d'entraînement :",
"items": {
"workouts": "Historique et préférences d'entraînement",
"settings": "Paramètres et configurations de l'app",
"device": "Type d'appareil et version iOS pour l'optimisation"
}
},
"usage": {
"title": "Utilisation des Données",
"content": "Vos données sont utilisées uniquement pour : personnaliser votre expérience, suivre vos progrès, synchroniser vos données sur vos appareils, et améliorer notre application."
},
"sharing": {
"title": "Partage des Données",
"content": "Nous ne vendons ni ne partageons vos informations personnelles avec des tiers. Vos données d'entraînement restent privées et sécurisées."
},
"security": {
"title": "Sécurité",
"content": "Nous mettons en œuvre des mesures de sécurité conformes aux standards de l'industrie pour protéger vos données, incluant le chiffrement et l'authentification sécurisée."
},
"rights": {
"title": "Vos Droits",
"content": "Vous avez le droit d'accéder, modifier ou supprimer vos données personnelles à tout moment. Vous pouvez exporter ou supprimer vos données depuis les paramètres de l'app."
},
"contact": {
"title": "Nous Contacter",
"content": "Si vous avez des questions sur cette politique de confidentialité, contactez-nous à :"
}
}
}
}

View File

@@ -3,14 +3,16 @@
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
### Feb 20, 2026
### Feb 28, 2026
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #5392 | 10:22 PM | 🔵 | User requests sandbox purchase testing capability for RevenueCat | ~514 |
| #5391 | 10:20 PM | 🟣 | RevenueCat subscription system fully integrated and tested | ~656 |
| #5386 | 10:16 PM | | Updated RevenueCat API key in production configuration | ~115 |
| #5384 | 9:28 PM | 🟣 | Implemented RevenueCat subscription system | ~190 |
| #5353 | 7:47 PM | 🟣 | RevenueCat Service Initialization Module Created | ~244 |
| #5275 | 2:42 PM | 🟣 | Implemented daily reminder feature for TabataGo app | ~204 |
| #5591 | 7:56 PM | | PostHog analytics enabled in development mode | ~249 |
| #5590 | 7:51 PM | 🔴 | Fixed screenshotMode configuration typo | ~177 |
| #5581 | 7:48 PM | 🟣 | PostHog session replay and advanced analytics enabled | ~370 |
| #5580 | " | 🔵 | PostHog configuration shows session replay disabled | ~229 |
| #5577 | 7:45 PM | 🟣 | PostHog API key configured in analytics service | ~255 |
| #5576 | 7:44 PM | 🔵 | PostHog service uses placeholder API key | ~298 |
| #5574 | " | 🔵 | Analytics service file located | ~193 |
| #5572 | 7:43 PM | 🔵 | PostHog integration points identified | ~228 |
</claude-mem-context>

View File

@@ -1,27 +1,27 @@
/**
* TabataFit PostHog Analytics Service
* Initialize and configure PostHog for user analytics
* Initialize and configure PostHog for user analytics and session replay
*
* Follows the same pattern as purchases.ts:
* - initializeAnalytics() called once at app startup
* - track() helper for type-safe event tracking
* - identifyUser() for user identification
* - Session replay enabled for onboarding funnel analysis
*/
import PostHog from 'posthog-react-native'
import PostHog, { PostHogConfig } from 'posthog-react-native'
type EventProperties = Record<string, string | number | boolean | string[]>
// PostHog configuration
// Replace with your actual PostHog project API key
const POSTHOG_API_KEY = '__YOUR_POSTHOG_API_KEY__'
const POSTHOG_HOST = 'https://us.i.posthog.com'
const POSTHOG_API_KEY = 'phc_9MuXpbtF6LfPycCAzI4xEPhggwwZyGiy9htW0jJ0LTi'
const POSTHOG_HOST = 'https://eu.i.posthog.com'
// Singleton client instance
let posthogClient: PostHog | null = null
/**
* Initialize PostHog SDK
* Initialize PostHog SDK with session replay
* Call this once at app startup (after store hydration)
*/
export async function initializeAnalytics(): Promise<PostHog | null> {
@@ -39,12 +39,30 @@ export async function initializeAnalytics(): Promise<PostHog | null> {
}
try {
posthogClient = new PostHog(POSTHOG_API_KEY, {
const config: PostHogConfig = {
host: POSTHOG_HOST,
enableSessionReplay: false,
})
// Session Replay - enabled for onboarding funnel analysis
enableSessionReplay: true,
sessionReplayConfig: {
// Mask sensitive inputs (passwords, etc.)
maskAllTextInputs: true,
// Capture screenshots for better replay quality
screenshotMode: 'lazy', // Only capture when needed
// Network capture for API debugging in replays
captureNetworkTelemetry: true,
},
// Autocapture configuration
autocapture: {
captureScreens: true,
captureTouches: true,
},
// Flush events more frequently during onboarding
flushAt: 10,
}
console.log('[Analytics] PostHog initialized successfully')
posthogClient = new PostHog(POSTHOG_API_KEY, config)
console.log('[Analytics] PostHog initialized with session replay')
return posthogClient
} catch (error) {
console.error('[Analytics] Failed to initialize PostHog:', error)
@@ -70,8 +88,16 @@ export function track(event: string, properties?: EventProperties): void {
posthogClient?.capture(event, properties)
}
/**
* Track a screen view
*/
export function trackScreen(screenName: string, properties?: EventProperties): void {
track('$screen', { $screen_name: screenName, ...properties })
}
/**
* Identify a user with traits
* Call after onboarding completion to link session replays to user
*/
export function identifyUser(
userId: string,
@@ -82,3 +108,41 @@ export function identifyUser(
}
posthogClient?.identify(userId, traits)
}
/**
* Set user properties (without identifying)
*/
export function setUserProperties(properties: EventProperties): void {
if (__DEV__) {
console.log('[Analytics] set user properties', properties)
}
posthogClient?.personProperties(properties)
}
/**
* Start a new session replay recording
* Useful for key moments like onboarding start
*/
export function startSessionRecording(): void {
if (__DEV__) {
console.log('[Analytics] start session recording')
}
posthogClient?.startSessionRecording()
}
/**
* Stop session replay recording
*/
export function stopSessionRecording(): void {
if (__DEV__) {
console.log('[Analytics] stop session recording')
}
posthogClient?.stopSessionRecording()
}
/**
* Get the current session URL for debugging
*/
export function getSessionReplayUrl(): string | null {
return posthogClient?.getSessionReplayUrl() ?? null
}

View File

@@ -0,0 +1,140 @@
import { supabase, isSupabaseConfigured } from '../supabase'
import type { MusicVibe } from '../types'
export interface MusicTrack {
id: string
title: string
artist: string
duration: number
url: string
vibe: MusicVibe
}
const MOCK_TRACKS: Record<MusicVibe, MusicTrack[]> = {
electronic: [
{ id: '1', title: 'Energy Pulse', artist: 'Neon Dreams', duration: 240, url: '', vibe: 'electronic' },
{ id: '2', title: 'Cyber Sprint', artist: 'Digital Flux', duration: 180, url: '', vibe: 'electronic' },
{ id: '3', title: 'High Voltage', artist: 'Circuit Breakers', duration: 200, url: '', vibe: 'electronic' },
],
'hip-hop': [
{ id: '4', title: 'Street Heat', artist: 'Urban Flow', duration: 210, url: '', vibe: 'hip-hop' },
{ id: '5', title: 'Rhythm Power', artist: 'Beat Masters', duration: 195, url: '', vibe: 'hip-hop' },
{ id: '6', title: 'Flow State', artist: 'MC Dynamic', duration: 220, url: '', vibe: 'hip-hop' },
],
pop: [
{ id: '7', title: 'Summer Energy', artist: 'The Popstars', duration: 185, url: '', vibe: 'pop' },
{ id: '8', title: 'Upbeat Vibes', artist: 'Chart Toppers', duration: 200, url: '', vibe: 'pop' },
{ id: '9', title: 'Feel Good', artist: 'Radio Hits', duration: 175, url: '', vibe: 'pop' },
],
rock: [
{ id: '10', title: 'Power Chord', artist: 'The Amplifiers', duration: 230, url: '', vibe: 'rock' },
{ id: '11', title: 'High Gain', artist: ' distortion', duration: 205, url: '', vibe: 'rock' },
{ id: '12', title: ' adrenaline', artist: 'Thunderstruck', duration: 215, url: '', vibe: 'rock' },
],
chill: [
{ id: '13', title: 'Smooth Flow', artist: 'Lo-Fi Beats', duration: 250, url: '', vibe: 'chill' },
{ id: '14', title: 'Zen Mode', artist: 'Calm Collective', duration: 240, url: '', vibe: 'chill' },
{ id: '15', title: 'Deep Breath', artist: 'Mindful Tones', duration: 260, url: '', vibe: 'chill' },
],
}
class MusicService {
private cache: Map<MusicVibe, MusicTrack[]> = new Map()
async loadTracksForVibe(vibe: MusicVibe): Promise<MusicTrack[]> {
if (this.cache.has(vibe)) {
return this.cache.get(vibe)!
}
if (!isSupabaseConfigured()) {
console.log(`[Music] Using mock tracks for vibe: ${vibe}`)
return MOCK_TRACKS[vibe] || MOCK_TRACKS.electronic
}
try {
const { data: files, error } = await supabase
.storage
.from('music')
.list(vibe)
if (error) {
console.error('[Music] Error loading tracks:', error)
return MOCK_TRACKS[vibe] || MOCK_TRACKS.electronic
}
if (!files || files.length === 0) {
console.log(`[Music] No tracks found for vibe: ${vibe}, using mock data`)
return MOCK_TRACKS[vibe] || MOCK_TRACKS.electronic
}
const tracks: MusicTrack[] = await Promise.all(
files
.filter(file => file.name.endsWith('.mp3') || file.name.endsWith('.m4a'))
.map(async (file, index) => {
const { data: urlData } = await supabase
.storage
.from('music')
.createSignedUrl(`${vibe}/${file.name}`, 3600)
const fileName = file.name.replace(/\.[^/.]+$/, '')
const [artist, title] = fileName.includes(' - ')
? fileName.split(' - ')
: ['Unknown Artist', fileName]
return {
id: `${vibe}-${index}`,
title: title || fileName,
artist: artist || 'Unknown Artist',
duration: 180,
url: urlData?.signedUrl || '',
vibe,
}
})
)
const validTracks = tracks.filter(track => track.url)
if (validTracks.length === 0) {
console.log(`[Music] No valid tracks for vibe: ${vibe}, using mock data`)
return MOCK_TRACKS[vibe] || MOCK_TRACKS.electronic
}
this.cache.set(vibe, validTracks)
console.log(`[Music] Loaded ${validTracks.length} tracks for vibe: ${vibe}`)
return validTracks
} catch (error) {
console.error('[Music] Error loading tracks:', error)
return MOCK_TRACKS[vibe] || MOCK_TRACKS.electronic
}
}
clearCache(vibe?: MusicVibe): void {
if (vibe) {
this.cache.delete(vibe)
} else {
this.cache.clear()
}
}
getRandomTrack(tracks: MusicTrack[]): MusicTrack | null {
if (tracks.length === 0) return null
const randomIndex = Math.floor(Math.random() * tracks.length)
return tracks[randomIndex]
}
getNextTrack(tracks: MusicTrack[], currentTrackId: string, shuffle: boolean = false): MusicTrack | null {
if (tracks.length === 0) return null
if (tracks.length === 1) return tracks[0]
if (shuffle) {
return this.getRandomTrack(tracks.filter(t => t.id !== currentTrackId))
}
const currentIndex = tracks.findIndex(t => t.id === currentTrackId)
const nextIndex = (currentIndex + 1) % tracks.length
return tracks[nextIndex]
}
}
export const musicService = new MusicService()

View File

@@ -45,6 +45,8 @@ export const useUserStore = create<UserState>()(
haptics: true,
soundEffects: true,
voiceCoaching: true,
musicEnabled: true,
musicVolume: 0.5,
reminders: false,
reminderTime: '09:00',
},

View File

@@ -0,0 +1,37 @@
/**
* TabataFit Supabase Client
* Initialize Supabase client with environment variables
*/
import { createClient } from '@supabase/supabase-js'
import type { Database } from './database.types'
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY
if (!supabaseUrl || !supabaseAnonKey) {
console.warn(
'Supabase credentials not found. Using mock data. ' +
'Please set EXPO_PUBLIC_SUPABASE_URL and EXPO_PUBLIC_SUPABASE_ANON_KEY in your .env file'
)
}
// Create Supabase client with type safety
export const supabase = createClient<Database>(
supabaseUrl ?? 'http://localhost:54321',
supabaseAnonKey ?? 'mock-key',
{
auth: {
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: false,
},
}
)
// Check if Supabase is properly configured
export const isSupabaseConfigured = (): boolean => {
return !!supabaseUrl && !!supabaseAnonKey &&
supabaseUrl !== 'http://localhost:54321' &&
supabaseAnonKey !== 'mock-key'
}

View File

@@ -0,0 +1,291 @@
/**
* TabataFit Supabase Database Types
* Generated types for the Supabase schema
*/
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export interface Database {
public: {
Tables: {
workouts: {
Row: {
id: string
title: string
trainer_id: string
category: 'full-body' | 'core' | 'upper-body' | 'lower-body' | 'cardio'
level: 'Beginner' | 'Intermediate' | 'Advanced'
duration: number
calories: number
rounds: number
prep_time: number
work_time: number
rest_time: number
equipment: string[]
music_vibe: 'electronic' | 'hip-hop' | 'pop' | 'rock' | 'chill'
exercises: {
name: string
duration: number
}[]
thumbnail_url: string | null
video_url: string | null
is_featured: boolean
created_at: string
updated_at: string
}
Insert: {
id?: string
title: string
trainer_id: string
category: 'full-body' | 'core' | 'upper-body' | 'lower-body' | 'cardio'
level: 'Beginner' | 'Intermediate' | 'Advanced'
duration: number
calories: number
rounds: number
prep_time: number
work_time: number
rest_time: number
equipment?: string[]
music_vibe: 'electronic' | 'hip-hop' | 'pop' | 'rock' | 'chill'
exercises: {
name: string
duration: number
}[]
thumbnail_url?: string | null
video_url?: string | null
is_featured?: boolean
created_at?: string
updated_at?: string
}
Update: {
id?: string
title?: string
trainer_id?: string
category?: 'full-body' | 'core' | 'upper-body' | 'lower-body' | 'cardio'
level?: 'Beginner' | 'Intermediate' | 'Advanced'
duration?: number
calories?: number
rounds?: number
prep_time?: number
work_time?: number
rest_time?: number
equipment?: string[]
music_vibe?: 'electronic' | 'hip-hop' | 'pop' | 'rock' | 'chill'
exercises?: {
name: string
duration: number
}[]
thumbnail_url?: string | null
video_url?: string | null
is_featured?: boolean
updated_at?: string
}
}
trainers: {
Row: {
id: string
name: string
specialty: string
color: string
avatar_url: string | null
workout_count: number
created_at: string
updated_at: string
}
Insert: {
id?: string
name: string
specialty: string
color: string
avatar_url?: string | null
workout_count?: number
created_at?: string
updated_at?: string
}
Update: {
id?: string
name?: string
specialty?: string
color?: string
avatar_url?: string | null
workout_count?: number
updated_at?: string
}
}
collections: {
Row: {
id: string
title: string
description: string
icon: string
gradient: string[] | null
created_at: string
updated_at: string
}
Insert: {
id?: string
title: string
description: string
icon: string
gradient?: string[] | null
created_at?: string
updated_at?: string
}
Update: {
id?: string
title?: string
description?: string
icon?: string
gradient?: string[] | null
updated_at?: string
}
}
collection_workouts: {
Row: {
id: string
collection_id: string
workout_id: string
sort_order: number
created_at: string
}
Insert: {
id?: string
collection_id: string
workout_id: string
sort_order?: number
created_at?: string
}
Update: {
id?: string
collection_id?: string
workout_id?: string
sort_order?: number
}
}
programs: {
Row: {
id: string
title: string
description: string
weeks: number
workouts_per_week: number
level: 'Beginner' | 'Intermediate' | 'Advanced'
created_at: string
updated_at: string
}
Insert: {
id?: string
title: string
description: string
weeks: number
workouts_per_week: number
level: 'Beginner' | 'Intermediate' | 'Advanced'
created_at?: string
updated_at?: string
}
Update: {
id?: string
title?: string
description?: string
weeks?: number
workouts_per_week?: number
level?: 'Beginner' | 'Intermediate' | 'Advanced'
updated_at?: string
}
}
program_workouts: {
Row: {
id: string
program_id: string
workout_id: string
week_number: number
day_number: number
created_at: string
}
Insert: {
id?: string
program_id: string
workout_id: string
week_number: number
day_number: number
created_at?: string
}
Update: {
id?: string
program_id?: string
workout_id?: string
week_number?: number
day_number?: number
}
}
achievements: {
Row: {
id: string
title: string
description: string
icon: string
requirement: number
type: 'workouts' | 'streak' | 'minutes' | 'calories'
created_at: string
updated_at: string
}
Insert: {
id?: string
title: string
description: string
icon: string
requirement: number
type: 'workouts' | 'streak' | 'minutes' | 'calories'
created_at?: string
updated_at?: string
}
Update: {
id?: string
title?: string
description?: string
icon?: string
requirement?: number
type?: 'workouts' | 'streak' | 'minutes' | 'calories'
updated_at?: string
}
}
admin_users: {
Row: {
id: string
email: string
role: 'admin' | 'editor'
created_at: string
last_login: string | null
}
Insert: {
id?: string
email: string
role?: 'admin' | 'editor'
created_at?: string
last_login?: string | null
}
Update: {
id?: string
email?: string
role?: 'admin' | 'editor'
last_login?: string | null
}
}
}
Views: {
[_ in never]: never
}
Functions: {
[_ in never]: never
}
Enums: {
[_ in never]: never
}
}
}

View File

@@ -0,0 +1,2 @@
export type { Database } from './database.types'
export { supabase, isSupabaseConfigured } from './client'

View File

@@ -8,6 +8,8 @@ export interface UserSettings {
haptics: boolean
soundEffects: boolean
voiceCoaching: boolean
musicEnabled: boolean
musicVolume: number
reminders: boolean
reminderTime: string
}

View 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
View 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()