@jjlmoya/utils-finance 1.14.0 → 1.16.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.
Files changed (225) hide show
  1. package/package.json +1 -1
  2. package/src/pages/[locale]/[slug].astro +29 -13
  3. package/src/tests/locale_completeness.test.ts +6 -16
  4. package/src/tests/shared-test-helpers.ts +56 -0
  5. package/src/tests/tool_exports.test.ts +34 -0
  6. package/src/tool/compoundInterest/bibliography.ts +20 -0
  7. package/src/tool/compoundInterest/compound-interest-calculator.css +315 -69
  8. package/src/tool/compoundInterest/entry.ts +2 -0
  9. package/src/tool/compoundInterest/i18n/de.ts +3 -16
  10. package/src/tool/compoundInterest/i18n/en.ts +3 -20
  11. package/src/tool/compoundInterest/i18n/es.ts +2 -20
  12. package/src/tool/compoundInterest/i18n/fr.ts +3 -20
  13. package/src/tool/compoundInterest/i18n/id.ts +3 -16
  14. package/src/tool/compoundInterest/i18n/it.ts +3 -16
  15. package/src/tool/compoundInterest/i18n/ja.ts +3 -16
  16. package/src/tool/compoundInterest/i18n/ko.ts +3 -16
  17. package/src/tool/compoundInterest/i18n/nl.ts +3 -16
  18. package/src/tool/compoundInterest/i18n/pl.ts +2 -16
  19. package/src/tool/compoundInterest/i18n/pt.ts +3 -16
  20. package/src/tool/compoundInterest/i18n/ru.ts +3 -16
  21. package/src/tool/compoundInterest/i18n/sv.ts +3 -16
  22. package/src/tool/compoundInterest/i18n/tr.ts +3 -16
  23. package/src/tool/compoundInterest/i18n/zh.ts +3 -16
  24. package/src/tool/compoundInterest/seo.astro +2 -1
  25. package/src/tool/courtFeeCalculator/bibliography.ts +16 -0
  26. package/src/tool/courtFeeCalculator/entry.ts +2 -0
  27. package/src/tool/courtFeeCalculator/i18n/de.ts +2 -18
  28. package/src/tool/courtFeeCalculator/i18n/en.ts +2 -22
  29. package/src/tool/courtFeeCalculator/i18n/es.ts +2 -22
  30. package/src/tool/courtFeeCalculator/i18n/fr.ts +2 -22
  31. package/src/tool/courtFeeCalculator/i18n/id.ts +2 -18
  32. package/src/tool/courtFeeCalculator/i18n/it.ts +2 -18
  33. package/src/tool/courtFeeCalculator/i18n/ja.ts +2 -18
  34. package/src/tool/courtFeeCalculator/i18n/ko.ts +2 -18
  35. package/src/tool/courtFeeCalculator/i18n/nl.ts +2 -18
  36. package/src/tool/courtFeeCalculator/i18n/pl.ts +2 -18
  37. package/src/tool/courtFeeCalculator/i18n/pt.ts +2 -18
  38. package/src/tool/courtFeeCalculator/i18n/ru.ts +2 -18
  39. package/src/tool/courtFeeCalculator/i18n/sv.ts +2 -18
  40. package/src/tool/courtFeeCalculator/i18n/tr.ts +2 -18
  41. package/src/tool/courtFeeCalculator/i18n/zh.ts +2 -18
  42. package/src/tool/courtFeeCalculator/seo.astro +2 -1
  43. package/src/tool/debtSnowball/bibliography.ts +12 -0
  44. package/src/tool/debtSnowball/entry.ts +2 -0
  45. package/src/tool/debtSnowball/i18n/de.ts +2 -14
  46. package/src/tool/debtSnowball/i18n/en.ts +2 -14
  47. package/src/tool/debtSnowball/i18n/es.ts +2 -14
  48. package/src/tool/debtSnowball/i18n/fr.ts +2 -14
  49. package/src/tool/debtSnowball/i18n/id.ts +2 -14
  50. package/src/tool/debtSnowball/i18n/it.ts +2 -14
  51. package/src/tool/debtSnowball/i18n/ja.ts +2 -14
  52. package/src/tool/debtSnowball/i18n/ko.ts +2 -14
  53. package/src/tool/debtSnowball/i18n/nl.ts +2 -14
  54. package/src/tool/debtSnowball/i18n/pl.ts +2 -14
  55. package/src/tool/debtSnowball/i18n/pt.ts +2 -14
  56. package/src/tool/debtSnowball/i18n/ru.ts +2 -14
  57. package/src/tool/debtSnowball/i18n/sv.ts +2 -14
  58. package/src/tool/debtSnowball/i18n/tr.ts +2 -14
  59. package/src/tool/debtSnowball/i18n/zh.ts +2 -14
  60. package/src/tool/debtSnowball/seo.astro +2 -1
  61. package/src/tool/fireCalculator/bibliography.ts +20 -0
  62. package/src/tool/fireCalculator/entry.ts +2 -0
  63. package/src/tool/fireCalculator/i18n/de.ts +2 -22
  64. package/src/tool/fireCalculator/i18n/en.ts +2 -22
  65. package/src/tool/fireCalculator/i18n/es.ts +2 -22
  66. package/src/tool/fireCalculator/i18n/fr.ts +2 -22
  67. package/src/tool/fireCalculator/i18n/id.ts +2 -22
  68. package/src/tool/fireCalculator/i18n/it.ts +2 -22
  69. package/src/tool/fireCalculator/i18n/ja.ts +2 -22
  70. package/src/tool/fireCalculator/i18n/ko.ts +2 -22
  71. package/src/tool/fireCalculator/i18n/nl.ts +2 -22
  72. package/src/tool/fireCalculator/i18n/pl.ts +2 -22
  73. package/src/tool/fireCalculator/i18n/pt.ts +2 -22
  74. package/src/tool/fireCalculator/i18n/ru.ts +2 -22
  75. package/src/tool/fireCalculator/i18n/sv.ts +2 -22
  76. package/src/tool/fireCalculator/i18n/tr.ts +2 -22
  77. package/src/tool/fireCalculator/i18n/zh.ts +2 -22
  78. package/src/tool/fireCalculator/seo.astro +2 -1
  79. package/src/tool/ibanBicSwiftConverter/bibliography.ts +12 -0
  80. package/src/tool/ibanBicSwiftConverter/entry.ts +2 -0
  81. package/src/tool/ibanBicSwiftConverter/i18n/de.ts +2 -14
  82. package/src/tool/ibanBicSwiftConverter/i18n/en.ts +2 -18
  83. package/src/tool/ibanBicSwiftConverter/i18n/es.ts +2 -18
  84. package/src/tool/ibanBicSwiftConverter/i18n/fr.ts +2 -18
  85. package/src/tool/ibanBicSwiftConverter/i18n/id.ts +2 -14
  86. package/src/tool/ibanBicSwiftConverter/i18n/it.ts +2 -14
  87. package/src/tool/ibanBicSwiftConverter/i18n/ja.ts +2 -14
  88. package/src/tool/ibanBicSwiftConverter/i18n/ko.ts +2 -14
  89. package/src/tool/ibanBicSwiftConverter/i18n/nl.ts +2 -14
  90. package/src/tool/ibanBicSwiftConverter/i18n/pl.ts +2 -14
  91. package/src/tool/ibanBicSwiftConverter/i18n/pt.ts +2 -14
  92. package/src/tool/ibanBicSwiftConverter/i18n/ru.ts +2 -14
  93. package/src/tool/ibanBicSwiftConverter/i18n/sv.ts +2 -14
  94. package/src/tool/ibanBicSwiftConverter/i18n/tr.ts +2 -14
  95. package/src/tool/ibanBicSwiftConverter/i18n/zh.ts +2 -14
  96. package/src/tool/ibanBicSwiftConverter/seo.astro +2 -1
  97. package/src/tool/inflation/bibliography.ts +20 -0
  98. package/src/tool/inflation/entry.ts +2 -0
  99. package/src/tool/inflation/i18n/de.ts +3 -16
  100. package/src/tool/inflation/i18n/en.ts +3 -20
  101. package/src/tool/inflation/i18n/es.ts +3 -20
  102. package/src/tool/inflation/i18n/fr.ts +3 -20
  103. package/src/tool/inflation/i18n/id.ts +3 -16
  104. package/src/tool/inflation/i18n/it.ts +3 -16
  105. package/src/tool/inflation/i18n/ja.ts +3 -16
  106. package/src/tool/inflation/i18n/ko.ts +3 -16
  107. package/src/tool/inflation/i18n/nl.ts +3 -16
  108. package/src/tool/inflation/i18n/pl.ts +3 -16
  109. package/src/tool/inflation/i18n/pt.ts +3 -16
  110. package/src/tool/inflation/i18n/ru.ts +3 -16
  111. package/src/tool/inflation/i18n/sv.ts +3 -16
  112. package/src/tool/inflation/i18n/tr.ts +3 -16
  113. package/src/tool/inflation/i18n/zh.ts +3 -16
  114. package/src/tool/inflation/seo.astro +2 -1
  115. package/src/tool/lateInterest/bibliography.ts +16 -0
  116. package/src/tool/lateInterest/entry.ts +2 -0
  117. package/src/tool/lateInterest/i18n/de.ts +3 -16
  118. package/src/tool/lateInterest/i18n/en.ts +3 -16
  119. package/src/tool/lateInterest/i18n/es.ts +3 -16
  120. package/src/tool/lateInterest/i18n/fr.ts +3 -16
  121. package/src/tool/lateInterest/i18n/id.ts +3 -16
  122. package/src/tool/lateInterest/i18n/it.ts +3 -16
  123. package/src/tool/lateInterest/i18n/ja.ts +3 -16
  124. package/src/tool/lateInterest/i18n/ko.ts +3 -16
  125. package/src/tool/lateInterest/i18n/nl.ts +3 -16
  126. package/src/tool/lateInterest/i18n/pl.ts +3 -16
  127. package/src/tool/lateInterest/i18n/pt.ts +3 -16
  128. package/src/tool/lateInterest/i18n/ru.ts +3 -16
  129. package/src/tool/lateInterest/i18n/sv.ts +3 -16
  130. package/src/tool/lateInterest/i18n/tr.ts +3 -16
  131. package/src/tool/lateInterest/i18n/zh.ts +3 -16
  132. package/src/tool/lateInterest/seo.astro +2 -1
  133. package/src/tool/legalInterestRate/bibliography.ts +16 -0
  134. package/src/tool/legalInterestRate/entry.ts +2 -0
  135. package/src/tool/legalInterestRate/i18n/de.ts +2 -18
  136. package/src/tool/legalInterestRate/i18n/en.ts +2 -22
  137. package/src/tool/legalInterestRate/i18n/es.ts +2 -22
  138. package/src/tool/legalInterestRate/i18n/fr.ts +2 -22
  139. package/src/tool/legalInterestRate/i18n/id.ts +2 -18
  140. package/src/tool/legalInterestRate/i18n/it.ts +2 -18
  141. package/src/tool/legalInterestRate/i18n/ja.ts +2 -18
  142. package/src/tool/legalInterestRate/i18n/ko.ts +2 -18
  143. package/src/tool/legalInterestRate/i18n/nl.ts +2 -18
  144. package/src/tool/legalInterestRate/i18n/pl.ts +2 -18
  145. package/src/tool/legalInterestRate/i18n/pt.ts +2 -18
  146. package/src/tool/legalInterestRate/i18n/ru.ts +2 -18
  147. package/src/tool/legalInterestRate/i18n/sv.ts +2 -18
  148. package/src/tool/legalInterestRate/i18n/tr.ts +2 -18
  149. package/src/tool/legalInterestRate/i18n/zh.ts +2 -18
  150. package/src/tool/legalInterestRate/seo.astro +2 -1
  151. package/src/tool/lotteryOptimizer/bibliography.ts +16 -0
  152. package/src/tool/lotteryOptimizer/entry.ts +2 -0
  153. package/src/tool/lotteryOptimizer/i18n/de.ts +2 -18
  154. package/src/tool/lotteryOptimizer/i18n/en.ts +2 -18
  155. package/src/tool/lotteryOptimizer/i18n/es.ts +2 -18
  156. package/src/tool/lotteryOptimizer/i18n/fr.ts +2 -18
  157. package/src/tool/lotteryOptimizer/i18n/id.ts +2 -18
  158. package/src/tool/lotteryOptimizer/i18n/it.ts +2 -18
  159. package/src/tool/lotteryOptimizer/i18n/ja.ts +2 -18
  160. package/src/tool/lotteryOptimizer/i18n/ko.ts +2 -18
  161. package/src/tool/lotteryOptimizer/i18n/nl.ts +2 -18
  162. package/src/tool/lotteryOptimizer/i18n/pl.ts +2 -18
  163. package/src/tool/lotteryOptimizer/i18n/pt.ts +2 -18
  164. package/src/tool/lotteryOptimizer/i18n/ru.ts +2 -18
  165. package/src/tool/lotteryOptimizer/i18n/sv.ts +2 -18
  166. package/src/tool/lotteryOptimizer/i18n/tr.ts +2 -18
  167. package/src/tool/lotteryOptimizer/i18n/zh.ts +2 -18
  168. package/src/tool/lotteryOptimizer/seo.astro +2 -1
  169. package/src/tool/lotteryOptimizer/spain-lottery-optimizer.css +524 -7
  170. package/src/tool/mortgage/bibliography.ts +20 -0
  171. package/src/tool/mortgage/entry.ts +2 -0
  172. package/src/tool/mortgage/i18n/de.ts +3 -16
  173. package/src/tool/mortgage/i18n/en.ts +3 -20
  174. package/src/tool/mortgage/i18n/es.ts +3 -20
  175. package/src/tool/mortgage/i18n/fr.ts +3 -20
  176. package/src/tool/mortgage/i18n/id.ts +3 -12
  177. package/src/tool/mortgage/i18n/it.ts +3 -16
  178. package/src/tool/mortgage/i18n/ja.ts +3 -12
  179. package/src/tool/mortgage/i18n/ko.ts +3 -12
  180. package/src/tool/mortgage/i18n/nl.ts +3 -12
  181. package/src/tool/mortgage/i18n/pl.ts +3 -12
  182. package/src/tool/mortgage/i18n/pt.ts +3 -12
  183. package/src/tool/mortgage/i18n/ru.ts +3 -12
  184. package/src/tool/mortgage/i18n/sv.ts +3 -12
  185. package/src/tool/mortgage/i18n/tr.ts +3 -12
  186. package/src/tool/mortgage/i18n/zh.ts +3 -12
  187. package/src/tool/mortgage/seo.astro +2 -1
  188. package/src/tool/percentageCalculator/bibliography.ts +16 -0
  189. package/src/tool/percentageCalculator/entry.ts +2 -0
  190. package/src/tool/percentageCalculator/i18n/de.ts +3 -12
  191. package/src/tool/percentageCalculator/i18n/en.ts +3 -16
  192. package/src/tool/percentageCalculator/i18n/es.ts +3 -16
  193. package/src/tool/percentageCalculator/i18n/fr.ts +3 -16
  194. package/src/tool/percentageCalculator/i18n/id.ts +3 -12
  195. package/src/tool/percentageCalculator/i18n/it.ts +3 -12
  196. package/src/tool/percentageCalculator/i18n/ja.ts +3 -12
  197. package/src/tool/percentageCalculator/i18n/ko.ts +3 -12
  198. package/src/tool/percentageCalculator/i18n/nl.ts +3 -12
  199. package/src/tool/percentageCalculator/i18n/pl.ts +3 -12
  200. package/src/tool/percentageCalculator/i18n/pt.ts +3 -12
  201. package/src/tool/percentageCalculator/i18n/ru.ts +3 -12
  202. package/src/tool/percentageCalculator/i18n/sv.ts +3 -12
  203. package/src/tool/percentageCalculator/i18n/tr.ts +3 -12
  204. package/src/tool/percentageCalculator/i18n/zh.ts +3 -12
  205. package/src/tool/percentageCalculator/percentage-calculator.css +20 -0
  206. package/src/tool/percentageCalculator/seo.astro +2 -1
  207. package/src/tool/rentIncreaseCalculator/bibliography.ts +16 -0
  208. package/src/tool/rentIncreaseCalculator/entry.ts +2 -0
  209. package/src/tool/rentIncreaseCalculator/i18n/de.ts +2 -18
  210. package/src/tool/rentIncreaseCalculator/i18n/en.ts +2 -18
  211. package/src/tool/rentIncreaseCalculator/i18n/es.ts +2 -18
  212. package/src/tool/rentIncreaseCalculator/i18n/fr.ts +2 -18
  213. package/src/tool/rentIncreaseCalculator/i18n/id.ts +2 -18
  214. package/src/tool/rentIncreaseCalculator/i18n/it.ts +2 -18
  215. package/src/tool/rentIncreaseCalculator/i18n/ja.ts +2 -18
  216. package/src/tool/rentIncreaseCalculator/i18n/ko.ts +2 -18
  217. package/src/tool/rentIncreaseCalculator/i18n/nl.ts +2 -18
  218. package/src/tool/rentIncreaseCalculator/i18n/pl.ts +2 -18
  219. package/src/tool/rentIncreaseCalculator/i18n/pt.ts +2 -18
  220. package/src/tool/rentIncreaseCalculator/i18n/ru.ts +2 -18
  221. package/src/tool/rentIncreaseCalculator/i18n/sv.ts +2 -18
  222. package/src/tool/rentIncreaseCalculator/i18n/tr.ts +2 -18
  223. package/src/tool/rentIncreaseCalculator/i18n/zh.ts +2 -18
  224. package/src/tool/rentIncreaseCalculator/seo.astro +2 -1
  225. package/src/types.ts +0 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-finance",
