@jjlmoya/utils-finance 1.40.0 → 1.42.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 +1 -1
- package/src/category/index.ts +3 -2
- package/src/entries.ts +8 -1
- package/src/index.ts +1 -0
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/rentalDepositReturn/bibliography.astro +11 -0
- package/src/tool/rentalDepositReturn/bibliography.ts +6 -0
- package/src/tool/rentalDepositReturn/component.astro +53 -0
- package/src/tool/rentalDepositReturn/controller.ts +156 -0
- package/src/tool/rentalDepositReturn/currency.ts +67 -0
- package/src/tool/rentalDepositReturn/dom-views.ts +31 -0
- package/src/tool/rentalDepositReturn/entry.ts +28 -0
- package/src/tool/rentalDepositReturn/i18n/content-factory.ts +36 -0
- package/src/tool/rentalDepositReturn/i18n/de.ts +3 -0
- package/src/tool/rentalDepositReturn/i18n/en.ts +116 -0
- package/src/tool/rentalDepositReturn/i18n/es.ts +3 -0
- package/src/tool/rentalDepositReturn/i18n/fr.ts +3 -0
- package/src/tool/rentalDepositReturn/i18n/id.ts +3 -0
- package/src/tool/rentalDepositReturn/i18n/it.ts +3 -0
- package/src/tool/rentalDepositReturn/i18n/ja.ts +3 -0
- package/src/tool/rentalDepositReturn/i18n/ko.ts +3 -0
- package/src/tool/rentalDepositReturn/i18n/nl.ts +3 -0
- package/src/tool/rentalDepositReturn/i18n/pl.ts +3 -0
- package/src/tool/rentalDepositReturn/i18n/pt.ts +3 -0
- package/src/tool/rentalDepositReturn/i18n/ru.ts +3 -0
- package/src/tool/rentalDepositReturn/i18n/sv.ts +3 -0
- package/src/tool/rentalDepositReturn/i18n/tr.ts +3 -0
- package/src/tool/rentalDepositReturn/i18n/translations.ts +34 -0
- package/src/tool/rentalDepositReturn/i18n/zh.ts +3 -0
- package/src/tool/rentalDepositReturn/index.ts +11 -0
- package/src/tool/rentalDepositReturn/logic.test.ts +53 -0
- package/src/tool/rentalDepositReturn/logic.ts +108 -0
- package/src/tool/rentalDepositReturn/rental-deposit-return-calculator.css +591 -0
- package/src/tool/rentalDepositReturn/seo.astro +11 -0
- package/src/tool/rentalDepositReturn/ui.ts +53 -0
- package/src/tool/savingsRate/bibliography.astro +11 -0
- package/src/tool/savingsRate/bibliography.ts +12 -0
- package/src/tool/savingsRate/component.astro +326 -0
- package/src/tool/savingsRate/content.ts +1877 -0
- package/src/tool/savingsRate/entry.ts +31 -0
- package/src/tool/savingsRate/i18n/de.ts +3 -0
- package/src/tool/savingsRate/i18n/en.ts +1 -0
- package/src/tool/savingsRate/i18n/es.ts +3 -0
- package/src/tool/savingsRate/i18n/fr.ts +3 -0
- package/src/tool/savingsRate/i18n/id.ts +3 -0
- package/src/tool/savingsRate/i18n/it.ts +3 -0
- package/src/tool/savingsRate/i18n/ja.ts +3 -0
- package/src/tool/savingsRate/i18n/ko.ts +3 -0
- package/src/tool/savingsRate/i18n/nl.ts +3 -0
- package/src/tool/savingsRate/i18n/pl.ts +3 -0
- package/src/tool/savingsRate/i18n/pt.ts +3 -0
- package/src/tool/savingsRate/i18n/ru.ts +3 -0
- package/src/tool/savingsRate/i18n/sv.ts +3 -0
- package/src/tool/savingsRate/i18n/tr.ts +3 -0
- package/src/tool/savingsRate/i18n/zh.ts +3 -0
- package/src/tool/savingsRate/index.ts +11 -0
- package/src/tool/savingsRate/logic.test.ts +87 -0
- package/src/tool/savingsRate/logic.ts +121 -0
- package/src/tool/savingsRate/savings-rate-calculator.css +575 -0
- package/src/tool/savingsRate/seo.astro +11 -0
- package/src/tool/savingsRate/ui.ts +48 -0
- package/src/tools.ts +3 -1
package/package.json
CHANGED
package/src/category/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { FinanceCategoryEntry } from '../types';
|
|
2
2
|
import { compoundInterest } from '../tool/compoundInterest/entry';
|
|
3
|
-
import { financialFreedom } from '../tool/financialFreedom/entry';
|
|
3
|
+
import { financialFreedom } from '../tool/financialFreedom/entry';
|
|
4
|
+
import { savingsRate } from '../tool/savingsRate/entry';
|
|
4
5
|
import { profitability } from '../tool/profitability/entry';
|
|
5
6
|
import { mortgage } from '../tool/mortgage/entry';
|
|
6
7
|
import { inflation } from '../tool/inflation/entry';
|
|
@@ -23,7 +24,7 @@ import { loanToIncomeRatio } from '../tool/loanToIncomeRatio/entry';
|
|
|
23
24
|
|
|
24
25
|
export const financeCategory: FinanceCategoryEntry = {
|
|
25
26
|
icon: 'mdi:finance',
|
|
26
|
-
tools: [compoundInterest, financialFreedom, profitability, mortgage, inflation, percentageCalculator, lateInterest, ibanBicSwift, rentIncrease, lotteryOptimizer, courtFeeCalculator, legalInterestRate, fireCalculator, debtSnowball, revolvingCardCalculator, pesetaValue, salaryEmployerCost, dividendYield, inflationPurchasingPower, subscriptionCostAnnualizer, loanToIncomeRatio],
|
|
27
|
+
tools: [compoundInterest, financialFreedom, savingsRate, profitability, mortgage, inflation, percentageCalculator, lateInterest, ibanBicSwift, rentIncrease, lotteryOptimizer, courtFeeCalculator, legalInterestRate, fireCalculator, debtSnowball, revolvingCardCalculator, pesetaValue, salaryEmployerCost, dividendYield, inflationPurchasingPower, subscriptionCostAnnualizer, loanToIncomeRatio],
|
|
27
28
|
i18n: {
|
|
28
29
|
en: () => import('./i18n/en').then((m) => m.content),
|
|
29
30
|
es: () => import('./i18n/es').then((m) => m.content),
|
package/src/entries.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
import { savingsRate } from './tool/savingsRate/entry';
|
|
2
|
+
|
|
1
3
|
export { compoundInterest } from './tool/compoundInterest/entry';
|
|
2
4
|
export type { CompoundInterestLocaleContent } from './tool/compoundInterest/entry';
|
|
3
5
|
export { financialFreedom } from './tool/financialFreedom/entry';
|
|
4
6
|
export type { FinancialFreedomLocaleContent } from './tool/financialFreedom/entry';
|
|
7
|
+
export { savingsRate };
|
|
8
|
+
export type { SavingsRateLocaleContent } from './tool/savingsRate/entry';
|
|
5
9
|
export { profitability } from './tool/profitability/entry';
|
|
6
10
|
export type { ProfitabilityLocaleContent } from './tool/profitability/entry';
|
|
7
11
|
export { courtFeeCalculator } from './tool/courtFeeCalculator/entry';
|
|
@@ -40,6 +44,8 @@ export { subscriptionCostAnnualizer } from './tool/subscriptionCostAnnualizer/en
|
|
|
40
44
|
export type { SubscriptionCostAnnualizerLocaleContent } from './tool/subscriptionCostAnnualizer/entry';
|
|
41
45
|
export { loanToIncomeRatio } from './tool/loanToIncomeRatio/entry';
|
|
42
46
|
export type { LoanToIncomeRatioLocaleContent } from './tool/loanToIncomeRatio/entry';
|
|
47
|
+
export { rentalDepositReturn } from './tool/rentalDepositReturn/entry';
|
|
48
|
+
export type { RentalDepositReturnLocaleContent } from './tool/rentalDepositReturn/entry';
|
|
43
49
|
export { financeCategory } from './category';
|
|
44
50
|
import { compoundInterest } from './tool/compoundInterest/entry';
|
|
45
51
|
import { financialFreedom } from './tool/financialFreedom/entry';
|
|
@@ -63,4 +69,5 @@ import { mortgageExtraPaymentPlanner } from './tool/mortgageExtraPaymentPlanner/
|
|
|
63
69
|
import { inflationPurchasingPower } from './tool/inflationPurchasingPower/entry';
|
|
64
70
|
import { subscriptionCostAnnualizer } from './tool/subscriptionCostAnnualizer/entry';
|
|
65
71
|
import { loanToIncomeRatio } from './tool/loanToIncomeRatio/entry';
|
|
66
|
-
|
|
72
|
+
import { rentalDepositReturn } from './tool/rentalDepositReturn/entry';
|
|
73
|
+
export const ALL_ENTRIES = [compoundInterest, financialFreedom, savingsRate, profitability, courtFeeCalculator, debtSnowball, fireCalculator, ibanBicSwift, inflation, lateInterest, legalInterestRate, lotteryOptimizer, mortgage, percentageCalculator, rentIncrease, revolvingCardCalculator, pesetaValue, salaryEmployerCost, dividendYield, mortgageExtraPaymentPlanner, inflationPurchasingPower, subscriptionCostAnnualizer, loanToIncomeRatio, rentalDepositReturn];
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ export type {
|
|
|
18
18
|
export { ALL_ENTRIES, ALL_TOOLS } from './tools';
|
|
19
19
|
|
|
20
20
|
export { COMPOUND_INTEREST_TOOL } from './tool/compoundInterest';
|
|
21
|
+
export { SAVINGS_RATE_TOOL } from './tool/savingsRate';
|
|
21
22
|
export { MORTGAGE_TOOL } from './tool/mortgage';
|
|
22
23
|
export { INFLATION_TOOL } from './tool/inflation';
|
|
23
24
|
export { PERCENTAGE_CALCULATOR_TOOL } from './tool/percentageCalculator';
|
|
@@ -4,8 +4,8 @@ import { financeCategory } from '../data';
|
|
|
4
4
|
|
|
5
5
|
describe('Tool Validation Suite', () => {
|
|
6
6
|
describe('Library Registration', () => {
|
|
7
|
-
it('should have
|
|
8
|
-
expect(ALL_TOOLS.length).toBe(
|
|
7
|
+
it('should have 24 tools in ALL_TOOLS', () => {
|
|
8
|
+
expect(ALL_TOOLS.length).toBe(24);
|
|
9
9
|
});
|
|
10
10
|
|
|
11
11
|
it('financeCategory should be defined', () => {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { rentalDepositReturn } from './entry';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props { locale?: KnownLocale; }
|
|
7
|
+
const { locale = 'en' } = Astro.props;
|
|
8
|
+
const content = await rentalDepositReturn.i18n[locale]?.() ?? await rentalDepositReturn.i18n.en?.();
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
{content && <SharedBibliography links={content.bibliography} />}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliography: BibliographyEntry[] = [
|
|
4
|
+
{ name: 'Boletín Oficial del Estado, Ley 29/1994 de Arrendamientos Urbanos, artículo 36', url: 'https://www.boe.es/buscar/act.php?id=BOE-A-1994-26003' },
|
|
5
|
+
{ name: 'GOV.UK, Tenancy deposit protection: Overview', url: 'https://www.gov.uk/tenancy-deposit-protection' },
|
|
6
|
+
];
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { RentalDepositReturnUI } from './ui';
|
|
3
|
+
import { CURRENCIES, getDefaultCurrency, type CurrencyCode } from './currency';
|
|
4
|
+
import type { RentalDepositInput } from './logic';
|
|
5
|
+
import { calculateRentalDepositReturn } from './logic';
|
|
6
|
+
import { renderDepositResults, renderDepositScene } from './dom-views';
|
|
7
|
+
import './rental-deposit-return-calculator.css';
|
|
8
|
+
|
|
9
|
+
interface Props { ui: RentalDepositReturnUI; locale?: string; }
|
|
10
|
+
|
|
11
|
+
const { ui, locale = 'en' } = Astro.props;
|
|
12
|
+
const initialCurrency = getDefaultCurrency(locale);
|
|
13
|
+
const initial: RentalDepositInput = {
|
|
14
|
+
currency: initialCurrency,
|
|
15
|
+
deposit: 1200,
|
|
16
|
+
deductions: [
|
|
17
|
+
{ id: 'final-bills', description: 'Final utility bills', amount: 84, status: 'confirmed' },
|
|
18
|
+
{ id: 'wall-repair', description: 'Wall repair', amount: 165, status: 'disputed' },
|
|
19
|
+
{ id: 'cleaning', description: 'Cleaning charge', amount: 60, status: 'disputed' },
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
const initialResult = calculateRentalDepositReturn(initial);
|
|
23
|
+
const config = JSON.stringify({ ui, initial, locale });
|
|
24
|
+
const currencyOptions = Object.values(CURRENCIES) as Array<{ code: CurrencyCode; symbol: string }>;
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
<div class="rdr-tool" data-rdr-tool>
|
|
28
|
+
<form class="rdr-controls" aria-label={ui.deductionsTitle}>
|
|
29
|
+
<div class="rdr-top-controls">
|
|
30
|
+
<label class="rdr-deposit-field"><span>{ui.depositLabel}</span><input data-rdr-deposit type="number" min="0" step="0.01" inputmode="decimal" required /><small>{ui.depositHelp}</small></label>
|
|
31
|
+
<div class="rdr-currency-field"><span class="rdr-field-label">{ui.currencyLabel}</span><div class="rdr-currency-select" data-rdr-currency-select data-value={initialCurrency}>
|
|
32
|
+
<button type="button" class="rdr-currency-trigger" id="rdr-currency-trigger" data-rdr-currency-trigger aria-haspopup="listbox" aria-expanded="false" aria-controls="rdr-currency-menu" aria-label={`${ui.currencyLabel}: ${initialCurrency}`}><span class="rdr-currency-trigger-symbol" data-rdr-currency-symbol aria-hidden="true">{CURRENCIES[initialCurrency].symbol}</span><span class="rdr-currency-trigger-arrow" aria-hidden="true"></span></button>
|
|
33
|
+
<div class="rdr-currency-menu" id="rdr-currency-menu" data-rdr-currency-menu role="listbox" hidden aria-label={ui.currencyLabel}>
|
|
34
|
+
{currencyOptions.map((option) => <button type="button" class="rdr-currency-option" data-rdr-currency-option data-value={option.code} role="option" aria-label={`${option.code}: ${option.symbol}`} aria-selected={option.code === initialCurrency}><span aria-hidden="true">{option.symbol}</span></button>)}
|
|
35
|
+
</div>
|
|
36
|
+
</div><small>{ui.currencyHelp}</small></div>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="rdr-list-heading"><div><h2>{ui.deductionsTitle}</h2><p>{ui.deductionsHelp}</p></div><button type="button" class="rdr-secondary-button" data-rdr-reset>{ui.resetButton}</button></div>
|
|
39
|
+
<div class="rdr-list" data-rdr-list></div>
|
|
40
|
+
<div class="rdr-actions"><button type="button" class="rdr-primary-button" data-rdr-add>{ui.addButton}</button><button type="button" class="rdr-copy-button" data-rdr-copy><span data-rdr-copy-label>{ui.copyButton}</span></button></div>
|
|
41
|
+
</form>
|
|
42
|
+
<section class="rdr-output" aria-live="polite"><div class="rdr-scene-wrap" data-rdr-scene set:html={initialResult ? renderDepositScene(initialResult, ui, locale) : ''}></div><div class="rdr-results-wrap" data-rdr-results set:html={initialResult ? renderDepositResults(initialResult, ui, locale) : ''}></div><p class="rdr-error" data-rdr-error hidden>{ui.emptyMessage}</p></section>
|
|
43
|
+
<p class="rdr-privacy">{ui.privacyNote}</p>
|
|
44
|
+
<details class="rdr-method"><summary>{ui.methodTitle}</summary><p>{ui.methodText}</p><p>{ui.limitationText}</p></details>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<script is:inline type="application/json" data-rdr-config set:html={config}></script>
|
|
48
|
+
<script>
|
|
49
|
+
import { initRentalDepositReturn } from './controller';
|
|
50
|
+
const root = document.querySelector<HTMLElement>('[data-rdr-tool]');
|
|
51
|
+
const configElement = document.querySelector<HTMLScriptElement>('[data-rdr-config]');
|
|
52
|
+
if (root && configElement?.textContent) initRentalDepositReturn(root, JSON.parse(configElement.textContent));
|
|
53
|
+
</script>
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { calculateRentalDepositReturn, type DeductionInput, type DeductionStatus, type RentalDepositInput } from './logic';
|
|
2
|
+
import { CURRENCIES, type CurrencyCode } from './currency';
|
|
3
|
+
import { renderDepositResults, renderDepositScene } from './dom-views';
|
|
4
|
+
import type { RentalDepositReturnUI } from './ui';
|
|
5
|
+
|
|
6
|
+
interface Config { ui: RentalDepositReturnUI; initial: RentalDepositInput; locale: string; }
|
|
7
|
+
|
|
8
|
+
function createId(): string {
|
|
9
|
+
return `deduction-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function escapeAttribute(value: string): string {
|
|
13
|
+
return value.replace(/[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character] ?? character);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function fieldValue(row: HTMLElement, selector: string): string {
|
|
17
|
+
return row.querySelector<HTMLInputElement | HTMLSelectElement>(selector)?.value.trim() ?? '';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readDeductions(root: HTMLElement): DeductionInput[] {
|
|
21
|
+
return Array.from(root.querySelectorAll<HTMLElement>('[data-rdr-row]')).map((row) => ({
|
|
22
|
+
id: row.dataset.rdrRow ?? createId(),
|
|
23
|
+
description: fieldValue(row, '[data-rdr-field="description"]'),
|
|
24
|
+
amount: Number(fieldValue(row, '[data-rdr-field="amount"]')),
|
|
25
|
+
status: (fieldValue(row, '[data-rdr-field="status"]') || 'confirmed') as DeductionStatus,
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function collectState(root: HTMLElement): RentalDepositInput {
|
|
30
|
+
return {
|
|
31
|
+
currency: root.querySelector<HTMLElement>('[data-rdr-currency-select]')?.dataset.value ?? 'EUR',
|
|
32
|
+
deposit: Number(root.querySelector<HTMLInputElement>('[data-rdr-deposit]')?.value ?? 0),
|
|
33
|
+
deductions: readDeductions(root),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function render(root: HTMLElement, state: RentalDepositInput, ui: RentalDepositReturnUI, locale: string): void {
|
|
38
|
+
const result = calculateRentalDepositReturn(state);
|
|
39
|
+
const scene = root.querySelector<HTMLElement>('[data-rdr-scene]');
|
|
40
|
+
const results = root.querySelector<HTMLElement>('[data-rdr-results]');
|
|
41
|
+
const error = root.querySelector<HTMLElement>('[data-rdr-error]');
|
|
42
|
+
if (!scene || !results || !error) return;
|
|
43
|
+
if (!result) {
|
|
44
|
+
scene.innerHTML = '';
|
|
45
|
+
results.innerHTML = '';
|
|
46
|
+
error.hidden = false;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
error.hidden = true;
|
|
50
|
+
scene.innerHTML = renderDepositScene(result, ui, locale);
|
|
51
|
+
results.innerHTML = renderDepositResults(result, ui, locale);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function rowMarkup(item: DeductionInput, ui: RentalDepositReturnUI): string {
|
|
55
|
+
return `<div class="rdr-row" data-rdr-row="${escapeAttribute(item.id)}"><label><span>${escapeAttribute(ui.descriptionLabel)}</span><input data-rdr-field="description" value="${escapeAttribute(item.description)}" placeholder="${escapeAttribute(ui.descriptionPlaceholder)}" required /></label><label><span>${escapeAttribute(ui.amountLabel)}</span><input data-rdr-field="amount" type="number" min="0" step="0.01" value="${item.amount}" inputmode="decimal" required /></label><label><span>${escapeAttribute(ui.statusLabel)}</span><span class="rdr-select-wrap"><select data-rdr-field="status"><option value="confirmed" ${item.status === 'confirmed' ? 'selected' : ''}>${escapeAttribute(ui.confirmedOption)}</option><option value="disputed" ${item.status === 'disputed' ? 'selected' : ''}>${escapeAttribute(ui.disputedOption)}</option></select><span class="rdr-select-arrow" aria-hidden="true"></span></span></label><button type="button" class="rdr-remove" data-rdr-remove aria-label="${escapeAttribute(ui.removeButton)}">×</button></div>`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function report(result: ReturnType<typeof calculateRentalDepositReturn>, ui: RentalDepositReturnUI, locale: string): string {
|
|
59
|
+
if (!result) return '';
|
|
60
|
+
const format = (value: number): string => new Intl.NumberFormat(locale, { style: 'currency', currency: result.currency }).format(value);
|
|
61
|
+
const lines = result.deductions.map((deduction) => `- ${deduction.description}: ${format(deduction.amount)} (${deduction.status === 'confirmed' ? ui.confirmedOption : ui.disputedOption})`);
|
|
62
|
+
return [ui.reportTitle, `${ui.reportDeposit}: ${format(result.deposit)}`, `${ui.reportDeductions}:`, ...lines, `${ui.reportConfirmed}: ${format(result.confirmedDeductions)}`, `${ui.reportDisputed}: ${format(result.disputedDeductions)}`, `${ui.reportEstimatedReturn}: ${format(result.estimatedReturn)}`, `${ui.reportPotentialReturn}: ${format(result.undisputedReturn)}`, ui.reportNote].join('\n');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// eslint-disable-next-line max-lines-per-function, complexity
|
|
66
|
+
export function initRentalDepositReturn(root: HTMLElement, config: Config): void {
|
|
67
|
+
const deposit = root.querySelector<HTMLInputElement>('[data-rdr-deposit]');
|
|
68
|
+
const currency = root.querySelector<HTMLElement>('[data-rdr-currency-select]');
|
|
69
|
+
const currencyTrigger = currency?.querySelector<HTMLButtonElement>('[data-rdr-currency-trigger]');
|
|
70
|
+
const currencyMenu = currency?.querySelector<HTMLElement>('[data-rdr-currency-menu]');
|
|
71
|
+
const list = root.querySelector<HTMLElement>('[data-rdr-list]');
|
|
72
|
+
const copy = root.querySelector<HTMLButtonElement>('[data-rdr-copy]');
|
|
73
|
+
if (!deposit || !currency || !currencyTrigger || !currencyMenu || !list || !copy) return;
|
|
74
|
+
const initialState = config.initial;
|
|
75
|
+
deposit.value = String(initialState.deposit);
|
|
76
|
+
currency.dataset.value = initialState.currency;
|
|
77
|
+
list.innerHTML = initialState.deductions.map((item) => rowMarkup(item, config.ui)).join('');
|
|
78
|
+
render(root, initialState, config.ui, config.locale);
|
|
79
|
+
root.addEventListener('input', () => render(root, collectState(root), config.ui, config.locale));
|
|
80
|
+
root.addEventListener('change', () => render(root, collectState(root), config.ui, config.locale));
|
|
81
|
+
const closeCurrencyMenu = (): void => {
|
|
82
|
+
currencyMenu.hidden = true;
|
|
83
|
+
currencyTrigger.setAttribute('aria-expanded', 'false');
|
|
84
|
+
};
|
|
85
|
+
const syncCurrency = (value: CurrencyCode): void => {
|
|
86
|
+
currency.dataset.value = value;
|
|
87
|
+
const definition = CURRENCIES[value];
|
|
88
|
+
const symbol = currency.querySelector<HTMLElement>('[data-rdr-currency-symbol]');
|
|
89
|
+
if (symbol) symbol.textContent = definition.symbol;
|
|
90
|
+
currencyTrigger.setAttribute('aria-label', `${config.ui.currencyLabel}: ${value}`);
|
|
91
|
+
currency.querySelectorAll<HTMLButtonElement>('[data-rdr-currency-option]').forEach((option) => {
|
|
92
|
+
option.classList.toggle('is-active', option.dataset.value === value);
|
|
93
|
+
option.setAttribute('aria-selected', `${option.dataset.value === value}`);
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
syncCurrency(initialState.currency as CurrencyCode);
|
|
97
|
+
const openCurrencyMenu = (focusActive = false): void => {
|
|
98
|
+
currencyMenu.hidden = false;
|
|
99
|
+
currencyTrigger.setAttribute('aria-expanded', 'true');
|
|
100
|
+
if (focusActive) currency.querySelector<HTMLButtonElement>('.rdr-currency-option.is-active')?.focus();
|
|
101
|
+
};
|
|
102
|
+
currencyTrigger.addEventListener('click', () => {
|
|
103
|
+
const shouldOpen = currencyMenu.hidden;
|
|
104
|
+
closeCurrencyMenu();
|
|
105
|
+
if (shouldOpen) openCurrencyMenu();
|
|
106
|
+
});
|
|
107
|
+
currencyTrigger.addEventListener('keydown', (event) => {
|
|
108
|
+
if (event.key === 'Escape') closeCurrencyMenu();
|
|
109
|
+
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
|
|
110
|
+
event.preventDefault();
|
|
111
|
+
openCurrencyMenu(true);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
currencyMenu.querySelectorAll<HTMLButtonElement>('[data-rdr-currency-option]').forEach((option) => {
|
|
115
|
+
option.addEventListener('click', () => {
|
|
116
|
+
const value = option.dataset.value as CurrencyCode;
|
|
117
|
+
syncCurrency(value);
|
|
118
|
+
closeCurrencyMenu();
|
|
119
|
+
currencyTrigger.focus();
|
|
120
|
+
render(root, collectState(root), config.ui, config.locale);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
document.addEventListener('click', (event) => {
|
|
124
|
+
if (!currency.contains(event.target as Node)) closeCurrencyMenu();
|
|
125
|
+
});
|
|
126
|
+
root.addEventListener('click', (event) => {
|
|
127
|
+
const target = event.target as HTMLElement;
|
|
128
|
+
const remove = target.closest<HTMLButtonElement>('[data-rdr-remove]');
|
|
129
|
+
if (remove) {
|
|
130
|
+
remove.closest<HTMLElement>('[data-rdr-row]')?.remove();
|
|
131
|
+
render(root, collectState(root), config.ui, config.locale);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (target.closest('[data-rdr-add]')) {
|
|
135
|
+
const next: DeductionInput = { id: createId(), description: config.ui.newDeductionLabel, amount: 0, status: 'confirmed' };
|
|
136
|
+
list.insertAdjacentHTML('beforeend', rowMarkup(next, config.ui));
|
|
137
|
+
list.querySelector<HTMLElement>('[data-rdr-row]:last-child [data-rdr-field="description"]')?.focus();
|
|
138
|
+
render(root, collectState(root), config.ui, config.locale);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (target.closest('[data-rdr-reset]')) {
|
|
142
|
+
deposit.value = String(initialState.deposit);
|
|
143
|
+
syncCurrency(initialState.currency as CurrencyCode);
|
|
144
|
+
list.innerHTML = initialState.deductions.map((item) => rowMarkup(item, config.ui)).join('');
|
|
145
|
+
render(root, initialState, config.ui, config.locale);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (target.closest('[data-rdr-copy]')) {
|
|
149
|
+
const result = calculateRentalDepositReturn(collectState(root));
|
|
150
|
+
navigator.clipboard.writeText(report(result, config.ui, config.locale));
|
|
151
|
+
const label = copy.querySelector('[data-rdr-copy-label]');
|
|
152
|
+
if (label) label.textContent = config.ui.copiedButton;
|
|
153
|
+
window.setTimeout(() => { if (label) label.textContent = config.ui.copyButton; }, 2000);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export type CurrencyCode =
|
|
2
|
+
| 'AUD' | 'BRL' | 'CAD' | 'CHF' | 'CNY' | 'CZK' | 'DKK'
|
|
3
|
+
| 'EUR' | 'GBP' | 'IDR' | 'INR' | 'JPY' | 'KRW' | 'MXN' | 'NOK'
|
|
4
|
+
| 'PLN' | 'RUB' | 'SEK' | 'TRY' | 'USD';
|
|
5
|
+
|
|
6
|
+
export interface CurrencyDefinition {
|
|
7
|
+
code: CurrencyCode;
|
|
8
|
+
symbol: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const CURRENCIES = {
|
|
12
|
+
AUD: { code: 'AUD', symbol: 'A$' },
|
|
13
|
+
BRL: { code: 'BRL', symbol: 'R$' },
|
|
14
|
+
CAD: { code: 'CAD', symbol: 'C$' },
|
|
15
|
+
CHF: { code: 'CHF', symbol: 'CHF' },
|
|
16
|
+
CNY: { code: 'CNY', symbol: '¥' },
|
|
17
|
+
CZK: { code: 'CZK', symbol: 'Kč' },
|
|
18
|
+
DKK: { code: 'DKK', symbol: 'kr' },
|
|
19
|
+
EUR: { code: 'EUR', symbol: '€' },
|
|
20
|
+
GBP: { code: 'GBP', symbol: '£' },
|
|
21
|
+
IDR: { code: 'IDR', symbol: 'Rp' },
|
|
22
|
+
INR: { code: 'INR', symbol: '₹' },
|
|
23
|
+
JPY: { code: 'JPY', symbol: '¥' },
|
|
24
|
+
KRW: { code: 'KRW', symbol: '₩' },
|
|
25
|
+
MXN: { code: 'MXN', symbol: 'MX$' },
|
|
26
|
+
NOK: { code: 'NOK', symbol: 'kr' },
|
|
27
|
+
PLN: { code: 'PLN', symbol: 'zł' },
|
|
28
|
+
RUB: { code: 'RUB', symbol: '₽' },
|
|
29
|
+
SEK: { code: 'SEK', symbol: 'kr' },
|
|
30
|
+
TRY: { code: 'TRY', symbol: '₺' },
|
|
31
|
+
USD: { code: 'USD', symbol: '$' },
|
|
32
|
+
} as const satisfies Record<CurrencyCode, CurrencyDefinition>;
|
|
33
|
+
|
|
34
|
+
const DEFAULT_CURRENCY_BY_LOCALE: Record<string, CurrencyCode> = {
|
|
35
|
+
'en-au': 'AUD',
|
|
36
|
+
'en-ca': 'CAD',
|
|
37
|
+
'en-gb': 'GBP',
|
|
38
|
+
'en-in': 'INR',
|
|
39
|
+
'fr-ca': 'CAD',
|
|
40
|
+
'pt-br': 'BRL',
|
|
41
|
+
de: 'EUR',
|
|
42
|
+
en: 'USD',
|
|
43
|
+
es: 'EUR',
|
|
44
|
+
fr: 'EUR',
|
|
45
|
+
id: 'IDR',
|
|
46
|
+
it: 'EUR',
|
|
47
|
+
ja: 'JPY',
|
|
48
|
+
ko: 'KRW',
|
|
49
|
+
nl: 'EUR',
|
|
50
|
+
pl: 'PLN',
|
|
51
|
+
pt: 'EUR',
|
|
52
|
+
ru: 'RUB',
|
|
53
|
+
sv: 'SEK',
|
|
54
|
+
tr: 'TRY',
|
|
55
|
+
zh: 'CNY',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function getDefaultCurrency(locale: string): CurrencyCode {
|
|
59
|
+
const normalizedLocale = locale.toLowerCase().replace('_', '-');
|
|
60
|
+
return DEFAULT_CURRENCY_BY_LOCALE[normalizedLocale]
|
|
61
|
+
?? DEFAULT_CURRENCY_BY_LOCALE[normalizedLocale.split('-')[0] ?? '']
|
|
62
|
+
?? 'EUR';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function isCurrencyCode(value: string): value is CurrencyCode {
|
|
66
|
+
return Object.prototype.hasOwnProperty.call(CURRENCIES, value);
|
|
67
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { RentalDepositResult } from './logic';
|
|
2
|
+
import type { RentalDepositReturnUI } from './ui';
|
|
3
|
+
|
|
4
|
+
function escapeHtml(value: string): string {
|
|
5
|
+
return value.replace(/[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character] ?? character);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function formatMoney(value: number, currency: string, locale: string): string {
|
|
9
|
+
return new Intl.NumberFormat(locale, { style: 'currency', currency, maximumFractionDigits: 2 }).format(value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function metric(label: string, value: string, accent = ''): string {
|
|
13
|
+
return `<div class="rdr-metric ${accent}"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function renderDepositScene(result: RentalDepositResult, ui: RentalDepositReturnUI, locale = 'en-US'): string {
|
|
17
|
+
const used = result.deposit > 0 ? Math.min(100, (result.totalDeductions / result.deposit) * 100) : 0;
|
|
18
|
+
const returnWidth = result.deposit > 0 ? Math.max(0, 100 - used) : 0;
|
|
19
|
+
const rows = result.deductions.length === 0
|
|
20
|
+
? `<p class="rdr-scene-empty">${escapeHtml(ui.emptyMessage)}</p>`
|
|
21
|
+
: result.deductions.map((deduction) => `<div class="rdr-ledger-row ${deduction.status}"><span>${escapeHtml(deduction.description)}</span><i class="rdr-ledger-line"><b style="width:${Math.min(100, Math.max(0, deduction.shareOfDeposit)).toFixed(1)}%"></b></i><strong>${escapeHtml(formatMoney(deduction.amount, result.currency, locale))}</strong></div>`).join('');
|
|
22
|
+
return `<div class="rdr-scene" aria-label="${escapeHtml(ui.returnFlowTitle)}"><div class="rdr-scene-heading"><span class="rdr-key-mark" aria-hidden="true">⌁</span><div><strong>${escapeHtml(ui.returnFlowTitle)}</strong><p>${escapeHtml(ui.returnFlowText)}</p></div></div><div class="rdr-flow"><div class="rdr-flow-node rdr-flow-deposit"><span aria-hidden="true">●</span><strong>${escapeHtml(formatMoney(result.deposit, result.currency, locale))}</strong><small>${escapeHtml(ui.depositNode)}</small></div><div class="rdr-flow-arrow" aria-hidden="true">→</div><div class="rdr-flow-node rdr-flow-return" style="--return-width:${returnWidth.toFixed(1)}%"><span aria-hidden="true">↗</span><strong>${escapeHtml(formatMoney(result.estimatedReturn, result.currency, locale))}</strong><small>${escapeHtml(ui.returnNode)}</small></div></div><div class="rdr-meter" aria-label="${escapeHtml(ui.deductionNode)}"><span style="width:${used.toFixed(1)}%"></span></div><div class="rdr-meter-labels"><span>${escapeHtml(ui.deductionNode)} ${escapeHtml(formatMoney(result.totalDeductions, result.currency, locale))}</span><span>${escapeHtml(ui.returnNode)} ${escapeHtml(formatMoney(result.estimatedReturn, result.currency, locale))}</span></div><div class="rdr-ledger"><div class="rdr-ledger-title"><span>${escapeHtml(ui.deductionBreakdownTitle)}</span><span>${escapeHtml(ui.amountLabel)}</span></div>${rows}</div></div>`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function renderDepositResults(result: RentalDepositResult, ui: RentalDepositReturnUI, locale = 'en-US'): string {
|
|
26
|
+
const scenarioRows = result.scenarios.length === 0
|
|
27
|
+
? `<p class="rdr-muted">${escapeHtml(ui.noDisputed)}</p>`
|
|
28
|
+
: `<div class="rdr-table-wrap"><table><thead><tr><th scope="col">${escapeHtml(ui.scenarioColumn)}</th><th scope="col">${escapeHtml(ui.scenarioReturnColumn)}</th><th scope="col">${escapeHtml(ui.scenarioIncreaseColumn)}</th></tr></thead><tbody>${result.scenarios.map((scenario) => `<tr><th scope="row">${escapeHtml(scenario.description)}</th><td>${escapeHtml(formatMoney(scenario.returnAmount, result.currency, locale))}</td><td>+${escapeHtml(formatMoney(scenario.additionalReturn, result.currency, locale))}</td></tr>`).join('')}</tbody></table></div>`;
|
|
29
|
+
const warning = result.overDeducted > 0 ? `<div class="rdr-warning"><strong>${escapeHtml(ui.overDeductedTitle)}</strong><p>${escapeHtml(ui.overDeductedText)} ${escapeHtml(formatMoney(result.overDeducted, result.currency, locale))}</p></div>` : '';
|
|
30
|
+
return `<div class="rdr-results"><div class="rdr-metrics">${metric(ui.estimatedReturnLabel, formatMoney(result.estimatedReturn, result.currency, locale), 'rdr-accent')}${metric(ui.undisputedReturnLabel, formatMoney(result.undisputedReturn, result.currency, locale))}${metric(ui.totalDeductionsLabel, formatMoney(result.totalDeductions, result.currency, locale))}</div>${warning}<section class="rdr-panel"><h3>${escapeHtml(ui.scenariosTitle)}</h3><p>${escapeHtml(ui.scenariosText)}</p>${scenarioRows}</section><section class="rdr-panel rdr-summary"><h3>${escapeHtml(ui.summaryTitle)}</h3><dl><div><dt>${escapeHtml(ui.confirmedLabel)}</dt><dd>${escapeHtml(formatMoney(result.confirmedDeductions, result.currency, locale))}</dd></div><div><dt>${escapeHtml(ui.disputedLabel)}</dt><dd>${escapeHtml(formatMoney(result.disputedDeductions, result.currency, locale))}</dd></div></dl></section></div>`;
|
|
31
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { FinanceToolEntry, ToolLocaleContent } from '../../types';
|
|
2
|
+
import type { RentalDepositReturnUI } from './ui';
|
|
3
|
+
|
|
4
|
+
export type RentalDepositReturnLocaleContent = ToolLocaleContent<RentalDepositReturnUI>;
|
|
5
|
+
|
|
6
|
+
export const rentalDepositReturn: FinanceToolEntry<RentalDepositReturnUI> = {
|
|
7
|
+
id: 'rental-deposit-return-calculator',
|
|
8
|
+
icons: { bg: 'mdi:home-lock-open', fg: 'mdi:cash-refund' },
|
|
9
|
+
i18n: {
|
|
10
|
+
de: () => import('./i18n/de').then((module) => module.content),
|
|
11
|
+
en: () => import('./i18n/en').then((module) => module.content),
|
|
12
|
+
es: () => import('./i18n/es').then((module) => module.content),
|
|
13
|
+
fr: () => import('./i18n/fr').then((module) => module.content),
|
|
14
|
+
id: () => import('./i18n/id').then((module) => module.content),
|
|
15
|
+
it: () => import('./i18n/it').then((module) => module.content),
|
|
16
|
+
ja: () => import('./i18n/ja').then((module) => module.content),
|
|
17
|
+
ko: () => import('./i18n/ko').then((module) => module.content),
|
|
18
|
+
nl: () => import('./i18n/nl').then((module) => module.content),
|
|
19
|
+
pl: () => import('./i18n/pl').then((module) => module.content),
|
|
20
|
+
pt: () => import('./i18n/pt').then((module) => module.content),
|
|
21
|
+
ru: () => import('./i18n/ru').then((module) => module.content),
|
|
22
|
+
sv: () => import('./i18n/sv').then((module) => module.content),
|
|
23
|
+
tr: () => import('./i18n/tr').then((module) => module.content),
|
|
24
|
+
zh: () => import('./i18n/zh').then((module) => module.content),
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export { bibliography } from './bibliography';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { FAQPage, HowTo, SoftwareApplication, WithContext } from 'schema-dts';
|
|
2
|
+
import type { SEOSection, ToolLocaleContent } from '../../../types';
|
|
3
|
+
import { bibliography } from '../bibliography';
|
|
4
|
+
import type { RentalDepositReturnUI } from '../ui';
|
|
5
|
+
|
|
6
|
+
const baseUi: RentalDepositReturnUI = {
|
|
7
|
+
depositLabel: 'Deposit paid', depositHelp: 'Use the amount actually paid as the deposit, in the same currency as every line below.',
|
|
8
|
+
deductionsTitle: 'Items kept from the deposit', deductionsHelp: 'Enter the landlord or agent own breakdown. Mark a line disputed when you want to test the amount without it.',
|
|
9
|
+
descriptionLabel: 'Description', descriptionPlaceholder: 'e.g. Final water bill', amountLabel: 'Amount', statusLabel: 'Deduction status', confirmedOption: 'Confirmed', disputedOption: 'Disputed',
|
|
10
|
+
addButton: 'Add deduction', resetButton: 'Restore example', newDeductionLabel: 'New deduction', removeButton: 'Remove this deduction', currencyLabel: 'Currency', currencyHelp: 'All figures stay in this currency. No exchange rates are used.',
|
|
11
|
+
summaryTitle: 'Confirmed vs disputed', estimatedReturnLabel: 'Estimated return', undisputedReturnLabel: 'If disputed items are removed', totalDeductionsLabel: 'Total deductions', deductionBreakdownTitle: 'The ledger behind the result', confirmedLabel: 'Confirmed items', disputedLabel: 'Disputed items',
|
|
12
|
+
returnFlowTitle: 'Deposit to return', returnFlowText: 'A clear split between what is being kept and what could come back.', depositNode: 'Deposit in', deductionNode: 'Kept for deductions', returnNode: 'Estimated back', overDeductedTitle: 'The deductions exceed the deposit', overDeductedText: 'The entered breakdown is above the deposit by',
|
|
13
|
+
scenariosTitle: 'One dispute at a time', scenariosText: 'These scenarios show the return if one disputed line is removed while all other lines stay in place.', scenarioColumn: 'Removed line', scenarioReturnColumn: 'Return if removed', scenarioIncreaseColumn: 'Increase in return', noDisputed: 'Mark a line as disputed to see a comparison scenario.',
|
|
14
|
+
emptyMessage: 'Add a deposit and use a description with a zero or positive amount for every deduction.', copyButton: 'Copy claim summary', copiedButton: 'Summary copied', privacyNote: 'Your figures stay in this browser. Nothing is sent to a server.', methodTitle: 'How this estimate works',
|
|
15
|
+
methodText: 'The tool adds every deduction, subtracts that total from the deposit and floors the estimated return at zero. A disputed scenario removes one disputed line from the total so you can compare the effect of that line.', limitationText: 'This is a calculation of your own figures, not a legal decision. Deposit rules, allowable deductions, evidence requirements and deadlines vary by jurisdiction and agreement.',
|
|
16
|
+
reportTitle: 'Rental deposit return summary', reportDeposit: 'Deposit paid', reportDeductions: 'Deductions entered', reportConfirmed: 'Confirmed deductions', reportDisputed: 'Disputed deductions', reportEstimatedReturn: 'Estimated return', reportPotentialReturn: 'Return if disputed items are removed', reportNote: 'Check the agreement, invoices, condition record and local process before making a claim.',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const nativeUiPatches: Record<string, Partial<RentalDepositReturnUI>> = {
|
|
20
|
+
ja: { depositHelp: '\u5b9f\u969b\u306b\u652f\u6255\u3063\u305f\u4fdd\u8a3c\u91d1\u3068\u540c\u3058\u901a\u8ca8\u3067\u5165\u529b\u3057\u307e\u3059\u3002', deductionsHelp: '\u660e\u7d30\u306e\u5404\u9805\u76ee\u3092\u5165\u529b\u3057\u3001\u78ba\u8a8d\u3057\u305f\u3044\u9805\u76ee\u3092\u4e89\u8b70\u4e2d\u306b\u3057\u307e\u3059\u3002', descriptionPlaceholder: '\u4f8b: \u6700\u7d42\u6c34\u9053\u6599\u91d1', newDeductionLabel: '\u65b0\u3057\u3044\u63a7\u9664', removeButton: '\u3053\u306e\u63a7\u9664\u3092\u524a\u9664', currencyHelp: '\u3059\u3079\u3066\u306e\u91d1\u984d\u306b\u3053\u306e\u901a\u8ca8\u3092\u4f7f\u3044\u307e\u3059\u3002\u70ba\u66ff\u30ec\u30fc\u30c8\u306f\u4f7f\u3044\u307e\u305b\u3093\u3002', deductionBreakdownTitle: '\u7d50\u679c\u306e\u660e\u7d30', confirmedLabel: '\u78ba\u5b9a\u3057\u305f\u63a7\u9664', disputedLabel: '\u4e89\u8b70\u4e2d\u306e\u63a7\u9664', returnFlowText: '\u4fdd\u6301\u3055\u308c\u308b\u91d1\u984d\u3068\u8fd4\u9084\u3055\u308c\u308b\u53ef\u80fd\u6027\u306e\u3042\u308b\u91d1\u984d\u3092\u5206\u3051\u3066\u8868\u793a\u3057\u307e\u3059\u3002', depositNode: '\u5165\u91d1\u4fdd\u8a3c\u91d1', deductionNode: '\u63a7\u9664\u3068\u3057\u3066\u4fdd\u6301', overDeductedTitle: '\u63a7\u9664\u984d\u304c\u4fdd\u8a3c\u91d1\u3092\u8d85\u3048\u3066\u3044\u307e\u3059', overDeductedText: '\u5165\u529b\u3057\u305f\u660e\u7d30\u306f\u4fdd\u8a3c\u91d1\u3092\u6b21\u306e\u91d1\u984d\u3060\u3051\u8d85\u3048\u3066\u3044\u307e\u3059:', scenariosTitle: '\u4e00\u5ea6\u306b\u4e00\u3064\u306e\u4e89\u3044\u3092\u78ba\u8a8d', scenariosText: '\u4ed6\u306e\u9805\u76ee\u3092\u6b8b\u3057\u305f\u307e\u307e\u3001\u4e89\u8b70\u4e2d\u306e\u9805\u76ee\u3092\u4e00\u3064\u9664\u3044\u305f\u5834\u5408\u306e\u8fd4\u9084\u984d\u3067\u3059\u3002', scenarioColumn: '\u9664\u5916\u3059\u308b\u9805\u76ee', scenarioReturnColumn: '\u9664\u5916\u5f8c\u306e\u8fd4\u9084', scenarioIncreaseColumn: '\u8fd4\u9084\u306e\u5897\u52a0', noDisputed: '\u6bd4\u8f03\u3059\u308b\u306b\u306f\u9805\u76ee\u3092\u4e89\u8b70\u4e2d\u306b\u3057\u3066\u304f\u3060\u3055\u3044\u3002', emptyMessage: '\u4fdd\u8a3c\u91d1\u3068\u3001\u5404\u63a7\u9664\u306e\u8aac\u660e\u304a\u3088\u30730\u4ee5\u4e0a\u306e\u91d1\u984d\u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002', privacyNote: '\u5165\u529b\u3057\u305f\u6570\u5024\u306f\u3053\u306e\u30d6\u30e9\u30a6\u30b6\u30fc\u5185\u306b\u6b8b\u308a\u307e\u3059\u3002\u30b5\u30fc\u30d0\u30fc\u306b\u306f\u9001\u4fe1\u3055\u308c\u307e\u305b\u3093\u3002', methodText: '\u3059\u3079\u3066\u306e\u63a7\u9664\u3092\u5408\u8a08\u3057\u3066\u4fdd\u8a3c\u91d1\u304b\u3089\u5dee\u3057\u5f15\u304d\u3001\u8fd4\u9084\u984d\u304c\u8ca0\u306b\u306a\u3089\u306a\u3044\u3088\u3046\u306b\u3057\u307e\u3059\u3002', limitationText: '\u3053\u308c\u306f\u5165\u529b\u3057\u305f\u6570\u5024\u306e\u8a08\u7b97\u3067\u3042\u308a\u3001\u6cd5\u7684\u5224\u65ad\u3067\u306f\u3042\u308a\u307e\u305b\u3093\u3002' },
|
|
21
|
+
ko: { depositHelp: '\uc2e4\uc81c\ub85c \ub0a9\ubd80\ud55c \ubcf4\uc99d\uae08\uc744 \ubaa8\ub4e0 \ud56d\ubaa9\uacfc \uac19\uc740 \ud1b5\ud654\ub85c \uc785\ub825\ud558\uc138\uc694.', deductionsHelp: '\uba85\uc138\uc11c\uc758 \uac01 \ud56d\ubaa9\uc744 \uc785\ub825\ud558\uace0 \ud655\uc778\ud560 \ud56d\ubaa9\uc740 \uc774\uc758 \uc81c\uae30\ub85c \ud45c\uc2dc\ud558\uc138\uc694.', descriptionPlaceholder: '\uc608: \ucd5c\uc885 \uc218\ub3c4 \uc694\uae08', newDeductionLabel: '\uc0c8 \uacf5\uc81c \ud56d\ubaa9', removeButton: '\uc774 \uacf5\uc81c \ud56d\ubaa9 \uc0ad\uc81c', currencyHelp: '\ubaa8\ub4e0 \uae08\uc561\uc740 \uc774 \ud1b5\ud654\ub85c \ud45c\uc2dc\ub429\ub2c8\ub2e4. \ud658\uc728\uc740 \uc0ac\uc6a9\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.', deductionBreakdownTitle: '\uacb0\uacfc\uc758 \uc138\ubd80 \ub0b4\uc5ed', confirmedLabel: '\ud655\uc815\ub41c \uacf5\uc81c', disputedLabel: '\uc774\uc758 \uc81c\uae30 \uacf5\uc81c', returnFlowText: '\ubcf4\uc99d\uae08\uc5d0\uc11c \ubcf4\ub958\ub418\ub294 \uae08\uc561\uacfc \ub3cc\uc544\uc62c \uc218 \uc788\ub294 \uae08\uc561\uc744 \ub098\ub204\uc5b4 \ubcf4\uc5ec\uc90d\ub2c8\ub2e4.', depositNode: '\ub0a9\ubd80\ud55c \ubcf4\uc99d\uae08', deductionNode: '\uacf5\uc81c\ub85c \ubcf4\ub958', overDeductedTitle: '\uacf5\uc81c\uc561\uc774 \ubcf4\uc99d\uae08\uc744 \ucd08\uacfc\ud569\ub2c8\ub2e4', overDeductedText: '\uc785\ub825\ud55c \ub0b4\uc5ed\uc774 \ubcf4\uc99d\uae08\uc744 \ucd08\uacfc\ud55c \uae08\uc561:', scenariosTitle: '\ud55c \ubc88\uc5d0 \ud558\ub098\uc529 \uc774\uc758 \uc81c\uae30', scenariosText: '\ub2e4\ub978 \ud56d\ubaa9\uc740 \uadf8\ub300\ub85c \ub450\uace0 \uc774\uc758 \uc81c\uae30 \ud56d\ubaa9 \ud558\ub098\ub97c \ube80 \ubc18\ud658\uc561\uc744 \ubcf4\uc5ec\uc90d\ub2c8\ub2e4.', scenarioColumn: '\uc81c\uc678\ud55c \ud56d\ubaa9', scenarioReturnColumn: '\uc81c\uc678 \ud6c4 \ubc18\ud658\uc561', scenarioIncreaseColumn: '\ubc18\ud658\uc561 \uc99d\uac00', noDisputed: '\ube44\uad50\ud558\ub824\uba74 \ud56d\ubaa9\uc744 \uc774\uc758 \uc81c\uae30\ub85c \ud45c\uc2dc\ud558\uc138\uc694.', emptyMessage: '\ubcf4\uc99d\uae08\uacfc \uac01 \uacf5\uc81c\uc758 \uc124\uba85 \ubc0f 0 \uc774\uc0c1\uc758 \uae08\uc561\uc744 \uc785\ub825\ud558\uc138\uc694.', privacyNote: '\uc785\ub825\ud55c \uc218\uce58\ub294 \uc774 \ube0c\ub77c\uc6b0\uc800\uc5d0\ub9cc \ub0a8\uc2b5\ub2c8\ub2e4. \uc11c\ubc84\ub85c \uc804\uc1a1\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.', methodText: '\ubaa8\ub4e0 \uacf5\uc81c\uc561\uc744 \ub354\ud574 \ubcf4\uc99d\uae08\uc5d0\uc11c \ube7c\uace0 \ubc18\ud658\uc561\uc774 \uc74c\uc218\uac00 \ub418\uc9c0 \uc54a\uac8c \ud569\ub2c8\ub2e4.', limitationText: '\uc785\ub825\ud55c \uae08\uc561\uc5d0 \ub300\ud55c \uacc4\uc0b0\uc77c \ubfd0 \ubc95\uc801 \ud310\ub2e8\uc774 \uc544\ub2d9\ub2c8\ub2e4.' },
|
|
22
|
+
ru: { depositHelp: '\u0423\u043a\u0430\u0436\u0438\u0442\u0435 \u0444\u0430\u043a\u0442\u0438\u0447\u0435\u0441\u043a\u0438 \u0432\u043d\u0435\u0441\u0451\u043d\u043d\u044b\u0439 \u0437\u0430\u043b\u043e\u0433 \u0432 \u0442\u043e\u0439 \u0436\u0435 \u0432\u0430\u043b\u044e\u0442\u0435, \u0447\u0442\u043e \u0438 \u0432\u0441\u0435 \u0441\u0442\u0440\u043e\u043a\u0438 \u043d\u0438\u0436\u0435.', deductionsHelp: '\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0432\u0441\u0435 \u0441\u0442\u0440\u043e\u043a\u0438 \u0438\u0437 \u0440\u0430\u0441\u0447\u0451\u0442\u0430 \u0438 \u043e\u0442\u043c\u0435\u0442\u044c\u0442\u0435 \u0441\u043f\u043e\u0440\u043d\u044b\u0435 \u043f\u043e\u0437\u0438\u0446\u0438\u0438.', descriptionPlaceholder: '\u043d\u0430\u043f\u0440\u0438\u043c\u0435\u0440, \u0438\u0442\u043e\u0433\u043e\u0432\u044b\u0439 \u0441\u0447\u0451\u0442 \u0437\u0430 \u0432\u043e\u0434\u0443', newDeductionLabel: '\u041d\u043e\u0432\u043e\u0435 \u0443\u0434\u0435\u0440\u0436\u0430\u043d\u0438\u0435', removeButton: '\u0423\u0434\u0430\u043b\u0438\u0442\u044c \u044d\u0442\u043e \u0443\u0434\u0435\u0440\u0436\u0430\u043d\u0438\u0435', currencyHelp: '\u0412\u0441\u0435 \u0441\u0443\u043c\u043c\u044b \u0443\u043a\u0430\u0437\u0430\u043d\u044b \u0432 \u044d\u0442\u043e\u0439 \u0432\u0430\u043b\u044e\u0442\u0435. \u041a\u0443\u0440\u0441\u044b \u043e\u0431\u043c\u0435\u043d\u0430 \u043d\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u044e\u0442\u0441\u044f.', deductionBreakdownTitle: '\u0420\u0430\u0437\u0431\u043e\u0440 \u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430', confirmedLabel: '\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0451\u043d\u043d\u044b\u0435 \u0443\u0434\u0435\u0440\u0436\u0430\u043d\u0438\u044f', disputedLabel: '\u041e\u0441\u043f\u0430\u0440\u0438\u0432\u0430\u0435\u043c\u044b\u0435 \u0443\u0434\u0435\u0440\u0436\u0430\u043d\u0438\u044f', returnFlowText: '\u041f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u0435\u0442 \u0440\u0430\u0437\u043d\u0438\u0446\u0443 \u043c\u0435\u0436\u0434\u0443 \u0443\u0434\u0435\u0440\u0436\u0430\u043d\u043d\u043e\u0439 \u0441\u0443\u043c\u043c\u043e\u0439 \u0438 \u0432\u043e\u0437\u043c\u043e\u0436\u043d\u044b\u043c \u0432\u043e\u0437\u0432\u0440\u0430\u0442\u043e\u043c.', depositNode: '\u0412\u043d\u0435\u0441\u0451\u043d\u043d\u044b\u0439 \u0437\u0430\u043b\u043e\u0433', deductionNode: '\u0423\u0434\u0435\u0440\u0436\u0430\u043d\u043e', overDeductedTitle: '\u0423\u0434\u0435\u0440\u0436\u0430\u043d\u0438\u044f \u043f\u0440\u0435\u0432\u044b\u0448\u0430\u044e\u0442 \u0437\u0430\u043b\u043e\u0433', overDeductedText: '\u0423\u043a\u0430\u0437\u0430\u043d\u043d\u044b\u0435 \u0443\u0434\u0435\u0440\u0436\u0430\u043d\u0438\u044f \u043f\u0440\u0435\u0432\u044b\u0448\u0430\u044e\u0442 \u0437\u0430\u043b\u043e\u0433 \u043d\u0430', scenariosTitle: '\u041f\u0440\u043e\u0432\u0435\u0440\u043a\u0430 \u043e\u0434\u043d\u043e\u0433\u043e \u0441\u043f\u043e\u0440\u0430', scenariosText: '\u0412\u043e\u0437\u0432\u0440\u0430\u0442, \u0435\u0441\u043b\u0438 \u0443\u0431\u0440\u0430\u0442\u044c \u043e\u0434\u043d\u0443 \u043e\u0441\u043f\u0430\u0440\u0438\u0432\u0430\u0435\u043c\u0443\u044e \u0441\u0442\u0440\u043e\u043a\u0443 \u0438 \u043e\u0441\u0442\u0430\u0432\u0438\u0442\u044c \u043e\u0441\u0442\u0430\u043b\u044c\u043d\u044b\u0435.', scenarioColumn: '\u0423\u0431\u0440\u0430\u043d\u043d\u0430\u044f \u0441\u0442\u0440\u043e\u043a\u0430', scenarioReturnColumn: '\u0412\u043e\u0437\u0432\u0440\u0430\u0442 \u043f\u043e\u0441\u043b\u0435 \u0443\u0434\u0430\u043b\u0435\u043d\u0438\u044f', scenarioIncreaseColumn: '\u0423\u0432\u0435\u043b\u0438\u0447\u0435\u043d\u0438\u0435 \u0432\u043e\u0437\u0432\u0440\u0430\u0442\u0430', noDisputed: '\u041e\u0442\u043c\u0435\u0442\u044c\u0442\u0435 \u0441\u0442\u0440\u043e\u043a\u0443 \u043a\u0430\u043a \u043e\u0441\u043f\u0430\u0440\u0438\u0432\u0430\u0435\u043c\u0443\u044e, \u0447\u0442\u043e\u0431\u044b \u0443\u0432\u0438\u0434\u0435\u0442\u044c \u0441\u0440\u0430\u0432\u043d\u0435\u043d\u0438\u0435.', emptyMessage: '\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0437\u0430\u043b\u043e\u0433, \u043e\u043f\u0438\u0441\u0430\u043d\u0438\u0435 \u043a\u0430\u0436\u0434\u043e\u0433\u043e \u0443\u0434\u0435\u0440\u0436\u0430\u043d\u0438\u044f \u0438 \u0441\u0443\u043c\u043c\u0443 \u043d\u0435 \u043c\u0435\u043d\u044c\u0448\u0435 \u043d\u0443\u043b\u044f.', privacyNote: '\u0412\u0430\u0448\u0438 \u0446\u0438\u0444\u0440\u044b \u043e\u0441\u0442\u0430\u044e\u0442\u0441\u044f \u0432 \u044d\u0442\u043e\u043c \u0431\u0440\u0430\u0443\u0437\u0435\u0440\u0435 \u0438 \u043d\u0435 \u043e\u0442\u043f\u0440\u0430\u0432\u043b\u044f\u044e\u0442\u0441\u044f \u043d\u0430 \u0441\u0435\u0440\u0432\u0435\u0440.', methodText: '\u0418\u043d\u0441\u0442\u0440\u0443\u043c\u0435\u043d\u0442 \u0441\u043a\u043b\u0430\u0434\u044b\u0432\u0430\u0435\u0442 \u0443\u0434\u0435\u0440\u0436\u0430\u043d\u0438\u044f, \u0432\u044b\u0447\u0438\u0442\u0430\u0435\u0442 \u0438\u0445 \u0438\u0437 \u0437\u0430\u043b\u043e\u0433\u0430 \u0438 \u043d\u0435 \u0434\u043e\u043f\u0443\u0441\u043a\u0430\u0435\u0442 \u043e\u0442\u0440\u0438\u0446\u0430\u0442\u0435\u043b\u044c\u043d\u044b\u0439 \u0432\u043e\u0437\u0432\u0440\u0430\u0442.', limitationText: '\u042d\u0442\u043e \u0440\u0430\u0441\u0447\u0451\u0442 \u0432\u0430\u0448\u0438\u0445 \u0441\u0443\u043c\u043c, \u0430 \u043d\u0435 \u044e\u0440\u0438\u0434\u0438\u0447\u0435\u0441\u043a\u043e\u0435 \u0440\u0435\u0448\u0435\u043d\u0438\u0435.' },
|
|
23
|
+
zh: { depositHelp: '\u8bf7\u8f93\u5165\u5b9e\u9645\u652f\u4ed8\u7684\u62bc\u91d1\uff0c\u6240\u6709\u9879\u76ee\u4f7f\u7528\u540c\u4e00\u8d27\u5e01\u3002', deductionsHelp: '\u8f93\u5165\u7ed3\u7b97\u5355\u4e2d\u7684\u6bcf\u4e2a\u6263\u6b3e\u9879\u76ee\uff0c\u5e76\u6807\u8bb0\u60f3\u8981\u6838\u67e5\u7684\u4e89\u8bae\u9879\u76ee\u3002', descriptionPlaceholder: '\u4f8b\u5982\uff1a\u6700\u7ec8\u6c34\u8d39', newDeductionLabel: '\u65b0\u6263\u6b3e', removeButton: '\u5220\u9664\u6b64\u6263\u6b3e', currencyHelp: '\u6240\u6709\u91d1\u989d\u90fd\u4f7f\u7528\u6b64\u8d27\u5e01\uff0c\u4e0d\u4f7f\u7528\u6c47\u7387\u6362\u7b97\u3002', deductionBreakdownTitle: '\u7ed3\u679c\u660e\u7ec6', confirmedLabel: '\u5df2\u786e\u8ba4\u6263\u6b3e', disputedLabel: '\u6709\u4e89\u8bae\u6263\u6b3e', returnFlowText: '\u6e05\u695a\u533a\u5206\u4fdd\u7559\u91d1\u989d\u548c\u53ef\u80fd\u9000\u8fd8\u7684\u91d1\u989d\u3002', depositNode: '\u62bc\u91d1\u91d1\u989d', deductionNode: '\u6263\u9664\u5e76\u4fdd\u7559', overDeductedTitle: '\u6263\u6b3e\u8d85\u8fc7\u62bc\u91d1', overDeductedText: '\u8f93\u5165\u7684\u6263\u6b3e\u8d85\u8fc7\u62bc\u91d1', scenariosTitle: '\u9010\u9879\u67e5\u770b\u4e89\u8bae', scenariosText: '\u4fdd\u7559\u5176\u4ed6\u9879\u76ee\u4e0d\u53d8\uff0c\u79fb\u9664\u4e00\u4e2a\u6709\u4e89\u8bae\u9879\u76ee\u540e\u53ef\u9000\u8fd8\u7684\u91d1\u989d\u3002', scenarioColumn: '\u79fb\u9664\u9879\u76ee', scenarioReturnColumn: '\u79fb\u9664\u540e\u7684\u9000\u8fd8', scenarioIncreaseColumn: '\u9000\u8fd8\u589e\u52a0', noDisputed: '\u5c06\u9879\u76ee\u6807\u8bb0\u4e3a\u6709\u4e89\u8bae\u5373\u53ef\u67e5\u770b\u6bd4\u8f83\u3002', emptyMessage: '\u8bf7\u8f93\u5165\u62bc\u91d1\u3001\u6bcf\u9879\u6263\u6b3e\u7684\u8bf4\u660e\u4ee5\u53ca\u4e0d\u5c0f\u4e8e\u96f6\u7684\u91d1\u989d\u3002', privacyNote: '\u4f60\u7684\u6570\u5b57\u53ea\u4fdd\u7559\u5728\u6b64\u6d4f\u89c8\u5668\u4e2d\uff0c\u4e0d\u4f1a\u53d1\u9001\u5230\u670d\u52a1\u5668\u3002', methodText: '\u8ba1\u7b97\u5668\u6c47\u603b\u6240\u6709\u6263\u6b3e\u5e76\u4ece\u62bc\u91d1\u4e2d\u51cf\u53bb\uff0c\u9000\u8fd8\u91d1\u989d\u4e0d\u4f1a\u4f4e\u4e8e\u96f6\u3002', limitationText: '\u8fd9\u662f\u5bf9\u4f60\u8f93\u5165\u91d1\u989d\u7684\u8ba1\u7b97\uff0c\u4e0d\u662f\u6cd5\u5f8b\u5224\u65ad\u3002' },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export interface RentalDepositLocaleCopy { title: string; description: string; ui: Partial<RentalDepositReturnUI>; faq: Array<{ question: string; answer: string }>; howTo: Array<{ name: string; text: string }>; seo: SEOSection[]; }
|
|
27
|
+
|
|
28
|
+
export function createRentalDepositContent(locale: string, copy: RentalDepositLocaleCopy): ToolLocaleContent<RentalDepositReturnUI> {
|
|
29
|
+
const ui: RentalDepositReturnUI = { ...baseUi, ...(nativeUiPatches[locale] ?? {}), ...copy.ui };
|
|
30
|
+
const localizedSlugs: Record<string, string> = { de: 'mietkaution-rueckzahlung-rechner', es: 'calculadora-devolucion-fianza-alquiler', fr: 'calculateur-restitution-depot-garantie', id: 'kalkulator-pengembalian-deposit-sewa', it: 'calcolatore-restituzione-deposito-cauzionale', nl: 'rekenhulp-terugbetaling-huurwaarborg', pl: 'kalkulator-zwrotu-kaucji-wynajem', pt: 'calculadora-devolucao-deposito-arrendamento', ru: 'kalkulyator-vozvrata-arendnogo-zaloga', sv: 'kalkylator-aterbetalning-hyresdeposition', tr: 'kira-depozitosu-iade-hesaplayici' };
|
|
31
|
+
const faqSchema: WithContext<FAQPage> = { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: copy.faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })) };
|
|
32
|
+
const howToSchema: WithContext<HowTo> = { '@context': 'https://schema.org', '@type': 'HowTo', name: copy.howTo[0]?.name ?? copy.title, description: copy.description, step: copy.howTo.map((step, index) => ({ '@type': 'HowToStep', position: index + 1, name: step.name, text: step.text })) };
|
|
33
|
+
const appSchema: WithContext<SoftwareApplication> = { '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: copy.title, description: copy.description, applicationCategory: 'FinanceApplication', operatingSystem: 'All', offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' }, inLanguage: locale };
|
|
34
|
+
const seo: SEOSection[] = [...copy.seo, { type: 'title', text: ui.scenariosTitle, level: 3 }, { type: 'paragraph', html: `<p>${copy.description} ${ui.methodText} ${ui.limitationText}</p>` }, { type: 'list', items: [ui.depositLabel, ui.deductionsTitle, ui.estimatedReturnLabel, ui.copyButton] }];
|
|
35
|
+
return { slug: localizedSlugs[locale] ?? 'rental-deposit-return-calculator', title: copy.title, description: copy.description, ui, faq: copy.faq, bibliography, howTo: copy.howTo, schemas: [faqSchema, howToSchema, appSchema], seo };
|
|
36
|
+
}
|