CodeGym /Kursy /ChatGPT Apps /Lokalizacja widżetów: Next + React (architektura i18n)

Lokalizacja widżetów: Next + React (architektura i18n)

ChatGPT Apps
Poziom 9 , Lekcja 2
Dostępny

1. Dlaczego widżet potrzebuje oddzielnej architektury i18n w ChatGPT App

W typowej aplikacji Next.js często opierasz się na URL (/en/..., /ru/...) albo routerze, aby powiązać język z trasą. W widżecie ChatGPT jest ciekawiej: twój UI żyje wewnątrz iframe w sandboxie, a adresem URL nie zarządzasz ty. Język przychodzi jako stan z ChatGPT, na przykład przez openai/locale lub hook w rodzaju useOpenAiGlobal('locale'), a nie z paska adresu.

Powstaje nietypowa sytuacja. Z punktu widzenia Next.js twój widżet to umownie jedna strona /widget, ale w środku musi umieć renderować się w dowolnym języku, który wskaże platforma. Język przełączamy nie nawigacją, lecz stanem. To automatycznie pcha w stronę architektury „jeden UI, wiele słowników” i jeszcze raz podkreśla: trzymanie tekstów w kodzie to ślepa uliczka.

Dodatkowo, w tym samym dialogu ChatGPT może uruchamiać twój App dla użytkowników z różnych krajów. Nie możesz „raz ustalić, że App jest rosyjskojęzyczny” i zapomnieć. Widżet musi łatwo się przeinicjalizować pod nowe locale, nie zmieniając logiki biznesowej – właśnie po to potrzebna jest staranna warstwa i18n.

2. Główna zasada: w kodzie nie powinno być tekstów

W skrócie filozofia lokalizacji UI brzmi tak: komponenty React nie potrzebują realnych tekstów, potrzebują kluczy.

Zamiast:


// ŹLE: tekst jest zahardkodowany w komponencie
<button>Wybierz prezent</button>

widżet powinien wyglądać tak:


// DOBRZE: komponent zna tylko klucz
<button>{t('buttons.pick_gift')}</button>

A właściwe teksty „Wybierz prezent” i „Pick a gift” przechowywane są w słownikach ru.json i en.json.

Po co to wszystko komplikować, skoro można było po prostu if (locale === 'ru')?

Po pierwsze, skalowalność. Gdy tylko trzeba dodać trzeci język, if/else zamienia się w chaos. Po drugie, separacja odpowiedzialności. Tłumacz lub product owner może zmieniać teksty w plikach JSON bez dotykania kodu, a deweloper może refaktoryzować komponenty bez ryzyka przypadkowego popsucia połowy copy w UI. Po trzecie, spójność: jeden źródłowy katalog tekstów pomaga uniknąć sytuacji, gdy na jednym przycisku jest „Kup”, a na innym „Zapłać”, tylko dlatego, że autorzy komponentów nazwali go według nastroju.

W świecie ChatGPT App jest to szczególnie przydatne: czasem zechcesz generować tłumaczenia przez LLM i potem dodawać je do słowników. Trzymanie wszystkich tekstów w plikach JSON jest znacznie wygodniejsze niż rozrzucanie ich po komponentach.

3. Strukturyzujemy słowniki dla widżetu GiftGenius

Kontynuujmy rozwój naszej aplikacji szkoleniowej GiftGenius — widżetu do doboru prezentów. Potrzebujemy co najmniej dwóch języków: ru i en. Utwórzmy bazową strukturę:

/app
  /widget
    GiftWidget.tsx
/locales
  /en
    widget.json
  /ru
    widget.json

Najprostsza zawartość słownika locales/en/widget.json:

{
  "title": "GiftGenius",
  "forms": {
    "recipient": {
      "label": "Recipient",
      "placeholder": "Who is this gift for?"
    },
    "budget": {
      "label": "Budget",
      "placeholder": "For example, 50"
    }
  },
  "buttons": {
    "pick_gift": "Find gifts",
    "try_again": "Try again"
  },
  "errors": {
    "no_gifts": "No gifts found for your criteria."
  }
}

I odpowiadający mu locales/ru/widget.json:

{
  "title": "GiftGenius",
  "forms": {
    "recipient": {
      "label": "Odbiorca",
      "placeholder": "Dla kogo szukamy prezentu?"
    },
    "budget": {
      "label": "Budżet",
      "placeholder": "Na przykład, 50"
    }
  },
  "buttons": {
    "pick_gift": "Znajdź prezenty",
    "try_again": "Spróbuj ponownie"
  },
  "errors": {
    "no_gifts": "Nie znaleziono prezentów dla Twoich kryteriów."
  }
}