3
- "version": "1.14.0",
3
+ "version": "1.16.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -34,18 +34,28 @@ export async function getStaticPaths() {
34
34
  ]),
35
35
  ) as Partial<Record<KnownLocale, string>>;
36
36
 
37
+ const firstLoader = entry.i18n.en ?? Object.values(entry.i18n)[0];
38
+ const englishSlug = firstLoader ? (await firstLoader()).slug : entry.id;
39
+
37
40
  for (const { locale, content } of localeContents) {
38
- const allToolsNav = await Promise.all(
39
- ALL_TOOLS.map(async ({ entry: navEntry }) => ({
40
- id: navEntry.id,
41
- title: (await navEntry.i18n[locale]!()).title,
42
- href: `/${locale}/${(await navEntry.i18n[locale]!()).slug}`,
43
- isActive: navEntry.id === entry.id,
44
- })),
45
- );
41
+ const allToolsNav = (
42
+ await Promise.all(
43
+ ALL_TOOLS.map(async ({ entry: navEntry }) => {
44
+ const loader = navEntry.i18n[locale] ?? navEntry.i18n.en;
45
+ if (!loader) return null;
46
+ const navContent = await loader();
47
+ return {
48
+ id: navEntry.id,
49
+ title: navContent.title,
50
+ href: `/${locale}/${navContent.slug}`,
51
+ isActive: navEntry.id === entry.id,
52
+ };
53
+ }),
54
+ )
55
+ ).filter(Boolean) as NavItem[];
46
56
  paths.push({
47
57
  params: { locale, slug: content.slug },
48
- props: { Component, locale, content, localeUrls, allToolsNav },
58
+ props: { Component, locale, content, localeUrls, allToolsNav, englishSlug },
49
59
  });
50
60
  }
