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/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 | --- 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).