diff --git a/apps/web/src/components/settings/ThemingSettings.tsx b/apps/web/src/components/settings/ThemingSettings.tsx index 5aafe7f61f..d93ef9d85f 100644 --- a/apps/web/src/components/settings/ThemingSettings.tsx +++ b/apps/web/src/components/settings/ThemingSettings.tsx @@ -263,7 +263,7 @@ export default function ThemingSettings({ preferences, onSaved }: ThemingSetting
@@ -276,6 +276,7 @@ export default function ThemingSettings({ preferences, onSaved }: ThemingSetting void handleAppearanceChange({ locale: value })} aria-pressed={localePreference === value} disabled={isSavingAppearance} @@ -297,12 +298,18 @@ export default function ThemingSettings({ preferences, onSaved }: ThemingSetting {appearanceSuccess && ( - + {appearanceSuccess} )} {appearanceError && ( - + {appearanceError} )} diff --git a/apps/web/src/components/settings/TicketFormsCard.tsx b/apps/web/src/components/settings/TicketFormsCard.tsx index ea9717245c..c4b65d6663 100644 --- a/apps/web/src/components/settings/TicketFormsCard.tsx +++ b/apps/web/src/components/settings/TicketFormsCard.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { ticketFormFieldsSchema, TICKET_FORM_FIELD_TYPES, @@ -187,6 +188,7 @@ function emptyDraft(): FormDraft { const inputCls = 'w-full rounded-md border bg-background px-2.5 py-1.5 text-sm'; export default function TicketFormsCard() { + const { t } = useTranslation('settings'); const [forms, setForms] = useState([]); const [orgs, setOrgs] = useState([]); const [categories, setCategories] = useState([]); @@ -304,25 +306,27 @@ export default function TicketFormsCard() { if (!editing || saving) return; const d = editing.draft; const localIssues: string[] = []; - if (!d.name.trim()) localIssues.push('Name is required.'); + if (!d.name.trim()) localIssues.push(t('ticketFormsCard.validation.nameRequired')); // Org-scoped create needs an org. If orgs FAILED to load, the inline // `ticket-form-orgs-error` notice (with retry) is the honest feedback — emit // the "select an org" line only when orgs are actually available to pick. const orgScopeNeedsOrg = editing.id === undefined && d.ownerScope === 'organization' && !d.orgId; const orgScopeBlockedByLoad = orgScopeNeedsOrg && orgsLoadFailed; if (orgScopeNeedsOrg && !orgsLoadFailed) { - localIssues.push('Select an organization for an organization-scoped form.'); + localIssues.push(t('ticketFormsCard.validation.selectOrgRequired')); } // Allowlist is only meaningful on partner-wide forms. Checked-but-empty is // unrepresentable: force a choice rather than silently sending null/[]. if (d.ownerScope === 'partner' && d.limitOrgs && d.visibleOrgIds.length === 0) { - localIssues.push('Select at least one organization or uncheck "Limit to specific organizations".'); + localIssues.push(t('ticketFormsCard.validation.limitOrgsRequired')); } const built = buildFields(d.fields); const parsed = ticketFormFieldsSchema.safeParse(built); if (!parsed.success) { for (const issue of parsed.error.issues) { - const path = issue.path.length ? `Field ${issue.path.join('.')}: ` : ''; + const path = issue.path.length + ? t('ticketFormsCard.validation.fieldPrefix', { path: issue.path.join('.') }) + : ''; localIssues.push(`${path}${issue.message}`); } } @@ -336,7 +340,10 @@ export default function TicketFormsCard() { if (!token || keys.has(token) || seen.has(token)) continue; seen.add(token); localIssues.push( - `Title template references unknown field "{{${token}}}". Available keys: ${built.map((f) => f.key).join(', ') || 'none'}.` + t('ticketFormsCard.validation.unknownFieldKey', { + tokenSyntax: `{{${token}}}`, + keys: built.map((f) => f.key).join(', ') || t('ticketFormsCard.validation.noKeysAvailable') + }) ); } } @@ -394,8 +401,8 @@ export default function TicketFormsCard() { try { await runAction({ request, - successMessage: 'Form saved', - errorFallback: 'Failed to save form. Retry.', + successMessage: t('ticketFormsCard.formSaved'), + errorFallback: t('ticketFormsCard.saveFailed'), onUnauthorized: UNAUTHORIZED }); closeEditor(); @@ -418,8 +425,8 @@ export default function TicketFormsCard() { try { await runAction({ request: () => fetchWithAuth(`/ticket-forms/${id}`, { method: 'DELETE' }), - successMessage: 'Form deleted', - errorFallback: 'Failed to delete form.', + successMessage: t('ticketFormsCard.formDeleted'), + errorFallback: t('ticketFormsCard.deleteFailed'), onUnauthorized: UNAUTHORIZED }); await load(); @@ -435,11 +442,8 @@ export default function TicketFormsCard() { - Intake forms - - Custom fields collected when a ticket is created. Share one form across all organizations or scope it to a - single org. - + {t('ticketFormsCard.heading')} + {t('ticketFormsCard.subheading')} - New form + {t('ticketFormsCard.newForm')} @@ -458,7 +462,7 @@ export default function TicketFormsCard() { {editing.id === undefined && ( - Scope + {t('ticketFormsCard.scopeLegend')} patchDraft({ ownerScope: 'partner' })} data-testid="ticket-form-owner-partner" /> - All organizations (partner-wide) + {t('ticketFormsCard.allOrgsOption')} {t('ticketFormsCard.partnerWideHint')} patchDraft({ ownerScope: 'organization' })} data-testid="ticket-form-owner-org" /> - This organization only + {t('ticketFormsCard.orgOnlyOption')} {draft.ownerScope === 'organization' && ( patchDraft({ orgId: e.target.value })} data-testid="ticket-form-owner-org-select" > - Select an organization… + {t('ticketFormsCard.selectOrgPlaceholder')} {orgs.map((o) => ( {o.name} @@ -503,14 +507,14 @@ export default function TicketFormsCard() { both `ticket-form-orgs-error` nodes simultaneously. */} {draft.ownerScope === 'organization' && orgsLoadFailed && ( - Organizations failed to load, so an org-scoped form can't be created yet.{' '} + {t('ticketFormsCard.orgsLoadFailedNotice')}{' '} void load()} className="underline hover:text-foreground" data-testid="ticket-form-orgs-retry" > - Retry + {t('ticketFormsCard.retry')} )} @@ -521,7 +525,7 @@ export default function TicketFormsCard() { scope, or editing a partner-wide row where orgId === null). */} {draft.ownerScope === 'partner' && ( - Visibility + {t('ticketFormsCard.visibilityLegend')} patchDraft({ limitOrgs: e.target.checked })} data-testid="ticket-form-limit-orgs" /> - Limit to specific organizations + {t('ticketFormsCard.limitOrgsLabel')} {draft.limitOrgs && (orgsLoadFailed ? ( - Organizations failed to load, so the allowlist can't be edited yet.{' '} + {t('ticketFormsCard.allowlistOrgsLoadFailedNotice')}{' '} void load()} className="underline hover:text-foreground" data-testid="ticket-form-orgs-retry" > - Retry + {t('ticketFormsCard.retry')} ) : ( @@ -566,7 +570,7 @@ export default function TicketFormsCard() { - Name + {t('ticketFormsCard.nameLabel')} - Description (optional) + {t('ticketFormsCard.descriptionLabel')} {t('ticketFormsCard.optional')} - Category (optional) + {t('ticketFormsCard.categoryLabel')} {t('ticketFormsCard.optional')} patchDraft({ categoryId: e.target.value })} data-testid="ticket-form-category" > - No category + {t('ticketFormsCard.noCategoryOption')} {categories.map((c) => ( {c.name} @@ -612,7 +616,7 @@ export default function TicketFormsCard() { - Title template (optional) + {t('ticketFormsCard.titleTemplateLabel')} {t('ticketFormsCard.optional')} patchDraft({ titleTemplate: e.target.value })} data-testid="ticket-form-title-template" /> - Use {'{{field_key}}'} to insert responses. + + {t('ticketFormsCard.titleTemplateHint', { syntax: '{{field_key}}' })} + - Description intro (optional) + {t('ticketFormsCard.descriptionIntroLabel')} {t('ticketFormsCard.optional')} - Default priority (optional) + {t('ticketFormsCard.defaultPriorityLabel')} {t('ticketFormsCard.optional')} patchDraft({ defaultPriority: e.target.value })} data-testid="ticket-form-priority" > - No default + {t('ticketFormsCard.noDefaultPriorityOption')} {PRIORITIES.map((p) => ( - {p} + {t(/* i18n-dynamic */ `ticketFormsCard.priorityOptions.${p}`)} ))} @@ -667,7 +673,7 @@ export default function TicketFormsCard() { onChange={(e) => patchDraft({ showInPortal: e.target.checked })} data-testid="ticket-form-portal" /> - Show in portal + {t('ticketFormsCard.showInPortal')} patchDraft({ isActive: e.target.checked })} data-testid="ticket-form-active" /> - Active + {t('ticketFormsCard.active')} {/* Field editor */} - Fields + {t('ticketFormsCard.fieldsHeading')} - Add field + {t('ticketFormsCard.addField')} {draft.fields.length === 0 && ( - No fields yet. Add one to start collecting responses. + {t('ticketFormsCard.noFieldsYet')} )} {draft.fields.map((f, i) => ( @@ -704,29 +710,29 @@ export default function TicketFormsCard() { patchField(i, { label: e.target.value })} data-testid={`ticket-form-field-label-${i}`} /> patchField(i, { type: e.target.value as TicketFormFieldType })} data-testid={`ticket-form-field-type-${i}`} > - {TICKET_FORM_FIELD_TYPES.map((t) => ( - - {t} + {TICKET_FORM_FIELD_TYPES.map((fieldType) => ( + + {t(/* i18n-dynamic */ `ticketFormsCard.fieldTypeOptions.${fieldType}`)} ))} - key: {derivedKeys[i]} + {t('ticketFormsCard.keyLabel', { key: derivedKeys[i] })} @@ -737,7 +743,7 @@ export default function TicketFormsCard() { onChange={(e) => patchField(i, { required: e.target.checked })} data-testid={`ticket-form-field-required-${i}`} /> - Required + {t('ticketFormsCard.required')} ↑ @@ -755,7 +761,7 @@ export default function TicketFormsCard() { disabled={i === draft.fields.length - 1} className="rounded border px-1.5 py-0.5 disabled:opacity-40" data-testid={`ticket-form-field-down-${i}`} - aria-label="Move field down" + aria-label={t('ticketFormsCard.moveFieldDown')} > ↓ @@ -765,7 +771,7 @@ export default function TicketFormsCard() { className="rounded border px-1.5 py-0.5" data-testid={`ticket-form-field-expand-${i}`} > - {f.expanded ? 'Less' : 'More'} + {f.expanded ? t('ticketFormsCard.less') : t('ticketFormsCard.more')} - Remove + {t('ticketFormsCard.remove')} @@ -781,14 +787,14 @@ export default function TicketFormsCard() { patchField(i, { helpText: e.target.value })} data-testid={`ticket-form-field-help-${i}`} /> patchField(i, { placeholder: e.target.value })} data-testid={`ticket-form-field-placeholder-${i}`} @@ -797,7 +803,7 @@ export default function TicketFormsCard() { patchField(i, { optionsText: e.target.value })} data-testid={`ticket-form-field-options-${i}`} @@ -812,10 +818,10 @@ export default function TicketFormsCard() { {/* Right column: live preview using the SHARED renderer */} - Preview + {t('ticketFormsCard.previewHeading')} {previewFields.length === 0 ? ( - Add fields to see a preview. + {t('ticketFormsCard.previewEmpty')} ) : ( - {saving ? 'Saving' : 'Save'} + {saving ? t('ticketFormsCard.saving') : t('ticketFormsCard.save')} - Cancel + {t('ticketFormsCard.cancel')} @@ -861,23 +867,23 @@ export default function TicketFormsCard() { {loading ? ( - Loading. + {t('ticketFormsCard.loading')} ) : error ? ( - Failed to load intake forms.{' '} + {t('ticketFormsCard.loadFailed')}{' '} void load()} className="underline hover:text-foreground" data-testid="ticket-forms-retry" > - Retry + {t('ticketFormsCard.retry')} ) : forms.length === 0 ? ( - No intake forms yet. Create one to collect structured details on new tickets. + {t('ticketFormsCard.emptyState')} ) : ( @@ -894,33 +900,35 @@ export default function TicketFormsCard() { (form.visibleOrgIds == null ? ( - All orgs + {t('ticketFormsCard.allOrgsBadge')} ) : ( - {form.visibleOrgIds.length} org{form.visibleOrgIds.length === 1 ? '' : 's'} + {t('ticketFormsCard.orgCount', { count: form.visibleOrgIds.length })} ))} {form.showInPortal && ( - Portal + {t('ticketFormsCard.portalBadge')} )} {!form.isActive && ( - Inactive + {t('ticketFormsCard.inactiveBadge')} )} - {form.fields.length} field{form.fields.length === 1 ? '' : 's'} + {t('ticketFormsCard.fieldsCount', { count: form.fields.length })} {form.categoryId - ? ` · ${categories.find((c) => c.id === form.categoryId)?.name ?? 'Category'}` + ? t('ticketFormsCard.categorySuffix', { + category: categories.find((c) => c.id === form.categoryId)?.name ?? t('ticketFormsCard.categoryFallback') + }) : ''} @@ -931,7 +939,7 @@ export default function TicketFormsCard() { className="text-muted-foreground hover:text-foreground" data-testid={`ticket-form-edit-${form.id}`} > - Edit + {t('ticketFormsCard.edit')} - {confirmDeleteId === form.id ? 'Confirm delete' : 'Delete'} + {confirmDeleteId === form.id ? t('ticketFormsCard.confirmDelete') : t('ticketFormsCard.delete')} diff --git a/apps/web/src/components/tickets/CreateTicketPage.tsx b/apps/web/src/components/tickets/CreateTicketPage.tsx index c36024cebf..902d2de1aa 100644 --- a/apps/web/src/components/tickets/CreateTicketPage.tsx +++ b/apps/web/src/components/tickets/CreateTicketPage.tsx @@ -260,7 +260,7 @@ export default function CreateTicketPage() { )} {forms.length > 0 && ( - Start from a form (optional) + {t('createTicketPage.startFromForm')} - Blank ticket + {t('createTicketPage.blankTicketOption')} {forms.map((f) => ({f.name}))} {selectedForm?.description && {selectedForm.description}} diff --git a/apps/web/src/components/tickets/TicketFormFields.tsx b/apps/web/src/components/tickets/TicketFormFields.tsx index bcea7386cd..a31c2d0b63 100644 --- a/apps/web/src/components/tickets/TicketFormFields.tsx +++ b/apps/web/src/components/tickets/TicketFormFields.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import type { TicketFormField } from '@breeze/shared'; interface Props { @@ -13,6 +14,7 @@ interface Props { const inputCls = 'w-full rounded-md border bg-background px-2.5 py-1.5 text-sm'; export default function TicketFormFields({ fields, values, errors, onChange }: Props) { + const { t } = useTranslation('tickets'); return ( {fields.map((f) => { @@ -70,7 +72,7 @@ export default function TicketFormFields({ fields, values, errors, onChange }: P value={(values[f.key] as string) ?? ''} onChange={(e) => onChange(f.key, e.target.value)} > - Select… + {t('ticketFormFields.selectPlaceholder')} {(f.options ?? []).map((o) => ( {o} diff --git a/apps/web/src/locales/en/settings.json b/apps/web/src/locales/en/settings.json index c1b63bbe74..4b161d351c 100644 --- a/apps/web/src/locales/en/settings.json +++ b/apps/web/src/locales/en/settings.json @@ -2171,6 +2171,96 @@ "inboundEmail": "Inbound Email", "cannedResponses": "Canned responses" }, + "ticketFormsCard": { + "heading": "Intake forms", + "subheading": "Custom fields collected when a ticket is created. Share one form across all organizations or scope it to a single org.", + "newForm": "New form", + "scopeLegend": "Scope", + "allOrgsOption": "All organizations", + "partnerWideHint": "(partner-wide)", + "orgOnlyOption": "This organization only", + "selectOrgPlaceholder": "Select an organization…", + "orgsLoadFailedNotice": "Organizations failed to load, so an org-scoped form can't be created yet.", + "retry": "Retry", + "visibilityLegend": "Visibility", + "limitOrgsLabel": "Limit to specific organizations", + "allowlistOrgsLoadFailedNotice": "Organizations failed to load, so the allowlist can't be edited yet.", + "nameLabel": "Name", + "optional": "(optional)", + "descriptionLabel": "Description", + "categoryLabel": "Category", + "noCategoryOption": "No category", + "titleTemplateLabel": "Title template", + "titleTemplateHint": "Use {{syntax}} to insert responses.", + "descriptionIntroLabel": "Description intro", + "defaultPriorityLabel": "Default priority", + "noDefaultPriorityOption": "No default", + "priorityOptions": { + "low": "Low", + "normal": "Normal", + "high": "High", + "urgent": "Urgent" + }, + "showInPortal": "Show in portal", + "active": "Active", + "fieldsHeading": "Fields", + "addField": "Add field", + "noFieldsYet": "No fields yet. Add one to start collecting responses.", + "fieldLabelPlaceholder": "Field label", + "fieldTypeAriaLabel": "Field type", + "fieldTypeOptions": { + "text": "text", + "textarea": "textarea", + "select": "select", + "checkbox": "checkbox", + "date": "date", + "number": "number" + }, + "keyLabel": "key: {{key}}", + "required": "Required", + "moveFieldUp": "Move field up", + "moveFieldDown": "Move field down", + "more": "More", + "less": "Less", + "remove": "Remove", + "helpTextPlaceholder": "Help text (optional)", + "placeholderInputPlaceholder": "Placeholder (optional)", + "optionsPlaceholder": "One option per line", + "previewHeading": "Preview", + "previewEmpty": "Add fields to see a preview.", + "save": "Save", + "saving": "Saving", + "cancel": "Cancel", + "loading": "Loading.", + "loadFailed": "Failed to load intake forms.", + "emptyState": "No intake forms yet. Create one to collect structured details on new tickets.", + "allOrgsTooltip": "Partner-wide form — available to every organization", + "allOrgsBadge": "All orgs", + "limitedOrgsTooltip": "Partner-wide form limited to specific organizations", + "orgCount_one": "{{count}} org", + "orgCount_other": "{{count}} orgs", + "portalBadge": "Portal", + "inactiveBadge": "Inactive", + "fieldsCount_one": "{{count}} field", + "fieldsCount_other": "{{count}} fields", + "categorySuffix": " · {{category}}", + "categoryFallback": "Category", + "edit": "Edit", + "delete": "Delete", + "confirmDelete": "Confirm delete", + "validation": { + "nameRequired": "Name is required.", + "selectOrgRequired": "Select an organization for an organization-scoped form.", + "limitOrgsRequired": "Select at least one organization or uncheck \"Limit to specific organizations\".", + "fieldPrefix": "Field {{path}}: ", + "unknownFieldKey": "Title template references unknown field \"{{tokenSyntax}}\". Available keys: {{keys}}.", + "noKeysAvailable": "none" + }, + "formSaved": "Form saved", + "saveFailed": "Failed to save form. Retry.", + "formDeleted": "Form deleted", + "deleteFailed": "Failed to delete form." + }, "orgSecuritySettings": { "title": "Security", "description": "Define password, MFA, and session requirements for your organization.", diff --git a/apps/web/src/locales/en/tickets.json b/apps/web/src/locales/en/tickets.json index d0c82d472e..571e31b27e 100644 --- a/apps/web/src/locales/en/tickets.json +++ b/apps/web/src/locales/en/tickets.json @@ -250,6 +250,8 @@ "createTicket": "Create ticket", "creationFailed": "Ticket creation failed. Retry.", "createdToast": "Ticket {{number}} created", + "startFromForm": "Start from a form (optional)", + "blankTicketOption": "Blank ticket", "priorityOptions": { "low": "Low", "normal": "Normal", @@ -257,6 +259,9 @@ "urgent": "Urgent" } }, + "ticketFormFields": { + "selectPlaceholder": "Select…" + }, "ticketsPage": { "title": "Tickets", "createTicket": "Create ticket", diff --git a/apps/web/src/locales/pt-BR/settings.json b/apps/web/src/locales/pt-BR/settings.json index 71efbdda2d..cccd69b738 100644 --- a/apps/web/src/locales/pt-BR/settings.json +++ b/apps/web/src/locales/pt-BR/settings.json @@ -2171,6 +2171,96 @@ "inboundEmail": "E-mail de entrada", "cannedResponses": "Respostas prontas" }, + "ticketFormsCard": { + "heading": "Formulários de abertura", + "subheading": "Campos personalizados coletados na abertura de um chamado. Compartilhe um formulário entre todas as organizações ou restrinja-o a uma única organização.", + "newForm": "Novo formulário", + "scopeLegend": "Escopo", + "allOrgsOption": "Todas as organizações", + "partnerWideHint": "(em todo o parceiro)", + "orgOnlyOption": "Somente esta organização", + "selectOrgPlaceholder": "Selecionar uma organização…", + "orgsLoadFailedNotice": "Falha ao carregar as organizações, portanto um formulário restrito a uma organização ainda não pode ser criado.", + "retry": "Tentar novamente", + "visibilityLegend": "Visibilidade", + "limitOrgsLabel": "Restringir a organizações específicas", + "allowlistOrgsLoadFailedNotice": "Falha ao carregar as organizações, portanto a lista de permissões ainda não pode ser editada.", + "nameLabel": "Nome", + "optional": "(opcional)", + "descriptionLabel": "Descrição", + "categoryLabel": "Categoria", + "noCategoryOption": "Sem categoria", + "titleTemplateLabel": "Modelo de título", + "titleTemplateHint": "Use {{syntax}} para inserir respostas.", + "descriptionIntroLabel": "Introdução da descrição", + "defaultPriorityLabel": "Prioridade padrão", + "noDefaultPriorityOption": "Sem padrão", + "priorityOptions": { + "low": "Baixa", + "normal": "Normal", + "high": "Alta", + "urgent": "Urgente" + }, + "showInPortal": "Exibir no portal", + "active": "Ativo", + "fieldsHeading": "Campos", + "addField": "Adicionar campo", + "noFieldsYet": "Ainda não há campos. Adicione um para começar a coletar respostas.", + "fieldLabelPlaceholder": "Rótulo do campo", + "fieldTypeAriaLabel": "Tipo do campo", + "fieldTypeOptions": { + "text": "texto", + "textarea": "área de texto", + "select": "seleção", + "checkbox": "caixa de seleção", + "date": "data", + "number": "número" + }, + "keyLabel": "chave: {{key}}", + "required": "Obrigatório", + "moveFieldUp": "Mover campo para cima", + "moveFieldDown": "Mover campo para baixo", + "more": "Mais", + "less": "Menos", + "remove": "Remover", + "helpTextPlaceholder": "Texto de ajuda (opcional)", + "placeholderInputPlaceholder": "Espaço reservado (opcional)", + "optionsPlaceholder": "Uma opção por linha", + "previewHeading": "Pré-visualização", + "previewEmpty": "Adicione campos para ver uma pré-visualização.", + "save": "Salvar", + "saving": "Salvando", + "cancel": "Cancelar", + "loading": "Carregando.", + "loadFailed": "Falha ao carregar os formulários de abertura.", + "emptyState": "Ainda não há formulários de abertura. Crie um para coletar detalhes estruturados nos novos chamados.", + "allOrgsTooltip": "Formulário em todo o parceiro — disponível para todas as organizações", + "allOrgsBadge": "Todas as orgs", + "limitedOrgsTooltip": "Formulário em todo o parceiro restrito a organizações específicas", + "orgCount_one": "{{count}} org", + "orgCount_other": "{{count}} orgs", + "portalBadge": "Portal", + "inactiveBadge": "Inativo", + "fieldsCount_one": "{{count}} campo", + "fieldsCount_other": "{{count}} campos", + "categorySuffix": " · {{category}}", + "categoryFallback": "Categoria", + "edit": "Editar", + "delete": "Excluir", + "confirmDelete": "Confirmar exclusão", + "validation": { + "nameRequired": "O nome é obrigatório.", + "selectOrgRequired": "Selecione uma organização para um formulário restrito a uma organização.", + "limitOrgsRequired": "Selecione pelo menos uma organização ou desmarque \"Restringir a organizações específicas\".", + "fieldPrefix": "Campo {{path}}: ", + "unknownFieldKey": "O modelo de título faz referência a um campo desconhecido \"{{tokenSyntax}}\". Chaves disponíveis: {{keys}}.", + "noKeysAvailable": "nenhuma" + }, + "formSaved": "Formulário salvo", + "saveFailed": "Falha ao salvar o formulário. Tente novamente.", + "formDeleted": "Formulário excluído", + "deleteFailed": "Falha ao excluir o formulário." + }, "orgSecuritySettings": { "title": "Segurança", "description": "Defina requisitos de senha, MFA e sessão para sua organização.", diff --git a/apps/web/src/locales/pt-BR/tickets.json b/apps/web/src/locales/pt-BR/tickets.json index 48d15204e4..edda764803 100644 --- a/apps/web/src/locales/pt-BR/tickets.json +++ b/apps/web/src/locales/pt-BR/tickets.json @@ -250,6 +250,8 @@ "createTicket": "Criar chamado", "creationFailed": "Falha ao criar chamado. Tente novamente.", "createdToast": "Chamado {{number}} criado", + "startFromForm": "Começar a partir de um formulário (opcional)", + "blankTicketOption": "Chamado em branco", "priorityOptions": { "low": "Baixa", "normal": "Normal", @@ -257,6 +259,9 @@ "urgent": "Urgente" } }, + "ticketFormFields": { + "selectPlaceholder": "Selecionar…" + }, "ticketsPage": { "title": "Chamados", "createTicket": "Criar chamado", diff --git a/docker/Dockerfile.api.dev b/docker/Dockerfile.api.dev index 06d58cff5d..3f77fbc6cf 100644 --- a/docker/Dockerfile.api.dev +++ b/docker/Dockerfile.api.dev @@ -14,6 +14,7 @@ COPY turbo.json tsconfig.json ./ # Copy package.json files for dependency installation COPY apps/api/package.json ./apps/api/ COPY packages/shared/package.json ./packages/shared/ +COPY packages/extension-api/package.json ./packages/extension-api/ # Install all dependencies (including devDependencies for development) RUN pnpm install @@ -21,6 +22,7 @@ RUN pnpm install # Copy source code (will be overridden by volume mounts in dev) COPY apps/api ./apps/api COPY packages/shared ./packages/shared +COPY packages/extension-api ./packages/extension-api # Set working directory to api WORKDIR /app/apps/api diff --git a/e2e-tests/pages/SettingsAppearancePage.ts b/e2e-tests/pages/SettingsAppearancePage.ts new file mode 100644 index 0000000000..b43767d55b --- /dev/null +++ b/e2e-tests/pages/SettingsAppearancePage.ts @@ -0,0 +1,29 @@ +import { BasePage } from './BasePage'; + +/** + * /settings/profile — the "Appearance" (Theming) card, including the + * Language fieldset. Also exposes the org-switcher trigger in the Header + * island (pre-existing testid, unrelated component) so specs can assert + * that a locale switch propagates to islands other than the settings page + * itself, without a full reload. + */ +export class SettingsAppearancePage extends BasePage { + url = '/settings/profile'; + + languageLegend = () => this.page.getByTestId('theming-language-legend'); + localeOptionEn = () => this.page.getByTestId('locale-option-en'); + localeOptionPtBR = () => this.page.getByTestId('locale-option-pt-BR'); + appearanceSuccess = () => this.page.getByTestId('theming-appearance-success'); + appearanceError = () => this.page.getByTestId('theming-appearance-error'); + + // Header island (apps/web/src/components/layout/OrgSwitcher.tsx) — mounted + // as a sibling Astro island (`` + // in DashboardLayout.astro), independent from the `` island the Language fieldset above lives in. + orgSwitcherTrigger = () => this.page.getByTestId('org-switcher-trigger'); + + async goto() { + await this.page.goto(this.url); + await this.languageLegend().waitFor(); + } +} diff --git a/e2e-tests/tests/i18n-language-switch.spec.ts b/e2e-tests/tests/i18n-language-switch.spec.ts new file mode 100644 index 0000000000..be26c43b9d --- /dev/null +++ b/e2e-tests/tests/i18n-language-switch.spec.ts @@ -0,0 +1,84 @@ +import { test, expect } from '../fixtures'; +import { SettingsAppearancePage } from '../pages/SettingsAppearancePage'; + +// Exact strings pulled from apps/web/src/locales/{en,pt-BR}/{common,settings}.json. +// settings.json: language.title, themingSettings.themingPreferencesSaved, +// language.englishDescription, language.ptBRDescription. +// common.json: layout.org.selectTitle (org-switcher-trigger `title` attribute). +const EN = { + languageTitle: 'Language', + saved: 'Theming preferences saved.', + englishDescription: 'English (United States)', + ptBRDescription: 'Portuguese (Brazil)', + orgSwitcherTitle: 'Select Organization (Cmd+O)', +}; + +const PT_BR = { + languageTitle: 'Idioma', + saved: 'Preferências de tema salvas.', + englishDescription: 'Inglês (Estados Unidos)', + ptBRDescription: 'Português (Brasil)', + orgSwitcherTitle: 'Selecionar organização (Cmd+O)', +}; + +// The `users.preferences.locale` change made below is real server-side state +// shared by every context that logs in as E2E_ADMIN_EMAIL, so this spec must +// run to completion serially and always leave the account in `en` — see the +// final assertions block. +test.describe.configure({ mode: 'serial' }); + +test.describe('Cross-island language switching', () => { + test('pt-BR selection propagates to independent islands, survives reload, and reverts to en', async ({ authedPage }) => { + test.setTimeout(60_000); + const settings = new SettingsAppearancePage(authedPage); + await settings.goto(); + + // Force a known baseline regardless of locale state left over from a + // prior (possibly interrupted) run of this spec. + await settings.localeOptionEn().click(); + await expect(settings.appearanceSuccess()).toHaveText(EN.saved); + await expect(settings.languageLegend()).toHaveText(EN.languageTitle); + await expect(settings.orgSwitcherTrigger()).toHaveAttribute('title', EN.orgSwitcherTitle); + + // --- Switch to pt-BR and save ------------------------------------------------ + await settings.localeOptionPtBR().click(); + await expect(settings.appearanceSuccess()).toHaveText(PT_BR.saved); + + // (a) Island 1 — the settings page content itself (the ProfilePage / + // ThemingSettings Astro island) re-renders in Portuguese with no reload. + await expect(settings.languageLegend()).toHaveText(PT_BR.languageTitle); + await expect(settings.localeOptionEn()).toContainText(PT_BR.englishDescription); + await expect(settings.localeOptionPtBR()).toContainText(PT_BR.ptBRDescription); + + // Island 2 — the Header island (`` + // in DashboardLayout.astro), a completely independent Astro island from + // the settings page content above, also updates with no reload. + await expect(settings.orgSwitcherTrigger()).toHaveAttribute('title', PT_BR.orgSwitcherTitle); + + // --- (b) Full reload: restoration is deferred until Astro hydration -------- + // finishes (scheduleStoredLocaleAfterHydration), so poll rather than + // asserting immediately after reload. + await authedPage.reload(); + await settings.languageLegend().waitFor(); + await settings.orgSwitcherTrigger().waitFor(); + + // localStorage-derived state (aria-pressed) is available at first mount. + await expect(settings.localeOptionPtBR()).toHaveAttribute('aria-pressed', 'true'); + + // i18next-derived text/attributes only flip once hydration completes. + await expect + .poll(async () => settings.languageLegend().textContent(), { timeout: 15_000 }) + .toBe(PT_BR.languageTitle); + await expect + .poll(async () => settings.orgSwitcherTrigger().getAttribute('title'), { timeout: 15_000 }) + .toBe(PT_BR.orgSwitcherTitle); + + // --- (c) Switch back to en and verify both islands again -------------------- + await settings.localeOptionEn().click(); + await expect(settings.appearanceSuccess()).toHaveText(EN.saved); + await expect(settings.languageLegend()).toHaveText(EN.languageTitle); + await expect(settings.localeOptionEn()).toContainText(EN.englishDescription); + await expect(settings.localeOptionPtBR()).toContainText(EN.ptBRDescription); + await expect(settings.orgSwitcherTrigger()).toHaveAttribute('title', EN.orgSwitcherTitle); + }); +});
- Custom fields collected when a ticket is created. Share one form across all organizations or scope it to a - single org. -
{t('ticketFormsCard.subheading')}
- Organizations failed to load, so an org-scoped form can't be created yet.{' '} + {t('ticketFormsCard.orgsLoadFailedNotice')}{' '} void load()} className="underline hover:text-foreground" data-testid="ticket-form-orgs-retry" > - Retry + {t('ticketFormsCard.retry')}
- Organizations failed to load, so the allowlist can't be edited yet.{' '} + {t('ticketFormsCard.allowlistOrgsLoadFailedNotice')}{' '} void load()} className="underline hover:text-foreground" data-testid="ticket-form-orgs-retry" > - Retry + {t('ticketFormsCard.retry')}
Use {'{{field_key}}'} to insert responses.
+ {t('ticketFormsCard.titleTemplateHint', { syntax: '{{field_key}}' })} +
- No fields yet. Add one to start collecting responses. + {t('ticketFormsCard.noFieldsYet')}
- key: {derivedKeys[i]} + {t('ticketFormsCard.keyLabel', { key: derivedKeys[i] })}
Add fields to see a preview.
{t('ticketFormsCard.previewEmpty')}
- Loading. + {t('ticketFormsCard.loading')}
- Failed to load intake forms.{' '} + {t('ticketFormsCard.loadFailed')}{' '} void load()} className="underline hover:text-foreground" data-testid="ticket-forms-retry" > - Retry + {t('ticketFormsCard.retry')}
- No intake forms yet. Create one to collect structured details on new tickets. + {t('ticketFormsCard.emptyState')}
{selectedForm.description}