@jjlmoya/utils-finance 1.45.0 → 1.47.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 +2 -1
- package/src/entries.ts +5 -1
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/mfe_assets_contract.test.ts +48 -0
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/loanAprScenarioCalculator/bibliography.astro +11 -0
- package/src/tool/loanAprScenarioCalculator/bibliography.ts +29 -0
- package/src/tool/loanAprScenarioCalculator/component.astro +62 -0
- package/src/tool/loanAprScenarioCalculator/controller.ts +205 -0
- package/src/tool/loanAprScenarioCalculator/currency-dom.ts +107 -0
- package/src/tool/loanAprScenarioCalculator/currency.test.ts +57 -0
- package/src/tool/loanAprScenarioCalculator/currency.ts +105 -0
- package/src/tool/loanAprScenarioCalculator/dom-views.ts +190 -0
- package/src/tool/loanAprScenarioCalculator/entry.ts +28 -0
- package/src/tool/loanAprScenarioCalculator/evaluator.ts +21 -0
- package/src/tool/loanAprScenarioCalculator/i18n/de.ts +194 -0
- package/src/tool/loanAprScenarioCalculator/i18n/en.ts +194 -0
- package/src/tool/loanAprScenarioCalculator/i18n/es.ts +195 -0
- package/src/tool/loanAprScenarioCalculator/i18n/fr.ts +195 -0
- package/src/tool/loanAprScenarioCalculator/i18n/id.ts +193 -0
- package/src/tool/loanAprScenarioCalculator/i18n/it.ts +193 -0
- package/src/tool/loanAprScenarioCalculator/i18n/ja.ts +186 -0
- package/src/tool/loanAprScenarioCalculator/i18n/ko.ts +186 -0
- package/src/tool/loanAprScenarioCalculator/i18n/nl.ts +194 -0
- package/src/tool/loanAprScenarioCalculator/i18n/pl.ts +194 -0
- package/src/tool/loanAprScenarioCalculator/i18n/pt.ts +194 -0
- package/src/tool/loanAprScenarioCalculator/i18n/ru.ts +193 -0
- package/src/tool/loanAprScenarioCalculator/i18n/sv.ts +189 -0
- package/src/tool/loanAprScenarioCalculator/i18n/tr.ts +193 -0
- package/src/tool/loanAprScenarioCalculator/i18n/zh.ts +181 -0
- package/src/tool/loanAprScenarioCalculator/index.ts +11 -0
- package/src/tool/loanAprScenarioCalculator/loan-apr-scenario-calculator.css +699 -0
- package/src/tool/loanAprScenarioCalculator/logic.test.ts +65 -0
- package/src/tool/loanAprScenarioCalculator/logic.ts +259 -0
- package/src/tool/loanAprScenarioCalculator/presets.ts +51 -0
- package/src/tool/loanAprScenarioCalculator/scenario-dom.ts +101 -0
- package/src/tool/loanAprScenarioCalculator/seo.astro +11 -0
- package/src/tool/loanAprScenarioCalculator/storage.ts +60 -0
- package/src/tool/loanAprScenarioCalculator/ui.ts +55 -0
- package/src/tool/loanAprScenarioCalculator/validation.ts +24 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/bibliography.astro +11 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/bibliography.ts +6 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/component.astro +80 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/controller.ts +102 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/currency.test.ts +17 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/currency.ts +69 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/dom-views.ts +68 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/entry.ts +28 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/evaluator.ts +17 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/i18n/de.ts +40 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/i18n/en.ts +98 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/i18n/es.ts +38 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/i18n/fr.ts +38 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/i18n/id.ts +38 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/i18n/it.ts +38 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/i18n/ja.ts +38 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/i18n/ko.ts +38 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/i18n/nl.ts +38 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/i18n/pl.ts +38 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/i18n/pt.ts +38 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/i18n/ru.ts +38 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/i18n/sv.ts +38 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/i18n/tr.ts +38 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/i18n/zh.ts +38 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/index.ts +11 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/logic.test.ts +52 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/logic.ts +108 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/renter-insurance-coverage-gap-calculator.css +462 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/seo.astro +11 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/storage.ts +23 -0
- package/src/tool/renterInsuranceCoverageGapCalculator/ui.ts +48 -0
- package/src/tools.ts +3 -1
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { KnownLocale } from '../../types';
|
|
3
|
+
import { INVENTORY_CATEGORIES, type RenterInsuranceInput } from './logic';
|
|
4
|
+
import { currencyOptions, defaultCurrencyByLocale } from './currency';
|
|
5
|
+
import type { RenterInsuranceCoverageGapCalculatorUI } from './ui';
|
|
6
|
+
|
|
7
|
+
interface Props { locale?: KnownLocale; ui?: RenterInsuranceCoverageGapCalculatorUI; }
|
|
8
|
+
const { ui } = Astro.props;
|
|
9
|
+
const t = ui as RenterInsuranceCoverageGapCalculatorUI;
|
|
10
|
+
const locale = Astro.props.locale ?? 'en';
|
|
11
|
+
const defaultCurrency = defaultCurrencyByLocale[locale] ?? 'USD';
|
|
12
|
+
const defaultSymbol = currencyOptions.find(({ code }) => code === defaultCurrency)?.symbol ?? defaultCurrency;
|
|
13
|
+
const initial: RenterInsuranceInput = {
|
|
14
|
+
currency: defaultCurrency,
|
|
15
|
+
policyLimit: 18000,
|
|
16
|
+
deductible: 250,
|
|
17
|
+
sampleLoss: 5000,
|
|
18
|
+
inventory: [
|
|
19
|
+
{ category: 'furniture', value: 7000 },
|
|
20
|
+
{ category: 'electronics', value: 6000 },
|
|
21
|
+
{ category: 'clothing', value: 2500 },
|
|
22
|
+
{ category: 'kitchen', value: 1500 },
|
|
23
|
+
{ category: 'personal', value: 1000 },
|
|
24
|
+
{ category: 'other', value: 1000 },
|
|
25
|
+
],
|
|
26
|
+
};
|
|
27
|
+
const config = JSON.stringify({ ui: t, initial, locale, currencies: currencyOptions }).replace(/</g, '\\u003c');
|
|
28
|
+
const categoryLabels: Record<(typeof INVENTORY_CATEGORIES)[number], string> = {
|
|
29
|
+
furniture: t.labelCategoryFurniture,
|
|
30
|
+
electronics: t.labelCategoryElectronics,
|
|
31
|
+
clothing: t.labelCategoryClothing,
|
|
32
|
+
kitchen: t.labelCategoryKitchen,
|
|
33
|
+
personal: t.labelCategoryPersonal,
|
|
34
|
+
other: t.labelCategoryOther,
|
|
35
|
+
};
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
<div class="ric-tool" data-ric-tool>
|
|
39
|
+
<form class="ric-card" data-ric-form novalidate>
|
|
40
|
+
<div class="ric-workspace">
|
|
41
|
+
<section class="ric-inventory-panel" aria-labelledby="ric-inventory-title">
|
|
42
|
+
<div class="ric-panel-heading"><span class="ric-eyebrow">01 / {t.labelInventory}</span><h2 id="ric-inventory-title">{t.labelInventory}</h2><p>{t.labelInventoryHint}</p></div>
|
|
43
|
+
<div class="ric-shelf-scene" data-ric-visual role="img" aria-label={t.labelCoverageLine}>
|
|
44
|
+
<div class="ric-shelf-top"><span>{t.labelReplacementValue}</span><span data-ric-loss-label>{t.labelLossScenario}</span></div>
|
|
45
|
+
<div class="ric-shelf-track"><span class="ric-contents-fill" data-ric-contents-fill></span><span class="ric-limit-line" data-ric-limit-line aria-label={t.labelLimitMarker}></span><span class="ric-loss-mark" data-ric-loss-mark aria-hidden="true"></span></div>
|
|
46
|
+
<div class="ric-shelf-legend"><span><i class="ric-dot ric-dot-contents"></i>{t.labelReplacementValue}</span><span><i class="ric-dot ric-dot-limit"></i>{t.labelCoverageLine}</span></div>
|
|
47
|
+
</div>
|
|
48
|
+
<div class="ric-inventory-list">
|
|
49
|
+
{INVENTORY_CATEGORIES.map((category) => <label class="ric-inventory-row" for={`ric-${category}`}><span>{categoryLabels[category]}</span><span class="ric-money-input"><input id={`ric-${category}`} data-category={category} type="number" min="0" max="1000000000" step="0.01" value={initial.inventory.find((item) => item.category === category)?.value ?? 0} placeholder={t.labelAmountPlaceholder} inputmode="decimal" required /><b data-ric-unit>{defaultSymbol}</b></span></label>)}
|
|
50
|
+
</div>
|
|
51
|
+
<ol class="ric-largest-list" data-ric-largest-list aria-label={t.labelLargestCategories}></ol>
|
|
52
|
+
</section>
|
|
53
|
+
|
|
54
|
+
<section class="ric-policy-panel" aria-labelledby="ric-policy-title">
|
|
55
|
+
<div class="ric-panel-heading"><span class="ric-eyebrow">02 / {t.labelPolicyBoundary}</span><h2 id="ric-policy-title">{t.labelSetLimit}</h2><p>{t.labelPolicyBoundaryHint}</p></div>
|
|
56
|
+
<div class="ric-field-stack">
|
|
57
|
+
<div class="ric-field ric-currency-field"><span class="ric-label" id="ric-currency-label">{t.labelCurrency}</span><div class="ric-select"><button type="button" class="ric-select-trigger" data-ric-currency-trigger aria-expanded="false" aria-haspopup="listbox" aria-labelledby="ric-currency-label"><span class="ric-currency-trigger-main"><strong data-ric-selected-symbol>{defaultSymbol}</strong><span data-ric-selected-code>{defaultCurrency}</span></span><span class="ric-currency-arrow" aria-hidden="true"></span></button><div class="ric-select-menu" data-ric-currency-menu role="listbox" aria-label={t.labelCurrency} hidden>{currencyOptions.map(({ code, symbol, name }) => <button type="button" class="ric-currency-option" data-ric-currency-option data-currency={code} role="option" aria-selected={code === defaultCurrency} aria-label={`${symbol} ${name}`}><strong>{symbol}</strong><span>{code}</span><small>{name}</small></button>)}</div></div><small>{t.labelApproximateRates}</small></div>
|
|
58
|
+
<div class="ric-field"><label for="ric-policy-limit">{t.labelPolicyLimit}</label><div class="ric-money-input"><input id="ric-policy-limit" data-field="policyLimit" type="number" min="0.01" max="1000000000" step="0.01" value={initial.policyLimit} placeholder={t.labelAmountPlaceholder} inputmode="decimal" required /><b data-ric-unit>{defaultSymbol}</b></div><small>{t.labelPolicyLimitHelp}</small></div>
|
|
59
|
+
<div class="ric-field"><label for="ric-deductible">{t.labelDeductible}</label><div class="ric-money-input"><input id="ric-deductible" data-field="deductible" type="number" min="0" max="1000000000" step="0.01" value={initial.deductible} placeholder={t.labelAmountPlaceholder} inputmode="decimal" required /><b data-ric-unit>{defaultSymbol}</b></div><small>{t.labelDeductibleHelp}</small></div>
|
|
60
|
+
<div class="ric-field"><label for="ric-sample-loss">{t.labelSampleLoss}</label><div class="ric-money-input"><input id="ric-sample-loss" data-field="sampleLoss" type="number" min="0.01" max="1000000000" step="0.01" value={initial.sampleLoss} placeholder={t.labelAmountPlaceholder} inputmode="decimal" required /><b data-ric-unit>{defaultSymbol}</b></div><small>{t.labelSampleLossHelp}</small></div>
|
|
61
|
+
</div>
|
|
62
|
+
<div class="ric-result-block" aria-live="polite" aria-atomic="true"><span class="ric-eyebrow">03 / {t.labelStatus}</span><strong class="ric-status" data-ric-status>{t.labelStatusReview}</strong><p data-ric-status-text></p><p class="ric-error" data-ric-error hidden>{t.labelInvalidInput}</p><div class="ric-metric-main"><span>{t.labelCoverageGap}</span><strong data-ric-gap>—</strong></div><div class="ric-metric-line"><span>{t.labelTotalContents}</span><b data-ric-total>—</b></div><div class="ric-metric-line"><span>{t.labelCoverageRatio}</span><b data-ric-ratio>—</b></div><div class="ric-metric-line"><span>{t.labelEstimatedPayout}</span><b data-ric-payout>—</b></div><div class="ric-metric-line"><span>{t.labelOutOfPocket}</span><b data-ric-out-of-pocket>—</b></div><p class="ric-result-note">{t.labelIllustrativePayout}</p></div>
|
|
63
|
+
<div class="ric-actions"><button type="button" class="ric-button ric-button-secondary" data-ric-reset>{t.labelReset}</button><button type="button" class="ric-button ric-button-primary" data-ric-copy>{t.labelCopy}</button><span data-ric-copy-status role="status" aria-live="polite"></span></div>
|
|
64
|
+
</section>
|
|
65
|
+
</div>
|
|
66
|
+
<p class="ric-privacy">{t.labelPrivacyNote}</p><details class="ric-method"><summary>{t.labelMethodTitle}</summary><p>{t.labelMethodText}</p></details>
|
|
67
|
+
</form>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<script is:inline type="application/json" data-ric-config set:html={config}></script>
|
|
71
|
+
<script>
|
|
72
|
+
import { mountRenterInsuranceTool } from './controller';
|
|
73
|
+
const configElement = document.querySelector<HTMLScriptElement>('[data-ric-config]');
|
|
74
|
+
const root = document.querySelector<HTMLElement>('[data-ric-tool]');
|
|
75
|
+
if (root && configElement?.textContent) mountRenterInsuranceTool(root, JSON.parse(configElement.textContent));
|
|
76
|
+
</script>
|
|
77
|
+
|
|
78
|
+
<style>
|
|
79
|
+
@import './renter-insurance-coverage-gap-calculator.css';
|
|
80
|
+
</style>
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { calculateRenterInsuranceCoverageGap, validateRenterInsuranceInput, type RenterInsuranceInput } from './logic';
|
|
2
|
+
import { convertRenterInsuranceCurrency, isRenterInsuranceCurrencyCode, type RenterInsuranceCurrencyCode } from './currency';
|
|
3
|
+
import { clearRenterInsuranceResult, formatMoney, renderRenterInsuranceResult, type ViewConfig } from './dom-views';
|
|
4
|
+
import { readSavedRenterInsuranceInput, saveRenterInsuranceInput } from './storage';
|
|
5
|
+
import type { RenterInsuranceCoverageGapCalculatorUI } from './ui';
|
|
6
|
+
|
|
7
|
+
interface ControllerConfig {
|
|
8
|
+
initial: RenterInsuranceInput;
|
|
9
|
+
locale: string;
|
|
10
|
+
ui: RenterInsuranceCoverageGapCalculatorUI;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const getNumber = (form: HTMLFormElement, field: string): number => Number(form.querySelector<HTMLInputElement>(`[data-field="${field}"]`)?.value ?? Number.NaN);
|
|
14
|
+
|
|
15
|
+
const readInput = (form: HTMLFormElement, currency: string): RenterInsuranceInput => ({
|
|
16
|
+
currency,
|
|
17
|
+
policyLimit: getNumber(form, 'policyLimit'),
|
|
18
|
+
deductible: getNumber(form, 'deductible'),
|
|
19
|
+
sampleLoss: getNumber(form, 'sampleLoss'),
|
|
20
|
+
inventory: Array.from(form.querySelectorAll<HTMLInputElement>('[data-category]')).map((input) => ({ category: input.dataset.category as RenterInsuranceInput['inventory'][number]['category'], value: Number(input.value) })),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const setFieldValues = (form: HTMLFormElement, input: RenterInsuranceInput): void => {
|
|
24
|
+
['policyLimit', 'deductible', 'sampleLoss'].forEach((field) => {
|
|
25
|
+
const element = form.querySelector<HTMLInputElement>(`[data-field="${field}"]`);
|
|
26
|
+
if (element) element.value = String(input[field as 'policyLimit' | 'deductible' | 'sampleLoss']);
|
|
27
|
+
});
|
|
28
|
+
input.inventory.forEach((item) => {
|
|
29
|
+
const element = form.querySelector<HTMLInputElement>(`[data-category="${item.category}"]`);
|
|
30
|
+
if (element) element.value = String(item.value);
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const updateFieldErrors = (root: HTMLElement, form: HTMLFormElement, input: RenterInsuranceInput): boolean => {
|
|
35
|
+
const errors = validateRenterInsuranceInput(input);
|
|
36
|
+
form.querySelectorAll<HTMLInputElement>('[data-field], [data-category]').forEach((field) => field.setAttribute('aria-invalid', 'false'));
|
|
37
|
+
Object.keys(errors).forEach((key) => form.querySelector<HTMLInputElement>(`[data-field="${key}"]`)?.setAttribute('aria-invalid', 'true'));
|
|
38
|
+
const summary = root.querySelector<HTMLElement>('[data-ric-error]');
|
|
39
|
+
if (summary) summary.hidden = Object.keys(errors).length === 0;
|
|
40
|
+
if (errors.inventory) form.querySelectorAll<HTMLInputElement>('[data-category]').forEach((field) => field.setAttribute('aria-invalid', 'true'));
|
|
41
|
+
return Object.keys(errors).length > 0;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const setCurrency = (root: HTMLElement, currency: RenterInsuranceCurrencyCode): void => {
|
|
45
|
+
const selectedOption = root.querySelector<HTMLButtonElement>(`[data-ric-currency-option][data-currency="${currency}"]`);
|
|
46
|
+
const symbol = selectedOption?.querySelector('strong')?.textContent ?? currency;
|
|
47
|
+
root.querySelectorAll<HTMLElement>('[data-ric-unit]').forEach((unit) => { unit.textContent = symbol; });
|
|
48
|
+
root.querySelectorAll<HTMLButtonElement>('[data-ric-currency-option]').forEach((option) => option.setAttribute('aria-selected', String(option.dataset.currency === currency)));
|
|
49
|
+
const selectedCode = root.querySelector<HTMLElement>('[data-ric-selected-code]');
|
|
50
|
+
if (selectedCode) selectedCode.textContent = currency;
|
|
51
|
+
const selectedSymbol = root.querySelector<HTMLElement>('[data-ric-selected-symbol]');
|
|
52
|
+
if (selectedSymbol) selectedSymbol.textContent = symbol;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const mountCurrencyMenu = (root: HTMLElement, onSelect: (currency: RenterInsuranceCurrencyCode) => void): void => {
|
|
56
|
+
const trigger = root.querySelector<HTMLButtonElement>('[data-ric-currency-trigger]');
|
|
57
|
+
const menu = root.querySelector<HTMLElement>('[data-ric-currency-menu]');
|
|
58
|
+
const close = (): void => { if (menu && trigger) { menu.hidden = true; trigger.setAttribute('aria-expanded', 'false'); } };
|
|
59
|
+
trigger?.addEventListener('click', () => { if (menu) { menu.hidden = !menu.hidden; trigger.setAttribute('aria-expanded', String(!menu.hidden)); } });
|
|
60
|
+
root.querySelectorAll<HTMLButtonElement>('[data-ric-currency-option]').forEach((option) => option.addEventListener('click', () => { const nextCurrency = option.dataset.currency; if (nextCurrency && isRenterInsuranceCurrencyCode(nextCurrency)) onSelect(nextCurrency); close(); }));
|
|
61
|
+
document.addEventListener('click', (event) => { if (!root.contains(event.target as Node)) close(); });
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const mountCopyAction = (root: HTMLElement, form: HTMLFormElement, config: ControllerConfig, getCurrency: () => RenterInsuranceCurrencyCode): void => {
|
|
65
|
+
form.querySelector('[data-ric-copy]')?.addEventListener('click', () => {
|
|
66
|
+
const result = calculateRenterInsuranceCoverageGap(readInput(form, getCurrency()));
|
|
67
|
+
if (!result) return;
|
|
68
|
+
const summary = `${config.ui.labelCoverageGap}: ${formatMoney(config.locale, result.currency, result.coverageGap)}. ${config.ui.labelCoverageRatio}: ${Math.round(result.coverageRatio * 100)}%. ${config.ui.labelLargestCategories}: ${result.largestCategories.map((item) => item.category).join(', ')}.`;
|
|
69
|
+
navigator.clipboard?.writeText(summary).then(() => { const status = root.querySelector<HTMLElement>('[data-ric-copy-status]'); if (status) status.textContent = config.ui.labelCopied; }).catch(() => { const status = root.querySelector<HTMLElement>('[data-ric-copy-status]'); if (status) status.textContent = config.ui.labelCopyFailed; });
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export function mountRenterInsuranceTool(root: HTMLElement, config: ControllerConfig): void {
|
|
74
|
+
const form = root.querySelector<HTMLFormElement>('[data-ric-form]');
|
|
75
|
+
if (!form) return;
|
|
76
|
+
const saved = readSavedRenterInsuranceInput(config.initial);
|
|
77
|
+
let currency: RenterInsuranceCurrencyCode = isRenterInsuranceCurrencyCode(saved.currency) ? saved.currency : config.initial.currency as RenterInsuranceCurrencyCode;
|
|
78
|
+
setFieldValues(form, saved);
|
|
79
|
+
const viewConfig: ViewConfig = { root, locale: config.locale, ui: config.ui };
|
|
80
|
+
const update = (): void => {
|
|
81
|
+
const input = readInput(form, currency);
|
|
82
|
+
if (updateFieldErrors(root, form, input)) { clearRenterInsuranceResult(root, config.ui); return; }
|
|
83
|
+
const result = calculateRenterInsuranceCoverageGap(input);
|
|
84
|
+
if (!result) return;
|
|
85
|
+
saveRenterInsuranceInput(input);
|
|
86
|
+
renderRenterInsuranceResult(viewConfig, result);
|
|
87
|
+
};
|
|
88
|
+
const reset = (): void => { currency = config.initial.currency as RenterInsuranceCurrencyCode; setFieldValues(form, config.initial); setCurrency(root, currency); update(); };
|
|
89
|
+
form.querySelectorAll<HTMLInputElement>('[data-field], [data-category]').forEach((field) => field.addEventListener('input', update));
|
|
90
|
+
form.querySelector('[data-ric-reset]')?.addEventListener('click', reset);
|
|
91
|
+
mountCopyAction(root, form, config, () => currency);
|
|
92
|
+
mountCurrencyMenu(root, (nextCurrency) => {
|
|
93
|
+
if (nextCurrency === currency) return;
|
|
94
|
+
const moneyFields = [...Array.from(form.querySelectorAll<HTMLInputElement>('[data-field]')), ...Array.from(form.querySelectorAll<HTMLInputElement>('[data-category]'))];
|
|
95
|
+
moneyFields.forEach((field) => { const value = Number(field.value); if (Number.isFinite(value)) field.value = String(convertRenterInsuranceCurrency(value, currency, nextCurrency)); });
|
|
96
|
+
currency = nextCurrency;
|
|
97
|
+
setCurrency(root, currency);
|
|
98
|
+
update();
|
|
99
|
+
});
|
|
100
|
+
setCurrency(root, currency);
|
|
101
|
+
update();
|
|
102
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { convertRenterInsuranceCurrency, currencyOptions, defaultCurrencyByLocale } from './currency';
|
|
3
|
+
|
|
4
|
+
describe('renter insurance currency support', () => {
|
|
5
|
+
it('offers the international currency set used by insurance tools', () => {
|
|
6
|
+
expect(currencyOptions.map(({ code }) => code)).toEqual(['AUD', 'BRL', 'CAD', 'CHF', 'CNY', 'CZK', 'DKK', 'EUR', 'GBP', 'IDR', 'INR', 'JPY', 'KRW', 'MXN', 'NOK', 'PLN', 'RUB', 'SEK', 'TRY', 'USD']);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('selects a useful default for each supported locale family', () => {
|
|
10
|
+
expect(defaultCurrencyByLocale).toMatchObject({ en: 'USD', es: 'EUR', ja: 'JPY', zh: 'CNY' });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('converts monetary inputs with reversible planning factors', () => {
|
|
14
|
+
expect(convertRenterInsuranceCurrency(100, 'EUR', 'USD')).toBe(108.7);
|
|
15
|
+
expect(convertRenterInsuranceCurrency(108.7, 'USD', 'EUR')).toBe(100);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export const currencyOptions = [
|
|
2
|
+
{ code: 'AUD', symbol: 'A$', name: 'Australian dollar' },
|
|
3
|
+
{ code: 'BRL', symbol: 'R$', name: 'Brazilian real' },
|
|
4
|
+
{ code: 'CAD', symbol: 'C$', name: 'Canadian dollar' },
|
|
5
|
+
{ code: 'CHF', symbol: 'CHF', name: 'Swiss franc' },
|
|
6
|
+
{ code: 'CNY', symbol: '¥', name: 'Chinese yuan' },
|
|
7
|
+
{ code: 'CZK', symbol: 'Kč', name: 'Czech koruna' },
|
|
8
|
+
{ code: 'DKK', symbol: 'kr', name: 'Danish krone' },
|
|
9
|
+
{ code: 'EUR', symbol: '€', name: 'Euro' },
|
|
10
|
+
{ code: 'GBP', symbol: '£', name: 'British pound' },
|
|
11
|
+
{ code: 'IDR', symbol: 'Rp', name: 'Indonesian rupiah' },
|
|
12
|
+
{ code: 'INR', symbol: '₹', name: 'Indian rupee' },
|
|
13
|
+
{ code: 'JPY', symbol: '¥', name: 'Japanese yen' },
|
|
14
|
+
{ code: 'KRW', symbol: '₩', name: 'South Korean won' },
|
|
15
|
+
{ code: 'MXN', symbol: 'MX$', name: 'Mexican peso' },
|
|
16
|
+
{ code: 'NOK', symbol: 'kr', name: 'Norwegian krone' },
|
|
17
|
+
{ code: 'PLN', symbol: 'zł', name: 'Polish złoty' },
|
|
18
|
+
{ code: 'RUB', symbol: '₽', name: 'Russian ruble' },
|
|
19
|
+
{ code: 'SEK', symbol: 'kr', name: 'Swedish krona' },
|
|
20
|
+
{ code: 'TRY', symbol: '₺', name: 'Turkish lira' },
|
|
21
|
+
{ code: 'USD', symbol: '$', name: 'US dollar' },
|
|
22
|
+
] as const;
|
|
23
|
+
|
|
24
|
+
export type RenterInsuranceCurrencyCode = (typeof currencyOptions)[number]['code'];
|
|
25
|
+
|
|
26
|
+
export const defaultCurrencyByLocale: Record<string, RenterInsuranceCurrencyCode> = {
|
|
27
|
+
de: 'EUR',
|
|
28
|
+
en: 'USD',
|
|
29
|
+
es: 'EUR',
|
|
30
|
+
fr: 'EUR',
|
|
31
|
+
id: 'IDR',
|
|
32
|
+
it: 'EUR',
|
|
33
|
+
ja: 'JPY',
|
|
34
|
+
ko: 'KRW',
|
|
35
|
+
nl: 'EUR',
|
|
36
|
+
pl: 'PLN',
|
|
37
|
+
pt: 'EUR',
|
|
38
|
+
ru: 'RUB',
|
|
39
|
+
sv: 'SEK',
|
|
40
|
+
tr: 'TRY',
|
|
41
|
+
zh: 'CNY',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const approximateUsdFactors: Record<RenterInsuranceCurrencyCode, number> = {
|
|
45
|
+
AUD: 1.52,
|
|
46
|
+
BRL: 5.1,
|
|
47
|
+
CAD: 1.38,
|
|
48
|
+
CHF: 0.88,
|
|
49
|
+
CNY: 7.2,
|
|
50
|
+
CZK: 23,
|
|
51
|
+
DKK: 6.85,
|
|
52
|
+
USD: 1,
|
|
53
|
+
EUR: 0.92,
|
|
54
|
+
GBP: 0.79,
|
|
55
|
+
IDR: 16000,
|
|
56
|
+
INR: 84,
|
|
57
|
+
JPY: 150,
|
|
58
|
+
KRW: 1380,
|
|
59
|
+
MXN: 18,
|
|
60
|
+
NOK: 10.7,
|
|
61
|
+
PLN: 3.95,
|
|
62
|
+
RUB: 90,
|
|
63
|
+
SEK: 10.5,
|
|
64
|
+
TRY: 34,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const isRenterInsuranceCurrencyCode = (value: string): value is RenterInsuranceCurrencyCode => currencyOptions.some(({ code }) => code === value);
|
|
68
|
+
|
|
69
|
+
export const convertRenterInsuranceCurrency = (amount: number, from: RenterInsuranceCurrencyCode, to: RenterInsuranceCurrencyCode): number => Number((amount * approximateUsdFactors[to] / approximateUsdFactors[from]).toFixed(2));
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { CoverageCategoryResult, RenterInsuranceResult } from './logic';
|
|
2
|
+
import type { RenterInsuranceCoverageGapCalculatorUI } from './ui';
|
|
3
|
+
import { evaluateCoverage } from './evaluator';
|
|
4
|
+
|
|
5
|
+
export interface ViewConfig {
|
|
6
|
+
root: HTMLElement;
|
|
7
|
+
locale: string;
|
|
8
|
+
ui: RenterInsuranceCoverageGapCalculatorUI;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const setText = (root: HTMLElement, selector: string, value: string): void => {
|
|
12
|
+
const element = root.querySelector<HTMLElement>(selector);
|
|
13
|
+
if (element) element.textContent = value;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const formatMoney = (locale: string, currency: string, value: number): string => new Intl.NumberFormat(locale, {
|
|
17
|
+
style: 'currency',
|
|
18
|
+
currency,
|
|
19
|
+
maximumFractionDigits: 2,
|
|
20
|
+
}).format(value);
|
|
21
|
+
|
|
22
|
+
const renderCategoryList = (root: HTMLElement, categories: CoverageCategoryResult[], locale: string, currency: string): void => {
|
|
23
|
+
const list = root.querySelector<HTMLOListElement>('[data-ric-largest-list]');
|
|
24
|
+
if (!list) return;
|
|
25
|
+
list.replaceChildren(...categories.map((category) => {
|
|
26
|
+
const item = document.createElement('li');
|
|
27
|
+
item.textContent = `${category.category}: ${formatMoney(locale, currency, category.value)}`;
|
|
28
|
+
return item;
|
|
29
|
+
}));
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const renderShelf = (root: HTMLElement, result: RenterInsuranceResult): void => {
|
|
33
|
+
const total = Math.max(result.totalContents, 1);
|
|
34
|
+
const contentWidth = Math.min(100, result.totalContents / total * 100);
|
|
35
|
+
const limitWidth = Math.min(100, result.policyLimit / total * 100);
|
|
36
|
+
root.querySelector<HTMLElement>('[data-ric-contents-fill]')?.style.setProperty('width', `${contentWidth}%`);
|
|
37
|
+
root.querySelector<HTMLElement>('[data-ric-limit-line]')?.style.setProperty('left', `${limitWidth}%`);
|
|
38
|
+
root.querySelector<HTMLElement>('[data-ric-loss-mark]')?.style.setProperty('left', `${Math.min(100, result.sampleLoss / total * 100)}%`);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function renderRenterInsuranceResult(config: ViewConfig, result: RenterInsuranceResult): void {
|
|
42
|
+
const { root, locale, ui } = config;
|
|
43
|
+
const evaluation = evaluateCoverage(result, ui);
|
|
44
|
+
root.dataset.status = evaluation.status;
|
|
45
|
+
setText(root, '[data-ric-status]', evaluation.label);
|
|
46
|
+
setText(root, '[data-ric-status-text]', evaluation.text);
|
|
47
|
+
setText(root, '[data-ric-total]', formatMoney(locale, result.currency, result.totalContents));
|
|
48
|
+
setText(root, '[data-ric-gap]', formatMoney(locale, result.currency, result.coverageGap));
|
|
49
|
+
setText(root, '[data-ric-ratio]', `${new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(result.coverageRatio * 100)}%`);
|
|
50
|
+
setText(root, '[data-ric-payout]', formatMoney(locale, result.currency, result.estimatedPayout));
|
|
51
|
+
setText(root, '[data-ric-out-of-pocket]', formatMoney(locale, result.currency, result.estimatedOutOfPocket));
|
|
52
|
+
setText(root, '[data-ric-loss-label]', `${ui.labelLossScenario}: ${formatMoney(locale, result.currency, result.sampleLoss)}`);
|
|
53
|
+
renderCategoryList(root, result.largestCategories, locale, result.currency);
|
|
54
|
+
renderShelf(root, result);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function clearRenterInsuranceResult(root: HTMLElement, ui: RenterInsuranceCoverageGapCalculatorUI): void {
|
|
58
|
+
root.dataset.status = 'invalid';
|
|
59
|
+
['total', 'gap', 'ratio', 'payout', 'out-of-pocket'].forEach((key) => setText(root, `[data-ric-${key}]`, '—'));
|
|
60
|
+
setText(root, '[data-ric-status]', ui.labelInvalidInput);
|
|
61
|
+
setText(root, '[data-ric-status-text]', '');
|
|
62
|
+
root.querySelector<HTMLElement>('[data-ric-contents-fill]')?.style.setProperty('width', '0%');
|
|
63
|
+
root.querySelector<HTMLElement>('[data-ric-limit-line]')?.style.setProperty('left', '0%');
|
|
64
|
+
root.querySelector<HTMLElement>('[data-ric-loss-mark]')?.style.setProperty('left', '0%');
|
|
65
|
+
root.querySelector('[data-ric-largest-list]')?.replaceChildren();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export { formatMoney };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { FinanceToolEntry, ToolLocaleContent } from '../../types';
|
|
2
|
+
import type { RenterInsuranceCoverageGapCalculatorUI } from './ui';
|
|
3
|
+
|
|
4
|
+
export type RenterInsuranceCoverageGapCalculatorLocaleContent = ToolLocaleContent<RenterInsuranceCoverageGapCalculatorUI>;
|
|
5
|
+
|
|
6
|
+
export const renterInsuranceCoverageGapCalculator: FinanceToolEntry<RenterInsuranceCoverageGapCalculatorUI> = {
|
|
7
|
+
id: 'renter-insurance-coverage-gap-calculator',
|
|
8
|
+
icons: { bg: 'mdi:home-alert', fg: 'mdi:shield-home' },
|
|
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,17 @@
|
|
|
1
|
+
import type { CoverageStatus, RenterInsuranceResult } from './logic';
|
|
2
|
+
import type { RenterInsuranceCoverageGapCalculatorUI } from './ui';
|
|
3
|
+
|
|
4
|
+
export interface CoverageEvaluation {
|
|
5
|
+
status: CoverageStatus;
|
|
6
|
+
label: string;
|
|
7
|
+
text: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function evaluateCoverage(result: RenterInsuranceResult, ui: RenterInsuranceCoverageGapCalculatorUI): CoverageEvaluation {
|
|
11
|
+
const content: Record<CoverageStatus, { label: string; text: string }> = {
|
|
12
|
+
safe: { label: ui.labelStatusSafe, text: ui.labelStatusSafeText },
|
|
13
|
+
review: { label: ui.labelStatusReview, text: ui.labelStatusReviewText },
|
|
14
|
+
exposed: { label: ui.labelStatusExposed, text: ui.labelStatusExposedText },
|
|
15
|
+
};
|
|
16
|
+
return { status: result.status, ...content[result.status] };
|
|
17
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ToolLocaleContent } from '../../../types';
|
|
2
|
+
import { bibliography } from '../bibliography';
|
|
3
|
+
import type { RenterInsuranceCoverageGapCalculatorUI } from '../ui';
|
|
4
|
+
|
|
5
|
+
export const ui: RenterInsuranceCoverageGapCalculatorUI = {
|
|
6
|
+
labelPolicyLimit: 'Deckungssumme Hausrat', labelDeductible: 'Selbstbeteiligung', labelSampleLoss: 'Beispielschaden', labelCurrency: 'Währung', labelApproximateRates: 'Die Umrechnung nutzt ungefähre Planungsfaktoren und keine Live-Wechselkurse.', labelPolicyBoundary: 'Versicherungsgrenze', labelSetLimit: 'Grenze festlegen', labelPolicyBoundaryHint: 'Übernimm die Hausratwerte aus deiner Police und prüfe anschließend einen möglichen Schaden.', labelInventory: 'Ersatzwert-Inventar', labelInventoryHint: 'Schätze je Kategorie, was der komplette Ersatz heute kosten würde.', labelPolicyLimitHelp: 'Nutze die Hausratdeckung aus der Police, nicht die Gebäudesumme.', labelDeductibleHelp: 'Trage die Selbstbeteiligung für einen Hausratschaden ein.', labelSampleLossHelp: 'Dient nur zur Darstellung eines beispielhaften Schadenfalls nach der Lücke.', labelTotalContents: 'Geschätzter Hausratwert', labelCoverageGap: 'Mögliche Deckungslücke', labelCoverageRatio: 'Geschätzter Deckungsgrad', labelEstimatedPayout: 'Beispielauszahlung', labelOutOfPocket: 'Beispielhafte Eigenzahlung', labelStatus: 'Deckungseinschätzung', labelStatusSafe: 'Grenze deckt Inventar', labelStatusReview: 'Kleine Lücke prüfen', labelStatusExposed: 'Hausrat ist unterversichert', labelStatusSafeText: 'Die eingetragene Hausratgrenze erreicht mindestens den Ersatzwert. Prüfe Sonderlimits für einzelne Gegenstände zusätzlich.', labelStatusReviewText: 'Die Grenze liegt nahe am Inventarwert, aber eine kleine Lücke bleibt. Prüfe teure Gegenstände und neue Anschaffungen.', labelStatusExposedText: 'Die Grenze liegt unter dem Inventarwert. Prüfe die Police, bevor du dich bei einem Totalschaden darauf verlässt.', labelLargestCategories: 'Größte Inventarkategorien', labelCoverageLine: 'Deckungssumme', labelReplacementValue: 'Ersatzwert', labelLimitMarker: 'Markierung der Grenze', labelLossScenario: 'Schadenszenario', labelIllustrativePayout: 'Nur ein Beispiel: Eine Unterversicherungsregel muss auf deine Police nicht zutreffen.', labelReset: 'Beispiel zurücksetzen', labelCopy: 'Zusammenfassung kopieren', labelCopied: 'Zusammenfassung kopiert', labelCopyFailed: 'Ergebnis manuell aus dem Feld kopieren', labelInvalidInput: 'Prüfe den markierten Wert', labelMethodTitle: 'So funktioniert die Schätzung', labelMethodText: 'Das Tool addiert deine Kategoriewerte und vergleicht sie mit der Hausratdeckung. Die Lücke ist die positive Differenz. Die Beispielauszahlung wendet den Deckungsgrad auf den Schaden an und zieht die Selbstbeteiligung ab. Das ist ein Planungsmodell und keine Vorhersage einer Regulierung. Deine Police kann Einzelgrenzen, Neuwertregeln, Ausschlüsse, Erstes-Risiko-Deckung oder eine andere Behandlung von Unterversicherung vorsehen.', labelPrivacyNote: 'Dein Inventar bleibt in diesem Browser. Konto, Upload und Serverberechnung sind nicht erforderlich.', labelAmountPlaceholder: '0,00', labelCategoryFurniture: 'Möbel und Dekoration', labelCategoryElectronics: 'Elektronik', labelCategoryClothing: 'Kleidung und Schuhe', labelCategoryKitchen: 'Küche und Geräte', labelCategoryPersonal: 'Persönliche Gegenstände', labelCategoryOther: 'Sonstiger Hausrat',
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const slug = 'mieterversicherung-unterversicherung-hausrat-rechner';
|
|
10
|
+
const title = 'Rechner für die Deckungslücke bei der Hausratversicherung';
|
|
11
|
+
const description = 'Prüfe, ob die Hausratdeckung deiner Mieterversicherung den Ersatzwert deiner Sachen erreicht. Vergleiche Inventar, Versicherungssumme, Selbstbeteiligung und einen Beispielschaden direkt im Browser.';
|
|
12
|
+
const faq = [
|
|
13
|
+
{ question: 'Was misst dieser Rechner für die Hausratversicherung?', answer: 'Er addiert die von dir eingetragenen Ersatzwerte und vergleicht die Summe mit der Hausratdeckung. Die Differenz zeigt als Planungsschätzung, welcher Betrag möglicherweise außerhalb der Grenze liegt.' },
|
|
14
|
+
{ question: 'Welchen Wert soll ich für mein Inventar eintragen?', answer: 'Nutze realistische aktuelle Ersatzkosten für Möbel, Elektronik, Kleidung, Geräte und persönliche Gegenstände. Prüfe, ob deine Police den Neuwert oder eine andere Bewertungsgrundlage verlangt.' },
|
|
15
|
+
{ question: 'Beweist das Ergebnis eine Unterversicherung?', answer: 'Nein. Das Ergebnis hängt von deinem Inventar, dem Vertrag und deinen Bewertungsannahmen ab. Einzelgrenzen, Ausschlüsse, besondere Vereinbarungen und Unterversicherungsklauseln können einen Schaden anders behandeln.' },
|
|
16
|
+
{ question: 'Warum zeigt der Rechner eine Beispielauszahlung?', answer: 'Sie macht sichtbar, was ein einfaches proportionales Modell für den eingetragenen Schaden nach Abzug der Selbstbeteiligung ergeben würde. Sie sagt nicht voraus, was der Versicherer tatsächlich zahlt.' },
|
|
17
|
+
{ question: 'Soll ich Einbauten und Ausstattung des Vermieters einrechnen?', answer: 'Nur Gegenstände, die laut Police zu deiner Hausratdeckung gehören. Haftpflicht, Gebäude, Einbauten und Hausrat können getrennte Bereiche sein und sollten nicht künstlich zusammengezählt werden.' },
|
|
18
|
+
];
|
|
19
|
+
const howTo = [
|
|
20
|
+
{ name: 'Ersatzwert-Inventar erstellen', text: 'Gehe durch alle Räume und trage ein, was ein Ersatz des Hausrats heute kosten würde. Ordne die Werte den sechs Kategorien zu, damit die größten Posten sichtbar bleiben.' },
|
|
21
|
+
{ name: 'Hausratgrenze eintragen', text: 'Übernimm die Versicherungssumme aus dem Versicherungsschein oder Angebot. Verwende nicht die Gebäude- oder Haftpflichtsumme.' },
|
|
22
|
+
{ name: 'Selbstbeteiligung und Schaden ergänzen', text: 'Trage die Selbstbeteiligung und einen Beispielschaden ein, um eine beispielhafte Eigenzahlung zu sehen.' },
|
|
23
|
+
{ name: 'Entscheidungsgrenze prüfen', text: 'Bei einer Lücke solltest du teure Gegenstände, Einzelgrenzen, Wertsachen und neue Anschaffungen mit Versicherer oder Makler prüfen.' },
|
|
24
|
+
];
|
|
25
|
+
export const content: ToolLocaleContent<RenterInsuranceCoverageGapCalculatorUI> = { slug, title, description, ui, seo: [
|
|
26
|
+
{ type: 'title', text: 'Vergleiche die Grenze mit dem Wert deines Hausrats', level: 2 },
|
|
27
|
+
{ type: 'paragraph', html: '<p>Eine Hausratversicherung wird leicht zu niedrig angesetzt, wenn die Summe nur grob gewählt statt Raum für Raum ermittelt wird. Dieser Rechner macht deine eigenen Ersatzschätzungen als Deckungsgrenze sichtbar, bevor du einen Schaden melden musst.</p>' },
|
|
28
|
+
{ type: 'title', text: 'Das solltest du vor der Berechnung sammeln', level: 2 },
|
|
29
|
+
{ type: 'list', items: ['Ersatzwerte für Möbel, Elektronik, Kleidung, Geräte und persönliche Gegenstände.', 'Die Hausratdeckung aus dem Versicherungsschein, getrennt von Gebäude und Haftpflicht.', 'Die Selbstbeteiligung für einen Hausratschaden.', 'Einen realistischen Beispielschaden, wenn du die Beispielauszahlung prüfen möchtest.'] },
|
|
30
|
+
{ type: 'title', text: 'So liest du die Deckungslücke', level: 2 },
|
|
31
|
+
{ type: 'paragraph', html: '<p>Die Lücke ist der Inventarwert oberhalb der Grenze. Null bedeutet, dass beide Summen unter deinen Annahmen übereinstimmen; es bestätigt nicht die Deckung jedes einzelnen Gegenstands. Eine positive Lücke ist ein Anlass, Bewertungsgrundlage, Einzelgrenzen, Ausschlüsse und Unterversicherungsklauseln zu prüfen.</p>' },
|
|
32
|
+
{ type: 'table', headers: ['Signal', 'Aussage', 'Nächster sinnvoller Schritt'], rows: [['Grenze deckt Inventar', 'Die Grenze erreicht mindestens die eingetragene Ersatzsumme.', 'Wertsachen, Einzelgrenzen und Ausschlüsse prüfen.'], ['Kleine Lücke prüfen', 'Die Grenze liegt nahe am Wert, doch neue Käufe oder Rundungen können zählen.', 'Die größten Kategorien neu bewerten und die Summe anpassen.'], ['Hausrat ist unterversichert', 'Die Grenze liegt deutlich unter dem Ersatzwert.', 'Neues Angebot anfordern oder die Unterversicherungsregel klären.']] },
|
|
33
|
+
{ type: 'title', text: 'Grenzen der Schätzung', level: 2 },
|
|
34
|
+
{ type: 'paragraph', html: '<p>Das Tool liest keine Police, entscheidet nicht über die Deckung und berechnet keine verbindliche Entschädigung. Das Schadenbeispiel nutzt ein einfaches proportionales Modell und zieht die Selbstbeteiligung ab. Verträge können andere Klauseln, Wertansätze, Ausschlüsse oder Erstes-Risiko-Regeln enthalten.</p>' },
|
|
35
|
+
{ type: 'tip', title: 'Tipp zur Dokumentation', html: '<p>Bewahre Rechnungen, Fotos und ein datiertes Inventar für wertvolle Sachen auf. Der Financial Ombudsman Service erläutert, warum fehlende Ersatzwertberechnung zu Unterversicherung führen kann; das spanische Versicherungsvertragsgesetz beschreibt die Versicherungssumme als maximale Entschädigung und eine proportionale Regel bei Unterversicherung, sofern die Police nichts anderes bestimmt.</p>' },
|
|
36
|
+
], faq, bibliography, howTo, schemas: [
|
|
37
|
+
{ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })) },
|
|
38
|
+
{ '@context': 'https://schema.org', '@type': 'HowTo', name: title, step: howTo.map((step, index) => ({ '@type': 'HowToStep', position: index + 1, name: step.name, text: step.text })) },
|
|
39
|
+
{ '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: title, description, applicationCategory: 'FinanceApplication', operatingSystem: 'All', offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' } },
|
|
40
|
+
] };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { ToolLocaleContent } from '../../../types';
|
|
2
|
+
import { bibliography } from '../bibliography';
|
|
3
|
+
import type { RenterInsuranceCoverageGapCalculatorUI } from '../ui';
|
|
4
|
+
|
|
5
|
+
export const ui: RenterInsuranceCoverageGapCalculatorUI = {
|
|
6
|
+
labelPolicyLimit: 'Contents coverage limit',
|
|
7
|
+
labelDeductible: 'Deductible',
|
|
8
|
+
labelSampleLoss: 'Illustrative loss scenario',
|
|
9
|
+
labelCurrency: 'Currency',
|
|
10
|
+
labelApproximateRates: 'Currency changes use approximate planning factors, not live exchange rates.',
|
|
11
|
+
labelPolicyBoundary: 'Policy boundary',
|
|
12
|
+
labelSetLimit: 'Set the limit',
|
|
13
|
+
labelPolicyBoundaryHint: 'Use the contents figures from your policy, then test one possible loss.',
|
|
14
|
+
labelInventory: 'Replacement inventory',
|
|
15
|
+
labelInventoryHint: 'Estimate what it would cost to replace everything today, by category.',
|
|
16
|
+
labelPolicyLimitHelp: 'Use the contents limit shown in your policy, not the building limit.',
|
|
17
|
+
labelDeductibleHelp: 'Enter the excess or deductible that applies to a contents claim.',
|
|
18
|
+
labelSampleLossHelp: 'Used only to show an illustrative claim scenario after the gap is found.',
|
|
19
|
+
labelTotalContents: 'Estimated contents value',
|
|
20
|
+
labelCoverageGap: 'Potential coverage gap',
|
|
21
|
+
labelCoverageRatio: 'Estimated cover ratio',
|
|
22
|
+
labelEstimatedPayout: 'Illustrative payout',
|
|
23
|
+
labelOutOfPocket: 'Illustrative out of pocket',
|
|
24
|
+
labelStatus: 'Coverage reading',
|
|
25
|
+
labelStatusSafe: 'Limit reaches the inventory',
|
|
26
|
+
labelStatusReview: 'Small gap to review',
|
|
27
|
+
labelStatusExposed: 'Contents are underinsured',
|
|
28
|
+
labelStatusSafeText: 'Your entered contents limit is at least as high as the replacement inventory. Check special item limits separately.',
|
|
29
|
+
labelStatusReviewText: 'The limit is close to the inventory estimate, but a small gap remains. Recheck expensive items and recent purchases.',
|
|
30
|
+
labelStatusExposedText: 'The limit is below the inventory estimate. Review the policy before relying on it for a total contents loss.',
|
|
31
|
+
labelLargestCategories: 'Largest inventory categories',
|
|
32
|
+
labelCoverageLine: 'Policy limit',
|
|
33
|
+
labelReplacementValue: 'Replacement value',
|
|
34
|
+
labelLimitMarker: 'Limit marker',
|
|
35
|
+
labelLossScenario: 'Loss scenario',
|
|
36
|
+
labelIllustrativePayout: 'Illustrative only: a proportional clause may not apply to your policy.',
|
|
37
|
+
labelReset: 'Reset example',
|
|
38
|
+
labelCopy: 'Copy summary',
|
|
39
|
+
labelCopied: 'Summary copied',
|
|
40
|
+
labelCopyFailed: 'Copy it manually from the result',
|
|
41
|
+
labelInvalidInput: 'Check the highlighted value',
|
|
42
|
+
labelMethodTitle: 'How this estimate works',
|
|
43
|
+
labelMethodText: 'The tool adds your category estimates and compares them with the contents limit. The gap is the positive difference between those values. The illustrative payout applies the cover ratio to the loss scenario, then subtracts the deductible; this is a planning model, not a prediction of a claim settlement. Your policy may use item limits, new-for-old rules, exclusions, first-loss cover or a different treatment of underinsurance.',
|
|
44
|
+
labelPrivacyNote: 'Your inventory stays in this browser. No account, upload or server calculation is required.',
|
|
45
|
+
labelAmountPlaceholder: '0.00',
|
|
46
|
+
labelCategoryFurniture: 'Furniture and decor',
|
|
47
|
+
labelCategoryElectronics: 'Electronics',
|
|
48
|
+
labelCategoryClothing: 'Clothing and shoes',
|
|
49
|
+
labelCategoryKitchen: 'Kitchen and appliances',
|
|
50
|
+
labelCategoryPersonal: 'Personal items',
|
|
51
|
+
labelCategoryOther: 'Other contents',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const slug = 'renter-insurance-coverage-gap-calculator';
|
|
55
|
+
const title = 'Renter Insurance Coverage Gap Calculator';
|
|
56
|
+
const description = 'Estimate whether your renters contents coverage limit reaches the replacement value of your belongings. Compare your inventory, policy limit, deductible and an illustrative loss scenario in your browser.';
|
|
57
|
+
|
|
58
|
+
const faq = [
|
|
59
|
+
{ question: 'What does this renters insurance coverage calculator measure?', answer: 'It adds the replacement values you enter for household contents and compares that total with your policy contents limit. The difference is a planning estimate of the amount that may sit outside the limit.' },
|
|
60
|
+
{ question: 'Which value should I use for my contents inventory?', answer: 'Use a realistic current replacement estimate for all belongings covered by the contents section, including furniture, electronics, clothing, appliances and personal items. Check whether your policy asks for new-for-old or another valuation basis.' },
|
|
61
|
+
{ question: 'Does the result prove that I am underinsured?', answer: 'No. The result is only as good as your inventory, policy wording and valuation assumptions. Item sublimits, exclusions, special schedules and clauses about underinsurance can change a claim.' },
|
|
62
|
+
{ question: 'Why does the calculator show an illustrative payout?', answer: 'It shows what a simple proportional model would produce for the loss amount you enter, after the deductible. It is included to make the effect of a gap tangible, but it does not predict what an insurer will pay.' },
|
|
63
|
+
{ question: 'Should I include my landlord\'s fixtures and fittings?', answer: 'Only include items that belong in your contents cover and follow your policy wording. Tenants liability, buildings, fixtures and contents can be separate sections, so do not merge them just to increase the inventory total.' },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
const howTo = [
|
|
67
|
+
{ name: 'Build a replacement inventory', text: 'Walk through each room and enter what it would cost to replace the contents today. Group items into the six categories so the largest sources of value remain visible.' },
|
|
68
|
+
{ name: 'Enter the contents limit', text: 'Copy the contents sum insured from your policy schedule or quote. Do not use the buildings limit or a liability limit.' },
|
|
69
|
+
{ name: 'Add the deductible and test a loss', text: 'Enter the deductible for contents claims and a sample loss amount to see an illustrative out-of-pocket scenario.' },
|
|
70
|
+
{ name: 'Review the decision boundary', text: 'If a gap appears, revisit expensive items, single-item limits, valuables and recent purchases with the insurer or broker before relying on the policy.' },
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
export const content: ToolLocaleContent<RenterInsuranceCoverageGapCalculatorUI> = {
|
|
74
|
+
slug,
|
|
75
|
+
title,
|
|
76
|
+
description,
|
|
77
|
+
ui,
|
|
78
|
+
seo: [
|
|
79
|
+
{ type: 'title', text: 'Check the limit against the value of your belongings', level: 2 },
|
|
80
|
+
{ type: 'paragraph', html: '<p>Renters contents insurance is easiest to underestimate when the limit is chosen as a round number rather than built from a room-by-room inventory. This calculator turns your own replacement estimates into a visible coverage boundary, so you can see whether the policy limit leaves a gap before you need to claim.</p>' },
|
|
81
|
+
{ type: 'title', text: 'What to gather before calculating', level: 2 },
|
|
82
|
+
{ type: 'list', items: ['A replacement estimate for furniture, electronics, clothing, appliances and personal items.', 'The contents limit from the policy schedule, separate from buildings or liability cover.', 'The deductible or excess that applies to a contents claim.', 'A realistic sample loss if you want to explore an illustrative payout.'] },
|
|
83
|
+
{ type: 'title', text: 'How to read the coverage gap', level: 2 },
|
|
84
|
+
{ type: 'paragraph', html: '<p>The gap is the contents estimate above the limit. A zero gap means the two totals meet under your assumptions; it does not confirm that every item is covered. A positive gap is a prompt to inspect the valuation basis, expensive-item sublimits, exclusions and any underinsurance clause in the policy.</p>' },
|
|
85
|
+
{ type: 'table', headers: ['Signal', 'What it tells you', 'Next useful action'], rows: [['Limit reaches inventory', 'The entered limit is at least the entered replacement total.', 'Check valuables, item limits and policy exclusions.'], ['Small gap to review', 'The limit is close, but recent purchases or rounding could matter.', 'Recheck the biggest categories and update the schedule if needed.'], ['Contents are underinsured', 'The entered limit is materially below the replacement total.', 'Ask for a revised quote or confirm how the policy handles underinsurance.']] },
|
|
86
|
+
{ type: 'title', text: 'Limits of the estimate', level: 2 },
|
|
87
|
+
{ type: 'paragraph', html: '<p>This tool does not read a policy, decide whether a loss is covered or calculate a binding settlement. The payout scenario uses a simple proportional model and subtracts the deductible. Real policies may use different clauses, item limits, valuation rules, exclusions or first-loss arrangements.</p>' },
|
|
88
|
+
{ type: 'tip', title: 'Evidence tip', html: '<p>Keep receipts, photographs and a dated inventory for high-value belongings. The Financial Ombudsman Service explains why consumers can be underinsured when they do not calculate the full replacement cost of contents, while Spanish insurance law describes the sum insured as the maximum indemnity and sets a proportional rule for underinsurance unless the policy says otherwise.</p>' },
|
|
89
|
+
],
|
|
90
|
+
faq,
|
|
91
|
+
bibliography,
|
|
92
|
+
howTo,
|
|
93
|
+
schemas: [
|
|
94
|
+
{ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })) },
|
|
95
|
+
{ '@context': 'https://schema.org', '@type': 'HowTo', name: title, step: howTo.map((step, index) => ({ '@type': 'HowToStep', position: index + 1, name: step.name, text: step.text })) },
|
|
96
|
+
{ '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: title, description, applicationCategory: 'FinanceApplication', operatingSystem: 'All', offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' } },
|
|
97
|
+
],
|
|
98
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ToolLocaleContent } from '../../../types';
|
|
2
|
+
import { bibliography } from '../bibliography';
|
|
3
|
+
import type { RenterInsuranceCoverageGapCalculatorUI } from '../ui';
|
|
4
|
+
export const ui: RenterInsuranceCoverageGapCalculatorUI = {
|
|
5
|
+
labelPolicyLimit: 'Límite de contenido asegurado', labelDeductible: 'Franquicia', labelSampleLoss: 'Siniestro de ejemplo', labelCurrency: 'Moneda', labelApproximateRates: 'La conversión usa factores aproximados para planificar, no tipos de cambio en tiempo real.', labelPolicyBoundary: 'Límite de la póliza', labelSetLimit: 'Fija el límite', labelPolicyBoundaryHint: 'Usa las cifras de contenido de tu póliza y prueba después una posible pérdida.', labelInventory: 'Inventario de reposición', labelInventoryHint: 'Calcula por categoría cuánto costaría sustituirlo todo hoy.', labelPolicyLimitHelp: 'Usa el límite del contenido de la póliza, no el límite del edificio.', labelDeductibleHelp: 'Introduce la franquicia aplicable a un siniestro de contenido.', labelSampleLossHelp: 'Solo sirve para mostrar un siniestro orientativo después de detectar la brecha.', labelTotalContents: 'Valor estimado del contenido', labelCoverageGap: 'Posible brecha de cobertura', labelCoverageRatio: 'Proporción cubierta estimada', labelEstimatedPayout: 'Indemnización orientativa', labelOutOfPocket: 'Pago de tu bolsillo orientativo', labelStatus: 'Lectura de cobertura', labelStatusSafe: 'El límite alcanza el inventario', labelStatusReview: 'Revisa una brecha pequeña', labelStatusExposed: 'El contenido está infrasegurado', labelStatusSafeText: 'El límite de contenido indicado es igual o superior al inventario de reposición. Comprueba aparte los límites de objetos especiales.', labelStatusReviewText: 'El límite está cerca del inventario, pero queda una brecha pequeña. Revisa los objetos caros y las compras recientes.', labelStatusExposedText: 'El límite está por debajo del inventario. Revisa la póliza antes de confiar en ella para una pérdida total.', labelLargestCategories: 'Mayores categorías del inventario', labelCoverageLine: 'Límite de póliza', labelReplacementValue: 'Valor de reposición', labelLimitMarker: 'Marcador del límite', labelLossScenario: 'Escenario de pérdida', labelIllustrativePayout: 'Solo orientativo: una cláusula proporcional puede no aplicarse a tu póliza.', labelReset: 'Restablecer ejemplo', labelCopy: 'Copiar resumen', labelCopied: 'Resumen copiado', labelCopyFailed: 'Copia manualmente el resultado', labelInvalidInput: 'Revisa el valor destacado', labelMethodTitle: 'Cómo funciona esta estimación', labelMethodText: 'La herramienta suma tus estimaciones por categoría y las compara con el límite de contenido. La brecha es la diferencia positiva entre ambos valores. La indemnización orientativa aplica la proporción cubierta al escenario de pérdida y resta la franquicia; es un modelo de planificación, no una predicción de liquidación. Tu póliza puede tener límites por objeto, reglas de valor a nuevo, exclusiones, cobertura a primer riesgo u otro tratamiento del infraseguro.', labelPrivacyNote: 'Tu inventario permanece en este navegador. No hace falta cuenta, subida de archivos ni cálculo en servidor.', labelAmountPlaceholder: '0,00', labelCategoryFurniture: 'Muebles y decoración', labelCategoryElectronics: 'Electrónica', labelCategoryClothing: 'Ropa y calzado', labelCategoryKitchen: 'Cocina y electrodomésticos', labelCategoryPersonal: 'Objetos personales', labelCategoryOther: 'Otros contenidos',
|
|
6
|
+
};
|
|
7
|
+
const slug = 'calculadora-brecha-cobertura-seguro-inquilino';
|
|
8
|
+
const title = 'Calculadora de brecha de cobertura del seguro de inquilino';
|
|
9
|
+
const description = 'Comprueba si el límite de contenido de tu seguro de inquilino alcanza el valor de reposición de tus pertenencias. Compara inventario, póliza, franquicia y un siniestro orientativo en el navegador.';
|
|
10
|
+
const faq = [
|
|
11
|
+
{ question: '¿Qué mide esta calculadora de cobertura para inquilinos?', answer: 'Suma los valores de reposición que introduces para el contenido de tu vivienda y los compara con el límite de la póliza. La diferencia es una estimación orientativa de lo que podría quedar fuera del límite.' },
|
|
12
|
+
{ question: '¿Qué valor debo usar para mi inventario?', answer: 'Usa un coste actual y realista de reposición para muebles, electrónica, ropa, electrodomésticos y objetos personales. Comprueba si tu póliza exige valor a nuevo u otra base de valoración.' },
|
|
13
|
+
{ question: '¿El resultado demuestra que estoy infrasegurado?', answer: 'No. Depende del inventario, las condiciones y las hipótesis de valoración. Los sublímites, exclusiones, objetos declarados y cláusulas de infraseguro pueden cambiar un siniestro.' },
|
|
14
|
+
{ question: '¿Por qué aparece una indemnización orientativa?', answer: 'Muestra qué produciría un modelo proporcional sencillo para la pérdida indicada, después de la franquicia. Ayuda a entender la brecha, pero no predice lo que pagará la aseguradora.' },
|
|
15
|
+
{ question: '¿Debo incluir las instalaciones del propietario?', answer: 'Incluye solo lo que corresponda a tu cobertura de contenido según la póliza. Responsabilidad civil, continente, instalaciones y contenido pueden ser apartados distintos y no conviene mezclarlos.' },
|
|
16
|
+
];
|
|
17
|
+
const howTo = [
|
|
18
|
+
{ name: 'Crea un inventario de reposición', text: 'Recorre cada habitación e introduce cuánto costaría reponer hoy su contenido. Agrupa los artículos en las seis categorías para ver dónde está el mayor valor.' },
|
|
19
|
+
{ name: 'Introduce el límite de contenido', text: 'Copia la suma asegurada de contenido de las condiciones o del presupuesto. No uses el límite del continente ni el de responsabilidad civil.' },
|
|
20
|
+
{ name: 'Añade la franquicia y prueba una pérdida', text: 'Introduce la franquicia de los siniestros de contenido y un importe de pérdida para ver un escenario orientativo de pago propio.' },
|
|
21
|
+
{ name: 'Revisa el punto de decisión', text: 'Si aparece una brecha, comprueba objetos caros, sublímites, joyas y compras recientes con la aseguradora o la correduría.' },
|
|
22
|
+
];
|
|
23
|
+
export const content: ToolLocaleContent<RenterInsuranceCoverageGapCalculatorUI> = { slug, title, description, ui, seo: [
|
|
24
|
+
{ type: 'title', text: 'Compara el límite con el valor de tus pertenencias', level: 2 },
|
|
25
|
+
{ type: 'paragraph', html: '<p>El seguro de contenido de una vivienda alquilada suele quedarse corto cuando el límite se elige como una cifra redonda y no a partir de un inventario habitación por habitación. Esta calculadora convierte tus valores de reposición en un límite visible para detectar una brecha antes de necesitar el seguro.</p>' },
|
|
26
|
+
{ type: 'title', text: 'Qué debes reunir antes de calcular', level: 2 },
|
|
27
|
+
{ type: 'list', items: ['Una estimación de reposición para muebles, electrónica, ropa, electrodomésticos y objetos personales.', 'El límite de contenido de la póliza, separado de continente y responsabilidad civil.', 'La franquicia que se aplica a un siniestro de contenido.', 'Una pérdida de ejemplo realista si quieres explorar la indemnización orientativa.'] },
|
|
28
|
+
{ type: 'title', text: 'Cómo interpretar la brecha de cobertura', level: 2 },
|
|
29
|
+
{ type: 'paragraph', html: '<p>La brecha es el valor del contenido que supera el límite. Una brecha cero significa que ambos totales coinciden según tus hipótesis; no confirma que cada objeto esté cubierto. Una brecha positiva invita a revisar la valoración, los sublímites de objetos caros, las exclusiones y cualquier cláusula de infraseguro.</p>' },
|
|
30
|
+
{ type: 'table', headers: ['Señal', 'Qué indica', 'Siguiente acción útil'], rows: [['El límite alcanza el inventario', 'El límite introducido llega al total de reposición indicado.', 'Comprueba objetos valiosos, sublímites y exclusiones.'], ['Revisa una brecha pequeña', 'El límite está cerca, pero las compras recientes o el redondeo pueden importar.', 'Revisa las categorías mayores y actualiza la póliza si hace falta.'], ['El contenido está infrasegurado', 'El límite indicado queda claramente por debajo del valor de reposición.', 'Pide un presupuesto revisado o confirma cómo trata la póliza el infraseguro.']] },
|
|
31
|
+
{ type: 'title', text: 'Límites de la estimación', level: 2 },
|
|
32
|
+
{ type: 'paragraph', html: '<p>Esta herramienta no lee una póliza, decide si un daño está cubierto ni calcula una liquidación vinculante. El escenario usa un modelo proporcional sencillo y resta la franquicia. Las pólizas reales pueden aplicar cláusulas, límites, valoraciones, exclusiones o coberturas a primer riesgo diferentes.</p>' },
|
|
33
|
+
{ type: 'tip', title: 'Consejo de prueba', html: '<p>Guarda facturas, fotografías y un inventario fechado de las pertenencias de alto valor. El Financial Ombudsman Service explica por qué se produce infraseguro cuando no se calcula el coste completo de reposición; la Ley española de Contrato de Seguro describe la suma asegurada como límite máximo de indemnización y contempla una regla proporcional salvo pacto distinto.</p>' },
|
|
34
|
+
], faq, bibliography, howTo, schemas: [
|
|
35
|
+
{ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })) },
|
|
36
|
+
{ '@context': 'https://schema.org', '@type': 'HowTo', name: title, step: howTo.map((step, index) => ({ '@type': 'HowToStep', position: index + 1, name: step.name, text: step.text })) },
|
|
37
|
+
{ '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: title, description, applicationCategory: 'FinanceApplication', operatingSystem: 'All', offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' } },
|
|
38
|
+
] };
|