From 398b25d53a8d3de78413a5e5b19b19ff48baf1f2 Mon Sep 17 00:00:00 2001 From: keilynrp Date: Thu, 8 Oct 2026 15:04:16 -0600 Subject: [PATCH 1/2] feat(i18n): selector de idioma del cliente al compartir una cotizacion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultimo punto de la tarea 12 que quedaba abierto desde la tarea 2: el dialogo de compartir cotizacion deja elegir el idioma del cliente. El backend ya guardaba `locale` en la sesion de portal y lo usa para redactar el correo que le manda al cliente; faltaba que la interfaz lo enviara. - `QuoteDetail`: un desplegable «Idioma del cliente» con los idiomas ofrecidos, cada uno escrito en si mismo. Propone el idioma de la interfaz (el vendedor suele escribirle al cliente en el suyo) y siempre manda un idioma concreto, tanto al copiar el enlace como al enviar el correo. Se restablece al cerrar el dialogo. Como el selector de la interfaz, no se dibuja si solo hay un idioma ofrecido. - Un texto de ayuda dice solo lo que es cierto: «El correo al cliente sale en este idioma». La pagina del portal sigue el idioma del navegador del cliente, no el de la sesion, y el texto no promete lo contrario. - `services/portal.ts`: `locale` en `PortalSession` y en `CreateSessionDto`. - `i18n/languageName.ts`: el nombre de un idioma en si mismo sale de `LanguageSelector` a un modulo propio para compartirlo con el dialogo. - Dos claves nuevas en `sales` (es y en): `shareDialog.clientLanguage` y `shareDialog.clientLanguageHint`. Tests: en espanol, que se propone el idioma de la interfaz y se envia con la sesion, que se puede escribir al cliente en otro idioma y que se restablece al reabrir; en ingles real, que se propone `en` y se puede cambiar a `es`. Comprobado con mutaciones: no enviar `locale` hace fallar tres tests, y una copia del texto en espanol falla el check de catalogos y el test. El plan marca el punto como hecho. Co-Authored-By: Claude Sonnet 5.5 --- .../src/components/LanguageSelector.tsx | 11 +----- composable-os/src/i18n/english-sales.test.tsx | 21 +++++++++- composable-os/src/i18n/languageName.ts | 9 +++++ composable-os/src/locales/en/sales.json | 2 + composable-os/src/locales/es/sales.json | 2 + composable-os/src/pages/QuoteDetail.test.tsx | 38 ++++++++++++++++++- composable-os/src/pages/QuoteDetail.tsx | 30 ++++++++++++++- composable-os/src/services/portal.ts | 4 ++ .../superpowers/plans/2026-08-07-i18n-full.md | 6 ++- 9 files changed, 108 insertions(+), 15 deletions(-) create mode 100644 composable-os/src/i18n/languageName.ts diff --git a/composable-os/src/components/LanguageSelector.tsx b/composable-os/src/components/LanguageSelector.tsx index e9c8adb..924a861 100644 --- a/composable-os/src/components/LanguageSelector.tsx +++ b/composable-os/src/components/LanguageSelector.tsx @@ -3,22 +3,13 @@ import { useTranslation } from "react-i18next"; import { Label } from "@/components/ui/label"; import { useToast } from "@/hooks/use-toast"; import { SUPPORTED_LOCALES } from "@/i18n"; +import { languageName } from "@/i18n/languageName"; import { catalogueFor } from "@/i18n/locale"; import { useT } from "@/i18n/useT"; import { useAuth } from "@/lib/auth"; import { translateApiError } from "@/lib/errors"; import { cn } from "@/lib/utils"; -/** El idioma escrito en si mismo ("Espanol", "English"), sin pasar por un catalogo. */ -function languageName(code: string): string { - try { - const name = new Intl.DisplayNames([code], { type: "language" }).of(code) ?? code; - return name.charAt(0).toLocaleUpperCase(code) + name.slice(1); - } catch { - return code; - } -} - interface Props { /** Idiomas ofrecidos. Por defecto, los que tienen catalogo enviado. */ locales?: readonly string[]; diff --git a/composable-os/src/i18n/english-sales.test.tsx b/composable-os/src/i18n/english-sales.test.tsx index 7ab5080..9c1b679 100644 --- a/composable-os/src/i18n/english-sales.test.tsx +++ b/composable-os/src/i18n/english-sales.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; import i18next, { type i18n as I18n } from "i18next"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -341,6 +341,25 @@ describe("QuoteDetail in English", () => { expect(screen.getByPlaceholderText("john@company.com")).toBeInTheDocument(); }); + it("proposes English for the customer and lets the seller switch to Spanish", async () => { + renderDetail(await english()); + fireEvent.click(await screen.findByRole("button", { name: /Share/ })); + + const select = await screen.findByLabelText("Customer language"); + expect(select).toHaveValue("en"); + expect(within(select).getByRole("option", { name: "English" })).toBeInTheDocument(); + expect(within(select).getByRole("option", { name: "Español" })).toBeInTheDocument(); + expect(screen.getByText("The email to the customer is sent in this language.")).toBeInTheDocument(); + + // «Copy link» no cierra el diálogo, así que se puede probar con los dos idiomas seguidos. + fireEvent.click(screen.getByRole("button", { name: /Copy link/ })); + await waitFor(() => expect(portal.createSession).toHaveBeenLastCalledWith(expect.objectContaining({ locale: "en" }))); + + fireEvent.change(select, { target: { value: "es" } }); + fireEvent.click(screen.getByRole("button", { name: /Copy link/ })); + await waitFor(() => expect(portal.createSession).toHaveBeenLastCalledWith(expect.objectContaining({ locale: "es" }))); + }); + it("copies the link and sends the email, saying to whom", async () => { renderDetail(await english()); fireEvent.click(await screen.findByRole("button", { name: /Share/ })); diff --git a/composable-os/src/i18n/languageName.ts b/composable-os/src/i18n/languageName.ts new file mode 100644 index 0000000..13f2775 --- /dev/null +++ b/composable-os/src/i18n/languageName.ts @@ -0,0 +1,9 @@ +/** El idioma escrito en si mismo ("Espanol", "English"), sin pasar por un catalogo. */ +export function languageName(code: string): string { + try { + const name = new Intl.DisplayNames([code], { type: "language" }).of(code) ?? code; + return name.charAt(0).toLocaleUpperCase(code) + name.slice(1); + } catch { + return code; + } +} diff --git a/composable-os/src/locales/en/sales.json b/composable-os/src/locales/en/sales.json index 7ad5bf6..ae41570 100644 --- a/composable-os/src/locales/en/sales.json +++ b/composable-os/src/locales/en/sales.json @@ -93,6 +93,8 @@ "clientEmail": "Customer email", "emailPlaceholder": "john@company.com", "validFor": "Valid for", + "clientLanguage": "Customer language", + "clientLanguageHint": "The email to the customer is sent in this language.", "copyLink": "Copy link", "sendEmail": "Send email" }, diff --git a/composable-os/src/locales/es/sales.json b/composable-os/src/locales/es/sales.json index 837782e..99d3243 100644 --- a/composable-os/src/locales/es/sales.json +++ b/composable-os/src/locales/es/sales.json @@ -93,6 +93,8 @@ "clientEmail": "Email del cliente", "emailPlaceholder": "juan@empresa.com", "validFor": "Válida por", + "clientLanguage": "Idioma del cliente", + "clientLanguageHint": "El correo al cliente sale en este idioma.", "copyLink": "Copiar link", "sendEmail": "Enviar email" }, diff --git a/composable-os/src/pages/QuoteDetail.test.tsx b/composable-os/src/pages/QuoteDetail.test.tsx index b573374..6aff884 100644 --- a/composable-os/src/pages/QuoteDetail.test.tsx +++ b/composable-os/src/pages/QuoteDetail.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import i18n from "@/i18n"; @@ -162,6 +162,42 @@ describe("QuoteDetail, in Spanish", () => { expect(screen.getByPlaceholderText("juan@empresa.com")).toBeInTheDocument(); }); + it("proposes the interface language for the customer and sends it with the session", async () => { + render(); + fireEvent.click(await screen.findByRole("button", { name: /Compartir/ })); + + const select = await screen.findByLabelText("Idioma del cliente"); + expect(select).toHaveValue("es"); + expect(within(select).getByRole("option", { name: "Español" })).toBeInTheDocument(); + expect(within(select).getByRole("option", { name: "English" })).toBeInTheDocument(); + expect(screen.getByText("El correo al cliente sale en este idioma.")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /Copiar link/ })); + await waitFor(() => expect(portal.createSession).toHaveBeenCalledWith(expect.objectContaining({ locale: "es" }))); + }); + + it("lets the seller write to the customer in another language", async () => { + render(); + fireEvent.click(await screen.findByRole("button", { name: /Compartir/ })); + fireEvent.change(await screen.findByLabelText("Idioma del cliente"), { target: { value: "en" } }); + fireEvent.change(screen.getByPlaceholderText("juan@empresa.com"), { target: { value: "ana@sol.co" } }); + fireEvent.click(screen.getByRole("button", { name: /Enviar email/ })); + + await waitFor(() => expect(portal.createSession).toHaveBeenCalledWith(expect.objectContaining({ locale: "en", client_email: "ana@sol.co" }))); + await waitFor(() => expect(portal.sendEmail).toHaveBeenCalledWith("s1")); + }); + + it("goes back to the interface language when the dialog is closed and reopened", async () => { + render(); + fireEvent.click(await screen.findByRole("button", { name: /Compartir/ })); + fireEvent.change(await screen.findByLabelText("Idioma del cliente"), { target: { value: "en" } }); + fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" }); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + + fireEvent.click(screen.getByRole("button", { name: /Compartir/ })); + expect(await screen.findByLabelText("Idioma del cliente")).toHaveValue("es"); + }); + it("copies the link and confirms", async () => { render(); fireEvent.click(await screen.findByRole("button", { name: /Compartir/ })); diff --git a/composable-os/src/pages/QuoteDetail.tsx b/composable-os/src/pages/QuoteDetail.tsx index ab5e2f2..4897e7c 100644 --- a/composable-os/src/pages/QuoteDetail.tsx +++ b/composable-os/src/pages/QuoteDetail.tsx @@ -21,6 +21,10 @@ import { Label } from "@/components/ui/label"; import { toast } from "sonner"; import { translateApiError } from "@/lib/errors"; import { ArrowLeft, Plus, Trash2, Download, Loader2, Share2, Copy, Mail } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { SUPPORTED_LOCALES } from "@/i18n"; +import { languageName } from "@/i18n/languageName"; +import { catalogueFor } from "@/i18n/locale"; import { useFormat } from "@/i18n/useFormat"; import { useT } from "@/i18n/useT"; @@ -166,6 +170,11 @@ export default function QuoteDetail() { const [shareEmail, setShareEmail] = useState(""); const [shareName, setShareName] = useState(""); const [shareDays, setShareDays] = useState<7 | 14 | 30>(14); + // `null` = no lo ha tocado: se propone el idioma de la interfaz, que es en el que el + // vendedor suele escribirle al cliente. Se manda siempre un idioma concreto. + const { i18n } = useTranslation(); + const [shareLocale, setShareLocale] = useState(null); + const clientLocale = shareLocale ?? catalogueFor(i18n.language, SUPPORTED_LOCALES); const { data: sessions = [] } = useQuery({ queryKey: ["portal-sessions", id], @@ -185,6 +194,7 @@ export default function QuoteDetail() { quote_id: id!, client_name: shareName || undefined, client_email: shareEmail || undefined, + locale: clientLocale, expire_hours: shareDays * 24, }), onSuccess: (s) => { @@ -203,6 +213,7 @@ export default function QuoteDetail() { quote_id: id!, client_name: shareName || undefined, client_email: shareEmail || undefined, + locale: clientLocale, expire_hours: shareDays * 24, }); return portalService.sendEmail(s.id); @@ -329,7 +340,7 @@ export default function QuoteDetail() { )} {/* Share dialog */} - { setShareOpen(o); if (!o) { setShareEmail(""); setShareName(""); setShareDays(14); } }}> + { setShareOpen(o); if (!o) { setShareEmail(""); setShareName(""); setShareDays(14); setShareLocale(null); } }}> {t("sales:detail.shareDialog.title")} @@ -361,6 +372,23 @@ export default function QuoteDetail() { className="h-8 text-sm" /> + {/* Como el selector de la interfaz: con un solo idioma ofrecido no hay nada que elegir */} + {SUPPORTED_LOCALES.length > 1 && ( +
+ + +

{t("sales:detail.shareDialog.clientLanguageHint")}

+
+ )}
diff --git a/composable-os/src/services/portal.ts b/composable-os/src/services/portal.ts index ecfbdff..4a369be 100644 --- a/composable-os/src/services/portal.ts +++ b/composable-os/src/services/portal.ts @@ -11,6 +11,8 @@ export interface PortalSession { action: string | null; // "accepted" | "rejected" | null client_name: string | null; client_email: string | null; + /** Idioma del cliente (`es`, `en-US`); `null` sigue al workspace. */ + locale: string | null; created_by_id: string | null; portal_url: string; created_at: string; @@ -20,6 +22,8 @@ export interface CreateSessionDto { quote_id: string; client_name?: string; client_email?: string; + /** Idioma en que el servidor redacta lo que le manda al cliente (el correo). */ + locale?: string; expire_hours?: number; } diff --git a/docs/superpowers/plans/2026-08-07-i18n-full.md b/docs/superpowers/plans/2026-08-07-i18n-full.md index cdf73ba..5a33d78 100644 --- a/docs/superpowers/plans/2026-08-07-i18n-full.md +++ b/docs/superpowers/plans/2026-08-07-i18n-full.md @@ -467,8 +467,10 @@ completa migrada no hay patron que seguir. - [x] Ofrecer `en` en el backend: `en` pasa de `UNRELEASED_LOCALES` a `SUPPORTED_LOCALES` (`("es", "en")`); el PATCH del locale, la sesion de portal, `Accept-Language` en el registro y el workspace lo aceptan. - - [ ] Selector de idioma en el dialogo de compartir cotizacion (pendiente desde la - tarea 2): el backend ya guarda el idioma en la sesion de portal. + - [x] Selector de idioma en el dialogo de compartir cotizacion (pendiente desde la + tarea 2): propone el idioma de la interfaz, lo manda como `locale` de la sesion de + portal y el servidor redacta en el el correo al cliente. La pagina del portal sigue + el idioma del navegador del cliente, no el de la sesion. - [ ] Recorrer la aplicacion en ingles buscando lo que el segundo idioma revela: cadenas concatenadas, orden de palabras, plurales, fechas e importes (decision de `babel` o tabla propia, pendiente desde la tarea 10). From 3271e994355de0d9b1040b650805161963a594eb Mon Sep 17 00:00:00 2001 From: keilynrp Date: Thu, 8 Oct 2026 17:06:37 -0600 Subject: [PATCH 2/2] docs(i18n): actualizar IMPLEMENTATION_ALIGNMENT tras ofrecer en y el selector La politica de gobernanza exige tocar este documento cuando cambia una pagina o un servicio, y este PR toca `QuoteDetail` y `services/portal.ts`. Ademas la fila de internacionalizacion habia quedado desactualizada en #74: seguia diciendo que `en` estaba retenido en `UNRELEASED_LOCALES` y que ningun segundo idioma se ofrecia. Ahora dice que `en` se ofrece (`SUPPORTED_LOCALES`), que el selector del dialogo de compartir cotizacion fija el idioma del correo al cliente (`portal_sessions.locale`) y que la pagina del portal sigue el idioma del navegador del cliente. Lo que sigue sin construir se mantiene: el PDF de cotizacion, la prosa en espanol de algunas respuestas del portal y del `send_email` de los workflows, y el formato de fechas e importes en lo que renderiza el servidor. Co-Authored-By: Claude Sonnet 5.5 --- docs/IMPLEMENTATION_ALIGNMENT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/IMPLEMENTATION_ALIGNMENT.md b/docs/IMPLEMENTATION_ALIGNMENT.md index 6706dfe..aed02c8 100644 --- a/docs/IMPLEMENTATION_ALIGNMENT.md +++ b/docs/IMPLEMENTATION_ALIGNMENT.md @@ -83,7 +83,7 @@ Legacy documents are historical reference only unless explicitly promoted by ADR | External brand-site integration | Strategic direction documented; public lead intake v1 now has code, contract tests, decision logging, event metadata, and rate-limit response behavior, but is not yet operationally proven under production-like traffic | One validated public integration slice before SDK/builder expansion | High | Run deployment-like validation for CRM public intake before expanding public surfaces | | Public API boundary | Portal proves public interaction can work; CRM public intake now defines the initial site-key/origin/idempotency/rate-limit model | Explicit public endpoint policy with origin, key, idempotency, abuse controls, and logging rules | High | Promote the CRM public intake contract into broader public API conventions only after production validation | | Future stack pressure | Strategic docs mention SDK, builder, semantic layers, and UKIP-compatible ideas | Current stack remains frozen until justified | Medium | Treat those areas as target-state only unless promoted by ADR | -| Internationalization | Committed as a core requirement (ADR 0014, product answer 2026-08-07), half built, and now fully decided. The backend is already language-neutral: registered error codes with structured `detail`, no user-facing prose, parity enforced in CI. The three open decisions closed 2026-08-07 — `react-i18next` promoted for the frontend tier only (ADR 0015), and locale ownership settled on `users.locale` with a workspace default plus `portal_sessions.locale` for external recipients (ADR 0016). Built: the frontend catalogue for every page, the three locale columns with their migration, the single resolution helper (`resolve_locale`, and `resolve_user_locale` for an authenticated user), and the backend catalogues (`app/core/i18n/locales//.json`, `translate()`), which today carry the invoice PDF labels and the 9 email templates (internal ones in the recipient's language, the two client-facing ones in the portal session's). Not built: the quote PDF (`sales/pdf.py`), the Spanish prose still returned by some portal API responses and by the workflow `send_email` action's defaults, locale-aware date and amount formatting in the server-rendered artifacts, and a second language offered to anyone. English is under way: the backend catalogues (invoice PDF, 9 emails) are complete but held back in `UNRELEASED_LOCALES`, the frontend has `common`, `errors`, `auth`, `layout`, `dashboard`, `analytics`, `companyProfile`, `customerPortal`, `contracts`, `crm`, `discovery`, `hr`, `inventory`, `invoicing`, `portalBuilder`, `projects`, `sales`, `settings` and `workflows` translated (19 of 19 namespaces) and offers a language only once every namespace exists. The CI checks (key parity, orphan strings, untranslated copies) already guard it: `src/i18n/catalogues.test.ts`, `backend/tests/test_i18n_catalogue_usage.py` — a frontend catalogue cannot reach the server-rendered artifacts | The product ships in more than one language, with no user-facing string hardcoded in either tier | Medium | Do not start a partial extraction: a half-translated screen reads as broken and is worse than none. The sequenced plan is `docs/superpowers/plans/2026-08-07-i18n-full.md`; the first executable task is the migration. Nothing blocks it now, but nothing schedules it either — it does not improve the wedge, so when it enters is an explicit call. Meanwhile hold the line that already works — new backend code raises codes, never prose | +| Internationalization | Committed as a core requirement (ADR 0014, product answer 2026-08-07), half built, and now fully decided. The backend is already language-neutral: registered error codes with structured `detail`, no user-facing prose, parity enforced in CI. The three open decisions closed 2026-08-07 — `react-i18next` promoted for the frontend tier only (ADR 0015), and locale ownership settled on `users.locale` with a workspace default plus `portal_sessions.locale` for external recipients (ADR 0016). Built: the frontend catalogue for every page, the three locale columns with their migration, the single resolution helper (`resolve_locale`, and `resolve_user_locale` for an authenticated user), and the backend catalogues (`app/core/i18n/locales//.json`, `translate()`), which today carry the invoice PDF labels and the 9 email templates (internal ones in the recipient's language, the two client-facing ones in the portal session's). Not built: the quote PDF (`sales/pdf.py`), the Spanish prose still returned by some portal API responses and by the workflow `send_email` action's defaults, and locale-aware date and amount formatting in the server-rendered artifacts. English is offered: the backend catalogues (invoice PDF, 9 emails) are complete and `en` is in `SUPPORTED_LOCALES`, and the frontend has `common`, `errors`, `auth`, `layout`, `dashboard`, `analytics`, `companyProfile`, `customerPortal`, `contracts`, `crm`, `discovery`, `hr`, `inventory`, `invoicing`, `portalBuilder`, `projects`, `sales`, `settings` and `workflows` translated (19 of 19 namespaces); it offers a language only once every namespace exists. The quote share dialog lets the seller choose the customer's language (`portal_sessions.locale`), which sets the language of the email sent to the customer; the portal page itself follows the customer's browser. The CI checks (key parity, orphan strings, untranslated copies) already guard it: `src/i18n/catalogues.test.ts`, `backend/tests/test_i18n_catalogue_usage.py` — a frontend catalogue cannot reach the server-rendered artifacts | The product ships in more than one language, with no user-facing string hardcoded in either tier | Medium | Do not start a partial extraction: a half-translated screen reads as broken and is worse than none. The sequenced plan is `docs/superpowers/plans/2026-08-07-i18n-full.md`; the first executable task is the migration. Nothing blocks it now, but nothing schedules it either — it does not improve the wedge, so when it enters is an explicit call. Meanwhile hold the line that already works — new backend code raises codes, never prose | | Schema drift between models and database | Closed. `alembic revision --autogenerate` on an unchanged model set emits an empty `upgrade()`, so a generated migration now contains only the intended change. Resolved in three steps: foreign-key delete rules declared in the models, `users.notification_preferences` tightened to `NOT NULL` in the database, and two leftovers removed — a redundant unique constraint on `public_sites.api_key` that duplicated the unique index, and an index on `quote_events.user_id` that no query used | Autogenerate stays silent on an unchanged model set | Medium | Keep it closed: run autogenerate before and after a model change and treat any unrelated output as drift to fix in its own change set, not to hand-edit away | ---