feat: added i18n
This commit is contained in:
@@ -15,6 +15,8 @@ import type {
|
||||
AboveLawBenefitsFlagMap,
|
||||
} from "@/lib/calculations";
|
||||
|
||||
import { useI18n } from "@/i18n/context";
|
||||
|
||||
interface BenefitsFormProps {
|
||||
data: CalculationData;
|
||||
enabled: AboveLawBenefitsFlagMap;
|
||||
@@ -28,12 +30,11 @@ export function BenefitsForm({
|
||||
onDataChange,
|
||||
onFlagsChange,
|
||||
}: BenefitsFormProps) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<FieldSet>
|
||||
<FieldLegend>Above Law Benefits</FieldLegend>
|
||||
<FieldDescription>
|
||||
Additional benefits commonly offered in Mexico
|
||||
</FieldDescription>
|
||||
<FieldLegend>{t("benefits.legend")}</FieldLegend>
|
||||
<FieldDescription>{t("benefits.description")}</FieldDescription>
|
||||
<FieldGroup>
|
||||
{/* Savings Fund */}
|
||||
<Field orientation="horizontal">
|
||||
@@ -45,8 +46,12 @@ export function BenefitsForm({
|
||||
}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<FieldLabel htmlFor="savings-fund-enabled">Savings Fund</FieldLabel>
|
||||
<FieldDescription>Do you receive extra savings?</FieldDescription>
|
||||
<FieldLabel htmlFor="savings-fund-enabled">
|
||||
{t("benefits.savingsFund.label")}
|
||||
</FieldLabel>
|
||||
<FieldDescription>
|
||||
{t("benefits.savingsFund.description")}
|
||||
</FieldDescription>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
@@ -54,7 +59,9 @@ export function BenefitsForm({
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 pl-8 animate-in slide-in-from-left-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="savings-percentage">
|
||||
Percentage ({data.savings_fund?.percentage || 7}%) Saved
|
||||
{t("benefits.savingsFund.percentage", {
|
||||
rate: data.savings_fund?.percentage || 7,
|
||||
})}
|
||||
</FieldLabel>
|
||||
<Slider
|
||||
id="savings-percentage"
|
||||
@@ -74,7 +81,7 @@ export function BenefitsForm({
|
||||
}
|
||||
/>
|
||||
<FieldDescription>
|
||||
Commonly capped at 13% for tax benefits
|
||||
{t("benefits.savingsFund.percentageInfo")}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
<Field className="items-center" orientation="horizontal">
|
||||
@@ -92,9 +99,11 @@ export function BenefitsForm({
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<FieldLabel htmlFor="savings-symmetrical">
|
||||
Is Symmetrical?
|
||||
{t("benefits.savingsFund.symmetrical")}
|
||||
</FieldLabel>
|
||||
<FieldDescription>Does the employer match it?</FieldDescription>
|
||||
<FieldDescription>
|
||||
{t("benefits.savingsFund.symmetricalInfo")}
|
||||
</FieldDescription>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
@@ -113,9 +122,11 @@ export function BenefitsForm({
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<FieldLabel htmlFor="food-vouchers-enabled">
|
||||
Food Vouchers
|
||||
{t("benefits.foodVouchers.label")}
|
||||
</FieldLabel>
|
||||
<FieldDescription>Monthly allowance for groceries</FieldDescription>
|
||||
<FieldDescription>
|
||||
{t("benefits.foodVouchers.description")}
|
||||
</FieldDescription>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
@@ -123,7 +134,7 @@ export function BenefitsForm({
|
||||
<div className="animate-in slide-in-from-left-2 pl-8">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="food-vouchers-amount">
|
||||
Monthly Amount
|
||||
{t("benefits.foodVouchers.amount")}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="food-vouchers-amount"
|
||||
@@ -150,15 +161,21 @@ export function BenefitsForm({
|
||||
}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<FieldLabel htmlFor="pto-enabled">Additional PTO</FieldLabel>
|
||||
<FieldDescription>Extra days off or floating days</FieldDescription>
|
||||
<FieldLabel htmlFor="pto-enabled">
|
||||
{t("benefits.additionalPto.label")}
|
||||
</FieldLabel>
|
||||
<FieldDescription>
|
||||
{t("benefits.additionalPto.description")}
|
||||
</FieldDescription>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{enabled.additional_pto && (
|
||||
<div className="animate-in slide-in-from-left-2 pl-8">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pto-days">Days of Additional PTO</FieldLabel>
|
||||
<FieldLabel htmlFor="pto-days">
|
||||
{t("benefits.additionalPto.days")}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="pto-days"
|
||||
type="number"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
FieldDescription,
|
||||
} from "../ui/field";
|
||||
import { Input } from "../ui/input";
|
||||
import {
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "../ui/select";
|
||||
import { Plus, Trash } from "lucide-react";
|
||||
import type { AdditionalBonus } from "@/lib/calculations";
|
||||
import { useI18n } from "@/i18n/context";
|
||||
|
||||
interface BonusesListProps {
|
||||
bonuses: AdditionalBonus[];
|
||||
@@ -24,6 +25,8 @@ interface BonusesListProps {
|
||||
}
|
||||
|
||||
export function BonusesList({ bonuses, onChange }: BonusesListProps) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const addBonus = () => {
|
||||
onChange([
|
||||
...bonuses,
|
||||
@@ -46,24 +49,22 @@ export function BonusesList({ bonuses, onChange }: BonusesListProps) {
|
||||
|
||||
return (
|
||||
<FieldSet>
|
||||
<FieldLegend>Additional Bonuses</FieldLegend>
|
||||
<FieldDescription>
|
||||
Performance bonuses, commissions, or annual incentives
|
||||
</FieldDescription>
|
||||
<FieldLegend>{t("bonuses.legend")}</FieldLegend>
|
||||
<FieldDescription>{t("bonuses.description")}</FieldDescription>
|
||||
|
||||
<div className="space-y-6 mt-4">
|
||||
<div className="mt-4 space-y-6">
|
||||
{bonuses.map((bonus) => (
|
||||
<FieldGroup
|
||||
key={bonus.id}
|
||||
className="grid grid-cols-1 md:grid-cols-[1fr_200px_200px_auto] gap-4 items-end p-4 border rounded-lg bg-card"
|
||||
className="grid grid-cols-1 items-end gap-4 rounded-lg border bg-card p-4 md:grid-cols-[1fr_200px_200px_auto]"
|
||||
>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`bonus-name-${bonus.id}`}>
|
||||
Description
|
||||
{t("common.description")}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={`bonus-name-${bonus.id}`}
|
||||
placeholder="e.g. Yearly Bonus"
|
||||
placeholder={t("bonuses.placeholderName")}
|
||||
value={bonus.name}
|
||||
onChange={(e) =>
|
||||
updateBonus(bonus.id, { name: e.target.value })
|
||||
@@ -72,7 +73,7 @@ export function BonusesList({ bonuses, onChange }: BonusesListProps) {
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`bonus-payout-${bonus.id}`}>
|
||||
Payout
|
||||
{t("common.payout")}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={`bonus-payout-${bonus.id}`}
|
||||
@@ -85,7 +86,9 @@ export function BonusesList({ bonuses, onChange }: BonusesListProps) {
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`bonus-type-${bonus.id}`}>Type</FieldLabel>
|
||||
<FieldLabel htmlFor={`bonus-type-${bonus.id}`}>
|
||||
{t("common.type")}
|
||||
</FieldLabel>
|
||||
<Select
|
||||
value={bonus.type}
|
||||
onValueChange={(value) =>
|
||||
@@ -98,22 +101,32 @@ export function BonusesList({ bonuses, onChange }: BonusesListProps) {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="one_time">One Time</SelectItem>
|
||||
<SelectItem value="monthly">Monthly</SelectItem>
|
||||
<SelectItem value="quarterly">Quarterly</SelectItem>
|
||||
<SelectItem value="bi-annual">Bi-Annual</SelectItem>
|
||||
<SelectItem value="yearly">Yearly</SelectItem>
|
||||
<SelectItem value="one_time">
|
||||
{t("bonuses.types.one_time")}
|
||||
</SelectItem>
|
||||
<SelectItem value="monthly">
|
||||
{t("bonuses.types.monthly")}
|
||||
</SelectItem>
|
||||
<SelectItem value="quarterly">
|
||||
{t("bonuses.types.quarterly")}
|
||||
</SelectItem>
|
||||
<SelectItem value="bi-annual">
|
||||
{t("bonuses.types.bi-annual")}
|
||||
</SelectItem>
|
||||
<SelectItem value="yearly">
|
||||
{t("bonuses.types.yearly")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => removeBonus(bonus.id)}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
<span className="sr-only">Remove</span>
|
||||
<span className="sr-only">{t("common.remove")}</span>
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
))}
|
||||
@@ -125,7 +138,7 @@ export function BonusesList({ bonuses, onChange }: BonusesListProps) {
|
||||
onClick={addBonus}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Bonus
|
||||
{t("bonuses.addBonus")}
|
||||
</Button>
|
||||
</div>
|
||||
</FieldSet>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { Input } from "../ui/input";
|
||||
import { Slider } from "../ui/slider";
|
||||
import type { CalculationData } from "@/lib/calculations";
|
||||
import { useI18n } from "@/i18n/context";
|
||||
|
||||
interface IncomeFormProps {
|
||||
data: CalculationData;
|
||||
@@ -16,13 +17,16 @@ interface IncomeFormProps {
|
||||
}
|
||||
|
||||
export function IncomeForm({ data, onChange }: IncomeFormProps) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<FieldSet>
|
||||
<FieldLegend>Income Information</FieldLegend>
|
||||
<FieldDescription>Salary and Benefits by law</FieldDescription>
|
||||
<FieldLegend>{t("income.legend")}</FieldLegend>
|
||||
<FieldDescription>{t("income.description")}</FieldDescription>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="base-salary">Base Monthly Salary</FieldLabel>
|
||||
<FieldLabel htmlFor="base-salary">
|
||||
{t("income.baseSalary")}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="base-salary"
|
||||
type="number"
|
||||
@@ -34,10 +38,10 @@ export function IncomeForm({ data, onChange }: IncomeFormProps) {
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="christmas-bonus">
|
||||
Days of Christmas Bonus
|
||||
{t("income.christmasBonus")}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="christmas-bonus"
|
||||
@@ -50,10 +54,12 @@ export function IncomeForm({ data, onChange }: IncomeFormProps) {
|
||||
})
|
||||
}
|
||||
/>
|
||||
<FieldDescription>Minimum 15 days by law</FieldDescription>
|
||||
<FieldDescription>{t("income.christmasBonusMin")}</FieldDescription>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="leave-days">Days of Paid Leave</FieldLabel>
|
||||
<FieldLabel htmlFor="leave-days">
|
||||
{t("income.paidLeave")}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="leave-days"
|
||||
type="number"
|
||||
@@ -63,11 +69,11 @@ export function IncomeForm({ data, onChange }: IncomeFormProps) {
|
||||
onChange({ days_of_leave: Number(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
<FieldDescription>Minimum 12 days for 1st year</FieldDescription>
|
||||
<FieldDescription>{t("income.paidLeaveMin")}</FieldDescription>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="leave-rate-slider">
|
||||
Rate of Paid Leave ({data.leave_rate || 25}%)
|
||||
{t("income.leaveRate", { rate: data.leave_rate || 25 })}
|
||||
</FieldLabel>
|
||||
<Slider
|
||||
id="leave-rate-slider"
|
||||
@@ -78,7 +84,7 @@ export function IncomeForm({ data, onChange }: IncomeFormProps) {
|
||||
value={[data.leave_rate || 25]}
|
||||
onValueChange={(values) => onChange({ leave_rate: values[0] })}
|
||||
/>
|
||||
<FieldDescription>Minimum 25% by law</FieldDescription>
|
||||
<FieldDescription>{t("income.leaveRateMin")}</FieldDescription>
|
||||
</Field>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
|
||||
@@ -9,8 +9,10 @@ import type {
|
||||
import { IncomeForm } from "./calculator/income-form";
|
||||
import { BenefitsForm } from "./calculator/benefits-form";
|
||||
import { BonusesList } from "./calculator/bonuses-list";
|
||||
import { useI18n } from "@/i18n/context";
|
||||
|
||||
export default function CompensationCalculator() {
|
||||
const { t, formatCurrency } = useI18n();
|
||||
const [calcData, setCalcData] = useState<CalculationData>({
|
||||
base_monthly_salary: 0,
|
||||
days_of_christmas_bonus: 15,
|
||||
@@ -48,12 +50,6 @@ export default function CompensationCalculator() {
|
||||
);
|
||||
}, [calcData, benefitsEnabled, additionalBonuses]);
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat("es-MX", {
|
||||
style: "currency",
|
||||
currency: "MXN",
|
||||
}).format(value);
|
||||
|
||||
const formattedGross = formatCurrency(grossResult.total);
|
||||
const formattedNet = formatCurrency(netResult.total);
|
||||
|
||||
@@ -62,64 +58,71 @@ export default function CompensationCalculator() {
|
||||
<main className="max-w-4xl mx-auto px-4 py-12 space-y-12">
|
||||
<header className="space-y-4">
|
||||
<h1 className="text-4xl font-extrabold tracking-tight lg:text-5xl">
|
||||
MX Compensation Calculator
|
||||
{t("calculator.title")}
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground">
|
||||
Estimate your total yearly compensation including law and above-law
|
||||
benefits.
|
||||
{t("calculator.subtitle")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="bg-primary/5 border border-primary/10 rounded-2xl p-8 sticky top-4 z-20 backdrop-blur-xl shadow-xl transition-all duration-300 hover:shadow-2xl hover:bg-primary/[0.07] grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<section className="sticky top-4 z-20 grid grid-cols-1 gap-8 rounded-2xl border border-primary/10 bg-primary/5 p-8 shadow-xl backdrop-blur-xl transition-all duration-300 hover:bg-primary/[0.07] hover:shadow-2xl md:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Total Yearly Gross
|
||||
{t("calculator.grossTotal")}
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-4xl md:text-5xl font-black transition-all bg-gradient-to-tr from-primary to-blue-500 bg-clip-text text-transparent">
|
||||
<span className="bg-linear-to-tr from-primary to-blue-500 bg-clip-text text-4xl font-black text-transparent transition-all md:text-5xl">
|
||||
{formattedGross}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-muted-foreground/50">
|
||||
MXN
|
||||
{t("common.mxn")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Total Yearly Net
|
||||
{t("calculator.netTotal")}
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-4xl md:text-5xl font-black transition-all bg-gradient-to-tr from-green-600 to-emerald-400 bg-clip-text text-transparent">
|
||||
<span className="bg-linear-to-tr from-green-600 to-emerald-400 bg-clip-text text-4xl font-black text-transparent transition-all md:text-5xl">
|
||||
{formattedNet}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-muted-foreground/50">
|
||||
MXN
|
||||
{t("common.mxn")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{netResult.breakdown && (
|
||||
<div className="col-span-1 md:col-span-2 pt-6 border-t border-primary/10 grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div className="col-span-1 grid grid-cols-2 gap-4 border-t border-primary/10 pt-6 text-sm md:col-span-2 md:grid-cols-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-muted-foreground">Monthly ISR</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t("calculator.monthlyIsr")}
|
||||
</span>
|
||||
<span className="font-mono font-bold text-red-500">
|
||||
-{formatCurrency(netResult.breakdown.isr)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-muted-foreground">Monthly IMSS</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t("calculator.monthlyImss")}
|
||||
</span>
|
||||
<span className="font-mono font-bold text-red-500">
|
||||
-{formatCurrency(netResult.breakdown.imss)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-muted-foreground">Monthly Net</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t("calculator.monthlyNet")}
|
||||
</span>
|
||||
<span className="font-mono font-bold text-green-600">
|
||||
{formatCurrency(netResult.breakdown.netSalary)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-muted-foreground">Exempt Benefits</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t("calculator.exemptBenefits")}
|
||||
</span>
|
||||
<span className="font-mono font-bold text-blue-600 underline decoration-dotted">
|
||||
{formatCurrency(
|
||||
netResult.breakdown.exemptBenefits.aguinaldo +
|
||||
@@ -161,32 +164,25 @@ export default function CompensationCalculator() {
|
||||
</FieldGroup>
|
||||
</form>
|
||||
|
||||
<section className="p-6 bg-muted/30 rounded-xl border border-dashed text-sm space-y-4">
|
||||
<section className="rounded-xl border border-dashed bg-muted/30 p-6 text-sm space-y-4">
|
||||
<h3 className="font-bold flex items-center gap-2">
|
||||
Assumptions & Sources (2025 Legislation)
|
||||
{t("assumptions.title")}
|
||||
</h3>
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-2 list-disc list-inside text-muted-foreground">
|
||||
<li>
|
||||
ISR calculated per Art. 96 LISR using 2025 Monthly Brackets.
|
||||
</li>
|
||||
<li>
|
||||
UMA value updated to 2025 rates ($113.14 daily, eff. Feb 1).
|
||||
</li>
|
||||
<li>IMSS share estimated on Base Salary (approx. SBC).</li>
|
||||
<li>Aguinaldo exempt up to 30 UMA (Art. 93 LISR).</li>
|
||||
<li>Savings Fund exempt if symmetrical (LISR/LIMSS).</li>
|
||||
<ul className="grid grid-cols-1 list-inside list-disc gap-x-8 gap-y-2 text-muted-foreground md:grid-cols-2">
|
||||
<li>{t("assumptions.isr")}</li>
|
||||
<li>{t("assumptions.uma")}</li>
|
||||
<li>{t("assumptions.imss")}</li>
|
||||
<li>{t("assumptions.aguinaldo")}</li>
|
||||
<li>{t("assumptions.savings")}</li>
|
||||
</ul>
|
||||
<p className="text-[10px] opacity-70 italic pt-2">
|
||||
Note: This calculator is for estimation only and does not constitute
|
||||
legal or financial advice. Correctness depends on specific contract
|
||||
terms.
|
||||
<p className="pt-2 text-[10px] italic opacity-70">
|
||||
{t("assumptions.note")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<footer className="pt-12 border-t text-center text-sm text-muted-foreground">
|
||||
<footer className="border-t pt-12 text-center text-sm text-muted-foreground">
|
||||
<p>
|
||||
© {new Date().getFullYear()} MX Compensation Calculator. Calculated
|
||||
based on 2025 Mexican Labor and Tax Law.
|
||||
{t("footer.copy", { year: new Date().getFullYear().toString() })}
|
||||
</p>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
@@ -1,27 +1,19 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useI18n } from "@/i18n/context";
|
||||
|
||||
import { ModeToggle } from "./mode-toggle";
|
||||
import { LanguageToggle } from "./language-toggle";
|
||||
|
||||
export default function Header() {
|
||||
const links = [{ to: "/", label: "Home" }] as const;
|
||||
const { t } = useI18n();
|
||||
const links = [{ to: "/", label: t("common.home" as any) }] as const;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-row items-center justify-between px-2 py-1">
|
||||
<nav className="flex gap-4 text-lg">
|
||||
{links.map(({ to, label }) => {
|
||||
return (
|
||||
<Link key={to} to={to}>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="flex items-center gap-2">
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="absolute left-0 right-0 px-page-width">
|
||||
<div className="flex items-center justify-end py-2">
|
||||
<LanguageToggle />
|
||||
</div>
|
||||
<hr />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
40
apps/web/src/components/language-toggle.tsx
Normal file
40
apps/web/src/components/language-toggle.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Button } from "./ui/button";
|
||||
import { useI18n, type Locale } from "@/i18n/context";
|
||||
import { Languages } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
|
||||
export function LanguageToggle() {
|
||||
const { locale, setLocale } = useI18n();
|
||||
|
||||
const locales: { label: string; value: Locale }[] = [
|
||||
{ label: "Español (MX)", value: "es-MX" },
|
||||
{ label: "English (US)", value: "en-US" },
|
||||
];
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9">
|
||||
<Languages className="h-[1.2rem] w-[1.2rem]" />
|
||||
<span className="sr-only">Toggle language</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{locales.map((l) => (
|
||||
<DropdownMenuItem
|
||||
key={l.value}
|
||||
onClick={() => setLocale(l.value)}
|
||||
className={locale === l.value ? "bg-accent font-bold" : ""}
|
||||
>
|
||||
{l.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
115
apps/web/src/i18n/context.tsx
Normal file
115
apps/web/src/i18n/context.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import type React from "react";
|
||||
import { createContext, useContext, useState, useEffect, useMemo } from "react";
|
||||
import { esMX } from "./locales/es-MX";
|
||||
import { enUS } from "./locales/en-US";
|
||||
import type { TranslationKeys } from "./locales/es-MX";
|
||||
|
||||
export type Locale = "es-MX" | "en-US";
|
||||
|
||||
const dictionaries: Record<Locale, TranslationKeys> = {
|
||||
"es-MX": esMX,
|
||||
"en-US": enUS,
|
||||
};
|
||||
|
||||
interface I18nContextType {
|
||||
locale: Locale;
|
||||
setLocale: (locale: Locale) => void;
|
||||
t: (key: string, interpolate?: Record<string, string | number>) => string;
|
||||
formatCurrency: (value: number) => string;
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextType | undefined>(undefined);
|
||||
|
||||
export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||
const [locale, setLocale] = useState<Locale>(() => {
|
||||
if (typeof window === "undefined") return "es-MX";
|
||||
const saved = localStorage.getItem("app_locale") as Locale;
|
||||
return saved && dictionaries[saved] ? saved : "es-MX";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("app_locale", locale);
|
||||
document.documentElement.lang = locale.split("-")[0];
|
||||
}, [locale]);
|
||||
|
||||
const t = useMemo(() => {
|
||||
return (
|
||||
path: string,
|
||||
interpolate?: Record<string, string | number>,
|
||||
): string => {
|
||||
const dict = dictionaries[locale];
|
||||
const keys = path.split(".");
|
||||
|
||||
// Safely traverse the dictionary
|
||||
let value: unknown = dict;
|
||||
for (const key of keys) {
|
||||
if (value && typeof value === "object" && key in value) {
|
||||
value = (value as Record<string, unknown>)[key];
|
||||
} else {
|
||||
value = undefined;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value !== "string") {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.warn(`[i18n] Missing key: ${path} for locale: ${locale}`);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
let result = value;
|
||||
if (interpolate) {
|
||||
for (const [k, v] of Object.entries(interpolate)) {
|
||||
result = result.replace(`{${k}}`, String(v));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}, [locale]);
|
||||
|
||||
const currencyFormatter = useMemo(() => {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: "currency",
|
||||
currency: "MXN",
|
||||
});
|
||||
}, [locale]);
|
||||
|
||||
const formatCurrency = (value: number) => currencyFormatter.format(value);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
locale,
|
||||
setLocale,
|
||||
t,
|
||||
formatCurrency,
|
||||
}),
|
||||
[locale, t, formatCurrency],
|
||||
);
|
||||
|
||||
return (
|
||||
<I18nContext.Provider value={contextValue}>{children}</I18nContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useI18n() {
|
||||
const context = useContext(I18nContext);
|
||||
if (!context) {
|
||||
throw new Error("useI18n must be used within an I18nProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// Helper for type-safe keys (for better DX)
|
||||
export type TFunction = (
|
||||
path: Leaves<TranslationKeys>,
|
||||
interpolate?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
// Magic type for deep keys
|
||||
type Leaves<T> = T extends object
|
||||
? {
|
||||
[K in keyof T]: `${Exclude<K, symbol>}${Leaves<T[K]> extends never ? "" : `.${Leaves<T[K]>}`}`;
|
||||
}[keyof T]
|
||||
: never;
|
||||
47
apps/web/src/i18n/i18n.test.ts
Normal file
47
apps/web/src/i18n/i18n.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { esMX } from "./locales/es-MX";
|
||||
import { enUS } from "./locales/en-US";
|
||||
|
||||
describe("i18n Locale Parity", () => {
|
||||
it("ensures en-US has all keys present in es-MX", () => {
|
||||
const compareKeys = (obj1: any, obj2: any, path = "") => {
|
||||
for (const key in obj1) {
|
||||
const currentPath = path ? `${path}.${key}` : key;
|
||||
expect(obj2, `Missing key: ${currentPath}`).toHaveProperty(key);
|
||||
if (typeof obj1[key] === "object" && obj1[key] !== null) {
|
||||
compareKeys(obj1[key], obj2[key], currentPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
compareKeys(esMX, enUS);
|
||||
});
|
||||
|
||||
it("ensures es-MX has all keys present in en-US", () => {
|
||||
const compareKeys = (obj1: any, obj2: any, path = "") => {
|
||||
for (const key in obj1) {
|
||||
const currentPath = path ? `${path}.${key}` : key;
|
||||
expect(obj2, `Missing key: ${currentPath}`).toHaveProperty(key);
|
||||
if (typeof obj1[key] === "object" && obj1[key] !== null) {
|
||||
compareKeys(obj1[key], obj2[key], currentPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
compareKeys(enUS, esMX);
|
||||
});
|
||||
});
|
||||
|
||||
describe("i18n Features", () => {
|
||||
it("supports string interpolation with {key}", () => {
|
||||
const template = esMX.footer.copy;
|
||||
const year = "2026";
|
||||
const result = template.replace("{year}", year);
|
||||
expect(result).toContain(year);
|
||||
expect(result).not.toContain("{year}");
|
||||
});
|
||||
|
||||
it("has es-MX as the rich default content", () => {
|
||||
expect(esMX.calculator.title).toBe("Calculadora de Compensación MX");
|
||||
});
|
||||
});
|
||||
83
apps/web/src/i18n/locales/en-US.ts
Normal file
83
apps/web/src/i18n/locales/en-US.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { TranslationKeys } from "./es-MX";
|
||||
|
||||
export const enUS: TranslationKeys = {
|
||||
common: {
|
||||
mxn: "MXN",
|
||||
home: "Home",
|
||||
yearly: "Yearly",
|
||||
monthly: "Monthly",
|
||||
remove: "Remove",
|
||||
description: "Description",
|
||||
type: "Type",
|
||||
payout: "Payout",
|
||||
},
|
||||
calculator: {
|
||||
title: "MX Compensation Calculator",
|
||||
subtitle:
|
||||
"Estimate your total yearly compensation including law and above-law benefits.",
|
||||
grossTotal: "Total Yearly Gross",
|
||||
netTotal: "Total Yearly Net",
|
||||
monthlyIsr: "Monthly ISR",
|
||||
monthlyImss: "Monthly IMSS",
|
||||
monthlyNet: "Monthly Net",
|
||||
exemptBenefits: "Exempt Benefits",
|
||||
},
|
||||
income: {
|
||||
legend: "Income Information",
|
||||
description: "Salary and benefits by law",
|
||||
baseSalary: "Base Monthly Salary",
|
||||
christmasBonus: "Days of Christmas Bonus",
|
||||
christmasBonusMin: "Minimum 15 days by law",
|
||||
paidLeave: "Days of Paid Leave",
|
||||
paidLeaveMin: "Minimum 12 days for 1st year",
|
||||
leaveRate: "Rate of Paid Leave ({rate}%)",
|
||||
leaveRateMin: "Minimum 25% by law",
|
||||
},
|
||||
benefits: {
|
||||
legend: "Above Law Benefits",
|
||||
description: "Additional benefits commonly offered in Mexico",
|
||||
savingsFund: {
|
||||
label: "Savings Fund",
|
||||
description: "Do you receive extra savings?",
|
||||
percentage: "Percentage ({rate}%) Saved",
|
||||
percentageInfo: "Commonly capped at 13% for tax benefits",
|
||||
symmetrical: "Is Symmetrical?",
|
||||
symmetricalInfo: "Does the employer match it?",
|
||||
},
|
||||
foodVouchers: {
|
||||
label: "Food Vouchers",
|
||||
description: "Monthly allowance for groceries",
|
||||
amount: "Monthly Amount",
|
||||
},
|
||||
additionalPto: {
|
||||
label: "Additional PTO",
|
||||
description: "Extra days off or floating days",
|
||||
days: "Days of Additional PTO",
|
||||
},
|
||||
},
|
||||
bonuses: {
|
||||
legend: "Additional Bonuses",
|
||||
description: "Performance bonuses, commissions, or annual incentives",
|
||||
addBonus: "Add Bonus",
|
||||
placeholderName: "e.g. Yearly Bonus",
|
||||
types: {
|
||||
one_time: "One Time",
|
||||
monthly: "Monthly",
|
||||
quarterly: "Quarterly",
|
||||
"bi-annual": "Bi-Annual",
|
||||
yearly: "Yearly",
|
||||
},
|
||||
},
|
||||
assumptions: {
|
||||
title: "Assumptions & Sources (2025 Legislation)",
|
||||
isr: "ISR calculated per Art. 96 LISR using 2025 Monthly Brackets.",
|
||||
uma: "UMA value updated to 2025 rates ($113.14 daily, eff. Feb 1).",
|
||||
imss: "IMSS share estimated on Base Salary (approx. SBC).",
|
||||
aguinaldo: "Aguinaldo exempt up to 30 UMA (Art. 93 LISR).",
|
||||
savings: "Savings Fund exempt if symmetrical (LISR/LIMSS).",
|
||||
note: "Note: This calculator is for estimation only and does not constitute legal or financial advice. Correctness depends on specific contract terms.",
|
||||
},
|
||||
footer: {
|
||||
copy: "© {year} floocs.com. Calculated based on 2025 Mexican Labor and Tax Law.",
|
||||
},
|
||||
};
|
||||
83
apps/web/src/i18n/locales/es-MX.ts
Normal file
83
apps/web/src/i18n/locales/es-MX.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
export const esMX = {
|
||||
common: {
|
||||
mxn: "MXN",
|
||||
home: "Inicio",
|
||||
yearly: "Anual",
|
||||
monthly: "Mensual",
|
||||
remove: "Eliminar",
|
||||
description: "Descripción",
|
||||
type: "Tipo",
|
||||
payout: "Monto",
|
||||
},
|
||||
calculator: {
|
||||
title: "Calculadora de Compensación MX",
|
||||
subtitle:
|
||||
"Estima tu compensación anual total incluyendo prestaciones de ley y superiores.",
|
||||
grossTotal: "Total Bruto Anual",
|
||||
netTotal: "Total Neto Anual",
|
||||
monthlyIsr: "ISR Mensual",
|
||||
monthlyImss: "IMSS Mensual",
|
||||
monthlyNet: "Neto Mensual",
|
||||
exemptBenefits: "Prestaciones Exentas",
|
||||
},
|
||||
income: {
|
||||
legend: "Información de Ingresos",
|
||||
description: "Salario y prestaciones de ley",
|
||||
baseSalary: "Salario Mensual Bruto",
|
||||
christmasBonus: "Días de Aguinaldo",
|
||||
christmasBonusMin: "Mínimo 15 días por ley",
|
||||
paidLeave: "Días de Vacaciones",
|
||||
paidLeaveMin: "Mínimo 12 días por ley (1er año)",
|
||||
leaveRate: "Prima Vacacional ({rate}%)",
|
||||
leaveRateMin: "Mínimo 25% por ley",
|
||||
},
|
||||
benefits: {
|
||||
legend: "Prestaciones Superiores",
|
||||
description: "Beneficios adicionales comunes en México",
|
||||
savingsFund: {
|
||||
label: "Fondo de Ahorro",
|
||||
description: "¿Recibes ahorro extra?",
|
||||
percentage: "Porcentaje ({rate}%) Ahorrado",
|
||||
percentageInfo: "Comúnmente topado al 13% para beneficios fiscales",
|
||||
symmetrical: "¿Es Simétrico?",
|
||||
symmetricalInfo: "¿El patrón aporta lo mismo?",
|
||||
},
|
||||
foodVouchers: {
|
||||
label: "Vales de Despensa",
|
||||
description: "Monto mensual para despensa",
|
||||
amount: "Monto Mensual",
|
||||
},
|
||||
additionalPto: {
|
||||
label: "PTO Adicional",
|
||||
description: "Días extra o días flotantes",
|
||||
days: "Días de PTO Adicional",
|
||||
},
|
||||
},
|
||||
bonuses: {
|
||||
legend: "Bonos Adicionales",
|
||||
description: "Bonos por desempeño, comisiones o incentivos anuales",
|
||||
addBonus: "Agregar Bono",
|
||||
placeholderName: "ej. Bono Anual",
|
||||
types: {
|
||||
one_time: "Única vez",
|
||||
monthly: "Mensual",
|
||||
quarterly: "Trimestral",
|
||||
"bi-annual": "Semestral",
|
||||
yearly: "Anual",
|
||||
},
|
||||
},
|
||||
assumptions: {
|
||||
title: "Supuestos y Fuentes (Legislación 2025)",
|
||||
isr: "ISR calculado según Art. 96 LISR usando Tablas Mensuales 2025.",
|
||||
uma: "Valor UMA actualizado a tasas 2025 ($113.14 diario, efectivo Feb 1).",
|
||||
imss: "Cuota IMSS estimada sobre Salario Base (SBC aprox).",
|
||||
aguinaldo: "Aguinaldo exento hasta 30 UMA (Art. 93 LISR).",
|
||||
savings: "Fondo de Ahorro exento si es simétrico (LISR/LIMSS).",
|
||||
note: "Nota: Esta calculadora es solo para fines estimativos y no constituye asesoría legal o financiera. La exactitud depende de los términos específicos del contrato.",
|
||||
},
|
||||
footer: {
|
||||
copy: "© {year} floocs.com. Calculado basado en la Ley Federal del Trabajo y Fiscal de 2025.",
|
||||
},
|
||||
};
|
||||
|
||||
export type TranslationKeys = typeof esMX;
|
||||
@@ -1,52 +1,74 @@
|
||||
import { HeadContent, Outlet, createRootRouteWithContext } from "@tanstack/react-router";
|
||||
import {
|
||||
HeadContent,
|
||||
Outlet,
|
||||
createRootRouteWithContext,
|
||||
} from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import Header from "@/components/header";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { I18nProvider, useI18n } from "@/i18n/context";
|
||||
|
||||
import "../index.css";
|
||||
|
||||
export interface RouterAppContext {}
|
||||
export interface RouterAppContext {
|
||||
/**
|
||||
* App context
|
||||
*/
|
||||
}
|
||||
|
||||
export const Route = createRootRouteWithContext<RouterAppContext>()({
|
||||
component: RootComponent,
|
||||
head: () => ({
|
||||
meta: [
|
||||
{
|
||||
title: "mx-comp-calc",
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
content: "mx-comp-calc is a web application",
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{
|
||||
rel: "icon",
|
||||
href: "/favicon.ico",
|
||||
},
|
||||
],
|
||||
}),
|
||||
component: RootComponent,
|
||||
head: () => ({
|
||||
meta: [
|
||||
{
|
||||
title: "MX Compensation Calculator",
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
content: "Estimate your total compensation in Mexico (2025)",
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{
|
||||
rel: "icon",
|
||||
href: "/favicon.ico",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
function RootComponent() {
|
||||
return (
|
||||
<>
|
||||
<HeadContent />
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
disableTransitionOnChange
|
||||
storageKey="vite-ui-theme"
|
||||
>
|
||||
<div className="grid grid-rows-[auto_1fr] h-svh">
|
||||
{/* <Header /> */}
|
||||
<Outlet />
|
||||
</div>
|
||||
<Toaster richColors />
|
||||
</ThemeProvider>
|
||||
<TanStackRouterDevtools position="bottom-left" />
|
||||
</>
|
||||
);
|
||||
function RootLayout() {
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t("calculator.title");
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
disableTransitionOnChange
|
||||
storageKey="vite-ui-theme"
|
||||
>
|
||||
<div className="grid h-svh grid-rows-[auto_1fr]">
|
||||
<Header />
|
||||
<Outlet />
|
||||
</div>
|
||||
<Toaster richColors />
|
||||
<TanStackRouterDevtools position="bottom-left" />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function RootComponent() {
|
||||
return (
|
||||
<I18nProvider>
|
||||
<HeadContent />
|
||||
<RootLayout />
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user