51
61
  }
@@ -66,11 +76,16 @@ interface Props {
66
76
  content: ToolLocaleContent;
67
77
  localeUrls: Partial<Record<KnownLocale, string>>;
68
78
  allToolsNav: NavItem[];
79
+ englishSlug: string;
69
80
  }
70
81
 
71
- const { Component, locale, content, localeUrls, allToolsNav } = Astro.props;
82
+ const { Component, locale, content, localeUrls, allToolsNav, englishSlug } = Astro.props;
83
+
84
+ const cssFiles = import.meta.glob("../../tool/*/*.css", { query: "?raw", import: "default" });
85
+ const cssKey = Object.keys(cssFiles).find((k) => k.endsWith(`/${englishSlug}.css`));
86
+ const toolCss = cssKey ? await cssFiles[cssKey]() as string : "";
72
87
 
73
- const seoContent: UtilitySEOContent = { locale, sections: content.seo };
88
+ const seoContent: UtilitySEOContent = { locale, sections: content.seo ?? [] };
74
89
 
75
90
  const words = content.title.split(" ");
76
91
  const titleHighlight = words[0] || "";
@@ -89,8 +104,9 @@ const titleBase = words.slice(1).join(" ") || "";
89
104
  tools={allToolsNav}
90
105
  />
91
106
  <Fragment slot="head">
