CodeGym /Corsi /ChatGPT Apps /Localizzazione dei widget: Next + React (architettura i18...

Localizzazione dei widget: Next + React (architettura i18n)

ChatGPT Apps
Livello 9 , Lezione 2
Disponibile

1. Perché al widget serve un'architettura i18n separata in ChatGPT App

In una classica applicazione Next.js spesso ti affidi all’URL (/en/..., /ru/...) o al router per legare la lingua alla rotta. In un widget ChatGPT è tutto più interessante: la tua UI vive dentro un iframe in sandbox e l’URL non è gestito da te. La lingua arriva come stato da ChatGPT, per esempio tramite openai/locale o un hook come useOpenAiGlobal('locale'), e non dalla barra degli indirizzi.

Si crea una situazione insolita. Dal punto di vista di Next.js, il tuo widget è virtualmente una sola pagina /widget, ma al suo interno deve sapersi renderizzare in qualsiasi lingua imponga la piattaforma. Il cambio di lingua va fatto non con la navigazione, ma con lo stato. Questo porta automaticamente a un’architettura «un'unica UI, molti dizionari» e sottolinea ancora una volta: conservare le stringhe nel codice è una strada senza uscita.

Inoltre, all'interno dello stesso dialogo ChatGPT può avviare la tua App per utenti di paesi diversi. Non puoi «decidere una volta per tutte che l’App è in russo» e dimenticartene. Il widget deve potersi re-inizializzare facilmente a un nuovo locale, senza cambiare la logica di business — ed è proprio per questo che serve un sottile strato i18n.

2. Principio chiave: nessuna stringa hardcoded nel codice

Se volessimo riassumere in breve la filosofia della localizzazione UI, suona così: i componenti React non hanno bisogno di testi reali, hanno bisogno di chiavi.

Invece di:


// MALE: stringa hardcoded nel componente
<button>Scegli un regalo</button>

il widget dovrebbe essere così:


// BENE: il componente conosce solo la chiave
<button>{t('buttons.pick_gift')}</button>

E i testi reali «Scegli un regalo» e «Pick a gift» sono memorizzati nei dizionari ru.json e en.json.

Perché tutta questa complicazione, se si poteva semplicemente fare if (locale === 'ru')?

Primo, scalabilità. Appena devi aggiungere una terza lingua, gli if/else diventano ingestibili. Secondo, separazione delle responsabilità. Un traduttore o un product può aggiornare i testi nei file JSON senza toccare il codice, e lo sviluppatore può fare refactoring dei componenti senza rischiare di rompere metà della copy UI. Terzo, uniformità: una singola fonte di verità per i testi evita che su un bottone ci sia «Acquista» e su un altro «Paga» solo perché gli autori dei componenti li hanno chiamati a sentimento.

Nel mondo delle ChatGPT App questo è particolarmente utile: a volte vorrai generare le traduzioni tramite LLM e poi aggiungerle ai dizionari. Conservare tutti i testi in file JSON è molto più comodo che spargerli nei componenti.

3. Strutturiamo i dizionari per il widget GiftGenius

Continuiamo a sviluppare la nostra app didattica GiftGenius — il widget per la scelta dei regali. Ora ci servono almeno due lingue: ru e en. Creiamo una struttura di base:

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

Contenuto più semplice del dizionario 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."
  }
}

E il corrispondente locales/ru/widget.json:

{
  "title": "GiftGenius",
  "forms": {
    "recipient": {
      "label": "Destinatario",
      "placeholder": "Per chi stiamo cercando un regalo?"
    },
    "budget": {
      "label": "Budget",
      "placeholder": "Ad esempio, 50"
    }
  },
  "buttons": {
    "pick_gift": "Trova regali",
    "try_again": "Riprova"
  },
  "errors": {
    "no_gifts": "Nessun regalo trovato per i tuoi criteri."
  }
}

Nota che la struttura delle chiavi è identica per entrambe le lingue. Questo è fondamentale: i componenti dipendono dalle chiavi, non dalle stringhe specifiche. Se in una lingua ti dimentichi di aggiungere errors.no_gifts, otterrai un errore chiaro, non una UI mezza tradotta.

