Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 1 addition & 10 deletions composable-os/src/components/LanguageSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
21 changes: 20 additions & 1 deletion composable-os/src/i18n/english-sales.test.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -341,6 +341,25 @@ describe("QuoteDetail in English", () => {
expect(screen.getByPlaceholderText("[email protected]")).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/ }));
Expand Down
9 changes: 9 additions & 0 deletions composable-os/src/i18n/languageName.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
2 changes: 2 additions & 0 deletions composable-os/src/locales/en/sales.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@
"clientEmail": "Customer email",
"emailPlaceholder": "[email protected]",
"validFor": "Valid for",
"clientLanguage": "Customer language",
"clientLanguageHint": "The email to the customer is sent in this language.",
"copyLink": "Copy link",
"sendEmail": "Send email"
},
Expand Down
2 changes: 2 additions & 0 deletions composable-os/src/locales/es/sales.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@
"clientEmail": "Email del cliente",
"emailPlaceholder": "[email protected]",
"validFor": "Válida por",
"clientLanguage": "Idioma del cliente",
"clientLanguageHint": "El correo al cliente sale en este idioma.",
"copyLink": "Copiar link",
"sendEmail": "Enviar email"
},
Expand Down
38 changes: 37 additions & 1 deletion composable-os/src/pages/QuoteDetail.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -162,6 +162,42 @@ describe("QuoteDetail, in Spanish", () => {
expect(screen.getByPlaceholderText("[email protected]")).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("[email protected]"), { target: { value: "[email protected]" } });
fireEvent.click(screen.getByRole("button", { name: /Enviar email/ }));

await waitFor(() => expect(portal.createSession).toHaveBeenCalledWith(expect.objectContaining({ locale: "en", client_email: "[email protected]" })));
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/ }));
Expand Down
30 changes: 29 additions & 1 deletion composable-os/src/pages/QuoteDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<string | null>(null);
const clientLocale = shareLocale ?? catalogueFor(i18n.language, SUPPORTED_LOCALES);

const { data: sessions = [] } = useQuery({
queryKey: ["portal-sessions", id],
Expand All @@ -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) => {
Expand All @@ -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);
Expand Down Expand Up @@ -329,7 +340,7 @@ export default function QuoteDetail() {
)}

{/* Share dialog */}
<Dialog open={shareOpen} onOpenChange={(o) => { setShareOpen(o); if (!o) { setShareEmail(""); setShareName(""); setShareDays(14); } }}>
<Dialog open={shareOpen} onOpenChange={(o) => { setShareOpen(o); if (!o) { setShareEmail(""); setShareName(""); setShareDays(14); setShareLocale(null); } }}>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset the locale after successful email sends

Resetting shareLocale only in onOpenChange misses the successful-send path: sendPortalEmailMutation.onSuccess closes this controlled dialog directly with setShareOpen(false), which does not invoke onOpenChange. After sending to one customer in a non-default language, reopening the dialog retains that language and can send the next customer's email in it instead of proposing the interface language as intended. Route successful closure through the reset logic or reset shareLocale in the success handler.

Useful? React with 👍 / 👎.

<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t("sales:detail.shareDialog.title")}</DialogTitle>
Expand Down Expand Up @@ -361,6 +372,23 @@ export default function QuoteDetail() {
className="h-8 text-sm"
/>
</div>
{/* Como el selector de la interfaz: con un solo idioma ofrecido no hay nada que elegir */}
{SUPPORTED_LOCALES.length > 1 && (
<div className="space-y-1.5">
<Label htmlFor="share-locale" className="text-xs">{t("sales:detail.shareDialog.clientLanguage")}</Label>
<select
id="share-locale"
value={clientLocale}
onChange={(e) => setShareLocale(e.target.value)}
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm"
>
{SUPPORTED_LOCALES.map((code) => (
<option key={code} value={code}>{languageName(code)}</option>
))}
</select>
<p className="text-[11px] text-muted-foreground">{t("sales:detail.shareDialog.clientLanguageHint")}</p>
</div>
)}
<div className="space-y-1.5">
<Label className="text-xs">{t("sales:detail.shareDialog.validFor")}</Label>
<div className="flex gap-2">
Expand Down
4 changes: 4 additions & 0 deletions composable-os/src/services/portal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down
2 changes: 1 addition & 1 deletion docs/IMPLEMENTATION_ALIGNMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<language>/<domain>.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/<language>/<domain>.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 |

---
Expand Down
6 changes: 4 additions & 2 deletions docs/superpowers/plans/2026-08-07-i18n-full.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading