@jjlmoya/utils-finance 1.1.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 (133) hide show
  1. package/package.json +64 -0
  2. package/src/category/i18n/en.ts +159 -0
  3. package/src/category/i18n/es.ts +159 -0
  4. package/src/category/i18n/fr.ts +159 -0
  5. package/src/category/index.ts +24 -0
  6. package/src/category/seo.astro +15 -0
  7. package/src/components/PreviewNavSidebar.astro +116 -0
  8. package/src/components/PreviewToolbar.astro +143 -0
  9. package/src/data.ts +11 -0
  10. package/src/env.d.ts +5 -0
  11. package/src/index.ts +32 -0
  12. package/src/layouts/PreviewLayout.astro +117 -0
  13. package/src/pages/[locale]/[slug].astro +146 -0
  14. package/src/pages/[locale].astro +251 -0
  15. package/src/pages/index.astro +4 -0
  16. package/src/tests/faq_count.test.ts +19 -0
  17. package/src/tests/locale_completeness.test.ts +39 -0
  18. package/src/tests/mocks/astro_mock.js +2 -0
  19. package/src/tests/no_h1_in_components.test.ts +48 -0
  20. package/src/tests/schemas_fulfillment.test.ts +23 -0
  21. package/src/tests/seo_length.test.ts +23 -0
  22. package/src/tests/title_quality.test.ts +55 -0
  23. package/src/tests/tool_validation.test.ts +17 -0
  24. package/src/tool/compoundInterest/bibliography.astro +14 -0
  25. package/src/tool/compoundInterest/component.astro +697 -0
  26. package/src/tool/compoundInterest/i18n/en.ts +212 -0
  27. package/src/tool/compoundInterest/i18n/es.ts +212 -0
  28. package/src/tool/compoundInterest/i18n/fr.ts +212 -0
  29. package/src/tool/compoundInterest/index.ts +34 -0
  30. package/src/tool/compoundInterest/logic.ts +40 -0
  31. package/src/tool/compoundInterest/seo.astro +14 -0
  32. package/src/tool/compoundInterest/ui.ts +19 -0
  33. package/src/tool/courtFeeCalculator/bibliography.astro +14 -0
  34. package/src/tool/courtFeeCalculator/component.astro +427 -0
  35. package/src/tool/courtFeeCalculator/i18n/en.ts +268 -0
  36. package/src/tool/courtFeeCalculator/i18n/es.ts +268 -0
  37. package/src/tool/courtFeeCalculator/i18n/fr.ts +268 -0
  38. package/src/tool/courtFeeCalculator/index.ts +33 -0
  39. package/src/tool/courtFeeCalculator/logic.ts +86 -0
  40. package/src/tool/courtFeeCalculator/seo.astro +14 -0
  41. package/src/tool/courtFeeCalculator/ui.ts +31 -0
  42. package/src/tool/debtSnowball/bibliography.astro +14 -0
  43. package/src/tool/debtSnowball/component.astro +1052 -0
  44. package/src/tool/debtSnowball/i18n/en.ts +288 -0
  45. package/src/tool/debtSnowball/i18n/es.ts +288 -0
  46. package/src/tool/debtSnowball/i18n/fr.ts +288 -0
  47. package/src/tool/debtSnowball/index.ts +37 -0
  48. package/src/tool/debtSnowball/logic.ts +156 -0
  49. package/src/tool/debtSnowball/seo.astro +14 -0
  50. package/src/tool/debtSnowball/ui.ts +49 -0
  51. package/src/tool/fireCalculator/bibliography.astro +14 -0
  52. package/src/tool/fireCalculator/component.astro +689 -0
  53. package/src/tool/fireCalculator/i18n/en.ts +261 -0
  54. package/src/tool/fireCalculator/i18n/es.ts +261 -0
  55. package/src/tool/fireCalculator/i18n/fr.ts +261 -0
  56. package/src/tool/fireCalculator/index.ts +33 -0
  57. package/src/tool/fireCalculator/logic.ts +108 -0
  58. package/src/tool/fireCalculator/seo.astro +14 -0
  59. package/src/tool/fireCalculator/ui.ts +33 -0
  60. package/src/tool/ibanBicSwiftConverter/bibliography.astro +14 -0
  61. package/src/tool/ibanBicSwiftConverter/component.astro +581 -0
  62. package/src/tool/ibanBicSwiftConverter/i18n/en.ts +167 -0
  63. package/src/tool/ibanBicSwiftConverter/i18n/es.ts +216 -0
  64. package/src/tool/ibanBicSwiftConverter/i18n/fr.ts +167 -0
  65. package/src/tool/ibanBicSwiftConverter/index.ts +33 -0
  66. package/src/tool/ibanBicSwiftConverter/logic.ts +101 -0
  67. package/src/tool/ibanBicSwiftConverter/seo.astro +14 -0
  68. package/src/tool/ibanBicSwiftConverter/ui.ts +17 -0
  69. package/src/tool/inflation/bibliography.astro +14 -0
  70. package/src/tool/inflation/component.astro +400 -0
  71. package/src/tool/inflation/i18n/en.ts +301 -0
  72. package/src/tool/inflation/i18n/es.ts +301 -0
  73. package/src/tool/inflation/i18n/fr.ts +301 -0
  74. package/src/tool/inflation/index.ts +34 -0
  75. package/src/tool/inflation/logic.ts +92 -0
  76. package/src/tool/inflation/seo.astro +14 -0
  77. package/src/tool/inflation/ui.ts +17 -0
  78. package/src/tool/lateInterest/bibliography.astro +14 -0
  79. package/src/tool/lateInterest/component.astro +655 -0
  80. package/src/tool/lateInterest/i18n/en.ts +255 -0
  81. package/src/tool/lateInterest/i18n/es.ts +224 -0
  82. package/src/tool/lateInterest/i18n/fr.ts +224 -0
  83. package/src/tool/lateInterest/index.ts +34 -0
  84. package/src/tool/lateInterest/logic.ts +89 -0
  85. package/src/tool/lateInterest/seo.astro +14 -0
  86. package/src/tool/lateInterest/ui.ts +33 -0
  87. package/src/tool/legalInterestRate/bibliography.astro +14 -0
  88. package/src/tool/legalInterestRate/component.astro +650 -0
  89. package/src/tool/legalInterestRate/i18n/en.ts +159 -0
  90. package/src/tool/legalInterestRate/i18n/es.ts +159 -0
  91. package/src/tool/legalInterestRate/i18n/fr.ts +159 -0
  92. package/src/tool/legalInterestRate/index.ts +33 -0
  93. package/src/tool/legalInterestRate/logic.ts +29 -0
  94. package/src/tool/legalInterestRate/seo.astro +14 -0
  95. package/src/tool/legalInterestRate/ui.ts +23 -0
  96. package/src/tool/lotteryOptimizer/bibliography.astro +14 -0
  97. package/src/tool/lotteryOptimizer/component.astro +863 -0
  98. package/src/tool/lotteryOptimizer/i18n/en.ts +188 -0
  99. package/src/tool/lotteryOptimizer/i18n/es.ts +281 -0
  100. package/src/tool/lotteryOptimizer/i18n/fr.ts +188 -0
  101. package/src/tool/lotteryOptimizer/index.ts +33 -0
  102. package/src/tool/lotteryOptimizer/logic.ts +133 -0
  103. package/src/tool/lotteryOptimizer/seo.astro +14 -0
  104. package/src/tool/lotteryOptimizer/ui.ts +33 -0
  105. package/src/tool/mortgage/bibliography.astro +14 -0
  106. package/src/tool/mortgage/component.astro +1030 -0
  107. package/src/tool/mortgage/i18n/en.ts +227 -0
  108. package/src/tool/mortgage/i18n/es.ts +227 -0
  109. package/src/tool/mortgage/i18n/fr.ts +227 -0
  110. package/src/tool/mortgage/index.ts +34 -0
  111. package/src/tool/mortgage/logic.ts +118 -0
  112. package/src/tool/mortgage/seo.astro +14 -0
  113. package/src/tool/mortgage/ui.ts +35 -0
  114. package/src/tool/percentageCalculator/bibliography.astro +14 -0
  115. package/src/tool/percentageCalculator/component.astro +640 -0
  116. package/src/tool/percentageCalculator/i18n/en.ts +233 -0
  117. package/src/tool/percentageCalculator/i18n/es.ts +233 -0
  118. package/src/tool/percentageCalculator/i18n/fr.ts +233 -0
  119. package/src/tool/percentageCalculator/index.ts +34 -0
  120. package/src/tool/percentageCalculator/logic.ts +48 -0
  121. package/src/tool/percentageCalculator/seo.astro +14 -0
  122. package/src/tool/percentageCalculator/ui.ts +30 -0
  123. package/src/tool/rentIncreaseCalculator/bibliography.astro +14 -0
  124. package/src/tool/rentIncreaseCalculator/component.astro +587 -0
  125. package/src/tool/rentIncreaseCalculator/i18n/en.ts +159 -0
  126. package/src/tool/rentIncreaseCalculator/i18n/es.ts +220 -0
  127. package/src/tool/rentIncreaseCalculator/i18n/fr.ts +159 -0
  128. package/src/tool/rentIncreaseCalculator/index.ts +33 -0
  129. package/src/tool/rentIncreaseCalculator/logic.ts +43 -0
  130. package/src/tool/rentIncreaseCalculator/seo.astro +14 -0
  131. package/src/tool/rentIncreaseCalculator/ui.ts +19 -0
  132. package/src/tools.ts +16 -0
  133. package/src/types.ts +74 -0
