feat: added i18n

This commit is contained in:
2026-01-01 22:15:43 -06:00
parent b944edf902
commit 5114cfc389
11 changed files with 558 additions and 144 deletions

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);
}

View 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>
);
}

View 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;

View 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");
});
});

View 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.",
},
};

View 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;

View File

@@ -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>
);
}