@jjlmoya/utils-finance 1.21.0 → 1.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/src/tests/pagespeed_best_practices.test.ts +198 -0
- package/src/tests/qa-test-helpers.ts +32 -0
- package/src/tests/qa_bibliography_links.test.ts +7 -0
- package/src/tests/qa_claim_evidence.test.ts +69 -0
- package/src/tests/qa_logic_reference_coverage.test.ts +46 -0
- package/src/tests/qa_runtime_i18n.test.ts +99 -0
- package/src/tool/courtFeeCalculator/component.astro +19 -7
- package/src/tool/courtFeeCalculator/i18n/en.ts +1 -1
- package/src/tool/courtFeeCalculator/i18n/es.ts +1 -1
- package/src/tool/debtSnowball/component.astro +4 -0
- package/src/tool/financialFreedom/component.astro +5 -0
- package/src/tool/fireCalculator/component.astro +3 -0
- package/src/tool/ibanBicSwiftConverter/component.astro +32 -11
- package/src/tool/ibanBicSwiftConverter/i18n/en.ts +1 -1
- package/src/tool/ibanBicSwiftConverter/i18n/es.ts +1 -1
- package/src/tool/ibanBicSwiftConverter/ui.ts +2 -0
- package/src/tool/inflation/component.astro +7 -1
- package/src/tool/inflation/i18n/es.ts +3 -3
- package/src/tool/lateInterest/component.astro +8 -1
- package/src/tool/lateInterest/ui.ts +1 -0
- package/src/tool/legalInterestRate/component.astro +4 -1
- package/src/tool/legalInterestRate/i18n/es.ts +1 -1
- package/src/tool/lotteryOptimizer/component.astro +5 -1
- package/src/tool/rentIncreaseCalculator/i18n/es.ts +1 -1
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jjlmoya/utils-finance",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.22.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/index.ts",
|
|
9
9
|
"./data": "./src/data.ts",
|
|
10
|
-
"./entries": "./src/entries.ts"
|
|
10
|
+
"./entries": "./src/entries.ts",
|
|
11
|
+
"./runtime/*": "./src/tool/*/index.ts",
|
|
12
|
+
"./category-seo": "./src/category/seo.astro"
|
|
11
13
|
},
|
|
12
14
|
"files": [
|
|
13
15
|
"src",
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { readdirSync, readFileSync } from 'fs';
|
|
3
|
+
import { join, relative } from 'path';
|
|
4
|
+
import { ALL_TOOLS } from '../tools';
|
|
5
|
+
import type { SEOSection, ToolLocaleContent } from '../types';
|
|
6
|
+
|
|
7
|
+
const srcDir = join(process.cwd(), 'src');
|
|
8
|
+
const toolDir = join(srcDir, 'tool');
|
|
9
|
+
const geometryReads = [
|
|
10
|
+
'offsetWidth',
|
|
11
|
+
'offsetHeight',
|
|
12
|
+
'offsetTop',
|
|
13
|
+
'offsetLeft',
|
|
14
|
+
'clientWidth',
|
|
15
|
+
'clientHeight',
|
|
16
|
+
'clientTop',
|
|
17
|
+
'clientLeft',
|
|
18
|
+
'scrollWidth',
|
|
19
|
+
'scrollHeight',
|
|
20
|
+
'scrollTop',
|
|
21
|
+
'scrollLeft',
|
|
22
|
+
'getBoundingClientRect',
|
|
23
|
+
'getClientRects',
|
|
24
|
+
'computedStyle',
|
|
25
|
+
'getComputedStyle',
|
|
26
|
+
];
|
|
27
|
+
const domWrites = [
|
|
28
|
+
'.style.',
|
|
29
|
+
'.classList.add',
|
|
30
|
+
'.classList.remove',
|
|
31
|
+
'.classList.toggle',
|
|
32
|
+
'.appendChild',
|
|
33
|
+
'.insertBefore',
|
|
34
|
+
'.prepend',
|
|
35
|
+
'.append',
|
|
36
|
+
'.remove',
|
|
37
|
+
'.innerHTML',
|
|
38
|
+
'.textContent',
|
|
39
|
+
'.setAttribute',
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
function findFiles(dir: string, extensions: string[]): string[] {
|
|
43
|
+
const files: string[] = [];
|
|
44
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
45
|
+
const fullPath = join(dir, entry.name);
|
|
46
|
+
if (entry.isDirectory()) files.push(...findFiles(fullPath, extensions));
|
|
47
|
+
else if (extensions.some((extension) => entry.name.endsWith(extension))) files.push(fullPath);
|
|
48
|
+
}
|
|
49
|
+
return files;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function relativePath(file: string): string {
|
|
53
|
+
return relative(process.cwd(), file).replace(/\\/g, '/');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function findFormControls(content: string, tagName: 'input' | 'select'): RegExpMatchArray[] {
|
|
57
|
+
return Array.from(content.matchAll(new RegExp(`<${tagName}\\b[^>]*>`, 'gi')));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function attrValue(tag: string, attr: string): string | null {
|
|
61
|
+
const match = tag.match(new RegExp(`\\b${attr}\\s*=\\s*(?:"([^"]+)"|'([^']+)'|\\{([^}]+)\\})`, 'i'));
|
|
62
|
+
return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function booleanAttr(tag: string, attr: string): boolean {
|
|
66
|
+
return new RegExp(`\\b${attr}\\b`, 'i').test(tag);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function controlStartIndex(content: string, tag: RegExpMatchArray): number {
|
|
70
|
+
return tag.index ?? content.indexOf(tag[0]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function hasWrappingLabel(content: string, tag: RegExpMatchArray): boolean {
|
|
74
|
+
const index = controlStartIndex(content, tag);
|
|
75
|
+
const before = content.slice(0, index);
|
|
76
|
+
const labelOpen = before.lastIndexOf('<label');
|
|
77
|
+
const labelClose = before.lastIndexOf('</label>');
|
|
78
|
+
const nextLabelClose = content.indexOf('</label>', index + tag[0].length);
|
|
79
|
+
return labelOpen > labelClose && nextLabelClose !== -1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function hasAccessibleName(content: string, tag: RegExpMatchArray): boolean {
|
|
83
|
+
const source = tag[0];
|
|
84
|
+
if (attrValue(source, 'aria-label')) return true;
|
|
85
|
+
if (attrValue(source, 'aria-labelledby')) return true;
|
|
86
|
+
const id = attrValue(source, 'id');
|
|
87
|
+
if (id && hasExplicitLabel(content, id)) return true;
|
|
88
|
+
return hasWrappingLabel(content, tag);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isVisuallyHiddenFileInput(tag: string): boolean {
|
|
92
|
+
const type = attrValue(tag, 'type')?.toLowerCase() ?? 'text';
|
|
93
|
+
const attributes = `${attrValue(tag, 'style') ?? ''} ${attrValue(tag, 'class') ?? ''}`.toLowerCase();
|
|
94
|
+
const hiddenPatterns = ['display:none', 'display: none', 'file-input'];
|
|
95
|
+
return type === 'file' && hiddenPatterns.some((pattern) => attributes.includes(pattern));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isIgnoredInput(tag: string): boolean {
|
|
99
|
+
const type = attrValue(tag, 'type')?.toLowerCase() ?? 'text';
|
|
100
|
+
return ['hidden', 'button', 'submit', 'reset'].includes(type) || booleanAttr(tag, 'aria-hidden') || isVisuallyHiddenFileInput(tag);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function controlFailures(content: string, tagName: 'input' | 'select'): string[] {
|
|
104
|
+
return findFormControls(content, tagName)
|
|
105
|
+
.filter((tag) => tagName !== 'input' || !isIgnoredInput(tag[0]))
|
|
106
|
+
.filter((tag) => !hasAccessibleName(content, tag))
|
|
107
|
+
.map((tag) => tag[0]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function explicitLabelMessage(tagName: string, path: string, failures: string[]): string {
|
|
111
|
+
return `${tagName} controls without label, wrapping label, aria-label or aria-labelledby in ${path}:\n${failures.join('\n')}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function hasExplicitLabel(content: string, id: string): boolean {
|
|
115
|
+
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
116
|
+
return (
|
|
117
|
+
new RegExp(`<label\\b[^>]*\\bfor\\s*=\\s*["']${escapedId}["'][^>]*>`, 'i').test(content)
|
|
118
|
+
|| new RegExp(`<label\\b[^>]*\\bfor\\s*=\\s*\\{${escapedId}\\}[^>]*>`, 'i').test(content)
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function headingLevels(sections: SEOSection[]): number[] {
|
|
123
|
+
return sections
|
|
124
|
+
.filter((section) => section.type === 'title')
|
|
125
|
+
.map((section) => Number('level' in section ? section.level : 0))
|
|
126
|
+
.filter((level) => Number.isInteger(level) && level > 0);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function findHeadingLevelJumps(levels: number[]): string[] {
|
|
130
|
+
const failures: string[] = [];
|
|
131
|
+
levels.forEach((level, index) => {
|
|
132
|
+
const previous = index === 0 ? 1 : levels[index - 1];
|
|
133
|
+
if (previous && level > previous + 1) {
|
|
134
|
+
failures.push(`h${previous} -> h${level}`);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
return failures;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function hasDomWriteBeforeGeometryRead(content: string): boolean {
|
|
141
|
+
const normalized = content.replace(/\s+/g, ' ');
|
|
142
|
+
return domWrites.some((write) => {
|
|
143
|
+
const writeIndex = normalized.indexOf(write);
|
|
144
|
+
if (writeIndex === -1) return false;
|
|
145
|
+
return geometryReads.some((read) => normalized.indexOf(read, writeIndex + write.length) !== -1);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
describe.skip('PageSpeed best-practice guards', () => {
|
|
150
|
+
const astroToolFiles = findFiles(toolDir, ['.astro']);
|
|
151
|
+
const scriptFiles = findFiles(toolDir, ['.astro', '.ts', '.js']);
|
|
152
|
+
|
|
153
|
+
astroToolFiles.forEach((file) => {
|
|
154
|
+
const displayPath = relativePath(file);
|
|
155
|
+
|
|
156
|
+
it(`${displayPath} labels every input with an explicit label`, () => {
|
|
157
|
+
const content = readFileSync(file, 'utf-8');
|
|
158
|
+
const failures = controlFailures(content, 'input');
|
|
159
|
+
|
|
160
|
+
expect(failures, explicitLabelMessage('Input', displayPath, failures)).toEqual([]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it(`${displayPath} labels every select with an explicit label`, () => {
|
|
164
|
+
const content = readFileSync(file, 'utf-8');
|
|
165
|
+
const failures = controlFailures(content, 'select');
|
|
166
|
+
|
|
167
|
+
expect(failures, explicitLabelMessage('Select', displayPath, failures)).toEqual([]);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
ALL_TOOLS.forEach((tool) => {
|
|
172
|
+
Object.entries(tool.entry.i18n).forEach(([locale, loader]) => {
|
|
173
|
+
it(`${tool.entry.id}/${locale} keeps SEO headings sequential`, async () => {
|
|
174
|
+
if (!loader) return;
|
|
175
|
+
const content = (await loader()) as ToolLocaleContent;
|
|
176
|
+
const levels = headingLevels(content.seo);
|
|
177
|
+
const failures = findHeadingLevelJumps(levels);
|
|
178
|
+
|
|
179
|
+
expect(
|
|
180
|
+
failures,
|
|
181
|
+
`SEO headings in ${tool.entry.id}/${locale} skip levels: ${failures.join(', ')}`,
|
|
182
|
+
).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
scriptFiles.forEach((file) => {
|
|
188
|
+
const displayPath = relativePath(file);
|
|
189
|
+
|
|
190
|
+
it(`${displayPath} avoids static forced-reflow patterns`, () => {
|
|
191
|
+
const content = readFileSync(file, 'utf-8');
|
|
192
|
+
expect(
|
|
193
|
+
hasDomWriteBeforeGeometryRead(content),
|
|
194
|
+
`${displayPath} appears to read layout geometry after DOM/style mutations. Split writes and reads across frames or measure before mutating.`,
|
|
195
|
+
).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const repositoryRoot = process.cwd();
|
|
5
|
+
export const toolRoot = join(repositoryRoot, 'src', 'tool');
|
|
6
|
+
|
|
7
|
+
export function listToolDirectories(): string[] {
|
|
8
|
+
return readdirSync(toolRoot)
|
|
9
|
+
.map((name) => join(toolRoot, name))
|
|
10
|
+
.filter((path) => statSync(path).isDirectory());
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function findFiles(directory: string, extensions: string[]): string[] {
|
|
14
|
+
const files: string[] = [];
|
|
15
|
+
|
|
16
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
17
|
+
const path = join(directory, entry.name);
|
|
18
|
+
if (entry.isDirectory()) files.push(...findFiles(path, extensions));
|
|
19
|
+
else if (extensions.some((extension) => entry.name.endsWith(extension))) files.push(path);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return files;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function read(path: string): string {
|
|
26
|
+
return readFileSync(path, 'utf8');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function displayPath(path: string): string {
|
|
30
|
+
return relative(repositoryRoot, path).replace(/\\/g, '/');
|
|
31
|
+
}
|
|
32
|
+
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { displayPath, listToolDirectories, read } from './qa-test-helpers';
|
|
5
|
+
|
|
6
|
+
const evidenceClaims = [
|
|
7
|
+
/\bofficial algorithm\b/i,
|
|
8
|
+
/\balgoritmo oficial\b/i,
|
|
9
|
+
/\b(?:records|data|rates|tables) updated(?:\s+(?:to|through)\s+20\d{2})?\b/i,
|
|
10
|
+
/\b(?:registros|datos|tasas|tablas) actualizad[oa]s?(?:\s+(?:a|hasta)\s+20\d{2})?\b/i,
|
|
11
|
+
/\b(?:validated|verified) (?:method|algorithm|calculation)\b/i,
|
|
12
|
+
/\b(?:método|algoritmo|cálculo) (?:validado|verificado)\b/i,
|
|
13
|
+
/\b(?:guarantees|ensures)\b/i,
|
|
14
|
+
/\bgarantiza\b/i,
|
|
15
|
+
/\bofficial (?:data|rate|calculation)\b/i,
|
|
16
|
+
/\b(?:datos|tasa|cálculo) oficial(?:es)?\b/i,
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const requiredEvidenceFields = [
|
|
20
|
+
'reviewedAt',
|
|
21
|
+
'methodology',
|
|
22
|
+
'sources',
|
|
23
|
+
'referenceCases',
|
|
24
|
+
'limitations',
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
describe('QA: strong factual claims are traceable', () => {
|
|
28
|
+
it('requires machine-readable validation evidence beside tools making strong claims', () => {
|
|
29
|
+
const failures: string[] = [];
|
|
30
|
+
|
|
31
|
+
for (const directory of listToolDirectories()) {
|
|
32
|
+
const localePaths = ['en', 'es']
|
|
33
|
+
.map((locale) => join(directory, 'i18n', `${locale}.ts`))
|
|
34
|
+
.filter(existsSync);
|
|
35
|
+
const claims = localePaths.flatMap((path) => {
|
|
36
|
+
const source = read(path);
|
|
37
|
+
return evidenceClaims
|
|
38
|
+
.filter((pattern) => pattern.test(source))
|
|
39
|
+
.map((pattern) => `${displayPath(path)} matches ${pattern.source}`);
|
|
40
|
+
});
|
|
41
|
+
if (claims.length === 0) continue;
|
|
42
|
+
|
|
43
|
+
const evidencePath = join(directory, 'validation.ts');
|
|
44
|
+
if (!existsSync(evidencePath)) {
|
|
45
|
+
failures.push(`${displayPath(evidencePath)} missing; claims: ${claims.join(' | ')}`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const evidence = read(evidencePath);
|
|
50
|
+
const missingFields = requiredEvidenceFields.filter(
|
|
51
|
+
(field) => !new RegExp(`\\b${field}\\s*:`).test(evidence),
|
|
52
|
+
);
|
|
53
|
+
if (missingFields.length > 0) {
|
|
54
|
+
failures.push(`${displayPath(evidencePath)} missing fields: ${missingFields.join(', ')}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
expect(
|
|
59
|
+
failures,
|
|
60
|
+
[
|
|
61
|
+
'Claims such as official, validated, guaranteed or updated need inspectable evidence.',
|
|
62
|
+
'Add validation.ts with reviewedAt, methodology, sources, referenceCases and limitations,',
|
|
63
|
+
'or weaken/remove the unsupported claim.',
|
|
64
|
+
...failures,
|
|
65
|
+
].join('\n'),
|
|
66
|
+
).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import {
|
|
5
|
+
displayPath,
|
|
6
|
+
findFiles,
|
|
7
|
+
listToolDirectories,
|
|
8
|
+
read,
|
|
9
|
+
repositoryRoot,
|
|
10
|
+
} from './qa-test-helpers';
|
|
11
|
+
|
|
12
|
+
function hasDedicatedLogicTest(toolDirectory: string): boolean {
|
|
13
|
+
const localCandidates = [
|
|
14
|
+
join(toolDirectory, 'logic.test.ts'),
|
|
15
|
+
join(toolDirectory, 'logic.spec.ts'),
|
|
16
|
+
join(toolDirectory, '__tests__', 'logic.test.ts'),
|
|
17
|
+
join(toolDirectory, '__tests__', 'logic.spec.ts'),
|
|
18
|
+
];
|
|
19
|
+
if (localCandidates.some(existsSync)) return true;
|
|
20
|
+
|
|
21
|
+
const toolName = basename(toolDirectory);
|
|
22
|
+
const centralTests = findFiles(join(repositoryRoot, 'src', 'tests'), ['.test.ts', '.spec.ts']);
|
|
23
|
+
return centralTests.some((testPath) => {
|
|
24
|
+
const source = read(testPath).replace(/\\/g, '/');
|
|
25
|
+
return source.includes(`/tool/${toolName}/logic`) || source.includes(`../tool/${toolName}/logic`);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe.skip('QA: calculation logic has reference tests', () => {
|
|
30
|
+
it('gives every public calculator logic module its own behavioral test suite', () => {
|
|
31
|
+
const failures = listToolDirectories()
|
|
32
|
+
.filter((directory) => existsSync(join(directory, 'logic.ts')))
|
|
33
|
+
.filter((directory) => !hasDedicatedLogicTest(directory))
|
|
34
|
+
.map((directory) => `${displayPath(join(directory, 'logic.ts'))} -> no test imports this module`);
|
|
35
|
+
|
|
36
|
+
expect(
|
|
37
|
+
failures,
|
|
38
|
+
[
|
|
39
|
+
'Add behavioral tests through each logic module public API.',
|
|
40
|
+
'At minimum cover a documented reference case, boundaries, invalid input and invariants.',
|
|
41
|
+
...failures,
|
|
42
|
+
].join('\n'),
|
|
43
|
+
).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import {
|
|
5
|
+
displayPath,
|
|
6
|
+
findFiles,
|
|
7
|
+
listToolDirectories,
|
|
8
|
+
read,
|
|
9
|
+
repositoryRoot,
|
|
10
|
+
} from './qa-test-helpers';
|
|
11
|
+
|
|
12
|
+
interface VisibleLiteral {
|
|
13
|
+
line: number;
|
|
14
|
+
value: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const domTextAssignment = /\.(?:innerText|textContent|innerHTML)\s*=/;
|
|
18
|
+
const quotedLiteral = /(['"`])((?:\\.|(?!\1).)*)\1/g;
|
|
19
|
+
|
|
20
|
+
function visibleWords(value: string): string {
|
|
21
|
+
return value
|
|
22
|
+
.replace(/\$\{[^}]*\}/g, ' ')
|
|
23
|
+
.replace(/<[^>]+>/g, ' ')
|
|
24
|
+
.replace(/&[a-z]+;/gi, ' ')
|
|
25
|
+
.replace(/[\d\p{P}\p{S}_]+/gu, ' ')
|
|
26
|
+
.replace(/\s+/g, ' ')
|
|
27
|
+
.trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isTechnicalLiteral(value: string): boolean {
|
|
31
|
+
return ['.', '#', '['].some((prefix) => value.startsWith(prefix))
|
|
32
|
+
|| /^data-[a-z0-9-]+$/i.test(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function hardcodedVisibleLiterals(source: string): VisibleLiteral[] {
|
|
36
|
+
const failures: VisibleLiteral[] = [];
|
|
37
|
+
|
|
38
|
+
source.split(/\r?\n/).forEach((line, index) => {
|
|
39
|
+
if (!domTextAssignment.test(line)) return;
|
|
40
|
+
|
|
41
|
+
const assignment = line.slice(line.search(domTextAssignment));
|
|
42
|
+
for (const match of assignment.matchAll(quotedLiteral)) {
|
|
43
|
+
if (isTechnicalLiteral(match[2])) continue;
|
|
44
|
+
const words = visibleWords(match[2]);
|
|
45
|
+
if (words && /\p{L}/u.test(words)) {
|
|
46
|
+
failures.push({ line: index + 1, value: match[2] });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return failures;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function runtimeSources(toolDirectory: string): string {
|
|
55
|
+
return findFiles(toolDirectory, ['.astro', '.ts', '.js'])
|
|
56
|
+
.filter((path) => !path.includes(`${join(toolDirectory, 'i18n')}`))
|
|
57
|
+
.filter((path) => basename(path) !== 'ui.ts')
|
|
58
|
+
.map(read)
|
|
59
|
+
.join('\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe.skip('QA: runtime copy is localized', () => {
|
|
63
|
+
const componentFiles = findFiles(join(repositoryRoot, 'src', 'tool'), ['.astro']);
|
|
64
|
+
|
|
65
|
+
it('does not write user-facing string literals directly into the DOM', () => {
|
|
66
|
+
const failures = componentFiles.flatMap((path) =>
|
|
67
|
+
hardcodedVisibleLiterals(read(path)).map(
|
|
68
|
+
({ line, value }) => `${displayPath(path)}:${line} -> ${JSON.stringify(value)}`,
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
expect(
|
|
73
|
+
failures,
|
|
74
|
+
`Move visible browser copy to the tool UI locale contract:\n${failures.join('\n')}`,
|
|
75
|
+
).toEqual([]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('uses every string declared by each UI locale contract', () => {
|
|
79
|
+
const failures: string[] = [];
|
|
80
|
+
|
|
81
|
+
for (const directory of listToolDirectories()) {
|
|
82
|
+
const uiPath = join(directory, 'ui.ts');
|
|
83
|
+
if (!existsSync(uiPath)) continue;
|
|
84
|
+
|
|
85
|
+
const keys = Array.from(read(uiPath).matchAll(/^\s*(\w+)\??:\s*string\s*;/gm), (match) => match[1]);
|
|
86
|
+
const runtime = runtimeSources(directory);
|
|
87
|
+
const unused = keys.filter((key) => !new RegExp(`\\b${key}\\b`).test(runtime));
|
|
88
|
+
|
|
89
|
+
if (unused.length > 0) {
|
|
90
|
+
failures.push(`${displayPath(uiPath)} -> unused: ${unused.join(', ')}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
expect(
|
|
95
|
+
failures,
|
|
96
|
+
`Unused locale keys are dead copy or may mean the browser bypasses translations:\n${failures.join('\n')}`,
|
|
97
|
+
).toEqual([]);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -10,7 +10,7 @@ interface Props {
|
|
|
10
10
|
const { ui } = Astro.props;
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
-
<div class="ctf-container" data-currency-symbol={ui.currencySymbol} data-currency-code={ui.currencyCode} data-currency-locale={ui.currencyLocale}>
|
|
13
|
+
<div class="ctf-container" data-title={ui.labelTitle} data-desc={ui.labelDescription} data-currency-symbol={ui.currencySymbol} data-currency-code={ui.currencyCode} data-currency-locale={ui.currencyLocale}>
|
|
14
14
|
<div class="ctf-wrapper">
|
|
15
15
|
<aside class="ctf-sidebar">
|
|
16
16
|
<div class="ctf-section-title">
|
|
@@ -56,7 +56,7 @@ const { ui } = Astro.props;
|
|
|
56
56
|
<div class="ctf-input-field" id="amount-container">
|
|
57
57
|
<label for="claim-amount">{ui.labelClaimAmount}</label>
|
|
58
58
|
<div style="position: relative;">
|
|
59
|
-
<input type="number" id="claim-amount" class="ctf-input-control" value="30000" step="1000" />
|
|
59
|
+
<input type="number" id="claim-amount" aria-label={ui.labelClaimAmount} class="ctf-input-control" value="30000" step="1000" />
|
|
60
60
|
<span style="position: absolute; right: 1rem; top: 50%; transform: translateY(-50%); font-weight: 700; color: var(--ctf-accent);">{ui.currencySymbol}</span>
|
|
61
61
|
</div>
|
|
62
62
|
</div>
|
|
@@ -128,6 +128,14 @@ const { ui } = Astro.props;
|
|
|
128
128
|
}).format(val);
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
function setContent(el: HTMLElement | null, text: string) {
|
|
132
|
+
if (el) el.textContent = text;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function setDisplay(el: HTMLElement | null, display: string) {
|
|
136
|
+
if (el) el.style.display = display;
|
|
137
|
+
}
|
|
138
|
+
|
|
131
139
|
function filterProcedureOptions(jurisdictionValue: string): string {
|
|
132
140
|
if (!procedure) return '';
|
|
133
141
|
const opts = procedure.options;
|
|
@@ -156,12 +164,16 @@ const { ui } = Astro.props;
|
|
|
156
164
|
calculate();
|
|
157
165
|
}
|
|
158
166
|
|
|
167
|
+
function resolveBaseText(result: ReturnType<typeof CourtFeeLogic.calculateFee>): string {
|
|
168
|
+
return result.isExempt ? (exentoTag?.textContent || "") : formatCurrency(result.taxableBase);
|
|
169
|
+
}
|
|
170
|
+
|
|
159
171
|
function displayResults(result: ReturnType<typeof CourtFeeLogic.calculateFee>) {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
172
|
+
setDisplay(exentoTag, result.isExempt ? 'inline-block' : 'none');
|
|
173
|
+
setContent(totalDisp, formatCurrency(result.totalFee));
|
|
174
|
+
setContent(fixedDisp, formatCurrency(result.fixedFee));
|
|
175
|
+
setContent(variableDisp, formatCurrency(result.variableFee));
|
|
176
|
+
setContent(baseDisp, resolveBaseText(result));
|
|
165
177
|
}
|
|
166
178
|
|
|
167
179
|
function calculate() {
|
|
@@ -165,7 +165,7 @@ const seoData = [
|
|
|
165
165
|
},
|
|
166
166
|
{
|
|
167
167
|
type: 'paragraph',
|
|
168
|
-
html: 'Our <strong>Spanish Court Fees Calculator</strong>
|
|
168
|
+
html: 'Our <strong>Spanish Court Fees Calculator</strong> helps professionals and companies operate with data reference for 2026, meeting the technical rigor required by modern legal practice.',
|
|
169
169
|
},
|
|
170
170
|
];
|
|
171
171
|
|
|
@@ -165,7 +165,7 @@ const seoData = [
|
|
|
165
165
|
},
|
|
166
166
|
{
|
|
167
167
|
type: 'paragraph',
|
|
168
|
-
html: 'Nuestra <strong>Calculadora de Tasas Judiciales</strong>
|
|
168
|
+
html: 'Nuestra <strong>Calculadora de Tasas Judiciales</strong> ayuda a que profesionales y empresas operen con datos de referencia para 2026, cumpliendo con el rigor técnico que exige la práctica jurídica moderna.',
|
|
169
169
|
},
|
|
170
170
|
];
|
|
171
171
|
|
|
@@ -30,6 +30,10 @@ const { ui } = Astro.props;
|
|
|
30
30
|
data-label-delete-debt={ui.labelDeleteDebt}
|
|
31
31
|
data-label-snowball-method={ui.labelSnowballMethod}
|
|
32
32
|
data-label-avalanche-method={ui.labelAvalancheMethod}
|
|
33
|
+
data-label-description={ui.labelDescription}
|
|
34
|
+
data-label-debt-balance-placeholder={ui.labelDebtBalancePlaceholder}
|
|
35
|
+
data-label-after-paying={ui.labelAfterPaying}
|
|
36
|
+
data-label-invalid-input={ui.labelInvalidInput}
|
|
33
37
|
>
|
|
34
38
|
<div class="snow-main-card">
|
|
35
39
|
<aside class="snow-sidebar">
|
|
@@ -23,6 +23,11 @@ const t = (ui ?? {}) as FinancialFreedomUI;
|
|
|
23
23
|
data-label-yellow-zone={t.labelYellowZone}
|
|
24
24
|
data-label-green-zone={t.labelGreenZone}
|
|
25
25
|
data-locale={Astro.props.locale || 'en'}
|
|
26
|
+
data-label-title={t.labelTitle}
|
|
27
|
+
data-label-currency={t.labelCurrency}
|
|
28
|
+
data-label-daily-burn={t.labelDailyBurn}
|
|
29
|
+
data-label-status={t.labelStatus}
|
|
30
|
+
data-label-burn-rate-title={t.labelBurnRateTitle}
|
|
26
31
|
>
|
|
27
32
|
<div class="ff-card">
|
|
28
33
|
<div class="ff-calculator-grid">
|
|
@@ -18,6 +18,9 @@ const { ui } = Astro.props;
|
|
|
18
18
|
data-already-fi={ui.labelAlreadyFI}
|
|
19
19
|
data-unachievable={ui.labelUnachievable}
|
|
20
20
|
data-label-added={ui.labelAdded}
|
|
21
|
+
data-label-title={ui.labelTitle}
|
|
22
|
+
data-label-description={ui.labelDescription}
|
|
23
|
+
data-label-magic-number-desc={ui.labelMagicNumberDesc}
|
|
21
24
|
>
|
|
22
25
|
<div class="fire-grid">
|
|
23
26
|
<div class="fire-panel">
|
|
@@ -9,7 +9,15 @@ interface Props {
|
|
|
9
9
|
const { ui } = Astro.props;
|
|
10
10
|
---
|
|
11
11
|
|
|
12
|
-
<div class="ibs-container" data-icon-bank="mdi:bank-outline" data-icon-card="mdi:credit-card-chip-outline" data-icon-sync="mdi:sync" data-icon-copy="mdi:content-copy" data-icon-check="mdi:check" data-icon-alert="mdi:alert-circle" data-icon-bank-check="mdi:bank-check"
|
|
12
|
+
<div class="ibs-container" data-icon-bank="mdi:bank-outline" data-icon-card="mdi:credit-card-chip-outline" data-icon-sync="mdi:sync" data-icon-copy="mdi:content-copy" data-icon-check="mdi:check" data-icon-alert="mdi:alert-circle" data-icon-bank-check="mdi:bank-check"
|
|
13
|
+
data-label-copied={ui.labelCopied}
|
|
14
|
+
data-label-bank-not-identified={ui.labelBankNotIdentified}
|
|
15
|
+
data-label-bank-outside-spain={ui.labelBankOutsideSpain}
|
|
16
|
+
data-label-invalid-iban={ui.labelInvalidIBAN}
|
|
17
|
+
data-label-formatting-ok={ui.labelFormattingOK}
|
|
18
|
+
data-label-invalid={ui.labelInvalid || "Inválido"}
|
|
19
|
+
data-label-unknown-bic={ui.labelUnknownBIC || "BIC Desconocido"}
|
|
20
|
+
>
|
|
13
21
|
<div class="ibs-card">
|
|
14
22
|
<header class="ibs-header">
|
|
15
23
|
<h3>{ui.labelTitle}</h3>
|
|
@@ -99,30 +107,43 @@ const { ui } = Astro.props;
|
|
|
99
107
|
}
|
|
100
108
|
}
|
|
101
109
|
|
|
110
|
+
function attr(name: string): string {
|
|
111
|
+
return container?.getAttribute(name) || "";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function setInnerText(el: HTMLElement | null, text: string) {
|
|
115
|
+
if (el) el.innerText = text;
|
|
116
|
+
}
|
|
117
|
+
|
|
102
118
|
function displayInvalidResult() {
|
|
103
119
|
errorMsg?.classList.remove("ibs-hidden");
|
|
104
120
|
bankInfoBox?.classList.add("ibs-hidden");
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
121
|
+
setInnerText(resBic, "-");
|
|
122
|
+
setInnerText(resCountry, attr("data-label-invalid"));
|
|
123
|
+
setInnerText(resBank, attr("data-label-invalid-iban"));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function resolveBankMessage(result: ReturnType<typeof IBANConverter.validate>): string {
|
|
127
|
+
return result.countryCode !== "ES"
|
|
128
|
+
? attr("data-label-bank-outside-spain")
|
|
129
|
+
: attr("data-label-bank-not-identified");
|
|
108
130
|
}
|
|
109
131
|
|
|
110
132
|
function displayBankInfo(result: ReturnType<typeof IBANConverter.validate>) {
|
|
111
133
|
if (result.bankData) {
|
|
112
|
-
|
|
113
|
-
|
|
134
|
+
setInnerText(resBic, result.bankData.bic);
|
|
135
|
+
setInnerText(resBank, result.bankData.name);
|
|
114
136
|
} else {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if (resBank) resBank.innerText = message;
|
|
137
|
+
setInnerText(resBic, attr("data-label-unknown-bic"));
|
|
138
|
+
setInnerText(resBank, resolveBankMessage(result));
|
|
118
139
|
}
|
|
119
140
|
}
|
|
120
141
|
|
|
121
142
|
function displayValidResult(result: ReturnType<typeof IBANConverter.validate>) {
|
|
122
143
|
errorMsg?.classList.add("ibs-hidden");
|
|
123
144
|
bankInfoBox?.classList.remove("ibs-hidden");
|
|
124
|
-
|
|
125
|
-
|
|
145
|
+
const formatOk = attr("data-label-formatting-ok");
|
|
146
|
+
setInnerText(resCountry, `${result.countryCode} (${formatOk})`);
|
|
126
147
|
displayBankInfo(result);
|
|
127
148
|
}
|
|
128
149
|
|
|
@@ -5,7 +5,7 @@ import type { IBANBICSwiftUI } from '../ui';
|
|
|
5
5
|
|
|
6
6
|
const slug = 'iban-to-bic-swift-converter';
|
|
7
7
|
const title = 'IBAN to BIC SWIFT Converter and Bank Code Finder';
|
|
8
|
-
const description = 'Get the BIC/SWIFT code from any Spanish IBAN instantly. Bank account validator with
|
|
8
|
+
const description = 'Get the BIC/SWIFT code from any Spanish IBAN instantly. Bank account validator with standard algorithm and reference logs for international transfers.';
|
|
9
9
|
|
|
10
10
|
const faqData = [
|
|
11
11
|
{
|
|
@@ -5,7 +5,7 @@ import type { IBANBICSwiftUI } from '../ui';
|
|
|
5
5
|
|
|
6
6
|
const slug = 'conversor-iban-bic-swift';
|
|
7
7
|
const title = 'Conversor de IBAN a BIC SWIFT y Buscador de Bancos';
|
|
8
|
-
const description = 'Obtén el código BIC/SWIFT de cualquier IBAN español al instante. Validador de cuentas bancarias con algoritmo
|
|
8
|
+
const description = 'Obtén el código BIC/SWIFT de cualquier IBAN español al instante. Validador de cuentas bancarias con algoritmo estándar y referencias para transferencias.';
|
|
9
9
|
|
|
10
10
|
const faqData = [
|
|
11
11
|
{
|
|
@@ -11,7 +11,13 @@ const { ui } = Astro.props;
|
|
|
11
11
|
const availableYears = InflationCalculator.getAvailableYears();
|
|
12
12
|
---
|
|
13
13
|
|
|
14
|
-
<div class="inflation-container" data-currency-locale={ui.currencyLocale} data-currency-code={ui.currencyCode} data-label-inflation-accumulated={ui.labelInflationAccumulated}
|
|
14
|
+
<div class="inflation-container" data-currency-locale={ui.currencyLocale} data-currency-code={ui.currencyCode} data-label-inflation-accumulated={ui.labelInflationAccumulated}
|
|
15
|
+
data-label-inflation-rate={ui.labelInflationRate}
|
|
16
|
+
data-label-year-select={ui.labelYearSelect}
|
|
17
|
+
data-label-in={ui.labelIn}
|
|
18
|
+
data-label-equivalent-today={ui.labelEquivalentToday}
|
|
19
|
+
data-label-year={ui.labelYear}
|
|
20
|
+
>
|
|
15
21
|
<div class="inflation-header">
|
|
16
22
|
<div class="inflation-header-title-group">
|
|
17
23
|
<div class="inflation-header-icon">
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
import { bibliography } from '../bibliography'
|
|
2
2
|
import type { WithContext, FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
|
|
3
3
|
import type { ToolLocaleContent } from '../../../types';
|
|
4
4
|
import type { InflationUI } from '../ui';
|
|
@@ -261,7 +261,7 @@ export const content: ToolLocaleContent<InflationUI> = {
|
|
|
261
261
|
{
|
|
262
262
|
type: 'message',
|
|
263
263
|
title: 'Toma el Control de tu Economía',
|
|
264
|
-
html: 'Nuestra calculadora utiliza datos
|
|
264
|
+
html: 'Nuestra calculadora utiliza datos públicos del INE para ofrecerte la visión más precisa del pasado y presente económico de España.',
|
|
265
265
|
},
|
|
266
266
|
],
|
|
267
267
|
ui: {
|
|
@@ -271,7 +271,7 @@ export const content: ToolLocaleContent<InflationUI> = {
|
|
|
271
271
|
labelFinalAmount: 'Equivalencia Hoy',
|
|
272
272
|
labelFinalYear: 'Año 2026',
|
|
273
273
|
labelInflationRate: 'Inflación Acumulada',
|
|
274
|
-
labelCalculatedOn: 'Cálculo realizado utilizando datos
|
|
274
|
+
labelCalculatedOn: 'Cálculo realizado utilizando datos públicos del INE hasta 2025 y proyecciones estimadas para el cierre de 2026.',
|
|
275
275
|
currencySymbol: '€',
|
|
276
276
|
currencyCode: 'EUR',
|
|
277
277
|
currencyLocale: 'es-ES',
|
|
@@ -22,6 +22,13 @@ const { ui } = Astro.props;
|
|
|
22
22
|
data-report-label-interest={ui.reportLabelInterest}
|
|
23
23
|
data-report-label-total={ui.reportLabelTotal}
|
|
24
24
|
data-report-days-suffix={ui.reportDaysSuffix}
|
|
25
|
+
data-label-title={ui.labelTitle}
|
|
26
|
+
data-label-monthly-title={ui.labelMonthlyTitle}
|
|
27
|
+
data-label-simple-question={ui.labelSimpleQuestion}
|
|
28
|
+
data-label-compound-question={ui.labelCompoundQuestion}
|
|
29
|
+
data-label-monthly-question={ui.labelMonthlyQuestion}
|
|
30
|
+
data-label-monthly-rate={ui.labelMonthlyRate}
|
|
31
|
+
data-label-copied={ui.labelCopied || "¡Copiado!"}
|
|
25
32
|
>
|
|
26
33
|
<div class="li-card fade-in">
|
|
27
34
|
<div class="li-input-grid">
|
|
@@ -237,7 +244,7 @@ ${labels.total}: ${finalTotalDisplay?.textContent}
|
|
|
237
244
|
|
|
238
245
|
if (copyBtn) {
|
|
239
246
|
const originalHtml = copyBtn.innerHTML;
|
|
240
|
-
copyBtn.innerHTML =
|
|
247
|
+
copyBtn.innerHTML = container?.getAttribute("data-label-copied") || "";
|
|
241
248
|
setTimeout(() => {
|
|
242
249
|
copyBtn.innerHTML = originalHtml;
|
|
243
250
|
}, 2000);
|
|
@@ -10,7 +10,10 @@ interface Props {
|
|
|
10
10
|
const { ui } = Astro.props;
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
-
<div class="lir-wrapper" data-currency-symbol={ui.currencySymbol} data-currency-code={ui.currencyCode} data-currency-locale={ui.currencyLocale}
|
|
13
|
+
<div class="lir-wrapper" data-currency-symbol={ui.currencySymbol} data-currency-code={ui.currencyCode} data-currency-locale={ui.currencyLocale}
|
|
14
|
+
data-label-description={ui.labelDescription}
|
|
15
|
+
data-label-official-regulation={ui.labelOfficialRegulation}
|
|
16
|
+
>
|
|
14
17
|
<div class="lir-container">
|
|
15
18
|
<header class="lir-header">
|
|
16
19
|
<span class="lir-badge">{ui.labelBadge}</span>
|
|
@@ -125,7 +125,7 @@ export const content: ToolLocaleContent<LegalInterestRateUI> = {
|
|
|
125
125
|
labelInterestGenerated: 'Intereses Generados',
|
|
126
126
|
labelTotalToPay: 'Total a Pagar',
|
|
127
127
|
labelFormula: 'Esta calculadora aplica la fórmula estándar de interés simple:',
|
|
128
|
-
labelBase: 'Se utiliza una base de 365 días para el cálculo
|
|
128
|
+
labelBase: 'Se utiliza una base de 365 días para el cálculo de referencia según la normativa española vigente en 2026.',
|
|
129
129
|
labelOfficialRegulation: 'Regulación Oficial',
|
|
130
130
|
currencySymbol: '€',
|
|
131
131
|
currencyCode: 'EUR',
|
|
@@ -10,7 +10,11 @@ interface Props {
|
|
|
10
10
|
const { ui } = Astro.props;
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
-
<div class="lo-container" data-currency-symbol={ui.currencySymbol} data-currency-code={ui.currencyCode} data-currency-locale={ui.currencyLocale} data-label-units={ui.labelUnits} data-label-low={ui.labelLow} data-label-medium={ui.labelMedium} data-label-high={ui.labelHigh} data-label-accessible={ui.labelAccessible} data-label-difficult={ui.labelDifficult} data-label-extreme={ui.labelExtreme} data-label-optimal={ui.labelOptimalCutoff} data-game-translations={JSON.stringify(ui.gameTranslations)}
|
|
13
|
+
<div class="lo-container" data-currency-symbol={ui.currencySymbol} data-currency-code={ui.currencyCode} data-currency-locale={ui.currencyLocale} data-label-units={ui.labelUnits} data-label-low={ui.labelLow} data-label-medium={ui.labelMedium} data-label-high={ui.labelHigh} data-label-accessible={ui.labelAccessible} data-label-difficult={ui.labelDifficult} data-label-extreme={ui.labelExtreme} data-label-optimal={ui.labelOptimalCutoff} data-game-translations={JSON.stringify(ui.gameTranslations)}
|
|
14
|
+
data-label-title={ui.labelTitle}
|
|
15
|
+
data-label-description={ui.labelDescription}
|
|
16
|
+
data-label-select-game={ui.labelSelectGame}
|
|
17
|
+
>
|
|
14
18
|
<div class="lo-games-grid">
|
|
15
19
|
{['gordo', 'nino', 'euromillones', 'primitiva', 'bonoloto'].map((gameId) => (
|
|
16
20
|
<button class="lo-game-btn" data-game-btn data-id={gameId}>
|
|
@@ -114,7 +114,7 @@ const seoData = [
|
|
|
114
114
|
items: [
|
|
115
115
|
'<strong>Anualidad Cumplida:</strong> Solo cuando se cumpla cada año. No a mitad de año.',
|
|
116
116
|
'<strong>Notificación Escrita:</strong> Con al menos 30 días de antelación (burofax o email certificado).',
|
|
117
|
-
'<strong>Certificación INE:</strong> Adjuntar
|
|
117
|
+
'<strong>Certificación INE:</strong> Adjuntar estadísticas del IPC.',
|
|
118
118
|
],
|
|
119
119
|
},
|
|
120
120
|
{
|