Zwróć uwagę, że struktura kluczy jest identyczna dla obu języków. To krytyczne: komponenty opierają się na kluczach, a nie na konkretnych tekstach. Jeśli w jednym języku zapomnisz dodać errors.no_gifts, dostaniesz zrozumiały błąd, a nie częściowo przetłumaczony UI.

W realnym projekcie warto dzielić słowniki na obszary: widget, checkout, errors itp. W aplikacji szkoleniowej wystarczy po jednym pliku na język, żeby nie komplikować.

4. Skąd brać locale w widżecie Apps SDK

W klasycznej aplikacji przeglądarkowej sięgnąłbyś do navigator.language. W widżecie ChatGPT tak zrobić można, ale nie trzeba: ChatGPT już wyliczył preferowaną lokalizację użytkownika i przekazuje ją do kontekstu Apps SDK. Może to być pole locale w window.openai, które można odczytać bezpośrednio lub przez wygodny hook w rodzaju useOpenAiGlobal('locale').

Typowo dla starterów Apps SDK masz korzeń widżetu, gdzie dostępne są globalne dane z ChatGPT. Umownie:

"use client";

import { useOpenAiGlobal } from "openai-apps-sdk/react";

export function GiftWidgetRoot() {
  const locale = useOpenAiGlobal("locale") ?? "en";
  // ...
}

Powyższy przykład jest ilustracyjny; dokładne API zależy od wersji SDK, ale ogólna idea jest trafna: locale — to zewnętrzna prawda pochodząca z ChatGPT, a nie z przeglądarki użytkownika.

Region (userLocation) również jest przekazywany przez _meta["openai/userLocation"]. Przyda nam się później, gdy będziemy formatować ceny i uwzględniać walutę. Do tekstów wystarczy locale — zwykle przychodzi w formacie BCP‑47 (en, en-US, ru-RU itp.).

5. Piszemy minimalną warstwę i18n: kontekst + hook useT

Aby widżet był samowystarczalny i nie zamienił się w podręcznik do react-i18next, zaimplementujemy lekką własną warstwę i18n. Dla małego widżetu ChatGPT to w zupełności wystarczy, a zasady są te same, co w popularnych bibliotekach.

Najpierw opiszemy typy i stworzymy kontekst w app/widget/i18n.tsx:

"use client";

import React, { createContext, useContext } from "react";

type Messages = Record<string, any>;

type I18nContextValue = {
  locale: string;
  messages: Messages;
};

const I18nContext = createContext<I18nContextValue | null>(null);

Teraz stwórzmy provider, który dostaje locale i słownik:

type Props = {
  locale: string;
  messages: Messages;
  children: React.ReactNode;
};

export function I18nProvider({ locale, messages, children }: Props) {
  return (
    <I18nContext.Provider value={{ locale, messages }}>
      {children}
    </I18nContext.Provider>
  );
}

Najciekawszy jest hook useT, który będzie pobierał teksty po kluczu:

export function useT() {
  const ctx = useContext(I18nContext);
  if (!ctx) throw new Error("useT must be used within I18nProvider");

  function t(path: string): string {
    return path.split(".").reduce((obj: any, part) => obj?.[part], ctx.messages) 
           ?? path;
  }

  return { t, locale: ctx.locale };
}

Obsługujemy zagnieżdżone klucze w stylu forms.recipient.label, a w razie braku tłumaczenia zwracamy sam klucz — to bardziej użyteczne niż ciche pokazanie pustego miejsca.

6. Wbudowujemy provider i18n w korzeń widżetu

Wcześniej widzieliśmy już GiftWidgetRoot, który po prostu czytał locale z useOpenAiGlobal. Teraz użyjemy I18nProvider w tym korzennym komponencie i dodamy ładowanie słownika. Załóżmy, że wcześniej wyglądał mniej więcej tak:

"use client";

export function GiftWidgetRoot() {
  return (
    <div>
      <h1>GiftGenius</h1>
      {/* formularze i wyniki */}
    </div>
  );
}

Dodajmy ładowanie słownika i providera. Dla prostoty użyjemy synchronicznego require/import na podstawie locale, ale w Next.js 16 możesz użyć też importu asynchronicznego (przez dynamic import), jeśli słowniki są duże.

"use client";

