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)
1
.claude/skills/building-native-ui
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/building-native-ui
|
||||
4
.env
Normal 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
@@ -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
@@ -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
@@ -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
|
After Width: | Height: | Size: 31 KiB |
41
admin-web/.gitignore
vendored
Normal 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
@@ -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
|
||||
94
admin-web/app/collections/page.tsx
Normal 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
|
After Width: | Height: | Size: 25 KiB |
126
admin-web/app/globals.css
Normal 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
@@ -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>
|
||||
);
|
||||
}
|
||||
119
admin-web/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
admin-web/app/media/page.tsx
Normal 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
@@ -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>
|
||||
);
|
||||
}
|
||||
114
admin-web/app/trainers/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
152
admin-web/app/workouts/page.tsx
Normal 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
@@ -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": {}
|
||||
}
|
||||
77
admin-web/components/sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
admin-web/components/ui/alert.tsx
Normal 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 }
|
||||
48
admin-web/components/ui/badge.tsx
Normal 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 }
|
||||
64
admin-web/components/ui/button.tsx
Normal 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 }
|
||||
92
admin-web/components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
158
admin-web/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
257
admin-web/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
}
|
||||
21
admin-web/components/ui/input.tsx
Normal 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 }
|
||||
24
admin-web/components/ui/label.tsx
Normal 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 }
|
||||
116
admin-web/components/ui/table.tsx
Normal 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,
|
||||
}
|
||||
91
admin-web/components/ui/tabs.tsx
Normal 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 }
|
||||
18
admin-web/eslint.config.mjs
Normal 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
@@ -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
@@ -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
@@ -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
33
admin-web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
admin-web/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
admin-web/public/file.svg
Normal 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 |
1
admin-web/public/globe.svg
Normal 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 |
1
admin-web/public/next.svg
Normal 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 |
1
admin-web/public/vercel.svg
Normal 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 |
1
admin-web/public/window.svg
Normal 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
@@ -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"]
|
||||
}
|
||||
13
app.json
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
@@ -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],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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
@@ -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],
|
||||
},
|
||||
})
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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: {
|
||||
|
||||
|
Before Width: | Height: | Size: 384 KiB After Width: | Height: | Size: 151 KiB |
67
assets/images/icon.svg
Normal 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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
64
src/admin/components/AdminAuthProvider.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
|
||||
import { adminService } from '../services/adminService'
|
||||
|
||||
interface AdminAuthContextType {
|
||||
isAuthenticated: boolean
|
||||
isAdmin: boolean
|
||||
isLoading: boolean
|
||||
signIn: (email: string, password: string) => Promise<void>
|
||||
signOut: () => Promise<void>
|
||||
}
|
||||
|
||||
const AdminAuthContext = createContext<AdminAuthContextType | undefined>(undefined)
|
||||
|
||||
export function AdminAuthProvider({ children }: { children: ReactNode }) {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth()
|
||||
}, [])
|
||||
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const user = await adminService.getCurrentUser()
|
||||
if (user) {
|
||||
setIsAuthenticated(true)
|
||||
const adminStatus = await adminService.isAdmin()
|
||||
setIsAdmin(adminStatus)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const signIn = async (email: string, password: string) => {
|
||||
await adminService.signIn(email, password)
|
||||
setIsAuthenticated(true)
|
||||
const adminStatus = await adminService.isAdmin()
|
||||
setIsAdmin(adminStatus)
|
||||
}
|
||||
|
||||
const signOut = async () => {
|
||||
await adminService.signOut()
|
||||
setIsAuthenticated(false)
|
||||
setIsAdmin(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminAuthContext.Provider value={{ isAuthenticated, isAdmin, isLoading, signIn, signOut }}>
|
||||
{children}
|
||||
</AdminAuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAdminAuth() {
|
||||
const context = useContext(AdminAuthContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useAdminAuth must be used within an AdminAuthProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
282
src/admin/services/adminService.ts
Normal file
@@ -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()
|
||||
2
src/features/watch/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './types';
|
||||
export { useWatchSync } from './useWatchSync';
|
||||
82
src/features/watch/types.ts
Normal 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;
|
||||
}
|
||||
215
src/features/watch/useWatchSync.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
314
src/shared/data/dataService.ts
Normal 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()
|
||||
@@ -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'
|
||||
|
||||
240
src/shared/hooks/useMusicPlayer.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
397
src/shared/hooks/useSupabaseData.ts
Normal 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 }
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 à :"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
140
src/shared/services/music.ts
Normal 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()
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
37
src/shared/supabase/client.ts
Normal 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'
|
||||
}
|
||||
291
src/shared/supabase/database.types.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
2
src/shared/supabase/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { Database } from './database.types'
|
||||
export { supabase, isSupabaseConfigured } from './client'
|
||||
@@ -8,6 +8,8 @@ export interface UserSettings {
|
||||
haptics: boolean
|
||||
soundEffects: boolean
|
||||
voiceCoaching: boolean
|
||||
musicEnabled: boolean
|
||||
musicVolume: number
|
||||
reminders: boolean
|
||||
reminderTime: string
|
||||
}
|
||||
|
||||
204
supabase/migrations/001_initial_schema.sql
Normal file
@@ -0,0 +1,204 @@
|
||||
-- Supabase Schema for TabataFit
|
||||
-- Run this in your Supabase SQL Editor
|
||||
|
||||
-- Enable UUID extension
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Trainers table
|
||||
CREATE TABLE trainers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
specialty TEXT NOT NULL,
|
||||
color TEXT NOT NULL,
|
||||
avatar_url TEXT,
|
||||
workout_count INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Workouts table
|
||||
CREATE TABLE workouts (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
trainer_id TEXT NOT NULL REFERENCES trainers(id) ON DELETE CASCADE,
|
||||
category TEXT NOT NULL CHECK (category IN ('full-body', 'core', 'upper-body', 'lower-body', 'cardio')),
|
||||
level TEXT NOT NULL CHECK (level IN ('Beginner', 'Intermediate', 'Advanced')),
|
||||
duration INTEGER NOT NULL CHECK (duration IN (4, 8, 12, 20)),
|
||||
calories INTEGER NOT NULL,
|
||||
rounds INTEGER NOT NULL,
|
||||
prep_time INTEGER NOT NULL DEFAULT 10,
|
||||
work_time INTEGER NOT NULL DEFAULT 20,
|
||||
rest_time INTEGER NOT NULL DEFAULT 10,
|
||||
equipment TEXT[] DEFAULT '{}',
|
||||
music_vibe TEXT NOT NULL CHECK (music_vibe IN ('electronic', 'hip-hop', 'pop', 'rock', 'chill')),
|
||||
exercises JSONB NOT NULL DEFAULT '[]',
|
||||
thumbnail_url TEXT,
|
||||
video_url TEXT,
|
||||
is_featured BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Collections table
|
||||
CREATE TABLE collections (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
icon TEXT NOT NULL,
|
||||
gradient TEXT[],
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Collection-Workout junction table
|
||||
CREATE TABLE collection_workouts (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
workout_id TEXT NOT NULL REFERENCES workouts(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
UNIQUE(collection_id, workout_id)
|
||||
);
|
||||
|
||||
-- Programs table
|
||||
CREATE TABLE programs (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
weeks INTEGER NOT NULL,
|
||||
workouts_per_week INTEGER NOT NULL,
|
||||
level TEXT NOT NULL CHECK (level IN ('Beginner', 'Intermediate', 'Advanced')),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Program-Workout junction table
|
||||
CREATE TABLE program_workouts (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
program_id TEXT NOT NULL REFERENCES programs(id) ON DELETE CASCADE,
|
||||
workout_id TEXT NOT NULL REFERENCES workouts(id) ON DELETE CASCADE,
|
||||
week_number INTEGER NOT NULL,
|
||||
day_number INTEGER NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
UNIQUE(program_id, week_number, day_number)
|
||||
);
|
||||
|
||||
-- Achievements table
|
||||
CREATE TABLE achievements (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
icon TEXT NOT NULL,
|
||||
requirement INTEGER NOT NULL,
|
||||
type TEXT NOT NULL CHECK (type IN ('workouts', 'streak', 'minutes', 'calories')),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Admin users table (for dashboard access)
|
||||
CREATE TABLE admin_users (
|
||||
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
email TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'editor' CHECK (role IN ('admin', 'editor')),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
last_login TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- Create storage buckets
|
||||
INSERT INTO storage.buckets (id, name, public) VALUES
|
||||
('videos', 'videos', true),
|
||||
('thumbnails', 'thumbnails', true),
|
||||
('avatars', 'avatars', true)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Set up storage policies
|
||||
CREATE POLICY "Public videos access"
|
||||
ON storage.objects FOR SELECT
|
||||
USING (bucket_id = 'videos');
|
||||
|
||||
CREATE POLICY "Admin videos upload"
|
||||
ON storage.objects FOR INSERT
|
||||
WITH CHECK (
|
||||
bucket_id = 'videos'
|
||||
AND EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid())
|
||||
);
|
||||
|
||||
CREATE POLICY "Public thumbnails access"
|
||||
ON storage.objects FOR SELECT
|
||||
USING (bucket_id = 'thumbnails');
|
||||
|
||||
CREATE POLICY "Admin thumbnails upload"
|
||||
ON storage.objects FOR INSERT
|
||||
WITH CHECK (
|
||||
bucket_id = 'thumbnails'
|
||||
AND EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid())
|
||||
);
|
||||
|
||||
CREATE POLICY "Public avatars access"
|
||||
ON storage.objects FOR SELECT
|
||||
USING (bucket_id = 'avatars');
|
||||
|
||||
CREATE POLICY "Admin avatars upload"
|
||||
ON storage.objects FOR INSERT
|
||||
WITH CHECK (
|
||||
bucket_id = 'avatars'
|
||||
AND EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid())
|
||||
);
|
||||
|
||||
-- Row Level Security policies
|
||||
ALTER TABLE trainers ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE workouts ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE collections ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE collection_workouts ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE programs ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE program_workouts ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE achievements ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE admin_users ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Public read access for all content
|
||||
CREATE POLICY "Public trainers read" ON trainers FOR SELECT USING (true);
|
||||
CREATE POLICY "Public workouts read" ON workouts FOR SELECT USING (true);
|
||||
CREATE POLICY "Public collections read" ON collections FOR SELECT USING (true);
|
||||
CREATE POLICY "Public collection_workouts read" ON collection_workouts FOR SELECT USING (true);
|
||||
CREATE POLICY "Public programs read" ON programs FOR SELECT USING (true);
|
||||
CREATE POLICY "Public program_workouts read" ON program_workouts FOR SELECT USING (true);
|
||||
CREATE POLICY "Public achievements read" ON achievements FOR SELECT USING (true);
|
||||
|
||||
-- Admin write access
|
||||
CREATE POLICY "Admin trainers all" ON trainers
|
||||
FOR ALL USING (EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid()));
|
||||
CREATE POLICY "Admin workouts all" ON workouts
|
||||
FOR ALL USING (EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid()));
|
||||
CREATE POLICY "Admin collections all" ON collections
|
||||
FOR ALL USING (EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid()));
|
||||
CREATE POLICY "Admin collection_workouts all" ON collection_workouts
|
||||
FOR ALL USING (EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid()));
|
||||
CREATE POLICY "Admin programs all" ON programs
|
||||
FOR ALL USING (EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid()));
|
||||
CREATE POLICY "Admin program_workouts all" ON program_workouts
|
||||
FOR ALL USING (EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid()));
|
||||
CREATE POLICY "Admin achievements all" ON achievements
|
||||
FOR ALL USING (EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid()));
|
||||
CREATE POLICY "Admin users manage" ON admin_users
|
||||
FOR ALL USING (EXISTS (SELECT 1 FROM admin_users WHERE id = auth.uid()));
|
||||
|
||||
-- Function to update updated_at timestamp
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- Triggers for updated_at
|
||||
CREATE TRIGGER update_trainers_updated_at BEFORE UPDATE ON trainers
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_workouts_updated_at BEFORE UPDATE ON workouts
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_collections_updated_at BEFORE UPDATE ON collections
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_programs_updated_at BEFORE UPDATE ON programs
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER update_achievements_updated_at BEFORE UPDATE ON achievements
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
183
supabase/seed.ts
Normal file
@@ -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()
|
||||