@jjlmoya/utils-finance 1.20.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/compoundInterest/compound-interest-calculator.css +75 -2
- 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/courtFeeCalculator/spanish-court-fees-calculator.css +16 -0
- 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 +34 -12
- package/src/tool/ibanBicSwiftConverter/i18n/en.ts +1 -1
- package/src/tool/ibanBicSwiftConverter/i18n/es.ts +1 -1
- package/src/tool/ibanBicSwiftConverter/iban-to-bic-swift-converter.css +7 -4
- 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
|
+
});
|
|
@@ -22,6 +22,16 @@
|
|
|
22
22
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
|
23
23
|
border: 1px solid var(--cic-border-subtle);
|
|
24
24
|
overflow: hidden;
|
|
25
|
+
width: 100%;
|
|
26
|
+
max-width: 100%;
|
|
27
|
+
min-width: 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.cic-root,
|
|
31
|
+
.cic-root *,
|
|
32
|
+
.cic-root *::before,
|
|
33
|
+
.cic-root *::after {
|
|
34
|
+
box-sizing: border-box;
|
|
25
35
|
}
|
|
26
36
|
|
|
27
37
|
.theme-dark .cic-root {
|
|
@@ -52,12 +62,14 @@
|
|
|
52
62
|
gap: 1rem;
|
|
53
63
|
align-items: center;
|
|
54
64
|
justify-content: space-between;
|
|
65
|
+
min-width: 0;
|
|
55
66
|
}
|
|
56
67
|
|
|
57
68
|
.cic-header-left {
|
|
58
69
|
display: flex;
|
|
59
70
|
align-items: center;
|
|
60
71
|
gap: 0.75rem;
|
|
72
|
+
min-width: 0;
|
|
61
73
|
}
|
|
62
74
|
|
|
63
75
|
.cic-icon-wrap {
|
|
@@ -68,6 +80,7 @@
|
|
|
68
80
|
display: flex;
|
|
69
81
|
align-items: center;
|
|
70
82
|
justify-content: center;
|
|
83
|
+
flex-shrink: 0;
|
|
71
84
|
}
|
|
72
85
|
|
|
73
86
|
.cic-icon {
|
|
@@ -79,6 +92,7 @@
|
|
|
79
92
|
font-size: 1.25rem;
|
|
80
93
|
font-weight: 700;
|
|
81
94
|
color: var(--cic-text);
|
|
95
|
+
overflow-wrap: anywhere;
|
|
82
96
|
}
|
|
83
97
|
|
|
84
98
|
.cic-realtime {
|
|
@@ -104,6 +118,7 @@
|
|
|
104
118
|
|
|
105
119
|
.cic-grid {
|
|
106
120
|
display: grid;
|
|
121
|
+
min-width: 0;
|
|
107
122
|
}
|
|
108
123
|
|
|
109
124
|
.cic-panel-left {
|
|
@@ -113,18 +128,21 @@
|
|
|
113
128
|
gap: 1.5rem;
|
|
114
129
|
background: var(--cic-bg-panel);
|
|
115
130
|
border-bottom: 1px solid var(--cic-border-subtle);
|
|
131
|
+
min-width: 0;
|
|
116
132
|
}
|
|
117
133
|
|
|
118
134
|
.cic-fields {
|
|
119
135
|
display: flex;
|
|
120
136
|
flex-direction: column;
|
|
121
137
|
gap: 1rem;
|
|
138
|
+
min-width: 0;
|
|
122
139
|
}
|
|
123
140
|
|
|
124
141
|
.cic-grid-2 {
|
|
125
142
|
display: grid;
|
|
126
|
-
grid-template-columns: 1fr 1fr;
|
|
143
|
+
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
127
144
|
gap: 1rem;
|
|
145
|
+
min-width: 0;
|
|
128
146
|
}
|
|
129
147
|
|
|
130
148
|
.cic-label {
|
|
@@ -139,6 +157,7 @@
|
|
|
139
157
|
|
|
140
158
|
.cic-input-wrap {
|
|
141
159
|
position: relative;
|
|
160
|
+
min-width: 0;
|
|
142
161
|
}
|
|
143
162
|
|
|
144
163
|
.cic-currency {
|
|
@@ -168,7 +187,7 @@
|
|
|
168
187
|
color: var(--cic-text);
|
|
169
188
|
font-weight: 700;
|
|
170
189
|
font-size: 1.125rem;
|
|
171
|
-
|
|
190
|
+
min-width: 0;
|
|
172
191
|
}
|
|
173
192
|
|
|
174
193
|
.cic-input-prefixed {
|
|
@@ -192,6 +211,7 @@
|
|
|
192
211
|
display: flex;
|
|
193
212
|
flex-direction: column;
|
|
194
213
|
gap: 0.75rem;
|
|
214
|
+
min-width: 0;
|
|
195
215
|
}
|
|
196
216
|
|
|
197
217
|
.cic-summary-row {
|
|
@@ -199,12 +219,15 @@
|
|
|
199
219
|
justify-content: space-between;
|
|
200
220
|
align-items: center;
|
|
201
221
|
font-size: 0.875rem;
|
|
222
|
+
gap: 0.75rem;
|
|
223
|
+
min-width: 0;
|
|
202
224
|
}
|
|
203
225
|
|
|
204
226
|
.cic-summary-label {
|
|
205
227
|
display: flex;
|
|
206
228
|
align-items: center;
|
|
207
229
|
gap: 0.5rem;
|
|
230
|
+
min-width: 0;
|
|
208
231
|
}
|
|
209
232
|
|
|
210
233
|
.cic-dot-neutral {
|
|
@@ -235,6 +258,8 @@
|
|
|
235
258
|
.cic-summary-value {
|
|
236
259
|
font-weight: 700;
|
|
237
260
|
color: var(--cic-text);
|
|
261
|
+
overflow-wrap: anywhere;
|
|
262
|
+
text-align: right;
|
|
238
263
|
}
|
|
239
264
|
|
|
240
265
|
.cic-summary-value-accent {
|
|
@@ -248,6 +273,8 @@
|
|
|
248
273
|
display: flex;
|
|
249
274
|
justify-content: space-between;
|
|
250
275
|
align-items: flex-end;
|
|
276
|
+
gap: 1rem;
|
|
277
|
+
min-width: 0;
|
|
251
278
|
}
|
|
252
279
|
|
|
253
280
|
.cic-total-label {
|
|
@@ -262,6 +289,8 @@
|
|
|
262
289
|
font-size: 1.5rem;
|
|
263
290
|
font-weight: 900;
|
|
264
291
|
color: var(--cic-text);
|
|
292
|
+
overflow-wrap: anywhere;
|
|
293
|
+
text-align: right;
|
|
265
294
|
}
|
|
266
295
|
|
|
267
296
|
.cic-panel-right {
|
|
@@ -269,12 +298,56 @@
|
|
|
269
298
|
display: flex;
|
|
270
299
|
flex-direction: column;
|
|
271
300
|
min-height: 500px;
|
|
301
|
+
min-width: 0;
|
|
272
302
|
}
|
|
273
303
|
|
|
274
304
|
.cic-canvas-wrap {
|
|
275
305
|
position: relative;
|
|
276
306
|
width: 100%;
|
|
277
307
|
flex: 1;
|
|
308
|
+
min-width: 0;
|
|
309
|
+
min-height: 280px;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
.cic-canvas-wrap canvas {
|
|
313
|
+
max-width: 100%;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
@media (max-width: 640px) {
|
|
317
|
+
.cic-root {
|
|
318
|
+
border-radius: 1.25rem;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.cic-header,
|
|
322
|
+
.cic-panel-left,
|
|
323
|
+
.cic-panel-right {
|
|
324
|
+
padding: 1rem;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
.cic-header {
|
|
328
|
+
align-items: flex-start;
|
|
329
|
+
flex-direction: column;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
.cic-grid-2 {
|
|
333
|
+
grid-template-columns: minmax(0, 1fr);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
.cic-input {
|
|
337
|
+
font-size: 1rem;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
.cic-input-prefixed {
|
|
341
|
+
padding-left: 2.5rem;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
.cic-input-suffixed {
|
|
345
|
+
padding-right: 2.5rem;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
.cic-panel-right {
|
|
349
|
+
min-height: 320px;
|
|
350
|
+
}
|
|
278
351
|
}
|
|
279
352
|
|
|
280
353
|
.cic-insight {
|
|
@@ -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
|
|
|
@@ -114,6 +114,22 @@
|
|
|
114
114
|
transition: all 0.2s;
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
select.ctf-input-control {
|
|
118
|
+
appearance: none;
|
|
119
|
+
background-color: var(--ctf-bg-input);
|
|
120
|
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20' fill='none'%3E%3Cpath d='M5 7.5L10 12.5L15 7.5' stroke='%23f1f5f9' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
|
121
|
+
background-position: right 0.875rem center;
|
|
122
|
+
background-repeat: no-repeat;
|
|
123
|
+
background-size: 1rem;
|
|
124
|
+
line-height: 1.25;
|
|
125
|
+
min-height: 2.75rem;
|
|
126
|
+
padding-right: 2.75rem;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.theme-light select.ctf-input-control {
|
|
130
|
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20' fill='none'%3E%3Cpath d='M5 7.5L10 12.5L15 7.5' stroke='%231e293b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
|
131
|
+
}
|
|
132
|
+
|
|
117
133
|
.ctf-input-control:focus {
|
|
118
134
|
outline: none;
|
|
119
135
|
border-color: var(--ctf-accent);
|
|
@@ -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>
|
|
@@ -27,7 +35,7 @@ const { ui } = Astro.props;
|
|
|
27
35
|
id="iban-input"
|
|
28
36
|
placeholder={ui.labelInputPlaceholder}
|
|
29
37
|
autocomplete="off"
|
|
30
|
-
maxlength="
|
|
38
|
+
maxlength="42"
|
|
31
39
|
/>
|
|
32
40
|
<span class="ibs-input-icon" data-icon="card"></span>
|
|
33
41
|
</div>
|
|
@@ -91,6 +99,7 @@ const { ui } = Astro.props;
|
|
|
91
99
|
formatted += value[i];
|
|
92
100
|
}
|
|
93
101
|
principalInput.value = formatted;
|
|
102
|
+
principalInput.scrollLeft = principalInput.scrollWidth;
|
|
94
103
|
|
|
95
104
|
errorMsg?.classList.add("ibs-hidden");
|
|
96
105
|
if (value.length === 0) {
|
|
@@ -98,30 +107,43 @@ const { ui } = Astro.props;
|
|
|
98
107
|
}
|
|
99
108
|
}
|
|
100
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
|
+
|
|
101
118
|
function displayInvalidResult() {
|
|
102
119
|
errorMsg?.classList.remove("ibs-hidden");
|
|
103
120
|
bankInfoBox?.classList.add("ibs-hidden");
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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");
|
|
107
130
|
}
|
|
108
131
|
|
|
109
132
|
function displayBankInfo(result: ReturnType<typeof IBANConverter.validate>) {
|
|
110
133
|
if (result.bankData) {
|
|
111
|
-
|
|
112
|
-
|
|
134
|
+
setInnerText(resBic, result.bankData.bic);
|
|
135
|
+
setInnerText(resBank, result.bankData.name);
|
|
113
136
|
} else {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (resBank) resBank.innerText = message;
|
|
137
|
+
setInnerText(resBic, attr("data-label-unknown-bic"));
|
|
138
|
+
setInnerText(resBank, resolveBankMessage(result));
|
|
117
139
|
}
|
|
118
140
|
}
|
|
119
141
|
|
|
120
142
|
function displayValidResult(result: ReturnType<typeof IBANConverter.validate>) {
|
|
121
143
|
errorMsg?.classList.add("ibs-hidden");
|
|
122
144
|
bankInfoBox?.classList.remove("ibs-hidden");
|
|
123
|
-
|
|
124
|
-
|
|
145
|
+
const formatOk = attr("data-label-formatting-ok");
|
|
146
|
+
setInnerText(resCountry, `${result.countryCode} (${formatOk})`);
|
|
125
147
|
displayBankInfo(result);
|
|
126
148
|
}
|
|
127
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
|
{
|
|
@@ -189,6 +189,7 @@
|
|
|
189
189
|
border: 2px solid var(--ibs-border-input);
|
|
190
190
|
transition: all 0.3s ease;
|
|
191
191
|
min-width: 0;
|
|
192
|
+
width: 100%;
|
|
192
193
|
}
|
|
193
194
|
|
|
194
195
|
.ibs-input-wrapper:focus-within {
|
|
@@ -207,6 +208,9 @@
|
|
|
207
208
|
outline: none;
|
|
208
209
|
letter-spacing: 0.02em;
|
|
209
210
|
min-width: 0;
|
|
211
|
+
overflow-x: auto;
|
|
212
|
+
text-overflow: clip;
|
|
213
|
+
white-space: nowrap;
|
|
210
214
|
}
|
|
211
215
|
|
|
212
216
|
.ibs-input-wrapper input::placeholder {
|
|
@@ -454,13 +458,12 @@
|
|
|
454
458
|
}
|
|
455
459
|
|
|
456
460
|
.ibs-input-wrapper input {
|
|
457
|
-
padding: 1rem;
|
|
458
|
-
font-size:
|
|
461
|
+
padding: 0.875rem 1rem;
|
|
462
|
+
font-size: 0.95rem;
|
|
459
463
|
}
|
|
460
464
|
|
|
461
465
|
.ibs-input-icon {
|
|
462
|
-
|
|
463
|
-
font-size: 1.25rem;
|
|
466
|
+
display: none;
|
|
464
467
|
}
|
|
465
468
|
|
|
466
469
|
.ibs-bank-name-value,
|
|
@@ -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
|
{
|