import { useOpenAiGlobal } from "openai-apps-sdk/react";
import { I18nProvider } from "./i18n";
import { GiftWidget } from "./GiftWidget";

function loadMessages(locale: string) {
  if (locale.startsWith("ru")) {
    return require("/locales/ru/widget.json"); 
  }
  return require("/locales/en/widget.json");
}

export function GiftWidgetRoot() {
  const locale = useOpenAiGlobal("locale") ?? "en";
  const messages = loadMessages(locale);

  return (
    <I18nProvider locale={locale} messages={messages}>
      <GiftWidget />
    </I18nProvider>
  );
}

Komponent GiftWidget nie myśli już w ogóle o językach, wie tylko, że jest funkcja t:

"use client";

import { useT } from "./i18n";

export function GiftWidget() {
  const { t } = useT();

  return (
    <div>
      <h1>{t("title")}</h1>
      <label>{t("forms.recipient.label")}</label>
      {/* reszta UI */}
    </div>
  );
}

Jeśli jutro ChatGPT uruchomi widżet z locale = "de-DE", możesz dodać locales/de/widget.json i jedną linijkę w loadMessages, nie dotykając reszty kodu. Właśnie o to chodziło.

7. Formaty zależne od lokalizacji: liczby, daty, waluty

Teksty już przenieśliśmy do słowników i opakowaliśmy widżet w I18nProvider. Ale teksty to tylko połowa UX: użytkownik z USA oczekuje 12/31/2025, a użytkownik z Niemiec — 31.12.2025. Podobnie z liczbami i walutami. Pokazanie użytkownikowi z Rosji ceny „1,234.56 USD” to dobry sposób, by zasugerować, że wasz „inteligentny” asystent wcale nie jest zbyt uważny.

Na szczęście w przeglądarce (i w sandboxie ChatGPT) dostępne jest standardowe API Intl. Dodajmy do i18n.tsx dwie funkcje narzędziowe, które korzystają z bieżącego locale:

export function useFormatters() {
  const { locale } = useT();

  const formatCurrency = (value: number, currency: string) =>
    new Intl.NumberFormat(locale, {
      style: "currency",
      currency,
      maximumFractionDigits: 2,
    }).format(value);

  const formatDate = (date: Date) =>
    new Intl.DateTimeFormat(locale).format(date);

  return { formatCurrency, formatDate };
}

Teraz w komponencie, w którym pokazujemy budżet lub ceny prezentów (załóżmy, że już je dostajemy z serwera MCP z podaną currency):

import { useFormatters } from "./i18n";

type GiftCardProps = {
  name: string;
  price: number;
  currency: string;
};

export function GiftCard({ name, price, currency }: GiftCardProps) {
  const { formatCurrency } = useFormatters();

  return (
    <div>
      <div>{name}</div>
      <div>{formatCurrency(price, currency)}</div>
    </div>
  );
}

Jeśli chcesz uczynić formatowanie jeszcze „sprytniejszym” (np. wybierać walutę na podstawie userLocation), możesz łączyć locale i region. Architektonicznie to kontynuacja linii, którą omawiałeś dla MCP‑Gateway: locale wpływa na język tekstu, a userLocation — na reguły biznesowe i walutę.

8. Reakcja na zmianę języka: co, jeśli ChatGPT zmieni locale w locie

W zwykłym webie użytkownik sam klika „EN / RU” i dokładnie wiesz, kiedy zmienić język. W ChatGPT App model może teoretycznie uznać, że użytkownikowi wygodniej w innym języku (albo użytkownik przełączy język interfejsu w ustawieniach) i openai/locale się zmieni.

Jeśli SDK daje ci reaktywny sygnał (przez hook lub zdarzenie), wzorzec w kodzie będzie taki:

export function GiftWidgetRoot() {
  const locale = useOpenAiGlobal("locale") ?? "en";
  const messages = useMemo(() => loadMessages(locale), [locale]);

  return (
    <I18nProvider locale={locale} messages={messages}>
      <GiftWidget />
    </I18nProvider>
  );
}

Tutaj loadMessages zostanie wywołane ponownie przy zmianie locale, a cały UI automatycznie się przerysuje z nowymi tłumaczeniami. W większości realnych scenariuszy lokalizacja jest stabilna w ramach sesji, ale warto z góry przewidzieć poprawny model reaktywny.

9. Trochę o złożonych tekstach: placeholdery i pluralizacja

