Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2fcf0fae7 | |||
| 0f53f8a08f | |||
| 36e273e2a1 | |||
| e61e020d9d | |||
| 4120cc9a8e | |||
| c4e88d3658 | |||
| 5ab6ece5ef | |||
| b3907d046d | |||
| 9026ac7fe3 | |||
| 73f5905990 | |||
| 5519ed1de0 | |||
| f51f3caa34 | |||
| 933f2cf808 | |||
| a1d754a5fb | |||
| 78ff75b941 | |||
| d5dc170417 | |||
| 1093449e46 | |||
| 54aa22d1a3 | |||
| 22f7b79a3d | |||
| 999cdd821c | |||
| 496541b144 | |||
| d21e14c3b0 | |||
| 8f0d5c4cf5 | |||
| 15661356d2 | |||
| 603cc238fa | |||
| 63ef1d124c | |||
| c222e55a52 | |||
| e0c986f594 | |||
| 1f7d589698 | |||
| e5f679e54c | |||
| 803b79f03c | |||
| aa98600147 | |||
| ad7724cda6 | |||
| ada163f538 | |||
| 4d685558aa | |||
| 07283d0334 | |||
| dca35ec0bf | |||
| c3b0466092 | |||
| a75d7d3cc6 | |||
| 37eb1f8e21 | |||
| 9b15d7e61f | |||
| 322ab2a5fa | |||
| c4d2130d5b | |||
| 051b729451 | |||
| 443a0f460e | |||
| 7367fce4a6 | |||
| ab50ae0bdf | |||
| 90fb0f223e | |||
| a49c8e01ac | |||
| 19402be375 | |||
| 84afa88ca4 | |||
| df856c62df |
@@ -16,9 +16,6 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Clear Bun cache
|
|
||||||
run: bun pm cache rm || true
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install
|
run: bun install
|
||||||
|
|
||||||
@@ -41,9 +38,6 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Clear Bun cache
|
|
||||||
run: bun pm cache rm || true
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install
|
run: bun install
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +1,3 @@
|
|||||||
## [0.1.12] - 2026-04-27
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- ci: clear Bun cache before install to fix integrity check failures
|
|
||||||
|
|
||||||
## [0.1.11] - 2026-04-27
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## [0.1.10] - 2026-04-27
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- fix: prevent content overflow on right side of screen
|
|
||||||
|
|
||||||
## [0.1.9] - 2026-04-27
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- test: remove migrated Playwright tests (epic3-rankings, home-page)
|
|
||||||
|
|
||||||
## [0.1.8] - 2026-04-27
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- test: migrate rankings and home-page tests from Playwright to Cucumber
|
|
||||||
|
|
||||||
## [0.1.7] - 2026-04-27
|
|
||||||
|
|
||||||
### Patch Changes
|
|
||||||
|
|
||||||
- fix: replace waitForLoadState('networkidle') with domcontentloaded in all E2E tests
|
|
||||||
- feat: Implement tournament schedule tab and fix E2E tests (#27)
|
|
||||||
|
|
||||||
## [0.1.6] - 2026-04-26
|
## [0.1.6] - 2026-04-26
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ test.describe.serial('Account Lifecycle Acceptance Test', () => {
|
|||||||
await page.goto('/auth/register');
|
await page.goto('/auth/register');
|
||||||
|
|
||||||
// Wait for JavaScript to be ready
|
// Wait for JavaScript to be ready
|
||||||
await page.waitForLoadState('domcontentloaded');
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
// Wait for the form to be visible and the submit button to be enabled
|
// Wait for the form to be visible and the submit button to be enabled
|
||||||
await page.waitForSelector('form');
|
await page.waitForSelector('form');
|
||||||
@@ -105,7 +105,7 @@ test.describe.serial('Account Lifecycle Acceptance Test', () => {
|
|||||||
|
|
||||||
// Reload to ensure session is loaded from cookies
|
// Reload to ensure session is loaded from cookies
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await page.waitForLoadState('domcontentloaded');
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
// Wait for logout button to appear
|
// Wait for logout button to appear
|
||||||
await page.waitForSelector('text=Sign out', { timeout: 10000 });
|
await page.waitForSelector('text=Sign out', { timeout: 10000 });
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
Feature: Club Admin Dashboard
|
|
||||||
As a club admin
|
|
||||||
I want to view club-wide statistics and manage club operations
|
|
||||||
So that I can effectively oversee the club
|
|
||||||
|
|
||||||
@happy-path @admin @issue-11
|
|
||||||
Scenario: Club admin views dashboard with statistics
|
|
||||||
Given I am logged in as a club admin
|
|
||||||
When I go to the admin dashboard
|
|
||||||
Then I should see "Admin Dashboard"
|
|
||||||
And I should see total player count
|
|
||||||
And I should see active tournament count
|
|
||||||
|
|
||||||
@happy-path @admin @issue-11
|
|
||||||
Scenario: Club admin views recent activity feed
|
|
||||||
Given I am logged in as a club admin
|
|
||||||
And there are recent activities in the system
|
|
||||||
When I go to the admin dashboard
|
|
||||||
Then I should see the activity feed section
|
|
||||||
And I should see recent player registrations
|
|
||||||
|
|
||||||
@happy-path @admin @issue-11
|
|
||||||
Scenario: Club admin searches player directory
|
|
||||||
Given I am logged in as a club admin
|
|
||||||
And there are multiple players in the system
|
|
||||||
When I go to the player management page
|
|
||||||
And I search for "Player 1"
|
|
||||||
Then I should see search results
|
|
||||||
|
|
||||||
@happy-path @admin @issue-11
|
|
||||||
Scenario: Club admin updates club settings
|
|
||||||
Given I am logged in as a club admin
|
|
||||||
When I go to the club settings page
|
|
||||||
And I update the club name
|
|
||||||
And I save the settings
|
|
||||||
Then the settings should be saved successfully
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
Feature: Home Page
|
|
||||||
As a visitor
|
|
||||||
I want to see the home page
|
|
||||||
So that I can learn about the club and view player rankings
|
|
||||||
|
|
||||||
@happy-path @public @home
|
|
||||||
Scenario: Home page displays Top 10 Players
|
|
||||||
Given I am on the home page
|
|
||||||
Then I should see "Top 10 Players"
|
|
||||||
And I should see a rankings table
|
|
||||||
|
|
||||||
@happy-path @public @home
|
|
||||||
Scenario: Home page displays club information
|
|
||||||
Given I am on the home page
|
|
||||||
Then I should see "Club Information"
|
|
||||||
And I should see "Club President"
|
|
||||||
|
|
||||||
@happy-path @public @home
|
|
||||||
Scenario: Home page displays most recent tournament
|
|
||||||
Given I am on the home page
|
|
||||||
Then I should see "Most Recent Tournament"
|
|
||||||
|
|
||||||
@happy-path @public @home
|
|
||||||
Scenario: Home page has sign in and create account links
|
|
||||||
Given I am on the home page
|
|
||||||
Then I should see "Sign In"
|
|
||||||
And I should see "Create Account"
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
Feature: Rankings Page
|
|
||||||
As a visitor
|
|
||||||
I want to view player rankings
|
|
||||||
So that I can see top players and their statistics
|
|
||||||
|
|
||||||
@happy-path @public @rankings
|
|
||||||
Scenario: Rankings page loads and displays rankings table
|
|
||||||
When I go to the rankings page
|
|
||||||
Then I should see "Player Rankings" in the page heading
|
|
||||||
And I should see a rankings table
|
|
||||||
|
|
||||||
@happy-path @public @rankings
|
|
||||||
Scenario: Rankings table displays player columns
|
|
||||||
When I go to the rankings page
|
|
||||||
Then I should see a rankings table with columns
|
|
||||||
And the table should have column headers
|
|
||||||
|
|
||||||
@happy-path @public @rankings
|
|
||||||
Scenario: Rankings page is publicly accessible (no login required)
|
|
||||||
Given I am not logged in
|
|
||||||
When I go to the rankings page
|
|
||||||
Then I should be on the rankings page
|
|
||||||
And I should see the rankings table
|
|
||||||
@@ -177,7 +177,7 @@ When('I register with valid credentials', async function () {
|
|||||||
world.user = credentials;
|
world.user = credentials;
|
||||||
|
|
||||||
await world.page.goto(`${world.baseURL}/auth/register`);
|
await world.page.goto(`${world.baseURL}/auth/register`);
|
||||||
await world.page.waitForLoadState('domcontentloaded');
|
await world.page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
await world.page.fill('input[name="name"]', credentials.name);
|
await world.page.fill('input[name="name"]', credentials.name);
|
||||||
await world.page.fill('input[name="email"]', credentials.email);
|
await world.page.fill('input[name="email"]', credentials.email);
|
||||||
@@ -194,7 +194,7 @@ When('I register with duplicate email', async function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await world.page.goto(`${world.baseURL}/auth/register`);
|
await world.page.goto(`${world.baseURL}/auth/register`);
|
||||||
await world.page.waitForLoadState('domcontentloaded');
|
await world.page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
await world.page.fill('input[name="name"]', world.user.name);
|
await world.page.fill('input[name="name"]', world.user.name);
|
||||||
await world.page.fill('input[name="email"]', world.user.email);
|
await world.page.fill('input[name="email"]', world.user.email);
|
||||||
@@ -220,7 +220,7 @@ When('I register with weak password', async function () {
|
|||||||
credentials.password = 'weak'; // Too short
|
credentials.password = 'weak'; // Too short
|
||||||
|
|
||||||
await world.page.goto(`${world.baseURL}/auth/register`);
|
await world.page.goto(`${world.baseURL}/auth/register`);
|
||||||
await world.page.waitForLoadState('domcontentloaded');
|
await world.page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
await world.page.fill('input[name="name"]', credentials.name);
|
await world.page.fill('input[name="name"]', credentials.name);
|
||||||
await world.page.fill('input[name="email"]', credentials.email);
|
await world.page.fill('input[name="email"]', credentials.email);
|
||||||
@@ -240,7 +240,7 @@ When('I log in with valid credentials', async function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await world.page.goto(`${world.baseURL}/auth/login`);
|
await world.page.goto(`${world.baseURL}/auth/login`);
|
||||||
await world.page.waitForLoadState('domcontentloaded');
|
await world.page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
await world.page.fill('input[name="email"]', world.user.email);
|
await world.page.fill('input[name="email"]', world.user.email);
|
||||||
await world.page.fill('input[name="password"]', world.user.password);
|
await world.page.fill('input[name="password"]', world.user.password);
|
||||||
@@ -252,7 +252,7 @@ When('I log in with valid credentials', async function () {
|
|||||||
|
|
||||||
When('I log in with invalid credentials', async function () {
|
When('I log in with invalid credentials', async function () {
|
||||||
await world.page.goto(`${world.baseURL}/auth/login`);
|
await world.page.goto(`${world.baseURL}/auth/login`);
|
||||||
await world.page.waitForLoadState('domcontentloaded');
|
await world.page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
await world.page.fill('input[name="email"]', 'nonexistent@example.com');
|
await world.page.fill('input[name="email"]', 'nonexistent@example.com');
|
||||||
await world.page.fill('input[name="password"]', 'wrongpassword');
|
await world.page.fill('input[name="password"]', 'wrongpassword');
|
||||||
@@ -384,7 +384,7 @@ When('I go to the tournament schedule page', async function () {
|
|||||||
console.log('🌍 Going to tournament schedule page');
|
console.log('🌍 Going to tournament schedule page');
|
||||||
const tournamentId = world.tournament?.id || 1;
|
const tournamentId = world.tournament?.id || 1;
|
||||||
await world.page.goto(`${world.baseURL}/admin/tournaments/${tournamentId}/schedule`);
|
await world.page.goto(`${world.baseURL}/admin/tournaments/${tournamentId}/schedule`);
|
||||||
await world.page.waitForLoadState('domcontentloaded');
|
await world.page.waitForLoadState('networkidle');
|
||||||
});
|
});
|
||||||
|
|
||||||
Given('a tournament has a generated schedule', async function () {
|
Given('a tournament has a generated schedule', async function () {
|
||||||
@@ -395,57 +395,3 @@ Given('a tournament has a generated schedule', async function () {
|
|||||||
// 2. Add teams/participants
|
// 2. Add teams/participants
|
||||||
// 3. Generate schedule via API or UI
|
// 3. Generate schedule via API or UI
|
||||||
});
|
});
|
||||||
|
|
||||||
Given('there are recent activities in the system', async function () {
|
|
||||||
// Create test activities using the activity logger
|
|
||||||
const prisma = await world.getPrisma()
|
|
||||||
|
|
||||||
// Use timestamp to ensure unique names
|
|
||||||
const timestamp = Date.now()
|
|
||||||
|
|
||||||
// Create a test player first
|
|
||||||
const player = await prisma.player.create({
|
|
||||||
data: {
|
|
||||||
name: `Test Activity Player ${timestamp}`,
|
|
||||||
normalizedName: `test activity player ${timestamp}`,
|
|
||||||
currentElo: 1000,
|
|
||||||
gamesPlayed: 0,
|
|
||||||
wins: 0,
|
|
||||||
losses: 0,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create an activity
|
|
||||||
await (prisma as any).activity.create({
|
|
||||||
data: {
|
|
||||||
type: 'player_registration',
|
|
||||||
description: `Test Activity Player ${timestamp} registered`,
|
|
||||||
playerId: player.id,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log('🌍 Created test activity for player:', player.name)
|
|
||||||
})
|
|
||||||
|
|
||||||
Given('there are multiple players in the system', async function () {
|
|
||||||
const prisma = await world.getPrisma()
|
|
||||||
|
|
||||||
// Use timestamp to ensure unique names
|
|
||||||
const timestamp = Date.now()
|
|
||||||
|
|
||||||
// Create multiple test players
|
|
||||||
for (let i = 1; i <= 5; i++) {
|
|
||||||
await prisma.player.create({
|
|
||||||
data: {
|
|
||||||
name: `Test Player ${i} ${timestamp}`,
|
|
||||||
normalizedName: `test player ${i} ${timestamp}`,
|
|
||||||
currentElo: 1000 + i * 10,
|
|
||||||
gamesPlayed: 0,
|
|
||||||
wins: 0,
|
|
||||||
losses: 0,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('🌍 Created 5 test players')
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ When('I click the {string} button', async function (buttonText: string) {
|
|||||||
console.log(`🌍 URL did not change immediately after click`);
|
console.log(`🌍 URL did not change immediately after click`);
|
||||||
// Wait for potential network activity to settle
|
// Wait for potential network activity to settle
|
||||||
try {
|
try {
|
||||||
await world.page.waitForLoadState('domcontentloaded', { timeout: 3000 });
|
await world.page.waitForLoadState('networkidle', { timeout: 3000 });
|
||||||
} catch {
|
} catch {
|
||||||
console.log(`🌍 Network idle not reached, continuing anyway`);
|
console.log(`🌍 Network idle not reached, continuing anyway`);
|
||||||
}
|
}
|
||||||
@@ -405,7 +405,7 @@ Then('I should see the {string} button', async function (buttonText: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Then('I should be redirected to {string}', async function (path: string) {
|
Then('I should be redirected to {string}', async function (path: string) {
|
||||||
await world.page.waitForLoadState('domcontentloaded');
|
await world.page.waitForLoadState('networkidle');
|
||||||
const currentUrl = world.page.url();
|
const currentUrl = world.page.url();
|
||||||
|
|
||||||
console.log(`🌍 Checking redirect to: ${path}`);
|
console.log(`🌍 Checking redirect to: ${path}`);
|
||||||
@@ -436,7 +436,7 @@ When('I wait for {int} seconds', async function (seconds: number) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
When('I wait for the page to load', async function () {
|
When('I wait for the page to load', async function () {
|
||||||
await world.page.waitForLoadState('domcontentloaded');
|
await world.page.waitForLoadState('networkidle');
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -449,7 +449,7 @@ Then('the URL should contain {string}', async function (expectedPath: string) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Then('I should be redirected to the login page', async function () {
|
Then('I should be redirected to the login page', async function () {
|
||||||
await world.page.waitForLoadState('domcontentloaded');
|
await world.page.waitForLoadState('networkidle');
|
||||||
const currentUrl = world.page.url();
|
const currentUrl = world.page.url();
|
||||||
|
|
||||||
console.log(`🌍 Checking redirect to login page. Current URL: ${currentUrl}`);
|
console.log(`🌍 Checking redirect to login page. Current URL: ${currentUrl}`);
|
||||||
@@ -475,127 +475,3 @@ Then('I should see {string} error', async function (errorMessage: string) {
|
|||||||
expect(content).toMatch(new RegExp(errorMessage, 'i'));
|
expect(content).toMatch(new RegExp(errorMessage, 'i'));
|
||||||
console.log(`🌍 Verified error message: ${errorMessage}`);
|
console.log(`🌍 Verified error message: ${errorMessage}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Admin Dashboard Steps
|
|
||||||
When('I go to the admin dashboard', async function () {
|
|
||||||
console.log('🌍 Going to admin dashboard');
|
|
||||||
await world.page.goto(`${world.baseURL}/admin`);
|
|
||||||
await world.page.waitForLoadState('domcontentloaded');
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('I should see total player count', async function () {
|
|
||||||
await expect(world.page.locator('text=Total Players')).toBeVisible();
|
|
||||||
console.log('🌍 Verified total players section is visible');
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('I should see active tournament count', async function () {
|
|
||||||
// Use more specific locator to find the stats card
|
|
||||||
await expect(world.page.locator('dt:has-text("Tournaments")').first()).toBeVisible();
|
|
||||||
console.log('🌍 Verified tournaments section is visible');
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('I should see the activity feed section', async function () {
|
|
||||||
await expect(world.page.locator('text=Recent Activity')).toBeVisible();
|
|
||||||
console.log('🌍 Verified activity feed section is visible');
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('I should see recent player registrations', async function () {
|
|
||||||
// Check if there are any activities in the feed
|
|
||||||
const activityItems = await world.page.locator('.divide-y.divide-gray-200 li').count();
|
|
||||||
console.log(`🌍 Found ${activityItems} activity items`);
|
|
||||||
|
|
||||||
// Also check for the activity text
|
|
||||||
const content = await world.page.content();
|
|
||||||
const hasActivityText = content.includes('Test Activity Player');
|
|
||||||
console.log(`🌍 Activity text found in page: ${hasActivityText}`);
|
|
||||||
|
|
||||||
expect(activityItems).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
When('I go to the player management page', async function () {
|
|
||||||
console.log('🌍 Going to player management page');
|
|
||||||
await world.page.goto(`${world.baseURL}/admin/players`);
|
|
||||||
await world.page.waitForLoadState('domcontentloaded');
|
|
||||||
});
|
|
||||||
|
|
||||||
When('I search for {string}', async function (searchTerm: string) {
|
|
||||||
console.log(`🌍 Searching for: ${searchTerm}`);
|
|
||||||
await world.page.fill('input[name="search"]', searchTerm);
|
|
||||||
await world.page.waitForTimeout(500); // Wait for search to execute
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('I should see search results', async function () {
|
|
||||||
// Check if player table is visible
|
|
||||||
await expect(world.page.locator('table')).toBeVisible();
|
|
||||||
console.log('🌍 Verified search results are displayed');
|
|
||||||
});
|
|
||||||
|
|
||||||
When('I go to the club settings page', async function () {
|
|
||||||
console.log('🌍 Going to club settings page');
|
|
||||||
await world.page.goto(`${world.baseURL}/admin/settings`);
|
|
||||||
await world.page.waitForLoadState('domcontentloaded');
|
|
||||||
});
|
|
||||||
|
|
||||||
When('I update the club name', async function () {
|
|
||||||
console.log('🌍 Updating club name');
|
|
||||||
await world.page.fill('input[id="clubName"]', 'Test Club Updated');
|
|
||||||
});
|
|
||||||
|
|
||||||
When('I save the settings', async function () {
|
|
||||||
console.log('🌍 Saving settings');
|
|
||||||
await world.page.click('button:has-text("Save Settings")');
|
|
||||||
await world.page.waitForTimeout(1000); // Wait for save to complete
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('the settings should be saved successfully', async function () {
|
|
||||||
await expect(world.page.locator('text=Settings saved successfully')).toBeVisible();
|
|
||||||
console.log('🌍 Verified settings were saved successfully');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Rankings Page Steps
|
|
||||||
When('I go to the rankings page', async function () {
|
|
||||||
console.log('🌍 Going to rankings page');
|
|
||||||
await world.page.goto(`${world.baseURL}/rankings`);
|
|
||||||
await world.page.waitForLoadState('domcontentloaded');
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('I should see {string} in the page heading', async function (heading: string) {
|
|
||||||
// Use a more flexible selector that matches text content
|
|
||||||
await expect(world.page.locator(`text=${heading}`)).toBeVisible();
|
|
||||||
console.log(`🌍 Verified heading "${heading}" is visible`);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('I should see a rankings table', async function () {
|
|
||||||
await expect(world.page.locator('table')).toBeVisible();
|
|
||||||
console.log('🌍 Verified rankings table is visible');
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('I should see a rankings table with columns', async function () {
|
|
||||||
const table = world.page.locator('table');
|
|
||||||
await expect(table).toBeVisible();
|
|
||||||
const headerCount = await world.page.locator('th').count();
|
|
||||||
expect(headerCount).toBeGreaterThan(0);
|
|
||||||
console.log(`🌍 Verified rankings table has ${headerCount} columns`);
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('the table should have column headers', async function () {
|
|
||||||
const headerCount = await world.page.locator('th').count();
|
|
||||||
expect(headerCount).toBeGreaterThan(0);
|
|
||||||
console.log(`🌍 Verified table has ${headerCount} column headers`);
|
|
||||||
});
|
|
||||||
|
|
||||||
Given('I am not logged in', async function () {
|
|
||||||
// This is just a documentation step - the test environment starts fresh
|
|
||||||
console.log('🌍 User is not logged in (fresh session)');
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('I should be on the rankings page', async function () {
|
|
||||||
const currentUrl = world.page.url();
|
|
||||||
console.log(`🌍 Checking current URL: ${currentUrl}`);
|
|
||||||
expect(currentUrl).toContain('/rankings');
|
|
||||||
});
|
|
||||||
|
|
||||||
Then('I should see the rankings table', async function () {
|
|
||||||
await expect(world.page.locator('table')).toBeVisible();
|
|
||||||
console.log('🌍 Verified rankings table is visible');
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ test.describe('Elo Rating Updates', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await page.goto('/admin');
|
await page.goto('/admin');
|
||||||
await page.waitForLoadState('domcontentloaded');
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
// Check if we're logged in
|
// Check if we're logged in
|
||||||
const content = await page.content();
|
const content = await page.content();
|
||||||
@@ -211,19 +211,19 @@ test.describe('Elo Rating Updates', () => {
|
|||||||
console.log('Logging out and logging in as admin...');
|
console.log('Logging out and logging in as admin...');
|
||||||
await page.click('button:has-text("Sign out")');
|
await page.click('button:has-text("Sign out")');
|
||||||
await page.waitForURL('/auth/login');
|
await page.waitForURL('/auth/login');
|
||||||
await page.waitForLoadState('domcontentloaded');
|
await page.waitForLoadState('networkidle');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isLoggedIn || page.url().includes('/players/')) {
|
if (!isLoggedIn || page.url().includes('/players/')) {
|
||||||
// If not logged in or logged in as regular user, log in as admin
|
// If not logged in or logged in as regular user, log in as admin
|
||||||
console.log('Logging in as admin...');
|
console.log('Logging in as admin...');
|
||||||
await page.goto('/auth/login');
|
await page.goto('/auth/login');
|
||||||
await page.waitForLoadState('domcontentloaded');
|
await page.waitForLoadState('networkidle');
|
||||||
await page.fill('input[name="email"]', adminEmail);
|
await page.fill('input[name="email"]', adminEmail);
|
||||||
await page.fill('input[name="password"]', adminPassword);
|
await page.fill('input[name="password"]', adminPassword);
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await page.waitForURL(/\/(admin|players)/, { timeout: 10000 });
|
await page.waitForURL(/\/(admin|players)/, { timeout: 10000 });
|
||||||
await page.waitForLoadState('domcontentloaded');
|
await page.waitForLoadState('networkidle');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify tournament ownership
|
// Verify tournament ownership
|
||||||
@@ -238,7 +238,7 @@ test.describe('Elo Rating Updates', () => {
|
|||||||
await page.goto('/admin/matches/upload');
|
await page.goto('/admin/matches/upload');
|
||||||
|
|
||||||
// Wait for page to load and tournaments to be fetched
|
// Wait for page to load and tournaments to be fetched
|
||||||
await page.waitForLoadState('domcontentloaded');
|
await page.waitForLoadState('networkidle');
|
||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
// Wait for tournament dropdown to be ready
|
// Wait for tournament dropdown to be ready
|
||||||
@@ -313,7 +313,7 @@ ${tournament.id},2,1,${player1.name},${player3.name},10,${player2.name},${player
|
|||||||
|
|
||||||
// Navigate back to upload page for second match
|
// Navigate back to upload page for second match
|
||||||
await page.goto('/admin/matches/upload');
|
await page.goto('/admin/matches/upload');
|
||||||
await page.waitForLoadState('domcontentloaded');
|
await page.waitForLoadState('networkidle');
|
||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
// Re-select the tournament for the second upload
|
// Re-select the tournament for the second upload
|
||||||
@@ -384,7 +384,7 @@ ${tournament.id},2,1,${player1.name},${player3.name},10,${player2.name},${player
|
|||||||
await page.goto(`/players/${player.id}/profile`);
|
await page.goto(`/players/${player.id}/profile`);
|
||||||
|
|
||||||
// Wait for page to load
|
// Wait for page to load
|
||||||
await page.waitForLoadState('domcontentloaded');
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
// Verify rating is displayed (from player.currentElo)
|
// Verify rating is displayed (from player.currentElo)
|
||||||
await expect(page.locator('text=1750')).toBeVisible();
|
await expect(page.locator('text=1750')).toBeVisible();
|
||||||
|
|||||||
@@ -94,8 +94,8 @@ test.describe.serial('Epic 1: User Logout', () => {
|
|||||||
// Login first
|
// Login first
|
||||||
await page.goto('http://localhost:3000/auth/login');
|
await page.goto('http://localhost:3000/auth/login');
|
||||||
|
|
||||||
// Wait for page to load
|
// Wait for JavaScript to be ready
|
||||||
await page.waitForLoadState('domcontentloaded');
|
await page.waitForLoadState('networkidle');
|
||||||
await page.waitForSelector('form');
|
await page.waitForSelector('form');
|
||||||
await page.waitForSelector('button[type="submit"]:not([disabled])');
|
await page.waitForSelector('button[type="submit"]:not([disabled])');
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ test.describe.serial('Epic 1: User Logout', () => {
|
|||||||
// Navigate to home page to check navigation (session should persist)
|
// Navigate to home page to check navigation (session should persist)
|
||||||
// Use reload to ensure session is read from cookies
|
// Use reload to ensure session is read from cookies
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await page.waitForLoadState('domcontentloaded');
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
// Wait a moment for the navigation component to update
|
// Wait a moment for the navigation component to update
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
@@ -166,7 +166,7 @@ test.describe.serial('Epic 1: User Logout', () => {
|
|||||||
|
|
||||||
// Navigate to home using reload to ensure session is loaded
|
// Navigate to home using reload to ensure session is loaded
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await page.waitForLoadState('domcontentloaded');
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
// Wait for logout button to appear
|
// Wait for logout button to appear
|
||||||
await page.waitForSelector('text=Sign out', { timeout: 10000 });
|
await page.waitForSelector('text=Sign out', { timeout: 10000 });
|
||||||
@@ -190,7 +190,7 @@ test.describe.serial('Epic 1: User Logout', () => {
|
|||||||
|
|
||||||
// Navigate to home using reload to ensure session is loaded
|
// Navigate to home using reload to ensure session is loaded
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await page.waitForLoadState('domcontentloaded');
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
// Wait for logout button to appear
|
// Wait for logout button to appear
|
||||||
await page.waitForSelector('text=Sign out', { timeout: 10000 });
|
await page.waitForSelector('text=Sign out', { timeout: 10000 });
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ test.describe.serial('Epic 1: User Registration', () => {
|
|||||||
test('Registration with valid data creates account', async ({ page }) => {
|
test('Registration with valid data creates account', async ({ page }) => {
|
||||||
await page.goto('http://localhost:3000/auth/register');
|
await page.goto('http://localhost:3000/auth/register');
|
||||||
|
|
||||||
// Wait for page to load
|
// Wait for JavaScript to be ready
|
||||||
await page.waitForLoadState('domcontentloaded');
|
await page.waitForLoadState('networkidle');
|
||||||
await page.waitForSelector('form');
|
await page.waitForSelector('form');
|
||||||
await page.waitForSelector('button[type="submit"]:not([disabled])');
|
await page.waitForSelector('button[type="submit"]:not([disabled])');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Epic 3: Rankings & Public Data
|
||||||
|
* Acceptance Test: Player Rankings Page
|
||||||
|
*
|
||||||
|
* User Story: As a visitor, I want to view player rankings so that I can see top players
|
||||||
|
*
|
||||||
|
* Acceptance Criteria:
|
||||||
|
* - Sortable rankings table
|
||||||
|
* - Columns: Rank, Name, Elo, Win Rate, Games Played
|
||||||
|
* - Search/filter functionality
|
||||||
|
* - Pagination
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Epic 3: Rankings Page', () => {
|
||||||
|
test('Rankings page loads and displays rankings table', async ({ page }) => {
|
||||||
|
await page.goto('http://localhost:3000/rankings');
|
||||||
|
|
||||||
|
// Check page title or heading
|
||||||
|
await expect(page.locator('h1, h2')).toContainText(/rankings?/i);
|
||||||
|
|
||||||
|
// Check for rankings table
|
||||||
|
await expect(page.locator('table')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Rankings table displays player columns', async ({ page }) => {
|
||||||
|
await page.goto('http://localhost:3000/rankings');
|
||||||
|
|
||||||
|
// Check for expected column headers
|
||||||
|
const table = page.locator('table');
|
||||||
|
await expect(table).toBeVisible();
|
||||||
|
|
||||||
|
// Check for column headers (may vary based on implementation)
|
||||||
|
const headerCount = await page.locator('th').count();
|
||||||
|
expect(headerCount).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Rankings page is publicly accessible (no login required)', async ({ page }) => {
|
||||||
|
// Navigate directly to rankings without logging in
|
||||||
|
await page.goto('http://localhost:3000/rankings');
|
||||||
|
|
||||||
|
// Page should load without redirecting to login
|
||||||
|
await expect(page).toHaveURL(/.*rankings.*/);
|
||||||
|
await expect(page.locator('body')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
+2
-2
@@ -173,7 +173,7 @@ export default async function globalSetup(config: FullConfig) {
|
|||||||
// Navigate to admin page to refresh session
|
// Navigate to admin page to refresh session
|
||||||
console.log('Navigating to admin page for admin user...');
|
console.log('Navigating to admin page for admin user...');
|
||||||
await page.goto(`${baseURL}/admin`);
|
await page.goto(`${baseURL}/admin`);
|
||||||
await page.waitForLoadState('domcontentloaded');
|
await page.waitForLoadState('networkidle');
|
||||||
console.log('Admin page loaded:', page.url());
|
console.log('Admin page loaded:', page.url());
|
||||||
|
|
||||||
// Wait a bit to ensure session is refreshed
|
// Wait a bit to ensure session is refreshed
|
||||||
@@ -181,7 +181,7 @@ export default async function globalSetup(config: FullConfig) {
|
|||||||
|
|
||||||
// Refresh the page to force session reload
|
// Refresh the page to force session reload
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await page.waitForLoadState('domcontentloaded');
|
await page.waitForLoadState('networkidle');
|
||||||
console.log('Page reloaded');
|
console.log('Page reloaded');
|
||||||
|
|
||||||
// Save the authentication state
|
// Save the authentication state
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { createTestPlayer, cleanupTestRecords, getCreatedRecordCounts } from '@/__tests__/test-utils'
|
||||||
|
|
||||||
|
test.describe('Home Page', () => {
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
// Clean up any existing test records before each test
|
||||||
|
await cleanupTestRecords();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async () => {
|
||||||
|
// Clean up test records after each test
|
||||||
|
await cleanupTestRecords();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display top 10 players', async ({ page }) => {
|
||||||
|
// Create some test players with unique names and very high Elo to ensure they're in top 10
|
||||||
|
const timestamp = Date.now()
|
||||||
|
const players = []
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const playerName = `Home Test Player ${timestamp} ${i + 1}`
|
||||||
|
const player = await createTestPlayer({
|
||||||
|
name: playerName,
|
||||||
|
currentElo: 2000 - i * 10,
|
||||||
|
})
|
||||||
|
players.push(player)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to home page
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
// Check that the page loads
|
||||||
|
await expect(page.locator('text=Top 10 Players')).toBeVisible()
|
||||||
|
|
||||||
|
// Check that at least one of our test players is displayed
|
||||||
|
// Use a more specific locator to avoid multiple matches
|
||||||
|
await expect(
|
||||||
|
page.locator(`a:has-text("Home Test Player ${timestamp} 1")`)
|
||||||
|
).toBeVisible()
|
||||||
|
|
||||||
|
// Verify cleanup will work
|
||||||
|
const counts = getCreatedRecordCounts();
|
||||||
|
expect(counts.players).toBe(3);
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should display club president', async ({ page }) => {
|
||||||
|
const timestamp = Date.now()
|
||||||
|
// Create a club admin user
|
||||||
|
const clubAdmin = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: `president-${timestamp}@example.com`,
|
||||||
|
name: `Club President ${timestamp}`,
|
||||||
|
role: 'club_admin',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Navigate to home page
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
// Check that the club president section is visible
|
||||||
|
await expect(page.locator('text=Club President')).toBeVisible()
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await prisma.user.delete({ where: { id: clubAdmin.id } })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should display most recent tournament', async ({ page }) => {
|
||||||
|
const timestamp = Date.now()
|
||||||
|
// Create a tournament with a future date to ensure it's the most recent
|
||||||
|
const tournament = await prisma.event.create({
|
||||||
|
data: {
|
||||||
|
name: `Recent Tournament ${timestamp}`,
|
||||||
|
eventType: 'tournament',
|
||||||
|
eventDate: new Date(Date.now() + 86400000), // Tomorrow
|
||||||
|
status: 'completed',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create players for the match
|
||||||
|
const player1Name = `Home Match Player 1 ${timestamp}`
|
||||||
|
const player1 = await prisma.player.create({
|
||||||
|
data: { name: player1Name, normalizedName: player1Name.toLowerCase(), currentElo: 1500 },
|
||||||
|
})
|
||||||
|
const player2Name = `Home Match Player 2 ${timestamp}`
|
||||||
|
const player2 = await prisma.player.create({
|
||||||
|
data: { name: player2Name, normalizedName: player2Name.toLowerCase(), currentElo: 1480 },
|
||||||
|
})
|
||||||
|
const player3Name = `Home Match Player 3 ${timestamp}`
|
||||||
|
const player3 = await prisma.player.create({
|
||||||
|
data: { name: player3Name, normalizedName: player3Name.toLowerCase(), currentElo: 1450 },
|
||||||
|
})
|
||||||
|
const player4Name = `Home Match Player 4 ${timestamp}`
|
||||||
|
const player4 = await prisma.player.create({
|
||||||
|
data: { name: player4Name, normalizedName: player4Name.toLowerCase(), currentElo: 1420 },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create a match in the tournament
|
||||||
|
await prisma.match.create({
|
||||||
|
data: {
|
||||||
|
eventId: tournament.id,
|
||||||
|
player1P1Id: player1.id,
|
||||||
|
player1P2Id: player2.id,
|
||||||
|
player2P1Id: player3.id,
|
||||||
|
player2P2Id: player4.id,
|
||||||
|
team1Score: 10,
|
||||||
|
team2Score: 5,
|
||||||
|
status: 'completed',
|
||||||
|
playedAt: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Navigate to home page
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
// Check that the tournament section is visible
|
||||||
|
await expect(page.locator('text=Most Recent Tournament')).toBeVisible()
|
||||||
|
await expect(page.locator(`text=Recent Tournament ${timestamp}`)).toBeVisible()
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await prisma.match.deleteMany({ where: { eventId: tournament.id } })
|
||||||
|
await prisma.event.delete({ where: { id: tournament.id } })
|
||||||
|
await prisma.player.deleteMany({
|
||||||
|
where: { id: { in: [player1.id, player2.id, player3.id, player4.id] } },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -241,7 +241,7 @@ test.describe.serial('Tournament with 10 Participants and Variable Team Durabili
|
|||||||
await page.goto(`http://localhost:3000/admin/tournaments/${tournamentId}`);
|
await page.goto(`http://localhost:3000/admin/tournaments/${tournamentId}`);
|
||||||
|
|
||||||
// Wait for page to load and data to be fetched
|
// Wait for page to load and data to be fetched
|
||||||
await page.waitForLoadState('domcontentloaded');
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
// Wait for the page content to appear (not just "Loading...")
|
// Wait for the page content to appear (not just "Loading...")
|
||||||
await expect(page.locator('text=Matchups')).toBeVisible({ timeout: 15000 });
|
await expect(page.locator('text=Matchups')).toBeVisible({ timeout: 15000 });
|
||||||
|
|||||||
Generated
+9840
File diff suppressed because it is too large
Load Diff
+20
-20
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "euchre_camp",
|
"name": "euchre_camp",
|
||||||
"version": "0.1.12",
|
"version": "0.1.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "NEXT_PUBLIC_GIT_COMMIT=$(git rev-parse --short HEAD) next dev",
|
"dev": "NEXT_PUBLIC_GIT_COMMIT=$(git rev-parse --short HEAD) next dev",
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"test:acceptance:headed": "bun x playwright test e2e/ --headed",
|
"test:acceptance:headed": "bun x playwright test e2e/ --headed",
|
||||||
"test:acceptance:cucumber": "DATABASE_URL=$(grep DATABASE_URL .env.development | cut -d'=' -f2 | tr -d '\"') DATABASE_PROVIDER=postgresql bun cucumber-js --config e2e/cucumber/cucumber.config.ts",
|
"test:acceptance:cucumber": "DATABASE_URL=$(grep DATABASE_URL .env.development | cut -d'=' -f2 | tr -d '\"') DATABASE_PROVIDER=postgresql bun cucumber-js --config e2e/cucumber/cucumber.config.ts",
|
||||||
"test:acceptance:cucumber:pretty": "DATABASE_URL=$(grep DATABASE_URL .env.development | cut -d'=' -f2 | tr -d '\"') DATABASE_PROVIDER=postgresql bun cucumber-js --config e2e/cucumber/cucumber.config.ts --format pretty:cucumber-pretty",
|
"test:acceptance:cucumber:pretty": "DATABASE_URL=$(grep DATABASE_URL .env.development | cut -d'=' -f2 | tr -d '\"') DATABASE_PROVIDER=postgresql bun cucumber-js --config e2e/cucumber/cucumber.config.ts --format pretty:cucumber-pretty",
|
||||||
"test:acceptance:cucumber:prod": "bun run build && (trap 'kill $(jobs -p) 2>/dev/null || true' EXIT; DATABASE_URL=${DATABASE_URL:-$(grep DATABASE_URL .env.development | cut -d'=' -f2 | tr -d '\"')} DATABASE_PROVIDER=${DATABASE_PROVIDER:-postgresql} bun run start & echo 'Waiting for server to start...'; for i in {1..30}; do if curl -s http://localhost:3000 > /dev/null 2>&1; then echo 'Server ready!'; break; fi; sleep 1; done; npm run test:acceptance:cucumber)",
|
"test:acceptance:cucumber:prod": "bun run build && (DATABASE_URL=${DATABASE_URL:-$(grep DATABASE_URL .env.development | cut -d'=' -f2 | tr -d '\"')} DATABASE_PROVIDER=${DATABASE_PROVIDER:-postgresql} bun run start & SERVER_PID=$! && sleep 15 && npm run test:acceptance:cucumber; kill $SERVER_PID 2>/dev/null || true)",
|
||||||
"cucumber": "DATABASE_URL=$(grep DATABASE_URL .env.development | cut -d'=' -f2 | tr -d '\"') DATABASE_PROVIDER=postgresql bun cucumber-js --config e2e/cucumber/cucumber.config.ts",
|
"cucumber": "DATABASE_URL=$(grep DATABASE_URL .env.development | cut -d'=' -f2 | tr -d '\"') DATABASE_PROVIDER=postgresql bun cucumber-js --config e2e/cucumber/cucumber.config.ts",
|
||||||
"db:switch": "bun run scripts/switch-database.js",
|
"db:switch": "bun run scripts/switch-database.js",
|
||||||
"db:setup-postgres": "bun run scripts/setup-postgres.js",
|
"db:setup-postgres": "bun run scripts/setup-postgres.js",
|
||||||
@@ -44,35 +44,35 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@prisma/adapter-pg": "^7.8.0",
|
"@prisma/adapter-pg": "^7.6.0",
|
||||||
"@prisma/client": "^7.8.0",
|
"@prisma/client": "^7.6.0",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-auth": "^1.6.9",
|
"better-auth": "^1.5.6",
|
||||||
"glicko2": "^1.2.1",
|
"glicko2": "^1.2.1",
|
||||||
"jose": "^6.2.2",
|
"jose": "^6.2.2",
|
||||||
"next": "^16.2.4",
|
"next": "^16.2.1",
|
||||||
"openskill": "^4.1.1",
|
"openskill": "^4.1.1",
|
||||||
"papaparse": "^5.5.3",
|
"papaparse": "^5.5.3",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"prisma": "^7.8.0",
|
"prisma": "^7.6.0",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.4",
|
||||||
"react-hook-form": "^7.74.0",
|
"react-hook-form": "^7.72.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cucumber/cucumber": "^12.8.2",
|
"@cucumber/cucumber": "^12.8.2",
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.58.2",
|
||||||
"@tailwindcss/postcss": "^4.2.4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/bun": "^1.3.13",
|
"@types/bun": "^1.3.11",
|
||||||
"@types/jsdom": "^28.0.1",
|
"@types/jsdom": "^28.0.1",
|
||||||
"@types/node": "^20.19.39",
|
"@types/node": "^20",
|
||||||
"@types/papaparse": "^5.5.2",
|
"@types/papaparse": "^5.5.2",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
@@ -80,12 +80,12 @@
|
|||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"argon2": "^0.44.0",
|
"argon2": "^0.44.0",
|
||||||
"cucumber-pretty": "^6.0.1",
|
"cucumber-pretty": "^6.0.1",
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^8.57.0",
|
||||||
"eslint-config-next": "^16.2.4",
|
"eslint-config-next": "^16.2.1",
|
||||||
"jsdom": "^29.1.0",
|
"jsdom": "^29.0.1",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5",
|
||||||
"vitest": "^4.1.5"
|
"vitest": "^4.1.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
-- DropForeignKey
|
|
||||||
ALTER TABLE "matches" DROP CONSTRAINT "matches_player1P1Id_fkey";
|
|
||||||
|
|
||||||
-- DropForeignKey
|
|
||||||
ALTER TABLE "matches" DROP CONSTRAINT "matches_player1P2Id_fkey";
|
|
||||||
|
|
||||||
-- DropForeignKey
|
|
||||||
ALTER TABLE "matches" DROP CONSTRAINT "matches_player2P1Id_fkey";
|
|
||||||
|
|
||||||
-- DropForeignKey
|
|
||||||
ALTER TABLE "matches" DROP CONSTRAINT "matches_player2P2Id_fkey";
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "activities" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"type" TEXT NOT NULL,
|
|
||||||
"description" TEXT NOT NULL,
|
|
||||||
"userId" TEXT,
|
|
||||||
"playerId" INTEGER,
|
|
||||||
"eventId" INTEGER,
|
|
||||||
"matchId" INTEGER,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "activities_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "club_settings" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"clubName" TEXT NOT NULL DEFAULT 'Euchre Club',
|
|
||||||
"defaultEloRating" INTEGER NOT NULL DEFAULT 1200,
|
|
||||||
"partnershipEnabled" BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
"notificationsEnabled" BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
"matchVerification" BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "club_settings_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "matches" ADD CONSTRAINT "matches_player1P1Id_fkey" FOREIGN KEY ("player1P1Id") REFERENCES "players"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "matches" ADD CONSTRAINT "matches_player1P2Id_fkey" FOREIGN KEY ("player1P2Id") REFERENCES "players"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "matches" ADD CONSTRAINT "matches_player2P1Id_fkey" FOREIGN KEY ("player2P1Id") REFERENCES "players"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "matches" ADD CONSTRAINT "matches_player2P2Id_fkey" FOREIGN KEY ("player2P2Id") REFERENCES "players"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "activities" ADD CONSTRAINT "activities_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "activities" ADD CONSTRAINT "activities_playerId_fkey" FOREIGN KEY ("playerId") REFERENCES "players"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "activities" ADD CONSTRAINT "activities_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "events"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "activities" ADD CONSTRAINT "activities_matchId_fkey" FOREIGN KEY ("matchId") REFERENCES "matches"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
@@ -27,7 +27,6 @@ model Player {
|
|||||||
partnershipGames2 PartnershipGame[] @relation("PartnershipPlayer2")
|
partnershipGames2 PartnershipGame[] @relation("PartnershipPlayer2")
|
||||||
partnershipStats PartnershipStat[] @relation("StatPlayer1")
|
partnershipStats PartnershipStat[] @relation("StatPlayer1")
|
||||||
partnershipStats2 PartnershipStat[] @relation("StatPlayer2")
|
partnershipStats2 PartnershipStat[] @relation("StatPlayer2")
|
||||||
activities Activity[]
|
|
||||||
user User?
|
user User?
|
||||||
eloRating EloRating?
|
eloRating EloRating?
|
||||||
glicko2Rating Glicko2Rating?
|
glicko2Rating Glicko2Rating?
|
||||||
@@ -54,7 +53,6 @@ model User {
|
|||||||
ownedTournaments Event[] @relation("TournamentOwner")
|
ownedTournaments Event[] @relation("TournamentOwner")
|
||||||
createdMatches Match[] @relation("MatchCreator")
|
createdMatches Match[] @relation("MatchCreator")
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
activities Activity[]
|
|
||||||
player Player? @relation(fields: [playerId], references: [id])
|
player Player? @relation(fields: [playerId], references: [id])
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
@@ -81,7 +79,6 @@ model Event {
|
|||||||
owner User? @relation("TournamentOwner", fields: [ownerId], references: [id])
|
owner User? @relation("TournamentOwner", fields: [ownerId], references: [id])
|
||||||
matches Match[]
|
matches Match[]
|
||||||
rounds TournamentRound[]
|
rounds TournamentRound[]
|
||||||
activities Activity[]
|
|
||||||
|
|
||||||
// Team configuration fields
|
// Team configuration fields
|
||||||
teamDurability String @default("permanent") // permanent, variable, per_round
|
teamDurability String @default("permanent") // permanent, variable, per_round
|
||||||
@@ -170,7 +167,6 @@ model Match {
|
|||||||
isCasual Boolean @default(false)
|
isCasual Boolean @default(false)
|
||||||
bracketMatchups BracketMatchup[]
|
bracketMatchups BracketMatchup[]
|
||||||
eloSnapshots EloSnapshot[]
|
eloSnapshots EloSnapshot[]
|
||||||
activities Activity[]
|
|
||||||
createdBy User? @relation("MatchCreator", fields: [createdById], references: [id])
|
createdBy User? @relation("MatchCreator", fields: [createdById], references: [id])
|
||||||
event Event? @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
event Event? @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||||
player1P1 Player? @relation("MatchPlayer1", fields: [player1P1Id], references: [id])
|
player1P1 Player? @relation("MatchPlayer1", fields: [player1P1Id], references: [id])
|
||||||
@@ -329,34 +325,3 @@ model OpenSkillRating {
|
|||||||
|
|
||||||
@@map("open_skill_ratings")
|
@@map("open_skill_ratings")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Activity {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
type String // "player_registration", "tournament_created", "match_completed", "partnership_recorded"
|
|
||||||
description String
|
|
||||||
userId String?
|
|
||||||
playerId Int?
|
|
||||||
eventId Int?
|
|
||||||
matchId Int?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
user User? @relation(fields: [userId], references: [id])
|
|
||||||
player Player? @relation(fields: [playerId], references: [id])
|
|
||||||
event Event? @relation(fields: [eventId], references: [id])
|
|
||||||
match Match? @relation(fields: [matchId], references: [id])
|
|
||||||
|
|
||||||
@@map("activities")
|
|
||||||
}
|
|
||||||
|
|
||||||
model ClubSettings {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
clubName String @default("Euchre Club")
|
|
||||||
defaultEloRating Int @default(1200)
|
|
||||||
partnershipEnabled Boolean @default(true)
|
|
||||||
notificationsEnabled Boolean @default(true)
|
|
||||||
matchVerification Boolean @default(false)
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@map("club_settings")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -59,17 +59,6 @@ export default async function AdminDashboard() {
|
|||||||
}),
|
}),
|
||||||
]) as [number, number, number, EventModel[]]
|
]) as [number, number, number, EventModel[]]
|
||||||
|
|
||||||
// Get recent activities (using any type to bypass TypeScript error for now)
|
|
||||||
const recentActivities = await (prisma as any).activity.findMany({
|
|
||||||
take: 10,
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
include: {
|
|
||||||
user: { select: { name: true } },
|
|
||||||
player: { select: { name: true } },
|
|
||||||
event: { select: { name: true } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<Navigation />
|
<Navigation />
|
||||||
@@ -242,41 +231,6 @@ export default async function AdminDashboard() {
|
|||||||
</Link> in the rankings page.
|
</Link> in the rankings page.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Activity Feed */}
|
|
||||||
<div className="bg-white shadow rounded-lg p-6 mt-6">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h2 className="text-lg font-medium text-gray-900">Recent Activity</h2>
|
|
||||||
<Link
|
|
||||||
href="/admin/activity"
|
|
||||||
className="text-green-600 hover:text-green-900 text-sm font-medium"
|
|
||||||
>
|
|
||||||
View All
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
{recentActivities.length > 0 ? (
|
|
||||||
<ul className="divide-y divide-gray-200">
|
|
||||||
{recentActivities.map((activity: any) => (
|
|
||||||
<li key={activity.id} className="py-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-900">{activity.description}</p>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
{new Date(activity.createdAt).toLocaleDateString()} at{' '}
|
|
||||||
{new Date(activity.createdAt).toLocaleTimeString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
|
||||||
{activity.type}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : (
|
|
||||||
<p className="text-gray-500">No recent activities.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -231,34 +231,8 @@ export default function AdminPlayersPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Search and Filter Controls */}
|
|
||||||
<div className="bg-white shadow rounded-lg p-4 mb-4">
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<div className="flex-1">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="search"
|
|
||||||
placeholder="Search players by name..."
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-green-500 focus:border-green-500"
|
|
||||||
onChange={async (e) => {
|
|
||||||
const search = e.target.value
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/players?search=${encodeURIComponent(search)}`)
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json()
|
|
||||||
setPlayers(data)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Search failed:', err)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Player Table */}
|
{/* Player Table */}
|
||||||
<div className="bg-white shadow rounded-lg overflow-x-auto">
|
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -1,224 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
|
||||||
import Navigation from "@/components/Navigation"
|
|
||||||
import { redirect } from "next/navigation"
|
|
||||||
import { getSession } from "@/lib/auth-simple"
|
|
||||||
|
|
||||||
interface ClubSettings {
|
|
||||||
id: number
|
|
||||||
clubName: string
|
|
||||||
defaultEloRating: number
|
|
||||||
partnershipEnabled: boolean
|
|
||||||
notificationsEnabled: boolean
|
|
||||||
matchVerification: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ClubSettingsPage() {
|
|
||||||
const [settings, setSettings] = useState<ClubSettings | null>(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [error, setError] = useState("")
|
|
||||||
const [success, setSuccess] = useState("")
|
|
||||||
|
|
||||||
const [formSettings, setFormSettings] = useState<Partial<ClubSettings>>({})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchSettings()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const fetchSettings = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/admin/settings")
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to fetch settings")
|
|
||||||
}
|
|
||||||
const data = await response.json()
|
|
||||||
setSettings(data)
|
|
||||||
setFormSettings(data || {})
|
|
||||||
} catch (err: unknown) {
|
|
||||||
setError(err instanceof Error ? err.message : "Failed to fetch settings")
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
setSaving(true)
|
|
||||||
setError("")
|
|
||||||
setSuccess("")
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/admin/settings", {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(formSettings),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json()
|
|
||||||
throw new Error(errorData.error || "Failed to save settings")
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedSettings = await response.json()
|
|
||||||
setSettings(updatedSettings)
|
|
||||||
setSuccess("Settings saved successfully!")
|
|
||||||
} catch (err: unknown) {
|
|
||||||
setError(err instanceof Error ? err.message : "Failed to save settings")
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50">
|
|
||||||
<Navigation />
|
|
||||||
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
|
||||||
<div className="px-4 py-6 sm:px-0">
|
|
||||||
<p className="text-gray-500">Loading settings...</p>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50">
|
|
||||||
<Navigation />
|
|
||||||
|
|
||||||
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
|
||||||
<div className="px-4 py-6 sm:px-0">
|
|
||||||
{/* Page Header */}
|
|
||||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Club Settings</h1>
|
|
||||||
<p className="text-gray-500 mt-1">
|
|
||||||
Configure your club's default settings and preferences.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Settings Form */}
|
|
||||||
<div className="bg-white shadow rounded-lg p-6">
|
|
||||||
{error && (
|
|
||||||
<div className="mb-4 p-4 bg-red-100 text-red-700 rounded">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{success && (
|
|
||||||
<div className="mb-4 p-4 bg-green-100 text-green-700 rounded">
|
|
||||||
{success}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Club Name */}
|
|
||||||
<div>
|
|
||||||
<label htmlFor="clubName" className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Club Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="clubName"
|
|
||||||
value={formSettings.clubName || ""}
|
|
||||||
onChange={(e) => setFormSettings({ ...formSettings, clubName: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-green-500 focus:border-green-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Default Elo Rating */}
|
|
||||||
<div>
|
|
||||||
<label htmlFor="defaultEloRating" className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Default Elo Rating
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
id="defaultEloRating"
|
|
||||||
value={formSettings.defaultEloRating || 1000}
|
|
||||||
onChange={(e) => setFormSettings({ ...formSettings, defaultEloRating: parseInt(e.target.value) })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-green-500 focus:border-green-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Partnership Tracking */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-gray-700">Partnership Tracking</label>
|
|
||||||
<p className="text-sm text-gray-500">Enable partnership performance analytics</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFormSettings({ ...formSettings, partnershipEnabled: !formSettings.partnershipEnabled })}
|
|
||||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
|
|
||||||
formSettings.partnershipEnabled ? 'bg-green-600' : 'bg-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
|
||||||
formSettings.partnershipEnabled ? 'translate-x-5' : 'translate-x-0'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Notifications */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-gray-700">Notifications</label>
|
|
||||||
<p className="text-sm text-gray-500">Send email notifications for updates</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFormSettings({ ...formSettings, notificationsEnabled: !formSettings.notificationsEnabled })}
|
|
||||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
|
|
||||||
formSettings.notificationsEnabled ? 'bg-green-600' : 'bg-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
|
||||||
formSettings.notificationsEnabled ? 'translate-x-5' : 'translate-x-0'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Match Verification */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-gray-700">Match Verification</label>
|
|
||||||
<p className="text-sm text-gray-500">Require admin verification for match results</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFormSettings({ ...formSettings, matchVerification: !formSettings.matchVerification })}
|
|
||||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
|
|
||||||
formSettings.matchVerification ? 'bg-green-600' : 'bg-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
|
||||||
formSettings.matchVerification ? 'translate-x-5' : 'translate-x-0'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Save Button */}
|
|
||||||
<div className="mt-8 flex justify-end">
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving}
|
|
||||||
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{saving ? 'Saving...' : 'Save Settings'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
import { getSession } from '@/lib/auth-simple'
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const session = await getSession()
|
|
||||||
if (!session) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
|
||||||
const limit = parseInt(searchParams.get('limit') || '20')
|
|
||||||
const offset = parseInt(searchParams.get('offset') || '0')
|
|
||||||
const type = searchParams.get('type')
|
|
||||||
|
|
||||||
const where: any = {}
|
|
||||||
if (type) {
|
|
||||||
where.type = type
|
|
||||||
}
|
|
||||||
|
|
||||||
const activities = await prisma.activity.findMany({
|
|
||||||
where,
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
take: limit,
|
|
||||||
skip: offset,
|
|
||||||
include: {
|
|
||||||
user: { select: { name: true } },
|
|
||||||
player: { select: { name: true } },
|
|
||||||
event: { select: { name: true } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(activities)
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
import { getSession } from '@/lib/auth-simple'
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const session = await getSession()
|
|
||||||
if (!session) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const settings = await (prisma as any).clubSettings.findFirst()
|
|
||||||
return NextResponse.json(settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest) {
|
|
||||||
const session = await getSession()
|
|
||||||
if (!session) {
|
|
||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await request.json()
|
|
||||||
|
|
||||||
// Get the existing settings record (should be id: 1 or first record)
|
|
||||||
let settings = await (prisma as any).clubSettings.findFirst()
|
|
||||||
|
|
||||||
if (!settings) {
|
|
||||||
// Create default settings if none exist
|
|
||||||
settings = await (prisma as any).clubSettings.create({
|
|
||||||
data: {
|
|
||||||
clubName: 'Euchre Club',
|
|
||||||
defaultEloRating: 1200,
|
|
||||||
partnershipEnabled: true,
|
|
||||||
notificationsEnabled: true,
|
|
||||||
matchVerification: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the settings
|
|
||||||
const updatedSettings = await (prisma as any).clubSettings.update({
|
|
||||||
where: { id: settings.id },
|
|
||||||
data,
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json(updatedSettings)
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -6,22 +6,10 @@ import { prisma } from "@/lib/prisma";
|
|||||||
*
|
*
|
||||||
* Get all players with their user associations
|
* Get all players with their user associations
|
||||||
* This is a public endpoint (no authentication required)
|
* This is a public endpoint (no authentication required)
|
||||||
* Supports search query parameter
|
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url)
|
|
||||||
const search = searchParams.get('search') || ''
|
|
||||||
const limit = parseInt(searchParams.get('limit') || '50')
|
|
||||||
const offset = parseInt(searchParams.get('offset') || '0')
|
|
||||||
|
|
||||||
const where: any = {}
|
|
||||||
if (search) {
|
|
||||||
where.name = { contains: search, mode: 'insensitive' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const players = await prisma.player.findMany({
|
const players = await prisma.player.findMany({
|
||||||
where,
|
|
||||||
include: {
|
include: {
|
||||||
user: {
|
user: {
|
||||||
select: {
|
select: {
|
||||||
@@ -29,9 +17,7 @@ export async function GET(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: { currentElo: 'desc' },
|
orderBy: { name: "asc" },
|
||||||
take: limit,
|
|
||||||
skip: offset,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(players);
|
return NextResponse.json(players);
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ export default function RootLayout({
|
|||||||
lang="en"
|
lang="en"
|
||||||
className={`${inter.variable} h-full antialiased`}
|
className={`${inter.variable} h-full antialiased`}
|
||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col overflow-x-hidden">
|
<body className="min-h-full flex flex-col">
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
{children}
|
{children}
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export default function RankingsClient({ players }: { players: PlayerWithRatings
|
|||||||
|
|
||||||
{/* Elo Rating Tab */}
|
{/* Elo Rating Tab */}
|
||||||
{activeTab === "elo" && (
|
{activeTab === "elo" && (
|
||||||
<div className="bg-white shadow overflow-x-auto sm:rounded-lg">
|
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||||
<h2 className="text-xl font-semibold text-gray-900 px-6 py-4 border-b">
|
<h2 className="text-xl font-semibold text-gray-900 px-6 py-4 border-b">
|
||||||
Elo Rating Rankings
|
Elo Rating Rankings
|
||||||
</h2>
|
</h2>
|
||||||
@@ -117,7 +117,7 @@ export default function RankingsClient({ players }: { players: PlayerWithRatings
|
|||||||
|
|
||||||
{/* OpenSkill Rating Tab */}
|
{/* OpenSkill Rating Tab */}
|
||||||
{activeTab === "openskill" && (
|
{activeTab === "openskill" && (
|
||||||
<div className="bg-white shadow overflow-x-auto sm:rounded-lg">
|
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||||
<h2 className="text-xl font-semibold text-gray-900 px-6 py-4 border-b">
|
<h2 className="text-xl font-semibold text-gray-900 px-6 py-4 border-b">
|
||||||
OpenSkill Rating Rankings
|
OpenSkill Rating Rankings
|
||||||
</h2>
|
</h2>
|
||||||
@@ -178,7 +178,7 @@ export default function RankingsClient({ players }: { players: PlayerWithRatings
|
|||||||
|
|
||||||
{/* Glicko2 Rating Tab */}
|
{/* Glicko2 Rating Tab */}
|
||||||
{activeTab === "glicko2" && (
|
{activeTab === "glicko2" && (
|
||||||
<div className="bg-white shadow overflow-x-auto sm:rounded-lg">
|
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||||
<h2 className="text-xl font-semibold text-gray-900 px-6 py-4 border-b">
|
<h2 className="text-xl font-semibold text-gray-900 px-6 py-4 border-b">
|
||||||
Glicko2 Rating Rankings
|
Glicko2 Rating Rankings
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -50,14 +50,14 @@ export default function Navigation() {
|
|||||||
<nav className="bg-white shadow-sm">
|
<nav className="bg-white shadow-sm">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex justify-between h-16">
|
<div className="flex justify-between h-16">
|
||||||
<div className="flex items-center min-w-0 overflow-hidden">
|
<div className="flex items-center">
|
||||||
<Link
|
<Link
|
||||||
href="/wordmark-redirect"
|
href="/wordmark-redirect"
|
||||||
className="text-xl font-bold text-gray-900 no-underline flex-shrink-0"
|
className="text-xl font-bold text-gray-900 no-underline"
|
||||||
>
|
>
|
||||||
EuchreCamp
|
EuchreCamp
|
||||||
</Link>
|
</Link>
|
||||||
<div className="hidden md:ml-6 md:flex md:space-x-8 min-w-0 overflow-hidden">
|
<div className="hidden md:ml-6 md:flex md:space-x-8">
|
||||||
<Link
|
<Link
|
||||||
href="/rankings"
|
href="/rankings"
|
||||||
className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
className="border-transparent text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium"
|
||||||
@@ -110,7 +110,7 @@ export default function Navigation() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center min-w-0 overflow-hidden">
|
<div className="flex items-center">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-gray-500">Loading...</div>
|
<div className="text-gray-500">Loading...</div>
|
||||||
) : session ? (
|
) : session ? (
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import { prisma } from './prisma'
|
|
||||||
|
|
||||||
export type ActivityType =
|
|
||||||
| 'player_registration'
|
|
||||||
| 'tournament_created'
|
|
||||||
| 'match_completed'
|
|
||||||
| 'partnership_recorded'
|
|
||||||
|
|
||||||
export interface ActivityData {
|
|
||||||
type: ActivityType
|
|
||||||
description: string
|
|
||||||
userId?: string
|
|
||||||
playerId?: number
|
|
||||||
eventId?: number
|
|
||||||
matchId?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function logActivity(data: ActivityData) {
|
|
||||||
return prisma.activity.create({
|
|
||||||
data: {
|
|
||||||
type: data.type,
|
|
||||||
description: data.description,
|
|
||||||
userId: data.userId,
|
|
||||||
playerId: data.playerId,
|
|
||||||
eventId: data.eventId,
|
|
||||||
matchId: data.matchId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user