JavaRush /Курси /ChatGPT Apps /Локалізація віджетів: Next + React (i18n‑архітектура)

Локалізація віджетів: Next + React (i18n‑архітектура)

ChatGPT Apps
Рівень 9 , Лекція 2
Відкрита

1. Навіщо віджету окрема i18n‑архітектура в ChatGPT App

У звичайному застосунку Next.js ви часто спираєтеся на URL (/en/..., /ru/...) або роутер, щоб прив’язати мову до маршруту. У ChatGPT‑віджеті все цікавіше: ваш UI живе всередині iframe у пісочниці, а URL контролюєте не ви. Мова приходить у вигляді стану від ChatGPT — наприклад, через openai/locale або хук на кшталт useOpenAiGlobal('locale'), а не з адресного рядка.

У результаті маємо незвичну ситуацію. Із точки зору Next.js, ваш віджет — це умовно одна сторінка /widget, але всередині вона має вміти рендеритися будь-якою мовою, яку задасть платформа. Мову потрібно перемикати не навігацією, а станом. Це автоматично підштовхує до архітектури «один UI, багато словників» і ще раз підкреслює: зберігати рядки прямо в коді — тупиковий шлях.

Додатково: в одному й тому самому діалозі ChatGPT може запускати ваш App для користувачів із різних країн. Ви не можете «один раз вирішити, що застосунок україномовний» і забути. Віджет має легко переініціалізовуватися під новий locale, не змінюючи бізнес-логіку, — саме для цього й потрібна акуратна i18n‑прокладка.

2. Головний принцип: рядків у коді не має бути

Якщо коротко сформулювати філософію UI‑локалізації, вона звучить так: React‑компонентам не потрібні «живі» тексти, їм потрібні ключі.

Замість цього:


// ПОГАНО: рядок жорстко зашито в компонент
<button>Підібрати подарунок</button>

віджет має виглядати так:


// ДОБРЕ: компонент знає лише ключ
<button>{t('buttons.pick_gift')}</button>

А реальні рядки «Підібрати подарунок» і «Pick a gift» зберігаються в словниках ru.json і en.json.

Навіщо це ускладнення, якщо можна було просто if (locale === 'ru')?

По‑перше, масштабованість. Щойно вам потрібно додати третю мову, if/else перетворюється на кашу. По‑друге, розподіл відповідальності. Перекладач або продакт-менеджер може змінювати тексти в JSON‑файлах, не торкаючись коду, а розробник — рефакторити компоненти, не ризикуючи випадково зламати половину UI‑текстів. По‑третє, послідовність: єдине джерело істини для текстів допомагає уникнути ситуації, коли на одній кнопці «Купити», а на іншій — «Оплатити» лише тому, що автори компонентів підписували її «за настроєм».

У світі ChatGPT App це особливо корисно: інколи ви захочете згенерувати переклади через LLM, а потім додати їх у словники. Зберігати всі тексти в JSON‑файлах набагато зручніше, ніж розкидати їх по компонентах.

3. Структуруємо словники для віджета GiftGenius

Продовжимо розвивати наш навчальний застосунок GiftGenius — віджет для підбору подарунків. Нам уже потрібні щонайменше дві мови: ru і en. Створімо базову структуру:

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

Найпростіший вміст словника 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."
  }
}

І відповідний locales/ru/widget.json:

{
  "title": "GiftGenius",
  "forms": {
    "recipient": {
      "label": "Одержувач",
      "placeholder": "Для кого шукаємо подарунок?"
    },
    "budget": {
      "label": "Бюджет",
      "placeholder": "Наприклад, 50"
    }
  },
  "buttons": {
    "pick_gift": "Знайти подарунки",
    "try_again": "Спробувати ще раз"
  },
  "errors": {
    "no_gifts": "Подарунків за вашими критеріями не знайдено."
  }
}

Зверніть увагу: структура ключів ідентична для обох мов. Це критично, бо компоненти спираються на ключі, а не на конкретні рядки. Якщо в одній мові ви забудете додати errors.no_gifts, отримаєте зрозумілу помилку, а не напівперекладений UI.