Reaktywność względem locale mamy ogarniętą. Kolejne naturalne pytanie: co robić z dynamicznymi częściami tekstu — ilościami, imionami itp.? W aplikacji prezentowej może to być coś w rodzaju „Znaleziono 3 prezenty dla Maszy”.

Najprostszy sposób poradzić sobie z takimi frazami — dodać wsparcie placeholderów w t() i podstawiać wartości w locie. W tym celu zmodyfikujemy useT, aby przyjmował drugim argumentem obiekt wartości:

type Values = Record<string, string | number>;

export function useT() {
  const ctx = useContext(I18nContext);
  if (!ctx) throw new Error("useT must be used within I18nProvider");

  function t(path: string, values?: Values): string {
    let text =
      path.split(".").reduce((obj: any, part) => obj?.[part], ctx.messages) ??
      path;

    if (values) {
      Object.entries(values).forEach(([key, value]) => {
        text = text.replace(`{{${key}}}`, String(value));
      });
    }
    return text;
  }

  return { t, locale: ctx.locale };
}

Teraz dodajmy wiersz do widget.json:

"results": {
  "summary": "Found {{count}} gifts for {{name}}"
}

I użyjmy go:

const { t } = useT();

<p>{t("results.summary", { count, name: recipientName })}</p>

Pluralizację można rozwiązać na różne sposoby: albo dodać kilka kluczy (one, few, many) i wybierać je ręcznie, albo podłączyć bibliotekę typu react-intl/i18next, która ma pełne wsparcie zasad liczby mnogiej (plural rules). Do aplikacji szkoleniowej ręczny wybór po zakresach (np. if count === 1, if count < 5 itd.) jest jak najbardziej akceptowalny.

10. Gdzie umieścić i18n w strukturze szablonu Apps SDK dla Next.js

Z punktu widzenia Next.js 16 i oficjalnego szablonu Apps SDK twój widżet to zwykle wyspecjalizowany entrypoint w app/ (na przykład app/widget/page.tsx lub osobny komponent, który Apps SDK renderuje wewnątrz ChatGPT).

Typowy wzorzec:

// app/widget/page.tsx
"use client";

import { GiftWidgetRoot } from "./GiftWidgetRoot";

export default function WidgetPage() {
  return <GiftWidgetRoot />;
}

Warstwa i18n żyje w całości po stronie klienta — wszystko, co napisaliśmy powyżej, to client components. Ważne, że w środowisku ChatGPT i tak wszystko jest renderowane po stronie klienta wewnątrz iframe, więc klasyczne wzorce SSR‑i18n (lokalizowany HTML po stronie serwera) można na razie odłożyć. To bardzo upraszcza życie: pracujesz jak z typowym SPA, tylko zamiast navigator.language używasz openai/locale.

Jeśli potrzebujesz współdzielić tłumaczenia między kilkoma widżetami jednego App (np. główny kreator i „mały widżet inline”), możesz wynieść I18nProvider do osobnego modułu i używać ponownie.

11. Mini‑testowanie lokalizacji

Gdy tylko w systemie pojawia się warstwa i18n, warto zacząć ją testować osobno — w przeciwnym razie każda literówka w kluczu zamienia się w „częściowo przetłumaczony UI”. Skoro już zrobiliśmy architekturę, grzechem byłoby jej nie sprawdzić.

Po pierwsze, sensownie jest napisać proste testy jednostkowe dla loadMessages i useT (z użyciem React Testing Library albo nawet bez Reacta — testując po prostu funkcję t). Takie testy wyłapują literówki w kluczach i pomogą, jeśli ty lub tłumacz przypadkowo usuniecie potrzebną gałąź słownika.

Po drugie, wygodnie przewidzieć tryb „lokalnego uruchomienia” widżetu poza ChatGPT, w którym możesz wymusić locale przez parametr query lub przycisk w UI. To użyteczne i dla ciebie, i dla QA: nikt nie musi odpalać całego Dev Mode i ChatGPT tylko po to, by zobaczyć, jak wygląda niemieckie tłumaczenie. Z takimi podstawowymi testami i lokalnym przebiegiem po różnych locale o wiele spokojniej będziesz rozwijać UI i teksty, a potem przejdziesz do lokalizacji opisów tools.

Jak to się łączy z zachowaniem modelu

