Gonçalo Rodrigues 4b7c01e632 feat(finance): i18n — TOML-based translations for all personal finance templates
Adds a full translation layer (English + European Portuguese) using
BurntSushi/toml with go:embed. Locale detection reads the lang cookie,
falls back to Accept-Language, then defaults to "en". A language switcher
in the nav writes the cookie and redirects back. All 20 personal finance
templates now use {{.T.Get "key"}} for every UI string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 22:32:49 +01:00

349 lines
19 KiB
HTML

{{define "content"}}
{{$d := .}}
{{$r := .Result}}
<div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:24px; flex-wrap:wrap; gap:8px;">
<h1>{{$d.T.Get "plan.title"}}</h1>
<span class="text-muted">{{if $d.Email}}{{$d.Email}}{{end}}</span>
</div>
<!-- Inputs form -->
<div class="card animate-on-scroll" style="margin-bottom:20px;">
<h2 style="margin-bottom:4px;">{{$d.T.Get "plan.scenario_title"}}</h2>
<p style="font-size:13px; color:var(--text3); margin-bottom:16px;">
{{$d.T.Get "plan.scenario_desc"}}
</p>
<form method="GET" action="/plan">
<input type="hidden" name="run" value="1">
<div class="grid" style="gap:14px; margin-bottom:16px;">
<div>
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "plan.label_current_asset"}}</label>
<select name="property_id" style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px;">
<option value="">{{$d.T.Get "plan.option_none_asset"}}</option>
{{range $d.Properties}}
<option value="{{.ID}}" {{if and $d.HasResult (eq $d.Form.PropertyID .ID)}}selected{{end}}>{{.Name}} (€{{cents .CurrentValueCents}})</option>
{{end}}
</select>
</div>
<div>
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "plan.label_current_loan"}}</label>
<select name="loan_id" style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px;">
<option value="">{{$d.T.Get "plan.option_none_loan"}}</option>
{{range $d.Loans}}
<option value="{{.ID}}" {{if and $d.HasResult (eq $d.Form.LoanID .ID)}}selected{{end}}>{{.Name}} (€{{cents .BalanceCents}} left)</option>
{{end}}
</select>
</div>
<div>
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "plan.label_dream_cost"}}</label>
<input type="number" name="dream_cost" min="0" step="1000"
value="{{if $d.HasResult}}{{div $d.Form.DreamCostCents 100}}{{end}}"
placeholder="{{$d.T.Get "plan.placeholder_dream_cost"}}"
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
</div>
<div>
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "plan.label_down_pct"}}</label>
<input type="number" name="down_pct" min="0" max="100" step="1"
value="{{if $d.HasResult}}{{round $d.Form.DownPaymentPct}}{{else}}20{{end}}"
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
</div>
<div>
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "plan.label_loan_rate"}}</label>
<input type="number" name="const_rate" min="0" max="30" step="0.1"
value="{{if $d.HasResult}}{{$d.Form.ConstructionRatePct}}{{else}}4.0{{end}}"
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
</div>
<div>
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "plan.label_loan_term"}}</label>
<input type="number" name="const_term" min="1" max="40" step="1"
value="{{if $d.HasResult}}{{$d.Form.ConstructionTermYears}}{{else}}30{{end}}"
placeholder="{{$d.T.Get "plan.placeholder_loan_term"}}"
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
</div>
<div>
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "plan.label_build_months"}}</label>
<input type="number" name="build_months" min="1" max="60" step="1"
value="{{if $d.HasResult}}{{$d.Form.BuildMonths}}{{else}}18{{end}}"
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
</div>
<div>
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "plan.label_monthly_savings"}}</label>
<input type="number" name="monthly_savings" min="0" step="100"
value="{{if $d.HasResult}}{{div $d.Form.MonthlySavingsCents 100}}{{end}}"
placeholder="{{$d.T.Get "plan.placeholder_savings"}}"
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
</div>
<div>
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "plan.label_sale_price"}}</label>
<input type="number" name="sale_price" min="0" step="1000"
value="{{if $d.HasResult}}{{div $d.Form.ExpectedSalePriceCents 100}}{{end}}"
placeholder="{{$d.T.Get "plan.placeholder_sale_price"}}"
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
</div>
</div>
<button type="submit" class="btn btn-primary" style="width:100%;">{{$d.T.Get "plan.btn_run"}}</button>
</form>
</div>
{{if $d.HasResult}}
{{if $r.Warning}}
<div style="background:rgba(251,191,36,0.12); border:1px solid rgba(251,191,36,0.4); border-radius:var(--radius); padding:12px 16px; margin-bottom:16px; font-size:13px; color:var(--text2);">
⚠️ {{$r.Warning}}
</div>
{{end}}
<!-- Summary bar -->
<div class="grid" style="margin-bottom:20px;">
<div class="card value-card animate-on-scroll">
<h2>{{$d.T.Get "plan.result_total_timeline"}}</h2>
<div class="value positive">{{$r.TotalYears}}y {{$r.TotalRemMonths}}m</div>
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "plan.result_until_paid"}}</p>
</div>
<div class="card value-card animate-on-scroll">
<h2>{{$d.T.Get "plan.result_final_monthly"}}</h2>
<div class="value positive">€{{cents $r.Phase4MonthlyCents}}</div>
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "plan.result_after_selling"}}</p>
</div>
<div class="card value-card animate-on-scroll">
<h2>{{$d.T.Get "plan.result_total_interest"}}</h2>
<div class="value" style="color:var(--red);">€{{cents $r.TotalInterestCents}}</div>
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "plan.result_across_both"}}</p>
</div>
<div class="card value-card animate-on-scroll">
<h2>{{$d.T.Get "plan.result_free_by"}}</h2>
<div class="value positive" style="font-size:24px;">{{dateShort $r.FinalDate}}</div>
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "plan.result_fully_paid"}}</p>
</div>
</div>
<!-- Phase timeline -->
<div class="card animate-on-scroll" style="margin-bottom:20px;">
<h2 style="margin-bottom:20px;">{{$d.T.Get "plan.roadmap_title"}}</h2>
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:0; position:relative;">
<!-- connector bar -->
<div style="position:absolute; top:20px; left:12.5%; right:12.5%; height:3px; background:var(--border); z-index:0; border-radius:2px;"></div>
<!-- Phase 1 -->
<div style="text-align:center; position:relative; padding:0 8px;">
<div style="width:40px; height:40px; border-radius:50%; background:var(--accent); color:#fff; display:flex; align-items:center; justify-content:center; font-size:18px; margin:0 auto 12px; position:relative; z-index:1;">1</div>
<div style="font-weight:600; font-size:13px; color:var(--text1); margin-bottom:6px;">{{$d.T.Get "plan.phase1_title"}}</div>
{{if gt $r.Phase1Months 0}}
<div style="font-size:22px; font-weight:500; color:var(--accent); margin-bottom:4px;">{{$r.Phase1Months}}mo</div>
<div style="font-size:11px; color:var(--text3);">until {{dateShort $r.Phase1EndDate}}</div>
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
<div>{{$d.T.Get "plan.phase1_target"}} <strong>€{{cents $r.DownPaymentCents}}</strong></div>
<div>{{$d.T.Get "plan.phase1_already_have"}} <strong>€{{cents $r.AlreadyHaveCents}}</strong></div>
<div>{{$d.T.Get "plan.phase1_still_need"}} <strong>€{{cents $r.StillNeededCents}}</strong></div>
<div>{{$d.T.Get "plan.phase1_saving"}} <strong>€{{cents $r.Form.MonthlySavingsCents}}/mo</strong></div>
</div>
{{else}}
<div style="font-size:16px; font-weight:600; color:var(--green); margin-bottom:4px;">{{$d.T.Get "plan.phase1_ready"}}</div>
<div style="font-size:11px; color:var(--text3);">{{$d.T.Get "plan.phase1_equity_covers"}}</div>
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
<div>{{$d.T.Get "plan.phase1_down_payment"}} <strong>€{{cents $r.DownPaymentCents}}</strong></div>
<div>{{$d.T.Get "plan.phase1_your_equity"}} <strong style="color:var(--green);">€{{cents $r.AlreadyHaveCents}}</strong></div>
</div>
{{end}}
</div>
<!-- Phase 2 -->
<div style="text-align:center; position:relative; padding:0 8px;">
<div style="width:40px; height:40px; border-radius:50%; background:var(--orange, #f97316); color:#fff; display:flex; align-items:center; justify-content:center; font-size:18px; margin:0 auto 12px; position:relative; z-index:1;">2</div>
<div style="font-weight:600; font-size:13px; color:var(--text1); margin-bottom:6px;">{{$d.T.Get "plan.phase2_title"}}</div>
<div style="font-size:22px; font-weight:500; color:var(--orange, #f97316); margin-bottom:4px;">{{$r.Phase2Months}}mo</div>
<div style="font-size:11px; color:var(--text3);">until {{dateShort $r.Phase2EndDate}}</div>
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
<div>{{$d.T.Get "plan.phase2_new_loan"}} <strong>€{{cents $r.ConstructionLoanCents}}</strong></div>
{{if $r.CurrentLoan}}
<div>{{$d.T.Get "plan.phase2_existing_loan"}} <strong>€{{cents $r.CurrentMonthlyCents}}/mo</strong></div>
{{end}}
<div>{{$d.T.Get "plan.phase2_new_emi"}} <strong>€{{cents $r.ConstructionMonthly}}/mo</strong></div>
<div style="border-top:1px solid var(--border); margin-top:6px; padding-top:6px;">{{$d.T.Get "plan.phase2_total_burden"}} <strong style="color:var(--orange, #f97316);">€{{cents $r.Phase2MonthlyCents}}/mo</strong></div>
</div>
</div>
<!-- Phase 3 -->
<div style="text-align:center; position:relative; padding:0 8px;">
<div style="width:40px; height:40px; border-radius:50%; background:var(--teal, #14b8a6); color:#fff; display:flex; align-items:center; justify-content:center; font-size:18px; margin:0 auto 12px; position:relative; z-index:1;">3</div>
<div style="font-weight:600; font-size:13px; color:var(--text1); margin-bottom:6px;">{{$d.T.Get "plan.phase3_title"}}</div>
<div style="font-size:16px; font-weight:600; color:var(--teal, #14b8a6); margin-bottom:4px;">{{$d.T.Get "plan.phase3_one_time"}}</div>
<div style="font-size:11px; color:var(--text3);">{{$d.T.Get "plan.phase3_after_acquisition"}}</div>
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
<div>{{$d.T.Get "plan.phase3_sale_price"}} <strong>€{{cents $r.SalePriceCents}}</strong></div>
<div>{{$d.T.Get "plan.phase3_pay_off"}} <strong style="color:var(--red);">-€{{cents $r.MortgagePayoffCents}}</strong></div>
<div style="border-top:1px solid var(--border); margin-top:6px; padding-top:6px;">{{$d.T.Get "plan.phase3_net_proceeds"}} <strong style="color:var(--green);">€{{cents $r.NetProceedsCents}}</strong></div>
<div>{{$d.T.Get "plan.phase3_applied"}}</div>
</div>
</div>
<!-- Phase 4 -->
<div style="text-align:center; position:relative; padding:0 8px;">
<div style="width:40px; height:40px; border-radius:50%; background:var(--green); color:#fff; display:flex; align-items:center; justify-content:center; font-size:18px; margin:0 auto 12px; position:relative; z-index:1;">4</div>
<div style="font-weight:600; font-size:13px; color:var(--text1); margin-bottom:6px;">{{$d.T.Get "plan.phase4_title"}}</div>
{{if gt $r.Phase4Months 0}}
<div style="font-size:22px; font-weight:500; color:var(--green); margin-bottom:4px;">{{$r.Phase4Months}}mo</div>
<div style="font-size:11px; color:var(--text3);">paid off {{dateShort $r.Phase4EndDate}}</div>
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
<div>{{$d.T.Get "plan.phase4_remaining_loan"}} <strong>€{{cents $r.RemainingBalanceCents}}</strong></div>
<div>{{$d.T.Get "plan.phase4_monthly_payment"}} <strong style="color:var(--green);">€{{cents $r.Phase4MonthlyCents}}/mo</strong></div>
<div style="font-size:11px; color:var(--text3); margin-top:4px;">{{$d.T.Get "plan.phase4_just_new_loan"}}</div>
</div>
{{else}}
<div style="font-size:16px; font-weight:600; color:var(--green); margin-bottom:4px;">{{$d.T.Get "plan.phase4_fully_paid"}}</div>
<div style="font-size:11px; color:var(--text3);">{{$d.T.Get "plan.phase4_sale_cleared"}}</div>
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
<div style="color:var(--green); font-weight:600;">{{$d.T.Get "plan.phase4_no_remaining"}}</div>
<div>{{$d.T.Get "plan.phase4_sale_covers"}}</div>
</div>
{{end}}
</div>
</div>
</div>
<!-- Monthly cost chart -->
<div class="card animate-on-scroll" style="margin-bottom:20px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
<h2>{{$d.T.Get "plan.chart_title"}}</h2>
<span style="font-size:11px; color:var(--text3);">{{$d.T.Get "plan.chart_subtitle"}}</span>
</div>
<canvas id="cost-chart" height="160"></canvas>
</div>
<script>
(function() {
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const style = getComputedStyle(document.documentElement);
const accent = style.getPropertyValue('--accent').trim() || '#6979f8';
const green = style.getPropertyValue('--green').trim() || '#4ade80';
const orange = '#f97316';
const phase1Months = {{$r.Phase1Months}};
const phase2Months = {{$r.Phase2Months}};
const phase4Months = {{$r.Phase4Months}};
const existingMonthly = {{$r.CurrentMonthlyCents}} / 100;
const phase2Monthly = {{$r.Phase2MonthlyCents}} / 100;
const phase4Monthly = {{$r.Phase4MonthlyCents}} / 100;
const labels = [];
const costs = [];
const colors = [];
for (let i = 0; i < phase1Months; i++) {
labels.push('Save ' + (i+1));
costs.push(existingMonthly);
colors.push(accent);
}
for (let i = 0; i < phase2Months; i++) {
labels.push('Acquire ' + (i+1));
costs.push(phase2Monthly);
colors.push(orange);
}
for (let i = 0; i < phase4Months; i++) {
labels.push('Goal ' + (i+1));
costs.push(phase4Monthly);
colors.push(green);
}
const maxLabels = 24;
const step = Math.ceil(labels.length / maxLabels);
const displayLabels = labels.map((l, i) => i % step === 0 ? l : '');
new Chart(document.getElementById('cost-chart').getContext('2d'), {
type: 'bar',
data: {
labels: displayLabels,
datasets: [{
data: costs,
backgroundColor: colors,
borderRadius: 3,
borderSkipped: false,
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
title: (items) => labels[items[0].dataIndex],
label: (c) => '€' + c.parsed.y.toLocaleString('pt-PT', {minimumFractionDigits:2}) + '/mo'
}
}
},
scales: {
x: {
grid: { display: false },
ticks: { color: isDark ? '#5c6585' : '#9fa8c7', font: { size: 10 } }
},
y: {
grid: { color: isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.05)' },
ticks: {
color: isDark ? '#5c6585' : '#9fa8c7',
callback: v => '€' + (v/1000).toFixed(0) + 'k'
}
}
}
}
});
})();
</script>
<!-- Key levers -->
<div class="card animate-on-scroll">
<h2 style="margin-bottom:14px;">{{$d.T.Get "plan.levers_title"}}</h2>
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; font-size:13px; color:var(--text2);">
<div style="background:var(--bg3); border-radius:var(--radius-sm); padding:12px 14px;">
<div style="font-weight:600; color:var(--text1); margin-bottom:6px;">{{$d.T.Get "plan.lever1_title"}}</div>
<div>{{$d.T.Get "plan.lever1_desc"}}</div>
</div>
<div style="background:var(--bg3); border-radius:var(--radius-sm); padding:12px 14px;">
<div style="font-weight:600; color:var(--text1); margin-bottom:6px;">{{$d.T.Get "plan.lever2_title"}}</div>
<div>{{$d.T.Get "plan.lever2_desc"}}</div>
</div>
<div style="background:var(--bg3); border-radius:var(--radius-sm); padding:12px 14px;">
<div style="font-weight:600; color:var(--text1); margin-bottom:6px;">{{$d.T.Get "plan.lever3_title"}}</div>
<div>{{$d.T.Get "plan.lever3_desc"}}</div>
</div>
<div style="background:var(--bg3); border-radius:var(--radius-sm); padding:12px 14px;">
<div style="font-weight:600; color:var(--text1); margin-bottom:6px;">{{$d.T.Get "plan.lever4_title"}}</div>
<div>{{$d.T.Get "plan.lever4_desc"}}</div>
</div>
</div>
</div>
{{else}}
<!-- Empty state -->
<div class="card empty-state animate-on-scroll">
<div style="font-size:48px; margin-bottom:16px;">🎯</div>
<h3>{{$d.T.Get "plan.empty_title"}}</h3>
<p style="margin-bottom:8px; max-width:480px; margin-left:auto; margin-right:auto;">
{{$d.T.Get "plan.empty_desc"}}
</p>
{{if not $d.Properties}}
<p style="font-size:13px; color:var(--text3); margin-top:12px;">
Tip: <a href="/property" style="color:var(--accent);">{{$d.T.Get "plan.scenario_desc"}}</a>
</p>
{{end}}
</div>
{{end}}
{{end}}