У реальному проєкті словники розумно ділити за областями: widget, checkout, errors тощо. У навчальному застосунку достатньо одного файлу на мову, щоб зайвий раз не ускладнювати.

4. Звідки брати locale у віджеті Apps SDK

У класичному браузерному застосунку ви б заглянули в navigator.language. У ChatGPT‑віджеті так робити можна, але зазвичай не варто: ChatGPT уже визначив пріоритетну локаль користувача й передає її в контекст Apps SDK. Це може бути поле locale у window.openai, яке можна прочитати напряму або через зручний хук на кшталт useOpenAiGlobal('locale').

Зазвичай у стартових шаблонах Apps SDK у вас є кореневий компонент віджета, де доступні глобальні дані з ChatGPT. Умовно:

"use client";

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

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

Приклад вище — ілюстративний: точне API залежить від версії SDK. Але загальна ідея правильна: locale — це зовнішня «правда», що приходить від ChatGPT, а не від браузера користувача.

Регіон (userLocation) теж передається через _meta["openai/userLocation"]. Він знадобиться нам трохи пізніше, коли форматуватимемо ціни й враховуватимемо валюту. Для текстів достатньо locale — зазвичай він приходить у форматі BCP‑47 (en, en-US, uk-UA тощо).

5. Пишемо мінімальний i18n‑шар: контекст + хук useT

Щоб віджет був самодостатнім і не перетворювався на підручник з react-i18next, реалізуємо легкий власний i18n‑шар. Для невеликого ChatGPT‑віджета цього більш ніж достатньо, а принципи ті самі, що й у популярних бібліотеках.

Спочатку опишемо типи й створимо контекст у 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);

Тепер зробімо провайдер, який отримує locale і словник:

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

Найцікавіше — хук useT, який діставатиме рядки за ключем:

export function useT() {
  const ctx = useContext(I18nContext);
  if (!ctx) throw new Error("useT має використовуватися всередині I18nProvider");

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

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

Ми підтримуємо вкладені ключі на кшталт forms.recipient.label. А якщо перекладу немає — повертаємо сам ключ: це корисніше, ніж мовчки показувати порожній рядок.

6. Вбудовуємо i18n‑провайдер у кореневий компонент віджета

Раніше ми вже бачили GiftWidgetRoot, який просто читав locale із useOpenAiGlobal. Тепер використаємо I18nProvider у цьому кореневому компоненті й додамо завантаження словника. Припустімо, раніше він виглядав приблизно так:

"use client";

export function GiftWidgetRoot() {
  return (
    <div>
      <h1>GiftGenius</h1>
      {/* форми та результати */}
    </div>
  );
}

Додамо завантаження словника й провайдер. Для простоти використаємо синхронний require/import за locale. Але в Next.js 16 ви можете використати й асинхронний імпорт (через dynamic import), якщо словники великі.

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

Компонент GiftWidget тепер узагалі не думає про мови: він знає лише, що є функція 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>
      {/* інший UI */}
    </div>
  );
}

Якщо завтра ChatGPT запустить віджет із locale = "de-DE", ви зможете додати locales/de/widget.json і один рядок у loadMessages, не торкаючись решти коду. Заради цього все й починалося.

7. Локалізовані формати: числа, дати, валюти

Ми вже винесли тексти в словники та обгорнули віджет у I18nProvider. Але тексти — це лише половина UX: користувач зі США очікує побачити 12/31/2025, а користувач із Німеччини — 31.12.2025. Те саме стосується чисел і валют. Показати українському користувачу ціну «1,234.56 USD» — надійний спосіб продемонструвати, що ваш «розумний» асистент насправді не дуже уважний.

На щастя, у браузері (і в пісочниці ChatGPT) доступний стандартний API Intl. Додамо в i18n.tsx кілька утиліт, які використовують поточний 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 };
}

Тепер у компоненті, де ми показуємо бюджет або ціни подарунків (припустімо, ми вже отримуємо їх від MCP‑сервера із зазначенням 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>
  );
}

Якщо хочеться зробити форматування ще «розумнішим» (наприклад, вибирати валюту на основі userLocation), можна комбінувати locale і регіон. Архітектурно це продовжує лінію, яку ви вже обговорювали для MCP‑Gateway: locale впливає на мову тексту, а userLocation — на бізнес‑правила та валюту.