In un progetto reale è sensato dividere i dizionari per aree: widget, checkout, errors e così via. In un’app didattica basta un file per lingua per non complicare.

4. Da dove prendere il locale nel widget dell’Apps SDK

In una classica app browser avresti consultato navigator.language. In un widget ChatGPT si può fare, ma non serve: ChatGPT ha già determinato per l’utente la locale preferita e la passa nel contesto dell’Apps SDK. Può essere il campo locale in window.openai, leggibile direttamente o tramite un comodo hook come useOpenAiGlobal('locale').

Tipico degli starter dell’Apps SDK è avere un componente root del widget, dove sono disponibili i dati globali da ChatGPT. Ad esempio:

"use client";

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

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

L’esempio sopra è illustrativo; l’API esatta dipende dalla versione dell’SDK, ma l’idea generale è corretta: locale è una verità esterna che arriva da ChatGPT, non dal browser dell’utente.

Anche la regione (userLocation) è passata tramite _meta["openai/userLocation"]. Ci servirà più avanti quando formatteremo i prezzi e considereremo la valuta. Per i testi basta locale — di solito arriva in formato BCP‑47 (en, en-US, ru-RU, ecc.).

5. Scriviamo un livello i18n minimale: contesto + hook useT

Perché il widget sia autosufficiente e non si trasformi in un manuale di react-i18next, implementiamo un nostro leggero livello i18n. Per un piccolo widget ChatGPT è più che sufficiente e i principi sono gli stessi delle librerie popolari.

Per prima cosa definiamo i tipi e creiamo il contesto in 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);

Ora creiamo un provider che riceve locale e il dizionario:

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

La parte più interessante è l’hook useT, che recupererà le stringhe per chiave:

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

Supportiamo chiavi annidate come forms.recipient.label e, in caso di assenza della traduzione, ritorniamo la chiave stessa — è più utile che mostrare il vuoto.

6. Inseriamo il provider i18n nel componente root del widget

Prima abbiamo già visto GiftWidgetRoot, che leggeva semplicemente locale da useOpenAiGlobal. Ora usiamo I18nProvider in questo componente root e aggiungiamo il caricamento del dizionario. Supponiamo che prima fosse più o meno così:

"use client";

export function GiftWidgetRoot() {
  return (
    <div>
      <h1>GiftGenius</h1>
      {/* moduli e risultati */}
    </div>
  );
}

Aggiungiamo il caricamento del dizionario e il provider. Per semplicità usiamo require/import sincrono in base al locale, ma in Next.js 16 puoi usare anche l’import asincrono (tramite dynamic import) se i dizionari sono grandi.

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

Il componente GiftWidget ora non pensa affatto alle lingue, sa solo che esiste una funzione 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>
      {/* resto della UI */}
    </div>
  );
}

Se domani ChatGPT crea un widget con locale = "de-DE", potrai aggiungere locales/de/widget.json e una riga in loadMessages, senza toccare il resto del codice. È esattamente questo il punto.

7. Formati localizzabili: numeri, date, valute

Abbiamo già spostato i testi nei dizionari e avvolto il widget in I18nProvider. Ma i testi sono solo metà dell’UX: l’utente degli USA si aspetta di vedere 12/31/2025, mentre l’utente tedesco 31.12.2025. Lo stesso vale per numeri e valute. Mostrare a un utente russo il prezzo «1,234.56 USD» è un ottimo modo per far capire che il tuo assistente «intelligente» in realtà non è molto attento.

Per fortuna, nel browser (e nella sandbox di ChatGPT) è disponibile lo standard Intl API. Aggiungiamo in i18n.tsx un paio di utility che usano il locale corrente:

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

Ora, nel componente in cui mostriamo il budget o i prezzi dei regali (supponiamo di riceverli già dal server MCP con currency indicata):

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

Se vuoi rendere la formattazione ancora più «intelligente» (per esempio, scegliere la valuta in base a userLocation), puoi combinare locale e regione. Architetturalmente, questo prosegue la linea già discussa per MCP‑Gateway: locale influenza la lingua del testo, userLocation le regole di business e la valuta.

