wip
This commit is contained in:
@@ -1,295 +0,0 @@
|
||||
<html><head><style type="text/css">
|
||||
@keyframes gridpilot-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.85; transform: scale(1.03); }
|
||||
}
|
||||
|
||||
@keyframes gridpilot-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes gridpilot-slide-in {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes gridpilot-checkered {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: 20px 20px; }
|
||||
}
|
||||
|
||||
@keyframes gridpilot-progress {
|
||||
0% { background-position: 0% 50%; }
|
||||
100% { background-position: 100% 50%; }
|
||||
}
|
||||
|
||||
#gridpilot-overlay {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 340px;
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
z-index: 2147483647;
|
||||
animation: gridpilot-slide-in 0.4s ease-out;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#gridpilot-overlay * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.gridpilot-card {
|
||||
background: #12121B;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(183, 183, 187, 0.2);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gridpilot-header {
|
||||
background: linear-gradient(90deg, #c8102e 0%, #a00d25 100%);
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gridpilot-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
linear-gradient(45deg, transparent 48%, rgba(255,255,255,0.05) 49%, rgba(255,255,255,0.05) 51%, transparent 52%),
|
||||
linear-gradient(-45deg, transparent 48%, rgba(255,255,255,0.05) 49%, rgba(255,255,255,0.05) 51%, transparent 52%);
|
||||
background-size: 8px 8px;
|
||||
animation: gridpilot-checkered 1.5s linear infinite;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.gridpilot-logo {
|
||||
font-size: 22px;
|
||||
animation: gridpilot-pulse 2s ease-in-out infinite;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.gridpilot-title {
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.5px;
|
||||
text-transform: uppercase;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.3);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.gridpilot-btn {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #ffffff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 3px;
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.gridpilot-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.gridpilot-btn:active {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.gridpilot-btn.paused {
|
||||
background: #4e4e57;
|
||||
border-color: #ffffff;
|
||||
color: #ffffff;
|
||||
animation: gridpilot-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.gridpilot-close-btn {
|
||||
background: rgba(200, 16, 46, 0.6);
|
||||
border-color: rgba(200, 16, 46, 0.8);
|
||||
}
|
||||
|
||||
.gridpilot-close-btn:hover {
|
||||
background: rgba(200, 16, 46, 0.8);
|
||||
border-color: #c8102e;
|
||||
}
|
||||
|
||||
.gridpilot-close-btn:active {
|
||||
background: #c8102e;
|
||||
}
|
||||
|
||||
.gridpilot-header-buttons {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.gridpilot-body {
|
||||
padding: 14px;
|
||||
background: #1a1a24;
|
||||
}
|
||||
|
||||
.gridpilot-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.gridpilot-spinner {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 2px solid rgba(200, 16, 46, 0.3);
|
||||
border-top-color: #c8102e;
|
||||
border-radius: 50%;
|
||||
animation: gridpilot-spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gridpilot-spinner.paused {
|
||||
animation-play-state: paused;
|
||||
border-top-color: #777880;
|
||||
border-color: rgba(119, 120, 128, 0.3);
|
||||
}
|
||||
|
||||
.gridpilot-action-text {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.gridpilot-progress-container {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.gridpilot-progress-bar {
|
||||
height: 4px;
|
||||
background: rgba(78, 78, 87, 0.5);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gridpilot-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #c8102e, #e8304a, #c8102e);
|
||||
background-size: 200% 100%;
|
||||
animation: gridpilot-progress 2s linear infinite;
|
||||
border-radius: 2px;
|
||||
transition: width 0.4s ease-out;
|
||||
}
|
||||
|
||||
.gridpilot-progress-fill.paused {
|
||||
animation-play-state: paused;
|
||||
background: #777880;
|
||||
}
|
||||
|
||||
.gridpilot-step-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.gridpilot-step-text {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.gridpilot-step-count {
|
||||
color: #c8102e;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gridpilot-personality {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(183, 183, 187, 0.15);
|
||||
}
|
||||
|
||||
.gridpilot-footer {
|
||||
background: #12121B;
|
||||
padding: 8px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border-top: 1px solid rgba(183, 183, 187, 0.1);
|
||||
}
|
||||
|
||||
.gridpilot-footer-text {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.gridpilot-footer-dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background: #c8102e;
|
||||
border-radius: 50%;
|
||||
animation: gridpilot-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.gridpilot-footer-dot.paused {
|
||||
background: #777880;
|
||||
animation: none;
|
||||
}
|
||||
</style></head><body><div id="gridpilot-overlay">
|
||||
<div class="gridpilot-card">
|
||||
<div class="gridpilot-header">
|
||||
<span class="gridpilot-logo">🏎️</span>
|
||||
<span class="gridpilot-title">GridPilot</span>
|
||||
<div class="gridpilot-header-buttons">
|
||||
<button class="gridpilot-btn gridpilot-close-btn" id="gridpilot-close-btn" onclick="(function() {
|
||||
window.__gridpilot_close_requested = true;
|
||||
})()">Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gridpilot-body">
|
||||
<div class="gridpilot-status">
|
||||
<div class="gridpilot-spinner"></div>
|
||||
<span class="gridpilot-action-text" id="gridpilot-action">Processing step 0...</span>
|
||||
</div>
|
||||
<div class="gridpilot-progress-container">
|
||||
<div class="gridpilot-progress-bar">
|
||||
<div class="gridpilot-progress-fill" id="gridpilot-progress" style="width: 0%"></div>
|
||||
</div>
|
||||
<div class="gridpilot-step-info">
|
||||
<span class="gridpilot-step-text" id="gridpilot-step-text">Processing step 0...</span>
|
||||
<span class="gridpilot-step-count" id="gridpilot-step-count">Step 0 of 17</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gridpilot-personality" id="gridpilot-personality">🏁 Getting ready for the green flag...</div>
|
||||
</div>
|
||||
<div class="gridpilot-footer">
|
||||
<div class="gridpilot-footer-dot"></div>
|
||||
<span class="gridpilot-footer-text">Automating your session setup</span>
|
||||
</div>
|
||||
</div>
|
||||
</div></body></html>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 18 KiB |
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 448 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 389 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 390 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 389 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 388 KiB |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -8,7 +8,8 @@ import { StartAutomationSessionUseCase } from '@/packages/application/use-cases/
|
||||
import { CheckAuthenticationUseCase } from '@/packages/application/use-cases/CheckAuthenticationUseCase';
|
||||
import { InitiateLoginUseCase } from '@/packages/application/use-cases/InitiateLoginUseCase';
|
||||
import { ClearSessionUseCase } from '@/packages/application/use-cases/ClearSessionUseCase';
|
||||
import { loadAutomationConfig, getAutomationMode, AutomationMode } from '@/packages/infrastructure/config';
|
||||
import { ConfirmCheckoutUseCase } from '@/packages/application/use-cases/ConfirmCheckoutUseCase';
|
||||
import { loadAutomationConfig, getAutomationMode, AutomationMode, BrowserModeConfigLoader } from '@/packages/infrastructure/config';
|
||||
import { PinoLogAdapter } from '@/packages/infrastructure/adapters/logging/PinoLogAdapter';
|
||||
import { NoOpLogAdapter } from '@/packages/infrastructure/adapters/logging/NoOpLogAdapter';
|
||||
import { loadLoggingConfig } from '@/packages/infrastructure/config/LoggingConfig';
|
||||
@@ -16,6 +17,7 @@ import type { ISessionRepository } from '@/packages/application/ports/ISessionRe
|
||||
import type { IScreenAutomation } from '@/packages/application/ports/IScreenAutomation';
|
||||
import type { IAutomationEngine } from '@/packages/application/ports/IAutomationEngine';
|
||||
import type { IAuthenticationService } from '@/packages/application/ports/IAuthenticationService';
|
||||
import type { ICheckoutConfirmationPort } from '@/packages/application/ports/ICheckoutConfirmationPort';
|
||||
import type { ILogger } from '@/packages/application/ports/ILogger';
|
||||
|
||||
export interface BrowserConnectionResult {
|
||||
@@ -92,7 +94,11 @@ function getAdapterMode(envMode: AutomationMode): AutomationAdapterMode {
|
||||
* @param logger - Logger instance for the adapter
|
||||
* @returns PlaywrightAutomationAdapter instance (implements both IScreenAutomation and IAuthenticationService)
|
||||
*/
|
||||
function createBrowserAutomationAdapter(mode: AutomationMode, logger: ILogger): PlaywrightAutomationAdapter | MockBrowserAutomationAdapter {
|
||||
function createBrowserAutomationAdapter(
|
||||
mode: AutomationMode,
|
||||
logger: ILogger,
|
||||
browserModeConfigLoader: BrowserModeConfigLoader
|
||||
): PlaywrightAutomationAdapter | MockBrowserAutomationAdapter {
|
||||
const config = loadAutomationConfig();
|
||||
|
||||
// Resolve absolute template path for Electron environment
|
||||
@@ -108,18 +114,28 @@ function createBrowserAutomationAdapter(mode: AutomationMode, logger: ILogger):
|
||||
});
|
||||
|
||||
const adapterMode = getAdapterMode(mode);
|
||||
logger.info('Creating browser automation adapter', { envMode: mode, adapterMode });
|
||||
|
||||
// Get browser mode configuration from provided loader
|
||||
const browserModeConfig = browserModeConfigLoader.load();
|
||||
|
||||
logger.info('Creating browser automation adapter', {
|
||||
envMode: mode,
|
||||
adapterMode,
|
||||
browserMode: browserModeConfig.mode,
|
||||
browserModeSource: browserModeConfig.source,
|
||||
});
|
||||
|
||||
switch (mode) {
|
||||
case 'production':
|
||||
case 'development':
|
||||
return new PlaywrightAutomationAdapter(
|
||||
{
|
||||
headless: mode === 'production',
|
||||
headless: browserModeConfig.mode === 'headless',
|
||||
mode: adapterMode,
|
||||
userDataDir: sessionDataPath,
|
||||
},
|
||||
logger.child({ adapter: 'Playwright', mode: adapterMode })
|
||||
logger.child({ adapter: 'Playwright', mode: adapterMode }),
|
||||
browserModeConfigLoader
|
||||
);
|
||||
|
||||
case 'test':
|
||||
@@ -139,7 +155,9 @@ export class DIContainer {
|
||||
private checkAuthenticationUseCase: CheckAuthenticationUseCase | null = null;
|
||||
private initiateLoginUseCase: InitiateLoginUseCase | null = null;
|
||||
private clearSessionUseCase: ClearSessionUseCase | null = null;
|
||||
private confirmCheckoutUseCase: ConfirmCheckoutUseCase | null = null;
|
||||
private automationMode: AutomationMode;
|
||||
private browserModeConfigLoader: BrowserModeConfigLoader;
|
||||
|
||||
private constructor() {
|
||||
// Initialize logger first - it's needed by other components
|
||||
@@ -153,8 +171,15 @@ export class DIContainer {
|
||||
|
||||
const config = loadAutomationConfig();
|
||||
|
||||
// Initialize browser mode config loader as singleton
|
||||
this.browserModeConfigLoader = new BrowserModeConfigLoader();
|
||||
|
||||
this.sessionRepository = new InMemorySessionRepository();
|
||||
this.browserAutomation = createBrowserAutomationAdapter(config.mode, this.logger);
|
||||
this.browserAutomation = createBrowserAutomationAdapter(
|
||||
config.mode,
|
||||
this.logger,
|
||||
this.browserModeConfigLoader
|
||||
);
|
||||
this.automationEngine = new MockAutomationEngineAdapter(
|
||||
this.browserAutomation,
|
||||
this.sessionRepository
|
||||
@@ -241,6 +266,21 @@ export class DIContainer {
|
||||
return null;
|
||||
}
|
||||
|
||||
public setConfirmCheckoutUseCase(
|
||||
checkoutConfirmationPort: ICheckoutConfirmationPort
|
||||
): void {
|
||||
// Create ConfirmCheckoutUseCase with checkout service from browser automation
|
||||
// and the provided confirmation port
|
||||
this.confirmCheckoutUseCase = new ConfirmCheckoutUseCase(
|
||||
this.browserAutomation as any, // implements ICheckoutService
|
||||
checkoutConfirmationPort
|
||||
);
|
||||
}
|
||||
|
||||
public getConfirmCheckoutUseCase(): ConfirmCheckoutUseCase | null {
|
||||
return this.confirmCheckoutUseCase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize automation connection based on mode.
|
||||
* In production/development mode, connects via Playwright browser automation.
|
||||
@@ -292,6 +332,14 @@ export class DIContainer {
|
||||
this.logger.info('DIContainer shutdown complete');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the browser mode configuration loader.
|
||||
* Provides access to runtime browser mode control (headed/headless).
|
||||
*/
|
||||
public getBrowserModeConfigLoader(): BrowserModeConfigLoader {
|
||||
return this.browserModeConfigLoader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the singleton instance (useful for testing with different configurations).
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DIContainer } from './di-container';
|
||||
import type { HostedSessionConfig } from '@/packages/domain/entities/HostedSessionConfig';
|
||||
import { StepId } from '@/packages/domain/value-objects/StepId';
|
||||
import { AuthenticationState } from '@/packages/domain/value-objects/AuthenticationState';
|
||||
import { ElectronCheckoutConfirmationAdapter } from '@/packages/infrastructure/adapters/ipc/ElectronCheckoutConfirmationAdapter';
|
||||
|
||||
let progressMonitorInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
@@ -14,6 +15,10 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
|
||||
const automationEngine = container.getAutomationEngine();
|
||||
const logger = container.getLogger();
|
||||
|
||||
// Setup checkout confirmation adapter and wire it into the container
|
||||
const checkoutConfirmationAdapter = new ElectronCheckoutConfirmationAdapter(mainWindow);
|
||||
container.setConfirmCheckoutUseCase(checkoutConfirmationAdapter);
|
||||
|
||||
// Authentication handlers
|
||||
ipcMain.handle('auth:check', async () => {
|
||||
try {
|
||||
@@ -21,11 +26,10 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
|
||||
const checkAuthUseCase = container.getCheckAuthenticationUseCase();
|
||||
|
||||
if (!checkAuthUseCase) {
|
||||
logger.warn('Authentication not available in mock mode');
|
||||
logger.error('Authentication use case not available');
|
||||
return {
|
||||
success: true,
|
||||
state: AuthenticationState.AUTHENTICATED,
|
||||
message: 'Mock mode - authentication bypassed'
|
||||
success: false,
|
||||
error: 'Authentication not available - check system configuration'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -301,4 +305,36 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Browser mode control handlers
|
||||
ipcMain.handle('browser-mode:get', async () => {
|
||||
try {
|
||||
const loader = container.getBrowserModeConfigLoader();
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return { mode: loader.getDevelopmentMode(), isDevelopment: true };
|
||||
}
|
||||
return { mode: 'headless', isDevelopment: false };
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error('Unknown error');
|
||||
logger.error('Failed to get browser mode', err);
|
||||
return { mode: 'headless', isDevelopment: false };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('browser-mode:set', async (_event: IpcMainInvokeEvent, mode: 'headed' | 'headless') => {
|
||||
try {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const loader = container.getBrowserModeConfigLoader();
|
||||
loader.setDevelopmentMode(mode);
|
||||
logger.info('Browser mode updated', { mode });
|
||||
return { success: true, mode };
|
||||
}
|
||||
logger.warn('Browser mode change requested but not in development mode');
|
||||
return { success: false, error: 'Only available in development mode' };
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error('Unknown error');
|
||||
logger.error('Failed to set browser mode', err);
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -20,6 +20,17 @@ export interface AuthActionResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CheckoutConfirmationRequest {
|
||||
price: string;
|
||||
state: 'ready' | 'insufficient_funds';
|
||||
sessionMetadata: {
|
||||
sessionName: string;
|
||||
trackId: string;
|
||||
carIds: string[];
|
||||
};
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
startAutomation: (config: HostedSessionConfig) => Promise<{
|
||||
success: boolean;
|
||||
@@ -37,6 +48,12 @@ export interface ElectronAPI {
|
||||
initiateLogin: () => Promise<AuthActionResponse>;
|
||||
confirmLogin: () => Promise<AuthActionResponse>;
|
||||
logout: () => Promise<AuthActionResponse>;
|
||||
// Browser Mode APIs
|
||||
getBrowserMode: () => Promise<{ mode: 'headed' | 'headless'; isDevelopment: boolean }>;
|
||||
setBrowserMode: (mode: 'headed' | 'headless') => Promise<{ success: boolean; mode?: string; error?: string }>;
|
||||
// Checkout Confirmation APIs
|
||||
onCheckoutConfirmationRequest: (callback: (request: CheckoutConfirmationRequest) => void) => () => void;
|
||||
confirmCheckout: (decision: 'confirmed' | 'cancelled' | 'timeout') => void;
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
@@ -56,4 +73,18 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
initiateLogin: () => ipcRenderer.invoke('auth:login'),
|
||||
confirmLogin: () => ipcRenderer.invoke('auth:confirmLogin'),
|
||||
logout: () => ipcRenderer.invoke('auth:logout'),
|
||||
// Browser Mode APIs
|
||||
getBrowserMode: () => ipcRenderer.invoke('browser-mode:get'),
|
||||
setBrowserMode: (mode: 'headed' | 'headless') => ipcRenderer.invoke('browser-mode:set', mode),
|
||||
// Checkout Confirmation APIs
|
||||
onCheckoutConfirmationRequest: (callback: (request: CheckoutConfirmationRequest) => void) => {
|
||||
const listener = (_event: any, request: CheckoutConfirmationRequest) => callback(request);
|
||||
ipcRenderer.on('checkout:request-confirmation', listener);
|
||||
return () => {
|
||||
ipcRenderer.removeListener('checkout:request-confirmation', listener);
|
||||
};
|
||||
},
|
||||
confirmCheckout: (decision: 'confirmed' | 'cancelled' | 'timeout') => {
|
||||
ipcRenderer.send('checkout:confirm', decision);
|
||||
},
|
||||
} as ElectronAPI);
|
||||
@@ -5,7 +5,7 @@
|
||||
"main": "dist/main/main.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "unset ELECTRON_RUN_AS_NODE && electron-vite dev",
|
||||
"dev": "NODE_ENV=development unset ELECTRON_RUN_AS_NODE && electron-vite dev",
|
||||
"build": "electron-vite build",
|
||||
"preview": "unset ELECTRON_RUN_AS_NODE && electron-vite preview",
|
||||
"start": "unset ELECTRON_RUN_AS_NODE && electron ."
|
||||
|
||||
@@ -2,6 +2,9 @@ import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { SessionCreationForm } from './components/SessionCreationForm';
|
||||
import { SessionProgressMonitor } from './components/SessionProgressMonitor';
|
||||
import { LoginPrompt } from './components/LoginPrompt';
|
||||
import { BrowserModeToggle } from './components/BrowserModeToggle';
|
||||
import { CheckoutConfirmationDialog } from './components/CheckoutConfirmationDialog';
|
||||
import { RaceCreationSuccessScreen } from './components/RaceCreationSuccessScreen';
|
||||
import type { HostedSessionConfig } from '../../../packages/domain/entities/HostedSessionConfig';
|
||||
|
||||
interface SessionProgress {
|
||||
@@ -24,6 +27,26 @@ export function App() {
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [loginStatus, setLoginStatus] = useState<LoginStatus>('idle');
|
||||
|
||||
const [checkoutRequest, setCheckoutRequest] = useState<{
|
||||
price: string;
|
||||
state: 'ready' | 'insufficient_funds';
|
||||
sessionMetadata: {
|
||||
sessionName: string;
|
||||
trackId: string;
|
||||
carIds: string[];
|
||||
};
|
||||
timeoutMs: number;
|
||||
} | null>(null);
|
||||
|
||||
const [raceCreationResult, setRaceCreationResult] = useState<{
|
||||
sessionId: string;
|
||||
sessionName: string;
|
||||
trackId: string;
|
||||
carIds: string[];
|
||||
finalPrice: string;
|
||||
createdAt: Date;
|
||||
} | null>(null);
|
||||
|
||||
const handleLogin = useCallback(async () => {
|
||||
if (!window.electronAPI) return;
|
||||
|
||||
@@ -91,6 +114,11 @@ export function App() {
|
||||
}
|
||||
};
|
||||
|
||||
// Subscribe to checkout confirmation requests
|
||||
const unsubscribeCheckout = window.electronAPI.onCheckoutConfirmationRequest((request) => {
|
||||
setCheckoutRequest(request);
|
||||
});
|
||||
|
||||
checkAuth();
|
||||
|
||||
window.electronAPI.onSessionProgress((newProgress: SessionProgress) => {
|
||||
@@ -101,6 +129,11 @@ export function App() {
|
||||
setIsRunning(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup subscription on unmount
|
||||
return () => {
|
||||
unsubscribeCheckout?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleStartAutomation = async (config: HostedSessionConfig) => {
|
||||
@@ -157,6 +190,16 @@ export function App() {
|
||||
);
|
||||
}
|
||||
|
||||
// Show checkout confirmation dialog if requested
|
||||
if (checkoutRequest) {
|
||||
return <CheckoutConfirmationDialog request={checkoutRequest} />;
|
||||
}
|
||||
|
||||
// Show race creation success screen if completed
|
||||
if (raceCreationResult) {
|
||||
return <RaceCreationSuccessScreen result={raceCreationResult} />;
|
||||
}
|
||||
|
||||
if (authState !== 'AUTHENTICATED') {
|
||||
return (
|
||||
<LoginPrompt
|
||||
@@ -178,37 +221,42 @@ export function App() {
|
||||
<div style={{
|
||||
flex: 1,
|
||||
padding: '2rem',
|
||||
borderRight: '1px solid #333'
|
||||
borderRight: '1px solid #333',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<h1 style={{ marginBottom: '2rem', color: '#fff' }}>
|
||||
GridPilot Companion
|
||||
</h1>
|
||||
<p style={{ marginBottom: '2rem', color: '#aaa' }}>
|
||||
Hosted Session Automation POC
|
||||
</p>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h1 style={{ marginBottom: '2rem', color: '#fff' }}>
|
||||
GridPilot Companion
|
||||
</h1>
|
||||
<p style={{ marginBottom: '2rem', color: '#aaa' }}>
|
||||
Hosted Session Automation POC
|
||||
</p>
|
||||
|
||||
<SessionCreationForm
|
||||
onSubmit={handleStartAutomation}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
{isRunning && (
|
||||
<button
|
||||
onClick={handleStopAutomation}
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
padding: '0.75rem 1.5rem',
|
||||
backgroundColor: '#dc3545',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1rem',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
Stop Automation
|
||||
</button>
|
||||
)}
|
||||
<SessionCreationForm
|
||||
onSubmit={handleStartAutomation}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
{isRunning && (
|
||||
<button
|
||||
onClick={handleStopAutomation}
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
padding: '0.75rem 1.5rem',
|
||||
backgroundColor: '#dc3545',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1rem',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
Stop Automation
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<BrowserModeToggle />
|
||||
</div>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
|
||||
40
apps/companion/renderer/components/BrowserModeToggle.tsx
Normal file
40
apps/companion/renderer/components/BrowserModeToggle.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function BrowserModeToggle() {
|
||||
const [mode, setMode] = useState<'headed' | 'headless'>('headed');
|
||||
const [isDevelopment, setIsDevelopment] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
window.electronAPI.getBrowserMode().then(({ mode, isDevelopment }) => {
|
||||
setMode(mode);
|
||||
setIsDevelopment(isDevelopment);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!isDevelopment) return null;
|
||||
|
||||
const handleToggle = async () => {
|
||||
const newMode = mode === 'headed' ? 'headless' : 'headed';
|
||||
const result = await window.electronAPI.setBrowserMode(newMode);
|
||||
if (result.success) {
|
||||
setMode(newMode);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '10px', borderTop: '1px solid #333' }}>
|
||||
<label style={{ color: '#aaa', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mode === 'headless'}
|
||||
onChange={handleToggle}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
Headless Browser Mode
|
||||
</label>
|
||||
<div style={{ fontSize: '0.85rem', color: '#666', marginTop: '4px', marginLeft: '24px' }}>
|
||||
{mode === 'headless' ? 'Browser runs in background' : 'Browser window visible'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* CheckoutConfirmationDialog component
|
||||
* Displays checkout information and requests user confirmation before proceeding.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
interface CheckoutConfirmationRequest {
|
||||
price: string;
|
||||
state: 'ready' | 'insufficient_funds';
|
||||
sessionMetadata: {
|
||||
sessionName: string;
|
||||
trackId: string;
|
||||
carIds: string[];
|
||||
};
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
interface CheckoutConfirmationDialogProps {
|
||||
request: CheckoutConfirmationRequest;
|
||||
}
|
||||
|
||||
export const CheckoutConfirmationDialog: React.FC<CheckoutConfirmationDialogProps> = ({
|
||||
request,
|
||||
}) => {
|
||||
const [remainingSeconds, setRemainingSeconds] = useState(
|
||||
Math.floor(request.timeoutMs / 1000)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Countdown timer
|
||||
const intervalId = setInterval(() => {
|
||||
setRemainingSeconds((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(intervalId);
|
||||
window.electronAPI.confirmCheckout('timeout');
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
const handleConfirm = () => {
|
||||
window.electronAPI.confirmCheckout('confirmed');
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
window.electronAPI.confirmCheckout('cancelled');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="checkout-confirmation-dialog">
|
||||
<div className="dialog-overlay">
|
||||
<div className="dialog-content">
|
||||
<h2>Confirm Checkout</h2>
|
||||
|
||||
<div className="checkout-details">
|
||||
<div className="price-section">
|
||||
<span className="price-label">Price:</span>
|
||||
<span className="price-value">{request.price}</span>
|
||||
</div>
|
||||
|
||||
{request.state === 'insufficient_funds' && (
|
||||
<div className="warning-section">
|
||||
<span className="warning-text">⚠️ Insufficient funds</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="session-info">
|
||||
<div className="info-row">
|
||||
<span className="info-label">Session:</span>
|
||||
<span className="info-value">{request.sessionMetadata.sessionName}</span>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<span className="info-label">Track:</span>
|
||||
<span className="info-value">{request.sessionMetadata.trackId}</span>
|
||||
</div>
|
||||
<div className="info-row">
|
||||
<span className="info-label">Cars:</span>
|
||||
<span className="info-value">
|
||||
{request.sessionMetadata.carIds.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="countdown-section">
|
||||
<span className="countdown-text">
|
||||
Time remaining: {remainingSeconds}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dialog-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-cancel"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-confirm"
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* RaceCreationSuccessScreen component
|
||||
* Displays the successful race creation result with session details.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface RaceCreationResult {
|
||||
sessionId: string;
|
||||
sessionName: string;
|
||||
trackId: string;
|
||||
carIds: string[];
|
||||
finalPrice: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface RaceCreationSuccessScreenProps {
|
||||
result: RaceCreationResult;
|
||||
}
|
||||
|
||||
export const RaceCreationSuccessScreen: React.FC<RaceCreationSuccessScreenProps> = ({
|
||||
result,
|
||||
}) => {
|
||||
return (
|
||||
<div className="race-creation-success-screen">
|
||||
<div className="success-container">
|
||||
<div className="success-header">
|
||||
<h2>✅ Race Created Successfully!</h2>
|
||||
</div>
|
||||
|
||||
<div className="success-details">
|
||||
<div className="detail-section">
|
||||
<h3>Session Information</h3>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Session Name:</span>
|
||||
<span className="detail-value">{result.sessionName}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Session ID:</span>
|
||||
<span className="detail-value">{result.sessionId}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h3>Track & Cars</h3>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Track:</span>
|
||||
<span className="detail-value">{result.trackId}</span>
|
||||
</div>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Cars:</span>
|
||||
<span className="detail-value">{result.carIds.join(', ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h3>Financial</h3>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Final Price:</span>
|
||||
<span className="detail-value price">{result.finalPrice}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h3>Created</h3>
|
||||
<div className="detail-row">
|
||||
<span className="detail-label">Timestamp:</span>
|
||||
<span className="detail-value">
|
||||
{result.createdAt.toISOString().split('T')[0]} at{' '}
|
||||
{result.createdAt.toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -32,8 +32,7 @@ const STEP_NAMES: { [key: number]: string } = {
|
||||
14: 'Set Time of Day',
|
||||
15: 'Configure Weather',
|
||||
16: 'Set Race Options',
|
||||
17: 'Configure Team Driving',
|
||||
18: 'Set Track Conditions'
|
||||
17: 'Set Track Conditions'
|
||||
};
|
||||
|
||||
export function SessionProgressMonitor({ sessionId, progress, isRunning }: SessionProgressMonitorProps) {
|
||||
@@ -142,7 +141,7 @@ export function SessionProgressMonitor({ sessionId, progress, isRunning }: Sessi
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: '1rem', color: '#aaa', fontSize: '14px' }}>
|
||||
Progress: {progress.completedSteps.length} / 18 steps
|
||||
Progress: {progress.completedSteps.length} / 17 steps
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||
|
||||
Reference in New Issue
Block a user