8. Реакція на зміну мови: що, якщо ChatGPT змінив locale «на льоту»

У звичайному вебі користувач сам натискає «EN / UK», і ви точно знаєте, коли змінювати мову. У ChatGPT App модель теоретично може вирішити, що користувачеві зручніше іншою мовою (або користувач перемкне мову інтерфейсу в налаштуваннях), і openai/locale зміниться.

Якщо SDK дає вам реактивний сигнал (через хук або подію), шаблон коду буде таким:

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

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

Тут loadMessages буде переобчислено під час зміни locale, а весь UI автоматично перерендериться з новими перекладами. У більшості реальних сценаріїв локаль стабільна в межах сесії, але закласти правильну реактивну модель усе одно корисно.

9. Трохи про складні рядки: плейсхолдери та множину

Із реактивністю за locale розібралися. Наступне природне питання: що робити з динамічними частинами тексту — кількостями, іменами тощо? У подарунковому застосунку це може бути щось на кшталт «Знайдено 3 подарунки для Маші».

Найпростіший спосіб упоратися з такими фразами — підтримати плейсхолдери (заповнювачі) в t() і підставляти значення «на льоту». Для цього модифікуємо useT, щоб він приймав другим аргументом об’єкт значень:

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

export function useT() {
  const ctx = useContext(I18nContext);
  if (!ctx) throw new Error("useT має використовуватися всередині 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 };
}

Тепер додамо рядок до widget.json:

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

І використаємо його:

const { t } = useT();

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

З множиною можна вчинити по‑різному: або завести кілька ключів (one, few, many) і вибирати їх вручну, або підключити бібліотеку на кшталт react-intl/i18next, де є повноцінна підтримка правил множини (plural rules). Для навчального віджета ручний вибір за діапазонами (наприклад, if count === 1, if count < 5 тощо) цілком прийнятний.

10. Де розмістити i18n у структурі Next.js‑шаблону Apps SDK

Із точки зору Next.js 16 і офіційного шаблону Apps SDK, ваш віджет — це зазвичай спеціалізований entrypoint у app/ (наприклад, app/widget/page.tsx) або окремий компонент, який Apps SDK рендерить усередині ChatGPT.

Типовий патерн:

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

import { GiftWidgetRoot } from "./GiftWidgetRoot";

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

i18n‑шар живе повністю в клієнтській частині: усе, що ми написали вище, — це client components. Важливо, що в середовищі ChatGPT у вас і так усе рендериться на клієнті всередині iframe, тому класичні SSR‑i18n‑патерни (локалізований HTML на сервері) можна тимчасово відкласти. Це суттєво спрощує роботу: ви дієте як у звичайному SPA, тільки замість navigator.language використовуєте openai/locale.

Якщо вам знадобиться спільно використовувати переклади між кількома віджетами одного App (наприклад, основний майстер і «маленький inline‑віджет»), можна винести I18nProvider в окремий модуль і перевикористовувати.

11. Міні‑тестування локалізації

Щойно в системі з’являється i18n‑шар, його варто тестувати окремо — інакше будь‑яка помилка в ключі перетвориться на «напівперекладений UI». Раз уже ми архітектуру зробили, гріх її не перевірити.

По‑перше, має сенс написати прості unit‑тести на loadMessages і useT (з використанням React Testing Library або навіть без React — просто тестуючи функцію t). Такі тести ловлять помилки в ключах і допомагають, якщо ви чи перекладач випадково видалите потрібну гілку словника.

По‑друге, зручно передбачити режим «локального запуску» віджета поза ChatGPT, де ви зможете примусово задати locale через параметр запиту (query‑параметр) або кнопку в UI. Це корисно і для вас, і для QA: ніхто не зобов’язаний запускати весь Dev Mode і ChatGPT лише для того, щоб подивитися, як виглядає німецький переклад. Маючи базові тести й локальний запуск для різних locale, ви значно спокійніше розвиватимете і UI, і тексти, а потім перейдете до локалізації описів tools.

Як усе це пов’язано з поведінкою моделі

Глибоко в локалізацію descriptions інструментів ми зайдемо в наступній лекції, але вже зараз важливо побачити зв’язок: віджет і інструменти мають говорити з користувачем однією мовою. Ви вже будуєте UI, який підлаштовується під openai/locale. MCP‑сервер за цим самим сигналом вибирає правильний каталог і тексти. Логічно, що й опис suggest_gifts, і поля recipient, budget будуть пояснені моделі мовою користувача — це зменшить кількість дивних викликів інструментів і некоректних аргументів.

Тобто i18n‑архітектура віджета — це не просто косметика. Це перша цеглинка в загальній системі, де UI‑шар, MCP‑шар і модель використовують один і той самий контекст локалі.

12. Типові помилки під час локалізації віджетів

Помилка № 1: жорстко зашиті рядки прямо в JSX.
Дуже поширена історія: віджет починали як швидкий прототип однією мовою, а потім раптом з’явилося «потрібна ще англійська». У результаті UI виявляється усіяний рядками болгарської, а спроба додати англійську перетворюється на глобальний пошук‑заміни по проєкту. Що раніше ви заведете словники й функцію t(), то менше проблем матимете далі.

Помилка № 2: if (locale === 'ru') на кожному кроці.
Таке розгалуження іноді здається «швидким рішенням», але миттєво ламається, щойно з’являється третя мова або варіанти на кшталт uk-UA, ru, ru-UA. Краще один раз написати loadMessages(locale) із нормалізацією (locale.split('-')[0]) і більше про це не думати, ніж розмазувати перевірки по всьому коду.

Помилка № 3: змішування бізнес‑логіки та текстів.
Іноді розробники будують у компонентах складні умови, які одночасно визначають бізнес‑гілкування і вибір тексту. Наприклад: «якщо подарунків немає — показати ось цю фразу, а якщо бюджет малий — іншу». У підсумку тексти важко змінювати, логіка розповзається, а переклади «лізуть» у TypeScript. Значно краще, коли компоненти віддають у словники лише ключ (errors.no_gifts, errors.budget_too_low), а тексти редагуються окремо.

Помилка № 4: відсутність форматування дат і валют за локаллю.
Показати користувачеві в Німеччині ціну $1,234.56 замість 1.234,56 $ — не баг, а UX‑антипатерн. Користувачі часто сприймають це як «цей сервіс зроблено не для мене». Про Intl.NumberFormat і Intl.DateTimeFormat легко забути, якщо ви звикли жити в одному регіоні. Тому корисно винести форматери в хук на кшталт useFormatters() і завжди користуватися ними замість ручної конкатенації рядків.

Помилка № 5: ігнорування можливої зміни locale.
Деякі розробники читають locale один раз під час монтування й далі вважають його константою. У більшості випадків це спрацює, але якщо ChatGPT чи платформа все‑таки змінить локаль (наприклад, користувач перемкнув мову інтерфейсу), ваш віджет залишиться старою мовою. Правильніше ставитися до locale як до частини реактивного стану й прив’язувати до нього useMemo/useEffect.

Помилка № 6: різні структури словників для різних мов.
Іноді переклад однією мовою доручають одній людині, іншою — іншій, і в результаті widget.en.json та widget.ru.json розходяться за структурою. В одному є forms.budget.placeholder, а в іншому — тільки forms.budget.label. У рантаймі це призводить до undefined і дивних помилок. Завжди тримайте один «канонічний» файл (зазвичай англійський), від якого інші мови наслідують структуру. Для генерації нових словників можна навіть писати скрипти, що перевіряють відповідність ключів.

Помилка № 7: спроба розв’язати все одразу через важкий i18n‑фреймворк.
Популярні рішення на кшталт react-i18next або next-intl потужні й корисні, але для невеликого ChatGPT‑віджета вони можуть бути надмірними. Часто простіше почати з легкого власного шару (I18nProvider, useT, словники в JSON), а вже потім, зі зростанням застосунку, мігрувати на повноцінну бібліотеку, якщо справді знадобляться складні правила множини, ICU‑формат тощо.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