8. Reazione al cambio di lingua: e se ChatGPT cambia locale al volo

Nel web tradizionale è l’utente stesso a cliccare «EN / RU» e tu sai esattamente quando cambiare lingua. In una ChatGPT App il modello può teoricamente decidere che all’utente conviene un’altra lingua (oppure l’utente cambia la lingua dell’interfaccia nelle impostazioni), e openai/locale cambia.

Se l’SDK ti dà un segnale reattivo (tramite hook o evento), il pattern di codice sarà questo:

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

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

Qui loadMessages verrà rieseguito al cambio di locale, e tutta la UI verrà automaticamente ri-renderizzata con le nuove traduzioni. Nella maggior parte dei casi reali la locale è stabile nella sessione, ma prevedere il modello reattivo corretto è comunque utile.

9. Un accenno alle stringhe complesse: placeholder e pluralizzazione

Abbiamo chiarito la reattività rispetto a locale. La domanda successiva naturale è: che fare con le parti dinamiche del testo — quantità, nomi, ecc.? Nell’app dei regali potrebbe essere qualcosa come «Trovati 3 regali per Masha».

Il modo più semplice per gestire frasi di questo tipo è supportare i placeholder in t() e sostituire i valori al volo. A tal fine modifichiamo useT in modo che accetti come secondo argomento un oggetto di valori:

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

Ora aggiungiamo una stringa in widget.json:

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

E usiamola:

const { t } = useT();

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

Per la pluralizzazione ci sono varie strade: o definire più chiavi (one, few, many) e sceglierle a mano, oppure usare una libreria come react-intl/i18next, che ha il supporto completo alle regole del plurale (plural rules). Per un widget didattico è più che accettabile una scelta manuale per intervalli (ad esempio, if count === 1, if count < 5, ecc.).

10. Dove collocare i18n nella struttura del template Next.js dell’Apps SDK

Dal punto di vista di Next.js 16 e del template ufficiale dell’Apps SDK, il tuo widget è solitamente un entrypoint specializzato in app/ (per esempio, app/widget/page.tsx o un componente separato che l’Apps SDK renderizza dentro ChatGPT).

Pattern tipico:

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

import { GiftWidgetRoot } from "./GiftWidgetRoot";

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

Il livello i18n vive completamente nella parte client — tutto ciò che abbiamo scritto sopra sono client components. È importante notare che nell’ambiente ChatGPT tutto viene comunque renderizzato sul client all’interno di un iframe, quindi i pattern i18n classici SSR (HTML localizzato sul server) possono essere messi momentaneamente da parte. Questo semplifica molto la vita: lavori come con una SPA normale, solo che invece di navigator.language usi openai/locale.

Se ti serve condividere le traduzioni tra più widget della stessa App (per esempio, una procedura principale e un «piccolo widget inline»), puoi estrarre I18nProvider in un modulo separato e riutilizzarlo.

11. Mini‑testing della localizzazione

Non appena nella sistema compare un livello i18n, conviene iniziare a testarlo separatamente — altrimenti qualsiasi refuso in una chiave si trasforma in una «UI mezza tradotta». Visto che abbiamo creato l’architettura, tanto vale verificarla.

Primo, ha senso scrivere semplici unit test per loadMessages e useT (con React Testing Library o anche senza React — testando direttamente la funzione t). Questi test intercettano refusi nelle chiavi e aiutano se tu o un traduttore eliminate accidentalmente un ramo necessario del dizionario.

Secondo, è comodo prevedere una modalità di «esecuzione locale» del widget fuori da ChatGPT, dove puoi forzare il locale tramite un parametro di query o un bottone nella UI. È utile sia per te che per il QA: nessuno è obbligato ad avviare tutto il Dev Mode e ChatGPT solo per vedere come appare la traduzione tedesca. Con questi test di base e l’esecuzione locale su diversi locale svilupperai con molta più tranquillità sia la UI che i testi, per poi passare alla localizzazione delle descrizioni dei tools.

In che modo tutto questo è collegato al comportamento del modello