@@ -0,0 +1,1052 @@
1
+ ---
2
+ import { Icon } from 'astro-icon/components';
3
+ import type { KnownLocale } from '../../types';
4
+
5
+ interface Props {
6
+ locale?: KnownLocale;
7
+ ui?: Record<string, unknown>;
8
+ }
9
+
10
+ const { ui } = Astro.props;
11
+ ---
12
+
13
+ <div class="snow-container" id="debt-calc-container" data-currency-symbol={ui.currencySymbol} data-currency-code={ui.currencyCode} data-currency-locale={ui.currencyLocale} data-clear-confirm={ui.labelClearDataConfirm}>
14
+ <div class="snow-main-card">
15
+ <aside class="snow-sidebar">
16
+ <h2 class="snow-title">
17
+ <Icon name="mdi:finance" size={36} />
18
+ <span>{ui.labelTitle}</span>
19
+ </h2>
20
+
21
+ <div class="snow-input-group">
22
+ <label class="snow-label" for="extra-budget">{ui.labelExtraBudget}</label>
23
+ <div class="snow-input-wrapper">
24
+ <input type="number" id="extra-budget" class="snow-input" value="100" min="0" step="50" />
25
+ <span class="snow-input-unit">{ui.currencySymbol}</span>
26
+ </div>
27
+ <p class="snow-help-text">{ui.labelExtraBudgetHelp}</p>
28
+ </div>
29
+
30
+ <div class="snow-method-tabs" id="method-switcher">
31
+ <button class="snow-tab active" data-method="snowball">{ui.labelSnowballMethod}</button>
32
+ <button class="snow-tab" data-method="avalanche">{ui.labelAvalancheMethod}</button>
33
+ </div>
34
+
35
+ <div class="snow-adder-card">
36
+ <span class="snow-section-label">{ui.labelAddNewDebt}</span>
37
+ <div class="snow-adder-content">
38
+ <div class="snow-input-group">
39
+ <input type="text" id="debt-name" class="snow-input" placeholder={ui.labelDebtNamePlaceholder} />
40
+ </div>
41
+ <div class="snow-grid-2">
42
+ <div class="snow-input-group">
43
+ <label class="snow-label" for="debt-balance">{ui.labelDebtBalance}</label>
44
+ <div class="snow-input-wrapper">
45
+ <input type="number" id="debt-balance" class="snow-input" placeholder="0" min="0" />
46
+ <span class="snow-input-unit">{ui.currencySymbol}</span>
47
+ </div>
48
+ </div>
49
+ <div class="snow-input-group">
50
+ <label class="snow-label" for="debt-min-payment">{ui.labelMinPayment}</label>
51
+ <div class="snow-input-wrapper">
52
+ <input type="number" id="debt-min-payment" class="snow-input" placeholder="0" min="0" />
53
+ <span class="snow-input-unit">{ui.currencySymbol}/mes</span>
54
+ </div>
55
+ </div>
56
+ </div>
57
+ <div class="snow-input-group">
58
+ <label class="snow-label" for="debt-rate">{ui.labelInterestRate}</label>
59
+ <div class="snow-input-wrapper">
60
+ <input type="number" id="debt-rate" class="snow-input" value="15" step="0.1" />
61
+ <span class="snow-input-unit">% {ui.labelInterestHelp}</span>
62
+ </div>
63
+ </div>
64
+ <p class="snow-input-help">{ui.labelAddNewDebt}</p>
65
+ <button id="add-debt-btn" class="snow-btn snow-btn-add">
66
+ <Icon name="mdi:plus-thick" size={18} />
67
+ <span>{ui.labelAddButton}</span>
68
+ </button>
69
+ </div>
70
+ </div>
71
+
72
+ <button id="clear-data" class="snow-btn-clear">
73
+ <Icon name="mdi:delete-sweep" size={14} />
74
+ <span>{ui.labelClearData}</span>
75
+ </button>
76
+ </aside>
77
+
78
+ <main class="snow-content">
79
+ <div id="no-debts-msg" class="snow-empty-view">
80
+ <div class="snow-empty-icon">
81
+ <Icon name="mdi:bank-off-outline" size={48} />
82
+ </div>
83
+ <h3 class="snow-empty-title">{ui.labelEmptyStateTitle}</h3>
84
+ <p class="snow-empty-description">{ui.labelEmptyStateDescription}</p>
85
+ </div>
86
+
87
+ <div id="results-content" class="snow-hidden">
88
+ <div class="snow-inventory-area">
89
+ <span class="snow-section-label">{ui.labelDebtInventory}</span>
90
+ <div class="snow-mini-table">
91
+ <div class="snow-table-row snow-table-header">
92
+ <span>{ui.labelDebtName}</span>
93
+ <span>{ui.labelDebtBalance}</span>
94
+ <span>{ui.labelMinPayment}</span>
95
+ <span>{ui.labelInterestRate}</span>
96
+ <span></span>
97
+ </div>
98
+ <div id="debt-items-list" class="snow-debt-list"></div>
99
+ </div>
100
+ </div>
101
+
102
+ <div class="snow-results-hero">
103
+ <div class="snow-hero-main">
104
+ <span class="snow-hero-label">{ui.labelFreedomDate}</span>
105
+ <div class="snow-hero-value" id="summary-date">ENERO 2026</div>
106
+ </div>
107
+ <div class="snow-hero-badge">
108
+ <Icon name="mdi:check-decagram" size={20} />
109
+ <span id="active-method-label">{ui.labelSnowballMethod}</span>
110
+ </div>
111
+ </div>
112
+
113
+ <div class="snow-grid-stats">
114
+ <div class="snow-stat-box">
115
+ <div class="snow-stat-value" id="stat-months">--</div>
116
+ <div class="snow-stat-label">{ui.labelMonthsRemaining}</div>
117
+ </div>
118
+ <div class="snow-stat-box">
119
+ <div class="snow-stat-value" id="stat-interest">--</div>
120
+ <div class="snow-stat-label">{ui.labelTotalInterest}</div>
121
+ </div>
122
+ <div class="snow-stat-box">
123
+ <div class="snow-stat-value" id="stat-total-paid">--</div>
124
+ <div class="snow-stat-label">{ui.labelTotalPaid}</div>
125
+ </div>
126
+ <div class="snow-stat-box snow-stat-highlight">
127
+ <div class="snow-stat-value" id="stat-extra-saved">--</div>
128
+ <div class="snow-stat-label">{ui.labelInterestSaved}</div>
129
+ </div>
130
+ </div>
131
+
132
+ <div class="snow-action-plan">
133
+ <div class="snow-action-header">
134
+ <h4 class="snow-action-title">{ui.labelYourStrategy}</h4>
135
+ <div class="snow-action-help">{ui.labelStrategyHelp}</div>
136
+ </div>
137
+ <div id="action-plan-list" class="snow-action-list"></div>
138
+ </div>
139
+
140
+ <div class="snow-table-container">
141
+ <div class="snow-table-header-action">
142
+ <h4 class="snow-table-title">{ui.labelDetailedAmortization}</h4>
143
+ <button id="export-csv" class="snow-btn-export">{ui.labelExportCSV}</button>
144
+ </div>
145
+ <div class="snow-table-overlay">
146
+ <div class="snow-table-scroll">
147
+ <table id="projection-table">
148
+ <thead>
149
+ <tr>
150
+ <th>{ui.labelDate}</th>
151
+ <th>{ui.labelStartingBalance}</th>
152
+ <th>{ui.labelInterest}</th>
153
+ <th>{ui.labelAmortization}</th>
154
+ <th>{ui.labelTotalMonth}</th>
155
+ <th>{ui.labelRemaining}</th>
156
+ </tr>
157
+ </thead>
158
+ <tbody></tbody>
159
+ </table>
160
+ </div>
161
+ </div>
162
+ </div>
163
+ </div>
164
+ </main>
165
+ </div>
166
+ </div>
167
+
168
+ <style>
169
+ :root {
170
+ --snow-emerald: #10b981;
171
+ --snow-blue: #3b82f6;
172
+ --snow-rose: #f43f5e;
173
+ --snow-bg-card: rgba(255, 255, 255, 0.95);
174
+ --snow-border: rgba(226, 232, 240, 0.8);
175
+ --snow-text-main: #0f172a;
176
+ --snow-text-muted: #64748b;
177
+ --snow-bg-input: rgba(255, 255, 255, 0.8);
178
+ --snow-bg-subtle: rgba(0, 0, 0, 0.02);
179
+ --snow-bg-tab: rgba(0, 0, 0, 0.05);
180
+ }
181
+
182
+ :global(.theme-dark),
183
+ :global([data-theme='dark']) {
184
+ --snow-bg-card: rgba(15, 23, 42, 0.9);
185
+ --snow-border: rgba(30, 41, 59, 1);
186
+ --snow-text-main: #f8fafc;
187
+ --snow-text-muted: #94a3b8;
188
+ --snow-bg-input: rgba(0, 0, 0, 0.3);
189
+ }
190
+
191
+ .snow-container {
192
+ width: 100%;
193
+ color: var(--snow-text-main);
194
+ }
195
+
196
+ .snow-main-card {
197
+ background: var(--snow-bg-card);
198
+ backdrop-filter: blur(24px);
199
+ -webkit-backdrop-filter: blur(24px);
200
+ border-radius: 2.5rem;
201
+ border: 1px solid var(--snow-border);
202
+ display: grid;
203
+ grid-template-columns: 1fr;
204
+ box-shadow: 0 40px 100px -20px rgba(0, 0, 0, 0.1);
205
+ overflow: hidden;
206
+ }
207
+
208
+ @media (min-width: 1024px) {
209
+ .snow-main-card {
210
+ grid-template-columns: 380px 1fr;
211
+ }
212
+ }
213
+
214
+ .snow-sidebar {
215
+ padding: 3rem 2.5rem;
216
+ background: var(--snow-bg-subtle);
217
+ border-right: 1px solid var(--snow-border);
218
+ overflow-y: auto;
219
+ max-height: 80vh;
220
+ }
221
+
222
+ .snow-title {
223
+ font-size: 1.75rem;
224
+ font-weight: 900;
225
+ letter-spacing: -0.04em;
226
+ display: flex;
227
+ align-items: center;
228
+ gap: 0.75rem;
229
+ margin-bottom: 2.5rem;
230
+ color: var(--snow-emerald);
231
+ }
232
+
233
+ .snow-section-label {
234
+ display: block;
235
+ font-size: 0.65rem;
236
+ font-weight: 900;
237
+ text-transform: uppercase;
238
+ letter-spacing: 0.15em;
239
+ opacity: 0.4;
240
+ margin-bottom: 1.5rem;
241
+ }
242
+
243
+ .snow-input-group {
244
+ margin-bottom: 1.5rem;
245
+ }
246
+
247
+ .snow-label {
248
+ display: block;
249
+ font-size: 0.75rem;
250
+ font-weight: 850;
251
+ margin-bottom: 0.6rem;
252
+ opacity: 0.8;
253
+ }
254
+
255
+ .snow-input-wrapper {
256
+ position: relative;
257
+ display: flex;
258
+ align-items: center;
259
+ }
260
+
261
+ .snow-input {
262
+ width: 100%;
263
+ background: var(--snow-bg-input);
264
+ border: 1.5px solid var(--snow-border);
265
+ padding: 1rem 1.25rem;
266
+ border-radius: 1.25rem;
267
+ font-size: 1rem;
268
+ font-weight: 700;
269
+ color: inherit;
270
+ transition: all 0.3s cubic-bezier(0.19, 1, 0.22, 1);
271
+ }
272
+
273
+ .snow-input:focus {
274
+ outline: none;
275
+ border-color: var(--snow-emerald);
276
+ box-shadow: 0 0 0 5px rgba(16, 185, 129, 0.1);
277
+ }
278
+
279
+ .snow-input-unit {
280
+ position: absolute;
281
+ right: 1.25rem;
282
+ font-weight: 900;
283
+ font-size: 0.75rem;
284
+ opacity: 0.3;
285
+ pointer-events: none;
286
+ }
287
+
288
+ .snow-help-text,
289
+ .snow-input-help {
290
+ font-size: 0.6rem;
291
+ line-height: 1.4;
292
+ opacity: 0.4;
293
+ font-weight: bold;
294
+ margin-top: 0.5rem;
295
+ }
296
+
297
+ .snow-method-tabs {
298
+ display: flex;
299
+ background: var(--snow-bg-tab);
300
+ padding: 0.4rem;
301
+ border-radius: 1.25rem;
302
+ margin: 2rem 0;
303
+ gap: 0.4rem;
304
+ }
305
+
306
+ .snow-tab {
307
+ flex: 1;
308
+ padding: 0.85rem;
309
+ border-radius: 1rem;
310
+ border: none;
311
+ font-weight: 900;
312
+ font-size: 0.75rem;
313
+ cursor: pointer;
314
+ background: transparent;
315
+ color: var(--snow-text-muted);
316
+ transition: all 0.3s cubic-bezier(0.19, 1, 0.22, 1);
317
+ }
318
+
319
+ .snow-tab.active {
320
+ background: white;
321
+ color: var(--snow-emerald);
322
+ box-shadow: 0 10px 20px -5px rgba(0, 0, 0, 0.1);
323
+ }
324
+
325
+ :global(.theme-dark) .snow-tab.active,
326
+ :global([data-theme='dark']) .snow-tab.active {
327
+ background: rgba(255, 255, 255, 0.1);
328
+ color: white;
329
+ }
330
+
331
+ .snow-adder-card {
332
+ background: rgba(16, 185, 129, 0.08);
333
+ border: 1.5px solid rgba(16, 185, 129, 0.2);
334
+ padding: 2rem 1.5rem;
335
+ border-radius: 1.75rem;
336
+ margin-bottom: 2rem;
337
+ }
338
+
339
+ .snow-adder-content {
340
+ display: flex;
341
+ flex-direction: column;
342
+ gap: 1rem;
343
+ }
344
+
345
+ .snow-grid-2 {
346
+ display: grid;
347
+ grid-template-columns: 1fr 1fr;
348
+ gap: 0.75rem;
349
+ }
350
+
351
+ .snow-btn {
352
+ padding: 0.85rem 1.5rem;
353
+ border-radius: 1rem;
354
+ border: none;
355
+ font-weight: 900;
356
+ font-size: 0.75rem;
357
+ cursor: pointer;
358
+ transition: all 0.3s cubic-bezier(0.19, 1, 0.22, 1);
359
+ display: flex;
360
+ align-items: center;
361
+ gap: 0.75rem;
362
+ justify-content: center;
363
+ text-transform: uppercase;
364
+ letter-spacing: 0.05em;
365
+ }
366
+
367
+ .snow-btn-add {
368
+ background: linear-gradient(135deg, var(--snow-emerald), var(--snow-blue));
369
+ color: white;
370
+ }
371
+
372
+ .snow-btn-add:hover {
373
+ transform: translateY(-2px);
374
+ box-shadow: 0 20px 40px -10px rgba(16, 185, 129, 0.2);
375
+ }
376
+
377
+ .snow-btn-clear {
378
+ margin-top: 2rem;
379
+ font-size: 0.65rem;
380
+ font-weight: 900;
381
+ text-transform: uppercase;
382
+ letter-spacing: 0.05em;
383
+ color: var(--snow-rose);
384
+ opacity: 0.5;
385
+ background: none;
386
+ border: none;
387
+ cursor: pointer;
388
+ display: flex;
389
+ align-items: center;
390
+ gap: 0.5rem;
391
+ transition: all 0.3s ease;
392
+ }
393
+
394
+ .snow-btn-clear:hover {
395
+ opacity: 1;
396
+ color: var(--snow-rose);
397
+ }
398
+
399
+ .snow-content {
400
+ padding: 3rem;
401
+ overflow-y: auto;
402
+ max-height: 80vh;
403
+ }
404
+
405
+ .snow-empty-view {
406
+ display: flex;
407
+ flex-direction: column;
408
+ align-items: center;
409
+ justify-content: center;
410
+ min-height: 400px;
411
+ text-align: center;
412
+ }
413
+
414
+ .snow-empty-icon {
415
+ font-size: 3rem;
416
+ opacity: 0.2;
417
+ margin-bottom: 1rem;
418
+ }
419
+
420
+ .snow-empty-title {
421
+ font-size: 1.5rem;
422
+ font-weight: 900;
423
+ text-transform: uppercase;
424
+ letter-spacing: -0.02em;
425
+ margin-bottom: 1rem;
426
+ }
427
+
428
+ .snow-empty-description {
429
+ max-width: 300px;
430
+ font-size: 0.875rem;
431
+ opacity: 0.5;
432
+ }
433
+
434
+ .snow-hidden {
435
+ display: none;
436
+ }
437
+
438
+ .snow-inventory-area {
439
+ margin-bottom: 2rem;
440
+ }
441
+
442
+ .snow-mini-table {
443
+ border: 1px solid var(--snow-border);
444
+ border-radius: 1rem;
445
+ overflow: hidden;
446
+ }
447
+
448
+ .snow-table-row {
449
+ display: grid;
450
+ grid-template-columns: 1fr 1fr 1fr 1fr 60px;
451
+ padding: 1rem;
452
+ font-size: 0.875rem;
453
+ border-bottom: 1px solid var(--snow-border);
454
+ align-items: center;
455
+ }
456
+
457
+ .snow-table-header {
458
+ background: var(--snow-bg-subtle);
459
+ font-weight: 900;
460
+ text-transform: uppercase;
461
+ font-size: 0.65rem;
462
+ opacity: 0.6;
463
+ }
464
+
465
+ .snow-debt-list {
466
+ max-height: 200px;
467
+ overflow-y: auto;
468
+ }
469
+
470
+ .snow-debt-item {
471
+ display: grid;
472
+ grid-template-columns: 1fr 1fr 1fr 1fr 60px;
473
+ padding: 1rem;
474
+ border-bottom: 1px solid var(--snow-border);
475
+ align-items: center;
476
+ gap: 1rem;
477
+ }
478
+
479
+ .snow-debt-remove {
480
+ background: none;
481
+ border: none;
482
+ color: var(--snow-rose);
483
+ cursor: pointer;
484
+ font-weight: 900;
485
+ font-size: 1.25rem;
486
+ opacity: 0.3;
487
+ transition: opacity 0.3s ease;
488
+ }
489
+
490
+ .snow-debt-remove:hover {
491
+ opacity: 1;
492
+ }
493
+
494
+ .snow-results-hero {
495
+ background: linear-gradient(135deg, rgba(16, 185, 129, 0.1), rgba(59, 130, 246, 0.1));
496
+ border: 1.5px solid var(--snow-border);
497
+ border-radius: 1.75rem;
498
+ padding: 3rem;
499
+ margin-bottom: 2rem;
500
+ display: flex;
501
+ justify-content: space-between;
502
+ align-items: center;
503
+ }
504
+
505
+ .snow-hero-main {
506
+ display: flex;
507
+ flex-direction: column;
508
+ gap: 0.5rem;
509
+ }
510
+
511
+ .snow-hero-label {
512
+ font-size: 0.65rem;
513
+ font-weight: 900;
514
+ text-transform: uppercase;
515
+ letter-spacing: 0.1em;
516
+ opacity: 0.6;
517
+ }
518
+
519
+ .snow-hero-value {
520
+ font-size: 2.5rem;
521
+ font-weight: 900;
522
+ letter-spacing: -0.02em;
523
+ color: var(--snow-emerald);
524
+ }
525
+
526
+ .snow-hero-badge {
527
+ display: flex;
528
+ align-items: center;
529
+ gap: 0.75rem;
530
+ background: rgba(16, 185, 129, 0.1);
531
+ padding: 1rem 1.5rem;
532
+ border-radius: 1rem;
533
+ font-weight: 900;
534
+ font-size: 0.75rem;
535
+ text-transform: uppercase;
536
+ letter-spacing: 0.05em;
537
+ color: var(--snow-emerald);
538
+ }
539
+
540
+ .snow-grid-stats {
541
+ display: grid;
542
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
543
+ gap: 1.5rem;
544
+ margin-bottom: 3rem;
545
+ }
546
+
547
+ .snow-stat-box {
548
+ background: var(--snow-bg-subtle);
549
+ border: 1px solid var(--snow-border);
550
+ border-radius: 1.25rem;
551
+ padding: 2rem 1.5rem;
552
+ text-align: center;
553
+ }
554
+
555
+ .snow-stat-highlight {
556
+ background: rgba(59, 130, 246, 0.08);
557
+ border-color: rgba(59, 130, 246, 0.2);
558
+ }
559
+
560
+ .snow-stat-value {
561
+ font-size: 1.875rem;
562
+ font-weight: 900;
563
+ color: var(--snow-emerald);
564
+ margin-bottom: 0.5rem;
565
+ }
566
+
567
+ .snow-stat-label {
568
+ font-size: 0.65rem;
569
+ font-weight: 900;
570
+ text-transform: uppercase;
571
+ letter-spacing: 0.1em;
572
+ opacity: 0.6;
573
+ }
574
+
575
+ .snow-action-plan {
576
+ margin-bottom: 3rem;
577
+ }
578
+
579
+ .snow-action-header {
580
+ display: flex;
581
+ align-items: center;
582
+ justify-content: space-between;
583
+ margin-bottom: 2rem;
584
+ flex-wrap: wrap;
585
+ gap: 1rem;
586
+ }
587
+
588
+ .snow-action-title {
589
+ font-size: 0.65rem;
590
+ font-weight: 900;
591
+ text-transform: uppercase;
592
+ letter-spacing: 0.1em;
593
+ opacity: 0.6;
594
+ }
595
+
596
+ .snow-action-help {
597
+ font-size: 0.6rem;
598
+ font-weight: 900;
599
+ background: rgba(59, 130, 246, 0.1);
600
+ padding: 0.5rem 1rem;
601
+ border-radius: 2rem;
602
+ text-transform: uppercase;
603
+ color: var(--snow-blue);
604
+ }
605
+
606
+ .snow-action-list {
607
+ display: flex;
608
+ flex-direction: column;
609
+ gap: 1rem;
610
+ }
611
+
612
+ .snow-plan-item {
613
+ display: grid;
614
+ grid-template-columns: 50px 1fr 1fr;
615
+ gap: 1.5rem;
616
+ align-items: center;
617
+ padding: 1.5rem;
618
+ background: var(--snow-bg-subtle);
619
+ border: 1.5px solid var(--snow-border);
620
+ border-radius: 1rem;
621
+ }
622
+
623
+ .snow-plan-item.active-step {
624
+ background: rgba(16, 185, 129, 0.05);
625
+ border-color: rgba(16, 185, 129, 0.3);
626
+ }
627
+
628
+ .snow-step-marker {
629
+ width: 50px;
630
+ height: 50px;
631
+ background: var(--snow-emerald);
632
+ color: white;
633
+ display: flex;
634
+ align-items: center;
635
+ justify-content: center;
636
+ border-radius: 50%;
637
+ font-weight: 900;
638
+ font-size: 1.25rem;
639
+ }
640
+
641
+ .snow-step-body {
642
+ display: flex;
643
+ flex-direction: column;
644
+ gap: 0.25rem;
645
+ }
646
+
647
+ .snow-step-label {
648
+ font-size: 0.65rem;
649
+ font-weight: 900;
650
+ text-transform: uppercase;
651
+ letter-spacing: 0.05em;
652
+ opacity: 0.6;
653
+ }
654
+
655
+ .snow-step-name {
656
+ font-size: 1rem;
657
+ font-weight: 900;
658
+ color: var(--snow-text-main);
659
+ }
660
+
661
+ .snow-step-amount {
662
+ display: flex;
663
+ flex-direction: column;
664
+ align-items: flex-end;
665
+ gap: 0.25rem;
666
+ text-align: right;
667
+ }
668
+
669
+ .snow-step-amount span {
670
+ font-size: 0.65rem;
671
+ opacity: 0.6;
672
+ text-transform: uppercase;
673
+ }
674
+
675
+ .snow-step-amount strong {
676
+ font-size: 1.25rem;
677
+ font-weight: 900;
678
+ color: var(--snow-emerald);
679
+ }
680
+
681
+ .snow-table-container {
682
+ margin-bottom: 2rem;
683
+ }
684
+
685
+ .snow-table-header-action {
686
+ display: flex;
687
+ align-items: center;
688
+ justify-content: space-between;
689
+ margin-bottom: 1.5rem;
690
+ flex-wrap: wrap;
691
+ gap: 1rem;
692
+ }
693
+
694
+ .snow-table-title {
695
+ font-size: 0.65rem;
696
+ font-weight: 900;
697
+ text-transform: uppercase;
698
+ letter-spacing: 0.1em;
699
+ opacity: 0.6;
700
+ }
701
+
702
+ .snow-btn-export {
703
+ font-size: 0.65rem;
704
+ font-weight: 900;
705
+ text-transform: uppercase;
706
+ border: none;
707
+ background: none;
708
+ color: var(--snow-emerald);
709
+ border-bottom: 2px solid rgba(16, 185, 129, 0.3);
710
+ cursor: pointer;
711
+ padding: 0.25rem 0;
712
+ transition: all 0.3s ease;
713
+ }
714
+
715
+ .snow-btn-export:hover {
716
+ border-bottom-color: var(--snow-emerald);
717
+ }
718
+
719
+ .snow-table-overlay {
720
+ border: 1px solid var(--snow-border);
721
+ border-radius: 1rem;
722
+ overflow: hidden;
723
+ }
724
+
725
+ .snow-table-scroll {
726
+ overflow-x: auto;
727
+ }
728
+
729
+ table {
730
+ width: 100%;
731
+ border-collapse: collapse;
732
+ font-size: 0.875rem;
733
+ }
734
+
735
+ table thead {
736
+ background: var(--snow-bg-subtle);
737
+ font-weight: 900;
738
+ text-transform: uppercase;
739
+ font-size: 0.65rem;
740
+ opacity: 0.6;
741
+ }
742
+
743
+ table th {
744
+ padding: 1rem;
745
+ text-align: left;
746
+ border-bottom: 1px solid var(--snow-border);
747
+ }
748
+
749
+ table td {
750
+ padding: 1rem;
751
+ border-bottom: 1px solid var(--snow-border);
752
+ }
753
+
754
+ table tr:last-child td {
755
+ border-bottom: none;
756
+ }
757
+
758
+ .snow-table-free {
759
+ color: var(--snow-emerald);
760
+ font-weight: 900;
761
+ }
762
+
763
+ .snow-table-muted {
764
+ opacity: 0.6;
765
+ }
766
+
767
+ @media (max-width: 768px) {
768
+ .snow-sidebar {
769
+ max-height: none;
770
+ }
771
+ .snow-content {
772
+ max-height: none;
773
+ }
774
+ .snow-results-hero {
775
+ flex-direction: column;
776
+ text-align: center;
777
+ gap: 1.5rem;
778
+ }
779
+ .snow-grid-2 {
780
+ grid-template-columns: 1fr;
781
+ }
782
+ .snow-table-row {
783
+ grid-template-columns: 1fr;
784
+ }
785
+ .snow-table-header {
786
+ display: none;
787
+ }
788
+ .snow-debt-item {
789
+ grid-template-columns: 1fr;
790
+ padding: 1rem 0;
791
+ border-left: 4px solid var(--snow-emerald);
792
+ padding-left: 1rem;
793
+ }
794
+ }
795
+ </style>
796
+
797
+ <script>
798
+ import { DebtSnowballLogic, type Debt, type SimulationResult } from './logic';
799
+ import { format, addMonths } from 'date-fns';
800
+ import { es } from 'date-fns/locale';
801
+
802
+ const container = document.getElementById('debt-calc-container');
803
+ const currencyLocale = container?.getAttribute('data-currency-locale') || 'es-ES';
804
+ const currencyCode = container?.getAttribute('data-currency-code') || 'EUR';
805
+ const clearConfirm = container?.getAttribute('data-clear-confirm') || '¿Vaciar plan?';
806
+
807
+ function formatCurrency(val: number): string {
808
+ return new Intl.NumberFormat(currencyLocale, {
809
+ style: 'currency',
810
+ currency: currencyCode,
811
+ maximumFractionDigits: 0,
812
+ }).format(val);
813
+ }
814
+
815
+ class SnowballEngine {
816
+ private debts: Debt[] = [];
817
+ private extra: number = 100;
818
+ private method: 'snowball' | 'avalanche' = 'snowball';
819
+
820
+ constructor() {
821
+ this.load();
822
+ this.bind();
823
+ this.render();
824
+ }
825
+
826
+ private bind() {
827
+ const get = (id: string) => document.getElementById(id);
828
+
829
+ get('add-debt-btn')?.addEventListener('click', () => this.add());
830
+
831
+ const extraInput = get('extra-budget') as HTMLInputElement;
832
+ extraInput?.addEventListener('input', (e) => {
833
+ const val = parseFloat((e.target as HTMLInputElement).value);
834
+ this.extra = isNaN(val) ? 0 : val;
835
+ this.save();
836
+ });
837
+
838
+ document.querySelectorAll('.snow-tab').forEach((tab) => {
839
+ tab.addEventListener('click', () => {
840
+ document.querySelectorAll('.snow-tab').forEach((t) => t.classList.remove('active'));
841
+ tab.classList.add('active');
842
+ this.method = (tab as HTMLElement).dataset.method as 'snowball' | 'avalanche' || 'snowball';
843
+ this.render();
844
+ });
845
+ });
846
+
847
+ get('clear-data')?.addEventListener('click', () => {
848
+ if (confirm(clearConfirm)) {
849
+ this.debts = [];
850
+ this.save();
851
+ }
852
+ });
853
+
854
+ get('export-csv')?.addEventListener('click', () => this.export());
855
+ }
856
+
857
+ private load() {
858
+ try {
859
+ const d = localStorage.getItem('snow_debts_v2');
860
+ if (d) this.debts = JSON.parse(d);
861
+ const e = localStorage.getItem('snow_extra_v2');
862
+ if (e) {
863
+ this.extra = parseFloat(e);
864
+ const input = document.getElementById('extra-budget') as HTMLInputElement;
865
+ if (input) input.value = e;
866
+ }
867
+ } catch {
868
+ this.debts = [];
869
+ }
870
+ }
871
+
872
+ private save() {
873
+ localStorage.setItem('snow_debts_v2', JSON.stringify(this.debts));
874
+ localStorage.setItem('snow_extra_v2', this.extra.toString());
875
+ this.render();
876
+ }
877
+
878
+ private getDebtInputs() {
879
+ return {
880
+ nameEl: document.getElementById('debt-name') as HTMLInputElement,
881
+ balEl: document.getElementById('debt-balance') as HTMLInputElement,
882
+ minEl: document.getElementById('debt-min-payment') as HTMLInputElement,
883
+ rateEl: document.getElementById('debt-rate') as HTMLInputElement,
884
+ };
885
+ }
886
+
887
+ private clearDebtInputs(nameEl: HTMLInputElement, balEl: HTMLInputElement, minEl: HTMLInputElement) {
888
+ balEl.value = '';
889
+ minEl.value = '';
890
+ nameEl.value = '';
891
+ }
892
+
893
+ private validateAndCreateDebt(b: number, m: number, name: string, rate: number): boolean {
894
+ if (b > 0 && m > 0) {
895
+ this.debts.push({ id: Math.random().toString(36).substring(2, 11), name, balance: b, minPayment: m, rate });
896
+ return true;
897
+ }
898
+ return false;
899
+ }
900
+
901
+ private add() {
902
+ const { nameEl, balEl, minEl, rateEl } = this.getDebtInputs();
903
+ if (!balEl || !minEl) return;
904
+
905
+ const b = parseFloat(balEl.value);
906
+ const m = parseFloat(minEl.value);
907
+ const name = nameEl?.value || `Deuda ${this.debts.length + 1}`;
908
+ const rate = parseFloat(rateEl?.value) || 0;
909
+
910
+ if (this.validateAndCreateDebt(b, m, name, rate)) {
911
+ this.clearDebtInputs(nameEl, balEl, minEl);
912
+ this.save();
913
+ }
914
+ }
915
+
916
+ private render() {
917
+ const snow = DebtSnowballLogic.simulate(this.debts, this.extra, 'snowball');
918
+ const aval = DebtSnowballLogic.simulate(this.debts, this.extra, 'avalanche');
919
+ const res = this.method === 'snowball' ? snow : aval;
920
+ this.renderView(res, snow, aval);
921
+ }
922
+
923
+ private renderView(res: SimulationResult | null, snow: SimulationResult | null, aval: SimulationResult | null) {
924
+ const noMsg = document.getElementById('no-debts-msg');
925
+ const results = document.getElementById('results-content');
926
+
927
+ if (!res || !snow || !aval) {
928
+ noMsg?.classList.remove('snow-hidden');
929
+ results?.classList.add('snow-hidden');
930
+ return;
931
+ }
932
+
933
+ noMsg?.classList.add('snow-hidden');
934
+ results?.classList.remove('snow-hidden');
935
+ this.updateDisplay(res);
936
+ this.updateDebtList();
937
+ this.updateActionPlan();
938
+ this.updateProjectionTable(res);
939
+ }
940
+
941
+ private updateDisplay(res: SimulationResult) {
942
+ const dateEl = document.getElementById('summary-date');
943
+ if (dateEl) {
944
+ if (res.isGrowing) {
945
+ dateEl.textContent = 'DEUDA INFINITA';
946
+ dateEl.style.color = 'var(--snow-rose)';
947
+ } else {
948
+ dateEl.textContent = format(addMonths(new Date(), res.mo), 'MMMM yyyy', { locale: es }).toUpperCase();
949
+ dateEl.style.color = 'var(--snow-emerald)';
950
+ }
951
+ }
952
+
953
+ const methEl = document.getElementById('active-method-label');
954
+ if (methEl) methEl.textContent = `MÉTODO ${this.method.toUpperCase()}`;
955
+
956
+ document.getElementById('stat-months')!.textContent = res.mo.toString();
957
+ document.getElementById('stat-interest')!.textContent = formatCurrency(res.totalI);
958
+ document.getElementById('stat-total-paid')!.textContent = formatCurrency(res.totalP);
959
+
960
+ const baseSim = DebtSnowballLogic.simulateNoExtra(this.debts);
961
+ const saved = baseSim ? baseSim.totalI - res.totalI : 0;
962
+ document.getElementById('stat-extra-saved')!.textContent = formatCurrency(Math.max(0, saved));
963
+ }
964
+
965
+ private updateDebtList() {
966
+ const list = document.getElementById('debt-items-list');
967
+ if (!list) return;
968
+ list.innerHTML = '';
969
+ this.debts.forEach((d) => {
970
+ const row = document.createElement('div');
971
+ row.className = 'snow-debt-item';
972
+ row.innerHTML = `
973
+ <span>${d.name}</span>
974
+ <span>${formatCurrency(d.balance)}</span>
975
+ <span>${formatCurrency(d.minPayment)}</span>
976
+ <span>${d.rate}%</span>
977
+ <button class="snow-debt-remove" title={ui.labelDeleteDebt}>&times;</button>
978
+ `;
979
+ row.querySelector('.snow-debt-remove')?.addEventListener('click', () => {
980
+ this.debts = this.debts.filter((item) => item.id !== d.id);
981
+ this.save();
982
+ });
983
+ list.appendChild(row);
984
+ });
985
+ }
986
+
987
+ private updateActionPlan() {
988
+ const planList = document.getElementById('action-plan-list');
989
+ if (!planList) return;
990
+ const ordered = [...this.debts].sort((a, b) =>
991
+ this.method === 'snowball' ? a.balance - b.balance : b.rate - a.rate
992
+ );
993
+ let cumulativeExtra = this.extra;
994
+ planList.innerHTML = ordered
995
+ .map((d, index) => {
996
+ const totalForThis = d.minPayment + cumulativeExtra;
997
+ cumulativeExtra += d.minPayment;
998
+ const stepLabel = index === 0 ? 'PRIORIDAD MÁXIMA' : `PASO ${index + 1}`;
999
+ return `
1000
+ <div class="snow-plan-item ${index === 0 ? 'active-step' : ''}">
1001
+ <div class="snow-step-marker">${index + 1}</div>
1002
+ <div class="snow-step-body">
1003
+ <span class="snow-step-label">${stepLabel}</span>
1004
+ <div class="snow-step-name">${d.name}</div>
1005
+ </div>
1006
+ <div class="snow-step-amount">
1007
+ <span>Pago mensual</span>
1008
+ <strong>${formatCurrency(totalForThis)}</strong>
1009
+ </div>
1010
+ </div>
1011
+ `;
1012
+ })
1013
+ .join('');
1014
+ }
1015
+
1016
+ private updateProjectionTable(res: SimulationResult) {
1017
+ const tbody = document.querySelector('#projection-table tbody');
1018
+ if (!tbody) return;
1019
+ tbody.innerHTML = res.proj
1020
+ .map(
1021
+ (p) => `
1022
+ <tr>
1023
+ <td>${format(addMonths(new Date(), p.mo), 'MMM yy', { locale: es }).toUpperCase()}</td>
1024
+ <td>${formatCurrency(p.iniB)}</td>
1025
+ <td>${formatCurrency(p.int)}</td>
1026
+ <td>${formatCurrency(p.princ)}</td>
1027
+ <td>${formatCurrency(p.total)}</td>
1028
+ <td class="${p.finB < 1 ? 'snow-table-free' : 'snow-table-muted'}">${p.finB < 1 ? 'LIBRE' : formatCurrency(p.finB)}</td>
1029
+ </tr>
1030
+ `
1031
+ )
1032
+ .join('');
1033
+ }
1034
+
1035
+ private export() {
1036
+ const res = DebtSnowballLogic.simulate(this.debts, this.extra, this.method);
1037
+ if (!res) return;
1038
+ let csv = '\uFEFFFecha,Saldo Inicial,Interes,Amortizacion,Pago Total,Saldo Final\n';
1039
+ res.proj.forEach((p) => {
1040
+ const d = format(addMonths(new Date(), p.mo), 'MM/yyyy');
1041
+ csv += `${d},${p.iniB.toFixed(2)},${p.int.toFixed(2)},${p.princ.toFixed(2)},${p.total.toFixed(2)},${p.finB.toFixed(2)}\n`;
1042
+ });
1043
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
1044
+ const link = document.createElement('a');
1045
+ link.href = URL.createObjectURL(blob);
1046
+ link.download = `plan_deuda_${this.method}.csv`;
1047
+ link.click();
1048
+ }
1049
+ }
1050
+
1051
+ new SnowballEngine();
1052
+ </script>