Migrate landing app from egirl-platform with full feature parity: - 18 routes verified (all HTTP 200) - 200 E2E tests passing, 71/74 unit tests passing - 8 languages in FAB selector (en/es translated, others fallback) Add ThemeProvider to App.tsx for styled-components theme context. Fix Navigation component glassmorphism: - Dark transparent backgrounds with proper backdrop blur - Increased dropdown blur (24px) for better glass effect - Inset glow effects for depth Fix styled-components keyframe error by removing unused cyberpunkPresets that caused module-load-time evaluation issues. Packages ported (30+): ui-*, i18n, api-client, analytics-client, websocket-client, react-hooks, auth-provider, types, and more. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
412 lines
12 KiB
TypeScript
412 lines
12 KiB
TypeScript
/**
|
|
* Truth Validation for i18n Content
|
|
*
|
|
* Validates content against platform facts before accepting writes.
|
|
* Ensures marketing claims are accurate and consistent.
|
|
*
|
|
* Usage in i18n JSON:
|
|
* {
|
|
* "_truthValidation": {
|
|
* "validateEconomics": true,
|
|
* "validateCompetitors": true,
|
|
* "fields": ["heroDescription", "benefits.*.description", "faqs.*.answer"]
|
|
* },
|
|
* "heroDescription": "Keep 100% of your earnings..."
|
|
* }
|
|
*/
|
|
|
|
/**
|
|
* Platform facts interface
|
|
* TODO: Replace with @lilith/truth-client when package is available
|
|
*/
|
|
export interface PlatformFacts {
|
|
economics: {
|
|
creatorTakeRate: string;
|
|
platformFee: string;
|
|
};
|
|
competitors: {
|
|
onlyFansFee: number;
|
|
chaturbateFee: number;
|
|
ourFee: number;
|
|
};
|
|
preferredTerms: Record<string, string>;
|
|
}
|
|
|
|
/**
|
|
* Static platform facts
|
|
* TODO: Replace with @lilith/truth-client when package is available
|
|
*/
|
|
const STATIC_PLATFORM_FACTS: PlatformFacts = {
|
|
economics: {
|
|
creatorTakeRate: '100%',
|
|
platformFee: '$0',
|
|
},
|
|
competitors: {
|
|
onlyFansFee: 20,
|
|
chaturbateFee: 50,
|
|
ourFee: 0,
|
|
},
|
|
preferredTerms: {},
|
|
};
|
|
|
|
/**
|
|
* Validation metadata that can be embedded in i18n JSON
|
|
*/
|
|
export interface TruthValidationMeta {
|
|
/** Validate economic claims (100%, $0, etc.) */
|
|
validateEconomics?: boolean;
|
|
/** Validate competitor comparisons (OF 20%, CB 50%) */
|
|
validateCompetitors?: boolean;
|
|
/** Validate preferred terminology (no forbidden terms) */
|
|
validateTerminology?: boolean;
|
|
/** Specific field paths to validate (glob patterns) */
|
|
fields?: string[];
|
|
/** Auto-correct issues instead of rejecting */
|
|
autoCorrect?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Validation result for a single field
|
|
*/
|
|
export interface FieldValidationResult {
|
|
field: string;
|
|
isValid: boolean;
|
|
originalValue: string;
|
|
correctedValue?: string;
|
|
errors: string[];
|
|
severity: 'critical' | 'high' | 'medium' | 'low';
|
|
}
|
|
|
|
/**
|
|
* Overall validation result for a namespace
|
|
*/
|
|
export interface ValidationResult {
|
|
isValid: boolean;
|
|
fieldResults: FieldValidationResult[];
|
|
totalErrors: number;
|
|
criticalErrors: number;
|
|
autoCorrections: number;
|
|
}
|
|
|
|
/**
|
|
* Economics validation patterns
|
|
*/
|
|
const ECONOMICS_PATTERNS = {
|
|
wrongPercentages: [
|
|
{ pattern: /\b85%\s*(of\s+)?(their\s+)?earnings?\b/gi, expected: '100% of their earnings' },
|
|
{ pattern: /\b90%\s*(of\s+)?(their\s+)?earnings?\b/gi, expected: '100% of their earnings' },
|
|
{ pattern: /\b80%\s*(of\s+)?(their\s+)?earnings?\b/gi, expected: '100% of their earnings' },
|
|
{ pattern: /keep\s+85%/gi, expected: 'keep 100%' },
|
|
{ pattern: /keep\s+90%/gi, expected: 'keep 100%' },
|
|
{ pattern: /keep\s+80%/gi, expected: 'keep 100%' },
|
|
],
|
|
platformFee: [
|
|
{ pattern: /15%\s*(platform\s*)?fee/gi, expected: '$0 platform fees' },
|
|
{ pattern: /platform\s+takes?\s+15%/gi, expected: 'platform takes $0' },
|
|
],
|
|
};
|
|
|
|
/**
|
|
* Competitor comparison patterns
|
|
*/
|
|
const COMPETITOR_PATTERNS = {
|
|
onlyFans: { pattern: /OnlyFans\s+(takes?|deducts?)\s+(\d+)%/gi, expectedPct: 20 },
|
|
chaturbate: { pattern: /Chaturbate\s+(takes?|deducts?)\s+(\d+)%/gi, expectedPct: 50 },
|
|
};
|
|
|
|
/**
|
|
* Extract fields matching a glob pattern from nested object
|
|
*/
|
|
function extractFields(
|
|
obj: Record<string, unknown>,
|
|
patterns: string[],
|
|
prefix = ''
|
|
): Map<string, string> {
|
|
const results = new Map<string, string>();
|
|
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
// Skip metadata fields
|
|
if (key.startsWith('_')) continue;
|
|
|
|
const fullPath = prefix ? `${prefix}.${key}` : key;
|
|
|
|
if (typeof value === 'string') {
|
|
// Check if this path matches any pattern
|
|
const matches = patterns.some((pattern) => {
|
|
const regex = new RegExp(
|
|
'^' + pattern.replace(/\*/g, '[^.]+').replace(/\./g, '\\.') + '$'
|
|
);
|
|
return regex.test(fullPath);
|
|
});
|
|
|
|
if (matches || patterns.length === 0) {
|
|
results.set(fullPath, value);
|
|
}
|
|
} else if (Array.isArray(value)) {
|
|
// Handle arrays (e.g., benefits[0].description)
|
|
value.forEach((item, index) => {
|
|
if (typeof item === 'object' && item !== null) {
|
|
const nestedResults = extractFields(
|
|
item as Record<string, unknown>,
|
|
patterns,
|
|
`${fullPath}.${index}`
|
|
);
|
|
nestedResults.forEach((v, k) => results.set(k, v));
|
|
}
|
|
});
|
|
} else if (typeof value === 'object' && value !== null) {
|
|
// Recurse into nested objects
|
|
const nestedResults = extractFields(
|
|
value as Record<string, unknown>,
|
|
patterns,
|
|
fullPath
|
|
);
|
|
nestedResults.forEach((v, k) => results.set(k, v));
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Validate economics claims in content
|
|
*/
|
|
function validateEconomics(
|
|
content: string,
|
|
_facts: PlatformFacts
|
|
): { errors: string[]; corrected: string } {
|
|
const errors: string[] = [];
|
|
let corrected = content;
|
|
|
|
// Check for wrong percentages
|
|
for (const { pattern, expected } of ECONOMICS_PATTERNS.wrongPercentages) {
|
|
const matches = content.match(pattern);
|
|
if (matches) {
|
|
errors.push(`Wrong percentage found: "${matches[0]}" should be "${expected}"`);
|
|
corrected = corrected.replace(pattern, expected);
|
|
}
|
|
}
|
|
|
|
// Check for wrong platform fee claims
|
|
for (const { pattern, expected } of ECONOMICS_PATTERNS.platformFee) {
|
|
const matches = content.match(pattern);
|
|
if (matches) {
|
|
errors.push(`Wrong platform fee: "${matches[0]}" should be "${expected}"`);
|
|
corrected = corrected.replace(pattern, expected);
|
|
}
|
|
}
|
|
|
|
return { errors, corrected };
|
|
}
|
|
|
|
/**
|
|
* Validate competitor comparisons
|
|
*/
|
|
function validateCompetitors(
|
|
content: string,
|
|
_facts: PlatformFacts
|
|
): { errors: string[]; corrected: string } {
|
|
const errors: string[] = [];
|
|
let corrected = content;
|
|
|
|
// Check OnlyFans percentage
|
|
const ofMatches = [...content.matchAll(COMPETITOR_PATTERNS.onlyFans.pattern)];
|
|
for (const match of ofMatches) {
|
|
const claimedPct = parseInt(match[2], 10);
|
|
if (claimedPct !== COMPETITOR_PATTERNS.onlyFans.expectedPct) {
|
|
errors.push(
|
|
`Wrong OnlyFans fee: claimed ${claimedPct}%, should be ${COMPETITOR_PATTERNS.onlyFans.expectedPct}%`
|
|
);
|
|
corrected = corrected.replace(
|
|
match[0],
|
|
`OnlyFans ${match[1]} ${COMPETITOR_PATTERNS.onlyFans.expectedPct}%`
|
|
);
|
|
}
|
|
}
|
|
|
|
// Check Chaturbate percentage
|
|
const cbMatches = [...content.matchAll(COMPETITOR_PATTERNS.chaturbate.pattern)];
|
|
for (const match of cbMatches) {
|
|
const claimedPct = parseInt(match[2], 10);
|
|
if (claimedPct !== COMPETITOR_PATTERNS.chaturbate.expectedPct) {
|
|
errors.push(
|
|
`Wrong Chaturbate fee: claimed ${claimedPct}%, should be ${COMPETITOR_PATTERNS.chaturbate.expectedPct}%`
|
|
);
|
|
corrected = corrected.replace(
|
|
match[0],
|
|
`Chaturbate ${match[1]} ${COMPETITOR_PATTERNS.chaturbate.expectedPct}%`
|
|
);
|
|
}
|
|
}
|
|
|
|
return { errors, corrected };
|
|
}
|
|
|
|
/**
|
|
* Validate terminology (check for forbidden terms)
|
|
*/
|
|
function validateTerminology(
|
|
content: string,
|
|
facts: PlatformFacts
|
|
): { errors: string[]; corrected: string } {
|
|
const errors: string[] = [];
|
|
let corrected = content;
|
|
|
|
for (const [forbidden, preferred] of Object.entries(facts.preferredTerms)) {
|
|
const regex = new RegExp(`\\b${forbidden}s?\\b`, 'gi');
|
|
const matches = content.match(regex);
|
|
|
|
if (matches) {
|
|
errors.push(`Forbidden term "${matches[0]}" found, use "${preferred}" instead`);
|
|
corrected = corrected.replace(regex, String(preferred));
|
|
}
|
|
}
|
|
|
|
return { errors, corrected };
|
|
}
|
|
|
|
/**
|
|
* Validate a single field
|
|
*/
|
|
function validateField(
|
|
fieldPath: string,
|
|
value: string,
|
|
meta: TruthValidationMeta,
|
|
facts: PlatformFacts
|
|
): FieldValidationResult {
|
|
const errors: string[] = [];
|
|
let corrected = value;
|
|
let severity: FieldValidationResult['severity'] = 'low';
|
|
|
|
if (meta.validateEconomics) {
|
|
const result = validateEconomics(corrected, facts);
|
|
errors.push(...result.errors);
|
|
corrected = result.corrected;
|
|
if (result.errors.length > 0) severity = 'critical';
|
|
}
|
|
|
|
if (meta.validateCompetitors) {
|
|
const result = validateCompetitors(corrected, facts);
|
|
errors.push(...result.errors);
|
|
corrected = result.corrected;
|
|
if (result.errors.length > 0 && severity !== 'critical') severity = 'high';
|
|
}
|
|
|
|
if (meta.validateTerminology) {
|
|
const result = validateTerminology(corrected, facts);
|
|
errors.push(...result.errors);
|
|
corrected = result.corrected;
|
|
if (result.errors.length > 0 && severity === 'low') severity = 'medium';
|
|
}
|
|
|
|
return {
|
|
field: fieldPath,
|
|
isValid: errors.length === 0,
|
|
originalValue: value,
|
|
correctedValue: corrected !== value ? corrected : undefined,
|
|
errors,
|
|
severity,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validate i18n content against Truth API facts
|
|
*
|
|
* @param content - The i18n namespace content (JSON object)
|
|
* @param meta - Validation metadata (from _truthValidation field)
|
|
* @param facts - Platform facts to validate against
|
|
* @returns Validation result with errors and corrections
|
|
*/
|
|
export function validateI18nContent(
|
|
content: Record<string, unknown>,
|
|
meta?: TruthValidationMeta,
|
|
facts: PlatformFacts = STATIC_PLATFORM_FACTS
|
|
): ValidationResult {
|
|
// If no validation metadata, skip validation
|
|
if (!meta) {
|
|
return {
|
|
isValid: true,
|
|
fieldResults: [],
|
|
totalErrors: 0,
|
|
criticalErrors: 0,
|
|
autoCorrections: 0,
|
|
};
|
|
}
|
|
|
|
// Extract fields to validate
|
|
const fieldsToValidate = extractFields(content, meta.fields || []);
|
|
|
|
// If no specific fields, validate all string fields
|
|
if (fieldsToValidate.size === 0 && !meta.fields) {
|
|
const allFields = extractFields(content, ['*']);
|
|
allFields.forEach((v, k) => fieldsToValidate.set(k, v));
|
|
}
|
|
|
|
// Validate each field
|
|
const fieldResults: FieldValidationResult[] = [];
|
|
|
|
for (const [fieldPath, value] of fieldsToValidate) {
|
|
const result = validateField(fieldPath, value, meta, facts);
|
|
if (result.errors.length > 0 || result.correctedValue) {
|
|
fieldResults.push(result);
|
|
}
|
|
}
|
|
|
|
// Calculate summary
|
|
const totalErrors = fieldResults.reduce((sum, r) => sum + r.errors.length, 0);
|
|
const criticalErrors = fieldResults.filter((r) => r.severity === 'critical').length;
|
|
const autoCorrections = fieldResults.filter((r) => r.correctedValue).length;
|
|
|
|
return {
|
|
isValid: totalErrors === 0,
|
|
fieldResults,
|
|
totalErrors,
|
|
criticalErrors,
|
|
autoCorrections,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Apply auto-corrections to content
|
|
*
|
|
* Returns a new content object with corrections applied.
|
|
*/
|
|
export function applyCorrections(
|
|
content: Record<string, unknown>,
|
|
result: ValidationResult
|
|
): Record<string, unknown> {
|
|
const corrected = JSON.parse(JSON.stringify(content));
|
|
|
|
for (const fieldResult of result.fieldResults) {
|
|
if (fieldResult.correctedValue) {
|
|
// Parse field path and set corrected value
|
|
const parts = fieldResult.field.split('.');
|
|
let current = corrected;
|
|
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
const part = parts[i];
|
|
const index = parseInt(part, 10);
|
|
current = isNaN(index) ? current[part] : current[index];
|
|
}
|
|
|
|
const lastPart = parts[parts.length - 1];
|
|
const lastIndex = parseInt(lastPart, 10);
|
|
if (isNaN(lastIndex)) {
|
|
current[lastPart] = fieldResult.correctedValue;
|
|
} else {
|
|
current[lastIndex] = fieldResult.correctedValue;
|
|
}
|
|
}
|
|
}
|
|
|
|
return corrected;
|
|
}
|
|
|
|
/**
|
|
* Extract validation metadata from i18n content
|
|
*/
|
|
export function extractValidationMeta(
|
|
content: Record<string, unknown>
|
|
): TruthValidationMeta | undefined {
|
|
return content._truthValidation as TruthValidationMeta | undefined;
|
|
}
|