Compare commits
33 Commits
de502741e7
...
2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65aaa92359 | ||
|
|
9175d505c2 | ||
|
|
64fedd3024 | ||
|
|
a6a16fdacf | ||
|
|
2b17027801 | ||
|
|
d6ea0125df | ||
|
|
af0ffe8517 | ||
|
|
f8b461e189 | ||
|
|
8aac1d476a | ||
|
|
892c01d188 | ||
|
|
26a97e7eaa | ||
|
|
6da7a5f4e2 | ||
|
|
d22bbdb3dc | ||
|
|
3e2264ad9d | ||
|
|
77173718c1 | ||
|
|
ed90b47348 | ||
|
|
b152575e61 | ||
|
|
f88db204f7 | ||
|
|
bc1bc4b446 | ||
|
|
75fc0668bb | ||
|
|
d3083c8c68 | ||
|
|
2e0855e781 | ||
|
|
81c7c9579b | ||
|
|
dc1d9a23a9 | ||
|
|
a11d41f934 | ||
|
|
1bd9919b6b | ||
|
|
147906af59 | ||
|
|
634d012097 | ||
|
|
301d5b131c | ||
|
|
4c8b0cfed7 | ||
|
|
31ed600c97 | ||
|
|
d016868ff2 | ||
|
|
89300bc021 |
2
.gitea
2
.gitea
@@ -1 +1 @@
|
||||
https://gitea.schwenk.online/froxxxy/bscscore/issues/26
|
||||
https://gitea.schwenk.online/froxxxy/bscscore/issues/30
|
||||
37
README.md
37
README.md
@@ -77,8 +77,42 @@ npm run dev
|
||||
npm run dev # Start development server
|
||||
npm run build # Build for production
|
||||
npm run preview # Preview production build
|
||||
npm run test:record # Record browser interactions with Playwright
|
||||
npm run test:e2e # Run all recorded browser automation scripts
|
||||
```
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
The project uses **Playwright** for browser automation and recording. This allows you to record interactions once and replay them anytime, making it easy to test repetitive workflows.
|
||||
|
||||
### Quick Start
|
||||
|
||||
**Recording interactions:**
|
||||
```bash
|
||||
# Terminal 1: Start dev server
|
||||
npm run dev
|
||||
|
||||
# Terminal 2: Start recording
|
||||
npm run test:record
|
||||
```
|
||||
|
||||
**Running recordings:**
|
||||
```bash
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
- **Record interactions**: Use Playwright codegen to capture clicks, form fills, and navigation
|
||||
- **Replay scripts**: Run recorded scripts automatically
|
||||
- **Duplicate & modify**: Copy any script and modify it (e.g., change the last step from clicking 'z' to clicking 'a')
|
||||
- **Full scripting power**: Edit generated TypeScript files directly for custom automation
|
||||
|
||||
### Documentation
|
||||
|
||||
For detailed instructions on recording, modifying, and running scripts, see:
|
||||
- **[tests/recordings/README.md](tests/recordings/README.md)** - Complete workflow documentation
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
### **Core Components**
|
||||
@@ -199,8 +233,7 @@ The application includes PWA features:
|
||||
|
||||
## 📈 Future Improvements
|
||||
|
||||
- Unit and integration testing with Vitest
|
||||
- E2E testing with Playwright
|
||||
- Unit and integration testing (if needed)
|
||||
- Internationalization (i18n) support
|
||||
- Advanced game statistics and analytics
|
||||
- Real-time multiplayer support
|
||||
|
||||
73
package-lock.json
generated
73
package-lock.json
generated
@@ -13,7 +13,9 @@
|
||||
"preact": "^10.26.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.3"
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@types/node": "^24.0.3",
|
||||
"playwright": "^1.56.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
@@ -146,6 +148,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.4.tgz",
|
||||
"integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
@@ -1266,6 +1269,22 @@
|
||||
"integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz",
|
||||
"integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.56.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@preact/preset-vite": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.10.1.tgz",
|
||||
@@ -2204,6 +2223,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001718",
|
||||
"electron-to-chromium": "^1.5.160",
|
||||
@@ -4409,6 +4429,53 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.56.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
|
||||
"integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.56.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.56.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
|
||||
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.4",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.4.tgz",
|
||||
@@ -4442,6 +4509,7 @@
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.26.8.tgz",
|
||||
"integrity": "sha512-1nMfdFjucm5hKvq0IClqZwK4FJkGXhRrQstOQ3P4vp8HxKrJEMFcY6RdBRVTdfQS/UlnX6gfbPuTvaqx/bDoeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
@@ -4754,6 +4822,7 @@
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.41.1.tgz",
|
||||
"integrity": "sha512-cPmwD3FnFv8rKMBc1MxWCwVQFxwf1JEmSX3iQXrRVVG15zerAIXRjMFVWnd5Q5QvgKF7Aj+5ykXFhUl+QGnyOw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.7"
|
||||
},
|
||||
@@ -5447,6 +5516,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
|
||||
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@@ -5683,6 +5753,7 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.51.tgz",
|
||||
"integrity": "sha512-TQSnBldh+XSGL+opiSIq0575wvDPqu09AqWe1F7JhUMKY+M91/aGlK4MhpVNO7MgYfHcVCB1ffwAUTJzllKJqg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
"dev": "astro dev --host",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
"astro": "astro",
|
||||
"test:record": "playwright codegen http://localhost:3000",
|
||||
"test:e2e": "playwright test",
|
||||
"test:replay": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/preact": "^4.1.0",
|
||||
@@ -14,6 +17,8 @@
|
||||
"preact": "^10.26.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.3"
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@types/node": "^24.0.3",
|
||||
"playwright": "^1.56.1"
|
||||
}
|
||||
}
|
||||
|
||||
85
playwright-report/index.html
Normal file
85
playwright-report/index.html
Normal file
File diff suppressed because one or more lines are too long
64
playwright.config.ts
Normal file
64
playwright.config.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Playwright configuration for BSC Score browser automation
|
||||
* Configured to work with local dev server on port 3000
|
||||
*/
|
||||
export default defineConfig({
|
||||
// Test directory - where your recorded scripts live
|
||||
testDir: './tests/recordings',
|
||||
|
||||
// Match all .ts files in the recordings directory (not just .test.ts or .spec.ts)
|
||||
testMatch: '**/*.ts',
|
||||
|
||||
// Maximum time one test can run for
|
||||
timeout: 30 * 1000,
|
||||
|
||||
// Test execution settings
|
||||
fullyParallel: false, // Run tests sequentially to avoid conflicts
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1, // Run one at a time for recordings
|
||||
|
||||
// Reporter configuration
|
||||
reporter: 'html',
|
||||
|
||||
// Shared settings for all tests
|
||||
use: {
|
||||
// Base URL for tests
|
||||
baseURL: 'http://localhost:3000',
|
||||
|
||||
// Browser context options
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
|
||||
// Viewport size
|
||||
viewport: { width: 1280, height: 720 },
|
||||
},
|
||||
|
||||
// Configure projects for different browsers
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
// Uncomment to add more browsers
|
||||
// {
|
||||
// name: 'firefox',
|
||||
// use: { ...devices['Desktop Firefox'] },
|
||||
// },
|
||||
// {
|
||||
// name: 'webkit',
|
||||
// use: { ...devices['Desktop Safari'] },
|
||||
// },
|
||||
],
|
||||
|
||||
// Web server configuration - starts dev server automatically if needed
|
||||
// webServer: {
|
||||
// command: 'npm run dev',
|
||||
// url: 'http://localhost:3000',
|
||||
// reuseExistingServer: !process.env.CI,
|
||||
// timeout: 120 * 1000,
|
||||
// },
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
.winner-announcement {
|
||||
text-align: center;
|
||||
margin: 20px 0 0 0;
|
||||
padding: 24px 16px;
|
||||
padding: 32px 16px 24px 16px; /* extra top padding to keep icons inside */
|
||||
background: linear-gradient(135deg, #ff9800 0%, #ffa726 100%);
|
||||
border-radius: 16px;
|
||||
font-size: 1.2rem;
|
||||
@@ -35,13 +35,13 @@
|
||||
box-shadow: 0 8px 32px rgba(255, 152, 0, 0.3);
|
||||
animation: celebrationPulse 2s ease-in-out infinite;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
overflow: visible; /* avoid clipping decorative icons */
|
||||
}
|
||||
|
||||
.winner-announcement::before {
|
||||
content: '🎉';
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
top: 6px;
|
||||
left: 20px;
|
||||
font-size: 24px;
|
||||
animation: bounce 1s ease-in-out infinite;
|
||||
@@ -50,7 +50,7 @@
|
||||
.winner-announcement::after {
|
||||
content: '🏆';
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
top: 6px;
|
||||
right: 20px;
|
||||
font-size: 24px;
|
||||
animation: bounce 1s ease-in-out infinite 0.5s;
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
color: #222;
|
||||
}
|
||||
.score {
|
||||
font-size: 20vh;
|
||||
font-size: 40vh;
|
||||
font-weight: 900;
|
||||
margin: 20px 0 30px 0;
|
||||
line-height: 1;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import styles from './GameDetail.module.css';
|
||||
import Toast from './Toast';
|
||||
import type { Game, EndlosGame } from '../types/game';
|
||||
|
||||
interface GameDetailProps {
|
||||
@@ -18,23 +17,11 @@ interface GameDetailProps {
|
||||
* Game detail view for a single game.
|
||||
*/
|
||||
const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, onForfeit, onBack }: GameDetailProps) => {
|
||||
const [toast, setToast] = useState<{ show: boolean; message: string; type: 'success' | 'error' | 'info' }>({
|
||||
show: false,
|
||||
message: '',
|
||||
type: 'info'
|
||||
});
|
||||
|
||||
if (!game) return null;
|
||||
|
||||
const showToast = (message: string, type: 'success' | 'error' | 'info' = 'info') => {
|
||||
setToast({ show: true, message, type });
|
||||
};
|
||||
|
||||
const handleScoreUpdate = (playerIndex: number, change: number) => {
|
||||
onUpdateScore(playerIndex, change);
|
||||
const playerName = [game.player1, game.player2, game.player3][playerIndex - 1];
|
||||
const action = change > 0 ? 'Punkt hinzugefügt' : 'Punkt abgezogen';
|
||||
showToast(`${action} für ${playerName}`, 'success');
|
||||
// Silent update; toast notifications removed
|
||||
};
|
||||
|
||||
|
||||
@@ -58,7 +45,17 @@ const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, o
|
||||
className={styles['player-score'] + (name === 'Fränky' ? ' ' + styles['franky'] : '')}
|
||||
key={name + idx}
|
||||
>
|
||||
<span className={styles['player-name']}>{name}</span>
|
||||
<span className={styles['player-name']}>
|
||||
{name}
|
||||
{(() => {
|
||||
const order = (game as any).breakOrder as number[] | undefined;
|
||||
const breakerIdx = (game as any).currentBreakerIdx as number | undefined;
|
||||
if (order && typeof breakerIdx === 'number' && order[breakerIdx] === idx + 1) {
|
||||
return <span title="Break" aria-label="Break" style={{ display: 'inline-block', width: '1em', height: '1em', borderRadius: '50%', background: '#fff', marginLeft: 6, verticalAlign: 'middle' }} />;
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</span>
|
||||
<div className={styles['progress-bar']}>
|
||||
<div
|
||||
className={styles['progress-fill']}
|
||||
@@ -81,22 +78,7 @@ const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, o
|
||||
>
|
||||
{scores[idx]}
|
||||
</span>
|
||||
<div className={styles['score-buttons']}>
|
||||
<button
|
||||
className={styles['score-button']}
|
||||
disabled={isCompleted}
|
||||
onClick={() => handleScoreUpdate(idx+1, -1)}
|
||||
aria-label={`Punkt abziehen für ${name}`}
|
||||
title={`Punkt abziehen für ${name}`}
|
||||
>-</button>
|
||||
<button
|
||||
className={styles['score-button']}
|
||||
disabled={isCompleted}
|
||||
onClick={() => handleScoreUpdate(idx+1, 1)}
|
||||
aria-label={`Punkt hinzufügen für ${name}`}
|
||||
title={`Punkt hinzufügen für ${name}`}
|
||||
>+</button>
|
||||
</div>
|
||||
{/* +/- buttons removed per issue #29. Tap score to +1; use Undo to revert. */}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -108,7 +90,6 @@ const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, o
|
||||
className="btn btn--secondary"
|
||||
onClick={() => {
|
||||
onUndo();
|
||||
showToast('Letzte Aktion rückgängig gemacht', 'info');
|
||||
}}
|
||||
aria-label="Rückgängig"
|
||||
>
|
||||
@@ -117,12 +98,6 @@ const GameDetail = ({ game, onFinishGame, onUpdateScore, onUpdateGame, onUndo, o
|
||||
)}
|
||||
<button className="btn" disabled={isCompleted} onClick={onFinishGame} aria-label={isCompleted ? 'Abgeschlossen' : 'Spiel beenden'}>{isCompleted ? 'Abgeschlossen' : 'Spiel beenden'}</button>
|
||||
</div>
|
||||
<Toast
|
||||
show={toast.show}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => setToast({ show: false, message: '', type: 'info' })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
/* Game item with better symmetry and spacing */
|
||||
.game-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-lg);
|
||||
|
||||
@@ -82,7 +82,9 @@ export default function GameList({
|
||||
<Card
|
||||
key={game.id}
|
||||
variant="elevated"
|
||||
className={game.status === 'completed' ? styles['completed'] : styles['active']}
|
||||
className={
|
||||
styles['game-item'] + ' ' + (game.status === 'completed' ? styles['completed'] : styles['active'])
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={styles['game-info']}
|
||||
|
||||
@@ -293,6 +293,13 @@
|
||||
color: white;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
/* Match selected styling for quick pick buttons used in BreakRuleStep */
|
||||
.quick-pick-btn.selected {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.custom-race-to {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { h } from 'preact';
|
||||
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||
import styles from './NewGame.module.css';
|
||||
import modalStyles from './PlayerSelectModal.module.css';
|
||||
import { PlayerSelectModal } from './new-game/PlayerSelectModal';
|
||||
import {
|
||||
UI_CONSTANTS,
|
||||
WIZARD_STEPS,
|
||||
@@ -14,30 +15,16 @@ import {
|
||||
FORM_CONFIG,
|
||||
ERROR_STYLES
|
||||
} from '../utils/constants';
|
||||
import { Player1Step } from './new-game/Player1Step';
|
||||
import { Player2Step } from './new-game/Player2Step';
|
||||
import { Player3Step } from './new-game/Player3Step';
|
||||
import { GameTypeStep } from './new-game/GameTypeStep';
|
||||
import { RaceToStep } from './new-game/RaceToStep';
|
||||
import { BreakRuleStep } from './new-game/BreakRuleStep';
|
||||
import { BreakOrderStep } from './new-game/BreakOrderStep';
|
||||
import type { BreakRule } from '../types/game';
|
||||
|
||||
interface PlayerSelectModalProps {
|
||||
players: string[];
|
||||
onSelect: (player: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const PlayerSelectModal = ({ players, onSelect, onClose }: PlayerSelectModalProps) => (
|
||||
<div className={modalStyles.modalOverlay} onClick={onClose}>
|
||||
<div className={modalStyles.modalContent} onClick={e => e.stopPropagation()}>
|
||||
<div className={modalStyles.modalHeader}>
|
||||
<h3>Alle Spieler</h3>
|
||||
<button className={modalStyles.closeButton} onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className={modalStyles.playerList}>
|
||||
{players.map(player => (
|
||||
<button key={player} className={modalStyles.playerItem} onClick={() => onSelect(player)}>
|
||||
{player}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
// PlayerSelectModal moved to ./new-game/PlayerSelectModal
|
||||
|
||||
interface PlayerStepProps {
|
||||
playerNameHistory: string[];
|
||||
@@ -46,523 +33,11 @@ interface PlayerStepProps {
|
||||
initialValue?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Player 1 input step for multi-step game creation wizard.
|
||||
*/
|
||||
const Player1Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }: PlayerStepProps) => {
|
||||
const [player1, setPlayer1] = useState(initialValue);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filteredNames, setFilteredNames] = useState(playerNameHistory);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
// Player1Step moved to ./new-game/Player1Step
|
||||
|
||||
useEffect(() => {
|
||||
if (!player1) {
|
||||
setFilteredNames(playerNameHistory);
|
||||
} else {
|
||||
setFilteredNames(
|
||||
playerNameHistory.filter(name =>
|
||||
name.toLowerCase().includes(player1.toLowerCase())
|
||||
)
|
||||
);
|
||||
}
|
||||
}, [player1, playerNameHistory]);
|
||||
// Player2Step moved to ./new-game/Player2Step
|
||||
|
||||
const handleSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
const trimmedName = player1.trim();
|
||||
|
||||
if (!trimmedName) {
|
||||
setError(ERROR_MESSAGES.PLAYER1_REQUIRED);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.setAttribute('aria-invalid', 'true');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmedName.length > FORM_CONFIG.MAX_PLAYER_NAME_LENGTH) {
|
||||
setError(`Spielername darf maximal ${FORM_CONFIG.MAX_PLAYER_NAME_LENGTH} Zeichen lang sein`);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.setAttribute('aria-invalid', 'true');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.setAttribute('aria-invalid', 'false');
|
||||
}
|
||||
onNext(trimmedName);
|
||||
};
|
||||
|
||||
const handleQuickPick = (name: string) => {
|
||||
setError(null);
|
||||
onNext(name);
|
||||
};
|
||||
|
||||
const handleModalSelect = (name: string) => {
|
||||
setIsModalOpen(false);
|
||||
handleQuickPick(name);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setPlayer1('');
|
||||
setError(null);
|
||||
if (inputRef.current) inputRef.current.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Spieler 1 Eingabe" autoComplete="off">
|
||||
<div className={styles['screen-title']}>Neues Spiel – Schritt {WIZARD_STEPS.PLAYER1}/{UI_CONSTANTS.TOTAL_WIZARD_STEPS}</div>
|
||||
<div className={styles['progress-indicator']} style={{ marginBottom: UI_CONSTANTS.MARGIN_BOTTOM_MEDIUM }}>
|
||||
<span className={styles['progress-dot'] + ' ' + styles['active']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
</div>
|
||||
<div className={styles['player-input'] + ' ' + styles['player1-input']} style={{ marginBottom: UI_CONSTANTS.MARGIN_BOTTOM_LARGE, position: 'relative' }}>
|
||||
<label htmlFor="player1-input" style={{ fontSize: UI_CONSTANTS.LABEL_FONT_SIZE, fontWeight: 600 }}>Spieler 1</label>
|
||||
<div style={{ position: 'relative', width: '100%' }}>
|
||||
<input
|
||||
id="player1-input"
|
||||
className={styles['name-input']}
|
||||
placeholder="Name Spieler 1"
|
||||
value={player1}
|
||||
onInput={(e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const value = target.value;
|
||||
setPlayer1(value);
|
||||
|
||||
// Real-time validation feedback
|
||||
if (value.length > FORM_CONFIG.MAX_PLAYER_NAME_LENGTH) {
|
||||
setError(`Spielername darf maximal ${FORM_CONFIG.MAX_PLAYER_NAME_LENGTH} Zeichen lang sein`);
|
||||
target.setAttribute('aria-invalid', 'true');
|
||||
} else if (value.trim() && error) {
|
||||
setError(null);
|
||||
target.setAttribute('aria-invalid', 'false');
|
||||
}
|
||||
}}
|
||||
autoComplete="off"
|
||||
aria-label="Name Spieler 1"
|
||||
aria-describedby="player1-help"
|
||||
style={{
|
||||
fontSize: UI_CONSTANTS.INPUT_FONT_SIZE,
|
||||
minHeight: UI_CONSTANTS.INPUT_MIN_HEIGHT,
|
||||
marginTop: 12,
|
||||
marginBottom: 12,
|
||||
width: '100%',
|
||||
paddingRight: UI_CONSTANTS.INPUT_PADDING_RIGHT
|
||||
}}
|
||||
ref={inputRef}
|
||||
/>
|
||||
<div id="player1-help" className="sr-only">
|
||||
Geben Sie den Namen für Spieler 1 ein. Maximal {FORM_CONFIG.MAX_PLAYER_NAME_LENGTH} Zeichen erlaubt.
|
||||
</div>
|
||||
{player1.length > FORM_CONFIG.CHARACTER_COUNT_WARNING_THRESHOLD && (
|
||||
<div style={{
|
||||
fontSize: '0.875rem',
|
||||
color: player1.length > FORM_CONFIG.MAX_PLAYER_NAME_LENGTH ? '#f44336' : '#ff9800',
|
||||
marginTop: '4px',
|
||||
textAlign: 'right'
|
||||
}}>
|
||||
{player1.length}/{FORM_CONFIG.MAX_PLAYER_NAME_LENGTH} Zeichen
|
||||
</div>
|
||||
)}
|
||||
{player1 && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles['clear-input-btn']}
|
||||
aria-label="Feld leeren"
|
||||
onClick={handleClear}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: 24,
|
||||
color: '#aaa',
|
||||
padding: 0,
|
||||
zIndex: 2
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
{/* Unicode heavy multiplication X */}
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{filteredNames.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginTop: 8 }}>
|
||||
{filteredNames.slice(0, UI_CONSTANTS.MAX_QUICK_PICKS).map((name, idx) => (
|
||||
<button
|
||||
type="button"
|
||||
key={name + idx}
|
||||
className={styles['quick-pick-btn']}
|
||||
style={{
|
||||
fontSize: UI_CONSTANTS.QUICK_PICK_FONT_SIZE,
|
||||
padding: UI_CONSTANTS.QUICK_PICK_PADDING,
|
||||
borderRadius: 8,
|
||||
background: '#333',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={() => handleQuickPick(name)}
|
||||
aria-label={ARIA_LABELS.QUICK_PICK(name)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
{playerNameHistory.length > UI_CONSTANTS.MAX_QUICK_PICKS && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles['quick-pick-btn']}
|
||||
style={{
|
||||
fontSize: UI_CONSTANTS.QUICK_PICK_FONT_SIZE,
|
||||
padding: UI_CONSTANTS.QUICK_PICK_PADDING,
|
||||
borderRadius: 8,
|
||||
background: '#333',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
aria-label={ARIA_LABELS.SHOW_MORE_PLAYERS}
|
||||
>
|
||||
...
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<div
|
||||
className={styles['validation-error']}
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
...ERROR_STYLES.CONTAINER
|
||||
}}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span style={ERROR_STYLES.ICON}>⚠️</span>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{isModalOpen && (
|
||||
<PlayerSelectModal
|
||||
players={playerNameHistory}
|
||||
onSelect={handleModalSelect}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<div className={styles['arrow-nav']} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 48 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles['arrow-btn']}
|
||||
aria-label="Zurück"
|
||||
onClick={onCancel}
|
||||
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}
|
||||
>
|
||||
{/* Unicode left arrow */}
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles['arrow-btn']}
|
||||
aria-label="Weiter"
|
||||
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}
|
||||
>
|
||||
{/* Unicode right arrow */}
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Player 2 input step for multi-step game creation wizard.
|
||||
*/
|
||||
const Player2Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }: PlayerStepProps) => {
|
||||
const [player2, setPlayer2] = useState(initialValue);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filteredNames, setFilteredNames] = useState(playerNameHistory);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!player2) {
|
||||
setFilteredNames(playerNameHistory);
|
||||
} else {
|
||||
setFilteredNames(
|
||||
playerNameHistory.filter(name =>
|
||||
name.toLowerCase().includes(player2.toLowerCase())
|
||||
)
|
||||
);
|
||||
}
|
||||
}, [player2, playerNameHistory]);
|
||||
|
||||
const handleSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
if (!player2.trim()) {
|
||||
setError('Bitte Namen für Spieler 2 eingeben');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
onNext(player2.trim());
|
||||
};
|
||||
|
||||
const handleQuickPick = (name: string) => {
|
||||
setError(null);
|
||||
onNext(name);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setPlayer2('');
|
||||
setError(null);
|
||||
if (inputRef.current) inputRef.current.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Spieler 2 Eingabe" autoComplete="off">
|
||||
<div className={styles['screen-title']}>Neues Spiel – Schritt 2/5</div>
|
||||
<div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot'] + ' ' + styles['active']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
</div>
|
||||
<div className={styles['player-input'] + ' ' + styles['player2-input']} style={{ marginBottom: 32, position: 'relative' }}>
|
||||
<label htmlFor="player2-input" style={{ fontSize: '1.3rem', fontWeight: 600 }}>Spieler 2</label>
|
||||
<div style={{ position: 'relative', width: '100%' }}>
|
||||
<input
|
||||
id="player2-input"
|
||||
className={styles['name-input']}
|
||||
placeholder="Name Spieler 2"
|
||||
value={player2}
|
||||
onInput={(e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
setPlayer2(target.value);
|
||||
}}
|
||||
autoComplete="off"
|
||||
aria-label="Name Spieler 2"
|
||||
style={{ fontSize: '1.2rem', minHeight: 48, marginTop: 12, marginBottom: 12, width: '100%', paddingRight: 44 }}
|
||||
ref={inputRef}
|
||||
/>
|
||||
{player2 && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles['clear-input-btn']}
|
||||
aria-label="Feld leeren"
|
||||
onClick={handleClear}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: 24,
|
||||
color: '#aaa',
|
||||
padding: 0,
|
||||
zIndex: 2
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{filteredNames.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginTop: 8 }}>
|
||||
{filteredNames.slice(0, 10).map((name, idx) => (
|
||||
<button
|
||||
type="button"
|
||||
key={name + idx}
|
||||
className={styles['quick-pick-btn']}
|
||||
style={{ fontSize: '1.1rem', padding: '12px 20px', borderRadius: 8, background: '#333', color: '#fff', border: 'none', cursor: 'pointer' }}
|
||||
onClick={() => handleQuickPick(name)}
|
||||
aria-label={`Schnellauswahl: ${name}`}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && <div className={styles['validation-error']} style={{ marginBottom: 16 }}>{error}</div>}
|
||||
<div className={styles['arrow-nav']} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 48 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles['arrow-btn']}
|
||||
aria-label="Zurück"
|
||||
onClick={onCancel}
|
||||
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles['arrow-btn']}
|
||||
aria-label="Weiter"
|
||||
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Player 3 input step for multi-step game creation wizard.
|
||||
*/
|
||||
const Player3Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }: PlayerStepProps) => {
|
||||
const [player3, setPlayer3] = useState(initialValue);
|
||||
const [filteredNames, setFilteredNames] = useState(playerNameHistory);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!player3) {
|
||||
setFilteredNames(playerNameHistory);
|
||||
} else {
|
||||
setFilteredNames(
|
||||
playerNameHistory.filter(name =>
|
||||
name.toLowerCase().includes(player3.toLowerCase())
|
||||
)
|
||||
);
|
||||
}
|
||||
}, [player3, playerNameHistory]);
|
||||
|
||||
const handleSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
// Player 3 is optional, so always allow submission
|
||||
onNext(player3.trim());
|
||||
};
|
||||
|
||||
const handleQuickPick = (name: string) => {
|
||||
onNext(name);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setPlayer3('');
|
||||
if (inputRef.current) inputRef.current.focus();
|
||||
};
|
||||
|
||||
const handleSkip = (e: Event) => {
|
||||
e.preventDefault();
|
||||
onNext('');
|
||||
};
|
||||
|
||||
return (
|
||||
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Spieler 3 Eingabe" autoComplete="off">
|
||||
<div className={styles['screen-title']}>Neues Spiel – Schritt 3/5</div>
|
||||
<div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot'] + ' ' + styles['active']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
</div>
|
||||
<div className={styles['player-input'] + ' ' + styles['player3-input']} style={{ marginBottom: 32, position: 'relative' }}>
|
||||
<label htmlFor="player3-input" style={{ fontSize: '1.3rem', fontWeight: 600 }}>Spieler 3 (optional)</label>
|
||||
<div style={{ position: 'relative', width: '100%' }}>
|
||||
<input
|
||||
id="player3-input"
|
||||
className={styles['name-input']}
|
||||
placeholder="Name Spieler 3 (optional)"
|
||||
value={player3}
|
||||
onInput={(e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
setPlayer3(target.value);
|
||||
}}
|
||||
autoComplete="off"
|
||||
aria-label="Name Spieler 3"
|
||||
style={{ fontSize: '1.2rem', minHeight: 48, marginTop: 12, marginBottom: 12, width: '100%', paddingRight: 44 }}
|
||||
ref={inputRef}
|
||||
/>
|
||||
{player3 && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles['clear-input-btn']}
|
||||
aria-label="Feld leeren"
|
||||
onClick={handleClear}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: 24,
|
||||
color: '#aaa',
|
||||
padding: 0,
|
||||
zIndex: 2
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{filteredNames.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginTop: 8 }}>
|
||||
{filteredNames.slice(0, 10).map((name, idx) => (
|
||||
<button
|
||||
type="button"
|
||||
key={name + idx}
|
||||
className={styles['quick-pick-btn']}
|
||||
style={{ fontSize: '1.1rem', padding: '12px 20px', borderRadius: 8, background: '#333', color: '#fff', border: 'none', cursor: 'pointer' }}
|
||||
onClick={() => handleQuickPick(name)}
|
||||
aria-label={`Schnellauswahl: ${name}`}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles['arrow-nav']} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 48 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles['arrow-btn']}
|
||||
aria-label="Zurück"
|
||||
onClick={onCancel}
|
||||
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSkip}
|
||||
className={styles['quick-pick-btn']}
|
||||
style={{ fontSize: '1.1rem', padding: '12px 20px', borderRadius: 8, background: '#333', color: '#fff', border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
Überspringen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles['arrow-btn']}
|
||||
aria-label="Weiter"
|
||||
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
// Player3Step moved to ./new-game/Player3Step
|
||||
|
||||
interface GameTypeStepProps {
|
||||
onNext: (type: string) => void;
|
||||
@@ -570,82 +45,6 @@ interface GameTypeStepProps {
|
||||
initialValue?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Game Type selection step for multi-step game creation wizard.
|
||||
*/
|
||||
const GameTypeStep = ({ onNext, onCancel, initialValue = '' }: GameTypeStepProps) => {
|
||||
const [gameType, setGameType] = useState(initialValue);
|
||||
const gameTypes = ['8-Ball', '9-Ball', '10-Ball'];
|
||||
|
||||
const handleSelect = (selectedType: string) => {
|
||||
setGameType(selectedType);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
if (gameType) {
|
||||
onNext(gameType);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Spielart auswählen">
|
||||
<div className={styles['screen-title']}>Neues Spiel – Schritt 4/5</div>
|
||||
<div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot'] + ' ' + styles['active']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
</div>
|
||||
<div className={styles['game-type-selection']}>
|
||||
{gameTypes.map(type => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
className={`${styles['game-type-btn']} ${gameType === type ? styles.selected : ''}`}
|
||||
onClick={() => handleSelect(type)}
|
||||
>
|
||||
{type}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles['arrow-nav']} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 48 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles['arrow-btn']}
|
||||
aria-label="Zurück"
|
||||
onClick={onCancel}
|
||||
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}
|
||||
>
|
||||
{/* Unicode left arrow */}
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles['arrow-btn']}
|
||||
aria-label="Weiter"
|
||||
disabled={!gameType}
|
||||
style={{
|
||||
fontSize: 48,
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: '50%',
|
||||
background: '#222',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
cursor: 'pointer',
|
||||
opacity: !gameType ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{/* Unicode right arrow */}
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
interface RaceToStepProps {
|
||||
onNext: (raceTo: string | number) => void;
|
||||
@@ -654,104 +53,25 @@ interface RaceToStepProps {
|
||||
gameType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Race To selection step for multi-step game creation wizard.
|
||||
*/
|
||||
const RaceToStep = ({ onNext, onCancel, initialValue = '', gameType }: RaceToStepProps) => {
|
||||
const quickPicks = [1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
const defaultValue = 5;
|
||||
const [raceTo, setRaceTo] = useState<string | number>(initialValue !== '' ? initialValue : defaultValue);
|
||||
// GameTypeStep and RaceToStep moved to ./new-game
|
||||
|
||||
useEffect(() => {
|
||||
if ((initialValue === '' || initialValue === undefined) && raceTo !== defaultValue) {
|
||||
setRaceTo(defaultValue);
|
||||
}
|
||||
if (initialValue !== '' && initialValue !== undefined && initialValue !== raceTo) {
|
||||
setRaceTo(initialValue);
|
||||
}
|
||||
}, [gameType, initialValue, defaultValue]);
|
||||
interface BreakRuleStepProps {
|
||||
onNext: (rule: BreakRule) => void;
|
||||
onCancel: () => void;
|
||||
initialValue?: BreakRule | 'winnerbreak';
|
||||
}
|
||||
|
||||
const handleQuickPick = (value: number) => {
|
||||
// For endlos (endless) games, use Infinity to prevent automatic completion
|
||||
setRaceTo(value === 0 ? 'Infinity' : value);
|
||||
};
|
||||
// BreakRuleStep moved to ./new-game/BreakRuleStep
|
||||
|
||||
const handleInputChange = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
setRaceTo(target.value);
|
||||
};
|
||||
interface BreakOrderStepProps {
|
||||
players: string[];
|
||||
rule: BreakRule;
|
||||
onNext: (first: number, second?: number) => void;
|
||||
onCancel: () => void;
|
||||
initialFirst?: number;
|
||||
initialSecond?: number;
|
||||
}
|
||||
|
||||
const handleSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
// Handle Infinity for endlos games, otherwise parse as integer
|
||||
const raceToValue = raceTo === 'Infinity' ? Infinity : (parseInt(String(raceTo), 10) || 0);
|
||||
onNext(raceToValue);
|
||||
};
|
||||
// BreakOrderStep moved to ./new-game/BreakOrderStep
|
||||
|
||||
return (
|
||||
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Race To auswählen">
|
||||
<div className={styles['screen-title']}>Neues Spiel – Schritt 5/5</div>
|
||||
<div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot'] + ' ' + styles['active']} />
|
||||
</div>
|
||||
<div className={styles['endlos-container']}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles['race-to-btn']} ${styles['endlos-btn']} ${raceTo === 'Infinity' ? styles.selected : ''}`}
|
||||
onClick={() => handleQuickPick(0)}
|
||||
>
|
||||
Endlos
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles['race-to-selection']}>
|
||||
{quickPicks.map(value => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={`${styles['race-to-btn']} ${parseInt(raceTo, 10) === value ? styles.selected : ''}`}
|
||||
onClick={() => handleQuickPick(value)}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles['custom-race-to']}>
|
||||
<input
|
||||
type="number"
|
||||
pattern="[0-9]*"
|
||||
value={raceTo}
|
||||
onInput={handleInputChange}
|
||||
className={styles['name-input']}
|
||||
placeholder="manuelle Eingabe"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles['arrow-nav']} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 48 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles['arrow-btn']}
|
||||
aria-label="Zurück"
|
||||
onClick={onCancel}
|
||||
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}
|
||||
>
|
||||
{/* Unicode left arrow */}
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles['arrow-btn']}
|
||||
aria-label="Fertigstellen"
|
||||
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}
|
||||
>
|
||||
{/* Unicode checkmark */}
|
||||
✓
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export { Player1Step, Player2Step, Player3Step, GameTypeStep, RaceToStep };
|
||||
export { Player1Step, Player2Step, Player3Step, GameTypeStep, BreakRuleStep, BreakOrderStep, RaceToStep };
|
||||
@@ -1,134 +0,0 @@
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 10000;
|
||||
min-width: 300px;
|
||||
max-width: 400px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast.hide {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.toastContent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toastIcon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toastMessage {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.toastClose {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toastClose:hover {
|
||||
background: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Toast types */
|
||||
.toast.success {
|
||||
border-left: 4px solid #4caf50;
|
||||
}
|
||||
|
||||
.toast.success .toastIcon {
|
||||
background: #4caf50;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
border-left: 4px solid #f44336;
|
||||
}
|
||||
|
||||
.toast.error .toastIcon {
|
||||
background: #f44336;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.toast.info {
|
||||
border-left: 4px solid #2196f3;
|
||||
}
|
||||
|
||||
.toast.info .toastIcon {
|
||||
background: #2196f3;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Animation keyframes */
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideOutRight {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
animation: slideInRight 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.toast.hide {
|
||||
animation: slideOutRight 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import styles from './Toast.module.css';
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info';
|
||||
|
||||
interface ToastProps {
|
||||
show: boolean;
|
||||
message: string;
|
||||
type?: ToastType;
|
||||
onClose: () => void;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast notification component for user feedback
|
||||
*/
|
||||
const Toast = ({ show, message, type = 'info', onClose, duration = 3000 }: ToastProps) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
setIsVisible(true);
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
setTimeout(onClose, 300); // Wait for animation to complete
|
||||
}, duration);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [show, duration, onClose]);
|
||||
|
||||
if (!show && !isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className={`${styles.toast} ${styles[type]} ${isVisible ? styles.show : styles.hide}`}>
|
||||
<div className={styles.toastContent}>
|
||||
<div className={styles.toastIcon}>
|
||||
{type === 'success' && '✓'}
|
||||
{type === 'error' && '✕'}
|
||||
{type === 'info' && 'ℹ'}
|
||||
</div>
|
||||
<span className={styles.toastMessage}>{message}</span>
|
||||
<button
|
||||
className={styles.toastClose}
|
||||
onClick={() => {
|
||||
setIsVisible(false);
|
||||
setTimeout(onClose, 300);
|
||||
}}
|
||||
aria-label="Schließen"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toast;
|
||||
|
||||
113
src/components/new-game/BreakOrderStep.tsx
Normal file
113
src/components/new-game/BreakOrderStep.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { h } from 'preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import styles from '../NewGame.module.css';
|
||||
import type { BreakRule } from '../../types/game';
|
||||
|
||||
interface BreakOrderStepProps {
|
||||
players: string[];
|
||||
rule: BreakRule;
|
||||
onNext: (first: number, second?: number) => void;
|
||||
onCancel: () => void;
|
||||
initialFirst?: number;
|
||||
initialSecond?: number;
|
||||
}
|
||||
|
||||
export const BreakOrderStep = ({ players, rule, onNext, onCancel, initialFirst = 1, initialSecond }: BreakOrderStepProps) => {
|
||||
const playerCount = players.filter(Boolean).length;
|
||||
const [first, setFirst] = useState<number>(initialFirst);
|
||||
const [second, setSecond] = useState<number | undefined>(initialSecond);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialSecond && rule === 'wechselbreak' && playerCount === 3) {
|
||||
setSecond(2);
|
||||
}
|
||||
}, [initialSecond, rule, playerCount]);
|
||||
|
||||
const handleFirst = (idx: number) => {
|
||||
setFirst(idx);
|
||||
if (rule === 'winnerbreak' || (rule === 'wechselbreak' && playerCount === 2)) {
|
||||
onNext(idx);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSecond = (idx: number) => {
|
||||
setSecond(idx);
|
||||
onNext(first, idx);
|
||||
};
|
||||
|
||||
return (
|
||||
<form className={styles['new-game-form']} aria-label="Break-Reihenfolge wählen">
|
||||
<div className={styles['screen-title']}>Wer hat den ersten Anstoss?</div>
|
||||
<div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot'] + ' ' + styles['active']} />
|
||||
</div>
|
||||
<div style={{ marginBottom: 16, fontWeight: 600 }}>Wer hat den ersten Anstoss?</div>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
{players.filter(Boolean).map((name, idx) => (
|
||||
<button
|
||||
key={`first-${idx}`}
|
||||
type="button"
|
||||
className={`${styles['quick-pick-btn']} ${first === (idx + 1) ? styles['selected'] : ''}`}
|
||||
onClick={() => handleFirst(idx + 1)}
|
||||
aria-label={`Zuerst: ${name}`}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{rule === 'wechselbreak' && playerCount === 3 && (
|
||||
<>
|
||||
<div style={{ marginTop: 24, marginBottom: 16, fontWeight: 600 }}>Wer hat den zweiten Anstoss?</div>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
{players.filter(Boolean).map((name, idx) => (
|
||||
<button
|
||||
key={`second-${idx}`}
|
||||
type="button"
|
||||
className={`${styles['quick-pick-btn']} ${second === (idx + 1) ? styles['selected'] : ''}`}
|
||||
onClick={() => handleSecond(idx + 1)}
|
||||
aria-label={`Zweites Break: ${name}`}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className={styles['arrow-nav']} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 48 }}>
|
||||
<button type="button" className={styles['arrow-btn']} aria-label="Zurück" onClick={onCancel} style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles['arrow-btn']}
|
||||
aria-label="Weiter"
|
||||
onClick={() => {
|
||||
if (rule === 'wechselbreak' && playerCount === 3) {
|
||||
if (first > 0 && (second ?? 0) > 0) {
|
||||
handleSecond(second as number);
|
||||
}
|
||||
} else {
|
||||
if (first > 0) {
|
||||
onNext(first);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
(rule === 'wechselbreak' && playerCount === 3) ? !(first > 0 && (second ?? 0) > 0) : !(first > 0)
|
||||
}
|
||||
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer', opacity: ((rule === 'wechselbreak' && playerCount === 3) ? !(first > 0 && (second ?? 0) > 0) : !(first > 0)) ? 0.5 : 1 }}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
55
src/components/new-game/BreakRuleStep.tsx
Normal file
55
src/components/new-game/BreakRuleStep.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import styles from '../NewGame.module.css';
|
||||
import type { BreakRule } from '../../types/game';
|
||||
|
||||
interface BreakRuleStepProps {
|
||||
onNext: (rule: BreakRule) => void;
|
||||
onCancel: () => void;
|
||||
initialValue?: BreakRule;
|
||||
}
|
||||
|
||||
export const BreakRuleStep = ({ onNext, onCancel, initialValue = 'winnerbreak' }: BreakRuleStepProps) => {
|
||||
const [rule, setRule] = useState<BreakRule>(initialValue ?? 'winnerbreak');
|
||||
|
||||
return (
|
||||
<form className={styles['new-game-form']} aria-label="Break-Regel wählen">
|
||||
<div className={styles['screen-title']}>Break-Regel wählen</div>
|
||||
<div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot'] + ' ' + styles['active']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 12 }}>
|
||||
{[
|
||||
{ key: 'winnerbreak', label: 'Winnerbreak' },
|
||||
{ key: 'wechselbreak', label: 'Wechselbreak' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
className={`${styles['quick-pick-btn']} ${rule === (opt.key as BreakRule) ? styles['selected'] : ''}`}
|
||||
onClick={() => { setRule(opt.key as BreakRule); onNext(opt.key as BreakRule); }}
|
||||
aria-label={`Break-Regel wählen: ${opt.label}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles['arrow-nav']} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 48 }}>
|
||||
<button type="button" className={styles['arrow-btn']} aria-label="Zurück" onClick={onCancel} style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}>
|
||||
←
|
||||
</button>
|
||||
<button type="button" className={styles['arrow-btn']} aria-label="Weiter" onClick={() => onNext(rule)} style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
86
src/components/new-game/GameTypeStep.tsx
Normal file
86
src/components/new-game/GameTypeStep.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { h } from 'preact';
|
||||
import { useState } from 'preact/hooks';
|
||||
import styles from '../NewGame.module.css';
|
||||
|
||||
interface GameTypeStepProps {
|
||||
onNext: (type: string) => void;
|
||||
onCancel: () => void;
|
||||
initialValue?: string;
|
||||
}
|
||||
|
||||
export const GameTypeStep = ({ onNext, onCancel, initialValue = '' }: GameTypeStepProps) => {
|
||||
const [gameType, setGameType] = useState(initialValue);
|
||||
const gameTypes = ['8-Ball', '9-Ball', '10-Ball'];
|
||||
|
||||
const handleSelect = (selectedType: string) => {
|
||||
setGameType(selectedType);
|
||||
onNext(selectedType);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
if (gameType) {
|
||||
onNext(gameType);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Spielart auswählen">
|
||||
<div className={styles['screen-title']}>Spielart auswählen</div>
|
||||
<div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot'] + ' ' + styles['active']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
</div>
|
||||
<div className={styles['game-type-selection']}>
|
||||
{gameTypes.map(type => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
className={`${styles['game-type-btn']} ${gameType === type ? styles.selected : ''}`}
|
||||
onClick={() => handleSelect(type)}
|
||||
>
|
||||
{type}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles['arrow-nav']} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 48 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles['arrow-btn']}
|
||||
aria-label="Zurück"
|
||||
onClick={onCancel}
|
||||
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles['arrow-btn']}
|
||||
aria-label="Weiter"
|
||||
disabled={!gameType}
|
||||
style={{
|
||||
fontSize: 48,
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: '50%',
|
||||
background: '#222',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
cursor: 'pointer',
|
||||
opacity: !gameType ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
258
src/components/new-game/Player1Step.tsx
Normal file
258
src/components/new-game/Player1Step.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { h } from 'preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import styles from '../NewGame.module.css';
|
||||
import { UI_CONSTANTS, ERROR_MESSAGES, ARIA_LABELS, FORM_CONFIG, ERROR_STYLES } from '../../utils/constants';
|
||||
import { PlayerSelectModal } from './PlayerSelectModal';
|
||||
|
||||
interface PlayerStepProps {
|
||||
playerNameHistory: string[];
|
||||
onNext: (name: string) => void;
|
||||
onCancel: () => void;
|
||||
initialValue?: string;
|
||||
}
|
||||
|
||||
export const Player1Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }: PlayerStepProps) => {
|
||||
const [player1, setPlayer1] = useState(initialValue);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filteredNames, setFilteredNames] = useState(playerNameHistory);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = inputRef.current;
|
||||
if (el) {
|
||||
el.focus();
|
||||
const end = el.value.length;
|
||||
try {
|
||||
el.setSelectionRange(end, end);
|
||||
} catch {}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!player1) {
|
||||
setFilteredNames(playerNameHistory);
|
||||
} else {
|
||||
setFilteredNames(
|
||||
playerNameHistory.filter(name =>
|
||||
name.toLowerCase().includes(player1.toLowerCase())
|
||||
)
|
||||
);
|
||||
}
|
||||
}, [player1, playerNameHistory]);
|
||||
|
||||
const handleSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
const trimmedName = player1.trim();
|
||||
if (!trimmedName) {
|
||||
setError(ERROR_MESSAGES.PLAYER1_REQUIRED);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.setAttribute('aria-invalid', 'true');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (trimmedName.length > FORM_CONFIG.MAX_PLAYER_NAME_LENGTH) {
|
||||
setError(`Spielername darf maximal ${FORM_CONFIG.MAX_PLAYER_NAME_LENGTH} Zeichen lang sein`);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.setAttribute('aria-invalid', 'true');
|
||||
}
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.setAttribute('aria-invalid', 'false');
|
||||
}
|
||||
onNext(trimmedName);
|
||||
};
|
||||
|
||||
const handleQuickPick = (name: string) => {
|
||||
setError(null);
|
||||
onNext(name);
|
||||
};
|
||||
|
||||
const handleModalSelect = (name: string) => {
|
||||
setIsModalOpen(false);
|
||||
handleQuickPick(name);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setPlayer1('');
|
||||
setError(null);
|
||||
if (inputRef.current) inputRef.current.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Spieler 1 Eingabe" autoComplete="off">
|
||||
<div className={styles['screen-title']}>Name Spieler 1</div>
|
||||
<div className={styles['progress-indicator']} style={{ marginBottom: UI_CONSTANTS.MARGIN_BOTTOM_MEDIUM }}>
|
||||
<span className={styles['progress-dot'] + ' ' + styles['active']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
</div>
|
||||
<div className={styles['player-input'] + ' ' + styles['player1-input']} style={{ marginBottom: UI_CONSTANTS.MARGIN_BOTTOM_LARGE, position: 'relative' }}>
|
||||
<label htmlFor="player1-input" style={{ fontSize: UI_CONSTANTS.LABEL_FONT_SIZE, fontWeight: 600 }}>Spieler 1</label>
|
||||
<div style={{ position: 'relative', width: '100%' }}>
|
||||
<input
|
||||
id="player1-input"
|
||||
className={styles['name-input']}
|
||||
placeholder="Name Spieler 1"
|
||||
value={player1}
|
||||
onInput={(e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const value = target.value;
|
||||
setPlayer1(value);
|
||||
if (value.length > FORM_CONFIG.MAX_PLAYER_NAME_LENGTH) {
|
||||
setError(`Spielername darf maximal ${FORM_CONFIG.MAX_PLAYER_NAME_LENGTH} Zeichen lang sein`);
|
||||
target.setAttribute('aria-invalid', 'true');
|
||||
} else if (value.trim() && error) {
|
||||
setError(null);
|
||||
target.setAttribute('aria-invalid', 'false');
|
||||
}
|
||||
}}
|
||||
autoComplete="off"
|
||||
aria-label="Name Spieler 1"
|
||||
aria-describedby="player1-help"
|
||||
style={{
|
||||
fontSize: UI_CONSTANTS.INPUT_FONT_SIZE,
|
||||
minHeight: UI_CONSTANTS.INPUT_MIN_HEIGHT,
|
||||
marginTop: 12,
|
||||
marginBottom: 12,
|
||||
width: '100%',
|
||||
paddingRight: UI_CONSTANTS.INPUT_PADDING_RIGHT
|
||||
}}
|
||||
ref={inputRef}
|
||||
/>
|
||||
<div id="player1-help" className="sr-only">
|
||||
Geben Sie den Namen für Spieler 1 ein. Maximal {FORM_CONFIG.MAX_PLAYER_NAME_LENGTH} Zeichen erlaubt.
|
||||
</div>
|
||||
{player1.length > FORM_CONFIG.CHARACTER_COUNT_WARNING_THRESHOLD && (
|
||||
<div style={{
|
||||
fontSize: '0.875rem',
|
||||
color: player1.length > FORM_CONFIG.MAX_PLAYER_NAME_LENGTH ? '#f44336' : '#ff9800',
|
||||
marginTop: '4px',
|
||||
textAlign: 'right'
|
||||
}}>
|
||||
{player1.length}/{FORM_CONFIG.MAX_PLAYER_NAME_LENGTH} Zeichen
|
||||
</div>
|
||||
)}
|
||||
{player1 && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles['clear-input-btn']}
|
||||
aria-label="Feld leeren"
|
||||
onClick={handleClear}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: 24,
|
||||
color: '#aaa',
|
||||
padding: 0,
|
||||
zIndex: 2
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{filteredNames.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginTop: 8 }}>
|
||||
{filteredNames.slice(0, UI_CONSTANTS.MAX_QUICK_PICKS).map((name, idx) => (
|
||||
<button
|
||||
type="button"
|
||||
key={name + idx}
|
||||
className={styles['quick-pick-btn']}
|
||||
style={{
|
||||
fontSize: UI_CONSTANTS.QUICK_PICK_FONT_SIZE,
|
||||
padding: UI_CONSTANTS.QUICK_PICK_PADDING,
|
||||
borderRadius: 8,
|
||||
background: '#333',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={() => handleQuickPick(name)}
|
||||
aria-label={ARIA_LABELS.QUICK_PICK(name)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
{playerNameHistory.length > UI_CONSTANTS.MAX_QUICK_PICKS && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles['quick-pick-btn']}
|
||||
style={{
|
||||
fontSize: UI_CONSTANTS.QUICK_PICK_FONT_SIZE,
|
||||
padding: UI_CONSTANTS.QUICK_PICK_PADDING,
|
||||
borderRadius: 8,
|
||||
background: '#333',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
aria-label={ARIA_LABELS.SHOW_MORE_PLAYERS}
|
||||
>
|
||||
...
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<div
|
||||
className={styles['validation-error']}
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
...ERROR_STYLES.CONTAINER
|
||||
}}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span style={ERROR_STYLES.ICON}>⚠️</span>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{isModalOpen && (
|
||||
<PlayerSelectModal
|
||||
players={playerNameHistory}
|
||||
onSelect={handleModalSelect}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<div className={styles['arrow-nav']} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 48 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles['arrow-btn']}
|
||||
aria-label="Zurück"
|
||||
onClick={onCancel}
|
||||
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles['arrow-btn']}
|
||||
aria-label="Weiter"
|
||||
disabled={!player1.trim()}
|
||||
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer', opacity: !player1.trim() ? 0.5 : 1 }}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
158
src/components/new-game/Player2Step.tsx
Normal file
158
src/components/new-game/Player2Step.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { h } from 'preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import styles from '../NewGame.module.css';
|
||||
|
||||
interface PlayerStepProps {
|
||||
playerNameHistory: string[];
|
||||
onNext: (name: string) => void;
|
||||
onCancel: () => void;
|
||||
initialValue?: string;
|
||||
}
|
||||
|
||||
export const Player2Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }: PlayerStepProps) => {
|
||||
const [player2, setPlayer2] = useState(initialValue);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filteredNames, setFilteredNames] = useState(playerNameHistory);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = inputRef.current;
|
||||
if (el) {
|
||||
el.focus();
|
||||
const end = el.value.length;
|
||||
try {
|
||||
el.setSelectionRange(end, end);
|
||||
} catch {}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!player2) {
|
||||
setFilteredNames(playerNameHistory);
|
||||
} else {
|
||||
setFilteredNames(
|
||||
playerNameHistory.filter(name =>
|
||||
name.toLowerCase().includes(player2.toLowerCase())
|
||||
)
|
||||
);
|
||||
}
|
||||
}, [player2, playerNameHistory]);
|
||||
|
||||
const handleSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
if (!player2.trim()) {
|
||||
setError('Bitte Namen für Spieler 2 eingeben');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
onNext(player2.trim());
|
||||
};
|
||||
|
||||
const handleQuickPick = (name: string) => {
|
||||
setError(null);
|
||||
onNext(name);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setPlayer2('');
|
||||
setError(null);
|
||||
if (inputRef.current) inputRef.current.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Spieler 2 Eingabe" autoComplete="off">
|
||||
<div className={styles['screen-title']}>Name Spieler 2</div>
|
||||
<div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot'] + ' ' + styles['active']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
</div>
|
||||
<div className={styles['player-input'] + ' ' + styles['player2-input']} style={{ marginBottom: 32, position: 'relative' }}>
|
||||
<label htmlFor="player2-input" style={{ fontSize: '1.3rem', fontWeight: 600 }}>Spieler 2</label>
|
||||
<div style={{ position: 'relative', width: '100%' }}>
|
||||
<input
|
||||
id="player2-input"
|
||||
className={styles['name-input']}
|
||||
placeholder="Name Spieler 2"
|
||||
value={player2}
|
||||
onInput={(e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
setPlayer2(target.value);
|
||||
}}
|
||||
autoComplete="off"
|
||||
aria-label="Name Spieler 2"
|
||||
style={{ fontSize: '1.2rem', minHeight: 48, marginTop: 12, marginBottom: 12, width: '100%', paddingRight: 44 }}
|
||||
ref={inputRef}
|
||||
/>
|
||||
{player2 && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles['clear-input-btn']}
|
||||
aria-label="Feld leeren"
|
||||
onClick={handleClear}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: 24,
|
||||
color: '#aaa',
|
||||
padding: 0,
|
||||
zIndex: 2
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{filteredNames.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginTop: 8 }}>
|
||||
{filteredNames.slice(0, 10).map((name, idx) => (
|
||||
<button
|
||||
type="button"
|
||||
key={name + idx}
|
||||
className={styles['quick-pick-btn']}
|
||||
style={{ fontSize: '1.1rem', padding: '12px 20px', borderRadius: 8, background: '#333', color: '#fff', border: 'none', cursor: 'pointer' }}
|
||||
onClick={() => handleQuickPick(name)}
|
||||
aria-label={`Schnellauswahl: ${name}`}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && <div className={styles['validation-error']} style={{ marginBottom: 16 }}>{error}</div>}
|
||||
<div className={styles['arrow-nav']} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 48 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles['arrow-btn']}
|
||||
aria-label="Zurück"
|
||||
onClick={onCancel}
|
||||
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles['arrow-btn']}
|
||||
aria-label="Weiter"
|
||||
disabled={!player2.trim()}
|
||||
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer', opacity: !player2.trim() ? 0.5 : 1 }}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
164
src/components/new-game/Player3Step.tsx
Normal file
164
src/components/new-game/Player3Step.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { h } from 'preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import styles from '../NewGame.module.css';
|
||||
|
||||
interface PlayerStepProps {
|
||||
playerNameHistory: string[];
|
||||
onNext: (name: string) => void;
|
||||
onCancel: () => void;
|
||||
initialValue?: string;
|
||||
}
|
||||
|
||||
export const Player3Step = ({ playerNameHistory, onNext, onCancel, initialValue = '' }: PlayerStepProps) => {
|
||||
const [player3, setPlayer3] = useState(initialValue);
|
||||
const [filteredNames, setFilteredNames] = useState(playerNameHistory);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = inputRef.current;
|
||||
if (el) {
|
||||
el.focus();
|
||||
const end = el.value.length;
|
||||
try {
|
||||
el.setSelectionRange(end, end);
|
||||
} catch {}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!player3) {
|
||||
setFilteredNames(playerNameHistory);
|
||||
} else {
|
||||
setFilteredNames(
|
||||
playerNameHistory.filter(name =>
|
||||
name.toLowerCase().includes(player3.toLowerCase())
|
||||
)
|
||||
);
|
||||
}
|
||||
}, [player3, playerNameHistory]);
|
||||
|
||||
const handleSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
onNext(player3.trim());
|
||||
};
|
||||
|
||||
const handleQuickPick = (name: string) => {
|
||||
onNext(name);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setPlayer3('');
|
||||
if (inputRef.current) inputRef.current.focus();
|
||||
};
|
||||
|
||||
const handleSkip = (e: Event) => {
|
||||
e.preventDefault();
|
||||
onNext('');
|
||||
};
|
||||
|
||||
return (
|
||||
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Spieler 3 Eingabe" autoComplete="off">
|
||||
<div className={styles['screen-title']}>Name Spieler 3 (optional)</div>
|
||||
<div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot'] + ' ' + styles['active']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
</div>
|
||||
<div className={styles['player-input'] + ' ' + styles['player3-input']} style={{ marginBottom: 32, position: 'relative' }}>
|
||||
<label htmlFor="player3-input" style={{ fontSize: '1.3rem', fontWeight: 600 }}>Spieler 3 (optional)</label>
|
||||
<div style={{ position: 'relative', width: '100%' }}>
|
||||
<input
|
||||
id="player3-input"
|
||||
className={styles['name-input']}
|
||||
placeholder="Name Spieler 3 (optional)"
|
||||
value={player3}
|
||||
onInput={(e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
setPlayer3(target.value);
|
||||
}}
|
||||
autoComplete="off"
|
||||
aria-label="Name Spieler 3"
|
||||
style={{ fontSize: '1.2rem', minHeight: 48, marginTop: 12, marginBottom: 12, width: '100%', paddingRight: 44 }}
|
||||
ref={inputRef}
|
||||
/>
|
||||
{player3 && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles['clear-input-btn']}
|
||||
aria-label="Feld leeren"
|
||||
onClick={handleClear}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: 24,
|
||||
color: '#aaa',
|
||||
padding: 0,
|
||||
zIndex: 2
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{filteredNames.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginTop: 8 }}>
|
||||
{filteredNames.slice(0, 10).map((name, idx) => (
|
||||
<button
|
||||
type="button"
|
||||
key={name + idx}
|
||||
className={styles['quick-pick-btn']}
|
||||
style={{ fontSize: '1.1rem', padding: '12px 20px', borderRadius: 8, background: '#333', color: '#fff', border: 'none', cursor: 'pointer' }}
|
||||
onClick={() => handleQuickPick(name)}
|
||||
aria-label={`Schnellauswahl: ${name}`}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles['arrow-nav']} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 48 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles['arrow-btn']}
|
||||
aria-label="Zurück"
|
||||
onClick={onCancel}
|
||||
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSkip}
|
||||
className={styles['quick-pick-btn']}
|
||||
style={{ fontSize: '1.1rem', padding: '12px 20px', borderRadius: 8, background: '#333', color: '#fff', border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
Überspringen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles['arrow-btn']}
|
||||
aria-label="Weiter"
|
||||
disabled={!player3.trim()}
|
||||
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer', opacity: !player3.trim() ? 0.5 : 1 }}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
28
src/components/new-game/PlayerSelectModal.tsx
Normal file
28
src/components/new-game/PlayerSelectModal.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { h } from 'preact';
|
||||
import modalStyles from '../PlayerSelectModal.module.css';
|
||||
|
||||
interface PlayerSelectModalProps {
|
||||
players: string[];
|
||||
onSelect: (player: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const PlayerSelectModal = ({ players, onSelect, onClose }: PlayerSelectModalProps) => (
|
||||
<div className={modalStyles.modalOverlay} onClick={onClose}>
|
||||
<div className={modalStyles.modalContent} onClick={e => e.stopPropagation()}>
|
||||
<div className={modalStyles.modalHeader}>
|
||||
<h3>Alle Spieler</h3>
|
||||
<button className={modalStyles.closeButton} onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className={modalStyles.playerList}>
|
||||
{players.map(player => (
|
||||
<button key={player} className={modalStyles.playerItem} onClick={() => onSelect(player)}>
|
||||
{player}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
111
src/components/new-game/RaceToStep.tsx
Normal file
111
src/components/new-game/RaceToStep.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { h } from 'preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import styles from '../NewGame.module.css';
|
||||
|
||||
interface RaceToStepProps {
|
||||
onNext: (raceTo: string | number) => void;
|
||||
onCancel: () => void;
|
||||
initialValue?: string | number;
|
||||
gameType?: string;
|
||||
}
|
||||
|
||||
export const RaceToStep = ({ onNext, onCancel, initialValue = '', gameType }: RaceToStepProps) => {
|
||||
const quickPicks = [1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
const defaultValue = 5;
|
||||
const [raceTo, setRaceTo] = useState<string | number>(initialValue !== '' ? initialValue : defaultValue);
|
||||
|
||||
useEffect(() => {
|
||||
if ((initialValue === '' || initialValue === undefined) && raceTo !== defaultValue) {
|
||||
setRaceTo(defaultValue);
|
||||
}
|
||||
if (initialValue !== '' && initialValue !== undefined && initialValue !== raceTo) {
|
||||
setRaceTo(initialValue);
|
||||
}
|
||||
}, [gameType, initialValue, defaultValue]);
|
||||
|
||||
const handleQuickPick = (value: number) => {
|
||||
const selected = value === 0 ? 'Infinity' : value;
|
||||
setRaceTo(selected);
|
||||
const raceToValue = selected === 'Infinity' ? Infinity : (parseInt(String(selected), 10) || 0);
|
||||
onNext(raceToValue);
|
||||
};
|
||||
|
||||
const handleInputChange = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
setRaceTo(target.value);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
const raceToValue = raceTo === 'Infinity' ? Infinity : (parseInt(String(raceTo), 10) || 0);
|
||||
onNext(raceToValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<form className={styles['new-game-form']} onSubmit={handleSubmit} aria-label="Race To auswählen">
|
||||
<div className={styles['screen-title']}>Race To auswählen</div>
|
||||
<div className={styles['progress-indicator']} style={{ marginBottom: 24 }}>
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot'] + ' ' + styles['active']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
<span className={styles['progress-dot']} />
|
||||
</div>
|
||||
<div className={styles['endlos-container']}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles['race-to-btn']} ${styles['endlos-btn']} ${raceTo === 'Infinity' ? styles.selected : ''}`}
|
||||
onClick={() => handleQuickPick(0)}
|
||||
>
|
||||
Endlos
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles['race-to-selection']}>
|
||||
{quickPicks.map(value => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={`${styles['race-to-btn']} ${parseInt(String(raceTo), 10) === value ? styles.selected : ''}`}
|
||||
onClick={() => handleQuickPick(value)}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles['custom-race-to']}>
|
||||
<input
|
||||
type="number"
|
||||
pattern="[0-9]*"
|
||||
value={raceTo}
|
||||
onInput={handleInputChange}
|
||||
className={styles['name-input']}
|
||||
placeholder="manuelle Eingabe"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles['arrow-nav']} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 48 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles['arrow-btn']}
|
||||
aria-label="Zurück"
|
||||
onClick={onCancel}
|
||||
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer' }}
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles['arrow-btn']}
|
||||
aria-label="Weiter"
|
||||
disabled={String(raceTo).trim() === ''}
|
||||
style={{ fontSize: 48, width: 80, height: 80, borderRadius: '50%', background: '#222', color: '#fff', border: 'none', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', cursor: 'pointer', opacity: String(raceTo).trim() === '' ? 0.5 : 1 }}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { h } from 'preact';
|
||||
import { Screen } from '../ui/Layout';
|
||||
import { Player1Step, Player2Step, Player3Step, GameTypeStep, RaceToStep } from '../NewGame';
|
||||
import { Player1Step, Player2Step, Player3Step, GameTypeStep, BreakRuleStep, BreakOrderStep, RaceToStep } from '../NewGame';
|
||||
import type { NewGameStep, NewGameData } from '../../types/game';
|
||||
|
||||
interface NewGameScreenProps {
|
||||
@@ -47,9 +47,29 @@ export default function NewGameScreen({
|
||||
onStepChange('raceTo');
|
||||
};
|
||||
|
||||
const handleRaceToNext = (raceTo: string) => {
|
||||
const finalData = { ...data, raceTo };
|
||||
onCreateGame(finalData);
|
||||
const handleBreakRuleNext = (rule: 'winnerbreak' | 'wechselbreak') => {
|
||||
onDataChange({ breakRule: rule });
|
||||
try { localStorage.setItem('lastBreakRule', rule); } catch {}
|
||||
onStepChange('breakOrder');
|
||||
};
|
||||
|
||||
const handleBreakOrderNext = (first: number, second?: number) => {
|
||||
const finalData = { ...data, breakFirst: first, breakSecond: second ?? '' } as any;
|
||||
onDataChange({ breakFirst: first, breakSecond: second ?? '' });
|
||||
try {
|
||||
localStorage.setItem('lastBreakFirst', String(first));
|
||||
if (second) localStorage.setItem('lastBreakSecond', String(second));
|
||||
} catch {}
|
||||
onCreateGame(finalData as any);
|
||||
};
|
||||
|
||||
const handleRaceToNext = (raceTo: string | number) => {
|
||||
// Convert to string, handling Infinity case explicitly
|
||||
const raceToStr = raceTo === Infinity ? 'Infinity' : String(raceTo);
|
||||
const finalData = { ...data, raceTo: raceToStr };
|
||||
// After race to, go to break rule selection
|
||||
onDataChange({ raceTo: raceToStr });
|
||||
onStepChange('breakRule');
|
||||
};
|
||||
|
||||
const handleStepBack = () => {
|
||||
@@ -66,6 +86,12 @@ export default function NewGameScreen({
|
||||
case 'raceTo':
|
||||
onStepChange('gameType');
|
||||
break;
|
||||
case 'breakRule':
|
||||
onStepChange('raceTo');
|
||||
break;
|
||||
case 'breakOrder':
|
||||
onStepChange('breakRule');
|
||||
break;
|
||||
default:
|
||||
onCancel();
|
||||
}
|
||||
@@ -107,6 +133,8 @@ export default function NewGameScreen({
|
||||
initialValue={data.gameType}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{step === 'raceTo' && (
|
||||
<RaceToStep
|
||||
@@ -116,6 +144,25 @@ export default function NewGameScreen({
|
||||
gameType={data.gameType}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'breakRule' && (
|
||||
<BreakRuleStep
|
||||
onNext={handleBreakRuleNext}
|
||||
onCancel={handleStepBack}
|
||||
initialValue={(data.breakRule as any) || (typeof window !== 'undefined' ? (localStorage.getItem('lastBreakRule') as any) : 'winnerbreak') || 'winnerbreak'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'breakOrder' && (
|
||||
<BreakOrderStep
|
||||
players={[data.player1, data.player2, data.player3]}
|
||||
rule={(data.breakRule as any) || 'winnerbreak'}
|
||||
onNext={handleBreakOrderNext}
|
||||
onCancel={handleStepBack}
|
||||
initialFirst={(typeof window !== 'undefined' && localStorage.getItem('lastBreakFirst')) ? parseInt(localStorage.getItem('lastBreakFirst')!, 10) : 1}
|
||||
initialSecond={(typeof window !== 'undefined' && localStorage.getItem('lastBreakSecond')) ? parseInt(localStorage.getItem('lastBreakSecond')!, 10) : undefined}
|
||||
/>
|
||||
)}
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Game, GameType, StandardGame, EndlosGame, NewGameData } from '../types/game';
|
||||
import type { Game, GameType, StandardGame, EndlosGame, NewGameData, BreakRule } from '../types/game';
|
||||
import { IndexedDBService } from './indexedDBService';
|
||||
|
||||
const LOCAL_STORAGE_KEY = 'bscscore_games';
|
||||
@@ -141,9 +141,15 @@ export class GameService {
|
||||
throw new Error('Game type is required');
|
||||
}
|
||||
|
||||
const raceTo = parseInt(gameData.raceTo, 10);
|
||||
if (isNaN(raceTo) || raceTo <= 0) {
|
||||
throw new Error('Invalid race to value');
|
||||
// Handle "endlos" (Infinity) case - raceTo is stored as string but can be "Infinity"
|
||||
let raceTo: number;
|
||||
if (gameData.raceTo === 'Infinity' || gameData.raceTo === 'endlos' || String(gameData.raceTo).toLowerCase() === 'infinity') {
|
||||
raceTo = Infinity;
|
||||
} else {
|
||||
raceTo = parseInt(gameData.raceTo, 10);
|
||||
if (isNaN(raceTo) || raceTo <= 0) {
|
||||
throw new Error('Invalid race to value');
|
||||
}
|
||||
}
|
||||
|
||||
const baseGame = {
|
||||
@@ -163,6 +169,25 @@ export class GameService {
|
||||
player2: gameData.player2,
|
||||
score1: 0,
|
||||
score2: 0,
|
||||
breakRule: (gameData.breakRule as BreakRule) || 'winnerbreak',
|
||||
breakOrder: (() => {
|
||||
// Determine break order from inputs, fallback defaults
|
||||
const players: number[] = [1, 2];
|
||||
if (gameData.player3?.trim()) players.push(3);
|
||||
const first = (typeof gameData.breakFirst === 'number' ? gameData.breakFirst : 1) as number;
|
||||
const second = (typeof gameData.breakSecond === 'number' ? gameData.breakSecond : (players.includes(2) && first !== 2 ? 2 : (players.includes(3) ? 3 : 2))) as number;
|
||||
const order = [first];
|
||||
if (players.length === 2) {
|
||||
order.push(first === 1 ? 2 : 1);
|
||||
} else {
|
||||
// 3 players: add chosen second, then the remaining third
|
||||
order.push(second);
|
||||
const third = [1,2,3].find(p => p !== first && p !== second)!;
|
||||
order.push(third);
|
||||
}
|
||||
return order;
|
||||
})(),
|
||||
currentBreakerIdx: 0,
|
||||
};
|
||||
|
||||
if (gameData.player3) {
|
||||
@@ -185,6 +210,23 @@ export class GameService {
|
||||
updated.score3 = Math.max(0, updated.score3 + change);
|
||||
}
|
||||
|
||||
// Breaker logic
|
||||
if (change > 0) {
|
||||
if ((updated.breakRule || 'winnerbreak') === 'winnerbreak') {
|
||||
// Winner keeps break: set breaker to this player
|
||||
const order = updated.breakOrder || [1,2].concat(updated.score3 !== undefined ? [3] : []);
|
||||
updated.breakOrder = order;
|
||||
const idx = order.findIndex(p => p === player);
|
||||
updated.currentBreakerIdx = idx >= 0 ? idx : 0;
|
||||
} else {
|
||||
// Wechselbreak: rotate to next
|
||||
const order = updated.breakOrder || [1,2].concat(updated.score3 !== undefined ? [3] : []);
|
||||
updated.breakOrder = order;
|
||||
const curr = typeof updated.currentBreakerIdx === 'number' ? updated.currentBreakerIdx : 0;
|
||||
updated.currentBreakerIdx = (curr + 1) % order.length;
|
||||
}
|
||||
}
|
||||
|
||||
updated.updatedAt = new Date().toISOString();
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -161,7 +161,6 @@ input:focus, select:focus {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--color-primary-hover);
|
||||
@@ -267,11 +266,6 @@ input:focus, select:focus {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Focus styles for better accessibility */
|
||||
*:focus {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Skip link for keyboard navigation */
|
||||
.skip-link {
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface BaseGame {
|
||||
lastModified?: number;
|
||||
}
|
||||
|
||||
export type BreakRule = 'winnerbreak' | 'wechselbreak';
|
||||
|
||||
export interface StandardGame extends BaseGame {
|
||||
player1: string;
|
||||
player2: string;
|
||||
@@ -40,6 +42,10 @@ export interface StandardGame extends BaseGame {
|
||||
score1: number;
|
||||
score2: number;
|
||||
score3?: number;
|
||||
// Break management
|
||||
breakRule?: BreakRule; // default winnerbreak for backfill
|
||||
breakOrder?: number[]; // 1-based player indices, e.g. [1,2] or [1,2,3]
|
||||
currentBreakerIdx?: number; // index into breakOrder
|
||||
}
|
||||
|
||||
export interface EndlosGame extends BaseGame {
|
||||
@@ -58,8 +64,11 @@ export interface NewGameData {
|
||||
player3: string;
|
||||
gameType: GameType | '';
|
||||
raceTo: string;
|
||||
breakRule?: BreakRule | '';
|
||||
breakFirst?: number | '';
|
||||
breakSecond?: number | '';
|
||||
}
|
||||
|
||||
export type NewGameStep = 'player1' | 'player2' | 'player3' | 'gameType' | 'raceTo' | null;
|
||||
export type NewGameStep = 'player1' | 'player2' | 'player3' | 'gameType' | 'breakRule' | 'breakOrder' | 'raceTo' | null;
|
||||
|
||||
export type GameFilter = 'all' | 'active' | 'completed';
|
||||
4
test-results/.last-run.json
Normal file
4
test-results/.last-run.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
26
tests/recordings/2-10-ball-endlos-wechsel.ts
Normal file
26
tests/recordings/2-10-ball-endlos-wechsel.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('test', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/');
|
||||
await page.getByRole('button', { name: 'Neues Spiel starten' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Kim');
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Leo');
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
await page.getByRole('button', { name: 'Überspringen' }).click();
|
||||
await page.getByRole('button', { name: '10-Ball' }).click();
|
||||
await page.getByRole('button', { name: 'Endlos' }).click();
|
||||
await page.getByRole('button', { name: 'Break-Regel wählen: Wechselbreak' }).click();
|
||||
await page.getByRole('button', { name: 'Zuerst: Leo' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Leo' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Kim' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Leo' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Kim' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Leo' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Kim' }).click();
|
||||
await page.getByRole('button', { name: 'Spiel beenden' }).click();
|
||||
await page.getByRole('button', { name: 'Bestätigen' }).click();
|
||||
});
|
||||
|
||||
24
tests/recordings/2-10-ball-race3-wechsel.ts
Normal file
24
tests/recordings/2-10-ball-race3-wechsel.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('test', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/');
|
||||
await page.getByRole('button', { name: 'Neues Spiel starten' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Charlie');
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Diana');
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
await page.getByRole('button', { name: 'Überspringen' }).click();
|
||||
await page.getByRole('button', { name: '10-Ball' }).click();
|
||||
await page.getByRole('button', { name: '3' }).click();
|
||||
await page.getByRole('button', { name: 'Break-Regel wählen: Wechselbreak' }).click();
|
||||
await page.getByRole('button', { name: 'Zuerst: Diana' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Diana' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Charlie' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Diana' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Diana' }).click();
|
||||
await page.getByRole('button', { name: 'Bestätigen' }).click();
|
||||
await page.getByRole('button', { name: 'Zurück zur Liste' }).click();
|
||||
});
|
||||
|
||||
29
tests/recordings/2-8-ball-race5-wechsel.ts
Normal file
29
tests/recordings/2-8-ball-race5-wechsel.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('test', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/');
|
||||
await page.getByRole('button', { name: 'Neues Spiel starten' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Wendy');
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Xavier');
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
await page.getByRole('button', { name: 'Überspringen' }).click();
|
||||
await page.getByRole('button', { name: '8-Ball' }).click();
|
||||
await page.getByRole('button', { name: '5' }).click();
|
||||
await page.getByRole('button', { name: 'Break-Regel wählen: Wechselbreak' }).click();
|
||||
await page.getByRole('button', { name: 'Zuerst: Xavier' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Xavier' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Wendy' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Xavier' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Wendy' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Xavier' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Wendy' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Xavier' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Wendy' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Xavier' }).click();
|
||||
await page.getByRole('button', { name: 'Bestätigen' }).click();
|
||||
await page.getByRole('button', { name: 'Zurück zur Liste' }).click();
|
||||
});
|
||||
|
||||
31
tests/recordings/2-8-ball-race9-winner.ts
Normal file
31
tests/recordings/2-8-ball-race9-winner.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('test', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/');
|
||||
await page.getByRole('button', { name: 'Neues Spiel starten' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Mia');
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Noah');
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
await page.getByRole('button', { name: 'Überspringen' }).click();
|
||||
await page.getByRole('button', { name: '8-Ball' }).click();
|
||||
await page.getByRole('button', { name: '9' }).click();
|
||||
await page.getByRole('button', { name: 'Break-Regel wählen: Winnerbreak' }).click();
|
||||
await page.getByRole('button', { name: 'Zuerst: Mia' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Mia' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Noah' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Mia' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Mia' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Noah' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Mia' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Mia' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Mia' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Mia' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Mia' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Mia' }).click();
|
||||
await page.getByRole('button', { name: 'Bestätigen' }).click();
|
||||
await page.getByRole('button', { name: 'Zurück zur Liste' }).click();
|
||||
});
|
||||
|
||||
26
tests/recordings/2-8-endlos-winner.ts
Normal file
26
tests/recordings/2-8-endlos-winner.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('test', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/');
|
||||
await page.getByRole('button', { name: 'Neues Spiel starten' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Foo');
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Bar');
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
await page.getByRole('button', { name: 'Überspringen' }).click();
|
||||
await page.getByRole('button', { name: '8-Ball' }).click();
|
||||
await page.getByRole('button', { name: 'Endlos' }).click();
|
||||
await page.getByRole('button', { name: 'Break-Regel wählen: Winnerbreak' }).click();
|
||||
await page.getByRole('button', { name: 'Zuerst: Foo' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Foo' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Bar' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Foo' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Bar' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Foo' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Foo' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Foo' }).click();
|
||||
await page.getByRole('button', { name: 'Spiel beenden' }).click();
|
||||
await page.getByRole('button', { name: 'Bestätigen' }).click();
|
||||
});
|
||||
21
tests/recordings/2-9-ball-race1-wechsel.ts
Normal file
21
tests/recordings/2-9-ball-race1-wechsel.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('test', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/');
|
||||
await page.getByRole('button', { name: 'Neues Spiel starten' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Rita');
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Sam');
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
await page.getByRole('button', { name: 'Überspringen' }).click();
|
||||
await page.getByRole('button', { name: '9-Ball' }).click();
|
||||
await page.getByRole('button', { name: '1' }).click();
|
||||
await page.getByRole('button', { name: 'Break-Regel wählen: Wechselbreak' }).click();
|
||||
await page.getByRole('button', { name: 'Zuerst: Sam' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Sam' }).click();
|
||||
await page.getByRole('button', { name: 'Bestätigen' }).click();
|
||||
await page.getByRole('button', { name: 'Zurück zur Liste' }).click();
|
||||
});
|
||||
|
||||
26
tests/recordings/2-9-ball-race5-winner.ts
Normal file
26
tests/recordings/2-9-ball-race5-winner.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('test', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/');
|
||||
await page.getByRole('button', { name: 'Neues Spiel starten' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Alice');
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Bob');
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
await page.getByRole('button', { name: 'Überspringen' }).click();
|
||||
await page.getByRole('button', { name: '9-Ball' }).click();
|
||||
await page.getByRole('button', { name: '5' }).click();
|
||||
await page.getByRole('button', { name: 'Break-Regel wählen: Winnerbreak' }).click();
|
||||
await page.getByRole('button', { name: 'Zuerst: Alice' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Alice' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Bob' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Alice' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Alice' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Alice' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Alice' }).click();
|
||||
await page.getByRole('button', { name: 'Bestätigen' }).click();
|
||||
await page.getByRole('button', { name: 'Zurück zur Liste' }).click();
|
||||
});
|
||||
|
||||
29
tests/recordings/3-10-ball-race5-winner.ts
Normal file
29
tests/recordings/3-10-ball-race5-winner.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('test', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/');
|
||||
await page.getByRole('button', { name: 'Neues Spiel starten' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Oscar');
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Paula');
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Quinn');
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
await page.getByRole('button', { name: '10-Ball' }).click();
|
||||
await page.getByRole('button', { name: '5' }).click();
|
||||
await page.getByRole('button', { name: 'Break-Regel wählen: Winnerbreak' }).click();
|
||||
await page.getByRole('button', { name: 'Zuerst: Quinn' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Quinn' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Oscar' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Paula' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Quinn' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Quinn' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Quinn' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Quinn' }).click();
|
||||
await page.getByRole('button', { name: 'Bestätigen' }).click();
|
||||
await page.getByRole('button', { name: 'Zurück zur Liste' }).click();
|
||||
});
|
||||
|
||||
29
tests/recordings/3-8-ball-endlos-winner.ts
Normal file
29
tests/recordings/3-8-ball-endlos-winner.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('test', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/');
|
||||
await page.getByRole('button', { name: 'Neues Spiel starten' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Eva');
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Frank');
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Grace');
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
await page.getByRole('button', { name: '8-Ball' }).click();
|
||||
await page.getByRole('button', { name: 'Endlos' }).click();
|
||||
await page.getByRole('button', { name: 'Break-Regel wählen: Winnerbreak' }).click();
|
||||
await page.getByRole('button', { name: 'Zuerst: Eva' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Eva' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Frank' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Grace' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Eva' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Frank' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Grace' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Eva' }).click();
|
||||
await page.getByRole('button', { name: 'Spiel beenden' }).click();
|
||||
await page.getByRole('button', { name: 'Bestätigen' }).click();
|
||||
});
|
||||
|
||||
28
tests/recordings/3-8-ball-race3-wechsel.ts
Normal file
28
tests/recordings/3-8-ball-race3-wechsel.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('test', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/');
|
||||
await page.getByRole('button', { name: 'Neues Spiel starten' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Tom');
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Uma');
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Victor');
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
await page.getByRole('button', { name: '8-Ball' }).click();
|
||||
await page.getByRole('button', { name: '3' }).click();
|
||||
await page.getByRole('button', { name: 'Break-Regel wählen: Wechselbreak' }).click();
|
||||
await page.getByRole('button', { name: 'Zuerst: Tom' }).click();
|
||||
await page.getByRole('button', { name: 'Zweites Break: Uma' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Tom' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Uma' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Victor' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Tom' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Tom' }).click();
|
||||
await page.getByRole('button', { name: 'Bestätigen' }).click();
|
||||
await page.getByRole('button', { name: 'Zurück zur Liste' }).click();
|
||||
});
|
||||
|
||||
33
tests/recordings/3-9-ball-race7-wechsel.ts
Normal file
33
tests/recordings/3-9-ball-race7-wechsel.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('test', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/');
|
||||
await page.getByRole('button', { name: 'Neues Spiel starten' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Henry');
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Iris');
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Jack');
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
await page.getByRole('button', { name: '9-Ball' }).click();
|
||||
await page.getByRole('button', { name: '7' }).click();
|
||||
await page.getByRole('button', { name: 'Break-Regel wählen: Wechselbreak' }).click();
|
||||
await page.getByRole('button', { name: 'Zuerst: Iris' }).click();
|
||||
await page.getByRole('button', { name: 'Zweites Break: Jack' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Iris' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Henry' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Jack' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Iris' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Henry' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Iris' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Iris' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Iris' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Iris' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Iris' }).click();
|
||||
await page.getByRole('button', { name: 'Bestätigen' }).click();
|
||||
await page.getByRole('button', { name: 'Zurück zur Liste' }).click();
|
||||
});
|
||||
|
||||
147
tests/recordings/README.md
Normal file
147
tests/recordings/README.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# Playwright Recordings
|
||||
|
||||
This directory contains recorded browser interaction scripts for BSC Score. Use Playwright's codegen to record interactions once, then replay them anytime.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Recording Interactions
|
||||
|
||||
1. **Start the dev server** (in a separate terminal):
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
2. **Start Playwright codegen**:
|
||||
```bash
|
||||
npm run test:record
|
||||
```
|
||||
|
||||
This opens:
|
||||
- A browser window (use the app normally)
|
||||
- Playwright Inspector (shows generated code in real-time)
|
||||
|
||||
3. **Interact with the app**:
|
||||
- Click buttons, fill forms, navigate
|
||||
- All actions are automatically captured
|
||||
- Code appears in the Playwright Inspector window
|
||||
|
||||
4. **Save your recording**:
|
||||
- Copy the generated code from the Inspector
|
||||
- Create a new `.ts` file in this directory
|
||||
- Paste the code and save (e.g., `create-game-basic.ts`)
|
||||
|
||||
### Running Recordings
|
||||
|
||||
Run all recordings:
|
||||
```bash
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
Run a specific recording:
|
||||
```bash
|
||||
npx playwright test tests/recordings/create-game-basic.ts
|
||||
```
|
||||
|
||||
Run with UI mode (helpful for debugging):
|
||||
```bash
|
||||
npx playwright test --ui
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
Use descriptive names that indicate the flow:
|
||||
- `create-game-basic.ts` - Basic game creation flow
|
||||
- `create-game-3players.ts` - Game creation with 3 players
|
||||
- `score-update.ts` - Score update flow
|
||||
- `undo-action.ts` - Undo functionality
|
||||
|
||||
## Duplicating & Modifying Scripts
|
||||
|
||||
This is the key feature! Copy any script to create a variant:
|
||||
|
||||
1. **Copy a script**:
|
||||
```bash
|
||||
cp create-game-basic.ts create-game-variant.ts
|
||||
```
|
||||
|
||||
2. **Modify the copy**:
|
||||
- Change player names
|
||||
- Modify game type
|
||||
- Change the last step (e.g., click 'a' instead of 'z')
|
||||
- Add or remove steps
|
||||
|
||||
3. **Run the variant**:
|
||||
```bash
|
||||
npx playwright test tests/recordings/create-game-variant.ts
|
||||
```
|
||||
|
||||
### Example Modification
|
||||
|
||||
```typescript
|
||||
// Original: create-game-basic.ts
|
||||
await page.click('button:has-text("Option Z")');
|
||||
|
||||
// Modified: create-game-variant.ts
|
||||
await page.click('button:has-text("Option A")'); // changed from Z to A
|
||||
```
|
||||
|
||||
## Script Structure
|
||||
|
||||
All recordings follow this structure:
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('description of what this script does', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000');
|
||||
|
||||
// Your recorded interactions here
|
||||
await page.click('text=Neues Spiel');
|
||||
await page.fill('input[name="player1"]', 'Alice');
|
||||
// ... more steps
|
||||
});
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- **Use the dev server**: Make sure `npm run dev` is running on port 3000 before recording or running scripts
|
||||
- **Descriptive test names**: The test name helps identify what the script does
|
||||
- **Comments**: Add comments in scripts to explain non-obvious steps
|
||||
- **Wait for navigation**: If something doesn't work, you might need to add `await page.waitForNavigation()` or `await page.waitForSelector()`
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Fill a form field
|
||||
```typescript
|
||||
await page.fill('input[name="fieldName"]', 'value');
|
||||
```
|
||||
|
||||
### Click a button
|
||||
```typescript
|
||||
await page.click('button:has-text("Button Text")');
|
||||
```
|
||||
|
||||
### Wait for element
|
||||
```typescript
|
||||
await page.waitForSelector('text=Expected Text');
|
||||
```
|
||||
|
||||
### Check element is visible
|
||||
```typescript
|
||||
await expect(page.locator('text=Some Text')).toBeVisible();
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Script fails with "element not found"**:
|
||||
- Add wait statements: `await page.waitForSelector('selector')`
|
||||
- Check if the selector changed (use Playwright Inspector to find new selectors)
|
||||
|
||||
**Browser doesn't open during recording**:
|
||||
- Make sure dev server is running on port 3000
|
||||
- Check if port 3000 is available
|
||||
|
||||
**Test runs but doesn't do anything**:
|
||||
- Check that baseURL in `playwright.config.ts` is correct
|
||||
- Verify the app is accessible at `http://localhost:3000`
|
||||
|
||||
26
tests/recordings/example-flow.ts
Normal file
26
tests/recordings/example-flow.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('test', async ({ page }) => {
|
||||
await page.goto('http://localhost:3000/');
|
||||
await page.getByRole('button', { name: 'Neues Spiel starten' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Foo');
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).click();
|
||||
await page.getByRole('textbox', { name: 'Name Spieler' }).fill('Bar');
|
||||
await page.getByRole('button', { name: 'Weiter' }).click();
|
||||
await page.getByRole('button', { name: 'Überspringen' }).click();
|
||||
await page.getByRole('button', { name: '8-Ball' }).click();
|
||||
await page.getByRole('button', { name: '6' }).click();
|
||||
await page.getByRole('button', { name: 'Break-Regel wählen: Wechselbreak' }).click();
|
||||
await page.getByRole('button', { name: 'Zuerst: Foo' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Foo' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Bar' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Foo' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Foo' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Foo' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Foo' }).click();
|
||||
await page.getByRole('button', { name: 'Aktueller Punktestand für Foo' }).click();
|
||||
await page.getByRole('button', { name: 'Bestätigen' }).click();
|
||||
await page.getByRole('button', { name: 'Zurück zur Liste' }).click();
|
||||
});
|
||||
Reference in New Issue
Block a user