feat: explore tab, React Query data layer, programs, sync, analytics, testing infrastructure
- Replace browse tab with Supabase-connected explore tab with filters - Add React Query for data fetching with loading states - Add 3 structured programs with weekly progression - Add Supabase anonymous auth sync service - Add PostHog analytics with screen tracking and events - Add comprehensive test strategy (Vitest + Maestro E2E) - Add RevenueCat subscription system with DEV simulation - Add i18n translations for new screens (EN/FR/DE/ES) - Add data deletion modal, sync consent modal - Add assessment screen and program routes - Add GitHub Actions CI workflow - Update activity store with sync integration
This commit is contained in:
130
admin-web/e2e/collections.spec.ts
Normal file
130
admin-web/e2e/collections.spec.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Collections List Page', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/collections')
|
||||
})
|
||||
|
||||
test('should display collections page header', async ({ page }) => {
|
||||
const heading = page.getByRole('heading', { name: /collections|tabatafit admin/i })
|
||||
await expect(heading).toBeVisible()
|
||||
})
|
||||
|
||||
test('should have Add Collection button', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/collections')) return
|
||||
|
||||
const addButton = page.getByRole('button', { name: /add collection/i })
|
||||
await expect(addButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('should display subtitle text', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/collections')) return
|
||||
|
||||
await expect(page.getByText(/organize workouts into collections/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('should display collection cards after loading', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/collections')) return
|
||||
|
||||
// Wait for loading to finish
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
// Should show collection cards in a grid layout
|
||||
const grid = page.locator('[class*="grid"]')
|
||||
await expect(grid).toBeVisible()
|
||||
})
|
||||
|
||||
test('should display collection title and description on cards', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/collections')) return
|
||||
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
// Find collection cards
|
||||
const cards = page.locator('[class*="bg-neutral-900"]').filter({ has: page.locator('h3') })
|
||||
const count = await cards.count()
|
||||
|
||||
if (count > 0) {
|
||||
const firstCard = cards.first()
|
||||
// Card should have a title (h3)
|
||||
await expect(firstCard.locator('h3')).toBeVisible()
|
||||
// Card should have description text (p element)
|
||||
const description = firstCard.locator('p').first()
|
||||
await expect(description).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('should display collection icon', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/collections')) return
|
||||
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
// Icon containers have specific styling
|
||||
const iconContainers = page.locator('[class*="w-12"][class*="h-12"][class*="rounded-xl"]')
|
||||
const count = await iconContainers.count()
|
||||
|
||||
if (count > 0) {
|
||||
await expect(iconContainers.first()).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('should display gradient bars for collections that have them', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/collections')) return
|
||||
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
// Gradient bars have inline background style with linear-gradient
|
||||
const gradientBars = page.locator('[class*="h-2"][class*="rounded-full"]')
|
||||
const count = await gradientBars.count()
|
||||
|
||||
// Gradient bars are optional (only shown if collection has gradient property)
|
||||
if (count > 0) {
|
||||
const firstBar = gradientBars.first()
|
||||
const style = await firstBar.getAttribute('style')
|
||||
expect(style).toContain('linear-gradient')
|
||||
}
|
||||
})
|
||||
|
||||
test('should have edit and delete buttons on cards', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/collections')) return
|
||||
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
const editButtons = page.locator('button').filter({ has: page.locator('svg.lucide-edit') })
|
||||
const deleteButtons = page.locator('button').filter({ has: page.locator('svg.lucide-trash-2') })
|
||||
|
||||
const editCount = await editButtons.count()
|
||||
const deleteCount = await deleteButtons.count()
|
||||
|
||||
// If collections are displayed, they should have action buttons
|
||||
if (editCount > 0) {
|
||||
expect(editCount).toBeGreaterThan(0)
|
||||
expect(deleteCount).toBeGreaterThan(0)
|
||||
// Each collection should have both edit and delete
|
||||
expect(editCount).toBe(deleteCount)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Collections Page Loading State', () => {
|
||||
test('should show loading spinner initially', async ({ page }) => {
|
||||
// Navigate and check for spinner before data loads
|
||||
await page.goto('/collections')
|
||||
|
||||
const url = page.url()
|
||||
if (!url.includes('/collections')) return
|
||||
|
||||
// The spinner might be very brief, so we just verify the page loads
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// After loading, spinner should be gone
|
||||
const spinner = page.locator('[class*="animate-spin"]')
|
||||
await expect(spinner).not.toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
})
|
||||
160
admin-web/e2e/trainers.spec.ts
Normal file
160
admin-web/e2e/trainers.spec.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Trainers List Page', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/trainers')
|
||||
})
|
||||
|
||||
test('should display trainers page header', async ({ page }) => {
|
||||
const heading = page.getByRole('heading', { name: /trainers|tabatafit admin/i })
|
||||
await expect(heading).toBeVisible()
|
||||
})
|
||||
|
||||
test('should have Add Trainer button', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/trainers')) return
|
||||
|
||||
const addButton = page.getByRole('link', { name: /add trainer/i }).or(
|
||||
page.getByRole('button', { name: /add trainer/i })
|
||||
)
|
||||
await expect(addButton).toBeVisible()
|
||||
})
|
||||
|
||||
test('should display trainer cards or empty state', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/trainers')) return
|
||||
|
||||
// Wait for loading to finish
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
// Should show either trainer cards or empty state
|
||||
const hasTrainerCards = await page.locator('[class*="grid"]').locator('[class*="bg-neutral-900"]').count() > 0
|
||||
const hasEmptyState = await page.getByText(/no trainers yet/i).isVisible().catch(() => false)
|
||||
const hasError = await page.getByText(/failed to load/i).isVisible().catch(() => false)
|
||||
|
||||
expect(hasTrainerCards || hasEmptyState || hasError).toBeTruthy()
|
||||
})
|
||||
|
||||
test('should display trainer name and specialty on cards', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/trainers')) return
|
||||
|
||||
// Wait for loading to finish
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
// If there are trainer cards, check they have name and specialty
|
||||
const cards = page.locator('[class*="bg-neutral-900"]').filter({ has: page.locator('h3') })
|
||||
const count = await cards.count()
|
||||
|
||||
if (count > 0) {
|
||||
const firstCard = cards.first()
|
||||
// Card should have a name (h3 element)
|
||||
await expect(firstCard.locator('h3')).toBeVisible()
|
||||
// Card should have specialty text
|
||||
const specialtyText = firstCard.locator('p').first()
|
||||
await expect(specialtyText).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('should show workout count on trainer cards', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/trainers')) return
|
||||
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
// Check for "X workouts" text
|
||||
const workoutCountText = page.getByText(/\d+ workouts/i)
|
||||
const visible = await workoutCountText.first().isVisible().catch(() => false)
|
||||
|
||||
// Only assert if trainers exist
|
||||
if (visible) {
|
||||
await expect(workoutCountText.first()).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('should have edit and delete action buttons on cards', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/trainers')) return
|
||||
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
// Find edit and delete buttons (icon buttons with svg)
|
||||
const editButtons = page.locator('button').filter({ has: page.locator('svg.lucide-edit') })
|
||||
const deleteButtons = page.locator('button').filter({ has: page.locator('svg.lucide-trash-2') })
|
||||
|
||||
const editCount = await editButtons.count()
|
||||
const deleteCount = await deleteButtons.count()
|
||||
|
||||
// If trainers are displayed, they should have action buttons
|
||||
if (editCount > 0) {
|
||||
expect(editCount).toBeGreaterThan(0)
|
||||
expect(deleteCount).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Trainers Delete Dialog', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/trainers')
|
||||
})
|
||||
|
||||
test('should open delete confirmation dialog', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/trainers')) return
|
||||
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
const deleteButtons = page.locator('button').filter({ has: page.locator('svg.lucide-trash-2') })
|
||||
const count = await deleteButtons.count()
|
||||
|
||||
if (count > 0) {
|
||||
await deleteButtons.first().click()
|
||||
|
||||
// Dialog should appear
|
||||
await expect(page.getByRole('heading', { name: /delete trainer/i })).toBeVisible()
|
||||
await expect(page.getByText(/are you sure/i)).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /cancel/i })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /^delete$/i })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('should close delete dialog on Cancel', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/trainers')) return
|
||||
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
const deleteButtons = page.locator('button').filter({ has: page.locator('svg.lucide-trash-2') })
|
||||
const count = await deleteButtons.count()
|
||||
|
||||
if (count > 0) {
|
||||
await deleteButtons.first().click()
|
||||
await expect(page.getByRole('heading', { name: /delete trainer/i })).toBeVisible()
|
||||
|
||||
// Click cancel
|
||||
await page.getByRole('button', { name: /cancel/i }).click()
|
||||
|
||||
// Dialog should close
|
||||
await expect(page.getByRole('heading', { name: /delete trainer/i })).not.toBeVisible()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Trainers Error State', () => {
|
||||
test('should show error state with retry button on failure', async ({ page }) => {
|
||||
// This test verifies the error UI exists in the component
|
||||
// In actual failure scenarios, it would show the error state
|
||||
await page.goto('/trainers')
|
||||
|
||||
const url = page.url()
|
||||
if (!url.includes('/trainers')) return
|
||||
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
// Check if error state is shown (only if Supabase is unreachable)
|
||||
const hasError = await page.getByText(/failed to load trainers/i).isVisible().catch(() => false)
|
||||
if (hasError) {
|
||||
await expect(page.getByRole('button', { name: /try again/i })).toBeVisible()
|
||||
}
|
||||
})
|
||||
})
|
||||
207
admin-web/e2e/workouts.spec.ts
Normal file
207
admin-web/e2e/workouts.spec.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Workouts List Page', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/workouts')
|
||||
})
|
||||
|
||||
test('should display workouts page header', async ({ page }) => {
|
||||
// May redirect to login if not authenticated
|
||||
const heading = page.getByRole('heading', { name: /workouts|tabatafit admin/i })
|
||||
await expect(heading).toBeVisible()
|
||||
})
|
||||
|
||||
test('should have Add Workout button', async ({ page }) => {
|
||||
// If authenticated, should see the Add Workout button
|
||||
const addButton = page.getByRole('link', { name: /add workout/i })
|
||||
|
||||
// Page might redirect to login — check if we're on workouts page
|
||||
const url = page.url()
|
||||
if (url.includes('/workouts')) {
|
||||
await expect(addButton).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('should display workouts table with correct columns', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts')) return
|
||||
|
||||
// Wait for loading to finish (loader disappears)
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
const table = page.locator('table')
|
||||
// Table may or may not be visible depending on data
|
||||
const tableVisible = await table.isVisible().catch(() => false)
|
||||
|
||||
if (tableVisible) {
|
||||
await expect(page.getByRole('columnheader', { name: /title/i })).toBeVisible()
|
||||
await expect(page.getByRole('columnheader', { name: /category/i })).toBeVisible()
|
||||
await expect(page.getByRole('columnheader', { name: /level/i })).toBeVisible()
|
||||
await expect(page.getByRole('columnheader', { name: /duration/i })).toBeVisible()
|
||||
await expect(page.getByRole('columnheader', { name: /rounds/i })).toBeVisible()
|
||||
await expect(page.getByRole('columnheader', { name: /actions/i })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('should navigate to new workout page', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts')) return
|
||||
|
||||
const addButton = page.getByRole('link', { name: /add workout/i })
|
||||
if (await addButton.isVisible().catch(() => false)) {
|
||||
await addButton.click()
|
||||
await expect(page).toHaveURL(/.*workouts\/new/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('New Workout Page', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/workouts/new')
|
||||
})
|
||||
|
||||
test('should display create workout heading', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts/new')) return
|
||||
|
||||
await expect(page.getByRole('heading', { name: /create new workout/i })).toBeVisible()
|
||||
})
|
||||
|
||||
test('should have back to workouts link', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts/new')) return
|
||||
|
||||
const backLink = page.getByRole('link', { name: /back to workouts/i })
|
||||
await expect(backLink).toBeVisible()
|
||||
})
|
||||
|
||||
test('should display workout form with tabs', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts/new')) return
|
||||
|
||||
// Form should have 4 tabs: Basics, Timing, Content, Media
|
||||
await expect(page.getByRole('tab', { name: /basics/i })).toBeVisible()
|
||||
await expect(page.getByRole('tab', { name: /timing/i })).toBeVisible()
|
||||
await expect(page.getByRole('tab', { name: /content/i })).toBeVisible()
|
||||
await expect(page.getByRole('tab', { name: /media/i })).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show basics tab fields by default', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts/new')) return
|
||||
|
||||
// Basics tab should be active by default
|
||||
await expect(page.getByLabel(/workout title/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('should switch between form tabs', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts/new')) return
|
||||
|
||||
// Click Timing tab
|
||||
await page.getByRole('tab', { name: /timing/i }).click()
|
||||
await expect(page.getByLabel(/total rounds/i)).toBeVisible()
|
||||
|
||||
// Click Content tab
|
||||
await page.getByRole('tab', { name: /content/i }).click()
|
||||
await expect(page.getByText(/exercises/i).first()).toBeVisible()
|
||||
|
||||
// Click Media tab
|
||||
await page.getByRole('tab', { name: /media/i }).click()
|
||||
await expect(page.getByText(/music vibe/i).first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('should have Cancel and Create Workout buttons', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts/new')) return
|
||||
|
||||
await expect(page.getByRole('button', { name: /cancel/i })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /create workout/i })).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show validation errors on empty submit', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts/new')) return
|
||||
|
||||
// Clear the title field and submit
|
||||
const titleInput = page.getByLabel(/workout title/i)
|
||||
await titleInput.fill('')
|
||||
|
||||
// Click submit
|
||||
await page.getByRole('button', { name: /create workout/i }).click()
|
||||
|
||||
// Should show validation error for title
|
||||
await expect(page.getByText(/title is required/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('should navigate back when Cancel is clicked', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts/new')) return
|
||||
|
||||
await page.getByRole('button', { name: /cancel/i }).click()
|
||||
await expect(page).toHaveURL(/.*workouts$/)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Workout Detail Page', () => {
|
||||
test('should show 404 or redirect for non-existent workout', async ({ page }) => {
|
||||
await page.goto('/workouts/non-existent-id')
|
||||
|
||||
// Should either show not found or redirect
|
||||
const url = page.url()
|
||||
const hasNotFound = await page.getByText(/not found/i).isVisible().catch(() => false)
|
||||
const redirectedToLogin = url.includes('/login')
|
||||
const redirectedToWorkouts = url.match(/\/workouts\/?$/)
|
||||
|
||||
expect(hasNotFound || redirectedToLogin || redirectedToWorkouts).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Workout Delete Dialog', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/workouts')
|
||||
})
|
||||
|
||||
test('should open delete confirmation dialog', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts')) return
|
||||
|
||||
// Wait for loading to finish
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
// Find a delete button in the table actions
|
||||
const deleteButtons = page.locator('button').filter({ has: page.locator('svg.lucide-trash-2') })
|
||||
const count = await deleteButtons.count()
|
||||
|
||||
if (count > 0) {
|
||||
await deleteButtons.first().click()
|
||||
|
||||
// Dialog should appear
|
||||
await expect(page.getByRole('heading', { name: /delete workout/i })).toBeVisible()
|
||||
await expect(page.getByText(/are you sure/i)).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /cancel/i })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /^delete$/i })).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('should close delete dialog on Cancel', async ({ page }) => {
|
||||
const url = page.url()
|
||||
if (!url.includes('/workouts')) return
|
||||
|
||||
await page.waitForSelector('[class*="animate-spin"]', { state: 'detached', timeout: 10000 }).catch(() => {})
|
||||
|
||||
const deleteButtons = page.locator('button').filter({ has: page.locator('svg.lucide-trash-2') })
|
||||
const count = await deleteButtons.count()
|
||||
|
||||
if (count > 0) {
|
||||
await deleteButtons.first().click()
|
||||
await expect(page.getByRole('heading', { name: /delete workout/i })).toBeVisible()
|
||||
|
||||
// Click cancel
|
||||
await page.getByRole('button', { name: /cancel/i }).click()
|
||||
|
||||
// Dialog should close
|
||||
await expect(page.getByRole('heading', { name: /delete workout/i })).not.toBeVisible()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user