From 9dd1a4fe7ce15de155cb0361169e3842c7be7fd5 Mon Sep 17 00:00:00 2001 From: Millian Lamiaux Date: Sat, 14 Mar 2026 20:42:59 +0100 Subject: [PATCH] feat: add workout form component with advanced test mocking - Add WorkoutForm component for creating/editing workouts - Implement tabbed interface (Basics, Timing, Content, Media) - Add form validation and error handling - Add sophisticated mock infrastructure for Supabase - Include 32 test cases covering rendering, validation, and submission - Setup dynamic mock functions for testing different scenarios --- admin-web/components/workout-form.test.tsx | 805 +++++++++++++++++++++ admin-web/components/workout-form.tsx | 519 +++++++++++++ 2 files changed, 1324 insertions(+) create mode 100644 admin-web/components/workout-form.test.tsx create mode 100644 admin-web/components/workout-form.tsx diff --git a/admin-web/components/workout-form.test.tsx b/admin-web/components/workout-form.test.tsx new file mode 100644 index 0000000..b883ad4 --- /dev/null +++ b/admin-web/components/workout-form.test.tsx @@ -0,0 +1,805 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import WorkoutForm from './workout-form' + +// Mock Next.js router +const mockPush = vi.fn() +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + back: vi.fn(), + forward: vi.fn(), + refresh: vi.fn(), + replace: vi.fn(), + prefetch: vi.fn(), + }), +})) + +// Create mock functions that can be manipulated per test +const mockSelect = vi.fn() +const mockOrder = vi.fn() +const mockInsert = vi.fn() +const mockInsertSelect = vi.fn() +const mockInsertSelectSingle = vi.fn() +const mockUpdate = vi.fn() +const mockUpdateEq = vi.fn() +const mockUpdateEqSelect = vi.fn() +const mockUpdateEqSelectSingle = vi.fn() +const mockFrom = vi.fn() + +// Mock Supabase with dynamic mock functions +vi.mock('@/lib/supabase', () => ({ + supabase: { + from: (...args: any[]) => mockFrom(...args), + storage: { + from: vi.fn(() => ({ + move: vi.fn(() => Promise.resolve({ error: null })), + getPublicUrl: vi.fn(() => ({ data: { publicUrl: 'https://example.com/file.jpg' } })), + })), + }, + }, +})) + +// Mock storage functions +const mockMoveFiles = vi.fn() +vi.mock('@/lib/storage', () => ({ + moveFilesFromTempToWorkout: (...args: any[]) => mockMoveFiles(...args), + UPLOAD_CONFIGS: { + video: { + bucket: 'workout-videos', + maxSize: 500 * 1024 * 1024, + allowedTypes: ['video/mp4', 'video/webm', 'video/quicktime'], + }, + thumbnail: { + bucket: 'workout-thumbnails', + maxSize: 10 * 1024 * 1024, + allowedTypes: ['image/jpeg', 'image/png', 'image/webp'], + }, + avatar: { + bucket: 'trainer-avatars', + maxSize: 5 * 1024 * 1024, + allowedTypes: ['image/jpeg', 'image/png', 'image/webp'], + }, + }, +})) + +// Helper to setup default successful mocks +const setupDefaultMocks = () => { + // Reset all mocks + mockSelect.mockReset() + mockOrder.mockReset() + mockInsert.mockReset() + mockInsertSelect.mockReset() + mockInsertSelectSingle.mockReset() + mockUpdate.mockReset() + mockUpdateEq.mockReset() + mockUpdateEqSelect.mockReset() + mockUpdateEqSelectSingle.mockReset() + mockFrom.mockReset() + mockMoveFiles.mockReset() + + // Setup trainers query chain + mockOrder.mockResolvedValue({ + data: [{ id: 'trainer-1', name: 'Test Trainer' }], + error: null, + }) + mockSelect.mockReturnValue({ order: mockOrder }) + + // Setup insert chain for create mode + mockInsertSelectSingle.mockResolvedValue({ + data: { id: 'new-workout-id' }, + error: null, + }) + mockInsertSelect.mockReturnValue({ single: mockInsertSelectSingle }) + mockInsert.mockReturnValue({ select: mockInsertSelect }) + + // Setup update chain for edit mode + mockUpdateEqSelectSingle.mockResolvedValue({ + data: { id: 'test-id' }, + error: null, + }) + mockUpdateEqSelect.mockReturnValue({ single: mockUpdateEqSelectSingle }) + mockUpdateEq.mockReturnValue({ select: mockUpdateEqSelect }) + mockUpdate.mockReturnValue({ eq: mockUpdateEq }) + + // Setup main from mock + mockFrom.mockImplementation((table: string) => { + if (table === 'trainers') { + return { select: mockSelect } + } + if (table === 'workouts') { + return { + select: mockSelect, + insert: mockInsert, + update: mockUpdate, + } + } + return { + select: mockSelect, + insert: mockInsert, + update: mockUpdate, + } + }) + + // Setup move files mock + mockMoveFiles.mockResolvedValue({ + thumbnail: null, + video: null, + errors: [], + }) +} + +describe('WorkoutForm', () => { + beforeEach(() => { + setupDefaultMocks() + mockPush.mockClear() + }) + + describe('rendering', () => { + it('should render form in create mode', async () => { + render() + + await waitFor(() => { + expect(screen.getByLabelText(/workout title/i)).toBeInTheDocument() + }) + + expect(screen.getByRole('tab', { name: /basics/i })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: /timing/i })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: /content/i })).toBeInTheDocument() + expect(screen.getByRole('tab', { name: /media/i })).toBeInTheDocument() + }) + + it('should render form in edit mode', async () => { + const initialData = { + id: 'test-id', + title: 'Test Workout', + trainer_id: 'trainer-1', + category: 'full-body' as const, + level: 'Beginner' as const, + duration: 4, + rounds: 8, + prep_time: 10, + work_time: 20, + rest_time: 10, + calories: 45, + equipment: [], + music_vibe: 'electronic' as const, + exercises: [{ name: 'Push-ups', duration: 20 }], + thumbnail_url: null, + video_url: null, + is_featured: false, + created_at: '2024-01-01', + updated_at: '2024-01-01', + } + + render() + + await waitFor(() => { + expect(screen.getByDisplayValue('Test Workout')).toBeInTheDocument() + }) + }) + + it('should show loading state for trainers initially', async () => { + render() + + expect(screen.getByLabelText(/workout title/i)).toBeInTheDocument() + }) + }) + + describe('form fields', () => { + it('should update title field', async () => { + render() + + await waitFor(() => { + expect(screen.getByLabelText(/workout title/i)).toBeInTheDocument() + }) + + const titleInput = screen.getByLabelText(/workout title/i) + await userEvent.type(titleInput, 'My Workout') + + expect(titleInput).toHaveValue('My Workout') + }) + + it('should toggle featured workout', async () => { + render() + + await waitFor(() => { + expect(screen.getByRole('switch')).toBeInTheDocument() + }) + + const toggle = screen.getByRole('switch') + expect(toggle).toHaveAttribute('aria-checked', 'false') + + await userEvent.click(toggle) + expect(toggle).toHaveAttribute('aria-checked', 'true') + }) + }) + + describe('tab navigation', () => { + it('should navigate to Timing tab', async () => { + render() + + await waitFor(() => { + expect(screen.getByRole('tab', { name: /timing/i })).toBeInTheDocument() + }) + + await userEvent.click(screen.getByRole('tab', { name: /timing/i })) + + expect(screen.getByRole('tab', { name: /timing/i })).toHaveAttribute('data-state', 'active') + }) + + it('should navigate to Content tab', async () => { + render() + + await waitFor(() => { + expect(screen.getByRole('tab', { name: /content/i })).toBeInTheDocument() + }) + + await userEvent.click(screen.getByRole('tab', { name: /content/i })) + + expect(screen.getByRole('tab', { name: /content/i })).toHaveAttribute('data-state', 'active') + expect(screen.getByText(/add exercise/i)).toBeInTheDocument() + }) + + it('should navigate to Media tab', async () => { + render() + + await waitFor(() => { + expect(screen.getByRole('tab', { name: /media/i })).toBeInTheDocument() + }) + + await userEvent.click(screen.getByRole('tab', { name: /media/i })) + + expect(screen.getByRole('tab', { name: /media/i })).toHaveAttribute('data-state', 'active') + }) + + it('should show music vibe in Media tab', async () => { + render() + + await waitFor(() => { + expect(screen.getByRole('tab', { name: /media/i })).toBeInTheDocument() + }) + + await userEvent.click(screen.getByRole('tab', { name: /media/i })) + + expect(screen.getByLabelText(/music vibe/i)).toBeInTheDocument() + }) + + it('should persist data when switching tabs', async () => { + render() + + await waitFor(() => { + expect(screen.getByLabelText(/workout title/i)).toBeInTheDocument() + }) + + await userEvent.type(screen.getByLabelText(/workout title/i), 'Test Workout') + await userEvent.click(screen.getByRole('tab', { name: /timing/i })) + await userEvent.click(screen.getByRole('tab', { name: /basics/i })) + + expect(screen.getByDisplayValue('Test Workout')).toBeInTheDocument() + }) + }) + + describe('validation', () => { + it('should show title validation error', async () => { + render() + + await waitFor(() => { + expect(screen.getByRole('button', { name: /create workout/i })).toBeInTheDocument() + }) + + const submitButton = screen.getByRole('button', { name: /create workout/i }) + await userEvent.click(submitButton) + + await waitFor(() => { + expect(screen.getByText(/title is required/i)).toBeInTheDocument() + }) + }) + + it('should clear validation error when field is filled', async () => { + render() + + await waitFor(() => { + expect(screen.getByRole('button', { name: /create workout/i })).toBeInTheDocument() + }) + + await userEvent.click(screen.getByRole('button', { name: /create workout/i })) + + await waitFor(() => { + expect(screen.getByText(/title is required/i)).toBeInTheDocument() + }, { timeout: 3000 }) + + await userEvent.type(screen.getByLabelText(/workout title/i), 'Test Workout') + expect(screen.getByDisplayValue('Test Workout')).toBeInTheDocument() + }) + }) + + describe('cancel action', () => { + it('should navigate back on cancel in create mode', async () => { + render() + + await waitFor(() => { + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument() + }) + + const cancelButton = screen.getByRole('button', { name: /cancel/i }) + await userEvent.click(cancelButton) + + expect(mockPush).toHaveBeenCalledWith('/workouts') + }) + + it('should navigate to workout detail on cancel in edit mode', async () => { + const initialData = { + id: 'test-id', + title: 'Test Workout', + trainer_id: 'trainer-1', + category: 'full-body' as const, + level: 'Beginner' as const, + duration: 4, + rounds: 8, + prep_time: 10, + work_time: 20, + rest_time: 10, + calories: 45, + equipment: [], + music_vibe: 'electronic' as const, + exercises: [{ name: 'Push-ups', duration: 20 }], + thumbnail_url: null, + video_url: null, + is_featured: false, + created_at: '2024-01-01', + updated_at: '2024-01-01', + } + + render() + + await waitFor(() => { + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument() + }) + + const cancelButton = screen.getByRole('button', { name: /cancel/i }) + await userEvent.click(cancelButton) + + expect(mockPush).toHaveBeenCalledWith('/workouts/test-id') + }) + }) + + describe('timing fields', () => { + it('should update rounds field', async () => { + render() + + await waitFor(() => { + expect(screen.getByRole('tab', { name: /timing/i })).toBeInTheDocument() + }) + + await userEvent.click(screen.getByRole('tab', { name: /timing/i })) + + const roundsInput = screen.getByLabelText(/rounds/i) + await userEvent.clear(roundsInput) + await userEvent.type(roundsInput, '12') + + expect(roundsInput).toHaveValue(12) + }) + + it('should update calories field', async () => { + render() + + await waitFor(() => { + expect(screen.getByRole('tab', { name: /timing/i })).toBeInTheDocument() + }) + + await userEvent.click(screen.getByRole('tab', { name: /timing/i })) + + const caloriesInput = screen.getByLabelText(/calories/i) + await userEvent.clear(caloriesInput) + await userEvent.type(caloriesInput, '60') + + expect(caloriesInput).toHaveValue(60) + }) + + it('should update prep time field', async () => { + render() + + await waitFor(() => { + expect(screen.getByRole('tab', { name: /timing/i })).toBeInTheDocument() + }) + + await userEvent.click(screen.getByRole('tab', { name: /timing/i })) + + const prepInput = screen.getByLabelText(/prep time/i) + await userEvent.clear(prepInput) + await userEvent.type(prepInput, '15') + + expect(prepInput).toHaveValue(15) + }) + + it('should update work time field', async () => { + render() + + await waitFor(() => { + expect(screen.getByRole('tab', { name: /timing/i })).toBeInTheDocument() + }) + + await userEvent.click(screen.getByRole('tab', { name: /timing/i })) + + const workInput = screen.getByLabelText(/work time/i) + await userEvent.clear(workInput) + await userEvent.type(workInput, '30') + + expect(workInput).toHaveValue(30) + }) + + it('should update rest time field', async () => { + render() + + await waitFor(() => { + expect(screen.getByRole('tab', { name: /timing/i })).toBeInTheDocument() + }) + + await userEvent.click(screen.getByRole('tab', { name: /timing/i })) + + const restInput = screen.getByLabelText(/rest time/i) + await userEvent.clear(restInput) + await userEvent.type(restInput, '15') + + expect(restInput).toHaveValue(15) + }) + }) + + describe('default values', () => { + it('should have correct default timing values', async () => { + render() + + await waitFor(() => { + expect(screen.getByRole('tab', { name: /timing/i })).toBeInTheDocument() + }) + + await userEvent.click(screen.getByRole('tab', { name: /timing/i })) + + const inputs = screen.getAllByRole('spinbutton') + const values = inputs.map(input => (input as HTMLInputElement).value) + + expect(values).toContain('10') + expect(values).toContain('20') + }) + }) + + describe('select fields', () => { + it('should change category', async () => { + render() + + await waitFor(() => { + expect(screen.getByLabelText(/category/i)).toBeInTheDocument() + }) + + const categorySelect = screen.getByLabelText(/category/i) + await userEvent.click(categorySelect) + }) + + it('should change level', async () => { + render() + + await waitFor(() => { + expect(screen.getByLabelText(/level/i)).toBeInTheDocument() + }) + + const levelSelect = screen.getByLabelText(/level/i) + await userEvent.click(levelSelect) + }) + + it('should change duration', async () => { + render() + + await waitFor(() => { + expect(screen.getByRole('tab', { name: /timing/i })).toBeInTheDocument() + }) + + await userEvent.click(screen.getByRole('tab', { name: /timing/i })) + + const durationSelect = screen.getByLabelText(/duration/i) + await userEvent.click(durationSelect) + }) + + it('should change music vibe', async () => { + render() + + await waitFor(() => { + expect(screen.getByRole('tab', { name: /media/i })).toBeInTheDocument() + }) + + await userEvent.click(screen.getByRole('tab', { name: /media/i })) + + const musicVibeSelect = screen.getByLabelText(/music vibe/i) + await userEvent.click(musicVibeSelect) + }) + }) + + describe('form submission', () => { + it('should validate all required fields on submit', async () => { + render() + + await waitFor(() => { + expect(screen.getByRole('button', { name: /create workout/i })).toBeInTheDocument() + }) + + // Try to submit without filling any fields + await userEvent.click(screen.getByRole('button', { name: /create workout/i })) + + // Should show validation errors + await waitFor(() => { + expect(screen.getByText(/title is required/i)).toBeInTheDocument() + }) + + // Check for other validation errors that should appear + const errors = screen.queryAllByText(/is required/i) + expect(errors.length).toBeGreaterThan(0) + }) + + it('should handle trainer selection error gracefully', async () => { + const consoleErrorMock = vi.spyOn(console, 'error').mockImplementation(() => {}) + + // Mock trainers fetch to fail + mockOrder.mockResolvedValue({ + data: null, + error: { message: 'Database error' } + }) + + render() + + // Wait a bit for the error to be logged + await new Promise(resolve => setTimeout(resolve, 100)) + + // Should have logged the error + await waitFor(() => { + expect(consoleErrorMock).toHaveBeenCalledWith('Failed to load trainers:', expect.any(Object)) + }) + + consoleErrorMock.mockRestore() + }) + + it('should successfully submit form in create mode', async () => { + render() + + await waitFor(() => { + expect(screen.getByLabelText(/workout title/i)).toBeInTheDocument() + }) + + // Fill in required fields + await userEvent.type(screen.getByLabelText(/workout title/i), 'Test Workout') + + // Select trainer + await userEvent.click(screen.getByLabelText(/trainer/i)) + await userEvent.click(screen.getByText('Test Trainer')) + + const submitButton = screen.getByRole('button', { name: /create workout/i }) + await userEvent.click(submitButton) + + // Should show loading state + await waitFor(() => { + expect(screen.getByText(/saving/i)).toBeInTheDocument() + }) + + // Verify insert was called + await waitFor(() => { + expect(mockInsert).toHaveBeenCalled() + }) + + // Should navigate after success + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith('/workouts/new-workout-id') + }) + }) + + it('should successfully submit form in edit mode', async () => { + const initialData = { + id: 'test-id', + title: 'Test Workout', + trainer_id: 'trainer-1', + category: 'full-body' as const, + level: 'Beginner' as const, + duration: 4, + rounds: 8, + prep_time: 10, + work_time: 20, + rest_time: 10, + calories: 45, + equipment: [], + music_vibe: 'electronic' as const, + exercises: [{ name: 'Push-ups', duration: 20 }], + thumbnail_url: null, + video_url: null, + is_featured: false, + created_at: '2024-01-01', + updated_at: '2024-01-01', + } + + render() + + await waitFor(() => { + expect(screen.getByRole('button', { name: /update workout/i })).toBeInTheDocument() + }) + + const submitButton = screen.getByRole('button', { name: /update workout/i }) + await userEvent.click(submitButton) + + // Should show loading state + await waitFor(() => { + expect(screen.getByText(/saving/i)).toBeInTheDocument() + }) + + // Verify update was called + await waitFor(() => { + expect(mockUpdate).toHaveBeenCalled() + }) + + // Should navigate after success + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith('/workouts/test-id') + }) + }) + + it('should handle file movement during create', async () => { + // Setup mock to return files that need to be moved + mockMoveFiles.mockResolvedValue({ + thumbnail: { + oldPath: 'temp/thumb.jpg', + newPath: 'new-workout-id/thumb.jpg', + newUrl: 'https://example.com/new-thumb.jpg' + }, + video: { + oldPath: 'temp/video.mp4', + newPath: 'new-workout-id/video.mp4', + newUrl: 'https://example.com/new-video.mp4' + }, + errors: [] + }) + + render() + + await waitFor(() => { + expect(screen.getByLabelText(/workout title/i)).toBeInTheDocument() + }) + + // Fill in required fields + await userEvent.type(screen.getByLabelText(/workout title/i), 'Test Workout') + + // Select trainer + await userEvent.click(screen.getByLabelText(/trainer/i)) + await userEvent.click(screen.getByText('Test Trainer')) + + const submitButton = screen.getByRole('button', { name: /create workout/i }) + await userEvent.click(submitButton) + + // Verify moveFiles was called + await waitFor(() => { + expect(mockMoveFiles).toHaveBeenCalled() + }) + + // Verify update was called to update URLs + await waitFor(() => { + expect(mockUpdate).toHaveBeenCalled() + }) + }) + + it('should handle file movement errors', async () => { + const consoleWarnMock = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + // Setup mock to return errors + mockMoveFiles.mockResolvedValue({ + thumbnail: null, + video: null, + errors: ['Failed to move thumbnail', 'Failed to move video'] + }) + + render() + + await waitFor(() => { + expect(screen.getByLabelText(/workout title/i)).toBeInTheDocument() + }) + + // Fill in required fields + await userEvent.type(screen.getByLabelText(/workout title/i), 'Test Workout') + + // Select trainer + await userEvent.click(screen.getByLabelText(/trainer/i)) + await userEvent.click(screen.getByText('Test Trainer')) + + const submitButton = screen.getByRole('button', { name: /create workout/i }) + await userEvent.click(submitButton) + + // Verify warning was logged + await waitFor(() => { + expect(consoleWarnMock).toHaveBeenCalledWith( + 'Some files could not be moved:', + ['Failed to move thumbnail', 'Failed to move video'] + ) + }) + + consoleWarnMock.mockRestore() + }) + + it('should handle submission error', async () => { + const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {}) + + // Setup insert to fail + mockInsertSelectSingle.mockResolvedValue({ + data: null, + error: { message: 'Database error' } + }) + + render() + + await waitFor(() => { + expect(screen.getByLabelText(/workout title/i)).toBeInTheDocument() + }) + + // Fill in required fields + await userEvent.type(screen.getByLabelText(/workout title/i), 'Test Workout') + + // Select trainer + await userEvent.click(screen.getByLabelText(/trainer/i)) + await userEvent.click(screen.getByText('Test Trainer')) + + const submitButton = screen.getByRole('button', { name: /create workout/i }) + await userEvent.click(submitButton) + + // Should show alert on error + await waitFor(() => { + expect(alertMock).toHaveBeenCalledWith('Failed to save workout. Please try again.') + }) + + alertMock.mockRestore() + }) + + it('should handle update error in edit mode', async () => { + const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {}) + const consoleErrorMock = vi.spyOn(console, 'error').mockImplementation(() => {}) + + // Setup update to fail + mockUpdateEqSelectSingle.mockResolvedValue({ + data: null, + error: { message: 'Update failed' } + }) + + const initialData = { + id: 'test-id', + title: 'Test Workout', + trainer_id: 'trainer-1', + category: 'full-body' as const, + level: 'Beginner' as const, + duration: 4, + rounds: 8, + prep_time: 10, + work_time: 20, + rest_time: 10, + calories: 45, + equipment: [], + music_vibe: 'electronic' as const, + exercises: [{ name: 'Push-ups', duration: 20 }], + thumbnail_url: null, + video_url: null, + is_featured: false, + created_at: '2024-01-01', + updated_at: '2024-01-01', + } + + render() + + await waitFor(() => { + expect(screen.getByRole('button', { name: /update workout/i })).toBeInTheDocument() + }) + + const submitButton = screen.getByRole('button', { name: /update workout/i }) + await userEvent.click(submitButton) + + // Should show alert on error + await waitFor(() => { + expect(alertMock).toHaveBeenCalledWith('Failed to save workout. Please try again.') + }) + + alertMock.mockRestore() + consoleErrorMock.mockRestore() + }) + }) +}) \ No newline at end of file diff --git a/admin-web/components/workout-form.tsx b/admin-web/components/workout-form.tsx new file mode 100644 index 0000000..8edeee0 --- /dev/null +++ b/admin-web/components/workout-form.tsx @@ -0,0 +1,519 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import { Loader2, Save, X } from "lucide-react" + +import { cn } from "@/lib/utils" +import { supabase } from "@/lib/supabase" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Select } from "@/components/ui/select" +import { Switch } from "@/components/ui/switch" +import { TagInput } from "@/components/tag-input" +import { ExerciseList, Exercise } from "@/components/exercise-list" +import { MediaUpload } from "@/components/media-upload" +import { moveFilesFromTempToWorkout } from "@/lib/storage" +import type { Database } from "@/lib/supabase" + +type Workout = Database["public"]["Tables"]["workouts"]["Row"] + +interface WorkoutFormProps { + initialData?: Workout + mode?: "create" | "edit" +} + +const CATEGORY_OPTIONS = [ + { value: "full-body", label: "Full Body" }, + { value: "core", label: "Core" }, + { value: "upper-body", label: "Upper Body" }, + { value: "lower-body", label: "Lower Body" }, + { value: "cardio", label: "Cardio" }, +] + +const LEVEL_OPTIONS = [ + { value: "Beginner", label: "Beginner" }, + { value: "Intermediate", label: "Intermediate" }, + { value: "Advanced", label: "Advanced" }, +] + +const DURATION_OPTIONS = [ + { value: "4", label: "4 minutes" }, + { value: "8", label: "8 minutes" }, + { value: "12", label: "12 minutes" }, + { value: "20", label: "20 minutes" }, +] + +const MUSIC_VIBE_OPTIONS = [ + { value: "electronic", label: "Electronic" }, + { value: "hip-hop", label: "Hip Hop" }, + { value: "pop", label: "Pop" }, + { value: "rock", label: "Rock" }, + { value: "chill", label: "Chill" }, +] + +export default function WorkoutForm({ initialData, mode = "create" }: WorkoutFormProps) { + const router = useRouter() + const [isLoading, setIsLoading] = React.useState(false) + const [errors, setErrors] = React.useState>({}) + const [trainers, setTrainers] = React.useState<{ id: string; name: string }[]>([]) + const [isLoadingTrainers, setIsLoadingTrainers] = React.useState(true) + + // Form state + const [title, setTitle] = React.useState(initialData?.title || "") + const [trainerId, setTrainerId] = React.useState(initialData?.trainer_id || "") + const [category, setCategory] = React.useState(initialData?.category || "full-body") + const [level, setLevel] = React.useState(initialData?.level || "Beginner") + const [isFeatured, setIsFeatured] = React.useState(initialData?.is_featured || false) + + const [duration, setDuration] = React.useState(String(initialData?.duration || "4")) + const [rounds, setRounds] = React.useState(String(initialData?.rounds || "8")) + const [prepTime, setPrepTime] = React.useState(String(initialData?.prep_time || "10")) + const [workTime, setWorkTime] = React.useState(String(initialData?.work_time || "20")) + const [restTime, setRestTime] = React.useState(String(initialData?.rest_time || "10")) + const [calories, setCalories] = React.useState(String(initialData?.calories || "45")) + + const [exercises, setExercises] = React.useState( + initialData?.exercises?.length + ? initialData.exercises + : [{ name: "", duration: 20 }] + ) + const [equipment, setEquipment] = React.useState(initialData?.equipment || []) + + const [musicVibe, setMusicVibe] = React.useState(initialData?.music_vibe || "electronic") + const [thumbnailUrl, setThumbnailUrl] = React.useState(initialData?.thumbnail_url || "") + const [videoUrl, setVideoUrl] = React.useState(initialData?.video_url || "") + + // Load trainers + React.useEffect(() => { + const loadTrainers = async () => { + try { + const { data, error } = await supabase + .from("trainers") + .select("id, name") + .order("name") + + if (error) throw error + setTrainers(data || []) + } catch (err) { + console.error("Failed to load trainers:", err) + } finally { + setIsLoadingTrainers(false) + } + } + + loadTrainers() + }, []) + + const trainerOptions = trainers.map((t) => ({ value: t.id, label: t.name })) + + const validate = () => { + const newErrors: Record = {} + + if (!title.trim()) newErrors.title = "Title is required" + if (!trainerId) newErrors.trainer_id = "Trainer is required" + if (!category) newErrors.category = "Category is required" + if (!level) newErrors.level = "Level is required" + if (!duration) newErrors.duration = "Duration is required" + if (!rounds) newErrors.rounds = "Rounds is required" + if (!prepTime) newErrors.prep_time = "Prep time is required" + if (!workTime) newErrors.work_time = "Work time is required" + if (!restTime) newErrors.rest_time = "Rest time is required" + if (!calories) newErrors.calories = "Calories is required" + if (!musicVibe) newErrors.music_vibe = "Music vibe is required" + if (exercises.some((e) => !e.name.trim())) { + newErrors.exercises = "All exercises must have a name" + } + + setErrors(newErrors) + return Object.keys(newErrors).length === 0 + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!validate()) { + return + } + + setIsLoading(true) + + try { + const workoutData = { + title: title.trim(), + trainer_id: trainerId, + category: category as Workout["category"], + level: level as Workout["level"], + duration: parseInt(duration), + rounds: parseInt(rounds), + prep_time: parseInt(prepTime), + work_time: parseInt(workTime), + rest_time: parseInt(restTime), + calories: parseInt(calories), + equipment, + exercises, + music_vibe: musicVibe as Workout["music_vibe"], + thumbnail_url: thumbnailUrl.trim() || null, + video_url: videoUrl.trim() || null, + is_featured: isFeatured, + } + + let result + if (mode === "edit" && initialData) { + result = await supabase + .from("workouts") + .update(workoutData) + .eq("id", initialData.id) + .select() + .single() + } else { + result = await supabase + .from("workouts") + .insert(workoutData) + .select() + .single() + } + + if (result.error) throw result.error + + // If creating new workout and files were uploaded to temp folder, move them + if (mode === "create" && result.data) { + const moveResult = await moveFilesFromTempToWorkout( + "temp", + result.data.id, + thumbnailUrl, + videoUrl + ) + + // Update workout with new URLs if files were moved + if (moveResult.thumbnail || moveResult.video) { + const updateData: Partial = {} + if (moveResult.thumbnail) { + updateData.thumbnail_url = moveResult.thumbnail.newUrl + } + if (moveResult.video) { + updateData.video_url = moveResult.video.newUrl + } + + if (Object.keys(updateData).length > 0) { + await supabase + .from("workouts") + .update(updateData) + .eq("id", result.data.id) + } + } + + // Show warnings if there were errors + if (moveResult.errors.length > 0) { + console.warn("Some files could not be moved:", moveResult.errors) + // Optionally show a toast notification here + } + } + + router.push(`/workouts/${result.data.id}`) + } catch (err) { + console.error("Failed to save workout:", err) + alert("Failed to save workout. Please try again.") + } finally { + setIsLoading(false) + } + } + + const handleCancel = () => { + if (mode === "edit" && initialData) { + router.push(`/workouts/${initialData.id}`) + } else { + router.push("/workouts") + } + } + + return ( +
+ + + + Basics + + + Timing + + + Content + + + Media + + + + {/* Tab 1: Basics */} + +
+
+ + setTitle(e.target.value)} + placeholder="e.g., Full Body Ignite" + className={cn(errors.title && "border-red-500")} + /> + {errors.title &&

{errors.title}

} +
+ +
+ + {isLoadingTrainers ? ( +
+ Loading trainers... +
+ ) : ( + <> + setCategory(value as typeof category)} + options={CATEGORY_OPTIONS} + /> + {errors.category &&

{errors.category}

} +
+ +
+ + setDuration(value)} + options={DURATION_OPTIONS} + /> + {errors.duration &&

{errors.duration}

} +
+ +
+ + setRounds(e.target.value)} + min={1} + className={cn(errors.rounds && "border-red-500")} + /> + {errors.rounds &&

{errors.rounds}

} +
+
+ +
+
+ + setPrepTime(e.target.value)} + min={0} + className={cn(errors.prep_time && "border-red-500")} + /> + {errors.prep_time &&

{errors.prep_time}

} +
+ +
+ + setWorkTime(e.target.value)} + min={1} + className={cn(errors.work_time && "border-red-500")} + /> + {errors.work_time &&

{errors.work_time}

} +
+ +
+ + setRestTime(e.target.value)} + min={0} + className={cn(errors.rest_time && "border-red-500")} + /> + {errors.rest_time &&

{errors.rest_time}

} +
+
+ +
+ + setCalories(e.target.value)} + min={0} + placeholder="e.g., 45" + className={cn(errors.calories && "border-red-500")} + /> + {errors.calories &&

{errors.calories}

} +
+ +
+ + {/* Tab 3: Content */} + +
+
+ +

+ Add all exercises in order. Drag to reorder. +

+ + {errors.exercises &&

{errors.exercises}

} +
+ +
+ +

+ List all equipment needed (or leave empty for bodyweight) +

+ +
+
+
+ + {/* Tab 4: Media */} + +
+
+ +