107
+ {toolCss ? <Fragment set:html={`<style is:inline>${toolCss}</style>`} /> : null}
92
108
  {
93
- content.schemas.map((schema: unknown) => (
109
+ ( content.schemas ?? []).map((schema: unknown) => (
94
110
  <script
95
111
  is:inline
96
112
  type="application/ld+json"
@@ -120,7 +136,7 @@ const titleBase = words.slice(1).join(" ") || "";
120
136
  </section>
121
137
 
122
138
  <section class="section-bibliography">
123
- <Bibliography links={content.bibliography} title={content.bibliographyTitle} />
139
+ <Bibliography links={content.bibliography} />
124
140
  </section>
125
141
  </div>
126
142
  </PreviewLayout>
@@ -7,28 +7,18 @@ describe('Locale Completeness Validation', () => {
7
7
  describe(`Tool: ${tool.entry.id}`, () => {
8
8
  Object.keys(tool.entry.i18n).forEach((locale) => {
9
9
  describe(`Locale: ${locale}`, () => {
10
- it('faqTitle should be defined when faq items exist', async () => {
10
+ it('should have faq items', async () => {
11
11
  const loader = tool.entry.i18n[locale as keyof typeof tool.entry.i18n];
12
12
  const content = (await loader?.()) as ToolLocaleContent;
13
-
14
- if (content.faq.length > 0) {
15
- expect(
16
- content.faqTitle,
17
- `Tool "${tool.entry.id}" locale "${locale}" has ${content.faq.length} FAQ items but is missing faqTitle`,
18
- ).toBeTruthy();
19
- }
13
+ expect(content.faq).toBeDefined();
14
+ expect(Array.isArray(content.faq)).toBe(true);
20
15
  });
21
16
 
22
- it('bibliographyTitle should be defined when bibliography items exist', async () => {
17
+ it('should have bibliography items', async () => {
23
18
  const loader = tool.entry.i18n[locale as keyof typeof tool.entry.i18n];
24
19
  const content = (await loader?.()) as ToolLocaleContent;
25
-
26
- if (content.bibliography.length > 0) {
27
- expect(
28
- content.bibliographyTitle,
29
- `Tool "${tool.entry.id}" locale "${locale}" has ${content.bibliography.length} bibliography items but is missing bibliographyTitle`,
30
- ).toBeTruthy();
31
- }
20
+ expect(content.bibliography).toBeDefined();
21
+ expect(Array.isArray(content.bibliography)).toBe(true);
32
22
  });
33
23
  });
34
24
  });
@@ -0,0 +1,56 @@
1
+ import type { ToolDefinition } from '../types';
2
+
3
+ export interface ToolExportValidationResult {
4
+ passed: boolean;
5
+ failures: string[];
6
+ }
7
+
8
+ function validateComponentType(
9
+ toolId: string,
10
+ componentName: string,
11
+ component: unknown,
12
+ failures: string[],
13
+ ): void {
14
+ if (typeof component !== 'function') {
15
+ failures.push(`${toolId}: ${componentName} is not a function (${typeof component})`);
16
+ }
17
+ }
18
+
19
+ async function validateComponentExecution(
20
+ toolId: string,
21
+ componentName: string,
22
+ fn: () => Promise<unknown>,
23
+ failures: string[],
24
+ ): Promise<void> {
25
+ try {
26
+ const result = await fn();
27
+ if (!result || typeof result !== 'object') {
28
+ failures.push(`${toolId}: ${componentName} import returned invalid result`);
29
+ }
30
+ } catch (error) {
31
+ failures.push(`${toolId}: ${componentName} execution error - ${error instanceof Error ? error.message : 'unknown'}`);
32
+ }
33
+ }
34
+
35
+ export async function validateToolExports(tools: ToolDefinition[]): Promise<ToolExportValidationResult> {
36
+ const failures: string[] = [];
37
+
38
+ for (const tool of tools) {
39
+ validateComponentType(tool.entry.id, 'Component', tool.Component, failures);
40
+ validateComponentType(tool.entry.id, 'SEOComponent', tool.SEOComponent, failures);
41
+ validateComponentType(tool.entry.id, 'BibliographyComponent', tool.BibliographyComponent, failures);
42
+
43
+ const componentFn = tool.Component as () => Promise<unknown>;
44
+ const seoFn = tool.SEOComponent as () => Promise<unknown>;
45
+ const bibFn = tool.BibliographyComponent as () => Promise<unknown>;
46
+
47
+ await validateComponentExecution(tool.entry.id, 'Component', componentFn, failures);
48
+ await validateComponentExecution(tool.entry.id, 'SEOComponent', seoFn, failures);
49
+ await validateComponentExecution(tool.entry.id, 'BibliographyComponent', bibFn, failures);
50
+ }
51
+
52
+ return {
53
+ passed: failures.length === 0,
54
+ failures,
55
+ };
56
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ALL_TOOLS } from '../tools';
3
+ import { validateToolExports } from './shared-test-helpers';
4
+
5
+ describe('Tool Exports Pattern Validation', () => {
6
+ describe('Component Exports Format', () => {
7
+ ALL_TOOLS.forEach((tool) => {
8
+ it(`${tool.entry.id}: Component should be a lazy-loaded function`, () => {
9
+ expect(typeof tool.Component).toBe('function');
10
+ expect(tool.Component).toBeInstanceOf(Function);
11
+ });
12
+
13
+ it(`${tool.entry.id}: SEOComponent should be a lazy-loaded function`, () => {
14
+ expect(typeof tool.SEOComponent).toBe('function');
15
+ expect(tool.SEOComponent).toBeInstanceOf(Function);
16
+ });
17
+
18
+ it(`${tool.entry.id}: BibliographyComponent should be a lazy-loaded function`, () => {
19
+ expect(typeof tool.BibliographyComponent).toBe('function');
20
+ expect(tool.BibliographyComponent).toBeInstanceOf(Function);
21
+ });
22
+ });
23
+ });
24
+
25
+ describe('Dynamic Import Validation', () => {
26
+ it('all tools must have functional dynamic imports', async () => {
27
+ const result = await validateToolExports(ALL_TOOLS);
28
+ if (!result.passed) {
29
+ throw new Error(`Tool export validation failed:\n${result.failures.join('\n')}`);
30
+ }
31
+ expect(result.passed).toBe(true);
32
+ });
33
+ });
34
+ });
@@ -0,0 +1,20 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'Investopedia: Compound Interest Definition',
6
+ url: 'https://www.investopedia.com/terms/c/compoundinterest.asp',
7
+ },
8
+ {
9
+ name: 'Investopedia: Rule of 72',
10
+ url: 'https://www.investopedia.com/terms/r/ruleof72.asp',
11
+ },
12
+ {
13
+ name: 'Khan Academy: Interest and Debt',
14
+ url: 'https://www.khanacademy.org/economics-finance-domain/core-finance/interest-tutorial',
15
+ },
16
+ {
17
+ name: 'Banco de España: Portal del Cliente Bancario',
18
+ url: 'https://www.bde.es/bde/es/secciones/servicios/Particulares_y_e/Clientesbancarios/',
19
+ },
20
+ ];
@@ -1,79 +1,325 @@
1
- .cic-insight.cic-insight-visible {
2
- opacity: 1;
3
- }
1
+ .cic-root {
2
+ --cic-accent: #10b981;
3
+ --cic-accent-text: #059669;
4
+ --cic-accent-icon-bg: #d1fae5;
5
+ --cic-bg: #fff;
6
+ --cic-bg-panel: rgba(248, 250, 252, 0.3);
7
+ --cic-bg-card: #fff;
8
+ --cic-bg-header: rgba(248, 250, 252, 0.5);
9
+ --cic-border: #e2e8f0;
10
+ --cic-border-subtle: #f1f5f9;
11
+ --cic-text: #0f172a;
12
+ --cic-text-muted: #64748b;
13
+ --cic-text-subtle: #94a3b8;
14
+ --cic-input-bg: #fff;
15
+ --cic-input-border: #e2e8f0;
16
+ --cic-summary-bg: #fff;
17
+ --cic-insight-bg: #ecfdf5;
18
+ --cic-insight-text: #065f46;
4
19
 
5
- .cic-insight-year {
6
- font-weight: 700;
7
- color: #10b981;
8
- }
20
+ background: var(--cic-bg);
21
+ border-radius: 1.5rem;
22
+ box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
23
+ border: 1px solid var(--cic-border-subtle);
24
+ overflow: hidden;
25
+ }
26
+
27
+ .theme-dark .cic-root {
28
+ --cic-accent-text: #34d399;
29
+ --cic-accent-icon-bg: rgba(16, 185, 129, 0.2);
30
+ --cic-bg: #1e293b;
31
+ --cic-bg-panel: rgba(15, 23, 42, 0.1);
32
+ --cic-bg-card: #1e293b;
33
+ --cic-bg-header: rgba(30, 41, 59, 0.5);
34
+ --cic-border: #334155;
35
+ --cic-border-subtle: #334155;
36
+ --cic-text: #fff;
37
+ --cic-text-muted: #94a3b8;
38
+ --cic-text-subtle: #64748b;
39
+ --cic-input-bg: #0f172a;
40
+ --cic-input-border: #334155;
41
+ --cic-summary-bg: #1e293b;
42
+ --cic-insight-bg: rgba(16, 185, 129, 0.1);
43
+ --cic-insight-text: #6ee7b7;
44
+ }
45
+
46
+ .cic-header {
47
+ padding: 1.5rem;
48
+ border-bottom: 1px solid var(--cic-border-subtle);
49
+ background: var(--cic-bg-header);
50
+ display: flex;
51
+ flex-wrap: wrap;
52
+ gap: 1rem;
53
+ align-items: center;
54
+ justify-content: space-between;
55
+ }
56
+
57
+ .cic-header-left {
58
+ display: flex;
59
+ align-items: center;
60
+ gap: 0.75rem;
61
+ }
62
+
63
+ .cic-icon-wrap {
64
+ padding: 0.625rem;
65
+ background: var(--cic-accent-icon-bg);
66
+ border-radius: 0.75rem;
67
+ color: var(--cic-accent-text);
68
+ display: flex;
69
+ align-items: center;
70
+ justify-content: center;
71
+ }
72
+
73
+ .cic-icon {
74
+ width: 1.5rem;
75
+ height: 1.5rem;
76
+ }
77
+
78
+ .cic-title {
79
+ font-size: 1.25rem;
80
+ font-weight: 700;
81
+ color: var(--cic-text);
82
+ }
83
+
84
+ .cic-realtime {
85
+ display: flex;
86
+ align-items: center;
87
+ gap: 0.5rem;
88
+ font-size: 0.875rem;
89
+ color: var(--cic-text-muted);
90
+ }
91
+
92
+ .cic-dot {
93
+ width: 0.5rem;
94
+ height: 0.5rem;
95
+ border-radius: 50%;
96
+ background: var(--cic-accent);
97
+ animation: cic-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
98
+ }
99
+
100
+ @keyframes cic-pulse {
101
+ 0%, 100% { opacity: 1; }
102
+ 50% { opacity: 0.4; }
103
+ }
104
+
105
+ .cic-grid {
106
+ display: grid;
107
+ }
108
+
109
+ .cic-panel-left {
110
+ padding: 1.5rem;
111
+ display: flex;
112
+ flex-direction: column;
113
+ gap: 1.5rem;
114
+ background: var(--cic-bg-panel);
115
+ border-bottom: 1px solid var(--cic-border-subtle);
116
+ }
117
+
118
+ .cic-fields {
119
+ display: flex;
120
+ flex-direction: column;
121
+ gap: 1rem;
122
+ }
123
+
124
+ .cic-grid-2 {
125
+ display: grid;
126
+ grid-template-columns: 1fr 1fr;
127
+ gap: 1rem;
128
+ }
129
+
130
+ .cic-label {
131
+ display: block;
132
+ font-size: 0.75rem;
133
+ font-weight: 700;
134
+ color: var(--cic-text-subtle);
135
+ text-transform: uppercase;
136
+ letter-spacing: 0.1em;
137
+ margin-bottom: 0.5rem;
138
+ }
139
+
140
+ .cic-input-wrap {
141
+ position: relative;
142
+ }
143
+
144
+ .cic-currency {
145
+ position: absolute;
146
+ left: 0.5rem;
147
+ top: 50%;
148
+ transform: translateY(-50%);
149
+ color: var(--cic-text-subtle);
150
+ font-weight: 700;
151
+ pointer-events: none;
152
+ font-size: 0.75rem;
153
+ }
154
+
155
+ .cic-currency-right {
156
+ left: auto;
157
+ right: 0.5rem;
158
+ }
159
+
160
+ .cic-input {
161
+ width: 100%;
162
+ padding: 0.75rem 1rem;
163
+ background: var(--cic-input-bg);
164
+ border: 2px solid var(--cic-input-border);
165
+ border-radius: 0.75rem;
166
+ outline: none;
167
+ transition: border-color 0.2s;
168
+ color: var(--cic-text);
169
+ font-weight: 700;
170
+ font-size: 1.125rem;
171
+ box-sizing: border-box;
172
+ }
9
173
 
10
- .theme-dark .cic-root {
11
- --cic-accent-text: #34d399;
12
- --cic-accent-icon-bg: rgba(16, 185, 129, 0.2);
13
- --cic-bg: #1e293b;
14
- --cic-bg-panel: rgba(15, 23, 42, 0.1);
15
- --cic-bg-card: #1e293b;
16
- --cic-bg-header: rgba(30, 41, 59, 0.5);
17
- --cic-border: #334155;
18
- --cic-border-subtle: #334155;
19
- --cic-text: #fff;
20
- --cic-text-muted: #94a3b8;
21
- --cic-text-subtle: #64748b;
22
- --cic-input-bg: #0f172a;
23
- --cic-input-border: #334155;
24
- --cic-summary-bg: #1e293b;
25
- --cic-insight-bg: rgba(16, 185, 129, 0.1);
26
- --cic-insight-text: #6ee7b7;
174
+ .cic-input-prefixed {
175
+ padding-left: 3.5rem;
176
+ }
177
+
178
+ .cic-input-suffixed {
179
+ padding-right: 5rem;
180
+ }
181
+
182
+ .cic-input:focus {
183
+ border-color: var(--cic-accent);
184
+ }
185
+
186
+ .cic-summary {
187
+ background: var(--cic-summary-bg);
188
+ border-radius: 1rem;
189
+ padding: 1rem;
190
+ border: 1px solid var(--cic-border);
191
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
192
+ display: flex;
193
+ flex-direction: column;
194
+ gap: 0.75rem;
195
+ }
196
+
197
+ .cic-summary-row {
198
+ display: flex;
199
+ justify-content: space-between;
200
+ align-items: center;
201
+ font-size: 0.875rem;
202
+ }
203
+
204
+ .cic-summary-label {
205
+ display: flex;
206
+ align-items: center;
207
+ gap: 0.5rem;
208
+ }
209
+
210
+ .cic-dot-neutral {
211
+ width: 0.75rem;
212
+ height: 0.75rem;
213
+ border-radius: 50%;
214
+ background: var(--cic-text-subtle);
215
+ flex-shrink: 0;
216
+ }
217
+
218
+ .cic-dot-accent {
219
+ width: 0.75rem;
220
+ height: 0.75rem;
221
+ border-radius: 50%;
222
+ background: var(--cic-accent);
223
+ flex-shrink: 0;
224
+ }
225
+
226
+ .cic-text-muted {
227
+ color: var(--cic-text-muted);
228
+ }
229
+
230
+ .cic-text-accent {
231
+ color: var(--cic-accent-text);
232
+ font-weight: 500;
233
+ }
234
+
235
+ .cic-summary-value {
236
+ font-weight: 700;
237
+ color: var(--cic-text);
238
+ }
239
+
240
+ .cic-summary-value-accent {
241
+ color: var(--cic-accent-text);
242
+ }
243
+
244
+ .cic-summary-total {
245
+ padding-top: 0.75rem;
246
+ margin-top: 0.25rem;
247
+ border-top: 1px solid var(--cic-border-subtle);
248
+ display: flex;
249
+ justify-content: space-between;
250
+ align-items: flex-end;
251
+ }
252
+
253
+ .cic-total-label {
254
+ color: var(--cic-text-subtle);
255
+ font-size: 0.75rem;
256
+ font-weight: 700;
257
+ text-transform: uppercase;
258
+ letter-spacing: 0.05em;
259
+ }
260
+
261
+ .cic-total-value {
262
+ font-size: 1.5rem;
263
+ font-weight: 900;
264
+ color: var(--cic-text);
265
+ }
266
+
267
+ .cic-panel-right {
268
+ padding: 1.5rem;
269
+ display: flex;
270
+ flex-direction: column;
271
+ min-height: 500px;
272
+ }
273
+
274
+ .cic-canvas-wrap {
275
+ position: relative;
276
+ width: 100%;
277
+ flex: 1;
278
+ }
279
+
280
+ .cic-insight {
281
+ display: none;
282
+ margin-top: 1rem;
283
+ padding: 1rem;
284
+ background: var(--cic-insight-bg);
285
+ border-radius: 0.75rem;
286
+ font-size: 0.875rem;
287
+ color: var(--cic-insight-text);
288
+ opacity: 0;
289
+ transition: opacity 0.5s;
290
+ }
291
+
292
+ @media (min-width: 768px) {
293
+ .cic-insight {
294
+ display: block;
27
295
  }
296
+ }
28
297
 
29
- .theme-dark .cic-insight-year {
30
- color: #fff;
298
+ @media (min-width: 1024px) {
299
+ .cic-grid {
300
+ grid-template-columns: 4fr 8fr;
31
301
  }
32
302
 
33
- [data-theme='dark'] .cic-root {
34
- --cic-accent-text: #34d399;
35
- --cic-accent-icon-bg: rgba(16, 185, 129, 0.2);
36
- --cic-bg: #1e293b;
37
- --cic-bg-panel: rgba(15, 23, 42, 0.1);
38
- --cic-bg-card: #1e293b;
39
- --cic-bg-header: rgba(30, 41, 59, 0.5);
40
- --cic-border: #334155;
41
- --cic-border-subtle: #334155;
42
- --cic-text: #fff;
43
- --cic-text-muted: #94a3b8;
44
- --cic-text-subtle: #64748b;
45
- --cic-input-bg: #0f172a;
46
- --cic-input-border: #334155;
47
- --cic-summary-bg: #1e293b;
48
- --cic-insight-bg: rgba(16, 185, 129, 0.1);
49
- --cic-insight-text: #6ee7b7;
303
+ .cic-panel-left {
304
+ border-bottom: none;
305
+ border-right: 1px solid var(--cic-border-subtle);
50
306
  }
51
307
 
52
- [data-theme='dark'] .cic-insight-year {
53
- color: #fff;
308
+ .cic-panel-right {
309
+ padding: 2rem;
310
+ min-height: auto;
54
311
  }
312
+ }
313
+
314
+ .cic-insight.cic-insight-visible {
315
+ opacity: 1;
316
+ }
317
+
318
+ .cic-insight-year {
319
+ font-weight: 700;
320
+ color: #10b981;
321
+ }
55
322
 
56
- @media (prefers-color-scheme: dark) {
57
- .cic-root {
58
- --cic-accent-text: #34d399;
59
- --cic-accent-icon-bg: rgba(16, 185, 129, 0.2);
60
- --cic-bg: #1e293b;
61
- --cic-bg-panel: rgba(15, 23, 42, 0.1);
62
- --cic-bg-card: #1e293b;
63
- --cic-bg-header: rgba(30, 41, 59, 0.5);
64
- --cic-border: #334155;
65
- --cic-border-subtle: #334155;
66
- --cic-text: #fff;
67
- --cic-text-muted: #94a3b8;
68
- --cic-text-subtle: #64748b;
69
- --cic-input-bg: #0f172a;
70
- --cic-input-border: #334155;
71
- --cic-summary-bg: #1e293b;
72
- --cic-insight-bg: rgba(16, 185, 129, 0.1);
73
- --cic-insight-text: #6ee7b7;
74
- }
75
-
76
- .cic-insight-year {
77
- color: #fff;
78
- }
79
- }
323
+ .theme-dark .cic-insight-year {
324
+ color: #fff;
325
+ }
@@ -28,3 +28,5 @@ export const compoundInterest: FinanceToolEntry<CompoundInterestUI> = {
28
28
  zh: () => import('./i18n/zh').then((m) => m.content),
29
29
  },
30
30
  };
31
+
32
+ export { bibliography } from './bibliography';
@@ -1,3 +1,4 @@
1
+ import { bibliography } from '../bibliography'
1
2
  import type { WithContext, FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
2
3
  import type { ToolLocaleContent } from '../../../types';
3
4
  import type { CompoundInterestUI } from '../ui';
@@ -87,23 +88,9 @@ export const content: ToolLocaleContent<CompoundInterestUI> = {
87
88
  slug,
88
89
  title,
89
90
  description,
90
- faqTitle: 'Häufig gestellte Fragen',
91
91
  faq: faqData,
92
- bibliographyTitle: 'Quellen und Referenzen',
93
- bibliography: [
94
- {
95
- name: 'Investopedia: Compound Interest Definition',
96
- url: 'https://www.investopedia.com/terms/c/compoundinterest.asp',
97
- },
98
- {
99
- name: 'Investopedia: Rule of 72',
100
- url: 'https://www.investopedia.com/terms/r/ruleof72.asp',
101
- },
102
- {
103
- name: 'Khan Academy: Interest and Debt',
104
- url: 'https://www.khanacademy.org/economics-finance-domain/core-finance/interest-tutorial',
105
- },
106
- ],
92
+ bibliography,
93
+
107
94
  howTo: howToData,
108
95
  schemas: [faqSchema, howToSchema, appSchema],
109
96
  seo: [