import { useAuth } from "@/contexts/AuthContext";
import { type Lang, type TKey, dict } from "@/i18n/dict";
import { supabase } from "@/lib/supabase";
import { type ReactNode, createContext, useCallback, useContext, useEffect, useState } from "react";
const STORAGE_KEY = "devist.lang";
type I18nState = {
lang: Lang;
setLang: (l: Lang) => void;
t: (key: TKey, vars?: Record<string, string | number>) => string;
};
const I18nContext = createContext<I18nState | null>(null);
const SUPPORTED: readonly Lang[] = ["en", "ko"];
function isLang(v: unknown): v is Lang {
return typeof v === "string" && (SUPPORTED as readonly string[]).includes(v);
}
function detectInitial(): Lang {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (isLang(saved)) return saved;
} catch {
// localStorage unavailable
}
if (typeof navigator !== "undefined") {
const nav = navigator.language?.toLowerCase() ?? "";
if (nav.startsWith("ko")) return "ko";
}
return "en";
}
function interpolate(s: string, vars?: Record<string, string | number>): string {
if (!vars) return s;
return s.replace(/\{(\w+)\}/g, (_, k) =>
vars[k] === undefined ? `{${k}}` : String(vars[k]),
);
}
export function I18nProvider({ children }: { children: ReactNode }) {
const { session } = useAuth();
const [lang, setLangState] = useState<Lang>(detectInitial);
// When the session arrives or changes, prefer the user's stored
// preference (auth.users.user_metadata.lang). If they have one,
// override local state. If they don't but we have a local choice,
// back-fill it to user_metadata so it carries to other devices.
useEffect(() => {
if (!session?.user) return;
const remote = session.user.user_metadata?.lang;
if (isLang(remote)) {
if (remote !== lang) {
setLangState(remote);
}
} else if (supabase) {
// Back-fill the user's metadata with the locally-detected lang.
supabase.auth.updateUser({ data: { lang } }).catch(() => {
// Non-fatal; user can still use the app.
});
}
// We intentionally only react to session.user.id changes — `lang`
// changes are handled by setLang below, which writes through.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session?.user?.id]);
// Persist locally + reflect on <html lang>.
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, lang);
} catch {
// ignore
}
document.documentElement.lang = lang;
}, [lang]);
const setLang = useCallback(
(l: Lang) => {
setLangState(l);
// Write-through to user metadata so other devices sync.
if (session?.user && supabase) {
supabase.auth.updateUser({ data: { lang: l } }).catch(() => {
// Non-fatal.
});
}
},
[session?.user],
);
const t = useCallback(
(key: TKey, vars?: Record<string, string | number>) => {
const raw = dict[lang][key] ?? dict.en[key] ?? key;
return interpolate(raw, vars);
},
[lang],
);
return (
<I18nContext.Provider value={{ lang, setLang, t }}>{children}</I18nContext.Provider>
);
}
export function useI18n() {
const ctx = useContext(I18nContext);
if (!ctx) throw new Error("useI18n must be used inside I18nProvider");
return ctx;
}