Głębiej wejdziemy w lokalizację opisów narzędzi w następnej lekcji, ale już teraz warto zobaczyć powiązanie: widżet i narzędzia powinny mówić językiem użytkownika. Budujesz UI, który dostosowuje się do openai/locale. Serwer MCP na podstawie tego samego sygnału wybiera właściwy katalog i teksty. Logiczne jest więc, że opis suggest_gifts oraz pola recipient, budget będą wyjaśnione modelowi w języku użytkownika — to zmniejszy liczbę dziwnych wywołań narzędzi (tool-calls) i niepoprawnych argumentów.

A więc architektura i18n widżetu to nie tylko kosmetyka. To pierwszy klocek w całym systemie, w którym warstwa UI, warstwa MCP i model używają tego samego kontekstu lokalizacji.

12. Typowe błędy przy lokalizacji widżetów

Błąd nr 1: hard‑kodowane teksty bezpośrednio w JSX.
Bardzo częsta historia: widżet zaczynano jako szybki prototyp w jednym języku, a potem nagle „potrzebny jest jeszcze angielski”. W rezultacie UI jest naszpikowany tekstami po rosyjsku, a próba dodania angielskiego zamienia się w globalne szukanie‑i‑zamienianie w projekcie. Im wcześniej wprowadzisz słowniki i funkcję t(), tym mniej problemów później.

Błąd nr 2: if (locale === 'ru') na każdym kroku.
Takie warunki czasem wydają się „szybkim rozwiązaniem”, ale natychmiast się sypią, gdy pojawia się trzeci język albo warianty typu ru-RU, ru, ru-UA. Lepiej raz napisać loadMessages(locale) z normalizacją (locale.split('-')[0]) i przestać o tym myśleć, niż rozsmarowywać warunki po całym kodzie.

Błąd nr 3: mieszanie logiki biznesowej z tekstami.
Czasem deweloperzy tworzą w komponentach złożone warunki, które jednocześnie rozwiązują rozwidlenia biznesowe i wybór tekstu. Na przykład „jeśli prezentów nie ma, pokaż tę frazę, a jeśli budżet jest mały — inną”. W efekcie trudno zmienia się copy, logika się rozłazi, a tłumaczenia wchodzą do TypeScripta. O wiele lepiej, gdy komponenty zwracają do słowników tylko klucz (errors.no_gifts, errors.budget_too_low), a teksty edytuje się osobno.

Błąd nr 4: brak formatowania dat/walut zgodnie z lokalizacją.
Pokazanie użytkownikowi w Niemczech ceny $1,234.56 zamiast 1.234,56 $ to nie bug, tylko antywzorzec UX. Użytkownicy odbierają to jako „ten serwis nie jest dla mnie”. Bardzo łatwo zapomnieć o Intl.NumberFormat i Intl.DateTimeFormat, jeśli działasz na co dzień w jednym regionie. Dlatego warto wynieść formatery do hooka w rodzaju useFormatters() i zawsze używać ich zamiast ręcznej konkatenacji napisów.

Błąd nr 5: nieuwzględnienie możliwej zmiany locale.
Niektórzy deweloperzy odczytują locale jeden raz przy montowaniu i dalej traktują je jak stałą. W większości przypadków to zadziała, ale jeśli ChatGPT lub platforma jednak zmieni lokalizację (np. użytkownik przełączy język interfejsu), twój widżet zostanie przy starym języku. Poprawniej traktować locale jako część stanu reaktywnego i powiązać z nim useMemo/useEffect.

Błąd nr 6: przechowywanie różnych struktur słowników dla różnych języków.
Czasem tłumaczenie na jeden język powierzane jest jednej osobie, na drugi — innej i w efekcie widget.en.json i widget.ru.json rozjeżdżają się strukturą. W jednym jest forms.budget.placeholder, w drugim — tylko forms.budget.label. W runtime kończy się to undefined i dziwnymi błędami. Zawsze trzymaj jeden „kanoniczny” plik (zwykle angielski), po którym pozostałe języki dziedziczą strukturę. Do generowania nowych słowników można nawet pisać skrypty, które sprawdzają zgodność kluczy.

Błąd nr 7: próba rozwiązania wszystkiego od razu ciężkim frameworkiem i18n.
Popularne rozwiązania w rodzaju react-i18next czy next-intl są mocne i przydatne, ale dla małego widżetu ChatGPT mogą być nadmiarowe. Często prościej zacząć od lekkiej własnej warstwy (I18nProvider, useT, słowniki w JSON), a dopiero później, wraz ze wzrostem aplikacji, migrować na pełnoprawną bibliotekę, jeśli naprawdę będą potrzebne złożone pluralizacje, ICU‑format itp.

Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION