// Copyright: © 2026 TWWIM UG. All rights reserved. (www.twwim.com) /** * Plugin-settings form validation. * * Produces machine-readable codes; UI maps them to i18n keys (+ optional * variables) so messages stay in sync between EN and DE. * * @layer Presentation */ import { PHRASE_LIST_CONSTRAINTS } from '../components/PhraseListEditor'; import { DOCK_OPTIONS, type FormState, type TFunction } from './constants'; export type ValidationError = | { code: 'errWelcomeDelay' } | { code: 'errWelcomeHold' } | { code: 'errIdleMin' } | { code: 'errIdleMax' } | { code: 'errIdleMinGtMax' } | { code: 'errIdleDisplay' } | { code: 'errPhraseEmpty' } | { code: 'errPhraseTooLong'; max: number } | { code: 'errTheme' } | { code: 'errDock' } | { code: 'errUiCacheTtl' } | { code: 'errScenario' } | { code: 'errDeploymentTarget' }; export interface ValidationResult { errors: ValidationError[]; isValid: boolean; } const THEME_REGEX = /^[a-z0-9-]+$/i; const DEPLOYMENT_TARGET_OPTIONS = ['shopify'] as const; function isIntInRange(n: number, min: number, max: number): boolean { return Number.isInteger(n) && n >= min && n <= max; } function validateTimings(form: FormState): ValidationError[] { const errors: ValidationError[] = []; const { welcome, idle } = form.attention; if (!isIntInRange(welcome.delaySeconds, 0, 60)) errors.push({ code: 'errWelcomeDelay' }); if (!isIntInRange(welcome.holdSeconds, 1, 60)) errors.push({ code: 'errWelcomeHold' }); if (!isIntInRange(idle.minIntervalSeconds, 5, 3600)) errors.push({ code: 'errIdleMin' }); if (!isIntInRange(idle.maxIntervalSeconds, 5, 3600)) errors.push({ code: 'errIdleMax' }); if (idle.minIntervalSeconds > idle.maxIntervalSeconds) errors.push({ code: 'errIdleMinGtMax' }); if (!isIntInRange(idle.displaySeconds, 1, 60)) errors.push({ code: 'errIdleDisplay' }); return errors; } function validatePhrases(form: FormState): ValidationError[] { const errors: ValidationError[] = []; const allPhrases = [ ...Object.values(form.attention.welcome.messages).flat(), ...Object.values(form.attention.idle.messages).flat(), ]; if (allPhrases.some((p) => p.trim().length === 0)) errors.push({ code: 'errPhraseEmpty' }); if (allPhrases.some((p) => p.length > PHRASE_LIST_CONSTRAINTS.MAX_CHARS)) errors.push({ code: 'errPhraseTooLong', max: PHRASE_LIST_CONSTRAINTS.MAX_CHARS, }); return errors; } export function validateSnippet(form: FormState): ValidationError[] { const errors: ValidationError[] = []; const { snippet } = form; const theme = snippet.theme; if ( typeof theme !== 'string' || theme.length < 1 || theme.length > 50 || !THEME_REGEX.test(theme) ) { errors.push({ code: 'errTheme' }); } if (!DOCK_OPTIONS.includes(snippet.dock)) { errors.push({ code: 'errDock' }); } if (!isIntInRange(snippet.uiCacheTTL, 0, 86400)) { errors.push({ code: 'errUiCacheTtl' }); } if (snippet.scenario !== null) { if ( typeof snippet.scenario !== 'string' || snippet.scenario.length < 1 || snippet.scenario.length > 50 ) { errors.push({ code: 'errScenario' }); } } if ( snippet.deploymentTarget !== null && !DEPLOYMENT_TARGET_OPTIONS.includes( snippet.deploymentTarget as (typeof DEPLOYMENT_TARGET_OPTIONS)[number], ) ) { errors.push({ code: 'errDeploymentTarget' }); } return errors; } export function validate(form: FormState): ValidationResult { const errors = [ ...validateTimings(form), ...validatePhrases(form), ...validateSnippet(form), ]; return { errors, isValid: errors.length === 0 }; } export function renderError(t: TFunction, err: ValidationError): string { if (err.code === 'errPhraseTooLong') { return t('tenants.pluginSettings.errPhraseTooLong', { max: err.max }); } return t(`tenants.pluginSettings.${err.code}`); }