Entreremo in profondità nella localizzazione delle descriptions degli strumenti nella prossima lezione, ma è già importante vedere il collegamento: il widget e gli strumenti devono parlare la stessa lingua dell’utente. Stai già costruendo una UI che si adatta a openai/locale. Il server MCP, in base allo stesso segnale, sceglie il catalogo e i testi corretti. È logico che anche la descrizione di suggest_gifts e i campi recipient, budget vengano spiegati al modello nella lingua dell’utente — questo ridurrà il numero di chiamate agli strumenti insolite e di argomenti non corretti.

Quindi l’architettura i18n del widget non è solo cosmetica. È il primo mattone di un sistema complessivo in cui il livello UI, il livello MCP e il modello utilizzano lo stesso contesto di locale.

12. Errori tipici nella localizzazione dei widget

Errore n. 1: stringhe hardcoded direttamente in JSX.
Storia frequentissima: il widget è iniziato come un prototipo veloce in una singola lingua, e all’improvviso arriva «serve anche l’inglese». Il risultato è una UI piena di stringhe in russo, e aggiungere l’inglese diventa un find & replace globale sul progetto. Prima introduci dizionari e la funzione t(), meno problemi avrai in seguito.

Errore n. 2: if (locale === 'ru') ovunque.
Questa condizione a volte sembra «una soluzione rapida», ma si rompe subito appena compare una terza lingua o varianti come ru-RU, ru, ru-UA. Meglio scrivere una volta loadMessages(locale) con normalizzazione (locale.split('-')[0]) e poi non pensarci più, che spargere controlli per tutto il codice.

Errore n. 3: mescolare logica di business e testi.
A volte gli sviluppatori introducono nei componenti condizioni complesse che allo stesso tempo gestiscono i rami di business e la scelta del testo. Per esempio, «se non ci sono regali, mostra questa frase, e se il budget è basso — un’altra». Alla fine è difficile modificare la copy, la logica si disperde e le traduzioni finiscono dentro TypeScript. Molto meglio quando i componenti restituiscono ai dizionari solo la chiave (errors.no_gifts, errors.budget_too_low) e i testi si modificano separatamente.

Errore n. 4: niente formattazione di date/valute per locale.
Mostrare a un utente in Germania il prezzo $1,234.56 invece di 1.234,56 $ non è un bug, è un anti‑pattern di UX. Ma gli utenti lo percepiscono come «questo servizio non è fatto per me». È molto facile dimenticarsi di Intl.NumberFormat e Intl.DateTimeFormat se sei abituato a vivere in una sola regione. Per questo è utile estrarre i formattatori in un hook come useFormatters() e usarli sempre, invece di concatenare stringhe a mano.

Errore n. 5: non considerare il possibile cambio di locale.
Alcuni sviluppatori leggono locale una sola volta al montaggio e poi lo considerano una costante. Nella maggior parte dei casi funzionerà, ma se ChatGPT o la piattaforma cambiano la locale (ad esempio, l’utente cambia la lingua dell’interfaccia), il tuo widget resterà nella lingua precedente. È più corretto trattare locale come parte dello stato reattivo e legare a esso useMemo/useEffect.

Errore n. 6: mantenere strutture di dizionari diverse per lingue diverse.
A volte la traduzione per una lingua è affidata a una persona, per un’altra a un’altra, e alla fine widget.en.json e widget.ru.json divergono nella struttura. In uno c’è forms.budget.placeholder, nell’altro solo forms.budget.label. A runtime questo si traduce in undefined ed errori strani. Tieni sempre un file «canonico» (di solito l’inglese) da cui le altre lingue ereditano la struttura. Per generare nuovi dizionari puoi persino scrivere script che verificano la corrispondenza delle chiavi.

Errore n. 7: tentare di risolvere tutto subito con un framework i18n pesante.
Soluzioni popolari come react-i18next o next-intl sono potenti e utili, ma per un piccolo widget ChatGPT possono essere eccessive. Spesso è più semplice iniziare con un proprio layer leggero (I18nProvider, useT, dizionari in JSON) e poi, con la crescita dell’applicazione, migrare a una libreria completa se davvero servono pluralizzazioni complesse, formato ICU, ecc.

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