Files
gridpilot.gg/apps/companion/main/automation/infrastructure/adapters/automation/CheckoutPriceExtractor.ts
2025-12-23 11:49:47 +01:00

106 lines
3.7 KiB
TypeScript

import { Result } from '@core/shared/application/Result';
import { CheckoutPrice } from '../../../domain/value-objects/CheckoutPrice';
import { CheckoutState } from '../../../domain/value-objects/CheckoutState';
import type { CheckoutInfoDTO } from '../../../application/dto/CheckoutInfoDTO';
import { IRACING_SELECTORS } from './dom/IRacingSelectors';
interface Page {
locator(_selector: string): Locator;
}
interface Locator {
first(): Locator;
locator(_selector: string): Locator;
getAttribute(name: string): Promise<string | null>;
innerHTML(): Promise<string>;
textContent(): Promise<string | null>;
}
export class CheckoutPriceExtractor {
// Use the price action selector from IRACING_SELECTORS
private readonly selector = IRACING_SELECTORS.BLOCKED_SELECTORS.priceAction;
constructor(private readonly page: Page) {}
async extractCheckoutInfo(): Promise<Result<CheckoutInfoDTO>> {
try {
// Prefer the explicit pill element which contains the price
const pillLocator = this.page.locator('.label-pill, .label-inverse');
const pillText = await pillLocator.first().textContent().catch(() => null);
let price: CheckoutPrice | null = null;
let state = CheckoutState.unknown();
let buttonHtml = '';
if (pillText) {
// Parse price if possible
try {
price = CheckoutPrice.fromString(pillText.trim());
} catch {
price = null;
}
// Try to find the containing button and its classes/html
// Primary: locate button via known selector that contains the pill
const buttonLocator = this.page.locator(this.selector).first();
let classes = await buttonLocator.getAttribute('class').catch(() => null);
let html = await buttonLocator.innerHTML().catch(() => '');
if (!classes) {
// Fallback: find ancestor <a> of the pill (XPath)
const ancestorButton = pillLocator.first().locator('xpath=ancestor::a[1]');
classes = await ancestorButton.getAttribute('class').catch(() => null);
html = await ancestorButton.innerHTML().catch(() => '');
}
if (classes) {
state = CheckoutState.fromButtonClasses(classes);
buttonHtml = html ?? '';
}
} else {
// No pill found — attempt to read button directly (best-effort)
const buttonLocator = this.page.locator(this.selector).first();
const classes = await buttonLocator.getAttribute('class').catch(() => null);
const html = await buttonLocator.innerHTML().catch(() => '');
if (classes) {
state = CheckoutState.fromButtonClasses(classes);
buttonHtml = html ?? '';
}
}
// Additional fallback: search the wizard-footer for any price text if pill was not present or parsing failed
if (!price) {
try {
const footerLocator = this.page.locator('.wizard-footer, .modal-footer').first();
const footerText = await footerLocator.textContent().catch(() => null);
if (footerText) {
const match = footerText.match(/\$\d+\.\d{2}/);
if (match) {
try {
price = CheckoutPrice.fromString(match[0]);
} catch {
price = null;
}
}
}
} catch {
// ignore footer parse errors
}
}
return Result.ok({
price,
state,
buttonHtml
});
} catch (error) {
// On any unexpected error, return an "unknown" result (do not throw)
return Result.ok({
price: null,
state: CheckoutState.unknown(),
buttonHtml: ''
});
}
}
}